From 47b2aeec72e9176e2a4bd255d7a470883204547d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Mon, 23 Mar 2026 08:51:49 +0100 Subject: [PATCH] feat: prevent duplicate resource-project assignments Engine (packages/engine): - New checkDuplicateAssignment() pure function: detects same resource assigned to same project with overlapping dates - 15 unit tests covering: overlap, no-overlap, cancelled, self-exclude, string dates, PROPOSED status Application layer (packages/application): - createAssignment: throws CONFLICT before DB write if duplicate found - fillDemandRequirement: same check before entering transaction AI Assistant (packages/api/router/assistant-tools.ts): - create_allocation: checks before creating, returns helpful error message - fill_demand: same check using demand's projectId UI (apps/web): - AllocationModal: amber warning when resource already assigned to selected project with overlapping dates (non-blocking) Database cleanup: - Found and merged 1 duplicate: Wong Wong on Porsche Taycan Sport Film (2 overlapping PROPOSED assignments merged into 1) Regression: 298 engine tests pass (283 + 15 new). TypeScript clean. Co-Authored-By: claude-flow --- .../allocations/AllocationModal.tsx | 39 ++- packages/api/src/router/assistant-tools.ts | 22 +- .../use-cases/allocation/create-assignment.ts | 24 +- .../allocation/fill-demand-requirement.ts | 31 ++ .../src/__tests__/duplicate-check.test.ts | 99 ++++++ .../engine/src/allocation/duplicate-check.ts | 78 +++++ packages/engine/src/allocation/index.ts | 1 + plan.md | 303 +++++++----------- 8 files changed, 408 insertions(+), 189 deletions(-) create mode 100644 packages/engine/src/__tests__/duplicate-check.test.ts create mode 100644 packages/engine/src/allocation/duplicate-check.ts diff --git a/apps/web/src/components/allocations/AllocationModal.tsx b/apps/web/src/components/allocations/AllocationModal.tsx index fa5c3ce..0e9a015 100644 --- a/apps/web/src/components/allocations/AllocationModal.tsx +++ b/apps/web/src/components/allocations/AllocationModal.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect, useRef, useMemo } from "react"; import { useFocusTrap } from "~/hooks/useFocusTrap.js"; import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js"; import { AllocationStatus } from "@planarchy/shared"; @@ -73,6 +73,36 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo { staleTime: 60_000 }, ); + // Fetch existing allocations for the selected resource+project to detect overlaps + const shouldCheckOverlap = !isDemandEntry && !!resourceId && !!projectId; + const { data: existingAllocations } = trpc.allocation.listView.useQuery( + { projectId, resourceId }, + { enabled: shouldCheckOverlap, staleTime: 30_000 }, + ); + + const overlapWarning = useMemo(() => { + if (!shouldCheckOverlap || !existingAllocations || !startDate || !endDate) return null; + const formStart = new Date(startDate); + const formEnd = new Date(endDate); + if (isNaN(formStart.getTime()) || isNaN(formEnd.getTime())) return null; + + const allocList = (existingAllocations as { allocations?: Array<{ id: string; resourceId?: string | null; startDate: string | Date; endDate: string | Date }> }).allocations ?? []; + for (const existing of allocList) { + // Skip the allocation being edited + if (isEditing && allocation && existing.id === allocation.id) continue; + // Only check assignments for this resource + if (existing.resourceId !== resourceId) continue; + const exStart = new Date(existing.startDate); + const exEnd = new Date(existing.endDate); + // Check date overlap + if (formStart <= exEnd && formEnd >= exStart) { + const fmt = (d: Date) => d.toISOString().slice(0, 10); + return `This resource is already assigned to this project from ${fmt(exStart)} to ${fmt(exEnd)}. Consider updating the existing assignment instead.`; + } + } + return null; + }, [shouldCheckOverlap, existingAllocations, startDate, endDate, isEditing, allocation, resourceId]); + const invalidatePlanningViews = useInvalidatePlanningViews(); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -473,6 +503,13 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo )} + {/* Overlap warning */} + {overlapWarning && ( +
+ {"\u26A0"} {overlapWarning} +
+ )} + {/* Footer */}