The labelled icon-badge tiles were inconsistent: some used a same-hue light tile + colored glyph (e.g. the Settings 2FA row: success.light tile + success.main glyph — low contrast, the reported "badly visible" icon), and the solid-tile ones used the bright .main fill in dark mode without darkening, so a white glyph on bright green/amber was low-contrast too (DashProfile's success badge was ~1.9:1). Add a single `iconBadgeSx(color)` helper (theme.ts): solid `<color>.main` fill + white glyph, darkened in dark mode (FILLED_DARK_BG) so white stays legible; `default` = neutral grey. Applied to every badge so they can't drift: StatCard, DashActivityFeed, DashProfile (2FA badge), Dashboard (2FA banner icon), AttendanceHistory (month tile), and the Settings 2FA row tile (now solid green when required / grey when optional + white lock, instead of the low-contrast light-green tile). Also switched DashProfile's backup-codes warning callout from warning.light to a subtle warning wash so its amber text stays readable in dark mode. tsc -b --noEmit, npm run build, vitest 152/152 all clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
884 lines
30 KiB
TypeScript
884 lines
30 KiB
TypeScript
import { useState, useMemo, useRef } from "react";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import Box from "@mui/material/Box";
|
|
import Typography from "@mui/material/Typography";
|
|
import { useAuth } from "../context/AuthContext";
|
|
import Forbidden from "../components/Forbidden";
|
|
import { companySettingsOptions } from "../lib/queries/settings";
|
|
import { iconBadgeSx } from "../theme";
|
|
import {
|
|
attendanceHistoryOptions,
|
|
type AttendanceRecord,
|
|
type ProjectLogEntry,
|
|
} from "../lib/queries/attendance";
|
|
import {
|
|
formatDate,
|
|
formatDatetime,
|
|
formatTime,
|
|
calculateWorkMinutes,
|
|
formatMinutes,
|
|
getLeaveTypeName,
|
|
getLeaveTypeBadgeClass,
|
|
calculateWorkMinutesPrint,
|
|
formatTimeOrDatetimePrint,
|
|
calculateFreeHolidayHours,
|
|
holidaysInMonth,
|
|
normalizeDateStr,
|
|
formatHoursDecimal,
|
|
} from "../utils/attendanceHelpers";
|
|
import {
|
|
Button,
|
|
Card,
|
|
DataTable,
|
|
FilterBar,
|
|
MonthField,
|
|
ProgressBar,
|
|
StatusChip,
|
|
PageHeader,
|
|
PageEnter,
|
|
EmptyState,
|
|
LoadingState,
|
|
type DataColumn,
|
|
} from "../ui";
|
|
|
|
const MONTH_NAMES = [
|
|
"Leden",
|
|
"Únor",
|
|
"Březen",
|
|
"Duben",
|
|
"Květen",
|
|
"Červen",
|
|
"Červenec",
|
|
"Srpen",
|
|
"Září",
|
|
"Říjen",
|
|
"Listopad",
|
|
"Prosinec",
|
|
];
|
|
|
|
const PrintIcon = (
|
|
<svg
|
|
width="18"
|
|
height="18"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
>
|
|
<polyline points="6 9 6 2 18 2 18 9" />
|
|
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
|
|
<rect x="6" y="14" width="12" height="8" />
|
|
</svg>
|
|
);
|
|
|
|
const formatBreakRange = (record: AttendanceRecord): string => {
|
|
if (record.break_start && record.break_end) {
|
|
return `${formatTime(record.break_start)} - ${formatTime(record.break_end)}`;
|
|
}
|
|
if (record.break_start) {
|
|
return `${formatTime(record.break_start)} - ?`;
|
|
}
|
|
return "—";
|
|
};
|
|
|
|
function getEasterSunday(year: number): string {
|
|
const a = year % 19;
|
|
const b = Math.floor(year / 100);
|
|
const c = year % 100;
|
|
const d = Math.floor(b / 4);
|
|
const e = b % 4;
|
|
const f = Math.floor((b + 8) / 25);
|
|
const g = Math.floor((b - f + 1) / 3);
|
|
const h = (19 * a + b - d - g + 15) % 30;
|
|
const i = Math.floor(c / 4);
|
|
const k = c % 4;
|
|
const l = (32 + 2 * e + 2 * i - h - k) % 7;
|
|
const m = Math.floor((a + 11 * h + 22 * l) / 451);
|
|
const month = Math.floor((h + l - 7 * m + 114) / 31);
|
|
const day = ((h + l - 7 * m + 114) % 31) + 1;
|
|
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
|
}
|
|
|
|
function getCzechHolidays(year: number): string[] {
|
|
const y = String(year);
|
|
const holidays = [
|
|
`${y}-01-01`,
|
|
`${y}-05-01`,
|
|
`${y}-05-08`,
|
|
`${y}-07-05`,
|
|
`${y}-07-06`,
|
|
`${y}-09-28`,
|
|
`${y}-10-28`,
|
|
`${y}-11-17`,
|
|
`${y}-12-24`,
|
|
`${y}-12-25`,
|
|
`${y}-12-26`,
|
|
];
|
|
const easterSunday = getEasterSunday(year);
|
|
const easterDate = new Date(easterSunday);
|
|
const goodFriday = new Date(easterDate);
|
|
goodFriday.setDate(goodFriday.getDate() - 2);
|
|
const easterMonday = new Date(easterDate);
|
|
easterMonday.setDate(easterMonday.getDate() + 1);
|
|
const pad = (n: number) => String(n).padStart(2, "0");
|
|
holidays.push(
|
|
`${goodFriday.getFullYear()}-${pad(goodFriday.getMonth() + 1)}-${pad(goodFriday.getDate())}`,
|
|
);
|
|
holidays.push(
|
|
`${easterMonday.getFullYear()}-${pad(easterMonday.getMonth() + 1)}-${pad(easterMonday.getDate())}`,
|
|
);
|
|
holidays.sort();
|
|
return holidays;
|
|
}
|
|
|
|
function getBusinessDaysInMonth(year: number, month: number): number {
|
|
const holidays = getCzechHolidays(year);
|
|
let count = 0;
|
|
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
|
for (let day = 1; day <= daysInMonth; day++) {
|
|
const date = new Date(year, month, day);
|
|
const dow = date.getDay();
|
|
if (dow !== 0 && dow !== 6) {
|
|
const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
|
if (!holidays.includes(dateStr)) {
|
|
count++;
|
|
}
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
const renderProjectCell = (record: AttendanceRecord) => {
|
|
if (record.project_logs && record.project_logs.length > 0) {
|
|
return (
|
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.25 }}>
|
|
{record.project_logs.map((log, i) => {
|
|
let h: number,
|
|
m: number,
|
|
isActive = false;
|
|
if (log.hours !== null && log.hours !== undefined) {
|
|
h = parseInt(String(log.hours)) || 0;
|
|
m = parseInt(String(log.minutes)) || 0;
|
|
} else {
|
|
isActive = !log.ended_at;
|
|
const end = log.ended_at ? new Date(log.ended_at) : new Date();
|
|
const mins = Math.max(
|
|
0,
|
|
Math.floor(
|
|
(end.getTime() - new Date(log.started_at!).getTime()) / 60000,
|
|
),
|
|
);
|
|
h = Math.floor(mins / 60);
|
|
m = mins % 60;
|
|
}
|
|
return (
|
|
<StatusChip
|
|
key={log.id || i}
|
|
color={isActive ? "info" : "default"}
|
|
label={`${log.project_name || `#${log.project_id}`} (${h}:${String(m).padStart(2, "0")}h${isActive ? " ▸" : ""})`}
|
|
sx={{ fontSize: "0.7rem" }}
|
|
/>
|
|
);
|
|
})}
|
|
</Box>
|
|
);
|
|
}
|
|
if (record.project_name) {
|
|
return (
|
|
<StatusChip
|
|
color="default"
|
|
label={record.project_name}
|
|
sx={{
|
|
fontSize: "0.75rem",
|
|
height: "auto",
|
|
"& .MuiChip-label": { whiteSpace: "normal", py: 0.25 },
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
return "—";
|
|
};
|
|
|
|
export default function AttendanceHistory() {
|
|
const { user, hasPermission } = useAuth();
|
|
const queryClient = useQueryClient();
|
|
const { data: companySettings } = useQuery(companySettingsOptions());
|
|
const companyName = companySettings?.company_name || "";
|
|
const printRef = useRef<HTMLDivElement>(null);
|
|
const [month, setMonth] = useState(() => {
|
|
const now = new Date();
|
|
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
|
});
|
|
const { data, isPending } = useQuery(
|
|
attendanceHistoryOptions({ month, userId: user?.id }),
|
|
);
|
|
const records = data ?? [];
|
|
|
|
const computed = useMemo(() => {
|
|
const [yearStr, monthStr] = month.split("-");
|
|
const monthIndex = parseInt(monthStr, 10) - 1;
|
|
const monthName = `${MONTH_NAMES[monthIndex]} ${yearStr}`;
|
|
|
|
let totalMinutes = 0;
|
|
let vacationHours = 0;
|
|
let sickHours = 0;
|
|
let unpaidHours = 0;
|
|
|
|
for (const record of records) {
|
|
const leaveType = record.leave_type || "work";
|
|
if (leaveType === "work") {
|
|
totalMinutes += calculateWorkMinutes(record);
|
|
} else {
|
|
const hours =
|
|
record.leave_hours != null ? Number(record.leave_hours) : 8;
|
|
if (leaveType === "vacation") vacationHours += hours;
|
|
else if (leaveType === "sick") sickHours += hours;
|
|
else if (leaveType === "unpaid") unpaidHours += hours;
|
|
// "holiday" is no longer a user-selectable leave_type — it is
|
|
// auto-computed from Czech public holidays by the print/KPI code.
|
|
// Old records may still exist; skip so they don't double-count.
|
|
}
|
|
}
|
|
|
|
const yr = parseInt(yearStr, 10);
|
|
const mo = parseInt(monthStr, 10) - 1;
|
|
const businessDays = getBusinessDaysInMonth(yr, mo);
|
|
// Fund counts Mon-Fri + holidays (holidays are paid via the fond).
|
|
const holidayDates = holidaysInMonth(yr, mo + 1);
|
|
const holidayCount = holidayDates.length;
|
|
const fund = (businessDays + holidayCount) * 8;
|
|
const worked = Math.round((totalMinutes / 60) * 100) / 100;
|
|
// Covered = worked + vacation + sick (NOT unpaid — unpaid is voluntary).
|
|
const leaveHours = vacationHours + sickHours;
|
|
const covered = Math.round((worked + leaveHours) * 100) / 100;
|
|
|
|
// fondUsed = real_worked + vacation + worked_holiday + free_holiday.
|
|
// (the all-in number: everything the user got paid for this month).
|
|
const realWorked = totalMinutes / 60;
|
|
const freeHolidayHours = calculateFreeHolidayHours(
|
|
records as unknown as Parameters<typeof calculateFreeHolidayHours>[0],
|
|
holidayDates,
|
|
);
|
|
const workedHolidayHours = records
|
|
.filter(
|
|
(r) =>
|
|
(r.leave_type || "work") === "work" &&
|
|
holidayDates.includes(normalizeDateStr(r.shift_date)),
|
|
)
|
|
.reduce((sum, r) => sum + calculateWorkMinutesPrint(r) / 60, 0);
|
|
const fondUsed =
|
|
realWorked + vacationHours + workedHolidayHours + freeHolidayHours;
|
|
const delta = fondUsed - fund;
|
|
const missing = Math.max(0, Math.round(-delta * 100) / 100);
|
|
const overtime = Math.max(0, Math.round(delta * 100) / 100);
|
|
const remaining = missing;
|
|
|
|
const monthlyFund = {
|
|
fund,
|
|
business_days: businessDays,
|
|
holiday_count: holidayCount,
|
|
worked: Math.round(fondUsed * 100) / 100,
|
|
covered,
|
|
remaining,
|
|
overtime,
|
|
};
|
|
|
|
return {
|
|
monthName,
|
|
totalMinutes,
|
|
vacationHours,
|
|
sickHours,
|
|
unpaidHours,
|
|
monthlyFund,
|
|
fondUsed,
|
|
freeHolidayHours,
|
|
workedHolidayHours,
|
|
delta,
|
|
};
|
|
}, [records, month]);
|
|
|
|
if (!hasPermission("attendance.history")) return <Forbidden />;
|
|
|
|
const handlePrint = () => {
|
|
if (!printRef.current) return;
|
|
const content = printRef.current.innerHTML;
|
|
const printWindow = window.open("", "_blank");
|
|
if (!printWindow) return;
|
|
printWindow.document.write(`
|
|
<!DOCTYPE html>
|
|
<html lang="cs">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Docházka - ${computed.monthName}</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-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; }
|
|
.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; }
|
|
.leave-summary {
|
|
margin-top: 10px;
|
|
padding: 8px 15px;
|
|
background: #f9f9f9;
|
|
border: 1px solid #ddd;
|
|
font-size: 10px;
|
|
}
|
|
.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-unpaid { background: #f3f4f6; color: #6b7280; }
|
|
.badge-overtime { background: #fef3c7; color: #d97706; }
|
|
@media print {
|
|
body { padding: 0; margin: 0; }
|
|
@page { size: A4 portrait; margin: 10mm; }
|
|
.user-section { page-break-inside: avoid; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
${content}
|
|
</body>
|
|
</html>
|
|
`);
|
|
printWindow.document.close();
|
|
printWindow.onload = () => {
|
|
printWindow.print();
|
|
};
|
|
};
|
|
|
|
const fund = computed.monthlyFund;
|
|
const progressValue =
|
|
fund.fund > 0 ? Math.min(100, (fund.worked / fund.fund) * 100) : 0;
|
|
const progressColor =
|
|
computed.delta > 0
|
|
? "warning"
|
|
: computed.delta >= 0
|
|
? "success"
|
|
: "primary";
|
|
const deltaColor =
|
|
computed.delta > 0
|
|
? "warning.main"
|
|
: computed.delta < 0
|
|
? "error.main"
|
|
: "success.main";
|
|
|
|
const columns: DataColumn<AttendanceRecord>[] = [
|
|
{
|
|
key: "date",
|
|
header: "Datum",
|
|
width: "10%",
|
|
mono: true,
|
|
render: (record) => formatDate(record.shift_date),
|
|
},
|
|
{
|
|
key: "type",
|
|
header: "Typ",
|
|
width: "11%",
|
|
render: (record) => {
|
|
const leaveType = record.leave_type || "work";
|
|
return (
|
|
<StatusChip
|
|
color={leaveTypeChipColor(leaveType)}
|
|
label={getLeaveTypeName(leaveType)}
|
|
/>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
key: "arrival",
|
|
header: "Příchod",
|
|
width: "10%",
|
|
mono: true,
|
|
render: (record) => {
|
|
const isLeave = (record.leave_type || "work") !== "work";
|
|
return isLeave ? "—" : formatDatetime(record.arrival_time);
|
|
},
|
|
},
|
|
{
|
|
key: "break",
|
|
header: "Pauza",
|
|
width: "11%",
|
|
mono: true,
|
|
render: (record) => {
|
|
const isLeave = (record.leave_type || "work") !== "work";
|
|
return isLeave ? "—" : formatBreakRange(record);
|
|
},
|
|
},
|
|
{
|
|
key: "departure",
|
|
header: "Odchod",
|
|
width: "10%",
|
|
mono: true,
|
|
render: (record) => {
|
|
const isLeave = (record.leave_type || "work") !== "work";
|
|
return isLeave ? "—" : formatDatetime(record.departure_time);
|
|
},
|
|
},
|
|
{
|
|
key: "hours",
|
|
header: "Hodiny",
|
|
width: "9%",
|
|
mono: true,
|
|
render: (record) => {
|
|
const leaveType = record.leave_type || "work";
|
|
const isLeave = leaveType !== "work";
|
|
const workMinutes = isLeave
|
|
? (Number(record.leave_hours) || 8) * 60
|
|
: calculateWorkMinutes(record);
|
|
return workMinutes > 0 ? formatMinutes(workMinutes, true) : "—";
|
|
},
|
|
},
|
|
{
|
|
key: "projects",
|
|
header: "Projekty",
|
|
width: "18%",
|
|
render: (record) => renderProjectCell(record),
|
|
},
|
|
{
|
|
key: "notes",
|
|
header: "Poznámka",
|
|
width: "11%",
|
|
render: (record) => record.notes || "",
|
|
},
|
|
];
|
|
|
|
return (
|
|
<PageEnter>
|
|
<PageHeader
|
|
title="Historie docházky"
|
|
subtitle={computed.monthName}
|
|
actions={
|
|
records.length > 0 ? (
|
|
<Button
|
|
variant="outlined"
|
|
color="inherit"
|
|
startIcon={PrintIcon}
|
|
onClick={handlePrint}
|
|
title="Tisk docházky"
|
|
>
|
|
Tisk
|
|
</Button>
|
|
) : undefined
|
|
}
|
|
/>
|
|
|
|
{/* Filters */}
|
|
<FilterBar>
|
|
<Box sx={{ flex: "0 0 180px" }}>
|
|
<MonthField value={month} onChange={(val) => setMonth(val)} />
|
|
</Box>
|
|
</FilterBar>
|
|
|
|
{/* Monthly Fund Card */}
|
|
<Card sx={{ mt: 3 }}>
|
|
{isPending ? (
|
|
<LoadingState />
|
|
) : (
|
|
<Box
|
|
sx={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 2,
|
|
flexWrap: "wrap",
|
|
}}
|
|
>
|
|
<Box
|
|
sx={[
|
|
{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
width: 44,
|
|
height: 44,
|
|
borderRadius: 2,
|
|
flexShrink: 0,
|
|
},
|
|
iconBadgeSx("info"),
|
|
]}
|
|
>
|
|
<svg
|
|
width="24"
|
|
height="24"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
>
|
|
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
|
<line x1="16" y1="2" x2="16" y2="6" />
|
|
<line x1="8" y1="2" x2="8" y2="6" />
|
|
<line x1="3" y1="10" x2="21" y2="10" />
|
|
</svg>
|
|
</Box>
|
|
<Box sx={{ flex: 1, minWidth: "200px" }}>
|
|
<Box
|
|
sx={{
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
alignItems: "baseline",
|
|
mb: 0.75,
|
|
}}
|
|
>
|
|
<Typography sx={{ fontWeight: 600, fontSize: "1rem" }}>
|
|
Fond: {fund.worked}h / {fund.fund}h
|
|
</Typography>
|
|
<Typography
|
|
variant="body2"
|
|
color="text.secondary"
|
|
sx={{ fontSize: "0.8125rem" }}
|
|
>
|
|
{fund.business_days} prac. dnů
|
|
{fund.holiday_count > 0 && ` + ${fund.holiday_count} svátky`}
|
|
</Typography>
|
|
</Box>
|
|
<ProgressBar value={progressValue} color={progressColor} />
|
|
<Box
|
|
sx={{
|
|
display: "flex",
|
|
flexWrap: "wrap",
|
|
gap: 0.5,
|
|
mt: 1,
|
|
minHeight: "1.5rem",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
}}
|
|
>
|
|
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5 }}>
|
|
{computed.totalMinutes > 0 && (
|
|
<StatusChip
|
|
color="info"
|
|
label={`Práce: ${formatHoursDecimal(computed.totalMinutes)}h`}
|
|
title="Odpracováno (reálně)"
|
|
/>
|
|
)}
|
|
{computed.vacationHours > 0 && (
|
|
<StatusChip
|
|
color="success"
|
|
label={`Dov: ${computed.vacationHours}h`}
|
|
/>
|
|
)}
|
|
{computed.sickHours > 0 && (
|
|
<StatusChip
|
|
color="error"
|
|
label={`Nem: ${computed.sickHours}h`}
|
|
/>
|
|
)}
|
|
{computed.freeHolidayHours > 0 && (
|
|
<StatusChip
|
|
color="warning"
|
|
label={`Sv: ${Math.round(computed.freeHolidayHours)}h`}
|
|
/>
|
|
)}
|
|
{computed.unpaidHours > 0 && (
|
|
<StatusChip
|
|
color="default"
|
|
label={`Nep: ${computed.unpaidHours}h`}
|
|
/>
|
|
)}
|
|
</Box>
|
|
<Typography
|
|
variant="caption"
|
|
sx={{ fontWeight: 600, color: deltaColor }}
|
|
>
|
|
{computed.delta > 0
|
|
? `Přesčas: +${formatHoursDecimal(computed.delta * 60)}h`
|
|
: computed.delta < 0
|
|
? `Zbývá: ${formatHoursDecimal(-computed.delta * 60)}h`
|
|
: "Fond splněn"}
|
|
</Typography>
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
</Card>
|
|
|
|
{/* Records Table */}
|
|
<Card sx={{ mt: 3 }}>
|
|
{isPending ? (
|
|
<LoadingState />
|
|
) : (
|
|
<DataTable<AttendanceRecord>
|
|
columns={columns}
|
|
rows={records}
|
|
rowKey={(record) => record.id}
|
|
empty={<EmptyState title="Za tento měsíc nejsou žádné záznamy." />}
|
|
/>
|
|
)}
|
|
</Card>
|
|
|
|
{/* Hidden Print Content */}
|
|
{records.length > 0 && (
|
|
<div ref={printRef} style={{ display: "none" }}>
|
|
<table className="print-wrapper-table">
|
|
<thead>
|
|
<tr>
|
|
<td>
|
|
<div className="print-header">
|
|
<div className="print-header-left">
|
|
<img
|
|
src="/api/admin/company-settings/logo?variant=light"
|
|
alt=""
|
|
className="print-logo"
|
|
/>
|
|
<div className="print-header-text">
|
|
<h1>EVIDENCE DOCHÁZKY</h1>
|
|
<div className="company">{companyName}</div>
|
|
</div>
|
|
</div>
|
|
<div className="print-header-right">
|
|
<div className="period">{computed.monthName}</div>
|
|
<div className="filters">
|
|
Zaměstnanec: {user?.fullName || ""}
|
|
</div>
|
|
<div className="generated">
|
|
Vygenerováno: {new Date().toLocaleString("cs-CZ")}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td>
|
|
<div className="user-section">
|
|
<div className="user-header">
|
|
<h3>{user?.fullName || ""}</h3>
|
|
<span className="total">
|
|
Odpracováno:{" "}
|
|
{formatMinutes(computed.totalMinutes, true)}
|
|
</span>
|
|
</div>
|
|
|
|
{(computed.vacationHours > 0 || computed.sickHours > 0) && (
|
|
<div className="leave-summary">
|
|
{computed.vacationHours > 0 && (
|
|
<>
|
|
<span className="leave-badge badge-vacation">
|
|
Dovolená: {computed.vacationHours}h
|
|
</span>{" "}
|
|
</>
|
|
)}
|
|
{computed.sickHours > 0 && (
|
|
<>
|
|
<span className="leave-badge badge-sick">
|
|
Nemoc: {computed.sickHours}h
|
|
</span>{" "}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th style={{ width: "70px" }}>Datum</th>
|
|
<th style={{ width: "70px" }}>Typ</th>
|
|
<th className="text-center" style={{ width: "70px" }}>
|
|
Příchod
|
|
</th>
|
|
<th className="text-center" style={{ width: "90px" }}>
|
|
Pauza
|
|
</th>
|
|
<th className="text-center" style={{ width: "70px" }}>
|
|
Odchod
|
|
</th>
|
|
<th className="text-center" style={{ width: "80px" }}>
|
|
Hodiny
|
|
</th>
|
|
<th>Projekty</th>
|
|
<th>Poznámka</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{[...records]
|
|
.sort((a, b) =>
|
|
a.shift_date.localeCompare(b.shift_date),
|
|
)
|
|
.map((record) => {
|
|
const leaveType = record.leave_type || "work";
|
|
const isLeave = leaveType !== "work";
|
|
const workMinutes =
|
|
calculateWorkMinutesPrint(record);
|
|
const hours = Math.floor(workMinutes / 60);
|
|
const mins = workMinutes % 60;
|
|
|
|
return (
|
|
<tr key={record.id}>
|
|
<td>{formatDate(record.shift_date)}</td>
|
|
<td>
|
|
<span
|
|
className={`leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}
|
|
>
|
|
{getLeaveTypeName(leaveType)}
|
|
</span>
|
|
</td>
|
|
<td className="text-center">
|
|
{isLeave
|
|
? "—"
|
|
: formatTimeOrDatetimePrint(
|
|
record.arrival_time,
|
|
record.shift_date,
|
|
)}
|
|
</td>
|
|
<td className="text-center">
|
|
{isLeave ||
|
|
!record.break_start ||
|
|
!record.break_end
|
|
? "—"
|
|
: `${formatTimeOrDatetimePrint(record.break_start, record.shift_date)} - ${formatTimeOrDatetimePrint(record.break_end, record.shift_date)}`}
|
|
</td>
|
|
<td className="text-center">
|
|
{isLeave
|
|
? "—"
|
|
: formatTimeOrDatetimePrint(
|
|
record.departure_time,
|
|
record.shift_date,
|
|
)}
|
|
</td>
|
|
<td className="text-center">
|
|
{workMinutes > 0
|
|
? `${hours}:${String(mins).padStart(2, "0")}`
|
|
: "—"}
|
|
</td>
|
|
<td style={{ fontSize: "8px" }}>
|
|
{record.project_logs &&
|
|
record.project_logs.length > 0
|
|
? record.project_logs.map((log, i) => {
|
|
let h: number, m: number;
|
|
if (
|
|
log.hours !== null &&
|
|
log.hours !== undefined
|
|
) {
|
|
h = parseInt(String(log.hours)) || 0;
|
|
m =
|
|
parseInt(String(log.minutes)) || 0;
|
|
} else if (
|
|
log.started_at &&
|
|
log.ended_at
|
|
) {
|
|
const mins2 = Math.max(
|
|
0,
|
|
Math.floor(
|
|
(new Date(
|
|
log.ended_at,
|
|
).getTime() -
|
|
new Date(
|
|
log.started_at,
|
|
).getTime()) /
|
|
60000,
|
|
),
|
|
);
|
|
h = Math.floor(mins2 / 60);
|
|
m = mins2 % 60;
|
|
} else {
|
|
h = 0;
|
|
m = 0;
|
|
}
|
|
return (
|
|
<div key={log.id || i}>
|
|
{log.project_name ||
|
|
`#${log.project_id}`}{" "}
|
|
({h}:{String(m).padStart(2, "0")}h)
|
|
</div>
|
|
);
|
|
})
|
|
: record.project_name || "—"}
|
|
</td>
|
|
<td>{record.notes || ""}</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
<tfoot>
|
|
<tr>
|
|
<td colSpan={6} className="text-right">
|
|
Odpracováno:
|
|
</td>
|
|
<td className="text-center">
|
|
{formatMinutes(computed.totalMinutes, true)}
|
|
</td>
|
|
<td colSpan={2}></td>
|
|
</tr>
|
|
</tfoot>
|
|
</table>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</PageEnter>
|
|
);
|
|
}
|
|
|
|
/** Map an attendance leave_type to a StatusChip semantic color (mirrors the
|
|
* legacy getLeaveTypeBadgeClass colors): work→info, vacation→info(blue),
|
|
* sick→error(red), unpaid→default(gray). */
|
|
function leaveTypeChipColor(
|
|
type: string,
|
|
): "default" | "success" | "error" | "warning" | "info" {
|
|
switch (type) {
|
|
case "vacation":
|
|
return "info";
|
|
case "sick":
|
|
return "error";
|
|
case "unpaid":
|
|
return "default";
|
|
default:
|
|
return "info";
|
|
}
|
|
}
|