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
32 changes: 26 additions & 6 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 @@ -136,14 +136,23 @@

<v-alert v-if="reportData.discrepancy" type="info" variant="tonal" class="mt-6 border-red"
color="error" icon="mdi-alert-circle">
<div class="font-weight-bold">Correction Required</div>
<div class="font-weight-bold">Balance Discrepancy Detected</div>
The database balance is out of sync. This action will synchronize the records and notify
the user.
</v-alert>

<v-alert v-if="reportData.transferred_days_orphaned" type="warning" variant="tonal" class="mt-4"
icon="mdi-link-variant-off">
<div class="font-weight-bold">Orphaned Transferred Balance Link</div>
The {{ reportData.year + 1 }} balance is pointing to an outdated transferred days record
(showing {{ reportData.next_year_transferred_value }} instead of {{
reportData.expected_remaining }}).
This will be fixed when you click the synchronize button.
</v-alert>

<div class="d-flex justify-end mt-4">
<v-btn v-if="reportData.discrepancy" color="error" @click="showFixConfirm = true"
:loading="isFixing" class="text-none font-weight-bold px-8">
<v-btn v-if="reportData.discrepancy || reportData.transferred_days_orphaned" color="error"
@click="showFixConfirm = true" :loading="isFixing" class="text-none font-weight-bold px-8">
Synchronize User Balance
</v-btn>
</div>
Expand Down Expand Up @@ -251,6 +260,9 @@ export default {
} else {
addLog('Calculations match database records.', 'success')
}
if (result.transferred_days_orphaned) {
addLog(`ALERT: ${selectedYear.value + 1} transferred balance is incorrectly linked (shows ${result.next_year_transferred_value} instead of ${result.expected_remaining}).`, 'warn')
}
} catch (err: any) {
const errorMsg = err.response?.data?.message || err.message || 'Unknown error'
addLog(`Error generating report: ${errorMsg}`, 'error')
Expand All @@ -271,8 +283,16 @@ export default {
reason: selectedReason.value
})

addLog(`SUCCESS: Balance updated to ${result.new_remaining}.`, 'success')
addLog(`Notification sent to user. Process complete.`, 'info')
if (result.balance_fixed) {
addLog(`Balance updated to ${result.new_remaining}.`, 'success')
}
if (result.transferred_days_fixed) {
addLog(`Fixed orphaned transferred balance link for ${selectedYear.value + 1}.`, 'success')
}
if (result.balance_fixed) {
addLog(`Notification sent to user.`, 'info')
}
addLog(`Process complete.`, 'success')

// Refresh report
await generateReport()
Expand Down
50 changes: 45 additions & 5 deletions client/src/views/UsersView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,19 @@
You can change the selected office to discover the team in other offices.
</v-alert>
</v-col>
<v-col cols="6" sm="6" md="6" class="pa-1">
<v-col cols="12" sm="4" md="4" class="pa-1">
<v-autocomplete clearable v-model="selectedOffice" :items="offices" label="Office" return-object
item-title="country" @click:clear="clearFilter('office')" @update:model-value="applyFilters" />
</v-col>
<v-col cols="6" sm="6" md="6" class="pa-1">
<v-col cols="12" sm="4" md="4" class="pa-1">
<v-autocomplete clearable v-model="selectedTeam" :items="teams" label="Team" return-object item-title="name"
item-value="id" @click:clear="clearFilter('team')" @update:model-value="applyFilters" />
</v-col>
<v-col cols="12" sm="4" md="4" class="pa-1">
<v-autocomplete clearable v-model="selectedUser" :items="dropdownUsers" label="User" return-object
item-title="full_name" item-value="id" @click:clear="clearFilter('user')" @update:model-value="applyFilters"
:loading="isDropdownLoading" />
</v-col>
</v-row>

<template v-if="isLoading">
Expand Down Expand Up @@ -51,6 +56,7 @@ import { useRoute, useRouter } from "vue-router";
import { $api } from "@/clients";
import UserCard from "@/components/userCard.vue";
import type { Api } from "@/types";
import { debounce } from "lodash";

