315 lines
8.3 KiB
TypeScript
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." />}
|
|
/>
|
|
);
|
|
}
|