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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user