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,186 @@
"use client";
import { useRef, useState } from "react";
import { AllocationStatus } from "@planarchy/shared";
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import { trpc } from "~/lib/trpc/client.js";
interface OpenDemandAllocation {
id: string;
entityId?: string | null;
sourceAllocationId?: string | null;
projectId: string;
roleId: string | null;
role: string | null;
headcount: number;
startDate: Date | string;
endDate: Date | string;
hoursPerDay: number;
roleEntity?: { id: string; name: string; color: string | null } | null;
project?: { id: string; name: string; shortCode: string };
}
interface FillOpenDemandModalProps {
allocation: OpenDemandAllocation;
onClose: () => void;
onSuccess: () => void;
}
function formatDate(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleDateString("en-GB", { year: "numeric", month: "short", day: "numeric" });
}
export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpenDemandModalProps) {
const [resourceId, setResourceId] = useState("");
const [hoursPerDay, setHoursPerDay] = useState<number>(allocation.hoursPerDay);
const [search, setSearch] = useState("");
const [serverError, setServerError] = useState<string | null>(null);
const panelRef = useRef<HTMLDivElement>(null);
useFocusTrap(panelRef, true);
const utils = trpc.useUtils();
const invalidatePlanningViews = async () => {
await utils.allocation.list.invalidate();
await utils.allocation.listView.invalidate();
await utils.timeline.getEntries.invalidate();
await utils.timeline.getEntriesView.invalidate();
await utils.timeline.getProjectContext.invalidate();
await utils.timeline.getBudgetStatus.invalidate();
};
const { data: resources } = trpc.resource.list.useQuery(
{ isActive: true, search: search || undefined, limit: 100 },
{ staleTime: 15_000 },
) as { data: { resources: Array<{ id: string; displayName: string; eid: string }> } | undefined };
const fillOpenDemandMutation = trpc.allocation.fillOpenDemandByAllocation.useMutation({
onSuccess: async () => {
await invalidatePlanningViews();
onSuccess();
},
onError: (err) => setServerError(err.message),
});
const roleName = allocation.roleEntity?.name ?? allocation.role ?? "Unknown Role";
const roleColor = allocation.roleEntity?.color ?? "#6366f1";
const resourceList = resources?.resources ?? [];
const isPending = fillOpenDemandMutation.isPending;
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!resourceId) {
setServerError("Please select a resource.");
return;
}
fillOpenDemandMutation.mutate({
allocationId: getPlanningEntryMutationId(allocation),
resourceId,
hoursPerDay,
status: AllocationStatus.PROPOSED,
});
}
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-md mx-4"
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
>
<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">Assign Open Demand</h2>
<button type="button" onClick={onClose} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-xl leading-none">&times;</button>
</div>
<div className="px-6 pt-4 pb-2">
{/* Demand summary */}
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3 mb-4 flex items-start gap-3">
<div className="w-3 h-3 rounded-full mt-1 flex-shrink-0" style={{ backgroundColor: roleColor }} />
<div>
<div className="font-medium text-gray-900 dark:text-gray-100">{roleName}</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{allocation.project?.name} · {formatDate(allocation.startDate)} {formatDate(allocation.endDate)}
</div>
{allocation.headcount > 1 && (
<div className="text-xs text-amber-600 mt-0.5">
{allocation.headcount} slots remaining assigning one resource
</div>
)}
</div>
</div>
</div>
<form onSubmit={handleSubmit} className="px-6 pb-5 space-y-4">
{/* Resource search */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Search Resource
</label>
<input
type="text"
placeholder="Search by name or EID…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Assign Resource <span className="text-red-500">*</span>
</label>
<select
value={resourceId}
onChange={(e) => setResourceId(e.target.value)}
className="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"
required
size={Math.min(6, Math.max(3, resourceList.length))}
>
<option value="">Select a resource</option>
{resourceList.map((r) => (
<option key={r.id} value={r.id}>
{r.displayName} ({r.eid})
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Hours / Day</label>
<input
type="number"
value={hoursPerDay}
onChange={(e) => setHoursPerDay(Number(e.target.value))}
min={0.5}
max={8}
step={0.5}
className="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"
/>
</div>
{serverError && (
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 px-4 py-3 text-sm text-red-700 dark:text-red-400">
{serverError}
</div>
)}
<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 || !resourceId} className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50">
{isPending ? "Assigning…" : "Create Assignment"}
</button>
</div>
</form>
</div>
</div>
);
}