feat(ui): broad consistency sweep — sources, dates, comboboxes, milestones

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 <sm
- contacts row restructured so Primary toggle + Remove get their own strip
- customize-dashboard footer stacks vertically on mobile; Done full-width
- interest-form client/berth pickers no longer cmdk-filter on UUID (typing
  "Carlos" now returns Carlos Vega instead of "No clients found")

Data + consistency
- SOURCES + SOURCE_LABELS + formatSource() in lib/constants; 9 surfaces
  now resolve interest/client source from one place
- INTEREST_OUTCOMES adds lost_other (picker, badge, timeline)
- Berth options natural-sort A1 → A2 → … → A10 via lib/utils/mooring-sort
- archiver downgraded ^8 → ^7.0.1 so the GDPR export route compiles
- TableBody last-row uses border-b-0 (not border-0); colored left-accent
  on the bottom berth row now renders
- Hide Invite-to-Portal until port setting === true (was !== false default-show)
- OwnerPicker primer query resolves entity name on first paint (no more
  UUID flash before the popover opens)

Terminology
- Replaced user-facing "Documenso" with "signing service" / "Generated EOI" /
  "Manual EOI" in 8 components (admin/internal references kept)
- Plainer status-change copy on berth-detail-header

Forms + editing
- InlineEditableField gained a `date` variant (native picker); applied to
  company incorporation date and ready for other YYYY-MM-DD plaintext fields
- Inline source picker on interest-tabs detail (was free text)
- TagPicker self-hides when port has no tags AND nothing is selected
- New ReminderDaysInput with preset chips (1d / 3d / 1wk / 2wk / 1mo / custom)
- Compose dialog follow-up is now a toggle that reveals datetime picker

Pipeline milestones
- changeStageSchema accepts optional milestoneDate; service stamps it on the
  matching date column instead of always using now
- MilestoneAdvanceButton popover collects a back-date before stage advance
- Applied to every "Mark X manually" surface on the interest overview

EOI / linked-berths polish
- Add-bypass row aligned inline with toggle descriptions
- Tooltips on "Specifically pitching" / "Mark in EOI bundle" explain their
  legal vs. public-map consequences

Surfaces
- Companies list now has the column picker + persisted hidden-column prefs
- NotesList aggregate flag enabled on clients, companies, residential_clients
  (yachts already aggregated)

ft/m unit toggle (interim, before drift fix)
- "Berth size desired" gets a section-level ft/m toggle; per-field hint shows
  the converted value. Storage stays canonical-ft for now; the drift-safe
  persistence migration is the next step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 14:50:58 +02:00
parent 638000bb58
commit 3ffee79f3f
132 changed files with 5784 additions and 997 deletions

2
.gitignore vendored
View File

