import { useState, useEffect, useRef } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useAlert } from "../context/AlertContext"; import { useAuth } from "../context/AuthContext"; import { Link } from "react-router-dom"; import { motion, AnimatePresence } from "framer-motion"; import AdminDatePicker from "../components/AdminDatePicker"; import ConfirmModal from "../components/ConfirmModal"; import useModalLock from "../hooks/useModalLock"; import { formatTime, calculateWorkMinutes, formatMinutes, } from "../utils/attendanceHelpers"; import FormField from "../components/FormField"; import Forbidden from "../components/Forbidden"; import apiFetch from "../utils/api"; import { jsonQuery } from "../lib/apiAdapter"; import { Skeleton } from "boneyard-js/react"; import AttendanceFixture from "../fixtures/AttendanceFixture"; 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; holiday_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; } function pluralizeDays(n: number) { if (n === 1) return "den"; if (n >= 2 && n <= 4) return "dny"; return "dnů"; } function getFundBarBackground(fund: MonthlyFund) { if (fund.overtime > 0) return "linear-gradient(135deg, var(--warning), #d97706)"; if (fund.covered >= fund.fund) return "linear-gradient(135deg, var(--success), #059669)"; return "var(--gradient)"; } export default function Attendance() { const alert = useAlert(); const { hasPermission } = useAuth(); const queryClient = useQueryClient(); 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 [submitting, setSubmitting] = useState(false); const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false); const [leaveForm, setLeaveForm] = useState({ leave_type: "vacation", date_from: new Date().toISOString().split("T")[0], date_to: new Date().toISOString().split("T")[0], 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); useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; if (geoAbortRef.current) geoAbortRef.current.abort(); if (punchTimeoutRef.current) clearTimeout(punchTimeoutRef.current); }; }, []); // Sync notes from query data when the shift changes useEffect(() => { if (statusQuery.data) { setNotes(statusQuery.data.ongoing_shift?.notes || ""); } }, [statusQuery.data]); useModalLock(isLeaveModalOpen); 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 response = await apiFetch(`${API_BASE}/attendance`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ punch_action: action, ...gpsData }), }); if (response.status === 401) return; const result = await response.json(); setSubmitting(false); if (result.success) { await queryClient.invalidateQueries({ queryKey: ["attendance", "status"], }); punchTimeoutRef.current = setTimeout(() => { alert.success(result.data?.message || result.message || "Uloženo"); }, 300); } else { alert.error(result.error); } } catch { setSubmitting(false); alert.error("Chyba připojení"); } }; const handleBreak = async () => { setSubmitting(true); try { const response = await apiFetch(`${API_BASE}/attendance`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ punch_action: "break_start" }), }); if (response.status === 401) return; const result = await response.json(); if (result.success) { await queryClient.invalidateQueries({ queryKey: ["attendance", "status"], }); alert.success( result.data?.message || result.message || "Přestávka zaznamenána", ); } else { alert.error(result.error); } } catch { alert.error("Chyba připojení"); } finally { setSubmitting(false); } }; const handleSaveNotes = async () => { try { const response = await apiFetch(`${API_BASE}/attendance/notes`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ notes }), }); if (response.status === 401) return; const result = await response.json(); if (result.success) { alert.success("Poznámka byla uložena"); } else { alert.error(result.error); } } catch { alert.error("Chyba připojení"); } }; const handleSwitchProject = async (newProjectId: string | null) => { setSwitchingProject(true); try { const response = await apiFetch(`${API_BASE}/attendance/switch-project`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ project_id: newProjectId || null }), }); if (response.status === 401) return; const result = await response.json(); if (result.success) { await queryClient.invalidateQueries({ queryKey: ["attendance", "status"], }); alert.success( result.data?.message || result.message || "Projekt přepnut", ); } else { alert.error(result.error); } } catch { alert.error("Chyba připojení"); } finally { setSwitchingProject(false); } }; const calculateBusinessDays = (from: string, to: string) => { if (!from || !to) return 0; const start = new Date(from); const end = new Date(to); 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 response = await apiFetch(`${API_BASE}/leave-requests`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(leaveForm), }); if (response.status === 401) return; const result = await response.json(); if (result.success) { setIsLeaveModalOpen(false); await queryClient.invalidateQueries({ queryKey: ["attendance", "status"], }); await new Promise((resolve) => setTimeout(resolve, 300)); alert.success( result.data?.message || result.message || "Žádost odeslána", ); setLeaveForm({ leave_type: "vacation", date_from: new Date().toISOString().split("T")[0], date_to: new Date().toISOString().split("T")[0], notes: "", }); } else { alert.error(result.error); } } catch { alert.error("Chyba připojení"); } finally { setRequestSubmitting(false); } }; if (!statusQuery.data) { return ( } >
); } 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; return (

Docházka

{new Date().toLocaleDateString("cs-CZ", { weekday: "long", day: "numeric", month: "long", year: "numeric", })}

{/* Left Column - Clock In/Out */}
{isOngoingShift ? ( <> Pracuji ) : ( <> Nepracuji )}
{new Date().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 ? ( {projects.find( (p) => 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}`} ) : ( Žádný )}
{projectLogs.length > 0 && (
{projectLogs.map((log, i) => { if (!log.started_at) return null; const start = new Date(log.started_at); const end = log.ended_at ? new Date(log.ended_at) : new Date(); const mins = Math.max( 0, Math.floor( (end.getTime() - start.getTime()) / 60000, ), ); const h = Math.floor(mins / 60); const mm = mins % 60; return (
{log.project_name || `Projekt #${log.project_id}`} {formatTime(log.started_at)} –{" "} {log.ended_at ? formatTime(log.ended_at) : "nyní"} {h}:{String(mm).padStart(2, "0")} h
); })}
)}
)}
{!ongoingShift.break_start && ( )}