"use client"; import { MILLISECONDS_PER_DAY } from "@capakraken/shared"; import { clsx } from "clsx"; import { useSession } from "next-auth/react"; import { useCallback, 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 { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js"; import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.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"; import { TimelineHeader } from "./TimelineHeader.js"; import { TimelineToolbar } from "./TimelineToolbar.js"; import { addDays } from "./utils.js"; import { HEADER_DAY_HEIGHT, HEADER_MONTH_HEIGHT, LABEL_WIDTH } from "./timelineConstants.js"; import { formatDateShort } from "~/lib/format.js"; import { TimelineProvider, useTimelineContext, type TimelineAssignmentEntry, } from "./TimelineContext.js"; import { TimelineResourcePanel } from "./TimelineResourcePanel.js"; import { TimelineProjectPanel, type OpenDemandAssignment } from "./TimelineProjectPanel.js"; import { ProjectColorLegend } from "./ProjectColorLegend.js"; import { useMultiSelectIntersection } from "~/hooks/useMultiSelectIntersection.js"; import type { TimelineVisualOverrides } from "./allocationVisualState.js"; import { SuccessToast } from "~/components/ui/SuccessToast.js"; import { useTimelineKeyboard } from "~/hooks/useTimelineKeyboard.js"; import { KeyboardShortcutOverlay } from "./KeyboardShortcutOverlay.js"; import { InlineAllocationEditor } from "./InlineAllocationEditor.js"; // ─── Entry point ──────────────────────────────────────────────────────────── // Two-layer mount: the outer shell creates drag state + project context, // then wraps children with TimelineProvider. The inner content consumes context. export function TimelineView() { const { data: session, status: sessionStatus } = useSession(); const mousePosRef = useRef({ x: 0, y: 0 }); const role = sessionStatus === "authenticated" ? ((session.user as { role?: string } | undefined)?.role ?? "USER") : null; const isSelfServiceTimeline = role === "USER" || role === "VIEWER"; const canManageTimeline = !isSelfServiceTimeline; 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; projectId: string; allocation?: TimelineAssignmentEntry | null; x: number; y: number; contextDate?: Date; } | null>(null); const [newAllocPopover, setNewAllocPopover] = useState<{ resourceId: string; startDate: Date; endDate: Date; 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. // useTimelineDrag only needs cellWidth for pixel→day conversion during drag. // We start with 40 (day zoom default) and update via a ref. const cellWidthRef = useRef(40); const invalidateTimeline = useInvalidateTimeline(); const batchShiftMutationOuter = trpc.timeline.batchShiftAllocations.useMutation({ onSuccess: invalidateTimeline, }); const [dragErrorToast, setDragErrorToast] = useState(null); const { dragState, allocDragState, rangeState, multiSelectState, setMultiSelectState, optimisticAllocations, reconcileOptimisticAllocations, shiftPreview, isPreviewLoading, isApplying, isAllocSaving, onProjectBarMouseDown, onAllocMouseDown, onRowMouseDown, onCanvasMouseMove, onCanvasMouseUp, onCanvasMouseLeave, onCanvasRightMouseDown, clearMultiSelect, onProjectBarTouchStart, onAllocTouchStart, onRowTouchStart, onCanvasTouchMove, onCanvasTouchEnd, } = useTimelineDrag({ cellWidthRef, onBlockClick: (info) => { setPopover({ allocationId: info.allocationId, projectId: info.projectId, x: mousePosRef.current.x, y: mousePosRef.current.y, }); }, onRangeSelected: (info) => { setNewAllocPopover({ resourceId: info.resourceId, startDate: info.startDate, endDate: info.endDate, 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, selectedIds) => { const ids = selectedIds ?? multiSelectState.selectedAllocationIds; if (ids.length > 0 && daysDelta !== 0) { pushBatchHistoryRef.current(ids, daysDelta, mode); batchShiftMutationOuter.mutate({ allocationIds: ids, daysDelta, mode }); clearMultiSelect(); } }, onMutationError: (message) => setDragErrorToast(message), }); const [openPanelProjectId, setOpenPanelProjectId] = useState(null); const dragProjectId = dragState.isDragging ? dragState.projectId : null; const contextProjectId = canManageTimeline ? (dragProjectId ?? openPanelProjectId) : null; const { contextResourceIds, contextAllocations } = useProjectDragContext(contextProjectId, canManageTimeline); return ( <> setDragErrorToast(null)} /> ); } // ─── Content (inside TimelineProvider — has context access) ───────────────── function TimelineViewContent({ mousePosRef, cellWidthRef, dragState, allocDragState, rangeState, multiSelectState, setMultiSelectState, optimisticAllocations, reconcileOptimisticAllocations, onCanvasRightMouseDown, clearMultiSelect, shiftPreview, isPreviewLoading, isApplying, isAllocSaving, onProjectBarMouseDown, onAllocMouseDown, onRowMouseDown, onCanvasMouseMove, onCanvasMouseUp, onCanvasMouseLeave, onProjectBarTouchStart, onAllocTouchStart, onRowTouchStart, onCanvasTouchMove, onCanvasTouchEnd, contextResourceIds, popover, setPopover, newAllocPopover, setNewAllocPopover, openPanelProjectId, setOpenPanelProjectId, canUndo, canRedo, isSelfServiceTimeline, undo, redo, }: { mousePosRef: React.RefObject<{ x: number; y: number }>; cellWidthRef: React.RefObject; dragState: ReturnType["dragState"]; allocDragState: ReturnType["allocDragState"]; rangeState: ReturnType["rangeState"]; multiSelectState: ReturnType["multiSelectState"]; setMultiSelectState: ReturnType["setMultiSelectState"]; optimisticAllocations: TimelineVisualOverrides; reconcileOptimisticAllocations: ReturnType["reconcileOptimisticAllocations"]; onCanvasRightMouseDown: ReturnType["onCanvasRightMouseDown"]; clearMultiSelect: ReturnType["clearMultiSelect"]; shiftPreview: ReturnType["shiftPreview"]; isPreviewLoading: boolean; isApplying: boolean; isAllocSaving: boolean; onProjectBarMouseDown: ReturnType["onProjectBarMouseDown"]; onAllocMouseDown: ReturnType["onAllocMouseDown"]; onRowMouseDown: ReturnType["onRowMouseDown"]; onCanvasMouseMove: ReturnType["onCanvasMouseMove"]; onCanvasMouseUp: ReturnType["onCanvasMouseUp"]; onCanvasMouseLeave: ReturnType["onCanvasMouseLeave"]; onProjectBarTouchStart: ReturnType["onProjectBarTouchStart"]; onAllocTouchStart: ReturnType["onAllocTouchStart"]; onRowTouchStart: ReturnType["onRowTouchStart"]; onCanvasTouchMove: ReturnType["onCanvasTouchMove"]; onCanvasTouchEnd: ReturnType["onCanvasTouchEnd"]; contextResourceIds: string[]; popover: { allocationId: string; projectId: string; allocation?: TimelineAssignmentEntry | null; x: number; y: number; contextDate?: Date; } | null; setPopover: React.Dispatch>; newAllocPopover: { resourceId: string; startDate: Date; endDate: Date; suggestedProjectId: string | null; anchorX: number; anchorY: number; selectionResourceId: string; selectionStart: Date; selectionEnd: Date; } | null; setNewAllocPopover: React.Dispatch>; openPanelProjectId: string | null; setOpenPanelProjectId: React.Dispatch>; canUndo: boolean; canRedo: boolean; isSelfServiceTimeline: boolean; undo: () => Promise; redo: () => Promise; }) { const ctx = useTimelineContext(); const { resources, projectGroups, allocsByResource, openDemandsByProject, viewStart, viewEnd, viewDays, visibleAssignments, visibleDemands, setViewStart, setViewDays, filters, setFilters, filterOpen, setFilterOpen, viewMode, setViewMode, today, isLoading, isInitialLoading, isEntriesError, totalAllocCount, } = ctx; const scrollContainerRef = useRef(null); const canvasRef = useRef(null); // Tooltip DOM refs const dragTooltipRef = useRef(null); const allocTooltipRef = useRef(null); const rangeHintRef = useRef(null); const multiDragTooltipRef = useRef(null); const [openDemandToAssign, setOpenDemandToAssign] = useState(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 | null>(null); const previousViewModeRef = useRef(viewMode); const invalidateTimelineInner = useInvalidateTimeline(); const batchDeleteMutation = trpc.allocation.batchDelete.useMutation({ onSuccess: () => { invalidateTimelineInner(); clearMultiSelect(); }, }); // ─── Batch-delete handler — shared by keyboard shortcut and action bar ───── const handleBatchDelete = useCallback(() => { 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 }); } }, [batchDeleteMutation, multiSelectState.selectedAllocationIds]); const { CELL_WIDTH, dates, totalCanvasWidth, toLeft, toWidth, gridLines, monthGroups, xToDate } = useTimelineLayout(viewStart, viewDays, filters.zoom, filters.showWeekends, today); const { showShortcuts, setShowShortcuts } = useTimelineKeyboard({ scrollContainerRef, cellWidth: CELL_WIDTH, selectedAllocationIds: multiSelectState.selectedAllocationIds, onDeleteSelected: handleBatchDelete, }); const [inlineEditTarget, setInlineEditTarget] = useState<{ allocationId: string; startDate: Date; endDate: Date; hoursPerDay: number; barRect: DOMRect; } | null>(null); const hasActivePointerOverlay = dragState.isDragging || allocDragState.isActive || rangeState.isSelecting || multiSelectState.isMultiDragging; useEffect(() => { if (optimisticAllocations.size === 0) return; reconcileOptimisticAllocations([...visibleAssignments, ...visibleDemands]); }, [ optimisticAllocations, reconcileOptimisticAllocations, visibleAssignments, visibleDemands, ]); useEffect(() => { if (!hasActivePointerOverlay) return; setPopover(null); setDemandPopover(null); setResourceHover(null); }, [hasActivePointerOverlay]); useEffect(() => { if (previousViewModeRef.current === viewMode) { return; } previousViewModeRef.current = viewMode; setPopover(null); setDemandPopover(null); setNewAllocPopover(null); setResourceHover(null); }, [viewMode, setNewAllocPopover]); useEffect(() => { if (!isInitialLoading) return; setPopover(null); setDemandPopover(null); setNewAllocPopover(null); setResourceHover(null); }, [isInitialLoading, setNewAllocPopover]); // ─── 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(); 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]); // Keep cellWidthRef in sync so the drag hook uses the correct value. cellWidthRef.current = CELL_WIDTH; // ─── Native mousemove listener — updates tooltips without React state ───── useEffect(() => { if (!hasActivePointerOverlay) return; const el = canvasRef.current; if (!el) return; const handler = (e: MouseEvent) => { const x = e.clientX; const y = e.clientY; mousePosRef.current = { x, y }; if (dragTooltipRef.current) { dragTooltipRef.current.style.left = `${x + 12}px`; dragTooltipRef.current.style.top = `${y - 8}px`; } if (allocTooltipRef.current) { allocTooltipRef.current.style.left = `${x + 14}px`; allocTooltipRef.current.style.top = `${y - 36}px`; } if (rangeHintRef.current) { 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`; } }; // 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(() => { const el = scrollContainerRef.current; if (!el) return; const handler = (e: WheelEvent) => { if (e.shiftKey && e.deltaY !== 0 && e.deltaX === 0) { e.preventDefault(); el.scrollLeft += e.deltaY; } }; el.addEventListener("wheel", handler, { passive: false }); return () => el.removeEventListener("wheel", handler); }, [isLoading]); // eslint-disable-line react-hooks/exhaustive-deps // ─── Keyboard undo/redo ─────────────────────────────────────────────────── useEffect(() => { const handler = (e: KeyboardEvent) => { const isMac = navigator.platform.toUpperCase().includes("MAC"); const modKey = isMac ? e.metaKey : e.ctrlKey; if (!modKey) return; if (e.key === "z" && !e.shiftKey) { e.preventDefault(); void undo(); } if ((e.key === "z" && e.shiftKey) || e.key === "y") { e.preventDefault(); void redo(); } }; window.addEventListener("keydown", handler); 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(() => { if (hasActivePointerOverlay) { if (resourceHoverTimerRef.current) { clearTimeout(resourceHoverTimerRef.current); resourceHoverTimerRef.current = null; } setResourceHover(null); return; } const canvas = canvasRef.current; if (!canvas) return; const HOVER_DELAY = 400; function onMouseOver(e: MouseEvent) { if (hasActivePointerOverlay) return; const target = (e.target as HTMLElement).closest("[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) { if (hasActivePointerOverlay) return; 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, isInitialLoading, hasActivePointerOverlay]); // eslint-disable-line react-hooks/exhaustive-deps // ─── Scroll-left tracking for horizontal virtualization ──────────────────── // Updated via RAF so React state only updates after a frame, not on every // pixel of scroll. The ref gives instant reads inside event handlers. const scrollLeftRef = useRef(0); const scrollRafRef = useRef(null); const [scrollLeft, setScrollLeft] = useState(0); // ─── Navigation callbacks for TimelineToolbar ──────────────────────────── const handleNavigateBack = useCallback( () => setViewStart((v) => addDays(v, -28)), [setViewStart], ); const handleNavigateToday = useCallback( () => setViewStart(addDays(today, -30)), [setViewStart, today], ); const handleNavigateForward = useCallback( () => setViewStart((v) => addDays(v, 28)), [setViewStart], ); const handleUndo = useCallback(() => { void undo(); }, [undo]); const handleRedo = useCallback(() => { void redo(); }, [redo]); // ─── Scroll handler — extends date range and tracks scroll offset ───────── const handleContainerScroll = useCallback(() => { const el = scrollContainerRef.current; if (!el) return; const distanceFromRight = el.scrollWidth - el.scrollLeft - el.clientWidth; if (distanceFromRight < CELL_WIDTH * 40) { setViewDays((d) => d + 120); } scrollLeftRef.current = el.scrollLeft; if (scrollRafRef.current === null) { scrollRafRef.current = requestAnimationFrame(() => { scrollRafRef.current = null; setScrollLeft(scrollLeftRef.current); }); } }, [CELL_WIDTH, setViewDays]); // ─── Canvas mousemove — only forwards event when drag overlay is active ─── const handleMouseMove = useCallback( (e: React.MouseEvent) => { if (!hasActivePointerOverlay) return; onCanvasMouseMove(e); }, [hasActivePointerOverlay, onCanvasMouseMove], ); // ─── openAllocationPopoverAt — routed to demand or allocation popover ───── const openAllocationPopoverAt = useCallback( ( info: { allocationId: string; projectId: string; contextDate?: Date }, anchorX: number, anchorY: number, ) => { if (hasActivePointerOverlay) return; const demands = openDemandsByProject.get(info.projectId); const demand = demands?.find((d) => d.id === info.allocationId); if (demand) { setDemandPopover({ demand, x: anchorX, y: anchorY }); return; } const allocation = visibleAssignments.find((entry) => ( entry.id === info.allocationId || entry.entityId === info.allocationId || entry.sourceAllocationId === info.allocationId || getPlanningEntryMutationId(entry) === info.allocationId )) ?? null; setPopover({ allocationId: info.allocationId, projectId: info.projectId, allocation, x: anchorX, y: anchorY, ...(info.contextDate ? { contextDate: info.contextDate } : {}), }); }, [hasActivePointerOverlay, openDemandsByProject, visibleAssignments], ); // ─── onOpenDemandClick for project panel — guards against overlay-active ── const handleOpenDemandClick = useCallback( (demand: TimelineDemandEntry, anchorX: number, anchorY: number) => { if (hasActivePointerOverlay) return; setDemandPopover({ demand, x: anchorX, y: anchorY }); }, [hasActivePointerOverlay], ); // ─── onInlineEdit for resource panel — opens inline allocation editor ───── const handleInlineEdit = useCallback( (id: string, vals: { startDate: Date; endDate: Date; hoursPerDay: number }, rect: DOMRect) => { setInlineEditTarget({ allocationId: id, ...vals, barRect: rect }); }, [], ); // ─── FloatingActionBar callbacks ──────────────────────────────────────────── const handleShowBatchAssign = useCallback(() => setShowBatchAssign(true), []); // ─── Stable panel event handlers — self-service gets a typed no-op so the // memo() on ResourcePanel/ProjectPanel is not defeated by new fn refs. // eslint-disable-next-line @typescript-eslint/no-explicit-any const stableNoop = useCallback((..._args: any[]) => undefined, []); const panelOnAllocMouseDown = (isSelfServiceTimeline ? stableNoop : onAllocMouseDown) as typeof onAllocMouseDown; const panelOnAllocTouchStart = (isSelfServiceTimeline ? stableNoop : onAllocTouchStart) as typeof onAllocTouchStart; const panelOnRowMouseDown = (isSelfServiceTimeline ? stableNoop : onRowMouseDown) as typeof onRowMouseDown; const panelOnRowTouchStart = (isSelfServiceTimeline ? stableNoop : onRowTouchStart) as typeof onRowTouchStart; const panelOnAllocationContextMenu = (isSelfServiceTimeline ? stableNoop : openAllocationPopoverAt) as typeof openAllocationPopoverAt; const panelOnProjectBarMouseDown = (isSelfServiceTimeline ? stableNoop : onProjectBarMouseDown) as typeof onProjectBarMouseDown; const panelOnProjectBarTouchStart = (isSelfServiceTimeline ? stableNoop : onProjectBarTouchStart) as typeof onProjectBarTouchStart; const panelOnOpenPanel = (isSelfServiceTimeline ? stableNoop : setOpenPanelProjectId) as typeof setOpenPanelProjectId; const panelOnOpenDemandClick = (isSelfServiceTimeline ? stableNoop : handleOpenDemandClick) as typeof handleOpenDemandClick; // ─── Multi-select intersection computation ──────────────────────────────── useMultiSelectIntersection({ multiSelectState, setMultiSelectState, clearMultiSelect, canvasRef, viewMode, resources, allocsByResource, projectGroups, openDemandsByProject, dates, today, CELL_WIDTH, toLeft, toWidth, }); return (
{/* Toolbar */} {/* Project color legend */} {/* Scrollable canvas */}
{isEntriesError ? (
Failed to load timeline data. Please try refreshing the page.
) : isInitialLoading ? (
Loading timeline...
) : (
{/* Canvas rows */}
void onCanvasMouseUp(e)} onMouseLeave={onCanvasMouseLeave} onMouseDown={(e) => { if (!isSelfServiceTimeline && 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 || multiSelectState.isMultiDragging) && "cursor-grabbing select-none", rangeState.isSelecting && "cursor-crosshair select-none", multiSelectState.isSelecting && "cursor-crosshair select-none", )} > {viewMode === "resource" && ( )} {viewMode === "project" && ( )}
)}
{/* Multi-select rectangle overlay */} {multiSelectState.isSelecting && (
)} {/* Saving indicators */} {(isApplying || isAllocSaving) && (
{isApplying ? "Applying shift…" : "Saving…"}
)} {/* Drag preview tooltip */} {dragState.isDragging && dragState.daysDelta !== 0 && (
)} {/* Alloc drag tooltip */} {allocDragState.isActive && allocDragState.daysDelta !== 0 && allocDragState.currentStartDate && allocDragState.currentEndDate && (
{allocDragState.projectName}
{formatDateShort(allocDragState.currentStartDate)} {" – "} {formatDateShort(allocDragState.currentEndDate)}
)} {/* Range-select hint */} {rangeState.isSelecting && rangeState.startDate && rangeState.currentDate && (
{(() => { const end = rangeState.currentDate; const [s, e] = rangeState.startDate <= end ? [rangeState.startDate, end] : [end, rangeState.startDate]; const days = Math.round((e.getTime() - s.getTime()) / MILLISECONDS_PER_DAY) + 1; return `${days} day${days !== 1 ? "s" : ""}`; })()}
)} {/* Multi-drag tooltip */} {multiSelectState.isMultiDragging && multiSelectState.multiDragDaysDelta !== 0 && (
{multiSelectState.multiDragMode === "resize-start" ? "Start " : multiSelectState.multiDragMode === "resize-end" ? "End " : ""} {multiSelectState.multiDragDaysDelta > 0 ? "+" : ""} {multiSelectState.multiDragDaysDelta}d {" "} ({multiSelectState.selectedAllocationIds.length} allocations)
)} {/* Allocation / Demand popover (click path) */} {!isSelfServiceTimeline && !hasActivePointerOverlay && popover && (() => { // Check if clicked allocation is actually a demand const clickedDemand = openDemandsByProject.get(popover.projectId)?.find((d) => d.id === popover.allocationId); if (clickedDemand) { return ( 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} ignoreScrollContainers={[scrollContainerRef]} /> ); } return ( setPopover(null)} onOpenPanel={(pid) => { setPopover(null); setOpenPanelProjectId(pid); }} anchorX={popover.x} anchorY={popover.y} ignoreScrollContainers={[scrollContainerRef]} {...(popover.contextDate ? { contextDate: popover.contextDate } : {})} /> ); })()} {/* Demand popover */} {!isSelfServiceTimeline && !hasActivePointerOverlay && demandPopover && ( setDemandPopover(null)} onOpenPanel={(pid) => { setDemandPopover(null); setOpenPanelProjectId(pid); }} 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} ignoreScrollContainers={[scrollContainerRef]} /> )} {/* New allocation popover */} {!isSelfServiceTimeline && newAllocPopover && ( setNewAllocPopover(null)} onCreated={() => setNewAllocPopover(null)} ignoreScrollContainers={[scrollContainerRef]} /> )} {/* Project side panel */} {!isSelfServiceTimeline && openPanelProjectId && ( setOpenPanelProjectId(null)} /> )} {/* Open-demand assignment modal */} {!isSelfServiceTimeline && openDemandToAssign && ( setOpenDemandToAssign(null)} onSuccess={() => setOpenDemandToAssign(null)} /> )} {/* Multi-select floating action bar */} {/* Batch assign popover */} {showBatchAssign && multiSelectState.dateRange && ( setShowBatchAssign(false)} onCreated={() => { setShowBatchAssign(false); clearMultiSelect(); }} /> )} {/* Resource hover card */} {!hasActivePointerOverlay && resourceHover && ( setResourceHover(null)} /> )} {/* Inline allocation editor */} {inlineEditTarget && ( setInlineEditTarget(null)} onSaved={() => setInlineEditTarget(null)} /> )} {/* Keyboard shortcut overlay */} {showShortcuts && setShowShortcuts(false)} />} {/* Keyboard shortcut hint button */}
); }