chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -0,0 +1,837 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { EstimateStatus } from "@planarchy/shared";
|
||||
import { computeEvenSpread } from "@planarchy/engine";
|
||||
import { isSpreadsheetFile } from "~/lib/excel.js";
|
||||
import { parseScopeImport } from "~/lib/scopeImportParser.js";
|
||||
import { clsx } from "clsx";
|
||||
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
|
||||
import { ProjectCombobox } from "~/components/ui/ProjectCombobox.js";
|
||||
import { ResourceCombobox } from "~/components/ui/ResourceCombobox.js";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
const INPUT_CLS =
|
||||
"w-full rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-100";
|
||||
const SELECT_CLS = INPUT_CLS;
|
||||
const LABEL_CLS = "mb-1.5 block text-xs font-semibold uppercase tracking-wide text-gray-500";
|
||||
const STEP_LABELS = ["Setup", "Assumptions", "Scope", "Staffing", "Review"];
|
||||
|
||||
interface AssumptionRow {
|
||||
id: string;
|
||||
category: string;
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface ScopeRow {
|
||||
id: string;
|
||||
sequenceNo: number;
|
||||
scopeType: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface DemandRow {
|
||||
id: string;
|
||||
name: string;
|
||||
roleId: string | null;
|
||||
resourceId: string | null;
|
||||
hours: string;
|
||||
chapter: string;
|
||||
costRate: string;
|
||||
billRate: string;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
interface ProjectOption {
|
||||
id: string;
|
||||
shortCode: string;
|
||||
name: string;
|
||||
startDate?: string | Date | null;
|
||||
endDate?: string | Date | null;
|
||||
}
|
||||
|
||||
interface RoleOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ResourceOption {
|
||||
id: string;
|
||||
eid: string;
|
||||
displayName: string;
|
||||
chapter: string | null;
|
||||
currency: string;
|
||||
lcrCents: number;
|
||||
ucrCents: number;
|
||||
roleId: string | null;
|
||||
federalState: string | null;
|
||||
}
|
||||
|
||||
function makeAssumption(): AssumptionRow {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
category: "commercial",
|
||||
key: "",
|
||||
label: "",
|
||||
value: "",
|
||||
};
|
||||
}
|
||||
|
||||
function makeScope(sequenceNo = 1): ScopeRow {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
sequenceNo,
|
||||
scopeType: "SHOT",
|
||||
name: "",
|
||||
description: "",
|
||||
};
|
||||
}
|
||||
|
||||
function makeDemand(): DemandRow {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
name: "",
|
||||
roleId: null,
|
||||
resourceId: null,
|
||||
hours: "8",
|
||||
chapter: "",
|
||||
costRate: "",
|
||||
billRate: "",
|
||||
currency: "EUR",
|
||||
};
|
||||
}
|
||||
|
||||
function toCents(value: string) {
|
||||
const parsed = Number.parseFloat(value);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.round(parsed * 100);
|
||||
}
|
||||
|
||||
function toHours(value: string) {
|
||||
const parsed = Number.parseFloat(value);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return 0;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function formatMoney(cents: number, currency = "EUR") {
|
||||
return new Intl.NumberFormat("de-DE", {
|
||||
style: "currency",
|
||||
currency,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(cents / 100);
|
||||
}
|
||||
|
||||
function slugify(value: string) {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "");
|
||||
}
|
||||
|
||||
export function EstimateWizard({ onClose }: { onClose: () => void }) {
|
||||
const [step, setStep] = useState(0);
|
||||
const [name, setName] = useState("");
|
||||
const [projectId, setProjectId] = useState<string | null>(null);
|
||||
const [opportunityId, setOpportunityId] = useState("");
|
||||
const [baseCurrency, setBaseCurrency] = useState("EUR");
|
||||
const [status, setStatus] = useState<EstimateStatus>(EstimateStatus.DRAFT);
|
||||
const [versionLabel, setVersionLabel] = useState("Initial");
|
||||
const [versionNotes, setVersionNotes] = useState("");
|
||||
const [assumptions, setAssumptions] = useState<AssumptionRow[]>([makeAssumption()]);
|
||||
const [scopeItems, setScopeItems] = useState<ScopeRow[]>([makeScope(1)]);
|
||||
const [demandLines, setDemandLines] = useState<DemandRow[]>([makeDemand()]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [scopeImportWarnings, setScopeImportWarnings] = useState<string[]>([]);
|
||||
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
useFocusTrap(panelRef, true);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const projectsQuery = trpc.project.list.useQuery({ limit: 200 }, { staleTime: 60_000 });
|
||||
const rolesQuery = trpc.role.list.useQuery({ isActive: true }, { staleTime: 60_000 });
|
||||
const resourcesQuery = trpc.resource.list.useQuery(
|
||||
{ limit: 500, includeRoles: true, isActive: true },
|
||||
{ staleTime: 60_000 },
|
||||
);
|
||||
|
||||
const createMutation = trpc.estimate.create.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.estimate.list.invalidate();
|
||||
onClose();
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
setError(mutationError.message);
|
||||
},
|
||||
});
|
||||
|
||||
const projectRows = (projectsQuery.data?.projects ?? []) as unknown as ProjectOption[];
|
||||
const roleRows = (rolesQuery.data ?? []) as unknown as RoleOption[];
|
||||
const resourceRows = (resourcesQuery.data?.resources ?? []) as unknown as ResourceOption[];
|
||||
|
||||
const projects: ProjectOption[] = projectRows.map((project) => ({
|
||||
id: project.id,
|
||||
shortCode: project.shortCode,
|
||||
name: project.name,
|
||||
}));
|
||||
const roles: RoleOption[] = roleRows.map((role) => ({
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
}));
|
||||
const resources: ResourceOption[] = resourceRows.map((resource) => ({
|
||||
id: resource.id,
|
||||
eid: resource.eid,
|
||||
displayName: resource.displayName,
|
||||
chapter: resource.chapter,
|
||||
currency: resource.currency,
|
||||
lcrCents: resource.lcrCents,
|
||||
ucrCents: resource.ucrCents,
|
||||
roleId: resource.roleId,
|
||||
federalState: resource.federalState,
|
||||
}));
|
||||
|
||||
const selectedProject = projectId
|
||||
? projects.find((project) => project.id === projectId) ?? null
|
||||
: null;
|
||||
|
||||
const summary = useMemo(() => {
|
||||
return demandLines.reduce(
|
||||
(accumulator, line) => {
|
||||
const hours = toHours(line.hours);
|
||||
const costTotalCents = Math.round(hours * toCents(line.costRate));
|
||||
const priceTotalCents = Math.round(hours * toCents(line.billRate));
|
||||
|
||||
return {
|
||||
totalHours: accumulator.totalHours + hours,
|
||||
totalCostCents: accumulator.totalCostCents + costTotalCents,
|
||||
totalPriceCents: accumulator.totalPriceCents + priceTotalCents,
|
||||
};
|
||||
},
|
||||
{ totalHours: 0, totalCostCents: 0, totalPriceCents: 0 },
|
||||
);
|
||||
}, [demandLines]);
|
||||
|
||||
const marginCents = summary.totalPriceCents - summary.totalCostCents;
|
||||
const marginPercent = summary.totalPriceCents > 0
|
||||
? Math.round((marginCents / summary.totalPriceCents) * 100)
|
||||
: 0;
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
function updateAssumption(id: string, patch: Partial<AssumptionRow>) {
|
||||
setAssumptions((current) =>
|
||||
current.map((row) => (row.id === id ? { ...row, ...patch } : row)),
|
||||
);
|
||||
}
|
||||
|
||||
function updateScopeItem(id: string, patch: Partial<ScopeRow>) {
|
||||
setScopeItems((current) =>
|
||||
current.map((row) => (row.id === id ? { ...row, ...patch } : row)),
|
||||
);
|
||||
}
|
||||
|
||||
function updateDemandLine(id: string, patch: Partial<DemandRow>) {
|
||||
setDemandLines((current) =>
|
||||
current.map((row) => (row.id === id ? { ...row, ...patch } : row)),
|
||||
);
|
||||
}
|
||||
|
||||
function applyResource(resourceId: string | null, demandLineId: string) {
|
||||
const resource = resourceId
|
||||
? resources.find((item) => item.id === resourceId) ?? null
|
||||
: null;
|
||||
|
||||
updateDemandLine(demandLineId, {
|
||||
resourceId,
|
||||
name: resource?.displayName ?? "",
|
||||
chapter: resource?.chapter ?? "",
|
||||
currency: resource?.currency ?? baseCurrency,
|
||||
costRate: resource ? (resource.lcrCents / 100).toFixed(2) : "",
|
||||
billRate: resource ? (resource.ucrCents / 100).toFixed(2) : "",
|
||||
roleId: resource?.roleId ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleScopeImport(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
event.target.value = "";
|
||||
|
||||
if (!isSpreadsheetFile(file)) {
|
||||
setScopeImportWarnings(["Unsupported file type. Please upload .xlsx, .xls, or .csv."]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await parseScopeImport(file);
|
||||
setScopeImportWarnings(result.warnings);
|
||||
|
||||
if (result.rows.length > 0) {
|
||||
const imported: ScopeRow[] = result.rows.map((row) => ({
|
||||
id: crypto.randomUUID(),
|
||||
sequenceNo: row.sequenceNo,
|
||||
scopeType: row.scopeType,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
}));
|
||||
setScopeItems((current) => {
|
||||
const nonEmpty = current.filter((item) => item.name.trim());
|
||||
return [...nonEmpty, ...imported];
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setScopeImportWarnings(["Failed to parse the file. Please check the format."]);
|
||||
}
|
||||
}
|
||||
|
||||
function validateStep(targetStep: number) {
|
||||
if (targetStep === 1 && !name.trim()) {
|
||||
setError("Estimate name is required.");
|
||||
return false;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
return true;
|
||||
}
|
||||
|
||||
function goNext() {
|
||||
const nextStep = Math.min(step + 1, STEP_LABELS.length - 1);
|
||||
if (!validateStep(nextStep)) return;
|
||||
setStep(nextStep);
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
setStep((current) => Math.max(current - 1, 0));
|
||||
}
|
||||
|
||||
function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!name.trim()) {
|
||||
setError("Estimate name is required.");
|
||||
setStep(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedDemandLines = demandLines
|
||||
.map((line, index) => {
|
||||
const resource = line.resourceId
|
||||
? resources.find((item) => item.id === line.resourceId) ?? null
|
||||
: null;
|
||||
const role = line.roleId
|
||||
? roles.find((item) => item.id === line.roleId) ?? null
|
||||
: null;
|
||||
const hours = toHours(line.hours);
|
||||
const costRateCents = toCents(line.costRate);
|
||||
const billRateCents = toCents(line.billRate);
|
||||
const displayName = line.name.trim() || resource?.displayName || role?.name || `Line ${index + 1}`;
|
||||
|
||||
return {
|
||||
resourceId: line.resourceId ?? undefined,
|
||||
roleId: line.roleId ?? undefined,
|
||||
lineType: "LABOR",
|
||||
name: displayName,
|
||||
chapter: line.chapter || resource?.chapter || undefined,
|
||||
hours,
|
||||
days: hours > 0 ? Number((hours / 8).toFixed(2)) : undefined,
|
||||
rateSource: resource ? "RESOURCE" : role ? "ROLE" : "MANUAL",
|
||||
costRateCents,
|
||||
billRateCents,
|
||||
currency: line.currency || resource?.currency || baseCurrency,
|
||||
costTotalCents: Math.round(hours * costRateCents),
|
||||
priceTotalCents: Math.round(hours * billRateCents),
|
||||
monthlySpread:
|
||||
selectedProject?.startDate && selectedProject?.endDate && hours > 0
|
||||
? computeEvenSpread({
|
||||
totalHours: hours,
|
||||
startDate: new Date(selectedProject.startDate),
|
||||
endDate: new Date(selectedProject.endDate),
|
||||
}).spread
|
||||
: {},
|
||||
staffingAttributes: {
|
||||
linkedResource: resource ? true : false,
|
||||
linkedRole: role ? true : false,
|
||||
},
|
||||
metadata: {},
|
||||
};
|
||||
})
|
||||
.filter((line) => line.hours > 0);
|
||||
|
||||
const normalizedScopeItems = scopeItems
|
||||
.map((item, index) => ({
|
||||
sequenceNo: index + 1,
|
||||
scopeType: item.scopeType.trim() || "SHOT",
|
||||
name: item.name.trim(),
|
||||
description: item.description.trim() || undefined,
|
||||
technicalSpec: {},
|
||||
sortOrder: index,
|
||||
metadata: {},
|
||||
}))
|
||||
.filter((item) => item.name.length > 0);
|
||||
|
||||
const normalizedAssumptions = assumptions
|
||||
.map((assumption, index) => ({
|
||||
category: assumption.category.trim() || "general",
|
||||
key: assumption.key.trim() || slugify(assumption.label) || `assumption_${index + 1}`,
|
||||
label: assumption.label.trim(),
|
||||
valueType: "text",
|
||||
value: assumption.value.trim(),
|
||||
sortOrder: index,
|
||||
}))
|
||||
.filter((assumption) => assumption.label.length > 0 && String(assumption.value).length > 0);
|
||||
|
||||
const seenResources = new Set<string>();
|
||||
const resourceSnapshots = normalizedDemandLines.flatMap((line) => {
|
||||
if (!line.resourceId) return [];
|
||||
if (seenResources.has(line.resourceId)) return [];
|
||||
seenResources.add(line.resourceId);
|
||||
|
||||
const resource = resources.find((item) => item.id === line.resourceId) ?? null;
|
||||
if (!resource) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
resourceId: resource.id,
|
||||
sourceEid: resource.eid,
|
||||
displayName: resource.displayName,
|
||||
chapter: resource.chapter ?? undefined,
|
||||
roleId: resource.roleId ?? undefined,
|
||||
currency: resource.currency,
|
||||
lcrCents: resource.lcrCents,
|
||||
ucrCents: resource.ucrCents,
|
||||
location: resource.federalState ?? undefined,
|
||||
attributes: {},
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
createMutation.mutate({
|
||||
projectId: projectId ?? undefined,
|
||||
name: name.trim(),
|
||||
opportunityId: opportunityId.trim() || undefined,
|
||||
baseCurrency,
|
||||
status,
|
||||
versionLabel: versionLabel.trim() || undefined,
|
||||
versionNotes: versionNotes.trim() || undefined,
|
||||
assumptions: normalizedAssumptions,
|
||||
scopeItems: normalizedScopeItems,
|
||||
demandLines: normalizedDemandLines,
|
||||
resourceSnapshots,
|
||||
metrics: [],
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-950/45 p-4">
|
||||
<div ref={panelRef} className="flex max-h-[92vh] w-full max-w-6xl flex-col overflow-hidden rounded-[32px] bg-white shadow-2xl">
|
||||
<div className="border-b border-gray-100 px-6 py-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-brand-600">Estimate Wizard</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-gray-900">Create a connected estimate</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Rates, resource snapshots, and project linkage are pulled from existing Planarchy data.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-full border border-gray-200 px-3 py-2 text-sm text-gray-500 transition hover:border-gray-300 hover:text-gray-700"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-2 md:grid-cols-5">
|
||||
{STEP_LABELS.map((label, index) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (index <= step || validateStep(index)) {
|
||||
setStep(index);
|
||||
}
|
||||
}}
|
||||
className={clsx(
|
||||
"rounded-2xl px-4 py-3 text-left transition",
|
||||
index === step
|
||||
? "bg-brand-600 text-white"
|
||||
: index < step
|
||||
? "bg-brand-50 text-brand-700"
|
||||
: "bg-gray-50 text-gray-400",
|
||||
)}
|
||||
>
|
||||
<span className="block text-xs uppercase tracking-wide">Step {index + 1}</span>
|
||||
<span className="mt-1 block text-sm font-semibold">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="grid min-h-0 flex-1 gap-0 lg:grid-cols-[minmax(0,1.15fr),360px]">
|
||||
<div className="min-h-0 overflow-y-auto px-6 py-6">
|
||||
{step === 0 && (
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<div>
|
||||
<label className={LABEL_CLS}>Estimate Name</label>
|
||||
<input value={name} onChange={(event) => setName(event.target.value)} className={INPUT_CLS} placeholder="CGI Breakdown Q2 2026" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLS}>Linked Project</label>
|
||||
<ProjectCombobox value={projectId} onChange={setProjectId} placeholder="Link to project" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLS}>Opportunity ID</label>
|
||||
<input value={opportunityId} onChange={(event) => setOpportunityId(event.target.value)} className={INPUT_CLS} placeholder="Optional CRM or sales reference" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLS}>Estimate Status</label>
|
||||
<select value={status} onChange={(event) => setStatus(event.target.value as EstimateStatus)} className={SELECT_CLS}>
|
||||
{Object.values(EstimateStatus).map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value.replace("_", " ")}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLS}>Base Currency</label>
|
||||
<input value={baseCurrency} onChange={(event) => setBaseCurrency(event.target.value.toUpperCase())} className={INPUT_CLS} maxLength={3} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLS}>Version Label</label>
|
||||
<input value={versionLabel} onChange={(event) => setVersionLabel(event.target.value)} className={INPUT_CLS} placeholder="Initial" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={LABEL_CLS}>Version Notes</label>
|
||||
<textarea
|
||||
value={versionNotes}
|
||||
onChange={(event) => setVersionNotes(event.target.value)}
|
||||
rows={5}
|
||||
className={INPUT_CLS}
|
||||
placeholder="Document assumptions, exclusions, or client comments."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-3xl border border-gray-100 bg-gray-50 p-5">
|
||||
<p className="text-sm font-semibold text-gray-900">Live connection preview</p>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||
<div className="rounded-2xl bg-white px-4 py-3">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Project source</p>
|
||||
<p className="mt-1 text-sm text-gray-700">
|
||||
{selectedProject ? `${selectedProject.shortCode} - ${selectedProject.name}` : "Not linked yet"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-white px-4 py-3">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Live catalogs</p>
|
||||
<p className="mt-1 text-sm text-gray-700">
|
||||
{roles.length} roles, {resources.length} active resources available
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Commercial and delivery assumptions</h3>
|
||||
<p className="text-sm text-gray-500">These rows replace free-form spreadsheet notes with structured data.</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => setAssumptions((current) => [...current, makeAssumption()])} className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-gray-600 transition hover:border-gray-300 hover:text-gray-900">
|
||||
Add assumption
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{assumptions.map((row) => (
|
||||
<div key={row.id} className="grid gap-3 rounded-2xl border border-gray-100 p-4 md:grid-cols-[140px,1fr,1fr,1.2fr,auto]">
|
||||
<input value={row.category} onChange={(event) => updateAssumption(row.id, { category: event.target.value })} className={INPUT_CLS} placeholder="Category" />
|
||||
<input value={row.label} onChange={(event) => updateAssumption(row.id, { label: event.target.value })} className={INPUT_CLS} placeholder="Label" />
|
||||
<input value={row.key} onChange={(event) => updateAssumption(row.id, { key: event.target.value })} className={INPUT_CLS} placeholder="Key (optional)" />
|
||||
<input value={row.value} onChange={(event) => updateAssumption(row.id, { value: event.target.value })} className={INPUT_CLS} placeholder="Value" />
|
||||
<button type="button" onClick={() => setAssumptions((current) => current.filter((item) => item.id !== row.id))} className="rounded-xl border border-transparent px-3 py-2 text-sm text-red-500 transition hover:border-red-100 hover:bg-red-50">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Scope breakdown</h3>
|
||||
<p className="text-sm text-gray-500">Create structured work packages that can later evolve into versioned estimate scope.</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<label className="cursor-pointer rounded-xl border border-gray-200 px-3 py-2 text-sm text-gray-600 transition hover:border-gray-300 hover:text-gray-900">
|
||||
Import XLSX
|
||||
<input type="file" accept=".xlsx,.xls,.csv" onChange={handleScopeImport} className="hidden" />
|
||||
</label>
|
||||
<button type="button" onClick={() => setScopeItems((current) => [...current, makeScope(current.length + 1)])} className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-gray-600 transition hover:border-gray-300 hover:text-gray-900">
|
||||
Add scope row
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{scopeImportWarnings.length > 0 && (
|
||||
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
|
||||
{scopeImportWarnings.map((warning, index) => (
|
||||
<p key={index}>{warning}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{scopeItems.map((item, index) => (
|
||||
<div key={item.id} className="grid gap-3 rounded-2xl border border-gray-100 p-4 md:grid-cols-[90px,120px,1fr,1.2fr,auto]">
|
||||
<input value={String(index + 1)} readOnly className={clsx(INPUT_CLS, "bg-gray-50 text-gray-500")} />
|
||||
<input value={item.scopeType} onChange={(event) => updateScopeItem(item.id, { scopeType: event.target.value })} className={INPUT_CLS} placeholder="Type" />
|
||||
<input value={item.name} onChange={(event) => updateScopeItem(item.id, { name: event.target.value })} className={INPUT_CLS} placeholder="Name" />
|
||||
<input value={item.description} onChange={(event) => updateScopeItem(item.id, { description: event.target.value })} className={INPUT_CLS} placeholder="Description" />
|
||||
<button type="button" onClick={() => setScopeItems((current) => current.filter((row) => row.id !== item.id))} className="rounded-xl border border-transparent px-3 py-2 text-sm text-red-500 transition hover:border-red-100 hover:bg-red-50">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Staffing and rate lines</h3>
|
||||
<p className="text-sm text-gray-500">Selecting a resource pre-fills cost rate, sell rate, chapter, and role from live data.</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => setDemandLines((current) => [...current, makeDemand()])} className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-gray-600 transition hover:border-gray-300 hover:text-gray-900">
|
||||
Add staffing line
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{demandLines.map((line) => {
|
||||
const resource = line.resourceId
|
||||
? resources.find((item) => item.id === line.resourceId) ?? null
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div key={line.id} className="rounded-3xl border border-gray-100 p-4">
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div>
|
||||
<label className={LABEL_CLS}>Resource</label>
|
||||
<ResourceCombobox value={line.resourceId} onChange={(resourceId) => applyResource(resourceId, line.id)} placeholder="Search resource" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLS}>Role</label>
|
||||
<select value={line.roleId ?? ""} onChange={(event) => updateDemandLine(line.id, { roleId: event.target.value || null })} className={SELECT_CLS}>
|
||||
<option value="">Unassigned</option>
|
||||
{roles.map((role) => (
|
||||
<option key={role.id} value={role.id}>
|
||||
{role.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLS}>Line Name</label>
|
||||
<input value={line.name} onChange={(event) => updateDemandLine(line.id, { name: event.target.value })} className={INPUT_CLS} placeholder="Compositing, lighting, PM, ..." />
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLS}>Chapter</label>
|
||||
<input value={line.chapter} onChange={(event) => updateDemandLine(line.id, { chapter: event.target.value })} className={INPUT_CLS} placeholder="Auto-filled from resource when linked" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLS}>Hours</label>
|
||||
<input value={line.hours} onChange={(event) => updateDemandLine(line.id, { hours: event.target.value })} className={INPUT_CLS} inputMode="decimal" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLS}>Currency</label>
|
||||
<input value={line.currency} onChange={(event) => updateDemandLine(line.id, { currency: event.target.value.toUpperCase() })} className={INPUT_CLS} maxLength={3} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLS}>Cost Rate / h</label>
|
||||
<input value={line.costRate} onChange={(event) => updateDemandLine(line.id, { costRate: event.target.value })} className={INPUT_CLS} inputMode="decimal" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLS}>Sell Rate / h</label>
|
||||
<input value={line.billRate} onChange={(event) => updateDemandLine(line.id, { billRate: event.target.value })} className={INPUT_CLS} inputMode="decimal" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 rounded-2xl bg-gray-50 px-4 py-3">
|
||||
<div className="text-sm text-gray-600">
|
||||
{resource ? `Linked to ${resource.displayName} (${resource.eid})` : "Manual line"}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4 text-sm">
|
||||
<span className="font-medium text-gray-700">
|
||||
Cost {formatMoney(Math.round(toHours(line.hours) * toCents(line.costRate)), line.currency)}
|
||||
</span>
|
||||
<span className="font-medium text-gray-700">
|
||||
Price {formatMoney(Math.round(toHours(line.hours) * toCents(line.billRate)), line.currency)}
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" onClick={() => setDemandLines((current) => current.filter((item) => item.id !== line.id))} className="rounded-xl border border-transparent px-3 py-2 text-sm text-red-500 transition hover:border-red-100 hover:bg-red-50">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 4 && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Review</h3>
|
||||
<p className="text-sm text-gray-500">The summary metrics below are recalculated from the demand rows and persisted on create.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-3xl border border-gray-100 bg-gray-50 px-5 py-4">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Total Hours</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-gray-900">{summary.totalHours.toFixed(1)}</p>
|
||||
</div>
|
||||
<div className="rounded-3xl border border-gray-100 bg-gray-50 px-5 py-4">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Total Cost</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-gray-900">{formatMoney(summary.totalCostCents, baseCurrency)}</p>
|
||||
</div>
|
||||
<div className="rounded-3xl border border-gray-100 bg-gray-50 px-5 py-4">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Total Price</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-gray-900">{formatMoney(summary.totalPriceCents, baseCurrency)}</p>
|
||||
</div>
|
||||
<div className="rounded-3xl border border-gray-100 bg-gray-50 px-5 py-4">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Margin</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-gray-900">
|
||||
{formatMoney(marginCents, baseCurrency)} ({marginPercent}%)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
<div className="rounded-3xl border border-gray-100 p-5">
|
||||
<p className="text-sm font-semibold text-gray-900">Estimate envelope</p>
|
||||
<dl className="mt-4 space-y-2 text-sm text-gray-600">
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt>Name</dt>
|
||||
<dd className="text-right text-gray-900">{name || "Untitled"}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt>Project</dt>
|
||||
<dd className="text-right text-gray-900">{selectedProject ? `${selectedProject.shortCode}` : "Standalone"}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt>Status</dt>
|
||||
<dd className="text-right text-gray-900">{status.replace("_", " ")}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt>Version</dt>
|
||||
<dd className="text-right text-gray-900">{versionLabel || "Initial"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="rounded-3xl border border-gray-100 p-5">
|
||||
<p className="text-sm font-semibold text-gray-900">Connected records</p>
|
||||
<dl className="mt-4 space-y-2 text-sm text-gray-600">
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt>Assumptions</dt>
|
||||
<dd className="text-right text-gray-900">{assumptions.filter((row) => row.label.trim()).length}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt>Scope items</dt>
|
||||
<dd className="text-right text-gray-900">{scopeItems.filter((row) => row.name.trim()).length}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt>Demand lines</dt>
|
||||
<dd className="text-right text-gray-900">{demandLines.filter((row) => toHours(row.hours) > 0).length}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt>Resource snapshots</dt>
|
||||
<dd className="text-right text-gray-900">{new Set(demandLines.map((row) => row.resourceId).filter(Boolean)).size}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<aside className="border-t border-gray-100 bg-gray-50 px-6 py-6 lg:border-l lg:border-t-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-gray-500">Dynamic summary</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="rounded-2xl bg-white px-4 py-3">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Project link</p>
|
||||
<p className="mt-1 text-sm text-gray-800">
|
||||
{selectedProject ? `${selectedProject.shortCode} - ${selectedProject.name}` : "No linked project"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-white px-4 py-3">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Resource-linked demand</p>
|
||||
<p className="mt-1 text-sm text-gray-800">
|
||||
{demandLines.filter((line) => line.resourceId).length} of {demandLines.length} rows tied to live resources
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-white px-4 py-3">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Calculated totals</p>
|
||||
<p className="mt-1 text-sm text-gray-800">{summary.totalHours.toFixed(1)} h</p>
|
||||
<p className="mt-1 text-sm text-gray-800">{formatMoney(summary.totalCostCents, baseCurrency)} cost</p>
|
||||
<p className="mt-1 text-sm text-gray-800">{formatMoney(summary.totalPriceCents, baseCurrency)} price</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-gray-100 px-6 py-4">
|
||||
<button type="button" onClick={step === 0 ? onClose : goBack} className="rounded-xl border border-gray-200 px-4 py-2 text-sm font-medium text-gray-600 transition hover:border-gray-300 hover:text-gray-900">
|
||||
{step === 0 ? "Cancel" : "Back"}
|
||||
</button>
|
||||
{step < STEP_LABELS.length - 1 ? (
|
||||
<button type="button" onClick={goNext} className="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700">
|
||||
Next
|
||||
</button>
|
||||
) : (
|
||||
<button type="submit" disabled={createMutation.isPending} className="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-60">
|
||||
{createMutation.isPending ? "Creating..." : "Create Estimate"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user