"use client"; import { useState, useEffect, useRef } from "react"; import { useFocusTrap } from "~/hooks/useFocusTrap.js"; import { AllocationStatus } from "@planarchy/shared"; import type { AllocationWithDetails, RecurrencePattern } from "@planarchy/shared"; import { trpc } from "~/lib/trpc/client.js"; import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js"; import { DateInput } from "~/components/ui/DateInput.js"; import { RecurrenceEditor } from "./RecurrenceEditor.js"; const ALLOCATION_STATUSES = Object.values(AllocationStatus); type EntryKind = "demand" | "assignment"; interface AllocationModalProps { allocation?: AllocationWithDetails | null; onClose: () => void; onSuccess: () => void; } function toDateInputValue(date: Date | string | null | undefined): string { if (!date) return ""; const d = typeof date === "string" ? new Date(date) : date; return d.toISOString().split("T")[0] ?? ""; } 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 [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 panelRef = useRef(null); useFocusTrap(panelRef, true); const { data: resources } = trpc.resource.list.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 }, ); const utils = trpc.useUtils(); const invalidatePlanningViews = () => { void utils.allocation.list.invalidate(); void (utils as { allocation: { listView: { invalidate: () => Promise } } }).allocation.listView.invalidate(); void utils.allocation.listDemands.invalidate(); void utils.allocation.listAssignments.invalidate(); void utils.timeline.getEntries.invalidate(); void utils.timeline.getEntriesView.invalidate(); void utils.timeline.getProjectContext.invalidate(); void utils.timeline.getBudgetStatus.invalidate(); }; // 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; useEffect(() => { setServerError(null); }, [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, startDate: start, endDate: end, hoursPerDay, percentage, status: status as AllocationStatus, metadata, }, }); } else if (isDemandEntry) { createDemandMutation.mutate({ projectId, role: roleString, roleId: roleId || undefined, headcount, 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, }); } } 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 (
{ if (e.target === e.currentTarget) onClose(); }} >
{ if (e.key === "Escape") onClose(); }} > {/* 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" />
)}
{/* 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 */}
{/* 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}
)} {/* Footer */}
); }