"use client"; import { useState, useCallback } from "react"; import Link from "next/link"; import { VacationStatus, VacationType } from "@nexus/shared"; import { trpc } from "~/lib/trpc/client.js"; import { VacationModal } from "./VacationModal.js"; import { TeamCalendar } from "./TeamCalendar.js"; import { FilterChips } from "~/components/ui/FilterChips.js"; import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js"; import { useTableSort } from "~/hooks/useTableSort.js"; import { useViewPrefs } from "~/hooks/useViewPrefs.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { VACATION_STATUS_BADGE as STATUS_BADGE, VACATION_TYPE_LABELS as TYPE_LABELS, VACATION_TYPE_BADGE, } from "~/lib/status-styles.js"; import { SuccessToast } from "~/components/ui/SuccessToast.js"; type VacationStatusFilter = VacationStatus | "ALL"; type VacationTypeFilter = VacationType | "ALL"; type Tab = "list" | "team-calendar"; export function VacationClient() { const [tab, setTab] = useState("list"); const [showModal, setShowModal] = useState(false); const [statusFilter, setStatusFilter] = useState("ALL"); const [typeFilter, setTypeFilter] = useState("ALL"); const [resourceFilter, setResourceFilter] = useState(""); const [selected, setSelected] = useState>(new Set()); const [batchRejectReason, setBatchRejectReason] = useState(""); const [showBatchRejectInput, setShowBatchRejectInput] = useState(false); const [toast, setToast] = useState<{ show: boolean; message: string; variant: "success" | "warning"; }>({ show: false, message: "", variant: "success", }); const clearToast = useCallback(() => setToast((t) => ({ ...t, show: false })), []); const { data: vacations, isLoading, error: vacationError, refetch, } = trpc.vacation.list.useQuery( { ...(statusFilter !== "ALL" ? { status: statusFilter } : {}), ...(typeFilter !== "ALL" ? { type: typeFilter } : {}), ...(resourceFilter ? { resourceId: resourceFilter } : {}), limit: 200, }, { staleTime: 15_000 }, ); const { data: resources } = trpc.resource.directory.useQuery( { isActive: true, limit: 500 }, { staleTime: 60_000 }, ); const { data: pending } = trpc.vacation.getPendingApprovals.useQuery(undefined, { staleTime: 15_000, }); const utils = trpc.useUtils(); function invalidateAll() { return Promise.all([ utils.vacation.list.invalidate(), utils.vacation.getPendingApprovals.invalidate(), utils.entitlement.getBalance.invalidate(), ]); } const approveMutation = trpc.vacation.approve.useMutation({ onSuccess: invalidateAll }); const rejectMutation = trpc.vacation.reject.useMutation({ onSuccess: invalidateAll }); const cancelMutation = trpc.vacation.cancel.useMutation({ onSuccess: () => utils.vacation.list.invalidate(), }); const batchApproveMutation = trpc.vacation.batchApprove.useMutation({ onSuccess: async () => { setSelected(new Set()); setToast({ show: true, message: "Vacations approved", variant: "success" }); await invalidateAll(); }, }); const batchRejectMutation = trpc.vacation.batchReject.useMutation({ onSuccess: async () => { setSelected(new Set()); setShowBatchRejectInput(false); setBatchRejectReason(""); setToast({ show: true, message: "Vacations rejected", variant: "warning" }); await invalidateAll(); }, }); const resourceList = (resources?.resources ?? []) as unknown as Array<{ id: string; displayName: string; eid: string; }>; const vacationList = vacations ?? []; const pendingList = pending ?? []; const vacViewPrefs = useViewPrefs("vacations"); const { sorted, sortField, sortDir, toggle } = useTableSort(vacationList, { initialField: vacViewPrefs.savedSort?.field ?? null, initialDir: vacViewPrefs.savedSort?.dir ?? null, onSortChange: (field, dir) => { vacViewPrefs.setSavedSort(field && dir ? { field, dir } : null); }, }); function handleSort(field: string) { if (field === "resource") { toggle( "resource", (v) => (v.resource as { displayName: string } | undefined)?.displayName ?? null, ); } else { toggle(field); } } function clearAll() { setStatusFilter("ALL"); setTypeFilter("ALL"); setResourceFilter(""); } const selectedResourceName = resourceFilter ? resourceList.find((r) => r.id === resourceFilter)?.displayName : null; const chips = [ ...(statusFilter !== "ALL" ? [{ label: `Status: ${statusFilter}`, onRemove: () => setStatusFilter("ALL") }] : []), ...(typeFilter !== "ALL" ? [{ label: `Type: ${TYPE_LABELS[typeFilter]}`, onRemove: () => setTypeFilter("ALL") }] : []), ...(resourceFilter ? [ { label: `Resource: ${selectedResourceName ?? resourceFilter}`, onRemove: () => setResourceFilter(""), }, ] : []), ]; const pendingIds = pendingList.map((v) => v.id); const selectedPending = [...selected].filter((id) => pendingIds.includes(id)); const allPendingSelected = pendingIds.length > 0 && pendingIds.every((id) => selected.has(id)); function toggleSelectAll() { if (allPendingSelected) { setSelected(new Set()); } else { setSelected(new Set(pendingIds)); } } return (
{/* Header */}

Vacations

Manage vacation requests and approvals

Regional public holidays are maintained in{" "} Holiday Calendars .

{/* Tabs */}
{(["list", "team-calendar"] as Tab[]).map((t) => ( ))}
{tab === "team-calendar" ? ( ) : ( <> {/* Pending approvals (manager view) */} {pendingList.length > 0 && (

Pending Approvals ({pendingList.length})

{/* Batch controls */}
{selectedPending.length > 0 && ( <> )}
{/* Batch reject reason input */} {showBatchRejectInput && selectedPending.length > 0 && (
setBatchRejectReason(e.target.value)} className="flex-1 px-3 py-1.5 text-sm border border-amber-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-400" />
)}
{pendingList.map((v) => (
{ const next = new Set(selected); if (e.target.checked) next.add(v.id); else next.delete(v.id); setSelected(next); }} className="rounded border-gray-300 dark:border-gray-600 text-amber-600 shrink-0" /> {v.resource.displayName} ({v.resource.eid}) · {TYPE_LABELS[v.type as VacationType]} · {new Date(v.startDate).toLocaleDateString("en-GB")} –{" "} {new Date(v.endDate).toLocaleDateString("en-GB")} {v.note && ( {v.note} )}
))}
)} {/* Filters */}
{/* Filter chips */} {chips.length > 0 && } {/* List */}
{isLoading ? (
Loading…
) : vacationError ? (
Error: {vacationError.message}
) : vacationList.length === 0 ? (
No vacations found.
) : ( {sorted.map((v) => { const type = v.type as VacationType; const status = v.status as VacationStatus; const resource = v.resource as { displayName: string; eid: string } | undefined; const vExtra = v as unknown as { rejectionReason?: string | null; isHalfDay?: boolean; }; return ( ); })}
Note / Reason{" "}
{resource?.displayName ?? "—"} {resource?.eid && ( ({resource.eid}) )} {TYPE_LABELS[type] ?? type} {new Date(v.startDate).toLocaleDateString("en-GB")} {vExtra.isHalfDay && ( ½ )} {new Date(v.endDate).toLocaleDateString("en-GB")} {status} {vExtra.rejectionReason ? ( {vExtra.rejectionReason} ) : ( (v.note ?? "—") )} {status === VacationStatus.CANCELLED ? ( ) : ( )}
)}
)} {showModal && ( setShowModal(false)} onSuccess={() => { setShowModal(false); void refetch(); }} /> )}
); }