Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion client/src/clients/api/vacations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export class VacationsApi extends ApiClientBase {
return userBalance
}

async updateUsersBalance(data: { user_ids: number[], balance: Api.BalanceVacation }) {
async updateUsersBalance(data: { user_ids: number[], balance: Api.BalanceVacation, year?: number }) {
ApiClientBase.assertUser()
return await this.unwrap(() => this.$http.put<Api.Returns.Balances>(this.getUrl(`/balance/users`), data), {
transform: (d) => d.results
Expand Down
8 changes: 4 additions & 4 deletions client/src/components/dashboard/AuditUserBalance.vue
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
<div class="d-flex justify-space-between align-end mb-4">
<div>
<h3 class="text-h6 font-weight-bold">Audit Results for {{ reportData.user_full_name
}}</h3>
}}</h3>
<div class="text-body-2 text-medium-emphasis">Period: {{ reportData.year }} |
Reason: <span class="text-capitalize">{{ reportData.reason }}</span></div>
</div>
Expand Down Expand Up @@ -124,9 +124,9 @@
<span v-else>{{ vac.recalc }}</span>
</td>
<td>
<v-chip size="x-small" :color="vac.is_old_balance ? 'secondary' : 'primary'"
variant="tonal">
{{ vac.is_old_balance ? 'Transferred' : 'Main' }}
<v-chip size="x-small"
:color="vac.affects_year_balance ? 'primary' : 'secondary'" variant="tonal">
{{ vac.balance_source || (vac.is_old_balance ? 'Transferred' : 'Main') }}
</v-chip>
</td>
</tr>
Expand Down
47 changes: 41 additions & 6 deletions client/src/components/dashboard/SetUserVacationBalance.vue
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
<!-- Tooltip Content -->
<template #default>
<v-card class="pa-2">
<h3 class="pa-2">Vacation Balances</h3>
<h3 class="pa-2">Vacation Balances ({{ selectedYear }})</h3>
<v-card v-if="slotProps.item">
<div class="reason mb-2" v-for="(type, index) in defaultBalanceValues" :key="index">
<div class="d-flex">
Expand Down Expand Up @@ -67,11 +67,18 @@
</template>
</v-autocomplete>
</v-col>
<v-col cols="12">
<!-- Year Selection -->
<v-select v-model="selectedYear" :items="availableYears" label="Year" :rules="requiredRules"
:disabled="isLoading" hint="Select the year for which you want to view/update the balance" persistent-hint>
</v-select>
</v-col>
<v-col cols="12">
<v-alert type="info">
The form below allows you to set vacation balances for users. You can select multiple users and then update
the
balance as the base balance for all selected users.
The form below allows you to set vacation balances for users. You can select the year and multiple users,
then
update
the balance as the base balance for all selected users for the specified year.
</v-alert>

<v-row class="mt-1">
Expand Down Expand Up @@ -116,18 +123,24 @@
const user = ApiClientBase.user
const officeUsers = ref<any[]>([])
const selectedUsers = ref<Api.User[]>([])
const page = ref(0)

Check warning on line 126 in client/src/components/dashboard/SetUserVacationBalance.vue

View workflow job for this annotation

GitHub Actions / Run linters (18.x)

'page' is assigned a value but never used
const count = ref(0)

Check warning on line 127 in client/src/components/dashboard/SetUserVacationBalance.vue

View workflow job for this annotation

GitHub Actions / Run linters (18.x)

'count' is assigned a value but never used
const balances: Ref<{ [userId: number]: Api.BalanceVacation }> = ref({})
const isLoading = ref(false)

// Year selection - dynamically generate last 5 years
const currentYear = new Date().getFullYear()
const availableYears = Array.from({ length: 5 }, (_, i) => currentYear - i)
const selectedYear = ref(currentYear)

// Computed Properties
const selectedSomeUsers = computed(() => selectedUsers.value.length > 0)
const selectedAllUsers = computed(() => selectedUsers.value.length === officeUsers.value.length)
const balanceState = useAsyncState(async () => {
const balance_result = await $api.vacations.updateUsersBalance(
{
user_ids: selectedUsers.value.map(user => user.id),
year: selectedYear.value,
balance: {
total_days: {
annual: +totalBalanceFields.value.find((i) => i.key === 'annual')!.value,
Expand All @@ -149,6 +162,8 @@
}
)

// Clear old balances and update with new ones for the selected year
balances.value = {}
for (let i = 0; i < balance_result!.length; i++) {
balances.value[balance_result![i].user!.id] = balance_result![i]
}
Expand All @@ -169,9 +184,9 @@
]


// Fetch and Cache User Balance
// Fetch and Cache User Balance for selected year
const fetchUserBalance = async (userId: number) => {
const result = await $api.vacations.getUserBalance(userId)
const result = await $api.vacations.getUserBalance(userId, { year: selectedYear.value })
Reflect.set(balances.value, userId, result)
}

Expand Down Expand Up @@ -290,6 +305,24 @@
}
}, { deep: true })

// Watch for year changes to refetch balances
watch(selectedYear, async () => {
// Clear cached balances when year changes
balances.value = {}

// Refetch balances for all selected users with new year
for (const user of selectedUsers.value) {
await fetchUserBalance(user.id)
}

// Update form values
if (selectedUsers.value.length == 1) {
updateBalanceFormValues({ user: selectedUsers.value[0] })
} else {
updateBalanceFormValues({ reset: true })
}
})

const updateBalanceFormValues = (options?: { reset?: boolean, user?: Api.User }) => {
if (options && options.reset) {
for (let i = 0; i < totalBalanceFields.value.length; i++) {
Expand Down Expand Up @@ -341,6 +374,8 @@
Reflect,
isLoading,
submitBalance,
availableYears,
selectedYear,

fetchUserBalance,
getUserById,
Expand Down
2 changes: 2 additions & 0 deletions server/cshr/serializers/vacations.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
FloatField,
ReadOnlyField,
CharField,
IntegerField,
)
from cshr.models.vacations import (
UserVacationBalance,
Expand Down Expand Up @@ -145,6 +146,7 @@ class BalanceObjectSerializer(Serializer):
class UpdateVacationBalanceSerializer(Serializer):
user_ids = ListField()
balance = BalanceObjectSerializer()
year = IntegerField(required=False)


class UserVacationBalanceSerializer(ModelSerializer):
Expand Down
26 changes: 24 additions & 2 deletions server/cshr/services/balance.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from datetime import datetime
from typing import Any, Dict, List, Optional
from django.db.models import Q
from cshr.models.users import User, USER_TYPE
from cshr.models.vacations import UserVacationBalance, Vacation
from cshr.models.requests import STATUS_CHOICES
Expand Down Expand Up @@ -31,11 +32,16 @@ def audit_user_balance(user: User, year: int, reason: str) -> Dict[str, Any]:
total_quota = getattr(balance_obj.total_days, reason, 0)
current_db_remaining = getattr(balance_obj.remaining_days, reason, 0)

# Query vacations that affect this year's balance:
# 1. Vacations from this year that are NOT from old balance (consumed from current year's balance)
# 2. Vacations from the next year that ARE from old balance (consumed from this year's transferred balance)
vacations = Vacation.objects.filter(
applying_user=user,
from_date__year=year,
status__in=[STATUS_CHOICES.APPROVED, STATUS_CHOICES.CANCEL_REJECTED],
reason=reason,
).filter(
Q(from_date__year=year, is_old_balance=False) | # Regular vacations from this year
Q(from_date__year=year + 1, is_old_balance=True) # Next year vacations using this year's balance
).order_by("from_date")

reports = []
Expand All @@ -52,9 +58,23 @@ def audit_user_balance(user: User, year: int, reason: str) -> Dict[str, Any]:
):
days = 1.0

if not vac.is_old_balance:
# Count days that affect this year's remaining balance:
# - Current year vacations (not from old balance) → deducted from this year's remaining
# - Next year vacations (from old balance) → deducted from this year's remaining (via transferred)
is_current_year_main = vac.from_date.year == year and not vac.is_old_balance
is_next_year_transferred = vac.from_date.year == year + 1 and vac.is_old_balance

if is_current_year_main or is_next_year_transferred:
total_recalc_current += days

# Determine the balance source description
if is_next_year_transferred:
balance_source = f"Transferred (from {year})"
elif vac.is_old_balance:
balance_source = f"Transferred (from {year - 1})"
else:
balance_source = "Main"

reports.append(
{
"id": vac.id,
Expand All @@ -63,6 +83,8 @@ def audit_user_balance(user: User, year: int, reason: str) -> Dict[str, Any]:
"stored": vac.actual_days,
"recalc": days,
"is_old_balance": vac.is_old_balance,
"balance_source": balance_source,
"affects_year_balance": is_current_year_main or is_next_year_transferred,
}
)

Expand Down
25 changes: 24 additions & 1 deletion server/cshr/views/vacations.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,12 +275,35 @@ def get(self, request: Request, user_id: str) -> Response:
Use this endpoint to get user balance
### Parameters
- `user_id`: The id of the user
- `year`: Optional year parameter (defaults to current year)
"""

user = get_user_by_id(user_id)
if user is None:
return CustomResponse.not_found(message="User not found", status_code=404)

year = request.query_params.get("year")
if year:
try:
year = int(year)
except ValueError:
return CustomResponse.bad_request(message="Invalid year parameter")

# Get or create balance for specific year
user_balance, _ = UserVacationBalance.objects.get_or_create(
user=user,
year=year,
defaults={
"total_days": VacationBalanceModel.objects.create(),
"remaining_days": VacationBalanceModel.objects.create(compensation=0, paternity=0, maternity=0),
"transferred_days": None,
},
)
return CustomResponse.success(
message="Success found balance.",
data=self.serializer_class(user_balance).data,
)

balance_calculator = VacationBalanceCalculator(
applying_user=user, vacation=None
)
Expand All @@ -305,7 +328,7 @@ def put(self, request: Request):

user_ids = serializer.validated_data.get("user_ids", [])
balance = serializer.validated_data.get("balance", {})
current_year = datetime.now().year
current_year = serializer.validated_data.get("year", datetime.now().year)

users = self.get_users_by_ids(user_ids)
if not isinstance(users, list): # If not a list, it's a response (error case)
Expand Down
Loading