- 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>
1111 lines
35 KiB
TypeScript
1111 lines
35 KiB
TypeScript
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,
|
||
};
|
||
}
|