Files
Nexus/apps/web/src/components/vacations/VacationClient.tsx
T
Hartmut 4a5edeef3e
CI / Unit Tests (pull_request) Successful in 5m46s
CI / Lint (pull_request) Failing after 3m49s
CI / E2E Tests (pull_request) Has been skipped
CI / Fresh-Linux Docker Deploy (pull_request) Has been skipped
CI / Assistant Split Regression (pull_request) Failing after 35s
CI / Architecture Guardrails (pull_request) Failing after 2m14s
CI / Typecheck (pull_request) Successful in 4m22s
CI / Build (pull_request) Has been skipped
CI / Release Images (pull_request) Has been skipped
rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI
- @capakraken/* → @nexus/* across 12 packages (root + 11 workspaces),
  1551 import lines migrated via codemod
- User-visible brand strings renamed (emails, page titles, PWA
  manifest, mobile header, MFA backup-codes header, tooltips, signin
  page, invite page, weekly digest, install prompt)
- TOTP issuer "CapaKraken" → "Nexus" (existing secrets still valid;
  re-enrollment relabels them in users' authenticator apps)
- Function rename: assertCapaKrakenDbTarget → assertNexusDbTarget
- LocalStorage migration shim in apps/web/src/app/layout.tsx copies
  capakraken_* → nexus_* on first load (guarded by nexus_migrated_v1
  sentinel; runs once per browser, then never again)
- Service-worker cache name capakraken-v2 → nexus-v2 with one-time
  caches.delete('capakraken-v2') from the same shim
- Email-domain fixtures @capakraken.{dev,app} → @nexus.{dev,app} in
  seed data, e2e specs, SMTP default fallback
- Dockerfile.dev / Dockerfile.prod / all .github/workflows/*.yml
  pnpm --filter @capakraken/* → @nexus/*
- README, CLAUDE.md, LEARNINGS.md, all docs/*.md, .env.example,
  tooling/deploy/.env.production.example brand sweep

Phase 1 deliberately leaves untouched (handled in Phase 3 cutover):
- PostgreSQL DB name "capakraken" and POSTGRES_USER "capakraken"
- Volume names capakraken_pgdata etc.
- Compose project name "capakraken" / "capakraken-prod"
- db-target-guard default expectedDatabase
- env-var CAPAKRAKEN_EXPECTED_DB_NAME
- Container DNS names in docker-compose.ci.yml

Quality gates green: pnpm typecheck (7/7), pnpm test:unit (7/7),
pnpm lint (0 errors), check:exports/imports/architecture all pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 15:10:44 +02:00

562 lines
24 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, 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<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-amber-950/30 border border-amber-200 dark:border-amber-700/60 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>
);
}