Files
Nexus/apps/web/src/components/allocations/FillOpenDemandModal.tsx
T
Hartmut b41c1d2501
CI / Architecture Guardrails (push) Successful in 2m38s
CI / Assistant Split Regression (push) Successful in 3m33s
CI / Typecheck (push) Successful in 3m51s
CI / Lint (push) Successful in 5m2s
CI / E2E Tests (push) Has been cancelled
CI / Fresh-Linux Docker Deploy (push) Has been cancelled
CI / Release Images (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI (#61)
rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI (#61)

Co-authored-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
Co-committed-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
2026-05-21 16:28:40 +02:00

601 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<PlannedResource[]>([]);
// Planning phase state
const [resourceId, setResourceId] = useState("");
const [hoursPerDay, setHoursPerDay] = useState<number>(allocation.hoursPerDay);
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [serverError, setServerError] = useState<string | null>(null);
// Submit phase state
const [submitting, setSubmitting] = useState(false);
const [submitProgress, setSubmitProgress] = useState(0);
const panelRef = useRef<HTMLDivElement>(null);
useFocusTrap(panelRef, true);
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(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 (
<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 && !submitting) 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" && !submitting) 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 flex items-center gap-1">
{phase === "plan" ? "Plan Demand Assignment" : "Confirm Assignments"}
<InfoTooltip content="Fill an open demand by assigning one or more real resources to a placeholder staffing requirement. Each assignment creates a new allocation." />
</h2>
<button
type="button"
onClick={onClose}
disabled={submitting}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-xl leading-none disabled:opacity-30"
>
&times;
</button>
</div>
<div className="px-6 pt-4 pb-2 space-y-3">
{/* Demand summary */}
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3 flex items-start gap-3">
<div
className="w-3 h-3 rounded-full mt-1 flex-shrink-0"
style={{ backgroundColor: roleColor }}
/>
<div className="flex-1">
<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} · {formatDateMedium(allocation.startDate)} {" "}
{formatDateMedium(allocation.endDate)}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{allocation.hoursPerDay}h/day · {totalDemandHours.toLocaleString()}h total
{allocation.budgetCents && allocation.budgetCents > 0
? ` · Budget: ${formatCents(allocation.budgetCents)} EUR`
: ""}
</div>
</div>
</div>
{/* Coverage bar */}
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400 mb-1.5">
<span>Demand coverage</span>
<span>
{Math.round(consumedHours)}h / {totalDemandHours}h (
{totalDemandHours > 0 ? Math.round((consumedHours / totalDemandHours) * 100) : 0}%)
</span>
</div>
<div className="w-full h-2.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden flex">
{planned.map((r, i) => (
<div
key={r.resourceId}
className="h-full transition-all"
style={{
width: `${Math.min(100, (r.availableHours / totalDemandHours) * 100)}%`,
backgroundColor: `hsl(${(i * 60 + 200) % 360}, 60%, 55%)`,
}}
title={`${r.resourceName}: ${Math.round(r.availableHours)}h`}
/>
))}
</div>
{/* Planned resources list */}
{planned.length > 0 && (
<div className="mt-2 space-y-1">
{planned.map((r, i) => (
<div key={r.resourceId} className="flex items-center gap-2 text-xs group">
<div
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: `hsl(${(i * 60 + 200) % 360}, 60%, 55%)` }}
/>
<span className="text-gray-700 dark:text-gray-300 font-medium">
{r.resourceName}
</span>
<span className="text-gray-400">({r.eid})</span>
<span className="text-gray-500">{r.hoursPerDay}h/day</span>
<span className="ml-auto text-gray-500">
{Math.round(r.availableHours)}h · {r.coveragePercent}%
</span>
{phase === "plan" && (
<button
type="button"
onClick={() => removeFromPlan(r.resourceId)}
className="text-red-400 hover:text-red-600 opacity-0 group-hover:opacity-100 transition-opacity"
title="Remove"
>
&times;
</button>
)}
</div>
))}
{remainingHours > 0 && (
<div className="flex items-center gap-2 text-xs">
<div className="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600 flex-shrink-0" />
<span className="text-amber-600 dark:text-amber-400 font-medium">
Remaining: {Math.round(remainingHours)}h
</span>
</div>
)}
</div>
)}
</div>
</div>
{/* ─── PLAN PHASE: add resources ──────────────────────────────── */}
{phase === "plan" && (
<div className="px-6 pb-5 space-y-4">
<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) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Select Resource
</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"
size={Math.min(6, Math.max(3, resourceList.length))}
>
<option value="">Select a resource...</option>
{resourceList.map((r) => {
const alreadyPlanned = isAlreadyPlanned(r.id);
return (
<option key={r.id} value={r.id} disabled={alreadyPlanned}>
{r.displayName} ({r.eid}){alreadyPlanned ? " (planned)" : ""}
</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={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"
/>
</div>
{/* Availability preview */}
{resourceId && avail && (
<div
className={`rounded-lg p-3 border text-sm ${
avail.coveragePercent >= 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"
}`}
>
<div className="font-medium text-gray-900 dark:text-gray-100 mb-1.5">
Availability: {avail.resource.name}
</div>
<div className="grid grid-cols-3 gap-2 text-xs">
<div>
<span className="text-gray-500 dark:text-gray-400">Available</span>
<div className="font-semibold text-green-700 dark:text-green-400">
{avail.availableDays} days
</div>
</div>
<div>
<span className="text-gray-500 dark:text-gray-400">Conflicts</span>
<div className="font-semibold text-red-700 dark:text-red-400">
{avail.conflictDays} days
</div>
</div>
<div>
<span className="text-gray-500 dark:text-gray-400">Hours</span>
<div className="font-semibold text-gray-900 dark:text-gray-100">
{avail.totalAvailableHours}h / {avail.totalRequestedHours}h
</div>
</div>
</div>
{avail.existingAssignments.length > 0 && (
<div className="mt-2 pt-2 border-t border-gray-200 dark:border-gray-700">
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">
Existing bookings:
</div>
{avail.existingAssignments.slice(0, 4).map((a, i) => (
<div key={i} className="text-xs text-gray-600 dark:text-gray-300">
{a.code} · {a.hoursPerDay}h/day · {a.start} {a.end}
</div>
))}
{avail.existingAssignments.length > 4 && (
<div className="text-xs text-gray-400">
+{avail.existingAssignments.length - 4} more
</div>
)}
</div>
)}
</div>
)}
{resourceId && availabilityQuery.isLoading && (
<div className="text-xs text-gray-400 dark:text-gray-500 animate-pulse">
Checking availability...
</div>
)}
{/* Action buttons */}
<div className="flex items-center justify-between gap-3 pt-2">
<button
type="button"
onClick={onClose}
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"
>
Cancel
</button>
<div className="flex items-center gap-2">
<button
type="button"
onClick={addToPlan}
disabled={!resourceId || !avail || isAlreadyPlanned(resourceId)}
className="px-4 py-2 border border-brand-600 text-brand-600 rounded-lg hover:bg-brand-50 dark:hover:bg-brand-900/20 text-sm font-medium disabled:opacity-40 disabled:cursor-not-allowed"
>
+ Queue Assignment
</button>
{planned.length > 0 && (
<button
type="button"
onClick={() => setPhase("confirm")}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium"
>
Review Queued ({planned.length})
</button>
)}
</div>
</div>
</div>
)}
{/* ─── CONFIRM PHASE: review & submit all ────────────────────── */}
{phase === "confirm" && (
<div className="px-6 pb-5 space-y-4">
<div className="rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-900">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">
Resource
</th>
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">
h/day
</th>
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">
Hours
</th>
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">
<span className="inline-flex items-center justify-end gap-0.5">
Est. Cost
<InfoTooltip content="Estimated cost = resource LCR x available hours in the demand period." />
</span>
</th>
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">
<span className="inline-flex items-center justify-end gap-0.5">
Coverage
<InfoTooltip content="Percentage of the demand period this resource can cover, accounting for existing bookings." />
</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{planned.map((r) => (
<tr key={r.resourceId}>
<td className="px-3 py-2 font-medium text-gray-900 dark:text-gray-100">
{r.resourceName}
<span className="ml-1 text-xs text-gray-400 font-mono">{r.eid}</span>
</td>
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">
{r.hoursPerDay}h
</td>
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">
{Math.round(r.availableHours)}h
</td>
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">
{formatCents(r.estimatedCostCents)} EUR
</td>
<td className="px-3 py-2 text-right">
<span
className={`font-medium ${r.coveragePercent >= 100 ? "text-green-600" : r.coveragePercent >= 50 ? "text-amber-600" : "text-red-600"}`}
>
{r.coveragePercent}%
</span>
</td>
</tr>
))}
</tbody>
<tfoot className="bg-gray-50 dark:bg-gray-900">
<tr>
<td className="px-3 py-2 text-xs font-semibold text-gray-700 dark:text-gray-300">
Total
</td>
<td className="px-3 py-2" />
<td className="px-3 py-2 text-right text-xs font-semibold text-gray-700 dark:text-gray-300">
{Math.round(consumedHours)}h / {totalDemandHours}h
</td>
<td className="px-3 py-2 text-right text-xs font-semibold text-gray-700 dark:text-gray-300">
{formatCents(planned.reduce((s, r) => s + r.estimatedCostCents, 0))} EUR
</td>
<td className="px-3 py-2 text-right text-xs font-semibold text-gray-700 dark:text-gray-300">
{totalDemandHours > 0
? Math.round((consumedHours / totalDemandHours) * 100)
: 0}
%
</td>
</tr>
{allocation.budgetCents && allocation.budgetCents > 0 && (
<tr>
<td
colSpan={3}
className="px-3 py-1.5 text-right text-xs text-gray-500 dark:text-gray-400"
>
Role Budget:
</td>
<td className="px-3 py-1.5 text-right text-xs font-semibold text-gray-700 dark:text-gray-300">
{formatCents(allocation.budgetCents)} EUR
</td>
<td className="px-3 py-1.5 text-right text-xs">
{(() => {
const totalCost = planned.reduce((s, r) => s + r.estimatedCostCents, 0);
const remain = allocation.budgetCents! - totalCost;
return (
<span
className={remain < 0 ? "text-red-600 font-medium" : "text-green-600"}
>
{remain < 0
? `${formatCents(Math.abs(remain))} over`
: `${formatCents(remain)} left`}
</span>
);
})()}
</td>
</tr>
)}
</tfoot>
</table>
</div>
{remainingHours > 0 && (
<div className="text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 rounded-lg px-3 py-2 border border-amber-200 dark:border-amber-800">
{Math.round(remainingHours)}h remain uncovered. You can add more resources or assign
partially.
</div>
)}
{submitting && (
<div className="text-sm text-brand-600 dark:text-brand-400 animate-pulse">
Assigning resource {submitProgress}/{planned.length}...
</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-between gap-3 pt-2">
<button
type="button"
onClick={() => setPhase("plan")}
disabled={submitting}
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"
>
Back to Planning
</button>
<button
type="button"
onClick={() => void handleConfirmSubmit()}
disabled={submitting || planned.length === 0}
className="px-5 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-semibold disabled:opacity-50"
>
{submitting
? `Assigning ${submitProgress}/${planned.length}...`
: `Confirm & Assign ${planned.length} Resource${planned.length !== 1 ? "s" : ""}`}
</button>
</div>
</div>
)}
</div>
</div>
);
}