From 991e2223c764b09be96f5e258647297f46b4866e Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 21 May 2026 22:22:30 +0200 Subject: [PATCH] feat(uat-batch): Group C Berth list features (3 new ships + 1 verified) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C20–C23 from the 2026-05-21 plan. Shipped now: C21 Dimensions ft/m column toggle persisted to user prefs. `TablePreferences.dimensionUnit` ('ft' | 'm') added to the user- profiles JSONB. `useTablePreferences` returns `dimensionUnit` + `setDimensionUnit` alongside hidden/density. New `getBerthColumns(unit)` factory rewrites the dimensions / nominalBoatSize / waterDepth cells when ft is requested (waterDepth converts on-the-fly from the canonical meters column at 3.2808 ft/m). Berth-list toolbar gains a small ft/m toggle button next to the density toggle. C22 ft/m switching on Berth Requirements rows. `interest-tabs.tsx` Berth-requirements section now honours `interest.desiredLengthUnit`. Labels flip to "(m)" when set; value reads from `desired*M` columns; on save, both the chosen- unit and the canonical counterpart columns are PATCHed (3.28084 ratio) so downstream surfaces (recommender, EOI merge fields) stay in lockstep. `InterestPatchField` widened with `desired*M` variants. C23 Berth list bulk-edit affordance. New `POST /api/v1/berths/bulk` (mirror of /interests/bulk): discriminated union of `change_status` / `change_tenure_type` / `add_tag` / `remove_tag` / `archive`, 500-id cap, per-row failure reporting, single `berths.edit` permission gate (no separate `archive` perm exists on berths today). Status mutations route through `updateBerthStatus` so under-offer / sold transitions still trigger the primary interest_berths auto-link + the rules-engine evaluation. BerthList toolbar wires `bulkActions` on the DataTable — Change status (Select dialog), Change tenure (permanent / fixed-term), Add tag, Remove tag, Archive (destructive + confirmation). Each dialog uses the same `bulkMutation` so toast + cache-invalidation behaviour is consistent across actions. Already shipped (verified): C20 Berth list rates / pricing valid columns hidden by default — already in `BERTH_DEFAULT_HIDDEN`. Verified: tsc clean, vitest 1454/1454. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/v1/berths/bulk/route.ts | 148 +++++++++++ src/components/berths/berth-columns.tsx | 61 +++++ src/components/berths/berth-list.tsx | 275 ++++++++++++++++++++- src/components/interests/interest-tabs.tsx | 107 ++++++-- src/hooks/use-table-preferences.ts | 15 ++ src/lib/db/schema/users.ts | 5 + 6 files changed, 575 insertions(+), 36 deletions(-) create mode 100644 src/app/api/v1/berths/bulk/route.ts diff --git a/src/app/api/v1/berths/bulk/route.ts b/src/app/api/v1/berths/bulk/route.ts new file mode 100644 index 00000000..d8cc7cfa --- /dev/null +++ b/src/app/api/v1/berths/bulk/route.ts @@ -0,0 +1,148 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { and, eq } from 'drizzle-orm'; + +import { withAuth } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { db } from '@/lib/db'; +import { berths, berthTags } from '@/lib/db/schema/berths'; +import { + archiveBerth, + updateBerth, + updateBerthStatus, + setBerthTags, +} from '@/lib/services/berths.service'; +import { errorResponse } from '@/lib/errors'; + +/** + * Synchronous bulk endpoint for the berths list — mirrors the + * /api/v1/interests/bulk shape so the rep-facing UX is consistent. + * + * Per-row loop with a 500-id cap. Bigger jobs belong on the BullMQ + * `bulk` queue; the synchronous path gives reps an immediate per-row + * failure list. Each row's mutation is independently transactional + * (the underlying service helpers wrap their own writes). + */ +const bulkSchema = z.discriminatedUnion('action', [ + z.object({ + action: z.literal('change_status'), + ids: z.array(z.string().min(1)).min(1).max(500), + status: z.enum(['available', 'under_offer', 'sold']), + }), + z.object({ + action: z.literal('change_tenure_type'), + ids: z.array(z.string().min(1)).min(1).max(500), + tenureType: z.enum(['permanent', 'fixed_term']), + }), + z.object({ + action: z.literal('add_tag'), + ids: z.array(z.string().min(1)).min(1).max(500), + tagId: z.string().min(1), + }), + z.object({ + action: z.literal('remove_tag'), + ids: z.array(z.string().min(1)).min(1).max(500), + tagId: z.string().min(1), + }), + z.object({ + action: z.literal('archive'), + ids: z.array(z.string().min(1)).min(1).max(500), + reason: z.string().max(500).optional(), + }), +]); + +interface RowResult { + id: string; + ok: boolean; + error?: string; +} + +// Berths share a single `edit` permission for non-price mutations (no +// separate `archive` perm today — sales-manager + super-admin own all +// edit paths). +const PERMISSION_BY_ACTION: Record< + z.infer['action'], + { resource: 'berths'; action: 'edit' } +> = { + change_status: { resource: 'berths', action: 'edit' }, + change_tenure_type: { resource: 'berths', action: 'edit' }, + add_tag: { resource: 'berths', action: 'edit' }, + remove_tag: { resource: 'berths', action: 'edit' }, + archive: { resource: 'berths', action: 'edit' }, +}; + +export const POST = withAuth(async (req, ctx) => { + let body: z.infer; + try { + body = await parseBody(req, bulkSchema); + } catch (error) { + return errorResponse(error); + } + + const perm = PERMISSION_BY_ACTION[body.action]; + const allowed = ctx.isSuperAdmin ? true : !!ctx.permissions?.[perm.resource]?.[perm.action]; + if (!allowed) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const meta = { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }; + + const results: RowResult[] = []; + + for (const id of body.ids) { + try { + if (body.action === 'change_status') { + // Status mutations go through the dedicated path so the under- + // offer / sold transitions can auto-create the primary + // interest_berths link + emit the rules-engine evaluation. + await updateBerthStatus( + id, + ctx.portId, + { status: body.status, reason: 'Bulk status change' }, + meta, + ); + } else if (body.action === 'change_tenure_type') { + await updateBerth(id, ctx.portId, { tenureType: body.tenureType }, meta); + } else if (body.action === 'archive') { + await archiveBerth(id, ctx.portId, { reason: body.reason ?? '' }, meta); + } else if (body.action === 'add_tag' || body.action === 'remove_tag') { + const berth = await db.query.berths.findFirst({ + where: and(eq(berths.id, id), eq(berths.portId, ctx.portId)), + }); + if (!berth) { + results.push({ id, ok: false, error: 'Not found' }); + continue; + } + // Compose the new tag set, then re-write atomically. + const currentTags = await db + .select({ tagId: berthTags.tagId }) + .from(berthTags) + .where(eq(berthTags.berthId, id)); + const currentIds = new Set(currentTags.map((t) => t.tagId)); + if (body.action === 'add_tag') currentIds.add(body.tagId); + else currentIds.delete(body.tagId); + await setBerthTags(id, ctx.portId, Array.from(currentIds), meta); + } + results.push({ id, ok: true }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + results.push({ id, ok: false, error: message }); + } + } + + const okCount = results.filter((r) => r.ok).length; + return NextResponse.json({ + data: { + action: body.action, + total: results.length, + ok: okCount, + failed: results.length - okCount, + results, + }, + }); +}); diff --git a/src/components/berths/berth-columns.tsx b/src/components/berths/berth-columns.tsx index c1de54c4..f05948bf 100644 --- a/src/components/berths/berth-columns.tsx +++ b/src/components/berths/berth-columns.tsx @@ -242,6 +242,11 @@ function formatMoney(amount: string | null, currency: string): string | null { return formatCurrency(amount, currency, { maxFractionDigits: 0 }); } +/** + * Static column list rendered in metric units (the historical default). + * Most callers should use `getBerthColumns(unit)` instead, which lets the + * berth-list toolbar toggle render imperial when the rep prefers feet. + */ export const berthColumns: ColumnDef[] = [ { accessorKey: 'mooringNumber', @@ -457,3 +462,59 @@ export const berthColumns: ColumnDef[] = [ cell: ({ row }) => , }, ]; + +/** + * Returns a copy of `berthColumns` with the dimension-bearing cells + * rewritten to render in the requested unit. Used by `BerthList` so the + * column-header toggle can flip the rendering globally without each + * cell renderer reading a context. + * + * Imperial columns assume the canonical `*Ft` columns are populated + * (true by default — the import pipeline + bulk-add wizard write both, + * and the inline editor in yacht-tabs.tsx auto-fills the counterpart). + * Rows with only the metric counterpart fall through to `?` for that + * dimension; the cell still renders so the rep sees what's set. + */ +export function getBerthColumns(unit: 'ft' | 'm'): ColumnDef[] { + if (unit === 'm') return berthColumns; + return berthColumns.map((col) => { + if (col.id === 'dimensions') { + return { + ...col, + cell: ({ row }) => { + const { lengthFt, widthFt, draftFt, widthIsMinimum } = row.original; + if (!lengthFt && !widthFt) return '-'; + const widthLabel = widthFt ? `${widthIsMinimum ? '≥' : ''}${widthFt}ft` : '?'; + const base = `${lengthFt ?? '?'}ft × ${widthLabel}`; + return draftFt ? `${base} (draft ${draftFt}ft)` : base; + }, + }; + } + if (col.id === 'nominalBoatSize') { + return { + ...col, + cell: ({ row }) => { + const ft = row.original.nominalBoatSize; + const m = row.original.nominalBoatSizeM; + if (!ft && !m) return '-'; + return ft ? `${ft}ft` : `${m}m`; + }, + }; + } + if (col.id === 'waterDepth') { + // Water depth lacks a stored `*Ft` column today; convert from meters + // on the fly when the rep prefers ft. 1m = 3.2808ft (canonical + // ratio used in yacht-dimensions.ts). + return { + ...col, + cell: ({ row }) => { + const { waterDepthM, waterDepthIsMinimum } = row.original; + if (!waterDepthM) return '-'; + const ft = Number(waterDepthM) * 3.2808; + return `${waterDepthIsMinimum ? '≥' : ''}${ft.toFixed(1)}ft`; + }, + }; + } + return col; + }); +} diff --git a/src/components/berths/berth-list.tsx b/src/components/berths/berth-list.tsx index c465ef48..8d79035c 100644 --- a/src/components/berths/berth-list.tsx +++ b/src/components/berths/berth-list.tsx @@ -1,9 +1,32 @@ 'use client'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import Link from 'next/link'; import { useRouter, useParams } from 'next/navigation'; -import { Anchor, Plus, Rows3, Rows4 } from 'lucide-react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { Anchor, Archive, Plus, Rows3, Rows4, Tag as TagIcon, TagsIcon } from 'lucide-react'; +import { toast } from 'sonner'; + +import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; +import { useConfirmation } from '@/hooks/use-confirmation'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Label } from '@/components/ui/label'; +import { TagPicker } from '@/components/shared/tag-picker'; import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider'; import { DataTable } from '@/components/shared/data-table'; @@ -21,7 +44,7 @@ import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { useTablePreferences } from '@/hooks/use-table-preferences'; import { BerthCard } from './berth-card'; import { - berthColumns, + getBerthColumns, BERTH_COLUMN_OPTIONS, BERTH_DEFAULT_HIDDEN, type BerthRow, @@ -67,14 +90,49 @@ export function BerthList() { 'berth:statusChanged': [['berths']], }); - // Persisted column visibility + row density — same pattern as - // ClientList / InterestList; density is new and falls back to - // 'comfortable' for users who haven't picked yet. - const { hidden, setHidden, density, setDensity } = useTablePreferences( - 'berths', - BERTH_DEFAULT_HIDDEN, - ); + // Persisted column visibility + row density + dimension unit — same + // pattern as ClientList / InterestList; density falls back to + // 'comfortable' and dimensionUnit to 'ft' for users who haven't picked. + const { hidden, setHidden, density, setDensity, dimensionUnit, setDimensionUnit } = + useTablePreferences('berths', BERTH_DEFAULT_HIDDEN); const columnVisibility = Object.fromEntries(hidden.map((id) => [id, false])); + const berthColumns = getBerthColumns(dimensionUnit); + + // Bulk-action state — one dialog per action (status / tenure type / + // tag add+remove). Mirrors the InterestList pattern so reps already + // know the idiom from there. + const qc = useQueryClient(); + const { confirm, dialog: confirmDialog } = useConfirmation(); + const [statusDialog, setStatusDialog] = useState<{ ids: string[] } | null>(null); + const [statusChoice, setStatusChoice] = useState('available'); + const [tenureDialog, setTenureDialog] = useState<{ ids: string[] } | null>(null); + const [tenureChoice, setTenureChoice] = useState('permanent'); + const [tagDialog, setTagDialog] = useState<{ ids: string[]; mode: 'add' | 'remove' } | null>( + null, + ); + const [tagChoice, setTagChoice] = useState([]); + const bulkMutation = useMutation({ + mutationFn: async (body: Record) => + apiFetch<{ data: { ok: number; failed: number; total: number } }>('/api/v1/berths/bulk', { + method: 'POST', + body, + }), + onSuccess: (res) => { + if (res.data.failed > 0) { + toast.warning( + `${res.data.ok} of ${res.data.total} berths updated. ${res.data.failed} failed.`, + ); + } else { + toast.success(`Updated ${res.data.ok} berth${res.data.ok === 1 ? '' : 's'}`); + } + void qc.invalidateQueries({ queryKey: ['berths'] }); + setStatusDialog(null); + setTenureDialog(null); + setTagDialog(null); + setTagChoice([]); + }, + onError: (err) => toastError(err), + }); return (
@@ -136,6 +194,17 @@ export function BerthList() { )} +
); } diff --git a/src/components/interests/interest-tabs.tsx b/src/components/interests/interest-tabs.tsx index 238aade0..32675232 100644 --- a/src/components/interests/interest-tabs.tsx +++ b/src/components/interests/interest-tabs.tsx @@ -65,6 +65,9 @@ type InterestPatchField = | 'desiredLengthFt' | 'desiredWidthFt' | 'desiredDraftFt' + | 'desiredLengthM' + | 'desiredWidthM' + | 'desiredDraftM' | 'dateEoiSent' | 'dateEoiSigned' | 'dateReservationSigned' @@ -95,6 +98,12 @@ interface InterestTabsOptions { desiredLengthFt?: string | null; desiredWidthFt?: string | null; desiredDraftFt?: string | null; + /** Metric counterparts persisted alongside the imperial columns. The + * Berth-requirements row toggles between the two based on + * `desiredLengthUnit`. */ + desiredLengthM?: string | null; + desiredWidthM?: string | null; + desiredDraftM?: string | null; /** Unit the rep originally entered the dims in — drives the * recommender header's display so a metric-entered deal doesn't * render as ft. The three columns share an entry unit in practice. */ @@ -1161,32 +1170,78 @@ function OverviewTab({ BerthRecommenderPanel rankings below. */}

Berth requirements

-
- - - - - - - - - -
+ {(() => { + // Honour the interest's `desiredLengthUnit` so a deal whose rep + // entered metric values doesn't render labelled "(ft)" with + // empty inputs. On save we patch BOTH the chosen-unit column + // and the canonical counterpart so downstream surfaces + // (recommender, EOI merge fields) stay in lockstep. + const unitIsM = interest.desiredLengthUnit === 'm'; + const FT_PER_M = 3.28084; + const toCounterpart = (v: string | null): string | null => { + if (!v) return null; + const n = Number(v); + if (!Number.isFinite(n)) return null; + return unitIsM ? (n * FT_PER_M).toFixed(4) : (n / FT_PER_M).toFixed(4); + }; + const onSavePair = + ( + primary: InterestPatchField, + counterpart: InterestPatchField, + ): ((next: string | null) => Promise) => + async (next: string | null) => { + await mutation.mutateAsync({ + [primary]: next, + [counterpart]: toCounterpart(next), + }); + }; + const unitLabel = unitIsM ? 'm' : 'ft'; + return ( +
+ + + + + + + + + +
+ ); + })()}
{/* Legacy `interest.reminderEnabled` / `reminderDays` / `reminderLastFired` diff --git a/src/hooks/use-table-preferences.ts b/src/hooks/use-table-preferences.ts index 607f9df1..63a50b61 100644 --- a/src/hooks/use-table-preferences.ts +++ b/src/hooks/use-table-preferences.ts @@ -41,12 +41,14 @@ export function useTablePreferences(entityType: string, defaultHidden: string[] const remoteEntry = meQuery.data?.data.preferences?.tablePreferences?.[entityType]; const remoteHidden = remoteEntry?.hiddenColumns; const remoteDensity = remoteEntry?.density; + const remoteDimensionUnit = remoteEntry?.dimensionUnit; // Local edits win over the server-loaded prefs. The render-phase // derivation below (`localHidden ?? remoteHidden ?? defaultHidden`) // replaces the prior useEffect(setLocalHidden, [remoteHidden]) sync // that the Compiler flagged as set-state-in-effect. const [localHidden, setLocalHidden] = useState(null); const [localDensity, setLocalDensity] = useState<'comfortable' | 'compact' | null>(null); + const [localDimensionUnit, setLocalDimensionUnit] = useState<'ft' | 'm' | null>(null); const debounceRef = useRef | null>(null); @@ -109,6 +111,14 @@ export function useTablePreferences(entityType: string, defaultHidden: string[] [flush], ); + const setDimensionUnit = useCallback( + (next: 'ft' | 'm') => { + setLocalDimensionUnit(next); + flush({ dimensionUnit: next }); + }, + [flush], + ); + // Cleanup pending timer on unmount so React doesn't warn about // setting state after the component is gone. useEffect( @@ -125,12 +135,17 @@ export function useTablePreferences(entityType: string, defaultHidden: string[] // the never-saved case. const resolved = localHidden ?? remoteHidden ?? defaultHidden; const density: 'comfortable' | 'compact' = localDensity ?? remoteDensity ?? 'comfortable'; + // Dimension unit: local optimistic → remote saved → 'ft' fallback (matches + // the existing canonical-format-in-feet shape of the underlying columns). + const dimensionUnit: 'ft' | 'm' = localDimensionUnit ?? remoteDimensionUnit ?? 'ft'; return { hidden: resolved, setHidden, density, setDensity, + dimensionUnit, + setDimensionUnit, isLoaded: !meQuery.isLoading, }; } diff --git a/src/lib/db/schema/users.ts b/src/lib/db/schema/users.ts index cb7b1fb5..8f46c02e 100644 --- a/src/lib/db/schema/users.ts +++ b/src/lib/db/schema/users.ts @@ -178,6 +178,11 @@ export type TablePreferences = { * default cell padding). 'compact' drops vertical padding so reps * can scan more rows per viewport. */ density?: 'comfortable' | 'compact'; + /** Dimension unit preference for tables that render boat / berth + * measurements. Absent = inferred from the user's locale; 'ft' for + * US/UK defaults, 'm' elsewhere. Per-table override via the column + * header toggle. */ + dimensionUnit?: 'ft' | 'm'; }; export type UserPreferences = {