'use client'; /** * Bulk berth price-reconcile table (CM-2 Part A). * * Lists the price parsed from each berth's current spec sheet next to the stored * price, with per-row + select-all approval. Nothing is written until the rep * approves — the apply mutation posts only the checked, changed rows. */ import { useMemo, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { apiFetch } from '@/lib/api/client'; import { toastError } from '@/lib/api/toast-error'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { EmptyState } from '@/components/ui/empty-state'; interface Row { berthId: string; mooringNumber: string; area: string | null; currentPrice: number | null; currentCurrency: string; parsedPrice: number | null; parsedCurrency: string | null; status: 'changed' | 'matched' | 'needs_review' | 'no_pdf'; warning?: string; } const STATUS_STYLE: Record = { changed: 'bg-amber-100 text-amber-800', matched: 'bg-muted text-muted-foreground', needs_review: 'bg-red-100 text-red-700', no_pdf: 'bg-slate-100 text-slate-500', }; const STATUS_LABEL: Record = { changed: 'Changed', matched: 'Matched', needs_review: 'Needs review', no_pdf: 'No PDF', }; const fmt = (n: number | null, ccy: string | null) => n == null ? '—' : `${n.toLocaleString()} ${ccy ?? ''}`.trim(); export function BerthPriceReconcileTable() { const qc = useQueryClient(); const { data, isLoading } = useQuery<{ data: Row[] }>({ queryKey: ['berths', 'price-reconcile'], queryFn: () => apiFetch('/api/v1/berths/price-reconcile'), }); const rows = useMemo(() => data?.data ?? [], [data]); const selectable = useMemo(() => rows.filter((r) => r.status === 'changed'), [rows]); const [checked, setChecked] = useState>({}); const apply = useMutation({ mutationFn: async (): Promise<{ data: { updated: number } }> => { const approvals = selectable .filter((r) => checked[r.berthId] && r.parsedPrice != null) .map((r) => ({ berthId: r.berthId, price: r.parsedPrice as number, currency: r.parsedCurrency ?? r.currentCurrency, })); return apiFetch('/api/v1/berths/price-reconcile/apply', { method: 'POST', body: { approvals }, }); }, onSuccess: (res) => { toast.success(`Updated ${res.data.updated} berth price(s).`); setChecked({}); void qc.invalidateQueries({ queryKey: ['berths'] }); }, onError: (e: Error) => toastError(e), }); if (isLoading) { return

Parsing spec sheets…

; } if (rows.length === 0) { return ( ); } const allChecked = selectable.length > 0 && selectable.every((r) => checked[r.berthId]); const selectedCount = selectable.filter((r) => checked[r.berthId]).length; const reviewCount = rows.filter((r) => r.status === 'needs_review').length; const noPdfCount = rows.filter((r) => r.status === 'no_pdf').length; return (

{selectable.length} changed · {reviewCount} need review · {noPdfCount} without a PDF

{rows.map((r) => ( ))}
setChecked( c === true ? Object.fromEntries(selectable.map((r) => [r.berthId, true])) : {}, ) } /> Mooring Area Current Parsed Status
{r.status === 'changed' ? ( setChecked((p) => ({ ...p, [r.berthId]: c === true })) } /> ) : null} {r.mooringNumber} {r.area ?? '—'} {fmt(r.currentPrice, r.currentCurrency)} {fmt(r.parsedPrice, r.parsedCurrency)} {STATUS_LABEL[r.status]} {r.warning ? ( {r.warning} ) : null}
); }