Files
CapaKraken/apps/web/src/components/projects/ProjectWizard.tsx
T
Hartmut b0e55786c3 feat: AI assistant (HartBOT), demand filling, budget-per-role, project favorites, and UX improvements
AI Assistant (HartBOT):
- Chat panel with inline layout, session persistence, message history (up-arrow recall)
- OpenAI function calling with 20+ tools (search, navigate, create/cancel allocations, update status)
- RBAC-aware tool filtering, fuzzy search with word-level matching
- Navigation actions (router.push) and data invalidation after mutations
- Country/metro city/org unit/role filtering on resource search

Demand Filling Enhancements:
- Two-phase fill modal: plan multiple resources, then confirm & assign all at once
- Availability preview per resource (available/partial/conflict days, existing bookings)
- Coverage bar showing demand hours distribution across assigned resources
- Fill demand from project detail page (new Assign button per demand)
- Fixed: filled demands no longer shown on timeline, demand bars no longer overlap

Budget per Role:
- DemandRequirement.budgetCents field (schema + API + UI)
- Project wizard step 3: budget input per role with allocation summary bar
- Project detail: allocated vs booked budget per demand
- Fill demand modal: role budget display with cost estimates
- AllocationModal: budget field for demand editing

Project Favorites:
- User.favoriteProjectIds (JSONB) with toggle API
- Star button on projects list and detail page (optimistic updates)
- "My Projects" dashboard widget (favorites + responsible person projects)

Project Management:
- Edit project from detail page (ProjectModal integration)
- Edit demands from detail page (AllocationModal integration)
- Admin-only project deletion (cascades assignments + demands)
- Create user accounts from admin panel

Timeline Fixes:
- Country multi-select filter with backend support
- URL param sync for same-page navigation (AI assistant integration)
- Demand lane stacking (no more overlapping bars)
- Single-day booking resize handles (always visible, min 6px)
- Single-day resize allowed (start === end)
- "All Clients" toggle (select all / deselect all)

Other Fixes:
- crypto.randomUUID fallback for non-secure contexts
- Chat message limit raised (200 max, client sends last 40)
- Status dropdown portal (no longer clipped by table overflow)
- Cents display restored in budget views (2 decimal places)
- Allocations grouped view with project sub-groups (collapsed by default)
- Server-side resource search for project wizard (no 500 limit)

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-16 15:31:48 +01:00

1217 lines
42 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";
// ─── 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)</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 *</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 *</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 *</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 *</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 *</label>
<DateInput
value={state.startDate}
onChange={(v) => onChange({ startDate: v })}
/>
</div>
<div>
<label className={LABEL_CLS}>End Date *</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) *</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</label>
<ResourcePersonPicker
value={state.responsiblePerson}
onChange={(v) => onChange({ responsiblePerson: v })}
/>
</div>
</div>
<div>
<label className={LABEL_CLS}>
Win Probability: <strong>{state.winProbability}%</strong>
</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 *</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</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</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)</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</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)</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)</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">
{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="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">
Save as
</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>
);
}