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) {
|
||||
// Moving from step 0 → step 1: require name
|
||||
if (targetStep === 1 && !name.trim()) {
|
||||
setError("Estimate name is required.");
|
||||
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);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
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 type { StaffingRequirement, BlueprintFieldDefinition } from "@capakraken/shared";
|
||||
import { BlueprintTarget, FieldType, OrderType, AllocationType, ProjectStatus, AllocationStatus, RolePresetsSchema } from "@capakraken/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { uuid } from "~/lib/uuid.js";
|
||||
import { DateInput } from "~/components/ui/DateInput.js";
|
||||
@@ -70,6 +70,9 @@ interface WizardState {
|
||||
staffingReqs: StaffingRequirement[];
|
||||
assignments: Assignment[];
|
||||
saveAsDraft: boolean;
|
||||
dynamicFields: Record<string, unknown>;
|
||||
blueprintName: string | null;
|
||||
blueprintFieldDefs: BlueprintFieldDefinition[];
|
||||
}
|
||||
|
||||
function formatDateForInput(date: Date): string {
|
||||
@@ -95,6 +98,9 @@ function makeDefaultState(): WizardState {
|
||||
staffingReqs: [],
|
||||
assignments: [],
|
||||
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 ────────────────────────────────────────────
|
||||
|
||||
interface Step1Props {
|
||||
@@ -172,20 +289,41 @@ function Step1({ state, onChange }: Step1Props) {
|
||||
{ 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 };
|
||||
) as { data: Array<{ id: string; name: string; description?: string | null; rolePresets?: unknown; fieldDefs?: unknown }> | undefined };
|
||||
|
||||
const selectedBp = blueprints?.find((b) => b.id === state.blueprintId);
|
||||
|
||||
function selectBlueprint(id: string | null) {
|
||||
if (!id) {
|
||||
onChange({ blueprintId: null });
|
||||
onChange({ blueprintId: null, blueprintName: null, blueprintFieldDefs: [], dynamicFields: {} });
|
||||
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 });
|
||||
// Validate rolePresets structure before using — filter out malformed entries
|
||||
const parsedPresets = RolePresetsSchema.safeParse(
|
||||
Array.isArray(bp?.rolePresets) ? bp.rolePresets : [],
|
||||
);
|
||||
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 (
|
||||
@@ -299,6 +437,37 @@ function Step1({ state, onChange }: Step1Props) {
|
||||
</select>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -494,6 +663,9 @@ function Step3({ state, onChange }: Step3Props) {
|
||||
);
|
||||
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>) {
|
||||
const next = state.staffingReqs.map((r, i) =>
|
||||
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>
|
||||
<input
|
||||
type="text"
|
||||
list="chapter-options"
|
||||
value={req.chapter ?? ""}
|
||||
onChange={(e) =>
|
||||
updateReq(idx, { chapter: e.target.value || undefined } as Partial<StaffingRequirement>)
|
||||
@@ -661,6 +834,11 @@ function Step3({ state, onChange }: Step3Props) {
|
||||
placeholder="e.g. Art Direction"
|
||||
className={INPUT_CLS}
|
||||
/>
|
||||
{chapters.length > 0 && (
|
||||
<datalist id="chapter-options">
|
||||
{chapters.map((ch) => <option key={ch} value={ch} />)}
|
||||
</datalist>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -903,9 +1081,15 @@ interface Step5Props {
|
||||
onSubmit: () => void;
|
||||
isSubmitting: boolean;
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
{/* Project summary */}
|
||||
@@ -949,7 +1133,29 @@ function Step5({ state, onChange, onSubmit, isSubmitting, submitError }: Step5Pr
|
||||
<span className="text-gray-500">Responsible:</span> {state.responsiblePerson}
|
||||
</div>
|
||||
)}
|
||||
{state.blueprintName && (
|
||||
<div className="col-span-2">
|
||||
<span className="text-gray-500">Blueprint:</span> {state.blueprintName}
|
||||
</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>
|
||||
|
||||
{/* Staffing summary */}
|
||||
@@ -957,25 +1163,53 @@ function Step5({ state, onChange, onSubmit, isSubmitting, submitError }: Step5Pr
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-600 uppercase tracking-wider mb-2">
|
||||
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>
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-1.5">
|
||||
{state.staffingReqs.map((req) => {
|
||||
const assigned = state.assignments.filter((a) => a.requirementId === req.id);
|
||||
const assignedNames = assigned.map((a) => a.resourceName);
|
||||
const unassigned = req.headcount - assigned.length;
|
||||
return (
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<span className="flex-1 font-medium">{req.role || "Unnamed"}</span>
|
||||
<span className="text-gray-400">{req.hoursPerDay}h/day</span>
|
||||
<span
|
||||
className={clsx(
|
||||
"text-xs font-medium",
|
||||
assigned.length >= req.headcount ? "text-green-600" : "text-amber-600",
|
||||
)}
|
||||
>
|
||||
{assigned.length}/{req.headcount} assigned
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex-1 font-medium">{req.role || "Unnamed"}</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
|
||||
className={clsx(
|
||||
"text-xs font-medium",
|
||||
assigned.length >= req.headcount ? "text-green-600" : "text-amber-600",
|
||||
)}
|
||||
>
|
||||
{assigned.length}/{req.headcount} assigned
|
||||
</span>
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
@@ -983,6 +1217,17 @@ function Step5({ state, onChange, onSubmit, isSubmitting, submitError }: Step5Pr
|
||||
</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 */}
|
||||
<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">
|
||||
@@ -1057,6 +1302,7 @@ export function ProjectWizard({ open, onClose, onSuccess }: ProjectWizardProps)
|
||||
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);
|
||||
|
||||
@@ -1079,6 +1325,7 @@ export function ProjectWizard({ open, onClose, onSuccess }: ProjectWizardProps)
|
||||
setState(makeDefaultState());
|
||||
setIsSubmitting(false);
|
||||
setSubmitError(null);
|
||||
setSubmitWarnings([]);
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
@@ -1088,10 +1335,17 @@ export function ProjectWizard({ open, onClose, onSuccess }: ProjectWizardProps)
|
||||
|
||||
function canGoNext(): boolean {
|
||||
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 (
|
||||
state.shortCode.trim().length > 0 &&
|
||||
/^[A-Z0-9_-]+$/.test(state.shortCode) &&
|
||||
state.name.trim().length > 0
|
||||
state.name.trim().length > 0 &&
|
||||
!missingRequired
|
||||
);
|
||||
}
|
||||
if (step === 1) {
|
||||
@@ -1102,12 +1356,23 @@ export function ProjectWizard({ open, onClose, onSuccess }: ProjectWizardProps)
|
||||
(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;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
setIsSubmitting(true);
|
||||
setSubmitError(null);
|
||||
setSubmitWarnings([]);
|
||||
|
||||
try {
|
||||
const project = await createProject.mutateAsync({
|
||||
@@ -1123,9 +1388,11 @@ export function ProjectWizard({ open, onClose, onSuccess }: ProjectWizardProps)
|
||||
status: state.saveAsDraft ? ProjectStatus.DRAFT : ProjectStatus.ACTIVE,
|
||||
responsiblePerson: state.responsiblePerson.trim(),
|
||||
blueprintId: state.blueprintId ?? undefined,
|
||||
dynamicFields: {},
|
||||
dynamicFields: state.dynamicFields,
|
||||
});
|
||||
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Create draft assignments for assigned resources
|
||||
for (const assignment of state.assignments) {
|
||||
try {
|
||||
@@ -1144,8 +1411,9 @@ export function ProjectWizard({ open, onClose, onSuccess }: ProjectWizardProps)
|
||||
status: AllocationStatus.PROPOSED,
|
||||
metadata: {},
|
||||
});
|
||||
} catch {
|
||||
// Non-fatal — skip duplicate allocations
|
||||
} catch (err) {
|
||||
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,
|
||||
metadata: {},
|
||||
});
|
||||
} catch {
|
||||
// Non-fatal
|
||||
} 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();
|
||||
@@ -1252,6 +1523,7 @@ export function ProjectWizard({ open, onClose, onSuccess }: ProjectWizardProps)
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={isSubmitting}
|
||||
submitError={submitError}
|
||||
submitWarnings={submitWarnings}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user