import { useState, useEffect, useCallback, useRef } from "react"; import apiFetch from "../utils/api"; import { calcProjectMinutesTotal, calcFormWorkMinutes, calculateWorkMinutes, getDatePart, getTimePart, formatDate, formatMinutes, getLeaveTypeName, getLeaveTypeBadgeClass, formatTimeOrDatetimePrint, calculateWorkMinutesPrint, } from "../utils/attendanceHelpers"; import type { ShiftFormData, ProjectLog, Project, User, } from "../components/ShiftFormModal"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface AlertContext { alert: { success: (msg: string) => void; error: (msg: string) => void }; } 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; 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; holiday_hours: number; unpaid_hours: number; overtime: number; missing: number; fund: number | null; business_days: number; worked_hours: number; covered: number; } 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"; const combineDatetime = (date: string, time: string): string | null => date && time ? `${date}T${time}:00` : null; /** * Compute per-user totals from raw attendance records. * This replaces the server-side `user_totals` that the PHP backend returned. */ function computeUserTotals( records: AttendanceRecord[], userMap: Map, month: string, ): Record { const totals: Record = {}; 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, holiday_hours: 0, unpaid_hours: 0, overtime: 0, missing: 0, fund: null, business_days: 0, worked_hours: 0, covered: 0, }; } const t = totals[uid]; const leaveType = rec.leave_type || "work"; if (leaveType === "work") { // Only work records contribute to "minutes" (matching PHP calculateUserTotals) t.minutes += calculateWorkMinutes(rec); } 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 "holiday": t.holiday_hours += leaveHours; break; case "unpaid": t.unpaid_hours += leaveHours; break; } } // 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 business days in month (Mon-Fri) 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); } for (const uid of Object.keys(totals)) { const t = totals[uid]; // Subtract holiday days from business days for this user const holidayDays = Math.round(t.holiday_hours / 8); const bizDays = Math.max(0, rawBizDays - holidayDays); const fund = bizDays * 8; const workedHours = Math.round((t.minutes / 60) * 10) / 10; // Covered = worked + vacation + sick (NOT holiday/unpaid — matching PHP) const leaveHours = t.vacation_hours + t.sick_hours; const covered = Math.round((workedHours + leaveHours) * 10) / 10; t.fund = fund; t.business_days = bizDays; 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); } return totals; } // --------------------------------------------------------------------------- // Print helpers // --------------------------------------------------------------------------- function escapeHtml(str: string): string { return str .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function renderFundStatus(userData: Record): string { if (userData.overtime > 0) return `+${escapeHtml(String(userData.overtime))}h přesčas`; if (userData.missing > 0) return `−${escapeHtml(String(userData.missing))}h`; return 'splněno'; } 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 buildLeaveSummaryHtml( userId: string, userData: Record, printData: Record, ): string { const bal = printData.leave_balances[userId]; let parts = `Dovolená ${escapeHtml(String(printData.year))}: Zbývá ${bal.vacation_remaining.toFixed(1)}h z ${bal.vacation_total}h`; if (userData.vacation_hours > 0) parts += ` | Tento měsíc: ${escapeHtml(String(userData.vacation_hours))}h`; if (userData.sick_hours > 0) parts += ` | Nemoc: ${escapeHtml(String(userData.sick_hours))}h`; if (userData.holiday_hours > 0) parts += ` | Svátek: ${escapeHtml(String(userData.holiday_hours))}h`; if (userData.overtime > 0) parts += ` | Přesčas: +${escapeHtml(String(userData.overtime))}h`; return `
${parts}
`; } function buildUserSectionHtml( userId: string, userData: Record, printData: Record, ): string { const leaveHtml = printData.leave_balances[userId] ? buildLeaveSummaryHtml(userId, userData, printData) : ""; const recordRows = (userData.records || []) .map((record: any) => { const leaveType = record.leave_type || "work"; 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))}`; return ` ${escapeHtml(formatDate(record.shift_date))} ${escapeHtml(getLeaveTypeName(leaveType))} ${isLeave ? "—" : escapeHtml(formatTimeOrDatetimePrint(record.arrival_time, record.shift_date))} ${breakCell} ${isLeave ? "—" : escapeHtml(formatTimeOrDatetimePrint(record.departure_time, record.shift_date))} ${workMinutes > 0 ? `${hours}:${String(mins).padStart(2, "0")}` : "—"} ${buildProjectLogsHtml(record)} ${escapeHtml(record.notes || "")} `; }) .join(""); const fundRow = userData.fund !== null ? ` Fond měsíce: ${escapeHtml(String(userData.covered))}h / ${escapeHtml(String(userData.fund))}h ${renderFundStatus(userData)} ` : ""; return `

${escapeHtml(userData.name)}

Odpracováno: ${escapeHtml(formatMinutes(userData.minutes))} h
${leaveHtml} ${recordRows} ${fundRow}
Datum Typ Příchod Pauza Odchod Hodiny Projekty Poznámka
Odpracováno: ${escapeHtml(formatMinutes(userData.minutes))} h
`; } 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) { // ---- 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("-"); let recordsUrl = `${API_BASE}/attendance?year=${yearStr}&month=${monthStr}&limit=1000`; 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); 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); 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); 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 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); 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 }); 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, }; }