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) Co-authored-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com> Co-committed-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
601 lines
26 KiB
TypeScript
601 lines
26 KiB
TypeScript
"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"
|
||
>
|
||
×
|
||
</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"
|
||
>
|
||
×
|
||
</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>
|
||
);
|
||
}
|