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>
This commit is contained in:
2026-05-25 17:44:14 +02:00
parent da391b1830
commit c549622af4
4 changed files with 546 additions and 69 deletions

View File

@@ -41,7 +41,7 @@
> - Reports P3-P7 (~31 h): BullMQ render+email queues + landing + builder + sub-pages + CSV/PNG outputs + metadata overrides.
> - Tenancies P2-P7 (~36 h): rename migration + perms seed + webhook auto-create + public-map flip rules + sidebar entry + top-level page + entity tab CTAs + 4 reporting widgets.
> - UploadForSigningDialog field metadata (full bundle, ~6-9 h): PlacedField.defaultValue + fieldMeta with per-type panel inputs + Documenso v2 `field/create-many` payload extension.
> - B3 Wave (~50 h): interest dimensions dual-source, universal file preview Tier 1+2, bulk-price editing UI, web analytics integration, universal upload-with-fields remaining UI sites, 5 remaining PDF resolvers, recharts→ECharts migration.
> - B3 Wave remaining: bulk-price editing UI (~2-3 h — backend already shipped); Umami Phase 4a (marketing-site instrumentation, separate repo) + Phase 3/5 (events tab + funnels, blocked on 4a). _Shipped this session: B3-1 (interest dimensions dual-source). Shipped earlier in week: universal file preview Tier 1+2, all 16 PDF resolvers (incl. the 5 previously-tracked "remaining"), universal upload-with-fields backend + most UI sites._ Recharts→ECharts migration removed (rejected).\_
> - B4 bugs: Global-search dropdown translucent still wants live-browser repro to confirm the defensive fix actually addressed the cause.
---

View File

