From 72ea6117a09d40bfddcbe7f7ae1f4a9a89d40f2b Mon Sep 17 00:00:00 2001 From: Mahmoud-Emad Date: Tue, 13 Jan 2026 13:19:19 +0200 Subject: [PATCH 1/6] feat: Improve balance synchronization and fix orphaned links - Display a warning for orphaned transferred days - Enable synchronize button for orphaned days - Add logic to fix orphaned transferred days links - Improve serializer to fetch previous year's balance directly - Update log messages for balance and transferred days fixes --- .../components/dashboard/AuditUserBalance.vue | 32 +++++++++++++--- server/cshr/serializers/vacations.py | 30 ++++++++++++--- server/cshr/services/balance.py | 38 +++++++++++++++++++ 3 files changed, 88 insertions(+), 12 deletions(-) diff --git a/client/src/components/dashboard/AuditUserBalance.vue b/client/src/components/dashboard/AuditUserBalance.vue index 2ddbe04c..9edadd70 100644 --- a/client/src/components/dashboard/AuditUserBalance.vue +++ b/client/src/components/dashboard/AuditUserBalance.vue @@ -45,7 +45,7 @@

Audit Results for {{ reportData.user_full_name - }}

+ }}
Period: {{ reportData.year }} | Reason: {{ reportData.reason }}
@@ -136,14 +136,23 @@ -
Correction Required
+
Balance Discrepancy Detected
The database balance is out of sync. This action will synchronize the records and notify the user.
+ +
Orphaned Transferred Balance Link
+ 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. +
+
- + Synchronize User Balance
@@ -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') @@ -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() diff --git a/server/cshr/serializers/vacations.py b/server/cshr/serializers/vacations.py index f6cddf98..2a36fcbf 100644 --- a/server/cshr/serializers/vacations.py +++ b/server/cshr/serializers/vacations.py @@ -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 diff --git a/server/cshr/services/balance.py b/server/cshr/services/balance.py index b32a4a6f..ba5a5df1 100644 --- a/server/cshr/services/balance.py +++ b/server/cshr/services/balance.py @@ -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, @@ -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: @@ -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 From ee67cc8d0da89be073bd5088fb2d0416dabb3821 Mon Sep 17 00:00:00 2001 From: Mahmoud-Emad Date: Tue, 13 Jan 2026 13:31:09 +0200 Subject: [PATCH 2/6] feat: Add search functionality to team members - Add search input field for team members - Implement debounced search handler - Filter team members by name on backend - Update "no users" message based on search query --- client/src/views/TeamView.vue | 37 +++++++++++++++++++++++++++++++---- server/cshr/views/users.py | 14 +++++++------ 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/client/src/views/TeamView.vue b/client/src/views/TeamView.vue index 424320ad..fb8530a1 100644 --- a/client/src/views/TeamView.vue +++ b/client/src/views/TeamView.vue @@ -25,7 +25,15 @@
-

Team Members

+ +
+

Team Members

