Files
app/src/admin/hooks/useAttendanceAdmin.ts
BOHA 1ee59b54bc chore(release): v2.4.35 — lint cleanup (68→0) + duplicate-Zavřít modal fix
UI fix:
- Close-only modals showing a redundant second close button now use
  hideCancel: ReceivedInvoices paid-detail modal (was two identical "Zavřít"
  buttons) and PlanCellModal "Den je součástí rozsahu".
- Add modal-duplicate-close test enforcing close-only modals set hideCancel.

Lint: cleared all 68 warnings → 0.
- preserve-caught-error: attach { cause } in ai.service / exchange-rates.
- no-require-imports: package.json version read via fs (APP_VERSION) instead
  of require(), avoiding a rootDir-expanding static JSON import.
- react-hooks/exhaustive-deps (11): ref-in-cleanup copies, derived-value
  useMemo wrapping, PlanGrid field extraction, stable nextKey useCallback,
  AuthContext documented cycle-break.
- no-explicit-any (53): precise route param/Prisma types, generic enrich*()
  preserving payload shape, minimal vite module type, frontend body/query-key
  types, SystemInfo for Settings.

Refactor (test enablement): shift-form types moved to dependency-free
shiftFormTypes.ts so the print-HTML builders are unit-testable without the
component graph; characterization test pins their output.

Gates: 649 tests pass, tsc -b clean, lint 0. Verified touched flows live
via Playwright (PlanWork CRUD + optimistic cache, warehouse form keys,
Settings system info, invoice detail).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:35:31 +02:00

