"use client"; import { clsx } from "clsx"; import { useEffect, useRef, useState } from "react"; import { AllocationStatus } from "@planarchy/shared"; import { trpc } from "~/lib/trpc/client.js"; import { DateInput } from "~/components/ui/DateInput.js"; interface NewAllocationPopoverProps { resourceId: string; startDate: Date; endDate: Date; /** Pre-selected project (from project-view sub-row context) */ suggestedProjectId?: string | null; anchorX: number; anchorY: number; onClose: () => void; onCreated: () => void; } function toDateInput(d: Date): string { return d.toISOString().split("T")[0] ?? ""; } export function NewAllocationPopover({ resourceId, startDate, endDate, suggestedProjectId, anchorX, anchorY, onClose, onCreated, }: NewAllocationPopoverProps) { const ref = useRef(null); const utils = trpc.useUtils(); const [search, setSearch] = useState(""); const [selectedProjectId, setSelectedProjectId] = useState( suggestedProjectId ?? null, ); const [role, setRole] = useState("Team Member"); const [hoursPerDay, setHoursPerDay] = useState(8); const [start, setStart] = useState(toDateInput(startDate)); const [end, setEnd] = useState(toDateInput(endDate)); const [dropdownOpen, setDropdownOpen] = useState(!suggestedProjectId); const { data: projectsData } = trpc.project.list.useQuery( { search, limit: 20 }, { staleTime: 30_000 }, ); const projects = projectsData?.projects ?? []; const selectedProject = projects.find((p) => p.id === selectedProjectId) ?? (suggestedProjectId ? projects.find((p) => p.id === suggestedProjectId) : null); const createMutation = trpc.timeline.quickAssign.useMutation({ onSuccess: () => { void utils.timeline.getEntries.invalidate(); void utils.timeline.getEntriesView.invalidate(); void utils.timeline.getProjectContext.invalidate(); void utils.timeline.getBudgetStatus.invalidate(); onCreated(); onClose(); }, }); // Close on outside click useEffect(() => { function handleClick(e: MouseEvent) { if (ref.current && !ref.current.contains(e.target as Node)) { onClose(); } } document.addEventListener("mousedown", handleClick); return () => document.removeEventListener("mousedown", handleClick); }, [onClose]); function handleCreate() { if (!selectedProjectId) return; createMutation.mutate({ resourceId, projectId: selectedProjectId, startDate: new Date(start), endDate: new Date(end), hoursPerDay, role, status: AllocationStatus.PROPOSED, }); } const canCreate = !!selectedProjectId && !!start && !!end && hoursPerDay > 0; const left = Math.min(anchorX - 10, typeof window !== "undefined" ? window.innerWidth - 340 : anchorX); const top = Math.min(anchorY + 8, typeof window !== "undefined" ? window.innerHeight - 440 : anchorY); const ORDER_COLORS: Record = { CHARGEABLE: "bg-emerald-100 text-emerald-700", INTERNAL: "bg-blue-100 text-blue-700", BD: "bg-violet-100 text-violet-700", OVERHEAD: "bg-gray-100 text-gray-600", }; return (
{/* Header */}
Assign to Project
{/* Date range */}
{/* Project picker */}
{selectedProject && !dropdownOpen ? (
{ setDropdownOpen(true); setSearch(""); }} > {selectedProject.name}
) : (
setSearch(e.target.value)} onFocus={() => setDropdownOpen(true)} className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400" /> {dropdownOpen && projects.length > 0 && (
{projects.map((p) => ( ))}
)}
)}
{/* Role */}
setRole(e.target.value)} className="w-full border border-gray-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400" />
{/* Hours per day */}
setHoursPerDay(parseFloat(e.target.value))} className="w-24 border border-gray-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400" />
{[4, 6, 8].map((h) => ( ))}
{/* Overbooking notice */}

Overlapping allocations are allowed — resource may be overbooked.

{/* Error */} {createMutation.isError && (

{createMutation.error.message}

)} {/* Actions */}
); }