refactor(web): extract 4 pure render functions from TimelineResourcePanel
Move renderAllocBlocksFromData, renderLoadGraph, renderHeatmapOverlay, renderDailyBars into timelineResourceRender.tsx (707 lines). TimelineResourcePanel reduced from 1,270 to 589 lines. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,34 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { MILLISECONDS_PER_DAY } from "@capakraken/shared";
|
|
||||||
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 {
|
import { useTimelineContext, type TimelineAssignmentEntry } from "./TimelineContext.js";
|
||||||
useTimelineContext,
|
import { applyVisualOverrides, type TimelineVisualOverrides } from "./allocationVisualState.js";
|
||||||
type TimelineAssignmentEntry,
|
|
||||||
} from "./TimelineContext.js";
|
|
||||||
import {
|
|
||||||
applyPointerOffsetPreviewRect,
|
|
||||||
applyVisualOverrides,
|
|
||||||
getDragPointerOffset,
|
|
||||||
type TimelineVisualOverrides,
|
|
||||||
} from "./allocationVisualState.js";
|
|
||||||
import { ConflictOverlay } from "./ConflictOverlay.js";
|
import { ConflictOverlay } from "./ConflictOverlay.js";
|
||||||
import { computeSubLanes } from "./utils.js";
|
import { computeSubLanes } from "./utils.js";
|
||||||
import { heatmapBgColor } from "./heatmapUtils.js";
|
|
||||||
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
|
|
||||||
import {
|
import {
|
||||||
TimelineTooltip,
|
TimelineTooltip,
|
||||||
type HeatmapHoverData,
|
type HeatmapHoverData,
|
||||||
type VacationHoverData,
|
type VacationHoverData,
|
||||||
} from "./TimelineTooltip.js";
|
} from "./TimelineTooltip.js";
|
||||||
import {
|
import { ROW_HEIGHT, SUB_LANE_HEIGHT, LABEL_WIDTH } from "./timelineConstants.js";
|
||||||
ROW_HEIGHT,
|
|
||||||
SUB_LANE_HEIGHT,
|
|
||||||
LABEL_WIDTH,
|
|
||||||
} from "./timelineConstants.js";
|
|
||||||
import { getProjectColor } from "~/lib/project-colors.js";
|
|
||||||
import type {
|
import type {
|
||||||
DragState,
|
DragState,
|
||||||
AllocDragState,
|
AllocDragState,
|
||||||
@@ -36,7 +20,6 @@ import type {
|
|||||||
ShiftPreviewData,
|
ShiftPreviewData,
|
||||||
MultiSelectState,
|
MultiSelectState,
|
||||||
} from "~/hooks/useTimelineDrag.js";
|
} from "~/hooks/useTimelineDrag.js";
|
||||||
import type { HeatmapColorScheme } from "~/hooks/useAppPreferences.js";
|
|
||||||
import {
|
import {
|
||||||
buildVacationBlocksByResource,
|
buildVacationBlocksByResource,
|
||||||
renderVacationBlocks,
|
renderVacationBlocks,
|
||||||
@@ -50,15 +33,16 @@ import {
|
|||||||
updateTooltipPosition,
|
updateTooltipPosition,
|
||||||
} from "./timelineHover.js";
|
} from "./timelineHover.js";
|
||||||
import { buildResourceHeatmapHover } from "./timelineHeatmap.js";
|
import { buildResourceHeatmapHover } from "./timelineHeatmap.js";
|
||||||
|
import { buildResourceCapacitySeries } from "./timelineCapacity.js";
|
||||||
|
import { isAllocationScheduledOnDate } from "./timelineAvailability.js";
|
||||||
import {
|
import {
|
||||||
buildResourceCapacitySeries,
|
renderAllocBlocksFromData,
|
||||||
type ResourceCapacitySeries,
|
renderLoadGraph,
|
||||||
} from "./timelineCapacity.js";
|
renderHeatmapOverlay,
|
||||||
import {
|
renderDailyBars,
|
||||||
buildAllocationWorkingDaySegments,
|
type AllocBlockData,
|
||||||
isAllocationScheduledOnDate,
|
type AllocMouseDownInfo,
|
||||||
toLocalDateKey,
|
} from "./timelineResourceRender.js";
|
||||||
} from "./timelineAvailability.js";
|
|
||||||
|
|
||||||
// ─── Props ──────────────────────────────────────────────────────────────────
|
// ─── Props ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -81,7 +65,11 @@ interface TimelineResourcePanelProps {
|
|||||||
multiSelectState: MultiSelectState;
|
multiSelectState: MultiSelectState;
|
||||||
optimisticAllocations: TimelineVisualOverrides;
|
optimisticAllocations: TimelineVisualOverrides;
|
||||||
suppressHoverInteractions: boolean;
|
suppressHoverInteractions: boolean;
|
||||||
onInlineEdit?: (allocationId: string, initialValues: { startDate: Date; endDate: Date; hoursPerDay: number }, barRect: DOMRect) => void;
|
onInlineEdit?: (
|
||||||
|
allocationId: string,
|
||||||
|
initialValues: { startDate: Date; endDate: Date; hoursPerDay: number },
|
||||||
|
barRect: DOMRect,
|
||||||
|
) => void;
|
||||||
/** Horizontal virtualization: current scroll offset and visible container width */
|
/** Horizontal virtualization: current scroll offset and visible container width */
|
||||||
scrollLeft?: number;
|
scrollLeft?: number;
|
||||||
containerWidth?: number;
|
containerWidth?: number;
|
||||||
@@ -95,20 +83,6 @@ interface TimelineResourcePanelProps {
|
|||||||
xToDate: (clientX: number, rect: DOMRect) => Date;
|
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;
|
|
||||||
allocationStartDate?: Date;
|
|
||||||
allocationEndDate?: Date;
|
|
||||||
scope?: "allocation" | "segment";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RowMouseDownInfo {
|
export interface RowMouseDownInfo {
|
||||||
resourceId: string;
|
resourceId: string;
|
||||||
startDate: Date;
|
startDate: Date;
|
||||||
@@ -223,7 +197,15 @@ function TimelineResourcePanelInner({
|
|||||||
totalCanvasWidth,
|
totalCanvasWidth,
|
||||||
filters.showWeekends,
|
filters.showWeekends,
|
||||||
),
|
),
|
||||||
[vacationsByResource, toLeft, toWidth, CELL_WIDTH, totalCanvasWidth, filters.showVacations, filters.showWeekends],
|
[
|
||||||
|
vacationsByResource,
|
||||||
|
toLeft,
|
||||||
|
toWidth,
|
||||||
|
CELL_WIDTH,
|
||||||
|
totalCanvasWidth,
|
||||||
|
filters.showVacations,
|
||||||
|
filters.showWeekends,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── Memo 3: assignmentBlocks — pre-computed per resource for strip mode ──
|
// ─── Memo 3: assignmentBlocks — pre-computed per resource for strip mode ──
|
||||||
@@ -332,7 +314,14 @@ function TimelineResourcePanelInner({
|
|||||||
// ─── Vacation hover ───────────────────────────────────────────────────────
|
// ─── Vacation hover ───────────────────────────────────────────────────────
|
||||||
const handleRowVacationHover = useCallback(
|
const handleRowVacationHover = useCallback(
|
||||||
(e: React.MouseEvent, resourceId: string) => {
|
(e: React.MouseEvent, resourceId: string) => {
|
||||||
updateTooltipPosition(vacationTooltipPosRef, vacationTooltipRef, e.clientX, e.clientY, 14, -8);
|
updateTooltipPosition(
|
||||||
|
vacationTooltipPosRef,
|
||||||
|
vacationTooltipRef,
|
||||||
|
e.clientX,
|
||||||
|
e.clientY,
|
||||||
|
14,
|
||||||
|
-8,
|
||||||
|
);
|
||||||
scheduleVacationHoverUpdate({
|
scheduleVacationHoverUpdate({
|
||||||
frameRef: vacationHoverRafRef,
|
frameRef: vacationHoverRafRef,
|
||||||
hoveredKeyRef: hoveredVacationKeyRef,
|
hoveredKeyRef: hoveredVacationKeyRef,
|
||||||
@@ -413,9 +402,10 @@ function TimelineResourcePanelInner({
|
|||||||
|
|
||||||
// Utilization background tint — only highlight over-utilization
|
// Utilization background tint — only highlight over-utilization
|
||||||
const utilPct = utilizationByResource.get(resource.id) ?? 0;
|
const utilPct = utilizationByResource.get(resource.id) ?? 0;
|
||||||
const utilBg = utilPct > 100
|
const utilBg =
|
||||||
? "rgba(254,202,202,0.18)" // red tint for over-utilized
|
utilPct > 100
|
||||||
: undefined;
|
? "rgba(254,202,202,0.18)" // red tint for over-utilized
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -518,20 +508,14 @@ function TimelineResourcePanelInner({
|
|||||||
onInlineEdit,
|
onInlineEdit,
|
||||||
scrollLeft,
|
scrollLeft,
|
||||||
containerWidth,
|
containerWidth,
|
||||||
optimisticAllocations.size > 0 ? new Set(optimisticAllocations.keys()) : undefined,
|
optimisticAllocations.size > 0
|
||||||
|
? new Set(optimisticAllocations.keys())
|
||||||
|
: undefined,
|
||||||
)}
|
)}
|
||||||
{filters.showVacations &&
|
{filters.showVacations &&
|
||||||
renderVacationBlocks(
|
renderVacationBlocks(vacationBlocksByResource.get(resource.id) ?? [], rowHeight)}
|
||||||
vacationBlocksByResource.get(resource.id) ?? [],
|
|
||||||
rowHeight,
|
|
||||||
)}
|
|
||||||
{displayMode === "strip" &&
|
{displayMode === "strip" &&
|
||||||
renderLoadGraph(
|
renderLoadGraph(allocs, dates, CELL_WIDTH, resourceCapacityById.get(resource.id))}
|
||||||
allocs,
|
|
||||||
dates,
|
|
||||||
CELL_WIDTH,
|
|
||||||
resourceCapacityById.get(resource.id),
|
|
||||||
)}
|
|
||||||
{displayMode === "heatmap" &&
|
{displayMode === "heatmap" &&
|
||||||
renderHeatmapOverlay(
|
renderHeatmapOverlay(
|
||||||
allocs,
|
allocs,
|
||||||
@@ -597,674 +581,8 @@ function TimelineResourcePanelInner({
|
|||||||
|
|
||||||
// ResourcePanelTooltips removed — now uses shared TimelineTooltip component
|
// 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; contextDate?: Date },
|
|
||||||
anchorX: number,
|
|
||||||
anchorY: number,
|
|
||||||
) => void,
|
|
||||||
multiSelectState: MultiSelectState,
|
|
||||||
suppressHoverInteractions: boolean,
|
|
||||||
onInlineEdit?: (allocationId: string, initialValues: { startDate: Date; endDate: Date; hoursPerDay: number }, barRect: DOMRect) => void,
|
|
||||||
scrollLeft = 0,
|
|
||||||
containerWidth = 1200,
|
|
||||||
pendingMutationIds?: ReadonlySet<string>,
|
|
||||||
) {
|
|
||||||
const OVERSCAN_PX = 10 * CELL_WIDTH;
|
|
||||||
const visibleLeft = scrollLeft - OVERSCAN_PX;
|
|
||||||
const visibleRight = scrollLeft + containerWidth + OVERSCAN_PX;
|
|
||||||
const anyDragActive = dragState.isDragging || allocDragState.isActive;
|
|
||||||
const selectedAllocationSet = new Set(multiSelectState.selectedAllocationIds);
|
|
||||||
|
|
||||||
function toUtcDay(value: Date): Date {
|
|
||||||
return new Date(Date.UTC(value.getFullYear(), value.getMonth(), value.getDate()));
|
|
||||||
}
|
|
||||||
|
|
||||||
function addUtcDays(value: Date, days: number): Date {
|
|
||||||
const next = new Date(value);
|
|
||||||
next.setUTCDate(next.getUTCDate() + days);
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveSegmentContextDate(
|
|
||||||
clientX: number,
|
|
||||||
rect: DOMRect,
|
|
||||||
segmentStart: Date,
|
|
||||||
segmentEnd: Date,
|
|
||||||
): Date {
|
|
||||||
const start = toUtcDay(segmentStart);
|
|
||||||
const end = toUtcDay(segmentEnd);
|
|
||||||
const rawIndex = Math.floor((clientX - rect.left) / CELL_WIDTH);
|
|
||||||
const maxIndex = Math.max(
|
|
||||||
0,
|
|
||||||
Math.round((end.getTime() - start.getTime()) / MILLISECONDS_PER_DAY),
|
|
||||||
);
|
|
||||||
const dayIndex = Math.min(Math.max(rawIndex, 0), maxIndex);
|
|
||||||
return addUtcDays(start, dayIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sameDate(a: Date | null, b: Date | null) {
|
|
||||||
return Boolean(a && b) && a!.getTime() === b!.getTime();
|
|
||||||
}
|
|
||||||
|
|
||||||
return blockData.flatMap(({ alloc, lane }) => {
|
|
||||||
const allocStart = toUtcDay(new Date(alloc.startDate));
|
|
||||||
const allocEnd = toUtcDay(new Date(alloc.endDate));
|
|
||||||
|
|
||||||
const isProjectShifted = dragState.isDragging && dragState.projectId === alloc.projectId;
|
|
||||||
|
|
||||||
let dispStart = allocStart;
|
|
||||||
let dispEnd = allocEnd;
|
|
||||||
if (isProjectShifted && dragState.currentStartDate && dragState.currentEndDate) {
|
|
||||||
dispStart = dragState.currentStartDate;
|
|
||||||
dispEnd = dragState.currentEndDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Multi-drag offset: shift selected allocations visually during multi-drag
|
|
||||||
const isMultiDragTarget =
|
|
||||||
multiSelectState.isMultiDragging &&
|
|
||||||
selectedAllocationSet.has(alloc.id);
|
|
||||||
const multiDragPx = isMultiDragTarget ? multiSelectState.multiDragDaysDelta * CELL_WIDTH : 0;
|
|
||||||
const multiDragMode = multiSelectState.multiDragMode;
|
|
||||||
|
|
||||||
const blockTop = 8 + lane * SUB_LANE_HEIGHT;
|
|
||||||
const blockHeight = SUB_LANE_HEIGHT - 8;
|
|
||||||
|
|
||||||
const customColor = (alloc.project as { color?: string | null }).color;
|
|
||||||
const projectColor = getProjectColor(alloc.projectId);
|
|
||||||
const blockBgColor = customColor ?? projectColor.hex + "B3";
|
|
||||||
const hasRecurrence = !!(alloc.metadata as Record<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,
|
|
||||||
allocationStartDate: allocStart,
|
|
||||||
allocationEndDate: allocEnd,
|
|
||||||
scope: "allocation",
|
|
||||||
};
|
|
||||||
|
|
||||||
const segments = buildAllocationWorkingDaySegments(
|
|
||||||
{ ...alloc, startDate: dispStart, endDate: dispEnd },
|
|
||||||
dispStart,
|
|
||||||
dispEnd,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (segments.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return segments.flatMap((segment, segmentIndex) => {
|
|
||||||
const isFirstSegment = segmentIndex === 0;
|
|
||||||
const segmentKey = `${alloc.id}-${segmentIndex}`;
|
|
||||||
const draggedSegmentActive =
|
|
||||||
allocDragState.isActive &&
|
|
||||||
allocDragState.allocationId === alloc.id &&
|
|
||||||
sameDate(allocDragState.originalStartDate, toUtcDay(segment.start)) &&
|
|
||||||
sameDate(allocDragState.originalEndDate, toUtcDay(segment.end));
|
|
||||||
const isBeingDragged = isProjectShifted || draggedSegmentActive;
|
|
||||||
const isOtherDragged = anyDragActive && !isBeingDragged;
|
|
||||||
|
|
||||||
let segmentLeft = toLeft(segment.start);
|
|
||||||
let segmentWidth = Math.max(CELL_WIDTH, toWidth(segment.start, segment.end));
|
|
||||||
let dragTransform: string | undefined;
|
|
||||||
|
|
||||||
if (isProjectShifted) {
|
|
||||||
const preview = applyPointerOffsetPreviewRect({
|
|
||||||
left: segmentLeft,
|
|
||||||
width: segmentWidth,
|
|
||||||
mode: "move",
|
|
||||||
pointerOffsetX: getDragPointerOffset(
|
|
||||||
dragState.pointerDeltaX,
|
|
||||||
dragState.daysDelta,
|
|
||||||
CELL_WIDTH,
|
|
||||||
),
|
|
||||||
minWidth: CELL_WIDTH,
|
|
||||||
});
|
|
||||||
segmentLeft = preview.left;
|
|
||||||
segmentWidth = preview.width;
|
|
||||||
dragTransform = preview.transform;
|
|
||||||
} else if (draggedSegmentActive) {
|
|
||||||
const preview = applyPointerOffsetPreviewRect({
|
|
||||||
left: segmentLeft,
|
|
||||||
width: segmentWidth,
|
|
||||||
mode: allocDragState.mode,
|
|
||||||
pointerOffsetX: getDragPointerOffset(
|
|
||||||
allocDragState.pointerDeltaX,
|
|
||||||
allocDragState.daysDelta,
|
|
||||||
CELL_WIDTH,
|
|
||||||
),
|
|
||||||
minWidth: CELL_WIDTH,
|
|
||||||
});
|
|
||||||
segmentLeft = preview.left;
|
|
||||||
segmentWidth = preview.width;
|
|
||||||
dragTransform = preview.transform;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isMultiDragTarget && multiDragMode === "resize-start") {
|
|
||||||
segmentLeft += multiDragPx;
|
|
||||||
segmentWidth = Math.max(CELL_WIDTH, segmentWidth - multiDragPx);
|
|
||||||
} else if (isMultiDragTarget && multiDragMode === "resize-end") {
|
|
||||||
segmentWidth = Math.max(CELL_WIDTH, segmentWidth + multiDragPx);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (segmentWidth <= 0 || segmentLeft >= totalCanvasWidth) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Horizontal virtualization: skip bars entirely outside the visible window
|
|
||||||
const effectiveRight = segmentLeft + segmentWidth;
|
|
||||||
if (effectiveRight < visibleLeft || segmentLeft > visibleRight) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleWidth = segmentWidth >= 48 ? 10 : 6;
|
|
||||||
const dragInset = Math.min(handleWidth, Math.max(2, Math.floor(segmentWidth / 4)));
|
|
||||||
const segmentInfo: AllocMouseDownInfo = {
|
|
||||||
...allocInfo,
|
|
||||||
startDate: toUtcDay(segment.start),
|
|
||||||
endDate: toUtcDay(segment.end),
|
|
||||||
scope: "segment",
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={segmentKey}
|
|
||||||
data-allocation-id={alloc.id}
|
|
||||||
data-timeline-entry-type="allocation"
|
|
||||||
data-timeline-drag-preview="project-shift allocation"
|
|
||||||
data-timeline-project-id={alloc.projectId}
|
|
||||||
data-allocation-segment-index={segmentIndex}
|
|
||||||
data-allocation-segment-start={toLocalDateKey(segment.start)}
|
|
||||||
data-allocation-segment-end={toLocalDateKey(segment.end)}
|
|
||||||
className={clsx(
|
|
||||||
"absolute text-white group/block",
|
|
||||||
hasRecurrence && "opacity-80",
|
|
||||||
isBeingDragged
|
|
||||||
? "opacity-90 z-20"
|
|
||||||
: isOtherDragged
|
|
||||||
? "opacity-30 z-[10]"
|
|
||||||
: "transition-[opacity] duration-75 z-[10]",
|
|
||||||
selectedAllocationSet.has(alloc.id) && "z-20",
|
|
||||||
pendingMutationIds?.has(alloc.id) && !isBeingDragged && "animate-pulse",
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
left: segmentLeft + 2,
|
|
||||||
width: segmentWidth - 4,
|
|
||||||
top: blockTop,
|
|
||||||
height: blockHeight,
|
|
||||||
...((multiDragPx && multiDragMode === "move") || dragTransform
|
|
||||||
? {
|
|
||||||
transform: [
|
|
||||||
dragTransform,
|
|
||||||
multiDragPx && multiDragMode === "move"
|
|
||||||
? `translateX(${multiDragPx}px)`
|
|
||||||
: null,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" "),
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
}}
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
if (e.button === 2) e.stopPropagation();
|
|
||||||
}}
|
|
||||||
onDoubleClick={(e) => {
|
|
||||||
if (suppressHoverInteractions || !onInlineEdit) return;
|
|
||||||
e.stopPropagation();
|
|
||||||
const toUtcDate = (v: Date | string) => {
|
|
||||||
const d = new Date(v);
|
|
||||||
return new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
|
|
||||||
};
|
|
||||||
onInlineEdit(
|
|
||||||
alloc.id,
|
|
||||||
{
|
|
||||||
startDate: toUtcDate(alloc.startDate),
|
|
||||||
endDate: toUtcDate(alloc.endDate),
|
|
||||||
hoursPerDay: alloc.hoursPerDay,
|
|
||||||
},
|
|
||||||
e.currentTarget.getBoundingClientRect(),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
onContextMenu={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
if (suppressHoverInteractions) return;
|
|
||||||
onAllocationContextMenu(
|
|
||||||
{
|
|
||||||
allocationId: getPlanningEntryMutationId(alloc),
|
|
||||||
projectId: alloc.projectId,
|
|
||||||
contextDate: resolveSegmentContextDate(
|
|
||||||
e.clientX,
|
|
||||||
e.currentTarget.getBoundingClientRect(),
|
|
||||||
segment.start,
|
|
||||||
segment.end,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
e.clientX,
|
|
||||||
e.clientY,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
data-allocation-handle="start"
|
|
||||||
className="absolute inset-y-0 left-0 z-30 flex items-center justify-center cursor-ew-resize rounded-l-md hover:bg-black/15 transition-colors"
|
|
||||||
style={{ width: handleWidth }}
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onAllocMouseDown(e, { ...segmentInfo, mode: "resize-start" });
|
|
||||||
}}
|
|
||||||
onTouchStart={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onAllocTouchStart(e, { ...segmentInfo, mode: "resize-start" });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{handleWidth >= 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
|
|
||||||
aria-hidden="true"
|
|
||||||
className={clsx(
|
|
||||||
"pointer-events-none absolute inset-0 z-0 rounded-md overflow-hidden",
|
|
||||||
hasRecurrence && "border-2 border-dashed border-white/60",
|
|
||||||
isBeingDragged
|
|
||||||
? "shadow-2xl ring-2 ring-white ring-offset-1 scale-[1.01]"
|
|
||||||
: "hover:ring-2 hover:ring-white hover:ring-offset-1",
|
|
||||||
selectedAllocationSet.has(alloc.id) && "ring-2 ring-sky-500 ring-offset-1",
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
backgroundColor: blockBgColor,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
data-allocation-interaction="body"
|
|
||||||
className={clsx(
|
|
||||||
"absolute inset-y-0 z-20 flex min-w-0 select-none items-center gap-1 px-1 pointer-events-auto",
|
|
||||||
isBeingDragged ? "cursor-grabbing" : "cursor-grab",
|
|
||||||
)}
|
|
||||||
style={{ left: dragInset, right: dragInset }}
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onAllocMouseDown(e, segmentInfo);
|
|
||||||
}}
|
|
||||||
onTouchStart={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onAllocTouchStart(e, segmentInfo);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{hasRecurrence && segmentWidth > 28 && (
|
|
||||||
<span className="text-[10px] opacity-80 flex-shrink-0">↻</span>
|
|
||||||
)}
|
|
||||||
{isFirstSegment && segmentWidth > 60 ? (
|
|
||||||
<span className="text-xs font-semibold truncate">{alloc.project.name}</span>
|
|
||||||
) : isFirstSegment ? (
|
|
||||||
<span className="text-[9px] font-bold truncate opacity-90">{alloc.project.shortCode}</span>
|
|
||||||
) : null}
|
|
||||||
{isFirstSegment && segmentWidth > 130 && (
|
|
||||||
<span className="text-[10px] opacity-75 truncate">{alloc.role}</span>
|
|
||||||
)}
|
|
||||||
{isFirstSegment && segmentWidth > 190 && (
|
|
||||||
<span className="text-[10px] opacity-60 truncate">{alloc.hoursPerDay}h</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
data-allocation-handle="end"
|
|
||||||
className="absolute inset-y-0 right-0 z-30 flex items-center justify-center cursor-ew-resize rounded-r-md hover:bg-black/15 transition-colors"
|
|
||||||
style={{ width: handleWidth }}
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onAllocMouseDown(e, { ...segmentInfo, mode: "resize-end" });
|
|
||||||
}}
|
|
||||||
onTouchStart={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onAllocTouchStart(e, { ...segmentInfo, mode: "resize-end" });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{handleWidth >= 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,
|
|
||||||
capacity: ResourceCapacitySeries | undefined,
|
|
||||||
) {
|
|
||||||
const GRAPH_H = 12;
|
|
||||||
|
|
||||||
function hoursOnDay(list: TimelineAssignmentEntry[], t: number) {
|
|
||||||
const currentDate = new Date(t);
|
|
||||||
return list.reduce((sum, a) => {
|
|
||||||
return isAllocationScheduledOnDate(a, currentDate) ? 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 bookingFactor = capacity?.bookingFactorsByDay[i] ?? 1;
|
|
||||||
const capacityHours = capacity?.capacityHoursByDay[i] ?? 8;
|
|
||||||
const totalH = hoursOnDay(allocs, t) * bookingFactor;
|
|
||||||
if (totalH === 0) return null;
|
|
||||||
|
|
||||||
const totalBarH =
|
|
||||||
capacityHours > 0
|
|
||||||
? Math.min(GRAPH_H, Math.round((Math.min(totalH, capacityHours) / capacityHours) * GRAPH_H))
|
|
||||||
: GRAPH_H;
|
|
||||||
const utilizationPct = capacityHours > 0 ? (totalH / capacityHours) * 100 : 100;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className={clsx(
|
|
||||||
"absolute bottom-0 rounded-t-sm",
|
|
||||||
utilizationPct > 100
|
|
||||||
? "bg-red-500 opacity-80"
|
|
||||||
: utilizationPct > 75
|
|
||||||
? "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,
|
|
||||||
capacity: ResourceCapacitySeries | undefined,
|
|
||||||
) {
|
|
||||||
return dates.map((date, i) => {
|
|
||||||
const bookingFactor = capacity?.bookingFactorsByDay[i] ?? 1;
|
|
||||||
const capacityHours = capacity?.capacityHoursByDay[i] ?? 8;
|
|
||||||
const totalH = allocs.reduce((sum, a) => {
|
|
||||||
return isAllocationScheduledOnDate(a, date) ? sum + a.hoursPerDay * bookingFactor : sum;
|
|
||||||
}, 0);
|
|
||||||
const pct = capacityHours > 0 ? (totalH / capacityHours) * 100 : totalH > 0 ? 100 : 0;
|
|
||||||
const bg = heatmapBgColor(pct, 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; contextDate?: Date },
|
|
||||||
anchorX: number,
|
|
||||||
anchorY: number,
|
|
||||||
) => void,
|
|
||||||
toLeft: (d: Date) => number,
|
|
||||||
toWidth: (s: Date, e: Date) => number,
|
|
||||||
totalCanvasWidth: number,
|
|
||||||
capacity: ResourceCapacitySeries | undefined,
|
|
||||||
multiSelectState: MultiSelectState,
|
|
||||||
suppressHoverInteractions: boolean,
|
|
||||||
) {
|
|
||||||
const BAR_AREA = rowHeight - 8;
|
|
||||||
const selectedAllocationSet = new Set(multiSelectState.selectedAllocationIds);
|
|
||||||
|
|
||||||
return dates.flatMap((date, i) => {
|
|
||||||
const dayTimestamp = date.getTime();
|
|
||||||
const covering = allocs.filter((a) => {
|
|
||||||
const isDragged = allocDragState.isActive && allocDragState.allocationId === a.id;
|
|
||||||
return isAllocationScheduledOnDate(
|
|
||||||
{
|
|
||||||
...a,
|
|
||||||
startDate:
|
|
||||||
isDragged && allocDragState.currentStartDate
|
|
||||||
? allocDragState.currentStartDate
|
|
||||||
: a.startDate,
|
|
||||||
endDate:
|
|
||||||
isDragged && allocDragState.currentEndDate
|
|
||||||
? allocDragState.currentEndDate
|
|
||||||
: a.endDate,
|
|
||||||
},
|
|
||||||
date,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (covering.length === 0) return [];
|
|
||||||
|
|
||||||
const bookingFactor = capacity?.bookingFactorsByDay[i] ?? 1;
|
|
||||||
const capacityHours = capacity?.capacityHoursByDay[i] ?? 8;
|
|
||||||
const totalH = covering.reduce((sum, a) => sum + a.hoursPerDay * bookingFactor, 0);
|
|
||||||
const isOver = totalH > capacityHours;
|
|
||||||
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 effectiveHours = alloc.hoursPerDay * bookingFactor;
|
|
||||||
if (effectiveHours <= 0 || capacityHours <= 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const segH = Math.max(
|
|
||||||
2,
|
|
||||||
Math.min(
|
|
||||||
BAR_AREA - stackedH,
|
|
||||||
Math.round(
|
|
||||||
((capacityHours > 0 ? effectiveHours / capacityHours : effectiveHours > 0 ? 1 : 0) *
|
|
||||||
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 = dayTimestamp === dispStart.getTime();
|
|
||||||
const isLastDay = dayTimestamp === dispEnd.getTime();
|
|
||||||
const EDGE_W = CELL_WIDTH >= 16 ? 4 : 0;
|
|
||||||
const dragPointerOffset = isBeingDragged
|
|
||||||
? getDragPointerOffset(
|
|
||||||
allocDragState.pointerDeltaX,
|
|
||||||
allocDragState.daysDelta,
|
|
||||||
CELL_WIDTH,
|
|
||||||
)
|
|
||||||
: 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}`}
|
|
||||||
data-allocation-id={alloc.id}
|
|
||||||
data-timeline-entry-type="allocation"
|
|
||||||
data-timeline-drag-preview="allocation"
|
|
||||||
data-timeline-project-id={alloc.projectId}
|
|
||||||
className={clsx(
|
|
||||||
"absolute rounded-sm flex items-stretch overflow-hidden",
|
|
||||||
isBeingDragged
|
|
||||||
? "opacity-90 ring-2 ring-white ring-offset-1 z-20"
|
|
||||||
: "transition-opacity duration-75 hover:opacity-80 z-[10]",
|
|
||||||
selectedAllocationSet.has(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 &&
|
|
||||||
selectedAllocationSet.has(alloc.id)) ||
|
|
||||||
dragPointerOffset
|
|
||||||
? {
|
|
||||||
transform: [
|
|
||||||
dragPointerOffset ? `translateX(${dragPointerOffset}px)` : null,
|
|
||||||
multiSelectState.isMultiDragging &&
|
|
||||||
selectedAllocationSet.has(alloc.id)
|
|
||||||
? `translateX(${multiSelectState.multiDragDaysDelta * CELL_WIDTH}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,
|
|
||||||
contextDate: new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())),
|
|
||||||
},
|
|
||||||
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.filter(Boolean);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TimelineResourcePanel = memo(TimelineResourcePanelInner);
|
export const TimelineResourcePanel = memo(TimelineResourcePanelInner);
|
||||||
|
|
||||||
// ─── Re-export tooltip types for the parent ─────────────────────────────────
|
// ─── Re-export types for consumers ──────────────────────────────────────────
|
||||||
export type { AllocBlockData };
|
export type { AllocBlockData, AllocMouseDownInfo } from "./timelineResourceRender.js";
|
||||||
export type { VacationBlockInfo } from "./renderHelpers.js";
|
export type { VacationBlockInfo } from "./renderHelpers.js";
|
||||||
|
|||||||
@@ -0,0 +1,700 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { MILLISECONDS_PER_DAY } from "@capakraken/shared";
|
||||||
|
import { clsx } from "clsx";
|
||||||
|
import React from "react";
|
||||||
|
import type { TimelineAssignmentEntry } from "./TimelineContext.js";
|
||||||
|
import { applyPointerOffsetPreviewRect, getDragPointerOffset } from "./allocationVisualState.js";
|
||||||
|
import { heatmapBgColor } from "./heatmapUtils.js";
|
||||||
|
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
|
||||||
|
import { SUB_LANE_HEIGHT } from "./timelineConstants.js";
|
||||||
|
import { getProjectColor } from "~/lib/project-colors.js";
|
||||||
|
import type { DragState, AllocDragState, MultiSelectState } from "~/hooks/useTimelineDrag.js";
|
||||||
|
import type { HeatmapColorScheme } from "~/hooks/useAppPreferences.js";
|
||||||
|
import type { ResourceCapacitySeries } from "./timelineCapacity.js";
|
||||||
|
import {
|
||||||
|
buildAllocationWorkingDaySegments,
|
||||||
|
isAllocationScheduledOnDate,
|
||||||
|
toLocalDateKey,
|
||||||
|
} from "./timelineAvailability.js";
|
||||||
|
// ─── Shared interaction types ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface AllocMouseDownInfo {
|
||||||
|
mode: "move" | "resize-start" | "resize-end";
|
||||||
|
allocationId: string;
|
||||||
|
mutationAllocationId: string;
|
||||||
|
projectId: string;
|
||||||
|
projectName: string;
|
||||||
|
resourceId: string | null;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
allocationStartDate?: Date;
|
||||||
|
allocationEndDate?: Date;
|
||||||
|
scope?: "allocation" | "segment";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helper types ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface AllocBlockData {
|
||||||
|
alloc: TimelineAssignmentEntry;
|
||||||
|
lane: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Pure render functions (no hooks, extracted from TimelineResourcePanel) ───
|
||||||
|
|
||||||
|
export function renderAllocBlocksFromData(
|
||||||
|
blockData: AllocBlockData[],
|
||||||
|
_allocs: TimelineAssignmentEntry[],
|
||||||
|
dragState: DragState,
|
||||||
|
allocDragState: AllocDragState,
|
||||||
|
toLeft: (d: Date) => number,
|
||||||
|
toWidth: (s: Date, e: Date) => number,
|
||||||
|
CELL_WIDTH: number,
|
||||||
|
totalCanvasWidth: number,
|
||||||
|
onAllocMouseDown: (e: React.MouseEvent, info: AllocMouseDownInfo) => void,
|
||||||
|
onAllocTouchStart: (e: React.TouchEvent, info: AllocMouseDownInfo) => void,
|
||||||
|
onAllocationContextMenu: (
|
||||||
|
info: { allocationId: string; projectId: string; contextDate?: Date },
|
||||||
|
anchorX: number,
|
||||||
|
anchorY: number,
|
||||||
|
) => void,
|
||||||
|
multiSelectState: MultiSelectState,
|
||||||
|
suppressHoverInteractions: boolean,
|
||||||
|
onInlineEdit?: (
|
||||||
|
allocationId: string,
|
||||||
|
initialValues: { startDate: Date; endDate: Date; hoursPerDay: number },
|
||||||
|
barRect: DOMRect,
|
||||||
|
) => void,
|
||||||
|
scrollLeft = 0,
|
||||||
|
containerWidth = 1200,
|
||||||
|
pendingMutationIds?: ReadonlySet<string>,
|
||||||
|
) {
|
||||||
|
const OVERSCAN_PX = 10 * CELL_WIDTH;
|
||||||
|
const visibleLeft = scrollLeft - OVERSCAN_PX;
|
||||||
|
const visibleRight = scrollLeft + containerWidth + OVERSCAN_PX;
|
||||||
|
const anyDragActive = dragState.isDragging || allocDragState.isActive;
|
||||||
|
const selectedAllocationSet = new Set(multiSelectState.selectedAllocationIds);
|
||||||
|
|
||||||
|
function toUtcDay(value: Date): Date {
|
||||||
|
return new Date(Date.UTC(value.getFullYear(), value.getMonth(), value.getDate()));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addUtcDays(value: Date, days: number): Date {
|
||||||
|
const next = new Date(value);
|
||||||
|
next.setUTCDate(next.getUTCDate() + days);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSegmentContextDate(
|
||||||
|
clientX: number,
|
||||||
|
rect: DOMRect,
|
||||||
|
segmentStart: Date,
|
||||||
|
segmentEnd: Date,
|
||||||
|
): Date {
|
||||||
|
const start = toUtcDay(segmentStart);
|
||||||
|
const end = toUtcDay(segmentEnd);
|
||||||
|
const rawIndex = Math.floor((clientX - rect.left) / CELL_WIDTH);
|
||||||
|
const maxIndex = Math.max(
|
||||||
|
0,
|
||||||
|
Math.round((end.getTime() - start.getTime()) / MILLISECONDS_PER_DAY),
|
||||||
|
);
|
||||||
|
const dayIndex = Math.min(Math.max(rawIndex, 0), maxIndex);
|
||||||
|
return addUtcDays(start, dayIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameDate(a: Date | null, b: Date | null) {
|
||||||
|
return Boolean(a && b) && a!.getTime() === b!.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
return blockData.flatMap(({ alloc, lane }) => {
|
||||||
|
const allocStart = toUtcDay(new Date(alloc.startDate));
|
||||||
|
const allocEnd = toUtcDay(new Date(alloc.endDate));
|
||||||
|
|
||||||
|
const isProjectShifted = dragState.isDragging && dragState.projectId === alloc.projectId;
|
||||||
|
|
||||||
|
let dispStart = allocStart;
|
||||||
|
let dispEnd = allocEnd;
|
||||||
|
if (isProjectShifted && dragState.currentStartDate && dragState.currentEndDate) {
|
||||||
|
dispStart = dragState.currentStartDate;
|
||||||
|
dispEnd = dragState.currentEndDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-drag offset: shift selected allocations visually during multi-drag
|
||||||
|
const isMultiDragTarget =
|
||||||
|
multiSelectState.isMultiDragging && selectedAllocationSet.has(alloc.id);
|
||||||
|
const multiDragPx = isMultiDragTarget ? multiSelectState.multiDragDaysDelta * CELL_WIDTH : 0;
|
||||||
|
const multiDragMode = multiSelectState.multiDragMode;
|
||||||
|
|
||||||
|
const blockTop = 8 + lane * SUB_LANE_HEIGHT;
|
||||||
|
const blockHeight = SUB_LANE_HEIGHT - 8;
|
||||||
|
|
||||||
|
const customColor = (alloc.project as { color?: string | null }).color;
|
||||||
|
const projectColor = getProjectColor(alloc.projectId);
|
||||||
|
const blockBgColor = customColor ?? projectColor.hex + "B3";
|
||||||
|
const hasRecurrence = !!(alloc.metadata as Record<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,
|
||||||
|
allocationStartDate: allocStart,
|
||||||
|
allocationEndDate: allocEnd,
|
||||||
|
scope: "allocation",
|
||||||
|
};
|
||||||
|
|
||||||
|
const segments = buildAllocationWorkingDaySegments(
|
||||||
|
{ ...alloc, startDate: dispStart, endDate: dispEnd },
|
||||||
|
dispStart,
|
||||||
|
dispEnd,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (segments.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments.flatMap((segment, segmentIndex) => {
|
||||||
|
const isFirstSegment = segmentIndex === 0;
|
||||||
|
const segmentKey = `${alloc.id}-${segmentIndex}`;
|
||||||
|
const draggedSegmentActive =
|
||||||
|
allocDragState.isActive &&
|
||||||
|
allocDragState.allocationId === alloc.id &&
|
||||||
|
sameDate(allocDragState.originalStartDate, toUtcDay(segment.start)) &&
|
||||||
|
sameDate(allocDragState.originalEndDate, toUtcDay(segment.end));
|
||||||
|
const isBeingDragged = isProjectShifted || draggedSegmentActive;
|
||||||
|
const isOtherDragged = anyDragActive && !isBeingDragged;
|
||||||
|
|
||||||
|
let segmentLeft = toLeft(segment.start);
|
||||||
|
let segmentWidth = Math.max(CELL_WIDTH, toWidth(segment.start, segment.end));
|
||||||
|
let dragTransform: string | undefined;
|
||||||
|
|
||||||
|
if (isProjectShifted) {
|
||||||
|
const preview = applyPointerOffsetPreviewRect({
|
||||||
|
left: segmentLeft,
|
||||||
|
width: segmentWidth,
|
||||||
|
mode: "move",
|
||||||
|
pointerOffsetX: getDragPointerOffset(
|
||||||
|
dragState.pointerDeltaX,
|
||||||
|
dragState.daysDelta,
|
||||||
|
CELL_WIDTH,
|
||||||
|
),
|
||||||
|
minWidth: CELL_WIDTH,
|
||||||
|
});
|
||||||
|
segmentLeft = preview.left;
|
||||||
|
segmentWidth = preview.width;
|
||||||
|
dragTransform = preview.transform;
|
||||||
|
} else if (draggedSegmentActive) {
|
||||||
|
const preview = applyPointerOffsetPreviewRect({
|
||||||
|
left: segmentLeft,
|
||||||
|
width: segmentWidth,
|
||||||
|
mode: allocDragState.mode,
|
||||||
|
pointerOffsetX: getDragPointerOffset(
|
||||||
|
allocDragState.pointerDeltaX,
|
||||||
|
allocDragState.daysDelta,
|
||||||
|
CELL_WIDTH,
|
||||||
|
),
|
||||||
|
minWidth: CELL_WIDTH,
|
||||||
|
});
|
||||||
|
segmentLeft = preview.left;
|
||||||
|
segmentWidth = preview.width;
|
||||||
|
dragTransform = preview.transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMultiDragTarget && multiDragMode === "resize-start") {
|
||||||
|
segmentLeft += multiDragPx;
|
||||||
|
segmentWidth = Math.max(CELL_WIDTH, segmentWidth - multiDragPx);
|
||||||
|
} else if (isMultiDragTarget && multiDragMode === "resize-end") {
|
||||||
|
segmentWidth = Math.max(CELL_WIDTH, segmentWidth + multiDragPx);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segmentWidth <= 0 || segmentLeft >= totalCanvasWidth) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontal virtualization: skip bars entirely outside the visible window
|
||||||
|
const effectiveRight = segmentLeft + segmentWidth;
|
||||||
|
if (effectiveRight < visibleLeft || segmentLeft > visibleRight) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleWidth = segmentWidth >= 48 ? 10 : 6;
|
||||||
|
const dragInset = Math.min(handleWidth, Math.max(2, Math.floor(segmentWidth / 4)));
|
||||||
|
const segmentInfo: AllocMouseDownInfo = {
|
||||||
|
...allocInfo,
|
||||||
|
startDate: toUtcDay(segment.start),
|
||||||
|
endDate: toUtcDay(segment.end),
|
||||||
|
scope: "segment",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={segmentKey}
|
||||||
|
data-allocation-id={alloc.id}
|
||||||
|
data-timeline-entry-type="allocation"
|
||||||
|
data-timeline-drag-preview="project-shift allocation"
|
||||||
|
data-timeline-project-id={alloc.projectId}
|
||||||
|
data-allocation-segment-index={segmentIndex}
|
||||||
|
data-allocation-segment-start={toLocalDateKey(segment.start)}
|
||||||
|
data-allocation-segment-end={toLocalDateKey(segment.end)}
|
||||||
|
className={clsx(
|
||||||
|
"absolute text-white group/block",
|
||||||
|
hasRecurrence && "opacity-80",
|
||||||
|
isBeingDragged
|
||||||
|
? "opacity-90 z-20"
|
||||||
|
: isOtherDragged
|
||||||
|
? "opacity-30 z-[10]"
|
||||||
|
: "transition-[opacity] duration-75 z-[10]",
|
||||||
|
selectedAllocationSet.has(alloc.id) && "z-20",
|
||||||
|
pendingMutationIds?.has(alloc.id) && !isBeingDragged && "animate-pulse",
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
left: segmentLeft + 2,
|
||||||
|
width: segmentWidth - 4,
|
||||||
|
top: blockTop,
|
||||||
|
height: blockHeight,
|
||||||
|
...((multiDragPx && multiDragMode === "move") || dragTransform
|
||||||
|
? {
|
||||||
|
transform: [
|
||||||
|
dragTransform,
|
||||||
|
multiDragPx && multiDragMode === "move" ? `translateX(${multiDragPx}px)` : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" "),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
if (e.button === 2) e.stopPropagation();
|
||||||
|
}}
|
||||||
|
onDoubleClick={(e) => {
|
||||||
|
if (suppressHoverInteractions || !onInlineEdit) return;
|
||||||
|
e.stopPropagation();
|
||||||
|
const toUtcDate = (v: Date | string) => {
|
||||||
|
const d = new Date(v);
|
||||||
|
return new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
|
||||||
|
};
|
||||||
|
onInlineEdit(
|
||||||
|
alloc.id,
|
||||||
|
{
|
||||||
|
startDate: toUtcDate(alloc.startDate),
|
||||||
|
endDate: toUtcDate(alloc.endDate),
|
||||||
|
hoursPerDay: alloc.hoursPerDay,
|
||||||
|
},
|
||||||
|
e.currentTarget.getBoundingClientRect(),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (suppressHoverInteractions) return;
|
||||||
|
onAllocationContextMenu(
|
||||||
|
{
|
||||||
|
allocationId: getPlanningEntryMutationId(alloc),
|
||||||
|
projectId: alloc.projectId,
|
||||||
|
contextDate: resolveSegmentContextDate(
|
||||||
|
e.clientX,
|
||||||
|
e.currentTarget.getBoundingClientRect(),
|
||||||
|
segment.start,
|
||||||
|
segment.end,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
e.clientX,
|
||||||
|
e.clientY,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-allocation-handle="start"
|
||||||
|
className="absolute inset-y-0 left-0 z-30 flex items-center justify-center cursor-ew-resize rounded-l-md hover:bg-black/15 transition-colors"
|
||||||
|
style={{ width: handleWidth }}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAllocMouseDown(e, { ...segmentInfo, mode: "resize-start" });
|
||||||
|
}}
|
||||||
|
onTouchStart={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAllocTouchStart(e, { ...segmentInfo, mode: "resize-start" });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{handleWidth >= 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
|
||||||
|
aria-hidden="true"
|
||||||
|
className={clsx(
|
||||||
|
"pointer-events-none absolute inset-0 z-0 rounded-md overflow-hidden",
|
||||||
|
hasRecurrence && "border-2 border-dashed border-white/60",
|
||||||
|
isBeingDragged
|
||||||
|
? "shadow-2xl ring-2 ring-white ring-offset-1 scale-[1.01]"
|
||||||
|
: "hover:ring-2 hover:ring-white hover:ring-offset-1",
|
||||||
|
selectedAllocationSet.has(alloc.id) && "ring-2 ring-sky-500 ring-offset-1",
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: blockBgColor,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
data-allocation-interaction="body"
|
||||||
|
className={clsx(
|
||||||
|
"absolute inset-y-0 z-20 flex min-w-0 select-none items-center gap-1 px-1 pointer-events-auto",
|
||||||
|
isBeingDragged ? "cursor-grabbing" : "cursor-grab",
|
||||||
|
)}
|
||||||
|
style={{ left: dragInset, right: dragInset }}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAllocMouseDown(e, segmentInfo);
|
||||||
|
}}
|
||||||
|
onTouchStart={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAllocTouchStart(e, segmentInfo);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hasRecurrence && segmentWidth > 28 && (
|
||||||
|
<span className="text-[10px] opacity-80 flex-shrink-0">↻</span>
|
||||||
|
)}
|
||||||
|
{isFirstSegment && segmentWidth > 60 ? (
|
||||||
|
<span className="text-xs font-semibold truncate">{alloc.project.name}</span>
|
||||||
|
) : isFirstSegment ? (
|
||||||
|
<span className="text-[9px] font-bold truncate opacity-90">
|
||||||
|
{alloc.project.shortCode}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{isFirstSegment && segmentWidth > 130 && (
|
||||||
|
<span className="text-[10px] opacity-75 truncate">{alloc.role}</span>
|
||||||
|
)}
|
||||||
|
{isFirstSegment && segmentWidth > 190 && (
|
||||||
|
<span className="text-[10px] opacity-60 truncate">{alloc.hoursPerDay}h</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
data-allocation-handle="end"
|
||||||
|
className="absolute inset-y-0 right-0 z-30 flex items-center justify-center cursor-ew-resize rounded-r-md hover:bg-black/15 transition-colors"
|
||||||
|
style={{ width: handleWidth }}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAllocMouseDown(e, { ...segmentInfo, mode: "resize-end" });
|
||||||
|
}}
|
||||||
|
onTouchStart={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAllocTouchStart(e, { ...segmentInfo, mode: "resize-end" });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{handleWidth >= 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 ────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function renderLoadGraph(
|
||||||
|
allocs: TimelineAssignmentEntry[],
|
||||||
|
dates: Date[],
|
||||||
|
CELL_WIDTH: number,
|
||||||
|
capacity: ResourceCapacitySeries | undefined,
|
||||||
|
) {
|
||||||
|
const GRAPH_H = 12;
|
||||||
|
|
||||||
|
function hoursOnDay(list: TimelineAssignmentEntry[], t: number) {
|
||||||
|
const currentDate = new Date(t);
|
||||||
|
return list.reduce((sum, a) => {
|
||||||
|
return isAllocationScheduledOnDate(a, currentDate) ? 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 bookingFactor = capacity?.bookingFactorsByDay[i] ?? 1;
|
||||||
|
const capacityHours = capacity?.capacityHoursByDay[i] ?? 8;
|
||||||
|
const totalH = hoursOnDay(allocs, t) * bookingFactor;
|
||||||
|
if (totalH === 0) return null;
|
||||||
|
|
||||||
|
const totalBarH =
|
||||||
|
capacityHours > 0
|
||||||
|
? Math.min(
|
||||||
|
GRAPH_H,
|
||||||
|
Math.round((Math.min(totalH, capacityHours) / capacityHours) * GRAPH_H),
|
||||||
|
)
|
||||||
|
: GRAPH_H;
|
||||||
|
const utilizationPct = capacityHours > 0 ? (totalH / capacityHours) * 100 : 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={clsx(
|
||||||
|
"absolute bottom-0 rounded-t-sm",
|
||||||
|
utilizationPct > 100
|
||||||
|
? "bg-red-500 opacity-80"
|
||||||
|
: utilizationPct > 75
|
||||||
|
? "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 ────────────────────────────────
|
||||||
|
|
||||||
|
export function renderHeatmapOverlay(
|
||||||
|
allocs: TimelineAssignmentEntry[],
|
||||||
|
dates: Date[],
|
||||||
|
CELL_WIDTH: number,
|
||||||
|
heatmapScheme: HeatmapColorScheme,
|
||||||
|
capacity: ResourceCapacitySeries | undefined,
|
||||||
|
) {
|
||||||
|
return dates.map((date, i) => {
|
||||||
|
const bookingFactor = capacity?.bookingFactorsByDay[i] ?? 1;
|
||||||
|
const capacityHours = capacity?.capacityHoursByDay[i] ?? 8;
|
||||||
|
const totalH = allocs.reduce((sum, a) => {
|
||||||
|
return isAllocationScheduledOnDate(a, date) ? sum + a.hoursPerDay * bookingFactor : sum;
|
||||||
|
}, 0);
|
||||||
|
const pct = capacityHours > 0 ? (totalH / capacityHours) * 100 : totalH > 0 ? 100 : 0;
|
||||||
|
const bg = heatmapBgColor(pct, 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 ────────────────────────────────────────────
|
||||||
|
|
||||||
|
export 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; contextDate?: Date },
|
||||||
|
anchorX: number,
|
||||||
|
anchorY: number,
|
||||||
|
) => void,
|
||||||
|
toLeft: (d: Date) => number,
|
||||||
|
toWidth: (s: Date, e: Date) => number,
|
||||||
|
totalCanvasWidth: number,
|
||||||
|
capacity: ResourceCapacitySeries | undefined,
|
||||||
|
multiSelectState: MultiSelectState,
|
||||||
|
suppressHoverInteractions: boolean,
|
||||||
|
) {
|
||||||
|
const BAR_AREA = rowHeight - 8;
|
||||||
|
const selectedAllocationSet = new Set(multiSelectState.selectedAllocationIds);
|
||||||
|
|
||||||
|
return dates.flatMap((date, i) => {
|
||||||
|
const dayTimestamp = date.getTime();
|
||||||
|
const covering = allocs.filter((a) => {
|
||||||
|
const isDragged = allocDragState.isActive && allocDragState.allocationId === a.id;
|
||||||
|
return isAllocationScheduledOnDate(
|
||||||
|
{
|
||||||
|
...a,
|
||||||
|
startDate:
|
||||||
|
isDragged && allocDragState.currentStartDate
|
||||||
|
? allocDragState.currentStartDate
|
||||||
|
: a.startDate,
|
||||||
|
endDate:
|
||||||
|
isDragged && allocDragState.currentEndDate ? allocDragState.currentEndDate : a.endDate,
|
||||||
|
},
|
||||||
|
date,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (covering.length === 0) return [];
|
||||||
|
|
||||||
|
const bookingFactor = capacity?.bookingFactorsByDay[i] ?? 1;
|
||||||
|
const capacityHours = capacity?.capacityHoursByDay[i] ?? 8;
|
||||||
|
const totalH = covering.reduce((sum, a) => sum + a.hoursPerDay * bookingFactor, 0);
|
||||||
|
const isOver = totalH > capacityHours;
|
||||||
|
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 effectiveHours = alloc.hoursPerDay * bookingFactor;
|
||||||
|
if (effectiveHours <= 0 || capacityHours <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const segH = Math.max(
|
||||||
|
2,
|
||||||
|
Math.min(
|
||||||
|
BAR_AREA - stackedH,
|
||||||
|
Math.round(
|
||||||
|
(capacityHours > 0 ? effectiveHours / capacityHours : effectiveHours > 0 ? 1 : 0) *
|
||||||
|
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 = dayTimestamp === dispStart.getTime();
|
||||||
|
const isLastDay = dayTimestamp === dispEnd.getTime();
|
||||||
|
const EDGE_W = CELL_WIDTH >= 16 ? 4 : 0;
|
||||||
|
const dragPointerOffset = isBeingDragged
|
||||||
|
? getDragPointerOffset(allocDragState.pointerDeltaX, allocDragState.daysDelta, CELL_WIDTH)
|
||||||
|
: 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}`}
|
||||||
|
data-allocation-id={alloc.id}
|
||||||
|
data-timeline-entry-type="allocation"
|
||||||
|
data-timeline-drag-preview="allocation"
|
||||||
|
data-timeline-project-id={alloc.projectId}
|
||||||
|
className={clsx(
|
||||||
|
"absolute rounded-sm flex items-stretch overflow-hidden",
|
||||||
|
isBeingDragged
|
||||||
|
? "opacity-90 ring-2 ring-white ring-offset-1 z-20"
|
||||||
|
: "transition-opacity duration-75 hover:opacity-80 z-[10]",
|
||||||
|
selectedAllocationSet.has(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 && selectedAllocationSet.has(alloc.id)) ||
|
||||||
|
dragPointerOffset
|
||||||
|
? {
|
||||||
|
transform: [
|
||||||
|
dragPointerOffset ? `translateX(${dragPointerOffset}px)` : null,
|
||||||
|
multiSelectState.isMultiDragging && selectedAllocationSet.has(alloc.id)
|
||||||
|
? `translateX(${multiSelectState.multiDragDaysDelta * CELL_WIDTH}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,
|
||||||
|
contextDate: new Date(
|
||||||
|
Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
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.filter(Boolean);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user