import { useState, useEffect, useCallback, useRef } from "react"; 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"; 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 [loading, setLoading] = useState(true); const [submitting, setSubmitting] = useState(false); const [data, setData] = useState({ ongoing_shift: null, today_shifts: [], date: "", leave_balance: { vacation_total: 160, vacation_used: 0, vacation_remaining: 160, sick_used: 0, }, monthly_fund: null, project_logs: [], active_project_id: null, }); const [showLeaveModal, setShowLeaveModal] = 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 [projects, setProjects] = useState([]); const [switchingProject, setSwitchingProject] = useState(false); const [projectLogs, setProjectLogs] = useState([]); const [activeProjectId, setActiveProjectId] = useState(null); const [gpsConfirm, setGpsConfirm] = useState<{ show: boolean; action: string | null; }>({ show: false, action: null }); const geoAbortRef = useRef(null); useEffect(() => { return () => { if (geoAbortRef.current) geoAbortRef.current.abort(); }; }, []); const fetchData = useCallback(async () => { try { const response = await apiFetch(`${API_BASE}/attendance/status`); if (response.status === 401) return; const result = await response.json(); if (result.success) { setData(result.data); setNotes(result.data.ongoing_shift?.notes || ""); setProjectLogs(result.data.project_logs || []); setActiveProjectId(result.data.active_project_id || null); } } catch { alert.error("Nepodařilo se načíst data"); } finally { setLoading(false); } }, [alert]); useEffect(() => { fetchData(); }, [fetchData]); useEffect(() => { const loadProjects = async () => { try { const response = await apiFetch( `${API_BASE}/attendance?action=projects`, ); const result = await response.json(); if (result.success) { const items = Array.isArray(result.data) ? result.data : []; setProjects(items); } } catch { // silent - projects are supplementary } }; loadProjects(); }, []); useModalLock(showLeaveModal); if (!hasPermission("attendance.record")) return ; const handlePunch = (action: string) => { setSubmitting(true); if (!navigator.geolocation) { alert.warning("GPS není dostupná"); submitPunch(action, {}); return; } navigator.geolocation.getCurrentPosition( (position) => { const { latitude, longitude, accuracy } = position.coords; submitPunch(action, { latitude, longitude, accuracy, address: "" }); if (geoAbortRef.current) geoAbortRef.current.abort(); const controller = new AbortController(); geoAbortRef.current = controller; fetch( `https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}&zoom=18&addressdetails=1`, { headers: { "Accept-Language": "cs" }, signal: controller.signal, }, ) .then((r) => r.json()) .then((geoData) => { if (geoData.display_name) { apiFetch(`${API_BASE}/attendance/update-address`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ latitude, longitude, address: geoData.display_name, punch_action: action, }), }).catch(() => {}); } }) .catch(() => {}); }, (geoError) => { 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); setGpsConfirm({ show: true, action }); }, { enableHighAccuracy: true, timeout: 10000, 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 fetchData(); 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 fetchData(); 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 fetchData(); 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) { setShowLeaveModal(false); await fetchData(); 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 (loading) { return (
); } const { ongoing_shift: ongoingShift, today_shifts: todayShifts, leave_balance: leaveBalance, } = data; 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) => { const start = new Date(log.started_at!); const end = log.ended_at ? new Date(log.ended_at) : new Date(); const mins = 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 && ( )}