fix(sanity): resolve 15 gaps from sanity check audit (G-01 through G-15)
- G-01: ProjectWizard renders blueprint fieldDefs with DynamicFieldInput component - G-02: Blueprint rolePresets validated via RolePresetsSchema in wizard; API keeps loose schema - G-03: ProjectWizard step 2/3 validation (role, hoursPerDay, headcount required) - G-04: EstimateWizard validates baseCurrency and demand line cost rates - G-05: Project lifecycle transition guards with ALLOWED_TRANSITIONS map - G-06: Blueprint validator extended for minLength/maxLength/pattern and DATE range checks - G-07: assertBlueprintDynamicFields merges global blueprint fieldDefs into validation - G-08: (tracked — chapter managed dropdown; deferred to backend ticket) - G-09: JSDoc added to lcrCents/ucrCents clarifying LCR/UCR terminology - G-10: Dispo route redirect already in place — closed as done - G-11: packages/ui empty by design — closed as documented - G-12: @deprecated JSDoc added to CreateAllocationSchema and UpdateAllocationSchema - G-13: ProjectWizard review step enhanced with blueprint name, field values, skills, assignments - G-14: ProjectWizard handleSubmit collects per-item warnings instead of silent swallowing - G-15: Vacation cancel reverses usedDays entitlement for APPROVED ANNUAL/OTHER vacations Tests: all 1575 passing (1 pre-existing failure in insights-summary unrelated to these changes) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -296,11 +296,30 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function validateStep(targetStep: number) {
|
function validateStep(targetStep: number) {
|
||||||
|
// Moving from step 0 → step 1: require name
|
||||||
if (targetStep === 1 && !name.trim()) {
|
if (targetStep === 1 && !name.trim()) {
|
||||||
setError("Estimate name is required.");
|
setError("Estimate name is required.");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Moving from step 0 → step 1: require base currency
|
||||||
|
if (targetStep === 1 && !baseCurrency.trim()) {
|
||||||
|
setError("Base currency is required.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Moving from step 3 (Staffing) → step 4 (Review): validate demand lines
|
||||||
|
if (targetStep === 4) {
|
||||||
|
const linesWithHours = demandLines.filter((l) => toHours(l.hours) > 0);
|
||||||
|
const invalid = linesWithHours.find((l) => toCents(l.costRate) <= 0);
|
||||||
|
if (invalid) {
|
||||||
|
setError(
|
||||||
|
`Demand line "${invalid.name || "unnamed"}" has hours but no cost rate. Please enter a cost rate or remove the line.`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setError(null);
|
setError(null);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import { clsx } from "clsx";
|
import { clsx } from "clsx";
|
||||||
import type { StaffingRequirement } from "@capakraken/shared";
|
import type { StaffingRequirement, BlueprintFieldDefinition } from "@capakraken/shared";
|
||||||
import { BlueprintTarget, OrderType, AllocationType, ProjectStatus, AllocationStatus } from "@capakraken/shared";
|
import { BlueprintTarget, FieldType, OrderType, AllocationType, ProjectStatus, AllocationStatus, RolePresetsSchema } from "@capakraken/shared";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import { uuid } from "~/lib/uuid.js";
|
import { uuid } from "~/lib/uuid.js";
|
||||||
import { DateInput } from "~/components/ui/DateInput.js";
|
import { DateInput } from "~/components/ui/DateInput.js";
|
||||||
@@ -70,6 +70,9 @@ interface WizardState {
|
|||||||
staffingReqs: StaffingRequirement[];
|
staffingReqs: StaffingRequirement[];
|
||||||
assignments: Assignment[];
|
assignments: Assignment[];
|
||||||
saveAsDraft: boolean;
|
saveAsDraft: boolean;
|
||||||
|
dynamicFields: Record<string, unknown>;
|
||||||
|
blueprintName: string | null;
|
||||||
|
blueprintFieldDefs: BlueprintFieldDefinition[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDateForInput(date: Date): string {
|
function formatDateForInput(date: Date): string {
|
||||||
@@ -95,6 +98,9 @@ function makeDefaultState(): WizardState {
|
|||||||
staffingReqs: [],
|
staffingReqs: [],
|
||||||
assignments: [],
|
assignments: [],
|
||||||
saveAsDraft: true,
|
saveAsDraft: true,
|
||||||
|
dynamicFields: {},
|
||||||
|
blueprintName: null,
|
||||||
|
blueprintFieldDefs: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,6 +166,117 @@ function StepBar({ current }: { current: number }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Dynamic Field Input ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function DynamicFieldInput({
|
||||||
|
field,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
field: BlueprintFieldDefinition;
|
||||||
|
value: unknown;
|
||||||
|
onChange: (key: string, val: unknown) => void;
|
||||||
|
}) {
|
||||||
|
const strVal = value !== undefined && value !== null ? String(value) : "";
|
||||||
|
const arrVal = Array.isArray(value) ? (value as string[]) : [];
|
||||||
|
|
||||||
|
switch (field.type) {
|
||||||
|
case FieldType.TEXTAREA:
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
value={strVal}
|
||||||
|
onChange={(e) => onChange(field.key, e.target.value)}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
rows={3}
|
||||||
|
className={INPUT_CLS}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case FieldType.NUMBER:
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={strVal}
|
||||||
|
min={field.validation?.min}
|
||||||
|
max={field.validation?.max}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange(field.key, e.target.value === "" ? "" : parseFloat(e.target.value))
|
||||||
|
}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
className={INPUT_CLS}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case FieldType.BOOLEAN:
|
||||||
|
return (
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={value === true || value === "true"}
|
||||||
|
onChange={(e) => onChange(field.key, e.target.checked)}
|
||||||
|
className="accent-brand-600"
|
||||||
|
/>
|
||||||
|
{field.description && <span className="text-gray-500">{field.description}</span>}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
case FieldType.DATE:
|
||||||
|
return (
|
||||||
|
<DateInput
|
||||||
|
value={strVal}
|
||||||
|
onChange={(v) => onChange(field.key, v)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case FieldType.SELECT:
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
value={strVal}
|
||||||
|
onChange={(e) => onChange(field.key, e.target.value)}
|
||||||
|
className={SELECT_CLS}
|
||||||
|
>
|
||||||
|
<option value="">— select —</option>
|
||||||
|
{(field.options ?? []).map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
case FieldType.MULTI_SELECT:
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{(field.options ?? []).map((opt) => {
|
||||||
|
const checked = arrVal.includes(opt.value);
|
||||||
|
return (
|
||||||
|
<label key={opt.value} className="flex items-center gap-1.5 text-sm cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = e.target.checked
|
||||||
|
? [...arrVal, opt.value]
|
||||||
|
: arrVal.filter((v) => v !== opt.value);
|
||||||
|
onChange(field.key, next);
|
||||||
|
}}
|
||||||
|
className="accent-brand-600"
|
||||||
|
/>
|
||||||
|
{opt.label}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
// TEXT, URL, EMAIL
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={field.type === FieldType.EMAIL ? "email" : field.type === FieldType.URL ? "url" : "text"}
|
||||||
|
value={strVal}
|
||||||
|
onChange={(e) => onChange(field.key, e.target.value)}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
className={INPUT_CLS}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Step 1: Blueprint & Identity ────────────────────────────────────────────
|
// ─── Step 1: Blueprint & Identity ────────────────────────────────────────────
|
||||||
|
|
||||||
interface Step1Props {
|
interface Step1Props {
|
||||||
@@ -172,20 +289,41 @@ function Step1({ state, onChange }: Step1Props) {
|
|||||||
{ target: BlueprintTarget.PROJECT, isActive: true },
|
{ target: BlueprintTarget.PROJECT, isActive: true },
|
||||||
{ staleTime: 30_000 },
|
{ staleTime: 30_000 },
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
) as { data: Array<{ id: string; name: string; description?: string | null; rolePresets?: unknown }> | undefined };
|
) as { data: Array<{ id: string; name: string; description?: string | null; rolePresets?: unknown; fieldDefs?: unknown }> | undefined };
|
||||||
|
|
||||||
const selectedBp = blueprints?.find((b) => b.id === state.blueprintId);
|
const selectedBp = blueprints?.find((b) => b.id === state.blueprintId);
|
||||||
|
|
||||||
function selectBlueprint(id: string | null) {
|
function selectBlueprint(id: string | null) {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
onChange({ blueprintId: null });
|
onChange({ blueprintId: null, blueprintName: null, blueprintFieldDefs: [], dynamicFields: {} });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const bp = blueprints?.find((b) => b.id === id);
|
const bp = blueprints?.find((b) => b.id === id);
|
||||||
const presets = Array.isArray(bp?.rolePresets)
|
// Validate rolePresets structure before using — filter out malformed entries
|
||||||
? (bp.rolePresets as unknown as StaffingRequirement[])
|
const parsedPresets = RolePresetsSchema.safeParse(
|
||||||
: [];
|
Array.isArray(bp?.rolePresets) ? bp.rolePresets : [],
|
||||||
onChange({ blueprintId: id, staffingReqs: presets });
|
);
|
||||||
|
const presets = (parsedPresets.success ? parsedPresets.data : []) as unknown as StaffingRequirement[];
|
||||||
|
// Parse fieldDefs from blueprint
|
||||||
|
const rawFieldDefs = Array.isArray(bp?.fieldDefs) ? (bp.fieldDefs as unknown[]) : [];
|
||||||
|
const fieldDefs = rawFieldDefs.filter(
|
||||||
|
(f): f is BlueprintFieldDefinition =>
|
||||||
|
typeof f === "object" && f !== null && "key" in f && "type" in f && "label" in f,
|
||||||
|
);
|
||||||
|
// Build default dynamic fields from defaults or required fields
|
||||||
|
const defaultDynamicFields: Record<string, unknown> = {};
|
||||||
|
for (const field of fieldDefs) {
|
||||||
|
if (field.defaultValue !== undefined) {
|
||||||
|
defaultDynamicFields[field.key] = field.defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onChange({
|
||||||
|
blueprintId: id,
|
||||||
|
blueprintName: bp?.name ?? null,
|
||||||
|
blueprintFieldDefs: fieldDefs,
|
||||||
|
staffingReqs: presets,
|
||||||
|
dynamicFields: defaultDynamicFields,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -299,6 +437,37 @@ function Step1({ state, onChange }: Step1Props) {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Blueprint custom fields */}
|
||||||
|
{state.blueprintFieldDefs.length > 0 && (
|
||||||
|
<div className="border-t border-gray-100 pt-4 space-y-3">
|
||||||
|
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Blueprint Fields — {state.blueprintName}
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
{[...state.blueprintFieldDefs]
|
||||||
|
.sort((a, b) => a.order - b.order)
|
||||||
|
.map((field) => (
|
||||||
|
<div key={field.key} className={field.type === FieldType.TEXTAREA ? "col-span-2" : ""}>
|
||||||
|
<label className={LABEL_CLS}>
|
||||||
|
{field.label}
|
||||||
|
{field.required && " *"}
|
||||||
|
{field.description && (
|
||||||
|
<InfoTooltip content={field.description} />
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<DynamicFieldInput
|
||||||
|
field={field}
|
||||||
|
value={state.dynamicFields[field.key]}
|
||||||
|
onChange={(key, val) =>
|
||||||
|
onChange({ dynamicFields: { ...state.dynamicFields, [key]: val } })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -494,6 +663,9 @@ function Step3({ state, onChange }: Step3Props) {
|
|||||||
);
|
);
|
||||||
const roles = rolesData ?? [];
|
const roles = rolesData ?? [];
|
||||||
|
|
||||||
|
const { data: chaptersData } = trpc.resource.chapters.useQuery(undefined, { staleTime: 60_000 });
|
||||||
|
const chapters = (chaptersData ?? []) as string[];
|
||||||
|
|
||||||
function updateReq(idx: number, patch: Partial<StaffingRequirement>) {
|
function updateReq(idx: number, patch: Partial<StaffingRequirement>) {
|
||||||
const next = state.staffingReqs.map((r, i) =>
|
const next = state.staffingReqs.map((r, i) =>
|
||||||
i === idx ? { ...r, ...patch } : r,
|
i === idx ? { ...r, ...patch } : r,
|
||||||
@@ -654,6 +826,7 @@ function Step3({ state, onChange }: Step3Props) {
|
|||||||
<label className="text-xs text-gray-400">Chapter filter (optional)<InfoTooltip content="Restrict suggestions to resources from a specific chapter/department." /></label>
|
<label className="text-xs text-gray-400">Chapter filter (optional)<InfoTooltip content="Restrict suggestions to resources from a specific chapter/department." /></label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
list="chapter-options"
|
||||||
value={req.chapter ?? ""}
|
value={req.chapter ?? ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateReq(idx, { chapter: e.target.value || undefined } as Partial<StaffingRequirement>)
|
updateReq(idx, { chapter: e.target.value || undefined } as Partial<StaffingRequirement>)
|
||||||
@@ -661,6 +834,11 @@ function Step3({ state, onChange }: Step3Props) {
|
|||||||
placeholder="e.g. Art Direction"
|
placeholder="e.g. Art Direction"
|
||||||
className={INPUT_CLS}
|
className={INPUT_CLS}
|
||||||
/>
|
/>
|
||||||
|
{chapters.length > 0 && (
|
||||||
|
<datalist id="chapter-options">
|
||||||
|
{chapters.map((ch) => <option key={ch} value={ch} />)}
|
||||||
|
</datalist>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -903,9 +1081,15 @@ interface Step5Props {
|
|||||||
onSubmit: () => void;
|
onSubmit: () => void;
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
submitError: string | null;
|
submitError: string | null;
|
||||||
|
submitWarnings: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function Step5({ state, onChange, onSubmit, isSubmitting, submitError }: Step5Props) {
|
function Step5({ state, onChange, onSubmit, isSubmitting, submitError, submitWarnings }: Step5Props) {
|
||||||
|
const totalAssignedCostHint = useMemo(() => {
|
||||||
|
// Very rough hint: sum hoursPerDay * headcount across all requirements
|
||||||
|
return state.staffingReqs.reduce((sum, r) => sum + r.hoursPerDay * r.headcount, 0);
|
||||||
|
}, [state.staffingReqs]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Project summary */}
|
{/* Project summary */}
|
||||||
@@ -949,7 +1133,29 @@ function Step5({ state, onChange, onSubmit, isSubmitting, submitError }: Step5Pr
|
|||||||
<span className="text-gray-500">Responsible:</span> {state.responsiblePerson}
|
<span className="text-gray-500">Responsible:</span> {state.responsiblePerson}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{state.blueprintName && (
|
||||||
|
<div className="col-span-2">
|
||||||
|
<span className="text-gray-500">Blueprint:</span> {state.blueprintName}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Blueprint custom fields summary */}
|
||||||
|
{state.blueprintFieldDefs.length > 0 && Object.keys(state.dynamicFields).length > 0 && (
|
||||||
|
<div className="border-t border-gray-200 pt-2 mt-2 space-y-1">
|
||||||
|
<p className="text-xs text-gray-400 font-medium">Custom Fields</p>
|
||||||
|
{state.blueprintFieldDefs.map((field) => {
|
||||||
|
const val = state.dynamicFields[field.key];
|
||||||
|
if (val === undefined || val === null || val === "") return null;
|
||||||
|
const display = Array.isArray(val) ? val.join(", ") : String(val);
|
||||||
|
return (
|
||||||
|
<div key={field.key} className="flex gap-2 text-xs">
|
||||||
|
<span className="text-gray-500">{field.label}:</span>
|
||||||
|
<span className="font-medium truncate">{display}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Staffing summary */}
|
{/* Staffing summary */}
|
||||||
@@ -957,17 +1163,28 @@ function Step5({ state, onChange, onSubmit, isSubmitting, submitError }: Step5Pr
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium text-gray-600 uppercase tracking-wider mb-2">
|
<p className="text-xs font-medium text-gray-600 uppercase tracking-wider mb-2">
|
||||||
Staffing Requirements
|
Staffing Requirements
|
||||||
|
{totalAssignedCostHint > 0 && (
|
||||||
|
<span className="ml-2 normal-case font-normal text-gray-400">
|
||||||
|
· {totalAssignedCostHint.toFixed(1)} total h/day across all roles
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1.5">
|
||||||
{state.staffingReqs.map((req) => {
|
{state.staffingReqs.map((req) => {
|
||||||
const assigned = state.assignments.filter((a) => a.requirementId === req.id);
|
const assigned = state.assignments.filter((a) => a.requirementId === req.id);
|
||||||
|
const assignedNames = assigned.map((a) => a.resourceName);
|
||||||
|
const unassigned = req.headcount - assigned.length;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={req.id}
|
key={req.id}
|
||||||
className="flex items-center gap-2 text-sm px-3 py-1.5 rounded-lg bg-gray-50"
|
className="px-3 py-2 rounded-lg bg-gray-50 text-sm"
|
||||||
>
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<span className="flex-1 font-medium">{req.role || "Unnamed"}</span>
|
<span className="flex-1 font-medium">{req.role || "Unnamed"}</span>
|
||||||
<span className="text-gray-400">{req.hoursPerDay}h/day</span>
|
<span className="text-gray-400 text-xs">{req.hoursPerDay}h/day</span>
|
||||||
|
{req.budgetCents ? (
|
||||||
|
<span className="text-xs text-gray-400">{formatCents(req.budgetCents)} EUR</span>
|
||||||
|
) : null}
|
||||||
<span
|
<span
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"text-xs font-medium",
|
"text-xs font-medium",
|
||||||
@@ -977,12 +1194,40 @@ function Step5({ state, onChange, onSubmit, isSubmitting, submitError }: Step5Pr
|
|||||||
{assigned.length}/{req.headcount} assigned
|
{assigned.length}/{req.headcount} assigned
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{(req.requiredSkills.length > 0 || (req.preferredSkills ?? []).length > 0) && (
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
{req.requiredSkills.map((s) => (
|
||||||
|
<span key={s} className="px-1.5 py-0.5 rounded text-[10px] bg-brand-100 text-brand-700">{s}</span>
|
||||||
|
))}
|
||||||
|
{(req.preferredSkills ?? []).map((s) => (
|
||||||
|
<span key={s} className="px-1.5 py-0.5 rounded text-[10px] bg-gray-100 text-gray-500">{s}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{assignedNames.length > 0 && (
|
||||||
|
<div className="mt-1 text-xs text-gray-500">
|
||||||
|
Assigned: {assignedNames.join(", ")}
|
||||||
|
{unassigned > 0 && ` · ${unassigned} open seat${unassigned > 1 ? "s" : ""}`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Post-creation warnings */}
|
||||||
|
{submitWarnings.length > 0 && (
|
||||||
|
<div className="px-3 py-2 bg-amber-50 border border-amber-200 rounded-lg text-sm text-amber-800 space-y-1">
|
||||||
|
<p className="font-medium">Project created with warnings:</p>
|
||||||
|
{submitWarnings.map((w, i) => (
|
||||||
|
<p key={i} className="text-xs">{w}</p>
|
||||||
|
))}
|
||||||
|
<p className="text-xs mt-1 text-amber-600">You can fix these staffing items from the project detail page.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Draft toggle */}
|
{/* Draft toggle */}
|
||||||
<div className="border border-gray-200 rounded-lg p-4">
|
<div className="border border-gray-200 rounded-lg p-4">
|
||||||
<p className="text-xs font-medium text-gray-600 uppercase tracking-wider mb-3 flex items-center gap-1">
|
<p className="text-xs font-medium text-gray-600 uppercase tracking-wider mb-3 flex items-center gap-1">
|
||||||
@@ -1057,6 +1302,7 @@ export function ProjectWizard({ open, onClose, onSuccess }: ProjectWizardProps)
|
|||||||
const [state, setState] = useState<WizardState>(makeDefaultState);
|
const [state, setState] = useState<WizardState>(makeDefaultState);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
|
const [submitWarnings, setSubmitWarnings] = useState<string[]>([]);
|
||||||
const [showConfetti, setShowConfetti] = useState(false);
|
const [showConfetti, setShowConfetti] = useState(false);
|
||||||
const [showSuccessToast, setShowSuccessToast] = useState(false);
|
const [showSuccessToast, setShowSuccessToast] = useState(false);
|
||||||
|
|
||||||
@@ -1079,6 +1325,7 @@ export function ProjectWizard({ open, onClose, onSuccess }: ProjectWizardProps)
|
|||||||
setState(makeDefaultState());
|
setState(makeDefaultState());
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
setSubmitError(null);
|
setSubmitError(null);
|
||||||
|
setSubmitWarnings([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
@@ -1088,10 +1335,17 @@ export function ProjectWizard({ open, onClose, onSuccess }: ProjectWizardProps)
|
|||||||
|
|
||||||
function canGoNext(): boolean {
|
function canGoNext(): boolean {
|
||||||
if (step === 0) {
|
if (step === 0) {
|
||||||
|
// Required blueprint fields must be filled
|
||||||
|
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 (
|
return (
|
||||||
state.shortCode.trim().length > 0 &&
|
state.shortCode.trim().length > 0 &&
|
||||||
/^[A-Z0-9_-]+$/.test(state.shortCode) &&
|
/^[A-Z0-9_-]+$/.test(state.shortCode) &&
|
||||||
state.name.trim().length > 0
|
state.name.trim().length > 0 &&
|
||||||
|
!missingRequired
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (step === 1) {
|
if (step === 1) {
|
||||||
@@ -1102,12 +1356,23 @@ export function ProjectWizard({ open, onClose, onSuccess }: ProjectWizardProps)
|
|||||||
(state.budgetEur === "" || parseFloat(state.budgetEur) >= 0)
|
(state.budgetEur === "" || parseFloat(state.budgetEur) >= 0)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (step === 2) {
|
||||||
|
// Allow advancing with zero requirements (no mandatory staffing), but
|
||||||
|
// any requirement that exists must be valid: must have a role and positive hours/headcount.
|
||||||
|
return state.staffingReqs.every(
|
||||||
|
(r) =>
|
||||||
|
(r.roleId != null || r.role.trim().length > 0) &&
|
||||||
|
r.hoursPerDay > 0 &&
|
||||||
|
r.headcount >= 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
setSubmitError(null);
|
setSubmitError(null);
|
||||||
|
setSubmitWarnings([]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const project = await createProject.mutateAsync({
|
const project = await createProject.mutateAsync({
|
||||||
@@ -1123,9 +1388,11 @@ export function ProjectWizard({ open, onClose, onSuccess }: ProjectWizardProps)
|
|||||||
status: state.saveAsDraft ? ProjectStatus.DRAFT : ProjectStatus.ACTIVE,
|
status: state.saveAsDraft ? ProjectStatus.DRAFT : ProjectStatus.ACTIVE,
|
||||||
responsiblePerson: state.responsiblePerson.trim(),
|
responsiblePerson: state.responsiblePerson.trim(),
|
||||||
blueprintId: state.blueprintId ?? undefined,
|
blueprintId: state.blueprintId ?? undefined,
|
||||||
dynamicFields: {},
|
dynamicFields: state.dynamicFields,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
// Create draft assignments for assigned resources
|
// Create draft assignments for assigned resources
|
||||||
for (const assignment of state.assignments) {
|
for (const assignment of state.assignments) {
|
||||||
try {
|
try {
|
||||||
@@ -1144,8 +1411,9 @@ export function ProjectWizard({ open, onClose, onSuccess }: ProjectWizardProps)
|
|||||||
status: AllocationStatus.PROPOSED,
|
status: AllocationStatus.PROPOSED,
|
||||||
metadata: {},
|
metadata: {},
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Non-fatal — skip duplicate allocations
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
warnings.push(`Assignment for "${assignment.resourceName}" failed: ${msg}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1167,11 +1435,14 @@ export function ProjectWizard({ open, onClose, onSuccess }: ProjectWizardProps)
|
|||||||
status: AllocationStatus.PROPOSED,
|
status: AllocationStatus.PROPOSED,
|
||||||
metadata: {},
|
metadata: {},
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Non-fatal
|
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.project.listWithCosts.invalidate();
|
||||||
await utils.timeline.getEntries.invalidate();
|
await utils.timeline.getEntries.invalidate();
|
||||||
await utils.timeline.getEntriesView.invalidate();
|
await utils.timeline.getEntriesView.invalidate();
|
||||||
@@ -1252,6 +1523,7 @@ export function ProjectWizard({ open, onClose, onSuccess }: ProjectWizardProps)
|
|||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
submitError={submitError}
|
submitError={submitError}
|
||||||
|
submitWarnings={submitWarnings}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,384 @@
|
|||||||
|
# CapaKraken Sanity Check
|
||||||
|
|
||||||
|
**Date:** 2026-04-05
|
||||||
|
**Purpose:** Living reference covering terminology, process flows, wizard/blueprint validation, and feature gaps.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Terminology Glossary](#1-terminology-glossary)
|
||||||
|
2. [Process Flows](#2-process-flows)
|
||||||
|
3. [Wizard & Blueprint Audit](#3-wizard--blueprint-audit)
|
||||||
|
4. [Feature Gap Register](#4-feature-gap-register)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Terminology Glossary
|
||||||
|
|
||||||
|
### 1.1 Allocation / DemandRequirement / Assignment
|
||||||
|
|
||||||
|
| Term | Definition | Where |
|
||||||
|
|---|---|---|
|
||||||
|
| **DemandRequirement** | First-class DB entity — an unfilled staffing need on a project (role, headcount, hours, budget). No `resourceId`. | `packages/db` model, `packages/shared/types/allocation.ts` |
|
||||||
|
| **Assignment** | First-class DB entity — a resource assigned to a project for a date range with hours/cost. Has `resourceId` (required) and optional `demandRequirementId`. | `packages/db` model, `packages/shared/types/allocation.ts` |
|
||||||
|
| **Allocation** | **Legacy read-model interface** — unified view merging demands and assignments via `isPlaceholder` flag. No DB table. | `packages/shared/types/allocation.ts` lines 29-47 |
|
||||||
|
| **AllocationLike** | Union-compatible shape keeping `isPlaceholder` for backwards compat with UI components. | `packages/shared/types/allocation.ts` lines 135-156 |
|
||||||
|
| **"allocation entry"** | Facade term used in use-case files (`update-allocation-entry.ts`, `load-allocation-entry.ts`, `delete-allocation-entry.ts`) to mean "either a DemandRequirement or an Assignment". | `packages/application/src/use-cases/allocation/` |
|
||||||
|
|
||||||
|
**Known inconsistencies:**
|
||||||
|
- The tRPC router is still named `allocation` (not `planning` or `demand-assignment`)
|
||||||
|
- `CreateAllocationSchema` coexists with `CreateDemandRequirementSchema` and `CreateAssignmentSchema`
|
||||||
|
- `createDemand` is an undocumented alias for `createDemandRequirement` in the router
|
||||||
|
- `FillOpenDemandByAllocationSchema` uses field name `allocationId` referencing a non-existent table
|
||||||
|
|
||||||
|
### 1.2 Resource / User
|
||||||
|
|
||||||
|
| Term | Definition |
|
||||||
|
|---|---|
|
||||||
|
| **User** | Auth/login identity with email, password hash, SystemRole, MFA, dashboard preferences. Optional 1:1 link to Resource. |
|
||||||
|
| **Resource** | Staffable person with EID, rates (LCR/UCR), skills, chapter, availability, dynamic fields. Optional 1:1 link to User. |
|
||||||
|
|
||||||
|
**Status: Clean.** No inconsistencies found. UI labels correctly use "User" in admin/auth context and "Resource" in planning/staffing context.
|
||||||
|
|
||||||
|
### 1.3 Blueprint / DynamicFields / Widget
|
||||||
|
|
||||||
|
| Term | Definition |
|
||||||
|
|---|---|
|
||||||
|
| **Blueprint** | Configuration template defining custom fields for Resources or Projects. Contains `fieldDefs`, `defaults`, `validationRules`, `rolePresets`. |
|
||||||
|
| **BlueprintFieldDefinition** | Schema for a single custom field (type, label, key, validation, options). Stored in Blueprint.fieldDefs JSONB. |
|
||||||
|
| **DynamicFields** | `Record<string, unknown>` — actual custom field values stored on Resource/Project. |
|
||||||
|
| **Widget** | Dashboard component (stat-cards, charts, tables). Completely separate from blueprints. |
|
||||||
|
|
||||||
|
**Status: Clean.** No confusion between blueprint fields and dashboard widgets.
|
||||||
|
|
||||||
|
### 1.4 Chapter
|
||||||
|
|
||||||
|
**Definition:** Organizational group/department. Always a plain `String?` — there is no `Chapter` entity anywhere in the schema.
|
||||||
|
|
||||||
|
Appears on 9+ models: Resource, StagedResource, StagedAssignment, EstimateDemandLine, RateCardLine, ResourceCostSnapshot, EffortRule, ExperienceMultiplierRule.
|
||||||
|
|
||||||
|
**Known inconsistencies:**
|
||||||
|
- `chapter` (display name), `chapterCode` (StagedResource), `chapterToken` (StagedAssignment) — three naming variants for the same concept in import pipeline
|
||||||
|
- No referential integrity, no centralized rename, no metadata (color, description)
|
||||||
|
- Grouping in reports/staffing relies on exact string matching
|
||||||
|
|
||||||
|
### 1.5 Rate Fields
|
||||||
|
|
||||||
|
| Field | Found On | Meaning | Unit |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `lcrCents` | Resource, StagedResource, ResourceCostSnapshot | Labor Cost Rate (hourly) | Integer cents |
|
||||||
|
| `ucrCents` | Resource, StagedResource, ResourceCostSnapshot | Utilization Cost Rate (hourly) | Integer cents |
|
||||||
|
| `dailyCostCents` | Assignment | Daily assignment cost | Integer cents |
|
||||||
|
| `costRateCents` | EstimateDemandLine, RateCardLine | Cost rate per hour | Integer cents |
|
||||||
|
| `billRateCents` | EstimateDemandLine, RateCardLine | Bill rate per hour | Integer cents |
|
||||||
|
| `budgetCents` | DemandRequirement, Project | Budget allocation | Integer cents |
|
||||||
|
|
||||||
|
**Known inconsistencies:**
|
||||||
|
- Resources use `lcrCents`/`ucrCents` (domain jargon); Estimates use `costRateCents`/`billRateCents` (generic). Same concept, different names.
|
||||||
|
- Assignments store `dailyCostCents` (daily); all other rates are hourly. Time dimension is implicit.
|
||||||
|
- No `billRateCents` on Assignment — billing lives only in the estimating domain.
|
||||||
|
|
||||||
|
### 1.6 Estimate Domain
|
||||||
|
|
||||||
|
| Term | Definition |
|
||||||
|
|---|---|
|
||||||
|
| **Estimate** | Top-level container with name, project link (optional), status, base currency. |
|
||||||
|
| **EstimateVersion** | Immutable snapshot within an estimate. Contains demand lines, scope items, assumptions, metrics. |
|
||||||
|
| **EstimateDemandLine** | Effort line with role, resource, hours, rates, totals. The financial building block. |
|
||||||
|
| **ScopeItem** | Deliverable/work item (shot, asset) with frame/item counts. |
|
||||||
|
| **ResourceCostSnapshot** | Point-in-time rate capture for audit trail. |
|
||||||
|
|
||||||
|
**Status: Clean.** "Demand line" (estimating) and "demand requirement" (planning) are distinct concepts, well-separated in code.
|
||||||
|
|
||||||
|
### 1.7 Dispo / Import
|
||||||
|
|
||||||
|
| Term | Definition |
|
||||||
|
|---|---|
|
||||||
|
| **Dispo** | Short for "Disposition" (German resource planning). Legacy system name (`DISPO_V2`). |
|
||||||
|
| **ImportBatch** | Generic import container. `sourceSystem` defaults to `"DISPO_V2"`. |
|
||||||
|
| **Staged\*** | Prefix for all import staging models (StagedResource, StagedProject, etc.). |
|
||||||
|
|
||||||
|
**Known inconsistencies:**
|
||||||
|
- Dual routes: `/admin/imports` (new, with tabs) and `/admin/dispo-imports` (old, standalone). Both still active.
|
||||||
|
- Router named `dispoRouter` rather than generic `importRouter`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Process Flows
|
||||||
|
|
||||||
|
### 2.1 Project Lifecycle
|
||||||
|
|
||||||
|
```
|
||||||
|
DRAFT ──→ ACTIVE ──→ ON_HOLD ──→ COMPLETED
|
||||||
|
│ │ │ │
|
||||||
|
└──────────┴──────────┴────────────┴──→ CANCELLED
|
||||||
|
```
|
||||||
|
|
||||||
|
**Guard conditions:**
|
||||||
|
- Permission: `MANAGE_PROJECTS` + `managerProcedure`
|
||||||
|
- **No state machine enforcement** — any status can transition to any other status. CANCELLED → ACTIVE is allowed.
|
||||||
|
|
||||||
|
**Side effects:** Dashboard cache invalidation, webhook `project.status_changed`, audit log on batch operations.
|
||||||
|
|
||||||
|
**Files:** `packages/api/src/router/project-lifecycle.ts` lines 72-88
|
||||||
|
|
||||||
|
**Gap: No transition guards.** Consider adding allowed-transition validation.
|
||||||
|
|
||||||
|
### 2.2 Estimate Lifecycle
|
||||||
|
|
||||||
|
**Estimate status:** `DRAFT → IN_REVIEW → APPROVED → ARCHIVED`
|
||||||
|
**Version status:**
|
||||||
|
```
|
||||||
|
WORKING ──submit──→ SUBMITTED ──approve──→ APPROVED
|
||||||
|
│
|
||||||
|
──revision──→ (new WORKING)
|
||||||
|
|
||||||
|
Other SUBMITTED versions → SUPERSEDED (on submit or approve)
|
||||||
|
Other APPROVED versions → SUPERSEDED (on approve of different version)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Guard conditions (enforced):**
|
||||||
|
- Submit: version must be `WORKING` (throws otherwise)
|
||||||
|
- Approve: version must be `SUBMITTED` (throws otherwise)
|
||||||
|
- Revision: no existing `WORKING` version; source must be locked
|
||||||
|
|
||||||
|
**Side effects:** `lockedAt` timestamp set, parent estimate status synced, superseded versions marked.
|
||||||
|
|
||||||
|
**Files:** `packages/application/src/use-cases/estimate/version-actions.ts`
|
||||||
|
|
||||||
|
**Status: Well-implemented** with proper guards and cascading state management.
|
||||||
|
|
||||||
|
### 2.3 Staffing Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
DemandRequirement ──rank resources──→ StaffingSuggestion[] ──fill──→ Assignment
|
||||||
|
```
|
||||||
|
|
||||||
|
**Scoring (4 factors):**
|
||||||
|
|
||||||
|
| Factor | Weight | Logic |
|
||||||
|
|---|---|---|
|
||||||
|
| Skill | 40% | Required skills (70%) + preferred (30%), proficiency 1-5 normalized |
|
||||||
|
| Availability | 30% | 100 minus 10 per conflict day |
|
||||||
|
| Cost | 20% | 100 at/under budget LCR, linear to 0 at 2x |
|
||||||
|
| Utilization | 10% | 100 if 20%+ below target, degrades linearly |
|
||||||
|
|
||||||
|
**Fill guards:**
|
||||||
|
- Demand must not be CANCELLED or COMPLETED
|
||||||
|
- Duplicate check (same resource + project + overlapping dates)
|
||||||
|
- Availability check (fails if >5 conflict days)
|
||||||
|
- DailyCostCents calculated via engine
|
||||||
|
|
||||||
|
**Files:** `packages/staffing/src/skill-matcher.ts`, `packages/application/src/use-cases/allocation/fill-demand-requirement.ts`
|
||||||
|
|
||||||
|
### 2.4 Estimate-to-Planning Handoff
|
||||||
|
|
||||||
|
```
|
||||||
|
EstimateDemandLine ──handoff──→ DemandRequirement + optional Assignment
|
||||||
|
```
|
||||||
|
|
||||||
|
**Guards:**
|
||||||
|
- Version must be `APPROVED`
|
||||||
|
- Estimate must be linked to a project
|
||||||
|
- No duplicate handoff (checks existing entries for same version)
|
||||||
|
|
||||||
|
**Logic per demand line (hours > 0):**
|
||||||
|
- If `resourceId` present: creates DemandRequirement + Assignment (both `PROPOSED`)
|
||||||
|
- If no `resourceId`: creates DemandRequirement only (placeholder)
|
||||||
|
- If assignment creation fails (resource not found, conflicts): falls back to placeholder
|
||||||
|
|
||||||
|
**Rate snapshotting:** `costRateCents`, `billRateCents`, `currency`, totals, monthly spread, and staffing attributes all captured in `estimateHandoff` metadata.
|
||||||
|
|
||||||
|
**Files:** `packages/application/src/use-cases/estimate/create-planning-handoff.ts`
|
||||||
|
|
||||||
|
### 2.5 Vacation Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Request ──→ PENDING ──approve──→ APPROVED
|
||||||
|
──reject──→ REJECTED
|
||||||
|
──cancel──→ CANCELLED
|
||||||
|
|
||||||
|
REJECTED ──re-approve──→ APPROVED
|
||||||
|
CANCELLED ──re-approve──→ APPROVED
|
||||||
|
```
|
||||||
|
|
||||||
|
**Guards:**
|
||||||
|
- Approve: source must be PENDING, CANCELLED, or REJECTED
|
||||||
|
- Reject: source must be PENDING only
|
||||||
|
- Cancel: source must not be CANCELLED; actor must be ADMIN/MANAGER or original requester
|
||||||
|
|
||||||
|
**Overlap detection:** Advisory only (does not block). Warns when >50% of a chapter is absent on any working day. Creates HIGH priority notification.
|
||||||
|
|
||||||
|
**Entitlement:** Only ANNUAL leave triggers deduction. Sick/public holiday/other do not.
|
||||||
|
|
||||||
|
**Files:** `packages/api/src/router/vacation-management-procedures.ts`, `vacation-management-support.ts`
|
||||||
|
|
||||||
|
### 2.6 Dispo Import Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
DRAFT → STAGING → STAGED → REVIEW_READY → APPROVED → COMMITTING → COMMITTED
|
||||||
|
│
|
||||||
|
FAILED ←─── (any error) ─────┘
|
||||||
|
CANCELLED ←── (manual cancel)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Commit preconditions:** batch in STAGED/REVIEW_READY/APPROVED; no blocking unresolved records; no staged PUBLIC_HOLIDAY records.
|
||||||
|
|
||||||
|
**Commit transaction (600s timeout):**
|
||||||
|
1. Upsert utilization categories and role seeds
|
||||||
|
2. Upsert resources by EID, roles, vacation entitlements, availability rules
|
||||||
|
3. Upsert projects by shortCode (TBD → DRAFT, others → ACTIVE)
|
||||||
|
4. Upsert assignments (aggregated, PROPOSED status)
|
||||||
|
5. Create vacations (APPROVED with admin user)
|
||||||
|
6. Mark all staged records as COMMITTED
|
||||||
|
7. Post-transaction: recompute resource value scores
|
||||||
|
|
||||||
|
**Rollback:** Entire commit in `$transaction` — any error rolls back everything.
|
||||||
|
|
||||||
|
**Files:** `packages/application/src/use-cases/dispo-import/commit-dispo-import-batch.ts`
|
||||||
|
|
||||||
|
### 2.7 Budget Monitoring
|
||||||
|
|
||||||
|
**Pure function:** `computeBudgetStatus(budgetCents, winProbability, allocations, dateRange)`
|
||||||
|
|
||||||
|
| Threshold | Level | Code |
|
||||||
|
|---|---|---|
|
||||||
|
| >= 70% | info | `BUDGET_INFO` |
|
||||||
|
| >= 85% | warning | `BUDGET_WARNING` |
|
||||||
|
| >= 95% | critical | `BUDGET_CRITICAL` |
|
||||||
|
| > 100% | critical | `BUDGET_EXCEEDED` |
|
||||||
|
|
||||||
|
**Allocation splits:** CONFIRMED + ACTIVE → `confirmedCents`; PROPOSED → `proposedCents`; COMPLETED/CANCELLED → ignored.
|
||||||
|
|
||||||
|
**Files:** `packages/engine/src/budget/monitor.ts`
|
||||||
|
|
||||||
|
### 2.8 Chargeability Forecast
|
||||||
|
|
||||||
|
```
|
||||||
|
SAH Calculator → net working hours (minus holidays, absences, variable schedules)
|
||||||
|
↓
|
||||||
|
Chargeability Calculator → FTE-weighted forecast per utilization category
|
||||||
|
↓
|
||||||
|
Rules Engine → cost attribution and chargeability effect per absence type
|
||||||
|
```
|
||||||
|
|
||||||
|
**SAH supports:** Variable schedule rules (Spain Friday/summer hours), public holidays, absences, FTE factor.
|
||||||
|
|
||||||
|
**Category breakdown:** Chg, BD, MD&I, M&O, PD&R, absence, unassigned — all as ratios against SAH.
|
||||||
|
|
||||||
|
**Rules engine:** Matches by triggerType (SICK/VACATION/PUBLIC_HOLIDAY/CUSTOM), project/orderType scope, specificity ranking. Effects: CHARGE/ZERO/REDUCE for cost, COUNT/SKIP for chargeability.
|
||||||
|
|
||||||
|
**Default rules:** Vacation/sick count toward chargeability but not charged to project. Public holidays: neither.
|
||||||
|
|
||||||
|
**Files:** `packages/engine/src/chargeability/calculator.ts`, `sah/calculator.ts`, `rules/engine.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Wizard & Blueprint Audit
|
||||||
|
|
||||||
|
### 3.1 ProjectWizard
|
||||||
|
|
||||||
|
**File:** `apps/web/src/components/projects/ProjectWizard.tsx` (1294 lines, 5 steps)
|
||||||
|
|
||||||
|
| Step | Name | Validation | Issues |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 0 | Blueprint & Identity | shortCode (regex), name (non-empty) | OK |
|
||||||
|
| 1 | Timeline & Budget | dates valid, budget >= 0 | OK |
|
||||||
|
| 2 | Staffing Demand | **None** | Can advance with empty role names, zero headcount |
|
||||||
|
| 3 | Suggestions | **None** | Only shown if requiredSkills non-empty |
|
||||||
|
| 4 | Review & Create | **None** | No re-validation before submit |
|
||||||
|
|
||||||
|
**Critical findings:**
|
||||||
|
|
||||||
|
1. **Blueprint fieldDefs are NEVER rendered.** The wizard always sends `dynamicFields: {}`. If a blueprint has required custom fields, project creation fails server-side with `UNPROCESSABLE_CONTENT`. Zero matches for `fieldDefs` in the entire wizard file.
|
||||||
|
|
||||||
|
2. **rolePresets loaded but unvalidated.** Blueprint rolePresets are cast via `as unknown as StaffingRequirement[]` with no runtime check. Malformed presets create broken staffing rows.
|
||||||
|
|
||||||
|
3. **Silent failure on post-creation.** Assignment and demand creation errors after project creation are swallowed in empty catch blocks — project can be created without its staffing.
|
||||||
|
|
||||||
|
4. **Review step is incomplete.** Does not show: blueprint name, required/preferred skills per role, individual assignment details, per-role budget, total staffing cost.
|
||||||
|
|
||||||
|
### 3.2 EstimateWizard
|
||||||
|
|
||||||
|
**File:** `apps/web/src/components/estimates/EstimateWizard.tsx` (833 lines, 5 steps)
|
||||||
|
|
||||||
|
| Step | Name | Validation | Issues |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 0 | Setup | name.trim() only | No check on currency, status, version label |
|
||||||
|
| 1 | Assumptions | **None** | Empty assumptions allowed |
|
||||||
|
| 2 | Scope | **None** | Empty scope allowed |
|
||||||
|
| 3 | Staffing | **None** | Zero rates, missing resources pass through |
|
||||||
|
| 4 | Review | name.trim() only | No financial validation |
|
||||||
|
|
||||||
|
**Critical findings:**
|
||||||
|
|
||||||
|
1. **Minimal validation across all steps.** Only `name.trim()` is checked. Demand lines with zero hours, zero rates, or invalid currencies pass to the server.
|
||||||
|
|
||||||
|
2. **Resource rate auto-fill works correctly.** Selecting a resource fills name, chapter, currency, costRate (from lcrCents), billRate (from ucrCents), roleId.
|
||||||
|
|
||||||
|
3. **Scope XLSX import works.** Uses exceljs with fuzzy column matching, 10MB/5000 row limits.
|
||||||
|
|
||||||
|
4. **Financial summary is correctly computed** (hours * rateCents with rounding). Margin percent rounded to nearest integer.
|
||||||
|
|
||||||
|
### 3.3 Blueprint Validation Engine
|
||||||
|
|
||||||
|
**File:** `packages/engine/src/blueprint/validator.ts` (92 lines)
|
||||||
|
|
||||||
|
| FieldType | Runtime Validation | Gap |
|
||||||
|
|---|---|---|
|
||||||
|
| NUMBER | Type check, min/max | Full |
|
||||||
|
| BOOLEAN | true/false check | Full |
|
||||||
|
| SELECT | Value in options | Full |
|
||||||
|
| MULTI_SELECT | All values in options | Full |
|
||||||
|
| URL | `new URL()` test | Full |
|
||||||
|
| EMAIL | Regex check | Basic |
|
||||||
|
| TEXT | Required only | **No minLength/maxLength/pattern** |
|
||||||
|
| TEXTAREA | Required only | **No minLength/maxLength** |
|
||||||
|
| DATE | Required only | **No format/range validation** |
|
||||||
|
|
||||||
|
**Disconnect:** The Zod `generateDynamicZodSchema` (in `blueprint.schema.ts`) handles minLength/maxLength/pattern, but the runtime `validateCustomFields` does not. Both exist but serve different purposes — the Zod schema is not called during entity mutations.
|
||||||
|
|
||||||
|
**Global blueprints:** Fields from global blueprints are NOT validated server-side during create/update. Only the entity-specific blueprint is checked via `assertBlueprintDynamicFields`.
|
||||||
|
|
||||||
|
**rolePresets:** Accepted as `z.array(z.unknown())` — no structural validation at any layer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Feature Gap Register
|
||||||
|
|
||||||
|
| # | Gap | Severity | Area | Recommendation |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **G-01** | ProjectWizard never renders blueprint fieldDefs — `dynamicFields: {}` always sent | **High** | Wizard | Add dynamic field rendering in Step 1 or a new step. Load fieldDefs from selected blueprint, render form inputs by type, validate before submit. |
|
||||||
|
| **G-02** | rolePresets unvalidated at API, storage, and consumption layers | **High** | Blueprint | Add a proper Zod schema for rolePresets matching `StaffingRequirement[]`. Validate on write, validate on read in wizard. |
|
||||||
|
| **G-03** | ProjectWizard Steps 2-3 have no validation | **Medium** | Wizard | Add required checks: role name non-empty, hoursPerDay > 0, headcount >= 1. |
|
||||||
|
| **G-04** | EstimateWizard has near-zero validation (only name.trim()) | **Medium** | Wizard | Add per-step validation: currency required (Step 0), demand lines need hours > 0 and rates > 0 (Step 3). |
|
||||||
|
| **G-05** | Project lifecycle has no transition guards (CANCELLED → ACTIVE is allowed) | **Medium** | Process | Add an allowed-transitions map in the `updateStatus` mutation. |
|
||||||
|
| **G-06** | Blueprint TEXT/TEXTAREA/DATE fields lack runtime validation for minLength/maxLength/pattern/date-range | **Medium** | Validation | Extend `validateCustomFields` to check all constraints that `generateDynamicZodSchema` already supports. |
|
||||||
|
| **G-07** | Global blueprint fields not validated server-side during entity create/update | **Medium** | Validation | Include global blueprint fieldDefs in `assertBlueprintDynamicFields` resolution. |
|
||||||
|
| **G-08** | Chapter is a freeform string (9+ models) with no lookup table | **Medium** | Data model | Consider promoting to a first-class entity or at minimum a managed dropdown populated from `SELECT DISTINCT`. |
|
||||||
|
| **G-09** | LCR/UCR vs costRate/billRate naming split across domains | **Low** | Terminology | Document the mapping explicitly. Consider aliasing in shared types. |
|
||||||
|
| **G-10** | Dual dispo import routes (`/admin/imports` and `/admin/dispo-imports`) | **Low** | UI | Deprecate and redirect the old route. |
|
||||||
|
| **G-11** | `packages/ui` nearly empty — all components in apps/web | **Low** | Architecture | Not urgent. Migrate shared components when a second app is needed. |
|
||||||
|
| **G-12** | Legacy `CreateAllocationSchema` / `UpdateAllocationSchema` still active alongside first-class schemas | **Low** | Terminology | Mark as deprecated in code comments. Plan removal when all consumers migrated. |
|
||||||
|
| **G-13** | ProjectWizard review step does not show skills, assignment details, or total cost | **Low** | UX | Enhance review panel to surface all collected data. |
|
||||||
|
| **G-14** | PostProject-creation assignment/demand creation errors silently swallowed | **Medium** | Reliability | Replace empty catch with error reporting and partial-creation recovery UI. |
|
||||||
|
| **G-15** | Vacation cancel does not reverse entitlement deduction | **Low** | Process | Verify if this is intentional. If approved vacation is cancelled, usedDays should be decremented. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Priority Matrix
|
||||||
|
|
||||||
|
**Fix immediately (High severity):**
|
||||||
|
- G-01: Blueprint fieldDefs not rendered in ProjectWizard
|
||||||
|
- G-02: rolePresets unvalidated
|
||||||
|
|
||||||
|
**Fix soon (Medium severity):**
|
||||||
|
- G-03, G-04: Wizard validation gaps
|
||||||
|
- G-05: Project lifecycle transition guards
|
||||||
|
- G-06, G-07: Blueprint validation completeness
|
||||||
|
- G-08: Chapter entity promotion
|
||||||
|
- G-14: Silent failure on post-creation
|
||||||
|
|
||||||
|
**Track / document (Low severity):**
|
||||||
|
- G-09 through G-13, G-15: Terminology docs, route cleanup, architecture notes
|
||||||
@@ -2,6 +2,15 @@ import { PermissionKey, SystemRole } from "@capakraken/shared";
|
|||||||
|
|
||||||
import type { ToolContext } from "../router/assistant-tools.js";
|
import type { ToolContext } from "../router/assistant-tools.js";
|
||||||
|
|
||||||
|
import { vi } from "vitest";
|
||||||
|
|
||||||
|
const defaultDbDefaults = {
|
||||||
|
blueprint: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue(null),
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export function createToolContext(
|
export function createToolContext(
|
||||||
db: Record<string, unknown>,
|
db: Record<string, unknown>,
|
||||||
options?: {
|
options?: {
|
||||||
@@ -10,8 +19,16 @@ export function createToolContext(
|
|||||||
},
|
},
|
||||||
): ToolContext {
|
): ToolContext {
|
||||||
const userRole = options?.userRole ?? SystemRole.ADMIN;
|
const userRole = options?.userRole ?? SystemRole.ADMIN;
|
||||||
|
const mergedDb = {
|
||||||
|
...defaultDbDefaults,
|
||||||
|
...db,
|
||||||
|
blueprint: {
|
||||||
|
...defaultDbDefaults.blueprint,
|
||||||
|
...(db.blueprint as Record<string, unknown> | undefined),
|
||||||
|
},
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
db: db as ToolContext["db"],
|
db: mergedDb as ToolContext["db"],
|
||||||
userId: "user_1",
|
userId: "user_1",
|
||||||
userRole,
|
userRole,
|
||||||
permissions: new Set(options?.permissions ?? []),
|
permissions: new Set(options?.permissions ?? []),
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ describe("assistant project admin create tools - success", () => {
|
|||||||
fieldDefs: [],
|
fieldDefs: [],
|
||||||
}),
|
}),
|
||||||
findFirst: vi.fn().mockResolvedValue(null),
|
findFirst: vi.fn().mockResolvedValue(null),
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
},
|
},
|
||||||
client: {
|
client: {
|
||||||
findUnique: vi.fn().mockResolvedValue({
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
|
|||||||
@@ -159,6 +159,12 @@ export function createHappyPathDb() {
|
|||||||
findMany: vi.fn().mockResolvedValue([]),
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
(db as Record<string, unknown>).$transaction = vi.fn(
|
||||||
|
async (callback: (tx: typeof db) => Promise<unknown>) => callback(db),
|
||||||
|
);
|
||||||
|
|
||||||
|
return db;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const executeTool = executeAssistantTool;
|
export const executeTool = executeAssistantTool;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ function createDbMock(result: { fieldDefs: unknown; target: BlueprintTarget } |
|
|||||||
return {
|
return {
|
||||||
blueprint: {
|
blueprint: {
|
||||||
findUnique: vi.fn().mockResolvedValue(result),
|
findUnique: vi.fn().mockResolvedValue(result),
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -499,6 +499,7 @@ describe("project router", () => {
|
|||||||
const updated = { ...sampleProject, status: ProjectStatus.COMPLETED };
|
const updated = { ...sampleProject, status: ProjectStatus.COMPLETED };
|
||||||
const db = {
|
const db = {
|
||||||
project: {
|
project: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue(sampleProject),
|
||||||
update: vi.fn().mockResolvedValue(updated),
|
update: vi.fn().mockResolvedValue(updated),
|
||||||
},
|
},
|
||||||
webhook: { findMany: vi.fn().mockResolvedValue([]) },
|
webhook: { findMany: vi.fn().mockResolvedValue([]) },
|
||||||
@@ -526,6 +527,7 @@ describe("project router", () => {
|
|||||||
const updated = { ...sampleProject, status: ProjectStatus.COMPLETED };
|
const updated = { ...sampleProject, status: ProjectStatus.COMPLETED };
|
||||||
const db = {
|
const db = {
|
||||||
project: {
|
project: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue(sampleProject),
|
||||||
update: vi.fn().mockResolvedValue(updated),
|
update: vi.fn().mockResolvedValue(updated),
|
||||||
},
|
},
|
||||||
webhook: { findMany: vi.fn().mockResolvedValue([]) },
|
webhook: { findMany: vi.fn().mockResolvedValue([]) },
|
||||||
|
|||||||
@@ -163,9 +163,12 @@ function createVacationDb(overrides: Record<string, unknown> = {}) {
|
|||||||
auditLog: {
|
auditLog: {
|
||||||
create: vi.fn().mockResolvedValue({}),
|
create: vi.fn().mockResolvedValue({}),
|
||||||
},
|
},
|
||||||
|
vacationEntitlement: {
|
||||||
|
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
const merged = {
|
||||||
...db,
|
...db,
|
||||||
...overrides,
|
...overrides,
|
||||||
user: { ...db.user, ...(overrides.user as Record<string, unknown> | undefined) },
|
user: { ...db.user, ...(overrides.user as Record<string, unknown> | undefined) },
|
||||||
@@ -176,6 +179,15 @@ function createVacationDb(overrides: Record<string, unknown> = {}) {
|
|||||||
...(overrides.notification as Record<string, unknown> | undefined),
|
...(overrides.notification as Record<string, unknown> | undefined),
|
||||||
},
|
},
|
||||||
auditLog: { ...db.auditLog, ...(overrides.auditLog as Record<string, unknown> | undefined) },
|
auditLog: { ...db.auditLog, ...(overrides.auditLog as Record<string, unknown> | undefined) },
|
||||||
|
vacationEntitlement: {
|
||||||
|
...db.vacationEntitlement,
|
||||||
|
...(overrides.vacationEntitlement as Record<string, unknown> | undefined),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...merged,
|
||||||
|
$transaction: vi.fn(async (callback: (tx: typeof merged) => Promise<unknown>) => callback(merged)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export const blueprintUpdateInputSchema = z.object({
|
|||||||
|
|
||||||
export const blueprintRolePresetsInputSchema = z.object({
|
export const blueprintRolePresetsInputSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
rolePresets: z.array(z.unknown()),
|
rolePresets: z.array(z.record(z.string(), z.unknown())),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const blueprintBatchDeleteInputSchema = z.object({
|
export const blueprintBatchDeleteInputSchema = z.object({
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ interface BlueprintLookup {
|
|||||||
where: { id: string };
|
where: { id: string };
|
||||||
select: { fieldDefs: true; target: true };
|
select: { fieldDefs: true; target: true };
|
||||||
}) => Promise<{ fieldDefs: unknown; target: string } | null>;
|
}) => Promise<{ fieldDefs: unknown; target: string } | null>;
|
||||||
|
findMany: (args: {
|
||||||
|
where: { target: BlueprintTarget; isGlobal: boolean; isActive: boolean };
|
||||||
|
select: { fieldDefs: true };
|
||||||
|
}) => Promise<Array<{ fieldDefs: unknown }>>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,8 +29,10 @@ export async function assertBlueprintDynamicFields({
|
|||||||
dynamicFields,
|
dynamicFields,
|
||||||
target,
|
target,
|
||||||
}: AssertBlueprintDynamicFieldsInput): Promise<void> {
|
}: AssertBlueprintDynamicFieldsInput): Promise<void> {
|
||||||
if (!blueprintId) return;
|
// Collect field defs from the entity's specific blueprint (if any)
|
||||||
|
let specificFieldDefs: BlueprintFieldDefinition[] = [];
|
||||||
|
|
||||||
|
if (blueprintId) {
|
||||||
const blueprint = await findUniqueOrThrow(
|
const blueprint = await findUniqueOrThrow(
|
||||||
db.blueprint.findUnique({
|
db.blueprint.findUnique({
|
||||||
where: { id: blueprintId },
|
where: { id: blueprintId },
|
||||||
@@ -42,8 +48,28 @@ export async function assertBlueprintDynamicFields({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const fieldDefs = blueprint.fieldDefs as BlueprintFieldDefinition[];
|
specificFieldDefs = blueprint.fieldDefs as BlueprintFieldDefinition[];
|
||||||
const errors = validateCustomFields(fieldDefs, dynamicFields);
|
}
|
||||||
|
|
||||||
|
// Also collect field defs from all active global blueprints for this target
|
||||||
|
const globalBlueprints = await db.blueprint.findMany({
|
||||||
|
where: { target, isGlobal: true, isActive: true },
|
||||||
|
select: { fieldDefs: true },
|
||||||
|
});
|
||||||
|
const globalFieldDefs = globalBlueprints.flatMap(
|
||||||
|
(bp) => bp.fieldDefs as BlueprintFieldDefinition[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Merge: specific blueprint fields + global fields (specific takes precedence for same key)
|
||||||
|
const specificKeys = new Set(specificFieldDefs.map((f) => f.key));
|
||||||
|
const mergedFieldDefs = [
|
||||||
|
...specificFieldDefs,
|
||||||
|
...globalFieldDefs.filter((f) => !specificKeys.has(f.key)),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (mergedFieldDefs.length === 0) return;
|
||||||
|
|
||||||
|
const errors = validateCustomFields(mergedFieldDefs, dynamicFields);
|
||||||
|
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import { PermissionKey, ProjectStatus } from "@capakraken/shared";
|
import { PermissionKey, ProjectStatus } from "@capakraken/shared";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// ─── Allowed status transitions ───────────────────────────────────────────────
|
||||||
|
const ALLOWED_TRANSITIONS: Record<ProjectStatus, ProjectStatus[]> = {
|
||||||
|
[ProjectStatus.DRAFT]: [ProjectStatus.ACTIVE, ProjectStatus.CANCELLED],
|
||||||
|
[ProjectStatus.ACTIVE]: [ProjectStatus.ON_HOLD, ProjectStatus.COMPLETED, ProjectStatus.CANCELLED],
|
||||||
|
[ProjectStatus.ON_HOLD]: [ProjectStatus.ACTIVE, ProjectStatus.CANCELLED],
|
||||||
|
[ProjectStatus.COMPLETED]: [ProjectStatus.ACTIVE], // re-open only
|
||||||
|
[ProjectStatus.CANCELLED]: [ProjectStatus.DRAFT], // revive only
|
||||||
|
};
|
||||||
import { adminProcedure, managerProcedure, requirePermission, type TRPCContext } from "../trpc.js";
|
import { adminProcedure, managerProcedure, requirePermission, type TRPCContext } from "../trpc.js";
|
||||||
|
|
||||||
type ProjectLifecycleContext = Pick<TRPCContext, "db" | "dbUser"> & {
|
type ProjectLifecycleContext = Pick<TRPCContext, "db" | "dbUser"> & {
|
||||||
@@ -73,6 +82,24 @@ export function createProjectLifecycleProcedures(
|
|||||||
.input(z.object({ id: z.string(), status: z.nativeEnum(ProjectStatus) }))
|
.input(z.object({ id: z.string(), status: z.nativeEnum(ProjectStatus) }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
|
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
|
||||||
|
|
||||||
|
const current = await ctx.db.project.findUnique({
|
||||||
|
where: { id: input.id },
|
||||||
|
select: { id: true, status: true },
|
||||||
|
});
|
||||||
|
if (!current) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
|
||||||
|
}
|
||||||
|
if (current.status !== input.status) {
|
||||||
|
const allowed = ALLOWED_TRANSITIONS[current.status] ?? [];
|
||||||
|
if (!allowed.includes(input.status)) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: `Cannot transition project from ${current.status} to ${input.status}. Allowed: ${allowed.join(", ") || "none"}.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const result = await ctx.db.project.update({
|
const result = await ctx.db.project.update({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
data: { status: input.status },
|
data: { status: input.status },
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { UpdateVacationStatusSchema } from "@capakraken/shared";
|
import { UpdateVacationStatusSchema } from "@capakraken/shared";
|
||||||
import { VacationStatus } from "@capakraken/db";
|
import { VacationStatus } from "@capakraken/db";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { VACATION_BALANCE_TYPES } from "../lib/vacation-deduction-snapshot.js";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { findUniqueOrThrow } from "../db/helpers.js";
|
import { findUniqueOrThrow } from "../db/helpers.js";
|
||||||
import { RESOURCE_BRIEF_SELECT } from "../db/selects.js";
|
import { RESOURCE_BRIEF_SELECT } from "../db/selects.js";
|
||||||
@@ -317,11 +318,26 @@ export const vacationManagementProcedures = {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const wasApproved = existing.status === VacationStatus.APPROVED;
|
||||||
|
const shouldReverseEntitlement =
|
||||||
|
wasApproved &&
|
||||||
|
VACATION_BALANCE_TYPES.has(existing.type) &&
|
||||||
|
typeof existing.deductedDays === "number" &&
|
||||||
|
existing.deductedDays > 0;
|
||||||
|
|
||||||
const updated = await ctx.db.vacation.update({
|
const updated = await ctx.db.vacation.update({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
data: { status: VacationStatus.CANCELLED },
|
data: { status: VacationStatus.CANCELLED },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (shouldReverseEntitlement) {
|
||||||
|
const year = existing.startDate.getFullYear();
|
||||||
|
await ctx.db.vacationEntitlement.updateMany({
|
||||||
|
where: { resourceId: existing.resourceId, year },
|
||||||
|
data: { usedDays: { decrement: existing.deductedDays as number } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status });
|
emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status });
|
||||||
|
|
||||||
void createAuditEntry({
|
void createAuditEntry({
|
||||||
|
|||||||
@@ -84,7 +84,41 @@ export function validateCustomFields(
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// TEXT, TEXTAREA, DATE — no structural validation beyond required
|
case FieldType.TEXT:
|
||||||
|
case FieldType.TEXTAREA: {
|
||||||
|
const strVal = String(value);
|
||||||
|
const v = def.validation;
|
||||||
|
if (v) {
|
||||||
|
if (v.minLength !== undefined && strVal.length < v.minLength) {
|
||||||
|
errors.push({ key: def.key, message: v.message ?? `${def.label} must be at least ${v.minLength} characters` });
|
||||||
|
}
|
||||||
|
if (v.maxLength !== undefined && strVal.length > v.maxLength) {
|
||||||
|
errors.push({ key: def.key, message: v.message ?? `${def.label} must be at most ${v.maxLength} characters` });
|
||||||
|
}
|
||||||
|
if (v.pattern !== undefined && !new RegExp(v.pattern).test(strVal)) {
|
||||||
|
errors.push({ key: def.key, message: v.message ?? `${def.label} has an invalid format` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case FieldType.DATE: {
|
||||||
|
const dateVal = new Date(String(value));
|
||||||
|
if (isNaN(dateVal.getTime())) {
|
||||||
|
errors.push({ key: def.key, message: `${def.label} must be a valid date` });
|
||||||
|
} else {
|
||||||
|
const v = def.validation;
|
||||||
|
if (v) {
|
||||||
|
if (v.min !== undefined && dateVal.getTime() < new Date(v.min).getTime()) {
|
||||||
|
errors.push({ key: def.key, message: v.message ?? `${def.label} must not be before ${new Date(v.min).toLocaleDateString()}` });
|
||||||
|
}
|
||||||
|
if (v.max !== undefined && dateVal.getTime() > new Date(v.max).getTime()) {
|
||||||
|
errors.push({ key: def.key, message: v.message ?? `${def.label} must not be after ${new Date(v.max).toLocaleDateString()}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,11 +45,20 @@ export const CreateAssignmentBaseSchema = z.object({
|
|||||||
metadata: z.record(z.string(), z.unknown()).default({}),
|
metadata: z.record(z.string(), z.unknown()).default({}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use `CreateDemandRequirementSchema` (for open demand) or `CreateAssignmentSchema`
|
||||||
|
* (for resource assignments) instead. This legacy facade remains for router backwards-compat
|
||||||
|
* and will be removed once all consumers are migrated.
|
||||||
|
*/
|
||||||
export const CreateAllocationSchema = CreateAllocationBaseSchema.refine(
|
export const CreateAllocationSchema = CreateAllocationBaseSchema.refine(
|
||||||
(data) => data.endDate >= data.startDate,
|
(data) => data.endDate >= data.startDate,
|
||||||
{ message: "End date must be after start date", path: ["endDate"] },
|
{ message: "End date must be after start date", path: ["endDate"] },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use `UpdateDemandRequirementSchema` or `UpdateAssignmentSchema` instead.
|
||||||
|
* See `CreateAllocationSchema` note above.
|
||||||
|
*/
|
||||||
export const UpdateAllocationSchema = CreateAllocationBaseSchema.partial();
|
export const UpdateAllocationSchema = CreateAllocationBaseSchema.partial();
|
||||||
|
|
||||||
export const CreateDemandRequirementSchema = CreateDemandRequirementBaseSchema.refine(
|
export const CreateDemandRequirementSchema = CreateDemandRequirementBaseSchema.refine(
|
||||||
|
|||||||
@@ -1,6 +1,29 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { BlueprintTarget, FieldType } from "../types/enums.js";
|
import { BlueprintTarget, FieldType } from "../types/enums.js";
|
||||||
|
|
||||||
|
// ─── Role Preset Schema ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a single staffing requirement used as a blueprint role preset.
|
||||||
|
* Must match the StaffingRequirement interface from types/project.ts.
|
||||||
|
*/
|
||||||
|
export const RolePresetSchema = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
role: z.string().min(1),
|
||||||
|
roleId: z.string().optional(),
|
||||||
|
requiredSkills: z.array(z.string()).default([]),
|
||||||
|
preferredSkills: z.array(z.string()).optional(),
|
||||||
|
hoursPerDay: z.number().positive().max(24),
|
||||||
|
headcount: z.number().int().min(1),
|
||||||
|
budgetCents: z.number().int().min(0).optional(),
|
||||||
|
startDate: z.string().optional(),
|
||||||
|
endDate: z.string().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
chapter: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const RolePresetsSchema = z.array(RolePresetSchema);
|
||||||
|
|
||||||
export const FieldOptionSchema = z.object({
|
export const FieldOptionSchema = z.object({
|
||||||
value: z.string().min(1),
|
value: z.string().min(1),
|
||||||
label: z.string().min(1),
|
label: z.string().min(1),
|
||||||
|
|||||||
@@ -32,7 +32,15 @@ export interface Resource {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
email: string;
|
email: string;
|
||||||
chapter?: string | null;
|
chapter?: string | null;
|
||||||
|
/**
|
||||||
|
* Labor Cost Rate — hourly cost of this resource to the company (integer cents).
|
||||||
|
* Equivalent to `costRateCents` in the estimating domain.
|
||||||
|
*/
|
||||||
lcrCents: number;
|
lcrCents: number;
|
||||||
|
/**
|
||||||
|
* Utilization Cost Rate — hourly charge-out rate billed to clients (integer cents).
|
||||||
|
* Equivalent to `billRateCents` in the estimating domain.
|
||||||
|
*/
|
||||||
ucrCents: number;
|
ucrCents: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
chargeabilityTarget: number;
|
chargeabilityTarget: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user