1427 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useCallback, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query";
import apiFetch from "../utils/api";
import { isHoliday } from "../../utils/czech-holidays";
import {
calcProjectMinutesTotal,
calcFormWorkMinutes,
calculateWorkMinutes,
combineDatetime,
getDatePart,
getTimePart,
formatDate,
formatMinutes,
getLeaveTypeName,
formatTimeOrDatetimePrint,
calculateWorkMinutesPrint,
isWeekendDate,
getDaysInMonth,
shiftDateForMonth,
formatHoursDecimal,
calculateNightMinutes,
normalizeDateStr,
holidaysInMonth,
calculateFreeHolidayHours,
getCzechWeekday,
} from "../utils/attendanceHelpers";
import type {
ShiftFormData,
ProjectLog,
Project,
User,
} from "../components/shiftFormTypes";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface AlertContext {
alert: { success: (msg: string) => void; error: (msg: string) => void };
}
export interface AttendanceRecord {
id: number;
user_id: number;
shift_date: string;
leave_type?: string;
leave_hours?: number;
arrival_time?: string | null;
departure_time?: string | null;
break_start?: string | null;
break_end?: string | null;
notes?: string | null;
project_id?: number | null;
project_name?: string;
project_logs?: Array<{
id?: number;
project_id: number;
project_name?: string;
started_at?: string;
ended_at?: string | null;
hours?: string | number | null;
minutes?: string | number | null;
}>;
user_name?: string;
users?: {
id: number;
first_name: string;
last_name: string;
username: string;
};
}
interface ApiUser {
id: number;
first_name: string;
last_name: string;
username: string;
}
interface UserTotal {
name: string;
minutes: number;
working: boolean;
vacation_hours: number;
sick_hours: number;
unpaid_hours: number;
overtime: number;
missing: number;
fund: number | null;
business_days: number;
worked_hours: number;
covered: number;
// mzda-style summary fields (set by computeUserTotals)
worked_hours_raw?: number;
odpracovano?: number;
vcetne_svatku?: number;
prescas?: number;
svatek?: number;
weekend_hours?: number;
night_minutes?: number;
worked_holiday_hours?: number; // sum of hours worked ON a holiday
records?: AttendanceRecord[];
}
interface LeaveBalance {
vacation_total: number;
vacation_remaining: number;
}
interface BulkForm {
month: string;
user_ids: string[];
arrival_time: string;
departure_time: string;
break_start_time: string;
break_end_time: string;
}
interface AttendanceData {
records: AttendanceRecord[];
users: User[];
user_totals: Record<string, UserTotal>;
leave_balances: Record<string, LeaveBalance>;
}
interface DeleteConfirmState {
show: boolean;
record: AttendanceRecord | null;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const API_BASE = "/api/admin";
/**
* Compute per-user totals from raw attendance records.
* This replaces the server-side `user_totals` that the PHP backend returned.
*
* Adds mzda-style summary fields (odpracovano, vcetne_svatku, prescas, svatek,
* weekend_hours, night_minutes) and a fund computed as
* (rawBizDays + holidayCount) × 8 — holidays count as work days for fund
* purposes, per the mzda.pdf model.
*/
function computeUserTotals(
records: AttendanceRecord[],
userMap: Map<number, string>,
month: string,
): Record<string, UserTotal> {
const totals: Record<string, UserTotal> = {};
const recordsByUser = new Map<number, AttendanceRecord[]>();
for (const rec of records) {
const uid = String(rec.user_id);
if (!totals[uid]) {
const name =
userMap.get(rec.user_id) ??
(rec.users
? `${rec.users.first_name} ${rec.users.last_name}`.trim() ||
rec.users.username
: `User #${rec.user_id}`);
totals[uid] = {
name,
minutes: 0,
working: false,
vacation_hours: 0,
sick_hours: 0,
unpaid_hours: 0,
overtime: 0,
missing: 0,
fund: null,
business_days: 0,
worked_hours: 0,
covered: 0,
records: [],
};
}
const t = totals[uid];
t.records!.push(rec);
if (!recordsByUser.has(rec.user_id)) recordsByUser.set(rec.user_id, []);
recordsByUser.get(rec.user_id)!.push(rec);
const leaveType = rec.leave_type || "work";
if (leaveType === "work") {
// Only work records contribute to "minutes" (matching PHP calculateUserTotals)
t.minutes += calculateWorkMinutes({
...rec,
notes: rec.notes ?? undefined,
});
} else {
const leaveHours = Number(rec.leave_hours) || 8;
switch (leaveType) {
case "vacation":
t.vacation_hours += leaveHours;
break;
case "sick":
t.sick_hours += leaveHours;
break;
case "unpaid":
t.unpaid_hours += leaveHours;
break;
// "holiday" leave_type is no longer selectable in the UI — it is
// auto-computed from Czech public holidays (see freeHolidayHours
// below). Old records may still exist in the DB; treat them as a
// paid non-work entry and skip so they don't double-count with
// the auto-detected svátek.
}
}
// Track if user is currently working (has arrival but no departure)
if (rec.arrival_time && !rec.departure_time) {
t.working = true;
}
}
// 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);
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;
// Legacy fields (kept so existing UI doesn't break).
const workedHours = Math.round(workedHoursRaw * 10) / 10;
const covered = Math.round((workedHours + t.vacation_hours) * 10) / 10;
t.fund = fund;
t.business_days = rawBizDays;
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.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;
}
return totals;
}
// ---------------------------------------------------------------------------
// Print helpers
// ---------------------------------------------------------------------------
function escapeHtml(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
/** Shape of the print endpoint payload consumed by the HTML builders. */
interface PrintData {
month: string;
month_name: string;
selected_user_name?: string | null;
user_totals: Record<string, UserTotal>;
}
export function buildProjectLogsHtml(record: AttendanceRecord): string {
if (record.project_logs && record.project_logs.length > 0) {
return record.project_logs
.map((log) => {
let h: number, m: number;
if (log.hours !== null && log.hours !== undefined) {
// hours/minutes arrive as string or number — String() before parseInt
// (parseInt already coerces to string internally, so this is a no-op
// at runtime, it just satisfies parseInt's string parameter type).
h = parseInt(String(log.hours)) || 0;
m = parseInt(String(log.minutes)) || 0;
} else if (log.started_at && log.ended_at) {
const mins = Math.max(
0,
Math.floor(
(new Date(log.ended_at).getTime() -
new Date(log.started_at).getTime()) /
60000,
),
);
h = Math.floor(mins / 60);
m = mins % 60;
} else {
h = 0;
m = 0;
}
return `<div>${escapeHtml(log.project_name || `#${log.project_id}`)} (${h}:${String(m).padStart(2, "0")}h)</div>`;
})
.join("");
}
return escapeHtml(record.project_name || "—");
}
export function buildUserSectionHtml(
userId: string,
userData: UserTotal,
printData: PrintData,
): string {
// Build a date-keyed lookup of the user's records.
const recordsByDate = new Map<string, AttendanceRecord>();
for (const r of userData.records || []) {
recordsByDate.set(normalizeDateStr(r.shift_date), r);
}
// Iterate one row per day of the month.
const [yearStr, monthStr] = printData.month.split("-");
const yr = parseInt(yearStr, 10);
const mo = parseInt(monthStr, 10);
const daysInMonth = getDaysInMonth(yr, mo);
const userRecords = userData.records ?? [];
const rows: string[] = [];
for (let d = 1; d <= daysInMonth; d++) {
const dateStr = shiftDateForMonth(yr, mo, d);
const record = 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) {
rows.push(`<tr class="${rowClasses}">
<td>${escapeHtml(formatDate(dateStr))}</td>
<td>${escapeHtml(getCzechWeekday(dateStr))}</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr>`);
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))}`;
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>
<td class="text-center">${isLeave ? "—" : escapeHtml(formatTimeOrDatetimePrint(record.arrival_time, record.shift_date))}</td>
<td class="text-center">${breakCell}</td>
<td class="text-center">${isLeave ? "—" : escapeHtml(formatTimeOrDatetimePrint(record.departure_time, record.shift_date))}</td>
<td class="text-center">${hoursCell}</td>
<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;
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>
</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>
</div>
<div class="summary-row">
<span class="summary-label">Dovolená:</span>
<span class="summary-value">${formatHoursDecimal(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>
</div>
<div class="summary-row">
<span class="summary-label">Svátek:</span>
<span class="summary-value">${formatHoursDecimal(freeHolidayHours * 60)}h</span>
</div>
<div class="summary-row">
<span class="summary-label">So/Ne:</span>
<span class="summary-value">${formatHoursDecimal(weekendHoursRaw * 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>
</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>
</div>
<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 holiday"></span>Svátek</span>
<span class="legend-item"><span class="legend-swatch weekend-holiday"></span>Víkend + svátek</span>
<span class="legend-item"><span class="legend-swatch vacation"></span>Dovolená</span>
</div>
</div>`;
return `<div class="user-section">
<div class="user-header">
<h3>${escapeHtml(userData.name)}</h3>
<span class="total">Odpracováno: ${escapeHtml(formatMinutes(userData.minutes))} h</span>
</div>
<table>
<thead><tr>
<th style="width:55px">Datum</th>
<th style="width:55px">Den</th>
<th style="width:60px">Typ</th>
<th class="text-center" style="width:55px">Příchod</th>
<th class="text-center" style="width:80px">Pauza</th>
<th class="text-center" style="width:55px">Odchod</th>
<th class="text-center" style="width:85px">Hodiny</th>
<th>Projekty</th>
<th>Poznámka</th>
</tr></thead>
<tbody>${rows.join("")}</tbody>
</table>
${summaryHtml}
</div>`;
}
export function buildPrintHtml(
pData: PrintData,
userSections: string,
emptyMsg: string,
filterNote: string,
companyName: string,
): string {
return `<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Docházka - ${escapeHtml(pData.month_name)}</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
font-size: 11px; line-height: 1.4; color: #000; background: #fff; padding: 15mm;
}
.print-header {
display: flex; justify-content: space-between; align-items: flex-start;
margin-bottom: 20px; padding-bottom: 15px; border-bottom: 2px solid #333;
}
.print-header-left { display: flex; align-items: center; gap: 12px; }
.print-logo { height: 40px; width: auto; }
.print-header-text { text-align: left; }
.print-header-right { text-align: right; }
.print-header h1 { font-size: 18px; font-weight: 700; margin-bottom: 3px; }
.print-header .company { font-size: 11px; color: #666; }
.print-header .period { font-size: 13px; font-weight: 600; color: #333; margin-bottom: 2px; }
.print-header .filters { font-size: 10px; color: #666; }
.print-header .generated { font-size: 9px; color: #888; margin-top: 5px; }
.user-section { margin-bottom: 25px; page-break-inside: avoid; }
.user-header {
background: #f5f5f5; border: 1px solid #ddd; padding: 10px 15px;
margin-bottom: 10px; display: flex; justify-content: space-between; align-items: center;
}
.user-header h3 { font-size: 13px; font-weight: 600; }
.user-header .total { font-size: 12px; font-weight: 600; }
.user-section table { width: 100%; border-collapse: collapse; margin-bottom: 15px; }
.user-section th, .user-section td { border: 1px solid #333; padding: 6px 8px; text-align: left; }
.user-section th { background: #333; color: #fff; font-weight: 600; font-size: 10px; text-transform: uppercase; }
.user-section td { font-size: 10px; }
.user-section tr:nth-child(even) { background: #f9f9f9; }
/* Weekend and holiday row highlighting — must come after the
:nth-child(even) rule and use higher specificity (tr.X td) to win. */
.user-section tr.weekend td { background: #fde68a; }
.user-section tr.holiday td { background: #bbf7d0; }
.user-section tr.weekend.holiday td { background: #fcd34d; }
.user-section tr.vacation td { background: #bfdbfe; }
.text-center { text-align: center; }
.text-right { text-align: right; }
.leave-badge { display: inline-block; padding: 2px 6px; border-radius: 3px; font-size: 9px; font-weight: 500; }
.badge-vacation { background: #dbeafe; color: #1d4ed8; }
.badge-sick { background: #fee2e2; color: #dc2626; }
.badge-holiday { background: #dcfce7; color: #16a34a; }
.badge-unpaid { background: #f3f4f6; color: #6b7280; }
.badge-overtime { background: #fef3c7; color: #d97706; }
.user-summary {
margin-top: 10px; padding: 10px 15px;
background: #f9f9f9; border: 1px solid #ddd; font-size: 10px;
}
.user-summary .summary-row {
display: flex; justify-content: space-between; padding: 2px 0;
}
.user-summary .summary-label { color: #555; }
.user-summary .summary-value { font-weight: 600; }
.user-summary .summary-fund {
border-top: 1px solid #ddd; margin-top: 4px; padding-top: 6px;
}
.legend {
display: flex; flex-wrap: wrap; gap: 14px;
margin-top: 8px; padding-top: 6px;
border-top: 1px dashed #ddd;
font-size: 9px; color: #555;
}
.legend-item { display: inline-flex; align-items: center; gap: 5px; }
.legend-swatch {
display: inline-block; width: 12px; height: 12px;
border: 1px solid #333; vertical-align: middle;
}
.legend-swatch.weekend { background: #fde68a; }
.legend-swatch.holiday { background: #bbf7d0; }
.legend-swatch.weekend-holiday { background: #fcd34d; }
.legend-swatch.vacation { background: #bfdbfe; }
.print-wrapper-table { width: 100%; border-collapse: collapse; border: none; }
.print-wrapper-table > thead > tr > td,
.print-wrapper-table > tbody > tr > td { padding: 0; border: none; background: none; }
@media print {
body { padding: 0; margin: 0; }
@page { size: A4 portrait; margin: 10mm; }
.user-section { page-break-inside: avoid; }
}
</style>
</head>
<body>
<table class="print-wrapper-table">
<thead><tr><td>
<div class="print-header">
<div class="print-header-left">
<img src="/api/admin/company-settings/logo?variant=light" alt="" class="print-logo" />
<div class="print-header-text">
<h1>EVIDENCE DOCHÁZKY</h1>
<div class="company">${escapeHtml(companyName)}</div>
</div>
</div>
<div class="print-header-right">
<div class="period">${escapeHtml(pData.month_name)}</div>
${filterNote}
<div class="generated">Vygenerováno: ${new Date().toLocaleString("cs-CZ")}</div>
</div>
</div>
</td></tr></thead>
<tbody><tr><td>
${userSections}
${emptyMsg}
</td></tr></tbody>
</table>
</body>
</html>`;
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
export default function useAttendanceAdmin({ alert }: AlertContext) {
const queryClient = useQueryClient();
// ---- Core state ----
const [loading, setLoading] = useState(true);
const [month, setMonth] = useState(() => {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
});
const [filterUserId, setFilterUserId] = useState("");
const [data, setData] = useState<AttendanceData>({
records: [],
users: [],
user_totals: {},
leave_balances: {},
});
// ---- Bulk modal ----
const [showBulkModal, setShowBulkModal] = useState(false);
const [bulkSubmitting, setBulkSubmitting] = useState(false);
const [bulkForm, setBulkForm] = useState<BulkForm>({
month: "",
user_ids: [],
arrival_time: "08:00",
departure_time: "16:30",
break_start_time: "12:00",
break_end_time: "12:30",
});
// ---- Create modal ----
const [showCreateModal, setShowCreateModal] = useState(false);
const now = new Date();
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
const [createForm, setCreateForm] = useState<ShiftFormData>({
user_id: "",
shift_date: today,
leave_type: "work",
leave_hours: 8,
arrival_date: today,
arrival_time: "",
break_start_date: today,
break_start_time: "",
break_end_date: today,
break_end_time: "",
departure_date: today,
departure_time: "",
notes: "",
});
// ---- Edit modal ----
const [showEditModal, setShowEditModal] = useState(false);
const [editingRecord, setEditingRecord] = useState<AttendanceRecord | null>(
null,
);
const [editForm, setEditForm] = useState<ShiftFormData>({
user_id: "",
shift_date: "",
leave_type: "work",
leave_hours: 8,
arrival_date: "",
arrival_time: "",
break_start_date: "",
break_start_time: "",
break_end_date: "",
break_end_time: "",
departure_date: "",
departure_time: "",
notes: "",
});
// ---- Delete ----
const [deleteConfirm, setDeleteConfirm] = useState<DeleteConfirmState>({
show: false,
record: null,
});
// ---- Projects ----
const [projectList, setProjectList] = useState<Project[]>([]);
const [createProjectLogs, setCreateProjectLogs] = useState<ProjectLog[]>([]);
const [editProjectLogs, setEditProjectLogs] = useState<ProjectLog[]>([]);
// ---- Print ref (kept for API compat) ----
const printRef = useRef<HTMLDivElement | null>(null);
// ---- Ref to hold full user list for user_totals computation ----
const usersRef = useRef<Map<number, string>>(new Map());
// =========================================================================
// Load projects once
// =========================================================================
useEffect(() => {
const loadProjects = async () => {
try {
const response = await apiFetch(
`${API_BASE}/attendance?action=projects`,
);
const result = await response.json();
if (result.success)
setProjectList(result.data?.projects ?? result.data ?? []);
} catch {
/* silent */
}
};
loadProjects();
}, []);
// =========================================================================
// Load users once
// =========================================================================
useEffect(() => {
const loadUsers = async () => {
try {
const response = await apiFetch(
`${API_BASE}/attendance?action=attendance_users`,
);
const result = await response.json();
if (result.success) {
const apiUsers: ApiUser[] = result.data;
const mapped: User[] = apiUsers.map((u) => ({
id: u.id,
name: `${u.first_name} ${u.last_name}`.trim() || u.username,
}));
const nameMap = new Map<number, string>();
for (const u of mapped) nameMap.set(u.id as number, u.name);
usersRef.current = nameMap;
setData((prev) => ({ ...prev, users: mapped }));
}
} catch {
/* silent */
}
};
loadUsers();
}, []);
// =========================================================================
// Fetch attendance records + leave balances
// =========================================================================
const fetchData = useCallback(
async (showLoading = true) => {
if (showLoading) setLoading(true);
try {
const [yearStr, monthStr] = month.split("-");
// The KPI cards + summary totals are computed from THIS fetch — it
// must cover the complete month (the server allows limit up to 2000
// for exactly this view; 2000 ≈ 64 employees × 31 days).
let recordsUrl = `${API_BASE}/attendance?year=${yearStr}&month=${monthStr}&limit=2000`;
if (filterUserId) recordsUrl += `&user_id=${filterUserId}`;
const [recordsResponse, balancesResponse] = await Promise.all([
apiFetch(recordsUrl),
apiFetch(`${API_BASE}/attendance?action=balances&year=${yearStr}`),
]);
if (recordsResponse.status === 401) return;
const recordsResult = await recordsResponse.json();
const balancesResult = await balancesResponse.json();
const records: AttendanceRecord[] = recordsResult.success
? Array.isArray(recordsResult.data)
? recordsResult.data
: []
: [];
// balancesResult.data is { users: [...], balances: { uid: {...} } }
const balancesObj = balancesResult.success ? balancesResult.data : {};
const leaveBalances: Record<string, LeaveBalance> =
balancesObj?.balances ?? balancesObj ?? {};
const userTotals = computeUserTotals(records, usersRef.current, month);
setData((prev) => ({
...prev,
records,
user_totals: userTotals,
leave_balances: leaveBalances,
}));
} catch {
alert.error("Nepodařilo se načíst data");
} finally {
if (showLoading) setLoading(false);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[month, filterUserId],
);
// Initial load with skeleton, filter changes without skeleton
const initialLoadDone = useRef(false);
useEffect(() => {
if (!initialLoadDone.current) {
initialLoadDone.current = true;
fetchData(true);
} else {
fetchData(false);
}
}, [fetchData]);
// =========================================================================
// Validation helper
// =========================================================================
const validateProjectLogs = (
logs: ProjectLog[],
formData: ShiftFormData,
): boolean => {
const totalWork = calcFormWorkMinutes(formData);
const totalProject = calcProjectMinutesTotal(
logs.map((l) => ({
...l,
project_id:
l.project_id !== "" && l.project_id != null
? Number(l.project_id)
: undefined,
})),
);
if (totalWork > 0 && totalProject !== totalWork) {
const wH = Math.floor(totalWork / 60);
const wM = totalWork % 60;
const pH = Math.floor(totalProject / 60);
const pM = totalProject % 60;
alert.error(
`Součet hodin projektů (${pH}h ${pM}m) neodpovídá odpracovanému času (${wH}h ${wM}m)`,
);
return false;
}
return true;
};
// =========================================================================
// Create modal
// =========================================================================
const openCreateModal = () => {
const d = new Date();
const todayDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
setCreateForm({
user_id: "",
shift_date: todayDate,
leave_type: "work",
leave_hours: 8,
arrival_date: todayDate,
arrival_time: "",
break_start_date: todayDate,
break_start_time: "",
break_end_date: todayDate,
break_end_time: "",
departure_date: todayDate,
departure_time: "",
notes: "",
});
setCreateProjectLogs([]);
setShowCreateModal(true);
};
const handleCreateShiftDateChange = (newDate: string) => {
setCreateForm((prev) => ({
...prev,
shift_date: newDate,
arrival_date: newDate,
break_start_date: newDate,
break_end_date: newDate,
departure_date: newDate,
}));
};
const handleCreateSubmit = async () => {
if (!createForm.user_id || !createForm.shift_date) {
alert.error("Vyplňte zaměstnance a datum směny");
return;
}
const filteredCreateLogs = createProjectLogs.filter((l) => l.project_id);
if (filteredCreateLogs.length > 0 && createForm.leave_type === "work") {
if (!validateProjectLogs(filteredCreateLogs, createForm)) return;
}
try {
const isLeave = createForm.leave_type !== "work";
const payload: Record<string, unknown> = {
user_id: Number(createForm.user_id),
shift_date: createForm.shift_date,
leave_type: createForm.leave_type,
notes: createForm.notes || null,
};
if (isLeave) {
payload.leave_hours = createForm.leave_hours || 8;
payload.arrival_time = null;
payload.departure_time = null;
payload.break_start = null;
payload.break_end = null;
} else {
payload.arrival_time = combineDatetime(
createForm.arrival_date,
createForm.arrival_time,
);
payload.departure_time = combineDatetime(
createForm.departure_date,
createForm.departure_time,
);
payload.break_start = combineDatetime(
createForm.break_start_date,
createForm.break_start_time,
);
payload.break_end = combineDatetime(
createForm.break_end_date,
createForm.break_end_time,
);
}
if (filteredCreateLogs.length > 0 && createForm.leave_type === "work") {
payload.project_logs = filteredCreateLogs;
}
const response = await apiFetch(`${API_BASE}/attendance`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const result = await response.json();
if (result.success) {
setShowCreateModal(false);
queryClient.invalidateQueries({ queryKey: ["attendance"] });
// The dashboard embeds attendance (Přítomní dnes / Docházka dnes /
// punch-button state) — without this it serves a stale cache for up
// to its staleTime after records change here.
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
await fetchData(false);
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(
result.message || result.data?.message || "Záznam vytvořen",
);
} else {
alert.error(result.error || "Nepodařilo se vytvořit záznam");
}
} catch {
alert.error("Chyba připojení");
}
};
// =========================================================================
// Bulk modal
// =========================================================================
const openBulkModal = () => {
setBulkForm({
month,
user_ids: data.users.map((u) => String(u.id)),
arrival_time: "08:00",
departure_time: "16:30",
break_start_time: "12:00",
break_end_time: "12:30",
});
setShowBulkModal(true);
};
const toggleBulkUser = (userId: number | string) => {
const uid = String(userId);
setBulkForm((prev) => ({
...prev,
user_ids: prev.user_ids.includes(uid)
? prev.user_ids.filter((u) => u !== uid)
: [...prev.user_ids, uid],
}));
};
const toggleAllBulkUsers = () => {
const allIds = data.users.map((u) => String(u.id));
setBulkForm((prev) => ({
...prev,
user_ids: prev.user_ids.length === allIds.length ? [] : allIds,
}));
};
const handleBulkSubmit = async () => {
if (!bulkForm.month) {
alert.error("Vyberte měsíc");
return;
}
if (bulkForm.user_ids.length === 0) {
alert.error("Vyberte alespoň jednoho zaměstnance");
return;
}
setBulkSubmitting(true);
try {
const response = await apiFetch(
`${API_BASE}/attendance?action=bulk_attendance`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(bulkForm),
},
);
const result = await response.json();
if (result.success) {
setShowBulkModal(false);
queryClient.invalidateQueries({ queryKey: ["attendance"] });
// The dashboard embeds attendance (Přítomní dnes / Docházka dnes /
// punch-button state) — without this it serves a stale cache for up
// to its staleTime after records change here.
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
await fetchData(false);
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(
result.message || result.data?.message || "Záznamy vytvořeny",
);
} else {
alert.error(result.error || "Nepodařilo se vytvořit záznamy");
}
} catch {
alert.error("Chyba připojení");
} finally {
setBulkSubmitting(false);
}
};
// =========================================================================
// Edit modal
// =========================================================================
const openEditModal = (record: AttendanceRecord) => {
// Enrich record with user_name for the modal subtitle
const userName = record.users
? `${record.users.first_name} ${record.users.last_name}`.trim() ||
record.users.username
: ((record as unknown as Record<string, unknown>).user_name as string) ||
`User #${record.user_id}`;
const enriched = { ...record, user_name: userName };
setEditingRecord(enriched);
const shiftDate = getDatePart(record.shift_date) || record.shift_date;
setEditForm({
user_id: String(record.user_id),
shift_date: shiftDate,
leave_type: record.leave_type || "work",
leave_hours: Number(record.leave_hours) || 8,
arrival_date: getDatePart(record.arrival_time) || shiftDate,
arrival_time: getTimePart(record.arrival_time),
break_start_date: getDatePart(record.break_start) || shiftDate,
break_start_time: getTimePart(record.break_start),
break_end_date: getDatePart(record.break_end) || shiftDate,
break_end_time: getTimePart(record.break_end),
departure_date: getDatePart(record.departure_time) || shiftDate,
departure_time: getTimePart(record.departure_time),
notes: record.notes || "",
});
const logs: ProjectLog[] = (record.project_logs || []).map((l) => {
if (l.hours !== null && l.hours !== undefined) {
return {
project_id: String(l.project_id),
hours: String(l.hours),
minutes: String(l.minutes || 0),
};
}
if (l.started_at && l.ended_at) {
const mins = Math.max(
0,
Math.floor(
(new Date(l.ended_at).getTime() -
new Date(l.started_at).getTime()) /
60000,
),
);
return {
project_id: String(l.project_id),
hours: String(Math.floor(mins / 60)),
minutes: String(mins % 60),
};
}
return { project_id: String(l.project_id), hours: "", minutes: "" };
});
setEditProjectLogs(logs);
setShowEditModal(true);
};
const handleEditSubmit = async () => {
if (!editingRecord) return;
const isWork = (editForm.leave_type || "work") === "work";
const filteredEditLogs = isWork
? editProjectLogs.filter((l) => l.project_id)
: [];
if (filteredEditLogs.length > 0) {
if (!validateProjectLogs(filteredEditLogs, editForm)) return;
}
try {
const isLeave = editForm.leave_type !== "work";
const payload: Record<string, unknown> = {
leave_type: editForm.leave_type,
notes: editForm.notes || null,
};
if (isLeave) {
payload.leave_hours = editForm.leave_hours || 8;
payload.arrival_time = null;
payload.departure_time = null;
payload.break_start = null;
payload.break_end = null;
} else {
payload.arrival_time = combineDatetime(
editForm.arrival_date,
editForm.arrival_time,
);
payload.departure_time = combineDatetime(
editForm.departure_date,
editForm.departure_time,
);
payload.break_start = combineDatetime(
editForm.break_start_date,
editForm.break_start_time,
);
payload.break_end = combineDatetime(
editForm.break_end_date,
editForm.break_end_time,
);
}
if (filteredEditLogs.length > 0) {
payload.project_logs = filteredEditLogs;
}
const response = await apiFetch(
`${API_BASE}/attendance/${editingRecord.id}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
);
const result = await response.json();
if (result.success) {
setShowEditModal(false);
queryClient.invalidateQueries({ queryKey: ["attendance"] });
// The dashboard embeds attendance (Přítomní dnes / Docházka dnes /
// punch-button state) — without this it serves a stale cache for up
// to its staleTime after records change here.
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
await fetchData(false);
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(
result.message || result.data?.message || "Záznam aktualizován",
);
} else {
alert.error(result.error || "Nepodařilo se uložit");
}
} catch {
alert.error("Chyba připojení");
}
};
// =========================================================================
// Delete
// =========================================================================
const handleDelete = async () => {
if (!deleteConfirm.record) return;
try {
const response = await apiFetch(
`${API_BASE}/attendance/${deleteConfirm.record.id}`,
{ method: "DELETE" },
);
const result = await response.json();
if (result.success) {
setDeleteConfirm({ show: false, record: null });
queryClient.invalidateQueries({ queryKey: ["attendance"] });
// The dashboard embeds attendance (Přítomní dnes / Docházka dnes /
// punch-button state) — without this it serves a stale cache for up
// to its staleTime after records change here.
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
await fetchData(false);
alert.success(
result.message || result.data?.message || "Záznam smazán",
);
} else {
alert.error(result.error || "Nepodařilo se smazat");
}
} catch {
alert.error("Chyba připojení");
}
};
// =========================================================================
// Print
// =========================================================================
const handlePrint = async () => {
try {
const [response, settingsRes] = await Promise.all([
apiFetch(
`${API_BASE}/attendance?action=print&month=${month}${filterUserId ? `&user_id=${filterUserId}` : ""}`,
),
apiFetch(`${API_BASE}/company-settings`),
]);
const settingsData = await settingsRes.json();
const companyName = settingsData.success
? settingsData.data.company_name || ""
: "";
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
const pData = result.data;
const userSections = Object.entries(pData.user_totals)
.map(([uid, uData]) =>
buildUserSectionHtml(uid, uData as UserTotal, pData),
)
.join("");
const emptyMsg =
Object.keys(pData.user_totals).length === 0
? '<p style="text-align:center;padding:20px">Za vybrané období nejsou žádné záznamy.</p>'
: "";
const filterNote = pData.selected_user_name
? `<div class="filters">Zaměstnanec: ${escapeHtml(pData.selected_user_name)}</div>`
: "";
const bodyContent = buildPrintHtml(
pData,
userSections,
emptyMsg,
filterNote,
companyName,
);
const printWindow = window.open("", "_blank");
if (printWindow) {
printWindow.document.open();
printWindow.document.write(bodyContent);
printWindow.document.close();
printWindow.addEventListener("load", () => printWindow.print(), {
once: true,
});
}
}
} catch {
alert.error("Nepodařilo se připravit tisk");
}
};
// =========================================================================
// Derived
// =========================================================================
const hasData = Object.keys(data.user_totals).length > 0;
// =========================================================================
// Public API
// =========================================================================
return {
loading,
month,
setMonth,
filterUserId,
setFilterUserId,
data,
hasData,
showBulkModal,
setShowBulkModal,
bulkSubmitting,
bulkForm,
setBulkForm,
showCreateModal,
setShowCreateModal,
createForm,
setCreateForm,
showEditModal,
setShowEditModal,
editingRecord,
editForm,
setEditForm,
deleteConfirm,
setDeleteConfirm,
projectList,
createProjectLogs,
setCreateProjectLogs,
editProjectLogs,
setEditProjectLogs,
printRef,
openCreateModal,
handleCreateShiftDateChange,
handleCreateSubmit,
openBulkModal,
toggleBulkUser,
toggleAllBulkUsers,
handleBulkSubmit,
openEditModal,
handleEditSubmit,
handleDelete,
handlePrint,
};
}