Lands the UI half of the bulk-price feature (backend already shipped).
Reps with berths.update_prices can retune pricing without unlocking the
rest of the berth schema, both one-at-a-time and in bulk.
- berth-columns: PriceCell wraps InlineEditableField, gated by
can('berths', 'update_prices'). Click → input → save through
PATCH /api/v1/berths/[id]/price. stopPropagation so row click
doesn't navigate while editing.
- bulk-price-edit-sheet: right-side Sheet listing selected berths from
the React Query cache. Per-row price + currency inputs with dirty-
highlight. "Set all to" + "Adjust by %" shortcuts. Diff-only POST to
/bulk-update-prices reports updated/unchanged/missing. Body is keyed
on the selection so useState initializes fresh per open.
- berth-list: new "Update prices" bulk action gated by the same
permission, sits between Remove tag and Archive.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
387 lines
14 KiB
TypeScript
387 lines
14 KiB
TypeScript
'use client';
|
|
|
|
import { useMemo, useState } from 'react';
|
|
import { useMutation, useQueryClient, type QueryClient } from '@tanstack/react-query';
|
|
import { toast } from 'sonner';
|
|
import { AlertCircle, Loader2 } from 'lucide-react';
|
|
|
|
import { apiFetch } from '@/lib/api/client';
|
|
import { toastError } from '@/lib/api/toast-error';
|
|
import {
|
|
Sheet,
|
|
SheetContent,
|
|
SheetDescription,
|
|
SheetFooter,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
} from '@/components/ui/sheet';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import { Separator } from '@/components/ui/separator';
|
|
import { SUPPORTED_CURRENCIES, formatCurrency } from '@/lib/utils/currency';
|
|
import type { BerthRow } from './berth-columns';
|
|
|
|
interface BulkPriceEditSheetProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
/** IDs of the rows the rep selected in the list. */
|
|
ids: string[];
|
|
}
|
|
|
|
interface DraftRow {
|
|
berthId: string;
|
|
mooringNumber: string;
|
|
area: string | null;
|
|
currentPrice: string | null;
|
|
currentCurrency: string;
|
|
newPrice: string;
|
|
newCurrency: string;
|
|
}
|
|
|
|
type BulkResponse = {
|
|
data: { updated: number; unchanged: number; missing: number; missingIds: string[] };
|
|
};
|
|
|
|
const NO_CURRENCY_CHANGE = '__no_change__';
|
|
|
|
/**
|
|
* Outer sheet shell + a body that's keyed on the selection so opening
|
|
* with a fresh set of ids remounts the inner component. That lets us
|
|
* seed `useState` from the React Query cache via the initializer
|
|
* function instead of a setState-in-effect.
|
|
*/
|
|
export function BulkPriceEditSheet({ open, onOpenChange, ids }: BulkPriceEditSheetProps) {
|
|
return (
|
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
<SheetContent
|
|
side="right"
|
|
className="flex w-full flex-col gap-0 p-0 sm:max-w-lg lg:max-w-2xl"
|
|
>
|
|
{open ? (
|
|
<BulkPriceEditBody key={ids.join(',')} ids={ids} onClose={() => onOpenChange(false)} />
|
|
) : null}
|
|
</SheetContent>
|
|
</Sheet>
|
|
);
|
|
}
|
|
|
|
function resolveCachedBerths(qc: QueryClient, ids: string[]): BerthRow[] {
|
|
const cached = qc.getQueriesData<{ data: BerthRow[]; total?: number }>({
|
|
queryKey: ['berths'],
|
|
});
|
|
const set = new Map<string, BerthRow>();
|
|
for (const [, payload] of cached) {
|
|
const rows = (payload as { data?: BerthRow[] } | undefined)?.data;
|
|
if (!rows) continue;
|
|
for (const r of rows) set.set(r.id, r);
|
|
}
|
|
return ids.map((id) => set.get(id)).filter((r): r is BerthRow => Boolean(r));
|
|
}
|
|
|
|
function BulkPriceEditBody({ ids, onClose }: { ids: string[]; onClose: () => void }) {
|
|
const qc = useQueryClient();
|
|
|
|
// Resolve the selected rows from React Query's cache. The list endpoint
|
|
// doesn't support an ids= filter, so we lean on the cache — the typical
|
|
// bulk-select scenario is multi-select inside the currently-rendered
|
|
// page, which is always cached. Any ids the cache can't resolve are
|
|
// still sent to the backend; the bulk-update endpoint reports them as
|
|
// `missing` in its response. Compute synchronously on every render —
|
|
// this component is keyed on ids in its parent so the work is bounded.
|
|
const cachedRows = useMemo(() => resolveCachedBerths(qc, ids), [qc, ids]);
|
|
const unresolvedCount = ids.length - cachedRows.length;
|
|
|
|
const [drafts, setDrafts] = useState<DraftRow[]>(() =>
|
|
cachedRows.map((r) => ({
|
|
berthId: r.id,
|
|
mooringNumber: r.mooringNumber,
|
|
area: r.area,
|
|
currentPrice: r.price,
|
|
currentCurrency: r.priceCurrency,
|
|
newPrice: r.price ?? '',
|
|
newCurrency: r.priceCurrency,
|
|
})),
|
|
);
|
|
const [setAllPrice, setSetAllPrice] = useState('');
|
|
const [setAllCurrency, setSetAllCurrency] = useState<string>(NO_CURRENCY_CHANGE);
|
|
const [adjustPercent, setAdjustPercent] = useState('');
|
|
|
|
function applySetAll() {
|
|
if (setAllPrice.trim() === '') {
|
|
toast.error('Enter an amount to apply to every selected berth.');
|
|
return;
|
|
}
|
|
const n = Number(setAllPrice);
|
|
if (!Number.isFinite(n) || n < 0) {
|
|
toast.error('Amount must be a positive number.');
|
|
return;
|
|
}
|
|
setDrafts((rows) =>
|
|
rows.map((r) => ({
|
|
...r,
|
|
newPrice: String(n),
|
|
newCurrency: setAllCurrency === NO_CURRENCY_CHANGE ? r.newCurrency : setAllCurrency,
|
|
})),
|
|
);
|
|
}
|
|
|
|
function applyPercent() {
|
|
if (adjustPercent.trim() === '') {
|
|
toast.error('Enter a % to adjust by (positive or negative).');
|
|
return;
|
|
}
|
|
const pct = Number(adjustPercent);
|
|
if (!Number.isFinite(pct)) {
|
|
toast.error('% must be a number.');
|
|
return;
|
|
}
|
|
setDrafts((rows) =>
|
|
rows.map((r) => {
|
|
const base = r.currentPrice ? Number(r.currentPrice) : null;
|
|
if (base === null || !Number.isFinite(base)) return r;
|
|
const next = Math.max(0, Math.round(base * (1 + pct / 100)));
|
|
return { ...r, newPrice: String(next) };
|
|
}),
|
|
);
|
|
}
|
|
|
|
function resetAll() {
|
|
setDrafts((rows) =>
|
|
rows.map((r) => ({
|
|
...r,
|
|
newPrice: r.currentPrice ?? '',
|
|
newCurrency: r.currentCurrency,
|
|
})),
|
|
);
|
|
setSetAllPrice('');
|
|
setSetAllCurrency(NO_CURRENCY_CHANGE);
|
|
setAdjustPercent('');
|
|
}
|
|
|
|
// Build the diff payload — only rows whose price OR currency actually
|
|
// changed from the loaded baseline. Stops us from billing the backend
|
|
// (and the audit log) for no-op rows when the rep tweaks only a few.
|
|
const dirtyDrafts = useMemo(
|
|
() =>
|
|
drafts.filter((r) => {
|
|
const newPriceNum = r.newPrice.trim() === '' ? null : Number(r.newPrice);
|
|
const currentPriceNum = r.currentPrice === null ? null : Number(r.currentPrice);
|
|
const priceChanged = newPriceNum !== currentPriceNum;
|
|
const currencyChanged = r.newCurrency !== r.currentCurrency;
|
|
return priceChanged || currencyChanged;
|
|
}),
|
|
[drafts],
|
|
);
|
|
|
|
const mutation = useMutation<BulkResponse, Error, void>({
|
|
mutationFn: async () => {
|
|
const updates = dirtyDrafts.map((r) => {
|
|
const trimmed = r.newPrice.trim();
|
|
const parsed = trimmed === '' ? null : Number(trimmed);
|
|
if (parsed !== null && (!Number.isFinite(parsed) || parsed < 0)) {
|
|
throw new Error(`Berth ${r.mooringNumber}: price must be ≥ 0`);
|
|
}
|
|
return {
|
|
berthId: r.berthId,
|
|
price: parsed,
|
|
...(r.newCurrency !== r.currentCurrency ? { priceCurrency: r.newCurrency } : {}),
|
|
};
|
|
});
|
|
return apiFetch<BulkResponse>('/api/v1/berths/bulk-update-prices', {
|
|
method: 'POST',
|
|
body: { updates },
|
|
});
|
|
},
|
|
onSuccess: (res) => {
|
|
const { updated, unchanged, missing } = res.data;
|
|
const parts = [`${updated} updated`];
|
|
if (unchanged > 0) parts.push(`${unchanged} unchanged`);
|
|
if (missing > 0) parts.push(`${missing} not found`);
|
|
toast.success(parts.join(' · '));
|
|
void qc.invalidateQueries({ queryKey: ['berths'] });
|
|
onClose();
|
|
},
|
|
onError: (err) => toastError(err),
|
|
});
|
|
|
|
return (
|
|
<>
|
|
<SheetHeader className="border-b p-6">
|
|
<SheetTitle>Bulk update prices</SheetTitle>
|
|
<SheetDescription>
|
|
Editing prices for {ids.length} selected berth{ids.length === 1 ? '' : 's'}. Rows whose
|
|
value is unchanged are skipped server-side.
|
|
</SheetDescription>
|
|
</SheetHeader>
|
|
|
|
{/* Set-all + percent-adjust shortcuts */}
|
|
<div className="grid grid-cols-1 gap-4 border-b p-6 sm:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
Set all to
|
|
</Label>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
inputMode="decimal"
|
|
placeholder="Amount"
|
|
value={setAllPrice}
|
|
onChange={(e) => setSetAllPrice(e.target.value)}
|
|
className="h-8 flex-1"
|
|
/>
|
|
<Select value={setAllCurrency} onValueChange={setSetAllCurrency}>
|
|
<SelectTrigger className="h-8 w-28">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value={NO_CURRENCY_CHANGE}>Keep</SelectItem>
|
|
{SUPPORTED_CURRENCIES.map((c) => (
|
|
<SelectItem key={c.code} value={c.code}>
|
|
{c.code}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<Button type="button" size="sm" variant="secondary" onClick={applySetAll}>
|
|
Apply
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
Adjust by %
|
|
</Label>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
inputMode="decimal"
|
|
placeholder="e.g. 5 or -10"
|
|
value={adjustPercent}
|
|
onChange={(e) => setAdjustPercent(e.target.value)}
|
|
className="h-8 flex-1"
|
|
/>
|
|
<Button type="button" size="sm" variant="secondary" onClick={applyPercent}>
|
|
Apply
|
|
</Button>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
Rounds to nearest whole unit; clamps at 0.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Per-row diff list */}
|
|
<div className="flex-1 overflow-y-auto p-6">
|
|
{unresolvedCount > 0 ? (
|
|
<div className="mb-3 flex items-start gap-2 rounded border border-amber-200 bg-amber-50 p-2.5 text-xs text-amber-900">
|
|
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden />
|
|
<span>
|
|
{unresolvedCount} selected berth{unresolvedCount === 1 ? '' : 's'} not visible on the
|
|
current page. Switch pages to load and edit them.
|
|
</span>
|
|
</div>
|
|
) : null}
|
|
{drafts.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">No berths to edit.</p>
|
|
) : (
|
|
<ul className="divide-y">
|
|
{drafts.map((r) => {
|
|
const newNum = r.newPrice.trim() === '' ? null : Number(r.newPrice);
|
|
const currentNum = r.currentPrice === null ? null : Number(r.currentPrice);
|
|
const isDirty = newNum !== currentNum || r.newCurrency !== r.currentCurrency;
|
|
return (
|
|
<li key={r.berthId} className="flex items-center gap-3 py-2.5">
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-sm font-medium">
|
|
{r.mooringNumber}
|
|
{r.area ? (
|
|
<span className="ml-1.5 text-xs text-muted-foreground">· {r.area}</span>
|
|
) : null}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
Current:{' '}
|
|
{r.currentPrice
|
|
? formatCurrency(r.currentPrice, r.currentCurrency, {
|
|
maxFractionDigits: 0,
|
|
})
|
|
: '—'}
|
|
</p>
|
|
</div>
|
|
<Input
|
|
inputMode="decimal"
|
|
value={r.newPrice}
|
|
onChange={(e) =>
|
|
setDrafts((rows) =>
|
|
rows.map((x) =>
|
|
x.berthId === r.berthId ? { ...x, newPrice: e.target.value } : x,
|
|
),
|
|
)
|
|
}
|
|
placeholder="Price"
|
|
className={`h-8 w-32 ${isDirty ? 'border-primary' : ''}`}
|
|
/>
|
|
<Select
|
|
value={r.newCurrency}
|
|
onValueChange={(v) =>
|
|
setDrafts((rows) =>
|
|
rows.map((x) => (x.berthId === r.berthId ? { ...x, newCurrency: v } : x)),
|
|
)
|
|
}
|
|
>
|
|
<SelectTrigger className="h-8 w-24">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{SUPPORTED_CURRENCIES.map((c) => (
|
|
<SelectItem key={c.code} value={c.code}>
|
|
{c.code}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
|
|
<Separator />
|
|
<SheetFooter className="flex flex-row items-center justify-between gap-2 p-6">
|
|
<div className="text-xs text-muted-foreground">
|
|
{dirtyDrafts.length} of {drafts.length} row{drafts.length === 1 ? '' : 's'} changed
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button type="button" variant="ghost" onClick={resetAll} disabled={mutation.isPending}>
|
|
Reset
|
|
</Button>
|
|
<Button type="button" variant="outline" onClick={onClose} disabled={mutation.isPending}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
onClick={() => mutation.mutate()}
|
|
disabled={mutation.isPending || dirtyDrafts.length === 0}
|
|
>
|
|
{mutation.isPending ? (
|
|
<>
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden />
|
|
Applying…
|
|
</>
|
|
) : (
|
|
`Apply (${dirtyDrafts.length})`
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</SheetFooter>
|
|
</>
|
|
);
|
|
}
|