feat(mui): migrate Leave Requests (Žádosti) onto MUI kit

Replaces admin-* CSS, ConfirmModal, and legacy markup with PageHeader,
Card, DataTable, ConfirmDialog, StatusChip, EmptyState, LoadingState.
Dual StatusChip badges per row: leave-type (info/error/default) and
status (warning/success/error/default). Note cell preserved with Box
title-tooltip and ellipsis truncation. cancelMutation and invalidate:
["leave-requests", "leave", "attendance"] preserved verbatim.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-06 23:40:45 +02:00
parent a5103ee738
commit 1074adee0d

View File

@@ -3,11 +3,22 @@ import { useQuery } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { motion } from "framer-motion";
import Box from "@mui/material/Box";
import Forbidden from "../components/Forbidden";
import { formatDate, formatDatetime } from "../utils/attendanceHelpers";
import ConfirmModal from "../components/ConfirmModal";
import { leaveRequestsOptions, type LeaveRequest } from "../lib/queries/leave";
import { useApiMutation } from "../lib/queries/mutations";
import {
Button,
Card,
DataTable,
ConfirmDialog,
StatusChip,
PageHeader,
EmptyState,
LoadingState,
type DataColumn,
} from "../ui";
const API_BASE = "/api/admin";
@@ -24,18 +35,40 @@ const statusLabels: Record<string, string> = {
cancelled: "Zrušeno",
};
const statusClasses: Record<string, string> = {
pending: "badge-pending",
approved: "badge-approved",
rejected: "badge-rejected",
cancelled: "badge-cancelled",
// Leave type → StatusChip color
const leaveTypeColor: Record<
string,
"info" | "error" | "default" | "success" | "warning"
> = {
vacation: "info",
sick: "error",
unpaid: "default",
};
const leaveTypeClasses: Record<string, string> = {
vacation: "badge-vacation",
sick: "badge-sick",
unpaid: "badge-unpaid",
};
// Status → StatusChip color
const statusColor: Record<string, "warning" | "success" | "error" | "default"> =
{
pending: "warning",
approved: "success",
rejected: "error",
cancelled: "default",
};
const CancelIcon = (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
);
export default function LeaveRequests() {
const alert = useAlert();
@@ -72,167 +105,179 @@ export default function LeaveRequests() {
}
};
function renderNoteCell(req: LeaveRequest) {
const truncate = (text: string) =>
text.length > 40 ? `${text.substring(0, 40)}...` : text;
if (req.status === "rejected" && req.reviewer_note) {
return (
<span
style={{ color: "var(--danger)", fontSize: "0.875rem" }}
title={req.reviewer_note}
>
{truncate(req.reviewer_note)}
</span>
);
}
if (req.notes) {
return (
<span
className="text-secondary"
style={{ fontSize: "0.875rem" }}
title={req.notes}
>
{truncate(req.notes)}
</span>
);
}
return <span className="text-muted"></span>;
if (isPending) {
return <LoadingState />;
}
if (isPending) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
const columns: DataColumn<LeaveRequest>[] = [
{
key: "leave_type",
header: "Typ",
width: "14%",
render: (req) => (
<StatusChip
label={leaveTypeLabels[req.leave_type] || req.leave_type}
color={leaveTypeColor[req.leave_type] ?? "default"}
/>
),
},
{
key: "date_from",
header: "Od",
width: "11%",
mono: true,
render: (req) => formatDate(req.date_from),
},
{
key: "date_to",
header: "Do",
width: "11%",
mono: true,
render: (req) => formatDate(req.date_to),
},
{
key: "total_days",
header: "Dny",
width: "7%",
mono: true,
render: (req) => String(req.total_days),
},
{
key: "total_hours",
header: "Hodiny",
width: "8%",
mono: true,
render: (req) => `${req.total_hours}h`,
},
{
key: "status",
header: "Stav",
width: "16%",
render: (req) => (
<StatusChip
label={statusLabels[req.status] || req.status}
color={statusColor[req.status] ?? "default"}
/>
),
},
{
key: "notes",
header: "Poznámka",
width: "20%",
render: (req) => {
const truncate = (text: string) =>
text.length > 40 ? `${text.substring(0, 40)}...` : text;
if (req.status === "rejected" && req.reviewer_note) {
return (
<Box
component="span"
title={req.reviewer_note}
sx={{
color: "error.main",
fontSize: "0.875rem",
display: "block",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{truncate(req.reviewer_note)}
</Box>
);
}
if (req.notes) {
return (
<Box
component="span"
title={req.notes}
sx={{
color: "text.secondary",
fontSize: "0.875rem",
display: "block",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{truncate(req.notes)}
</Box>
);
}
return (
<Box component="span" sx={{ color: "text.disabled" }}>
</Box>
);
},
},
{
key: "created_at",
header: "Podáno",
width: "13%",
mono: true,
render: (req) => formatDatetime(req.created_at),
},
{
key: "actions",
header: "Akce",
width: "8%",
align: "right",
render: (req) =>
req.status === "pending" ? (
<Button
size="small"
variant="outlined"
startIcon={CancelIcon}
onClick={() => setCancelModal({ open: true, id: req.id })}
>
Zrušit
</Button>
) : null,
},
];
return (
<div>
<Box>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Moje žádosti</h1>
<p className="admin-page-subtitle">Přehled žádostí o nepřítomnost</p>
</div>
<PageHeader
title="Moje žádosti"
subtitle="Přehled žádostí o nepřítomnost"
/>
</motion.div>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
{requests.length === 0 ? (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
</div>
<p>Zatím nemáte žádné žádosti</p>
<p style={{ fontSize: "0.875rem", color: "var(--text-muted)" }}>
Novou žádost můžete podat na stránce Docházka
</p>
</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Typ</th>
<th>Od</th>
<th>Do</th>
<th>Dny</th>
<th>Hodiny</th>
<th>Stav</th>
<th>Poznámka</th>
<th>Podáno</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{requests.map((req) => (
<tr key={req.id}>
<td>
<span
className={`attendance-leave-badge ${leaveTypeClasses[req.leave_type] || ""}`}
>
{leaveTypeLabels[req.leave_type] || req.leave_type}
</span>
</td>
<td className="admin-mono">
{formatDate(req.date_from)}
</td>
<td className="admin-mono">{formatDate(req.date_to)}</td>
<td className="admin-mono">{req.total_days}</td>
<td className="admin-mono">{req.total_hours}h</td>
<td>
<span
className={`admin-badge ${statusClasses[req.status] || ""}`}
>
{statusLabels[req.status] || req.status}
</span>
</td>
<td style={{ maxWidth: "200px" }}>
{renderNoteCell(req)}
</td>
<td
className="admin-mono"
style={{ whiteSpace: "nowrap" }}
>
{formatDatetime(req.created_at)}
</td>
<td>
<div className="admin-table-actions">
{req.status === "pending" && (
<button
onClick={() =>
setCancelModal({ open: true, id: req.id })
}
className="admin-btn admin-btn-secondary admin-btn-sm"
>
Zrušit
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<Card>
<DataTable<LeaveRequest>
columns={columns}
rows={requests}
rowKey={(req) => req.id}
empty={
<EmptyState
title="Zatím nemáte žádné žádosti"
description="Novou žádost můžete podat na stránce Docházka"
/>
}
/>
</Card>
</motion.div>
<ConfirmModal
<ConfirmDialog
isOpen={cancelModal.open}
onClose={() => setCancelModal({ open: false, id: null })}
onConfirm={handleCancel}
title="Zrušit žádost"
message="Opravdu chcete zrušit tuto žádost o nepřítomnost?"
confirmText="Zrušit žádost"
type="warning"
loading={cancelMutation.isPending}
/>
</div>
</Box>
);
}