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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user