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
+498 -19
View File
@@ -1,13 +1,19 @@
"use client";
import { clsx } from "clsx";
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useAllocationHistory } from "~/hooks/useAllocationHistory.js";
import { useProjectDragContext } from "~/hooks/useProjectDragContext.js";
import { useTimelineDrag } from "~/hooks/useTimelineDrag.js";
import { useTimelineLayout } from "~/hooks/useTimelineLayout.js";
import { trpc } from "~/lib/trpc/client.js";
import { FillOpenDemandModal } from "~/components/allocations/FillOpenDemandModal.js";
import { AllocationPopover } from "./AllocationPopover.js";
import { DemandPopover } from "./DemandPopover.js";
import { ResourceHoverCard } from "./ResourceHoverCard.js";
import type { TimelineDemandEntry } from "./TimelineContext.js";
import { BatchAssignPopover } from "./BatchAssignPopover.js";
import { FloatingActionBar } from "./FloatingActionBar.js";
import { NewAllocationPopover } from "./NewAllocationPopover.js";
import { ProjectPanel } from "./ProjectPanel.js";
import { ShiftPreviewTooltip } from "./ShiftPreviewTooltip.js";
@@ -31,9 +37,11 @@ import { TimelineProjectPanel, type OpenDemandAssignment } from "./TimelineProje
export function TimelineView() {
const mousePosRef = useRef({ x: 0, y: 0 });
const { push: pushHistory, undo, redo, canUndo, canRedo } = useAllocationHistory();
const { push: pushHistory, pushBatch: pushBatchHistory, undo, redo, canUndo, canRedo } = useAllocationHistory();
const pushHistoryRef = useRef(pushHistory);
pushHistoryRef.current = pushHistory;
const pushBatchHistoryRef = useRef(pushBatchHistory);
pushBatchHistoryRef.current = pushBatchHistory;
const [popover, setPopover] = useState<{
allocationId: string;
@@ -48,6 +56,10 @@ export function TimelineView() {
suggestedProjectId: string | null;
anchorX: number;
anchorY: number;
/** Selection coordinates to keep the overlay visible while popover is open */
selectionResourceId: string;
selectionStart: Date;
selectionEnd: Date;
} | null>(null);
// cellWidth placeholder — the real value comes from useTimelineLayout inside the content.
@@ -55,10 +67,22 @@ export function TimelineView() {
// We start with 40 (day zoom default) and update via a ref.
const cellWidthRef = useRef(40);
const outerUtils = trpc.useUtils();
const batchShiftMutationOuter = trpc.timeline.batchShiftAllocations.useMutation({
onSuccess: () => {
void outerUtils.timeline.getEntries.invalidate();
void outerUtils.timeline.getEntriesView.invalidate();
void outerUtils.timeline.getProjectContext.invalidate();
void outerUtils.timeline.getBudgetStatus.invalidate();
},
});
const {
dragState,
allocDragState,
rangeState,
multiSelectState,
setMultiSelectState,
shiftPreview,
isPreviewLoading,
isApplying,
@@ -69,6 +93,8 @@ export function TimelineView() {
onCanvasMouseMove,
onCanvasMouseUp,
onCanvasMouseLeave,
onCanvasRightMouseDown,
clearMultiSelect,
onProjectBarTouchStart,
onAllocTouchStart,
onRowTouchStart,
@@ -92,11 +118,33 @@ export function TimelineView() {
suggestedProjectId: info.suggestedProjectId,
anchorX: info.anchorX,
anchorY: info.anchorY,
selectionResourceId: info.resourceId,
selectionStart: info.startDate,
selectionEnd: info.endDate,
});
},
onAllocationMoved: (snapshot) => {
pushHistoryRef.current(snapshot);
},
onShiftClickAlloc: (allocationId: string) => {
setMultiSelectState(prev => {
const ids = new Set(prev.selectedAllocationIds);
if (ids.has(allocationId)) {
ids.delete(allocationId);
} else {
ids.add(allocationId);
}
return { ...prev, isSelecting: false, selectedAllocationIds: [...ids] };
});
},
onMultiDragComplete: (daysDelta, mode) => {
const ids = multiSelectState.selectedAllocationIds;
if (ids.length > 0 && daysDelta !== 0) {
pushBatchHistoryRef.current(ids, daysDelta, mode);
batchShiftMutationOuter.mutate({ allocationIds: ids, daysDelta, mode });
clearMultiSelect();
}
},
});
const [openPanelProjectId, setOpenPanelProjectId] = useState<string | null>(null);
@@ -115,6 +163,10 @@ export function TimelineView() {
dragState={dragState}
allocDragState={allocDragState}
rangeState={rangeState}
multiSelectState={multiSelectState}
setMultiSelectState={setMultiSelectState}
onCanvasRightMouseDown={onCanvasRightMouseDown}
clearMultiSelect={clearMultiSelect}
shiftPreview={shiftPreview}
isPreviewLoading={isPreviewLoading}
isApplying={isApplying}
@@ -154,6 +206,10 @@ function TimelineViewContent({
dragState,
allocDragState,
rangeState,
multiSelectState,
setMultiSelectState,
onCanvasRightMouseDown,
clearMultiSelect,
shiftPreview,
isPreviewLoading,
isApplying,
@@ -186,6 +242,10 @@ function TimelineViewContent({
dragState: ReturnType<typeof useTimelineDrag>["dragState"];
allocDragState: ReturnType<typeof useTimelineDrag>["allocDragState"];
rangeState: ReturnType<typeof useTimelineDrag>["rangeState"];
multiSelectState: ReturnType<typeof useTimelineDrag>["multiSelectState"];
setMultiSelectState: ReturnType<typeof useTimelineDrag>["setMultiSelectState"];
onCanvasRightMouseDown: ReturnType<typeof useTimelineDrag>["onCanvasRightMouseDown"];
clearMultiSelect: ReturnType<typeof useTimelineDrag>["clearMultiSelect"];
shiftPreview: ReturnType<typeof useTimelineDrag>["shiftPreview"];
isPreviewLoading: boolean;
isApplying: boolean;
@@ -211,6 +271,9 @@ function TimelineViewContent({
suggestedProjectId: string | null;
anchorX: number;
anchorY: number;
selectionResourceId: string;
selectionStart: Date;
selectionEnd: Date;
} | null;
setNewAllocPopover: React.Dispatch<React.SetStateAction<typeof newAllocPopover>>;
openPanelProjectId: string | null;
@@ -224,6 +287,8 @@ function TimelineViewContent({
const {
resources,
projectGroups,
allocsByResource,
openDemandsByProject,
viewStart,
viewEnd,
viewDays,
@@ -248,12 +313,69 @@ function TimelineViewContent({
const dragTooltipRef = useRef<HTMLDivElement>(null);
const allocTooltipRef = useRef<HTMLDivElement>(null);
const rangeHintRef = useRef<HTMLDivElement>(null);
const multiDragTooltipRef = useRef<HTMLDivElement>(null);
const [openDemandToAssign, setOpenDemandToAssign] = useState<OpenDemandAssignment | null>(null);
const [demandPopover, setDemandPopover] = useState<{
demand: TimelineDemandEntry;
x: number;
y: number;
} | null>(null);
const [showBatchAssign, setShowBatchAssign] = useState(false);
const [resourceHover, setResourceHover] = useState<{
resourceId: string;
anchorEl: HTMLElement;
} | null>(null);
const resourceHoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const utils = trpc.useUtils();
const batchDeleteMutation = trpc.allocation.batchDelete.useMutation({
onSuccess: () => {
void utils.timeline.getEntries.invalidate();
void utils.timeline.getEntriesView.invalidate();
void utils.timeline.getProjectContext.invalidate();
void utils.timeline.getBudgetStatus.invalidate();
clearMultiSelect();
},
});
const { CELL_WIDTH, dates, totalCanvasWidth, toLeft, toWidth, gridLines, monthGroups, xToDate } =
useTimelineLayout(viewStart, viewDays, filters.zoom, filters.showWeekends, today);
const hasActivePointerOverlay =
dragState.isDragging || allocDragState.isActive || rangeState.isSelecting;
dragState.isDragging || allocDragState.isActive || rangeState.isSelecting || multiSelectState.isMultiDragging;
// ─── Keep selection overlay visible while popover is open ───────────────────
const effectiveRangeState: typeof rangeState = rangeState.isSelecting
? rangeState
: newAllocPopover
? {
isSelecting: true,
resourceId: newAllocPopover.selectionResourceId,
startDate: newAllocPopover.selectionStart,
currentDate: newAllocPopover.selectionEnd,
suggestedProjectId: newAllocPopover.suggestedProjectId,
startClientX: 0,
}
: rangeState;
// ─── Auto-suggest project for resource-view range select ───────────────────
const enrichedSuggestedProjectId = useMemo(() => {
if (!newAllocPopover) return null;
// Already has a suggestion (e.g. from project view)
if (newAllocPopover.suggestedProjectId) return newAllocPopover.suggestedProjectId;
// Resource view: find the project with the most hours in this resource's row
const allocs = allocsByResource.get(newAllocPopover.resourceId);
if (!allocs || allocs.length === 0) return null;
const projectHours = new Map<string, number>();
for (const alloc of allocs) {
projectHours.set(alloc.projectId, (projectHours.get(alloc.projectId) ?? 0) + alloc.hoursPerDay);
}
let maxPid: string | null = null;
let maxH = 0;
for (const [pid, h] of projectHours) {
if (h > maxH) { maxH = h; maxPid = pid; }
}
return maxPid;
}, [newAllocPopover, allocsByResource]);
function openAllocationPopoverAt(
info: {
@@ -263,6 +385,13 @@ function TimelineViewContent({
anchorX: number,
anchorY: number,
) {
// Check if this is a demand (not an assignment) — route to DemandPopover
const demands = openDemandsByProject.get(info.projectId);
const demand = demands?.find((d) => d.id === info.allocationId);
if (demand) {
setDemandPopover({ demand, x: anchorX, y: anchorY });
return;
}
setPopover({
allocationId: info.allocationId,
projectId: info.projectId,
@@ -295,10 +424,16 @@ function TimelineViewContent({
rangeHintRef.current.style.left = `${x + 12}px`;
rangeHintRef.current.style.top = `${y - 28}px`;
}
if (multiDragTooltipRef.current) {
multiDragTooltipRef.current.style.left = `${x + 14}px`;
multiDragTooltipRef.current.style.top = `${y - 36}px`;
}
};
el.addEventListener("mousemove", handler, { passive: true });
return () => el.removeEventListener("mousemove", handler);
}, [hasActivePointerOverlay, isLoading, mousePosRef]); // eslint-disable-line react-hooks/exhaustive-deps
// During multi-drag, listen on document (cursor may leave canvas)
const target: EventTarget = multiSelectState.isMultiDragging ? document : el;
target.addEventListener("mousemove", handler as EventListener, { passive: true });
return () => target.removeEventListener("mousemove", handler as EventListener);
}, [hasActivePointerOverlay, isLoading, mousePosRef, multiSelectState.isMultiDragging]); // eslint-disable-line react-hooks/exhaustive-deps
// ─── Shift+wheel → horizontal scroll ──────────────────────────────────────
useEffect(() => {
@@ -333,6 +468,92 @@ function TimelineViewContent({
return () => window.removeEventListener("keydown", handler);
}, [undo, redo]);
// ─── ESC to close overlays (topmost first) ─────────────────────────────────
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
if (multiSelectState.selectedAllocationIds.length > 0 || multiSelectState.selectedResourceIds.length > 0) {
e.preventDefault();
clearMultiSelect();
return;
}
if (demandPopover) {
e.preventDefault();
setDemandPopover(null);
} else if (popover) {
e.preventDefault();
setPopover(null);
} else if (newAllocPopover) {
e.preventDefault();
setNewAllocPopover(null);
} else if (openDemandToAssign) {
e.preventDefault();
setOpenDemandToAssign(null);
} else if (openPanelProjectId) {
e.preventDefault();
setOpenPanelProjectId(null);
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [demandPopover, popover, newAllocPopover, openDemandToAssign, openPanelProjectId, setPopover, setNewAllocPopover, setOpenPanelProjectId, multiSelectState.selectedAllocationIds.length, multiSelectState.selectedResourceIds.length, clearMultiSelect]);
// ─── Resource hover card — event delegation on label columns ──────────────
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const HOVER_DELAY = 400;
function onMouseOver(e: MouseEvent) {
const target = (e.target as HTMLElement).closest<HTMLElement>("[data-resource-hover-id]");
if (!target) return;
const rid = target.dataset.resourceHoverId;
if (!rid) return;
// Clear any pending hide
if (resourceHoverTimerRef.current) {
clearTimeout(resourceHoverTimerRef.current);
resourceHoverTimerRef.current = null;
}
// If already showing this resource, skip
if (resourceHover?.resourceId === rid) return;
resourceHoverTimerRef.current = setTimeout(() => {
resourceHoverTimerRef.current = null;
setResourceHover({ resourceId: rid, anchorEl: target });
}, HOVER_DELAY);
}
function onMouseOut(e: MouseEvent) {
const related = e.relatedTarget as HTMLElement | null;
// Don't close if moving into another resource-hover target or the hover card itself
if (related?.closest?.("[data-resource-hover-id]") || related?.closest?.("[data-resource-hover-card]")) return;
if (resourceHoverTimerRef.current) {
clearTimeout(resourceHoverTimerRef.current);
resourceHoverTimerRef.current = null;
}
// Small delay before hiding to allow moving into hover card
resourceHoverTimerRef.current = setTimeout(() => {
resourceHoverTimerRef.current = null;
setResourceHover(null);
}, 150);
}
canvas.addEventListener("mouseover", onMouseOver, { passive: true });
canvas.addEventListener("mouseout", onMouseOut, { passive: true });
return () => {
canvas.removeEventListener("mouseover", onMouseOver);
canvas.removeEventListener("mouseout", onMouseOut);
if (resourceHoverTimerRef.current) {
clearTimeout(resourceHoverTimerRef.current);
resourceHoverTimerRef.current = null;
}
};
}, [resourceHover?.resourceId]); // eslint-disable-line react-hooks/exhaustive-deps
// ─── Lazy-extend date range on scroll ─────────────────────────────────────
function handleContainerScroll() {
const el = scrollContainerRef.current;
@@ -348,6 +569,126 @@ function TimelineViewContent({
onCanvasMouseMove(e);
};
// ─── Multi-select intersection computation ────────────────────────────────
useEffect(() => {
// Only compute when drag just ended (isSelecting false but has coordinates)
if (multiSelectState.isSelecting) return;
if (multiSelectState.startX === 0 && multiSelectState.startY === 0) return;
if (multiSelectState.selectedAllocationIds.length > 0 || multiSelectState.selectedResourceIds.length > 0) return;
const canvasEl = canvasRef.current;
if (!canvasEl) return;
// Selection rectangle in viewport coordinates (same coordinate space as
// getBoundingClientRect). Using viewport coords directly avoids any
// coordinate transformation errors from sticky headers or virtualizer offsets.
const selTop = Math.min(multiSelectState.startY, multiSelectState.currentY);
const selBottom = Math.max(multiSelectState.startY, multiSelectState.currentY);
const selLeft = Math.min(multiSelectState.startX, multiSelectState.currentX);
const selRight = Math.max(multiSelectState.startX, multiSelectState.currentX);
// For X-axis: convert viewport X to canvas-relative X for allocation matching.
// Query any row element to find the actual canvas area position.
const canvasRect = canvasEl.getBoundingClientRect();
const canvasXOffset = canvasRect.left + LABEL_WIDTH;
const toCanvasX = (clientX: number) => clientX - canvasXOffset;
const selLeftCanvas = toCanvasX(selLeft);
const selRightCanvas = toCanvasX(selRight);
// Derive date range from pixel X positions
const colIndexStart = Math.max(0, Math.min(dates.length - 1, Math.floor(selLeftCanvas / CELL_WIDTH)));
const colIndexEnd = Math.max(0, Math.min(dates.length - 1, Math.floor(selRightCanvas / CELL_WIDTH)));
const startDate = dates[colIndexStart] ?? today;
const endDate = dates[colIndexEnd] ?? today;
// Find allocations within the rectangle by querying actual DOM positions.
// This avoids any mismatch between computed row positions and actual rendering.
const selectedIds: string[] = [];
const selectedResIds: string[] = [];
// Query all rendered row elements (virtualizer only renders visible + overscan rows)
const rowElements = canvasEl.querySelectorAll<HTMLElement>("[data-index]");
if (viewMode === "resource") {
rowElements.forEach((rowEl) => {
const idx = Number(rowEl.dataset.index);
const resource = resources[idx];
if (!resource) return;
const rowRect = rowEl.getBoundingClientRect();
// Compare directly in viewport coordinates
if (rowRect.bottom < selTop || rowRect.top > selBottom) return;
selectedResIds.push(resource.id);
const allocs = allocsByResource.get(resource.id) ?? [];
for (const alloc of allocs) {
const allocLeft = toLeft(new Date(alloc.startDate));
const allocRight = allocLeft + toWidth(new Date(alloc.startDate), new Date(alloc.endDate));
if (allocRight >= selLeftCanvas && allocLeft <= selRightCanvas) {
selectedIds.push(alloc.id);
}
}
});
} else if (viewMode === "project") {
// Project view: query actual resource row DOM elements by data attribute.
// Each row carries data-project-id and data-resource-id for alloc lookup.
const projectRowEls = canvasEl.querySelectorAll<HTMLElement>("[data-project-resource-row]");
projectRowEls.forEach((rowEl) => {
const rowRect = rowEl.getBoundingClientRect();
if (rowRect.bottom < selTop || rowRect.top > selBottom) return;
const projectId = rowEl.dataset.projectId;
const resourceId = rowEl.dataset.resourceId;
if (!projectId || !resourceId) return;
// Find matching group and row
const group = projectGroups.find((g) => g.id === projectId);
if (!group) return;
const row = group.resourceRows.find((r) => r.resource.id === resourceId);
if (!row) return;
for (const alloc of row.allocs) {
const allocLeft = toLeft(new Date(alloc.startDate));
const allocRight = allocLeft + toWidth(new Date(alloc.startDate), new Date(alloc.endDate));
if (allocRight >= selLeftCanvas && allocLeft <= selRightCanvas) {
selectedIds.push(alloc.id);
}
}
});
// Also check demand rows for open demand selection
const demandRowEls = canvasEl.querySelectorAll<HTMLElement>("[data-project-demand-row]");
demandRowEls.forEach((rowEl) => {
const rowRect = rowEl.getBoundingClientRect();
if (rowRect.bottom < selTop || rowRect.top > selBottom) return;
const projectId = rowEl.dataset.projectId;
if (!projectId) return;
const demands = openDemandsByProject.get(projectId) ?? [];
for (const demand of demands) {
const allocLeft = toLeft(new Date(demand.startDate));
const allocRight = allocLeft + toWidth(new Date(demand.startDate), new Date(demand.endDate));
if (allocRight >= selLeftCanvas && allocLeft <= selRightCanvas) {
selectedIds.push(demand.id);
}
}
});
}
if (selectedIds.length > 0 || selectedResIds.length > 0) {
setMultiSelectState(prev => ({
...prev,
selectedAllocationIds: selectedIds,
selectedResourceIds: selectedResIds,
dateRange: { start: startDate, end: endDate },
}));
} else {
clearMultiSelect();
}
}, [multiSelectState.isSelecting, multiSelectState.startX, multiSelectState.startY]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<div className="relative flex flex-1 flex-col gap-4 min-h-0">
{/* Toolbar */}
@@ -404,14 +745,21 @@ function TimelineViewContent({
onMouseMove={handleMouseMove}
onMouseUp={(e) => void onCanvasMouseUp(e)}
onMouseLeave={onCanvasMouseLeave}
onMouseDown={(e) => {
if (e.button === 2) {
onCanvasRightMouseDown(e);
}
}}
onContextMenu={(e) => e.preventDefault()}
onTouchMove={(e) => {
if (!hasActivePointerOverlay) return;
onCanvasTouchMove(e);
}}
onTouchEnd={(e) => void onCanvasTouchEnd(e)}
className={clsx(
(dragState.isDragging || allocDragState.isActive) && "cursor-grabbing select-none",
(dragState.isDragging || allocDragState.isActive || multiSelectState.isMultiDragging) && "cursor-grabbing select-none",
rangeState.isSelecting && "cursor-crosshair select-none",
multiSelectState.isSelecting && "cursor-crosshair select-none",
)}
>
{viewMode === "resource" && (
@@ -419,7 +767,7 @@ function TimelineViewContent({
scrollContainerRef={scrollContainerRef}
dragState={dragState}
allocDragState={allocDragState}
rangeState={rangeState}
rangeState={effectiveRangeState}
shiftPreview={shiftPreview}
contextResourceIds={contextResourceIds}
onAllocMouseDown={onAllocMouseDown}
@@ -427,6 +775,7 @@ function TimelineViewContent({
onRowMouseDown={onRowMouseDown}
onRowTouchStart={onRowTouchStart}
onAllocationContextMenu={openAllocationPopoverAt}
multiSelectState={multiSelectState}
CELL_WIDTH={CELL_WIDTH}
dates={dates}
totalCanvasWidth={totalCanvasWidth}
@@ -442,7 +791,8 @@ function TimelineViewContent({
scrollContainerRef={scrollContainerRef}
dragState={dragState}
allocDragState={allocDragState}
rangeState={rangeState}
rangeState={effectiveRangeState}
multiSelectState={multiSelectState}
onProjectBarMouseDown={onProjectBarMouseDown}
onProjectBarTouchStart={onProjectBarTouchStart}
onAllocMouseDown={onAllocMouseDown}
@@ -466,6 +816,19 @@ function TimelineViewContent({
)}
</div>
{/* Multi-select rectangle overlay */}
{multiSelectState.isSelecting && (
<div
className="fixed border-2 border-sky-500 bg-sky-500/10 pointer-events-none z-30 rounded"
style={{
left: Math.min(multiSelectState.startX, multiSelectState.currentX),
top: Math.min(multiSelectState.startY, multiSelectState.currentY),
width: Math.abs(multiSelectState.currentX - multiSelectState.startX),
height: Math.abs(multiSelectState.currentY - multiSelectState.startY),
}}
/>
)}
{/* Saving indicators */}
{(isApplying || isAllocSaving) && (
<div className="pointer-events-none absolute inset-0 z-50 flex items-center justify-center rounded-2xl bg-white/50 dark:bg-gray-950/50">
@@ -540,18 +903,95 @@ function TimelineViewContent({
</div>
)}
{/* Allocation popover */}
{popover && (
<AllocationPopover
allocationId={popover.allocationId}
projectId={popover.projectId}
onClose={() => setPopover(null)}
{/* Multi-drag tooltip */}
{multiSelectState.isMultiDragging && multiSelectState.multiDragDaysDelta !== 0 && (
<div
ref={multiDragTooltipRef}
className="fixed z-50 bg-sky-700 text-white text-xs px-2.5 py-1.5 rounded-lg pointer-events-none shadow-lg font-medium"
style={{ left: mousePosRef.current.x + 14, top: mousePosRef.current.y - 36 }}
>
{multiSelectState.multiDragMode === "resize-start" ? "Start " : multiSelectState.multiDragMode === "resize-end" ? "End " : ""}
{multiSelectState.multiDragDaysDelta > 0 ? "+" : ""}
{multiSelectState.multiDragDaysDelta}d
{" "}
({multiSelectState.selectedAllocationIds.length} allocations)
</div>
)}
{/* Allocation / Demand popover (click path) */}
{popover && (() => {
// Check if clicked allocation is actually a demand
const clickedDemand = openDemandsByProject.get(popover.projectId)?.find((d) => d.id === popover.allocationId);
if (clickedDemand) {
return (
<DemandPopover
demand={clickedDemand}
onClose={() => setPopover(null)}
onOpenPanel={(pid) => {
setPopover(null);
setOpenPanelProjectId(pid);
}}
onFillDemand={(d) => {
setPopover(null);
setOpenDemandToAssign({
id: d.id,
projectId: d.projectId,
roleId: d.roleId,
role: d.role,
headcount: d.requestedHeadcount,
startDate: new Date(d.startDate),
endDate: new Date(d.endDate),
hoursPerDay: d.hoursPerDay,
...(d.roleEntity !== undefined ? { roleEntity: d.roleEntity } : {}),
...(d.project !== undefined ? { project: d.project } : {}),
});
}}
anchorX={popover.x}
anchorY={popover.y}
/>
);
}
return (
<AllocationPopover
allocationId={popover.allocationId}
projectId={popover.projectId}
onClose={() => setPopover(null)}
onOpenPanel={(pid) => {
setPopover(null);
setOpenPanelProjectId(pid);
}}
anchorX={popover.x}
anchorY={popover.y}
/>
);
})()}
{/* Demand popover */}
{demandPopover && (
<DemandPopover
demand={demandPopover.demand}
onClose={() => setDemandPopover(null)}
onOpenPanel={(pid) => {
setPopover(null);
setDemandPopover(null);
setOpenPanelProjectId(pid);
}}
anchorX={popover.x}
anchorY={popover.y}
onFillDemand={(d) => {
setDemandPopover(null);
setOpenDemandToAssign({
id: d.id,
projectId: d.projectId,
roleId: d.roleId,
role: d.role,
headcount: d.requestedHeadcount,
startDate: new Date(d.startDate),
endDate: new Date(d.endDate),
hoursPerDay: d.hoursPerDay,
...(d.roleEntity !== undefined ? { roleEntity: d.roleEntity } : {}),
...(d.project !== undefined ? { project: d.project } : {}),
});
}}
anchorX={demandPopover.x}
anchorY={demandPopover.y}
/>
)}
@@ -561,7 +1001,7 @@ function TimelineViewContent({
resourceId={newAllocPopover.resourceId}
startDate={newAllocPopover.startDate}
endDate={newAllocPopover.endDate}
suggestedProjectId={newAllocPopover.suggestedProjectId}
suggestedProjectId={enrichedSuggestedProjectId}
anchorX={newAllocPopover.anchorX}
anchorY={newAllocPopover.anchorY}
onClose={() => setNewAllocPopover(null)}
@@ -582,6 +1022,45 @@ function TimelineViewContent({
onSuccess={() => setOpenDemandToAssign(null)}
/>
)}
{/* Multi-select floating action bar */}
<FloatingActionBar
selectedAllocationCount={multiSelectState.selectedAllocationIds.length}
selectedResourceCount={multiSelectState.selectedResourceIds.length}
onDelete={() => {
if (multiSelectState.selectedAllocationIds.length === 0) return;
const msg = `Delete ${multiSelectState.selectedAllocationIds.length} allocation(s)? This cannot be undone.`;
if (window.confirm(msg)) {
batchDeleteMutation.mutate({ ids: multiSelectState.selectedAllocationIds });
}
}}
onAssign={() => setShowBatchAssign(true)}
onClear={clearMultiSelect}
isDeleting={batchDeleteMutation.isPending}
/>
{/* Batch assign popover */}
{showBatchAssign && multiSelectState.dateRange && (
<BatchAssignPopover
resourceIds={multiSelectState.selectedResourceIds}
startDate={multiSelectState.dateRange.start}
endDate={multiSelectState.dateRange.end}
onClose={() => setShowBatchAssign(false)}
onCreated={() => {
setShowBatchAssign(false);
clearMultiSelect();
}}
/>
)}
{/* Resource hover card */}
{resourceHover && (
<ResourceHoverCard
resourceId={resourceHover.resourceId}
anchorEl={resourceHover.anchorEl}
onClose={() => setResourceHover(null)}
/>
)}
</div>
);
}