Files
CapaKraken/apps/web/src/components/projects/ProjectWizard.tsx
T
Hartmut 093e13b88f feat: project cover art with AI generation, branding rename, RBAC fix, computation graph
- Add DALL-E cover art generation for projects (Azure OpenAI + standard OpenAI)
- CoverArtSection component with generate/upload/remove/focus-point controls
- Client-side image compression (10MB input → WebP/JPEG, max 1920px)
- DALL-E settings in admin panel (deployment, endpoint, API key)
- MCP assistant tools for cover art (generate_project_cover, remove_project_cover)
- Rename "Planarchy" → "plANARCHY" across all UI-facing text (13 files)
- Fix hardcoded canEdit={true} on project detail page — now checks user role
- Computation graph visualization (2D/3D) for calculation rules
- OG image and OpenGraph metadata

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-18 11:31:56 +01:00

1228 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { clsx } from "clsx";
import type { StaffingRequirement } from "@planarchy/shared";
import { BlueprintTarget, OrderType, AllocationType, ProjectStatus, AllocationStatus } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
import { uuid } from "~/lib/uuid.js";
import { DateInput } from "~/components/ui/DateInput.js";
import { SkillTagInput } from "~/components/ui/SkillTagInput.js";
import { usePermissions } from "~/hooks/usePermissions.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
// ─── Constants ────────────────────────────────────────────────────────────────
const ORDER_TYPE_OPTIONS = [
{ value: "BD", label: "BD" },
{ value: "CHARGEABLE", label: "Chargeable" },
{ value: "INTERNAL", label: "Internal" },
{ value: "OVERHEAD", label: "Overhead" },
] as const;
const ALLOCATION_TYPE_OPTIONS = [
{ value: "INT", label: "INT" },
{ value: "EXT", label: "EXT" },
] as const;
const INPUT_CLS =
"px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm w-full";
const SELECT_CLS =
"px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm w-full bg-white";
const LABEL_CLS = "block text-xs font-medium text-gray-600 mb-1";
const BTN_PRIMARY =
"px-5 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50 transition-colors";
const BTN_SECONDARY =
"px-5 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 text-sm font-medium transition-colors";
const BTN_DANGER =
"px-2 py-1 text-red-500 hover:text-red-700 hover:bg-red-50 rounded text-sm transition-colors";
// ─── Types ────────────────────────────────────────────────────────────────────
interface Assignment {
requirementId: string;
resourceId: string;
resourceName: string;
role: string;
}
interface WizardState {
blueprintId: string | null;
shortCode: string;
name: string;
orderType: string;
allocationType: string;
startDate: string;
endDate: string;
budgetEur: string;
winProbability: number;
responsiblePerson: string;
staffingReqs: StaffingRequirement[];
assignments: Assignment[];
saveAsDraft: boolean;
}
function formatDateForInput(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
function makeDefaultState(): WizardState {
const today = formatDateForInput(new Date());
return {
blueprintId: null,
shortCode: "",
name: "",
orderType: "CHARGEABLE",
allocationType: "INT",
startDate: today,
endDate: today,
budgetEur: "",
winProbability: 100,
responsiblePerson: "",
staffingReqs: [],
assignments: [],
saveAsDraft: true,
};
}
function makeReq(): StaffingRequirement {
return {
id: uuid(),
role: "",
requiredSkills: [],
preferredSkills: [],
hoursPerDay: 8,
headcount: 1,
};
}
// ─── Step indicators ─────────────────────────────────────────────────────────
const STEPS = [
"Blueprint & Identity",
"Timeline & Budget",
"Staffing Demand",
"Suggestions",
"Review & Create",
];
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>
);
}
// ─── Step 1: Blueprint & Identity ────────────────────────────────────────────
interface Step1Props {
state: WizardState;
onChange: (patch: Partial<WizardState>) => void;
}
function Step1({ state, onChange }: Step1Props) {
const { data: blueprints } = trpc.blueprint.list.useQuery(
{ 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 };
const selectedBp = blueprints?.find((b) => b.id === state.blueprintId);
function selectBlueprint(id: string | null) {
if (!id) {
onChange({ blueprintId: null });
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 });
}
return (
<div className="space-y-5">
{/* Blueprint picker */}
<div>
<label className={LABEL_CLS}>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">
{/* Short code */}
<div>
<label className={LABEL_CLS}>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={INPUT_CLS}
/>
<p className="text-xs text-gray-400 mt-0.5">Uppercase alphanumeric, max 20 chars</p>
</div>
{/* Name */}
<div>
<label className={LABEL_CLS}>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={INPUT_CLS}
/>
</div>
{/* Order type */}
<div>
<label className={LABEL_CLS}>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={SELECT_CLS}
>
{ORDER_TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{/* Allocation type */}
<div>
<label className={LABEL_CLS}>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={SELECT_CLS}
>
{ALLOCATION_TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
</div>
);
}
// ─── Responsible Person Picker ────────────────────────────────────────────────
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 containerRef = useRef<HTMLDivElement>(null);
// Debounce search query to avoid excessive API calls
useEffect(() => {
const timer = setTimeout(() => setDebouncedSearch(query), 200);
return () => clearTimeout(timer);
}, [query]);
// Server-side search — no client-side limit, searches full database
const { data } = trpc.resource.list.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],
);
// Sync local query when external value changes (e.g. wizard reset)
useEffect(() => {
setQuery(value);
}, [value]);
// Close on outside click
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
return (
<div ref={containerRef} className="relative">
<input
type="text"
value={query}
onChange={(e) => {
setQuery(e.target.value);
onChange(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
placeholder="Search by name or EID…"
className={INPUT_CLS}
/>
{open && filtered.length > 0 && (
<ul
className="absolute z-50 left-0 right-0 mt-1 bg-white border border-gray-200 rounded-lg shadow-lg overflow-hidden max-h-48 overflow-y-auto"
onMouseDown={(e) => e.preventDefault()}
>
{filtered.map((r) => (
<li key={r.id}>
<button
type="button"
onMouseDown={() => {
onChange(r.displayName);
setQuery(r.displayName);
setOpen(false);
}}
className="w-full text-left px-3 py-2 text-sm flex items-baseline gap-2 hover:bg-gray-50 transition-colors"
>
<span className="truncate">{r.displayName}</span>
<span className="text-xs text-gray-400 font-mono shrink-0">{r.eid}</span>
</button>
</li>
))}
</ul>
)}
</div>
);
}
// ─── Step 2: Timeline & Budget ────────────────────────────────────────────────
interface Step2Props {
state: WizardState;
onChange: (patch: Partial<WizardState>) => void;
}
function Step2({ state, onChange }: Step2Props) {
return (
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className={LABEL_CLS}>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={LABEL_CLS}>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={LABEL_CLS}>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={INPUT_CLS}
/>
</div>
<div>
<label className={LABEL_CLS}>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={LABEL_CLS}>
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>
);
}
// ─── Step 3: Staffing Demand ─────────────────────────────────────────────────
interface Step3Props {
state: WizardState;
onChange: (patch: Partial<WizardState>) => void;
}
function Step3({ state, onChange }: Step3Props) {
const { data: rolesData } = trpc.role.list.useQuery(
{ isActive: true },
{ staleTime: 30_000 },
);
const roles = rolesData ?? [];
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 border-red-200 text-red-700" : remainingCents === 0 ? "bg-green-50 border-green-200 text-green-700" : "bg-amber-50 border-amber-200 text-amber-700"}`}>
<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: {(projectBudgetCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR</span>
<span>Allocated: {(allocatedCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR</span>
<span className="font-semibold">Remaining: {(remainingCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} 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 &ldquo;+ Add Role&rdquo; 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 {
// Clear roleId — rebuild without the key
const { roleId: _r, ...rest } = state.staffingReqs[idx]!;
void _r;
onChange({ staffingReqs: state.staffingReqs.map((r, i) => i === idx ? rest : r) });
}
}}
className={SELECT_CLS}
>
<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(INPUT_CLS, 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={INPUT_CLS}
/>
</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={INPUT_CLS}
/>
</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={INPUT_CLS}
/>
</div>
<div className="pt-5">
<button
type="button"
onClick={() => removeReq(idx)}
className={BTN_DANGER}
>
×
</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"
value={req.chapter ?? ""}
onChange={(e) =>
updateReq(idx, { chapter: e.target.value || undefined } as Partial<StaffingRequirement>)
}
placeholder="e.g. Art Direction"
className={INPUT_CLS}
/>
</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>
);
}
// ─── Step 4: Suggestions ─────────────────────────────────────────────────────
// Matches StaffingSuggestion from @planarchy/shared (returned by staffing.getSuggestions)
type SuggestionItem = {
resourceId: string;
resourceName: string;
eid: string;
score: number;
currentUtilization: number;
availabilityConflicts: string[];
estimatedDailyCostCents: number;
valueScore?: number;
};
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">Add required skills in Step 3 to see suggestions.</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;
}
function Step4({ 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>
);
}
// ─── Step 5: Review & Create ─────────────────────────────────────────────────
interface Step5Props {
state: WizardState;
onChange: (patch: Partial<WizardState>) => void;
onSubmit: () => void;
isSubmitting: boolean;
submitError: string | null;
}
function Step5({ state, onChange, onSubmit, isSubmitting, submitError }: Step5Props) {
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">Type:</span> {state.orderType} / {state.allocationType}
</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>
)}
</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
</p>
<div className="space-y-1">
{state.staffingReqs.map((req) => {
const assigned = state.assignments.filter((a) => a.requirementId === req.id);
return (
<div
key={req.id}
className="flex items-center gap-2 text-sm px-3 py-1.5 rounded-lg bg-gray-50"
>
<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>
);
})}
</div>
</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>
);
}
// ─── Wizard shell ─────────────────────────────────────────────────────────────
interface ProjectWizardProps {
open: boolean;
onClose: () => void;
}
export function ProjectWizard({ open, onClose }: ProjectWizardProps) {
const utils = trpc.useUtils();
const [step, setStep] = useState(0);
const [state, setState] = useState<WizardState>(makeDefaultState);
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const createProject = trpc.project.create.useMutation();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const createAssignment = (trpc.allocation.createAssignment.useMutation as any)() as {
mutateAsync: (input: unknown) => Promise<unknown>;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const createDemandRequirement = (trpc.allocation.createDemandRequirement.useMutation as any)() as {
mutateAsync: (input: unknown) => Promise<unknown>;
};
const patch = useCallback((p: Partial<WizardState>) => {
setState((prev) => ({ ...prev, ...p }));
}, []);
function reset() {
setStep(0);
setState(makeDefaultState());
setIsSubmitting(false);
setSubmitError(null);
}
function handleClose() {
reset();
onClose();
}
function canGoNext(): boolean {
if (step === 0) {
return (
state.shortCode.trim().length > 0 &&
/^[A-Z0-9_-]+$/.test(state.shortCode) &&
state.name.trim().length > 0
);
}
if (step === 1) {
return (
!!state.startDate &&
!!state.endDate &&
state.endDate >= state.startDate &&
(state.budgetEur === "" || parseFloat(state.budgetEur) >= 0)
);
}
return true;
}
async function handleSubmit() {
setIsSubmitting(true);
setSubmitError(null);
try {
const project = await createProject.mutateAsync({
shortCode: state.shortCode.trim(),
name: state.name.trim(),
orderType: state.orderType as unknown as OrderType,
allocationType: state.allocationType as unknown as AllocationType,
winProbability: state.winProbability,
budgetCents: state.budgetEur ? Math.round(parseFloat(state.budgetEur) * 100) : 0,
startDate: new Date(state.startDate),
endDate: new Date(state.endDate),
staffingReqs: state.staffingReqs,
status: state.saveAsDraft ? ProjectStatus.DRAFT : ProjectStatus.ACTIVE,
responsiblePerson: state.responsiblePerson.trim() || undefined,
blueprintId: state.blueprintId ?? undefined,
dynamicFields: {},
});
// Create draft assignments for assigned resources
for (const assignment of state.assignments) {
try {
const req = state.staffingReqs.find((r) => r.id === assignment.requirementId);
const hoursPerDay = req?.hoursPerDay ?? 8;
const percentage = Math.min(100, Math.round((hoursPerDay / 8) * 100));
await createAssignment.mutateAsync({
projectId: project.id,
resourceId: assignment.resourceId,
startDate: new Date(state.startDate),
endDate: new Date(state.endDate),
hoursPerDay,
percentage,
role: assignment.role,
roleId: req?.roleId,
status: AllocationStatus.PROPOSED,
metadata: {},
});
} catch {
// Non-fatal — skip duplicate allocations
}
}
// Create open demand for unassigned slots
for (const req of state.staffingReqs) {
const assignedCount = state.assignments.filter((a) => a.requirementId === req.id).length;
const unassigned = req.headcount - assignedCount;
if (unassigned <= 0) continue;
try {
await createDemandRequirement.mutateAsync({
projectId: project.id,
startDate: new Date(state.startDate),
endDate: new Date(state.endDate),
hoursPerDay: req.hoursPerDay,
percentage: Math.min(100, Math.round((req.hoursPerDay / 8) * 100)),
role: req.role || undefined,
roleId: req.roleId,
headcount: unassigned,
status: AllocationStatus.PROPOSED,
metadata: {},
});
} catch {
// Non-fatal
}
}
await utils.project.list.invalidate();
await utils.timeline.getEntries.invalidate();
await utils.timeline.getEntriesView.invalidate();
handleClose();
} catch (err) {
setSubmitError(err instanceof Error ? err.message : "Failed to create project");
} finally {
setIsSubmitting(false);
}
}
if (!open) return null;
function handleBackdropClick(e: React.MouseEvent<HTMLDivElement>) {
if (e.target === e.currentTarget) handleClose();
}
return (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8 px-4"
onClick={handleBackdropClick}
>
<div className="bg-white rounded-xl shadow-2xl w-full max-w-2xl">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">New Project Wizard</h2>
<button
type="button"
onClick={handleClose}
className="text-gray-400 hover:text-gray-600 text-2xl leading-none"
aria-label="Close"
>
×
</button>
</div>
{/* Body */}
<div className="px-6 py-5">
<StepBar current={step} />
{step === 0 && <Step1 state={state} onChange={patch} />}
{step === 1 && <Step2 state={state} onChange={patch} />}
{step === 2 && <Step3 state={state} onChange={patch} />}
{step === 3 && <Step4 state={state} onChange={patch} />}
{step === 4 && (
<Step5
state={state}
onChange={patch}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
submitError={submitError}
/>
)}
</div>
{/* Footer nav */}
{step < 4 && (
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200">
<button
type="button"
onClick={step === 0 ? handleClose : () => setStep((s) => s - 1)}
className={BTN_SECONDARY}
>
{step === 0 ? "Cancel" : "← Back"}
</button>
<button
type="button"
onClick={() => setStep((s) => s + 1)}
disabled={!canGoNext()}
className={BTN_PRIMARY}
>
{step === 3 ? "Review →" : "Next →"}
</button>
</div>
)}
{step === 4 && (
<div className="flex items-center px-6 py-4 border-t border-gray-200">
<button
type="button"
onClick={() => setStep((s) => s - 1)}
className={BTN_SECONDARY}
>
Back
</button>
</div>
)}
</div>
</div>
);
}