1054 lines
39 KiB
TypeScript
1054 lines
39 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 {
|
|
useTimelineContext,
|
|
type TimelineAssignmentEntry,
|
|
} from "./TimelineContext.js";
|
|
import { ConflictOverlay } from "./ConflictOverlay.js";
|
|
import { computeSubLanes } from "./utils.js";
|
|
import { heatmapBgColor } from "./heatmapUtils.js";
|
|
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
|
|
import { TimelineTooltip } from "./TimelineTooltip.js";
|
|
import {
|
|
ROW_HEIGHT,
|
|
SUB_LANE_HEIGHT,
|
|
LABEL_WIDTH,
|
|
} from "./timelineConstants.js";
|
|
import { getProjectColor } from "~/lib/project-colors.js";
|
|
import type {
|
|
DragState,
|
|
AllocDragState,
|
|
RangeState,
|
|
ShiftPreviewData,
|
|
MultiSelectState,
|
|
} from "~/hooks/useTimelineDrag.js";
|
|
import type { HeatmapColorScheme } from "~/hooks/useAppPreferences.js";
|
|
import {
|
|
renderVacationBlocks,
|
|
renderRangeOverlay,
|
|
renderOverbookingBlink,
|
|
type VacationBlockInfo,
|
|
} from "./renderHelpers.js";
|
|
|
|
// ─── Props ──────────────────────────────────────────────────────────────────
|
|
|
|
interface TimelineResourcePanelProps {
|
|
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
|
|
dragState: DragState;
|
|
allocDragState: AllocDragState;
|
|
rangeState: RangeState;
|
|
shiftPreview: ShiftPreviewData | null;
|
|
contextResourceIds: string[];
|
|
onAllocMouseDown: (e: React.MouseEvent, info: AllocMouseDownInfo) => void;
|
|
onAllocTouchStart: (e: React.TouchEvent, info: AllocMouseDownInfo) => void;
|
|
onRowMouseDown: (e: React.MouseEvent, info: RowMouseDownInfo) => void;
|
|
onRowTouchStart: (e: React.TouchEvent, info: RowMouseDownInfo) => void;
|
|
onAllocationContextMenu: (
|
|
info: { allocationId: string; projectId: string },
|
|
anchorX: number,
|
|
anchorY: number,
|
|
) => void;
|
|
multiSelectState: MultiSelectState;
|
|
// Layout from useTimelineLayout
|
|
CELL_WIDTH: number;
|
|
dates: Date[];
|
|
totalCanvasWidth: number;
|
|
toLeft: (date: Date) => number;
|
|
toWidth: (start: Date, end: Date) => number;
|
|
gridLines: React.ReactNode;
|
|
xToDate: (clientX: number, rect: DOMRect) => Date;
|
|
}
|
|
|
|
export interface AllocMouseDownInfo {
|
|
mode: "move" | "resize-start" | "resize-end";
|
|
allocationId: string;
|
|
mutationAllocationId: string;
|
|
projectId: string;
|
|
projectName: string;
|
|
resourceId: string | null;
|
|
startDate: Date;
|
|
endDate: Date;
|
|
}
|
|
|
|
export interface RowMouseDownInfo {
|
|
resourceId: string;
|
|
startDate: Date;
|
|
suggestedProjectId?: string;
|
|
}
|
|
|
|
// ─── Component ──────────────────────────────────────────────────────────────
|
|
|
|
function TimelineResourcePanelInner({
|
|
scrollContainerRef,
|
|
dragState,
|
|
allocDragState,
|
|
rangeState,
|
|
shiftPreview,
|
|
contextResourceIds,
|
|
onAllocMouseDown,
|
|
onAllocTouchStart,
|
|
onRowMouseDown,
|
|
onRowTouchStart,
|
|
onAllocationContextMenu,
|
|
multiSelectState,
|
|
CELL_WIDTH,
|
|
dates,
|
|
totalCanvasWidth,
|
|
toLeft,
|
|
toWidth,
|
|
gridLines,
|
|
xToDate,
|
|
}: TimelineResourcePanelProps) {
|
|
const {
|
|
resources,
|
|
allocsByResource,
|
|
vacationsByResource,
|
|
filters,
|
|
viewStart,
|
|
viewEnd,
|
|
displayMode,
|
|
heatmapScheme,
|
|
blinkOverbookedDays,
|
|
activeFilterCount,
|
|
} = useTimelineContext();
|
|
|
|
// ─── Heatmap hover state ────────────────────────────────────────────────────
|
|
const heatmapRafRef = useRef<number | null>(null);
|
|
const lastHeatmapDayRef = useRef<number>(-1);
|
|
const vacationHoverRafRef = useRef<number | null>(null);
|
|
const hoveredVacationKeyRef = useRef<string | null>(null);
|
|
const pendingHeatmapRef = useRef<{
|
|
clientX: number;
|
|
rect: DOMRect;
|
|
allocs: TimelineAssignmentEntry[];
|
|
} | null>(null);
|
|
const heatmapTooltipRef = useRef<HTMLDivElement | null>(null);
|
|
const vacationTooltipRef = useRef<HTMLDivElement | null>(null);
|
|
const heatmapTooltipPosRef = useRef({ left: 0, top: 0 });
|
|
const vacationTooltipPosRef = useRef({ left: 0, top: 0 });
|
|
|
|
const [heatmapHover, setHeatmapHover] = useState<{
|
|
date: Date;
|
|
totalH: number;
|
|
pct: number;
|
|
breakdown: {
|
|
projectId: string;
|
|
shortCode: string;
|
|
projectName: string;
|
|
orderType: string;
|
|
hoursPerDay: number;
|
|
responsiblePerson?: string | null;
|
|
role?: string | null;
|
|
status?: string;
|
|
startDate?: string;
|
|
endDate?: string;
|
|
}[];
|
|
} | null>(null);
|
|
|
|
const [vacationHover, setVacationHover] = useState<null | {
|
|
type: string;
|
|
startDate: Date | string;
|
|
endDate: Date | string;
|
|
note?: string | null;
|
|
requestedBy?: { name?: string | null; email: string } | null;
|
|
approvedBy?: { name?: string | null; email: string } | null;
|
|
approvedAt?: Date | string | null;
|
|
}>(null);
|
|
|
|
// ─── Virtual row list ────────────────────────────────────────────────────────
|
|
const rowVirtualizer = useVirtualizer({
|
|
count: resources.length,
|
|
getScrollElement: () => scrollContainerRef.current,
|
|
estimateSize: () => ROW_HEIGHT,
|
|
overscan: 5,
|
|
});
|
|
const virtualItems = rowVirtualizer.getVirtualItems();
|
|
const totalRowHeight = rowVirtualizer.getTotalSize();
|
|
|
|
// ─── Memo 1: resourceRows — which rows to render ─────────────────────────
|
|
// (virtualizer handles which subset is visible; this memo just pre-computes
|
|
// per-row data that the render loop needs)
|
|
const resourceRows = useMemo(() => {
|
|
return resources.map((resource) => {
|
|
const allocs = allocsByResource.get(resource.id) ?? [];
|
|
const isContextResource = contextResourceIds.includes(resource.id);
|
|
return { resource, allocs, isContextResource };
|
|
});
|
|
}, [resources, allocsByResource, contextResourceIds]);
|
|
|
|
// ─── Memo 2: vacationBlocks — vacation bar positions per resource ─────────
|
|
const vacationBlocksByResource = useMemo(() => {
|
|
if (!filters.showVacations) return new Map<string, VacationBlockInfo[]>();
|
|
|
|
const result = new Map<string, VacationBlockInfo[]>();
|
|
for (const [resourceId, vacations] of vacationsByResource) {
|
|
const blocks: VacationBlockInfo[] = [];
|
|
for (const v of vacations) {
|
|
const vStart = new Date(v.startDate);
|
|
const vEnd = new Date(v.endDate);
|
|
const left = toLeft(vStart);
|
|
const width = Math.max(CELL_WIDTH, toWidth(vStart, vEnd));
|
|
if (width <= 0 || left >= totalCanvasWidth) continue;
|
|
blocks.push({ vacation: v, left, width });
|
|
}
|
|
if (blocks.length > 0) {
|
|
result.set(resourceId, blocks);
|
|
}
|
|
}
|
|
return result;
|
|
}, [vacationsByResource, toLeft, toWidth, CELL_WIDTH, totalCanvasWidth, filters.showVacations]);
|
|
|
|
// ─── Memo 3: assignmentBlocks — pre-computed per resource for strip mode ──
|
|
// (Bar mode computes differently per-day, so we only pre-compute for strip.)
|
|
const assignmentBlocksByResource = useMemo(() => {
|
|
if (displayMode === "bar")
|
|
return new Map<string, { laneCount: number; blockData: AllocBlockData[] }>();
|
|
|
|
const result = new Map<string, { laneCount: number; blockData: AllocBlockData[] }>();
|
|
for (const { resource, allocs } of resourceRows) {
|
|
if (allocs.length === 0) continue;
|
|
|
|
const subLaneMap = computeSubLanes(
|
|
allocs.map((a) => ({
|
|
id: a.id,
|
|
startDate: new Date(a.startDate),
|
|
endDate: new Date(a.endDate),
|
|
})),
|
|
);
|
|
const laneCount = subLaneMap.size > 0 ? Math.max(...subLaneMap.values()) + 1 : 1;
|
|
const blockData: AllocBlockData[] = allocs.map((alloc) => ({
|
|
alloc,
|
|
lane: subLaneMap.get(alloc.id) ?? 0,
|
|
}));
|
|
result.set(resource.id, { laneCount, blockData });
|
|
}
|
|
return result;
|
|
}, [displayMode, resourceRows]);
|
|
|
|
// ─── Memo 4: utilization per resource for row background tint ───────────
|
|
const utilizationByResource = useMemo(() => {
|
|
const REF_H = 8;
|
|
const result = new Map<string, number>(); // resourceId -> avg utilization pct
|
|
for (const { resource, allocs } of resourceRows) {
|
|
if (allocs.length === 0) continue;
|
|
let totalHours = 0;
|
|
let dayCount = 0;
|
|
for (const date of dates) {
|
|
const t = date.getTime();
|
|
let dayH = 0;
|
|
for (const a of allocs) {
|
|
const s = new Date(a.startDate);
|
|
s.setHours(0, 0, 0, 0);
|
|
const e = new Date(a.endDate);
|
|
e.setHours(0, 0, 0, 0);
|
|
if (t >= s.getTime() && t <= e.getTime()) dayH += a.hoursPerDay;
|
|
}
|
|
if (dayH > 0) {
|
|
totalHours += dayH;
|
|
dayCount++;
|
|
}
|
|
}
|
|
if (dayCount > 0) {
|
|
result.set(resource.id, (totalHours / dayCount / REF_H) * 100);
|
|
}
|
|
}
|
|
return result;
|
|
}, [resourceRows, dates]);
|
|
|
|
// ─── Heatmap row hover handler ────────────────────────────────────────────
|
|
const handleRowHeatmapMove = useCallback(
|
|
(e: React.MouseEvent, allocs: TimelineAssignmentEntry[]) => {
|
|
heatmapTooltipPosRef.current = { left: e.clientX + 16, top: e.clientY - 52 };
|
|
if (heatmapTooltipRef.current) {
|
|
heatmapTooltipRef.current.style.left = `${heatmapTooltipPosRef.current.left}px`;
|
|
heatmapTooltipRef.current.style.top = `${heatmapTooltipPosRef.current.top}px`;
|
|
}
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
const dayIndex = Math.floor((e.clientX - rect.left) / CELL_WIDTH);
|
|
if (dayIndex === lastHeatmapDayRef.current) return;
|
|
|
|
pendingHeatmapRef.current = { clientX: e.clientX, rect, allocs };
|
|
if (heatmapRafRef.current !== null) return;
|
|
|
|
heatmapRafRef.current = requestAnimationFrame(() => {
|
|
heatmapRafRef.current = null;
|
|
const pending = pendingHeatmapRef.current;
|
|
pendingHeatmapRef.current = null;
|
|
if (!pending) return;
|
|
|
|
const { clientX, rect: r, allocs: a } = pending;
|
|
const dayIdx = Math.floor((clientX - r.left) / CELL_WIDTH);
|
|
const date = dates[dayIdx];
|
|
if (!date) {
|
|
lastHeatmapDayRef.current = -1;
|
|
startTransition(() => setHeatmapHover(null));
|
|
return;
|
|
}
|
|
lastHeatmapDayRef.current = dayIdx;
|
|
|
|
const t = date.getTime();
|
|
const REF_H = 8;
|
|
const projectHours = new Map<
|
|
string,
|
|
{
|
|
shortCode: string;
|
|
projectName: string;
|
|
orderType: string;
|
|
hours: number;
|
|
responsiblePerson?: string | null;
|
|
role?: string | null;
|
|
status?: string;
|
|
startDate?: string;
|
|
endDate?: string;
|
|
}
|
|
>();
|
|
for (const alloc of a) {
|
|
const s = new Date(alloc.startDate);
|
|
s.setHours(0, 0, 0, 0);
|
|
const ev = new Date(alloc.endDate);
|
|
ev.setHours(0, 0, 0, 0);
|
|
if (t < s.getTime() || t > ev.getTime()) continue;
|
|
const existing = projectHours.get(alloc.projectId);
|
|
if (existing) {
|
|
existing.hours += alloc.hoursPerDay;
|
|
} else {
|
|
projectHours.set(alloc.projectId, {
|
|
shortCode: alloc.project.shortCode,
|
|
projectName: alloc.project.name,
|
|
orderType: alloc.project.orderType,
|
|
hours: alloc.hoursPerDay,
|
|
responsiblePerson:
|
|
(alloc.project as { responsiblePerson?: string | null }).responsiblePerson ?? null,
|
|
role: alloc.role ?? alloc.roleEntity?.name ?? null,
|
|
status: alloc.status,
|
|
startDate: new Date(alloc.startDate).toISOString().slice(0, 10),
|
|
endDate: new Date(alloc.endDate).toISOString().slice(0, 10),
|
|
});
|
|
}
|
|
}
|
|
|
|
const breakdown = [...projectHours.entries()]
|
|
.map(([projectId, v]) => ({ projectId, ...v, hoursPerDay: v.hours }))
|
|
.sort((a, b) => b.hoursPerDay - a.hoursPerDay);
|
|
|
|
const totalH = breakdown.reduce((sum, b) => sum + b.hoursPerDay, 0);
|
|
startTransition(() => {
|
|
setHeatmapHover({ date, totalH, pct: (totalH / REF_H) * 100, breakdown });
|
|
});
|
|
});
|
|
},
|
|
[CELL_WIDTH, dates],
|
|
);
|
|
|
|
// ─── Vacation hover ───────────────────────────────────────────────────────
|
|
const handleRowVacationHover = useCallback(
|
|
(e: React.MouseEvent, resourceId: string) => {
|
|
vacationTooltipPosRef.current = { left: e.clientX + 14, top: e.clientY - 8 };
|
|
if (vacationTooltipRef.current) {
|
|
vacationTooltipRef.current.style.left = `${vacationTooltipPosRef.current.left}px`;
|
|
vacationTooltipRef.current.style.top = `${vacationTooltipPosRef.current.top}px`;
|
|
}
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
const clientX = e.clientX;
|
|
|
|
if (vacationHoverRafRef.current !== null) return;
|
|
|
|
vacationHoverRafRef.current = requestAnimationFrame(() => {
|
|
vacationHoverRafRef.current = null;
|
|
const date = xToDate(clientX, rect);
|
|
date.setHours(0, 0, 0, 0);
|
|
const t = date.getTime();
|
|
const resourceVacations = vacationsByResource.get(resourceId) ?? [];
|
|
const hit =
|
|
resourceVacations.find((v) => {
|
|
const s = new Date(v.startDate);
|
|
s.setHours(0, 0, 0, 0);
|
|
const end = new Date(v.endDate);
|
|
end.setHours(0, 0, 0, 0);
|
|
return t >= s.getTime() && t <= end.getTime();
|
|
}) ?? null;
|
|
|
|
const nextKey = hit ? `${resourceId}:${hit.id}` : null;
|
|
if (nextKey === hoveredVacationKeyRef.current) return;
|
|
|
|
hoveredVacationKeyRef.current = nextKey;
|
|
startTransition(() => {
|
|
setVacationHover(hit);
|
|
});
|
|
});
|
|
},
|
|
[vacationsByResource, xToDate],
|
|
);
|
|
|
|
const clearHoverTooltips = useCallback(() => {
|
|
if (heatmapRafRef.current !== null) {
|
|
cancelAnimationFrame(heatmapRafRef.current);
|
|
heatmapRafRef.current = null;
|
|
}
|
|
if (vacationHoverRafRef.current !== null) {
|
|
cancelAnimationFrame(vacationHoverRafRef.current);
|
|
vacationHoverRafRef.current = null;
|
|
}
|
|
|
|
const shouldClearHeatmap = lastHeatmapDayRef.current !== -1;
|
|
const shouldClearVacation = hoveredVacationKeyRef.current !== null;
|
|
|
|
lastHeatmapDayRef.current = -1;
|
|
hoveredVacationKeyRef.current = null;
|
|
|
|
if (shouldClearHeatmap || shouldClearVacation) {
|
|
startTransition(() => {
|
|
if (shouldClearHeatmap) setHeatmapHover(null);
|
|
if (shouldClearVacation) setVacationHover(null);
|
|
});
|
|
}
|
|
}, []);
|
|
|
|
// ─── Cleanup rAF on unmount ───────────────────────────────────────────────
|
|
useEffect(
|
|
() => () => {
|
|
if (heatmapRafRef.current !== null) cancelAnimationFrame(heatmapRafRef.current);
|
|
if (vacationHoverRafRef.current !== null) cancelAnimationFrame(vacationHoverRafRef.current);
|
|
},
|
|
[],
|
|
);
|
|
|
|
// ─── Render helpers ───────────────────────────────────────────────────────
|
|
|
|
if (resources.length === 0) {
|
|
return (
|
|
<div className="text-center py-16 text-gray-400">
|
|
No allocations in this time range{activeFilterCount > 0 && " (filtered)"}.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
height: totalRowHeight,
|
|
position: "relative",
|
|
}}
|
|
>
|
|
{virtualItems.map((virtualRow) => {
|
|
const rowData = resourceRows[virtualRow.index];
|
|
if (!rowData) return null;
|
|
const { resource, allocs, isContextResource } = rowData;
|
|
const inBarMode = displayMode === "bar";
|
|
const precomputed = assignmentBlocksByResource.get(resource.id);
|
|
const laneCount = inBarMode ? 1 : (precomputed?.laneCount ?? 1);
|
|
const rowHeight = inBarMode
|
|
? ROW_HEIGHT
|
|
: Math.max(ROW_HEIGHT, laneCount * SUB_LANE_HEIGHT + 16);
|
|
|
|
// Utilization background tint
|
|
const utilPct = utilizationByResource.get(resource.id) ?? 0;
|
|
const utilBg = utilPct > 100
|
|
? "rgba(254,202,202,0.18)" // red tint for over-utilized
|
|
: utilPct >= 50
|
|
? `rgba(59,130,246,${Math.min(0.06 + (utilPct - 50) * 0.0014, 0.12)})` // faint blue tint scaling 50-100%
|
|
: undefined;
|
|
|
|
return (
|
|
<div
|
|
key={resource.id}
|
|
data-index={virtualRow.index}
|
|
ref={rowVirtualizer.measureElement}
|
|
style={{
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
width: "100%",
|
|
transform: `translateY(${virtualRow.start}px)`,
|
|
}}
|
|
>
|
|
<div
|
|
className={clsx(
|
|
"flex border-b border-gray-100 dark:border-gray-800 hover:bg-blue-50/20 dark:hover:bg-gray-800/30 group transition-colors",
|
|
dragState.isDragging && isContextResource && "border-l-4 border-l-brand-400",
|
|
)}
|
|
style={{ height: rowHeight, ...(utilBg ? { backgroundColor: utilBg } : {}) }}
|
|
>
|
|
{/* Label column */}
|
|
<div
|
|
className={clsx(
|
|
"flex-shrink-0 border-r border-gray-200 dark:border-gray-700 flex items-center px-4 gap-2.5 bg-white dark:bg-gray-900 sticky left-0 z-30 group-hover:bg-blue-50 dark:group-hover:bg-gray-800",
|
|
dragState.isDragging && isContextResource && "bg-brand-50 dark:bg-brand-950/40",
|
|
)}
|
|
style={{ width: LABEL_WIDTH }}
|
|
>
|
|
<div className="w-8 h-8 rounded-full bg-brand-100 dark:bg-brand-900/40 flex items-center justify-center text-xs font-bold text-brand-700 dark:text-brand-300 flex-shrink-0">
|
|
{resource.displayName.slice(0, 2).toUpperCase()}
|
|
</div>
|
|
<div className="min-w-0" data-resource-hover-id={resource.id}>
|
|
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate cursor-pointer hover:text-brand-600 dark:hover:text-brand-400 transition-colors">
|
|
{resource.displayName}
|
|
</div>
|
|
<div className="text-xs text-gray-400 dark:text-gray-500 truncate">
|
|
{resource.chapter ?? resource.eid}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Row canvas */}
|
|
<div
|
|
data-testid="timeline-resource-row-canvas"
|
|
data-resource-id={resource.id}
|
|
data-resource-eid={resource.eid}
|
|
data-resource-name={resource.displayName}
|
|
className="relative overflow-hidden touch-none"
|
|
style={{ width: totalCanvasWidth, height: rowHeight, touchAction: "none" }}
|
|
onMouseDown={(e) => {
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
const date = xToDate(e.clientX, rect);
|
|
onRowMouseDown(e, { resourceId: resource.id, startDate: date });
|
|
}}
|
|
onTouchStart={(e) => {
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
const date = xToDate(e.touches[0]?.clientX ?? 0, rect);
|
|
onRowTouchStart(e, { resourceId: resource.id, startDate: date });
|
|
}}
|
|
onMouseMove={(e) => {
|
|
handleRowHeatmapMove(e, allocs);
|
|
handleRowVacationHover(e, resource.id);
|
|
}}
|
|
onMouseLeave={clearHoverTooltips}
|
|
>
|
|
{gridLines}
|
|
{inBarMode
|
|
? renderDailyBars(
|
|
allocs,
|
|
rowHeight,
|
|
CELL_WIDTH,
|
|
dates,
|
|
allocDragState,
|
|
onAllocMouseDown,
|
|
onAllocTouchStart,
|
|
onAllocationContextMenu,
|
|
toLeft,
|
|
toWidth,
|
|
totalCanvasWidth,
|
|
multiSelectState,
|
|
)
|
|
: renderAllocBlocksFromData(
|
|
precomputed?.blockData ?? [],
|
|
allocs,
|
|
dragState,
|
|
allocDragState,
|
|
toLeft,
|
|
toWidth,
|
|
CELL_WIDTH,
|
|
totalCanvasWidth,
|
|
onAllocMouseDown,
|
|
onAllocTouchStart,
|
|
onAllocationContextMenu,
|
|
multiSelectState,
|
|
)}
|
|
{filters.showVacations &&
|
|
renderVacationBlocks(
|
|
vacationBlocksByResource.get(resource.id) ?? [],
|
|
rowHeight,
|
|
)}
|
|
{displayMode === "strip" && renderLoadGraph(allocs, dates, CELL_WIDTH)}
|
|
{displayMode === "heatmap" &&
|
|
renderHeatmapOverlay(allocs, dates, CELL_WIDTH, heatmapScheme)}
|
|
{blinkOverbookedDays &&
|
|
renderOverbookingBlink(allocs, dates, CELL_WIDTH)}
|
|
{renderRangeOverlay(
|
|
rangeState,
|
|
resource.id,
|
|
rowHeight,
|
|
toLeft,
|
|
toWidth,
|
|
CELL_WIDTH,
|
|
)}
|
|
|
|
{dragState.isDragging &&
|
|
dragState.projectId &&
|
|
shiftPreview &&
|
|
!shiftPreview.valid &&
|
|
shiftPreview.conflictCount > 0 &&
|
|
allocs.some((a) => a.projectId === dragState.projectId) && (
|
|
<ConflictOverlay
|
|
left={toLeft(dragState.currentStartDate ?? viewStart) + 2}
|
|
width={
|
|
toWidth(
|
|
dragState.currentStartDate ?? viewStart,
|
|
dragState.currentEndDate ?? viewEnd,
|
|
) - 4
|
|
}
|
|
height={rowHeight - 8}
|
|
type="availability"
|
|
message={`${shiftPreview.conflictCount} conflict(s)`}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{/* Tooltips rendered inside the panel so they live near their data source */}
|
|
<TimelineTooltip
|
|
heatmapTooltipRef={heatmapTooltipRef}
|
|
heatmapTooltipPos={heatmapTooltipPosRef.current}
|
|
vacationTooltipRef={vacationTooltipRef}
|
|
vacationTooltipPos={vacationTooltipPosRef.current}
|
|
heatmapHover={heatmapHover}
|
|
vacationHover={vacationHover}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ResourcePanelTooltips removed — now uses shared TimelineTooltip component
|
|
|
|
// ─── Helper types ───────────────────────────────────────────────────────────
|
|
|
|
interface AllocBlockData {
|
|
alloc: TimelineAssignmentEntry;
|
|
lane: number;
|
|
}
|
|
|
|
// ─── Pure render functions (no hooks, extracted from TimelineView) ───────────
|
|
|
|
function renderAllocBlocksFromData(
|
|
blockData: AllocBlockData[],
|
|
_allocs: TimelineAssignmentEntry[],
|
|
dragState: DragState,
|
|
allocDragState: AllocDragState,
|
|
toLeft: (d: Date) => number,
|
|
toWidth: (s: Date, e: Date) => number,
|
|
CELL_WIDTH: number,
|
|
totalCanvasWidth: number,
|
|
onAllocMouseDown: (e: React.MouseEvent, info: AllocMouseDownInfo) => void,
|
|
onAllocTouchStart: (e: React.TouchEvent, info: AllocMouseDownInfo) => void,
|
|
onAllocationContextMenu: (
|
|
info: { allocationId: string; projectId: string },
|
|
anchorX: number,
|
|
anchorY: number,
|
|
) => void,
|
|
multiSelectState: MultiSelectState,
|
|
) {
|
|
const anyDragActive = dragState.isDragging || allocDragState.isActive;
|
|
|
|
return blockData.map(({ alloc, lane }) => {
|
|
const allocStart = new Date(alloc.startDate);
|
|
const allocEnd = new Date(alloc.endDate);
|
|
|
|
const isProjectShifted = dragState.isDragging && dragState.projectId === alloc.projectId;
|
|
const isAllocDragged = allocDragState.isActive && allocDragState.allocationId === alloc.id;
|
|
const isBeingDragged = isProjectShifted || isAllocDragged;
|
|
const isOtherDragged = anyDragActive && !isBeingDragged;
|
|
|
|
let dispStart = allocStart;
|
|
let dispEnd = allocEnd;
|
|
if (isProjectShifted && dragState.currentStartDate && dragState.currentEndDate) {
|
|
dispStart = dragState.currentStartDate;
|
|
dispEnd = dragState.currentEndDate;
|
|
} else if (isAllocDragged && allocDragState.currentStartDate && allocDragState.currentEndDate) {
|
|
dispStart = allocDragState.currentStartDate;
|
|
dispEnd = allocDragState.currentEndDate;
|
|
}
|
|
|
|
// Multi-drag offset: shift selected allocations visually during multi-drag
|
|
const isMultiDragTarget =
|
|
multiSelectState.isMultiDragging &&
|
|
multiSelectState.selectedAllocationIds.includes(alloc.id);
|
|
const multiDragPx = isMultiDragTarget ? multiSelectState.multiDragDaysDelta * CELL_WIDTH : 0;
|
|
const multiDragMode = multiSelectState.multiDragMode;
|
|
|
|
let left = toLeft(dispStart);
|
|
let width = Math.max(CELL_WIDTH, toWidth(dispStart, dispEnd));
|
|
|
|
// For multi-drag resize, adjust left/width instead of using translateX
|
|
if (isMultiDragTarget && multiDragMode === "resize-start") {
|
|
left += multiDragPx;
|
|
width = Math.max(CELL_WIDTH, width - multiDragPx);
|
|
} else if (isMultiDragTarget && multiDragMode === "resize-end") {
|
|
width = Math.max(CELL_WIDTH, width + multiDragPx);
|
|
}
|
|
if (width <= 0 || left >= totalCanvasWidth) return null;
|
|
|
|
const blockTop = 8 + lane * SUB_LANE_HEIGHT;
|
|
const blockHeight = SUB_LANE_HEIGHT - 8;
|
|
|
|
const customColor = (alloc.project as { color?: string | null }).color;
|
|
const projectColor = getProjectColor(alloc.projectId);
|
|
const blockBgColor = customColor ?? projectColor.hex + "B3";
|
|
const HANDLE_W = width >= 48 ? 10 : 6;
|
|
const hasRecurrence = !!(alloc.metadata as Record<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={alloc.id}
|
|
className={clsx(
|
|
"absolute rounded-md flex items-stretch overflow-hidden transition-all duration-75 group/block text-white",
|
|
hasRecurrence && "opacity-80 border-2 border-dashed border-white/60",
|
|
isBeingDragged
|
|
? "opacity-90 shadow-2xl ring-2 ring-white ring-offset-1 z-20 scale-[1.01]"
|
|
: isOtherDragged
|
|
? "opacity-30 z-[10]"
|
|
: "hover:ring-2 hover:ring-white hover:ring-offset-1 z-[10]",
|
|
multiSelectState.selectedAllocationIds.includes(alloc.id) && "ring-2 ring-sky-500 ring-offset-1 z-20",
|
|
)}
|
|
style={{
|
|
left: left + 2,
|
|
width: width - 4,
|
|
top: blockTop,
|
|
height: blockHeight,
|
|
backgroundColor: blockBgColor,
|
|
...(multiDragPx && multiDragMode === "move" ? { transform: `translateX(${multiDragPx}px)` } : {}),
|
|
}}
|
|
onMouseDown={(e) => {
|
|
// Stop right-click mouseDown from bubbling to the canvas,
|
|
// which would falsely start a multi-selection rectangle.
|
|
if (e.button === 2) e.stopPropagation();
|
|
}}
|
|
onContextMenu={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
onAllocationContextMenu(
|
|
{ allocationId: alloc.id, projectId: alloc.projectId },
|
|
e.clientX,
|
|
e.clientY,
|
|
);
|
|
}}
|
|
>
|
|
{/* Left resize handle */}
|
|
<div
|
|
className="flex-shrink-0 flex items-center justify-center cursor-ew-resize hover:bg-black/20 transition-colors"
|
|
style={{ width: HANDLE_W }}
|
|
onMouseDown={(e) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" })}
|
|
onTouchStart={(e) => {
|
|
e.stopPropagation();
|
|
onAllocTouchStart(e, { ...allocInfo, mode: "resize-start" });
|
|
}}
|
|
>
|
|
{HANDLE_W >= 10 && (
|
|
<div className="flex flex-col gap-0.5 opacity-60 group-hover/block:opacity-100">
|
|
<div className="w-px h-2.5 bg-white rounded" />
|
|
<div className="w-px h-2.5 bg-white rounded" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Center -- move */}
|
|
<div
|
|
className={clsx(
|
|
"flex-1 flex items-center gap-1 px-1 min-w-0 select-none",
|
|
isBeingDragged ? "cursor-grabbing" : "cursor-grab",
|
|
)}
|
|
onMouseDown={(e) => onAllocMouseDown(e, allocInfo)}
|
|
onTouchStart={(e) => {
|
|
e.stopPropagation();
|
|
onAllocTouchStart(e, allocInfo);
|
|
}}
|
|
>
|
|
{hasRecurrence && width > 28 && (
|
|
<span className="text-[10px] opacity-80 flex-shrink-0">↻</span>
|
|
)}
|
|
{width > 60 ? (
|
|
<span className="text-xs font-semibold truncate">{alloc.project.name}</span>
|
|
) : (
|
|
<span className="text-[9px] font-bold truncate opacity-90">{alloc.project.shortCode}</span>
|
|
)}
|
|
{width > 130 && <span className="text-[10px] opacity-75 truncate">{alloc.role}</span>}
|
|
{width > 190 && (
|
|
<span className="text-[10px] opacity-60 truncate">{alloc.hoursPerDay}h</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Right resize handle */}
|
|
<div
|
|
className="flex-shrink-0 flex items-center justify-center cursor-ew-resize hover:bg-black/20 transition-colors"
|
|
style={{ width: HANDLE_W }}
|
|
onMouseDown={(e) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" })}
|
|
onTouchStart={(e) => {
|
|
e.stopPropagation();
|
|
onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" });
|
|
}}
|
|
>
|
|
{HANDLE_W >= 10 && (
|
|
<div className="flex flex-col gap-0.5 opacity-60 group-hover/block:opacity-100">
|
|
<div className="w-px h-2.5 bg-white rounded" />
|
|
<div className="w-px h-2.5 bg-white rounded" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
}
|
|
|
|
// ─── Strip-mode: daily load graph ────────────────────────────────────────────
|
|
|
|
function renderLoadGraph(allocs: TimelineAssignmentEntry[], dates: Date[], CELL_WIDTH: number) {
|
|
const GRAPH_H = 12;
|
|
const REF_H = 8;
|
|
|
|
function hoursOnDay(list: TimelineAssignmentEntry[], t: number) {
|
|
return list.reduce((sum, a) => {
|
|
const s = new Date(a.startDate);
|
|
s.setHours(0, 0, 0, 0);
|
|
const e = new Date(a.endDate);
|
|
e.setHours(0, 0, 0, 0);
|
|
return t >= s.getTime() && t <= e.getTime() ? sum + a.hoursPerDay : sum;
|
|
}, 0);
|
|
}
|
|
|
|
return (
|
|
<div className="absolute inset-x-0 bottom-1 pointer-events-none" style={{ height: GRAPH_H }}>
|
|
{dates.map((date, i) => {
|
|
const t = date.getTime();
|
|
const totalH = hoursOnDay(allocs, t);
|
|
if (totalH === 0) return null;
|
|
|
|
const totalBarH = Math.min(GRAPH_H, Math.round((totalH / REF_H) * GRAPH_H));
|
|
|
|
return (
|
|
<div
|
|
key={i}
|
|
className={clsx(
|
|
"absolute bottom-0 rounded-t-sm",
|
|
totalH > 12
|
|
? "bg-red-500 opacity-80"
|
|
: totalH > 8
|
|
? "bg-amber-400 opacity-80"
|
|
: "bg-brand-500 opacity-80",
|
|
)}
|
|
style={{ left: i * CELL_WIDTH + 3, width: CELL_WIDTH - 6, height: totalBarH }}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Heatmap-mode: utilisation colour overlay ────────────────────────────────
|
|
|
|
function renderHeatmapOverlay(
|
|
allocs: TimelineAssignmentEntry[],
|
|
dates: Date[],
|
|
CELL_WIDTH: number,
|
|
heatmapScheme: HeatmapColorScheme,
|
|
) {
|
|
const REF_H = 8;
|
|
return dates.map((date, i) => {
|
|
const t = date.getTime();
|
|
const totalH = allocs.reduce((sum, a) => {
|
|
const s = new Date(a.startDate);
|
|
s.setHours(0, 0, 0, 0);
|
|
const e = new Date(a.endDate);
|
|
e.setHours(0, 0, 0, 0);
|
|
return t >= s.getTime() && t <= e.getTime() ? sum + a.hoursPerDay : sum;
|
|
}, 0);
|
|
const bg = heatmapBgColor((totalH / REF_H) * 100, heatmapScheme);
|
|
if (!bg) return null;
|
|
return (
|
|
<div
|
|
key={`hm-${i}`}
|
|
className="absolute top-0 bottom-0 pointer-events-none z-10"
|
|
style={{ left: i * CELL_WIDTH, width: CELL_WIDTH, backgroundColor: bg }}
|
|
/>
|
|
);
|
|
});
|
|
}
|
|
|
|
// ─── Bar-mode: stacked daily bars ────────────────────────────────────────────
|
|
|
|
function renderDailyBars(
|
|
allocs: TimelineAssignmentEntry[],
|
|
rowHeight: number,
|
|
CELL_WIDTH: number,
|
|
dates: Date[],
|
|
allocDragState: AllocDragState,
|
|
onAllocMouseDown: (e: React.MouseEvent, info: AllocMouseDownInfo) => void,
|
|
onAllocTouchStart: (e: React.TouchEvent, info: AllocMouseDownInfo) => void,
|
|
onAllocationContextMenu: (
|
|
info: { allocationId: string; projectId: string },
|
|
anchorX: number,
|
|
anchorY: number,
|
|
) => void,
|
|
toLeft: (d: Date) => number,
|
|
toWidth: (s: Date, e: Date) => number,
|
|
totalCanvasWidth: number,
|
|
multiSelectState: MultiSelectState,
|
|
) {
|
|
const BAR_AREA = rowHeight - 8;
|
|
const REF_H = 8;
|
|
|
|
return dates.flatMap((date, i) => {
|
|
const t = date.getTime();
|
|
|
|
const covering = allocs.filter((a) => {
|
|
const isDragged = allocDragState.isActive && allocDragState.allocationId === a.id;
|
|
const s = new Date(
|
|
isDragged && allocDragState.currentStartDate
|
|
? allocDragState.currentStartDate
|
|
: a.startDate,
|
|
);
|
|
const e = new Date(
|
|
isDragged && allocDragState.currentEndDate ? allocDragState.currentEndDate : a.endDate,
|
|
);
|
|
s.setHours(0, 0, 0, 0);
|
|
e.setHours(0, 0, 0, 0);
|
|
return t >= s.getTime() && t <= e.getTime();
|
|
});
|
|
|
|
if (covering.length === 0) return [];
|
|
|
|
const totalH = covering.reduce((sum, a) => sum + a.hoursPerDay, 0);
|
|
const isOver = totalH > REF_H;
|
|
let stackedH = 0;
|
|
|
|
const segs: React.ReactNode[] = covering.map((alloc) => {
|
|
const customColor = (alloc.project as { color?: string | null }).color;
|
|
const projectColor = getProjectColor(alloc.projectId);
|
|
const segBgColor = customColor ?? projectColor.hex + "B3";
|
|
const segH = Math.max(
|
|
2,
|
|
Math.min(BAR_AREA - stackedH, Math.round((alloc.hoursPerDay / REF_H) * BAR_AREA)),
|
|
);
|
|
const bottom = 4 + stackedH;
|
|
stackedH += segH;
|
|
const isBeingDragged = allocDragState.isActive && allocDragState.allocationId === alloc.id;
|
|
|
|
const dispStart = new Date(
|
|
isBeingDragged && allocDragState.currentStartDate
|
|
? allocDragState.currentStartDate
|
|
: alloc.startDate,
|
|
);
|
|
const dispEnd = new Date(
|
|
isBeingDragged && allocDragState.currentEndDate
|
|
? allocDragState.currentEndDate
|
|
: alloc.endDate,
|
|
);
|
|
dispStart.setHours(0, 0, 0, 0);
|
|
dispEnd.setHours(0, 0, 0, 0);
|
|
const isFirstDay = t === dispStart.getTime();
|
|
const isLastDay = t === dispEnd.getTime();
|
|
const EDGE_W = CELL_WIDTH >= 16 ? 4 : 0;
|
|
|
|
const allocInfo: AllocMouseDownInfo = {
|
|
mode: "move",
|
|
allocationId: alloc.id,
|
|
mutationAllocationId: getPlanningEntryMutationId(alloc),
|
|
projectId: alloc.projectId,
|
|
projectName: alloc.project.name,
|
|
resourceId: alloc.resourceId,
|
|
startDate: new Date(alloc.startDate),
|
|
endDate: new Date(alloc.endDate),
|
|
};
|
|
|
|
return (
|
|
<div
|
|
key={`bar-${i}-${alloc.id}`}
|
|
className={clsx(
|
|
"absolute rounded-sm transition-all duration-75 flex items-stretch overflow-hidden",
|
|
isBeingDragged
|
|
? "opacity-90 ring-2 ring-white ring-offset-1 z-20"
|
|
: "hover:opacity-80 z-[10]",
|
|
multiSelectState.selectedAllocationIds.includes(alloc.id) && "ring-2 ring-sky-500 ring-offset-1 z-20",
|
|
)}
|
|
style={{
|
|
left: i * CELL_WIDTH + 2,
|
|
width: CELL_WIDTH - 4,
|
|
height: segH,
|
|
bottom,
|
|
backgroundColor: segBgColor,
|
|
...(multiSelectState.isMultiDragging &&
|
|
multiSelectState.selectedAllocationIds.includes(alloc.id)
|
|
? { transform: `translateX(${multiSelectState.multiDragDaysDelta * CELL_WIDTH}px)` }
|
|
: {}),
|
|
}}
|
|
onMouseDown={(e) => {
|
|
if (e.button === 2) e.stopPropagation();
|
|
}}
|
|
onContextMenu={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
onAllocationContextMenu(
|
|
{ allocationId: alloc.id, projectId: alloc.projectId },
|
|
e.clientX,
|
|
e.clientY,
|
|
);
|
|
}}
|
|
>
|
|
{isFirstDay && EDGE_W > 0 && (
|
|
<div
|
|
className="flex-shrink-0 cursor-ew-resize hover:bg-black/20"
|
|
style={{ width: EDGE_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", "cursor-grab")}
|
|
onMouseDown={(e) => {
|
|
e.stopPropagation();
|
|
onAllocMouseDown(e, allocInfo);
|
|
}}
|
|
onTouchStart={(e) => {
|
|
e.stopPropagation();
|
|
onAllocTouchStart(e, allocInfo);
|
|
}}
|
|
/>
|
|
{isLastDay && EDGE_W > 0 && (
|
|
<div
|
|
className="flex-shrink-0 cursor-ew-resize hover:bg-black/20"
|
|
style={{ width: EDGE_W }}
|
|
onMouseDown={(e) => {
|
|
e.stopPropagation();
|
|
onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" });
|
|
}}
|
|
onTouchStart={(e) => {
|
|
e.stopPropagation();
|
|
onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" });
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
});
|
|
|
|
if (isOver) {
|
|
segs.push(
|
|
<div
|
|
key={`bar-${i}-over`}
|
|
className="absolute bg-red-500/70 rounded-t-sm pointer-events-none z-30"
|
|
style={{ left: i * CELL_WIDTH + 2, width: CELL_WIDTH - 4, top: 4, height: 3 }}
|
|
/>,
|
|
);
|
|
}
|
|
|
|
return segs;
|
|
});
|
|
}
|
|
|
|
export const TimelineResourcePanel = memo(TimelineResourcePanelInner);
|
|
|
|
// ─── Re-export tooltip types for the parent ─────────────────────────────────
|
|
export type { AllocBlockData };
|
|
export type { VacationBlockInfo } from "./renderHelpers.js";
|