"use client"; import { useState, useCallback, useEffect, useMemo, useRef } from "react"; import { clsx } from "clsx"; import type { StaffingRequirement } from "@planarchy/shared"; import { BlueprintTarget, OrderType, AllocationType, ProjectStatus, AllocationStatus } from "@planarchy/shared"; import { trpc } from "~/lib/trpc/client.js"; import { uuid } from "~/lib/uuid.js"; import { DateInput } from "~/components/ui/DateInput.js"; import { SkillTagInput } from "~/components/ui/SkillTagInput.js"; import { usePermissions } from "~/hooks/usePermissions.js"; // ─── Constants ──────────────────────────────────────────────────────────────── const ORDER_TYPE_OPTIONS = [ { value: "BD", label: "BD" }, { value: "CHARGEABLE", label: "Chargeable" }, { value: "INTERNAL", label: "Internal" }, { value: "OVERHEAD", label: "Overhead" }, ] as const; const ALLOCATION_TYPE_OPTIONS = [ { value: "INT", label: "INT" }, { value: "EXT", label: "EXT" }, ] as const; const INPUT_CLS = "px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm w-full"; const SELECT_CLS = "px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm w-full bg-white"; const LABEL_CLS = "block text-xs font-medium text-gray-600 mb-1"; const BTN_PRIMARY = "px-5 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50 transition-colors"; const BTN_SECONDARY = "px-5 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 text-sm font-medium transition-colors"; const BTN_DANGER = "px-2 py-1 text-red-500 hover:text-red-700 hover:bg-red-50 rounded text-sm transition-colors"; // ─── Types ──────────────────────────────────────────────────────────────────── interface Assignment { requirementId: string; resourceId: string; resourceName: string; role: string; } interface WizardState { blueprintId: string | null; shortCode: string; name: string; orderType: string; allocationType: string; startDate: string; endDate: string; budgetEur: string; winProbability: number; responsiblePerson: string; staffingReqs: StaffingRequirement[]; assignments: Assignment[]; saveAsDraft: boolean; } function formatDateForInput(date: Date): string { const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, "0"); const d = String(date.getDate()).padStart(2, "0"); return `${y}-${m}-${d}`; } function makeDefaultState(): WizardState { const today = formatDateForInput(new Date()); return { blueprintId: null, shortCode: "", name: "", orderType: "CHARGEABLE", allocationType: "INT", startDate: today, endDate: today, budgetEur: "", winProbability: 100, responsiblePerson: "", staffingReqs: [], assignments: [], saveAsDraft: true, }; } function makeReq(): StaffingRequirement { return { id: uuid(), role: "", requiredSkills: [], preferredSkills: [], hoursPerDay: 8, headcount: 1, }; } // ─── Step indicators ───────────────────────────────────────────────────────── const STEPS = [ "Blueprint & Identity", "Timeline & Budget", "Staffing Demand", "Suggestions", "Review & Create", ]; function StepBar({ current }: { current: number }) { return (
{STEPS.map((label, idx) => (
{idx < current ? "✓" : idx + 1}
{label}
{idx < STEPS.length - 1 && (
)}
))}
); } // ─── Step 1: Blueprint & Identity ──────────────────────────────────────────── interface Step1Props { state: WizardState; onChange: (patch: Partial) => void; } function Step1({ state, onChange }: Step1Props) { const { data: blueprints } = trpc.blueprint.list.useQuery( { target: BlueprintTarget.PROJECT, isActive: true }, { staleTime: 30_000 }, // eslint-disable-next-line @typescript-eslint/no-explicit-any ) as { data: Array<{ id: string; name: string; description?: string | null; rolePresets?: unknown }> | undefined }; const selectedBp = blueprints?.find((b) => b.id === state.blueprintId); function selectBlueprint(id: string | null) { if (!id) { onChange({ blueprintId: null }); return; } const bp = blueprints?.find((b) => b.id === id); const presets = Array.isArray(bp?.rolePresets) ? (bp.rolePresets as unknown as StaffingRequirement[]) : []; onChange({ blueprintId: id, staffingReqs: presets }); } return (
{/* Blueprint picker */}
{(blueprints ?? []).map((bp) => ( ))}
{selectedBp && (

Selected: {selectedBp.name} {Array.isArray(selectedBp.rolePresets) && selectedBp.rolePresets.length > 0 ? ` — ${selectedBp.rolePresets.length} role presets will be loaded in Step 3` : ""}

)}
{/* Short code */}
onChange({ shortCode: e.target.value.toUpperCase() })} placeholder="e.g. BMW26D" className={INPUT_CLS} />

Uppercase alphanumeric, max 20 chars

