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