Files
app/src/admin/hooks/useAttendanceAdmin.ts
BOHA aa6c1b5094 refactor: fix all Low findings from FLAWS_REPORT audit
- Auth: TOTP params from config, JWT error logging, audit log failure
  logging, replaced_by_hash validation on token rotation
- Invoices: remove dead VAT code, consistent PDF permissions,
  WebP magic-byte detection, deduped exchange-rate fetches
- Orders/Offers: multipart limit from config, use paginated() helper,
  payment method from DB in PDF
- Projects: verify project exists before creating note
- Attendance: action_type enum validation, consistent local-time
  shift_date construction, holiday attendance in work fund,
  trips.view permission on last-km query
- Users: paginated() helper usage, remove duplicate dashboard keys,
  parallel currency conversion, single hashToken implementation
- Frontend: memoized customInput, reliable print onload, modal prop
  standardization (isOpen), ConfirmModal type icons, id===0 key
  fallback, Login useCallback, CompanySettings ConfirmModal,
  Attendance timeout cleanup, Dashboard memoization, beforeunload
  dirty-state warnings on Invoice/Offer/Order detail
- Schema: invoice_alert_log timestamp, config/env comment on
  Date.prototype.toJSON override
- Utils: exchange-rate inflight dedup

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 08:45:37 +02:00

