"use client"; import { useState, useEffect, useMemo } from "react"; import { useDebounce } from "~/hooks/useDebounce.js"; 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 { DateRangePresets } from "~/components/ui/DateRangePresets.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"; interface AllocationModalProps { allocation?: AllocationWithDetails | null; onClose: () => void; onSuccess: () => void; } export function AllocationModal({ allocation, onClose, onSuccess }: AllocationModalProps) { const isEditing = Boolean(allocation); const initialEntryKind: EntryKind = allocation && !allocation.resourceId ? "demand" : "assignment"; const [entryKind, setEntryKind] = useState(initialEntryKind); const isDemandEntry = entryKind === "demand"; const [resourceId, setResourceId] = useState(allocation?.resourceId ?? ""); const [projectId, setProjectId] = useState(allocation?.projectId ?? ""); const [roleId, setRoleId] = useState(allocation?.roleId ?? ""); const [roleFreeText, setRoleFreeText] = useState(allocation?.role ?? ""); const [headcount, setHeadcount] = useState(allocation?.headcount ?? 1); const [budgetEur, setBudgetEur] = useState(() => { const cents = (allocation as { budgetCents?: number } | undefined)?.budgetCents ?? 0; return cents > 0 ? String(cents / 100) : ""; }); const [startDate, setStartDate] = useState(toDateInputValue(allocation?.startDate)); const [endDate, setEndDate] = useState(toDateInputValue(allocation?.endDate)); const [hoursPerDay, setHoursPerDay] = useState(allocation?.hoursPerDay ?? 8); const [status, setStatus] = useState( allocation?.status ?? AllocationStatus.PROPOSED, ); const existingMeta = allocation?.metadata as Record | undefined; const [isRecurring, setIsRecurring] = useState(!!existingMeta?.recurrence); const [recurrence, setRecurrence] = useState( existingMeta?.recurrence as RecurrencePattern | undefined, ); const [serverError, setServerError] = useState(null); const [overbookingAcknowledged, setOverbookingAcknowledged] = useState(false); const { data: resources } = trpc.resource.directory.useQuery( { isActive: true, limit: 500 }, { staleTime: 60_000 }, ); const { data: projects } = trpc.project.list.useQuery( { limit: 500 }, { staleTime: 60_000 }, ); const { data: rolesData } = trpc.role.list.useQuery( { isActive: true }, { 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 }, ); // Debounce conflict-check inputs so we don't fire on every keystroke/interaction. const debouncedResourceId = useDebounce(resourceId, 400); const debouncedStartDate = useDebounce(startDate, 400); const debouncedEndDate = useDebounce(endDate, 400); const debouncedHoursPerDay = useDebounce(hoursPerDay, 400); // Pre-flight conflict check: overbooking + vacation overlap for this resource/period. const conflictCheckStart = debouncedStartDate ? new Date(debouncedStartDate) : null; const conflictCheckEnd = debouncedEndDate ? new Date(debouncedEndDate) : null; const shouldCheckConflicts = !isDemandEntry && !!debouncedResourceId && conflictCheckStart !== null && !isNaN(conflictCheckStart.getTime()) && conflictCheckEnd !== null && !isNaN(conflictCheckEnd.getTime()) && debouncedHoursPerDay > 0; // eslint-disable-next-line @typescript-eslint/no-explicit-any const { data: conflictResult, isFetching: checkingConflicts } = (trpc.allocation.checkConflicts.useQuery as any)( { resourceId: debouncedResourceId, startDate: conflictCheckStart, endDate: conflictCheckEnd, hoursPerDay: debouncedHoursPerDay, 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); 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 const createDemandMutation = (trpc.allocation.createDemandRequirement.useMutation as any)({ onSuccess: () => { invalidatePlanningViews(); onSuccess(); }, onError: (err: { message: string }) => { setServerError(err.message); }, }) as { isPending: boolean; isError: boolean; error?: { message: string }; mutate: (input: unknown) => void; }; // eslint-disable-next-line @typescript-eslint/no-explicit-any const createAssignmentMutation = (trpc.allocation.createAssignment.useMutation as any)({ onSuccess: () => { invalidatePlanningViews(); onSuccess(); }, onError: (err: { message: string }) => { setServerError(err.message); }, }) as { isPending: boolean; isError: boolean; error?: { message: string }; mutate: (input: unknown) => void; }; // eslint-disable-next-line @typescript-eslint/no-explicit-any const updateMutation = (trpc.allocation.update.useMutation as any)({ onSuccess: () => { invalidatePlanningViews(); onSuccess(); }, onError: (err: { message: string }) => { setServerError(err.message); }, }) as { isPending: boolean; isError: boolean; error?: { message: string }; mutate: (input: unknown) => void; }; const isPending = createDemandMutation.isPending || 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) { e.preventDefault(); setServerError(null); if (!projectId) { setServerError("Please select a project."); return; } if (!isDemandEntry && !resourceId) { setServerError("Please select a resource."); return; } if (!startDate || !endDate) { setServerError("Please fill in start and end dates."); return; } const start = new Date(startDate); const end = new Date(endDate); if (end < start) { setServerError("End date must be on or after start date."); return; } const baseMeta = (allocation?.metadata as Record | undefined) ?? {}; const metadata: Record = { ...baseMeta, ...(isRecurring && recurrence ? { recurrence } : { recurrence: undefined }), }; if (!isRecurring) delete metadata.recurrence; // Determine role string from roleId if set const rolesList = rolesData ?? []; const selectedRole = rolesList.find((r) => r.id === roleId); const roleString = selectedRole ? selectedRole.name : (roleFreeText || undefined); const percentage = Math.min(100, Math.round((hoursPerDay / 8) * 100)); if (isEditing && allocation) { updateMutation.mutate({ id: getPlanningEntryMutationId(allocation), data: { resourceId: isDemandEntry ? undefined : (resourceId || undefined), projectId, role: roleString, roleId: roleId || undefined, headcount: isDemandEntry ? headcount : 1, ...(isDemandEntry && budgetEur ? { budgetCents: Math.round(parseFloat(budgetEur) * 100) } : {}), startDate: start, endDate: end, hoursPerDay, percentage, status: status as AllocationStatus, metadata, }, }); } else if (isDemandEntry) { createDemandMutation.mutate({ projectId, role: roleString, roleId: roleId || undefined, headcount, ...(budgetEur ? { budgetCents: Math.round(parseFloat(budgetEur) * 100) } : {}), startDate: start, endDate: end, hoursPerDay, percentage, status: status as AllocationStatus, metadata, }); } else { createAssignmentMutation.mutate({ resourceId, projectId, role: roleString, roleId: roleId || undefined, startDate: start, endDate: end, hoursPerDay, percentage, status: status as AllocationStatus, metadata, allowOverbooking: overbookingAcknowledged, }); } } const inputClass = "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm dark:bg-gray-900 dark:text-gray-100"; const labelClass = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"; const resourceList = (resources?.resources ?? []) as Array<{ id: string; displayName: string; eid: string }>; const projectList = (projects?.projects ?? []) as Array<{ id: string; name: string; shortCode: string }>; const rolesList = (rolesData ?? []) as Array<{ id: string; name: string; color: string | null }>; const entryLabel = isDemandEntry ? "Open Demand" : "Assignment"; return (
{/* Header */}

{isEditing ? `Edit ${entryLabel}` : `New ${entryLabel}`}

{/* Form */}
{/* Demand toggle */}
{isDemandEntry && (
setHeadcount(Math.max(1, Number(e.target.value)))} min={1} max={50} className="w-16 px-2 py-1 border border-gray-300 dark:border-gray-600 rounded text-sm text-center dark:bg-gray-900 dark:text-gray-100" />
setBudgetEur(e.target.value)} min={0} step={100} placeholder="0" className="w-28 px-2 py-1 border border-gray-300 dark:border-gray-600 rounded text-sm dark:bg-gray-900 dark:text-gray-100" />
)}
{/* Resource is only required for assignments */} {!isDemandEntry && (
)} {/* Project */}
{/* Role */}
{!roleId && ( setRoleFreeText(e.target.value)} placeholder="Or type a custom role…" className={`${inputClass} mt-1`} maxLength={200} /> )}
{/* Dates */}
Date Range * { setStartDate(s); setEndDate(e); }} />
{/* Hours/Day + Status */}
setHoursPerDay(Number(e.target.value))} min={0.5} max={8} step={0.5} className={inputClass} />
{/* Recurring toggle */}
{isRecurring && (
)}
{/* Server error */} {serverError && (
{serverError}
)} {/* Overlap warning (same-project duplicate) */} {overlapWarning && (
{"\u26A0"} {overlapWarning}
)} {/* Overbooking + vacation conflict panel */} {conflictResult && ( )} {!conflictResult && checkingConflicts && ( {}} /> )} {/* Footer */}
); }