From 996012e3d3a4b0323ccabaf4b4630d8b37f1c398 Mon Sep 17 00:00:00 2001 From: BOHA Date: Wed, 8 Jul 2026 00:12:20 +0200 Subject: [PATCH] =?UTF-8?q?feat(attendance):=20'Chyb=C4=9Bj=C3=ADc=C3=AD?= =?UTF-8?q?=20hodiny'=20(manko)=20row=20=E2=80=94=20real=20hours=20below?= =?UTF-8?q?=20the=20fond?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit manko = max(0, fond − (worked_total + dovolená + nemoc + neplacené volno)) using REAL clock time, not the paušál. Supplement next to Přesčas — the verified binding formulas are untouched. Why: the paušál Odpracováno can nominally cover the fond while dozens of real hours are missing (Dominik 6/2026: 21 days × 8 + 8h dovolená = 176 = fond exactly, thanks to weekend shifts compensating 3 recordless weekdays — yet real worked time was 136,5h). Přesčas = 0 correctly, and the 31,5h gap vanished from every total. Now it's a permanent summary line, red+bold when positive, plus a 'Chybí: Xh' chip on the admin screen cards. +4 tests incl. the real June dataset as a pinned acceptance case (also pins noc 6,75 — early shifts starting 05:30/05:45 legitimately count night minutes before 06:00). Co-Authored-By: Claude Fable 5 --- .../attendance-print-html.test.ts.snap | 4 ++ src/__tests__/attendance-mzda-summary.test.ts | 64 +++++++++++++++++++ src/admin/hooks/useAttendanceAdmin.ts | 9 +++ src/admin/pages/AttendanceAdmin.tsx | 9 +++ src/admin/utils/attendanceHelpers.ts | 27 ++++++++ 5 files changed, 113 insertions(+) diff --git a/src/__tests__/__snapshots__/attendance-print-html.test.ts.snap b/src/__tests__/__snapshots__/attendance-print-html.test.ts.snap index 1ae94c1..28f565d 100644 --- a/src/__tests__/__snapshots__/attendance-print-html.test.ts.snap +++ b/src/__tests__/__snapshots__/attendance-print-html.test.ts.snap @@ -347,6 +347,10 @@ exports[`buildUserSectionHtml > produces a stable per-user section (full output Přesčas: 0,00h +
+ Chybějící hodiny: + 152,00h +
Svátek: 0,00h diff --git a/src/__tests__/attendance-mzda-summary.test.ts b/src/__tests__/attendance-mzda-summary.test.ts index ae3fe4f..e8f7b6a 100644 --- a/src/__tests__/attendance-mzda-summary.test.ts +++ b/src/__tests__/attendance-mzda-summary.test.ts @@ -133,6 +133,70 @@ describe("computeMzdaSummary — built-in acceptance dataset (Dominik 5/2026)", it("Práce v noci = 36,75 — VČETNĚ směny 31.5.→1.6. (výplatnice s 31 h se mýlí)", () => { expect(s.nightMinutes / 60).toBe(36.75); }); + + it("Chybějící hodiny = 0 (145,25 + 24 pokrývá fond 152)", () => { + expect(s.manko).toBe(0); + }); +}); + +describe("computeMzdaSummary — Chybějící hodiny (Dominik 6/2026, reálná data)", () => { + // Real production month exposing the gap the paušál hides: 21 attended days + // × 8 = 168 + 8h dovolená = 176 = fond exactly (weekend shifts nominally + // compensate 3 missing weekdays 1.6./11.6./15.6.), yet REAL worked time is + // only 136,5h → 31,5h below the fond. Přesčas must stay 0 (formulas are + // binding), manko must surface the 31,5. + const JUNE = [ + W("2026-06-02", "06:15", "14:00", ["10:00", "10:30"]), + W("2026-06-03", "06:30", "13:30", ["10:00", "10:30"]), + W("2026-06-04", "06:00", "13:45", ["09:45", "10:15"]), + W("2026-06-05", "06:15", "13:45", ["10:00", "10:30"]), + W("2026-06-07", "07:15", "13:00", ["09:30", "10:00"]), // Sunday + W("2026-06-08", "07:15", "13:30", ["10:15", "10:45"]), + W("2026-06-09", "06:00", "11:15", ["09:30", "10:00"]), + W("2026-06-10", "06:00", "11:45", ["09:30", "10:00"]), + W("2026-06-12", "06:00", "13:45", ["09:45", "10:15"]), + W("2026-06-14", "17:30", "2026-06-15 04:15:00", ["22:45", "23:15"]), // Sun→Mon + W("2026-06-16", "07:30", "13:30", ["10:00", "10:30"]), + W("2026-06-17", "10:45", "13:15"), + W("2026-06-18", "06:00", "13:15", ["09:30", "10:00"]), + W("2026-06-19", "07:00", "13:15", ["10:00", "10:30"]), + W("2026-06-22", "06:00", "13:30", ["09:45", "10:15"]), + W("2026-06-23", "06:00", "13:45", ["09:45", "10:15"]), + W("2026-06-24", "05:30", "13:45", ["09:30", "10:00"]), + V("2026-06-25", 8), + W("2026-06-26", "05:45", "14:45", ["10:15", "10:45"]), + W("2026-06-27", "05:45", "17:00", ["11:15", "11:45"]), // Saturday + W("2026-06-29", "11:45", "14:30"), + W("2026-06-30", "06:30", "09:15"), + W("2026-06-30", "10:45", "13:30"), + ]; + const s = computeMzdaSummary(JUNE as never, "2026-06"); + + it("verified rows stay exactly as before (fond 176, odpracováno 168, přesčas 0)", () => { + expect(s.fund).toBe(176); + expect(s.businessDays).toBe(22); + expect(s.odpracovano).toBe(168); // 21 days × 8 flat + expect(s.vacationHours).toBe(8); + expect(s.workedHoursRaw).toBe(136.5); + expect(s.prescas).toBe(0); // (136,5 + 8) − 176 < 0 — unchanged formula + expect(s.vcetneSvatku).toBe(168); + expect(s.svatekHours).toBe(0); + expect(s.weekendHours).toBe(26.25); // shifts starting 7.6., 14.6., 27.6. + // 14.6. shift (22:00–04:15 minus break = 5:45) + early starts before + // 06:00: 24.6. 05:30 (0:30), 26.6. 05:45 (0:15), 27.6. 05:45 (0:15). + expect(s.nightMinutes / 60).toBe(6.75); + }); + + it("Chybějící hodiny = 31,5 — the gap the paušál nominally hides", () => { + expect(s.manko).toBe(31.5); // 176 − (136,5 + 8) + }); + + it("nemoc a neplacené volno kryjí fond stejně jako dovolená", () => { + const withSick = [...JUNE, { ...V("2026-06-01", 8), leave_type: "sick" }]; + const s2 = computeMzdaSummary(withSick as never, "2026-06"); + expect(s2.sickHours).toBe(8); + expect(s2.manko).toBe(23.5); // 31,5 − 8 + }); }); describe("computeMzdaSummary — formula edge cases", () => { diff --git a/src/admin/hooks/useAttendanceAdmin.ts b/src/admin/hooks/useAttendanceAdmin.ts index 89f1f9b..2ebd31c 100644 --- a/src/admin/hooks/useAttendanceAdmin.ts +++ b/src/admin/hooks/useAttendanceAdmin.ts @@ -97,6 +97,7 @@ interface UserTotal { weekend_hours?: number; night_minutes?: number; worked_holiday_hours?: number; // sum of hours worked ON a holiday + manko?: number; // Chybějící hodiny: real clock time below the fond records?: AttendanceRecord[]; } @@ -243,6 +244,7 @@ function computeUserTotals( t.weekend_hours = s.weekendHours; t.night_minutes = s.nightMinutes; t.worked_holiday_hours = s.svatekHours; + t.manko = s.manko; } return totals; @@ -427,6 +429,10 @@ export function buildUserSectionHtml( Přesčas: ${formatHoursDecimal(s.prescas * 60)}h
+
+ Chybějící hodiny: + ${formatHoursDecimal(s.manko * 60)}h +
Svátek: ${formatHoursDecimal(s.svatekHours * 60)}h @@ -547,6 +553,9 @@ export function buildPrintHtml( } .user-summary .summary-label { color: #555; } .user-summary .summary-value { font-weight: 600; } + /* Chybějící hodiny > 0 — must jump out of the sheet, not hide in it. */ + .user-summary .summary-alert .summary-label, + .user-summary .summary-alert .summary-value { color: #b00020; font-weight: 700; } .user-summary .summary-fund { border-top: 1px solid #ddd; margin-top: 4px; padding-top: 6px; } diff --git a/src/admin/pages/AttendanceAdmin.tsx b/src/admin/pages/AttendanceAdmin.tsx index 690b918..e1bcdb2 100644 --- a/src/admin/pages/AttendanceAdmin.tsx +++ b/src/admin/pages/AttendanceAdmin.tsx @@ -46,6 +46,8 @@ interface UserTotalData { prescas?: number; svatek?: number; worked_holiday_hours?: number; + /** Chybějící hodiny: real clock time below the fond (approved absences count). */ + manko?: number; } /** Fond "used" total mirrors the Fond footer line below: @@ -293,6 +295,13 @@ export default function AttendanceAdmin() { label={`Nep: ${ut.unpaid_hours}h`} /> )} + {ut.manko !== undefined && ut.manko > 0 && ( + + )} {/* Fond usage */} diff --git a/src/admin/utils/attendanceHelpers.ts b/src/admin/utils/attendanceHelpers.ts index 6e0ce04..b539262 100644 --- a/src/admin/utils/attendanceHelpers.ts +++ b/src/admin/utils/attendanceHelpers.ts @@ -411,6 +411,20 @@ export interface MzdaSummary { fund: number; /** Mon–Fri non-holiday day count backing the fond. */ businessDays: number; + /** Nemoc: sum of sick leave_hours (evidence records). */ + sickHours: number; + /** Neplacené volno: sum of unpaid leave_hours (evidence records). */ + unpaidHours: number; + /** + * Chybějící hodiny (manko): + * max(0, fond − (worked_total + dovolená + nemoc + neplacené volno)) + * — REAL clock hours below the fond. A supplement next to Přesčas, never a + * replacement: the paušál Odpracováno can nominally cover the fond (weekend + * work compensating missing weekdays) while dozens of real hours are + * missing — e.g. Dominik 6/2026: paušál 168 + 8 dovolená = fond 176, yet + * real worked was 136,5 → manko 31,5. This line makes that visible. + */ + manko: number; } /** Compute the payroll recap for one user's month of records. */ @@ -429,6 +443,8 @@ export const computeMzdaSummary = ( let weekendMinutes = 0; let nightMinutes = 0; let vacationHours = 0; + let sickHours = 0; + let unpaidHours = 0; // Distinct non-holiday calendar days (start day) with a "Práce" record — // two shifts on one day still count that day once. const workDays = new Set(); @@ -438,6 +454,8 @@ export const computeMzdaSummary = ( if (lt !== "work") { // "holiday" placeholder records are evidence-only — skip everywhere. if (lt === "vacation") vacationHours += Number(r.leave_hours) || 8; + else if (lt === "sick") sickHours += Number(r.leave_hours) || 8; + else if (lt === "unpaid") unpaidHours += Number(r.leave_hours) || 8; continue; } const m = calculateWorkMinutesPrint(r); @@ -468,6 +486,12 @@ export const computeMzdaSummary = ( const workedHoursRaw = workedMinutes / 60; const odpracovano = workDays.size * 8; const prescas = Math.max(0, workedHoursRaw + vacationHours - fund); + // Chybějící hodiny: REAL clock time (not the paušál) vs the fond — approved + // absences (dovolená/nemoc/neplacené volno) cover the fond. + const manko = Math.max( + 0, + fund - (workedHoursRaw + vacationHours + sickHours + unpaidHours), + ); return { workedHoursRaw, @@ -480,5 +504,8 @@ export const computeMzdaSummary = ( nightMinutes, fund, businessDays, + sickHours, + unpaidHours, + manko, }; };