"use client"; import { clsx } from "clsx"; import { useEffect, useState } from "react"; import { AllocationStatus, type StaffingRequirement } from "@capakraken/shared"; import { trpc } from "~/lib/trpc/client.js"; import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js"; import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js"; import { DateInput } from "~/components/ui/DateInput.js"; interface ProjectPanelProps { projectId: string; onClose: () => void; } interface DemandSummary { id: string; role: string; hoursPerDay: number; requestedHeadcount: number; } interface ProjectPanelAssignment { id: string; entityId?: string; sourceAllocationId?: string; resourceId: string; role: string | null; startDate: Date | string; endDate: Date | string; hoursPerDay: number; metadata: { includeSaturday?: boolean } | null; resource?: { displayName: string; eid: string; } | null; } interface ProjectPanelDemand { id: string; entityId?: string; sourceAllocationId?: string; role: string | null; hoursPerDay: number; requestedHeadcount: number; roleEntity?: { name: string; } | null; } interface ProjectPanelProject { name: string; orderType?: string; status?: string; startDate: Date | string; endDate: Date | string; budgetCents: number; staffingReqs?: unknown; } interface ProjectPanelContext { project: ProjectPanelProject; assignments?: ProjectPanelAssignment[]; demands?: ProjectPanelDemand[]; } interface ProjectPanelResource { id: string; displayName: string; eid: string; chapter?: string | null; } const STATUS_COLORS = { green: "bg-green-500", amber: "bg-amber-400", red: "bg-red-500", }; function toDateInput(d: Date | string): string { const dt = new Date(d); const y = dt.getFullYear(); const m = String(dt.getMonth() + 1).padStart(2, "0"); const day = String(dt.getDate()).padStart(2, "0"); return `${y}-${m}-${day}`; } function normalizeRole(value: string | null | undefined): string { return (value ?? "").trim().toLowerCase(); } export function ProjectPanel({ projectId, onClose }: ProjectPanelProps) { const invalidateTimeline = useInvalidateTimeline(); const { data: ctx, isLoading } = trpc.timeline.getProjectContext.useQuery( { projectId }, { staleTime: 5_000 }, ); const { data: budgetStatus } = trpc.timeline.getBudgetStatus.useQuery( { projectId }, { staleTime: 5_000 }, ); const updateMutation = trpc.timeline.updateAllocationInline.useMutation({ onSuccess: invalidateTimeline, }); const deleteMutation = trpc.allocation.deleteAssignment.useMutation({ onSuccess: invalidateTimeline, }); const createAssignmentMutation = trpc.allocation.createAssignment.useMutation({ onSuccess: () => { invalidateTimeline(); setAddingMember(false); setResourceSearch(""); }, }); const [addingMember, setAddingMember] = useState(false); const [resourceSearch, setResourceSearch] = useState(""); const [pendingEdits, setPendingEdits] = useState< Record >({}); const [confirmDelete, setConfirmDelete] = useState(null); const { data: allResources } = trpc.resource.directory.useQuery( { search: resourceSearch }, { enabled: addingMember, staleTime: 10_000 }, ); if (isLoading || !ctx) { return (
Loading…
); } const { project, assignments = [], demands = [] } = ctx as unknown as ProjectPanelContext; const staffingReqs = (project.staffingReqs as unknown as StaffingRequirement[]) ?? []; const effectiveAssignments = assignments as unknown as ProjectPanelAssignment[]; const projectDemands = demands as unknown as ProjectPanelDemand[]; const effectiveDemands: DemandSummary[] = projectDemands.length > 0 ? projectDemands.map((demand) => ({ id: demand.id, role: demand.roleEntity?.name ?? demand.role ?? "Unassigned", hoursPerDay: demand.hoursPerDay, requestedHeadcount: demand.requestedHeadcount, })) : staffingReqs.map((req, index) => ({ id: `staffing-${index}`, role: req.role, hoursPerDay: req.hoursPerDay, requestedHeadcount: req.headcount, })); // Demand vs supply matching const reqMatches = effectiveDemands.map((demand) => { const demandRole = normalizeRole(demand.role); const matched = effectiveAssignments.filter((assignment) => normalizeRole(assignment.role).includes(demandRole), ); const totalHeadcount = matched.length; const fulfilled = totalHeadcount >= demand.requestedHeadcount; const partial = !fulfilled && totalHeadcount > 0; return { demand, matched, fulfilled, partial }; }); const unmatchedAssignments = effectiveAssignments.filter( (assignment) => !effectiveDemands.some((demand) => normalizeRole(assignment.role).includes(normalizeRole(demand.role)), ), ); // Budget bar const budgetEUR = (project.budgetCents / 100).toFixed(0); const allocatedEUR = budgetStatus ? (budgetStatus.allocatedCents / 100).toFixed(0) : "—"; const utilPct = budgetStatus?.utilizationPercent ?? 0; const budgetBarColor = utilPct >= 100 ? STATUS_COLORS.red : utilPct >= 85 ? STATUS_COLORS.amber : STATUS_COLORS.green; function getEdit(id: string) { return pendingEdits[id] ?? {}; } function setEdit(id: string, patch: typeof pendingEdits[string]) { setPendingEdits((prev) => ({ ...prev, [id]: { ...(prev[id] ?? {}), ...patch } })); } function saveEdit(allocId: string) { const edit = getEdit(allocId); const alloc = effectiveAssignments.find((a) => a.id === allocId); if (!alloc) return; updateMutation.mutate({ allocationId: getPlanningEntryMutationId(alloc), hoursPerDay: edit.hoursPerDay, startDate: edit.startDate ? new Date(edit.startDate) : undefined, endDate: edit.endDate ? new Date(edit.endDate) : undefined, includeSaturday: edit.includeSaturday, role: edit.role, }); setPendingEdits((prev) => { const next = { ...prev }; delete next[allocId]; return next; }); } function handleAddMember(resourceId: string) { createAssignmentMutation.mutate({ resourceId, projectId, startDate: new Date(project.startDate), endDate: new Date(project.endDate), hoursPerDay: 8, percentage: 100, role: "Team Member", status: AllocationStatus.PROPOSED, metadata: {}, }); } const availableResources = (allResources?.resources ?? []) as unknown as ProjectPanelResource[]; const filteredResources = availableResources.filter( (r) => !effectiveAssignments.some((a) => a.resourceId === r.id), ); return ( {/* Header */}

{project.name}

{project.orderType} {project.status}
{toDateInput(project.startDate)} → {toDateInput(project.endDate)}
{/* Budget section */}

Budget

Allocated €{allocatedEUR} / €{budgetEUR}
{utilPct.toFixed(1)}% utilized {budgetStatus && ( €{(budgetStatus.remainingCents / 100).toFixed(0)} remaining )}
{budgetStatus?.warnings.map((w, i) => (
{w.message}
))}
{/* Demand vs Supply */} {effectiveDemands.length > 0 && (

Demand vs Supply

{reqMatches.map(({ demand, matched, fulfilled, partial }) => (
{demand.role} {demand.requestedHeadcount} needed · {demand.hoursPerDay}h/day
{fulfilled ? "✓ Filled" : partial ? `${matched.length}/${demand.requestedHeadcount}` : "Unfilled"}
{matched.length > 0 && (
{matched.map((a) => (
{a.resource?.displayName} {a.hoursPerDay}h/day
))}
)}
))}
{unmatchedAssignments.length > 0 && (
Unmatched assignments
{unmatchedAssignments.map((a) => (
{a.resource?.displayName} — {a.role} {a.hoursPerDay}h/day
))}
)}
)} {/* Team */}

Team

{/* Resource search for adding */} {addingMember && (
setResourceSearch(e.target.value)} className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400" /> {filteredResources.slice(0, 8).map((r) => ( ))}
)}
{effectiveAssignments.map((alloc) => { const edit = getEdit(alloc.id); const isDirty = Object.keys(edit).length > 0; const meta = alloc.metadata as { includeSaturday?: boolean } | null; const inclSat = edit.includeSaturday ?? meta?.includeSaturday ?? false; return (
{/* Resource name + delete */}
{alloc.resource?.displayName} {alloc.resource?.eid}
{confirmDelete === alloc.id ? (
Remove?
) : ( )}
{/* Role */} setEdit(alloc.id, { role: e.target.value })} className="w-full border border-gray-200 rounded-lg px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400" placeholder="Role" /> {/* Dates + hours */}
setEdit(alloc.id, { startDate: v })} className="w-full border border-gray-200 rounded-lg px-2 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400" />
setEdit(alloc.id, { endDate: v })} className="w-full border border-gray-200 rounded-lg px-2 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400" />
setEdit(alloc.id, { hoursPerDay: parseFloat(e.target.value) })} className="w-full border border-gray-200 rounded-lg px-2 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400" />
{/* Saturday toggle */} {/* Save button */} {isDirty && ( )}
); })} {effectiveAssignments.length === 0 && (
No team members yet. Add one above.
)}
); } function PanelShell({ children, onClose }: { children: React.ReactNode; onClose: () => void }) { useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); onClose(); } }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, [onClose]); return (
Project Details
{children}
); }