From 221ae5784e36c131db86ff0731914b62ae58a45a Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 23 May 2026 00:52:59 +0200 Subject: [PATCH] chore(autonomous-session): consolidate uncommitted work from prior session Bundles the prior autonomous-session output that was sitting unstaged: - Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances) - country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk after the per-subpath dynamic-import approach silently failed in webpack) - Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index, redirects (ocr to ai, reports to dashboard, invitations to users), docs/admin-ia-proposal.md - Per-template email tester (registry + endpoint + UI on Email admin page) - Cancel-document mode picker (delete-from-Documenso vs keep-for-audit) - Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers - Customize-widgets per-region sortables at xl+ (charts/rails/feed); single flat sortable below xl when the layout stacks; per-viewport saved orders - Audit doc updates capturing each shipped item - Lint fixes: react-compiler immutability in DonutChart (reduce instead of let-reassign), set-state-in-effect disables in CountryFlag and UploadForSigning preview-bytes effect, unused 'confirm' destructures in interest contract + reservation tabs, unescaped apostrophe in test-template card copy --- docs/admin-ia-proposal.md | 415 ++++++++++++ docs/superpowers/audits/alpha-uat-master.md | 21 + package.json | 1 + pnpm-lock.yaml | 8 + src/app/(auth)/login/page.tsx | 4 +- src/app/(auth)/reset-password/page.tsx | 2 +- src/app/(auth)/set-password/page.tsx | 2 +- src/app/(auth)/setup/page.tsx | 6 +- .../(dashboard)/[portSlug]/admin/ai/page.tsx | 6 +- .../[portSlug]/admin/berths/page.tsx | 88 +++ .../[portSlug]/admin/branding/page.tsx | 2 +- .../[portSlug]/admin/brochures/page.tsx | 2 +- .../[portSlug]/admin/documenso/page.tsx | 8 +- .../[portSlug]/admin/email/page.tsx | 6 +- .../admin/errors/[requestId]/page.tsx | 12 +- .../[portSlug]/admin/errors/codes/page.tsx | 4 +- .../(dashboard)/[portSlug]/admin/ocr/page.tsx | 20 +- .../[portSlug]/admin/pipeline-rules/page.tsx | 2 +- .../[portSlug]/admin/reports/page.tsx | 22 +- .../admin/residential-stages/page.tsx | 2 +- .../admin/templates/[id]/editor/page.tsx | 2 +- .../[portSlug]/admin/users/page.tsx | 2 +- .../admin/website-analytics/page.tsx | 8 +- .../(dashboard)/[portSlug]/alerts/page.tsx | 2 +- .../[portSlug]/expenses/scan/page.tsx | 4 +- .../(dashboard)/[portSlug]/reminders/page.tsx | 2 +- .../[portSlug]/residential/page.tsx | 2 +- src/app/(dashboard)/layout.tsx | 6 +- src/app/(portal)/layout.tsx | 2 +- src/app/(portal)/portal/interests/page.tsx | 2 +- src/app/(portal)/portal/login/page.tsx | 2 +- src/app/api/auth/[...all]/route.ts | 2 +- src/app/api/auth/set-password/route.ts | 4 +- .../api/auth/sign-in-by-identifier/route.ts | 2 +- src/app/api/portal/auth/activate/route.ts | 2 +- .../api/portal/auth/forgot-password/route.ts | 2 +- .../api/portal/auth/reset-password/route.ts | 2 +- src/app/api/portal/auth/sign-in/route.ts | 2 +- src/app/api/public/berths/route.ts | 2 +- .../api/public/email-pixel/[sendId]/route.ts | 6 +- src/app/api/public/files/[id]/route.ts | 2 +- src/app/api/public/health/route.ts | 2 +- src/app/api/public/interests/route.ts | 2 +- .../public/supplemental-info/[token]/route.ts | 2 +- src/app/api/public/website-inquiries/route.ts | 2 +- src/app/api/ready/route.ts | 2 +- src/app/api/storage/[token]/route.ts | 10 +- src/app/api/v1/admin/audit/export/route.ts | 2 +- .../v1/admin/branding/email-preview/route.ts | 6 +- src/app/api/v1/admin/branding/logo/route.ts | 2 +- .../v1/admin/brochures/[id]/versions/route.ts | 6 +- .../v1/admin/custom-fields/[fieldId]/route.ts | 2 +- src/app/api/v1/admin/dashboard-stats/route.ts | 2 +- .../sync-template/[templateId]/route.ts | 4 +- .../documenso/sync-template/report/route.ts | 2 +- .../api/v1/admin/documenso/templates/route.ts | 2 +- src/app/api/v1/admin/email-templates/route.ts | 2 +- .../api/v1/admin/email/sales-config/route.ts | 4 +- .../email/sales-config/test-smtp/route.ts | 6 +- src/app/api/v1/admin/email/test-send/route.ts | 8 +- .../api/v1/admin/email/test-template/route.ts | 100 +++ .../v1/admin/embedded-signing/test/route.ts | 6 +- .../admin/error-events/[requestId]/route.ts | 2 +- src/app/api/v1/admin/roles/[id]/route.ts | 2 +- src/app/api/v1/admin/roles/route.ts | 2 +- .../v1/admin/settings/[key]/reveal/route.ts | 4 +- .../api/v1/admin/settings/resolved/route.ts | 6 +- src/app/api/v1/admin/storage/route.ts | 4 +- .../users/[id]/permission-overrides/route.ts | 10 +- src/app/api/v1/admin/users/picker/route.ts | 2 +- src/app/api/v1/ai/email-draft/route.ts | 2 +- src/app/api/v1/alerts/route.ts | 2 +- .../berths/[id]/interest-documents/route.ts | 2 +- .../v1/berths/[id]/pdf-upload-url/handlers.ts | 2 +- .../v1/berths/[id]/pdf-versions/handlers.ts | 2 +- src/app/api/v1/berths/bulk-add/route.ts | 2 +- .../api/v1/berths/bulk-update-prices/route.ts | 2 +- src/app/api/v1/berths/bulk/route.ts | 4 +- .../api/v1/berths/check-duplicates/route.ts | 2 +- src/app/api/v1/bootstrap/status/route.ts | 2 +- src/app/api/v1/bootstrap/super-admin/route.ts | 4 +- .../[contactId]/promote-to-primary/route.ts | 2 +- .../[id]/gdpr-export/[exportId]/route.ts | 2 +- .../clients/[id]/hard-delete-request/route.ts | 2 +- src/app/api/v1/clients/[id]/restore/route.ts | 4 +- .../clients/bulk-archive-preflight/route.ts | 4 +- src/app/api/v1/clients/bulk/route.ts | 2 +- .../v1/clients/match-candidates/handlers.ts | 2 +- src/app/api/v1/dashboard/forecast/route.ts | 2 +- src/app/api/v1/document-folders/[id]/route.ts | 4 +- src/app/api/v1/document-folders/route.ts | 2 +- .../[id]/detect-fields/route.ts | 8 +- .../document-templates/[id]/preview/route.ts | 6 +- .../[id]/source-pdf/route.ts | 2 +- src/app/api/v1/documents/[id]/cancel/route.ts | 12 +- .../[id]/download/[...slug]/handlers.ts | 2 +- src/app/api/v1/documents/[id]/folder/route.ts | 2 +- .../documents/[id]/send-invitation/route.ts | 14 +- .../v1/documents/auto-detect-fields/route.ts | 8 +- .../v1/documents/signing-defaults/route.ts | 8 +- src/app/api/v1/expenses/export/pdf/route.ts | 4 +- src/app/api/v1/expenses/scan-receipt/route.ts | 2 +- src/app/api/v1/expenses/trip-labels/route.ts | 2 +- src/app/api/v1/files/folders/route.ts | 2 +- .../[id]/berths/[berthId]/handlers.ts | 4 +- .../api/v1/interests/[id]/berths/handlers.ts | 2 +- .../v1/interests/[id]/eoi-context/route.ts | 2 +- .../api/v1/interests/[id]/payments/route.ts | 2 +- .../interests/[id]/recommend-berths/route.ts | 4 +- src/app/api/v1/interests/[id]/stage/route.ts | 2 +- .../[id]/supplemental-info-request/route.ts | 10 +- .../api/v1/interests/[id]/timeline/route.ts | 168 ++++- .../[id]/upload-for-signing/route.ts | 8 +- src/app/api/v1/interests/board/route.ts | 4 +- src/app/api/v1/interests/bulk/route.ts | 2 +- src/app/api/v1/internal/dev-flags/route.ts | 2 +- src/app/api/v1/internal/vitals/route.ts | 2 +- src/app/api/v1/me/avatar/route.ts | 6 +- .../api/v1/me/email/confirm/[token]/route.ts | 2 +- src/app/api/v1/me/email/route.ts | 6 +- src/app/api/v1/me/password-reset/route.ts | 2 +- src/app/api/v1/me/ports/route.ts | 4 +- src/app/api/v1/me/route.ts | 18 +- src/app/api/v1/reports/generate/route.ts | 11 +- .../api/v1/reports/templates/[id]/route.ts | 6 +- src/app/api/v1/reports/templates/route.ts | 2 +- .../v1/residential/interests/bulk/route.ts | 2 +- src/app/api/v1/saved-views/route.ts | 2 +- .../api/v1/search/recently-viewed/route.ts | 2 +- src/app/api/v1/search/route.ts | 6 +- src/app/api/v1/tracked-links/route.ts | 2 +- src/app/api/v1/vocabularies/route.ts | 2 +- src/app/api/v1/website-analytics/route.ts | 6 +- .../api/v1/yachts/[id]/field-history/route.ts | 2 +- src/app/api/webhooks/documenso/route.ts | 24 +- src/app/dashboard/page.tsx | 4 +- src/app/docs/deal-pulse/page.tsx | 4 +- src/app/globals.css | 8 +- src/app/layout.tsx | 2 +- src/app/page.tsx | 2 +- .../public/supplemental-info/[token]/page.tsx | 2 +- src/app/q/[slug]/route.ts | 6 +- .../admin/admin-sections-browser.tsx | 495 +++++++------- src/components/admin/audit/audit-log-list.tsx | 4 +- src/components/admin/backup-admin-panel.tsx | 2 +- .../admin/branding/email-preview-card.tsx | 20 +- .../admin/branding/pdf-logo-uploader.tsx | 4 +- .../admin/bulk-add-berths-wizard.tsx | 10 +- .../admin/documenso/embedded-signing-card.tsx | 2 +- .../admin/documenso/template-sync-button.tsx | 2 +- .../admin/email/smtp-test-send-card.tsx | 4 +- .../admin/email/test-template-card.tsx | 177 +++++ .../admin/forms/form-template-form.tsx | 4 +- src/components/admin/onboarding-checklist.tsx | 6 +- src/components/admin/ports/port-form.tsx | 22 +- .../admin/qualification-criteria-admin.tsx | 2 +- src/components/admin/reconcile-queue.tsx | 2 +- .../admin/sales-email-config-card.tsx | 4 +- src/components/admin/sends-log.tsx | 2 +- .../admin/shared/registry-driven-form.tsx | 8 +- .../admin/shared/settings-form-card.tsx | 12 +- .../admin/shared/template-token-picker.tsx | 4 +- src/components/admin/storage-admin-panel.tsx | 2 +- src/components/admin/tags/tag-form.tsx | 2 +- .../admin/templates/template-editor.tsx | 8 +- src/components/admin/users/user-form.tsx | 2 +- .../admin/users/user-permission-matrix.tsx | 2 +- src/components/alerts/alert-rail.tsx | 2 +- src/components/alerts/alerts-page-shell.tsx | 2 +- .../berths/active-interests-popover.tsx | 2 +- src/components/berths/berth-card.tsx | 8 +- src/components/berths/berth-columns.tsx | 10 +- src/components/berths/berth-detail-header.tsx | 8 +- src/components/berths/berth-detail.tsx | 2 +- src/components/berths/berth-documents-tab.tsx | 4 +- .../berths/berth-interest-pulse.tsx | 2 +- src/components/berths/berth-list.tsx | 14 +- src/components/berths/berth-tabs.tsx | 4 +- src/components/berths/catch-up-wizard.tsx | 6 +- src/components/berths/mooring-letter-tone.ts | 6 +- .../berths/pdf-reconcile-dialog.tsx | 6 +- .../clients/bulk-hard-delete-dialog.tsx | 2 +- src/components/clients/client-card.tsx | 26 +- .../clients/client-channel-editor.tsx | 6 +- src/components/clients/client-columns.tsx | 21 +- .../clients/client-detail-header.tsx | 19 +- src/components/clients/client-form.tsx | 16 +- src/components/clients/client-list.tsx | 4 +- .../clients/client-pipeline-summary.tsx | 6 +- src/components/clients/client-tabs.tsx | 2 +- src/components/clients/contacts-editor.tsx | 6 +- .../clients/dedup-suggestion-panel.tsx | 4 +- src/components/clients/hard-delete-dialog.tsx | 2 +- .../clients/send-documents-dialog.tsx | 2 +- .../clients/smart-archive-dialog.tsx | 2 +- src/components/companies/company-columns.tsx | 2 +- src/components/companies/company-form.tsx | 18 +- src/components/companies/company-list.tsx | 2 +- .../dashboard/active-deals-tile.tsx | 6 +- src/components/dashboard/activity-feed.tsx | 78 ++- .../dashboard/berth-heat-widget.tsx | 2 +- .../dashboard/clients-by-country-widget.tsx | 11 +- .../dashboard/customize-widgets-menu.tsx | 135 +++- src/components/dashboard/dashboard-shell.tsx | 12 +- src/components/dashboard/hot-deals-card.tsx | 2 +- .../dashboard/pipeline-value-tile.tsx | 2 +- .../dashboard/source-conversion-chart.tsx | 2 +- .../dashboard/timezone-drift-banner.tsx | 2 +- .../dashboard/website-glance-tile.tsx | 6 +- src/components/dashboard/widget-registry.tsx | 14 +- .../documents/cancel-document-dialog.tsx | 146 ++++ .../documents/create-document-wizard.tsx | 4 +- src/components/documents/document-detail.tsx | 6 +- src/components/documents/document-list.tsx | 2 +- src/components/documents/documents-hub.tsx | 10 +- .../documents/eoi-cancel-dialog.tsx | 50 +- .../documents/eoi-generate-dialog.tsx | 44 +- .../documents/external-eoi-edit-dialog.tsx | 8 +- .../documents/new-document-menu.tsx | 4 +- .../documents/signing-details-dialog.tsx | 2 +- src/components/documents/signing-progress.tsx | 54 +- .../documents/upload-for-signing-dialog.tsx | 117 ++-- .../email/tracked-link-composer-button.tsx | 6 +- src/components/expenses/expense-detail.tsx | 4 +- .../expenses/expense-form-dialog.tsx | 15 +- src/components/files/file-preview-dialog.tsx | 10 +- src/components/files/file-upload-zone.tsx | 4 +- src/components/files/pdf-viewer.tsx | 6 +- src/components/inbox/inbox-page-shell.tsx | 4 +- .../add-berth-to-interest-dialog.tsx | 2 +- .../interests/berth-recommender-panel.tsx | 8 +- src/components/interests/deal-pulse-chip.tsx | 2 +- .../interests/external-eoi-upload-dialog.tsx | 10 +- .../interests/inline-stage-picker.tsx | 18 +- .../interest-berth-status-banner.tsx | 6 +- src/components/interests/interest-columns.tsx | 6 +- .../interests/interest-contact-log-tab.tsx | 10 +- .../interests/interest-contract-tab.tsx | 52 +- .../interests/interest-detail-header.tsx | 8 +- src/components/interests/interest-detail.tsx | 6 +- .../interests/interest-documents-tab.tsx | 6 +- src/components/interests/interest-eoi-tab.tsx | 37 +- src/components/interests/interest-form.tsx | 14 +- src/components/interests/interest-list.tsx | 14 +- src/components/interests/interest-picker.tsx | 2 +- .../interests/interest-reservation-tab.tsx | 54 +- src/components/interests/interest-tabs.tsx | 46 +- .../interests/interest-timeline.tsx | 2 +- .../interests/linked-berths-list.tsx | 18 +- src/components/interests/multi-eoi-chip.tsx | 2 +- src/components/interests/payments-section.tsx | 2 +- src/components/interests/pipeline-board.tsx | 6 +- .../interests/qualification-checklist.tsx | 4 +- .../interests/skip-ahead-banner.tsx | 2 +- .../interests/stage-guidance-card.tsx | 6 +- .../supplemental-info-request-button.tsx | 10 +- src/components/layout/app-shell.tsx | 8 +- .../layout/mobile/mobile-bottom-tabs.tsx | 6 +- .../layout/mobile/mobile-layout.tsx | 2 +- .../layout/mobile/mobile-topbar.tsx | 4 +- src/components/layout/mobile/more-sheet.tsx | 29 +- src/components/layout/sidebar.tsx | 10 +- src/components/layout/topbar.tsx | 44 +- src/components/layout/user-menu.tsx | 6 +- .../notifications/notification-bell.tsx | 2 +- src/components/portal/password-set-form.tsx | 6 +- src/components/reminders/reminder-form.tsx | 32 +- src/components/reminders/reminders-inline.tsx | 6 +- .../reports/export-dashboard-pdf-button.tsx | 188 +++++- .../reports/generate-report-form.tsx | 2 +- src/components/reports/pdf-preview-modal.tsx | 138 +++- src/components/reports/reports-list.tsx | 2 +- .../reports/saved-templates-picker.tsx | 2 +- .../residential-client-detail-header.tsx | 4 +- .../residential/residential-client-detail.tsx | 2 +- .../residential/residential-interest-card.tsx | 4 +- .../residential-interest-columns.tsx | 10 +- .../residential-interest-filters.tsx | 6 +- .../residential-interests-list.tsx | 12 +- src/components/scan/scan-shell.tsx | 12 +- src/components/search/command-search.tsx | 28 +- src/components/search/highlight-match.tsx | 2 +- .../search/mobile-search-overlay.tsx | 22 +- src/components/search/track-entity-view.tsx | 2 +- src/components/settings/user-settings.tsx | 14 +- src/components/shared/addresses-editor.tsx | 19 +- src/components/shared/berth-picker.tsx | 6 +- src/components/shared/branded-auth-shell.tsx | 6 +- src/components/shared/client-picker.tsx | 16 +- src/components/shared/column-picker.tsx | 8 +- src/components/shared/country-combobox.tsx | 23 +- src/components/shared/country-flag.tsx | 117 ++++ src/components/shared/data-table.tsx | 18 +- src/components/shared/detail-not-found.tsx | 2 +- src/components/shared/dev-mode-banner.tsx | 4 +- src/components/shared/drawer.tsx | 2 +- .../shared/entity-activity-feed.tsx | 8 +- src/components/shared/field-history.tsx | 4 +- src/components/shared/filter-bar.tsx | 4 +- .../shared/image-cropper-dialog.tsx | 6 +- .../shared/inline-country-field.tsx | 27 +- .../shared/inline-editable-field.tsx | 2 +- src/components/shared/inline-tag-editor.tsx | 6 +- src/components/shared/interest-picker.tsx | 4 +- src/components/shared/notes-list.tsx | 12 +- src/components/shared/owner-picker.tsx | 2 +- src/components/shared/realtime-toasts.tsx | 2 +- src/components/shared/save-view-dialog.tsx | 2 +- .../shared/saved-views-dropdown.tsx | 2 +- .../shared/send-document-dialog.tsx | 10 +- src/components/shared/tag-picker.tsx | 2 +- src/components/shared/user-picker.tsx | 2 +- src/components/shared/web-vitals-reporter.tsx | 4 +- src/components/ui/alert-dialog.tsx | 2 +- src/components/ui/calendar.tsx | 2 +- src/components/ui/command.tsx | 2 +- src/components/ui/date-picker.tsx | 8 +- src/components/ui/date-time-picker.tsx | 2 +- src/components/ui/dropdown-menu.tsx | 2 +- src/components/ui/field-error.tsx | 4 +- src/components/ui/field-label.tsx | 2 +- src/components/ui/file-input-button.tsx | 4 +- src/components/ui/input.tsx | 2 +- src/components/ui/kpi-tile.tsx | 2 +- src/components/ui/select.tsx | 2 +- src/components/ui/sonner.tsx | 2 +- src/components/ui/table.tsx | 2 +- .../website-analytics/pageviews-chart.tsx | 6 +- .../website-analytics/realtime-panel.tsx | 24 +- .../session-detail-sheet.tsx | 16 +- .../website-analytics/sessions-list.tsx | 6 +- src/components/website-analytics/top-list.tsx | 2 +- .../use-website-analytics.ts | 8 +- .../website-analytics/visitor-world-map.tsx | 2 +- .../website-analytics-shell.tsx | 8 +- src/components/yachts/yacht-detail-header.tsx | 4 +- src/components/yachts/yacht-detail.tsx | 2 +- src/components/yachts/yacht-form.tsx | 6 +- src/components/yachts/yacht-tabs.tsx | 4 +- src/hooks/use-breadcrumb-hint.ts | 2 +- src/hooks/use-confirmation.tsx | 4 +- src/hooks/use-create-from-url.ts | 2 +- src/hooks/use-dashboard-integrations.ts | 8 +- src/hooks/use-dashboard-widgets.ts | 98 ++- src/hooks/use-form-scroll-to-error.ts | 8 +- src/hooks/use-is-mobile.ts | 4 +- src/hooks/use-paginated-query.ts | 2 +- src/hooks/use-search.ts | 6 +- src/hooks/use-table-preferences.ts | 6 +- src/hooks/use-track-entity-view.ts | 2 +- src/hooks/use-vocabulary.ts | 2 +- src/hooks/use-voice-transcription.ts | 8 +- src/i18n/request.ts | 2 +- src/jobs/processors/imap-bounce-poller.ts | 20 +- src/lib/analytics/range.ts | 4 +- src/lib/api/client.ts | 8 +- src/lib/api/helpers.ts | 10 +- src/lib/api/route-helpers.ts | 4 +- src/lib/api/toast-error.ts | 2 +- src/lib/audit.ts | 8 +- src/lib/auth/index.ts | 6 +- src/lib/branding/url.ts | 4 +- src/lib/constants.ts | 14 +- src/lib/constants/file-validation.ts | 4 +- src/lib/db/index.ts | 4 +- ..._sends_preserve_audit_on_parent_delete.sql | 2 +- .../db/migrations/0039_expense_trip_label.sql | 2 +- .../0041_role_permissions_edit_keys.sql | 4 +- .../0042_missing_fk_constraints.sql | 4 +- .../0043_client_archive_metadata.sql | 2 +- ...047_system_settings_nulls_not_distinct.sql | 2 +- .../migrations/0051_documents_hub_split.sql | 4 +- .../migrations/0052_audit_critical_fixes.sql | 4 +- .../db/migrations/0053_measurement_units.sql | 4 +- .../0054_user_profiles_username.sql | 2 +- .../0055_user_permission_overrides.sql | 2 +- .../db/migrations/0056_audit_hardening.sql | 2 +- .../db/migrations/0057_search_fts_indexes.sql | 2 +- .../0060_documents_invitation_message.sql | 2 +- .../db/migrations/0062_pipeline_refactor.sql | 4 +- .../db/migrations/0065_predeploy_schema.sql | 6 +- .../0066_normalize_legacy_status_override.sql | 2 +- .../0069_interest_notes_pipeline_stage.sql | 2 +- .../db/migrations/0070_h01_fk_on_delete.sql | 2 +- .../0071_pg_trgm_search_indexes.sql | 2 +- .../db/migrations/0072_phase4_reminders.sql | 10 +- .../migrations/0073_phase3_eoi_overrides.sql | 8 +- .../0074_phase6_bounce_tracking.sql | 2 +- ...075_c2_document_events_recipient_email.sql | 2 +- .../migrations/0076_email_open_tracking.sql | 2 +- src/lib/db/migrations/0077_tracked_links.sql | 2 +- .../db/migrations/0078_files_interest_id.sql | 2 +- .../0081_interest_field_history.sql | 4 +- .../migrations/0082_berth_mooring_unique.sql | 2 +- src/lib/db/schema/berths.ts | 8 +- src/lib/db/schema/brochures.ts | 18 +- src/lib/db/schema/clients.ts | 6 +- src/lib/db/schema/documents.ts | 26 +- src/lib/db/schema/financial.ts | 6 +- src/lib/db/schema/index.ts | 2 +- src/lib/db/schema/interest-field-history.ts | 4 +- src/lib/db/schema/interests.ts | 6 +- src/lib/db/schema/operations.ts | 12 +- src/lib/db/schema/pipeline.ts | 6 +- src/lib/db/schema/reports.ts | 2 +- src/lib/db/schema/reservations.ts | 2 +- src/lib/db/schema/residential.ts | 4 +- src/lib/db/schema/supplemental-forms.ts | 2 +- src/lib/db/schema/system.ts | 16 +- src/lib/db/schema/tracked-links.ts | 6 +- src/lib/db/schema/users.ts | 35 +- src/lib/db/schema/website-submissions.ts | 2 +- src/lib/db/schema/yachts.ts | 2 +- src/lib/db/seed-bootstrap.ts | 6 +- src/lib/db/seed-permissions.ts | 2 +- src/lib/db/seed-synthetic-data.ts | 8 +- src/lib/db/seed-synthetic.ts | 2 +- src/lib/db/seed-wide-synthetic-data.ts | 6 +- src/lib/db/seed-wide-synthetic.ts | 2 +- src/lib/db/utils.ts | 2 +- src/lib/email/auth-shell-branding.ts | 2 +- src/lib/email/bounce-parser.ts | 8 +- src/lib/email/branding-resolver.ts | 2 +- src/lib/email/index.ts | 6 +- src/lib/email/resolve-subject.ts | 2 +- src/lib/email/shell.ts | 14 +- src/lib/email/template-catalog.ts | 26 +- src/lib/email/template-overrides.ts | 2 +- src/lib/email/templates/crm-invite.tsx | 4 +- src/lib/email/templates/document-signing.tsx | 24 +- .../templates/inquiry-sales-notification.tsx | 10 +- .../email/templates/notification-digest.tsx | 6 +- src/lib/email/templates/portal-auth.tsx | 16 +- .../email/templates/residential-inquiry.tsx | 2 +- src/lib/email/test-registry.ts | 247 +++++++ src/lib/email/tracking-pixel.ts | 2 +- src/lib/env.ts | 16 +- src/lib/error-classifier.ts | 10 +- src/lib/error-codes.ts | 14 +- src/lib/errors.ts | 10 +- src/lib/fetch-with-timeout.ts | 4 +- src/lib/labels/document-status.ts | 6 +- src/lib/logger.ts | 2 +- src/lib/minio/index.ts | 2 +- src/lib/pdf/brand-kit/DataTable.tsx | 2 +- src/lib/pdf/brand-kit/KeyValueGrid.tsx | 2 +- src/lib/pdf/brand-kit/charts/LineChart.tsx | 2 +- src/lib/pdf/fill-eoi-form.ts | 12 +- src/lib/pdf/inspect-acroform.ts | 6 +- src/lib/pdf/reports/berth-list-report.tsx | 4 +- src/lib/pdf/reports/branded-document.tsx | 2 +- src/lib/pdf/reports/charts.tsx | 329 +++++++++ src/lib/pdf/reports/client-list-report.tsx | 7 +- src/lib/pdf/reports/dashboard-report.tsx | 582 +++++++++++++++- src/lib/pdf/reports/interest-list-report.tsx | 4 +- src/lib/pdf/reports/render-report.ts | 12 +- src/lib/pdf/reports/report-table.tsx | 9 +- src/lib/pdf/reports/styles.ts | 2 +- src/lib/pdf/reports/types.ts | 4 +- src/lib/pdf/templates/berth-spec.tsx | 44 +- src/lib/pdf/templates/client-summary.tsx | 18 +- src/lib/pdf/templates/interest-summary.tsx | 38 +- .../pdf/templates/parent-company-expense.tsx | 4 +- .../pdf/templates/reports/activity-report.tsx | 8 +- .../templates/reports/occupancy-report.tsx | 2 +- .../pdf/templates/reports/pipeline-report.tsx | 4 +- .../pdf/templates/reports/revenue-report.tsx | 2 +- src/lib/portal/auth.ts | 2 +- src/lib/queue/audit-helpers.ts | 6 +- src/lib/queue/scheduler.ts | 12 +- src/lib/queue/workers/ai.ts | 8 +- src/lib/queue/workers/bulk.ts | 4 +- src/lib/queue/workers/documents.ts | 2 +- src/lib/queue/workers/import.ts | 6 +- src/lib/queue/workers/maintenance.ts | 4 +- src/lib/queue/workers/notifications.ts | 2 +- src/lib/queue/workers/reports.ts | 2 +- src/lib/queue/workers/webhooks.ts | 2 +- src/lib/rate-limit.ts | 2 +- src/lib/request-context.ts | 12 +- src/lib/services/active-interest.ts | 2 +- src/lib/services/ai-budget.service.ts | 2 +- src/lib/services/analytics.service.ts | 2 +- src/lib/services/audit.service.ts | 2 +- src/lib/services/backup.service.ts | 4 +- src/lib/services/berth-heat.service.ts | 2 +- src/lib/services/berth-pdf-parser.ts | 30 +- src/lib/services/berth-pdf.service.ts | 30 +- src/lib/services/berth-recommender.service.ts | 2 +- .../services/berth-reservations.service.ts | 2 +- src/lib/services/berth-rules-engine.ts | 6 +- src/lib/services/berths.service.ts | 24 +- src/lib/services/bootstrap.service.ts | 4 +- src/lib/services/brochures.service.ts | 6 +- .../client-archive-dossier.service.ts | 12 +- src/lib/services/client-archive.service.ts | 16 +- .../services/client-hard-delete.service.ts | 16 +- src/lib/services/client-merge.service.ts | 2 +- src/lib/services/client-restore.service.ts | 20 +- src/lib/services/clients.service.ts | 12 +- .../services/company-memberships.service.ts | 6 +- .../custom-document-upload.service.ts | 188 ++++-- .../services/dashboard-report-data.service.ts | 630 +++++++++++++++++- src/lib/services/dashboard-report-widgets.ts | 265 +++++++- src/lib/services/dashboard.service.ts | 131 +++- src/lib/services/deal-health.ts | 30 +- src/lib/services/documenso-client.ts | 132 ++-- src/lib/services/documenso-payload.ts | 24 +- src/lib/services/documenso-signers.ts | 2 +- .../documenso-template-sync.service.ts | 20 +- src/lib/services/documenso-webhook.ts | 2 +- src/lib/services/document-field-detector.ts | 34 +- src/lib/services/document-folders.service.ts | 34 +- src/lib/services/document-import.ts | 4 +- src/lib/services/document-reminders.ts | 16 +- src/lib/services/document-sends.service.ts | 40 +- .../document-signing-emails.service.ts | 24 +- src/lib/services/document-templates.ts | 10 +- src/lib/services/documents.service.ts | 115 ++-- src/lib/services/email-accounts.service.ts | 2 +- src/lib/services/email-compose.service.ts | 4 +- src/lib/services/email-routing.ts | 2 +- src/lib/services/entity-activity.service.ts | 6 +- src/lib/services/eoi-context.ts | 4 +- src/lib/services/eoi-overrides.service.ts | 18 +- src/lib/services/error-events.service.ts | 10 +- src/lib/services/expense-pdf.service.ts | 42 +- src/lib/services/expenses.ts | 2 +- src/lib/services/external-eoi.service.ts | 22 +- src/lib/services/external-signing.service.ts | 4 +- src/lib/services/files.ts | 8 +- src/lib/services/gdpr-bundle-builder.ts | 2 +- src/lib/services/gdpr-export.service.ts | 2 +- src/lib/services/image-normalize.ts | 4 +- .../services/inquiry-notifications.service.ts | 2 +- src/lib/services/interest-berths.service.ts | 10 +- .../services/interest-contact-log.service.ts | 6 +- src/lib/services/interest-scoring.service.ts | 4 +- src/lib/services/interests.service.ts | 64 +- src/lib/services/invoices.ts | 6 +- src/lib/services/list-report-data.service.ts | 2 +- .../services/next-in-line-notify.service.ts | 10 +- src/lib/services/notes.service.ts | 10 +- .../services/notification-digest.service.ts | 6 +- src/lib/services/notifications.service.ts | 4 +- src/lib/services/ocr-providers.ts | 2 +- src/lib/services/payments.service.ts | 6 +- src/lib/services/port-config.ts | 24 +- src/lib/services/portal-auth.service.ts | 6 +- src/lib/services/portal.service.ts | 6 +- src/lib/services/ports.service.ts | 4 +- src/lib/services/public-interest.service.ts | 10 +- src/lib/services/qualification.service.ts | 20 +- src/lib/services/recently-viewed.service.ts | 2 +- src/lib/services/reminders.service.ts | 4 +- src/lib/services/report-generators.ts | 10 +- src/lib/services/report-math.ts | 2 +- src/lib/services/report-templates.service.ts | 2 +- .../services/residential-stages.service.ts | 10 +- src/lib/services/residential.service.ts | 8 +- .../services/sales-email-config.service.ts | 12 +- src/lib/services/search-nav-catalog.ts | 34 +- src/lib/services/search.service.ts | 66 +- src/lib/services/settings.service.ts | 4 +- .../services/supplemental-forms.service.ts | 14 +- src/lib/services/system-monitoring.service.ts | 2 +- src/lib/services/tracked-links.service.ts | 4 +- src/lib/services/umami.service.ts | 14 +- src/lib/services/users.service.ts | 14 +- src/lib/services/webhook-dispatch.ts | 2 +- src/lib/services/webhooks.service.ts | 6 +- src/lib/services/yachts.service.ts | 2 +- src/lib/settings/registry.ts | 62 +- src/lib/settings/resolver.ts | 10 +- src/lib/settings/types.ts | 8 +- src/lib/socket/events.ts | 2 +- src/lib/socket/server.ts | 4 +- src/lib/storage/filesystem.ts | 22 +- src/lib/storage/index.ts | 22 +- src/lib/storage/migrate.ts | 8 +- src/lib/storage/s3.ts | 10 +- src/lib/templates/berth-range.ts | 2 +- src/lib/templates/bindable-fields.ts | 16 +- src/lib/templates/field-map.ts | 12 +- src/lib/templates/merge-fields.ts | 2 +- src/lib/utils/currency.ts | 4 +- src/lib/utils/download.ts | 4 +- src/lib/utils/format-date.ts | 16 +- src/lib/utils/markdown-email.ts | 20 +- src/lib/validators/berths.ts | 2 +- src/lib/validators/brochures.ts | 4 +- src/lib/validators/document-templates.ts | 10 +- src/lib/validators/documents.ts | 2 +- src/lib/validators/expenses.ts | 2 +- src/lib/validators/files.ts | 2 +- src/lib/validators/interests.ts | 2 +- src/lib/validators/invoices.ts | 2 +- src/lib/validators/qualification.ts | 6 +- src/lib/validators/residential.ts | 4 +- src/lib/validators/search.ts | 4 +- src/lib/validators/tags.ts | 2 +- src/lib/validators/user-preferences.ts | 13 +- src/lib/validators/username.ts | 2 +- src/lib/validators/webhooks.ts | 2 +- src/lib/validators/yachts.ts | 2 +- src/lib/vocabularies.ts | 6 +- src/providers/port-provider.tsx | 4 +- src/providers/socket-provider.tsx | 4 +- src/proxy.ts | 8 +- src/server.ts | 2 +- src/types/ts-reset.d.ts | 2 +- tests/e2e/audit/mobile.spec.ts | 10 +- .../e2e/destructive/01-yacht-archive.spec.ts | 6 +- tests/e2e/exhaustive/01-yachts.spec.ts | 6 +- tests/e2e/exhaustive/02-companies.spec.ts | 2 +- tests/e2e/exhaustive/03-reservations.spec.ts | 4 +- tests/e2e/exhaustive/04-client-detail.spec.ts | 4 +- tests/e2e/exhaustive/07-berths.spec.ts | 2 +- tests/e2e/exhaustive/08-portal.spec.ts | 2 +- .../e2e/realapi/alert-engine-realtime.spec.ts | 2 +- tests/e2e/realapi/documenso-cancel.spec.ts | 2 +- tests/e2e/realapi/documenso-real-api.spec.ts | 2 +- .../email-attachments-roundtrip.spec.ts | 2 +- .../e2e/realapi/minio-file-lifecycle.spec.ts | 2 +- .../realapi/portal-imap-activation.spec.ts | 4 +- tests/e2e/realapi/receipt-ocr.spec.ts | 6 +- tests/e2e/realapi/smtp-system-send.spec.ts | 4 +- tests/e2e/smoke/01-auth.spec.ts | 25 +- tests/e2e/smoke/03-pipeline.spec.ts | 8 +- .../smoke/04-documents-hub-aggregated.spec.ts | 14 +- ...4-documents-hub-upload-into-entity.spec.ts | 24 +- tests/e2e/smoke/07-error-handling.spec.ts | 32 +- tests/e2e/smoke/11-global-search.spec.ts | 2 +- tests/e2e/smoke/12-notifications.spec.ts | 4 +- tests/e2e/smoke/14-webhooks.spec.ts | 2 +- tests/e2e/smoke/15-custom-fields.spec.ts | 4 +- tests/e2e/smoke/17-client-portal.spec.ts | 4 +- tests/e2e/smoke/18-ai-features.spec.ts | 4 +- tests/e2e/smoke/19-system-monitoring.spec.ts | 6 +- tests/e2e/smoke/20-accessibility.spec.ts | 8 +- ...20-critical-path-client-to-invoice.spec.ts | 2 +- tests/e2e/smoke/21-role-based-ui.spec.ts | 14 +- tests/e2e/smoke/22-error-recovery.spec.ts | 87 ++- tests/e2e/smoke/23-portal-flow.spec.ts | 18 +- tests/e2e/smoke/25-security-api.spec.ts | 50 +- tests/e2e/smoke/26-residential.spec.ts | 6 +- .../e2e/smoke/28-berth-interests-tab.spec.ts | 4 +- tests/e2e/smoke/31-i18n-form-fields.spec.ts | 4 +- tests/e2e/smoke/global-setup.ts | 4 +- tests/e2e/visual/snapshots.spec.ts | 8 +- tests/global-setup.ts | 4 +- tests/helpers/click-everything.ts | 4 +- tests/helpers/factories.ts | 14 +- tests/integration/ai-budget.test.ts | 2 +- tests/integration/alerts-engine.test.ts | 6 +- .../alerts-tenant-isolation.test.ts | 2 +- tests/integration/analytics-service.test.ts | 4 +- .../api/berth-reservations-list.test.ts | 2 +- tests/integration/api/companies.test.ts | 2 +- tests/integration/api/memberships.test.ts | 4 +- tests/integration/api/reservations.test.ts | 2 +- .../api/saved-views-ownership.test.ts | 4 +- tests/integration/api/yachts-detail.test.ts | 4 +- tests/integration/api/yachts.test.ts | 2 +- tests/integration/audit-search.test.ts | 2 +- .../backfill-document-folders.test.ts | 12 +- tests/integration/berth-pdf-versions.test.ts | 6 +- ...client-relationship-port-isolation.test.ts | 4 +- .../crm-invite-super-admin-gate.test.ts | 2 +- tests/integration/crud-audit.test.ts | 8 +- tests/integration/custom-fields.test.ts | 16 +- tests/integration/dedup/client-merge.test.ts | 2 +- .../dedup/match-candidates-api.test.ts | 6 +- .../integration/document-folders-crud.test.ts | 6 +- .../document-templates-eoi.test.ts | 10 +- ...cument-templates-generate-and-sign.test.ts | 4 +- .../documents-completion-auto-deposit.test.ts | 8 +- .../documents-expired-webhook.test.ts | 8 +- .../documents-hub-eoi-queue.test.ts | 6 +- .../documents-list-folder-filter.test.ts | 2 +- tests/integration/expense-dedup.test.ts | 2 +- .../files-folder-aggregation.test.ts | 10 +- tests/integration/gdpr-export.test.ts | 4 +- .../interests-port-fk-isolation.test.ts | 2 +- tests/integration/interests-yacht.test.ts | 2 +- .../invoices-billing-entity.test.ts | 2 +- tests/integration/maintenance-cleanup.test.ts | 2 +- .../notification-lifecycle.test.ts | 4 +- tests/integration/ocr-config.test.ts | 4 +- tests/integration/permission-matrix.test.ts | 12 +- .../integration/pipeline-transitions.test.ts | 2 +- tests/integration/port-scoping.test.ts | 8 +- tests/integration/portal-auth.test.ts | 6 +- .../integration/public-interest-trio.test.ts | 6 +- .../public-residential-inquiry.test.ts | 6 +- .../recommendations-yacht-dims.test.ts | 2 +- tests/integration/schema-constraints.test.ts | 6 +- tests/integration/webhook-delivery.test.ts | 4 +- tests/unit/aggregated-projection.test.ts | 8 +- tests/unit/audit.test.ts | 2 +- tests/unit/comms-safety.test.ts | 24 +- tests/unit/concurrent-operations.test.ts | 20 +- tests/unit/constants.test.ts | 2 +- tests/unit/custom-field-validation.test.ts | 16 +- tests/unit/dedup/find-matches.test.ts | 40 +- tests/unit/dedup/migration-transform.test.ts | 14 +- tests/unit/dedup/normalize.test.ts | 8 +- .../unit/document-folders-regression.test.ts | 14 +- .../document-folders-system-folders.test.ts | 12 +- .../unit/document-folders-validators.test.ts | 2 +- tests/unit/document-import.test.ts | 4 +- tests/unit/document-sends-validators.test.ts | 2 +- tests/unit/format-date.test.ts | 14 +- .../unit/hooks/realtime-invalidation.test.ts | 6 +- tests/unit/i18n-countries.test.ts | 2 +- tests/unit/i18n-phone.test.ts | 2 +- tests/unit/i18n-subdivisions.test.ts | 2 +- tests/unit/i18n-timezones.test.ts | 2 +- tests/unit/logo-service.test.ts | 4 +- .../unit/markdown-email-sanitization.test.ts | 8 +- tests/unit/pdf-report-renderer.test.ts | 4 +- tests/unit/pdf/fill-eoi-form.test.ts | 6 +- tests/unit/security-encryption.test.ts | 12 +- tests/unit/security-error-responses.test.ts | 8 +- .../unit/security-input-sanitization.test.ts | 12 +- tests/unit/security-permission-checks.test.ts | 15 +- tests/unit/security-sensitive-data.test.ts | 10 +- .../berth-recommender.test.ts.snap | 36 +- .../unit/services/berth-pdf-acroform.test.ts | 2 +- tests/unit/services/berth-pdf-parser.test.ts | 6 +- tests/unit/services/berth-recommender.test.ts | 8 +- .../unit/services/berth-reservations.test.ts | 8 +- tests/unit/services/companies.test.ts | 14 +- .../unit/services/company-memberships.test.ts | 12 +- tests/unit/services/documenso-payload.test.ts | 4 +- .../services/documenso-place-fields.test.ts | 31 +- tests/unit/services/documenso-signers.test.ts | 2 +- .../services/document-signing-urls.test.ts | 10 +- tests/unit/services/eoi-context.test.ts | 4 +- tests/unit/services/portal.test.ts | 17 +- tests/unit/services/report-math.test.ts | 2 +- tests/unit/services/search.test.ts | 12 +- tests/unit/services/yachts.test.ts | 12 +- tests/unit/storage/filesystem-backend.test.ts | 6 +- tests/unit/validators.test.ts | 2 +- .../validators/document-templates.test.ts | 2 +- tests/unit/webhook-ssrf-validator.test.ts | 4 +- tests/unit/website-inquiries-503.test.ts | 2 +- tests/unit/website-inquiries.test.ts | 8 +- 749 files changed, 7440 insertions(+), 3118 deletions(-) create mode 100644 docs/admin-ia-proposal.md create mode 100644 src/app/(dashboard)/[portSlug]/admin/berths/page.tsx create mode 100644 src/app/api/v1/admin/email/test-template/route.ts create mode 100644 src/components/admin/email/test-template-card.tsx create mode 100644 src/components/documents/cancel-document-dialog.tsx create mode 100644 src/components/shared/country-flag.tsx create mode 100644 src/lib/email/test-registry.ts create mode 100644 src/lib/pdf/reports/charts.tsx diff --git a/docs/admin-ia-proposal.md b/docs/admin-ia-proposal.md new file mode 100644 index 00000000..241e1e1b --- /dev/null +++ b/docs/admin-ia-proposal.md @@ -0,0 +1,415 @@ +# Admin IA — Audit + Proposed Regrouping + +**Status:** Phase 1 (proposal + decisions) — captured 2026-05-22 from B3 #10. Open questions resolved in section 7; final IA reflected in section 8. Phase 2 (execution) is mechanical from here. + +## Resolved decisions (2026-05-22) + +User answered the 5 open questions from section 7: + +1. **Forms + Document Templates** → moved to **Sales workflow** (not "Content"). Both are workflow inputs, not abstract content. +2. **Webhooks** → keep as its own thing; **new "Integrations" domain** is the right home (Webhooks + Documenso + Website analytics + AI all belong together as "external system + provider config"). +3. **AI configuration** → keep a dedicated `/admin/ai` panel that consolidates every AI feature in one place; lives under the new **Integrations** domain. +4. **`/admin/reports`** → **DELETE entirely** (confirmed duplicative — the dashboard already renders Pipeline funnel + Berth occupancy + KPI cards via widgets). Redirect to `/[portSlug]/dashboard`. +5. **`/admin/settings`** (generic KV editor) → keep visible to all admins under System & observability. + +**Net effect:** 7 domains instead of 6; 3 pages deleted (ocr, invitations, reports) instead of 2. Final IA in section 8. + +--- + +**Goal:** today's 41 admin pages are organically grown and discoverability is poor (test-email lives on Branding, an SMTP test on Email, an OCR-settings duplicate exists on both `/admin/ai` and `/admin/ocr`, etc.). Below: page-by-page inventory + a recommended IA in 6 domains. + +**Out of scope here:** the actual file moves, route redirects, and nav updates. That's Phase 2 (~4–6h once the IA below is locked). + +--- + +## 1. Page-by-page inventory (current state, 41 pages) + +Sorted alphabetically. Each row: what the page renders today + its current admin-sections-browser group. + +| Route | What it renders | Current group | +| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | +| `/admin` | `` — landing tile grid grouped into 5 categories | — | +| `/admin/ai` | `` (ai master controls + provider credentials) + `` + AI-suggestions card | Operations | +| `/admin/audit` | `` — full mutation log search | Data Quality | +| `/admin/backup` | `` — backup posture (read-only) | Operations | +| `/admin/berths/bulk-add` | `` — generate berth rows in bulk | (not in landing browser) | +| `/admin/berths/reconcile` | `` — review berths missing required fields | (not in landing browser) | +| `/admin/branding` | `` (identity) + email branding form + `` + `` | Configuration | +| `/admin/brochures` | `` — upload/version port brochures | (not in landing browser) | +| `/admin/custom-fields` | `` — per-entity custom-field definitions | Content | +| `/admin/documenso` | `` (api creds, signers, templates, behavior) + `` + `` + `` | Configuration ("EOI signing service") | +| `/admin/duplicates` | `` — suspected-duplicate clients | Data Quality | +| `/admin/email` | `` (from address + smtp) + `` + `` (new) + `` + `` | Configuration | +| `/admin/email-templates` | `` — subject-line overrides per transactional template | Content | +| `/admin/errors` | error-event list (system errors) | (not in landing browser) | +| `/admin/errors/codes` | error-code catalog reference | (not in landing browser) | +| `/admin/errors/[requestId]` | single error-event detail | (not in landing browser) | +| `/admin/forms` | `` — public inquiry/intake form schemas | Content | +| `/admin/import` | CSV import wizard | Data Quality ("Bulk Import") | +| `/admin/inquiries` | `` — public-site submissions awaiting triage | Data Quality | +| `/admin/invitations` | (empty body — comment says merged into `/admin/users` 2026-05-21) | (not in landing browser) | +| `/admin/monitoring` | `` — BullMQ queue health | Operations | +| `/admin/monitoring/[queueName]` | `` — single-queue drill-down | (not in landing browser) | +| `/admin/ocr` | `` — **DUPLICATES the same form on `/admin/ai`** | (not in landing browser) | +| `/admin/onboarding` | `` — cross-page setup checklist for new ports | Operations | +| `/admin/pipeline-rules` | per-trigger berth-rules editor + `` | Configuration ("Pipeline auto-advance") | +| `/admin/ports` | `` — manage marinas (super-admin only) | Operations | +| `/admin/pulse` | `` — pulse chip tuning | Configuration | +| `/admin/qualification-criteria` | `` — lead-qualification rubric | Operations | +| `/admin/reminders` | `` | Configuration | +| `/admin/reports` | `` — saved analytics + ad-hoc queries | Operations | +| `/admin/residential-stages` | `` + stage-template registry form | Operations | +| `/admin/roles` | `` — role/permission matrix | Access | +| `/admin/sends` | `` — brochure + per-berth PDF send retries | Data Quality | +| `/admin/settings` | `` — generic system_settings KV editor (escape hatch) | Configuration ("System Settings") | +| `/admin/storage` | `` — storage backend selector + migration | Operations | +| `/admin/tags` | `` — color-coded tags per entity | Content | +| `/admin/templates` | `` — PDF + email document templates (merge-field-driven) | Content | +| `/admin/templates/[id]/editor` | per-template editor (PDF + email body) | (not in landing browser) | +| `/admin/users` | `` + `` (tabs, merged 2026-05-21) | Access | +| `/admin/vocabularies` | `` — admin-editable enum lists | Content | +| `/admin/webhooks` | `` + `` + `` | Configuration | +| `/admin/website-analytics` | Umami creds form + `` | Operations | + +--- + +## 2. Issues identified + +### 2.1 Duplicates + +1. **`/admin/ocr` duplicates `/admin/ai`** — same `` is mounted on both. The AI page is the source of truth (it also has the master AI switch + provider creds + AI-suggestions config). **Recommendation: delete `/admin/ocr`** + add a redirect. +2. **`/admin/invitations` is dead** — the page body is empty (per the comment, merged into `/admin/users` 2026-05-21). **Recommendation: delete the route** + add a redirect to `/admin/users?tab=invitations`. + +### 2.2 Misplaced cards + +1. **`` is on Branding but tests email rendering** — overlap with the new per-template tester on `/admin/email`. **Recommendation: KEEP on Branding** (it's a one-click "does the email LOOK right with current logo/colors?" affordance — that's a branding-validation concern, not a delivery test). Add a sibling link "→ Test individual templates" pointing at `/admin/email`. +2. **`` is on `/admin/email`** — correct home, but it's structurally identical to the noreply SMTP card above it (just a second mailbox). **Recommendation: keep but reformat** so both mailboxes are in matching cards stacked, with a shared "Test send" footer per mailbox. +3. **`` is on `/admin/email`** — actually it's a routing-rule editor (when X event fires, route through Y mailbox). Conceptually closer to a workflow rule than a credentials setting. **Recommendation: keep on Email** for now (the routing IS about email plumbing) but cross-link from Workflows since changing the rule changes behaviour. + +### 2.3 Inconsistent naming + +1. **"Documenso & EOI"** page title implies EOI lives separately — but EOI generation is one of multiple Documenso flows. **Recommendation: rename to "Signing service (Documenso)"**. +2. **"Bulk Import"** vs `/admin/import` — fine, but the page should explicitly say "Data import" (matches the page title ``). +3. **"Send Log"** vs `/admin/sends` — fine; consider renaming the route slug to `/admin/send-log` for clarity, but that costs cross-references. + +### 2.4 Pages not in the admin-sections-browser tile grid + +A bunch of pages exist as routes but aren't surfaced on `/admin`: + +- `/admin/berths/bulk-add`, `/admin/berths/reconcile` — only reachable from deep links inside the Berths page +- `/admin/brochures` +- `/admin/email-templates`, `/admin/tags`, `/admin/vocabularies`, `/admin/custom-fields`, `/admin/forms` — actually these ARE in the browser under "Content", verified +- `/admin/qualification-criteria`, `/admin/residential-stages` — under Operations +- `/admin/errors`, `/admin/errors/codes`, `/admin/errors/[requestId]` +- `/admin/ocr` (duplicate, recommended for deletion) +- `/admin/invitations` (dead, recommended for deletion) + +**Recommendation:** surface every active page on `/admin` (no hidden surfaces — discoverability matters for admins). Move `/admin/berths/bulk-add` + `/admin/berths/reconcile` to a new "Berths admin" landing card. + +### 2.5 Categories that don't quite fit + +- **"Content"** is doing too much heavy lifting — it lumps tag color picker (visual), vocab enum lists (config), form templates (workflow), and document templates (mail merge). These are all things admins _tune_ but their cognitive shape is different. +- **"Data Quality"** mixes inbound queues (Inquiry Inbox) with cleanup utilities (Duplicates, Bulk Import) — those serve different daily-workflows. +- **"Operations"** is the catch-all for "anything observability or infra-shaped" but also has things that are pure setup (AI configuration, residential pipeline stages). + +--- + +## 3. Proposed IA — 6 domains, 38 pages + +Two pages deleted (`/admin/ocr`, `/admin/invitations`), one moved out of admin entirely (`/admin/reports` — see below), one new sub-area (`/admin/berths`). Net page count: 41 → 38. + +### Domain 1. **Brand & Communication** (5 pages) + +_Everything about how outbound looks + which channel it ships on._ + +| Page | Action | Notes | +| ------------------------ | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `/admin/branding` | KEEP | Logo, colors, app name, email header/footer HTML, the visual "does it look right?" tester. | +| `/admin/email` | KEEP | SMTP creds (noreply + sales), routing, per-template tester, SMTP connectivity probe. | +| `/admin/email-templates` | KEEP | Subject-line overrides per transactional template. Stays separate from `/admin/email` because the audience is "copywriter" vs "ops". | +| `/admin/documenso` | RENAME → "Signing service" | API creds, signer identities, templates, behaviour. Page title currently says "Documenso & EOI" — drop "& EOI" (EOI is one of many doc types). | +| `/admin/webhooks` | KEEP | Outbound webhook subscriptions + delivery log. Sits here because webhooks are an outbound-comms channel, same conceptual bucket as email. | + +### Domain 2. **Sales workflow** (7 pages) + +_How the pipeline behaves end-to-end — triggers, scoring, templates._ + +| Page | Action | Notes | +| ------------------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| `/admin/pipeline-rules` | KEEP | Berth-rules engine triggers + auto-advance. | +| `/admin/pulse` | KEEP | Deal Pulse chip tuning. | +| `/admin/reminders` | KEEP | Default reminder behaviour + digest window. | +| `/admin/qualification-criteria` | MOVE FROM "Operations" → here | Lead-qualification rubric — clearly a sales-workflow concern. | +| `/admin/residential-stages` | MOVE FROM "Operations" → here | Residential pipeline shape. Same domain as the standard pipeline rules. | +| `/admin/forms` | MOVE FROM "Content" → here | Form templates drive lead intake — workflow input, not "content". | +| `/admin/templates` | MOVE FROM "Content" → here | Document templates carry merge fields tied to the pipeline (EOI, reservation, contract). These ARE pipeline artefacts. | + +### Domain 3. **Catalog** (4 pages) + +_Tenant-defined data shapes — values that get attached to records._ + +| Page | Action | Notes | +| ---------------------- | -------------------------- | ---------------------------------------------------------------------------- | +| `/admin/vocabularies` | KEEP | Admin-editable enum lists (berth_side_pontoon_options, lead_category, etc.). | +| `/admin/tags` | KEEP | Color tags. | +| `/admin/custom-fields` | KEEP | Per-entity field definitions. | +| `/admin/brochures` | MOVE FROM ungrouped → here | Brochure assets are catalog artefacts (per-port versioned PDFs). | + +### Domain 4. **Identity & access** (3 pages) + +_Who can use the system and what they can do._ + +| Page | Action | Notes | +| -------------- | ------ | -------------------------------------------- | +| `/admin/users` | KEEP | Active users + invitations (already merged). | +| `/admin/roles` | KEEP | Role/permission matrix. | +| `/admin/ports` | KEEP | Super-admin only; per-port management. | + +### Domain 5. **Inbox & data quality** (6 pages) + +_Stuff that lands in admin queues + tools to clean up data._ + +| Page | Action | Notes | +| ------------------------- | ----------------------------------------------------------- | ----------------------------------------------------------- | +| `/admin/inquiries` | KEEP | Public-site form submissions. | +| `/admin/sends` | KEEP | Brochure + per-berth-PDF send retries. | +| `/admin/duplicates` | KEEP | Suspected-duplicate review queue. | +| `/admin/import` | KEEP | CSV imports. | +| `/admin/berths` | NEW INDEX | Landing page that surfaces the two berth-admin tools below. | +| `/admin/berths/bulk-add` | MOVE FROM ungrouped → keep route, surface via /admin/berths | Bulk berth row generator. | +| `/admin/berths/reconcile` | MOVE FROM ungrouped → keep route, surface via /admin/berths | Berth-pdf reconciliation queue. | + +(Counted as one Berths entry on the landing tile + the two existing routes as sub-pages.) + +### Domain 6. **System & observability** (10 pages) + +_Infra, observability, escape hatches._ + +| Page | Action | Notes | +| ------------------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `/admin/audit` | KEEP | Mutation audit log. | +| `/admin/monitoring` | KEEP | BullMQ queue health. | +| `/admin/monitoring/[queueName]` | KEEP | Single-queue detail. | +| `/admin/errors` | SURFACE on landing | Error-event list (currently hidden from `/admin` tile grid). | +| `/admin/errors/codes` | KEEP as sub-page | Linked from `/admin/errors`. | +| `/admin/errors/[requestId]` | KEEP as sub-page | Linked from `/admin/errors`. | +| `/admin/backup` | KEEP | Backup posture. | +| `/admin/storage` | KEEP | Storage backend selector + migration. | +| `/admin/website-analytics` | KEEP | Umami creds. | +| `/admin/ai` | KEEP | AI config (master switch, providers, OCR settings, suggestions). | +| `/admin/settings` | KEEP | Generic KV editor (escape hatch for advanced flags). Stays in this domain because it's an admin-debug surface, not a normal-day setting. | +| `/admin/onboarding` | KEEP, FLOATS | Cross-cutting setup checklist. Stays accessible from `/admin` landing but doesn't belong in any single domain — it links to many. | + +### Out of admin entirely + +| Page | Action | Rationale | +| ---------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/admin/reports` | MOVE OUT → `/[portSlug]/reports` | Reports are an end-user feature, not admin config. Today it lives in admin only because it's permission-gated; should be a top-level nav item with the same permission gate. Defer to a follow-up; for the IA pass, just stop surfacing it on `/admin`. | + +### Deleted + +| Page | Action | Rationale | +| -------------------- | ----------------------------- | -------------------------------------------- | +| `/admin/ocr` | DELETE + 301 → `/admin/ai` | Duplicate of `/admin/ai`. | +| `/admin/invitations` | DELETE + 301 → `/admin/users` | Empty page; functionality merged 2026-05-21. | + +--- + +## 4. Misplaced cards / sub-section moves + +These are smaller-grained moves _within_ the new IA — cards that should change page even though the routes stay put. + +1. **`` (currently on `/admin/branding`)** → KEEP on Branding (visual brand check); add a "→ Test individual templates" link pointing at `/admin/email#test-template`. +2. **`` (currently on `/admin/email`)** → KEEP on Email; cross-link from a "Routing rules" subsection of the new Workflow domain. +3. **`` (currently on `/admin/documenso`)** → KEEP; consider surfacing duplicate on `/admin/templates` (since "Sync from Documenso" populates template IDs there). +4. **``** → consider exposing a slim version as a banner on `/admin` landing for ports that haven't completed setup. + +--- + +## 5. Proposed `/admin` landing tile groups + +The `admin-sections-browser.tsx` array should be rebuilt to match the 6 domains above. Sketch: + +```ts +const SECTIONS: AdminSection[] = [ + { + title: 'Brand & Communication', + description: 'How outbound looks and which channels it ships on.', + items: ['branding', 'email', 'email-templates', 'documenso', 'webhooks'], + }, + { + title: 'Sales workflow', + description: 'Pipeline behaviour, scoring, document + form templates.', + items: [ + 'pipeline-rules', + 'pulse', + 'reminders', + 'qualification-criteria', + 'residential-stages', + 'forms', + 'templates', + ], + }, + { + title: 'Catalog', + description: 'Tenant-defined enums, tags, custom fields, and brochures.', + items: ['vocabularies', 'tags', 'custom-fields', 'brochures'], + }, + { + title: 'Identity & access', + description: 'Who can use the system and what they can do.', + items: ['users', 'roles', 'ports'], + }, + { + title: 'Inbox & data quality', + description: 'Admin queues + cleanup tools.', + items: ['inquiries', 'sends', 'duplicates', 'import', 'berths'], + }, + { + title: 'System & observability', + description: 'Infra, observability, escape hatches.', + items: [ + 'audit', + 'monitoring', + 'errors', + 'backup', + 'storage', + 'website-analytics', + 'ai', + 'settings', + ], + }, +]; +``` + +Onboarding checklist surfaces above the grid (or as a banner on incomplete ports), not as a tile. + +--- + +## 6. Phase 2 execution plan (~4–6h) + +Once the above IA is approved (or amended), the migration is mechanical: + +1. **Update `admin-sections-browser.tsx`** to the 6-domain shape above. (~30 min) +2. **Delete `/admin/ocr`** + add `redirect()` to `/admin/ai`. (~10 min) +3. **Delete `/admin/invitations`** + add `redirect()` to `/admin/users`. (~10 min) +4. **Rename "Documenso & EOI"** → "Signing service" (page title + landing label). (~5 min) +5. **Create `/admin/berths/page.tsx`** index that surfaces bulk-add + reconcile. (~30 min) +6. **Move `/admin/reports` out of admin** — touches sidebar nav + landing browser + permission docs. Defer to its own task if scope creeps. (~1h) +7. **Cross-link cards** per section 4 (EmailPreviewCard → /admin/email link, etc.). (~30 min) +8. **Smoke pass** — click every tile, confirm every page loads, every redirect lands. (~30 min) +9. **Audit doc update** — mark B3 #10 SHIPPED in `alpha-uat-master.md`. (~10 min) + +Total: ~4 h plus ~1 h for the Reports move if we include it. + +--- + +## 7. Open questions (resolved) + +| # | Question | Decision | +| --- | ---------------------------------------- | ------------------------------------------------------------------------------- | +| 1 | Forms + Document Templates placement | Moved to **Sales workflow** (not Content) | +| 2 | Webhooks placement | **New "Integrations" domain** (webhooks + documenso + website-analytics + ai) | +| 3 | AI configuration placement | Keep dedicated `/admin/ai` panel; lives under **Integrations** | +| 4 | `/admin/reports` | **DELETE entirely** (duplicates dashboard); redirect to `/[portSlug]/dashboard` | +| 5 | `/admin/settings` (KV editor) visibility | Keep visible to all admins under **System & observability** | + +--- + +## 8. Final IA — 7 domains, 38 pages + +After resolutions. Three pages deleted (`/admin/ocr`, `/admin/invitations`, `/admin/reports`); one new sub-area (`/admin/berths` index); one new domain (Integrations) split out from Brand & Communication. + +### Domain 1. **Brand & Communication** (3 pages) + +_How outbound LOOKS — visual and copy._ + +- `/admin/branding` — logo, colors, app name, email shell HTML, EmailPreviewCard (visual check) +- `/admin/email` — SMTP creds (noreply + sales), routing, per-template tester, SMTP probe +- `/admin/email-templates` — subject-line + copy overrides per transactional template + +### Domain 2. **Sales workflow** (7 pages) + +_How the pipeline BEHAVES — triggers, scoring, templates._ + +- `/admin/pipeline-rules` — berth-rules engine + auto-advance +- `/admin/pulse` — Deal Pulse chip tuning +- `/admin/reminders` — default behaviour + digest +- `/admin/qualification-criteria` — lead-scoring rubric +- `/admin/residential-stages` — residential pipeline shape +- `/admin/forms` — lead intake form templates (moved from Content) +- `/admin/templates` — document templates with merge fields (moved from Content) + +### Domain 3. **Catalog** (4 pages) + +_Tenant-defined data shapes that attach to records._ + +- `/admin/vocabularies` — admin-editable enum lists +- `/admin/tags` — color tags +- `/admin/custom-fields` — per-entity field definitions +- `/admin/brochures` — per-port versioned PDF assets + +### Domain 4. **Identity & access** (3 pages) + +- `/admin/users` — active users + invitations (merged) +- `/admin/roles` — role/permission matrix +- `/admin/ports` — super-admin only, per-port management + +### Domain 5. **Inbox & data quality** (5 pages, 1 sub-index) + +_Admin queues + cleanup tools._ + +- `/admin/inquiries` — public-site submissions +- `/admin/sends` — outbound send retry log +- `/admin/duplicates` — duplicate-client review queue +- `/admin/import` — CSV imports +- `/admin/berths` — **NEW** index page surfacing the two existing sub-tools: + - `/admin/berths/bulk-add` (bulk row generator) + - `/admin/berths/reconcile` (berth-pdf reconciliation queue) + +### Domain 6. **Integrations** (4 pages) — NEW DOMAIN + +_External-system + provider configuration._ + +- `/admin/documenso` — signing service (rename from "Documenso & EOI" → "Signing service") +- `/admin/webhooks` — outbound subscriptions + delivery log +- `/admin/website-analytics` — Umami creds +- `/admin/ai` — dedicated AI panel consolidating master switch + provider creds + OCR settings + AI-suggestions config + +### Domain 7. **System & observability** (7 pages + 1 floating) + +_Infra, observability, escape hatches._ + +- `/admin/audit` — mutation audit log +- `/admin/monitoring` — BullMQ queue health (+ `/admin/monitoring/[queueName]` sub-page) +- `/admin/errors` — error-event list (+ `/admin/errors/codes` + `/admin/errors/[requestId]`) +- `/admin/backup` — backup posture +- `/admin/storage` — storage backend selector + migration +- `/admin/settings` — generic KV editor (escape hatch) +- `/admin/onboarding` — cross-cutting setup checklist (floats above the grid for incomplete ports) + +### Deleted + +| Page | Action | Rationale | +| -------------------- | -------------------------------------- | ---------------------------------------------------- | +| `/admin/ocr` | DELETE + 301 → `/admin/ai` | Duplicate of `/admin/ai`'s OcrSettingsForm | +| `/admin/invitations` | DELETE + 301 → `/admin/users` | Empty page; merged into `/admin/users` on 2026-05-21 | +| `/admin/reports` | DELETE + 301 → `/[portSlug]/dashboard` | Three widgets all already on the dashboard | + +--- + +## 9. Phase 2 execution plan (~4-5 h) + +Updated to reflect the resolved decisions. Reports move-out becomes a delete (simpler). + +1. **Update `admin-sections-browser.tsx`** to the 7-domain shape above. (~45 min — 7 groups, ~30 tiles) +2. **Delete `/admin/ocr`** + add `redirect()` to `/admin/ai`. (~10 min) +3. **Delete `/admin/invitations`** + add `redirect()` to `/admin/users`. (~10 min) +4. **Delete `/admin/reports`** + add `redirect()` to `/[portSlug]/dashboard`. (~10 min) + remove from sidebar nav + landing browser. (~15 min) +5. **Rename `/admin/documenso`** page title → "Signing service" (page title + landing tile label). (~5 min) +6. **Create `/admin/berths/page.tsx`** index page surfacing bulk-add + reconcile sub-tools. (~30 min) +7. **Cross-link ``** on Branding to add a "→ Test individual templates" link pointing at `/admin/email#test-template`. (~10 min) +8. **Smoke pass** — click every tile on the new `/admin` landing, confirm every page loads, every redirect lands. (~30 min) +9. **Update `alpha-uat-master.md`** Bucket 3 #10 → SHIPPED with this proposal's commit hash. (~5 min) + +Total: ~3.5-4 h. diff --git a/docs/superpowers/audits/alpha-uat-master.md b/docs/superpowers/audits/alpha-uat-master.md index 45757d31..db86bdc4 100644 --- a/docs/superpowers/audits/alpha-uat-master.md +++ b/docs/superpowers/audits/alpha-uat-master.md @@ -146,6 +146,9 @@ _Copy tweaks, alignment, single-prop edits, obvious typos._ - Service docstring updated to cite the verified v3 endpoint behaviour + the flat-shape rationale so the next reader doesn't repeat the v1-nested mistake. - `tsc --noEmit` clean. Verified live: dashboard tile + website-analytics page both render 2,081 pageviews / 726 visitors / 872 visits / 457 bounces over 30d (the real numbers from analytics.portnimara.com). Fixed in this session. 16. **Revenue Breakdown widget removed end-to-end** — _src/components/dashboard/{revenue-breakdown-chart.tsx (deleted), widget-registry.tsx, use-analytics.ts}_, _src/app/api/v1/analytics/route.ts_, _src/lib/services/analytics.service.ts_, _tests/integration/analytics-service.test.ts_ — the "Revenue Breakdown" tile (bar chart of invoice totals by status × currency) wasn't aligned with how the org uses invoicing (no client-facing invoicing through the system — only employee expense-sheet PDFs for trip reimbursement) and was redundant once the Pipeline Value tile shipped with a weighted forecast + per-stage breakdown. Removed: widget file, dynamic import, registry entry, `useRevenue` hook, `RevenueBreakdownData` type, `MetricBase` union member, `ALL_METRICS` entry, `SnapshotData` union member, `getRevenueBreakdown` + `computeRevenueBreakdown` service functions, `refreshSnapshotsForPort` revenue branch, route dictionary entry, integration test. `RevenueReportPdf` (separate code path for the reports module) intentionally kept. `tsc --noEmit` clean. Fixed in this session. +17. **Finish CountryFlag rollout — table + filter surfaces** — _src/components/shared/country-flag.tsx (shipped this session)_ + _src/components/clients/client-columns.tsx:173_ (nationality column cell — currently renders bare ISO code; should prefix with ``) + _src/components/clients/client-filters.tsx_ (nationality filter pill — render flag next to selected country name) + _src/components/yachts/yacht-form.tsx_ (flag the yacht's country if surfaced anywhere outside the CountryCombobox transitive path) + audit any remaining `flagEmoji` or `0x1f1e6` codepoint references with `rg -n "0x1f1e6\|flagEmoji"` → expected 0 hits. Shipped this session: country-combobox + inline-country-field + addresses-editor (replaced existing emoji glyphs, which never rendered on Windows) and added flags to clients-by-country-widget / client-card / client-detail-header / website-analytics realtime + sessions + session-detail. Library: `country-flag-icons` (MIT, ~1-2 KB per flag, dynamically imported on first render, cached). Effort: ~30 min for the remaining surfaces. Captured 2026-05-22 from UAT. + - **SHIPPED in this session:** client-columns nationality cell now renders flag + name. client-filters is a free-text input (no rendered chip surface to flag). No yacht country rendering exists outside CountryCombobox. Final `rg "0x1f1e6\|flagEmoji"` returns 0 hits. + - **Follow-up fix in this session:** the original `CountryFlag` used a template-string dynamic import (`import('country-flag-icons/string/3x2/${code}')`), which silently fails in Next.js's webpack because the package's `exports` field gates each subpath. Symptom: every flag rendered as the muted placeholder box. Replaced with a single lazy `import('country-flag-icons/string/3x2')` that loads the whole index once (~1.6 MB raw / ~400 KB gzip, single chunk shared across the app), caches on a module-level promise, and lookups become synchronous after first render. --- @@ -521,6 +524,11 @@ _Component refactors, multi-file edits, single-service tweaks, new validators._ 3. **Pipeline Value tile expanded with per-stage breakdown** — _src/components/dashboard/pipeline-value-tile.tsx_, _src/lib/services/dashboard.service.ts_ — replaced the single-number KPI with a richer card: gross headline + weighted forecast on top, per-stage rows below (label · mini bar · gross value · count + close-probability), and a footnote when default stage weights are in use. Service `getRevenueForecast` extended to return `grossValue`, `weight`, `totalGrossValue`, and `dealsMissingPrice` alongside the existing weighted shape; the tile pulls from `/kpis` (for gross + currency + activeInterests) and `/forecast` (for breakdown). Per-stage warning chip surfaces when berths are missing a `price` so a silently undercounted gross is visible (full coverage → "berth price missing", partial → "N of M missing price"). Leadership can now see how much of the headline is near-close vs speculative. Fixed in this session. 4. **"How weighted forecast works" info popover on the Pipeline Value tile** — _src/components/dashboard/pipeline-value-tile.tsx_ — added an `Info` icon next to the description that opens a `Popover` (click or hover) explaining the close-probability model + showing the per-stage weight table (live from `/forecast`, fallback to `STAGE_WEIGHTS` constant) + a note about whether default or per-port weights are in use. Fixed in this session. 5. **Bulk + inline berth price editing — backend complete** — _src/lib/db/schema/users.ts_, _src/lib/db/seed-permissions.ts_, _src/components/admin/roles/role-form.tsx_, _src/components/admin/users/user-permission-matrix.tsx_, _src/app/api/v1/admin/users/[id]/permission-overrides/route.ts_, _src/lib/validators/berths.ts_, _src/lib/services/berths.service.ts_, _src/app/api/v1/berths/[id]/price/route.ts_, _src/app/api/v1/berths/bulk-update-prices/route.ts_, _tests/helpers/factories.ts_ — new `berths.update_prices` permission carved out from generic `berths.edit` so sales reps can update prices without exposing the full edit surface. Permission seeded on for super_admin/director/sales_manager/sales_agent, off for viewer/residential_partner. New validators (`updateBerthPriceSchema`, `bulkUpdateBerthPricesSchema` capped at 500/batch), services (`updateBerthPrice`, `bulkUpdateBerthPrices`, both transactional + per-row audited with `fieldChanged='price'` + realtime `berth:updated` + webhook fan-out), and routes (`PATCH /api/v1/berths/[id]/price`, `POST /api/v1/berths/bulk-update-prices`). UI shipping in a follow-up — see Features bucket #1. Fixed in this session. +6. **Cancel-document: choose delete-from-Documenso vs keep-for-audit** — _src/lib/services/documents.service.ts (cancelDocument)_ + _src/lib/services/documenso-client.ts (voidDocument)_ + every cancel-document UI surface (interest reservation tab, contract tab, EOI cancel dialog, send-document dialog admin actions, etc.) — today cancel always fires `DELETE /api/v2/envelope/{id}` (or v1 equivalent), which unclogs the Documenso instance but loses the upstream audit trail. UX ask: present the rep with an explicit choice on cancel: (a) **Delete upstream** (current behaviour — frees the Documenso slot, history rendered from CRM `documents` row only) or (b) **Keep for audit** (local row → `status='cancelled'`, no DELETE call; rep can later reopen on Documenso for forensics). Default to (a). Plumb a `cancelMode: 'delete' | 'keep_remote'` param through `cancelDocument` + the route handler; only call `documensoVoid` when mode === 'delete'. ~1-1.5h: service param + UI radio in the existing confirm-cancel dialog + audit-doc-status reflection in the cancelled-doc badge ("Cancelled, kept on Documenso" when keep_remote). Captured 2026-05-22. +7. **Document signature reminders: drop rate-limit when automatic** — _src/components/interests/interest-reservation-tab.tsx:740_ ("Reminders are rate-limited (max once per 7 days per signer)") + the underlying remind-signer service. Today both manual and scheduled-auto reminders share the same 7-days-per-signer throttle. The cap is right for manual clicks (avoids harassment) but breaks the auto-cadence cron: if the rules engine wants to nudge a stale signer every 3 days, it gets swallowed. Plumb a `triggeredBy: 'manual' | 'auto'` flag from the caller and skip the rate-limit when `auto` (the cron's own cadence is the throttle). Manual UI keeps the 7-day cap. ~30-45 min: service param + cron caller + UI copy update ("Reminders are rate-limited for manual sends — automatic follow-ups run on the configured cadence"). Captured 2026-05-22. +8. **EOI tab: add upload-draft-then-place-fields option (parity with Contract / Reservation)** — _src/components/interests/interest-eoi-tab.tsx_ (no `UploadForSigningDialog` mount yet) + _src/app/api/v1/interests/[id]/upload-for-signing/route.ts_ (`documentTypeSchema` is locked to `'contract' | 'reservation_agreement'`) + _src/lib/services/custom-document-upload.service.ts_ (`CustomDocumentType` union, `targetStage` switch, `dateContractSent` / `reservationDocStatus` branch). EOI currently has two paths — template-generated (`EoiGenerateDialog`, `/template/{id}/generate-document`) and external paper-signed upload (`ExternalEoiUploadDialog`, `markExternallySigned`) — but no upload-draft-then-drag-fields flow like Contract/Reservation. Reps with a bespoke EOI PDF have to either generate from template (loses custom layout) or mark-as-external (no signing). Fix shape: extend the union to `'eoi'`, add an EOI branch to the stage-advance + doc-status switch (`pipelineStage='eoi_sent'`, `eoiDocStatus='sent'`, `dateEoiSent`), wire `` into the EOI tab next to the existing "Generate EOI" CTA. Effort: ~2-3h including the route validator bump, service branch, UI mount, and a smoke playwright run. Captured 2026-05-22. +9. **Surface per-signer copyable signing URLs on every Documenso-driven doc** — _src/components/interests/interest-eoi-tab.tsx_, _src/components/interests/interest-reservation-tab.tsx_, _src/components/interests/interest-contract-tab.tsx_, _src/components/documents/signing-details-dialog.tsx_, _src/components/shared/send-document-dialog.tsx_ — once a document has been created in Documenso, each signer's `signingUrl` is already stored on the `document_signers` row (returned by `/api/v1/documents/{id}/signers`). Today the rep sees Pending / Invited badges but no way to grab a specific signer's signing URL for QA or manual delivery. Add a "Copy signing link" button next to each signer row across every signing-doc tab (EOI / Reservation / Contract / SigningDetails / SendDocument admin panel). Behaviour: button is disabled when `signingUrl` is null (Documenso hadn't returned a URL yet — e.g. send-mode failure); on click, copy to clipboard via `navigator.clipboard.writeText`, toast "Signing link copied" + the truncated URL. Useful for: smoke-testing the signing flow without spamming the rep's inbox, manually pasting a link into a custom email or Slack DM when the auto-send mode failed, and for sales reps who want to QA the look of the page before the customer touches it. ~30-45 min, all UI surface work — backend already exposes the data. Captured 2026-05-22. +10. **Per-template "Send a test" tester for every transactional email the system emits** — _src/components/admin/branding/email-preview-card.tsx_ (current sample-only tester), _src/lib/email/templates/_ (all template files), _src/app/api/v1/admin/branding/email-preview/route.ts_ (current endpoint), new endpoint `/api/v1/admin/email/test-template`. Today the admin can send ONE generic branded shell from Branding, plus an SMTP-connectivity ping from Email — but no way to fire a specific template (password reset, EOI invitation, signing reminder, GDPR export ready, portal activation, reminder digest, bounce-back notice, …) to a designated address. Add a per-template tester card: dropdown of every registered template (read from a central template registry exposing `id, label, sampleProps`), recipient email input, "Send test" button. Backend route renders the selected template with realistic sample props (port branding, fake but plausible client/yacht/EOI), pipes through the same sender helper as the real flow, returns delivery status. Goes on the Email admin page next to the existing SMTP test card. Effort: ~2-3h (registry + endpoint + card + sample-prop fixtures for each template). Captured 2026-05-22. --- @@ -676,6 +684,12 @@ _New UI surfaces, new endpoints, schema migrations, multi-step flows._ - **(a) Pre-flight config-shape errors at known integration boundaries** — _src/lib/services/documenso-client.ts_, _src/lib/services/storage/\*_, _src/lib/email/_, _src/lib/services/imap-bounce-poller.ts_, IMAP, SMS providers, payment gateways, etc. — when a call would fail because admin/env config is empty or unparseable, raise a typed `CodedError` _before_ the network call with an operator-facing message like `"Documenso is not configured for {portName}. Open Admin → Documenso settings to enter the API key, or set DOCUMENSO_API_KEY in env."` Include the offending setting key + port name. The `documenso-client` `resolveCreds()` is the canonical example to template from — others (IMAP, S3, SMTP, Stripe etc.) should follow the same pattern. - **(b) User-facing error-message audit** — _src/lib/errors.ts_, all `try/catch` blocks in `src/app/api/*`, all `toastError` consumers in `src/components/*` — scan for `errorResponse(err)` paths that return generic "Something went wrong" / status codes only, and enrich with: (i) the operation that failed ("EOI generation", "Send invoice", "Upload file"), (ii) the likely cause (config missing, permission denied, conflict, etc.), (iii) the next step (where to fix it). Especially important for setting-driven features (email send accounts, storage backends, Documenso config, webhook secrets) where the real cause is one config field off-screen. The error catalog in `src/lib/errors.ts` already supports `CodedError` with operator-friendly `userMessage` — most call sites just need to populate it. - Total scope: probably a 1-2 day audit + remediation pass. Out-of-scope items to consider during the pass: a per-port "Integrations health" admin page that probes each external integration and shows green/red with the same diagnostic copy. +9. **Universal "upload file → optionally place signing fields"** — _src/components/documents/upload-for-signing-dialog.tsx_ (the existing place-fields step) + _src/components/documents/new-document-menu.tsx_ (Documents Hub upload), _src/components/documents/documents-hub.tsx_ (root + folder upload), _src/components/files/file-upload-zone.tsx_ (the shared dropzone), _src/components/clients/_ + _yachts/_ + _companies/_ document-tab upload surfaces — every modal where a PDF can land should expose an optional "Send for signature?" toggle that swaps the regular file-upload for the field-placement wizard. Avoids the re-upload friction the user currently hits when an arbitrary doc needs signatures. Shape: extract a `` from `UploadForSigningDialog`, mount it conditionally after the dropzone in every upload modal, and route through `/upload-for-signing` when fields are placed (skip it when only a plain file is uploaded). Backend: extend `CustomDocumentType` to accept `'generic'` (no pipeline-stage advance, no doc-status flip — just files + documents row in `sent` status). Effort: ~8-12h. Captured 2026-05-22. +10. **Comprehensive admin-settings IA audit + regroup** — _src/app/(dashboard)/[portSlug]/admin/_ — 41 admin pages today, organically grown: `ai`, `audit`, `backup`, `berths/bulk-add`, `berths/reconcile`, `branding`, `brochures`, `custom-fields`, `documenso`, `duplicates`, `email-templates`, `email`, `errors`, `forms`, `import`, `inquiries`, `invitations`, `monitoring`, `ocr`, `onboarding`, `pipeline-rules`, `ports`, `pulse`, `qualification-criteria`, `reminders`, `reports`, `residential-stages`, `roles`, `sends`, `settings`, `storage`, `tags`, `templates`, `users`, `vocabularies`, `webhooks`, `website-analytics`. Settings are scattered — e.g. test-email lives on Branding, SMTP test on Email, password-reset copy probably in `email-templates`, but the rep has to guess. Audit each page for: (a) what settings live there now, (b) which settings logically belong elsewhere ("right home" test — Documenso send mode currently lives on Documenso, makes sense; per-port email signature would make more sense under Branding than Email), (c) duplicates (vocabularies vs custom-fields vs qualification-criteria overlap on enum tuning). Then propose a regrouped IA — likely fewer top-level pages with clear domain headers (Configuration → Branding, Email, Documenso, Storage, Webhooks; Workflows → Pipeline rules, Reminders, Auto-stage advancement; Catalog → Vocabularies, Tags, Custom fields, Qualification criteria; Operations → Monitoring, Pulse, Audit log, Errors, Backup; Data → Import, Duplicates, Bulk berth tools; Identity → Users, Roles, Invitations, Onboarding). Pair with a new admin index page that groups by domain instead of a flat alphabetical list. Effort: ~1.5-2 days — audit pass + IA proposal review + actual file moves + nav updates + redirect shims for old URLs. Captured 2026-05-22. + - **SHIPPED in this session (Phase 1 + Phase 2):** Full audit + proposal at `docs/admin-ia-proposal.md`. Final IA = 7 domains, 38 pages (down from 41 via three deletes). `admin-sections-browser.tsx` rewritten to the new domain shape (Brand & Communication, Sales workflow, Catalog, Identity & access, Inbox & data quality, Integrations, System & observability). Deleted with redirects: `/admin/ocr` → `/admin/ai`, `/admin/reports` → `/[portSlug]/dashboard`, `/admin/invitations` → `/admin/users` (this last one was already a redirect). Renamed: "Documenso & EOI" → "Signing service (Documenso)". New: `/admin/berths` index page surfacing bulk-add + reconcile sub-tools (which were previously discoverable only via deep links). `` on Branding cross-links to `/admin/email` per-template tester. Search-nav-catalog updated (ocr entry removed, berths entry added). tsc clean. +11. **B3 #9 follow-up — UI wiring for universal upload-with-fields** — _src/components/documents/upload-for-signing-dialog.tsx_ (`` lives inside this monolith — needs extraction into a standalone component the other upload modals can mount conditionally), _src/components/documents/new-document-menu.tsx_ + _src/components/documents/documents-hub.tsx_ + _src/components/files/file-upload-zone.tsx_ + entity-tab upload sites (client/yacht/company doc tabs). **Backend foundations SHIPPED 2026-05-22**: `CustomDocumentType` union now includes `'generic'`; `uploadDocumentForSigning` skips pipeline-stage advance + doc-status flip when generic; route validator accepts the new value; storage path category routes to `signed-source/`. **UI half deferred** to a paired session — needs careful surgery to each upload modal to add the "Send for signature?" toggle + mount the extracted field-placement step. Effort for UI wiring: ~5-7h. Captured 2026-05-22. +12. **Time-period PDF report + chart rendering + deeper data** — _src/lib/pdf/reports/dashboard-report.tsx_, _src/lib/services/dashboard-report-data.service.ts_, _src/lib/pdf/reports/types.ts_, new _src/lib/pdf/reports/charts.tsx_, _src/components/reports/export-dashboard-pdf-button.tsx_ (date-range picker). Today's PDF report ignores dateFrom/dateTo for most sections and renders every chart-style widget as a table. User wants: (a) **time-range filter** that scopes EVERY section to a chosen window — new clients in the window, new interests in the window, active interests touching the window, in-progress berths (sold/under-offer transitions in the window), pipeline counts at the start vs end of window, etc.; (b) **chart rendering** — react-pdf supports SVG, so build small SVG generators (``, ``, ``) inline OR pre-render via vega-lite/d3-node to PNG and embed; (c) **deeper data per section** — add berths-in-flight (status changes within window), client+interest cohort tables, contact-cadence histogram, document-signing throughput. Shape: extend `DashboardReportData` with `window: {from, to}` and new sub-sections; extend the export-PDF dialog to take a date-range; route handler propagates the window to every per-section resolver. Effort: ~8-12h depending on chart-rendering approach (inline SVG is ~6h, vega-lite pre-render is ~10h with a worker round-trip). Captured 2026-05-22. + - **SHIPPED in this session:** Catalog expanded from 5 ids to 25 — chart variants (pipeline funnel bar, berth status donut, source conversion bar, lead source donut, occupancy timeline line) + period cohorts (new clients/interests, berths sold, deposits received, documents/contracts signed) + value views (pipeline value breakdown, revenue forecast, avg sales cycle, berth demand, country distribution, deal pulse distribution, recent activity). Hand-rolled SVG chart primitives in `src/lib/pdf/reports/charts.tsx` (HorizontalBarChart, DonutChart, LineChart) using @react-pdf/renderer's native Svg/Path/Rect support. Export-dialog grew a date-range picker with Last-30/90-days quick presets, defaults to last 30 days. Route + service plumbing carries dateFrom/dateTo. 11 of 16 pending resolvers landed (new_clients_period, new_interests_period, berths_sold_period via audit log, deposits_received_period, signed_documents_period, contracts_signed_period, berth_demand_ranking, lead_source_donut, client_country_distribution, recent_activity, pipeline_value_breakdown, revenue_forecast, avg_sales_cycle). Still pending (in this session's PENDING_RESOLVER_IDS set): stage_conversion_rates, occupancy_timeline_chart (needs daily buckets), inquiry_inbox_summary, reminders_summary, deal_pulse_distribution (requires the pulse service's dynamic computation, not a simple column query — left as follow-up). Also shipped: PDF logo absolutize for server-side fetch (was empty because @react-pdf/renderer can't fetch path-only URLs server-side), "Dashboard report" → "Report" default name, section-orphan fix (`wrap={false}` + `minPresenceAhead`). --- @@ -825,6 +839,13 @@ _Functional defects. Tag each with `[critical|high|medium|low]` prefix._ - **Effort:** ~10 min for the move + verify (no code change, just file relocation + manual click-through). Captured 2026-05-21 from UAT. - **SHIPPED in 2d57417:** route relocated via `git mv` to `src/app/public/supplemental-info/[token]/page.tsx`. URL `/public/supplemental-info/` unchanged (route groups don't affect URLs). Sweep of `src/app/(portal)/` confirmed no other public token routes were similarly nested. 12. **[high] Command-search quick-create buttons routed to dead `/new` pages** — _src/components/search/command-search.tsx_ — ZeroState "New client/yacht/company" buttons pushed `//new?name=…` which matched the `[id]` dynamic segment and rendered the entity-not-found page. Fixed by switching to `/?create=1&prefill_name=…` (the existing `useCreateFromUrl` convention) + adding `prefill` prop support to `YachtForm` + `CompanyForm` and wiring `prefill_name` reads in their list components. Now correctly pops the create sheet pre-filled. Fixed in this session. +13. **[high] Dashboard widget cross-group reorder silently ignored by the Customize modal** — _src/components/dashboard/customize-widgets-menu.tsx:113-136_ vs _src/components/dashboard/dashboard-shell.tsx:88-90_ — the Customize modal exposes a single flat `SortableContext` over ALL visible widgets, so a rep can drag (e.g.) "My Reminders" (rail) above "Pipeline Funnel" (chart). The new order persists correctly (`setOrder(...)` → `dashboardWidgetOrder` PATCH → optimistic cache update), and `visibleWidgets` recomputes sorted by rank. BUT the shell then re-buckets `visibleWidgets.filter(w => w.group === 'chart' | 'rail' | 'feed')` into three independent slots before rendering — so any cross-group reorder leaves the dashboard visually unchanged. Intra-group reorders DO work (within charts column, within rails aside, within feed). User-perceived bug: "rearranging apps in the customize modal still does not change the order of them." + - **Decision needed** before fixing — two viable directions: + - **(a) Flatten the dashboard layout** to a single ordered grid (drop the chart/rail/feed bucketing). Honour the rep's exact order across the whole page. Implementation: replace the three-block layout in DashboardShell with one auto-fit grid + per-widget span hints on the registry (`{ colSpan: 1 | 2 | 'full' }`); rails would naturally widen to their hinted column count, feed becomes a `col-span-full` row. Bigger UI surgery, but most honest semantics. + - **(b) Scope the Customize modal sortable to per-group sub-lists.** Render three SortableContexts ("Charts", "Rails", "Feed") inside the modal, each with its own drag handles. Cross-group moves disallowed (or shown as a toggle to move a widget between groups). Smaller code change but loses the flexibility the current UI implies. + - **Recommended:** (b) for the short-term fix (matches the actual rendering reality), with (a) parked as a v2 follow-up after we see whether reps actually want the flat layout. + - **Effort:** ~30-45 min for (b); ~3-4 h for (a) including registry schema bump + responsive layout audit. Captured 2026-05-22 from UAT. + - **SHIPPED in this session:** combined approach. At xl viewports the Customize modal renders three region-scoped sortables (Charts / Side rail / Activity) — matches the actual side-by-side dashboard layout. Below xl where the dashboard stacks all three regions into one visual column, the modal renders a single flat sortable so the rep can drag across regions freely. Plus per-viewport saved orders: `userPreferences.dashboardWidgetOrder` (xl/desktop) + new `dashboardWidgetOrderMobile` (stacked), so reps can customize each layout independently. The hook auto-picks the right field based on viewport. --- diff --git a/package.json b/package.json index 91ad7879..6d869c3a 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "country-flag-icons": "^1.6.17", "cron-parser": "^5.5.0", "date-fns": "^4.1.0", "drizzle-orm": "^0.45.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de010006..c9fa9397 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -145,6 +145,9 @@ importers: cmdk: specifier: ^1.1.1 version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + country-flag-icons: + specifier: ^1.6.17 + version: 1.6.17 cron-parser: specifier: ^5.5.0 version: 5.5.0 @@ -4155,6 +4158,9 @@ packages: resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} engines: {node: '>=10'} + country-flag-icons@1.6.17: + resolution: {integrity: sha512-Nmik0289ZVZSI3c7mJR/amg6DyY7Z59b0sTFSKayeX72mHfPzCPJygwJs2pYgQULzuAyWeCUgwAJ+Dq8OR+JFw==} + crc-32@1.2.2: resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} engines: {node: '>=0.8'} @@ -11049,6 +11055,8 @@ snapshots: path-type: 4.0.0 yaml: 1.10.3 + country-flag-icons@1.6.17: {} + crc-32@1.2.2: {} crc32-stream@6.0.0: diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index dd24ca98..751c0387 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -17,7 +17,7 @@ import { useAuthBranding } from '@/components/shared/auth-branding-provider'; // `identifier` accepts either an email address or a username (3–30 lowercase // letters / digits / dot / underscore / hyphen). The server endpoint // /api/auth/sign-in-by-identifier resolves the username server-side and -// forwards to better-auth in one round-trip — the canonical email is never +// forwards to better-auth in one round-trip - the canonical email is never // returned to the browser, which closes the username-enumeration vector. const loginSchema = z.object({ identifier: z.string().min(1, 'Email or username is required'), @@ -61,7 +61,7 @@ export default function LoginPage() { if (payload.data?.needsBootstrap) router.replace('/setup'); }) .catch(() => { - /* silent — login UX must still work even if status check fails */ + /* silent - login UX must still work even if status check fails */ }); return () => { cancelled = true; diff --git a/src/app/(auth)/reset-password/page.tsx b/src/app/(auth)/reset-password/page.tsx index e405e31e..3a54ef3d 100644 --- a/src/app/(auth)/reset-password/page.tsx +++ b/src/app/(auth)/reset-password/page.tsx @@ -59,7 +59,7 @@ export default function ResetPasswordPage() { }); // Treat 400 "user not found" as success so we don't leak whether the - // account exists — the success copy says "if an account exists…". + // account exists - the success copy says "if an account exists…". // Anything else (5xx, network) surfaces as a real error. if (!response.ok && response.status !== 400) { toast.error('Something went wrong. Please try again.'); diff --git a/src/app/(auth)/set-password/page.tsx b/src/app/(auth)/set-password/page.tsx index 0c0b0481..26c45be6 100644 --- a/src/app/(auth)/set-password/page.tsx +++ b/src/app/(auth)/set-password/page.tsx @@ -31,7 +31,7 @@ type SetPasswordFormData = z.infer; * H-03: tokens travel in the URL fragment (`#token=…`) so they never land * in HTTP access logs or HTTP-Referer headers. Pre-fragment links still * carry `?token=…` and stay functional until every outstanding invite - * expires — drop the `?token=` fallback after that grace period. + * expires - drop the `?token=` fallback after that grace period. */ function readTokenFromUrl(): string { if (typeof window === 'undefined') return ''; diff --git a/src/app/(auth)/setup/page.tsx b/src/app/(auth)/setup/page.tsx index e02b4fa3..c065726f 100644 --- a/src/app/(auth)/setup/page.tsx +++ b/src/app/(auth)/setup/page.tsx @@ -31,7 +31,7 @@ interface StatusResp { /** * First-run setup. On a fresh DB the very first visitor can claim the * super-admin account here. Once anyone claims it, future visits to - * /setup redirect back to /login — the precondition is verified both + * /setup redirect back to /login - the precondition is verified both * server-side (`/api/v1/bootstrap/status` + `/api/v1/bootstrap/super-admin`'s * internal recheck) and client-side here. */ @@ -58,13 +58,13 @@ export default function SetupPage() { const res = await apiFetch('/api/v1/bootstrap/status'); if (cancelled) return; if (!res.data.needsBootstrap) { - // Already initialized — bounce to login. Replace, not push, + // Already initialized - bounce to login. Replace, not push, // so back-button doesn't trap the user here. router.replace('/login'); return; } } catch { - // Status endpoint failed — let the user try anyway; the POST + // Status endpoint failed - let the user try anyway; the POST // does its own check and will surface a 409 if the window closed. } finally { if (!cancelled) setChecking(false); diff --git a/src/app/(dashboard)/[portSlug]/admin/ai/page.tsx b/src/app/(dashboard)/[portSlug]/admin/ai/page.tsx index 2a56eac5..889c676a 100644 --- a/src/app/(dashboard)/[portSlug]/admin/ai/page.tsx +++ b/src/app/(dashboard)/[portSlug]/admin/ai/page.tsx @@ -42,7 +42,7 @@ export default function AiAdminPage() { {/* - Berth-PDF parser AI fallback — currently configured via the + Berth-PDF parser AI fallback - currently configured via the BERTH_PDF_PARSER_* env vars. No per-port override surface today; when one is added, it lands here so admins don't have to hunt. */} @@ -63,10 +63,10 @@ export default function AiAdminPage() { {/* Future AI surfaces. Each gets a section here once it ships: - Recommender embeddings (currently rule-based, not LLM-based) - - Contact-log action extraction (deferred — needs user demand) + - Contact-log action extraction (deferred - needs user demand) - Inquiry-form auto-classification (deferred) Listing them inert here closes the "where do I configure AI?" - loop — admins land on /admin/ai and see the full landscape. + loop - admins land on /admin/ai and see the full landscape. */} diff --git a/src/app/(dashboard)/[portSlug]/admin/berths/page.tsx b/src/app/(dashboard)/[portSlug]/admin/berths/page.tsx new file mode 100644 index 00000000..5bd8c22d --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/admin/berths/page.tsx @@ -0,0 +1,88 @@ +import Link from 'next/link'; +import type { Route } from 'next'; +import { AlertCircle, Anchor, FileSearch } from 'lucide-react'; + +import { PageHeader } from '@/components/shared/page-header'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; + +/** + * Berths admin index. Both sub-pages (`bulk-add`, `reconcile`) existed + * pre-2026-05-22 but were only reachable via deep links from inside the + * Berths list. Surfacing them on a dedicated admin landing tile so the + * tools are discoverable without prior knowledge of the URL - part of + * the admin IA regroup (B3 #10 Phase 2). + */ +export default async function BerthsAdminIndex({ + params, +}: { + params: Promise<{ portSlug: string }>; +}) { + const { portSlug } = await params; + const tools = [ + { + href: `/${portSlug}/admin/berths/bulk-add` as Route, + label: 'Bulk add berths', + description: + 'Generate many berth rows in one wizard - set pier, prefix, mooring number range, and per-berth defaults; preview before commit.', + icon: Anchor, + }, + { + href: `/${portSlug}/admin/berths/reconcile` as Route, + label: 'Reconciliation queue', + description: + "Berths missing required fields after import / PDF parse. Surface what's missing per row and link straight to the edit sheet.", + icon: FileSearch, + }, + ] as const; + + return ( +
+ + +
+ {tools.map((t) => { + const Icon = t.icon; + return ( + + + + + {t.label} + + + {t.description} + + + + ); + })} +
+ + + + + Not what you're looking for? + + + + For single-berth edits, browse to the{' '} + + Berths list + {' '} + and click any row. Per-berth PDF uploads + brochure assignment also live there. + + + +
+ ); +} diff --git a/src/app/(dashboard)/[portSlug]/admin/branding/page.tsx b/src/app/(dashboard)/[portSlug]/admin/branding/page.tsx index 3af67355..9c9e985c 100644 --- a/src/app/(dashboard)/[portSlug]/admin/branding/page.tsx +++ b/src/app/(dashboard)/[portSlug]/admin/branding/page.tsx @@ -52,7 +52,7 @@ const FIELDS: SettingFieldDef[] = [ description: 'Blurred photo shown behind the white email card and the auth-shell (login / reset password) pages. Leave blank to render a plain off-white backdrop. Recommended: 1920x1080 JPG, pre-blurred to ~20px gaussian so it reads as a soft background even on small clients.', type: 'image-upload', - // 16:9 — landscape. Without an explicit aspect, the cropper falls + // 16:9 - landscape. Without an explicit aspect, the cropper falls // back to 1:1 and renders a circular mask (intended for avatars), // which is the wrong UX for a viewport-cover background. imageAspect: 16 / 9, diff --git a/src/app/(dashboard)/[portSlug]/admin/brochures/page.tsx b/src/app/(dashboard)/[portSlug]/admin/brochures/page.tsx index 03ef6f50..17a99300 100644 --- a/src/app/(dashboard)/[portSlug]/admin/brochures/page.tsx +++ b/src/app/(dashboard)/[portSlug]/admin/brochures/page.tsx @@ -6,7 +6,7 @@ import { BrochuresAdminPanel } from '@/components/admin/brochures-admin-panel'; * * Lists brochures, lets per-port admins upload new versions via direct-to- * storage presigned URLs (so the 20MB+ file never traverses Next.js's - * body-size limit — see §11.1), and toggle the default flag. + * body-size limit - see §11.1), and toggle the default flag. */ export default function BrochuresAdminPage() { return ( diff --git a/src/app/(dashboard)/[portSlug]/admin/documenso/page.tsx b/src/app/(dashboard)/[portSlug]/admin/documenso/page.tsx index ba2b06d1..9343a296 100644 --- a/src/app/(dashboard)/[portSlug]/admin/documenso/page.tsx +++ b/src/app/(dashboard)/[portSlug]/admin/documenso/page.tsx @@ -7,7 +7,7 @@ import { TemplateSyncButton } from '@/components/admin/documenso/template-sync-b import { PageHeader } from '@/components/shared/page-header'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -// All field arrays removed — every Documenso setting now flows through +// All field arrays removed - every Documenso setting now flows through // `RegistryDrivenForm`, which surfaces the env-fallback / port / global // source badge on each field. The settings themselves live in // `src/lib/settings/registry.ts` under sections `documenso.api` / @@ -17,8 +17,8 @@ export default function DocumensoSettingsPage() { return (
@@ -200,7 +200,7 @@ export default function DocumensoSettingsPage() { } /> diff --git a/src/app/(dashboard)/[portSlug]/admin/email/page.tsx b/src/app/(dashboard)/[portSlug]/admin/email/page.tsx index 42db8ff9..2fa76b8c 100644 --- a/src/app/(dashboard)/[portSlug]/admin/email/page.tsx +++ b/src/app/(dashboard)/[portSlug]/admin/email/page.tsx @@ -5,6 +5,7 @@ import { RegistryDrivenForm } from '@/components/admin/shared/registry-driven-fo import { SalesEmailConfigCard } from '@/components/admin/sales-email-config-card'; import { EmailRoutingCard } from '@/components/admin/email-routing-card'; import { SmtpTestSendCard } from '@/components/admin/email/smtp-test-send-card'; +import { TestTemplateCard } from '@/components/admin/email/test-template-card'; export default function EmailSettingsPage() { return ( @@ -14,7 +15,7 @@ export default function EmailSettingsPage() { description="Per-port outgoing email configuration. SMTP credentials and the From address default to environment variables when these fields are blank. Header/footer HTML lives under Branding." /> - {/* Explainer for the "two accounts" model — addresses the recurring + {/* Explainer for the "two accounts" model - addresses the recurring UAT question "why are there separate SMTP credentials for sales and noreply?". Keeps the answer in front of the admin before they reach the per-card form below. */} @@ -39,7 +40,7 @@ export default function EmailSettingsPage() {
{/* Registry-driven so each field shows the "Using env fallback / - port / global / default" badge inline — admins can tell at a + port / global / default" badge inline - admins can tell at a glance which fields are coming from .env vs. UI overrides. */} + diff --git a/src/app/(dashboard)/[portSlug]/admin/errors/[requestId]/page.tsx b/src/app/(dashboard)/[portSlug]/admin/errors/[requestId]/page.tsx index b169f80a..90c1682c 100644 --- a/src/app/(dashboard)/[portSlug]/admin/errors/[requestId]/page.tsx +++ b/src/app/(dashboard)/[portSlug]/admin/errors/[requestId]/page.tsx @@ -163,11 +163,11 @@ export default function ErrorEventDetailPage() { - + - - + +
@@ -176,11 +176,11 @@ export default function ErrorEventDetailPage() { Error - +

Message

- {event.errorMessage ?? '—'} + {event.errorMessage ?? '-'}

{event.errorStack && ( @@ -240,7 +240,7 @@ function KV({ label, value, mono }: { label: string; value: string | null; mono? return (

{label}

-

{value ?? '—'}

+

{value ?? '-'}

); } diff --git a/src/app/(dashboard)/[portSlug]/admin/errors/codes/page.tsx b/src/app/(dashboard)/[portSlug]/admin/errors/codes/page.tsx index 3c514039..2ad5db3c 100644 --- a/src/app/(dashboard)/[portSlug]/admin/errors/codes/page.tsx +++ b/src/app/(dashboard)/[portSlug]/admin/errors/codes/page.tsx @@ -20,7 +20,7 @@ import { ERROR_CODES } from '@/lib/error-codes'; * plain-language meaning + status code without leaving the app. * * Pulls directly from `src/lib/error-codes.ts` so it stays in sync - * automatically — adding an entry to the registry adds a row here. + * automatically - adding an entry to the registry adds a row here. */ export default function ErrorCodeReferencePage() { const params = useParams<{ portSlug: string }>(); @@ -39,7 +39,7 @@ export default function ErrorCodeReferencePage() { }, [search]); // Group by domain prefix (the part before the first underscore) so - // the table reads naturally — Expenses, Berths, Storage, etc. + // the table reads naturally - Expenses, Berths, Storage, etc. const grouped = useMemo(() => { const groups = new Map(); for (const entry of entries) { diff --git a/src/app/(dashboard)/[portSlug]/admin/ocr/page.tsx b/src/app/(dashboard)/[portSlug]/admin/ocr/page.tsx index c77238bd..cf16ecd4 100644 --- a/src/app/(dashboard)/[portSlug]/admin/ocr/page.tsx +++ b/src/app/(dashboard)/[portSlug]/admin/ocr/page.tsx @@ -1,5 +1,19 @@ -import { OcrSettingsForm } from '@/components/admin/ocr-settings-form'; +import { redirect } from 'next/navigation'; -export default function OcrSettingsPage() { - return ; +/** + * Legacy route. OCR settings now live on the consolidated AI panel at + * `/admin/ai` (the same `` is mounted there alongside + * the master AI switch + provider credentials). Kept as a redirect-only + * page so any bookmarks / docs / deep links land on the right surface. + * + * Slated for full removal once the 2026-05-22 admin IA migration has + * had a quarter to bed in. + */ +export default async function OcrLegacyRedirectPage({ + params, +}: { + params: Promise<{ portSlug: string }>; +}) { + const { portSlug } = await params; + redirect(`/${portSlug}/admin/ai`); } diff --git a/src/app/(dashboard)/[portSlug]/admin/pipeline-rules/page.tsx b/src/app/(dashboard)/[portSlug]/admin/pipeline-rules/page.tsx index b0d07bf4..94ab14ff 100644 --- a/src/app/(dashboard)/[portSlug]/admin/pipeline-rules/page.tsx +++ b/src/app/(dashboard)/[portSlug]/admin/pipeline-rules/page.tsx @@ -85,7 +85,7 @@ export default function PipelineRulesPage() { }); // Hydrate the local form once the server-side state arrives. We treat - // missing keys as the registered default — the page's persisted JSON + // missing keys as the registered default - the page's persisted JSON // doesn't have to enumerate every trigger, just the overrides. useEffect(() => { const persisted = data?.data?.values?.stage_advance_rules?.value; diff --git a/src/app/(dashboard)/[portSlug]/admin/reports/page.tsx b/src/app/(dashboard)/[portSlug]/admin/reports/page.tsx index 201f9a7c..22de0853 100644 --- a/src/app/(dashboard)/[portSlug]/admin/reports/page.tsx +++ b/src/app/(dashboard)/[portSlug]/admin/reports/page.tsx @@ -1,5 +1,21 @@ -import { ReportsDashboard } from '@/components/admin/reports-dashboard'; +import { redirect } from 'next/navigation'; -export default function AdminReportsPage() { - return ; +/** + * 2026-05-22: `/admin/reports` deleted. The page rendered three cards + * - Pipeline funnel, Berth occupancy, and a KPI grid - all of which + * are already covered by the main Dashboard widgets (`pipeline_funnel`, + * `occupancy_timeline`, `kpi_*`). Redirecting to the dashboard so any + * lingering bookmarks land somewhere coherent. + * + * The `` component file lives on in the repo for now + * pending a follow-up sweep - once we confirm no other surface mounts + * it, the component + its data hook can be removed too. + */ +export default async function ReportsLegacyRedirectPage({ + params, +}: { + params: Promise<{ portSlug: string }>; +}) { + const { portSlug } = await params; + redirect(`/${portSlug}/dashboard`); } diff --git a/src/app/(dashboard)/[portSlug]/admin/residential-stages/page.tsx b/src/app/(dashboard)/[portSlug]/admin/residential-stages/page.tsx index 112c774d..7d99c43a 100644 --- a/src/app/(dashboard)/[portSlug]/admin/residential-stages/page.tsx +++ b/src/app/(dashboard)/[portSlug]/admin/residential-stages/page.tsx @@ -12,7 +12,7 @@ export default function ResidentialStagesPage() { /> - {/* Partner forwarding — sits on the same admin page so all + {/* Partner forwarding - sits on the same admin page so all residential-only port settings live in one place. Reps still see every inquiry in the CRM; this is an outbound courtesy notification for the partner who handles residential leads. */} diff --git a/src/app/(dashboard)/[portSlug]/admin/templates/[id]/editor/page.tsx b/src/app/(dashboard)/[portSlug]/admin/templates/[id]/editor/page.tsx index f6340e02..78608c38 100644 --- a/src/app/(dashboard)/[portSlug]/admin/templates/[id]/editor/page.tsx +++ b/src/app/(dashboard)/[portSlug]/admin/templates/[id]/editor/page.tsx @@ -1,7 +1,7 @@ import { TemplateEditor } from '@/components/admin/templates/template-editor'; /** - * Phase 7.1 — PDF template editor (read + place markers). + * Phase 7.1 - PDF template editor (read + place markers). * * Renders the source PDF for the selected template and lets the admin * drop merge-field markers by clicking on the page. Persists the marker diff --git a/src/app/(dashboard)/[portSlug]/admin/users/page.tsx b/src/app/(dashboard)/[portSlug]/admin/users/page.tsx index 32d0ac77..46c48161 100644 --- a/src/app/(dashboard)/[portSlug]/admin/users/page.tsx +++ b/src/app/(dashboard)/[portSlug]/admin/users/page.tsx @@ -4,7 +4,7 @@ import { PageHeader } from '@/components/shared/page-header'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; /** - * "People with access" surface — covers BOTH currently-active CRM users + * "People with access" surface - covers BOTH currently-active CRM users * and pending invitations. Previously these lived on separate routes * (/admin/users + /admin/invitations); merged 2026-05-21 so admins land * on one page and tab between states. The standalone /admin/invitations diff --git a/src/app/(dashboard)/[portSlug]/admin/website-analytics/page.tsx b/src/app/(dashboard)/[portSlug]/admin/website-analytics/page.tsx index d84f400e..af751ed1 100644 --- a/src/app/(dashboard)/[portSlug]/admin/website-analytics/page.tsx +++ b/src/app/(dashboard)/[portSlug]/admin/website-analytics/page.tsx @@ -50,15 +50,15 @@ const FIELDS: SettingFieldDef[] = [ }, { key: 'umami_api_token', - label: 'API key (Umami Cloud only — optional)', + label: 'API key (Umami Cloud only - optional)', description: - 'Only fill this if you use Umami Cloud, which uses a long-lived API key instead of username/password. Leave blank for self-hosted installs — the username + password above are used instead. Stored AES-256-GCM at rest.', + 'Only fill this if you use Umami Cloud, which uses a long-lived API key instead of username/password. Leave blank for self-hosted installs - the username + password above are used instead. Stored AES-256-GCM at rest.', type: 'password', defaultValue: '', }, ]; -// Tracking-pixel kill switch — opt-in per port. When enabled, outbound +// Tracking-pixel kill switch - opt-in per port. When enabled, outbound // sales sends embed a 1×1 pixel pointing at /api/public/email-pixel that // records opens to `document_send_opens` and cross-posts to Umami. const TRACKING_FIELDS: SettingFieldDef[] = [ @@ -66,7 +66,7 @@ const TRACKING_FIELDS: SettingFieldDef[] = [ key: 'email_open_tracking_enabled', label: 'Track email opens', description: - 'Embeds an invisible 1×1 tracking pixel in outbound sales emails. Each open is recorded in the CRM and cross-posted to Umami as an "email-opened" event. Apple Mail privacy proxy will over-count; clients that block images will under-count — standard email-tracking caveats apply.', + 'Embeds an invisible 1×1 tracking pixel in outbound sales emails. Each open is recorded in the CRM and cross-posted to Umami as an "email-opened" event. Apple Mail privacy proxy will over-count; clients that block images will under-count - standard email-tracking caveats apply.', type: 'boolean', defaultValue: false, }, diff --git a/src/app/(dashboard)/[portSlug]/alerts/page.tsx b/src/app/(dashboard)/[portSlug]/alerts/page.tsx index 71b033e0..39a71997 100644 --- a/src/app/(dashboard)/[portSlug]/alerts/page.tsx +++ b/src/app/(dashboard)/[portSlug]/alerts/page.tsx @@ -1,6 +1,6 @@ import { redirect } from 'next/navigation'; -// Legacy /alerts route — merged into /inbox in 2026-05-11. The hash +// Legacy /alerts route - merged into /inbox in 2026-05-11. The hash // scrolls + expands the Alerts section on the merged page, so old // bookmarks land in the right spot. export default async function AlertsRedirect({ diff --git a/src/app/(dashboard)/[portSlug]/expenses/scan/page.tsx b/src/app/(dashboard)/[portSlug]/expenses/scan/page.tsx index 228f70cf..8b1007c0 100644 --- a/src/app/(dashboard)/[portSlug]/expenses/scan/page.tsx +++ b/src/app/(dashboard)/[portSlug]/expenses/scan/page.tsx @@ -45,7 +45,7 @@ export default function ScanReceiptPage() { const [previewUrl, setPreviewUrl] = useState(null); // After OCR succeeds we also upload the receipt to /api/v1/files/upload // so the expense links to the actual image. The legacy scanner skipped - // this step and saved expenses without their receipt — which silently + // this step and saved expenses without their receipt - which silently // disqualified them from parent-company reimbursement (the warning the // PDF export now surfaces). const [uploadedFile, setUploadedFile] = useState(null); @@ -365,7 +365,7 @@ export default function ScanReceiptPage() { disabled={ saveMutation.isPending || !amount || - // Block save while the receipt upload is still in flight — + // Block save while the receipt upload is still in flight - // otherwise the rep can hit Save before the storage round // trip finishes and the expense lands without `receiptFileIds`, // silently re-creating the legacy receipt-loss bug. diff --git a/src/app/(dashboard)/[portSlug]/reminders/page.tsx b/src/app/(dashboard)/[portSlug]/reminders/page.tsx index 977e5e53..3678576c 100644 --- a/src/app/(dashboard)/[portSlug]/reminders/page.tsx +++ b/src/app/(dashboard)/[portSlug]/reminders/page.tsx @@ -1,6 +1,6 @@ import { redirect } from 'next/navigation'; -// Legacy /reminders route — merged into /inbox in 2026-05-11. The hash +// Legacy /reminders route - merged into /inbox in 2026-05-11. The hash // scrolls + expands the Reminders section on the merged page. export default async function RemindersRedirect({ params, diff --git a/src/app/(dashboard)/[portSlug]/residential/page.tsx b/src/app/(dashboard)/[portSlug]/residential/page.tsx index e3ea7480..6c128270 100644 --- a/src/app/(dashboard)/[portSlug]/residential/page.tsx +++ b/src/app/(dashboard)/[portSlug]/residential/page.tsx @@ -1,7 +1,7 @@ import { redirect } from 'next/navigation'; /** - * //residential is a namespace segment — the actual landing is + * //residential is a namespace segment - the actual landing is * /residential/clients. Without a page.tsx here, the breadcrumb's * "Residential" link 404s. Server-redirect to the Clients sub-page so * the link works as a useful shortcut. diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index 7287e2a6..fb01c4cf 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -38,7 +38,7 @@ export default async function DashboardLayout({ children }: { children: React.Re : portRoles.map((pr) => pr.port); // Prefer a previously-resolved tier from the client's cookie so the - // server renders the matching shell on first paint — eliminates the + // server renders the matching shell on first paint - eliminates the // mobile↔desktop chrome flicker that happens when UA-based classification // disagrees with the actual viewport (e.g. macOS Safari with the // window dragged below 1024). AppShell writes the cookie after the @@ -58,7 +58,7 @@ export default async function DashboardLayout({ children }: { children: React.Re // Per-port logo map for the sidebar. Resolved server-side so the // sidebar can swap brand on port switch without an extra round-trip. - // Falls back to null per port when no logo is configured — the + // Falls back to null per port when no logo is configured - the // sidebar surfaces nothing rather than leaking a generic placeholder. const portBrandingEntries = await Promise.all( ports.map(async (p) => { @@ -85,7 +85,7 @@ export default async function DashboardLayout({ children }: { children: React.Re flag in prod) so the banner is dev/staging-only. */} {/* #26: AppShell mounts ONE responsive tree (desktop OR - * mobile) per render — never both — so pages don't pay the + * mobile) per render - never both - so pages don't pay the * double-state, double-fetch, double-Tabs-provider tax. */} ({ diff --git a/src/app/(portal)/portal/interests/page.tsx b/src/app/(portal)/portal/interests/page.tsx index c68f63f6..d7e0b9ae 100644 --- a/src/app/(portal)/portal/interests/page.tsx +++ b/src/app/(portal)/portal/interests/page.tsx @@ -85,7 +85,7 @@ export default async function PortalInterestsPage() { )} {/* leadCategory ("hot_lead" / "qualified_lead" / etc.) - is a staff classification — never render to clients. + is a staff classification - never render to clients. Privacy + optics: we shouldn't be telling the prospect they're a "hot lead". */}
diff --git a/src/app/(portal)/portal/login/page.tsx b/src/app/(portal)/portal/login/page.tsx index 706abd6e..d004f243 100644 --- a/src/app/(portal)/portal/login/page.tsx +++ b/src/app/(portal)/portal/login/page.tsx @@ -14,7 +14,7 @@ import { BrandedAuthShell } from '@/components/shared/branded-auth-shell'; * Validate the `?next=` post-login redirect target. auth-flow-auditor M10: * an unvalidated `next` lets `/portal/login?next=https://evil.example` * navigate cross-site after sign-in. Only allow same-origin paths - * scoped to the portal surface — anything else falls back to the + * scoped to the portal surface - anything else falls back to the * dashboard. */ function safeNextPath(raw: string | null): string { diff --git a/src/app/api/auth/[...all]/route.ts b/src/app/api/auth/[...all]/route.ts index 1c1ac224..538fdd08 100644 --- a/src/app/api/auth/[...all]/route.ts +++ b/src/app/api/auth/[...all]/route.ts @@ -10,7 +10,7 @@ const upstream = toNextJsHandler(auth); /** * Wrap better-auth's `[...all]` handler so we can stamp the audit log on * authentication events. Better-auth itself doesn't fire any callback we - * can hook on sign-in / sign-out / failed-login — we inspect the route + * can hook on sign-in / sign-out / failed-login - we inspect the route * + response status after the upstream handler finishes. * * Successful sign-in → action 'login' (severity info) diff --git a/src/app/api/auth/set-password/route.ts b/src/app/api/auth/set-password/route.ts index 1978dc49..a6631aca 100644 --- a/src/app/api/auth/set-password/route.ts +++ b/src/app/api/auth/set-password/route.ts @@ -12,7 +12,7 @@ const bodySchema = z.object({ }); export async function POST(req: NextRequest): Promise { - // 10/hour/IP — bounds brute-force against either token store. + // 10/hour/IP - bounds brute-force against either token store. const limited = await enforcePublicRateLimit(req, 'portalToken'); if (limited) return limited; @@ -26,7 +26,7 @@ export async function POST(req: NextRequest): Promise { // `auth.api.resetPassword` (rotates the password on an existing // user). // Try the CRM-invite path first. If the token isn't in that table - // (NotFoundError), fall through to better-auth — these are mutually + // (NotFoundError), fall through to better-auth - these are mutually // exclusive token spaces, so at most one will accept it. try { const result = await consumeCrmInvite({ token, password }); diff --git a/src/app/api/auth/sign-in-by-identifier/route.ts b/src/app/api/auth/sign-in-by-identifier/route.ts index ced7492a..710a06e3 100644 --- a/src/app/api/auth/sign-in-by-identifier/route.ts +++ b/src/app/api/auth/sign-in-by-identifier/route.ts @@ -41,7 +41,7 @@ async function resolveToEmail(identifier: string): Promise { } export async function POST(req: NextRequest) { - // Rate-limit on IP — same 5/15min bucket the sign-in endpoint uses. + // Rate-limit on IP - same 5/15min bucket the sign-in endpoint uses. const ip = clientIp(req); const rl = await checkRateLimit(ip, rateLimiters.auth); if (!rl.allowed) { diff --git a/src/app/api/portal/auth/activate/route.ts b/src/app/api/portal/auth/activate/route.ts index 4037da47..295ee389 100644 --- a/src/app/api/portal/auth/activate/route.ts +++ b/src/app/api/portal/auth/activate/route.ts @@ -11,7 +11,7 @@ const bodySchema = z.object({ }); export async function POST(req: NextRequest): Promise { - // 10/hour/IP — bounds brute-force against the 32-byte activation token. + // 10/hour/IP - bounds brute-force against the 32-byte activation token. const limited = await enforcePublicRateLimit(req, 'portalToken'); if (limited) return limited; diff --git a/src/app/api/portal/auth/forgot-password/route.ts b/src/app/api/portal/auth/forgot-password/route.ts index ed404086..7adfdac5 100644 --- a/src/app/api/portal/auth/forgot-password/route.ts +++ b/src/app/api/portal/auth/forgot-password/route.ts @@ -9,7 +9,7 @@ import { requestPasswordReset } from '@/lib/services/portal-auth.service'; const bodySchema = z.object({ email: z.string().email() }); export async function POST(req: NextRequest): Promise { - // 3/hour/IP — tightest of the portal limiters because each successful + // 3/hour/IP - tightest of the portal limiters because each successful // call sends an outbound email and timing differences here are the // primary email-enumeration vector. const limited = await enforcePublicRateLimit(req, 'portalForgot'); diff --git a/src/app/api/portal/auth/reset-password/route.ts b/src/app/api/portal/auth/reset-password/route.ts index 107d0aa3..ef1b370c 100644 --- a/src/app/api/portal/auth/reset-password/route.ts +++ b/src/app/api/portal/auth/reset-password/route.ts @@ -11,7 +11,7 @@ const bodySchema = z.object({ }); export async function POST(req: NextRequest): Promise { - // 10/hour/IP — bounds brute-force against the 32-byte reset token. + // 10/hour/IP - bounds brute-force against the 32-byte reset token. const limited = await enforcePublicRateLimit(req, 'portalToken'); if (limited) return limited; diff --git a/src/app/api/portal/auth/sign-in/route.ts b/src/app/api/portal/auth/sign-in/route.ts index 56850f8b..c1eb4f18 100644 --- a/src/app/api/portal/auth/sign-in/route.ts +++ b/src/app/api/portal/auth/sign-in/route.ts @@ -27,7 +27,7 @@ export async function POST(req: NextRequest): Promise { } // Per-(ip,email) bucket: 5 attempts / 15min. Keyed on email-lowercase so - // the limiter is per-account-per-IP, not just per-IP — a NATed network + // the limiter is per-account-per-IP, not just per-IP - a NATed network // shouldn't be able to lock a single victim by burning their bucket. const limited = await enforcePublicRateLimit( req, diff --git a/src/app/api/public/berths/route.ts b/src/app/api/public/berths/route.ts index b0ecb1ff..ef57a9ac 100644 --- a/src/app/api/public/berths/route.ts +++ b/src/app/api/public/berths/route.ts @@ -72,7 +72,7 @@ export async function GET(request: Request): Promise { ); } - // 1. Active berths for the port — retired moorings are hidden via + // 1. Active berths for the port - retired moorings are hidden via // the archived_at soft-delete column (migration 0065). const berthRows = await db .select() diff --git a/src/app/api/public/email-pixel/[sendId]/route.ts b/src/app/api/public/email-pixel/[sendId]/route.ts index 9be3ecbb..eb623cc9 100644 --- a/src/app/api/public/email-pixel/[sendId]/route.ts +++ b/src/app/api/public/email-pixel/[sendId]/route.ts @@ -36,7 +36,7 @@ function gifResponse(): NextResponse { headers: { 'Content-Type': 'image/gif', 'Content-Length': String(TRANSPARENT_GIF.length), - // Tell every upstream cache to keep its hands off — we count opens + // Tell every upstream cache to keep its hands off - we count opens // on the FETCH itself, so any cached response is a missed open. 'Cache-Control': 'no-store, no-cache, must-revalidate, private', Pragma: 'no-cache', @@ -62,7 +62,7 @@ export async function GET( const userAgent = req.headers.get('user-agent'); const referer = req.headers.get('referer'); - // Best-effort write — never block the pixel response on a slow DB. + // Best-effort write - never block the pixel response on a slow DB. // The pixel must return promptly so email clients render normally. db.insert(documentSendOpens) .values({ @@ -85,7 +85,7 @@ export async function GET( }); // Cross-post to Umami so the marketing funnel includes opens. Don't - // await — fire-and-forget so the pixel response stays fast. + // await - fire-and-forget so the pixel response stays fast. trackEvent( sendRow.portId, 'email-opened', diff --git a/src/app/api/public/files/[id]/route.ts b/src/app/api/public/files/[id]/route.ts index 37794384..9b3fd076 100644 --- a/src/app/api/public/files/[id]/route.ts +++ b/src/app/api/public/files/[id]/route.ts @@ -8,7 +8,7 @@ import { errorResponse, NotFoundError } from '@/lib/errors'; /** * Public, unauthenticated stream-by-id for branding assets only. Used by - * outbound email templates and the branded auth shell — surfaces where + * outbound email templates and the branded auth shell - surfaces where * the consumer can't authenticate (an inbox image fetch has no session * cookie). The `category = 'branding'` gate ensures only assets the * admin explicitly uploaded as port branding leak through this surface; diff --git a/src/app/api/public/health/route.ts b/src/app/api/public/health/route.ts index dd717ebf..8a2d0a87 100644 --- a/src/app/api/public/health/route.ts +++ b/src/app/api/public/health/route.ts @@ -20,7 +20,7 @@ import { logger } from '@/lib/logger'; * the marketing site uses on startup AND what k8s readiness * probes should hit, because it returns 503 on hard dep failures. * - * The dep checks (DB SELECT 1, Redis PING) run on every request — they + * The dep checks (DB SELECT 1, Redis PING) run on every request - they * are <5ms each. If either fails, the response is 503 so a load balancer * stops routing to this instance. */ diff --git a/src/app/api/public/interests/route.ts b/src/app/api/public/interests/route.ts index 7f27161d..474540cb 100644 --- a/src/app/api/public/interests/route.ts +++ b/src/app/api/public/interests/route.ts @@ -24,7 +24,7 @@ async function gateRateLimit(ip: string): Promise { } } -// POST /api/public/interests — unauthenticated public interest registration. +// POST /api/public/interests - unauthenticated public interest registration. // The transactional trio creation (client + yacht + interest, plus optional // company + membership) lives in `createPublicInterest()` so it's testable // without an HTTP fixture. This handler is the thin HTTP shell: rate-limit, diff --git a/src/app/api/public/supplemental-info/[token]/route.ts b/src/app/api/public/supplemental-info/[token]/route.ts index d511ecb4..2a33daa3 100644 --- a/src/app/api/public/supplemental-info/[token]/route.ts +++ b/src/app/api/public/supplemental-info/[token]/route.ts @@ -5,7 +5,7 @@ import { loadByToken, applySubmission } from '@/lib/services/supplemental-forms. import { errorResponse } from '@/lib/errors'; /** - * Public — no auth. Loads the prefill data for the form. The token in + * Public - no auth. Loads the prefill data for the form. The token in * the URL is the only credential; rejects expired / unknown tokens with * 404 (deliberately conflated to avoid leaking which tokens exist). */ diff --git a/src/app/api/public/website-inquiries/route.ts b/src/app/api/public/website-inquiries/route.ts index 3e4924f0..525fb8e7 100644 --- a/src/app/api/public/website-inquiries/route.ts +++ b/src/app/api/public/website-inquiries/route.ts @@ -92,7 +92,7 @@ export async function POST(req: NextRequest) { return errorResponse(new RateLimitError(retryAfter)); } - // Parse + validate body. Reject anything that doesn't conform — the + // Parse + validate body. Reject anything that doesn't conform - the // website is a known caller; a malformed payload signals tampering. let parsed; try { diff --git a/src/app/api/ready/route.ts b/src/app/api/ready/route.ts index 34d826c1..8905a101 100644 --- a/src/app/api/ready/route.ts +++ b/src/app/api/ready/route.ts @@ -20,7 +20,7 @@ interface ReadyResponse { } /** - * Readiness probe — verifies that every backing service this process + * Readiness probe - verifies that every backing service this process * needs to serve traffic is reachable. A 503 should drop the pod from the * load balancer until the next probe succeeds; it should not trigger a * pod restart (that's what `/api/health` is for). diff --git a/src/app/api/storage/[token]/route.ts b/src/app/api/storage/[token]/route.ts index cbc8a64a..360938b5 100644 --- a/src/app/api/storage/[token]/route.ts +++ b/src/app/api/storage/[token]/route.ts @@ -66,7 +66,7 @@ export async function GET( // Single-use enforcement. SET NX with a TTL pinned to the token's own // expiry so the dedup window never closes before the token does. Using // the body half of the token as the dedup key (signature included - // would also work but body is enough — a reused token has the same body). + // would also work but body is enough - a reused token has the same body). const replayKey = `storage:proxy:seen:${token.split('.')[0]}`; const remainingSeconds = Math.max( REPLAY_TTL_FLOOR_SECONDS, @@ -109,7 +109,7 @@ export async function GET( headers.set('Content-Type', payload.c ?? 'application/octet-stream'); headers.set('Content-Length', String(size)); if (payload.f) { - // RFC 5987 — quote the filename and provide a UTF-8 fallback. + // RFC 5987 - quote the filename and provide a UTF-8 fallback. const safe = payload.f.replace(/"/g, ''); headers.set( 'Content-Disposition', @@ -126,7 +126,7 @@ export async function GET( * Filesystem-backend upload proxy. The presigned URL minted by * `FilesystemBackend.presignUpload` points here. Without this handler the * browser-driven berth-PDF / brochure uploads would 405 in filesystem - * deployments — the entire pluggable-storage abstraction relied on the + * deployments - the entire pluggable-storage abstraction relied on the * GET-only counterpart for downloads. * * Same token-verify + single-use replay protection as GET, plus: @@ -186,7 +186,7 @@ export async function PUT( } // Read the body into a buffer with a hard cap. Filesystem deployments are - // small-tenant (single-node only — see FilesystemBackend boot guard) so + // small-tenant (single-node only - see FilesystemBackend boot guard) so // 50 MB ceiling fits comfortably in heap; no streaming needed. let buffer: Buffer; try { @@ -216,7 +216,7 @@ export async function PUT( } // Magic-byte gate: when the token was minted with `c=application/pdf` - // (the only consumer today — berth PDFs + brochures), refuse anything + // (the only consumer today - berth PDFs + brochures), refuse anything // that isn't actually a PDF. Mirrors the post-upload check in // berth-pdf.service.ts so the two paths behave identically. if (payload.c === 'application/pdf' && !isPdfMagic(buffer)) { diff --git a/src/app/api/v1/admin/audit/export/route.ts b/src/app/api/v1/admin/audit/export/route.ts index 2606cee4..2a1c2406 100644 --- a/src/app/api/v1/admin/audit/export/route.ts +++ b/src/app/api/v1/admin/audit/export/route.ts @@ -5,7 +5,7 @@ import { errorResponse } from '@/lib/errors'; import { searchAuditLogs } from '@/lib/services/audit-search.service'; /** - * M-AU03 — CSV export of audit log search results. + * M-AU03 - CSV export of audit log search results. * * Accepts the same query-string filters as `GET /api/v1/admin/audit` * (q, userId, action, entityType, entityId, severity, source, from, to) diff --git a/src/app/api/v1/admin/branding/email-preview/route.ts b/src/app/api/v1/admin/branding/email-preview/route.ts index 0270055d..fe2a098e 100644 --- a/src/app/api/v1/admin/branding/email-preview/route.ts +++ b/src/app/api/v1/admin/branding/email-preview/route.ts @@ -8,7 +8,7 @@ import { getPortBrandingConfig } from '@/lib/services/port-config'; import { renderShell } from '@/lib/email/shell'; import { sendEmail } from '@/lib/email'; -const SAMPLE_SUBJECT_SUFFIX = ' — branding preview'; +const SAMPLE_SUBJECT_SUFFIX = ' - branding preview'; function buildSampleEmail(branding: { logoUrl: string | null; @@ -51,7 +51,7 @@ function buildSampleEmail(branding: { return { subject, html }; } -// GET — return the sample email rendered with the current port's branding. +// GET - return the sample email rendered with the current port's branding. export const GET = withAuth( withPermission('admin', 'manage_settings', async (_req, ctx) => { try { @@ -69,7 +69,7 @@ const sendTestSchema = z.object({ recipient: z.string().email('Enter a valid email address'), }); -// POST — actually send the sample email to a single recipient. +// POST - actually send the sample email to a single recipient. export const POST = withAuth( withPermission('admin', 'manage_settings', async (req, ctx) => { try { diff --git a/src/app/api/v1/admin/branding/logo/route.ts b/src/app/api/v1/admin/branding/logo/route.ts index ec640d6c..5715d11d 100644 --- a/src/app/api/v1/admin/branding/logo/route.ts +++ b/src/app/api/v1/admin/branding/logo/route.ts @@ -49,7 +49,7 @@ export const GET = withAuth( if (!file) { return NextResponse.json({ data: null }); } - // Path-only — the admin UI renders this as `` and the + // Path-only - the admin UI renders this as `` and the // browser resolves against the current origin. Stays valid whether // the admin opens the page from localhost or a LAN IP. return NextResponse.json({ diff --git a/src/app/api/v1/admin/brochures/[id]/versions/route.ts b/src/app/api/v1/admin/brochures/[id]/versions/route.ts index 027dbddd..7d54f00d 100644 --- a/src/app/api/v1/admin/brochures/[id]/versions/route.ts +++ b/src/app/api/v1/admin/brochures/[id]/versions/route.ts @@ -11,8 +11,8 @@ import { registerBrochureVersionSchema } from '@/lib/validators/brochures'; /** * Two-step upload (per §11.1): - * 1. GET (no body) — server returns a fresh storage key + presigned URL. - * 2. POST (metadata) — after the browser PUTs to the URL, register the + * 1. GET (no body) - server returns a fresh storage key + presigned URL. + * 2. POST (metadata) - after the browser PUTs to the URL, register the * version row server-side. * * Direct-to-storage uploads bypass Next.js's body-size limit; the server @@ -47,7 +47,7 @@ export const GET = withAuth( ); // Storage keys generated by `generateBrochureStorageKey` look like -// `/brochures//.pdf`. Reject anything else — +// `/brochures//.pdf`. Reject anything else - // without this, an admin holding manage_settings on port A could ship a // foreign port's storage key (signed EOI bytes, another port's brochure) // and have registerBrochureVersion repoint THIS port's brochure version diff --git a/src/app/api/v1/admin/custom-fields/[fieldId]/route.ts b/src/app/api/v1/admin/custom-fields/[fieldId]/route.ts index 7b888a9c..28919258 100644 --- a/src/app/api/v1/admin/custom-fields/[fieldId]/route.ts +++ b/src/app/api/v1/admin/custom-fields/[fieldId]/route.ts @@ -13,7 +13,7 @@ export const PATCH = withAuth( // Read raw body before parsing so we can inspect `fieldType` // (the schema strips it; the service rejects any change). Using - // req.json() directly here is intentional — parseBody would lose + // req.json() directly here is intentional - parseBody would lose // the raw view we need for the mutation-attempt detection below. const body = (await req.json()) as Record; const data = updateFieldSchema.parse(body); diff --git a/src/app/api/v1/admin/dashboard-stats/route.ts b/src/app/api/v1/admin/dashboard-stats/route.ts index bec9e32f..89077934 100644 --- a/src/app/api/v1/admin/dashboard-stats/route.ts +++ b/src/app/api/v1/admin/dashboard-stats/route.ts @@ -87,7 +87,7 @@ export const GET = withAuth( ), ), // "completed30d" = interests that hit a terminal outcome in - // the last 30 days (any outcome — won, lost, or cancelled). + // the last 30 days (any outcome - won, lost, or cancelled). // Use `outcome_at` not `updated_at` so unrelated edits to a // long-closed deal don't drag it back into the window. db diff --git a/src/app/api/v1/admin/documenso/sync-template/[templateId]/route.ts b/src/app/api/v1/admin/documenso/sync-template/[templateId]/route.ts index ad944fba..ca110232 100644 --- a/src/app/api/v1/admin/documenso/sync-template/[templateId]/route.ts +++ b/src/app/api/v1/admin/documenso/sync-template/[templateId]/route.ts @@ -13,7 +13,7 @@ import { syncDocumensoTemplate } from '@/lib/services/documenso-template-sync.se * field name→ID map at documenso_eoi_field_map for v2 prefillFields usage. * * Accepts either a numeric template ID (`123`) or a Documenso 2.x envelope - * ID (`envelope_xxxxxxxx`) — the latter is what the Documenso UI URL shows, + * ID (`envelope_xxxxxxxx`) - the latter is what the Documenso UI URL shows, * so paste-from-URL works out of the box on v2 instances. Envelope IDs get * resolved to their numeric template id via `findTemplateIdByEnvelopeId` * before the sync runs. @@ -30,7 +30,7 @@ export const POST = withAuth( if (/^envelope_/.test(raw)) { const resolved = await findTemplateIdByEnvelopeId(raw, ctx.portId); if (!resolved) { - throw new NotFoundError(`Template "${raw}" — no matching envelopeId found`); + throw new NotFoundError(`Template "${raw}" - no matching envelopeId found`); } templateId = resolved; } else { diff --git a/src/app/api/v1/admin/documenso/sync-template/report/route.ts b/src/app/api/v1/admin/documenso/sync-template/report/route.ts index ddc3beda..1cfafcc9 100644 --- a/src/app/api/v1/admin/documenso/sync-template/report/route.ts +++ b/src/app/api/v1/admin/documenso/sync-template/report/route.ts @@ -11,7 +11,7 @@ import { getEoiTemplateSyncReport } from '@/lib/services/documenso-template-sync * so the admin panel's status box survives a page reload without re-hitting * Documenso. Returns `{ data: null }` when no sync has run for this port. * - * Admin-only via `admin.manage_settings` — same gate as the sync write + * Admin-only via `admin.manage_settings` - same gate as the sync write * endpoint, since the report contains template recipient identities and * AcroForm field names that aren't OK to leak outside the admin surface. */ diff --git a/src/app/api/v1/admin/documenso/templates/route.ts b/src/app/api/v1/admin/documenso/templates/route.ts index ef581ff7..23fb1c8b 100644 --- a/src/app/api/v1/admin/documenso/templates/route.ts +++ b/src/app/api/v1/admin/documenso/templates/route.ts @@ -9,7 +9,7 @@ import { listTemplates } from '@/lib/services/documenso-client'; * * Lists every Documenso template visible to the configured API key * for the calling port. Drives the "Documenso-first templates" admin - * picker (R62) — reps see real template names instead of having to + * picker (R62) - reps see real template names instead of having to * type numeric IDs. * * Gated on `admin.manage_settings` since the data exposed is essentially diff --git a/src/app/api/v1/admin/email-templates/route.ts b/src/app/api/v1/admin/email-templates/route.ts index e23caad7..b4039b5d 100644 --- a/src/app/api/v1/admin/email-templates/route.ts +++ b/src/app/api/v1/admin/email-templates/route.ts @@ -76,7 +76,7 @@ export const PUT = withAuth( userAgent: ctx.userAgent, }; if (body.subject === null || body.subject === '') { - // Clear the override (and only at the per-port level — never touch global). + // Clear the override (and only at the per-port level - never touch global). await deleteSetting(settingKey, ctx.portId, meta); } else { await upsertSetting(settingKey, body.subject, ctx.portId, meta); diff --git a/src/app/api/v1/admin/email/sales-config/route.ts b/src/app/api/v1/admin/email/sales-config/route.ts index 4ddc45b6..a8fa63c1 100644 --- a/src/app/api/v1/admin/email/sales-config/route.ts +++ b/src/app/api/v1/admin/email/sales-config/route.ts @@ -16,14 +16,14 @@ import { updateSalesEmailConfigSchema } from '@/lib/validators/sales-email-confi * GET /api/v1/admin/email/sales-config * * Returns the redacted view of the sales-email config. Per §14.10 - * reps can't see the decrypted password — the response only carries + * reps can't see the decrypted password - the response only carries * `*PassIsSet` boolean markers via `redactSalesConfigForResponse`. * * Today this endpoint is admin-only because it's consumed only by the * admin UI panel (`src/components/admin/sales-email-config-card.tsx`). * A future rep-facing surface that needs the from-address or body * templates can split into a separate `/email/sales-config/preview` - * endpoint scoped to `email.view` — keeping the admin endpoint locked + * endpoint scoped to `email.view` - keeping the admin endpoint locked * to `manage_settings` avoids accidentally widening secret-adjacent * surfaces (e.g. the SMTP host name itself can be a leak vector). */ diff --git a/src/app/api/v1/admin/email/sales-config/test-smtp/route.ts b/src/app/api/v1/admin/email/sales-config/test-smtp/route.ts index 5b5a6e38..d7add43d 100644 --- a/src/app/api/v1/admin/email/sales-config/test-smtp/route.ts +++ b/src/app/api/v1/admin/email/sales-config/test-smtp/route.ts @@ -19,7 +19,7 @@ const bodySchema = z.object({ * Sends a small text/HTML message to either the body-supplied `to` or * (default) the admin's own email so they get the verification in their * inbox. Returns { ok: true } on success or { ok: false, error } on - * failure — the admin UI rates accordingly. + * failure - the admin UI rates accordingly. */ export const POST = withAuth( withPermission('admin', 'manage_settings', async (req, ctx) => { @@ -28,13 +28,13 @@ export const POST = withAuth( const recipient = body.to ?? ctx.user.email; if (!recipient) { return NextResponse.json( - { data: { ok: false, error: 'No recipient resolved — sign-in email is empty' } }, + { data: { ok: false, error: 'No recipient resolved - sign-in email is empty' } }, { status: 200 }, ); } try { - const subject = `Port Nimara CRM — SMTP test (${new Date().toLocaleTimeString()})`; + const subject = `Port Nimara CRM - SMTP test (${new Date().toLocaleTimeString()})`; const html = `

Hello,

This is a test message sent from your CRM's Sales SMTP configuration. If you received this, your SMTP credentials work.

Timestamp: ${new Date().toISOString()}

`; const text = `This is a test message sent from your CRM's Sales SMTP configuration. If you received this, your SMTP credentials work.\n\nTimestamp: ${new Date().toISOString()}`; await sendEmail(recipient, subject, html, undefined, text, ctx.portId); diff --git a/src/app/api/v1/admin/email/test-send/route.ts b/src/app/api/v1/admin/email/test-send/route.ts index 0d040cec..97d3b841 100644 --- a/src/app/api/v1/admin/email/test-send/route.ts +++ b/src/app/api/v1/admin/email/test-send/route.ts @@ -21,7 +21,7 @@ const testSendSchema = z.object({ * - The branding one exercises the rendering pipeline + logo bytes. * * Surface SMTP errors to the caller directly (auth failure, ENOTFOUND, - * connection refused) — the whole point of the test is to see them + * connection refused) - the whole point of the test is to see them * inline in the admin UI. */ export const POST = withAuth( @@ -30,7 +30,7 @@ export const POST = withAuth( if (!ctx.portId) throw new ValidationError('No active port'); const { recipient } = await parseBody(req, testSendSchema); - const subject = 'CRM SMTP test — connection verified'; + const subject = 'CRM SMTP test - connection verified'; const html = `

SMTP test

@@ -39,11 +39,11 @@ export const POST = withAuth( are reaching ${recipient}.

- Sent from /admin/email — Port Nimara CRM + Sent from /admin/email - Port Nimara CRM

`; - const text = `SMTP test\n\nIf you're reading this, the SMTP credentials configured for this port are reaching ${recipient}.\n\nSent from /admin/email — Port Nimara CRM`; + const text = `SMTP test\n\nIf you're reading this, the SMTP credentials configured for this port are reaching ${recipient}.\n\nSent from /admin/email - Port Nimara CRM`; const info = await sendEmail(recipient, subject, html, undefined, text, ctx.portId); logger.info( diff --git a/src/app/api/v1/admin/email/test-template/route.ts b/src/app/api/v1/admin/email/test-template/route.ts new file mode 100644 index 00000000..3d5a211b --- /dev/null +++ b/src/app/api/v1/admin/email/test-template/route.ts @@ -0,0 +1,100 @@ +import { NextResponse } from 'next/server'; +import { eq } from 'drizzle-orm'; +import { z } from 'zod'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { db } from '@/lib/db'; +import { ports } from '@/lib/db/schema/ports'; +import { sendEmail } from '@/lib/email'; +import { findTestTemplate, TEST_TEMPLATES } from '@/lib/email/test-registry'; +import { errorResponse, NotFoundError, ValidationError } from '@/lib/errors'; + +const bodySchema = z.object({ + templateId: z.string().min(1), + recipient: z.string().email(), +}); + +/** + * GET - return the test-template registry (id + label + description) + * so the admin UI dropdown can render without duplicating the catalog + * client-side. + */ +export const GET = withAuth( + withPermission('admin', 'manage_settings', async () => { + try { + return NextResponse.json({ + data: TEST_TEMPLATES.map((t) => ({ + id: t.id, + label: t.label, + description: t.description, + })), + }); + } catch (error) { + return errorResponse(error); + } + }), +); + +/** + * POST - render the chosen template with realistic sample fixtures and + * fire it through the configured SMTP transport. Used by admins to + * preview each transactional template against a designated address + * without triggering the real upstream flow. + * + * Permission: `admin.manage_settings` - same gate as the existing + * SMTP test-send (the port's real From / SMTP credentials are used). + */ +export const POST = withAuth( + withPermission('admin', 'manage_settings', async (req, ctx) => { + try { + const body = await parseBody(req, bodySchema); + const template = findTestTemplate(body.templateId); + if (!template) { + throw new ValidationError(`Unknown templateId: ${body.templateId}`); + } + + // Resolve port branding context so the rendered email actually + // matches the admin's port (header logo, accent colour) instead of + // falling through to defaults. + const port = await db.query.ports.findFirst({ where: eq(ports.id, ctx.portId) }); + if (!port) throw new NotFoundError('Port'); + + // No publicUrl column on `ports` yet - synthesise a plausible URL + // from the slug so the sample renders with a "real-looking" base. + const portUrl = `https://${port.slug}.example`; + const rendered = await template.render({ + recipientName: 'Sample Recipient', + recipientEmail: body.recipient, + portName: port.name, + portUrl, + }); + + // Subject prefix makes it visually unambiguous in the recipient's + // inbox that this is a test - important because some of the + // templates (signing reminder, etc.) would otherwise look + // identical to a real production send. + const taggedSubject = `[TEST · ${template.label}] ${rendered.subject}`; + + const info = await sendEmail( + body.recipient, + taggedSubject, + rendered.html, + undefined, + rendered.text, + ctx.portId, + ); + + return NextResponse.json({ + data: { + templateId: template.id, + recipient: body.recipient, + subject: taggedSubject, + messageId: info.messageId ?? null, + }, + }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/admin/embedded-signing/test/route.ts b/src/app/api/v1/admin/embedded-signing/test/route.ts index c23e42ef..73933a8c 100644 --- a/src/app/api/v1/admin/embedded-signing/test/route.ts +++ b/src/app/api/v1/admin/embedded-signing/test/route.ts @@ -17,8 +17,8 @@ import { logger } from '@/lib/logger'; * get sent there from outbound emails. * * Two checks: - * 1. Bare host returns 2xx — the site is up. - * 2. `/sign/health` (or `/`) returns 2xx within 5s — soft probe; not + * 1. Bare host returns 2xx - the site is up. + * 2. `/sign/health` (or `/`) returns 2xx within 5s - soft probe; not * every marketing site exposes /sign/health, so we degrade to a * root probe when the dedicated path 404s. */ @@ -60,7 +60,7 @@ export const POST = withAuth( } }; - // Try root first — it's the most universal signal of "the site is + // Try root first - it's the most universal signal of "the site is // up." Then probe /sign/success which the post-signing redirect // typically points to, so admins can also catch a stale path. await probe('/'); diff --git a/src/app/api/v1/admin/error-events/[requestId]/route.ts b/src/app/api/v1/admin/error-events/[requestId]/route.ts index c7d6dd09..aee39f85 100644 --- a/src/app/api/v1/admin/error-events/[requestId]/route.ts +++ b/src/app/api/v1/admin/error-events/[requestId]/route.ts @@ -24,7 +24,7 @@ export const GET = withAuth( if (!event) throw new NotFoundError('Error event'); // Tenant scoping. A port_id of null on the row means the error - // fired pre-port-resolve (login page, public form, etc.) — those + // fired pre-port-resolve (login page, public form, etc.) - those // are visible to super admins only. if (!ctx.isSuperAdmin) { if (!event.portId || event.portId !== ctx.portId) { diff --git a/src/app/api/v1/admin/roles/[id]/route.ts b/src/app/api/v1/admin/roles/[id]/route.ts index 2dbe5d48..f54e2c5c 100644 --- a/src/app/api/v1/admin/roles/[id]/route.ts +++ b/src/app/api/v1/admin/roles/[id]/route.ts @@ -17,7 +17,7 @@ export const GET = withAuth( }), ); -// Mutations on global roles are super-admin-only — see route.ts header. +// Mutations on global roles are super-admin-only - see route.ts header. export const PATCH = withAuth(async (req, ctx, params) => { try { requireSuperAdmin(ctx, 'roles.update'); diff --git a/src/app/api/v1/admin/roles/route.ts b/src/app/api/v1/admin/roles/route.ts index ef2c4e52..5e2c5555 100644 --- a/src/app/api/v1/admin/roles/route.ts +++ b/src/app/api/v1/admin/roles/route.ts @@ -18,7 +18,7 @@ export const GET = withAuth( ); // Roles are global (no port_id) and assignments span every port via -// userPortRoles, so creation must be super-admin-only — a per-port admin +// userPortRoles, so creation must be super-admin-only - a per-port admin // holding admin.manage_users must never be able to mint a role that lives // in another tenant. export const POST = withAuth(async (req, ctx) => { diff --git a/src/app/api/v1/admin/settings/[key]/reveal/route.ts b/src/app/api/v1/admin/settings/[key]/reveal/route.ts index b6ca5edf..76bda4d5 100644 --- a/src/app/api/v1/admin/settings/[key]/reveal/route.ts +++ b/src/app/api/v1/admin/settings/[key]/reveal/route.ts @@ -14,11 +14,11 @@ import { getSetting } from '@/lib/settings/resolver'; * form so the operator can verify what they saved earlier. * * Gated on `admin.manage_settings` (the same permission required to write - * the value — so this never widens an existing trust boundary). Every + * the value - so this never widens an existing trust boundary). Every * reveal is audit-logged with the request id so a super-admin can trace * who looked at what and when. * - * Refuses to reveal values resolved from `env` or `default` — those would + * Refuses to reveal values resolved from `env` or `default` - those would * leak server-process secrets via the API. */ export const POST = withAuth( diff --git a/src/app/api/v1/admin/settings/resolved/route.ts b/src/app/api/v1/admin/settings/resolved/route.ts index 33e15f4a..89a9edaa 100644 --- a/src/app/api/v1/admin/settings/resolved/route.ts +++ b/src/app/api/v1/admin/settings/resolved/route.ts @@ -12,11 +12,11 @@ import { resolveForAdminAPI } from '@/lib/settings/resolver'; * Returns the resolved value + source (port/global/env/default) for every * requested registry entry. Drives both the registry-driven admin form * (sections param) and the onboarding-checklist auto-detection (keys - * param) — both need port→global→env→default resolution rather than the + * param) - both need port→global→env→default resolution rather than the * raw `/admin/settings` rows (which only show DB writes). * * Either parameter is supported; if both are present the sets union. - * Sensitive fields surface `isSet` only — never the decrypted value. + * Sensitive fields surface `isSet` only - never the decrypted value. */ export const GET = withAuth( withPermission('admin', 'manage_settings', async (req, ctx) => { @@ -55,7 +55,7 @@ export const GET = withAuth( // Return the entry metadata so the client can render labels/types // without bundling the registry into the client JS. Strip the - // `validator` + `transform` function references — they're not + // `validator` + `transform` function references - they're not // JSON-serializable. const entriesForClient = entries.map((e) => ({ key: e.key, diff --git a/src/app/api/v1/admin/storage/route.ts b/src/app/api/v1/admin/storage/route.ts index d9da1778..d1e6c6f3 100644 --- a/src/app/api/v1/admin/storage/route.ts +++ b/src/app/api/v1/admin/storage/route.ts @@ -1,8 +1,8 @@ /** * Admin storage status + connection test. Super-admin only. * - * GET /api/v1/admin/storage — current backend + capacity stats - * POST /api/v1/admin/storage/test — exercise list/put/get/delete on s3 + * GET /api/v1/admin/storage - current backend + capacity stats + * POST /api/v1/admin/storage/test - exercise list/put/get/delete on s3 */ import { NextResponse } from 'next/server'; diff --git a/src/app/api/v1/admin/users/[id]/permission-overrides/route.ts b/src/app/api/v1/admin/users/[id]/permission-overrides/route.ts index 205c815c..374f05cc 100644 --- a/src/app/api/v1/admin/users/[id]/permission-overrides/route.ts +++ b/src/app/api/v1/admin/users/[id]/permission-overrides/route.ts @@ -7,7 +7,7 @@ * * PUT accepts a Partial map (use null at a leaf to clear an * override) and upserts it onto user_permission_overrides for (userId, portId). - * Permission `admin.manage_users` is required — same gate as the user-edit + * Permission `admin.manage_users` is required - same gate as the user-edit * drawer that hosts the matrix. */ import { and, eq } from 'drizzle-orm'; @@ -85,7 +85,7 @@ const ALLOWED_RESOURCE_ACTIONS: Record> = { }; const updateOverridesSchema = z.object({ - /** Partial — passthrough JSON. Validated structurally + /** Partial - passthrough JSON. Validated structurally * by limiting depth + leaf type below. */ overrides: z.record(z.string(), z.record(z.string(), z.boolean())).default({}), }); @@ -121,7 +121,7 @@ export const GET = withAuth( ), }); if (baseline && portOverride?.permissionOverrides) { - // Cheap structural merge — same shape as helpers.ts's deepMerge. + // Cheap structural merge - same shape as helpers.ts's deepMerge. baseline = mergePerms(baseline, portOverride.permissionOverrides); } } @@ -162,7 +162,7 @@ export const PUT = withAuth( } // Reject overrides for users that aren't actually assigned to this - // port — prevents cross-tenant pollution where an admin in port A + // port - prevents cross-tenant pollution where an admin in port A // writes a row keyed on (userIdFromPortB, portA). The withAuth // resolver scopes lookups to the caller's port so the row would // never apply, but it still consumes a unique slot and confuses @@ -183,7 +183,7 @@ export const PUT = withAuth( // honour. // CALLER-SUPERSET (authz-auditor CRITICAL): an admin with only // `admin.manage_users` previously could grant another user any - // permission leaf — including ones they don't hold themselves + // permission leaf - including ones they don't hold themselves // (e.g. `permanently_delete_clients`, `system_backup`). Require // every `true` write to be a leaf the caller already has. // Super-admins bypass (they hold all leaves by definition). diff --git a/src/app/api/v1/admin/users/picker/route.ts b/src/app/api/v1/admin/users/picker/route.ts index 76aa8544..0edc2803 100644 --- a/src/app/api/v1/admin/users/picker/route.ts +++ b/src/app/api/v1/admin/users/picker/route.ts @@ -14,7 +14,7 @@ import { errorResponse } from '@/lib/errors'; * slot). Returns only the fields needed to render an option: id, email, * name. Excludes deactivated users. * - * Gated on `admin.manage_settings` — anyone editing per-port admin + * Gated on `admin.manage_settings` - anyone editing per-port admin * settings can already see all the configured Documenso recipient * email/name values, so revealing the user roster to them doesn't * widen the trust boundary. Tighter than the full `admin/users` GET diff --git a/src/app/api/v1/ai/email-draft/route.ts b/src/app/api/v1/ai/email-draft/route.ts index 7b7cd9de..2b8908f7 100644 --- a/src/app/api/v1/ai/email-draft/route.ts +++ b/src/app/api/v1/ai/email-draft/route.ts @@ -9,7 +9,7 @@ import { parseBody } from '@/lib/api/route-helpers'; import { requestDraftSchema } from '@/lib/validators/ai'; import { CodedError, errorResponse } from '@/lib/errors'; -// Gated on `email.send` — the draft endpoint spends OpenAI tokens and +// Gated on `email.send` - the draft endpoint spends OpenAI tokens and // renders client/interest-scoped content; only roles permitted to send // emails should be able to mint drafts (auditor-A3 §7). export const POST = withAuth( diff --git a/src/app/api/v1/alerts/route.ts b/src/app/api/v1/alerts/route.ts index 415b41ab..f01c90f3 100644 --- a/src/app/api/v1/alerts/route.ts +++ b/src/app/api/v1/alerts/route.ts @@ -6,7 +6,7 @@ import { listAlertsForPort } from '@/lib/services/alerts.service'; type AlertStatus = 'open' | 'dismissed' | 'resolved'; // Tier-4 (authz-auditor): alerts include permission_denied + audit-adjacent -// signals. Gated on admin.view_audit_log — same permission the audit log +// signals. Gated on admin.view_audit_log - same permission the audit log // page uses. export const GET = withAuth( withPermission('admin', 'view_audit_log', async (req: NextRequest, ctx) => { diff --git a/src/app/api/v1/berths/[id]/interest-documents/route.ts b/src/app/api/v1/berths/[id]/interest-documents/route.ts index 6b1a3fae..733b9301 100644 --- a/src/app/api/v1/berths/[id]/interest-documents/route.ts +++ b/src/app/api/v1/berths/[id]/interest-documents/route.ts @@ -6,7 +6,7 @@ import { listDealDocumentsForBerth } from '@/lib/services/documents.service'; /** * GET /api/v1/berths/[id]/interest-documents (renamed from - * `/deal-documents` in the 2026-05-14 terminology sweep — canonical + * `/deal-documents` in the 2026-05-14 terminology sweep - canonical * noun is "interest"). * * Lists documents attached to interests currently linked to this berth. diff --git a/src/app/api/v1/berths/[id]/pdf-upload-url/handlers.ts b/src/app/api/v1/berths/[id]/pdf-upload-url/handlers.ts index a1a6ceb7..174a14d7 100644 --- a/src/app/api/v1/berths/[id]/pdf-upload-url/handlers.ts +++ b/src/app/api/v1/berths/[id]/pdf-upload-url/handlers.ts @@ -21,7 +21,7 @@ import { getStorageBackend } from '@/lib/storage'; const postBodySchema = z.object({ fileName: z.string().min(1).max(255), - /** Size hint in bytes — used to early-reject oversized uploads before we + /** Size hint in bytes - used to early-reject oversized uploads before we * burn a presigned URL. */ sizeBytes: z.number().int().nonnegative().optional(), }); diff --git a/src/app/api/v1/berths/[id]/pdf-versions/handlers.ts b/src/app/api/v1/berths/[id]/pdf-versions/handlers.ts index eab0faf5..260f4dd4 100644 --- a/src/app/api/v1/berths/[id]/pdf-versions/handlers.ts +++ b/src/app/api/v1/berths/[id]/pdf-versions/handlers.ts @@ -44,7 +44,7 @@ export const getHandler: RouteHandler = async (_req, ctx, params) => { // and pdf-upload-url tenant-scopes the berth lookup. Without this regex, // a rep with berths.edit could ship the storage key of a foreign-port // PDF (signed EOI, brochure blob, another port's berth) and have the -// service repoint THIS berth's currentPdfVersionId at it — subsequent +// service repoint THIS berth's currentPdfVersionId at it - subsequent // pdf-download serves those bytes under the rep's own permission gate. const STORAGE_KEY_RE = /^berths\/[A-Za-z0-9_-]+\/uploads\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}_/; diff --git a/src/app/api/v1/berths/bulk-add/route.ts b/src/app/api/v1/berths/bulk-add/route.ts index e021b984..fae2fe44 100644 --- a/src/app/api/v1/berths/bulk-add/route.ts +++ b/src/app/api/v1/berths/bulk-add/route.ts @@ -16,7 +16,7 @@ import { bulkAddBerthsSchema } from '@/lib/validators/berths'; */ export const POST = withAuth( // F13: aligned with the seed-permissions scope (`berths.import`). - // The previous `berths.create` was a phantom key — not in the role + // The previous `berths.create` was a phantom key - not in the role // matrix, so non-super-admins silently failed permission resolution. withPermission('berths', 'import', async (req, ctx) => { try { diff --git a/src/app/api/v1/berths/bulk-update-prices/route.ts b/src/app/api/v1/berths/bulk-update-prices/route.ts index ea072f95..c2c01462 100644 --- a/src/app/api/v1/berths/bulk-update-prices/route.ts +++ b/src/app/api/v1/berths/bulk-update-prices/route.ts @@ -13,7 +13,7 @@ import { errorResponse } from '@/lib/errors'; * Gated by `berths.update_prices`. Returns counts so the UI can present * "Updated N · Unchanged M · Missing K" feedback. * - * Audit: one `audit_log` row per actually-updated berth (idempotent — + * Audit: one `audit_log` row per actually-updated berth (idempotent - * berths whose new price matches the existing value are skipped and * counted as `unchanged`). */ diff --git a/src/app/api/v1/berths/bulk/route.ts b/src/app/api/v1/berths/bulk/route.ts index d8cc7cfa..4a414ebb 100644 --- a/src/app/api/v1/berths/bulk/route.ts +++ b/src/app/api/v1/berths/bulk/route.ts @@ -15,7 +15,7 @@ import { import { errorResponse } from '@/lib/errors'; /** - * Synchronous bulk endpoint for the berths list — mirrors the + * 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 @@ -58,7 +58,7 @@ interface RowResult { } // Berths share a single `edit` permission for non-price mutations (no -// separate `archive` perm today — sales-manager + super-admin own all +// separate `archive` perm today - sales-manager + super-admin own all // edit paths). const PERMISSION_BY_ACTION: Record< z.infer['action'], diff --git a/src/app/api/v1/berths/check-duplicates/route.ts b/src/app/api/v1/berths/check-duplicates/route.ts index 0af1a436..4ab816d2 100644 --- a/src/app/api/v1/berths/check-duplicates/route.ts +++ b/src/app/api/v1/berths/check-duplicates/route.ts @@ -25,7 +25,7 @@ const checkSchema = z.object({ * surfacing the constraint violation at submit time. * * Format validation mirrors the CLAUDE.md canonical (`^[A-Z]+\d+$`). - * Archived berths are excluded — bulk-add re-using a previously-archived + * Archived berths are excluded - bulk-add re-using a previously-archived * mooring number is a legitimate flow. * * Permission gating: `berths.import` (same scope as bulk-add itself). diff --git a/src/app/api/v1/bootstrap/status/route.ts b/src/app/api/v1/bootstrap/status/route.ts index 24057702..a7f0712e 100644 --- a/src/app/api/v1/bootstrap/status/route.ts +++ b/src/app/api/v1/bootstrap/status/route.ts @@ -6,7 +6,7 @@ import { errorResponse } from '@/lib/errors'; /** * GET /api/v1/bootstrap/status * - * PUBLIC — no auth required. Used by the /setup and /login pages to + * PUBLIC - no auth required. Used by the /setup and /login pages to * decide which screen to show on first visit. Returns only a single * boolean to keep the response small and minimize info leakage. */ diff --git a/src/app/api/v1/bootstrap/super-admin/route.ts b/src/app/api/v1/bootstrap/super-admin/route.ts index ad0867f8..172d9047 100644 --- a/src/app/api/v1/bootstrap/super-admin/route.ts +++ b/src/app/api/v1/bootstrap/super-admin/route.ts @@ -14,7 +14,7 @@ const bodySchema = z.object({ /** * POST /api/v1/bootstrap/super-admin * - * PUBLIC — no auth required, but bound by a single-shot precondition: + * PUBLIC - no auth required, but bound by a single-shot precondition: * refuses to run when a super-admin already exists. Idempotently safe: * the service double-checks the precondition before insert, so two * racing first-run requests can't both create accounts. @@ -26,7 +26,7 @@ export async function POST(req: NextRequest) { // atomically before the insert. if (await hasAnySuperAdmin()) { throw new ConflictError( - 'A super-administrator account already exists — first-run setup is closed.', + 'A super-administrator account already exists - first-run setup is closed.', ); } const body = await parseBody(req, bodySchema); diff --git a/src/app/api/v1/clients/[id]/contacts/[contactId]/promote-to-primary/route.ts b/src/app/api/v1/clients/[id]/contacts/[contactId]/promote-to-primary/route.ts index 1583ec74..22421bc5 100644 --- a/src/app/api/v1/clients/[id]/contacts/[contactId]/promote-to-primary/route.ts +++ b/src/app/api/v1/clients/[id]/contacts/[contactId]/promote-to-primary/route.ts @@ -5,7 +5,7 @@ import { errorResponse } from '@/lib/errors'; import { promoteContactToPrimary } from '@/lib/services/clients.service'; /** - * Phase 3d — promote a non-primary `client_contacts` row to primary, + * Phase 3d - promote a non-primary `client_contacts` row to primary, * demoting the prior primary for the same channel inside a single * transaction. Surfaces from the "[EOI] Set as primary" action on the * client detail panel, and from the EOI dialog's "Set as default for diff --git a/src/app/api/v1/clients/[id]/gdpr-export/[exportId]/route.ts b/src/app/api/v1/clients/[id]/gdpr-export/[exportId]/route.ts index 922ec011..3899ae23 100644 --- a/src/app/api/v1/clients/[id]/gdpr-export/[exportId]/route.ts +++ b/src/app/api/v1/clients/[id]/gdpr-export/[exportId]/route.ts @@ -9,7 +9,7 @@ import { createAuditLog } from '@/lib/audit'; * Returns a fresh signed URL for an existing GDPR export. Staff use this * from the admin UI; the email path embeds its own signed URL. * - * Every call writes a `view` audit row at 'warning' severity — GDPR + * Every call writes a `view` audit row at 'warning' severity - GDPR * exports contain the entire personal data of a client and a fresh * presigned URL would let the operator download it; we want a clear * trail of who pulled what when. diff --git a/src/app/api/v1/clients/[id]/hard-delete-request/route.ts b/src/app/api/v1/clients/[id]/hard-delete-request/route.ts index e77081fd..380fa354 100644 --- a/src/app/api/v1/clients/[id]/hard-delete-request/route.ts +++ b/src/app/api/v1/clients/[id]/hard-delete-request/route.ts @@ -10,7 +10,7 @@ import { errorResponse, NotFoundError } from '@/lib/errors'; * `clients.delete` (the standard archive permission) is enforced by the * route wrapper; the service additionally requires the client to be * archived. The dedicated `admin.permanently_delete_clients` flag is - * checked by the partner /hard-delete route — see route comment there. + * checked by the partner /hard-delete route - see route comment there. */ export const POST = withAuth( withPermission( diff --git a/src/app/api/v1/clients/[id]/restore/route.ts b/src/app/api/v1/clients/[id]/restore/route.ts index 17e36d7d..d9105665 100644 --- a/src/app/api/v1/clients/[id]/restore/route.ts +++ b/src/app/api/v1/clients/[id]/restore/route.ts @@ -14,7 +14,7 @@ import { errorResponse, NotFoundError } from '@/lib/errors'; * * Backwards-compat: clients archived before the smart-archive feature * have no archive_metadata. The dossier returns empty arrays in that - * case, and a POST with no body simply un-archives them — same effect + * case, and a POST with no body simply un-archives them - same effect * as the old endpoint. */ const restoreSchema = z.object({ @@ -32,7 +32,7 @@ export const POST = withAuth( try { body = await parseBody(req, restoreSchema); } catch { - // Empty / non-JSON body — defaults are fine. + // Empty / non-JSON body - defaults are fine. } const result = await restoreClientWithSelections({ diff --git a/src/app/api/v1/clients/bulk-archive-preflight/route.ts b/src/app/api/v1/clients/bulk-archive-preflight/route.ts index 07bccdec..b06fdbe3 100644 --- a/src/app/api/v1/clients/bulk-archive-preflight/route.ts +++ b/src/app/api/v1/clients/bulk-archive-preflight/route.ts @@ -50,7 +50,7 @@ export const POST = withAuth( }, }); } catch { - // Generic blocker text — never include the inner error so an + // Generic blocker text - never include the inner error so an // attacker can't distinguish "not found" from "in another port" // by enumerating UUIDs (audit R2-M9). The operator already // selected these IDs so they don't need to know the cause. @@ -59,7 +59,7 @@ export const POST = withAuth( fullName: '(unknown)', stakeLevel: 'low', highStakesStage: null, - blockers: ['Could not load dossier — client may have been removed'], + blockers: ['Could not load dossier - client may have been removed'], summary: { berths: 0, yachts: 0, reservations: 0, signedDocs: 0 }, }); } diff --git a/src/app/api/v1/clients/bulk/route.ts b/src/app/api/v1/clients/bulk/route.ts index 4c3bc635..1cbbf539 100644 --- a/src/app/api/v1/clients/bulk/route.ts +++ b/src/app/api/v1/clients/bulk/route.ts @@ -110,7 +110,7 @@ export const POST = withAuth(async (req, ctx) => { const reason = perClientReason ?? 'Bulk archive (low-stakes auto-mode)'; // Pick the berth's first linked interest from the dossier // (authoritative interest_berths join). Berths with no linked - // interest for this client are dropped — emitting an empty + // interest for this client are dropped - emitting an empty // interestId causes the delete to silently match zero rows // (audit R2-H3). const berthDecisions = dossier.berths diff --git a/src/app/api/v1/clients/match-candidates/handlers.ts b/src/app/api/v1/clients/match-candidates/handlers.ts index 2b7d0888..7ba888d7 100644 --- a/src/app/api/v1/clients/match-candidates/handlers.ts +++ b/src/app/api/v1/clients/match-candidates/handlers.ts @@ -143,7 +143,7 @@ export async function getMatchCandidatesHandler( interestsByClient.set(r.clientId, (interestsByClient.get(r.clientId) ?? 0) + 1); } - // Build a lookup from the original pool for archived flag — the dedup + // Build a lookup from the original pool for archived flag - the dedup // candidate type intentionally doesn't carry it, but the suggestion card // needs to differentiate "use this live client" from "restore this // archived client". Without this the UX swallows soft-deleted dupes. diff --git a/src/app/api/v1/dashboard/forecast/route.ts b/src/app/api/v1/dashboard/forecast/route.ts index 98b2b362..50806a0f 100644 --- a/src/app/api/v1/dashboard/forecast/route.ts +++ b/src/app/api/v1/dashboard/forecast/route.ts @@ -8,7 +8,7 @@ import { parseRangeSlug, rangeToBounds } from '@/lib/analytics/range'; * GET /api/v1/dashboard/forecast * GET /api/v1/dashboard/forecast?range=7d|30d|90d|today|custom-- * - * Same range semantics as /kpis — the weighted forecast scopes to + * Same range semantics as /kpis - the weighted forecast scopes to * interests whose createdAt falls inside the window when range is set, * or all-time when not. */ diff --git a/src/app/api/v1/document-folders/[id]/route.ts b/src/app/api/v1/document-folders/[id]/route.ts index e4cc3365..f78bbc42 100644 --- a/src/app/api/v1/document-folders/[id]/route.ts +++ b/src/app/api/v1/document-folders/[id]/route.ts @@ -13,12 +13,12 @@ import { /** * PATCH supports either { name } (rename) or { parentId } (move). - * Refuses both in the same body — keeps the audit log clean + * Refuses both in the same body - keeps the audit log clean * (one operation per call) and prevents the rep from accidentally * doing two unrelated changes in one click. */ // `.strict()` on each branch so a body with BOTH name and parentId is -// rejected by both members and the union produces a 400 — without it, +// rejected by both members and the union produces a 400 - without it, // z.union silently picks the first match and drops the other key, // which would let a rename request silently swallow a move attempt. const patchBodySchema = z.union([renameFolderSchema.strict(), moveFolderSchema.strict()]); diff --git a/src/app/api/v1/document-folders/route.ts b/src/app/api/v1/document-folders/route.ts index c0d48c83..a616a1fc 100644 --- a/src/app/api/v1/document-folders/route.ts +++ b/src/app/api/v1/document-folders/route.ts @@ -11,7 +11,7 @@ import { listTree, createFolder } from '@/lib/services/document-folders.service' * * Returns the entire folder tree for the caller's port. Roots come * back at the top level with `children` nested. Cached on the client - * via TanStack — folders change rarely; the manager mutations + * via TanStack - folders change rarely; the manager mutations * invalidate the query. * * Permission: documents.view (read-only; everyone in the port can diff --git a/src/app/api/v1/document-templates/[id]/detect-fields/route.ts b/src/app/api/v1/document-templates/[id]/detect-fields/route.ts index 120a1a3e..a444237c 100644 --- a/src/app/api/v1/document-templates/[id]/detect-fields/route.ts +++ b/src/app/api/v1/document-templates/[id]/detect-fields/route.ts @@ -9,7 +9,7 @@ import { getStorageBackend } from '@/lib/storage'; import { detectFields } from '@/lib/services/document-field-detector'; /** - * Phase 4 — Auto-detect signature/date/initials/name/email anchors in the + * Phase 4 - Auto-detect signature/date/initials/name/email anchors in the * template's current source PDF and return suggested field placements. * * The detector (`src/lib/services/document-field-detector.ts`) scans each @@ -18,7 +18,7 @@ import { detectFields } from '@/lib/services/document-field-detector'; * coords (0..100 of page dimensions), which the editor converts to its * own 0..1 marker coords before adding to the field map. * - * Permission: `admin.manage_settings` — same gate as the editor itself. + * Permission: `admin.manage_settings` - same gate as the editor itself. */ export const POST = withAuth( withPermission('admin', 'manage_settings', async (_req, ctx, params) => { @@ -29,7 +29,7 @@ export const POST = withAuth( if (!template) throw new NotFoundError('Template'); if (!template.sourceFileId) { throw new ValidationError( - 'Template has no source PDF — upload one first via the Replace PDF button', + 'Template has no source PDF - upload one first via the Replace PDF button', ); } @@ -40,7 +40,7 @@ export const POST = withAuth( throw new NotFoundError('Source PDF file row missing'); } - // Read the PDF blob from storage. Buffer the whole stream — the + // Read the PDF blob from storage. Buffer the whole stream - the // detector needs a contiguous Buffer for pdfjs-dist, and template // source PDFs are capped at 10MB by the source-pdf upload route. const backend = await getStorageBackend(); diff --git a/src/app/api/v1/document-templates/[id]/preview/route.ts b/src/app/api/v1/document-templates/[id]/preview/route.ts index ba2b488c..31cc0326 100644 --- a/src/app/api/v1/document-templates/[id]/preview/route.ts +++ b/src/app/api/v1/document-templates/[id]/preview/route.ts @@ -18,13 +18,13 @@ const previewBodySchema = z.object({ }); /** - * Phase 7.2 — live preview endpoint for the PDF editor. + * Phase 7.2 - live preview endpoint for the PDF editor. * * Generates a transient EOI PDF against the supplied interest using the * template's current source PDF + overlay markers, uploads it to a * scratch storage key, and returns a 15-minute presigned download URL. * - * The blob is intentionally not linked to a `files` row — preview PDFs + * The blob is intentionally not linked to a `files` row - preview PDFs * are throwaway. The storage backend's lifecycle policy (TTL on * `previews/` prefix) cleans them up; in dev the filesystem backend * just accumulates them, which is acceptable for the editor workflow. @@ -39,7 +39,7 @@ export const POST = withAuth( }); if (!template) throw new NotFoundError('Template'); if (template.templateType !== 'eoi') { - // Live preview is currently EOI-only — that's where the + // Live preview is currently EOI-only - that's where the // editor's overlay-positions flow into rendering. Other // template types are deferred (no in-app fill yet). throw new ValidationError( diff --git a/src/app/api/v1/document-templates/[id]/source-pdf/route.ts b/src/app/api/v1/document-templates/[id]/source-pdf/route.ts index 947944b9..92c5d333 100644 --- a/src/app/api/v1/document-templates/[id]/source-pdf/route.ts +++ b/src/app/api/v1/document-templates/[id]/source-pdf/route.ts @@ -15,7 +15,7 @@ const MAX_PDF_BYTES = 10 * 1024 * 1024; const PDF_MAGIC = Buffer.from([0x25, 0x50, 0x44, 0x46, 0x2d]); // "%PDF-" /** - * Phase 7.2 — replace the template's source PDF while preserving the + * Phase 7.2 - replace the template's source PDF while preserving the * field map. The existing `overlay_positions` is kept exactly as-is; * the client warns when the new page count truncates the previous set * (markers on now-orphaned pages are invisible at render time). diff --git a/src/app/api/v1/documents/[id]/cancel/route.ts b/src/app/api/v1/documents/[id]/cancel/route.ts index 623712a2..e5f34992 100644 --- a/src/app/api/v1/documents/[id]/cancel/route.ts +++ b/src/app/api/v1/documents/[id]/cancel/route.ts @@ -10,6 +10,15 @@ const cancelBodySchema = z .object({ reason: z.string().max(2000).optional().nullable(), notifyRecipients: z.array(z.string().uuid()).max(20).optional(), + /** + * Whether to also DELETE the document from Documenso. `delete` (the + * default) frees the upstream envelope slot - useful for unclogging + * the Documenso log when a draft was abandoned. `keep_remote` + * leaves the envelope intact for audit purposes; only the local + * row is marked `cancelled`. Audit-trail copy on the cancelled-doc + * badge changes accordingly. + */ + cancelMode: z.enum(['delete', 'keep_remote']).optional(), }) .strict() .optional(); @@ -17,7 +26,7 @@ const cancelBodySchema = z export const POST = withAuth( withPermission('documents', 'edit', async (req, ctx, params) => { try { - // Body is optional — legacy callers POST with `{}`. parseBody returns + // Body is optional - legacy callers POST with `{}`. parseBody returns // null when the request has no body; default to empty options. let body: z.infer = undefined; try { @@ -37,6 +46,7 @@ export const POST = withAuth( { reason: body?.reason ?? null, notifyRecipients: body?.notifyRecipients ?? [], + cancelMode: body?.cancelMode ?? 'delete', }, ); return NextResponse.json({ data: doc }); diff --git a/src/app/api/v1/documents/[id]/download/[...slug]/handlers.ts b/src/app/api/v1/documents/[id]/download/[...slug]/handlers.ts index d01e377b..f0de68ad 100644 --- a/src/app/api/v1/documents/[id]/download/[...slug]/handlers.ts +++ b/src/app/api/v1/documents/[id]/download/[...slug]/handlers.ts @@ -8,7 +8,7 @@ * Lookup is keyed off the doc id; the slug embeds the current folder path + * filename so a forwarded link reads like `Deals 2026/Q1/contract.pdf` even * though the underlying storage key is a UUID. The slug is rebuilt from - * current state and compared with the supplied path — a stale or + * current state and compared with the supplied path - a stale or * hand-edited URL 404s rather than silently serving the wrong file. */ diff --git a/src/app/api/v1/documents/[id]/folder/route.ts b/src/app/api/v1/documents/[id]/folder/route.ts index 15b20edd..facd6947 100644 --- a/src/app/api/v1/documents/[id]/folder/route.ts +++ b/src/app/api/v1/documents/[id]/folder/route.ts @@ -11,7 +11,7 @@ import { createAuditLog } from '@/lib/audit'; /** * Per-document move endpoint. Moving a single document is a deliberate - * user action so we DO bump `updated_at` here — different semantics from + * user action so we DO bump `updated_at` here - different semantics from * the bulk soft-rescue in `deleteFolderSoftRescue` where the timestamp * stays put because reps did not act on the individual documents. * diff --git a/src/app/api/v1/documents/[id]/send-invitation/route.ts b/src/app/api/v1/documents/[id]/send-invitation/route.ts index 2082aa41..2ccff4b6 100644 --- a/src/app/api/v1/documents/[id]/send-invitation/route.ts +++ b/src/app/api/v1/documents/[id]/send-invitation/route.ts @@ -15,7 +15,7 @@ import { getPortDocumensoConfig } from '@/lib/services/port-config'; import { errorResponse, NotFoundError, ValidationError } from '@/lib/errors'; const bodySchema = z.object({ - /** Optional — defaults to the next pending signer in signing-order. */ + /** Optional - defaults to the next pending signer in signing-order. */ recipientId: z.string().optional(), }); @@ -63,7 +63,7 @@ export const POST = withAuth( // Self-heal flow when target.signingUrl is null. Two scenarios: // 1. Envelope was created before the auto-distribute fix shipped - // — never distributed, so we must call /envelope/distribute + // - never distributed, so we must call /envelope/distribute // to mint URLs. // 2. Envelope WAS auto-distributed at generate time, but the // response we got didn't carry signingUrls into our DB row @@ -74,7 +74,7 @@ export const POST = withAuth( // Defensive flow: try `getEnvelope` FIRST (cheap, always works). // If recipients carry signingUrls, persist + skip distribute. // If not, fall through to distribute, but catch 4xx so we don't - // surface a confusing "Documenso upstream error" to the rep — + // surface a confusing "Documenso upstream error" to the rep - // instead we re-fetch via GET one more time and accept whatever // URLs the envelope has. if (!target.signingUrl && doc.documensoId) { @@ -116,7 +116,7 @@ export const POST = withAuth( recovered = true; } } catch { - // ignore — fall through to distribute attempt + // ignore - fall through to distribute attempt } // Step 2: distribute, only if GET didn't recover URLs. @@ -125,7 +125,7 @@ export const POST = withAuth( const distributed = await distributeEnvelopeV2(doc.documensoId, ctx.portId); await persistUrlsForDocument(distributed.recipients); } catch { - // Probably "already distributed" — last-ditch GET. + // Probably "already distributed" - last-ditch GET. try { const fetched = await getDocument(doc.documensoId, ctx.portId); await persistUrlsForDocument(fetched.recipients); @@ -146,7 +146,7 @@ export const POST = withAuth( if (!target.signingUrl) { throw new ValidationError( - 'Signer has no Documenso URL yet — try regenerating the EOI; v2 envelopes require distribution before the signing link exists.', + 'Signer has no Documenso URL yet - try regenerating the EOI; v2 envelopes require distribution before the signing link exists.', ); } @@ -161,7 +161,7 @@ export const POST = withAuth( documentLabel: DOC_TYPE_LABEL[doc.documentType] ?? 'Expression of Interest', signerRole: (target.signerRole as SignerRole) ?? 'client', senderName: docCfg.developerName ?? null, - // Phase 6 — surface the per-doc rep-authored note when set so + // Phase 6 - surface the per-doc rep-authored note when set so // every cascaded invite and any manual resend show the same // copy. Falls back to the template default when null/empty. customMessage: doc.invitationMessage, diff --git a/src/app/api/v1/documents/auto-detect-fields/route.ts b/src/app/api/v1/documents/auto-detect-fields/route.ts index 45a40894..a4630785 100644 --- a/src/app/api/v1/documents/auto-detect-fields/route.ts +++ b/src/app/api/v1/documents/auto-detect-fields/route.ts @@ -6,19 +6,19 @@ import { detectFields } from '@/lib/services/document-field-detector'; import { isPdfMagic } from '@/lib/services/berth-pdf-parser'; /** - * Phase 4 — Auto-detect anchor scanner endpoint. + * Phase 4 - Auto-detect anchor scanner endpoint. * * POST `/api/v1/documents/auto-detect-fields` * * Body: multipart/form-data * - file: the source PDF the rep just uploaded * - * Returns: `{ data: { fields: DetectedField[] } }` — seed state for the + * Returns: `{ data: { fields: DetectedField[] } }` - seed state for the * drag-drop overlay. Empty array when the PDF has no extractable text - * (image-only scan) — the dialog falls back to manual placement + * (image-only scan) - the dialog falls back to manual placement * without an error toast. * - * Permission: documents.send_for_signing — the only flow that calls + * Permission: documents.send_for_signing - the only flow that calls * this endpoint is the upload-for-signing dialog, which already * requires that bit. Reusing it here means a custom role with the * upload bit but no send bit can't dry-run the detector to pull diff --git a/src/app/api/v1/documents/signing-defaults/route.ts b/src/app/api/v1/documents/signing-defaults/route.ts index 4f00daaa..c89796fb 100644 --- a/src/app/api/v1/documents/signing-defaults/route.ts +++ b/src/app/api/v1/documents/signing-defaults/route.ts @@ -10,11 +10,11 @@ import { getEoiTemplateSyncReport } from '@/lib/services/documenso-template-sync * * Returns the per-port developer + approver defaults the * UploadForSigningDialog uses to prefill the recipient configurator. - * No secrets are exposed — just the display name, email, and the + * No secrets are exposed - just the display name, email, and the * sendMode flag so the UI can show the right CTA copy ("Send now" vs * "Save draft, send manually"). * - * Permission: documents.send_for_signing — the only caller is the + * Permission: documents.send_for_signing - the only caller is the * upload-for-signing dialog which already requires this permission to * complete the flow. */ @@ -25,7 +25,7 @@ export const GET = withAuth( // Signing order resolution chain (highest → lowest priority): // 1. Cached `documento_eoi_template_sync_report.templateMeta.signingOrder` - // — populated by the admin "Sync from Documenso" button and + // - populated by the admin "Sync from Documenso" button and // represents the live template's bound order. On v2 this is the // authoritative value because `/template/use` doesn't accept a // per-call override. @@ -53,7 +53,7 @@ export const GET = withAuth( signingOrder, // Surface where the value came from so the UI tooltip can be // honest about the source. Helps reps debug "I changed it in - // Documenso but the CRM still says X" — they need to re-run + // Documenso but the CRM still says X" - they need to re-run // Sync to pull the change. signingOrderSource: syncReport?.templateMeta?.signingOrder ? 'template' diff --git a/src/app/api/v1/expenses/export/pdf/route.ts b/src/app/api/v1/expenses/export/pdf/route.ts index 8667bb33..c79e077a 100644 --- a/src/app/api/v1/expenses/export/pdf/route.ts +++ b/src/app/api/v1/expenses/export/pdf/route.ts @@ -9,7 +9,7 @@ import { exportExpensePdfSchema } from '@/lib/validators/expenses'; /** * POST /api/v1/expenses/export/pdf * - * Streams the expense report PDF directly to the client — body bytes + * Streams the expense report PDF directly to the client - body bytes * leave the process as pdfkit writes them, so the route is safe for * hundreds of expenses with full-resolution receipt images. See * `expense-pdf.service.ts` for the memory-budget design. @@ -53,7 +53,7 @@ export const POST = withAuth( // Forward the request abort signal so the streaming PDF builder // stops fetching/resizing receipts the moment the client disconnects // (otherwise an aborted 1000-receipt export keeps the worker busy - // for minutes after the user navigated away — see audit finding 2). + // for minutes after the user navigated away - see audit finding 2). signal: req.signal, }); diff --git a/src/app/api/v1/expenses/scan-receipt/route.ts b/src/app/api/v1/expenses/scan-receipt/route.ts index 23523c6d..ab6d0ea6 100644 --- a/src/app/api/v1/expenses/scan-receipt/route.ts +++ b/src/app/api/v1/expenses/scan-receipt/route.ts @@ -30,7 +30,7 @@ export const POST = withAuth( const formData = await req.formData(); const file = formData.get('file') as File | null; if (!file) throw new ValidationError('A file is required'); - // Hard 10 MB cap — without this any authenticated rep could grief + // Hard 10 MB cap - without this any authenticated rep could grief // their own port's AI budget by sending arbitrarily large images // and burning OCR tokens (auditor-E3 §28). const MAX_OCR_BYTES = 10 * 1024 * 1024; diff --git a/src/app/api/v1/expenses/trip-labels/route.ts b/src/app/api/v1/expenses/trip-labels/route.ts index a8641550..647fb627 100644 --- a/src/app/api/v1/expenses/trip-labels/route.ts +++ b/src/app/api/v1/expenses/trip-labels/route.ts @@ -13,7 +13,7 @@ import { listTripLabels } from '@/lib/services/expenses'; * "Palm Beach 2026" vs " palm beach 2026 " split across two groups in * the PDF export. * - * Permission: `expenses.view` — same gate as the list endpoint. + * Permission: `expenses.view` - same gate as the list endpoint. */ export const GET = withAuth( withPermission('expenses', 'view', async (req, ctx) => { diff --git a/src/app/api/v1/files/folders/route.ts b/src/app/api/v1/files/folders/route.ts index fef2b466..21206d33 100644 --- a/src/app/api/v1/files/folders/route.ts +++ b/src/app/api/v1/files/folders/route.ts @@ -31,7 +31,7 @@ export const POST = withAuth( // Zero-byte marker through the active storage backend. S3 stores it // as an empty object; the filesystem backend currently materializes // it as an empty file (a future refactor should move folder - // bookkeeping to a DB-backed virtual-folder table — see + // bookkeeping to a DB-backed virtual-folder table - see // docs/audit-comprehensive-2026-05-05.md HIGH §3 follow-up). await ( await getStorageBackend() diff --git a/src/app/api/v1/interests/[id]/berths/[berthId]/handlers.ts b/src/app/api/v1/interests/[id]/berths/[berthId]/handlers.ts index 40cf34d1..dd92a870 100644 --- a/src/app/api/v1/interests/[id]/berths/[berthId]/handlers.ts +++ b/src/app/api/v1/interests/[id]/berths/[berthId]/handlers.ts @@ -39,7 +39,7 @@ const patchBerthSchema = z async function loadScopedRow(interestId: string, berthId: string, portId: string) { // Verify interest port-scope first so unrelated 404s look identical to a - // truly-missing row (enumeration prevention — plan §14.10). + // truly-missing row (enumeration prevention - plan §14.10). const interest = await db.query.interests.findFirst({ where: eq(interests.id, interestId), }); @@ -73,7 +73,7 @@ export const patchHandler: RouteHandler = async (req, ctx, params) => { const { interest } = await loadScopedRow(interestId, berthId, ctx.portId); // Plan §5.5: the bypass control is only available once the interest's - // primary EOI is signed. Defend the API too — never trust the UI to + // primary EOI is signed. Defend the API too - never trust the UI to // gate this. if (body.eoiBypassReason !== undefined && interest.eoiStatus !== 'signed') { throw new ValidationError('EOI bypass requires a signed primary EOI on the interest'); diff --git a/src/app/api/v1/interests/[id]/berths/handlers.ts b/src/app/api/v1/interests/[id]/berths/handlers.ts index 1ca32d7e..328b38ce 100644 --- a/src/app/api/v1/interests/[id]/berths/handlers.ts +++ b/src/app/api/v1/interests/[id]/berths/handlers.ts @@ -63,7 +63,7 @@ export const addHandler: RouteHandler = async (req, ctx, params) => { } // Tenant scope: berth must belong to this port (never trust a client- - // supplied id to cross port boundaries — plan §14.10). + // supplied id to cross port boundaries - plan §14.10). const berth = await db.query.berths.findFirst({ where: and(eq(berths.id, body.berthId), eq(berths.portId, ctx.portId)), }); diff --git a/src/app/api/v1/interests/[id]/eoi-context/route.ts b/src/app/api/v1/interests/[id]/eoi-context/route.ts index ee6cb4be..0f9a8ac6 100644 --- a/src/app/api/v1/interests/[id]/eoi-context/route.ts +++ b/src/app/api/v1/interests/[id]/eoi-context/route.ts @@ -14,7 +14,7 @@ import { buildEoiContext } from '@/lib/services/eoi-context'; * correct) every value before sending the document for signing. * * Augments the core context with `available.emails` / `available.phones` - * — every non-deleted client_contacts row for the linked client. The + * - every non-deleted client_contacts row for the linked client. The * dialog renders these as combobox options so the rep can pick a * secondary contact for this EOI (Phase 3b). * diff --git a/src/app/api/v1/interests/[id]/payments/route.ts b/src/app/api/v1/interests/[id]/payments/route.ts index c7c997c7..0d3e5d0a 100644 --- a/src/app/api/v1/interests/[id]/payments/route.ts +++ b/src/app/api/v1/interests/[id]/payments/route.ts @@ -28,7 +28,7 @@ export const GET = withAuth( export const POST = withAuth( withPermission('payments', 'record', async (req, ctx, params) => { try { - // Body's interestId must match the URL param — defense-in-depth against + // Body's interestId must match the URL param - defense-in-depth against // a client that sends one ID in the URL but another in the body. const body = await parseBody(req, createPaymentSchema); if (body.interestId !== params.id) { diff --git a/src/app/api/v1/interests/[id]/recommend-berths/route.ts b/src/app/api/v1/interests/[id]/recommend-berths/route.ts index c942c1d8..aa713292 100644 --- a/src/app/api/v1/interests/[id]/recommend-berths/route.ts +++ b/src/app/api/v1/interests/[id]/recommend-berths/route.ts @@ -7,8 +7,8 @@ import { errorResponse } from '@/lib/errors'; import { recommendBerths } from '@/lib/services/berth-recommender.service'; /** - * POST body — mirrors `RecommendBerthsArgs` minus the `interestId` (route - * param) and `portId` (resolved from the auth context — never trust a + * POST body - mirrors `RecommendBerthsArgs` minus the `interestId` (route + * param) and `portId` (resolved from the auth context - never trust a * client-supplied port, plan §14.10). */ const recommendBerthsSchema = z diff --git a/src/app/api/v1/interests/[id]/stage/route.ts b/src/app/api/v1/interests/[id]/stage/route.ts index 91b728e3..8e20bf44 100644 --- a/src/app/api/v1/interests/[id]/stage/route.ts +++ b/src/app/api/v1/interests/[id]/stage/route.ts @@ -26,7 +26,7 @@ export const PATCH = withAuth( ipAddress: ctx.ipAddress, userAgent: ctx.userAgent, }); - // A19 / F27: same-stage write returns the sentinel — emit 204. + // A19 / F27: same-stage write returns the sentinel - emit 204. if (interest === STAGE_NOOP) { return new NextResponse(null, { status: 204 }); } diff --git a/src/app/api/v1/interests/[id]/supplemental-info-request/route.ts b/src/app/api/v1/interests/[id]/supplemental-info-request/route.ts index 0b93b76c..fd7954e9 100644 --- a/src/app/api/v1/interests/[id]/supplemental-info-request/route.ts +++ b/src/app/api/v1/interests/[id]/supplemental-info-request/route.ts @@ -19,7 +19,7 @@ import { brandingPrimaryColor, renderShell } from '@/lib/email/shell'; * Generates a one-shot token + emails the client the public form URL. */ /** - * GET — list past issuances for the interest. Lets the rep see when each + * GET - list past issuances for the interest. Lets the rep see when each * token was generated + which one is currently active, so they can choose * Resend (re-email the existing token) over Regenerate (mint a fresh one) * when the same client is still working through the existing form. @@ -58,7 +58,7 @@ export const POST = withAuth( resendTokenId = body.tokenId; } } catch { - // No JSON body — keep the default. + // No JSON body - keep the default. } const result = resendTokenId @@ -72,13 +72,13 @@ export const POST = withAuth( // §1.4: prefer the per-port supplemental_form_url (typically the // marketing site's hosted form) when configured; otherwise fall // back to the built-in CRM route. Both modes use the same token - // — the marketing site forwards the token to the same backend. + // - the marketing site forwards the token to the same backend. const emailCfg = await getPortEmailConfig(ctx.portId); const link = emailCfg.supplementalFormUrl ? `${emailCfg.supplementalFormUrl}?token=${encodeURIComponent(result.token)}` : `${env.NEXT_PUBLIC_APP_URL}/public/supplemental-info/${result.token}`; - // Resend implies "email me again" — the rep clicked the action with + // Resend implies "email me again" - the rep clicked the action with // intent. Force the email path on for resends regardless of the // `sendEmail` body flag. const willSendEmail = resendTokenId ? true : shouldSendEmail; @@ -106,7 +106,7 @@ export const POST = withAuth(

Before we draft your Expression of Interest, we need to confirm a few details. - The form below is pre-filled with what we have on file — please review, correct + The form below is pre-filled with what we have on file - please review, correct anything that's wrong, and add what's missing.

diff --git a/src/app/api/v1/interests/[id]/timeline/route.ts b/src/app/api/v1/interests/[id]/timeline/route.ts index 8af93bcd..d9df5190 100644 --- a/src/app/api/v1/interests/[id]/timeline/route.ts +++ b/src/app/api/v1/interests/[id]/timeline/route.ts @@ -7,7 +7,10 @@ import { db } from '@/lib/db'; import { interests } from '@/lib/db/schema/interests'; import { auditLogs } from '@/lib/db/schema/system'; import { documents, documentEvents } from '@/lib/db/schema/documents'; -import { user } from '@/lib/db/schema/users'; +import { user, userProfiles } from '@/lib/db/schema/users'; +import { berths } from '@/lib/db/schema/berths'; +import { yachts } from '@/lib/db/schema/yachts'; +import { clients } from '@/lib/db/schema/clients'; import { stageLabel } from '@/lib/constants'; const OUTCOME_LABELS: Record = { @@ -107,6 +110,77 @@ export const GET = withAuth( return userNameById.get(userId) ?? null; }; + // Collect every UUID that appears in an audit row's newValue under + // a known FK field, then batch-resolve to human labels - berth + // mooring numbers, yacht names, client names, user display names. + // Without this, `Updated primary berth → ` leaks raw IDs + // into the timeline. Order: scan rows, fetch labels, build maps. + const berthIds = new Set(); + const yachtIds = new Set(); + const clientFkIds = new Set(); + const userFkIds = new Set(); + const USER_FK_FIELDS = new Set(['assignedTo', 'ownerId', 'createdBy', 'reassignedTo']); + for (const row of auditRows) { + const nv = row.newValue as Record | null; + if (!nv) continue; + for (const [key, val] of Object.entries(nv)) { + if (typeof val !== 'string' || val.length < 32) continue; + if (key === 'berthId') berthIds.add(val); + else if (key === 'yachtId') yachtIds.add(val); + else if (key === 'clientId') clientFkIds.add(val); + else if (USER_FK_FIELDS.has(key)) userFkIds.add(val); + } + } + const [berthRows, yachtRows, clientRows, profileRows] = await Promise.all([ + berthIds.size > 0 + ? db + .select({ id: berths.id, mooring: berths.mooringNumber }) + .from(berths) + .where(inArray(berths.id, Array.from(berthIds))) + : Promise.resolve([] as Array<{ id: string; mooring: string }>), + yachtIds.size > 0 + ? db + .select({ id: yachts.id, name: yachts.name }) + .from(yachts) + .where(inArray(yachts.id, Array.from(yachtIds))) + : Promise.resolve([] as Array<{ id: string; name: string }>), + clientFkIds.size > 0 + ? db + .select({ id: clients.id, name: clients.fullName }) + .from(clients) + .where(inArray(clients.id, Array.from(clientFkIds))) + : Promise.resolve([] as Array<{ id: string; name: string }>), + userFkIds.size > 0 + ? db + .select({ + userId: userProfiles.userId, + displayName: userProfiles.displayName, + firstName: userProfiles.firstName, + lastName: userProfiles.lastName, + }) + .from(userProfiles) + .where(inArray(userProfiles.userId, Array.from(userFkIds))) + : Promise.resolve( + [] as Array<{ + userId: string; + displayName: string | null; + firstName: string | null; + lastName: string | null; + }>, + ), + ]); + const fkLabels: FkLabelMaps = { + berths: new Map(berthRows.map((b) => [b.id, `Berth ${b.mooring}`])), + yachts: new Map(yachtRows.map((y) => [y.id, y.name])), + clients: new Map(clientRows.map((c) => [c.id, c.name])), + users: new Map( + profileRows.map((p) => [ + p.userId, + [p.firstName, p.lastName].filter(Boolean).join(' ').trim() || p.displayName || 'User', + ]), + ), + }; + // Union and sort const auditEvents: TimelineEvent[] = auditRows.map((row) => ({ id: row.id, @@ -117,6 +191,7 @@ export const GET = withAuth( row.newValue as Record | null, (row.metadata as Record) ?? {}, row.userId, + fkLabels, ), userId: row.userId, userName: resolveUserName(row.userId), @@ -171,11 +246,19 @@ export const GET = withAuth( }), ); +interface FkLabelMaps { + berths: Map; + yachts: Map; + clients: Map; + users: Map; +} + function buildAuditDescription( action: string, newValue: Record | null, metadata: Record, userId: string | null, + fkLabels: FkLabelMaps, ): string { if (action === 'create') return 'Interest created'; if (action === 'archive') return 'Interest archived'; @@ -209,21 +292,63 @@ function buildAuditDescription( return `Stage changed to ${stageLabel(newValue.pipelineStage as string)}`; } if (action === 'update') { + // Interest-berth link mutations get a sentence per flag transition + // ("Berth A1 added to EOI bundle") instead of a literal key/value + // dump. The audit row is logged against the parent interest, but + // `newValue` carries the interest_berths row's flags + the keying + // berthId - so the rep reads it as "what just happened on this + // berth link", not "field X changed to Y". + if (newValue && 'berthId' in newValue && typeof newValue.berthId === 'string') { + const berthLabel = fkLabels.berths.get(newValue.berthId) ?? '(removed berth)'; + const phrases = describeInterestBerthFlags(newValue); + if (phrases.length > 0) { + return phrases.map((p) => p.replace('{berth}', berthLabel)).join(' · '); + } + } // §1.1: surface which field(s) changed instead of a generic // "Interest updated". We have the new-value bag in audit_logs; // human-friendly labels for the most common fields. - return describeUpdateDiff(newValue); + return describeUpdateDiff(newValue, fkLabels); } return action; } +/** + * Convert an `interest_berths` audit-log diff bag into one or more + * plain-English sentences. The audit row's `newValue` carries the + * post-state for one or more flag columns (`isPrimary`, + * `isInEoiBundle`, `isSpecificInterest`) plus the keying `berthId`. + * We narrate each flag transition individually so reps don't see a + * literal "X → on / Y → off" dump. + * + * `{berth}` is a placeholder the caller substitutes with the resolved + * mooring label (e.g. "Berth A1") - keeping the substitution out of + * here lets us return the same string shape for the "removed berth" + * fallback case. + */ +function describeInterestBerthFlags(newValue: Record): string[] { + const phrases: string[] = []; + if (newValue.isPrimary === true) phrases.push('{berth} set as primary berth'); + else if (newValue.isPrimary === false) phrases.push('{berth} no longer primary berth'); + if (newValue.isInEoiBundle === true) phrases.push('{berth} added to EOI bundle'); + else if (newValue.isInEoiBundle === false) phrases.push('{berth} removed from EOI bundle'); + if (newValue.isSpecificInterest === true) + phrases.push('{berth} marked as specific interest (public Under Offer)'); + else if (newValue.isSpecificInterest === false) + phrases.push('{berth} no longer marked as specific interest'); + return phrases; +} + /** * Render a "leadCategory: hot_lead, source: website" style description from * an audit log's newValue payload. Filters out audit-internal fields, * passes through human-friendly labels for known fields, falls back to * the raw key name when the field isn't in the catalog. */ -function describeUpdateDiff(newValue: Record | null): string { +function describeUpdateDiff( + newValue: Record | null, + fkLabels: FkLabelMaps, +): string { if (!newValue) return 'Interest updated'; // Audit-internal / housekeeping fields skipped from the timeline copy. @@ -235,6 +360,7 @@ function describeUpdateDiff(newValue: Record | null): string { assignedTo: 'owner', yachtId: 'yacht', berthId: 'primary berth', + clientId: 'client', eoiDocStatus: 'EOI status', reservationDocStatus: 'reservation status', contractDocStatus: 'contract status', @@ -255,14 +381,31 @@ function describeUpdateDiff(newValue: Record | null): string { reminderDays: 'reminder cadence', reminderNote: 'reminder note', outcome: 'outcome', + isPrimary: 'primary-berth flag', + isInEoiBundle: 'in EOI bundle', + isSpecificInterest: 'specific-interest flag', + }; + + // FK-field → label-map lookup. When the audit row carries a UUID for + // one of these fields, we substitute the human label (mooring number, + // yacht/client name, user display name) instead of leaking the id. + const FK_FIELD_MAP: Record = { + berthId: 'berths', + yachtId: 'yachts', + clientId: 'clients', + assignedTo: 'users', + ownerId: 'users', + createdBy: 'users', + reassignedTo: 'users', }; const changed: string[] = []; for (const [key, value] of Object.entries(newValue)) { if (SKIP.has(key)) continue; if (key === 'pipelineStage') continue; // handled by the earlier branch - const label = FIELD_LABELS[key] ?? key; - const formatted = formatDiffValue(value); + const label = FIELD_LABELS[key] ?? humanizeKey(key); + const fkMapKey = FK_FIELD_MAP[key]; + const formatted = formatDiffValue(value, fkMapKey ? fkLabels[fkMapKey] : null); changed.push(formatted ? `${label} → ${formatted}` : label); } @@ -272,11 +415,24 @@ function describeUpdateDiff(newValue: Record | null): string { return `Updated ${changed.slice(0, 3).join(', ')} and ${changed.length - 3} more`; } -function formatDiffValue(v: unknown): string { +function humanizeKey(key: string): string { + return key + .replace(/([a-z])([A-Z])/g, '$1 $2') + .replace(/_/g, ' ') + .toLowerCase(); +} + +function formatDiffValue(v: unknown, fkLabelMap: Map | null): string { if (v === null || v === undefined) return 'cleared'; if (typeof v === 'boolean') return v ? 'on' : 'off'; if (typeof v === 'number') return String(v); if (typeof v === 'string') { + // Resolve UUIDs through the supplied FK label map when present, so + // `berthId → a53e3b1d-...` renders as `primary berth → Berth A1`. + // Falls back to "(removed)" if the entity is gone, never the raw id. + if (fkLabelMap) { + return fkLabelMap.get(v) ?? '(removed)'; + } // Truncate verbose strings so the timeline line stays one row. return v.length > 40 ? `${v.slice(0, 37)}…` : v; } diff --git a/src/app/api/v1/interests/[id]/upload-for-signing/route.ts b/src/app/api/v1/interests/[id]/upload-for-signing/route.ts index c1130033..52d49d76 100644 --- a/src/app/api/v1/interests/[id]/upload-for-signing/route.ts +++ b/src/app/api/v1/interests/[id]/upload-for-signing/route.ts @@ -11,7 +11,7 @@ import { import { isPdfMagic } from '@/lib/services/berth-pdf-parser'; /** - * Phase 3 — Custom document upload-to-Documenso endpoint. + * Phase 3 - Custom document upload-to-Documenso endpoint. * * POST `/api/v1/interests/[id]/upload-for-signing` * @@ -25,7 +25,7 @@ import { isPdfMagic } from '@/lib/services/berth-pdf-parser'; * The Contract + Reservation tabs (Phase 4) post here from their * drag-drop UI. Tests can invoke the service directly. * - * Permission: documents.send_for_signing — sending a document for + * Permission: documents.send_for_signing - sending a document for * signing is destructive (queues an outbound email + an admin-visible * Documenso doc). Plus interests.edit because the pipeline-stage * auto-advance side-effect is interest-mutating (matches the @@ -62,7 +62,7 @@ const fieldSchema = z.object({ fieldMeta: z.record(z.string(), z.unknown()).optional(), }); -const documentTypeSchema = z.enum(['contract', 'reservation_agreement']); +const documentTypeSchema = z.enum(['eoi', 'contract', 'reservation_agreement', 'generic']); const MAX_PDF_BYTES = 50 * 1024 * 1024; @@ -104,7 +104,7 @@ export const POST = withAuth( throw new ValidationError(`File exceeds ${MAX_PDF_BYTES / 1024 / 1024} MB cap`); } const buffer = Buffer.from(await file.arrayBuffer()); - // Magic-byte check at the route boundary too — service repeats it + // Magic-byte check at the route boundary too - service repeats it // as defense in depth but a bad upload should error before we hit // any side-effecting code. if (!isPdfMagic(buffer)) { diff --git a/src/app/api/v1/interests/board/route.ts b/src/app/api/v1/interests/board/route.ts index f0d78f3b..4c5ce581 100644 --- a/src/app/api/v1/interests/board/route.ts +++ b/src/app/api/v1/interests/board/route.ts @@ -7,13 +7,13 @@ import { listInterestsForBoard } from '@/lib/services/interests.service'; import { boardFiltersSchema } from '@/lib/validators/interests'; /** - * Board (kanban) endpoint — returns every active interest for the port + * Board (kanban) endpoint - returns every active interest for the port * with a minimal projection (id, clientName, mooring, leadCategory, * stage, updatedAt). No pagination: the kanban renders the whole * pipeline at once. The service hard-caps at 5000 rows to keep payload * size bounded; if `truncated: true` the UI surfaces a banner. * - * Filter params are a strict subset of the list endpoint — see + * Filter params are a strict subset of the list endpoint - see * boardFiltersSchema. `pipelineStage` and `includeArchived` are * intentionally rejected at validation time. */ diff --git a/src/app/api/v1/interests/bulk/route.ts b/src/app/api/v1/interests/bulk/route.ts index 13ee56c6..ae6b7caf 100644 --- a/src/app/api/v1/interests/bulk/route.ts +++ b/src/app/api/v1/interests/bulk/route.ts @@ -19,7 +19,7 @@ import { errorResponse } from '@/lib/errors'; * Synchronous bulk endpoint for the interests list. * * Per-row loop is fine for the page-size cap (100 rows max). Larger jobs - * (CSV imports, port-wide migrations) belong on the BullMQ `bulk` queue — + * (CSV imports, port-wide migrations) belong on the BullMQ `bulk` queue - * see src/lib/queue/workers/bulk.ts. The synchronous path gives the user * instant feedback and a per-row failure list, which the queue can't. */ diff --git a/src/app/api/v1/internal/dev-flags/route.ts b/src/app/api/v1/internal/dev-flags/route.ts index cbd054ee..acae51ac 100644 --- a/src/app/api/v1/internal/dev-flags/route.ts +++ b/src/app/api/v1/internal/dev-flags/route.ts @@ -7,7 +7,7 @@ import { env } from '@/lib/env'; * GET /api/v1/internal/dev-flags * * Read-only feed of dev-mode safety flags that the UI surfaces as - * always-visible badges. Authenticated (any signed-in user) — these + * always-visible badges. Authenticated (any signed-in user) - these * flags affect every outbound email so reps need to see them too, * not just admins. * diff --git a/src/app/api/v1/internal/vitals/route.ts b/src/app/api/v1/internal/vitals/route.ts index 2054ee58..375336fe 100644 --- a/src/app/api/v1/internal/vitals/route.ts +++ b/src/app/api/v1/internal/vitals/route.ts @@ -7,7 +7,7 @@ import { logger } from '@/lib/logger'; * from `WebVitalsReporter` so it survives page unload. Body shape matches * the `Metric` type from `web-vitals` v4. * - * For now we log structured to pino — once we have a perf-tracking table + * For now we log structured to pino - once we have a perf-tracking table * (or external aggregator) wired, this can persist instead. The key value * today is establishing the baseline before optimisation work. */ diff --git a/src/app/api/v1/me/avatar/route.ts b/src/app/api/v1/me/avatar/route.ts index a323e7a4..96f7af4d 100644 --- a/src/app/api/v1/me/avatar/route.ts +++ b/src/app/api/v1/me/avatar/route.ts @@ -17,7 +17,7 @@ const MAX_AVATAR_BYTES = 2 * 1024 * 1024; * table (so an S3↔filesystem swap carries it correctly), and writes * the file id into `user_profiles.avatar_file_id`. * - * Files are scoped to the user's CURRENT port — the rep can't end up + * Files are scoped to the user's CURRENT port - the rep can't end up * with an avatar that's only visible from one port. (Avatars render * via the GET handler below, which presigns by id regardless of port.) */ @@ -98,7 +98,7 @@ export const POST = withAuth(async (req, ctx) => { .where(eq(userProfiles.userId, ctx.userId)); if (priorAvatarId && priorAvatarId !== record.id) { - // Best-effort delete — a stale-blob failure shouldn't fail the + // Best-effort delete - a stale-blob failure shouldn't fail the // new-avatar response. deleteFile handles ref-check + blob // delete + audit so a referenced file (somehow) is safe. try { @@ -111,7 +111,7 @@ export const POST = withAuth(async (req, ctx) => { } catch (err) { logger.warn( { err, priorAvatarId, userId: ctx.userId }, - 'avatar replace: failed to clean up prior avatar file — orphan blob possible', + 'avatar replace: failed to clean up prior avatar file - orphan blob possible', ); } } diff --git a/src/app/api/v1/me/email/confirm/[token]/route.ts b/src/app/api/v1/me/email/confirm/[token]/route.ts index 089324a0..83e9eab9 100644 --- a/src/app/api/v1/me/email/confirm/[token]/route.ts +++ b/src/app/api/v1/me/email/confirm/[token]/route.ts @@ -9,7 +9,7 @@ import { errorResponse, ValidationError } from '@/lib/errors'; import { env } from '@/lib/env'; /** - * Public confirmation endpoint — clicked from the email sent to the + * Public confirmation endpoint - clicked from the email sent to the * NEW address. Applies the email change atomically and redirects the * user back to /settings with a success flag. * diff --git a/src/app/api/v1/me/email/route.ts b/src/app/api/v1/me/email/route.ts index e42c6032..a7fffc02 100644 --- a/src/app/api/v1/me/email/route.ts +++ b/src/app/api/v1/me/email/route.ts @@ -45,7 +45,7 @@ export const PATCH = withAuth(async (req, ctx) => { } if (!REQUIRES_VERIFICATION) { - // Instant change — dev only. + // Instant change - dev only. const [updated] = await db .update(user) .set({ email, emailVerified: false, updatedAt: new Date() }) @@ -67,7 +67,7 @@ export const PATCH = withAuth(async (req, ctx) => { return NextResponse.json({ data: { email: updated.email, instant: true } }); } - // Verification flow — generate a single-use token, hash it, persist. + // Verification flow - generate a single-use token, hash it, persist. const rawToken = crypto.randomBytes(32).toString('base64url'); const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex'); const expiresAt = new Date(Date.now() + VERIFY_TOKEN_TTL_MINUTES * 60 * 1000); @@ -111,7 +111,7 @@ export const PATCH = withAuth(async (req, ctx) => { const confirmBody = `

Hi,

You (or someone using your account) requested to change the sign-in email on your ${appName} account from ${safeOldEmail} to ${safeNewEmail}.

-

Click here to confirm this change — the link expires in ${VERIFY_TOKEN_TTL_MINUTES} minutes.

+

Click here to confirm this change - the link expires in ${VERIFY_TOKEN_TTL_MINUTES} minutes.

If you didn't request this, ignore this email.

`; const cancelBody = ` diff --git a/src/app/api/v1/me/password-reset/route.ts b/src/app/api/v1/me/password-reset/route.ts index fa5f2495..73845ad1 100644 --- a/src/app/api/v1/me/password-reset/route.ts +++ b/src/app/api/v1/me/password-reset/route.ts @@ -10,7 +10,7 @@ import { errorResponse } from '@/lib/errors'; * one-time reset token and dispatches the email via the * `sendResetPassword` callback configured in src/lib/auth/index.ts. * - * The email always goes to the user's CURRENT account email — no way + * The email always goes to the user's CURRENT account email - no way * to redirect to a different inbox here, so the endpoint is safe even * if a session is hijacked (the attacker can't move the reset email * to themselves). diff --git a/src/app/api/v1/me/ports/route.ts b/src/app/api/v1/me/ports/route.ts index 66e1ff05..cb7755d4 100644 --- a/src/app/api/v1/me/ports/route.ts +++ b/src/app/api/v1/me/ports/route.ts @@ -13,14 +13,14 @@ import { errorResponse } from '@/lib/errors'; * * M-NEW-1: this endpoint INTENTIONALLY skips `withAuth`'s port-context * requirement. Callers hit /me/ports specifically to LEARN which ports - * they have access to — they can't have selected one yet, so the + * they have access to - they can't have selected one yet, so the * X-Port-Id header is by definition absent on the first call. Pre-fix * this meant non-super-admins got a 400 "Port context required" and * the client had to special-case the response shape. * * Auth is still enforced (session check); permissions logic skipped * because the endpoint exposes only IDs+slugs+names of ports the user - * is already a member of — same surface area as a `me` profile read. + * is already a member of - same surface area as a `me` profile read. */ export async function GET() { try { diff --git a/src/app/api/v1/me/route.ts b/src/app/api/v1/me/route.ts index be902201..91c41c45 100644 --- a/src/app/api/v1/me/route.ts +++ b/src/app/api/v1/me/route.ts @@ -18,12 +18,12 @@ const updateProfileSchema = z.object({ * Optional sign-in alias. `null` clears the existing value; a string * must match the 2–30 lowercase shape pinned by USERNAME_REGEX (also * enforced by `chk_user_profiles_username_shape` in migration 0054). - * Uniqueness is checked below before the UPDATE — collisions surface + * Uniqueness is checked below before the UPDATE - collisions surface * as a 409 with a friendly message. */ username: z.union([z.string().transform((s) => s.trim().toLowerCase()), z.null()]).optional(), phone: z.string().nullable().optional(), - // Refuse `javascript:` / `data:` schemes — z.string().url() lets them + // Refuse `javascript:` / `data:` schemes - z.string().url() lets them // through and `` would otherwise be a stored-XSS // vector if any future renderer treated the value as a link. avatarUrl: z @@ -32,7 +32,7 @@ const updateProfileSchema = z.object({ .refine((u) => /^https?:\/\//i.test(u), 'must be an http(s) URL') .nullable() .optional(), - // Strict allow-list — no `.passthrough()` here. The previous schema let + // Strict allow-list - no `.passthrough()` here. The previous schema let // arbitrary client-supplied keys survive validation and persist into // `userProfiles.preferences` JSONB unbounded; auditor-E3 §28 caught this. // Add new keys here as the UI surfaces them rather than letting the @@ -51,7 +51,7 @@ const updateProfileSchema = z.object({ // be set via hand-rolled SQL because the allow-list at line // 154 silently stripped unknown keys. defaultPortId: z.string().uuid().optional(), - // Per-table column visibility. Keyed by entity type — entries + // Per-table column visibility. Keyed by entity type - entries // with an empty `hiddenColumns` mean "all visible". The validator // caps total entries / IDs so a malicious client can't bloat the // 8 KB preferences blob; see merge step below for the byte cap. @@ -65,7 +65,7 @@ const updateProfileSchema = z.object({ .strict(), ) .optional(), - // Phase 4 — per-user default reminder firing time-of-day. HH:MM in + // Phase 4 - per-user default reminder firing time-of-day. HH:MM in // 24h local clock (validated below). Per-reminder dueAt overrides // this; this is only the dialog's default when the rep doesn't // pick an explicit time. Server clamps to '00:00'–'23:59'. @@ -158,7 +158,7 @@ export const PATCH = withAuth(async (req, ctx: AuthContext) => { } } if (body.preferences !== undefined) { - // Allow-list — only retain keys defined in the strict schema. Pre- + // Allow-list - only retain keys defined in the strict schema. Pre- // strict rows may carry extra keys from when the schema was // .passthrough(); the merge prunes them so legacy bloat doesn't // accumulate forever, and a future schema regression that tries @@ -169,7 +169,7 @@ export const PATCH = withAuth(async (req, ctx: AuthContext) => { 'timezone', 'tablePreferences', 'defaultPortId', - // Phase 4 — reminder default firing time. + // Phase 4 - reminder default firing time. 'digestTimeOfDay', ]); const existing = (profile.preferences as Record) ?? {}; @@ -178,7 +178,7 @@ export const PATCH = withAuth(async (req, ctx: AuthContext) => { ALLOWED_PREF_KEYS.has(k), ), ); - // Hard cap on the merged JSONB — defense in depth against any + // Hard cap on the merged JSONB - defense in depth against any // future schema growth that might re-introduce free-form keys. const serialized = JSON.stringify(merged); if (Buffer.byteLength(serialized, 'utf8') > 8 * 1024) { @@ -190,7 +190,7 @@ export const PATCH = withAuth(async (req, ctx: AuthContext) => { // concurrency-auditor M-2: pre-check at line 132-139 is TOCTOU // against `idx_user_profiles_username_unique`. Two concurrent claims // on the same username will see "available" in their own pre-check - // and the loser's UPDATE fails with 23505 — surface that as + // and the loser's UPDATE fails with 23505 - surface that as // ConflictError rather than letting it bubble as a generic 500. let updated; try { diff --git a/src/app/api/v1/reports/generate/route.ts b/src/app/api/v1/reports/generate/route.ts index f2e67b96..53251a12 100644 --- a/src/app/api/v1/reports/generate/route.ts +++ b/src/app/api/v1/reports/generate/route.ts @@ -66,7 +66,7 @@ const requestSchema = z.object({ * new permission added in seed-data; defaults to true for super- * admins and sales-managers, false for sales-agents). The route also * defends in depth via per-section service calls, each of which - * reapplies the active port filter — a rep with reports.export but + * reapplies the active port filter - a rep with reports.export but * no clients.view still gets a clients table in the PDF, since the * export is a snapshot of state the rep already has dashboard access * to. Defer narrower per-section gating to a follow-up once the @@ -84,7 +84,10 @@ export const POST = withAuth( const data: ReportData = {}; switch (body.config.kind) { case 'dashboard': - data.dashboard = await resolveDashboardReportData(ctx.portId, body.config.widgetIds); + data.dashboard = await resolveDashboardReportData(ctx.portId, body.config.widgetIds, { + dateFrom: body.config.dateFrom, + dateTo: body.config.dateTo, + }); break; case 'clients': data.clients = await resolveClientReportData(ctx.portId, { @@ -122,7 +125,7 @@ export const POST = withAuth( }); // Audit BEFORE returning so a failed write doesn't silently - // leak an export. The `void` is intentional — audit is best- + // leak an export. The `void` is intentional - audit is best- // effort relative to the export; the PDF download succeeding // is the contract. void createAuditLog({ @@ -149,7 +152,7 @@ export const POST = withAuth( const filename = sanitizeFilename(`${body.title}.pdf`); // Stream the buffer back inline. The Content-Disposition uses - // `attachment; filename*` (RFC 5987) for unicode titles — + // `attachment; filename*` (RFC 5987) for unicode titles - // Port names with diacritics need this or browsers fall back // to a mojibake'd ASCII filename. `filename=` carries the // ASCII fallback for older HTTP stacks. diff --git a/src/app/api/v1/reports/templates/[id]/route.ts b/src/app/api/v1/reports/templates/[id]/route.ts index ac2151f7..07a10b40 100644 --- a/src/app/api/v1/reports/templates/[id]/route.ts +++ b/src/app/api/v1/reports/templates/[id]/route.ts @@ -21,10 +21,10 @@ const patchBodySchema = z }); /** - * GET — single template (used by the dialog to hydrate a saved + * GET - single template (used by the dialog to hydrate a saved * template into the form when the rep picks it). - * PATCH — rename, retitle, or rewrite the config. - * DELETE — remove. Cascade-safe: no FKs reference a saved template. + * PATCH - rename, retitle, or rewrite the config. + * DELETE - remove. Cascade-safe: no FKs reference a saved template. */ export const GET = withAuth( withPermission('reports', 'export', async (_req, ctx, params) => { diff --git a/src/app/api/v1/reports/templates/route.ts b/src/app/api/v1/reports/templates/route.ts index 3f3a9756..d1228753 100644 --- a/src/app/api/v1/reports/templates/route.ts +++ b/src/app/api/v1/reports/templates/route.ts @@ -25,7 +25,7 @@ const createBodySchema = z.object({ * Persist a template. The dialog calls this when the rep ticks * "Save as template" while configuring an export. * - * Both gated on `reports.export` — the same permission that lets + * Both gated on `reports.export` - the same permission that lets * the rep generate reports also lets them save templates. */ export const GET = withAuth( diff --git a/src/app/api/v1/residential/interests/bulk/route.ts b/src/app/api/v1/residential/interests/bulk/route.ts index c8132668..82e84289 100644 --- a/src/app/api/v1/residential/interests/bulk/route.ts +++ b/src/app/api/v1/residential/interests/bulk/route.ts @@ -11,7 +11,7 @@ import { import { PIPELINE_STAGES } from '@/lib/validators/residential'; /** - * Synchronous bulk endpoint for the residential interests list — mirrors + * Synchronous bulk endpoint for the residential interests list - mirrors * the `/api/v1/interests/bulk` shape (and the new `/api/v1/berths/bulk`) * so the rep-facing UX is consistent. Per-row loop with a 100-id cap. * diff --git a/src/app/api/v1/saved-views/route.ts b/src/app/api/v1/saved-views/route.ts index 2b9b3d4c..4a9ed0d5 100644 --- a/src/app/api/v1/saved-views/route.ts +++ b/src/app/api/v1/saved-views/route.ts @@ -13,7 +13,7 @@ const listQuerySchema = z.object({ // Saved views are owner-only by design: every service call filters by // (portId, userId), so any authenticated user can manage exactly their -// own views. We deliberately skip `withPermission(...)` here — there is +// own views. We deliberately skip `withPermission(...)` here - there is // no resource-level permission to add. See `savedViewsService` for the // ownership filter that backs this route. export const GET = withAuth(async (req, ctx) => { diff --git a/src/app/api/v1/search/recently-viewed/route.ts b/src/app/api/v1/search/recently-viewed/route.ts index e6b6ef3f..0366d6fc 100644 --- a/src/app/api/v1/search/recently-viewed/route.ts +++ b/src/app/api/v1/search/recently-viewed/route.ts @@ -9,7 +9,7 @@ import { getRecentlyViewed, trackView } from '@/lib/services/recently-viewed.ser import { trackViewSchema } from '@/lib/validators/search'; /** - * Hydrated row returned by GET — contains the same `type` + `id` from + * Hydrated row returned by GET - contains the same `type` + `id` from * the Redis sorted set, plus the labels needed to render the row in the * dropdown ("Client · Jane Smith") without an extra round-trip. * diff --git a/src/app/api/v1/search/route.ts b/src/app/api/v1/search/route.ts index f86d08a8..207729fb 100644 --- a/src/app/api/v1/search/route.ts +++ b/src/app/api/v1/search/route.ts @@ -34,7 +34,7 @@ export const GET = withAuth(async (req: NextRequest, ctx) => { ]); // Resolve `:portSlug` placeholders in the nav-catalog hrefs. Done - // here (not in the service) so the service stays portSlug-free — + // here (not in the service) so the service stays portSlug-free - // it only knows portId, which is the right tenant boundary anyway. if (results.navigation.length > 0) { results.navigation = results.navigation.map((n) => ({ @@ -46,7 +46,7 @@ export const GET = withAuth(async (req: NextRequest, ctx) => { // Re-run affinity sort with the now-resolved set. The service can // also accept this up front, but we kick off both queries in - // parallel for latency, then apply on results — affinity is just a + // parallel for latency, then apply on results - affinity is just a // post-sort, so order of operations doesn't change correctness. if (touchedIds.size > 0) { const reorder = (rows: T[]) => { @@ -68,7 +68,7 @@ export const GET = withAuth(async (req: NextRequest, ctx) => { results.reminders = reorder(results.reminders); } - // Fire-and-forget — recent search history is non-critical. + // Fire-and-forget - recent search history is non-critical. if (parsed.q.length >= 2) { saveRecentSearch(ctx.userId, ctx.portId, parsed.q); } diff --git a/src/app/api/v1/tracked-links/route.ts b/src/app/api/v1/tracked-links/route.ts index ad2eae68..40b6b64d 100644 --- a/src/app/api/v1/tracked-links/route.ts +++ b/src/app/api/v1/tracked-links/route.ts @@ -11,7 +11,7 @@ import { errorResponse } from '@/lib/errors'; * * Mints a new tracked redirect-link the rep can drop into an outgoing * email or chat. Body: { targetUrl, sendId? }. Returns the slug + the - * full public URL (`/q/`) — caller pastes the URL into + * full public URL (`/q/`) - caller pastes the URL into * the message draft. * * Gated on `email.send` since this surface is consumed from compose UIs. diff --git a/src/app/api/v1/vocabularies/route.ts b/src/app/api/v1/vocabularies/route.ts index e51a3088..1606be18 100644 --- a/src/app/api/v1/vocabularies/route.ts +++ b/src/app/api/v1/vocabularies/route.ts @@ -10,7 +10,7 @@ import { resolveVocabulary, VOCABULARIES, type VocabularyKey } from '@/lib/vocab * * Returns the resolved per-port vocabulary lists (admin overrides * merged with shipped defaults). Any authenticated user in this port - * can read — vocabularies drive in-app pickers (status reasons, + * can read - vocabularies drive in-app pickers (status reasons, * interest temperatures, expense categories, etc.) so reps need read * access without holding `admin.manage_settings`. * diff --git a/src/app/api/v1/website-analytics/route.ts b/src/app/api/v1/website-analytics/route.ts index 1e0e6d03..ba9fd612 100644 --- a/src/app/api/v1/website-analytics/route.ts +++ b/src/app/api/v1/website-analytics/route.ts @@ -38,7 +38,7 @@ import { const ISO_DATE_RX = /^\d{4}-\d{2}-\d{2}$/; // Umami v2/v3 metric `type` values surfaced by the CRM. `path` is the -// current name for what older versions called `url` — accept both as +// current name for what older versions called `url` - accept both as // inbound metric names (old clients won't break) but `path` is what the // service forwards to Umami. const TOP_METRIC_RX = /^top-(path|url|referrer|country|browser|os|device|event)$/; @@ -119,7 +119,7 @@ export const GET = withAuth( data = await getSessionsWeekly(ctx.portId, range); } else if (TOP_METRIC_RX.test(metric)) { const raw = metric.replace(/^top-/, ''); - // Legacy alias — older callers still send `top-url`; map to the + // Legacy alias - older callers still send `top-url`; map to the // Umami v3 enum name to keep them working post-rewrite. const type = (raw === 'url' ? 'path' : raw) as UmamiMetricType; const limit = Number(url.searchParams.get('limit') ?? 10); @@ -138,7 +138,7 @@ export const GET = withAuth( // `data === null` from the service means Umami isn't configured for // this port. Return 200 with `data: null` + the explicit // `notConfigured: true` flag so the UI renders a "configure your - // credentials" empty state. **Do not throw** — a 409 here would + // credentials" empty state. **Do not throw** - a 409 here would // trigger React Query's default retry loop, and every retry fires // a system_settings lookup → pool saturation → server hang. if (data === null) { diff --git a/src/app/api/v1/yachts/[id]/field-history/route.ts b/src/app/api/v1/yachts/[id]/field-history/route.ts index bcce18e5..579d2b8b 100644 --- a/src/app/api/v1/yachts/[id]/field-history/route.ts +++ b/src/app/api/v1/yachts/[id]/field-history/route.ts @@ -12,7 +12,7 @@ import { errorResponse } from '@/lib/errors'; * Returns every supplemental-form override that touched the yacht, * resolved by joining interest_field_history through interests.yachtId. * The history table itself doesn't carry a yacht_id column (the writer - * scopes by interest + client only) — joining at read time avoids a + * scopes by interest + client only) - joining at read time avoids a * schema migration just to support this rollup. */ export const GET = withAuth( diff --git a/src/app/api/webhooks/documenso/route.ts b/src/app/api/webhooks/documenso/route.ts index d7c80c3a..c56ff135 100644 --- a/src/app/api/webhooks/documenso/route.ts +++ b/src/app/api/webhooks/documenso/route.ts @@ -32,7 +32,7 @@ function canonicalizeEvent(event: string): string { // Discriminated union of every Documenso event we know how to react to. // Adding a new event type forces a compile error in the `match(...)` -// below via `.exhaustive()` — so we can't ship a Documenso 2.x bump +// below via `.exhaustive()` - so we can't ship a Documenso 2.x bump // without consciously deciding how to handle each new event. Anything // not in this list falls through to the structured-log catch-all below. type KnownDocumensoEvent = @@ -90,7 +90,7 @@ type DocumensoRecipient = { readStatus?: string; signedAt?: string | null; /** Per-recipient signing token Documenso uses as the URL tail. - * Present on both v1.13 and v2 payloads under varied field names — + * Present on both v1.13 and v2 payloads under varied field names - * we coalesce them below. Phase 2: passed through to the handlers * so they can match against `document_signers.signing_token` * instead of email. */ @@ -162,8 +162,8 @@ async function handleDocumensoWebhook(req: NextRequest): Promise { source: 'webhook', }); } - // Always return 200 (webhook best-practice — don't leak signal). Body - // is intentionally empty/uniform — error-ux-auditor H5 noted the + // Always return 200 (webhook best-practice - don't leak signal). Body + // is intentionally empty/uniform - error-ux-auditor H5 noted the // literal "Invalid secret" string confirms the endpoint expects a // secret, which is a free reconnaissance hint for enumeration. return NextResponse.json({ ok: false }, { status: 200 }); @@ -206,14 +206,14 @@ async function handleDocumensoWebhook(req: NextRequest): Promise { // Every handler accepts an optional `portId` and refuses to mutate when // the lookup is ambiguous across multiple ports without one. Forward - // the secret-resolved portId everywhere — not just the expired path — + // the secret-resolved portId everywhere - not just the expired path - // so signed/completed/opened/rejected/cancelled events can't flip a // foreign-tenant document via documensoId reuse. const portScope = matchedPortId ? { portId: matchedPortId } : {}; try { if (!isKnownEvent(event)) { - // New / unknown Documenso event — structured log catches the + // New / unknown Documenso event - structured log catches the // shape so we can add a handler before the next webhook lands. logger.info({ event }, 'Unhandled Documenso webhook event type'); } else { @@ -222,12 +222,12 @@ async function handleDocumensoWebhook(req: NextRequest): Promise { // v1.13 fires DOCUMENT_SIGNED per recipient sign; // 2.x fires DOCUMENT_RECIPIENT_COMPLETED for the same semantics. // Some 2.x deployments emit RECIPIENT_SIGNED as a v2-flavoured alias - // — log when we see it (telemetry) and route to the same handler so + // - log when we see it (telemetry) and route to the same handler so // v2 deployments don't silently drop per-recipient signs. if (e === 'RECIPIENT_SIGNED') { logger.info( { event: e, documensoId }, - 'Documenso v2 RECIPIENT_SIGNED received — routing to recipient-signed handler', + 'Documenso v2 RECIPIENT_SIGNED received - routing to recipient-signed handler', ); } const signedRecipients = recipients.filter( @@ -253,7 +253,7 @@ async function handleDocumensoWebhook(req: NextRequest): Promise { if (e === 'RECIPIENT_VIEWED') { logger.info( { event: e, documensoId }, - 'Documenso v2 RECIPIENT_VIEWED received — routing to document-opened handler', + 'Documenso v2 RECIPIENT_VIEWED received - routing to document-opened handler', ); } const openedRecipients = recipients.filter( @@ -293,7 +293,7 @@ async function handleDocumensoWebhook(req: NextRequest): Promise { await handleDocumentExpired({ documentId: documensoId, ...portScope }); }) .with('DOCUMENT_REMINDER_SENT', async () => { - // Auto-reminder — informational only, no state change. + // Auto-reminder - informational only, no state change. logger.info( { documensoId, @@ -313,7 +313,7 @@ async function handleDocumensoWebhook(req: NextRequest): Promise { } catch (err) { logger.error({ err, event }, 'Error processing Documenso webhook'); // The audit caught that webhook handlers were the only API surface - // bypassing the platform-error pipeline — admin/errors was silent on + // bypassing the platform-error pipeline - admin/errors was silent on // Documenso webhook crashes. Pipe them in so they surface alongside // every other 5xx. void captureErrorEvent({ @@ -327,7 +327,7 @@ async function handleDocumensoWebhook(req: NextRequest): Promise { } // Wrap with withPublicContext so the handler runs inside a -// runWithRequestContext ALS frame — without it the inline +// runWithRequestContext ALS frame - without it the inline // `captureErrorEvent` call in the catch block silently no-ops because // getRequestContext() returns null for unauthenticated routes. export const POST = withPublicContext(handleDocumensoWebhook); diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index a25594c7..2260a352 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -13,7 +13,7 @@ import { userPortRoles, userProfiles } from '@/lib/db/schema/users'; * port slug. * * Resolution order: - * 1. `preferences.defaultPortId` — last port the user worked in, written + * 1. `preferences.defaultPortId` - last port the user worked in, written * by the port-switcher. Only honoured if the user still has access * (preference may be stale after a role revoke or port archive). * 2. Super-admin → first port alphabetically. Other users → first @@ -31,7 +31,7 @@ export default async function DashboardRedirectPage() { let slug: string | undefined; if (lastPortId) { - // Verify access before honouring the preference — a stale id (port + // Verify access before honouring the preference - a stale id (port // archived, role revoked) shouldn't strand the user on a 403. if (profile?.isSuperAdmin) { const port = await db.query.ports.findFirst({ where: eq(portsTable.id, lastPortId) }); diff --git a/src/app/docs/deal-pulse/page.tsx b/src/app/docs/deal-pulse/page.tsx index 8aad3b79..dc50dc7a 100644 --- a/src/app/docs/deal-pulse/page.tsx +++ b/src/app/docs/deal-pulse/page.tsx @@ -7,14 +7,14 @@ export const metadata: Metadata = { }; /** - * §7.1 — public explainer page for the Deal Pulse + Heat scoring model. + * §7.1 - public explainer page for the Deal Pulse + Heat scoring model. * Linked from the popover in `deal-pulse-chip.tsx` ("Full guide"). Kept * intentionally text-heavy + jargon-free so a new sales rep can read * once and internalize the model without bouncing back to the kanban. * * Public route (no auth) so external docs links and email signatures * resolve cleanly. The route lives outside (dashboard) for that reason - * — middleware lets `/docs/...` through. + * - middleware lets `/docs/...` through. */ export default function DealPulseDocsPage() { return ( diff --git a/src/app/globals.css b/src/app/globals.css index aea66b90..1b37ed90 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -194,7 +194,7 @@ --secondary-foreground: 0 0% 100%; --muted: 210 11% 96%; /* #f1f3f5 */ --muted-foreground: 228 10% 49%; /* #71768a */ - --accent: 213 60% 95%; /* #eef3fb — soft brand-blue tint for hover/focus */ + --accent: 213 60% 95%; /* #eef3fb - soft brand-blue tint for hover/focus */ --accent-foreground: 224 39% 19%; /* dark navy text for contrast on light bg */ --destructive: 0 65% 51%; /* #d32f2f */ --destructive-foreground: 0 0% 100%; @@ -289,7 +289,7 @@ /* * No global focus ring. shadcn components opt in individually * (Button uses `focus-visible:ring-1`, DropdownMenuItem uses - * `focus:bg-accent`, etc.) — that gives us a quiet, per-component + * `focus:bg-accent`, etc.) - that gives us a quiet, per-component * indicator without the chunky `ring-2 + ring-offset-2` artifact * the global rule was creating on every rounded element. * @@ -372,7 +372,7 @@ div.recharts-responsive-container:focus-visible, * Vaul drawer (bottom-direction) animation timing override. * * Vaul's defaults feel slightly snappy when the drawer is full-screen - * (mobile search overlay) — the snap-on / snap-off reads as janky at + * (mobile search overlay) - the snap-on / snap-off reads as janky at * scale. We slow it down and use a softer easing curve (ease-out-quint) * which decelerates smoothly without the elastic kick. * @@ -409,7 +409,7 @@ div.recharts-responsive-container:focus-visible, * sub-pixel jitter during the spring-physics close animation. * * `contain: layout style paint` isolates the drawer's render tree - * from the rest of the document — repaints inside the drawer (e.g. + * from the rest of the document - repaints inside the drawer (e.g. * focus-state changes on a button) don't invalidate the parent. */ [data-vaul-drawer] { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1482a3d4..93ad6302 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -44,7 +44,7 @@ export async function generateMetadata(): Promise { default: appName, template: `%s | ${appName}`, }, - description: `${appName} — marina management system`, + description: `${appName} - marina management system`, appleWebApp: { capable: true, statusBarStyle: 'black-translucent', diff --git a/src/app/page.tsx b/src/app/page.tsx index beea0c67..3cb23118 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,7 +1,7 @@ import { redirect } from 'next/navigation'; /** - * Root `/` has no UI of its own — every authenticated surface lives under + * Root `/` has no UI of its own - every authenticated surface lives under * a port slug. Forward to `/dashboard`, which resolves the user's default * port and redirects again to `//dashboard`. Without this, a * post-login redirect of `redirect=/` (set by middleware when the user diff --git a/src/app/public/supplemental-info/[token]/page.tsx b/src/app/public/supplemental-info/[token]/page.tsx index dc946428..bca0a78d 100644 --- a/src/app/public/supplemental-info/[token]/page.tsx +++ b/src/app/public/supplemental-info/[token]/page.tsx @@ -158,7 +158,7 @@ export default function SupplementalInfoPage({ params }: PageProps) { ); } - // Tokens are now reusable until expiry — the consumed flag is kept + // Tokens are now reusable until expiry - the consumed flag is kept // so the form can show a soft "you've submitted this before" banner // (and prefill the entered values) without locking the recipient out // of updating their details. diff --git a/src/app/q/[slug]/route.ts b/src/app/q/[slug]/route.ts index 67314883..46820d27 100644 --- a/src/app/q/[slug]/route.ts +++ b/src/app/q/[slug]/route.ts @@ -9,9 +9,9 @@ import { trackEvent } from '@/lib/services/umami.service'; /** * GET /q/[slug] * - * Phase 4c — tracked redirect link. Looks up the slug, records the + * Phase 4c - tracked redirect link. Looks up the slug, records the * click (fire-and-forget so the redirect stays fast), and 302s the - * recipient to the target URL. Unknown slugs 404 — we deliberately do + * recipient to the target URL. Unknown slugs 404 - we deliberately do * NOT redirect anonymous traffic to a default home page since that * would be an open-redirect risk (although `targetUrl` is admin-stored * not user-supplied, this keeps the endpoint surface small). @@ -25,7 +25,7 @@ export async function GET( ): Promise { const { slug } = await ctx.params; - // Slug format gate — reject obvious noise without hitting the DB. + // Slug format gate - reject obvious noise without hitting the DB. if (!/^[a-zA-Z0-9_-]{1,64}$/.test(slug)) { return new NextResponse('Not found', { status: 404 }); } diff --git a/src/components/admin/admin-sections-browser.tsx b/src/components/admin/admin-sections-browser.tsx index 6b522ef3..7168dbfb 100644 --- a/src/components/admin/admin-sections-browser.tsx +++ b/src/components/admin/admin-sections-browser.tsx @@ -4,8 +4,10 @@ import { useMemo, useState } from 'react'; import Link from 'next/link'; import { Activity, - BarChart3, + AlertCircle, + Anchor, BellRing, + BookMarked, BookOpen, ClipboardList, CopyCheck, @@ -15,6 +17,7 @@ import { FileText, FileUp, GitBranch, + Home, Inbox, ListChecks, Mail, @@ -64,62 +67,60 @@ interface AdminGroup { // Catalog lives inside the client component so React Server Components don't // need to serialize the lucide icon factories (functions / $$typeof refs) -// across the RSC boundary — that crash was the error the rep hit when the +// across the RSC boundary - that crash was the error the rep hit when the // catalog lived in the server-component `page.tsx`. Adding a new admin -// surface? Append it to the matching group below. +// surface? Append it to the matching domain below. +// +// 7-domain IA regrouped 2026-05-22 per docs/admin-ia-proposal.md: +// 1. Brand & Communication - how outbound looks +// 2. Sales workflow - pipeline behaviour + templates +// 3. Catalog - tenant-defined data shapes +// 4. Identity & access - who can use the system +// 5. Inbox & data quality - admin queues + cleanup +// 6. Integrations - external systems + providers +// 7. System & observability - infra + diagnostics + escape hatches +// +// Pages deleted in the regroup (redirect-shimmed): /admin/ocr (dup of /ai), +// /admin/invitations (merged into /users 2026-05-21), /admin/reports +// (duplicated dashboard widgets). See docs/admin-ia-proposal.md §8. const GROUPS: AdminGroup[] = [ { - title: 'Access', - description: 'Who can sign in and what they can do once they do.', + title: 'Brand & Communication', + description: 'How outbound looks and which channels it ships on.', sections: [ { - href: 'users', - label: 'Users', - description: 'CRM accounts, role assignments, and per-user residential access toggles.', - icon: Users, - keywords: [ - 'accounts', - 'staff', - 'team', - 'disable user', - 'reset password', - 'residential access', - ], + href: 'branding', + label: 'Branding', + description: 'App name, logo, primary color, and email header/footer HTML.', + icon: Paintbrush, + keywords: ['logo', 'colors', 'email shell', 'preview', 'test email'], }, - // /admin/invitations merged into /admin/users (Active users + - // Invitations tabs) on 2026-05-21. The standalone tile was - // removed; reps still find the invitation flow via the Users - // tile's "Invitations" tab. { - href: 'roles', - label: 'Roles & Permissions', - description: 'Default permission sets and per-port role overrides.', - icon: Shield, + href: 'email', + label: 'Email Settings', + description: + 'SMTP credentials (noreply + sales), per-template tester, routing rules, and the connectivity probe.', + icon: Mail, + keywords: ['smtp', 'noreply', 'sales mailbox', 'test send', 'routing'], + }, + { + href: 'email-templates', + label: 'Email Templates', + description: + 'Subject-line + body overrides for transactional emails (portal, inquiry, invite).', + icon: FilePen, }, ], }, { - title: 'Configuration', - description: 'Branding, integrations, and per-port settings.', + title: 'Sales workflow', + description: 'How the pipeline behaves - triggers, scoring, document + form templates.', sections: [ - { - href: 'email', - label: 'Email Settings', - description: 'From address, signatures, and per-port SMTP overrides.', - icon: Mail, - }, - { - href: 'documenso', - label: 'EOI signing service', - description: - 'API credentials, EOI template, and default in-app vs external signing pathway.', - icon: FileSignature, - }, { href: 'pipeline-rules', - label: 'Pipeline auto-advance', + label: 'Pipeline rules', description: - 'Per-trigger control: which lifecycle events (EOI signed, deposit received, contract signed) auto-advance the deal stage.', + 'Berth-rules engine triggers + per-event auto-advance (EOI signed, deposit received, contract signed).', icon: GitBranch, keywords: ['pipeline', 'auto-advance', 'stage rules', 'aggressive', 'conservative'], }, @@ -127,7 +128,7 @@ const GROUPS: AdminGroup[] = [ href: 'pulse', label: 'Deal Pulse', description: - 'Configure the chip on every interest header — master toggle, per-signal toggles, and tier-label overrides.', + 'Configure the chip on every interest header - master toggle, per-signal toggles, and tier-label overrides.', icon: Activity, keywords: ['pulse', 'deal-health', 'health chip', 'hot warm cold'], }, @@ -138,15 +139,232 @@ const GROUPS: AdminGroup[] = [ icon: BellRing, }, { - href: 'branding', - label: 'Branding', - description: 'App name, logo, primary color, and email header/footer HTML.', - icon: Paintbrush, + href: 'qualification-criteria', + label: 'Qualification criteria', + description: + 'Checklist reps complete to qualify an enquiry. Enable/disable/reorder per port; affects the "ready to qualify" hint on the interest detail.', + icon: ListChecks, + keywords: ['qualification', 'criteria', 'checklist', 'qualify', 'enquiry', 'sales gate'], + }, + { + href: 'residential-stages', + label: 'Residential pipeline stages', + description: + 'Configure stages residential interests flow through. Removing a stage with active interests prompts for reassignment.', + icon: Home, + keywords: ['stages', 'pipeline', 'residential funnel', 'reassign'], + }, + { + href: 'forms', + label: 'Forms', + description: 'Form templates used by client-facing inquiry and intake flows.', + icon: ClipboardList, + }, + { + href: 'templates', + label: 'Document Templates', + description: + 'PDF + email templates with merge-field placeholders - EOI, reservation, contract.', + icon: FileText, + }, + ], + }, + { + title: 'Catalog', + description: 'Tenant-defined data shapes that attach to records.', + sections: [ + { + href: 'vocabularies', + label: 'Vocabularies', + description: + 'Per-port pick lists used across the CRM: interest temperatures, status reasons, tenure types, expense categories, document types.', + icon: BookOpen, + }, + { + href: 'tags', + label: 'Tags', + description: 'Color-coded tags applied to clients, yachts, companies, and interests.', + icon: Tag, + }, + { + href: 'custom-fields', + label: 'Custom Fields', + description: 'Tenant-defined fields for clients, yachts, and reservations.', + icon: SlidersHorizontal, + }, + { + href: 'brochures', + label: 'Brochures', + description: 'Per-port versioned brochure PDFs distributed through the public site + reps.', + icon: BookMarked, + }, + ], + }, + { + title: 'Identity & access', + description: 'Who can sign in and what they can do once they do.', + sections: [ + { + href: 'users', + label: 'Users', + description: + 'Active CRM accounts + pending invitations (merged tabs), role assignments, residential access toggles.', + icon: Users, + keywords: [ + 'accounts', + 'staff', + 'team', + 'invitations', + 'invite', + 'disable user', + 'reset password', + 'residential access', + ], + }, + { + href: 'roles', + label: 'Roles & Permissions', + description: 'Default permission sets and per-port role overrides.', + icon: Shield, + }, + { + href: 'ports', + label: 'Ports', + description: 'Manage the marinas/ports this installation serves (super-admin only).', + icon: Ship, + }, + ], + }, + { + title: 'Inbox & data quality', + description: 'Admin queues for inbound submissions + cleanup tooling.', + sections: [ + { + href: 'inquiries', + label: 'Inquiry Inbox', + description: + 'Submissions captured from the public marketing site (berth, residence, contact).', + icon: Inbox, + }, + { + href: 'sends', + label: 'Send Log', + description: 'Brochure and per-berth PDF sends, with delivery failures surfaced for retry.', + icon: Send, + }, + { + href: 'duplicates', + label: 'Duplicates', + description: 'Review queue of suspected duplicate clients flagged by the dedup engine.', + icon: CopyCheck, + }, + { + href: 'import', + label: 'Bulk Import', + description: 'CSV-driven imports for clients, yachts, and reservations.', + icon: FileUp, + }, + { + href: 'berths', + label: 'Berths admin', + description: + 'Bulk-add new berths in one wizard + reconcile berths missing required fields.', + icon: Anchor, + keywords: ['bulk add', 'reconcile', 'berth pdf', 'mooring', 'bulk berths'], + }, + ], + }, + { + title: 'Integrations', + description: 'External systems + provider configuration.', + sections: [ + { + href: 'documenso', + label: 'Signing service (Documenso)', + description: + 'API credentials, signer identities, templates, and signing-behaviour for every document the CRM puts out for signature.', + icon: FileSignature, + keywords: ['documenso', 'eoi', 'signing', 'envelope', 'signer', 'embedded'], + }, + { + href: 'webhooks', + label: 'Webhooks', + description: 'Outgoing webhook subscriptions, secrets, and delivery log.', + icon: Webhook, + }, + { + href: 'website-analytics', + label: 'Website analytics (Umami)', + description: 'Per-port Umami URL, API token, and Website ID.', + icon: TrendingUp, + keywords: ['umami', 'analytics', 'traffic', 'visitors', 'marketing', 'pageviews'], + }, + { + href: 'ai', + label: 'AI configuration', + description: + 'One panel for every AI feature: master switch, provider credentials, OCR settings, interest scoring, email-drafts, recommender controls.', + icon: Sparkles, + keywords: [ + 'openai', + 'anthropic', + 'gpt', + 'claude', + 'llm', + 'api key', + 'embeddings', + 'receipt', + 'scan', + 'tesseract', + 'ocr', + 'expense scanner', + 'interest scoring', + 'email drafts', + ], + }, + ], + }, + { + title: 'System & observability', + description: 'Infra, observability, escape hatches.', + sections: [ + { + href: 'audit', + label: 'Audit Log', + description: 'Searchable log of every authenticated mutation in the system.', + icon: ScrollText, + }, + { + href: 'monitoring', + label: 'Queue Monitoring', + description: 'BullMQ queue health, throughput, and retry diagnostics.', + icon: Activity, + }, + { + href: 'errors', + label: 'Errors', + description: + 'Recent server-side error events with request-id drill-down, plus the error-code catalog.', + icon: AlertCircle, + keywords: ['exceptions', 'request id', 'stack trace', 'error code'], + }, + { + href: 'backup', + label: 'Backup & Restore', + description: 'Backup posture + retention policy (read-only).', + icon: DatabaseBackup, + }, + { + href: 'storage', + label: 'Storage Backend', + description: + 'Choose between S3-compatible object store or local filesystem; migrate between them.', + icon: Server, }, { href: 'settings', label: 'System Settings', - description: 'Generic key/value configuration store for advanced flags.', + description: 'Generic key/value configuration store for advanced flags (escape hatch).', icon: Settings, // Mirrors KNOWN_SETTINGS in settings-manager.tsx so a search for any // individual flag jumps straight to this card. Keep in sync when @@ -188,194 +406,15 @@ const GROUPS: AdminGroup[] = [ 'default currency', ], }, - { - href: 'webhooks', - label: 'Webhooks', - description: 'Outgoing webhook subscriptions, secrets, and delivery log.', - icon: Webhook, - }, - ], - }, - { - title: 'Content', - description: 'Forms, templates, and labels that users see.', - sections: [ - { - href: 'forms', - label: 'Forms', - description: 'Form templates used by client-facing inquiry and intake flows.', - icon: ClipboardList, - }, - { - href: 'templates', - label: 'Document Templates', - description: 'PDF + email templates with merge-field placeholders.', - icon: FileText, - }, - { - href: 'email-templates', - label: 'Email Templates', - description: 'Customize subject lines for transactional emails (portal, inquiry, invite).', - icon: FilePen, - }, - { - href: 'tags', - label: 'Tags', - description: 'Color-coded tags applied to clients, yachts, companies, and interests.', - icon: Tag, - }, - { - href: 'vocabularies', - label: 'Vocabularies', - description: - 'Per-port pick lists used across the CRM: interest temperatures, status reasons, tenure types, expense categories, document types.', - icon: BookOpen, - }, - { - href: 'custom-fields', - label: 'Custom Fields', - description: 'Tenant-defined fields for clients, yachts, and reservations.', - icon: SlidersHorizontal, - }, - ], - }, - { - title: 'Data Quality', - description: 'Cleanup, imports, and the audit trail.', - sections: [ - { - href: 'inquiries', - label: 'Inquiry Inbox', - description: - 'Submissions captured from the public marketing site (berth, residence, contact).', - icon: Inbox, - }, - { - href: 'sends', - label: 'Send Log', - description: 'Brochure and per-berth PDF sends, with delivery failures surfaced for retry.', - icon: Send, - }, - { - href: 'duplicates', - label: 'Duplicates', - description: 'Review queue of suspected duplicate clients flagged by the dedup engine.', - icon: CopyCheck, - }, - { - href: 'import', - label: 'Bulk Import', - description: 'CSV-driven imports for clients, yachts, and reservations.', - icon: FileUp, - }, - { - href: 'audit', - label: 'Audit Log', - description: 'Searchable log of every authenticated mutation in the system.', - icon: ScrollText, - }, - ], - }, - { - title: 'Operations', - description: 'Health checks and disaster recovery.', - sections: [ - { - href: 'reports', - label: 'Reports', - description: 'Saved analytics views and ad-hoc query results.', - icon: BarChart3, - }, - { - href: 'monitoring', - label: 'Queue Monitoring', - description: 'BullMQ queue health, throughput, and retry diagnostics.', - icon: Activity, - }, - { - href: 'backup', - label: 'Backup & Restore', - description: 'Backup posture + retention policy (read-only).', - icon: DatabaseBackup, - }, - { - href: 'storage', - label: 'Storage Backend', - description: - 'Choose between S3-compatible object store or local filesystem; migrate between them.', - icon: Server, - }, - ], - }, - { - title: 'Tenancy', - description: 'Multi-port and multi-install scaffolding.', - sections: [ - { - href: 'ports', - label: 'Ports', - description: 'Manage the marinas/ports this installation serves.', - icon: Ship, - }, { href: 'onboarding', label: 'Onboarding checklist', description: - 'Step-by-step setup checklist for fresh ports — auto-detects what you’ve configured and lets you mark manual steps complete.', + 'Step-by-step setup checklist for fresh ports - auto-detects what you’ve configured and lets you mark manual steps complete.', icon: ListChecks, }, ], }, - { - title: 'Integrations', - description: 'Third-party providers wired into the app.', - sections: [ - { - href: 'ai', - label: 'AI configuration', - description: - 'Master switch, provider credentials, and the Receipt OCR settings in one place. Per-feature pages (berth-PDF parser, recommender) link out from here.', - icon: Sparkles, - keywords: [ - 'openai', - 'anthropic', - 'gpt', - 'claude', - 'llm', - 'api key', - 'embeddings', - 'receipt', - 'scan', - 'tesseract', - 'ocr', - 'expense scanner', - ], - }, - { - href: 'website-analytics', - label: 'Website analytics (Umami)', - description: 'Per-port Umami URL, API token, and Website ID.', - icon: TrendingUp, - keywords: ['umami', 'analytics', 'traffic', 'visitors', 'marketing', 'pageviews'], - }, - { - href: 'residential-stages', - label: 'Residential pipeline stages', - description: - 'Configure stages residential interests flow through. Removing a stage with active interests prompts for reassignment.', - icon: ListChecks, - keywords: ['stages', 'pipeline', 'residential funnel', 'reassign'], - }, - { - href: 'qualification-criteria', - label: 'Qualification criteria', - description: - 'Checklist reps complete to qualify an enquiry. Enable/disable/reorder per port; affects the soft "ready to qualify" hint on the interest detail.', - icon: ListChecks, - keywords: ['qualification', 'criteria', 'checklist', 'qualify', 'enquiry', 'sales gate'], - }, - ], - }, ]; interface AdminSectionsBrowserProps { diff --git a/src/components/admin/audit/audit-log-list.tsx b/src/components/admin/audit/audit-log-list.tsx index 4a316706..76c64914 100644 --- a/src/components/admin/audit/audit-log-list.tsx +++ b/src/components/admin/audit/audit-log-list.tsx @@ -165,7 +165,7 @@ export function AuditLogList() { if (source !== 'all') params.set('source', source); if (debouncedSearch) params.set('search', debouncedSearch); if (debouncedUserId) params.set('userId', debouncedUserId); - // Skip the date filters when From > To — the inline warning below + // Skip the date filters when From > To - the inline warning below // tells the user to fix it; we don't want to fire a request with a // useless empty range either. const datesValid = !(dateFrom && dateTo && dateFrom > dateTo); @@ -213,7 +213,7 @@ export function AuditLogList() { useEffect(() => { // Refetch on filter change. Migrating this list to useInfiniteQuery - // would be the proper fix but is deferred — the fetch-on-effect + // would be the proper fix but is deferred - the fetch-on-effect // pattern here is functionally correct and gated by the queryString // memo so it only fires when filters actually change. // eslint-disable-next-line react-hooks/set-state-in-effect diff --git a/src/components/admin/backup-admin-panel.tsx b/src/components/admin/backup-admin-panel.tsx index df9b8d0b..047e4dcc 100644 --- a/src/components/admin/backup-admin-panel.tsx +++ b/src/components/admin/backup-admin-panel.tsx @@ -38,7 +38,7 @@ const STATUS_TONE: Record = { }; function formatBytes(n: number | null): string { - if (n === null) return '—'; + if (n === null) return '-'; if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`; diff --git a/src/components/admin/branding/email-preview-card.tsx b/src/components/admin/branding/email-preview-card.tsx index a90b65a7..b3b668d7 100644 --- a/src/components/admin/branding/email-preview-card.tsx +++ b/src/components/admin/branding/email-preview-card.tsx @@ -1,7 +1,10 @@ 'use client'; import { useState } from 'react'; -import { Eye, Send } from 'lucide-react'; +import Link from 'next/link'; +import { useParams } from 'next/navigation'; +import type { Route } from 'next'; +import { ArrowRight, Eye, Send } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; @@ -21,6 +24,8 @@ interface PreviewResponse { * without firing one of the real flows. */ export function EmailPreviewCard() { + const params = useParams<{ portSlug: string }>(); + const portSlug = params?.portSlug ?? ''; const [html, setHtml] = useState(null); const [subject, setSubject] = useState(null); const [loadingPreview, setLoadingPreview] = useState(false); @@ -116,6 +121,19 @@ export function EmailPreviewCard() { Sends the same sample email to the address you enter. Useful for checking how it lands in Gmail, Outlook, Apple Mail, etc.

+ {/* The branded shell only fires the generic sample copy. To + test the actual subject/body of a specific transactional + template (password reset, EOI invite, signing reminder + etc.), use the per-template tester on Email Settings. */} + {portSlug ? ( + + Test individual templates (password reset, EOI invite, signing reminder, …) + + + ) : null}
diff --git a/src/components/admin/branding/pdf-logo-uploader.tsx b/src/components/admin/branding/pdf-logo-uploader.tsx index 57db78c4..dbb5f989 100644 --- a/src/components/admin/branding/pdf-logo-uploader.tsx +++ b/src/components/admin/branding/pdf-logo-uploader.tsx @@ -64,8 +64,8 @@ async function brandingHeaders(): Promise { const WARNING_LABELS: Record = { trimmed: 'Auto-trimmed whitespace borders', resized: 'Downscaled for size', - 'no-alpha': 'No transparent background — will show a white box on dark headers', - 'jpeg-source': 'JPEG source — prefer PNG with alpha for crisp rendering', + 'no-alpha': 'No transparent background - will show a white box on dark headers', + 'jpeg-source': 'JPEG source - prefer PNG with alpha for crisp rendering', 'svg-rasterized': 'SVG rasterized to PNG at 300 DPI', 'heic-converted': 'HEIC/HEIF converted to PNG', 'webp-converted': 'WebP converted to PNG', diff --git a/src/components/admin/bulk-add-berths-wizard.tsx b/src/components/admin/bulk-add-berths-wizard.tsx index a7a30119..f48df398 100644 --- a/src/components/admin/bulk-add-berths-wizard.tsx +++ b/src/components/admin/bulk-add-berths-wizard.tsx @@ -11,7 +11,7 @@ * dimensions / pontoon / pricing. "Apply to all" inputs at the top * of each column copy a value down. Validation is inline. * - * Per PRE-DEPLOY-PLAN § 1.4.13. Drag-fill is a stretch — left as a + * Per PRE-DEPLOY-PLAN § 1.4.13. Drag-fill is a stretch - left as a * follow-up; keyboard-friendly "Apply to all" covers most of the * speed win without the complexity. */ @@ -39,7 +39,7 @@ import { useVocabulary } from '@/hooks/use-vocabulary'; import { CurrencySelect } from '@/components/shared/currency-select'; // Common dock-letter shorthand. Wizard accepts any uppercase letter -// sequence matching the canonical mooring regex (`^[A-Z]+$`) — these +// sequence matching the canonical mooring regex (`^[A-Z]+$`) - these // five are the most-frequently-used; reps add new ones via the // "Custom" input below. const COMMON_DOCK_LETTERS = ['A', 'B', 'C', 'D', 'E'] as const; @@ -117,7 +117,7 @@ export function BulkAddBerthsWizard() { const [checkingDups, setCheckingDups] = useState(false); async function handleGenerate() { - // Validate the dock letter — must be one or more uppercase letters per + // Validate the dock letter - must be one or more uppercase letters per // the canonical mooring regex. Custom-input path normalises to upper // already, but guard against an empty input. if (!letter || !/^[A-Z]+$/.test(letter)) { @@ -321,7 +321,7 @@ export function BulkAddBerthsWizard() { {/* Dimension-unit toggle. The wizard stores values as-entered; conversion to canonical feet (1 m = 3.28084 ft) happens once at submit. Switching mid-edit leaves existing inputs as - numeric strings — the rep is responsible for re-entering if + numeric strings - the rep is responsible for re-entering if the unit interpretation just changed under them. */} + + + {lastResult?.kind === 'ok' && ( +
+ +
+
+ {lastResult.label} sent to {lastResult.recipient}. +
+ {lastResult.messageId ? ( +
+ Message-ID: {lastResult.messageId} +
+ ) : null} +
+ Check the recipient's mailbox (and spam folder) to confirm delivery. +
+
+
+ )} + {lastResult?.kind === 'err' && ( +
+ +
+
Send failed
+
{lastResult.message}
+
+
+ )} + + + ); +} diff --git a/src/components/admin/forms/form-template-form.tsx b/src/components/admin/forms/form-template-form.tsx index 8b163bdc..8f7fd655 100644 --- a/src/components/admin/forms/form-template-form.tsx +++ b/src/components/admin/forms/form-template-form.tsx @@ -115,7 +115,7 @@ function FormTemplateFormBody({ open, onOpenChange, template, onSaved }: Props) function changeBinding(idx: number, raw: string) { if (raw === BIND_TO_NONE) { - // Clear the binding but leave the rest of the field untouched — + // Clear the binding but leave the rest of the field untouched - // admins may want to keep a custom field that no longer autofills. setFields((prev) => prev.map((f, i) => { @@ -324,7 +324,7 @@ function FormTemplateFormBody({ open, onOpenChange, template, onSaved }: Props) * Map a bindable column's natural input type onto the form-field types we * actually render. When binding to a `number` column we still let the * admin keep `select` if they'd already chosen it (e.g. they want to - * constrain to specific values) — same for `textarea`. + * constrain to specific values) - same for `textarea`. */ function coerceFieldType( bindableType: BindableType, diff --git a/src/components/admin/onboarding-checklist.tsx b/src/components/admin/onboarding-checklist.tsx index f1ec7da7..5a65e068 100644 --- a/src/components/admin/onboarding-checklist.tsx +++ b/src/components/admin/onboarding-checklist.tsx @@ -21,7 +21,7 @@ interface OnboardingStep { autoCheckSettingKey?: string; /** Multi-key gate: all listed setting keys must be present (non-empty) * for the step to auto-complete. Useful for compound checks where a - * single key would falsely mark "done" — e.g. Documenso needs a URL + * single key would falsely mark "done" - e.g. Documenso needs a URL * plus signer identity plus a template id, not just the URL. */ autoCheckSettingKeysAll?: readonly string[]; /** Override: read this many users / tags / roles from a list endpoint @@ -133,7 +133,7 @@ export function OnboardingChecklist() { const params = useParams<{ portSlug: string }>(); const portSlug = params?.portSlug ?? ''; const [autoChecks, setAutoChecks] = useState>({}); - // Per-step source flags — populated for steps whose auto-check resolved + // Per-step source flags - populated for steps whose auto-check resolved // via the env / default fallback rather than a port / global override. // Surfaces "Resolving from env" copy so super admins see what's // backing each green tick without digging into the settings page. @@ -157,7 +157,7 @@ export function OnboardingChecklist() { if (s.autoCheckSettingKeysAll) for (const k of s.autoCheckSettingKeysAll) keys.add(k); } // Manual-checkbox state still lives in the raw system_settings - // row (it's a JSON blob, not a per-key registry entry) — keep + // row (it's a JSON blob, not a per-key registry entry) - keep // fetching it the old way. const [resolved, settings] = await Promise.all([ keys.size > 0 diff --git a/src/components/admin/ports/port-form.tsx b/src/components/admin/ports/port-form.tsx index d5a34ddc..fe9e2651 100644 --- a/src/components/admin/ports/port-form.tsx +++ b/src/components/admin/ports/port-form.tsx @@ -21,17 +21,17 @@ import { apiFetch } from '@/lib/api/client'; // Marina deals price in a small set; an admin who needs an exotic // currency can add it here without a schema change. const CURRENCY_OPTIONS: Array<{ value: string; label: string }> = [ - { value: 'USD', label: 'USD — US Dollar' }, - { value: 'EUR', label: 'EUR — Euro' }, - { value: 'GBP', label: 'GBP — British Pound' }, - { value: 'CHF', label: 'CHF — Swiss Franc' }, - { value: 'AED', label: 'AED — UAE Dirham' }, - { value: 'SAR', label: 'SAR — Saudi Riyal' }, - { value: 'PLN', label: 'PLN — Polish Złoty' }, - { value: 'AUD', label: 'AUD — Australian Dollar' }, - { value: 'CAD', label: 'CAD — Canadian Dollar' }, - { value: 'NZD', label: 'NZD — New Zealand Dollar' }, - { value: 'JPY', label: 'JPY — Japanese Yen' }, + { value: 'USD', label: 'USD - US Dollar' }, + { value: 'EUR', label: 'EUR - Euro' }, + { value: 'GBP', label: 'GBP - British Pound' }, + { value: 'CHF', label: 'CHF - Swiss Franc' }, + { value: 'AED', label: 'AED - UAE Dirham' }, + { value: 'SAR', label: 'SAR - Saudi Riyal' }, + { value: 'PLN', label: 'PLN - Polish Złoty' }, + { value: 'AUD', label: 'AUD - Australian Dollar' }, + { value: 'CAD', label: 'CAD - Canadian Dollar' }, + { value: 'NZD', label: 'NZD - New Zealand Dollar' }, + { value: 'JPY', label: 'JPY - Japanese Yen' }, ]; interface PortFormProps { diff --git a/src/components/admin/qualification-criteria-admin.tsx b/src/components/admin/qualification-criteria-admin.tsx index b3b8baa6..ea31fdad 100644 --- a/src/components/admin/qualification-criteria-admin.tsx +++ b/src/components/admin/qualification-criteria-admin.tsx @@ -54,7 +54,7 @@ interface ListResponse { * Per-port qualification-criteria admin. Lists current criteria, add via * the dialog, toggle enabled inline, drag-to-reorder via dnd-kit (the * whole list ships in one PATCH so partial failure can't scramble the - * order — see qualification.service.reorderCriteria). + * order - see qualification.service.reorderCriteria). */ export function QualificationCriteriaAdmin() { const queryClient = useQueryClient(); diff --git a/src/components/admin/reconcile-queue.tsx b/src/components/admin/reconcile-queue.tsx index 247ec574..c7c01ca1 100644 --- a/src/components/admin/reconcile-queue.tsx +++ b/src/components/admin/reconcile-queue.tsx @@ -34,7 +34,7 @@ const STATUS_PILL: Record = { }; function relativeAge(iso: string | null): string { - if (!iso) return '—'; + if (!iso) return '-'; const days = Math.floor((Date.now() - new Date(iso).getTime()) / 86_400_000); if (days <= 0) return 'today'; if (days === 1) return 'yesterday'; diff --git a/src/components/admin/sales-email-config-card.tsx b/src/components/admin/sales-email-config-card.tsx index c41c228d..0295a16b 100644 --- a/src/components/admin/sales-email-config-card.tsx +++ b/src/components/admin/sales-email-config-card.tsx @@ -8,7 +8,7 @@ * that the document-sends flow uses. * * §14.10 enforcement: passwords are write-only. The GET endpoint never - * returns the decrypted value — only a `*PassIsSet` boolean. Empty + * returns the decrypted value - only a `*PassIsSet` boolean. Empty * password input means "leave unchanged"; explicit `null` sent over the * wire means "clear". */ @@ -129,7 +129,7 @@ export function SalesEmailConfigCard() { } useEffect(() => { - // Initial load on mount — canonical fetch-once pattern. + // Initial load on mount - canonical fetch-once pattern. // eslint-disable-next-line react-hooks/set-state-in-effect void refresh(); }, []); diff --git a/src/components/admin/sends-log.tsx b/src/components/admin/sends-log.tsx index b38141a1..156ea94f 100644 --- a/src/components/admin/sends-log.tsx +++ b/src/components/admin/sends-log.tsx @@ -26,7 +26,7 @@ interface SendRow { brochureId: string | null; clientId: string | null; interestId: string | null; - /** Phase 6 — populated by the IMAP bounce poller when a delivery + /** Phase 6 - populated by the IMAP bounce poller when a delivery * failure for this send was matched in the configured mailbox. */ bounceStatus: 'hard' | 'soft' | 'ooo' | null; bounceReason: string | null; diff --git a/src/components/admin/shared/registry-driven-form.tsx b/src/components/admin/shared/registry-driven-form.tsx index b422f5a8..64bad49d 100644 --- a/src/components/admin/shared/registry-driven-form.tsx +++ b/src/components/admin/shared/registry-driven-form.tsx @@ -92,7 +92,7 @@ export function RegistryDrivenForm({ sections, title, description, extra }: Prop ), }); - // Lifted draft state — every field's current input value is held here so + // Lifted draft state - every field's current input value is held here so // a card-level "Save N changes" button can write them all in one batch. // Sensitive fields seed as empty (we never seed cleartext from the server); // non-sensitive fields seed from the resolved value. @@ -313,13 +313,13 @@ function SettingField({ }, onSuccess: (r) => { if (r.revealed && r.value != null) { - // Server reveal — populate draft but do NOT mark dirty (the value + // Server reveal - populate draft but do NOT mark dirty (the value // already matches what's stored). setDraft(r.value, { dirty: false }); setRevealedFromServer(true); setShowSecret(true); } else { - toast.info(`${entry.label} isn't set — nothing to reveal.`); + toast.info(`${entry.label} isn't set - nothing to reveal.`); } }, onError: (err) => toastError(err, `Failed to reveal ${entry.label}`), @@ -439,7 +439,7 @@ function SettingField({ disabled={ reveal.isPending || // Disable when the value is resolved from env/default and the - // rep hasn't typed anything yet — there's no in-app cleartext + // rep hasn't typed anything yet - there's no in-app cleartext // path for those, and silently no-op'ing was indistinguishable // from a broken toggle. (!showSecret && diff --git a/src/components/admin/shared/settings-form-card.tsx b/src/components/admin/shared/settings-form-card.tsx index b00fcb5c..49d02c45 100644 --- a/src/components/admin/shared/settings-form-card.tsx +++ b/src/components/admin/shared/settings-form-card.tsx @@ -44,14 +44,14 @@ export interface SettingFieldDef { options?: Array<{ value: string; label: string }>; /** For 'image-upload' fields: aspect ratio for the cropper (default 1). * For 'html' fields: when set, renders an "Insert default" button that - * pastes this text into the textarea — used for email-template defaults + * pastes this text into the textarea - used for email-template defaults * so admins can see the baseline before editing. */ defaultTemplate?: string; /** For 'image-upload' fields: cropper aspect ratio. */ imageAspect?: number; /** For 'image-upload' fields: output format. Default 'jpeg' (smaller * files, good for photos / backgrounds). Use 'png' for logos with - * transparency — JPEG has no alpha channel, so transparent pixels + * transparency - JPEG has no alpha channel, so transparent pixels * composite against black on export. */ imageFormat?: 'jpeg' | 'png'; } @@ -87,7 +87,7 @@ export function SettingsFormCard({ title, description, fields, extra }: Settings // Parent components often pass `FIELDS.slice(0, 5)` directly, so the prop // reference changes on every render. Capture it in a ref so the fetch // callback can read the current list without being re-created and looping - // through useEffect forever. Update inside an effect — writing to ref + // through useEffect forever. Update inside an effect - writing to ref // .current during render trips the React Compiler purity rules. const fieldsRef = useRef(fields); useEffect(() => { @@ -112,7 +112,7 @@ export function SettingsFormCard({ title, description, fields, extra }: Settings }, []); useEffect(() => { - // Initial load — fetchValues internally setStates loading + values. + // Initial load - fetchValues internally setStates loading + values. // eslint-disable-next-line react-hooks/set-state-in-effect void fetchValues(); }, [fetchValues]); @@ -404,13 +404,13 @@ function ImageUploadField({ async function uploadCropped(blob: Blob) { const fd = new FormData(); - // Trust the blob's own MIME — the cropper auto-picks PNG when the + // Trust the blob's own MIME - the cropper auto-picks PNG when the // source had alpha, JPEG otherwise. Hardcoding to JPEG here threw // away the alpha channel on transparent logos. const mime = blob.type || 'image/jpeg'; const ext = mime === 'image/png' ? 'png' : 'jpg'; fd.append('file', new File([blob], `image.${ext}`, { type: mime })); - // Raw fetch (not apiFetch — FormData body) → manually attach the + // Raw fetch (not apiFetch - FormData body) → manually attach the // X-Port-Id header that the admin settings route requires. const headers = new Headers(); if (typeof window !== 'undefined') { diff --git a/src/components/admin/shared/template-token-picker.tsx b/src/components/admin/shared/template-token-picker.tsx index 5107bb36..06c56081 100644 --- a/src/components/admin/shared/template-token-picker.tsx +++ b/src/components/admin/shared/template-token-picker.tsx @@ -41,12 +41,12 @@ function useCustomFieldTokens() { * Renders the canonical `MERGE_FIELDS` catalog grouped by entity scope. * Below the static catalog, lazily fetches per-port custom field * definitions and renders any whose entityType is resolvable at - * send-time (client / interest / berth — see `mergeCustomFieldValues` + * send-time (client / interest / berth - see `mergeCustomFieldValues` * in `document-sends.service.ts`) as a "Custom" group. * * The validator accepts any `{{custom.}}` shape, but only * the three entity types above resolve to real values, so we only show - * those — surfacing client-portal-only fields would mis-set expectations. + * those - surfacing client-portal-only fields would mis-set expectations. */ export function TemplateTokenPicker() { const customQuery = useCustomFieldTokens(); diff --git a/src/components/admin/storage-admin-panel.tsx b/src/components/admin/storage-admin-panel.tsx index 0a1e480f..30a3f96b 100644 --- a/src/components/admin/storage-admin-panel.tsx +++ b/src/components/admin/storage-admin-panel.tsx @@ -209,7 +209,7 @@ export function StorageAdminPanel() { // Dry run first so the dialog shows the exact rows + bytes. dryRunMutation.mutate({ from: s.backend, to: otherBackend }); } else { - // Switch-only — no dry run, just show the warning. + // Switch-only - no dry run, just show the warning. setDryRun(null); setConfirmOpen(true); } diff --git a/src/components/admin/tags/tag-form.tsx b/src/components/admin/tags/tag-form.tsx index 07452b9d..8bd17cf9 100644 --- a/src/components/admin/tags/tag-form.tsx +++ b/src/components/admin/tags/tag-form.tsx @@ -111,7 +111,7 @@ export function TagForm({ open, onOpenChange, tag, onSuccess }: TagFormProps) { ))}
- {/* Native colour picker — clicking the swatch opens the + {/* Native colour picker - clicking the swatch opens the * OS picker, and the chosen colour writes back as a * hex string. Keeps a manual hex input next to it for * pasting brand colours from spec sheets. */} diff --git a/src/components/admin/templates/template-editor.tsx b/src/components/admin/templates/template-editor.tsx index b03efaa4..c28ad0b9 100644 --- a/src/components/admin/templates/template-editor.tsx +++ b/src/components/admin/templates/template-editor.tsx @@ -46,7 +46,7 @@ interface TemplateData { templateFormat: string; sourceFileId: string | null; overlayPositions: FieldMap | null; - /** Tokens marked as required for the EOI flow — see + /** Tokens marked as required for the EOI flow - see * STANDARD_EOI_MERGE_FIELDS in lib/templates/merge-fields. The editor * surfaces a checklist of which required tokens are still unplaced. */ mergeFields?: string[] | null; @@ -79,7 +79,7 @@ const DEFAULT_MARKER_H = 0.04; const MIN_MARKER_DIM = 0.02; /** - * Phase 7.1 + 7.2 — PDF marker editor. + * Phase 7.1 + 7.2 - PDF marker editor. * * - Click anywhere to drop a marker (page-aware). * - Drag markers to move; corner handles to resize. @@ -158,7 +158,7 @@ function TemplateEditorBody({ const handler = (e: BeforeUnloadEvent) => { e.preventDefault(); // Modern browsers ignore the returnValue string and show their own - // generic "you have unsaved changes" prompt — setting it still + // generic "you have unsaved changes" prompt - setting it still // triggers the prompt, just without our wording. e.returnValue = ''; }; @@ -385,7 +385,7 @@ function TemplateEditorBody({ // Map detected type → best-guess merge token. Falls back to first // sorted token when the detector finds a Documenso-only field // (SIGNATURE / INITIALS) that has no direct merge-token equivalent - // in the in-app fill pipeline — the user retags from the dropdown. + // in the in-app fill pipeline - the user retags from the dropdown. const fallbackToken = TOKEN_OPTIONS[0] ?? ''; const pick = (candidates: string[]): string => { for (const c of candidates) { diff --git a/src/components/admin/users/user-form.tsx b/src/components/admin/users/user-form.tsx index 6b6dc887..b7826f2e 100644 --- a/src/components/admin/users/user-form.tsx +++ b/src/components/admin/users/user-form.tsx @@ -100,7 +100,7 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) { function handleSubmit(e: React.FormEvent) { e.preventDefault(); // Admin email change for an existing user goes through a confirmation - // dialog because it locks the original sign-in identity out — the + // dialog because it locks the original sign-in identity out - the // submit path runs after the admin acknowledges. New-user creation // and same-email saves go straight through. if (isEdit && email.trim().toLowerCase() !== originalEmail.toLowerCase()) { diff --git a/src/components/admin/users/user-permission-matrix.tsx b/src/components/admin/users/user-permission-matrix.tsx index fd66b029..2fe5ca32 100644 --- a/src/components/admin/users/user-permission-matrix.tsx +++ b/src/components/admin/users/user-permission-matrix.tsx @@ -52,7 +52,7 @@ const GROUP_LABELS: Record = { residential_interests: 'Residential Interests', }; -// Mirrors RolePermissions in src/lib/db/schema/users.ts — used as the +// Mirrors RolePermissions in src/lib/db/schema/users.ts - used as the // canonical leaf list so the matrix shows every action even when the // baseline JSON omits a key (older roles, partial overrides). const PERMISSION_LEAVES: Record = { diff --git a/src/components/alerts/alert-rail.tsx b/src/components/alerts/alert-rail.tsx index 8f2f3963..eb8aa7fd 100644 --- a/src/components/alerts/alert-rail.tsx +++ b/src/components/alerts/alert-rail.tsx @@ -20,7 +20,7 @@ export function AlertRail() { const overflow = Math.max(alerts.length - visible.length, 0); // Smooth enter/leave for alerts as new ones arrive via socket realtime - // and stale ones get dismissed — replaces the jarring "card just + // and stale ones get dismissed - replaces the jarring "card just // appears/disappears" with a subtle fade+slide. const [animateRef] = useAutoAnimate(); diff --git a/src/components/alerts/alerts-page-shell.tsx b/src/components/alerts/alerts-page-shell.tsx index 8bb28d24..96769178 100644 --- a/src/components/alerts/alerts-page-shell.tsx +++ b/src/components/alerts/alerts-page-shell.tsx @@ -14,7 +14,7 @@ import type { AlertStatus } from './types'; * `embedded` mode drops the PageHeader and outer spacing so the shell * can render as a section inside the merged Inbox page without * duplicating chrome. Standalone /alerts route still uses the default - * (non-embedded) mode via the redirect — actually, /alerts now redirects + * (non-embedded) mode via the redirect - actually, /alerts now redirects * to /inbox#alerts, so non-embedded mode is currently unused but kept * for flexibility. */ diff --git a/src/components/berths/active-interests-popover.tsx b/src/components/berths/active-interests-popover.tsx index e4ce1708..ffe879a6 100644 --- a/src/components/berths/active-interests-popover.tsx +++ b/src/components/berths/active-interests-popover.tsx @@ -31,7 +31,7 @@ interface Props { */ export function ActiveInterestsPopover({ berthId, portSlug, count }: Props) { // Lazy-load: only fetch when the popover opens. Pattern from the - // detail-label fallback queries elsewhere in the codebase — the + // detail-label fallback queries elsewhere in the codebase - the // `enabled` flag flips on first open. const { data, isLoading, isError } = useQuery<{ data: ActiveInterestRow[] }>({ queryKey: ['berth', berthId, 'active-interests'], diff --git a/src/components/berths/berth-card.tsx b/src/components/berths/berth-card.tsx index 7052bc8b..e1f919df 100644 --- a/src/components/berths/berth-card.tsx +++ b/src/components/berths/berth-card.tsx @@ -44,7 +44,7 @@ export function BerthCard({ berth }: BerthCardProps) { // already conveyed by the pill below, so the stripe is dock-keyed. const accentClass = mooringLetterDot(berth.mooringNumber) ?? 'bg-slate-300'; - // Dimensions string — Length × Width × Draft (each segment is optional). + // Dimensions string - Length × Width × Draft (each segment is optional). // The avatar already conveys the mooring number, so this becomes the // primary "what is this berth" line. const dimParts: string[] = []; @@ -53,7 +53,7 @@ export function BerthCard({ berth }: BerthCardProps) { if (berth.draftM) dimParts.push(`${berth.draftM}m draft`); const dimText = dimParts.length > 0 ? dimParts.join(' × ') : null; - // Recommended boat size — the most rep-actionable signal in a glance + // Recommended boat size - the most rep-actionable signal in a glance // ("can my client's yacht park here?"). Tenure was previously here but // dropped: tenure is set per EOI/contract, not per berth, so showing // it as a berth property was misleading. @@ -64,7 +64,7 @@ export function BerthCard({ berth }: BerthCardProps) { boatCapacityText = `Fits up to ${berth.nominalBoatSize}ft`; } - // Water depth — operational; matters for deep-keel yachts. + // Water depth - operational; matters for deep-keel yachts. let waterDepthText: string | null = null; if (berth.waterDepthM) { const prefix = berth.waterDepthIsMinimum ? '≥ ' : ''; @@ -134,7 +134,7 @@ export function BerthCard({ berth }: BerthCardProps) { } >
- {/* The mooring number IS the avatar — recognisable at a glance + {/* The mooring number IS the avatar - recognisable at a glance (A1, B12, …) and eliminates the duplicate berth-number heading that previously sat to the right of an anchor icon. */} = [ @@ -108,7 +108,7 @@ export const BERTH_COLUMN_OPTIONS: Array<{ id: string; label: string }> = [ { id: 'tags', label: 'Tags' }, ]; -/** Hidden by default — power-users turn them on via the picker. */ +/** Hidden by default - power-users turn them on via the picker. */ export const BERTH_DEFAULT_HIDDEN: string[] = [ 'tenure', 'sidePontoon', @@ -148,14 +148,14 @@ function StatusBadge({ status }: { status: string }) { /** * #67 Phase 2: small amber chip beside the status pill flagging rows * whose status was set manually and has no backing interest. These are - * the candidates for the catch-up wizard — the rep flipped a berth to + * the candidates for the catch-up wizard - the rep flipped a berth to * "Under Offer" or "Sold" without ever creating the matching deal. */ function ManualBadge() { return ( Manual @@ -470,7 +470,7 @@ export const berthColumns: ColumnDef[] = [ * 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, + * (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. diff --git a/src/components/berths/berth-detail-header.tsx b/src/components/berths/berth-detail-header.tsx index b8d512f7..d4c7066d 100644 --- a/src/components/berths/berth-detail-header.tsx +++ b/src/components/berths/berth-detail-header.tsx @@ -105,7 +105,7 @@ interface InterestOption { id: string; clientName: string; pipelineStage: string; - /** Used to sort the picker — most recently interacted with floats to the top. */ + /** Used to sort the picker - most recently interacted with floats to the top. */ updatedAt?: string; } @@ -138,7 +138,7 @@ function StatusChangeDialog({ const interestId = watch('interestId'); const showInterestPicker = status === 'under_offer' || status === 'sold'; - // Active interests for this port — used to populate the prospect + // Active interests for this port - used to populate the prospect // selector when status moves to under_offer / sold. Only fetched when // the picker is actually visible to avoid an unnecessary round-trip // for available-status changes. @@ -317,7 +317,7 @@ export function BerthDetailHeader({ berth }: BerthDetailHeaderProps) { /** * Searchable combobox for picking a linked prospect when changing berth * status. Replaces the bare Select which had no filter, no stage colours, - * and no recency sort — for ports with 200+ active interests that became + * and no recency sort - for ports with 200+ active interests that became * a scroll-fest. Stage labels render with the same coloured pill the rest * of the CRM uses for stage badges so the rep can scan the list visually. */ @@ -332,7 +332,7 @@ function InterestLinkPicker({ }) { const [open, setOpen] = useState(false); // Sort with the most recently updated interest first so reps see the - // active deals at the top of the list — older / dormant ones drop + // active deals at the top of the list - older / dormant ones drop // beneath. `updatedAt` is set on every patch + every stage advance. const sorted = [...options].sort((a, b) => { if (!a.updatedAt && !b.updatedAt) return 0; diff --git a/src/components/berths/berth-detail.tsx b/src/components/berths/berth-detail.tsx index b5609eaa..2ddd0a39 100644 --- a/src/components/berths/berth-detail.tsx +++ b/src/components/berths/berth-detail.tsx @@ -51,7 +51,7 @@ export function BerthDetail({ berthId }: BerthDetailProps) { useEffect(() => { if (searchParams.get('edit') === 'true') { - // setState in effect is the right shape here — the URL is an + // setState in effect is the right shape here - the URL is an // external store and the trigger is a query-param change, not a // prop in the React tree. // eslint-disable-next-line react-hooks/set-state-in-effect diff --git a/src/components/berths/berth-documents-tab.tsx b/src/components/berths/berth-documents-tab.tsx index 2d57c7fa..07ed655c 100644 --- a/src/components/berths/berth-documents-tab.tsx +++ b/src/components/berths/berth-documents-tab.tsx @@ -1,9 +1,9 @@ /** - * Documents tab on the berth detail page (Phase 6b — see plan §5.6). + * Documents tab on the berth detail page (Phase 6b - see plan §5.6). * * Sections: * - Current PDF panel (download link, "Replace PDF" button, parse-engine chip). - * - Version history list — newest first, with rollback affordance on every + * - Version history list - newest first, with rollback affordance on every * non-current row. * - Reconcile-diff dialog (PdfReconcileDialog), opened after a successful * upload + parse. Shows auto-applied vs conflicted fields and lets the diff --git a/src/components/berths/berth-interest-pulse.tsx b/src/components/berths/berth-interest-pulse.tsx index d8c8bb39..fba43429 100644 --- a/src/components/berths/berth-interest-pulse.tsx +++ b/src/components/berths/berth-interest-pulse.tsx @@ -47,7 +47,7 @@ export function BerthInterestPulse({ berthId }: { berthId: string }) { // Stay in sync with the linked-berths list + add-to-interest dialog. // Each of those flows emits a realtime socket event but does NOT // invalidate this exact query key (it's berth-scoped, theirs are - // interest-scoped) — bridge via the invalidation hook. + // interest-scoped) - bridge via the invalidation hook. useRealtimeInvalidation({ 'interest:berthLinked': [queryKey], 'interest:berthUnlinked': [queryKey], diff --git a/src/components/berths/berth-list.tsx b/src/components/berths/berth-list.tsx index 8d79035c..dc8b9f88 100644 --- a/src/components/berths/berth-list.tsx +++ b/src/components/berths/berth-list.tsx @@ -90,7 +90,7 @@ export function BerthList() { 'berth:statusChanged': [['berths']], }); - // Persisted column visibility + row density + dimension unit — same + // 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 } = @@ -98,7 +98,7 @@ export function BerthList() { const columnVisibility = Object.fromEntries(hidden.map((id) => [id, false])); const berthColumns = getBerthColumns(dimensionUnit); - // Bulk-action state — one dialog per action (status / tenure type / + // 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(); @@ -143,16 +143,16 @@ export function BerthList() { // No "New" button - berths are import-only /> - {/* Toolbar — two halves separated by `justify-between` so the + {/* Toolbar - two halves separated by `justify-between` so the Columns + Saved-views actions stay pinned to the right edge of the row at every width. The previous `ml-auto` trick didn't - survive flex-wrap on intermediate widths — the actions ended + survive flex-wrap on intermediate widths - the actions ended up centered. */}
d.key !== 'search')} values={filters} @@ -302,7 +302,7 @@ export function BerthList() { : undefined } cardRender={(row) => } - // 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 // group. Data is already sorted by mooringNumber (A1, A2, …, B1, // B2, …) so consecutive rows naturally share dock letters. @@ -436,7 +436,7 @@ export function BerthList() { toast.error('Pick at least one tag.'); return; } - // Per-tag bulk call — the endpoint takes one tagId at a + // Per-tag bulk call - the endpoint takes one tagId at a // time. For the typical 1-2 tag case the round-trips are // cheap; multi-tag UX can come later. const action = tagDialog?.mode === 'add' ? 'add_tag' : 'remove_tag'; diff --git a/src/components/berths/berth-tabs.tsx b/src/components/berths/berth-tabs.tsx index ca9e7199..4a2e6967 100644 --- a/src/components/berths/berth-tabs.tsx +++ b/src/components/berths/berth-tabs.tsx @@ -81,7 +81,7 @@ function SpecRow({ label, value }: { label: string; value: React.ReactNode }) { /** * Tags Card for the berth overview. Wraps the InlineTagEditor in a Card so * the section header uses CardTitle styling; mirrors the visibility rule - * the editor itself uses — hides entirely when the port has no tags + * the editor itself uses - hides entirely when the port has no tags * defined AND this berth has none applied. */ function BerthTagsCard({ berth }: { berth: BerthData }) { @@ -215,7 +215,7 @@ function OverviewTab({ berth }: { berth: BerthData }) { const patch = useBerthPatch(berth.id); // User-selected display unit for dimensions. Persisted in localStorage // so reps' preferred unit sticks across navigations + sessions. - // Lazy initializer reads localStorage on first render — avoids the + // Lazy initializer reads localStorage on first render - avoids the // mount-effect-setState shape the compiler flags. const [units, setUnits] = useState<'ft' | 'm'>(() => { if (typeof window === 'undefined') return 'ft'; diff --git a/src/components/berths/catch-up-wizard.tsx b/src/components/berths/catch-up-wizard.tsx index 120ccc33..68cfbc43 100644 --- a/src/components/berths/catch-up-wizard.tsx +++ b/src/components/berths/catch-up-wizard.tsx @@ -61,7 +61,7 @@ const STATUS_TO_STAGES: Record = { * under_offer → enquiry...reservation, available → any) * * Doc upload + payment recording (Phases 4.4 / 4.5 of the spec) are - * out of scope for the initial cut — once the interest exists, the rep + * out of scope for the initial cut - once the interest exists, the rep * has the standard interest detail page to upload contracts and record * payments. The wizard's job is to get them from "manual berth, no * interest" to "interest exists, override cleared" in one round-trip. @@ -94,7 +94,7 @@ export function CatchUpWizard({ berthId, open, onOpenChange }: CatchUpWizardProp }); const allowedStages = berth ? (STATUS_TO_STAGES[berth.data.status] ?? PIPELINE_STAGES) : []; - // Default the stage picker to the "right" default for each status — + // Default the stage picker to the "right" default for each status - // sold defaults to contract (and we auto-set outcome=won server-side), // under_offer defaults to eoi since that's the most common pre-deal // status that reps mark manually. @@ -124,7 +124,7 @@ export function CatchUpWizard({ berthId, open, onOpenChange }: CatchUpWizardProp ); }, onSuccess: (res) => { - toast.success('Berth reconciled — new interest created'); + toast.success('Berth reconciled - new interest created'); queryClient.invalidateQueries({ queryKey: ['berths'] }); queryClient.invalidateQueries({ queryKey: ['berths', 'reconcile-queue'] }); queryClient.invalidateQueries({ queryKey: ['interests'] }); diff --git a/src/components/berths/mooring-letter-tone.ts b/src/components/berths/mooring-letter-tone.ts index 99b2a032..4d24d463 100644 --- a/src/components/berths/mooring-letter-tone.ts +++ b/src/components/berths/mooring-letter-tone.ts @@ -1,14 +1,14 @@ /** * Maps a berth's mooring-letter prefix (A, B, C…) to a subtle visual - * accent. Pontoons cluster physically — A row is one dock, B another - * — so the berth grid reads at a glance when each pontoon's rows + * accent. Pontoons cluster physically - A row is one dock, B another + * - so the berth grid reads at a glance when each pontoon's rows * share a colour cue. Earlier iteration tinted the entire row * background; that proved visually noisy. This version keeps rows * white and surfaces the colour as a coloured left border, plus a * matching dot the column factory uses inside the Mooring # cell. * * Cycle wraps at the 8th letter; ports with more pontoons get - * repeats (fine in practice — they don't sit adjacent on the page). + * repeats (fine in practice - they don't sit adjacent on the page). */ const BORDER_CYCLE = [ 'border-l-4 border-l-rose-400', diff --git a/src/components/berths/pdf-reconcile-dialog.tsx b/src/components/berths/pdf-reconcile-dialog.tsx index bb5d410a..73f41c3c 100644 --- a/src/components/berths/pdf-reconcile-dialog.tsx +++ b/src/components/berths/pdf-reconcile-dialog.tsx @@ -1,13 +1,13 @@ /** - * Reconcile-diff dialog (Phase 6b — see plan §4.7b, §14.6). + * Reconcile-diff dialog (Phase 6b - see plan §4.7b, §14.6). * * Shown after a successful per-berth PDF upload + parse. Surfaces three * sections: * - Warnings (mooring-number mismatch, imperial-vs-metric drift, etc.) * so the rep can abort before applying. - * - Auto-applied fields — fields the parser found that the CRM had as null; + * - Auto-applied fields - fields the parser found that the CRM had as null; * these are pre-checked and applied on confirm. - * - Conflicts — fields where CRM and PDF disagree on a non-null value. + * - Conflicts - fields where CRM and PDF disagree on a non-null value. * The rep picks "Keep CRM" or "Use PDF" per row before confirming. * * On confirm, the dialog POSTs to /pdf-versions/parse-results/apply with the diff --git a/src/components/clients/bulk-hard-delete-dialog.tsx b/src/components/clients/bulk-hard-delete-dialog.tsx index df33d847..9ba1ea89 100644 --- a/src/components/clients/bulk-hard-delete-dialog.tsx +++ b/src/components/clients/bulk-hard-delete-dialog.tsx @@ -34,7 +34,7 @@ interface SkippedRow { } /** - * Key-based remount of the body when the dialog opens — fresh state per + * Key-based remount of the body when the dialog opens - fresh state per * open without an open→reset useEffect (React Compiler-safe). */ export function BulkHardDeleteDialog(props: Props) { diff --git a/src/components/clients/client-card.tsx b/src/components/clients/client-card.tsx index 736d868b..fb5fdc1f 100644 --- a/src/components/clients/client-card.tsx +++ b/src/components/clients/client-card.tsx @@ -1,5 +1,6 @@ 'use client'; +import type { ReactNode } from 'react'; import { Archive, MoreHorizontal, Pencil } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -9,6 +10,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; +import { CountryFlag } from '@/components/shared/country-flag'; import { TagBadge } from '@/components/shared/tag-badge'; import { ListCard, @@ -34,7 +36,21 @@ export function ClientCard({ client, portSlug, onEdit, onArchive }: ClientCardPr const sourceLabel = formatSource(client.source); const tags = client.tags ?? []; - const meta = [nationality, sourceLabel].filter(Boolean) as string[]; + const metaItems: { key: string; node: ReactNode }[] = []; + if (nationality) { + metaItems.push({ + key: 'nationality', + node: ( + + + {nationality} + + ), + }); + } + if (sourceLabel) { + metaItems.push({ key: 'source', node: {sourceLabel} }); + } const interest = client.latestInterest ?? null; const interestCount = client.interestCount ?? 0; @@ -91,12 +107,12 @@ export function ClientCard({ client, portSlug, onEdit, onArchive }: ClientCardPr

{primaryContactValue}

) : null} - {meta.length > 0 ? ( + {metaItems.length > 0 ? (
- {meta.map((m, i) => ( - + {metaItems.map((m, i) => ( + {i > 0 ? · : null} - {m} + {m.node} ))}
diff --git a/src/components/clients/client-channel-editor.tsx b/src/components/clients/client-channel-editor.tsx index 1024b771..f06006a7 100644 --- a/src/components/clients/client-channel-editor.tsx +++ b/src/components/clients/client-channel-editor.tsx @@ -30,7 +30,7 @@ export interface ContactRow { interface Props { clientId: string; /** - * Channel filter — picker shows only `email` (or `phone` + `whatsapp` for + * Channel filter - picker shows only `email` (or `phone` + `whatsapp` for * phone-style channels). Edits / promotions stay scoped to the chosen * channel. */ @@ -39,11 +39,11 @@ interface Props { * value rendering when the picker isn't open). */ primaryContactId: string | null; primaryValue: string | null; - /** Phone channel only — E.164 form + ISO-3166-1 alpha-2 country code so the + /** Phone channel only - E.164 form + ISO-3166-1 alpha-2 country code so the * inline phone editor can preserve the national-format roundtrip. */ primaryValueE164?: string | null; primaryValueCountry?: string | null; - /** Query keys to invalidate after any mutation succeeds — the parent + /** Query keys to invalidate after any mutation succeeds - the parent * detail view is usually keyed on `['interest', interestId]` or * `['clients', clientId]` so the picker can't hard-code which to bump. */ invalidateKeys?: ReadonlyArray; diff --git a/src/components/clients/client-columns.tsx b/src/components/clients/client-columns.tsx index c7512de3..c675bbf5 100644 --- a/src/components/clients/client-columns.tsx +++ b/src/components/clients/client-columns.tsx @@ -15,6 +15,7 @@ import { } from '@/components/ui/dropdown-menu'; import { Badge } from '@/components/ui/badge'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { CountryFlag } from '@/components/shared/country-flag'; import { getCountryName } from '@/lib/i18n/countries'; import { stageDotClass, stageLabel, formatSource, formatOutcome } from '@/lib/constants'; import { cn } from '@/lib/utils'; @@ -29,7 +30,7 @@ export interface ClientRow { createdAt: string; primaryEmail?: string | null; primaryPhone?: string | null; - /** E.164 (digits + leading +) — used to build wa.me / tel: links. */ + /** E.164 (digits + leading +) - used to build wa.me / tel: links. */ primaryPhoneE164?: string | null; yachtCount?: number; companyCount?: number; @@ -39,7 +40,7 @@ export interface ClientRow { * Berths the client has interests in (active only) with the most-active * interest's stage attached. Sorted server-side: open deals first, most * progressed stage first, then mooring alphabetical. Each chip in the - * list view links to the interest, not the berth — that's the action + * list view links to the interest, not the berth - that's the action * sales reps want. */ linkedBerths?: Array<{ @@ -53,7 +54,7 @@ export interface ClientRow { } /** - * Picker manifest — drives the `` dropdown next to the + * Picker manifest - drives the `` dropdown next to the * filter bar. Order here is the order shown in the menu. `alwaysVisible` * marks columns the user can't hide (otherwise the table is unusable). * @@ -76,7 +77,7 @@ export const CLIENT_COLUMN_OPTIONS: ColumnPickerOption[] = [ /** * Default-hidden columns for a fresh user. The hook merges this with - * the user's saved overrides — once they explicitly toggle a column, + * the user's saved overrides - once they explicitly toggle a column, * their choice wins. New columns surface for existing users by default * (they're absent from the user's stored hidden list). */ @@ -174,8 +175,12 @@ export function getClientColumns({ header: 'Country', cell: ({ getValue }) => { const iso = getValue() as string | null; + if (!iso) return -; return ( - {iso ? getCountryName(iso, 'en') : '-'} + + + {getCountryName(iso, 'en')} + ); }, }, @@ -264,7 +269,7 @@ export function getClientColumns({ }, }, { - // Hidden by default — the per-berth stage is now carried by each + // Hidden by default - the per-berth stage is now carried by each // chip in the Berths column, so this standalone column is only // useful when a user has explicitly toggled it on. id: 'latestStage', @@ -327,10 +332,10 @@ export function getClientColumns({ /** * Single berth-with-stage chip used in the inline (top-2) chip row of * the Berths column. Shows mooring + full stage label, with a colored - * dot for stage reinforcement (decorative — the label carries the + * dot for stage reinforcement (decorative - the label carries the * meaning so color-blind / no-hover users don't lose anything). * - * Click target is the *interest*, not the berth — the user almost + * Click target is the *interest*, not the berth - the user almost * always wants to act on the deal, not look at the berth's static * specs. Outcome-set rows (won/lost/cancelled) get a muted dot so they * read as historical context rather than active work. diff --git a/src/components/clients/client-detail-header.tsx b/src/components/clients/client-detail-header.tsx index 243a78a9..0a6b9f79 100644 --- a/src/components/clients/client-detail-header.tsx +++ b/src/components/clients/client-detail-header.tsx @@ -16,6 +16,7 @@ import { HardDeleteDialog } from '@/components/clients/hard-delete-dialog'; import { ReminderForm } from '@/components/reminders/reminder-form'; import { useQueryClient } from '@tanstack/react-query'; import { DetailHeaderStrip } from '@/components/shared/detail-header-strip'; +import { CountryFlag } from '@/components/shared/country-flag'; import { PortalInviteButton } from '@/components/clients/portal-invite-button'; import { GdprExportButton } from '@/components/clients/gdpr-export-button'; import { cn } from '@/lib/utils'; @@ -69,7 +70,6 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) { const addedLabel = client.createdAt ? `Added ${format(new Date(client.createdAt), 'MMM d, yyyy')}` : null; - const meta = [country, addedLabel].filter(Boolean) as string[]; return ( <> @@ -87,8 +87,21 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) { )}
- {meta.length > 0 ? ( -

{meta.join(' · ')}

+ {country || addedLabel ? ( +

+ {country ? ( + + + {country} + + ) : null} + {country && addedLabel ? · : null} + {addedLabel ? {addedLabel} : null} +

) : null}
diff --git a/src/components/clients/client-form.tsx b/src/components/clients/client-form.tsx index 6b59cfe6..00eda419 100644 --- a/src/components/clients/client-form.tsx +++ b/src/components/clients/client-form.tsx @@ -42,7 +42,7 @@ interface ClientFormProps { * or opening the create-interest dialog pre-filled with that * clientId. Skipped in edit mode. */ onUseExistingClient?: (clientId: string) => void; - /** Optional initial values for the create flow — used by the + /** Optional initial values for the create flow - used by the * inquiry-inbox "Convert to client" triage step (P-4.5) so the rep * doesn't retype values they just read in the inbox. The * `sourceInquiryId` is persisted to `clients.source_inquiry_id` on @@ -110,7 +110,7 @@ export function ClientForm({ // Primary-address fields. Live outside RHF because the API splits // client creation (`POST /api/v1/clients`) from address creation - // (`POST /api/v1/clients/{id}/addresses`) — the address gets chained + // (`POST /api/v1/clients/{id}/addresses`) - the address gets chained // after the client POST returns the new id. Edit mode uses the // dedicated Addresses tab; the form here is create-only. const [addressOpen, setAddressOpen] = useState(false); @@ -217,7 +217,7 @@ export function ClientForm({ } // Primary is per-channel (DB has a partial unique index on // (client_id, channel) WHERE is_primary). For every channel present - // in the cleaned set, ensure exactly one row is flagged primary — + // in the cleaned set, ensure exactly one row is flagged primary - // promote the first row of that channel if none was explicitly // marked, and clear duplicates so the API doesn't 409. const seenPrimaryByChannel = new Set(); @@ -225,7 +225,7 @@ export function ClientForm({ if (c.isPrimary && !seenPrimaryByChannel.has(c.channel)) { seenPrimaryByChannel.add(c.channel); } else if (c.isPrimary) { - // duplicate primary within the channel — clear + // duplicate primary within the channel - clear c.isPrimary = false; } } @@ -253,7 +253,7 @@ export function ClientForm({ body: payload, }); // Chain the address POST when any field is filled. Address errors - // don't unwind the client create — surface a toast warning and + // don't unwind the client create - surface a toast warning and // leave the client in place so the rep can finish in the // Addresses tab. const hasAddress = @@ -467,7 +467,7 @@ export function ClientForm({ const checked = !!v; const thisChannel = watch(`contacts.${index}.channel`); if (checked) { - // Primary is per-channel — flipping this one on + // Primary is per-channel - flipping this one on // clears the flag on every other row sharing the // same channel. (DB enforces uniqueness via a // partial index, but doing it client-side avoids @@ -589,7 +589,7 @@ export function ClientForm({ - {/* Primary Address — create-only. Editing happens in the + {/* Primary Address - create-only. Editing happens in the client detail page's Addresses tab. */} {!isEdit ? (
@@ -657,7 +657,7 @@ export function ClientForm({ value={addrCountryIso} onChange={(iso) => { setAddrCountryIso(iso ?? null); - // Clear region if country changes — keeps the + // Clear region if country changes - keeps the // subdivision picker consistent with its country. setAddrSubdivisionIso(null); }} diff --git a/src/components/clients/client-list.tsx b/src/components/clients/client-list.tsx index a888f290..85cabc46 100644 --- a/src/components/clients/client-list.tsx +++ b/src/components/clients/client-list.tsx @@ -170,7 +170,7 @@ export function ClientList() { }); // Per-user column visibility, persisted into user_profiles.preferences - // via /api/v1/me. Hidden IDs are the source of truth — `actions` and + // via /api/v1/me. Hidden IDs are the source of truth - `actions` and // `select` columns aren't user-toggleable so they're never in the // hidden set. New columns surface for existing users by default. const { hidden, setHidden } = useTablePreferences('clients', CLIENT_DEFAULT_HIDDEN); @@ -190,7 +190,7 @@ export function ClientList() { { - // Atomic replace — sequential setFilter() calls dropped all + // Atomic replace - sequential setFilter() calls dropped all // but the last value (each one read stale `filters` from // closure and overwrote). setAllFilters writes the whole // saved view in one setState. diff --git a/src/components/clients/client-pipeline-summary.tsx b/src/components/clients/client-pipeline-summary.tsx index bc9a3c87..e1ccc22a 100644 --- a/src/components/clients/client-pipeline-summary.tsx +++ b/src/components/clients/client-pipeline-summary.tsx @@ -28,7 +28,7 @@ export interface ClientInterestRow { dateLastContact: string | null; berthMooringNumber?: string | null; yachtName?: string | null; - /** Requirements surfaced on the Client Overview panel — "Wants L × W × D + /** Requirements surfaced on the Client Overview panel - "Wants L × W × D * · Source" lets reps see what the deal is looking for without drilling * into the Interest detail. Fields are nullable when the rep hasn't * captured constraints yet. */ @@ -88,7 +88,7 @@ export function StageStepper({ ); })}
- {/* Stage-name row below the bar — surfaces all reached stage names + {/* Stage-name row below the bar - surfaces all reached stage names inline (compact short-labels) so the bar isn't a mystery without hovering. Future stages render in muted text so the rep can still see the ladder ahead. The `xs` size variant hides this row to @@ -323,7 +323,7 @@ function PanelVariant({ clientId, portSlug }: { clientId: string; portSlug: stri
{/* Requirements one-liner: "Wants 50ft × 18ft × 8ft · Referral". - Hidden when the rep hasn't captured any constraints yet — + Hidden when the rep hasn't captured any constraints yet - noise reduction over empty placeholders. */} {(() => { const dims = [i.desiredLengthFt, i.desiredWidthFt, i.desiredDraftFt] diff --git a/src/components/clients/client-tabs.tsx b/src/components/clients/client-tabs.tsx index 86cbd255..dccd672e 100644 --- a/src/components/clients/client-tabs.tsx +++ b/src/components/clients/client-tabs.tsx @@ -169,7 +169,7 @@ function OverviewTab({ value={client.nationalityIso ?? null} onSave={async (iso) => { // Auto-default the timezone to the country's primary - // zone when none is set yet — saves the rep a click + // zone when none is set yet - saves the rep a click // and matches what a marina actually wants for first // contact (London for GB, NYC for US, etc.). Only // fires when timezone is empty so we never clobber a diff --git a/src/components/clients/contacts-editor.tsx b/src/components/clients/contacts-editor.tsx index d5b4263a..2c4f3031 100644 --- a/src/components/clients/contacts-editor.tsx +++ b/src/components/clients/contacts-editor.tsx @@ -32,7 +32,7 @@ interface Contact { valueCountry?: string | null; label?: string | null; isPrimary: boolean; - /** Phase 3d — origin tag surfaced as an [EOI] badge when an EOI + /** Phase 3d - origin tag surfaced as an [EOI] badge when an EOI * spawned this contact. */ source?: string | null; sourceDocumentId?: string | null; @@ -230,7 +230,7 @@ function ContactRow({
{/* Override history is only meaningful for the canonical "primary email" / "primary phone" entries the supplemental form - overwrites — secondary contacts don't have a matching + overwrites - secondary contacts don't have a matching bindable path. The icon renders nothing when no rows exist. */} {contact.isPrimary && contact.channel === 'email' ? ( @@ -276,7 +276,7 @@ function ContactRow({ className="inline-flex items-center rounded bg-amber-100 px-1 text-[10px] font-medium text-amber-800" title={ contact.sourceDocumentId - ? 'Spawned from an EOI — open the source document for details.' + ? 'Spawned from an EOI - open the source document for details.' : 'Spawned from an EOI override.' } > diff --git a/src/components/clients/dedup-suggestion-panel.tsx b/src/components/clients/dedup-suggestion-panel.tsx index aa798da3..46175f7c 100644 --- a/src/components/clients/dedup-suggestion-panel.tsx +++ b/src/components/clients/dedup-suggestion-panel.tsx @@ -20,7 +20,7 @@ interface MatchData { emails: string[]; phonesE164: string[]; /** ISO timestamp when the client was archived. When set, the matched - * client is soft-deleted — the suggestion panel surfaces a Restore link + * client is soft-deleted - the suggestion panel surfaces a Restore link * to the existing restore wizard instead of "Use this client". */ archivedAt: string | null; } @@ -137,7 +137,7 @@ export function DedupSuggestionPanel({ ? 'This contact info belongs to an archived client' : isHigh ? 'This looks like an existing client' - : 'Possible match — check before creating'} + : 'Possible match - check before creating'}

{isArchived && (

diff --git a/src/components/clients/hard-delete-dialog.tsx b/src/components/clients/hard-delete-dialog.tsx index 442cd3b7..aa242b42 100644 --- a/src/components/clients/hard-delete-dialog.tsx +++ b/src/components/clients/hard-delete-dialog.tsx @@ -35,7 +35,7 @@ type Stage = 'intent' | 'confirm'; * Outer wrapper keeps the Dialog mounted (so its close animation runs); * the body only mounts when `open` is true and remounts on each * open via the `clientId` key. This avoids the open→reset-state - * useEffect that React Compiler flags — fresh state per open is just + * useEffect that React Compiler flags - fresh state per open is just * the natural mount. */ export function HardDeleteDialog(props: Props) { diff --git a/src/components/clients/send-documents-dialog.tsx b/src/components/clients/send-documents-dialog.tsx index 27d85de7..52805057 100644 --- a/src/components/clients/send-documents-dialog.tsx +++ b/src/components/clients/send-documents-dialog.tsx @@ -58,7 +58,7 @@ export function SendDocumentsDialog({ | null >(null); - // Lightweight brochures fetch — only fires once dialog is opened. + // Lightweight brochures fetch - only fires once dialog is opened. const brochuresQuery = useQuery({ queryKey: ['brochures', 'list'], queryFn: () => apiFetch('/api/v1/admin/brochures'), diff --git a/src/components/clients/smart-archive-dialog.tsx b/src/components/clients/smart-archive-dialog.tsx index 1b65f966..b73de3e7 100644 --- a/src/components/clients/smart-archive-dialog.tsx +++ b/src/components/clients/smart-archive-dialog.tsx @@ -206,7 +206,7 @@ function SmartArchiveDialogBody({ if (!dossier) throw new Error('No dossier'); // Pick the first linked interest for this berth from the // authoritative dossier join. Berths with no linked interest for - // this client are skipped — sending an empty interestId would + // this client are skipped - sending an empty interestId would // make the server-side delete silently match zero rows. const berthDec = dossier.berths .map((b) => { diff --git a/src/components/companies/company-columns.tsx b/src/components/companies/company-columns.tsx index 43ab7976..5aa91586 100644 --- a/src/components/companies/company-columns.tsx +++ b/src/components/companies/company-columns.tsx @@ -52,7 +52,7 @@ export const COMPANY_COLUMN_OPTIONS = [ { id: 'actions', label: 'Actions', alwaysVisible: true }, ]; -/** Hidden by default — keep the table dense; opt-in to longer columns. */ +/** Hidden by default - keep the table dense; opt-in to longer columns. */ export const COMPANY_DEFAULT_HIDDEN: string[] = ['legalName', 'taxId']; interface GetCompanyColumnsOptions { diff --git a/src/components/companies/company-form.tsx b/src/components/companies/company-form.tsx index 7b20d2cd..c3b00ca2 100644 --- a/src/components/companies/company-form.tsx +++ b/src/components/companies/company-form.tsx @@ -77,7 +77,7 @@ interface CompanyFormProps { notes: string | null; }; /** - * Optional initial values for the create flow — used by the global + * Optional initial values for the create flow - used by the global * command-search quick-create ("New company 'matthew'" → lands on * `/companies?create=1&prefill_name=matthew`). Ignored in edit mode. */ @@ -91,7 +91,7 @@ export function CompanyForm({ open, onOpenChange, company, prefill }: CompanyFor const router = useRouter(); const isEdit = !!company; const [formError, setFormError] = useState(null); - // Connection state — only used in create mode. Editing companies is done + // Connection state - only used in create mode. Editing companies is done // from the detail page where members + yachts have their own tabs that // know how to handle removal / reassignment cleanly. const [attachedClientIds, setAttachedClientIds] = useState([]); @@ -107,7 +107,7 @@ export function CompanyForm({ open, onOpenChange, company, prefill }: CompanyFor { yachtId: string; yachtName: string }[] | null >(null); // Reserved for the inverse pull-in (attached yacht → owner client). Wired - // through but the inferring query is deferred — owner history isn't yet + // through but the inferring query is deferred - owner history isn't yet // surfaced cheaply via the yacht endpoint. // const [pendingOwnerPullIn, setPendingOwnerPullIn] = useState<...>(null); const [createInterestFor, setCreateInterestFor] = useState(null); @@ -174,7 +174,7 @@ export function CompanyForm({ open, onOpenChange, company, prefill }: CompanyFor }); const newCompanyId = res.data.id; // Connect each attached client as a company member. Failures collected - // here surface as a toast but don't roll back the company create — the + // here surface as a toast but don't roll back the company create - the // rep can fix individual mismatches from the company detail page. for (const clientId of attachedClientIds) { try { @@ -232,10 +232,10 @@ export function CompanyForm({ open, onOpenChange, company, prefill }: CompanyFor return; } } catch { - // Yacht lookup failure is non-fatal — fall through to interest prompt. + // Yacht lookup failure is non-fatal - fall through to interest prompt. } - // (Step 2b — yacht-owner pull-in — deferred. Adding it cleanly needs + // (Step 2b - yacht-owner pull-in - deferred. Adding it cleanly needs // the yachts API to surface prior owners post-transfer, which currently // only lives in the activity log. Tracked for follow-up.) @@ -396,7 +396,7 @@ export function CompanyForm({ open, onOpenChange, company, prefill }: CompanyFor - {/* Connections — only on create. Editing membership / yacht ownership + {/* Connections - only on create. Editing membership / yacht ownership from this form would race with the same actions on the detail tabs (and the audit trail of a "create + attach 5 clients in one flow" is much more readable than 6 separate create rows). */} @@ -498,7 +498,7 @@ export function CompanyForm({ open, onOpenChange, company, prefill }: CompanyFor {/* Stacked "+ New client" / "+ New yacht" forms. On successful create - the picker we open them from doesn't know the new id yet — the + the picker we open them from doesn't know the new id yet - the ClientList / YachtList query refetches via react-query invalidation and the rep can pick the new entity from the dropdown immediately. */} @@ -506,7 +506,7 @@ export function CompanyForm({ open, onOpenChange, company, prefill }: CompanyFor )} diff --git a/src/components/companies/company-list.tsx b/src/components/companies/company-list.tsx index 48a817ca..a3059baf 100644 --- a/src/components/companies/company-list.tsx +++ b/src/components/companies/company-list.tsx @@ -125,7 +125,7 @@ export function CompanyList() { onArchive: (company) => setArchiveCompany(company), }); - // Persisted column visibility — same pattern as ClientList / BerthList. + // Persisted column visibility - same pattern as ClientList / BerthList. const { hidden, setHidden } = useTablePreferences('companies', COMPANY_DEFAULT_HIDDEN); const columnVisibility = Object.fromEntries(hidden.map((id) => [id, false])); diff --git a/src/components/dashboard/active-deals-tile.tsx b/src/components/dashboard/active-deals-tile.tsx index 385f2c63..397d10dd 100644 --- a/src/components/dashboard/active-deals-tile.tsx +++ b/src/components/dashboard/active-deals-tile.tsx @@ -19,7 +19,7 @@ interface KpiResponse { } /** - * Compact rail-sized KPI tile — single number, label, and a click- + * Compact rail-sized KPI tile - single number, label, and a click- * through to the interests pipeline. Reuses the existing dashboard KPIs * endpoint so we don't pay an extra round-trip. */ @@ -36,7 +36,7 @@ export function ActiveDealsTile() { return ( {/* shadcn's default CardContent ships with `pt-0 sm:pt-0` (it assumes a - CardHeader sits above). The `sm:` variants are required — without + CardHeader sits above). The `sm:` variants are required - without them `sm:pt-0` wins at the sm breakpoint and the content snaps to the top edge. */} @@ -57,7 +57,7 @@ export function ActiveDealsTile() {

c.toUpperCase()); } +/** Entity type alias map for the feed labels. Most types humanize fine + * via `humanizeFieldName`, but a few read awkwardly ("Residential + * Client" is clearer than the raw enum, notes flatten to their parent). */ +const ENTITY_TYPE_LABELS: Record = { + residential_client: 'Residential client', + residential_interest: 'Residential interest', + berth_reservation: 'Berth reservation', + berth_maintenance_log: 'Berth maintenance', + berth_recommendation: 'Berth recommendation', + client_note: 'Client note', + yacht_note: 'Yacht note', + company_note: 'Company note', + interest_note: 'Interest note', + interest_qualification: 'Interest qualification', + document_send: 'Document send', + document_folder: 'Document folder', + document_template: 'Document template', + documentTemplate: 'Document template', + form_template: 'Form template', + report_template: 'Report template', + email_account: 'Email account', + email_message: 'Email message', + user_email_change: 'Email change', + custom_field_definition: 'Custom field', + custom_field_values: 'Custom field', + expense_export: 'Expense export', + gdpr_export: 'GDPR export', + qualification_criterion: 'Qualification criterion', + website_submission: 'Website submission', + webhook_inbound: 'Inbound webhook', + webhook_delivery: 'Webhook delivery', + audit_log: 'Audit log', + portal_user: 'Portal user', + portal_session: 'Portal session', + portal_auth_token: 'Portal token', + client_contact: 'Client contact', + clientContact: 'Client contact', + clientAddress: 'Client address', + companyAddress: 'Company address', + clientRelationship: 'Client relationship', + company_membership: 'Company membership', + crm_invite: 'CRM invite', + queue_job: 'Queue job', + super_admin: 'Super admin', +}; +function humanizeEntityType(type: string): string { + return ENTITY_TYPE_LABELS[type] ?? humanizeFieldName(type); +} + /** Map enum-typed field values to their canonical human labels. The audit * log stores raw enum strings (`deposit_10pct`, `lost_other_marina`); the * feed should read like `10% Deposit`, not the wire value. */ @@ -85,13 +134,13 @@ function normalizeEnumValue(field: string, value: unknown): unknown { * count; nulls / empty render as em-dash. */ function shortValue(value: unknown, fieldContext?: string): string { if (fieldContext) value = normalizeEnumValue(fieldContext, value); - if (value === null || value === undefined || value === '') return '—'; + if (value === null || value === undefined || value === '') return '-'; if (typeof value === 'string') return value; if (typeof value === 'number' || typeof value === 'boolean') return String(value); if (Array.isArray(value)) return `${value.length} item${value.length === 1 ? '' : 's'}`; if (typeof value === 'object') { const entries = Object.entries(value as Record); - if (entries.length === 0) return '—'; + if (entries.length === 0) return '-'; return entries .slice(0, 3) .map( @@ -199,7 +248,7 @@ function ActivityFeedInner() { // A1: permission_denied rows on the activity feed render as a bare // action badge with no entity name (they target `admin.X` with empty - // entityId). They're noise for the rep — keep them in the audit log + // entityId). They're noise for the rep - keep them in the audit log // page but hide them from the dashboard feed. const items = (data ?? []).filter((i) => i.action !== 'permission_denied'); @@ -245,18 +294,23 @@ function ActivityFeedInner() { space between them. */} · - {item.entityType} + {humanizeEntityType(item.entityType)} ) : ( - <> - {item.entityType} - {item.entityId && ( - - {item.entityId.slice(0, 8)} + // No resolvable label - either the entity was + // deleted or the type isn't in the server-side + // resolver yet. Either way we never expose a + // UUID fragment: it reads as noise to the rep + // and leaks an internal identifier. + + {humanizeEntityType(item.entityType)} + {item.entityId ? ( + + (removed) - )} - + ) : null} + )}

{diffLine ? ( diff --git a/src/components/dashboard/berth-heat-widget.tsx b/src/components/dashboard/berth-heat-widget.tsx index 92b88a87..ff71578f 100644 --- a/src/components/dashboard/berth-heat-widget.tsx +++ b/src/components/dashboard/berth-heat-widget.tsx @@ -1,7 +1,7 @@ 'use client'; /** - * Berth-demand widget — ranks berths by active interest count, with a + * Berth-demand widget - ranks berths by active interest count, with a * horizontal bar per row encoding magnitude relative to the leader. * Matches the standard CardHeader / CardContent layout of its dashboard * siblings; the bars (not chrome) do the visual work. diff --git a/src/components/dashboard/clients-by-country-widget.tsx b/src/components/dashboard/clients-by-country-widget.tsx index 0a04ccc5..1fa76d3b 100644 --- a/src/components/dashboard/clients-by-country-widget.tsx +++ b/src/components/dashboard/clients-by-country-widget.tsx @@ -9,6 +9,7 @@ import { Globe } from 'lucide-react'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Skeleton } from '@/components/ui/skeleton'; import { apiFetch } from '@/lib/api/client'; +import { CountryFlag } from '@/components/shared/country-flag'; import { getCountryName } from '@/lib/i18n/countries'; import { cn } from '@/lib/utils'; @@ -32,7 +33,7 @@ interface ClientsByCountryResponse { * into a specific market. Country names render via the existing * locale-aware helper; unknown ISO codes fall back to the raw code. * - * Variant (b) of the master-doc design — a true choropleth would need + * Variant (b) of the master-doc design - a true choropleth would need * a heavier viz lib (react-simple-maps + topojson) and pushes us to * the chart-library migration agenda. Variant (a) ships now; the * world-map variant can land alongside the recharts→ECharts pass. @@ -100,13 +101,11 @@ export function ClientsByCountryWidget({ limit = 8 }: { limit?: number } = {}) { className="group flex items-center justify-between gap-3 rounded-md px-2 py-1.5 -mx-2 hover:bg-foreground/5" title={`${row.count} client${row.count === 1 ? '' : 's'} in ${name}`} > -
- - {row.country} - +
+ {name}
- {/* Mini bar — same `BerthHeatWidget` idiom: a thin + {/* Mini bar - same `BerthHeatWidget` idiom: a thin background track with a coloured fill. The count sits on the right so the eye can read both the bar shape and the precise number. */} diff --git a/src/components/dashboard/customize-widgets-menu.tsx b/src/components/dashboard/customize-widgets-menu.tsx index eb656310..f0d452dc 100644 --- a/src/components/dashboard/customize-widgets-menu.tsx +++ b/src/components/dashboard/customize-widgets-menu.tsx @@ -33,23 +33,35 @@ import { import { Switch } from '@/components/ui/switch'; import { cn } from '@/lib/utils'; import { useDashboardWidgets } from '@/hooks/use-dashboard-widgets'; -import type { DashboardWidget } from './widget-registry'; +import type { DashboardWidget, WidgetGroup } from './widget-registry'; + +// The dashboard renders widgets in three independent visual regions at +// xl (1280+): charts (main column), rails (right aside), feed (full- +// width). Below xl, all three regions stack into one visual column - +// from the rep's eye it reads as a single ordered list, so the modal +// flattens its sortable in that tier. At xl it splits into three +// region-scoped sortables to match the actual side-by-side layout. +const GROUP_LABELS: Record = { + chart: 'Charts', + rail: 'Side rail', + feed: 'Activity', +}; +const GROUP_ORDER: readonly WidgetGroup[] = ['chart', 'rail', 'feed']; /** - * Combined visibility + reorder picker for the dashboard header. Two - * sections in one modal: + * Combined visibility + reorder picker for the dashboard header. * - * 1. "On dashboard" — visible widgets, each row with a drag handle - * (reorder via dnd-kit single SortableContext, no buckets); flipping - * a switch off moves the row to section 2. - * 2. "Hidden" — widgets currently off; flipping a switch on appends to - * the bottom of section 1. + * The dashboard renders widgets in three independent visual regions - + * Charts (main column), Side rail (right aside), Activity (full-width + * feed). A drag across regions can't change the visual outcome, so the + * modal exposes one sortable list per region instead of a single flat + * list that silently fails on cross-region moves. Toggling a widget off + * moves it to the "Hidden" section; toggling on appends it to the + * bottom of its native region. * * Both visibility toggles and order changes commit optimistically via * `useDashboardWidgets` so the dashboard reflows in the background and - * the rep can keep editing. The "Rearrange" button on the header is - * gone — order lives here too now, keeping all dashboard layout - * controls in one place. + * the rep can keep editing. */ export function CustomizeWidgetsMenu() { const [open, setOpen] = useState(false); @@ -57,6 +69,7 @@ export function CustomizeWidgetsMenu() { allWidgets, visibleWidgets, visibility, + isXlLayout, setVisible, setAll, setOrder, @@ -79,7 +92,53 @@ export function CustomizeWidgetsMenu() { useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ); - function onDragEnd(event: DragEndEvent) { + // Visible widgets split per region. Empty regions render nothing so + // we don't show an "On dashboard / Side rail (0)" tease. + const visibleByGroup: Record = { + chart: visibleWidgets.filter((w) => w.group === 'chart'), + rail: visibleWidgets.filter((w) => w.group === 'rail'), + feed: visibleWidgets.filter((w) => w.group === 'feed'), + }; + + // A drag inside group X only moves widgets within that group. Rebuild + // the flat order by walking `visibleWidgets` in its current sequence + // and replacing each group-X slot with the next id from the reordered + // group list. This preserves the relative position of every other + // widget - only the dragged group's internal order changes. + function reorderGroup(group: WidgetGroup, oldIndex: number, newIndex: number) { + const groupIds = visibleByGroup[group].map((w) => w.id); + if ( + oldIndex < 0 || + newIndex < 0 || + oldIndex >= groupIds.length || + newIndex >= groupIds.length + ) { + return; + } + const reordered = arrayMove(groupIds, oldIndex, newIndex); + let cursor = 0; + const nextOrder = visibleWidgets.map((w) => + w.group === group ? (reordered[cursor++] ?? w.id) : w.id, + ); + setOrder(nextOrder); + } + + function makeDragEndHandler(group: WidgetGroup) { + return (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + const ids = visibleByGroup[group].map((w) => w.id); + const oldIndex = ids.indexOf(String(active.id)); + const newIndex = ids.indexOf(String(over.id)); + if (oldIndex === -1 || newIndex === -1) return; + reorderGroup(group, oldIndex, newIndex); + }; + } + + // Flat reorder used by the stacked layout (< xl). One SortableContext + // over every visible widget; drops persist via setOrder, which the + // hook routes to the mobile order field. + function onFlatDragEnd(event: DragEndEvent) { const { active, over } = event; if (!over || active.id === over.id) return; const ids = visibleWidgets.map((w) => w.id); @@ -97,24 +156,64 @@ export function CustomizeWidgetsMenu() { Customize - + Customize dashboard - Drag a visible widget to change its position. Toggle the switch to show or hide. Hidden - widgets leave no empty space - the layout reflows to fill the available width. + {isXlLayout + ? 'Editing the desktop layout - drag a widget to reorder it within its region.' + : 'Editing the stacked layout for this device - drag a widget to reorder. Your desktop arrangement is saved separately.'}{' '} + Toggle the switch to show or hide. Hidden widgets leave no empty space - the layout + reflows to fill the available width. {/* Toggle + reorder list. Capped at ~60vh with internal scroll so - the modal doesn't push the action footer off-screen. */} + the modal doesn't push the action footer off-screen. The + layout matches what the rep is actually seeing: at xl the + dashboard renders charts | rails | feed as three independent + slots, so the picker exposes three region-scoped sortables. + Below xl everything stacks into one column visually, so the + picker collapses to a single flat sortable that reorders + across the whole list. */}
- {visibleWidgets.length > 0 ? ( + {isXlLayout ? ( + GROUP_ORDER.map((group) => { + const widgets = visibleByGroup[group]; + if (widgets.length === 0) return null; + return ( +
+ + w.id)} + strategy={verticalListSortingStrategy} + > +
    + {widgets.map((w, idx) => ( + setVisible(w.id, checked)} + /> + ))} +
+
+
+
+ ); + }) + ) : visibleWidgets.length > 0 ? (
w.id)} diff --git a/src/components/dashboard/dashboard-shell.tsx b/src/components/dashboard/dashboard-shell.tsx index bded6d70..128a0586 100644 --- a/src/components/dashboard/dashboard-shell.tsx +++ b/src/components/dashboard/dashboard-shell.tsx @@ -90,7 +90,7 @@ export function DashboardShell({ const feed = visibleWidgets.filter((w) => w.group === 'feed'); // Reuses the existing ['me'] cache (5-minute staleTime) populated by - // useTablePreferences elsewhere — usually a cache hit, so no extra + // useTablePreferences elsewhere - usually a cache hit, so no extra // request. When the page server-prefetches the first name we seed it // here via `initialData` so the cache is warm before the post-mount // fetch resolves, eliminating the "Welcome back → Hello, Matt" flash. @@ -107,12 +107,12 @@ export function DashboardShell({ // Greeting word is computed in a useEffect so the rendered HTML can't lock // to the server's clock during hydration. Until the effect fires, the - // header reads "Welcome" — a neutral phrase that's correct at every hour + // header reads "Welcome" - a neutral phrase that's correct at every hour // and never produces a hydration warning. `clientGreeting` flips to the // local-time-aware phrasing once the component has mounted. const [clientGreeting, setClientGreeting] = useState(null); useEffect(() => { - // setState here is intentional — we delay the time-aware greeting + // setState here is intentional - we delay the time-aware greeting // until after hydration to avoid SSR/client clock mismatch. // eslint-disable-next-line react-hooks/set-state-in-effect setClientGreeting(timeOfDayGreeting()); @@ -149,7 +149,7 @@ export function DashboardShell({
{/* Mobile-only greeting strip. The shared PageHeader is hidden below `sm` (its title is normally duplicated by the topbar), - so we render the welcome message inline here for mobile — + so we render the welcome message inline here for mobile - keeps the personalized touch from desktop without polluting the topbar (which stays "Dashboard" for wayfinding). */}
@@ -170,8 +170,8 @@ export function DashboardShell({ actions={
- +
} /> @@ -232,7 +232,7 @@ export function DashboardShell({ /** * Placeholder shown when the rep has hidden every widget. Without this, * the dashboard collapses to just the gradient header strip and looks - * like a broken page — this hints at the "Customize" button to bring + * like a broken page - this hints at the "Customize" button to bring * widgets back. */ function EmptyDashboardHint() { diff --git a/src/components/dashboard/hot-deals-card.tsx b/src/components/dashboard/hot-deals-card.tsx index 6bcc52ff..05980569 100644 --- a/src/components/dashboard/hot-deals-card.tsx +++ b/src/components/dashboard/hot-deals-card.tsx @@ -25,7 +25,7 @@ interface HotDealsResponse { // Local label map intentionally narrowed to the stages this widget // surfaces. Keys MUST match the canonical DB values for the 7-stage -// pipeline (post-2026-05 refactor) — the reporting audit caught typos +// pipeline (post-2026-05 refactor) - the reporting audit caught typos // that broke the rank ladder server-side AND rendered raw enum to the user. const STAGE_LABELS: Record = { contract: 'Contract', diff --git a/src/components/dashboard/pipeline-value-tile.tsx b/src/components/dashboard/pipeline-value-tile.tsx index ef371f0e..99496343 100644 --- a/src/components/dashboard/pipeline-value-tile.tsx +++ b/src/components/dashboard/pipeline-value-tile.tsx @@ -59,7 +59,7 @@ const STAGE_BAR_CLASS: Record = { export function PipelineValueTile({ range }: { range?: DateRange } = {}) { // Range query-string is keyed on the slug ('7d' / 'custom-2026-01-01...'). // When range is undefined, the tile falls back to the "all active deals" - // snapshot — preserves the old behaviour for callers that don't yet + // snapshot - preserves the old behaviour for callers that don't yet // thread range through. const slug = range ? rangeToSlug(range) : null; const qs = slug ? `?range=${encodeURIComponent(slug)}` : ''; diff --git a/src/components/dashboard/source-conversion-chart.tsx b/src/components/dashboard/source-conversion-chart.tsx index 70969c2a..2ca51097 100644 --- a/src/components/dashboard/source-conversion-chart.tsx +++ b/src/components/dashboard/source-conversion-chart.tsx @@ -70,7 +70,7 @@ export function SourceConversionChart() {
- {/* Inline bar — keeps the widget compact and lets eight + {/* Inline bar - keeps the widget compact and lets eight rows share the same vertical space a Recharts plot would use for two. */}
diff --git a/src/components/dashboard/timezone-drift-banner.tsx b/src/components/dashboard/timezone-drift-banner.tsx index b5ee8306..fef19c83 100644 --- a/src/components/dashboard/timezone-drift-banner.tsx +++ b/src/components/dashboard/timezone-drift-banner.tsx @@ -86,7 +86,7 @@ export function TimezoneDriftBanner() { try { window.localStorage.setItem(DISMISS_STORAGE_KEY, 'true'); } catch { - // Non-fatal — we just don't persist the dismissal. + // Non-fatal - we just don't persist the dismissal. } } diff --git a/src/components/dashboard/website-glance-tile.tsx b/src/components/dashboard/website-glance-tile.tsx index 37af8a8f..9f6c80ac 100644 --- a/src/components/dashboard/website-glance-tile.tsx +++ b/src/components/dashboard/website-glance-tile.tsx @@ -4,11 +4,11 @@ * Compact "Website at a glance" tile for the main sales dashboard. Shows * pageviews for the dashboard's current range + active visitors right * now + a deep-link to the full /website-analytics page. Soft-fails - * (renders nothing) when Umami isn't configured for this port — the + * (renders nothing) when Umami isn't configured for this port - the * configure-prompt lives on the dedicated page, not the dashboard. * * When an Umami call fails (auth, network, shape) the tile renders a - * dash "—" instead of "0" so the rep can tell error from no-data. + * dash "-" instead of "0" so the rep can tell error from no-data. */ import Link from 'next/link'; @@ -49,7 +49,7 @@ export function WebsiteGlanceTile({ range = '30d' }: Props) { return null; } - // Umami v3 returns flat numbers — `data?.data?.pageviews` is a number, + // Umami v3 returns flat numbers - `data?.data?.pageviews` is a number, // not `{value, prev}`. The previous nested shape was Umami v1; v3 moved // comparison values into a sibling `comparison` block. const pageviews = stats.data?.data?.pageviews; diff --git a/src/components/dashboard/widget-registry.tsx b/src/components/dashboard/widget-registry.tsx index d4848903..612c6766 100644 --- a/src/components/dashboard/widget-registry.tsx +++ b/src/components/dashboard/widget-registry.tsx @@ -1,5 +1,5 @@ /** - * Dashboard widget registry — the single source of truth for which + * Dashboard widget registry - the single source of truth for which * widgets exist, what they're called, where they live, and what they * default to. The DashboardShell loops over this; the settings UI also * loops over this. Adding a new widget = adding one entry here. @@ -76,7 +76,7 @@ export type WidgetGroup = 'chart' | 'rail' | 'feed'; export type WidgetIntegration = 'umami' | 'documenso'; export interface DashboardWidget { - /** Stable persistence key. Don't rename — old preferences would break. */ + /** Stable persistence key. Don't rename - old preferences would break. */ id: string; label: string; description: string; @@ -92,7 +92,7 @@ export interface DashboardWidget { /** * Some widgets self-gate (e.g. WebsiteGlanceTile renders null when * Umami isn't configured). When `true`, the settings UI still shows - * the toggle so admins can enable it once the integration is wired — + * the toggle so admins can enable it once the integration is wired - * but the widget itself decides whether to render content. */ selfGates?: boolean; @@ -106,7 +106,7 @@ export interface DashboardWidget { export const DASHBOARD_WIDGETS: readonly DashboardWidget[] = [ // ── KPI tiles (rail) ──────────────────────────────────────────────── - // Off by default — keep the existing dashboard layout unchanged for + // Off by default - keep the existing dashboard layout unchanged for // users on first paint after the upgrade; reps can flip them on from // the Customize menu. { @@ -166,10 +166,10 @@ export const DASHBOARD_WIDGETS: readonly DashboardWidget[] = [ { id: 'source_conversion', label: 'Source Conversion', - description: 'Win rate per lead source — which channels deliver buyers, not just leads.', + description: 'Win rate per lead source - which channels deliver buyers, not just leads.', render: () => , group: 'chart', - // Flipped on 2026-05-14 — investor-facing conversion-funnel-by-source + // Flipped on 2026-05-14 - investor-facing conversion-funnel-by-source // surface (PRE-DEPLOY-PLAN § 1.6.23). Reads inquiry → client linkage // (clients.source_inquiry_id) added in migration 0065. defaultVisible: true, @@ -189,7 +189,7 @@ export const DASHBOARD_WIDGETS: readonly DashboardWidget[] = [ description: 'Per-country distribution of the active client book. Click a row to filter the clients list by country.', render: () => , - // Same rail-tile idiom as BerthHeatWidget + HotDealsCard — compact + // Same rail-tile idiom as BerthHeatWidget + HotDealsCard - compact // ranked list with mini-bars. Variant (a) per the master-doc design; // the world-map variant lands alongside the recharts→ECharts pass. group: 'rail', diff --git a/src/components/documents/cancel-document-dialog.tsx b/src/components/documents/cancel-document-dialog.tsx new file mode 100644 index 00000000..97055c60 --- /dev/null +++ b/src/components/documents/cancel-document-dialog.tsx @@ -0,0 +1,146 @@ +'use client'; + +import { useState } from 'react'; +import { Loader2, XCircle } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Label } from '@/components/ui/label'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { Textarea } from '@/components/ui/textarea'; + +export type CancelMode = 'delete' | 'keep_remote'; + +interface CancelDocumentDialogProps { + open: boolean; + onOpenChange: (next: boolean) => void; + /** Label used in the dialog ("Cancel reservation", "Cancel contract", "Cancel EOI"). */ + documentLabel: string; + /** Fires when the rep confirms. Caller invokes the mutation with the + * chosen `cancelMode` (and optional reason). The dialog stays open + * until `onOpenChange(false)` is called by the parent - typically on + * mutation success/failure. */ + onConfirm: (params: { cancelMode: CancelMode; reason: string }) => void; + /** When true, disables the confirm action + shows a spinner. */ + isSubmitting?: boolean; +} + +/** + * Cancel-confirm dialog with an explicit "what to do with Documenso?" + * choice. Default `'delete'` mirrors the prior behaviour - DELETE the + * upstream envelope to keep the Documenso log uncluttered. `keep_remote` + * leaves the envelope intact so admins can later inspect it for audit / + * forensics; only the local CRM row flips to `cancelled`. + * + * Used by the Reservation / Contract / EOI tabs (any signing-doc + * surface that exposes a Cancel CTA). Replaces the previous + * `useConfirmation()` flow which had no way to surface this choice. + */ +export function CancelDocumentDialog({ + open, + onOpenChange, + documentLabel, + onConfirm, + isSubmitting = false, +}: CancelDocumentDialogProps) { + const [cancelMode, setCancelMode] = useState('delete'); + const [reason, setReason] = useState(''); + + function reset() { + setCancelMode('delete'); + setReason(''); + } + + return ( + { + if (!next) reset(); + onOpenChange(next); + }} + > + + + Cancel {documentLabel.toLowerCase()} + + Signers will no longer be able to sign. Choose how to handle the document on Documenso. + + + +
+ setCancelMode(value as CancelMode)} + className="gap-3" + > + + + + +
+ +