Files
app/src/admin/hooks/useAttendanceAdmin.ts
BOHA 996012e3d3 feat(attendance): 'Chybějící hodiny' (manko) row — real hours below the fond
manko = max(0, fond − (worked_total + dovolená + nemoc + neplacené volno))
using REAL clock time, not the paušál. Supplement next to Přesčas — the
verified binding formulas are untouched.

Why: the paušál Odpracováno can nominally cover the fond while dozens of
real hours are missing (Dominik 6/2026: 21 days × 8 + 8h dovolená = 176 =
fond exactly, thanks to weekend shifts compensating 3 recordless weekdays —
yet real worked time was 136,5h). Přesčas = 0 correctly, and the 31,5h gap
vanished from every total. Now it's a permanent summary line, red+bold when
positive, plus a 'Chybí: Xh' chip on the admin screen cards.

+4 tests incl. the real June dataset as a pinned acceptance case (also
pins noc 6,75 — early shifts starting 05:30/05:45 legitimately count
night minutes before 06:00).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 00:12:20 +02:00

1311 lines
44 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 { useQueryClient } from "@tanstack/react-query";
import apiFetch from "../utils/api";
import { isHoliday } from "../../utils/czech-holidays";
import {
calcProjectMinutesTotal,
calcFormWorkMinutes,
calculateWorkMinutes,
combineDatetime,
getDatePart,
getTimePart,
formatDate,
formatMinutes,
getLeaveTypeName,
formatTimeOrDatetimePrint,
calculateWorkMinutesPrint,
isWeekendDate,
getDaysInMonth,
shiftDateForMonth,
formatHoursDecimal,
normalizeDateStr,
computeMzdaSummary,
getCzechWeekday,
} from "../utils/attendanceHelpers";
import type {
ShiftFormData,
ProjectLog,
Project,
User,
} from "../components/shiftFormTypes";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface AlertContext {
alert: { success: (msg: string) => void; error: (msg: string) => void };
}
export interface AttendanceRecord {
id: number;
user_id: number;
shift_date: string;
leave_type?: string;
leave_hours?: number;
arrival_time?: string | null;
departure_time?: string | null;
break_start?: string | null;
break_end?: string | null;
notes?: string | null;
project_id?: number | null;
project_name?: string;
project_logs?: Array<{
id?: number;
project_id: number;
project_name?: string;
started_at?: string;
ended_at?: string | null;
hours?: string | number | null;
minutes?: string | number | null;
}>;
user_name?: string;
users?: {
id: number;
first_name: string;
last_name: string;
username: string;
};
}
interface ApiUser {
id: number;
first_name: string;
last_name: string;
username: string;
}
interface UserTotal {
name: string;
minutes: number;
working: boolean;
vacation_hours: number;
sick_hours: number;
unpaid_hours: number;
overtime: number;
missing: number;
fund: number | null;
business_days: number;
worked_hours: number;
covered: number;
// mzda-style summary fields (set by computeUserTotals)
worked_hours_raw?: number;
odpracovano?: number;
vcetne_svatku?: number;
prescas?: number;
svatek?: number;
weekend_hours?: number;
night_minutes?: number;
worked_holiday_hours?: number; // sum of hours worked ON a holiday
manko?: number; // Chybějící hodiny: real clock time below the fond
records?: AttendanceRecord[];
}
interface LeaveBalance {
vacation_total: number;
vacation_remaining: number;
}
interface BulkForm {
month: string;
user_ids: string[];
arrival_time: string;
departure_time: string;
break_start_time: string;
break_end_time: string;
}
interface AttendanceData {
records: AttendanceRecord[];
users: User[];
user_totals: Record<string, UserTotal>;
leave_balances: Record<string, LeaveBalance>;
}
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<number, string>,
month: string,
): Record<string, UserTotal> {
const totals: Record<string, UserTotal> = {};
const recordsByUser = new Map<number, AttendanceRecord[]>();
for (const rec of records) {
const uid = String(rec.user_id);
if (!totals[uid]) {
const name =
userMap.get(rec.user_id) ??
(rec.users
? `${rec.users.first_name} ${rec.users.last_name}`.trim() ||
rec.users.username
: `User #${rec.user_id}`);
totals[uid] = {
name,
minutes: 0,
working: false,
vacation_hours: 0,
sick_hours: 0,
unpaid_hours: 0,
overtime: 0,
missing: 0,
fund: null,
business_days: 0,
worked_hours: 0,
covered: 0,
records: [],
};
}
const t = totals[uid];
t.records!.push(rec);
if (!recordsByUser.has(rec.user_id)) recordsByUser.set(rec.user_id, []);
recordsByUser.get(rec.user_id)!.push(rec);
const leaveType = rec.leave_type || "work";
if (leaveType === "work") {
// Only work records contribute to "minutes" (matching PHP calculateUserTotals)
t.minutes += calculateWorkMinutes({
...rec,
notes: rec.notes ?? undefined,
});
} else {
const leaveHours = Number(rec.leave_hours) || 8;
switch (leaveType) {
case "vacation":
t.vacation_hours += leaveHours;
break;
case "sick":
t.sick_hours += leaveHours;
break;
case "unpaid":
t.unpaid_hours += leaveHours;
break;
// "holiday" leave_type is no longer selectable in the UI — it is
// auto-computed from Czech public holidays (see freeHolidayHours
// below). Old records may still exist in the DB; treat them as a
// paid non-work entry and skip so they don't double-count with
// the auto-detected svátek.
}
}
// Track if user is currently working (has arrival but no departure)
if (rec.arrival_time && !rec.departure_time) {
t.working = true;
}
}
// Rekapitulace per user — the SAME shared computation the print uses
// (computeMzdaSummary), so the screen and the printed sheet can never
// disagree. Accountant spec 2026-07: Odpracováno = worked minus
// holiday-worked; Vč. svátků = ALL worked; Přesčas = max(0, worked fond);
// Svátek = hours actually worked on holidays.
for (const uid of Object.keys(totals)) {
const t = totals[uid];
const userRecords = recordsByUser.get(Number(uid)) || [];
const s = computeMzdaSummary(userRecords, month);
// Legacy fields (kept so existing UI doesn't break).
const workedHours = Math.round(s.workedHoursRaw * 10) / 10;
const covered = Math.round((workedHours + t.vacation_hours) * 10) / 10;
t.fund = s.fund;
t.business_days = s.businessDays;
t.worked_hours = workedHours;
t.covered = covered;
t.missing = Math.max(0, Math.round((s.fund - covered) * 10) / 10);
t.overtime = Math.max(0, Math.round((covered - s.fund) * 10) / 10);
t.worked_hours_raw = s.workedHoursRaw;
t.odpracovano = s.odpracovano;
t.vcetne_svatku = s.vcetneSvatku;
t.prescas = s.prescas;
t.svatek = s.svatekHours;
t.weekend_hours = s.weekendHours;
t.night_minutes = s.nightMinutes;
t.worked_holiday_hours = s.svatekHours;
t.manko = s.manko;
}
return totals;
}
// ---------------------------------------------------------------------------
// Print helpers
// ---------------------------------------------------------------------------
function escapeHtml(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
/** Shape of the print endpoint payload consumed by the HTML builders. */
interface PrintData {
month: string;
month_name: string;
selected_user_name?: string | null;
user_totals: Record<string, UserTotal>;
}
export function buildProjectLogsHtml(record: AttendanceRecord): string {
if (record.project_logs && record.project_logs.length > 0) {
return record.project_logs
.map((log) => {
let h: number, m: number;
if (log.hours !== null && log.hours !== undefined) {
// hours/minutes arrive as string or number — String() before parseInt
// (parseInt already coerces to string internally, so this is a no-op
// at runtime, it just satisfies parseInt's string parameter type).
h = parseInt(String(log.hours)) || 0;
m = parseInt(String(log.minutes)) || 0;
} else if (log.started_at && log.ended_at) {
const mins = Math.max(
0,
Math.floor(
(new Date(log.ended_at).getTime() -
new Date(log.started_at).getTime()) /
60000,
),
);
h = Math.floor(mins / 60);
m = mins % 60;
} else {
h = 0;
m = 0;
}
return `<div>${escapeHtml(log.project_name || `#${log.project_id}`)} (${h}:${String(m).padStart(2, "0")}h)</div>`;
})
.join("");
}
return escapeHtml(record.project_name || "—");
}
export function buildUserSectionHtml(
userId: string,
userData: UserTotal,
printData: PrintData,
): string {
// Build a date-keyed lookup of the user's records. A day may hold MULTIPLE
// records (two shifts, or work + partial-day vacation) — keep them ALL; a
// single-record map silently dropped every shift but the last one.
const recordsByDate = new Map<string, AttendanceRecord[]>();
for (const r of userData.records || []) {
const key = normalizeDateStr(r.shift_date);
const list = recordsByDate.get(key);
if (list) list.push(r);
else recordsByDate.set(key, [r]);
}
// Stable in-day order: by arrival time (leave records without times last).
for (const list of recordsByDate.values()) {
list.sort((a, b) =>
String(a.arrival_time || "9999").localeCompare(
String(b.arrival_time || "9999"),
),
);
}
// Iterate one row per day of the month.
const [yearStr, monthStr] = printData.month.split("-");
const yr = parseInt(yearStr, 10);
const mo = parseInt(monthStr, 10);
const daysInMonth = getDaysInMonth(yr, mo);
const userRecords = userData.records ?? [];
const rows: string[] = [];
for (let d = 1; d <= daysInMonth; d++) {
const dateStr = shiftDateForMonth(yr, mo, d);
const dayRecords = recordsByDate.get(dateStr);
const weekend = isWeekendDate(dateStr);
const holiday = isHoliday(dateStr);
if (!dayRecords || dayRecords.length === 0) {
const rowClasses = [weekend && "weekend", holiday && "holiday"]
.filter(Boolean)
.join(" ");
rows.push(`<tr class="${rowClasses}">
<td>${escapeHtml(formatDate(dateStr))}</td>
<td>${escapeHtml(getCzechWeekday(dateStr))}</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr>`);
continue;
}
// One row PER RECORD — a day can hold two shifts, or work + partial-day
// vacation. (A single row per day silently dropped the extra shifts.)
for (const record of dayRecords) {
const leaveType = (record.leave_type as string) || "work";
const rowClasses = [
weekend && "weekend",
holiday && "holiday",
leaveType === "vacation" && "vacation",
]
.filter(Boolean)
.join(" ");
const isLeave = leaveType !== "work";
const workMinutes = calculateWorkMinutesPrint(record);
const hours = Math.floor(workMinutes / 60);
const mins = workMinutes % 60;
const breakCell =
isLeave || !record.break_start || !record.break_end
? "—"
: `${escapeHtml(formatTimeOrDatetimePrint(record.break_start, record.shift_date))} - ${escapeHtml(formatTimeOrDatetimePrint(record.break_end, record.shift_date))}`;
let hoursCell: string;
if (workMinutes > 0 && !isLeave) {
hoursCell = `${hours}:${String(mins).padStart(2, "0")} (${formatHoursDecimal(workMinutes)})`;
} else if (isLeave) {
hoursCell = formatHoursDecimal((Number(record.leave_hours) || 8) * 60);
} else {
hoursCell = "—";
}
rows.push(`<tr class="${rowClasses}">
<td>${escapeHtml(formatDate(dateStr))}</td>
<td>${escapeHtml(getCzechWeekday(dateStr))}</td>
<td>${escapeHtml(getLeaveTypeName(leaveType))}</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">${hoursCell}</td>
<td style="font-size:8px">${buildProjectLogsHtml(record)}</td>
<td>${escapeHtml(record.notes || "")}</td>
</tr>`);
}
}
// Rekapitulace — single shared implementation (accountant spec 2026-07):
// Odpracováno = all worked hours EXCEPT hours worked on holidays
// Vč. svátků a přesč. = ALL worked hours (line2 line1 = Svátek)
// Přesčas = max(0, worked fond)
// Svátek = hours actually worked on holiday dates
const s = computeMzdaSummary(userRecords, printData.month);
const summaryHtml = `<div class="user-summary">
<div class="summary-row">
<span class="summary-label">Odpracováno:</span>
<span class="summary-value">${formatHoursDecimal(s.odpracovano * 60)}h</span>
</div>
<div class="summary-row">
<span class="summary-label">Odpracováno včetně svátků a přesčasů:</span>
<span class="summary-value">${formatHoursDecimal(s.vcetneSvatku * 60)}h</span>
</div>
<div class="summary-row">
<span class="summary-label">Dovolená:</span>
<span class="summary-value">${formatHoursDecimal(s.vacationHours * 60)}h</span>
</div>
<div class="summary-row">
<span class="summary-label">Přesčas:</span>
<span class="summary-value">${formatHoursDecimal(s.prescas * 60)}h</span>
</div>
<div class="summary-row${s.manko > 0 ? " summary-alert" : ""}">
<span class="summary-label">Chybějící hodiny:</span>
<span class="summary-value">${formatHoursDecimal(s.manko * 60)}h</span>
</div>
<div class="summary-row">
<span class="summary-label">Svátek:</span>
<span class="summary-value">${formatHoursDecimal(s.svatekHours * 60)}h</span>
</div>
<div class="summary-row">
<span class="summary-label">So/Ne:</span>
<span class="summary-value">${formatHoursDecimal(s.weekendHours * 60)}h</span>
</div>
<div class="summary-row">
<span class="summary-label">Práce v noci:</span>
<span class="summary-value">${formatHoursDecimal(s.nightMinutes)}h</span>
</div>
<div class="summary-row summary-fund">
<span class="summary-label">Fond měsíce:</span>
<span class="summary-value">${formatHoursDecimal(s.fund * 60)}h (${s.businessDays} dnů)</span>
</div>
<div class="legend">
<span class="legend-item"><span class="legend-swatch weekend"></span>Víkend (So/Ne)</span>
<span class="legend-item"><span class="legend-swatch holiday"></span>Svátek</span>
<span class="legend-item"><span class="legend-swatch weekend-holiday"></span>Víkend + svátek</span>
<span class="legend-item"><span class="legend-swatch vacation"></span>Dovolená</span>
</div>
</div>`;
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>
<table>
<thead><tr>
<th style="width:55px">Datum</th>
<th style="width:55px">Den</th>
<th style="width:60px">Typ</th>
<th class="text-center" style="width:55px">Příchod</th>
<th class="text-center" style="width:80px">Pauza</th>
<th class="text-center" style="width:55px">Odchod</th>
<th class="text-center" style="width:85px">Hodiny</th>
<th>Projekty</th>
<th>Poznámka</th>
</tr></thead>
<tbody>${rows.join("")}</tbody>
</table>
${summaryHtml}
</div>`;
}
export function buildPrintHtml(
pData: PrintData,
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;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
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; }
/* Weekend and holiday row highlighting — must come after the
:nth-child(even) rule and use higher specificity (tr.X td) to win. */
.user-section tr.weekend td { background: #fde68a; }
.user-section tr.holiday td { background: #bbf7d0; }
.user-section tr.weekend.holiday td { background: #fcd34d; }
.user-section tr.vacation td { background: #bfdbfe; }
.text-center { text-align: center; }
.text-right { text-align: right; }
.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; }
.user-summary {
margin-top: 10px; padding: 10px 15px;
background: #f9f9f9; border: 1px solid #ddd; font-size: 10px;
}
.user-summary .summary-row {
display: flex; justify-content: space-between; padding: 2px 0;
}
.user-summary .summary-label { color: #555; }
.user-summary .summary-value { font-weight: 600; }
/* Chybějící hodiny > 0 — must jump out of the sheet, not hide in it. */
.user-summary .summary-alert .summary-label,
.user-summary .summary-alert .summary-value { color: #b00020; font-weight: 700; }
.user-summary .summary-fund {
border-top: 1px solid #ddd; margin-top: 4px; padding-top: 6px;
}
.legend {
display: flex; flex-wrap: wrap; gap: 14px;
margin-top: 8px; padding-top: 6px;
border-top: 1px dashed #ddd;
font-size: 9px; color: #555;
}
.legend-item { display: inline-flex; align-items: center; gap: 5px; }
.legend-swatch {
display: inline-block; width: 12px; height: 12px;
border: 1px solid #333; vertical-align: middle;
}
.legend-swatch.weekend { background: #fde68a; }
.legend-swatch.holiday { background: #bbf7d0; }
.legend-swatch.weekend-holiday { background: #fcd34d; }
.legend-swatch.vacation { background: #bfdbfe; }
.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) {
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<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("-");
// 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<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.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<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);
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<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);
queryClient.invalidateQueries({ queryKey: ["attendance"] });
// The dashboard embeds attendance (Přítomní dnes / Docházka dnes /
// punch-button state) — without this it serves a stale cache for up
// to its staleTime after records change here.
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
await fetchData(false);
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(
result.message || result.data?.message || "Záznam aktualizován",
);
} else {
alert.error(result.error || "Nepodařilo se uložit");
}
} catch {
alert.error("Chyba připojení");
}
};
// =========================================================================
// Delete
// =========================================================================
const handleDelete = async () => {
if (!deleteConfirm.record) return;
try {
const response = await apiFetch(
`${API_BASE}/attendance/${deleteConfirm.record.id}`,
{ method: "DELETE" },
);
const result = await response.json();
if (result.success) {
setDeleteConfirm({ show: false, record: null });
queryClient.invalidateQueries({ queryKey: ["attendance"] });
// The dashboard embeds attendance (Přítomní dnes / Docházka dnes /
// punch-button state) — without this it serves a stale cache for up
// to its staleTime after records change here.
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
await fetchData(false);
alert.success(
result.message || result.data?.message || "Záznam smazán",
);
} else {
alert.error(result.error || "Nepodařilo se smazat");
}
} catch {
alert.error("Chyba připojení");
}
};
// =========================================================================
// Print
// =========================================================================
const handlePrint = async () => {
try {
const [response, settingsRes] = await Promise.all([
apiFetch(
`${API_BASE}/attendance?action=print&month=${month}${filterUserId ? `&user_id=${filterUserId}` : ""}`,
),
apiFetch(`${API_BASE}/company-settings`),
]);
const settingsData = await settingsRes.json();
const companyName = settingsData.success
? settingsData.data.company_name || ""
: "";
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
const pData = result.data;
const userSections = Object.entries(pData.user_totals)
.map(([uid, uData]) =>
buildUserSectionHtml(uid, uData as UserTotal, pData),
)
.join("");
const emptyMsg =
Object.keys(pData.user_totals).length === 0
? '<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,
};
}