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,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>
);
}