diff --git a/src/components/interests/linked-berths-list.tsx b/src/components/interests/linked-berths-list.tsx index 538a782a..2d3d2e4d 100644 --- a/src/components/interests/linked-berths-list.tsx +++ b/src/components/interests/linked-berths-list.tsx @@ -16,14 +16,22 @@ * - "Remove" — calls `removeInterestBerth`. */ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import Link from 'next/link'; import { useParams } from 'next/navigation'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { Anchor, Loader2, Star, Trash2 } from 'lucide-react'; +import { Anchor, Loader2, Plus, Star, Trash2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; import { Dialog, DialogContent, @@ -39,7 +47,9 @@ import { Textarea } from '@/components/ui/textarea'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { HelpCircle } from 'lucide-react'; import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; import { cn } from '@/lib/utils'; +import { toast } from 'sonner'; // ─── Types (mirror the API GET shape — see interest-berths.service.ts) ───── @@ -151,6 +161,22 @@ function useUpdateLink(interestId: string) { }); } +function useAddLink(interestId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (args: { berthId: string; isSpecificInterest: boolean }) => + apiFetch(`/api/v1/interests/${interestId}/berths`, { + method: 'POST', + body: args, + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['interest-berths', interestId] }); + qc.invalidateQueries({ queryKey: ['interests', interestId] }); + qc.invalidateQueries({ queryKey: ['berth-recommendations', interestId] }); + }, + }); +} + function useRemoveLink(interestId: string) { const qc = useQueryClient(); return useMutation({ @@ -481,6 +507,134 @@ function LinkedBerthRowItem({ ); } +// ─── Add berth dialog ─────────────────────────────────────────────────────── + +interface BerthOption { + id: string; + mooringNumber: string; + area: string | null; + status: string; +} + +interface AddBerthDialogProps { + open: boolean; + onOpenChange: (next: boolean) => void; + interestId: string; + /** IDs already linked — filtered out of the picker so the rep can't double-add. */ + excludeIds: Set; +} + +function AddBerthDialog({ open, onOpenChange, interestId, excludeIds }: AddBerthDialogProps) { + const [selected, setSelected] = useState(null); + const [markSpecific, setMarkSpecific] = useState(false); + const mutation = useAddLink(interestId); + + const { data: options, isLoading } = useQuery<{ data: BerthOption[] }>({ + queryKey: ['berth-options', 'add-to-interest'], + queryFn: () => apiFetch<{ data: BerthOption[] }>('/api/v1/berths/options'), + enabled: open, + staleTime: 60_000, + }); + + // Filter out berths already linked to the interest so the rep can't + // double-add. Pre-natural-sorted by the service. + const available = useMemo(() => { + const list = options?.data ?? []; + return list.filter((b) => !excludeIds.has(b.id)); + }, [options, excludeIds]); + + async function handleAdd() { + if (!selected) return; + try { + await mutation.mutateAsync({ + berthId: selected.id, + isSpecificInterest: markSpecific, + }); + toast.success(`Linked berth ${selected.mooringNumber}`); + onOpenChange(false); + } catch (err) { + toastError(err); + } + } + + return ( + + + + Add berth to interest + + Link an existing berth. Defaults to a non-primary, non-bundle link. Edit those flags on + the row after it lands. + + +
+
+ + + + {isLoading ? ( +
Loading berths…
+ ) : ( + <> + No berths available. + + {available.map((b) => ( + setSelected(b)} + className={cn('cursor-pointer', selected?.id === b.id ? 'bg-muted' : '')} + > + {b.mooringNumber} + + {formatStatus(b.status)} + + + ))} + + + )} +
+
+
+ {selected ? ( +
+ Selected: {selected.mooringNumber} +
+ ) : null} +
+ + +
+
+ + + + +
+
+ ); +} + // ─── Component ────────────────────────────────────────────────────────────── export function LinkedBerthsList({ interestId }: LinkedBerthsListProps) { @@ -493,6 +647,8 @@ export function LinkedBerthsList({ interestId }: LinkedBerthsListProps) { const rows = data?.data ?? []; const eoiStatus = data?.meta.eoiStatus ?? null; const isPending = updateMutation.isPending || removeMutation.isPending; + const [addOpen, setAddOpen] = useState(false); + const linkedBerthIds = useMemo(() => new Set(rows.map((r) => r.berthId)), [rows]); // Three-bucket split per the Deal-berth + Bundle model: // • dealBerth: the single is_primary row — the one templates/EOI @@ -520,11 +676,21 @@ export function LinkedBerthsList({ interestId }: LinkedBerthsListProps) { return ( - + Linked berths{rows.length > 0 ? ` (${rows.length})` : ''} + {isLoading ? ( @@ -580,6 +746,14 @@ export function LinkedBerthsList({ interestId }: LinkedBerthsListProps) {

) : null}
+ {addOpen ? ( + + ) : null}
); }