1df208dbcc
Allocation bars that have active optimistic overrides (post-drag, awaiting server confirmation) now pulse subtly via animate-pulse. The pending set is derived from the existing optimisticAllocations map keys, requiring no additional state. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1211 lines
45 KiB
TypeScript
1211 lines
45 KiB
TypeScript
"use client";
|
|
|
|
import { clsx } from "clsx";
|
|
import { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
import type { CSSProperties } from "react";
|
|
import {
|
|
useTimelineContext,
|
|
type TimelineAssignmentEntry,
|
|
type TimelineDemandEntry,
|
|
} from "./TimelineContext.js";
|
|
import {
|
|
applyPointerOffsetPreviewRect,
|
|
applyVisualOverrides,
|
|
getDragPointerOffset,
|
|
type TimelineVisualOverrides,
|
|
} from "./allocationVisualState.js";
|
|
import { heatmapColor } from "./heatmapUtils.js";
|
|
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
|
|
import { formatDateLong } from "~/lib/format.js";
|
|
import {
|
|
TimelineTooltip,
|
|
type DemandHoverData,
|
|
type HeatmapHoverData,
|
|
type VacationHoverData,
|
|
} from "./TimelineTooltip.js";
|
|
import {
|
|
ROW_HEIGHT,
|
|
SUB_LANE_HEIGHT,
|
|
LABEL_WIDTH,
|
|
PROJECT_HEADER_HEIGHT,
|
|
ORDER_TYPE_COLORS,
|
|
} from "./timelineConstants.js";
|
|
import { getProjectColor } from "~/lib/project-colors.js";
|
|
import type { DragState, AllocDragState, RangeState, MultiSelectState } from "~/hooks/useTimelineDrag.js";
|
|
import type { AllocMouseDownInfo, RowMouseDownInfo } from "./TimelineResourcePanel.js";
|
|
import {
|
|
buildVacationBlocksByResource,
|
|
renderVacationBlocks,
|
|
renderRangeOverlay,
|
|
renderOverbookingBlink,
|
|
type VacationBlockInfo,
|
|
} from "./renderHelpers.js";
|
|
import {
|
|
buildDemandHoverData,
|
|
cancelHoverFrame,
|
|
collectResourcesWithVacations,
|
|
scheduleVacationHoverUpdate,
|
|
updateTooltipPosition,
|
|
} from "./timelineHover.js";
|
|
import { buildResourceHeatmapSeries } from "./timelineHeatmap.js";
|
|
import { buildResourceCapacitySeries } from "./timelineCapacity.js";
|
|
import {
|
|
buildProjectRowMetrics,
|
|
type ProjectDayMetric,
|
|
} from "./timelineProjectMetrics.js";
|
|
import {
|
|
buildProjectFlatRows,
|
|
estimateProjectRowHeight,
|
|
type OpenDemandRowLayout,
|
|
type ProjectFlatRow,
|
|
} from "./timelineProjectRows.js";
|
|
|
|
// ─── Props ──────────────────────────────────────────────────────────────────
|
|
|
|
interface TimelineProjectPanelProps {
|
|
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
|
|
dragState: DragState;
|
|
allocDragState: AllocDragState;
|
|
rangeState: RangeState;
|
|
onProjectBarMouseDown: (e: React.MouseEvent, info: ProjectBarInfo) => void;
|
|
onProjectBarTouchStart: (e: React.TouchEvent, info: ProjectBarInfo) => void;
|
|
onAllocMouseDown: (e: React.MouseEvent, info: AllocMouseDownInfo) => void;
|
|
onAllocTouchStart: (e: React.TouchEvent, info: AllocMouseDownInfo) => void;
|
|
onRowMouseDown: (e: React.MouseEvent, info: RowMouseDownInfo) => void;
|
|
onRowTouchStart: (e: React.TouchEvent, info: RowMouseDownInfo) => void;
|
|
onOpenPanel: (projectId: string) => void;
|
|
onOpenDemandClick: (demand: TimelineDemandEntry, anchorX: number, anchorY: number) => void;
|
|
onAllocationContextMenu: (
|
|
info: { allocationId: string; projectId: string; contextDate?: Date },
|
|
anchorX: number,
|
|
anchorY: number,
|
|
) => void;
|
|
multiSelectState: MultiSelectState;
|
|
optimisticAllocations: TimelineVisualOverrides;
|
|
suppressHoverInteractions: boolean;
|
|
// Layout from useTimelineLayout
|
|
CELL_WIDTH: number;
|
|
dates: Date[];
|
|
totalCanvasWidth: number;
|
|
toLeft: (date: Date) => number;
|
|
toWidth: (start: Date, end: Date) => number;
|
|
gridLines: React.ReactNode;
|
|
xToDate: (clientX: number, rect: DOMRect) => Date;
|
|
}
|
|
|
|
export interface ProjectBarInfo {
|
|
projectId: string;
|
|
projectName: string;
|
|
startDate: Date;
|
|
endDate: Date;
|
|
}
|
|
|
|
export interface OpenDemandAssignment {
|
|
id: string;
|
|
projectId: string;
|
|
roleId: string | null;
|
|
role: string | null;
|
|
headcount: number;
|
|
budgetCents?: number;
|
|
startDate: Date;
|
|
endDate: Date;
|
|
hoursPerDay: number;
|
|
roleEntity?: { id: string; name: string; color: string | null } | null;
|
|
project?: { id: string; name: string; shortCode: string };
|
|
}
|
|
|
|
type HeatmapHoverState = HeatmapHoverData;
|
|
|
|
const EMPTY_DAY_METRICS: ProjectDayMetric[] = [];
|
|
const SVG_XMLNS = "http://www.w3.org/2000/svg";
|
|
|
|
// ─── Component ──────────────────────────────────────────────────────────────
|
|
|
|
function TimelineProjectPanelInner({
|
|
scrollContainerRef,
|
|
dragState,
|
|
allocDragState,
|
|
rangeState,
|
|
onProjectBarMouseDown,
|
|
onProjectBarTouchStart,
|
|
onAllocMouseDown,
|
|
onAllocTouchStart,
|
|
onRowMouseDown,
|
|
onRowTouchStart,
|
|
onOpenPanel,
|
|
onOpenDemandClick,
|
|
onAllocationContextMenu,
|
|
multiSelectState,
|
|
optimisticAllocations,
|
|
suppressHoverInteractions,
|
|
CELL_WIDTH,
|
|
dates,
|
|
totalCanvasWidth,
|
|
toLeft,
|
|
toWidth,
|
|
gridLines,
|
|
xToDate,
|
|
}: TimelineProjectPanelProps) {
|
|
const {
|
|
projectGroups,
|
|
openDemandsByProject,
|
|
allocsByResource,
|
|
vacationsByResource,
|
|
filters,
|
|
displayMode,
|
|
heatmapScheme,
|
|
blinkOverbookedDays,
|
|
activeFilterCount,
|
|
today,
|
|
} = useTimelineContext();
|
|
|
|
const visualAllocsByResource = useMemo(() => {
|
|
if (optimisticAllocations.size === 0) return allocsByResource;
|
|
|
|
const next = new Map<string, TimelineAssignmentEntry[]>();
|
|
for (const [resourceId, allocs] of allocsByResource) {
|
|
next.set(resourceId, applyVisualOverrides(allocs, optimisticAllocations));
|
|
}
|
|
return next;
|
|
}, [allocsByResource, optimisticAllocations]);
|
|
|
|
const visualProjectGroups = useMemo(
|
|
() => projectGroups.map((project) => ({
|
|
...project,
|
|
resourceRows: project.resourceRows.map((row) => ({
|
|
...row,
|
|
allocs: applyVisualOverrides(row.allocs, optimisticAllocations),
|
|
})),
|
|
})),
|
|
[projectGroups, optimisticAllocations],
|
|
);
|
|
|
|
// ─── Heatmap hover (same mechanism as resource panel) ─────────────────────
|
|
const heatmapRafRef = useRef<number | null>(null);
|
|
const lastHeatmapDayRef = useRef<number>(-1);
|
|
const lastHeatmapResourceRef = useRef<string | null>(null);
|
|
const vacationHoverRafRef = useRef<number | null>(null);
|
|
const hoveredVacationKeyRef = useRef<string | null>(null);
|
|
const pendingHeatmapRef = useRef<{
|
|
clientX: number;
|
|
rect: DOMRect;
|
|
resourceId: string;
|
|
} | null>(null);
|
|
const heatmapTooltipRef = useRef<HTMLDivElement | null>(null);
|
|
const vacationTooltipRef = useRef<HTMLDivElement | null>(null);
|
|
const demandTooltipRef = useRef<HTMLDivElement | null>(null);
|
|
const heatmapTooltipPosRef = useRef({ left: 0, top: 0 });
|
|
const vacationTooltipPosRef = useRef({ left: 0, top: 0 });
|
|
const demandTooltipPosRef = useRef({ left: 0, top: 0 });
|
|
|
|
const [heatmapHover, setHeatmapHover] = useState<HeatmapHoverState | null>(null);
|
|
const [vacationHover, setVacationHover] = useState<VacationHoverData | null>(null);
|
|
const [demandHover, setDemandHover] = useState<DemandHoverData | null>(null);
|
|
|
|
const resourceCapacityById = useMemo(
|
|
() => buildResourceCapacitySeries(visualAllocsByResource, vacationsByResource, dates),
|
|
[dates, vacationsByResource, visualAllocsByResource],
|
|
);
|
|
|
|
const { resourceHeatmapById, resourceTotalHoursById } = useMemo(
|
|
() => buildResourceHeatmapSeries(visualAllocsByResource, dates, resourceCapacityById),
|
|
[dates, resourceCapacityById, visualAllocsByResource],
|
|
);
|
|
|
|
const vacationBlocksByResource = useMemo(
|
|
() =>
|
|
buildVacationBlocksByResource(
|
|
vacationsByResource,
|
|
filters.showVacations,
|
|
toLeft,
|
|
toWidth,
|
|
CELL_WIDTH,
|
|
totalCanvasWidth,
|
|
filters.showWeekends,
|
|
),
|
|
[CELL_WIDTH, filters.showVacations, filters.showWeekends, toLeft, toWidth, totalCanvasWidth, vacationsByResource],
|
|
);
|
|
|
|
const projectRowMetrics = useMemo(() => {
|
|
return buildProjectRowMetrics(
|
|
dates,
|
|
visualProjectGroups,
|
|
resourceTotalHoursById,
|
|
resourceCapacityById,
|
|
);
|
|
}, [dates, resourceCapacityById, resourceTotalHoursById, visualProjectGroups]);
|
|
|
|
const flatRows = useMemo(
|
|
() => buildProjectFlatRows(visualProjectGroups, openDemandsByProject, optimisticAllocations),
|
|
[openDemandsByProject, optimisticAllocations, visualProjectGroups],
|
|
);
|
|
|
|
const rowVirtualizer = useVirtualizer({
|
|
count: flatRows.length,
|
|
getScrollElement: () => scrollContainerRef.current,
|
|
estimateSize: (index) => estimateProjectRowHeight(flatRows[index]),
|
|
overscan: 8,
|
|
getItemKey: (index) => flatRows[index]?.key ?? index,
|
|
});
|
|
const virtualItems = rowVirtualizer.getVirtualItems();
|
|
const totalRowHeight = rowVirtualizer.getTotalSize();
|
|
|
|
const resourcesWithVacations = useMemo(
|
|
() => collectResourcesWithVacations(vacationsByResource),
|
|
[vacationsByResource],
|
|
);
|
|
|
|
const handleRowHeatmapMove = useCallback(
|
|
(e: React.MouseEvent, resourceId: string) => {
|
|
updateTooltipPosition(heatmapTooltipPosRef, heatmapTooltipRef, e.clientX, e.clientY, 16, -52);
|
|
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
const dayIndex = Math.floor((e.clientX - rect.left) / CELL_WIDTH);
|
|
if (
|
|
dayIndex === lastHeatmapDayRef.current &&
|
|
resourceId === lastHeatmapResourceRef.current
|
|
)
|
|
return;
|
|
|
|
pendingHeatmapRef.current = { clientX: e.clientX, rect, resourceId };
|
|
if (heatmapRafRef.current !== null) return;
|
|
|
|
heatmapRafRef.current = requestAnimationFrame(() => {
|
|
heatmapRafRef.current = null;
|
|
const pending = pendingHeatmapRef.current;
|
|
pendingHeatmapRef.current = null;
|
|
if (!pending) return;
|
|
|
|
const { clientX, rect: pendingRect, resourceId: pendingResourceId } = pending;
|
|
const nextDayIndex = Math.floor((clientX - pendingRect.left) / CELL_WIDTH);
|
|
const nextHeatmap = resourceHeatmapById.get(pendingResourceId)?.[nextDayIndex] ?? null;
|
|
if (nextDayIndex < 0 || nextDayIndex >= dates.length) {
|
|
lastHeatmapDayRef.current = -1;
|
|
lastHeatmapResourceRef.current = null;
|
|
startTransition(() => setHeatmapHover(null));
|
|
return;
|
|
}
|
|
|
|
lastHeatmapDayRef.current = nextDayIndex;
|
|
lastHeatmapResourceRef.current = pendingResourceId;
|
|
|
|
startTransition(() => {
|
|
setHeatmapHover(nextHeatmap);
|
|
});
|
|
});
|
|
},
|
|
[CELL_WIDTH, dates.length, resourceHeatmapById],
|
|
);
|
|
|
|
const handleRowVacationHover = useCallback(
|
|
(e: React.MouseEvent, resourceId: string) => {
|
|
if (!resourcesWithVacations.has(resourceId)) {
|
|
if (hoveredVacationKeyRef.current !== null) {
|
|
hoveredVacationKeyRef.current = null;
|
|
startTransition(() => {
|
|
setVacationHover(null);
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
updateTooltipPosition(vacationTooltipPosRef, vacationTooltipRef, e.clientX, e.clientY, 14, -8);
|
|
scheduleVacationHoverUpdate({
|
|
frameRef: vacationHoverRafRef,
|
|
hoveredKeyRef: hoveredVacationKeyRef,
|
|
resourceId,
|
|
clientX: e.clientX,
|
|
rect: e.currentTarget.getBoundingClientRect(),
|
|
xToDate,
|
|
vacations: vacationsByResource.get(resourceId) ?? [],
|
|
onHoverChange: (hit) => {
|
|
startTransition(() => {
|
|
setVacationHover(hit);
|
|
});
|
|
},
|
|
});
|
|
},
|
|
[resourcesWithVacations, vacationsByResource, xToDate],
|
|
);
|
|
|
|
const clearHoverTooltips = useCallback(() => {
|
|
cancelHoverFrame(heatmapRafRef);
|
|
cancelHoverFrame(vacationHoverRafRef);
|
|
|
|
const shouldClearHeatmap = lastHeatmapDayRef.current !== -1;
|
|
const shouldClearVacation = hoveredVacationKeyRef.current !== null;
|
|
const shouldClearDemand = demandHover !== null;
|
|
|
|
lastHeatmapDayRef.current = -1;
|
|
lastHeatmapResourceRef.current = null;
|
|
hoveredVacationKeyRef.current = null;
|
|
|
|
if (shouldClearHeatmap || shouldClearVacation || shouldClearDemand) {
|
|
startTransition(() => {
|
|
if (shouldClearHeatmap) setHeatmapHover(null);
|
|
if (shouldClearVacation) setVacationHover(null);
|
|
if (shouldClearDemand) setDemandHover(null);
|
|
});
|
|
}
|
|
}, [demandHover]);
|
|
|
|
const handleDemandHoverMove = useCallback(
|
|
(e: React.MouseEvent, demand: TimelineDemandEntry) => {
|
|
updateTooltipPosition(demandTooltipPosRef, demandTooltipRef, e.clientX, e.clientY, 16, -36);
|
|
|
|
startTransition(() => {
|
|
setDemandHover(buildDemandHoverData(demand));
|
|
});
|
|
},
|
|
[],
|
|
);
|
|
|
|
useEffect(
|
|
() => () => {
|
|
cancelHoverFrame(heatmapRafRef);
|
|
cancelHoverFrame(vacationHoverRafRef);
|
|
},
|
|
[],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!suppressHoverInteractions) return;
|
|
clearHoverTooltips();
|
|
}, [clearHoverTooltips, suppressHoverInteractions]);
|
|
|
|
if (visualProjectGroups.length === 0) {
|
|
return (
|
|
<div className="text-center py-16 text-gray-400">
|
|
No projects in this time range{activeFilterCount > 0 && " (filtered)"}.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
height: totalRowHeight,
|
|
position: "relative",
|
|
}}
|
|
>
|
|
{virtualItems.map((virtualRow) => {
|
|
const row = flatRows[virtualRow.index];
|
|
if (!row) return null;
|
|
|
|
return (
|
|
<div
|
|
key={row.key}
|
|
data-index={virtualRow.index}
|
|
ref={rowVirtualizer.measureElement}
|
|
style={{
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
width: "100%",
|
|
transform: `translateY(${virtualRow.start}px)`,
|
|
}}
|
|
>
|
|
{row.type === "header" ? (
|
|
(() => {
|
|
const { project } = row;
|
|
const customColor = project.color;
|
|
const projectColor = getProjectColor(project.id);
|
|
const colors = ORDER_TYPE_COLORS[project.orderType] ?? {
|
|
bg: "bg-gray-400",
|
|
text: "text-white",
|
|
light: "bg-gray-50 border-gray-200 dark:bg-gray-800 dark:border-gray-700",
|
|
};
|
|
const isThisProjectShifting =
|
|
dragState.isDragging && dragState.projectId === project.id;
|
|
const projDispStart =
|
|
isThisProjectShifting && dragState.currentStartDate
|
|
? dragState.currentStartDate
|
|
: project.startDate;
|
|
const projDispEnd =
|
|
isThisProjectShifting && dragState.currentEndDate
|
|
? dragState.currentEndDate
|
|
: project.endDate;
|
|
const projLeft = toLeft(projDispStart);
|
|
const projWidth = Math.max(CELL_WIDTH, toWidth(projDispStart, projDispEnd));
|
|
|
|
return (
|
|
<div
|
|
data-project-group="true"
|
|
className={clsx("flex border-b border-gray-200 dark:border-gray-700 group/proj", colors.light)}
|
|
style={{ height: PROJECT_HEADER_HEIGHT, borderLeft: `4px solid ${customColor ?? projectColor.hex}` }}
|
|
>
|
|
<div
|
|
className={clsx(
|
|
"flex-shrink-0 border-r border-gray-300 dark:border-gray-600 flex items-center px-4 gap-2.5 sticky left-0 z-30 cursor-pointer",
|
|
colors.light,
|
|
)}
|
|
style={{ width: LABEL_WIDTH }}
|
|
onClick={() => onOpenPanel(project.id)}
|
|
>
|
|
<div
|
|
className="w-3 h-3 rounded-full flex-shrink-0"
|
|
style={{ backgroundColor: customColor ?? projectColor.hex }}
|
|
/>
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-semibold text-gray-800 dark:text-gray-100 truncate">
|
|
{project.name}
|
|
</div>
|
|
<div className="text-xs text-gray-400 dark:text-gray-500">
|
|
{project.status}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
className="relative overflow-hidden"
|
|
style={{ width: totalCanvasWidth, height: PROJECT_HEADER_HEIGHT }}
|
|
>
|
|
{gridLines}
|
|
{projWidth > 0 && projLeft < totalCanvasWidth && (
|
|
<div
|
|
data-timeline-entry-type="project-bar"
|
|
data-timeline-drag-preview="project-shift"
|
|
data-timeline-project-id={project.id}
|
|
className={clsx(
|
|
"absolute rounded flex items-center px-2 gap-1.5 text-white",
|
|
isThisProjectShifting
|
|
? "opacity-90 shadow-lg ring-2 ring-white ring-offset-1 cursor-grabbing z-20 scale-[1.01]"
|
|
: "cursor-grab transition-[opacity,box-shadow] duration-75 hover:opacity-90 hover:ring-2 hover:ring-white hover:ring-offset-1",
|
|
)}
|
|
style={{
|
|
left: projLeft + 2,
|
|
width: projWidth - 4,
|
|
top: 8,
|
|
height: 24,
|
|
backgroundColor: customColor ?? projectColor.hex + "CC",
|
|
...(isThisProjectShifting
|
|
? {
|
|
transform: `translateX(${getDragPointerOffset(
|
|
dragState.pointerDeltaX,
|
|
dragState.daysDelta,
|
|
CELL_WIDTH,
|
|
)}px)`,
|
|
}
|
|
: {}),
|
|
}}
|
|
onClick={() => {
|
|
if (!dragState.isDragging) onOpenPanel(project.id);
|
|
}}
|
|
onMouseDown={(e) => {
|
|
if (e.button === 2) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (!dragState.isDragging) {
|
|
onOpenPanel(project.id);
|
|
}
|
|
return;
|
|
}
|
|
onProjectBarMouseDown(e, {
|
|
projectId: project.id,
|
|
projectName: project.name,
|
|
startDate: project.startDate,
|
|
endDate: project.endDate,
|
|
});
|
|
}}
|
|
onTouchStart={(e) =>
|
|
onProjectBarTouchStart(e, {
|
|
projectId: project.id,
|
|
projectName: project.name,
|
|
startDate: project.startDate,
|
|
endDate: project.endDate,
|
|
})
|
|
}
|
|
onContextMenu={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (!dragState.isDragging) {
|
|
onOpenPanel(project.id);
|
|
}
|
|
}}
|
|
>
|
|
<span className="text-xs font-semibold truncate">{project.name}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})()
|
|
) : row.type === "open-demand" ? (
|
|
renderOpenDemandRow(
|
|
row.openDemandCount,
|
|
row.layout,
|
|
row.projectId,
|
|
CELL_WIDTH,
|
|
totalCanvasWidth,
|
|
toLeft,
|
|
toWidth,
|
|
gridLines,
|
|
onOpenDemandClick,
|
|
onAllocMouseDown,
|
|
onAllocTouchStart,
|
|
onAllocationContextMenu,
|
|
handleDemandHoverMove,
|
|
clearHoverTooltips,
|
|
multiSelectState,
|
|
allocDragState,
|
|
suppressHoverInteractions,
|
|
)
|
|
) : (
|
|
<div
|
|
data-project-resource-row="true"
|
|
data-project-id={row.project.id}
|
|
data-resource-id={row.resource.id}
|
|
className="flex border-b border-gray-100 dark:border-gray-800 hover:bg-blue-50/20 dark:hover:bg-gray-800/30 group"
|
|
style={{ height: ROW_HEIGHT }}
|
|
>
|
|
<div
|
|
className="flex-shrink-0 border-r border-gray-200 dark:border-gray-700 flex items-center pl-8 pr-4 gap-2 bg-white dark:bg-gray-900 sticky left-0 z-30 group-hover:bg-blue-50 dark:group-hover:bg-gray-800"
|
|
style={{ width: LABEL_WIDTH }}
|
|
>
|
|
<div className="w-6 h-6 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center text-[10px] font-bold text-gray-600 dark:text-gray-300 flex-shrink-0">
|
|
{row.resource.displayName.slice(0, 2).toUpperCase()}
|
|
</div>
|
|
<div className="min-w-0" data-resource-hover-id={row.resource.id}>
|
|
<div className="text-xs font-medium text-gray-800 dark:text-gray-200 truncate cursor-pointer hover:text-brand-600 dark:hover:text-brand-400 transition-colors">
|
|
{row.resource.displayName}
|
|
</div>
|
|
<div className="text-[10px] text-gray-400 dark:text-gray-500 truncate">{row.resource.eid}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
data-testid="timeline-project-resource-row-canvas"
|
|
data-project-id={row.project.id}
|
|
data-resource-id={row.resource.id}
|
|
className="relative overflow-hidden touch-none"
|
|
style={{
|
|
width: totalCanvasWidth,
|
|
height: ROW_HEIGHT,
|
|
touchAction: "none",
|
|
}}
|
|
onMouseDown={(e) => {
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
const date = xToDate(e.clientX, rect);
|
|
onRowMouseDown(e, {
|
|
resourceId: row.resource.id,
|
|
startDate: date,
|
|
suggestedProjectId: row.project.id,
|
|
});
|
|
}}
|
|
onTouchStart={(e) => {
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
const date = xToDate(e.touches[0]?.clientX ?? 0, rect);
|
|
onRowTouchStart(e, {
|
|
resourceId: row.resource.id,
|
|
startDate: date,
|
|
suggestedProjectId: row.project.id,
|
|
});
|
|
}}
|
|
onMouseMove={(e) => {
|
|
if (suppressHoverInteractions) return;
|
|
handleRowHeatmapMove(e, row.resource.id);
|
|
handleRowVacationHover(e, row.resource.id);
|
|
}}
|
|
onMouseLeave={clearHoverTooltips}
|
|
>
|
|
{gridLines}
|
|
{renderProjectUtilOverlay(
|
|
projectRowMetrics.get(row.metricsKey) ?? EMPTY_DAY_METRICS,
|
|
CELL_WIDTH,
|
|
displayMode,
|
|
heatmapScheme,
|
|
totalCanvasWidth,
|
|
)}
|
|
{renderProjectDragHandles(
|
|
row.allocs,
|
|
allocDragState,
|
|
toLeft,
|
|
toWidth,
|
|
CELL_WIDTH,
|
|
totalCanvasWidth,
|
|
onAllocMouseDown,
|
|
onAllocTouchStart,
|
|
onAllocationContextMenu,
|
|
multiSelectState,
|
|
suppressHoverInteractions,
|
|
)}
|
|
{filters.showVacations &&
|
|
renderVacationBlocks(
|
|
vacationBlocksByResource.get(row.resource.id) ?? [],
|
|
ROW_HEIGHT,
|
|
)}
|
|
{blinkOverbookedDays &&
|
|
renderOverbookingBlink(
|
|
visualAllocsByResource.get(row.resource.id) ?? [],
|
|
dates,
|
|
CELL_WIDTH,
|
|
resourceCapacityById.get(row.resource.id)?.capacityHoursByDay,
|
|
resourceCapacityById.get(row.resource.id)?.bookingFactorsByDay,
|
|
)}
|
|
{renderRangeOverlay(
|
|
rangeState,
|
|
row.resource.id,
|
|
ROW_HEIGHT,
|
|
toLeft,
|
|
toWidth,
|
|
CELL_WIDTH,
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
<TimelineTooltip
|
|
heatmapTooltipRef={heatmapTooltipRef}
|
|
heatmapTooltipPos={heatmapTooltipPosRef.current}
|
|
vacationTooltipRef={vacationTooltipRef}
|
|
vacationTooltipPos={vacationTooltipPosRef.current}
|
|
demandTooltipRef={demandTooltipRef}
|
|
demandTooltipPos={demandTooltipPosRef.current}
|
|
heatmapHover={heatmapHover}
|
|
vacationHover={vacationHover}
|
|
demandHover={demandHover}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ProjectPanelTooltips removed — now uses shared TimelineTooltip component
|
|
|
|
// ─── Pure render functions ──────────────────────────────────────────────────
|
|
|
|
function renderOpenDemandRow(
|
|
openDemandCount: number,
|
|
layout: OpenDemandRowLayout,
|
|
projectId: string,
|
|
CELL_WIDTH: number,
|
|
totalCanvasWidth: number,
|
|
toLeft: (d: Date) => number,
|
|
toWidth: (s: Date, e: Date) => number,
|
|
rowGridLines: React.ReactNode,
|
|
onOpenDemandClick: (demand: TimelineDemandEntry, anchorX: number, anchorY: number) => void,
|
|
onAllocMouseDown: (e: React.MouseEvent, info: AllocMouseDownInfo) => void,
|
|
onAllocTouchStart: (e: React.TouchEvent, info: AllocMouseDownInfo) => void,
|
|
onAllocationContextMenu: (
|
|
info: { allocationId: string; projectId: string; contextDate?: Date },
|
|
anchorX: number,
|
|
anchorY: number,
|
|
) => void,
|
|
onDemandHoverMove: (e: React.MouseEvent, demand: TimelineDemandEntry) => void,
|
|
onClearHoverTooltips: () => void,
|
|
multiSelectState: MultiSelectState,
|
|
allocDragState: AllocDragState,
|
|
suppressHoverInteractions: boolean,
|
|
) {
|
|
const selectedAllocationSet = new Set(multiSelectState.selectedAllocationIds);
|
|
const { visibleOpenDemands, laneMap, rowHeight } = layout;
|
|
if (visibleOpenDemands.length === 0) return null;
|
|
|
|
return (
|
|
<div
|
|
data-project-demand-row="true"
|
|
data-project-id={projectId}
|
|
className="group relative isolate flex border-b border-dashed border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-slate-950 hover:bg-amber-100/80 dark:hover:bg-slate-900"
|
|
style={{ height: rowHeight }}
|
|
>
|
|
<div
|
|
className="sticky left-0 z-30 flex flex-shrink-0 items-center gap-2 border-r border-amber-200 bg-amber-50 pl-8 pr-4 dark:border-amber-800 dark:bg-slate-950"
|
|
style={{ width: LABEL_WIDTH, height: rowHeight }}
|
|
>
|
|
<div className="pointer-events-none absolute inset-0 bg-amber-50 dark:bg-slate-950" />
|
|
<div className="relative z-10 flex items-center gap-2 min-w-0">
|
|
<div className="w-6 h-6 rounded-full bg-amber-100 dark:bg-amber-900/50 flex items-center justify-center text-[10px] font-bold text-amber-600 dark:text-amber-400 flex-shrink-0 border border-dashed border-amber-400 dark:border-amber-600">
|
|
?
|
|
</div>
|
|
<div className="min-w-0">
|
|
<div className="text-xs font-medium text-amber-700 dark:text-amber-400 truncate">Open demand</div>
|
|
<div className="text-[10px] text-amber-500 dark:text-amber-600 truncate">
|
|
{openDemandCount} open demand{openDemandCount > 1 ? "s" : ""}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
className="relative overflow-hidden bg-amber-50 touch-none dark:bg-slate-950"
|
|
style={{ width: totalCanvasWidth, height: rowHeight }}
|
|
onMouseLeave={onClearHoverTooltips}
|
|
>
|
|
{rowGridLines}
|
|
<div className="pointer-events-none absolute inset-x-0 inset-y-0 border-y border-dashed border-amber-200/70 dark:border-amber-800/80" />
|
|
<div className="pointer-events-none absolute inset-x-0 inset-y-1 rounded-md bg-amber-100/25 dark:bg-amber-950/35" />
|
|
{visibleOpenDemands.map((alloc) => {
|
|
const allocStart = new Date(alloc.startDate);
|
|
const allocEnd = new Date(alloc.endDate);
|
|
|
|
const isAllocDragged = allocDragState.isActive && allocDragState.allocationId === alloc.id;
|
|
const dispStart =
|
|
isAllocDragged && allocDragState.currentStartDate
|
|
? allocDragState.currentStartDate
|
|
: allocStart;
|
|
const dispEnd =
|
|
isAllocDragged && allocDragState.currentEndDate ? allocDragState.currentEndDate : allocEnd;
|
|
|
|
// Multi-drag visual offset
|
|
const isMultiDragTarget =
|
|
multiSelectState.isMultiDragging &&
|
|
selectedAllocationSet.has(alloc.id);
|
|
const multiDragPx = isMultiDragTarget ? multiSelectState.multiDragDaysDelta * CELL_WIDTH : 0;
|
|
const multiDragMode = multiSelectState.multiDragMode;
|
|
|
|
let left = toLeft(dispStart);
|
|
let width = Math.max(CELL_WIDTH, toWidth(dispStart, dispEnd));
|
|
let dragTransform: string | undefined;
|
|
|
|
if (isAllocDragged) {
|
|
const preview = applyPointerOffsetPreviewRect({
|
|
left,
|
|
width,
|
|
mode: allocDragState.mode,
|
|
pointerOffsetX: getDragPointerOffset(
|
|
allocDragState.pointerDeltaX,
|
|
allocDragState.daysDelta,
|
|
CELL_WIDTH,
|
|
),
|
|
minWidth: CELL_WIDTH,
|
|
});
|
|
left = preview.left;
|
|
width = preview.width;
|
|
dragTransform = preview.transform;
|
|
}
|
|
|
|
// Clamp negative left (bar starts before view) to avoid extending outside canvas.
|
|
if (left < 0) {
|
|
width += left;
|
|
left = 0;
|
|
}
|
|
if (width <= 0 || left >= totalCanvasWidth) return null;
|
|
|
|
if (isMultiDragTarget && multiDragMode === "resize-start") {
|
|
left += multiDragPx;
|
|
width = Math.max(CELL_WIDTH, width - multiDragPx);
|
|
} else if (isMultiDragTarget && multiDragMode === "resize-end") {
|
|
width = Math.max(CELL_WIDTH, width + multiDragPx);
|
|
}
|
|
|
|
const roleEntity = (
|
|
alloc as { roleEntity?: { id: string; name: string; color: string | null } | null }
|
|
).roleEntity;
|
|
const roleName =
|
|
roleEntity?.name ?? (alloc as { role?: string | null }).role ?? "Open demand";
|
|
const roleColor = roleEntity?.color ?? "#f59e0b";
|
|
const headcount = (alloc as { headcount?: number }).headcount ?? 1;
|
|
const lane = laneMap.get(alloc.id) ?? 0;
|
|
const top = 8 + lane * SUB_LANE_HEIGHT;
|
|
const blockHeight = SUB_LANE_HEIGHT - 8;
|
|
|
|
const HANDLE_W = width >= 48 ? 8 : 6;
|
|
|
|
const allocInfo: AllocMouseDownInfo = {
|
|
mode: "move",
|
|
allocationId: alloc.id,
|
|
mutationAllocationId: getPlanningEntryMutationId(alloc),
|
|
projectId: alloc.projectId,
|
|
projectName: alloc.project.name,
|
|
resourceId: null,
|
|
startDate: allocStart,
|
|
endDate: allocEnd,
|
|
};
|
|
|
|
return (
|
|
<div
|
|
key={alloc.id}
|
|
data-allocation-id={alloc.id}
|
|
data-timeline-entry-type="demand"
|
|
data-timeline-drag-preview="project-shift allocation"
|
|
data-timeline-project-id={alloc.projectId}
|
|
className={clsx(
|
|
"absolute rounded-md flex items-stretch overflow-hidden z-[10] group/demand",
|
|
isAllocDragged
|
|
? "ring-2 ring-amber-500 z-20"
|
|
: "hover:ring-2 hover:ring-amber-400 hover:ring-offset-1",
|
|
selectedAllocationSet.has(alloc.id) && "ring-2 ring-sky-500 ring-offset-1 z-20",
|
|
)}
|
|
style={{
|
|
left: left + 2,
|
|
width: width - 4,
|
|
top,
|
|
height: blockHeight,
|
|
backgroundColor: `${roleColor}4D`,
|
|
border: `2px dashed ${roleColor}B3`,
|
|
...((multiDragPx && multiDragMode === "move") || dragTransform
|
|
? {
|
|
transform: [dragTransform, multiDragPx && multiDragMode === "move"
|
|
? `translateX(${multiDragPx}px)`
|
|
: null]
|
|
.filter(Boolean)
|
|
.join(" "),
|
|
}
|
|
: {}),
|
|
}}
|
|
onMouseDown={(e) => {
|
|
if (e.button === 2) e.stopPropagation();
|
|
}}
|
|
onContextMenu={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (suppressHoverInteractions) return;
|
|
onAllocationContextMenu(
|
|
{ allocationId: alloc.id, projectId: alloc.projectId },
|
|
e.clientX,
|
|
e.clientY,
|
|
);
|
|
}}
|
|
onMouseMove={(e) => {
|
|
if (suppressHoverInteractions) return;
|
|
onDemandHoverMove(e, alloc);
|
|
}}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
if (suppressHoverInteractions) return;
|
|
onOpenDemandClick(alloc, e.clientX, e.clientY);
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key !== "Enter" && e.key !== " ") {
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (suppressHoverInteractions) return;
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
onOpenDemandClick(alloc, rect.left + rect.width / 2, rect.top + rect.height / 2);
|
|
}}
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label={`Open demand details for ${roleName} on ${alloc.project.name}`}
|
|
>
|
|
{/* Left resize handle */}
|
|
<div
|
|
className="flex-shrink-0 cursor-ew-resize hover:bg-black/10"
|
|
style={{ width: HANDLE_W }}
|
|
onMouseDown={(e) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" })}
|
|
onTouchStart={(e) => {
|
|
e.stopPropagation();
|
|
onAllocTouchStart(e, { ...allocInfo, mode: "resize-start" });
|
|
}}
|
|
/>
|
|
|
|
{/* Center — move + click */}
|
|
<div
|
|
className={clsx(
|
|
"flex-1 min-w-0 flex items-center px-1 gap-1",
|
|
isAllocDragged ? "cursor-grabbing" : "cursor-grab",
|
|
)}
|
|
onMouseDown={(e) => {
|
|
e.stopPropagation();
|
|
onAllocMouseDown(e, allocInfo);
|
|
}}
|
|
onTouchStart={(e) => {
|
|
e.stopPropagation();
|
|
onAllocTouchStart(e, allocInfo);
|
|
}}
|
|
>
|
|
<span className="text-xs font-medium truncate" style={{ color: roleColor }}>
|
|
{roleName}
|
|
{headcount > 1 ? ` x${headcount}` : ""}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Right resize handle */}
|
|
<div
|
|
className="flex-shrink-0 cursor-ew-resize hover:bg-black/10"
|
|
style={{ width: HANDLE_W }}
|
|
onMouseDown={(e) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" })}
|
|
onTouchStart={(e) => {
|
|
e.stopPropagation();
|
|
onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" });
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Project-view: per-resource utilisation band ────────────────────────────
|
|
|
|
function renderProjectUtilOverlay(
|
|
dayMetrics: ProjectDayMetric[],
|
|
CELL_WIDTH: number,
|
|
displayMode: string,
|
|
heatmapScheme: string,
|
|
totalCanvasWidth: number,
|
|
) {
|
|
if (dayMetrics.length === 0 || totalCanvasWidth <= 0) return null;
|
|
|
|
const BAND_H = 7;
|
|
const BAR_H = ROW_HEIGHT - BAND_H - 11;
|
|
const useHeatmapColors = displayMode === "bar";
|
|
const svgParts: string[] = [
|
|
`<svg xmlns="${SVG_XMLNS}" width="${totalCanvasWidth}" height="${ROW_HEIGHT}" viewBox="0 0 ${totalCanvasWidth} ${ROW_HEIGHT}" preserveAspectRatio="none" shape-rendering="crispEdges">`,
|
|
];
|
|
|
|
dayMetrics.forEach(({ projH, totalH, capacityH }, i) => {
|
|
if ((totalH === 0 && projH === 0) || capacityH <= 0) return;
|
|
|
|
const isOver = totalH > capacityH;
|
|
const totalBarH = Math.max(
|
|
projH > 0 ? 2 : 0,
|
|
Math.round((Math.min(totalH, capacityH) / capacityH) * BAR_H),
|
|
);
|
|
const projBarH =
|
|
projH > 0 ? Math.min(totalBarH, Math.max(2, Math.round((projH / capacityH) * BAR_H))) : 0;
|
|
const otherBarH = totalBarH - projBarH;
|
|
const projPct = (projH / capacityH) * 100;
|
|
const totalPct = (totalH / capacityH) * 100;
|
|
const projColor = useHeatmapColors
|
|
? heatmapColor(
|
|
projPct,
|
|
heatmapScheme as import("~/hooks/useAppPreferences.js").HeatmapColorScheme,
|
|
"bar",
|
|
) ?? "rgba(59,130,246,0.8)"
|
|
: "rgba(96,165,250,0.8)";
|
|
const totalColor = useHeatmapColors
|
|
? heatmapColor(
|
|
totalPct,
|
|
heatmapScheme as import("~/hooks/useAppPreferences.js").HeatmapColorScheme,
|
|
"bar",
|
|
) ?? "rgba(156,163,175,0.5)"
|
|
: isOver
|
|
? "rgba(252,211,77,0.8)"
|
|
: "rgba(209,213,219,0.8)";
|
|
const xBand = i * CELL_WIDTH + 1;
|
|
const xBar = i * CELL_WIDTH + 3;
|
|
const bandWidth = Math.max(CELL_WIDTH - 2, 0);
|
|
const barWidth = Math.max(CELL_WIDTH - 6, 0);
|
|
|
|
if (projH > 0 && bandWidth > 0) {
|
|
svgParts.push(
|
|
`<rect x="${xBand}" y="6" width="${bandWidth}" height="${BAND_H}" fill="${projColor}" />`,
|
|
);
|
|
}
|
|
|
|
if (otherBarH > 0 && barWidth > 0) {
|
|
svgParts.push(
|
|
`<rect x="${xBar}" y="${ROW_HEIGHT - 4 - projBarH - otherBarH}" width="${barWidth}" height="${otherBarH}" fill="${totalColor}" />`,
|
|
);
|
|
}
|
|
|
|
if (projBarH > 0 && barWidth > 0) {
|
|
svgParts.push(
|
|
`<rect x="${xBar}" y="${ROW_HEIGHT - 4 - projBarH}" width="${barWidth}" height="${projBarH}" fill="${projColor}" />`,
|
|
);
|
|
}
|
|
|
|
if (isOver && totalBarH > 0 && barWidth > 0) {
|
|
svgParts.push(
|
|
`<rect x="${xBar}" y="${ROW_HEIGHT - 4 - totalBarH - 3}" width="${barWidth}" height="3" fill="rgb(239,68,68)" />`,
|
|
);
|
|
}
|
|
});
|
|
|
|
svgParts.push("</svg>");
|
|
const svgDataUri = `url("data:image/svg+xml;utf8,${encodeURIComponent(svgParts.join(""))}")`;
|
|
|
|
return (
|
|
<div
|
|
data-project-util-bar="true"
|
|
className="absolute inset-0 pointer-events-none"
|
|
style={{
|
|
backgroundImage: svgDataUri,
|
|
backgroundPosition: "0 0",
|
|
backgroundRepeat: "no-repeat",
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// ─── Project-view: transparent drag handles ─────────────────────────────────
|
|
|
|
function renderProjectDragHandles(
|
|
allocs: TimelineAssignmentEntry[],
|
|
allocDragState: AllocDragState,
|
|
toLeft: (d: Date) => number,
|
|
toWidth: (s: Date, e: Date) => number,
|
|
CELL_WIDTH: number,
|
|
totalCanvasWidth: number,
|
|
onAllocMouseDown: (e: React.MouseEvent, info: AllocMouseDownInfo) => void,
|
|
onAllocTouchStart: (e: React.TouchEvent, info: AllocMouseDownInfo) => void,
|
|
onAllocationContextMenu: (
|
|
info: { allocationId: string; projectId: string; contextDate?: Date },
|
|
anchorX: number,
|
|
anchorY: number,
|
|
) => void,
|
|
multiSelectState: MultiSelectState,
|
|
suppressHoverInteractions: boolean,
|
|
) {
|
|
const selectedAllocationSet = new Set(multiSelectState.selectedAllocationIds);
|
|
return allocs.map((alloc) => {
|
|
const allocStart = new Date(alloc.startDate);
|
|
const allocEnd = new Date(alloc.endDate);
|
|
|
|
const isAllocDragged = allocDragState.isActive && allocDragState.allocationId === alloc.id;
|
|
const dispStart =
|
|
isAllocDragged && allocDragState.currentStartDate
|
|
? allocDragState.currentStartDate
|
|
: allocStart;
|
|
const dispEnd =
|
|
isAllocDragged && allocDragState.currentEndDate ? allocDragState.currentEndDate : allocEnd;
|
|
|
|
let left = toLeft(dispStart);
|
|
let width = Math.max(CELL_WIDTH, toWidth(dispStart, dispEnd));
|
|
let dragTransform: string | undefined;
|
|
|
|
if (isAllocDragged) {
|
|
const preview = applyPointerOffsetPreviewRect({
|
|
left,
|
|
width,
|
|
mode: allocDragState.mode,
|
|
pointerOffsetX: getDragPointerOffset(
|
|
allocDragState.pointerDeltaX,
|
|
allocDragState.daysDelta,
|
|
CELL_WIDTH,
|
|
),
|
|
minWidth: CELL_WIDTH,
|
|
});
|
|
left = preview.left;
|
|
width = preview.width;
|
|
dragTransform = preview.transform;
|
|
}
|
|
if (width <= 0 || left >= totalCanvasWidth) return null;
|
|
|
|
// Multi-drag visual offset
|
|
const isMultiDragTarget =
|
|
multiSelectState.isMultiDragging &&
|
|
selectedAllocationSet.has(alloc.id);
|
|
const multiDragPx = isMultiDragTarget ? multiSelectState.multiDragDaysDelta * CELL_WIDTH : 0;
|
|
const multiDragMode = multiSelectState.multiDragMode;
|
|
|
|
if (isMultiDragTarget && multiDragMode === "resize-start") {
|
|
left += multiDragPx;
|
|
width = Math.max(CELL_WIDTH, width - multiDragPx);
|
|
} else if (isMultiDragTarget && multiDragMode === "resize-end") {
|
|
width = Math.max(CELL_WIDTH, width + multiDragPx);
|
|
}
|
|
|
|
// Always show resize handles — for narrow bars, use overlapping handles
|
|
const HANDLE_W = width >= 48 ? 8 : 6;
|
|
const hasRecurrence = !!(alloc.metadata as Record<string, unknown> | null)?.recurrence;
|
|
|
|
const allocInfo: AllocMouseDownInfo = {
|
|
mode: "move",
|
|
allocationId: alloc.id,
|
|
mutationAllocationId: getPlanningEntryMutationId(alloc),
|
|
projectId: alloc.projectId,
|
|
projectName: alloc.project.name,
|
|
resourceId: alloc.resourceId,
|
|
startDate: allocStart,
|
|
endDate: allocEnd,
|
|
};
|
|
|
|
return (
|
|
<div
|
|
key={`dh-${alloc.id}`}
|
|
data-allocation-id={alloc.id}
|
|
data-timeline-entry-type="allocation"
|
|
data-timeline-drag-preview="project-shift allocation"
|
|
data-timeline-project-id={alloc.projectId}
|
|
className={clsx(
|
|
"absolute flex items-stretch rounded",
|
|
hasRecurrence && "border-2 border-dashed border-brand-400/60",
|
|
isAllocDragged
|
|
? "ring-2 ring-brand-400 z-20"
|
|
: "hover:ring-1 hover:ring-brand-300/70 z-[15]",
|
|
selectedAllocationSet.has(alloc.id) && "ring-2 ring-sky-500 ring-offset-1 z-20",
|
|
)}
|
|
style={{
|
|
left: left + 2,
|
|
width: width - 4,
|
|
top: 2,
|
|
bottom: 2,
|
|
...((multiDragPx && multiDragMode === "move") || dragTransform
|
|
? {
|
|
transform: [
|
|
dragTransform,
|
|
multiDragPx && multiDragMode === "move"
|
|
? `translateX(${multiDragPx}px)`
|
|
: null,
|
|
]
|
|
.filter(Boolean)
|
|
.join(" "),
|
|
}
|
|
: {}),
|
|
}}
|
|
onMouseDown={(e) => {
|
|
if (e.button === 2) e.stopPropagation();
|
|
}}
|
|
onContextMenu={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (suppressHoverInteractions) return;
|
|
onAllocationContextMenu(
|
|
{
|
|
allocationId: getPlanningEntryMutationId(alloc),
|
|
projectId: alloc.projectId,
|
|
},
|
|
e.clientX,
|
|
e.clientY,
|
|
);
|
|
}}
|
|
>
|
|
<div
|
|
className="flex-shrink-0 cursor-ew-resize"
|
|
style={{ width: HANDLE_W }}
|
|
onMouseDown={(e) => {
|
|
e.stopPropagation();
|
|
onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" });
|
|
}}
|
|
onTouchStart={(e) => {
|
|
e.stopPropagation();
|
|
onAllocTouchStart(e, { ...allocInfo, mode: "resize-start" });
|
|
}}
|
|
/>
|
|
<div
|
|
className={clsx(
|
|
"flex-1 min-w-0 flex items-center",
|
|
isAllocDragged ? "cursor-grabbing" : "cursor-grab",
|
|
)}
|
|
onMouseDown={(e) => {
|
|
e.stopPropagation();
|
|
onAllocMouseDown(e, allocInfo);
|
|
}}
|
|
onTouchStart={(e) => {
|
|
e.stopPropagation();
|
|
onAllocTouchStart(e, allocInfo);
|
|
}}
|
|
>
|
|
{hasRecurrence && width > 28 && (
|
|
<span className="text-[10px] text-brand-600 opacity-70 pointer-events-none pl-1">
|
|
↻
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div
|
|
className="flex-shrink-0 cursor-ew-resize"
|
|
style={{ width: HANDLE_W }}
|
|
onMouseDown={(e) => {
|
|
e.stopPropagation();
|
|
onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" });
|
|
}}
|
|
onTouchStart={(e) => {
|
|
e.stopPropagation();
|
|
onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" });
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
});
|
|
}
|
|
|
|
export const TimelineProjectPanel = memo(TimelineProjectPanelInner);
|