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