diff --git a/src/__tests__/__snapshots__/attendance-print-html.test.ts.snap b/src/__tests__/__snapshots__/attendance-print-html.test.ts.snap
index ba8fdaf..1ae94c1 100644
--- a/src/__tests__/__snapshots__/attendance-print-html.test.ts.snap
+++ b/src/__tests__/__snapshots__/attendance-print-html.test.ts.snap
@@ -337,7 +337,7 @@ exports[`buildUserSectionHtml > produces a stable per-user section (full output
Svátek:
diff --git a/src/__tests__/attendance-mzda-summary.test.ts b/src/__tests__/attendance-mzda-summary.test.ts
new file mode 100644
index 0000000..7d4b97a
--- /dev/null
+++ b/src/__tests__/attendance-mzda-summary.test.ts
@@ -0,0 +1,176 @@
+import { describe, it, expect } from "vitest";
+import { computeMzdaSummary } from "../admin/utils/attendanceHelpers";
+import { buildUserSectionHtml } from "../admin/hooks/useAttendanceAdmin";
+
+/**
+ * Payroll recap spec (accountant notes, confirmed by the owner 2026-07):
+ * - Odpracováno = all worked hours EXCEPT hours worked on holidays
+ * - Vč. svátků a přesčasů = ALL worked hours (so line2 − line1 = Svátek)
+ * - Dovolená = Σ vacation leave_hours (hour-based)
+ * - Přesčas = max(0, worked − fond) — month-level, never negative
+ * - Svátek = hours actually WORKED on public-holiday dates
+ * - So/Ne = worked hours on Sat/Sun
+ * - Fond = Mon–Fri (holidays included) × 8 h
+ * June 2026: 22 workdays, no holidays → fond 176.
+ * July 2026: 23 Mon–Fri days (6.7. Monday holiday counted; 5.7. is Sunday) → fond 184.
+ */
+
+const work = (
+ day: string,
+ from: string,
+ to: string,
+ brk?: [string, string],
+) => ({
+ shift_date: `${day}T02:00:00`,
+ arrival_time: `${day}T${from}:00`,
+ departure_time: `${day}T${to}:00`,
+ break_start: brk ? `${day}T${brk[0]}:00` : null,
+ break_end: brk ? `${day}T${brk[1]}:00` : null,
+ leave_type: "work",
+});
+const vacation = (day: string, hours: number) => ({
+ shift_date: `${day}T02:00:00`,
+ leave_type: "vacation",
+ leave_hours: hours,
+ arrival_time: null,
+ departure_time: null,
+ break_start: null,
+ break_end: null,
+});
+
+const JUNE_WORKDAYS = [
+ "01",
+ "02",
+ "03",
+ "04",
+ "05",
+ "08",
+ "09",
+ "10",
+ "11",
+ "12",
+ "15",
+ "16",
+ "17",
+ "18",
+ "19",
+ "22",
+ "23",
+ "24",
+ "25",
+ "26",
+ "29",
+ "30",
+].map((d) => `2026-06-${d}`);
+
+describe("computeMzdaSummary — accountant spec", () => {
+ it("single 3h day: worked 3, no overtime (173h below fond)", () => {
+ const s = computeMzdaSummary(
+ [work("2026-06-02", "09:00", "12:00")],
+ "2026-06",
+ );
+ expect(s.odpracovano).toBe(3);
+ expect(s.vcetneSvatku).toBe(3);
+ expect(s.prescas).toBe(0);
+ expect(s.fund).toBe(176);
+ });
+
+ it("accountant's Přesčas example: +3h one day, −2h elsewhere → 1h overtime", () => {
+ const recs = JUNE_WORKDAYS.slice(0, 20).map((d) =>
+ work(d, "08:00", "16:30", ["12:00", "12:30"]),
+ ); // 20 × 8h
+ recs.push(work("2026-06-29", "08:00", "14:00")); // 6h (2h short)
+ recs.push(work("2026-06-30", "08:00", "19:30", ["12:00", "12:30"])); // 11h (+3h)
+ const s = computeMzdaSummary(recs, "2026-06");
+ expect(s.vcetneSvatku).toBe(177);
+ expect(s.prescas).toBe(1);
+ });
+
+ it("accountant's Svátek example: 10h worked on 6.7. → Svátek 10; excluded from Odpracováno", () => {
+ const julyWorkdays = [
+ "01",
+ "02",
+ "03",
+ "07",
+ "08",
+ "09",
+ "10",
+ "13",
+ "14",
+ "15",
+ "16",
+ "17",
+ "20",
+ "21",
+ "22",
+ "23",
+ "24",
+ "27",
+ "28",
+ "29",
+ "30",
+ "31",
+ ].map((d) => `2026-07-${d}`);
+ const recs = julyWorkdays.map((d) =>
+ work(d, "08:00", "16:30", ["12:00", "12:30"]),
+ ); // 22 × 8h
+ recs.push(work("2026-07-06", "08:00", "18:30", ["12:00", "12:30"])); // holiday, 10h
+ const s = computeMzdaSummary(recs, "2026-07");
+ expect(s.fund).toBe(184); // 23 Mon–Fri days incl. the Monday holiday
+ expect(s.svatekHours).toBe(10);
+ expect(s.vcetneSvatku).toBe(186); // all worked hours
+ expect(s.odpracovano).toBe(176); // minus the holiday-worked 10h
+ expect(s.prescas).toBe(2); // max(0, 186 − 184) — never negative
+ });
+
+ it("vacation does NOT flow into Přesčas (worked-hours-only rule)", () => {
+ const recs: ReturnType
[] =
+ JUNE_WORKDAYS.slice(0, 21).map((d) =>
+ work(d, "08:00", "16:30", ["12:00", "12:30"]),
+ ); // 21 × 8 = 168
+ recs.push(vacation("2026-06-30", 8));
+ const s = computeMzdaSummary(recs, "2026-06");
+ expect(s.vacationHours).toBe(8);
+ expect(s.prescas).toBe(0); // 168 worked < 176 fond — vacation doesn't count
+ });
+
+ it("partial-day vacation next to a work shift on the same day (5h work + 3h vacation)", () => {
+ const recs = [
+ work("2026-06-02", "08:00", "13:00"), // 5h
+ vacation("2026-06-02", 3),
+ ];
+ const s = computeMzdaSummary(recs, "2026-06");
+ expect(s.odpracovano).toBe(5);
+ expect(s.vacationHours).toBe(3);
+ });
+
+ it("weekend hours count into So/Ne and into worked totals", () => {
+ const s = computeMzdaSummary(
+ [work("2026-06-06", "08:00", "12:00")], // Saturday 4h
+ "2026-06",
+ );
+ expect(s.weekendHours).toBe(4);
+ expect(s.odpracovano).toBe(4);
+ });
+});
+
+describe("print day table — multiple records on one day", () => {
+ it("renders one row per record (two shifts are both visible)", () => {
+ const records = [
+ work("2026-06-02", "06:00", "10:00"),
+ work("2026-06-02", "14:00", "18:00"),
+ ];
+ const totalMins = 8 * 60;
+ const html = buildUserSectionHtml(
+ "1",
+ { name: "Test", minutes: totalMins, vacation_hours: 0, records } as never,
+ { month: "2026-06", month_name: "Červen 2026", user_totals: {} } as never,
+ );
+ // Both shifts appear, each with its own arrival time.
+ expect(html).toContain("06:00");
+ expect(html).toContain("14:00");
+ // Two rows dated 2. 6.
+ const rows = html.match(/2\. 6\. 2026<\/td>/g) || [];
+ expect(rows.length).toBe(2);
+ });
+});
diff --git a/src/admin/hooks/useAttendanceAdmin.ts b/src/admin/hooks/useAttendanceAdmin.ts
index b97a360..89f1f9b 100644
--- a/src/admin/hooks/useAttendanceAdmin.ts
+++ b/src/admin/hooks/useAttendanceAdmin.ts
@@ -18,10 +18,8 @@ import {
getDaysInMonth,
shiftDateForMonth,
formatHoursDecimal,
- calculateNightMinutes,
normalizeDateStr,
- holidaysInMonth,
- calculateFreeHolidayHours,
+ computeMzdaSummary,
getCzechWeekday,
} from "../utils/attendanceHelpers";
import type {
@@ -216,116 +214,35 @@ function computeUserTotals(
}
}
- // Add fund data per user (matching PHP addFundDataToUserTotals)
- const [yearStr, monthStr] = month.split("-");
- const yr = parseInt(yearStr, 10);
- const mo = parseInt(monthStr, 10) - 1;
-
- // Count Mon-Fri business days in month (INCLUDING holidays).
- // The fund is rawBizDays × 8 — holidays stay in the count so the
- // user is paid for them via the fund. Svátek (free holiday hours) is
- // computed separately as "8h per holiday the user did not work".
- let rawBizDays = 0;
- const cur = new Date(yr, mo, 1);
- while (cur.getMonth() === mo) {
- const dow = cur.getDay();
- if (dow !== 0 && dow !== 6) rawBizDays++;
- cur.setDate(cur.getDate() + 1);
- }
-
- const holidayDates = holidaysInMonth(yr, mo + 1);
-
+ // Rekapitulace per user — the SAME shared computation the print uses
+ // (computeMzdaSummary), so the screen and the printed sheet can never
+ // disagree. Accountant spec 2026-07: Odpracováno = worked minus
+ // holiday-worked; Vč. svátků = ALL worked; Přesčas = max(0, worked − fond);
+ // Svátek = hours actually worked on holidays.
for (const uid of Object.keys(totals)) {
const t = totals[uid];
const userRecords = recordsByUser.get(Number(uid)) || [];
-
- // Odpracováno: floor(worked / 8) × 8 — round to whole days, drop remainder.
- const workedHoursRaw = t.minutes / 60;
- const odpracovano = Math.floor(workedHoursRaw / 8) * 8;
-
- // So/Ne: sum of worked hours on Sat/Sun (raw, not rounded).
- const weekendHours = userRecords
- .filter(
- (r) =>
- (r.leave_type || "work") === "work" && isWeekendDate(r.shift_date),
- )
- .reduce((sum, r) => sum + calculateWorkMinutesPrint(r) / 60, 0);
-
- // Práce v noci: minutes in 22:00-06:00 across all work records.
- const nightMinutes = userRecords
- .filter((r) => (r.leave_type || "work") === "work")
- .reduce((sum, r) => sum + calculateNightMinutes(r), 0);
-
- // Svátek (free): 8h per holiday the user did not work.
- const freeHolidayHours = calculateFreeHolidayHours(
- userRecords,
- holidayDates,
- );
-
- // Worked holidays: holidays the user DID work (counted toward
- // Odpracováno, but their 8h must not flow into Přesčas).
- const workedDatesSet = new Set(
- userRecords
- .filter((r) => (r.leave_type || "work") === "work")
- .map((r) => normalizeDateStr(r.shift_date))
- .filter(Boolean),
- );
- let workedHolidayCount = 0;
- let workedHolidayHours = 0;
- for (const hd of holidayDates) {
- if (workedDatesSet.has(hd)) workedHolidayCount++;
- }
- // Sum the actual hours worked on holiday dates (for the KPI card's
- // "fond used" total = real_work + vacation + worked_holiday + free_holiday).
- for (const r of userRecords) {
- if ((r.leave_type || "work") !== "work") continue;
- const d = normalizeDateStr(r.shift_date);
- if (d && holidayDates.includes(d)) {
- workedHolidayHours += calculateWorkMinutesPrint(r) / 60;
- }
- }
-
- // Fund = Mon-Fri × 8h. Holidays count as work days (already in rawBizDays).
- const fund = rawBizDays * 8;
-
- // Včetně svátků a přesčasů (mzda formula):
- // odpracovano + vacation + remainder − min(freeHols, workedHols × 8)
- //
- // Two-way logic matching the print:
- // • If the user worked at least one holiday, that worked holiday's
- // 8h is in Odpracováno and shouldn't also count as Přesčas. We
- // subtract one 8h per worked holiday (capped at the total free
- // holiday hours — can't go negative).
- // • If the user did NOT work any holiday, no adjustment is needed:
- // all unworked holidays are already paid via the Svátek line and
- // don't flow into Přesčas, so vcetne_svatku just equals the
- // work + vacation + remainder.
- const remainder = workedHoursRaw - odpracovano;
- const holidayAdj = Math.min(freeHolidayHours, workedHolidayCount * 8);
- const vcetneSv = odpracovano - holidayAdj + t.vacation_hours + remainder;
-
- // Přesčas = vcetneSv − odpracovano (derived).
- const prescas = vcetneSv - odpracovano;
+ const s = computeMzdaSummary(userRecords, month);
// Legacy fields (kept so existing UI doesn't break).
- const workedHours = Math.round(workedHoursRaw * 10) / 10;
+ const workedHours = Math.round(s.workedHoursRaw * 10) / 10;
const covered = Math.round((workedHours + t.vacation_hours) * 10) / 10;
- t.fund = fund;
- t.business_days = rawBizDays;
+ t.fund = s.fund;
+ t.business_days = s.businessDays;
t.worked_hours = workedHours;
t.covered = covered;
- t.missing = Math.max(0, Math.round((fund - covered) * 10) / 10);
- t.overtime = Math.max(0, Math.round((covered - fund) * 10) / 10);
+ t.missing = Math.max(0, Math.round((s.fund - covered) * 10) / 10);
+ t.overtime = Math.max(0, Math.round((covered - s.fund) * 10) / 10);
- t.worked_hours_raw = workedHoursRaw;
- t.odpracovano = odpracovano;
- t.vcetne_svatku = vcetneSv;
- t.prescas = prescas;
- t.svatek = freeHolidayHours;
- t.weekend_hours = weekendHours;
- t.night_minutes = nightMinutes;
- t.worked_holiday_hours = workedHolidayHours;
+ t.worked_hours_raw = s.workedHoursRaw;
+ t.odpracovano = s.odpracovano;
+ t.vcetne_svatku = s.vcetneSvatku;
+ t.prescas = s.prescas;
+ t.svatek = s.svatekHours;
+ t.weekend_hours = s.weekendHours;
+ t.night_minutes = s.nightMinutes;
+ t.worked_holiday_hours = s.svatekHours;
}
return totals;
@@ -390,10 +307,23 @@ export function buildUserSectionHtml(
userData: UserTotal,
printData: PrintData,
): string {
- // Build a date-keyed lookup of the user's records.
- const recordsByDate = new Map();
+ // Build a date-keyed lookup of the user's records. A day may hold MULTIPLE
+ // records (two shifts, or work + partial-day vacation) — keep them ALL; a
+ // single-record map silently dropped every shift but the last one.
+ const recordsByDate = new Map();
for (const r of userData.records || []) {
- recordsByDate.set(normalizeDateStr(r.shift_date), r);
+ const key = normalizeDateStr(r.shift_date);
+ const list = recordsByDate.get(key);
+ if (list) list.push(r);
+ else recordsByDate.set(key, [r]);
+ }
+ // Stable in-day order: by arrival time (leave records without times last).
+ for (const list of recordsByDate.values()) {
+ list.sort((a, b) =>
+ String(a.arrival_time || "9999").localeCompare(
+ String(b.arrival_time || "9999"),
+ ),
+ );
}
// Iterate one row per day of the month.
@@ -407,19 +337,14 @@ export function buildUserSectionHtml(
const rows: string[] = [];
for (let d = 1; d <= daysInMonth; d++) {
const dateStr = shiftDateForMonth(yr, mo, d);
- const record = recordsByDate.get(dateStr);
+ const dayRecords = recordsByDate.get(dateStr);
const weekend = isWeekendDate(dateStr);
const holiday = isHoliday(dateStr);
- const leaveType = (record?.leave_type as string) || "work";
- const rowClasses = [
- weekend && "weekend",
- holiday && "holiday",
- leaveType === "vacation" && "vacation",
- ]
- .filter(Boolean)
- .join(" ");
- if (!record) {
+ if (!dayRecords || dayRecords.length === 0) {
+ const rowClasses = [weekend && "weekend", holiday && "holiday"]
+ .filter(Boolean)
+ .join(" ");
rows.push(`
| ${escapeHtml(formatDate(dateStr))} |
${escapeHtml(getCzechWeekday(dateStr))} |
@@ -434,25 +359,37 @@ export function buildUserSectionHtml(
continue;
}
- const isLeave = leaveType !== "work";
- const workMinutes = calculateWorkMinutesPrint(record);
- const hours = Math.floor(workMinutes / 60);
- const mins = workMinutes % 60;
- const breakCell =
- isLeave || !record.break_start || !record.break_end
- ? "—"
- : `${escapeHtml(formatTimeOrDatetimePrint(record.break_start, record.shift_date))} - ${escapeHtml(formatTimeOrDatetimePrint(record.break_end, record.shift_date))}`;
+ // One row PER RECORD — a day can hold two shifts, or work + partial-day
+ // vacation. (A single row per day silently dropped the extra shifts.)
+ for (const record of dayRecords) {
+ const leaveType = (record.leave_type as string) || "work";
+ const rowClasses = [
+ weekend && "weekend",
+ holiday && "holiday",
+ leaveType === "vacation" && "vacation",
+ ]
+ .filter(Boolean)
+ .join(" ");
- let hoursCell: string;
- if (workMinutes > 0 && !isLeave) {
- hoursCell = `${hours}:${String(mins).padStart(2, "0")} (${formatHoursDecimal(workMinutes)})`;
- } else if (isLeave) {
- hoursCell = formatHoursDecimal((Number(record.leave_hours) || 8) * 60);
- } else {
- hoursCell = "—";
- }
+ const isLeave = leaveType !== "work";
+ const workMinutes = calculateWorkMinutesPrint(record);
+ const hours = Math.floor(workMinutes / 60);
+ const mins = workMinutes % 60;
+ const breakCell =
+ isLeave || !record.break_start || !record.break_end
+ ? "—"
+ : `${escapeHtml(formatTimeOrDatetimePrint(record.break_start, record.shift_date))} - ${escapeHtml(formatTimeOrDatetimePrint(record.break_end, record.shift_date))}`;
- rows.push(`
+ let hoursCell: string;
+ if (workMinutes > 0 && !isLeave) {
+ hoursCell = `${hours}:${String(mins).padStart(2, "0")} (${formatHoursDecimal(workMinutes)})`;
+ } else if (isLeave) {
+ hoursCell = formatHoursDecimal((Number(record.leave_hours) || 8) * 60);
+ } else {
+ hoursCell = "—";
+ }
+
+ rows.push(`
| ${escapeHtml(formatDate(dateStr))} |
${escapeHtml(getCzechWeekday(dateStr))} |
${escapeHtml(getLeaveTypeName(leaveType))} |
@@ -463,110 +400,48 @@ export function buildUserSectionHtml(
${buildProjectLogsHtml(record)} |
${escapeHtml(record.notes || "")} |
`);
- }
-
- // ----- mzda-style summary numbers (computed here, not from userData,
- // because the backend's getPrintData only returns the legacy fields). -----
-
- // Worked minutes: sum of calculateWorkMinutesPrint across work records.
- let workedMinutes = 0;
- let weekendHoursRaw = 0;
- let nightMinutes = 0;
- for (const r of userRecords) {
- const leaveType = r.leave_type || "work";
- if (leaveType !== "work") continue;
- const m = calculateWorkMinutesPrint(r);
- workedMinutes += m;
- if (isWeekendDate(r.shift_date)) {
- weekendHoursRaw += m / 60;
}
- nightMinutes += calculateNightMinutes(r);
}
- // Sum of vacation/sick/unpaid hours (in hours, not minutes). The
- // "holiday" leave_type is auto-computed from Czech holidays further down
- // and no longer selectable in the UI, so it's deliberately omitted here.
- let vacationHours = 0;
- for (const r of userRecords) {
- const lt = r.leave_type || "work";
- if (lt === "work") continue;
- const h = Number(r.leave_hours) || 8;
- if (lt === "vacation") vacationHours += h;
- }
-
- // Odpracováno: floor(worked / 8) × 8.
- const workedHoursRaw = workedMinutes / 60;
- const odpracovano = Math.floor(workedHoursRaw / 8) * 8;
-
- // Free Svátek: 8h per holiday date the user did not work.
- const holidayDates = holidaysInMonth(yr, mo);
- const workedDates = new Set(
- userRecords
- .filter((r) => (r.leave_type || "work") === "work")
- .map((r) => normalizeDateStr(r.shift_date))
- .filter(Boolean),
- );
- let freeHolidayHours = 0;
- let workedHolidayCount = 0;
- for (const hd of holidayDates) {
- if (workedDates.has(hd)) workedHolidayCount++;
- else freeHolidayHours += 8;
- }
-
- // Fund: count Mon-Fri (incl. holidays) × 8h.
- let rawBizDays = 0;
- const cur = new Date(yr, mo - 1, 1);
- while (cur.getMonth() === mo - 1) {
- const dow = cur.getDay();
- if (dow !== 0 && dow !== 6) rawBizDays++;
- cur.setDate(cur.getDate() + 1);
- }
- const businessDays = rawBizDays;
- const fund = businessDays * 8;
-
- // Včetně svátků a přesčasů: floor(worked/8)*8 − worked_holiday*8 + vacation + remainder.
- // (A worked holiday counts toward Odpracováno, but its 8h should not flow
- // into Přesčas — so we subtract it here. Unworked holidays are paid via
- // the fund and don't affect this line.)
- const remainder = workedHoursRaw - odpracovano;
- const vcetneSv =
- odpracovano - workedHolidayCount * 8 + vacationHours + remainder;
-
- // Přesčas = #2 - #1.
- const prescas = vcetneSv - odpracovano;
+ // Rekapitulace — single shared implementation (accountant spec 2026-07):
+ // Odpracováno = all worked hours EXCEPT hours worked on holidays
+ // Vč. svátků a přesč. = ALL worked hours (line2 − line1 = Svátek)
+ // Přesčas = max(0, worked − fond)
+ // Svátek = hours actually worked on holiday dates
+ const s = computeMzdaSummary(userRecords, printData.month);
const summaryHtml = `
Odpracováno:
- ${formatHoursDecimal(odpracovano * 60)}h
+ ${formatHoursDecimal(s.odpracovano * 60)}h
Odpracováno včetně svátků a přesčasů:
- ${formatHoursDecimal(vcetneSv * 60)}h
+ ${formatHoursDecimal(s.vcetneSvatku * 60)}h
Dovolená:
- ${formatHoursDecimal(vacationHours * 60)}h
+ ${formatHoursDecimal(s.vacationHours * 60)}h
Přesčas:
- ${formatHoursDecimal(prescas * 60)}h
+ ${formatHoursDecimal(s.prescas * 60)}h
Svátek:
- ${formatHoursDecimal(freeHolidayHours * 60)}h
+ ${formatHoursDecimal(s.svatekHours * 60)}h
So/Ne:
- ${formatHoursDecimal(weekendHoursRaw * 60)}h
+ ${formatHoursDecimal(s.weekendHours * 60)}h
Práce v noci:
- ${formatHoursDecimal(nightMinutes)}h
+ ${formatHoursDecimal(s.nightMinutes)}h
Fond měsíce:
- ${formatHoursDecimal(fund * 60)}h (${businessDays} dnů)
+ ${formatHoursDecimal(s.fund * 60)}h (${s.businessDays} dnů)
Víkend (So/Ne)
diff --git a/src/admin/utils/attendanceHelpers.ts b/src/admin/utils/attendanceHelpers.ts
index b058ddd..9a17f66 100644
--- a/src/admin/utils/attendanceHelpers.ts
+++ b/src/admin/utils/attendanceHelpers.ts
@@ -370,3 +370,94 @@ export const calculateFreeHolidayHours = (
}
return free;
};
+
+// ---------------------------------------------------------------------------
+// Mzda rekapitulace (single source — the print AND the admin screen consume
+// this, so the two can never drift again; spec confirmed with the accountant
+// 2026-07, see the summary lines below)
+// ---------------------------------------------------------------------------
+
+export interface MzdaSummary {
+ /** All worked hours in the month: arrival→departure minus recorded breaks. */
+ workedHoursRaw: number;
+ /** Odpracováno: all worked hours EXCEPT hours worked on public holidays. */
+ odpracovano: number;
+ /** Odpracováno včetně svátků a přesčasů: ALL worked hours (line2 − line1 = Svátek). */
+ vcetneSvatku: number;
+ /** Dovolená: sum of vacation leave_hours (hour-based, partial days allowed). */
+ vacationHours: number;
+ /** Přesčas: worked hours above the monthly fund — max(0, worked − fond). */
+ prescas: number;
+ /** Svátek: hours actually WORKED on public-holiday dates. */
+ svatekHours: number;
+ /** So/Ne: worked hours on Saturdays and Sundays. */
+ weekendHours: number;
+ /** Práce v noci: worked minutes in the 22:00–06:00 window. */
+ nightMinutes: number;
+ /** Fond měsíce: Mon–Fri (public holidays included) × 8 h. */
+ fund: number;
+ businessDays: number;
+ /** 8 h per holiday the user did NOT work (fond is covered by these). */
+ freeHolidayHours: number;
+}
+
+/** Compute the payroll recap for one user's month of records. */
+export const computeMzdaSummary = (
+ records: AttendanceRecord[],
+ monthStr: string,
+): MzdaSummary => {
+ const [yearStr, monthNumStr] = monthStr.split("-");
+ const yr = parseInt(yearStr, 10);
+ const mo = parseInt(monthNumStr, 10);
+
+ const holidayDates = holidaysInMonth(yr, mo);
+ const holidaySet = new Set(holidayDates);
+
+ let workedMinutes = 0;
+ let svatekMinutes = 0;
+ let weekendMinutes = 0;
+ let nightMinutes = 0;
+ let vacationHours = 0;
+
+ for (const r of records) {
+ const lt = r.leave_type || "work";
+ if (lt !== "work") {
+ if (lt === "vacation") vacationHours += Number(r.leave_hours) || 8;
+ continue;
+ }
+ const m = calculateWorkMinutesPrint(r);
+ workedMinutes += m;
+ const day = normalizeDateStr(r.shift_date);
+ if (holidaySet.has(day)) svatekMinutes += m;
+ if (isWeekendDate(day)) weekendMinutes += m;
+ nightMinutes += calculateNightMinutes(r);
+ }
+
+ // Fond: Mon–Fri × 8 h; public holidays stay in the count (they are paid via
+ // the fond — an unworked holiday earns its 8 h, see freeHolidayHours).
+ let businessDays = 0;
+ const cur = new Date(yr, mo - 1, 1);
+ while (cur.getMonth() === mo - 1) {
+ const dow = cur.getDay();
+ if (dow !== 0 && dow !== 6) businessDays++;
+ cur.setDate(cur.getDate() + 1);
+ }
+ const fund = businessDays * 8;
+
+ const workedHoursRaw = workedMinutes / 60;
+ const svatekHours = svatekMinutes / 60;
+
+ return {
+ workedHoursRaw,
+ odpracovano: workedHoursRaw - svatekHours,
+ vcetneSvatku: workedHoursRaw,
+ vacationHours,
+ prescas: Math.max(0, workedHoursRaw - fund),
+ svatekHours,
+ weekendHours: weekendMinutes / 60,
+ nightMinutes,
+ fund,
+ businessDays,
+ freeHolidayHours: calculateFreeHolidayHours(records, holidayDates),
+ };
+};
|