diff --git a/src/app/(dashboard)/[portSlug]/admin/berths/page.tsx b/src/app/(dashboard)/[portSlug]/admin/berths/page.tsx index 5bd8c22d..8b164ffd 100644 --- a/src/app/(dashboard)/[portSlug]/admin/berths/page.tsx +++ b/src/app/(dashboard)/[portSlug]/admin/berths/page.tsx @@ -1,6 +1,6 @@ import Link from 'next/link'; import type { Route } from 'next'; -import { AlertCircle, Anchor, FileSearch } from 'lucide-react'; +import { AlertCircle, Anchor, FileSearch, BadgeDollarSign } from 'lucide-react'; import { PageHeader } from '@/components/shared/page-header'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; @@ -33,6 +33,13 @@ export default async function BerthsAdminIndex({ "Berths missing required fields after import / PDF parse. Surface what's missing per row and link straight to the edit sheet.", icon: FileSearch, }, + { + href: `/${portSlug}/admin/berths/price-reconcile` as Route, + label: 'Price reconciliation', + description: + 'Parse the purchase price from each berth’s current spec sheet and review old→new per berth. Approve per row or in bulk; nothing is written until you approve.', + icon: BadgeDollarSign, + }, ] as const; return ( diff --git a/src/app/(dashboard)/[portSlug]/admin/berths/price-reconcile/page.tsx b/src/app/(dashboard)/[portSlug]/admin/berths/price-reconcile/page.tsx new file mode 100644 index 00000000..7b1c5d0d --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/admin/berths/price-reconcile/page.tsx @@ -0,0 +1,15 @@ +import { PageHeader } from '@/components/shared/page-header'; +import { BerthPriceReconcileTable } from '@/components/berths/berth-price-reconcile-table'; + +export default function BerthPriceReconcilePage() { + return ( +
+ + +
+ ); +} diff --git a/src/components/berths/berth-price-reconcile-table.tsx b/src/components/berths/berth-price-reconcile-table.tsx new file mode 100644 index 00000000..a4f8ee89 --- /dev/null +++ b/src/components/berths/berth-price-reconcile-table.tsx @@ -0,0 +1,172 @@ +'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])) + : {}, + ) + } + /> + MooringAreaCurrentParsedStatus
+ {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} +
+
+
+ ); +}