"use client"; import React, { type RefObject } from "react"; import { clsx } from "clsx"; import { useEffect, useState } from "react"; import { createPortal } from "react-dom"; import type { AllocationLike, Assignment } from "@capakraken/shared"; import { trpc } from "~/lib/trpc/client.js"; import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js"; import { useViewportPopover } from "~/hooks/useViewportPopover.js"; import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js"; import { DateInput } from "~/components/ui/DateInput.js"; interface AllocationPopoverProps { allocationId: string; projectId: string; initialAllocation?: AllocationPopoverAssignment | null; onClose: () => void; onOpenPanel: (projectId: string) => void; /** Pixel position relative to the viewport */ anchorX: number; anchorY: number; contextDate?: Date; ignoreScrollContainers?: RefObject[]; } type AllocationPopoverAssignment = Assignment; export function AllocationPopover({ allocationId, projectId, initialAllocation = null, onClose, onOpenPanel, anchorX, anchorY, contextDate, ignoreScrollContainers, }: AllocationPopoverProps) { const utils = trpc.useUtils(); const invalidatePlanningViews = useInvalidatePlanningViews(); const { ref, style } = useViewportPopover({ anchor: { kind: "point", x: anchorX, y: anchorY }, width: 300, estimatedHeight: 360, onClose, ...(ignoreScrollContainers ? { ignoreScrollContainers } : {}), }); const shouldLoadAllocation = !initialAllocation; const allocationQuery = trpc.allocation.getAssignmentById.useQuery( { id: allocationId }, { staleTime: 10_000, enabled: shouldLoadAllocation, retry: false, }, ); const fetchedAllocation = allocationQuery.data as AllocationPopoverAssignment | undefined; const allocation = initialAllocation ?? fetchedAllocation; const isLoading = shouldLoadAllocation && allocationQuery.isLoading; const allocationError = shouldLoadAllocation ? allocationQuery.error : null; const [hoursPerDay, setHoursPerDay] = useState(null); const [startDate, setStartDate] = useState(""); const [endDate, setEndDate] = useState(""); const [includeSaturday, setIncludeSaturday] = useState(false); const [role, setRole] = useState(""); const [carveStartDate, setCarveStartDate] = useState(""); const [carveEndDate, setCarveEndDate] = useState(""); useEffect(() => { if (allocation) { setHoursPerDay(allocation.hoursPerDay); setStartDate(toDateInput(new Date(allocation.startDate))); setEndDate(toDateInput(new Date(allocation.endDate))); const meta = allocation.metadata as { includeSaturday?: boolean } | null; setIncludeSaturday(meta?.includeSaturday ?? false); setRole(allocation.role ?? ""); const defaultCarveDate = contextDate ? toDateInput(contextDate) : ""; setCarveStartDate(defaultCarveDate); setCarveEndDate(defaultCarveDate); } }, [allocation, contextDate]); const updateMutation = trpc.timeline.updateAllocationInline.useMutation({ onSuccess: () => { void invalidatePlanningViews(); void utils.allocation.getAssignmentById.invalidate({ id: allocationId }); onClose(); }, }); const carveMutation = trpc.timeline.carveAllocationRange.useMutation({ onSuccess: () => { void invalidatePlanningViews(); void utils.allocation.getAssignmentById.invalidate({ id: allocationId }); onClose(); }, }); function toDateInput(d: Date): string { 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}`; } function handleSave() { if (!allocation || hoursPerDay === null) return; updateMutation.mutate({ allocationId: getPlanningEntryMutationId(allocation), hoursPerDay, startDate: startDate ? new Date(startDate) : undefined, endDate: endDate ? new Date(endDate) : undefined, includeSaturday, role: role || undefined, }); } function handleCarveRange() { if (!allocation || !carveStartDate || !carveEndDate) return; carveMutation.mutate({ allocationId: getPlanningEntryMutationId(allocation), startDate: new Date(carveStartDate), endDate: new Date(carveEndDate), }); } if (isLoading) { const loadingPopover = (
Loading...
); return typeof document === "undefined" ? loadingPopover : createPortal(loadingPopover, document.body); } if (allocationError) { const errorPopover = (
Allocation unavailable

The selected booking could not be loaded right now.

{allocationError.message}

); return typeof document === "undefined" ? errorPopover : createPortal(errorPopover, document.body); } if (!allocation) { const missingPopover = (
Allocation unavailable

The selected booking could not be resolved from the current timeline data.

); return typeof document === "undefined" ? missingPopover : createPortal(missingPopover, document.body); } const dailyCostEUR = ( ((hoursPerDay ?? allocation.hoursPerDay) * (allocation.resource?.lcrCents ?? 0)) / 100 ).toFixed(2); const carveDateRangeInvalid = Boolean(carveStartDate && carveEndDate) && carveEndDate < carveStartDate; const popover = (
{/* Header */}
{role}
{/* Resource */}
Resource:{" "} {allocation.resource?.displayName} ·{" "} {allocation.resource?.eid}
{/* 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-full border border-gray-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400" />
{/* Date range */}
{/* Include Saturday */} {/* Error */} {updateMutation.isError && (

{updateMutation.error.message}

)} {carveMutation.isError && (

{carveMutation.error.message}

)} {/* Actions */}
Remove Date Range
{contextDate ? `Prefilled from ${toDateInput(contextDate)}` : "Create a gap or split this booking."}
{carveDateRangeInvalid && (

End date must be on or after the start date.

)}
{/* Link to full panel */}
); return typeof document === "undefined" ? popover : createPortal(popover, document.body); }