Files
Nexus/apps/web/src/components/vacations/VacationClient.tsx
T
Hartmut eb283147d1 feat: project colors, timeline filters, sidebar fix, GitLooper agent, and misc improvements
- Fix sidebar double-highlight on /vacations/my (Gitea #6): add isNavItemActive() helper
- Add project color picker (schema + API + modal + timeline rendering)
- Add ProjectCombobox/ResourceCombobox to timeline toolbar
- Show PENDING vacations on timeline with dashed/dimmed style
- Add "show demand projects" preference with localStorage persistence
- Add ProjectAssignmentsTable with total hours/cost columns
- Extend vacation API to accept status arrays
- Add GitLooper formal YAML agent configuration
- Extend user admin with permission overrides UI
- Add delete-assignment use case tests
- Add status-styles.ts shared badge constants
- Centralize formatMoney/formatCents in format.ts

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-17 10:22:52 +01:00

450 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState } from "react";
import { VacationStatus, VacationType } from "@planarchy/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";
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 { 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.list.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());
await invalidateAll();
},
});
const batchRejectMutation = trpc.vacation.batchReject.useMutation({
onSuccess: async () => {
setSelected(new Set());
setShowBatchRejectInput(false);
setBatchRejectReason("");
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">
{/* 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>
</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 border border-amber-200 rounded-xl p-4">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-amber-800">
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 cursor-pointer">
<input
type="checkbox"
checked={allPendingSelected}
onChange={toggleSelectAll}
className="rounded border-amber-300 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} />
<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} />
<SortableColumnHeader label="End" field="endDate" sortField={sortField} sortDir={sortDir} onSort={handleSort} />
<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>
);
}