refactor(web): extract render functions from TimelineProjectPanel into dedicated module
Move renderOpenDemandRow, renderProjectUtilOverlay, and renderProjectDragHandles (534 lines) to timelineProjectRenderers.tsx. TimelineProjectPanel: 1230 -> 687 lines. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,6 @@
|
|||||||
import { clsx } from "clsx";
|
import { clsx } from "clsx";
|
||||||
import { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import type { CSSProperties } from "react";
|
|
||||||
import {
|
import {
|
||||||
useTimelineData,
|
useTimelineData,
|
||||||
useTimelineView,
|
useTimelineView,
|
||||||
@@ -12,14 +11,16 @@ import {
|
|||||||
type TimelineDemandEntry,
|
type TimelineDemandEntry,
|
||||||
} from "./TimelineContext.js";
|
} from "./TimelineContext.js";
|
||||||
import {
|
import {
|
||||||
applyPointerOffsetPreviewRect,
|
|
||||||
applyVisualOverrides,
|
applyVisualOverrides,
|
||||||
getDragPointerOffset,
|
getDragPointerOffset,
|
||||||
type TimelineVisualOverrides,
|
type TimelineVisualOverrides,
|
||||||
} from "./allocationVisualState.js";
|
} from "./allocationVisualState.js";
|
||||||
import { heatmapColor } from "./heatmapUtils.js";
|
|
||||||
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
|
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
|
||||||
import { formatDateLong } from "~/lib/format.js";
|
import {
|
||||||
|
renderOpenDemandRow,
|
||||||
|
renderProjectUtilOverlay,
|
||||||
|
renderProjectDragHandles,
|
||||||
|
} from "./timelineProjectRenderers.js";
|
||||||
import {
|
import {
|
||||||
TimelineTooltip,
|
TimelineTooltip,
|
||||||
type DemandHoverData,
|
type DemandHoverData,
|
||||||
@@ -28,7 +29,6 @@ import {
|
|||||||
} from "./TimelineTooltip.js";
|
} from "./TimelineTooltip.js";
|
||||||
import {
|
import {
|
||||||
ROW_HEIGHT,
|
ROW_HEIGHT,
|
||||||
SUB_LANE_HEIGHT,
|
|
||||||
LABEL_WIDTH,
|
LABEL_WIDTH,
|
||||||
PROJECT_HEADER_HEIGHT,
|
PROJECT_HEADER_HEIGHT,
|
||||||
ORDER_TYPE_COLORS,
|
ORDER_TYPE_COLORS,
|
||||||
@@ -46,7 +46,6 @@ import {
|
|||||||
renderVacationBlocks,
|
renderVacationBlocks,
|
||||||
renderRangeOverlay,
|
renderRangeOverlay,
|
||||||
renderOverbookingBlink,
|
renderOverbookingBlink,
|
||||||
type VacationBlockInfo,
|
|
||||||
} from "./renderHelpers.js";
|
} from "./renderHelpers.js";
|
||||||
import {
|
import {
|
||||||
buildDemandHoverData,
|
buildDemandHoverData,
|
||||||
@@ -58,12 +57,7 @@ import {
|
|||||||
import { buildResourceHeatmapSeries } from "./timelineHeatmap.js";
|
import { buildResourceHeatmapSeries } from "./timelineHeatmap.js";
|
||||||
import { buildResourceCapacitySeries } from "./timelineCapacity.js";
|
import { buildResourceCapacitySeries } from "./timelineCapacity.js";
|
||||||
import { buildProjectRowMetrics, type ProjectDayMetric } from "./timelineProjectMetrics.js";
|
import { buildProjectRowMetrics, type ProjectDayMetric } from "./timelineProjectMetrics.js";
|
||||||
import {
|
import { buildProjectFlatRows, estimateProjectRowHeight } from "./timelineProjectRows.js";
|
||||||
buildProjectFlatRows,
|
|
||||||
estimateProjectRowHeight,
|
|
||||||
type OpenDemandRowLayout,
|
|
||||||
type ProjectFlatRow,
|
|
||||||
} from "./timelineProjectRows.js";
|
|
||||||
|
|
||||||
// ─── Props ──────────────────────────────────────────────────────────────────
|
// ─── Props ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -122,7 +116,6 @@ export interface OpenDemandAssignment {
|
|||||||
type HeatmapHoverState = HeatmapHoverData;
|
type HeatmapHoverState = HeatmapHoverData;
|
||||||
|
|
||||||
const EMPTY_DAY_METRICS: ProjectDayMetric[] = [];
|
const EMPTY_DAY_METRICS: ProjectDayMetric[] = [];
|
||||||
const SVG_XMLNS = "http://www.w3.org/2000/svg";
|
|
||||||
|
|
||||||
// ─── Component ──────────────────────────────────────────────────────────────
|
// ─── 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 (
|
|
||||||
<div
|
|
||||||
data-project-demand-row="true"
|
|
||||||
data-project-id={projectId}
|
|
||||||
className="group relative isolate flex border-b border-dashed border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-[rgb(var(--surface-card))] hover:bg-amber-100/80 dark:hover:bg-[rgb(var(--surface-elevated))]"
|
|
||||||
style={{ height: rowHeight }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="sticky left-0 z-30 flex flex-shrink-0 items-center gap-2 border-r border-amber-200 bg-amber-50 pl-8 pr-4 dark:border-amber-800 dark:bg-[rgb(var(--surface-card))]"
|
|
||||||
style={{ width: LABEL_WIDTH, height: rowHeight }}
|
|
||||||
>
|
|
||||||
<div className="pointer-events-none absolute inset-0 bg-amber-50 dark:bg-[rgb(var(--surface-card))]" />
|
|
||||||
<div className="relative z-10 flex items-center gap-2 min-w-0">
|
|
||||||
<div className="w-6 h-6 rounded-full bg-amber-100 dark:bg-amber-900/50 flex items-center justify-center text-[10px] font-bold text-amber-600 dark:text-amber-400 flex-shrink-0 border border-dashed border-amber-400 dark:border-amber-600">
|
|
||||||
?
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="text-xs font-medium text-amber-700 dark:text-amber-400 truncate">
|
|
||||||
Open demand
|
|
||||||
</div>
|
|
||||||
<div className="text-[10px] text-amber-500 dark:text-amber-600 truncate">
|
|
||||||
{openDemandCount} open demand{openDemandCount > 1 ? "s" : ""}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="relative overflow-hidden bg-amber-50 touch-none dark:bg-[rgb(var(--surface-card))]"
|
|
||||||
style={{ width: totalCanvasWidth, height: rowHeight }}
|
|
||||||
onMouseLeave={onClearHoverTooltips}
|
|
||||||
>
|
|
||||||
{rowGridLines}
|
|
||||||
<div className="pointer-events-none absolute inset-x-0 inset-y-0 border-y border-dashed border-amber-200/70 dark:border-amber-800/80" />
|
|
||||||
<div className="pointer-events-none absolute inset-x-0 inset-y-1 rounded-md bg-amber-100/25 dark:bg-amber-950/35" />
|
|
||||||
{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 (
|
|
||||||
<div
|
|
||||||
key={alloc.id}
|
|
||||||
data-allocation-id={alloc.id}
|
|
||||||
data-timeline-entry-type="demand"
|
|
||||||
data-timeline-drag-preview="project-shift allocation"
|
|
||||||
data-timeline-project-id={alloc.projectId}
|
|
||||||
className={clsx(
|
|
||||||
"absolute rounded-md flex items-stretch overflow-hidden z-[10] group/demand",
|
|
||||||
isAllocDragged
|
|
||||||
? "ring-2 ring-amber-500 z-20"
|
|
||||||
: "hover:ring-2 hover:ring-amber-400 hover:ring-offset-1",
|
|
||||||
selectedAllocationSet.has(alloc.id) && "ring-2 ring-sky-500 ring-offset-1 z-20",
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
left: left + 2,
|
|
||||||
width: width - 4,
|
|
||||||
top,
|
|
||||||
height: blockHeight,
|
|
||||||
backgroundColor: `${roleColor}4D`,
|
|
||||||
border: `2px dashed ${roleColor}B3`,
|
|
||||||
...((multiDragPx && multiDragMode === "move") || dragTransform
|
|
||||||
? {
|
|
||||||
transform: [
|
|
||||||
dragTransform,
|
|
||||||
multiDragPx && multiDragMode === "move"
|
|
||||||
? `translateX(${multiDragPx}px)`
|
|
||||||
: null,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" "),
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
}}
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
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 */}
|
|
||||||
<div
|
|
||||||
className="flex-shrink-0 cursor-ew-resize hover:bg-black/10"
|
|
||||||
style={{ width: HANDLE_W }}
|
|
||||||
onMouseDown={(e) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" })}
|
|
||||||
onTouchStart={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onAllocTouchStart(e, { ...allocInfo, mode: "resize-start" });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Center — move + click */}
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
"flex-1 min-w-0 flex items-center px-1 gap-1",
|
|
||||||
isAllocDragged ? "cursor-grabbing" : "cursor-grab",
|
|
||||||
)}
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onAllocMouseDown(e, allocInfo);
|
|
||||||
}}
|
|
||||||
onTouchStart={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onAllocTouchStart(e, allocInfo);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="text-xs font-medium truncate" style={{ color: roleColor }}>
|
|
||||||
{roleName}
|
|
||||||
{headcount > 1 ? ` x${headcount}` : ""}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right resize handle */}
|
|
||||||
<div
|
|
||||||
className="flex-shrink-0 cursor-ew-resize hover:bg-black/10"
|
|
||||||
style={{ width: HANDLE_W }}
|
|
||||||
onMouseDown={(e) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" })}
|
|
||||||
onTouchStart={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 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[] = [
|
|
||||||
`<svg xmlns="${SVG_XMLNS}" width="${totalCanvasWidth}" height="${ROW_HEIGHT}" viewBox="0 0 ${totalCanvasWidth} ${ROW_HEIGHT}" preserveAspectRatio="none" shape-rendering="crispEdges">`,
|
|
||||||
];
|
|
||||||
|
|
||||||
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(
|
|
||||||
`<rect x="${xBand}" y="6" width="${bandWidth}" height="${BAND_H}" fill="${projColor}" />`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (otherBarH > 0 && barWidth > 0) {
|
|
||||||
svgParts.push(
|
|
||||||
`<rect x="${xBar}" y="${ROW_HEIGHT - 4 - projBarH - otherBarH}" width="${barWidth}" height="${otherBarH}" fill="${totalColor}" />`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (projBarH > 0 && barWidth > 0) {
|
|
||||||
svgParts.push(
|
|
||||||
`<rect x="${xBar}" y="${ROW_HEIGHT - 4 - projBarH}" width="${barWidth}" height="${projBarH}" fill="${projColor}" />`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isOver && totalBarH > 0 && barWidth > 0) {
|
|
||||||
svgParts.push(
|
|
||||||
`<rect x="${xBar}" y="${ROW_HEIGHT - 4 - totalBarH - 3}" width="${barWidth}" height="3" fill="rgb(239,68,68)" />`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
svgParts.push("</svg>");
|
|
||||||
const svgDataUri = `url("data:image/svg+xml;utf8,${encodeURIComponent(svgParts.join(""))}")`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-project-util-bar="true"
|
|
||||||
className="absolute inset-0 pointer-events-none"
|
|
||||||
style={{
|
|
||||||
backgroundImage: svgDataUri,
|
|
||||||
backgroundPosition: "0 0",
|
|
||||||
backgroundRepeat: "no-repeat",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 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<string, unknown> | 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 (
|
|
||||||
<div
|
|
||||||
key={`dh-${alloc.id}`}
|
|
||||||
data-allocation-id={alloc.id}
|
|
||||||
data-timeline-entry-type="allocation"
|
|
||||||
data-timeline-drag-preview="project-shift allocation"
|
|
||||||
data-timeline-project-id={alloc.projectId}
|
|
||||||
className={clsx(
|
|
||||||
"absolute flex items-stretch rounded",
|
|
||||||
hasRecurrence && "border-2 border-dashed border-brand-400/60",
|
|
||||||
isAllocDragged
|
|
||||||
? "ring-2 ring-brand-400 z-20"
|
|
||||||
: "hover:ring-1 hover:ring-brand-300/70 z-[15]",
|
|
||||||
selectedAllocationSet.has(alloc.id) && "ring-2 ring-sky-500 ring-offset-1 z-20",
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
left: left + 2,
|
|
||||||
width: width - 4,
|
|
||||||
top: 2,
|
|
||||||
bottom: 2,
|
|
||||||
...((multiDragPx && multiDragMode === "move") || dragTransform
|
|
||||||
? {
|
|
||||||
transform: [
|
|
||||||
dragTransform,
|
|
||||||
multiDragPx && multiDragMode === "move" ? `translateX(${multiDragPx}px)` : null,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" "),
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
}}
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
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,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="flex-shrink-0 cursor-ew-resize"
|
|
||||||
style={{ width: HANDLE_W }}
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" });
|
|
||||||
}}
|
|
||||||
onTouchStart={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onAllocTouchStart(e, { ...allocInfo, mode: "resize-start" });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
"flex-1 min-w-0 flex items-center",
|
|
||||||
isAllocDragged ? "cursor-grabbing" : "cursor-grab",
|
|
||||||
)}
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onAllocMouseDown(e, allocInfo);
|
|
||||||
}}
|
|
||||||
onTouchStart={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onAllocTouchStart(e, allocInfo);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{hasRecurrence && width > 28 && (
|
|
||||||
<span className="text-[10px] text-brand-600 opacity-70 pointer-events-none pl-1">
|
|
||||||
↻
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="flex-shrink-0 cursor-ew-resize"
|
|
||||||
style={{ width: HANDLE_W }}
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" });
|
|
||||||
}}
|
|
||||||
onTouchStart={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TimelineProjectPanel = memo(TimelineProjectPanelInner);
|
export const TimelineProjectPanel = memo(TimelineProjectPanelInner);
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
data-project-demand-row="true"
|
||||||
|
data-project-id={projectId}
|
||||||
|
className="group relative isolate flex border-b border-dashed border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-[rgb(var(--surface-card))] hover:bg-amber-100/80 dark:hover:bg-[rgb(var(--surface-elevated))]"
|
||||||
|
style={{ height: rowHeight }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="sticky left-0 z-30 flex flex-shrink-0 items-center gap-2 border-r border-amber-200 bg-amber-50 pl-8 pr-4 dark:border-amber-800 dark:bg-[rgb(var(--surface-card))]"
|
||||||
|
style={{ width: LABEL_WIDTH, height: rowHeight }}
|
||||||
|
>
|
||||||
|
<div className="pointer-events-none absolute inset-0 bg-amber-50 dark:bg-[rgb(var(--surface-card))]" />
|
||||||
|
<div className="relative z-10 flex items-center gap-2 min-w-0">
|
||||||
|
<div className="w-6 h-6 rounded-full bg-amber-100 dark:bg-amber-900/50 flex items-center justify-center text-[10px] font-bold text-amber-600 dark:text-amber-400 flex-shrink-0 border border-dashed border-amber-400 dark:border-amber-600">
|
||||||
|
?
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-xs font-medium text-amber-700 dark:text-amber-400 truncate">
|
||||||
|
Open demand
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-amber-500 dark:text-amber-600 truncate">
|
||||||
|
{openDemandCount} open demand{openDemandCount > 1 ? "s" : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="relative overflow-hidden bg-amber-50 touch-none dark:bg-[rgb(var(--surface-card))]"
|
||||||
|
style={{ width: totalCanvasWidth, height: rowHeight }}
|
||||||
|
onMouseLeave={onClearHoverTooltips}
|
||||||
|
>
|
||||||
|
{rowGridLines}
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 inset-y-0 border-y border-dashed border-amber-200/70 dark:border-amber-800/80" />
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 inset-y-1 rounded-md bg-amber-100/25 dark:bg-amber-950/35" />
|
||||||
|
{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 (
|
||||||
|
<div
|
||||||
|
key={alloc.id}
|
||||||
|
data-allocation-id={alloc.id}
|
||||||
|
data-timeline-entry-type="demand"
|
||||||
|
data-timeline-drag-preview="project-shift allocation"
|
||||||
|
data-timeline-project-id={alloc.projectId}
|
||||||
|
className={clsx(
|
||||||
|
"absolute rounded-md flex items-stretch overflow-hidden z-[10] group/demand",
|
||||||
|
isAllocDragged
|
||||||
|
? "ring-2 ring-amber-500 z-20"
|
||||||
|
: "hover:ring-2 hover:ring-amber-400 hover:ring-offset-1",
|
||||||
|
selectedAllocationSet.has(alloc.id) && "ring-2 ring-sky-500 ring-offset-1 z-20",
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
left: left + 2,
|
||||||
|
width: width - 4,
|
||||||
|
top,
|
||||||
|
height: blockHeight,
|
||||||
|
backgroundColor: `${roleColor}4D`,
|
||||||
|
border: `2px dashed ${roleColor}B3`,
|
||||||
|
...((multiDragPx && multiDragMode === "move") || dragTransform
|
||||||
|
? {
|
||||||
|
transform: [
|
||||||
|
dragTransform,
|
||||||
|
multiDragPx && multiDragMode === "move"
|
||||||
|
? `translateX(${multiDragPx}px)`
|
||||||
|
: null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" "),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
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 */}
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 cursor-ew-resize hover:bg-black/10"
|
||||||
|
style={{ width: HANDLE_W }}
|
||||||
|
onMouseDown={(e) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" })}
|
||||||
|
onTouchStart={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAllocTouchStart(e, { ...allocInfo, mode: "resize-start" });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Center — move + click */}
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"flex-1 min-w-0 flex items-center px-1 gap-1",
|
||||||
|
isAllocDragged ? "cursor-grabbing" : "cursor-grab",
|
||||||
|
)}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAllocMouseDown(e, allocInfo);
|
||||||
|
}}
|
||||||
|
onTouchStart={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAllocTouchStart(e, allocInfo);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-xs font-medium truncate" style={{ color: roleColor }}>
|
||||||
|
{roleName}
|
||||||
|
{headcount > 1 ? ` x${headcount}` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right resize handle */}
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 cursor-ew-resize hover:bg-black/10"
|
||||||
|
style={{ width: HANDLE_W }}
|
||||||
|
onMouseDown={(e) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" })}
|
||||||
|
onTouchStart={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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[] = [
|
||||||
|
`<svg xmlns="${SVG_XMLNS}" width="${totalCanvasWidth}" height="${ROW_HEIGHT}" viewBox="0 0 ${totalCanvasWidth} ${ROW_HEIGHT}" preserveAspectRatio="none" shape-rendering="crispEdges">`,
|
||||||
|
];
|
||||||
|
|
||||||
|
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(
|
||||||
|
`<rect x="${xBand}" y="6" width="${bandWidth}" height="${BAND_H}" fill="${projColor}" />`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (otherBarH > 0 && barWidth > 0) {
|
||||||
|
svgParts.push(
|
||||||
|
`<rect x="${xBar}" y="${ROW_HEIGHT - 4 - projBarH - otherBarH}" width="${barWidth}" height="${otherBarH}" fill="${totalColor}" />`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (projBarH > 0 && barWidth > 0) {
|
||||||
|
svgParts.push(
|
||||||
|
`<rect x="${xBar}" y="${ROW_HEIGHT - 4 - projBarH}" width="${barWidth}" height="${projBarH}" fill="${projColor}" />`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOver && totalBarH > 0 && barWidth > 0) {
|
||||||
|
svgParts.push(
|
||||||
|
`<rect x="${xBar}" y="${ROW_HEIGHT - 4 - totalBarH - 3}" width="${barWidth}" height="3" fill="rgb(239,68,68)" />`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
svgParts.push("</svg>");
|
||||||
|
const svgDataUri = `url("data:image/svg+xml;utf8,${encodeURIComponent(svgParts.join(""))}")`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-project-util-bar="true"
|
||||||
|
className="absolute inset-0 pointer-events-none"
|
||||||
|
style={{
|
||||||
|
backgroundImage: svgDataUri,
|
||||||
|
backgroundPosition: "0 0",
|
||||||
|
backgroundRepeat: "no-repeat",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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<string, unknown> | 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 (
|
||||||
|
<div
|
||||||
|
key={`dh-${alloc.id}`}
|
||||||
|
data-allocation-id={alloc.id}
|
||||||
|
data-timeline-entry-type="allocation"
|
||||||
|
data-timeline-drag-preview="project-shift allocation"
|
||||||
|
data-timeline-project-id={alloc.projectId}
|
||||||
|
className={clsx(
|
||||||
|
"absolute flex items-stretch rounded",
|
||||||
|
hasRecurrence && "border-2 border-dashed border-brand-400/60",
|
||||||
|
isAllocDragged
|
||||||
|
? "ring-2 ring-brand-400 z-20"
|
||||||
|
: "hover:ring-1 hover:ring-brand-300/70 z-[15]",
|
||||||
|
selectedAllocationSet.has(alloc.id) && "ring-2 ring-sky-500 ring-offset-1 z-20",
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
left: left + 2,
|
||||||
|
width: width - 4,
|
||||||
|
top: 2,
|
||||||
|
bottom: 2,
|
||||||
|
...((multiDragPx && multiDragMode === "move") || dragTransform
|
||||||
|
? {
|
||||||
|
transform: [
|
||||||
|
dragTransform,
|
||||||
|
multiDragPx && multiDragMode === "move" ? `translateX(${multiDragPx}px)` : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" "),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 cursor-ew-resize"
|
||||||
|
style={{ width: HANDLE_W }}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" });
|
||||||
|
}}
|
||||||
|
onTouchStart={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAllocTouchStart(e, { ...allocInfo, mode: "resize-start" });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"flex-1 min-w-0 flex items-center",
|
||||||
|
isAllocDragged ? "cursor-grabbing" : "cursor-grab",
|
||||||
|
)}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAllocMouseDown(e, allocInfo);
|
||||||
|
}}
|
||||||
|
onTouchStart={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAllocTouchStart(e, allocInfo);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hasRecurrence && width > 28 && (
|
||||||
|
<span className="text-[10px] text-brand-600 opacity-70 pointer-events-none pl-1">
|
||||||
|
↻
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 cursor-ew-resize"
|
||||||
|
style={{ width: HANDLE_W }}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" });
|
||||||
|
}}
|
||||||
|
onTouchStart={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user