Files
app/src/admin/pages/LeaveApproval.tsx
BOHA c2746d78c9 fix(dashboard): stale punch button/KPIs after attendance changes on other pages
Verified flow: clock in on dashboard -> delete the record in /attendance/admin
-> navigate back: 'Zaznamenat odchod', the Pritomni KPI and the presence card
still showed the pre-delete state until F5. Root cause: every attendance
mutation outside the dashboard invalidated only ["attendance"], never
["dashboard"], so within the dashboard query's 60s staleTime the remount was
served from cache.

- useAttendanceAdmin create/bulk/edit/delete, /attendance punch +
  leave-request, AttendanceCreate, LeaveApproval approve/reject now also
  invalidate ["dashboard"] (CLAUDE.md rule: mutations invalidate every domain
  that embeds their data)
- systemic backstop: dashboardOptions uses refetchOnMount "always" - the
  dashboard aggregates attendance/offers/invoices/orders/projects/leave, and
  domain pages can't all be expected to invalidate it; returning to the
  dashboard must never show pre-mutation data

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:40:06 +02:00

492 lines
14 KiB
TypeScript

import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "../context/AuthContext";
import { useAlert } from "../context/AlertContext";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { formatDate, formatDatetime } from "../utils/attendanceHelpers";
import { czechPlural } from "../utils/formatters";
import {
leavePendingOptions,
leaveProcessedOptions,
} from "../lib/queries/leave";
import Forbidden from "../components/Forbidden";
import { useApiMutation } from "../lib/queries/mutations";
import {
Button,
Card,
DataTable,
ConfirmDialog,
Modal,
StatusChip,
Field,
TextField,
PageHeader,
PageEnter,
EmptyState,
LoadingState,
Tabs,
TabPanel,
type DataColumn,
type TabDef,
} 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",
};
interface RawLeaveRequest {
id: number;
leave_type: string;
date_from: string;
date_to: string;
total_days: number;
total_hours: number;
status: string;
notes?: string;
reviewer_note?: string;
created_at: string;
reviewed_at?: string;
users_leave_requests_user_idTousers?: {
first_name: string;
last_name: string;
};
users_leave_requests_reviewer_idTousers?: {
first_name: string;
last_name: string;
} | null;
}
interface LeaveRequest {
id: number;
employee_name: string;
leave_type: string;
date_from: string;
date_to: string;
total_days: number;
total_hours: number;
status: string;
notes?: string;
reviewer_name?: string;
reviewer_note?: string;
created_at: string;
reviewed_at?: string;
}
function mapLeaveRequest(raw: RawLeaveRequest): LeaveRequest {
const user = raw.users_leave_requests_user_idTousers;
const reviewer = raw.users_leave_requests_reviewer_idTousers;
return {
id: raw.id,
employee_name: user ? `${user.first_name} ${user.last_name}` : "Neznámý",
leave_type: raw.leave_type,
date_from: raw.date_from,
date_to: raw.date_to,
total_days: raw.total_days,
total_hours: raw.total_hours,
status: raw.status,
notes: raw.notes,
reviewer_name: reviewer
? `${reviewer.first_name} ${reviewer.last_name}`
: undefined,
reviewer_note: raw.reviewer_note,
created_at: raw.created_at,
reviewed_at: raw.reviewed_at,
};
}
export default function LeaveApproval() {
const { hasPermission } = useAuth();
const alert = useAlert();
const [activeTab, setActiveTab] = useState<"pending" | "processed">(
"pending",
);
const { data: pendingData, isPending: loading } = useQuery(
leavePendingOptions(),
);
const { data: processedData } = useQuery({
...leaveProcessedOptions(),
enabled: activeTab === "processed",
});
const pendingRequests =
(pendingData as RawLeaveRequest[] | undefined)?.map(mapLeaveRequest) ?? [];
const pendingCount = pendingRequests.length;
const processedRequests =
(processedData as RawLeaveRequest[] | undefined)?.map(mapLeaveRequest) ??
[];
const [approveModal, setApproveModal] = useState<{
open: boolean;
request: LeaveRequest | null;
}>({ open: false, request: null });
const [rejectModal, setRejectModal] = useState<{
open: boolean;
request: LeaveRequest | null;
}>({ open: false, request: null });
const [rejectNote, setRejectNote] = useState("");
const approveMutation = useApiMutation<
{ id: number; status: "approved" },
unknown
>({
url: ({ id }) => `${API_BASE}/leave-requests/${id}`,
method: () => "PUT",
invalidate: ["leave-requests", "leave", "attendance", "users", "dashboard"],
onSuccess: () => {
setApproveModal({ open: false, request: null });
alert.success("Žádost byla schválena");
},
});
const rejectMutation = useApiMutation<
{ id: number; status: "rejected"; reviewer_note: string },
unknown
>({
url: ({ id }) => `${API_BASE}/leave-requests/${id}`,
method: () => "PUT",
invalidate: ["leave-requests", "leave", "attendance", "users", "dashboard"],
onSuccess: () => {
setRejectModal({ open: false, request: null });
setRejectNote("");
alert.success("Žádost byla zamítnuta");
},
});
if (!hasPermission("attendance.approve")) return <Forbidden />;
const handleApprove = async () => {
if (!approveModal.request) return;
try {
await approveMutation.mutateAsync({
id: approveModal.request.id,
status: "approved",
});
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const handleReject = async () => {
if (!rejectNote.trim()) {
alert.error("Důvod zamítnutí je povinný");
return;
}
if (!rejectModal.request) return;
try {
await rejectMutation.mutateAsync({
id: rejectModal.request.id,
status: "rejected",
reviewer_note: rejectNote,
});
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
if (loading) {
return <LoadingState />;
}
const tabs: TabDef[] = [
{ value: "pending", label: `Ke schválení (${pendingCount})` },
{ value: "processed", label: "Vyřízené" },
];
const processedColumns: DataColumn<LeaveRequest>[] = [
{
key: "employee_name",
header: "Zaměstnanec",
width: "16%",
bold: true,
render: (req) => req.employee_name,
},
{
key: "leave_type",
header: "Typ",
width: "13%",
render: (req) => (
<StatusChip
label={leaveTypeLabels[req.leave_type] || req.leave_type}
color={leaveTypeColor[req.leave_type] ?? "default"}
/>
),
},
{
key: "date_from",
header: "Od",
width: "10%",
mono: true,
render: (req) => formatDate(req.date_from),
},
{
key: "date_to",
header: "Do",
width: "10%",
mono: true,
render: (req) => formatDate(req.date_to),
},
{
key: "total_days",
header: "Dny",
width: "6%",
mono: true,
render: (req) => String(req.total_days),
},
{
key: "status",
header: "Stav",
width: "13%",
render: (req) => (
<StatusChip
label={statusLabels[req.status] || req.status}
color={statusColor[req.status] ?? "default"}
/>
),
},
{
key: "reviewer_name",
header: "Schválil",
width: "12%",
render: (req) => req.reviewer_name || "—",
},
{
key: "reviewer_note",
header: "Poznámka",
width: "12%",
render: (req) =>
req.reviewer_note ? (
<Box component="span" title={req.reviewer_note}>
{req.reviewer_note.length > 40
? `${req.reviewer_note.substring(0, 40)}...`
: req.reviewer_note}
</Box>
) : (
"—"
),
},
{
key: "reviewed_at",
header: "Vyřízeno",
width: "8%",
mono: true,
render: (req) => formatDatetime(req.reviewed_at),
},
];
return (
<PageEnter>
<PageHeader
title="Schvalování nepřítomnosti"
subtitle={
pendingCount > 0
? `${pendingCount} ${czechPlural(pendingCount, "žádost čeká", "žádosti čekají", "žádostí čeká")} na schválení`
: "Žádné čekající žádosti"
}
/>
<Tabs
value={activeTab}
onChange={(v) => setActiveTab(v as "pending" | "processed")}
tabs={tabs}
/>
{/* Pending Tab */}
<TabPanel value="pending" current={activeTab}>
{pendingRequests.length === 0 ? (
<Card>
<EmptyState title="Žádné čekající žádosti" />
</Card>
) : (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
{pendingRequests.map((req) => (
<Card key={req.id}>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
flexWrap: "wrap",
gap: 2,
}}
>
<Box sx={{ flex: 1 }}>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1.5,
mb: 1,
}}
>
<Typography
component="strong"
sx={{ fontSize: "1rem", fontWeight: 600 }}
>
{req.employee_name}
</Typography>
<StatusChip
label={
leaveTypeLabels[req.leave_type] || req.leave_type
}
color={leaveTypeColor[req.leave_type] ?? "default"}
/>
</Box>
<Box
sx={{
display: "flex",
gap: 3,
flexWrap: "wrap",
fontSize: "0.875rem",
color: "text.secondary",
}}
>
<Box component="span">
<strong>{formatDate(req.date_from)}</strong> {" "}
<strong>{formatDate(req.date_to)}</strong>
</Box>
<Box component="span">
{req.total_days}{" "}
{czechPlural(req.total_days, "den", "dny", "dnů")} (
{req.total_hours}h)
</Box>
<Box component="span" sx={{ color: "text.disabled" }}>
Podáno: {formatDatetime(req.created_at)}
</Box>
</Box>
{req.notes && (
<Box
sx={{
mt: 1,
fontSize: "0.875rem",
fontStyle: "italic",
color: "text.secondary",
}}
>
{req.notes}
</Box>
)}
</Box>
<Box sx={{ display: "flex", gap: 1, flexShrink: 0 }}>
<Button
size="small"
color="success"
variant="outlined"
onClick={() =>
setApproveModal({ open: true, request: req })
}
>
Schválit
</Button>
<Button
size="small"
color="error"
variant="outlined"
onClick={() =>
setRejectModal({ open: true, request: req })
}
>
Zamítnout
</Button>
</Box>
</Box>
</Card>
))}
</Box>
)}
</TabPanel>
{/* Processed Tab */}
<TabPanel value="processed" current={activeTab}>
<Card>
<DataTable<LeaveRequest>
columns={processedColumns}
rows={processedRequests}
rowKey={(req) => req.id}
empty={<EmptyState title="Zatím žádné vyřízené žádosti" />}
/>
</Card>
</TabPanel>
{/* Approve Confirmation */}
<ConfirmDialog
isOpen={approveModal.open}
onClose={() => setApproveModal({ open: false, request: null })}
onConfirm={handleApprove}
title="Schválit žádost"
message={
approveModal.request
? `Schválit ${approveModal.request.total_days} ${czechPlural(approveModal.request.total_days, "den", "dny", "dnů")} ${leaveTypeLabels[approveModal.request.leave_type]?.toLowerCase() || ""} pro ${approveModal.request.employee_name}?`
: ""
}
confirmText="Schválit"
loading={approveMutation.isPending}
/>
{/* Reject Modal */}
<Modal
isOpen={rejectModal.open && !!rejectModal.request}
onClose={() => {
setRejectModal({ open: false, request: null });
setRejectNote("");
}}
onSubmit={handleReject}
title="Zamítnout žádost"
submitText="Zamítnout"
loading={rejectMutation.isPending}
>
{rejectModal.request && (
<>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{rejectModal.request.employee_name} {" "}
{leaveTypeLabels[rejectModal.request.leave_type]},{" "}
{formatDate(rejectModal.request.date_from)} {" "}
{formatDate(rejectModal.request.date_to)} (
{rejectModal.request.total_days} dnů)
</Typography>
<Field label="Důvod zamítnutí" required>
<TextField
value={rejectNote}
onChange={(e) => setRejectNote(e.target.value)}
placeholder="Uveďte důvod zamítnutí..."
multiline
rows={3}
autoFocus
/>
</Field>
</>
)}
</Modal>
</PageEnter>
);
}