"use client"; import { MILLISECONDS_PER_DAY } from "@nexus/shared"; import { clsx } from "clsx"; import React from "react"; import type { TimelineAssignmentEntry } from "./TimelineContext.js"; import { applyPointerOffsetPreviewRect, getDragPointerOffset } from "./allocationVisualState.js"; import { heatmapBgColor } from "./heatmapUtils.js"; import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js"; import { SUB_LANE_HEIGHT } from "./timelineConstants.js"; import { getProjectColor } from "~/lib/project-colors.js"; import type { DragState, AllocDragState, MultiSelectState } from "~/hooks/useTimelineDrag.js"; import type { HeatmapColorScheme } from "~/hooks/useAppPreferences.js"; import type { ResourceCapacitySeries } from "./timelineCapacity.js"; import { buildAllocationWorkingDaySegments, isAllocationScheduledOnDate, toLocalDateKey, } from "./timelineAvailability.js"; // ─── Shared interaction types ──────────────────────────────────────────────── export interface AllocMouseDownInfo { mode: "move" | "resize-start" | "resize-end"; allocationId: string; mutationAllocationId: string; projectId: string; projectName: string; resourceId: string | null; startDate: Date; endDate: Date; allocationStartDate?: Date; allocationEndDate?: Date; scope?: "allocation" | "segment"; } // ─── Helper types ─────────────────────────────────────────────────────────── export interface AllocBlockData { alloc: TimelineAssignmentEntry; lane: number; } // ─── Pure render functions (no hooks, extracted from TimelineResourcePanel) ─── export 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; contextDate?: Date }, anchorX: number, anchorY: number, ) => void, multiSelectState: MultiSelectState, suppressHoverInteractions: boolean, onInlineEdit?: ( allocationId: string, initialValues: { startDate: Date; endDate: Date; hoursPerDay: number }, barRect: DOMRect, ) => void, scrollLeft = 0, containerWidth = 1200, pendingMutationIds?: ReadonlySet, ) { const OVERSCAN_PX = 10 * CELL_WIDTH; const visibleLeft = scrollLeft - OVERSCAN_PX; const visibleRight = scrollLeft + containerWidth + OVERSCAN_PX; const anyDragActive = dragState.isDragging || allocDragState.isActive; const selectedAllocationSet = new Set(multiSelectState.selectedAllocationIds); function toUtcDay(value: Date): Date { return new Date(Date.UTC(value.getFullYear(), value.getMonth(), value.getDate())); } function addUtcDays(value: Date, days: number): Date { const next = new Date(value); next.setUTCDate(next.getUTCDate() + days); return next; } function resolveSegmentContextDate( clientX: number, rect: DOMRect, segmentStart: Date, segmentEnd: Date, ): Date { const start = toUtcDay(segmentStart); const end = toUtcDay(segmentEnd); const rawIndex = Math.floor((clientX - rect.left) / CELL_WIDTH); const maxIndex = Math.max( 0, Math.round((end.getTime() - start.getTime()) / MILLISECONDS_PER_DAY), ); const dayIndex = Math.min(Math.max(rawIndex, 0), maxIndex); return addUtcDays(start, dayIndex); } function sameDate(a: Date | null, b: Date | null) { return Boolean(a && b) && a!.getTime() === b!.getTime(); } return blockData.flatMap(({ alloc, lane }) => { const allocStart = toUtcDay(new Date(alloc.startDate)); const allocEnd = toUtcDay(new Date(alloc.endDate)); const isProjectShifted = dragState.isDragging && dragState.projectId === alloc.projectId; let dispStart = allocStart; let dispEnd = allocEnd; if (isProjectShifted && dragState.currentStartDate && dragState.currentEndDate) { dispStart = dragState.currentStartDate; dispEnd = dragState.currentEndDate; } // Multi-drag offset: shift selected allocations visually during multi-drag const isMultiDragTarget = multiSelectState.isMultiDragging && selectedAllocationSet.has(alloc.id); const multiDragPx = isMultiDragTarget ? multiSelectState.multiDragDaysDelta * CELL_WIDTH : 0; const multiDragMode = multiSelectState.multiDragMode; 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 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, allocationStartDate: allocStart, allocationEndDate: allocEnd, scope: "allocation", }; const segments = buildAllocationWorkingDaySegments( { ...alloc, startDate: dispStart, endDate: dispEnd }, dispStart, dispEnd, ); if (segments.length === 0) { return []; } return segments.flatMap((segment, segmentIndex) => { const isFirstSegment = segmentIndex === 0; const segmentKey = `${alloc.id}-${segmentIndex}`; const draggedSegmentActive = allocDragState.isActive && allocDragState.allocationId === alloc.id && sameDate(allocDragState.originalStartDate, toUtcDay(segment.start)) && sameDate(allocDragState.originalEndDate, toUtcDay(segment.end)); const isBeingDragged = isProjectShifted || draggedSegmentActive; const isOtherDragged = anyDragActive && !isBeingDragged; let segmentLeft = toLeft(segment.start); let segmentWidth = Math.max(CELL_WIDTH, toWidth(segment.start, segment.end)); let dragTransform: string | undefined; if (isProjectShifted) { const preview = applyPointerOffsetPreviewRect({ left: segmentLeft, width: segmentWidth, mode: "move", pointerOffsetX: getDragPointerOffset( dragState.pointerDeltaX, dragState.daysDelta, CELL_WIDTH, ), minWidth: CELL_WIDTH, }); segmentLeft = preview.left; segmentWidth = preview.width; dragTransform = preview.transform; } else if (draggedSegmentActive) { const preview = applyPointerOffsetPreviewRect({ left: segmentLeft, width: segmentWidth, mode: allocDragState.mode, pointerOffsetX: getDragPointerOffset( allocDragState.pointerDeltaX, allocDragState.daysDelta, CELL_WIDTH, ), minWidth: CELL_WIDTH, }); segmentLeft = preview.left; segmentWidth = preview.width; dragTransform = preview.transform; } if (isMultiDragTarget && multiDragMode === "resize-start") { segmentLeft += multiDragPx; segmentWidth = Math.max(CELL_WIDTH, segmentWidth - multiDragPx); } else if (isMultiDragTarget && multiDragMode === "resize-end") { segmentWidth = Math.max(CELL_WIDTH, segmentWidth + multiDragPx); } if (segmentWidth <= 0 || segmentLeft >= totalCanvasWidth) { return []; } // Horizontal virtualization: skip bars entirely outside the visible window const effectiveRight = segmentLeft + segmentWidth; if (effectiveRight < visibleLeft || segmentLeft > visibleRight) { return []; } const handleWidth = segmentWidth >= 48 ? 10 : 6; const dragInset = Math.min(handleWidth, Math.max(2, Math.floor(segmentWidth / 4))); const segmentInfo: AllocMouseDownInfo = { ...allocInfo, startDate: toUtcDay(segment.start), endDate: toUtcDay(segment.end), scope: "segment", }; return (
{ if (e.button === 2) e.stopPropagation(); }} onDoubleClick={(e) => { if (suppressHoverInteractions || !onInlineEdit) return; e.stopPropagation(); const toUtcDate = (v: Date | string) => { const d = new Date(v); return new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())); }; onInlineEdit( alloc.id, { startDate: toUtcDate(alloc.startDate), endDate: toUtcDate(alloc.endDate), hoursPerDay: alloc.hoursPerDay, }, e.currentTarget.getBoundingClientRect(), ); }} onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); if (suppressHoverInteractions) return; onAllocationContextMenu( { allocationId: getPlanningEntryMutationId(alloc), projectId: alloc.projectId, contextDate: resolveSegmentContextDate( e.clientX, e.currentTarget.getBoundingClientRect(), segment.start, segment.end, ), }, e.clientX, e.clientY, ); }} >
{ e.stopPropagation(); onAllocMouseDown(e, { ...segmentInfo, mode: "resize-start" }); }} onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, { ...segmentInfo, mode: "resize-start" }); }} > {handleWidth >= 10 && (
)}