export default {
name: "UsersView",
Expand All @@ -61,8 +67,13 @@ export default {
const isLoading = ref(true);
const offices = ref<Api.LocationType[]>([]);
const users = ref<Api.User[]>([]);
const dropdownUsers = ref<Api.User[]>([]);
const isDropdownLoading = ref(false);

const selectedOffice = ref<Api.LocationType | null>(null);
const selectedTeam = ref<{ name: string, id: number } | null>(null);
const selectedUser = ref<Api.User | null>(null);

const officePage = ref(1);
const usersPage = ref(1);
const officeCount = ref(0);
Expand All @@ -89,32 +100,53 @@ export default {
const res = await $api.users.list({
location_id: selectedOffice?.value?.id || "",
team_name: selectedTeam?.value?.name || "",
user_full_name: selectedUser?.value?.full_name || "",
page: usersPage.value,
});
usersCount.value = Math.ceil(res!.count / 12);
users.value = res!.results;
isLoading.value = false;
};

const clearFilter = (filter: "office" | "team") => {
const fetchDropdownUsers = async () => {
isDropdownLoading.value = true;
const res = await $api.users.active.list();
dropdownUsers.value = res || [];
isDropdownLoading.value = false;
};

const clearFilter = (filter: "office" | "team" | "user") => {
if (filter === "office") selectedOffice.value = null;
if (filter === "team") selectedTeam.value = null;
if (filter === "user") selectedUser.value = null;
applyFilters();
};

const applyFilters = () => {
console.log("selectedUser.value?.full_name: ", selectedUser.value?.full_name)
router.push({
path: "/users",
query: {
location_id: selectedOffice.value?.id || "",
team_name: selectedTeam.value?.name || "",
user_name: selectedUser.value?.full_name || "",
},
});
};

// Debounced search to avoid too many API calls
const performSearch = debounce(() => {
usersPage.value = 1;
applyFilters();
}, 300);

const onSearchChange = () => {
performSearch();
};

const initialize = async () => {
isLoading.value = true;
await Promise.all([fetchOffices(), fetchUsers()]);
await Promise.all([fetchOffices(), fetchUsers(), fetchDropdownUsers()]);
if (route.query.location_id) {
selectedOffice.value = offices.value.find(
(office) => office.id === Number(route.query.location_id)
Expand All @@ -123,12 +155,16 @@ export default {
if (route.query.team_name) {
selectedTeam.value = teams.find((team) => team.name === route.query.team_name) as { name: string, id: number };
}
if (route.query.user_name) {
selectedUser.value = dropdownUsers.value.find((user) => user.full_name === route.query.user_name) as Api.User;
}

isLoading.value = false;
};

onMounted(initialize);

watch([selectedOffice, selectedTeam], () => {
watch([selectedOffice, selectedTeam, selectedUser], () => {
// Reset page to 1 whenever filters change
usersPage.value = 1;
fetchUsers();
Expand All @@ -142,14 +178,18 @@ export default {
return {
offices,
users,
dropdownUsers,
teams,
selectedOffice,
selectedTeam,
selectedUser,
usersPage,
usersCount,
isLoading,
clearFilter,
applyFilters,
onSearchChange,
isDropdownLoading,
};
},
};
Expand Down
30 changes: 24 additions & 6 deletions server/cshr/serializers/vacations.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,10 +188,28 @@ def get_transferred_days(
self, obj: UserVacationBalance
) -> VacationBalanceSerializer:
"""
this function serialize transferred days
Serialize transferred days by directly fetching the previous year's remaining balance.
This is more reliable than using the transferred_days relationship which can become orphaned.
Returns None if the balance is locked (not available for use).
"""
return (
None
if not obj.transferred_days
else VacationBalanceSerializer(obj.transferred_days).data
)
# Always fetch the previous year's remaining_days directly for accuracy
from cshr.models.vacations import UserVacationBalance as UVB

previous_year_balance = UVB.objects.filter(
user=obj.user, year=obj.year - 1
).select_related("remaining_days").first()

if previous_year_balance and previous_year_balance.remaining_days:
# If the balance is locked, don't return it (user can't use old balance)
if previous_year_balance.remaining_days.is_locked:
return None
return VacationBalanceSerializer(previous_year_balance.remaining_days).data

# Fallback to the stored transferred_days if no previous year exists
# Also check if it's locked
if obj.transferred_days:
if obj.transferred_days.is_locked:
return None
return VacationBalanceSerializer(obj.transferred_days).data

return None
38 changes: 38 additions & 0 deletions server/cshr/services/balance.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,14 @@ def audit_user_balance(user: User, year: int, reason: str) -> Dict[str, Any]:

expected_remaining = total_quota - total_recalc_current

# Check if next year's transferred_days is correctly linked
next_year_balance = UserVacationBalance.objects.filter(user=user, year=year + 1).first()
transferred_days_orphaned = False
if next_year_balance and next_year_balance.transferred_days:
# Check if it's pointing to the correct object (this year's remaining_days)
if next_year_balance.transferred_days.id != balance_obj.remaining_days.id:
transferred_days_orphaned = True

return {
"user_full_name": user.full_name,
"year": year,
Expand All @@ -99,14 +107,37 @@ def audit_user_balance(user: User, year: int, reason: str) -> Dict[str, Any]:
"expected_remaining": expected_remaining,
"vacations": reports,
"discrepancy": expected_remaining != current_db_remaining,
"transferred_days_orphaned": transferred_days_orphaned,
"next_year_transferred_value": getattr(next_year_balance.transferred_days, reason, None) if (next_year_balance and next_year_balance.transferred_days) else None,
}

@staticmethod
def fix_transferred_days_link(user: User, year: int) -> bool:
"""
Fixes the transferred_days relationship for the next year's balance.
Returns True if a fix was applied.
"""
current_balance = UserVacationBalance.objects.filter(user=user, year=year).first()
next_year_balance = UserVacationBalance.objects.filter(user=user, year=year + 1).first()

if not current_balance or not next_year_balance:
return False

if next_year_balance.transferred_days and next_year_balance.transferred_days.id != current_balance.remaining_days.id:
# Fix the orphaned reference
next_year_balance.transferred_days = current_balance.remaining_days
next_year_balance.save()
return True

return False

@staticmethod
def fix_user_balance(
user: User, year: int, reason: str, admin_sender: Optional[User] = None
) -> Dict[str, Any]:
"""
Recalculates and fixes the user's balance. Sends a notification if fixed.
Also fixes orphaned transferred_days references.
"""
audit_result = BalanceService.audit_user_balance(user, year, reason)
if not audit_result:
Expand Down Expand Up @@ -147,10 +178,17 @@ def fix_user_balance(
)
notification_service.send()

# Always try to fix orphaned transferred_days link (independent of balance discrepancy)
transferred_days_fixed = False
if audit_result.get("transferred_days_orphaned"):
transferred_days_fixed = BalanceService.fix_transferred_days_link(user, year)

return {
"new_remaining": audit_result["expected_remaining"],
"total_consumed": audit_result["total_quota"]
- audit_result["expected_remaining"],
"balance_fixed": audit_result["discrepancy"],
"transferred_days_fixed": transferred_days_fixed,
}

@staticmethod
Expand Down
24 changes: 19 additions & 5 deletions server/cshr/views/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,17 +49,36 @@ def get_queryset(self) -> Response:
"""get all users in the system for a normal user"""
location_id = ""
team_name = ""
user_full_name = ""

if self.request.query_params.get("location_id"):
location_id = self.request.query_params.get("location_id")

if self.request.query_params.get("team_name"):
team_name = self.request.query_params.get("team_name")

if self.request.query_params.get("user_full_name"):
user_full_name = self.request.query_params.get("user_full_name").strip()

if len(location_id) > 0 or len(team_name) > 0:
options = {"location": {"id": location_id}, "team": {"name": team_name}}
query_set = get_all_of_users(options)
else:
query_set = get_all_of_users()

# Filter by search query (full name)
if user_full_name:
user_full_name = user_full_name.strip()
if " " in user_full_name:
first_name, last_name = user_full_name.split(" ")
query_set = query_set.filter(
Q(first_name__icontains=first_name.lower()) & Q(last_name__icontains=last_name.lower())
)
else:
query_set = query_set.filter(
Q(first_name__icontains=user_full_name .lower()) | Q(last_name__icontains=user_full_name.lower())
)

return query_set


Expand All @@ -72,11 +91,6 @@ def get_queryset(self) -> Response:
"""
Get all team information, Team leaders and team members
"""
# page_size = self.request.query_params.get("page_size")
# if not page_size:
# page_size = 10
# self.pagination_class.page_size = int(page_size)

user: User = self.request.user
query_set: List[User] = get_user_team_members(user)
return query_set
Expand Down
Loading