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
@@ -12,7 +12,7 @@ import { ConflictOverlay } from "./ConflictOverlay.js";
import { computeSubLanes } from "./utils.js";
import { heatmapBgColor, heatmapColor } from "./heatmapUtils.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import { formatDateLong } from "~/lib/format.js";
import { TimelineTooltip } from "./TimelineTooltip.js";
import {
ROW_HEIGHT,
SUB_LANE_HEIGHT,
@@ -24,6 +24,7 @@ import type {
AllocDragState,
RangeState,
ShiftPreviewData,
MultiSelectState,
} from "~/hooks/useTimelineDrag.js";
import type { HeatmapColorScheme } from "~/hooks/useAppPreferences.js";
@@ -45,6 +46,7 @@ interface TimelineResourcePanelProps {
anchorX: number,
anchorY: number,
) => void;
multiSelectState: MultiSelectState;
// Layout from useTimelineLayout
CELL_WIDTH: number;
dates: Date[];
@@ -86,6 +88,7 @@ export function TimelineResourcePanel({
onRowMouseDown,
onRowTouchStart,
onAllocationContextMenu,
multiSelectState,
CELL_WIDTH,
dates,
totalCanvasWidth,
@@ -103,6 +106,7 @@ export function TimelineResourcePanel({
viewEnd,
displayMode,
heatmapScheme,
blinkOverbookedDays,
activeFilterCount,
} = useTimelineContext();
@@ -407,7 +411,7 @@ export function TimelineResourcePanel({
>
<div
className={clsx(
"flex border-b border-gray-100 hover:bg-blue-50/20 group transition-colors",
"flex border-b border-gray-100 dark:border-gray-800 hover:bg-blue-50/20 dark:hover:bg-gray-800/30 group transition-colors",
dragState.isDragging && isContextResource && "border-l-4 border-l-brand-400",
)}
style={{ height: rowHeight }}
@@ -415,19 +419,19 @@ export function TimelineResourcePanel({
{/* Label column */}
<div
className={clsx(
"flex-shrink-0 border-r border-gray-200 flex items-center px-4 gap-2.5 bg-white sticky left-0 z-30 group-hover:bg-blue-50",
dragState.isDragging && isContextResource && "bg-brand-50",
"flex-shrink-0 border-r border-gray-200 dark:border-gray-700 flex items-center px-4 gap-2.5 bg-white dark:bg-gray-900 sticky left-0 z-30 group-hover:bg-blue-50 dark:group-hover:bg-gray-800",
dragState.isDragging && isContextResource && "bg-brand-50 dark:bg-brand-950/40",
)}
style={{ width: LABEL_WIDTH }}
>
<div className="w-8 h-8 rounded-full bg-brand-100 flex items-center justify-center text-xs font-bold text-brand-700 flex-shrink-0">
<div className="w-8 h-8 rounded-full bg-brand-100 dark:bg-brand-900/40 flex items-center justify-center text-xs font-bold text-brand-700 dark:text-brand-300 flex-shrink-0">
{resource.displayName.slice(0, 2).toUpperCase()}
</div>
<div className="min-w-0">
<div className="text-sm font-medium text-gray-900 truncate">
<div className="min-w-0" data-resource-hover-id={resource.id}>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate cursor-pointer hover:text-brand-600 dark:hover:text-brand-400 transition-colors">
{resource.displayName}
</div>
<div className="text-xs text-gray-400 truncate">
<div className="text-xs text-gray-400 dark:text-gray-500 truncate">
{resource.chapter ?? resource.eid}
</div>
</div>
@@ -467,6 +471,7 @@ export function TimelineResourcePanel({
toLeft,
toWidth,
totalCanvasWidth,
multiSelectState,
)
: renderAllocBlocksFromData(
precomputed?.blockData ?? [],
@@ -480,6 +485,7 @@ export function TimelineResourcePanel({
onAllocMouseDown,
onAllocTouchStart,
onAllocationContextMenu,
multiSelectState,
)}
{renderVacationBlocksForRow(
vacationBlocksByResource.get(resource.id) ?? [],
@@ -488,6 +494,8 @@ export function TimelineResourcePanel({
{displayMode === "strip" && renderLoadGraph(allocs, dates, CELL_WIDTH)}
{displayMode === "heatmap" &&
renderHeatmapOverlay(allocs, dates, CELL_WIDTH, heatmapScheme)}
{blinkOverbookedDays &&
renderOverbookingBlink(allocs, dates, CELL_WIDTH)}
{renderRangeOverlay(
rangeState,
resource.id,
@@ -523,7 +531,7 @@ export function TimelineResourcePanel({
})}
{/* Tooltips rendered inside the panel so they live near their data source */}
<ResourcePanelTooltips
<TimelineTooltip
heatmapTooltipRef={heatmapTooltipRef}
heatmapTooltipPos={heatmapTooltipPosRef.current}
vacationTooltipRef={vacationTooltipRef}
@@ -535,113 +543,7 @@ export function TimelineResourcePanel({
);
}
// ─── Tooltip sub-component (portal-free: positioned fixed) ──────────────────
function ResourcePanelTooltips({
heatmapTooltipRef,
heatmapTooltipPos,
vacationTooltipRef,
vacationTooltipPos,
heatmapHover,
vacationHover,
}: {
heatmapTooltipRef: React.RefObject<HTMLDivElement | null>;
heatmapTooltipPos: { left: number; top: number };
vacationTooltipRef: React.RefObject<HTMLDivElement | null>;
vacationTooltipPos: { left: number; top: number };
heatmapHover: {
date: Date;
totalH: number;
pct: number;
breakdown: {
projectId: string;
shortCode: string;
projectName: string;
orderType: string;
hoursPerDay: number;
responsiblePerson?: string | null;
}[];
} | null;
vacationHover: {
type: string;
startDate: Date | string;
endDate: Date | string;
note?: string | null;
requestedBy?: { name?: string | null; email: string } | null;
approvedBy?: { name?: string | null; email: string } | null;
approvedAt?: Date | string | null;
} | null;
}) {
return (
<>
{heatmapHover ? (
<div
ref={heatmapTooltipRef}
style={{
left: heatmapTooltipPos.left,
top: heatmapTooltipPos.top,
backgroundColor: "rgba(3, 7, 18, 0.96)",
}}
className="fixed z-40 max-w-sm pointer-events-none rounded-xl border border-gray-800 bg-gray-950/96 px-3 py-2 text-xs text-white shadow-2xl"
>
<div className="flex items-center justify-between gap-3">
<span className="font-semibold">{formatDateLong(heatmapHover.date)}</span>
<span className="text-[11px] text-gray-300">
{heatmapHover.totalH.toFixed(1)}h · {Math.round(heatmapHover.pct)}%
</span>
</div>
<div className="mt-2 space-y-1.5">
{heatmapHover.breakdown.length > 0 ? (
heatmapHover.breakdown.slice(0, 6).map((entry) => (
<div
key={`${entry.projectId}-${entry.shortCode}`}
className="flex items-start justify-between gap-3"
>
<div className="min-w-0">
<div className="truncate font-medium text-white">
{entry.shortCode ? `${entry.shortCode} · ` : ""}
{entry.projectName}
</div>
<div className="truncate text-[11px] text-gray-400">
{entry.responsiblePerson
? `Lead: ${entry.responsiblePerson}`
: entry.orderType}
</div>
</div>
<span className="whitespace-nowrap text-[11px] font-semibold text-gray-200">
{entry.hoursPerDay}h
</span>
</div>
))
) : (
<div className="text-[11px] text-gray-400">No bookings on this day.</div>
)}
</div>
</div>
) : null}
{vacationHover ? (
<div
ref={vacationTooltipRef}
style={{
left: vacationTooltipPos.left,
top: vacationTooltipPos.top,
backgroundColor: "rgba(120, 53, 15, 0.95)",
}}
className="fixed z-40 max-w-xs pointer-events-none rounded-xl border border-amber-700/50 bg-amber-950/95 px-3 py-2 text-xs text-amber-50 shadow-2xl"
>
<div className="font-semibold">{vacationHover.type.replaceAll("_", " ")}</div>
<div className="mt-1 text-[11px] text-amber-100/90">
{formatDateLong(vacationHover.startDate)} to {formatDateLong(vacationHover.endDate)}
</div>
{vacationHover.note ? (
<div className="mt-2 text-[11px] text-amber-100/80">{vacationHover.note}</div>
) : null}
</div>
) : null}
</>
);
}
// ResourcePanelTooltips removed — now uses shared TimelineTooltip component
// ─── Helper types ───────────────────────────────────────────────────────────
@@ -749,6 +651,7 @@ function renderAllocBlocksFromData(
anchorX: number,
anchorY: number,
) => void,
multiSelectState: MultiSelectState,
) {
const anyDragActive = dragState.isDragging || allocDragState.isActive;
@@ -771,8 +674,23 @@ function renderAllocBlocksFromData(
dispEnd = allocDragState.currentEndDate;
}
const left = toLeft(dispStart);
const width = Math.max(CELL_WIDTH, toWidth(dispStart, dispEnd));
// Multi-drag offset: shift selected allocations visually during multi-drag
const isMultiDragTarget =
multiSelectState.isMultiDragging &&
multiSelectState.selectedAllocationIds.includes(alloc.id);
const multiDragPx = isMultiDragTarget ? multiSelectState.multiDragDaysDelta * CELL_WIDTH : 0;
const multiDragMode = multiSelectState.multiDragMode;
let left = toLeft(dispStart);
let width = Math.max(CELL_WIDTH, toWidth(dispStart, dispEnd));
// For multi-drag resize, adjust left/width instead of using translateX
if (isMultiDragTarget && multiDragMode === "resize-start") {
left += multiDragPx;
width = Math.max(CELL_WIDTH, width - multiDragPx);
} else if (isMultiDragTarget && multiDragMode === "resize-end") {
width = Math.max(CELL_WIDTH, width + multiDragPx);
}
if (width <= 0 || left >= totalCanvasWidth) return null;
const blockTop = 8 + lane * SUB_LANE_HEIGHT;
@@ -811,6 +729,7 @@ function renderAllocBlocksFromData(
: isOtherDragged
? "opacity-30 z-[10]"
: "hover:ring-2 hover:ring-white hover:ring-offset-1 z-[10]",
multiSelectState.selectedAllocationIds.includes(alloc.id) && "ring-2 ring-sky-500 ring-offset-1 z-20",
)}
style={{
left: left + 2,
@@ -818,6 +737,12 @@ function renderAllocBlocksFromData(
top: blockTop,
height: blockHeight,
...(customColor ? { backgroundColor: customColor } : {}),
...(multiDragPx && multiDragMode === "move" ? { transform: `translateX(${multiDragPx}px)` } : {}),
}}
onMouseDown={(e) => {
// Stop right-click mouseDown from bubbling to the canvas,
// which would falsely start a multi-selection rectangle.
if (e.button === 2) e.stopPropagation();
}}
onContextMenu={(e) => {
e.preventDefault();
@@ -965,6 +890,45 @@ function renderHeatmapOverlay(
});
}
// ─── Overbooking blink overlay ───────────────────────────────────────────────
function renderOverbookingBlink(
allocs: TimelineAssignmentEntry[],
dates: Date[],
CELL_WIDTH: number,
) {
const REF_H = 8;
const overbooked: number[] = [];
for (let i = 0; i < dates.length; i++) {
const d = new Date(dates[i]!);
d.setHours(0, 0, 0, 0);
const t = d.getTime();
let totalH = 0;
for (const a of allocs) {
const s = new Date(a.startDate);
s.setHours(0, 0, 0, 0);
const e = new Date(a.endDate);
e.setHours(0, 0, 0, 0);
if (t >= s.getTime() && t <= e.getTime()) totalH += a.hoursPerDay;
}
if (totalH > REF_H) overbooked.push(i);
}
if (overbooked.length === 0) return null;
return overbooked.map((i) => (
<div
key={`ob-${i}`}
className="absolute top-0 bottom-0 pointer-events-none z-[15] animate-overbooking-blink"
style={{
left: i * CELL_WIDTH,
width: CELL_WIDTH,
}}
/>
));
}
// ─── Bar-mode: stacked daily bars ────────────────────────────────────────────
function renderDailyBars(
@@ -983,6 +947,7 @@ function renderDailyBars(
toLeft: (d: Date) => number,
toWidth: (s: Date, e: Date) => number,
totalCanvasWidth: number,
multiSelectState: MultiSelectState,
) {
const BAR_AREA = rowHeight - 8;
const REF_H = 8;
@@ -1061,8 +1026,21 @@ function renderDailyBars(
isBeingDragged
? "opacity-90 ring-2 ring-white ring-offset-1 z-20"
: "hover:opacity-80 z-[10]",
multiSelectState.selectedAllocationIds.includes(alloc.id) && "ring-2 ring-sky-500 ring-offset-1 z-20",
)}
style={{ left: i * CELL_WIDTH + 2, width: CELL_WIDTH - 4, height: segH, bottom }}
style={{
left: i * CELL_WIDTH + 2,
width: CELL_WIDTH - 4,
height: segH,
bottom,
...(multiSelectState.isMultiDragging &&
multiSelectState.selectedAllocationIds.includes(alloc.id)
? { transform: `translateX(${multiSelectState.multiDragDaysDelta * CELL_WIDTH}px)` }
: {}),
}}
onMouseDown={(e) => {
if (e.button === 2) e.stopPropagation();
}}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();