Files
app/src/admin/components/AttendanceShiftTable.tsx
2026-06-07 00:57:26 +02:00

315 lines
8.3 KiB
TypeScript

import { Link as RouterLink } from "react-router-dom";
import Box from "@mui/material/Box";
import IconButton from "@mui/material/IconButton";
import {
formatDate,
formatDatetime,
formatTime,
calculateWorkMinutes,
formatMinutes,
getLeaveTypeName,
} from "../utils/attendanceHelpers";
import { DataTable, StatusChip, EmptyState, type DataColumn } from "../ui";
import type { AttendanceRecord as HookAttendanceRecord } from "../hooks/useAttendanceAdmin";
interface AttendanceRecord extends HookAttendanceRecord {
arrival_lat?: number | string | null;
arrival_lng?: number | string | null;
departure_lat?: number | string | null;
departure_lng?: number | string | null;
}
interface AttendanceShiftTableProps {
records: AttendanceRecord[];
onEdit: (record: AttendanceRecord) => void;
onDelete: (record: AttendanceRecord) => void;
}
const EditIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
);
const DeleteIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
);
/** 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";
}
}
function formatBreak(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 renderProjectCell(record: AttendanceRecord): React.ReactNode {
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;
let durationValid = true;
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 start = log.started_at ? new Date(log.started_at) : null;
if (start && !isNaN(start.getTime()) && !isNaN(end.getTime())) {
const mins = Math.max(
0,
Math.floor((end.getTime() - start.getTime()) / 60000),
);
h = Math.floor(mins / 60);
m = mins % 60;
} else {
durationValid = false;
h = 0;
m = 0;
}
}
return (
<StatusChip
key={log.id ?? i}
color={isActive ? "info" : "default"}
label={`${log.project_name || `#${log.project_id}`} ${
durationValid
? `(${h}:${String(m).padStart(2, "0")}h${isActive ? " ▸" : ""})`
: "—"
}`}
sx={{ fontSize: "0.7rem", maxWidth: "100%" }}
/>
);
})}
</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 AttendanceShiftTable({
records,
onEdit,
onDelete,
}: AttendanceShiftTableProps) {
const columns: DataColumn<AttendanceRecord>[] = [
{
key: "date",
header: "Datum",
width: "9%",
mono: true,
render: (record) => formatDate(record.shift_date),
},
{
key: "user",
header: "Zaměstnanec",
width: "14%",
render: (record) => record.user_name,
},
{
key: "type",
header: "Typ",
width: "10%",
render: (record) => {
const leaveType = record.leave_type || "work";
return (
<StatusChip
color={leaveTypeChipColor(leaveType)}
label={getLeaveTypeName(leaveType)}
/>
);
},
},
{
key: "arrival",
header: "Příchod",
width: "9%",
mono: true,
render: (record) => {
const isLeave = (record.leave_type || "work") !== "work";
return isLeave ? "—" : formatDatetime(record.arrival_time);
},
},
{
key: "break",
header: "Pauza",
width: "10%",
mono: true,
render: (record) => {
const isLeave = (record.leave_type || "work") !== "work";
return isLeave ? "—" : formatBreak(record);
},
},
{
key: "departure",
header: "Odchod",
width: "9%",
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
? (record.leave_hours != null ? Number(record.leave_hours) : 8) * 60
: calculateWorkMinutes({
...record,
notes: record.notes ?? undefined,
});
return workMinutes > 0 ? `${formatMinutes(workMinutes)} h` : "—";
},
},
{
key: "project",
header: "Projekt",
width: "13%",
render: (record) => renderProjectCell(record),
},
{
key: "gps",
header: "GPS",
width: "5%",
render: (record) => {
const hasLocation =
(record.arrival_lat && record.arrival_lng) ||
(record.departure_lat && record.departure_lng);
return hasLocation ? (
<Box
component={RouterLink}
to={`/attendance/location/${record.id}`}
title="Zobrazit polohu"
aria-label="Zobrazit polohu"
sx={{ textDecoration: "none" }}
>
<span aria-hidden="true">{"📍"}</span>
</Box>
) : (
"—"
);
},
},
{
key: "notes",
header: "Poznámka",
width: "9%",
render: (record) => (
<Box
component="span"
title={record.notes || ""}
sx={{
display: "block",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{record.notes || ""}
</Box>
),
},
{
key: "actions",
header: "Akce",
width: "8%",
align: "right",
render: (record) => (
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
<IconButton
size="small"
onClick={() => onEdit(record)}
aria-label="Upravit"
title="Upravit"
>
{EditIcon}
</IconButton>
<IconButton
size="small"
color="error"
onClick={() => onDelete(record)}
aria-label="Smazat"
title="Smazat"
>
{DeleteIcon}
</IconButton>
</Box>
),
},
];
return (
<DataTable<AttendanceRecord>
columns={columns}
rows={records}
rowKey={(record) => record.id}
empty={<EmptyState title="Za tento měsíc nejsou žádné záznamy." />}
/>
);
}