Files
Nexus/apps/web/src/components/timeline/TimelineProjectPanel.tsx
T
Hartmut eb283147d1 feat: project colors, timeline filters, sidebar fix, GitLooper agent, and misc improvements
- Fix sidebar double-highlight on /vacations/my (Gitea #6): add isNavItemActive() helper
- Add project color picker (schema + API + modal + timeline rendering)
- Add ProjectCombobox/ResourceCombobox to timeline toolbar
- Show PENDING vacations on timeline with dashed/dimmed style
- Add "show demand projects" preference with localStorage persistence
- Add ProjectAssignmentsTable with total hours/cost columns
- Extend vacation API to accept status arrays
- Add GitLooper formal YAML agent configuration
- Extend user admin with permission overrides UI
- Add delete-assignment use case tests
- Add status-styles.ts shared badge constants
- Centralize formatMoney/formatCents in format.ts

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-17 10:22:52 +01:00

1343 lines
47 KiB
TypeScript

"use client";
import { clsx } from "clsx";
import { 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 { heatmapColor } from "./heatmapUtils.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import { formatDateLong } from "~/lib/format.js";
import {
ROW_HEIGHT,
SUB_LANE_HEIGHT,
LABEL_WIDTH,
PROJECT_HEADER_HEIGHT,
ORDER_TYPE_COLORS,
} from "./timelineConstants.js";
import type { DragState, AllocDragState, RangeState } from "~/hooks/useTimelineDrag.js";
import type { AllocMouseDownInfo, RowMouseDownInfo } from "./TimelineResourcePanel.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: OpenDemandAssignment) => void;
onAllocationContextMenu: (
info: { allocationId: string; projectId: string },
anchorX: number,
anchorY: number,
) => void;
// 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 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;
};
const EMPTY_DAY_METRICS: ProjectDayMetric[] = [];
const SVG_XMLNS = "http://www.w3.org/2000/svg";
function buildProjectRowGridBackground(dates: Date[], CELL_WIDTH: number, today: Date) {
const gradientLayers: string[] = [
`repeating-linear-gradient(to right, transparent 0, transparent ${Math.max(
CELL_WIDTH - 1,
0,
)}px, rgba(229, 231, 235, 1) ${Math.max(CELL_WIDTH - 1, 0)}px, rgba(229, 231, 235, 1) ${CELL_WIDTH}px)`,
];
dates.forEach((date, index) => {
const left = index * CELL_WIDTH;
const right = left + CELL_WIDTH;
const isToday = date.toDateString() === today.toDateString();
const isSaturday = date.getDay() === 6;
const isSunday = date.getDay() === 0;
if (isSaturday) {
gradientLayers.push(
`linear-gradient(to right, transparent ${left}px, rgba(254, 243, 199, 0.4) ${left}px, rgba(254, 243, 199, 0.4) ${right}px, transparent ${right}px)`,
);
} else if (isSunday) {
gradientLayers.push(
`linear-gradient(to right, transparent ${left}px, rgba(243, 244, 246, 0.6) ${left}px, rgba(243, 244, 246, 0.6) ${right}px, transparent ${right}px)`,
);
}
if (isToday) {
gradientLayers.push(
`linear-gradient(to right, transparent ${left}px, rgba(110, 231, 183, 0.95) ${left}px, rgba(110, 231, 183, 0.95) ${Math.min(
left + 2,
right,
)}px, transparent ${Math.min(left + 2, right)}px)`,
);
}
});
return {
backgroundImage: gradientLayers.join(", "),
backgroundRepeat: "no-repeat",
} as const;
}
// ─── Component ──────────────────────────────────────────────────────────────
export function TimelineProjectPanel({
scrollContainerRef,
dragState,
allocDragState,
rangeState,
onProjectBarMouseDown,
onProjectBarTouchStart,
onAllocMouseDown,
onAllocTouchStart,
onRowMouseDown,
onRowTouchStart,
onOpenPanel,
onOpenDemandClick,
onAllocationContextMenu,
CELL_WIDTH,
dates,
totalCanvasWidth,
toLeft,
toWidth,
gridLines,
xToDate,
}: TimelineProjectPanelProps) {
const {
projectGroups,
openDemandsByProject,
allocsByResource,
vacationsByResource,
filters,
displayMode,
heatmapScheme,
activeFilterCount,
today,
} = useTimelineContext();
// ─── 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 heatmapTooltipPosRef = useRef({ left: 0, top: 0 });
const vacationTooltipPosRef = 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 { 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 nextHeatmapById = new Map<string, (HeatmapHoverState | null)[]>();
const nextTotalHoursById = new Map<string, number[]>();
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 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);
});
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 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 * (DEMAND_LANE_HEIGHT + DEMAND_LANE_GAP) + 8);
}
return ROW_HEIGHT;
},
overscan: 8,
getItemKey: (index) => flatRows[index]?.key ?? index,
});
const virtualItems = rowVirtualizer.getVirtualItems();
const totalRowHeight = rowVirtualizer.getTotalSize();
const resourceRowGridStyle = useMemo(
() => buildProjectRowGridBackground(dates, CELL_WIDTH, today),
[CELL_WIDTH, dates, today],
);
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 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`;
}
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;
}
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);
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);
});
});
},
[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;
}
const shouldClearHeatmap = lastHeatmapDayRef.current !== -1;
const shouldClearVacation = hoveredVacationKeyRef.current !== null;
lastHeatmapDayRef.current = -1;
lastHeatmapResourceRef.current = null;
hoveredVacationKeyRef.current = null;
if (shouldClearHeatmap || shouldClearVacation) {
startTransition(() => {
if (shouldClearHeatmap) setHeatmapHover(null);
if (shouldClearVacation) setVacationHover(null);
});
}
}, []);
useEffect(
() => () => {
if (heatmapRafRef.current !== null) cancelAnimationFrame(heatmapRafRef.current);
if (vacationHoverRafRef.current !== null) cancelAnimationFrame(vacationHoverRafRef.current);
},
[],
);
if (projectGroups.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 colors = ORDER_TYPE_COLORS[project.orderType] ?? {
bg: "bg-gray-400",
text: "text-white",
light: "bg-gray-50 border-gray-200",
};
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 group/proj", colors.light)}
style={{ height: PROJECT_HEADER_HEIGHT }}
>
<div
className={clsx(
"flex-shrink-0 border-r border-gray-300 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="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
className={clsx(
"absolute rounded flex items-center px-2 gap-1.5 transition-all duration-75",
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",
!customColor && colors.bg,
customColor ? "text-white" : colors.text,
)}
style={{
left: projLeft + 2,
width: projWidth - 4,
top: 8,
height: 24,
...(customColor ? { backgroundColor: customColor } : {}),
}}
onClick={() => {
if (!dragState.isDragging) onOpenPanel(project.id);
}}
onMouseDown={(e) =>
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()}
>
<span className="text-xs font-semibold truncate">{project.name}</span>
</div>
)}
</div>
</div>
);
})()
) : row.type === "open-demand" ? (
renderOpenDemandRow(
row.openDemands,
CELL_WIDTH,
totalCanvasWidth,
toLeft,
toWidth,
resourceRowGridStyle,
onOpenDemandClick,
)
) : (
<div
data-project-resource-row="true"
className="flex border-b border-gray-100 hover:bg-blue-50/20 group"
style={{ height: ROW_HEIGHT }}
>
<div
className="flex-shrink-0 border-r border-gray-200 flex items-center pl-8 pr-4 gap-2 bg-white sticky left-0 z-30 group-hover:bg-blue-50"
style={{ width: LABEL_WIDTH }}
>
<div className="w-6 h-6 rounded-full bg-gray-100 flex items-center justify-center text-[10px] font-bold text-gray-600 flex-shrink-0">
{row.resource.displayName.slice(0, 2).toUpperCase()}
</div>
<div className="min-w-0">
<div className="text-xs font-medium text-gray-800 truncate">
{row.resource.displayName}
</div>
<div className="text-[10px] text-gray-400 truncate">{row.resource.eid}</div>
</div>
</div>
<div
className="relative overflow-hidden touch-none"
style={{
width: totalCanvasWidth,
height: ROW_HEIGHT,
touchAction: "none",
...resourceRowGridStyle,
}}
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) => {
handleRowHeatmapMove(e, row.resource.id);
handleRowVacationHover(e, row.resource.id);
}}
onMouseLeave={clearHoverTooltips}
>
{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,
)}
{renderVacationBlocksForProjectRow(
vacationsByResource.get(row.resource.id) ?? [],
ROW_HEIGHT,
toLeft,
toWidth,
CELL_WIDTH,
totalCanvasWidth,
filters.showVacations,
)}
{renderRangeOverlayProject(
rangeState,
row.resource.id,
ROW_HEIGHT,
toLeft,
toWidth,
CELL_WIDTH,
)}
</div>
</div>
)}
</div>
);
})}
<ProjectPanelTooltips
heatmapTooltipRef={heatmapTooltipRef}
heatmapTooltipPos={heatmapTooltipPosRef.current}
vacationTooltipRef={vacationTooltipRef}
vacationTooltipPos={vacationTooltipPosRef.current}
heatmapHover={heatmapHover}
vacationHover={vacationHover}
/>
</div>
);
}
function ProjectPanelTooltips({
heatmapTooltipRef,
heatmapTooltipPos,
vacationTooltipRef,
vacationTooltipPos,
heatmapHover,
vacationHover,
}: {
heatmapTooltipRef: React.RefObject<HTMLDivElement | null>;
heatmapTooltipPos: { left: number; top: number };
vacationTooltipRef: React.RefObject<HTMLDivElement | null>;
vacationTooltipPos: { left: number; top: number };
heatmapHover: {
date: Date;
totalH: number;
pct: number;
breakdown: {
projectId: string;
shortCode: string;
projectName: string;
orderType: string;
hoursPerDay: number;
responsiblePerson?: string | null;
}[];
} | null;
vacationHover: {
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;
}) {
return (
<>
{heatmapHover ? (
<div
ref={heatmapTooltipRef}
style={{
left: heatmapTooltipPos.left,
top: heatmapTooltipPos.top,
backgroundColor: "rgba(3, 7, 18, 0.96)",
}}
className="fixed z-40 max-w-sm pointer-events-none rounded-xl border border-gray-800 bg-gray-950/96 px-3 py-2 text-xs text-white shadow-2xl"
>
<div className="flex items-center justify-between gap-3">
<span className="font-semibold">{formatDateLong(heatmapHover.date)}</span>
<span className="text-[11px] text-gray-300">
{heatmapHover.totalH.toFixed(1)}h · {Math.round(heatmapHover.pct)}%
</span>
</div>
<div className="mt-2 space-y-1.5">
{heatmapHover.breakdown.length > 0 ? (
heatmapHover.breakdown.slice(0, 6).map((entry) => (
<div
key={`${entry.projectId}-${entry.shortCode}`}
className="flex items-start justify-between gap-3"
>
<div className="min-w-0">
<div className="truncate font-medium text-white">
{entry.shortCode ? `${entry.shortCode} · ` : ""}
{entry.projectName}
</div>
<div className="truncate text-[11px] text-gray-400">
{entry.responsiblePerson
? `Lead: ${entry.responsiblePerson}`
: entry.orderType}
</div>
</div>
<span className="whitespace-nowrap text-[11px] font-semibold text-gray-200">
{entry.hoursPerDay}h
</span>
</div>
))
) : (
<div className="text-[11px] text-gray-400">No bookings on this day.</div>
)}
</div>
</div>
) : null}
{vacationHover ? (
<div
ref={vacationTooltipRef}
style={{
left: vacationTooltipPos.left,
top: vacationTooltipPos.top,
backgroundColor: "rgba(120, 53, 15, 0.95)",
}}
className="fixed z-40 max-w-xs pointer-events-none rounded-xl border border-amber-700/50 bg-amber-950/95 px-3 py-2 text-xs text-amber-50 shadow-2xl"
>
<div className="font-semibold">{vacationHover.type.replaceAll("_", " ")}</div>
<div className="mt-1 text-[11px] text-amber-100/90">
{formatDateLong(vacationHover.startDate)} to {formatDateLong(vacationHover.endDate)}
</div>
{vacationHover.note ? (
<div className="mt-2 text-[11px] text-amber-100/80">{vacationHover.note}</div>
) : null}
</div>
) : null}
</>
);
}
// ─── 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;
}
const DEMAND_LANE_HEIGHT = 30;
const DEMAND_LANE_GAP = 2;
function renderOpenDemandRow(
openDemands: TimelineDemandEntry[],
CELL_WIDTH: number,
totalCanvasWidth: number,
toLeft: (d: Date) => number,
toWidth: (s: Date, e: Date) => number,
rowGridStyle: CSSProperties,
onOpenDemandClick: (demand: OpenDemandAssignment) => void,
) {
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 * (DEMAND_LANE_HEIGHT + DEMAND_LANE_GAP) + 8);
return (
<div
className="flex border-b border-dashed border-amber-200 bg-amber-50/30 hover:bg-amber-50/50 group"
style={{ minHeight: rowHeight }}
>
<div
className="flex-shrink-0 border-r border-amber-200 flex items-center pl-8 pr-4 gap-2 bg-amber-50 sticky left-0 z-30"
style={{ width: LABEL_WIDTH, minHeight: rowHeight }}
>
<div className="w-6 h-6 rounded-full bg-amber-100 flex items-center justify-center text-[10px] font-bold text-amber-600 flex-shrink-0 border border-dashed border-amber-400">
?
</div>
<div className="min-w-0">
<div className="text-xs font-medium text-amber-700 truncate">Open demand</div>
<div className="text-[10px] text-amber-500 truncate">
{openDemands.length} open demand{openDemands.length > 1 ? "s" : ""}
</div>
</div>
</div>
<div
className="relative overflow-hidden"
style={{ width: totalCanvasWidth, minHeight: rowHeight, ...rowGridStyle }}
>
{openDemands.map((alloc) => {
const allocStart = new Date(alloc.startDate);
const allocEnd = new Date(alloc.endDate);
const left = toLeft(allocStart);
const width = Math.max(CELL_WIDTH, toWidth(allocStart, allocEnd));
if (width <= 0 || left >= totalCanvasWidth) return null;
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 = 4 + lane * (DEMAND_LANE_HEIGHT + DEMAND_LANE_GAP);
return (
<div
key={alloc.id}
className="absolute rounded-md flex items-center px-2 gap-1 overflow-hidden cursor-pointer hover:ring-2 hover:ring-amber-400 hover:ring-offset-1 z-[10]"
style={{
left: left + 2,
width: width - 4,
top,
height: DEMAND_LANE_HEIGHT,
backgroundColor: `${roleColor}33`,
border: `2px dashed ${roleColor}99`,
}}
onClick={() => {
onOpenDemandClick({
id: getPlanningEntryMutationId(alloc),
projectId: alloc.projectId,
roleId: (alloc as { roleId?: string | null }).roleId ?? null,
role: (alloc as { role?: string | null }).role ?? null,
headcount,
startDate: allocStart,
endDate: allocEnd,
hoursPerDay: alloc.hoursPerDay,
roleEntity: roleEntity ?? null,
project: alloc.project as { id: string; name: string; shortCode: string },
});
}}
>
<span className="text-xs font-medium truncate" style={{ color: roleColor }}>
{roleName}
{headcount > 1 ? ` x${headcount}` : ""}
</span>
</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 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;
const isOver = totalH > REF_H;
const totalBarH = Math.max(
projH > 0 ? 2 : 0,
Math.round((Math.min(totalH, REF_H) / REF_H) * BAR_H),
);
const projBarH =
projH > 0 ? Math.min(totalBarH, Math.max(2, Math.round((projH / REF_H) * BAR_H))) : 0;
const otherBarH = totalBarH - projBarH;
const projPct = (projH / REF_H) * 100;
const totalPct = (totalH / REF_H) * 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 },
anchorX: number,
anchorY: number,
) => void,
) {
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;
const left = toLeft(dispStart);
const width = Math.max(CELL_WIDTH, toWidth(dispStart, dispEnd));
if (width <= 0 || left >= totalCanvasWidth) return null;
// 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}`}
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]",
)}
style={{ left: left + 2, width: width - 4, top: 2, bottom: 2 }}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
onAllocationContextMenu(
{ allocationId: alloc.id, projectId: alloc.projectId },
e.clientX,
e.clientY,
);
}}
>
<div
className="flex-shrink-0 cursor-ew-resize"
style={{ width: HANDLE_W }}
onMouseDown={(e) => 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) => 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) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" })}
onTouchStart={(e) => {
e.stopPropagation();
onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" });
}}
/>
</div>
);
});
}
// ─── Vacation blocks for project view rows ──────────────────────────────────
const TYPE_COLORS: Record<string, string> = {
ANNUAL: "bg-orange-400/40",
SICK: "bg-red-500/40",
PUBLIC_HOLIDAY: "bg-violet-400/40",
OTHER: "bg-amber-400/40",
};
const TYPE_BORDER: Record<string, string> = {
ANNUAL: "border-orange-500",
SICK: "border-red-600",
PUBLIC_HOLIDAY: "border-violet-500",
OTHER: "border-amber-500",
};
const TYPE_LABELS_SHORT: Record<string, string> = {
ANNUAL: "Annual",
SICK: "Sick",
PUBLIC_HOLIDAY: "Holiday",
OTHER: "Other",
};
function renderVacationBlocksForProjectRow(
vacations: { id: string; type: string; startDate: Date | string; endDate: Date | string }[],
rowHeight: number,
toLeft: (d: Date) => number,
toWidth: (s: Date, e: Date) => number,
CELL_WIDTH: number,
totalCanvasWidth: number,
showVacations: boolean,
) {
if (!showVacations || vacations.length === 0) return null;
return vacations.map((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) return null;
const colorClass = TYPE_COLORS[v.type] ?? "bg-orange-400/40";
const borderClass = TYPE_BORDER[v.type] ?? "border-orange-500";
const label = TYPE_LABELS_SHORT[v.type] ?? v.type;
return (
<div
key={`vac-${v.id}`}
className={clsx(
"absolute z-[5] flex items-end px-1 pb-0.5 overflow-hidden border-t-2 pointer-events-none",
colorClass,
borderClass,
)}
style={{ left: left + 1, width: width - 2, top: 0, height: rowHeight }}
>
{width > 40 && (
<span className="text-[9px] font-bold truncate opacity-70 text-gray-700 dark:text-gray-200 pointer-events-none">
🏖 {label}
</span>
)}
</div>
);
});
}
// ─── Range overlay for project view ─────────────────────────────────────────
function renderRangeOverlayProject(
rangeState: RangeState,
resourceId: string,
rowHeight: number,
toLeft: (d: Date) => number,
toWidth: (s: Date, e: Date) => number,
CELL_WIDTH: number,
) {
if (!rangeState.isSelecting || rangeState.resourceId !== resourceId || !rangeState.startDate) {
return null;
}
const end = rangeState.currentDate ?? rangeState.startDate;
const [selStart, selEnd] =
rangeState.startDate <= end ? [rangeState.startDate, end] : [end, rangeState.startDate];
const left = toLeft(selStart);
const width = Math.max(CELL_WIDTH, toWidth(selStart, selEnd));
return (
<div
className="absolute bg-brand-200/40 border-2 border-brand-400 rounded pointer-events-none z-10"
style={{ left, width, top: 4, height: rowHeight - 8 }}
/>
);
}