"use client";
import { createPortal } from "react-dom";
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { clsx } from "clsx";
import type { StaffingRequirement } from "@capakraken/shared";
import { BlueprintTarget, OrderType, AllocationType, ProjectStatus, AllocationStatus } from "@capakraken/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";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { formatCents } from "~/lib/format.js";
import { ConfettiBurst } from "~/components/ui/ConfettiBurst.js";
import { SuccessToast } from "~/components/ui/SuccessToast.js";
import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.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 */}
{/* 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("");
// Tracks whether the current value was explicitly chosen from the dropdown
// vs typed as free text. Cleared on any manual keypress.
const [isConfirmed, setIsConfirmed] = useState(false);
const inputRef = 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.directory.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);
// If the external value is cleared (reset), also clear the confirmed state
if (!value) setIsConfirmed(false);
}, [value]);
const { panelRef, position } = useAnchoredOverlay({
open,
onClose: () => setOpen(false),
align: "start",
matchTriggerWidth: true,
triggerRef: inputRef,
});
return (
{
setQuery(e.target.value);
onChange(e.target.value);
setIsConfirmed(false); // User is typing — selection no longer confirmed
setOpen(true);
}}
onFocus={() => setOpen(true)}
placeholder="Search by name or EID…"
className={`${INPUT_CLS} ${isConfirmed ? "ring-2 ring-green-400 ring-offset-0" : ""}`}
/>
{isConfirmed && (
)}
{open && filtered.length > 0 && typeof document !== "undefined"
? createPortal(
e.preventDefault()}
>
{filtered.map((r) => (
-
))}
,
document.body,
)
: null}
);
}
// ─── Step 2: Timeline & Budget ────────────────────────────────────────────────
interface Step2Props {
state: WizardState;
onChange: (patch: Partial) => void;
}
function Step2({ state, onChange }: Step2Props) {
return (
onChange({ startDate: v })}
/>
onChange({ endDate: v })}
/>
);
}
// ─── 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: {formatCents(projectBudgetCents)} EUR
Allocated: {formatCents(allocatedCents)} EUR
Remaining: {formatCents(remainingCents)} 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 @capakraken/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 (
AI-powered resource suggestions based on skills, availability, and utilization.
{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 */}
{submitError && (
{submitError}
)}
);
}
// ─── Wizard shell ─────────────────────────────────────────────────────────────
interface ProjectWizardProps {
open: boolean;
onClose: () => void;
onSuccess?: (shortCode: string, name: string) => void;
}
export function ProjectWizard({ open, onClose, onSuccess }: 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 [showConfetti, setShowConfetti] = useState(false);
const [showSuccessToast, setShowSuccessToast] = useState(false);
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(),
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.listWithCosts.invalidate();
await utils.timeline.getEntries.invalidate();
await utils.timeline.getEntriesView.invalidate();
setShowConfetti(true);
setShowSuccessToast(true);
setTimeout(() => {
setShowConfetti(false);
onSuccess?.(project.shortCode, project.name);
handleClose();
}, 1200);
} catch (err) {
let errorMessage = "Failed to create project";
if (err instanceof Error) {
try {
const parsed: unknown = JSON.parse(err.message);
if (Array.isArray(parsed) && parsed.length > 0) {
errorMessage = (parsed as { message?: string }[])
.map((e) => e.message)
.filter(Boolean)
.join("; ");
} else {
errorMessage = err.message;
}
} catch {
errorMessage = err.message;
}
}
setSubmitError(errorMessage);
} finally {
setIsSubmitting(false);
}
}
if (!open) return null;
function handleBackdropClick(e: React.MouseEvent) {
if (e.target === e.currentTarget) handleClose();
}
return (
{/* Celebration effects */}
setShowSuccessToast(false)}
/>
{/* Header */}
New Project Wizard
{/* Body */}
{step === 0 && }
{step === 1 && }
{step === 2 && }
{step === 3 && }
{step === 4 && (
)}
{/* Footer nav */}
{step < 4 && (
)}
{step === 4 && (
)}
);
}