1124 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, UserTotal>;
leave_balances: Record<string, LeaveBalance>;
}
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<number, string>,
month: string,
): Record<string, UserTotal> {
const totals: Record<string, UserTotal> = {};
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function renderFundStatus(userData: Record<string, any>): string {
if (userData.overtime > 0)
return `<span class="leave-badge badge-overtime">+${escapeHtml(String(userData.overtime))}h přesčas</span>`;
if (userData.missing > 0)
return `<span style="color:#dc2626">${escapeHtml(String(userData.missing))}h</span>`;
return '<span style="color:#16a34a">splněno</span>';
}
function buildProjectLogsHtml(record: Record<string, any>): 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 `<div>${escapeHtml(log.project_name || `#${log.project_id}`)} (${h}:${String(m).padStart(2, "0")}h)</div>`;
})
.join("");
}
return escapeHtml(record.project_name || "—");
}
function buildLeaveSummaryHtml(
userId: string,
userData: Record<string, any>,
printData: Record<string, any>,
): string {
const bal = printData.leave_balances[userId];
let parts = `<strong>Dovolená ${escapeHtml(String(printData.year))}:</strong> Zbývá ${bal.vacation_remaining.toFixed(1)}h z ${bal.vacation_total}h`;
if (userData.vacation_hours > 0)
parts += ` | <span class="leave-badge badge-vacation">Tento měsíc: ${escapeHtml(String(userData.vacation_hours))}h</span>`;
if (userData.sick_hours > 0)
parts += ` | <span class="leave-badge badge-sick">Nemoc: ${escapeHtml(String(userData.sick_hours))}h</span>`;
if (userData.holiday_hours > 0)
parts += ` | <span class="leave-badge badge-holiday">Svátek: ${escapeHtml(String(userData.holiday_hours))}h</span>`;
if (userData.overtime > 0)
parts += ` | <span class="leave-badge badge-overtime">Přesčas: +${escapeHtml(String(userData.overtime))}h</span>`;
return `<div class="leave-summary">${parts}</div>`;
}
function buildUserSectionHtml(
userId: string,
userData: Record<string, any>,
printData: Record<string, any>,
): 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 `<tr>
<td>${escapeHtml(formatDate(record.shift_date))}</td>
<td><span class="leave-badge ${escapeHtml(getLeaveTypeBadgeClass(leaveType))}">${escapeHtml(getLeaveTypeName(leaveType))}</span></td>
<td class="text-center">${isLeave ? "—" : escapeHtml(formatTimeOrDatetimePrint(record.arrival_time, record.shift_date))}</td>
<td class="text-center">${breakCell}</td>
<td class="text-center">${isLeave ? "—" : escapeHtml(formatTimeOrDatetimePrint(record.departure_time, record.shift_date))}</td>
<td class="text-center">${workMinutes > 0 ? `${hours}:${String(mins).padStart(2, "0")}` : "—"}</td>
<td style="font-size:8px">${buildProjectLogsHtml(record)}</td>
<td>${escapeHtml(record.notes || "")}</td>
</tr>`;
})
.join("");
const fundRow =
userData.fund !== null
? `<tr>
<td colspan="6" class="text-right">Fond měsíce:</td>
<td class="text-center">${escapeHtml(String(userData.covered))}h / ${escapeHtml(String(userData.fund))}h</td>
<td colspan="2">${renderFundStatus(userData)}</td>
</tr>`
: "";
return `<div class="user-section">
<div class="user-header">
<h3>${escapeHtml(userData.name)}</h3>
<span class="total">Odpracováno: ${escapeHtml(formatMinutes(userData.minutes))} h</span>
</div>
${leaveHtml}
<table>
<thead><tr>
<th style="width:70px">Datum</th>
<th style="width:70px">Typ</th>
<th class="text-center" style="width:70px">Příchod</th>
<th class="text-center" style="width:90px">Pauza</th>
<th class="text-center" style="width:70px">Odchod</th>
<th class="text-center" style="width:80px">Hodiny</th>
<th>Projekty</th>
<th>Poznámka</th>
</tr></thead>
<tbody>${recordRows}</tbody>
<tfoot>
<tr>
<td colspan="6" class="text-right">Odpracováno:</td>
<td class="text-center">${escapeHtml(formatMinutes(userData.minutes))} h</td>
<td colspan="2"></td>
</tr>
${fundRow}
</tfoot>
</table>
</div>`;
}
function buildPrintHtml(
pData: Record<string, any>,
userSections: string,
emptyMsg: string,
filterNote: string,
companyName: string,
): string {
return `<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Docházka - ${escapeHtml(pData.month_name)}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
font-size: 11px; line-height: 1.4; color: #000; background: #fff; padding: 15mm;
}
.print-header {
display: flex; justify-content: space-between; align-items: flex-start;
margin-bottom: 20px; padding-bottom: 15px; border-bottom: 2px solid #333;
}
.print-header-left { display: flex; align-items: center; gap: 12px; }
.print-logo { height: 40px; width: auto; }
.print-header-text { text-align: left; }
.print-header-right { text-align: right; }
.print-header h1 { font-size: 18px; font-weight: 700; margin-bottom: 3px; }
.print-header .company { font-size: 11px; color: #666; }
.print-header .period { font-size: 13px; font-weight: 600; color: #333; margin-bottom: 2px; }
.print-header .filters { font-size: 10px; color: #666; }
.print-header .generated { font-size: 9px; color: #888; margin-top: 5px; }
.user-section { margin-bottom: 25px; page-break-inside: avoid; }
.user-header {
background: #f5f5f5; border: 1px solid #ddd; padding: 10px 15px;
margin-bottom: 10px; display: flex; justify-content: space-between; align-items: center;
}
.user-header h3 { font-size: 13px; font-weight: 600; }
.user-header .total { font-size: 12px; font-weight: 600; }
.user-section table { width: 100%; border-collapse: collapse; margin-bottom: 15px; }
.user-section th, .user-section td { border: 1px solid #333; padding: 6px 8px; text-align: left; }
.user-section th { background: #333; color: #fff; font-weight: 600; font-size: 10px; text-transform: uppercase; }
.user-section td { font-size: 10px; }
.user-section tr:nth-child(even) { background: #f9f9f9; }
.text-center { text-align: center; }
.text-right { text-align: right; }
.user-section tfoot td { background: #eee; font-weight: 600; }
.leave-badge { display: inline-block; padding: 2px 6px; border-radius: 3px; font-size: 9px; font-weight: 500; }
.badge-vacation { background: #dbeafe; color: #1d4ed8; }
.badge-sick { background: #fee2e2; color: #dc2626; }
.badge-holiday { background: #dcfce7; color: #16a34a; }
.badge-unpaid { background: #f3f4f6; color: #6b7280; }
.badge-overtime { background: #fef3c7; color: #d97706; }
.leave-summary {
margin-top: 10px; padding: 8px 15px; background: #f9f9f9;
border: 1px solid #ddd; font-size: 10px;
}
.print-wrapper-table { width: 100%; border-collapse: collapse; border: none; }
.print-wrapper-table > thead > tr > td,
.print-wrapper-table > tbody > tr > td { padding: 0; border: none; background: none; }
@media print {
body { padding: 0; margin: 0; }
@page { size: A4 portrait; margin: 10mm; }
.user-section { page-break-inside: avoid; }
}
</style>
</head>
<body>
<table class="print-wrapper-table">
<thead><tr><td>
<div class="print-header">
<div class="print-header-left">
<img src="/api/admin/company-settings/logo?variant=light" alt="" class="print-logo" />
<div class="print-header-text">
<h1>EVIDENCE DOCHÁZKY</h1>
<div class="company">${escapeHtml(companyName)}</div>
</div>
</div>
<div class="print-header-right">
<div class="period">${escapeHtml(pData.month_name)}</div>
${filterNote}
<div class="generated">Vygenerováno: ${new Date().toLocaleString("cs-CZ")}</div>
</div>
</div>
</td></tr></thead>
<tbody><tr><td>
${userSections}
${emptyMsg}
</td></tr></tbody>
</table>
</body>
</html>`;
}
// ---------------------------------------------------------------------------
// 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<AttendanceData>({
records: [],
users: [],
user_totals: {},
leave_balances: {},
});
// ---- Bulk modal ----
const [showBulkModal, setShowBulkModal] = useState(false);
const [bulkSubmitting, setBulkSubmitting] = useState(false);
const [bulkForm, setBulkForm] = useState<BulkForm>({
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<ShiftFormData>({
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<AttendanceRecord | null>(
null,
);
const [editForm, setEditForm] = useState<ShiftFormData>({
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<DeleteConfirmState>({
show: false,
record: null,
});
// ---- Projects ----
const [projectList, setProjectList] = useState<Project[]>([]);
const [createProjectLogs, setCreateProjectLogs] = useState<ProjectLog[]>([]);
const [editProjectLogs, setEditProjectLogs] = useState<ProjectLog[]>([]);
// ---- Print ref (kept for API compat) ----
const printRef = useRef<HTMLDivElement | null>(null);
// ---- Ref to hold full user list for user_totals computation ----
const usersRef = useRef<Map<number, string>>(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<number, string>();
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<string, LeaveBalance> =
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<string, unknown> = {
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<string, unknown>).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<string, unknown> = {
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<string, any>, pData),
)
.join("");
const emptyMsg =
Object.keys(pData.user_totals).length === 0
? '<p style="text-align:center;padding:20px">Za vybrané období nejsou žádné záznamy.</p>'
: "";
const filterNote = pData.selected_user_name
? `<div class="filters">Zaměstnanec: ${escapeHtml(pData.selected_user_name)}</div>`
: "";
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,
};
}