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:
@@ -1,6 +1,6 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import type { Route } from 'next';
|
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 { PageHeader } from '@/components/shared/page-header';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
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.",
|
"Berths missing required fields after import / PDF parse. Surface what's missing per row and link straight to the edit sheet.",
|
||||||
icon: FileSearch,
|
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;
|
] as const;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
172
src/components/berths/berth-price-reconcile-table.tsx
Normal file
172
src/components/berths/berth-price-reconcile-table.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user