diff --git a/docs/superpowers/audits/alpha-uat-master.md b/docs/superpowers/audits/alpha-uat-master.md index 92fd7fba..be1d2fb5 100644 --- a/docs/superpowers/audits/alpha-uat-master.md +++ b/docs/superpowers/audits/alpha-uat-master.md @@ -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. --- diff --git a/src/components/berths/berth-columns.tsx b/src/components/berths/berth-columns.tsx index 3f48ab6a..60c88f31 100644 --- a/src/components/berths/berth-columns.tsx +++ b/src/components/berths/berth-columns.tsx @@ -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 ; } -function joinNonNull(parts: Array, 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 {display ?? '-'}; + } + + return ( + e.stopPropagation()} className="inline-flex"> + { + 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); + }} + /> + + ); } -function formatMoney(amount: string | null, currency: string): string | null { - if (!amount) return null; - return formatCurrency(amount, currency, { maxFractionDigits: 0 }); +function joinNonNull(parts: Array, sep = ' · '): string { + return parts.filter((p): p is string => Boolean(p)).join(sep); } /** @@ -399,7 +452,13 @@ export const berthColumns: ColumnDef[] = [ id: 'price', accessorKey: 'price', header: 'Price', - cell: ({ row }) => formatMoney(row.original.price, row.original.priceCurrency) ?? '-', + cell: ({ row }) => ( + + ), }, { id: 'rates', diff --git a/src/components/berths/berth-list.tsx b/src/components/berths/berth-list.tsx index dc8b9f88..b94296da 100644 --- a/src/components/berths/berth-list.tsx +++ b/src/components/berths/berth-list.tsx @@ -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([]); + const [priceSheet, setPriceSheet] = useState<{ ids: string[] } | null>(null); const bulkMutation = useMutation({ mutationFn: async (body: Record) => 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; + }> = []; + 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) => } // 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() { + !o && setPriceSheet(null)} + ids={priceSheet?.ids ?? []} + /> + !o && setTagDialog(null)}> diff --git a/src/components/berths/bulk-price-edit-sheet.tsx b/src/components/berths/bulk-price-edit-sheet.tsx new file mode 100644 index 00000000..f0e208d3 --- /dev/null +++ b/src/components/berths/bulk-price-edit-sheet.tsx @@ -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 ( + + + {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, + }) + : '—'} +