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, normalizeDateStr, computeMzdaSummary, 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 manko?: number; // Chybějící hodiny: real clock time below the fond 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; leave_balances: Record; } 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, month: string, ): Record { const totals: Record = {}; const recordsByUser = new Map(); 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; } } // Rekapitulace per user — the SAME shared computation the print uses // (computeMzdaSummary), so the screen and the printed sheet can never // disagree. Accountant spec 2026-07: Odpracováno = worked minus // holiday-worked; Vč. svátků = ALL worked; Přesčas = max(0, worked − fond); // Svátek = hours actually worked on holidays. for (const uid of Object.keys(totals)) { const t = totals[uid]; const userRecords = recordsByUser.get(Number(uid)) || []; const s = computeMzdaSummary(userRecords, month); // Legacy fields (kept so existing UI doesn't break). const workedHours = Math.round(s.workedHoursRaw * 10) / 10; const covered = Math.round((workedHours + t.vacation_hours) * 10) / 10; t.fund = s.fund; t.business_days = s.businessDays; t.worked_hours = workedHours; t.covered = covered; t.missing = Math.max(0, Math.round((s.fund - covered) * 10) / 10); t.overtime = Math.max(0, Math.round((covered - s.fund) * 10) / 10); t.worked_hours_raw = s.workedHoursRaw; t.odpracovano = s.odpracovano; t.vcetne_svatku = s.vcetneSvatku; t.prescas = s.prescas; t.svatek = s.svatekHours; t.weekend_hours = s.weekendHours; t.night_minutes = s.nightMinutes; t.worked_holiday_hours = s.svatekHours; t.manko = s.manko; } return totals; } // --------------------------------------------------------------------------- // Print helpers // --------------------------------------------------------------------------- function escapeHtml(str: string): string { return str .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } /** 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; } 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 `
${escapeHtml(log.project_name || `#${log.project_id}`)} (${h}:${String(m).padStart(2, "0")}h)
`; }) .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. A day may hold MULTIPLE // records (two shifts, or work + partial-day vacation) — keep them ALL; a // single-record map silently dropped every shift but the last one. const recordsByDate = new Map(); for (const r of userData.records || []) { const key = normalizeDateStr(r.shift_date); const list = recordsByDate.get(key); if (list) list.push(r); else recordsByDate.set(key, [r]); } // Stable in-day order: by arrival time (leave records without times last). for (const list of recordsByDate.values()) { list.sort((a, b) => String(a.arrival_time || "9999").localeCompare( String(b.arrival_time || "9999"), ), ); } // 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 dayRecords = recordsByDate.get(dateStr); const weekend = isWeekendDate(dateStr); const holiday = isHoliday(dateStr); if (!dayRecords || dayRecords.length === 0) { const rowClasses = [weekend && "weekend", holiday && "holiday"] .filter(Boolean) .join(" "); rows.push(` ${escapeHtml(formatDate(dateStr))} ${escapeHtml(getCzechWeekday(dateStr))} — — — — — `); continue; } // One row PER RECORD — a day can hold two shifts, or work + partial-day // vacation. (A single row per day silently dropped the extra shifts.) for (const record of dayRecords) { const leaveType = (record.leave_type as string) || "work"; const rowClasses = [ weekend && "weekend", holiday && "holiday", leaveType === "vacation" && "vacation", ] .filter(Boolean) .join(" "); 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(` ${escapeHtml(formatDate(dateStr))} ${escapeHtml(getCzechWeekday(dateStr))} ${escapeHtml(getLeaveTypeName(leaveType))} ${isLeave ? "—" : escapeHtml(formatTimeOrDatetimePrint(record.arrival_time, record.shift_date))} ${breakCell} ${isLeave ? "—" : escapeHtml(formatTimeOrDatetimePrint(record.departure_time, record.shift_date))} ${hoursCell} ${buildProjectLogsHtml(record)} ${escapeHtml(record.notes || "")} `); } } // Rekapitulace — single shared implementation (accountant spec 2026-07): // Odpracováno = all worked hours EXCEPT hours worked on holidays // Vč. svátků a přesč. = ALL worked hours (line2 − line1 = Svátek) // Přesčas = max(0, worked − fond) // Svátek = hours actually worked on holiday dates const s = computeMzdaSummary(userRecords, printData.month); const summaryHtml = `
Odpracováno: ${formatHoursDecimal(s.odpracovano * 60)}h
Odpracováno včetně svátků a přesčasů: ${formatHoursDecimal(s.vcetneSvatku * 60)}h
Dovolená: ${formatHoursDecimal(s.vacationHours * 60)}h
Přesčas: ${formatHoursDecimal(s.prescas * 60)}h
Chybějící hodiny: ${formatHoursDecimal(s.manko * 60)}h
Svátek: ${formatHoursDecimal(s.svatekHours * 60)}h
So/Ne: ${formatHoursDecimal(s.weekendHours * 60)}h
Práce v noci: ${formatHoursDecimal(s.nightMinutes)}h
Fond měsíce: ${formatHoursDecimal(s.fund * 60)}h (${s.businessDays} dnů)
Víkend (So/Ne) Svátek Víkend + svátek Dovolená
`; return `

${escapeHtml(userData.name)}

Odpracováno: ${escapeHtml(formatMinutes(userData.minutes))} h
${rows.join("")}
Datum Den Typ Příchod Pauza Odchod Hodiny Projekty Poznámka
${summaryHtml}
`; } export function buildPrintHtml( pData: PrintData, userSections: string, emptyMsg: string, filterNote: string, companyName: string, ): string { return ` Docházka - ${escapeHtml(pData.month_name)} `; } // --------------------------------------------------------------------------- // 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({ records: [], users: [], user_totals: {}, leave_balances: {}, }); // ---- Bulk modal ---- const [showBulkModal, setShowBulkModal] = useState(false); const [bulkSubmitting, setBulkSubmitting] = useState(false); const [bulkForm, setBulkForm] = useState({ 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({ 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( null, ); const [editForm, setEditForm] = useState({ 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({ show: false, record: null, }); // ---- Projects ---- const [projectList, setProjectList] = useState([]); const [createProjectLogs, setCreateProjectLogs] = useState([]); const [editProjectLogs, setEditProjectLogs] = useState([]); // ---- Print ref (kept for API compat) ---- const printRef = useRef(null); // ---- Ref to hold full user list for user_totals computation ---- const usersRef = useRef>(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(); 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 = 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 = { 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).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 = { 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 ? '

Za vybrané období nejsou žádné záznamy.

' : ""; const filterNote = pData.selected_user_name ? `
Zaměstnanec: ${escapeHtml(pData.selected_user_name)}
` : ""; 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, }; }