b0e55786c3
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>
1217 lines
42 KiB
TypeScript
1217 lines
42 KiB
TypeScript
"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 “+ 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 *</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>
|
||
);
|
||
}
|