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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user