Files
app/src/admin/hooks/useAttendanceAdmin.ts
BOHA 6b31b2f74b feat: system settings, dynamic logos, template numbering, permission consolidation
- System settings page with tabs: Security, System, Firma
- Configurable attendance rules (break thresholds, rounding) from DB
- Configurable document numbering with template patterns ({YYYY}/{PREFIX}/{NNN})
- Dynamic logo upload (light/dark variants) served from DB instead of static files
- Email settings (SMTP from/name, alert/leave emails) configurable in UI
- Currency and VAT rate lists configurable, used across all modules
- Permissions simplified: offers.settings + settings.roles + settings.security → settings.manage
- Leaflet bundled locally, removed unpkg.com from CSP
- Silent catch blocks fixed with proper logging
- console.log replaced with app.log.info in server.ts
- Schema renamed: company-settings.schema → settings.schema
- App info section: version, Node.js, uptime, memory, DB status, NAS status

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 10:15:47 +01:00

1111 lines
35 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 renderFundStatus(userData: Record<string, any>): string {
if (userData.overtime > 0)
return `<span class="leave-badge badge-overtime">+${userData.overtime}h přesčas</span>`;
if (userData.missing > 0)
return `<span style="color:#dc2626">${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>${log.project_name || `#${log.project_id}`} (${h}:${String(m).padStart(2, "0")}h)</div>`;
})
.join("");
}
return 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á ${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: ${userData.vacation_hours}h</span>`;
if (userData.sick_hours > 0)
parts += ` | <span class="leave-badge badge-sick">Nemoc: ${userData.sick_hours}h</span>`;
if (userData.holiday_hours > 0)
parts += ` | <span class="leave-badge badge-holiday">Svátek: ${userData.holiday_hours}h</span>`;
if (userData.overtime > 0)
parts += ` | <span class="leave-badge badge-overtime">Přesčas: +${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
? "—"
: `${formatTimeOrDatetimePrint(record.break_start, record.shift_date)} - ${formatTimeOrDatetimePrint(record.break_end, record.shift_date)}`;
return `<tr>
<td>${formatDate(record.shift_date)}</td>
<td><span class="leave-badge ${getLeaveTypeBadgeClass(leaveType)}">${getLeaveTypeName(leaveType)}</span></td>
<td class="text-center">${isLeave ? "—" : formatTimeOrDatetimePrint(record.arrival_time, record.shift_date)}</td>
<td class="text-center">${breakCell}</td>
<td class="text-center">${isLeave ? "—" : 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>${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">${userData.covered}h / ${userData.fund}h</td>
<td colspan="2">${renderFundStatus(userData)}</td>
</tr>`
: "";
return `<div class="user-section">
<div class="user-header">
<h3>${userData.name}</h3>
<span class="total">Odpracováno: ${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">${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 - ${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">${companyName}</div>
</div>
</div>
<div class="print-header-right">
<div class="period">${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}/users?limit=1000`);
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: ${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.onload = () => printWindow.print();
}
}
} 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,
};
}