feat(interests): explicit "Add berth" CTA on LinkedBerthsList

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 19:50:27 +02:00
parent ca172fa2b8
commit 3999d4bbea

View File

@@ -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<string>;
}
function AddBerthDialog({ open, onOpenChange, interestId, excludeIds }: AddBerthDialogProps) {
const [selected, setSelected] = useState<BerthOption | null>(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<BerthOption[]>(() => {
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Add berth to interest</DialogTitle>
<DialogDescription>
Link an existing berth. Defaults to a non-primary, non-bundle link. Edit those flags on
the row after it lands.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="rounded-md border">
<Command>
<CommandInput placeholder="Search by mooring number…" />
<CommandList>
{isLoading ? (
<div className="p-3 text-sm text-muted-foreground">Loading berths</div>
) : (
<>
<CommandEmpty>No berths available.</CommandEmpty>
<CommandGroup heading={`${available.length} available`}>
{available.map((b) => (
<CommandItem
key={b.id}
value={`${b.mooringNumber} ${b.area ?? ''} ${b.status}`}
onSelect={() => setSelected(b)}
className={cn('cursor-pointer', selected?.id === b.id ? 'bg-muted' : '')}
>
<span className="font-medium">{b.mooringNumber}</span>
<span className="ml-2 text-xs text-muted-foreground">
{formatStatus(b.status)}
</span>
</CommandItem>
))}
</CommandGroup>
</>
)}
</CommandList>
</Command>
</div>
{selected ? (
<div className="rounded-md border bg-muted/30 px-3 py-2 text-sm">
Selected: <span className="font-semibold">{selected.mooringNumber}</span>
</div>
) : null}
<div className="flex items-center gap-2">
<Switch
id="add-berth-specific"
checked={markSpecific}
onCheckedChange={setMarkSpecific}
/>
<Label htmlFor="add-berth-specific" className="text-sm">
Mark as Specifically pitching (shows Under Offer on the public map)
</Label>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={mutation.isPending}
>
Cancel
</Button>
<Button onClick={handleAdd} disabled={!selected || mutation.isPending}>
{mutation.isPending ? (
<Loader2 className="mr-1.5 size-3.5 animate-spin" aria-hidden />
) : (
<Plus className="mr-1.5 size-3.5" aria-hidden />
)}
Add berth
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ─── 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 (
<Card>
<CardHeader>
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
<CardTitle className="flex items-center gap-2 text-base">
<Anchor className="size-4 text-brand-600" aria-hidden />
Linked berths{rows.length > 0 ? ` (${rows.length})` : ''}
</CardTitle>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setAddOpen(true)}
aria-label="Add a berth to this interest"
>
<Plus className="mr-1.5 size-3.5" aria-hidden />
Add berth
</Button>
</CardHeader>
<CardContent className="space-y-5">
{isLoading ? (
@@ -580,6 +746,14 @@ export function LinkedBerthsList({ interestId }: LinkedBerthsListProps) {
</p>
) : null}
</CardContent>
{addOpen ? (
<AddBerthDialog
open
onOpenChange={setAddOpen}
interestId={interestId}
excludeIds={linkedBerthIds}
/>
) : null}
</Card>
);
}