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 { useAlert } from "../context/AlertContext";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import { formatDate, formatDatetime } from "../utils/attendanceHelpers";
|
import { formatDate, formatDatetime } from "../utils/attendanceHelpers";
|
||||||
import ConfirmModal from "../components/ConfirmModal";
|
|
||||||
import { leaveRequestsOptions, type LeaveRequest } from "../lib/queries/leave";
|
import { leaveRequestsOptions, type LeaveRequest } from "../lib/queries/leave";
|
||||||
import { useApiMutation } from "../lib/queries/mutations";
|
import { useApiMutation } from "../lib/queries/mutations";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
DataTable,
|
||||||
|
ConfirmDialog,
|
||||||
|
StatusChip,
|
||||||
|
PageHeader,
|
||||||
|
EmptyState,
|
||||||
|
LoadingState,
|
||||||
|
type DataColumn,
|
||||||
|
} from "../ui";
|
||||||
|
|
||||||
const API_BASE = "/api/admin";
|
const API_BASE = "/api/admin";
|
||||||
|
|
||||||
@@ -24,18 +35,40 @@ const statusLabels: Record<string, string> = {
|
|||||||
cancelled: "Zrušeno",
|
cancelled: "Zrušeno",
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusClasses: Record<string, string> = {
|
// Leave type → StatusChip color
|
||||||
pending: "badge-pending",
|
const leaveTypeColor: Record<
|
||||||
approved: "badge-approved",
|
string,
|
||||||
rejected: "badge-rejected",
|
"info" | "error" | "default" | "success" | "warning"
|
||||||
cancelled: "badge-cancelled",
|
> = {
|
||||||
|
vacation: "info",
|
||||||
|
sick: "error",
|
||||||
|
unpaid: "default",
|
||||||
};
|
};
|
||||||
|
|
||||||
const leaveTypeClasses: Record<string, string> = {
|
// Status → StatusChip color
|
||||||
vacation: "badge-vacation",
|
const statusColor: Record<string, "warning" | "success" | "error" | "default"> =
|
||||||
sick: "badge-sick",
|
{
|
||||||
unpaid: "badge-unpaid",
|
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() {
|
export default function LeaveRequests() {
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
@@ -72,167 +105,179 @@ export default function LeaveRequests() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function renderNoteCell(req: LeaveRequest) {
|
if (isPending) {
|
||||||
const truncate = (text: string) =>
|
return <LoadingState />;
|
||||||
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) {
|
const columns: DataColumn<LeaveRequest>[] = [
|
||||||
return (
|
{
|
||||||
<div className="admin-loading">
|
key: "leave_type",
|
||||||
<div className="admin-spinner" />
|
header: "Typ",
|
||||||
</div>
|
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 (
|
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>
|
<PageHeader
|
||||||
<h1 className="admin-page-title">Moje žádosti</h1>
|
title="Moje žádosti"
|
||||||
<p className="admin-page-subtitle">Přehled žádostí o nepřítomnost</p>
|
subtitle="Přehled žádostí o nepřítomnost"
|
||||||
</div>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<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.06 }}
|
transition={{ duration: 0.25, delay: 0.06 }}
|
||||||
>
|
>
|
||||||
<div className="admin-card-body">
|
<Card>
|
||||||
{requests.length === 0 ? (
|
<DataTable<LeaveRequest>
|
||||||
<div className="admin-empty-state">
|
columns={columns}
|
||||||
<div className="admin-empty-icon">
|
rows={requests}
|
||||||
<svg
|
rowKey={(req) => req.id}
|
||||||
width="28"
|
empty={
|
||||||
height="28"
|
<EmptyState
|
||||||
viewBox="0 0 24 24"
|
title="Zatím nemáte žádné žádosti"
|
||||||
fill="none"
|
description="Novou žádost můžete podat na stránce Docházka"
|
||||||
stroke="currentColor"
|
/>
|
||||||
strokeWidth="1.5"
|
}
|
||||||
strokeLinecap="round"
|
/>
|
||||||
strokeLinejoin="round"
|
</Card>
|
||||||
>
|
|
||||||
<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>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<ConfirmModal
|
<ConfirmDialog
|
||||||
isOpen={cancelModal.open}
|
isOpen={cancelModal.open}
|
||||||
onClose={() => setCancelModal({ open: false, id: null })}
|
onClose={() => setCancelModal({ open: false, id: null })}
|
||||||
onConfirm={handleCancel}
|
onConfirm={handleCancel}
|
||||||
title="Zrušit žádost"
|
title="Zrušit žádost"
|
||||||
message="Opravdu chcete zrušit tuto žádost o nepřítomnost?"
|
message="Opravdu chcete zrušit tuto žádost o nepřítomnost?"
|
||||||
confirmText="Zrušit žádost"
|
confirmText="Zrušit žádost"
|
||||||
type="warning"
|
|
||||||
loading={cancelMutation.isPending}
|
loading={cancelMutation.isPending}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user