feat: timeline multi-select, demand popover, resource hover card, merged tooltips, dark mode fixes

Major timeline enhancements:
- Right-click drag multi-selection with floating action bar (batch delete/assign)
- DemandPopover for demand strip details (replaces broken "Loading" modal)
- ResourceHoverCard on name hover showing skills, rates, role, chapter
- Merged heatmap+vacation tooltips into unified TimelineTooltip component
- Fixed overbooking blink animation (date normalization, z-index ordering)
- Fixed dark mode sticky column bleed-through in project view
- System roles admin page, notification task management, performance review docs

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-18 23:43:51 +01:00
parent d0f04f13f8
commit ddec3a927a
67 changed files with 4930 additions and 1166 deletions
@@ -35,7 +35,7 @@ const TABS: Array<{ id: WorkspaceTab; label: string }> = [
function EmptyState({ children }: { children: React.ReactNode }) {
return (
<div className="rounded-3xl border border-dashed border-gray-200 bg-white px-5 py-10 text-center text-sm text-gray-400">
<div className="rounded-3xl border border-dashed border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-5 py-10 text-center text-sm text-gray-400">
{children}
</div>
);
@@ -53,8 +53,8 @@ function ActionNotice({
className={clsx(
"rounded-2xl border px-4 py-3 text-sm",
tone === "success"
? "border-emerald-200 bg-emerald-50 text-emerald-800"
: "border-rose-200 bg-rose-50 text-rose-800",
? "border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-800 dark:bg-emerald-950/50 dark:text-emerald-300"
: "border-rose-200 bg-rose-50 text-rose-800 dark:border-rose-800 dark:bg-rose-950/50 dark:text-rose-300",
)}
>
{children}
@@ -182,7 +182,7 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string })
<div className="mx-auto max-w-7xl space-y-6 p-6">
<Link
href="/estimates"
className="inline-flex items-center gap-1 text-sm text-gray-500 transition-colors hover:text-gray-800"
className="inline-flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400 transition-colors hover:text-gray-800 dark:hover:text-gray-200"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
@@ -190,21 +190,21 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string })
Back to Estimates
</Link>
<div className="rounded-[28px] border border-gray-200 bg-gradient-to-br from-white via-white to-brand-50 p-6 shadow-sm">
<div className="rounded-[28px] border border-gray-200 dark:border-gray-700 bg-gradient-to-br from-white via-white to-brand-50 dark:from-gray-900 dark:via-gray-900 dark:to-gray-900 p-6 shadow-sm dark:shadow-black/20">
<div className="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-brand-600">Estimate Workspace <InfoTooltip content="Central workspace for inspecting and editing an estimate's scope, staffing, financials, and version history." /></p>
<h1 className="mt-2 text-3xl font-semibold text-gray-900">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-brand-600 dark:text-sky-400">Estimate Workspace <InfoTooltip content="Central workspace for inspecting and editing an estimate's scope, staffing, financials, and version history." /></p>
<h1 className="mt-2 text-3xl font-semibold text-gray-900 dark:text-gray-50">
{estimate?.name ?? "Loading estimate"}
</h1>
<p className="mt-2 max-w-3xl text-sm text-gray-600">
<p className="mt-2 max-w-3xl text-sm text-gray-600 dark:text-gray-300">
Use the tabs below to inspect the connected estimate structure, version context, and staffing breakdown without relying on spreadsheet tabs.
</p>
</div>
{estimate && (
<div className="flex flex-col gap-3 lg:items-end">
<div className="grid gap-2 text-sm text-gray-500 lg:text-right">
<div className="grid gap-2 text-sm text-gray-500 dark:text-gray-400 lg:text-right">
<span>{estimate.project ? `${estimate.project.shortCode} - ${estimate.project.name}` : "Standalone estimate"}</span>
<span>Updated {formatDateLong(estimate.updatedAt)}</span>
</div>
@@ -215,7 +215,7 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string })
if (!editableTab && !isEditing) return;
setIsEditing((current) => !current);
}}
className="rounded-2xl border border-brand-200 bg-white px-4 py-2 text-sm font-semibold text-brand-700 transition hover:border-brand-300 hover:bg-brand-50"
className="rounded-2xl border border-brand-200 dark:border-sky-700 bg-white dark:bg-gray-800 px-4 py-2 text-sm font-semibold text-brand-700 dark:text-sky-300 transition hover:border-brand-300 dark:hover:border-sky-600 hover:bg-brand-50 dark:hover:bg-gray-700"
>
{isEditing ? "Close editor" : editableTab ? "Edit working draft" : "Draft editor available in editable tabs"}
</button>
@@ -238,7 +238,7 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string })
{actionMessage && <ActionNotice tone="success">{actionMessage}</ActionNotice>}
{actionError && <ActionNotice tone="error">{actionError}</ActionNotice>}
<div className="flex flex-wrap gap-2 border-b border-gray-200">
<div className="flex flex-wrap gap-2 border-b border-gray-200 dark:border-gray-700">
{TABS.map((item) => (
<button
key={item.id}
@@ -247,8 +247,8 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string })
className={clsx(
"rounded-t-2xl border-b-2 px-4 py-3 text-sm font-medium transition-colors",
tab === item.id
? "border-brand-600 text-brand-700"
: "border-transparent text-gray-500 hover:text-gray-800",
? "border-brand-600 text-brand-700 dark:border-sky-400 dark:text-sky-300"
: "border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200",
)}
>
{item.label}
@@ -23,10 +23,10 @@ function getDefaultDateRange(): { start: string; end: string } {
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";
if (ratio < 0.25) return "bg-blue-50 dark:bg-blue-900/20";
if (ratio < 0.5) return "bg-blue-100 dark:bg-blue-900/30";
if (ratio < 0.75) return "bg-blue-200 dark:bg-blue-900/40";
return "bg-blue-300 dark:bg-blue-900/50";
}
export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProps) {
@@ -116,43 +116,43 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
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">
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6">
<h3 className="mb-4 text-lg font-semibold text-gray-900 dark:text-gray-100">
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">
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
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"
className="rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 px-3 py-2 text-sm"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
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"
className="rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 px-3 py-2 text-sm"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
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"
className="rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 px-3 py-2 text-sm"
>
<option value="even">Even Distribution</option>
<option value="front_loaded">Front Loaded (60/40)</option>
@@ -198,8 +198,8 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
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",
? "bg-sky-100 dark:bg-sky-900/40 text-sky-700 dark:text-sky-300"
: "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600",
)}
>
By Line
@@ -210,8 +210,8 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
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",
? "bg-sky-100 dark:bg-sky-900/40 text-sky-700 dark:text-sky-300"
: "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600",
)}
>
By Chapter
@@ -221,25 +221,25 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
{/* Phasing Grid */}
{phasingQuery.isLoading && (
<div className="rounded-3xl border border-gray-200 bg-white p-8 text-center text-gray-500">
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-8 text-center text-gray-500 dark:text-gray-400">
Loading phasing data...
</div>
)}
{data && !data.hasPhasing && (
<div className="rounded-3xl border border-gray-200 bg-white p-8 text-center text-gray-500">
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-8 text-center text-gray-500 dark:text-gray-400">
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="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 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]">
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
<th className="sticky left-0 z-10 bg-gray-50 dark:bg-gray-900 px-4 py-3 text-left font-medium text-gray-700 dark:text-gray-300 min-w-[200px]">
Demand Line
</th>
{data.weeks.map((week) => {
@@ -247,13 +247,13 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
return (
<th
key={key}
className="px-3 py-3 text-right font-medium text-gray-600 min-w-[80px] whitespace-nowrap"
className="px-3 py-3 text-right font-medium text-gray-600 dark:text-gray-400 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]">
<th className="sticky right-0 z-10 bg-gray-50 dark:bg-gray-900 px-4 py-3 text-right font-semibold text-gray-700 dark:text-gray-300 min-w-[90px]">
Total
</th>
</tr>
@@ -267,14 +267,14 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
return (
<tr
key={line.id}
className="border-b border-gray-100 hover:bg-gray-50/50"
className="border-b border-gray-100 dark:border-gray-700/50 hover:bg-gray-50/50 dark:hover:bg-gray-700/30"
>
<td className="sticky left-0 z-10 bg-white px-4 py-2 font-medium text-gray-900">
<td className="sticky left-0 z-10 bg-white dark:bg-gray-800 px-4 py-2 font-medium text-gray-900 dark:text-gray-100">
<div className="truncate max-w-[200px]" title={line.name}>
{line.name}
</div>
{line.chapter && (
<div className="text-xs text-gray-500">
<div className="text-xs text-gray-500 dark:text-gray-400">
{line.chapter}
</div>
)}
@@ -286,7 +286,7 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
<td
key={key}
className={clsx(
"px-3 py-2 text-right tabular-nums",
"px-3 py-2 text-right tabular-nums text-gray-700 dark:text-gray-200",
heatColor(hours, maxHours),
)}
>
@@ -294,7 +294,7 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
</td>
);
})}
<td className="sticky right-0 z-10 bg-white px-4 py-2 text-right font-semibold tabular-nums text-gray-900">
<td className="sticky right-0 z-10 bg-white dark:bg-gray-800 px-4 py-2 text-right font-semibold tabular-nums text-gray-900 dark:text-gray-100">
{lineTotal.toFixed(1)}
</td>
</tr>
@@ -302,8 +302,8 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
})}
</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">
<tr className="border-t-2 border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 font-semibold">
<td className="sticky left-0 z-10 bg-gray-50 dark:bg-gray-900 px-4 py-3 text-gray-700 dark:text-gray-300">
Total
</td>
{data.weeks.map((week) => {
@@ -312,13 +312,13 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
return (
<td
key={key}
className="px-3 py-3 text-right tabular-nums text-gray-900"
className="px-3 py-3 text-right tabular-nums text-gray-900 dark:text-gray-100"
>
{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">
<td className="sticky right-0 z-10 bg-gray-50 dark:bg-gray-900 px-4 py-3 text-right tabular-nums text-gray-900 dark:text-gray-100">
{Object.values(columnTotals)
.reduce((sum, h) => sum + h, 0)
.toFixed(1)}
@@ -331,12 +331,12 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
)}
{data?.hasPhasing && viewMode === "by_chapter" && (
<div className="rounded-3xl border border-gray-200 bg-white overflow-hidden">
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 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]">
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
<th className="sticky left-0 z-10 bg-gray-50 dark:bg-gray-900 px-4 py-3 text-left font-medium text-gray-700 dark:text-gray-300 min-w-[200px]">
Chapter
</th>
{data.weeks.map((week) => {
@@ -344,13 +344,13 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
return (
<th
key={key}
className="px-3 py-3 text-right font-medium text-gray-600 min-w-[80px] whitespace-nowrap"
className="px-3 py-3 text-right font-medium text-gray-600 dark:text-gray-400 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]">
<th className="sticky right-0 z-10 bg-gray-50 dark:bg-gray-900 px-4 py-3 text-right font-semibold text-gray-700 dark:text-gray-300 min-w-[90px]">
Total
</th>
</tr>
@@ -366,9 +366,9 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
return (
<tr
key={chapter}
className="border-b border-gray-100 hover:bg-gray-50/50"
className="border-b border-gray-100 dark:border-gray-700/50 hover:bg-gray-50/50 dark:hover:bg-gray-700/30"
>
<td className="sticky left-0 z-10 bg-white px-4 py-2 font-medium text-gray-900">
<td className="sticky left-0 z-10 bg-white dark:bg-gray-800 px-4 py-2 font-medium text-gray-900 dark:text-gray-100">
{chapter}
</td>
{data.weeks.map((week) => {
@@ -378,7 +378,7 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
<td
key={key}
className={clsx(
"px-3 py-2 text-right tabular-nums",
"px-3 py-2 text-right tabular-nums text-gray-700 dark:text-gray-200",
heatColor(hours, maxChapterHours),
)}
>
@@ -386,7 +386,7 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
</td>
);
})}
<td className="sticky right-0 z-10 bg-white px-4 py-2 text-right font-semibold tabular-nums text-gray-900">
<td className="sticky right-0 z-10 bg-white dark:bg-gray-800 px-4 py-2 text-right font-semibold tabular-nums text-gray-900 dark:text-gray-100">
{chapterTotal.toFixed(1)}
</td>
</tr>
@@ -394,8 +394,8 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
})}
</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">
<tr className="border-t-2 border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 font-semibold">
<td className="sticky left-0 z-10 bg-gray-50 dark:bg-gray-900 px-4 py-3 text-gray-700 dark:text-gray-300">
Total
</td>
{data.weeks.map((week) => {
@@ -404,13 +404,13 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
return (
<td
key={key}
className="px-3 py-3 text-right tabular-nums text-gray-900"
className="px-3 py-3 text-right tabular-nums text-gray-900 dark:text-gray-100"
>
{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">
<td className="sticky right-0 z-10 bg-gray-50 dark:bg-gray-900 px-4 py-3 text-right tabular-nums text-gray-900 dark:text-gray-100">
{Object.values(chapterColumnTotals)
.reduce((sum, h) => sum + h, 0)
.toFixed(1)}
@@ -424,8 +424,8 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
{/* 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">
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
<span className="font-medium">Current phasing:</span>{" "}
{data.config.pattern.replace("_", " ")} distribution from{" "}
{data.config.startDate} to {data.config.endDate} across{" "}
@@ -8,7 +8,7 @@ import type {
function EmptyState({ children }: { children: React.ReactNode }) {
return (
<div className="rounded-3xl border border-dashed border-gray-200 bg-white px-5 py-10 text-center text-sm text-gray-400">
<div className="rounded-3xl border border-dashed border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-5 py-10 text-center text-sm text-gray-400">
{children}
</div>
);
@@ -24,25 +24,25 @@ export function AssumptionsTab({ estimate }: { estimate: EstimateWorkspaceView }
}
return (
<div className="rounded-3xl border border-gray-200 bg-white shadow-sm">
<div className="border-b border-gray-100 px-6 py-4">
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-600">Commercial and delivery assumptions <InfoTooltip content="Preconditions that affect the estimate validity. If any assumption changes, the estimate may need revision." /></h2>
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-sm">
<div className="border-b border-gray-100 dark:border-gray-700/50 px-6 py-4">
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400">Commercial and delivery assumptions <InfoTooltip content="Preconditions that affect the estimate validity. If any assumption changes, the estimate may need revision." /></h2>
</div>
<div className="divide-y divide-gray-100">
<div className="divide-y divide-gray-100 dark:divide-gray-700/50">
{assumptions.map((assumption) => (
<div key={assumption.id} className="grid gap-3 px-6 py-4 md:grid-cols-[160px,1fr,1fr]">
<div>
<p className="text-xs uppercase tracking-wide text-gray-400">Category <InfoTooltip content="Groups assumptions by topic, e.g. 'commercial', 'delivery', 'technical'." /></p>
<p className="mt-1 text-sm font-medium text-gray-900">{assumption.category}</p>
<p className="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">{assumption.category}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-gray-400">Label <InfoTooltip content="Human-readable description of the assumption. The key below is the machine-readable identifier." /></p>
<p className="mt-1 text-sm text-gray-800">{assumption.label}</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-200">{assumption.label}</p>
<p className="mt-1 text-xs text-gray-400">{assumption.key}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-gray-400">Value <InfoTooltip content="The concrete value or condition for this assumption." /></p>
<p className="mt-1 text-sm text-gray-800">{String(assumption.value)}</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-200">{String(assumption.value)}</p>
</div>
</div>
))}
@@ -98,11 +98,11 @@ export function ExportsTab({
return (
<div className="space-y-4">
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-600">Export delivery <InfoTooltip content="Generate downloadable files from the current estimate version. Each format includes demand lines, scope, and financial summaries." /></h2>
<p className="mt-2 text-sm text-gray-500">
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400">Export delivery <InfoTooltip content="Generate downloadable files from the current estimate version. Each format includes demand lines, scope, and financial summaries." /></h2>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
Generate format-specific artifacts from the current version and download them directly from the stored serializer payload.
</p>
</div>
@@ -114,7 +114,7 @@ export function ExportsTab({
type="button"
onClick={() => onCreateExport(latestVersion.id, format)}
disabled={isCreatingExport}
className="rounded-2xl border border-brand-200 bg-white px-3 py-2 text-sm font-semibold text-brand-700 transition hover:border-brand-300 hover:bg-brand-50 disabled:cursor-not-allowed disabled:opacity-60"
className="rounded-2xl border border-brand-200 bg-white dark:bg-gray-800 px-3 py-2 text-sm font-semibold text-brand-700 transition hover:border-brand-300 hover:bg-brand-50 disabled:cursor-not-allowed disabled:opacity-60"
>
{isCreatingExport ? "Generating..." : `Create ${format}`}
</button>
@@ -124,16 +124,16 @@ export function ExportsTab({
</div>
</div>
<div className="rounded-3xl border border-gray-200 bg-white shadow-sm">
<div className="border-b border-gray-100 px-6 py-4">
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-600">Generated exports <InfoTooltip content="Previously generated export artifacts. XLSX/CSV contain tabular data; JSON is machine-readable; SAP/MMP are ERP integration formats." /></h2>
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-sm">
<div className="border-b border-gray-100 dark:border-gray-700/50 px-6 py-4">
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400">Generated exports <InfoTooltip content="Previously generated export artifacts. XLSX/CSV contain tabular data; JSON is machine-readable; SAP/MMP are ERP integration formats." /></h2>
</div>
{exports.length === 0 ? (
<div className="px-6 py-8">
<p className="text-sm text-gray-400">No exports have been generated for the current version yet.</p>
</div>
) : (
<div className="divide-y divide-gray-100">
<div className="divide-y divide-gray-100 dark:divide-gray-700/50">
{exports.map((estimateExport) => {
const payload = isEstimateExportArtifactPayload(estimateExport.payload)
? estimateExport.payload
@@ -144,57 +144,57 @@ export function ExportsTab({
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-medium text-gray-900">{estimateExport.fileName}</p>
<span className="rounded-full bg-gray-100 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wide text-gray-600">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">{estimateExport.fileName}</p>
<span className="rounded-full bg-gray-100 dark:bg-gray-800 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400">
{estimateExport.format}
</span>
{payload?.sheetNames?.length ? (
<span className="rounded-full bg-sky-50 px-2.5 py-1 text-[11px] font-semibold text-sky-700">
<span className="rounded-full bg-sky-50 dark:bg-sky-900/30 px-2.5 py-1 text-[11px] font-semibold text-sky-700 dark:text-sky-300">
{payload.sheetNames.length} sheets
</span>
) : null}
</div>
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-500">
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-500 dark:text-gray-400">
<span>{formatDateLong(estimateExport.createdAt)}</span>
{payload ? <span>{formatBytes(payload.byteLength)}</span> : null}
{payload?.rowCount != null ? <span>{payload.rowCount} rows</span> : null}
{payload?.lineCount != null ? <span>{payload.lineCount} lines</span> : null}
</div>
{payload ? (
<div className="mt-3 grid gap-2 text-xs text-gray-600 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-2xl bg-gray-50 px-3 py-2">
<div className="mt-3 grid gap-2 text-xs text-gray-600 dark:text-gray-400 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-2xl bg-gray-50 dark:bg-gray-900 px-3 py-2">
<p className="uppercase tracking-wide text-gray-400">Hours</p>
<p className="mt-1 font-semibold text-gray-900">
<p className="mt-1 font-semibold text-gray-900 dark:text-gray-100">
{payload.summary.totalHours.toFixed(1)}
</p>
</div>
<div className="rounded-2xl bg-gray-50 px-3 py-2">
<div className="rounded-2xl bg-gray-50 dark:bg-gray-900 px-3 py-2">
<p className="uppercase tracking-wide text-gray-400">Cost</p>
<p className="mt-1 font-semibold text-gray-900">
<p className="mt-1 font-semibold text-gray-900 dark:text-gray-100">
{formatMoney(
payload.summary.totalCostCents,
payload.summary.baseCurrency,
)}
</p>
</div>
<div className="rounded-2xl bg-gray-50 px-3 py-2">
<div className="rounded-2xl bg-gray-50 dark:bg-gray-900 px-3 py-2">
<p className="uppercase tracking-wide text-gray-400">Price</p>
<p className="mt-1 font-semibold text-gray-900">
<p className="mt-1 font-semibold text-gray-900 dark:text-gray-100">
{formatMoney(
payload.summary.totalPriceCents,
payload.summary.baseCurrency,
)}
</p>
</div>
<div className="rounded-2xl bg-gray-50 px-3 py-2">
<div className="rounded-2xl bg-gray-50 dark:bg-gray-900 px-3 py-2">
<p className="uppercase tracking-wide text-gray-400">Margin</p>
<p className="mt-1 font-semibold text-gray-900">
<p className="mt-1 font-semibold text-gray-900 dark:text-gray-100">
{payload.summary.marginPercent.toFixed(0)}%
</p>
</div>
</div>
) : (
<p className="mt-3 text-xs text-amber-700">
<p className="mt-3 text-xs text-amber-700 dark:text-amber-300">
Legacy export record detected. Regenerate it to get downloadable serializer output.
</p>
)}
@@ -204,7 +204,7 @@ export function ExportsTab({
<button
type="button"
onClick={() => downloadEstimateExport(estimateExport)}
className="rounded-2xl border border-brand-200 bg-white px-3 py-2 text-sm font-semibold text-brand-700 transition hover:border-brand-300 hover:bg-brand-50"
className="rounded-2xl border border-brand-200 bg-white dark:bg-gray-800 px-3 py-2 text-sm font-semibold text-brand-700 transition hover:border-brand-300 hover:bg-brand-50"
>
Download
</button>
@@ -11,7 +11,7 @@ import { formatMoney } from "~/lib/format.js";
function EmptyState({ children }: { children: React.ReactNode }) {
return (
<div className="rounded-3xl border border-dashed border-gray-200 bg-white px-5 py-10 text-center text-sm text-gray-400">
<div className="rounded-3xl border border-dashed border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-5 py-10 text-center text-sm text-gray-400">
{children}
</div>
);
@@ -77,33 +77,33 @@ export function FinancialsTab({ estimate, canEdit }: { estimate: EstimateWorkspa
<div className="space-y-6">
{/* Summary cards */}
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
<p className="text-xs uppercase tracking-wide text-gray-400">Total Cost <InfoTooltip content="Sum of (hours x cost rate) for all demand lines. Avg shows weighted average cost per hour." /></p>
<p className="mt-2 text-2xl font-semibold text-gray-900">{formatMoney(totals.costCents, estimate.baseCurrency)}</p>
<p className="mt-1 text-xs text-gray-500">Avg {formatMoney(Math.round(avgCostRate), estimate.baseCurrency)}/h</p>
<p className="mt-2 text-2xl font-semibold text-gray-900 dark:text-gray-100">{formatMoney(totals.costCents, estimate.baseCurrency)}</p>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">Avg {formatMoney(Math.round(avgCostRate), estimate.baseCurrency)}/h</p>
</div>
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
<p className="text-xs uppercase tracking-wide text-gray-400">Total Price <InfoTooltip content="Sum of (hours x sell rate) for all demand lines. This is the total client-facing revenue." /></p>
<p className="mt-2 text-2xl font-semibold text-gray-900">{formatMoney(totals.priceCents, estimate.baseCurrency)}</p>
<p className="mt-1 text-xs text-gray-500">Avg {formatMoney(Math.round(avgBillRate), estimate.baseCurrency)}/h</p>
<p className="mt-2 text-2xl font-semibold text-gray-900 dark:text-gray-100">{formatMoney(totals.priceCents, estimate.baseCurrency)}</p>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">Avg {formatMoney(Math.round(avgBillRate), estimate.baseCurrency)}/h</p>
</div>
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
<p className="text-xs uppercase tracking-wide text-gray-400">Margin <InfoTooltip content="Margin = Total Price - Total Cost. Margin % = Margin / Total Price x 100. Green = positive, red = negative." /></p>
<p className={clsx("mt-2 text-2xl font-semibold", marginCents >= 0 ? "text-emerald-700" : "text-red-700")}>
<p className={clsx("mt-2 text-2xl font-semibold", marginCents >= 0 ? "text-emerald-700 dark:text-emerald-400" : "text-red-700 dark:text-red-400")}>
{formatMoney(marginCents, estimate.baseCurrency)}
</p>
<p className="mt-1 text-xs text-gray-500">{marginPercent.toFixed(1)}% of price</p>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">{marginPercent.toFixed(1)}% of price</p>
</div>
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
<p className="text-xs uppercase tracking-wide text-gray-400">Total Hours <InfoTooltip content="Sum of all demand line hours. Each demand line contributes its hours to this total." /></p>
<p className="mt-2 text-2xl font-semibold text-gray-900">{totals.hours.toFixed(1)} h</p>
<p className="mt-1 text-xs text-gray-500">{demandLines.length} demand lines</p>
<p className="mt-2 text-2xl font-semibold text-gray-900 dark:text-gray-100">{totals.hours.toFixed(1)} h</p>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">{demandLines.length} demand lines</p>
</div>
</div>
{/* Margin waterfall: Cost -> Margin -> Price */}
<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">Cost to price bridge <InfoTooltip content="Visual waterfall: internal cost + margin = client price. Bar heights are proportional." /></h3>
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
<h3 className="mb-4 text-base font-semibold text-gray-900 dark:text-gray-100">Cost to price bridge <InfoTooltip content="Visual waterfall: internal cost + margin = client price. Bar heights are proportional." /></h3>
<div className="flex items-end gap-1 h-32">
{(() => {
const maxVal = Math.max(totals.costCents, totals.priceCents, 1);
@@ -113,22 +113,22 @@ export function FinancialsTab({ estimate, canEdit }: { estimate: EstimateWorkspa
return (
<>
<div className="flex-1 flex flex-col items-center gap-1">
<div className="w-full rounded-t-xl bg-gray-300" style={{ height: `${costH}%` }} />
<span className="text-xs font-medium text-gray-600">Cost</span>
<span className="text-xs text-gray-500">{formatMoney(totals.costCents, estimate.baseCurrency)}</span>
<div className="w-full rounded-t-xl bg-gray-300 dark:bg-gray-600" style={{ height: `${costH}%` }} />
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">Cost</span>
<span className="text-xs text-gray-500 dark:text-gray-400">{formatMoney(totals.costCents, estimate.baseCurrency)}</span>
</div>
<div className="flex-1 flex flex-col items-center gap-1">
<div
className={clsx("w-full rounded-t-xl", marginCents >= 0 ? "bg-emerald-400" : "bg-red-400")}
style={{ height: `${marginH}%` }}
/>
<span className="text-xs font-medium text-gray-600">Margin</span>
<span className="text-xs text-gray-500">{formatMoney(marginCents, estimate.baseCurrency)}</span>
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">Margin</span>
<span className="text-xs text-gray-500 dark:text-gray-400">{formatMoney(marginCents, estimate.baseCurrency)}</span>
</div>
<div className="flex-1 flex flex-col items-center gap-1">
<div className="w-full rounded-t-xl bg-brand-500" style={{ height: `${priceH}%` }} />
<span className="text-xs font-medium text-gray-600">Price</span>
<span className="text-xs text-gray-500">{formatMoney(totals.priceCents, estimate.baseCurrency)}</span>
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">Price</span>
<span className="text-xs text-gray-500 dark:text-gray-400">{formatMoney(totals.priceCents, estimate.baseCurrency)}</span>
</div>
</>
);
@@ -137,12 +137,12 @@ export function FinancialsTab({ estimate, canEdit }: { estimate: EstimateWorkspa
</div>
{/* Chapter breakdown */}
<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">Breakdown by chapter <InfoTooltip content="Financial aggregation by department/chapter. Chapter margin % = (price - cost) / price x 100." /></h3>
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
<h3 className="mb-3 text-base font-semibold text-gray-900 dark:text-gray-100">Breakdown by chapter <InfoTooltip content="Financial aggregation by department/chapter. Chapter margin % = (price - cost) / price x 100." /></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">
<tr className="border-b border-gray-200 dark:border-gray-700 text-xs uppercase tracking-wider text-gray-500 dark:text-gray-400">
<th className="py-2 pr-3 font-medium">Chapter</th>
<th className="px-3 py-2 text-right font-medium">Lines</th>
<th className="px-3 py-2 text-right font-medium">Hours</th>
@@ -157,31 +157,31 @@ export function FinancialsTab({ estimate, canEdit }: { estimate: EstimateWorkspa
const chapterMargin = data.priceCents - data.costCents;
const chapterMarginPct = data.priceCents > 0 ? (chapterMargin / data.priceCents) * 100 : 0;
return (
<tr key={chapter} className="border-b border-gray-100">
<td className="py-2 pr-3 font-medium text-gray-900">{chapter}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-600">{data.count}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{data.hours.toFixed(1)}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{formatMoney(data.costCents, estimate.baseCurrency)}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{formatMoney(data.priceCents, estimate.baseCurrency)}</td>
<td className={clsx("px-3 py-2 text-right tabular-nums", chapterMargin >= 0 ? "text-emerald-700" : "text-red-700")}>
<tr key={chapter} className="border-b border-gray-100 dark:border-gray-700/50">
<td className="py-2 pr-3 font-medium text-gray-900 dark:text-gray-100">{chapter}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-600 dark:text-gray-400">{data.count}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700 dark:text-gray-300">{data.hours.toFixed(1)}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700 dark:text-gray-300">{formatMoney(data.costCents, estimate.baseCurrency)}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700 dark:text-gray-300">{formatMoney(data.priceCents, estimate.baseCurrency)}</td>
<td className={clsx("px-3 py-2 text-right tabular-nums", chapterMargin >= 0 ? "text-emerald-700 dark:text-emerald-400" : "text-red-700 dark:text-red-400")}>
{formatMoney(chapterMargin, estimate.baseCurrency)}
</td>
<td className={clsx("pl-3 py-2 text-right tabular-nums", chapterMarginPct >= 0 ? "text-emerald-700" : "text-red-700")}>
<td className={clsx("pl-3 py-2 text-right tabular-nums", chapterMarginPct >= 0 ? "text-emerald-700 dark:text-emerald-400" : "text-red-700 dark:text-red-400")}>
{chapterMarginPct.toFixed(1)}%
</td>
</tr>
);
})}
<tr className="border-t-2 border-gray-300 font-semibold">
<td className="py-2 pr-3 text-gray-900">Total</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-900">{demandLines.length}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-900">{totals.hours.toFixed(1)}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-900">{formatMoney(totals.costCents, estimate.baseCurrency)}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-900">{formatMoney(totals.priceCents, estimate.baseCurrency)}</td>
<td className={clsx("px-3 py-2 text-right tabular-nums", marginCents >= 0 ? "text-emerald-700" : "text-red-700")}>
<tr className="border-t-2 border-gray-300 dark:border-gray-600 font-semibold">
<td className="py-2 pr-3 text-gray-900 dark:text-gray-100">Total</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-900 dark:text-gray-100">{demandLines.length}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-900 dark:text-gray-100">{totals.hours.toFixed(1)}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-900 dark:text-gray-100">{formatMoney(totals.costCents, estimate.baseCurrency)}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-900 dark:text-gray-100">{formatMoney(totals.priceCents, estimate.baseCurrency)}</td>
<td className={clsx("px-3 py-2 text-right tabular-nums", marginCents >= 0 ? "text-emerald-700 dark:text-emerald-400" : "text-red-700 dark:text-red-400")}>
{formatMoney(marginCents, estimate.baseCurrency)}
</td>
<td className={clsx("pl-3 py-2 text-right tabular-nums", marginPercent >= 0 ? "text-emerald-700" : "text-red-700")}>
<td className={clsx("pl-3 py-2 text-right tabular-nums", marginPercent >= 0 ? "text-emerald-700 dark:text-emerald-400" : "text-red-700 dark:text-red-400")}>
{marginPercent.toFixed(1)}%
</td>
</tr>
@@ -192,12 +192,12 @@ export function FinancialsTab({ estimate, canEdit }: { estimate: EstimateWorkspa
{/* Monthly cost/price phasing */}
{sortedMonths.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">Monthly financial phasing <InfoTooltip content="Monthly cost and price derived from each line's hourly spread. Cost = monthly hours x line cost rate. Price = monthly hours x line sell rate." /></h3>
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
<h3 className="mb-3 text-base font-semibold text-gray-900 dark:text-gray-100">Monthly financial phasing <InfoTooltip content="Monthly cost and price derived from each line's hourly spread. Cost = monthly hours x line cost rate. Price = monthly hours x line sell rate." /></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">
<tr className="border-b border-gray-200 dark:border-gray-700 text-xs uppercase tracking-wider text-gray-500 dark:text-gray-400">
<th className="py-2 pr-3 font-medium">Month</th>
<th className="px-3 py-2 text-right font-medium">Hours</th>
<th className="px-3 py-2 text-right font-medium">Cost</th>
@@ -210,12 +210,12 @@ export function FinancialsTab({ estimate, canEdit }: { estimate: EstimateWorkspa
const data = monthlyFinancials.get(month)!;
const mMargin = data.priceCents - data.costCents;
return (
<tr key={month} className="border-b border-gray-100">
<td className="py-2 pr-3 font-medium text-gray-900">{month}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{data.hours.toFixed(1)}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{formatMoney(data.costCents, estimate.baseCurrency)}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{formatMoney(data.priceCents, estimate.baseCurrency)}</td>
<td className={clsx("pl-3 py-2 text-right tabular-nums", mMargin >= 0 ? "text-emerald-700" : "text-red-700")}>
<tr key={month} className="border-b border-gray-100 dark:border-gray-700/50">
<td className="py-2 pr-3 font-medium text-gray-900 dark:text-gray-100">{month}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700 dark:text-gray-300">{data.hours.toFixed(1)}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700 dark:text-gray-300">{formatMoney(data.costCents, estimate.baseCurrency)}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700 dark:text-gray-300">{formatMoney(data.priceCents, estimate.baseCurrency)}</td>
<td className={clsx("pl-3 py-2 text-right tabular-nums", mMargin >= 0 ? "text-emerald-700 dark:text-emerald-400" : "text-red-700 dark:text-red-400")}>
{formatMoney(mMargin, estimate.baseCurrency)}
</td>
</tr>
@@ -11,18 +11,18 @@ import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { formatDateLong, formatMoney } from "~/lib/format.js";
const STATUS_STYLES: Record<EstimateStatus, string> = {
DRAFT: "bg-slate-100 text-slate-700",
IN_REVIEW: "bg-amber-100 text-amber-700",
APPROVED: "bg-emerald-100 text-emerald-700",
ARCHIVED: "bg-zinc-200 text-zinc-700",
DRAFT: "bg-slate-100 text-slate-700 dark:bg-slate-900/30 dark:text-slate-300",
IN_REVIEW: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300",
APPROVED: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300",
ARCHIVED: "bg-zinc-200 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300",
};
const VERSION_STYLES: Record<EstimateVersionStatus, string> = {
WORKING: "bg-sky-100 text-sky-700",
BASELINE: "bg-violet-100 text-violet-700",
SUBMITTED: "bg-amber-100 text-amber-700",
APPROVED: "bg-emerald-100 text-emerald-700",
SUPERSEDED: "bg-zinc-200 text-zinc-700",
WORKING: "bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-300",
BASELINE: "bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300",
SUBMITTED: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300",
APPROVED: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300",
SUPERSEDED: "bg-zinc-200 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300",
};
function formatMetricValue(metric: EstimateMetricView) {
@@ -43,13 +43,13 @@ export function OverviewTab({ estimate }: { estimate: EstimateWorkspaceView }) {
return (
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.1fr),340px]">
<section className="space-y-6">
<div className="rounded-3xl border border-gray-200 bg-white p-6 shadow-sm">
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6 shadow-sm">
<div className="flex flex-wrap items-center gap-2">
<span className={clsx("rounded-full px-3 py-1 text-xs font-semibold", STATUS_STYLES[estimate.status])}>
{estimate.status.replace("_", " ")}
</span>
{estimate.project && (
<span className="rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-600">
<span className="rounded-full bg-gray-100 dark:bg-gray-800 px-3 py-1 text-xs font-medium text-gray-600 dark:text-gray-400">
{estimate.project.shortCode}
</span>
)}
@@ -58,43 +58,43 @@ export function OverviewTab({ estimate }: { estimate: EstimateWorkspaceView }) {
<div className="mt-5 grid gap-4 md:grid-cols-2">
<div>
<p className="text-xs uppercase tracking-wide text-gray-400">Opportunity <InfoTooltip content="External CRM or sales reference linking this estimate to a sales opportunity." /></p>
<p className="mt-1 text-sm text-gray-800">{estimate.opportunityId ?? "Not set"}</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-200">{estimate.opportunityId ?? "Not set"}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-gray-400">Base currency <InfoTooltip content="The primary currency for all monetary calculations in this estimate." /></p>
<p className="mt-1 text-sm text-gray-800">{estimate.baseCurrency}</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-200">{estimate.baseCurrency}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-gray-400">Latest version <InfoTooltip content="The most recent version snapshot. Each version captures a full copy of scope, demand, and financials." /></p>
<p className="mt-1 text-sm text-gray-800">
<p className="mt-1 text-sm text-gray-800 dark:text-gray-200">
{latestVersion ? `v${latestVersion.versionNumber}${latestVersion.label ? ` - ${latestVersion.label}` : ""}` : "No version"}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-gray-400">Updated</p>
<p className="mt-1 text-sm text-gray-800">{formatDateLong(estimate.updatedAt)}</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-200">{formatDateLong(estimate.updatedAt)}</p>
</div>
</div>
{latestVersion?.notes && (
<div className="mt-5 rounded-2xl border border-gray-100 bg-gray-50 p-4">
<div className="mt-5 rounded-2xl border border-gray-100 dark:border-gray-700/50 bg-gray-50 dark:bg-gray-900 p-4">
<p className="text-xs uppercase tracking-wide text-gray-400">Version notes</p>
<p className="mt-2 text-sm text-gray-700">{latestVersion.notes}</p>
<p className="mt-2 text-sm text-gray-700 dark:text-gray-300">{latestVersion.notes}</p>
</div>
)}
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-gray-900">Scope items <InfoTooltip content="Deliverables or work packages included in this estimate version." /></p>
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">Scope items <InfoTooltip content="Deliverables or work packages included in this estimate version." /></p>
<span className="text-xs text-gray-400">{latestVersion?.scopeItems.length ?? 0}</span>
</div>
<div className="mt-4 space-y-2">
{(latestVersion?.scopeItems ?? []).slice(0, 4).map((item) => (
<div key={item.id} className="rounded-2xl border border-gray-100 px-4 py-3">
<div key={item.id} className="rounded-2xl border border-gray-100 dark:border-gray-700/50 px-4 py-3">
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-medium text-gray-900">{item.name}</p>
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">{item.name}</p>
<span className="text-xs text-gray-400">{item.scopeType}</span>
</div>
</div>
@@ -103,17 +103,17 @@ export function OverviewTab({ estimate }: { estimate: EstimateWorkspaceView }) {
</div>
</div>
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-gray-900">Demand lines <InfoTooltip content="Staffing demand rows with hours, cost rate, and sell rate per role or resource." /></p>
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">Demand lines <InfoTooltip content="Staffing demand rows with hours, cost rate, and sell rate per role or resource." /></p>
<span className="text-xs text-gray-400">{latestVersion?.demandLines.length ?? 0}</span>
</div>
<div className="mt-4 space-y-2">
{(latestVersion?.demandLines ?? []).slice(0, 4).map((line) => (
<div key={line.id} className="rounded-2xl border border-gray-100 px-4 py-3">
<div key={line.id} className="rounded-2xl border border-gray-100 dark:border-gray-700/50 px-4 py-3">
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-medium text-gray-900">{line.name}</p>
<span className="text-xs text-gray-500">{line.hours.toFixed(1)} h</span>
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">{line.name}</p>
<span className="text-xs text-gray-500 dark:text-gray-400">{line.hours.toFixed(1)} h</span>
</div>
</div>
))}
@@ -124,44 +124,44 @@ export function OverviewTab({ estimate }: { estimate: EstimateWorkspaceView }) {
</section>
<aside className="space-y-4">
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<p className="text-sm font-semibold text-gray-900">Summary metrics <InfoTooltip content="Key financial indicators derived from the latest version's demand lines." /></p>
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">Summary metrics <InfoTooltip content="Key financial indicators derived from the latest version's demand lines." /></p>
<div className="mt-4 space-y-3">
{latestMetrics.length === 0 ? (
<p className="text-sm text-gray-400">No derived metrics available yet.</p>
) : (
latestMetrics.map((metric) => (
<div key={metric.id} className="flex items-center justify-between gap-3 rounded-2xl bg-gray-50 px-4 py-3">
<div key={metric.id} className="flex items-center justify-between gap-3 rounded-2xl bg-gray-50 dark:bg-gray-900 px-4 py-3">
<span className="text-xs uppercase tracking-wide text-gray-400">{metric.label}</span>
<span className="text-sm font-semibold text-gray-900">{formatMetricValue(metric)}</span>
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">{formatMetricValue(metric)}</span>
</div>
))
)}
</div>
</div>
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<p className="text-sm font-semibold text-gray-900">Version context <InfoTooltip content="Metadata about the latest version, including its workflow status and linked records." /></p>
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">Version context <InfoTooltip content="Metadata about the latest version, including its workflow status and linked records." /></p>
<div className="mt-4 space-y-3">
{latestVersion ? (
<>
<div className="flex items-center justify-between gap-3">
<span className="text-sm text-gray-500">Status</span>
<span className="text-sm text-gray-500 dark:text-gray-400">Status</span>
<span className={clsx("rounded-full px-2.5 py-1 text-xs font-medium", VERSION_STYLES[latestVersion.status])}>
{latestVersion.status}
</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-sm text-gray-500">Assumptions</span>
<span className="text-sm font-medium text-gray-900">{latestVersion.assumptions.length}</span>
<span className="text-sm text-gray-500 dark:text-gray-400">Assumptions</span>
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">{latestVersion.assumptions.length}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-sm text-gray-500">Snapshots</span>
<span className="text-sm font-medium text-gray-900">{latestVersion.resourceSnapshots.length}</span>
<span className="text-sm text-gray-500 dark:text-gray-400">Snapshots</span>
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">{latestVersion.resourceSnapshots.length}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-sm text-gray-500">Exports</span>
<span className="text-sm font-medium text-gray-900">{latestVersion.exports.length}</span>
<span className="text-sm text-gray-500 dark:text-gray-400">Exports</span>
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">{latestVersion.exports.length}</span>
</div>
</>
) : (
@@ -8,7 +8,7 @@ import type {
function EmptyState({ children }: { children: React.ReactNode }) {
return (
<div className="rounded-3xl border border-dashed border-gray-200 bg-white px-5 py-10 text-center text-sm text-gray-400">
<div className="rounded-3xl border border-dashed border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-5 py-10 text-center text-sm text-gray-400">
{children}
</div>
);
@@ -25,24 +25,24 @@ export function ScopeTab({ estimate }: { estimate: EstimateWorkspaceView }) {
return (
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm text-gray-500">
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<span>Scope items define the deliverables and work packages in this estimate.</span>
<InfoTooltip content="Each scope item represents a distinct deliverable (e.g. a shot, sequence, or asset). Scope items organize the estimate but do not directly affect cost calculations." />
</div>
{scopeItems.map((item) => (
<div key={item.id} className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div key={item.id} className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<span className="rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-600">
<span className="rounded-full bg-gray-100 dark:bg-gray-800 px-2.5 py-1 text-xs font-medium text-gray-600 dark:text-gray-400">
#{item.sequenceNo}
</span>
<span className="rounded-full bg-brand-50 px-2.5 py-1 text-xs font-medium text-brand-700">
{item.scopeType}
</span>
</div>
<h3 className="mt-3 text-lg font-semibold text-gray-900">{item.name}</h3>
{item.description && <p className="mt-2 text-sm text-gray-600">{item.description}</p>}
<h3 className="mt-3 text-lg font-semibold text-gray-900 dark:text-gray-100">{item.name}</h3>
{item.description && <p className="mt-2 text-sm text-gray-600 dark:text-gray-400">{item.description}</p>}
</div>
<div className="grid gap-2 text-right text-xs text-gray-400">
{item.frameCount != null && <span>{item.frameCount} frames</span>}
@@ -18,7 +18,7 @@ import { formatMoney } from "~/lib/format.js";
function EmptyState({ children }: { children: React.ReactNode }) {
return (
<div className="rounded-3xl border border-dashed border-gray-200 bg-white px-5 py-10 text-center text-sm text-gray-400">
<div className="rounded-3xl border border-dashed border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-5 py-10 text-center text-sm text-gray-400">
{children}
</div>
);
@@ -71,11 +71,11 @@ export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspace
});
return (
<div key={line.id} className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div key={line.id} className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h3 className="text-lg font-semibold text-gray-900">{line.name}</h3>
<div className="mt-2 flex flex-wrap gap-2 text-xs text-gray-500">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">{line.name}</h3>
<div className="mt-2 flex flex-wrap gap-2 text-xs text-gray-500 dark:text-gray-400">
<span>{line.lineType}</span>
{line.chapter && <span>{line.chapter}</span>}
{line.rateSource && <span>{line.rateSource}</span>}
@@ -84,8 +84,8 @@ export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspace
className={clsx(
"rounded-full px-2.5 py-1 font-medium",
calculation.costRateMode === "resource"
? "bg-emerald-50 text-emerald-700"
: "bg-amber-50 text-amber-700",
? "bg-emerald-50 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300"
: "bg-amber-50 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300",
)}
>
Cost {calculation.costRateMode === "resource" ? "live" : "manual"}
@@ -94,8 +94,8 @@ export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspace
className={clsx(
"rounded-full px-2.5 py-1 font-medium",
calculation.billRateMode === "resource"
? "bg-emerald-50 text-emerald-700"
: "bg-amber-50 text-amber-700",
? "bg-emerald-50 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300"
: "bg-amber-50 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300",
)}
>
Sell {calculation.billRateMode === "resource" ? "live" : "manual"}
@@ -103,37 +103,37 @@ export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspace
</div>
</div>
<div className="text-right">
<p className="text-sm font-semibold text-gray-900">{line.hours.toFixed(1)} h</p>
<p className="mt-1 text-xs text-gray-500">{effectiveValues.currency}</p>
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">{line.hours.toFixed(1)} h</p>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">{effectiveValues.currency}</p>
</div>
</div>
<div className="mt-5 grid gap-3 md:grid-cols-4">
<div className="rounded-2xl bg-gray-50 px-4 py-3">
<div className="rounded-2xl bg-gray-50 dark:bg-gray-900 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Cost rate <InfoTooltip content="Internal hourly cost rate. Can be synced from the live resource or manually overridden." /></p>
<p className="mt-1 text-sm font-medium text-gray-900">{formatMoney(line.costRateCents, line.currency)}</p>
<p className="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">{formatMoney(line.costRateCents, line.currency)}</p>
{linkedSnapshot && calculation.costRateMode === "manual" && (
<p className="mt-1 text-xs text-gray-500">
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Live snapshot {formatMoney(linkedSnapshot.lcrCents, linkedSnapshot.currency)}
</p>
)}
</div>
<div className="rounded-2xl bg-gray-50 px-4 py-3">
<div className="rounded-2xl bg-gray-50 dark:bg-gray-900 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Sell rate <InfoTooltip content="Client-facing hourly rate. Can be synced from the live resource or manually overridden." /></p>
<p className="mt-1 text-sm font-medium text-gray-900">{formatMoney(line.billRateCents, line.currency)}</p>
<p className="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">{formatMoney(line.billRateCents, line.currency)}</p>
{linkedSnapshot && calculation.billRateMode === "manual" && (
<p className="mt-1 text-xs text-gray-500">
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Live snapshot {formatMoney(linkedSnapshot.ucrCents, linkedSnapshot.currency)}
</p>
)}
</div>
<div className="rounded-2xl bg-gray-50 px-4 py-3">
<div className="rounded-2xl bg-gray-50 dark:bg-gray-900 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Cost total <InfoTooltip content="Line cost total = hours x cost rate. Stored in cents." /></p>
<p className="mt-1 text-sm font-medium text-gray-900">{formatMoney(effectiveValues.costTotalCents, effectiveValues.currency)}</p>
<p className="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">{formatMoney(effectiveValues.costTotalCents, effectiveValues.currency)}</p>
</div>
<div className="rounded-2xl bg-gray-50 px-4 py-3">
<div className="rounded-2xl bg-gray-50 dark:bg-gray-900 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Price total <InfoTooltip content="Line price total = hours x sell rate. This is the client-facing revenue for this line." /></p>
<p className="mt-1 text-sm font-medium text-gray-900">{formatMoney(effectiveValues.priceTotalCents, effectiveValues.currency)}</p>
<p className="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">{formatMoney(effectiveValues.priceTotalCents, effectiveValues.currency)}</p>
</div>
</div>
@@ -144,9 +144,9 @@ export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspace
{Object.entries(line.monthlySpread)
.sort(([a], [b]) => a.localeCompare(b))
.map(([month, hours]) => (
<div key={month} className="rounded-xl bg-gray-50 px-3 py-1.5 text-xs">
<span className="text-gray-500">{month}</span>
<span className="ml-1.5 font-medium text-gray-900">{hours.toFixed(1)} h</span>
<div key={month} className="rounded-xl bg-gray-50 dark:bg-gray-900 px-3 py-1.5 text-xs">
<span className="text-gray-500 dark:text-gray-400">{month}</span>
<span className="ml-1.5 font-medium text-gray-900 dark:text-gray-100">{hours.toFixed(1)} h</span>
</div>
))}
</div>
@@ -165,17 +165,17 @@ export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspace
const months = Object.keys(aggregated).sort();
if (months.length === 0) return null;
return (
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<p className="mb-3 text-sm font-semibold text-gray-900">Aggregated monthly phasing <InfoTooltip content="Sum of hours across all demand lines per month, based on the project date range." /></p>
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
<p className="mb-3 text-sm font-semibold text-gray-900 dark:text-gray-100">Aggregated monthly phasing <InfoTooltip content="Sum of hours across all demand lines per month, based on the project date range." /></p>
<div className="flex flex-wrap gap-2">
{months.map((month) => (
<div key={month} className="rounded-xl bg-gray-50 px-3 py-2 text-sm">
<span className="text-gray-500">{month}</span>
<span className="ml-2 font-semibold text-gray-900">{(aggregated[month] ?? 0).toFixed(1)} h</span>
<div key={month} className="rounded-xl bg-gray-50 dark:bg-gray-900 px-3 py-2 text-sm">
<span className="text-gray-500 dark:text-gray-400">{month}</span>
<span className="ml-2 font-semibold text-gray-900 dark:text-gray-100">{(aggregated[month] ?? 0).toFixed(1)} h</span>
</div>
))}
</div>
<div className="mt-3 text-right text-sm font-semibold text-gray-700">
<div className="mt-3 text-right text-sm font-semibold text-gray-700 dark:text-gray-300">
Total: {Object.values(aggregated).reduce((a, b) => a + b, 0).toFixed(1)} h
</div>
</div>
@@ -12,11 +12,11 @@ import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { formatDateLong, formatMoney } from "~/lib/format.js";
const VERSION_STYLES: Record<EstimateVersionStatus, string> = {
WORKING: "bg-sky-100 text-sky-700",
BASELINE: "bg-violet-100 text-violet-700",
SUBMITTED: "bg-amber-100 text-amber-700",
APPROVED: "bg-emerald-100 text-emerald-700",
SUPERSEDED: "bg-zinc-200 text-zinc-700",
WORKING: "bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-300",
BASELINE: "bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300",
SUBMITTED: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300",
APPROVED: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300",
SUPERSEDED: "bg-zinc-200 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300",
};
function formatMetricValue(metric: EstimateMetricView) {
@@ -31,7 +31,7 @@ function formatMetricValue(metric: EstimateMetricView) {
function EmptyState({ children }: { children: React.ReactNode }) {
return (
<div className="rounded-3xl border border-dashed border-gray-200 bg-white px-5 py-10 text-center text-sm text-gray-400">
<div className="rounded-3xl border border-dashed border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-5 py-10 text-center text-sm text-gray-400">
{children}
</div>
);
@@ -75,24 +75,24 @@ export function VersionsTab({
return (
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm text-gray-500">
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<span>Versions are immutable snapshots of the estimate for comparison and audit.</span>
<InfoTooltip content="Each version captures a full copy of scope, assumptions, demand lines, and metrics. WORKING versions can be edited; SUBMITTED and APPROVED versions are locked." />
</div>
{versions.map((version) => (
<div key={version.id} className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div key={version.id} className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<span className="text-lg font-semibold text-gray-900">v{version.versionNumber}</span>
<span className="text-lg font-semibold text-gray-900 dark:text-gray-100">v{version.versionNumber}</span>
<span className={clsx("rounded-full px-2.5 py-1 text-xs font-medium", VERSION_STYLES[version.status])}>
{version.status}
</span>
</div>
<p className="mt-2 text-sm text-gray-600">{version.label ?? "Unlabeled version"}</p>
{version.notes && <p className="mt-2 text-sm text-gray-500">{version.notes}</p>}
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">{version.label ?? "Unlabeled version"}</p>
{version.notes && <p className="mt-2 text-sm text-gray-500 dark:text-gray-400">{version.notes}</p>}
</div>
<div className="text-right text-sm text-gray-500">
<div className="text-right text-sm text-gray-500 dark:text-gray-400">
<p>Updated {formatDateLong(version.updatedAt)}</p>
{version.lockedAt && (
<p className="mt-1">Locked {formatDateLong(version.lockedAt)}</p>
@@ -130,7 +130,7 @@ export function VersionsTab({
type="button"
onClick={() => onCreateRevision(version.id)}
disabled={isSubmitting || isApproving || isCreatingRevision || isCreatingPlanningHandoff}
className="rounded-2xl border border-brand-200 bg-white px-3 py-2 text-sm font-semibold text-brand-700 transition hover:border-brand-300 hover:bg-brand-50 disabled:cursor-not-allowed disabled:opacity-60"
className="rounded-2xl border border-brand-200 bg-white dark:bg-gray-800 px-3 py-2 text-sm font-semibold text-brand-700 transition hover:border-brand-300 hover:bg-brand-50 disabled:cursor-not-allowed disabled:opacity-60"
>
{isCreatingRevision ? "Creating revision..." : "Create working revision"}
</button>
@@ -160,7 +160,7 @@ export function VersionsTab({
)}
{version.status === EstimateVersionStatus.APPROVED && !hasLinkedProject && (
<p className="mt-3 text-sm text-amber-700">
<p className="mt-3 text-sm text-amber-700 dark:text-amber-300">
Link this estimate to a project before handing approved demand into planning.
</p>
)}
@@ -168,9 +168,9 @@ export function VersionsTab({
{version.metrics.length > 0 && (
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-5">
{version.metrics.map((metric) => (
<div key={metric.id} className="rounded-2xl bg-gray-50 px-4 py-3">
<div key={metric.id} className="rounded-2xl bg-gray-50 dark:bg-gray-900 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">{metric.label}</p>
<p className="mt-1 text-sm font-semibold text-gray-900">{formatMetricValue(metric)}</p>
<p className="mt-1 text-sm font-semibold text-gray-900 dark:text-gray-100">{formatMetricValue(metric)}</p>
</div>
))}
</div>