diff --git a/apps/web/src/components/allocations/AllocationModal.tsx b/apps/web/src/components/allocations/AllocationModal.tsx index fe61829..9d05130 100644 --- a/apps/web/src/components/allocations/AllocationModal.tsx +++ b/apps/web/src/components/allocations/AllocationModal.tsx @@ -1,15 +1,17 @@ "use client"; -import { useState, useEffect, useRef, useMemo } from "react"; -import { useFocusTrap } from "~/hooks/useFocusTrap.js"; +import { useState, useEffect, useMemo } from "react"; +import { AnimatedModal } from "~/components/ui/AnimatedModal.js"; import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js"; import { AllocationStatus } from "@capakraken/shared"; import type { AllocationWithDetails, RecurrencePattern } from "@capakraken/shared"; import { trpc } from "~/lib/trpc/client.js"; import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js"; +import { toDateInputValue } from "~/lib/format.js"; import { DateInput } from "~/components/ui/DateInput.js"; import { RecurrenceEditor } from "./RecurrenceEditor.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; +import { ConflictWarningPanel } from "./ConflictWarningPanel.js"; const ALLOCATION_STATUSES = Object.values(AllocationStatus); type EntryKind = "demand" | "assignment"; @@ -20,15 +22,6 @@ interface AllocationModalProps { onSuccess: () => void; } -function toDateInputValue(date: Date | string | null | undefined): string { - if (!date) return ""; - const d = typeof date === "string" ? new Date(date) : date; - const y = d.getFullYear(); - const m = String(d.getMonth() + 1).padStart(2, "0"); - const day = String(d.getDate()).padStart(2, "0"); - return `${y}-${m}-${day}`; -} - export function AllocationModal({ allocation, onClose, onSuccess }: AllocationModalProps) { const isEditing = Boolean(allocation); const initialEntryKind: EntryKind = allocation && !allocation.resourceId ? "demand" : "assignment"; @@ -56,9 +49,7 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo existingMeta?.recurrence as RecurrencePattern | undefined, ); const [serverError, setServerError] = useState(null); - - const panelRef = useRef(null); - useFocusTrap(panelRef, true); + const [overbookingAcknowledged, setOverbookingAcknowledged] = useState(false); const { data: resources } = trpc.resource.directory.useQuery( { isActive: true, limit: 500 }, @@ -80,6 +71,27 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo { enabled: shouldCheckOverlap, staleTime: 30_000 }, ); + // Pre-flight conflict check: overbooking + vacation overlap for this resource/period. + const conflictCheckStart = startDate ? new Date(startDate) : null; + const conflictCheckEnd = endDate ? new Date(endDate) : null; + const shouldCheckConflicts = + !isDemandEntry && + !!resourceId && + conflictCheckStart !== null && !isNaN(conflictCheckStart.getTime()) && + conflictCheckEnd !== null && !isNaN(conflictCheckEnd.getTime()) && + hoursPerDay > 0; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data: conflictResult, isFetching: checkingConflicts } = (trpc.allocation.checkConflicts.useQuery as any)( + { + resourceId, + startDate: conflictCheckStart, + endDate: conflictCheckEnd, + hoursPerDay, + excludeAssignmentId: isEditing && allocation?.id ? allocation.id : undefined, + }, + { enabled: shouldCheckConflicts, staleTime: 15_000 }, + ) as { data: import("@capakraken/shared").AllocationConflictCheckResult | undefined; isFetching: boolean }; + const overlapWarning = useMemo(() => { if (!shouldCheckOverlap || !existingAllocations || !startDate || !endDate) return null; const formStart = new Date(startDate); @@ -158,8 +170,13 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo createAssignmentMutation.isPending || updateMutation.isPending; + // Block submit when overbooking detected but not yet acknowledged + const hasUnacknowledgedOverbooking = + !isDemandEntry && conflictResult?.isOverbooking === true && !overbookingAcknowledged; + useEffect(() => { setServerError(null); + setOverbookingAcknowledged(false); }, [resourceId, projectId, roleId, roleFreeText, startDate, endDate, hoursPerDay, status, entryKind]); function handleSubmit(e: React.FormEvent) { @@ -245,6 +262,7 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo percentage, status: status as AllocationStatus, metadata, + allowOverbooking: overbookingAcknowledged, }); } } @@ -259,19 +277,11 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo const entryLabel = isDemandEntry ? "Open Demand" : "Assignment"; return ( -
{ - if (e.target === e.currentTarget) onClose(); - }} - > +
{ if (e.key === "Escape") onClose(); }} > {/* Header */}
@@ -506,13 +516,31 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
)} - {/* Overlap warning */} + {/* Overlap warning (same-project duplicate) */} {overlapWarning && (
{"\u26A0"} {overlapWarning}
)} + {/* Overbooking + vacation conflict panel */} + {conflictResult && ( + + )} + {!conflictResult && checkingConflicts && ( + {}} + /> + )} + {/* Footer */}
-
+ ); } diff --git a/apps/web/src/components/allocations/ConflictWarningPanel.tsx b/apps/web/src/components/allocations/ConflictWarningPanel.tsx new file mode 100644 index 0000000..e89f660 --- /dev/null +++ b/apps/web/src/components/allocations/ConflictWarningPanel.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { useState } from "react"; +import type { AllocationConflictCheckResult } from "@capakraken/shared"; + +const INITIAL_ROWS_SHOWN = 5; + +interface ConflictWarningPanelProps { + result: AllocationConflictCheckResult; + isLoading: boolean; + acknowledged: boolean; + onAcknowledge: (v: boolean) => void; +} + +export function ConflictWarningPanel({ + result, + isLoading, + acknowledged, + onAcknowledge, +}: ConflictWarningPanelProps) { + const [showAllDays, setShowAllDays] = useState(false); + + if (isLoading) { + return ( +
+ + Checking availability… +
+ ); + } + + const hasAny = result.isOverbooking || result.hasVacationOverlap; + if (!hasAny) return null; + + const conflictDays = result.overbooking?.conflictDays ?? []; + const visibleDays = showAllDays ? conflictDays : conflictDays.slice(0, INITIAL_ROWS_SHOWN); + const hiddenCount = conflictDays.length - INITIAL_ROWS_SHOWN; + + return ( +
+ {/* ── Overbooking ─────────────────────────────────────────────── */} + {result.isOverbooking && result.overbooking && ( +
+

+ ⚠ Overbooking on {result.overbooking.totalConflictDays} day + {result.overbooking.totalConflictDays !== 1 ? "s" : ""} + {" "}(up to {result.overbooking.maxOverbookPercent}% over capacity) +

+

+ The resource already has allocations that exceed their daily capacity on the following days. + You can still save — check the box below to confirm. +

+ + {/* Day-by-day table */} +
+ + + + + + + + + + + + {visibleDays.map((day) => ( + + + + + + + + ))} + +
DateCapacityBookedNewOver
{day.date}{day.availableHours}h{day.existingHours}h{day.requestedHours}h + +{day.overageHours.toFixed(1)}h +
+
+ + {hiddenCount > 0 && ( + + )} + + {/* Acknowledgment checkbox */} + +
+ )} + + {/* ── Vacation overlap ─────────────────────────────────────────── */} + {result.hasVacationOverlap && ( +
+

+ ℹ Resource has approved leave during this period +

+

+ Vacation days are excluded from billable hours and daily cost calculations. +

+
    + {result.vacationOverlap.map((v, i) => ( +
  • + + {v.type.replace(/_/g, " ").toLowerCase()} + {v.isHalfDay && (half-day)} + {v.startDate === v.endDate ? v.startDate : `${v.startDate} – ${v.endDate}`} +
  • + ))} +
+
+ )} +
+ ); +}