import { useState, useEffect, useRef } from "react"; import { useQuery } from "@tanstack/react-query"; import { Link as RouterLink } from "react-router-dom"; import { parseISO, isValid } from "date-fns"; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; import { useAlert } from "../context/AlertContext"; import { useAuth } from "../context/AuthContext"; import { formatTime, calculateWorkMinutes, formatMinutes, } from "../utils/attendanceHelpers"; import Forbidden from "../components/Forbidden"; import apiFetch from "../utils/api"; import { jsonQuery } from "../lib/apiAdapter"; import { useApiMutation } from "../lib/queries/mutations"; import { czechPlural, todayLocalStr } from "../utils/formatters"; import { Button, Card, DataTable, DateField, Field, Modal, ConfirmDialog, Select, StatCard, StatusChip, TextField, ProgressBar, PageEnter, EmptyState, LoadingState, type DataColumn, type ProgressColor, } from "../ui"; const API_BASE = "/api/admin"; interface ShiftRecord { id: number; user_id: number; shift_date: string; arrival_time?: string | null; departure_time?: string | null; break_start?: string | null; break_end?: string | null; notes?: string | null; project_id?: number | null; project_logs?: ProjectLog[]; } interface ProjectLog { id?: number; project_id?: number; project_name?: string; started_at?: string; ended_at?: string | null; } interface Project { id: number; name: string; project_number: string; } interface LeaveBalance { vacation_total: number; vacation_used: number; vacation_remaining: number; sick_used: number; } interface MonthlyFund { month_name: string; fund: number; worked: number; covered: number; remaining: number; overtime: number; leave_hours: number; vacation_hours: number; sick_hours: number; unpaid_hours: number; } interface AttendanceData { ongoing_shift: ShiftRecord | null; today_shifts: ShiftRecord[]; date: string; leave_balance: LeaveBalance; monthly_fund: MonthlyFund | null; project_logs: ProjectLog[]; active_project_id: number | null; } /** Maps the monthly-fund state to a ProgressBar color token. Mirrors the legacy * getFundBarBackground gradient conditional: overtime→warning, covered≥fund→ * success, otherwise→primary (was the accent gradient). */ function getFundBarColor(fund: MonthlyFund): ProgressColor { if (fund.overtime > 0) return "warning"; if (fund.covered >= fund.fund) return "success"; return "primary"; } const CalendarIcon = ( ); const VacationIcon = ( ); const SickIcon = ( ); const RequestsIcon = ( ); const HistoryIcon = ( ); const ManageIcon = ( ); const BalancesIcon = ( ); const ChevronIcon = ( ); /** Sidebar navigation row: icon + label on the left, chevron on the right. */ function QuickLink({ to, icon, label, }: { to: string; icon: React.ReactNode; label: string; }) { return ( ); } export default function Attendance() { const alert = useAlert(); const { hasPermission } = useAuth(); const statusQuery = useQuery({ queryKey: ["attendance", "status"], queryFn: () => jsonQuery("/api/admin/attendance/status"), }); const projectsQuery = useQuery({ queryKey: ["attendance", "projects"], queryFn: () => jsonQuery("/api/admin/attendance?action=projects"), }); const punchMutation = useApiMutation< Record, { message?: string } >({ url: () => `${API_BASE}/attendance`, method: () => "POST", // dashboard included: the punch-button state + presence cards live there. invalidate: ["attendance", "dashboard"], }); const notesMutation = useApiMutation<{ notes: string }, { message?: string }>( { url: () => `${API_BASE}/attendance/notes`, method: () => "POST", invalidate: ["attendance"], }, ); const switchProjectMutation = useApiMutation< { project_id: number | null }, { message?: string } >({ url: () => `${API_BASE}/attendance/switch-project`, method: () => "POST", invalidate: ["attendance"], }); const leaveRequestMutation = useApiMutation< { leave_type: string; date_from: string; date_to: string; notes: string }, { message?: string } >({ url: () => `${API_BASE}/leave-requests`, method: () => "POST", // dashboard included: approvers see the pending-requests KPI there. invalidate: ["attendance", "leave-requests", "leave", "users", "dashboard"], }); const [submitting, setSubmitting] = useState(false); const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false); const [leaveForm, setLeaveForm] = useState({ leave_type: "vacation", date_from: todayLocalStr(), date_to: todayLocalStr(), notes: "", }); const [requestSubmitting, setRequestSubmitting] = useState(false); const [notes, setNotes] = useState(""); const [switchingProject, setSwitchingProject] = useState(false); const [gpsConfirm, setGpsConfirm] = useState<{ isOpen: boolean; action: string | null; }>({ isOpen: false, action: null }); const geoAbortRef = useRef(null); const punchTimeoutRef = useRef | null>(null); const mountedRef = useRef(true); const latestActionRef = useRef(null); // Live wall-clock display (HH:MM) — re-render every 30s so the shown time // actually advances instead of freezing at first paint. const [now, setNow] = useState(() => new Date()); useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; if (geoAbortRef.current) geoAbortRef.current.abort(); if (punchTimeoutRef.current) clearTimeout(punchTimeoutRef.current); }; }, []); useEffect(() => { const id = setInterval(() => setNow(new Date()), 30_000); return () => clearInterval(id); }, []); // Sync notes from query data when the shift changes useEffect(() => { if (statusQuery.data) { setNotes(statusQuery.data.ongoing_shift?.notes || ""); } }, [statusQuery.data]); if (!hasPermission("attendance.record")) return ; const handlePunch = (action: string) => { setSubmitting(true); latestActionRef.current = action; // Some browsers silently hang on getCurrentPosition (especially with // enableHighAccuracy:true on desktops without GPS). Use a short safety // timeout and proceed without GPS rather than leaving the user stuck. const safetyTimeout = setTimeout(() => { if (mountedRef.current) { submitPunch(action, {}); } }, 6000); if (!navigator.geolocation) { clearTimeout(safetyTimeout); alert.warning("GPS není dostupná"); submitPunch(action, {}); return; } navigator.geolocation.getCurrentPosition( (position) => { clearTimeout(safetyTimeout); if (!mountedRef.current) return; try { const { latitude, longitude, accuracy } = position.coords; submitPunch(action, { latitude, longitude, accuracy, address: "" }); } catch { submitPunch(action, {}); } // Fire-and-forget reverse geocoding to update the address later if (geoAbortRef.current) geoAbortRef.current.abort(); const controller = new AbortController(); geoAbortRef.current = controller; fetch( `https://nominatim.openstreetmap.org/reverse?format=json&lat=${position.coords.latitude}&lon=${position.coords.longitude}&zoom=18&addressdetails=1`, { headers: { "Accept-Language": "cs" }, signal: controller.signal, }, ) .then((r) => r.json()) .then((geoData) => { if (!mountedRef.current) return; if (latestActionRef.current !== action) return; if (geoData.display_name) { apiFetch(`${API_BASE}/attendance/update-address`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ latitude: position.coords.latitude, longitude: position.coords.longitude, address: geoData.display_name, punch_action: action, }), }).catch(() => {}); } }) .catch(() => {}); }, (geoError) => { clearTimeout(safetyTimeout); if (!mountedRef.current) return; let errorMsg = "Nepodařilo se získat polohu"; if (geoError.code === geoError.PERMISSION_DENIED) { errorMsg = "Přístup k poloze byl zamítnut"; } else if (geoError.code === geoError.TIMEOUT) { errorMsg = "Vypršel časový limit"; } alert.error(errorMsg); setSubmitting(false); setGpsConfirm({ isOpen: true, action }); }, { enableHighAccuracy: false, timeout: 5000, maximumAge: 60000 }, ); }; const submitPunch = async ( action: string, gpsData: Record = {}, ) => { try { const result = await punchMutation.mutateAsync({ punch_action: action, ...gpsData, }); setSubmitting(false); punchTimeoutRef.current = setTimeout(() => { alert.success(result?.message || "Uloženo"); }, 300); } catch (e) { setSubmitting(false); alert.error(e instanceof Error ? e.message : "Chyba připojení"); } }; const handleBreak = async () => { setSubmitting(true); try { const result = await punchMutation.mutateAsync({ punch_action: "break_start", }); alert.success(result?.message || "Přestávka zaznamenána"); } catch (e) { alert.error(e instanceof Error ? e.message : "Chyba připojení"); } finally { setSubmitting(false); } }; const handleSaveNotes = async () => { try { await notesMutation.mutateAsync({ notes }); alert.success("Poznámka byla uložena"); } catch (e) { alert.error(e instanceof Error ? e.message : "Chyba připojení"); } }; const handleSwitchProject = async (newProjectId: string | null) => { setSwitchingProject(true); try { const result = await switchProjectMutation.mutateAsync({ project_id: newProjectId ? Number(newProjectId) : null, }); alert.success(result?.message || "Projekt přepnut"); } catch (e) { alert.error(e instanceof Error ? e.message : "Chyba připojení"); } finally { setSwitchingProject(false); } }; // Parse a YYYY-MM-DD string into a LOCAL-midnight Date. `new Date("YYYY-MM-DD")` // would parse as UTC midnight; reading it back with local getDay()/getDate() // can land on the wrong calendar day in the evening Prague window. const parseLocalDate = (s: string): Date | null => { const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s); if (!m) return null; return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])); }; const calculateBusinessDays = (from: string, to: string) => { if (!from || !to) return 0; const start = parseLocalDate(from); const end = parseLocalDate(to); if (!start || !end) return 0; if (end < start) return 0; let days = 0; const current = new Date(start); while (current <= end) { const day = current.getDay(); if (day !== 0 && day !== 6) days++; current.setDate(current.getDate() + 1); } return days; }; const handleRequestSubmit = async () => { setRequestSubmitting(true); try { const result = await leaveRequestMutation.mutateAsync(leaveForm); setIsLeaveModalOpen(false); await new Promise((resolve) => setTimeout(resolve, 300)); alert.success(result?.message || "Žádost odeslána"); setLeaveForm({ leave_type: "vacation", date_from: todayLocalStr(), date_to: todayLocalStr(), notes: "", }); } catch (e) { alert.error(e instanceof Error ? e.message : "Chyba připojení"); } finally { setRequestSubmitting(false); } }; if (statusQuery.isPending) { return ; } if (!statusQuery.data) { return null; } const { ongoing_shift: ongoingShift, today_shifts: todayShifts, leave_balance: leaveBalance, } = statusQuery.data; const data = statusQuery.data; const projects = projectsQuery.data ?? []; const projectLogs = data.project_logs ?? []; const activeProjectId = data.active_project_id ?? null; const isOngoingShift = ongoingShift && !ongoingShift.departure_time; const completedToday = todayShifts.filter((s) => s.departure_time); const vacationDaysRemaining = Math.floor(leaveBalance.vacation_remaining / 8); const vacationHoursRemaining = leaveBalance.vacation_remaining % 8; const businessDays = calculateBusinessDays( leaveForm.date_from, leaveForm.date_to, ); const completedColumns: DataColumn[] = [ { key: "arrival", header: "Příchod", mono: true, render: (shift) => formatTime(shift.arrival_time), }, { key: "break", header: "Pauza", mono: true, render: (shift) => shift.break_start && shift.break_end ? `${formatTime(shift.break_start)} - ${formatTime(shift.break_end)}` : "—", }, { key: "departure", header: "Odchod", mono: true, render: (shift) => formatTime(shift.departure_time), }, { key: "worked", header: "Odpracováno", mono: true, render: (shift) => formatMinutes( calculateWorkMinutes({ ...shift, notes: shift.notes ?? undefined, }), true, ), }, ...(projects.length > 0 ? [ { key: "projects", header: "Projekty", render: (shift: ShiftRecord) => { const shiftLogs = shift.project_logs || []; if (shiftLogs.length === 0) return "—"; return ( {shiftLogs.map((log, i) => { if (!log.started_at) return null; const mins = log.ended_at ? Math.floor( (new Date(log.ended_at).getTime() - new Date(log.started_at).getTime()) / 60000, ) : 0; const h = Math.floor(mins / 60); const mm = mins % 60; return ( {log.project_name || `#${log.project_id}`} ({h}: {String(mm).padStart(2, "0")}h) ); })} ); }, } as DataColumn, ] : []), ]; return ( Docházka {new Date().toLocaleDateString("cs-CZ", { weekday: "long", day: "numeric", month: "long", year: "numeric", })} {/* Left Column - Clock In/Out */} `0 0 0 4px ${theme.palette.success.main}33` : "none", }} /> {isOngoingShift ? "Pracuji" : "Nepracuji"} {now.toLocaleTimeString("cs-CZ", { hour: "2-digit", minute: "2-digit", })} {isOngoingShift ? ( <> Příchod {formatTime(ongoingShift.arrival_time)} Pauza {ongoingShift.break_start ? `${formatTime(ongoingShift.break_start)} - ${formatTime(ongoingShift.break_end)}` : "—"} Odchod {projects.length > 0 && ( Projekt {activeProjectId ? ( String(p.id) === String(activeProjectId), ) ? `${projects.find((p) => String(p.id) === String(activeProjectId))!.project_number} – ${projects.find((p) => String(p.id) === String(activeProjectId))!.name}` : `Projekt #${activeProjectId}` } sx={{ height: "auto", "& .MuiChip-label": { whiteSpace: "normal", py: 0.25, }, }} /> ) : ( Žádný )} setLeaveForm({ ...leaveForm, leave_type: val })} options={[ { value: "vacation", label: "Dovolená" }, { value: "sick", label: "Nemoc" }, { value: "unpaid", label: "Neplacené volno" }, ]} /> { setLeaveForm((prev) => ({ ...prev, date_from: val, date_to: prev.date_to < val ? val : prev.date_to, })); }} /> setLeaveForm({ ...leaveForm, date_to: val })} /> {leaveForm.date_from && leaveForm.date_to && ( {businessDays}{" "} {czechPlural( businessDays, "pracovní den", "pracovní dny", "pracovních dnů", )} {businessDays * 8} hodin )} setLeaveForm({ ...leaveForm, notes: e.target.value }) } placeholder="Volitelná poznámka..." /> { setGpsConfirm({ isOpen: false, action: null }); setSubmitting(false); }} onConfirm={() => { setGpsConfirm({ isOpen: false, action: null }); submitPunch(gpsConfirm.action!, {}); }} title="GPS nedostupná" message="Nepodařilo se získat polohu. Chcete pokračovat bez GPS?" confirmText="Pokračovat" cancelText="Zrušit" confirmVariant="primary" /> ); }