"use client"; import { useRef, useState, useMemo } from "react"; import { AllocationStatus } from "@nexus/shared"; import { useFocusTrap } from "~/hooks/useFocusTrap.js"; import { formatCents, formatDateMedium } from "~/lib/format.js"; import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js"; import { trpc } from "~/lib/trpc/client.js"; import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.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; budgetCents?: 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; } /** A planned (not yet submitted) resource assignment. */ interface PlannedResource { resourceId: string; resourceName: string; eid: string; hoursPerDay: number; availableHours: number; availableDays: number; conflictDays: number; coveragePercent: number; estimatedCostCents: number; } export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpenDemandModalProps) { // ─── Phase: "plan" (select resources) → "confirm" (review & submit) ── const [phase, setPhase] = useState<"plan" | "confirm">("plan"); const [planned, setPlanned] = useState([]); // Planning phase state const [resourceId, setResourceId] = useState(""); const [hoursPerDay, setHoursPerDay] = useState(allocation.hoursPerDay); const [search, setSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState(""); const [serverError, setServerError] = useState(null); // Submit phase state const [submitting, setSubmitting] = useState(false); const [submitProgress, setSubmitProgress] = useState(0); const panelRef = useRef(null); useFocusTrap(panelRef, true); const searchTimerRef = useRef>(undefined); function handleSearchChange(value: string) { setSearch(value); clearTimeout(searchTimerRef.current); searchTimerRef.current = setTimeout(() => setDebouncedSearch(value), 200); } const invalidatePlanningViews = useInvalidatePlanningViews(); const { data: resources } = trpc.resource.listStaff.useQuery( { isActive: true, search: debouncedSearch || undefined, limit: 50 }, { staleTime: 15_000 }, ) as { data: | { resources: Array<{ id: string; displayName: string; eid: string; lcrCents: number }> } | undefined; }; const availabilityQuery = trpc.allocation.checkResourceAvailability.useQuery( { resourceId, startDate: new Date(allocation.startDate), endDate: new Date(allocation.endDate), hoursPerDay, }, { enabled: !!resourceId && phase === "plan", staleTime: 10_000 }, ); const fillMutation = trpc.allocation.fillOpenDemandByAllocation.useMutation(); const roleName = allocation.roleEntity?.name ?? allocation.role ?? "Unknown Role"; const roleColor = allocation.roleEntity?.color ?? "#6366f1"; const resourceList = resources?.resources ?? []; const avail = availabilityQuery.data; const totalDemandHours = useMemo(() => { let workingDays = 0; const d = new Date(allocation.startDate); const end = new Date(allocation.endDate); while (d <= end) { if (d.getDay() !== 0 && d.getDay() !== 6) workingDays++; d.setDate(d.getDate() + 1); } return workingDays * allocation.hoursPerDay; }, [allocation.startDate, allocation.endDate, allocation.hoursPerDay]); const consumedHours = planned.reduce((sum, r) => sum + r.availableHours, 0); const remainingHours = Math.max(0, totalDemandHours - consumedHours); // ─── Planning phase actions ────────────────────────────────────────── function addToPlan() { if (!resourceId || !avail) return; const selectedResource = resourceList.find((r) => r.id === resourceId); if (!selectedResource) return; // Estimated cost = LCR * available hours const lcrCents = selectedResource.lcrCents ?? 0; const estimatedCostCents = Math.round(lcrCents * avail.totalAvailableHours); setPlanned((prev) => [ ...prev, { resourceId: selectedResource.id, resourceName: selectedResource.displayName, eid: selectedResource.eid, hoursPerDay, availableHours: avail.totalAvailableHours, availableDays: avail.availableDays, conflictDays: avail.conflictDays, coveragePercent: avail.coveragePercent, estimatedCostCents, }, ]); // Reset for next resource setResourceId(""); setSearch(""); setDebouncedSearch(""); setHoursPerDay(allocation.hoursPerDay); } function removeFromPlan(rid: string) { setPlanned((prev) => prev.filter((r) => r.resourceId !== rid)); } // ─── Confirm phase: submit all planned resources sequentially ──────── async function handleConfirmSubmit() { setSubmitting(true); setServerError(null); setSubmitProgress(0); const allocationId = getPlanningEntryMutationId(allocation); for (let i = 0; i < planned.length; i++) { const p = planned[i]!; setSubmitProgress(i + 1); try { await fillMutation.mutateAsync({ allocationId, resourceId: p.resourceId, hoursPerDay: p.hoursPerDay, status: AllocationStatus.PROPOSED, }); } catch (err) { setServerError( `Failed to assign ${p.resourceName}: ${err instanceof Error ? err.message : String(err)}`, ); setSubmitting(false); return; } } await invalidatePlanningViews(); setSubmitting(false); onSuccess(); } const isAlreadyPlanned = (rid: string) => planned.some((p) => p.resourceId === rid); // ─── Render ────────────────────────────────────────────────────────── return (
{ if (e.target === e.currentTarget && !submitting) onClose(); }} >
{ if (e.key === "Escape" && !submitting) onClose(); }} > {/* Header */}