@@ -29,6 +29,8 @@ docker-compose.override.yml
# Ad-hoc screenshots / scratch artifacts at repo root # Ad-hoc screenshots / scratch artifacts at repo root
/*.png /*.png
/*.jpg /*.jpg
# Local-only dashboard widget-combo screenshots — regenerated by manual testing
/combos/
# Legacy Nuxt portal — kept on disk for reference, not tracked here # Legacy Nuxt portal — kept on disk for reference, not tracked here
/client-portal/ /client-portal/

View File

@@ -52,6 +52,17 @@ const securityHeaders = [
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: 'standalone', 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: [ serverExternalPackages: [
'pino', 'pino',
'pino-pretty', 'pino-pretty',

View File

@@ -60,7 +60,7 @@
"@tanstack/react-query-devtools": "^5.100.9", "@tanstack/react-query-devtools": "^5.100.9",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@types/pdfkit": "^0.17.6", "@types/pdfkit": "^0.17.6",
"archiver": "^8.0.0", "archiver": "^7.0.1",
"better-auth": "^1.6.9", "better-auth": "^1.6.9",
"bullmq": "^5.76.6", "bullmq": "^5.76.6",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",

258
pnpm-lock.yaml generated
View File

@@ -110,8 +110,8 @@ importers:
specifier: ^0.17.6 specifier: ^0.17.6
version: 0.17.6 version: 0.17.6
archiver: archiver:
specifier: ^8.0.0 specifier: ^7.0.1
version: 8.0.0 version: 7.0.1
better-auth: better-auth:
specifier: ^1.6.9 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) 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': '@ioredis/commands@1.5.1':
resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==} 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': '@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -1042,6 +1046,10 @@ packages:
'@pinojs/redact@0.4.0': '@pinojs/redact@0.4.0':
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} 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': '@playwright/test@1.59.1':
resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==} resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -2121,6 +2129,10 @@ packages:
resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==}
engines: {node: '>=18'} engines: {node: '>=18'}
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
ansi-regex@6.2.2: ansi-regex@6.2.2:
resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -2140,9 +2152,13 @@ packages:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
archiver@8.0.0: archiver-utils@5.0.2:
resolution: {integrity: sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g==} resolution: {integrity: sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==}
engines: {node: '>=18'} engines: {node: '>= 14'}
archiver@7.0.1:
resolution: {integrity: sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==}
engines: {node: '>= 14'}
arg@5.0.2: arg@5.0.2:
resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
@@ -2390,6 +2406,9 @@ packages:
brace-expansion@1.1.14: brace-expansion@1.1.14:
resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==}
brace-expansion@2.1.0:
resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==}
brace-expansion@5.0.6: brace-expansion@5.0.6:
resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==}
engines: {node: 18 || 20 || >=22} engines: {node: 18 || 20 || >=22}
@@ -2543,9 +2562,9 @@ packages:
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
compress-commons@7.0.1: compress-commons@6.0.2:
resolution: {integrity: sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ==} resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==}
engines: {node: '>=18'} engines: {node: '>= 14'}
concat-map@0.0.1: concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
@@ -2569,9 +2588,9 @@ packages:
engines: {node: '>=0.8'} engines: {node: '>=0.8'}
hasBin: true hasBin: true
crc32-stream@7.0.1: crc32-stream@6.0.0:
resolution: {integrity: sha512-IBWsY8xznyQrcHn8h4bC8/4ErNke5elzgG8GcqF4RFPw6aHkWWRc7Tgw6upjaTX/CT/yQgqYENkxYsTYN+hW2g==} resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==}
engines: {node: '>=18'} engines: {node: '>= 14'}
cron-parser@4.9.0: cron-parser@4.9.0:
resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==}
@@ -2852,12 +2871,18 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
electron-to-chromium@1.5.352: electron-to-chromium@1.5.352:
resolution: {integrity: sha512-9wHk8x6dyuimoe18EdiDPWKExNdxYqo4fn4FwOVVper6RxT3cmpBwBkWWfSOCYJjQdIco/nPhJhNLmn4Ufg1Yg==} resolution: {integrity: sha512-9wHk8x6dyuimoe18EdiDPWKExNdxYqo4fn4FwOVVper6RxT3cmpBwBkWWfSOCYJjQdIco/nPhJhNLmn4Ufg1Yg==}
emoji-regex@10.6.0: emoji-regex@10.6.0:
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
emoji-regex@9.2.2: emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
@@ -3164,6 +3189,10 @@ packages:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
foreground-child@3.3.1:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
fraction.js@5.3.4: fraction.js@5.3.4:
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
@@ -3230,6 +3259,11 @@ packages:
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
engines: {node: '>=10.13.0'} 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: globals@14.0.0:
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -3242,6 +3276,9 @@ packages:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'} 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: has-bigints@1.1.0:
resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -3403,6 +3440,10 @@ packages:
resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==}
engines: {node: '>= 0.4'} 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: is-fullwidth-code-point@5.1.0:
resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -3447,9 +3488,9 @@ packages:
resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
is-stream@4.0.1: is-stream@2.0.1:
resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
engines: {node: '>=18'} engines: {node: '>=8'}
is-string@1.1.1: is-string@1.1.1:
resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==}
@@ -3514,6 +3555,9 @@ packages:
resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
jackspeak@3.4.3:
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
jiti@1.21.7: jiti@1.21.7:
resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
hasBin: true hasBin: true
@@ -3726,6 +3770,9 @@ packages:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true hasBin: true
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
lucide-react@1.14.0: lucide-react@1.14.0:
resolution: {integrity: sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==} resolution: {integrity: sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==}
peerDependencies: peerDependencies:
@@ -3785,6 +3832,14 @@ packages:
minimatch@3.1.5: minimatch@3.1.5:
resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} 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: minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
@@ -3792,6 +3847,10 @@ packages:
resolution: {integrity: sha512-E737MgufW8CeQAsTAtnEMrxZ9scMSf29kkhZoXzDTKj/Jszzo2SfeZUH9wbDQH2Rsq6TCtl/yQL0+XdVKZansQ==} resolution: {integrity: sha512-E737MgufW8CeQAsTAtnEMrxZ9scMSf29kkhZoXzDTKj/Jszzo2SfeZUH9wbDQH2Rsq6TCtl/yQL0+XdVKZansQ==}
engines: {node: ^16 || ^18 || >=20} 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: mongodb-connection-string-url@7.0.1:
resolution: {integrity: sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==} resolution: {integrity: sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==}
engines: {node: '>=20.19.0'} engines: {node: '>=20.19.0'}
@@ -4014,6 +4073,9 @@ packages:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'} engines: {node: '>=10'}
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
package-manager-detector@1.6.0: package-manager-detector@1.6.0:
resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==}
@@ -4048,6 +4110,10 @@ packages:
path-parse@1.0.7: path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} 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: pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
@@ -4306,9 +4372,8 @@ packages:
resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
readdir-glob@3.0.0: readdir-glob@1.1.3:
resolution: {integrity: sha512-AhNB2KgKeVJr16nK9LLZbJNWnYoT23ZrumNKFDebHBdkC8KHSqWo871JAUhoWC/RtjEVdqNMFpM6qrwRbaUqpw==} resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==}
engines: {node: '>=18'}
readdirp@3.6.0: readdirp@3.6.0:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
@@ -4608,6 +4673,14 @@ packages:
resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==}
engines: {node: '>=0.6.19'} 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: string-width@7.2.0:
resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -4645,6 +4718,10 @@ packages:
string_decoder@1.3.0: string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} 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: strip-ansi@7.2.0:
resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -5032,6 +5109,14 @@ packages:
resolution: {integrity: sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==} resolution: {integrity: sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==}
engines: {node: '>=20'} 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: wrap-ansi@9.0.2:
resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -5080,9 +5165,9 @@ packages:
resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==}
engines: {node: '>=18'} engines: {node: '>=18'}
zip-stream@7.0.5: zip-stream@6.0.1:
resolution: {integrity: sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w==} resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==}
engines: {node: '>=18'} engines: {node: '>= 14'}
zlibjs@0.3.1: zlibjs@0.3.1:
resolution: {integrity: sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==} resolution: {integrity: sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==}
@@ -5506,6 +5591,15 @@ snapshots:
'@ioredis/commands@1.5.1': {} '@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': '@jridgewell/gen-mapping@0.3.13':
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
@@ -5662,6 +5756,9 @@ snapshots:
'@pinojs/redact@0.4.0': {} '@pinojs/redact@0.4.0': {}
'@pkgjs/parseargs@0.11.0':
optional: true
'@playwright/test@1.59.1': '@playwright/test@1.59.1':
dependencies: dependencies:
playwright: 1.59.1 playwright: 1.59.1
@@ -6740,6 +6837,8 @@ snapshots:
dependencies: dependencies:
environment: 1.1.0 environment: 1.1.0
ansi-regex@5.0.1: {}
ansi-regex@6.2.2: {} ansi-regex@6.2.2: {}
ansi-styles@4.3.0: ansi-styles@4.3.0:
@@ -6755,17 +6854,25 @@ snapshots:
normalize-path: 3.0.0 normalize-path: 3.0.0
picomatch: 2.3.2 picomatch: 2.3.2
archiver@8.0.0: archiver-utils@5.0.2:
dependencies: dependencies:
async: 3.2.6 glob: 10.5.0
buffer-crc32: 1.0.0 graceful-fs: 4.2.11
is-stream: 4.0.1 is-stream: 2.0.1
lazystream: 1.0.1 lazystream: 1.0.1
lodash: 4.18.1
normalize-path: 3.0.0 normalize-path: 3.0.0
readable-stream: 4.7.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 tar-stream: 3.2.0
zip-stream: 7.0.5 zip-stream: 6.0.1
transitivePeerDependencies: transitivePeerDependencies:
- bare-abort-controller - bare-abort-controller
- bare-buffer - bare-buffer
@@ -6984,6 +7091,10 @@ snapshots:
balanced-match: 1.0.2 balanced-match: 1.0.2
concat-map: 0.0.1 concat-map: 0.0.1
brace-expansion@2.1.0:
dependencies:
balanced-match: 1.0.2
brace-expansion@5.0.6: brace-expansion@5.0.6:
dependencies: dependencies:
balanced-match: 4.0.4 balanced-match: 4.0.4
@@ -7141,11 +7252,11 @@ snapshots:
commander@4.1.1: {} commander@4.1.1: {}
compress-commons@7.0.1: compress-commons@6.0.2:
dependencies: dependencies:
crc-32: 1.2.2 crc-32: 1.2.2
crc32-stream: 7.0.1 crc32-stream: 6.0.0
is-stream: 4.0.1 is-stream: 2.0.1
normalize-path: 3.0.0 normalize-path: 3.0.0
readable-stream: 4.7.0 readable-stream: 4.7.0
@@ -7164,7 +7275,7 @@ snapshots:
crc-32@1.2.2: {} crc-32@1.2.2: {}
crc32-stream@7.0.1: crc32-stream@6.0.0:
dependencies: dependencies:
crc-32: 1.2.2 crc-32: 1.2.2
readable-stream: 4.7.0 readable-stream: 4.7.0
@@ -7340,10 +7451,14 @@ snapshots:
es-errors: 1.3.0 es-errors: 1.3.0
gopd: 1.2.0 gopd: 1.2.0
eastasianwidth@0.2.0: {}
electron-to-chromium@1.5.352: {} electron-to-chromium@1.5.352: {}
emoji-regex@10.6.0: {} emoji-regex@10.6.0: {}
emoji-regex@8.0.0: {}
emoji-regex@9.2.2: {} emoji-regex@9.2.2: {}
encoding-japanese@2.2.0: {} encoding-japanese@2.2.0: {}
@@ -7834,6 +7949,11 @@ snapshots:
dependencies: dependencies:
is-callable: 1.2.7 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: {} fraction.js@5.3.4: {}
fsevents@2.3.2: fsevents@2.3.2:
@@ -7911,6 +8031,15 @@ snapshots:
dependencies: dependencies:
is-glob: 4.0.3 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: {} globals@14.0.0: {}
globalthis@1.0.4: globalthis@1.0.4:
@@ -7920,6 +8049,8 @@ snapshots:
gopd@1.2.0: {} gopd@1.2.0: {}
graceful-fs@4.2.11: {}
has-bigints@1.1.0: {} has-bigints@1.1.0: {}
has-flag@4.0.0: {} has-flag@4.0.0: {}
@@ -8088,6 +8219,8 @@ snapshots:
dependencies: dependencies:
call-bound: 1.0.4 call-bound: 1.0.4
is-fullwidth-code-point@3.0.0: {}
is-fullwidth-code-point@5.1.0: is-fullwidth-code-point@5.1.0:
dependencies: dependencies:
get-east-asian-width: 1.6.0 get-east-asian-width: 1.6.0
@@ -8130,7 +8263,7 @@ snapshots:
dependencies: dependencies:
call-bound: 1.0.4 call-bound: 1.0.4
is-stream@4.0.1: {} is-stream@2.0.1: {}
is-string@1.1.1: is-string@1.1.1:
dependencies: dependencies:
@@ -8195,6 +8328,12 @@ snapshots:
has-symbols: 1.1.0 has-symbols: 1.1.0
set-function-name: 2.0.2 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: {} jiti@1.21.7: {}
jose@6.2.3: {} jose@6.2.3: {}
@@ -8383,6 +8522,8 @@ snapshots:
dependencies: dependencies:
js-tokens: 4.0.0 js-tokens: 4.0.0
lru-cache@10.4.3: {}
lucide-react@1.14.0(react@19.2.6): lucide-react@1.14.0(react@19.2.6):
dependencies: dependencies:
react: 19.2.6 react: 19.2.6
@@ -8446,6 +8587,14 @@ snapshots:
dependencies: dependencies:
brace-expansion: 1.1.14 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: {} minimist@1.2.8: {}
minio@8.0.7: minio@8.0.7:
@@ -8464,6 +8613,8 @@ snapshots:
through2: 4.0.2 through2: 4.0.2
xml2js: 0.6.2 xml2js: 0.6.2
minipass@7.1.3: {}
mongodb-connection-string-url@7.0.1: mongodb-connection-string-url@7.0.1:
dependencies: dependencies:
'@types/whatwg-url': 13.0.0 '@types/whatwg-url': 13.0.0
@@ -8673,6 +8824,8 @@ snapshots:
dependencies: dependencies:
p-limit: 3.1.0 p-limit: 3.1.0
package-json-from-dist@1.0.1: {}
package-manager-detector@1.6.0: {} package-manager-detector@1.6.0: {}
pako@0.2.9: {} pako@0.2.9: {}
@@ -8698,6 +8851,11 @@ snapshots:
path-parse@1.0.7: {} path-parse@1.0.7: {}
path-scurry@1.11.1:
dependencies:
lru-cache: 10.4.3
minipass: 7.1.3
pathe@2.0.3: {} pathe@2.0.3: {}
pdf-lib@1.17.1: pdf-lib@1.17.1:
@@ -8961,9 +9119,9 @@ snapshots:
process: 0.11.10 process: 0.11.10
string_decoder: 1.3.0 string_decoder: 1.3.0
readdir-glob@3.0.0: readdir-glob@1.1.3:
dependencies: dependencies:
minimatch: 10.2.5 minimatch: 5.1.9
readdirp@3.6.0: readdirp@3.6.0:
dependencies: dependencies:
@@ -9350,6 +9508,18 @@ snapshots:
string-argv@0.3.2: {} 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: string-width@7.2.0:
dependencies: dependencies:
emoji-regex: 10.6.0 emoji-regex: 10.6.0
@@ -9419,6 +9589,10 @@ snapshots:
dependencies: dependencies:
safe-buffer: 5.2.1 safe-buffer: 5.2.1
strip-ansi@6.0.1:
dependencies:
ansi-regex: 5.0.1
strip-ansi@7.2.0: strip-ansi@7.2.0:
dependencies: dependencies:
ansi-regex: 6.2.2 ansi-regex: 6.2.2
@@ -9868,6 +10042,18 @@ snapshots:
string-width: 8.2.1 string-width: 8.2.1
strip-ansi: 7.2.0 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: wrap-ansi@9.0.2:
dependencies: dependencies:
ansi-styles: 6.2.3 ansi-styles: 6.2.3
@@ -9896,10 +10082,10 @@ snapshots:
yoctocolors@2.1.2: {} yoctocolors@2.1.2: {}
zip-stream@7.0.5: zip-stream@6.0.1:
dependencies: dependencies:
compress-commons: 7.0.1 archiver-utils: 5.0.2
normalize-path: 3.0.0 compress-commons: 6.0.2
readable-stream: 4.7.0 readable-stream: 4.7.0
zlibjs@0.3.1: {} zlibjs@0.3.1: {}

View File

@@ -1,5 +1,13 @@
import { AlertsPageShell } from '@/components/alerts/alerts-page-shell'; import { redirect } from 'next/navigation';
export default function AlertsPage() { // Legacy /alerts route — merged into /inbox in 2026-05-11. The hash
return <AlertsPageShell />; // 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`);
} }

View File

@@ -23,6 +23,7 @@ import { ExpenseFormDialog } from '@/components/expenses/expense-form-dialog';
import { ExpenseCard } from '@/components/expenses/expense-card'; import { ExpenseCard } from '@/components/expenses/expense-card';
import { expenseFilterDefinitions } from '@/components/expenses/expense-filters'; import { expenseFilterDefinitions } from '@/components/expenses/expense-filters';
import { getExpenseColumns, type ExpenseRow } from '@/components/expenses/expense-columns'; 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 { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
@@ -33,6 +34,7 @@ export default function ExpensesPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
useCreateFromUrl(() => setCreateOpen(true));
const [editExpense, setEditExpense] = useState<ExpenseRow | null>(null); const [editExpense, setEditExpense] = useState<ExpenseRow | null>(null);
const [archiveExpense, setArchiveExpense] = useState<ExpenseRow | null>(null); const [archiveExpense, setArchiveExpense] = useState<ExpenseRow | null>(null);

View File

@@ -0,0 +1,5 @@
import { InboxPageShell } from '@/components/inbox/inbox-page-shell';
export default function InboxPage() {
return <InboxPageShell />;
}

View File

@@ -1,5 +1,12 @@
import { ReminderList } from '@/components/reminders/reminder-list'; import { redirect } from 'next/navigation';
export default function RemindersPage() { // Legacy /reminders route — merged into /inbox in 2026-05-11. The hash
return <ReminderList />; // 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`);
} }

View File

@@ -59,7 +59,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
email: session.user.email, email: session.user.email,
}} }}
/> />
<main className="flex-1 overflow-y-auto bg-background pt-3 px-6 pb-6"> <main className="flex-1 overflow-y-auto bg-background px-6 pt-3 pb-6">
{children} {children}
</main> </main>
</div> </div>

View File

@@ -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 });
}),
);

View File

@@ -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 });
}),
);

View File

@@ -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 });
}),
);

View File

@@ -6,14 +6,21 @@ import { parseBody } from '@/lib/api/route-helpers';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { userProfiles, type UserPreferences } from '@/lib/db/schema/users'; import { userProfiles, type UserPreferences } from '@/lib/db/schema/users';
import { errorResponse, NotFoundError } from '@/lib/errors'; import { errorResponse, NotFoundError } from '@/lib/errors';
import { getPortReminderConfig } from '@/lib/services/port-config';
import { updateUserPreferencesSchema } from '@/lib/validators/user-preferences'; import { updateUserPreferencesSchema } from '@/lib/validators/user-preferences';
export const GET = withAuth(async (_req, ctx) => { export const GET = withAuth(async (_req, ctx) => {
try { try {
const profile = await db.query.userProfiles.findFirst({ const [profile, portReminders] = await Promise.all([
where: eq(userProfiles.userId, ctx.userId), 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) { } catch (error) {
return errorResponse(error); return errorResponse(error);
} }

View File

@@ -17,8 +17,8 @@
--secondary-foreground: 0 0% 100%; --secondary-foreground: 0 0% 100%;
--muted: 210 11% 96%; /* #f1f3f5 */ --muted: 210 11% 96%; /* #f1f3f5 */
--muted-foreground: 228 10% 49%; /* #71768a */ --muted-foreground: 228 10% 49%; /* #71768a */
--accent: 190 18% 60%; /* #83aab1 */ --accent: 213 60% 95%; /* #eef3fb — soft brand-blue tint for hover/focus */
--accent-foreground: 0 0% 100%; --accent-foreground: 224 39% 19%; /* dark navy text for contrast on light bg */
--destructive: 0 65% 51%; /* #d32f2f */ --destructive: 0 65% 51%; /* #d32f2f */
--destructive-foreground: 0 0% 100%; --destructive-foreground: 0 0% 100%;
--border: 227 10% 82%; /* #cdcfd6 */ --border: 227 10% 82%; /* #cdcfd6 */
@@ -58,8 +58,8 @@
--secondary-foreground: 227 10% 82%; --secondary-foreground: 227 10% 82%;
--muted: 224 39% 18%; --muted: 224 39% 18%;
--muted-foreground: 228 10% 49%; --muted-foreground: 228 10% 49%;
--accent: 190 18% 50%; --accent: 224 39% 24%; /* subtle elevation above card for hover/focus */
--accent-foreground: 0 0% 100%; --accent-foreground: 227 10% 91%; /* light text on dark accent */
--destructive: 0 72% 63%; --destructive: 0 72% 63%;
--destructive-foreground: 0 0% 100%; --destructive-foreground: 0 0% 100%;
--border: 224 35% 28%; --border: 224 35% 28%;
@@ -91,6 +91,11 @@
body { body {
@apply bg-background text-foreground font-sans antialiased; @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 */ /* 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-color: transparent !important;
--tw-ring-offset-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;
}

View File

@@ -20,6 +20,7 @@ import {
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { SUPPORTED_CURRENCIES } from '@/lib/utils/currency';
interface Setting { interface Setting {
key: string; key: string;
@@ -218,6 +219,15 @@ const KNOWN_SETTINGS: Array<{
type: 'boolean', type: 'boolean',
defaultValue: true, 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() { export function SettingsManager() {

View File

@@ -30,7 +30,7 @@ export function AlertRail() {
<div className="flex items-baseline justify-between"> <div className="flex items-baseline justify-between">
<h2 className="text-sm font-semibold tracking-tight">Alerts</h2> <h2 className="text-sm font-semibold tracking-tight">Alerts</h2>
<Link <Link
href={portSlug ? (`/${portSlug}/alerts` as never) : ('/alerts' as never)} href={portSlug ? (`/${portSlug}/inbox#alerts` as never) : ('/inbox#alerts' as never)}
className="text-xs text-muted-foreground hover:text-foreground" className="text-xs text-muted-foreground hover:text-foreground"
> >
View all View all
@@ -53,7 +53,7 @@ export function AlertRail() {
))} ))}
{overflow > 0 ? ( {overflow > 0 ? (
<Link <Link
href={portSlug ? (`/${portSlug}/alerts` as never) : ('/alerts' as never)} href={portSlug ? (`/${portSlug}/inbox#alerts` as never) : ('/inbox#alerts' as never)}
className="block rounded-lg border border-dashed border-border px-3 py-2 text-center text-xs text-muted-foreground transition-colors hover:bg-accent" className="block rounded-lg border border-dashed border-border px-3 py-2 text-center text-xs text-muted-foreground transition-colors hover:bg-accent"
> >
+{overflow} more - view all +{overflow} more - view all

View File

@@ -10,7 +10,19 @@ import { AlertCard, AlertCardEmpty } from './alert-card';
import { useAlertCount, useAlertList, useAlertRealtime } from './use-alerts'; import { useAlertCount, useAlertList, useAlertRealtime } from './use-alerts';
import type { AlertStatus } from './types'; 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<AlertStatus>('open'); const [tab, setTab] = useState<AlertStatus>('open');
const { data: count } = useAlertCount(); const { data: count } = useAlertCount();
const { data, isLoading } = useAlertList(tab); const { data, isLoading } = useAlertList(tab);
@@ -20,19 +32,21 @@ export function AlertsPageShell() {
const alerts = data?.data ?? []; const alerts = data?.data ?? [];
return ( return (
<div className="space-y-6"> <div className={embedded ? 'space-y-3' : 'space-y-6'}>
<PageHeader {!embedded ? (
title="Alerts" <PageHeader
eyebrow="Operational" title="Alerts"
description="Rules-based signals about pipeline, agreements, expenses, and access" eyebrow="Operational"
kpiLine={ description="Rules-based signals about pipeline, agreements, expenses, and access"
<span> kpiLine={
<ShieldAlert className="mr-1 inline h-3 w-3" aria-hidden /> <span>
{total} active <ShieldAlert className="mr-1 inline h-3 w-3" aria-hidden />
</span> {total} active
} </span>
variant="gradient" }
/> variant="gradient"
/>
) : null}
<Tabs value={tab} onValueChange={(v) => setTab(v as AlertStatus)}> <Tabs value={tab} onValueChange={(v) => setTab(v as AlertStatus)}>
<TabsList> <TabsList>

View File

@@ -1,6 +1,6 @@
'use client'; '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 { useRouter, useParams } from 'next/navigation';
import { Button } from '@/components/ui/button'; 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. // already conveyed by the pill below, so the stripe is dock-keyed.
const accentClass = mooringLetterDot(berth.mooringNumber) ?? 'bg-slate-300'; const accentClass = mooringLetterDot(berth.mooringNumber) ?? 'bg-slate-300';
// Dimensions string // Dimensions string — Length × Width × Draft (each segment is optional).
let dimText: string | null = null; // The avatar already conveys the mooring number, so this becomes the
if (berth.lengthM || berth.widthM) { // primary "what is this berth" line.
const l = berth.lengthM ?? '?'; const dimParts: string[] = [];
const w = berth.widthM ?? '?'; if (berth.lengthM) dimParts.push(`${berth.lengthM}m`);
dimText = `${l}m × ${w}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[] = []; const metaParts: string[] = [];
if (dimText) metaParts.push(dimText); if (boatCapacityText) metaParts.push(boatCapacityText);
if (waterDepthText) metaParts.push(waterDepthText);
if (berth.price) if (berth.price)
metaParts.push(formatCurrency(berth.price, berth.priceCurrency, { maxFractionDigits: 0 })); metaParts.push(formatCurrency(berth.price, berth.priceCurrency, { maxFractionDigits: 0 }));
if (powerText) metaParts.push(powerText);
const tags = berth.tags ?? []; const tags = berth.tags ?? [];
@@ -101,26 +134,27 @@ export function BerthCard({ berth }: BerthCardProps) {
</DropdownMenu> </DropdownMenu>
} }
> >
<div className="flex items-start gap-3"> <div className="flex items-center gap-3">
<ListCardAvatar icon={<Anchor className="h-5 w-5" />} /> {/* 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. */}
<ListCardAvatar
initials={berth.mooringNumber}
className="text-base font-bold tracking-tight"
/>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
{/* Title row + spacer for actions button */} {/* Primary line: dimensions (L × W × Draft). The avatar
<div className="flex items-start justify-between gap-2"> already carries the area letter, so this slot becomes the
<h3 className="truncate text-base font-semibold tracking-tight text-foreground"> "what fits here" answer. Falls back gracefully when
{berth.mooringNumber} dimensions aren't recorded yet. */}
</h3> <div className="flex items-center justify-between gap-2">
<p className="min-w-0 truncate text-sm font-semibold text-foreground">
{dimText ?? <span className="font-normal text-muted-foreground">No dimensions</span>}
</p>
<span aria-hidden className="block h-9 w-9 shrink-0" /> <span aria-hidden className="block h-9 w-9 shrink-0" />
</div> </div>
{/* Area subtitle */} {/* Meta line: tenure · price · power. All optional. */}
{berth.area ? (
<p className="mt-0.5 inline-flex items-center gap-1 truncate text-sm text-muted-foreground">
<MapPin className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" aria-hidden />
<span className="truncate">{berth.area}</span>
</p>
) : null}
{/* Dimensions · Price meta line */}
{metaParts.length > 0 ? ( {metaParts.length > 0 ? (
<div className="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-xs text-muted-foreground"> <div className="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-xs text-muted-foreground">
{metaParts.map((part, i) => ( {metaParts.map((part, i) => (
@@ -132,8 +166,8 @@ export function BerthCard({ berth }: BerthCardProps) {
</div> </div>
) : null} ) : null}
{/* Status pill */} {/* Status pill + tags */}
<div className="mt-1.5"> <div className="mt-1.5 flex flex-wrap items-center gap-1.5">
<span <span
className={cn( className={cn(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium', 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium',
@@ -142,21 +176,15 @@ export function BerthCard({ berth }: BerthCardProps) {
> >
{statusLabel} {statusLabel}
</span> </span>
{tags.slice(0, 2).map((tag) => (
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
))}
{tags.length > 2 ? (
<span className="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-xs text-secondary-foreground">
+{tags.length - 2}
</span>
) : null}
</div> </div>
{/* Tags */}
{tags.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1">
{tags.slice(0, 2).map((tag) => (
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
))}
{tags.length > 2 ? (
<span className="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-xs text-secondary-foreground">
+{tags.length - 2}
</span>
) : null}
</div>
) : null}
</div> </div>
</div> </div>
</ListCard> </ListCard>

View File

@@ -222,8 +222,9 @@ function StatusChangeDialog({
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Picking an interest auto-creates a primary berth link if one doesn&apos;t already Link this status change to the prospect (interest) it relates to. The change will
exist, so the deal timeline + heat scorer attribute the change correctly. appear on that interest&apos;s timeline, and the berth gets attached to the prospect
automatically if it wasn&apos;t already.
</p> </p>
</div> </div>
)} )}

View File

@@ -31,7 +31,7 @@ export function BerthDetail({ berthId }: BerthDetailProps) {
}); });
const { setChrome } = useMobileChrome(); const { setChrome } = useMobileChrome();
const titleForChrome: string | null = data?.mooringNumber ?? null; const titleForChrome: string | null = data?.mooringNumber ? `Berth ${data.mooringNumber}` : null;
useEffect(() => { useEffect(() => {
setChrome({ title: titleForChrome, showBackButton: true }); setChrome({ title: titleForChrome, showBackButton: true });
return () => setChrome({ title: null, showBackButton: false }); return () => setChrome({ title: null, showBackButton: false });

View File

@@ -192,7 +192,7 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
return ( return (
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-[480px] sm:w-[540px] overflow-y-auto"> <SheetContent className="w-full sm:w-[540px] sm:max-w-none overflow-y-auto">
<SheetHeader> <SheetHeader>
<SheetTitle>Edit Berth {berth.mooringNumber}</SheetTitle> <SheetTitle>Edit Berth {berth.mooringNumber}</SheetTitle>
</SheetHeader> </SheetHeader>

View File

@@ -19,7 +19,7 @@ import {
import { TableSkeleton } from '@/components/shared/loading-skeleton'; import { TableSkeleton } from '@/components/shared/loading-skeleton';
import { EmptyState } from '@/components/shared/empty-state'; import { EmptyState } from '@/components/shared/empty-state';
import { Bookmark } from 'lucide-react'; 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'; import type { InterestRow } from '@/components/interests/interest-columns';
interface BerthInterestsTabProps { interface BerthInterestsTabProps {
@@ -46,13 +46,6 @@ const CATEGORY_LABELS: Record<string, string> = {
general_interest: 'General Interest', general_interest: 'General Interest',
}; };
const SOURCE_LABELS: Record<string, string> = {
website: 'Website',
manual: 'Manual',
referral: 'Referral',
broker: 'Broker',
};
interface ListResponse { interface ListResponse {
data: InterestRow[]; data: InterestRow[];
total: number; total: number;
@@ -179,9 +172,7 @@ export function BerthInterestsTab({ berthId }: BerthInterestsTabProps) {
<td className="px-3 py-2 text-muted-foreground"> <td className="px-3 py-2 text-muted-foreground">
{i.leadCategory ? (CATEGORY_LABELS[i.leadCategory] ?? i.leadCategory) : '-'} {i.leadCategory ? (CATEGORY_LABELS[i.leadCategory] ?? i.leadCategory) : '-'}
</td> </td>
<td className="px-3 py-2 text-muted-foreground"> <td className="px-3 py-2 text-muted-foreground">{formatSource(i.source) ?? '-'}</td>
{i.source ? (SOURCE_LABELS[i.source] ?? i.source) : '-'}
</td>
<td className="px-3 py-2 text-xs text-muted-foreground"> <td className="px-3 py-2 text-xs text-muted-foreground">
{new Date(i.createdAt).toLocaleDateString()} {new Date(i.createdAt).toLocaleDateString()}
</td> </td>

View File

@@ -8,6 +8,7 @@ import { FilterBar } from '@/components/shared/filter-bar';
import { PageHeader } from '@/components/shared/page-header'; import { PageHeader } from '@/components/shared/page-header';
import { SavedViewsDropdown } from '@/components/shared/saved-views-dropdown'; import { SavedViewsDropdown } from '@/components/shared/saved-views-dropdown';
import { ColumnPicker } from '@/components/shared/column-picker'; import { ColumnPicker } from '@/components/shared/column-picker';
import { Input } from '@/components/ui/input';
import { EmptyState } from '@/components/shared/empty-state'; import { EmptyState } from '@/components/shared/empty-state';
import { usePaginatedQuery } from '@/hooks/use-paginated-query'; import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
@@ -63,11 +64,27 @@ export function BerthList() {
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<FilterBar <FilterBar
filters={berthFilterDefinitions} // Search is hoisted out of the popover into the inline input
// below — keeps the daily "find by mooring/area" lookup one
// tap away instead of buried behind the Filters dropdown.
filters={berthFilterDefinitions.filter((d) => d.key !== 'search')}
values={filters} values={filters}
onChange={setFilter} onChange={setFilter}
onClear={clearFilters} onClear={clearFilters}
/> />
<Input
type="search"
inputMode="search"
placeholder="Search mooring or area…"
aria-label="Search berths"
value={(filters.search as string | undefined) ?? ''}
onChange={(e) => 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"
/>
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
<SavedViewsDropdown <SavedViewsDropdown
entityType="berths" entityType="berths"
@@ -101,6 +118,11 @@ export function BerthList() {
onRowClick={(row) => router.push(`/${params.portSlug}/berths/${row.id}`)} onRowClick={(row) => router.push(`/${params.portSlug}/berths/${row.id}`)}
getRowClassName={(row) => mooringLetterTone(row.mooringNumber)} getRowClassName={(row) => mooringLetterTone(row.mooringNumber)}
cardRender={(row) => <BerthCard berth={row.original} />} cardRender={(row) => <BerthCard berth={row.original} />}
// 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={ emptyState={
<EmptyState <EmptyState
icon={Anchor} icon={Anchor}

View File

@@ -1,13 +1,17 @@
'use client'; 'use client';
import { useEffect, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { cn } from '@/lib/utils';
import { type DetailTab } from '@/components/shared/detail-layout'; import { type DetailTab } from '@/components/shared/detail-layout';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { EntityActivityFeed } from '@/components/shared/entity-activity-feed'; import { EntityActivityFeed } from '@/components/shared/entity-activity-feed';
import { InlineEditableField } from '@/components/shared/inline-editable-field'; import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { InlineTagEditor } from '@/components/shared/inline-tag-editor'; import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { formatCurrency } from '@/lib/utils/currency';
import { import {
BERTH_ACCESS_OPTIONS, BERTH_ACCESS_OPTIONS,
BERTH_BOLLARD_CAPACITIES, BERTH_BOLLARD_CAPACITIES,
@@ -64,6 +68,40 @@ type BerthData = {
tags: Array<{ id: string; name: string; color: string }>; tags: Array<{ id: string; name: string; color: string }>;
}; };
/**
* 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 (
<div
role="tablist"
aria-label="Display unit"
className="inline-flex items-center gap-0.5 rounded-md border bg-muted/40 p-0.5 text-xs"
>
{(['ft', 'm'] as const).map((opt) => (
<button
key={opt}
type="button"
role="tab"
aria-selected={value === opt}
onClick={() => onChange(opt)}
className={cn(
'min-h-[28px] min-w-[40px] rounded px-2 font-medium transition-colors',
value === opt
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
>
{opt}
</button>
))}
</div>
);
}
function SpecRow({ label, value }: { label: string; value: React.ReactNode }) { function SpecRow({ label, value }: { label: string; value: React.ReactNode }) {
if (!value && value !== 0 && value !== false) return null; if (!value && value !== 0 && value !== false) return null;
// Mobile-first: stack vertically with label on top so long values // Mobile-first: stack vertically with label on top so long values
@@ -104,6 +142,7 @@ function useBerthPatch(berthId: string) {
function EditableSpec({ function EditableSpec({
label, label,
value, value,
displayValue,
field, field,
patch, patch,
numeric = false, numeric = false,
@@ -113,6 +152,9 @@ function EditableSpec({
}: { }: {
label: string; label: string;
value: string | null; 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; field: string;
patch: ReturnType<typeof useBerthPatch>; patch: ReturnType<typeof useBerthPatch>;
numeric?: boolean; numeric?: boolean;
@@ -142,6 +184,7 @@ function EditableSpec({
) : ( ) : (
<InlineEditableField <InlineEditableField
value={value} value={value}
displayValue={displayValue}
onSave={async (next) => { onSave={async (next) => {
if (numeric) { if (numeric) {
if (next === null || next.trim() === '') { 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 }) { function OverviewTab({ berth }: { berth: BerthData }) {
const patch = useBerthPatch(berth.id); const patch = useBerthPatch(berth.id);
// Round to at most 2 decimals; trim trailing zeros so "5.00" -> "5". // User-selected display unit for dimensions. Persisted in localStorage
const fmt = (v: string | null, fractionDigits = 2): string | null => { // so reps' preferred unit sticks across navigations + sessions.
if (v == null || v === '') return null; const [units, setUnits] = useState<'ft' | 'm'>('ft');
const n = Number(v); useEffect(() => {
if (Number.isNaN(n)) return v; const stored = localStorage.getItem('berth-overview-units');
return n.toLocaleString('en-US', { if (stored === 'ft' || stored === 'm') setUnits(stored);
minimumFractionDigits: 0, }, []);
maximumFractionDigits: fractionDigits, useEffect(() => {
}); localStorage.setItem('berth-overview-units', units);
}; }, [units]);
// Read-only display helper for the metric column on dimensions — const u = units;
// mirrors the pre-edit "X ft / Y m" rendering for fields where only // For each dimension, pick the column matching the selected unit and
// the foot value is editable today. // point linkedUnit at the opposite column so edits keep both in sync.
const formatNominalBoatSize = (ft: string | null, m: string | null): string | null => { const dim = (ftField: string, mField: string) =>
const ftFmt = fmt(ft, 0); units === 'ft'
const mFmt = fmt(m); ? { field: ftField, linkedUnit: { field: mField, multiplier: FT_TO_M } }
const parts: string[] = []; : { field: mField, linkedUnit: { field: ftField, multiplier: M_TO_FT } };
if (ftFmt) parts.push(`${ftFmt} ft`); const dimValue = (ftValue: string | null, mValue: string | null) =>
if (mFmt) parts.push(`${mFmt} m`); units === 'ft' ? ftValue : mValue;
return parts.length > 0 ? parts.join(' / ') : null;
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -204,62 +250,50 @@ function OverviewTab({ berth }: { berth: BerthData }) {
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Specifications */} {/* Specifications */}
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="flex flex-row items-center justify-between gap-2 pb-3">
<CardTitle className="text-sm font-medium">Specifications</CardTitle> <CardTitle className="text-sm font-medium">Specifications</CardTitle>
<UnitToggle value={units} onChange={setUnits} />
</CardHeader> </CardHeader>
<CardContent className="pt-0 divide-y"> <CardContent className="pt-0 divide-y">
<EditableSpec <EditableSpec
label="Length (ft)" label={`Length (${u})`}
value={berth.lengthFt} value={dimValue(berth.lengthFt, berth.lengthM)}
field="lengthFt" {...dim('lengthFt', 'lengthM')}
patch={patch} patch={patch}
numeric numeric
suffix="ft" suffix={u}
linkedUnit={{ field: 'lengthM', multiplier: 0.3048 }}
/> />
<EditableSpec <EditableSpec
label="Width (ft)" label={`Width (${u})`}
value={berth.widthFt} value={dimValue(berth.widthFt, berth.widthM)}
field="widthFt" {...dim('widthFt', 'widthM')}
patch={patch} patch={patch}
numeric numeric
suffix="ft" suffix={u}
linkedUnit={{ field: 'widthM', multiplier: 0.3048 }}
/> />
<EditableSpec <EditableSpec
label="Draft (ft)" label={`Draft (${u})`}
value={berth.draftFt} value={dimValue(berth.draftFt, berth.draftM)}
field="draftFt" {...dim('draftFt', 'draftM')}
patch={patch} patch={patch}
numeric numeric
suffix="ft" suffix={u}
linkedUnit={{ field: 'draftM', multiplier: 0.3048 }}
/> />
<EditableSpec <EditableSpec
label="Nominal Boat Size (ft)" label={`Nominal Boat Size (${u})`}
value={berth.nominalBoatSize} value={dimValue(berth.nominalBoatSize, berth.nominalBoatSizeM)}
field="nominalBoatSize" {...dim('nominalBoatSize', 'nominalBoatSizeM')}
patch={patch} patch={patch}
numeric numeric
suffix="ft" suffix={u}
linkedUnit={{ field: 'nominalBoatSizeM', multiplier: 0.3048 }}
/>
<SpecRow
label="Nominal Boat Size (m)"
value={
formatNominalBoatSize(berth.nominalBoatSize, berth.nominalBoatSizeM)?.split(
' / ',
)[1] ?? null
}
/> />
<EditableSpec <EditableSpec
label="Water Depth (ft)" label={`Water Depth (${u})`}
value={berth.waterDepth} value={dimValue(berth.waterDepth, berth.waterDepthM)}
field="waterDepth" {...dim('waterDepth', 'waterDepthM')}
patch={patch} patch={patch}
numeric numeric
suffix="ft" suffix={u}
linkedUnit={{ field: 'waterDepthM', multiplier: 0.3048 }}
/> />
<EditableSpec <EditableSpec
label="Mooring Type" label="Mooring Type"
@@ -371,6 +405,11 @@ function OverviewTab({ berth }: { berth: BerthData }) {
<EditableSpec <EditableSpec
label={`Price (${berth.priceCurrency || 'USD'})`} label={`Price (${berth.priceCurrency || 'USD'})`}
value={berth.price} value={berth.price}
displayValue={
berth.price
? formatCurrency(berth.price, berth.priceCurrency, { maxFractionDigits: 0 })
: null
}
field="price" field="price"
patch={patch} patch={patch}
numeric numeric

View File

@@ -164,7 +164,7 @@ export function BulkArchiveWizard({ open, onOpenChange, clientIds, onSuccess }:
<div className="rounded-md border bg-muted/30 p-3 text-xs text-muted-foreground"> <div className="rounded-md border bg-muted/30 p-3 text-xs text-muted-foreground">
Low-stakes defaults: release available/under-offer berths, keep sold ones, cancel 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 archived client. To customise per-client, archive that client individually
instead. instead.
</div> </div>

View File

@@ -17,16 +17,9 @@ import {
deriveInitials, deriveInitials,
} from '@/components/shared/list-card'; } from '@/components/shared/list-card';
import { getCountryName } from '@/lib/i18n/countries'; 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'; import type { ClientRow } from './client-columns';
const SOURCE_LABELS: Record<string, string> = {
website: 'Website',
manual: 'Manual',
referral: 'Referral',
broker: 'Broker',
};
interface ClientCardProps { interface ClientCardProps {
client: ClientRow; client: ClientRow;
portSlug: string; portSlug: string;
@@ -38,7 +31,7 @@ export function ClientCard({ client, portSlug, onEdit, onArchive }: ClientCardPr
// Card display: prefer email, fall back to phone. // Card display: prefer email, fall back to phone.
const primaryContactValue = client.primaryEmail ?? client.primaryPhone ?? null; const primaryContactValue = client.primaryEmail ?? client.primaryPhone ?? null;
const nationality = client.nationalityIso ? getCountryName(client.nationalityIso, 'en') : 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 tags = client.tags ?? [];
const meta = [nationality, sourceLabel].filter(Boolean) as string[]; const meta = [nationality, sourceLabel].filter(Boolean) as string[];

View File

@@ -15,7 +15,7 @@ import {
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { getCountryName } from '@/lib/i18n/countries'; 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 { cn } from '@/lib/utils';
import type { ColumnPickerOption } from '@/components/shared/column-picker'; 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']; export const CLIENT_DEFAULT_HIDDEN: string[] = ['latestStage'];
const SOURCE_LABELS: Record<string, string> = {
website: 'Website',
manual: 'Manual',
referral: 'Referral',
broker: 'Broker',
};
interface GetColumnsOptions { interface GetColumnsOptions {
portSlug: string; portSlug: string;
onEdit: (client: ClientRow) => void; onEdit: (client: ClientRow) => void;
@@ -191,10 +184,11 @@ export function getClientColumns({
header: 'Source', header: 'Source',
cell: ({ getValue }) => { cell: ({ getValue }) => {
const source = getValue() as string | null; const source = getValue() as string | null;
if (!source) return <span className="text-muted-foreground">-</span>; const label = formatSource(source);
if (!label) return <span className="text-muted-foreground">-</span>;
return ( return (
<Badge variant="outline" className="capitalize text-xs"> <Badge variant="outline" className="capitalize text-xs">
{SOURCE_LABELS[source] ?? source} {label}
</Badge> </Badge>
); );
}, },

View File

@@ -131,7 +131,7 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
</a> </a>
</Button> </Button>
) : null} ) : null}
{!isArchived && client.clientPortalEnabled !== false ? ( {!isArchived && client.clientPortalEnabled === true ? (
<div className="hidden sm:inline-flex"> <div className="hidden sm:inline-flex">
<PortalInviteButton <PortalInviteButton
clientId={client.id} clientId={client.id}

View File

@@ -23,7 +23,7 @@ export const clientFilterDefinitions: FilterDefinition[] = [
key: 'nationality', key: 'nationality',
label: 'Country', label: 'Country',
type: 'text', type: 'text',
placeholder: 'Filter by nationality...', placeholder: 'Filter by country...',
}, },
{ {
key: 'includeArchived', key: 'includeArchived',

View File

@@ -26,6 +26,7 @@ import { PhoneInput } from '@/components/shared/phone-input';
import { DedupSuggestionPanel } from '@/components/clients/dedup-suggestion-panel'; import { DedupSuggestionPanel } from '@/components/clients/dedup-suggestion-panel';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { createClientSchema, type CreateClientInput } from '@/lib/validators/clients'; import { createClientSchema, type CreateClientInput } from '@/lib/validators/clients';
import { SOURCES } from '@/lib/constants';
import type { CountryCode } from '@/lib/i18n/countries'; import type { CountryCode } from '@/lib/i18n/countries';
import { primaryTimezoneFor } from '@/lib/i18n/timezones'; import { primaryTimezoneFor } from '@/lib/i18n/timezones';
@@ -188,8 +189,8 @@ export function ClientForm({ open, onOpenChange, client, onUseExistingClient }:
Basic Information Basic Information
</h3> </h3>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="col-span-2 space-y-1"> <div className="sm:col-span-2 space-y-1">
<Label>Full Name *</Label> <Label>Full Name *</Label>
<Input {...register('fullName')} placeholder="John Smith" /> <Input {...register('fullName')} placeholder="John Smith" />
{errors.fullName && ( {errors.fullName && (
@@ -198,7 +199,7 @@ export function ClientForm({ open, onOpenChange, client, onUseExistingClient }:
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label>Nationality</Label> <Label>Country</Label>
<CountryCombobox <CountryCombobox
value={watch('nationalityIso')} value={watch('nationalityIso')}
onChange={(iso) => setValue('nationalityIso', iso ?? undefined)} onChange={(iso) => setValue('nationalityIso', iso ?? undefined)}
@@ -235,102 +236,107 @@ export function ClientForm({ open, onOpenChange, client, onUseExistingClient }:
{fields.map((field, index) => ( {fields.map((field, index) => (
<div <div
key={field.id} key={field.id}
className="grid grid-cols-12 gap-2 items-end p-3 rounded-lg border bg-muted/30" className="space-y-3 p-3 rounded-lg border bg-muted/30"
> >
<div className="col-span-3 space-y-1"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-12 sm:items-end sm:gap-2">
<Label className="text-xs">Channel</Label> <div className="space-y-1 sm:col-span-3">
<Select <Label className="text-xs">Channel</Label>
value={watch(`contacts.${index}.channel`)} <Select
onValueChange={(v) => value={watch(`contacts.${index}.channel`)}
setValue( onValueChange={(v) =>
`contacts.${index}.channel`, setValue(
v as 'email' | 'phone' | 'whatsapp' | 'other', `contacts.${index}.channel`,
) v as 'email' | 'phone' | 'whatsapp' | 'other',
} )
> }
<SelectTrigger className="h-8"> >
<SelectValue /> <SelectTrigger className="h-9 sm:h-8">
</SelectTrigger> <SelectValue />
<SelectContent> </SelectTrigger>
<SelectItem value="email">Email</SelectItem> <SelectContent>
<SelectItem value="phone">Phone</SelectItem> <SelectItem value="email">Email</SelectItem>
<SelectItem value="whatsapp">WhatsApp</SelectItem> <SelectItem value="phone">Phone</SelectItem>
<SelectItem value="other">Other</SelectItem> <SelectItem value="whatsapp">WhatsApp</SelectItem>
</SelectContent> <SelectItem value="other">Other</SelectItem>
</Select> </SelectContent>
</div> </Select>
</div>
<div className="col-span-5 space-y-1"> <div className="space-y-1 sm:col-span-5">
<Label className="text-xs">Value</Label> <Label className="text-xs">Value</Label>
{(() => { {(() => {
const channel = watch(`contacts.${index}.channel`); const channel = watch(`contacts.${index}.channel`);
if (channel === 'phone' || channel === 'whatsapp') { if (channel === 'phone' || channel === 'whatsapp') {
const e164 = watch(`contacts.${index}.valueE164`) ?? null; const e164 = watch(`contacts.${index}.valueE164`) ?? null;
const country = const country =
(watch(`contacts.${index}.valueCountry`) as CountryCode | undefined) ?? (watch(`contacts.${index}.valueCountry`) as CountryCode | undefined) ??
undefined; undefined;
return (
<PhoneInput
value={
e164 || country
? {
e164: e164 ?? null,
country: country ?? 'US',
}
: null
}
onChange={(v) => {
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 ( return (
<PhoneInput <Input
value={ {...register(`contacts.${index}.value`)}
e164 || country className="h-9 sm:h-8"
? { placeholder={channel === 'email' ? 'email@example.com' : 'value'}
e164: e164 ?? null,
country: country ?? 'US',
}
: null
}
onChange={(v) => {
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 ( </div>
<Input
{...register(`contacts.${index}.value`)} <div className="space-y-1 sm:col-span-4">
className="h-8" <Label className="text-xs">
placeholder={channel === 'email' ? 'email@example.com' : 'value'} {watch(`contacts.${index}.channel`) === 'other' ? 'Specify' : 'Label'}
/> </Label>
); <Input
})()} {...register(`contacts.${index}.label`)}
className="h-9 sm:h-8"
placeholder={
watch(`contacts.${index}.channel`) === 'other'
? 'e.g. Telegram, Signal'
: 'work'
}
/>
</div>
</div> </div>
<div className="col-span-2 space-y-1"> {/* Bottom strip: Primary toggle left, delete right. Sits on
<Label className="text-xs"> its own row on every breakpoint so neither control gets
{watch(`contacts.${index}.channel`) === 'other' ? 'Specify' : 'Label'} squashed by the field columns above. */}
</Label> <div className="flex items-center justify-between gap-3">
<Input <label className="flex items-center gap-2 text-sm cursor-pointer select-none">
{...register(`contacts.${index}.label`)} <Checkbox
className="h-8" checked={watch(`contacts.${index}.isPrimary`)}
placeholder={ onCheckedChange={(v) => setValue(`contacts.${index}.isPrimary`, !!v)}
watch(`contacts.${index}.channel`) === 'other' />
? 'e.g. Telegram, Signal' <span className="font-medium">Primary contact</span>
: 'work' </label>
}
/>
</div>
<div className="col-span-1 flex items-center gap-1 pb-1">
<Checkbox
checked={watch(`contacts.${index}.isPrimary`)}
onCheckedChange={(v) => setValue(`contacts.${index}.isPrimary`, !!v)}
/>
<Label className="text-xs">Primary</Label>
</div>
<div className="col-span-1 flex justify-end pb-1">
{fields.length > 1 && ( {fields.length > 1 && (
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" size="sm"
className="h-7 w-7 text-destructive" className="h-8 text-destructive hover:text-destructive"
onClick={() => remove(index)} onClick={() => remove(index)}
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="mr-1 h-3.5 w-3.5" />
Remove
</Button> </Button>
)} )}
</div> </div>
@@ -346,7 +352,7 @@ export function ClientForm({ open, onOpenChange, client, onUseExistingClient }:
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide"> <h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Source & Preferences Source & Preferences
</h3> </h3>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-1"> <div className="space-y-1">
<Label>Source</Label> <Label>Source</Label>
<Select <Select
@@ -359,11 +365,11 @@ export function ClientForm({ open, onOpenChange, client, onUseExistingClient }:
<SelectValue placeholder="Select source" /> <SelectValue placeholder="Select source" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="website">Website</SelectItem> {SOURCES.map((s) => (
<SelectItem value="manual">Manual</SelectItem> <SelectItem key={s.value} value={s.value}>
<SelectItem value="referral">Referral</SelectItem> {s.label}
<SelectItem value="broker">Broker</SelectItem> </SelectItem>
<SelectItem value="other">Other</SelectItem> ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -394,7 +400,7 @@ export function ClientForm({ open, onOpenChange, client, onUseExistingClient }:
data-testid="client-timezone" data-testid="client-timezone"
/> />
</div> </div>
<div className="col-span-2 space-y-1"> <div className="sm:col-span-2 space-y-1">
<Label>Source Details</Label> <Label>Source Details</Label>
<Input {...register('sourceDetails')} placeholder="Referred by John Doe" /> <Input {...register('sourceDetails')} placeholder="Referred by John Doe" />
</div> </div>

View File

@@ -38,6 +38,7 @@ import {
type ClientRow, type ClientRow,
} from '@/components/clients/client-columns'; } from '@/components/clients/client-columns';
import { ColumnPicker } from '@/components/shared/column-picker'; import { ColumnPicker } from '@/components/shared/column-picker';
import { useCreateFromUrl } from '@/hooks/use-create-from-url';
import { usePaginatedQuery } from '@/hooks/use-paginated-query'; import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { useTablePreferences } from '@/hooks/use-table-preferences'; import { useTablePreferences } from '@/hooks/use-table-preferences';
@@ -49,6 +50,7 @@ export function ClientList() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
useCreateFromUrl(() => setCreateOpen(true));
const [editClient, setEditClient] = useState<ClientRow | null>(null); const [editClient, setEditClient] = useState<ClientRow | null>(null);
const [archiveClient, setArchiveClient] = useState<ClientRow | null>(null); const [archiveClient, setArchiveClient] = useState<ClientRow | null>(null);
const [tagDialog, setTagDialog] = useState<{ ids: string[]; mode: 'add' | 'remove' } | null>( const [tagDialog, setTagDialog] = useState<{ ids: string[]; mode: 'add' | 'remove' } | null>(
@@ -141,17 +143,9 @@ export function ClientList() {
title="Clients" title="Clients"
description="Manage your client records" description="Manage your client records"
variant="gradient" variant="gradient"
actions={
<PermissionGate resource="clients" action="create">
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="mr-1.5 h-4 w-4" />
New Client
</Button>
</PermissionGate>
}
/> />
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<FilterBar <FilterBar
filters={clientFilterDefinitions} filters={clientFilterDefinitions}
values={filters} values={filters}
@@ -171,6 +165,16 @@ export function ClientList() {
onChange={setHidden} onChange={setHidden}
onSaveView={() => setSaveViewOpen(true)} onSaveView={() => 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. */}
<PermissionGate resource="clients" action="create">
<Button size="sm" className="ml-auto" onClick={() => setCreateOpen(true)}>
<Plus className="mr-1.5 h-4 w-4" />
New Client
</Button>
</PermissionGate>
</div> </div>
<SaveViewDialog <SaveViewDialog

View File

@@ -20,6 +20,7 @@ import { ContactsEditor } from '@/components/clients/contacts-editor';
import { AddressesEditor, type Address } from '@/components/shared/addresses-editor'; import { AddressesEditor, type Address } from '@/components/shared/addresses-editor';
import { EntityActivityFeed } from '@/components/shared/entity-activity-feed'; import { EntityActivityFeed } from '@/components/shared/entity-activity-feed';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { SOURCES } from '@/lib/constants';
type ClientPatchField = type ClientPatchField =
| 'fullName' | 'fullName'
@@ -31,13 +32,7 @@ type ClientPatchField =
| 'source' | 'source'
| 'sourceDetails'; | 'sourceDetails';
const SOURCE_OPTIONS = [ const SOURCE_OPTIONS = SOURCES.map((s) => ({ value: s.value, label: s.label }));
{ value: 'website', label: 'Website' },
{ value: 'manual', label: 'Manual' },
{ value: 'referral', label: 'Referral' },
{ value: 'broker', label: 'Broker' },
{ value: 'other', label: 'Other' },
];
const CONTACT_METHOD_OPTIONS = [ const CONTACT_METHOD_OPTIONS = [
{ value: 'email', label: 'Email' }, { value: 'email', label: 'Email' },
@@ -289,10 +284,10 @@ export function getClientTabs({ clientId, currentUserId, client }: ClientTabsOpt
badge: client.noteCount, badge: client.noteCount,
content: ( content: (
<NotesList <NotesList
aggregate
entityType="clients" entityType="clients"
entityId={clientId} entityId={clientId}
currentUserId={currentUserId} currentUserId={currentUserId}
aggregate
/> />
), ),
}, },

View File

@@ -477,12 +477,12 @@ export function SmartArchiveDialog({ open, onOpenChange, clientId, clientName, o
</Card> </Card>
)} )}
{/* In-flight Documenso envelopes */} {/* In-flight signing envelopes */}
{dossier.documents.filter((d) => d.isInFlight).length > 0 && ( {dossier.documents.filter((d) => d.isInFlight).length > 0 && (
<Card> <Card>
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2"> <CardTitle className="text-sm font-medium flex items-center gap-2">
<FileText className="h-4 w-4" /> In-flight Documenso envelopes <FileText className="h-4 w-4" /> In-flight signing envelopes
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent className="space-y-2">
@@ -502,7 +502,7 @@ export function SmartArchiveDialog({ open, onOpenChange, clientId, clientName, o
} }
> >
<option value="leave">Leave envelope pending</option> <option value="leave">Leave envelope pending</option>
<option value="void_documenso">Void in Documenso</option> <option value="void_documenso">Void the signing envelope</option>
</select> </select>
</div> </div>
))} ))}

View File

@@ -42,6 +42,19 @@ const STATUS_LABELS: Record<string, string> = {
dissolved: 'Dissolved', 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 { interface GetCompanyColumnsOptions {
portSlug: string; portSlug: string;
onEdit: (company: CompanyRow) => void; onEdit: (company: CompanyRow) => void;

View File

@@ -15,6 +15,7 @@ import { TableSkeleton } from '@/components/shared/loading-skeleton';
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog'; import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
import { PermissionGate } from '@/components/shared/permission-gate'; import { PermissionGate } from '@/components/shared/permission-gate';
import { TagPicker } from '@/components/shared/tag-picker'; import { TagPicker } from '@/components/shared/tag-picker';
import { ColumnPicker } from '@/components/shared/column-picker';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -26,9 +27,16 @@ import {
import { CompanyCard } from '@/components/companies/company-card'; import { CompanyCard } from '@/components/companies/company-card';
import { CompanyForm } from '@/components/companies/company-form'; import { CompanyForm } from '@/components/companies/company-form';
import { companyFilterDefinitions } from '@/components/companies/company-filters'; 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 { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { useTablePreferences } from '@/hooks/use-table-preferences';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
export function CompanyList() { export function CompanyList() {
@@ -37,6 +45,7 @@ export function CompanyList() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
useCreateFromUrl(() => setCreateOpen(true));
const [editCompany, setEditCompany] = useState<CompanyRow | null>(null); const [editCompany, setEditCompany] = useState<CompanyRow | null>(null);
const [archiveCompany, setArchiveCompany] = useState<CompanyRow | null>(null); const [archiveCompany, setArchiveCompany] = useState<CompanyRow | null>(null);
const [tagDialog, setTagDialog] = useState<{ ids: string[]; mode: 'add' | 'remove' } | null>( const [tagDialog, setTagDialog] = useState<{ ids: string[]; mode: 'add' | 'remove' } | null>(
@@ -102,6 +111,10 @@ export function CompanyList() {
onArchive: (company) => setArchiveCompany(company), 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 ( return (
<div className="space-y-4"> <div className="space-y-4">
<PageHeader <PageHeader
@@ -125,13 +138,16 @@ export function CompanyList() {
onChange={setFilter} onChange={setFilter}
onClear={clearFilters} onClear={clearFilters}
/> />
<SavedViewsDropdown <div className="ml-auto flex items-center gap-2">
entityType="companies" <SavedViewsDropdown
onApplyView={(savedFilters, _savedSort) => { entityType="companies"
clearFilters(); onApplyView={(savedFilters, _savedSort) => {
Object.entries(savedFilters).forEach(([key, val]) => setFilter(key, val)); clearFilters();
}} Object.entries(savedFilters).forEach(([key, val]) => setFilter(key, val));
/> }}
/>
<ColumnPicker columns={COMPANY_COLUMN_OPTIONS} hidden={hidden} onChange={setHidden} />
</div>
</div> </div>
{isLoading ? ( {isLoading ? (
@@ -145,6 +161,7 @@ export function CompanyList() {
) : ( ) : (
<DataTable <DataTable
columns={columns} columns={columns}
columnVisibility={columnVisibility}
data={data} data={data}
pagination={pagination} pagination={pagination}
onPaginationChange={(p, ps) => { onPaginationChange={(p, ps) => {

View File

@@ -56,7 +56,7 @@ export function CompanyPicker({
})(); })();
return ( return (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen} modal>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
variant="outline" variant="outline"

View File

@@ -147,12 +147,13 @@ function OverviewTab({ companyId, company }: { companyId: string; company: Compa
</EditableRow> </EditableRow>
<EditableRow label="Incorporation Date"> <EditableRow label="Incorporation Date">
<InlineEditableField <InlineEditableField
variant="date"
// The API returns this as an ISO timestamp ("2019-03-14T00:00:00.000Z") // The API returns this as an ISO timestamp ("2019-03-14T00:00:00.000Z")
// because Postgres `date` columns are serialized through JSON. Strip // because Postgres `date` columns are serialized through JSON. Strip
// the time portion so the read-only state shows just YYYY-MM-DD, // the time portion so the read-only state shows just YYYY-MM-DD,
// which is also the format the user types when editing. // which is also the format the date input expects.
value={company.incorporationDate ? company.incorporationDate.slice(0, 10) : null} value={company.incorporationDate ? company.incorporationDate.slice(0, 10) : null}
placeholder="YYYY-MM-DD" max={new Date().toISOString().slice(0, 10)}
onSave={save('incorporationDate')} onSave={save('incorporationDate')}
/> />
</EditableRow> </EditableRow>
@@ -226,10 +227,10 @@ export function getCompanyTabs({
label: 'Notes', label: 'Notes',
content: ( content: (
<NotesList <NotesList
aggregate
entityType="companies" entityType="companies"
entityId={companyId} entityId={companyId}
currentUserId={currentUserId} currentUserId={currentUserId}
aggregate
/> />
), ),
}, },

View File

@@ -0,0 +1,68 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import Link from 'next/link';
import type { Route } from 'next';
import { useParams } from 'next/navigation';
import { TrendingUp } from 'lucide-react';
import { apiFetch } from '@/lib/api/client';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
interface KpiResponse {
totalClients: number;
activeInterests: number;
pipelineValueUsd: number;
occupancyRate: number;
}
/**
* Compact rail-sized KPI tile — single number, label, and a click-
* through to the interests pipeline. Reuses the existing dashboard KPIs
* endpoint so we don't pay an extra round-trip.
*/
export function ActiveDealsTile() {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const { data, isLoading } = useQuery<KpiResponse>({
queryKey: ['dashboard', 'kpis'],
queryFn: () => apiFetch<KpiResponse>('/api/v1/dashboard/kpis'),
staleTime: 60_000,
});
return (
<Card>
{/* `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. */}
<CardContent className="flex items-center gap-3 pt-5 pb-5">
<div className="flex size-10 shrink-0 items-center justify-center rounded-md bg-accent text-foreground">
<TrendingUp className="size-5" aria-hidden />
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Active deals
</p>
{isLoading ? (
<Skeleton className="mt-1 h-7 w-12" />
) : (
<p className="text-2xl font-bold leading-tight text-foreground">
{data?.activeInterests ?? 0}
</p>
)}
</div>
<Link
// Next typedRoutes can't infer dynamic-segment routes from a template
// literal — cast through unknown rather than `any` so the lint rule
// is satisfied while the runtime href is still correct.
href={`/${portSlug}/interests` as unknown as Route}
className="text-xs font-medium text-primary hover:underline"
>
View
</Link>
</CardContent>
</Card>
);
}

View File

@@ -14,11 +14,103 @@ interface ActivityItem {
action: string; action: string;
entityType: string; entityType: string;
entityId: string | null; entityId: string | null;
/** Server-resolved human label (client name, yacht name, …) when the
* underlying entity still exists. Falls back to the id prefix in the UI. */
label: string | null;
userId: string | null; userId: string | null;
fieldChanged: string | null;
oldValue: unknown;
newValue: unknown;
metadata: Record<string, unknown> | null; metadata: Record<string, unknown> | null;
createdAt: string; createdAt: string;
} }
/** camelCase / snake_case field name → "Title Case" so the audit log
* reads naturally ("fullName" → "Full Name", "phone_number" → "Phone
* Number"). Single-word fields stay capitalized. */
function humanizeFieldName(name: string): string {
return name
.replace(/_/g, ' ')
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/\b\w/g, (c) => c.toUpperCase());
}
/** Render a JSON-ish value as a short, single-line preview. Strings come
* through as-is; objects flatten to "k: v, k: v"; arrays compress to a
* count; nulls / empty render as em-dash. */
function shortValue(value: unknown): string {
if (value === null || value === undefined || value === '') return '—';
if (typeof value === 'string') return value;
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
if (Array.isArray(value)) return `${value.length} item${value.length === 1 ? '' : 's'}`;
if (typeof value === 'object') {
const entries = Object.entries(value as Record<string, unknown>);
if (entries.length === 0) return '—';
return entries
.slice(0, 3)
.map(([k, v]) => `${humanizeFieldName(k)}: ${typeof v === 'string' ? v : JSON.stringify(v)}`)
.join(', ');
}
return String(value);
}
/** Build a "Field: old → new" diff string for the activity row's second
* line. Returns null when there's nothing useful to show.
*
* Audit logs for updates store the per-field diff inside `oldValue` as
* `{ field: { old, new }, … }` (see entity-diff.ts), so that's the
* shape we pattern-match first. Falls back to a fieldChanged/old→new
* pair when those are present, and finally to a key-by-key compare of
* two flat objects in `oldValue` vs `newValue`. */
function buildDiffLine(item: ActivityItem): string | null {
// Shape A: oldValue = { field: { old, new }, … }
if (
item.action === 'update' &&
item.oldValue &&
typeof item.oldValue === 'object' &&
!Array.isArray(item.oldValue)
) {
const diffMap = item.oldValue as Record<string, unknown>;
const entries = Object.entries(diffMap).filter(([, v]) => {
return v && typeof v === 'object' && 'old' in (v as object) && 'new' in (v as object);
});
if (entries.length > 0) {
return entries
.slice(0, 2)
.map(([field, v]) => {
const { old, new: nextValue } = v as { old: unknown; new: unknown };
return `${humanizeFieldName(field)}: ${shortValue(old)}${shortValue(nextValue)}`;
})
.join(' · ');
}
}
// Shape B: single-field change with explicit columns.
if (item.fieldChanged) {
return `${humanizeFieldName(item.fieldChanged)}: ${shortValue(item.oldValue)}${shortValue(item.newValue)}`;
}
// Shape C: flat oldValue vs flat newValue.
if (
item.action === 'update' &&
item.oldValue &&
typeof item.oldValue === 'object' &&
item.newValue &&
typeof item.newValue === 'object'
) {
const oldObj = item.oldValue as Record<string, unknown>;
const newObj = item.newValue as Record<string, unknown>;
const keys = Object.keys(oldObj).filter((k) => k in newObj);
if (keys.length === 0) return null;
return keys
.slice(0, 2)
.map((k) => `${humanizeFieldName(k)}: ${shortValue(oldObj[k])}${shortValue(newObj[k])}`)
.join(' · ');
}
return null;
}
const ACTION_VARIANTS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = { const ACTION_VARIANTS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
create: 'default', create: 'default',
update: 'secondary', update: 'secondary',
@@ -63,27 +155,49 @@ function ActivityFeedInner() {
</p> </p>
) : ( ) : (
<div className="max-h-80 overflow-y-auto space-y-3 pr-1"> <div className="max-h-80 overflow-y-auto space-y-3 pr-1">
{items.map((item) => ( {items.map((item) => {
<div const diffLine = buildDiffLine(item);
key={item.id} return (
className="flex items-start gap-3 text-sm border-b border-border pb-3 last:border-0 last:pb-0" <div
> key={item.id}
<ActionBadge action={item.action} /> className="flex items-start gap-3 text-sm border-b border-border pb-3 last:border-0 last:pb-0"
<div className="min-w-0 flex-1"> >
<p className="truncate text-foreground"> <ActionBadge action={item.action} />
<span className="font-medium capitalize">{item.entityType}</span> <div className="min-w-0 flex-1">
{item.entityId && ( <p className="truncate text-foreground">
<span className="ml-1 text-muted-foreground font-mono text-xs"> {item.label ? (
{item.entityId.slice(0, 8)} <>
</span> <span className="font-medium">{item.label}</span>
)} <span className="ml-1.5 text-muted-foreground text-xs capitalize">
</p> {item.entityType}
<p className="text-xs text-muted-foreground mt-0.5"> </span>
{formatDistanceToNow(new Date(item.createdAt), { addSuffix: true })} </>
</p> ) : (
<>
<span className="font-medium capitalize">{item.entityType}</span>
{item.entityId && (
<span className="ml-1 text-muted-foreground font-mono text-xs">
{item.entityId.slice(0, 8)}
</span>
)}
</>
)}
</p>
{diffLine ? (
<p
className="truncate text-xs text-muted-foreground mt-0.5"
title={diffLine}
>
{diffLine}
</p>
) : null}
<p className="text-[11px] text-muted-foreground/80 mt-0.5">
{formatDistanceToNow(new Date(item.createdAt), { addSuffix: true })}
</p>
</div>
</div> </div>
</div> );
))} })}
</div> </div>
)} )}
</CardContent> </CardContent>

View File

@@ -0,0 +1,101 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { Cell, Legend, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts';
import { apiFetch } from '@/lib/api/client';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
interface BerthStatusResponse {
data: {
total: number;
available: number;
underOffer: number;
sold: number;
maintenance: number;
};
}
// Brand-aligned palette. Order matches the legend reading order
// (positive → in-progress → closed → exception).
const SEGMENTS = [
{ key: 'available', label: 'Available', color: 'hsl(213 55% 56%)' },
{ key: 'underOffer', label: 'Under offer', color: 'hsl(38 92% 50%)' },
{ key: 'sold', label: 'Sold', color: 'hsl(142 70% 40%)' },
{ key: 'maintenance', label: 'Maintenance', color: 'hsl(228 10% 60%)' },
] as const;
/**
* Donut visualisation of the port's berth status mix. Sized to fit a
* single chart column (~360px wide) with a generous legend; degrades
* cleanly when a status has zero berths (segment is omitted, legend
* still hints at its absence).
*/
export function BerthStatusChart() {
const { data, isLoading } = useQuery<BerthStatusResponse>({
queryKey: ['dashboard', 'berth_status'],
queryFn: () => apiFetch<BerthStatusResponse>('/api/v1/dashboard/berth-status'),
staleTime: 60_000,
});
const stats = data?.data;
const chartData = stats
? SEGMENTS.map((s) => ({ ...s, value: stats[s.key] })).filter((s) => s.value > 0)
: [];
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Berth status</CardTitle>
<CardDescription>
{stats
? `${stats.sold} sold · ${stats.underOffer} under offer · ${stats.available} available`
: 'Distribution across the port'}
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-[240px] w-full" />
) : chartData.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">No berths yet.</p>
) : (
<ResponsiveContainer width="100%" height={240}>
<PieChart>
<Pie
data={chartData}
dataKey="value"
nameKey="label"
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={85}
paddingAngle={2}
>
{chartData.map((d) => (
<Cell key={d.key} fill={d.color} />
))}
</Pie>
<Tooltip
formatter={(value, _name, payload) => {
const numeric = typeof value === 'number' ? value : Number(value ?? 0);
const total = stats?.total ?? 0;
const pct = total > 0 ? Math.round((numeric / total) * 100) : 0;
const label = (payload as { payload?: { label?: string } } | undefined)
?.payload?.label;
return [`${numeric} (${pct}%)`, label ?? ''];
}}
/>
<Legend
verticalAlign="bottom"
height={36}
iconType="circle"
wrapperStyle={{ fontSize: 12 }}
/>
</PieChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
);
}

View File

@@ -24,14 +24,19 @@ interface ChartCardProps {
className?: string; className?: string;
} }
function downloadBlob(blob: Blob, filename: string) { /**
* Match the pattern used elsewhere in the codebase (see
* `src/app/(dashboard)/[portSlug]/expenses/page.tsx`, `client-files-tab.tsx`,
* `backup-admin-panel.tsx`). All four reduce to the same dead-simple shape
* and they all work — Chrome honours the `download` attribute and the
* file lands with the right name.
*/
function triggerBlobDownload(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
a.download = filename; a.download = filename;
document.body.appendChild(a);
a.click(); a.click();
a.remove();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
@@ -44,31 +49,28 @@ async function exportContainerAsPng(container: HTMLElement, filename: string) {
clone.setAttribute('height', String(height)); clone.setAttribute('height', String(height));
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
const xml = new XMLSerializer().serializeToString(clone); const xml = new XMLSerializer().serializeToString(clone);
const svgBlob = new Blob([xml], { type: 'image/svg+xml;charset=utf-8' }); const svgDataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(xml)}`;
const url = URL.createObjectURL(svgBlob);
const img = new Image(); const img = new Image();
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
img.onload = () => resolve(); img.onload = () => resolve();
img.onerror = () => reject(new Error('Failed to load chart for export')); img.onerror = () => reject(new Error('Failed to load chart for export'));
img.src = url; img.src = svgDataUrl;
}); });
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const dpr = window.devicePixelRatio ?? 1; const dpr = window.devicePixelRatio ?? 1;
canvas.width = width * dpr; canvas.width = width * dpr;
canvas.height = height * dpr; canvas.height = height * dpr;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
if (!ctx) { if (!ctx) return;
URL.revokeObjectURL(url);
return;
}
ctx.scale(dpr, dpr); ctx.scale(dpr, dpr);
ctx.fillStyle = '#ffffff'; ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, width, height); ctx.fillRect(0, 0, width, height);
ctx.drawImage(img, 0, 0, width, height); ctx.drawImage(img, 0, 0, width, height);
URL.revokeObjectURL(url); const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob((blob) => { canvas.toBlob((b) => resolve(b), 'image/png'),
if (blob) downloadBlob(blob, filename); );
}, 'image/png'); if (!blob) return;
triggerBlobDownload(blob, filename);
} }
export function ChartCard({ export function ChartCard({
@@ -84,7 +86,10 @@ export function ChartCard({
function onDownloadCsv() { function onDownloadCsv() {
const csv = toCsv?.(); const csv = toCsv?.();
if (!csv) return; if (!csv) return;
downloadBlob(new Blob([csv], { type: 'text/csv;charset=utf-8' }), `${exportFilename}.csv`); triggerBlobDownload(
new Blob([csv], { type: 'text/csv;charset=utf-8' }),
`${exportFilename}.csv`,
);
} }
function onDownloadPng() { function onDownloadPng() {

View File

@@ -0,0 +1,129 @@
'use client';
import { useState } from 'react';
import { LayoutGrid } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Switch } from '@/components/ui/switch';
import { useDashboardWidgets } from '@/hooks/use-dashboard-widgets';
/**
* Modal widget picker for the dashboard header. Replaced the original
* dropdown menu because 13 widgets + 3 footer buttons made the dropdown
* cramped and hid the descriptions reps need to know what each card
* actually shows.
*
* Backed by the same `useDashboardWidgets` hook that drives the
* Settings card — toggles update both surfaces optimistically.
*/
export function CustomizeWidgetsMenu() {
const [open, setOpen] = useState(false);
const { allWidgets, visibility, setVisible, setAll, resetToDefaults, isSaving } =
useDashboardWidgets();
const visibleCount = Object.values(visibility).filter(Boolean).length;
const allVisible = visibleCount === allWidgets.length;
const allHidden = visibleCount === 0;
// Reset is a no-op when state already matches the registry defaults —
// disable in that case to avoid pointless API round-trips.
const matchesDefaults = allWidgets.every(
(w) => (visibility[w.id] ?? false) === w.defaultVisible,
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-1.5">
<LayoutGrid className="h-4 w-4" />
Customize
</Button>
</DialogTrigger>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>Customize dashboard</DialogTitle>
<DialogDescription>
Pick which analytics cards appear on your dashboard. Hidden cards leave no empty
space the layout reflows to fill the available width.
</DialogDescription>
</DialogHeader>
{/* Toggle list. Capped at ~60vh with internal scroll so the modal
doesn't push the action footer off-screen on shorter viewports. */}
<div className="max-h-[60vh] -mx-2 overflow-y-auto px-2">
<div className="space-y-1 py-1">
{allWidgets.map((w) => (
<label
key={w.id}
className="flex cursor-pointer items-start justify-between gap-4 rounded-md px-3 py-2.5 hover:bg-accent/40"
>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-foreground">{w.label}</div>
<p className="text-xs text-muted-foreground">{w.description}</p>
</div>
<Switch
aria-label={`Show ${w.label}`}
checked={visibility[w.id] ?? false}
disabled={isSaving}
onCheckedChange={(checked) => setVisible(w.id, checked)}
className="mt-0.5 shrink-0"
/>
</label>
))}
</div>
</div>
{/* 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. */}
<DialogFooter className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-2">
<span className="text-xs text-muted-foreground sm:order-first">
{visibleCount} of {allWidgets.length} visible
</span>
<div className="flex flex-wrap items-center gap-2 sm:flex-nowrap">
<Button
variant="ghost"
size="sm"
disabled={matchesDefaults || isSaving}
onClick={resetToDefaults}
>
Reset to defaults
</Button>
<Button
variant="outline"
size="sm"
disabled={allHidden || isSaving}
onClick={() => setAll(false)}
>
Hide all
</Button>
<Button
variant="outline"
size="sm"
disabled={allVisible || isSaving}
onClick={() => setAll(true)}
>
Show all
</Button>
<Button
size="sm"
onClick={() => setOpen(false)}
className="w-full sm:w-auto"
>
Done
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -4,19 +4,13 @@ import { useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; 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 { apiFetch } from '@/lib/api/client';
import { PageHeader } from '@/components/shared/page-header'; 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 { 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 { 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'; import { isCustomRange, type DateRange } from '@/lib/analytics/range';
const PRESET_LABELS: Record<'today' | '7d' | '30d' | '90d', string> = { const PRESET_LABELS: Record<'today' | '7d' | '30d' | '90d', string> = {
@@ -43,8 +37,10 @@ function rangeLabel(range: DateRange): string {
interface MeData { interface MeData {
data?: { data?: {
firstName?: string | null; profile?: {
displayName?: string | null; firstName?: string | null;
displayName?: string | null;
};
}; };
} }
@@ -58,8 +54,14 @@ function timeOfDayGreeting(): string {
export function DashboardShell() { export function DashboardShell() {
const [range, setRange] = useState<DateRange>('30d'); const [range, setRange] = useState<DateRange>('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 // Reuses the existing ['me'] cache (5-minute staleTime) populated by
// useTablePreferences elsewhere — usually a cache hit, so no extra // useTablePreferences elsewhere — usually a cache hit, so no extra
@@ -70,7 +72,7 @@ export function DashboardShell() {
queryFn: ({ signal }) => apiFetch<MeData>('/api/v1/me', { signal }), queryFn: ({ signal }) => apiFetch<MeData>('/api/v1/me', { signal }),
staleTime: 5 * 60_000, 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 // 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). // we don't know the user's first name yet (e.g. profile not filled out).
const greeting = firstName ? `${timeOfDayGreeting()}, ${firstName}` : 'Welcome back'; const greeting = firstName ? `${timeOfDayGreeting()}, ${firstName}` : 'Welcome back';
@@ -96,51 +98,103 @@ export function DashboardShell() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* 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). */}
<div className="sm:hidden">
<p className="text-xs font-semibold uppercase tracking-wide text-brand">Dashboard</p>
<h1 className="mt-1 text-xl font-bold tracking-tight text-foreground">{greeting}</h1>
</div>
<PageHeader <PageHeader
title={greeting} title={greeting}
eyebrow="Dashboard" eyebrow="Dashboard"
description={`Live snapshot of ${portName} activity`} // The date-range subtitle only means something when at least
kpiLine={<span>{rangeLabel(range)}</span>} // 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 ? <span>{rangeLabel(range)}</span> : undefined}
variant="gradient" variant="gradient"
actions={<DateRangePicker value={range} onChange={setRange} />} actions={
<div className="flex items-center gap-2">
<DateRangePicker value={range} onChange={setRange} />
<CustomizeWidgetsMenu />
</div>
}
/> />
{/* `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 stretched to match the chart column's row height, which forces
MyRemindersRail (or any other child with `h-full`) to push later MyRemindersRail (or any other child with `h-full`) to push later
children out of the aside's box and into the rows below where children out of the aside's box. */}
ActivityFeed renders. */} {/* Charts + rails. Layout adapts to which regions have content so
<div className="grid gap-4 grid-cols-1 items-start xl:grid-cols-[minmax(0,1fr)_320px]"> we never leave a 320px stripe of dead space when only one side
<div className="grid gap-4 grid-cols-1 lg:grid-cols-2"> is populated:
<WidgetErrorBoundary> both → main 1fr column + 320px rail (the original layout)
<PipelineFunnelChart range={range} /> charts only → single full-width auto-fit chart grid
</WidgetErrorBoundary> rails only → rails widen into an auto-fit grid (no fixed 320)
<WidgetErrorBoundary> neither → nothing renders
<OccupancyTimelineChart range={range} /> The chart grid uses `minmax(360px, 1fr)` so a lone chart fills
</WidgetErrorBoundary> the row; the rails-only grid uses a slightly tighter `280px`
<WidgetErrorBoundary> minimum so KPI tiles + rails fit 3-4 across on a wide viewport
<RevenueBreakdownChart range={range} /> instead of stretching to 600px+ each. */}
</WidgetErrorBoundary> {charts.length > 0 && rails.length > 0 ? (
<WidgetErrorBoundary> <div className="grid gap-4 grid-cols-1 items-start xl:grid-cols-[minmax(0,1fr)_320px]">
<LeadSourceChart range={range} /> <div className="grid gap-4 grid-cols-1 lg:grid-cols-[repeat(auto-fit,minmax(360px,1fr))]">
</WidgetErrorBoundary> {charts.map((w) => (
<WidgetCell key={w.id} widget={w} range={range} />
))}
</div>
<aside className="min-w-0 space-y-4">
{rails.map((w) => (
<WidgetCell key={w.id} widget={w} range={range} />
))}
</aside>
</div> </div>
<aside className="min-w-0 space-y-4"> ) : charts.length > 0 ? (
{/* Soft-fail tile linking to /website-analytics. Hidden if Umami <div className="grid gap-4 grid-cols-1 lg:grid-cols-[repeat(auto-fit,minmax(360px,1fr))]">
isn't configured for this port. */} {charts.map((w) => (
<WidgetErrorBoundary> <WidgetCell key={w.id} widget={w} range={range} />
<WebsiteGlanceTile /> ))}
</WidgetErrorBoundary> </div>
<WidgetErrorBoundary> ) : rails.length > 0 ? (
<MyRemindersRail /> <div className="grid gap-4 grid-cols-1 sm:grid-cols-[repeat(auto-fit,minmax(280px,1fr))]">
</WidgetErrorBoundary> {rails.map((w) => (
<WidgetErrorBoundary> <WidgetCell key={w.id} widget={w} range={range} />
<AlertRail /> ))}
</WidgetErrorBoundary> </div>
</aside> ) : null}
</div>
<ActivityFeed /> {feed.map((w) => (
<WidgetCell key={w.id} widget={w} range={range} />
))}
{visibleWidgets.length === 0 ? <EmptyDashboardHint /> : null}
</div> </div>
); );
} }
/**
* 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 (
<div className="flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border bg-card/40 px-6 py-16 text-center">
<p className="text-sm font-medium text-foreground">No widgets on your dashboard yet</p>
<p className="max-w-sm text-sm text-muted-foreground">
Click <span className="font-medium text-foreground">Customize</span> above to pick which
analytics cards appear here.
</p>
</div>
);
}
function WidgetCell({ widget, range }: { widget: DashboardWidget; range: DateRange }) {
return <WidgetErrorBoundary>{widget.render(range)}</WidgetErrorBoundary>;
}

View File

@@ -126,7 +126,15 @@ export function DateRangePicker({ value, onChange, className }: DateRangePickerP
{isCustom ? formatCustom(value) : 'Custom'} {isCustom ? formatCustom(value) : 'Custom'}
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent align="end" className="w-[260px] p-3"> <PopoverContent
align="end"
// min() caps the popover at "viewport minus 16px" on narrow
// phones so it never overflows; otherwise sits at a compact
// 260px. Date inputs inside use w-auto so iOS's intrinsic
// date-input width (which ignores parent constraints) sizes
// to its own content rather than overflowing.
className="w-[min(260px,calc(100vw-1rem))] p-3"
>
<div className="space-y-3"> <div className="space-y-3">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> <div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Custom range Custom range
@@ -141,7 +149,7 @@ export function DateRangePicker({ value, onChange, className }: DateRangePickerP
empty result, and not understand why. */ empty result, and not understand why. */
max={draftTo && draftTo < today ? draftTo : today} max={draftTo && draftTo < today ? draftTo : today}
onChange={(e) => setDraftFrom(e.target.value)} 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"
/> />
</label> </label>
<label className="block text-xs"> <label className="block text-xs">
@@ -152,7 +160,7 @@ export function DateRangePicker({ value, onChange, className }: DateRangePickerP
min={draftFrom || undefined} min={draftFrom || undefined}
max={today} max={today}
onChange={(e) => setDraftTo(e.target.value)} onChange={(e) => setDraftTo(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"
/> />
</label> </label>
<div className="flex items-center justify-end gap-2 pt-1"> <div className="flex items-center justify-end gap-2 pt-1">

View File

@@ -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<string, string> = {
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<HotDealsResponse>({
queryKey: ['dashboard', 'hot_deals'],
queryFn: () => apiFetch<HotDealsResponse>('/api/v1/dashboard/hot-deals'),
staleTime: 60_000,
});
const deals = data?.data ?? [];
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-1.5 text-base">
<Flame className="size-4 text-orange-500" aria-hidden />
Hot deals
</CardTitle>
<CardDescription>Active interests closest to closing.</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-3">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
) : deals.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">
No active deals to chase. New leads will surface here once they advance past Open.
</p>
) : (
<ul className="space-y-1">
{deals.map((d) => (
<li key={d.id}>
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/interests/${d.id}` as any}
className="-mx-2 flex items-center justify-between gap-3 rounded-md px-2 py-2 hover:bg-accent/60"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">{d.clientName}</p>
<p className="text-xs text-muted-foreground">
{d.mooringNumber ? `Berth ${d.mooringNumber}` : 'No berth linked'}
{d.lastContact ? (
<>
{' · '}
last touched {formatDistanceToNow(new Date(d.lastContact))} ago
</>
) : null}
</p>
</div>
<Badge variant="secondary" className="shrink-0 text-[10px]">
{STAGE_LABELS[d.stage] ?? d.stage}
</Badge>
</Link>
</li>
))}
</ul>
)}
</CardContent>
</Card>
);
}

View File

@@ -7,6 +7,8 @@ import { EmptyState } from '@/components/shared/empty-state';
import { ChartCard } from './chart-card'; import { ChartCard } from './chart-card';
import { useLeadSource } from './use-analytics'; import { useLeadSource } from './use-analytics';
import type { DateRange } from '@/lib/services/analytics.service'; 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 { interface Props {
range: DateRange; range: DateRange;
@@ -20,10 +22,11 @@ const COLORS = [
'hsl(var(--chart-5))', '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<string, string> = { const SOURCE_LABELS: Record<string, string> = {
website: 'Website', ...CANONICAL_SOURCE_LABELS,
referral: 'Referral',
manual: 'Manual',
social: 'Social', social: 'Social',
unspecified: 'Unspecified', unspecified: 'Unspecified',
}; };
@@ -48,7 +51,7 @@ export function LeadSourceChart({ range }: Props) {
<ChartCard <ChartCard
title="Lead Source Attribution" title="Lead Source Attribution"
description="Where new interests came from" description="Where new interests came from"
exportFilename={`lead-source-${range}`} exportFilename={`lead-source-${rangeToSlug(range)}`}
toCsv={toCsv} toCsv={toCsv}
> >
{isLoading ? ( {isLoading ? (

View File

@@ -87,7 +87,7 @@ export function MyRemindersRail() {
) : null} ) : null}
</div> </div>
<Link <Link
href={`/${portSlug}/reminders` as never} href={`/${portSlug}/inbox#reminders` as never}
className="text-xs font-medium text-primary hover:underline" className="text-xs font-medium text-primary hover:underline"
> >
View all View all

View File

@@ -15,6 +15,7 @@ import { EmptyState } from '@/components/shared/empty-state';
import { ChartCard } from './chart-card'; import { ChartCard } from './chart-card';
import { useOccupancy } from './use-analytics'; import { useOccupancy } from './use-analytics';
import type { DateRange } from '@/lib/services/analytics.service'; import type { DateRange } from '@/lib/services/analytics.service';
import { rangeToSlug } from '@/lib/analytics/range';
interface Props { interface Props {
range: DateRange; range: DateRange;
@@ -41,7 +42,7 @@ export function OccupancyTimelineChart({ range }: Props) {
<ChartCard <ChartCard
title="Occupancy Timeline" title="Occupancy Timeline"
description="Daily berth occupancy across the range" description="Daily berth occupancy across the range"
exportFilename={`occupancy-timeline-${range}`} exportFilename={`occupancy-timeline-${rangeToSlug(range)}`}
toCsv={toCsv} toCsv={toCsv}
> >
{isLoading ? ( {isLoading ? (

View File

@@ -9,6 +9,7 @@ import { STAGE_SHORT_LABELS, safeStage, stageLabel } from '@/lib/constants';
import { ChartCard } from './chart-card'; import { ChartCard } from './chart-card';
import { useFunnel } from './use-analytics'; import { useFunnel } from './use-analytics';
import type { DateRange } from '@/lib/services/analytics.service'; import type { DateRange } from '@/lib/services/analytics.service';
import { rangeToSlug } from '@/lib/analytics/range';
interface Props { interface Props {
range: DateRange; range: DateRange;
@@ -38,7 +39,7 @@ export function PipelineFunnelChart({ range }: Props) {
<ChartCard <ChartCard
title="Pipeline Funnel" title="Pipeline Funnel"
description="Interests by stage with conversion rate vs. open" description="Interests by stage with conversion rate vs. open"
exportFilename={`pipeline-funnel-${range}`} exportFilename={`pipeline-funnel-${rangeToSlug(range)}`}
toCsv={toCsv} toCsv={toCsv}
> >
{isLoading ? ( {isLoading ? (

View File

@@ -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<KpiResponse>({
queryKey: ['dashboard', 'kpis'],
queryFn: () => apiFetch<KpiResponse>('/api/v1/dashboard/kpis'),
staleTime: 60_000,
});
return (
<Card>
{/* `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. */}
<CardContent className="flex items-center gap-3 pt-5 pb-5">
<div className="flex size-10 shrink-0 items-center justify-center rounded-md bg-accent text-foreground">
<DollarSign className="size-5" aria-hidden />
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Pipeline value
</p>
{isLoading ? (
<Skeleton className="mt-1 h-7 w-24" />
) : (
<p
className="truncate text-2xl font-bold leading-tight text-foreground"
title={formatCurrency(data?.pipelineValueUsd ?? 0, 'USD')}
>
{formatCurrency(data?.pipelineValueUsd ?? 0, 'USD', { maxFractionDigits: 0 })}
</p>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -7,6 +7,7 @@ import { EmptyState } from '@/components/shared/empty-state';
import { ChartCard } from './chart-card'; import { ChartCard } from './chart-card';
import { useRevenue } from './use-analytics'; import { useRevenue } from './use-analytics';
import type { DateRange } from '@/lib/services/analytics.service'; import type { DateRange } from '@/lib/services/analytics.service';
import { rangeToSlug } from '@/lib/analytics/range';
import { formatCurrency } from '@/lib/utils/currency'; import { formatCurrency } from '@/lib/utils/currency';
interface Props { interface Props {
@@ -42,7 +43,7 @@ export function RevenueBreakdownChart({ range }: Props) {
<ChartCard <ChartCard
title="Revenue Breakdown" title="Revenue Breakdown"
description="Invoice totals grouped by status and currency" description="Invoice totals grouped by status and currency"
exportFilename={`revenue-breakdown-${range}`} exportFilename={`revenue-breakdown-${rangeToSlug(range)}`}
toCsv={toCsv} toCsv={toCsv}
> >
{isLoading ? ( {isLoading ? (

View File

@@ -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<SourceConversionResponse>({
queryKey: ['dashboard', 'source_conversion'],
queryFn: () => apiFetch<SourceConversionResponse>('/api/v1/dashboard/source-conversion'),
staleTime: 60_000,
});
const rows = data?.data ?? [];
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Source conversion</CardTitle>
<CardDescription>Won deals as a percentage of leads per source.</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-3">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
) : rows.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">
Once interests have a source assigned, conversion rates will appear here.
</p>
) : (
<ul className="space-y-3">
{rows.map((r) => {
const pct = Math.round(r.conversionRate * 100);
const label = r.source
.replace(/_/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase());
return (
<li key={r.source} className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="font-medium text-foreground">{label}</span>
<span className="tabular-nums text-muted-foreground">
<span className="font-semibold text-foreground">{pct}%</span>
<span className="ml-1.5">
({r.won} won · {r.total} total)
</span>
</span>
</div>
{/* Inline bar — keeps the widget compact and lets eight
rows share the same vertical space a Recharts plot
would use for two. */}
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${Math.max(pct, 2)}%` }}
/>
</div>
</li>
);
})}
</ul>
)}
</CardContent>
</Card>
);
}

View File

@@ -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: () => <ActiveDealsTile />,
group: 'rail',
defaultVisible: false,
},
{
id: 'kpi_pipeline_value',
label: 'Pipeline Value',
description: 'Compact tile: total berth value of active deals (USD).',
render: () => <PipelineValueTile />,
group: 'rail',
defaultVisible: false,
},
// ── Charts (main area) ──────────────────────────────────────────────
{
id: 'pipeline_funnel',
label: 'Pipeline Funnel',
description: 'Interests by stage with conversion-rate vs open.',
render: (range) => <PipelineFunnelChart range={range} />,
group: 'chart',
defaultVisible: true,
},
{
id: 'occupancy_timeline',
label: 'Occupancy Timeline',
description: 'Daily berth occupancy across the range.',
render: (range) => <OccupancyTimelineChart range={range} />,
group: 'chart',
defaultVisible: true,
},
{
id: 'revenue_breakdown',
label: 'Revenue Breakdown',
description: 'Invoice totals grouped by status and currency.',
render: (range) => <RevenueBreakdownChart range={range} />,
group: 'chart',
defaultVisible: true,
},
{
id: 'lead_source',
label: 'Lead Source Attribution',
description: 'Where new interests came from.',
render: (range) => <LeadSourceChart range={range} />,
group: 'chart',
defaultVisible: true,
},
{
id: 'berth_status',
label: 'Berth Status',
description: 'Donut: available / under offer / sold split.',
render: () => <BerthStatusChart />,
group: 'chart',
defaultVisible: false,
},
{
id: 'source_conversion',
label: 'Source Conversion',
description: 'Win rate per lead source — which channels deliver buyers, not just leads.',
render: () => <SourceConversionChart />,
group: 'chart',
defaultVisible: false,
},
{
id: 'website_analytics',
label: 'Website Analytics',
description: 'Quick glance at marketing site traffic. Requires Umami.',
render: () => <WebsiteGlanceTile />,
group: 'rail',
defaultVisible: true,
selfGates: true,
requires: 'umami',
},
{
id: 'my_reminders',
label: 'My Reminders',
description: 'Your upcoming and overdue reminders.',
render: () => <MyRemindersRail />,
group: 'rail',
defaultVisible: true,
},
{
id: 'alerts',
label: 'Alerts',
description: 'System-flagged action items.',
render: () => <AlertRail />,
group: 'rail',
defaultVisible: true,
},
{
id: 'hot_deals',
label: 'Hot Deals',
description: 'Top 5 active interests closest to closing.',
render: () => <HotDealsCard />,
group: 'rail',
defaultVisible: false,
},
{
id: 'activity_feed',
label: 'Recent Activity',
description: 'Audit log of changes across the port.',
render: () => <ActivityFeed />,
group: 'feed',
defaultVisible: true,
},
];
/** Lookup helper so consumers don't have to scan the array. */
export const WIDGETS_BY_ID: Record<string, DashboardWidget> = Object.fromEntries(
DASHBOARD_WIDGETS.map((w) => [w.id, w]),
);

View File

@@ -230,8 +230,8 @@ export function CreateDocumentWizard({ portSlug }: CreateDocumentWizardProps) {
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="documenso-template">Documenso renders + signs</SelectItem> <SelectItem value="documenso-template">Generated EOI rendered + signed externally</SelectItem>
<SelectItem value="inapp">Render in CRM, sign via Documenso</SelectItem> <SelectItem value="inapp">Manual EOI rendered in CRM, sent for e-signature</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>

View File

@@ -157,7 +157,7 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) {
}; };
const handleCancel = async () => { 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); setIsCancelling(true);
try { try {
await apiFetch(`/api/v1/documents/${documentId}/cancel`, { method: 'POST' }); await apiFetch(`/api/v1/documents/${documentId}/cancel`, { method: 'POST' });

View File

@@ -70,7 +70,7 @@ export function DocumentTemplatePicker({
})(); })();
return ( return (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen} modal>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
variant="outline" variant="outline"

View File

@@ -6,10 +6,18 @@ import { useQueryClient } from '@tanstack/react-query';
import { ChevronDown, ChevronRight, FileText, Plus, Upload } from 'lucide-react'; import { ChevronDown, ChevronRight, FileText, Plus, Upload } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill'; import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
import { EmptyState } from '@/components/ui/empty-state'; import { EmptyState } from '@/components/ui/empty-state';
import { FileUploadZone } from '@/components/files/file-upload-zone';
import { PageHeader } from '@/components/shared/page-header'; import { PageHeader } from '@/components/shared/page-header';
import { PermissionGate } from '@/components/shared/permission-gate'; import { PermissionGate } from '@/components/shared/permission-gate';
import { usePaginatedQuery } from '@/hooks/use-paginated-query'; import { usePaginatedQuery } from '@/hooks/use-paginated-query';
@@ -219,6 +227,8 @@ function FlatFolderListing({ portSlug, folderId }: FlatFolderListingProps) {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [typeFilter, setTypeFilter] = useState<string | undefined>(undefined); const [typeFilter, setTypeFilter] = useState<string | undefined>(undefined);
const [expandedDocId, setExpandedDocId] = useState<string | null>(null); const [expandedDocId, setExpandedDocId] = useState<string | null>(null);
const [uploadOpen, setUploadOpen] = useState(false);
const queryClient = useQueryClient();
const queryParams = useMemo(() => { const queryParams = useMemo(() => {
const params = new URLSearchParams(); const params = new URLSearchParams();
@@ -364,19 +374,48 @@ function FlatFolderListing({ portSlug, folderId }: FlatFolderListingProps) {
<EmptyState <EmptyState
icon={<FileText className="h-7 w-7" />} icon={<FileText className="h-7 w-7" />}
title="No documents in this folder" title="No documents in this folder"
body="Create a document or move existing ones here." body="Upload a file, generate a signing flow, or move existing documents here."
actions={ actions={
<Button asChild> <>
<Link href={`/${portSlug}/documents/new`}> <Button onClick={() => setUploadOpen(true)}>
<Plus className="mr-1.5 h-4 w-4" /> <Upload className="mr-1.5 h-4 w-4" />
New document Upload file
</Link> </Button>
</Button> <Button asChild variant="outline">
<Link href={`/${portSlug}/documents/new`}>
<Plus className="mr-1.5 h-4 w-4" />
Generate for signing
</Link>
</Button>
</>
} }
/> />
) : ( ) : (
<ul className="rounded-md border bg-white shadow-xs">{documents.map(renderRow)}</ul> <ul className="rounded-md border bg-white shadow-xs">{documents.map(renderRow)}</ul>
)} )}
<Dialog open={uploadOpen} onOpenChange={setUploadOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Upload file</DialogTitle>
<DialogDescription>
{folderId === null
? 'File will be added to the root.'
: 'File will be added to the current folder.'}
</DialogDescription>
</DialogHeader>
<FileUploadZone
folderId={folderId}
onUploadComplete={(file) => {
if (!file) {
queryClient.invalidateQueries({ queryKey: ['files'] });
queryClient.invalidateQueries({ queryKey: ['documents'] });
setUploadOpen(false);
}
}}
/>
</DialogContent>
</Dialog>
</> </>
); );
} }

View File

@@ -235,7 +235,7 @@ export function EoiGenerateDialog({
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value={DOCUMENSO_TEMPLATE_VALUE}> <SelectItem value={DOCUMENSO_TEMPLATE_VALUE}>
Documenso Standard EOI (recommended) Standard EOI sent for e-signature (recommended)
</SelectItem> </SelectItem>
{inAppTemplates.map((t) => ( {inAppTemplates.map((t) => (
<SelectItem key={t.id} value={t.id}> <SelectItem key={t.id} value={t.id}>

View File

@@ -77,83 +77,88 @@ export function FolderActionsMenu({ selectedFolderId, onAfterDelete }: FolderAct
return ( return (
<> <>
<DropdownMenu> <div className="flex items-center gap-1">
<DropdownMenuTrigger asChild> <Button
<Button variant="ghost" size="icon"> variant="outline"
<MoreHorizontal className="h-4 w-4" /> size="sm"
<span className="sr-only">Folder actions</span> className="flex-1 justify-start"
</Button> onClick={() => {
</DropdownMenuTrigger> setName('');
<DropdownMenuContent align="end"> setCreateOpen(true);
<DropdownMenuItem }}
onClick={() => { >
setName(''); <FolderPlus className="mr-2 h-4 w-4" />
setCreateOpen(true); New folder {isFolderSelected ? 'inside this' : 'at root'}
}} </Button>
> {isFolderSelected ? (
<FolderPlus className="mr-2 h-4 w-4" /> <DropdownMenu>
New folder {isFolderSelected ? 'inside this' : 'at root'} <DropdownMenuTrigger asChild>
</DropdownMenuItem> <Button variant="ghost" size="icon">
{isFolderSelected ? ( <MoreHorizontal className="h-4 w-4" />
<TooltipProvider> <span className="sr-only">More folder actions</span>
<Tooltip> </Button>
<TooltipTrigger asChild> </DropdownMenuTrigger>
<span> <DropdownMenuContent align="end">
<DropdownMenuItem <TooltipProvider>
disabled={isSystem} <Tooltip>
onClick={() => { <TooltipTrigger asChild>
if (isSystem) return; <span>
setName(currentName); <DropdownMenuItem
setRenameOpen(true); disabled={isSystem}
}} onClick={() => {
> if (isSystem) return;
<Pencil className="mr-2 h-4 w-4" /> setName(currentName);
Rename setRenameOpen(true);
</DropdownMenuItem> }}
</span> >
</TooltipTrigger> <Pencil className="mr-2 h-4 w-4" />
{isSystem ? ( Rename
<TooltipContent>System folders can&apos;t be renamed.</TooltipContent> </DropdownMenuItem>
) : null} </span>
</Tooltip> </TooltipTrigger>
<Tooltip> {isSystem ? (
<TooltipTrigger asChild> <TooltipContent>System folders can&apos;t be renamed.</TooltipContent>
<span> ) : null}
<ConfirmationDialog </Tooltip>
trigger={ <Tooltip>
<DropdownMenuItem <TooltipTrigger asChild>
disabled={isSystem} <span>
onSelect={(e) => e.preventDefault()} <ConfirmationDialog
className="text-destructive" trigger={
> <DropdownMenuItem
<Trash2 className="mr-2 h-4 w-4" /> disabled={isSystem}
Delete onSelect={(e) => e.preventDefault()}
</DropdownMenuItem> className="text-destructive"
} >
title="Delete folder?" <Trash2 className="mr-2 h-4 w-4" />
description="Subfolders and documents inside will move up to the parent. The folder itself is removed." Delete
confirmLabel="Delete folder" </DropdownMenuItem>
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);
} }
}} title="Delete folder?"
/> description="Subfolders and documents inside will move up to the parent. The folder itself is removed."
</span> confirmLabel="Delete folder"
</TooltipTrigger> loading={deleteMutation.isPending}
{isSystem ? ( onConfirm={async () => {
<TooltipContent>System folders can&apos;t be deleted.</TooltipContent> try {
) : null} await deleteMutation.mutateAsync(selectedFolderId as string);
</Tooltip> toast.success('Folder deleted; contents moved to parent.');
</TooltipProvider> onAfterDelete?.();
) : null} } catch (err) {
</DropdownMenuContent> toastError(err);
</DropdownMenu> }
}}
/>
</span>
</TooltipTrigger>
{isSystem ? (
<TooltipContent>System folders can&apos;t be deleted.</TooltipContent>
) : null}
</Tooltip>
</TooltipProvider>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}> <Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">

View File

@@ -100,7 +100,7 @@ function TreeBody({
onClick={() => onSelect(undefined)} onClick={() => onSelect(undefined)}
/> />
<PseudoRow <PseudoRow
label="Root (no folder)" label="Root"
icon={Folder} icon={Folder}
active={selectedFolderId === null} active={selectedFolderId === null}
onClick={() => onSelect(null)} onClick={() => onSelect(null)}

View File

@@ -83,7 +83,7 @@ export function NewDocumentMenu({
<div className="flex flex-col"> <div className="flex flex-col">
<span>Generate document for signing</span> <span>Generate document for signing</span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
EOI, contract, or custom sent via Documenso EOI, contract, or custom sent for e-signature
</span> </span>
</div> </div>
</Link> </Link>

View File

@@ -61,7 +61,7 @@ export function TripLabelCombobox({
}; };
return ( return (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen} modal>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
id={id} id={id}

View File

@@ -0,0 +1,153 @@
'use client';
import { useEffect, useState } from 'react';
import { Bell, ChevronDown, ShieldAlert } from 'lucide-react';
import { cn } from '@/lib/utils';
import { PageHeader } from '@/components/shared/page-header';
import { AlertsPageShell } from '@/components/alerts/alerts-page-shell';
import { ReminderList } from '@/components/reminders/reminder-list';
import { useAlertCount } from '@/components/alerts/use-alerts';
/**
* Merged "Inbox" surface — replaces the previously-separate /alerts and
* /reminders pages. Two stacked sections (Alerts first, Reminders second)
* preserve the source distinction (system-flagged vs user-set) while
* giving reps a single "things demanding my attention" surface.
*
* Sections are collapsible; collapsed state persists in localStorage per
* section so reps can default to the layout they prefer.
*
* URL anchors:
* /inbox#alerts → ensures Alerts section is expanded + scrolls to it
* /inbox#reminders → ensures Reminders section is expanded + scrolls to it
*
* The legacy /alerts and /reminders routes redirect here with the
* appropriate hash, so old bookmarks land in the right place.
*/
export function InboxPageShell() {
const [alertsOpen, setAlertsOpen] = useState(true);
const [remindersOpen, setRemindersOpen] = useState(true);
const { data: alertCount } = useAlertCount();
// Hydrate collapsed state from localStorage on mount. Stored as
// 'true'/'false' strings; missing keys default to expanded.
useEffect(() => {
const a = localStorage.getItem('inbox.alerts.open');
const r = localStorage.getItem('inbox.reminders.open');
if (a === 'false') setAlertsOpen(false);
if (r === 'false') setRemindersOpen(false);
}, []);
// Honor URL hash: ensure the targeted section is expanded then scroll.
// Runs once on mount AND on hashchange so deep-linking from another tab
// / page works the same as initial navigation.
useEffect(() => {
function applyHash() {
const hash = window.location.hash.replace('#', '');
if (hash === 'alerts') {
setAlertsOpen(true);
document.getElementById('inbox-section-alerts')?.scrollIntoView({ behavior: 'smooth' });
} else if (hash === 'reminders') {
setRemindersOpen(true);
document.getElementById('inbox-section-reminders')?.scrollIntoView({ behavior: 'smooth' });
}
}
applyHash();
window.addEventListener('hashchange', applyHash);
return () => window.removeEventListener('hashchange', applyHash);
}, []);
function toggleAlerts() {
const next = !alertsOpen;
setAlertsOpen(next);
localStorage.setItem('inbox.alerts.open', String(next));
}
function toggleReminders() {
const next = !remindersOpen;
setRemindersOpen(next);
localStorage.setItem('inbox.reminders.open', String(next));
}
const activeAlerts = alertCount?.total ?? 0;
return (
<div className="space-y-6">
<PageHeader
title="Alerts & Reminders"
eyebrow="Action items"
description="Alerts the system has flagged plus your scheduled follow-ups, in one place."
variant="gradient"
/>
<section id="inbox-section-alerts" className="rounded-lg border bg-card shadow-xs">
<SectionHeader
icon={<ShieldAlert className="size-4 text-muted-foreground" aria-hidden />}
label="Alerts"
count={activeAlerts}
open={alertsOpen}
onToggle={toggleAlerts}
/>
{alertsOpen ? (
<div className="border-t px-4 pb-4 pt-3">
<AlertsPageShell embedded />
</div>
) : null}
</section>
<section id="inbox-section-reminders" className="rounded-lg border bg-card shadow-xs">
<SectionHeader
icon={<Bell className="size-4 text-muted-foreground" aria-hidden />}
label="Reminders"
open={remindersOpen}
onToggle={toggleReminders}
/>
{remindersOpen ? (
<div className="border-t px-4 pb-4 pt-3">
<ReminderList embedded />
</div>
) : null}
</section>
</div>
);
}
function SectionHeader({
icon,
label,
count,
open,
onToggle,
}: {
icon: React.ReactNode;
label: string;
count?: number;
open: boolean;
onToggle: () => void;
}) {
return (
<button
type="button"
onClick={onToggle}
aria-expanded={open}
className={cn(
'flex w-full items-center justify-between gap-2 px-4 py-3 text-left',
'min-h-[48px] hover:bg-muted/30',
)}
>
<span className="flex items-center gap-2">
{icon}
<span className="text-sm font-semibold text-foreground">{label}</span>
{count !== undefined && count > 0 ? (
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
{count}
</span>
) : null}
</span>
<ChevronDown
className={cn('size-4 text-muted-foreground transition-transform', open && 'rotate-180')}
aria-hidden
/>
</button>
);
}

View File

@@ -76,7 +76,7 @@ export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSucc
<DialogHeader> <DialogHeader>
<DialogTitle>Upload externally-signed EOI</DialogTitle> <DialogTitle>Upload externally-signed EOI</DialogTitle>
<DialogDescription> <DialogDescription>
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 uploaded PDF is filed against this interest and the pipeline stage is advanced to EOI
Signed. Signed.
</DialogDescription> </DialogDescription>

View File

@@ -18,7 +18,12 @@ import {
deriveInitials, deriveInitials,
} from '@/components/shared/list-card'; } from '@/components/shared/list-card';
import { cn } from '@/lib/utils'; 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 { computeUrgencyBadges } from '@/components/interests/urgency';
import type { InterestRow } from './interest-columns'; import type { InterestRow } from './interest-columns';
@@ -28,13 +33,6 @@ const CATEGORY_LABELS: Record<string, string> = {
hot_lead: 'Hot lead', hot_lead: 'Hot lead',
}; };
const SOURCE_LABELS: Record<string, string> = {
website: 'Website',
manual: 'Manual',
referral: 'Referral',
broker: 'Broker',
};
interface InterestCardProps { interface InterestCardProps {
interest: InterestRow; interest: InterestRow;
portSlug: string; portSlug: string;
@@ -48,7 +46,7 @@ export function InterestCard({ interest, portSlug, onEdit, onArchive }: Interest
const accentClass = stageDotClass(interest.pipelineStage); const accentClass = stageDotClass(interest.pipelineStage);
const isHotLead = interest.leadCategory === 'hot_lead'; const isHotLead = interest.leadCategory === 'hot_lead';
const categoryLabel = interest.leadCategory ? CATEGORY_LABELS[interest.leadCategory] : null; 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 tags = interest.tags ?? [];
const notesCount = interest.notesCount ?? 0; const notesCount = interest.notesCount ?? 0;
const urgencyBadges = computeUrgencyBadges(interest); const urgencyBadges = computeUrgencyBadges(interest);

View File

@@ -36,6 +36,7 @@ import {
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -391,14 +392,47 @@ function ComposeDialog({
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-2 rounded-md border bg-muted/30 p-3">
<Label htmlFor="cl-followup">Follow up by (optional creates a reminder)</Label> <label
<Input className="flex items-center gap-2 text-sm font-medium cursor-pointer select-none"
id="cl-followup" htmlFor="cl-followup-toggle"
type="datetime-local" >
value={followUpAt} <Checkbox
onChange={(e) => setFollowUpAt(e.target.value)} id="cl-followup-toggle"
/> checked={!!followUpAt}
onCheckedChange={(v) => {
if (v) {
// Default to a week from now @ 09:00 local so reps get a
// usable cadence without having to type a date.
const d = new Date();
d.setDate(d.getDate() + 7);
d.setHours(9, 0, 0, 0);
const tz = d.getTimezoneOffset() * 60_000;
setFollowUpAt(new Date(d.getTime() - tz).toISOString().slice(0, 16));
} else {
setFollowUpAt('');
}
}}
/>
Add follow-up reminder?
</label>
{followUpAt ? (
<div className="space-y-1 pl-6">
<Label htmlFor="cl-followup" className="text-xs text-muted-foreground">
Remind me on
</Label>
<Input
id="cl-followup"
type="datetime-local"
value={followUpAt}
onChange={(e) => setFollowUpAt(e.target.value)}
className="max-w-xs"
/>
<p className="text-[11px] text-muted-foreground">
A reminder is created on this interest for the time above.
</p>
</div>
) : null}
</div> </div>
</div> </div>

View File

@@ -278,7 +278,7 @@ function ActiveContractCard({
</div> </div>
) : signers.length === 0 ? ( ) : signers.length === 0 ? (
<p className="text-sm text-muted-foreground italic"> <p className="text-sm text-muted-foreground italic">
Documenso hasn&apos;t reported signers yet check back in a moment. The signing service hasn&apos;t reported signers yet check back in a moment.
</p> </p>
) : ( ) : (
<SigningProgress documentId={doc.id} signers={signers} /> <SigningProgress documentId={doc.id} signers={signers} />
@@ -341,7 +341,7 @@ function EmptyContractState({
</h2> </h2>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
Sales contracts are drafted custom per deal. Either upload a paper-signed copy you handled 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.
</p> </p>
<div className="mt-5 flex flex-wrap items-center justify-center gap-2"> <div className="mt-5 flex flex-wrap items-center justify-center gap-2">
<Button onClick={onUploadForSigning} size="sm" className="gap-1.5"> <Button onClick={onUploadForSigning} size="sm" className="gap-1.5">

View File

@@ -33,6 +33,7 @@ const OUTCOME_BADGE: Record<string, { label: string; className: string }> = {
lost_other_marina: { label: 'Lost - other marina', className: 'bg-rose-100 text-rose-700' }, lost_other_marina: { label: 'Lost - other marina', className: 'bg-rose-100 text-rose-700' },
lost_unqualified: { label: 'Lost - unqualified', className: 'bg-rose-100 text-rose-700' }, lost_unqualified: { label: 'Lost - unqualified', className: 'bg-rose-100 text-rose-700' },
lost_no_response: { label: 'Lost - no response', className: 'bg-rose-100 text-rose-700' }, lost_no_response: { label: 'Lost - no response', className: 'bg-rose-100 text-rose-700' },
lost_other: { label: 'Lost - other', className: 'bg-rose-100 text-rose-700' },
cancelled: { label: 'Cancelled', className: 'bg-slate-200 text-slate-700' }, cancelled: { label: 'Cancelled', className: 'bg-slate-200 text-slate-700' },
}; };

View File

@@ -267,7 +267,7 @@ function ActiveEoiCard({
</div> </div>
) : signers.length === 0 ? ( ) : signers.length === 0 ? (
<p className="text-sm text-muted-foreground italic"> <p className="text-sm text-muted-foreground italic">
Documenso hasn&apos;t reported signers yet check back in a moment. The signing service hasn&apos;t reported signers yet check back in a moment.
</p> </p>
) : ( ) : (
<SigningProgress documentId={doc.id} signers={signers} /> <SigningProgress documentId={doc.id} signers={signers} />
@@ -329,7 +329,7 @@ function EmptyEoiState({
No EOI in flight for this interest No EOI in flight for this interest
</h2> </h2>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
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. upload a paper-signed copy if it was signed outside the system.
</p> </p>
<div className="mt-5 flex flex-wrap items-center justify-center gap-2"> <div className="mt-5 flex flex-wrap items-center justify-center gap-2">

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useEffect } from 'react'; import { useEffect, useRef } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
@@ -18,6 +18,16 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet'; 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 { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { import {
Command, Command,
@@ -30,12 +40,13 @@ import {
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { TagPicker } from '@/components/shared/tag-picker'; import { TagPicker } from '@/components/shared/tag-picker';
import { ReminderDaysInput } from '@/components/shared/reminder-days-input';
import { YachtForm } from '@/components/yachts/yacht-form'; import { YachtForm } from '@/components/yachts/yacht-form';
import { YachtPicker } from '@/components/yachts/yacht-picker'; import { YachtPicker } from '@/components/yachts/yacht-picker';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { useEntityOptions } from '@/hooks/use-entity-options'; import { useEntityOptions } from '@/hooks/use-entity-options';
import { createInterestSchema, type CreateInterestInput } from '@/lib/validators/interests'; 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'; import { cn } from '@/lib/utils';
const CATEGORY_LABELS: Record<string, string> = { const CATEGORY_LABELS: Record<string, string> = {
@@ -77,14 +88,14 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
const [clientOpen, setClientOpen] = useState(false); const [clientOpen, setClientOpen] = useState(false);
const [berthOpen, setBerthOpen] = useState(false); const [berthOpen, setBerthOpen] = useState(false);
const [desiredUnit, setDesiredUnit] = useState<'ft' | 'm'>('ft');
const { const {
register,
handleSubmit, handleSubmit,
watch, watch,
setValue, setValue,
reset, reset,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting, isDirty },
} = useForm<CreateInterestInput>({ } = useForm<CreateInterestInput>({
resolver: zodResolver(createInterestSchema), resolver: zodResolver(createInterestSchema),
defaultValues: { defaultValues: {
@@ -102,6 +113,15 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
const selectedBerthId = watch('berthId'); const selectedBerthId = watch('berthId');
const selectedYachtId = watch('yachtId'); const selectedYachtId = watch('yachtId');
const [createYachtOpen, setCreateYachtOpen] = useState(false); 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 // Fetch the selected client's company memberships so the YachtPicker can
// include yachts owned by companies the client belongs to (e.g. a // 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); const selectedBerth = berthOptions.find((b) => b.value === selectedBerthId);
return ( return (
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet
open={open}
onOpenChange={(next) => {
if (next) {
onOpenChange(true);
return;
}
requestClose();
}}
>
<SheetContent className="w-full sm:max-w-2xl overflow-y-auto"> <SheetContent className="w-full sm:max-w-2xl overflow-y-auto">
<SheetHeader> <SheetHeader>
<SheetTitle>{isEdit ? 'Edit Interest' : 'New Interest'}</SheetTitle> <SheetTitle>{isEdit ? 'Edit Interest' : 'New Interest'}</SheetTitle>
@@ -215,7 +244,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
<div className="space-y-1"> <div className="space-y-1">
<Label>Client *</Label> <Label>Client *</Label>
<Popover open={clientOpen} onOpenChange={setClientOpen}> <Popover open={clientOpen} onOpenChange={setClientOpen} modal>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
variant="outline" variant="outline"
@@ -231,8 +260,13 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-[400px] p-0"> <PopoverContent className="w-[var(--radix-popper-anchor-width)] min-w-[280px] p-0">
<Command> {/* 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. */}
<Command shouldFilter={false}>
<CommandInput placeholder="Search clients..." onValueChange={setClientSearch} /> <CommandInput placeholder="Search clients..." onValueChange={setClientSearch} />
<CommandList> <CommandList>
<CommandEmpty> <CommandEmpty>
@@ -269,7 +303,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
<div className="space-y-1"> <div className="space-y-1">
<Label>Berth (optional)</Label> <Label>Berth (optional)</Label>
<Popover open={berthOpen} onOpenChange={setBerthOpen}> <Popover open={berthOpen} onOpenChange={setBerthOpen} modal>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
variant="outline" variant="outline"
@@ -284,8 +318,8 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-[400px] p-0"> <PopoverContent className="w-[var(--radix-popper-anchor-width)] min-w-[280px] p-0">
<Command> <Command shouldFilter={false}>
<CommandInput placeholder="Search berths..." onValueChange={setBerthSearch} /> <CommandInput placeholder="Search berths..." onValueChange={setBerthSearch} />
<CommandList> <CommandList>
<CommandEmpty> <CommandEmpty>
@@ -431,10 +465,11 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
<SelectValue placeholder="Select source" /> <SelectValue placeholder="Select source" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="website">Website</SelectItem> {SOURCES.map((s) => (
<SelectItem value="manual">Manual</SelectItem> <SelectItem key={s.value} value={s.value}>
<SelectItem value="referral">Referral</SelectItem> {s.label}
<SelectItem value="broker">Broker</SelectItem> </SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -444,48 +479,43 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
<Separator /> <Separator />
{/* Desired berth dimensions (recommender inputs) */} {/* Desired berth dimensions (recommender inputs) */}
<div className="space-y-2"> <div className="space-y-3">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide"> <div className="flex items-start justify-between gap-3">
Berth size desired <div>
</h3> <h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
<p className="text-xs text-muted-foreground"> Berth size desired
Imperial. Optional - the recommender treats blank fields as no constraint on that </h3>
axis. <p className="mt-1 text-xs text-muted-foreground">
</p> Optional - the recommender treats blank fields as no constraint on that axis.
<div className="grid grid-cols-3 gap-3"> </p>
<div className="space-y-1">
<Label htmlFor="desiredLengthFt">Length (ft)</Label>
<Input
id="desiredLengthFt"
{...register('desiredLengthFt')}
type="number"
step="0.01"
min={0}
placeholder="e.g. 60"
/>
</div>
<div className="space-y-1">
<Label htmlFor="desiredWidthFt">Width (ft)</Label>
<Input
id="desiredWidthFt"
{...register('desiredWidthFt')}
type="number"
step="0.01"
min={0}
placeholder="e.g. 18"
/>
</div>
<div className="space-y-1">
<Label htmlFor="desiredDraftFt">Draft (ft)</Label>
<Input
id="desiredDraftFt"
{...register('desiredDraftFt')}
type="number"
step="0.01"
min={0}
placeholder="e.g. 6"
/>
</div> </div>
<UnitToggle value={desiredUnit} onChange={setDesiredUnit} />
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<DimensionInput
htmlId="desiredLengthFt"
label="Length"
placeholder={desiredUnit === 'ft' ? 'e.g. 60' : 'e.g. 18.29'}
unit={desiredUnit}
ftValue={watch('desiredLengthFt') as string | undefined}
onChangeFt={(v) => setValue('desiredLengthFt', v, { shouldDirty: true })}
/>
<DimensionInput
htmlId="desiredWidthFt"
label="Width"
placeholder={desiredUnit === 'ft' ? 'e.g. 18' : 'e.g. 5.49'}
unit={desiredUnit}
ftValue={watch('desiredWidthFt') as string | undefined}
onChangeFt={(v) => setValue('desiredWidthFt', v, { shouldDirty: true })}
/>
<DimensionInput
htmlId="desiredDraftFt"
label="Draft"
placeholder={desiredUnit === 'ft' ? 'e.g. 6' : 'e.g. 1.83'}
unit={desiredUnit}
ftValue={watch('desiredDraftFt') as string | undefined}
onChangeFt={(v) => setValue('desiredDraftFt', v, { shouldDirty: true })}
/>
</div> </div>
</div> </div>
@@ -506,12 +536,11 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
</div> </div>
{reminderEnabled && ( {reminderEnabled && (
<div className="space-y-1"> <div className="space-y-1">
<Label>Reminder Days</Label> <Label htmlFor="reminderDays">Reminder cadence</Label>
<Input <ReminderDaysInput
{...register('reminderDays', { valueAsNumber: true })} id="reminderDays"
type="number" value={watch('reminderDays') ?? null}
min={1} onChange={(v) => setValue('reminderDays', v ?? undefined)}
placeholder="e.g. 7"
/> />
</div> </div>
)} )}
@@ -526,7 +555,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
</div> </div>
<SheetFooter> <SheetFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}> <Button type="button" variant="outline" onClick={requestClose}>
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={isSubmitting || mutation.isPending}> <Button type="submit" disabled={isSubmitting || mutation.isPending}>
@@ -537,6 +566,29 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
</Button> </Button>
</SheetFooter> </SheetFooter>
</form> </form>
<AlertDialog open={discardConfirmOpen} onOpenChange={setDiscardConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Discard unsaved changes?</AlertDialogTitle>
<AlertDialogDescription>
You&apos;ve filled in some fields. Closing now will lose them.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep editing</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setDiscardConfirmOpen(false);
onOpenChange(false);
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 focus:ring-destructive"
>
Discard
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</SheetContent> </SheetContent>
{createYachtOpen && selectedClientId && ( {createYachtOpen && selectedClientId && (
<YachtForm <YachtForm
@@ -548,3 +600,140 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
</Sheet> </Sheet>
); );
} }
// ── 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 (
<div
className="inline-flex rounded-md border bg-muted/30 p-0.5 text-xs"
role="radiogroup"
aria-label="Display unit"
>
{(['ft', 'm'] as const).map((u) => (
<button
key={u}
type="button"
role="radio"
aria-checked={value === u}
onClick={() => onChange(u)}
className={cn(
'h-7 rounded px-3 font-medium transition-colors',
value === u
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
>
{u}
</button>
))}
</div>
);
}
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<string>(() => 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 (
<div className="space-y-1">
<Label htmlFor={htmlId}>
{label} <span className="text-muted-foreground">({unit})</span>
</Label>
<Input
id={htmlId}
type="number"
inputMode="decimal"
step="0.01"
min={0}
placeholder={placeholder}
value={display}
onFocus={() => {
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 ? (
<p className="text-[11px] leading-tight text-muted-foreground"> {altValue}</p>
) : null}
</div>
);
}
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`;
}

View File

@@ -33,6 +33,7 @@ import {
} from '@/components/interests/interest-columns'; } from '@/components/interests/interest-columns';
import { ColumnPicker } from '@/components/shared/column-picker'; import { ColumnPicker } from '@/components/shared/column-picker';
import { SaveViewDialog } from '@/components/shared/save-view-dialog'; import { SaveViewDialog } from '@/components/shared/save-view-dialog';
import { useCreateFromUrl } from '@/hooks/use-create-from-url';
import { useTablePreferences } from '@/hooks/use-table-preferences'; import { useTablePreferences } from '@/hooks/use-table-preferences';
import { InterestCard } from '@/components/interests/interest-card'; import { InterestCard } from '@/components/interests/interest-card';
import { StageLegend } from '@/components/interests/stage-legend'; import { StageLegend } from '@/components/interests/stage-legend';
@@ -72,6 +73,7 @@ export function InterestList() {
}, [viewMode, setViewMode]); }, [viewMode, setViewMode]);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
useCreateFromUrl(() => setCreateOpen(true));
const [editInterest, setEditInterest] = useState<InterestRow | null>(null); const [editInterest, setEditInterest] = useState<InterestRow | null>(null);
const [archiveInterest, setArchiveInterest] = useState<InterestRow | null>(null); const [archiveInterest, setArchiveInterest] = useState<InterestRow | null>(null);
const [saveViewOpen, setSaveViewOpen] = useState(false); const [saveViewOpen, setSaveViewOpen] = useState(false);

View File

@@ -29,6 +29,7 @@ const OUTCOME_LABELS: Record<InterestOutcome, string> = {
lost_other_marina: 'Lost - went to another marina', lost_other_marina: 'Lost - went to another marina',
lost_unqualified: 'Lost - unqualified', lost_unqualified: 'Lost - unqualified',
lost_no_response: 'Lost - no response', lost_no_response: 'Lost - no response',
lost_other: 'Lost - other',
cancelled: 'Cancelled', cancelled: 'Cancelled',
}; };
@@ -36,6 +37,7 @@ const LOST_OUTCOMES: InterestOutcome[] = [
'lost_other_marina', 'lost_other_marina',
'lost_unqualified', 'lost_unqualified',
'lost_no_response', 'lost_no_response',
'lost_other',
'cancelled', 'cancelled',
]; ];

View File

@@ -64,7 +64,7 @@ export function InterestPicker({
})(); })();
return ( return (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen} modal>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
variant="outline" variant="outline"

View File

@@ -281,7 +281,7 @@ function ActiveReservationCard({
</div> </div>
) : signers.length === 0 ? ( ) : signers.length === 0 ? (
<p className="text-sm text-muted-foreground italic"> <p className="text-sm text-muted-foreground italic">
Documenso hasn&apos;t reported signers yet check back in a moment. The signing service hasn&apos;t reported signers yet check back in a moment.
</p> </p>
) : ( ) : (
<SigningProgress documentId={doc.id} signers={signers} /> <SigningProgress documentId={doc.id} signers={signers} />
@@ -344,7 +344,7 @@ function EmptyReservationState({
</h2> </h2>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
reservation agreements are drafted custom per deal. Either upload a paper-signed copy you reservation agreements 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. handled externally, or upload the draft PDF and send for e-signing.
</p> </p>
<div className="mt-5 flex flex-wrap items-center justify-center gap-2"> <div className="mt-5 flex flex-wrap items-center justify-center gap-2">
<Button onClick={onUploadForSigning} size="sm" className="gap-1.5"> <Button onClick={onUploadForSigning} size="sm" className="gap-1.5">

View File

@@ -9,6 +9,8 @@ import { Anchor, CheckCircle2, Circle, FileSignature, Plus, Send, Wallet } from
import type { DetailTab } from '@/components/shared/detail-layout'; import type { DetailTab } from '@/components/shared/detail-layout';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { NotesList } from '@/components/shared/notes-list'; import { NotesList } from '@/components/shared/notes-list';
import { InlineEditableField } from '@/components/shared/inline-editable-field'; import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { InlineTagEditor } from '@/components/shared/inline-tag-editor'; import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
@@ -20,6 +22,7 @@ import { InterestDocumentsTab } from '@/components/interests/interest-documents-
import { import {
LEAD_CATEGORIES, LEAD_CATEGORIES,
PIPELINE_STAGES, PIPELINE_STAGES,
SOURCES,
canTransitionStage, canTransitionStage,
type PipelineStage, type PipelineStage,
} from '@/lib/constants'; } from '@/lib/constants';
@@ -111,14 +114,17 @@ function useStageMutation(interestId: string) {
stage, stage,
reason, reason,
override, override,
milestoneDate,
}: { }: {
stage: string; stage: string;
reason?: string; reason?: string;
override?: boolean; override?: boolean;
/** Optional ISO date for the milestone column (instead of "now"). */
milestoneDate?: string;
}) => }) =>
apiFetch(`/api/v1/interests/${interestId}/stage`, { apiFetch(`/api/v1/interests/${interestId}/stage`, {
method: 'PATCH', method: 'PATCH',
body: { pipelineStage: stage, reason, override }, body: { pipelineStage: stage, reason, override, milestoneDate },
}), }),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ['interests', interestId] }); qc.invalidateQueries({ queryKey: ['interests', interestId] });
@@ -173,7 +179,7 @@ interface MilestoneSectionProps {
hideAutoButton?: boolean; hideAutoButton?: boolean;
}>; }>;
status: string | null; status: string | null;
onAdvance: (stage: string) => void; onAdvance: (stage: string, milestoneDate?: string) => void;
isPending: boolean; isPending: boolean;
/** Current pipelineStage. Used to mark steps as done when the pipeline has /** Current pipelineStage. Used to mark steps as done when the pipeline has
* moved past their advanceStage even if the date stamp is missing - e.g. * moved past their advanceStage even if the date stamp is missing - e.g.
@@ -196,6 +202,87 @@ interface MilestoneSectionProps {
* (Documenso webhook, paid invoice → deposit, etc.), they patch the same * (Documenso webhook, paid invoice → deposit, etc.), they patch the same
* stage endpoint and these checkmarks light up automatically. * stage endpoint and these checkmarks light up automatically.
*/ */
/**
* Button that opens a date-picker popover before advancing a milestone. The
* default is today, but the rep can back-date the event (e.g. "deposit
* landed yesterday") so the stamped milestone column reflects the real date
* rather than the click time.
*/
function MilestoneAdvanceButton({
label,
variant,
disabled,
onConfirm,
}: {
label: string;
variant: 'default' | 'outline' | 'ghostLink';
disabled?: boolean;
onConfirm: (milestoneDate: string) => void;
}) {
const [open, setOpen] = useState(false);
const [date, setDate] = useState<string>(() => new Date().toISOString().slice(0, 10));
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
{variant === 'ghostLink' ? (
<button
type="button"
disabled={disabled}
className="text-muted-foreground hover:text-foreground disabled:opacity-50"
>
{label}
</button>
) : (
<Button
type="button"
variant={variant}
size="sm"
disabled={disabled}
className="mt-2 h-7 px-2.5 text-xs"
>
{label}
</Button>
)}
</PopoverTrigger>
<PopoverContent align="start" className="w-64 space-y-2 p-3">
<div className="space-y-1">
<label className="text-xs font-medium" htmlFor="milestone-date">
Date completed
</label>
<Input
id="milestone-date"
type="date"
value={date}
max={new Date().toISOString().slice(0, 10)}
onChange={(e) => setDate(e.target.value)}
className="h-9"
/>
<p className="text-[11px] text-muted-foreground">
Defaults to today back-date if the event happened earlier.
</p>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" size="sm" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
type="button"
size="sm"
disabled={!date || disabled}
onClick={() => {
onConfirm(date);
setOpen(false);
}}
>
Confirm
</Button>
</div>
</PopoverContent>
</Popover>
);
}
function MilestoneSection({ function MilestoneSection({
title, title,
icon: Icon, icon: Icon,
@@ -282,16 +369,12 @@ function MilestoneSection({
) : null} ) : null}
</div> </div>
{isNext && step.advanceStage && !step.hideAutoButton ? ( {isNext && step.advanceStage && !step.hideAutoButton ? (
<Button <MilestoneAdvanceButton
type="button" label={step.actionLabel ?? `Mark as ${step.label.toLowerCase()}`}
variant={isActive ? 'default' : 'outline'} variant={isActive ? 'default' : 'outline'}
size="sm"
disabled={isPending} disabled={isPending}
onClick={() => onAdvance(step.advanceStage!)} onConfirm={(date) => onAdvance(step.advanceStage!, date)}
className="mt-2 h-7 px-2.5 text-xs" />
>
{step.actionLabel ?? `Mark as ${step.label.toLowerCase()}`}
</Button>
) : null} ) : null}
</div> </div>
</li> </li>
@@ -392,7 +475,7 @@ function OverviewTab({
* skip-ahead pattern from the inline stage picker so audit trails * skip-ahead pattern from the inline stage picker so audit trails
* stay consistent regardless of which surface the rep used. * 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 fromStage = interest.pipelineStage as PipelineStage;
const toStage = stage as PipelineStage; const toStage = stage as PipelineStage;
const isOverride = fromStage !== toStage && !canTransitionStage(fromStage, toStage); const isOverride = fromStage !== toStage && !canTransitionStage(fromStage, toStage);
@@ -409,6 +492,7 @@ function OverviewTab({
stage, stage,
reason: isOverride ? 'Skip-ahead from overview milestones' : 'Marked from overview', reason: isOverride ? 'Skip-ahead from overview milestones' : 'Marked from overview',
override: isOverride || undefined, override: isOverride || undefined,
milestoneDate,
}); });
}; };
@@ -566,14 +650,12 @@ function OverviewTab({
Create deposit invoice Create deposit invoice
</Link> </Link>
</Button> </Button>
<button <MilestoneAdvanceButton
type="button" label="Mark received manually"
onClick={() => advance('deposit_10pct')} variant="ghostLink"
disabled={stageMutation.isPending} disabled={stageMutation.isPending}
className="text-muted-foreground hover:text-foreground disabled:opacity-50" onConfirm={(date) => advance('deposit_10pct', date)}
> />
Mark received manually
</button>
</div> </div>
) : null, ) : null,
pastSummary: interest.dateDepositReceived pastSummary: interest.dateDepositReceived
@@ -682,7 +764,12 @@ function OverviewTab({
/> />
</EditableRow> </EditableRow>
<EditableRow label="Source"> <EditableRow label="Source">
<InlineEditableField value={interest.source} onSave={save('source')} /> <InlineEditableField
variant="select"
options={SOURCES.map((s) => ({ value: s.value, label: s.label }))}
value={interest.source}
onSave={save('source')}
/>
</EditableRow> </EditableRow>
</dl> </dl>
</div> </div>

View File

@@ -37,6 +37,7 @@ const LOST_OUTCOMES = new Set([
'lost_other_marina', 'lost_other_marina',
'lost_unqualified', 'lost_unqualified',
'lost_no_response', 'lost_no_response',
'lost_other',
'cancelled', 'cancelled',
]); ]);

View File

@@ -36,6 +36,13 @@ import { Label } from '@/components/ui/label';
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill'; import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea'; 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 { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -303,53 +310,96 @@ function LinkedBerthRowItem({ row, portSlug, eoiStatus, onUpdate, onRemove, isPe
</div> </div>
</div> </div>
<div className="mt-3 grid grid-cols-1 gap-3 border-t pt-3 sm:grid-cols-2"> <TooltipProvider delayDuration={200}>
<div className="space-y-1"> <div className="mt-3 grid grid-cols-1 gap-3 border-t pt-3 sm:grid-cols-2">
{/* Switch sits next to its label (gap-2.5) instead of being <div className="space-y-1">
flexed to the far right via justify-between — when the {/* Switch sits next to its label (gap-2.5) instead of being
column is wide, justify-between created a confusing visual flexed to the far right via justify-between — when the
gulf between the action and what it controls. */} column is wide, justify-between created a confusing visual
<div className="flex items-center gap-2.5"> gulf between the action and what it controls. */}
<Switch <div className="flex items-center gap-2.5">
id={`specific-${row.berthId}`} <Switch
checked={row.isSpecificInterest} id={`specific-${row.berthId}`}
disabled={isPending} checked={row.isSpecificInterest}
onCheckedChange={(checked) => onUpdate(row.berthId, { isSpecificInterest: checked })} disabled={isPending}
/> onCheckedChange={(checked) =>
<Label onUpdate(row.berthId, { isSpecificInterest: checked })
htmlFor={`specific-${row.berthId}`} }
className="text-sm font-medium cursor-pointer" />
> <Label
Specifically pitching htmlFor={`specific-${row.berthId}`}
</Label> className="text-sm font-medium cursor-pointer"
>
Specifically pitching
</Label>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex h-5 w-5 items-center justify-center rounded-full text-muted-foreground hover:bg-muted/60 hover:text-foreground"
aria-label="What does Specifically pitching do?"
>
<HelpCircle className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs text-[11px] leading-snug">
Mark this berth as one your client is actively considering. When on, the berth
appears as <strong>Under Offer</strong> on the public map and counts toward the
recommender&apos;s &quot;heat&quot; score. Turn off if the link is legal/EOI-only.
</TooltipContent>
</Tooltip>
</div>
<p className="text-xs text-muted-foreground">
{row.isSpecificInterest ? SPECIFIC_CONSEQUENCE_ON : SPECIFIC_CONSEQUENCE_OFF}
</p>
</div> </div>
<p className="text-xs text-muted-foreground"> <div className="space-y-1">
{row.isSpecificInterest ? SPECIFIC_CONSEQUENCE_ON : SPECIFIC_CONSEQUENCE_OFF} <div className="flex items-center gap-2.5">
</p> <Switch
</div> id={`bundle-${row.berthId}`}
<div className="space-y-1"> checked={row.isInEoiBundle}
<div className="flex items-center gap-2.5"> disabled={isPending}
<Switch onCheckedChange={(checked) => onUpdate(row.berthId, { isInEoiBundle: checked })}
id={`bundle-${row.berthId}`} />
checked={row.isInEoiBundle} <Label
disabled={isPending} htmlFor={`bundle-${row.berthId}`}
onCheckedChange={(checked) => onUpdate(row.berthId, { isInEoiBundle: checked })} className="text-sm font-medium cursor-pointer"
/> >
<Label htmlFor={`bundle-${row.berthId}`} className="text-sm font-medium cursor-pointer"> Mark in EOI bundle
Mark in EOI bundle </Label>
</Label> <Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex h-5 w-5 items-center justify-center rounded-full text-muted-foreground hover:bg-muted/60 hover:text-foreground"
aria-label="What does Mark in EOI bundle do?"
>
<HelpCircle className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs text-[11px] leading-snug">
Include this berth in the EOI&apos;s signed berth range. When on, the berth is
covered by the same signature and shows up in the EOI&apos;s
<strong> Berth Range</strong> form field (e.g. &quot;A1-A3, B5-B7&quot;). Turn off
to keep the link without legal coverage.
</TooltipContent>
</Tooltip>
</div>
<p className="text-xs text-muted-foreground">
{row.isInEoiBundle
? 'Covered by the interests EOI signature.'
: 'Not covered by the EOI bundle.'}
</p>
</div> </div>
<p className="text-xs text-muted-foreground">
{row.isInEoiBundle
? 'Covered by the interests EOI signature.'
: 'Not covered by the EOI bundle.'}
</p>
</div> </div>
</div> </TooltipProvider>
{showBypassControl ? ( {showBypassControl ? (
<div className="mt-3 flex flex-wrap items-start justify-between gap-2 border-t pt-3"> // Bypass section reads as a third toggle-style row: label + description
<div className="min-w-0 space-y-0.5"> // on the left, action button inline with the description so it doesn't
// float far-right while the toggles above are anchored left.
<div className="mt-3 flex flex-wrap items-center gap-3 border-t pt-3">
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-sm font-medium">Bypass EOI for this berth</p> <p className="text-sm font-medium">Bypass EOI for this berth</p>
{row.eoiBypassReason ? ( {row.eoiBypassReason ? (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">

View File

@@ -2,7 +2,7 @@
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; 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'; import { cn } from '@/lib/utils';
@@ -12,35 +12,26 @@ type TabSpec = {
segment: string; // route segment after /[portSlug]/ segment: string; // route segment after /[portSlug]/
}; };
// Bottom nav ordering, left → right: // Left-of-center: Dashboard, Clients. Right-of-center: Berths, More.
// Dashboard - daily overview // Search occupies the center slot. Documents demoted to the MoreSheet —
// Berths - marina inventory grid (touches sales + ops both) // reps reach docs less often than berths during a walking inventory check,
// Clients - the address book / dedup surface (centered: it's the // and pinned-to-client documents are accessed via the client detail anyway.
// primary mental anchor for "find this person", with const TABS_LEFT: TabSpec[] = [
// 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[] = [
{ label: 'Dashboard', icon: LayoutDashboard, segment: 'dashboard' }, { label: 'Dashboard', icon: LayoutDashboard, segment: 'dashboard' },
{ label: 'Berths', icon: Anchor, segment: 'berths' },
{ label: 'Clients', icon: Users, segment: 'clients' }, { label: 'Clients', icon: Users, segment: 'clients' },
{ label: 'Documents', icon: FileSignature, segment: 'documents' },
]; ];
export function MobileBottomTabs({ onMoreClick }: { onMoreClick: () => void }) { const TABS_RIGHT: TabSpec[] = [
const pathname = usePathname(); { label: 'Berths', icon: Anchor, segment: 'berths' },
];
// Derive the active port slug from the URL so tab links always target the interface MobileBottomTabsProps {
// current port, even after a port-switch. The dashboard route shape is onMoreClick: () => void;
// /[portSlug]/<rest>, so the slug is the first non-empty path segment. onSearchClick: () => void;
}
export function MobileBottomTabs({ onMoreClick, onSearchClick }: MobileBottomTabsProps) {
const pathname = usePathname();
const portSlug = pathname.split('/').filter(Boolean)[0] ?? 'port-nimara'; const portSlug = pathname.split('/').filter(Boolean)[0] ?? 'port-nimara';
function isActive(segment: string): boolean { function isActive(segment: string): boolean {
@@ -53,41 +44,42 @@ export function MobileBottomTabs({ onMoreClick }: { onMoreClick: () => void }) {
className={cn( className={cn(
'fixed bottom-0 inset-x-0 z-40 bg-background border-t border-border', 'fixed bottom-0 inset-x-0 z-40 bg-background border-t border-border',
'pb-safe-bottom', 'pb-safe-bottom',
'grid grid-cols-5', // 5 equal-flex slots.
'flex items-end',
)} )}
> >
{TABS.map((tab) => { {TABS_LEFT.map((tab) => (
const active = isActive(tab.segment); <NavTab
const Icon = tab.icon; key={tab.segment}
return ( tab={tab}
<Link portSlug={portSlug}
key={tab.segment} active={isActive(tab.segment)}
// eslint-disable-next-line @typescript-eslint/no-explicit-any />
href={`/${portSlug}/${tab.segment}` as any} ))}
aria-current={active ? 'page' : undefined}
className={cn( {/* Search button — styled identically to the other navbar tabs. */}
'relative flex flex-col items-center justify-center gap-0.5 h-14 text-xs transition-colors', <button
active ? 'text-primary' : 'text-muted-foreground', type="button"
)} onClick={onSearchClick}
> className="relative flex h-14 flex-1 flex-col items-center justify-center gap-0.5 text-xs text-muted-foreground transition-colors"
{/* Subtle pill background behind the icon when active. Keeps the >
tab grid alignment intact while giving the eye an anchor. */} <Search className="relative size-5" aria-hidden />
<span <span className="relative font-medium">Search</span>
aria-hidden </button>
className={cn(
'absolute top-1.5 h-7 w-12 rounded-full transition-all', {TABS_RIGHT.map((tab) => (
active ? 'bg-primary/10' : 'bg-transparent', <NavTab
)} key={tab.segment}
/> tab={tab}
<Icon className="relative size-5" aria-hidden /> portSlug={portSlug}
<span className="relative font-medium">{tab.label}</span> active={isActive(tab.segment)}
</Link> />
); ))}
})}
<button <button
type="button" type="button"
onClick={onMoreClick} onClick={onMoreClick}
className="relative flex flex-col items-center justify-center gap-0.5 h-14 text-xs text-muted-foreground transition-colors" className="relative flex h-14 flex-1 flex-col items-center justify-center gap-0.5 text-xs text-muted-foreground transition-colors"
> >
<Menu className="relative size-5" aria-hidden /> <Menu className="relative size-5" aria-hidden />
<span className="relative font-medium">More</span> <span className="relative font-medium">More</span>
@@ -95,3 +87,41 @@ export function MobileBottomTabs({ onMoreClick }: { onMoreClick: () => void }) {
</nav> </nav>
); );
} }
function NavTab({
tab,
portSlug,
active,
}: {
tab: TabSpec;
portSlug: string;
active: boolean;
}) {
const Icon = tab.icon;
return (
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/${tab.segment}` as any}
aria-current={active ? 'page' : undefined}
className={cn(
'relative flex flex-1 flex-col items-center justify-center gap-0.5 h-14 text-xs transition-colors',
active ? 'text-primary' : 'text-muted-foreground',
)}
>
{/* iOS-native active indicator: a 2px accent bar at the top of
the active tab. Cleaner than a colored pill — relies on the
icon + label color change (text-primary above) to do the
primary signaling, with this bar adding just enough visual
anchor to read as "selected". */}
<span
aria-hidden
className={cn(
'absolute inset-x-0 top-0 mx-auto h-[2px] w-8 rounded-full transition-opacity',
active ? 'bg-primary opacity-100' : 'opacity-0',
)}
/>
<Icon className="relative size-5" aria-hidden />
<span className="relative font-medium">{tab.label}</span>
</Link>
);
}

View File

@@ -7,6 +7,7 @@ import { MobileLayoutProvider } from './mobile-layout-provider';
import { MobileTopbar } from './mobile-topbar'; import { MobileTopbar } from './mobile-topbar';
import { MobileBottomTabs } from './mobile-bottom-tabs'; import { MobileBottomTabs } from './mobile-bottom-tabs';
import { MoreSheet } from './more-sheet'; import { MoreSheet } from './more-sheet';
import { MobileSearchOverlay } from '@/components/search/mobile-search-overlay';
/** /**
* Mobile shell: fixed compact topbar + scrollable content + fixed bottom tab * Mobile shell: fixed compact topbar + scrollable content + fixed bottom tab
@@ -17,6 +18,7 @@ import { MoreSheet } from './more-sheet';
*/ */
export function MobileLayout({ children }: { children: ReactNode }) { export function MobileLayout({ children }: { children: ReactNode }) {
const [moreOpen, setMoreOpen] = useState(false); const [moreOpen, setMoreOpen] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
return ( return (
<div data-shell="mobile" className="min-h-screen bg-background"> <div data-shell="mobile" className="min-h-screen bg-background">
@@ -33,8 +35,12 @@ export function MobileLayout({ children }: { children: ReactNode }) {
> >
{children} {children}
</main> </main>
<MobileBottomTabs onMoreClick={() => setMoreOpen(true)} /> <MobileBottomTabs
onMoreClick={() => setMoreOpen(true)}
onSearchClick={() => setSearchOpen(true)}
/>
<MoreSheet open={moreOpen} onOpenChange={setMoreOpen} /> <MoreSheet open={moreOpen} onOpenChange={setMoreOpen} />
<MobileSearchOverlay open={searchOpen} onOpenChange={setSearchOpen} />
</MobileLayoutProvider> </MobileLayoutProvider>
</div> </div>
); );

View File

@@ -5,19 +5,20 @@ import { usePathname } from 'next/navigation';
import { import {
Anchor, Anchor,
BarChart3, BarChart3,
Bell,
BellRing,
Bookmark, Bookmark,
Building2, Building2,
FileSignature,
Globe, Globe,
Home, Home,
Inbox,
Receipt, Receipt,
Settings, Settings,
Shield, Shield,
ShieldAlert,
Ship, Ship,
} from 'lucide-react'; } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { import {
Drawer, Drawer,
DrawerContent, DrawerContent,
@@ -26,6 +27,7 @@ import {
DrawerClose, DrawerClose,
} from '@/components/shared/drawer'; } from '@/components/shared/drawer';
import { useUmamiActive } from '@/components/website-analytics/use-website-analytics'; import { useUmamiActive } from '@/components/website-analytics/use-website-analytics';
import { apiFetch } from '@/lib/api/client';
type MoreItem = { type MoreItem = {
label: string; label: string;
@@ -33,32 +35,50 @@ type MoreItem = {
segment: string; segment: string;
}; };
// Order: most-likely overflow targets first. Interests is here (rather type MoreGroup = {
// than the bottom row) to dodge the Clients-vs-Interests UX confusion; label: string;
// reps reach the active deals via the Interests tab on a client detail items: MoreItem[];
// (or via the new bottom-sheet drawer). Yachts is asset-record traffic };
// best reached contextually from inside an interest or client.
// 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 // Interests stays here (not bottom nav) to dodge the Clients-vs-
// deferred (see sidebar.tsx). Re-add this entry once IMAP/SMTP wiring // Interests UX confusion. Inbox replaces the previously-separate
// + Google OAuth review are done. Website analytics is filtered below // Alerts + Reminders entries (merged 2026-05-11). Website analytics
// when Umami isn't configured for this port. // and Reservations are filtered out below when not applicable.
const MORE_ITEMS: MoreItem[] = [ const MORE_GROUPS: MoreGroup[] = [
{ label: 'Interests', icon: Bookmark, segment: 'interests' }, {
{ label: 'Yachts', icon: Ship, segment: 'yachts' }, label: 'Records',
{ label: 'Companies', icon: Building2, segment: 'companies' }, items: [
{ label: 'Expenses', icon: Receipt, segment: 'expenses' }, { label: 'Documents', icon: FileSignature, segment: 'documents' },
{ label: 'Reservations', icon: Anchor, segment: 'berth-reservations' }, { label: 'Interests', icon: Bookmark, segment: 'interests' },
// Notifications themselves live on the topbar bell — this entry deep-links { label: 'Yachts', icon: Ship, segment: 'yachts' },
// to the notification panel inside user-settings (collapsed in 2026-05-09). { label: 'Companies', icon: Building2, segment: 'companies' },
{ label: 'Notification preferences', icon: BellRing, segment: 'settings#notifications' }, { label: 'Residential', icon: Home, segment: 'residential/clients' },
{ 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: 'Operations',
{ label: 'Reminders', icon: Bell, segment: 'reminders' }, items: [
{ label: 'Settings', icon: Settings, segment: 'settings' }, { label: 'Alerts & Reminders', icon: Inbox, segment: 'inbox' },
{ label: 'Admin', icon: Shield, segment: 'admin' }, { 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({ export function MoreSheet({
@@ -74,10 +94,30 @@ export function MoreSheet({
// Hide "Website analytics" if Umami isn't wired up for this port — the // Hide "Website analytics" if Umami isn't wired up for this port — the
// dedicated tile on the dashboard already does the same. // dedicated tile on the dashboard already does the same.
const umami = useUmamiActive('today'); const umami = useUmamiActive('today');
const umamiConfigured = umami.data?.error !== 'umami_not_configured'; const umamiConfigured = !umami.isLoading && umami.data?.notConfigured !== true;
const items = MORE_ITEMS.filter(
(item) => item.segment !== 'website-analytics' || umamiConfigured, // 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 ( return (
<Drawer open={open} onOpenChange={onOpenChange}> <Drawer open={open} onOpenChange={onOpenChange}>
@@ -85,28 +125,36 @@ export function MoreSheet({
<DrawerHeader> <DrawerHeader>
<DrawerTitle>More</DrawerTitle> <DrawerTitle>More</DrawerTitle>
</DrawerHeader> </DrawerHeader>
<ul className="grid grid-cols-3 gap-2 px-3 pb-4"> <div className="space-y-4 px-3 pb-4">
{items.map((item) => { {groups.map((group) => (
const Icon = item.icon; <section key={group.label}>
return ( <h3 className="mb-1.5 px-1 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
<li key={item.segment}> {group.label}
<DrawerClose asChild> </h3>
<Link <ul className="grid grid-cols-3 gap-2">
// eslint-disable-next-line @typescript-eslint/no-explicit-any {group.items.map((item) => {
href={`/${portSlug}/${item.segment}` as any} const Icon = item.icon;
// min-h-[88px] guarantees a 44pt vertical touch target return (
// (Apple HIG); icon + label centered. The grid gap is <li key={item.segment}>
// 8px so each cell still has clearance from neighbours. <DrawerClose asChild>
className="flex min-h-[88px] flex-col items-center justify-center gap-1.5 rounded-md py-3 px-2 text-xs text-foreground hover:bg-accent active:bg-accent/80" <Link
> // eslint-disable-next-line @typescript-eslint/no-explicit-any
<Icon className="size-7 text-muted-foreground" aria-hidden /> href={`/${portSlug}/${item.segment}` as any}
<span className="font-medium">{item.label}</span> // min-h-[88px] guarantees a 44pt vertical touch
</Link> // target (Apple HIG); icon + label centered.
</DrawerClose> className="flex min-h-[88px] flex-col items-center justify-center gap-1.5 rounded-md py-3 px-2 text-center text-xs text-foreground hover:bg-accent active:bg-accent/80"
</li> >
); <Icon className="size-7 text-muted-foreground" aria-hidden />
})} <span className="font-medium leading-tight">{item.label}</span>
</ul> </Link>
</DrawerClose>
</li>
);
})}
</ul>
</section>
))}
</div>
</DrawerContent> </DrawerContent>
</Drawer> </Drawer>
); );

View File

@@ -13,7 +13,7 @@ import {
Building2, Building2,
Receipt, Receipt,
FileText, FileText,
Bell, Inbox,
Camera, Camera,
Globe, Globe,
Settings, Settings,
@@ -156,10 +156,12 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
title: 'Communication', title: 'Communication',
marinaRequired: true, marinaRequired: true,
items: [ 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 // feature (would require Google OAuth + scope review + IMAP
// syncing infra). Reminders stays since it's already wired up. // syncing infra). This entry routes to the merged
{ href: `${base}/reminders`, label: 'Reminders', icon: Bell }, // 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} href={item.href as any}
className={cn( className={cn(
'relative flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-all duration-150', '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', 'text-slate-700 hover:bg-accent hover:text-foreground',
active && 'text-white pl-[14px]', active && 'bg-accent text-foreground pl-[14px]',
collapsed && 'justify-center px-2', collapsed && 'justify-center px-2',
)} )}
> >
@@ -202,7 +204,7 @@ function NavItemLink({
<item.icon <item.icon
className={cn( className={cn(
'shrink-0', 'shrink-0',
active ? 'text-[#3a7bc8]' : 'text-[#83aab1]', active ? 'text-[#3a7bc8]' : 'text-slate-500',
collapsed ? 'w-5 h-5' : 'w-4 h-4', collapsed ? 'w-5 h-5' : 'w-4 h-4',
)} )}
/> />
@@ -252,7 +254,7 @@ function SidebarContent({
const [adminExpanded, setAdminExpanded] = useState(true); const [adminExpanded, setAdminExpanded] = useState(true);
const sections = buildNavSections(portSlug); const sections = buildNavSections(portSlug);
const umami = useUmamiActive('today'); 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 // Small label under the user identity when the user has access to more
// than one port — disambiguates which port is currently active without // than one port — disambiguates which port is currently active without
@@ -283,12 +285,13 @@ function SidebarContent({
// compete with the logo for attention. // compete with the logo for attention.
return ( return (
<TooltipProvider delayDuration={0}> <TooltipProvider delayDuration={0}>
<div className="flex flex-col h-full bg-[#1e2844]"> <div className="flex flex-col h-full bg-white">
{/* Brand header - logo centered (large when expanded, smaller when {/* Brand header - logo centered. Soft hairline below echoes the
collapsed). Collapse toggle floats top-right as a tiny chevron. */} inter-section separators in the nav so the eye reads the logo
as a distinct top-row, not a floating element. */}
<div <div
className={cn( className={cn(
'relative flex items-center justify-center border-b border-[#474e66]', 'relative flex items-center justify-center border-b border-slate-200',
collapsed ? 'h-16 px-2' : 'h-24 px-4', collapsed ? 'h-16 px-2' : 'h-24 px-4',
)} )}
> >
@@ -297,7 +300,7 @@ function SidebarContent({
alt="Port Nimara" alt="Port Nimara"
width={collapsed ? 40 : 72} width={collapsed ? 40 : 72}
height={collapsed ? 40 : 72} height={collapsed ? 40 : 72}
className="rounded-full shadow-md ring-2 ring-white/20" className="rounded-full shadow-sm"
unoptimized unoptimized
priority priority
/> />
@@ -307,7 +310,7 @@ function SidebarContent({
onClick={onToggleCollapse} onClick={onToggleCollapse}
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'} aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
className={cn( 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', collapsed ? 'top-1' : 'top-2',
)} )}
> >
@@ -333,13 +336,13 @@ function SidebarContent({
<div key={section.title}> <div key={section.title}>
{!collapsed && ( {!collapsed && (
<div className="flex items-center justify-between px-1 mb-1"> <div className="flex items-center justify-between px-1 mb-1">
<span className="text-[#83aab1] text-[10px] font-semibold uppercase tracking-[0.12em]"> <span className="text-slate-500 text-[10px] font-semibold uppercase tracking-[0.12em]">
{section.title} {section.title}
</span> </span>
{section.adminRequired && ( {section.adminRequired && (
<button <button
onClick={() => setAdminExpanded((v) => !v)} onClick={() => setAdminExpanded((v) => !v)}
className="text-[#71768a] hover:text-[#cdcfd6] transition-colors" className="text-slate-400 hover:text-slate-700 transition-colors"
> >
{adminExpanded ? ( {adminExpanded ? (
<ChevronUp className="w-3 h-3" /> <ChevronUp className="w-3 h-3" />
@@ -363,7 +366,7 @@ function SidebarContent({
))} ))}
</ul> </ul>
)} )}
<Separator className="mt-3 bg-[#474e66]/50" /> <Separator className="mt-3 bg-slate-200" />
</div> </div>
); );
})} })}
@@ -374,7 +377,7 @@ function SidebarContent({
user can click their name/avatar to access Profile / Settings / user can click their name/avatar to access Profile / Settings /
port-switcher / sign-out. The same UserMenu component drives the port-switcher / sign-out. The same UserMenu component drives the
top-right avatar dropdown, so the menu items stay consistent. */} top-right avatar dropdown, so the menu items stay consistent. */}
<div className={cn('border-t border-[#474e66] p-2', collapsed && 'flex justify-center')}> <div className={cn('border-t border-slate-200 p-2', collapsed && 'flex justify-center')}>
{collapsed ? ( {collapsed ? (
<UserMenu <UserMenu
align="start" align="start"
@@ -384,7 +387,7 @@ function SidebarContent({
<button <button
type="button" type="button"
aria-label="Open user menu" aria-label="Open user menu"
className="rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#3a7bc8] focus-visible:ring-offset-2 focus-visible:ring-offset-[#1e2844]" className="rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#3a7bc8] focus-visible:ring-offset-2 focus-visible:ring-offset-white"
> >
<Avatar className="w-8 h-8 cursor-pointer"> <Avatar className="w-8 h-8 cursor-pointer">
<AvatarImage src={undefined} /> <AvatarImage src={undefined} />
@@ -404,26 +407,26 @@ function SidebarContent({
<button <button
type="button" type="button"
aria-label="Open user menu" aria-label="Open user menu"
className="flex w-full items-center gap-3 rounded-md p-1.5 text-left transition-colors hover:bg-[#171f35] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#3a7bc8] focus-visible:ring-offset-2 focus-visible:ring-offset-[#1e2844]" className="flex w-full items-center gap-3 rounded-md p-1.5 text-left transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#3a7bc8] focus-visible:ring-offset-2 focus-visible:ring-offset-white"
> >
<Avatar className="w-8 h-8 shrink-0 shadow-sm ring-2 ring-white/30"> <Avatar className="w-8 h-8 shrink-0 shadow-sm ring-2 ring-slate-200">
<AvatarImage src={undefined} /> <AvatarImage src={undefined} />
<AvatarFallback className="bg-[#3a7bc8] text-white text-xs font-semibold"> <AvatarFallback className="bg-[#3a7bc8] text-white text-xs font-semibold">
{(user?.name ?? 'U').slice(0, 1).toUpperCase()} {(user?.name ?? 'U').slice(0, 1).toUpperCase()}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-white text-sm font-medium truncate"> <p className="text-foreground text-sm font-medium truncate">
{user?.name ?? 'User'} {user?.name ?? 'User'}
</p> </p>
<Badge <Badge
variant="outline" variant="outline"
className="text-[10px] px-1.5 py-0 text-[#83aab1] border-[#474e66] mt-0.5" className="text-[10px] px-1.5 py-0 text-slate-500 border-slate-300 mt-0.5"
> >
{isSuperAdmin ? 'Super Admin' : humanizeRole(portRoles[0]?.role?.name)} {isSuperAdmin ? 'Super Admin' : humanizeRole(portRoles[0]?.role?.name)}
</Badge> </Badge>
{currentPortName && ( {currentPortName && (
<p className="mt-1 text-[10px] text-[#71768a] truncate">{currentPortName}</p> <p className="mt-1 text-[10px] text-slate-400 truncate">{currentPortName}</p>
)} )}
</div> </div>
</button> </button>
@@ -461,10 +464,9 @@ export function Sidebar({ portRoles, isSuperAdmin = false, user, ports }: Sideba
return ( return (
<aside <aside
className={cn( className={cn(
'relative hidden md:flex flex-col h-screen border-r border-[#474e66] transition-all duration-200 ease-in-out shrink-0', 'relative hidden md:flex flex-col h-screen border-r border-slate-200 transition-all duration-200 ease-in-out shrink-0 bg-white',
sidebarCollapsed ? 'w-sidebar-collapsed' : 'w-sidebar', sidebarCollapsed ? 'w-sidebar-collapsed' : 'w-sidebar',
)} )}
style={{ backgroundColor: '#1e2844' }}
> >
<SidebarContent <SidebarContent
collapsed={sidebarCollapsed} collapsed={sidebarCollapsed}

View File

@@ -95,23 +95,40 @@ export function Topbar({ ports, user }: TopbarProps) {
<span className="hidden sm:inline">New</span> <span className="hidden sm:inline">New</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44"> <DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel className="text-xs text-muted-foreground">Create</DropdownMenuLabel> <DropdownMenuLabel className="text-xs text-muted-foreground">Create</DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{/* Each item routes to the list page with ?create=1 so the
relevant create sheet pops automatically (see
useCreateFromUrl). The legacy `/clients/new`-style routes
this menu used to push to landed on the dynamic detail
page with id="new" and silently 404'd. */}
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<DropdownMenuItem onClick={() => router.push(`${base}/clients/new` as any)}> <DropdownMenuItem onClick={() => router.push(`${base}/clients?create=1` as any)}>
New Client New Client
</DropdownMenuItem> </DropdownMenuItem>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<DropdownMenuItem onClick={() => router.push(`${base}/interests/new` as any)}> <DropdownMenuItem onClick={() => router.push(`${base}/yachts?create=1` as any)}>
New Yacht
</DropdownMenuItem>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<DropdownMenuItem onClick={() => router.push(`${base}/companies?create=1` as any)}>
New Company
</DropdownMenuItem>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<DropdownMenuItem onClick={() => router.push(`${base}/interests?create=1` as any)}>
New Interest New Interest
</DropdownMenuItem> </DropdownMenuItem>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<DropdownMenuItem onClick={() => router.push(`${base}/expenses/new` as any)}> <DropdownMenuItem onClick={() => router.push(`${base}/expenses?create=1` as any)}>
New Expense New Expense
</DropdownMenuItem> </DropdownMenuItem>
{/* /reminders 301s to /inbox#reminders (the merged page) and
the server redirect strips the query string, so point
straight at the new path. The Reminders section's
useCreateFromUrl handler still picks up ?create=1. */}
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<DropdownMenuItem onClick={() => router.push(`${base}/reminders/new` as any)}> <DropdownMenuItem onClick={() => router.push(`${base}/inbox?create=1#reminders` as any)}>
New Reminder New Reminder
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>

View File

@@ -29,6 +29,7 @@ interface ReminderPrefs {
interface UserPrefsResponse { interface UserPrefsResponse {
reminders?: ReminderPrefs; reminders?: ReminderPrefs;
timezone?: string; timezone?: string;
portReminderDigestEnabled?: boolean;
} }
const DAYS = [ const DAYS = [
@@ -96,6 +97,10 @@ export function ReminderDigestForm() {
); );
} }
if (!data?.portReminderDigestEnabled) {
return null;
}
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>

View File

@@ -13,6 +13,9 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet'; import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
import { ClientPicker } from '@/components/shared/client-picker';
import { InterestPicker } from '@/components/shared/interest-picker';
import { BerthPicker } from '@/components/shared/berth-picker';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { usePermissions } from '@/hooks/use-permissions'; import { usePermissions } from '@/hooks/use-permissions';
@@ -172,7 +175,9 @@ export function ReminderForm({
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4"> {/* 2fr/1fr split — the datetime-local control needs more room
for "MM/DD/YYYY HH:MM AM" than a 4-item priority Select. */}
<div className="grid grid-cols-[2fr_1fr] gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="reminder-due">Due Date & Time</Label> <Label htmlFor="reminder-due">Due Date & Time</Label>
<Input <Input
@@ -202,13 +207,18 @@ export function ReminderForm({
{canAssignOthers && ( {canAssignOthers && (
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="reminder-assign">Assign To</Label> <Label htmlFor="reminder-assign">Assign to user</Label>
<Select value={assignedTo} onValueChange={setAssignedTo}> <Select
value={assignedTo === '' ? '__self__' : assignedTo}
onValueChange={(v) => setAssignedTo(v === '__self__' ? '' : v)}
>
<SelectTrigger id="reminder-assign"> <SelectTrigger id="reminder-assign">
<SelectValue placeholder="Myself" /> <SelectValue placeholder="Myself" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="">Myself</SelectItem> {/* Radix Select forbids empty-string values, so use a
sentinel here and map back to '' in the handler. */}
<SelectItem value="__self__">Myself</SelectItem>
{users.map((u) => ( {users.map((u) => (
<SelectItem key={u.id} value={u.id}> <SelectItem key={u.id} value={u.id}>
{u.displayName} {u.displayName}
@@ -220,27 +230,36 @@ export function ReminderForm({
)} )}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-muted-foreground text-xs"> <Label className="text-xs text-muted-foreground">
Link to Entity (optional - paste UUIDs, or leave blank) Link to entity (optional)
</Label> </Label>
<p className="text-[11px] text-muted-foreground">
Pick a client first to scope the interest and berth dropdowns to that
client&apos;s deals.
</p>
<div className="grid grid-cols-1 gap-2"> <div className="grid grid-cols-1 gap-2">
<Input <ClientPicker
placeholder="Client ID" value={clientId || null}
value={clientId} onChange={(id) => {
onChange={(e) => setClientId(e.target.value)} setClientId(id ?? '');
className="text-xs" // Clearing the client also clears scoped selections so a
// stale interest/berth from a different client doesn't
// silently submit alongside the new client.
if (!id) {
setInterestId('');
setBerthId('');
}
}}
/> />
<Input <InterestPicker
placeholder="Interest ID" value={interestId || null}
value={interestId} onChange={(id) => setInterestId(id ?? '')}
onChange={(e) => setInterestId(e.target.value)} clientId={clientId || null}
className="text-xs"
/> />
<Input <BerthPicker
placeholder="Berth ID" value={berthId || null}
value={berthId} onChange={(id) => setBerthId(id ?? '')}
onChange={(e) => setBerthId(e.target.value)} clientId={clientId || null}
className="text-xs"
/> />
</div> </div>
</div> </div>

View File

@@ -2,7 +2,7 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { type ColumnDef } from '@tanstack/react-table'; 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 { formatDistanceToNow } from 'date-fns';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
@@ -11,6 +11,12 @@ import { PageHeader } from '@/components/shared/page-header';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -19,6 +25,7 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { useCreateFromUrl } from '@/hooks/use-create-from-url';
import { usePermissions } from '@/hooks/use-permissions'; import { usePermissions } from '@/hooks/use-permissions';
import { ReminderCard } from './reminder-card'; import { ReminderCard } from './reminder-card';
import { ReminderForm } from './reminder-form'; import { ReminderForm } from './reminder-form';
@@ -59,10 +66,20 @@ const STATUS_CONFIG = {
dismissed: { label: 'Dismissed', icon: XCircle }, dismissed: { label: 'Dismissed', icon: XCircle },
} as const; } 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<Reminder[]>([]); const [reminders, setReminders] = useState<Reminder[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [formOpen, setFormOpen] = useState(false); const [formOpen, setFormOpen] = useState(false);
useCreateFromUrl(() => setFormOpen(true));
const [editingReminder, setEditingReminder] = useState<Reminder | null>(null); const [editingReminder, setEditingReminder] = useState<Reminder | null>(null);
const [snoozingId, setSnoozingId] = useState<string | null>(null); const [snoozingId, setSnoozingId] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<'my' | 'all'>('my'); const [viewMode, setViewMode] = useState<'my' | 'all'>('my');
@@ -203,41 +220,97 @@ export function ReminderList() {
return null; return null;
} }
return ( return (
<div className="flex items-center justify-end gap-1"> <TooltipProvider delayDuration={150}>
<Button <div className="flex items-center justify-end gap-1">
variant="ghost" <Tooltip>
size="sm" <TooltipTrigger asChild>
className="text-green-600 hover:text-green-700" <Button
onClick={() => handleComplete(row.original.id)} variant="ghost"
> size="sm"
<CheckCircle2 className="h-4 w-4" /> aria-label="Mark complete"
</Button> className="text-green-600 hover:text-green-700"
<Button variant="ghost" size="sm" onClick={() => setSnoozingId(row.original.id)}> onClick={() => handleComplete(row.original.id)}
<Clock className="h-4 w-4" /> >
</Button> <CheckCircle2 className="h-4 w-4" />
<Button </Button>
variant="ghost" </TooltipTrigger>
size="sm" <TooltipContent>Mark complete</TooltipContent>
className="text-muted-foreground hover:text-foreground" </Tooltip>
onClick={() => handleDismiss(row.original.id)} <Tooltip>
> <TooltipTrigger asChild>
<XCircle className="h-4 w-4" /> <Button
</Button> variant="ghost"
</div> size="sm"
aria-label="Snooze"
onClick={() => setSnoozingId(row.original.id)}
>
<Clock className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Snooze</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
aria-label="Edit reminder"
className="text-muted-foreground hover:text-foreground"
onClick={() => {
setEditingReminder(row.original);
setFormOpen(true);
}}
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Edit</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
aria-label="Dismiss"
className="text-muted-foreground hover:text-foreground"
onClick={() => handleDismiss(row.original.id)}
>
<XCircle className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Dismiss</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
); );
}, },
enableSorting: false, enableSorting: false,
size: 120, size: 160,
}, },
]; ];
return ( return (
<div> <div>
<PageHeader {!embedded ? (
title="Reminders" <PageHeader
description={`${total} reminder${total !== 1 ? 's' : ''}`} title="Reminders"
actions={ description={`${total} reminder${total !== 1 ? 's' : ''}`}
actions={
<Button
onClick={() => {
setEditingReminder(null);
setFormOpen(true);
}}
>
<Plus className="mr-1.5 h-4 w-4" />
New Reminder
</Button>
}
/>
) : (
<div className="mb-3 flex justify-end">
<Button <Button
size="sm"
onClick={() => { onClick={() => {
setEditingReminder(null); setEditingReminder(null);
setFormOpen(true); setFormOpen(true);
@@ -246,8 +319,8 @@ export function ReminderList() {
<Plus className="mr-1.5 h-4 w-4" /> <Plus className="mr-1.5 h-4 w-4" />
New Reminder New Reminder
</Button> </Button>
} </div>
/> )}
{/* Wrap on phone widths so the priority filter doesn't get pushed {/* 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. */} off-screen by the My/All tabs + status filter taking the full row. */}

View File

@@ -17,11 +17,49 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import type { RequestReportInput } from '@/lib/validators/reports'; import type { RequestReportInput } from '@/lib/validators/reports';
const REPORT_TYPE_LABELS: Record<string, string> = { interface ReportTypeMeta {
pipeline: 'Pipeline Summary', label: string;
revenue: 'Revenue Report', subtitle: string;
activity: 'Activity Log', contents: string[];
occupancy: 'Berth Occupancy', }
const REPORT_TYPES: Record<string, ReportTypeMeta> = {
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 interests 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() { export function GenerateReportForm() {
@@ -74,13 +112,26 @@ export function GenerateReportForm() {
<SelectValue placeholder="Select a report type..." /> <SelectValue placeholder="Select a report type..." />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{Object.entries(REPORT_TYPE_LABELS).map(([value, label]) => ( {Object.entries(REPORT_TYPES).map(([value, meta]) => (
<SelectItem key={value} value={value}> <SelectItem key={value} value={value} className="py-2">
{label} <div className="flex flex-col">
<span className="font-medium">{meta.label}</span>
<span className="text-xs text-muted-foreground">{meta.subtitle}</span>
</div>
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
{reportType && REPORT_TYPES[reportType] ? (
<div className="mt-1 rounded-md border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
<p className="font-medium text-foreground">{REPORT_TYPES[reportType].subtitle}</p>
<ul className="mt-1 list-disc space-y-0.5 pl-4">
{REPORT_TYPES[reportType].contents.map((line) => (
<li key={line}>{line}</li>
))}
</ul>
</div>
) : null}
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
@@ -94,7 +145,7 @@ export function GenerateReportForm() {
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="flex flex-wrap gap-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="dateFrom">Date From (optional)</Label> <Label htmlFor="dateFrom">Date From (optional)</Label>
<Input <Input
@@ -102,6 +153,7 @@ export function GenerateReportForm() {
type="date" type="date"
value={dateFrom} value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)} onChange={(e) => setDateFrom(e.target.value)}
className="w-auto"
/> />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
@@ -111,6 +163,7 @@ export function GenerateReportForm() {
type="date" type="date"
value={dateTo} value={dateTo}
onChange={(e) => setDateTo(e.target.value)} onChange={(e) => setDateTo(e.target.value)}
className="w-auto"
/> />
</div> </div>
</div> </div>

View File

@@ -15,6 +15,7 @@ import { SubdivisionCombobox } from '@/components/shared/subdivision-combobox';
import { NotesList } from '@/components/shared/notes-list'; import { NotesList } from '@/components/shared/notes-list';
import { EntityActivityFeed } from '@/components/shared/entity-activity-feed'; import { EntityActivityFeed } from '@/components/shared/entity-activity-feed';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { SOURCES } from '@/lib/constants';
import type { CountryCode } from '@/lib/i18n/countries'; import type { CountryCode } from '@/lib/i18n/countries';
interface ResidentialInterestSummary { interface ResidentialInterestSummary {
@@ -62,13 +63,7 @@ const CONTACT_OPTIONS = [
{ value: 'email', label: 'Email' }, { value: 'email', label: 'Email' },
{ value: 'phone', label: 'Phone' }, { value: 'phone', label: 'Phone' },
]; ];
const SOURCE_OPTIONS = [ const SOURCE_OPTIONS = SOURCES.map((s) => ({ value: s.value, label: s.label }));
{ value: 'website', label: 'Website' },
{ value: 'manual', label: 'Manual' },
{ value: 'referral', label: 'Referral' },
{ value: 'broker', label: 'Broker' },
{ value: 'other', label: 'Other' },
];
function Row({ label, children }: { label: string; children: React.ReactNode }) { function Row({ label, children }: { label: string; children: React.ReactNode }) {
return ( return (
@@ -117,10 +112,10 @@ export function getResidentialClientTabs({
label: 'Notes', label: 'Notes',
content: ( content: (
<NotesList <NotesList
aggregate
entityType="residential_clients" entityType="residential_clients"
entityId={clientId} entityId={clientId}
currentUserId={currentUserId} currentUserId={currentUserId}
aggregate
/> />
), ),
}, },

View File

@@ -7,6 +7,7 @@ import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { NotesList } from '@/components/shared/notes-list'; import { NotesList } from '@/components/shared/notes-list';
import { EntityActivityFeed } from '@/components/shared/entity-activity-feed'; import { EntityActivityFeed } from '@/components/shared/entity-activity-feed';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { SOURCES } from '@/lib/constants';
interface ResidentialInterest { interface ResidentialInterest {
id: string; id: string;
@@ -25,13 +26,7 @@ interface Args {
stageOptions: Array<{ value: string; label: string }>; stageOptions: Array<{ value: string; label: string }>;
} }
const SOURCE_OPTIONS = [ const SOURCE_OPTIONS = SOURCES.map((s) => ({ value: s.value, label: s.label }));
{ value: 'website', label: 'Website' },
{ value: 'manual', label: 'Manual' },
{ value: 'referral', label: 'Referral' },
{ value: 'broker', label: 'Broker' },
{ value: 'other', label: 'Other' },
];
function Row({ label, children }: { label: string; children: React.ReactNode }) { function Row({ label, children }: { label: string; children: React.ReactNode }) {
return ( return (

View File

@@ -12,6 +12,7 @@ import {
} from 'react'; } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useQueryClient } from '@tanstack/react-query';
import { import {
Anchor, Anchor,
Bell, Bell,
@@ -100,6 +101,7 @@ export function CommandSearch() {
const [focusIndex, setFocusIndex] = useState<number>(-1); const [focusIndex, setFocusIndex] = useState<number>(-1);
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient();
const portSlug = useUIStore((s) => s.currentPortSlug); const portSlug = useUIStore((s) => s.currentPortSlug);
const wrapperRef = useRef<HTMLDivElement>(null); const wrapperRef = useRef<HTMLDivElement>(null);
@@ -113,8 +115,32 @@ export function CommandSearch() {
limit: activeBucket === 'all' ? 5 : 15, 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<SearchResults['totals'] | null>(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; 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. // Cmd/Ctrl+K focuses the input from anywhere on the page.
useEffect(() => { useEffect(() => {
function onKeyDown(e: globalThis.KeyboardEvent) { function onKeyDown(e: globalThis.KeyboardEvent) {
@@ -287,7 +313,7 @@ export function CommandSearch() {
> >
{/* Filter chip row — always visible while the dropdown is open. */} {/* Filter chip row — always visible while the dropdown is open. */}
<FilterChipRow <FilterChipRow
results={results} totals={chipTotals}
active={activeBucket} active={activeBucket}
onChange={setActiveBucket} onChange={setActiveBucket}
disabled={query.length < 2} disabled={query.length < 2}
@@ -337,18 +363,19 @@ export function CommandSearch() {
// ─── Filter chips ──────────────────────────────────────────────────────────── // ─── Filter chips ────────────────────────────────────────────────────────────
function FilterChipRow({ function FilterChipRow({
results, totals,
active, active,
onChange, onChange,
disabled, disabled,
}: { }: {
results: SearchResults | undefined; /** Counts from the last "all" query, persisted so chips stay visible
* when the user narrows to a single bucket. Falls back to the current
* results.totals when no "all" snapshot exists yet. */
totals: SearchResults['totals'] | undefined;
active: BucketType | 'all'; active: BucketType | 'all';
onChange: (b: BucketType | 'all') => void; onChange: (b: BucketType | 'all') => void;
disabled: boolean; 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 ( return (
<div <div
role="tablist" role="tablist"
@@ -364,10 +391,10 @@ function FilterChipRow({
All All
</ChipButton> </ChipButton>
{BUCKETS.map((b) => { {BUCKETS.map((b) => {
const count = results?.totals?.[b.type] ?? 0; const count = totals?.[b.type] ?? 0;
// Hide chips for buckets the current user can't see (count === 0 // Hide chips for buckets with zero matches in the last "all"
// when the bucket query was permission-skipped) — but only after // snapshot — keeps the row tight and avoids dead-end clicks.
// a query has run, otherwise we'd hide every chip on first paint. // Always show the active chip + every chip before a query has run.
if (!disabled && count === 0 && active !== b.type) return null; if (!disabled && count === 0 && active !== b.type) return null;
return ( return (
<ChipButton <ChipButton
@@ -701,6 +728,11 @@ function ResultRow({
<HighlightMatch text={row.sub} query={query} /> <HighlightMatch text={row.sub} query={query} />
</div> </div>
)} )}
{row.relatedVia && (
<div className="text-[11px] italic text-muted-foreground/80 truncate mt-0.5">
via {row.relatedVia.label}
</div>
)}
</div> </div>
</button> </button>
); );
@@ -759,9 +791,9 @@ function BucketSection({
// ─── Flat-row construction (drives keyboard nav + ARIA) ────────────────────── // ─── 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'; kind: 'recent-view';
key: string; key: string;
@@ -783,6 +815,9 @@ type FlatRow =
sub: string | null; sub: string | null;
href: string; href: string;
badges?: ResultBadge[]; 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'; kind: 'other-port';
@@ -791,7 +826,7 @@ type FlatRow =
href: string; href: string;
}; };
interface BuildFlatRowsArgs { export interface BuildFlatRowsArgs {
query: string; query: string;
results: SearchResults | undefined; results: SearchResults | undefined;
recentlyViewed: RecentlyViewedItem[]; recentlyViewed: RecentlyViewedItem[];
@@ -800,7 +835,7 @@ interface BuildFlatRowsArgs {
portSlug: string | null; portSlug: string | null;
} }
function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] { export function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
const { query, results, recentlyViewed, recentSearches, activeBucket, portSlug } = args; const { query, results, recentlyViewed, recentSearches, activeBucket, portSlug } = args;
const rows: FlatRow[] = []; const rows: FlatRow[] = [];
@@ -839,6 +874,7 @@ function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
sub: c.matchedContact ?? null, sub: c.matchedContact ?? null,
href: `/${portSlug}/clients/${c.id}`, href: `/${portSlug}/clients/${c.id}`,
badges: c.archivedAt ? [{ label: 'Archived', tone: 'neutral' }] : undefined, badges: c.archivedAt ? [{ label: 'Archived', tone: 'neutral' }] : undefined,
relatedVia: c.relatedVia ?? null,
}); });
} }
} }
@@ -866,6 +902,7 @@ function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
label: y.name, label: y.name,
sub, sub,
href: `/${portSlug}/yachts/${y.id}`, href: `/${portSlug}/yachts/${y.id}`,
relatedVia: y.relatedVia ?? null,
}); });
} }
} }
@@ -880,6 +917,7 @@ function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
label: co.name, label: co.name,
sub, sub,
href: `/${portSlug}/companies/${co.id}`, href: `/${portSlug}/companies/${co.id}`,
relatedVia: co.relatedVia ?? null,
}); });
} }
} }
@@ -903,6 +941,7 @@ function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
sub: i.berthMooringNumber, sub: i.berthMooringNumber,
href: `/${portSlug}/interests/${i.id}`, href: `/${portSlug}/interests/${i.id}`,
badges: badges.length > 0 ? badges : undefined, badges: badges.length > 0 ? badges : undefined,
relatedVia: i.relatedVia ?? null,
}); });
} }
} }
@@ -942,6 +981,7 @@ function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
sub, sub,
href: `/${portSlug}/berths/${b.id}`, href: `/${portSlug}/berths/${b.id}`,
badges: badges.length > 0 ? badges : undefined, badges: badges.length > 0 ? badges : undefined,
relatedVia: b.relatedVia ?? null,
}); });
} }
} }

View File

@@ -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<BucketType | 'all'>('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<number | null>(null);
const router = useRouter();
const queryClient = useQueryClient();
const portSlug = useUIStore((s) => s.currentPortSlug);
const inputRef = useRef<HTMLInputElement>(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 <TrackEntityView>.
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<SearchResults['totals'] | null>(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<HTMLInputElement>) => {
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<FlatRow[]>(
() =>
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 (
<VaulDrawer.Root
open={open}
onOpenChange={onOpenChange}
// iOS Safari fluidity recipe sourced from:
// - github.com/shadcn-ui/ui/issues/4321 (page reflow on open)
// - gracefullight.dev fix-ios-safari-scroll-issue-with-vaul-drawer
//
// - shouldScaleBackground=false: page doesn't shrink behind us;
// feels in-app rather than card-over-page.
// - repositionInputs=false: don't let Vaul jiggle the viewport
// when the input autofocuses and the keyboard appears — that
// was the source of the "scroll then jump back" we were seeing.
// Vaul still locks scroll via its modal=true default.
// - autoFocus=true: Vaul focuses the input synchronously inside
// the user-gesture frame, which is the only way iOS Safari
// will pop the keyboard on programmatic focus. The input has
// `autoFocus` set below so Vaul picks it as the target.
shouldScaleBackground={false}
repositionInputs={false}
>
<VaulDrawer.Portal>
<VaulDrawer.Overlay className="fixed inset-0 z-50 bg-black/30 backdrop-blur-sm" />
<VaulDrawer.Content
// Anchor by top + explicit height (not bottom: 0). iOS treats
// `bottom: 0` on position:fixed inconsistently when the
// keyboard is up (sometimes layout viewport, sometimes visual);
// anchoring by top + height removes that ambiguity. Height
// comes from visualViewport.height — the only iOS-reliable
// source for "visible area above keyboard". 12px gap at the
// top keeps a strip of backdrop visible.
style={
visibleHeight != null
? { top: '12px', bottom: 'auto', height: `${Math.max(0, visibleHeight - 12)}px` }
: undefined
}
className={cn(
'fixed inset-x-0 z-50 flex flex-col rounded-t-2xl',
// Fallback when visibleHeight hasn't measured yet (first
// frame, SSR): top+bottom CSS-only sizing.
visibleHeight == null && 'top-3 bottom-0',
'border-t bg-background shadow-[0_-12px_40px_-12px_rgba(0,0,0,0.25)]',
// Respect the bottom safe-area so the home indicator never
// overlaps the scroll region.
'pb-safe-bottom',
)}
>
{/* 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. */}
<VaulDrawer.Title className="sr-only">Search</VaulDrawer.Title>
{/* Drag handle — Vaul reads this as a swipe target. Centered grip
+ a small label below feels iOS-native. */}
<div className="flex flex-col items-center pt-2.5 pb-1.5">
<div className="h-1.5 w-12 rounded-full bg-muted" aria-hidden />
</div>
{/* Sticky header: input + Cancel. The Cancel slides in from the
right when the input has focus, otherwise it sits flat. */}
<div className="flex items-center gap-2 px-4 pb-3">
<label className="relative flex h-11 flex-1 items-center rounded-xl bg-muted/70 px-3 transition-colors focus-within:bg-muted">
<Search className="size-4 shrink-0 text-muted-foreground" aria-hidden />
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onPaste={onPaste}
placeholder="Search clients, yachts, interests…"
aria-label="Search"
inputMode="search"
enterKeyHint="search"
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
className={cn(
'ml-2 h-full w-full min-w-0 bg-transparent text-base outline-none',
'placeholder:text-muted-foreground',
)}
/>
{query.length > 0 ? (
<button
type="button"
onClick={() => {
setQuery('');
inputRef.current?.focus();
}}
aria-label="Clear search"
className="ml-1 inline-flex size-7 shrink-0 items-center justify-center rounded-full text-muted-foreground active:bg-foreground/10"
>
<X className="size-4" />
</button>
) : null}
</label>
<button
type="button"
onClick={close}
className="text-sm font-medium text-primary active:opacity-60"
>
Cancel
</button>
</div>
{/* 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. */}
<div className="border-b pb-3">
<div className="flex gap-1.5 overflow-x-auto px-4 [-webkit-overflow-scrolling:touch] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<BucketChip
label="All"
active={activeBucket === 'all'}
onClick={() => 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 (
<BucketChip
key={b.type}
label={b.label}
count={count > 0 ? count : undefined}
active={activeBucket === b.type}
onClick={() => setActiveBucket(b.type)}
/>
);
})}
</div>
</div>
{/* Results scroll region. overscroll-contain prevents the body
from rubber-banding when the user scrolls past the bottom. */}
<div className="flex-1 overflow-y-auto overscroll-contain px-2 pb-4 pt-1">
{showingEmptyHints && rows.length === 0 ? (
<EmptyHint />
) : showingEmptyHints ? (
<RowList rows={rows} query={query} onSelect={navigate} variant="empty" />
) : noResults ? (
<NoResults query={query} />
) : (
<RowList rows={rows} query={query} onSelect={navigate} variant="results" />
)}
</div>
</VaulDrawer.Content>
</VaulDrawer.Portal>
</VaulDrawer.Root>
);
}
function BucketChip({
label,
count,
active,
onClick,
}: {
label: string;
count?: number;
active: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={cn(
'shrink-0 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors',
active
? 'border-primary bg-primary text-primary-foreground'
: 'border-border bg-background text-muted-foreground active:bg-accent active:text-accent-foreground',
)}
>
{label}
{typeof count === 'number' && <span className="ml-1 opacity-70">({count})</span>}
</button>
);
}
function EmptyHint() {
return (
<div className="flex h-full flex-col items-center gap-3 px-6 pb-12 pt-28 text-center">
<div className="flex size-14 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Search className="size-7" aria-hidden />
</div>
<p className="text-sm text-muted-foreground">
Search clients, yachts, interests, berths, invoices, documents paste a UUID or
invoice number to jump directly.
</p>
</div>
);
}
function NoResults({ query }: { query: string }) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 px-6 pb-12 text-center">
<p className="text-sm font-medium text-foreground">No matches for &ldquo;{query}&rdquo;</p>
<p className="text-xs text-muted-foreground">
Try a different spelling, or switch buckets above.
</p>
</div>
);
}
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 (
<div className="space-y-3">
{variant === 'empty' && recentViews.length > 0 ? (
<Section icon={<Clock className="size-3.5" />} label="Recently viewed">
{recentViews.map((row) =>
row.kind === 'recent-view' ? (
<Row
key={row.key}
onSelect={() => onSelect(row.href)}
label={row.item.label}
sub={row.item.sub}
/>
) : null,
)}
</Section>
) : null}
{variant === 'empty' && recentTerms.length > 0 ? (
<Section icon={<History className="size-3.5" />} label="Recent searches">
<div className="flex flex-wrap gap-1.5 px-2 py-1">
{recentTerms.map((row) =>
row.kind === 'recent-term' ? (
<button
key={row.key}
type="button"
onClick={() => {
// Recent-term taps populate the input rather than
// navigating — the rep usually wants to refine, not
// jump straight back to the previous result.
const input = document.querySelector<HTMLInputElement>(
'input[aria-label="Search"]',
);
if (input) {
input.value = row.term;
input.dispatchEvent(new Event('input', { bubbles: true }));
input.focus();
}
}}
className="rounded-full border border-border bg-muted/40 px-3 py-1 text-xs text-muted-foreground active:bg-accent active:text-accent-foreground"
>
{row.term}
</button>
) : null,
)}
</div>
</Section>
) : null}
{variant === 'results' && results.length > 0 ? renderResultRows(results, query, onSelect) : null}
</div>
);
}
/**
* 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(
<div
key={`__bucket_${row.bucket}_${i}`}
className="px-3 pt-3 pb-1 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"
>
{BUCKET_LABELS[row.bucket] ?? row.bucket}
</div>,
);
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 ? <HighlightMatch text={row.sub} query={query} /> : null}
{row.relatedVia ? (
<span className="block text-[11px] italic text-muted-foreground/80">
via {row.relatedVia.label}
</span>
) : null}
</>
);
nodes.push(
<Row
key={row.key}
onSelect={() => onSelect(row.href)}
label={<HighlightMatch text={row.label} query={query} />}
sub={row.sub || row.relatedVia ? subContent : null}
icon={<Icon className="size-4 text-muted-foreground" aria-hidden />}
badges={row.badges}
/>,
);
} else if (row.kind === 'other-port') {
nodes.push(
<Row
key={row.key}
onSelect={() => 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<BucketType, string> = {
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 (
<section>
<div className="flex items-center gap-1.5 px-3 pt-2 pb-1 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
{icon}
{label}
</div>
<div>{children}</div>
</section>
);
}
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 (
<button
type="button"
onClick={onSelect}
className={cn(
'flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left',
'min-h-[52px] active:bg-accent',
)}
>
{icon ? <span className="shrink-0">{icon}</span> : null}
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-medium text-foreground">{label}</span>
{sub ? <span className="truncate text-xs text-muted-foreground">{sub}</span> : null}
</span>
{badges?.length ? (
<span className="flex shrink-0 gap-1">
{badges.map((b) => (
<span
key={b.label}
className={cn(
'rounded-full px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide',
BADGE_TONE[b.tone],
)}
>
{b.label}
</span>
))}
</span>
) : null}
</button>
);
}

View File

@@ -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 (
<Card id="dashboard">
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<CardTitle>Dashboard widgets</CardTitle>
<CardDescription>
Pick which cards show up on your dashboard. Hidden cards leave no empty space the
layout reflows to fill the available width.
</CardDescription>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setAll(true)}
disabled={allVisible || isSaving}
>
Show all
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setAll(false)}
disabled={allHidden || isSaving}
>
Hide all
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-1">
{allWidgets.map((w) => (
<div
key={w.id}
className="flex items-start justify-between gap-4 rounded-md px-3 py-2 hover:bg-accent/40"
>
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">{w.label}</div>
<p className="text-xs text-muted-foreground">{w.description}</p>
</div>
<Switch
aria-label={`Show ${w.label}`}
checked={visibility[w.id] ?? false}
disabled={isSaving}
onCheckedChange={(checked) => setVisible(w.id, checked)}
/>
</div>
))}
</CardContent>
</Card>
);
}

View File

@@ -15,6 +15,7 @@ import { TimezoneCombobox } from '@/components/shared/timezone-combobox';
import { ImageCropperDialog } from '@/components/shared/image-cropper-dialog'; import { ImageCropperDialog } from '@/components/shared/image-cropper-dialog';
import { NotificationPreferencesForm } from '@/components/notifications/notification-preferences-form'; import { NotificationPreferencesForm } from '@/components/notifications/notification-preferences-form';
import { ReminderDigestForm } from '@/components/notifications/reminder-digest-form'; import { ReminderDigestForm } from '@/components/notifications/reminder-digest-form';
import { DashboardWidgetsCard } from '@/components/settings/dashboard-widgets-card';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { primaryTimezoneFor } from '@/lib/i18n/timezones'; import { primaryTimezoneFor } from '@/lib/i18n/timezones';
import type { CountryCode } from '@/lib/i18n/countries'; import type { CountryCode } from '@/lib/i18n/countries';
@@ -186,7 +187,7 @@ export function UserSettings() {
<div> <div>
<PageHeader title="Settings" description="Manage your profile and notification preferences" /> <PageHeader title="Settings" description="Manage your profile and notification preferences" />
<div className="mt-6 space-y-6 max-w-2xl"> <div className="mt-6 space-y-6">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Profile</CardTitle> <CardTitle>Profile</CardTitle>
@@ -318,6 +319,8 @@ export function UserSettings() {
</CardContent> </CardContent>
</Card> </Card>
<DashboardWidgetsCard />
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Account</CardTitle> <CardTitle>Account</CardTitle>

View File

@@ -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<string>();
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.
<Popover open={open} onOpenChange={setOpen} modal>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
disabled={disabled}
className={cn('w-full justify-between', !value && 'text-muted-foreground')}
>
<span className="truncate">{selectedLabel}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[320px] p-0" align="start">
<Command shouldFilter={false}>
<CommandInput
placeholder={clientId ? "Search this client's berths…" : 'Search berths…'}
value={search}
onValueChange={setSearch}
/>
<CommandList>
<CommandEmpty>
{clientId ? 'No berths linked to this client.' : 'No berths found.'}
</CommandEmpty>
<CommandGroup>
{value ? (
<CommandItem
value="__clear__"
onSelect={() => {
onChange(null);
setOpen(false);
}}
className="text-muted-foreground"
>
Clear selection
</CommandItem>
) : null}
{options.map((o) => (
<CommandItem
key={o.id}
value={o.id}
onSelect={() => {
onChange(o.id);
setOpen(false);
}}
>
<Check
className={cn('mr-2 h-4 w-4', value === o.id ? 'opacity-100' : 'opacity-0')}
/>
<span className="truncate">{labelFor(o)}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -58,7 +58,10 @@ export function ClientPicker({
})(); })();
return ( return (
<Popover open={open} onOpenChange={setOpen}> // `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.
<Popover open={open} onOpenChange={setOpen} modal>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
variant="outline" variant="outline"
@@ -76,6 +79,18 @@ export function ClientPicker({
<CommandList> <CommandList>
<CommandEmpty>No clients found.</CommandEmpty> <CommandEmpty>No clients found.</CommandEmpty>
<CommandGroup> <CommandGroup>
{value ? (
<CommandItem
value="__clear__"
onSelect={() => {
onChange(null);
setOpen(false);
}}
className="text-muted-foreground"
>
Clear selection
</CommandItem>
) : null}
{options.map((c) => ( {options.map((c) => (
<CommandItem <CommandItem
key={c.id} key={c.id}

View File

@@ -87,7 +87,11 @@ export function CountryCombobox({
const selected = value ? options.find((o) => o.code === value) : undefined; const selected = value ? options.find((o) => o.code === value) : undefined;
return ( return (
<Popover open={open} onOpenChange={handleOpenChange}> // modal: required when this combobox is nested inside a Sheet
// (Radix Dialog). Without it, the parent Dialog's pointer-events
// handling swallows the trigger's tap on iOS Safari — same fix
// pattern as TimezoneCombobox.
<Popover modal open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
id={id} id={id}

View File

@@ -19,19 +19,76 @@ interface CurrencyInputProps extends Omit<
className?: string; className?: string;
} }
const groupFormatter = new Intl.NumberFormat('en-US', {
maximumFractionDigits: 2,
useGrouping: true,
});
function formatGrouped(value: number | string): string {
const n = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(n)) return '';
return groupFormatter.format(n);
}
function parseTyped(raw: string): { display: string; numeric: number | null } {
// Strip everything except digits, '.', '-'. Commas are formatting noise from
// our own display and are removed before re-grouping. (Locale note: this
// assumes '.' as decimal separator, matching the en-US formatter below.)
let cleaned = raw.replace(/[^\d.-]/g, '');
// Keep only the first '.' (additional dots are dropped).
const firstDot = cleaned.indexOf('.');
if (firstDot !== -1) {
cleaned = cleaned.slice(0, firstDot + 1) + cleaned.slice(firstDot + 1).replace(/\./g, '');
}
// Sign: only honour a leading '-'; strip any others.
const negative = cleaned.startsWith('-');
cleaned = (negative ? '-' : '') + cleaned.replace(/-/g, '');
if (cleaned === '' || cleaned === '-') return { display: cleaned, numeric: null };
const dot = cleaned.indexOf('.');
const intPart = dot === -1 ? cleaned : cleaned.slice(0, dot);
const fracPart = dot === -1 ? null : cleaned.slice(dot + 1);
const intDigitsOnly = intPart.replace('-', '');
const intNumeric = intDigitsOnly === '' ? 0 : Number(intDigitsOnly);
const numeric = (negative ? -1 : 1) * (intNumeric + (fracPart ? Number(`0.${fracPart}`) || 0 : 0));
const intDisplay =
intDigitsOnly === ''
? (negative ? '-' : '')
: (negative ? '-' : '') + groupFormatter.format(intNumeric);
const display = fracPart === null ? intDisplay : `${intDisplay}.${fracPart}`;
return { display, numeric: Number.isFinite(numeric) ? numeric : null };
}
/** /**
* Numeric input pre-decorated with a currency symbol. The display * Numeric input pre-decorated with a currency symbol and thousand-separator
* value is the raw number the user typed (we don't fight the keystroke * grouping (e.g. `3,528,000.50`). Uses `type="text"` + `inputMode="decimal"`
* cadence by re-formatting on every key) — formatted display lives in * so we can render commas (HTML `type="number"` strips them) while still
* read-only contexts via `formatCurrency()`. This keeps form behaviour * surfacing the decimal keypad on iOS/Android. The parent receives a raw
* predictable while still scoping the input to a money field via the * number via `onChange`; the formatted string is local UI state.
* symbol prefix and the `decimal` inputMode.
*/ */
export const CurrencyInput = React.forwardRef<HTMLInputElement, CurrencyInputProps>( export const CurrencyInput = React.forwardRef<HTMLInputElement, CurrencyInputProps>(
({ value, onChange, currency = 'USD', className, ...props }, ref) => { ({ value, onChange, currency = 'USD', className, onBlur, onFocus, ...props }, ref) => {
const symbol = currencySymbol(currency); const symbol = currencySymbol(currency);
const display = value === null || value === undefined || value === '' ? '' : String(value); const [display, setDisplay] = React.useState<string>(() =>
value === null || value === undefined || value === '' ? '' : formatGrouped(value),
);
const focusedRef = React.useRef(false);
// Re-sync the display when the controlled value changes externally (form
// reset, parent-driven update). Skip while the input is focused so we
// don't fight the user's keystrokes.
React.useEffect(() => {
if (focusedRef.current) return;
if (value === null || value === undefined || value === '') {
setDisplay('');
} else {
setDisplay(formatGrouped(value));
}
}, [value]);
return ( return (
<div className="relative"> <div className="relative">
@@ -43,19 +100,29 @@ export const CurrencyInput = React.forwardRef<HTMLInputElement, CurrencyInputPro
</span> </span>
<Input <Input
ref={ref} ref={ref}
type="number" type="text"
inputMode="decimal" inputMode="decimal"
step="0.01" autoComplete="off"
min="0"
value={display} value={display}
onChange={(e) => { onChange={(e) => {
const raw = e.target.value; const { display: nextDisplay, numeric } = parseTyped(e.target.value);
if (raw === '') { setDisplay(nextDisplay);
onChange(null); onChange(numeric);
return; }}
onFocus={(e) => {
focusedRef.current = true;
onFocus?.(e);
}}
onBlur={(e) => {
focusedRef.current = false;
// On blur, canonicalize to a clean grouped representation so the
// user sees the final value rather than any half-typed state.
if (value === null || value === undefined || value === '') {
setDisplay('');
} else {
setDisplay(formatGrouped(value));
} }
const n = Number(raw); onBlur?.(e);
onChange(Number.isFinite(n) ? n : null);
}} }}
className={cn('pl-9 tabular-nums', className)} className={cn('pl-9 tabular-nums', className)}
{...props} {...props}

View File

@@ -65,6 +65,15 @@ interface DataTableProps<TData> {
* sort, and selection stay in sync across the breakpoint. * sort, and selection stay in sync across the breakpoint.
*/ */
cardRender?: (row: Row<TData>) => React.ReactNode; cardRender?: (row: Row<TData>) => React.ReactNode;
/**
* Optional grouping key for the mobile card list. When set, consecutive
* rows that share the same returned key are visually grouped under a
* header showing the key. Rendered only on mobile (next to cardRender);
* the desktop table is unaffected. Useful for berths-by-area,
* documents-by-folder, etc. — pre-sort the data on the same key so
* adjacent rows already share groups.
*/
mobileGroupBy?: (row: TData) => string | null | undefined;
/** /**
* Per-column visibility map. Keys are column IDs, values mean * Per-column visibility map. Keys are column IDs, values mean
* "currently visible". Columns absent from the map are visible by * "currently visible". Columns absent from the map are visible by
@@ -90,6 +99,7 @@ export function DataTable<TData>({
onRowClick, onRowClick,
getRowClassName, getRowClassName,
cardRender, cardRender,
mobileGroupBy,
columnVisibility, columnVisibility,
}: DataTableProps<TData>) { }: DataTableProps<TData>) {
const [internalSelection, setInternalSelection] = useState<RowSelectionState>({}); const [internalSelection, setInternalSelection] = useState<RowSelectionState>({});
@@ -259,7 +269,30 @@ export function DataTable<TData>({
{emptyState ?? 'No results.'} {emptyState ?? 'No results.'}
</li> </li>
) : ( ) : (
rows.map((row) => <li key={row.id}>{cardRender(row)}</li>) (() => {
// Walk rows once, emitting a section header <li> every time
// the groupBy key changes. Keeps the existing flex-col gap-2
// rhythm; the header sits above the first card of each group
// with a faint top divider for visual rest between blocks.
let lastGroup: string | null | undefined;
const nodes: React.ReactNode[] = [];
rows.forEach((row, i) => {
const group = mobileGroupBy ? mobileGroupBy(row.original) : undefined;
if (mobileGroupBy && group !== lastGroup) {
nodes.push(
<li key={`__group_${group ?? '_none'}_${i}`} className="px-1 pt-3">
<div className="flex items-center gap-3 text-base font-bold tracking-tight text-foreground">
<span>{group ?? 'Other'}</span>
<span aria-hidden className="h-px flex-1 bg-border" />
</div>
</li>,
);
lastGroup = group;
}
nodes.push(<li key={row.id}>{cardRender(row)}</li>);
});
return nodes;
})()
)} )}
</ul> </ul>
)} )}

View File

@@ -5,11 +5,27 @@ import { Drawer as VaulDrawer } from 'vaul';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
// Default `shouldScaleBackground` to FALSE for smoother drag animations.
// Scaling the underlying page during the swipe rasterises a heavy DOM
// (dashboard widgets, charts, queries firing) into a composited layer
// every frame, which stutters on mid-tier phones. The bg-black/60
// overlay alone provides enough depth signal. Individual call sites can
// still opt back in if they have a lightweight page underneath.
//
// Also default `repositionInputs={false}` — when the drawer has form
// inputs, Vaul's viewport repositioning logic conflicts with iOS's
// keyboard handling and produces the visible scroll-then-jump we hit
// in the search overlay.
const Drawer = ({ const Drawer = ({
shouldScaleBackground = true, shouldScaleBackground = false,
repositionInputs = false,
...props ...props
}: React.ComponentProps<typeof VaulDrawer.Root>) => ( }: React.ComponentProps<typeof VaulDrawer.Root>) => (
<VaulDrawer.Root shouldScaleBackground={shouldScaleBackground} {...props} /> <VaulDrawer.Root
shouldScaleBackground={shouldScaleBackground}
repositionInputs={repositionInputs}
{...props}
/>
); );
Drawer.displayName = 'Drawer'; Drawer.displayName = 'Drawer';

View File

@@ -22,6 +22,13 @@ interface SelectOption {
interface BaseProps { interface BaseProps {
value: string | null | undefined; value: string | null | undefined;
/**
* Optional formatted version shown in display mode only. The edit
* input still works against the raw `value` (so the input shows the
* editable raw number, not the formatted string). Useful for
* currency, percentages, etc.
*/
displayValue?: string | null;
onSave: (next: string | null) => Promise<void>; onSave: (next: string | null) => Promise<void>;
placeholder?: string; placeholder?: string;
emptyText?: string; emptyText?: string;
@@ -43,7 +50,15 @@ interface TextareaProps extends BaseProps {
rows?: number; rows?: number;
} }
export type InlineEditableFieldProps = TextProps | SelectFieldProps | TextareaProps; interface DateProps extends BaseProps {
variant: 'date';
/** Optional min/max bounds in YYYY-MM-DD form (e.g. for incorporation dates that
* can't be in the future). */
min?: string;
max?: string;
}
export type InlineEditableFieldProps = TextProps | SelectFieldProps | TextareaProps | DateProps;
/** /**
* Click-to-edit field used in detail panels. Shows the value as plain text * Click-to-edit field used in detail panels. Shows the value as plain text
@@ -51,7 +66,15 @@ export type InlineEditableFieldProps = TextProps | SelectFieldProps | TextareaPr
* Enter/blur and cancels on Escape. * Enter/blur and cancels on Escape.
*/ */
export function InlineEditableField(props: InlineEditableFieldProps) { export function InlineEditableField(props: InlineEditableFieldProps) {
const { value, onSave, placeholder, emptyText = '-', className, disabled } = props; const {
value,
displayValue,
onSave,
placeholder,
emptyText = '-',
className,
disabled,
} = props;
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value ?? ''); const [draft, setDraft] = useState(value ?? '');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -131,11 +154,42 @@ export function InlineEditableField(props: InlineEditableFieldProps) {
); );
} }
if (props.variant === 'date') {
// Native date input: the browser provides the calendar UI, ISO-formatted
// value (YYYY-MM-DD) keeps the backend payload uniform. Saves on change
// (no extra blur tap on mobile) and on Enter; Escape reverts.
return (
<div className={cn('flex items-center gap-1', className)}>
<Input
type="date"
value={draft}
min={props.min}
max={props.max}
onChange={(e) => {
const next = e.target.value;
setDraft(next);
void commit(next);
}}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault();
cancel();
}
}}
disabled={saving || disabled}
className="h-8 text-sm w-auto"
/>
{saving && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
</div>
);
}
if (props.variant === 'textarea') { if (props.variant === 'textarea') {
if (!editing) { if (!editing) {
return ( return (
<ReadButton <ReadButton
value={value || null} value={value || null}
displayValue={displayValue}
emptyText={emptyText} emptyText={emptyText}
disabled={disabled} disabled={disabled}
onClick={() => setEditing(true)} onClick={() => setEditing(true)}
@@ -178,6 +232,7 @@ export function InlineEditableField(props: InlineEditableFieldProps) {
return ( return (
<ReadButton <ReadButton
value={value || null} value={value || null}
displayValue={displayValue}
emptyText={emptyText} emptyText={emptyText}
disabled={disabled} disabled={disabled}
onClick={() => setEditing(true)} onClick={() => setEditing(true)}
@@ -216,6 +271,7 @@ export function InlineEditableField(props: InlineEditableFieldProps) {
function ReadButton({ function ReadButton({
value, value,
displayValue,
emptyText, emptyText,
disabled, disabled,
onClick, onClick,
@@ -224,6 +280,8 @@ function ReadButton({
className, className,
}: { }: {
value: string | null; value: string | null;
/** Optional formatted version for display only (currency, percent, etc.) */
displayValue?: string | null;
emptyText: string; emptyText: string;
disabled?: boolean; disabled?: boolean;
onClick: () => void; onClick: () => void;
@@ -258,7 +316,7 @@ function ReadButton({
!value && 'text-muted-foreground', !value && 'text-muted-foreground',
)} )}
> >
{value ?? emptyText} {value ? (displayValue ?? value) : emptyText}
</span> </span>
{!disabled && ( {!disabled && (
<Icon <Icon

View File

@@ -146,7 +146,7 @@ export function InlinePhoneField({
{display ?? emptyText} {display ?? emptyText}
</span> </span>
{!disabled && ( {!disabled && (
<Pencil className="h-3 w-3 opacity-0 transition-opacity group-hover:opacity-50" /> <Pencil className="h-3 w-3 opacity-20 transition-opacity group-hover:opacity-60" />
)} )}
</button> </button>
); );

Some files were not shown because too many files have changed in this diff Show More