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,99 @@
"use client";
import { trpc } from "~/lib/trpc/client.js";
interface BalanceCardProps {
resourceId: string;
year?: number;
compact?: boolean;
}
export function BalanceCard({ resourceId, year = new Date().getFullYear(), compact = false }: BalanceCardProps) {
const { data: balance, isLoading } = trpc.entitlement.getBalance.useQuery(
{ resourceId, year },
{ staleTime: 30_000 },
);
if (isLoading) {
return (
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 animate-pulse">
<div className="h-4 bg-gray-100 dark:bg-gray-700 rounded w-1/3 mb-3" />
<div className="h-8 bg-gray-100 dark:bg-gray-700 rounded w-1/2" />
</div>
);
}
if (!balance) return null;
const pct = balance.entitledDays > 0
? Math.round((balance.usedDays / balance.entitledDays) * 100)
: 0;
if (compact) {
return (
<div className="flex items-center gap-3 text-sm">
<span className="font-medium text-gray-900 dark:text-gray-100">{balance.remainingDays}d remaining</span>
<span className="text-gray-400 dark:text-gray-600">·</span>
<span className="text-gray-500 dark:text-gray-400">{balance.usedDays}d used of {balance.entitledDays}d</span>
{balance.pendingDays > 0 && (
<>
<span className="text-gray-400 dark:text-gray-600">·</span>
<span className="text-amber-600">{balance.pendingDays}d pending</span>
</>
)}
</div>
);
}
return (
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
Vacation Balance {year}
</h3>
{balance.carryoverDays > 0 && (
<span className="text-xs text-gray-400 dark:text-gray-500">+{balance.carryoverDays}d carried over</span>
)}
</div>
<div className="grid grid-cols-4 gap-3">
<Stat label="Entitled" value={balance.entitledDays} color="text-gray-900" />
<Stat label="Used" value={balance.usedDays} color="text-gray-600" />
<Stat label="Pending" value={balance.pendingDays} color="text-amber-600" />
<Stat label="Remaining" value={balance.remainingDays} color={balance.remainingDays < 5 ? "text-red-600" : "text-emerald-600"} />
</div>
{/* Progress bar */}
<div className="relative h-2 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className="absolute inset-y-0 left-0 bg-emerald-500 rounded-full transition-all"
style={{ width: `${Math.min(100, pct)}%` }}
/>
{balance.pendingDays > 0 && (
<div
className="absolute inset-y-0 bg-amber-400 rounded-full"
style={{
left: `${Math.min(100, pct)}%`,
width: `${Math.min(100 - pct, Math.round((balance.pendingDays / balance.entitledDays) * 100))}%`,
}}
/>
)}
</div>
{balance.sickDays > 0 && (
<p className="text-xs text-gray-400 dark:text-gray-500">
{balance.sickDays} sick day{balance.sickDays !== 1 ? "s" : ""} recorded (not deducted from annual leave)
</p>
)}
</div>
);
}
function Stat({ label, value, color }: { label: string; value: number; color: string }) {
return (
<div className="text-center">
<p className={`text-xl font-bold ${color}`}>{value}</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{label}</p>
</div>
);
}
@@ -0,0 +1,132 @@
"use client";
import { useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
export function EntitlementManager() {
const [year, setYear] = useState(new Date().getFullYear());
const [bulkDays, setBulkDays] = useState(28);
const [bulkResult, setBulkResult] = useState<number | null>(null);
const utils = trpc.useUtils();
const bulkSetMutation = trpc.entitlement.bulkSet.useMutation({
onSuccess: async (data) => {
setBulkResult(data.updated);
await utils.entitlement.getBalance.invalidate();
},
});
const { data: summary, isLoading } = trpc.entitlement.getYearSummary.useQuery(
{ year },
{ staleTime: 30_000 },
);
return (
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 space-y-5">
<h3 className="text-sm font-semibold text-gray-800 dark:text-gray-100">Vacation Entitlement Manager</h3>
{/* Controls */}
<div className="flex flex-wrap gap-4 items-end">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Year</label>
<input
type="number"
value={year}
onChange={(e) => setYear(parseInt(e.target.value, 10))}
min={2020}
max={2030}
className="w-24 px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 dark:bg-gray-900 dark:text-gray-100"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Default Days (bulk set)</label>
<input
type="number"
value={bulkDays}
onChange={(e) => setBulkDays(parseInt(e.target.value, 10))}
min={0}
max={365}
className="w-24 px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 dark:bg-gray-900 dark:text-gray-100"
/>
</div>
<button
type="button"
onClick={() => {
setBulkResult(null);
bulkSetMutation.mutate({ year, entitledDays: bulkDays });
}}
disabled={bulkSetMutation.isPending}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{bulkSetMutation.isPending ? "Setting…" : "Bulk Set All Resources"}
</button>
{bulkResult !== null && (
<span className="text-sm text-emerald-600 dark:text-emerald-400">Updated {bulkResult} resources</span>
)}
</div>
{/* Year summary table */}
<div className="overflow-hidden rounded-lg border border-gray-100 dark:border-gray-700">
{isLoading ? (
<div className="p-6 text-center text-sm text-gray-400">Loading</div>
) : !summary?.length ? (
<div className="p-6 text-center text-sm text-gray-400">No resources found.</div>
) : (
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-50 dark:bg-gray-900 border-b border-gray-100 dark:border-gray-700">
<th className="text-left px-4 py-2.5 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Resource</th>
<th className="text-left px-4 py-2.5 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Chapter</th>
<th className="text-right px-4 py-2.5 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<span className="inline-flex items-center justify-end gap-0.5">
Entitled <InfoTooltip content="Total vacation days granted to this resource for the selected year." />
</span>
</th>
<th className="text-right px-4 py-2.5 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<span className="inline-flex items-center justify-end gap-0.5">
Carryover <InfoTooltip content="Unused days carried over from the previous year." />
</span>
</th>
<th className="text-right px-4 py-2.5 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<span className="inline-flex items-center justify-end gap-0.5">
Used <InfoTooltip content="Days already consumed by APPROVED vacations that have passed." />
</span>
</th>
<th className="text-right px-4 py-2.5 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<span className="inline-flex items-center justify-end gap-0.5">
Pending <InfoTooltip content="Days reserved by APPROVED future vacations not yet started." />
</span>
</th>
<th className="text-right px-4 py-2.5 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<span className="inline-flex items-center justify-end gap-0.5">
Remaining <InfoTooltip content="Entitled + Carryover Used Pending. Shown in red if fewer than 5 days remain." />
</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50 dark:divide-gray-700">
{summary.map((row) => (
<tr key={row.resourceId} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td className="px-4 py-2.5">
<span className="font-medium text-gray-900 dark:text-gray-100">{row.displayName}</span>
<span className="text-xs text-gray-400 dark:text-gray-500 ml-1">({row.eid})</span>
</td>
<td className="px-4 py-2.5 text-gray-500 dark:text-gray-400">{row.chapter ?? "—"}</td>
<td className="px-4 py-2.5 text-right text-gray-700 dark:text-gray-300">{row.entitledDays}</td>
<td className="px-4 py-2.5 text-right text-gray-400 dark:text-gray-500">{row.carryoverDays}</td>
<td className="px-4 py-2.5 text-right text-gray-700 dark:text-gray-300">{row.usedDays}</td>
<td className="px-4 py-2.5 text-right text-amber-600">{row.pendingDays}</td>
<td className={`px-4 py-2.5 text-right font-semibold ${row.remainingDays < 5 ? "text-red-600" : "text-emerald-600"}`}>
{row.remainingDays}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}
@@ -0,0 +1,172 @@
"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 { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { BalanceCard } from "./BalanceCard.js";
import { VacationCalendar } from "./VacationCalendar.js";
const STATUS_BADGE: Record<string, 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<string, string> = {
ANNUAL: "Annual Leave",
SICK: "Sick Leave",
PUBLIC_HOLIDAY: "Public Holiday",
OTHER: "Other",
};
export function MyVacationsClient() {
const [showModal, setShowModal] = useState(false);
const utils = trpc.useUtils();
// Find resource linked to current user
const { data: myResource } = trpc.resource.getMyResource.useQuery(undefined, {
staleTime: 60_000,
});
const resourceId = myResource?.id;
const { data: vacations, isLoading, refetch } = trpc.vacation.list.useQuery(
{ resourceId, limit: 200 },
{ enabled: !!resourceId, staleTime: 15_000 },
);
const cancelMutation = trpc.vacation.cancel.useMutation({
onSuccess: async () => {
await utils.vacation.list.invalidate();
await utils.entitlement.getBalance.invalidate();
},
});
const vacationList = vacations ?? [];
return (
<div className="p-6 max-w-4xl 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">My Vacations</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Manage your personal vacation requests</p>
</div>
<button
type="button"
onClick={() => setShowModal(true)}
disabled={!resourceId}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
+ Request Vacation
</button>
</div>
{!resourceId && (
<div className="rounded-xl bg-amber-50 border border-amber-200 p-4 text-sm text-amber-700">
Your account is not linked to a resource. Please contact an administrator.
</div>
)}
{/* Balance card */}
{resourceId && (
<BalanceCard resourceId={resourceId} />
)}
{/* Calendar */}
{resourceId && vacationList.length > 0 && (
<VacationCalendar vacations={vacationList} />
)}
{/* Vacation 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>
) : vacationList.length === 0 ? (
<div className="p-8 text-center text-sm text-gray-400">No vacation requests yet.</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">
<th className="text-left px-4 py-3 font-medium text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wider">
Type <InfoTooltip content="ANNUAL = paid annual leave · SICK = sick leave · PUBLIC_HOLIDAY = public holiday · OTHER = other leave types." />
</th>
<th className="text-left px-4 py-3 font-medium text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wider">Start</th>
<th className="text-left px-4 py-3 font-medium text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wider">End</th>
<th className="text-right px-4 py-3 font-medium text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wider">
<span className="inline-flex items-center justify-end gap-0.5">
Days <InfoTooltip content="Calendar days from start to end date (inclusive). Shows 0.5 for half-day requests (½ indicator on start date)." />
</span>
</th>
<th className="text-left px-4 py-3 font-medium text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wider">
Status <InfoTooltip content="PENDING = awaiting manager approval · APPROVED = confirmed leave · REJECTED = declined by manager · CANCELLED = withdrawn by employee." />
</th>
<th className="text-left px-4 py-3 font-medium text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wider">
Note <InfoTooltip content="Your note on the request, or the manager's rejection reason if declined." />
</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-gray-50 dark:divide-gray-700">
{vacationList.map((v) => {
const start = new Date(v.startDate);
const end = new Date(v.endDate);
const days = Math.round((end.getTime() - start.getTime()) / 86_400_000) + 1;
const status = v.status as string;
const type = v.type as string;
const vWithExtra = 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 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">{start.toLocaleDateString("en-GB")}</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">{end.toLocaleDateString("en-GB")}</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">{vWithExtra.isHalfDay ? "0.5" : days}</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-[200px]">
{vWithExtra.rejectionReason ? (
<span className="text-red-500">{vWithExtra.rejectionReason}</span>
) : (v.note ?? "—")}
</td>
<td className="px-4 py-3 text-right">
{(status === VacationStatus.PENDING || status === VacationStatus.APPROVED) && (
<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 && resourceId && (
<VacationModal
resourceId={resourceId}
onClose={() => setShowModal(false)}
onSuccess={() => {
setShowModal(false);
void refetch();
void utils.entitlement.getBalance.invalidate();
}}
/>
)}
</div>
);
}
@@ -0,0 +1,119 @@
"use client";
import { useState } from "react";
import { GERMAN_FEDERAL_STATES } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
export function PublicHolidayBatch() {
const [year, setYear] = useState(new Date().getFullYear());
const [federalState, setFederalState] = useState("BY");
const [chapter, setChapter] = useState("");
const [replaceExisting, setReplaceExisting] = useState(false);
const [result, setResult] = useState<{ created: number; holidays?: number; resources?: number } | null>(null);
const { data: resources } = trpc.resource.list.useQuery(
{ isActive: true, limit: 500 },
{ staleTime: 60_000 },
);
const resourceList = (resources?.resources ?? []) as Array<{ id: string; chapter?: string | null }>;
const chapters = Array.from(
new Set(resourceList.map((r) => r.chapter).filter(Boolean) as string[])
).sort();
const mutation = trpc.vacation.batchCreatePublicHolidays.useMutation({
onSuccess: (data) => setResult(data),
});
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setResult(null);
mutation.mutate({
year,
federalState: federalState || undefined,
chapter: chapter || undefined,
replaceExisting,
});
}
return (
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 space-y-4">
<h3 className="text-sm font-semibold text-gray-800 dark:text-gray-100">Batch Create Public Holidays</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">
Creates public holidays as APPROVED vacation entries for all resources (or a chapter).
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Year</label>
<input
type="number"
value={year}
onChange={(e) => setYear(parseInt(e.target.value, 10))}
min={2020}
max={2030}
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 dark:bg-gray-900 dark:text-gray-100"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Federal State</label>
<select
value={federalState}
onChange={(e) => setFederalState(e.target.value)}
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 dark:bg-gray-900 dark:text-gray-100"
>
<option value="">Federal only</option>
{Object.entries(GERMAN_FEDERAL_STATES).map(([abbr, name]) => (
<option key={abbr} value={abbr}>{name} ({abbr})</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Chapter (optional)</label>
<select
value={chapter}
onChange={(e) => setChapter(e.target.value)}
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 dark:bg-gray-900 dark:text-gray-100"
>
<option value="">All chapters</option>
{chapters.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
</div>
<label className="flex items-center gap-2 cursor-pointer text-sm text-gray-600 dark:text-gray-400">
<input
type="checkbox"
checked={replaceExisting}
onChange={(e) => setReplaceExisting(e.target.checked)}
className="rounded border-gray-300 dark:border-gray-600 text-brand-600"
/>
Replace existing public holidays on those dates
</label>
<div className="flex items-center gap-3">
<button
type="submit"
disabled={mutation.isPending}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{mutation.isPending ? "Creating…" : "Create Public Holidays"}
</button>
{result && (
<span className="text-sm text-emerald-600 dark:text-emerald-400">
Created {result.created} entries{result.holidays ? ` (${result.holidays} holidays × ${result.resources ?? 0} resources)` : ""}
</span>
)}
{mutation.error && (
<span className="text-sm text-red-500">{mutation.error.message}</span>
)}
</div>
</form>
</div>
);
}
@@ -0,0 +1,202 @@
"use client";
import { useState } from "react";
import { VacationStatus } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
const TYPE_COLOR: Record<string, string> = {
ANNUAL: "bg-brand-500",
SICK: "bg-red-400",
PUBLIC_HOLIDAY: "bg-emerald-500",
OTHER: "bg-purple-400",
};
const MONTH_NAMES = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
function isoDate(d: Date | string): string {
const date = typeof d === "string" ? new Date(d) : d;
return date.toISOString().slice(0, 10);
}
export function TeamCalendar() {
const now = new Date();
const [month, setMonth] = useState(now.getMonth());
const [year, setYear] = useState(now.getFullYear());
const [chapter, setChapter] = useState<string>("");
const firstDay = new Date(Date.UTC(year, month, 1));
const daysInMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
const { data: resources } = trpc.resource.list.useQuery(
{ isActive: true, limit: 500, ...(chapter ? { chapter } : {}) },
{ staleTime: 60_000 },
);
const { data: vacations } = trpc.vacation.list.useQuery(
{
startDate: firstDay,
endDate: new Date(Date.UTC(year, month + 1, 0)),
limit: 500,
},
{ staleTime: 15_000 },
);
// Distinct chapters for filter
const chapters = Array.from(
new Set((resources?.resources ?? []).map((r) => r.chapter).filter(Boolean) as string[])
).sort();
const resourceList = resources?.resources ?? [];
const vacationList = (vacations ?? []).filter(
(v) => v.status !== VacationStatus.CANCELLED && v.status !== VacationStatus.REJECTED,
);
// Build map: resourceId → date → vacation
const vacationMap = new Map<string, Map<string, { type: string; status: string }>>();
for (const v of vacationList) {
const start = isoDate(v.startDate);
const end = isoDate(v.endDate);
let cur = start;
while (cur <= end) {
const resourceVacs = vacationMap.get(v.resourceId) ?? new Map();
if (!resourceVacs.has(cur)) {
resourceVacs.set(cur, { type: v.type, status: v.status });
}
vacationMap.set(v.resourceId, resourceVacs);
// next day
const d = new Date(cur);
d.setUTCDate(d.getUTCDate() + 1);
cur = d.toISOString().slice(0, 10);
}
}
const days = Array.from({ length: daysInMonth }, (_, i) => i + 1);
const today = now.toISOString().slice(0, 10);
function prevMonth() {
if (month === 0) { setMonth(11); setYear(y => y - 1); }
else setMonth(m => m - 1);
}
function nextMonth() {
if (month === 11) { setMonth(0); setYear(y => y + 1); }
else setMonth(m => m + 1);
}
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
{/* Toolbar */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-gray-100 dark:border-gray-700 flex-wrap">
<button type="button" onClick={prevMonth} className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-gray-500 dark:text-gray-400">
</button>
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100 min-w-[120px] text-center">
{MONTH_NAMES[month]} {year}
</span>
<button type="button" onClick={nextMonth} className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-gray-500 dark:text-gray-400">
</button>
<div className="flex-1" />
<select
value={chapter}
onChange={(e) => setChapter(e.target.value)}
className="text-sm border border-gray-200 dark:border-gray-600 rounded-lg px-2 py-1 focus:outline-none focus:ring-2 focus:ring-brand-500 dark:bg-gray-900 dark:text-gray-100"
>
<option value="">All chapters</option>
{chapters.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
{/* Grid */}
<div className="overflow-x-auto">
<table className="min-w-full text-xs">
<thead>
<tr className="bg-gray-50 dark:bg-gray-900 border-b border-gray-100 dark:border-gray-700">
<th className="sticky left-0 z-10 bg-gray-50 dark:bg-gray-900 px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 w-36 border-r border-gray-100 dark:border-gray-700">
Resource
</th>
{days.map((d) => {
const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
const dow = new Date(dateStr).getUTCDay(); // 0=Sun, 6=Sat
const isWeekend = dow === 0 || dow === 6;
const isToday = dateStr === today;
return (
<th
key={d}
className={`px-1 py-2 text-center font-medium w-7 ${isWeekend ? "text-gray-300 dark:text-gray-600 bg-gray-50 dark:bg-gray-900" : isToday ? "text-brand-700 bg-brand-50" : "text-gray-500 dark:text-gray-400"}`}
>
{d}
</th>
);
})}
</tr>
</thead>
<tbody className="divide-y divide-gray-50 dark:divide-gray-700">
{resourceList.map((r) => {
const rMap = vacationMap.get(r.id);
return (
<tr key={r.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td className="sticky left-0 z-10 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700/50 px-3 py-1.5 border-r border-gray-100 dark:border-gray-700 font-medium text-gray-800 dark:text-gray-100 truncate max-w-[9rem]">
{r.displayName}
</td>
{days.map((d) => {
const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
const vac = rMap?.get(dateStr);
const dow = new Date(dateStr).getUTCDay();
const isWeekend = dow === 0 || dow === 6;
const isToday = dateStr === today;
let cellClass = "w-7 h-7";
if (vac) {
const color = TYPE_COLOR[vac.type] ?? "bg-gray-400";
const opacity = vac.status === "PENDING" ? "opacity-50" : "";
cellClass += ` ${color} ${opacity}`;
} else if (isWeekend) {
cellClass += " bg-gray-50 dark:bg-gray-900";
} else if (isToday) {
cellClass += " bg-brand-50";
}
return (
<td key={d} className="px-0.5 py-0.5">
<div
className={cellClass + " rounded-sm"}
title={vac ? `${r.displayName}: ${vac.type} (${vac.status})` : undefined}
/>
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
{resourceList.length === 0 && (
<div className="p-8 text-center text-sm text-gray-400">No resources found.</div>
)}
</div>
{/* Legend */}
<div className="px-4 py-2 border-t border-gray-100 dark:border-gray-700 flex gap-4 flex-wrap">
{Object.entries(TYPE_COLOR).map(([type, color]) => (
<span key={type} className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
<span className={`${color} w-3 h-3 rounded-sm inline-block`} />
{type.replace("_", " ")}
</span>
))}
<span className="flex items-center gap-1.5 text-xs text-gray-400 dark:text-gray-500">
<span className="opacity-50 bg-brand-500 w-3 h-3 rounded-sm inline-block" />
Pending
</span>
</div>
</div>
);
}
@@ -0,0 +1,181 @@
"use client";
import { useState } from "react";
import { VacationStatus, VacationType } from "@planarchy/shared";
interface VacationEntry {
id: string;
startDate: Date | string;
endDate: Date | string;
type: string;
status: string;
resource?: { displayName: string; eid: string } | null;
}
interface VacationCalendarProps {
vacations: VacationEntry[];
year?: number;
initialMonth?: number; // 0-indexed
}
const TYPE_COLOR: Record<string, string> = {
ANNUAL: "bg-brand-500",
SICK: "bg-red-400",
PUBLIC_HOLIDAY: "bg-emerald-400",
OTHER: "bg-purple-400",
};
const STATUS_OPACITY: Record<string, string> = {
APPROVED: "opacity-100",
PENDING: "opacity-60",
REJECTED: "opacity-30",
CANCELLED: "opacity-20",
};
const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const MONTH_NAMES = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
];
function isoDate(d: Date | string): string {
const date = typeof d === "string" ? new Date(d) : d;
return date.toISOString().slice(0, 10);
}
function addDays(dateStr: string, n: number): string {
const d = new Date(dateStr);
d.setUTCDate(d.getUTCDate() + n);
return d.toISOString().slice(0, 10);
}
function getDatesInRange(start: Date | string, end: Date | string): Set<string> {
const dates = new Set<string>();
let cur = isoDate(start);
const last = isoDate(end);
while (cur <= last) {
dates.add(cur);
cur = addDays(cur, 1);
}
return dates;
}
export function VacationCalendar({ vacations, year = new Date().getFullYear(), initialMonth = new Date().getMonth() }: VacationCalendarProps) {
const [month, setMonth] = useState(initialMonth);
const [currentYear, setCurrentYear] = useState(year);
function prevMonth() {
if (month === 0) { setMonth(11); setCurrentYear(y => y - 1); }
else setMonth(m => m - 1);
}
function nextMonth() {
if (month === 11) { setMonth(0); setCurrentYear(y => y + 1); }
else setMonth(m => m + 1);
}
// Build a set of date → vacation entries for fast lookup
const dateMap = new Map<string, VacationEntry[]>();
for (const v of vacations) {
if ([VacationStatus.CANCELLED, VacationStatus.REJECTED].includes(v.status as VacationStatus)) continue;
const dates = getDatesInRange(v.startDate, v.endDate);
for (const d of dates) {
const existing = dateMap.get(d) ?? [];
existing.push(v);
dateMap.set(d, existing);
}
}
// Build calendar grid
const firstDay = new Date(Date.UTC(currentYear, month, 1));
const daysInMonth = new Date(Date.UTC(currentYear, month + 1, 0)).getUTCDate();
// ISO weekday: Mon=1, Sun=7 → index 0-6
const startOffset = (firstDay.getUTCDay() + 6) % 7; // Mon first
const cells: (number | null)[] = [
...Array<null>(startOffset).fill(null),
...Array.from({ length: daysInMonth }, (_, i) => i + 1),
];
// Pad to complete last row
while (cells.length % 7 !== 0) cells.push(null);
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 dark:border-gray-700">
<button type="button" onClick={prevMonth} className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-gray-500 dark:text-gray-400">
</button>
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
{MONTH_NAMES[month]} {currentYear}
</h3>
<button type="button" onClick={nextMonth} className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-gray-500 dark:text-gray-400">
</button>
</div>
{/* Day names */}
<div className="grid grid-cols-7 border-b border-gray-100 dark:border-gray-700">
{DAYS.map((d) => (
<div key={d} className="px-2 py-1.5 text-center text-xs font-medium text-gray-400 dark:text-gray-500">
{d}
</div>
))}
</div>
{/* Days grid */}
<div className="grid grid-cols-7">
{cells.map((day, idx) => {
if (!day) {
return <div key={`empty-${idx}`} className="p-1 min-h-[60px] border-b border-r border-gray-50 dark:border-gray-700 bg-gray-50/30 dark:bg-gray-900/30" />;
}
const dateStr = `${currentYear}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
const dayVacations = dateMap.get(dateStr) ?? [];
const today = new Date().toISOString().slice(0, 10);
const isToday = dateStr === today;
return (
<div
key={dateStr}
className={`p-1 min-h-[60px] border-b border-r border-gray-50 dark:border-gray-700 ${isToday ? "bg-brand-50" : ""}`}
>
<span className={`text-xs font-medium block mb-1 ${isToday ? "text-brand-700" : "text-gray-500 dark:text-gray-400"}`}>
{day}
</span>
<div className="space-y-0.5">
{dayVacations.slice(0, 3).map((v) => {
const colorClass = TYPE_COLOR[v.type] ?? "bg-gray-400";
const opacityClass = STATUS_OPACITY[v.status] ?? "opacity-100";
const name = v.resource?.displayName ?? "—";
return (
<div
key={v.id + dateStr}
className={`${colorClass} ${opacityClass} text-white text-xs px-1 rounded truncate`}
title={`${name}${v.type} (${v.status})`}
>
{name.split(" ")[0]}
</div>
);
})}
{dayVacations.length > 3 && (
<div className="text-xs text-gray-400 dark:text-gray-500 pl-1">+{dayVacations.length - 3}</div>
)}
</div>
</div>
);
})}
</div>
{/* Legend */}
<div className="px-4 py-2 border-t border-gray-100 dark:border-gray-700 flex gap-4 flex-wrap">
{Object.entries(TYPE_COLOR).map(([type, color]) => (
<span key={type} className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
<span className={`${color} w-3 h-3 rounded-sm inline-block`} />
{type.replace("_", " ")}
</span>
))}
</div>
</div>
);
}
@@ -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>
);
}
@@ -0,0 +1,332 @@
"use client";
import { useRef, useState } from "react";
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
import { VacationType } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
import { DateInput } from "~/components/ui/DateInput.js";
import { useDebounce } from "~/hooks/useDebounce.js";
const VACATION_TYPES = Object.values(VacationType);
const VACATION_TYPE_LABELS: Record<VacationType, string> = {
ANNUAL: "Annual Leave",
SICK: "Sick Leave",
PUBLIC_HOLIDAY: "Public Holiday",
OTHER: "Other",
};
interface VacationModalProps {
resourceId?: string;
onClose: () => void;
onSuccess: () => void;
}
function toDateInputValue(date: Date | string | null | undefined): string {
if (!date) return "";
const d = typeof date === "string" ? new Date(date) : date;
return d.toISOString().split("T")[0] ?? "";
}
export function VacationModal({ resourceId: initialResourceId, onClose, onSuccess }: VacationModalProps) {
const [resourceId, setResourceId] = useState(initialResourceId ?? "");
const [type, setType] = useState<VacationType>(VacationType.ANNUAL);
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [note, setNote] = useState("");
const [isHalfDay, setIsHalfDay] = useState(false);
const [halfDayPart, setHalfDayPart] = useState<"MORNING" | "AFTERNOON">("MORNING");
const [serverError, setServerError] = useState<string | null>(null);
const panelRef = useRef<HTMLDivElement>(null);
useFocusTrap(panelRef, true);
const debouncedStart = useDebounce(startDate, 400);
const debouncedEnd = useDebounce(endDate, 400);
const { data: resources } = trpc.resource.list.useQuery(
{ isActive: true, limit: 500 },
{ staleTime: 60_000 },
);
// Team overlap: other resources in same chapter off in this period
const { data: teamOverlap } = trpc.vacation.getTeamOverlap.useQuery(
{
resourceId: resourceId,
startDate: debouncedStart ? new Date(debouncedStart) : new Date(),
endDate: debouncedEnd ? new Date(debouncedEnd) : new Date(),
},
{
enabled: !!resourceId && !!debouncedStart && !!debouncedEnd,
staleTime: 10_000,
},
);
// Show existing vacations for this resource in the selected period
const { data: existingVacations } = trpc.vacation.list.useQuery(
{
resourceId: resourceId || undefined,
startDate: startDate ? new Date(startDate) : undefined,
endDate: endDate ? new Date(endDate) : undefined,
},
{ enabled: !!resourceId && !!startDate && !!endDate, staleTime: 10_000 },
);
const utils = trpc.useUtils();
const createMutation = trpc.vacation.create.useMutation({
onSuccess: async () => {
await utils.vacation.list.invalidate();
onSuccess();
},
onError: (err) => setServerError(err.message),
});
const isPending = createMutation.isPending;
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setServerError(null);
if (!resourceId || !startDate || !endDate) {
setServerError("Please fill in all required fields.");
return;
}
const start = new Date(startDate);
const end = new Date(endDate);
if (end < start) {
setServerError("End date must be on or after start date.");
return;
}
createMutation.mutate({
resourceId,
type,
startDate: start,
endDate: end,
note: note || undefined,
isHalfDay,
...(isHalfDay ? { halfDayPart } : {}),
});
}
const inputClass =
"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm dark:bg-gray-900 dark:text-gray-100";
const labelClass = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1";
const resourceList = resources?.resources ?? [];
return (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
ref={panelRef}
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-lg mx-4"
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Request Vacation</h2>
<button
type="button"
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors text-xl leading-none"
aria-label="Close"
>
&times;
</button>
</div>
<form onSubmit={handleSubmit} className="px-6 py-5 space-y-4">
{/* Resource */}
{!initialResourceId && (
<div>
<label htmlFor="vac-resource" className={labelClass}>
Resource <span className="text-red-500">*</span>
</label>
<select
id="vac-resource"
value={resourceId}
onChange={(e) => setResourceId(e.target.value)}
className={inputClass}
required
>
<option value="">Select a resource</option>
{resourceList.map((r) => (
<option key={r.id} value={r.id}>
{r.displayName} ({r.eid})
</option>
))}
</select>
</div>
)}
{/* Type */}
<div>
<label htmlFor="vac-type" className={labelClass}>
Type <span className="text-red-500">*</span>
</label>
<select
id="vac-type"
value={type}
onChange={(e) => setType(e.target.value as VacationType)}
className={inputClass}
>
{VACATION_TYPES.map((t) => (
<option key={t} value={t}>
{VACATION_TYPE_LABELS[t]}
</option>
))}
</select>
</div>
{/* Dates */}
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="vac-start" className={labelClass}>
Start Date <span className="text-red-500">*</span>
</label>
<DateInput
id="vac-start"
value={startDate}
onChange={setStartDate}
className={inputClass}
required
/>
</div>
<div>
<label htmlFor="vac-end" className={labelClass}>
End Date <span className="text-red-500">*</span>
</label>
<DateInput
id="vac-end"
value={endDate}
onChange={setEndDate}
min={startDate}
className={inputClass}
required
/>
</div>
</div>
{/* Half-day toggle */}
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={isHalfDay}
onChange={(e) => setIsHalfDay(e.target.checked)}
className="rounded border-gray-300 dark:border-gray-600 text-brand-600 focus:ring-brand-500"
/>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Half day</span>
</label>
{isHalfDay && (
<div className="flex gap-3">
<label className="flex items-center gap-1.5 cursor-pointer text-sm text-gray-600 dark:text-gray-400">
<input
type="radio"
value="MORNING"
checked={halfDayPart === "MORNING"}
onChange={() => setHalfDayPart("MORNING")}
className="text-brand-600 focus:ring-brand-500"
/>
Morning
</label>
<label className="flex items-center gap-1.5 cursor-pointer text-sm text-gray-600 dark:text-gray-400">
<input
type="radio"
value="AFTERNOON"
checked={halfDayPart === "AFTERNOON"}
onChange={() => setHalfDayPart("AFTERNOON")}
className="text-brand-600 focus:ring-brand-500"
/>
Afternoon
</label>
</div>
)}
</div>
{/* Conflict warning */}
{existingVacations && existingVacations.length > 0 && (
<div className="rounded-lg bg-amber-50 border border-amber-200 px-4 py-3 text-sm text-amber-700">
<strong>Existing vacation in this period:</strong>
<ul className="mt-1 space-y-0.5">
{existingVacations.map((v) => (
<li key={v.id}>
{VACATION_TYPE_LABELS[v.type as VacationType]} {" "}
{new Date(v.startDate).toLocaleDateString("en-GB")} to{" "}
{new Date(v.endDate).toLocaleDateString("en-GB")} ({v.status})
</li>
))}
</ul>
</div>
)}
{/* Team overlap warning */}
{teamOverlap && teamOverlap.length > 0 && (
<div className="rounded-lg bg-blue-50 border border-blue-200 px-4 py-3 text-sm text-blue-700">
<strong>Team members off in this period ({teamOverlap.length}):</strong>
<ul className="mt-1 space-y-0.5">
{teamOverlap.map((v) => {
const r = v.resource as { displayName: string; eid: string } | null;
return (
<li key={v.id}>
{r?.displayName ?? "—"}{" "}
<span className="text-blue-500">({new Date(v.startDate).toLocaleDateString("en-GB")} {new Date(v.endDate).toLocaleDateString("en-GB")})</span>
</li>
);
})}
</ul>
</div>
)}
{/* Note */}
<div>
<label htmlFor="vac-note" className={labelClass}>
Note (optional)
</label>
<textarea
id="vac-note"
value={note}
onChange={(e) => setNote(e.target.value)}
rows={2}
maxLength={500}
className={`${inputClass} resize-none`}
placeholder="Optional reason or description…"
/>
</div>
{/* Server error */}
{serverError && (
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700">
{serverError}
</div>
)}
{/* Footer */}
<div className="flex items-center justify-end gap-3 pt-2">
<button
type="button"
onClick={onClose}
disabled={isPending}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={isPending}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{isPending ? "Submitting…" : "Submit Request"}
</button>
</div>
</form>
</div>
</div>
);
}