diff --git a/apps/web/src/components/timeline/TimelineProjectPanel.tsx b/apps/web/src/components/timeline/TimelineProjectPanel.tsx index 1d47b6e..22c705e 100644 --- a/apps/web/src/components/timeline/TimelineProjectPanel.tsx +++ b/apps/web/src/components/timeline/TimelineProjectPanel.tsx @@ -3,7 +3,6 @@ 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 { useTimelineData, useTimelineView, @@ -12,14 +11,16 @@ import { 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 { + renderOpenDemandRow, + renderProjectUtilOverlay, + renderProjectDragHandles, +} from "./timelineProjectRenderers.js"; import { TimelineTooltip, type DemandHoverData, @@ -28,7 +29,6 @@ import { } from "./TimelineTooltip.js"; import { ROW_HEIGHT, - SUB_LANE_HEIGHT, LABEL_WIDTH, PROJECT_HEADER_HEIGHT, ORDER_TYPE_COLORS, @@ -46,7 +46,6 @@ import { renderVacationBlocks, renderRangeOverlay, renderOverbookingBlink, - type VacationBlockInfo, } from "./renderHelpers.js"; import { buildDemandHoverData, @@ -58,12 +57,7 @@ import { 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"; +import { buildProjectFlatRows, estimateProjectRowHeight } from "./timelineProjectRows.js"; // ─── Props ────────────────────────────────────────────────────────────────── @@ -122,7 +116,6 @@ export interface OpenDemandAssignment { type HeatmapHoverState = HeatmapHoverData; const EMPTY_DAY_METRICS: ProjectDayMetric[] = []; -const SVG_XMLNS = "http://www.w3.org/2000/svg"; // ─── Component ────────────────────────────────────────────────────────────── @@ -687,544 +680,4 @@ function TimelineProjectPanelInner({ ); } -// 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 selectedAllocationSet = new Set(multiSelectState.selectedAllocationIds); - 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 && selectedAllocationSet.has(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, -) { - const selectedAllocationSet = new Set(multiSelectState.selectedAllocationIds); - 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 && selectedAllocationSet.has(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: getPlanningEntryMutationId(alloc), - 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); diff --git a/apps/web/src/components/timeline/timelineProjectRenderers.tsx b/apps/web/src/components/timeline/timelineProjectRenderers.tsx new file mode 100644 index 0000000..646722a --- /dev/null +++ b/apps/web/src/components/timeline/timelineProjectRenderers.tsx @@ -0,0 +1,550 @@ +import { clsx } from "clsx"; +import type { TimelineAssignmentEntry, TimelineDemandEntry } from "./TimelineContext.js"; +import { applyPointerOffsetPreviewRect, getDragPointerOffset } from "./allocationVisualState.js"; +import { heatmapColor } from "./heatmapUtils.js"; +import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js"; +import { ROW_HEIGHT, SUB_LANE_HEIGHT, LABEL_WIDTH } from "./timelineConstants.js"; +import type { AllocDragState, MultiSelectState } from "~/hooks/useTimelineDrag.js"; +import type { AllocMouseDownInfo } from "./TimelineResourcePanel.js"; +import type { OpenDemandRowLayout } from "./timelineProjectRows.js"; +import type { ProjectDayMetric } from "./timelineProjectMetrics.js"; + +const SVG_XMLNS = "http://www.w3.org/2000/svg"; + +// ─── Open-demand row ─────────────────────────────────────────────────────── + +export 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 selectedAllocationSet = new Set(multiSelectState.selectedAllocationIds); + 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 && selectedAllocationSet.has(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 ──────────────────────────── + +export 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 ───────────────────────────────── + +export 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, +) { + const selectedAllocationSet = new Set(multiSelectState.selectedAllocationIds); + 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 && selectedAllocationSet.has(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: getPlanningEntryMutationId(alloc), + 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" }); + }} + /> +
+ ); + }); +}