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:
@@ -7,6 +7,7 @@ import { useProjectDragContext } from "~/hooks/useProjectDragContext.js";
|
||||
import { useTimelineDrag } from "~/hooks/useTimelineDrag.js";
|
||||
import { useTimelineLayout } from "~/hooks/useTimelineLayout.js";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js";
|
||||
import { FillOpenDemandModal } from "~/components/allocations/FillOpenDemandModal.js";
|
||||
import { AllocationPopover } from "./AllocationPopover.js";
|
||||
import { DemandPopover } from "./DemandPopover.js";
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
} from "./TimelineContext.js";
|
||||
import { TimelineResourcePanel } from "./TimelineResourcePanel.js";
|
||||
import { TimelineProjectPanel, type OpenDemandAssignment } from "./TimelineProjectPanel.js";
|
||||
import { useMultiSelectIntersection } from "~/hooks/useMultiSelectIntersection.js";
|
||||
|
||||
// ─── Entry point ────────────────────────────────────────────────────────────
|
||||
// Two-layer mount: the outer shell creates drag state + project context,
|
||||
@@ -67,14 +69,9 @@ export function TimelineView() {
|
||||
// We start with 40 (day zoom default) and update via a ref.
|
||||
const cellWidthRef = useRef(40);
|
||||
|
||||
const outerUtils = trpc.useUtils();
|
||||
const invalidateTimeline = useInvalidateTimeline();
|
||||
const batchShiftMutationOuter = trpc.timeline.batchShiftAllocations.useMutation({
|
||||
onSuccess: () => {
|
||||
void outerUtils.timeline.getEntries.invalidate();
|
||||
void outerUtils.timeline.getEntriesView.invalidate();
|
||||
void outerUtils.timeline.getProjectContext.invalidate();
|
||||
void outerUtils.timeline.getBudgetStatus.invalidate();
|
||||
},
|
||||
onSuccess: invalidateTimeline,
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -327,13 +324,10 @@ function TimelineViewContent({
|
||||
} | null>(null);
|
||||
const resourceHoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const invalidateTimelineInner = useInvalidateTimeline();
|
||||
const batchDeleteMutation = trpc.allocation.batchDelete.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.timeline.getEntries.invalidate();
|
||||
void utils.timeline.getEntriesView.invalidate();
|
||||
void utils.timeline.getProjectContext.invalidate();
|
||||
void utils.timeline.getBudgetStatus.invalidate();
|
||||
invalidateTimelineInner();
|
||||
clearMultiSelect();
|
||||
},
|
||||
});
|
||||
@@ -570,124 +564,22 @@ function TimelineViewContent({
|
||||
};
|
||||
|
||||
// ─── Multi-select intersection computation ────────────────────────────────
|
||||
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 (same coordinate space as
|
||||
// getBoundingClientRect). Using viewport coords directly avoids any
|
||||
// coordinate transformation errors from sticky headers or virtualizer offsets.
|
||||
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);
|
||||
|
||||
// For X-axis: convert viewport X to canvas-relative X for allocation matching.
|
||||
// Query any row element to find the actual canvas area position.
|
||||
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;
|
||||
|
||||
// Find allocations within the rectangle by querying actual DOM positions.
|
||||
// This avoids any mismatch between computed row positions and actual rendering.
|
||||
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();
|
||||
// Compare directly in viewport coordinates
|
||||
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") {
|
||||
// Project view: query actual resource row DOM elements by data attribute.
|
||||
// Each row carries data-project-id and data-resource-id for alloc lookup.
|
||||
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;
|
||||
|
||||
// Find matching group and row
|
||||
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
|
||||
useMultiSelectIntersection({
|
||||
multiSelectState,
|
||||
setMultiSelectState,
|
||||
clearMultiSelect,
|
||||
canvasRef,
|
||||
viewMode,
|
||||
resources,
|
||||
allocsByResource,
|
||||
projectGroups,
|
||||
openDemandsByProject,
|
||||
dates,
|
||||
today,
|
||||
CELL_WIDTH,
|
||||
toLeft,
|
||||
toWidth,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-1 flex-col gap-4 min-h-0">
|
||||
|
||||
Reference in New Issue
Block a user