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.
|
> - 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.
|
> - 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.
|
> - 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.
|
> - 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 { type ColumnDef } from '@tanstack/react-table';
|
||||||
import { MoreHorizontal, Pencil, Activity, RefreshCw } from 'lucide-react';
|
import { MoreHorizontal, Pencil, Activity, RefreshCw } from 'lucide-react';
|
||||||
import { useRouter, useParams } from 'next/navigation';
|
import { useRouter, useParams } from 'next/navigation';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -15,6 +16,9 @@ import {
|
|||||||
import { TagBadge } from '@/components/shared/tag-badge';
|
import { TagBadge } from '@/components/shared/tag-badge';
|
||||||
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
|
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
|
||||||
import { formatCurrency } from '@/lib/utils/currency';
|
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 { mooringLetterDot } from './mooring-letter-tone';
|
||||||
import { stageBadgeClass, stageLabel } from '@/lib/constants';
|
import { stageBadgeClass, stageLabel } from '@/lib/constants';
|
||||||
import { CatchUpWizard } from '@/components/berths/catch-up-wizard';
|
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} />;
|
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 {
|
function joinNonNull(parts: Array<string | null | undefined>, sep = ' · '): string {
|
||||||
if (!amount) return null;
|
return parts.filter((p): p is string => Boolean(p)).join(sep);
|
||||||
return formatCurrency(amount, currency, { maxFractionDigits: 0 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -399,7 +452,13 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
|
|||||||
id: 'price',
|
id: 'price',
|
||||||
accessorKey: 'price',
|
accessorKey: 'price',
|
||||||
header: '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',
|
id: 'rates',
|
||||||
|
|||||||
@@ -4,7 +4,16 @@ import { useEffect, useState } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter, useParams } from 'next/navigation';
|
import { useRouter, useParams } from 'next/navigation';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
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 { toast } from 'sonner';
|
||||||
|
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
@@ -43,6 +52,7 @@ import { usePermissions } from '@/hooks/use-permissions';
|
|||||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||||
import { useTablePreferences } from '@/hooks/use-table-preferences';
|
import { useTablePreferences } from '@/hooks/use-table-preferences';
|
||||||
import { BerthCard } from './berth-card';
|
import { BerthCard } from './berth-card';
|
||||||
|
import { BulkPriceEditSheet } from './bulk-price-edit-sheet';
|
||||||
import {
|
import {
|
||||||
getBerthColumns,
|
getBerthColumns,
|
||||||
BERTH_COLUMN_OPTIONS,
|
BERTH_COLUMN_OPTIONS,
|
||||||
@@ -111,6 +121,7 @@ export function BerthList() {
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [tagChoice, setTagChoice] = useState<string[]>([]);
|
const [tagChoice, setTagChoice] = useState<string[]>([]);
|
||||||
|
const [priceSheet, setPriceSheet] = useState<{ ids: string[] } | null>(null);
|
||||||
const bulkMutation = useMutation({
|
const bulkMutation = useMutation({
|
||||||
mutationFn: async (body: Record<string, unknown>) =>
|
mutationFn: async (body: Record<string, unknown>) =>
|
||||||
apiFetch<{ data: { ok: number; failed: number; total: number } }>('/api/v1/berths/bulk', {
|
apiFetch<{ data: { ok: number; failed: number; total: number } }>('/api/v1/berths/bulk', {
|
||||||
@@ -239,68 +250,83 @@ export function BerthList() {
|
|||||||
getRowId={(row) => row.id}
|
getRowId={(row) => row.id}
|
||||||
onRowClick={(row) => router.push(`/${params.portSlug}/berths/${row.id}`)}
|
onRowClick={(row) => router.push(`/${params.portSlug}/berths/${row.id}`)}
|
||||||
getRowClassName={(row) => mooringLetterTone(row.mooringNumber)}
|
getRowClassName={(row) => mooringLetterTone(row.mooringNumber)}
|
||||||
bulkActions={
|
bulkActions={(() => {
|
||||||
can('berths', 'edit')
|
const actions: Array<{
|
||||||
? [
|
label: string;
|
||||||
...(can('berths', 'edit')
|
icon: typeof Anchor;
|
||||||
? [
|
variant?: 'destructive';
|
||||||
{
|
onClick: (ids: string[]) => void | Promise<void>;
|
||||||
label: 'Change status',
|
}> = [];
|
||||||
icon: Anchor,
|
if (can('berths', 'edit')) {
|
||||||
onClick: (ids: string[]) => {
|
actions.push(
|
||||||
if (ids.length === 0) return;
|
{
|
||||||
setStatusChoice('available');
|
label: 'Change status',
|
||||||
setStatusDialog({ ids });
|
icon: Anchor,
|
||||||
},
|
onClick: (ids: string[]) => {
|
||||||
},
|
if (ids.length === 0) return;
|
||||||
{
|
setStatusChoice('available');
|
||||||
label: 'Change tenure',
|
setStatusDialog({ ids });
|
||||||
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 });
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
]
|
},
|
||||||
: 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} />}
|
cardRender={(row) => <BerthCard berth={row.original} />}
|
||||||
// Group adjacent cards by dock letter (area) on mobile - adds a
|
// Group adjacent cards by dock letter (area) on mobile - adds a
|
||||||
// dim divider + uppercased label above the first card of each
|
// dim divider + uppercased label above the first card of each
|
||||||
@@ -416,6 +442,12 @@ export function BerthList() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<BulkPriceEditSheet
|
||||||
|
open={Boolean(priceSheet)}
|
||||||
|
onOpenChange={(o) => !o && setPriceSheet(null)}
|
||||||
|
ids={priceSheet?.ids ?? []}
|
||||||
|
/>
|
||||||
|
|
||||||
<Dialog open={Boolean(tagDialog)} onOpenChange={(o) => !o && setTagDialog(null)}>
|
<Dialog open={Boolean(tagDialog)} onOpenChange={(o) => !o && setTagDialog(null)}>
|
||||||
<DialogContent className="max-w-md">
|
<DialogContent className="max-w-md">
|
||||||
<DialogHeader>
|
<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