@@ -4,6 +4,7 @@ import { useState } from 'react';
import { type ColumnDef } from '@tanstack/react-table';
import { MoreHorizontal, Pencil, Activity, RefreshCw } from 'lucide-react';
import { useRouter, useParams } from 'next/navigation';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import {
@@ -15,6 +16,9 @@ import {
import { TagBadge } from '@/components/shared/tag-badge';
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
import { formatCurrency } from '@/lib/utils/currency';
import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { apiFetch } from '@/lib/api/client';
import { usePermissions } from '@/hooks/use-permissions';
import { mooringLetterDot } from './mooring-letter-tone';
import { stageBadgeClass, stageLabel } from '@/lib/constants';
import { CatchUpWizard } from '@/components/berths/catch-up-wizard';
@@ -233,13 +237,62 @@ function ActiveInterestsCell({ berthId, count }: { berthId: string; count: numbe
return <ActiveInterestsPopover berthId={berthId} portSlug={portSlug} count={count} />;
}
function joinNonNull(parts: Array<string | null | undefined>, sep = ' · '): string {
return parts.filter((p): p is string => Boolean(p)).join(sep);
/**
* Price column cell. Reps with the `berths.update_prices` permission get
* a click-to-edit inline field — saves go through the focused price-only
* route so non-`edit` roles can retune pricing without unlocking the rest
* of the berth schema. Click stops bubbling so the row's navigate-to-
* detail handler doesn't fire while the rep is editing.
*/
function PriceCell({
berthId,
price,
currency,
}: {
berthId: string;
price: string | null;
currency: string;
}) {
const { can } = usePermissions();
const qc = useQueryClient();
const display = price ? (formatCurrency(price, currency, { maxFractionDigits: 0 }) ?? '-') : null;
const mutation = useMutation({
mutationFn: async (next: number | null) =>
apiFetch(`/api/v1/berths/${berthId}/price`, {
method: 'PATCH',
body: { price: next },
}),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['berths'] });
},
});
if (!can('berths', 'update_prices')) {
return <span>{display ?? '-'}</span>;
}
return (
<div onClick={(e) => e.stopPropagation()} className="inline-flex">
<InlineEditableField
value={price ?? null}
displayValue={display}
emptyText="-"
placeholder="Enter price"
onSave={async (next) => {
const parsed = next === null || next.trim() === '' ? null : Number(next);
if (parsed !== null && (!Number.isFinite(parsed) || parsed < 0)) {
throw new Error('Price must be a positive number');
}
await mutation.mutateAsync(parsed);
}}
/>
</div>
);
}
function formatMoney(amount: string | null, currency: string): string | null {
if (!amount) return null;
return formatCurrency(amount, currency, { maxFractionDigits: 0 });
function joinNonNull(parts: Array<string | null | undefined>, sep = ' · '): string {
return parts.filter((p): p is string => Boolean(p)).join(sep);
}
/**
@@ -399,7 +452,13 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
id: 'price',
accessorKey: 'price',
header: 'Price',
cell: ({ row }) => formatMoney(row.original.price, row.original.priceCurrency) ?? '-',
cell: ({ row }) => (
<PriceCell
berthId={row.original.id}
price={row.original.price}
currency={row.original.priceCurrency}
/>
),
},
{
id: 'rates',

View File

@@ -4,7 +4,16 @@ import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useRouter, useParams } from 'next/navigation';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Anchor, Archive, Plus, Rows3, Rows4, Tag as TagIcon, TagsIcon } from 'lucide-react';
import {
Anchor,
Archive,
CircleDollarSign,
Plus,
Rows3,
Rows4,
Tag as TagIcon,
TagsIcon,
} from 'lucide-react';
import { toast } from 'sonner';
import { apiFetch } from '@/lib/api/client';
@@ -43,6 +52,7 @@ import { usePermissions } from '@/hooks/use-permissions';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { useTablePreferences } from '@/hooks/use-table-preferences';
import { BerthCard } from './berth-card';
import { BulkPriceEditSheet } from './bulk-price-edit-sheet';
import {
getBerthColumns,
BERTH_COLUMN_OPTIONS,
@@ -111,6 +121,7 @@ export function BerthList() {
null,
);
const [tagChoice, setTagChoice] = useState<string[]>([]);
const [priceSheet, setPriceSheet] = useState<{ ids: string[] } | null>(null);
const bulkMutation = useMutation({
mutationFn: async (body: Record<string, unknown>) =>
apiFetch<{ data: { ok: number; failed: number; total: number } }>('/api/v1/berths/bulk', {
@@ -239,68 +250,83 @@ export function BerthList() {
getRowId={(row) => row.id}
onRowClick={(row) => router.push(`/${params.portSlug}/berths/${row.id}`)}
getRowClassName={(row) => mooringLetterTone(row.mooringNumber)}
bulkActions={
can('berths', 'edit')
? [
...(can('berths', 'edit')
? [
{
label: 'Change status',
icon: Anchor,
onClick: (ids: string[]) => {
if (ids.length === 0) return;
setStatusChoice('available');
setStatusDialog({ ids });
},
},
{
label: 'Change tenure',
icon: Anchor,
onClick: (ids: string[]) => {
if (ids.length === 0) return;
setTenureChoice('permanent');
setTenureDialog({ ids });
},
},
{
label: 'Add tag',
icon: TagIcon,
onClick: (ids: string[]) => {
if (ids.length === 0) return;
setTagChoice([]);
setTagDialog({ ids, mode: 'add' });
},
},
{
label: 'Remove tag',
icon: TagsIcon,
onClick: (ids: string[]) => {
if (ids.length === 0) return;
setTagChoice([]);
setTagDialog({ ids, mode: 'remove' });
},
},
]
: []),
{
label: 'Archive',
icon: Archive,
variant: 'destructive' as const,
onClick: async (ids: string[]) => {
if (ids.length === 0) return;
const ok = await confirm({
title: `Archive ${ids.length} berth${ids.length === 1 ? '' : 's'}`,
description:
'Archived berths are hidden from option pickers. Existing interests + audit trail are preserved.',
confirmLabel: 'Archive',
});
if (!ok) return;
bulkMutation.mutate({ action: 'archive', ids });
},
bulkActions={(() => {
const actions: Array<{
label: string;
icon: typeof Anchor;
variant?: 'destructive';
onClick: (ids: string[]) => void | Promise<void>;
}> = [];
if (can('berths', 'edit')) {
actions.push(
{
label: 'Change status',
icon: Anchor,
onClick: (ids: string[]) => {
if (ids.length === 0) return;
setStatusChoice('available');
setStatusDialog({ ids });
},
]
: undefined
}
},
{
label: 'Change tenure',
icon: Anchor,
onClick: (ids: string[]) => {
if (ids.length === 0) return;
setTenureChoice('permanent');
setTenureDialog({ ids });
},
},
{
label: 'Add tag',
icon: TagIcon,
onClick: (ids: string[]) => {
if (ids.length === 0) return;
setTagChoice([]);
setTagDialog({ ids, mode: 'add' });
},
},
{
label: 'Remove tag',
icon: TagsIcon,
onClick: (ids: string[]) => {
if (ids.length === 0) return;
setTagChoice([]);
setTagDialog({ ids, mode: 'remove' });
},
},
);
}
if (can('berths', 'update_prices')) {
actions.push({
label: 'Update prices',
icon: CircleDollarSign,
onClick: (ids: string[]) => {
if (ids.length === 0) return;
setPriceSheet({ ids });
},
});
}
if (can('berths', 'edit')) {
actions.push({
label: 'Archive',
icon: Archive,
variant: 'destructive',
onClick: async (ids: string[]) => {
if (ids.length === 0) return;
const ok = await confirm({
title: `Archive ${ids.length} berth${ids.length === 1 ? '' : 's'}`,
description:
'Archived berths are hidden from option pickers. Existing interests + audit trail are preserved.',
confirmLabel: 'Archive',
});
if (!ok) return;
bulkMutation.mutate({ action: 'archive', ids });
},
});
}
return actions.length > 0 ? actions : undefined;
})()}
cardRender={(row) => <BerthCard berth={row.original} />}
// Group adjacent cards by dock letter (area) on mobile - adds a
// dim divider + uppercased label above the first card of each
@@ -416,6 +442,12 @@ export function BerthList() {
</DialogContent>
</Dialog>
<BulkPriceEditSheet
open={Boolean(priceSheet)}
onOpenChange={(o) => !o && setPriceSheet(null)}
ids={priceSheet?.ids ?? []}
/>
<Dialog open={Boolean(tagDialog)} onOpenChange={(o) => !o && setTagDialog(null)}>
<DialogContent className="max-w-md">
<DialogHeader>

View File

@@ -0,0 +1,386 @@
'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>
</>
);
}