import { useState, useCallback } from "react"; import type { OrderType, AllocationType } from "@capakraken/shared"; import { ProjectStatus, AllocationStatus } from "@capakraken/shared"; import { trpc } from "~/lib/trpc/client.js"; import { makeDefaultState, type WizardState } from "./types.js"; // ─── Standalone pure validation function ───────────────────────────────────── export function canGoNext(step: number, state: WizardState): boolean { if (step === 0) { const missingRequired = state.blueprintFieldDefs.some((f) => { if (!f.required) return false; const val = state.dynamicFields[f.key]; return ( val === undefined || val === null || val === "" || (Array.isArray(val) && val.length === 0) ); }); return ( state.shortCode.trim().length > 0 && /^[A-Z0-9_-]+$/.test(state.shortCode) && state.name.trim().length > 0 && !missingRequired ); } if (step === 1) { return ( !!state.startDate && !!state.endDate && state.endDate >= state.startDate && (state.budgetEur === "" || parseFloat(state.budgetEur) >= 0) ); } if (step === 2) { return state.staffingReqs.every( (r) => (r.roleId != null || r.role.trim().length > 0) && r.hoursPerDay > 0 && r.headcount >= 1, ); } return true; } // ─── Hook options ───────────────────────────────────────────────────────────── export interface UseProjectWizardFormOptions { onClose: () => void; onSuccess?: ((shortCode: string, name: string) => void) | undefined; } // ─── Hook ───────────────────────────────────────────────────────────────────── export function useProjectWizardForm({ onClose, onSuccess }: UseProjectWizardFormOptions) { 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 [submitWarnings, setSubmitWarnings] = useState([]); 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; }; 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); setSubmitWarnings([]); } function handleClose() { reset(); onClose(); } async function handleSubmit() { setIsSubmitting(true); setSubmitError(null); setSubmitWarnings([]); 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: state.dynamicFields, }); const warnings: string[] = []; // 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 (err) { const msg = err instanceof Error ? err.message : String(err); warnings.push(`Assignment for "${assignment.resourceName}" failed: ${msg}`); } } // 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 (err) { const msg = err instanceof Error ? err.message : String(err); warnings.push( `Demand for "${req.role || "Unnamed Role"}" (${unassigned} seat${unassigned > 1 ? "s" : ""}) failed: ${msg}`, ); } } if (warnings.length > 0) setSubmitWarnings(warnings); 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); } } return { step, setStep, state, patch, reset, canGoNext: () => canGoNext(step, state), isSubmitting, submitError, submitWarnings, showConfetti, showSuccessToast, setShowSuccessToast, handleSubmit, handleClose, }; }