import { useState, useMemo, useRef } from "react"; import { useQuery } from "@tanstack/react-query"; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; import { useAuth } from "../context/AuthContext"; import Forbidden from "../components/Forbidden"; import { companySettingsOptions } from "../lib/queries/settings"; import { iconBadgeSx } from "../theme"; import { attendanceHistoryOptions, type AttendanceRecord, } from "../lib/queries/attendance"; import { formatDate, formatDatetime, formatTime, calculateWorkMinutes, formatMinutes, getLeaveTypeName, getLeaveTypeBadgeClass, calculateWorkMinutesPrint, formatTimeOrDatetimePrint, calculateFreeHolidayHours, holidaysInMonth, normalizeDateStr, formatHoursDecimal, } from "../utils/attendanceHelpers"; import { Button, Card, DataTable, FilterBar, MonthField, ProgressBar, StatusChip, PageHeader, PageEnter, EmptyState, LoadingState, type DataColumn, } from "../ui"; const MONTH_NAMES = [ "Leden", "Únor", "Březen", "Duben", "Květen", "Červen", "Červenec", "Srpen", "Září", "Říjen", "Listopad", "Prosinec", ]; const PrintIcon = ( ); const formatBreakRange = (record: AttendanceRecord): string => { if (record.break_start && record.break_end) { return `${formatTime(record.break_start)} - ${formatTime(record.break_end)}`; } if (record.break_start) { return `${formatTime(record.break_start)} - ?`; } return "—"; }; function getEasterSunday(year: number): string { const a = year % 19; const b = Math.floor(year / 100); const c = year % 100; const d = Math.floor(b / 4); const e = b % 4; const f = Math.floor((b + 8) / 25); const g = Math.floor((b - f + 1) / 3); const h = (19 * a + b - d - g + 15) % 30; const i = Math.floor(c / 4); const k = c % 4; const l = (32 + 2 * e + 2 * i - h - k) % 7; const m = Math.floor((a + 11 * h + 22 * l) / 451); const month = Math.floor((h + l - 7 * m + 114) / 31); const day = ((h + l - 7 * m + 114) % 31) + 1; return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; } function getCzechHolidays(year: number): string[] { const y = String(year); const holidays = [ `${y}-01-01`, `${y}-05-01`, `${y}-05-08`, `${y}-07-05`, `${y}-07-06`, `${y}-09-28`, `${y}-10-28`, `${y}-11-17`, `${y}-12-24`, `${y}-12-25`, `${y}-12-26`, ]; const easterSunday = getEasterSunday(year); const easterDate = new Date(easterSunday); const goodFriday = new Date(easterDate); goodFriday.setDate(goodFriday.getDate() - 2); const easterMonday = new Date(easterDate); easterMonday.setDate(easterMonday.getDate() + 1); const pad = (n: number) => String(n).padStart(2, "0"); holidays.push( `${goodFriday.getFullYear()}-${pad(goodFriday.getMonth() + 1)}-${pad(goodFriday.getDate())}`, ); holidays.push( `${easterMonday.getFullYear()}-${pad(easterMonday.getMonth() + 1)}-${pad(easterMonday.getDate())}`, ); holidays.sort(); return holidays; } function getBusinessDaysInMonth(year: number, month: number): number { const holidays = getCzechHolidays(year); let count = 0; const daysInMonth = new Date(year, month + 1, 0).getDate(); for (let day = 1; day <= daysInMonth; day++) { const date = new Date(year, month, day); const dow = date.getDay(); if (dow !== 0 && dow !== 6) { const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`; if (!holidays.includes(dateStr)) { count++; } } } return count; } const renderProjectCell = (record: AttendanceRecord) => { if (record.project_logs && record.project_logs.length > 0) { return ( {record.project_logs.map((log, i) => { let h: number, m: number, isActive = false; if (log.hours !== null && log.hours !== undefined) { h = parseInt(String(log.hours)) || 0; m = parseInt(String(log.minutes)) || 0; } else { isActive = !log.ended_at; const end = log.ended_at ? new Date(log.ended_at) : new Date(); const mins = Math.max( 0, Math.floor( (end.getTime() - new Date(log.started_at!).getTime()) / 60000, ), ); h = Math.floor(mins / 60); m = mins % 60; } return ( ); })} ); } if (record.project_name) { return ( ); } return "—"; }; export default function AttendanceHistory() { const { user, hasPermission } = useAuth(); const { data: companySettings } = useQuery(companySettingsOptions()); const companyName = companySettings?.company_name || ""; const printRef = useRef(null); const [month, setMonth] = useState(() => { const now = new Date(); return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`; }); const { data, isPending } = useQuery( attendanceHistoryOptions({ month, userId: user?.id }), ); const records = useMemo(() => data ?? [], [data]); const computed = useMemo(() => { const [yearStr, monthStr] = month.split("-"); const monthIndex = parseInt(monthStr, 10) - 1; const monthName = `${MONTH_NAMES[monthIndex]} ${yearStr}`; let totalMinutes = 0; let vacationHours = 0; let sickHours = 0; let unpaidHours = 0; for (const record of records) { const leaveType = record.leave_type || "work"; if (leaveType === "work") { totalMinutes += calculateWorkMinutes(record); } else { const hours = record.leave_hours != null ? Number(record.leave_hours) : 8; if (leaveType === "vacation") vacationHours += hours; else if (leaveType === "sick") sickHours += hours; else if (leaveType === "unpaid") unpaidHours += hours; // "holiday" is no longer a user-selectable leave_type — it is // auto-computed from Czech public holidays by the print/KPI code. // Old records may still exist; skip so they don't double-count. } } const yr = parseInt(yearStr, 10); const mo = parseInt(monthStr, 10) - 1; const businessDays = getBusinessDaysInMonth(yr, mo); // Fund counts Mon-Fri + holidays (holidays are paid via the fond). const holidayDates = holidaysInMonth(yr, mo + 1); const holidayCount = holidayDates.length; const fund = (businessDays + holidayCount) * 8; const worked = Math.round((totalMinutes / 60) * 100) / 100; // Covered = worked + vacation + sick (NOT unpaid — unpaid is voluntary). const leaveHours = vacationHours + sickHours; const covered = Math.round((worked + leaveHours) * 100) / 100; // fondUsed = real_worked + vacation + worked_holiday + free_holiday. // (the all-in number: everything the user got paid for this month). const realWorked = totalMinutes / 60; const freeHolidayHours = calculateFreeHolidayHours( records as unknown as Parameters[0], holidayDates, ); const workedHolidayHours = records .filter( (r) => (r.leave_type || "work") === "work" && holidayDates.includes(normalizeDateStr(r.shift_date)), ) .reduce((sum, r) => sum + calculateWorkMinutesPrint(r) / 60, 0); const fondUsed = realWorked + vacationHours + workedHolidayHours + freeHolidayHours; const delta = fondUsed - fund; const missing = Math.max(0, Math.round(-delta * 100) / 100); const overtime = Math.max(0, Math.round(delta * 100) / 100); const remaining = missing; const monthlyFund = { fund, business_days: businessDays, holiday_count: holidayCount, worked: Math.round(fondUsed * 100) / 100, covered, remaining, overtime, }; return { monthName, totalMinutes, vacationHours, sickHours, unpaidHours, monthlyFund, fondUsed, freeHolidayHours, workedHolidayHours, delta, }; }, [records, month]); if (!hasPermission("attendance.history")) return ; const handlePrint = () => { if (!printRef.current) return; const content = printRef.current.innerHTML; const printWindow = window.open("", "_blank"); if (!printWindow) return; printWindow.document.write(` Docházka - ${computed.monthName} ${content} `); printWindow.document.close(); printWindow.onload = () => { printWindow.print(); }; }; const fund = computed.monthlyFund; const progressValue = fund.fund > 0 ? Math.min(100, (fund.worked / fund.fund) * 100) : 0; const progressColor = computed.delta > 0 ? "warning" : computed.delta >= 0 ? "success" : "primary"; const deltaColor = computed.delta > 0 ? "warning.main" : computed.delta < 0 ? "error.main" : "success.main"; const columns: DataColumn[] = [ { key: "date", header: "Datum", width: "10%", mono: true, render: (record) => formatDate(record.shift_date), }, { key: "type", header: "Typ", width: "11%", render: (record) => { const leaveType = record.leave_type || "work"; return ( ); }, }, { key: "arrival", header: "Příchod", width: "10%", mono: true, render: (record) => { const isLeave = (record.leave_type || "work") !== "work"; return isLeave ? "—" : formatDatetime(record.arrival_time); }, }, { key: "break", header: "Pauza", width: "11%", mono: true, render: (record) => { const isLeave = (record.leave_type || "work") !== "work"; return isLeave ? "—" : formatBreakRange(record); }, }, { key: "departure", header: "Odchod", width: "10%", mono: true, render: (record) => { const isLeave = (record.leave_type || "work") !== "work"; return isLeave ? "—" : formatDatetime(record.departure_time); }, }, { key: "hours", header: "Hodiny", width: "9%", mono: true, render: (record) => { const leaveType = record.leave_type || "work"; const isLeave = leaveType !== "work"; const workMinutes = isLeave ? (Number(record.leave_hours) || 8) * 60 : calculateWorkMinutes(record); return workMinutes > 0 ? formatMinutes(workMinutes, true) : "—"; }, }, { key: "projects", header: "Projekty", width: "18%", render: (record) => renderProjectCell(record), }, { key: "notes", header: "Poznámka", width: "11%", render: (record) => record.notes || "", }, ]; return ( 0 ? ( ) : undefined } /> {/* Filters */} setMonth(val)} /> {/* Monthly Fund Card */} {isPending ? ( ) : ( Fond: {fund.worked}h / {fund.fund}h {fund.business_days} prac. dnů {fund.holiday_count > 0 && ` + ${fund.holiday_count} svátky`} {computed.totalMinutes > 0 && ( )} {computed.vacationHours > 0 && ( )} {computed.sickHours > 0 && ( )} {computed.freeHolidayHours > 0 && ( )} {computed.unpaidHours > 0 && ( )} {computed.delta > 0 ? `Přesčas: +${formatHoursDecimal(computed.delta * 60)}h` : computed.delta < 0 ? `Zbývá: ${formatHoursDecimal(-computed.delta * 60)}h` : "Fond splněn"} )} {/* Records Table */} {isPending ? ( ) : ( columns={columns} rows={records} rowKey={(record) => record.id} empty={} /> )} {/* Hidden Print Content */} {records.length > 0 && (

EVIDENCE DOCHÁZKY

{companyName}
{computed.monthName}
Zaměstnanec: {user?.fullName || ""}
Vygenerováno: {new Date().toLocaleString("cs-CZ")}

{user?.fullName || ""}

Odpracováno:{" "} {formatMinutes(computed.totalMinutes, true)}
{(computed.vacationHours > 0 || computed.sickHours > 0) && (
{computed.vacationHours > 0 && ( <> Dovolená: {computed.vacationHours}h {" "} )} {computed.sickHours > 0 && ( <> Nemoc: {computed.sickHours}h {" "} )}
)} {[...records] .sort((a, b) => a.shift_date.localeCompare(b.shift_date), ) .map((record) => { const leaveType = record.leave_type || "work"; const isLeave = leaveType !== "work"; const workMinutes = calculateWorkMinutesPrint(record); const hours = Math.floor(workMinutes / 60); const mins = workMinutes % 60; return ( ); })}
Datum Typ Příchod Pauza Odchod Hodiny Projekty Poznámka
{formatDate(record.shift_date)} {getLeaveTypeName(leaveType)} {isLeave ? "—" : formatTimeOrDatetimePrint( record.arrival_time, record.shift_date, )} {isLeave || !record.break_start || !record.break_end ? "—" : `${formatTimeOrDatetimePrint(record.break_start, record.shift_date)} - ${formatTimeOrDatetimePrint(record.break_end, record.shift_date)}`} {isLeave ? "—" : formatTimeOrDatetimePrint( record.departure_time, record.shift_date, )} {workMinutes > 0 ? `${hours}:${String(mins).padStart(2, "0")}` : "—"} {record.project_logs && record.project_logs.length > 0 ? record.project_logs.map((log, i) => { let h: number, m: number; if ( log.hours !== null && log.hours !== undefined ) { h = parseInt(String(log.hours)) || 0; m = parseInt(String(log.minutes)) || 0; } else if ( log.started_at && log.ended_at ) { const mins2 = Math.max( 0, Math.floor( (new Date( log.ended_at, ).getTime() - new Date( log.started_at, ).getTime()) / 60000, ), ); h = Math.floor(mins2 / 60); m = mins2 % 60; } else { h = 0; m = 0; } return (
{log.project_name || `#${log.project_id}`}{" "} ({h}:{String(m).padStart(2, "0")}h)
); }) : record.project_name || "—"}
{record.notes || ""}
Odpracováno: {formatMinutes(computed.totalMinutes, true)}
)}
); } /** Map an attendance leave_type to a StatusChip semantic color (mirrors the * legacy getLeaveTypeBadgeClass colors): work→info, vacation→info(blue), * sick→error(red), unpaid→default(gray). */ function leaveTypeChipColor( type: string, ): "default" | "success" | "error" | "warning" | "info" { switch (type) { case "vacation": return "info"; case "sick": return "error"; case "unpaid": return "default"; default: return "info"; } }