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>
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user