Files
CapaKraken/apps/web/src/components/timeline/TimelineProjectPanel.tsx
T

1205 lines
45 KiB
TypeScript

"use client";
import { clsx } from "clsx";
import { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import type { CSSProperties } from "react";
import {
useTimelineContext,
type TimelineAssignmentEntry,
type TimelineDemandEntry,
} from "./TimelineContext.js";
import {
applyPointerOffsetPreviewRect,
applyVisualOverrides,
getDragPointerOffset,
type TimelineVisualOverrides,
} from "./allocationVisualState.js";
import { heatmapColor } from "./heatmapUtils.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import { formatDateLong } from "~/lib/format.js";
import {
TimelineTooltip,
type DemandHoverData,
type HeatmapHoverData,
type VacationHoverData,
} from "./TimelineTooltip.js";
import {
ROW_HEIGHT,
SUB_LANE_HEIGHT,
LABEL_WIDTH,
PROJECT_HEADER_HEIGHT,
ORDER_TYPE_COLORS,
} from "./timelineConstants.js";
import { getProjectColor } from "~/lib/project-colors.js";
import type { DragState, AllocDragState, RangeState, MultiSelectState } from "~/hooks/useTimelineDrag.js";
import type { AllocMouseDownInfo, RowMouseDownInfo } from "./TimelineResourcePanel.js";
import {
buildVacationBlocksByResource,
renderVacationBlocks,
renderRangeOverlay,
renderOverbookingBlink,
type VacationBlockInfo,
} from "./renderHelpers.js";
import {
buildDemandHoverData,
cancelHoverFrame,
collectResourcesWithVacations,
scheduleVacationHoverUpdate,
updateTooltipPosition,
} from "./timelineHover.js";
import { buildResourceHeatmapSeries } from "./timelineHeatmap.js";
import { buildResourceCapacitySeries } from "./timelineCapacity.js";
import {
buildProjectRowMetrics,
type ProjectDayMetric,
} from "./timelineProjectMetrics.js";
import {
buildProjectFlatRows,
estimateProjectRowHeight,
type OpenDemandRowLayout,
type ProjectFlatRow,
} from "./timelineProjectRows.js";
// ─── Props ──────────────────────────────────────────────────────────────────
interface TimelineProjectPanelProps {
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
dragState: DragState;
allocDragState: AllocDragState;
rangeState: RangeState;
onProjectBarMouseDown: (e: React.MouseEvent, info: ProjectBarInfo) => void;
onProjectBarTouchStart: (e: React.TouchEvent, info: ProjectBarInfo) => void;
onAllocMouseDown: (e: React.MouseEvent, info: AllocMouseDownInfo) => void;
onAllocTouchStart: (e: React.TouchEvent, info: AllocMouseDownInfo) => void;
onRowMouseDown: (e: React.MouseEvent, info: RowMouseDownInfo) => void;
onRowTouchStart: (e: React.TouchEvent, info: RowMouseDownInfo) => void;
onOpenPanel: (projectId: string) => void;
onOpenDemandClick: (demand: TimelineDemandEntry, anchorX: number, anchorY: number) => void;
onAllocationContextMenu: (
info: { allocationId: string; projectId: string; contextDate?: Date },
anchorX: number,
anchorY: number,
) => void;
multiSelectState: MultiSelectState;
optimisticAllocations: TimelineVisualOverrides;
suppressHoverInteractions: boolean;
// Layout from useTimelineLayout
CELL_WIDTH: number;
dates: Date[];
totalCanvasWidth: number;
toLeft: (date: Date) => number;
toWidth: (start: Date, end: Date) => number;
gridLines: React.ReactNode;
xToDate: (clientX: number, rect: DOMRect) => Date;
}
export interface ProjectBarInfo {
projectId: string;
projectName: string;
startDate: Date;
endDate: Date;
}
export interface OpenDemandAssignment {
id: string;
projectId: string;
roleId: string | null;
role: string | null;
headcount: number;
budgetCents?: number;
startDate: Date;
endDate: Date;
hoursPerDay: number;
roleEntity?: { id: string; name: string; color: string | null } | null;
project?: { id: string; name: string; shortCode: string };
}
type HeatmapHoverState = HeatmapHoverData;
const EMPTY_DAY_METRICS: ProjectDayMetric[] = [];
const SVG_XMLNS = "http://www.w3.org/2000/svg";
// ─── Component ──────────────────────────────────────────────────────────────
function TimelineProjectPanelInner({
scrollContainerRef,
dragState,
allocDragState,
rangeState,
onProjectBarMouseDown,
onProjectBarTouchStart,
onAllocMouseDown,
onAllocTouchStart,
onRowMouseDown,
onRowTouchStart,
onOpenPanel,
onOpenDemandClick,
onAllocationContextMenu,
multiSelectState,
optimisticAllocations,
suppressHoverInteractions,
CELL_WIDTH,
dates,
totalCanvasWidth,
toLeft,
toWidth,
gridLines,
xToDate,
}: TimelineProjectPanelProps) {
const {
projectGroups,
openDemandsByProject,
allocsByResource,
vacationsByResource,
filters,
displayMode,
heatmapScheme,
blinkOverbookedDays,
activeFilterCount,
today,
} = useTimelineContext();
const visualAllocsByResource = useMemo(() => {
if (optimisticAllocations.size === 0) return allocsByResource;
const next = new Map<string, TimelineAssignmentEntry[]>();
for (const [resourceId, allocs] of allocsByResource) {
next.set(resourceId, applyVisualOverrides(allocs, optimisticAllocations));
}
return next;
}, [allocsByResource, optimisticAllocations]);
const visualProjectGroups = useMemo(
() => projectGroups.map((project) => ({
...project,
resourceRows: project.resourceRows.map((row) => ({
...row,
allocs: applyVisualOverrides(row.allocs, optimisticAllocations),
})),
})),
[projectGroups, optimisticAllocations],
);
// ─── Heatmap hover (same mechanism as resource panel) ─────────────────────
const heatmapRafRef = useRef<number | null>(null);
const lastHeatmapDayRef = useRef<number>(-1);
const lastHeatmapResourceRef = useRef<string | null>(null);
const vacationHoverRafRef = useRef<number | null>(null);
const hoveredVacationKeyRef = useRef<string | null>(null);
const pendingHeatmapRef = useRef<{
clientX: number;
rect: DOMRect;
resourceId: string;
} | null>(null);
const heatmapTooltipRef = useRef<HTMLDivElement | null>(null);
const vacationTooltipRef = useRef<HTMLDivElement | null>(null);
const demandTooltipRef = useRef<HTMLDivElement | null>(null);
const heatmapTooltipPosRef = useRef({ left: 0, top: 0 });
const vacationTooltipPosRef = useRef({ left: 0, top: 0 });
const demandTooltipPosRef = useRef({ left: 0, top: 0 });
const [heatmapHover, setHeatmapHover] = useState<HeatmapHoverState | null>(null);
const [vacationHover, setVacationHover] = useState<VacationHoverData | null>(null);
const [demandHover, setDemandHover] = useState<DemandHoverData | null>(null);
const resourceCapacityById = useMemo(
() => buildResourceCapacitySeries(visualAllocsByResource, vacationsByResource, dates),
[dates, vacationsByResource, visualAllocsByResource],
);
const { resourceHeatmapById, resourceTotalHoursById } = useMemo(
() => buildResourceHeatmapSeries(visualAllocsByResource, dates, resourceCapacityById),
[dates, resourceCapacityById, visualAllocsByResource],
);
const vacationBlocksByResource = useMemo(
() =>
buildVacationBlocksByResource(
vacationsByResource,
filters.showVacations,
toLeft,
toWidth,
CELL_WIDTH,
totalCanvasWidth,
),
[CELL_WIDTH, filters.showVacations, toLeft, toWidth, totalCanvasWidth, vacationsByResource],
);
const projectRowMetrics = useMemo(() => {
return buildProjectRowMetrics(
dates,
visualProjectGroups,
resourceTotalHoursById,
resourceCapacityById,
);
}, [dates, resourceCapacityById, resourceTotalHoursById, visualProjectGroups]);
const flatRows = useMemo(
() => buildProjectFlatRows(visualProjectGroups, openDemandsByProject, optimisticAllocations),
[openDemandsByProject, optimisticAllocations, visualProjectGroups],
);
const rowVirtualizer = useVirtualizer({
count: flatRows.length,
getScrollElement: () => scrollContainerRef.current,
estimateSize: (index) => estimateProjectRowHeight(flatRows[index]),
overscan: 8,
getItemKey: (index) => flatRows[index]?.key ?? index,
});
const virtualItems = rowVirtualizer.getVirtualItems();
const totalRowHeight = rowVirtualizer.getTotalSize();
const resourcesWithVacations = useMemo(
() => collectResourcesWithVacations(vacationsByResource),
[vacationsByResource],
);
const handleRowHeatmapMove = useCallback(
(e: React.MouseEvent, resourceId: string) => {
updateTooltipPosition(heatmapTooltipPosRef, heatmapTooltipRef, e.clientX, e.clientY, 16, -52);
const rect = e.currentTarget.getBoundingClientRect();
const dayIndex = Math.floor((e.clientX - rect.left) / CELL_WIDTH);
if (
dayIndex === lastHeatmapDayRef.current &&
resourceId === lastHeatmapResourceRef.current
)
return;
pendingHeatmapRef.current = { clientX: e.clientX, rect, resourceId };
if (heatmapRafRef.current !== null) return;
heatmapRafRef.current = requestAnimationFrame(() => {
heatmapRafRef.current = null;
const pending = pendingHeatmapRef.current;
pendingHeatmapRef.current = null;
if (!pending) return;
const { clientX, rect: pendingRect, resourceId: pendingResourceId } = pending;
const nextDayIndex = Math.floor((clientX - pendingRect.left) / CELL_WIDTH);
const nextHeatmap = resourceHeatmapById.get(pendingResourceId)?.[nextDayIndex] ?? null;
if (nextDayIndex < 0 || nextDayIndex >= dates.length) {
lastHeatmapDayRef.current = -1;
lastHeatmapResourceRef.current = null;
startTransition(() => setHeatmapHover(null));
return;
}
lastHeatmapDayRef.current = nextDayIndex;
lastHeatmapResourceRef.current = pendingResourceId;
startTransition(() => {
setHeatmapHover(nextHeatmap);
});
});
},
[CELL_WIDTH, dates.length, resourceHeatmapById],
);
const handleRowVacationHover = useCallback(
(e: React.MouseEvent, resourceId: string) => {
if (!resourcesWithVacations.has(resourceId)) {
if (hoveredVacationKeyRef.current !== null) {
hoveredVacationKeyRef.current = null;
startTransition(() => {
setVacationHover(null);
});
}
return;
}
updateTooltipPosition(vacationTooltipPosRef, vacationTooltipRef, e.clientX, e.clientY, 14, -8);
scheduleVacationHoverUpdate({
frameRef: vacationHoverRafRef,
hoveredKeyRef: hoveredVacationKeyRef,
resourceId,
clientX: e.clientX,
rect: e.currentTarget.getBoundingClientRect(),
xToDate,
vacations: vacationsByResource.get(resourceId) ?? [],
onHoverChange: (hit) => {
startTransition(() => {
setVacationHover(hit);
});
},
});
},
[resourcesWithVacations, vacationsByResource, xToDate],
);
const clearHoverTooltips = useCallback(() => {
cancelHoverFrame(heatmapRafRef);
cancelHoverFrame(vacationHoverRafRef);
const shouldClearHeatmap = lastHeatmapDayRef.current !== -1;
const shouldClearVacation = hoveredVacationKeyRef.current !== null;
const shouldClearDemand = demandHover !== null;
lastHeatmapDayRef.current = -1;
lastHeatmapResourceRef.current = null;
hoveredVacationKeyRef.current = null;
if (shouldClearHeatmap || shouldClearVacation || shouldClearDemand) {
startTransition(() => {
if (shouldClearHeatmap) setHeatmapHover(null);
if (shouldClearVacation) setVacationHover(null);
if (shouldClearDemand) setDemandHover(null);
});
}
}, [demandHover]);
const handleDemandHoverMove = useCallback(
(e: React.MouseEvent, demand: TimelineDemandEntry) => {
updateTooltipPosition(demandTooltipPosRef, demandTooltipRef, e.clientX, e.clientY, 16, -36);
startTransition(() => {
setDemandHover(buildDemandHoverData(demand));
});
},
[],
);
useEffect(
() => () => {
cancelHoverFrame(heatmapRafRef);
cancelHoverFrame(vacationHoverRafRef);
},
[],
);
useEffect(() => {
if (!suppressHoverInteractions) return;
clearHoverTooltips();
}, [clearHoverTooltips, suppressHoverInteractions]);
if (visualProjectGroups.length === 0) {
return (
<div className="text-center py-16 text-gray-400">
No projects in this time range{activeFilterCount > 0 && " (filtered)"}.
</div>
);
}
return (
<div
style={{
height: totalRowHeight,
position: "relative",
}}
>
{virtualItems.map((virtualRow) => {
const row = flatRows[virtualRow.index];
if (!row) return null;
return (
<div
key={row.key}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualRow.start}px)`,
}}
>
{row.type === "header" ? (
(() => {
const { project } = row;
const customColor = project.color;
const projectColor = getProjectColor(project.id);
const colors = ORDER_TYPE_COLORS[project.orderType] ?? {
bg: "bg-gray-400",
text: "text-white",
light: "bg-gray-50 border-gray-200 dark:bg-gray-800 dark:border-gray-700",
};
const isThisProjectShifting =
dragState.isDragging && dragState.projectId === project.id;
const projDispStart =
isThisProjectShifting && dragState.currentStartDate
? dragState.currentStartDate
: project.startDate;
const projDispEnd =
isThisProjectShifting && dragState.currentEndDate
? dragState.currentEndDate
: project.endDate;
const projLeft = toLeft(projDispStart);
const projWidth = Math.max(CELL_WIDTH, toWidth(projDispStart, projDispEnd));
return (
<div
data-project-group="true"
className={clsx("flex border-b border-gray-200 dark:border-gray-700 group/proj", colors.light)}
style={{ height: PROJECT_HEADER_HEIGHT, borderLeft: `4px solid ${customColor ?? projectColor.hex}` }}
>
<div
className={clsx(
"flex-shrink-0 border-r border-gray-300 dark:border-gray-600 flex items-center px-4 gap-2.5 sticky left-0 z-30 cursor-pointer",
colors.light,
)}
style={{ width: LABEL_WIDTH }}
onClick={() => onOpenPanel(project.id)}
>
<div
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: customColor ?? projectColor.hex }}
/>
<div className="min-w-0">
<div className="text-sm font-semibold text-gray-800 dark:text-gray-100 truncate">
{project.name}
</div>
<div className="text-xs text-gray-400 dark:text-gray-500">
{project.status}
</div>
</div>
</div>
<div
className="relative overflow-hidden"
style={{ width: totalCanvasWidth, height: PROJECT_HEADER_HEIGHT }}
>
{gridLines}
{projWidth > 0 && projLeft < totalCanvasWidth && (
<div
data-timeline-entry-type="project-bar"
data-timeline-drag-preview="project-shift"
data-timeline-project-id={project.id}
className={clsx(
"absolute rounded flex items-center px-2 gap-1.5 text-white",
isThisProjectShifting
? "opacity-90 shadow-lg ring-2 ring-white ring-offset-1 cursor-grabbing z-20 scale-[1.01]"
: "cursor-grab transition-[opacity,box-shadow] duration-75 hover:opacity-90 hover:ring-2 hover:ring-white hover:ring-offset-1",
)}
style={{
left: projLeft + 2,
width: projWidth - 4,
top: 8,
height: 24,
backgroundColor: customColor ?? projectColor.hex + "CC",
...(isThisProjectShifting
? {
transform: `translateX(${getDragPointerOffset(
dragState.pointerDeltaX,
dragState.daysDelta,
CELL_WIDTH,
)}px)`,
}
: {}),
}}
onClick={() => {
if (!dragState.isDragging) onOpenPanel(project.id);
}}
onMouseDown={(e) => {
if (e.button === 2) {
e.preventDefault();
e.stopPropagation();
if (!dragState.isDragging) {
onOpenPanel(project.id);
}
return;
}
onProjectBarMouseDown(e, {
projectId: project.id,
projectName: project.name,
startDate: project.startDate,
endDate: project.endDate,
});
}}
onTouchStart={(e) =>
onProjectBarTouchStart(e, {
projectId: project.id,
projectName: project.name,
startDate: project.startDate,
endDate: project.endDate,
})
}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
if (!dragState.isDragging) {
onOpenPanel(project.id);
}
}}
>
<span className="text-xs font-semibold truncate">{project.name}</span>
</div>
)}
</div>
</div>
);
})()
) : row.type === "open-demand" ? (
renderOpenDemandRow(
row.openDemandCount,
row.layout,
row.projectId,
CELL_WIDTH,
totalCanvasWidth,
toLeft,
toWidth,
gridLines,
onOpenDemandClick,
onAllocMouseDown,
onAllocTouchStart,
onAllocationContextMenu,
handleDemandHoverMove,
clearHoverTooltips,
multiSelectState,
allocDragState,
suppressHoverInteractions,
)
) : (
<div
data-project-resource-row="true"
data-project-id={row.project.id}
data-resource-id={row.resource.id}
className="flex border-b border-gray-100 dark:border-gray-800 hover:bg-blue-50/20 dark:hover:bg-gray-800/30 group"
style={{ height: ROW_HEIGHT }}
>
<div
className="flex-shrink-0 border-r border-gray-200 dark:border-gray-700 flex items-center pl-8 pr-4 gap-2 bg-white dark:bg-gray-900 sticky left-0 z-30 group-hover:bg-blue-50 dark:group-hover:bg-gray-800"
style={{ width: LABEL_WIDTH }}
>
<div className="w-6 h-6 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center text-[10px] font-bold text-gray-600 dark:text-gray-300 flex-shrink-0">
{row.resource.displayName.slice(0, 2).toUpperCase()}
</div>
<div className="min-w-0" data-resource-hover-id={row.resource.id}>
<div className="text-xs font-medium text-gray-800 dark:text-gray-200 truncate cursor-pointer hover:text-brand-600 dark:hover:text-brand-400 transition-colors">
{row.resource.displayName}
</div>
<div className="text-[10px] text-gray-400 dark:text-gray-500 truncate">{row.resource.eid}</div>
</div>
</div>
<div
data-testid="timeline-project-resource-row-canvas"
data-project-id={row.project.id}
data-resource-id={row.resource.id}
className="relative overflow-hidden touch-none"
style={{
width: totalCanvasWidth,
height: ROW_HEIGHT,
touchAction: "none",
}}
onMouseDown={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const date = xToDate(e.clientX, rect);
onRowMouseDown(e, {
resourceId: row.resource.id,
startDate: date,
suggestedProjectId: row.project.id,
});
}}
onTouchStart={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const date = xToDate(e.touches[0]?.clientX ?? 0, rect);
onRowTouchStart(e, {
resourceId: row.resource.id,
startDate: date,
suggestedProjectId: row.project.id,
});
}}
onMouseMove={(e) => {
if (suppressHoverInteractions) return;
handleRowHeatmapMove(e, row.resource.id);
handleRowVacationHover(e, row.resource.id);
}}
onMouseLeave={clearHoverTooltips}
>
{gridLines}
{renderProjectUtilOverlay(
projectRowMetrics.get(row.metricsKey) ?? EMPTY_DAY_METRICS,
CELL_WIDTH,
displayMode,
heatmapScheme,
totalCanvasWidth,
)}
{renderProjectDragHandles(
row.allocs,
allocDragState,
toLeft,
toWidth,
CELL_WIDTH,
totalCanvasWidth,
onAllocMouseDown,
onAllocTouchStart,
onAllocationContextMenu,
multiSelectState,
suppressHoverInteractions,
)}
{filters.showVacations &&
renderVacationBlocks(
vacationBlocksByResource.get(row.resource.id) ?? [],
ROW_HEIGHT,
)}
{blinkOverbookedDays &&
renderOverbookingBlink(
visualAllocsByResource.get(row.resource.id) ?? [],
dates,
CELL_WIDTH,
resourceCapacityById.get(row.resource.id)?.capacityHoursByDay,
resourceCapacityById.get(row.resource.id)?.bookingFactorsByDay,
)}
{renderRangeOverlay(
rangeState,
row.resource.id,
ROW_HEIGHT,
toLeft,
toWidth,
CELL_WIDTH,
)}
</div>
</div>
)}
</div>
);
})}
<TimelineTooltip
heatmapTooltipRef={heatmapTooltipRef}
heatmapTooltipPos={heatmapTooltipPosRef.current}
vacationTooltipRef={vacationTooltipRef}
vacationTooltipPos={vacationTooltipPosRef.current}
demandTooltipRef={demandTooltipRef}
demandTooltipPos={demandTooltipPosRef.current}
heatmapHover={heatmapHover}
vacationHover={vacationHover}
demandHover={demandHover}
/>
</div>
);
}
// ProjectPanelTooltips removed — now uses shared TimelineTooltip component
// ─── Pure render functions ──────────────────────────────────────────────────
function renderOpenDemandRow(
openDemandCount: number,
layout: OpenDemandRowLayout,
projectId: string,
CELL_WIDTH: number,
totalCanvasWidth: number,
toLeft: (d: Date) => number,
toWidth: (s: Date, e: Date) => number,
rowGridLines: React.ReactNode,
onOpenDemandClick: (demand: TimelineDemandEntry, anchorX: number, anchorY: number) => void,
onAllocMouseDown: (e: React.MouseEvent, info: AllocMouseDownInfo) => void,
onAllocTouchStart: (e: React.TouchEvent, info: AllocMouseDownInfo) => void,
onAllocationContextMenu: (
info: { allocationId: string; projectId: string; contextDate?: Date },
anchorX: number,
anchorY: number,
) => void,
onDemandHoverMove: (e: React.MouseEvent, demand: TimelineDemandEntry) => void,
onClearHoverTooltips: () => void,
multiSelectState: MultiSelectState,
allocDragState: AllocDragState,
suppressHoverInteractions: boolean,
) {
const { visibleOpenDemands, laneMap, rowHeight } = layout;
if (visibleOpenDemands.length === 0) return null;
return (
<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-slate-950 hover:bg-amber-100/80 dark:hover:bg-slate-900"
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-slate-950"
style={{ width: LABEL_WIDTH, height: rowHeight }}
>
<div className="pointer-events-none absolute inset-0 bg-amber-50 dark:bg-slate-950" />
<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-slate-950"
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 &&
multiSelectState.selectedAllocationIds.includes(alloc.id);
const multiDragPx = isMultiDragTarget ? multiSelectState.multiDragDaysDelta * CELL_WIDTH : 0;
const multiDragMode = multiSelectState.multiDragMode;
let left = toLeft(dispStart);
let width = Math.max(CELL_WIDTH, toWidth(dispStart, dispEnd));
let dragTransform: string | undefined;
if (isAllocDragged) {
const preview = applyPointerOffsetPreviewRect({
left,
width,
mode: allocDragState.mode,
pointerOffsetX: getDragPointerOffset(
allocDragState.pointerDeltaX,
allocDragState.daysDelta,
CELL_WIDTH,
),
minWidth: CELL_WIDTH,
});
left = preview.left;
width = preview.width;
dragTransform = preview.transform;
}
// Clamp negative left (bar starts before view) to avoid extending outside canvas.
if (left < 0) {
width += left;
left = 0;
}
if (width <= 0 || left >= totalCanvasWidth) return null;
if (isMultiDragTarget && multiDragMode === "resize-start") {
left += multiDragPx;
width = Math.max(CELL_WIDTH, width - multiDragPx);
} else if (isMultiDragTarget && multiDragMode === "resize-end") {
width = Math.max(CELL_WIDTH, width + multiDragPx);
}
const roleEntity = (
alloc as { roleEntity?: { id: string; name: string; color: string | null } | null }
).roleEntity;
const roleName =
roleEntity?.name ?? (alloc as { role?: string | null }).role ?? "Open demand";
const roleColor = roleEntity?.color ?? "#f59e0b";
const headcount = (alloc as { headcount?: number }).headcount ?? 1;
const lane = laneMap.get(alloc.id) ?? 0;
const top = 8 + lane * SUB_LANE_HEIGHT;
const blockHeight = SUB_LANE_HEIGHT - 8;
const HANDLE_W = width >= 48 ? 8 : 6;
const allocInfo: AllocMouseDownInfo = {
mode: "move",
allocationId: alloc.id,
mutationAllocationId: getPlanningEntryMutationId(alloc),
projectId: alloc.projectId,
projectName: alloc.project.name,
resourceId: null,
startDate: allocStart,
endDate: allocEnd,
};
return (
<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",
multiSelectState.selectedAllocationIds.includes(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,
) {
return allocs.map((alloc) => {
const allocStart = new Date(alloc.startDate);
const allocEnd = new Date(alloc.endDate);
const isAllocDragged = allocDragState.isActive && allocDragState.allocationId === alloc.id;
const dispStart =
isAllocDragged && allocDragState.currentStartDate
? allocDragState.currentStartDate
: allocStart;
const dispEnd =
isAllocDragged && allocDragState.currentEndDate ? allocDragState.currentEndDate : allocEnd;
let left = toLeft(dispStart);
let width = Math.max(CELL_WIDTH, toWidth(dispStart, dispEnd));
let dragTransform: string | undefined;
if (isAllocDragged) {
const preview = applyPointerOffsetPreviewRect({
left,
width,
mode: allocDragState.mode,
pointerOffsetX: getDragPointerOffset(
allocDragState.pointerDeltaX,
allocDragState.daysDelta,
CELL_WIDTH,
),
minWidth: CELL_WIDTH,
});
left = preview.left;
width = preview.width;
dragTransform = preview.transform;
}
if (width <= 0 || left >= totalCanvasWidth) return null;
// Multi-drag visual offset
const isMultiDragTarget =
multiSelectState.isMultiDragging &&
multiSelectState.selectedAllocationIds.includes(alloc.id);
const multiDragPx = isMultiDragTarget ? multiSelectState.multiDragDaysDelta * CELL_WIDTH : 0;
const multiDragMode = multiSelectState.multiDragMode;
if (isMultiDragTarget && multiDragMode === "resize-start") {
left += multiDragPx;
width = Math.max(CELL_WIDTH, width - multiDragPx);
} else if (isMultiDragTarget && multiDragMode === "resize-end") {
width = Math.max(CELL_WIDTH, width + multiDragPx);
}
// Always show resize handles — for narrow bars, use overlapping handles
const HANDLE_W = width >= 48 ? 8 : 6;
const hasRecurrence = !!(alloc.metadata as Record<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]",
multiSelectState.selectedAllocationIds.includes(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: alloc.id, 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);