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:
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { useInvalidateTimeline } from "./useInvalidatePlanningViews.js";
|
||||
import type { AllocationMovedSnapshot } from "./useTimelineDrag.js";
|
||||
|
||||
export type { AllocationMovedSnapshot };
|
||||
@@ -18,7 +19,7 @@ export function useAllocationHistory() {
|
||||
const past = useRef<HistoryEntry[]>([]);
|
||||
const future = useRef<HistoryEntry[]>([]);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const invalidateTimeline = useInvalidateTimeline();
|
||||
|
||||
// Configurable max steps from system settings
|
||||
const { data: settings } = trpc.settings.getSystemSettings.useQuery(undefined, {
|
||||
@@ -27,21 +28,11 @@ export function useAllocationHistory() {
|
||||
const maxHistory = settings?.timelineUndoMaxSteps ?? DEFAULT_MAX_HISTORY;
|
||||
|
||||
const updateMutation = trpc.timeline.updateAllocationInline.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.timeline.getEntries.invalidate();
|
||||
void utils.timeline.getEntriesView.invalidate();
|
||||
void utils.timeline.getProjectContext.invalidate();
|
||||
void utils.timeline.getBudgetStatus.invalidate();
|
||||
},
|
||||
onSuccess: invalidateTimeline,
|
||||
});
|
||||
|
||||
const batchShiftMutation = trpc.timeline.batchShiftAllocations.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.timeline.getEntries.invalidate();
|
||||
void utils.timeline.getEntriesView.invalidate();
|
||||
void utils.timeline.getProjectContext.invalidate();
|
||||
void utils.timeline.getBudgetStatus.invalidate();
|
||||
},
|
||||
onSuccess: invalidateTimeline,
|
||||
});
|
||||
|
||||
const push = useCallback((snapshot: AllocationMovedSnapshot) => {
|
||||
@@ -58,13 +49,6 @@ export function useAllocationHistory() {
|
||||
setCanRedo(false);
|
||||
}, [maxHistory]);
|
||||
|
||||
const invalidateAll = useCallback(() => {
|
||||
void utils.timeline.getEntries.invalidate();
|
||||
void utils.timeline.getEntriesView.invalidate();
|
||||
void utils.timeline.getProjectContext.invalidate();
|
||||
void utils.timeline.getBudgetStatus.invalidate();
|
||||
}, [utils]);
|
||||
|
||||
const undo = useCallback(async () => {
|
||||
const last = past.current[past.current.length - 1];
|
||||
if (!last) return;
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
/** Invalidates just the 4 timeline queries */
|
||||
export function useInvalidateTimeline() {
|
||||
const utils = trpc.useUtils();
|
||||
return () => {
|
||||
void utils.timeline.getEntries.invalidate();
|
||||
void utils.timeline.getEntriesView.invalidate();
|
||||
void utils.timeline.getProjectContext.invalidate();
|
||||
void utils.timeline.getBudgetStatus.invalidate();
|
||||
};
|
||||
}
|
||||
|
||||
/** Invalidates all 8 planning-related queries (4 timeline + 4 allocation) */
|
||||
export function useInvalidatePlanningViews() {
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
return () => {
|
||||
void utils.allocation.list.invalidate();
|
||||
void (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { useInvalidateTimeline } from "./useInvalidatePlanningViews.js";
|
||||
import { pixelsToDays, computeDragDates } from "~/components/timeline/dragMath.js";
|
||||
|
||||
// ─── Project-shift drag state ───────────────────────────────────────────────
|
||||
|
||||
@@ -214,6 +216,7 @@ export function useTimelineDrag({
|
||||
onMultiDragCompleteRef.current = onMultiDragComplete;
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const invalidateTimeline = useInvalidateTimeline();
|
||||
|
||||
// Project-shift preview
|
||||
const { data: previewData, isFetching: isPreviewLoading } = trpc.timeline.previewShift.useQuery(
|
||||
@@ -235,9 +238,7 @@ export function useTimelineDrag({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const applyShiftMutation = (trpc.timeline.applyShift.useMutation as any)({
|
||||
onSuccess: (data: { project: { id: string } }) => {
|
||||
void utils.timeline.getEntries.invalidate();
|
||||
void utils.timeline.getEntriesView.invalidate();
|
||||
void utils.timeline.getBudgetStatus.invalidate();
|
||||
invalidateTimeline();
|
||||
void utils.project.list.invalidate();
|
||||
onShiftApplied?.(data.project.id);
|
||||
},
|
||||
@@ -251,10 +252,7 @@ export function useTimelineDrag({
|
||||
|
||||
const updateAllocMutation = trpc.timeline.updateAllocationInline.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.timeline.getEntries.invalidate();
|
||||
void utils.timeline.getEntriesView.invalidate();
|
||||
void utils.timeline.getProjectContext.invalidate();
|
||||
void utils.timeline.getBudgetStatus.invalidate();
|
||||
invalidateTimeline();
|
||||
const snap = pendingSnapshotRef.current;
|
||||
if (snap) {
|
||||
onAllocationMovedRef.current?.(snap);
|
||||
@@ -378,7 +376,7 @@ export function useTimelineDrag({
|
||||
|
||||
function handleMultiMove(ev: MouseEvent) {
|
||||
const deltaX = ev.clientX - startMouseX;
|
||||
const daysDelta = Math.round(deltaX / cellWidthRef.current);
|
||||
const daysDelta = pixelsToDays(deltaX, cellWidthRef.current);
|
||||
if (daysDelta === currentDaysDelta) return;
|
||||
currentDaysDelta = daysDelta;
|
||||
|
||||
@@ -432,25 +430,15 @@ export function useTimelineDrag({
|
||||
if (!alloc.isActive || !alloc.originalStartDate || !alloc.originalEndDate) return;
|
||||
|
||||
const deltaX = ev.clientX - alloc.startMouseX;
|
||||
const daysDelta = Math.round(deltaX / cellWidthRef.current);
|
||||
const daysDelta = pixelsToDays(deltaX, cellWidthRef.current);
|
||||
if (daysDelta === alloc.daysDelta) return;
|
||||
|
||||
const newStart = new Date(alloc.originalStartDate);
|
||||
const newEnd = new Date(alloc.originalEndDate);
|
||||
|
||||
if (alloc.mode === "move") {
|
||||
newStart.setDate(newStart.getDate() + daysDelta);
|
||||
newEnd.setDate(newEnd.getDate() + daysDelta);
|
||||
} else if (alloc.mode === "resize-start") {
|
||||
newStart.setDate(newStart.getDate() + daysDelta);
|
||||
// Allow same-day (single day booking), prevent crossing
|
||||
if (newStart > newEnd) newStart.setTime(newEnd.getTime());
|
||||
} else {
|
||||
// resize-end
|
||||
newEnd.setDate(newEnd.getDate() + daysDelta);
|
||||
// Allow same-day (single day booking), prevent crossing
|
||||
if (newEnd < newStart) newEnd.setTime(newStart.getTime());
|
||||
}
|
||||
const { start: newStart, end: newEnd } = computeDragDates(
|
||||
alloc.mode,
|
||||
alloc.originalStartDate,
|
||||
alloc.originalEndDate,
|
||||
daysDelta,
|
||||
);
|
||||
|
||||
const updated: AllocDragState = {
|
||||
...alloc,
|
||||
@@ -545,12 +533,14 @@ export function useTimelineDrag({
|
||||
const drag = dragStateRef.current;
|
||||
if (drag.isDragging && drag.originalStartDate && drag.originalEndDate) {
|
||||
const deltaX = e.clientX - drag.startMouseX;
|
||||
const daysDelta = Math.round(deltaX / cellWidth);
|
||||
const daysDelta = pixelsToDays(deltaX, cellWidth);
|
||||
if (daysDelta !== drag.daysDelta) {
|
||||
const newStart = new Date(drag.originalStartDate);
|
||||
newStart.setDate(newStart.getDate() + daysDelta);
|
||||
const newEnd = new Date(drag.originalEndDate);
|
||||
newEnd.setDate(newEnd.getDate() + daysDelta);
|
||||
const { start: newStart, end: newEnd } = computeDragDates(
|
||||
"move",
|
||||
drag.originalStartDate,
|
||||
drag.originalEndDate,
|
||||
daysDelta,
|
||||
);
|
||||
const updated: DragState = {
|
||||
...drag,
|
||||
currentStartDate: newStart,
|
||||
@@ -567,7 +557,7 @@ export function useTimelineDrag({
|
||||
const range = rangeStateRef.current;
|
||||
if (range.isSelecting && range.startDate) {
|
||||
const deltaX = e.clientX - range.startClientX;
|
||||
const daysDelta = Math.round(deltaX / cellWidth);
|
||||
const daysDelta = pixelsToDays(deltaX, cellWidth);
|
||||
const currentDate = new Date(range.startDate);
|
||||
currentDate.setDate(currentDate.getDate() + daysDelta);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user