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