feat(platform): checkpoint current implementation state
This commit is contained in:
@@ -9,10 +9,21 @@ import {
|
||||
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 } from "./TimelineTooltip.js";
|
||||
import {
|
||||
TimelineTooltip,
|
||||
type DemandHoverData,
|
||||
type HeatmapHoverData,
|
||||
type VacationHoverData,
|
||||
} from "./TimelineTooltip.js";
|
||||
import {
|
||||
ROW_HEIGHT,
|
||||
SUB_LANE_HEIGHT,
|
||||
@@ -24,11 +35,31 @@ 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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -46,11 +77,13 @@ interface TimelineProjectPanelProps {
|
||||
onOpenPanel: (projectId: string) => void;
|
||||
onOpenDemandClick: (demand: TimelineDemandEntry, anchorX: number, anchorY: number) => void;
|
||||
onAllocationContextMenu: (
|
||||
info: { allocationId: string; projectId: string },
|
||||
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[];
|
||||
@@ -82,57 +115,7 @@ export interface OpenDemandAssignment {
|
||||
project?: { id: string; name: string; shortCode: string };
|
||||
}
|
||||
|
||||
type HeatmapBreakdownEntry = {
|
||||
projectId: string;
|
||||
shortCode: string;
|
||||
projectName: string;
|
||||
orderType: string;
|
||||
hoursPerDay: number;
|
||||
responsiblePerson?: string | null;
|
||||
};
|
||||
|
||||
type HeatmapHoverState = {
|
||||
date: Date;
|
||||
totalH: number;
|
||||
pct: number;
|
||||
breakdown: HeatmapBreakdownEntry[];
|
||||
};
|
||||
|
||||
type ProjectDayMetric = {
|
||||
projH: number;
|
||||
totalH: number;
|
||||
};
|
||||
|
||||
type HeatmapBreakdownAccumulator = {
|
||||
shortCode: string;
|
||||
projectName: string;
|
||||
orderType: string;
|
||||
responsiblePerson: string | null;
|
||||
hours: number;
|
||||
};
|
||||
|
||||
type ProjectFlatRow =
|
||||
| {
|
||||
type: "header";
|
||||
key: string;
|
||||
project: NonNullable<ReturnType<typeof useTimelineContext>["projectGroups"]>[number];
|
||||
}
|
||||
| {
|
||||
type: "open-demand";
|
||||
key: string;
|
||||
projectId: string;
|
||||
openDemands: TimelineDemandEntry[];
|
||||
}
|
||||
| {
|
||||
type: "resource";
|
||||
key: string;
|
||||
project: NonNullable<ReturnType<typeof useTimelineContext>["projectGroups"]>[number];
|
||||
resource: NonNullable<
|
||||
ReturnType<typeof useTimelineContext>["projectGroups"]
|
||||
>[number]["resourceRows"][number]["resource"];
|
||||
allocs: TimelineAssignmentEntry[];
|
||||
metricsKey: string;
|
||||
};
|
||||
type HeatmapHoverState = HeatmapHoverData;
|
||||
|
||||
const EMPTY_DAY_METRICS: ProjectDayMetric[] = [];
|
||||
const SVG_XMLNS = "http://www.w3.org/2000/svg";
|
||||
@@ -154,6 +137,8 @@ function TimelineProjectPanelInner({
|
||||
onOpenDemandClick,
|
||||
onAllocationContextMenu,
|
||||
multiSelectState,
|
||||
optimisticAllocations,
|
||||
suppressHoverInteractions,
|
||||
CELL_WIDTH,
|
||||
dates,
|
||||
totalCanvasWidth,
|
||||
@@ -175,6 +160,27 @@ function TimelineProjectPanelInner({
|
||||
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);
|
||||
@@ -193,239 +199,65 @@ function TimelineProjectPanelInner({
|
||||
const vacationTooltipPosRef = useRef({ left: 0, top: 0 });
|
||||
const demandTooltipPosRef = useRef({ left: 0, top: 0 });
|
||||
|
||||
const [heatmapHover, setHeatmapHover] = useState<{
|
||||
date: Date;
|
||||
totalH: number;
|
||||
pct: number;
|
||||
breakdown: HeatmapBreakdownEntry[];
|
||||
} | null>(null);
|
||||
const [vacationHover, setVacationHover] = useState<null | {
|
||||
type: string;
|
||||
startDate: Date | string;
|
||||
endDate: Date | string;
|
||||
note?: string | null;
|
||||
requestedBy?: { name?: string | null; email: string } | null;
|
||||
approvedBy?: { name?: string | null; email: string } | null;
|
||||
approvedAt?: Date | string | null;
|
||||
}>(null);
|
||||
const [demandHover, setDemandHover] = useState<null | {
|
||||
roleName: string;
|
||||
roleColor: string;
|
||||
projectName: string;
|
||||
projectShortCode?: string | null;
|
||||
requestedHeadcount: number;
|
||||
unfilledHeadcount: number;
|
||||
startDate: Date | string;
|
||||
endDate: Date | string;
|
||||
hoursPerDay: number;
|
||||
totalHours: number;
|
||||
percentage?: number;
|
||||
status?: string;
|
||||
totalCostCents?: number;
|
||||
dailyCostCents?: number;
|
||||
}>(null);
|
||||
const [heatmapHover, setHeatmapHover] = useState<HeatmapHoverState | null>(null);
|
||||
const [vacationHover, setVacationHover] = useState<VacationHoverData | null>(null);
|
||||
const [demandHover, setDemandHover] = useState<DemandHoverData | null>(null);
|
||||
|
||||
const { resourceHeatmapById, resourceTotalHoursById } = useMemo(() => {
|
||||
const dateIndexByTime = new Map<number, number>();
|
||||
dates.forEach((date, index) => {
|
||||
const normalized = new Date(date);
|
||||
normalized.setHours(0, 0, 0, 0);
|
||||
dateIndexByTime.set(normalized.getTime(), index);
|
||||
});
|
||||
const resourceCapacityById = useMemo(
|
||||
() => buildResourceCapacitySeries(visualAllocsByResource, vacationsByResource, dates),
|
||||
[dates, vacationsByResource, visualAllocsByResource],
|
||||
);
|
||||
|
||||
const nextHeatmapById = new Map<string, (HeatmapHoverState | null)[]>();
|
||||
const nextTotalHoursById = new Map<string, number[]>();
|
||||
const { resourceHeatmapById, resourceTotalHoursById } = useMemo(
|
||||
() => buildResourceHeatmapSeries(visualAllocsByResource, dates, resourceCapacityById),
|
||||
[dates, resourceCapacityById, visualAllocsByResource],
|
||||
);
|
||||
|
||||
for (const [resourceId, allocs] of allocsByResource) {
|
||||
if (allocs.length === 0) continue;
|
||||
|
||||
const totalHours = new Array<number>(dates.length).fill(0);
|
||||
const breakdownMaps = Array.from({ length: dates.length }, () => new Map<string, HeatmapBreakdownAccumulator>());
|
||||
|
||||
for (const alloc of allocs) {
|
||||
const current = new Date(alloc.startDate);
|
||||
current.setHours(0, 0, 0, 0);
|
||||
const end = new Date(alloc.endDate);
|
||||
end.setHours(0, 0, 0, 0);
|
||||
|
||||
while (current.getTime() <= end.getTime()) {
|
||||
const dayIndex = dateIndexByTime.get(current.getTime());
|
||||
if (dayIndex !== undefined) {
|
||||
totalHours[dayIndex] = (totalHours[dayIndex] ?? 0) + alloc.hoursPerDay;
|
||||
|
||||
const dayBreakdown = breakdownMaps[dayIndex];
|
||||
if (!dayBreakdown) {
|
||||
current.setDate(current.getDate() + 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = dayBreakdown.get(alloc.projectId);
|
||||
if (existing) {
|
||||
existing.hours += alloc.hoursPerDay;
|
||||
} else {
|
||||
dayBreakdown.set(alloc.projectId, {
|
||||
shortCode: alloc.project.shortCode,
|
||||
projectName: alloc.project.name,
|
||||
orderType: alloc.project.orderType,
|
||||
responsiblePerson:
|
||||
(alloc.project as { responsiblePerson?: string | null }).responsiblePerson ??
|
||||
null,
|
||||
hours: alloc.hoursPerDay,
|
||||
});
|
||||
}
|
||||
}
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
nextTotalHoursById.set(resourceId, totalHours);
|
||||
nextHeatmapById.set(
|
||||
resourceId,
|
||||
totalHours.map((totalH, dayIndex) => {
|
||||
if (totalH === 0) return null;
|
||||
|
||||
const dayBreakdown = breakdownMaps[dayIndex];
|
||||
if (!dayBreakdown) return null;
|
||||
|
||||
const breakdown: HeatmapBreakdownEntry[] = [...dayBreakdown.entries()]
|
||||
.map(([projectId, value]) => ({
|
||||
projectId,
|
||||
shortCode: value.shortCode,
|
||||
projectName: value.projectName,
|
||||
orderType: value.orderType,
|
||||
responsiblePerson: value.responsiblePerson,
|
||||
hoursPerDay: value.hours,
|
||||
}))
|
||||
.sort((a, b) => b.hoursPerDay - a.hoursPerDay);
|
||||
|
||||
return {
|
||||
date: dates[dayIndex] ?? new Date(),
|
||||
totalH,
|
||||
pct: (totalH / 8) * 100,
|
||||
breakdown,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
resourceHeatmapById: nextHeatmapById,
|
||||
resourceTotalHoursById: nextTotalHoursById,
|
||||
};
|
||||
}, [allocsByResource, dates]);
|
||||
const vacationBlocksByResource = useMemo(
|
||||
() =>
|
||||
buildVacationBlocksByResource(
|
||||
vacationsByResource,
|
||||
filters.showVacations,
|
||||
toLeft,
|
||||
toWidth,
|
||||
CELL_WIDTH,
|
||||
totalCanvasWidth,
|
||||
),
|
||||
[CELL_WIDTH, filters.showVacations, toLeft, toWidth, totalCanvasWidth, vacationsByResource],
|
||||
);
|
||||
|
||||
const projectRowMetrics = useMemo(() => {
|
||||
const dateIndexByTime = new Map<number, number>();
|
||||
dates.forEach((date, index) => {
|
||||
const normalized = new Date(date);
|
||||
normalized.setHours(0, 0, 0, 0);
|
||||
dateIndexByTime.set(normalized.getTime(), index);
|
||||
});
|
||||
return buildProjectRowMetrics(
|
||||
dates,
|
||||
visualProjectGroups,
|
||||
resourceTotalHoursById,
|
||||
resourceCapacityById,
|
||||
);
|
||||
}, [dates, resourceCapacityById, resourceTotalHoursById, visualProjectGroups]);
|
||||
|
||||
const nextMetrics = new Map<string, ProjectDayMetric[]>();
|
||||
|
||||
for (const project of projectGroups) {
|
||||
for (const { resource, allocs } of project.resourceRows) {
|
||||
const projectHours = new Array<number>(dates.length).fill(0);
|
||||
|
||||
for (const alloc of allocs) {
|
||||
const current = new Date(alloc.startDate);
|
||||
current.setHours(0, 0, 0, 0);
|
||||
const end = new Date(alloc.endDate);
|
||||
end.setHours(0, 0, 0, 0);
|
||||
|
||||
while (current.getTime() <= end.getTime()) {
|
||||
const dayIndex = dateIndexByTime.get(current.getTime());
|
||||
if (dayIndex !== undefined) {
|
||||
projectHours[dayIndex] = (projectHours[dayIndex] ?? 0) + alloc.hoursPerDay;
|
||||
}
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const totalHours = resourceTotalHoursById.get(resource.id);
|
||||
nextMetrics.set(
|
||||
`${project.id}:${resource.id}`,
|
||||
projectHours.map((projH, dayIndex) => ({
|
||||
projH,
|
||||
totalH: totalHours?.[dayIndex] ?? 0,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return nextMetrics;
|
||||
}, [dates, projectGroups, resourceTotalHoursById]);
|
||||
|
||||
const flatRows = useMemo(() => {
|
||||
const rows: ProjectFlatRow[] = [];
|
||||
|
||||
for (const project of projectGroups) {
|
||||
rows.push({ type: "header", key: `header-${project.id}`, project });
|
||||
|
||||
const openDemands = openDemandsByProject.get(project.id) ?? [];
|
||||
if (openDemands.length > 0) {
|
||||
rows.push({
|
||||
type: "open-demand",
|
||||
key: `open-demand-${project.id}`,
|
||||
projectId: project.id,
|
||||
openDemands,
|
||||
});
|
||||
}
|
||||
|
||||
for (const { resource, allocs } of project.resourceRows) {
|
||||
rows.push({
|
||||
type: "resource",
|
||||
key: `${project.id}-${resource.id}`,
|
||||
project,
|
||||
resource,
|
||||
allocs,
|
||||
metricsKey: `${project.id}:${resource.id}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}, [openDemandsByProject, projectGroups]);
|
||||
const flatRows = useMemo(
|
||||
() => buildProjectFlatRows(visualProjectGroups, openDemandsByProject, optimisticAllocations),
|
||||
[openDemandsByProject, optimisticAllocations, visualProjectGroups],
|
||||
);
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: flatRows.length,
|
||||
getScrollElement: () => scrollContainerRef.current,
|
||||
estimateSize: (index) => {
|
||||
const row = flatRows[index];
|
||||
if (!row) return ROW_HEIGHT;
|
||||
if (row.type === "header") return PROJECT_HEADER_HEIGHT;
|
||||
if (row.type === "open-demand") {
|
||||
const laneCount = assignDemandLanes(row.openDemands).size > 0
|
||||
? Math.max(...assignDemandLanes(row.openDemands).values()) + 1
|
||||
: 1;
|
||||
return Math.max(ROW_HEIGHT, laneCount * SUB_LANE_HEIGHT + 16);
|
||||
}
|
||||
return ROW_HEIGHT;
|
||||
},
|
||||
estimateSize: (index) => estimateProjectRowHeight(flatRows[index]),
|
||||
overscan: 8,
|
||||
getItemKey: (index) => flatRows[index]?.key ?? index,
|
||||
});
|
||||
const virtualItems = rowVirtualizer.getVirtualItems();
|
||||
const totalRowHeight = rowVirtualizer.getTotalSize();
|
||||
|
||||
const resourcesWithVacations = useMemo(() => {
|
||||
const result = new Set<string>();
|
||||
for (const [resourceId, vacations] of vacationsByResource) {
|
||||
if (vacations.length > 0) {
|
||||
result.add(resourceId);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [vacationsByResource]);
|
||||
const resourcesWithVacations = useMemo(
|
||||
() => collectResourcesWithVacations(vacationsByResource),
|
||||
[vacationsByResource],
|
||||
);
|
||||
|
||||
const handleRowHeatmapMove = useCallback(
|
||||
(e: React.MouseEvent, resourceId: string) => {
|
||||
heatmapTooltipPosRef.current = { left: e.clientX + 16, top: e.clientY - 52 };
|
||||
if (heatmapTooltipRef.current) {
|
||||
heatmapTooltipRef.current.style.left = `${heatmapTooltipPosRef.current.left}px`;
|
||||
heatmapTooltipRef.current.style.top = `${heatmapTooltipPosRef.current.top}px`;
|
||||
}
|
||||
updateTooltipPosition(heatmapTooltipPosRef, heatmapTooltipRef, e.clientX, e.clientY, 16, -52);
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const dayIndex = Math.floor((e.clientX - rect.left) / CELL_WIDTH);
|
||||
@@ -477,52 +309,28 @@ function TimelineProjectPanelInner({
|
||||
return;
|
||||
}
|
||||
|
||||
vacationTooltipPosRef.current = { left: e.clientX + 14, top: e.clientY - 8 };
|
||||
if (vacationTooltipRef.current) {
|
||||
vacationTooltipRef.current.style.left = `${vacationTooltipPosRef.current.left}px`;
|
||||
vacationTooltipRef.current.style.top = `${vacationTooltipPosRef.current.top}px`;
|
||||
}
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const clientX = e.clientX;
|
||||
if (vacationHoverRafRef.current !== null) return;
|
||||
|
||||
vacationHoverRafRef.current = requestAnimationFrame(() => {
|
||||
vacationHoverRafRef.current = null;
|
||||
const date = xToDate(clientX, rect);
|
||||
date.setHours(0, 0, 0, 0);
|
||||
const time = date.getTime();
|
||||
const resourceVacations = vacationsByResource.get(resourceId) ?? [];
|
||||
const hit =
|
||||
resourceVacations.find((vacation) => {
|
||||
const start = new Date(vacation.startDate);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
const end = new Date(vacation.endDate);
|
||||
end.setHours(0, 0, 0, 0);
|
||||
return time >= start.getTime() && time <= end.getTime();
|
||||
}) ?? null;
|
||||
|
||||
const nextKey = hit ? `${resourceId}:${hit.id}` : null;
|
||||
if (nextKey === hoveredVacationKeyRef.current) return;
|
||||
|
||||
hoveredVacationKeyRef.current = nextKey;
|
||||
startTransition(() => {
|
||||
setVacationHover(hit);
|
||||
});
|
||||
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(() => {
|
||||
if (heatmapRafRef.current !== null) {
|
||||
cancelAnimationFrame(heatmapRafRef.current);
|
||||
heatmapRafRef.current = null;
|
||||
}
|
||||
if (vacationHoverRafRef.current !== null) {
|
||||
cancelAnimationFrame(vacationHoverRafRef.current);
|
||||
vacationHoverRafRef.current = null;
|
||||
}
|
||||
cancelHoverFrame(heatmapRafRef);
|
||||
cancelHoverFrame(vacationHoverRafRef);
|
||||
|
||||
const shouldClearHeatmap = lastHeatmapDayRef.current !== -1;
|
||||
const shouldClearVacation = hoveredVacationKeyRef.current !== null;
|
||||
@@ -543,37 +351,10 @@ function TimelineProjectPanelInner({
|
||||
|
||||
const handleDemandHoverMove = useCallback(
|
||||
(e: React.MouseEvent, demand: TimelineDemandEntry) => {
|
||||
demandTooltipPosRef.current = { left: e.clientX + 16, top: e.clientY - 36 };
|
||||
if (demandTooltipRef.current) {
|
||||
demandTooltipRef.current.style.left = `${demandTooltipPosRef.current.left}px`;
|
||||
demandTooltipRef.current.style.top = `${demandTooltipPosRef.current.top}px`;
|
||||
}
|
||||
|
||||
const startDate = new Date(demand.startDate);
|
||||
const endDate = new Date(demand.endDate);
|
||||
const days = Math.max(1, Math.round((endDate.getTime() - startDate.getTime()) / 86_400_000) + 1);
|
||||
updateTooltipPosition(demandTooltipPosRef, demandTooltipRef, e.clientX, e.clientY, 16, -36);
|
||||
|
||||
startTransition(() => {
|
||||
setDemandHover({
|
||||
roleName: demand.roleEntity?.name ?? demand.role ?? "Open demand",
|
||||
roleColor: demand.roleEntity?.color ?? "#f59e0b",
|
||||
projectName: demand.project.name,
|
||||
projectShortCode: demand.project.shortCode,
|
||||
requestedHeadcount: demand.requestedHeadcount,
|
||||
unfilledHeadcount: demand.unfilledHeadcount,
|
||||
startDate: demand.startDate,
|
||||
endDate: demand.endDate,
|
||||
hoursPerDay: demand.hoursPerDay,
|
||||
totalHours: demand.hoursPerDay * days,
|
||||
percentage: demand.percentage,
|
||||
status: demand.status,
|
||||
...(demand.dailyCostCents > 0
|
||||
? {
|
||||
totalCostCents: demand.dailyCostCents * days,
|
||||
dailyCostCents: demand.dailyCostCents,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
setDemandHover(buildDemandHoverData(demand));
|
||||
});
|
||||
},
|
||||
[],
|
||||
@@ -581,13 +362,18 @@ function TimelineProjectPanelInner({
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (heatmapRafRef.current !== null) cancelAnimationFrame(heatmapRafRef.current);
|
||||
if (vacationHoverRafRef.current !== null) cancelAnimationFrame(vacationHoverRafRef.current);
|
||||
cancelHoverFrame(heatmapRafRef);
|
||||
cancelHoverFrame(vacationHoverRafRef);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
if (projectGroups.length === 0) {
|
||||
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)"}.
|
||||
@@ -677,11 +463,14 @@ function TimelineProjectPanelInner({
|
||||
{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 transition-all duration-75 text-white",
|
||||
"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 hover:opacity-90 hover:ring-2 hover:ring-white hover:ring-offset-1",
|
||||
: "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,
|
||||
@@ -689,18 +478,35 @@ function TimelineProjectPanelInner({
|
||||
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) =>
|
||||
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,
|
||||
@@ -709,7 +515,13 @@ function TimelineProjectPanelInner({
|
||||
endDate: project.endDate,
|
||||
})
|
||||
}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!dragState.isDragging) {
|
||||
onOpenPanel(project.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="text-xs font-semibold truncate">{project.name}</span>
|
||||
</div>
|
||||
@@ -720,7 +532,8 @@ function TimelineProjectPanelInner({
|
||||
})()
|
||||
) : row.type === "open-demand" ? (
|
||||
renderOpenDemandRow(
|
||||
row.openDemands,
|
||||
row.openDemandCount,
|
||||
row.layout,
|
||||
row.projectId,
|
||||
CELL_WIDTH,
|
||||
totalCanvasWidth,
|
||||
@@ -735,6 +548,7 @@ function TimelineProjectPanelInner({
|
||||
clearHoverTooltips,
|
||||
multiSelectState,
|
||||
allocDragState,
|
||||
suppressHoverInteractions,
|
||||
)
|
||||
) : (
|
||||
<div
|
||||
@@ -788,6 +602,7 @@ function TimelineProjectPanelInner({
|
||||
});
|
||||
}}
|
||||
onMouseMove={(e) => {
|
||||
if (suppressHoverInteractions) return;
|
||||
handleRowHeatmapMove(e, row.resource.id);
|
||||
handleRowVacationHover(e, row.resource.id);
|
||||
}}
|
||||
@@ -812,29 +627,20 @@ function TimelineProjectPanelInner({
|
||||
onAllocTouchStart,
|
||||
onAllocationContextMenu,
|
||||
multiSelectState,
|
||||
suppressHoverInteractions,
|
||||
)}
|
||||
{filters.showVacations &&
|
||||
renderVacationBlocks(
|
||||
(vacationsByResource.get(row.resource.id) ?? []).reduce<VacationBlockInfo[]>(
|
||||
(acc, v) => {
|
||||
const vStart = new Date(v.startDate);
|
||||
const vEnd = new Date(v.endDate);
|
||||
const left = toLeft(vStart);
|
||||
const width = Math.max(CELL_WIDTH, toWidth(vStart, vEnd));
|
||||
if (width > 0 && left < totalCanvasWidth) {
|
||||
acc.push({ vacation: v, left, width });
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[],
|
||||
),
|
||||
vacationBlocksByResource.get(row.resource.id) ?? [],
|
||||
ROW_HEIGHT,
|
||||
)}
|
||||
{blinkOverbookedDays &&
|
||||
renderOverbookingBlink(
|
||||
allocsByResource.get(row.resource.id) ?? [],
|
||||
visualAllocsByResource.get(row.resource.id) ?? [],
|
||||
dates,
|
||||
CELL_WIDTH,
|
||||
resourceCapacityById.get(row.resource.id)?.capacityHoursByDay,
|
||||
resourceCapacityById.get(row.resource.id)?.bookingFactorsByDay,
|
||||
)}
|
||||
{renderRangeOverlay(
|
||||
rangeState,
|
||||
@@ -870,41 +676,9 @@ function TimelineProjectPanelInner({
|
||||
|
||||
// ─── Pure render functions ──────────────────────────────────────────────────
|
||||
|
||||
/** Assign lane indices to demands so overlapping bars don't stack on top of each other. */
|
||||
function assignDemandLanes(
|
||||
demands: TimelineDemandEntry[],
|
||||
): Map<string, number> {
|
||||
const laneMap = new Map<string, number>();
|
||||
// Each lane tracks the latest end-date occupying it
|
||||
const laneEnds: Date[] = [];
|
||||
|
||||
// Sort by start date for greedy lane assignment
|
||||
const sorted = [...demands].sort(
|
||||
(a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime(),
|
||||
);
|
||||
|
||||
for (const d of sorted) {
|
||||
const start = new Date(d.startDate);
|
||||
let assigned = -1;
|
||||
for (let i = 0; i < laneEnds.length; i++) {
|
||||
if (laneEnds[i]! < start) {
|
||||
assigned = i;
|
||||
laneEnds[i] = new Date(d.endDate);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (assigned === -1) {
|
||||
assigned = laneEnds.length;
|
||||
laneEnds.push(new Date(d.endDate));
|
||||
}
|
||||
laneMap.set(d.id, assigned);
|
||||
}
|
||||
|
||||
return laneMap;
|
||||
}
|
||||
|
||||
function renderOpenDemandRow(
|
||||
openDemands: TimelineDemandEntry[],
|
||||
openDemandCount: number,
|
||||
layout: OpenDemandRowLayout,
|
||||
projectId: string,
|
||||
CELL_WIDTH: number,
|
||||
totalCanvasWidth: number,
|
||||
@@ -915,7 +689,7 @@ function renderOpenDemandRow(
|
||||
onAllocMouseDown: (e: React.MouseEvent, info: AllocMouseDownInfo) => void,
|
||||
onAllocTouchStart: (e: React.TouchEvent, info: AllocMouseDownInfo) => void,
|
||||
onAllocationContextMenu: (
|
||||
info: { allocationId: string; projectId: string },
|
||||
info: { allocationId: string; projectId: string; contextDate?: Date },
|
||||
anchorX: number,
|
||||
anchorY: number,
|
||||
) => void,
|
||||
@@ -923,12 +697,10 @@ function renderOpenDemandRow(
|
||||
onClearHoverTooltips: () => void,
|
||||
multiSelectState: MultiSelectState,
|
||||
allocDragState: AllocDragState,
|
||||
suppressHoverInteractions: boolean,
|
||||
) {
|
||||
if (openDemands.length === 0) return null;
|
||||
|
||||
const laneMap = assignDemandLanes(openDemands);
|
||||
const laneCount = laneMap.size > 0 ? Math.max(...laneMap.values()) + 1 : 1;
|
||||
const rowHeight = Math.max(ROW_HEIGHT, laneCount * SUB_LANE_HEIGHT + 16);
|
||||
const { visibleOpenDemands, laneMap, rowHeight } = layout;
|
||||
if (visibleOpenDemands.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -949,7 +721,7 @@ function renderOpenDemandRow(
|
||||
<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">
|
||||
{openDemands.length} open demand{openDemands.length > 1 ? "s" : ""}
|
||||
{openDemandCount} open demand{openDemandCount > 1 ? "s" : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -963,7 +735,7 @@ function renderOpenDemandRow(
|
||||
{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" />
|
||||
{openDemands.map((alloc) => {
|
||||
{visibleOpenDemands.map((alloc) => {
|
||||
const allocStart = new Date(alloc.startDate);
|
||||
const allocEnd = new Date(alloc.endDate);
|
||||
|
||||
@@ -984,7 +756,26 @@ function renderOpenDemandRow(
|
||||
|
||||
let left = toLeft(dispStart);
|
||||
let width = Math.max(CELL_WIDTH, toWidth(dispStart, dispEnd));
|
||||
// Clamp negative left (bar starts before view) to avoid extending outside canvas
|
||||
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;
|
||||
@@ -1025,6 +816,10 @@ function renderOpenDemandRow(
|
||||
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
|
||||
@@ -1039,8 +834,14 @@ function renderOpenDemandRow(
|
||||
height: blockHeight,
|
||||
backgroundColor: `${roleColor}4D`,
|
||||
border: `2px dashed ${roleColor}B3`,
|
||||
...(multiDragPx && multiDragMode === "move"
|
||||
? { transform: `translateX(${multiDragPx}px)` }
|
||||
...((multiDragPx && multiDragMode === "move") || dragTransform
|
||||
? {
|
||||
transform: [dragTransform, multiDragPx && multiDragMode === "move"
|
||||
? `translateX(${multiDragPx}px)`
|
||||
: null]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
@@ -1049,15 +850,20 @@ function renderOpenDemandRow(
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (suppressHoverInteractions) return;
|
||||
onAllocationContextMenu(
|
||||
{ allocationId: alloc.id, projectId: alloc.projectId },
|
||||
e.clientX,
|
||||
e.clientY,
|
||||
);
|
||||
}}
|
||||
onMouseMove={(e) => onDemandHoverMove(e, alloc)}
|
||||
onMouseMove={(e) => {
|
||||
if (suppressHoverInteractions) return;
|
||||
onDemandHoverMove(e, alloc);
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (suppressHoverInteractions) return;
|
||||
onOpenDemandClick(alloc, e.clientX, e.clientY);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
@@ -1066,6 +872,7 @@ function renderOpenDemandRow(
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (suppressHoverInteractions) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
onOpenDemandClick(alloc, rect.left + rect.width / 2, rect.top + rect.height / 2);
|
||||
}}
|
||||
@@ -1136,25 +943,24 @@ function renderProjectUtilOverlay(
|
||||
|
||||
const BAND_H = 7;
|
||||
const BAR_H = ROW_HEIGHT - BAND_H - 11;
|
||||
const REF_H = 8;
|
||||
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 }, i) => {
|
||||
if (totalH === 0 && projH === 0) return;
|
||||
dayMetrics.forEach(({ projH, totalH, capacityH }, i) => {
|
||||
if ((totalH === 0 && projH === 0) || capacityH <= 0) return;
|
||||
|
||||
const isOver = totalH > REF_H;
|
||||
const isOver = totalH > capacityH;
|
||||
const totalBarH = Math.max(
|
||||
projH > 0 ? 2 : 0,
|
||||
Math.round((Math.min(totalH, REF_H) / REF_H) * BAR_H),
|
||||
Math.round((Math.min(totalH, capacityH) / capacityH) * BAR_H),
|
||||
);
|
||||
const projBarH =
|
||||
projH > 0 ? Math.min(totalBarH, Math.max(2, Math.round((projH / REF_H) * BAR_H))) : 0;
|
||||
projH > 0 ? Math.min(totalBarH, Math.max(2, Math.round((projH / capacityH) * BAR_H))) : 0;
|
||||
const otherBarH = totalBarH - projBarH;
|
||||
const projPct = (projH / REF_H) * 100;
|
||||
const totalPct = (totalH / REF_H) * 100;
|
||||
const projPct = (projH / capacityH) * 100;
|
||||
const totalPct = (totalH / capacityH) * 100;
|
||||
const projColor = useHeatmapColors
|
||||
? heatmapColor(
|
||||
projPct,
|
||||
@@ -1229,11 +1035,12 @@ function renderProjectDragHandles(
|
||||
onAllocMouseDown: (e: React.MouseEvent, info: AllocMouseDownInfo) => void,
|
||||
onAllocTouchStart: (e: React.TouchEvent, info: AllocMouseDownInfo) => void,
|
||||
onAllocationContextMenu: (
|
||||
info: { allocationId: string; projectId: string },
|
||||
info: { allocationId: string; projectId: string; contextDate?: Date },
|
||||
anchorX: number,
|
||||
anchorY: number,
|
||||
) => void,
|
||||
multiSelectState: MultiSelectState,
|
||||
suppressHoverInteractions: boolean,
|
||||
) {
|
||||
return allocs.map((alloc) => {
|
||||
const allocStart = new Date(alloc.startDate);
|
||||
@@ -1249,6 +1056,24 @@ function renderProjectDragHandles(
|
||||
|
||||
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
|
||||
@@ -1283,6 +1108,10 @@ function renderProjectDragHandles(
|
||||
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",
|
||||
@@ -1296,9 +1125,18 @@ function renderProjectDragHandles(
|
||||
width: width - 4,
|
||||
top: 2,
|
||||
bottom: 2,
|
||||
...(multiDragPx && multiDragMode === "move"
|
||||
? { transform: `translateX(${multiDragPx}px)` }
|
||||
: {}),
|
||||
...((multiDragPx && multiDragMode === "move") || dragTransform
|
||||
? {
|
||||
transform: [
|
||||
dragTransform,
|
||||
multiDragPx && multiDragMode === "move"
|
||||
? `translateX(${multiDragPx}px)`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
if (e.button === 2) e.stopPropagation();
|
||||
@@ -1306,6 +1144,7 @@ function renderProjectDragHandles(
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (suppressHoverInteractions) return;
|
||||
onAllocationContextMenu(
|
||||
{ allocationId: alloc.id, projectId: alloc.projectId },
|
||||
e.clientX,
|
||||
@@ -1316,7 +1155,10 @@ function renderProjectDragHandles(
|
||||
<div
|
||||
className="flex-shrink-0 cursor-ew-resize"
|
||||
style={{ width: HANDLE_W }}
|
||||
onMouseDown={(e) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" })}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" });
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
e.stopPropagation();
|
||||
onAllocTouchStart(e, { ...allocInfo, mode: "resize-start" });
|
||||
@@ -1327,7 +1169,10 @@ function renderProjectDragHandles(
|
||||
"flex-1 min-w-0 flex items-center",
|
||||
isAllocDragged ? "cursor-grabbing" : "cursor-grab",
|
||||
)}
|
||||
onMouseDown={(e) => onAllocMouseDown(e, allocInfo)}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
onAllocMouseDown(e, allocInfo);
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
e.stopPropagation();
|
||||
onAllocTouchStart(e, allocInfo);
|
||||
@@ -1342,7 +1187,10 @@ function renderProjectDragHandles(
|
||||
<div
|
||||
className="flex-shrink-0 cursor-ew-resize"
|
||||
style={{ width: HANDLE_W }}
|
||||
onMouseDown={(e) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" })}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" });
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
e.stopPropagation();
|
||||
onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" });
|
||||
|
||||
Reference in New Issue
Block a user