Files
pn-new-crm/src/components/berths/bulk-price-edit-sheet.tsx
Matt c549622af4 feat(b3-2): bulk-price editing UI — inline cell + bulk-edit sheet
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>
2026-05-25 17:44:14 +02:00

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>
</>
);
}