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

@@ -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',