Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3530dd247c | ||
|
|
996012e3d3 | ||
|
|
3cd7cfcc34 | ||
|
|
68d9c224bc | ||
|
|
200aa0e1a8 | ||
|
|
e3b414d22c |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.43",
|
"version": "2.4.46",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.43",
|
"version": "2.4.46",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.102.0",
|
"@anthropic-ai/sdk": "^0.102.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.43",
|
"version": "2.4.46",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "dist/server.js",
|
"main": "dist/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -337,7 +337,7 @@ exports[`buildUserSectionHtml > produces a stable per-user section (full output
|
|||||||
</div>
|
</div>
|
||||||
<div class="summary-row">
|
<div class="summary-row">
|
||||||
<span class="summary-label">Odpracováno včetně svátků a přesčasů:</span>
|
<span class="summary-label">Odpracováno včetně svátků a přesčasů:</span>
|
||||||
<span class="summary-value">16,00h</span>
|
<span class="summary-value">8,00h</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-row">
|
<div class="summary-row">
|
||||||
<span class="summary-label">Dovolená:</span>
|
<span class="summary-label">Dovolená:</span>
|
||||||
@@ -345,7 +345,11 @@ exports[`buildUserSectionHtml > produces a stable per-user section (full output
|
|||||||
</div>
|
</div>
|
||||||
<div class="summary-row">
|
<div class="summary-row">
|
||||||
<span class="summary-label">Přesčas:</span>
|
<span class="summary-label">Přesčas:</span>
|
||||||
<span class="summary-value">8,00h</span>
|
<span class="summary-value">0,00h</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-row summary-alert">
|
||||||
|
<span class="summary-label">Chybějící hodiny:</span>
|
||||||
|
<span class="summary-value">152,00h</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-row">
|
<div class="summary-row">
|
||||||
<span class="summary-label">Svátek:</span>
|
<span class="summary-label">Svátek:</span>
|
||||||
|
|||||||
284
src/__tests__/attendance-mzda-summary.test.ts
Normal file
284
src/__tests__/attendance-mzda-summary.test.ts
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
computeMzdaSummary,
|
||||||
|
formatHoursDecimal1,
|
||||||
|
} from "../admin/utils/attendanceHelpers";
|
||||||
|
import { buildUserSectionHtml } from "../admin/hooks/useAttendanceAdmin";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BINDING payroll-recap formulas (owner spec 2026-07, back-computed against
|
||||||
|
* the real payslip of Dominik Vesecký 5/2026 — the dataset below is the
|
||||||
|
* built-in acceptance test and MUST match exactly, not approximately):
|
||||||
|
*
|
||||||
|
* fond = (Po–Pá dny, které NEJSOU svátek) × 8
|
||||||
|
* odpracováno = (kalendářní dny se záznamem Práce mimo svátek) × 8, paušálně
|
||||||
|
* svátek = čisté odpracované hodiny ve dnech svátku
|
||||||
|
* dovolená = Σ leave_hours (evidence)
|
||||||
|
* přesčas = max(0, (worked_total + dovolená) − fond) [měsíční]
|
||||||
|
* vč. svátků = odpracováno + přesčas
|
||||||
|
* so/ne = čisté hodiny směn začínajících v So/Ne (celá směna)
|
||||||
|
* noc = průnik s 22:00–06:00 minus pauzy v pásmu, ke dni začátku
|
||||||
|
*
|
||||||
|
* Expected 5/2026 (holidays 1.5. + 8.5.): fond 152 · odpracováno 144 ·
|
||||||
|
* svátek 8 · dovolená 24 · přesčas 17,25 (=17,3 na desetiny) · vč. 161,25
|
||||||
|
* (=161,3) · So/Ne 57,25 · noc 36,75 (payslip said 31 — accountant's own
|
||||||
|
* error, she missed the 31.5.→1.6. overnight shift; 36,75 is CORRECT).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const W = (d: string, from: string, to: string, brk?: [string, string]) => ({
|
||||||
|
shift_date: `${d}T02:00:00`,
|
||||||
|
arrival_time: from.includes(" ") ? from.replace(" ", "T") : `${d}T${from}:00`,
|
||||||
|
departure_time: to.includes(" ") ? to.replace(" ", "T") : `${d}T${to}:00`,
|
||||||
|
break_start: brk
|
||||||
|
? brk[0].includes(" ")
|
||||||
|
? brk[0].replace(" ", "T")
|
||||||
|
: `${d}T${brk[0]}:00`
|
||||||
|
: null,
|
||||||
|
break_end: brk
|
||||||
|
? brk[1].includes(" ")
|
||||||
|
? brk[1].replace(" ", "T")
|
||||||
|
: `${d}T${brk[1]}:00`
|
||||||
|
: null,
|
||||||
|
leave_type: "work",
|
||||||
|
});
|
||||||
|
const V = (d: string, h: number) => ({
|
||||||
|
shift_date: `${d}T02:00:00`,
|
||||||
|
leave_type: "vacation",
|
||||||
|
leave_hours: h,
|
||||||
|
arrival_time: null,
|
||||||
|
departure_time: null,
|
||||||
|
break_start: null,
|
||||||
|
break_end: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The exact dataset from the spec (times with a space carry a full date). */
|
||||||
|
const DOMINIK_MAY_2026 = [
|
||||||
|
W("2026-05-01", "08:15", "17:15", ["12:30", "13:30"]),
|
||||||
|
W("2026-05-03", "21:30", "2026-05-04 06:15:00", [
|
||||||
|
"2026-05-04 01:52:00",
|
||||||
|
"2026-05-04 02:22:00",
|
||||||
|
]),
|
||||||
|
V("2026-05-05", 8),
|
||||||
|
V("2026-05-06", 8),
|
||||||
|
V("2026-05-07", 8),
|
||||||
|
W("2026-05-10", "21:15", "2026-05-11 06:30:00", [
|
||||||
|
"2026-05-11 01:52:00",
|
||||||
|
"2026-05-11 02:22:00",
|
||||||
|
]),
|
||||||
|
W("2026-05-12", "07:45", "16:00", ["11:52", "12:22"]),
|
||||||
|
W("2026-05-13", "08:00", "16:15", ["12:07", "12:37"]),
|
||||||
|
W("2026-05-14", "08:00", "16:15", ["12:07", "12:37"]),
|
||||||
|
W("2026-05-15", "08:00", "16:00", ["12:00", "12:30"]),
|
||||||
|
W("2026-05-16", "10:00", "23:30", ["16:45", "17:45"]),
|
||||||
|
W("2026-05-17", "21:30", "2026-05-18 06:30:00", [
|
||||||
|
"2026-05-18 02:00:00",
|
||||||
|
"2026-05-18 02:30:00",
|
||||||
|
]),
|
||||||
|
W("2026-05-19", "09:30", "17:00", ["13:15", "13:45"]),
|
||||||
|
W("2026-05-20", "08:30", "16:30", ["12:30", "13:00"]),
|
||||||
|
W("2026-05-21", "09:45", "15:15"),
|
||||||
|
W("2026-05-22", "10:45", "15:30"),
|
||||||
|
W("2026-05-24", "17:45", "2026-05-25 06:00:00", [
|
||||||
|
"23:45",
|
||||||
|
"2026-05-25 00:45:00",
|
||||||
|
]),
|
||||||
|
W("2026-05-26", "09:00", "14:30"),
|
||||||
|
W("2026-05-27", "10:00", "15:30"),
|
||||||
|
W("2026-05-28", "08:00", "15:45", ["11:45", "12:15"]),
|
||||||
|
W("2026-05-29", "08:15", "15:00", ["11:30", "12:00"]),
|
||||||
|
W("2026-05-31", "19:45", "2026-06-01 04:15:00", [
|
||||||
|
"2026-06-01 00:00:00",
|
||||||
|
"2026-06-01 00:30:00",
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("computeMzdaSummary — built-in acceptance dataset (Dominik 5/2026)", () => {
|
||||||
|
const s = computeMzdaSummary(DOMINIK_MAY_2026 as never, "2026-05");
|
||||||
|
|
||||||
|
it("Fond měsíce = 152 (19 Po–Pá dnů mimo svátky 1.5. a 8.5.)", () => {
|
||||||
|
expect(s.businessDays).toBe(19);
|
||||||
|
expect(s.fund).toBe(152);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Odpracováno = 144 (18 pracovních dnů mimo svátek × 8, paušálně)", () => {
|
||||||
|
expect(s.odpracovano).toBe(144);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Svátek = 8 (skutečné hodiny odpracované 1. 5.)", () => {
|
||||||
|
expect(s.svatekHours).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Dovolená = 24", () => {
|
||||||
|
expect(s.vacationHours).toBe(24);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("worked_total = 145,25 (145:15 čistého času)", () => {
|
||||||
|
expect(s.workedHoursRaw).toBe(145.25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Přesčas = 17,25 → zobrazeno 17,3 ((145,25 + 24) − 152)", () => {
|
||||||
|
expect(s.prescas).toBe(17.25);
|
||||||
|
expect(formatHoursDecimal1(s.prescas * 60)).toBe("17,3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Odpracováno včetně svátků a přesčasů = 161,25 → zobrazeno 161,3 (144 + 17,25)", () => {
|
||||||
|
expect(s.vcetneSvatku).toBe(161.25);
|
||||||
|
expect(formatHoursDecimal1(s.vcetneSvatku * 60)).toBe("161,3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("So/Ne = 57,25 (6 směn začínajících v So/Ne, celé směny)", () => {
|
||||||
|
expect(s.weekendHours).toBe(57.25);
|
||||||
|
});
|
||||||
|
|
||||||
|
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", () => {
|
||||||
|
it("Odpracováno is a flat 8h per day: a lone 4:45 day counts as 8", () => {
|
||||||
|
const s = computeMzdaSummary(
|
||||||
|
[W("2026-06-02", "10:45", "15:30")] as never,
|
||||||
|
"2026-06",
|
||||||
|
);
|
||||||
|
expect(s.odpracovano).toBe(8);
|
||||||
|
expect(s.workedHoursRaw).toBe(4.75);
|
||||||
|
expect(s.prescas).toBe(0); // (4,75 + 0) − 176 < 0
|
||||||
|
expect(s.vcetneSvatku).toBe(8); // odpracováno + přesčas
|
||||||
|
});
|
||||||
|
|
||||||
|
it("two shifts on one day count that day ONCE (8h, not 16h)", () => {
|
||||||
|
const s = computeMzdaSummary(
|
||||||
|
[
|
||||||
|
W("2026-06-02", "06:00", "10:00"),
|
||||||
|
W("2026-06-02", "14:00", "18:00"),
|
||||||
|
] as never,
|
||||||
|
"2026-06",
|
||||||
|
);
|
||||||
|
expect(s.odpracovano).toBe(8);
|
||||||
|
expect(s.workedHoursRaw).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dovolená covers the fond in Přesčas", () => {
|
||||||
|
// 176h fond (June 2026): 152h worked + 30h vacation = 182 → +6 přesčas.
|
||||||
|
const recs: unknown[] = [];
|
||||||
|
const days = [
|
||||||
|
"01",
|
||||||
|
"02",
|
||||||
|
"03",
|
||||||
|
"04",
|
||||||
|
"05",
|
||||||
|
"08",
|
||||||
|
"09",
|
||||||
|
"10",
|
||||||
|
"11",
|
||||||
|
"12",
|
||||||
|
"15",
|
||||||
|
"16",
|
||||||
|
"17",
|
||||||
|
"18",
|
||||||
|
"19",
|
||||||
|
"22",
|
||||||
|
"23",
|
||||||
|
"24",
|
||||||
|
"25",
|
||||||
|
].map((d) => `2026-06-${d}`);
|
||||||
|
for (const d of days) recs.push(W(d, "08:00", "16:30", ["12:00", "12:30"])); // 19×8=152
|
||||||
|
recs.push(V("2026-06-29", 8), V("2026-06-30", 8), V("2026-06-26", 14));
|
||||||
|
const s = computeMzdaSummary(recs as never, "2026-06");
|
||||||
|
expect(s.vacationHours).toBe(30);
|
||||||
|
expect(s.prescas).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a shift STARTING on a holiday goes to Svátek, not to Odpracováno days", () => {
|
||||||
|
const s = computeMzdaSummary(
|
||||||
|
[W("2026-07-06", "08:00", "18:30", ["12:00", "12:30"])] as never, // 6.7. holiday, 10h
|
||||||
|
"2026-07",
|
||||||
|
);
|
||||||
|
expect(s.svatekHours).toBe(10);
|
||||||
|
expect(s.odpracovano).toBe(0);
|
||||||
|
expect(s.fund).toBe(176); // 23 Mon–Fri − 6.7. holiday = 22 × 8
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("print day table — multiple records on one day", () => {
|
||||||
|
it("renders one row per record (two shifts are both visible)", () => {
|
||||||
|
const records = [
|
||||||
|
W("2026-06-02", "06:00", "10:00"),
|
||||||
|
W("2026-06-02", "14:00", "18:00"),
|
||||||
|
];
|
||||||
|
const html = buildUserSectionHtml(
|
||||||
|
"1",
|
||||||
|
{ name: "Test", minutes: 480, vacation_hours: 0, records } as never,
|
||||||
|
{ month: "2026-06", month_name: "Červen 2026", user_totals: {} } as never,
|
||||||
|
);
|
||||||
|
expect(html).toContain("06:00");
|
||||||
|
expect(html).toContain("14:00");
|
||||||
|
const rows = html.match(/<td>2\. 6\. 2026<\/td>/g) || [];
|
||||||
|
expect(rows.length).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,10 +18,8 @@ import {
|
|||||||
getDaysInMonth,
|
getDaysInMonth,
|
||||||
shiftDateForMonth,
|
shiftDateForMonth,
|
||||||
formatHoursDecimal,
|
formatHoursDecimal,
|
||||||
calculateNightMinutes,
|
|
||||||
normalizeDateStr,
|
normalizeDateStr,
|
||||||
holidaysInMonth,
|
computeMzdaSummary,
|
||||||
calculateFreeHolidayHours,
|
|
||||||
getCzechWeekday,
|
getCzechWeekday,
|
||||||
} from "../utils/attendanceHelpers";
|
} from "../utils/attendanceHelpers";
|
||||||
import type {
|
import type {
|
||||||
@@ -99,6 +97,7 @@ interface UserTotal {
|
|||||||
weekend_hours?: number;
|
weekend_hours?: number;
|
||||||
night_minutes?: number;
|
night_minutes?: number;
|
||||||
worked_holiday_hours?: number; // sum of hours worked ON a holiday
|
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[];
|
records?: AttendanceRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,116 +215,36 @@ function computeUserTotals(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add fund data per user (matching PHP addFundDataToUserTotals)
|
// Rekapitulace per user — the SAME shared computation the print uses
|
||||||
const [yearStr, monthStr] = month.split("-");
|
// (computeMzdaSummary), so the screen and the printed sheet can never
|
||||||
const yr = parseInt(yearStr, 10);
|
// disagree. Accountant spec 2026-07: Odpracováno = worked minus
|
||||||
const mo = parseInt(monthStr, 10) - 1;
|
// holiday-worked; Vč. svátků = ALL worked; Přesčas = max(0, worked − fond);
|
||||||
|
// Svátek = hours actually worked on holidays.
|
||||||
// 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);
|
|
||||||
|
|
||||||
for (const uid of Object.keys(totals)) {
|
for (const uid of Object.keys(totals)) {
|
||||||
const t = totals[uid];
|
const t = totals[uid];
|
||||||
const userRecords = recordsByUser.get(Number(uid)) || [];
|
const userRecords = recordsByUser.get(Number(uid)) || [];
|
||||||
|
const s = computeMzdaSummary(userRecords, month);
|
||||||
// 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;
|
|
||||||
|
|
||||||
// Legacy fields (kept so existing UI doesn't break).
|
// 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;
|
const covered = Math.round((workedHours + t.vacation_hours) * 10) / 10;
|
||||||
|
|
||||||
t.fund = fund;
|
t.fund = s.fund;
|
||||||
t.business_days = rawBizDays;
|
t.business_days = s.businessDays;
|
||||||
t.worked_hours = workedHours;
|
t.worked_hours = workedHours;
|
||||||
t.covered = covered;
|
t.covered = covered;
|
||||||
t.missing = Math.max(0, Math.round((fund - covered) * 10) / 10);
|
t.missing = Math.max(0, Math.round((s.fund - covered) * 10) / 10);
|
||||||
t.overtime = Math.max(0, Math.round((covered - fund) * 10) / 10);
|
t.overtime = Math.max(0, Math.round((covered - s.fund) * 10) / 10);
|
||||||
|
|
||||||
t.worked_hours_raw = workedHoursRaw;
|
t.worked_hours_raw = s.workedHoursRaw;
|
||||||
t.odpracovano = odpracovano;
|
t.odpracovano = s.odpracovano;
|
||||||
t.vcetne_svatku = vcetneSv;
|
t.vcetne_svatku = s.vcetneSvatku;
|
||||||
t.prescas = prescas;
|
t.prescas = s.prescas;
|
||||||
t.svatek = freeHolidayHours;
|
t.svatek = s.svatekHours;
|
||||||
t.weekend_hours = weekendHours;
|
t.weekend_hours = s.weekendHours;
|
||||||
t.night_minutes = nightMinutes;
|
t.night_minutes = s.nightMinutes;
|
||||||
t.worked_holiday_hours = workedHolidayHours;
|
t.worked_holiday_hours = s.svatekHours;
|
||||||
|
t.manko = s.manko;
|
||||||
}
|
}
|
||||||
|
|
||||||
return totals;
|
return totals;
|
||||||
@@ -390,10 +309,23 @@ export function buildUserSectionHtml(
|
|||||||
userData: UserTotal,
|
userData: UserTotal,
|
||||||
printData: PrintData,
|
printData: PrintData,
|
||||||
): string {
|
): string {
|
||||||
// Build a date-keyed lookup of the user's records.
|
// Build a date-keyed lookup of the user's records. A day may hold MULTIPLE
|
||||||
const recordsByDate = new Map<string, AttendanceRecord>();
|
// 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<string, AttendanceRecord[]>();
|
||||||
for (const r of userData.records || []) {
|
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.
|
// Iterate one row per day of the month.
|
||||||
@@ -407,19 +339,14 @@ export function buildUserSectionHtml(
|
|||||||
const rows: string[] = [];
|
const rows: string[] = [];
|
||||||
for (let d = 1; d <= daysInMonth; d++) {
|
for (let d = 1; d <= daysInMonth; d++) {
|
||||||
const dateStr = shiftDateForMonth(yr, mo, d);
|
const dateStr = shiftDateForMonth(yr, mo, d);
|
||||||
const record = recordsByDate.get(dateStr);
|
const dayRecords = recordsByDate.get(dateStr);
|
||||||
const weekend = isWeekendDate(dateStr);
|
const weekend = isWeekendDate(dateStr);
|
||||||
const holiday = isHoliday(dateStr);
|
const holiday = isHoliday(dateStr);
|
||||||
const leaveType = (record?.leave_type as string) || "work";
|
|
||||||
const rowClasses = [
|
if (!dayRecords || dayRecords.length === 0) {
|
||||||
weekend && "weekend",
|
const rowClasses = [weekend && "weekend", holiday && "holiday"]
|
||||||
holiday && "holiday",
|
|
||||||
leaveType === "vacation" && "vacation",
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ");
|
.join(" ");
|
||||||
|
|
||||||
if (!record) {
|
|
||||||
rows.push(`<tr class="${rowClasses}">
|
rows.push(`<tr class="${rowClasses}">
|
||||||
<td>${escapeHtml(formatDate(dateStr))}</td>
|
<td>${escapeHtml(formatDate(dateStr))}</td>
|
||||||
<td>${escapeHtml(getCzechWeekday(dateStr))}</td>
|
<td>${escapeHtml(getCzechWeekday(dateStr))}</td>
|
||||||
@@ -434,6 +361,18 @@ export function buildUserSectionHtml(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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(" ");
|
||||||
|
|
||||||
const isLeave = leaveType !== "work";
|
const isLeave = leaveType !== "work";
|
||||||
const workMinutes = calculateWorkMinutesPrint(record);
|
const workMinutes = calculateWorkMinutesPrint(record);
|
||||||
const hours = Math.floor(workMinutes / 60);
|
const hours = Math.floor(workMinutes / 60);
|
||||||
@@ -464,109 +403,51 @@ export function buildUserSectionHtml(
|
|||||||
<td>${escapeHtml(record.notes || "")}</td>
|
<td>${escapeHtml(record.notes || "")}</td>
|
||||||
</tr>`);
|
</tr>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- 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
|
// Rekapitulace — single shared implementation (accountant spec 2026-07):
|
||||||
// "holiday" leave_type is auto-computed from Czech holidays further down
|
// Odpracováno = all worked hours EXCEPT hours worked on holidays
|
||||||
// and no longer selectable in the UI, so it's deliberately omitted here.
|
// Vč. svátků a přesč. = ALL worked hours (line2 − line1 = Svátek)
|
||||||
let vacationHours = 0;
|
// Přesčas = max(0, worked − fond)
|
||||||
for (const r of userRecords) {
|
// Svátek = hours actually worked on holiday dates
|
||||||
const lt = r.leave_type || "work";
|
const s = computeMzdaSummary(userRecords, printData.month);
|
||||||
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;
|
|
||||||
|
|
||||||
const summaryHtml = `<div class="user-summary">
|
const summaryHtml = `<div class="user-summary">
|
||||||
<div class="summary-row">
|
<div class="summary-row">
|
||||||
<span class="summary-label">Odpracováno:</span>
|
<span class="summary-label">Odpracováno:</span>
|
||||||
<span class="summary-value">${formatHoursDecimal(odpracovano * 60)}h</span>
|
<span class="summary-value">${formatHoursDecimal(s.odpracovano * 60)}h</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-row">
|
<div class="summary-row">
|
||||||
<span class="summary-label">Odpracováno včetně svátků a přesčasů:</span>
|
<span class="summary-label">Odpracováno včetně svátků a přesčasů:</span>
|
||||||
<span class="summary-value">${formatHoursDecimal(vcetneSv * 60)}h</span>
|
<span class="summary-value">${formatHoursDecimal(s.vcetneSvatku * 60)}h</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-row">
|
<div class="summary-row">
|
||||||
<span class="summary-label">Dovolená:</span>
|
<span class="summary-label">Dovolená:</span>
|
||||||
<span class="summary-value">${formatHoursDecimal(vacationHours * 60)}h</span>
|
<span class="summary-value">${formatHoursDecimal(s.vacationHours * 60)}h</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-row">
|
<div class="summary-row">
|
||||||
<span class="summary-label">Přesčas:</span>
|
<span class="summary-label">Přesčas:</span>
|
||||||
<span class="summary-value">${formatHoursDecimal(prescas * 60)}h</span>
|
<span class="summary-value">${formatHoursDecimal(s.prescas * 60)}h</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-row${s.manko > 0 ? " summary-alert" : ""}">
|
||||||
|
<span class="summary-label">Chybějící hodiny:</span>
|
||||||
|
<span class="summary-value">${formatHoursDecimal(s.manko * 60)}h</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-row">
|
<div class="summary-row">
|
||||||
<span class="summary-label">Svátek:</span>
|
<span class="summary-label">Svátek:</span>
|
||||||
<span class="summary-value">${formatHoursDecimal(freeHolidayHours * 60)}h</span>
|
<span class="summary-value">${formatHoursDecimal(s.svatekHours * 60)}h</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-row">
|
<div class="summary-row">
|
||||||
<span class="summary-label">So/Ne:</span>
|
<span class="summary-label">So/Ne:</span>
|
||||||
<span class="summary-value">${formatHoursDecimal(weekendHoursRaw * 60)}h</span>
|
<span class="summary-value">${formatHoursDecimal(s.weekendHours * 60)}h</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-row">
|
<div class="summary-row">
|
||||||
<span class="summary-label">Práce v noci:</span>
|
<span class="summary-label">Práce v noci:</span>
|
||||||
<span class="summary-value">${formatHoursDecimal(nightMinutes)}h</span>
|
<span class="summary-value">${formatHoursDecimal(s.nightMinutes)}h</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-row summary-fund">
|
<div class="summary-row summary-fund">
|
||||||
<span class="summary-label">Fond měsíce:</span>
|
<span class="summary-label">Fond měsíce:</span>
|
||||||
<span class="summary-value">${formatHoursDecimal(fund * 60)}h (${businessDays} dnů)</span>
|
<span class="summary-value">${formatHoursDecimal(s.fund * 60)}h (${s.businessDays} dnů)</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="legend">
|
<div class="legend">
|
||||||
<span class="legend-item"><span class="legend-swatch weekend"></span>Víkend (So/Ne)</span>
|
<span class="legend-item"><span class="legend-swatch weekend"></span>Víkend (So/Ne)</span>
|
||||||
@@ -672,6 +553,9 @@ export function buildPrintHtml(
|
|||||||
}
|
}
|
||||||
.user-summary .summary-label { color: #555; }
|
.user-summary .summary-label { color: #555; }
|
||||||
.user-summary .summary-value { font-weight: 600; }
|
.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 {
|
.user-summary .summary-fund {
|
||||||
border-top: 1px solid #ddd; margin-top: 4px; padding-top: 6px;
|
border-top: 1px solid #ddd; margin-top: 4px; padding-top: 6px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ interface UserTotalData {
|
|||||||
prescas?: number;
|
prescas?: number;
|
||||||
svatek?: number;
|
svatek?: number;
|
||||||
worked_holiday_hours?: 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:
|
/** Fond "used" total mirrors the Fond footer line below:
|
||||||
@@ -293,6 +295,13 @@ export default function AttendanceAdmin() {
|
|||||||
label={`Nep: ${ut.unpaid_hours}h`}
|
label={`Nep: ${ut.unpaid_hours}h`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{ut.manko !== undefined && ut.manko > 0 && (
|
||||||
|
<StatusChip
|
||||||
|
color="error"
|
||||||
|
label={`Chybí: ${String(Math.round(ut.manko * 100) / 100).replace(".", ",")}h`}
|
||||||
|
title="Skutečně odpracované hodiny + schválené absence nedosahují fondu měsíce"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Fond usage */}
|
{/* Fond usage */}
|
||||||
|
|||||||
@@ -370,3 +370,142 @@ export const calculateFreeHolidayHours = (
|
|||||||
}
|
}
|
||||||
return free;
|
return free;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mzda rekapitulace (single source — the print AND the admin screen consume
|
||||||
|
// this, so the two can never drift again). Formulas are BINDING per the
|
||||||
|
// accountant-verified spec (2026-07, back-computed to the tenth of an hour
|
||||||
|
// against a real payslip — Dominik Vesecký 5/2026; the built-in dataset test
|
||||||
|
// in attendance-mzda-summary.test.ts pins every line). Do not "improve" them:
|
||||||
|
// fond = (Po–Pá dny, které NEJSOU svátek) × 8
|
||||||
|
// odpracováno = (kalendářní dny se záznamem Práce mimo svátek) × 8 — FLAT
|
||||||
|
// 8 h per day regardless of the shift's real length; real
|
||||||
|
// deviations settle monthly via Přesčas
|
||||||
|
// svátek = skutečné čisté hodiny odpracované ve dnech svátku
|
||||||
|
// dovolená = Σ leave_hours (evidence only, never from timestamps)
|
||||||
|
// přesčas = max(0, (worked_total + dovolená) − fond) [monthly]
|
||||||
|
// vč. svátků = odpracováno + přesčas (plain sum, NOT from timestamps)
|
||||||
|
// so/ne = čisté hodiny směn ZAČÍNAJÍCÍCH v So/Ne (celá směna)
|
||||||
|
// noc = průnik směny s 22:00–06:00 minus pauzy v pásmu; celá směna
|
||||||
|
// patří dni svého ZAČÁTKU (i přes hranici měsíce)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface MzdaSummary {
|
||||||
|
/** worked_total: all net worked hours (arrival→departure minus breaks). */
|
||||||
|
workedHoursRaw: number;
|
||||||
|
/** Odpracováno: non-holiday calendar days with a work record × 8 h flat. */
|
||||||
|
odpracovano: number;
|
||||||
|
/** Odpracováno včetně svátků a přesčasů = odpracovano + prescas. */
|
||||||
|
vcetneSvatku: number;
|
||||||
|
/** Dovolená: sum of vacation leave_hours (hour-based, partial days allowed). */
|
||||||
|
vacationHours: number;
|
||||||
|
/** Přesčas: max(0, (worked_total + dovolená) − fond) — monthly, never daily. */
|
||||||
|
prescas: number;
|
||||||
|
/** Svátek: net hours actually WORKED on public-holiday dates. */
|
||||||
|
svatekHours: number;
|
||||||
|
/** So/Ne: net hours of shifts whose START day is Sat/Sun (whole shift). */
|
||||||
|
weekendHours: number;
|
||||||
|
/** Práce v noci: minutes in 22:00–06:00 minus breaks in the window. */
|
||||||
|
nightMinutes: number;
|
||||||
|
/** Fond měsíce: Mon–Fri days that are NOT public holidays × 8 h. */
|
||||||
|
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. */
|
||||||
|
export const computeMzdaSummary = (
|
||||||
|
records: AttendanceRecord[],
|
||||||
|
monthStr: string,
|
||||||
|
): MzdaSummary => {
|
||||||
|
const [yearStr, monthNumStr] = monthStr.split("-");
|
||||||
|
const yr = parseInt(yearStr, 10);
|
||||||
|
const mo = parseInt(monthNumStr, 10);
|
||||||
|
|
||||||
|
const holidaySet = new Set(holidaysInMonth(yr, mo));
|
||||||
|
|
||||||
|
let workedMinutes = 0;
|
||||||
|
let svatekMinutes = 0;
|
||||||
|
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<string>();
|
||||||
|
|
||||||
|
for (const r of records) {
|
||||||
|
const lt = r.leave_type || "work";
|
||||||
|
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);
|
||||||
|
workedMinutes += m;
|
||||||
|
const day = normalizeDateStr(r.shift_date);
|
||||||
|
if (holidaySet.has(day)) {
|
||||||
|
svatekMinutes += m;
|
||||||
|
} else if (day) {
|
||||||
|
workDays.add(day);
|
||||||
|
}
|
||||||
|
if (isWeekendDate(day)) weekendMinutes += m;
|
||||||
|
nightMinutes += calculateNightMinutes(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fond: Mon–Fri days that are NOT public holidays × 8 h.
|
||||||
|
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) {
|
||||||
|
const dayStr = `${yr}-${String(mo).padStart(2, "0")}-${String(cur.getDate()).padStart(2, "0")}`;
|
||||||
|
if (!holidaySet.has(dayStr)) businessDays++;
|
||||||
|
}
|
||||||
|
cur.setDate(cur.getDate() + 1);
|
||||||
|
}
|
||||||
|
const fund = businessDays * 8;
|
||||||
|
|
||||||
|
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,
|
||||||
|
odpracovano,
|
||||||
|
vcetneSvatku: odpracovano + prescas,
|
||||||
|
vacationHours,
|
||||||
|
prescas,
|
||||||
|
svatekHours: svatekMinutes / 60,
|
||||||
|
weekendHours: weekendMinutes / 60,
|
||||||
|
nightMinutes,
|
||||||
|
fund,
|
||||||
|
businessDays,
|
||||||
|
sickHours,
|
||||||
|
unpaidHours,
|
||||||
|
manko,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user