chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user