'use client'; /** * Linked-berths list — plan §5.5. * * Shows every berth currently linked to the interest with per-row controls: * - "Specifically pitching" toggle (`is_specific_interest`) — drives the * public-map "Under Offer" sub-status. Each state surfaces its consequence * in plain text below the toggle. * - "Mark in EOI bundle" toggle (`is_in_eoi_bundle`). * - "Set as primary" button when this row isn't already primary. The * service helper handles the demote-prior-primary case in a single tx. * - "Bypass EOI for this berth" with a reason textarea. Only rendered when * the parent interest's `eoiStatus === 'signed'`. Writes * `eoi_bypass_reason`, `eoi_bypassed_by`, `eoi_bypassed_at`. * - "Remove" — calls `removeInterestBerth`. */ import { 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 { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Label } from '@/components/ui/label'; import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill'; import { Switch } from '@/components/ui/switch'; 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 { cn } from '@/lib/utils'; // ─── Types (mirror the API GET shape — see interest-berths.service.ts) ───── export interface LinkedBerthRow { id: string; interestId: string; berthId: string; isPrimary: boolean; isSpecificInterest: boolean; isInEoiBundle: boolean; eoiBypassReason: string | null; eoiBypassedBy: string | null; eoiBypassedAt: string | null; addedBy: string | null; addedAt: string; notes: string | null; mooringNumber: string | null; area: string | null; status: string; lengthFt: string | null; widthFt: string | null; draftFt: string | null; } interface LinkedBerthsResponse { data: LinkedBerthRow[]; meta: { eoiStatus: string | null }; } interface LinkedBerthsListProps { interestId: string; } // ─── Helpers ──────────────────────────────────────────────────────────────── function statusToPill(status: string): StatusPillStatus { switch (status) { case 'available': return 'active'; case 'under_offer': return 'sent'; case 'sold': return 'completed'; case 'reserved': return 'partial'; default: return 'pending'; } } function formatStatus(status: string): string { return status.replace(/_/g, ' ').replace(/\b\w/g, (m) => m.toUpperCase()); } function formatDimensions( length: string | null, width: string | null, draft: string | null, ): string | null { const parts: string[] = []; const toNum = (v: string | null) => { if (v === null) return null; const n = parseFloat(v); return Number.isFinite(n) ? n : null; }; const l = toNum(length); const w = toNum(width); const d = toNum(draft); if (l !== null) parts.push(`${l.toFixed(1)}ft L`); if (w !== null) parts.push(`${w.toFixed(1)}ft W`); if (d !== null) parts.push(`${d.toFixed(1)}ft D`); return parts.length > 0 ? parts.join(' · ') : null; } const SPECIFIC_CONSEQUENCE_ON = 'This berth will appear as under interest on the public map.'; const SPECIFIC_CONSEQUENCE_OFF = 'This berth is hidden from the public map.'; // ─── Hooks ────────────────────────────────────────────────────────────────── function useLinkedBerths(interestId: string) { return useQuery({ queryKey: ['interest-berths', interestId] as const, queryFn: () => apiFetch(`/api/v1/interests/${interestId}/berths`), staleTime: 30_000, }); } interface PatchPayload { isPrimary?: boolean; isSpecificInterest?: boolean; isInEoiBundle?: boolean; eoiBypassReason?: string | null; } function useUpdateLink(interestId: string) { const qc = useQueryClient(); return useMutation({ mutationFn: async (args: { berthId: string; patch: PatchPayload }) => apiFetch(`/api/v1/interests/${interestId}/berths/${args.berthId}`, { method: 'PATCH', body: args.patch, }), onSuccess: () => { qc.invalidateQueries({ queryKey: ['interest-berths', interestId] }); qc.invalidateQueries({ queryKey: ['interests', interestId] }); }, }); } function useRemoveLink(interestId: string) { const qc = useQueryClient(); return useMutation({ mutationFn: async (berthId: string) => apiFetch(`/api/v1/interests/${interestId}/berths/${berthId}`, { method: 'DELETE' }), onSuccess: () => { qc.invalidateQueries({ queryKey: ['interest-berths', interestId] }); qc.invalidateQueries({ queryKey: ['interests', interestId] }); qc.invalidateQueries({ queryKey: ['berth-recommendations', interestId] }); }, }); } // ─── Bypass dialog ────────────────────────────────────────────────────────── interface BypassDialogProps { row: LinkedBerthRow; open: boolean; onOpenChange: (open: boolean) => void; onSubmit: (reason: string | null) => void; isPending: boolean; } function BypassDialog({ row, open, onOpenChange, onSubmit, isPending }: BypassDialogProps) { const [reason, setReason] = useState(row.eoiBypassReason ?? ''); return ( Bypass EOI for berth {row.mooringNumber} Record why this berth's individual EOI is being waived. The interest's primary EOI signature will cover it instead.