refactor(web): decompose ProjectWizard into step components
Extract each wizard step into its own file under project-wizard/: StepBar, DynamicFieldInput, Step1Identity, ResourcePersonPicker, Step2Timeline, Step3Staffing, Step4Suggestions, Step5Review. Main file reduced from 1,385 to 112 lines. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,109 @@
|
|||||||
|
import type { BlueprintFieldDefinition } from "@capakraken/shared";
|
||||||
|
import { FieldType } from "@capakraken/shared";
|
||||||
|
import { DateInput } from "~/components/ui/DateInput.js";
|
||||||
|
|
||||||
|
export 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="app-input"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
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="app-input"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
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="app-select w-full"
|
||||||
|
>
|
||||||
|
<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="app-input"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { useState, useEffect, useMemo, useRef } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
|
import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js";
|
||||||
|
|
||||||
|
export function ResourcePersonPicker({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
}) {
|
||||||
|
const [query, setQuery] = useState(value);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||||
|
const [isConfirmed, setIsConfirmed] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setDebouncedSearch(query), 200);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
const { data } = trpc.resource.directory.useQuery(
|
||||||
|
{ isActive: true, search: debouncedSearch || undefined, limit: 30 },
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
{ staleTime: 15_000, placeholderData: (prev: any) => prev },
|
||||||
|
);
|
||||||
|
const filtered = useMemo(
|
||||||
|
() =>
|
||||||
|
(data?.resources ?? []) as unknown as Array<{ id: string; displayName: string; eid: string }>,
|
||||||
|
[data],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setQuery(value);
|
||||||
|
if (!value) setIsConfirmed(false);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const { panelRef, position } = useAnchoredOverlay<HTMLInputElement>({
|
||||||
|
open,
|
||||||
|
onClose: () => setOpen(false),
|
||||||
|
align: "start",
|
||||||
|
matchTriggerWidth: true,
|
||||||
|
triggerRef: inputRef,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQuery(e.target.value);
|
||||||
|
onChange(e.target.value);
|
||||||
|
setIsConfirmed(false);
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
placeholder="Search by name or EID…"
|
||||||
|
className={`app-input ${isConfirmed ? "ring-2 ring-green-400 ring-offset-0" : ""}`}
|
||||||
|
/>
|
||||||
|
{isConfirmed && (
|
||||||
|
<span className="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-green-500">
|
||||||
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2.5}
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{open && filtered.length > 0 && typeof document !== "undefined"
|
||||||
|
? createPortal(
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
className="fixed z-[9998] max-h-48 overflow-y-auto rounded-lg border border-gray-200 bg-white shadow-lg"
|
||||||
|
style={{
|
||||||
|
top: position.top,
|
||||||
|
left: position.left,
|
||||||
|
width: position.minWidth,
|
||||||
|
}}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<ul>
|
||||||
|
{filtered.map((r) => (
|
||||||
|
<li key={r.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={() => {
|
||||||
|
onChange(r.displayName);
|
||||||
|
setQuery(r.displayName);
|
||||||
|
setIsConfirmed(true);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className="flex w-full items-baseline gap-2 px-3 py-2 text-left text-sm transition-colors hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<span className="truncate">{r.displayName}</span>
|
||||||
|
<span className="shrink-0 font-mono text-xs text-gray-400">{r.eid}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import { clsx } from "clsx";
|
||||||
|
import type { StaffingRequirement, BlueprintFieldDefinition } from "@capakraken/shared";
|
||||||
|
import { BlueprintTarget, FieldType, RolePresetsSchema } from "@capakraken/shared";
|
||||||
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
|
import { DynamicFieldInput } from "./DynamicFieldInput.js";
|
||||||
|
import { ORDER_TYPE_OPTIONS, ALLOCATION_TYPE_OPTIONS, type WizardState } from "./types.js";
|
||||||
|
|
||||||
|
interface Step1Props {
|
||||||
|
state: WizardState;
|
||||||
|
onChange: (patch: Partial<WizardState>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Step1Identity({ state, onChange }: Step1Props) {
|
||||||
|
const { data: blueprints } = trpc.blueprint.list.useQuery(
|
||||||
|
{ target: BlueprintTarget.PROJECT, isActive: true },
|
||||||
|
{ staleTime: 30_000 },
|
||||||
|
) 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,
|
||||||
|
blueprintName: null,
|
||||||
|
blueprintFieldDefs: [],
|
||||||
|
dynamicFields: {},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bp = blueprints?.find((b) => b.id === id);
|
||||||
|
const parsedPresets = RolePresetsSchema.safeParse(
|
||||||
|
Array.isArray(bp?.rolePresets) ? bp.rolePresets : [],
|
||||||
|
);
|
||||||
|
const presets = (parsedPresets.success
|
||||||
|
? parsedPresets.data
|
||||||
|
: []) as unknown as StaffingRequirement[];
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
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 (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Blueprint picker */}
|
||||||
|
<div>
|
||||||
|
<label className="app-label">
|
||||||
|
Project Blueprint (optional)
|
||||||
|
<InfoTooltip content="Blueprints are templates that pre-fill role presets and default settings. Selecting one loads staffing requirements into Step 3." />
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mb-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectBlueprint(null)}
|
||||||
|
className={clsx(
|
||||||
|
"text-left p-3 rounded-lg border text-sm transition-colors",
|
||||||
|
!state.blueprintId
|
||||||
|
? "border-brand-500 bg-brand-50 text-brand-700"
|
||||||
|
: "border-gray-200 hover:border-gray-300 text-gray-600",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="font-medium">No Blueprint</div>
|
||||||
|
<div className="text-xs text-gray-400 mt-0.5">Start blank</div>
|
||||||
|
</button>
|
||||||
|
{(blueprints ?? []).map((bp) => (
|
||||||
|
<button
|
||||||
|
key={bp.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectBlueprint(bp.id)}
|
||||||
|
className={clsx(
|
||||||
|
"text-left p-3 rounded-lg border text-sm transition-colors",
|
||||||
|
state.blueprintId === bp.id
|
||||||
|
? "border-brand-500 bg-brand-50 text-brand-700"
|
||||||
|
: "border-gray-200 hover:border-gray-300 text-gray-600",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="font-medium truncate">{bp.name}</div>
|
||||||
|
{bp.description && (
|
||||||
|
<div className="text-xs text-gray-400 mt-0.5 truncate">{bp.description}</div>
|
||||||
|
)}
|
||||||
|
{Array.isArray(bp.rolePresets) && bp.rolePresets.length > 0 && (
|
||||||
|
<div className="text-xs text-brand-500 mt-1">
|
||||||
|
{bp.rolePresets.length} role preset{bp.rolePresets.length !== 1 ? "s" : ""}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{selectedBp && (
|
||||||
|
<p className="text-xs text-brand-600">
|
||||||
|
Selected: <strong>{selectedBp.name}</strong>
|
||||||
|
{Array.isArray(selectedBp.rolePresets) && selectedBp.rolePresets.length > 0
|
||||||
|
? ` — ${selectedBp.rolePresets.length} role presets will be loaded in Step 3`
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="app-label">
|
||||||
|
Short Code *
|
||||||
|
<InfoTooltip content="Unique chargecode for this project, used for time tracking and cost attribution. Must be uppercase alphanumeric." />
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={state.shortCode}
|
||||||
|
onChange={(e) => onChange({ shortCode: e.target.value.toUpperCase() })}
|
||||||
|
placeholder="e.g. BMW26D"
|
||||||
|
className="app-input"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-400 mt-0.5">Uppercase alphanumeric, max 20 chars</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="app-label">
|
||||||
|
Project Name *
|
||||||
|
<InfoTooltip content="Display name shown on the timeline and in reports." />
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={state.name}
|
||||||
|
onChange={(e) => onChange({ name: e.target.value })}
|
||||||
|
placeholder="e.g. BMW X3 Campaign"
|
||||||
|
className="app-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="app-label">
|
||||||
|
Order Type *
|
||||||
|
<InfoTooltip content="BD = Business Development (pre-sales), CHARGEABLE = billable client work, INTERNAL = internal project, OVERHEAD = non-project overhead." />
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={state.orderType}
|
||||||
|
onChange={(e) => onChange({ orderType: e.target.value })}
|
||||||
|
className="app-select w-full"
|
||||||
|
>
|
||||||
|
{ORDER_TYPE_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="app-label">
|
||||||
|
Allocation Type *
|
||||||
|
<InfoTooltip content="INT = staffed with internal resources, EXT = staffed with external contractors or freelancers." />
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={state.allocationType}
|
||||||
|
onChange={(e) => onChange({ allocationType: e.target.value })}
|
||||||
|
className="app-select w-full"
|
||||||
|
>
|
||||||
|
{ALLOCATION_TYPE_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</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="app-label">
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { DateInput } from "~/components/ui/DateInput.js";
|
||||||
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
|
import { ResourcePersonPicker } from "./ResourcePersonPicker.js";
|
||||||
|
import type { WizardState } from "./types.js";
|
||||||
|
|
||||||
|
interface Step2Props {
|
||||||
|
state: WizardState;
|
||||||
|
onChange: (patch: Partial<WizardState>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Step2Timeline({ state, onChange }: Step2Props) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="app-label">
|
||||||
|
Start Date *
|
||||||
|
<InfoTooltip content="First day of the project period. Assignments and budget tracking begin from this date." />
|
||||||
|
</label>
|
||||||
|
<DateInput value={state.startDate} onChange={(v) => onChange({ startDate: v })} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="app-label">
|
||||||
|
End Date *
|
||||||
|
<InfoTooltip content="Last day of the project period. Defines the timeline boundary for all assignments." />
|
||||||
|
</label>
|
||||||
|
<DateInput value={state.endDate} onChange={(v) => onChange({ endDate: v })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="app-label">
|
||||||
|
Budget (EUR)
|
||||||
|
<InfoTooltip content="Total project budget in EUR. Stored internally as cents. Used to track spending against assignments." />
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={100}
|
||||||
|
value={state.budgetEur}
|
||||||
|
onChange={(e) => onChange({ budgetEur: e.target.value })}
|
||||||
|
placeholder="e.g. 50000"
|
||||||
|
className="app-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="app-label">
|
||||||
|
Responsible Person
|
||||||
|
<InfoTooltip content="Project lead or account manager. Search by name or employee ID." />
|
||||||
|
</label>
|
||||||
|
<ResourcePersonPicker
|
||||||
|
value={state.responsiblePerson}
|
||||||
|
onChange={(v) => onChange({ responsiblePerson: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="app-label">
|
||||||
|
Win Probability: <strong>{state.winProbability}%</strong>
|
||||||
|
<InfoTooltip content="Likelihood of winning this project (0-100%). Affects the weighted pipeline value: budget x probability." />
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={5}
|
||||||
|
value={state.winProbability}
|
||||||
|
onChange={(e) => onChange({ winProbability: parseInt(e.target.value, 10) })}
|
||||||
|
className="w-full accent-brand-600"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-xs text-gray-400 mt-0.5">
|
||||||
|
<span>0% (Unlikely)</span>
|
||||||
|
<span>100% (Confirmed)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import { clsx } from "clsx";
|
||||||
|
import type { StaffingRequirement } from "@capakraken/shared";
|
||||||
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
|
import { SkillTagInput } from "~/components/ui/SkillTagInput.js";
|
||||||
|
import { formatCents } from "~/lib/format.js";
|
||||||
|
import { makeReq, type WizardState } from "./types.js";
|
||||||
|
|
||||||
|
interface Step3Props {
|
||||||
|
state: WizardState;
|
||||||
|
onChange: (patch: Partial<WizardState>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Step3Staffing({ state, onChange }: Step3Props) {
|
||||||
|
const { data: rolesData } = trpc.role.list.useQuery({ isActive: true }, { staleTime: 30_000 });
|
||||||
|
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));
|
||||||
|
onChange({ staffingReqs: next });
|
||||||
|
}
|
||||||
|
|
||||||
|
function addReq() {
|
||||||
|
onChange({ staffingReqs: [...state.staffingReqs, makeReq()] });
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeReq(idx: number) {
|
||||||
|
onChange({ staffingReqs: state.staffingReqs.filter((_, i) => i !== idx) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{state.blueprintId && state.staffingReqs.length > 0 && (
|
||||||
|
<div className="mb-3 px-3 py-2 bg-brand-50 border border-brand-200 rounded-lg text-xs text-brand-700">
|
||||||
|
Blueprint presets auto-loaded. Edit as needed.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Budget allocation summary */}
|
||||||
|
{state.budgetEur &&
|
||||||
|
parseFloat(state.budgetEur) > 0 &&
|
||||||
|
state.staffingReqs.length > 0 &&
|
||||||
|
(() => {
|
||||||
|
const projectBudgetCents = Math.round(parseFloat(state.budgetEur || "0") * 100);
|
||||||
|
const allocatedCents = state.staffingReqs.reduce(
|
||||||
|
(sum, r) => sum + (r.budgetCents ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const remainingCents = projectBudgetCents - allocatedCents;
|
||||||
|
const pct =
|
||||||
|
projectBudgetCents > 0 ? Math.round((allocatedCents / projectBudgetCents) * 100) : 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`mb-3 rounded-lg border p-3 text-xs ${remainingCents < 0 ? "bg-red-50 dark:bg-red-950/30 border-red-200 dark:border-red-800 text-red-700 dark:text-red-400" : remainingCents === 0 ? "bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-800 text-green-700 dark:text-green-400" : "bg-amber-50 dark:bg-amber-950/30 border-amber-200 dark:border-amber-800 text-amber-700 dark:text-amber-300"}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<span className="font-semibold">Budget Allocation</span>
|
||||||
|
<span>{pct}% allocated</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-1.5 bg-gray-200 rounded-full overflow-hidden mb-1.5">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all ${remainingCents < 0 ? "bg-red-500" : remainingCents === 0 ? "bg-green-500" : "bg-amber-500"}`}
|
||||||
|
style={{ width: `${Math.min(100, pct)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Project: {formatCents(projectBudgetCents)} EUR</span>
|
||||||
|
<span>Allocated: {formatCents(allocatedCents)} EUR</span>
|
||||||
|
<span className="font-semibold">Remaining: {formatCents(remainingCents)} EUR</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
<div className="space-y-3 max-h-[45vh] overflow-y-auto pr-1">
|
||||||
|
{state.staffingReqs.length === 0 && (
|
||||||
|
<p className="text-sm text-gray-400 text-center py-8">
|
||||||
|
No requirements yet. Click “+ Add Role” to define staffing needs.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{state.staffingReqs.map((req, idx) => (
|
||||||
|
<div key={req.id} className="border border-gray-200 rounded-lg p-3 bg-white">
|
||||||
|
<div className="flex flex-wrap items-start gap-2">
|
||||||
|
<div className="flex-1 min-w-32">
|
||||||
|
<label className="text-xs text-gray-400">
|
||||||
|
Role *
|
||||||
|
<InfoTooltip content="Select a predefined role or enter a custom role name. Defines the skill profile for this staffing demand." />
|
||||||
|
</label>
|
||||||
|
{roles.length > 0 ? (
|
||||||
|
<select
|
||||||
|
value={req.roleId ?? ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const selectedId = e.target.value;
|
||||||
|
const matched = roles.find((ro) => ro.id === selectedId);
|
||||||
|
if (selectedId && matched) {
|
||||||
|
updateReq(idx, { roleId: matched.id, role: matched.name });
|
||||||
|
} else {
|
||||||
|
const { roleId: _r, ...rest } = state.staffingReqs[idx]!;
|
||||||
|
void _r;
|
||||||
|
onChange({
|
||||||
|
staffingReqs: state.staffingReqs.map((r, i) => (i === idx ? rest : r)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="app-select w-full"
|
||||||
|
>
|
||||||
|
<option value="">Custom / Free text…</option>
|
||||||
|
{roles.map((ro) => (
|
||||||
|
<option key={ro.id} value={ro.id}>
|
||||||
|
{ro.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : null}
|
||||||
|
{!req.roleId && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={req.role}
|
||||||
|
onChange={(e) => updateReq(idx, { role: e.target.value })}
|
||||||
|
placeholder="e.g. 3D Artist"
|
||||||
|
className={clsx("app-input", roles.length > 0 && "mt-1")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="w-20">
|
||||||
|
<label className="text-xs text-gray-400">
|
||||||
|
h/day
|
||||||
|
<InfoTooltip content="Planned working hours per day for this role." />
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={req.hoursPerDay}
|
||||||
|
min={0}
|
||||||
|
max={24}
|
||||||
|
step={0.5}
|
||||||
|
onChange={(e) => updateReq(idx, { hoursPerDay: parseFloat(e.target.value) || 0 })}
|
||||||
|
className="app-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-16">
|
||||||
|
<label className="text-xs text-gray-400">
|
||||||
|
Count
|
||||||
|
<InfoTooltip content="Number of people needed for this role. Unfilled seats become placeholder demands until assigned." />
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={req.headcount}
|
||||||
|
min={1}
|
||||||
|
max={20}
|
||||||
|
onChange={(e) => updateReq(idx, { headcount: parseInt(e.target.value, 10) || 1 })}
|
||||||
|
className="app-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-28">
|
||||||
|
<label className="text-xs text-gray-400">
|
||||||
|
Budget (EUR)
|
||||||
|
<InfoTooltip content="Optional budget cap for this role. Tracked against actual assignment costs." />
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={req.budgetCents ? req.budgetCents / 100 : ""}
|
||||||
|
min={0}
|
||||||
|
step={100}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = parseFloat(e.target.value);
|
||||||
|
updateReq(idx, {
|
||||||
|
budgetCents: Number.isFinite(val) && val > 0 ? Math.round(val * 100) : 0,
|
||||||
|
} as Partial<StaffingRequirement>);
|
||||||
|
}}
|
||||||
|
placeholder="0"
|
||||||
|
className="app-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="pt-5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeReq(idx)}
|
||||||
|
className="app-action-danger-btn"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-2">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400">
|
||||||
|
Required skills
|
||||||
|
<InfoTooltip content="Skills a resource must have to be suggested for this role." />
|
||||||
|
</label>
|
||||||
|
<SkillTagInput
|
||||||
|
value={req.requiredSkills}
|
||||||
|
onChange={(skills) => updateReq(idx, { requiredSkills: skills })}
|
||||||
|
placeholder="Add required skill…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400">
|
||||||
|
Preferred skills (optional)
|
||||||
|
<InfoTooltip content="Nice-to-have skills that boost a resource's match score but are not mandatory." />
|
||||||
|
</label>
|
||||||
|
<SkillTagInput
|
||||||
|
value={req.preferredSkills ?? []}
|
||||||
|
onChange={(skills) => updateReq(idx, { preferredSkills: skills })}
|
||||||
|
placeholder="Add preferred skill…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<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>)
|
||||||
|
}
|
||||||
|
placeholder="e.g. Art Direction"
|
||||||
|
className="app-input"
|
||||||
|
/>
|
||||||
|
{chapters.length > 0 && (
|
||||||
|
<datalist id="chapter-options">
|
||||||
|
{chapters.map((ch) => (
|
||||||
|
<option key={ch} value={ch} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addReq}
|
||||||
|
className="mt-3 flex items-center gap-1.5 text-sm text-brand-600 hover:text-brand-800 font-medium"
|
||||||
|
>
|
||||||
|
<span className="text-lg leading-none">+</span> Add Role
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import { clsx } from "clsx";
|
||||||
|
import type { StaffingRequirement } from "@capakraken/shared";
|
||||||
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
|
import { usePermissions } from "~/hooks/usePermissions.js";
|
||||||
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
|
import type { Assignment, SuggestionItem, WizardState } from "./types.js";
|
||||||
|
|
||||||
|
interface ReqSuggestionsProps {
|
||||||
|
req: StaffingRequirement;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
assignments: Assignment[];
|
||||||
|
onAssign: (resourceId: string, resourceName: string, role: string) => void;
|
||||||
|
onUnassign: (resourceId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReqSuggestions({
|
||||||
|
req,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
assignments,
|
||||||
|
onAssign,
|
||||||
|
onUnassign,
|
||||||
|
}: ReqSuggestionsProps) {
|
||||||
|
const { canViewScores } = usePermissions();
|
||||||
|
const { data, isLoading } = trpc.staffing.getSuggestions.useQuery(
|
||||||
|
{
|
||||||
|
requiredSkills: req.requiredSkills,
|
||||||
|
preferredSkills: req.preferredSkills,
|
||||||
|
startDate: new Date(startDate),
|
||||||
|
endDate: new Date(endDate),
|
||||||
|
hoursPerDay: req.hoursPerDay,
|
||||||
|
...(req.chapter ? { chapter: req.chapter } : {}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: req.requiredSkills.length > 0 && !!startDate && !!endDate,
|
||||||
|
staleTime: 30_000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const reqAssignments = assignments.filter((a) => a.requirementId === req.id);
|
||||||
|
const assignedCount = reqAssignments.length;
|
||||||
|
|
||||||
|
if (!req.requiredSkills.length) {
|
||||||
|
return (
|
||||||
|
<p className="text-xs text-amber-600">
|
||||||
|
No skills defined for this demand yet. Go back to Step 3 and add required skills — the AI
|
||||||
|
will then suggest matching resources here.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <p className="text-xs text-gray-400 animate-pulse">Loading suggestions…</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestions = (data ?? []) as unknown as SuggestionItem[];
|
||||||
|
|
||||||
|
function availBadge(item: SuggestionItem) {
|
||||||
|
if (item.availabilityConflicts.length === 0) {
|
||||||
|
return (
|
||||||
|
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-green-100 text-green-700">
|
||||||
|
Available
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (item.currentUtilization >= 100) {
|
||||||
|
return (
|
||||||
|
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-100 text-red-700">
|
||||||
|
Unavailable
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-100 text-amber-700">
|
||||||
|
Partial
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 mb-2">
|
||||||
|
Assigned: {assignedCount} / {req.headcount} needed
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5 max-h-52 overflow-y-auto">
|
||||||
|
{suggestions.length === 0 && (
|
||||||
|
<p className="text-xs text-gray-400">No matching resources found.</p>
|
||||||
|
)}
|
||||||
|
{suggestions.slice(0, 10).map((item) => {
|
||||||
|
const isAssigned = reqAssignments.some((a) => a.resourceId === item.resourceId);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.resourceId}
|
||||||
|
className={clsx(
|
||||||
|
"flex items-center gap-2 px-2.5 py-1.5 rounded-lg border text-xs",
|
||||||
|
isAssigned
|
||||||
|
? "border-brand-300 bg-brand-50"
|
||||||
|
: "border-gray-200 bg-white hover:border-gray-300",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium text-gray-800 truncate">{item.resourceName}</div>
|
||||||
|
<div className="text-gray-400 font-mono">{item.eid}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||||
|
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-gray-100 text-gray-600">
|
||||||
|
Score {item.score}
|
||||||
|
</span>
|
||||||
|
{canViewScores && item.valueScore != null && (
|
||||||
|
<span
|
||||||
|
className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${
|
||||||
|
item.valueScore >= 70
|
||||||
|
? "bg-green-100 text-green-700"
|
||||||
|
: item.valueScore >= 40
|
||||||
|
? "bg-amber-100 text-amber-700"
|
||||||
|
: "bg-red-100 text-red-700"
|
||||||
|
}`}
|
||||||
|
title="Value Score (price/quality)"
|
||||||
|
>
|
||||||
|
★ {item.valueScore}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-gray-400">{Math.round(item.currentUtilization)}%</span>
|
||||||
|
{availBadge(item)}
|
||||||
|
</div>
|
||||||
|
{isAssigned ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onUnassign(item.resourceId)}
|
||||||
|
className="flex-shrink-0 px-2 py-0.5 text-xs border border-brand-300 text-brand-700 rounded hover:bg-brand-100"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onAssign(item.resourceId, item.resourceName, req.role)}
|
||||||
|
disabled={assignedCount >= req.headcount}
|
||||||
|
className="flex-shrink-0 px-2 py-0.5 text-xs bg-brand-600 text-white rounded hover:bg-brand-700 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Assign
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Step4Props {
|
||||||
|
state: WizardState;
|
||||||
|
onChange: (patch: Partial<WizardState>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Step4Suggestions({ state, onChange }: Step4Props) {
|
||||||
|
function assign(requirementId: string, resourceId: string, resourceName: string, role: string) {
|
||||||
|
onChange({
|
||||||
|
assignments: [...state.assignments, { requirementId, resourceId, resourceName, role }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function unassign(requirementId: string, resourceId: string) {
|
||||||
|
onChange({
|
||||||
|
assignments: state.assignments.filter(
|
||||||
|
(a) => !(a.requirementId === requirementId && a.resourceId === resourceId),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.staffingReqs.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-gray-400 text-center py-8">
|
||||||
|
No staffing requirements defined. Go back to Step 3 to add some.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5 max-h-[55vh] overflow-y-auto pr-1">
|
||||||
|
<p className="text-xs text-gray-500 flex items-center gap-1">
|
||||||
|
AI-powered resource suggestions based on skills, availability, and utilization.
|
||||||
|
<InfoTooltip content="Resources are ranked by skill match score, current utilization, and availability in the project period. Assign resources here or leave unfilled to create placeholder demands." />
|
||||||
|
</p>
|
||||||
|
{state.staffingReqs.map((req) => (
|
||||||
|
<div key={req.id} className="border border-gray-200 rounded-lg p-3">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="font-medium text-sm text-gray-800">{req.role || "Unnamed Role"}</span>
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
{req.hoursPerDay}h/day · {req.headcount} needed
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ReqSuggestions
|
||||||
|
req={req}
|
||||||
|
startDate={state.startDate}
|
||||||
|
endDate={state.endDate}
|
||||||
|
assignments={state.assignments}
|
||||||
|
onAssign={(resourceId, resourceName, role) =>
|
||||||
|
assign(req.id, resourceId, resourceName, role)
|
||||||
|
}
|
||||||
|
onUnassign={(resourceId) => unassign(req.id, resourceId)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { clsx } from "clsx";
|
||||||
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
|
import { formatCents } from "~/lib/format.js";
|
||||||
|
import { BTN_PRIMARY, type WizardState } from "./types.js";
|
||||||
|
|
||||||
|
interface Step5Props {
|
||||||
|
state: WizardState;
|
||||||
|
onChange: (patch: Partial<WizardState>) => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
submitError: string | null;
|
||||||
|
submitWarnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Step5Review({
|
||||||
|
state,
|
||||||
|
onChange,
|
||||||
|
onSubmit,
|
||||||
|
isSubmitting,
|
||||||
|
submitError,
|
||||||
|
submitWarnings,
|
||||||
|
}: Step5Props) {
|
||||||
|
const totalAssignedCostHint = useMemo(() => {
|
||||||
|
return state.staffingReqs.reduce((sum, r) => sum + r.hoursPerDay * r.headcount, 0);
|
||||||
|
}, [state.staffingReqs]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Project summary */}
|
||||||
|
<div className="flex items-center gap-1 mb-1">
|
||||||
|
<p className="text-xs font-medium text-gray-600 uppercase tracking-wider">
|
||||||
|
Project Summary
|
||||||
|
</p>
|
||||||
|
<InfoTooltip content="Review all project details before creation. The project, staffing demands, and any pre-assigned resources will be created together." />
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-50 rounded-lg p-4 text-sm space-y-2">
|
||||||
|
<div className="grid grid-cols-2 gap-x-6 gap-y-1">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Code:</span>{" "}
|
||||||
|
<span className="font-mono font-medium">{state.shortCode || "—"}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Name:</span>{" "}
|
||||||
|
<span className="font-medium">{state.name || "—"}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Order Type:</span> {state.orderType}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Allocation Type:</span> {state.allocationType}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Status on create:</span>{" "}
|
||||||
|
{state.saveAsDraft ? "Draft" : "Active"}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Budget:</span>{" "}
|
||||||
|
{state.budgetEur ? `€${parseFloat(state.budgetEur).toLocaleString()}` : "—"}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Dates:</span> {state.startDate} → {state.endDate}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Win %:</span> {state.winProbability}%
|
||||||
|
</div>
|
||||||
|
{state.responsiblePerson && (
|
||||||
|
<div className="col-span-2">
|
||||||
|
<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 */}
|
||||||
|
{state.staffingReqs.length > 0 && (
|
||||||
|
<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.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="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="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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Post-creation warnings */}
|
||||||
|
{submitWarnings.length > 0 && (
|
||||||
|
<div className="px-3 py-2 bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 rounded-lg text-sm text-amber-800 dark:text-amber-300 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 dark:text-amber-400">
|
||||||
|
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">
|
||||||
|
Save as
|
||||||
|
<InfoTooltip content="Draft projects are hidden from the timeline until activated. Active projects appear on the timeline immediately." />
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="saveMode"
|
||||||
|
checked={state.saveAsDraft}
|
||||||
|
onChange={() => onChange({ saveAsDraft: true })}
|
||||||
|
className="accent-brand-600"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium">Draft</div>
|
||||||
|
<div className="text-xs text-gray-400">Hidden on timeline until enabled</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="saveMode"
|
||||||
|
checked={!state.saveAsDraft}
|
||||||
|
onChange={() => onChange({ saveAsDraft: false })}
|
||||||
|
className="accent-brand-600"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium">Active</div>
|
||||||
|
<div className="text-xs text-gray-400">Visible on timeline immediately</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{submitError && (
|
||||||
|
<div className="px-3 py-2 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||||
|
{submitError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button type="button" onClick={onSubmit} disabled={isSubmitting} className={BTN_PRIMARY}>
|
||||||
|
{isSubmitting
|
||||||
|
? "Creating…"
|
||||||
|
: state.saveAsDraft
|
||||||
|
? "Create Draft Project"
|
||||||
|
: "Create Active Project"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { clsx } from "clsx";
|
||||||
|
import { STEPS } from "./types.js";
|
||||||
|
|
||||||
|
export function StepBar({ current }: { current: number }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-0 mb-6">
|
||||||
|
{STEPS.map((label, idx) => (
|
||||||
|
<div key={idx} className="flex items-center flex-1 min-w-0">
|
||||||
|
<div className="flex flex-col items-center flex-shrink-0">
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"w-7 h-7 rounded-full flex items-center justify-center text-xs font-semibold transition-colors",
|
||||||
|
idx < current
|
||||||
|
? "bg-brand-600 text-white"
|
||||||
|
: idx === current
|
||||||
|
? "bg-brand-600 text-white ring-4 ring-brand-100"
|
||||||
|
: "bg-gray-100 text-gray-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{idx < current ? "✓" : idx + 1}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"mt-1 text-[10px] text-center whitespace-nowrap leading-tight max-w-[72px]",
|
||||||
|
idx === current ? "text-brand-600 font-medium" : "text-gray-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{idx < STEPS.length - 1 && (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"h-0.5 flex-1 mx-1 mt-[-14px]",
|
||||||
|
idx < current ? "bg-brand-400" : "bg-gray-200",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user