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>
492 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|