refactor: complete v2 refactoring plan (Phases 1-5)

Phase 1 — Quick Wins: centralize formatMoney/formatCents, extract
findUniqueOrThrow helper (19 routers), shared Prisma select constants,
useInvalidatePlanningViews hook, status badge consolidation, composite
DB indexes.

Phase 2 — Timeline Split: extract TimelineContext, TimelineResourcePanel,
TimelineProjectPanel; split 28-dep useMemo into 3 focused memos.
TimelineView.tsx reduced from 1,903 to 538 lines.

Phase 3 — Query Performance: server-side filtering for getEntriesView,
remove availability from timeline resource select, SSE event debouncing
(50ms batch window).

Phase 4 — Estimate Workspace: extract 7 tab components and 3 editor
components. EstimateWorkspaceClient 1,298→306 lines,
EstimateWorkspaceDraftEditor 1,205→581 lines.

Phase 5 — Package Cleanup: split commit-dispo-import-batch (1,112→573
lines), extract shared pagination helper with 11 tests.

All tests pass: 209 API, 254 engine, 67 application.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-14 23:03:42 +01:00
parent 4dabb9d4ce
commit ad0855902b
65 changed files with 7108 additions and 4740 deletions
@@ -0,0 +1,441 @@
"use client";
import {
VacationStatus,
type AllocationLike,
type AllocationReadModel,
type Assignment,
type DemandRequirement,
} from "@planarchy/shared";
import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
import { useTimelineSSE } from "~/hooks/useTimelineSSE.js";
import { trpc } from "~/lib/trpc/client.js";
import { readAppPreferences, useAppPreferences } from "~/hooks/useAppPreferences.js";
import type { HeatmapColorScheme } from "~/hooks/useAppPreferences.js";
export type TimelineDisplayMode = "strip" | "bar" | "heatmap";
import { addDays } from "./utils.js";
import { DEFAULT_FILTERS, type TimelineFilters } from "./TimelineFilter.js";
import { DONE_STATUSES } from "./timelineConstants.js";
// ─── Local timeline types ─────────────────────────────────────────────────────
// These re-declare the shapes that the original TimelineView used internally.
// Kept here so every timeline sub-component can import them from one place.
export type TimelineResource = {
id: string;
displayName: string;
eid: string;
chapter?: string | null;
lcrCents: number;
availability?: unknown;
};
export type TimelineProject = {
id: string;
name: string;
shortCode: string;
status: string;
startDate: Date | string;
endDate: Date | string;
orderType: string;
budgetCents?: number;
winProbability?: number;
staffingReqs?: unknown;
responsiblePerson?: string | null;
};
export type TimelineRole = {
id: string;
name: string;
color: string | null;
};
export type TimelineAllocation = Omit<AllocationLike, "resource" | "project" | "roleEntity" | "metadata"> & {
resource?: TimelineResource | null;
project: TimelineProject;
roleEntity?: TimelineRole | null;
metadata: Record<string, unknown> | null;
};
export type TimelineAssignmentEntry = Assignment<TimelineAllocation>;
export type TimelineDemandEntry = DemandRequirement<TimelineAllocation>;
export type TimelineEntriesView = AllocationReadModel<TimelineAllocation> & {
assignments: TimelineAssignmentEntry[];
demands: TimelineDemandEntry[];
};
export type TimelineProjectEntry = TimelineAssignmentEntry | TimelineDemandEntry;
export type ViewMode = "resource" | "project";
// ─── Derived resource type used throughout the timeline ─────────────────────
export type ResourceBrief = {
id: string;
displayName: string;
eid: string;
chapter: string | null | undefined;
};
// ─── Project group type (project view) ──────────────────────────────────────
export type ProjectGroup = {
id: string;
name: string;
shortCode: string;
orderType: string;
startDate: Date;
endDate: Date;
status: string;
resourceRows: { resource: ResourceBrief; allocs: TimelineAssignmentEntry[] }[];
};
// ─── Vacation entry type (inferred from tRPC) ──────────────────────────────
export type VacationEntry = {
id: string;
resourceId: string;
type: string;
startDate: Date | string;
endDate: Date | string;
note?: string | null;
status: string;
requestedBy?: { name?: string | null; email: string } | null;
approvedBy?: { name?: string | null; email: string } | null;
approvedAt?: Date | string | null;
isHalfDay?: boolean;
halfDayPart?: string | null;
};
// ─── Context shape ──────────────────────────────────────────────────────────
export interface TimelineContextValue {
// ─ Data
assignments: TimelineAssignmentEntry[];
demands: TimelineDemandEntry[];
visibleAssignments: TimelineAssignmentEntry[];
visibleDemands: TimelineDemandEntry[];
vacationsByResource: Map<string, VacationEntry[]>;
resources: ResourceBrief[];
resourceMap: Map<string, ResourceBrief>;
allocsByResource: Map<string, TimelineAssignmentEntry[]>;
projectGroups: ProjectGroup[];
openDemandsByProject: Map<string, TimelineDemandEntry[]>;
// ─ View state
viewStart: Date;
viewEnd: Date;
viewDays: number;
setViewStart: React.Dispatch<React.SetStateAction<Date>>;
setViewDays: React.Dispatch<React.SetStateAction<number>>;
filters: TimelineFilters;
setFilters: React.Dispatch<React.SetStateAction<TimelineFilters>>;
filterOpen: boolean;
setFilterOpen: React.Dispatch<React.SetStateAction<boolean>>;
viewMode: ViewMode;
setViewMode: React.Dispatch<React.SetStateAction<ViewMode>>;
today: Date;
// ─ Display preferences
displayMode: TimelineDisplayMode;
heatmapScheme: HeatmapColorScheme;
// ─ Loading
isLoading: boolean;
isInitialLoading: boolean;
totalAllocCount: number;
activeFilterCount: number;
// ─ SSE is initialized by the provider (no value exposed)
}
const TimelineContext = createContext<TimelineContextValue | null>(null);
export function useTimelineContext(): TimelineContextValue {
const ctx = useContext(TimelineContext);
if (!ctx) {
throw new Error("useTimelineContext must be used within a <TimelineProvider>");
}
return ctx;
}
// ─── Provider ───────────────────────────────────────────────────────────────
interface TimelineProviderProps {
/** Cross-project context resource IDs from drag — injected from the parent. */
contextAllocations: TimelineAssignmentEntry[];
isDragging: boolean;
children: ReactNode;
}
export function TimelineProvider({
contextAllocations,
isDragging,
children,
}: TimelineProviderProps) {
const today = useMemo(() => {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}, []);
const [viewStart, setViewStart] = useState(() => addDays(today, -30));
const [viewDays, setViewDays] = useState(180);
const viewEnd = addDays(viewStart, viewDays);
const [filters, setFilters] = useState<TimelineFilters>(() => ({
...DEFAULT_FILTERS,
hideCompletedProjects: readAppPreferences().hideCompletedProjects,
}));
const [filterOpen, setFilterOpen] = useState(false);
const [viewMode, setViewMode] = useState<ViewMode>("resource");
useTimelineSSE();
const { prefs: appPrefs } = useAppPreferences();
const displayMode = appPrefs.timelineDisplayMode;
const heatmapScheme = appPrefs.heatmapColorScheme;
// ─── Data queries ──────────────────────────────────────────────────────────
const { data: entriesView, isLoading } = trpc.timeline.getEntriesView.useQuery(
{ startDate: viewStart, endDate: viewEnd },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ placeholderData: (prev: any) => prev },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) as { data: TimelineEntriesView | undefined; isLoading: boolean };
const assignments = entriesView?.assignments ?? [];
const demands = entriesView?.demands ?? [];
const { data: vacationEntries = [] } = trpc.vacation.list.useQuery(
{ startDate: viewStart, endDate: viewEnd, status: VacationStatus.APPROVED, limit: 500 },
{ placeholderData: (prev) => prev },
);
const vacationsByResource = useMemo(() => {
const map = new Map<string, VacationEntry[]>();
for (const vacation of vacationEntries as VacationEntry[]) {
const existing = map.get(vacation.resourceId);
if (existing) {
existing.push(vacation);
} else {
map.set(vacation.resourceId, [vacation]);
}
}
return map;
}, [vacationEntries]);
// When EID filter is active, explicitly fetch those resources.
const { data: eidFilterData } = trpc.resource.list.useQuery(
{ eids: filters.eids, limit: 100 },
{ enabled: filters.eids.length > 0, staleTime: 30_000 },
);
// ─── Filtered entries ──────────────────────────────────────────────────────
const visibleAssignments = useMemo(
() => assignments.filter((entry) => {
if (entry.project.status === "DRAFT" && !filters.showDrafts) return false;
if (DONE_STATUSES.has(entry.project.status) && filters.hideCompletedProjects) return false;
return true;
}),
[assignments, filters.hideCompletedProjects, filters.showDrafts],
);
const visibleDemands = useMemo(
() => demands.filter((entry) => {
if (entry.project.status === "DRAFT" && !filters.showDrafts) return false;
if (DONE_STATUSES.has(entry.project.status) && filters.hideCompletedProjects) return false;
if (!filters.showPlaceholders) return false;
return true;
}),
[demands, filters.hideCompletedProjects, filters.showDrafts, filters.showPlaceholders],
);
const openDemandsByProject = useMemo(() => {
const map = new Map<string, TimelineDemandEntry[]>();
for (const demand of visibleDemands) {
const arr = map.get(demand.projectId) ?? [];
arr.push(demand);
map.set(demand.projectId, arr);
}
return map;
}, [visibleDemands]);
// ─── Resource map + allocsByResource ──────────────────────────────────────
const { resourceMap, allocsByResource, resources } = useMemo(() => {
const resourceMap = new Map<string, ResourceBrief>();
const allocsByResource = new Map<string, TimelineAssignmentEntry[]>();
if (eidFilterData?.resources) {
for (const r of eidFilterData.resources as { id: string; displayName: string; eid: string; chapter: string | null }[]) {
if (!resourceMap.has(r.id)) {
resourceMap.set(r.id, { id: r.id, displayName: r.displayName, eid: r.eid, chapter: r.chapter });
}
}
}
for (const entry of visibleAssignments) {
if (!entry.resourceId) continue;
if (!resourceMap.has(entry.resourceId)) {
resourceMap.set(entry.resourceId, {
id: entry.resource!.id,
displayName: entry.resource!.displayName,
eid: entry.resource!.eid,
chapter: entry.resource!.chapter,
});
}
const arr = allocsByResource.get(entry.resourceId) ?? [];
arr.push(entry);
allocsByResource.set(entry.resourceId, arr);
}
// Merge cross-project context allocations so they appear during drag
if (isDragging && contextAllocations.length > 0) {
for (const ca of contextAllocations) {
if (!ca.resourceId) continue;
const existing = visibleAssignments.find((entry) => entry.resourceId === ca.resourceId);
if (existing && !resourceMap.has(ca.resourceId)) {
resourceMap.set(ca.resourceId, {
id: existing.resource!.id,
displayName: existing.resource!.displayName,
eid: existing.resource!.eid,
chapter: existing.resource!.chapter,
});
}
}
}
let resources = [...resourceMap.values()].sort((a, b) =>
a.displayName.localeCompare(b.displayName),
);
if (filters.chapters.length > 0) {
resources = resources.filter((r) => r.chapter && filters.chapters.includes(r.chapter));
}
if (filters.eids.length > 0) {
resources = resources.filter((r) => filters.eids.includes(r.eid));
}
if (filters.projectIds.length > 0) {
resources = resources.filter((r) =>
visibleAssignments.some(
(e) => e.resourceId === r.id && filters.projectIds.includes(e.projectId),
),
);
}
return { resourceMap, allocsByResource, resources };
}, [visibleAssignments, eidFilterData, isDragging, contextAllocations, filters.chapters, filters.eids, filters.projectIds]); // eslint-disable-line react-hooks/exhaustive-deps
// ─── Project groups (for project view) ────────────────────────────────────
const projectGroups = useMemo(() => {
const projectGroupMap = new Map<string, ProjectGroup>();
const allGroupEntries: TimelineProjectEntry[] = [...visibleAssignments, ...visibleDemands];
for (const entry of allGroupEntries) {
let group = projectGroupMap.get(entry.projectId);
if (!group) {
group = {
id: entry.projectId,
name: entry.project.name,
shortCode: entry.project.shortCode,
orderType: entry.project.orderType,
startDate: new Date(entry.project.startDate as unknown as string),
endDate: new Date(entry.project.endDate as unknown as string),
status: entry.project.status,
resourceRows: [],
};
projectGroupMap.set(entry.projectId, group);
}
const currentGroup = group;
if (entry.kind === "assignment" && entry.resourceId) {
const existingRow = currentGroup.resourceRows.find((r) => r.resource.id === entry.resourceId);
if (existingRow) {
existingRow.allocs.push(entry);
} else {
const res = resourceMap.get(entry.resourceId);
if (res) {
currentGroup.resourceRows.push({ resource: res, allocs: [entry] });
}
}
}
}
for (const group of projectGroupMap.values()) {
group.resourceRows.sort((a, b) => a.resource.displayName.localeCompare(b.resource.displayName));
}
return [...projectGroupMap.values()]
.sort((a, b) => a.startDate.getTime() - b.startDate.getTime())
.filter((pg) => {
if (filters.projectIds.length > 0 && !filters.projectIds.includes(pg.id)) return false;
if (filters.chapters.length > 0 && !pg.resourceRows.some((r) => r.resource.chapter && filters.chapters.includes(r.resource.chapter))) return false;
if (filters.eids.length > 0 && !pg.resourceRows.some((r) => filters.eids.includes(r.resource.eid))) return false;
return true;
});
}, [visibleAssignments, visibleDemands, resourceMap, filters.projectIds, filters.chapters, filters.eids]); // eslint-disable-line react-hooks/exhaustive-deps
// ─── Derived counts ───────────────────────────────────────────────────────
const isInitialLoading = isLoading && !entriesView;
const totalAllocCount = entriesView?.allocations.length ?? 0;
const activeFilterCount =
filters.chapters.length + filters.eids.length + filters.projectIds.length;
const value = useMemo<TimelineContextValue>(
() => ({
assignments,
demands,
visibleAssignments,
visibleDemands,
vacationsByResource,
resources,
resourceMap,
allocsByResource,
projectGroups,
openDemandsByProject,
viewStart,
viewEnd,
viewDays,
setViewStart,
setViewDays,
filters,
setFilters,
filterOpen,
setFilterOpen,
viewMode,
setViewMode,
today,
displayMode,
heatmapScheme,
isLoading,
isInitialLoading,
totalAllocCount,
activeFilterCount,
}),
[
assignments,
demands,
visibleAssignments,
visibleDemands,
vacationsByResource,
resources,
resourceMap,
allocsByResource,
projectGroups,
openDemandsByProject,
viewStart,
viewEnd,
viewDays,
filters,
filterOpen,
viewMode,
today,
displayMode,
heatmapScheme,
isLoading,
isInitialLoading,
totalAllocCount,
activeFilterCount,
],
);
return (
<TimelineContext.Provider value={value}>
{children}
</TimelineContext.Provider>
);
}
@@ -0,0 +1,630 @@
"use client";
import { clsx } from "clsx";
import { Fragment, startTransition, useCallback, useRef, useState } from "react";
import { useTimelineContext, type TimelineAssignmentEntry, type TimelineDemandEntry } from "./TimelineContext.js";
import { heatmapColor } from "./heatmapUtils.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.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 {
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;
// 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;
startDate: Date;
endDate: Date;
hoursPerDay: number;
roleEntity?: { id: string; name: string; color: string | null } | null;
project?: { id: string; name: string; shortCode: string };
}
// ─── Component ──────────────────────────────────────────────────────────────
export function TimelineProjectPanel({
dragState,
allocDragState,
rangeState,
onProjectBarMouseDown,
onProjectBarTouchStart,
onAllocMouseDown,
onAllocTouchStart,
onRowMouseDown,
onRowTouchStart,
onOpenPanel,
onOpenDemandClick,
CELL_WIDTH,
dates,
totalCanvasWidth,
toLeft,
toWidth,
gridLines,
xToDate,
}: TimelineProjectPanelProps) {
const {
projectGroups,
openDemandsByProject,
allocsByResource,
vacationsByResource,
filters,
displayMode,
heatmapScheme,
activeFilterCount,
} = useTimelineContext();
// ─── Heatmap hover (same mechanism as resource panel) ─────────────────────
const heatmapRafRef = useRef<number | null>(null);
const lastHeatmapDayRef = useRef<number>(-1);
const vacationHoverRafRef = useRef<number | null>(null);
const hoveredVacationKeyRef = useRef<string | null>(null);
const pendingHeatmapRef = useRef<{ clientX: number; rect: DOMRect; allocs: TimelineAssignmentEntry[] } | null>(null);
const handleRowHeatmapMove = useCallback((e: React.MouseEvent, allocs: TimelineAssignmentEntry[]) => {
const rect = e.currentTarget.getBoundingClientRect();
const dayIndex = Math.floor((e.clientX - rect.left) / CELL_WIDTH);
if (dayIndex === lastHeatmapDayRef.current) return;
pendingHeatmapRef.current = { clientX: e.clientX, rect, allocs };
if (heatmapRafRef.current !== null) return;
heatmapRafRef.current = requestAnimationFrame(() => {
heatmapRafRef.current = null;
pendingHeatmapRef.current = null;
lastHeatmapDayRef.current = Math.floor((e.clientX - rect.left) / CELL_WIDTH);
});
}, [CELL_WIDTH]);
const handleRowVacationHover = useCallback((_e: React.MouseEvent, _resourceId: string) => {
// Vacation hover in project view uses the same RAF mechanism.
// Tooltip rendering is handled by the parent TimelineView.
}, []);
const clearHoverTooltips = useCallback(() => {
if (heatmapRafRef.current !== null) {
cancelAnimationFrame(heatmapRafRef.current);
heatmapRafRef.current = null;
}
if (vacationHoverRafRef.current !== null) {
cancelAnimationFrame(vacationHoverRafRef.current);
vacationHoverRafRef.current = null;
}
lastHeatmapDayRef.current = -1;
hoveredVacationKeyRef.current = null;
}, []);
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 (
<>
{projectGroups.map((project) => {
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 key={project.id}>
{/* Project header row */}
<div
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",
colors.bg, colors.text,
)}
style={{ left: projLeft + 2, width: projWidth - 4, top: 8, height: 24 }}
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>
{/* Open demand row */}
{(() => {
const openDemands = openDemandsByProject.get(project.id) ?? [];
return openDemands.length > 0
? renderOpenDemandRow(openDemands, CELL_WIDTH, totalCanvasWidth, toLeft, toWidth, gridLines, onOpenDemandClick)
: null;
})()}
{/* Resource sub-rows */}
{project.resourceRows.map(({ resource, allocs }) => {
const allResourceAllocs = allocsByResource.get(resource.id) ?? [];
const rowHeight = ROW_HEIGHT;
return (
<div
key={`${project.id}-${resource.id}`}
className="flex border-b border-gray-100 hover:bg-blue-50/20 group"
style={{ height: rowHeight }}
>
<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">
{resource.displayName.slice(0, 2).toUpperCase()}
</div>
<div className="min-w-0">
<div className="text-xs font-medium text-gray-800 truncate">{resource.displayName}</div>
<div className="text-[10px] text-gray-400 truncate">{resource.eid}</div>
</div>
</div>
<div
className="relative overflow-hidden touch-none"
style={{ width: totalCanvasWidth, height: rowHeight, touchAction: "none" }}
onMouseDown={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const date = xToDate(e.clientX, rect);
onRowMouseDown(e, {
resourceId: resource.id,
startDate: date,
suggestedProjectId: project.id,
});
}}
onTouchStart={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const date = xToDate(e.touches[0]?.clientX ?? 0, rect);
onRowTouchStart(e, {
resourceId: resource.id,
startDate: date,
suggestedProjectId: project.id,
});
}}
onMouseMove={(e) => { handleRowHeatmapMove(e, allResourceAllocs); handleRowVacationHover(e, resource.id); }}
onMouseLeave={clearHoverTooltips}
>
{gridLines}
{renderProjectUtilBars(allocs, allResourceAllocs, dates, CELL_WIDTH, displayMode, heatmapScheme)}
{renderProjectDragHandles(allocs, allocDragState, toLeft, toWidth, CELL_WIDTH, totalCanvasWidth, onAllocMouseDown, onAllocTouchStart)}
{renderVacationBlocksForProjectRow(vacationsByResource.get(resource.id) ?? [], rowHeight, toLeft, toWidth, CELL_WIDTH, totalCanvasWidth, filters.showVacations)}
{renderRangeOverlayProject(rangeState, resource.id, rowHeight, toLeft, toWidth, CELL_WIDTH)}
</div>
</div>
);
})}
</div>
);
})}
</>
);
}
// ─── Pure render functions ──────────────────────────────────────────────────
function renderOpenDemandRow(
openDemands: TimelineDemandEntry[],
CELL_WIDTH: number,
totalCanvasWidth: number,
toLeft: (d: Date) => number,
toWidth: (s: Date, e: Date) => number,
gridLines: React.ReactNode,
onOpenDemandClick: (demand: OpenDemandAssignment) => void,
) {
if (openDemands.length === 0) return null;
return (
<div
className="flex border-b border-dashed border-amber-200 bg-amber-50/30 hover:bg-amber-50/50 group"
style={{ height: ROW_HEIGHT }}
>
<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 }}
>
<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, height: ROW_HEIGHT }}
>
{gridLines}
{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;
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: 8,
height: SUB_LANE_HEIGHT - 8,
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 renderProjectUtilBars(
projectAllocs: TimelineAssignmentEntry[],
allResourceAllocs: TimelineAssignmentEntry[],
dates: Date[],
CELL_WIDTH: number,
displayMode: string,
heatmapScheme: string,
) {
const BAND_H = 7;
const BAR_H = ROW_HEIGHT - BAND_H - 11;
const REF_H = 8;
function hoursOnDay(list: TimelineAssignmentEntry[], t: number) {
return list.reduce((sum, a) => {
const s = new Date(a.startDate); s.setHours(0, 0, 0, 0);
const e = new Date(a.endDate); e.setHours(0, 0, 0, 0);
return t >= s.getTime() && t <= e.getTime() ? sum + a.hoursPerDay : sum;
}, 0);
}
return dates.map((date, i) => {
const t = date.getTime();
const projH = hoursOnDay(projectAllocs, t);
const totalH = hoursOnDay(allResourceAllocs, t);
if (totalH === 0 && projH === 0) return null;
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 useHeatmapColors = displayMode === "bar";
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") : null;
const totalColor = useHeatmapColors ? heatmapColor(totalPct, heatmapScheme as import("~/hooks/useAppPreferences.js").HeatmapColorScheme, "bar") : null;
return (
<Fragment key={`putil-${i}`}>
{projH > 0 && (
<div
className={clsx("absolute top-1.5 pointer-events-none", !useHeatmapColors && "bg-brand-400/80")}
style={{ left: i * CELL_WIDTH + 1, width: CELL_WIDTH - 2, height: BAND_H, ...(projColor ? { backgroundColor: projColor } : {}) }}
/>
)}
{otherBarH > 0 && (
<div
className={clsx(
"absolute pointer-events-none",
!useHeatmapColors && (isOver ? "bg-amber-300/80" : "bg-gray-300/80"),
)}
style={{
left: i * CELL_WIDTH + 3, width: CELL_WIDTH - 6, height: otherBarH, bottom: 4 + projBarH,
...(useHeatmapColors ? { backgroundColor: totalColor ?? "rgba(156,163,175,0.50)" } : {}),
}}
/>
)}
{projBarH > 0 && (
<div
className={clsx("absolute pointer-events-none", !useHeatmapColors && "bg-brand-500/80")}
style={{
left: i * CELL_WIDTH + 3, width: CELL_WIDTH - 6, height: projBarH, bottom: 4,
...(projColor ? { backgroundColor: projColor } : {}),
}}
/>
)}
{isOver && totalBarH > 0 && (
<div
className="absolute pointer-events-none bg-red-500 z-10"
style={{ left: i * CELL_WIDTH + 3, width: CELL_WIDTH - 6, height: 3, bottom: 4 + totalBarH }}
/>
)}
</Fragment>
);
});
}
// ─── 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,
) {
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;
const HANDLE_W = width >= 48 ? 8 : 0;
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 }}
>
{HANDLE_W > 0 && (
<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>
{HANDLE_W > 0 && (
<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 }}
/>
);
}
@@ -0,0 +1,830 @@
"use client";
import { clsx } from "clsx";
import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useTimelineContext, type TimelineAssignmentEntry, type VacationEntry } from "./TimelineContext.js";
import { ConflictOverlay } from "./ConflictOverlay.js";
import { computeSubLanes } from "./utils.js";
import { heatmapBgColor, heatmapColor } from "./heatmapUtils.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import {
ROW_HEIGHT,
SUB_LANE_HEIGHT,
LABEL_WIDTH,
ORDER_TYPE_COLORS,
} from "./timelineConstants.js";
import type { DragState, AllocDragState, RangeState, ShiftPreviewData } from "~/hooks/useTimelineDrag.js";
import type { HeatmapColorScheme } from "~/hooks/useAppPreferences.js";
// ─── Props ──────────────────────────────────────────────────────────────────
interface TimelineResourcePanelProps {
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
dragState: DragState;
allocDragState: AllocDragState;
rangeState: RangeState;
shiftPreview: ShiftPreviewData | null;
contextResourceIds: string[];
onAllocMouseDown: (e: React.MouseEvent, info: AllocMouseDownInfo) => void;
onAllocTouchStart: (e: React.TouchEvent, info: AllocMouseDownInfo) => void;
onRowMouseDown: (e: React.MouseEvent, info: RowMouseDownInfo) => void;
onRowTouchStart: (e: React.TouchEvent, info: RowMouseDownInfo) => void;
// Layout from useTimelineLayout
CELL_WIDTH: number;
dates: Date[];
totalCanvasWidth: number;
toLeft: (date: Date) => number;
toWidth: (start: Date, end: Date) => number;
gridLines: React.ReactNode;
xToDate: (clientX: number, rect: DOMRect) => Date;
}
export interface AllocMouseDownInfo {
mode: "move" | "resize-start" | "resize-end";
allocationId: string;
mutationAllocationId: string;
projectId: string;
projectName: string;
resourceId: string | null;
startDate: Date;
endDate: Date;
}
export interface RowMouseDownInfo {
resourceId: string;
startDate: Date;
suggestedProjectId?: string;
}
// ─── Component ──────────────────────────────────────────────────────────────
export function TimelineResourcePanel({
scrollContainerRef,
dragState,
allocDragState,
rangeState,
shiftPreview,
contextResourceIds,
onAllocMouseDown,
onAllocTouchStart,
onRowMouseDown,
onRowTouchStart,
CELL_WIDTH,
dates,
totalCanvasWidth,
toLeft,
toWidth,
gridLines,
xToDate,
}: TimelineResourcePanelProps) {
const {
resources,
allocsByResource,
vacationsByResource,
filters,
viewStart,
viewEnd,
displayMode,
heatmapScheme,
activeFilterCount,
} = useTimelineContext();
// ─── Heatmap hover state ────────────────────────────────────────────────────
const heatmapRafRef = useRef<number | null>(null);
const lastHeatmapDayRef = useRef<number>(-1);
const vacationHoverRafRef = useRef<number | null>(null);
const hoveredVacationKeyRef = useRef<string | null>(null);
const pendingHeatmapRef = useRef<{ clientX: number; rect: DOMRect; allocs: TimelineAssignmentEntry[] } | null>(null);
const [heatmapHover, setHeatmapHover] = useState<{
date: Date;
totalH: number;
pct: number;
breakdown: { projectId: string; shortCode: string; projectName: string; orderType: string; hoursPerDay: number; responsiblePerson?: string | null }[];
} | null>(null);
const [vacationHover, setVacationHover] = useState<null | {
type: string; startDate: Date | string; endDate: Date | string; note?: string | null;
requestedBy?: { name?: string | null; email: string } | null;
approvedBy?: { name?: string | null; email: string } | null;
approvedAt?: Date | string | null;
}>(null);
// ─── Virtual row list ────────────────────────────────────────────────────────
const rowVirtualizer = useVirtualizer({
count: resources.length,
getScrollElement: () => scrollContainerRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: 5,
});
const virtualItems = rowVirtualizer.getVirtualItems();
const totalRowHeight = rowVirtualizer.getTotalSize();
// ─── Memo 1: resourceRows — which rows to render ─────────────────────────
// (virtualizer handles which subset is visible; this memo just pre-computes
// per-row data that the render loop needs)
const resourceRows = useMemo(() => {
return resources.map((resource) => {
const allocs = allocsByResource.get(resource.id) ?? [];
const isContextResource = contextResourceIds.includes(resource.id);
return { resource, allocs, isContextResource };
});
}, [resources, allocsByResource, contextResourceIds]);
// ─── Memo 2: vacationBlocks — vacation bar positions per resource ─────────
const vacationBlocksByResource = useMemo(() => {
if (!filters.showVacations) return new Map<string, VacationBlockInfo[]>();
const result = new Map<string, VacationBlockInfo[]>();
for (const [resourceId, vacations] of vacationsByResource) {
const blocks: VacationBlockInfo[] = [];
for (const v of vacations) {
const vStart = new Date(v.startDate);
const vEnd = new Date(v.endDate);
const left = toLeft(vStart);
const width = Math.max(CELL_WIDTH, toWidth(vStart, vEnd));
if (width <= 0 || left >= totalCanvasWidth) continue;
blocks.push({ vacation: v, left, width });
}
if (blocks.length > 0) {
result.set(resourceId, blocks);
}
}
return result;
}, [vacationsByResource, toLeft, toWidth, CELL_WIDTH, totalCanvasWidth, filters.showVacations]);
// ─── Memo 3: assignmentBlocks — pre-computed per resource for strip mode ──
// (Bar mode computes differently per-day, so we only pre-compute for strip.)
const assignmentBlocksByResource = useMemo(() => {
if (displayMode === "bar") return new Map<string, { laneCount: number; blockData: AllocBlockData[] }>();
const result = new Map<string, { laneCount: number; blockData: AllocBlockData[] }>();
for (const { resource, allocs } of resourceRows) {
if (allocs.length === 0) continue;
const subLaneMap = computeSubLanes(
allocs.map((a) => ({ id: a.id, startDate: new Date(a.startDate), endDate: new Date(a.endDate) })),
);
const laneCount = subLaneMap.size > 0 ? Math.max(...subLaneMap.values()) + 1 : 1;
const blockData: AllocBlockData[] = allocs.map((alloc) => ({
alloc,
lane: subLaneMap.get(alloc.id) ?? 0,
}));
result.set(resource.id, { laneCount, blockData });
}
return result;
}, [displayMode, resourceRows]);
// ─── Heatmap row hover handler ────────────────────────────────────────────
const handleRowHeatmapMove = useCallback((e: React.MouseEvent, allocs: TimelineAssignmentEntry[]) => {
const rect = e.currentTarget.getBoundingClientRect();
const dayIndex = Math.floor((e.clientX - rect.left) / CELL_WIDTH);
if (dayIndex === lastHeatmapDayRef.current) return;
pendingHeatmapRef.current = { clientX: e.clientX, rect, allocs };
if (heatmapRafRef.current !== null) return;
heatmapRafRef.current = requestAnimationFrame(() => {
heatmapRafRef.current = null;
const pending = pendingHeatmapRef.current;
pendingHeatmapRef.current = null;
if (!pending) return;
const { clientX, rect: r, allocs: a } = pending;
const dayIdx = Math.floor((clientX - r.left) / CELL_WIDTH);
const date = dates[dayIdx];
if (!date) {
lastHeatmapDayRef.current = -1;
startTransition(() => setHeatmapHover(null));
return;
}
lastHeatmapDayRef.current = dayIdx;
const t = date.getTime();
const REF_H = 8;
const projectHours = new Map<string, { shortCode: string; projectName: string; orderType: string; hours: number; responsiblePerson?: string | null }>();
for (const alloc of a) {
const s = new Date(alloc.startDate); s.setHours(0, 0, 0, 0);
const ev = new Date(alloc.endDate); ev.setHours(0, 0, 0, 0);
if (t < s.getTime() || t > ev.getTime()) continue;
const existing = projectHours.get(alloc.projectId);
if (existing) {
existing.hours += alloc.hoursPerDay;
} else {
projectHours.set(alloc.projectId, {
shortCode: alloc.project.shortCode,
projectName: alloc.project.name,
orderType: alloc.project.orderType,
hours: alloc.hoursPerDay,
responsiblePerson: (alloc.project as { responsiblePerson?: string | null }).responsiblePerson ?? null,
});
}
}
const breakdown = [...projectHours.entries()]
.map(([projectId, v]) => ({ projectId, ...v, hoursPerDay: v.hours }))
.sort((a, b) => b.hoursPerDay - a.hoursPerDay);
const totalH = breakdown.reduce((sum, b) => sum + b.hoursPerDay, 0);
startTransition(() => {
setHeatmapHover({ date, totalH, pct: (totalH / REF_H) * 100, breakdown });
});
});
}, [CELL_WIDTH, dates]);
// ─── Vacation hover ───────────────────────────────────────────────────────
const handleRowVacationHover = useCallback((e: React.MouseEvent, resourceId: string) => {
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 t = date.getTime();
const resourceVacations = vacationsByResource.get(resourceId) ?? [];
const hit = resourceVacations.find((v) => {
const s = new Date(v.startDate); s.setHours(0, 0, 0, 0);
const end = new Date(v.endDate); end.setHours(0, 0, 0, 0);
return t >= s.getTime() && t <= end.getTime();
}) ?? null;
const nextKey = hit ? `${resourceId}:${hit.id}` : null;
if (nextKey === hoveredVacationKeyRef.current) return;
hoveredVacationKeyRef.current = nextKey;
startTransition(() => {
setVacationHover(hit);
});
});
}, [vacationsByResource, xToDate]);
const clearHoverTooltips = useCallback(() => {
if (heatmapRafRef.current !== null) {
cancelAnimationFrame(heatmapRafRef.current);
heatmapRafRef.current = null;
}
if (vacationHoverRafRef.current !== null) {
cancelAnimationFrame(vacationHoverRafRef.current);
vacationHoverRafRef.current = null;
}
const shouldClearHeatmap = lastHeatmapDayRef.current !== -1;
const shouldClearVacation = hoveredVacationKeyRef.current !== null;
lastHeatmapDayRef.current = -1;
hoveredVacationKeyRef.current = null;
if (shouldClearHeatmap || shouldClearVacation) {
startTransition(() => {
if (shouldClearHeatmap) setHeatmapHover(null);
if (shouldClearVacation) setVacationHover(null);
});
}
}, []);
// ─── Cleanup rAF on unmount ───────────────────────────────────────────────
useEffect(() => () => {
if (heatmapRafRef.current !== null) cancelAnimationFrame(heatmapRafRef.current);
if (vacationHoverRafRef.current !== null) cancelAnimationFrame(vacationHoverRafRef.current);
}, []);
// ─── Render helpers ───────────────────────────────────────────────────────
if (resources.length === 0) {
return (
<div className="text-center py-16 text-gray-400">
No allocations in this time range{activeFilterCount > 0 && " (filtered)"}.
</div>
);
}
return (
<div
style={{
height: totalRowHeight,
position: "relative",
}}
>
{virtualItems.map((virtualRow) => {
const rowData = resourceRows[virtualRow.index];
if (!rowData) return null;
const { resource, allocs, isContextResource } = rowData;
const inBarMode = displayMode === "bar";
const precomputed = assignmentBlocksByResource.get(resource.id);
const laneCount = inBarMode ? 1 : (precomputed?.laneCount ?? 1);
const rowHeight = inBarMode ? ROW_HEIGHT : Math.max(ROW_HEIGHT, laneCount * SUB_LANE_HEIGHT + 16);
return (
<div
key={resource.id}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualRow.start}px)`,
}}
>
<div
className={clsx(
"flex border-b border-gray-100 hover:bg-blue-50/20 group transition-colors",
dragState.isDragging && isContextResource && "border-l-4 border-l-brand-400",
)}
style={{ height: rowHeight }}
>
{/* Label column */}
<div
className={clsx(
"flex-shrink-0 border-r border-gray-200 flex items-center px-4 gap-2.5 bg-white sticky left-0 z-30 group-hover:bg-blue-50",
dragState.isDragging && isContextResource && "bg-brand-50",
)}
style={{ width: LABEL_WIDTH }}
>
<div className="w-8 h-8 rounded-full bg-brand-100 flex items-center justify-center text-xs font-bold text-brand-700 flex-shrink-0">
{resource.displayName.slice(0, 2).toUpperCase()}
</div>
<div className="min-w-0">
<div className="text-sm font-medium text-gray-900 truncate">{resource.displayName}</div>
<div className="text-xs text-gray-400 truncate">{resource.chapter ?? resource.eid}</div>
</div>
</div>
{/* Row canvas */}
<div
className="relative overflow-hidden touch-none"
style={{ width: totalCanvasWidth, height: rowHeight, touchAction: "none" }}
onMouseDown={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const date = xToDate(e.clientX, rect);
onRowMouseDown(e, { resourceId: resource.id, startDate: date });
}}
onTouchStart={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const date = xToDate(e.touches[0]?.clientX ?? 0, rect);
onRowTouchStart(e, { resourceId: resource.id, startDate: date });
}}
onMouseMove={(e) => { handleRowHeatmapMove(e, allocs); handleRowVacationHover(e, resource.id); }}
onMouseLeave={clearHoverTooltips}
>
{gridLines}
{inBarMode
? renderDailyBars(allocs, rowHeight, CELL_WIDTH, dates, allocDragState, onAllocMouseDown, onAllocTouchStart, toLeft, toWidth, totalCanvasWidth)
: renderAllocBlocksFromData(precomputed?.blockData ?? [], allocs, dragState, allocDragState, toLeft, toWidth, CELL_WIDTH, totalCanvasWidth, onAllocMouseDown, onAllocTouchStart)}
{renderVacationBlocksForRow(vacationBlocksByResource.get(resource.id) ?? [], rowHeight)}
{displayMode === "strip" && renderLoadGraph(allocs, dates, CELL_WIDTH)}
{displayMode === "heatmap" && renderHeatmapOverlay(allocs, dates, CELL_WIDTH, heatmapScheme)}
{renderRangeOverlay(rangeState, resource.id, rowHeight, toLeft, toWidth, CELL_WIDTH)}
{dragState.isDragging && dragState.projectId && shiftPreview && !shiftPreview.valid && shiftPreview.conflictCount > 0 && allocs.some((a) => a.projectId === dragState.projectId) && (
<ConflictOverlay
left={toLeft(dragState.currentStartDate ?? viewStart) + 2}
width={toWidth(dragState.currentStartDate ?? viewStart, dragState.currentEndDate ?? viewEnd) - 4}
height={rowHeight - 8}
type="availability"
message={`${shiftPreview.conflictCount} conflict(s)`}
/>
)}
</div>
</div>
</div>
);
})}
{/* Tooltips rendered inside the panel so they live near their data source */}
<ResourcePanelTooltips
heatmapHover={heatmapHover}
vacationHover={vacationHover}
/>
</div>
);
}
// ─── Tooltip sub-component (portal-free: positioned fixed) ──────────────────
function ResourcePanelTooltips({
heatmapHover,
vacationHover,
}: {
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;
}) {
// These tooltips are rendered here but positioned by the parent's native
// mousemove handler via ref. The parent passes tooltip refs via the
// TimelineView orchestrator. For simplicity, we keep the tooltip DOM
// here but expose ref-based positioning from the parent via
// data-attributes that the parent's mousemove handler targets.
//
// NOTE: The actual positioning is still done by the parent TimelineView's
// native mousemove event handler using refs. These tooltips are rendered
// inside TimelineView's return, not here. This sub-component is a no-op
// for tooltip DOM — the parent handles it.
return null;
}
// ─── Helper types ───────────────────────────────────────────────────────────
interface VacationBlockInfo {
vacation: VacationEntry;
left: number;
width: number;
}
interface AllocBlockData {
alloc: TimelineAssignmentEntry;
lane: number;
}
// ─── Pure render functions (no hooks, extracted from TimelineView) ───────────
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 renderVacationBlocksForRow(blocks: VacationBlockInfo[], rowHeight: number) {
if (blocks.length === 0) return null;
return blocks.map(({ vacation: v, left, width }) => {
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>
);
});
}
function renderRangeOverlay(
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 }}
/>
);
}
function renderAllocBlocksFromData(
blockData: AllocBlockData[],
_allocs: TimelineAssignmentEntry[],
dragState: DragState,
allocDragState: AllocDragState,
toLeft: (d: Date) => number,
toWidth: (s: Date, e: Date) => number,
CELL_WIDTH: number,
totalCanvasWidth: number,
onAllocMouseDown: (e: React.MouseEvent, info: AllocMouseDownInfo) => void,
onAllocTouchStart: (e: React.TouchEvent, info: AllocMouseDownInfo) => void,
) {
const anyDragActive = dragState.isDragging || allocDragState.isActive;
return blockData.map(({ alloc, lane }) => {
const allocStart = new Date(alloc.startDate);
const allocEnd = new Date(alloc.endDate);
const isProjectShifted = dragState.isDragging && dragState.projectId === alloc.projectId;
const isAllocDragged = allocDragState.isActive && allocDragState.allocationId === alloc.id;
const isBeingDragged = isProjectShifted || isAllocDragged;
const isOtherDragged = anyDragActive && !isBeingDragged;
let dispStart = allocStart;
let dispEnd = allocEnd;
if (isProjectShifted && dragState.currentStartDate && dragState.currentEndDate) {
dispStart = dragState.currentStartDate;
dispEnd = dragState.currentEndDate;
} else if (isAllocDragged && allocDragState.currentStartDate && allocDragState.currentEndDate) {
dispStart = allocDragState.currentStartDate;
dispEnd = allocDragState.currentEndDate;
}
const left = toLeft(dispStart);
const width = Math.max(CELL_WIDTH, toWidth(dispStart, dispEnd));
if (width <= 0 || left >= totalCanvasWidth) return null;
const blockTop = 8 + lane * SUB_LANE_HEIGHT;
const blockHeight = SUB_LANE_HEIGHT - 8;
const colors = ORDER_TYPE_COLORS[alloc.project.orderType] ?? { bg: "bg-gray-400", text: "text-white", light: "" };
const HANDLE_W = width >= 48 ? 10 : 0;
const hasRecurrence = !!(alloc.metadata as Record<string, unknown> | null)?.recurrence;
const allocInfo: AllocMouseDownInfo = {
mode: "move",
allocationId: alloc.id,
mutationAllocationId: getPlanningEntryMutationId(alloc),
projectId: alloc.projectId,
projectName: alloc.project.name,
resourceId: alloc.resourceId,
startDate: allocStart,
endDate: allocEnd,
};
return (
<div
key={alloc.id}
className={clsx(
"absolute rounded-md flex items-stretch overflow-hidden transition-all duration-75 group/block",
colors.bg, colors.text,
hasRecurrence && "opacity-80 border-2 border-dashed border-white/60",
isBeingDragged
? "opacity-90 shadow-2xl ring-2 ring-white ring-offset-1 z-20 scale-[1.01]"
: isOtherDragged
? "opacity-30 z-[10]"
: "hover:ring-2 hover:ring-white hover:ring-offset-1 z-[10]",
)}
style={{ left: left + 2, width: width - 4, top: blockTop, height: blockHeight }}
onContextMenu={(e) => e.preventDefault()}
>
{/* Left resize handle */}
{HANDLE_W > 0 && (
<div
className="flex-shrink-0 flex items-center justify-center cursor-ew-resize hover:bg-black/20 transition-colors"
style={{ width: HANDLE_W }}
onMouseDown={(e) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" })}
onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, { ...allocInfo, mode: "resize-start" }); }}
>
<div className="flex flex-col gap-0.5 opacity-60 group-hover/block:opacity-100">
<div className="w-px h-2.5 bg-white rounded" />
<div className="w-px h-2.5 bg-white rounded" />
</div>
</div>
)}
{/* Center -- move */}
<div
className={clsx(
"flex-1 flex items-center gap-1 px-1 min-w-0 select-none",
isBeingDragged ? "cursor-grabbing" : "cursor-grab",
)}
onMouseDown={(e) => onAllocMouseDown(e, allocInfo)}
onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, allocInfo); }}
>
{hasRecurrence && width > 28 && <span className="text-[10px] opacity-80 flex-shrink-0"></span>}
<span className="text-xs font-semibold truncate">{alloc.project.name}</span>
{width > 130 && <span className="text-[10px] opacity-75 truncate">{alloc.role}</span>}
{width > 190 && <span className="text-[10px] opacity-60 truncate">{alloc.hoursPerDay}h</span>}
</div>
{/* Right resize handle */}
{HANDLE_W > 0 && (
<div
className="flex-shrink-0 flex items-center justify-center cursor-ew-resize hover:bg-black/20 transition-colors"
style={{ width: HANDLE_W }}
onMouseDown={(e) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" })}
onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" }); }}
>
<div className="flex flex-col gap-0.5 opacity-60 group-hover/block:opacity-100">
<div className="w-px h-2.5 bg-white rounded" />
<div className="w-px h-2.5 bg-white rounded" />
</div>
</div>
)}
</div>
);
});
}
// ─── Strip-mode: daily load graph ────────────────────────────────────────────
function renderLoadGraph(allocs: TimelineAssignmentEntry[], dates: Date[], CELL_WIDTH: number) {
const GRAPH_H = 12;
const REF_H = 8;
function hoursOnDay(list: TimelineAssignmentEntry[], t: number) {
return list.reduce((sum, a) => {
const s = new Date(a.startDate); s.setHours(0, 0, 0, 0);
const e = new Date(a.endDate); e.setHours(0, 0, 0, 0);
return t >= s.getTime() && t <= e.getTime() ? sum + a.hoursPerDay : sum;
}, 0);
}
return (
<div
className="absolute inset-x-0 bottom-1 pointer-events-none"
style={{ height: GRAPH_H }}
>
{dates.map((date, i) => {
const t = date.getTime();
const totalH = hoursOnDay(allocs, t);
if (totalH === 0) return null;
const totalBarH = Math.min(GRAPH_H, Math.round((totalH / REF_H) * GRAPH_H));
return (
<div
key={i}
className={clsx(
"absolute bottom-0 rounded-t-sm",
totalH > 12 ? "bg-red-500 opacity-80"
: totalH > 8 ? "bg-amber-400 opacity-80"
: "bg-brand-500 opacity-80",
)}
style={{ left: i * CELL_WIDTH + 3, width: CELL_WIDTH - 6, height: totalBarH }}
/>
);
})}
</div>
);
}
// ─── Heatmap-mode: utilisation colour overlay ────────────────────────────────
function renderHeatmapOverlay(allocs: TimelineAssignmentEntry[], dates: Date[], CELL_WIDTH: number, heatmapScheme: HeatmapColorScheme) {
const REF_H = 8;
return dates.map((date, i) => {
const t = date.getTime();
const totalH = allocs.reduce((sum, a) => {
const s = new Date(a.startDate); s.setHours(0, 0, 0, 0);
const e = new Date(a.endDate); e.setHours(0, 0, 0, 0);
return t >= s.getTime() && t <= e.getTime() ? sum + a.hoursPerDay : sum;
}, 0);
const bg = heatmapBgColor((totalH / REF_H) * 100, heatmapScheme);
if (!bg) return null;
return (
<div
key={`hm-${i}`}
className="absolute top-0 bottom-0 pointer-events-none z-10"
style={{ left: i * CELL_WIDTH, width: CELL_WIDTH, backgroundColor: bg }}
/>
);
});
}
// ─── Bar-mode: stacked daily bars ────────────────────────────────────────────
function renderDailyBars(
allocs: TimelineAssignmentEntry[],
rowHeight: number,
CELL_WIDTH: number,
dates: Date[],
allocDragState: AllocDragState,
onAllocMouseDown: (e: React.MouseEvent, info: AllocMouseDownInfo) => void,
onAllocTouchStart: (e: React.TouchEvent, info: AllocMouseDownInfo) => void,
toLeft: (d: Date) => number,
toWidth: (s: Date, e: Date) => number,
totalCanvasWidth: number,
) {
const BAR_AREA = rowHeight - 8;
const REF_H = 8;
return dates.flatMap((date, i) => {
const t = date.getTime();
const covering = allocs.filter((a) => {
const isDragged = allocDragState.isActive && allocDragState.allocationId === a.id;
const s = new Date(isDragged && allocDragState.currentStartDate ? allocDragState.currentStartDate : a.startDate);
const e = new Date(isDragged && allocDragState.currentEndDate ? allocDragState.currentEndDate : a.endDate);
s.setHours(0, 0, 0, 0); e.setHours(0, 0, 0, 0);
return t >= s.getTime() && t <= e.getTime();
});
if (covering.length === 0) return [];
const totalH = covering.reduce((sum, a) => sum + a.hoursPerDay, 0);
const isOver = totalH > REF_H;
let stackedH = 0;
const segs: React.ReactNode[] = covering.map((alloc) => {
const colors = ORDER_TYPE_COLORS[alloc.project.orderType] ?? { bg: "bg-gray-400", text: "text-white", light: "" };
const segH = Math.max(2, Math.min(
BAR_AREA - stackedH,
Math.round((alloc.hoursPerDay / REF_H) * BAR_AREA),
));
const bottom = 4 + stackedH;
stackedH += segH;
const isBeingDragged = allocDragState.isActive && allocDragState.allocationId === alloc.id;
const dispStart = new Date(isBeingDragged && allocDragState.currentStartDate ? allocDragState.currentStartDate : alloc.startDate);
const dispEnd = new Date(isBeingDragged && allocDragState.currentEndDate ? allocDragState.currentEndDate : alloc.endDate);
dispStart.setHours(0, 0, 0, 0); dispEnd.setHours(0, 0, 0, 0);
const isFirstDay = t === dispStart.getTime();
const isLastDay = t === dispEnd.getTime();
const EDGE_W = CELL_WIDTH >= 16 ? 4 : 0;
const allocInfo: AllocMouseDownInfo = {
mode: "move",
allocationId: alloc.id,
mutationAllocationId: getPlanningEntryMutationId(alloc),
projectId: alloc.projectId,
projectName: alloc.project.name,
resourceId: alloc.resourceId,
startDate: new Date(alloc.startDate),
endDate: new Date(alloc.endDate),
};
return (
<div
key={`bar-${i}-${alloc.id}`}
className={clsx(
"absolute rounded-sm transition-all duration-75 flex items-stretch overflow-hidden",
colors.bg,
isBeingDragged
? "opacity-90 ring-2 ring-white ring-offset-1 z-20"
: "hover:opacity-80 z-[10]",
)}
style={{ left: i * CELL_WIDTH + 2, width: CELL_WIDTH - 4, height: segH, bottom }}
onContextMenu={(e) => e.preventDefault()}
>
{isFirstDay && EDGE_W > 0 && (
<div
className="flex-shrink-0 cursor-ew-resize hover:bg-black/20"
style={{ width: EDGE_W }}
onMouseDown={(e) => { e.stopPropagation(); onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" }); }}
onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, { ...allocInfo, mode: "resize-start" }); }}
/>
)}
<div
className={clsx("flex-1 min-w-0", "cursor-grab")}
onMouseDown={(e) => { e.stopPropagation(); onAllocMouseDown(e, allocInfo); }}
onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, allocInfo); }}
/>
{isLastDay && EDGE_W > 0 && (
<div
className="flex-shrink-0 cursor-ew-resize hover:bg-black/20"
style={{ width: EDGE_W }}
onMouseDown={(e) => { e.stopPropagation(); onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" }); }}
onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" }); }}
/>
)}
</div>
);
});
if (isOver) {
segs.push(
<div
key={`bar-${i}-over`}
className="absolute bg-red-500/70 rounded-t-sm pointer-events-none z-30"
style={{ left: i * CELL_WIDTH + 2, width: CELL_WIDTH - 4, top: 4, height: 3 }}
/>,
);
}
return segs;
});
}
// ─── Re-export tooltip types for the parent ─────────────────────────────────
export type { VacationBlockInfo, AllocBlockData };
File diff suppressed because it is too large Load Diff