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`.
|
* - "Remove" — calls `removeInterestBerth`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from '@/components/ui/command';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -39,7 +47,9 @@ import { Textarea } from '@/components/ui/textarea';
|
|||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { HelpCircle } from 'lucide-react';
|
import { HelpCircle } from 'lucide-react';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { toastError } from '@/lib/api/toast-error';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
// ─── Types (mirror the API GET shape — see interest-berths.service.ts) ─────
|
// ─── 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) {
|
function useRemoveLink(interestId: string) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
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 ──────────────────────────────────────────────────────────────
|
// ─── Component ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function LinkedBerthsList({ interestId }: LinkedBerthsListProps) {
|
export function LinkedBerthsList({ interestId }: LinkedBerthsListProps) {
|
||||||
@@ -493,6 +647,8 @@ export function LinkedBerthsList({ interestId }: LinkedBerthsListProps) {
|
|||||||
const rows = data?.data ?? [];
|
const rows = data?.data ?? [];
|
||||||
const eoiStatus = data?.meta.eoiStatus ?? null;
|
const eoiStatus = data?.meta.eoiStatus ?? null;
|
||||||
const isPending = updateMutation.isPending || removeMutation.isPending;
|
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:
|
// Three-bucket split per the Deal-berth + Bundle model:
|
||||||
// • dealBerth: the single is_primary row — the one templates/EOI
|
// • dealBerth: the single is_primary row — the one templates/EOI
|
||||||
@@ -520,11 +676,21 @@ export function LinkedBerthsList({ interestId }: LinkedBerthsListProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<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">
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
<Anchor className="size-4 text-brand-600" aria-hidden />
|
<Anchor className="size-4 text-brand-600" aria-hidden />
|
||||||
Linked berths{rows.length > 0 ? ` (${rows.length})` : ''}
|
Linked berths{rows.length > 0 ? ` (${rows.length})` : ''}
|
||||||
</CardTitle>
|
</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>
|
</CardHeader>
|
||||||
<CardContent className="space-y-5">
|
<CardContent className="space-y-5">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -580,6 +746,14 @@ export function LinkedBerthsList({ interestId }: LinkedBerthsListProps) {
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
{addOpen ? (
|
||||||
|
<AddBerthDialog
|
||||||
|
open
|
||||||
|
onOpenChange={setAddOpen}
|
||||||
|
interestId={interestId}
|
||||||
|
excludeIds={linkedBerthIds}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user