{phase === "plan" ? "Plan Demand Assignment" : "Confirm Assignments"}

{/* Demand summary */}
{roleName}
{allocation.project?.name} · {formatDateMedium(allocation.startDate)} –{" "} {formatDateMedium(allocation.endDate)}
{allocation.hoursPerDay}h/day · {totalDemandHours.toLocaleString()}h total {allocation.budgetCents && allocation.budgetCents > 0 ? ` · Budget: ${formatCents(allocation.budgetCents)} EUR` : ""}
{/* Coverage bar */}
Demand coverage {Math.round(consumedHours)}h / {totalDemandHours}h ( {totalDemandHours > 0 ? Math.round((consumedHours / totalDemandHours) * 100) : 0}%)
{planned.map((r, i) => (
))}
{/* Planned resources list */} {planned.length > 0 && (
{planned.map((r, i) => (
{r.resourceName} ({r.eid}) {r.hoursPerDay}h/day {Math.round(r.availableHours)}h · {r.coveragePercent}% {phase === "plan" && ( )}
))} {remainingHours > 0 && (
Remaining: {Math.round(remainingHours)}h
)}
)}
{/* ─── PLAN PHASE: add resources ──────────────────────────────── */} {phase === "plan" && (
handleSearchChange(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" />
setHoursPerDay(Number(e.target.value))} min={0.5} max={24} 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" />
{/* Availability preview */} {resourceId && avail && (
= 100 ? "bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800" : avail.coveragePercent >= 50 ? "bg-amber-50 border-amber-200 dark:bg-amber-900/20 dark:border-amber-800" : "bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800" }`} >
Availability: {avail.resource.name}
Available
{avail.availableDays} days
Conflicts
{avail.conflictDays} days
Hours
{avail.totalAvailableHours}h / {avail.totalRequestedHours}h
{avail.existingAssignments.length > 0 && (
Existing bookings:
{avail.existingAssignments.slice(0, 4).map((a, i) => (
{a.code} · {a.hoursPerDay}h/day · {a.start} – {a.end}
))} {avail.existingAssignments.length > 4 && (
+{avail.existingAssignments.length - 4} more
)}
)}
)} {resourceId && availabilityQuery.isLoading && (
Checking availability...
)} {/* Action buttons */}
{planned.length > 0 && ( )}
)} {/* ─── CONFIRM PHASE: review & submit all ────────────────────── */} {phase === "confirm" && (
{planned.map((r) => ( ))} {allocation.budgetCents && allocation.budgetCents > 0 && ( )}
Resource h/day Hours Est. Cost Coverage
{r.resourceName} {r.eid} {r.hoursPerDay}h {Math.round(r.availableHours)}h {formatCents(r.estimatedCostCents)} EUR = 100 ? "text-green-600" : r.coveragePercent >= 50 ? "text-amber-600" : "text-red-600"}`} > {r.coveragePercent}%
Total {Math.round(consumedHours)}h / {totalDemandHours}h {formatCents(planned.reduce((s, r) => s + r.estimatedCostCents, 0))} EUR {totalDemandHours > 0 ? Math.round((consumedHours / totalDemandHours) * 100) : 0} %
Role Budget: {formatCents(allocation.budgetCents)} EUR {(() => { const totalCost = planned.reduce((s, r) => s + r.estimatedCostCents, 0); const remain = allocation.budgetCents! - totalCost; return ( {remain < 0 ? `${formatCents(Math.abs(remain))} over` : `${formatCents(remain)} left`} ); })()}
{remainingHours > 0 && (
{Math.round(remainingHours)}h remain uncovered. You can add more resources or assign partially.
)} {submitting && (
Assigning resource {submitProgress}/{planned.length}...
)} {serverError && (
{serverError}
)}
)}
); }