{/* Name */}
onChange({ name: e.target.value })} placeholder="e.g. BMW X3 Campaign" className={INPUT_CLS} />
{/* Order type */}
{/* Allocation type */}
); } // ─── Responsible Person Picker ──────────────────────────────────────────────── function ResourcePersonPicker({ value, onChange }: { value: string; onChange: (v: string) => void }) { const [query, setQuery] = useState(value); const [open, setOpen] = useState(false); const [debouncedSearch, setDebouncedSearch] = useState(""); const containerRef = useRef(null); // Debounce search query to avoid excessive API calls useEffect(() => { const timer = setTimeout(() => setDebouncedSearch(query), 200); return () => clearTimeout(timer); }, [query]); // Server-side search — no client-side limit, searches full database const { data } = trpc.resource.list.useQuery( { isActive: true, search: debouncedSearch || undefined, limit: 30 }, // eslint-disable-next-line @typescript-eslint/no-explicit-any { staleTime: 15_000, placeholderData: (prev: any) => prev }, ); const filtered = useMemo( () => (data?.resources ?? []) as unknown as Array<{ id: string; displayName: string; eid: string }>, [data], ); // Sync local query when external value changes (e.g. wizard reset) useEffect(() => { setQuery(value); }, [value]); // Close on outside click useEffect(() => { if (!open) return; function handleClick(e: MouseEvent) { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { setOpen(false); } } document.addEventListener("mousedown", handleClick); return () => document.removeEventListener("mousedown", handleClick); }, [open]); return (
{ setQuery(e.target.value); onChange(e.target.value); setOpen(true); }} onFocus={() => setOpen(true)} placeholder="Search by name or EID…" className={INPUT_CLS} /> {open && filtered.length > 0 && (
    e.preventDefault()} > {filtered.map((r) => (
  • ))}
)}
); } // ─── Step 2: Timeline & Budget ──────────────────────────────────────────────── interface Step2Props { state: WizardState; onChange: (patch: Partial) => void; } function Step2({ state, onChange }: Step2Props) { return (
onChange({ startDate: v })} />
onChange({ endDate: v })} />
onChange({ budgetEur: e.target.value })} placeholder="e.g. 50000" className={INPUT_CLS} />
onChange({ responsiblePerson: v })} />
onChange({ winProbability: parseInt(e.target.value, 10) })} className="w-full accent-brand-600" />
0% (Unlikely) 100% (Confirmed)
); } // ─── Step 3: Staffing Demand ───────────────────────────────────────────────── interface Step3Props { state: WizardState; onChange: (patch: Partial) => void; } function Step3({ state, onChange }: Step3Props) { const { data: rolesData } = trpc.role.list.useQuery( { isActive: true }, { staleTime: 30_000 }, ); const roles = rolesData ?? []; function updateReq(idx: number, patch: Partial) { const next = state.staffingReqs.map((r, i) => i === idx ? { ...r, ...patch } : r, ); onChange({ staffingReqs: next }); } function addReq() { onChange({ staffingReqs: [...state.staffingReqs, makeReq()] }); } function removeReq(idx: number) { onChange({ staffingReqs: state.staffingReqs.filter((_, i) => i !== idx) }); } return (
{state.blueprintId && state.staffingReqs.length > 0 && (
Blueprint presets auto-loaded. Edit as needed.
)} {/* Budget allocation summary */} {state.budgetEur && parseFloat(state.budgetEur) > 0 && state.staffingReqs.length > 0 && (() => { const projectBudgetCents = Math.round(parseFloat(state.budgetEur || "0") * 100); const allocatedCents = state.staffingReqs.reduce((sum, r) => sum + (r.budgetCents ?? 0), 0); const remainingCents = projectBudgetCents - allocatedCents; const pct = projectBudgetCents > 0 ? Math.round((allocatedCents / projectBudgetCents) * 100) : 0; return (
Budget Allocation {pct}% allocated
Project: {(projectBudgetCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR Allocated: {(allocatedCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR Remaining: {(remainingCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR
); })()}
{state.staffingReqs.length === 0 && (

No requirements yet. Click “+ Add Role” to define staffing needs.

)} {state.staffingReqs.map((req, idx) => (
{roles.length > 0 ? ( ) : null} {(!req.roleId) && ( updateReq(idx, { role: e.target.value })} placeholder="e.g. 3D Artist" className={clsx(INPUT_CLS, roles.length > 0 && "mt-1")} /> )}
updateReq(idx, { hoursPerDay: parseFloat(e.target.value) || 0 })} className={INPUT_CLS} />
updateReq(idx, { headcount: parseInt(e.target.value, 10) || 1 })} className={INPUT_CLS} />
{ const val = parseFloat(e.target.value); updateReq(idx, { budgetCents: Number.isFinite(val) && val > 0 ? Math.round(val * 100) : 0 } as Partial); }} placeholder="0" className={INPUT_CLS} />
updateReq(idx, { requiredSkills: skills })} placeholder="Add required skill…" />
updateReq(idx, { preferredSkills: skills })} placeholder="Add preferred skill…" />
updateReq(idx, { chapter: e.target.value || undefined } as Partial) } placeholder="e.g. Art Direction" className={INPUT_CLS} />
))}
); } // ─── Step 4: Suggestions ───────────────────────────────────────────────────── // Matches StaffingSuggestion from @planarchy/shared (returned by staffing.getSuggestions) type SuggestionItem = { resourceId: string; resourceName: string; eid: string; score: number; currentUtilization: number; availabilityConflicts: string[]; estimatedDailyCostCents: number; valueScore?: number; }; interface ReqSuggestionsProps { req: StaffingRequirement; startDate: string; endDate: string; assignments: Assignment[]; onAssign: (resourceId: string, resourceName: string, role: string) => void; onUnassign: (resourceId: string) => void; } function ReqSuggestions({ req, startDate, endDate, assignments, onAssign, onUnassign, }: ReqSuggestionsProps) { const { canViewScores } = usePermissions(); const { data, isLoading } = trpc.staffing.getSuggestions.useQuery( { requiredSkills: req.requiredSkills, preferredSkills: req.preferredSkills, startDate: new Date(startDate), endDate: new Date(endDate), hoursPerDay: req.hoursPerDay, ...(req.chapter ? { chapter: req.chapter } : {}), }, { enabled: req.requiredSkills.length > 0 && !!startDate && !!endDate, staleTime: 30_000, }, ); const reqAssignments = assignments.filter((a) => a.requirementId === req.id); const assignedCount = reqAssignments.length; if (!req.requiredSkills.length) { return (

