feat(berths): CM-2 — bulk price-reconcile admin page

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-19 10:38:08 +02:00
parent 9147f2857e
commit b3753b96a1
3 changed files with 195 additions and 1 deletions

View File

@@ -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 berths 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 (

View File

@@ -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 (
<div className="space-y-6">
<PageHeader
title="Berth price reconciliation"
eyebrow="ADMIN"
description="Prices parsed from each berth's current spec sheet, shown against the stored price. Review the changes and approve the ones you trust — nothing is written until you approve it."
/>
<BerthPriceReconcileTable />
</div>
);
}

View File

@@ -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<Row['status'], string> = {
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<Row['status'], string> = {
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<Record<string, boolean>>({});
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 <p className="p-6 text-sm text-muted-foreground">Parsing spec sheets</p>;
}
if (rows.length === 0) {
return (
<EmptyState title="No berths to reconcile" body="No active berths found for this port." />
);
}
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 (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm text-muted-foreground">
{selectable.length} changed · {reviewCount} need review · {noPdfCount} without a PDF
</p>
<Button
size="sm"
disabled={selectedCount === 0 || apply.isPending}
onClick={() => apply.mutate()}
>
{apply.isPending ? 'Applying…' : `Approve selected (${selectedCount})`}
</Button>
</div>
<div className="overflow-hidden rounded-md border bg-white">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/30 text-start text-xs text-muted-foreground">
<th className="w-10 p-2 ps-3">
<Checkbox
aria-label="Select all changed"
checked={allChecked}
onCheckedChange={(c) =>
setChecked(
c === true
? Object.fromEntries(selectable.map((r) => [r.berthId, true]))
: {},
)
}
/>
</th>
<th className="p-2">Mooring</th>
<th className="p-2">Area</th>
<th className="p-2 text-end">Current</th>
<th className="p-2 text-end">Parsed</th>
<th className="p-2">Status</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.berthId} className="border-b last:border-0">
<td className="p-2 ps-3">
{r.status === 'changed' ? (
<Checkbox
aria-label={`Approve ${r.mooringNumber}`}
checked={!!checked[r.berthId]}
onCheckedChange={(c) =>
setChecked((p) => ({ ...p, [r.berthId]: c === true }))
}
/>
) : null}
</td>
<td className="p-2 font-medium">{r.mooringNumber}</td>
<td className="p-2 text-muted-foreground">{r.area ?? '—'}</td>
<td className="p-2 text-end tabular-nums">
{fmt(r.currentPrice, r.currentCurrency)}
</td>
<td className="p-2 text-end tabular-nums">
{fmt(r.parsedPrice, r.parsedCurrency)}
</td>
<td className="p-2">
<span className={`rounded px-2 py-0.5 text-xs ${STATUS_STYLE[r.status]}`}>
{STATUS_LABEL[r.status]}
</span>
{r.warning ? (
<span className="ms-2 text-xs text-muted-foreground">{r.warning}</span>
) : null}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}