UI fix:
- Close-only modals showing a redundant second close button now use
hideCancel: ReceivedInvoices paid-detail modal (was two identical "Zavřít"
buttons) and PlanCellModal "Den je součástí rozsahu".
- Add modal-duplicate-close test enforcing close-only modals set hideCancel.
Lint: cleared all 68 warnings → 0.
- preserve-caught-error: attach { cause } in ai.service / exchange-rates.
- no-require-imports: package.json version read via fs (APP_VERSION) instead
of require(), avoiding a rootDir-expanding static JSON import.
- react-hooks/exhaustive-deps (11): ref-in-cleanup copies, derived-value
useMemo wrapping, PlanGrid field extraction, stable nextKey useCallback,
AuthContext documented cycle-break.
- no-explicit-any (53): precise route param/Prisma types, generic enrich*()
preserving payload shape, minimal vite module type, frontend body/query-key
types, SystemInfo for Settings.
Refactor (test enablement): shift-form types moved to dependency-free
shiftFormTypes.ts so the print-HTML builders are unit-testable without the
component graph; characterization test pins their output.
Gates: 649 tests pass, tsc -b clean, lint 0. Verified touched flows live
via Playwright (PlanWork CRUD + optimistic cache, warehouse form keys,
Settings system info, invoice detail).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1427 lines
48 KiB
TypeScript
1427 lines
48 KiB
TypeScript
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,
|
||
calculateNightMinutes,
|
||
normalizeDateStr,
|
||
holidaysInMonth,
|
||
calculateFreeHolidayHours,
|
||
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
|
||
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;
|
||
}
|
||
}
|
||
|
||
// 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 Mon-Fri business days in month (INCLUDING holidays).
|
||
// The fund is rawBizDays × 8 — holidays stay in the count so the
|
||
// user is paid for them via the fund. Svátek (free holiday hours) is
|
||
// computed separately as "8h per holiday the user did not work".
|
||
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);
|
||
}
|
||
|
||
const holidayDates = holidaysInMonth(yr, mo + 1);
|
||
|
||
for (const uid of Object.keys(totals)) {
|
||
const t = totals[uid];
|
||
const userRecords = recordsByUser.get(Number(uid)) || [];
|
||
|
||
// Odpracováno: floor(worked / 8) × 8 — round to whole days, drop remainder.
|
||
const workedHoursRaw = t.minutes / 60;
|
||
const odpracovano = Math.floor(workedHoursRaw / 8) * 8;
|
||
|
||
// So/Ne: sum of worked hours on Sat/Sun (raw, not rounded).
|
||
const weekendHours = userRecords
|
||
.filter(
|
||
(r) =>
|
||
(r.leave_type || "work") === "work" && isWeekendDate(r.shift_date),
|
||
)
|
||
.reduce((sum, r) => sum + calculateWorkMinutesPrint(r) / 60, 0);
|
||
|
||
// Práce v noci: minutes in 22:00-06:00 across all work records.
|
||
const nightMinutes = userRecords
|
||
.filter((r) => (r.leave_type || "work") === "work")
|
||
.reduce((sum, r) => sum + calculateNightMinutes(r), 0);
|
||
|
||
// Svátek (free): 8h per holiday the user did not work.
|
||
const freeHolidayHours = calculateFreeHolidayHours(
|
||
userRecords,
|
||
holidayDates,
|
||
);
|
||
|
||
// Worked holidays: holidays the user DID work (counted toward
|
||
// Odpracováno, but their 8h must not flow into Přesčas).
|
||
const workedDatesSet = new Set(
|
||
userRecords
|
||
.filter((r) => (r.leave_type || "work") === "work")
|
||
.map((r) => normalizeDateStr(r.shift_date))
|
||
.filter(Boolean),
|
||
);
|
||
let workedHolidayCount = 0;
|
||
let workedHolidayHours = 0;
|
||
for (const hd of holidayDates) {
|
||
if (workedDatesSet.has(hd)) workedHolidayCount++;
|
||
}
|
||
// Sum the actual hours worked on holiday dates (for the KPI card's
|
||
// "fond used" total = real_work + vacation + worked_holiday + free_holiday).
|
||
for (const r of userRecords) {
|
||
if ((r.leave_type || "work") !== "work") continue;
|
||
const d = normalizeDateStr(r.shift_date);
|
||
if (d && holidayDates.includes(d)) {
|
||
workedHolidayHours += calculateWorkMinutesPrint(r) / 60;
|
||
}
|
||
}
|
||
|
||
// Fund = Mon-Fri × 8h. Holidays count as work days (already in rawBizDays).
|
||
const fund = rawBizDays * 8;
|
||
|
||
// Včetně svátků a přesčasů (mzda formula):
|
||
// odpracovano + vacation + remainder − min(freeHols, workedHols × 8)
|
||
//
|
||
// Two-way logic matching the print:
|
||
// • If the user worked at least one holiday, that worked holiday's
|
||
// 8h is in Odpracováno and shouldn't also count as Přesčas. We
|
||
// subtract one 8h per worked holiday (capped at the total free
|
||
// holiday hours — can't go negative).
|
||
// • If the user did NOT work any holiday, no adjustment is needed:
|
||
// all unworked holidays are already paid via the Svátek line and
|
||
// don't flow into Přesčas, so vcetne_svatku just equals the
|
||
// work + vacation + remainder.
|
||
const remainder = workedHoursRaw - odpracovano;
|
||
const holidayAdj = Math.min(freeHolidayHours, workedHolidayCount * 8);
|
||
const vcetneSv = odpracovano - holidayAdj + t.vacation_hours + remainder;
|
||
|
||
// Přesčas = vcetneSv − odpracovano (derived).
|
||
const prescas = vcetneSv - odpracovano;
|
||
|
||
// Legacy fields (kept so existing UI doesn't break).
|
||
const workedHours = Math.round(workedHoursRaw * 10) / 10;
|
||
const covered = Math.round((workedHours + t.vacation_hours) * 10) / 10;
|
||
|
||
t.fund = fund;
|
||
t.business_days = rawBizDays;
|
||
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);
|
||
|
||
t.worked_hours_raw = workedHoursRaw;
|
||
t.odpracovano = odpracovano;
|
||
t.vcetne_svatku = vcetneSv;
|
||
t.prescas = prescas;
|
||
t.svatek = freeHolidayHours;
|
||
t.weekend_hours = weekendHours;
|
||
t.night_minutes = nightMinutes;
|
||
t.worked_holiday_hours = workedHolidayHours;
|
||
}
|
||
|
||
return totals;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Print helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function escapeHtml(str: string): string {
|
||
return str
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
/** 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.
|
||
const recordsByDate = new Map<string, AttendanceRecord>();
|
||
for (const r of userData.records || []) {
|
||
recordsByDate.set(normalizeDateStr(r.shift_date), r);
|
||
}
|
||
|
||
// 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 record = recordsByDate.get(dateStr);
|
||
const weekend = isWeekendDate(dateStr);
|
||
const holiday = isHoliday(dateStr);
|
||
const leaveType = (record?.leave_type as string) || "work";
|
||
const rowClasses = [
|
||
weekend && "weekend",
|
||
holiday && "holiday",
|
||
leaveType === "vacation" && "vacation",
|
||
]
|
||
.filter(Boolean)
|
||
.join(" ");
|
||
|
||
if (!record) {
|
||
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;
|
||
}
|
||
|
||
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>`);
|
||
}
|
||
|
||
// ----- mzda-style summary numbers (computed here, not from userData,
|
||
// because the backend's getPrintData only returns the legacy fields). -----
|
||
|
||
// Worked minutes: sum of calculateWorkMinutesPrint across work records.
|
||
let workedMinutes = 0;
|
||
let weekendHoursRaw = 0;
|
||
let nightMinutes = 0;
|
||
for (const r of userRecords) {
|
||
const leaveType = r.leave_type || "work";
|
||
if (leaveType !== "work") continue;
|
||
const m = calculateWorkMinutesPrint(r);
|
||
workedMinutes += m;
|
||
if (isWeekendDate(r.shift_date)) {
|
||
weekendHoursRaw += m / 60;
|
||
}
|
||
nightMinutes += calculateNightMinutes(r);
|
||
}
|
||
|
||
// Sum of vacation/sick/unpaid hours (in hours, not minutes). The
|
||
// "holiday" leave_type is auto-computed from Czech holidays further down
|
||
// and no longer selectable in the UI, so it's deliberately omitted here.
|
||
let vacationHours = 0;
|
||
for (const r of userRecords) {
|
||
const lt = r.leave_type || "work";
|
||
if (lt === "work") continue;
|
||
const h = Number(r.leave_hours) || 8;
|
||
if (lt === "vacation") vacationHours += h;
|
||
}
|
||
|
||
// Odpracováno: floor(worked / 8) × 8.
|
||
const workedHoursRaw = workedMinutes / 60;
|
||
const odpracovano = Math.floor(workedHoursRaw / 8) * 8;
|
||
|
||
// Free Svátek: 8h per holiday date the user did not work.
|
||
const holidayDates = holidaysInMonth(yr, mo);
|
||
const workedDates = new Set(
|
||
userRecords
|
||
.filter((r) => (r.leave_type || "work") === "work")
|
||
.map((r) => normalizeDateStr(r.shift_date))
|
||
.filter(Boolean),
|
||
);
|
||
let freeHolidayHours = 0;
|
||
let workedHolidayCount = 0;
|
||
for (const hd of holidayDates) {
|
||
if (workedDates.has(hd)) workedHolidayCount++;
|
||
else freeHolidayHours += 8;
|
||
}
|
||
|
||
// Fund: count Mon-Fri (incl. holidays) × 8h.
|
||
let rawBizDays = 0;
|
||
const cur = new Date(yr, mo - 1, 1);
|
||
while (cur.getMonth() === mo - 1) {
|
||
const dow = cur.getDay();
|
||
if (dow !== 0 && dow !== 6) rawBizDays++;
|
||
cur.setDate(cur.getDate() + 1);
|
||
}
|
||
const businessDays = rawBizDays;
|
||
const fund = businessDays * 8;
|
||
|
||
// Včetně svátků a přesčasů: floor(worked/8)*8 − worked_holiday*8 + vacation + remainder.
|
||
// (A worked holiday counts toward Odpracováno, but its 8h should not flow
|
||
// into Přesčas — so we subtract it here. Unworked holidays are paid via
|
||
// the fund and don't affect this line.)
|
||
const remainder = workedHoursRaw - odpracovano;
|
||
const vcetneSv =
|
||
odpracovano - workedHolidayCount * 8 + vacationHours + remainder;
|
||
|
||
// Přesčas = #2 - #1.
|
||
const prescas = vcetneSv - odpracovano;
|
||
|
||
const summaryHtml = `<div class="user-summary">
|
||
<div class="summary-row">
|
||
<span class="summary-label">Odpracováno:</span>
|
||
<span class="summary-value">${formatHoursDecimal(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(vcetneSv * 60)}h</span>
|
||
</div>
|
||
<div class="summary-row">
|
||
<span class="summary-label">Dovolená:</span>
|
||
<span class="summary-value">${formatHoursDecimal(vacationHours * 60)}h</span>
|
||
</div>
|
||
<div class="summary-row">
|
||
<span class="summary-label">Přesčas:</span>
|
||
<span class="summary-value">${formatHoursDecimal(prescas * 60)}h</span>
|
||
</div>
|
||
<div class="summary-row">
|
||
<span class="summary-label">Svátek:</span>
|
||
<span class="summary-value">${formatHoursDecimal(freeHolidayHours * 60)}h</span>
|
||
</div>
|
||
<div class="summary-row">
|
||
<span class="summary-label">So/Ne:</span>
|
||
<span class="summary-value">${formatHoursDecimal(weekendHoursRaw * 60)}h</span>
|
||
</div>
|
||
<div class="summary-row">
|
||
<span class="summary-label">Práce v noci:</span>
|
||
<span class="summary-value">${formatHoursDecimal(nightMinutes)}h</span>
|
||
</div>
|
||
<div class="summary-row summary-fund">
|
||
<span class="summary-label">Fond měsíce:</span>
|
||
<span class="summary-value">${formatHoursDecimal(fund * 60)}h (${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; }
|
||
.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,
|
||
};
|
||
}
|