diff --git a/src/__tests__/attendance-workfund.test.ts b/src/__tests__/attendance-workfund.test.ts
new file mode 100644
index 0000000..5a1b164
--- /dev/null
+++ b/src/__tests__/attendance-workfund.test.ts
@@ -0,0 +1,124 @@
+import { describe, it, expect, beforeAll, afterAll } from "vitest";
+import prisma from "../config/database";
+import { getWorkfund } from "../services/attendance.service";
+
+/**
+ * Print-recap parity for the Bilance page's monthly fund overview
+ * (getWorkfund), per the binding formulas (computeMzdaSummary):
+ * overtime (Přesčas) = max(0, worked + dovolená − fond)
+ * missing (Chybějící hodiny) = max(0, fond − (worked + dovolená + nemoc
+ * + neplacené volno))
+ * The two coverage bases differ deliberately: only dovolená earns toward
+ * overtime, every approved absence covers the fond.
+ */
+
+const USERNAME = "wf_parity_test_user";
+let userId: number;
+
+const june = (day: string) => new Date(`2026-06-${day}T00:00:00Z`);
+const dt = (day: string, hm: string) => new Date(`2026-06-${day}T${hm}:00`);
+
+beforeAll(async () => {
+ await prisma.attendance.deleteMany({
+ where: { users: { username: USERNAME } },
+ });
+ await prisma.users.deleteMany({ where: { username: USERNAME } });
+
+ // getAttendanceUsers includes only users whose role carries
+ // attendance.record — the seeded admin role has every permission.
+ const adminRole = await prisma.roles.findFirst({ where: { name: "admin" } });
+ if (!adminRole) throw new Error("Admin role not found — seed the DB first");
+ const user = await prisma.users.create({
+ data: {
+ username: USERNAME,
+ email: `${USERNAME}@test.local`,
+ password_hash:
+ "$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO",
+ first_name: "Workfund",
+ last_name: "Parity",
+ is_active: true,
+ role_id: adminRole.id,
+ },
+ });
+ userId = user.id;
+
+ // June 2026 (fond 176 = 22 non-holiday weekdays × 8):
+ // 10 work days of 8h net (08:00–16:30, 30min break) → worked 80
+ const workDays = ["01", "02", "03", "04", "05", "08", "09", "10", "11", "12"];
+ for (const d of workDays) {
+ await prisma.attendance.create({
+ data: {
+ user_id: userId,
+ shift_date: june(d),
+ arrival_time: dt(d, "08:00"),
+ departure_time: dt(d, "16:30"),
+ break_start: dt(d, "12:00"),
+ break_end: dt(d, "12:30"),
+ leave_type: "work",
+ },
+ });
+ }
+ // dovolená 24, nemoc 16, neplacené volno 8
+ const leaves: Array<[string, string, number]> = [
+ ["15", "vacation", 8],
+ ["16", "vacation", 8],
+ ["17", "vacation", 8],
+ ["18", "sick", 8],
+ ["19", "sick", 8],
+ ["22", "unpaid", 8],
+ ];
+ for (const [d, type, hours] of leaves) {
+ await prisma.attendance.create({
+ data: {
+ user_id: userId,
+ shift_date: june(d),
+ leave_type: type as never,
+ leave_hours: hours,
+ },
+ });
+ }
+});
+
+afterAll(async () => {
+ await prisma.attendance.deleteMany({ where: { user_id: userId } });
+ await prisma.users.deleteMany({ where: { id: userId } });
+});
+
+describe("getWorkfund — print-recap parity", () => {
+ it("computes overtime from worked+dovolená and missing from all approved absences", async () => {
+ const data = await getWorkfund(2026);
+ // getWorkfund's future-year early return types months as a bare {} —
+ // narrow to the populated shape (2026 is never a future year here).
+ const months = data.months as Record<
+ string,
+ {
+ fund: number;
+ users: Record<
+ string,
+ {
+ worked: number;
+ covered: number;
+ covered_prescas: number;
+ overtime: number;
+ missing: number;
+ }
+ >;
+ }
+ >;
+ const juneData = months["6"];
+ expect(juneData).toBeTruthy();
+ expect(juneData.fund).toBe(176);
+
+ const us = juneData.users[String(userId)];
+ expect(us).toBeTruthy();
+ expect(us.worked).toBe(80);
+ // covered (manko base): 80 + 24 + 16 + 8
+ expect(us.covered).toBe(128);
+ // covered_prescas (Přesčas base): 80 + 24 — sick/unpaid excluded
+ expect(us.covered_prescas).toBe(104);
+ // Chybějící hodiny: 176 − 128
+ expect(us.missing).toBe(48);
+ // Přesčas: max(0, 104 − 176)
+ expect(us.overtime).toBe(0);
+ });
+});
diff --git a/src/admin/lib/queries/attendance.ts b/src/admin/lib/queries/attendance.ts
index 981d3e3..735a1e5 100644
--- a/src/admin/lib/queries/attendance.ts
+++ b/src/admin/lib/queries/attendance.ts
@@ -77,7 +77,10 @@ export interface BalancesData {
export interface FundUserData {
name: string;
worked: number;
+ /** worked + dovolená + nemoc + neplacené volno — covers the fond (manko base). */
covered: number;
+ /** worked + dovolená only — the Přesčas base (print-recap parity). */
+ covered_prescas: number;
overtime: number;
missing: number;
}
diff --git a/src/admin/pages/AttendanceAdmin.tsx b/src/admin/pages/AttendanceAdmin.tsx
index e1bcdb2..ad6edc8 100644
--- a/src/admin/pages/AttendanceAdmin.tsx
+++ b/src/admin/pages/AttendanceAdmin.tsx
@@ -50,26 +50,25 @@ interface UserTotalData {
manko?: number;
}
-/** Fond "used" total mirrors the Fond footer line below:
- * real_worked + vacation + worked_holiday + free_holiday. */
+/** Fond coverage — print-recap parity (computeMzdaSummary): real worked
+ * hours + every approved absence (dovolená + nemoc + neplacené volno).
+ * This is the base the Chybějící hodiny line measures against. */
function getFondUsed(data: UserTotalData): number {
const realWorked = data.worked_hours_raw ?? data.worked_hours;
return (
realWorked +
(data.vacation_hours ?? 0) +
- (data.worked_holiday_hours ?? 0) +
- (data.svatek ?? 0)
+ (data.sick_hours ?? 0) +
+ (data.unpaid_hours ?? 0)
);
}
-/** Maps the Fond delta (used − fund) to a ProgressBar color token, mirroring
- * the legacy getFundBarBackground conditional: over fund → warning, exactly
- * at fund → success, under fund → primary (was the accent gradient). */
+/** Bar color follows the print recap: Přesčas > 0 → warning, Chybějící
+ * hodiny > 0 → error, exactly covered → success. */
function getFundBarColor(data: UserTotalData): ProgressColor {
- const delta = getFondUsed(data) - (data.fund ?? 0);
- if (delta > 0) return "warning";
- if (delta >= 0) return "success";
- return "primary";
+ if ((data.prescas ?? 0) > 0) return "warning";
+ if ((data.manko ?? 0) > 0) return "error";
+ return "success";
}
const PrintIcon = (
@@ -304,15 +303,15 @@ export default function AttendanceAdmin() {
)}
- {/* Fond usage */}
+ {/* Fond usage — the ± badges are the PRINT recap values
+ (Přesčas / Chybějící hodiny), so the card and the
+ printed sheet can never disagree. */}
{ut.fund !== null &&
(() => {
- // Fond "used" = real_worked + vacation +
- // worked_holiday + free_holiday (everything the user
- // actually got paid for this month).
const fondUsed = getFondUsed(ut);
const fundVal = ut.fund ?? 0;
- const delta = fondUsed - fundVal;
+ const prescas = ut.prescas ?? 0;
+ const manko = ut.manko ?? 0;
const pct = Math.min(
100,
(fondUsed / (ut.fund || 1)) * 100,
@@ -334,26 +333,28 @@ export default function AttendanceAdmin() {
Fond: {formatHoursDecimal(fondUsed * 60)}h /{" "}
{formatHoursDecimal(fundVal * 60)}h
- {delta > 0 && (
+ {prescas > 0 && (
- +{formatHoursDecimal(delta * 60)}h
+ +{formatHoursDecimal(prescas * 60)}h
)}
- {delta < 0 && (
+ {manko > 0 && (
- -{formatHoursDecimal(Math.abs(delta) * 60)}h
+ -{formatHoursDecimal(manko * 60)}h
)}
diff --git a/src/admin/pages/AttendanceBalances.tsx b/src/admin/pages/AttendanceBalances.tsx
index 78d37da..b63014b 100644
--- a/src/admin/pages/AttendanceBalances.tsx
+++ b/src/admin/pages/AttendanceBalances.tsx
@@ -251,6 +251,7 @@ export default function AttendanceBalances() {
let totalFund = 0;
let totalWorked = 0;
let totalCovered = 0;
+ let totalCoveredPrescas = 0;
for (const monthData of Object.values(fundData.months)) {
// Use prorated fund (fund_to_date) for current month, full fund for past
totalFund += monthData.fund_to_date ?? monthData.fund;
@@ -258,15 +259,20 @@ export default function AttendanceBalances() {
if (us) {
totalWorked += us.worked;
totalCovered += us.covered;
+ // Older cached payloads may lack covered_prescas — fall back to
+ // covered (the pre-parity behavior) rather than dropping to 0.
+ totalCoveredPrescas += us.covered_prescas ?? us.covered;
}
}
+ // Print-recap parity: missing (Chybějící hodiny) counts every approved
+ // absence; overtime (Přesčas) counts only worked + dovolená.
const missing = Math.max(
0,
Math.round((totalFund - totalCovered) * 10) / 10,
);
const overtime = Math.max(
0,
- Math.round((totalCovered - totalFund) * 10) / 10,
+ Math.round((totalCoveredPrescas - totalFund) * 10) / 10,
);
return {
fund: totalFund,
diff --git a/src/services/attendance.service.ts b/src/services/attendance.service.ts
index 6a17a04..ef05eee 100644
--- a/src/services/attendance.service.ts
+++ b/src/services/attendance.service.ts
@@ -574,6 +574,7 @@ export async function getWorkfund(year: number) {
name: string;
worked: number;
covered: number;
+ covered_prescas: number;
overtime: number;
missing: number;
}
@@ -614,6 +615,7 @@ export async function getWorkfund(year: number) {
name: string;
worked: number;
covered: number;
+ covered_prescas: number;
overtime: number;
missing: number;
}
@@ -624,6 +626,7 @@ export async function getWorkfund(year: number) {
let worked = 0;
let vacationHours = 0;
let sickHours = 0;
+ let unpaidHours = 0;
for (const rec of recs) {
const lt = (rec.leave_type as string) || "work";
if (lt === "work") {
@@ -639,21 +642,35 @@ export async function getWorkfund(year: number) {
vacationHours += Number(rec.leave_hours) || 8;
} else if (lt === "sick") {
sickHours += Number(rec.leave_hours) || 8;
+ } else if (lt === "unpaid") {
+ unpaidHours += Number(rec.leave_hours) || 8;
}
// "holiday" is auto-derived from Czech public holidays.
}
+ // Print-recap parity (computeMzdaSummary — the binding formulas):
+ // overtime (Přesčas) = max(0, worked + dovolená − fond)
+ // missing (Chybějící hodiny) = max(0, fond − (worked + dovolená
+ // + nemoc + neplacené volno))
+ // The two coverage sets differ deliberately: only dovolená earns
+ // toward overtime, but every approved absence covers the fond.
const userFund = fundToDate;
const workedRound = Math.round(worked * 10) / 10;
- const leaveHours = vacationHours + sickHours;
- const covered = Math.round((worked + leaveHours) * 10) / 10;
+ const coveredPrescas = Math.round((worked + vacationHours) * 10) / 10;
+ const covered =
+ Math.round((worked + vacationHours + sickHours + unpaidHours) * 10) /
+ 10;
const missing = Math.max(0, Math.round((userFund - covered) * 10) / 10);
- const overtime = Math.max(0, Math.round((covered - userFund) * 10) / 10);
+ const overtime = Math.max(
+ 0,
+ Math.round((coveredPrescas - userFund) * 10) / 10,
+ );
monthUsers[String(u.id)] = {
name: `${u.first_name} ${u.last_name}`.trim(),
worked: workedRound,
covered,
+ covered_prescas: coveredPrescas,
overtime,
missing,
};