Add required skills in Step 3 to see suggestions.

); } if (isLoading) { return

Loading suggestions…

; } const suggestions = (data ?? []) as unknown as SuggestionItem[]; function availBadge(item: SuggestionItem) { if (item.availabilityConflicts.length === 0) { return ( Available ); } if (item.currentUtilization >= 100) { return ( Unavailable ); } return ( Partial ); } return (

Assigned: {assignedCount} / {req.headcount} needed

{suggestions.length === 0 && (

No matching resources found.

)} {suggestions.slice(0, 10).map((item) => { const isAssigned = reqAssignments.some((a) => a.resourceId === item.resourceId); return (
{item.resourceName}
{item.eid}
Score {item.score} {canViewScores && item.valueScore != null && ( = 70 ? "bg-green-100 text-green-700" : item.valueScore >= 40 ? "bg-amber-100 text-amber-700" : "bg-red-100 text-red-700" }`} title="Value Score (price/quality)" > ★ {item.valueScore} )} {Math.round(item.currentUtilization)}% {availBadge(item)}
{isAssigned ? ( ) : ( )}
); })}
); } interface Step4Props { state: WizardState; onChange: (patch: Partial) => void; } function Step4({ state, onChange }: Step4Props) { function assign(requirementId: string, resourceId: string, resourceName: string, role: string) { onChange({ assignments: [ ...state.assignments, { requirementId, resourceId, resourceName, role }, ], }); } function unassign(requirementId: string, resourceId: string) { onChange({ assignments: state.assignments.filter( (a) => !(a.requirementId === requirementId && a.resourceId === resourceId), ), }); } if (state.staffingReqs.length === 0) { return (

No staffing requirements defined. Go back to Step 3 to add some.

); } return (
{state.staffingReqs.map((req) => (
{req.role || "Unnamed Role"} {req.hoursPerDay}h/day · {req.headcount} needed
assign(req.id, resourceId, resourceName, role) } onUnassign={(resourceId) => unassign(req.id, resourceId)} />
))}
); } // ─── Step 5: Review & Create ───────────────────────────────────────────────── interface Step5Props { state: WizardState; onChange: (patch: Partial) => void; onSubmit: () => void; isSubmitting: boolean; submitError: string | null; } function Step5({ state, onChange, onSubmit, isSubmitting, submitError }: Step5Props) { return (
{/* Project summary */}
Code:{" "} {state.shortCode || "—"}
Name:{" "} {state.name || "—"}
Type: {state.orderType} / {state.allocationType}
Budget:{" "} {state.budgetEur ? `€${parseFloat(state.budgetEur).toLocaleString()}` : "—"}
Dates:{" "} {state.startDate} → {state.endDate}
Win %: {state.winProbability}%
{state.responsiblePerson && (
Responsible: {state.responsiblePerson}
)}
{/* Staffing summary */} {state.staffingReqs.length > 0 && (

Staffing Requirements

{state.staffingReqs.map((req) => { const assigned = state.assignments.filter((a) => a.requirementId === req.id); return (
{req.role || "Unnamed"} {req.hoursPerDay}h/day = req.headcount ? "text-green-600" : "text-amber-600", )} > {assigned.length}/{req.headcount} assigned
); })}
)} {/* Draft toggle */}

Save as

{submitError && (
{submitError}
)}
); } // ─── Wizard shell ───────────────────────────────────────────────────────────── interface ProjectWizardProps { open: boolean; onClose: () => void; } export function ProjectWizard({ open, onClose }: ProjectWizardProps) { const utils = trpc.useUtils(); const [step, setStep] = useState(0); const [state, setState] = useState(makeDefaultState); const [isSubmitting, setIsSubmitting] = useState(false); const [submitError, setSubmitError] = useState(null); const createProject = trpc.project.create.useMutation(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const createAssignment = (trpc.allocation.createAssignment.useMutation as any)() as { mutateAsync: (input: unknown) => Promise; }; // eslint-disable-next-line @typescript-eslint/no-explicit-any const createDemandRequirement = (trpc.allocation.createDemandRequirement.useMutation as any)() as { mutateAsync: (input: unknown) => Promise; }; const patch = useCallback((p: Partial) => { setState((prev) => ({ ...prev, ...p })); }, []); function reset() { setStep(0); setState(makeDefaultState()); setIsSubmitting(false); setSubmitError(null); } function handleClose() { reset(); onClose(); } function canGoNext(): boolean { if (step === 0) { return ( state.shortCode.trim().length > 0 && /^[A-Z0-9_-]+$/.test(state.shortCode) && state.name.trim().length > 0 ); } if (step === 1) { return ( !!state.startDate && !!state.endDate && state.endDate >= state.startDate && (state.budgetEur === "" || parseFloat(state.budgetEur) >= 0) ); } return true; } async function handleSubmit() { setIsSubmitting(true); setSubmitError(null); try { const project = await createProject.mutateAsync({ shortCode: state.shortCode.trim(), name: state.name.trim(), orderType: state.orderType as unknown as OrderType, allocationType: state.allocationType as unknown as AllocationType, winProbability: state.winProbability, budgetCents: state.budgetEur ? Math.round(parseFloat(state.budgetEur) * 100) : 0, startDate: new Date(state.startDate), endDate: new Date(state.endDate), staffingReqs: state.staffingReqs, status: state.saveAsDraft ? ProjectStatus.DRAFT : ProjectStatus.ACTIVE, responsiblePerson: state.responsiblePerson.trim() || undefined, blueprintId: state.blueprintId ?? undefined, dynamicFields: {}, }); // Create draft assignments for assigned resources for (const assignment of state.assignments) { try { const req = state.staffingReqs.find((r) => r.id === assignment.requirementId); const hoursPerDay = req?.hoursPerDay ?? 8; const percentage = Math.min(100, Math.round((hoursPerDay / 8) * 100)); await createAssignment.mutateAsync({ projectId: project.id, resourceId: assignment.resourceId, startDate: new Date(state.startDate), endDate: new Date(state.endDate), hoursPerDay, percentage, role: assignment.role, roleId: req?.roleId, status: AllocationStatus.PROPOSED, metadata: {}, }); } catch { // Non-fatal — skip duplicate allocations } } // Create open demand for unassigned slots for (const req of state.staffingReqs) { const assignedCount = state.assignments.filter((a) => a.requirementId === req.id).length; const unassigned = req.headcount - assignedCount; if (unassigned <= 0) continue; try { await createDemandRequirement.mutateAsync({ projectId: project.id, startDate: new Date(state.startDate), endDate: new Date(state.endDate), hoursPerDay: req.hoursPerDay, percentage: Math.min(100, Math.round((req.hoursPerDay / 8) * 100)), role: req.role || undefined, roleId: req.roleId, headcount: unassigned, status: AllocationStatus.PROPOSED, metadata: {}, }); } catch { // Non-fatal } } await utils.project.list.invalidate(); await utils.timeline.getEntries.invalidate(); await utils.timeline.getEntriesView.invalidate(); handleClose(); } catch (err) { setSubmitError(err instanceof Error ? err.message : "Failed to create project"); } finally { setIsSubmitting(false); } } if (!open) return null; function handleBackdropClick(e: React.MouseEvent) { if (e.target === e.currentTarget) handleClose(); } return (
{/* Header */}

New Project Wizard

{/* Body */}
{step === 0 && } {step === 1 && } {step === 2 && } {step === 3 && } {step === 4 && ( )}
{/* Footer nav */} {step < 4 && (
)} {step === 4 && (
)}
); }