From 3999d4bbea0e4be1408e59558b3fa5defb328d4f Mon Sep 17 00:00:00 2001
From: Matt
Date: Thu, 21 May 2026 19:50:27 +0200
Subject: [PATCH] feat(interests): explicit "Add berth" CTA on LinkedBerthsList
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Previously reps could only add berths through the recommender panel
below the list or by indirect side-effects (EOI generation). New
button on the card header opens a searchable picker dialog backed by
/api/v1/berths/options.
- AddBerthDialog uses the existing Command primitive (cmdk) for the
searchable list. Berths already linked to the interest are filtered
out so the rep can't double-add.
- "Specifically pitching" switch surfaces the same Under Offer
consequence the per-row toggle does. Defaults off (interest is
internal-only until the rep promotes it).
- Mutation hits POST /api/v1/interests/[id]/berths with the new
link's `isSpecificInterest` flag. is_in_eoi_bundle / is_primary
stay at their server defaults — the rep flips them on the row after
the link lands. Invalidates interest-berths + berth-recommendations
caches so the row appears immediately and the recommender drops
the just-added berth.
- Dialog only mounts while open so picker state resets on each
invocation (avoids set-state-in-effect re-hydration).
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../interests/linked-berths-list.tsx | 180 +++++++++++++++++-
1 file changed, 177 insertions(+), 3 deletions(-)
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 (
+
+ );
+}
+
// ─── 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}
);
}