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:
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
386
src/components/berths/bulk-price-edit-sheet.tsx
Normal file
386
src/components/berths/bulk-price-edit-sheet.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user