Compare commits

...

4 Commits

Author SHA1 Message Date
BOHA
200aa0e1a8 chore(release): v2.4.44 — payroll recap per accountant spec
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 18:00:37 +02:00
BOHA
e3b414d22c fix(attendance): payroll recap per accountant spec; multi-shift day rows; shared computation
Rekapitulace (accountant notes, confirmed by owner 2026-07):
- Odpracováno = all worked hours EXCEPT hours worked on holidays
  (was floor(worked/8)×8 — a 3h day printed as 0h)
- Vč. svátků a přesčasů = ALL worked hours; line2 − line1 = Svátek
  (was worked + vacation − 8h×worked-holidays — could print less than
  actually worked, and vacation inflated it)
- Přesčas = max(0, worked − fond), month-level, never negative
  (was the sub-8h remainder + vacation — printed 3h overtime for a
  3h month, 8h overtime for pure vacation, and −6h when a holiday
  was worked)
- Svátek = hours actually WORKED on holiday dates
  (was 8h per UNworked holiday — opposite meaning)
- So/Ne, Práce v noci, Dovolená, Fond unchanged (verified correct)

Single shared computeMzdaSummary now feeds BOTH the print and the
admin screen (they had drifted: capped vs uncapped holiday adjustment).

Print day table: one row PER RECORD — days with two shifts (real in
prod data) silently dropped all but the last record from the table
while the totals counted them all.

