"use client"; import { clsx } from "clsx"; import { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { useTimelineData, useTimelineView, useTimelineDisplay, type TimelineAssignmentEntry, } from "./TimelineContext.js"; import { applyVisualOverrides, type TimelineVisualOverrides } from "./allocationVisualState.js"; import { ConflictOverlay } from "./ConflictOverlay.js"; import { computeSubLanes } from "./utils.js"; import { TimelineTooltip, type HeatmapHoverData, type VacationHoverData, } from "./TimelineTooltip.js"; import { ROW_HEIGHT, SUB_LANE_HEIGHT, LABEL_WIDTH } from "./timelineConstants.js"; import type { DragState, AllocDragState, RangeState, ShiftPreviewData, MultiSelectState, } from "~/hooks/useTimelineDrag.js"; import { buildVacationBlocksByResource, renderVacationBlocks, renderRangeOverlay, renderOverbookingBlink, type VacationBlockInfo, } from "./renderHelpers.js"; import { cancelHoverFrame, scheduleVacationHoverUpdate, updateTooltipPosition, } from "./timelineHover.js"; import { buildResourceHeatmapHover } from "./timelineHeatmap.js"; import { buildResourceCapacitySeries } from "./timelineCapacity.js"; import { isAllocationScheduledOnDate } from "./timelineAvailability.js"; import { renderAllocBlocksFromData, renderLoadGraph, renderHeatmapOverlay, renderDailyBars, type AllocBlockData, type AllocMouseDownInfo, } from "./timelineResourceRender.js"; // ─── Props ────────────────────────────────────────────────────────────────── interface TimelineResourcePanelProps { scrollContainerRef: React.RefObject; dragState: DragState; allocDragState: AllocDragState; rangeState: RangeState; shiftPreview: ShiftPreviewData | null; contextResourceIds: string[]; onAllocMouseDown: (e: React.MouseEvent, info: AllocMouseDownInfo) => void; onAllocTouchStart: (e: React.TouchEvent, info: AllocMouseDownInfo) => void; onRowMouseDown: (e: React.MouseEvent, info: RowMouseDownInfo) => void; onRowTouchStart: (e: React.TouchEvent, info: RowMouseDownInfo) => void; onAllocationContextMenu: ( info: { allocationId: string; projectId: string; contextDate?: Date }, anchorX: number, anchorY: number, ) => void; multiSelectState: MultiSelectState; optimisticAllocations: TimelineVisualOverrides; suppressHoverInteractions: boolean; onInlineEdit?: ( allocationId: string, initialValues: { startDate: Date; endDate: Date; hoursPerDay: number }, barRect: DOMRect, ) => void; /** Horizontal virtualization: current scroll offset and visible container width */ scrollLeft?: number; containerWidth?: number; // Layout from useTimelineLayout CELL_WIDTH: number; dates: Date[]; totalCanvasWidth: number; toLeft: (date: Date) => number; toWidth: (start: Date, end: Date) => number; gridLines: React.ReactNode; xToDate: (clientX: number, rect: DOMRect) => Date; } export interface RowMouseDownInfo { resourceId: string; startDate: Date; suggestedProjectId?: string; } // ─── Component ────────────────────────────────────────────────────────────── function TimelineResourcePanelInner({ scrollContainerRef, dragState, allocDragState, rangeState, shiftPreview, contextResourceIds, onAllocMouseDown, onAllocTouchStart, onRowMouseDown, onRowTouchStart, onAllocationContextMenu, multiSelectState, optimisticAllocations, suppressHoverInteractions, onInlineEdit, scrollLeft = 0, containerWidth = 1200, CELL_WIDTH, dates, totalCanvasWidth, toLeft, toWidth, gridLines, xToDate, }: TimelineResourcePanelProps) { const { resources, allocsByResource, vacationsByResource } = useTimelineData(); const { filters, viewStart, viewEnd, activeFilterCount } = useTimelineView(); const { displayMode, heatmapScheme, blinkOverbookedDays } = useTimelineDisplay(); // ─── Heatmap hover state ──────────────────────────────────────────────────── const heatmapRafRef = useRef(null); const lastHeatmapDayRef = useRef(-1); const vacationHoverRafRef = useRef(null); const hoveredVacationKeyRef = useRef(null); const pendingHeatmapRef = useRef<{ clientX: number; rect: DOMRect; allocs: TimelineAssignmentEntry[]; } | null>(null); const heatmapTooltipRef = useRef(null); const vacationTooltipRef = useRef(null); const heatmapTooltipPosRef = useRef({ left: 0, top: 0 }); const vacationTooltipPosRef = useRef({ left: 0, top: 0 }); const [heatmapHover, setHeatmapHover] = useState(null); const [vacationHover, setVacationHover] = useState(null); // ─── Virtual row list ──────────────────────────────────────────────────────── const rowVirtualizer = useVirtualizer({ count: resources.length, getScrollElement: () => scrollContainerRef.current, estimateSize: () => ROW_HEIGHT, overscan: 5, }); const virtualItems = rowVirtualizer.getVirtualItems(); const totalRowHeight = rowVirtualizer.getTotalSize(); const visualAllocsByResource = useMemo(() => { if (optimisticAllocations.size === 0) return allocsByResource; const next = new Map(); for (const [resourceId, allocs] of allocsByResource) { next.set(resourceId, applyVisualOverrides(allocs, optimisticAllocations)); } return next; }, [allocsByResource, optimisticAllocations]); const resourceCapacityById = useMemo( () => buildResourceCapacitySeries(visualAllocsByResource, vacationsByResource, dates), [dates, vacationsByResource, visualAllocsByResource], ); // ─── Memo 1: resourceRows — which rows to render ───────────────────────── // (virtualizer handles which subset is visible; this memo just pre-computes // per-row data that the render loop needs) const resourceRows = useMemo(() => { const contextSet = new Set(contextResourceIds); return resources.map((resource) => { const allocs = visualAllocsByResource.get(resource.id) ?? []; const isContextResource = contextSet.has(resource.id); return { resource, allocs, isContextResource }; }); }, [resources, visualAllocsByResource, contextResourceIds]); // ─── Memo 2: vacationBlocks — vacation bar positions per resource ───────── const vacationBlocksByResource = useMemo( () => buildVacationBlocksByResource( vacationsByResource, filters.showVacations, toLeft, toWidth, CELL_WIDTH, totalCanvasWidth, filters.showWeekends, ), [ vacationsByResource, toLeft, toWidth, CELL_WIDTH, totalCanvasWidth, filters.showVacations, filters.showWeekends, ], ); // ─── Memo 3: assignmentBlocks — pre-computed per resource for strip mode ── // (Bar mode computes differently per-day, so we only pre-compute for strip.) const assignmentBlocksByResource = useMemo(() => { if (displayMode === "bar") return new Map(); const result = new Map(); for (const { resource, allocs } of resourceRows) { if (allocs.length === 0) continue; const subLaneMap = computeSubLanes( allocs.map((a) => ({ id: a.id, startDate: new Date(a.startDate), endDate: new Date(a.endDate), })), ); const laneCount = subLaneMap.size > 0 ? Math.max(...subLaneMap.values()) + 1 : 1; const blockData: AllocBlockData[] = allocs.map((alloc) => ({ alloc, lane: subLaneMap.get(alloc.id) ?? 0, })); result.set(resource.id, { laneCount, blockData }); } return result; }, [displayMode, resourceRows]); // ─── Memo 4: utilization per resource for row background tint ─────────── const utilizationByResource = useMemo(() => { const result = new Map(); // resourceId -> avg utilization pct for (const { resource, allocs } of resourceRows) { if (allocs.length === 0) continue; const capacity = resourceCapacityById.get(resource.id); let totalPct = 0; let dayCount = 0; for (let dayIndex = 0; dayIndex < dates.length; dayIndex++) { const date = dates[dayIndex]; if (!date) continue; const bookingFactor = capacity?.bookingFactorsByDay[dayIndex] ?? 1; const capacityHours = capacity?.capacityHoursByDay[dayIndex] ?? 8; let dayH = 0; for (const a of allocs) { if (isAllocationScheduledOnDate(a, date)) { dayH += a.hoursPerDay * bookingFactor; } } if (dayH > 0 && capacityHours > 0) { totalPct += (dayH / capacityHours) * 100; dayCount++; } } if (dayCount > 0) { result.set(resource.id, totalPct / dayCount); } } return result; }, [dates, resourceCapacityById, resourceRows]); // ─── Heatmap row hover handler ──────────────────────────────────────────── const handleRowHeatmapMove = useCallback( (e: React.MouseEvent, allocs: TimelineAssignmentEntry[]) => { updateTooltipPosition(heatmapTooltipPosRef, heatmapTooltipRef, e.clientX, e.clientY, 16, -52); const rect = e.currentTarget.getBoundingClientRect(); const dayIndex = Math.floor((e.clientX - rect.left) / CELL_WIDTH); if (dayIndex === lastHeatmapDayRef.current) return; pendingHeatmapRef.current = { clientX: e.clientX, rect, allocs }; if (heatmapRafRef.current !== null) return; heatmapRafRef.current = requestAnimationFrame(() => { heatmapRafRef.current = null; const pending = pendingHeatmapRef.current; pendingHeatmapRef.current = null; if (!pending) return; const { clientX, rect: r, allocs: a } = pending; const dayIdx = Math.floor((clientX - r.left) / CELL_WIDTH); const date = dates[dayIdx]; if (!date) { lastHeatmapDayRef.current = -1; startTransition(() => setHeatmapHover(null)); return; } lastHeatmapDayRef.current = dayIdx; const resourceId = a[0]?.resourceId ?? null; const capacity = resourceId ? resourceCapacityById.get(resourceId) : undefined; const nextHeatmap = buildResourceHeatmapHover(date, a, { ...(capacity?.capacityHoursByDay[dayIdx] !== undefined ? { capacityHours: capacity.capacityHoursByDay[dayIdx] } : {}), ...(capacity?.bookingFactorsByDay[dayIdx] !== undefined ? { bookingFactor: capacity.bookingFactorsByDay[dayIdx] } : {}), }); startTransition(() => { setHeatmapHover(nextHeatmap); }); }); }, [CELL_WIDTH, dates, resourceCapacityById], ); // ─── Vacation hover ─────────────────────────────────────────────────────── const handleRowVacationHover = useCallback( (e: React.MouseEvent, resourceId: string) => { updateTooltipPosition( vacationTooltipPosRef, vacationTooltipRef, e.clientX, e.clientY, 14, -8, ); scheduleVacationHoverUpdate({ frameRef: vacationHoverRafRef, hoveredKeyRef: hoveredVacationKeyRef, resourceId, clientX: e.clientX, rect: e.currentTarget.getBoundingClientRect(), xToDate, vacations: vacationsByResource.get(resourceId) ?? [], onHoverChange: (hit) => { startTransition(() => { setVacationHover(hit); }); }, }); }, [vacationsByResource, xToDate], ); const clearHoverTooltips = useCallback(() => { cancelHoverFrame(heatmapRafRef); cancelHoverFrame(vacationHoverRafRef); const shouldClearHeatmap = lastHeatmapDayRef.current !== -1; const shouldClearVacation = hoveredVacationKeyRef.current !== null; lastHeatmapDayRef.current = -1; hoveredVacationKeyRef.current = null; if (shouldClearHeatmap || shouldClearVacation) { startTransition(() => { if (shouldClearHeatmap) setHeatmapHover(null); if (shouldClearVacation) setVacationHover(null); }); } }, []); // ─── Cleanup rAF on unmount ─────────────────────────────────────────────── useEffect( () => () => { cancelHoverFrame(heatmapRafRef); cancelHoverFrame(vacationHoverRafRef); }, [], ); useEffect(() => { if (!suppressHoverInteractions) return; clearHoverTooltips(); }, [clearHoverTooltips, suppressHoverInteractions]); // ─── Render helpers ─────────────────────────────────────────────────────── if (resources.length === 0) { return (
No allocations in this time range{activeFilterCount > 0 && " (filtered)"}.
); } return (
{virtualItems.map((virtualRow) => { const rowData = resourceRows[virtualRow.index]; if (!rowData) return null; const { resource, allocs, isContextResource } = rowData; const inBarMode = displayMode === "bar"; const precomputed = assignmentBlocksByResource.get(resource.id); const laneCount = inBarMode ? 1 : (precomputed?.laneCount ?? 1); const rowHeight = inBarMode ? ROW_HEIGHT : Math.max(ROW_HEIGHT, laneCount * SUB_LANE_HEIGHT + 16); // Utilization background tint — only highlight over-utilization const utilPct = utilizationByResource.get(resource.id) ?? 0; const utilBg = utilPct > 100 ? "rgba(254,202,202,0.18)" // red tint for over-utilized : undefined; return (
{/* Label column */}
{resource.displayName.slice(0, 2).toUpperCase()}
{resource.displayName}
{resource.chapter ?? resource.eid}
{/* Row canvas */}
{ const rect = e.currentTarget.getBoundingClientRect(); const date = xToDate(e.clientX, rect); onRowMouseDown(e, { resourceId: resource.id, startDate: date }); }} onTouchStart={(e) => { const rect = e.currentTarget.getBoundingClientRect(); const date = xToDate(e.touches[0]?.clientX ?? 0, rect); onRowTouchStart(e, { resourceId: resource.id, startDate: date }); }} onMouseMove={(e) => { if (suppressHoverInteractions) return; handleRowHeatmapMove(e, allocs); handleRowVacationHover(e, resource.id); }} onMouseLeave={clearHoverTooltips} > {gridLines} {inBarMode ? renderDailyBars( allocs, rowHeight, CELL_WIDTH, dates, allocDragState, onAllocMouseDown, onAllocTouchStart, onAllocationContextMenu, toLeft, toWidth, totalCanvasWidth, resourceCapacityById.get(resource.id), multiSelectState, suppressHoverInteractions, ) : renderAllocBlocksFromData( precomputed?.blockData ?? [], allocs, dragState, allocDragState, toLeft, toWidth, CELL_WIDTH, totalCanvasWidth, onAllocMouseDown, onAllocTouchStart, onAllocationContextMenu, multiSelectState, suppressHoverInteractions, onInlineEdit, scrollLeft, containerWidth, optimisticAllocations.size > 0 ? new Set(optimisticAllocations.keys()) : undefined, )} {filters.showVacations && renderVacationBlocks(vacationBlocksByResource.get(resource.id) ?? [], rowHeight)} {displayMode === "strip" && renderLoadGraph(allocs, dates, CELL_WIDTH, resourceCapacityById.get(resource.id))} {displayMode === "heatmap" && renderHeatmapOverlay( allocs, dates, CELL_WIDTH, heatmapScheme, resourceCapacityById.get(resource.id), )} {blinkOverbookedDays && renderOverbookingBlink( allocs, dates, CELL_WIDTH, resourceCapacityById.get(resource.id)?.capacityHoursByDay, resourceCapacityById.get(resource.id)?.bookingFactorsByDay, )} {renderRangeOverlay( rangeState, resource.id, rowHeight, toLeft, toWidth, CELL_WIDTH, )} {dragState.isDragging && dragState.projectId && shiftPreview && !shiftPreview.valid && shiftPreview.conflictCount > 0 && allocs.some((a) => a.projectId === dragState.projectId) && ( )}
); })} {/* Tooltips rendered inside the panel so they live near their data source */}
); } // ResourcePanelTooltips removed — now uses shared TimelineTooltip component export const TimelineResourcePanel = memo(TimelineResourcePanelInner); // ─── Re-export types for consumers ────────────────────────────────────────── export type { AllocBlockData, AllocMouseDownInfo } from "./timelineResourceRender.js"; export type { VacationBlockInfo } from "./renderHelpers.js";