refactor: consolidate duplicated code across web and API packages

- Extract shared render helpers (vacation blocks, range overlay, overbooking blink) into renderHelpers.tsx
- Centralize status badge styles and vacation color maps into status-styles.ts
- Extract dragMath.ts utility from useTimelineDrag for reuse
- Split useInvalidatePlanningViews into useInvalidateTimeline (4 queries) + useInvalidatePlanningViews (8 queries)
- Adopt findUniqueOrThrow() and Prisma select constants across API routers
- Add shared fmtEur() helper for API-side money formatting
- Wrap TimelineResourcePanel and TimelineProjectPanel with React.memo
- Fix pre-existing TS2589 deep type errors in TeamCalendar and VacationModal
- 38 files changed, reducing ~400 lines of duplicated code

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-19 00:10:08 +01:00
parent ddec3a927a
commit e7b74f13bd
38 changed files with 637 additions and 652 deletions
@@ -0,0 +1,166 @@
/**
* Computes which allocations/resources fall within the multi-select rectangle
* after the user finishes a right-click drag selection.
*/
import { useEffect } from "react";
import { LABEL_WIDTH } from "~/components/timeline/timelineConstants.js";
import type { MultiSelectState, AllocDragMode } from "~/hooks/useTimelineDrag.js";
import type { TimelineAssignmentEntry } from "~/components/timeline/TimelineContext.js";
import type { ViewMode, ResourceBrief } from "~/components/timeline/TimelineContext.js";
interface ProjectGroup {
id: string;
resourceRows: {
resource: { id: string };
allocs: TimelineAssignmentEntry[];
}[];
}
interface DemandEntry {
id: string;
startDate: Date | string;
endDate: Date | string;
}
export function useMultiSelectIntersection({
multiSelectState,
setMultiSelectState,
clearMultiSelect,
canvasRef,
viewMode,
resources,
allocsByResource,
projectGroups,
openDemandsByProject,
dates,
today,
CELL_WIDTH,
toLeft,
toWidth,
}: {
multiSelectState: MultiSelectState;
setMultiSelectState: React.Dispatch<React.SetStateAction<MultiSelectState>>;
clearMultiSelect: () => void;
canvasRef: React.RefObject<HTMLDivElement | null>;
viewMode: ViewMode;
resources: ResourceBrief[];
allocsByResource: Map<string, TimelineAssignmentEntry[]>;
projectGroups: ProjectGroup[];
openDemandsByProject: Map<string, DemandEntry[]>;
dates: Date[];
today: Date;
CELL_WIDTH: number;
toLeft: (d: Date) => number;
toWidth: (s: Date, e: Date) => number;
}) {
useEffect(() => {
// Only compute when drag just ended (isSelecting false but has coordinates)
if (multiSelectState.isSelecting) return;
if (multiSelectState.startX === 0 && multiSelectState.startY === 0) return;
if (multiSelectState.selectedAllocationIds.length > 0 || multiSelectState.selectedResourceIds.length > 0) return;
const canvasEl = canvasRef.current;
if (!canvasEl) return;
// Selection rectangle in viewport coordinates
const selTop = Math.min(multiSelectState.startY, multiSelectState.currentY);
const selBottom = Math.max(multiSelectState.startY, multiSelectState.currentY);
const selLeft = Math.min(multiSelectState.startX, multiSelectState.currentX);
const selRight = Math.max(multiSelectState.startX, multiSelectState.currentX);
// Convert viewport X to canvas-relative X for allocation matching
const canvasRect = canvasEl.getBoundingClientRect();
const canvasXOffset = canvasRect.left + LABEL_WIDTH;
const toCanvasX = (clientX: number) => clientX - canvasXOffset;
const selLeftCanvas = toCanvasX(selLeft);
const selRightCanvas = toCanvasX(selRight);
// Derive date range from pixel X positions
const colIndexStart = Math.max(0, Math.min(dates.length - 1, Math.floor(selLeftCanvas / CELL_WIDTH)));
const colIndexEnd = Math.max(0, Math.min(dates.length - 1, Math.floor(selRightCanvas / CELL_WIDTH)));
const startDate = dates[colIndexStart] ?? today;
const endDate = dates[colIndexEnd] ?? today;
const selectedIds: string[] = [];
const selectedResIds: string[] = [];
// Query all rendered row elements (virtualizer only renders visible + overscan rows)
const rowElements = canvasEl.querySelectorAll<HTMLElement>("[data-index]");
if (viewMode === "resource") {
rowElements.forEach((rowEl) => {
const idx = Number(rowEl.dataset.index);
const resource = resources[idx];
if (!resource) return;
const rowRect = rowEl.getBoundingClientRect();
if (rowRect.bottom < selTop || rowRect.top > selBottom) return;
selectedResIds.push(resource.id);
const allocs = allocsByResource.get(resource.id) ?? [];
for (const alloc of allocs) {
const allocLeft = toLeft(new Date(alloc.startDate));
const allocRight = allocLeft + toWidth(new Date(alloc.startDate), new Date(alloc.endDate));
if (allocRight >= selLeftCanvas && allocLeft <= selRightCanvas) {
selectedIds.push(alloc.id);
}
}
});
} else if (viewMode === "project") {
const projectRowEls = canvasEl.querySelectorAll<HTMLElement>("[data-project-resource-row]");
projectRowEls.forEach((rowEl) => {
const rowRect = rowEl.getBoundingClientRect();
if (rowRect.bottom < selTop || rowRect.top > selBottom) return;
const projectId = rowEl.dataset.projectId;
const resourceId = rowEl.dataset.resourceId;
if (!projectId || !resourceId) return;
const group = projectGroups.find((g) => g.id === projectId);
if (!group) return;
const row = group.resourceRows.find((r) => r.resource.id === resourceId);
if (!row) return;
for (const alloc of row.allocs) {
const allocLeft = toLeft(new Date(alloc.startDate));
const allocRight = allocLeft + toWidth(new Date(alloc.startDate), new Date(alloc.endDate));
if (allocRight >= selLeftCanvas && allocLeft <= selRightCanvas) {
selectedIds.push(alloc.id);
}
}
});
// Also check demand rows for open demand selection
const demandRowEls = canvasEl.querySelectorAll<HTMLElement>("[data-project-demand-row]");
demandRowEls.forEach((rowEl) => {
const rowRect = rowEl.getBoundingClientRect();
if (rowRect.bottom < selTop || rowRect.top > selBottom) return;
const projectId = rowEl.dataset.projectId;
if (!projectId) return;
const demands = openDemandsByProject.get(projectId) ?? [];
for (const demand of demands) {
const allocLeft = toLeft(new Date(demand.startDate));
const allocRight = allocLeft + toWidth(new Date(demand.startDate), new Date(demand.endDate));
if (allocRight >= selLeftCanvas && allocLeft <= selRightCanvas) {
selectedIds.push(demand.id);
}
}
});
}
if (selectedIds.length > 0 || selectedResIds.length > 0) {
setMultiSelectState(prev => ({
...prev,
selectedAllocationIds: selectedIds,
selectedResourceIds: selectedResIds,
dateRange: { start: startDate, end: endDate },
}));
} else {
clearMultiSelect();
}
}, [multiSelectState.isSelecting, multiSelectState.startX, multiSelectState.startY]); // eslint-disable-line react-hooks/exhaustive-deps
}