+7 spec tests (accountant's own examples); print snapshot updated to
the new spec values.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 18:00:17 +02:00
BOHA
89656ac2c6 chore(release): v2.4.43 — timezone-proof attendance print
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 16:26:44 +02:00
BOHA
94030230e7 fix(attendance): timezone-proof print dates; overnight-only time prefixes
Two bugs in the attendance admin print (Tisk on Správa docházky):

1. Viewer-timezone date shift: day rows are built from date-only strings
   ('2026-06-01'), which new Date() parses as UTC midnight per spec; local
   formatting then rendered the PREVIOUS day for any viewer west of UTC —
   a June print opened with '31. 5. Neděle' (a May row holding June 1's
   punches) and every weekday name + weekend tint shifted by one. Czech
   viewers were unaffected (UTC+1/+2), US viewers always hit it.
   formatDate / getCzechWeekday / isWeekendDate now parse the calendar day
   from the string parts — identical output in every timezone.

2. Every time cell carried a bogus date prefix ('1.6. 08:00'): the
   overnight-only guard compared the extracted date part against the raw
   wire shift_date ('2026-06-01T02:00:00') — never equal. Now compares
   normalized date parts; the prefix fires only for true overnight punches.

Verified end-to-end against the production June payload; +8 regression
tests (timezone-proof by construction — parts-based, no UTC parse).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 16:26:23 +02:00
8 changed files with 462 additions and 218 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "app-ts",
"version": "2.4.42",
"version": "2.4.44",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "app-ts",
"version": "2.4.42",
"version": "2.4.44",
"license": "ISC",
"dependencies": {
"@anthropic-ai/sdk": "^0.102.0",

View File

@@ -1,6 +1,6 @@
{
"name": "app-ts",
"version": "2.4.42",
"version": "2.4.44",
"description": "",
"main": "dist/server.js",
"scripts": {

View File

@@ -337,7 +337,7 @@ exports[`buildUserSectionHtml > produces a stable per-user section (full output
</div>
<div class="summary-row">
<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 class="summary-row">
<span class="summary-label">Dovolená:</span>
@@ -345,7 +345,7 @@ exports[`buildUserSectionHtml > produces a stable per-user section (full output
</div>
<div class="summary-row">
<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">
<span class="summary-label">Svátek:</span>

View File

@@ -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 = MonFri (holidays included) × 8 h
* June 2026: 22 workdays, no holidays → fond 176.
* July 2026: 23 MonFri 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 MonFri 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<typeof work | typeof vacation>[] =
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(/<td>2\. 6\. 2026<\/td>/g) || [];
expect(rows.length).toBe(2);
});
});

View File

@@ -0,0 +1,77 @@
import { describe, it, expect } from "vitest";
import {
getCzechWeekday,
isWeekendDate,
formatTimeOrDatetimePrint,
} from "../admin/utils/attendanceHelpers";
import { formatDate } from "../admin/utils/formatters";
/**
* Regression: the attendance admin print builds its day rows from date-ONLY
* strings ("2026-06-01"). `new Date("YYYY-MM-DD")` is UTC midnight per the
* ECMAScript spec, so formatting it with local getters rendered the PREVIOUS
* day for viewers west of UTC — a June print opened with "31. 5. 2026 Neděle"
* (a May row) and every weekday name was shifted by one. The helpers now
* parse the calendar day from the string parts, which is timezone-proof by
* construction: these assertions hold in EVERY zone (on a machine west of
* UTC the old implementation fails them).
*/
describe("attendance print — timezone-proof date rendering", () => {
it("formatDate renders a date-only string as that exact calendar day", () => {
expect(formatDate("2026-06-01")).toBe("1. 6. 2026");
expect(formatDate("2026-06-30")).toBe("30. 6. 2026");
});
it("formatDate keeps naive-datetime (wire format) behavior", () => {
expect(formatDate("2026-06-01T02:00:00")).toBe("1. 6. 2026");
});
it("getCzechWeekday names the string's own calendar day", () => {
expect(getCzechWeekday("2026-06-01")).toBe("Pondělí");
expect(getCzechWeekday("2026-06-06")).toBe("Sobota");
expect(getCzechWeekday("2026-06-07")).toBe("Neděle");
// naive-datetime wire format resolves to the same day
expect(getCzechWeekday("2026-06-01T02:00:00")).toBe("Pondělí");
});
it("isWeekendDate flags the string's own calendar day", () => {
expect(isWeekendDate("2026-06-06")).toBe(true); // Saturday
expect(isWeekendDate("2026-06-07")).toBe(true); // Sunday
expect(isWeekendDate("2026-06-01")).toBe(false); // Monday
expect(isWeekendDate("2026-06-06T02:00:00")).toBe(true);
});
it("agrees with a locally-constructed date in the current zone (guards a regression to UTC parsing)", () => {
// Local construction is the ground truth for "calendar day" in any zone.
const local = new Date(2026, 5, 1, 12).toLocaleDateString("cs-CZ", {
weekday: "long",
});
const expected = local.charAt(0).toUpperCase() + local.slice(1);
expect(getCzechWeekday("2026-06-01")).toBe(expected);
});
});
describe("attendance print — overnight date prefix on time cells", () => {
// shift_date arrives as a naive datetime on the wire ("2026-06-01T02:00:00");
// the guard used to compare it raw against the extracted date part, so the
// overnight-only "d.m." prefix fired on EVERY cell ("1.6. 08:00").
const wireShiftDate = "2026-06-01T02:00:00";
it("same-day punch renders time only", () => {
expect(
formatTimeOrDatetimePrint("2026-06-01T08:00:00", wireShiftDate),
).toBe("08:00");
});
it("overnight punch keeps the date prefix", () => {
expect(
formatTimeOrDatetimePrint("2026-06-02T01:30:00", wireShiftDate),
).toBe("2.6. 01:30");
});
it("date-only shiftDate also matches", () => {
expect(formatTimeOrDatetimePrint("2026-06-01T08:00:00", "2026-06-01")).toBe(
"08:00",
);
});
});

View File

@@ -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<string, AttendanceRecord>();
// 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<string, AttendanceRecord[]>();
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(`<tr class="${rowClasses}">
<td>${escapeHtml(formatDate(dateStr))}</td>
<td>${escapeHtml(getCzechWeekday(dateStr))}</td>
@@ -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(`<tr class="${rowClasses}">
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(`<tr class="${rowClasses}">
<td>${escapeHtml(formatDate(dateStr))}</td>
<td>${escapeHtml(getCzechWeekday(dateStr))}</td>
<td>${escapeHtml(getLeaveTypeName(leaveType))}</td>
@@ -463,110 +400,48 @@ export function buildUserSectionHtml(
<td style="font-size:8px">${buildProjectLogsHtml(record)}</td>
<td>${escapeHtml(record.notes || "")}</td>
</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
// "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 = `<div class="user-summary">
<div class="summary-row">
<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 class="summary-row">
<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 class="summary-row">
<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 class="summary-row">
<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">
<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 class="summary-row">
<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 class="summary-row">
<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 class="summary-row summary-fund">
<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 class="legend">
<span class="legend-item"><span class="legend-swatch weekend"></span>Víkend (So/Ne)</span>

View File

@@ -173,7 +173,10 @@ export const formatTimeOrDatetimePrint = (
const timeDate = dateMatch
? `${dateMatch[1]}-${dateMatch[2]}-${dateMatch[3]}`
: "";
if (timeDate && timeDate !== shiftDate) {
// Compare DATE PARTS: shiftDate arrives as a naive datetime off the wire
// ("2026-06-01T02:00:00"), so a raw string compare never matched and the
// overnight-only date prefix fired on every single time cell.
if (timeDate && timeDate !== normalizeDateStr(shiftDate)) {
const datePart = `${parseInt(dateMatch![3])}.${parseInt(dateMatch![2])}.`;
return `${datePart} ${extractTime(datetime)}`;
}
@@ -203,9 +206,23 @@ export const calculateWorkMinutesPrint = (record: AttendanceRecord): number => {
// Print template helpers (mzda-style counting)
// ---------------------------------------------------------------------------
/**
* Parse the calendar day of a date-only or naive-datetime string as a LOCAL
* Date. `new Date("YYYY-MM-DD")` is UTC midnight per the ECMAScript spec —
* in timezones west of UTC it renders as the PREVIOUS day, so a US viewer's
* June print started with "31. 5. Neděle" and every weekday name was shifted.
* Building the Date from its string parts keeps the calendar day identical
* in every timezone.
*/
const localDayFromString = (dateStr: string): Date => {
const m = String(dateStr).match(/^(\d{4})-(\d{2})-(\d{2})/);
if (m) return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
return new Date(dateStr);
};
/** Returns the Czech weekday name with the first letter capitalized. */
export const getCzechWeekday = (dateStr: string): string => {
const wd = new Date(dateStr).toLocaleDateString("cs-CZ", {
const wd = localDayFromString(dateStr).toLocaleDateString("cs-CZ", {
weekday: "long",
});
return wd.charAt(0).toUpperCase() + wd.slice(1);
@@ -213,7 +230,7 @@ export const getCzechWeekday = (dateStr: string): string => {
/** True if the date is a Saturday or Sunday. */
export const isWeekendDate = (dateStr: string): boolean => {
const dow = new Date(dateStr).getDay();
const dow = localDayFromString(dateStr).getDay();
return dow === 0 || dow === 6;
};
@@ -353,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:0006:00 window. */
nightMinutes: number;
/** Fond měsíce: MonFri (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: MonFri × 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),
};
};

View File

@@ -33,7 +33,15 @@ export function formatMultiCurrency(
export function formatDate(dateStr: string | null | undefined): string {
if (!dateStr) return "—";
const d = new Date(dateStr);
// Date-ONLY strings ("2026-06-01") parse as UTC midnight per the spec, which
// renders as the PREVIOUS day in timezones west of UTC — a US viewer's June
// attendance print started with 31. 5. Parse the parts instead so the
// calendar day is identical in every timezone. Datetime strings keep the
// local-parse behavior (naive wire times are already local).
const m = String(dateStr).match(/^(\d{4})-(\d{2})-(\d{2})$/);
const d = m
? new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]))
: new Date(dateStr);
return d.toLocaleDateString("cs-CZ");
}