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,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',
|
||||
|
||||
Reference in New Issue
Block a user