Files
app/src/admin/pages/LeaveRequests.tsx
BOHA 87e644eef8 fix(freshness): converge invalidation sets; global query-error toast; silent-toggle feedback
- 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>
2026-07-04 03:21:50 +02:00

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>
);
}