"use client"; import { clsx } from "clsx"; import { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import type { CSSProperties } from "react"; import { useTimelineContext, type TimelineAssignmentEntry, type TimelineDemandEntry, } from "./TimelineContext.js"; import { applyPointerOffsetPreviewRect, applyVisualOverrides, getDragPointerOffset, type TimelineVisualOverrides, } from "./allocationVisualState.js"; import { heatmapColor } from "./heatmapUtils.js"; import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js"; import { formatDateLong } from "~/lib/format.js"; import { TimelineTooltip, type DemandHoverData, type HeatmapHoverData, type VacationHoverData, } from "./TimelineTooltip.js"; import { ROW_HEIGHT, SUB_LANE_HEIGHT, LABEL_WIDTH, PROJECT_HEADER_HEIGHT, ORDER_TYPE_COLORS, } from "./timelineConstants.js"; import { getProjectColor } from "~/lib/project-colors.js"; import type { DragState, AllocDragState, RangeState, MultiSelectState } from "~/hooks/useTimelineDrag.js"; import type { AllocMouseDownInfo, RowMouseDownInfo } from "./TimelineResourcePanel.js"; import { buildVacationBlocksByResource, renderVacationBlocks, renderRangeOverlay, renderOverbookingBlink, type VacationBlockInfo, } from "./renderHelpers.js"; import { buildDemandHoverData, cancelHoverFrame, collectResourcesWithVacations, scheduleVacationHoverUpdate, updateTooltipPosition, } from "./timelineHover.js"; import { buildResourceHeatmapSeries } from "./timelineHeatmap.js"; import { buildResourceCapacitySeries } from "./timelineCapacity.js"; import { buildProjectRowMetrics, type ProjectDayMetric, } from "./timelineProjectMetrics.js"; import { buildProjectFlatRows, estimateProjectRowHeight, type OpenDemandRowLayout, type ProjectFlatRow, } from "./timelineProjectRows.js"; // ─── Props ────────────────────────────────────────────────────────────────── interface TimelineProjectPanelProps { scrollContainerRef: React.RefObject; dragState: DragState; allocDragState: AllocDragState; rangeState: RangeState; onProjectBarMouseDown: (e: React.MouseEvent, info: ProjectBarInfo) => void; onProjectBarTouchStart: (e: React.TouchEvent, info: ProjectBarInfo) => void; 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; onOpenPanel: (projectId: string) => void; onOpenDemandClick: (demand: TimelineDemandEntry, anchorX: number, anchorY: number) => void; onAllocationContextMenu: ( info: { allocationId: string; projectId: string; contextDate?: Date }, anchorX: number, anchorY: number, ) => void; multiSelectState: MultiSelectState; optimisticAllocations: TimelineVisualOverrides; suppressHoverInteractions: boolean; // 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 ProjectBarInfo { projectId: string; projectName: string; startDate: Date; endDate: Date; } export interface OpenDemandAssignment { id: string; projectId: string; roleId: string | null; role: string | null; headcount: number; budgetCents?: number; startDate: Date; endDate: Date; hoursPerDay: number; roleEntity?: { id: string; name: string; color: string | null } | null; project?: { id: string; name: string; shortCode: string }; } type HeatmapHoverState = HeatmapHoverData; const EMPTY_DAY_METRICS: ProjectDayMetric[] = []; const SVG_XMLNS = "http://www.w3.org/2000/svg"; // ─── Component ────────────────────────────────────────────────────────────── function TimelineProjectPanelInner({ scrollContainerRef, dragState, allocDragState, rangeState, onProjectBarMouseDown, onProjectBarTouchStart, onAllocMouseDown, onAllocTouchStart, onRowMouseDown, onRowTouchStart, onOpenPanel, onOpenDemandClick, onAllocationContextMenu, multiSelectState, optimisticAllocations, suppressHoverInteractions, CELL_WIDTH, dates, totalCanvasWidth, toLeft, toWidth, gridLines, xToDate, }: TimelineProjectPanelProps) { const { projectGroups, openDemandsByProject, allocsByResource, vacationsByResource, filters, displayMode, heatmapScheme, blinkOverbookedDays, activeFilterCount, today, } = useTimelineContext(); 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 visualProjectGroups = useMemo( () => projectGroups.map((project) => ({ ...project, resourceRows: project.resourceRows.map((row) => ({ ...row, allocs: applyVisualOverrides(row.allocs, optimisticAllocations), })), })), [projectGroups, optimisticAllocations], ); // ─── Heatmap hover (same mechanism as resource panel) ───────────────────── const heatmapRafRef = useRef(null); const lastHeatmapDayRef = useRef(-1); const lastHeatmapResourceRef = useRef(null); const vacationHoverRafRef = useRef(null); const hoveredVacationKeyRef = useRef(null); const pendingHeatmapRef = useRef<{ clientX: number; rect: DOMRect; resourceId: string; } | null>(null); const heatmapTooltipRef = useRef(null); const vacationTooltipRef = useRef(null); const demandTooltipRef = useRef(null); const heatmapTooltipPosRef = useRef({ left: 0, top: 0 }); const vacationTooltipPosRef = useRef({ left: 0, top: 0 }); const demandTooltipPosRef = useRef({ left: 0, top: 0 }); const [heatmapHover, setHeatmapHover] = useState(null); const [vacationHover, setVacationHover] = useState(null); const [demandHover, setDemandHover] = useState(null); const resourceCapacityById = useMemo( () => buildResourceCapacitySeries(visualAllocsByResource, vacationsByResource, dates), [dates, vacationsByResource, visualAllocsByResource], ); const { resourceHeatmapById, resourceTotalHoursById } = useMemo( () => buildResourceHeatmapSeries(visualAllocsByResource, dates, resourceCapacityById), [dates, resourceCapacityById, visualAllocsByResource], ); const vacationBlocksByResource = useMemo( () => buildVacationBlocksByResource( vacationsByResource, filters.showVacations, toLeft, toWidth, CELL_WIDTH, totalCanvasWidth, ), [CELL_WIDTH, filters.showVacations, toLeft, toWidth, totalCanvasWidth, vacationsByResource], ); const projectRowMetrics = useMemo(() => { return buildProjectRowMetrics( dates, visualProjectGroups, resourceTotalHoursById, resourceCapacityById, ); }, [dates, resourceCapacityById, resourceTotalHoursById, visualProjectGroups]); const flatRows = useMemo( () => buildProjectFlatRows(visualProjectGroups, openDemandsByProject, optimisticAllocations), [openDemandsByProject, optimisticAllocations, visualProjectGroups], ); const rowVirtualizer = useVirtualizer({ count: flatRows.length, getScrollElement: () => scrollContainerRef.current, estimateSize: (index) => estimateProjectRowHeight(flatRows[index]), overscan: 8, getItemKey: (index) => flatRows[index]?.key ?? index, }); const virtualItems = rowVirtualizer.getVirtualItems(); const totalRowHeight = rowVirtualizer.getTotalSize(); const resourcesWithVacations = useMemo( () => collectResourcesWithVacations(vacationsByResource), [vacationsByResource], ); const handleRowHeatmapMove = useCallback( (e: React.MouseEvent, resourceId: string) => { 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 && resourceId === lastHeatmapResourceRef.current ) return; pendingHeatmapRef.current = { clientX: e.clientX, rect, resourceId }; if (heatmapRafRef.current !== null) return; heatmapRafRef.current = requestAnimationFrame(() => { heatmapRafRef.current = null; const pending = pendingHeatmapRef.current; pendingHeatmapRef.current = null; if (!pending) return; const { clientX, rect: pendingRect, resourceId: pendingResourceId } = pending; const nextDayIndex = Math.floor((clientX - pendingRect.left) / CELL_WIDTH); const nextHeatmap = resourceHeatmapById.get(pendingResourceId)?.[nextDayIndex] ?? null; if (nextDayIndex < 0 || nextDayIndex >= dates.length) { lastHeatmapDayRef.current = -1; lastHeatmapResourceRef.current = null; startTransition(() => setHeatmapHover(null)); return; } lastHeatmapDayRef.current = nextDayIndex; lastHeatmapResourceRef.current = pendingResourceId; startTransition(() => { setHeatmapHover(nextHeatmap); }); }); }, [CELL_WIDTH, dates.length, resourceHeatmapById], ); const handleRowVacationHover = useCallback( (e: React.MouseEvent, resourceId: string) => { if (!resourcesWithVacations.has(resourceId)) { if (hoveredVacationKeyRef.current !== null) { hoveredVacationKeyRef.current = null; startTransition(() => { setVacationHover(null); }); } return; } 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); }); }, }); }, [resourcesWithVacations, vacationsByResource, xToDate], ); const clearHoverTooltips = useCallback(() => { cancelHoverFrame(heatmapRafRef); cancelHoverFrame(vacationHoverRafRef); const shouldClearHeatmap = lastHeatmapDayRef.current !== -1; const shouldClearVacation = hoveredVacationKeyRef.current !== null; const shouldClearDemand = demandHover !== null; lastHeatmapDayRef.current = -1; lastHeatmapResourceRef.current = null; hoveredVacationKeyRef.current = null; if (shouldClearHeatmap || shouldClearVacation || shouldClearDemand) { startTransition(() => { if (shouldClearHeatmap) setHeatmapHover(null); if (shouldClearVacation) setVacationHover(null); if (shouldClearDemand) setDemandHover(null); }); } }, [demandHover]); const handleDemandHoverMove = useCallback( (e: React.MouseEvent, demand: TimelineDemandEntry) => { updateTooltipPosition(demandTooltipPosRef, demandTooltipRef, e.clientX, e.clientY, 16, -36); startTransition(() => { setDemandHover(buildDemandHoverData(demand)); }); }, [], ); useEffect( () => () => { cancelHoverFrame(heatmapRafRef); cancelHoverFrame(vacationHoverRafRef); }, [], ); useEffect(() => { if (!suppressHoverInteractions) return; clearHoverTooltips(); }, [clearHoverTooltips, suppressHoverInteractions]); if (visualProjectGroups.length === 0) { return (
No projects in this time range{activeFilterCount > 0 && " (filtered)"}.
); } return (
{virtualItems.map((virtualRow) => { const row = flatRows[virtualRow.index]; if (!row) return null; return (
{row.type === "header" ? ( (() => { const { project } = row; const customColor = project.color; const projectColor = getProjectColor(project.id); const colors = ORDER_TYPE_COLORS[project.orderType] ?? { bg: "bg-gray-400", text: "text-white", light: "bg-gray-50 border-gray-200 dark:bg-gray-800 dark:border-gray-700", }; const isThisProjectShifting = dragState.isDragging && dragState.projectId === project.id; const projDispStart = isThisProjectShifting && dragState.currentStartDate ? dragState.currentStartDate : project.startDate; const projDispEnd = isThisProjectShifting && dragState.currentEndDate ? dragState.currentEndDate : project.endDate; const projLeft = toLeft(projDispStart); const projWidth = Math.max(CELL_WIDTH, toWidth(projDispStart, projDispEnd)); return (
onOpenPanel(project.id)} >
{project.name}
{project.status}
{gridLines} {projWidth > 0 && projLeft < totalCanvasWidth && (
{ if (!dragState.isDragging) onOpenPanel(project.id); }} onMouseDown={(e) => { if (e.button === 2) { e.preventDefault(); e.stopPropagation(); if (!dragState.isDragging) { onOpenPanel(project.id); } return; } onProjectBarMouseDown(e, { projectId: project.id, projectName: project.name, startDate: project.startDate, endDate: project.endDate, }); }} onTouchStart={(e) => onProjectBarTouchStart(e, { projectId: project.id, projectName: project.name, startDate: project.startDate, endDate: project.endDate, }) } onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); if (!dragState.isDragging) { onOpenPanel(project.id); } }} > {project.name}
)}
); })() ) : row.type === "open-demand" ? ( renderOpenDemandRow( row.openDemandCount, row.layout, row.projectId, CELL_WIDTH, totalCanvasWidth, toLeft, toWidth, gridLines, onOpenDemandClick, onAllocMouseDown, onAllocTouchStart, onAllocationContextMenu, handleDemandHoverMove, clearHoverTooltips, multiSelectState, allocDragState, suppressHoverInteractions, ) ) : (
{row.resource.displayName.slice(0, 2).toUpperCase()}
{row.resource.displayName}
{row.resource.eid}
{ const rect = e.currentTarget.getBoundingClientRect(); const date = xToDate(e.clientX, rect); onRowMouseDown(e, { resourceId: row.resource.id, startDate: date, suggestedProjectId: row.project.id, }); }} onTouchStart={(e) => { const rect = e.currentTarget.getBoundingClientRect(); const date = xToDate(e.touches[0]?.clientX ?? 0, rect); onRowTouchStart(e, { resourceId: row.resource.id, startDate: date, suggestedProjectId: row.project.id, }); }} onMouseMove={(e) => { if (suppressHoverInteractions) return; handleRowHeatmapMove(e, row.resource.id); handleRowVacationHover(e, row.resource.id); }} onMouseLeave={clearHoverTooltips} > {gridLines} {renderProjectUtilOverlay( projectRowMetrics.get(row.metricsKey) ?? EMPTY_DAY_METRICS, CELL_WIDTH, displayMode, heatmapScheme, totalCanvasWidth, )} {renderProjectDragHandles( row.allocs, allocDragState, toLeft, toWidth, CELL_WIDTH, totalCanvasWidth, onAllocMouseDown, onAllocTouchStart, onAllocationContextMenu, multiSelectState, suppressHoverInteractions, )} {filters.showVacations && renderVacationBlocks( vacationBlocksByResource.get(row.resource.id) ?? [], ROW_HEIGHT, )} {blinkOverbookedDays && renderOverbookingBlink( visualAllocsByResource.get(row.resource.id) ?? [], dates, CELL_WIDTH, resourceCapacityById.get(row.resource.id)?.capacityHoursByDay, resourceCapacityById.get(row.resource.id)?.bookingFactorsByDay, )} {renderRangeOverlay( rangeState, row.resource.id, ROW_HEIGHT, toLeft, toWidth, CELL_WIDTH, )}
)}
); })}
); } // ProjectPanelTooltips removed — now uses shared TimelineTooltip component // ─── Pure render functions ────────────────────────────────────────────────── function renderOpenDemandRow( openDemandCount: number, layout: OpenDemandRowLayout, projectId: string, CELL_WIDTH: number, totalCanvasWidth: number, toLeft: (d: Date) => number, toWidth: (s: Date, e: Date) => number, rowGridLines: React.ReactNode, onOpenDemandClick: (demand: TimelineDemandEntry, anchorX: number, anchorY: number) => void, onAllocMouseDown: (e: React.MouseEvent, info: AllocMouseDownInfo) => void, onAllocTouchStart: (e: React.TouchEvent, info: AllocMouseDownInfo) => void, onAllocationContextMenu: ( info: { allocationId: string; projectId: string; contextDate?: Date }, anchorX: number, anchorY: number, ) => void, onDemandHoverMove: (e: React.MouseEvent, demand: TimelineDemandEntry) => void, onClearHoverTooltips: () => void, multiSelectState: MultiSelectState, allocDragState: AllocDragState, suppressHoverInteractions: boolean, ) { const { visibleOpenDemands, laneMap, rowHeight } = layout; if (visibleOpenDemands.length === 0) return null; return (
?
Open demand
{openDemandCount} open demand{openDemandCount > 1 ? "s" : ""}
{rowGridLines}
{visibleOpenDemands.map((alloc) => { const allocStart = new Date(alloc.startDate); const allocEnd = new Date(alloc.endDate); const isAllocDragged = allocDragState.isActive && allocDragState.allocationId === alloc.id; const dispStart = isAllocDragged && allocDragState.currentStartDate ? allocDragState.currentStartDate : allocStart; const dispEnd = isAllocDragged && allocDragState.currentEndDate ? allocDragState.currentEndDate : allocEnd; // Multi-drag visual offset const isMultiDragTarget = multiSelectState.isMultiDragging && multiSelectState.selectedAllocationIds.includes(alloc.id); const multiDragPx = isMultiDragTarget ? multiSelectState.multiDragDaysDelta * CELL_WIDTH : 0; const multiDragMode = multiSelectState.multiDragMode; let left = toLeft(dispStart); let width = Math.max(CELL_WIDTH, toWidth(dispStart, dispEnd)); let dragTransform: string | undefined; if (isAllocDragged) { const preview = applyPointerOffsetPreviewRect({ left, width, mode: allocDragState.mode, pointerOffsetX: getDragPointerOffset( allocDragState.pointerDeltaX, allocDragState.daysDelta, CELL_WIDTH, ), minWidth: CELL_WIDTH, }); left = preview.left; width = preview.width; dragTransform = preview.transform; } // Clamp negative left (bar starts before view) to avoid extending outside canvas. if (left < 0) { width += left; left = 0; } if (width <= 0 || left >= totalCanvasWidth) return null; if (isMultiDragTarget && multiDragMode === "resize-start") { left += multiDragPx; width = Math.max(CELL_WIDTH, width - multiDragPx); } else if (isMultiDragTarget && multiDragMode === "resize-end") { width = Math.max(CELL_WIDTH, width + multiDragPx); } const roleEntity = ( alloc as { roleEntity?: { id: string; name: string; color: string | null } | null } ).roleEntity; const roleName = roleEntity?.name ?? (alloc as { role?: string | null }).role ?? "Open demand"; const roleColor = roleEntity?.color ?? "#f59e0b"; const headcount = (alloc as { headcount?: number }).headcount ?? 1; const lane = laneMap.get(alloc.id) ?? 0; const top = 8 + lane * SUB_LANE_HEIGHT; const blockHeight = SUB_LANE_HEIGHT - 8; const HANDLE_W = width >= 48 ? 8 : 6; const allocInfo: AllocMouseDownInfo = { mode: "move", allocationId: alloc.id, mutationAllocationId: getPlanningEntryMutationId(alloc), projectId: alloc.projectId, projectName: alloc.project.name, resourceId: null, startDate: allocStart, endDate: allocEnd, }; return (
{ if (e.button === 2) e.stopPropagation(); }} onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); if (suppressHoverInteractions) return; onAllocationContextMenu( { allocationId: alloc.id, projectId: alloc.projectId }, e.clientX, e.clientY, ); }} onMouseMove={(e) => { if (suppressHoverInteractions) return; onDemandHoverMove(e, alloc); }} onClick={(e) => { e.stopPropagation(); if (suppressHoverInteractions) return; onOpenDemandClick(alloc, e.clientX, e.clientY); }} onKeyDown={(e) => { if (e.key !== "Enter" && e.key !== " ") { return; } e.preventDefault(); e.stopPropagation(); if (suppressHoverInteractions) return; const rect = e.currentTarget.getBoundingClientRect(); onOpenDemandClick(alloc, rect.left + rect.width / 2, rect.top + rect.height / 2); }} role="button" tabIndex={0} aria-label={`Open demand details for ${roleName} on ${alloc.project.name}`} > {/* Left resize handle */}
onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" })} onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, { ...allocInfo, mode: "resize-start" }); }} /> {/* Center — move + click */}
{ e.stopPropagation(); onAllocMouseDown(e, allocInfo); }} onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, allocInfo); }} > {roleName} {headcount > 1 ? ` x${headcount}` : ""}
{/* Right resize handle */}
onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" })} onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" }); }} />
); })}
); } // ─── Project-view: per-resource utilisation band ──────────────────────────── function renderProjectUtilOverlay( dayMetrics: ProjectDayMetric[], CELL_WIDTH: number, displayMode: string, heatmapScheme: string, totalCanvasWidth: number, ) { if (dayMetrics.length === 0 || totalCanvasWidth <= 0) return null; const BAND_H = 7; const BAR_H = ROW_HEIGHT - BAND_H - 11; const useHeatmapColors = displayMode === "bar"; const svgParts: string[] = [ ``, ]; dayMetrics.forEach(({ projH, totalH, capacityH }, i) => { if ((totalH === 0 && projH === 0) || capacityH <= 0) return; const isOver = totalH > capacityH; const totalBarH = Math.max( projH > 0 ? 2 : 0, Math.round((Math.min(totalH, capacityH) / capacityH) * BAR_H), ); const projBarH = projH > 0 ? Math.min(totalBarH, Math.max(2, Math.round((projH / capacityH) * BAR_H))) : 0; const otherBarH = totalBarH - projBarH; const projPct = (projH / capacityH) * 100; const totalPct = (totalH / capacityH) * 100; const projColor = useHeatmapColors ? heatmapColor( projPct, heatmapScheme as import("~/hooks/useAppPreferences.js").HeatmapColorScheme, "bar", ) ?? "rgba(59,130,246,0.8)" : "rgba(96,165,250,0.8)"; const totalColor = useHeatmapColors ? heatmapColor( totalPct, heatmapScheme as import("~/hooks/useAppPreferences.js").HeatmapColorScheme, "bar", ) ?? "rgba(156,163,175,0.5)" : isOver ? "rgba(252,211,77,0.8)" : "rgba(209,213,219,0.8)"; const xBand = i * CELL_WIDTH + 1; const xBar = i * CELL_WIDTH + 3; const bandWidth = Math.max(CELL_WIDTH - 2, 0); const barWidth = Math.max(CELL_WIDTH - 6, 0); if (projH > 0 && bandWidth > 0) { svgParts.push( ``, ); } if (otherBarH > 0 && barWidth > 0) { svgParts.push( ``, ); } if (projBarH > 0 && barWidth > 0) { svgParts.push( ``, ); } if (isOver && totalBarH > 0 && barWidth > 0) { svgParts.push( ``, ); } }); svgParts.push(""); const svgDataUri = `url("data:image/svg+xml;utf8,${encodeURIComponent(svgParts.join(""))}")`; return (
); } // ─── Project-view: transparent drag handles ───────────────────────────────── function renderProjectDragHandles( allocs: TimelineAssignmentEntry[], allocDragState: AllocDragState, toLeft: (d: Date) => number, toWidth: (s: Date, e: Date) => number, CELL_WIDTH: number, totalCanvasWidth: number, onAllocMouseDown: (e: React.MouseEvent, info: AllocMouseDownInfo) => void, onAllocTouchStart: (e: React.TouchEvent, info: AllocMouseDownInfo) => void, onAllocationContextMenu: ( info: { allocationId: string; projectId: string; contextDate?: Date }, anchorX: number, anchorY: number, ) => void, multiSelectState: MultiSelectState, suppressHoverInteractions: boolean, ) { return allocs.map((alloc) => { const allocStart = new Date(alloc.startDate); const allocEnd = new Date(alloc.endDate); const isAllocDragged = allocDragState.isActive && allocDragState.allocationId === alloc.id; const dispStart = isAllocDragged && allocDragState.currentStartDate ? allocDragState.currentStartDate : allocStart; const dispEnd = isAllocDragged && allocDragState.currentEndDate ? allocDragState.currentEndDate : allocEnd; let left = toLeft(dispStart); let width = Math.max(CELL_WIDTH, toWidth(dispStart, dispEnd)); let dragTransform: string | undefined; if (isAllocDragged) { const preview = applyPointerOffsetPreviewRect({ left, width, mode: allocDragState.mode, pointerOffsetX: getDragPointerOffset( allocDragState.pointerDeltaX, allocDragState.daysDelta, CELL_WIDTH, ), minWidth: CELL_WIDTH, }); left = preview.left; width = preview.width; dragTransform = preview.transform; } if (width <= 0 || left >= totalCanvasWidth) return null; // Multi-drag visual offset const isMultiDragTarget = multiSelectState.isMultiDragging && multiSelectState.selectedAllocationIds.includes(alloc.id); const multiDragPx = isMultiDragTarget ? multiSelectState.multiDragDaysDelta * CELL_WIDTH : 0; const multiDragMode = multiSelectState.multiDragMode; if (isMultiDragTarget && multiDragMode === "resize-start") { left += multiDragPx; width = Math.max(CELL_WIDTH, width - multiDragPx); } else if (isMultiDragTarget && multiDragMode === "resize-end") { width = Math.max(CELL_WIDTH, width + multiDragPx); } // Always show resize handles — for narrow bars, use overlapping handles const HANDLE_W = width >= 48 ? 8 : 6; const hasRecurrence = !!(alloc.metadata as Record | null)?.recurrence; const allocInfo: AllocMouseDownInfo = { mode: "move", allocationId: alloc.id, mutationAllocationId: getPlanningEntryMutationId(alloc), projectId: alloc.projectId, projectName: alloc.project.name, resourceId: alloc.resourceId, startDate: allocStart, endDate: allocEnd, }; return (
{ if (e.button === 2) e.stopPropagation(); }} onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); if (suppressHoverInteractions) return; onAllocationContextMenu( { allocationId: alloc.id, projectId: alloc.projectId }, e.clientX, e.clientY, ); }} >
{ e.stopPropagation(); onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" }); }} onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, { ...allocInfo, mode: "resize-start" }); }} />
{ e.stopPropagation(); onAllocMouseDown(e, allocInfo); }} onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, allocInfo); }} > {hasRecurrence && width > 28 && ( )}
{ e.stopPropagation(); onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" }); }} onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" }); }} />
); }); } export const TimelineProjectPanel = memo(TimelineProjectPanelInner);