feat(mui): migrate Attendance Admin (Správa docházky) onto MUI kit

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-07 00:57:26 +02:00
parent 3eb364fba5
commit c690bd9a24
4 changed files with 914 additions and 785 deletions

View File

@@ -1,4 +1,6 @@
import { Link } from "react-router-dom"; import { Link as RouterLink } from "react-router-dom";
import Box from "@mui/material/Box";
import IconButton from "@mui/material/IconButton";
import { import {
formatDate, formatDate,
formatDatetime, formatDatetime,
@@ -6,8 +8,8 @@ import {
calculateWorkMinutes, calculateWorkMinutes,
formatMinutes, formatMinutes,
getLeaveTypeName, getLeaveTypeName,
getLeaveTypeBadgeClass,
} from "../utils/attendanceHelpers"; } from "../utils/attendanceHelpers";
import { DataTable, StatusChip, EmptyState, type DataColumn } from "../ui";
import type { AttendanceRecord as HookAttendanceRecord } from "../hooks/useAttendanceAdmin"; import type { AttendanceRecord as HookAttendanceRecord } from "../hooks/useAttendanceAdmin";
interface AttendanceRecord extends HookAttendanceRecord { interface AttendanceRecord extends HookAttendanceRecord {
@@ -23,6 +25,56 @@ interface AttendanceShiftTableProps {
onDelete: (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 { function formatBreak(record: AttendanceRecord): string {
if (record.break_start && record.break_end) { if (record.break_start && record.break_end) {
return `${formatTime(record.break_start)} - ${formatTime(record.break_end)}`; return `${formatTime(record.break_start)} - ${formatTime(record.break_end)}`;
@@ -36,9 +88,7 @@ function formatBreak(record: AttendanceRecord): string {
function renderProjectCell(record: AttendanceRecord): React.ReactNode { function renderProjectCell(record: AttendanceRecord): React.ReactNode {
if (record.project_logs && record.project_logs.length > 0) { if (record.project_logs && record.project_logs.length > 0) {
return ( return (
<div <Box sx={{ display: "flex", flexDirection: "column", gap: 0.25 }}>
style={{ display: "flex", flexDirection: "column", gap: "0.125rem" }}
>
{record.project_logs.map((log, i) => { {record.project_logs.map((log, i) => {
let h: number, let h: number,
m: number, m: number,
@@ -65,33 +115,32 @@ function renderProjectCell(record: AttendanceRecord): React.ReactNode {
} }
} }
return ( return (
<span <StatusChip
key={log.id ?? i} key={log.id ?? i}
className="admin-badge" color={isActive ? "info" : "default"}
style={{ label={`${log.project_name || `#${log.project_id}`} ${
fontSize: "0.7rem", durationValid
display: "inline-block",
background: isActive ? "var(--accent-light)" : undefined,
}}
>
{log.project_name || `#${log.project_id}`}{" "}
{durationValid
? `(${h}:${String(m).padStart(2, "0")}h${isActive ? " ▸" : ""})` ? `(${h}:${String(m).padStart(2, "0")}h${isActive ? " ▸" : ""})`
: "—"} : "—"
</span> }`}
sx={{ fontSize: "0.7rem", maxWidth: "100%" }}
/>
); );
})} })}
</div> </Box>
); );
} }
if (record.project_name) { if (record.project_name) {
return ( return (
<span <StatusChip
className="admin-badge admin-badge-wrap" color="default"
style={{ fontSize: "0.75rem" }} label={record.project_name}
> sx={{
{record.project_name} fontSize: "0.75rem",
</span> height: "auto",
"& .MuiChip-label": { whiteSpace: "normal", py: 0.25 },
}}
/>
); );
} }
return "—"; return "—";
@@ -102,141 +151,164 @@ export default function AttendanceShiftTable({
onEdit, onEdit,
onDelete, onDelete,
}: AttendanceShiftTableProps) { }: AttendanceShiftTableProps) {
if (records.length === 0) { 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 ( return (
<div className="admin-empty-state"> <StatusChip
<p>Za tento měsíc nejsou žádné záznamy.</p> color={leaveTypeChipColor(leaveType)}
</div> label={getLeaveTypeName(leaveType)}
/>
); );
} },
},
return ( {
<div className="admin-table-responsive"> key: "arrival",
<table className="admin-table"> header: "Příchod",
<thead> width: "9%",
<tr> mono: true,
<th>Datum</th> render: (record) => {
<th>Zaměstnanec</th> const isLeave = (record.leave_type || "work") !== "work";
<th>Typ</th> return isLeave ? "—" : formatDatetime(record.arrival_time);
<th>Příchod</th> },
<th>Pauza</th> },
<th>Odchod</th> {
<th>Hodiny</th> key: "break",
<th>Projekt</th> header: "Pauza",
<th>GPS</th> width: "10%",
<th>Poznámka</th> mono: true,
<th>Akce</th> render: (record) => {
</tr> const isLeave = (record.leave_type || "work") !== "work";
</thead> return isLeave ? "—" : formatBreak(record);
<tbody> },
{records.map((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 leaveType = record.leave_type || "work";
const isLeave = leaveType !== "work"; const isLeave = leaveType !== "work";
const workMinutes = isLeave const workMinutes = isLeave
? (record.leave_hours != null ? Number(record.leave_hours) : 8) * ? (record.leave_hours != null ? Number(record.leave_hours) : 8) * 60
60
: calculateWorkMinutes({ : calculateWorkMinutes({
...record, ...record,
notes: record.notes ?? undefined, 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 = const hasLocation =
(record.arrival_lat && record.arrival_lng) || (record.arrival_lat && record.arrival_lng) ||
(record.departure_lat && record.departure_lng); (record.departure_lat && record.departure_lng);
return hasLocation ? (
return ( <Box
<tr key={record.id}> component={RouterLink}
<td className="admin-mono">{formatDate(record.shift_date)}</td>
<td>{record.user_name}</td>
<td>
<span
className={`attendance-leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}
>
{getLeaveTypeName(leaveType)}
</span>
</td>
<td className="admin-mono">
{isLeave ? "—" : formatDatetime(record.arrival_time)}
</td>
<td className="admin-mono">
{isLeave ? "—" : formatBreak(record)}
</td>
<td className="admin-mono">
{isLeave ? "—" : formatDatetime(record.departure_time)}
</td>
<td className="admin-mono">
{workMinutes > 0 ? `${formatMinutes(workMinutes)} h` : "—"}
</td>
<td>{renderProjectCell(record)}</td>
<td>
{hasLocation ? (
<Link
to={`/attendance/location/${record.id}`} to={`/attendance/location/${record.id}`}
className="attendance-gps-link"
title="Zobrazit polohu" title="Zobrazit polohu"
aria-label="Zobrazit polohu" aria-label="Zobrazit polohu"
sx={{ textDecoration: "none" }}
> >
<span aria-hidden="true">{"📍"}</span> <span aria-hidden="true">{"📍"}</span>
</Link> </Box>
) : ( ) : (
"—" "—"
)} );
</td> },
<td },
style={{ {
maxWidth: "100px", key: "notes",
header: "Poznámka",
width: "9%",
render: (record) => (
<Box
component="span"
title={record.notes || ""}
sx={{
display: "block",
overflow: "hidden", overflow: "hidden",
textOverflow: "ellipsis", textOverflow: "ellipsis",
whiteSpace: "nowrap", whiteSpace: "nowrap",
}} }}
title={record.notes || ""}
> >
{record.notes || ""} {record.notes || ""}
</td> </Box>
<td> ),
<div className="admin-table-actions"> },
<button {
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)} onClick={() => onEdit(record)}
className="admin-btn-icon"
title="Upravit"
aria-label="Upravit" aria-label="Upravit"
title="Upravit"
> >
<svg {EditIcon}
width="18" </IconButton>
height="18" <IconButton
viewBox="0 0 24 24" size="small"
fill="none" color="error"
stroke="currentColor"
strokeWidth="2"
>
<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>
</button>
<button
onClick={() => onDelete(record)} onClick={() => onDelete(record)}
className="admin-btn-icon danger"
title="Smazat"
aria-label="Smazat" aria-label="Smazat"
title="Smazat"
> >
<svg {DeleteIcon}
width="18" </IconButton>
height="18" </Box>
viewBox="0 0 24 24" ),
fill="none" },
stroke="currentColor" ];
strokeWidth="2"
> return (
<polyline points="3 6 5 6 21 6" /> <DataTable<AttendanceRecord>
<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" /> columns={columns}
</svg> rows={records}
</button> rowKey={(record) => record.id}
</div> empty={<EmptyState title="Za tento měsíc nejsou žádné záznamy." />}
</td> />
</tr>
);
})}
</tbody>
</table>
</div>
); );
} }

View File

@@ -1,5 +1,6 @@
import FormModal from "./FormModal"; import Box from "@mui/material/Box";
import AdminDatePicker from "./AdminDatePicker"; import Typography from "@mui/material/Typography";
import { Modal, MonthField, TimeField, Field, CheckboxField } from "../ui";
interface BulkAttendanceForm { interface BulkAttendanceForm {
month: string; month: string;
@@ -39,127 +40,136 @@ export default function BulkAttendanceModal({
toggleAllUsers, toggleAllUsers,
}: BulkAttendanceModalProps) { }: BulkAttendanceModalProps) {
return ( return (
<FormModal <Modal
isOpen={isOpen} isOpen={isOpen}
onClose={onClose} onClose={onClose}
title="Vyplnit docházku za měsíc" title="Vyplnit docházku za měsíc"
size="lg" maxWidth="lg"
loading={submitting} loading={submitting}
onSubmit={onSubmit} onSubmit={onSubmit}
submitLabel="Vyplnit měsíc" submitText="Vyplnit měsíc"
submitDisabled={form.user_ids.length === 0} submitDisabled={form.user_ids.length === 0}
> >
<p <Typography
style={{ variant="body2"
color: "var(--text-secondary)", color="text.secondary"
marginTop: "0.25rem", sx={{ mt: 0.25, mb: 2 }}
marginBottom: "1rem",
fontSize: "0.875rem",
}}
> >
Vytvoří záznamy pro všechny pracovní dny. Svátky se automaticky označí. Vytvoří záznamy pro všechny pracovní dny. Svátky se automaticky označí.
Existující záznamy se přeskočí. Existující záznamy se přeskočí.
</p> </Typography>
<div className="admin-form"> <Field label="Měsíc">
<div className="admin-form-group"> <MonthField
<label className="admin-form-label">Měsíc</label>
<AdminDatePicker
mode="month"
value={form.month} value={form.month}
onChange={(val) => setForm({ ...form, month: val })} onChange={(val) => setForm({ ...form, month: val })}
/> />
</div> </Field>
<div className="admin-form-group"> <Box sx={{ mb: 2 }}>
<label className="admin-form-label"> <Box
sx={{
display: "flex",
alignItems: "center",
gap: 1.5,
mb: 0.5,
}}
>
<Typography
component="span"
variant="body2"
sx={{ fontWeight: 600, color: "text.secondary" }}
>
Zaměstnanci Zaměstnanci
<button </Typography>
<Typography
component="button"
type="button" type="button"
onClick={toggleAllUsers} onClick={toggleAllUsers}
style={{ variant="caption"
marginLeft: "0.75rem", sx={{
background: "none", background: "none",
border: "none", border: "none",
color: "var(--accent-color)", p: 0,
cursor: "pointer", cursor: "pointer",
fontSize: "0.8125rem",
fontWeight: 500, fontWeight: 500,
padding: 0, color: "primary.main",
}} }}
> >
{form.user_ids.length === users.length {form.user_ids.length === users.length
? "Odznačit vše" ? "Odznačit vše"
: "Vybrat vše"} : "Vybrat vše"}
</button> </Typography>
</label> </Box>
<div <Box
style={{ sx={{
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
gap: "0.375rem", gap: 0.25,
maxHeight: "200px", maxHeight: 200,
overflowY: "auto", overflowY: "auto",
padding: "0.75rem", p: 1.5,
background: "var(--bg-tertiary)", bgcolor: "action.hover",
borderRadius: "var(--border-radius-sm)", borderRadius: 2,
border: "1px solid var(--border-color)", border: 1,
borderColor: "divider",
}} }}
> >
{users.map((user) => ( {users.map((user) => (
<label key={user.id} className="admin-form-checkbox"> <CheckboxField
<input key={user.id}
type="checkbox" label={user.name}
checked={form.user_ids.includes(String(user.id))} checked={form.user_ids.includes(String(user.id))}
onChange={() => toggleUser(user.id)} onChange={() => toggleUser(user.id)}
/> />
<span>{user.name}</span>
</label>
))} ))}
</div> </Box>
<small className="admin-form-hint"> <Typography variant="caption" color="text.secondary">
Vybráno: {form.user_ids.length} z {users.length} Vybráno: {form.user_ids.length} z {users.length}
</small> </Typography>
</div> </Box>
<div className="admin-form-row"> <Box
<div className="admin-form-group"> sx={{
<label className="admin-form-label">Příchod</label> display: "grid",
<AdminDatePicker gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
mode="time" gap: 2,
}}
>
<Field label="Příchod">
<TimeField
value={form.arrival_time} value={form.arrival_time}
onChange={(val) => setForm({ ...form, arrival_time: val })} onChange={(val) => setForm({ ...form, arrival_time: val })}
/> />
</div> </Field>
<div className="admin-form-group"> <Field label="Odchod">
<label className="admin-form-label">Odchod</label> <TimeField
<AdminDatePicker
mode="time"
value={form.departure_time} value={form.departure_time}
onChange={(val) => setForm({ ...form, departure_time: val })} onChange={(val) => setForm({ ...form, departure_time: val })}
/> />
</div> </Field>
</div> </Box>
<div className="admin-form-row"> <Box
<div className="admin-form-group"> sx={{
<label className="admin-form-label">Začátek pauzy</label> display: "grid",
<AdminDatePicker gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
mode="time" gap: 2,
}}
>
<Field label="Začátek pauzy">
<TimeField
value={form.break_start_time} value={form.break_start_time}
onChange={(val) => setForm({ ...form, break_start_time: val })} onChange={(val) => setForm({ ...form, break_start_time: val })}
/> />
</div> </Field>
<div className="admin-form-group"> <Field label="Konec pauzy">
<label className="admin-form-label">Konec pauzy</label> <TimeField
<AdminDatePicker
mode="time"
value={form.break_end_time} value={form.break_end_time}
onChange={(val) => setForm({ ...form, break_end_time: val })} onChange={(val) => setForm({ ...form, break_end_time: val })}
/> />
</div> </Field>
</div> </Box>
</div> </Modal>
</FormModal>
); );
} }

View File

@@ -1,5 +1,16 @@
import AdminDatePicker from "./AdminDatePicker"; import Box from "@mui/material/Box";
import FormModal from "./FormModal"; import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import {
Modal,
Button,
Select,
TextField,
DateField,
TimeField,
Field,
Alert,
} from "../ui";
import { import {
calcFormWorkMinutes, calcFormWorkMinutes,
calcProjectMinutesTotal, calcProjectMinutesTotal,
@@ -80,6 +91,19 @@ export interface ShiftFormModalProps {
editingRecord: EditingRecord | null; editingRecord: EditingRecord | null;
} }
const RemoveIcon = (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
);
// ---------- ProjectTimeStatus ---------- // ---------- ProjectTimeStatus ----------
function ProjectTimeStatus({ form, projectLogs }: ProjectTimeStatusProps) { function ProjectTimeStatus({ form, projectLogs }: ProjectTimeStatusProps) {
@@ -100,30 +124,14 @@ function ProjectTimeStatus({ form, projectLogs }: ProjectTimeStatusProps) {
const isMatch = remaining === 0; const isMatch = remaining === 0;
return ( return (
<div <Box sx={{ mb: 1 }}>
style={{ <Alert severity={isMatch ? "success" : "error"}>
padding: "0.5rem 0.75rem", Odpracováno: {Math.floor(totalWork / 60)}h {totalWork % 60}m |
marginBottom: "0.5rem", Přiřazeno: {Math.floor(totalProject / 60)}h {totalProject % 60}m |
borderRadius: "6px", Zbývá: {Math.floor(Math.abs(remaining) / 60)}h{" "}
fontSize: "0.8rem", {Math.abs(remaining) % 60}m {remaining < 0 ? "(překročeno)" : ""}
background: isMatch </Alert>
? "var(--success-bg, rgba(34,197,94,0.1))" </Box>
: "var(--danger-bg, rgba(239,68,68,0.1))",
color: isMatch
? "var(--success-color, #16a34a)"
: "var(--danger-color, #dc2626)",
border: `1px solid ${
isMatch
? "var(--success-border, rgba(34,197,94,0.3))"
: "var(--danger-border, rgba(239,68,68,0.3))"
}`,
}}
>
Odpracováno: {Math.floor(totalWork / 60)}h {totalWork % 60}m | Přiřazeno:{" "}
{Math.floor(totalProject / 60)}h {totalProject % 60}m | Zbývá:{" "}
{Math.floor(Math.abs(remaining) / 60)}h {Math.abs(remaining) % 60}m{" "}
{remaining < 0 ? "(překročeno)" : ""}
</div>
); );
} }
@@ -137,65 +145,65 @@ function ProjectLogRow({
onRemove, onRemove,
}: ProjectLogRowProps) { }: ProjectLogRowProps) {
return ( return (
<div className="flex-row gap-2 mb-2"> <Box
<select sx={{
value={log.project_id} display: "flex",
onChange={(e) => onUpdate(index, "project_id", e.target.value)} alignItems: "center",
className="admin-form-select" gap: 1,
style={{ flex: 3, marginBottom: 0 }} mb: 1,
}}
> >
<option value=""> Projekt </option> <Box sx={{ flex: 3, minWidth: 0 }}>
{projectList.map((p) => ( <Select
<option key={p.id} value={p.id}> value={String(log.project_id)}
{p.project_number} {p.name} onChange={(val) => onUpdate(index, "project_id", val)}
</option> options={[
))} { value: "", label: "— Projekt —" },
</select> ...projectList.map((p) => ({
<input value: String(p.id),
label: `${p.project_number} ${p.name}`,
})),
]}
/>
</Box>
<Box sx={{ width: 70, flexShrink: 0 }}>
<TextField
type="number" type="number"
min="0"
max="24"
value={log.hours} value={log.hours}
onChange={(e) => onUpdate(index, "hours", e.target.value)} onChange={(e) => onUpdate(index, "hours", e.target.value)}
className="admin-form-input"
style={{ width: "60px", marginBottom: 0, textAlign: "center" }}
placeholder="h" placeholder="h"
slotProps={{
htmlInput: { min: 0, max: 24, style: { textAlign: "center" } },
}}
/> />
<span style={{ fontSize: "0.85rem", color: "var(--text-secondary)" }}> </Box>
<Typography variant="body2" color="text.secondary">
h h
</span> </Typography>
<input <Box sx={{ width: 70, flexShrink: 0 }}>
<TextField
type="number" type="number"
min="0"
max="59"
value={log.minutes} value={log.minutes}
onChange={(e) => onUpdate(index, "minutes", e.target.value)} onChange={(e) => onUpdate(index, "minutes", e.target.value)}
className="admin-form-input"
style={{ width: "60px", marginBottom: 0, textAlign: "center" }}
placeholder="m" placeholder="m"
slotProps={{
htmlInput: { min: 0, max: 59, style: { textAlign: "center" } },
}}
/> />
<span style={{ fontSize: "0.85rem", color: "var(--text-secondary)" }}> </Box>
<Typography variant="body2" color="text.secondary">
m m
</span> </Typography>
<button <IconButton
type="button" size="small"
onClick={() => onRemove(index)} onClick={() => onRemove(index)}
className="admin-btn admin-btn-secondary admin-btn-sm" aria-label="Odebrat"
style={{ padding: "0.375rem", flexShrink: 0 }}
title="Odebrat" title="Odebrat"
sx={{ flexShrink: 0 }}
> >
<svg {RemoveIcon}
width="16" </IconButton>
height="16" </Box>
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
); );
} }
@@ -245,7 +253,7 @@ export default function ShiftFormModal({
}; };
return ( return (
<FormModal <Modal
isOpen={isOpen} isOpen={isOpen}
onClose={onClose} onClose={onClose}
title={isCreate ? "Přidat záznam docházky" : "Upravit docházku"} title={isCreate ? "Přidat záznam docházky" : "Upravit docházku"}
@@ -254,169 +262,172 @@ export default function ShiftFormModal({
? `${editingRecord.user_name}${formatDate(editingRecord.shift_date)}` ? `${editingRecord.user_name}${formatDate(editingRecord.shift_date)}`
: undefined : undefined
} }
size="lg" maxWidth="lg"
onSubmit={onSubmit} onSubmit={onSubmit}
submitLabel="Uložit" submitText="Uložit"
cancelLabel="Zrušit" cancelText="Zrušit"
> >
<div className="admin-form">
{isCreate ? ( {isCreate ? (
<div className="admin-form-row"> <Box
<div className="admin-form-group"> sx={{
<label className="admin-form-label required">Zaměstnanec</label> display: "grid",
<select gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
value={form.user_id} gap: 2,
onChange={(e) => updateField("user_id", e.target.value)} }}
className="admin-form-select"
> >
<option value="">Vyberte zaměstnance</option> <Field label="Zaměstnanec" required>
{users.map((user) => ( <Select
<option key={user.id} value={user.id}> value={form.user_id}
{user.name} onChange={(val) => updateField("user_id", val)}
</option> options={[
))} { value: "", label: "Vyberte zaměstnance" },
</select> ...users.map((user) => ({
</div> value: String(user.id),
<div className="admin-form-group"> label: user.name,
<label className="admin-form-label required">Datum směny</label> })),
<AdminDatePicker ]}
mode="date" />
</Field>
<Field label="Datum směny" required>
<DateField
value={form.shift_date} value={form.shift_date}
onChange={(val) => onShiftDateChange(val)} onChange={(val) => onShiftDateChange(val)}
/> />
</div> </Field>
</div> </Box>
) : ( ) : (
<div className="admin-form-group"> <Field label="Datum směny">
<label className="admin-form-label">Datum směny</label> <DateField
<AdminDatePicker
mode="date"
value={form.shift_date} value={form.shift_date}
onChange={(val) => updateField("shift_date", val)} onChange={(val) => updateField("shift_date", val)}
/> />
</div> </Field>
)} )}
<div className="admin-form-group"> <Field label="Typ záznamu">
<label className="admin-form-label">Typ záznamu</label> <Select
<select
value={form.leave_type} value={form.leave_type}
onChange={(e) => updateField("leave_type", e.target.value)} onChange={(val) => updateField("leave_type", val)}
className="admin-form-select" options={[
> { value: "work", label: "Práce" },
<option value="work">Práce</option> { value: "vacation", label: "Dovolená" },
<option value="vacation">Dovolená</option> { value: "sick", label: "Nemoc" },
<option value="sick">Nemoc</option> { value: "unpaid", label: "Neplacené volno" },
<option value="unpaid">Neplacené volno</option> ]}
</select> />
</div> </Field>
{!isWorkType && ( {!isWorkType && (
<div className="admin-form-group"> <Field
<label className="admin-form-label">Počet hodin</label> label="Počet hodin"
<input hint={isCreate ? "8 hodin = celý den" : undefined}
>
<TextField
type="number" type="number"
inputMode="decimal" inputMode="decimal"
value={form.leave_hours} value={form.leave_hours}
onChange={(e) => onChange={(e) =>
updateField("leave_hours", parseFloat(e.target.value)) updateField("leave_hours", parseFloat(e.target.value))
} }
min="0.5" slotProps={{ htmlInput: { min: 0.5, max: 24, step: 0.5 } }}
max="24"
step="0.5"
className="admin-form-input"
/> />
{isCreate && ( </Field>
<small className="admin-form-hint">8 hodin = celý den</small>
)}
</div>
)} )}
{isWorkType && ( {isWorkType && (
<> <>
<div className="admin-form-row"> {/* Arrival */}
<div className="admin-form-group"> <Box
<label className="admin-form-label">Příchod - datum</label> sx={{
<AdminDatePicker display: "grid",
mode="date" gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Příchod - datum">
<DateField
value={form.arrival_date} value={form.arrival_date}
onChange={(val) => updateField("arrival_date", val)} onChange={(val) => updateField("arrival_date", val)}
/> />
</div> </Field>
<div className="admin-form-group"> <Field label="Příchod - čas">
<label className="admin-form-label">Příchod - čas</label> <TimeField
<AdminDatePicker
mode="time"
value={form.arrival_time} value={form.arrival_time}
onChange={(val) => updateField("arrival_time", val)} onChange={(val) => updateField("arrival_time", val)}
/> />
</div> </Field>
</div> </Box>
<div className="admin-form-row"> {/* Break start */}
<div className="admin-form-group"> <Box
<label className="admin-form-label"> sx={{
Začátek pauzy - datum display: "grid",
</label> gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
<AdminDatePicker gap: 2,
mode="date" }}
>
<Field label="Začátek pauzy - datum">
<DateField
value={form.break_start_date} value={form.break_start_date}
onChange={(val) => updateField("break_start_date", val)} onChange={(val) => updateField("break_start_date", val)}
/> />
</div> </Field>
<div className="admin-form-group"> <Field label="Začátek pauzy - čas">
<label className="admin-form-label">Začátek pauzy - čas</label> <TimeField
<AdminDatePicker
mode="time"
value={form.break_start_time} value={form.break_start_time}
onChange={(val) => updateField("break_start_time", val)} onChange={(val) => updateField("break_start_time", val)}
/> />
</div> </Field>
</div> </Box>
<div className="admin-form-row"> {/* Break end */}
<div className="admin-form-group"> <Box
<label className="admin-form-label">Konec pauzy - datum</label> sx={{
<AdminDatePicker display: "grid",
mode="date" gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Konec pauzy - datum">
<DateField
value={form.break_end_date} value={form.break_end_date}
onChange={(val) => updateField("break_end_date", val)} onChange={(val) => updateField("break_end_date", val)}
/> />
</div> </Field>
<div className="admin-form-group"> <Field label="Konec pauzy - čas">
<label className="admin-form-label">Konec pauzy - čas</label> <TimeField
<AdminDatePicker
mode="time"
value={form.break_end_time} value={form.break_end_time}
onChange={(val) => updateField("break_end_time", val)} onChange={(val) => updateField("break_end_time", val)}
/> />
</div> </Field>
</div> </Box>
<div className="admin-form-row"> {/* Departure */}
<div className="admin-form-group"> <Box
<label className="admin-form-label">Odchod - datum</label> sx={{
<AdminDatePicker display: "grid",
mode="date" gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Odchod - datum">
<DateField
value={form.departure_date} value={form.departure_date}
onChange={(val) => updateField("departure_date", val)} onChange={(val) => updateField("departure_date", val)}
/> />
</div> </Field>
<div className="admin-form-group"> <Field label="Odchod - čas">
<label className="admin-form-label">Odchod - čas</label> <TimeField
<AdminDatePicker
mode="time"
value={form.departure_time} value={form.departure_time}
onChange={(val) => updateField("departure_time", val)} onChange={(val) => updateField("departure_time", val)}
/> />
</div> </Field>
</div> </Box>
</> </>
)} )}
{isWorkType && projectList.length > 0 && ( {isWorkType && projectList.length > 0 && (
<div className="admin-form-group"> <Field label="Projekty">
<label className="admin-form-label">Projekty</label>
<ProjectTimeStatus form={form} projectLogs={projectLogs} /> <ProjectTimeStatus form={form} projectLogs={projectLogs} />
{projectLogs.map((log, i) => ( {projectLogs.map((log, i) => (
<ProjectLogRow <ProjectLogRow
@@ -428,26 +439,25 @@ export default function ShiftFormModal({
onRemove={removeProjectLog} onRemove={removeProjectLog}
/> />
))} ))}
<button <Button
type="button" variant="outlined"
color="inherit"
size="small"
onClick={addProjectLog} onClick={addProjectLog}
className="admin-btn admin-btn-secondary admin-btn-sm"
> >
+ Přidat projekt + Přidat projekt
</button> </Button>
</div> </Field>
)} )}
<div className="admin-form-group"> <Field label="Poznámka">
<label className="admin-form-label">Poznámka</label> <TextField
<textarea multiline
minRows={3}
value={form.notes} value={form.notes}
onChange={(e) => updateField("notes", e.target.value)} onChange={(e) => updateField("notes", e.target.value)}
className="admin-form-textarea"
rows={3}
/> />
</div> </Field>
</div> </Modal>
</FormModal>
); );
} }

View File

@@ -1,16 +1,30 @@
import { motion } from "framer-motion";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
import { motion } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import AdminDatePicker from "../components/AdminDatePicker";
import BulkAttendanceModal from "../components/BulkAttendanceModal"; import BulkAttendanceModal from "../components/BulkAttendanceModal";
import ShiftFormModal from "../components/ShiftFormModal"; import ShiftFormModal from "../components/ShiftFormModal";
import AttendanceShiftTable from "../components/AttendanceShiftTable"; import AttendanceShiftTable from "../components/AttendanceShiftTable";
import useModalLock from "../hooks/useModalLock"; import useModalLock from "../hooks/useModalLock";
import useAttendanceAdmin from "../hooks/useAttendanceAdmin"; import useAttendanceAdmin from "../hooks/useAttendanceAdmin";
import FormField from "../components/FormField";
import { formatMinutes, formatHoursDecimal } from "../utils/attendanceHelpers"; import { formatMinutes, formatHoursDecimal } from "../utils/attendanceHelpers";
import {
Button,
Card,
ConfirmDialog,
Field,
FilterBar,
LoadingState,
MonthField,
Select,
StatCard,
StatusChip,
ProgressBar,
type ProgressColor,
type StatCardColor,
} from "../ui";
interface UserTotalData { interface UserTotalData {
name: string; name: string;
@@ -35,23 +49,57 @@ interface UserTotalData {
worked_holiday_hours?: number; worked_holiday_hours?: number;
} }
function getFundBarBackground(data: UserTotalData) { /** Fond "used" total mirrors the Fond footer line below:
// fondUsed mirrors the Fond "used" line in the IIFE below: * real_worked + vacation + worked_holiday + free_holiday. */
// real_worked + vacation + worked_holiday + free_holiday function getFondUsed(data: UserTotalData): number {
// (delta is fondUsed - fund; 0 means exactly at the fund).
const realWorked = data.worked_hours_raw ?? data.worked_hours; const realWorked = data.worked_hours_raw ?? data.worked_hours;
const fondUsed = return (
realWorked + realWorked +
(data.vacation_hours ?? 0) + (data.vacation_hours ?? 0) +
(data.worked_holiday_hours ?? 0) + (data.worked_holiday_hours ?? 0) +
(data.svatek ?? 0); (data.svatek ?? 0)
const fundVal = data.fund ?? 0; );
const delta = fondUsed - fundVal;
if (delta > 0) return "linear-gradient(135deg, var(--warning), #d97706)";
if (delta >= 0) return "linear-gradient(135deg, var(--success), #059669)";
return "var(--gradient)";
} }
/** Maps the Fond delta (used fund) to a ProgressBar color token, mirroring
* the legacy getFundBarBackground conditional: over fund → warning, exactly
* at fund → success, under fund → primary (was the accent gradient). */
function getFundBarColor(data: UserTotalData): ProgressColor {
const delta = getFondUsed(data) - (data.fund ?? 0);
if (delta > 0) return "warning";
if (delta >= 0) return "success";
return "primary";
}
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 AddIcon = (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
);
export default function AttendanceAdmin() { export default function AttendanceAdmin() {
const alert = useAlert(); const alert = useAlert();
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
@@ -111,278 +159,267 @@ export default function AttendanceAdmin() {
Object.keys(data.user_totals).length === 0; Object.keys(data.user_totals).length === 0;
if (isInitialLoad) { if (isInitialLoad) {
return ( return <LoadingState />;
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
} }
const hasTotals = Object.keys(data.user_totals).length > 0;
return ( return (
<div> <Box>
<motion.div <motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }} transition={{ duration: 0.25 }}
> >
<div> <Box
<h1 className="admin-page-title">Správa docházky</h1> sx={{
</div> display: "flex",
<div className="admin-page-actions"> alignItems: "flex-start",
justifyContent: "space-between",
flexWrap: "wrap",
gap: 2,
mb: 3,
}}
>
<Typography variant="h4">Správa docházky</Typography>
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap" }}>
{hasData && ( {hasData && (
<button <Button
variant="outlined"
color="inherit"
startIcon={PrintIcon}
onClick={handlePrint} onClick={handlePrint}
className="admin-btn admin-btn-secondary"
title="Tisk docházky" title="Tisk docházky"
> >
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
style={{ marginRight: "0.5rem" }}
>
<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>
Tisk Tisk
</button> </Button>
)} )}
<button <Button variant="outlined" color="inherit" onClick={openBulkModal}>
onClick={openBulkModal}
className="admin-btn admin-btn-secondary"
>
Vyplnit měsíc Vyplnit měsíc
</button> </Button>
<button <Button startIcon={AddIcon} onClick={openCreateModal}>
onClick={openCreateModal}
className="admin-btn admin-btn-primary"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Přidat záznam Přidat záznam
</button> </Button>
</div> </Box>
</Box>
</motion.div> </motion.div>
{/* Filters */} {/* Filters */}
<motion.div <motion.div
className="admin-card mb-6"
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }} transition={{ duration: 0.25, delay: 0.06 }}
> >
<div className="admin-card-body"> <FilterBar>
<div className="admin-form-row"> <Box sx={{ flex: "0 0 200px" }}>
<FormField label="Měsíc"> <Field label="Měsíc">
<AdminDatePicker <MonthField value={month} onChange={(val) => setMonth(val)} />
mode="month" </Field>
value={month} </Box>
onChange={(val: string) => setMonth(val)} <Box sx={{ flex: "1 1 240px" }}>
/> <Field label="Zaměstnanec">
</FormField> <Select
<FormField label="Zaměstnanec">
<select
value={filterUserId} value={filterUserId}
onChange={(e) => setFilterUserId(e.target.value)} onChange={setFilterUserId}
className="admin-form-select" options={[
> { value: "", label: "Všichni" },
<option value="">Všichni</option> ...data.users.map((user) => ({
{data.users.map((user) => ( value: String(user.id),
<option key={user.id} value={user.id}> label: user.name,
{user.name} })),
</option> ]}
))} />
</select> </Field>
</FormField> </Box>
</div> </FilterBar>
</div>
</motion.div> </motion.div>
{/* User Totals */} {/* User Totals (KPI cards) */}
{Object.keys(data.user_totals).length > 0 && ( {hasTotals && (
<motion.div <motion.div
className="admin-grid admin-grid-3 mb-6"
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.09 }} transition={{ duration: 0.25, delay: 0.09 }}
> >
{Object.entries(data.user_totals).map(([uid, userData]) => { <Box
const ut = userData as UserTotalData; sx={{
return ( display: "grid",
<div key={uid} className="admin-card" style={{ display: "flex" }}> gridTemplateColumns: {
<div xs: "1fr",
className="admin-card-body" sm: "repeat(2, 1fr)",
style={{ lg: "repeat(3, 1fr)",
display: "flex", },
flexDirection: "column", gap: 2,
flex: 1, mb: 3,
}} }}
> >
<div className="flex-row gap-2 mb-2"> {Object.entries(data.user_totals).map(([uid, userData]) => {
<span style={{ fontWeight: 600 }}>{ut.name}</span> const ut = userData as UserTotalData;
<span const balance = data.leave_balances[uid];
className={`attendance-working-badge ${ut.working ? "working" : "finished"}`} const statColor: StatCardColor = ut.working
? "success"
: "default";
return (
<StatCard
key={uid}
label={ut.name}
value={
<Box
component="span"
sx={{
display: "inline-flex",
alignItems: "center",
gap: 1,
}}
> >
{ut.working ? "\u2713" : "\u2717"}
</span>
</div>
<div className="admin-stat-value">
{formatMinutes(ut.minutes)} {formatMinutes(ut.minutes)}
</div> <StatusChip
<div className="admin-stat-label">odpracováno</div> color={ut.working ? "success" : "default"}
<div label={ut.working ? "✓" : "✗"}
style={{ />
marginTop: "0.5rem", </Box>
marginBottom: "0.75rem", }
color={statColor}
footer={
<Box>
{/* Leave-type badges */}
<Box
sx={{
display: "flex", display: "flex",
flexWrap: "wrap", flexWrap: "wrap",
gap: "0.4rem", gap: 0.5,
minHeight: "2rem", mb: 1,
minHeight: "1.75rem",
alignItems: "center",
}} }}
> >
{ut.vacation_hours > 0 && ( {ut.vacation_hours > 0 && (
<span className="attendance-leave-badge badge-vacation"> <StatusChip
Dov: {ut.vacation_hours}h color="info"
</span> label={`Dov: ${ut.vacation_hours}h`}
/>
)} )}
{ut.sick_hours > 0 && ( {ut.sick_hours > 0 && (
<span className="attendance-leave-badge badge-sick"> <StatusChip
Nem: {ut.sick_hours}h color="error"
</span> label={`Nem: ${ut.sick_hours}h`}
/>
)} )}
{ut.svatek !== undefined && ut.svatek > 0 && ( {ut.svatek !== undefined && ut.svatek > 0 && (
<span className="attendance-leave-badge badge-holiday"> <StatusChip
Sv: {Math.round(ut.svatek)}h color="warning"
</span> label={`Sv: ${Math.round(ut.svatek)}h`}
/>
)} )}
{ut.unpaid_hours > 0 && ( {ut.unpaid_hours > 0 && (
<span className="attendance-leave-badge badge-unpaid"> <StatusChip
Nep: {ut.unpaid_hours}h color="default"
</span> label={`Nep: ${ut.unpaid_hours}h`}
/>
)} )}
</div> </Box>
{/* Fond usage */}
{ut.fund !== null && {ut.fund !== null &&
(() => { (() => {
// Fond "used" = real_worked + vacation + worked_holiday + free_holiday // Fond "used" = real_worked + vacation +
// (everything the user actually got paid for this month: // worked_holiday + free_holiday (everything the user
// raw worked time with sub-day remainders, plus // actually got paid for this month).
// vacation days, plus hours worked on holidays, plus const fondUsed = getFondUsed(ut);
// the 8h per unworked holiday from the fund).
const realWorked = ut.worked_hours_raw ?? ut.worked_hours;
const fondUsed =
realWorked +
(ut.vacation_hours ?? 0) +
(ut.worked_holiday_hours ?? 0) +
(ut.svatek ?? 0);
const fundVal = ut.fund ?? 0; const fundVal = ut.fund ?? 0;
const delta = fondUsed - fundVal; const delta = fondUsed - fundVal;
const pct = Math.min(
100,
(fondUsed / (ut.fund || 1)) * 100,
);
return ( return (
<div <Box>
className="kpi-card-footer" <Box
style={{ sx={{
marginTop: "auto",
paddingTop: "0.75rem",
borderTop: "1px solid var(--border-color)",
}}
>
<div
className="text-secondary"
style={{
display: "flex", display: "flex",
justifyContent: "space-between", justifyContent: "space-between",
alignItems: "center", alignItems: "center",
fontSize: "0.8rem", mb: 0.5,
}} }}
> >
<span> <Typography
variant="caption"
color="text.secondary"
>
Fond: {formatHoursDecimal(fondUsed * 60)}h /{" "} Fond: {formatHoursDecimal(fondUsed * 60)}h /{" "}
{formatHoursDecimal(fundVal * 60)}h {formatHoursDecimal(fundVal * 60)}h
</span> </Typography>
{delta > 0 && ( {delta > 0 && (
<span className="text-warning fw-600"> <Typography
+{formatHoursDecimal(delta * 60)}h variant="caption"
</span> sx={{
)} fontWeight: 600,
{delta <= 0 && delta < 0 && ( color: "warning.main",
<span className="text-danger fw-600">
-{formatHoursDecimal(Math.abs(delta) * 60)}h
</span>
)}
</div>
<div
style={{
marginTop: "0.375rem",
height: "4px",
background: "var(--bg-tertiary)",
borderRadius: "2px",
overflow: "hidden",
}} }}
> >
<div +{formatHoursDecimal(delta * 60)}h
style={{ </Typography>
height: "100%", )}
width: `${Math.min( {delta < 0 && (
100, <Typography
(fondUsed / (ut.fund || 1)) * 100, variant="caption"
)}%`, sx={{
background: getFundBarBackground(ut), fontWeight: 600,
borderRadius: "2px", color: "error.main",
transition: "width 0.3s ease",
}} }}
>
-{formatHoursDecimal(Math.abs(delta) * 60)}h
</Typography>
)}
</Box>
<ProgressBar
value={pct}
color={getFundBarColor(ut)}
height={4}
/> />
</div> </Box>
</div>
); );
})()} })()}
<div
className="text-secondary" {/* Remaining vacation */}
style={{ marginTop: "0.75rem", fontSize: "0.8rem" }} <Box sx={{ mt: 1.5 }}>
> {balance ? (
{data.leave_balances[uid] ? ( <Typography variant="caption" color="text.secondary">
<>
Zbývá dovolené:{" "} Zbývá dovolené:{" "}
{data.leave_balances[uid].vacation_remaining.toFixed(1)} {balance.vacation_remaining.toFixed(1)}h /{" "}
h / {data.leave_balances[uid].vacation_total}h {balance.vacation_total}h
</> </Typography>
) : ( ) : (
<span style={{ visibility: "hidden" }}></span> <Typography
variant="caption"
sx={{ visibility: "hidden" }}
>
</Typography>
)} )}
</div> </Box>
</div> </Box>
</div> }
/>
); );
})} })}
</Box>
</motion.div> </motion.div>
)} )}
{/* Records Table */} {/* Records Table */}
<motion.div <motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }} transition={{ duration: 0.25, delay: 0.12 }}
> >
<div className="admin-card-body"> <Card>
<AttendanceShiftTable <AttendanceShiftTable
records={data.records} records={data.records}
onEdit={openEditModal} onEdit={openEditModal}
onDelete={(record) => setDeleteConfirm({ show: true, record })} onDelete={(record) => setDeleteConfirm({ show: true, record })}
/> />
</div> </Card>
</motion.div> </motion.div>
{/* Modals */} {/* Modals */}
@@ -435,7 +472,7 @@ export default function AttendanceAdmin() {
} }
/> />
<ConfirmModal <ConfirmDialog
isOpen={deleteConfirm.show} isOpen={deleteConfirm.show}
onClose={() => setDeleteConfirm({ show: false, record: null })} onClose={() => setDeleteConfirm({ show: false, record: null })}
onConfirm={handleDelete} onConfirm={handleDelete}
@@ -444,6 +481,6 @@ export default function AttendanceAdmin() {
confirmText="Smazat" confirmText="Smazat"
confirmVariant="danger" confirmVariant="danger"
/> />
</div> </Box>
); );
} }