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