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/client/src/views/UsersView.vue b/client/src/views/UsersView.vue
index 3a161246..7382a4d4 100644
--- a/client/src/views/UsersView.vue
+++ b/client/src/views/UsersView.vue
@@ -12,14 +12,19 @@
You can change the selected office to discover the team in other offices.
-
+
-
+
+
+
+
@@ -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",
@@ -61,8 +67,13 @@ export default {
const isLoading = ref(true);
const offices = ref([]);
const users = ref([]);
+ const dropdownUsers = ref([]);
+ const isDropdownLoading = ref(false);
+
const selectedOffice = ref(null);
const selectedTeam = ref<{ name: string, id: number } | null>(null);
+ const selectedUser = ref(null);
+
const officePage = ref(1);
const usersPage = ref(1);
const officeCount = ref(0);
@@ -89,6 +100,7 @@ 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);
@@ -96,25 +108,45 @@ export default {
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)
@@ -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();
@@ -142,14 +178,18 @@ export default {
return {
offices,
users,
+ dropdownUsers,
teams,
selectedOffice,
selectedTeam,
+ selectedUser,
usersPage,
usersCount,
isLoading,
clearFilter,
applyFilters,
+ onSearchChange,
+ isDropdownLoading,
};
},
};
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
diff --git a/server/cshr/views/users.py b/server/cshr/views/users.py
index f4905dc3..ebcf0a23 100644
--- a/server/cshr/views/users.py
+++ b/server/cshr/views/users.py
@@ -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
@@ -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