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

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

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-18 23:43:51 +01:00
parent d0f04f13f8
commit ddec3a927a
67 changed files with 4930 additions and 1166 deletions
@@ -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{" "}