chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { clsx } from "clsx";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
interface ApplyEffortRulesProps {
|
||||
estimateId: string;
|
||||
canEdit: boolean;
|
||||
onApplied?: () => void;
|
||||
}
|
||||
|
||||
export function ApplyEffortRules({ estimateId, canEdit, onApplied }: ApplyEffortRulesProps) {
|
||||
const utils = trpc.useUtils();
|
||||
const { data: ruleSets, isLoading } = trpc.effortRule.list.useQuery();
|
||||
|
||||
const [selectedRuleSetId, setSelectedRuleSetId] = useState<string>("");
|
||||
const [mode, setMode] = useState<"replace" | "append">("replace");
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
|
||||
const previewQuery = trpc.effortRule.preview.useQuery(
|
||||
{ estimateId, ruleSetId: selectedRuleSetId },
|
||||
{ enabled: showPreview && Boolean(selectedRuleSetId) },
|
||||
);
|
||||
|
||||
const applyMutation = trpc.effortRule.apply.useMutation({
|
||||
onSuccess: (result) => {
|
||||
utils.estimate.getById.invalidate();
|
||||
setShowPreview(false);
|
||||
onApplied?.();
|
||||
if (result.warnings.length > 0) {
|
||||
alert(`Generated ${result.linesGenerated} demand lines.\n\nWarnings:\n${result.warnings.join("\n")}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Auto-select default rule set
|
||||
if (!selectedRuleSetId && ruleSets) {
|
||||
const defaultSet = ruleSets.find((rs) => rs.isDefault) ?? ruleSets[0];
|
||||
if (defaultSet) {
|
||||
setSelectedRuleSetId(defaultSet.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (!canEdit) return null;
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="text-sm text-gray-400">Loading effort rules...</p>;
|
||||
}
|
||||
|
||||
if (!ruleSets || ruleSets.length === 0) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed border-gray-300 bg-gray-50 p-4 text-center text-sm text-gray-500">
|
||||
No effort rule sets defined.{" "}
|
||||
<a href="/admin/effort-rules" className="text-brand-600 hover:underline">
|
||||
Create one
|
||||
</a>{" "}
|
||||
to auto-generate demand lines from scope items.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<h3 className="mb-3 text-base font-semibold text-gray-900">Generate demand lines from scope</h3>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Rule set</span>
|
||||
<select
|
||||
value={selectedRuleSetId}
|
||||
onChange={(e) => {
|
||||
setSelectedRuleSetId(e.target.value);
|
||||
setShowPreview(false);
|
||||
}}
|
||||
className="rounded-2xl border border-gray-300 bg-gray-50 px-3 py-2 text-sm text-gray-900"
|
||||
>
|
||||
{ruleSets.map((rs) => (
|
||||
<option key={rs.id} value={rs.id}>
|
||||
{rs.name} ({rs.rules.length} rules){rs.isDefault ? " *" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Mode</span>
|
||||
<select
|
||||
value={mode}
|
||||
onChange={(e) => setMode(e.target.value as "replace" | "append")}
|
||||
className="rounded-2xl border border-gray-300 bg-gray-50 px-3 py-2 text-sm text-gray-900"
|
||||
>
|
||||
<option value="replace">Replace existing lines</option>
|
||||
<option value="append">Append to existing</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
disabled={!selectedRuleSetId}
|
||||
className="rounded-2xl border border-gray-300 px-4 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
{showPreview ? "Hide preview" : "Preview"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!selectedRuleSetId) return;
|
||||
const action = mode === "replace" ? "replace all existing demand lines" : "append new demand lines";
|
||||
if (confirm(`This will ${action}. Continue?`)) {
|
||||
applyMutation.mutate({ estimateId, ruleSetId: selectedRuleSetId, mode });
|
||||
}
|
||||
}}
|
||||
disabled={!selectedRuleSetId || applyMutation.isPending}
|
||||
className="rounded-2xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
|
||||
>
|
||||
{applyMutation.isPending ? "Generating..." : "Apply rules"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{applyMutation.error && (
|
||||
<p className="mt-2 text-sm text-red-600">{applyMutation.error.message}</p>
|
||||
)}
|
||||
|
||||
{/* Preview */}
|
||||
{showPreview && previewQuery.data && (
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="flex flex-wrap gap-4 text-sm text-gray-600">
|
||||
<span>{previewQuery.data.scopeItemCount} scope items</span>
|
||||
<span>{previewQuery.data.ruleCount} rules</span>
|
||||
<span className="font-semibold text-brand-700">{previewQuery.data.lines.length} demand lines would be generated</span>
|
||||
{previewQuery.data.unmatchedScopeItems.length > 0 && (
|
||||
<span className="text-amber-600">{previewQuery.data.unmatchedScopeItems.length} unmatched scope items</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{previewQuery.data.warnings.length > 0 && (
|
||||
<div className="rounded-xl bg-amber-50 p-3 text-sm text-amber-700">
|
||||
{previewQuery.data.warnings.map((w, i) => (
|
||||
<p key={i}>{w}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Aggregated discipline summary */}
|
||||
{previewQuery.data.aggregated.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
|
||||
<th className="py-2 pr-3 font-medium">Discipline</th>
|
||||
<th className="px-3 py-2 font-medium">Chapter</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Total hours</th>
|
||||
<th className="pl-3 py-2 text-right font-medium">Lines</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{previewQuery.data.aggregated.map((agg, i) => (
|
||||
<tr key={i} className="border-b border-gray-100">
|
||||
<td className="py-1.5 pr-3 font-medium text-gray-900">{agg.discipline}</td>
|
||||
<td className="px-3 py-1.5 text-gray-500">{agg.chapter ?? "\u2014"}</td>
|
||||
<td className="px-3 py-1.5 text-right tabular-nums text-gray-700">{agg.totalHours.toFixed(1)} h</td>
|
||||
<td className="pl-3 py-1.5 text-right tabular-nums text-gray-500">{agg.lineCount}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detailed lines (collapsible) */}
|
||||
{previewQuery.data.lines.length > 0 && (
|
||||
<details className="text-sm">
|
||||
<summary className="cursor-pointer text-gray-500 hover:text-gray-700">
|
||||
Show all {previewQuery.data.lines.length} generated lines
|
||||
</summary>
|
||||
<div className="mt-2 overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
|
||||
<th className="py-2 pr-3 font-medium">Scope item</th>
|
||||
<th className="px-3 py-2 font-medium">Discipline</th>
|
||||
<th className="px-3 py-2 font-medium">Chapter</th>
|
||||
<th className="px-3 py-2 font-medium">Mode</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Units</th>
|
||||
<th className="px-3 py-2 text-right font-medium">h/unit</th>
|
||||
<th className="pl-3 py-2 text-right font-medium">Hours</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{previewQuery.data.lines.map((line, i) => (
|
||||
<tr key={i} className={clsx("border-b border-gray-100", i % 2 === 0 ? "" : "bg-gray-50")}>
|
||||
<td className="py-1.5 pr-3 text-gray-900">{line.scopeItemName}</td>
|
||||
<td className="px-3 py-1.5 text-gray-700">{line.discipline}</td>
|
||||
<td className="px-3 py-1.5 text-gray-500">{line.chapter ?? "\u2014"}</td>
|
||||
<td className="px-3 py-1.5 text-gray-500">{line.unitMode}</td>
|
||||
<td className="px-3 py-1.5 text-right tabular-nums text-gray-600">{line.unitCount}</td>
|
||||
<td className="px-3 py-1.5 text-right tabular-nums text-gray-600">{line.hoursPerUnit}</td>
|
||||
<td className="pl-3 py-1.5 text-right tabular-nums font-medium text-gray-900">{line.hours.toFixed(1)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPreview && previewQuery.isLoading && (
|
||||
<p className="mt-3 text-sm text-gray-400">Computing preview...</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { clsx } from "clsx";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
interface ApplyExperienceMultipliersProps {
|
||||
estimateId: string;
|
||||
canEdit: boolean;
|
||||
onApplied?: () => void;
|
||||
}
|
||||
|
||||
function formatCents(cents: number): string {
|
||||
return (cents / 100).toLocaleString("de-DE", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
|
||||
export function ApplyExperienceMultipliers({ estimateId, canEdit, onApplied }: ApplyExperienceMultipliersProps) {
|
||||
const utils = trpc.useUtils();
|
||||
const { data: sets, isLoading } = trpc.experienceMultiplier.list.useQuery();
|
||||
|
||||
const [selectedSetId, setSelectedSetId] = useState<string>("");
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
|
||||
const previewQuery = trpc.experienceMultiplier.preview.useQuery(
|
||||
{ estimateId, multiplierSetId: selectedSetId },
|
||||
{ enabled: showPreview && Boolean(selectedSetId) },
|
||||
);
|
||||
|
||||
const applyMutation = trpc.experienceMultiplier.apply.useMutation({
|
||||
onSuccess: (result) => {
|
||||
utils.estimate.getById.invalidate();
|
||||
setShowPreview(false);
|
||||
onApplied?.();
|
||||
alert(
|
||||
`Updated ${result.linesUpdated} demand line(s).\n` +
|
||||
`Hours: ${result.totalOriginalHours}h -> ${result.totalAdjustedHours}h`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// Auto-select default set
|
||||
if (!selectedSetId && sets) {
|
||||
const defaultSet = sets.find((s) => s.isDefault) ?? sets[0];
|
||||
if (defaultSet) {
|
||||
setSelectedSetId(defaultSet.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (!canEdit) return null;
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="text-sm text-gray-400">Loading experience multipliers...</p>;
|
||||
}
|
||||
|
||||
if (!sets || sets.length === 0) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed border-gray-300 bg-gray-50 p-4 text-center text-sm text-gray-500">
|
||||
No experience multiplier sets defined.{" "}
|
||||
<a href="/admin/experience-multipliers" className="text-brand-600 hover:underline">
|
||||
Create one
|
||||
</a>{" "}
|
||||
to apply rate and effort adjustments.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<h3 className="mb-3 text-base font-semibold text-gray-900">Apply experience multipliers</h3>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Multiplier set</span>
|
||||
<select
|
||||
value={selectedSetId}
|
||||
onChange={(e) => {
|
||||
setSelectedSetId(e.target.value);
|
||||
setShowPreview(false);
|
||||
}}
|
||||
className="rounded-2xl border border-gray-300 bg-gray-50 px-3 py-2 text-sm text-gray-900"
|
||||
>
|
||||
{sets.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name} ({s.rules.length} rules){s.isDefault ? " *" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
disabled={!selectedSetId}
|
||||
className="rounded-2xl border border-gray-300 px-4 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
{showPreview ? "Hide preview" : "Preview"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!selectedSetId) return;
|
||||
if (confirm("This will update cost/bill rates and hours on matching demand lines. Continue?")) {
|
||||
applyMutation.mutate({ estimateId, multiplierSetId: selectedSetId });
|
||||
}
|
||||
}}
|
||||
disabled={!selectedSetId || applyMutation.isPending}
|
||||
className="rounded-2xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
|
||||
>
|
||||
{applyMutation.isPending ? "Applying..." : "Apply multipliers"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{applyMutation.error && (
|
||||
<p className="mt-2 text-sm text-red-600">{applyMutation.error.message}</p>
|
||||
)}
|
||||
|
||||
{/* Preview */}
|
||||
{showPreview && previewQuery.data && (
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="flex flex-wrap gap-4 text-sm text-gray-600">
|
||||
<span>{previewQuery.data.demandLineCount} demand lines</span>
|
||||
<span>{previewQuery.data.ruleCount} rules</span>
|
||||
<span className="font-semibold text-brand-700">
|
||||
{previewQuery.data.linesChanged} line(s) would be adjusted
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{previewQuery.data.linesChanged > 0 && (
|
||||
<div className="rounded-xl bg-blue-50 p-3 text-sm text-blue-700">
|
||||
Total cost: {formatCents(previewQuery.data.totalOriginalCostCents)} {"->"}{" "}
|
||||
{formatCents(previewQuery.data.totalAdjustedCostCents)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Per-line preview */}
|
||||
{previewQuery.data.previews.length > 0 && (
|
||||
<details className="text-sm">
|
||||
<summary className="cursor-pointer text-gray-500 hover:text-gray-700">
|
||||
Show all {previewQuery.data.previews.length} lines
|
||||
</summary>
|
||||
<div className="mt-2 overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
|
||||
<th className="py-2 pr-3 font-medium">Name</th>
|
||||
<th className="px-3 py-2 font-medium">Chapter</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Cost rate</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Bill rate</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Hours</th>
|
||||
<th className="pl-3 py-2 font-medium">Changes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{previewQuery.data.previews.map((p, i) => (
|
||||
<tr
|
||||
key={p.demandLineId}
|
||||
className={clsx(
|
||||
"border-b border-gray-100",
|
||||
p.hasChanges ? "bg-amber-50" : i % 2 === 0 ? "" : "bg-gray-50",
|
||||
)}
|
||||
>
|
||||
<td className="py-1.5 pr-3 text-gray-900">{p.name}</td>
|
||||
<td className="px-3 py-1.5 text-gray-500">{p.chapter ?? "\u2014"}</td>
|
||||
<td className="px-3 py-1.5 text-right tabular-nums text-gray-700">
|
||||
{p.hasChanges && p.adjustedCostRateCents !== p.originalCostRateCents ? (
|
||||
<>
|
||||
<span className="line-through text-gray-400">{formatCents(p.originalCostRateCents)}</span>{" "}
|
||||
{formatCents(p.adjustedCostRateCents)}
|
||||
</>
|
||||
) : (
|
||||
formatCents(p.originalCostRateCents)
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right tabular-nums text-gray-700">
|
||||
{p.hasChanges && p.adjustedBillRateCents !== p.originalBillRateCents ? (
|
||||
<>
|
||||
<span className="line-through text-gray-400">{formatCents(p.originalBillRateCents)}</span>{" "}
|
||||
{formatCents(p.adjustedBillRateCents)}
|
||||
</>
|
||||
) : (
|
||||
formatCents(p.originalBillRateCents)
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right tabular-nums text-gray-700">
|
||||
{p.hasChanges && p.adjustedHours !== p.originalHours ? (
|
||||
<>
|
||||
<span className="line-through text-gray-400">{p.originalHours}h</span>{" "}
|
||||
{p.adjustedHours}h
|
||||
</>
|
||||
) : (
|
||||
`${p.originalHours}h`
|
||||
)}
|
||||
</td>
|
||||
<td className="pl-3 py-1.5 text-xs text-gray-500">
|
||||
{p.hasChanges ? p.appliedRules[0] ?? "" : "No change"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPreview && previewQuery.isLoading && (
|
||||
<p className="mt-3 text-sm text-gray-400">Computing preview...</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import type {
|
||||
EstimateDemandLineCalculationMetadata,
|
||||
EstimateDemandLineMetadata,
|
||||
EstimateDemandLineRateMode,
|
||||
} from "@planarchy/shared";
|
||||
|
||||
interface ResourceRateSnapshotLike {
|
||||
lcrCents: number;
|
||||
ucrCents: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
function parseRateMode(value: unknown): EstimateDemandLineRateMode | undefined {
|
||||
return value === "resource" || value === "manual" ? value : undefined;
|
||||
}
|
||||
|
||||
export function parseDemandLineMetadata(
|
||||
metadata: Record<string, unknown> | null | undefined,
|
||||
): EstimateDemandLineMetadata {
|
||||
if (typeof metadata !== "object" || metadata === null || Array.isArray(metadata)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return metadata as EstimateDemandLineMetadata;
|
||||
}
|
||||
|
||||
export function resolveDemandLineCalculationMetadata(options: {
|
||||
resourceSnapshot?: ResourceRateSnapshotLike | null | undefined;
|
||||
metadata?: Record<string, unknown> | null | undefined;
|
||||
costRateCents: number;
|
||||
billRateCents: number;
|
||||
}): EstimateDemandLineCalculationMetadata {
|
||||
const resourceSnapshot = options.resourceSnapshot;
|
||||
const parsedMetadata = parseDemandLineMetadata(options.metadata);
|
||||
const calculation =
|
||||
typeof parsedMetadata.calculation === "object" &&
|
||||
parsedMetadata.calculation !== null
|
||||
? parsedMetadata.calculation
|
||||
: undefined;
|
||||
const costRateMode =
|
||||
parseRateMode(calculation?.costRateMode) ??
|
||||
(resourceSnapshot && options.costRateCents === resourceSnapshot.lcrCents
|
||||
? "resource"
|
||||
: "manual");
|
||||
const billRateMode =
|
||||
parseRateMode(calculation?.billRateMode) ??
|
||||
(resourceSnapshot && options.billRateCents === resourceSnapshot.ucrCents
|
||||
? "resource"
|
||||
: "manual");
|
||||
|
||||
return {
|
||||
costRateMode,
|
||||
billRateMode,
|
||||
totalMode: "computed",
|
||||
liveCostRateCents: resourceSnapshot?.lcrCents ?? null,
|
||||
liveBillRateCents: resourceSnapshot?.ucrCents ?? null,
|
||||
liveCurrency: resourceSnapshot?.currency ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDemandLineMetadata(
|
||||
metadata: Record<string, unknown> | null | undefined,
|
||||
calculation: EstimateDemandLineCalculationMetadata,
|
||||
): EstimateDemandLineMetadata {
|
||||
return {
|
||||
...parseDemandLineMetadata(metadata),
|
||||
calculation,
|
||||
};
|
||||
}
|
||||
|
||||
export function getEffectiveDemandLineValues(options: {
|
||||
resourceSnapshot?: ResourceRateSnapshotLike | null | undefined;
|
||||
hours: number;
|
||||
currency?: string | null;
|
||||
defaultCurrency: string;
|
||||
costRateCents: number;
|
||||
billRateCents: number;
|
||||
costRateMode: EstimateDemandLineRateMode;
|
||||
billRateMode: EstimateDemandLineRateMode;
|
||||
}) {
|
||||
const effectiveCostRateCents =
|
||||
options.costRateMode === "resource" && options.resourceSnapshot
|
||||
? options.resourceSnapshot.lcrCents
|
||||
: options.costRateCents;
|
||||
const effectiveBillRateCents =
|
||||
options.billRateMode === "resource" && options.resourceSnapshot
|
||||
? options.resourceSnapshot.ucrCents
|
||||
: options.billRateCents;
|
||||
const currency =
|
||||
((options.costRateMode === "resource" || options.billRateMode === "resource") &&
|
||||
options.resourceSnapshot?.currency
|
||||
? options.resourceSnapshot.currency
|
||||
: options.currency) ||
|
||||
options.resourceSnapshot?.currency ||
|
||||
options.defaultCurrency;
|
||||
|
||||
return {
|
||||
effectiveCostRateCents,
|
||||
effectiveBillRateCents,
|
||||
currency,
|
||||
costTotalCents: Math.round(options.hours * effectiveCostRateCents),
|
||||
priceTotalCents: Math.round(options.hours * effectiveBillRateCents),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import type {
|
||||
EstimateDemandLineMetadata,
|
||||
EstimateExportArtifactPayload,
|
||||
EstimateExportFormat,
|
||||
EstimateStatus,
|
||||
EstimateVersionStatus,
|
||||
} from "@planarchy/shared";
|
||||
|
||||
export interface EstimateMetricView {
|
||||
id: string;
|
||||
key: string;
|
||||
label: string;
|
||||
valueDecimal: number;
|
||||
valueCents?: number | null;
|
||||
currency?: string | null;
|
||||
}
|
||||
|
||||
export interface EstimateAssumptionView {
|
||||
id: string;
|
||||
category: string;
|
||||
key: string;
|
||||
label: string;
|
||||
valueType: string;
|
||||
value: unknown;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
export interface EstimateScopeItemView {
|
||||
id: string;
|
||||
sequenceNo: number;
|
||||
scopeType: string;
|
||||
packageCode?: string | null;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
frameCount?: number | null;
|
||||
itemCount?: number | null;
|
||||
unitMode?: string | null;
|
||||
}
|
||||
|
||||
export interface EstimateDemandLineView {
|
||||
id: string;
|
||||
scopeItemId?: string | null;
|
||||
roleId?: string | null;
|
||||
resourceId?: string | null;
|
||||
lineType: string;
|
||||
name: string;
|
||||
chapter?: string | null;
|
||||
rateSource?: string | null;
|
||||
hours: number;
|
||||
currency: string;
|
||||
costRateCents: number;
|
||||
billRateCents: number;
|
||||
costTotalCents: number;
|
||||
priceTotalCents: number;
|
||||
monthlySpread?: Record<string, number>;
|
||||
metadata: EstimateDemandLineMetadata;
|
||||
}
|
||||
|
||||
export interface EstimateResourceSnapshotView {
|
||||
id: string;
|
||||
resourceId?: string | null;
|
||||
sourceEid?: string | null;
|
||||
displayName: string;
|
||||
chapter?: string | null;
|
||||
roleId?: string | null;
|
||||
currency: string;
|
||||
lcrCents: number;
|
||||
ucrCents: number;
|
||||
fte?: number | null;
|
||||
location?: string | null;
|
||||
country?: string | null;
|
||||
level?: string | null;
|
||||
workType?: string | null;
|
||||
attributes: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface EstimateExportView {
|
||||
id: string;
|
||||
fileName: string;
|
||||
format: EstimateExportFormat;
|
||||
payload?: EstimateExportArtifactPayload | Record<string, unknown> | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface EstimateVersionView {
|
||||
id: string;
|
||||
versionNumber: number;
|
||||
label?: string | null;
|
||||
status: EstimateVersionStatus;
|
||||
notes?: string | null;
|
||||
lockedAt?: Date | null;
|
||||
updatedAt: Date;
|
||||
assumptions: EstimateAssumptionView[];
|
||||
scopeItems: EstimateScopeItemView[];
|
||||
demandLines: EstimateDemandLineView[];
|
||||
resourceSnapshots: EstimateResourceSnapshotView[];
|
||||
metrics: EstimateMetricView[];
|
||||
exports: EstimateExportView[];
|
||||
}
|
||||
|
||||
export interface EstimateWorkspaceView {
|
||||
id: string;
|
||||
name: string;
|
||||
status: EstimateStatus;
|
||||
projectId?: string | null;
|
||||
opportunityId?: string | null;
|
||||
baseCurrency: string;
|
||||
updatedAt: Date;
|
||||
project?: {
|
||||
id: string;
|
||||
name: string;
|
||||
shortCode: string;
|
||||
startDate?: string | Date | null;
|
||||
endDate?: string | Date | null;
|
||||
} | null;
|
||||
versions: EstimateVersionView[];
|
||||
}
|
||||
|
||||
export type WorkspaceTab =
|
||||
| "overview"
|
||||
| "assumptions"
|
||||
| "scope"
|
||||
| "staffing"
|
||||
| "financials"
|
||||
| "phasing"
|
||||
| "versions"
|
||||
| "exports";
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,490 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { clsx } from "clsx";
|
||||
import {
|
||||
compareEstimateVersions,
|
||||
type VersionCompareInput,
|
||||
type ChapterSubtotal,
|
||||
type ResourceSnapshotDiff,
|
||||
type ScopeItemDiff,
|
||||
} from "@planarchy/engine";
|
||||
import type { EstimateVersionView } from "~/components/estimates/EstimateWorkspace.types.js";
|
||||
|
||||
function formatMoney(cents: number, currency = "EUR") {
|
||||
return new Intl.NumberFormat("de-DE", {
|
||||
style: "currency",
|
||||
currency,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(cents / 100);
|
||||
}
|
||||
|
||||
function formatDelta(value: number, formatter: (v: number) => string) {
|
||||
const prefix = value > 0 ? "+" : "";
|
||||
return `${prefix}${formatter(value)}`;
|
||||
}
|
||||
|
||||
function formatHoursDelta(delta: number) {
|
||||
const prefix = delta > 0 ? "+" : "";
|
||||
return `${prefix}${delta.toFixed(1)} h`;
|
||||
}
|
||||
|
||||
function versionToInput(v: EstimateVersionView): VersionCompareInput {
|
||||
return {
|
||||
label: v.label ?? null,
|
||||
versionNumber: v.versionNumber,
|
||||
demandLines: v.demandLines.map((l) => ({
|
||||
id: l.id,
|
||||
name: l.name,
|
||||
hours: l.hours,
|
||||
costRateCents: l.costRateCents,
|
||||
billRateCents: l.billRateCents,
|
||||
costTotalCents: l.costTotalCents,
|
||||
priceTotalCents: l.priceTotalCents,
|
||||
...(l.chapter !== undefined ? { chapter: l.chapter } : {}),
|
||||
lineType: l.lineType,
|
||||
})),
|
||||
assumptions: v.assumptions.map((a) => ({
|
||||
key: a.key,
|
||||
label: a.label,
|
||||
value: a.value,
|
||||
})),
|
||||
scopeItems: v.scopeItems.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
sequenceNo: s.sequenceNo,
|
||||
scopeType: s.scopeType,
|
||||
...(s.packageCode !== undefined ? { packageCode: s.packageCode } : {}),
|
||||
...(s.description !== undefined ? { description: s.description } : {}),
|
||||
...(s.frameCount !== undefined ? { frameCount: s.frameCount } : {}),
|
||||
...(s.itemCount !== undefined ? { itemCount: s.itemCount } : {}),
|
||||
})),
|
||||
resourceSnapshots: v.resourceSnapshots.map((r) => ({
|
||||
id: r.id,
|
||||
...(r.resourceId !== undefined ? { resourceId: r.resourceId } : {}),
|
||||
displayName: r.displayName,
|
||||
...(r.chapter !== undefined ? { chapter: r.chapter } : {}),
|
||||
currency: r.currency,
|
||||
lcrCents: r.lcrCents,
|
||||
ucrCents: r.ucrCents,
|
||||
...(r.location !== undefined ? { location: r.location } : {}),
|
||||
...(r.level !== undefined ? { level: r.level } : {}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const STATUS_ROW_STYLES = {
|
||||
added: "bg-emerald-50",
|
||||
removed: "bg-red-50",
|
||||
changed: "bg-amber-50",
|
||||
unchanged: "",
|
||||
} as const;
|
||||
|
||||
const STATUS_BADGE_STYLES = {
|
||||
added: "bg-emerald-100 text-emerald-700",
|
||||
removed: "bg-red-100 text-red-700",
|
||||
changed: "bg-amber-100 text-amber-700",
|
||||
unchanged: "bg-gray-100 text-gray-500",
|
||||
} as const;
|
||||
|
||||
export function VersionCompare({ versions }: { versions: EstimateVersionView[] }) {
|
||||
const sorted = useMemo(
|
||||
() => [...versions].sort((a, b) => b.versionNumber - a.versionNumber),
|
||||
[versions],
|
||||
);
|
||||
|
||||
const [aId, setAId] = useState<string>(sorted[1]?.id ?? sorted[0]?.id ?? "");
|
||||
const [bId, setBId] = useState<string>(sorted[0]?.id ?? "");
|
||||
const [hideUnchanged, setHideUnchanged] = useState(false);
|
||||
|
||||
const versionA = sorted.find((v) => v.id === aId);
|
||||
const versionB = sorted.find((v) => v.id === bId);
|
||||
|
||||
const diff = useMemo(() => {
|
||||
if (!versionA || !versionB || versionA.id === versionB.id) return null;
|
||||
return compareEstimateVersions(versionToInput(versionA), versionToInput(versionB));
|
||||
}, [versionA, versionB]);
|
||||
|
||||
if (sorted.length < 2) {
|
||||
return (
|
||||
<div className="rounded-3xl border border-gray-200 bg-gray-50 p-8 text-center text-sm text-gray-500">
|
||||
At least two versions are required to compare.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const filteredDemandDiffs = diff
|
||||
? hideUnchanged
|
||||
? diff.demandLineDiffs.filter((d) => d.status !== "unchanged")
|
||||
: diff.demandLineDiffs
|
||||
: [];
|
||||
|
||||
const filteredAssumptionDiffs = diff
|
||||
? hideUnchanged
|
||||
? diff.assumptionDiffs.filter((d) => d.status !== "unchanged")
|
||||
: diff.assumptionDiffs
|
||||
: [];
|
||||
|
||||
const filteredScopeDiffs = diff
|
||||
? hideUnchanged
|
||||
? diff.scopeItemDiffs.filter((d) => d.status !== "unchanged")
|
||||
: diff.scopeItemDiffs
|
||||
: [];
|
||||
|
||||
const filteredResourceDiffs = diff
|
||||
? hideUnchanged
|
||||
? diff.resourceSnapshotDiffs.filter((d) => d.status !== "unchanged")
|
||||
: diff.resourceSnapshotDiffs
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Version selectors */}
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<h3 className="mb-4 text-base font-semibold text-gray-900">Compare versions</h3>
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Base (A)</span>
|
||||
<select
|
||||
value={aId}
|
||||
onChange={(e) => setAId(e.target.value)}
|
||||
className="rounded-2xl border border-gray-300 bg-gray-50 px-3 py-2 text-sm text-gray-900 focus:border-brand-400 focus:outline-none focus:ring-1 focus:ring-brand-400"
|
||||
>
|
||||
{sorted.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
v{v.versionNumber} {v.label ? `\u2014 ${v.label}` : ""} ({v.status})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<span className="pb-2 text-sm text-gray-400">vs</span>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Compare (B)</span>
|
||||
<select
|
||||
value={bId}
|
||||
onChange={(e) => setBId(e.target.value)}
|
||||
className="rounded-2xl border border-gray-300 bg-gray-50 px-3 py-2 text-sm text-gray-900 focus:border-brand-400 focus:outline-none focus:ring-1 focus:ring-brand-400"
|
||||
>
|
||||
{sorted.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
v{v.versionNumber} {v.label ? `\u2014 ${v.label}` : ""} ({v.status})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 pb-2 text-sm text-gray-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hideUnchanged}
|
||||
onChange={(e) => setHideUnchanged(e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
Hide unchanged
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{aId === bId && (
|
||||
<p className="mt-3 text-sm text-amber-600">Select two different versions to compare.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{diff && (
|
||||
<>
|
||||
{/* Summary cards */}
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4 lg:grid-cols-8">
|
||||
<SummaryCard
|
||||
label="Hours"
|
||||
value={formatHoursDelta(diff.summary.totalHoursDelta)}
|
||||
positive={diff.summary.totalHoursDelta <= 0}
|
||||
/>
|
||||
<SummaryCard
|
||||
label="Cost"
|
||||
value={formatDelta(diff.summary.totalCostDelta, (v) => formatMoney(v))}
|
||||
positive={diff.summary.totalCostDelta <= 0}
|
||||
/>
|
||||
<SummaryCard
|
||||
label="Price"
|
||||
value={formatDelta(diff.summary.totalPriceDelta, (v) => formatMoney(v))}
|
||||
positive={diff.summary.totalPriceDelta >= 0}
|
||||
/>
|
||||
<SummaryCard
|
||||
label="Margin"
|
||||
value={`${diff.summary.marginPercentB.toFixed(1)}% (${diff.summary.marginPercentDelta >= 0 ? "+" : ""}${diff.summary.marginPercentDelta.toFixed(1)}pp)`}
|
||||
positive={diff.summary.marginPercentDelta >= 0}
|
||||
/>
|
||||
<SummaryCard label="Lines +" value={`+${diff.summary.linesAdded}`} positive />
|
||||
<SummaryCard label="Lines -" value={`-${diff.summary.linesRemoved}`} positive={diff.summary.linesRemoved === 0} />
|
||||
<SummaryCard label="Lines ~" value={String(diff.summary.linesChanged)} positive={diff.summary.linesChanged === 0} />
|
||||
<SummaryCard label="Resources ~" value={String(diff.summary.resourceSnapshotsChanged)} positive={diff.summary.resourceSnapshotsChanged === 0} />
|
||||
</div>
|
||||
|
||||
{/* Demand line diffs */}
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<h3 className="mb-3 text-base font-semibold text-gray-900">Demand lines</h3>
|
||||
{filteredDemandDiffs.length === 0 ? (
|
||||
<p className="py-4 text-center text-sm text-gray-400">
|
||||
{hideUnchanged ? "No changes in demand lines." : "No demand lines to compare."}
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
|
||||
<th className="py-2 pr-3 font-medium">Name</th>
|
||||
<th className="px-3 py-2 font-medium">Status</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Hours (A)</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Hours (B)</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Hours delta</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Cost (A)</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Cost (B)</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Cost delta</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Price (A)</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Price (B)</th>
|
||||
<th className="pl-3 py-2 text-right font-medium">Price delta</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredDemandDiffs.map((d, i) => (
|
||||
<tr key={i} className={clsx("border-b border-gray-100", STATUS_ROW_STYLES[d.status])}>
|
||||
<td className="py-2 pr-3 font-medium text-gray-900">{d.name}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={clsx("rounded-full px-2 py-0.5 text-xs font-medium", STATUS_BADGE_STYLES[d.status])}>
|
||||
{d.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.a?.hours.toFixed(1) ?? "\u2014"}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.b?.hours.toFixed(1) ?? "\u2014"}</td>
|
||||
<td className={clsx("px-3 py-2 text-right tabular-nums", deltaColor(d.hoursDelta))}>
|
||||
{d.hoursDelta != null ? formatHoursDelta(d.hoursDelta) : "\u2014"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
|
||||
{d.a ? formatMoney(d.a.costTotalCents) : "\u2014"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
|
||||
{d.b ? formatMoney(d.b.costTotalCents) : "\u2014"}
|
||||
</td>
|
||||
<td className={clsx("px-3 py-2 text-right tabular-nums", deltaColor(d.costDelta))}>
|
||||
{d.costDelta != null ? formatDelta(d.costDelta, (v) => formatMoney(v)) : "\u2014"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
|
||||
{d.a ? formatMoney(d.a.priceTotalCents) : "\u2014"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
|
||||
{d.b ? formatMoney(d.b.priceTotalCents) : "\u2014"}
|
||||
</td>
|
||||
<td className={clsx("pl-3 py-2 text-right tabular-nums", deltaColor(d.priceDelta))}>
|
||||
{d.priceDelta != null ? formatDelta(d.priceDelta, (v) => formatMoney(v)) : "\u2014"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Assumption diffs */}
|
||||
{(filteredAssumptionDiffs.length > 0 || !hideUnchanged) && (
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<h3 className="mb-3 text-base font-semibold text-gray-900">Assumptions</h3>
|
||||
{filteredAssumptionDiffs.length === 0 ? (
|
||||
<p className="py-4 text-center text-sm text-gray-400">
|
||||
{hideUnchanged ? "No changes in assumptions." : "No assumptions to compare."}
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
|
||||
<th className="py-2 pr-3 font-medium">Assumption</th>
|
||||
<th className="px-3 py-2 font-medium">Status</th>
|
||||
<th className="px-3 py-2 font-medium">Value (A)</th>
|
||||
<th className="pl-3 py-2 font-medium">Value (B)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredAssumptionDiffs.map((d) => (
|
||||
<tr key={d.key} className={clsx("border-b border-gray-100", STATUS_ROW_STYLES[d.status])}>
|
||||
<td className="py-2 pr-3 font-medium text-gray-900">{d.label}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={clsx("rounded-full px-2 py-0.5 text-xs font-medium", STATUS_BADGE_STYLES[d.status])}>
|
||||
{d.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-700">{formatAssumptionValue(d.aValue)}</td>
|
||||
<td className="pl-3 py-2 text-gray-700">{formatAssumptionValue(d.bValue)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chapter subtotals */}
|
||||
{diff.chapterSubtotals.length > 0 && (
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<h3 className="mb-3 text-base font-semibold text-gray-900">By chapter</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
|
||||
<th className="py-2 pr-3 font-medium">Chapter</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Hours (A)</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Hours (B)</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Hours delta</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Cost (A)</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Cost (B)</th>
|
||||
<th className="pl-3 py-2 text-right font-medium">Cost delta</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{diff.chapterSubtotals.map((ch) => (
|
||||
<tr key={ch.chapter} className={clsx("border-b border-gray-100", ch.costDelta !== 0 ? "bg-amber-50" : "")}>
|
||||
<td className="py-2 pr-3 font-medium text-gray-900">{ch.chapter}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{ch.hoursA.toFixed(1)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{ch.hoursB.toFixed(1)}</td>
|
||||
<td className={clsx("px-3 py-2 text-right tabular-nums", deltaColor(ch.hoursDelta))}>
|
||||
{formatHoursDelta(ch.hoursDelta)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{formatMoney(ch.costA)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{formatMoney(ch.costB)}</td>
|
||||
<td className={clsx("pl-3 py-2 text-right tabular-nums", deltaColor(ch.costDelta))}>
|
||||
{formatDelta(ch.costDelta, (v) => formatMoney(v))}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scope item diffs */}
|
||||
{filteredScopeDiffs.length > 0 && (
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<h3 className="mb-3 text-base font-semibold text-gray-900">
|
||||
Scope items
|
||||
{(diff.summary.scopeItemsAdded > 0 || diff.summary.scopeItemsRemoved > 0 || diff.summary.scopeItemsChanged > 0) && (
|
||||
<span className="ml-2 text-sm font-normal text-gray-500">
|
||||
{diff.summary.scopeItemsAdded > 0 && <span className="text-emerald-600">+{diff.summary.scopeItemsAdded}</span>}
|
||||
{diff.summary.scopeItemsRemoved > 0 && <span className="ml-2 text-red-600">-{diff.summary.scopeItemsRemoved}</span>}
|
||||
{diff.summary.scopeItemsChanged > 0 && <span className="ml-2 text-amber-600">~{diff.summary.scopeItemsChanged}</span>}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
|
||||
<th className="py-2 pr-3 font-medium">Name</th>
|
||||
<th className="px-3 py-2 font-medium">Type</th>
|
||||
<th className="px-3 py-2 font-medium">Status</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Frames (A)</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Frames (B)</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Items (A)</th>
|
||||
<th className="pl-3 py-2 text-right font-medium">Items (B)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredScopeDiffs.map((d, i) => (
|
||||
<tr key={i} className={clsx("border-b border-gray-100", STATUS_ROW_STYLES[d.status])}>
|
||||
<td className="py-2 pr-3 font-medium text-gray-900">{d.name}</td>
|
||||
<td className="px-3 py-2 text-gray-600">{d.scopeType}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={clsx("rounded-full px-2 py-0.5 text-xs font-medium", STATUS_BADGE_STYLES[d.status])}>
|
||||
{d.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.a?.frameCount ?? "\u2014"}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.b?.frameCount ?? "\u2014"}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.a?.itemCount ?? "\u2014"}</td>
|
||||
<td className="pl-3 py-2 text-right tabular-nums text-gray-700">{d.b?.itemCount ?? "\u2014"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Resource snapshot diffs */}
|
||||
{filteredResourceDiffs.length > 0 && (
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<h3 className="mb-3 text-base font-semibold text-gray-900">Resource rates</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
|
||||
<th className="py-2 pr-3 font-medium">Name</th>
|
||||
<th className="px-3 py-2 font-medium">Status</th>
|
||||
<th className="px-3 py-2 text-right font-medium">LCR (A)</th>
|
||||
<th className="px-3 py-2 text-right font-medium">LCR (B)</th>
|
||||
<th className="px-3 py-2 text-right font-medium">UCR (A)</th>
|
||||
<th className="px-3 py-2 text-right font-medium">UCR (B)</th>
|
||||
<th className="px-3 py-2 font-medium">Location (A)</th>
|
||||
<th className="pl-3 py-2 font-medium">Location (B)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredResourceDiffs.map((d, i) => (
|
||||
<tr key={i} className={clsx("border-b border-gray-100", STATUS_ROW_STYLES[d.status])}>
|
||||
<td className="py-2 pr-3 font-medium text-gray-900">{d.displayName}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={clsx("rounded-full px-2 py-0.5 text-xs font-medium", STATUS_BADGE_STYLES[d.status])}>
|
||||
{d.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.a ? formatMoney(d.a.lcrCents) : "\u2014"}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.b ? formatMoney(d.b.lcrCents) : "\u2014"}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.a ? formatMoney(d.a.ucrCents) : "\u2014"}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.b ? formatMoney(d.b.ucrCents) : "\u2014"}</td>
|
||||
<td className="px-3 py-2 text-gray-600">{d.a?.location ?? "\u2014"}</td>
|
||||
<td className="pl-3 py-2 text-gray-600">{d.b?.location ?? "\u2014"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryCard({
|
||||
label,
|
||||
value,
|
||||
positive,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
positive: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-3xl border border-gray-200 bg-gray-50 p-4 text-center shadow-sm">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-gray-500">{label}</p>
|
||||
<p className={clsx("mt-1 text-lg font-semibold tabular-nums", positive ? "text-emerald-700" : "text-red-700")}>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function deltaColor(delta: number | undefined): string {
|
||||
if (delta == null || delta === 0) return "text-gray-400";
|
||||
return delta > 0 ? "text-red-600" : "text-emerald-600";
|
||||
}
|
||||
|
||||
function formatAssumptionValue(value: unknown): string {
|
||||
if (value === undefined) return "\u2014";
|
||||
if (value === null) return "null";
|
||||
if (typeof value === "object") return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { clsx } from "clsx";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
interface WeeklyPhasingViewProps {
|
||||
estimateId: string;
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
type ViewMode = "by_line" | "by_chapter";
|
||||
type PhasingPattern = "even" | "front_loaded" | "back_loaded";
|
||||
|
||||
function getDefaultDateRange(): { start: string; end: string } {
|
||||
const now = new Date();
|
||||
const start = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`;
|
||||
const endDate = new Date(now.getFullYear(), now.getMonth() + 6, 0);
|
||||
const end = `${endDate.getFullYear()}-${String(endDate.getMonth() + 1).padStart(2, "0")}-${String(endDate.getDate()).padStart(2, "0")}`;
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function heatColor(hours: number, maxHours: number): string {
|
||||
if (hours === 0 || maxHours === 0) return "";
|
||||
const ratio = Math.min(hours / maxHours, 1);
|
||||
if (ratio < 0.25) return "bg-blue-50";
|
||||
if (ratio < 0.5) return "bg-blue-100";
|
||||
if (ratio < 0.75) return "bg-blue-200";
|
||||
return "bg-blue-300";
|
||||
}
|
||||
|
||||
export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProps) {
|
||||
const defaults = getDefaultDateRange();
|
||||
const [startDate, setStartDate] = useState(defaults.start);
|
||||
const [endDate, setEndDate] = useState(defaults.end);
|
||||
const [pattern, setPattern] = useState<PhasingPattern>("even");
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("by_line");
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const phasingQuery = trpc.estimate.getWeeklyPhasing.useQuery(
|
||||
{ estimateId },
|
||||
{ staleTime: 30_000 },
|
||||
);
|
||||
|
||||
const generateMutation = trpc.estimate.generateWeeklyPhasing.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.estimate.getWeeklyPhasing.invalidate({ estimateId });
|
||||
void utils.estimate.getById.invalidate({ id: estimateId });
|
||||
},
|
||||
});
|
||||
|
||||
const data = phasingQuery.data;
|
||||
|
||||
// Compute max hours for heat-map coloring
|
||||
const maxHours = useMemo(() => {
|
||||
if (!data?.hasPhasing) return 0;
|
||||
let max = 0;
|
||||
for (const line of data.lines) {
|
||||
for (const h of Object.values(line.weeklyHours)) {
|
||||
if (h > max) max = h;
|
||||
}
|
||||
}
|
||||
return max;
|
||||
}, [data]);
|
||||
|
||||
// Compute column totals
|
||||
const columnTotals = useMemo(() => {
|
||||
if (!data?.hasPhasing) return {};
|
||||
const totals: Record<string, number> = {};
|
||||
for (const line of data.lines) {
|
||||
for (const [weekKey, hours] of Object.entries(line.weeklyHours)) {
|
||||
totals[weekKey] = Math.round(((totals[weekKey] ?? 0) + hours) * 100) / 100;
|
||||
}
|
||||
}
|
||||
return totals;
|
||||
}, [data]);
|
||||
|
||||
// Compute chapter column totals
|
||||
const chapterColumnTotals = useMemo(() => {
|
||||
if (!data?.hasPhasing) return {};
|
||||
const totals: Record<string, number> = {};
|
||||
for (const chapterHours of Object.values(data.chapterAggregation)) {
|
||||
for (const [weekKey, hours] of Object.entries(chapterHours)) {
|
||||
totals[weekKey] = Math.round(((totals[weekKey] ?? 0) + hours) * 100) / 100;
|
||||
}
|
||||
}
|
||||
return totals;
|
||||
}, [data]);
|
||||
|
||||
// Compute max hours for chapter view
|
||||
const maxChapterHours = useMemo(() => {
|
||||
if (!data?.hasPhasing) return 0;
|
||||
let max = 0;
|
||||
for (const chapterHours of Object.values(data.chapterAggregation)) {
|
||||
for (const h of Object.values(chapterHours)) {
|
||||
if (h > max) max = h;
|
||||
}
|
||||
}
|
||||
return max;
|
||||
}, [data]);
|
||||
|
||||
const handleGenerate = () => {
|
||||
generateMutation.mutate({
|
||||
estimateId,
|
||||
startDate,
|
||||
endDate,
|
||||
pattern,
|
||||
});
|
||||
};
|
||||
|
||||
// Use config dates from existing phasing if available
|
||||
const effectiveStart = data?.hasPhasing ? data.config.startDate : startDate;
|
||||
const effectiveEnd = data?.hasPhasing ? data.config.endDate : endDate;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header / Controls */}
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold text-gray-900">
|
||||
Weekly Phasing (4Dispo)
|
||||
</h3>
|
||||
|
||||
{canEdit && (
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
Start Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={data?.hasPhasing ? effectiveStart : startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
End Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={data?.hasPhasing ? effectiveEnd : endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
Pattern
|
||||
</label>
|
||||
<select
|
||||
value={pattern}
|
||||
onChange={(e) => setPattern(e.target.value as PhasingPattern)}
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="even">Even Distribution</option>
|
||||
<option value="front_loaded">Front Loaded (60/40)</option>
|
||||
<option value="back_loaded">Back Loaded (40/60)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerate}
|
||||
disabled={generateMutation.isPending}
|
||||
className={clsx(
|
||||
"rounded-lg px-4 py-2 text-sm font-medium text-white",
|
||||
generateMutation.isPending
|
||||
? "cursor-not-allowed bg-gray-400"
|
||||
: "bg-sky-600 hover:bg-sky-700",
|
||||
)}
|
||||
>
|
||||
{generateMutation.isPending ? "Generating..." : "Generate Phasing"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{generateMutation.isError && (
|
||||
<p className="mt-2 text-sm text-red-600">
|
||||
{generateMutation.error.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{generateMutation.isSuccess && (
|
||||
<p className="mt-2 text-sm text-emerald-600">
|
||||
Phasing generated for {generateMutation.data.linesUpdated} demand
|
||||
lines.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* View toggle */}
|
||||
{data?.hasPhasing && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode("by_line")}
|
||||
className={clsx(
|
||||
"rounded-lg px-3 py-1.5 text-sm font-medium",
|
||||
viewMode === "by_line"
|
||||
? "bg-sky-100 text-sky-700"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200",
|
||||
)}
|
||||
>
|
||||
By Line
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode("by_chapter")}
|
||||
className={clsx(
|
||||
"rounded-lg px-3 py-1.5 text-sm font-medium",
|
||||
viewMode === "by_chapter"
|
||||
? "bg-sky-100 text-sky-700"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200",
|
||||
)}
|
||||
>
|
||||
By Chapter
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Phasing Grid */}
|
||||
{phasingQuery.isLoading && (
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-8 text-center text-gray-500">
|
||||
Loading phasing data...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && !data.hasPhasing && (
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-8 text-center text-gray-500">
|
||||
No weekly phasing generated yet. Use the controls above to generate a
|
||||
phasing distribution.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data?.hasPhasing && viewMode === "by_line" && (
|
||||
<div className="rounded-3xl border border-gray-200 bg-white overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 bg-gray-50">
|
||||
<th className="sticky left-0 z-10 bg-gray-50 px-4 py-3 text-left font-medium text-gray-700 min-w-[200px]">
|
||||
Demand Line
|
||||
</th>
|
||||
{data.weeks.map((week) => {
|
||||
const key = `${week.year}-W${String(week.weekNumber).padStart(2, "0")}`;
|
||||
return (
|
||||
<th
|
||||
key={key}
|
||||
className="px-3 py-3 text-right font-medium text-gray-600 min-w-[80px] whitespace-nowrap"
|
||||
>
|
||||
{week.label}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
<th className="sticky right-0 z-10 bg-gray-50 px-4 py-3 text-right font-semibold text-gray-700 min-w-[90px]">
|
||||
Total
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.lines.map((line) => {
|
||||
const lineTotal = Object.values(line.weeklyHours).reduce(
|
||||
(sum, h) => sum + h,
|
||||
0,
|
||||
);
|
||||
return (
|
||||
<tr
|
||||
key={line.id}
|
||||
className="border-b border-gray-100 hover:bg-gray-50/50"
|
||||
>
|
||||
<td className="sticky left-0 z-10 bg-white px-4 py-2 font-medium text-gray-900">
|
||||
<div className="truncate max-w-[200px]" title={line.name}>
|
||||
{line.name}
|
||||
</div>
|
||||
{line.chapter && (
|
||||
<div className="text-xs text-gray-500">
|
||||
{line.chapter}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
{data.weeks.map((week) => {
|
||||
const key = `${week.year}-W${String(week.weekNumber).padStart(2, "0")}`;
|
||||
const hours = line.weeklyHours[key] ?? 0;
|
||||
return (
|
||||
<td
|
||||
key={key}
|
||||
className={clsx(
|
||||
"px-3 py-2 text-right tabular-nums",
|
||||
heatColor(hours, maxHours),
|
||||
)}
|
||||
>
|
||||
{hours > 0 ? hours.toFixed(1) : "-"}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="sticky right-0 z-10 bg-white px-4 py-2 text-right font-semibold tabular-nums text-gray-900">
|
||||
{lineTotal.toFixed(1)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 border-gray-300 bg-gray-50 font-semibold">
|
||||
<td className="sticky left-0 z-10 bg-gray-50 px-4 py-3 text-gray-700">
|
||||
Total
|
||||
</td>
|
||||
{data.weeks.map((week) => {
|
||||
const key = `${week.year}-W${String(week.weekNumber).padStart(2, "0")}`;
|
||||
const total = columnTotals[key] ?? 0;
|
||||
return (
|
||||
<td
|
||||
key={key}
|
||||
className="px-3 py-3 text-right tabular-nums text-gray-900"
|
||||
>
|
||||
{total > 0 ? total.toFixed(1) : "-"}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="sticky right-0 z-10 bg-gray-50 px-4 py-3 text-right tabular-nums text-gray-900">
|
||||
{Object.values(columnTotals)
|
||||
.reduce((sum, h) => sum + h, 0)
|
||||
.toFixed(1)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data?.hasPhasing && viewMode === "by_chapter" && (
|
||||
<div className="rounded-3xl border border-gray-200 bg-white overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 bg-gray-50">
|
||||
<th className="sticky left-0 z-10 bg-gray-50 px-4 py-3 text-left font-medium text-gray-700 min-w-[200px]">
|
||||
Chapter
|
||||
</th>
|
||||
{data.weeks.map((week) => {
|
||||
const key = `${week.year}-W${String(week.weekNumber).padStart(2, "0")}`;
|
||||
return (
|
||||
<th
|
||||
key={key}
|
||||
className="px-3 py-3 text-right font-medium text-gray-600 min-w-[80px] whitespace-nowrap"
|
||||
>
|
||||
{week.label}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
<th className="sticky right-0 z-10 bg-gray-50 px-4 py-3 text-right font-semibold text-gray-700 min-w-[90px]">
|
||||
Total
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(data.chapterAggregation)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([chapter, weeklyHours]) => {
|
||||
const chapterTotal = Object.values(weeklyHours).reduce(
|
||||
(sum, h) => sum + h,
|
||||
0,
|
||||
);
|
||||
return (
|
||||
<tr
|
||||
key={chapter}
|
||||
className="border-b border-gray-100 hover:bg-gray-50/50"
|
||||
>
|
||||
<td className="sticky left-0 z-10 bg-white px-4 py-2 font-medium text-gray-900">
|
||||
{chapter}
|
||||
</td>
|
||||
{data.weeks.map((week) => {
|
||||
const key = `${week.year}-W${String(week.weekNumber).padStart(2, "0")}`;
|
||||
const hours = weeklyHours[key] ?? 0;
|
||||
return (
|
||||
<td
|
||||
key={key}
|
||||
className={clsx(
|
||||
"px-3 py-2 text-right tabular-nums",
|
||||
heatColor(hours, maxChapterHours),
|
||||
)}
|
||||
>
|
||||
{hours > 0 ? hours.toFixed(1) : "-"}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="sticky right-0 z-10 bg-white px-4 py-2 text-right font-semibold tabular-nums text-gray-900">
|
||||
{chapterTotal.toFixed(1)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 border-gray-300 bg-gray-50 font-semibold">
|
||||
<td className="sticky left-0 z-10 bg-gray-50 px-4 py-3 text-gray-700">
|
||||
Total
|
||||
</td>
|
||||
{data.weeks.map((week) => {
|
||||
const key = `${week.year}-W${String(week.weekNumber).padStart(2, "0")}`;
|
||||
const total = chapterColumnTotals[key] ?? 0;
|
||||
return (
|
||||
<td
|
||||
key={key}
|
||||
className="px-3 py-3 text-right tabular-nums text-gray-900"
|
||||
>
|
||||
{total > 0 ? total.toFixed(1) : "-"}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="sticky right-0 z-10 bg-gray-50 px-4 py-3 text-right tabular-nums text-gray-900">
|
||||
{Object.values(chapterColumnTotals)
|
||||
.reduce((sum, h) => sum + h, 0)
|
||||
.toFixed(1)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info about current phasing config */}
|
||||
{data?.hasPhasing && data.config && (
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
<span className="font-medium">Current phasing:</span>{" "}
|
||||
{data.config.pattern.replace("_", " ")} distribution from{" "}
|
||||
{data.config.startDate} to {data.config.endDate} across{" "}
|
||||
{data.weeks.length} weeks, {data.lines.length} demand lines.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user