813b21d1a0
Amber alert boxes were missing dark: variants, rendering as muddy dark-orange in dark mode with near-unreadable text. Fixed in: - VacationClient (Pending Approvals banner) - VacationModal (conflict warning) - ResourceDetail (load error) - SkillMatrixUpload (replace warning) - AllocationModal (open demand toggle) - ProjectWizard (budget bar, post-creation warnings) Pattern: bg-amber-50 → dark:bg-amber-950/30, border-amber-200 → dark:border-amber-800, text-amber-7/800 → dark:text-amber-300/400 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
468 lines
22 KiB
TypeScript
468 lines
22 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useCallback } from "react";
|
||
import Link from "next/link";
|
||
import { VacationStatus, VacationType } from "@capakraken/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<Tab>("list");
|
||
const [showModal, setShowModal] = useState(false);
|
||
const [statusFilter, setStatusFilter] = useState<VacationStatusFilter>("ALL");
|
||
const [typeFilter, setTypeFilter] = useState<VacationTypeFilter>("ALL");
|
||
const [resourceFilter, setResourceFilter] = useState<string>("");
|
||
const [selected, setSelected] = useState<Set<string>>(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 (
|
||
<div className="p-6 max-w-5xl mx-auto space-y-6">
|
||
<SuccessToast show={toast.show} message={toast.message} variant={toast.variant} onDone={clearToast} />
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Vacations</h1>
|
||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Manage vacation requests and approvals</p>
|
||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||
Regional public holidays are maintained in{" "}
|
||
<Link href="/admin/vacations" className="font-medium text-brand-700 hover:text-brand-800 dark:text-brand-400 dark:hover:text-brand-300">
|
||
Holiday Calendars
|
||
</Link>
|
||
.
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowModal(true)}
|
||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium"
|
||
>
|
||
+ Request Vacation
|
||
</button>
|
||
</div>
|
||
|
||
{/* Tabs */}
|
||
<div className="flex gap-1 border-b border-gray-200 dark:border-gray-700">
|
||
{(["list", "team-calendar"] as Tab[]).map((t) => (
|
||
<button
|
||
key={t}
|
||
type="button"
|
||
onClick={() => setTab(t)}
|
||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||
tab === t
|
||
? "border-brand-600 text-brand-700"
|
||
: "border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
|
||
}`}
|
||
>
|
||
{t === "list" ? "List" : "Team Calendar"}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{tab === "team-calendar" ? (
|
||
<TeamCalendar />
|
||
) : (
|
||
<>
|
||
{/* Pending approvals (manager view) */}
|
||
{pendingList.length > 0 && (
|
||
<div className="bg-amber-50 dark:bg-gray-800 border border-amber-200 dark:border-amber-700/50 rounded-xl p-4">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<h2 className="text-sm font-semibold text-amber-800 dark:text-amber-400">
|
||
Pending Approvals ({pendingList.length})
|
||
</h2>
|
||
{/* Batch controls */}
|
||
<div className="flex items-center gap-2">
|
||
<label className="flex items-center gap-1.5 text-xs text-amber-700 dark:text-amber-400 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={allPendingSelected}
|
||
onChange={toggleSelectAll}
|
||
className="rounded border-amber-300 dark:border-amber-600 text-amber-600"
|
||
/>
|
||
Select all
|
||
</label>
|
||
{selectedPending.length > 0 && (
|
||
<>
|
||
<button
|
||
type="button"
|
||
onClick={() => batchApproveMutation.mutate({ ids: selectedPending })}
|
||
disabled={batchApproveMutation.isPending}
|
||
className="px-2 py-1 bg-emerald-600 text-white text-xs rounded hover:bg-emerald-700 disabled:opacity-50"
|
||
>
|
||
Approve {selectedPending.length}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowBatchRejectInput((v) => !v)}
|
||
className="px-2 py-1 bg-red-600 text-white text-xs rounded hover:bg-red-700"
|
||
>
|
||
Reject {selectedPending.length}
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Batch reject reason input */}
|
||
{showBatchRejectInput && selectedPending.length > 0 && (
|
||
<div className="mb-3 flex gap-2">
|
||
<input
|
||
type="text"
|
||
placeholder="Rejection reason (optional)…"
|
||
value={batchRejectReason}
|
||
onChange={(e) => 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"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
batchRejectMutation.mutate({
|
||
ids: selectedPending,
|
||
...(batchRejectReason.trim() ? { rejectionReason: batchRejectReason.trim() } : {}),
|
||
})
|
||
}
|
||
disabled={batchRejectMutation.isPending}
|
||
className="px-3 py-1.5 bg-red-600 text-white text-xs rounded hover:bg-red-700 disabled:opacity-50"
|
||
>
|
||
Confirm Reject
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
<div className="space-y-2">
|
||
{pendingList.map((v) => (
|
||
<div
|
||
key={v.id}
|
||
className="flex items-center justify-between bg-white dark:bg-gray-800 rounded-lg px-4 py-2 shadow-sm"
|
||
>
|
||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||
<input
|
||
type="checkbox"
|
||
checked={selected.has(v.id)}
|
||
onChange={(e) => {
|
||
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"
|
||
/>
|
||
<span className="font-medium text-sm text-gray-900 dark:text-gray-100">{v.resource.displayName}</span>
|
||
<span className="text-xs text-gray-500 dark:text-gray-400">({v.resource.eid})</span>
|
||
<span className="mx-1 text-gray-300 dark:text-gray-600">·</span>
|
||
<span className={`inline-flex px-1.5 py-0.5 rounded-full text-xs font-medium ${VACATION_TYPE_BADGE[v.type as string] ?? "bg-gray-100 text-gray-600"}`}>{TYPE_LABELS[v.type as VacationType]}</span>
|
||
<span className="mx-1 text-gray-300 dark:text-gray-600">·</span>
|
||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||
{new Date(v.startDate).toLocaleDateString("en-GB")} –{" "}
|
||
{new Date(v.endDate).toLocaleDateString("en-GB")}
|
||
</span>
|
||
{v.note && (
|
||
<span className="text-xs text-gray-400 ml-2 italic truncate">{v.note}</span>
|
||
)}
|
||
</div>
|
||
<div className="flex gap-2 ml-4 shrink-0">
|
||
<button
|
||
type="button"
|
||
onClick={() => approveMutation.mutate({ id: v.id })}
|
||
disabled={approveMutation.isPending}
|
||
className="px-3 py-1 bg-emerald-600 text-white text-xs rounded hover:bg-emerald-700 disabled:opacity-50"
|
||
>
|
||
Approve
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => rejectMutation.mutate({ id: v.id })}
|
||
disabled={rejectMutation.isPending}
|
||
className="px-3 py-1 bg-red-600 text-white text-xs rounded hover:bg-red-700 disabled:opacity-50"
|
||
>
|
||
Reject
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Filters */}
|
||
<div className="flex flex-wrap gap-3 items-center">
|
||
<select
|
||
value={statusFilter}
|
||
onChange={(e) => setStatusFilter(e.target.value as VacationStatusFilter)}
|
||
className="px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 dark:bg-gray-900 dark:text-gray-100"
|
||
>
|
||
<option value="ALL">All statuses</option>
|
||
{Object.values(VacationStatus).map((s) => (
|
||
<option key={s} value={s}>{s}</option>
|
||
))}
|
||
</select>
|
||
<select
|
||
value={typeFilter}
|
||
onChange={(e) => setTypeFilter(e.target.value as VacationTypeFilter)}
|
||
className="px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 dark:bg-gray-900 dark:text-gray-100"
|
||
>
|
||
<option value="ALL">All types</option>
|
||
{Object.values(VacationType).map((t) => (
|
||
<option key={t} value={t}>{TYPE_LABELS[t]}</option>
|
||
))}
|
||
</select>
|
||
<select
|
||
value={resourceFilter}
|
||
onChange={(e) => setResourceFilter(e.target.value)}
|
||
className="px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 dark:bg-gray-900 dark:text-gray-100"
|
||
>
|
||
<option value="">All resources</option>
|
||
{resourceList.map((r) => (
|
||
<option key={r.id} value={r.id}>{r.displayName} ({r.eid})</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
{/* Filter chips */}
|
||
{chips.length > 0 && (
|
||
<FilterChips chips={chips} onClearAll={clearAll} />
|
||
)}
|
||
|
||
{/* List */}
|
||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||
{isLoading ? (
|
||
<div className="p-8 text-center text-sm text-gray-400">Loading…</div>
|
||
) : vacationError ? (
|
||
<div className="p-8 text-center text-sm text-red-500">Error: {vacationError.message}</div>
|
||
) : vacationList.length === 0 ? (
|
||
<div className="p-8 text-center text-sm text-gray-400">No vacations found.</div>
|
||
) : (
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
|
||
<SortableColumnHeader label="Resource" field="resource" sortField={sortField} sortDir={sortDir} onSort={handleSort} tooltip="The employee this vacation entry belongs to." />
|
||
<th className="px-3 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||
<span className="inline-flex items-center gap-0.5">
|
||
<button
|
||
type="button"
|
||
onClick={() => handleSort("type")}
|
||
className="flex items-center gap-0.5 justify-start w-full hover:text-gray-700 transition-colors group"
|
||
>
|
||
Type
|
||
</button>
|
||
<InfoTooltip content="ANNUAL = paid annual leave · SICK = sick leave · PUBLIC_HOLIDAY = public holiday · OTHER = other leave types." />
|
||
</span>
|
||
</th>
|
||
<SortableColumnHeader label="Start" field="startDate" sortField={sortField} sortDir={sortDir} onSort={handleSort} tooltip="First day of the leave period (inclusive). Shows a half-day indicator if applicable." />
|
||
<SortableColumnHeader label="End" field="endDate" sortField={sortField} sortDir={sortDir} onSort={handleSort} tooltip="Last day of the leave period (inclusive)." />
|
||
<th className="px-3 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||
<span className="inline-flex items-center gap-0.5">
|
||
<button
|
||
type="button"
|
||
onClick={() => handleSort("status")}
|
||
className="flex items-center gap-0.5 justify-start w-full hover:text-gray-700 transition-colors group"
|
||
>
|
||
Status
|
||
</button>
|
||
<InfoTooltip content="PENDING = awaiting manager approval · APPROVED = confirmed leave · REJECTED = declined by manager · CANCELLED = withdrawn by employee." />
|
||
</span>
|
||
</th>
|
||
<th className="text-left px-4 py-3 font-medium text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wider">
|
||
Note / Reason <InfoTooltip content="Employee's leave note, or manager's rejection reason if status is REJECTED." />
|
||
</th>
|
||
<th className="px-4 py-3" />
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-50 dark:divide-gray-700">
|
||
{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 (
|
||
<tr key={v.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||
<td className="px-4 py-3">
|
||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||
{resource?.displayName ?? "—"}
|
||
</span>
|
||
{resource?.eid && (
|
||
<span className="text-xs text-gray-400 dark:text-gray-500 ml-1">({resource.eid})</span>
|
||
)}
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${VACATION_TYPE_BADGE[type] ?? "bg-gray-100 text-gray-600"}`}>
|
||
{TYPE_LABELS[type] ?? type}
|
||
</span>
|
||
</td>
|
||
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">
|
||
{new Date(v.startDate).toLocaleDateString("en-GB")}
|
||
{vExtra.isHalfDay && <span className="ml-1 text-xs text-gray-400 dark:text-gray-500">½</span>}
|
||
</td>
|
||
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">
|
||
{new Date(v.endDate).toLocaleDateString("en-GB")}
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<span
|
||
className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_BADGE[status] ?? ""}`}
|
||
>
|
||
{status}
|
||
</span>
|
||
</td>
|
||
<td className="px-4 py-3 text-gray-400 dark:text-gray-500 text-xs max-w-xs truncate">
|
||
{vExtra.rejectionReason ? (
|
||
<span className="text-red-500">{vExtra.rejectionReason}</span>
|
||
) : (v.note ?? "—")}
|
||
</td>
|
||
<td className="px-4 py-3 text-right space-x-3">
|
||
{status === VacationStatus.CANCELLED ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => approveMutation.mutate({ id: v.id })}
|
||
disabled={approveMutation.isPending}
|
||
className="text-xs text-emerald-600 dark:text-emerald-400 hover:text-emerald-800 underline disabled:opacity-50"
|
||
>
|
||
Re-approve
|
||
</button>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
onClick={() => cancelMutation.mutate({ id: v.id })}
|
||
disabled={cancelMutation.isPending}
|
||
className="text-xs text-gray-400 hover:text-red-600 underline disabled:opacity-50"
|
||
>
|
||
Cancel
|
||
</button>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{showModal && (
|
||
<VacationModal
|
||
onClose={() => setShowModal(false)}
|
||
onSuccess={() => {
|
||
setShowModal(false);
|
||
void refetch();
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|