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, calculateNightMinutes, normalizeDateStr, holidaysInMonth, calculateFreeHolidayHours, getCzechWeekday, } from "../utils/attendanceHelpers"; import type { ShiftFormData, ProjectLog, Project, User, } from "../components/ShiftFormModal"; // --------------------------------------------------------------------------- // 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 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; } } // Add fund data per user (matching PHP addFundDataToUserTotals) const [yearStr, monthStr] = month.split("-"); const yr = parseInt(yearStr, 10); const mo = parseInt(monthStr, 10) - 1; // Count Mon-Fri business days in month (INCLUDING holidays). // The fund is rawBizDays × 8 — holidays stay in the count so the // user is paid for them via the fund. Svátek (free holiday hours) is // computed separately as "8h per holiday the user did not work". let rawBizDays = 0; const cur = new Date(yr, mo, 1); while (cur.getMonth() === mo) { const dow = cur.getDay(); if (dow !== 0 && dow !== 6) rawBizDays++; cur.setDate(cur.getDate() + 1); } const holidayDates = holidaysInMonth(yr, mo + 1); const holidayCount = holidayDates.length; for (const uid of Object.keys(totals)) { const t = totals[uid]; const userRecords = recordsByUser.get(Number(uid)) || []; // Odpracováno: floor(worked / 8) × 8 — round to whole days, drop remainder. const workedHoursRaw = t.minutes / 60; const odpracovano = Math.floor(workedHoursRaw / 8) * 8; // So/Ne: sum of worked hours on Sat/Sun (raw, not rounded). const weekendHours = userRecords .filter( (r) => (r.leave_type || "work") === "work" && isWeekendDate(r.shift_date), ) .reduce((sum, r) => sum + calculateWorkMinutesPrint(r) / 60, 0); // Práce v noci: minutes in 22:00-06:00 across all work records. const nightMinutes = userRecords .filter((r) => (r.leave_type || "work") === "work") .reduce((sum, r) => sum + calculateNightMinutes(r), 0); // Svátek (free): 8h per holiday the user did not work. const freeHolidayHours = calculateFreeHolidayHours( userRecords, holidayDates, ); // Worked holidays: holidays the user DID work (counted toward // Odpracováno, but their 8h must not flow into Přesčas). const workedDatesSet = new Set( userRecords .filter((r) => (r.leave_type || "work") === "work") .map((r) => normalizeDateStr(r.shift_date)) .filter(Boolean), ); let workedHolidayCount = 0; let workedHolidayHours = 0; for (const hd of holidayDates) { if (workedDatesSet.has(hd)) workedHolidayCount++; } // Sum the actual hours worked on holiday dates (for the KPI card's // "fond used" total = real_work + vacation + worked_holiday + free_holiday). for (const r of userRecords) { if ((r.leave_type || "work") !== "work") continue; const d = normalizeDateStr(r.shift_date); if (d && holidayDates.includes(d)) { workedHolidayHours += calculateWorkMinutesPrint(r) / 60; } } // Fund = Mon-Fri × 8h. Holidays count as work days (already in rawBizDays). const fund = rawBizDays * 8; // Včetně svátků a přesčasů (mzda formula): // odpracovano + vacation + remainder − min(freeHols, workedHols × 8) // // Two-way logic matching the print: // • If the user worked at least one holiday, that worked holiday's // 8h is in Odpracováno and shouldn't also count as Přesčas. We // subtract one 8h per worked holiday (capped at the total free // holiday hours — can't go negative). // • If the user did NOT work any holiday, no adjustment is needed: // all unworked holidays are already paid via the Svátek line and // don't flow into Přesčas, so vcetne_svatku just equals the // work + vacation + remainder. const remainder = workedHoursRaw - odpracovano; const holidayAdj = Math.min(freeHolidayHours, workedHolidayCount * 8); const vcetneSv = odpracovano - holidayAdj + t.vacation_hours + remainder; // Přesčas = vcetneSv − odpracovano (derived). const prescas = vcetneSv - odpracovano; // Legacy fields (kept so existing UI doesn't break). const workedHours = Math.round(workedHoursRaw * 10) / 10; const covered = Math.round((workedHours + t.vacation_hours) * 10) / 10; t.fund = fund; t.business_days = rawBizDays; t.worked_hours = workedHours; t.covered = covered; t.missing = Math.max(0, Math.round((fund - covered) * 10) / 10); t.overtime = Math.max(0, Math.round((covered - fund) * 10) / 10); t.worked_hours_raw = workedHoursRaw; t.odpracovano = odpracovano; t.vcetne_svatku = vcetneSv; t.prescas = prescas; t.svatek = freeHolidayHours; t.weekend_hours = weekendHours; t.night_minutes = nightMinutes; t.worked_holiday_hours = workedHolidayHours; } return totals; } // --------------------------------------------------------------------------- // Print helpers // --------------------------------------------------------------------------- function escapeHtml(str: string): string { return str .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function buildProjectLogsHtml(record: Record): string { if (record.project_logs && record.project_logs.length > 0) { return record.project_logs .map((log: any) => { let h: number, m: number; if (log.hours !== null && log.hours !== undefined) { h = parseInt(log.hours) || 0; m = parseInt(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 || "—"); } function buildUserSectionHtml( userId: string, userData: Record, printData: Record, ): string { // Build a date-keyed lookup of the user's records. const recordsByDate = new Map>(); for (const r of userData.records || []) { recordsByDate.set(normalizeDateStr(r.shift_date), r); } // 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 || []) as Record[]; const rows: string[] = []; for (let d = 1; d <= daysInMonth; d++) { const dateStr = shiftDateForMonth(yr, mo, d); const record = recordsByDate.get(dateStr); const weekend = isWeekendDate(dateStr); const holiday = isHoliday(dateStr); const leaveType = (record?.leave_type as string) || "work"; const rowClasses = [ weekend && "weekend", holiday && "holiday", leaveType === "vacation" && "vacation", ] .filter(Boolean) .join(" "); if (!record) { rows.push(` ${escapeHtml(formatDate(dateStr))} ${escapeHtml(getCzechWeekday(dateStr))} — — — — — `); continue; } 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 || "")} `); } // ----- mzda-style summary numbers (computed here, not from userData, // because the backend's getPrintData only returns the legacy fields). ----- // Worked minutes: sum of calculateWorkMinutesPrint across work records. let workedMinutes = 0; let weekendHoursRaw = 0; let nightMinutes = 0; for (const r of userRecords) { const leaveType = r.leave_type || "work"; if (leaveType !== "work") continue; const m = calculateWorkMinutesPrint(r); workedMinutes += m; if (isWeekendDate(r.shift_date)) { weekendHoursRaw += m / 60; } nightMinutes += calculateNightMinutes(r); } // Sum of vacation/sick/unpaid hours (in hours, not minutes). The // "holiday" leave_type is auto-computed from Czech holidays further down // and no longer selectable in the UI, so it's deliberately omitted here. let vacationHours = 0, sickHours = 0; for (const r of userRecords) { const lt = r.leave_type || "work"; if (lt === "work") continue; const h = Number(r.leave_hours) || 8; if (lt === "vacation") vacationHours += h; else if (lt === "sick") sickHours += h; } // Odpracováno: floor(worked / 8) × 8. const workedHoursRaw = workedMinutes / 60; const odpracovano = Math.floor(workedHoursRaw / 8) * 8; // Free Svátek: 8h per holiday date the user did not work. const holidayDates = holidaysInMonth(yr, mo); const holidayCount = holidayDates.length; const workedDates = new Set( userRecords .filter((r) => (r.leave_type || "work") === "work") .map((r) => normalizeDateStr(r.shift_date)) .filter(Boolean), ); let freeHolidayHours = 0; let workedHolidayCount = 0; for (const hd of holidayDates) { if (workedDates.has(hd)) workedHolidayCount++; else freeHolidayHours += 8; } // Fund: count Mon-Fri (incl. holidays) × 8h. let rawBizDays = 0; const cur = new Date(yr, mo - 1, 1); while (cur.getMonth() === mo - 1) { const dow = cur.getDay(); if (dow !== 0 && dow !== 6) rawBizDays++; cur.setDate(cur.getDate() + 1); } const businessDays = rawBizDays; const fund = businessDays * 8; // Včetně svátků a přesčasů: floor(worked/8)*8 − worked_holiday*8 + vacation + remainder. // (A worked holiday counts toward Odpracováno, but its 8h should not flow // into Přesčas — so we subtract it here. Unworked holidays are paid via // the fund and don't affect this line.) const remainder = workedHoursRaw - odpracovano; const vcetneSv = odpracovano - workedHolidayCount * 8 + vacationHours + remainder; // Přesčas = #2 - #1. const prescas = vcetneSv - odpracovano; const summaryHtml = `
Odpracováno: ${formatHoursDecimal(odpracovano * 60)}h
Odpracováno včetně svátků a přesčasů: ${formatHoursDecimal(vcetneSv * 60)}h
Dovolená: ${formatHoursDecimal(vacationHours * 60)}h
Přesčas: ${formatHoursDecimal(prescas * 60)}h
Svátek: ${formatHoursDecimal(freeHolidayHours * 60)}h
So/Ne: ${formatHoursDecimal(weekendHoursRaw * 60)}h
Práce v noci: ${formatHoursDecimal(nightMinutes)}h
Fond měsíce: ${formatHoursDecimal(fund * 60)}h (${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}
`; } function buildPrintHtml( pData: Record, 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 Record, 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, }; }