"use client"; import { clsx } from "clsx"; import { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { useTimelineContext, type TimelineAssignmentEntry, } from "./TimelineContext.js"; import { ConflictOverlay } from "./ConflictOverlay.js"; import { computeSubLanes } from "./utils.js"; import { heatmapBgColor } from "./heatmapUtils.js"; import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js"; import { TimelineTooltip } from "./TimelineTooltip.js"; import { ROW_HEIGHT, SUB_LANE_HEIGHT, LABEL_WIDTH, } from "./timelineConstants.js"; import { getProjectColor } from "~/lib/project-colors.js"; import type { DragState, AllocDragState, RangeState, ShiftPreviewData, MultiSelectState, } from "~/hooks/useTimelineDrag.js"; import type { HeatmapColorScheme } from "~/hooks/useAppPreferences.js"; import { renderVacationBlocks, renderRangeOverlay, renderOverbookingBlink, type VacationBlockInfo, } from "./renderHelpers.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 }, anchorX: number, anchorY: number, ) => void; multiSelectState: MultiSelectState; // 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 AllocMouseDownInfo { mode: "move" | "resize-start" | "resize-end"; allocationId: string; mutationAllocationId: string; projectId: string; projectName: string; resourceId: string | null; startDate: Date; endDate: 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, CELL_WIDTH, dates, totalCanvasWidth, toLeft, toWidth, gridLines, xToDate, }: TimelineResourcePanelProps) { const { resources, allocsByResource, vacationsByResource, filters, viewStart, viewEnd, displayMode, heatmapScheme, blinkOverbookedDays, activeFilterCount, } = useTimelineContext(); // ─── 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<{ date: Date; totalH: number; pct: number; breakdown: { projectId: string; shortCode: string; projectName: string; orderType: string; hoursPerDay: number; responsiblePerson?: string | null; role?: string | null; status?: string; startDate?: string; endDate?: string; }[]; } | null>(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(); // ─── 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(() => { return resources.map((resource) => { const allocs = allocsByResource.get(resource.id) ?? []; const isContextResource = contextResourceIds.includes(resource.id); return { resource, allocs, isContextResource }; }); }, [resources, allocsByResource, contextResourceIds]); // ─── Memo 2: vacationBlocks — vacation bar positions per resource ───────── const vacationBlocksByResource = useMemo(() => { if (!filters.showVacations) return new Map(); const result = new Map(); for (const [resourceId, vacations] of vacationsByResource) { const blocks: VacationBlockInfo[] = []; for (const v of vacations) { const vStart = new Date(v.startDate); const vEnd = new Date(v.endDate); const left = toLeft(vStart); const width = Math.max(CELL_WIDTH, toWidth(vStart, vEnd)); if (width <= 0 || left >= totalCanvasWidth) continue; blocks.push({ vacation: v, left, width }); } if (blocks.length > 0) { result.set(resourceId, blocks); } } return result; }, [vacationsByResource, toLeft, toWidth, CELL_WIDTH, totalCanvasWidth, filters.showVacations]); // ─── 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 REF_H = 8; const result = new Map(); // resourceId -> avg utilization pct for (const { resource, allocs } of resourceRows) { if (allocs.length === 0) continue; let totalHours = 0; let dayCount = 0; for (const date of dates) { const t = date.getTime(); let dayH = 0; for (const a of allocs) { const s = new Date(a.startDate); s.setHours(0, 0, 0, 0); const e = new Date(a.endDate); e.setHours(0, 0, 0, 0); if (t >= s.getTime() && t <= e.getTime()) dayH += a.hoursPerDay; } if (dayH > 0) { totalHours += dayH; dayCount++; } } if (dayCount > 0) { result.set(resource.id, (totalHours / dayCount / REF_H) * 100); } } return result; }, [resourceRows, dates]); // ─── Heatmap row hover handler ──────────────────────────────────────────── const handleRowHeatmapMove = useCallback( (e: React.MouseEvent, allocs: TimelineAssignmentEntry[]) => { heatmapTooltipPosRef.current = { left: e.clientX + 16, top: e.clientY - 52 }; if (heatmapTooltipRef.current) { heatmapTooltipRef.current.style.left = `${heatmapTooltipPosRef.current.left}px`; heatmapTooltipRef.current.style.top = `${heatmapTooltipPosRef.current.top}px`; } 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 t = date.getTime(); const REF_H = 8; const projectHours = new Map< string, { shortCode: string; projectName: string; orderType: string; hours: number; responsiblePerson?: string | null; role?: string | null; status?: string; startDate?: string; endDate?: string; } >(); for (const alloc of a) { const s = new Date(alloc.startDate); s.setHours(0, 0, 0, 0); const ev = new Date(alloc.endDate); ev.setHours(0, 0, 0, 0); if (t < s.getTime() || t > ev.getTime()) continue; const existing = projectHours.get(alloc.projectId); if (existing) { existing.hours += alloc.hoursPerDay; } else { projectHours.set(alloc.projectId, { shortCode: alloc.project.shortCode, projectName: alloc.project.name, orderType: alloc.project.orderType, hours: alloc.hoursPerDay, responsiblePerson: (alloc.project as { responsiblePerson?: string | null }).responsiblePerson ?? null, role: alloc.role ?? alloc.roleEntity?.name ?? null, status: alloc.status, startDate: new Date(alloc.startDate).toISOString().slice(0, 10), endDate: new Date(alloc.endDate).toISOString().slice(0, 10), }); } } const breakdown = [...projectHours.entries()] .map(([projectId, v]) => ({ projectId, ...v, hoursPerDay: v.hours })) .sort((a, b) => b.hoursPerDay - a.hoursPerDay); const totalH = breakdown.reduce((sum, b) => sum + b.hoursPerDay, 0); startTransition(() => { setHeatmapHover({ date, totalH, pct: (totalH / REF_H) * 100, breakdown }); }); }); }, [CELL_WIDTH, dates], ); // ─── Vacation hover ─────────────────────────────────────────────────────── const handleRowVacationHover = useCallback( (e: React.MouseEvent, resourceId: string) => { vacationTooltipPosRef.current = { left: e.clientX + 14, top: e.clientY - 8 }; if (vacationTooltipRef.current) { vacationTooltipRef.current.style.left = `${vacationTooltipPosRef.current.left}px`; vacationTooltipRef.current.style.top = `${vacationTooltipPosRef.current.top}px`; } const rect = e.currentTarget.getBoundingClientRect(); const clientX = e.clientX; if (vacationHoverRafRef.current !== null) return; vacationHoverRafRef.current = requestAnimationFrame(() => { vacationHoverRafRef.current = null; const date = xToDate(clientX, rect); date.setHours(0, 0, 0, 0); const t = date.getTime(); const resourceVacations = vacationsByResource.get(resourceId) ?? []; const hit = resourceVacations.find((v) => { const s = new Date(v.startDate); s.setHours(0, 0, 0, 0); const end = new Date(v.endDate); end.setHours(0, 0, 0, 0); return t >= s.getTime() && t <= end.getTime(); }) ?? null; const nextKey = hit ? `${resourceId}:${hit.id}` : null; if (nextKey === hoveredVacationKeyRef.current) return; hoveredVacationKeyRef.current = nextKey; startTransition(() => { setVacationHover(hit); }); }); }, [vacationsByResource, xToDate], ); const clearHoverTooltips = useCallback(() => { if (heatmapRafRef.current !== null) { cancelAnimationFrame(heatmapRafRef.current); heatmapRafRef.current = null; } if (vacationHoverRafRef.current !== null) { cancelAnimationFrame(vacationHoverRafRef.current); vacationHoverRafRef.current = null; } 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( () => () => { if (heatmapRafRef.current !== null) cancelAnimationFrame(heatmapRafRef.current); if (vacationHoverRafRef.current !== null) cancelAnimationFrame(vacationHoverRafRef.current); }, [], ); // ─── 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 const utilPct = utilizationByResource.get(resource.id) ?? 0; const utilBg = utilPct > 100 ? "rgba(254,202,202,0.18)" // red tint for over-utilized : utilPct >= 50 ? `rgba(59,130,246,${Math.min(0.06 + (utilPct - 50) * 0.0014, 0.12)})` // faint blue tint scaling 50-100% : 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) => { handleRowHeatmapMove(e, allocs); handleRowVacationHover(e, resource.id); }} onMouseLeave={clearHoverTooltips} > {gridLines} {inBarMode ? renderDailyBars( allocs, rowHeight, CELL_WIDTH, dates, allocDragState, onAllocMouseDown, onAllocTouchStart, onAllocationContextMenu, toLeft, toWidth, totalCanvasWidth, multiSelectState, ) : renderAllocBlocksFromData( precomputed?.blockData ?? [], allocs, dragState, allocDragState, toLeft, toWidth, CELL_WIDTH, totalCanvasWidth, onAllocMouseDown, onAllocTouchStart, onAllocationContextMenu, multiSelectState, )} {filters.showVacations && renderVacationBlocks( vacationBlocksByResource.get(resource.id) ?? [], rowHeight, )} {displayMode === "strip" && renderLoadGraph(allocs, dates, CELL_WIDTH)} {displayMode === "heatmap" && renderHeatmapOverlay(allocs, dates, CELL_WIDTH, heatmapScheme)} {blinkOverbookedDays && renderOverbookingBlink(allocs, dates, CELL_WIDTH)} {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 // ─── Helper types ─────────────────────────────────────────────────────────── interface AllocBlockData { alloc: TimelineAssignmentEntry; lane: number; } // ─── Pure render functions (no hooks, extracted from TimelineView) ─────────── function renderAllocBlocksFromData( blockData: AllocBlockData[], _allocs: TimelineAssignmentEntry[], dragState: DragState, 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 }, anchorX: number, anchorY: number, ) => void, multiSelectState: MultiSelectState, ) { const anyDragActive = dragState.isDragging || allocDragState.isActive; return blockData.map(({ alloc, lane }) => { const allocStart = new Date(alloc.startDate); const allocEnd = new Date(alloc.endDate); const isProjectShifted = dragState.isDragging && dragState.projectId === alloc.projectId; const isAllocDragged = allocDragState.isActive && allocDragState.allocationId === alloc.id; const isBeingDragged = isProjectShifted || isAllocDragged; const isOtherDragged = anyDragActive && !isBeingDragged; let dispStart = allocStart; let dispEnd = allocEnd; if (isProjectShifted && dragState.currentStartDate && dragState.currentEndDate) { dispStart = dragState.currentStartDate; dispEnd = dragState.currentEndDate; } else if (isAllocDragged && allocDragState.currentStartDate && allocDragState.currentEndDate) { dispStart = allocDragState.currentStartDate; dispEnd = allocDragState.currentEndDate; } // Multi-drag offset: shift selected allocations visually during multi-drag 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)); // For multi-drag resize, adjust left/width instead of using translateX 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); } if (width <= 0 || left >= totalCanvasWidth) return null; const blockTop = 8 + lane * SUB_LANE_HEIGHT; const blockHeight = SUB_LANE_HEIGHT - 8; const customColor = (alloc.project as { color?: string | null }).color; const projectColor = getProjectColor(alloc.projectId); const blockBgColor = customColor ?? projectColor.hex + "B3"; const HANDLE_W = width >= 48 ? 10 : 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 (
{ // Stop right-click mouseDown from bubbling to the canvas, // which would falsely start a multi-selection rectangle. if (e.button === 2) e.stopPropagation(); }} onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); onAllocationContextMenu( { allocationId: alloc.id, projectId: alloc.projectId }, e.clientX, e.clientY, ); }} > {/* Left resize handle */}
onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" })} onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, { ...allocInfo, mode: "resize-start" }); }} > {HANDLE_W >= 10 && (
)}
{/* Center -- move */}
onAllocMouseDown(e, allocInfo)} onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, allocInfo); }} > {hasRecurrence && width > 28 && ( )} {width > 60 ? ( {alloc.project.name} ) : ( {alloc.project.shortCode} )} {width > 130 && {alloc.role}} {width > 190 && ( {alloc.hoursPerDay}h )}
{/* Right resize handle */}
onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" })} onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" }); }} > {HANDLE_W >= 10 && (
)}
); }); } // ─── Strip-mode: daily load graph ──────────────────────────────────────────── function renderLoadGraph(allocs: TimelineAssignmentEntry[], dates: Date[], CELL_WIDTH: number) { const GRAPH_H = 12; const REF_H = 8; function hoursOnDay(list: TimelineAssignmentEntry[], t: number) { return list.reduce((sum, a) => { const s = new Date(a.startDate); s.setHours(0, 0, 0, 0); const e = new Date(a.endDate); e.setHours(0, 0, 0, 0); return t >= s.getTime() && t <= e.getTime() ? sum + a.hoursPerDay : sum; }, 0); } return (
{dates.map((date, i) => { const t = date.getTime(); const totalH = hoursOnDay(allocs, t); if (totalH === 0) return null; const totalBarH = Math.min(GRAPH_H, Math.round((totalH / REF_H) * GRAPH_H)); return (
12 ? "bg-red-500 opacity-80" : totalH > 8 ? "bg-amber-400 opacity-80" : "bg-brand-500 opacity-80", )} style={{ left: i * CELL_WIDTH + 3, width: CELL_WIDTH - 6, height: totalBarH }} /> ); })}
); } // ─── Heatmap-mode: utilisation colour overlay ──────────────────────────────── function renderHeatmapOverlay( allocs: TimelineAssignmentEntry[], dates: Date[], CELL_WIDTH: number, heatmapScheme: HeatmapColorScheme, ) { const REF_H = 8; return dates.map((date, i) => { const t = date.getTime(); const totalH = allocs.reduce((sum, a) => { const s = new Date(a.startDate); s.setHours(0, 0, 0, 0); const e = new Date(a.endDate); e.setHours(0, 0, 0, 0); return t >= s.getTime() && t <= e.getTime() ? sum + a.hoursPerDay : sum; }, 0); const bg = heatmapBgColor((totalH / REF_H) * 100, heatmapScheme); if (!bg) return null; return (
); }); } // ─── Bar-mode: stacked daily bars ──────────────────────────────────────────── function renderDailyBars( allocs: TimelineAssignmentEntry[], rowHeight: number, CELL_WIDTH: number, dates: Date[], allocDragState: AllocDragState, onAllocMouseDown: (e: React.MouseEvent, info: AllocMouseDownInfo) => void, onAllocTouchStart: (e: React.TouchEvent, info: AllocMouseDownInfo) => void, onAllocationContextMenu: ( info: { allocationId: string; projectId: string }, anchorX: number, anchorY: number, ) => void, toLeft: (d: Date) => number, toWidth: (s: Date, e: Date) => number, totalCanvasWidth: number, multiSelectState: MultiSelectState, ) { const BAR_AREA = rowHeight - 8; const REF_H = 8; return dates.flatMap((date, i) => { const t = date.getTime(); const covering = allocs.filter((a) => { const isDragged = allocDragState.isActive && allocDragState.allocationId === a.id; const s = new Date( isDragged && allocDragState.currentStartDate ? allocDragState.currentStartDate : a.startDate, ); const e = new Date( isDragged && allocDragState.currentEndDate ? allocDragState.currentEndDate : a.endDate, ); s.setHours(0, 0, 0, 0); e.setHours(0, 0, 0, 0); return t >= s.getTime() && t <= e.getTime(); }); if (covering.length === 0) return []; const totalH = covering.reduce((sum, a) => sum + a.hoursPerDay, 0); const isOver = totalH > REF_H; let stackedH = 0; const segs: React.ReactNode[] = covering.map((alloc) => { const customColor = (alloc.project as { color?: string | null }).color; const projectColor = getProjectColor(alloc.projectId); const segBgColor = customColor ?? projectColor.hex + "B3"; const segH = Math.max( 2, Math.min(BAR_AREA - stackedH, Math.round((alloc.hoursPerDay / REF_H) * BAR_AREA)), ); const bottom = 4 + stackedH; stackedH += segH; const isBeingDragged = allocDragState.isActive && allocDragState.allocationId === alloc.id; const dispStart = new Date( isBeingDragged && allocDragState.currentStartDate ? allocDragState.currentStartDate : alloc.startDate, ); const dispEnd = new Date( isBeingDragged && allocDragState.currentEndDate ? allocDragState.currentEndDate : alloc.endDate, ); dispStart.setHours(0, 0, 0, 0); dispEnd.setHours(0, 0, 0, 0); const isFirstDay = t === dispStart.getTime(); const isLastDay = t === dispEnd.getTime(); const EDGE_W = CELL_WIDTH >= 16 ? 4 : 0; const allocInfo: AllocMouseDownInfo = { mode: "move", allocationId: alloc.id, mutationAllocationId: getPlanningEntryMutationId(alloc), projectId: alloc.projectId, projectName: alloc.project.name, resourceId: alloc.resourceId, startDate: new Date(alloc.startDate), endDate: new Date(alloc.endDate), }; return (
{ if (e.button === 2) e.stopPropagation(); }} onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); onAllocationContextMenu( { allocationId: alloc.id, projectId: alloc.projectId }, e.clientX, e.clientY, ); }} > {isFirstDay && EDGE_W > 0 && (
{ 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); }} /> {isLastDay && EDGE_W > 0 && (
{ e.stopPropagation(); onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" }); }} onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" }); }} /> )}
); }); if (isOver) { segs.push(
, ); } return segs; }); } export const TimelineResourcePanel = memo(TimelineResourcePanelInner); // ─── Re-export tooltip types for the parent ───────────────────────────────── export type { AllocBlockData }; export type { VacationBlockInfo } from "./renderHelpers.js";