refactor(web): set up component test infra + decompose ProjectWizard

Phase 4a: Add @testing-library/react, user-event, jest-dom, jsdom.
Switch vitest environment to jsdom, add setup file, create test-utils
with QueryClient wrapper.

Phase 4b: Extract ProjectWizard form logic into project-wizard/ subdir:
- types.ts: WizardState, Assignment, constants, factory functions
- useProjectWizardForm.ts: form state hook + canGoNext pure function

Phase 4c: 32 tests for canGoNext validation (all 5 steps), makeDefaultState,
and makeReq factory function.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-10 17:00:45 +02:00
parent 2f2fe2631f
commit 63db4a09e6
10 changed files with 1350 additions and 349 deletions
@@ -0,0 +1,215 @@
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<WizardState>(makeDefaultState);
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [submitWarnings, setSubmitWarnings] = useState<string[]>([]);
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<unknown>;
};
const createDemandRequirement = (
trpc.allocation.createDemandRequirement.useMutation as any
)() as {
mutateAsync: (input: unknown) => Promise<unknown>;
};
const patch = useCallback((p: Partial<WizardState>) => {
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,
};
}