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", ? `(${h}:${String(m).padStart(2, "0")}h${isActive ? " ▸" : ""})`
background: isActive ? "var(--accent-light)" : undefined, : "—"
}} }`}
> sx={{ fontSize: "0.7rem", maxWidth: "100%" }}
{log.project_name || `#${log.project_id}`}{" "} />
{durationValid
? `(${h}:${String(m).padStart(2, "0")}h${isActive ? " ▸" : ""})`
: "—"}
</span>
); );
})} })}
</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>[] = [
return ( {
<div className="admin-empty-state"> key: "date",
<p>Za tento měsíc nejsou žádné záznamy.</p> header: "Datum",
</div> 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 ( return (
<div className="admin-table-responsive"> <DataTable<AttendanceRecord>
<table className="admin-table"> columns={columns}
<thead> rows={records}
<tr> rowKey={(record) => record.id}
<th>Datum</th> empty={<EmptyState title="Za tento měsíc nejsou žádné záznamy." />}
<th>Zaměstnanec</th> />
<th>Typ</th>
<th>Příchod</th>
<th>Pauza</th>
<th>Odchod</th>
<th>Hodiny</th>
<th>Projekt</th>
<th>GPS</th>
<th>Poznámka</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{records.map((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,
});
const hasLocation =
(record.arrival_lat && record.arrival_lng) ||
(record.departure_lat && record.departure_lng);
return (
<tr key={record.id}>
<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}`}
className="attendance-gps-link"
title="Zobrazit polohu"
aria-label="Zobrazit polohu"
>
<span aria-hidden="true">{"📍"}</span>
</Link>
) : (
"—"
)}
</td>
<td
style={{
maxWidth: "100px",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
title={record.notes || ""}
>
{record.notes || ""}
</td>
<td>
<div className="admin-table-actions">
<button
onClick={() => onEdit(record)}
className="admin-btn-icon"
title="Upravit"
aria-label="Upravit"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
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)}
className="admin-btn-icon danger"
title="Smazat"
aria-label="Smazat"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<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>
</button>
</div>
</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> value={form.month}
<AdminDatePicker onChange={(val) => setForm({ ...form, month: val })}
mode="month" />
value={form.month} </Field>
onChange={(val) => setForm({ ...form, month: val })}
/>
</div>
<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>
type="button" <Typography
onClick={toggleAllUsers} component="button"
style={{ type="button"
marginLeft: "0.75rem", onClick={toggleAllUsers}
background: "none", variant="caption"
border: "none", sx={{
color: "var(--accent-color)", background: "none",
cursor: "pointer", border: "none",
fontSize: "0.8125rem", p: 0,
fontWeight: 500, cursor: "pointer",
padding: 0, fontWeight: 500,
}} color: "primary.main",
>
{form.user_ids.length === users.length
? "Odznačit vše"
: "Vybrat vše"}
</button>
</label>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "0.375rem",
maxHeight: "200px",
overflowY: "auto",
padding: "0.75rem",
background: "var(--bg-tertiary)",
borderRadius: "var(--border-radius-sm)",
border: "1px solid var(--border-color)",
}} }}
> >
{users.map((user) => ( {form.user_ids.length === users.length
<label key={user.id} className="admin-form-checkbox"> ? "Odznačit vše"
<input : "Vybrat vše"}
type="checkbox" </Typography>
checked={form.user_ids.includes(String(user.id))} </Box>
onChange={() => toggleUser(user.id)} <Box
/> sx={{
<span>{user.name}</span> display: "flex",
</label> flexDirection: "column",
))} gap: 0.25,
</div> maxHeight: 200,
<small className="admin-form-hint"> overflowY: "auto",
Vybráno: {form.user_ids.length} z {users.length} p: 1.5,
</small> bgcolor: "action.hover",
</div> borderRadius: 2,
border: 1,
borderColor: "divider",
}}
>
{users.map((user) => (
<CheckboxField
key={user.id}
label={user.name}
checked={form.user_ids.includes(String(user.id))}
onChange={() => toggleUser(user.id)}
/>
))}
</Box>
<Typography variant="caption" color="text.secondary">
Vybráno: {form.user_ids.length} z {users.length}
</Typography>
</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,
value={form.arrival_time} }}
onChange={(val) => setForm({ ...form, arrival_time: val })} >
/> <Field label="Příchod">
</div> <TimeField
<div className="admin-form-group"> value={form.arrival_time}
<label className="admin-form-label">Odchod</label> onChange={(val) => setForm({ ...form, arrival_time: val })}
<AdminDatePicker />
mode="time" </Field>
value={form.departure_time} <Field label="Odchod">
onChange={(val) => setForm({ ...form, departure_time: val })} <TimeField
/> value={form.departure_time}
</div> onChange={(val) => setForm({ ...form, departure_time: val })}
</div> />
</Field>
</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,
value={form.break_start_time} }}
onChange={(val) => setForm({ ...form, break_start_time: val })} >
/> <Field label="Začátek pauzy">
</div> <TimeField
<div className="admin-form-group"> value={form.break_start_time}
<label className="admin-form-label">Konec pauzy</label> onChange={(val) => setForm({ ...form, break_start_time: val })}
<AdminDatePicker />
mode="time" </Field>
value={form.break_end_time} <Field label="Konec pauzy">
onChange={(val) => setForm({ ...form, break_end_time: val })} <TimeField
/> value={form.break_end_time}
</div> onChange={(val) => setForm({ ...form, break_end_time: val })}
</div> />
</div> </Field>
</FormModal> </Box>
</Modal>
); );
} }

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> >
{projectList.map((p) => ( <Box sx={{ flex: 3, minWidth: 0 }}>
<option key={p.id} value={p.id}> <Select
{p.project_number} {p.name} value={String(log.project_id)}
</option> onChange={(val) => onUpdate(index, "project_id", val)}
))} options={[
</select> { value: "", label: "— Projekt —" },
<input ...projectList.map((p) => ({
type="number" value: String(p.id),
min="0" label: `${p.project_number} ${p.name}`,
max="24" })),
value={log.hours} ]}
onChange={(e) => onUpdate(index, "hours", e.target.value)} />
className="admin-form-input" </Box>
style={{ width: "60px", marginBottom: 0, textAlign: "center" }} <Box sx={{ width: 70, flexShrink: 0 }}>
placeholder="h" <TextField
/> type="number"
<span style={{ fontSize: "0.85rem", color: "var(--text-secondary)" }}> value={log.hours}
onChange={(e) => onUpdate(index, "hours", e.target.value)}
placeholder="h"
slotProps={{
htmlInput: { min: 0, max: 24, style: { textAlign: "center" } },
}}
/>
</Box>
<Typography variant="body2" color="text.secondary">
h h
</span> </Typography>
<input <Box sx={{ width: 70, flexShrink: 0 }}>
type="number" <TextField
min="0" type="number"
max="59" value={log.minutes}
value={log.minutes} onChange={(e) => onUpdate(index, "minutes", e.target.value)}
onChange={(e) => onUpdate(index, "minutes", e.target.value)} placeholder="m"
className="admin-form-input" slotProps={{
style={{ width: "60px", marginBottom: 0, textAlign: "center" }} htmlInput: { min: 0, max: 59, style: { textAlign: "center" } },
placeholder="m" }}
/> />
<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,200 +262,202 @@ 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 ? ( <Box
<div className="admin-form-row"> sx={{
<div className="admin-form-group"> display: "grid",
<label className="admin-form-label required">Zaměstnanec</label> gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
<select gap: 2,
value={form.user_id} }}
onChange={(e) => updateField("user_id", e.target.value)} >
className="admin-form-select" <Field label="Zaměstnanec" required>
> <Select
<option value="">Vyberte zaměstnance</option> value={form.user_id}
{users.map((user) => ( onChange={(val) => updateField("user_id", val)}
<option key={user.id} value={user.id}> options={[
{user.name} { value: "", label: "Vyberte zaměstnance" },
</option> ...users.map((user) => ({
))} value: String(user.id),
</select> label: user.name,
</div> })),
<div className="admin-form-group"> ]}
<label className="admin-form-label required">Datum směny</label> />
<AdminDatePicker </Field>
mode="date" <Field label="Datum směny" required>
value={form.shift_date} <DateField
onChange={(val) => onShiftDateChange(val)}
/>
</div>
</div>
) : (
<div className="admin-form-group">
<label className="admin-form-label">Datum směny</label>
<AdminDatePicker
mode="date"
value={form.shift_date} value={form.shift_date}
onChange={(val) => updateField("shift_date", val)} onChange={(val) => onShiftDateChange(val)}
/> />
</div> </Field>
)} </Box>
) : (
<div className="admin-form-group"> <Field label="Datum směny">
<label className="admin-form-label">Typ záznamu</label> <DateField
<select value={form.shift_date}
value={form.leave_type} onChange={(val) => updateField("shift_date", val)}
onChange={(e) => updateField("leave_type", e.target.value)}
className="admin-form-select"
>
<option value="work">Práce</option>
<option value="vacation">Dovolená</option>
<option value="sick">Nemoc</option>
<option value="unpaid">Neplacené volno</option>
</select>
</div>
{!isWorkType && (
<div className="admin-form-group">
<label className="admin-form-label">Počet hodin</label>
<input
type="number"
inputMode="decimal"
value={form.leave_hours}
onChange={(e) =>
updateField("leave_hours", parseFloat(e.target.value))
}
min="0.5"
max="24"
step="0.5"
className="admin-form-input"
/>
{isCreate && (
<small className="admin-form-hint">8 hodin = celý den</small>
)}
</div>
)}
{isWorkType && (
<>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">Příchod - datum</label>
<AdminDatePicker
mode="date"
value={form.arrival_date}
onChange={(val) => updateField("arrival_date", val)}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Příchod - čas</label>
<AdminDatePicker
mode="time"
value={form.arrival_time}
onChange={(val) => updateField("arrival_time", val)}
/>
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">
Začátek pauzy - datum
</label>
<AdminDatePicker
mode="date"
value={form.break_start_date}
onChange={(val) => updateField("break_start_date", val)}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Začátek pauzy - čas</label>
<AdminDatePicker
mode="time"
value={form.break_start_time}
onChange={(val) => updateField("break_start_time", val)}
/>
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">Konec pauzy - datum</label>
<AdminDatePicker
mode="date"
value={form.break_end_date}
onChange={(val) => updateField("break_end_date", val)}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Konec pauzy - čas</label>
<AdminDatePicker
mode="time"
value={form.break_end_time}
onChange={(val) => updateField("break_end_time", val)}
/>
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">Odchod - datum</label>
<AdminDatePicker
mode="date"
value={form.departure_date}
onChange={(val) => updateField("departure_date", val)}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Odchod - čas</label>
<AdminDatePicker
mode="time"
value={form.departure_time}
onChange={(val) => updateField("departure_time", val)}
/>
</div>
</div>
</>
)}
{isWorkType && projectList.length > 0 && (
<div className="admin-form-group">
<label className="admin-form-label">Projekty</label>
<ProjectTimeStatus form={form} projectLogs={projectLogs} />
{projectLogs.map((log, i) => (
<ProjectLogRow
key={log._key || i}
log={log}
index={i}
projectList={projectList}
onUpdate={updateProjectLog}
onRemove={removeProjectLog}
/>
))}
<button
type="button"
onClick={addProjectLog}
className="admin-btn admin-btn-secondary admin-btn-sm"
>
+ Přidat projekt
</button>
</div>
)}
<div className="admin-form-group">
<label className="admin-form-label">Poznámka</label>
<textarea
value={form.notes}
onChange={(e) => updateField("notes", e.target.value)}
className="admin-form-textarea"
rows={3}
/> />
</div> </Field>
</div> )}
</FormModal>
<Field label="Typ záznamu">
<Select
value={form.leave_type}
onChange={(val) => updateField("leave_type", val)}
options={[
{ value: "work", label: "Práce" },
{ value: "vacation", label: "Dovolená" },
{ value: "sick", label: "Nemoc" },
{ value: "unpaid", label: "Neplacené volno" },
]}
/>
</Field>
{!isWorkType && (
<Field
label="Počet hodin"
hint={isCreate ? "8 hodin = celý den" : undefined}
>
<TextField
type="number"
inputMode="decimal"
value={form.leave_hours}
onChange={(e) =>
updateField("leave_hours", parseFloat(e.target.value))
}
slotProps={{ htmlInput: { min: 0.5, max: 24, step: 0.5 } }}
/>
</Field>
)}
{isWorkType && (
<>
{/* Arrival */}
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Příchod - datum">
<DateField
value={form.arrival_date}
onChange={(val) => updateField("arrival_date", val)}
/>
</Field>
<Field label="Příchod - čas">
<TimeField
value={form.arrival_time}
onChange={(val) => updateField("arrival_time", val)}
/>
</Field>
</Box>
{/* Break start */}
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Začátek pauzy - datum">
<DateField
value={form.break_start_date}
onChange={(val) => updateField("break_start_date", val)}
/>
</Field>
<Field label="Začátek pauzy - čas">
<TimeField
value={form.break_start_time}
onChange={(val) => updateField("break_start_time", val)}
/>
</Field>
</Box>
{/* Break end */}
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Konec pauzy - datum">
<DateField
value={form.break_end_date}
onChange={(val) => updateField("break_end_date", val)}
/>
</Field>
<Field label="Konec pauzy - čas">
<TimeField
value={form.break_end_time}
onChange={(val) => updateField("break_end_time", val)}
/>
</Field>
</Box>
{/* Departure */}
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Odchod - datum">
<DateField
value={form.departure_date}
onChange={(val) => updateField("departure_date", val)}
/>
</Field>
<Field label="Odchod - čas">
<TimeField
value={form.departure_time}
onChange={(val) => updateField("departure_time", val)}
/>
</Field>
</Box>
</>
)}
{isWorkType && projectList.length > 0 && (
<Field label="Projekty">
<ProjectTimeStatus form={form} projectLogs={projectLogs} />
{projectLogs.map((log, i) => (
<ProjectLogRow
key={log._key || i}
log={log}
index={i}
projectList={projectList}
onUpdate={updateProjectLog}
onRemove={removeProjectLog}
/>
))}
<Button
variant="outlined"
color="inherit"
size="small"
onClick={addProjectLog}
>
+ Přidat projekt
</Button>
</Field>
)}
<Field label="Poznámka">
<TextField
multiline
minRows={3}
value={form.notes}
onChange={(e) => updateField("notes", e.target.value)}
/>
</Field>
</Modal>
); );
} }

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",
{hasData && ( justifyContent: "space-between",
<button flexWrap: "wrap",
onClick={handlePrint} gap: 2,
className="admin-btn admin-btn-secondary" mb: 3,
title="Tisk docházky" }}
> >
<svg <Typography variant="h4">Správa docházky</Typography>
width="18" <Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap" }}>
height="18" {hasData && (
viewBox="0 0 24 24" <Button
fill="none" variant="outlined"
stroke="currentColor" color="inherit"
strokeWidth="2" startIcon={PrintIcon}
style={{ marginRight: "0.5rem" }} onClick={handlePrint}
title="Tisk docházky"
> >
<polyline points="6 9 6 2 18 2 18 9" /> Tisk
<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" /> </Button>
<rect x="6" y="14" width="12" height="8" /> )}
</svg> <Button variant="outlined" color="inherit" onClick={openBulkModal}>
Tisk Vyplnit měsíc
</button> </Button>
)} <Button startIcon={AddIcon} onClick={openCreateModal}>
<button Přidat záznam
onClick={openBulkModal} </Button>
className="admin-btn admin-btn-secondary" </Box>
> </Box>
Vyplnit měsíc
</button>
<button
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
</button>
</div>
</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"} {formatMinutes(ut.minutes)}
</span> <StatusChip
</div> color={ut.working ? "success" : "default"}
<div className="admin-stat-value"> label={ut.working ? "✓" : "✗"}
{formatMinutes(ut.minutes)} />
</div> </Box>
<div className="admin-stat-label">odpracováno</div> }
<div color={statColor}
style={{ footer={
marginTop: "0.5rem", <Box>
marginBottom: "0.75rem", {/* Leave-type badges */}
display: "flex", <Box
flexWrap: "wrap", sx={{
gap: "0.4rem", display: "flex",
minHeight: "2rem", flexWrap: "wrap",
}} gap: 0.5,
> mb: 1,
{ut.vacation_hours > 0 && ( minHeight: "1.75rem",
<span className="attendance-leave-badge badge-vacation"> alignItems: "center",
Dov: {ut.vacation_hours}h }}
</span> >
)} {ut.vacation_hours > 0 && (
{ut.sick_hours > 0 && ( <StatusChip
<span className="attendance-leave-badge badge-sick"> color="info"
Nem: {ut.sick_hours}h label={`Dov: ${ut.vacation_hours}h`}
</span> />
)} )}
{ut.svatek !== undefined && ut.svatek > 0 && ( {ut.sick_hours > 0 && (
<span className="attendance-leave-badge badge-holiday"> <StatusChip
Sv: {Math.round(ut.svatek)}h color="error"
</span> label={`Nem: ${ut.sick_hours}h`}
)} />
{ut.unpaid_hours > 0 && ( )}
<span className="attendance-leave-badge badge-unpaid"> {ut.svatek !== undefined && ut.svatek > 0 && (
Nep: {ut.unpaid_hours}h <StatusChip
</span> color="warning"
)} label={`Sv: ${Math.round(ut.svatek)}h`}
</div> />
{ut.fund !== null && )}
(() => { {ut.unpaid_hours > 0 && (
// Fond "used" = real_worked + vacation + worked_holiday + free_holiday <StatusChip
// (everything the user actually got paid for this month: color="default"
// raw worked time with sub-day remainders, plus label={`Nep: ${ut.unpaid_hours}h`}
// vacation days, plus hours worked on holidays, plus />
// the 8h per unworked holiday from the fund). )}
const realWorked = ut.worked_hours_raw ?? ut.worked_hours; </Box>
const fondUsed =
realWorked + {/* Fond usage */}
(ut.vacation_hours ?? 0) + {ut.fund !== null &&
(ut.worked_holiday_hours ?? 0) + (() => {
(ut.svatek ?? 0); // Fond "used" = real_worked + vacation +
const fundVal = ut.fund ?? 0; // worked_holiday + free_holiday (everything the user
const delta = fondUsed - fundVal; // actually got paid for this month).
return ( const fondUsed = getFondUsed(ut);
<div const fundVal = ut.fund ?? 0;
className="kpi-card-footer" const delta = fondUsed - fundVal;
style={{ const pct = Math.min(
marginTop: "auto", 100,
paddingTop: "0.75rem", (fondUsed / (ut.fund || 1)) * 100,
borderTop: "1px solid var(--border-color)", );
}} return (
> <Box>
<div <Box
className="text-secondary" sx={{
style={{ display: "flex",
display: "flex", justifyContent: "space-between",
justifyContent: "space-between", alignItems: "center",
alignItems: "center", mb: 0.5,
fontSize: "0.8rem", }}
}} >
<Typography
variant="caption"
color="text.secondary"
>
Fond: {formatHoursDecimal(fondUsed * 60)}h /{" "}
{formatHoursDecimal(fundVal * 60)}h
</Typography>
{delta > 0 && (
<Typography
variant="caption"
sx={{
fontWeight: 600,
color: "warning.main",
}}
>
+{formatHoursDecimal(delta * 60)}h
</Typography>
)}
{delta < 0 && (
<Typography
variant="caption"
sx={{
fontWeight: 600,
color: "error.main",
}}
>
-{formatHoursDecimal(Math.abs(delta) * 60)}h
</Typography>
)}
</Box>
<ProgressBar
value={pct}
color={getFundBarColor(ut)}
height={4}
/>
</Box>
);
})()}
{/* Remaining vacation */}
<Box sx={{ mt: 1.5 }}>
{balance ? (
<Typography variant="caption" color="text.secondary">
Zbývá dovolené:{" "}
{balance.vacation_remaining.toFixed(1)}h /{" "}
{balance.vacation_total}h
</Typography>
) : (
<Typography
variant="caption"
sx={{ visibility: "hidden" }}
> >
<span>
Fond: {formatHoursDecimal(fondUsed * 60)}h /{" "} </Typography>
{formatHoursDecimal(fundVal * 60)}h )}
</span> </Box>
{delta > 0 && ( </Box>
<span className="text-warning fw-600"> }
+{formatHoursDecimal(delta * 60)}h />
</span> );
)} })}
{delta <= 0 && delta < 0 && ( </Box>
<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
style={{
height: "100%",
width: `${Math.min(
100,
(fondUsed / (ut.fund || 1)) * 100,
)}%`,
background: getFundBarBackground(ut),
borderRadius: "2px",
transition: "width 0.3s ease",
}}
/>
</div>
</div>
);
})()}
<div
className="text-secondary"
style={{ marginTop: "0.75rem", fontSize: "0.8rem" }}
>
{data.leave_balances[uid] ? (
<>
Zbývá dovolené:{" "}
{data.leave_balances[uid].vacation_remaining.toFixed(1)}
h / {data.leave_balances[uid].vacation_total}h
</>
) : (
<span style={{ visibility: "hidden" }}></span>
)}
</div>
</div>
</div>
);
})}
</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>
); );
} }