- OrderDetail/InvoiceDetail/LeaveRequests mutations now invalidate the same domain sets as their list-page twins (orders+offers+projects+invoices / +projects / +users+dashboard) — no more stale project/order labels after a detail-page action. - USER_INVALIDATE hoisted to lib/queries/users.ts; DashProfile self-edit now refreshes trips/attendance/leave/projects like an admin edit. - Global QueryCache.onError toast: failed first-loads no longer masquerade as empty lists (data-present refetches stay silent; Unauthorized skipped; 4s rate limit; meta.suppressGlobalErrorToast opt-out used by offer/issued-order detail + the local TOTP QR query). - Users/Vehicles active-toggle mutations toast on error (was silent revert); clickable status chips got affordance tooltips (incl. warehouse parity). - AuditLog: refetchOnMount always + staleTime 0 (mutations write audit rows but nothing invalidates the key); search debounced; dead auditLogOptions module removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
272 lines
6.6 KiB
TypeScript
272 lines
6.6 KiB
TypeScript
import { useState } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { useAlert } from "../context/AlertContext";
|
|
import { useAuth } from "../context/AuthContext";
|
|
import Box from "@mui/material/Box";
|
|
import Forbidden from "../components/Forbidden";
|
|
import { formatDate, formatDatetime } from "../utils/attendanceHelpers";
|
|
import { leaveRequestsOptions, type LeaveRequest } from "../lib/queries/leave";
|
|
import { useApiMutation } from "../lib/queries/mutations";
|
|
import {
|
|
Button,
|
|
Card,
|
|
DataTable,
|
|
ConfirmDialog,
|
|
StatusChip,
|
|
PageHeader,
|
|
PageEnter,
|
|
EmptyState,
|
|
LoadingState,
|
|
type DataColumn,
|
|
} from "../ui";
|
|
|
|
const API_BASE = "/api/admin";
|
|
|
|
const leaveTypeLabels: Record<string, string> = {
|
|
vacation: "Dovolená",
|
|
sick: "Nemoc",
|
|
unpaid: "Neplacené volno",
|
|
};
|
|
|
|
const statusLabels: Record<string, string> = {
|
|
pending: "Čeká na schválení",
|
|
approved: "Schváleno",
|
|
rejected: "Zamítnuto",
|
|
cancelled: "Zrušeno",
|
|
};
|
|
|
|
// Leave type → StatusChip color
|
|
const leaveTypeColor: Record<
|
|
string,
|
|
"info" | "error" | "default" | "success" | "warning"
|
|
> = {
|
|
vacation: "info",
|
|
sick: "error",
|
|
unpaid: "default",
|
|
};
|
|
|
|
// 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();
|
|
const { hasPermission } = useAuth();
|
|
const { data: requests = [], isPending } = useQuery(
|
|
leaveRequestsOptions(true),
|
|
);
|
|
const [cancelModal, setCancelModal] = useState<{
|
|
open: boolean;
|
|
id: number | null;
|
|
}>({ open: false, id: null });
|
|
|
|
const cancelMutation = useApiMutation<
|
|
{ id: number },
|
|
{ message?: string; error?: string }
|
|
>({
|
|
url: ({ id }) => `${API_BASE}/leave-requests/${id}`,
|
|
method: () => "DELETE",
|
|
invalidate: ["leave-requests", "leave", "attendance", "users", "dashboard"],
|
|
onSuccess: (data) => {
|
|
setCancelModal({ open: false, id: null });
|
|
alert.success(data?.message || "Žádost byla zrušena");
|
|
},
|
|
});
|
|
|
|
if (!hasPermission("attendance.record")) return <Forbidden />;
|
|
|
|
const handleCancel = async () => {
|
|
if (!cancelModal.id) return;
|
|
try {
|
|
await cancelMutation.mutateAsync({ id: cancelModal.id });
|
|
} catch (e) {
|
|
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
|
}
|
|
};
|
|
|
|
if (isPending) {
|
|
return <LoadingState />;
|
|
}
|
|
|
|
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 (
|
|
<PageEnter>
|
|
<PageHeader
|
|
title="Moje žádosti"
|
|
subtitle="Přehled žádostí o nepřítomnost"
|
|
/>
|
|
|
|
<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>
|
|
|
|
<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"
|
|
loading={cancelMutation.isPending}
|
|
/>
|
|
</PageEnter>
|
|
);
|
|
}
|