+ + +
+ @@ -42,7 +50,8 @@ rounded="circle"> - Seems like there are no users. Please try changing the filters and attempting again. + No users found matching "{{ searchQuery }}". Try a different search. + Seems like there are no users. Please try changing the filters and attempting again. @@ -53,6 +62,7 @@ import { watch, ref, onMounted } from 'vue' import profileImage from '../components/profileImage.vue' import UserCard from '@/components/userCard.vue' import type { Api } from '@/types'; +import { debounce } from 'lodash'; export default { name: 'TeamView', @@ -75,14 +85,19 @@ export default { const teamPage = ref(1) const teamCount = ref(0) const teamUsers = ref([]) + const searchQuery = ref('') function displayValue(value: any) { return value ?? '-' } - // Function to list team based on page and itemsPerPage + // Function to list team based on page and search query async function listTeam(): Promise { - const res = await $api.users.team.list({ page: teamPage.value }) + const query: any = { page: teamPage.value } + if (searchQuery.value.trim()) { + query.search = searchQuery.value.trim() + } + const res = await $api.users.team.list(query) if (res && res.count) { teamCount.value = Math.ceil(res.count / 12) } else { @@ -91,6 +106,18 @@ export default { return res!.results } + // Debounced search handler + const performSearch = debounce(async () => { + loading.value = true + teamPage.value = 1 // Reset to first page when searching + teamUsers.value = await listTeam() + loading.value = false + }, 300) + + function onSearchChange() { + performSearch() + } + watch(teamPage, async () => { loading.value = true @@ -118,6 +145,8 @@ export default { teamPage, teamLeads, displayValue, + searchQuery, + onSearchChange, } } } diff --git a/server/cshr/views/users.py b/server/cshr/views/users.py index f4905dc3..4105e2d6 100644 --- a/server/cshr/views/users.py +++ b/server/cshr/views/users.py @@ -70,15 +70,17 @@ class TeamAPIView(ListAPIView): def get_queryset(self) -> Response: """ - Get all team information, Team leaders and team members + Get all team information, Team leaders and team members. + Supports filtering by 'search' query parameter for full_name. """ - # 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) + + # Filter by search query (full name) + search = self.request.query_params.get("search", "").strip() + if search: + query_set = query_set.filter(full_name__icontains=search) + return query_set From ba924fff8b223bc89e44082206e5e2400465fc64 Mon Sep 17 00:00:00 2001 From: Mahmoud-Emad Date: Tue, 13 Jan 2026 13:46:00 +0200 Subject: [PATCH 3/6] feat: Add user search functionality - Remove search input from Team view - Add search input to Users view - Implement debounced search in Users view - Update API to support user search by full name --- client/src/views/TeamView.vue | 37 ++++------------------------------ client/src/views/UsersView.vue | 35 +++++++++++++++++++++++++++++--- server/cshr/views/users.py | 19 +++++++++-------- 3 files changed, 47 insertions(+), 44 deletions(-) diff --git a/client/src/views/TeamView.vue b/client/src/views/TeamView.vue index fb8530a1..424320ad 100644 --- a/client/src/views/TeamView.vue +++ b/client/src/views/TeamView.vue @@ -25,15 +25,7 @@ - -
-

Team Members

- - -
- +

Team Members

@@ -50,8 +42,7 @@ rounded="circle"> - No users found matching "{{ searchQuery }}". Try a different search. - Seems like there are no users. Please try changing the filters and attempting again. + Seems like there are no users. Please try changing the filters and attempting again. @@ -62,7 +53,6 @@ import { watch, ref, onMounted } from 'vue' import profileImage from '../components/profileImage.vue' import UserCard from '@/components/userCard.vue' import type { Api } from '@/types'; -import { debounce } from 'lodash'; export default { name: 'TeamView', @@ -85,19 +75,14 @@ export default { const teamPage = ref(1) const teamCount = ref(0) const teamUsers = ref([]) - const searchQuery = ref('') function displayValue(value: any) { return value ?? '-' } - // Function to list team based on page and search query + // Function to list team based on page and itemsPerPage async function listTeam(): Promise { - const query: any = { page: teamPage.value } - if (searchQuery.value.trim()) { - query.search = searchQuery.value.trim() - } - const res = await $api.users.team.list(query) + const res = await $api.users.team.list({ page: teamPage.value }) if (res && res.count) { teamCount.value = Math.ceil(res.count / 12) } else { @@ -106,18 +91,6 @@ export default { return res!.results } - // Debounced search handler - const performSearch = debounce(async () => { - loading.value = true - teamPage.value = 1 // Reset to first page when searching - teamUsers.value = await listTeam() - loading.value = false - }, 300) - - function onSearchChange() { - performSearch() - } - watch(teamPage, async () => { loading.value = true @@ -145,8 +118,6 @@ export default { teamPage, teamLeads, displayValue, - searchQuery, - onSearchChange, } } } diff --git a/client/src/views/UsersView.vue b/client/src/views/UsersView.vue index 3a161246..9ce6481e 100644 --- a/client/src/views/UsersView.vue +++ b/client/src/views/UsersView.vue @@ -12,14 +12,18 @@ You can change the selected office to discover the team in other offices. - + - + + + +