From 3ffee79f3f7e481dc204cf4126bdd1723d8ae054 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 12 May 2026 14:50:58 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20broad=20consistency=20sweep=20?= =?UTF-8?q?=E2=80=94=20sources,=20dates,=20comboboxes,=20milestones?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mobile + responsive - berth-form full-width on phones (was 480px fixed → overflowed iPhone) - currency-input switched to inputMode=decimal with live thousands separator - client-form Country/Timezone/Source/Preferred-Contact full-width --- .gitignore | 2 + next.config.ts | 11 + package.json | 2 +- pnpm-lock.yaml | 258 ++++++-- .../(dashboard)/[portSlug]/alerts/page.tsx | 14 +- .../(dashboard)/[portSlug]/expenses/page.tsx | 2 + src/app/(dashboard)/[portSlug]/inbox/page.tsx | 5 + .../(dashboard)/[portSlug]/reminders/page.tsx | 13 +- src/app/(dashboard)/layout.tsx | 2 +- .../api/v1/dashboard/berth-status/route.ts | 11 + src/app/api/v1/dashboard/hot-deals/route.ts | 11 + .../v1/dashboard/source-conversion/route.ts | 11 + src/app/api/v1/users/me/preferences/route.ts | 13 +- src/app/globals.css | 69 +- .../admin/settings/settings-manager.tsx | 10 + src/components/alerts/alert-rail.tsx | 4 +- src/components/alerts/alerts-page-shell.tsx | 42 +- src/components/berths/berth-card.tsx | 108 ++-- src/components/berths/berth-detail-header.tsx | 5 +- src/components/berths/berth-detail.tsx | 2 +- src/components/berths/berth-form.tsx | 2 +- src/components/berths/berth-interests-tab.tsx | 13 +- src/components/berths/berth-list.tsx | 24 +- src/components/berths/berth-tabs.tsx | 149 +++-- .../clients/bulk-archive-wizard.tsx | 2 +- src/components/clients/client-card.tsx | 11 +- src/components/clients/client-columns.tsx | 14 +- .../clients/client-detail-header.tsx | 2 +- src/components/clients/client-filters.tsx | 2 +- src/components/clients/client-form.tsx | 192 +++--- src/components/clients/client-list.tsx | 22 +- src/components/clients/client-tabs.tsx | 11 +- .../clients/smart-archive-dialog.tsx | 6 +- src/components/companies/company-columns.tsx | 13 + src/components/companies/company-list.tsx | 33 +- src/components/companies/company-picker.tsx | 2 +- src/components/companies/company-tabs.tsx | 7 +- .../dashboard/active-deals-tile.tsx | 68 ++ src/components/dashboard/activity-feed.tsx | 154 ++++- .../dashboard/berth-status-chart.tsx | 101 +++ src/components/dashboard/chart-card.tsx | 35 +- .../dashboard/customize-widgets-menu.tsx | 129 ++++ src/components/dashboard/dashboard-shell.tsx | 152 +++-- .../dashboard/date-range-picker.tsx | 14 +- src/components/dashboard/hot-deals-card.tsx | 108 ++++ .../dashboard/lead-source-chart.tsx | 11 +- .../dashboard/my-reminders-rail.tsx | 2 +- .../dashboard/occupancy-timeline-chart.tsx | 3 +- .../dashboard/pipeline-funnel-chart.tsx | 3 +- .../dashboard/pipeline-value-tile.tsx | 54 ++ .../dashboard/revenue-breakdown-chart.tsx | 3 +- .../dashboard/source-conversion-chart.tsx | 91 +++ src/components/dashboard/widget-registry.tsx | 198 ++++++ .../documents/create-document-wizard.tsx | 4 +- src/components/documents/document-detail.tsx | 2 +- .../documents/document-template-picker.tsx | 2 +- src/components/documents/documents-hub.tsx | 53 +- .../documents/eoi-generate-dialog.tsx | 2 +- .../documents/folder-actions-menu.tsx | 157 ++--- .../documents/folder-tree-sidebar.tsx | 2 +- .../documents/new-document-menu.tsx | 2 +- .../expenses/trip-label-combobox.tsx | 2 +- src/components/inbox/inbox-page-shell.tsx | 153 +++++ .../interests/external-eoi-upload-dialog.tsx | 2 +- src/components/interests/interest-card.tsx | 16 +- .../interests/interest-contact-log-tab.tsx | 50 +- .../interests/interest-contract-tab.tsx | 4 +- .../interests/interest-detail-header.tsx | 1 + src/components/interests/interest-eoi-tab.tsx | 4 +- src/components/interests/interest-form.tsx | 315 +++++++-- src/components/interests/interest-list.tsx | 2 + .../interests/interest-outcome-dialog.tsx | 2 + src/components/interests/interest-picker.tsx | 2 +- .../interests/interest-reservation-tab.tsx | 4 +- src/components/interests/interest-tabs.tsx | 125 +++- .../interests/interest-timeline.tsx | 1 + .../interests/linked-berths-list.tsx | 134 ++-- .../layout/mobile/mobile-bottom-tabs.tsx | 140 ++-- .../layout/mobile/mobile-layout.tsx | 8 +- src/components/layout/mobile/more-sheet.tsx | 156 +++-- src/components/layout/sidebar.tsx | 54 +- src/components/layout/topbar.tsx | 27 +- .../notifications/reminder-digest-form.tsx | 5 + src/components/reminders/reminder-form.tsx | 61 +- src/components/reminders/reminder-list.tsx | 133 +++- .../reports/generate-report-form.tsx | 71 +- .../residential/residential-client-tabs.tsx | 11 +- .../residential/residential-interest-tabs.tsx | 9 +- src/components/search/command-search.tsx | 66 +- .../search/mobile-search-overlay.tsx | 607 ++++++++++++++++++ .../settings/dashboard-widgets-card.tsx | 77 +++ src/components/settings/user-settings.tsx | 5 +- src/components/shared/berth-picker.tsx | 187 ++++++ src/components/shared/client-picker.tsx | 17 +- src/components/shared/country-combobox.tsx | 6 +- src/components/shared/currency-input.tsx | 101 ++- src/components/shared/data-table.tsx | 35 +- src/components/shared/drawer.tsx | 20 +- .../shared/inline-editable-field.tsx | 64 +- src/components/shared/inline-phone-field.tsx | 2 +- src/components/shared/interest-picker.tsx | 143 +++++ src/components/shared/list-card.tsx | 6 +- src/components/shared/owner-picker.tsx | 31 +- src/components/shared/reminder-days-input.tsx | 104 +++ .../shared/subdivision-combobox.tsx | 2 +- src/components/shared/tag-picker.tsx | 9 +- src/components/shared/timezone-combobox.tsx | 7 +- src/components/ui/sheet.tsx | 5 +- src/components/ui/table.tsx | 6 +- src/components/yachts/yacht-list.tsx | 2 + src/components/yachts/yacht-picker.tsx | 2 +- src/hooks/use-create-from-url.ts | 27 + src/hooks/use-dashboard-integrations.ts | 45 ++ src/hooks/use-dashboard-widgets.ts | 143 +++++ src/hooks/use-search.ts | 16 + src/hooks/use-track-entity-view.ts | 19 +- src/lib/analytics/range.ts | 12 + src/lib/constants.ts | 62 +- src/lib/db/schema/users.ts | 7 + src/lib/services/berths.service.ts | 19 +- src/lib/services/companies.service.ts | 2 +- src/lib/services/dashboard.service.ts | 241 ++++++- src/lib/services/interests.service.ts | 15 +- src/lib/services/port-config.ts | 13 + src/lib/services/search.service.ts | 603 ++++++++++++++++- src/lib/utils/currency.ts | 27 +- src/lib/utils/mooring-sort.ts | 27 + src/lib/validators/companies.ts | 8 +- src/lib/validators/interests.ts | 9 + src/lib/validators/user-preferences.ts | 7 + src/lib/validators/yachts.ts | 21 +- tests/unit/services/companies.test.ts | 12 +- 132 files changed, 5784 insertions(+), 997 deletions(-) create mode 100644 src/app/(dashboard)/[portSlug]/inbox/page.tsx create mode 100644 src/app/api/v1/dashboard/berth-status/route.ts create mode 100644 src/app/api/v1/dashboard/hot-deals/route.ts create mode 100644 src/app/api/v1/dashboard/source-conversion/route.ts create mode 100644 src/components/dashboard/active-deals-tile.tsx create mode 100644 src/components/dashboard/berth-status-chart.tsx create mode 100644 src/components/dashboard/customize-widgets-menu.tsx create mode 100644 src/components/dashboard/hot-deals-card.tsx create mode 100644 src/components/dashboard/pipeline-value-tile.tsx create mode 100644 src/components/dashboard/source-conversion-chart.tsx create mode 100644 src/components/dashboard/widget-registry.tsx create mode 100644 src/components/inbox/inbox-page-shell.tsx create mode 100644 src/components/search/mobile-search-overlay.tsx create mode 100644 src/components/settings/dashboard-widgets-card.tsx create mode 100644 src/components/shared/berth-picker.tsx create mode 100644 src/components/shared/interest-picker.tsx create mode 100644 src/components/shared/reminder-days-input.tsx create mode 100644 src/hooks/use-create-from-url.ts create mode 100644 src/hooks/use-dashboard-integrations.ts create mode 100644 src/hooks/use-dashboard-widgets.ts create mode 100644 src/lib/utils/mooring-sort.ts diff --git a/.gitignore b/.gitignore index 703a41fe..15c7076d 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,8 @@ docker-compose.override.yml # Ad-hoc screenshots / scratch artifacts at repo root /*.png /*.jpg +# Local-only dashboard widget-combo screenshots — regenerated by manual testing +/combos/ # Legacy Nuxt portal — kept on disk for reference, not tracked here /client-portal/ diff --git a/next.config.ts b/next.config.ts index 9b1d3963..3a76e8bd 100644 --- a/next.config.ts +++ b/next.config.ts @@ -52,6 +52,17 @@ const securityHeaders = [ const nextConfig: NextConfig = { output: 'standalone', + // Hide the floating dev indicator (the little circle/N badge in the + // corner). Compile errors still surface via the full overlay; this + // only removes the idle "everything is fine" indicator that's been + // visible in every screenshot from the iPhone testing pass. + devIndicators: false, + // LAN access from a real iPhone hits the dev server via the Mac's + // local IP (e.g. 192.168.x.x), not localhost. Next 15 surfaces a + // warning for cross-origin /_next/* fetches unless we allow-list the + // origins explicitly. Wildcard the 192.168/0.0.0.0 ranges in dev so + // any LAN device works without a config edit per network. + ...(isProd ? {} : { allowedDevOrigins: ['192.168.1.42'] }), serverExternalPackages: [ 'pino', 'pino-pretty', diff --git a/package.json b/package.json index 835e821e..43298205 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "@tanstack/react-query-devtools": "^5.100.9", "@tanstack/react-table": "^8.21.3", "@types/pdfkit": "^0.17.6", - "archiver": "^8.0.0", + "archiver": "^7.0.1", "better-auth": "^1.6.9", "bullmq": "^5.76.6", "class-variance-authority": "^0.7.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2f5c81a..7911dcd7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -110,8 +110,8 @@ importers: specifier: ^0.17.6 version: 0.17.6 archiver: - specifier: ^8.0.0 - version: 8.0.0 + specifier: ^7.0.1 + version: 7.0.1 better-auth: specifier: ^1.6.9 version: 1.6.9(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(gel@2.2.0)(kysely@0.28.17)(postgres@3.4.9))(mongodb@7.1.0(socks@2.8.8))(next@15.5.18(@playwright/test@1.59.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.5) @@ -858,6 +858,10 @@ packages: '@ioredis/commands@1.5.1': resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==} + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1042,6 +1046,10 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@playwright/test@1.59.1': resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==} engines: {node: '>=18'} @@ -2121,6 +2129,10 @@ packages: resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} engines: {node: '>=18'} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + ansi-regex@6.2.2: resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} @@ -2140,9 +2152,13 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} - archiver@8.0.0: - resolution: {integrity: sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g==} - engines: {node: '>=18'} + archiver-utils@5.0.2: + resolution: {integrity: sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==} + engines: {node: '>= 14'} + + archiver@7.0.1: + resolution: {integrity: sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==} + engines: {node: '>= 14'} arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} @@ -2390,6 +2406,9 @@ packages: brace-expansion@1.1.14: resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} + brace-expansion@2.1.0: + resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} + brace-expansion@5.0.6: resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} @@ -2543,9 +2562,9 @@ packages: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} - compress-commons@7.0.1: - resolution: {integrity: sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ==} - engines: {node: '>=18'} + compress-commons@6.0.2: + resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==} + engines: {node: '>= 14'} concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -2569,9 +2588,9 @@ packages: engines: {node: '>=0.8'} hasBin: true - crc32-stream@7.0.1: - resolution: {integrity: sha512-IBWsY8xznyQrcHn8h4bC8/4ErNke5elzgG8GcqF4RFPw6aHkWWRc7Tgw6upjaTX/CT/yQgqYENkxYsTYN+hW2g==} - engines: {node: '>=18'} + crc32-stream@6.0.0: + resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==} + engines: {node: '>= 14'} cron-parser@4.9.0: resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} @@ -2852,12 +2871,18 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + electron-to-chromium@1.5.352: resolution: {integrity: sha512-9wHk8x6dyuimoe18EdiDPWKExNdxYqo4fn4FwOVVper6RxT3cmpBwBkWWfSOCYJjQdIco/nPhJhNLmn4Ufg1Yg==} emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} @@ -3164,6 +3189,10 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} @@ -3230,6 +3259,11 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -3242,6 +3276,9 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -3403,6 +3440,10 @@ packages: resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} engines: {node: '>= 0.4'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-fullwidth-code-point@5.1.0: resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} engines: {node: '>=18'} @@ -3447,9 +3488,9 @@ packages: resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} engines: {node: '>= 0.4'} - is-stream@4.0.1: - resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} - engines: {node: '>=18'} + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} is-string@1.1.1: resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} @@ -3514,6 +3555,9 @@ packages: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jiti@1.21.7: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true @@ -3726,6 +3770,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lucide-react@1.14.0: resolution: {integrity: sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==} peerDependencies: @@ -3785,6 +3832,14 @@ packages: minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} @@ -3792,6 +3847,10 @@ packages: resolution: {integrity: sha512-E737MgufW8CeQAsTAtnEMrxZ9scMSf29kkhZoXzDTKj/Jszzo2SfeZUH9wbDQH2Rsq6TCtl/yQL0+XdVKZansQ==} engines: {node: ^16 || ^18 || >=20} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + mongodb-connection-string-url@7.0.1: resolution: {integrity: sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==} engines: {node: '>=20.19.0'} @@ -4014,6 +4073,9 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} @@ -4048,6 +4110,10 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -4306,9 +4372,8 @@ packages: resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - readdir-glob@3.0.0: - resolution: {integrity: sha512-AhNB2KgKeVJr16nK9LLZbJNWnYoT23ZrumNKFDebHBdkC8KHSqWo871JAUhoWC/RtjEVdqNMFpM6qrwRbaUqpw==} - engines: {node: '>=18'} + readdir-glob@1.1.3: + resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} @@ -4608,6 +4673,14 @@ packages: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} engines: {node: '>=0.6.19'} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + string-width@7.2.0: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} @@ -4645,6 +4718,10 @@ packages: string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + strip-ansi@7.2.0: resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} @@ -5032,6 +5109,14 @@ packages: resolution: {integrity: sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==} engines: {node: '>=20'} + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + wrap-ansi@9.0.2: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} @@ -5080,9 +5165,9 @@ packages: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} - zip-stream@7.0.5: - resolution: {integrity: sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w==} - engines: {node: '>=18'} + zip-stream@6.0.1: + resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} + engines: {node: '>= 14'} zlibjs@0.3.1: resolution: {integrity: sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==} @@ -5506,6 +5591,15 @@ snapshots: '@ioredis/commands@1.5.1': {} + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -5662,6 +5756,9 @@ snapshots: '@pinojs/redact@0.4.0': {} + '@pkgjs/parseargs@0.11.0': + optional: true + '@playwright/test@1.59.1': dependencies: playwright: 1.59.1 @@ -6740,6 +6837,8 @@ snapshots: dependencies: environment: 1.1.0 + ansi-regex@5.0.1: {} + ansi-regex@6.2.2: {} ansi-styles@4.3.0: @@ -6755,17 +6854,25 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.2 - archiver@8.0.0: + archiver-utils@5.0.2: dependencies: - async: 3.2.6 - buffer-crc32: 1.0.0 - is-stream: 4.0.1 + glob: 10.5.0 + graceful-fs: 4.2.11 + is-stream: 2.0.1 lazystream: 1.0.1 + lodash: 4.18.1 normalize-path: 3.0.0 readable-stream: 4.7.0 - readdir-glob: 3.0.0 + + archiver@7.0.1: + dependencies: + archiver-utils: 5.0.2 + async: 3.2.6 + buffer-crc32: 1.0.0 + readable-stream: 4.7.0 + readdir-glob: 1.1.3 tar-stream: 3.2.0 - zip-stream: 7.0.5 + zip-stream: 6.0.1 transitivePeerDependencies: - bare-abort-controller - bare-buffer @@ -6984,6 +7091,10 @@ snapshots: balanced-match: 1.0.2 concat-map: 0.0.1 + brace-expansion@2.1.0: + dependencies: + balanced-match: 1.0.2 + brace-expansion@5.0.6: dependencies: balanced-match: 4.0.4 @@ -7141,11 +7252,11 @@ snapshots: commander@4.1.1: {} - compress-commons@7.0.1: + compress-commons@6.0.2: dependencies: crc-32: 1.2.2 - crc32-stream: 7.0.1 - is-stream: 4.0.1 + crc32-stream: 6.0.0 + is-stream: 2.0.1 normalize-path: 3.0.0 readable-stream: 4.7.0 @@ -7164,7 +7275,7 @@ snapshots: crc-32@1.2.2: {} - crc32-stream@7.0.1: + crc32-stream@6.0.0: dependencies: crc-32: 1.2.2 readable-stream: 4.7.0 @@ -7340,10 +7451,14 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + eastasianwidth@0.2.0: {} + electron-to-chromium@1.5.352: {} emoji-regex@10.6.0: {} + emoji-regex@8.0.0: {} + emoji-regex@9.2.2: {} encoding-japanese@2.2.0: {} @@ -7834,6 +7949,11 @@ snapshots: dependencies: is-callable: 1.2.7 + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + fraction.js@5.3.4: {} fsevents@2.3.2: @@ -7911,6 +8031,15 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + globals@14.0.0: {} globalthis@1.0.4: @@ -7920,6 +8049,8 @@ snapshots: gopd@1.2.0: {} + graceful-fs@4.2.11: {} + has-bigints@1.1.0: {} has-flag@4.0.0: {} @@ -8088,6 +8219,8 @@ snapshots: dependencies: call-bound: 1.0.4 + is-fullwidth-code-point@3.0.0: {} + is-fullwidth-code-point@5.1.0: dependencies: get-east-asian-width: 1.6.0 @@ -8130,7 +8263,7 @@ snapshots: dependencies: call-bound: 1.0.4 - is-stream@4.0.1: {} + is-stream@2.0.1: {} is-string@1.1.1: dependencies: @@ -8195,6 +8328,12 @@ snapshots: has-symbols: 1.1.0 set-function-name: 2.0.2 + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + jiti@1.21.7: {} jose@6.2.3: {} @@ -8383,6 +8522,8 @@ snapshots: dependencies: js-tokens: 4.0.0 + lru-cache@10.4.3: {} + lucide-react@1.14.0(react@19.2.6): dependencies: react: 19.2.6 @@ -8446,6 +8587,14 @@ snapshots: dependencies: brace-expansion: 1.1.14 + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.0 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.0 + minimist@1.2.8: {} minio@8.0.7: @@ -8464,6 +8613,8 @@ snapshots: through2: 4.0.2 xml2js: 0.6.2 + minipass@7.1.3: {} + mongodb-connection-string-url@7.0.1: dependencies: '@types/whatwg-url': 13.0.0 @@ -8673,6 +8824,8 @@ snapshots: dependencies: p-limit: 3.1.0 + package-json-from-dist@1.0.1: {} + package-manager-detector@1.6.0: {} pako@0.2.9: {} @@ -8698,6 +8851,11 @@ snapshots: path-parse@1.0.7: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + pathe@2.0.3: {} pdf-lib@1.17.1: @@ -8961,9 +9119,9 @@ snapshots: process: 0.11.10 string_decoder: 1.3.0 - readdir-glob@3.0.0: + readdir-glob@1.1.3: dependencies: - minimatch: 10.2.5 + minimatch: 5.1.9 readdirp@3.6.0: dependencies: @@ -9350,6 +9508,18 @@ snapshots: string-argv@0.3.2: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + string-width@7.2.0: dependencies: emoji-regex: 10.6.0 @@ -9419,6 +9589,10 @@ snapshots: dependencies: safe-buffer: 5.2.1 + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + strip-ansi@7.2.0: dependencies: ansi-regex: 6.2.2 @@ -9868,6 +10042,18 @@ snapshots: string-width: 8.2.1 strip-ansi: 7.2.0 + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + wrap-ansi@9.0.2: dependencies: ansi-styles: 6.2.3 @@ -9896,10 +10082,10 @@ snapshots: yoctocolors@2.1.2: {} - zip-stream@7.0.5: + zip-stream@6.0.1: dependencies: - compress-commons: 7.0.1 - normalize-path: 3.0.0 + archiver-utils: 5.0.2 + compress-commons: 6.0.2 readable-stream: 4.7.0 zlibjs@0.3.1: {} diff --git a/src/app/(dashboard)/[portSlug]/alerts/page.tsx b/src/app/(dashboard)/[portSlug]/alerts/page.tsx index cc069a24..71b033e0 100644 --- a/src/app/(dashboard)/[portSlug]/alerts/page.tsx +++ b/src/app/(dashboard)/[portSlug]/alerts/page.tsx @@ -1,5 +1,13 @@ -import { AlertsPageShell } from '@/components/alerts/alerts-page-shell'; +import { redirect } from 'next/navigation'; -export default function AlertsPage() { - return ; +// 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({ + params, +}: { + params: Promise<{ portSlug: string }>; +}) { + const { portSlug } = await params; + redirect(`/${portSlug}/inbox#alerts`); } diff --git a/src/app/(dashboard)/[portSlug]/expenses/page.tsx b/src/app/(dashboard)/[portSlug]/expenses/page.tsx index 3058091d..203dc19d 100644 --- a/src/app/(dashboard)/[portSlug]/expenses/page.tsx +++ b/src/app/(dashboard)/[portSlug]/expenses/page.tsx @@ -23,6 +23,7 @@ import { ExpenseFormDialog } from '@/components/expenses/expense-form-dialog'; import { ExpenseCard } from '@/components/expenses/expense-card'; import { expenseFilterDefinitions } from '@/components/expenses/expense-filters'; import { getExpenseColumns, type ExpenseRow } from '@/components/expenses/expense-columns'; +import { useCreateFromUrl } from '@/hooks/use-create-from-url'; import { usePaginatedQuery } from '@/hooks/use-paginated-query'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { apiFetch } from '@/lib/api/client'; @@ -33,6 +34,7 @@ export default function ExpensesPage() { const queryClient = useQueryClient(); const [createOpen, setCreateOpen] = useState(false); + useCreateFromUrl(() => setCreateOpen(true)); const [editExpense, setEditExpense] = useState(null); const [archiveExpense, setArchiveExpense] = useState(null); diff --git a/src/app/(dashboard)/[portSlug]/inbox/page.tsx b/src/app/(dashboard)/[portSlug]/inbox/page.tsx new file mode 100644 index 00000000..b0bdfb37 --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/inbox/page.tsx @@ -0,0 +1,5 @@ +import { InboxPageShell } from '@/components/inbox/inbox-page-shell'; + +export default function InboxPage() { + return ; +} diff --git a/src/app/(dashboard)/[portSlug]/reminders/page.tsx b/src/app/(dashboard)/[portSlug]/reminders/page.tsx index ad93699f..977e5e53 100644 --- a/src/app/(dashboard)/[portSlug]/reminders/page.tsx +++ b/src/app/(dashboard)/[portSlug]/reminders/page.tsx @@ -1,5 +1,12 @@ -import { ReminderList } from '@/components/reminders/reminder-list'; +import { redirect } from 'next/navigation'; -export default function RemindersPage() { - return ; +// 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, +}: { + params: Promise<{ portSlug: string }>; +}) { + const { portSlug } = await params; + redirect(`/${portSlug}/inbox#reminders`); } diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index 4954859a..aa2b8b26 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -59,7 +59,7 @@ export default async function DashboardLayout({ children }: { children: React.Re email: session.user.email, }} /> -
+
{children}
diff --git a/src/app/api/v1/dashboard/berth-status/route.ts b/src/app/api/v1/dashboard/berth-status/route.ts new file mode 100644 index 00000000..47822246 --- /dev/null +++ b/src/app/api/v1/dashboard/berth-status/route.ts @@ -0,0 +1,11 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { getBerthStatusDistribution } from '@/lib/services/dashboard.service'; + +export const GET = withAuth( + withPermission('reports', 'view_dashboard', async (_req: NextRequest, ctx) => { + const result = await getBerthStatusDistribution(ctx.portId); + return NextResponse.json({ data: result }); + }), +); diff --git a/src/app/api/v1/dashboard/hot-deals/route.ts b/src/app/api/v1/dashboard/hot-deals/route.ts new file mode 100644 index 00000000..20fe6764 --- /dev/null +++ b/src/app/api/v1/dashboard/hot-deals/route.ts @@ -0,0 +1,11 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { getHotDeals } from '@/lib/services/dashboard.service'; + +export const GET = withAuth( + withPermission('reports', 'view_dashboard', async (_req: NextRequest, ctx) => { + const result = await getHotDeals(ctx.portId); + return NextResponse.json({ data: result }); + }), +); diff --git a/src/app/api/v1/dashboard/source-conversion/route.ts b/src/app/api/v1/dashboard/source-conversion/route.ts new file mode 100644 index 00000000..84395e38 --- /dev/null +++ b/src/app/api/v1/dashboard/source-conversion/route.ts @@ -0,0 +1,11 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { getSourceConversion } from '@/lib/services/dashboard.service'; + +export const GET = withAuth( + withPermission('reports', 'view_dashboard', async (_req: NextRequest, ctx) => { + const result = await getSourceConversion(ctx.portId); + return NextResponse.json({ data: result }); + }), +); diff --git a/src/app/api/v1/users/me/preferences/route.ts b/src/app/api/v1/users/me/preferences/route.ts index 8adaa02d..cb0ccad8 100644 --- a/src/app/api/v1/users/me/preferences/route.ts +++ b/src/app/api/v1/users/me/preferences/route.ts @@ -6,14 +6,21 @@ import { parseBody } from '@/lib/api/route-helpers'; import { db } from '@/lib/db'; import { userProfiles, type UserPreferences } from '@/lib/db/schema/users'; import { errorResponse, NotFoundError } from '@/lib/errors'; +import { getPortReminderConfig } from '@/lib/services/port-config'; import { updateUserPreferencesSchema } from '@/lib/validators/user-preferences'; export const GET = withAuth(async (_req, ctx) => { try { - const profile = await db.query.userProfiles.findFirst({ - where: eq(userProfiles.userId, ctx.userId), + const [profile, portReminders] = await Promise.all([ + db.query.userProfiles.findFirst({ where: eq(userProfiles.userId, ctx.userId) }), + ctx.portId ? getPortReminderConfig(ctx.portId) : Promise.resolve(null), + ]); + return NextResponse.json({ + data: { + ...(profile?.preferences ?? {}), + portReminderDigestEnabled: portReminders?.digestEnabled ?? false, + }, }); - return NextResponse.json({ data: profile?.preferences ?? {} }); } catch (error) { return errorResponse(error); } diff --git a/src/app/globals.css b/src/app/globals.css index 27c3064b..cbf3b3cf 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -17,8 +17,8 @@ --secondary-foreground: 0 0% 100%; --muted: 210 11% 96%; /* #f1f3f5 */ --muted-foreground: 228 10% 49%; /* #71768a */ - --accent: 190 18% 60%; /* #83aab1 */ - --accent-foreground: 0 0% 100%; + --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%; --border: 227 10% 82%; /* #cdcfd6 */ @@ -58,8 +58,8 @@ --secondary-foreground: 227 10% 82%; --muted: 224 39% 18%; --muted-foreground: 228 10% 49%; - --accent: 190 18% 50%; - --accent-foreground: 0 0% 100%; + --accent: 224 39% 24%; /* subtle elevation above card for hover/focus */ + --accent-foreground: 227 10% 91%; /* light text on dark accent */ --destructive: 0 72% 63%; --destructive-foreground: 0 0% 100%; --border: 224 35% 28%; @@ -91,6 +91,11 @@ body { @apply bg-background text-foreground font-sans antialiased; + /* Suppress iOS Safari's default black tap-highlight overlay so our + * explicit `active:bg-accent` styles are the only press effect. + * Without this, every tap on a mobile button/link flashes a muddy + * dark rectangle on top of whatever active style we set. */ + -webkit-tap-highlight-color: transparent; } /* Wave watermark - subtle background texture for auth pages */ @@ -208,3 +213,59 @@ div.recharts-responsive-container:focus-visible, --tw-ring-color: transparent !important; --tw-ring-offset-color: transparent !important; } + +/* + * 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 + * scale. We slow it down and use a softer easing curve (ease-out-quint) + * which decelerates smoothly without the elastic kick. + * + * Scoped to mobile drawers via the data-vaul-drawer-direction attr so + * the smaller MoreSheet drawer keeps its punchier default. + * + * The overlay's opacity transition is matched to the same duration so + * the backdrop and drawer stay in sync. + */ +[data-vaul-drawer][data-vaul-drawer-direction='bottom'] { + transition: transform 480ms cubic-bezier(0.22, 1, 0.36, 1) !important; +} +[data-vaul-drawer][data-vaul-drawer-direction='bottom'][data-state='closed'] { + transition: transform 380ms cubic-bezier(0.4, 0, 0.2, 1) !important; +} +[data-vaul-overlay] { + transition: opacity 480ms cubic-bezier(0.22, 1, 0.36, 1) !important; +} +[data-vaul-overlay][data-state='closed'] { + transition: opacity 380ms cubic-bezier(0.4, 0, 0.2, 1) !important; +} + +/* + * GPU compositing hints for Vaul drawers. + * + * `will-change: transform` tells the browser to promote the drawer to + * its own composite layer ahead of the animation, so the swipe-drag + * transforms run on the GPU instead of triggering re-paints. Without + * this, Safari sometimes defers layer creation until the first frame + * of the drag, producing the visible "jump" reps were seeing when + * flicking the drawer down to close. + * + * `backface-visibility: hidden` keeps the layer flat and prevents + * 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. + * focus-state changes on a button) don't invalidate the parent. + */ +[data-vaul-drawer] { + will-change: transform; + backface-visibility: hidden; + contain: layout style paint; + -webkit-transform: translate3d(0, 0, 0); + transform: translate3d(0, 0, 0); +} +[data-vaul-overlay] { + will-change: opacity; + backface-visibility: hidden; +} diff --git a/src/components/admin/settings/settings-manager.tsx b/src/components/admin/settings/settings-manager.tsx index 39f9e015..d6c76fd8 100644 --- a/src/components/admin/settings/settings-manager.tsx +++ b/src/components/admin/settings/settings-manager.tsx @@ -20,6 +20,7 @@ import { } from '@/components/ui/select'; import { Separator } from '@/components/ui/separator'; import { apiFetch } from '@/lib/api/client'; +import { SUPPORTED_CURRENCIES } from '@/lib/utils/currency'; interface Setting { key: string; @@ -218,6 +219,15 @@ const KNOWN_SETTINGS: Array<{ type: 'boolean', defaultValue: true, }, + { + key: 'berths_default_currency', + label: 'Berths — default currency', + description: + 'Currency applied to newly-created berths when none is specified on the form. Existing berths keep their per-row currency. Defaults to USD.', + type: 'select', + defaultValue: 'USD', + options: SUPPORTED_CURRENCIES.map((c) => ({ value: c.code, label: `${c.code} — ${c.label}` })), + }, ]; export function SettingsManager() { diff --git a/src/components/alerts/alert-rail.tsx b/src/components/alerts/alert-rail.tsx index 341dd1ed..3286d541 100644 --- a/src/components/alerts/alert-rail.tsx +++ b/src/components/alerts/alert-rail.tsx @@ -30,7 +30,7 @@ export function AlertRail() {

Alerts

View all @@ -53,7 +53,7 @@ export function AlertRail() { ))} {overflow > 0 ? ( +{overflow} more - view all diff --git a/src/components/alerts/alerts-page-shell.tsx b/src/components/alerts/alerts-page-shell.tsx index f0eb2efd..8bb28d24 100644 --- a/src/components/alerts/alerts-page-shell.tsx +++ b/src/components/alerts/alerts-page-shell.tsx @@ -10,7 +10,19 @@ import { AlertCard, AlertCardEmpty } from './alert-card'; import { useAlertCount, useAlertList, useAlertRealtime } from './use-alerts'; import type { AlertStatus } from './types'; -export function AlertsPageShell() { +/** + * `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 + * to /inbox#alerts, so non-embedded mode is currently unused but kept + * for flexibility. + */ +interface AlertsPageShellProps { + embedded?: boolean; +} + +export function AlertsPageShell({ embedded = false }: AlertsPageShellProps = {}) { const [tab, setTab] = useState('open'); const { data: count } = useAlertCount(); const { data, isLoading } = useAlertList(tab); @@ -20,19 +32,21 @@ export function AlertsPageShell() { const alerts = data?.data ?? []; return ( -
- - - {total} active - - } - variant="gradient" - /> +
+ {!embedded ? ( + + + {total} active + + } + variant="gradient" + /> + ) : null} setTab(v as AlertStatus)}> diff --git a/src/components/berths/berth-card.tsx b/src/components/berths/berth-card.tsx index 997ca6a9..61d5b935 100644 --- a/src/components/berths/berth-card.tsx +++ b/src/components/berths/berth-card.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Activity, Anchor, MapPin, MoreHorizontal, Pencil } from 'lucide-react'; +import { Activity, MoreHorizontal, Pencil } from 'lucide-react'; import { useRouter, useParams } from 'next/navigation'; import { Button } from '@/components/ui/button'; @@ -45,18 +45,51 @@ 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 - let dimText: string | null = null; - if (berth.lengthM || berth.widthM) { - const l = berth.lengthM ?? '?'; - const w = berth.widthM ?? '?'; - dimText = `${l}m × ${w}m`; + // 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[] = []; + if (berth.lengthM) dimParts.push(`${berth.lengthM}m`); + if (berth.widthM) dimParts.push(`${berth.widthM}m`); + 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 + // ("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. + let boatCapacityText: string | null = null; + if (berth.nominalBoatSizeM) { + boatCapacityText = `Fits up to ${berth.nominalBoatSizeM}m`; + } else if (berth.nominalBoatSize) { + boatCapacityText = `Fits up to ${berth.nominalBoatSize}ft`; } + // Water depth — operational; matters for deep-keel yachts. + let waterDepthText: string | null = null; + if (berth.waterDepthM) { + const prefix = berth.waterDepthIsMinimum ? '≥ ' : ''; + waterDepthText = `${prefix}${berth.waterDepthM}m deep`; + } + + // Power label: combine capacity + voltage when both present. + let powerText: string | null = null; + if (berth.powerCapacity && berth.voltage) { + powerText = `${berth.powerCapacity}A / ${berth.voltage}V`; + } else if (berth.powerCapacity) { + powerText = `${berth.powerCapacity}A`; + } else if (berth.voltage) { + powerText = `${berth.voltage}V`; + } + + // Secondary meta: boat-capacity · water-depth · price · power. All + // optional; order favours the highest-utility scan signals first. const metaParts: string[] = []; - if (dimText) metaParts.push(dimText); + if (boatCapacityText) metaParts.push(boatCapacityText); + if (waterDepthText) metaParts.push(waterDepthText); if (berth.price) metaParts.push(formatCurrency(berth.price, berth.priceCurrency, { maxFractionDigits: 0 })); + if (powerText) metaParts.push(powerText); const tags = berth.tags ?? []; @@ -101,26 +134,27 @@ export function BerthCard({ berth }: BerthCardProps) { } > -
- } /> +
+ {/* 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. */} +
- {/* Title row + spacer for actions button */} -
-

- {berth.mooringNumber} -

+ {/* Primary line: dimensions (L × W × Draft). The avatar + already carries the area letter, so this slot becomes the + "what fits here" answer. Falls back gracefully when + dimensions aren't recorded yet. */} +
+

+ {dimText ?? No dimensions} +

- {/* Area subtitle */} - {berth.area ? ( -

- - {berth.area} -

- ) : null} - - {/* Dimensions · Price meta line */} + {/* Meta line: tenure · price · power. All optional. */} {metaParts.length > 0 ? (
{metaParts.map((part, i) => ( @@ -132,8 +166,8 @@ export function BerthCard({ berth }: BerthCardProps) {
) : null} - {/* Status pill */} -
+ {/* Status pill + tags */} +
{statusLabel} + {tags.slice(0, 2).map((tag) => ( + + ))} + {tags.length > 2 ? ( + + +{tags.length - 2} + + ) : null}
- - {/* Tags */} - {tags.length > 0 ? ( -
- {tags.slice(0, 2).map((tag) => ( - - ))} - {tags.length > 2 ? ( - - +{tags.length - 2} - - ) : null} -
- ) : null}
diff --git a/src/components/berths/berth-detail-header.tsx b/src/components/berths/berth-detail-header.tsx index 5497074c..bc8a50c1 100644 --- a/src/components/berths/berth-detail-header.tsx +++ b/src/components/berths/berth-detail-header.tsx @@ -222,8 +222,9 @@ function StatusChangeDialog({

- Picking an interest auto-creates a primary berth link if one doesn't already - exist, so the deal timeline + heat scorer attribute the change correctly. + Link this status change to the prospect (interest) it relates to. The change will + appear on that interest's timeline, and the berth gets attached to the prospect + automatically if it wasn't already.

)} diff --git a/src/components/berths/berth-detail.tsx b/src/components/berths/berth-detail.tsx index 686039c9..6f691332 100644 --- a/src/components/berths/berth-detail.tsx +++ b/src/components/berths/berth-detail.tsx @@ -31,7 +31,7 @@ export function BerthDetail({ berthId }: BerthDetailProps) { }); const { setChrome } = useMobileChrome(); - const titleForChrome: string | null = data?.mooringNumber ?? null; + const titleForChrome: string | null = data?.mooringNumber ? `Berth ${data.mooringNumber}` : null; useEffect(() => { setChrome({ title: titleForChrome, showBackButton: true }); return () => setChrome({ title: null, showBackButton: false }); diff --git a/src/components/berths/berth-form.tsx b/src/components/berths/berth-form.tsx index 1fe5944d..d9e4ad80 100644 --- a/src/components/berths/berth-form.tsx +++ b/src/components/berths/berth-form.tsx @@ -192,7 +192,7 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) { return ( - + Edit Berth {berth.mooringNumber} diff --git a/src/components/berths/berth-interests-tab.tsx b/src/components/berths/berth-interests-tab.tsx index d88677f2..06fcde97 100644 --- a/src/components/berths/berth-interests-tab.tsx +++ b/src/components/berths/berth-interests-tab.tsx @@ -19,7 +19,7 @@ import { import { TableSkeleton } from '@/components/shared/loading-skeleton'; import { EmptyState } from '@/components/shared/empty-state'; import { Bookmark } from 'lucide-react'; -import { PIPELINE_STAGES, stageLabel } from '@/lib/constants'; +import { PIPELINE_STAGES, stageLabel, formatSource } from '@/lib/constants'; import type { InterestRow } from '@/components/interests/interest-columns'; interface BerthInterestsTabProps { @@ -46,13 +46,6 @@ const CATEGORY_LABELS: Record = { general_interest: 'General Interest', }; -const SOURCE_LABELS: Record = { - website: 'Website', - manual: 'Manual', - referral: 'Referral', - broker: 'Broker', -}; - interface ListResponse { data: InterestRow[]; total: number; @@ -179,9 +172,7 @@ export function BerthInterestsTab({ berthId }: BerthInterestsTabProps) { {i.leadCategory ? (CATEGORY_LABELS[i.leadCategory] ?? i.leadCategory) : '-'} - - {i.source ? (SOURCE_LABELS[i.source] ?? i.source) : '-'} - + {formatSource(i.source) ?? '-'} {new Date(i.createdAt).toLocaleDateString()} diff --git a/src/components/berths/berth-list.tsx b/src/components/berths/berth-list.tsx index c1d2c55c..019d314c 100644 --- a/src/components/berths/berth-list.tsx +++ b/src/components/berths/berth-list.tsx @@ -8,6 +8,7 @@ import { FilterBar } from '@/components/shared/filter-bar'; import { PageHeader } from '@/components/shared/page-header'; import { SavedViewsDropdown } from '@/components/shared/saved-views-dropdown'; import { ColumnPicker } from '@/components/shared/column-picker'; +import { Input } from '@/components/ui/input'; import { EmptyState } from '@/components/shared/empty-state'; import { usePaginatedQuery } from '@/hooks/use-paginated-query'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; @@ -63,11 +64,27 @@ export function BerthList() {
d.key !== 'search')} values={filters} onChange={setFilter} onClear={clearFilters} /> + setFilter('search', e.target.value || undefined)} + // flex-1 + min-w-0 lets the input expand to fill the row's + // remaining width on mobile (where space is at a premium). + // sm:max-w-xs caps it at 320px on desktop so it doesn't grow + // absurdly wide on a 2k monitor. + className="h-8 min-w-0 flex-1 sm:max-w-xs" + />
router.push(`/${params.portSlug}/berths/${row.id}`)} getRowClassName={(row) => mooringLetterTone(row.mooringNumber)} cardRender={(row) => } + // 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. + mobileGroupBy={(row) => row.area ?? 'Unassigned'} emptyState={ ; }; +/** + * Compact ft/m segmented control for the Specifications card. Two + * tappable pills with `min-h-[36px]` for an Apple-HIG-friendly touch + * target. The active option gets the brand primary background; the + * other reads as muted. + */ +function UnitToggle({ value, onChange }: { value: 'ft' | 'm'; onChange: (v: 'ft' | 'm') => void }) { + return ( +
+ {(['ft', 'm'] as const).map((opt) => ( + + ))} +
+ ); +} + function SpecRow({ label, value }: { label: string; value: React.ReactNode }) { if (!value && value !== 0 && value !== false) return null; // Mobile-first: stack vertically with label on top so long values @@ -104,6 +142,7 @@ function useBerthPatch(berthId: string) { function EditableSpec({ label, value, + displayValue, field, patch, numeric = false, @@ -113,6 +152,9 @@ function EditableSpec({ }: { label: string; value: string | null; + /** Optional formatted version for display only (currency, percent, + * unit-suffixed). The edit input still works against the raw `value`. */ + displayValue?: string | null; field: string; patch: ReturnType; numeric?: boolean; @@ -142,6 +184,7 @@ function EditableSpec({ ) : ( { if (numeric) { if (next === null || next.trim() === '') { @@ -170,30 +213,33 @@ function EditableSpec({ ); } +// Conversion factors between feet and meters. 0.3048 is the exact +// definition (1 ft = 0.3048 m by international agreement). +const FT_TO_M = 0.3048; +const M_TO_FT = 1 / FT_TO_M; + function OverviewTab({ berth }: { berth: BerthData }) { const patch = useBerthPatch(berth.id); - // Round to at most 2 decimals; trim trailing zeros so "5.00" -> "5". - const fmt = (v: string | null, fractionDigits = 2): string | null => { - if (v == null || v === '') return null; - const n = Number(v); - if (Number.isNaN(n)) return v; - return n.toLocaleString('en-US', { - minimumFractionDigits: 0, - maximumFractionDigits: fractionDigits, - }); - }; + // User-selected display unit for dimensions. Persisted in localStorage + // so reps' preferred unit sticks across navigations + sessions. + const [units, setUnits] = useState<'ft' | 'm'>('ft'); + useEffect(() => { + const stored = localStorage.getItem('berth-overview-units'); + if (stored === 'ft' || stored === 'm') setUnits(stored); + }, []); + useEffect(() => { + localStorage.setItem('berth-overview-units', units); + }, [units]); - // Read-only display helper for the metric column on dimensions — - // mirrors the pre-edit "X ft / Y m" rendering for fields where only - // the foot value is editable today. - const formatNominalBoatSize = (ft: string | null, m: string | null): string | null => { - const ftFmt = fmt(ft, 0); - const mFmt = fmt(m); - const parts: string[] = []; - if (ftFmt) parts.push(`${ftFmt} ft`); - if (mFmt) parts.push(`${mFmt} m`); - return parts.length > 0 ? parts.join(' / ') : null; - }; + const u = units; + // For each dimension, pick the column matching the selected unit and + // point linkedUnit at the opposite column so edits keep both in sync. + const dim = (ftField: string, mField: string) => + units === 'ft' + ? { field: ftField, linkedUnit: { field: mField, multiplier: FT_TO_M } } + : { field: mField, linkedUnit: { field: ftField, multiplier: M_TO_FT } }; + const dimValue = (ftValue: string | null, mValue: string | null) => + units === 'ft' ? ftValue : mValue; return (
@@ -204,62 +250,50 @@ function OverviewTab({ berth }: { berth: BerthData }) {
{/* Specifications */} - + Specifications + - Low-stakes defaults: release available/under-offer berths, keep sold ones, cancel - reservations, leave invoices/Documenso envelopes alone. Yachts stay on the + reservations, leave invoices/signing envelopes alone. Yachts stay on the archived client. To customise per-client, archive that client individually instead.
diff --git a/src/components/clients/client-card.tsx b/src/components/clients/client-card.tsx index 8e26bb9c..1b0cb3d8 100644 --- a/src/components/clients/client-card.tsx +++ b/src/components/clients/client-card.tsx @@ -17,16 +17,9 @@ import { deriveInitials, } from '@/components/shared/list-card'; import { getCountryName } from '@/lib/i18n/countries'; -import { stageBadgeClass, stageLabel } from '@/lib/constants'; +import { stageBadgeClass, stageLabel, formatSource } from '@/lib/constants'; import type { ClientRow } from './client-columns'; -const SOURCE_LABELS: Record = { - website: 'Website', - manual: 'Manual', - referral: 'Referral', - broker: 'Broker', -}; - interface ClientCardProps { client: ClientRow; portSlug: string; @@ -38,7 +31,7 @@ export function ClientCard({ client, portSlug, onEdit, onArchive }: ClientCardPr // Card display: prefer email, fall back to phone. const primaryContactValue = client.primaryEmail ?? client.primaryPhone ?? null; const nationality = client.nationalityIso ? getCountryName(client.nationalityIso, 'en') : null; - const sourceLabel = client.source ? (SOURCE_LABELS[client.source] ?? client.source) : null; + const sourceLabel = formatSource(client.source); const tags = client.tags ?? []; const meta = [nationality, sourceLabel].filter(Boolean) as string[]; diff --git a/src/components/clients/client-columns.tsx b/src/components/clients/client-columns.tsx index 120b5a17..7a224660 100644 --- a/src/components/clients/client-columns.tsx +++ b/src/components/clients/client-columns.tsx @@ -15,7 +15,7 @@ import { import { Badge } from '@/components/ui/badge'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { getCountryName } from '@/lib/i18n/countries'; -import { stageDotClass, stageLabel } from '@/lib/constants'; +import { stageDotClass, stageLabel, formatSource } from '@/lib/constants'; import { cn } from '@/lib/utils'; import type { ColumnPickerOption } from '@/components/shared/column-picker'; @@ -81,13 +81,6 @@ export const CLIENT_COLUMN_OPTIONS: ColumnPickerOption[] = [ */ export const CLIENT_DEFAULT_HIDDEN: string[] = ['latestStage']; -const SOURCE_LABELS: Record = { - website: 'Website', - manual: 'Manual', - referral: 'Referral', - broker: 'Broker', -}; - interface GetColumnsOptions { portSlug: string; onEdit: (client: ClientRow) => void; @@ -191,10 +184,11 @@ export function getClientColumns({ header: 'Source', cell: ({ getValue }) => { const source = getValue() as string | null; - if (!source) return -; + const label = formatSource(source); + if (!label) return -; return ( - {SOURCE_LABELS[source] ?? source} + {label} ); }, diff --git a/src/components/clients/client-detail-header.tsx b/src/components/clients/client-detail-header.tsx index 76bb36c6..b910b770 100644 --- a/src/components/clients/client-detail-header.tsx +++ b/src/components/clients/client-detail-header.tsx @@ -131,7 +131,7 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) { ) : null} - {!isArchived && client.clientPortalEnabled !== false ? ( + {!isArchived && client.clientPortalEnabled === true ? (
-
-
+
+
{errors.fullName && ( @@ -198,7 +199,7 @@ export function ClientForm({ open, onOpenChange, client, onUseExistingClient }:
- + setValue('nationalityIso', iso ?? undefined)} @@ -235,102 +236,107 @@ export function ClientForm({ open, onOpenChange, client, onUseExistingClient }: {fields.map((field, index) => (
-
- - -
+
+
+ + +
-
- - {(() => { - const channel = watch(`contacts.${index}.channel`); - if (channel === 'phone' || channel === 'whatsapp') { - const e164 = watch(`contacts.${index}.valueE164`) ?? null; - const country = - (watch(`contacts.${index}.valueCountry`) as CountryCode | undefined) ?? - undefined; +
+ + {(() => { + const channel = watch(`contacts.${index}.channel`); + if (channel === 'phone' || channel === 'whatsapp') { + const e164 = watch(`contacts.${index}.valueE164`) ?? null; + const country = + (watch(`contacts.${index}.valueCountry`) as CountryCode | undefined) ?? + undefined; + return ( + { + setValue(`contacts.${index}.value`, v.e164 ?? ''); + setValue(`contacts.${index}.valueE164`, v.e164 ?? undefined); + setValue(`contacts.${index}.valueCountry`, v.country); + }} + data-testid={`contact-${index}-phone`} + /> + ); + } return ( - { - setValue(`contacts.${index}.value`, v.e164 ?? ''); - setValue(`contacts.${index}.valueE164`, v.e164 ?? undefined); - setValue(`contacts.${index}.valueCountry`, v.country); - }} - data-testid={`contact-${index}-phone`} + ); - } - return ( - - ); - })()} + })()} +
+ +
+ + +
-
- - -
- -
- setValue(`contacts.${index}.isPrimary`, !!v)} - /> - -
- -
+ {/* Bottom strip: Primary toggle left, delete right. Sits on + its own row on every breakpoint so neither control gets + squashed by the field columns above. */} +
+ {fields.length > 1 && ( )}
@@ -346,7 +352,7 @@ export function ClientForm({ open, onOpenChange, client, onUseExistingClient }:

Source & Preferences

-
+
@@ -394,7 +400,7 @@ export function ClientForm({ open, onOpenChange, client, onUseExistingClient }: data-testid="client-timezone" />
-
+
diff --git a/src/components/clients/client-list.tsx b/src/components/clients/client-list.tsx index e3a655e0..8139a25c 100644 --- a/src/components/clients/client-list.tsx +++ b/src/components/clients/client-list.tsx @@ -38,6 +38,7 @@ import { type ClientRow, } from '@/components/clients/client-columns'; import { ColumnPicker } from '@/components/shared/column-picker'; +import { useCreateFromUrl } from '@/hooks/use-create-from-url'; import { usePaginatedQuery } from '@/hooks/use-paginated-query'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { useTablePreferences } from '@/hooks/use-table-preferences'; @@ -49,6 +50,7 @@ export function ClientList() { const queryClient = useQueryClient(); const [createOpen, setCreateOpen] = useState(false); + useCreateFromUrl(() => setCreateOpen(true)); const [editClient, setEditClient] = useState(null); const [archiveClient, setArchiveClient] = useState(null); const [tagDialog, setTagDialog] = useState<{ ids: string[]; mode: 'add' | 'remove' } | null>( @@ -141,17 +143,9 @@ export function ClientList() { title="Clients" description="Manage your client records" variant="gradient" - actions={ - - - - } /> -
+
setSaveViewOpen(true)} /> + {/* New Client moved out of PageHeader actions and into the + filter row. Saves a row on mobile (no more dedicated + actions strip). ml-auto keeps the primary action at the + far-right edge, which is where reps look first. */} + + +
({ value: s.value, label: s.label })); const CONTACT_METHOD_OPTIONS = [ { value: 'email', label: 'Email' }, @@ -289,10 +284,10 @@ export function getClientTabs({ clientId, currentUserId, client }: ClientTabsOpt badge: client.noteCount, content: ( ), }, diff --git a/src/components/clients/smart-archive-dialog.tsx b/src/components/clients/smart-archive-dialog.tsx index dfa25ef2..194d6417 100644 --- a/src/components/clients/smart-archive-dialog.tsx +++ b/src/components/clients/smart-archive-dialog.tsx @@ -477,12 +477,12 @@ export function SmartArchiveDialog({ open, onOpenChange, clientId, clientName, o )} - {/* In-flight Documenso envelopes */} + {/* In-flight signing envelopes */} {dossier.documents.filter((d) => d.isInFlight).length > 0 && ( - In-flight Documenso envelopes + In-flight signing envelopes @@ -502,7 +502,7 @@ export function SmartArchiveDialog({ open, onOpenChange, clientId, clientName, o } > - +
))} diff --git a/src/components/companies/company-columns.tsx b/src/components/companies/company-columns.tsx index a543740a..cf777dde 100644 --- a/src/components/companies/company-columns.tsx +++ b/src/components/companies/company-columns.tsx @@ -42,6 +42,19 @@ const STATUS_LABELS: Record = { dissolved: 'Dissolved', }; +export const COMPANY_COLUMN_OPTIONS = [ + { id: 'name', label: 'Name', alwaysVisible: true }, + { id: 'legalName', label: 'Legal Name' }, + { id: 'taxId', label: 'Tax ID' }, + { id: 'memberCount', label: 'Members' }, + { id: 'yachtCount', label: 'Yachts' }, + { id: 'status', label: 'Status' }, + { id: 'actions', label: 'Actions', alwaysVisible: true }, +]; + +/** Hidden by default — keep the table dense; opt-in to longer columns. */ +export const COMPANY_DEFAULT_HIDDEN: string[] = ['legalName', 'taxId']; + interface GetCompanyColumnsOptions { portSlug: string; onEdit: (company: CompanyRow) => void; diff --git a/src/components/companies/company-list.tsx b/src/components/companies/company-list.tsx index ff106998..00000e2a 100644 --- a/src/components/companies/company-list.tsx +++ b/src/components/companies/company-list.tsx @@ -15,6 +15,7 @@ import { TableSkeleton } from '@/components/shared/loading-skeleton'; import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog'; import { PermissionGate } from '@/components/shared/permission-gate'; import { TagPicker } from '@/components/shared/tag-picker'; +import { ColumnPicker } from '@/components/shared/column-picker'; import { Dialog, DialogContent, @@ -26,9 +27,16 @@ import { import { CompanyCard } from '@/components/companies/company-card'; import { CompanyForm } from '@/components/companies/company-form'; import { companyFilterDefinitions } from '@/components/companies/company-filters'; -import { getCompanyColumns, type CompanyRow } from '@/components/companies/company-columns'; +import { + getCompanyColumns, + COMPANY_COLUMN_OPTIONS, + COMPANY_DEFAULT_HIDDEN, + type CompanyRow, +} from '@/components/companies/company-columns'; +import { useCreateFromUrl } from '@/hooks/use-create-from-url'; import { usePaginatedQuery } from '@/hooks/use-paginated-query'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; +import { useTablePreferences } from '@/hooks/use-table-preferences'; import { apiFetch } from '@/lib/api/client'; export function CompanyList() { @@ -37,6 +45,7 @@ export function CompanyList() { const queryClient = useQueryClient(); const [createOpen, setCreateOpen] = useState(false); + useCreateFromUrl(() => setCreateOpen(true)); const [editCompany, setEditCompany] = useState(null); const [archiveCompany, setArchiveCompany] = useState(null); const [tagDialog, setTagDialog] = useState<{ ids: string[]; mode: 'add' | 'remove' } | null>( @@ -102,6 +111,10 @@ export function CompanyList() { onArchive: (company) => setArchiveCompany(company), }); + // 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])); + return (
- { - clearFilters(); - Object.entries(savedFilters).forEach(([key, val]) => setFilter(key, val)); - }} - /> +
+ { + clearFilters(); + Object.entries(savedFilters).forEach(([key, val]) => setFilter(key, val)); + }} + /> +
{isLoading ? ( @@ -145,6 +161,7 @@ export function CompanyList() { ) : ( { diff --git a/src/components/companies/company-picker.tsx b/src/components/companies/company-picker.tsx index 34cd522d..f6cf9e8f 100644 --- a/src/components/companies/company-picker.tsx +++ b/src/components/companies/company-picker.tsx @@ -56,7 +56,7 @@ export function CompanyPicker({ })(); return ( - + + + + + Customize dashboard + + Pick which analytics cards appear on your dashboard. Hidden cards leave no empty + space — the layout reflows to fill the available width. + + + + {/* Toggle list. Capped at ~60vh with internal scroll so the modal + doesn't push the action footer off-screen on shorter viewports. */} +
+
+ {allWidgets.map((w) => ( + + ))} +
+
+ + {/* Footer: stacks vertically on mobile (counter row, secondary + buttons row, full-width primary "Done") so no button gets + orphaned beneath the others. Reverts to single inline row at + sm+ where there's space. */} + + + {visibleCount} of {allWidgets.length} visible + +
+ + + + +
+
+
+ + ); +} diff --git a/src/components/dashboard/dashboard-shell.tsx b/src/components/dashboard/dashboard-shell.tsx index 5e2419b3..019eaddc 100644 --- a/src/components/dashboard/dashboard-shell.tsx +++ b/src/components/dashboard/dashboard-shell.tsx @@ -4,19 +4,13 @@ import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; -import { usePortContext } from '@/providers/port-provider'; +import { useDashboardWidgets } from '@/hooks/use-dashboard-widgets'; import { apiFetch } from '@/lib/api/client'; import { PageHeader } from '@/components/shared/page-header'; -import { ActivityFeed } from './activity-feed'; +import { CustomizeWidgetsMenu } from './customize-widgets-menu'; import { DateRangePicker } from './date-range-picker'; -import { PipelineFunnelChart } from './pipeline-funnel-chart'; -import { OccupancyTimelineChart } from './occupancy-timeline-chart'; -import { RevenueBreakdownChart } from './revenue-breakdown-chart'; -import { LeadSourceChart } from './lead-source-chart'; -import { MyRemindersRail } from './my-reminders-rail'; -import { WebsiteGlanceTile } from './website-glance-tile'; import { WidgetErrorBoundary } from './widget-error-boundary'; -import { AlertRail } from '@/components/alerts/alert-rail'; +import type { DashboardWidget } from './widget-registry'; import { isCustomRange, type DateRange } from '@/lib/analytics/range'; const PRESET_LABELS: Record<'today' | '7d' | '30d' | '90d', string> = { @@ -43,8 +37,10 @@ function rangeLabel(range: DateRange): string { interface MeData { data?: { - firstName?: string | null; - displayName?: string | null; + profile?: { + firstName?: string | null; + displayName?: string | null; + }; }; } @@ -58,8 +54,14 @@ function timeOfDayGreeting(): string { export function DashboardShell() { const [range, setRange] = useState('30d'); - const { currentPort } = usePortContext(); - const portName = currentPort?.name ?? 'this port'; + + const { visibleWidgets } = useDashboardWidgets(); + + // Bucket once so the JSX stays readable. Registry order is preserved + // inside each bucket, so reordering the registry reorders the render. + const charts = visibleWidgets.filter((w) => w.group === 'chart'); + const rails = visibleWidgets.filter((w) => w.group === 'rail'); + 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 @@ -70,7 +72,7 @@ export function DashboardShell() { queryFn: ({ signal }) => apiFetch('/api/v1/me', { signal }), staleTime: 5 * 60_000, }); - const firstName = me.data?.data?.firstName?.trim(); + const firstName = me.data?.data?.profile?.firstName?.trim(); // Time-aware greeting line, falls back to a generic "Welcome back" when // we don't know the user's first name yet (e.g. profile not filled out). const greeting = firstName ? `${timeOfDayGreeting()}, ${firstName}` : 'Welcome back'; @@ -96,51 +98,103 @@ export function DashboardShell() { return (
+ {/* 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 — + keeps the personalized touch from desktop without polluting + the topbar (which stays "Dashboard" for wayfinding). */} +
+

Dashboard

+

{greeting}

+
+ {rangeLabel(range)}} + // The date-range subtitle only means something when at least + // one widget is on the page to consume the range; if everything + // is hidden it just reads as an orphaned line. + kpiLine={visibleWidgets.length > 0 ? {rangeLabel(range)} : undefined} variant="gradient" - actions={} + actions={ +
+ + +
+ } /> - {/* `items-start` is critical: without it, the right-column aside is + {/* Charts + rails sit side-by-side at xl+. Each side is an auto-fit + grid, so hiding a card causes the remaining ones to widen. + `items-start` is critical: without it, the right-column aside is stretched to match the chart column's row height, which forces MyRemindersRail (or any other child with `h-full`) to push later - children out of the aside's box and into the rows below where - ActivityFeed renders. */} -
-
- - - - - - - - - - - - + children out of the aside's box. */} + {/* Charts + rails. Layout adapts to which regions have content so + we never leave a 320px stripe of dead space when only one side + is populated: + both → main 1fr column + 320px rail (the original layout) + charts only → single full-width auto-fit chart grid + rails only → rails widen into an auto-fit grid (no fixed 320) + neither → nothing renders + The chart grid uses `minmax(360px, 1fr)` so a lone chart fills + the row; the rails-only grid uses a slightly tighter `280px` + minimum so KPI tiles + rails fit 3-4 across on a wide viewport + instead of stretching to 600px+ each. */} + {charts.length > 0 && rails.length > 0 ? ( +
+
+ {charts.map((w) => ( + + ))} +
+
- -
+ ) : charts.length > 0 ? ( +
+ {charts.map((w) => ( + + ))} +
+ ) : rails.length > 0 ? ( +
+ {rails.map((w) => ( + + ))} +
+ ) : null} - + {feed.map((w) => ( + + ))} + + {visibleWidgets.length === 0 ? : null}
); } + +/** + * 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 + * widgets back. + */ +function EmptyDashboardHint() { + return ( +
+

No widgets on your dashboard yet

+

+ Click Customize above to pick which + analytics cards appear here. +

+
+ ); +} + +function WidgetCell({ widget, range }: { widget: DashboardWidget; range: DateRange }) { + return {widget.render(range)}; +} diff --git a/src/components/dashboard/date-range-picker.tsx b/src/components/dashboard/date-range-picker.tsx index 642e3c77..04213512 100644 --- a/src/components/dashboard/date-range-picker.tsx +++ b/src/components/dashboard/date-range-picker.tsx @@ -126,7 +126,15 @@ export function DateRangePicker({ value, onChange, className }: DateRangePickerP {isCustom ? formatCustom(value) : 'Custom'} - +
Custom range @@ -141,7 +149,7 @@ export function DateRangePicker({ value, onChange, className }: DateRangePickerP empty result, and not understand why. */ max={draftTo && draftTo < today ? draftTo : today} onChange={(e) => setDraftFrom(e.target.value)} - className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm outline-none focus:border-brand/60 focus:ring-2 focus:ring-brand/15" + className="w-auto max-w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm outline-none focus:border-brand/60 focus:ring-2 focus:ring-brand/15" />
diff --git a/src/components/dashboard/hot-deals-card.tsx b/src/components/dashboard/hot-deals-card.tsx new file mode 100644 index 00000000..2fdf170a --- /dev/null +++ b/src/components/dashboard/hot-deals-card.tsx @@ -0,0 +1,108 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import Link from 'next/link'; +import { useParams } from 'next/navigation'; +import { Flame } from 'lucide-react'; +import { formatDistanceToNow } from 'date-fns'; + +import { apiFetch } from '@/lib/api/client'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; + +interface HotDeal { + id: string; + stage: string; + clientName: string; + mooringNumber: string | null; + lastContact: string | null; +} + +interface HotDealsResponse { + data: HotDeal[]; +} + +const STAGE_LABELS: Record = { + contract_signed: 'Contract Signed', + contract_sent: 'Contract Sent', + deposit_10: 'Deposit 10%', + eoi_signed: 'EOI Signed', + eoi_sent: 'EOI Sent', + in_comms: 'In Comms', + details_sent: 'Details Sent', + open: 'Open', + completed: 'Completed', +}; + +/** + * Top 5 in-flight interests closest to closing. Ranked server-side by + * pipeline stage (the further along, the closer to signing) with most- + * recent activity as a tiebreaker. Gives reps a "what should I be + * chasing this week" view without opening the full pipeline board. + */ +export function HotDealsCard() { + const params = useParams<{ portSlug: string }>(); + const portSlug = params?.portSlug ?? ''; + + const { data, isLoading } = useQuery({ + queryKey: ['dashboard', 'hot_deals'], + queryFn: () => apiFetch('/api/v1/dashboard/hot-deals'), + staleTime: 60_000, + }); + + const deals = data?.data ?? []; + + return ( + + + + + Hot deals + + Active interests closest to closing. + + + {isLoading ? ( +
+ + + +
+ ) : deals.length === 0 ? ( +

+ No active deals to chase. New leads will surface here once they advance past Open. +

+ ) : ( +
    + {deals.map((d) => ( +
  • + +
    +

    {d.clientName}

    +

    + {d.mooringNumber ? `Berth ${d.mooringNumber}` : 'No berth linked'} + {d.lastContact ? ( + <> + {' · '} + last touched {formatDistanceToNow(new Date(d.lastContact))} ago + + ) : null} +

    +
    + + {STAGE_LABELS[d.stage] ?? d.stage} + + +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/src/components/dashboard/lead-source-chart.tsx b/src/components/dashboard/lead-source-chart.tsx index 9ec47821..83e5669d 100644 --- a/src/components/dashboard/lead-source-chart.tsx +++ b/src/components/dashboard/lead-source-chart.tsx @@ -7,6 +7,8 @@ import { EmptyState } from '@/components/shared/empty-state'; import { ChartCard } from './chart-card'; import { useLeadSource } from './use-analytics'; import type { DateRange } from '@/lib/services/analytics.service'; +import { rangeToSlug } from '@/lib/analytics/range'; +import { SOURCE_LABELS as CANONICAL_SOURCE_LABELS } from '@/lib/constants'; interface Props { range: DateRange; @@ -20,10 +22,11 @@ const COLORS = [ 'hsl(var(--chart-5))', ]; +// Extend the canonical source labels with the analytics-specific buckets the +// API returns (`unspecified` for null sources, legacy `social`). Renames to the +// canonical set in /lib/constants stay in sync via the spread. const SOURCE_LABELS: Record = { - website: 'Website', - referral: 'Referral', - manual: 'Manual', + ...CANONICAL_SOURCE_LABELS, social: 'Social', unspecified: 'Unspecified', }; @@ -48,7 +51,7 @@ export function LeadSourceChart({ range }: Props) { {isLoading ? ( diff --git a/src/components/dashboard/my-reminders-rail.tsx b/src/components/dashboard/my-reminders-rail.tsx index 381b126d..2ded8a59 100644 --- a/src/components/dashboard/my-reminders-rail.tsx +++ b/src/components/dashboard/my-reminders-rail.tsx @@ -87,7 +87,7 @@ export function MyRemindersRail() { ) : null}
View all diff --git a/src/components/dashboard/occupancy-timeline-chart.tsx b/src/components/dashboard/occupancy-timeline-chart.tsx index f779829c..e95418bf 100644 --- a/src/components/dashboard/occupancy-timeline-chart.tsx +++ b/src/components/dashboard/occupancy-timeline-chart.tsx @@ -15,6 +15,7 @@ import { EmptyState } from '@/components/shared/empty-state'; import { ChartCard } from './chart-card'; import { useOccupancy } from './use-analytics'; import type { DateRange } from '@/lib/services/analytics.service'; +import { rangeToSlug } from '@/lib/analytics/range'; interface Props { range: DateRange; @@ -41,7 +42,7 @@ export function OccupancyTimelineChart({ range }: Props) { {isLoading ? ( diff --git a/src/components/dashboard/pipeline-funnel-chart.tsx b/src/components/dashboard/pipeline-funnel-chart.tsx index ed2c9aef..825276bd 100644 --- a/src/components/dashboard/pipeline-funnel-chart.tsx +++ b/src/components/dashboard/pipeline-funnel-chart.tsx @@ -9,6 +9,7 @@ import { STAGE_SHORT_LABELS, safeStage, stageLabel } from '@/lib/constants'; import { ChartCard } from './chart-card'; import { useFunnel } from './use-analytics'; import type { DateRange } from '@/lib/services/analytics.service'; +import { rangeToSlug } from '@/lib/analytics/range'; interface Props { range: DateRange; @@ -38,7 +39,7 @@ export function PipelineFunnelChart({ range }: Props) { {isLoading ? ( diff --git a/src/components/dashboard/pipeline-value-tile.tsx b/src/components/dashboard/pipeline-value-tile.tsx new file mode 100644 index 00000000..f9b8861e --- /dev/null +++ b/src/components/dashboard/pipeline-value-tile.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import { DollarSign } from 'lucide-react'; + +import { apiFetch } from '@/lib/api/client'; +import { Card, CardContent } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { formatCurrency } from '@/lib/utils/currency'; + +interface KpiResponse { + pipelineValueUsd: number; +} + +/** + * Total pipeline value for active interests, USD-denominated. Sourced + * from the same KPIs endpoint as the active-deals tile so the two + * share a cache entry and render in lockstep. + */ +export function PipelineValueTile() { + const { data, isLoading } = useQuery({ + queryKey: ['dashboard', 'kpis'], + queryFn: () => apiFetch('/api/v1/dashboard/kpis'), + staleTime: 60_000, + }); + + return ( + + {/* `pt-5 pb-5` is explicit because shadcn's default CardContent ships + with `pt-0` (it assumes a CardHeader sits above). Without these + overrides the tile content snaps to the top edge of the card. */} + +
+ +
+
+

+ Pipeline value +

+ {isLoading ? ( + + ) : ( +

+ {formatCurrency(data?.pipelineValueUsd ?? 0, 'USD', { maxFractionDigits: 0 })} +

+ )} +
+
+
+ ); +} diff --git a/src/components/dashboard/revenue-breakdown-chart.tsx b/src/components/dashboard/revenue-breakdown-chart.tsx index 190a4bb8..8e192ab5 100644 --- a/src/components/dashboard/revenue-breakdown-chart.tsx +++ b/src/components/dashboard/revenue-breakdown-chart.tsx @@ -7,6 +7,7 @@ import { EmptyState } from '@/components/shared/empty-state'; import { ChartCard } from './chart-card'; import { useRevenue } from './use-analytics'; import type { DateRange } from '@/lib/services/analytics.service'; +import { rangeToSlug } from '@/lib/analytics/range'; import { formatCurrency } from '@/lib/utils/currency'; interface Props { @@ -42,7 +43,7 @@ export function RevenueBreakdownChart({ range }: Props) { {isLoading ? ( diff --git a/src/components/dashboard/source-conversion-chart.tsx b/src/components/dashboard/source-conversion-chart.tsx new file mode 100644 index 00000000..f9af5b91 --- /dev/null +++ b/src/components/dashboard/source-conversion-chart.tsx @@ -0,0 +1,91 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; + +import { apiFetch } from '@/lib/api/client'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; + +interface SourceRow { + source: string; + total: number; + won: number; + lost: number; + conversionRate: number; +} + +interface SourceConversionResponse { + data: SourceRow[]; +} + +/** + * Horizontal bar list of lead-source conversion rates. Complements the + * existing Lead Source Attribution donut: that one shows where leads + * COME from, this shows which sources actually CONVERT. Lets marketing + * spend follow the buyers, not the tire-kickers. + * + * Renders only sources with at least one lead; uses a compact bar-in- + * row layout so 5-8 sources fit comfortably without scrolling. + */ +export function SourceConversionChart() { + const { data, isLoading } = useQuery({ + queryKey: ['dashboard', 'source_conversion'], + queryFn: () => apiFetch('/api/v1/dashboard/source-conversion'), + staleTime: 60_000, + }); + + const rows = data?.data ?? []; + + return ( + + + Source conversion + Won deals as a percentage of leads per source. + + + {isLoading ? ( +
+ + + +
+ ) : rows.length === 0 ? ( +

+ Once interests have a source assigned, conversion rates will appear here. +

+ ) : ( +
    + {rows.map((r) => { + const pct = Math.round(r.conversionRate * 100); + const label = r.source + .replace(/_/g, ' ') + .replace(/\b\w/g, (c) => c.toUpperCase()); + return ( +
  • +
    + {label} + + {pct}% + + ({r.won} won · {r.total} total) + + +
    + {/* 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/widget-registry.tsx b/src/components/dashboard/widget-registry.tsx new file mode 100644 index 00000000..12ab6791 --- /dev/null +++ b/src/components/dashboard/widget-registry.tsx @@ -0,0 +1,198 @@ +/** + * 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. + * + * Widget visibility is persisted per-user in + * `user_profiles.preferences.dashboardWidgets` as `{ [id]: boolean }`. + * Missing entries default to `defaultVisible`, so a brand-new widget + * surfaces for existing users automatically. + */ + +import type { ReactNode } from 'react'; + +import { ActiveDealsTile } from './active-deals-tile'; +import { ActivityFeed } from './activity-feed'; +import { BerthStatusChart } from './berth-status-chart'; +import { HotDealsCard } from './hot-deals-card'; +import { LeadSourceChart } from './lead-source-chart'; +import { OccupancyTimelineChart } from './occupancy-timeline-chart'; +import { PipelineFunnelChart } from './pipeline-funnel-chart'; +import { PipelineValueTile } from './pipeline-value-tile'; +import { RevenueBreakdownChart } from './revenue-breakdown-chart'; +import { SourceConversionChart } from './source-conversion-chart'; +import { WebsiteGlanceTile } from './website-glance-tile'; +import { MyRemindersRail } from './my-reminders-rail'; +import { AlertRail } from '@/components/alerts/alert-rail'; +import type { DateRange } from '@/lib/analytics/range'; + +/** + * Where a widget lives on the dashboard. The shell renders three + * separate auto-fit regions so charts and rails don't compete for the + * same horizontal slots (preserves the visual hierarchy the team has + * gotten used to). + * + * - 'chart' → main analytics region (wider min-col) + * - 'rail' → side-rail region (narrower min-col) + * - 'feed' → full-width row underneath everything else + */ +export type WidgetGroup = 'chart' | 'rail' | 'feed'; + +/** + * External integrations a widget can depend on. When the corresponding + * integration isn't connected for the active port, the widget is hidden + * from the picker AND from the rendered dashboard so reps can't toggle + * something that would render nothing. Wire new integrations through + * `useDashboardIntegrations()`. + */ +export type WidgetIntegration = 'umami' | 'documenso'; + +export interface DashboardWidget { + /** Stable persistence key. Don't rename — old preferences would break. */ + id: string; + label: string; + description: string; + /** + * Renders the widget. Receives the active date-range so chart widgets + * can react; non-chart widgets simply ignore it. Keeping this a + * function instead of a `ComponentType` lets each widget pick its own + * prop shape without leaking the union into the registry type. + */ + render: (range: DateRange) => ReactNode; + group: WidgetGroup; + defaultVisible: boolean; + /** + * 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 — + * but the widget itself decides whether to render content. + */ + selfGates?: boolean; + /** + * Names the external integration this widget depends on. When the + * integration isn't connected for the active port, the widget is + * filtered out of both the picker and the rendered dashboard. + */ + requires?: WidgetIntegration; +} + +export const DASHBOARD_WIDGETS: readonly DashboardWidget[] = [ + // ── KPI tiles (rail) ──────────────────────────────────────────────── + // 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. + { + id: 'kpi_active_deals', + label: 'Active Deals', + description: 'Compact tile: count of in-flight interests.', + render: () => , + group: 'rail', + defaultVisible: false, + }, + { + id: 'kpi_pipeline_value', + label: 'Pipeline Value', + description: 'Compact tile: total berth value of active deals (USD).', + render: () => , + group: 'rail', + defaultVisible: false, + }, + + // ── Charts (main area) ────────────────────────────────────────────── + { + id: 'pipeline_funnel', + label: 'Pipeline Funnel', + description: 'Interests by stage with conversion-rate vs open.', + render: (range) => , + group: 'chart', + defaultVisible: true, + }, + { + id: 'occupancy_timeline', + label: 'Occupancy Timeline', + description: 'Daily berth occupancy across the range.', + render: (range) => , + group: 'chart', + defaultVisible: true, + }, + { + id: 'revenue_breakdown', + label: 'Revenue Breakdown', + description: 'Invoice totals grouped by status and currency.', + render: (range) => , + group: 'chart', + defaultVisible: true, + }, + { + id: 'lead_source', + label: 'Lead Source Attribution', + description: 'Where new interests came from.', + render: (range) => , + group: 'chart', + defaultVisible: true, + }, + { + id: 'berth_status', + label: 'Berth Status', + description: 'Donut: available / under offer / sold split.', + render: () => , + group: 'chart', + defaultVisible: false, + }, + { + id: 'source_conversion', + label: 'Source Conversion', + description: 'Win rate per lead source — which channels deliver buyers, not just leads.', + render: () => , + group: 'chart', + defaultVisible: false, + }, + { + id: 'website_analytics', + label: 'Website Analytics', + description: 'Quick glance at marketing site traffic. Requires Umami.', + render: () => , + group: 'rail', + defaultVisible: true, + selfGates: true, + requires: 'umami', + }, + { + id: 'my_reminders', + label: 'My Reminders', + description: 'Your upcoming and overdue reminders.', + render: () => , + group: 'rail', + defaultVisible: true, + }, + { + id: 'alerts', + label: 'Alerts', + description: 'System-flagged action items.', + render: () => , + group: 'rail', + defaultVisible: true, + }, + { + id: 'hot_deals', + label: 'Hot Deals', + description: 'Top 5 active interests closest to closing.', + render: () => , + group: 'rail', + defaultVisible: false, + }, + { + id: 'activity_feed', + label: 'Recent Activity', + description: 'Audit log of changes across the port.', + render: () => , + group: 'feed', + defaultVisible: true, + }, +]; + +/** Lookup helper so consumers don't have to scan the array. */ +export const WIDGETS_BY_ID: Record = Object.fromEntries( + DASHBOARD_WIDGETS.map((w) => [w.id, w]), +); diff --git a/src/components/documents/create-document-wizard.tsx b/src/components/documents/create-document-wizard.tsx index 0b9b0a68..32ca9913 100644 --- a/src/components/documents/create-document-wizard.tsx +++ b/src/components/documents/create-document-wizard.tsx @@ -230,8 +230,8 @@ export function CreateDocumentWizard({ portSlug }: CreateDocumentWizardProps) { - Documenso renders + signs - Render in CRM, sign via Documenso + Generated EOI — rendered + signed externally + Manual EOI — rendered in CRM, sent for e-signature
diff --git a/src/components/documents/document-detail.tsx b/src/components/documents/document-detail.tsx index b30535d1..b4100585 100644 --- a/src/components/documents/document-detail.tsx +++ b/src/components/documents/document-detail.tsx @@ -157,7 +157,7 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) { }; const handleCancel = async () => { - if (!confirm('Cancel this document? This voids it in Documenso and cannot be undone.')) return; + if (!confirm('Cancel this document? This voids the signing envelope and cannot be undone.')) return; setIsCancelling(true); try { await apiFetch(`/api/v1/documents/${documentId}/cancel`, { method: 'POST' }); diff --git a/src/components/documents/document-template-picker.tsx b/src/components/documents/document-template-picker.tsx index 6b8b6632..3415633d 100644 --- a/src/components/documents/document-template-picker.tsx +++ b/src/components/documents/document-template-picker.tsx @@ -70,7 +70,7 @@ export function DocumentTemplatePicker({ })(); return ( - + + <> + + + } /> ) : (
    {documents.map(renderRow)}
)} + + + + + Upload file + + {folderId === null + ? 'File will be added to the root.' + : 'File will be added to the current folder.'} + + + { + if (!file) { + queryClient.invalidateQueries({ queryKey: ['files'] }); + queryClient.invalidateQueries({ queryKey: ['documents'] }); + setUploadOpen(false); + } + }} + /> + + ); } diff --git a/src/components/documents/eoi-generate-dialog.tsx b/src/components/documents/eoi-generate-dialog.tsx index 39dbbead..47488269 100644 --- a/src/components/documents/eoi-generate-dialog.tsx +++ b/src/components/documents/eoi-generate-dialog.tsx @@ -235,7 +235,7 @@ export function EoiGenerateDialog({ - Documenso Standard EOI (recommended) + Standard EOI — sent for e-signature (recommended) {inAppTemplates.map((t) => ( diff --git a/src/components/documents/folder-actions-menu.tsx b/src/components/documents/folder-actions-menu.tsx index cfc4be29..a4357523 100644 --- a/src/components/documents/folder-actions-menu.tsx +++ b/src/components/documents/folder-actions-menu.tsx @@ -77,83 +77,88 @@ export function FolderActionsMenu({ selectedFolderId, onAfterDelete }: FolderAct return ( <> - - - - - - { - setName(''); - setCreateOpen(true); - }} - > - - New folder {isFolderSelected ? 'inside this' : 'at root'} - - {isFolderSelected ? ( - - - - - { - if (isSystem) return; - setName(currentName); - setRenameOpen(true); - }} - > - - Rename - - - - {isSystem ? ( - System folders can't be renamed. - ) : null} - - - - - e.preventDefault()} - className="text-destructive" - > - - Delete - - } - title="Delete folder?" - description="Subfolders and documents inside will move up to the parent. The folder itself is removed." - confirmLabel="Delete folder" - loading={deleteMutation.isPending} - onConfirm={async () => { - try { - await deleteMutation.mutateAsync(selectedFolderId as string); - toast.success('Folder deleted; contents moved to parent.'); - onAfterDelete?.(); - } catch (err) { - toastError(err); +
+ + {isFolderSelected ? ( + + + + + + + + + + { + if (isSystem) return; + setName(currentName); + setRenameOpen(true); + }} + > + + Rename + + + + {isSystem ? ( + System folders can't be renamed. + ) : null} + + + + + e.preventDefault()} + className="text-destructive" + > + + Delete + } - }} - /> - - - {isSystem ? ( - System folders can't be deleted. - ) : null} - - - ) : null} - - + title="Delete folder?" + description="Subfolders and documents inside will move up to the parent. The folder itself is removed." + confirmLabel="Delete folder" + loading={deleteMutation.isPending} + onConfirm={async () => { + try { + await deleteMutation.mutateAsync(selectedFolderId as string); + toast.success('Folder deleted; contents moved to parent.'); + onAfterDelete?.(); + } catch (err) { + toastError(err); + } + }} + /> + + + {isSystem ? ( + System folders can't be deleted. + ) : null} + + + + + ) : null} +
diff --git a/src/components/documents/folder-tree-sidebar.tsx b/src/components/documents/folder-tree-sidebar.tsx index 02eb90e4..cc395ddd 100644 --- a/src/components/documents/folder-tree-sidebar.tsx +++ b/src/components/documents/folder-tree-sidebar.tsx @@ -100,7 +100,7 @@ function TreeBody({ onClick={() => onSelect(undefined)} /> onSelect(null)} diff --git a/src/components/documents/new-document-menu.tsx b/src/components/documents/new-document-menu.tsx index 4cb13757..88472b09 100644 --- a/src/components/documents/new-document-menu.tsx +++ b/src/components/documents/new-document-menu.tsx @@ -83,7 +83,7 @@ export function NewDocumentMenu({
Generate document for signing - EOI, contract, or custom — sent via Documenso + EOI, contract, or custom — sent for e-signature
diff --git a/src/components/expenses/trip-label-combobox.tsx b/src/components/expenses/trip-label-combobox.tsx index 57bcceb2..e7085331 100644 --- a/src/components/expenses/trip-label-combobox.tsx +++ b/src/components/expenses/trip-label-combobox.tsx @@ -61,7 +61,7 @@ export function TripLabelCombobox({ }; return ( - + + ); +} diff --git a/src/components/interests/external-eoi-upload-dialog.tsx b/src/components/interests/external-eoi-upload-dialog.tsx index 01fb54f9..0184b07f 100644 --- a/src/components/interests/external-eoi-upload-dialog.tsx +++ b/src/components/interests/external-eoi-upload-dialog.tsx @@ -76,7 +76,7 @@ export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSucc Upload externally-signed EOI - For EOIs signed outside Documenso (paper, in person, alternate e-sign vendor). The + For EOIs signed outside our signing service (paper, in person, alternate e-sign vendor). The uploaded PDF is filed against this interest and the pipeline stage is advanced to EOI Signed. diff --git a/src/components/interests/interest-card.tsx b/src/components/interests/interest-card.tsx index c56c9f87..9c4a8c3e 100644 --- a/src/components/interests/interest-card.tsx +++ b/src/components/interests/interest-card.tsx @@ -18,7 +18,12 @@ import { deriveInitials, } from '@/components/shared/list-card'; import { cn } from '@/lib/utils'; -import { stageBadgeClass, stageDotClass, stageLabel as toStageLabel } from '@/lib/constants'; +import { + stageBadgeClass, + stageDotClass, + stageLabel as toStageLabel, + formatSource, +} from '@/lib/constants'; import { computeUrgencyBadges } from '@/components/interests/urgency'; import type { InterestRow } from './interest-columns'; @@ -28,13 +33,6 @@ const CATEGORY_LABELS: Record = { hot_lead: 'Hot lead', }; -const SOURCE_LABELS: Record = { - website: 'Website', - manual: 'Manual', - referral: 'Referral', - broker: 'Broker', -}; - interface InterestCardProps { interest: InterestRow; portSlug: string; @@ -48,7 +46,7 @@ export function InterestCard({ interest, portSlug, onEdit, onArchive }: Interest const accentClass = stageDotClass(interest.pipelineStage); const isHotLead = interest.leadCategory === 'hot_lead'; const categoryLabel = interest.leadCategory ? CATEGORY_LABELS[interest.leadCategory] : null; - const sourceLabel = interest.source ? (SOURCE_LABELS[interest.source] ?? interest.source) : null; + const sourceLabel = formatSource(interest.source); const tags = interest.tags ?? []; const notesCount = interest.notesCount ?? 0; const urgencyBadges = computeUrgencyBadges(interest); diff --git a/src/components/interests/interest-contact-log-tab.tsx b/src/components/interests/interest-contact-log-tab.tsx index 92cea43a..795432af 100644 --- a/src/components/interests/interest-contact-log-tab.tsx +++ b/src/components/interests/interest-contact-log-tab.tsx @@ -36,6 +36,7 @@ import { } from '@/components/ui/dropdown-menu'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { Checkbox } from '@/components/ui/checkbox'; import { Select, SelectContent, @@ -391,14 +392,47 @@ function ComposeDialog({ />
-
- - setFollowUpAt(e.target.value)} - /> +
+ + {followUpAt ? ( +
+ + setFollowUpAt(e.target.value)} + className="max-w-xs" + /> +

+ A reminder is created on this interest for the time above. +

+
+ ) : null}
diff --git a/src/components/interests/interest-contract-tab.tsx b/src/components/interests/interest-contract-tab.tsx index ae9ca14d..735715b8 100644 --- a/src/components/interests/interest-contract-tab.tsx +++ b/src/components/interests/interest-contract-tab.tsx @@ -278,7 +278,7 @@ function ActiveContractCard({
) : signers.length === 0 ? (

- Documenso hasn't reported signers yet — check back in a moment. + The signing service hasn't reported signers yet — check back in a moment.

) : ( @@ -341,7 +341,7 @@ function EmptyContractState({

Sales contracts are drafted custom per deal. Either upload a paper-signed copy you handled - externally, or upload the draft PDF and send for e-signing via Documenso. + externally, or upload the draft PDF and send for e-signing.

) : signers.length === 0 ? (

- Documenso hasn't reported signers yet — check back in a moment. + The signing service hasn't reported signers yet — check back in a moment.

) : ( @@ -329,7 +329,7 @@ function EmptyEoiState({ No EOI in flight for this interest

- Generate the EOI to send it for signing — Documenso handles the signing chain. You can also + Generate the EOI to send it for signing — the signing service handles the signing chain. You can also upload a paper-signed copy if it was signed outside the system.

diff --git a/src/components/interests/interest-form.tsx b/src/components/interests/interest-form.tsx index ac0d009f..57370a56 100644 --- a/src/components/interests/interest-form.tsx +++ b/src/components/interests/interest-form.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; @@ -18,6 +18,16 @@ import { SelectValue, } from '@/components/ui/select'; import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Command, @@ -30,12 +40,13 @@ import { import { Checkbox } from '@/components/ui/checkbox'; import { Separator } from '@/components/ui/separator'; import { TagPicker } from '@/components/shared/tag-picker'; +import { ReminderDaysInput } from '@/components/shared/reminder-days-input'; import { YachtForm } from '@/components/yachts/yacht-form'; import { YachtPicker } from '@/components/yachts/yacht-picker'; import { apiFetch } from '@/lib/api/client'; import { useEntityOptions } from '@/hooks/use-entity-options'; import { createInterestSchema, type CreateInterestInput } from '@/lib/validators/interests'; -import { PIPELINE_STAGES, STAGE_LABELS, LEAD_CATEGORIES } from '@/lib/constants'; +import { PIPELINE_STAGES, STAGE_LABELS, LEAD_CATEGORIES, SOURCES } from '@/lib/constants'; import { cn } from '@/lib/utils'; const CATEGORY_LABELS: Record = { @@ -77,14 +88,14 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }: const [clientOpen, setClientOpen] = useState(false); const [berthOpen, setBerthOpen] = useState(false); + const [desiredUnit, setDesiredUnit] = useState<'ft' | 'm'>('ft'); const { - register, handleSubmit, watch, setValue, reset, - formState: { errors, isSubmitting }, + formState: { errors, isSubmitting, isDirty }, } = useForm({ resolver: zodResolver(createInterestSchema), defaultValues: { @@ -102,6 +113,15 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }: const selectedBerthId = watch('berthId'); const selectedYachtId = watch('yachtId'); const [createYachtOpen, setCreateYachtOpen] = useState(false); + const [discardConfirmOpen, setDiscardConfirmOpen] = useState(false); + + function requestClose() { + if (isDirty && !isSubmitting && !mutation.isPending) { + setDiscardConfirmOpen(true); + return; + } + onOpenChange(false); + } // Fetch the selected client's company memberships so the YachtPicker can // include yachts owned by companies the client belongs to (e.g. a @@ -200,7 +220,16 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }: const selectedBerth = berthOptions.find((b) => b.value === selectedBerthId); return ( - + { + if (next) { + onOpenChange(true); + return; + } + requestClose(); + }} + > {isEdit ? 'Edit Interest' : 'New Interest'} @@ -215,7 +244,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
- + - - + + {/* shouldFilter={false}: server-side search via setClientSearch + drives the result set. Without this, cmdk's default filter + matches the user's typed text against CommandItem.value + (the client UUID) and silently drops every result that + doesn't contain the typed substring in its id. */} + @@ -269,7 +303,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
- + - - + + @@ -431,10 +465,11 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }: - Website - Manual - Referral - Broker + {SOURCES.map((s) => ( + + {s.label} + + ))}
@@ -444,48 +479,43 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }: {/* Desired berth dimensions (recommender inputs) */} -
-

- Berth size desired -

-

- Imperial. Optional - the recommender treats blank fields as no constraint on that - axis. -

-
-
- - -
-
- - -
-
- - +
+
+
+

+ Berth size desired +

+

+ Optional - the recommender treats blank fields as no constraint on that axis. +

+ +
+
+ setValue('desiredLengthFt', v, { shouldDirty: true })} + /> + setValue('desiredWidthFt', v, { shouldDirty: true })} + /> + setValue('desiredDraftFt', v, { shouldDirty: true })} + />
@@ -506,12 +536,11 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
{reminderEnabled && (
- - Reminder cadence + setValue('reminderDays', v ?? undefined)} />
)} @@ -526,7 +555,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
- + + + + + Discard unsaved changes? + + You've filled in some fields. Closing now will lose them. + + + + Keep editing + { + setDiscardConfirmOpen(false); + onOpenChange(false); + }} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90 focus:ring-destructive" + > + Discard + + + + {createYachtOpen && selectedClientId && ( ); } + +// ── Helpers for the "Berth size desired" section ────────────────────────────── + +const FT_PER_M = 1 / 0.3048; + +function round2(n: number): number { + return Math.round(n * 100) / 100; +} + +function UnitToggle({ value, onChange }: { value: 'ft' | 'm'; onChange: (v: 'ft' | 'm') => void }) { + return ( +
+ {(['ft', 'm'] as const).map((u) => ( + + ))} +
+ ); +} + +interface DimensionInputProps { + htmlId: string; + label: string; + placeholder?: string; + unit: 'ft' | 'm'; + ftValue: string | number | undefined; + onChangeFt: (next: string | undefined) => void; +} + +/** + * Single dimension input bound to a form value stored in feet. Renders the + * value in the rep's chosen display unit and converts back on edit. The form + * state stays canonical ft so the recommender (which queries `b.length_ft` + * etc.) sees the same number regardless of which unit the rep typed in. + * + * Local `display` state preserves mid-typing strings like "18." that would + * otherwise be lost to round-tripping through Number(). + */ +function DimensionInput({ + htmlId, + label, + placeholder, + unit, + ftValue, + onChangeFt, +}: DimensionInputProps) { + const focusedRef = useRef(false); + const [display, setDisplay] = useState(() => computeDisplay(ftValue, unit)); + + // Re-sync from the canonical ft value when it changes externally (form + // reset, unit toggle). Skip while focused so we don't fight keystrokes. + useEffect(() => { + if (focusedRef.current) return; + setDisplay(computeDisplay(ftValue, unit)); + }, [ftValue, unit]); + + const altValue = computeAltDisplay(ftValue, unit); + + return ( +
+ + { + focusedRef.current = true; + }} + onBlur={() => { + focusedRef.current = false; + // Canonicalize the display from the ft source-of-truth on blur so + // any mid-typed garbage clears. + setDisplay(computeDisplay(ftValue, unit)); + }} + onChange={(e) => { + const raw = e.target.value; + setDisplay(raw); + if (raw === '') { + onChangeFt(undefined); + return; + } + const n = parseFloat(raw); + if (!Number.isFinite(n) || n <= 0) { + onChangeFt(undefined); + return; + } + const ft = unit === 'ft' ? n : n * FT_PER_M; + onChangeFt(String(round2(ft))); + }} + /> + {altValue ? ( +

≈ {altValue}

+ ) : null} +
+ ); +} + +function computeDisplay(ftValue: string | number | undefined, unit: 'ft' | 'm'): string { + if (ftValue === undefined || ftValue === null || ftValue === '') return ''; + const ft = typeof ftValue === 'number' ? ftValue : Number(ftValue); + if (!Number.isFinite(ft)) return ''; + const v = unit === 'ft' ? ft : ft * 0.3048; + return String(round2(v)); +} + +function computeAltDisplay( + ftValue: string | number | undefined, + unit: 'ft' | 'm', +): string | null { + if (ftValue === undefined || ftValue === null || ftValue === '') return null; + const ft = typeof ftValue === 'number' ? ftValue : Number(ftValue); + if (!Number.isFinite(ft) || ft <= 0) return null; + return unit === 'ft' ? `${round2(ft * 0.3048)} m` : `${round2(ft)} ft`; +} diff --git a/src/components/interests/interest-list.tsx b/src/components/interests/interest-list.tsx index 46d6fe77..63335009 100644 --- a/src/components/interests/interest-list.tsx +++ b/src/components/interests/interest-list.tsx @@ -33,6 +33,7 @@ import { } from '@/components/interests/interest-columns'; import { ColumnPicker } from '@/components/shared/column-picker'; import { SaveViewDialog } from '@/components/shared/save-view-dialog'; +import { useCreateFromUrl } from '@/hooks/use-create-from-url'; import { useTablePreferences } from '@/hooks/use-table-preferences'; import { InterestCard } from '@/components/interests/interest-card'; import { StageLegend } from '@/components/interests/stage-legend'; @@ -72,6 +73,7 @@ export function InterestList() { }, [viewMode, setViewMode]); const [createOpen, setCreateOpen] = useState(false); + useCreateFromUrl(() => setCreateOpen(true)); const [editInterest, setEditInterest] = useState(null); const [archiveInterest, setArchiveInterest] = useState(null); const [saveViewOpen, setSaveViewOpen] = useState(false); diff --git a/src/components/interests/interest-outcome-dialog.tsx b/src/components/interests/interest-outcome-dialog.tsx index 00d71f14..daaea08f 100644 --- a/src/components/interests/interest-outcome-dialog.tsx +++ b/src/components/interests/interest-outcome-dialog.tsx @@ -29,6 +29,7 @@ const OUTCOME_LABELS: Record = { lost_other_marina: 'Lost - went to another marina', lost_unqualified: 'Lost - unqualified', lost_no_response: 'Lost - no response', + lost_other: 'Lost - other', cancelled: 'Cancelled', }; @@ -36,6 +37,7 @@ const LOST_OUTCOMES: InterestOutcome[] = [ 'lost_other_marina', 'lost_unqualified', 'lost_no_response', + 'lost_other', 'cancelled', ]; diff --git a/src/components/interests/interest-picker.tsx b/src/components/interests/interest-picker.tsx index 97bc446d..4ba8ff55 100644 --- a/src/components/interests/interest-picker.tsx +++ b/src/components/interests/interest-picker.tsx @@ -64,7 +64,7 @@ export function InterestPicker({ })(); return ( - + + ) : ( + + )} + + +
+ + setDate(e.target.value)} + className="h-9" + /> +

+ Defaults to today — back-date if the event happened earlier. +

+
+
+ + +
+
+
+ ); +} + function MilestoneSection({ title, icon: Icon, @@ -282,16 +369,12 @@ function MilestoneSection({ ) : null}
{isNext && step.advanceStage && !step.hideAutoButton ? ( - + onConfirm={(date) => onAdvance(step.advanceStage!, date)} + /> ) : null}
@@ -392,7 +475,7 @@ function OverviewTab({ * skip-ahead pattern from the inline stage picker so audit trails * stay consistent regardless of which surface the rep used. */ - const advance = (stage: string) => { + const advance = (stage: string, milestoneDate?: string) => { const fromStage = interest.pipelineStage as PipelineStage; const toStage = stage as PipelineStage; const isOverride = fromStage !== toStage && !canTransitionStage(fromStage, toStage); @@ -409,6 +492,7 @@ function OverviewTab({ stage, reason: isOverride ? 'Skip-ahead from overview milestones' : 'Marked from overview', override: isOverride || undefined, + milestoneDate, }); }; @@ -566,14 +650,12 @@ function OverviewTab({ Create deposit invoice - + onConfirm={(date) => advance('deposit_10pct', date)} + />
) : null, pastSummary: interest.dateDepositReceived @@ -682,7 +764,12 @@ function OverviewTab({ /> - + ({ value: s.value, label: s.label }))} + value={interest.source} + onSave={save('source')} + />
diff --git a/src/components/interests/interest-timeline.tsx b/src/components/interests/interest-timeline.tsx index dbff32a0..17a4ddaf 100644 --- a/src/components/interests/interest-timeline.tsx +++ b/src/components/interests/interest-timeline.tsx @@ -37,6 +37,7 @@ const LOST_OUTCOMES = new Set([ 'lost_other_marina', 'lost_unqualified', 'lost_no_response', + 'lost_other', 'cancelled', ]); diff --git a/src/components/interests/linked-berths-list.tsx b/src/components/interests/linked-berths-list.tsx index 88a20d46..1ce2a9ef 100644 --- a/src/components/interests/linked-berths-list.tsx +++ b/src/components/interests/linked-berths-list.tsx @@ -36,6 +36,13 @@ import { Label } from '@/components/ui/label'; import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill'; import { Switch } from '@/components/ui/switch'; import { Textarea } from '@/components/ui/textarea'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { HelpCircle } from 'lucide-react'; import { apiFetch } from '@/lib/api/client'; import { cn } from '@/lib/utils'; @@ -303,53 +310,96 @@ function LinkedBerthRowItem({ row, portSlug, eoiStatus, onUpdate, onRemove, isPe
-
-
- {/* Switch sits next to its label (gap-2.5) instead of being - flexed to the far right via justify-between — when the - column is wide, justify-between created a confusing visual - gulf between the action and what it controls. */} -
- onUpdate(row.berthId, { isSpecificInterest: checked })} - /> - + +
+
+ {/* Switch sits next to its label (gap-2.5) instead of being + flexed to the far right via justify-between — when the + column is wide, justify-between created a confusing visual + gulf between the action and what it controls. */} +
+ + onUpdate(row.berthId, { isSpecificInterest: checked }) + } + /> + + + + + + + Mark this berth as one your client is actively considering. When on, the berth + appears as Under Offer on the public map and counts toward the + recommender's "heat" score. Turn off if the link is legal/EOI-only. + + +
+

+ {row.isSpecificInterest ? SPECIFIC_CONSEQUENCE_ON : SPECIFIC_CONSEQUENCE_OFF} +

-

- {row.isSpecificInterest ? SPECIFIC_CONSEQUENCE_ON : SPECIFIC_CONSEQUENCE_OFF} -

-
-
-
- onUpdate(row.berthId, { isInEoiBundle: checked })} - /> - +
+
+ onUpdate(row.berthId, { isInEoiBundle: checked })} + /> + + + + + + + Include this berth in the EOI's signed berth range. When on, the berth is + covered by the same signature and shows up in the EOI's + Berth Range form field (e.g. "A1-A3, B5-B7"). Turn off + to keep the link without legal coverage. + + +
+

+ {row.isInEoiBundle + ? 'Covered by the interest’s EOI signature.' + : 'Not covered by the EOI bundle.'} +

-

- {row.isInEoiBundle - ? 'Covered by the interest’s EOI signature.' - : 'Not covered by the EOI bundle.'} -

-
+
{showBypassControl ? ( -
-
+ // Bypass section reads as a third toggle-style row: label + description + // on the left, action button inline with the description so it doesn't + // float far-right while the toggles above are anchored left. +
+

Bypass EOI for this berth

{row.eoiBypassReason ? (

diff --git a/src/components/layout/mobile/mobile-bottom-tabs.tsx b/src/components/layout/mobile/mobile-bottom-tabs.tsx index be3af2fd..7704fbb6 100644 --- a/src/components/layout/mobile/mobile-bottom-tabs.tsx +++ b/src/components/layout/mobile/mobile-bottom-tabs.tsx @@ -2,7 +2,7 @@ import Link from 'next/link'; import { usePathname } from 'next/navigation'; -import { Anchor, FileSignature, LayoutDashboard, Menu, Users } from 'lucide-react'; +import { Anchor, LayoutDashboard, Menu, Search, Users } from 'lucide-react'; import { cn } from '@/lib/utils'; @@ -12,35 +12,26 @@ type TabSpec = { segment: string; // route segment after /[portSlug]/ }; -// Bottom nav ordering, left → right: -// Dashboard - daily overview -// Berths - marina inventory grid (touches sales + ops both) -// Clients - the address book / dedup surface (centered: it's the -// primary mental anchor for "find this person", with -// interests living as a tab on the client detail rather -// than a peer in the bottom nav) -// Documents - signature tracking (chase signers, EOI queue) -// More - overflow drawer (Interests, Yachts, Companies, …) -// -// Interests is intentionally NOT in the bottom row - having both Clients -// and Interests as peer tabs created a Clients-vs-Interests confusion -// for sales reps, and the per-client interests tab + the new bottom-sheet -// drawer cover the day-to-day deal review without needing a dedicated tab. -// Yachts stays out for the same reason as before: it's an asset record -// most often reached from inside an interest or client, not browsed. -const TABS: TabSpec[] = [ +// Left-of-center: Dashboard, Clients. Right-of-center: Berths, More. +// Search occupies the center slot. Documents demoted to the MoreSheet — +// reps reach docs less often than berths during a walking inventory check, +// and pinned-to-client documents are accessed via the client detail anyway. +const TABS_LEFT: TabSpec[] = [ { label: 'Dashboard', icon: LayoutDashboard, segment: 'dashboard' }, - { label: 'Berths', icon: Anchor, segment: 'berths' }, { label: 'Clients', icon: Users, segment: 'clients' }, - { label: 'Documents', icon: FileSignature, segment: 'documents' }, ]; -export function MobileBottomTabs({ onMoreClick }: { onMoreClick: () => void }) { - const pathname = usePathname(); +const TABS_RIGHT: TabSpec[] = [ + { label: 'Berths', icon: Anchor, segment: 'berths' }, +]; - // Derive the active port slug from the URL so tab links always target the - // current port, even after a port-switch. The dashboard route shape is - // /[portSlug]/, so the slug is the first non-empty path segment. +interface MobileBottomTabsProps { + onMoreClick: () => void; + onSearchClick: () => void; +} + +export function MobileBottomTabs({ onMoreClick, onSearchClick }: MobileBottomTabsProps) { + const pathname = usePathname(); const portSlug = pathname.split('/').filter(Boolean)[0] ?? 'port-nimara'; function isActive(segment: string): boolean { @@ -53,41 +44,42 @@ export function MobileBottomTabs({ onMoreClick }: { onMoreClick: () => void }) { className={cn( 'fixed bottom-0 inset-x-0 z-40 bg-background border-t border-border', 'pb-safe-bottom', - 'grid grid-cols-5', + // 5 equal-flex slots. + 'flex items-end', )} > - {TABS.map((tab) => { - const active = isActive(tab.segment); - const Icon = tab.icon; - return ( - - {/* Subtle pill background behind the icon when active. Keeps the - tab grid alignment intact while giving the eye an anchor. */} - - - {tab.label} - - ); - })} + {TABS_LEFT.map((tab) => ( + + ))} + + {/* Search button — styled identically to the other navbar tabs. */} + + + {TABS_RIGHT.map((tab) => ( + + ))} +

- setMoreOpen(true)} /> + setMoreOpen(true)} + onSearchClick={() => setSearchOpen(true)} + /> + ); diff --git a/src/components/layout/mobile/more-sheet.tsx b/src/components/layout/mobile/more-sheet.tsx index 23bd215c..5c307b7a 100644 --- a/src/components/layout/mobile/more-sheet.tsx +++ b/src/components/layout/mobile/more-sheet.tsx @@ -5,19 +5,20 @@ import { usePathname } from 'next/navigation'; import { Anchor, BarChart3, - Bell, - BellRing, Bookmark, Building2, + FileSignature, Globe, Home, + Inbox, Receipt, Settings, Shield, - ShieldAlert, Ship, } from 'lucide-react'; +import { useQuery } from '@tanstack/react-query'; + import { Drawer, DrawerContent, @@ -26,6 +27,7 @@ import { DrawerClose, } from '@/components/shared/drawer'; import { useUmamiActive } from '@/components/website-analytics/use-website-analytics'; +import { apiFetch } from '@/lib/api/client'; type MoreItem = { label: string; @@ -33,32 +35,50 @@ type MoreItem = { segment: string; }; -// Order: most-likely overflow targets first. Interests is here (rather -// than the bottom row) to dodge the Clients-vs-Interests UX confusion; -// reps reach the active deals via the Interests tab on a client detail -// (or via the new bottom-sheet drawer). Yachts is asset-record traffic -// best reached contextually from inside an interest or client. +type MoreGroup = { + label: string; + items: MoreItem[]; +}; + +// Logical grouping (vs alphabetical or frequency-ranked): keeps a stable +// spatial layout — reps' muscle memory survives — while making the +// "kind of thing" each tile is explicit. Three sections: +// - Records: entity lists (people, vessels, properties) +// - Operations: daily-use action surfaces +// - Configuration: port-level setup, hidden from most reps // -// Inbox is intentionally absent — the email/threading inbox feature was -// deferred (see sidebar.tsx). Re-add this entry once IMAP/SMTP wiring -// + Google OAuth review are done. Website analytics is filtered below -// when Umami isn't configured for this port. -const MORE_ITEMS: MoreItem[] = [ - { label: 'Interests', icon: Bookmark, segment: 'interests' }, - { label: 'Yachts', icon: Ship, segment: 'yachts' }, - { label: 'Companies', icon: Building2, segment: 'companies' }, - { label: 'Expenses', icon: Receipt, segment: 'expenses' }, - { label: 'Reservations', icon: Anchor, segment: 'berth-reservations' }, - // Notifications themselves live on the topbar bell — this entry deep-links - // to the notification panel inside user-settings (collapsed in 2026-05-09). - { label: 'Notification preferences', icon: BellRing, segment: 'settings#notifications' }, - { label: 'Residential', icon: Home, segment: 'residential/clients' }, - { label: 'Website analytics', icon: Globe, segment: 'website-analytics' }, - { label: 'Alerts', icon: ShieldAlert, segment: 'alerts' }, - { label: 'Reports', icon: BarChart3, segment: 'reports' }, - { label: 'Reminders', icon: Bell, segment: 'reminders' }, - { label: 'Settings', icon: Settings, segment: 'settings' }, - { label: 'Admin', icon: Shield, segment: 'admin' }, +// Interests stays here (not bottom nav) to dodge the Clients-vs- +// Interests UX confusion. Inbox replaces the previously-separate +// Alerts + Reminders entries (merged 2026-05-11). Website analytics +// and Reservations are filtered out below when not applicable. +const MORE_GROUPS: MoreGroup[] = [ + { + label: 'Records', + items: [ + { label: 'Documents', icon: FileSignature, segment: 'documents' }, + { label: 'Interests', icon: Bookmark, segment: 'interests' }, + { label: 'Yachts', icon: Ship, segment: 'yachts' }, + { label: 'Companies', icon: Building2, segment: 'companies' }, + { label: 'Residential', icon: Home, segment: 'residential/clients' }, + ], + }, + { + label: 'Operations', + items: [ + { label: 'Alerts & Reminders', icon: Inbox, segment: 'inbox' }, + { label: 'Expenses', icon: Receipt, segment: 'expenses' }, + { label: 'Reservations', icon: Anchor, segment: 'berth-reservations' }, + { label: 'Reports', icon: BarChart3, segment: 'reports' }, + ], + }, + { + label: 'Configuration', + items: [ + { label: 'Website analytics', icon: Globe, segment: 'website-analytics' }, + { label: 'Settings', icon: Settings, segment: 'settings' }, + { label: 'Admin', icon: Shield, segment: 'admin' }, + ], + }, ]; export function MoreSheet({ @@ -74,10 +94,30 @@ export function MoreSheet({ // Hide "Website analytics" if Umami isn't wired up for this port — the // dedicated tile on the dashboard already does the same. const umami = useUmamiActive('today'); - const umamiConfigured = umami.data?.error !== 'umami_not_configured'; - const items = MORE_ITEMS.filter( - (item) => item.segment !== 'website-analytics' || umamiConfigured, - ); + const umamiConfigured = !umami.isLoading && umami.data?.notConfigured !== true; + + // Hide "Reservations" until at least one exists for this port — until the + // marina has confirmed bookings, the page is empty and surfaces nothing + // useful. Cheap count via pageSize=1; cached 5 min so opening the sheet + // repeatedly doesn't refetch. + const reservations = useQuery<{ pagination?: { total: number } }>({ + queryKey: ['berth-reservations', 'sheet-count'], + queryFn: () => apiFetch('/api/v1/berth-reservations?pageSize=1'), + staleTime: 5 * 60_000, + enabled: open, + }); + const hasReservations = + !reservations.isLoading && (reservations.data?.pagination?.total ?? 0) > 0; + + // Per-group filter: keep only the items relevant to this port's state. + const groups = MORE_GROUPS.map((g) => ({ + ...g, + items: g.items.filter((item) => { + if (item.segment === 'website-analytics') return umamiConfigured; + if (item.segment === 'berth-reservations') return hasReservations; + return true; + }), + })).filter((g) => g.items.length > 0); return ( @@ -85,28 +125,36 @@ export function MoreSheet({ More -
    - {items.map((item) => { - const Icon = item.icon; - return ( -
  • - - - - {item.label} - - -
  • - ); - })} -
+
+ {groups.map((group) => ( +
+

+ {group.label} +

+
    + {group.items.map((item) => { + const Icon = item.icon; + return ( +
  • + + + + {item.label} + + +
  • + ); + })} +
+
+ ))} +
); diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 62a3e736..b139aef8 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -13,7 +13,7 @@ import { Building2, Receipt, FileText, - Bell, + Inbox, Camera, Globe, Settings, @@ -156,10 +156,12 @@ function buildNavSections(portSlug: string | undefined): NavSection[] { title: 'Communication', marinaRequired: true, items: [ - // Email tab removed: we deferred building a full inbox/threading + // Email tab removed: deferred building a full inbox/threading // feature (would require Google OAuth + scope review + IMAP - // syncing infra). Reminders stays since it's already wired up. - { href: `${base}/reminders`, label: 'Reminders', icon: Bell }, + // syncing infra). This entry routes to the merged + // Alerts + Reminders surface (2026-05-11) — explicit name so + // reps don't mistake it for an email inbox. + { href: `${base}/inbox`, label: 'Alerts & Reminders', icon: Inbox }, ], }, { @@ -188,8 +190,8 @@ function NavItemLink({ href={item.href as any} className={cn( 'relative flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-all duration-150', - 'text-[#cdcfd6] hover:bg-[#171f35] hover:text-white', - active && 'text-white pl-[14px]', + 'text-slate-700 hover:bg-accent hover:text-foreground', + active && 'bg-accent text-foreground pl-[14px]', collapsed && 'justify-center px-2', )} > @@ -202,7 +204,7 @@ function NavItemLink({ @@ -252,7 +254,7 @@ function SidebarContent({ const [adminExpanded, setAdminExpanded] = useState(true); const sections = buildNavSections(portSlug); const umami = useUmamiActive('today'); - const umamiConfigured = umami.data?.error !== 'umami_not_configured'; + const umamiConfigured = !umami.isLoading && umami.data?.notConfigured !== true; // Small label under the user identity when the user has access to more // than one port — disambiguates which port is currently active without @@ -283,12 +285,13 @@ function SidebarContent({ // compete with the logo for attention. return ( -
- {/* Brand header - logo centered (large when expanded, smaller when - collapsed). Collapse toggle floats top-right as a tiny chevron. */} +
+ {/* Brand header - logo centered. Soft hairline below echoes the + inter-section separators in the nav so the eye reads the logo + as a distinct top-row, not a floating element. */}
@@ -297,7 +300,7 @@ function SidebarContent({ alt="Port Nimara" width={collapsed ? 40 : 72} height={collapsed ? 40 : 72} - className="rounded-full shadow-md ring-2 ring-white/20" + className="rounded-full shadow-sm" unoptimized priority /> @@ -307,7 +310,7 @@ function SidebarContent({ onClick={onToggleCollapse} aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'} className={cn( - 'absolute right-2 flex h-6 w-6 items-center justify-center rounded-md text-[#83aab1] hover:bg-[#171f35] hover:text-white transition-colors', + 'absolute right-2 flex h-6 w-6 items-center justify-center rounded-md text-slate-400 hover:bg-slate-100 hover:text-slate-700 transition-colors', collapsed ? 'top-1' : 'top-2', )} > @@ -333,13 +336,13 @@ function SidebarContent({
{!collapsed && (
- + {section.title} {section.adminRequired && (
); })} @@ -374,7 +377,7 @@ function SidebarContent({ user can click their name/avatar to access Profile / Settings / port-switcher / sign-out. The same UserMenu component drives the top-right avatar dropdown, so the menu items stay consistent. */} -
+
{collapsed ? ( @@ -404,26 +407,26 @@ function SidebarContent({ @@ -461,10 +464,9 @@ export function Sidebar({ portRoles, isSuperAdmin = false, user, ports }: Sideba return (
-
+ {/* 2fr/1fr split — the datetime-local control needs more room + for "MM/DD/YYYY HH:MM AM" than a 4-item priority Select. */} +
- - setAssignedTo(v === '__self__' ? '' : v)} + > - Myself + {/* Radix Select forbids empty-string values, so use a + sentinel here and map back to '' in the handler. */} + Myself {users.map((u) => ( {u.displayName} @@ -220,27 +230,36 @@ export function ReminderForm({ )}
-
diff --git a/src/components/reminders/reminder-list.tsx b/src/components/reminders/reminder-list.tsx index 37761c72..3ac15c13 100644 --- a/src/components/reminders/reminder-list.tsx +++ b/src/components/reminders/reminder-list.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react'; import { type ColumnDef } from '@tanstack/react-table'; -import { Plus, CheckCircle2, Clock, XCircle, AlertTriangle, Bell } from 'lucide-react'; +import { Plus, CheckCircle2, Clock, Pencil, XCircle, AlertTriangle, Bell } from 'lucide-react'; import { formatDistanceToNow } from 'date-fns'; import { useParams } from 'next/navigation'; @@ -11,6 +11,12 @@ import { PageHeader } from '@/components/shared/page-header'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; import { Select, SelectContent, @@ -19,6 +25,7 @@ import { SelectValue, } from '@/components/ui/select'; import { apiFetch } from '@/lib/api/client'; +import { useCreateFromUrl } from '@/hooks/use-create-from-url'; import { usePermissions } from '@/hooks/use-permissions'; import { ReminderCard } from './reminder-card'; import { ReminderForm } from './reminder-form'; @@ -59,10 +66,20 @@ const STATUS_CONFIG = { dismissed: { label: 'Dismissed', icon: XCircle }, } as const; -export function ReminderList() { +interface ReminderListProps { + /** + * Embedded mode (used by the Inbox page) drops the PageHeader and + * surfaces the "New Reminder" button inline so the section can render + * alongside the Alerts section without duplicating page chrome. + */ + embedded?: boolean; +} + +export function ReminderList({ embedded = false }: ReminderListProps = {}) { const [reminders, setReminders] = useState([]); const [loading, setLoading] = useState(true); const [formOpen, setFormOpen] = useState(false); + useCreateFromUrl(() => setFormOpen(true)); const [editingReminder, setEditingReminder] = useState(null); const [snoozingId, setSnoozingId] = useState(null); const [viewMode, setViewMode] = useState<'my' | 'all'>('my'); @@ -203,41 +220,97 @@ export function ReminderList() { return null; } return ( -
- - - -
+ +
+ + + + + Mark complete + + + + + + Snooze + + + + + + Edit + + + + + + Dismiss + +
+
); }, enableSorting: false, - size: 120, + size: 160, }, ]; return (
- { + setEditingReminder(null); + setFormOpen(true); + }} + > + + New Reminder + + } + /> + ) : ( +
- } - /> +
+ )} {/* Wrap on phone widths so the priority filter doesn't get pushed off-screen by the My/All tabs + status filter taking the full row. */} diff --git a/src/components/reports/generate-report-form.tsx b/src/components/reports/generate-report-form.tsx index 67122f6f..d4673242 100644 --- a/src/components/reports/generate-report-form.tsx +++ b/src/components/reports/generate-report-form.tsx @@ -17,11 +17,49 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { apiFetch } from '@/lib/api/client'; import type { RequestReportInput } from '@/lib/validators/reports'; -const REPORT_TYPE_LABELS: Record = { - pipeline: 'Pipeline Summary', - revenue: 'Revenue Report', - activity: 'Activity Log', - occupancy: 'Berth Occupancy', +interface ReportTypeMeta { + label: string; + subtitle: string; + contents: string[]; +} + +const REPORT_TYPES: Record = { + pipeline: { + label: 'Pipeline Summary', + subtitle: 'Interest counts by stage and conversion rates', + contents: [ + 'Active (non-archived) interests grouped by pipeline stage', + 'Stage-to-stage drop-off counts', + 'Open vs. won vs. lost roll-up at the bottom', + ], + }, + revenue: { + label: 'Revenue Report', + subtitle: 'Berth-price totals rolled up by pipeline stage', + contents: [ + 'Sum of primary-berth prices grouped by stage', + 'Pulled from each interest’s primary berth link (non-primary junctions ignored)', + 'Sold-stage total reflects realised revenue; earlier stages are forecast', + ], + }, + activity: { + label: 'Activity Log', + subtitle: 'Audit events across the port for a date range', + contents: [ + 'Audit log entries (create / update / delete) per entity', + 'Filtered to the selected date range — defaults to last 30 days', + 'Includes actor name, entity type, and action verb', + ], + }, + occupancy: { + label: 'Berth Occupancy', + subtitle: 'Berth counts by status', + contents: [ + 'Berths grouped by status: Available, Under Offer, Sold', + 'Per-dock breakdown using the mooring-letter prefix', + 'Total port utilisation percentage at the top', + ], + }, }; export function GenerateReportForm() { @@ -74,13 +112,26 @@ export function GenerateReportForm() { - {Object.entries(REPORT_TYPE_LABELS).map(([value, label]) => ( - - {label} + {Object.entries(REPORT_TYPES).map(([value, meta]) => ( + +
+ {meta.label} + {meta.subtitle} +
))}
+ {reportType && REPORT_TYPES[reportType] ? ( +
+

{REPORT_TYPES[reportType].subtitle}

+
    + {REPORT_TYPES[reportType].contents.map((line) => ( +
  • {line}
  • + ))} +
+
+ ) : null}
@@ -94,7 +145,7 @@ export function GenerateReportForm() { />
-
+
setDateFrom(e.target.value)} + className="w-auto" />
@@ -111,6 +163,7 @@ export function GenerateReportForm() { type="date" value={dateTo} onChange={(e) => setDateTo(e.target.value)} + className="w-auto" />
diff --git a/src/components/residential/residential-client-tabs.tsx b/src/components/residential/residential-client-tabs.tsx index e90b370b..21c3f021 100644 --- a/src/components/residential/residential-client-tabs.tsx +++ b/src/components/residential/residential-client-tabs.tsx @@ -15,6 +15,7 @@ import { SubdivisionCombobox } from '@/components/shared/subdivision-combobox'; import { NotesList } from '@/components/shared/notes-list'; import { EntityActivityFeed } from '@/components/shared/entity-activity-feed'; import { apiFetch } from '@/lib/api/client'; +import { SOURCES } from '@/lib/constants'; import type { CountryCode } from '@/lib/i18n/countries'; interface ResidentialInterestSummary { @@ -62,13 +63,7 @@ const CONTACT_OPTIONS = [ { value: 'email', label: 'Email' }, { value: 'phone', label: 'Phone' }, ]; -const SOURCE_OPTIONS = [ - { value: 'website', label: 'Website' }, - { value: 'manual', label: 'Manual' }, - { value: 'referral', label: 'Referral' }, - { value: 'broker', label: 'Broker' }, - { value: 'other', label: 'Other' }, -]; +const SOURCE_OPTIONS = SOURCES.map((s) => ({ value: s.value, label: s.label })); function Row({ label, children }: { label: string; children: React.ReactNode }) { return ( @@ -117,10 +112,10 @@ export function getResidentialClientTabs({ label: 'Notes', content: ( ), }, diff --git a/src/components/residential/residential-interest-tabs.tsx b/src/components/residential/residential-interest-tabs.tsx index b57c2395..4dcb3594 100644 --- a/src/components/residential/residential-interest-tabs.tsx +++ b/src/components/residential/residential-interest-tabs.tsx @@ -7,6 +7,7 @@ import { InlineEditableField } from '@/components/shared/inline-editable-field'; import { NotesList } from '@/components/shared/notes-list'; import { EntityActivityFeed } from '@/components/shared/entity-activity-feed'; import { apiFetch } from '@/lib/api/client'; +import { SOURCES } from '@/lib/constants'; interface ResidentialInterest { id: string; @@ -25,13 +26,7 @@ interface Args { stageOptions: Array<{ value: string; label: string }>; } -const SOURCE_OPTIONS = [ - { value: 'website', label: 'Website' }, - { value: 'manual', label: 'Manual' }, - { value: 'referral', label: 'Referral' }, - { value: 'broker', label: 'Broker' }, - { value: 'other', label: 'Other' }, -]; +const SOURCE_OPTIONS = SOURCES.map((s) => ({ value: s.value, label: s.label })); function Row({ label, children }: { label: string; children: React.ReactNode }) { return ( diff --git a/src/components/search/command-search.tsx b/src/components/search/command-search.tsx index 1e6eabff..4318cd1c 100644 --- a/src/components/search/command-search.tsx +++ b/src/components/search/command-search.tsx @@ -12,6 +12,7 @@ import { } from 'react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; +import { useQueryClient } from '@tanstack/react-query'; import { Anchor, Bell, @@ -100,6 +101,7 @@ export function CommandSearch() { const [focusIndex, setFocusIndex] = useState(-1); const router = useRouter(); + const queryClient = useQueryClient(); const portSlug = useUIStore((s) => s.currentPortSlug); const wrapperRef = useRef(null); @@ -113,8 +115,32 @@ export function CommandSearch() { limit: activeBucket === 'all' ? 5 : 15, }); + // Persist the totals from the last "all" query so the filter chips stay + // populated when the user narrows to a single bucket. Without this, the + // narrowed query only returns counts for the active bucket and every + // other chip would vanish — making it impossible to swap between + // filters without clearing back to "All" first. + const lastAllTotalsRef = useRef(null); + useEffect(() => { + if (activeBucket === 'all' && results?.totals) { + lastAllTotalsRef.current = results.totals; + } + }, [activeBucket, results]); + const chipTotals: SearchResults['totals'] | undefined = + activeBucket === 'all' ? results?.totals : (lastAllTotalsRef.current ?? results?.totals); + const showDropdown = focused; + // CommandSearch lives in the header and persists across navigations, + // so its React Query cache never sees a remount. Invalidate the + // recently-viewed + recent-terms queries whenever the dropdown opens + // so the user sees fresh data after navigating around the app. + useEffect(() => { + if (!showDropdown) return; + queryClient.invalidateQueries({ queryKey: ['search', 'recently-viewed'] }); + queryClient.invalidateQueries({ queryKey: ['search', 'recent-terms'] }); + }, [showDropdown, queryClient]); + // Cmd/Ctrl+K focuses the input from anywhere on the page. useEffect(() => { function onKeyDown(e: globalThis.KeyboardEvent) { @@ -287,7 +313,7 @@ export function CommandSearch() { > {/* Filter chip row — always visible while the dropdown is open. */} void; disabled: boolean; }) { - // Show a chip for every bucket so the user can browse the search - // surface even with no query; counts only render when results exist. return (
{BUCKETS.map((b) => { - const count = results?.totals?.[b.type] ?? 0; - // Hide chips for buckets the current user can't see (count === 0 - // when the bucket query was permission-skipped) — but only after - // a query has run, otherwise we'd hide every chip on first paint. + const count = totals?.[b.type] ?? 0; + // Hide chips for buckets with zero matches in the last "all" + // snapshot — keeps the row tight and avoids dead-end clicks. + // Always show the active chip + every chip before a query has run. if (!disabled && count === 0 && active !== b.type) return null; return (
)} + {row.relatedVia && ( +
+ via {row.relatedVia.label} +
+ )}
); @@ -759,9 +791,9 @@ function BucketSection({ // ─── Flat-row construction (drives keyboard nav + ARIA) ────────────────────── -type ResultBadge = { label: string; tone: 'neutral' | 'warning' | 'success' | 'danger' }; +export type ResultBadge = { label: string; tone: 'neutral' | 'warning' | 'success' | 'danger' }; -type FlatRow = +export type FlatRow = | { kind: 'recent-view'; key: string; @@ -783,6 +815,9 @@ type FlatRow = sub: string | null; href: string; badges?: ResultBadge[]; + /** Provenance hint when the row was surfaced via graph expansion. + * Rendered as a subtle "via Berth A10" line below the sub. */ + relatedVia?: { type: string; label: string } | null; } | { kind: 'other-port'; @@ -791,7 +826,7 @@ type FlatRow = href: string; }; -interface BuildFlatRowsArgs { +export interface BuildFlatRowsArgs { query: string; results: SearchResults | undefined; recentlyViewed: RecentlyViewedItem[]; @@ -800,7 +835,7 @@ interface BuildFlatRowsArgs { portSlug: string | null; } -function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] { +export function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] { const { query, results, recentlyViewed, recentSearches, activeBucket, portSlug } = args; const rows: FlatRow[] = []; @@ -839,6 +874,7 @@ function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] { sub: c.matchedContact ?? null, href: `/${portSlug}/clients/${c.id}`, badges: c.archivedAt ? [{ label: 'Archived', tone: 'neutral' }] : undefined, + relatedVia: c.relatedVia ?? null, }); } } @@ -866,6 +902,7 @@ function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] { label: y.name, sub, href: `/${portSlug}/yachts/${y.id}`, + relatedVia: y.relatedVia ?? null, }); } } @@ -880,6 +917,7 @@ function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] { label: co.name, sub, href: `/${portSlug}/companies/${co.id}`, + relatedVia: co.relatedVia ?? null, }); } } @@ -903,6 +941,7 @@ function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] { sub: i.berthMooringNumber, href: `/${portSlug}/interests/${i.id}`, badges: badges.length > 0 ? badges : undefined, + relatedVia: i.relatedVia ?? null, }); } } @@ -942,6 +981,7 @@ function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] { sub, href: `/${portSlug}/berths/${b.id}`, badges: badges.length > 0 ? badges : undefined, + relatedVia: b.relatedVia ?? null, }); } } diff --git a/src/components/search/mobile-search-overlay.tsx b/src/components/search/mobile-search-overlay.tsx new file mode 100644 index 00000000..ce63a195 --- /dev/null +++ b/src/components/search/mobile-search-overlay.tsx @@ -0,0 +1,607 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useQueryClient } from '@tanstack/react-query'; +import { Drawer as VaulDrawer } from 'vaul'; +import { Clock, History, Search, X } from 'lucide-react'; + +import { apiFetch } from '@/lib/api/client'; +import { cn } from '@/lib/utils'; +import { useSearch, type BucketType, type SearchResults } from '@/hooks/use-search'; +import { useUIStore } from '@/stores/ui-store'; +import { buildFlatRows, type FlatRow } from './command-search'; +import { HighlightMatch } from './highlight-match'; + +// Match the desktop bucket order — feels consistent when reps switch contexts. +const BUCKETS: { type: BucketType; label: string }[] = [ + { type: 'clients', label: 'Clients' }, + { type: 'yachts', label: 'Yachts' }, + { type: 'companies', label: 'Companies' }, + { type: 'interests', label: 'Interests' }, + { type: 'berths', label: 'Berths' }, + { type: 'documents', label: 'Documents' }, + { type: 'invoices', label: 'Invoices' }, + { type: 'reminders', label: 'Reminders' }, +]; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const INVOICE_RE = /^INV-\d{6}-\d+$/i; +function looksLikePastedId(input: string): boolean { + const trimmed = input.trim(); + return UUID_RE.test(trimmed) || INVOICE_RE.test(trimmed); +} + +const BADGE_TONE: Record<'neutral' | 'warning' | 'success' | 'danger', string> = { + neutral: 'bg-muted text-muted-foreground', + warning: 'bg-amber-100 text-amber-900', + success: 'bg-emerald-100 text-emerald-900', + danger: 'bg-red-100 text-red-900', +}; + +interface MobileSearchOverlayProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function MobileSearchOverlay({ open, onOpenChange }: MobileSearchOverlayProps) { + const [query, setQuery] = useState(''); + const [activeBucket, setActiveBucket] = useState('all'); + // Tracks the visible-above-keyboard height. iOS Safari ignores + // keyboard area in `dvh`, so we use the visualViewport API directly: + // visualViewport.height is the actual visible area in CSS pixels, + // updates in real time as the keyboard rises/falls. + const [visibleHeight, setVisibleHeight] = useState(null); + const router = useRouter(); + const queryClient = useQueryClient(); + const portSlug = useUIStore((s) => s.currentPortSlug); + const inputRef = useRef(null); + + // The overlay is mounted once at the layout root, so the recently- + // viewed query won't refetch via the usual mount path. Bump it every + // time the drawer opens — the user is about to look at it, and the + // staleTime cache may have missed an entity view that happened in a + // route that doesn't render . + useEffect(() => { + if (!open) return; + queryClient.invalidateQueries({ queryKey: ['search', 'recently-viewed'] }); + queryClient.invalidateQueries({ queryKey: ['search', 'recent-terms'] }); + }, [open, queryClient]); + + useEffect(() => { + if (!open) { + setVisibleHeight(null); + return; + } + const vv = window.visualViewport; + if (!vv) return; + const update = () => setVisibleHeight(vv.height); + update(); + vv.addEventListener('resize', update); + vv.addEventListener('scroll', update); + return () => { + vv.removeEventListener('resize', update); + vv.removeEventListener('scroll', update); + }; + }, [open]); + + const { results, isFetching, recentSearches, recentlyViewed } = useSearch(query, { + type: activeBucket === 'all' ? undefined : activeBucket, + limit: activeBucket === 'all' ? 5 : 25, + }); + + // Persist counts from the last "all" query so chip counts stay visible + // when the user narrows to a single bucket. Narrowed queries only + // return counts for the active bucket, which would otherwise wipe the + // counts off every other chip the moment the user taps one. + const lastAllTotalsRef = useRef(null); + useEffect(() => { + if (activeBucket === 'all' && results?.totals) { + lastAllTotalsRef.current = results.totals; + } + }, [activeBucket, results]); + const chipTotals: SearchResults['totals'] | undefined = + activeBucket === 'all' ? results?.totals : (lastAllTotalsRef.current ?? results?.totals); + + // Auto-focus is delegated to Vaul's `autoFocus` + the input's + // `autoFocus` attribute (synchronous in-gesture, which iOS Safari + // requires before it'll pop the keyboard on programmatic focus). + // A useEffect setTimeout was the previous approach but broke the + // user-gesture chain — input was focused, keyboard stayed hidden. + + // Body scroll lock is delegated to Vaul (modal=true + noBodyStyles=false + // defaults). Manual position:fixed locking caused a visible scroll-then- + // jump on iOS Safari because the body briefly snaps to scrollY=0 after + // being taken out of flow, before the negative-top compensation paints. + // Vaul handles the lock natively via overflow:hidden which doesn't + // remove the body from flow. The trick to avoid Vaul's iOS scroll-lock + // race is `repositionInputs={false}` on the Drawer.Root (set below). + + // Reset query when the drawer closes. Without this, reopening the + // overlay would flash stale results before the empty state renders. + useEffect(() => { + if (!open) { + setQuery(''); + setActiveBucket('all'); + } + }, [open]); + + const close = useCallback(() => { + onOpenChange(false); + inputRef.current?.blur(); + }, [onOpenChange]); + + const navigate = useCallback( + (path: string) => { + close(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + router.push(path as any); + }, + [close, router], + ); + + // Paste a UUID or invoice number → jump straight to the entity. + const onPaste = useCallback( + async (e: React.ClipboardEvent) => { + const pasted = e.clipboardData.getData('text').trim(); + if (!looksLikePastedId(pasted)) return; + try { + const res = await apiFetch<{ found: boolean; href: string | null }>( + `/api/v1/search/resolve-id?id=${encodeURIComponent(pasted)}`, + ); + if (res.found && res.href) { + e.preventDefault(); + navigate(res.href); + } + } catch { + // Best-effort — fall through to text search. + } + }, + [navigate], + ); + + const rows = useMemo( + () => + buildFlatRows({ + query, + results, + recentlyViewed, + recentSearches, + activeBucket, + portSlug, + }), + [query, results, recentlyViewed, recentSearches, activeBucket, portSlug], + ); + + const showingEmptyHints = query.length < 2; + const noResults = !showingEmptyHints && rows.length === 0 && !isFetching; + + return ( + + + + + {/* Visually-hidden title for screen readers. Radix Dialog (which + Vaul wraps) requires a DialogTitle in the accessibility tree; + without this, the console throws an a11y violation. */} + Search + + {/* Drag handle — Vaul reads this as a swipe target. Centered grip + + a small label below feels iOS-native. */} +
+
+
+ + {/* Sticky header: input + Cancel. The Cancel slides in from the + right when the input has focus, otherwise it sits flat. */} +
+ + +
+ + {/* Bucket chips: horizontally scrollable so all buckets fit no + matter the phone width. "All" is sticky-left so it's always + one tap away when the user is deep in a bucket. */} +
+
+ setActiveBucket('all')} + /> + {BUCKETS.map((b) => { + const count = chipTotals?.[b.type] ?? 0; + // Hide chips with zero matches in the last "all" snapshot, + // unless this is the currently active chip. Always show all + // before a query has run (chipTotals undefined → count 0 + // and active 'all' means none get hidden). + if (query.length >= 2 && count === 0 && activeBucket !== b.type) { + return null; + } + return ( + 0 ? count : undefined} + active={activeBucket === b.type} + onClick={() => setActiveBucket(b.type)} + /> + ); + })} +
+
+ + {/* Results scroll region. overscroll-contain prevents the body + from rubber-banding when the user scrolls past the bottom. */} +
+ {showingEmptyHints && rows.length === 0 ? ( + + ) : showingEmptyHints ? ( + + ) : noResults ? ( + + ) : ( + + )} +
+ + + + ); +} + +function BucketChip({ + label, + count, + active, + onClick, +}: { + label: string; + count?: number; + active: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +function EmptyHint() { + return ( +
+
+ +
+

+ Search clients, yachts, interests, berths, invoices, documents — paste a UUID or + invoice number to jump directly. +

+
+ ); +} + +function NoResults({ query }: { query: string }) { + return ( +
+

No matches for “{query}”

+

+ Try a different spelling, or switch buckets above. +

+
+ ); +} + +function RowList({ + rows, + query, + onSelect, + variant, +}: { + rows: FlatRow[]; + query: string; + onSelect: (href: string) => void; + variant: 'empty' | 'results'; +}) { + // Split rows by section header — "Recently viewed", "Recent searches", + // "Results". Headers live inside the row list so they scroll with their + // content (instead of sticky-positioning, which adds visual noise). + const recentViews = rows.filter((r) => r.kind === 'recent-view'); + const recentTerms = rows.filter((r) => r.kind === 'recent-term'); + const results = rows.filter((r) => r.kind === 'result' || r.kind === 'other-port'); + + return ( +
+ {variant === 'empty' && recentViews.length > 0 ? ( +
} label="Recently viewed"> + {recentViews.map((row) => + row.kind === 'recent-view' ? ( + onSelect(row.href)} + label={row.item.label} + sub={row.item.sub} + /> + ) : null, + )} +
+ ) : null} + + {variant === 'empty' && recentTerms.length > 0 ? ( +
} label="Recent searches"> +
+ {recentTerms.map((row) => + row.kind === 'recent-term' ? ( + + ) : null, + )} +
+
+ ) : null} + + {variant === 'results' && results.length > 0 ? renderResultRows(results, query, onSelect) : null} +
+ ); +} + +/** + * Walk the flat result rows, inserting a small section header above the + * first row of each bucket so reps know exactly what kind of entity + * each result points to ("CLIENTS", "INTERESTS", "BERTHS", …). Bucket + * order follows `buildFlatRows`'s ordering — most-likely matches first. + */ +function renderResultRows( + rows: FlatRow[], + query: string, + onSelect: (path: string) => void, +): React.ReactNode[] { + const nodes: React.ReactNode[] = []; + let lastBucket: BucketType | null = null; + rows.forEach((row, i) => { + if (row.kind === 'result' && row.bucket !== lastBucket) { + nodes.push( +
+ {BUCKET_LABELS[row.bucket] ?? row.bucket} +
, + ); + lastBucket = row.bucket; + } else if (row.kind === 'other-port' && lastBucket !== null) { + // Reset bucket tracker so re-grouping works on subsequent results. + lastBucket = null; + } + + if (row.kind === 'result') { + const Icon = row.icon; + const subContent = ( + <> + {row.sub ? : null} + {row.relatedVia ? ( + + via {row.relatedVia.label} + + ) : null} + + ); + nodes.push( + onSelect(row.href)} + label={} + sub={row.sub || row.relatedVia ? subContent : null} + icon={} + badges={row.badges} + />, + ); + } else if (row.kind === 'other-port') { + nodes.push( + onSelect(row.href)} + label={row.item.label} + sub={`${row.item.portName} · other port`} + />, + ); + } + }); + return nodes; +} + +/** Human-readable bucket labels for the section-header rows. */ +const BUCKET_LABELS: Record = { + clients: 'Clients', + residentialClients: 'Residential clients', + yachts: 'Yachts', + companies: 'Companies', + interests: 'Interests', + residentialInterests: 'Residential interests', + berths: 'Berths', + invoices: 'Invoices', + expenses: 'Expenses', + documents: 'Documents', + files: 'Files', + reminders: 'Reminders', + brochures: 'Brochures', + tags: 'Tags', + navigation: 'Settings & navigation', + notes: 'Notes', +}; + +function Section({ + icon, + label, + children, +}: { + icon: React.ReactNode; + label: string; + children: React.ReactNode; +}) { + return ( +
+
+ {icon} + {label} +
+
{children}
+
+ ); +} + +function Row({ + onSelect, + label, + sub, + icon, + badges, +}: { + onSelect: () => void; + label: React.ReactNode; + sub?: React.ReactNode; + icon?: React.ReactNode; + badges?: { label: string; tone: 'neutral' | 'warning' | 'success' | 'danger' }[]; +}) { + return ( + + ); +} diff --git a/src/components/settings/dashboard-widgets-card.tsx b/src/components/settings/dashboard-widgets-card.tsx new file mode 100644 index 00000000..b952de8c --- /dev/null +++ b/src/components/settings/dashboard-widgets-card.tsx @@ -0,0 +1,77 @@ +'use client'; + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Switch } from '@/components/ui/switch'; +import { useDashboardWidgets } from '@/hooks/use-dashboard-widgets'; + +/** + * Per-user toggle list for dashboard widgets. The dashboard reads the + * same `useDashboardWidgets` hook, so flipping a switch here causes the + * dashboard to reflow on the next visit (or instantly if the user has + * both pages open in different tabs — TanStack Query's optimistic + * update + invalidate handles the cache sync). + * + * Mounted from UserSettings under the id `dashboard` so the dashboard + * "Customize" button can deep-link via `/settings#dashboard`. + */ +export function DashboardWidgetsCard() { + const { allWidgets, visibility, setVisible, setAll, isSaving } = useDashboardWidgets(); + + const visibleCount = Object.values(visibility).filter(Boolean).length; + const allVisible = visibleCount === allWidgets.length; + const allHidden = visibleCount === 0; + + return ( + + +
+
+ Dashboard widgets + + Pick which cards show up on your dashboard. Hidden cards leave no empty space — the + layout reflows to fill the available width. + +
+
+ + +
+
+
+ + {allWidgets.map((w) => ( +
+
+
{w.label}
+

{w.description}

+
+ setVisible(w.id, checked)} + /> +
+ ))} +
+
+ ); +} diff --git a/src/components/settings/user-settings.tsx b/src/components/settings/user-settings.tsx index 3273e410..7f9d0dd7 100644 --- a/src/components/settings/user-settings.tsx +++ b/src/components/settings/user-settings.tsx @@ -15,6 +15,7 @@ import { TimezoneCombobox } from '@/components/shared/timezone-combobox'; import { ImageCropperDialog } from '@/components/shared/image-cropper-dialog'; import { NotificationPreferencesForm } from '@/components/notifications/notification-preferences-form'; import { ReminderDigestForm } from '@/components/notifications/reminder-digest-form'; +import { DashboardWidgetsCard } from '@/components/settings/dashboard-widgets-card'; import { apiFetch } from '@/lib/api/client'; import { primaryTimezoneFor } from '@/lib/i18n/timezones'; import type { CountryCode } from '@/lib/i18n/countries'; @@ -186,7 +187,7 @@ export function UserSettings() {
-
+
Profile @@ -318,6 +319,8 @@ export function UserSettings() { + + Account diff --git a/src/components/shared/berth-picker.tsx b/src/components/shared/berth-picker.tsx new file mode 100644 index 00000000..ed2ab963 --- /dev/null +++ b/src/components/shared/berth-picker.tsx @@ -0,0 +1,187 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { Check, ChevronsUpDown } from 'lucide-react'; +import { useQuery } from '@tanstack/react-query'; + +import { Button } from '@/components/ui/button'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { useDebounce } from '@/hooks/use-debounce'; +import { apiFetch } from '@/lib/api/client'; +import { cn } from '@/lib/utils'; + +interface BerthOption { + id: string; + mooringNumber: string; + area: string | null; + status: string; +} + +interface BerthPickerProps { + value: string | null; + onChange: (berthId: string | null) => void; + /** When set, the dropdown is scoped to berths linked through any of + * this client's interests (via interest_berths.primary). Other berths + * are hidden so the picker mirrors the relationship the rep is + * already building. */ + clientId?: string | null; + placeholder?: string; + disabled?: boolean; +} + +/** + * Searchable berth picker. Free-text search when no client is selected; + * scoped to a client's primary-berth set when `clientId` is provided. + * + * The scoped query fetches the client's interests (limit 25) and + * intersects on `berthId`, which mirrors the relationship semantics the + * rest of the CRM uses ("berths that show up on this client's deals"). + */ +export function BerthPicker({ + value, + onChange, + clientId, + placeholder = 'Select berth...', + disabled, +}: BerthPickerProps) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(''); + const debounced = useDebounce(search, 300); + + // Free-text search path — used when there's no clientId scope. + const { data: searchData } = useQuery<{ data: BerthOption[] }>({ + queryKey: ['berth-picker', 'search', debounced], + queryFn: () => { + const params = new URLSearchParams({ page: '1', limit: '10', order: 'asc' }); + // The list endpoint doesn't accept `search`, so we filter + // client-side; pulling a larger page lets the typeahead feel + // responsive without round-tripping per keystroke. + params.set('limit', '50'); + return apiFetch(`/api/v1/berths?${params.toString()}`); + }, + enabled: open && !clientId, + }); + + // Scoped path — pull this client's interests (with their primary + // berth) and dedupe the berth set. + const { data: clientInterests } = useQuery<{ + data: Array<{ berthId: string | null; berthMooringNumber: string | null }>; + }>({ + queryKey: ['berth-picker', 'client', clientId], + queryFn: () => { + const params = new URLSearchParams({ + page: '1', + limit: '25', + order: 'desc', + includeArchived: 'false', + clientId: clientId!, + }); + return apiFetch(`/api/v1/interests?${params.toString()}`); + }, + enabled: open && !!clientId, + }); + + const options: BerthOption[] = useMemo(() => { + if (clientId) { + const rows = clientInterests?.data ?? []; + const seen = new Set(); + const out: BerthOption[] = []; + for (const r of rows) { + if (!r.berthId || seen.has(r.berthId)) continue; + seen.add(r.berthId); + out.push({ + id: r.berthId, + mooringNumber: r.berthMooringNumber ?? '', + area: null, + status: '', + }); + } + if (!debounced) return out; + const q = debounced.toLowerCase(); + return out.filter((b) => b.mooringNumber.toLowerCase().includes(q)); + } + const rows = searchData?.data ?? []; + if (!debounced) return rows; + const q = debounced.toLowerCase(); + return rows.filter((b) => b.mooringNumber.toLowerCase().includes(q)); + }, [clientId, clientInterests, searchData, debounced]); + + const labelFor = (o: BerthOption) => + o.area ? `Berth ${o.mooringNumber} · ${o.area}` : `Berth ${o.mooringNumber}`; + + const selectedLabel = (() => { + if (!value) return placeholder; + const match = options.find((o) => o.id === value); + return match ? labelFor(match) : `Berth ${value.slice(0, 8)}`; + })(); + + return ( + // `modal` is required when this picker is rendered inside a Sheet / + // Dialog — without it the CommandInput stays focus-blocked by the + // outer Sheet's focus trap and clicks/typing are silently dropped. + + + + + + + + + + {clientId ? 'No berths linked to this client.' : 'No berths found.'} + + + {value ? ( + { + onChange(null); + setOpen(false); + }} + className="text-muted-foreground" + > + Clear selection + + ) : null} + {options.map((o) => ( + { + onChange(o.id); + setOpen(false); + }} + > + + {labelFor(o)} + + ))} + + + + + + ); +} diff --git a/src/components/shared/client-picker.tsx b/src/components/shared/client-picker.tsx index c3f7155a..3e6d6e7f 100644 --- a/src/components/shared/client-picker.tsx +++ b/src/components/shared/client-picker.tsx @@ -58,7 +58,10 @@ export function ClientPicker({ })(); return ( - + // `modal` is required when this picker is rendered inside a Sheet / + // Dialog — without it the CommandInput stays focus-blocked by the + // outer Sheet's focus trap and clicks/typing are silently dropped. + ); diff --git a/src/components/shared/interest-picker.tsx b/src/components/shared/interest-picker.tsx new file mode 100644 index 00000000..17e5567d --- /dev/null +++ b/src/components/shared/interest-picker.tsx @@ -0,0 +1,143 @@ +'use client'; + +import { useState } from 'react'; +import { Check, ChevronsUpDown } from 'lucide-react'; +import { useQuery } from '@tanstack/react-query'; + +import { Button } from '@/components/ui/button'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { useDebounce } from '@/hooks/use-debounce'; +import { apiFetch } from '@/lib/api/client'; +import { cn } from '@/lib/utils'; + +interface InterestOption { + id: string; + clientName: string | null; + berthMooringNumber: string | null; + pipelineStage: string; +} + +interface InterestPickerProps { + value: string | null; + onChange: (interestId: string | null) => void; + /** When set, only this client's interests are listed. */ + clientId?: string | null; + placeholder?: string; + disabled?: boolean; +} + +/** + * Searchable interest picker. Mirrors ClientPicker. When `clientId` is + * provided the dropdown scopes to that client — so picking the client + * first naturally narrows the interest options. + */ +export function InterestPicker({ + value, + onChange, + clientId, + placeholder = 'Select interest...', + disabled, +}: InterestPickerProps) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(''); + const debounced = useDebounce(search, 300); + + const { data } = useQuery<{ data: InterestOption[] }>({ + queryKey: ['interest-picker', clientId ?? null, debounced], + queryFn: () => { + const params = new URLSearchParams({ + page: '1', + limit: '10', + order: 'desc', + includeArchived: 'false', + }); + if (debounced) params.set('search', debounced); + if (clientId) params.set('clientId', clientId); + return apiFetch(`/api/v1/interests?${params.toString()}`); + }, + enabled: open, + }); + + const options = data?.data ?? []; + + const labelFor = (o: InterestOption) => { + const parts = [o.clientName ?? 'Unknown client']; + if (o.berthMooringNumber) parts.push(`Berth ${o.berthMooringNumber}`); + parts.push(o.pipelineStage.replace(/_/g, ' ')); + return parts.join(' · '); + }; + + const selectedLabel = (() => { + if (!value) return placeholder; + const match = options.find((o) => o.id === value); + return match ? labelFor(match) : `Interest ${value.slice(0, 8)}`; + })(); + + return ( + // `modal` is required when this picker is rendered inside a Sheet / + // Dialog — without it the CommandInput stays focus-blocked by the + // outer Sheet's focus trap and clicks/typing are silently dropped. + + + + + + + + + No interests found. + + {value ? ( + { + onChange(null); + setOpen(false); + }} + className="text-muted-foreground" + > + Clear selection + + ) : null} + {options.map((o) => ( + { + onChange(o.id); + setOpen(false); + }} + > + + {labelFor(o)} + + ))} + + + + + + ); +} diff --git a/src/components/shared/list-card.tsx b/src/components/shared/list-card.tsx index ae4a6f70..861abbf0 100644 --- a/src/components/shared/list-card.tsx +++ b/src/components/shared/list-card.tsx @@ -31,8 +31,8 @@ interface ListCardProps { * Shared shell for every mobile list card. Wraps the body in a Link to the * detail page, paints an optional status accent bar on the left edge, and * exposes a top-right slot for an actions menu. Touch/hover feedback comes - * from a soft `hover:bg-muted/30` + `active:bg-muted/50` tint, no shadow - * shifts (which feel jittery on mobile). + * from a soft brand-blue tint via `hover:bg-accent/40` + `active:bg-accent`, + * no shadow shifts (which feel jittery on mobile). */ export function ListCard({ href, @@ -52,7 +52,7 @@ export function ListCard({
diff --git a/src/components/shared/owner-picker.tsx b/src/components/shared/owner-picker.tsx index 5b368159..5ead8b23 100644 --- a/src/components/shared/owner-picker.tsx +++ b/src/components/shared/owner-picker.tsx @@ -64,10 +64,35 @@ export function OwnerPicker({ const options = data?.data ?? []; - // Selected display label - show entity's name from current options if - // available, otherwise a truncated id fallback. + // Resolve the current value's display name even before the picker is opened. + // Without this primer query the trigger button rendered "Client <8-char-id>" + // on first paint and only filled in the real name after the user opened the + // dropdown (which kicked the list query). The lookup hits a per-id endpoint + // when possible and falls back to scanning the cached options array. + const valueLookupEndpoint = value + ? value.type === 'client' + ? `/api/v1/clients/${value.id}` + : `/api/v1/companies/${value.id}` + : null; + + const { data: valueDetail } = useQuery<{ + data: { id: string; name?: string | null; fullName?: string | null }; + }>({ + queryKey: ['owner-picker-resolve', value?.type, value?.id], + queryFn: () => apiFetch(valueLookupEndpoint!), + enabled: !!value && !!valueLookupEndpoint, + staleTime: 60_000, + }); + + // Selected display label - prefer the resolved entity name; fall back to a + // truncated id only when both the primer query and the options list miss. const selectedLabel = (() => { if (!value) return placeholder; + if (valueDetail?.data) { + const name = + value.type === 'client' ? valueDetail.data.fullName : valueDetail.data.name; + if (name) return name; + } const match = options.find((o) => o.id === value.id); if (match) { return type === 'client' @@ -80,7 +105,7 @@ export function OwnerPicker({ })(); return ( - + + ))} +
+ { + const raw = e.target.value; + setCustomStr(raw); + if (raw === '') { + onChange(null); + return; + } + const n = Number.parseInt(raw, 10); + if (Number.isFinite(n) && n > 0) onChange(n); + }} + /> +
+ ); +} + +function labelFor(days: number): string { + if (days === 1) return '1 day'; + if (days === 7) return '1 week'; + if (days === 14) return '2 weeks'; + if (days === 30) return '1 month'; + return `${days} days`; +} diff --git a/src/components/shared/subdivision-combobox.tsx b/src/components/shared/subdivision-combobox.tsx index 81a215d2..9b676dd5 100644 --- a/src/components/shared/subdivision-combobox.tsx +++ b/src/components/shared/subdivision-combobox.tsx @@ -75,7 +75,7 @@ export function SubdivisionCombobox({ else triggerLabel = placeholder; return ( - +