'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 ( {open ? ( onOpenChange(false)} /> ) : null} ); } function resolveCachedBerths(qc: QueryClient, ids: string[]): BerthRow[] { const cached = qc.getQueriesData<{ data: BerthRow[]; total?: number }>({ queryKey: ['berths'], }); const set = new Map(); 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(() => 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(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({ 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('/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 ( <> Bulk update prices Editing prices for {ids.length} selected berth{ids.length === 1 ? '' : 's'}. Rows whose value is unchanged are skipped server-side. {/* Set-all + percent-adjust shortcuts */} Set all to setSetAllPrice(e.target.value)} className="h-8 flex-1" /> Keep {SUPPORTED_CURRENCIES.map((c) => ( {c.code} ))} Apply Adjust by % setAdjustPercent(e.target.value)} className="h-8 flex-1" /> Apply Rounds to nearest whole unit; clamps at 0. {/* Per-row diff list */} {unresolvedCount > 0 ? ( {unresolvedCount} selected berth{unresolvedCount === 1 ? '' : 's'} not visible on the current page. Switch pages to load and edit them. ) : null} {drafts.length === 0 ? ( No berths to edit. ) : ( {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 ( {r.mooringNumber} {r.area ? ( · {r.area} ) : null} Current:{' '} {r.currentPrice ? formatCurrency(r.currentPrice, r.currentCurrency, { maxFractionDigits: 0, }) : '—'} 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' : ''}`} /> setDrafts((rows) => rows.map((x) => (x.berthId === r.berthId ? { ...x, newCurrency: v } : x)), ) } > {SUPPORTED_CURRENCIES.map((c) => ( {c.code} ))} ); })} )} {dirtyDrafts.length} of {drafts.length} row{drafts.length === 1 ? '' : 's'} changed Reset Cancel mutation.mutate()} disabled={mutation.isPending || dirtyDrafts.length === 0} > {mutation.isPending ? ( <> Applying… > ) : ( `Apply (${dirtyDrafts.length})` )} > ); }
Rounds to nearest whole unit; clamps at 0.
No berths to edit.
{r.mooringNumber} {r.area ? ( · {r.area} ) : null}
Current:{' '} {r.currentPrice ? formatCurrency(r.currentPrice, r.currentCurrency, { maxFractionDigits: 0, }) : '—'}