Covers the happy-path admin flow: open hub, open Folder Actions menu,
create a root folder, click into it, breadcrumb updates. Doesn't yet
cover delete (soft-rescue) or move-to-folder — separate spec when
needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The feature-flags query previously sat at ['documents', 'feature-flags'],
which the hub's useRealtimeInvalidation([['documents']]) registration
matched via TanStack's default prefix matching. Every document socket
event refetched the flag, silently defeating the 5-minute staleTime.
Move the key to ['documents-feature-flags'] so it sits outside the
prefix; document events no longer trigger a flag refetch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New documents_show_expired_tab system setting (default true). Public
read via GET /api/v1/documents/feature-flags (gated on documents.view
so reps can read it without holding manage_settings). When off, the
Expired tab is hidden from the documents hub — useful when expired
EOIs are noise that distracts reps from active deals.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Switching tab or folder while a type filter was active left the
filter applied silently — the chip cloud regenerated from the new
result set so no chip lit up, but the documentType= query param
kept narrowing the list. Reset typeFilter to undefined whenever tab
or selected folder changes.
Also use TYPE_LABELS for chip text so the filter chips match the
human-readable labels already shown in the row's Type column.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Type-filter chip cloud sourced from the documentTypes seen in the
current result set, replacing the static dropdown over the whole
DOCUMENT_TYPES enum. New "Move to folder…" entry on the per-row
action menu (gated on documents.manage_folders) opens the
MoveToFolderDialog Combobox.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Documents hub now opens with the folder tree on the left and a
breadcrumb on top. Folder selection is its own state — undefined =
"All", null = "Root only", string = specific folder. Filter pushes
through to /api/v1/documents via folderId query param.
Drops the "Signature-based only" pill — it defaulted to true and
silently hid informational documents, which confused new reps. With
folders the rep organises by location, not by signature-vs-not.
Adds an "In progress" hub tab covering status IN (draft, sent,
partially_signed) for the everyday "what's in flight" view.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cmdk filters by the CommandItem value prop, so the sentinel
"__root__" silently failed to match natural search terms like "no
folder". Use the human label instead. Also reset pickedId when the
dialog re-opens so a cancelled pick doesn't carry a stale highlight
into the next open.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cmdk Combobox dialog showing all folder paths flat (' / '-separated),
plus a "Root (no folder)" pseudo-option. Move button disabled when the
picked folder matches the document's current folder.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pass loading={deleteMutation.isPending} to ConfirmationDialog so a
second tap on Delete doesn't dispatch a concurrent DELETE. Also
disable the rename Save button when the name hasn't changed, so an
accidental click doesn't fire a no-op PATCH and a misleading
"Folder renamed" toast.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DropdownMenu trigger with three actions: New folder (works at root or
inside the selected folder), Rename, Delete (confirm-then-soft-rescue).
Delete copy explicitly tells reps the contents move to the parent so
nothing dies silently.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Match the existing src/components/ui/breadcrumb.tsx pattern: separator
chevrons are aria-hidden so screen readers don't announce them, and
the terminal segment (Root or current folder name) carries
aria-current="page" so SR users know which crumb is the current page.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Renders the current folder's path as a clickable breadcrumb with a
Home affordance back to "All documents". Each ancestor is clickable
to navigate up; the last segment is the current folder (non-clickable,
foreground colour).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Folder query failures previously rendered identically to an empty
list, hiding network problems from the user. Add an isError branch
that shows "Failed to load folders." in destructive color.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Persistent left rail with "All documents" + "Root" pseudo-rows above
the tree. Each tree row has a chevron toggle (expand/collapse) and a
clickable label (select). Renders unlimited depth without blowing out
the page — children only mount when their parent is expanded.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wraps the folder tree fetch in TanStack with a 30s staleTime, and
provides create / rename / move / delete / move-document mutations
that invalidate the relevant query keys. buildFolderPaths flattens
the tree into ' / '-separated path strings for picker dropdowns.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tasks 1-7 done in subagent-driven mode (11 commits 5bed62d → a0ffa1b).
The entire DB + service + API layer for folders is shipped: schema,
manage_folders perm, listTree/createFolder/renameFolder/moveFolder/
deleteFolderSoftRescue, validators, all 4 folder routes, the per-doc
move endpoint, and the listDocuments folder filter (with descendant
expansion). Reps can already manage folders end-to-end via direct
API calls.
Records the design decisions made mid-execution: hybrid storage
strategy (UUID-flat + path-style download URLs), permission split,
soft-rescue delete semantics, cycle prevention with port-scoped
ancestor walk, PATCH-body exclusivity via .strict(), and the
updatedAt bump rule (per-doc move yes, bulk soft-rescue no).
Tests at pause: 1213/1213 vitest, tsc clean. Resume prompt + task
ordering for Task 8 onwards included so a fresh session can pick up
without context.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
listDocuments accepts folderId (string | null | undefined) and
includeDescendants. folderId=null returns only docs at root;
includeDescendants=true expands the subtree via collectDescendantIds
(in-memory walk over the cached tree -- folder trees are small).
PATCH /api/v1/documents/[id]/folder moves a single document under
documents.manage_folders, with audit-log metadata { type: 'folder_move' }.
Bumping updatedAt is correct for per-doc moves because reps deliberately
acted on that document -- different semantics from the bulk soft-rescue
in Task 4.
createDocument accepts an optional folderId for the upcoming UI's
"create in current folder" affordance.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
z.union picks the first member that parses successfully, so a body
with { name, parentId } would silently be parsed as a rename and the
parentId dropped. The route comment claimed this was rejected — it
wasn't. Adding .strict() to each branch makes the rejection real:
both members refuse extra keys, the union produces a 400, and the
rep gets feedback instead of a silent half-op.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GET /api/v1/document-folders → full tree (documents.view).
POST /api/v1/document-folders → create (documents.manage_folders).
PATCH /api/v1/document-folders/[id] → rename OR move (union schema —
refuses both in one body so audit logs stay one-op-per-call).
DELETE /api/v1/document-folders/[id] → soft-rescue delete; returns 204.
PATCH passes ctx.userId through to the service-level audit-log
emitters (renameFolder + moveFolder gained userId in Task 4 fixes).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code-review followups on e9251a3:
- Move createAuditLog OUT of the deleteFolderSoftRescue transaction
callback so a rolled-back transaction can't leave a phantom audit
row. Pattern matches clients.service.ts, expense-dedup.service.ts.
- Add portId filter to the moveFolder ancestor-walk findFirst —
defense-in-depth so corrupted parentId pointing at another port
short-circuits the walk instead of silently traversing it.
- Drop updatedAt bump on rescued documents — folder rescue is an
administrative storage op, not a content change; bumping made
every rescued doc appear "recently modified" in list views.
- Add userId param + audit-log emission on renameFolder and
moveFolder for parity with createFolder + deleteFolderSoftRescue.
Tests updated to pass TEST_USER_ID as the new 4th arg.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User chose the hybrid storage strategy after reviewing the cost
analysis: storage paths stay UUID-flat (preserves the established
pattern across brochures, berth PDFs, invoices, reports, templates,
expense receipts, and the migrate-storage byte-verbatim copy), but
documents gain a path-style download URL so reps see meaningful
paths in shared links and browser tabs.
Task 18 wires the new /api/v1/documents/[id]/download/[...slug]
catch-all route + a downloadUrl field on list/detail responses.
The slug is validated for truth so a hand-edited URL with a
stale path 404s instead of silently serving the wrong file.
Task 19 is the importer the user mentioned: a one-shot script
that walks an organized legacy bucket, creates matching folder
tree + document rows pointing at existing storage keys verbatim.
Idempotent via the sibling-uniqueness index.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
renameFolder + moveFolder enforce sibling-name uniqueness via the
shared isSiblingNameConflict helper and reject cross-port leakage at
the service boundary. moveFolder walks the destination's ancestor
chain to refuse cycles before the write.
deleteFolderSoftRescue re-parents every child folder and document up
to the deleted folder's parent (or to root) inside a transaction,
then drops the folder row. Children never disappear silently — a
wrong click moves work up the tree, never deletes it. Audit-logged
with rescuedTo metadata.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code-review followups on 4b31f01:
- beforeEach now scopes the documentFolders cleanup to the test port
via .where(eq(documentFolders.portId, portId)) so parallel suites
don't wipe each other's fixtures.
- Cross-port parent guard message changed from "Parent folder not
found in this port" (read like a 404) to "Invalid parent folder"
to match the ValidationError type that already maps to 400.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
In-memory tree build (single SELECT + JS nesting); the folder tree is
small enough that a recursive CTE buys nothing. Sibling-name conflict
maps the Postgres unique-index 23505 to a typed ConflictError so the
UI can render a clean toast. Cross-port parentId rejected at the
service boundary. Also adds document_folders to the global teardown
CTE so test ports can be cleaned up without FK violations.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors files.manage_folders. Gates create / rename / move / delete
of document folders, plus moving documents between folders. Reps with
documents.edit but not manage_folders can rename docs in place but
can't reorganise the tree. Admin + sales_manager get the perm by
default; sales_rep + viewer don't.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code-review followups on 5bed62d. Adds the missing .references()
on documents.folder_id (lazy AnyPgColumn form to forward-reference
documentFolders, which is declared later in the same file) so a
future db:generate run doesn't silently drift the schema. Adds
documentFoldersRelations and a folder leg on documentsRelations so
Task 2's service layer can use Drizzle's relational query API for
parent/children/documents traversal. Inline WHY comment on the
parentId column.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a per-port folder tree (self-FK on parent_id, unlimited depth)
plus a nullable folder_id on documents (null = root). Sibling-name
uniqueness enforced via a unique index on (port_id, COALESCE(parent_id,
'__root__'), LOWER(name)) so two folders can't share a name inside
the same parent. ON DELETE SET NULL on documents.folder_id and ON
DELETE NO ACTION on the parent self-FK so a botched delete never
silently destroys data — the service layer implements soft-rescue
(bubble children up to parent) instead.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Captures the agreed cutover plan (Q6 in the decisions log: double-write
transition window, ~30 days, then NocoDB decommission). The CRM side
is wired today — public berth feed, website-inquiries intake, dual-mode
health probe, WEBSITE_INTAKE_SECRET env var. The runbook documents the
website-repo checklist and rollback path so we can pick it back up
when prep for prod begins.
Refreshes the audit-followups status snapshot to reflect what shipped
this session. Wave 11 is now broken out into A-G subitems so the
remaining group-discussion work is enumerated rather than collapsed.
Note: .env.example separately needs WEBSITE_INTAKE_SECRET added (see
runbook §Endpoints). The husky pre-commit hook blocks .env* files
intentionally — pass via a separate workflow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Berth detail page now has two tabs:
- Spec: the existing versioned berth-spec PDF surface (current panel,
version history, parser badge).
- Deal Documents: NEW. Lists EOIs / contracts / etc. attached to
interests currently linked to this berth via interest_berths.
New service helper listDealDocumentsForBerth joins documents →
interests → interest_berths with a port_id guard on both sides.
GET /api/v1/berths/[id]/deal-documents wraps it, gated on berths.view.
Read-only — title, type, status badge, and an Open link to the source
interest page. Edits / sends still happen on the interest's own page.
The Spec tab paragraph now points reps to the new Deal Documents tab
instead of telling them to navigate via Interests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When status moves to under_offer or sold, the dialog now surfaces an
interest selector below the reason textarea. Picking an interest
passes interestId on the PATCH, which the service uses to call
setPrimaryBerth — auto-creates a primary interest_berths row if not
present, demoting any prior primary in the same transaction so the
unique partial index never fires. Cross-port leakage is blocked inside
the existing interest-berths helper.
Reasons are now offered as quick-pick chips above the textarea,
sourced from the new berth_status_change_reasons vocabulary
(Wave 5). Clicking a chip fills the textarea so reps stay on the
keyboard.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New /admin/vocabularies route + VocabulariesManager component. Catalog
at src/lib/vocabularies.ts defines 11 vocabularies grouped into
Interests / Berths / Expenses / Documents domains, each shipping with
the canonical defaults from src/lib/constants.ts (interest temps,
status-change reasons, tenure types, expense categories, document
types, plus the 5 berth-spec dropdowns).
Editor supports add / remove / reorder / inline-rename / reset-to-
defaults; only dirty cards save. Uses the existing
/api/v1/admin/settings PUT endpoint (already gated on
admin.manage_settings) so storage piggybacks on system_settings
(port_id, key) per the established pattern.
Reps need read access without holding manage_settings — added a
public-read /api/v1/vocabularies endpoint plus useVocabulary() hook
(5-minute staleTime). The admin manager invalidates the vocabularies
query on save so consumers (status-change dialog, expense form, etc.)
pick up new lists immediately.
Adds a Vocabularies card to the admin landing page.
Follow-up sweep owed: actual consumers (interest-card temperature pill,
berth-tabs select dropdowns, expense form category list, etc.) still
read from the hardcoded constants.ts arrays. Wire them through
useVocabulary in a separate pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related cleanups for the user profile surface area:
(1) Add canonical first_name + last_name columns to user_profiles.
Migration 0049 backfills from display_name by splitting on the
first whitespace run; single-token names land as
(display_name, NULL) so we never throw away existing data.
Display name becomes an optional override (nicknames, vanity
formatting). /api/v1/me PATCH now accepts firstName/lastName,
and the user-settings form surfaces them as the primary inputs
with display name as a secondary "How your name appears" field.
(2) Remove the broken Notifications card from user-settings (it called
PATCH on an endpoint that has GET/PUT only and used a flat shape
vs the actual array shape). Replace with the working
NotificationPreferencesForm + ReminderDigestForm under a
#notifications anchor. /notifications/preferences becomes a
server-side redirect to /settings#notifications for back-compat;
the mobile More-sheet + user-menu Bell entry now deep-link to the
new anchor directly.
Drops the auto-generated drizzle-kit catch-up migration so we're not
sneaking accumulated schema drift into the journal — only the targeted
0049 lands here.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the HTML5 datalist Input with a Popover + cmdk Combobox
modeled after CountryCombobox. Free-text on first entry via the
"Create '<typed value>'" item; past labels grouped under "Past trips"
with a check-mark indicating the current selection. Reuses the
existing /api/v1/expenses/trip-labels endpoint (distinct values for
the port, ordered by most-recent expense date) — no new schema or
service work.
Drops useQuery from expense-form-dialog since the combobox now owns
its own data fetch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends the listForClientAggregated pattern to three new symmetric
helpers in notes.service so the Notes tab on yacht / company /
residential-client detail pages surfaces the full timeline (own notes
+ related-entity notes) instead of just rows on the entity itself.
- listForYachtAggregated: yacht own + owner client (when ownership
is polymorphic 'client') + linked interest notes.
- listForCompanyAggregated: company own + company-owned yacht notes
+ interests linked to those yachts.
- listForResidentialClientAggregated: own + residential interests.
Generalises NotesList so aggregate=true works for all four entity
types via SELF_SOURCE / AGGREGATABLE / SOURCE_BADGE_CLASS / SOURCE_LABEL
maps; cross-source notes render with a coloured chip and are read-only
(rep edits on the source entity's page so the right timeline records
the change).
Wires ?aggregate=true into the yacht / company / residential-client
notes routes; the yacht / company / residential-client tabs now pass
aggregate. Drops the legacy single-textarea spots on the companies
overview tab and the residential-interest "Initial brief" row in
favour of the threaded feed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Builds on the centralised formatter shipped in ee2da8f. Replaces
\`\${currency} \${amount}\` style concatenations across the dashboard
revenue tooltip, command-search invoice/expense fallback labels,
expense-duplicate banner, and the invoice + expense PDF templates.
Drops the duplicate \`currencySymbol\` helper inside expense-pdf.service
in favour of the shared util; the two PDF helpers (renderReceiptHeader,
addReceiptErrorPage) now take a currency code instead of a pre-rendered
symbol so the formatter is the single source for spacing + thousands
separators. Also re-runs Prettier on the files where the prior commit
shipped without it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the open-questions section with a Decisions log capturing the
chosen direction for vocabularies admin, notification prefs, name
fields, public-feed parity, website cutover, status-change prospect
link, trip-label UX, documents folders, and the berth Documents tab
split. Quick-status snapshot updated to reflect that Waves 4-10 are
now ready to start.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New `src/lib/utils/currency.ts` is the single source of truth for
display formatting (`formatCurrency`) and the supported-currency
catalog (`SUPPORTED_CURRENCIES`, 10 codes covering the marina market).
New shared components:
- `<CurrencyInput>` — number input with leading symbol prefix and
decimal inputMode, raw number value out via onChange.
- `<CurrencySelect>` — Select dropdown over `SUPPORTED_CURRENCIES`
with symbol + code + label per row, replaces the free-text 3-letter
inputs that let reps type "EURO" or "$$$" into a 3-char ISO column.
Threaded through every money input + display:
- Forms: berth (price/currency), expense (amount/currency), invoice
(currency Select + line-items unit-price + step-3 review totals).
- Reads: berth-card / berth-columns / invoice-card / expense-card /
dashboard KPIs / dashboard revenue-forecast / portal-invoices page.
Each had its own ad-hoc `Intl.NumberFormat` wrapper with slightly
different fallbacks; collapsed onto the shared helper.
`InvoiceLineItems` gained a `currency` prop so the unit-price input
prefix and the subtotal use the parent invoice's currency rather than
hard-coded `en-US` formatting.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bring the public berth feed to verbatim NocoDB parity (all fields
except Price, which is held pending an explicit policy decision per
the audit follow-ups Q4). Adds:
- Berth Approved (boolean)
- Water Depth (number)
- Width Is Minimum / Water Depth Is Minimum (boolean)
- Length / Width / Draft / Water Depth / Nominal Boat Size (Metric)
- CreatedAt / UpdatedAt (ISO strings, useful for cache invalidation)
Booleans pass through as nullable to preserve NocoDB's tri-state
checkbox semantics (true / false / unset). Test fixtures cover the
new fields end-to-end including the null-passthrough case.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The route is dual-mode — anonymous probes get a minimal `{status,
timestamp}` (so uptime monitors that can't carry the secret keep
working and never 503 on the platform), authenticated probes
(timing-safe X-Intake-Secret match against WEBSITE_INTAKE_SECRET) get
the full `{status, env, appUrl, timestamp, checks}` envelope and a
503 on hard dependency failures. Old doc only described the second
shape and didn't mention the secret gate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
company_notes was missing updated_at — every other notes table has it,
and notes.service.ts substituted created_at into the response shape so
callers wouldn't notice. Add the column (defaulted + backfilled to
created_at for existing rows), wire the update path to set it on
edit, and drop the substitution from the read + edit handlers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pre-commit hook reformatted these files after the substantive commits.
No semantic changes — markdown table alignment, list indentation, and
emphasis style normalisation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Single source of truth for the 2026-05-08 visual-audit work. Owns
status of each item, file pointers, every open question, and a
ready-to-paste prompt for resuming in a fresh session. Items grouped
by wave (the original triage buckets, kept stable across sessions).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Remove the "Total Clients / Active Interests / Pipeline Value /
Occupancy Rate" KPI grid from the dashboard — duplicated by the
charts below and rarely scanned. Pipeline funnel, occupancy timeline,
revenue breakdown, lead source charts and the activity feed remain.
Rename the company-members dropdown action "End Membership" →
"Remove from company" — matches how reps describe the action.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mobile UX:
- Hide ColumnPicker on `< sm` viewports (cards, no columns to toggle).
- Hide kanban toggle in interest list on mobile and snap viewMode back
to 'table' if the persisted choice was 'board'.
- Drop dead "Inbox" link from the More-sheet (email/IMAP feature is
deferred per sidebar.tsx note).
- Repoint Notifications nav from `/notifications` (no page.tsx — 404)
to `/notifications/preferences` and re-label as "Notification
preferences" (the bell stays the surface for actual notifications).
- Hide Website Analytics on both desktop sidebar and mobile More-sheet
when Umami isn't configured for the port (`useUmamiActive()`).
Interests:
- New `<StageLegend>` popover button in the filter row decodes the
card stripe colours to pipeline stage names, kept in sync with
`STAGE_DOT` automatically.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Client form: when nationality is picked and timezone empty, primary
IANA zone for the country is pre-filled (skips when user has chosen
a zone explicitly). When a contact's preferred channel is `'other'`,
the inline `Label` field flips to "Specify" / "e.g. Telegram, Signal"
so the rep records what the channel actually is.
Yacht form: replace the free-text 2-letter flag input with the shared
`CountryCombobox` so flags stay valid ISO codes.
User settings: timezone pre-populates from
`Intl.DateTimeFormat().resolvedOptions().timeZone` on first load
(was empty before); country change auto-fills timezone with the same
helper as the client form. Phone field upgraded to the shared
`<PhoneInput>` (country-flag dropdown + AsYouType formatter) seeded
from the page's country state.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Radix Popover swallowed wheel events on cmdk-backed comboboxes for
macOS users — the country / timezone dropdowns were unscrollable with
a Magic Mouse / trackpad. Add an `onWheel` translator on `CommandList`
plus `overscroll-contain` so the list captures the delta directly.
Lights up every cmdk popover (Companies, Residential Clients, Clients,
Yachts, settings).
Country and Timezone comboboxes now constrain popover content to
`w-[var(--radix-popper-anchor-width)]` with sensible `min-w-*` floors
so wide triggers get correspondingly wide popovers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pull verbatim SingleSelect choices from NocoDB Berths via MCP and lock
them into BERTH_*_OPTIONS / _TYPES in lib/constants.ts: Side Pontoon
(10 values), Mooring Type (5), Cleat Type (2), Cleat Capacity (2),
Bollard Type (2), Bollard Capacity (2), Access (5), Area (A–E), Bow
Facing (4-value UX-only constraint over a SingleLineText). Power
Capacity / Voltage stay numeric inputs (NocoDB stores Number).
Add `toSelectOptions()` mapper for shadcn `<Select>` `{value,label}`
pairs.
Wire every berth dropdown — both the modal form and the inline-edit
detail tabs — to `<Select>`. Inline `EditableSpec` gains
`selectOptions` for the variant and `linkedUnit { field, multiplier }`
to auto-patch the metric column on save (× 0.3048 for ft→m on length,
width, draft, nominal boat size, water depth).
Promote nominal boat size + tenure type from read-only `<SpecRow>` to
`<EditableSpec>` so reps can edit them. Tenure type currently uses the
validator's `'permanent' | 'fixed_term'` set; will swap to per-port
configurable list once Vocabularies admin lands (Wave 5).
Mobile berth cards: replace status-coloured stripe with
`mooringLetterDot()` so it groups by dock letter; status conveyed by
the existing pill below. Berth detail header: "{Letter} Dock" chip
instead of bare "A" / "B" text. Berth area filter: `<Select>` over
A/B/C/D/E (renamed to "Dock"). Documents tab gets a one-paragraph
explainer disambiguating the spec PDF from deal documents (Interests
tab).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drizzle SQL template was running `\d+$` through JS string-literal
escape rules, eating the backslash and matching every character class
\d alias instead of digits. Berths sorted lexically (A1, A10, A11,
A2, …) instead of by trailing number. Switch to `[0-9]+$` POSIX form
which survives the escape pass intact.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>