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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -29,6 +29,8 @@ docker-compose.override.yml
|
||||
# Ad-hoc screenshots / scratch artifacts at repo root
|
||||
/*.png
|
||||
/*.jpg
|
||||
# Local-only dashboard widget-combo screenshots — regenerated by manual testing
|
||||
/combos/
|
||||
|
||||
# Legacy Nuxt portal — kept on disk for reference, not tracked here
|
||||
/client-portal/
|
||||
|
||||
@@ -52,6 +52,17 @@ const securityHeaders = [
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
// Hide the floating dev indicator (the little circle/N badge in the
|
||||
// corner). Compile errors still surface via the full overlay; this
|
||||
// only removes the idle "everything is fine" indicator that's been
|
||||
// visible in every screenshot from the iPhone testing pass.
|
||||
devIndicators: false,
|
||||
// LAN access from a real iPhone hits the dev server via the Mac's
|
||||
// local IP (e.g. 192.168.x.x), not localhost. Next 15 surfaces a
|
||||
// warning for cross-origin /_next/* fetches unless we allow-list the
|
||||
// origins explicitly. Wildcard the 192.168/0.0.0.0 ranges in dev so
|
||||
// any LAN device works without a config edit per network.
|
||||
...(isProd ? {} : { allowedDevOrigins: ['192.168.1.42'] }),
|
||||
serverExternalPackages: [
|
||||
'pino',
|
||||
'pino-pretty',
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
"@tanstack/react-query-devtools": "^5.100.9",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/pdfkit": "^0.17.6",
|
||||
"archiver": "^8.0.0",
|
||||
"archiver": "^7.0.1",
|
||||
"better-auth": "^1.6.9",
|
||||
"bullmq": "^5.76.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
||||
258
pnpm-lock.yaml
generated
258
pnpm-lock.yaml
generated
@@ -110,8 +110,8 @@ importers:
|
||||
specifier: ^0.17.6
|
||||
version: 0.17.6
|
||||
archiver:
|
||||
specifier: ^8.0.0
|
||||
version: 8.0.0
|
||||
specifier: ^7.0.1
|
||||
version: 7.0.1
|
||||
better-auth:
|
||||
specifier: ^1.6.9
|
||||
version: 1.6.9(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(gel@2.2.0)(kysely@0.28.17)(postgres@3.4.9))(mongodb@7.1.0(socks@2.8.8))(next@15.5.18(@playwright/test@1.59.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.5)
|
||||
@@ -858,6 +858,10 @@ packages:
|
||||
'@ioredis/commands@1.5.1':
|
||||
resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==}
|
||||
|
||||
'@isaacs/cliui@8.0.2':
|
||||
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.13':
|
||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||
|
||||
@@ -1042,6 +1046,10 @@ packages:
|
||||
'@pinojs/redact@0.4.0':
|
||||
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
'@playwright/test@1.59.1':
|
||||
resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -2121,6 +2129,10 @@ packages:
|
||||
resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
ansi-regex@5.0.1:
|
||||
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
ansi-regex@6.2.2:
|
||||
resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -2140,9 +2152,13 @@ packages:
|
||||
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
archiver@8.0.0:
|
||||
resolution: {integrity: sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g==}
|
||||
engines: {node: '>=18'}
|
||||
archiver-utils@5.0.2:
|
||||
resolution: {integrity: sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
archiver@7.0.1:
|
||||
resolution: {integrity: sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
arg@5.0.2:
|
||||
resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
|
||||
@@ -2390,6 +2406,9 @@ packages:
|
||||
brace-expansion@1.1.14:
|
||||
resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==}
|
||||
|
||||
brace-expansion@2.1.0:
|
||||
resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==}
|
||||
|
||||
brace-expansion@5.0.6:
|
||||
resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
@@ -2543,9 +2562,9 @@ packages:
|
||||
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
compress-commons@7.0.1:
|
||||
resolution: {integrity: sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ==}
|
||||
engines: {node: '>=18'}
|
||||
compress-commons@6.0.2:
|
||||
resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
concat-map@0.0.1:
|
||||
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
||||
@@ -2569,9 +2588,9 @@ packages:
|
||||
engines: {node: '>=0.8'}
|
||||
hasBin: true
|
||||
|
||||
crc32-stream@7.0.1:
|
||||
resolution: {integrity: sha512-IBWsY8xznyQrcHn8h4bC8/4ErNke5elzgG8GcqF4RFPw6aHkWWRc7Tgw6upjaTX/CT/yQgqYENkxYsTYN+hW2g==}
|
||||
engines: {node: '>=18'}
|
||||
crc32-stream@6.0.0:
|
||||
resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
cron-parser@4.9.0:
|
||||
resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==}
|
||||
@@ -2852,12 +2871,18 @@ packages:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
eastasianwidth@0.2.0:
|
||||
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
||||
|
||||
electron-to-chromium@1.5.352:
|
||||
resolution: {integrity: sha512-9wHk8x6dyuimoe18EdiDPWKExNdxYqo4fn4FwOVVper6RxT3cmpBwBkWWfSOCYJjQdIco/nPhJhNLmn4Ufg1Yg==}
|
||||
|
||||
emoji-regex@10.6.0:
|
||||
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
|
||||
|
||||
emoji-regex@8.0.0:
|
||||
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
||||
|
||||
emoji-regex@9.2.2:
|
||||
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
|
||||
|
||||
@@ -3164,6 +3189,10 @@ packages:
|
||||
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
foreground-child@3.3.1:
|
||||
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
fraction.js@5.3.4:
|
||||
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
|
||||
|
||||
@@ -3230,6 +3259,11 @@ packages:
|
||||
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
glob@10.5.0:
|
||||
resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
hasBin: true
|
||||
|
||||
globals@14.0.0:
|
||||
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -3242,6 +3276,9 @@ packages:
|
||||
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
graceful-fs@4.2.11:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
|
||||
has-bigints@1.1.0:
|
||||
resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -3403,6 +3440,10 @@ packages:
|
||||
resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
is-fullwidth-code-point@3.0.0:
|
||||
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
is-fullwidth-code-point@5.1.0:
|
||||
resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -3447,9 +3488,9 @@ packages:
|
||||
resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
is-stream@4.0.1:
|
||||
resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==}
|
||||
engines: {node: '>=18'}
|
||||
is-stream@2.0.1:
|
||||
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
is-string@1.1.1:
|
||||
resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==}
|
||||
@@ -3514,6 +3555,9 @@ packages:
|
||||
resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
jackspeak@3.4.3:
|
||||
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
|
||||
|
||||
jiti@1.21.7:
|
||||
resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
|
||||
hasBin: true
|
||||
@@ -3726,6 +3770,9 @@ packages:
|
||||
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
||||
hasBin: true
|
||||
|
||||
lru-cache@10.4.3:
|
||||
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
|
||||
|
||||
lucide-react@1.14.0:
|
||||
resolution: {integrity: sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==}
|
||||
peerDependencies:
|
||||
@@ -3785,6 +3832,14 @@ packages:
|
||||
minimatch@3.1.5:
|
||||
resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
|
||||
|
||||
minimatch@5.1.9:
|
||||
resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
minimatch@9.0.9:
|
||||
resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
|
||||
minimist@1.2.8:
|
||||
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
||||
|
||||
@@ -3792,6 +3847,10 @@ packages:
|
||||
resolution: {integrity: sha512-E737MgufW8CeQAsTAtnEMrxZ9scMSf29kkhZoXzDTKj/Jszzo2SfeZUH9wbDQH2Rsq6TCtl/yQL0+XdVKZansQ==}
|
||||
engines: {node: ^16 || ^18 || >=20}
|
||||
|
||||
minipass@7.1.3:
|
||||
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
|
||||
mongodb-connection-string-url@7.0.1:
|
||||
resolution: {integrity: sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
@@ -4014,6 +4073,9 @@ packages:
|
||||
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
package-json-from-dist@1.0.1:
|
||||
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
||||
|
||||
package-manager-detector@1.6.0:
|
||||
resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==}
|
||||
|
||||
@@ -4048,6 +4110,10 @@ packages:
|
||||
path-parse@1.0.7:
|
||||
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
|
||||
|
||||
path-scurry@1.11.1:
|
||||
resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
|
||||
engines: {node: '>=16 || 14 >=14.18'}
|
||||
|
||||
pathe@2.0.3:
|
||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||
|
||||
@@ -4306,9 +4372,8 @@ packages:
|
||||
resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
|
||||
readdir-glob@3.0.0:
|
||||
resolution: {integrity: sha512-AhNB2KgKeVJr16nK9LLZbJNWnYoT23ZrumNKFDebHBdkC8KHSqWo871JAUhoWC/RtjEVdqNMFpM6qrwRbaUqpw==}
|
||||
engines: {node: '>=18'}
|
||||
readdir-glob@1.1.3:
|
||||
resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==}
|
||||
|
||||
readdirp@3.6.0:
|
||||
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
|
||||
@@ -4608,6 +4673,14 @@ packages:
|
||||
resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==}
|
||||
engines: {node: '>=0.6.19'}
|
||||
|
||||
string-width@4.2.3:
|
||||
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
string-width@5.1.2:
|
||||
resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
string-width@7.2.0:
|
||||
resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -4645,6 +4718,10 @@ packages:
|
||||
string_decoder@1.3.0:
|
||||
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
|
||||
|
||||
strip-ansi@6.0.1:
|
||||
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
strip-ansi@7.2.0:
|
||||
resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -5032,6 +5109,14 @@ packages:
|
||||
resolution: {integrity: sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
wrap-ansi@8.1.0:
|
||||
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
wrap-ansi@9.0.2:
|
||||
resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -5080,9 +5165,9 @@ packages:
|
||||
resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
zip-stream@7.0.5:
|
||||
resolution: {integrity: sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w==}
|
||||
engines: {node: '>=18'}
|
||||
zip-stream@6.0.1:
|
||||
resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
zlibjs@0.3.1:
|
||||
resolution: {integrity: sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==}
|
||||
@@ -5506,6 +5591,15 @@ snapshots:
|
||||
|
||||
'@ioredis/commands@1.5.1': {}
|
||||
|
||||
'@isaacs/cliui@8.0.2':
|
||||
dependencies:
|
||||
string-width: 5.1.2
|
||||
string-width-cjs: string-width@4.2.3
|
||||
strip-ansi: 7.2.0
|
||||
strip-ansi-cjs: strip-ansi@6.0.1
|
||||
wrap-ansi: 8.1.0
|
||||
wrap-ansi-cjs: wrap-ansi@7.0.0
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.13':
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
@@ -5662,6 +5756,9 @@ snapshots:
|
||||
|
||||
'@pinojs/redact@0.4.0': {}
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
optional: true
|
||||
|
||||
'@playwright/test@1.59.1':
|
||||
dependencies:
|
||||
playwright: 1.59.1
|
||||
@@ -6740,6 +6837,8 @@ snapshots:
|
||||
dependencies:
|
||||
environment: 1.1.0
|
||||
|
||||
ansi-regex@5.0.1: {}
|
||||
|
||||
ansi-regex@6.2.2: {}
|
||||
|
||||
ansi-styles@4.3.0:
|
||||
@@ -6755,17 +6854,25 @@ snapshots:
|
||||
normalize-path: 3.0.0
|
||||
picomatch: 2.3.2
|
||||
|
||||
archiver@8.0.0:
|
||||
archiver-utils@5.0.2:
|
||||
dependencies:
|
||||
async: 3.2.6
|
||||
buffer-crc32: 1.0.0
|
||||
is-stream: 4.0.1
|
||||
glob: 10.5.0
|
||||
graceful-fs: 4.2.11
|
||||
is-stream: 2.0.1
|
||||
lazystream: 1.0.1
|
||||
lodash: 4.18.1
|
||||
normalize-path: 3.0.0
|
||||
readable-stream: 4.7.0
|
||||
readdir-glob: 3.0.0
|
||||
|
||||
archiver@7.0.1:
|
||||
dependencies:
|
||||
archiver-utils: 5.0.2
|
||||
async: 3.2.6
|
||||
buffer-crc32: 1.0.0
|
||||
readable-stream: 4.7.0
|
||||
readdir-glob: 1.1.3
|
||||
tar-stream: 3.2.0
|
||||
zip-stream: 7.0.5
|
||||
zip-stream: 6.0.1
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
- bare-buffer
|
||||
@@ -6984,6 +7091,10 @@ snapshots:
|
||||
balanced-match: 1.0.2
|
||||
concat-map: 0.0.1
|
||||
|
||||
brace-expansion@2.1.0:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
|
||||
brace-expansion@5.0.6:
|
||||
dependencies:
|
||||
balanced-match: 4.0.4
|
||||
@@ -7141,11 +7252,11 @@ snapshots:
|
||||
|
||||
commander@4.1.1: {}
|
||||
|
||||
compress-commons@7.0.1:
|
||||
compress-commons@6.0.2:
|
||||
dependencies:
|
||||
crc-32: 1.2.2
|
||||
crc32-stream: 7.0.1
|
||||
is-stream: 4.0.1
|
||||
crc32-stream: 6.0.0
|
||||
is-stream: 2.0.1
|
||||
normalize-path: 3.0.0
|
||||
readable-stream: 4.7.0
|
||||
|
||||
@@ -7164,7 +7275,7 @@ snapshots:
|
||||
|
||||
crc-32@1.2.2: {}
|
||||
|
||||
crc32-stream@7.0.1:
|
||||
crc32-stream@6.0.0:
|
||||
dependencies:
|
||||
crc-32: 1.2.2
|
||||
readable-stream: 4.7.0
|
||||
@@ -7340,10 +7451,14 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
gopd: 1.2.0
|
||||
|
||||
eastasianwidth@0.2.0: {}
|
||||
|
||||
electron-to-chromium@1.5.352: {}
|
||||
|
||||
emoji-regex@10.6.0: {}
|
||||
|
||||
emoji-regex@8.0.0: {}
|
||||
|
||||
emoji-regex@9.2.2: {}
|
||||
|
||||
encoding-japanese@2.2.0: {}
|
||||
@@ -7834,6 +7949,11 @@ snapshots:
|
||||
dependencies:
|
||||
is-callable: 1.2.7
|
||||
|
||||
foreground-child@3.3.1:
|
||||
dependencies:
|
||||
cross-spawn: 7.0.6
|
||||
signal-exit: 4.1.0
|
||||
|
||||
fraction.js@5.3.4: {}
|
||||
|
||||
fsevents@2.3.2:
|
||||
@@ -7911,6 +8031,15 @@ snapshots:
|
||||
dependencies:
|
||||
is-glob: 4.0.3
|
||||
|
||||
glob@10.5.0:
|
||||
dependencies:
|
||||
foreground-child: 3.3.1
|
||||
jackspeak: 3.4.3
|
||||
minimatch: 9.0.9
|
||||
minipass: 7.1.3
|
||||
package-json-from-dist: 1.0.1
|
||||
path-scurry: 1.11.1
|
||||
|
||||
globals@14.0.0: {}
|
||||
|
||||
globalthis@1.0.4:
|
||||
@@ -7920,6 +8049,8 @@ snapshots:
|
||||
|
||||
gopd@1.2.0: {}
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
has-bigints@1.1.0: {}
|
||||
|
||||
has-flag@4.0.0: {}
|
||||
@@ -8088,6 +8219,8 @@ snapshots:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
|
||||
is-fullwidth-code-point@3.0.0: {}
|
||||
|
||||
is-fullwidth-code-point@5.1.0:
|
||||
dependencies:
|
||||
get-east-asian-width: 1.6.0
|
||||
@@ -8130,7 +8263,7 @@ snapshots:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
|
||||
is-stream@4.0.1: {}
|
||||
is-stream@2.0.1: {}
|
||||
|
||||
is-string@1.1.1:
|
||||
dependencies:
|
||||
@@ -8195,6 +8328,12 @@ snapshots:
|
||||
has-symbols: 1.1.0
|
||||
set-function-name: 2.0.2
|
||||
|
||||
jackspeak@3.4.3:
|
||||
dependencies:
|
||||
'@isaacs/cliui': 8.0.2
|
||||
optionalDependencies:
|
||||
'@pkgjs/parseargs': 0.11.0
|
||||
|
||||
jiti@1.21.7: {}
|
||||
|
||||
jose@6.2.3: {}
|
||||
@@ -8383,6 +8522,8 @@ snapshots:
|
||||
dependencies:
|
||||
js-tokens: 4.0.0
|
||||
|
||||
lru-cache@10.4.3: {}
|
||||
|
||||
lucide-react@1.14.0(react@19.2.6):
|
||||
dependencies:
|
||||
react: 19.2.6
|
||||
@@ -8446,6 +8587,14 @@ snapshots:
|
||||
dependencies:
|
||||
brace-expansion: 1.1.14
|
||||
|
||||
minimatch@5.1.9:
|
||||
dependencies:
|
||||
brace-expansion: 2.1.0
|
||||
|
||||
minimatch@9.0.9:
|
||||
dependencies:
|
||||
brace-expansion: 2.1.0
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
minio@8.0.7:
|
||||
@@ -8464,6 +8613,8 @@ snapshots:
|
||||
through2: 4.0.2
|
||||
xml2js: 0.6.2
|
||||
|
||||
minipass@7.1.3: {}
|
||||
|
||||
mongodb-connection-string-url@7.0.1:
|
||||
dependencies:
|
||||
'@types/whatwg-url': 13.0.0
|
||||
@@ -8673,6 +8824,8 @@ snapshots:
|
||||
dependencies:
|
||||
p-limit: 3.1.0
|
||||
|
||||
package-json-from-dist@1.0.1: {}
|
||||
|
||||
package-manager-detector@1.6.0: {}
|
||||
|
||||
pako@0.2.9: {}
|
||||
@@ -8698,6 +8851,11 @@ snapshots:
|
||||
|
||||
path-parse@1.0.7: {}
|
||||
|
||||
path-scurry@1.11.1:
|
||||
dependencies:
|
||||
lru-cache: 10.4.3
|
||||
minipass: 7.1.3
|
||||
|
||||
pathe@2.0.3: {}
|
||||
|
||||
pdf-lib@1.17.1:
|
||||
@@ -8961,9 +9119,9 @@ snapshots:
|
||||
process: 0.11.10
|
||||
string_decoder: 1.3.0
|
||||
|
||||
readdir-glob@3.0.0:
|
||||
readdir-glob@1.1.3:
|
||||
dependencies:
|
||||
minimatch: 10.2.5
|
||||
minimatch: 5.1.9
|
||||
|
||||
readdirp@3.6.0:
|
||||
dependencies:
|
||||
@@ -9350,6 +9508,18 @@ snapshots:
|
||||
|
||||
string-argv@0.3.2: {}
|
||||
|
||||
string-width@4.2.3:
|
||||
dependencies:
|
||||
emoji-regex: 8.0.0
|
||||
is-fullwidth-code-point: 3.0.0
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
string-width@5.1.2:
|
||||
dependencies:
|
||||
eastasianwidth: 0.2.0
|
||||
emoji-regex: 9.2.2
|
||||
strip-ansi: 7.2.0
|
||||
|
||||
string-width@7.2.0:
|
||||
dependencies:
|
||||
emoji-regex: 10.6.0
|
||||
@@ -9419,6 +9589,10 @@ snapshots:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
strip-ansi@6.0.1:
|
||||
dependencies:
|
||||
ansi-regex: 5.0.1
|
||||
|
||||
strip-ansi@7.2.0:
|
||||
dependencies:
|
||||
ansi-regex: 6.2.2
|
||||
@@ -9868,6 +10042,18 @@ snapshots:
|
||||
string-width: 8.2.1
|
||||
strip-ansi: 7.2.0
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
wrap-ansi@8.1.0:
|
||||
dependencies:
|
||||
ansi-styles: 6.2.3
|
||||
string-width: 5.1.2
|
||||
strip-ansi: 7.2.0
|
||||
|
||||
wrap-ansi@9.0.2:
|
||||
dependencies:
|
||||
ansi-styles: 6.2.3
|
||||
@@ -9896,10 +10082,10 @@ snapshots:
|
||||
|
||||
yoctocolors@2.1.2: {}
|
||||
|
||||
zip-stream@7.0.5:
|
||||
zip-stream@6.0.1:
|
||||
dependencies:
|
||||
compress-commons: 7.0.1
|
||||
normalize-path: 3.0.0
|
||||
archiver-utils: 5.0.2
|
||||
compress-commons: 6.0.2
|
||||
readable-stream: 4.7.0
|
||||
|
||||
zlibjs@0.3.1: {}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { AlertsPageShell } from '@/components/alerts/alerts-page-shell';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function AlertsPage() {
|
||||
return <AlertsPageShell />;
|
||||
// Legacy /alerts route — merged into /inbox in 2026-05-11. The hash
|
||||
// scrolls + expands the Alerts section on the merged page, so old
|
||||
// bookmarks land in the right spot.
|
||||
export default async function AlertsRedirect({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ portSlug: string }>;
|
||||
}) {
|
||||
const { portSlug } = await params;
|
||||
redirect(`/${portSlug}/inbox#alerts`);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { ExpenseFormDialog } from '@/components/expenses/expense-form-dialog';
|
||||
import { ExpenseCard } from '@/components/expenses/expense-card';
|
||||
import { expenseFilterDefinitions } from '@/components/expenses/expense-filters';
|
||||
import { getExpenseColumns, type ExpenseRow } from '@/components/expenses/expense-columns';
|
||||
import { useCreateFromUrl } from '@/hooks/use-create-from-url';
|
||||
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
@@ -33,6 +34,7 @@ export default function ExpensesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
useCreateFromUrl(() => setCreateOpen(true));
|
||||
const [editExpense, setEditExpense] = useState<ExpenseRow | null>(null);
|
||||
const [archiveExpense, setArchiveExpense] = useState<ExpenseRow | null>(null);
|
||||
|
||||
|
||||
5
src/app/(dashboard)/[portSlug]/inbox/page.tsx
Normal file
5
src/app/(dashboard)/[portSlug]/inbox/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { InboxPageShell } from '@/components/inbox/inbox-page-shell';
|
||||
|
||||
export default function InboxPage() {
|
||||
return <InboxPageShell />;
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
import { ReminderList } from '@/components/reminders/reminder-list';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function RemindersPage() {
|
||||
return <ReminderList />;
|
||||
// Legacy /reminders route — merged into /inbox in 2026-05-11. The hash
|
||||
// scrolls + expands the Reminders section on the merged page.
|
||||
export default async function RemindersRedirect({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ portSlug: string }>;
|
||||
}) {
|
||||
const { portSlug } = await params;
|
||||
redirect(`/${portSlug}/inbox#reminders`);
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
||||
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}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
11
src/app/api/v1/dashboard/berth-status/route.ts
Normal file
11
src/app/api/v1/dashboard/berth-status/route.ts
Normal 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 });
|
||||
}),
|
||||
);
|
||||
11
src/app/api/v1/dashboard/hot-deals/route.ts
Normal file
11
src/app/api/v1/dashboard/hot-deals/route.ts
Normal 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 });
|
||||
}),
|
||||
);
|
||||
11
src/app/api/v1/dashboard/source-conversion/route.ts
Normal file
11
src/app/api/v1/dashboard/source-conversion/route.ts
Normal 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 });
|
||||
}),
|
||||
);
|
||||
@@ -6,14 +6,21 @@ import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { db } from '@/lib/db';
|
||||
import { userProfiles, type UserPreferences } from '@/lib/db/schema/users';
|
||||
import { errorResponse, NotFoundError } from '@/lib/errors';
|
||||
import { getPortReminderConfig } from '@/lib/services/port-config';
|
||||
import { updateUserPreferencesSchema } from '@/lib/validators/user-preferences';
|
||||
|
||||
export const GET = withAuth(async (_req, ctx) => {
|
||||
try {
|
||||
const profile = await db.query.userProfiles.findFirst({
|
||||
where: eq(userProfiles.userId, ctx.userId),
|
||||
const [profile, portReminders] = await Promise.all([
|
||||
db.query.userProfiles.findFirst({ where: eq(userProfiles.userId, ctx.userId) }),
|
||||
ctx.portId ? getPortReminderConfig(ctx.portId) : Promise.resolve(null),
|
||||
]);
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
...(profile?.preferences ?? {}),
|
||||
portReminderDigestEnabled: portReminders?.digestEnabled ?? false,
|
||||
},
|
||||
});
|
||||
return NextResponse.json({ data: profile?.preferences ?? {} });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
--secondary-foreground: 0 0% 100%;
|
||||
--muted: 210 11% 96%; /* #f1f3f5 */
|
||||
--muted-foreground: 228 10% 49%; /* #71768a */
|
||||
--accent: 190 18% 60%; /* #83aab1 */
|
||||
--accent-foreground: 0 0% 100%;
|
||||
--accent: 213 60% 95%; /* #eef3fb — soft brand-blue tint for hover/focus */
|
||||
--accent-foreground: 224 39% 19%; /* dark navy text for contrast on light bg */
|
||||
--destructive: 0 65% 51%; /* #d32f2f */
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 227 10% 82%; /* #cdcfd6 */
|
||||
@@ -58,8 +58,8 @@
|
||||
--secondary-foreground: 227 10% 82%;
|
||||
--muted: 224 39% 18%;
|
||||
--muted-foreground: 228 10% 49%;
|
||||
--accent: 190 18% 50%;
|
||||
--accent-foreground: 0 0% 100%;
|
||||
--accent: 224 39% 24%; /* subtle elevation above card for hover/focus */
|
||||
--accent-foreground: 227 10% 91%; /* light text on dark accent */
|
||||
--destructive: 0 72% 63%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 224 35% 28%;
|
||||
@@ -91,6 +91,11 @@
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground font-sans antialiased;
|
||||
/* Suppress iOS Safari's default black tap-highlight overlay so our
|
||||
* explicit `active:bg-accent` styles are the only press effect.
|
||||
* Without this, every tap on a mobile button/link flashes a muddy
|
||||
* dark rectangle on top of whatever active style we set. */
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* Wave watermark - subtle background texture for auth pages */
|
||||
@@ -208,3 +213,59 @@ div.recharts-responsive-container:focus-visible,
|
||||
--tw-ring-color: transparent !important;
|
||||
--tw-ring-offset-color: transparent !important;
|
||||
}
|
||||
|
||||
/*
|
||||
* Vaul drawer (bottom-direction) animation timing override.
|
||||
*
|
||||
* Vaul's defaults feel slightly snappy when the drawer is full-screen
|
||||
* (mobile search overlay) — the snap-on / snap-off reads as janky at
|
||||
* scale. We slow it down and use a softer easing curve (ease-out-quint)
|
||||
* which decelerates smoothly without the elastic kick.
|
||||
*
|
||||
* Scoped to mobile drawers via the data-vaul-drawer-direction attr so
|
||||
* the smaller MoreSheet drawer keeps its punchier default.
|
||||
*
|
||||
* The overlay's opacity transition is matched to the same duration so
|
||||
* the backdrop and drawer stay in sync.
|
||||
*/
|
||||
[data-vaul-drawer][data-vaul-drawer-direction='bottom'] {
|
||||
transition: transform 480ms cubic-bezier(0.22, 1, 0.36, 1) !important;
|
||||
}
|
||||
[data-vaul-drawer][data-vaul-drawer-direction='bottom'][data-state='closed'] {
|
||||
transition: transform 380ms cubic-bezier(0.4, 0, 0.2, 1) !important;
|
||||
}
|
||||
[data-vaul-overlay] {
|
||||
transition: opacity 480ms cubic-bezier(0.22, 1, 0.36, 1) !important;
|
||||
}
|
||||
[data-vaul-overlay][data-state='closed'] {
|
||||
transition: opacity 380ms cubic-bezier(0.4, 0, 0.2, 1) !important;
|
||||
}
|
||||
|
||||
/*
|
||||
* GPU compositing hints for Vaul drawers.
|
||||
*
|
||||
* `will-change: transform` tells the browser to promote the drawer to
|
||||
* its own composite layer ahead of the animation, so the swipe-drag
|
||||
* transforms run on the GPU instead of triggering re-paints. Without
|
||||
* this, Safari sometimes defers layer creation until the first frame
|
||||
* of the drag, producing the visible "jump" reps were seeing when
|
||||
* flicking the drawer down to close.
|
||||
*
|
||||
* `backface-visibility: hidden` keeps the layer flat and prevents
|
||||
* sub-pixel jitter during the spring-physics close animation.
|
||||
*
|
||||
* `contain: layout style paint` isolates the drawer's render tree
|
||||
* from the rest of the document — repaints inside the drawer (e.g.
|
||||
* focus-state changes on a button) don't invalidate the parent.
|
||||
*/
|
||||
[data-vaul-drawer] {
|
||||
will-change: transform;
|
||||
backface-visibility: hidden;
|
||||
contain: layout style paint;
|
||||
-webkit-transform: translate3d(0, 0, 0);
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
[data-vaul-overlay] {
|
||||
will-change: opacity;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { SUPPORTED_CURRENCIES } from '@/lib/utils/currency';
|
||||
|
||||
interface Setting {
|
||||
key: string;
|
||||
@@ -218,6 +219,15 @@ const KNOWN_SETTINGS: Array<{
|
||||
type: 'boolean',
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
key: 'berths_default_currency',
|
||||
label: 'Berths — default currency',
|
||||
description:
|
||||
'Currency applied to newly-created berths when none is specified on the form. Existing berths keep their per-row currency. Defaults to USD.',
|
||||
type: 'select',
|
||||
defaultValue: 'USD',
|
||||
options: SUPPORTED_CURRENCIES.map((c) => ({ value: c.code, label: `${c.code} — ${c.label}` })),
|
||||
},
|
||||
];
|
||||
|
||||
export function SettingsManager() {
|
||||
|
||||
@@ -30,7 +30,7 @@ export function AlertRail() {
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h2 className="text-sm font-semibold tracking-tight">Alerts</h2>
|
||||
<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"
|
||||
>
|
||||
View all
|
||||
@@ -53,7 +53,7 @@ export function AlertRail() {
|
||||
))}
|
||||
{overflow > 0 ? (
|
||||
<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"
|
||||
>
|
||||
+{overflow} more - view all
|
||||
|
||||
@@ -10,7 +10,19 @@ import { AlertCard, AlertCardEmpty } from './alert-card';
|
||||
import { useAlertCount, useAlertList, useAlertRealtime } from './use-alerts';
|
||||
import type { AlertStatus } from './types';
|
||||
|
||||
export function AlertsPageShell() {
|
||||
/**
|
||||
* `embedded` mode drops the PageHeader and outer spacing so the shell
|
||||
* can render as a section inside the merged Inbox page without
|
||||
* duplicating chrome. Standalone /alerts route still uses the default
|
||||
* (non-embedded) mode via the redirect — actually, /alerts now redirects
|
||||
* to /inbox#alerts, so non-embedded mode is currently unused but kept
|
||||
* for flexibility.
|
||||
*/
|
||||
interface AlertsPageShellProps {
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
export function AlertsPageShell({ embedded = false }: AlertsPageShellProps = {}) {
|
||||
const [tab, setTab] = useState<AlertStatus>('open');
|
||||
const { data: count } = useAlertCount();
|
||||
const { data, isLoading } = useAlertList(tab);
|
||||
@@ -20,19 +32,21 @@ export function AlertsPageShell() {
|
||||
const alerts = data?.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Alerts"
|
||||
eyebrow="Operational"
|
||||
description="Rules-based signals about pipeline, agreements, expenses, and access"
|
||||
kpiLine={
|
||||
<span>
|
||||
<ShieldAlert className="mr-1 inline h-3 w-3" aria-hidden />
|
||||
{total} active
|
||||
</span>
|
||||
}
|
||||
variant="gradient"
|
||||
/>
|
||||
<div className={embedded ? 'space-y-3' : 'space-y-6'}>
|
||||
{!embedded ? (
|
||||
<PageHeader
|
||||
title="Alerts"
|
||||
eyebrow="Operational"
|
||||
description="Rules-based signals about pipeline, agreements, expenses, and access"
|
||||
kpiLine={
|
||||
<span>
|
||||
<ShieldAlert className="mr-1 inline h-3 w-3" aria-hidden />
|
||||
{total} active
|
||||
</span>
|
||||
}
|
||||
variant="gradient"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as AlertStatus)}>
|
||||
<TabsList>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { Activity, Anchor, MapPin, MoreHorizontal, Pencil } from 'lucide-react';
|
||||
import { Activity, MoreHorizontal, Pencil } from 'lucide-react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -45,18 +45,51 @@ export function BerthCard({ berth }: BerthCardProps) {
|
||||
// already conveyed by the pill below, so the stripe is dock-keyed.
|
||||
const accentClass = mooringLetterDot(berth.mooringNumber) ?? 'bg-slate-300';
|
||||
|
||||
// Dimensions string
|
||||
let dimText: string | null = null;
|
||||
if (berth.lengthM || berth.widthM) {
|
||||
const l = berth.lengthM ?? '?';
|
||||
const w = berth.widthM ?? '?';
|
||||
dimText = `${l}m × ${w}m`;
|
||||
// Dimensions string — Length × Width × Draft (each segment is optional).
|
||||
// The avatar already conveys the mooring number, so this becomes the
|
||||
// primary "what is this berth" line.
|
||||
const dimParts: string[] = [];
|
||||
if (berth.lengthM) dimParts.push(`${berth.lengthM}m`);
|
||||
if (berth.widthM) dimParts.push(`${berth.widthM}m`);
|
||||
if (berth.draftM) dimParts.push(`${berth.draftM}m draft`);
|
||||
const dimText = dimParts.length > 0 ? dimParts.join(' × ') : null;
|
||||
|
||||
// Recommended boat size — the most rep-actionable signal in a glance
|
||||
// ("can my client's yacht park here?"). Tenure was previously here but
|
||||
// dropped: tenure is set per EOI/contract, not per berth, so showing
|
||||
// it as a berth property was misleading.
|
||||
let boatCapacityText: string | null = null;
|
||||
if (berth.nominalBoatSizeM) {
|
||||
boatCapacityText = `Fits up to ${berth.nominalBoatSizeM}m`;
|
||||
} else if (berth.nominalBoatSize) {
|
||||
boatCapacityText = `Fits up to ${berth.nominalBoatSize}ft`;
|
||||
}
|
||||
|
||||
// Water depth — operational; matters for deep-keel yachts.
|
||||
let waterDepthText: string | null = null;
|
||||
if (berth.waterDepthM) {
|
||||
const prefix = berth.waterDepthIsMinimum ? '≥ ' : '';
|
||||
waterDepthText = `${prefix}${berth.waterDepthM}m deep`;
|
||||
}
|
||||
|
||||
// Power label: combine capacity + voltage when both present.
|
||||
let powerText: string | null = null;
|
||||
if (berth.powerCapacity && berth.voltage) {
|
||||
powerText = `${berth.powerCapacity}A / ${berth.voltage}V`;
|
||||
} else if (berth.powerCapacity) {
|
||||
powerText = `${berth.powerCapacity}A`;
|
||||
} else if (berth.voltage) {
|
||||
powerText = `${berth.voltage}V`;
|
||||
}
|
||||
|
||||
// Secondary meta: boat-capacity · water-depth · price · power. All
|
||||
// optional; order favours the highest-utility scan signals first.
|
||||
const metaParts: string[] = [];
|
||||
if (dimText) metaParts.push(dimText);
|
||||
if (boatCapacityText) metaParts.push(boatCapacityText);
|
||||
if (waterDepthText) metaParts.push(waterDepthText);
|
||||
if (berth.price)
|
||||
metaParts.push(formatCurrency(berth.price, berth.priceCurrency, { maxFractionDigits: 0 }));
|
||||
if (powerText) metaParts.push(powerText);
|
||||
|
||||
const tags = berth.tags ?? [];
|
||||
|
||||
@@ -101,26 +134,27 @@ export function BerthCard({ berth }: BerthCardProps) {
|
||||
</DropdownMenu>
|
||||
}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<ListCardAvatar icon={<Anchor className="h-5 w-5" />} />
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 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">
|
||||
{/* Title row + spacer for actions button */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="truncate text-base font-semibold tracking-tight text-foreground">
|
||||
{berth.mooringNumber}
|
||||
</h3>
|
||||
{/* Primary line: dimensions (L × W × Draft). The avatar
|
||||
already carries the area letter, so this slot becomes the
|
||||
"what fits here" answer. Falls back gracefully when
|
||||
dimensions aren't recorded yet. */}
|
||||
<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" />
|
||||
</div>
|
||||
|
||||
{/* Area subtitle */}
|
||||
{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 */}
|
||||
{/* Meta line: tenure · price · power. All optional. */}
|
||||
{metaParts.length > 0 ? (
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-xs text-muted-foreground">
|
||||
{metaParts.map((part, i) => (
|
||||
@@ -132,8 +166,8 @@ export function BerthCard({ berth }: BerthCardProps) {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Status pill */}
|
||||
<div className="mt-1.5">
|
||||
{/* Status pill + tags */}
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'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}
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
</ListCard>
|
||||
|
||||
@@ -222,8 +222,9 @@ function StatusChangeDialog({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Picking an interest auto-creates a primary berth link if one doesn't already
|
||||
exist, so the deal timeline + heat scorer attribute the change correctly.
|
||||
Link this status change to the prospect (interest) it relates to. The change will
|
||||
appear on that interest's timeline, and the berth gets attached to the prospect
|
||||
automatically if it wasn't already.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -31,7 +31,7 @@ export function BerthDetail({ berthId }: BerthDetailProps) {
|
||||
});
|
||||
|
||||
const { setChrome } = useMobileChrome();
|
||||
const titleForChrome: string | null = data?.mooringNumber ?? null;
|
||||
const titleForChrome: string | null = data?.mooringNumber ? `Berth ${data.mooringNumber}` : null;
|
||||
useEffect(() => {
|
||||
setChrome({ title: titleForChrome, showBackButton: true });
|
||||
return () => setChrome({ title: null, showBackButton: false });
|
||||
|
||||
@@ -192,7 +192,7 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
|
||||
|
||||
return (
|
||||
<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>
|
||||
<SheetTitle>Edit Berth {berth.mooringNumber}</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
import { TableSkeleton } from '@/components/shared/loading-skeleton';
|
||||
import { EmptyState } from '@/components/shared/empty-state';
|
||||
import { Bookmark } from 'lucide-react';
|
||||
import { PIPELINE_STAGES, stageLabel } from '@/lib/constants';
|
||||
import { PIPELINE_STAGES, stageLabel, formatSource } from '@/lib/constants';
|
||||
import type { InterestRow } from '@/components/interests/interest-columns';
|
||||
|
||||
interface BerthInterestsTabProps {
|
||||
@@ -46,13 +46,6 @@ const CATEGORY_LABELS: Record<string, string> = {
|
||||
general_interest: 'General Interest',
|
||||
};
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
website: 'Website',
|
||||
manual: 'Manual',
|
||||
referral: 'Referral',
|
||||
broker: 'Broker',
|
||||
};
|
||||
|
||||
interface ListResponse {
|
||||
data: InterestRow[];
|
||||
total: number;
|
||||
@@ -179,9 +172,7 @@ export function BerthInterestsTab({ berthId }: BerthInterestsTabProps) {
|
||||
<td className="px-3 py-2 text-muted-foreground">
|
||||
{i.leadCategory ? (CATEGORY_LABELS[i.leadCategory] ?? i.leadCategory) : '-'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">
|
||||
{i.source ? (SOURCE_LABELS[i.source] ?? i.source) : '-'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">{formatSource(i.source) ?? '-'}</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground">
|
||||
{new Date(i.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { FilterBar } from '@/components/shared/filter-bar';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { SavedViewsDropdown } from '@/components/shared/saved-views-dropdown';
|
||||
import { ColumnPicker } from '@/components/shared/column-picker';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { EmptyState } from '@/components/shared/empty-state';
|
||||
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
@@ -63,11 +64,27 @@ export function BerthList() {
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<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}
|
||||
onChange={setFilter}
|
||||
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">
|
||||
<SavedViewsDropdown
|
||||
entityType="berths"
|
||||
@@ -101,6 +118,11 @@ export function BerthList() {
|
||||
onRowClick={(row) => router.push(`/${params.portSlug}/berths/${row.id}`)}
|
||||
getRowClassName={(row) => mooringLetterTone(row.mooringNumber)}
|
||||
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
|
||||
icon={Anchor}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { type DetailTab } from '@/components/shared/detail-layout';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { EntityActivityFeed } from '@/components/shared/entity-activity-feed';
|
||||
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
||||
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { formatCurrency } from '@/lib/utils/currency';
|
||||
import {
|
||||
BERTH_ACCESS_OPTIONS,
|
||||
BERTH_BOLLARD_CAPACITIES,
|
||||
@@ -64,6 +68,40 @@ type BerthData = {
|
||||
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 }) {
|
||||
if (!value && value !== 0 && value !== false) return null;
|
||||
// Mobile-first: stack vertically with label on top so long values
|
||||
@@ -104,6 +142,7 @@ function useBerthPatch(berthId: string) {
|
||||
function EditableSpec({
|
||||
label,
|
||||
value,
|
||||
displayValue,
|
||||
field,
|
||||
patch,
|
||||
numeric = false,
|
||||
@@ -113,6 +152,9 @@ function EditableSpec({
|
||||
}: {
|
||||
label: string;
|
||||
value: string | null;
|
||||
/** Optional formatted version for display only (currency, percent,
|
||||
* unit-suffixed). The edit input still works against the raw `value`. */
|
||||
displayValue?: string | null;
|
||||
field: string;
|
||||
patch: ReturnType<typeof useBerthPatch>;
|
||||
numeric?: boolean;
|
||||
@@ -142,6 +184,7 @@ function EditableSpec({
|
||||
) : (
|
||||
<InlineEditableField
|
||||
value={value}
|
||||
displayValue={displayValue}
|
||||
onSave={async (next) => {
|
||||
if (numeric) {
|
||||
if (next === null || next.trim() === '') {
|
||||
@@ -170,30 +213,33 @@ function EditableSpec({
|
||||
);
|
||||
}
|
||||
|
||||
// Conversion factors between feet and meters. 0.3048 is the exact
|
||||
// definition (1 ft = 0.3048 m by international agreement).
|
||||
const FT_TO_M = 0.3048;
|
||||
const M_TO_FT = 1 / FT_TO_M;
|
||||
|
||||
function OverviewTab({ berth }: { berth: BerthData }) {
|
||||
const patch = useBerthPatch(berth.id);
|
||||
// Round to at most 2 decimals; trim trailing zeros so "5.00" -> "5".
|
||||
const fmt = (v: string | null, fractionDigits = 2): string | null => {
|
||||
if (v == null || v === '') return null;
|
||||
const n = Number(v);
|
||||
if (Number.isNaN(n)) return v;
|
||||
return n.toLocaleString('en-US', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: fractionDigits,
|
||||
});
|
||||
};
|
||||
// User-selected display unit for dimensions. Persisted in localStorage
|
||||
// so reps' preferred unit sticks across navigations + sessions.
|
||||
const [units, setUnits] = useState<'ft' | 'm'>('ft');
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem('berth-overview-units');
|
||||
if (stored === 'ft' || stored === 'm') setUnits(stored);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
localStorage.setItem('berth-overview-units', units);
|
||||
}, [units]);
|
||||
|
||||
// Read-only display helper for the metric column on dimensions —
|
||||
// mirrors the pre-edit "X ft / Y m" rendering for fields where only
|
||||
// the foot value is editable today.
|
||||
const formatNominalBoatSize = (ft: string | null, m: string | null): string | null => {
|
||||
const ftFmt = fmt(ft, 0);
|
||||
const mFmt = fmt(m);
|
||||
const parts: string[] = [];
|
||||
if (ftFmt) parts.push(`${ftFmt} ft`);
|
||||
if (mFmt) parts.push(`${mFmt} m`);
|
||||
return parts.length > 0 ? parts.join(' / ') : null;
|
||||
};
|
||||
const u = units;
|
||||
// For each dimension, pick the column matching the selected unit and
|
||||
// point linkedUnit at the opposite column so edits keep both in sync.
|
||||
const dim = (ftField: string, mField: string) =>
|
||||
units === 'ft'
|
||||
? { field: ftField, linkedUnit: { field: mField, multiplier: FT_TO_M } }
|
||||
: { field: mField, linkedUnit: { field: ftField, multiplier: M_TO_FT } };
|
||||
const dimValue = (ftValue: string | null, mValue: string | null) =>
|
||||
units === 'ft' ? ftValue : mValue;
|
||||
|
||||
return (
|
||||
<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">
|
||||
{/* Specifications */}
|
||||
<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>
|
||||
<UnitToggle value={units} onChange={setUnits} />
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 divide-y">
|
||||
<EditableSpec
|
||||
label="Length (ft)"
|
||||
value={berth.lengthFt}
|
||||
field="lengthFt"
|
||||
label={`Length (${u})`}
|
||||
value={dimValue(berth.lengthFt, berth.lengthM)}
|
||||
{...dim('lengthFt', 'lengthM')}
|
||||
patch={patch}
|
||||
numeric
|
||||
suffix="ft"
|
||||
linkedUnit={{ field: 'lengthM', multiplier: 0.3048 }}
|
||||
suffix={u}
|
||||
/>
|
||||
<EditableSpec
|
||||
label="Width (ft)"
|
||||
value={berth.widthFt}
|
||||
field="widthFt"
|
||||
label={`Width (${u})`}
|
||||
value={dimValue(berth.widthFt, berth.widthM)}
|
||||
{...dim('widthFt', 'widthM')}
|
||||
patch={patch}
|
||||
numeric
|
||||
suffix="ft"
|
||||
linkedUnit={{ field: 'widthM', multiplier: 0.3048 }}
|
||||
suffix={u}
|
||||
/>
|
||||
<EditableSpec
|
||||
label="Draft (ft)"
|
||||
value={berth.draftFt}
|
||||
field="draftFt"
|
||||
label={`Draft (${u})`}
|
||||
value={dimValue(berth.draftFt, berth.draftM)}
|
||||
{...dim('draftFt', 'draftM')}
|
||||
patch={patch}
|
||||
numeric
|
||||
suffix="ft"
|
||||
linkedUnit={{ field: 'draftM', multiplier: 0.3048 }}
|
||||
suffix={u}
|
||||
/>
|
||||
<EditableSpec
|
||||
label="Nominal Boat Size (ft)"
|
||||
value={berth.nominalBoatSize}
|
||||
field="nominalBoatSize"
|
||||
label={`Nominal Boat Size (${u})`}
|
||||
value={dimValue(berth.nominalBoatSize, berth.nominalBoatSizeM)}
|
||||
{...dim('nominalBoatSize', 'nominalBoatSizeM')}
|
||||
patch={patch}
|
||||
numeric
|
||||
suffix="ft"
|
||||
linkedUnit={{ field: 'nominalBoatSizeM', multiplier: 0.3048 }}
|
||||
/>
|
||||
<SpecRow
|
||||
label="Nominal Boat Size (m)"
|
||||
value={
|
||||
formatNominalBoatSize(berth.nominalBoatSize, berth.nominalBoatSizeM)?.split(
|
||||
' / ',
|
||||
)[1] ?? null
|
||||
}
|
||||
suffix={u}
|
||||
/>
|
||||
<EditableSpec
|
||||
label="Water Depth (ft)"
|
||||
value={berth.waterDepth}
|
||||
field="waterDepth"
|
||||
label={`Water Depth (${u})`}
|
||||
value={dimValue(berth.waterDepth, berth.waterDepthM)}
|
||||
{...dim('waterDepth', 'waterDepthM')}
|
||||
patch={patch}
|
||||
numeric
|
||||
suffix="ft"
|
||||
linkedUnit={{ field: 'waterDepthM', multiplier: 0.3048 }}
|
||||
suffix={u}
|
||||
/>
|
||||
<EditableSpec
|
||||
label="Mooring Type"
|
||||
@@ -371,6 +405,11 @@ function OverviewTab({ berth }: { berth: BerthData }) {
|
||||
<EditableSpec
|
||||
label={`Price (${berth.priceCurrency || 'USD'})`}
|
||||
value={berth.price}
|
||||
displayValue={
|
||||
berth.price
|
||||
? formatCurrency(berth.price, berth.priceCurrency, { maxFractionDigits: 0 })
|
||||
: null
|
||||
}
|
||||
field="price"
|
||||
patch={patch}
|
||||
numeric
|
||||
|
||||
@@ -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">
|
||||
Low-stakes defaults: release available/under-offer berths, keep sold ones, cancel
|
||||
reservations, leave invoices/Documenso envelopes alone. Yachts stay on the
|
||||
reservations, leave invoices/signing envelopes alone. Yachts stay on the
|
||||
archived client. To customise per-client, archive that client individually
|
||||
instead.
|
||||
</div>
|
||||
|
||||
@@ -17,16 +17,9 @@ import {
|
||||
deriveInitials,
|
||||
} from '@/components/shared/list-card';
|
||||
import { getCountryName } from '@/lib/i18n/countries';
|
||||
import { stageBadgeClass, stageLabel } from '@/lib/constants';
|
||||
import { stageBadgeClass, stageLabel, formatSource } from '@/lib/constants';
|
||||
import type { ClientRow } from './client-columns';
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
website: 'Website',
|
||||
manual: 'Manual',
|
||||
referral: 'Referral',
|
||||
broker: 'Broker',
|
||||
};
|
||||
|
||||
interface ClientCardProps {
|
||||
client: ClientRow;
|
||||
portSlug: string;
|
||||
@@ -38,7 +31,7 @@ export function ClientCard({ client, portSlug, onEdit, onArchive }: ClientCardPr
|
||||
// Card display: prefer email, fall back to phone.
|
||||
const primaryContactValue = client.primaryEmail ?? client.primaryPhone ?? null;
|
||||
const nationality = client.nationalityIso ? getCountryName(client.nationalityIso, 'en') : null;
|
||||
const sourceLabel = client.source ? (SOURCE_LABELS[client.source] ?? client.source) : null;
|
||||
const sourceLabel = formatSource(client.source);
|
||||
const tags = client.tags ?? [];
|
||||
|
||||
const meta = [nationality, sourceLabel].filter(Boolean) as string[];
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { getCountryName } from '@/lib/i18n/countries';
|
||||
import { stageDotClass, stageLabel } from '@/lib/constants';
|
||||
import { stageDotClass, stageLabel, formatSource } from '@/lib/constants';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ColumnPickerOption } from '@/components/shared/column-picker';
|
||||
|
||||
@@ -81,13 +81,6 @@ export const CLIENT_COLUMN_OPTIONS: ColumnPickerOption[] = [
|
||||
*/
|
||||
export const CLIENT_DEFAULT_HIDDEN: string[] = ['latestStage'];
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
website: 'Website',
|
||||
manual: 'Manual',
|
||||
referral: 'Referral',
|
||||
broker: 'Broker',
|
||||
};
|
||||
|
||||
interface GetColumnsOptions {
|
||||
portSlug: string;
|
||||
onEdit: (client: ClientRow) => void;
|
||||
@@ -191,10 +184,11 @@ export function getClientColumns({
|
||||
header: 'Source',
|
||||
cell: ({ getValue }) => {
|
||||
const source = getValue() as string | null;
|
||||
if (!source) return <span className="text-muted-foreground">-</span>;
|
||||
const label = formatSource(source);
|
||||
if (!label) return <span className="text-muted-foreground">-</span>;
|
||||
return (
|
||||
<Badge variant="outline" className="capitalize text-xs">
|
||||
{SOURCE_LABELS[source] ?? source}
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -131,7 +131,7 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
{!isArchived && client.clientPortalEnabled !== false ? (
|
||||
{!isArchived && client.clientPortalEnabled === true ? (
|
||||
<div className="hidden sm:inline-flex">
|
||||
<PortalInviteButton
|
||||
clientId={client.id}
|
||||
|
||||
@@ -23,7 +23,7 @@ export const clientFilterDefinitions: FilterDefinition[] = [
|
||||
key: 'nationality',
|
||||
label: 'Country',
|
||||
type: 'text',
|
||||
placeholder: 'Filter by nationality...',
|
||||
placeholder: 'Filter by country...',
|
||||
},
|
||||
{
|
||||
key: 'includeArchived',
|
||||
|
||||
@@ -26,6 +26,7 @@ import { PhoneInput } from '@/components/shared/phone-input';
|
||||
import { DedupSuggestionPanel } from '@/components/clients/dedup-suggestion-panel';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { createClientSchema, type CreateClientInput } from '@/lib/validators/clients';
|
||||
import { SOURCES } from '@/lib/constants';
|
||||
import type { CountryCode } from '@/lib/i18n/countries';
|
||||
import { primaryTimezoneFor } from '@/lib/i18n/timezones';
|
||||
|
||||
@@ -188,8 +189,8 @@ export function ClientForm({ open, onOpenChange, client, onUseExistingClient }:
|
||||
Basic Information
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="col-span-2 space-y-1">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2 space-y-1">
|
||||
<Label>Full Name *</Label>
|
||||
<Input {...register('fullName')} placeholder="John Smith" />
|
||||
{errors.fullName && (
|
||||
@@ -198,7 +199,7 @@ export function ClientForm({ open, onOpenChange, client, onUseExistingClient }:
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>Nationality</Label>
|
||||
<Label>Country</Label>
|
||||
<CountryCombobox
|
||||
value={watch('nationalityIso')}
|
||||
onChange={(iso) => setValue('nationalityIso', iso ?? undefined)}
|
||||
@@ -235,102 +236,107 @@ export function ClientForm({ open, onOpenChange, client, onUseExistingClient }:
|
||||
{fields.map((field, index) => (
|
||||
<div
|
||||
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">
|
||||
<Label className="text-xs">Channel</Label>
|
||||
<Select
|
||||
value={watch(`contacts.${index}.channel`)}
|
||||
onValueChange={(v) =>
|
||||
setValue(
|
||||
`contacts.${index}.channel`,
|
||||
v as 'email' | 'phone' | 'whatsapp' | 'other',
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="email">Email</SelectItem>
|
||||
<SelectItem value="phone">Phone</SelectItem>
|
||||
<SelectItem value="whatsapp">WhatsApp</SelectItem>
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-12 sm:items-end sm:gap-2">
|
||||
<div className="space-y-1 sm:col-span-3">
|
||||
<Label className="text-xs">Channel</Label>
|
||||
<Select
|
||||
value={watch(`contacts.${index}.channel`)}
|
||||
onValueChange={(v) =>
|
||||
setValue(
|
||||
`contacts.${index}.channel`,
|
||||
v as 'email' | 'phone' | 'whatsapp' | 'other',
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9 sm:h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="email">Email</SelectItem>
|
||||
<SelectItem value="phone">Phone</SelectItem>
|
||||
<SelectItem value="whatsapp">WhatsApp</SelectItem>
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="col-span-5 space-y-1">
|
||||
<Label className="text-xs">Value</Label>
|
||||
{(() => {
|
||||
const channel = watch(`contacts.${index}.channel`);
|
||||
if (channel === 'phone' || channel === 'whatsapp') {
|
||||
const e164 = watch(`contacts.${index}.valueE164`) ?? null;
|
||||
const country =
|
||||
(watch(`contacts.${index}.valueCountry`) as CountryCode | undefined) ??
|
||||
undefined;
|
||||
<div className="space-y-1 sm:col-span-5">
|
||||
<Label className="text-xs">Value</Label>
|
||||
{(() => {
|
||||
const channel = watch(`contacts.${index}.channel`);
|
||||
if (channel === 'phone' || channel === 'whatsapp') {
|
||||
const e164 = watch(`contacts.${index}.valueE164`) ?? null;
|
||||
const country =
|
||||
(watch(`contacts.${index}.valueCountry`) as CountryCode | undefined) ??
|
||||
undefined;
|
||||
return (
|
||||
<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 (
|
||||
<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`}
|
||||
<Input
|
||||
{...register(`contacts.${index}.value`)}
|
||||
className="h-9 sm:h-8"
|
||||
placeholder={channel === 'email' ? 'email@example.com' : 'value'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
{...register(`contacts.${index}.value`)}
|
||||
className="h-8"
|
||||
placeholder={channel === 'email' ? 'email@example.com' : 'value'}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 sm:col-span-4">
|
||||
<Label className="text-xs">
|
||||
{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 className="col-span-2 space-y-1">
|
||||
<Label className="text-xs">
|
||||
{watch(`contacts.${index}.channel`) === 'other' ? 'Specify' : 'Label'}
|
||||
</Label>
|
||||
<Input
|
||||
{...register(`contacts.${index}.label`)}
|
||||
className="h-8"
|
||||
placeholder={
|
||||
watch(`contacts.${index}.channel`) === 'other'
|
||||
? 'e.g. Telegram, Signal'
|
||||
: 'work'
|
||||
}
|
||||
/>
|
||||
</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">
|
||||
{/* Bottom strip: Primary toggle left, delete right. Sits on
|
||||
its own row on every breakpoint so neither control gets
|
||||
squashed by the field columns above. */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer select-none">
|
||||
<Checkbox
|
||||
checked={watch(`contacts.${index}.isPrimary`)}
|
||||
onCheckedChange={(v) => setValue(`contacts.${index}.isPrimary`, !!v)}
|
||||
/>
|
||||
<span className="font-medium">Primary contact</span>
|
||||
</label>
|
||||
{fields.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive"
|
||||
size="sm"
|
||||
className="h-8 text-destructive hover:text-destructive"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -346,7 +352,7 @@ export function ClientForm({ open, onOpenChange, client, onUseExistingClient }:
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Source & Preferences
|
||||
</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">
|
||||
<Label>Source</Label>
|
||||
<Select
|
||||
@@ -359,11 +365,11 @@ export function ClientForm({ open, onOpenChange, client, onUseExistingClient }:
|
||||
<SelectValue placeholder="Select source" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="website">Website</SelectItem>
|
||||
<SelectItem value="manual">Manual</SelectItem>
|
||||
<SelectItem value="referral">Referral</SelectItem>
|
||||
<SelectItem value="broker">Broker</SelectItem>
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
{SOURCES.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -394,7 +400,7 @@ export function ClientForm({ open, onOpenChange, client, onUseExistingClient }:
|
||||
data-testid="client-timezone"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1">
|
||||
<div className="sm:col-span-2 space-y-1">
|
||||
<Label>Source Details</Label>
|
||||
<Input {...register('sourceDetails')} placeholder="Referred by John Doe" />
|
||||
</div>
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
type ClientRow,
|
||||
} from '@/components/clients/client-columns';
|
||||
import { ColumnPicker } from '@/components/shared/column-picker';
|
||||
import { useCreateFromUrl } from '@/hooks/use-create-from-url';
|
||||
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { useTablePreferences } from '@/hooks/use-table-preferences';
|
||||
@@ -49,6 +50,7 @@ export function ClientList() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
useCreateFromUrl(() => setCreateOpen(true));
|
||||
const [editClient, setEditClient] = useState<ClientRow | null>(null);
|
||||
const [archiveClient, setArchiveClient] = useState<ClientRow | null>(null);
|
||||
const [tagDialog, setTagDialog] = useState<{ ids: string[]; mode: 'add' | 'remove' } | null>(
|
||||
@@ -141,17 +143,9 @@ export function ClientList() {
|
||||
title="Clients"
|
||||
description="Manage your client records"
|
||||
variant="gradient"
|
||||
actions={
|
||||
<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
|
||||
filters={clientFilterDefinitions}
|
||||
values={filters}
|
||||
@@ -171,6 +165,16 @@ export function ClientList() {
|
||||
onChange={setHidden}
|
||||
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>
|
||||
|
||||
<SaveViewDialog
|
||||
|
||||
@@ -20,6 +20,7 @@ import { ContactsEditor } from '@/components/clients/contacts-editor';
|
||||
import { AddressesEditor, type Address } from '@/components/shared/addresses-editor';
|
||||
import { EntityActivityFeed } from '@/components/shared/entity-activity-feed';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { SOURCES } from '@/lib/constants';
|
||||
|
||||
type ClientPatchField =
|
||||
| 'fullName'
|
||||
@@ -31,13 +32,7 @@ type ClientPatchField =
|
||||
| 'source'
|
||||
| 'sourceDetails';
|
||||
|
||||
const SOURCE_OPTIONS = [
|
||||
{ value: 'website', label: 'Website' },
|
||||
{ value: 'manual', label: 'Manual' },
|
||||
{ value: 'referral', label: 'Referral' },
|
||||
{ value: 'broker', label: 'Broker' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
];
|
||||
const SOURCE_OPTIONS = SOURCES.map((s) => ({ value: s.value, label: s.label }));
|
||||
|
||||
const CONTACT_METHOD_OPTIONS = [
|
||||
{ value: 'email', label: 'Email' },
|
||||
@@ -289,10 +284,10 @@ export function getClientTabs({ clientId, currentUserId, client }: ClientTabsOpt
|
||||
badge: client.noteCount,
|
||||
content: (
|
||||
<NotesList
|
||||
aggregate
|
||||
entityType="clients"
|
||||
entityId={clientId}
|
||||
currentUserId={currentUserId}
|
||||
aggregate
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -477,12 +477,12 @@ export function SmartArchiveDialog({ open, onOpenChange, clientId, clientName, o
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* In-flight Documenso envelopes */}
|
||||
{/* In-flight signing envelopes */}
|
||||
{dossier.documents.filter((d) => d.isInFlight).length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-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>
|
||||
</CardHeader>
|
||||
<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="void_documenso">Void in Documenso</option>
|
||||
<option value="void_documenso">Void the signing envelope</option>
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -42,6 +42,19 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
dissolved: 'Dissolved',
|
||||
};
|
||||
|
||||
export const COMPANY_COLUMN_OPTIONS = [
|
||||
{ id: 'name', label: 'Name', alwaysVisible: true },
|
||||
{ id: 'legalName', label: 'Legal Name' },
|
||||
{ id: 'taxId', label: 'Tax ID' },
|
||||
{ id: 'memberCount', label: 'Members' },
|
||||
{ id: 'yachtCount', label: 'Yachts' },
|
||||
{ id: 'status', label: 'Status' },
|
||||
{ id: 'actions', label: 'Actions', alwaysVisible: true },
|
||||
];
|
||||
|
||||
/** Hidden by default — keep the table dense; opt-in to longer columns. */
|
||||
export const COMPANY_DEFAULT_HIDDEN: string[] = ['legalName', 'taxId'];
|
||||
|
||||
interface GetCompanyColumnsOptions {
|
||||
portSlug: string;
|
||||
onEdit: (company: CompanyRow) => void;
|
||||
|
||||
@@ -15,6 +15,7 @@ import { TableSkeleton } from '@/components/shared/loading-skeleton';
|
||||
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
|
||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||
import { TagPicker } from '@/components/shared/tag-picker';
|
||||
import { ColumnPicker } from '@/components/shared/column-picker';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -26,9 +27,16 @@ import {
|
||||
import { CompanyCard } from '@/components/companies/company-card';
|
||||
import { CompanyForm } from '@/components/companies/company-form';
|
||||
import { companyFilterDefinitions } from '@/components/companies/company-filters';
|
||||
import { getCompanyColumns, type CompanyRow } from '@/components/companies/company-columns';
|
||||
import {
|
||||
getCompanyColumns,
|
||||
COMPANY_COLUMN_OPTIONS,
|
||||
COMPANY_DEFAULT_HIDDEN,
|
||||
type CompanyRow,
|
||||
} from '@/components/companies/company-columns';
|
||||
import { useCreateFromUrl } from '@/hooks/use-create-from-url';
|
||||
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { useTablePreferences } from '@/hooks/use-table-preferences';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
export function CompanyList() {
|
||||
@@ -37,6 +45,7 @@ export function CompanyList() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
useCreateFromUrl(() => setCreateOpen(true));
|
||||
const [editCompany, setEditCompany] = useState<CompanyRow | null>(null);
|
||||
const [archiveCompany, setArchiveCompany] = useState<CompanyRow | null>(null);
|
||||
const [tagDialog, setTagDialog] = useState<{ ids: string[]; mode: 'add' | 'remove' } | null>(
|
||||
@@ -102,6 +111,10 @@ export function CompanyList() {
|
||||
onArchive: (company) => setArchiveCompany(company),
|
||||
});
|
||||
|
||||
// Persisted column visibility — same pattern as ClientList / BerthList.
|
||||
const { hidden, setHidden } = useTablePreferences('companies', COMPANY_DEFAULT_HIDDEN);
|
||||
const columnVisibility = Object.fromEntries(hidden.map((id) => [id, false]));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
@@ -125,13 +138,16 @@ export function CompanyList() {
|
||||
onChange={setFilter}
|
||||
onClear={clearFilters}
|
||||
/>
|
||||
<SavedViewsDropdown
|
||||
entityType="companies"
|
||||
onApplyView={(savedFilters, _savedSort) => {
|
||||
clearFilters();
|
||||
Object.entries(savedFilters).forEach(([key, val]) => setFilter(key, val));
|
||||
}}
|
||||
/>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<SavedViewsDropdown
|
||||
entityType="companies"
|
||||
onApplyView={(savedFilters, _savedSort) => {
|
||||
clearFilters();
|
||||
Object.entries(savedFilters).forEach(([key, val]) => setFilter(key, val));
|
||||
}}
|
||||
/>
|
||||
<ColumnPicker columns={COMPANY_COLUMN_OPTIONS} hidden={hidden} onChange={setHidden} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
@@ -145,6 +161,7 @@ export function CompanyList() {
|
||||
) : (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
columnVisibility={columnVisibility}
|
||||
data={data}
|
||||
pagination={pagination}
|
||||
onPaginationChange={(p, ps) => {
|
||||
|
||||
@@ -56,7 +56,7 @@ export function CompanyPicker({
|
||||
})();
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover open={open} onOpenChange={setOpen} modal>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -147,12 +147,13 @@ function OverviewTab({ companyId, company }: { companyId: string; company: Compa
|
||||
</EditableRow>
|
||||
<EditableRow label="Incorporation Date">
|
||||
<InlineEditableField
|
||||
variant="date"
|
||||
// The API returns this as an ISO timestamp ("2019-03-14T00:00:00.000Z")
|
||||
// because Postgres `date` columns are serialized through JSON. Strip
|
||||
// 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}
|
||||
placeholder="YYYY-MM-DD"
|
||||
max={new Date().toISOString().slice(0, 10)}
|
||||
onSave={save('incorporationDate')}
|
||||
/>
|
||||
</EditableRow>
|
||||
@@ -226,10 +227,10 @@ export function getCompanyTabs({
|
||||
label: 'Notes',
|
||||
content: (
|
||||
<NotesList
|
||||
aggregate
|
||||
entityType="companies"
|
||||
entityId={companyId}
|
||||
currentUserId={currentUserId}
|
||||
aggregate
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
68
src/components/dashboard/active-deals-tile.tsx
Normal file
68
src/components/dashboard/active-deals-tile.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -14,11 +14,103 @@ interface ActivityItem {
|
||||
action: string;
|
||||
entityType: string;
|
||||
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;
|
||||
fieldChanged: string | null;
|
||||
oldValue: unknown;
|
||||
newValue: unknown;
|
||||
metadata: Record<string, unknown> | null;
|
||||
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'> = {
|
||||
create: 'default',
|
||||
update: 'secondary',
|
||||
@@ -63,27 +155,49 @@ function ActivityFeedInner() {
|
||||
</p>
|
||||
) : (
|
||||
<div className="max-h-80 overflow-y-auto space-y-3 pr-1">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-start gap-3 text-sm border-b border-border pb-3 last:border-0 last:pb-0"
|
||||
>
|
||||
<ActionBadge action={item.action} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-foreground">
|
||||
<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>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{formatDistanceToNow(new Date(item.createdAt), { addSuffix: true })}
|
||||
</p>
|
||||
{items.map((item) => {
|
||||
const diffLine = buildDiffLine(item);
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-start gap-3 text-sm border-b border-border pb-3 last:border-0 last:pb-0"
|
||||
>
|
||||
<ActionBadge action={item.action} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-foreground">
|
||||
{item.label ? (
|
||||
<>
|
||||
<span className="font-medium">{item.label}</span>
|
||||
<span className="ml-1.5 text-muted-foreground text-xs capitalize">
|
||||
{item.entityType}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
101
src/components/dashboard/berth-status-chart.tsx
Normal file
101
src/components/dashboard/berth-status-chart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -24,14 +24,19 @@ interface ChartCardProps {
|
||||
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 a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
@@ -44,31 +49,28 @@ async function exportContainerAsPng(container: HTMLElement, filename: string) {
|
||||
clone.setAttribute('height', String(height));
|
||||
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||
const xml = new XMLSerializer().serializeToString(clone);
|
||||
const svgBlob = new Blob([xml], { type: 'image/svg+xml;charset=utf-8' });
|
||||
const url = URL.createObjectURL(svgBlob);
|
||||
const svgDataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(xml)}`;
|
||||
const img = new Image();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
img.onload = () => resolve();
|
||||
img.onerror = () => reject(new Error('Failed to load chart for export'));
|
||||
img.src = url;
|
||||
img.src = svgDataUrl;
|
||||
});
|
||||
const canvas = document.createElement('canvas');
|
||||
const dpr = window.devicePixelRatio ?? 1;
|
||||
canvas.width = width * dpr;
|
||||
canvas.height = height * dpr;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
URL.revokeObjectURL(url);
|
||||
return;
|
||||
}
|
||||
if (!ctx) return;
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
URL.revokeObjectURL(url);
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) downloadBlob(blob, filename);
|
||||
}, 'image/png');
|
||||
const blob = await new Promise<Blob | null>((resolve) =>
|
||||
canvas.toBlob((b) => resolve(b), 'image/png'),
|
||||
);
|
||||
if (!blob) return;
|
||||
triggerBlobDownload(blob, filename);
|
||||
}
|
||||
|
||||
export function ChartCard({
|
||||
@@ -84,7 +86,10 @@ export function ChartCard({
|
||||
function onDownloadCsv() {
|
||||
const csv = toCsv?.();
|
||||
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() {
|
||||
|
||||
129
src/components/dashboard/customize-widgets-menu.tsx
Normal file
129
src/components/dashboard/customize-widgets-menu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -4,19 +4,13 @@ import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { usePortContext } from '@/providers/port-provider';
|
||||
import { useDashboardWidgets } from '@/hooks/use-dashboard-widgets';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { ActivityFeed } from './activity-feed';
|
||||
import { CustomizeWidgetsMenu } from './customize-widgets-menu';
|
||||
import { DateRangePicker } from './date-range-picker';
|
||||
import { PipelineFunnelChart } from './pipeline-funnel-chart';
|
||||
import { OccupancyTimelineChart } from './occupancy-timeline-chart';
|
||||
import { RevenueBreakdownChart } from './revenue-breakdown-chart';
|
||||
import { LeadSourceChart } from './lead-source-chart';
|
||||
import { MyRemindersRail } from './my-reminders-rail';
|
||||
import { WebsiteGlanceTile } from './website-glance-tile';
|
||||
import { WidgetErrorBoundary } from './widget-error-boundary';
|
||||
import { AlertRail } from '@/components/alerts/alert-rail';
|
||||
import type { DashboardWidget } from './widget-registry';
|
||||
import { isCustomRange, type DateRange } from '@/lib/analytics/range';
|
||||
|
||||
const PRESET_LABELS: Record<'today' | '7d' | '30d' | '90d', string> = {
|
||||
@@ -43,8 +37,10 @@ function rangeLabel(range: DateRange): string {
|
||||
|
||||
interface MeData {
|
||||
data?: {
|
||||
firstName?: string | null;
|
||||
displayName?: string | null;
|
||||
profile?: {
|
||||
firstName?: string | null;
|
||||
displayName?: string | null;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -58,8 +54,14 @@ function timeOfDayGreeting(): string {
|
||||
|
||||
export function DashboardShell() {
|
||||
const [range, setRange] = useState<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
|
||||
// useTablePreferences elsewhere — usually a cache hit, so no extra
|
||||
@@ -70,7 +72,7 @@ export function DashboardShell() {
|
||||
queryFn: ({ signal }) => apiFetch<MeData>('/api/v1/me', { signal }),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
const firstName = me.data?.data?.firstName?.trim();
|
||||
const firstName = me.data?.data?.profile?.firstName?.trim();
|
||||
// Time-aware greeting line, falls back to a generic "Welcome back" when
|
||||
// we don't know the user's first name yet (e.g. profile not filled out).
|
||||
const greeting = firstName ? `${timeOfDayGreeting()}, ${firstName}` : 'Welcome back';
|
||||
@@ -96,51 +98,103 @@ export function DashboardShell() {
|
||||
|
||||
return (
|
||||
<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
|
||||
title={greeting}
|
||||
eyebrow="Dashboard"
|
||||
description={`Live snapshot of ${portName} activity`}
|
||||
kpiLine={<span>{rangeLabel(range)}</span>}
|
||||
// The date-range subtitle only means something when at least
|
||||
// one widget is on the page to consume the range; if everything
|
||||
// is hidden it just reads as an orphaned line.
|
||||
kpiLine={visibleWidgets.length > 0 ? <span>{rangeLabel(range)}</span> : undefined}
|
||||
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
|
||||
MyRemindersRail (or any other child with `h-full`) to push later
|
||||
children out of the aside's box and into the rows below where
|
||||
ActivityFeed renders. */}
|
||||
<div className="grid gap-4 grid-cols-1 items-start xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<div className="grid gap-4 grid-cols-1 lg:grid-cols-2">
|
||||
<WidgetErrorBoundary>
|
||||
<PipelineFunnelChart range={range} />
|
||||
</WidgetErrorBoundary>
|
||||
<WidgetErrorBoundary>
|
||||
<OccupancyTimelineChart range={range} />
|
||||
</WidgetErrorBoundary>
|
||||
<WidgetErrorBoundary>
|
||||
<RevenueBreakdownChart range={range} />
|
||||
</WidgetErrorBoundary>
|
||||
<WidgetErrorBoundary>
|
||||
<LeadSourceChart range={range} />
|
||||
</WidgetErrorBoundary>
|
||||
children out of the aside's box. */}
|
||||
{/* Charts + rails. Layout adapts to which regions have content so
|
||||
we never leave a 320px stripe of dead space when only one side
|
||||
is populated:
|
||||
both → main 1fr column + 320px rail (the original layout)
|
||||
charts only → single full-width auto-fit chart grid
|
||||
rails only → rails widen into an auto-fit grid (no fixed 320)
|
||||
neither → nothing renders
|
||||
The chart grid uses `minmax(360px, 1fr)` so a lone chart fills
|
||||
the row; the rails-only grid uses a slightly tighter `280px`
|
||||
minimum so KPI tiles + rails fit 3-4 across on a wide viewport
|
||||
instead of stretching to 600px+ each. */}
|
||||
{charts.length > 0 && rails.length > 0 ? (
|
||||
<div className="grid gap-4 grid-cols-1 items-start xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<div className="grid gap-4 grid-cols-1 lg:grid-cols-[repeat(auto-fit,minmax(360px,1fr))]">
|
||||
{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>
|
||||
<aside className="min-w-0 space-y-4">
|
||||
{/* Soft-fail tile linking to /website-analytics. Hidden if Umami
|
||||
isn't configured for this port. */}
|
||||
<WidgetErrorBoundary>
|
||||
<WebsiteGlanceTile />
|
||||
</WidgetErrorBoundary>
|
||||
<WidgetErrorBoundary>
|
||||
<MyRemindersRail />
|
||||
</WidgetErrorBoundary>
|
||||
<WidgetErrorBoundary>
|
||||
<AlertRail />
|
||||
</WidgetErrorBoundary>
|
||||
</aside>
|
||||
</div>
|
||||
) : charts.length > 0 ? (
|
||||
<div className="grid gap-4 grid-cols-1 lg:grid-cols-[repeat(auto-fit,minmax(360px,1fr))]">
|
||||
{charts.map((w) => (
|
||||
<WidgetCell key={w.id} widget={w} range={range} />
|
||||
))}
|
||||
</div>
|
||||
) : rails.length > 0 ? (
|
||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-[repeat(auto-fit,minmax(280px,1fr))]">
|
||||
{rails.map((w) => (
|
||||
<WidgetCell key={w.id} widget={w} range={range} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ActivityFeed />
|
||||
{feed.map((w) => (
|
||||
<WidgetCell key={w.id} widget={w} range={range} />
|
||||
))}
|
||||
|
||||
{visibleWidgets.length === 0 ? <EmptyDashboardHint /> : null}
|
||||
</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>;
|
||||
}
|
||||
|
||||
@@ -126,7 +126,15 @@ export function DateRangePicker({ value, onChange, className }: DateRangePickerP
|
||||
{isCustom ? formatCustom(value) : 'Custom'}
|
||||
</Button>
|
||||
</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="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Custom range
|
||||
@@ -141,7 +149,7 @@ export function DateRangePicker({ value, onChange, className }: DateRangePickerP
|
||||
empty result, and not understand why. */
|
||||
max={draftTo && draftTo < today ? draftTo : today}
|
||||
onChange={(e) => setDraftFrom(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm outline-none focus:border-brand/60 focus:ring-2 focus:ring-brand/15"
|
||||
className="w-auto max-w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm outline-none focus:border-brand/60 focus:ring-2 focus:ring-brand/15"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-xs">
|
||||
@@ -152,7 +160,7 @@ export function DateRangePicker({ value, onChange, className }: DateRangePickerP
|
||||
min={draftFrom || undefined}
|
||||
max={today}
|
||||
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>
|
||||
<div className="flex items-center justify-end gap-2 pt-1">
|
||||
|
||||
108
src/components/dashboard/hot-deals-card.tsx
Normal file
108
src/components/dashboard/hot-deals-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import { EmptyState } from '@/components/shared/empty-state';
|
||||
import { ChartCard } from './chart-card';
|
||||
import { useLeadSource } from './use-analytics';
|
||||
import type { DateRange } from '@/lib/services/analytics.service';
|
||||
import { rangeToSlug } from '@/lib/analytics/range';
|
||||
import { SOURCE_LABELS as CANONICAL_SOURCE_LABELS } from '@/lib/constants';
|
||||
|
||||
interface Props {
|
||||
range: DateRange;
|
||||
@@ -20,10 +22,11 @@ const COLORS = [
|
||||
'hsl(var(--chart-5))',
|
||||
];
|
||||
|
||||
// Extend the canonical source labels with the analytics-specific buckets the
|
||||
// API returns (`unspecified` for null sources, legacy `social`). Renames to the
|
||||
// canonical set in /lib/constants stay in sync via the spread.
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
website: 'Website',
|
||||
referral: 'Referral',
|
||||
manual: 'Manual',
|
||||
...CANONICAL_SOURCE_LABELS,
|
||||
social: 'Social',
|
||||
unspecified: 'Unspecified',
|
||||
};
|
||||
@@ -48,7 +51,7 @@ export function LeadSourceChart({ range }: Props) {
|
||||
<ChartCard
|
||||
title="Lead Source Attribution"
|
||||
description="Where new interests came from"
|
||||
exportFilename={`lead-source-${range}`}
|
||||
exportFilename={`lead-source-${rangeToSlug(range)}`}
|
||||
toCsv={toCsv}
|
||||
>
|
||||
{isLoading ? (
|
||||
|
||||
@@ -87,7 +87,7 @@ export function MyRemindersRail() {
|
||||
) : null}
|
||||
</div>
|
||||
<Link
|
||||
href={`/${portSlug}/reminders` as never}
|
||||
href={`/${portSlug}/inbox#reminders` as never}
|
||||
className="text-xs font-medium text-primary hover:underline"
|
||||
>
|
||||
View all
|
||||
|
||||
@@ -15,6 +15,7 @@ import { EmptyState } from '@/components/shared/empty-state';
|
||||
import { ChartCard } from './chart-card';
|
||||
import { useOccupancy } from './use-analytics';
|
||||
import type { DateRange } from '@/lib/services/analytics.service';
|
||||
import { rangeToSlug } from '@/lib/analytics/range';
|
||||
|
||||
interface Props {
|
||||
range: DateRange;
|
||||
@@ -41,7 +42,7 @@ export function OccupancyTimelineChart({ range }: Props) {
|
||||
<ChartCard
|
||||
title="Occupancy Timeline"
|
||||
description="Daily berth occupancy across the range"
|
||||
exportFilename={`occupancy-timeline-${range}`}
|
||||
exportFilename={`occupancy-timeline-${rangeToSlug(range)}`}
|
||||
toCsv={toCsv}
|
||||
>
|
||||
{isLoading ? (
|
||||
|
||||
@@ -9,6 +9,7 @@ import { STAGE_SHORT_LABELS, safeStage, stageLabel } from '@/lib/constants';
|
||||
import { ChartCard } from './chart-card';
|
||||
import { useFunnel } from './use-analytics';
|
||||
import type { DateRange } from '@/lib/services/analytics.service';
|
||||
import { rangeToSlug } from '@/lib/analytics/range';
|
||||
|
||||
interface Props {
|
||||
range: DateRange;
|
||||
@@ -38,7 +39,7 @@ export function PipelineFunnelChart({ range }: Props) {
|
||||
<ChartCard
|
||||
title="Pipeline Funnel"
|
||||
description="Interests by stage with conversion rate vs. open"
|
||||
exportFilename={`pipeline-funnel-${range}`}
|
||||
exportFilename={`pipeline-funnel-${rangeToSlug(range)}`}
|
||||
toCsv={toCsv}
|
||||
>
|
||||
{isLoading ? (
|
||||
|
||||
54
src/components/dashboard/pipeline-value-tile.tsx
Normal file
54
src/components/dashboard/pipeline-value-tile.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { EmptyState } from '@/components/shared/empty-state';
|
||||
import { ChartCard } from './chart-card';
|
||||
import { useRevenue } from './use-analytics';
|
||||
import type { DateRange } from '@/lib/services/analytics.service';
|
||||
import { rangeToSlug } from '@/lib/analytics/range';
|
||||
import { formatCurrency } from '@/lib/utils/currency';
|
||||
|
||||
interface Props {
|
||||
@@ -42,7 +43,7 @@ export function RevenueBreakdownChart({ range }: Props) {
|
||||
<ChartCard
|
||||
title="Revenue Breakdown"
|
||||
description="Invoice totals grouped by status and currency"
|
||||
exportFilename={`revenue-breakdown-${range}`}
|
||||
exportFilename={`revenue-breakdown-${rangeToSlug(range)}`}
|
||||
toCsv={toCsv}
|
||||
>
|
||||
{isLoading ? (
|
||||
|
||||
91
src/components/dashboard/source-conversion-chart.tsx
Normal file
91
src/components/dashboard/source-conversion-chart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
198
src/components/dashboard/widget-registry.tsx
Normal file
198
src/components/dashboard/widget-registry.tsx
Normal 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]),
|
||||
);
|
||||
@@ -230,8 +230,8 @@ export function CreateDocumentWizard({ portSlug }: CreateDocumentWizardProps) {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="documenso-template">Documenso renders + signs</SelectItem>
|
||||
<SelectItem value="inapp">Render in CRM, sign via Documenso</SelectItem>
|
||||
<SelectItem value="documenso-template">Generated EOI — rendered + signed externally</SelectItem>
|
||||
<SelectItem value="inapp">Manual EOI — rendered in CRM, sent for e-signature</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -157,7 +157,7 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) {
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (!confirm('Cancel this document? This voids it in Documenso and cannot be undone.')) return;
|
||||
if (!confirm('Cancel this document? This voids the signing envelope and cannot be undone.')) return;
|
||||
setIsCancelling(true);
|
||||
try {
|
||||
await apiFetch(`/api/v1/documents/${documentId}/cancel`, { method: 'POST' });
|
||||
|
||||
@@ -70,7 +70,7 @@ export function DocumentTemplatePicker({
|
||||
})();
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover open={open} onOpenChange={setOpen} modal>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -6,10 +6,18 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||
import { ChevronDown, ChevronRight, FileText, Plus, Upload } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
|
||||
import { EmptyState } from '@/components/ui/empty-state';
|
||||
import { FileUploadZone } from '@/components/files/file-upload-zone';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
||||
@@ -219,6 +227,8 @@ function FlatFolderListing({ portSlug, folderId }: FlatFolderListingProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState<string | undefined>(undefined);
|
||||
const [expandedDocId, setExpandedDocId] = useState<string | null>(null);
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const queryParams = useMemo(() => {
|
||||
const params = new URLSearchParams();
|
||||
@@ -364,19 +374,48 @@ function FlatFolderListing({ portSlug, folderId }: FlatFolderListingProps) {
|
||||
<EmptyState
|
||||
icon={<FileText className="h-7 w-7" />}
|
||||
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={
|
||||
<Button asChild>
|
||||
<Link href={`/${portSlug}/documents/new`}>
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
New document
|
||||
</Link>
|
||||
</Button>
|
||||
<>
|
||||
<Button onClick={() => setUploadOpen(true)}>
|
||||
<Upload className="mr-1.5 h-4 w-4" />
|
||||
Upload file
|
||||
</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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -235,7 +235,7 @@ export function EoiGenerateDialog({
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={DOCUMENSO_TEMPLATE_VALUE}>
|
||||
Documenso Standard EOI (recommended)
|
||||
Standard EOI — sent for e-signature (recommended)
|
||||
</SelectItem>
|
||||
{inAppTemplates.map((t) => (
|
||||
<SelectItem key={t.id} value={t.id}>
|
||||
|
||||
@@ -77,83 +77,88 @@ export function FolderActionsMenu({ selectedFolderId, onAfterDelete }: FolderAct
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">Folder actions</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setName('');
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
<FolderPlus className="mr-2 h-4 w-4" />
|
||||
New folder {isFolderSelected ? 'inside this' : 'at root'}
|
||||
</DropdownMenuItem>
|
||||
{isFolderSelected ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<DropdownMenuItem
|
||||
disabled={isSystem}
|
||||
onClick={() => {
|
||||
if (isSystem) return;
|
||||
setName(currentName);
|
||||
setRenameOpen(true);
|
||||
}}
|
||||
>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{isSystem ? (
|
||||
<TooltipContent>System folders can't be renamed.</TooltipContent>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<ConfirmationDialog
|
||||
trigger={
|
||||
<DropdownMenuItem
|
||||
disabled={isSystem}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
title="Delete folder?"
|
||||
description="Subfolders and documents inside will move up to the parent. The folder itself is removed."
|
||||
confirmLabel="Delete folder"
|
||||
loading={deleteMutation.isPending}
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await deleteMutation.mutateAsync(selectedFolderId as string);
|
||||
toast.success('Folder deleted; contents moved to parent.');
|
||||
onAfterDelete?.();
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 justify-start"
|
||||
onClick={() => {
|
||||
setName('');
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
<FolderPlus className="mr-2 h-4 w-4" />
|
||||
New folder {isFolderSelected ? 'inside this' : 'at root'}
|
||||
</Button>
|
||||
{isFolderSelected ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">More folder actions</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<DropdownMenuItem
|
||||
disabled={isSystem}
|
||||
onClick={() => {
|
||||
if (isSystem) return;
|
||||
setName(currentName);
|
||||
setRenameOpen(true);
|
||||
}}
|
||||
>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{isSystem ? (
|
||||
<TooltipContent>System folders can't be renamed.</TooltipContent>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<ConfirmationDialog
|
||||
trigger={
|
||||
<DropdownMenuItem
|
||||
disabled={isSystem}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{isSystem ? (
|
||||
<TooltipContent>System folders can't be deleted.</TooltipContent>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
title="Delete folder?"
|
||||
description="Subfolders and documents inside will move up to the parent. The folder itself is removed."
|
||||
confirmLabel="Delete folder"
|
||||
loading={deleteMutation.isPending}
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await deleteMutation.mutateAsync(selectedFolderId as string);
|
||||
toast.success('Folder deleted; contents moved to parent.');
|
||||
onAfterDelete?.();
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{isSystem ? (
|
||||
<TooltipContent>System folders can't be deleted.</TooltipContent>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
|
||||
@@ -100,7 +100,7 @@ function TreeBody({
|
||||
onClick={() => onSelect(undefined)}
|
||||
/>
|
||||
<PseudoRow
|
||||
label="Root (no folder)"
|
||||
label="Root"
|
||||
icon={Folder}
|
||||
active={selectedFolderId === null}
|
||||
onClick={() => onSelect(null)}
|
||||
|
||||
@@ -83,7 +83,7 @@ export function NewDocumentMenu({
|
||||
<div className="flex flex-col">
|
||||
<span>Generate document for signing</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
EOI, contract, or custom — sent via Documenso
|
||||
EOI, contract, or custom — sent for e-signature
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -61,7 +61,7 @@ export function TripLabelCombobox({
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover open={open} onOpenChange={setOpen} modal>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id={id}
|
||||
|
||||
153
src/components/inbox/inbox-page-shell.tsx
Normal file
153
src/components/inbox/inbox-page-shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -76,7 +76,7 @@ export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSucc
|
||||
<DialogHeader>
|
||||
<DialogTitle>Upload externally-signed EOI</DialogTitle>
|
||||
<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
|
||||
Signed.
|
||||
</DialogDescription>
|
||||
|
||||
@@ -18,7 +18,12 @@ import {
|
||||
deriveInitials,
|
||||
} from '@/components/shared/list-card';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { stageBadgeClass, stageDotClass, stageLabel as toStageLabel } from '@/lib/constants';
|
||||
import {
|
||||
stageBadgeClass,
|
||||
stageDotClass,
|
||||
stageLabel as toStageLabel,
|
||||
formatSource,
|
||||
} from '@/lib/constants';
|
||||
import { computeUrgencyBadges } from '@/components/interests/urgency';
|
||||
import type { InterestRow } from './interest-columns';
|
||||
|
||||
@@ -28,13 +33,6 @@ const CATEGORY_LABELS: Record<string, string> = {
|
||||
hot_lead: 'Hot lead',
|
||||
};
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
website: 'Website',
|
||||
manual: 'Manual',
|
||||
referral: 'Referral',
|
||||
broker: 'Broker',
|
||||
};
|
||||
|
||||
interface InterestCardProps {
|
||||
interest: InterestRow;
|
||||
portSlug: string;
|
||||
@@ -48,7 +46,7 @@ export function InterestCard({ interest, portSlug, onEdit, onArchive }: Interest
|
||||
const accentClass = stageDotClass(interest.pipelineStage);
|
||||
const isHotLead = interest.leadCategory === 'hot_lead';
|
||||
const categoryLabel = interest.leadCategory ? CATEGORY_LABELS[interest.leadCategory] : null;
|
||||
const sourceLabel = interest.source ? (SOURCE_LABELS[interest.source] ?? interest.source) : null;
|
||||
const sourceLabel = formatSource(interest.source);
|
||||
const tags = interest.tags ?? [];
|
||||
const notesCount = interest.notesCount ?? 0;
|
||||
const urgencyBadges = computeUrgencyBadges(interest);
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -391,14 +392,47 @@ function ComposeDialog({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="cl-followup">Follow up by (optional — creates a reminder)</Label>
|
||||
<Input
|
||||
id="cl-followup"
|
||||
type="datetime-local"
|
||||
value={followUpAt}
|
||||
onChange={(e) => setFollowUpAt(e.target.value)}
|
||||
/>
|
||||
<div className="space-y-2 rounded-md border bg-muted/30 p-3">
|
||||
<label
|
||||
className="flex items-center gap-2 text-sm font-medium cursor-pointer select-none"
|
||||
htmlFor="cl-followup-toggle"
|
||||
>
|
||||
<Checkbox
|
||||
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>
|
||||
|
||||
|
||||
@@ -278,7 +278,7 @@ function ActiveContractCard({
|
||||
</div>
|
||||
) : signers.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
Documenso hasn't reported signers yet — check back in a moment.
|
||||
The signing service hasn't reported signers yet — check back in a moment.
|
||||
</p>
|
||||
) : (
|
||||
<SigningProgress documentId={doc.id} signers={signers} />
|
||||
@@ -341,7 +341,7 @@ function EmptyContractState({
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
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>
|
||||
<div className="mt-5 flex flex-wrap items-center justify-center gap-2">
|
||||
<Button onClick={onUploadForSigning} size="sm" className="gap-1.5">
|
||||
|
||||
@@ -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_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_other: { label: 'Lost - other', className: 'bg-rose-100 text-rose-700' },
|
||||
cancelled: { label: 'Cancelled', className: 'bg-slate-200 text-slate-700' },
|
||||
};
|
||||
|
||||
|
||||
@@ -267,7 +267,7 @@ function ActiveEoiCard({
|
||||
</div>
|
||||
) : signers.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
Documenso hasn't reported signers yet — check back in a moment.
|
||||
The signing service hasn't reported signers yet — check back in a moment.
|
||||
</p>
|
||||
) : (
|
||||
<SigningProgress documentId={doc.id} signers={signers} />
|
||||
@@ -329,7 +329,7 @@ function EmptyEoiState({
|
||||
No EOI in flight for this interest
|
||||
</h2>
|
||||
<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.
|
||||
</p>
|
||||
<div className="mt-5 flex flex-wrap items-center justify-center gap-2">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
@@ -18,6 +18,16 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import {
|
||||
Command,
|
||||
@@ -30,12 +40,13 @@ import {
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { TagPicker } from '@/components/shared/tag-picker';
|
||||
import { ReminderDaysInput } from '@/components/shared/reminder-days-input';
|
||||
import { YachtForm } from '@/components/yachts/yacht-form';
|
||||
import { YachtPicker } from '@/components/yachts/yacht-picker';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { useEntityOptions } from '@/hooks/use-entity-options';
|
||||
import { createInterestSchema, type CreateInterestInput } from '@/lib/validators/interests';
|
||||
import { PIPELINE_STAGES, STAGE_LABELS, LEAD_CATEGORIES } from '@/lib/constants';
|
||||
import { PIPELINE_STAGES, STAGE_LABELS, LEAD_CATEGORIES, SOURCES } from '@/lib/constants';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
@@ -77,14 +88,14 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
||||
|
||||
const [clientOpen, setClientOpen] = useState(false);
|
||||
const [berthOpen, setBerthOpen] = useState(false);
|
||||
const [desiredUnit, setDesiredUnit] = useState<'ft' | 'm'>('ft');
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
setValue,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
formState: { errors, isSubmitting, isDirty },
|
||||
} = useForm<CreateInterestInput>({
|
||||
resolver: zodResolver(createInterestSchema),
|
||||
defaultValues: {
|
||||
@@ -102,6 +113,15 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
||||
const selectedBerthId = watch('berthId');
|
||||
const selectedYachtId = watch('yachtId');
|
||||
const [createYachtOpen, setCreateYachtOpen] = useState(false);
|
||||
const [discardConfirmOpen, setDiscardConfirmOpen] = useState(false);
|
||||
|
||||
function requestClose() {
|
||||
if (isDirty && !isSubmitting && !mutation.isPending) {
|
||||
setDiscardConfirmOpen(true);
|
||||
return;
|
||||
}
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
// Fetch the selected client's company memberships so the YachtPicker can
|
||||
// include yachts owned by companies the client belongs to (e.g. a
|
||||
@@ -200,7 +220,16 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
||||
const selectedBerth = berthOptions.find((b) => b.value === selectedBerthId);
|
||||
|
||||
return (
|
||||
<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">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{isEdit ? 'Edit Interest' : 'New Interest'}</SheetTitle>
|
||||
@@ -215,7 +244,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>Client *</Label>
|
||||
<Popover open={clientOpen} onOpenChange={setClientOpen}>
|
||||
<Popover open={clientOpen} onOpenChange={setClientOpen} modal>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
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" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[400px] p-0">
|
||||
<Command>
|
||||
<PopoverContent className="w-[var(--radix-popper-anchor-width)] min-w-[280px] p-0">
|
||||
{/* 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} />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
@@ -269,7 +303,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>Berth (optional)</Label>
|
||||
<Popover open={berthOpen} onOpenChange={setBerthOpen}>
|
||||
<Popover open={berthOpen} onOpenChange={setBerthOpen} modal>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
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" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[400px] p-0">
|
||||
<Command>
|
||||
<PopoverContent className="w-[var(--radix-popper-anchor-width)] min-w-[280px] p-0">
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput placeholder="Search berths..." onValueChange={setBerthSearch} />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
@@ -431,10 +465,11 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
||||
<SelectValue placeholder="Select source" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="website">Website</SelectItem>
|
||||
<SelectItem value="manual">Manual</SelectItem>
|
||||
<SelectItem value="referral">Referral</SelectItem>
|
||||
<SelectItem value="broker">Broker</SelectItem>
|
||||
{SOURCES.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -444,48 +479,43 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
||||
<Separator />
|
||||
|
||||
{/* Desired berth dimensions (recommender inputs) */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Berth size desired
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Imperial. Optional - the recommender treats blank fields as no constraint on that
|
||||
axis.
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<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 className="space-y-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Berth size desired
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Optional - the recommender treats blank fields as no constraint on that axis.
|
||||
</p>
|
||||
</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>
|
||||
|
||||
@@ -506,12 +536,11 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
||||
</div>
|
||||
{reminderEnabled && (
|
||||
<div className="space-y-1">
|
||||
<Label>Reminder Days</Label>
|
||||
<Input
|
||||
{...register('reminderDays', { valueAsNumber: true })}
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="e.g. 7"
|
||||
<Label htmlFor="reminderDays">Reminder cadence</Label>
|
||||
<ReminderDaysInput
|
||||
id="reminderDays"
|
||||
value={watch('reminderDays') ?? null}
|
||||
onChange={(v) => setValue('reminderDays', v ?? undefined)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -526,7 +555,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
||||
</div>
|
||||
|
||||
<SheetFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
<Button type="button" variant="outline" onClick={requestClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting || mutation.isPending}>
|
||||
@@ -537,6 +566,29 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
|
||||
<AlertDialog open={discardConfirmOpen} onOpenChange={setDiscardConfirmOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Discard unsaved changes?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
You'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>
|
||||
{createYachtOpen && selectedClientId && (
|
||||
<YachtForm
|
||||
@@ -548,3 +600,140 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
||||
</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`;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from '@/components/interests/interest-columns';
|
||||
import { ColumnPicker } from '@/components/shared/column-picker';
|
||||
import { SaveViewDialog } from '@/components/shared/save-view-dialog';
|
||||
import { useCreateFromUrl } from '@/hooks/use-create-from-url';
|
||||
import { useTablePreferences } from '@/hooks/use-table-preferences';
|
||||
import { InterestCard } from '@/components/interests/interest-card';
|
||||
import { StageLegend } from '@/components/interests/stage-legend';
|
||||
@@ -72,6 +73,7 @@ export function InterestList() {
|
||||
}, [viewMode, setViewMode]);
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
useCreateFromUrl(() => setCreateOpen(true));
|
||||
const [editInterest, setEditInterest] = useState<InterestRow | null>(null);
|
||||
const [archiveInterest, setArchiveInterest] = useState<InterestRow | null>(null);
|
||||
const [saveViewOpen, setSaveViewOpen] = useState(false);
|
||||
|
||||
@@ -29,6 +29,7 @@ const OUTCOME_LABELS: Record<InterestOutcome, string> = {
|
||||
lost_other_marina: 'Lost - went to another marina',
|
||||
lost_unqualified: 'Lost - unqualified',
|
||||
lost_no_response: 'Lost - no response',
|
||||
lost_other: 'Lost - other',
|
||||
cancelled: 'Cancelled',
|
||||
};
|
||||
|
||||
@@ -36,6 +37,7 @@ const LOST_OUTCOMES: InterestOutcome[] = [
|
||||
'lost_other_marina',
|
||||
'lost_unqualified',
|
||||
'lost_no_response',
|
||||
'lost_other',
|
||||
'cancelled',
|
||||
];
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ export function InterestPicker({
|
||||
})();
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover open={open} onOpenChange={setOpen} modal>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -281,7 +281,7 @@ function ActiveReservationCard({
|
||||
</div>
|
||||
) : signers.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
Documenso hasn't reported signers yet — check back in a moment.
|
||||
The signing service hasn't reported signers yet — check back in a moment.
|
||||
</p>
|
||||
) : (
|
||||
<SigningProgress documentId={doc.id} signers={signers} />
|
||||
@@ -344,7 +344,7 @@ function EmptyReservationState({
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
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>
|
||||
<div className="mt-5 flex flex-wrap items-center justify-center gap-2">
|
||||
<Button onClick={onUploadForSigning} size="sm" className="gap-1.5">
|
||||
|
||||
@@ -9,6 +9,8 @@ import { Anchor, CheckCircle2, Circle, FileSignature, Plus, Send, Wallet } from
|
||||
|
||||
import type { DetailTab } from '@/components/shared/detail-layout';
|
||||
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 { InlineEditableField } from '@/components/shared/inline-editable-field';
|
||||
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
|
||||
@@ -20,6 +22,7 @@ import { InterestDocumentsTab } from '@/components/interests/interest-documents-
|
||||
import {
|
||||
LEAD_CATEGORIES,
|
||||
PIPELINE_STAGES,
|
||||
SOURCES,
|
||||
canTransitionStage,
|
||||
type PipelineStage,
|
||||
} from '@/lib/constants';
|
||||
@@ -111,14 +114,17 @@ function useStageMutation(interestId: string) {
|
||||
stage,
|
||||
reason,
|
||||
override,
|
||||
milestoneDate,
|
||||
}: {
|
||||
stage: string;
|
||||
reason?: string;
|
||||
override?: boolean;
|
||||
/** Optional ISO date for the milestone column (instead of "now"). */
|
||||
milestoneDate?: string;
|
||||
}) =>
|
||||
apiFetch(`/api/v1/interests/${interestId}/stage`, {
|
||||
method: 'PATCH',
|
||||
body: { pipelineStage: stage, reason, override },
|
||||
body: { pipelineStage: stage, reason, override, milestoneDate },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['interests', interestId] });
|
||||
@@ -173,7 +179,7 @@ interface MilestoneSectionProps {
|
||||
hideAutoButton?: boolean;
|
||||
}>;
|
||||
status: string | null;
|
||||
onAdvance: (stage: string) => void;
|
||||
onAdvance: (stage: string, milestoneDate?: string) => void;
|
||||
isPending: boolean;
|
||||
/** 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.
|
||||
@@ -196,6 +202,87 @@ interface MilestoneSectionProps {
|
||||
* (Documenso webhook, paid invoice → deposit, etc.), they patch the same
|
||||
* 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({
|
||||
title,
|
||||
icon: Icon,
|
||||
@@ -282,16 +369,12 @@ function MilestoneSection({
|
||||
) : null}
|
||||
</div>
|
||||
{isNext && step.advanceStage && !step.hideAutoButton ? (
|
||||
<Button
|
||||
type="button"
|
||||
<MilestoneAdvanceButton
|
||||
label={step.actionLabel ?? `Mark as ${step.label.toLowerCase()}`}
|
||||
variant={isActive ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
disabled={isPending}
|
||||
onClick={() => onAdvance(step.advanceStage!)}
|
||||
className="mt-2 h-7 px-2.5 text-xs"
|
||||
>
|
||||
{step.actionLabel ?? `Mark as ${step.label.toLowerCase()}`}
|
||||
</Button>
|
||||
onConfirm={(date) => onAdvance(step.advanceStage!, date)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
@@ -392,7 +475,7 @@ function OverviewTab({
|
||||
* skip-ahead pattern from the inline stage picker so audit trails
|
||||
* stay consistent regardless of which surface the rep used.
|
||||
*/
|
||||
const advance = (stage: string) => {
|
||||
const advance = (stage: string, milestoneDate?: string) => {
|
||||
const fromStage = interest.pipelineStage as PipelineStage;
|
||||
const toStage = stage as PipelineStage;
|
||||
const isOverride = fromStage !== toStage && !canTransitionStage(fromStage, toStage);
|
||||
@@ -409,6 +492,7 @@ function OverviewTab({
|
||||
stage,
|
||||
reason: isOverride ? 'Skip-ahead from overview milestones' : 'Marked from overview',
|
||||
override: isOverride || undefined,
|
||||
milestoneDate,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -566,14 +650,12 @@ function OverviewTab({
|
||||
Create deposit invoice
|
||||
</Link>
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => advance('deposit_10pct')}
|
||||
<MilestoneAdvanceButton
|
||||
label="Mark received manually"
|
||||
variant="ghostLink"
|
||||
disabled={stageMutation.isPending}
|
||||
className="text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
Mark received manually
|
||||
</button>
|
||||
onConfirm={(date) => advance('deposit_10pct', date)}
|
||||
/>
|
||||
</div>
|
||||
) : null,
|
||||
pastSummary: interest.dateDepositReceived
|
||||
@@ -682,7 +764,12 @@ function OverviewTab({
|
||||
/>
|
||||
</EditableRow>
|
||||
<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>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
@@ -37,6 +37,7 @@ const LOST_OUTCOMES = new Set([
|
||||
'lost_other_marina',
|
||||
'lost_unqualified',
|
||||
'lost_no_response',
|
||||
'lost_other',
|
||||
'cancelled',
|
||||
]);
|
||||
|
||||
|
||||
@@ -36,6 +36,13 @@ import { Label } from '@/components/ui/label';
|
||||
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { HelpCircle } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -303,53 +310,96 @@ function LinkedBerthRowItem({ row, portSlug, eoiStatus, onUpdate, onRemove, isPe
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-1 gap-3 border-t pt-3 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
{/* Switch sits next to its label (gap-2.5) instead of being
|
||||
flexed to the far right via justify-between — when the
|
||||
column is wide, justify-between created a confusing visual
|
||||
gulf between the action and what it controls. */}
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Switch
|
||||
id={`specific-${row.berthId}`}
|
||||
checked={row.isSpecificInterest}
|
||||
disabled={isPending}
|
||||
onCheckedChange={(checked) => onUpdate(row.berthId, { isSpecificInterest: checked })}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`specific-${row.berthId}`}
|
||||
className="text-sm font-medium cursor-pointer"
|
||||
>
|
||||
Specifically pitching
|
||||
</Label>
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<div className="mt-3 grid grid-cols-1 gap-3 border-t pt-3 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
{/* Switch sits next to its label (gap-2.5) instead of being
|
||||
flexed to the far right via justify-between — when the
|
||||
column is wide, justify-between created a confusing visual
|
||||
gulf between the action and what it controls. */}
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Switch
|
||||
id={`specific-${row.berthId}`}
|
||||
checked={row.isSpecificInterest}
|
||||
disabled={isPending}
|
||||
onCheckedChange={(checked) =>
|
||||
onUpdate(row.berthId, { isSpecificInterest: checked })
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`specific-${row.berthId}`}
|
||||
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's "heat" 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>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{row.isSpecificInterest ? SPECIFIC_CONSEQUENCE_ON : SPECIFIC_CONSEQUENCE_OFF}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Switch
|
||||
id={`bundle-${row.berthId}`}
|
||||
checked={row.isInEoiBundle}
|
||||
disabled={isPending}
|
||||
onCheckedChange={(checked) => onUpdate(row.berthId, { isInEoiBundle: checked })}
|
||||
/>
|
||||
<Label htmlFor={`bundle-${row.berthId}`} className="text-sm font-medium cursor-pointer">
|
||||
Mark in EOI bundle
|
||||
</Label>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Switch
|
||||
id={`bundle-${row.berthId}`}
|
||||
checked={row.isInEoiBundle}
|
||||
disabled={isPending}
|
||||
onCheckedChange={(checked) => onUpdate(row.berthId, { isInEoiBundle: checked })}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`bundle-${row.berthId}`}
|
||||
className="text-sm font-medium cursor-pointer"
|
||||
>
|
||||
Mark in EOI bundle
|
||||
</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's signed berth range. When on, the berth is
|
||||
covered by the same signature and shows up in the EOI's
|
||||
<strong> Berth Range</strong> form field (e.g. "A1-A3, B5-B7"). Turn off
|
||||
to keep the link without legal coverage.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{row.isInEoiBundle
|
||||
? 'Covered by the interest’s EOI signature.'
|
||||
: 'Not covered by the EOI bundle.'}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{row.isInEoiBundle
|
||||
? 'Covered by the interest’s EOI signature.'
|
||||
: 'Not covered by the EOI bundle.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
{showBypassControl ? (
|
||||
<div className="mt-3 flex flex-wrap items-start justify-between gap-2 border-t pt-3">
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
// Bypass section reads as a third toggle-style row: label + description
|
||||
// on the left, action button inline with the description so it doesn't
|
||||
// float far-right while the toggles above are anchored left.
|
||||
<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>
|
||||
{row.eoiBypassReason ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Anchor, FileSignature, LayoutDashboard, Menu, Users } from 'lucide-react';
|
||||
import { Anchor, LayoutDashboard, Menu, Search, Users } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -12,35 +12,26 @@ type TabSpec = {
|
||||
segment: string; // route segment after /[portSlug]/
|
||||
};
|
||||
|
||||
// Bottom nav ordering, left → right:
|
||||
// Dashboard - daily overview
|
||||
// Berths - marina inventory grid (touches sales + ops both)
|
||||
// Clients - the address book / dedup surface (centered: it's the
|
||||
// primary mental anchor for "find this person", with
|
||||
// interests living as a tab on the client detail rather
|
||||
// than a peer in the bottom nav)
|
||||
// Documents - signature tracking (chase signers, EOI queue)
|
||||
// More - overflow drawer (Interests, Yachts, Companies, …)
|
||||
//
|
||||
// Interests is intentionally NOT in the bottom row - having both Clients
|
||||
// and Interests as peer tabs created a Clients-vs-Interests confusion
|
||||
// for sales reps, and the per-client interests tab + the new bottom-sheet
|
||||
// drawer cover the day-to-day deal review without needing a dedicated tab.
|
||||
// Yachts stays out for the same reason as before: it's an asset record
|
||||
// most often reached from inside an interest or client, not browsed.
|
||||
const TABS: TabSpec[] = [
|
||||
// Left-of-center: Dashboard, Clients. Right-of-center: Berths, More.
|
||||
// Search occupies the center slot. Documents demoted to the MoreSheet —
|
||||
// reps reach docs less often than berths during a walking inventory check,
|
||||
// and pinned-to-client documents are accessed via the client detail anyway.
|
||||
const TABS_LEFT: TabSpec[] = [
|
||||
{ label: 'Dashboard', icon: LayoutDashboard, segment: 'dashboard' },
|
||||
{ label: 'Berths', icon: Anchor, segment: 'berths' },
|
||||
{ label: 'Clients', icon: Users, segment: 'clients' },
|
||||
{ label: 'Documents', icon: FileSignature, segment: 'documents' },
|
||||
];
|
||||
|
||||
export function MobileBottomTabs({ onMoreClick }: { onMoreClick: () => void }) {
|
||||
const pathname = usePathname();
|
||||
const TABS_RIGHT: TabSpec[] = [
|
||||
{ label: 'Berths', icon: Anchor, segment: 'berths' },
|
||||
];
|
||||
|
||||
// Derive the active port slug from the URL so tab links always target the
|
||||
// current port, even after a port-switch. The dashboard route shape is
|
||||
// /[portSlug]/<rest>, so the slug is the first non-empty path segment.
|
||||
interface MobileBottomTabsProps {
|
||||
onMoreClick: () => void;
|
||||
onSearchClick: () => void;
|
||||
}
|
||||
|
||||
export function MobileBottomTabs({ onMoreClick, onSearchClick }: MobileBottomTabsProps) {
|
||||
const pathname = usePathname();
|
||||
const portSlug = pathname.split('/').filter(Boolean)[0] ?? 'port-nimara';
|
||||
|
||||
function isActive(segment: string): boolean {
|
||||
@@ -53,41 +44,42 @@ export function MobileBottomTabs({ onMoreClick }: { onMoreClick: () => void }) {
|
||||
className={cn(
|
||||
'fixed bottom-0 inset-x-0 z-40 bg-background border-t border-border',
|
||||
'pb-safe-bottom',
|
||||
'grid grid-cols-5',
|
||||
// 5 equal-flex slots.
|
||||
'flex items-end',
|
||||
)}
|
||||
>
|
||||
{TABS.map((tab) => {
|
||||
const active = isActive(tab.segment);
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<Link
|
||||
key={tab.segment}
|
||||
// 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-col items-center justify-center gap-0.5 h-14 text-xs transition-colors',
|
||||
active ? 'text-primary' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{/* Subtle pill background behind the icon when active. Keeps the
|
||||
tab grid alignment intact while giving the eye an anchor. */}
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'absolute top-1.5 h-7 w-12 rounded-full transition-all',
|
||||
active ? 'bg-primary/10' : 'bg-transparent',
|
||||
)}
|
||||
/>
|
||||
<Icon className="relative size-5" aria-hidden />
|
||||
<span className="relative font-medium">{tab.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{TABS_LEFT.map((tab) => (
|
||||
<NavTab
|
||||
key={tab.segment}
|
||||
tab={tab}
|
||||
portSlug={portSlug}
|
||||
active={isActive(tab.segment)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Search button — styled identically to the other navbar tabs. */}
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<Search className="relative size-5" aria-hidden />
|
||||
<span className="relative font-medium">Search</span>
|
||||
</button>
|
||||
|
||||
{TABS_RIGHT.map((tab) => (
|
||||
<NavTab
|
||||
key={tab.segment}
|
||||
tab={tab}
|
||||
portSlug={portSlug}
|
||||
active={isActive(tab.segment)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
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 />
|
||||
<span className="relative font-medium">More</span>
|
||||
@@ -95,3 +87,41 @@ export function MobileBottomTabs({ onMoreClick }: { onMoreClick: () => void }) {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { MobileLayoutProvider } from './mobile-layout-provider';
|
||||
import { MobileTopbar } from './mobile-topbar';
|
||||
import { MobileBottomTabs } from './mobile-bottom-tabs';
|
||||
import { MoreSheet } from './more-sheet';
|
||||
import { MobileSearchOverlay } from '@/components/search/mobile-search-overlay';
|
||||
|
||||
/**
|
||||
* 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 }) {
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div data-shell="mobile" className="min-h-screen bg-background">
|
||||
@@ -33,8 +35,12 @@ export function MobileLayout({ children }: { children: ReactNode }) {
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
<MobileBottomTabs onMoreClick={() => setMoreOpen(true)} />
|
||||
<MobileBottomTabs
|
||||
onMoreClick={() => setMoreOpen(true)}
|
||||
onSearchClick={() => setSearchOpen(true)}
|
||||
/>
|
||||
<MoreSheet open={moreOpen} onOpenChange={setMoreOpen} />
|
||||
<MobileSearchOverlay open={searchOpen} onOpenChange={setSearchOpen} />
|
||||
</MobileLayoutProvider>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,19 +5,20 @@ import { usePathname } from 'next/navigation';
|
||||
import {
|
||||
Anchor,
|
||||
BarChart3,
|
||||
Bell,
|
||||
BellRing,
|
||||
Bookmark,
|
||||
Building2,
|
||||
FileSignature,
|
||||
Globe,
|
||||
Home,
|
||||
Inbox,
|
||||
Receipt,
|
||||
Settings,
|
||||
Shield,
|
||||
ShieldAlert,
|
||||
Ship,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
DrawerClose,
|
||||
} from '@/components/shared/drawer';
|
||||
import { useUmamiActive } from '@/components/website-analytics/use-website-analytics';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
type MoreItem = {
|
||||
label: string;
|
||||
@@ -33,32 +35,50 @@ type MoreItem = {
|
||||
segment: string;
|
||||
};
|
||||
|
||||
// Order: most-likely overflow targets first. Interests is here (rather
|
||||
// than the bottom row) to dodge the Clients-vs-Interests UX confusion;
|
||||
// reps reach the active deals via the Interests tab on a client detail
|
||||
// (or via the new bottom-sheet drawer). Yachts is asset-record traffic
|
||||
// best reached contextually from inside an interest or client.
|
||||
type MoreGroup = {
|
||||
label: string;
|
||||
items: MoreItem[];
|
||||
};
|
||||
|
||||
// Logical grouping (vs alphabetical or frequency-ranked): keeps a stable
|
||||
// spatial layout — reps' muscle memory survives — while making the
|
||||
// "kind of thing" each tile is explicit. Three sections:
|
||||
// - Records: entity lists (people, vessels, properties)
|
||||
// - Operations: daily-use action surfaces
|
||||
// - Configuration: port-level setup, hidden from most reps
|
||||
//
|
||||
// Inbox is intentionally absent — the email/threading inbox feature was
|
||||
// deferred (see sidebar.tsx). Re-add this entry once IMAP/SMTP wiring
|
||||
// + Google OAuth review are done. Website analytics is filtered below
|
||||
// when Umami isn't configured for this port.
|
||||
const MORE_ITEMS: MoreItem[] = [
|
||||
{ label: 'Interests', icon: Bookmark, segment: 'interests' },
|
||||
{ label: 'Yachts', icon: Ship, segment: 'yachts' },
|
||||
{ label: 'Companies', icon: Building2, segment: 'companies' },
|
||||
{ label: 'Expenses', icon: Receipt, segment: 'expenses' },
|
||||
{ label: 'Reservations', icon: Anchor, segment: 'berth-reservations' },
|
||||
// Notifications themselves live on the topbar bell — this entry deep-links
|
||||
// to the notification panel inside user-settings (collapsed in 2026-05-09).
|
||||
{ label: 'Notification preferences', icon: BellRing, segment: 'settings#notifications' },
|
||||
{ label: 'Residential', icon: Home, segment: 'residential/clients' },
|
||||
{ label: 'Website analytics', icon: Globe, segment: 'website-analytics' },
|
||||
{ label: 'Alerts', icon: ShieldAlert, segment: 'alerts' },
|
||||
{ label: 'Reports', icon: BarChart3, segment: 'reports' },
|
||||
{ label: 'Reminders', icon: Bell, segment: 'reminders' },
|
||||
{ label: 'Settings', icon: Settings, segment: 'settings' },
|
||||
{ label: 'Admin', icon: Shield, segment: 'admin' },
|
||||
// Interests stays here (not bottom nav) to dodge the Clients-vs-
|
||||
// Interests UX confusion. Inbox replaces the previously-separate
|
||||
// Alerts + Reminders entries (merged 2026-05-11). Website analytics
|
||||
// and Reservations are filtered out below when not applicable.
|
||||
const MORE_GROUPS: MoreGroup[] = [
|
||||
{
|
||||
label: 'Records',
|
||||
items: [
|
||||
{ label: 'Documents', icon: FileSignature, segment: 'documents' },
|
||||
{ label: 'Interests', icon: Bookmark, segment: 'interests' },
|
||||
{ label: 'Yachts', icon: Ship, segment: 'yachts' },
|
||||
{ label: 'Companies', icon: Building2, segment: 'companies' },
|
||||
{ label: 'Residential', icon: Home, segment: 'residential/clients' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Operations',
|
||||
items: [
|
||||
{ label: 'Alerts & Reminders', icon: Inbox, segment: 'inbox' },
|
||||
{ label: 'Expenses', icon: Receipt, segment: 'expenses' },
|
||||
{ label: 'Reservations', icon: Anchor, segment: 'berth-reservations' },
|
||||
{ label: 'Reports', icon: BarChart3, segment: 'reports' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Configuration',
|
||||
items: [
|
||||
{ label: 'Website analytics', icon: Globe, segment: 'website-analytics' },
|
||||
{ label: 'Settings', icon: Settings, segment: 'settings' },
|
||||
{ label: 'Admin', icon: Shield, segment: 'admin' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function MoreSheet({
|
||||
@@ -74,10 +94,30 @@ export function MoreSheet({
|
||||
// Hide "Website analytics" if Umami isn't wired up for this port — the
|
||||
// dedicated tile on the dashboard already does the same.
|
||||
const umami = useUmamiActive('today');
|
||||
const umamiConfigured = umami.data?.error !== 'umami_not_configured';
|
||||
const items = MORE_ITEMS.filter(
|
||||
(item) => item.segment !== 'website-analytics' || umamiConfigured,
|
||||
);
|
||||
const umamiConfigured = !umami.isLoading && umami.data?.notConfigured !== true;
|
||||
|
||||
// Hide "Reservations" until at least one exists for this port — until the
|
||||
// marina has confirmed bookings, the page is empty and surfaces nothing
|
||||
// useful. Cheap count via pageSize=1; cached 5 min so opening the sheet
|
||||
// repeatedly doesn't refetch.
|
||||
const reservations = useQuery<{ pagination?: { total: number } }>({
|
||||
queryKey: ['berth-reservations', 'sheet-count'],
|
||||
queryFn: () => apiFetch('/api/v1/berth-reservations?pageSize=1'),
|
||||
staleTime: 5 * 60_000,
|
||||
enabled: open,
|
||||
});
|
||||
const hasReservations =
|
||||
!reservations.isLoading && (reservations.data?.pagination?.total ?? 0) > 0;
|
||||
|
||||
// Per-group filter: keep only the items relevant to this port's state.
|
||||
const groups = MORE_GROUPS.map((g) => ({
|
||||
...g,
|
||||
items: g.items.filter((item) => {
|
||||
if (item.segment === 'website-analytics') return umamiConfigured;
|
||||
if (item.segment === 'berth-reservations') return hasReservations;
|
||||
return true;
|
||||
}),
|
||||
})).filter((g) => g.items.length > 0);
|
||||
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={onOpenChange}>
|
||||
@@ -85,28 +125,36 @@ export function MoreSheet({
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>More</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<ul className="grid grid-cols-3 gap-2 px-3 pb-4">
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<li key={item.segment}>
|
||||
<DrawerClose asChild>
|
||||
<Link
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/${portSlug}/${item.segment}` as any}
|
||||
// min-h-[88px] guarantees a 44pt vertical touch target
|
||||
// (Apple HIG); icon + label centered. The grid gap is
|
||||
// 8px so each cell still has clearance from neighbours.
|
||||
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"
|
||||
>
|
||||
<Icon className="size-7 text-muted-foreground" aria-hidden />
|
||||
<span className="font-medium">{item.label}</span>
|
||||
</Link>
|
||||
</DrawerClose>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div className="space-y-4 px-3 pb-4">
|
||||
{groups.map((group) => (
|
||||
<section key={group.label}>
|
||||
<h3 className="mb-1.5 px-1 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{group.label}
|
||||
</h3>
|
||||
<ul className="grid grid-cols-3 gap-2">
|
||||
{group.items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<li key={item.segment}>
|
||||
<DrawerClose asChild>
|
||||
<Link
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/${portSlug}/${item.segment}` as any}
|
||||
// min-h-[88px] guarantees a 44pt vertical touch
|
||||
// target (Apple HIG); icon + label centered.
|
||||
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"
|
||||
>
|
||||
<Icon className="size-7 text-muted-foreground" aria-hidden />
|
||||
<span className="font-medium leading-tight">{item.label}</span>
|
||||
</Link>
|
||||
</DrawerClose>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
Building2,
|
||||
Receipt,
|
||||
FileText,
|
||||
Bell,
|
||||
Inbox,
|
||||
Camera,
|
||||
Globe,
|
||||
Settings,
|
||||
@@ -156,10 +156,12 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
|
||||
title: 'Communication',
|
||||
marinaRequired: true,
|
||||
items: [
|
||||
// Email tab removed: we deferred building a full inbox/threading
|
||||
// Email tab removed: deferred building a full inbox/threading
|
||||
// feature (would require Google OAuth + scope review + IMAP
|
||||
// syncing infra). Reminders stays since it's already wired up.
|
||||
{ href: `${base}/reminders`, label: 'Reminders', icon: Bell },
|
||||
// syncing infra). This entry routes to the merged
|
||||
// Alerts + Reminders surface (2026-05-11) — explicit name so
|
||||
// reps don't mistake it for an email inbox.
|
||||
{ href: `${base}/inbox`, label: 'Alerts & Reminders', icon: Inbox },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -188,8 +190,8 @@ function NavItemLink({
|
||||
href={item.href as any}
|
||||
className={cn(
|
||||
'relative flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-all duration-150',
|
||||
'text-[#cdcfd6] hover:bg-[#171f35] hover:text-white',
|
||||
active && 'text-white pl-[14px]',
|
||||
'text-slate-700 hover:bg-accent hover:text-foreground',
|
||||
active && 'bg-accent text-foreground pl-[14px]',
|
||||
collapsed && 'justify-center px-2',
|
||||
)}
|
||||
>
|
||||
@@ -202,7 +204,7 @@ function NavItemLink({
|
||||
<item.icon
|
||||
className={cn(
|
||||
'shrink-0',
|
||||
active ? 'text-[#3a7bc8]' : 'text-[#83aab1]',
|
||||
active ? 'text-[#3a7bc8]' : 'text-slate-500',
|
||||
collapsed ? 'w-5 h-5' : 'w-4 h-4',
|
||||
)}
|
||||
/>
|
||||
@@ -252,7 +254,7 @@ function SidebarContent({
|
||||
const [adminExpanded, setAdminExpanded] = useState(true);
|
||||
const sections = buildNavSections(portSlug);
|
||||
const umami = useUmamiActive('today');
|
||||
const umamiConfigured = umami.data?.error !== 'umami_not_configured';
|
||||
const umamiConfigured = !umami.isLoading && umami.data?.notConfigured !== true;
|
||||
|
||||
// Small label under the user identity when the user has access to more
|
||||
// than one port — disambiguates which port is currently active without
|
||||
@@ -283,12 +285,13 @@ function SidebarContent({
|
||||
// compete with the logo for attention.
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div className="flex flex-col h-full bg-[#1e2844]">
|
||||
{/* Brand header - logo centered (large when expanded, smaller when
|
||||
collapsed). Collapse toggle floats top-right as a tiny chevron. */}
|
||||
<div className="flex flex-col h-full bg-white">
|
||||
{/* Brand header - logo centered. Soft hairline below echoes the
|
||||
inter-section separators in the nav so the eye reads the logo
|
||||
as a distinct top-row, not a floating element. */}
|
||||
<div
|
||||
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',
|
||||
)}
|
||||
>
|
||||
@@ -297,7 +300,7 @@ function SidebarContent({
|
||||
alt="Port Nimara"
|
||||
width={collapsed ? 40 : 72}
|
||||
height={collapsed ? 40 : 72}
|
||||
className="rounded-full shadow-md ring-2 ring-white/20"
|
||||
className="rounded-full shadow-sm"
|
||||
unoptimized
|
||||
priority
|
||||
/>
|
||||
@@ -307,7 +310,7 @@ function SidebarContent({
|
||||
onClick={onToggleCollapse}
|
||||
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
className={cn(
|
||||
'absolute right-2 flex h-6 w-6 items-center justify-center rounded-md text-[#83aab1] hover:bg-[#171f35] hover:text-white transition-colors',
|
||||
'absolute right-2 flex h-6 w-6 items-center justify-center rounded-md text-slate-400 hover:bg-slate-100 hover:text-slate-700 transition-colors',
|
||||
collapsed ? 'top-1' : 'top-2',
|
||||
)}
|
||||
>
|
||||
@@ -333,13 +336,13 @@ function SidebarContent({
|
||||
<div key={section.title}>
|
||||
{!collapsed && (
|
||||
<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}
|
||||
</span>
|
||||
{section.adminRequired && (
|
||||
<button
|
||||
onClick={() => setAdminExpanded((v) => !v)}
|
||||
className="text-[#71768a] hover:text-[#cdcfd6] transition-colors"
|
||||
className="text-slate-400 hover:text-slate-700 transition-colors"
|
||||
>
|
||||
{adminExpanded ? (
|
||||
<ChevronUp className="w-3 h-3" />
|
||||
@@ -363,7 +366,7 @@ function SidebarContent({
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<Separator className="mt-3 bg-[#474e66]/50" />
|
||||
<Separator className="mt-3 bg-slate-200" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -374,7 +377,7 @@ function SidebarContent({
|
||||
user can click their name/avatar to access Profile / Settings /
|
||||
port-switcher / sign-out. The same UserMenu component drives the
|
||||
top-right avatar dropdown, so the menu items stay consistent. */}
|
||||
<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 ? (
|
||||
<UserMenu
|
||||
align="start"
|
||||
@@ -384,7 +387,7 @@ function SidebarContent({
|
||||
<button
|
||||
type="button"
|
||||
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">
|
||||
<AvatarImage src={undefined} />
|
||||
@@ -404,26 +407,26 @@ function SidebarContent({
|
||||
<button
|
||||
type="button"
|
||||
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} />
|
||||
<AvatarFallback className="bg-[#3a7bc8] text-white text-xs font-semibold">
|
||||
{(user?.name ?? 'U').slice(0, 1).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<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'}
|
||||
</p>
|
||||
<Badge
|
||||
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)}
|
||||
</Badge>
|
||||
{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>
|
||||
</button>
|
||||
@@ -461,10 +464,9 @@ export function Sidebar({ portRoles, isSuperAdmin = false, user, ports }: Sideba
|
||||
return (
|
||||
<aside
|
||||
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',
|
||||
)}
|
||||
style={{ backgroundColor: '#1e2844' }}
|
||||
>
|
||||
<SidebarContent
|
||||
collapsed={sidebarCollapsed}
|
||||
|
||||
@@ -95,23 +95,40 @@ export function Topbar({ ports, user }: TopbarProps) {
|
||||
<span className="hidden sm:inline">New</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">Create</DropdownMenuLabel>
|
||||
<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 */}
|
||||
<DropdownMenuItem onClick={() => router.push(`${base}/clients/new` as any)}>
|
||||
<DropdownMenuItem onClick={() => router.push(`${base}/clients?create=1` as any)}>
|
||||
New Client
|
||||
</DropdownMenuItem>
|
||||
{/* 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
|
||||
</DropdownMenuItem>
|
||||
{/* 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
|
||||
</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 */}
|
||||
<DropdownMenuItem onClick={() => router.push(`${base}/reminders/new` as any)}>
|
||||
<DropdownMenuItem onClick={() => router.push(`${base}/inbox?create=1#reminders` as any)}>
|
||||
New Reminder
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -29,6 +29,7 @@ interface ReminderPrefs {
|
||||
interface UserPrefsResponse {
|
||||
reminders?: ReminderPrefs;
|
||||
timezone?: string;
|
||||
portReminderDigestEnabled?: boolean;
|
||||
}
|
||||
|
||||
const DAYS = [
|
||||
@@ -96,6 +97,10 @@ export function ReminderDigestForm() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!data?.portReminderDigestEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@@ -13,6 +13,9 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
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 { usePermissions } from '@/hooks/use-permissions';
|
||||
|
||||
@@ -172,7 +175,9 @@ export function ReminderForm({
|
||||
/>
|
||||
</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">
|
||||
<Label htmlFor="reminder-due">Due Date & Time</Label>
|
||||
<Input
|
||||
@@ -202,13 +207,18 @@ export function ReminderForm({
|
||||
|
||||
{canAssignOthers && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reminder-assign">Assign To</Label>
|
||||
<Select value={assignedTo} onValueChange={setAssignedTo}>
|
||||
<Label htmlFor="reminder-assign">Assign to user</Label>
|
||||
<Select
|
||||
value={assignedTo === '' ? '__self__' : assignedTo}
|
||||
onValueChange={(v) => setAssignedTo(v === '__self__' ? '' : v)}
|
||||
>
|
||||
<SelectTrigger id="reminder-assign">
|
||||
<SelectValue placeholder="Myself" />
|
||||
</SelectTrigger>
|
||||
<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) => (
|
||||
<SelectItem key={u.id} value={u.id}>
|
||||
{u.displayName}
|
||||
@@ -220,27 +230,36 @@ export function ReminderForm({
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground text-xs">
|
||||
Link to Entity (optional - paste UUIDs, or leave blank)
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Link to entity (optional)
|
||||
</Label>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Pick a client first to scope the interest and berth dropdowns to that
|
||||
client's deals.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
<Input
|
||||
placeholder="Client ID"
|
||||
value={clientId}
|
||||
onChange={(e) => setClientId(e.target.value)}
|
||||
className="text-xs"
|
||||
<ClientPicker
|
||||
value={clientId || null}
|
||||
onChange={(id) => {
|
||||
setClientId(id ?? '');
|
||||
// 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
|
||||
placeholder="Interest ID"
|
||||
value={interestId}
|
||||
onChange={(e) => setInterestId(e.target.value)}
|
||||
className="text-xs"
|
||||
<InterestPicker
|
||||
value={interestId || null}
|
||||
onChange={(id) => setInterestId(id ?? '')}
|
||||
clientId={clientId || null}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Berth ID"
|
||||
value={berthId}
|
||||
onChange={(e) => setBerthId(e.target.value)}
|
||||
className="text-xs"
|
||||
<BerthPicker
|
||||
value={berthId || null}
|
||||
onChange={(id) => setBerthId(id ?? '')}
|
||||
clientId={clientId || null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { type ColumnDef } from '@tanstack/react-table';
|
||||
import { Plus, CheckCircle2, Clock, XCircle, AlertTriangle, Bell } from 'lucide-react';
|
||||
import { Plus, CheckCircle2, Clock, Pencil, XCircle, AlertTriangle, Bell } from 'lucide-react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
@@ -11,6 +11,12 @@ import { PageHeader } from '@/components/shared/page-header';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -19,6 +25,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { useCreateFromUrl } from '@/hooks/use-create-from-url';
|
||||
import { usePermissions } from '@/hooks/use-permissions';
|
||||
import { ReminderCard } from './reminder-card';
|
||||
import { ReminderForm } from './reminder-form';
|
||||
@@ -59,10 +66,20 @@ const STATUS_CONFIG = {
|
||||
dismissed: { label: 'Dismissed', icon: XCircle },
|
||||
} as const;
|
||||
|
||||
export function ReminderList() {
|
||||
interface ReminderListProps {
|
||||
/**
|
||||
* Embedded mode (used by the Inbox page) drops the PageHeader and
|
||||
* surfaces the "New Reminder" button inline so the section can render
|
||||
* alongside the Alerts section without duplicating page chrome.
|
||||
*/
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
export function ReminderList({ embedded = false }: ReminderListProps = {}) {
|
||||
const [reminders, setReminders] = useState<Reminder[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
useCreateFromUrl(() => setFormOpen(true));
|
||||
const [editingReminder, setEditingReminder] = useState<Reminder | null>(null);
|
||||
const [snoozingId, setSnoozingId] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<'my' | 'all'>('my');
|
||||
@@ -203,41 +220,97 @@ export function ReminderList() {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-green-600 hover:text-green-700"
|
||||
onClick={() => handleComplete(row.original.id)}
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setSnoozingId(row.original.id)}>
|
||||
<Clock className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => handleDismiss(row.original.id)}
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label="Mark complete"
|
||||
className="text-green-600 hover:text-green-700"
|
||||
onClick={() => handleComplete(row.original.id)}
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Mark complete</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
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,
|
||||
size: 120,
|
||||
size: 160,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Reminders"
|
||||
description={`${total} reminder${total !== 1 ? 's' : ''}`}
|
||||
actions={
|
||||
{!embedded ? (
|
||||
<PageHeader
|
||||
title="Reminders"
|
||||
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
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingReminder(null);
|
||||
setFormOpen(true);
|
||||
@@ -246,8 +319,8 @@ export function ReminderList() {
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
New Reminder
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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. */}
|
||||
|
||||
@@ -17,11 +17,49 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import type { RequestReportInput } from '@/lib/validators/reports';
|
||||
|
||||
const REPORT_TYPE_LABELS: Record<string, string> = {
|
||||
pipeline: 'Pipeline Summary',
|
||||
revenue: 'Revenue Report',
|
||||
activity: 'Activity Log',
|
||||
occupancy: 'Berth Occupancy',
|
||||
interface ReportTypeMeta {
|
||||
label: string;
|
||||
subtitle: string;
|
||||
contents: string[];
|
||||
}
|
||||
|
||||
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 interest’s primary berth link (non-primary junctions ignored)',
|
||||
'Sold-stage total reflects realised revenue; earlier stages are forecast',
|
||||
],
|
||||
},
|
||||
activity: {
|
||||
label: 'Activity Log',
|
||||
subtitle: 'Audit events across the port for a date range',
|
||||
contents: [
|
||||
'Audit log entries (create / update / delete) per entity',
|
||||
'Filtered to the selected date range — defaults to last 30 days',
|
||||
'Includes actor name, entity type, and action verb',
|
||||
],
|
||||
},
|
||||
occupancy: {
|
||||
label: 'Berth Occupancy',
|
||||
subtitle: 'Berth counts by status',
|
||||
contents: [
|
||||
'Berths grouped by status: Available, Under Offer, Sold',
|
||||
'Per-dock breakdown using the mooring-letter prefix',
|
||||
'Total port utilisation percentage at the top',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export function GenerateReportForm() {
|
||||
@@ -74,13 +112,26 @@ export function GenerateReportForm() {
|
||||
<SelectValue placeholder="Select a report type..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(REPORT_TYPE_LABELS).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
{Object.entries(REPORT_TYPES).map(([value, meta]) => (
|
||||
<SelectItem key={value} value={value} className="py-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{meta.label}</span>
|
||||
<span className="text-xs text-muted-foreground">{meta.subtitle}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</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 className="grid gap-2">
|
||||
@@ -94,7 +145,7 @@ export function GenerateReportForm() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="dateFrom">Date From (optional)</Label>
|
||||
<Input
|
||||
@@ -102,6 +153,7 @@ export function GenerateReportForm() {
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
className="w-auto"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
@@ -111,6 +163,7 @@ export function GenerateReportForm() {
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
className="w-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { SubdivisionCombobox } from '@/components/shared/subdivision-combobox';
|
||||
import { NotesList } from '@/components/shared/notes-list';
|
||||
import { EntityActivityFeed } from '@/components/shared/entity-activity-feed';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { SOURCES } from '@/lib/constants';
|
||||
import type { CountryCode } from '@/lib/i18n/countries';
|
||||
|
||||
interface ResidentialInterestSummary {
|
||||
@@ -62,13 +63,7 @@ const CONTACT_OPTIONS = [
|
||||
{ value: 'email', label: 'Email' },
|
||||
{ value: 'phone', label: 'Phone' },
|
||||
];
|
||||
const SOURCE_OPTIONS = [
|
||||
{ value: 'website', label: 'Website' },
|
||||
{ value: 'manual', label: 'Manual' },
|
||||
{ value: 'referral', label: 'Referral' },
|
||||
{ value: 'broker', label: 'Broker' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
];
|
||||
const SOURCE_OPTIONS = SOURCES.map((s) => ({ value: s.value, label: s.label }));
|
||||
|
||||
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
@@ -117,10 +112,10 @@ export function getResidentialClientTabs({
|
||||
label: 'Notes',
|
||||
content: (
|
||||
<NotesList
|
||||
aggregate
|
||||
entityType="residential_clients"
|
||||
entityId={clientId}
|
||||
currentUserId={currentUserId}
|
||||
aggregate
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
||||
import { NotesList } from '@/components/shared/notes-list';
|
||||
import { EntityActivityFeed } from '@/components/shared/entity-activity-feed';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { SOURCES } from '@/lib/constants';
|
||||
|
||||
interface ResidentialInterest {
|
||||
id: string;
|
||||
@@ -25,13 +26,7 @@ interface Args {
|
||||
stageOptions: Array<{ value: string; label: string }>;
|
||||
}
|
||||
|
||||
const SOURCE_OPTIONS = [
|
||||
{ value: 'website', label: 'Website' },
|
||||
{ value: 'manual', label: 'Manual' },
|
||||
{ value: 'referral', label: 'Referral' },
|
||||
{ value: 'broker', label: 'Broker' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
];
|
||||
const SOURCE_OPTIONS = SOURCES.map((s) => ({ value: s.value, label: s.label }));
|
||||
|
||||
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Anchor,
|
||||
Bell,
|
||||
@@ -100,6 +101,7 @@ export function CommandSearch() {
|
||||
const [focusIndex, setFocusIndex] = useState<number>(-1);
|
||||
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const portSlug = useUIStore((s) => s.currentPortSlug);
|
||||
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
@@ -113,8 +115,32 @@ export function CommandSearch() {
|
||||
limit: activeBucket === 'all' ? 5 : 15,
|
||||
});
|
||||
|
||||
// Persist the totals from the last "all" query so the filter chips stay
|
||||
// populated when the user narrows to a single bucket. Without this, the
|
||||
// narrowed query only returns counts for the active bucket and every
|
||||
// other chip would vanish — making it impossible to swap between
|
||||
// filters without clearing back to "All" first.
|
||||
const lastAllTotalsRef = useRef<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;
|
||||
|
||||
// CommandSearch lives in the header and persists across navigations,
|
||||
// so its React Query cache never sees a remount. Invalidate the
|
||||
// recently-viewed + recent-terms queries whenever the dropdown opens
|
||||
// so the user sees fresh data after navigating around the app.
|
||||
useEffect(() => {
|
||||
if (!showDropdown) return;
|
||||
queryClient.invalidateQueries({ queryKey: ['search', 'recently-viewed'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['search', 'recent-terms'] });
|
||||
}, [showDropdown, queryClient]);
|
||||
|
||||
// Cmd/Ctrl+K focuses the input from anywhere on the page.
|
||||
useEffect(() => {
|
||||
function onKeyDown(e: globalThis.KeyboardEvent) {
|
||||
@@ -287,7 +313,7 @@ export function CommandSearch() {
|
||||
>
|
||||
{/* Filter chip row — always visible while the dropdown is open. */}
|
||||
<FilterChipRow
|
||||
results={results}
|
||||
totals={chipTotals}
|
||||
active={activeBucket}
|
||||
onChange={setActiveBucket}
|
||||
disabled={query.length < 2}
|
||||
@@ -337,18 +363,19 @@ export function CommandSearch() {
|
||||
// ─── Filter chips ────────────────────────────────────────────────────────────
|
||||
|
||||
function FilterChipRow({
|
||||
results,
|
||||
totals,
|
||||
active,
|
||||
onChange,
|
||||
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';
|
||||
onChange: (b: BucketType | 'all') => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
// Show a chip for every bucket so the user can browse the search
|
||||
// surface even with no query; counts only render when results exist.
|
||||
return (
|
||||
<div
|
||||
role="tablist"
|
||||
@@ -364,10 +391,10 @@ function FilterChipRow({
|
||||
All
|
||||
</ChipButton>
|
||||
{BUCKETS.map((b) => {
|
||||
const count = results?.totals?.[b.type] ?? 0;
|
||||
// Hide chips for buckets the current user can't see (count === 0
|
||||
// when the bucket query was permission-skipped) — but only after
|
||||
// a query has run, otherwise we'd hide every chip on first paint.
|
||||
const count = totals?.[b.type] ?? 0;
|
||||
// Hide chips for buckets with zero matches in the last "all"
|
||||
// snapshot — keeps the row tight and avoids dead-end clicks.
|
||||
// Always show the active chip + every chip before a query has run.
|
||||
if (!disabled && count === 0 && active !== b.type) return null;
|
||||
return (
|
||||
<ChipButton
|
||||
@@ -701,6 +728,11 @@ function ResultRow({
|
||||
<HighlightMatch text={row.sub} query={query} />
|
||||
</div>
|
||||
)}
|
||||
{row.relatedVia && (
|
||||
<div className="text-[11px] italic text-muted-foreground/80 truncate mt-0.5">
|
||||
via {row.relatedVia.label}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
@@ -759,9 +791,9 @@ function BucketSection({
|
||||
|
||||
// ─── Flat-row construction (drives keyboard nav + ARIA) ──────────────────────
|
||||
|
||||
type ResultBadge = { label: string; tone: 'neutral' | 'warning' | 'success' | 'danger' };
|
||||
export type ResultBadge = { label: string; tone: 'neutral' | 'warning' | 'success' | 'danger' };
|
||||
|
||||
type FlatRow =
|
||||
export type FlatRow =
|
||||
| {
|
||||
kind: 'recent-view';
|
||||
key: string;
|
||||
@@ -783,6 +815,9 @@ type FlatRow =
|
||||
sub: string | null;
|
||||
href: string;
|
||||
badges?: ResultBadge[];
|
||||
/** Provenance hint when the row was surfaced via graph expansion.
|
||||
* Rendered as a subtle "via Berth A10" line below the sub. */
|
||||
relatedVia?: { type: string; label: string } | null;
|
||||
}
|
||||
| {
|
||||
kind: 'other-port';
|
||||
@@ -791,7 +826,7 @@ type FlatRow =
|
||||
href: string;
|
||||
};
|
||||
|
||||
interface BuildFlatRowsArgs {
|
||||
export interface BuildFlatRowsArgs {
|
||||
query: string;
|
||||
results: SearchResults | undefined;
|
||||
recentlyViewed: RecentlyViewedItem[];
|
||||
@@ -800,7 +835,7 @@ interface BuildFlatRowsArgs {
|
||||
portSlug: string | null;
|
||||
}
|
||||
|
||||
function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
|
||||
export function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
|
||||
const { query, results, recentlyViewed, recentSearches, activeBucket, portSlug } = args;
|
||||
const rows: FlatRow[] = [];
|
||||
|
||||
@@ -839,6 +874,7 @@ function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
|
||||
sub: c.matchedContact ?? null,
|
||||
href: `/${portSlug}/clients/${c.id}`,
|
||||
badges: c.archivedAt ? [{ label: 'Archived', tone: 'neutral' }] : undefined,
|
||||
relatedVia: c.relatedVia ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -866,6 +902,7 @@ function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
|
||||
label: y.name,
|
||||
sub,
|
||||
href: `/${portSlug}/yachts/${y.id}`,
|
||||
relatedVia: y.relatedVia ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -880,6 +917,7 @@ function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
|
||||
label: co.name,
|
||||
sub,
|
||||
href: `/${portSlug}/companies/${co.id}`,
|
||||
relatedVia: co.relatedVia ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -903,6 +941,7 @@ function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
|
||||
sub: i.berthMooringNumber,
|
||||
href: `/${portSlug}/interests/${i.id}`,
|
||||
badges: badges.length > 0 ? badges : undefined,
|
||||
relatedVia: i.relatedVia ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -942,6 +981,7 @@ function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
|
||||
sub,
|
||||
href: `/${portSlug}/berths/${b.id}`,
|
||||
badges: badges.length > 0 ? badges : undefined,
|
||||
relatedVia: b.relatedVia ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
607
src/components/search/mobile-search-overlay.tsx
Normal file
607
src/components/search/mobile-search-overlay.tsx
Normal 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 “{query}”</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>
|
||||
);
|
||||
}
|
||||
77
src/components/settings/dashboard-widgets-card.tsx
Normal file
77
src/components/settings/dashboard-widgets-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { TimezoneCombobox } from '@/components/shared/timezone-combobox';
|
||||
import { ImageCropperDialog } from '@/components/shared/image-cropper-dialog';
|
||||
import { NotificationPreferencesForm } from '@/components/notifications/notification-preferences-form';
|
||||
import { ReminderDigestForm } from '@/components/notifications/reminder-digest-form';
|
||||
import { DashboardWidgetsCard } from '@/components/settings/dashboard-widgets-card';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { primaryTimezoneFor } from '@/lib/i18n/timezones';
|
||||
import type { CountryCode } from '@/lib/i18n/countries';
|
||||
@@ -186,7 +187,7 @@ export function UserSettings() {
|
||||
<div>
|
||||
<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>
|
||||
<CardHeader>
|
||||
<CardTitle>Profile</CardTitle>
|
||||
@@ -318,6 +319,8 @@ export function UserSettings() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<DashboardWidgetsCard />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Account</CardTitle>
|
||||
|
||||
187
src/components/shared/berth-picker.tsx
Normal file
187
src/components/shared/berth-picker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -58,7 +58,10 @@ export function ClientPicker({
|
||||
})();
|
||||
|
||||
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>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -76,6 +79,18 @@ export function ClientPicker({
|
||||
<CommandList>
|
||||
<CommandEmpty>No clients found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{value ? (
|
||||
<CommandItem
|
||||
value="__clear__"
|
||||
onSelect={() => {
|
||||
onChange(null);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
Clear selection
|
||||
</CommandItem>
|
||||
) : null}
|
||||
{options.map((c) => (
|
||||
<CommandItem
|
||||
key={c.id}
|
||||
|
||||
@@ -87,7 +87,11 @@ export function CountryCombobox({
|
||||
const selected = value ? options.find((o) => o.code === value) : undefined;
|
||||
|
||||
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>
|
||||
<Button
|
||||
id={id}
|
||||
|
||||
@@ -19,19 +19,76 @@ interface CurrencyInputProps extends Omit<
|
||||
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
|
||||
* value is the raw number the user typed (we don't fight the keystroke
|
||||
* cadence by re-formatting on every key) — formatted display lives in
|
||||
* read-only contexts via `formatCurrency()`. This keeps form behaviour
|
||||
* predictable while still scoping the input to a money field via the
|
||||
* symbol prefix and the `decimal` inputMode.
|
||||
* Numeric input pre-decorated with a currency symbol and thousand-separator
|
||||
* grouping (e.g. `3,528,000.50`). Uses `type="text"` + `inputMode="decimal"`
|
||||
* so we can render commas (HTML `type="number"` strips them) while still
|
||||
* surfacing the decimal keypad on iOS/Android. The parent receives a raw
|
||||
* number via `onChange`; the formatted string is local UI state.
|
||||
*/
|
||||
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 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 (
|
||||
<div className="relative">
|
||||
@@ -43,19 +100,29 @@ export const CurrencyInput = React.forwardRef<HTMLInputElement, CurrencyInputPro
|
||||
</span>
|
||||
<Input
|
||||
ref={ref}
|
||||
type="number"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
step="0.01"
|
||||
min="0"
|
||||
autoComplete="off"
|
||||
value={display}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
if (raw === '') {
|
||||
onChange(null);
|
||||
return;
|
||||
const { display: nextDisplay, numeric } = parseTyped(e.target.value);
|
||||
setDisplay(nextDisplay);
|
||||
onChange(numeric);
|
||||
}}
|
||||
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);
|
||||
onChange(Number.isFinite(n) ? n : null);
|
||||
onBlur?.(e);
|
||||
}}
|
||||
className={cn('pl-9 tabular-nums', className)}
|
||||
{...props}
|
||||
|
||||
@@ -65,6 +65,15 @@ interface DataTableProps<TData> {
|
||||
* sort, and selection stay in sync across the breakpoint.
|
||||
*/
|
||||
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
|
||||
* "currently visible". Columns absent from the map are visible by
|
||||
@@ -90,6 +99,7 @@ export function DataTable<TData>({
|
||||
onRowClick,
|
||||
getRowClassName,
|
||||
cardRender,
|
||||
mobileGroupBy,
|
||||
columnVisibility,
|
||||
}: DataTableProps<TData>) {
|
||||
const [internalSelection, setInternalSelection] = useState<RowSelectionState>({});
|
||||
@@ -259,7 +269,30 @@ export function DataTable<TData>({
|
||||
{emptyState ?? 'No results.'}
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -5,11 +5,27 @@ import { Drawer as VaulDrawer } from 'vaul';
|
||||
|
||||
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 = ({
|
||||
shouldScaleBackground = true,
|
||||
shouldScaleBackground = false,
|
||||
repositionInputs = false,
|
||||
...props
|
||||
}: React.ComponentProps<typeof VaulDrawer.Root>) => (
|
||||
<VaulDrawer.Root shouldScaleBackground={shouldScaleBackground} {...props} />
|
||||
<VaulDrawer.Root
|
||||
shouldScaleBackground={shouldScaleBackground}
|
||||
repositionInputs={repositionInputs}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
Drawer.displayName = 'Drawer';
|
||||
|
||||
|
||||
@@ -22,6 +22,13 @@ interface SelectOption {
|
||||
|
||||
interface BaseProps {
|
||||
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>;
|
||||
placeholder?: string;
|
||||
emptyText?: string;
|
||||
@@ -43,7 +50,15 @@ interface TextareaProps extends BaseProps {
|
||||
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
|
||||
@@ -51,7 +66,15 @@ export type InlineEditableFieldProps = TextProps | SelectFieldProps | TextareaPr
|
||||
* Enter/blur and cancels on Escape.
|
||||
*/
|
||||
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 [draft, setDraft] = useState(value ?? '');
|
||||
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 (!editing) {
|
||||
return (
|
||||
<ReadButton
|
||||
value={value || null}
|
||||
displayValue={displayValue}
|
||||
emptyText={emptyText}
|
||||
disabled={disabled}
|
||||
onClick={() => setEditing(true)}
|
||||
@@ -178,6 +232,7 @@ export function InlineEditableField(props: InlineEditableFieldProps) {
|
||||
return (
|
||||
<ReadButton
|
||||
value={value || null}
|
||||
displayValue={displayValue}
|
||||
emptyText={emptyText}
|
||||
disabled={disabled}
|
||||
onClick={() => setEditing(true)}
|
||||
@@ -216,6 +271,7 @@ export function InlineEditableField(props: InlineEditableFieldProps) {
|
||||
|
||||
function ReadButton({
|
||||
value,
|
||||
displayValue,
|
||||
emptyText,
|
||||
disabled,
|
||||
onClick,
|
||||
@@ -224,6 +280,8 @@ function ReadButton({
|
||||
className,
|
||||
}: {
|
||||
value: string | null;
|
||||
/** Optional formatted version for display only (currency, percent, etc.) */
|
||||
displayValue?: string | null;
|
||||
emptyText: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
@@ -258,7 +316,7 @@ function ReadButton({
|
||||
!value && 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{value ?? emptyText}
|
||||
{value ? (displayValue ?? value) : emptyText}
|
||||
</span>
|
||||
{!disabled && (
|
||||
<Icon
|
||||
|
||||
@@ -146,7 +146,7 @@ export function InlinePhoneField({
|
||||
{display ?? emptyText}
|
||||
</span>
|
||||
{!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>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user