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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user