chore(repo): initialize planarchy workspace

This commit is contained in:
2026-03-14 14:31:09 +01:00
commit dd55d0e78b
769 changed files with 166461 additions and 0 deletions
@@ -0,0 +1,458 @@
"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";
const STATUS_BADGE: Record<VacationStatus, string> = {
PENDING: "bg-amber-100 text-amber-700 dark:bg-yellow-900/30 dark:text-yellow-400",
APPROVED: "bg-emerald-100 text-emerald-700 dark:bg-green-900/30 dark:text-green-400",
REJECTED: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
CANCELLED: "bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400",
};
const TYPE_LABELS: Record<VacationType, string> = {
ANNUAL: "Annual Leave",
SICK: "Sick Leave",
PUBLIC_HOLIDAY: "Public Holiday",
OTHER: "Other",
};
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="text-sm text-gray-600 dark:text-gray-400">{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 text-gray-600 dark:text-gray-400">{TYPE_LABELS[type] ?? type}</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>
);
}