173 lines
6.1 KiB
TypeScript
173 lines
6.1 KiB
TypeScript
'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>
|
|
);
|
|
}
|