Compare commits
34 Commits
93c6554c95
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b9560531e | |||
| f55be14813 | |||
| 6bc81270b9 | |||
| 38e392e38b | |||
| 039ef25fe5 | |||
| b3753b96a1 | |||
| 9147f2857e | |||
| 47778796ad | |||
| f7425d1231 | |||
| df8c26d1b3 | |||
| 91703bdb00 | |||
| 3165ec651f | |||
| 661187cc79 | |||
| 4dc0bdd8c4 | |||
| 7f04c765f4 | |||
| 4d018be800 | |||
| 95d7776bb6 | |||
| 0cc05f302f | |||
| 54554a0928 | |||
| 9879b82e5f | |||
| 08adb4aeea | |||
| 6c4490f653 | |||
| 13efe177a5 | |||
| 7591231c47 | |||
| 2e8c4b43bf | |||
| fe863a588e | |||
| 05950ae0b6 | |||
| eff57af571 | |||
| 1750e265e7 | |||
| 2a7f922a01 | |||
| 39c19b2340 | |||
| d1f6d6a427 | |||
| 3b227fe9b2 | |||
| 95724c8e3a |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -65,3 +65,6 @@ tmp/
|
||||
# Internal docs + Claude instructions: kept local-only, not in the shared repo
|
||||
docs/
|
||||
/CLAUDE.md
|
||||
|
||||
# Client-facing feature screenshots (real PII — do not commit)
|
||||
docs/feature-screenshots/
|
||||
|
||||
@@ -59,6 +59,9 @@ RUN apk add --no-cache --virtual .merge-deps rsync \
|
||||
&& rsync -a --ignore-existing /opt/prod-node-modules/ ./node_modules/ \
|
||||
&& rm -rf /opt/prod-node-modules \
|
||||
&& apk del .merge-deps
|
||||
# pg_dump for the backup/DR bundle engine (src/lib/services/backup.service.ts
|
||||
# spawns `pg_dump`). Version pinned to match the postgres:16 server.
|
||||
RUN apk add --no-cache postgresql16-client
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
|
||||
@@ -26,6 +26,9 @@ FROM node:20-alpine AS runner
|
||||
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
|
||||
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 worker
|
||||
WORKDIR /app
|
||||
# pg_dump for the scheduled backup-push cron (maintenance worker runs
|
||||
# runScheduledBackupPush → pg_dump). Pinned to match the postgres:16 server.
|
||||
RUN apk add --no-cache postgresql16-client
|
||||
RUN chown -R worker:nodejs /app
|
||||
USER worker
|
||||
COPY --chown=worker:nodejs package.json pnpm-lock.yaml ./
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
"country-flag-icons": "^1.6.17",
|
||||
"cron-parser": "^5.5.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"docx-preview": "^0.3.7",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"echarts": "^6.0.0",
|
||||
"echarts-for-react": "^3.0.6",
|
||||
@@ -125,6 +126,7 @@
|
||||
"socket.io": "^4.8.3",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"sonner": "^2.0.7",
|
||||
"ssh2-sftp-client": "^12.1.1",
|
||||
"svgo": "^4.0.1",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tesseract.js": "^7.0.0",
|
||||
@@ -153,6 +155,7 @@
|
||||
"@types/papaparse": "^5.5.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/ssh2-sftp-client": "^9.0.6",
|
||||
"@types/topojson-client": "^3.1.5",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/coverage-v8": "^4.1.6",
|
||||
|
||||
121
pnpm-lock.yaml
generated
121
pnpm-lock.yaml
generated
@@ -157,6 +157,9 @@ importers:
|
||||
date-fns:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0
|
||||
docx-preview:
|
||||
specifier: ^0.3.7
|
||||
version: 0.3.7
|
||||
drizzle-orm:
|
||||
specifier: ^0.45.2
|
||||
version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.17)(postgres@3.4.9)
|
||||
@@ -292,6 +295,9 @@ importers:
|
||||
sonner:
|
||||
specifier: ^2.0.7
|
||||
version: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
ssh2-sftp-client:
|
||||
specifier: ^12.1.1
|
||||
version: 12.1.1
|
||||
svgo:
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.1
|
||||
@@ -371,6 +377,9 @@ importers:
|
||||
'@types/react-dom':
|
||||
specifier: ^19.2.3
|
||||
version: 19.2.3(@types/react@19.2.14)
|
||||
'@types/ssh2-sftp-client':
|
||||
specifier: ^9.0.6
|
||||
version: 9.0.6
|
||||
'@types/topojson-client':
|
||||
specifier: ^3.1.5
|
||||
version: 3.1.5
|
||||
@@ -3092,6 +3101,9 @@ packages:
|
||||
'@types/node@14.18.63':
|
||||
resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==}
|
||||
|
||||
'@types/node@18.19.130':
|
||||
resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
|
||||
|
||||
'@types/node@20.19.41':
|
||||
resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==}
|
||||
|
||||
@@ -3124,6 +3136,12 @@ packages:
|
||||
'@types/readdir-glob@1.1.5':
|
||||
resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==}
|
||||
|
||||
'@types/ssh2-sftp-client@9.0.6':
|
||||
resolution: {integrity: sha512-4+KvXO/V77y9VjI2op2T8+RCGI/GXQAwR0q5Qkj/EJ5YSeyKszqZP6F8i3H3txYoBqjc7sgorqyvBP3+w1EHyg==}
|
||||
|
||||
'@types/ssh2@1.15.5':
|
||||
resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==}
|
||||
|
||||
'@types/tedious@4.0.14':
|
||||
resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==}
|
||||
|
||||
@@ -3579,6 +3597,9 @@ packages:
|
||||
resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
asn1@0.2.6:
|
||||
resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==}
|
||||
|
||||
assertion-error@2.0.1:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -3691,6 +3712,9 @@ packages:
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
bcrypt-pbkdf@1.0.2:
|
||||
resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==}
|
||||
|
||||
better-auth@1.6.11:
|
||||
resolution: {integrity: sha512-Wwt6+q07dwIhsp6XiM7L1qSXVUWBEtNl+eZvwM778CguFqDZFBN9Pt6LtFaHl55t8Z+Zc//5kxcbgDY8/79vFQ==}
|
||||
peerDependencies:
|
||||
@@ -3853,6 +3877,10 @@ packages:
|
||||
resolution: {integrity: sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==}
|
||||
engines: {node: '>=0.2.0'}
|
||||
|
||||
buildcheck@0.0.7:
|
||||
resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
bullmq@5.76.8:
|
||||
resolution: {integrity: sha512-v3WTwA8diFtsADaJ8eK2ozyi2CYK9rDZCeoKF+dIPF/MUL8HxAOa3H72Gmu1lC4yKlho6t1PwNr/QpDVqaNEZQ==}
|
||||
engines: {node: '>=12.22.0'}
|
||||
@@ -3992,6 +4020,10 @@ packages:
|
||||
concat-map@0.0.1:
|
||||
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
||||
|
||||
concat-stream@2.0.0:
|
||||
resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==}
|
||||
engines: {'0': node >= 6.0}
|
||||
|
||||
conf@15.1.0:
|
||||
resolution: {integrity: sha512-Uy5YN9KEu0WWDaZAVJ5FAmZoaJt9rdK6kH+utItPyGsCqCgaTKkrmZx3zoE0/3q6S3bcp3Ihkk+ZqPxWxFK5og==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -4020,6 +4052,10 @@ packages:
|
||||
country-flag-icons@1.6.17:
|
||||
resolution: {integrity: sha512-Nmik0289ZVZSI3c7mJR/amg6DyY7Z59b0sTFSKayeX72mHfPzCPJygwJs2pYgQULzuAyWeCUgwAJ+Dq8OR+JFw==}
|
||||
|
||||
cpu-features@0.0.10:
|
||||
resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
crc-32@1.2.2:
|
||||
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
|
||||
engines: {node: '>=0.8'}
|
||||
@@ -4226,6 +4262,9 @@ packages:
|
||||
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
docx-preview@0.3.7:
|
||||
resolution: {integrity: sha512-Lav69CTA/IYZPJTsKH7oYeoZjyg96N0wEJMNslGJnZJ+dMUZK85Lt5ASC79yUlD48ecWjuv+rkcmFt6EVPV0Xg==}
|
||||
|
||||
dom-serializer@2.0.0:
|
||||
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
|
||||
|
||||
@@ -5717,6 +5756,9 @@ packages:
|
||||
msgpackr@2.0.1:
|
||||
resolution: {integrity: sha512-9J+tqTEsbHqY8YohazYgty7LgerFIWxvMLpUjqETSmjHojtJm2WnX2kK/2a1fLI7CO7ERP1YSEUXMucz4j+yBA==}
|
||||
|
||||
nan@2.27.0:
|
||||
resolution: {integrity: sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==}
|
||||
|
||||
nanoid@3.3.12:
|
||||
resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
@@ -6623,6 +6665,14 @@ packages:
|
||||
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||
engines: {node: '>= 10.x'}
|
||||
|
||||
ssh2-sftp-client@12.1.1:
|
||||
resolution: {integrity: sha512-wYVDgwkpcKG2iPGQQ+QR33xkWqLFIaVrYvA+uON4pmxTPaPuB81f1aooUEPN75e/9DCK6rrKYXb6zR6zP3+EtA==}
|
||||
engines: {node: '>=18.20.4'}
|
||||
|
||||
ssh2@1.17.0:
|
||||
resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==}
|
||||
engines: {node: '>=10.16.0'}
|
||||
|
||||
stable-hash@0.0.5:
|
||||
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
|
||||
|
||||
@@ -6960,6 +7010,9 @@ packages:
|
||||
tw-animate-css@1.4.0:
|
||||
resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
|
||||
|
||||
tweetnacl@0.14.5:
|
||||
resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==}
|
||||
|
||||
type-check@0.4.0:
|
||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -6988,6 +7041,9 @@ packages:
|
||||
resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
typedarray@0.0.6:
|
||||
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
|
||||
|
||||
typescript-eslint@8.59.3:
|
||||
resolution: {integrity: sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -7015,6 +7071,9 @@ packages:
|
||||
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
undici-types@5.26.5:
|
||||
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
|
||||
|
||||
undici-types@6.21.0:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
@@ -9984,6 +10043,10 @@ snapshots:
|
||||
|
||||
'@types/node@14.18.63': {}
|
||||
|
||||
'@types/node@18.19.130':
|
||||
dependencies:
|
||||
undici-types: 5.26.5
|
||||
|
||||
'@types/node@20.19.41':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
@@ -10024,6 +10087,14 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 20.19.41
|
||||
|
||||
'@types/ssh2-sftp-client@9.0.6':
|
||||
dependencies:
|
||||
'@types/ssh2': 1.15.5
|
||||
|
||||
'@types/ssh2@1.15.5':
|
||||
dependencies:
|
||||
'@types/node': 18.19.130
|
||||
|
||||
'@types/tedious@4.0.14':
|
||||
dependencies:
|
||||
'@types/node': 20.19.41
|
||||
@@ -10576,6 +10647,10 @@ snapshots:
|
||||
get-intrinsic: 1.3.0
|
||||
is-array-buffer: 3.0.5
|
||||
|
||||
asn1@0.2.6:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
ast-types-flow@0.0.8: {}
|
||||
@@ -10657,6 +10732,10 @@ snapshots:
|
||||
|
||||
baseline-browser-mapping@2.10.29: {}
|
||||
|
||||
bcrypt-pbkdf@1.0.2:
|
||||
dependencies:
|
||||
tweetnacl: 0.14.5
|
||||
|
||||
better-auth@1.6.11(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.17)(postgres@3.4.9))(mongodb@7.1.0(socks@2.8.8))(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(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.6):
|
||||
dependencies:
|
||||
'@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)
|
||||
@@ -10797,6 +10876,9 @@ snapshots:
|
||||
|
||||
buffers@0.1.1: {}
|
||||
|
||||
buildcheck@0.0.7:
|
||||
optional: true
|
||||
|
||||
bullmq@5.76.8:
|
||||
dependencies:
|
||||
cron-parser: 4.9.0
|
||||
@@ -10930,6 +11012,13 @@ snapshots:
|
||||
|
||||
concat-map@0.0.1: {}
|
||||
|
||||
concat-stream@2.0.0:
|
||||
dependencies:
|
||||
buffer-from: 1.1.2
|
||||
inherits: 2.0.4
|
||||
readable-stream: 3.6.2
|
||||
typedarray: 0.0.6
|
||||
|
||||
conf@15.1.0:
|
||||
dependencies:
|
||||
ajv: 8.20.0
|
||||
@@ -10965,6 +11054,12 @@ snapshots:
|
||||
|
||||
country-flag-icons@1.6.17: {}
|
||||
|
||||
cpu-features@0.0.10:
|
||||
dependencies:
|
||||
buildcheck: 0.0.7
|
||||
nan: 2.27.0
|
||||
optional: true
|
||||
|
||||
crc-32@1.2.2: {}
|
||||
|
||||
crc32-stream@4.0.3:
|
||||
@@ -11146,6 +11241,10 @@ snapshots:
|
||||
dependencies:
|
||||
esutils: 2.0.3
|
||||
|
||||
docx-preview@0.3.7:
|
||||
dependencies:
|
||||
jszip: 3.10.1
|
||||
|
||||
dom-serializer@2.0.0:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
@@ -12726,6 +12825,9 @@ snapshots:
|
||||
optionalDependencies:
|
||||
msgpackr-extract: 3.0.3
|
||||
|
||||
nan@2.27.0:
|
||||
optional: true
|
||||
|
||||
nanoid@3.3.12: {}
|
||||
|
||||
nanostores@1.3.0: {}
|
||||
@@ -13755,6 +13857,19 @@ snapshots:
|
||||
|
||||
split2@4.2.0: {}
|
||||
|
||||
ssh2-sftp-client@12.1.1:
|
||||
dependencies:
|
||||
concat-stream: 2.0.0
|
||||
ssh2: 1.17.0
|
||||
|
||||
ssh2@1.17.0:
|
||||
dependencies:
|
||||
asn1: 0.2.6
|
||||
bcrypt-pbkdf: 1.0.2
|
||||
optionalDependencies:
|
||||
cpu-features: 0.0.10
|
||||
nan: 2.27.0
|
||||
|
||||
stable-hash@0.0.5: {}
|
||||
|
||||
stackback@0.0.2: {}
|
||||
@@ -14093,6 +14208,8 @@ snapshots:
|
||||
|
||||
tw-animate-css@1.4.0: {}
|
||||
|
||||
tweetnacl@0.14.5: {}
|
||||
|
||||
type-check@0.4.0:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
@@ -14136,6 +14253,8 @@ snapshots:
|
||||
possible-typed-array-names: 1.1.0
|
||||
reflect.getprototypeof: 1.0.10
|
||||
|
||||
typedarray@0.0.6: {}
|
||||
|
||||
typescript-eslint@8.59.3(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3):
|
||||
dependencies:
|
||||
'@typescript-eslint/eslint-plugin': 8.59.3(@typescript-eslint/parser@8.59.3(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)
|
||||
@@ -14162,6 +14281,8 @@ snapshots:
|
||||
has-symbols: 1.1.0
|
||||
which-boxed-primitive: 1.1.1
|
||||
|
||||
undici-types@5.26.5: {}
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
|
||||
undici@7.25.0: {}
|
||||
|
||||
48
scripts/create-full-backup.ts
Normal file
48
scripts/create-full-backup.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Produce a full disaster-recovery bundle (db.dump + every blob + manifest.json)
|
||||
* to a local file. Same code path as the admin "Download full backup" button
|
||||
* (`createFullBackupTar`), minus the HTTP layer — for headless/ops use and for
|
||||
* rehearsing the restore runbook (docs/backup-restore-runbook.md).
|
||||
*
|
||||
* pnpm tsx scripts/create-full-backup.ts [outfile.tar]
|
||||
*
|
||||
* Defaults the output name to ./pn-crm-backup-<timestamp>.tar in the CWD.
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
|
||||
import { copyFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { createFullBackupTar } from '@/lib/services/backup-export.service';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const { tarPath, filename, manifest, cleanup } = await createFullBackupTar();
|
||||
try {
|
||||
const dest = path.resolve(process.argv[2] ?? filename);
|
||||
await copyFile(tarPath, dest);
|
||||
logger.info(
|
||||
{
|
||||
dest,
|
||||
storageBackend: manifest.storageBackend,
|
||||
dbDumpBytes: manifest.database.sizeBytes,
|
||||
blobs: manifest.counts.blobs,
|
||||
blobBytes: manifest.counts.blobBytes,
|
||||
skipped: manifest.counts.skipped,
|
||||
},
|
||||
'Full backup written',
|
||||
);
|
||||
if (manifest.skipped.length) {
|
||||
logger.warn({ skipped: manifest.skipped }, 'Some referenced blobs were missing in storage');
|
||||
}
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => process.stdout.write('', () => process.exit(0)))
|
||||
.catch((err) => {
|
||||
logger.error({ err }, 'Full backup failed');
|
||||
process.stderr.write('', () => process.exit(1));
|
||||
});
|
||||
31
scripts/decrypt-backup.ts
Normal file
31
scripts/decrypt-backup.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Decrypt an encrypted backup bundle (`*.tar.enc`) produced when a destination
|
||||
* has bundle encryption enabled. Restore step — see
|
||||
* docs/backup-restore-runbook.md.
|
||||
*
|
||||
* BACKUP_PASSPHRASE='…' pnpm tsx scripts/decrypt-backup.ts <in.tar.enc> <out.tar>
|
||||
*
|
||||
* The passphrase is read from $BACKUP_PASSPHRASE (not argv, to keep it out of
|
||||
* shell history / the process list).
|
||||
*/
|
||||
import { decryptFileToFile } from '@/lib/services/backup-destinations/bundle-encryption';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const [input, output] = process.argv.slice(2);
|
||||
const passphrase = process.env.BACKUP_PASSPHRASE;
|
||||
if (!input || !output) {
|
||||
throw new Error(
|
||||
'Usage: BACKUP_PASSPHRASE=… pnpm tsx scripts/decrypt-backup.ts <in.tar.enc> <out.tar>',
|
||||
);
|
||||
}
|
||||
if (!passphrase) throw new Error('Set BACKUP_PASSPHRASE in the environment');
|
||||
await decryptFileToFile(input, output, passphrase);
|
||||
process.stdout.write(`Decrypted → ${output}\n`, () => process.exit(0));
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
process.stderr.write(
|
||||
`Decrypt failed: ${err instanceof Error ? err.message : String(err)}\n`,
|
||||
() => process.exit(1),
|
||||
);
|
||||
});
|
||||
176
scripts/import-website-inquiries-from-nocodb.ts
Normal file
176
scripts/import-website-inquiries-from-nocodb.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* One-off import of historical "Website – Contact Form Submissions" from NocoDB
|
||||
* into the CRM `website_submissions` table, so they show up in the Inquiries
|
||||
* workbench alongside post-cutover submissions.
|
||||
*
|
||||
* The cutover migration imported interests / residential / berths / expenses but
|
||||
* NOT the contact-form table — those general contact-page inquiries (the
|
||||
* "broker"/"investor"/"owner" enquiries) were left behind in NocoDB.
|
||||
*
|
||||
* Idempotent: each row maps to a deterministic `submission_id`
|
||||
* (`nocodb-cf-<id>`) guarded by the unique index, plus a `migration_source_links`
|
||||
* ledger row (`source_system='nocodb_website_submissions'`). Re-running is a
|
||||
* no-op for already-imported rows.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm tsx scripts/import-website-inquiries-from-nocodb.ts # dry-run
|
||||
* pnpm tsx scripts/import-website-inquiries-from-nocodb.ts --apply # write
|
||||
* pnpm tsx scripts/import-website-inquiries-from-nocodb.ts --apply --port-slug port-nimara
|
||||
*
|
||||
* Requires NOCODB_URL + NOCODB_TOKEN in env (same as the migration). Writes to
|
||||
* whatever DATABASE_URL points at — point it at prod ONLY with explicit approval.
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import { db, closeDb } from '@/lib/db';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { websiteSubmissions } from '@/lib/db/schema/website-submissions';
|
||||
import { migrationSourceLinks } from '@/lib/db/schema/migration';
|
||||
import {
|
||||
loadNocoDbConfig,
|
||||
fetchAllRows,
|
||||
NOCO_TABLES,
|
||||
type NocoDbRow,
|
||||
} from '@/lib/dedup/nocodb-source';
|
||||
|
||||
const SOURCE_SYSTEM = 'nocodb_website_submissions';
|
||||
const APPLIED_ID = 'import-website-inquiries';
|
||||
|
||||
function arg(name: string): string | undefined {
|
||||
const hit = process.argv.find((a) => a.startsWith(`--${name}=`));
|
||||
if (hit) return hit.split('=')[1];
|
||||
const idx = process.argv.indexOf(`--${name}`);
|
||||
if (idx !== -1 && process.argv[idx + 1] && !process.argv[idx + 1]!.startsWith('--')) {
|
||||
return process.argv[idx + 1];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function str(row: NocoDbRow, ...keys: string[]): string {
|
||||
for (const k of keys) {
|
||||
const v = row[k];
|
||||
if (typeof v === 'string' && v.trim()) return v.trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function parseDate(row: NocoDbRow): Date {
|
||||
const raw = str(row, 'CreatedAt', 'created_at', 'Created At', 'createdAt');
|
||||
if (raw) {
|
||||
const d = new Date(raw);
|
||||
if (!Number.isNaN(d.getTime())) return d;
|
||||
}
|
||||
return new Date();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const apply = process.argv.includes('--apply');
|
||||
const portSlug = arg('port-slug') ?? 'port-nimara';
|
||||
|
||||
const [port] = await db
|
||||
.select({ id: ports.id })
|
||||
.from(ports)
|
||||
.where(eq(ports.slug, portSlug))
|
||||
.limit(1);
|
||||
if (!port) throw new Error(`Unknown port slug: ${portSlug}`);
|
||||
|
||||
const config = loadNocoDbConfig();
|
||||
console.log(`[import] Fetching contact-form submissions from NocoDB…`);
|
||||
const rows = await fetchAllRows(NOCO_TABLES.websiteContactFormSubmissions, config);
|
||||
console.log(`[import] Fetched ${rows.length} rows from NocoDB.`);
|
||||
|
||||
let inserted = 0;
|
||||
let skipped = 0;
|
||||
const samples: Array<Record<string, unknown>> = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const legacyId = String(row.Id);
|
||||
const submissionId = `nocodb-cf-${legacyId}`;
|
||||
const fullName = str(row, 'Full Name', 'Name', 'full_name');
|
||||
const email = str(row, 'Email Address', 'Email', 'email');
|
||||
const interest = str(row, 'Type of Interest', 'interest');
|
||||
const comments = str(row, 'Comments', 'comments');
|
||||
const receivedAt = parseDate(row);
|
||||
|
||||
const payload = {
|
||||
name: fullName,
|
||||
email,
|
||||
interest,
|
||||
comments,
|
||||
imported_from: 'nocodb_contact_form',
|
||||
legacy_nocodb_id: legacyId,
|
||||
};
|
||||
|
||||
if (samples.length < 3) {
|
||||
samples.push({
|
||||
submissionId,
|
||||
fullName,
|
||||
email,
|
||||
interest,
|
||||
receivedAt: receivedAt.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
if (!apply) {
|
||||
// Dry-run: count how many are not yet present.
|
||||
const [existing] = await db
|
||||
.select({ id: websiteSubmissions.id })
|
||||
.from(websiteSubmissions)
|
||||
.where(eq(websiteSubmissions.submissionId, submissionId))
|
||||
.limit(1);
|
||||
if (existing) skipped += 1;
|
||||
else inserted += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = await db
|
||||
.insert(websiteSubmissions)
|
||||
.values({
|
||||
portId: port.id,
|
||||
submissionId,
|
||||
kind: 'contact_form',
|
||||
payload,
|
||||
contactName: fullName || null,
|
||||
contactEmail: email || null,
|
||||
legacyNocodbId: legacyId,
|
||||
receivedAt,
|
||||
triageState: 'open',
|
||||
})
|
||||
.onConflictDoNothing({ target: websiteSubmissions.submissionId })
|
||||
.returning({ id: websiteSubmissions.id });
|
||||
|
||||
if (result[0]) {
|
||||
inserted += 1;
|
||||
await db
|
||||
.insert(migrationSourceLinks)
|
||||
.values({
|
||||
sourceSystem: SOURCE_SYSTEM,
|
||||
sourceId: legacyId,
|
||||
targetEntityType: 'website_submission',
|
||||
targetEntityId: result[0].id,
|
||||
appliedId: APPLIED_ID,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
} else {
|
||||
skipped += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n[import] Sample rows:');
|
||||
for (const s of samples) console.log(' ', JSON.stringify(s));
|
||||
console.log(
|
||||
`\n[import] ${apply ? 'APPLIED' : 'DRY-RUN'} — port=${portSlug}: ${inserted} ${
|
||||
apply ? 'inserted' : 'would insert'
|
||||
}, ${skipped} skipped (already present).`,
|
||||
);
|
||||
if (!apply) console.log('[import] Re-run with --apply to write these rows.');
|
||||
|
||||
await closeDb();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[import] FAILED:', err);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BackupAdminPanel } from '@/components/admin/backup-admin-panel';
|
||||
import { BackupDestinationsCard } from '@/components/admin/backup-destinations-card';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
|
||||
export default function BackupManagementPage() {
|
||||
@@ -7,9 +8,10 @@ export default function BackupManagementPage() {
|
||||
<PageHeader
|
||||
title="Backup & Restore"
|
||||
eyebrow="ADMIN"
|
||||
description="Trigger ad-hoc database snapshots, browse the history, and download a .dump file for offline restore."
|
||||
description="Download a full backup, configure where automated backups are pushed, and browse history. Restore steps live in docs/backup-restore-runbook.md."
|
||||
/>
|
||||
<BackupAdminPanel />
|
||||
<BackupDestinationsCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Link from 'next/link';
|
||||
import type { Route } from 'next';
|
||||
import { AlertCircle, Anchor, FileSearch } from 'lucide-react';
|
||||
import { AlertCircle, Anchor, FileSearch, BadgeDollarSign } from 'lucide-react';
|
||||
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -33,6 +33,13 @@ export default async function BerthsAdminIndex({
|
||||
"Berths missing required fields after import / PDF parse. Surface what's missing per row and link straight to the edit sheet.",
|
||||
icon: FileSearch,
|
||||
},
|
||||
{
|
||||
href: `/${portSlug}/admin/berths/price-reconcile` as Route,
|
||||
label: 'Price reconciliation',
|
||||
description:
|
||||
'Parse the purchase price from each berth’s current spec sheet and review old→new per berth. Approve per row or in bulk; nothing is written until you approve.',
|
||||
icon: BadgeDollarSign,
|
||||
},
|
||||
] as const;
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { BerthPriceReconcileTable } from '@/components/berths/berth-price-reconcile-table';
|
||||
|
||||
export default function BerthPriceReconcilePage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Berth price reconciliation"
|
||||
eyebrow="ADMIN"
|
||||
description="Prices parsed from each berth's current spec sheet, shown against the stored price. Review the changes and approve the ones you trust — nothing is written until you approve it."
|
||||
/>
|
||||
<BerthPriceReconcileTable />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { TemplateSyncButton } from '@/components/admin/documenso/template-sync-b
|
||||
import { WebhookHealthCard } from '@/components/admin/documenso/webhook-health-card';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { WarningCallout } from '@/components/ui/warning-callout';
|
||||
|
||||
// All field arrays removed - every Documenso setting now flows through
|
||||
// `RegistryDrivenForm`, which surfaces the env-fallback / port / global
|
||||
@@ -22,6 +23,35 @@ export default function DocumensoSettingsPage() {
|
||||
description="API credentials, signer identities, templates, and signing behaviour for every document the CRM puts out for signature (EOI, reservation, contract, custom uploads). Use the test-connection button to verify a saved configuration before relying on it."
|
||||
/>
|
||||
|
||||
<WarningCallout title="Use Documenso v2, not v1 (v1 API is deprecated)">
|
||||
<p>
|
||||
The CRM's signing features are built for Documenso 2.x (v2). Set the API version
|
||||
below to <strong>v1</strong> only if this port still points at a Documenso 1.13.x server.
|
||||
Be aware these CRM functions <strong>do not work (or run degraded)</strong> on v1:
|
||||
</p>
|
||||
<ul className="ms-4 mt-1 list-disc space-y-1">
|
||||
<li>
|
||||
<strong>Editing an envelope after it is created</strong> (title, subject, redirect URL):
|
||||
hard-fails, because v1 has no <code>/envelope/update</code> endpoint.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Upload-and-send contracts / reservations</strong> fall back to v1's
|
||||
per-field placement: page size is assumed to be A4, and rich field metadata (required
|
||||
flags, NUMBER min/max, CHECKBOX / DROPDOWN / RADIO option lists) is dropped.
|
||||
</li>
|
||||
<li>
|
||||
<strong>One-call send with per-recipient signing links</strong>,{' '}
|
||||
<strong>sequential signing enforcement</strong>, and the{' '}
|
||||
<strong>v2 webhook events</strong> (recipient viewed / signed, declined, reminder sent)
|
||||
are unavailable or ignored on v1.
|
||||
</li>
|
||||
</ul>
|
||||
<p className="mt-1">
|
||||
Recommended: upgrade the Documenso server to 2.x, then set the API version to v2 and run
|
||||
the test-connection button to confirm.
|
||||
</p>
|
||||
</WarningCallout>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { InquiryInbox } from '@/components/admin/inquiry-inbox';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function InquiriesPage() {
|
||||
return <InquiryInbox />;
|
||||
/**
|
||||
* The inquiry inbox is now a top-level, permission-gated page at
|
||||
* `/[portSlug]/inquiries` (resource `inquiries`), no longer admin-only.
|
||||
* Redirect the legacy admin URL so old bookmarks/links still land.
|
||||
*/
|
||||
interface AdminInquiriesRedirectProps {
|
||||
params: Promise<{ portSlug: string }>;
|
||||
}
|
||||
|
||||
export default async function AdminInquiriesRedirect({ params }: AdminInquiriesRedirectProps) {
|
||||
const { portSlug } = await params;
|
||||
redirect(`/${portSlug}/inquiries`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ClientGroupDetail } from '@/components/client-groups/client-group-detail';
|
||||
|
||||
export default async function ClientGroupDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ portSlug: string; groupId: string }>;
|
||||
}) {
|
||||
const { groupId } = await params;
|
||||
return <ClientGroupDetail groupId={groupId} />;
|
||||
}
|
||||
5
src/app/(dashboard)/[portSlug]/client-groups/page.tsx
Normal file
5
src/app/(dashboard)/[portSlug]/client-groups/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ClientGroupsList } from '@/components/client-groups/client-groups-list';
|
||||
|
||||
export default function ClientGroupsPage() {
|
||||
return <ClientGroupsList />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ClientsByCountryPage } from '@/components/clients/clients-by-country-page';
|
||||
|
||||
export default function ClientsByCountryRoute() {
|
||||
return <ClientsByCountryPage />;
|
||||
}
|
||||
11
src/app/(dashboard)/[portSlug]/inquiries/[id]/loading.tsx
Normal file
11
src/app/(dashboard)/[portSlug]/inquiries/[id]/loading.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
src/app/(dashboard)/[portSlug]/inquiries/[id]/page.tsx
Normal file
10
src/app/(dashboard)/[portSlug]/inquiries/[id]/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { InquiryDetail } from '@/components/inquiries/inquiry-detail';
|
||||
|
||||
interface InquiryDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function InquiryDetailPage({ params }: InquiryDetailPageProps) {
|
||||
const { id } = await params;
|
||||
return <InquiryDetail id={id} />;
|
||||
}
|
||||
5
src/app/(dashboard)/[portSlug]/inquiries/page.tsx
Normal file
5
src/app/(dashboard)/[portSlug]/inquiries/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { InquiryList } from '@/components/inquiries/inquiry-list';
|
||||
|
||||
export default function InquiriesPage() {
|
||||
return <InquiryList />;
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { getPortBrandingConfig } from '@/lib/services/port-config';
|
||||
import { isTenanciesModuleEnabled } from '@/lib/services/tenancies-module.service';
|
||||
import { isExpensesModuleEnabled } from '@/lib/services/expenses-module.service';
|
||||
import { isResidentialModuleEnabled } from '@/lib/services/residential-module.service';
|
||||
import { isMaintenanceModuleEnabled } from '@/lib/services/maintenance-module.service';
|
||||
|
||||
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
const headerList = await headers();
|
||||
@@ -127,12 +128,29 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
||||
const residentialModuleByPort: Record<string, boolean> =
|
||||
Object.fromEntries(residentialModuleEntries);
|
||||
|
||||
// Per-port maintenance-module gate. Defaults to enabled (registry
|
||||
// default) so existing ports keep the berth Maintenance tab on deploy.
|
||||
// Resolved server-side so the tab SSRs in/out without flicker.
|
||||
const maintenanceModuleEntries = await Promise.all(
|
||||
ports.map(async (p) => {
|
||||
try {
|
||||
return [p.id, await isMaintenanceModuleEnabled(p.id)] as const;
|
||||
} catch {
|
||||
// Conservative default on lookup failure: keep the feature visible.
|
||||
return [p.id, true] as const;
|
||||
}
|
||||
}),
|
||||
);
|
||||
const maintenanceModuleByPort: Record<string, boolean> =
|
||||
Object.fromEntries(maintenanceModuleEntries);
|
||||
|
||||
return (
|
||||
<QueryProvider>
|
||||
<PortProvider
|
||||
ports={ports}
|
||||
defaultPortId={ports[0]?.id ?? null}
|
||||
tenanciesModuleByPort={tenanciesModuleByPort}
|
||||
maintenanceModuleByPort={maintenanceModuleByPort}
|
||||
>
|
||||
<PermissionsProvider>
|
||||
<SocketProvider>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ScanShell } from '@/components/scan/scan-shell';
|
||||
import { db } from '@/lib/db';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { getPortBrandingConfig } from '@/lib/services/port-config';
|
||||
import { getResolvedOcrConfig } from '@/lib/services/ocr-config.service';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Scan receipt',
|
||||
@@ -14,5 +15,14 @@ export default async function ScanPage({ params }: { params: Promise<{ portSlug:
|
||||
const { portSlug } = await params;
|
||||
const port = await db.query.ports.findFirst({ where: eq(ports.slug, portSlug) });
|
||||
const branding = port ? await getPortBrandingConfig(port.id).catch(() => null) : null;
|
||||
return <ScanShell logoUrl={branding?.logoUrl ?? null} portName={port?.name ?? null} />;
|
||||
// CM-6: manual-entry mode is resolved server-side so the client can skip
|
||||
// on-device parsing entirely (no wasted Tesseract pass) and open an empty form.
|
||||
const ocr = port ? await getResolvedOcrConfig(port.id).catch(() => null) : null;
|
||||
return (
|
||||
<ScanShell
|
||||
logoUrl={branding?.logoUrl ?? null}
|
||||
portName={port?.name ?? null}
|
||||
manualEntry={ocr?.manualEntry ?? false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
autoPromoteWebsiteBerthInquiry,
|
||||
isWebsiteBerthAutopromoteEnabled,
|
||||
} from '@/lib/services/website-intake-promote.service';
|
||||
import { extractInquiryFields } from '@/lib/services/website-intake-fields';
|
||||
|
||||
/**
|
||||
* POST /api/public/website-inquiries
|
||||
@@ -149,6 +150,10 @@ export async function POST(req: NextRequest) {
|
||||
// hits, `returning()` yields zero rows and we look up the existing row to
|
||||
// return its id, mirroring the first-delivery shape so the website never
|
||||
// sees a difference between fresh and dup.
|
||||
// Extract contact name/email into real columns so the inquiry list can
|
||||
// search/sort/display without digging into the JSONB payload per row.
|
||||
const fields = extractInquiryFields(parsed.payload);
|
||||
|
||||
const insertResult = await db
|
||||
.insert(websiteSubmissions)
|
||||
.values({
|
||||
@@ -157,6 +162,8 @@ export async function POST(req: NextRequest) {
|
||||
kind: parsed.kind,
|
||||
payload: parsed.payload,
|
||||
legacyNocodbId: parsed.legacy_nocodb_id ?? null,
|
||||
contactName: fields.fullName || null,
|
||||
contactEmail: fields.email || null,
|
||||
sourceIp: ip,
|
||||
userAgent: req.headers.get('user-agent') ?? null,
|
||||
utmSource: parsed.utm_source ?? null,
|
||||
|
||||
62
src/app/api/v1/admin/backup/destinations/[id]/route.ts
Normal file
62
src/app/api/v1/admin/backup/destinations/[id]/route.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { errorResponse, NotFoundError } from '@/lib/errors';
|
||||
import {
|
||||
deleteDestination,
|
||||
updateDestination,
|
||||
type DestinationInput,
|
||||
} from '@/lib/services/backup-destinations.service';
|
||||
import { backupDestinationSchema } from '@/lib/validators/backup-destinations';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/** Update a backup destination. Super-admin only. */
|
||||
export const PUT = withAuth(async (req, ctx, params) => {
|
||||
try {
|
||||
requireSuperAdmin(ctx, 'admin.backup.destinations.update');
|
||||
const id = params.id;
|
||||
if (!id) throw new NotFoundError('Backup destination');
|
||||
const body = await parseBody(req, backupDestinationSchema);
|
||||
const updated = await updateDestination(id, body as DestinationInput);
|
||||
await createAuditLog({
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
action: 'update',
|
||||
entityType: 'backup_destination',
|
||||
entityId: id,
|
||||
severity: 'warning',
|
||||
metadata: { name: updated.name, type: updated.type, enabled: updated.enabled },
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: updated });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
});
|
||||
|
||||
/** Delete a backup destination. Super-admin only. */
|
||||
export const DELETE = withAuth(async (_req, ctx, params) => {
|
||||
try {
|
||||
requireSuperAdmin(ctx, 'admin.backup.destinations.delete');
|
||||
const id = params.id;
|
||||
if (!id) throw new NotFoundError('Backup destination');
|
||||
await deleteDestination(id);
|
||||
await createAuditLog({
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
action: 'delete',
|
||||
entityType: 'backup_destination',
|
||||
entityId: id,
|
||||
severity: 'warning',
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return new NextResponse(null, { status: 204 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
});
|
||||
38
src/app/api/v1/admin/backup/destinations/[id]/run/route.ts
Normal file
38
src/app/api/v1/admin/backup/destinations/[id]/run/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { errorResponse, NotFoundError } from '@/lib/errors';
|
||||
import { pushBackupToDestination } from '@/lib/services/backup-destinations.service';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
// A full backup (pg_dump + every blob) is assembled before the push, so allow
|
||||
// a long run on large datasets.
|
||||
export const maxDuration = 3600;
|
||||
|
||||
/** Assemble a fresh full backup and push it to this destination now. Super-admin. */
|
||||
export const POST = withAuth(async (_req, ctx, params) => {
|
||||
try {
|
||||
requireSuperAdmin(ctx, 'admin.backup.destinations.run');
|
||||
const id = params.id;
|
||||
if (!id) throw new NotFoundError('Backup destination');
|
||||
const result = await pushBackupToDestination(id, {
|
||||
trigger: 'manual',
|
||||
triggeredBy: ctx.userId,
|
||||
});
|
||||
await createAuditLog({
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
action: 'backup_export',
|
||||
entityType: 'backup_destination',
|
||||
entityId: id,
|
||||
severity: 'warning',
|
||||
metadata: { bytes: result.bytes, remoteRef: result.remoteRef },
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: result });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
});
|
||||
24
src/app/api/v1/admin/backup/destinations/[id]/test/route.ts
Normal file
24
src/app/api/v1/admin/backup/destinations/[id]/test/route.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
|
||||
import { errorResponse, NotFoundError } from '@/lib/errors';
|
||||
import { testDestination } from '@/lib/services/backup-destinations.service';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* Test connectivity to a destination (connect + verify the target dir/bucket).
|
||||
* Returns `{ data: { ok: true } }` or a structured error the UI can surface.
|
||||
* Super-admin only.
|
||||
*/
|
||||
export const POST = withAuth(async (_req, ctx, params) => {
|
||||
try {
|
||||
requireSuperAdmin(ctx, 'admin.backup.destinations.test');
|
||||
const id = params.id;
|
||||
if (!id) throw new NotFoundError('Backup destination');
|
||||
await testDestination(id);
|
||||
return NextResponse.json({ data: { ok: true } });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
});
|
||||
47
src/app/api/v1/admin/backup/destinations/route.ts
Normal file
47
src/app/api/v1/admin/backup/destinations/route.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import {
|
||||
createDestination,
|
||||
listDestinations,
|
||||
type DestinationInput,
|
||||
} from '@/lib/services/backup-destinations.service';
|
||||
import { backupDestinationSchema } from '@/lib/validators/backup-destinations';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/** List configured backup destinations (secrets masked). Super-admin only. */
|
||||
export const GET = withAuth(async (_req, ctx) => {
|
||||
try {
|
||||
requireSuperAdmin(ctx, 'admin.backup.destinations.list');
|
||||
return NextResponse.json({ data: await listDestinations() });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
});
|
||||
|
||||
/** Create a backup destination. Super-admin only. */
|
||||
export const POST = withAuth(async (req, ctx) => {
|
||||
try {
|
||||
requireSuperAdmin(ctx, 'admin.backup.destinations.create');
|
||||
const body = await parseBody(req, backupDestinationSchema);
|
||||
const created = await createDestination(body as DestinationInput);
|
||||
await createAuditLog({
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
action: 'create',
|
||||
entityType: 'backup_destination',
|
||||
entityId: created.id,
|
||||
severity: 'warning',
|
||||
metadata: { name: created.name, type: created.type, enabled: created.enabled },
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: created }, { status: 201 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
});
|
||||
71
src/app/api/v1/admin/backup/export/route.ts
Normal file
71
src/app/api/v1/admin/backup/export/route.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { createFullBackupTar } from '@/lib/services/backup-export.service';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
// A full backup pg_dumps the DB and streams every blob; on a large dataset the
|
||||
// assembly phase can run for a while before the download starts. Lift the
|
||||
// platform timeout accordingly (no-op on hosts without a hard cap).
|
||||
export const maxDuration = 3600;
|
||||
|
||||
/**
|
||||
* Stream a full disaster-recovery bundle (db.dump + all blobs + manifest.json)
|
||||
* as a tar download. Super-admin only — this egresses every tenant's data.
|
||||
*
|
||||
* The bundle is assembled to a temp file first, so any failure (pg_dump,
|
||||
* storage read) surfaces as a clean JSON error *before* the download begins
|
||||
* rather than as a truncated tar. The temp file is removed once the response
|
||||
* stream closes (including on client disconnect).
|
||||
*/
|
||||
export const GET = withAuth(async (_req, ctx) => {
|
||||
try {
|
||||
requireSuperAdmin(ctx, 'admin.backup.export');
|
||||
|
||||
const { tarPath, filename, manifest, cleanup } = await createFullBackupTar();
|
||||
|
||||
await createAuditLog({
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
action: 'backup_export',
|
||||
entityType: 'system_backup',
|
||||
entityId: filename,
|
||||
severity: 'warning',
|
||||
source: 'user',
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
metadata: {
|
||||
storageBackend: manifest.storageBackend,
|
||||
blobs: manifest.counts.blobs,
|
||||
blobBytes: manifest.counts.blobBytes,
|
||||
skipped: manifest.counts.skipped,
|
||||
dbDumpBytes: manifest.database.sizeBytes,
|
||||
},
|
||||
});
|
||||
|
||||
const { size } = await stat(tarPath);
|
||||
const nodeStream = createReadStream(tarPath);
|
||||
// Remove the temp tar once it's been fully sent or the client bails.
|
||||
nodeStream.on('close', () => void cleanup());
|
||||
nodeStream.on('error', () => void cleanup());
|
||||
|
||||
const webStream = Readable.toWeb(nodeStream) as ReadableStream<Uint8Array>;
|
||||
return new NextResponse(webStream, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/x-tar',
|
||||
'Content-Length': String(size),
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
});
|
||||
43
src/app/api/v1/admin/backup/schedule/route.ts
Normal file
43
src/app/api/v1/admin/backup/schedule/route.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { getSchedule, setSchedule } from '@/lib/services/backup-destinations.service';
|
||||
import { backupScheduleSchema } from '@/lib/validators/backup-destinations';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/** Read the global automated-backup schedule. Super-admin only. */
|
||||
export const GET = withAuth(async (_req, ctx) => {
|
||||
try {
|
||||
requireSuperAdmin(ctx, 'admin.backup.schedule.get');
|
||||
return NextResponse.json({ data: { schedule: await getSchedule() } });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
});
|
||||
|
||||
/** Set the global automated-backup schedule (off | daily | weekly). Super-admin. */
|
||||
export const PUT = withAuth(async (req, ctx) => {
|
||||
try {
|
||||
requireSuperAdmin(ctx, 'admin.backup.schedule.set');
|
||||
const { schedule } = await parseBody(req, backupScheduleSchema);
|
||||
await setSchedule(schedule, ctx.userId);
|
||||
await createAuditLog({
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
action: 'update',
|
||||
entityType: 'backup_schedule',
|
||||
entityId: 'global',
|
||||
severity: 'warning',
|
||||
metadata: { schedule },
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: { schedule } });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
});
|
||||
@@ -15,6 +15,7 @@ const saveSchema = z.object({
|
||||
clearApiKey: z.boolean().optional(),
|
||||
useGlobal: z.boolean().optional(),
|
||||
aiEnabled: z.boolean().optional(),
|
||||
manualEntry: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// Only role tiers that hold `admin.manage_settings` (director / super_admin)
|
||||
@@ -58,6 +59,7 @@ export const PUT = withAuth(
|
||||
clearApiKey: body.clearApiKey,
|
||||
useGlobal: body.useGlobal,
|
||||
aiEnabled: body.aiEnabled,
|
||||
manualEntry: body.manualEntry,
|
||||
},
|
||||
ctx.userId,
|
||||
);
|
||||
|
||||
23
src/app/api/v1/alerts/dismiss-all/route.ts
Normal file
23
src/app/api/v1/alerts/dismiss-all/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { ALERT_RULES } from '@/lib/db/schema/insights';
|
||||
import { dismissAllForPort } from '@/lib/services/alerts.service';
|
||||
|
||||
const bodySchema = z.object({
|
||||
ruleId: z.enum(ALERT_RULES).optional(),
|
||||
severity: z.enum(['info', 'warning', 'critical']).optional(),
|
||||
});
|
||||
|
||||
export const POST = withAuth(async (req, ctx) => {
|
||||
try {
|
||||
const { ruleId, severity } = await parseBody(req, bodySchema);
|
||||
const dismissed = await dismissAllForPort(ctx.portId, ctx.userId, { ruleId, severity });
|
||||
return NextResponse.json({ data: { dismissed } });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
});
|
||||
@@ -4,12 +4,14 @@ import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { updateMaintenanceLogSchema } from '@/lib/validators/berths';
|
||||
import { updateMaintenanceLog, deleteMaintenanceLog } from '@/lib/services/berths.service';
|
||||
import { assertMaintenanceModuleEnabled } from '@/lib/services/maintenance-module.service';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
|
||||
// PATCH /api/v1/berths/[id]/maintenance/[logId]
|
||||
export const PATCH = withAuth(
|
||||
withPermission('berths', 'edit', async (req, ctx, params) => {
|
||||
try {
|
||||
await assertMaintenanceModuleEnabled(ctx.portId);
|
||||
const body = await parseBody(req, updateMaintenanceLogSchema);
|
||||
const log = await updateMaintenanceLog(params.id!, params.logId!, ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
@@ -28,6 +30,7 @@ export const PATCH = withAuth(
|
||||
export const DELETE = withAuth(
|
||||
withPermission('berths', 'edit', async (_req, ctx, params) => {
|
||||
try {
|
||||
await assertMaintenanceModuleEnabled(ctx.portId);
|
||||
await deleteMaintenanceLog(params.id!, params.logId!, ctx.portId, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
|
||||
@@ -4,12 +4,14 @@ import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { addMaintenanceLogSchema } from '@/lib/validators/berths';
|
||||
import { getMaintenanceLogs, addMaintenanceLog } from '@/lib/services/berths.service';
|
||||
import { assertMaintenanceModuleEnabled } from '@/lib/services/maintenance-module.service';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
|
||||
// GET /api/v1/berths/[id]/maintenance
|
||||
export const GET = withAuth(
|
||||
withPermission('berths', 'view', async (req, ctx, params) => {
|
||||
try {
|
||||
await assertMaintenanceModuleEnabled(ctx.portId);
|
||||
const logs = await getMaintenanceLogs(params.id!, ctx.portId);
|
||||
return NextResponse.json({ data: logs });
|
||||
} catch (error) {
|
||||
@@ -22,6 +24,7 @@ export const GET = withAuth(
|
||||
export const POST = withAuth(
|
||||
withPermission('berths', 'edit', async (req, ctx, params) => {
|
||||
try {
|
||||
await assertMaintenanceModuleEnabled(ctx.portId);
|
||||
const body = await parseBody(req, addMaintenanceLogSchema);
|
||||
const log = await addMaintenanceLog(params.id!, ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
|
||||
36
src/app/api/v1/berths/price-reconcile/apply/handlers.ts
Normal file
36
src/app/api/v1/berths/price-reconcile/apply/handlers.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Route handler for `/api/v1/berths/price-reconcile/apply` (CM-2 Part A).
|
||||
*
|
||||
* Writes a rep-approved slice of parsed prices to the berths. In handlers.ts so
|
||||
* integration tests can call it directly.
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type RouteHandler } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { applyBulkBerthPrices } from '@/lib/services/berth-price-reconcile.service';
|
||||
|
||||
const bodySchema = z.object({
|
||||
approvals: z
|
||||
.array(
|
||||
z.object({
|
||||
berthId: z.string().min(1),
|
||||
price: z.number().nonnegative(),
|
||||
currency: z.string().min(1).max(8),
|
||||
}),
|
||||
)
|
||||
.min(1),
|
||||
});
|
||||
|
||||
export const postHandler: RouteHandler = async (req, ctx) => {
|
||||
try {
|
||||
const body = await parseBody(req, bodySchema);
|
||||
const result = await applyBulkBerthPrices(ctx.portId, body.approvals, ctx.userId);
|
||||
return NextResponse.json({ data: result });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
5
src/app/api/v1/berths/price-reconcile/apply/route.ts
Normal file
5
src/app/api/v1/berths/price-reconcile/apply/route.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
|
||||
import { postHandler } from './handlers';
|
||||
|
||||
export const POST = withAuth(withPermission('berths', 'edit', postHandler));
|
||||
21
src/app/api/v1/berths/price-reconcile/handlers.ts
Normal file
21
src/app/api/v1/berths/price-reconcile/handlers.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Route handlers for `/api/v1/berths/price-reconcile` (CM-2 Part A).
|
||||
*
|
||||
* In handlers.ts so integration tests can call them directly, bypassing the
|
||||
* auth/permission middleware (per CLAUDE.md "Route handler exports").
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { type RouteHandler } from '@/lib/api/helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { listPriceReconciliation } from '@/lib/services/berth-price-reconcile.service';
|
||||
|
||||
export const getHandler: RouteHandler = async (_req, ctx) => {
|
||||
try {
|
||||
const data = await listPriceReconciliation(ctx.portId);
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
5
src/app/api/v1/berths/price-reconcile/route.ts
Normal file
5
src/app/api/v1/berths/price-reconcile/route.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
|
||||
import { getHandler } from './handlers';
|
||||
|
||||
export const GET = withAuth(withPermission('berths', 'edit', getHandler));
|
||||
49
src/app/api/v1/client-groups/[id]/handlers.ts
Normal file
49
src/app/api/v1/client-groups/[id]/handlers.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { type RouteHandler } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import {
|
||||
archiveClientGroup,
|
||||
getClientGroupById,
|
||||
updateClientGroup,
|
||||
} from '@/lib/services/client-groups.service';
|
||||
import { updateClientGroupSchema } from '@/lib/validators/client-groups';
|
||||
|
||||
export const getHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
const group = await getClientGroupById(params.id!, ctx.portId);
|
||||
return NextResponse.json({ data: group });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const patchHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
const body = await parseBody(req, updateClientGroupSchema);
|
||||
const updated = await updateClientGroup(params.id!, ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: updated });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
await archiveClientGroup(params.id!, ctx.portId, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return new NextResponse(null, { status: 204 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
31
src/app/api/v1/client-groups/[id]/members/handlers.ts
Normal file
31
src/app/api/v1/client-groups/[id]/members/handlers.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { type RouteHandler } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { listGroupMembers, setGroupMembers } from '@/lib/services/client-groups.service';
|
||||
import { setGroupMembersSchema } from '@/lib/validators/client-groups';
|
||||
|
||||
export const getMembersHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
const members = await listGroupMembers(params.id!, ctx.portId);
|
||||
return NextResponse.json({ data: members, total: members.length });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const putMembersHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
const { clientIds } = await parseBody(req, setGroupMembersSchema);
|
||||
await setGroupMembers(params.id!, ctx.portId, clientIds, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return new NextResponse(null, { status: 204 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
6
src/app/api/v1/client-groups/[id]/members/route.ts
Normal file
6
src/app/api/v1/client-groups/[id]/members/route.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
|
||||
import { getMembersHandler, putMembersHandler } from './handlers';
|
||||
|
||||
export const GET = withAuth(withPermission('client_groups', 'view', getMembersHandler));
|
||||
export const PUT = withAuth(withPermission('client_groups', 'manage', putMembersHandler));
|
||||
7
src/app/api/v1/client-groups/[id]/route.ts
Normal file
7
src/app/api/v1/client-groups/[id]/route.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
|
||||
import { getHandler, patchHandler, deleteHandler } from './handlers';
|
||||
|
||||
export const GET = withAuth(withPermission('client_groups', 'view', getHandler));
|
||||
export const PATCH = withAuth(withPermission('client_groups', 'manage', patchHandler));
|
||||
export const DELETE = withAuth(withPermission('client_groups', 'manage', deleteHandler));
|
||||
31
src/app/api/v1/client-groups/handlers.ts
Normal file
31
src/app/api/v1/client-groups/handlers.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { type RouteHandler } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { createClientGroup, listClientGroups } from '@/lib/services/client-groups.service';
|
||||
import { createClientGroupSchema } from '@/lib/validators/client-groups';
|
||||
|
||||
export const listHandler: RouteHandler = async (req, ctx) => {
|
||||
try {
|
||||
const groups = await listClientGroups(ctx.portId);
|
||||
return NextResponse.json({ data: groups, total: groups.length });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const createHandler: RouteHandler = async (req, ctx) => {
|
||||
try {
|
||||
const body = await parseBody(req, createClientGroupSchema);
|
||||
const group = await createClientGroup(ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: group }, { status: 201 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
6
src/app/api/v1/client-groups/route.ts
Normal file
6
src/app/api/v1/client-groups/route.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
|
||||
import { listHandler, createHandler } from './handlers';
|
||||
|
||||
export const GET = withAuth(withPermission('client_groups', 'view', listHandler));
|
||||
export const POST = withAuth(withPermission('client_groups', 'manage', createHandler));
|
||||
8
src/app/api/v1/clients/[id]/proxy/route.ts
Normal file
8
src/app/api/v1/clients/[id]/proxy/route.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { makeProxyHandlers } from '@/lib/api/proxy-route-handlers';
|
||||
|
||||
const { getHandler, putHandler, deleteHandler } = makeProxyHandlers('client');
|
||||
|
||||
export const GET = withAuth(withPermission('clients', 'view', getHandler));
|
||||
export const PUT = withAuth(withPermission('clients', 'edit', putHandler));
|
||||
export const DELETE = withAuth(withPermission('clients', 'edit', deleteHandler));
|
||||
21
src/app/api/v1/documents/[id]/send-signed-copy/route.ts
Normal file
21
src/app/api/v1/documents/[id]/send-signed-copy/route.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { sendSignedCopyToClient } from '@/lib/services/documents.service';
|
||||
|
||||
/**
|
||||
* Manually (re)send the finalized signed PDF to the deal's client. Backs
|
||||
* the "Send signed copy to client" affordance on the EOI tab + document
|
||||
* detail. Same `documents.edit` gate as the reminder endpoint.
|
||||
*/
|
||||
export const POST = withAuth(
|
||||
withPermission('documents', 'edit', async (_req, ctx, params) => {
|
||||
try {
|
||||
const result = await sendSignedCopyToClient(params.id!, ctx.portId);
|
||||
return NextResponse.json({ data: result });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -48,6 +48,14 @@ export const POST = withAuth(
|
||||
}
|
||||
|
||||
const config = await getResolvedOcrConfig(ctx.portId);
|
||||
// CM-6: manual-entry mode short-circuits ALL parsing - the operator
|
||||
// types the details by hand. The client should skip this route entirely
|
||||
// in manual mode, but we guard server-side too.
|
||||
if (config.manualEntry) {
|
||||
return NextResponse.json({
|
||||
data: { parsed: EMPTY, source: 'manual', reason: 'manual-mode' },
|
||||
});
|
||||
}
|
||||
// Tesseract.js (in-browser) is the default. The server only invokes
|
||||
// an AI provider when (a) the port admin has flipped `aiEnabled` on
|
||||
// and (b) a key resolves. Otherwise the client falls back to its
|
||||
|
||||
@@ -8,6 +8,13 @@ export const GET = withAuth(
|
||||
withPermission('files', 'view', async (req, ctx, params) => {
|
||||
try {
|
||||
const result = await getDownloadUrl(params.id!, ctx.portId);
|
||||
// `?redirect=1` → 302 straight to the presigned (attachment) URL so a
|
||||
// plain <a href>/<Link> downloads the file. Without it we return the
|
||||
// JSON envelope for programmatic consumers (e.g. fetch + anchor click).
|
||||
// Linking the browser at the JSON form used to dump raw `{data:{url}}`.
|
||||
if (new URL(req.url).searchParams.has('redirect')) {
|
||||
return NextResponse.redirect(result.url, 302);
|
||||
}
|
||||
return NextResponse.json({ data: result });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
|
||||
@@ -8,6 +8,12 @@ export const GET = withAuth(
|
||||
withPermission('files', 'view', async (req, ctx, params) => {
|
||||
try {
|
||||
const result = await getPreviewUrl(params.id!, ctx.portId);
|
||||
// `?redirect=1` → 302 to the presigned (inline) URL so a plain
|
||||
// <a href>/<Link> opens the file in the browser. Default returns the
|
||||
// JSON envelope for programmatic consumers (e.g. FilePreviewDialog).
|
||||
if (new URL(req.url).searchParams.has('redirect')) {
|
||||
return NextResponse.redirect(result.url, 302);
|
||||
}
|
||||
return NextResponse.json({ data: result });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
|
||||
30
src/app/api/v1/inquiries/[id]/convert/route.ts
Normal file
30
src/app/api/v1/inquiries/[id]/convert/route.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse, ValidationError } from '@/lib/errors';
|
||||
import { convertInquiryToClient, convertInquiryToInterest } from '@/lib/services/inquiries.service';
|
||||
import { convertInquirySchema } from '@/lib/validators/inquiries';
|
||||
|
||||
export const POST = withAuth(
|
||||
withPermission('inquiries', 'manage', async (req, ctx, params) => {
|
||||
try {
|
||||
const id = params.id;
|
||||
if (!id) throw new ValidationError('id is required');
|
||||
const { target } = await parseBody(req, convertInquirySchema);
|
||||
const meta = {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
};
|
||||
const data =
|
||||
target === 'interest'
|
||||
? await convertInquiryToInterest(id, ctx.portId, meta)
|
||||
: await convertInquiryToClient(id, ctx.portId, meta);
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
18
src/app/api/v1/inquiries/[id]/route.ts
Normal file
18
src/app/api/v1/inquiries/[id]/route.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { errorResponse, ValidationError } from '@/lib/errors';
|
||||
import { getInquiryById } from '@/lib/services/inquiries.service';
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('inquiries', 'view', async (_req, ctx, params) => {
|
||||
try {
|
||||
const id = params.id;
|
||||
if (!id) throw new ValidationError('id is required');
|
||||
const data = await getInquiryById(id, ctx.portId);
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
26
src/app/api/v1/inquiries/[id]/triage/route.ts
Normal file
26
src/app/api/v1/inquiries/[id]/triage/route.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse, ValidationError } from '@/lib/errors';
|
||||
import { triageInquiry } from '@/lib/services/inquiries.service';
|
||||
import { triageInquirySchema } from '@/lib/validators/inquiries';
|
||||
|
||||
export const PATCH = withAuth(
|
||||
withPermission('inquiries', 'manage', async (req, ctx, params) => {
|
||||
try {
|
||||
const id = params.id;
|
||||
if (!id) throw new ValidationError('id is required');
|
||||
const { state } = await parseBody(req, triageInquirySchema);
|
||||
const data = await triageInquiry(id, ctx.portId, state, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
33
src/app/api/v1/inquiries/route.ts
Normal file
33
src/app/api/v1/inquiries/route.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseQuery } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { listInquiries } from '@/lib/services/inquiries.service';
|
||||
import { listInquiriesSchema } from '@/lib/validators/inquiries';
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('inquiries', 'view', async (req, ctx) => {
|
||||
try {
|
||||
const query = parseQuery(req, listInquiriesSchema);
|
||||
const result = await listInquiries(ctx.portId, query);
|
||||
|
||||
const { page, limit } = query;
|
||||
const totalPages = Math.ceil(result.total / limit);
|
||||
|
||||
return NextResponse.json({
|
||||
data: result.data,
|
||||
pagination: {
|
||||
page,
|
||||
pageSize: limit,
|
||||
total: result.total,
|
||||
totalPages,
|
||||
hasNextPage: page < totalPages,
|
||||
hasPreviousPage: page > 1,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Route handler for `/api/v1/interests/[id]/berths/[berthId]/price` (CM-2 Part B).
|
||||
*
|
||||
* Sets or clears the deal-specific price override for one (interest, berth).
|
||||
* In handlers.ts so integration tests can call it directly.
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type RouteHandler } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { setBerthPriceOverride } from '@/lib/services/interest-berths.service';
|
||||
|
||||
const bodySchema = z.object({
|
||||
price: z.number().nonnegative().nullable(),
|
||||
currency: z.string().min(1).max(8).optional(),
|
||||
});
|
||||
|
||||
export const putHandler: RouteHandler<{ id: string; berthId: string }> = async (
|
||||
req,
|
||||
ctx,
|
||||
params,
|
||||
) => {
|
||||
try {
|
||||
const body = await parseBody(req, bodySchema);
|
||||
await setBerthPriceOverride(
|
||||
params.id!,
|
||||
params.berthId!,
|
||||
body.price,
|
||||
body.currency ?? null,
|
||||
ctx.portId,
|
||||
);
|
||||
return new NextResponse(null, { status: 204 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
|
||||
import { putHandler } from './handlers';
|
||||
|
||||
export const PUT = withAuth(withPermission('interests', 'edit', putHandler));
|
||||
8
src/app/api/v1/interests/[id]/proxy/route.ts
Normal file
8
src/app/api/v1/interests/[id]/proxy/route.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { makeProxyHandlers } from '@/lib/api/proxy-route-handlers';
|
||||
|
||||
const { getHandler, putHandler, deleteHandler } = makeProxyHandlers('interest');
|
||||
|
||||
export const GET = withAuth(withPermission('interests', 'view', getHandler));
|
||||
export const PUT = withAuth(withPermission('interests', 'edit', putHandler));
|
||||
export const DELETE = withAuth(withPermission('interests', 'edit', deleteHandler));
|
||||
@@ -15,7 +15,13 @@ export const GET = withAuth(async (req, ctx) => {
|
||||
where: and(eq(systemSettings.key, key), eq(systemSettings.portId, ctx.portId)),
|
||||
});
|
||||
|
||||
return NextResponse.json({ enabled: setting?.value === true });
|
||||
// `default` applies ONLY when the setting was never written for this
|
||||
// port (row absent). An explicit stored `false` always disables. Lets
|
||||
// default-ON settings (e.g. pulse_enabled) gate correctly via
|
||||
// ?default=true while default-OFF flags keep the old behaviour.
|
||||
const def = req.nextUrl.searchParams.get('default') === 'true';
|
||||
const enabled = setting ? setting.value === true : def;
|
||||
return NextResponse.json({ enabled });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
|
||||
8
src/app/api/v1/yachts/[id]/proxy/route.ts
Normal file
8
src/app/api/v1/yachts/[id]/proxy/route.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { makeProxyHandlers } from '@/lib/api/proxy-route-handlers';
|
||||
|
||||
const { getHandler, putHandler, deleteHandler } = makeProxyHandlers('yacht');
|
||||
|
||||
export const GET = withAuth(withPermission('yachts', 'view', getHandler));
|
||||
export const PUT = withAuth(withPermission('yachts', 'edit', putHandler));
|
||||
export const DELETE = withAuth(withPermission('yachts', 'edit', deleteHandler));
|
||||
@@ -1,7 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Loader2, Download, Database, RefreshCw, AlertTriangle } from 'lucide-react';
|
||||
import {
|
||||
Loader2,
|
||||
Download,
|
||||
Database,
|
||||
RefreshCw,
|
||||
AlertTriangle,
|
||||
HardDriveDownload,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -94,6 +101,18 @@ export function BackupAdminPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
function downloadFullBundle() {
|
||||
// Streams a tar (db.dump + every blob + manifest.json) straight to disk via
|
||||
// the GET endpoint (cookie auth). The server assembles the bundle before
|
||||
// the first byte arrives, so the browser download sits "pending" for a
|
||||
// moment on large datasets — flag that so it doesn't look stuck.
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
toast.info(
|
||||
'Preparing full backup — your download starts once the server finishes assembling it.',
|
||||
);
|
||||
triggerUrlDownload('/api/v1/admin/backup/export', `pn-crm-backup-${stamp}.tar`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
@@ -126,6 +145,32 @@ export function BackupAdminPanel() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-3 space-y-0">
|
||||
<div>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<HardDriveDownload className="h-4 w-4" aria-hidden />
|
||||
Full disaster-recovery export
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Bundles the database dump <em>and every stored file</em> (documents, berth PDFs,
|
||||
brochures, GDPR exports) into one <code>.tar</code> and downloads it to this computer.
|
||||
Use this as an offsite, storage-backend-independent backup.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" onClick={downloadFullBundle}>
|
||||
<HardDriveDownload className="me-1.5 h-4 w-4" aria-hidden />
|
||||
Download full backup
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="text-xs text-muted-foreground">
|
||||
The archive contains <code>db.dump</code>, <code>blobs/<key></code> for every file,
|
||||
and a <code>manifest.json</code> with a SHA-256 per object for restore-side verification.
|
||||
Unlike the DB-only backup above, this does not depend on the active storage backend
|
||||
surviving. Restore steps live in <code>docs/backup-restore-runbook.md</code>.
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">History</CardTitle>
|
||||
|
||||
604
src/components/admin/backup-destinations-card.tsx
Normal file
604
src/components/admin/backup-destinations-card.tsx
Normal file
@@ -0,0 +1,604 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Loader2,
|
||||
Plus,
|
||||
Server,
|
||||
Cloud,
|
||||
FolderTree,
|
||||
Trash2,
|
||||
Pencil,
|
||||
PlugZap,
|
||||
UploadCloud,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
|
||||
type DestType = 'sftp' | 's3' | 'filesystem';
|
||||
type Schedule = 'off' | 'daily' | 'weekly';
|
||||
|
||||
interface Destination {
|
||||
id: string;
|
||||
name: string;
|
||||
type: DestType;
|
||||
enabled: boolean;
|
||||
config: Record<string, unknown>;
|
||||
retentionCount: number | null;
|
||||
encryptBundle: boolean;
|
||||
encryptionKeyIsSet: boolean;
|
||||
lastRunAt: string | null;
|
||||
lastStatus: string | null;
|
||||
lastError: string | null;
|
||||
lastBackupBytes: number | null;
|
||||
}
|
||||
|
||||
const TYPE_META: Record<DestType, { label: string; icon: typeof Server; hint: string }> = {
|
||||
sftp: { label: 'SFTP / SSH server', icon: Server, hint: 'Push to a server over SFTP.' },
|
||||
s3: { label: 'S3-compatible', icon: Cloud, hint: 'AWS S3, Backblaze B2, Wasabi, R2, MinIO.' },
|
||||
filesystem: {
|
||||
label: 'Mounted path / NAS',
|
||||
icon: FolderTree,
|
||||
hint: 'A directory this server can write to.',
|
||||
},
|
||||
};
|
||||
|
||||
const STATUS_TONE: Record<string, string> = {
|
||||
ok: 'text-emerald-700',
|
||||
failed: 'text-rose-700',
|
||||
};
|
||||
|
||||
function formatBytes(n: number | null): string {
|
||||
if (n === null) return '—';
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
export function BackupDestinationsCard() {
|
||||
const [destinations, setDestinations] = useState<Destination[]>([]);
|
||||
const [schedule, setSchedule] = useState<Schedule>('off');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState<Destination | 'new' | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [d, s] = await Promise.all([
|
||||
apiFetch<{ data: Destination[] }>('/api/v1/admin/backup/destinations'),
|
||||
apiFetch<{ data: { schedule: Schedule } }>('/api/v1/admin/backup/schedule'),
|
||||
]);
|
||||
setDestinations(d.data);
|
||||
setSchedule(s.data.schedule);
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function changeSchedule(value: Schedule) {
|
||||
setSchedule(value);
|
||||
try {
|
||||
await apiFetch('/api/v1/admin/backup/schedule', {
|
||||
method: 'PUT',
|
||||
body: { schedule: value },
|
||||
});
|
||||
toast.success(
|
||||
value === 'off' ? 'Automated backups turned off' : `Automated backups: ${value}`,
|
||||
);
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
void load();
|
||||
}
|
||||
}
|
||||
|
||||
async function test(id: string) {
|
||||
setBusyId(id);
|
||||
try {
|
||||
await apiFetch(`/api/v1/admin/backup/destinations/${id}/test`, { method: 'POST' });
|
||||
toast.success('Connection OK');
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function runNow(id: string) {
|
||||
setBusyId(id);
|
||||
try {
|
||||
toast.info('Backing up — assembling the bundle, then pushing. This can take a minute.');
|
||||
await apiFetch(`/api/v1/admin/backup/destinations/${id}/run`, { method: 'POST' });
|
||||
toast.success('Backup pushed');
|
||||
await load();
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
await load();
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
if (!confirm('Delete this backup destination? This does not delete already-pushed backups.'))
|
||||
return;
|
||||
setBusyId(id);
|
||||
try {
|
||||
await apiFetch(`/api/v1/admin/backup/destinations/${id}`, { method: 'DELETE' });
|
||||
toast.success('Destination deleted');
|
||||
await load();
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-3 space-y-0">
|
||||
<div>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<UploadCloud className="h-4 w-4" aria-hidden />
|
||||
Automated backup destinations
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Where scheduled backups are pushed. Each destination receives the same full bundle
|
||||
(database + every file) you can download above.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setEditing('new')}>
|
||||
<Plus className="me-1.5 h-4 w-4" aria-hidden />
|
||||
Add destination
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Label htmlFor="backup-schedule" className="text-sm">
|
||||
Schedule
|
||||
</Label>
|
||||
<Select value={schedule} onValueChange={(v) => void changeSchedule(v as Schedule)}>
|
||||
<SelectTrigger id="backup-schedule" className="w-44">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="off">Off</SelectItem>
|
||||
<SelectItem value="daily">Daily (02:00)</SelectItem>
|
||||
<SelectItem value="weekly">Weekly (Sun 02:00)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Pushes to every enabled destination below.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden /> Loading…
|
||||
</div>
|
||||
) : destinations.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No destinations yet. Add one to enable automated offsite backups.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y rounded-md border">
|
||||
{destinations.map((d) => {
|
||||
const Icon = TYPE_META[d.type].icon;
|
||||
return (
|
||||
<li key={d.id} className="flex items-center justify-between gap-3 p-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||
<span className="font-medium truncate">{d.name}</span>
|
||||
{d.encryptBundle && (
|
||||
<ShieldCheck
|
||||
className="h-3.5 w-3.5 text-emerald-600"
|
||||
aria-label="Encrypted"
|
||||
/>
|
||||
)}
|
||||
{!d.enabled && (
|
||||
<span className="text-xs rounded-full bg-muted px-1.5 py-0.5 text-muted-foreground">
|
||||
disabled
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">
|
||||
{TYPE_META[d.type].label}
|
||||
{d.lastStatus && (
|
||||
<>
|
||||
{' · '}
|
||||
<span className={STATUS_TONE[d.lastStatus] ?? ''}>
|
||||
{d.lastStatus === 'ok' ? 'last OK' : 'last FAILED'}
|
||||
</span>
|
||||
{d.lastRunAt && ` ${new Date(d.lastRunAt).toLocaleString()}`}
|
||||
{d.lastStatus === 'ok' && ` (${formatBytes(d.lastBackupBytes)})`}
|
||||
</>
|
||||
)}
|
||||
{d.lastStatus === 'failed' && d.lastError && (
|
||||
<span className="block text-rose-700 truncate">{d.lastError}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={busyId === d.id}
|
||||
onClick={() => void test(d.id)}
|
||||
>
|
||||
<PlugZap className="me-1 h-3.5 w-3.5" aria-hidden />
|
||||
Test
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busyId === d.id}
|
||||
onClick={() => void runNow(d.id)}
|
||||
>
|
||||
{busyId === d.id ? (
|
||||
<Loader2 className="me-1 h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<UploadCloud className="me-1 h-3.5 w-3.5" aria-hidden />
|
||||
)}
|
||||
Back up now
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" onClick={() => setEditing(d)}>
|
||||
<Pencil className="h-3.5 w-3.5" aria-hidden />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
disabled={busyId === d.id}
|
||||
onClick={() => void remove(d.id)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-rose-600" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{editing && (
|
||||
<DestinationDialog
|
||||
destination={editing === 'new' ? null : editing}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={() => {
|
||||
setEditing(null);
|
||||
void load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── add/edit dialog ─────────────────────────────────────────────────────────
|
||||
|
||||
interface DialogProps {
|
||||
destination: Destination | null;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
function DestinationDialog({ destination, onClose, onSaved }: DialogProps) {
|
||||
const isEdit = Boolean(destination);
|
||||
const cfg = (destination?.config ?? {}) as Record<string, unknown>;
|
||||
const str = (k: string) => (typeof cfg[k] === 'string' ? (cfg[k] as string) : '');
|
||||
const num = (k: string) => (typeof cfg[k] === 'number' ? String(cfg[k]) : '');
|
||||
|
||||
const [name, setName] = useState(destination?.name ?? '');
|
||||
const [type, setType] = useState<DestType>(destination?.type ?? 'sftp');
|
||||
const [enabled, setEnabled] = useState(destination?.enabled ?? true);
|
||||
const [retention, setRetention] = useState(
|
||||
destination?.retentionCount != null ? String(destination.retentionCount) : '',
|
||||
);
|
||||
const [encryptBundle, setEncryptBundle] = useState(destination?.encryptBundle ?? false);
|
||||
const [encryptionKey, setEncryptionKey] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Config fields (controlled). Secrets start blank on edit (kept server-side).
|
||||
const [c, setC] = useState<Record<string, string>>({
|
||||
directory: str('directory'),
|
||||
host: str('host'),
|
||||
port: num('port'),
|
||||
username: str('username'),
|
||||
password: '',
|
||||
privateKey: '',
|
||||
passphrase: '',
|
||||
remoteDir: str('remoteDir'),
|
||||
hostFingerprint: str('hostFingerprint'),
|
||||
endpoint: str('endpoint'),
|
||||
region: str('region'),
|
||||
bucket: str('bucket'),
|
||||
accessKey: str('accessKey'),
|
||||
secretKey: '',
|
||||
prefix: str('prefix'),
|
||||
});
|
||||
const set = (k: string, v: string) => setC((prev) => ({ ...prev, [k]: v }));
|
||||
|
||||
function buildConfig(): Record<string, unknown> {
|
||||
if (type === 'filesystem') return { directory: c.directory };
|
||||
if (type === 'sftp') {
|
||||
return {
|
||||
host: c.host,
|
||||
...(c.port ? { port: Number(c.port) } : {}),
|
||||
username: c.username,
|
||||
...(c.password ? { password: c.password } : {}),
|
||||
...(c.privateKey ? { privateKey: c.privateKey } : {}),
|
||||
...(c.passphrase ? { passphrase: c.passphrase } : {}),
|
||||
remoteDir: c.remoteDir,
|
||||
...(c.hostFingerprint ? { hostFingerprint: c.hostFingerprint } : {}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
endpoint: c.endpoint,
|
||||
...(c.region ? { region: c.region } : {}),
|
||||
bucket: c.bucket,
|
||||
accessKey: c.accessKey,
|
||||
...(c.secretKey ? { secretKey: c.secretKey } : {}),
|
||||
...(c.prefix ? { prefix: c.prefix } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
try {
|
||||
const body = {
|
||||
name,
|
||||
type,
|
||||
enabled,
|
||||
config: buildConfig(),
|
||||
retentionCount: retention ? Number(retention) : null,
|
||||
encryptBundle,
|
||||
...(encryptionKey ? { encryptionKey } : {}),
|
||||
};
|
||||
if (isEdit && destination) {
|
||||
await apiFetch(`/api/v1/admin/backup/destinations/${destination.id}`, {
|
||||
method: 'PUT',
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await apiFetch('/api/v1/admin/backup/destinations', {
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
}
|
||||
toast.success(isEdit ? 'Destination updated' : 'Destination added');
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const secretPlaceholder = isEdit ? 'unchanged — leave blank to keep' : '';
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? 'Edit destination' : 'Add backup destination'}</DialogTitle>
|
||||
<DialogDescription>{TYPE_META[type].hint}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Field label="Name">
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Hetzner box"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Type">
|
||||
<Select value={type} onValueChange={(v) => setType(v as DestType)} disabled={isEdit}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="sftp">SFTP / SSH server</SelectItem>
|
||||
<SelectItem value="s3">S3-compatible</SelectItem>
|
||||
<SelectItem value="filesystem">Mounted path / NAS</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
{type === 'filesystem' && (
|
||||
<Field label="Directory">
|
||||
<Input
|
||||
value={c.directory}
|
||||
onChange={(e) => set('directory', e.target.value)}
|
||||
placeholder="/mnt/backups"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{type === 'sftp' && (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="col-span-2">
|
||||
<Field label="Host">
|
||||
<Input
|
||||
value={c.host}
|
||||
onChange={(e) => set('host', e.target.value)}
|
||||
placeholder="backups.example.com"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Port">
|
||||
<Input
|
||||
value={c.port}
|
||||
onChange={(e) => set('port', e.target.value)}
|
||||
placeholder="22"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Username">
|
||||
<Input value={c.username} onChange={(e) => set('username', e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Password">
|
||||
<Input
|
||||
type="password"
|
||||
value={c.password}
|
||||
onChange={(e) => set('password', e.target.value)}
|
||||
placeholder={secretPlaceholder}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Private key (optional, instead of password)">
|
||||
<textarea
|
||||
className="w-full rounded-md border px-2 py-1.5 text-xs font-mono"
|
||||
rows={3}
|
||||
value={c.privateKey}
|
||||
onChange={(e) => set('privateKey', e.target.value)}
|
||||
placeholder={secretPlaceholder || '-----BEGIN OPENSSH PRIVATE KEY-----'}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Remote directory">
|
||||
<Input
|
||||
value={c.remoteDir}
|
||||
onChange={(e) => set('remoteDir', e.target.value)}
|
||||
placeholder="/srv/pn-crm-backups"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Host key fingerprint (optional, sha256 — pins the server)">
|
||||
<Input
|
||||
value={c.hostFingerprint}
|
||||
onChange={(e) => set('hostFingerprint', e.target.value)}
|
||||
placeholder="aa:bb:cc… or hex"
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === 's3' && (
|
||||
<>
|
||||
<Field label="Endpoint">
|
||||
<Input
|
||||
value={c.endpoint}
|
||||
onChange={(e) => set('endpoint', e.target.value)}
|
||||
placeholder="https://s3.us-west.example.com"
|
||||
/>
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="Bucket">
|
||||
<Input value={c.bucket} onChange={(e) => set('bucket', e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Region (optional)">
|
||||
<Input
|
||||
value={c.region}
|
||||
onChange={(e) => set('region', e.target.value)}
|
||||
placeholder="us-east-1"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Access key">
|
||||
<Input value={c.accessKey} onChange={(e) => set('accessKey', e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Secret key">
|
||||
<Input
|
||||
type="password"
|
||||
value={c.secretKey}
|
||||
onChange={(e) => set('secretKey', e.target.value)}
|
||||
placeholder={secretPlaceholder}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Prefix (optional)">
|
||||
<Input
|
||||
value={c.prefix}
|
||||
onChange={(e) => set('prefix', e.target.value)}
|
||||
placeholder="crm-backups/"
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="Keep last N (blank = all)">
|
||||
<Input
|
||||
value={retention}
|
||||
onChange={(e) => setRetention(e.target.value)}
|
||||
placeholder="7"
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex items-end pb-1">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||
Enabled (auto-pushed)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border p-3 space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Switch checked={encryptBundle} onCheckedChange={setEncryptBundle} />
|
||||
Encrypt the bundle before sending (AES-256)
|
||||
</label>
|
||||
{encryptBundle && (
|
||||
<Field label="Passphrase (needed to restore — store it safely)">
|
||||
<Input
|
||||
type="password"
|
||||
value={encryptionKey}
|
||||
onChange={(e) => setEncryptionKey(e.target.value)}
|
||||
placeholder={
|
||||
destination?.encryptionKeyIsSet ? 'unchanged — leave blank to keep' : ''
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => void save()} disabled={saving || !name}>
|
||||
{saving && <Loader2 className="me-1.5 h-4 w-4 animate-spin" aria-hidden />}
|
||||
{isEdit ? 'Save changes' : 'Add destination'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">{label}</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,7 @@ interface ConfigResp {
|
||||
hasApiKey: boolean;
|
||||
useGlobal: boolean;
|
||||
aiEnabled: boolean;
|
||||
manualEntry: boolean;
|
||||
};
|
||||
models: Record<Provider, string[]>;
|
||||
}
|
||||
@@ -54,7 +55,7 @@ function SettingsBlock(props: SettingsBlockProps) {
|
||||
// Key the body on the loaded payload so useState initializers seed
|
||||
// from server values cleanly.
|
||||
const sig = data?.data
|
||||
? `${data.data.provider}:${data.data.model}:${data.data.useGlobal}:${data.data.aiEnabled}`
|
||||
? `${data.data.provider}:${data.data.model}:${data.data.useGlobal}:${data.data.aiEnabled}:${data.data.manualEntry}`
|
||||
: 'loading';
|
||||
return (
|
||||
<SettingsBlockBody
|
||||
@@ -89,6 +90,7 @@ function SettingsBlockBody({
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [useGlobal, setUseGlobal] = useState(data?.data.useGlobal ?? false);
|
||||
const [aiEnabled, setAiEnabled] = useState(data?.data.aiEnabled ?? false);
|
||||
const [manualEntry, setManualEntry] = useState(data?.data.manualEntry ?? false);
|
||||
const [testStatus, setTestStatus] = useState<null | { ok: true } | { ok: false; reason: string }>(
|
||||
null,
|
||||
);
|
||||
@@ -105,6 +107,7 @@ function SettingsBlockBody({
|
||||
clearApiKey: Boolean(clearApiKey),
|
||||
useGlobal: scope === 'global' ? false : useGlobal,
|
||||
aiEnabled: scope === 'global' ? false : aiEnabled,
|
||||
manualEntry: scope === 'global' ? false : manualEntry,
|
||||
},
|
||||
}),
|
||||
onSuccess: () => {
|
||||
@@ -190,6 +193,25 @@ function SettingsBlockBody({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{scope === 'port' ? (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-border bg-muted/30 p-3">
|
||||
<Checkbox
|
||||
id={`manualEntry-${scope}`}
|
||||
checked={manualEntry}
|
||||
onCheckedChange={(v) => setManualEntry(v === true)}
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor={`manualEntry-${scope}`} className="text-sm font-medium">
|
||||
Manual entry only (skip receipt scanning)
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When on, staff just attach a receipt photo and type the details by hand - no
|
||||
on-device or AI parsing runs. Takes precedence over AI parsing above.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`provider-${scope}`}>Provider</Label>
|
||||
|
||||
@@ -103,6 +103,10 @@ const DEFAULT_PERMISSIONS: Record<string, Record<string, boolean>> = {
|
||||
delete: false,
|
||||
change_stage: false,
|
||||
},
|
||||
inquiries: {
|
||||
view: false,
|
||||
manage: false,
|
||||
},
|
||||
};
|
||||
|
||||
const GROUP_LABELS: Record<string, string> = {
|
||||
@@ -126,6 +130,7 @@ const GROUP_LABELS: Record<string, string> = {
|
||||
admin: 'Administration',
|
||||
residential_clients: 'Residential Clients',
|
||||
residential_interests: 'Residential Interests',
|
||||
inquiries: 'Inquiries',
|
||||
};
|
||||
|
||||
function formatAction(action: string): string {
|
||||
|
||||
@@ -48,6 +48,14 @@ const KNOWN_SETTINGS: Array<{
|
||||
type: 'boolean',
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
key: 'assignment_enabled',
|
||||
label: 'Interest Assignment',
|
||||
description:
|
||||
'Allow assigning interests to sales users (the "Assigned to" owner chip + auto-assign on create). Off by default - turn on only when more than one person works the pipeline. Disabling hides the assignment UI and stops auto-assigning new interests; existing assignment data is preserved and reappears if you re-enable.',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
key: 'tenancies_module_enabled',
|
||||
label: 'Tenancies Module',
|
||||
@@ -72,6 +80,14 @@ const KNOWN_SETTINGS: Array<{
|
||||
type: 'boolean',
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
key: 'maintenance_module_enabled',
|
||||
label: 'Berth Maintenance Module',
|
||||
description:
|
||||
'Enable the per-berth maintenance log (the "Maintenance" tab on each berth detail page). On by default. Disabling hides the Maintenance tab everywhere and blocks its log routes; previously-recorded maintenance logs are preserved and reappear when you re-enable.',
|
||||
type: 'boolean',
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
key: 'ai_interest_scoring',
|
||||
label: 'AI Interest Scoring',
|
||||
|
||||
@@ -62,7 +62,7 @@ export function AlertCard({ alert, readOnly = false }: AlertCardProps) {
|
||||
</div>
|
||||
</div>
|
||||
{!readOnly ? (
|
||||
<div className="flex shrink-0 items-start gap-1 opacity-0 transition-opacity duration-base ease-spring group-hover:opacity-100 focus-within:opacity-100">
|
||||
<div className="flex shrink-0 items-start gap-1 text-muted-foreground">
|
||||
{!acknowledged ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -4,10 +4,11 @@ import { useState } from 'react';
|
||||
import { ShieldAlert } from 'lucide-react';
|
||||
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { AlertCard, AlertCardEmpty } from './alert-card';
|
||||
import { useAlertCount, useAlertList, useAlertRealtime } from './use-alerts';
|
||||
import { useAlertCount, useAlertList, useAlertRealtime, useDismissAll } from './use-alerts';
|
||||
import type { AlertStatus } from './types';
|
||||
|
||||
/**
|
||||
@@ -30,6 +31,7 @@ export function AlertsPageShell({ embedded = false }: AlertsPageShellProps = {})
|
||||
|
||||
const total = count?.total ?? 0;
|
||||
const alerts = data?.data ?? [];
|
||||
const dismissAll = useDismissAll();
|
||||
|
||||
return (
|
||||
<div className={embedded ? 'space-y-3' : 'space-y-6'}>
|
||||
@@ -62,6 +64,18 @@ export function AlertsPageShell({ embedded = false }: AlertsPageShellProps = {})
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value={tab} className="mt-4 space-y-2">
|
||||
{tab === 'open' && alerts.length > 0 ? (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => dismissAll.mutate({})}
|
||||
disabled={dismissAll.isPending}
|
||||
>
|
||||
Dismiss all
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
|
||||
@@ -41,6 +41,15 @@ export function useAlertActions() {
|
||||
return { acknowledge, dismiss };
|
||||
}
|
||||
|
||||
export function useDismissAll() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (filter: { ruleId?: string; severity?: string } = {}) =>
|
||||
apiFetch('/api/v1/alerts/dismiss-all', { method: 'POST', body: filter }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['alerts'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAlertRealtime() {
|
||||
useRealtimeInvalidation({
|
||||
'alert:created': [['alerts']],
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { TagBadge } from '@/components/shared/tag-badge';
|
||||
import { ListCard, ListCardAvatar, ListCardMeta } from '@/components/shared/list-card';
|
||||
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
|
||||
import { BerthStatusQuickEdit } from './berth-status-quick-edit';
|
||||
import { formatCurrency } from '@/lib/utils/currency';
|
||||
import type { BerthRow } from './berth-columns';
|
||||
import { mooringLetterDot } from './mooring-letter-tone';
|
||||
@@ -167,7 +168,9 @@ export function BerthCard({ berth }: BerthCardProps) {
|
||||
|
||||
{/* Status pill + tags */}
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||||
<StatusPill status={statusPill}>{statusLabel}</StatusPill>
|
||||
<BerthStatusQuickEdit berthId={berth.id} currentStatus={berth.status}>
|
||||
<StatusPill status={statusPill}>{statusLabel}</StatusPill>
|
||||
</BerthStatusQuickEdit>
|
||||
{tags.slice(0, 2).map((tag) => (
|
||||
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
|
||||
))}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { TagBadge } from '@/components/shared/tag-badge';
|
||||
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
|
||||
import { BerthStatusQuickEdit } from './berth-status-quick-edit';
|
||||
import { formatCurrency } from '@/lib/utils/currency';
|
||||
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
@@ -342,7 +343,9 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
|
||||
const isManualUnreconciled = isManual && !r.latestInterestStage;
|
||||
return (
|
||||
<div className="inline-flex items-center gap-1.5">
|
||||
<StatusBadge status={r.status} />
|
||||
<BerthStatusQuickEdit berthId={r.id} currentStatus={r.status}>
|
||||
<StatusBadge status={r.status} />
|
||||
</BerthStatusQuickEdit>
|
||||
{isManual ? <ManualBadge variant={isManualUnreconciled ? 'catchup' : 'pinned'} /> : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { DetailLayout } from '@/components/shared/detail-layout';
|
||||
import { DetailNotFound } from '@/components/shared/detail-not-found';
|
||||
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { useTenanciesModuleEnabled } from '@/providers/port-provider';
|
||||
import { useTenanciesModuleEnabled, useMaintenanceModuleEnabled } from '@/providers/port-provider';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { BerthDetailHeader, type BerthDetailData } from './berth-detail-header';
|
||||
import { BerthForm } from './berth-form';
|
||||
@@ -22,6 +22,7 @@ export function BerthDetail({ berthId }: BerthDetailProps) {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
const tenanciesModuleEnabled = useTenanciesModuleEnabled();
|
||||
const maintenanceModuleEnabled = useMaintenanceModuleEnabled();
|
||||
|
||||
const { data, isLoading, error } = useQuery<BerthDetailData>({
|
||||
queryKey: ['berth', berthId],
|
||||
@@ -86,7 +87,9 @@ export function BerthDetail({ berthId }: BerthDetailProps) {
|
||||
<DetailLayout
|
||||
isLoading={isLoading}
|
||||
header={berth ? <BerthDetailHeader berth={berth} /> : null}
|
||||
tabs={berth ? buildBerthTabs(berth, { tenanciesModuleEnabled }) : []}
|
||||
tabs={
|
||||
berth ? buildBerthTabs(berth, { tenanciesModuleEnabled, maintenanceModuleEnabled }) : []
|
||||
}
|
||||
defaultTab="overview"
|
||||
/>
|
||||
{berth ? <BerthForm berth={berth} open={editOpen} onOpenChange={setEditOpen} /> : null}
|
||||
|
||||
@@ -19,8 +19,10 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { ChevronDown, ChevronRight, Download } from 'lucide-react';
|
||||
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
@@ -29,6 +31,21 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { PdfReconcileDialog } from './pdf-reconcile-dialog';
|
||||
|
||||
// pdfjs-dist is ~150kb gzip — lazy-load so the berth page only pulls it
|
||||
// in when a rep actually expands the spec-sheet preview. ssr:false
|
||||
// because the pdfjs worker setup needs `window`.
|
||||
const PdfViewer = dynamic(
|
||||
() => import('@/components/files/pdf-viewer').then((m) => ({ default: m.PdfViewer })),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex h-[600px] items-center justify-center text-sm text-muted-foreground">
|
||||
Loading PDF viewer…
|
||||
</div>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
interface PdfVersionRow {
|
||||
id: string;
|
||||
versionNumber: number;
|
||||
@@ -53,6 +70,7 @@ interface UploadUrlResponse {
|
||||
export function BerthDocumentsTab({ berthId }: { berthId: string }) {
|
||||
const qc = useQueryClient();
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [previewOpen, setPreviewOpen] = useState(true);
|
||||
const [pendingDiff, setPendingDiff] = useState<{
|
||||
versionId: string;
|
||||
autoApplied: Array<{ field: string; value: string | number }>;
|
||||
@@ -187,24 +205,45 @@ export function BerthDocumentsTab({ berthId }: { berthId: string }) {
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 text-sm">
|
||||
<CardContent className="space-y-3 pt-0 text-sm">
|
||||
{isLoading ? (
|
||||
<p className="text-muted-foreground">Loading…</p>
|
||||
) : current ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<a
|
||||
href={current.downloadUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium underline underline-offset-2"
|
||||
>
|
||||
{current.fileName}
|
||||
</a>
|
||||
<span className="text-muted-foreground">
|
||||
v{current.versionNumber} · {(current.fileSizeBytes / 1024 / 1024).toFixed(2)} MB
|
||||
</span>
|
||||
{current.parseEngine ? <ParseEngineBadge engine={current.parseEngine} /> : null}
|
||||
</div>
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreviewOpen((o) => !o)}
|
||||
className="inline-flex items-center gap-1 font-medium underline-offset-2 hover:underline"
|
||||
aria-expanded={previewOpen}
|
||||
>
|
||||
{previewOpen ? (
|
||||
<ChevronDown className="size-3.5 shrink-0" aria-hidden />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5 shrink-0" aria-hidden />
|
||||
)}
|
||||
{current.fileName}
|
||||
</button>
|
||||
<span className="text-muted-foreground">
|
||||
v{current.versionNumber} · {(current.fileSizeBytes / 1024 / 1024).toFixed(2)} MB
|
||||
</span>
|
||||
{current.parseEngine ? <ParseEngineBadge engine={current.parseEngine} /> : null}
|
||||
<a
|
||||
href={current.downloadUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="ml-auto inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Download className="size-3.5" aria-hidden />
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
{previewOpen ? (
|
||||
<div className="h-[600px] overflow-hidden rounded-md border bg-muted/20">
|
||||
<PdfViewer url={current.downloadUrl} fileName={current.fileName} />
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-muted-foreground">No PDF uploaded yet.</p>
|
||||
)}
|
||||
|
||||
@@ -4,16 +4,7 @@ import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Anchor,
|
||||
Archive,
|
||||
CircleDollarSign,
|
||||
Plus,
|
||||
Rows3,
|
||||
Rows4,
|
||||
Tag as TagIcon,
|
||||
TagsIcon,
|
||||
} from 'lucide-react';
|
||||
import { Anchor, Archive, CircleDollarSign, Plus, Tag as TagIcon, TagsIcon } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
@@ -103,8 +94,10 @@ export function BerthList() {
|
||||
// Persisted column visibility + row density + dimension unit - same
|
||||
// pattern as ClientList / InterestList; density falls back to
|
||||
// 'comfortable' and dimensionUnit to 'ft' for users who haven't picked.
|
||||
const { hidden, setHidden, density, setDensity, dimensionUnit, setDimensionUnit } =
|
||||
useTablePreferences('berths', BERTH_DEFAULT_HIDDEN);
|
||||
const { hidden, setHidden, dimensionUnit, setDimensionUnit } = useTablePreferences(
|
||||
'berths',
|
||||
BERTH_DEFAULT_HIDDEN,
|
||||
);
|
||||
const columnVisibility = Object.fromEntries(hidden.map((id) => [id, false]));
|
||||
const berthColumns = getBerthColumns(dimensionUnit);
|
||||
|
||||
@@ -187,36 +180,24 @@ export function BerthList() {
|
||||
applyView({ filters: savedFilters, sort: savedSort });
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setDensity(density === 'compact' ? 'comfortable' : 'compact')}
|
||||
aria-label={
|
||||
density === 'compact'
|
||||
? 'Switch to comfortable row spacing'
|
||||
: 'Switch to compact row spacing'
|
||||
}
|
||||
title={density === 'compact' ? 'Comfortable rows' : 'Compact rows'}
|
||||
>
|
||||
{density === 'compact' ? (
|
||||
<Rows3 className="h-4 w-4" aria-hidden />
|
||||
) : (
|
||||
<Rows4 className="h-4 w-4" aria-hidden />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setDimensionUnit(dimensionUnit === 'ft' ? 'm' : 'ft')}
|
||||
aria-label={`Switch to ${dimensionUnit === 'ft' ? 'metres' : 'feet'}`}
|
||||
title={`Switch to ${dimensionUnit === 'ft' ? 'metres' : 'feet'}`}
|
||||
className="font-mono text-xs"
|
||||
>
|
||||
{dimensionUnit === 'ft' ? 'ft' : 'm'}
|
||||
</Button>
|
||||
<ColumnPicker columns={BERTH_COLUMN_OPTIONS} hidden={hidden} onChange={setHidden} />
|
||||
{/* Table-only controls — hidden in card mode (<md, matching
|
||||
DataTable's table/card switch). The BerthCard ignores the
|
||||
dimension unit + renders no column set, so these toggles have
|
||||
no visible effect there. */}
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setDimensionUnit(dimensionUnit === 'ft' ? 'm' : 'ft')}
|
||||
aria-label={`Switch to ${dimensionUnit === 'ft' ? 'metres' : 'feet'}`}
|
||||
title={`Switch to ${dimensionUnit === 'ft' ? 'metres' : 'feet'}`}
|
||||
className="font-mono text-xs"
|
||||
>
|
||||
{dimensionUnit === 'ft' ? 'ft' : 'm'}
|
||||
</Button>
|
||||
<ColumnPicker columns={BERTH_COLUMN_OPTIONS} hidden={hidden} onChange={setHidden} />
|
||||
</div>
|
||||
<ExportListPdfButton kind="berths" />
|
||||
{canBulkAdd && (
|
||||
<Button asChild size="sm" variant="default">
|
||||
@@ -232,7 +213,6 @@ export function BerthList() {
|
||||
<DataTable<BerthRow>
|
||||
columns={berthColumns}
|
||||
columnVisibility={columnVisibility}
|
||||
density={density}
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
pagination={{
|
||||
|
||||
@@ -4,6 +4,7 @@ import Link from 'next/link';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { stageBadgeClass, stageLabel } from '@/lib/constants';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -80,27 +81,74 @@ export function BerthOccupancyChip({
|
||||
competing.find((r) => r.isInEoiBundle) ?? competing.find((r) => r.isPrimary) ?? competing[0]!;
|
||||
const extras = competing.length - 1;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/${portSlug}/interests/${primary.interestId}` as never}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md border border-amber-300 bg-amber-50 px-2 py-0.5 text-xs text-amber-900 hover:bg-amber-100 transition-colors',
|
||||
compact && 'max-w-[200px]',
|
||||
)}
|
||||
title={`Open ${primary.clientName} (${stageLabel(primary.pipelineStage)})`}
|
||||
>
|
||||
<span className="font-medium">Under offer to:</span>
|
||||
<span className={cn(compact && 'truncate min-w-0')}>{primary.clientName}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 rounded-full px-1.5 text-xs',
|
||||
stageBadgeClass(primary.pipelineStage),
|
||||
)}
|
||||
const chipClass = cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md border border-amber-300 bg-amber-50 px-2 py-0.5 text-xs text-amber-900 hover:bg-amber-100 transition-colors',
|
||||
// Cap tight on narrow viewports, but give the name room on desktop
|
||||
// so it isn't truncated to "Philippe Ca…" (UAT 2026-06-03).
|
||||
compact && 'max-w-[200px] md:max-w-[460px]',
|
||||
);
|
||||
|
||||
const stageChip = (stage: string) => (
|
||||
<span className={cn('shrink-0 rounded-full px-1.5 text-xs', stageBadgeClass(stage))}>
|
||||
{stageLabel(stage)}
|
||||
</span>
|
||||
);
|
||||
|
||||
// Single competing interest → the chip is a direct link to it.
|
||||
if (competing.length === 1) {
|
||||
return (
|
||||
<Link
|
||||
href={`/${portSlug}/interests/${primary.interestId}` as never}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={chipClass}
|
||||
title={`Open ${primary.clientName} (${stageLabel(primary.pipelineStage)})`}
|
||||
>
|
||||
{stageLabel(primary.pipelineStage)}
|
||||
</span>
|
||||
{extras > 0 ? <span className="shrink-0 text-amber-700">+{extras} more</span> : null}
|
||||
</Link>
|
||||
<span className="font-medium">Under offer to:</span>
|
||||
<span className={cn(compact && 'truncate min-w-0')}>{primary.clientName}</span>
|
||||
{stageChip(primary.pipelineStage)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// Multiple competing interests → the chip opens a popover that lists
|
||||
// every competing deal so no name is hidden behind "+N more" (UAT
|
||||
// 2026-06-03). Each row links to its interest.
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button type="button" onClick={(e) => e.stopPropagation()} className={chipClass}>
|
||||
<span className="font-medium">Under offer to:</span>
|
||||
<span className={cn(compact && 'truncate min-w-0')}>{primary.clientName}</span>
|
||||
{stageChip(primary.pipelineStage)}
|
||||
<span className="shrink-0 text-amber-700">+{extras} more</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-72 p-0" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="border-b px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
{competing.length} interests competing for this berth
|
||||
</div>
|
||||
<ul className="max-h-72 divide-y overflow-y-auto">
|
||||
{competing.map((r) => (
|
||||
<li key={r.interestId}>
|
||||
<Link
|
||||
href={`/${portSlug}/interests/${r.interestId}` as never}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex items-center justify-between gap-2 px-3 py-2 text-sm hover:bg-muted/60"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{r.clientName}
|
||||
{r.isInEoiBundle ? (
|
||||
<span className="ml-1.5 text-xs text-amber-700">· in EOI</span>
|
||||
) : r.isPrimary ? (
|
||||
<span className="ml-1.5 text-xs text-muted-foreground">· primary</span>
|
||||
) : null}
|
||||
</span>
|
||||
{stageChip(r.pipelineStage)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
172
src/components/berths/berth-price-reconcile-table.tsx
Normal file
172
src/components/berths/berth-price-reconcile-table.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Bulk berth price-reconcile table (CM-2 Part A).
|
||||
*
|
||||
* Lists the price parsed from each berth's current spec sheet next to the stored
|
||||
* price, with per-row + select-all approval. Nothing is written until the rep
|
||||
* approves — the apply mutation posts only the checked, changed rows.
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { EmptyState } from '@/components/ui/empty-state';
|
||||
|
||||
interface Row {
|
||||
berthId: string;
|
||||
mooringNumber: string;
|
||||
area: string | null;
|
||||
currentPrice: number | null;
|
||||
currentCurrency: string;
|
||||
parsedPrice: number | null;
|
||||
parsedCurrency: string | null;
|
||||
status: 'changed' | 'matched' | 'needs_review' | 'no_pdf';
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
const STATUS_STYLE: Record<Row['status'], string> = {
|
||||
changed: 'bg-amber-100 text-amber-800',
|
||||
matched: 'bg-muted text-muted-foreground',
|
||||
needs_review: 'bg-red-100 text-red-700',
|
||||
no_pdf: 'bg-slate-100 text-slate-500',
|
||||
};
|
||||
const STATUS_LABEL: Record<Row['status'], string> = {
|
||||
changed: 'Changed',
|
||||
matched: 'Matched',
|
||||
needs_review: 'Needs review',
|
||||
no_pdf: 'No PDF',
|
||||
};
|
||||
|
||||
const fmt = (n: number | null, ccy: string | null) =>
|
||||
n == null ? '—' : `${n.toLocaleString()} ${ccy ?? ''}`.trim();
|
||||
|
||||
export function BerthPriceReconcileTable() {
|
||||
const qc = useQueryClient();
|
||||
const { data, isLoading } = useQuery<{ data: Row[] }>({
|
||||
queryKey: ['berths', 'price-reconcile'],
|
||||
queryFn: () => apiFetch('/api/v1/berths/price-reconcile'),
|
||||
});
|
||||
const rows = useMemo(() => data?.data ?? [], [data]);
|
||||
const selectable = useMemo(() => rows.filter((r) => r.status === 'changed'), [rows]);
|
||||
const [checked, setChecked] = useState<Record<string, boolean>>({});
|
||||
|
||||
const apply = useMutation({
|
||||
mutationFn: async (): Promise<{ data: { updated: number } }> => {
|
||||
const approvals = selectable
|
||||
.filter((r) => checked[r.berthId] && r.parsedPrice != null)
|
||||
.map((r) => ({
|
||||
berthId: r.berthId,
|
||||
price: r.parsedPrice as number,
|
||||
currency: r.parsedCurrency ?? r.currentCurrency,
|
||||
}));
|
||||
return apiFetch('/api/v1/berths/price-reconcile/apply', {
|
||||
method: 'POST',
|
||||
body: { approvals },
|
||||
});
|
||||
},
|
||||
onSuccess: (res) => {
|
||||
toast.success(`Updated ${res.data.updated} berth price(s).`);
|
||||
setChecked({});
|
||||
void qc.invalidateQueries({ queryKey: ['berths'] });
|
||||
},
|
||||
onError: (e: Error) => toastError(e),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="p-6 text-sm text-muted-foreground">Parsing spec sheets…</p>;
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<EmptyState title="No berths to reconcile" body="No active berths found for this port." />
|
||||
);
|
||||
}
|
||||
|
||||
const allChecked = selectable.length > 0 && selectable.every((r) => checked[r.berthId]);
|
||||
const selectedCount = selectable.filter((r) => checked[r.berthId]).length;
|
||||
const reviewCount = rows.filter((r) => r.status === 'needs_review').length;
|
||||
const noPdfCount = rows.filter((r) => r.status === 'no_pdf').length;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectable.length} changed · {reviewCount} need review · {noPdfCount} without a PDF
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={selectedCount === 0 || apply.isPending}
|
||||
onClick={() => apply.mutate()}
|
||||
>
|
||||
{apply.isPending ? 'Applying…' : `Approve selected (${selectedCount})`}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-md border bg-white">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/30 text-start text-xs text-muted-foreground">
|
||||
<th className="w-10 p-2 ps-3">
|
||||
<Checkbox
|
||||
aria-label="Select all changed"
|
||||
checked={allChecked}
|
||||
onCheckedChange={(c) =>
|
||||
setChecked(
|
||||
c === true
|
||||
? Object.fromEntries(selectable.map((r) => [r.berthId, true]))
|
||||
: {},
|
||||
)
|
||||
}
|
||||
/>
|
||||
</th>
|
||||
<th className="p-2">Mooring</th>
|
||||
<th className="p-2">Area</th>
|
||||
<th className="p-2 text-end">Current</th>
|
||||
<th className="p-2 text-end">Parsed</th>
|
||||
<th className="p-2">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.berthId} className="border-b last:border-0">
|
||||
<td className="p-2 ps-3">
|
||||
{r.status === 'changed' ? (
|
||||
<Checkbox
|
||||
aria-label={`Approve ${r.mooringNumber}`}
|
||||
checked={!!checked[r.berthId]}
|
||||
onCheckedChange={(c) =>
|
||||
setChecked((p) => ({ ...p, [r.berthId]: c === true }))
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="p-2 font-medium">{r.mooringNumber}</td>
|
||||
<td className="p-2 text-muted-foreground">{r.area ?? '—'}</td>
|
||||
<td className="p-2 text-end tabular-nums">
|
||||
{fmt(r.currentPrice, r.currentCurrency)}
|
||||
</td>
|
||||
<td className="p-2 text-end tabular-nums">
|
||||
{fmt(r.parsedPrice, r.parsedCurrency)}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<span className={`rounded px-2 py-0.5 text-xs ${STATUS_STYLE[r.status]}`}>
|
||||
{STATUS_LABEL[r.status]}
|
||||
</span>
|
||||
{r.warning ? (
|
||||
<span className="ms-2 text-xs text-muted-foreground">{r.warning}</span>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
242
src/components/berths/berth-status-quick-edit.tsx
Normal file
242
src/components/berths/berth-status-quick-edit.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { FormErrorSummary } from '@/components/forms/form-error-summary';
|
||||
import { useFormScrollToError } from '@/hooks/use-form-scroll-to-error';
|
||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
import { useVocabulary } from '@/hooks/use-vocabulary';
|
||||
import { updateBerthStatusSchema, type UpdateBerthStatusInput } from '@/lib/validators/berths';
|
||||
import { BERTH_STATUSES, stageLabel } from '@/lib/constants';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
available: 'Available',
|
||||
under_offer: 'Under Offer',
|
||||
sold: 'Sold',
|
||||
};
|
||||
|
||||
interface InterestOption {
|
||||
id: string;
|
||||
clientName: string;
|
||||
pipelineStage: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Click-to-change berth status from the berths LIST. Wraps the status chip
|
||||
* (passed as children) in a button that opens a compact change-status dialog
|
||||
* — status dropdown + required reason (with quick-pick chips) + an optional
|
||||
* interest link when moving to under_offer/sold. Same PATCH endpoint +
|
||||
* validator + audit as the berth detail page. Reps without `berths.edit` see
|
||||
* a plain, non-interactive chip via the PermissionGate fallback.
|
||||
*/
|
||||
export function BerthStatusQuickEdit({
|
||||
berthId,
|
||||
currentStatus,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
berthId: string;
|
||||
currentStatus: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<PermissionGate resource="berths" action="edit" fallback={<>{children}</>}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
// The chip usually sits inside a clickable list card/row — stop the
|
||||
// click from also navigating to the berth detail page.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpen(true);
|
||||
}}
|
||||
title="Change status"
|
||||
aria-label="Change berth status"
|
||||
className={cn(
|
||||
'cursor-pointer rounded-full outline-none transition-opacity hover:opacity-80 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
{open && (
|
||||
<BerthStatusQuickEditDialog
|
||||
berthId={berthId}
|
||||
currentStatus={currentStatus}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
/>
|
||||
)}
|
||||
</PermissionGate>
|
||||
);
|
||||
}
|
||||
|
||||
function BerthStatusQuickEditDialog({
|
||||
berthId,
|
||||
currentStatus,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
berthId: string;
|
||||
currentStatus: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const reasonChips = useVocabulary('berth_status_change_reasons');
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<UpdateBerthStatusInput>({
|
||||
resolver: zodResolver(updateBerthStatusSchema),
|
||||
defaultValues: { status: currentStatus as (typeof BERTH_STATUSES)[number], reason: '' },
|
||||
});
|
||||
const submitWithScroll = useFormScrollToError(handleSubmit, errors);
|
||||
|
||||
const status = watch('status');
|
||||
const interestId = watch('interestId');
|
||||
const showInterestPicker = status === 'under_offer' || status === 'sold';
|
||||
|
||||
// Active interests for the picker — only fetched once the picker is shown.
|
||||
const interestsQuery = useQuery<{ data: InterestOption[] }>({
|
||||
queryKey: ['interests', 'status-link-picker'],
|
||||
queryFn: () => apiFetch('/api/v1/interests?pageSize=200&sort=updatedAt&order=desc'),
|
||||
enabled: open && showInterestPicker,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const interestOptions = interestsQuery.data?.data ?? [];
|
||||
|
||||
async function onSubmit(data: UpdateBerthStatusInput) {
|
||||
try {
|
||||
await apiFetch(`/api/v1/berths/${berthId}/status`, { method: 'PATCH', body: data });
|
||||
queryClient.invalidateQueries({ queryKey: ['berths'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['berth', berthId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['interests'] });
|
||||
toast.success('Status updated');
|
||||
reset();
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Change status</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={submitWithScroll(onSubmit)} className="space-y-4">
|
||||
<FormErrorSummary
|
||||
errors={errors}
|
||||
labels={{ status: 'Status', reason: 'Reason', interestId: 'Linked interest' }}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<Label>New status</Label>
|
||||
<Select
|
||||
value={status}
|
||||
onValueChange={(v) => {
|
||||
setValue('status', v as (typeof BERTH_STATUSES)[number]);
|
||||
// Clear any stale interest pick when returning to available.
|
||||
if (v === 'available') setValue('interestId', undefined);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{BERTH_STATUSES.map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{STATUS_LABELS[s] ?? s}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Reason *</Label>
|
||||
{reasonChips.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{reasonChips.map((chip) => (
|
||||
<button
|
||||
type="button"
|
||||
key={chip}
|
||||
onClick={() => setValue('reason', chip, { shouldDirty: true })}
|
||||
className="rounded-full border border-muted-foreground/20 bg-muted px-2.5 py-0.5 text-xs hover:bg-accent"
|
||||
>
|
||||
{chip}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Textarea {...register('reason')} placeholder="Reason for status change…" rows={3} />
|
||||
</div>
|
||||
{showInterestPicker && (
|
||||
<div className="space-y-2">
|
||||
<Label>Linked interest (optional)</Label>
|
||||
<Select
|
||||
value={interestId ?? '__none__'}
|
||||
onValueChange={(v) => setValue('interestId', v === '__none__' ? undefined : v)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No interest" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">No interest</SelectItem>
|
||||
{interestOptions.map((o) => (
|
||||
<SelectItem key={o.id} value={o.id}>
|
||||
{`${o.clientName || '(unnamed)'} · ${stageLabel(o.pipelineStage)}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Links this change to the interest it relates to — it shows on that interest's
|
||||
timeline and the berth attaches to it automatically.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Saving…' : 'Update status'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -427,7 +427,10 @@ function OverviewTab({ berth }: { berth: BerthData }) {
|
||||
|
||||
export function buildBerthTabs(
|
||||
berth: BerthData,
|
||||
opts: { tenanciesModuleEnabled: boolean } = { tenanciesModuleEnabled: false },
|
||||
opts: { tenanciesModuleEnabled: boolean; maintenanceModuleEnabled: boolean } = {
|
||||
tenanciesModuleEnabled: false,
|
||||
maintenanceModuleEnabled: true,
|
||||
},
|
||||
): DetailTab[] {
|
||||
const tabs: DetailTab[] = [
|
||||
{
|
||||
@@ -448,12 +451,15 @@ export function buildBerthTabs(
|
||||
content: <BerthTenanciesTab berthId={berth.id} />,
|
||||
});
|
||||
}
|
||||
tabs.push(...buildBerthDetailRemainder(berth));
|
||||
tabs.push(...buildBerthDetailRemainder(berth, opts));
|
||||
return tabs;
|
||||
}
|
||||
|
||||
function buildBerthDetailRemainder(berth: BerthData): DetailTab[] {
|
||||
return [
|
||||
function buildBerthDetailRemainder(
|
||||
berth: BerthData,
|
||||
opts: { maintenanceModuleEnabled: boolean } = { maintenanceModuleEnabled: true },
|
||||
): DetailTab[] {
|
||||
const tabs: DetailTab[] = [
|
||||
{
|
||||
id: 'spec',
|
||||
label: 'Spec',
|
||||
@@ -469,20 +475,23 @@ function buildBerthDetailRemainder(berth: BerthData): DetailTab[] {
|
||||
label: 'Waiting List',
|
||||
content: <WaitingListManager berthId={berth.id} />,
|
||||
},
|
||||
{
|
||||
];
|
||||
if (opts.maintenanceModuleEnabled) {
|
||||
tabs.push({
|
||||
id: 'maintenance',
|
||||
label: 'Maintenance',
|
||||
content: <BerthMaintenanceTab berthId={berth.id} />,
|
||||
},
|
||||
{
|
||||
id: 'activity',
|
||||
label: 'Activity',
|
||||
content: (
|
||||
<EntityActivityFeed
|
||||
endpoint={`/api/v1/berths/${berth.id}/activity`}
|
||||
emptyText="No activity recorded for this berth yet."
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
tabs.push({
|
||||
id: 'activity',
|
||||
label: 'Activity',
|
||||
content: (
|
||||
<EntityActivityFeed
|
||||
endpoint={`/api/v1/berths/${berth.id}/activity`}
|
||||
emptyText="No activity recorded for this berth yet."
|
||||
/>
|
||||
),
|
||||
});
|
||||
return tabs;
|
||||
}
|
||||
|
||||
304
src/components/client-groups/client-group-detail.tsx
Normal file
304
src/components/client-groups/client-group-detail.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import type { Route } from 'next';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { ArrowLeft, Copy, CopyCheck, Trash2, UserCog, Users } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||
import { EmptyState } from '@/components/shared/empty-state';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
|
||||
interface GroupMember {
|
||||
clientId: string;
|
||||
fullName: string;
|
||||
email: string | null;
|
||||
}
|
||||
interface ClientOption {
|
||||
id: string;
|
||||
fullName: string;
|
||||
primaryEmail: string | null;
|
||||
}
|
||||
|
||||
async function copyToClipboard(text: string, successMsg: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success(successMsg);
|
||||
} catch {
|
||||
toast.error('Copy failed — clipboard unavailable');
|
||||
}
|
||||
}
|
||||
|
||||
export function ClientGroupDetail({ groupId }: { groupId: string }) {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
const router = useRouter();
|
||||
const qc = useQueryClient();
|
||||
const [manageOpen, setManageOpen] = useState(false);
|
||||
|
||||
const { data: groupResp } = useQuery<{ data: { id: string; name: string; color: string } }>({
|
||||
queryKey: ['client-group', groupId],
|
||||
queryFn: () => apiFetch(`/api/v1/client-groups/${groupId}`),
|
||||
});
|
||||
const { data: membersResp, isLoading } = useQuery<{ data: GroupMember[] }>({
|
||||
queryKey: ['client-group', groupId, 'members'],
|
||||
queryFn: () => apiFetch(`/api/v1/client-groups/${groupId}/members`),
|
||||
});
|
||||
|
||||
const group = groupResp?.data;
|
||||
const members = useMemo(() => membersResp?.data ?? [], [membersResp]);
|
||||
const emails = members.map((m) => m.email).filter((e): e is string => !!e);
|
||||
|
||||
const archive = useMutation({
|
||||
mutationFn: () => apiFetch(`/api/v1/client-groups/${groupId}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Group archived');
|
||||
qc.invalidateQueries({ queryKey: ['client-groups'] });
|
||||
router.push(`/${portSlug}/client-groups` as Route);
|
||||
},
|
||||
onError: (err) => toastError(err),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Link
|
||||
href={`/${portSlug}/client-groups` as Route}
|
||||
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" aria-hidden />
|
||||
All groups
|
||||
</Link>
|
||||
|
||||
<PageHeader
|
||||
title={group?.name ?? 'Group'}
|
||||
eyebrow="Mailing group"
|
||||
kpiLine={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Users className="h-3.5 w-3.5" aria-hidden />
|
||||
{members.length} {members.length === 1 ? 'member' : 'members'}
|
||||
{emails.length < members.length ? (
|
||||
<span className="text-amber-700">
|
||||
· {members.length - emails.length} without email
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
}
|
||||
variant="gradient"
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={emails.length === 0}
|
||||
onClick={() =>
|
||||
copyToClipboard(emails.join(', '), `Copied ${emails.length} email addresses`)
|
||||
}
|
||||
>
|
||||
<CopyCheck className="me-1.5 h-4 w-4" aria-hidden />
|
||||
Copy all emails
|
||||
</Button>
|
||||
<PermissionGate resource="client_groups" action="manage">
|
||||
<Button variant="outline" onClick={() => setManageOpen(true)}>
|
||||
<UserCog className="me-1.5 h-4 w-4" aria-hidden />
|
||||
Manage members
|
||||
</Button>
|
||||
</PermissionGate>
|
||||
<PermissionGate resource="client_groups" action="manage">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-destructive"
|
||||
onClick={() => {
|
||||
if (confirm('Archive this group? Members are kept; the group is hidden.')) {
|
||||
archive.mutate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="me-1.5 h-4 w-4" aria-hidden />
|
||||
Archive
|
||||
</Button>
|
||||
</PermissionGate>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading members…</p>
|
||||
) : members.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title="No members yet"
|
||||
description="Use “Manage members” to add clients to this group."
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-xl border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 text-left text-xs uppercase tracking-wide text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-medium">Client</th>
|
||||
<th className="px-4 py-2 font-medium">Email</th>
|
||||
<th className="px-4 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{members.map((m) => (
|
||||
<tr key={m.clientId} className="hover:bg-muted/30">
|
||||
<td className="px-4 py-2">
|
||||
<Link
|
||||
href={`/${portSlug}/clients/${m.clientId}` as Route}
|
||||
className="text-foreground hover:underline"
|
||||
>
|
||||
{m.fullName}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">{m.email ?? '—'}</td>
|
||||
<td className="px-4 py-2 text-end">
|
||||
{m.email ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(m.email!, 'Email copied')}
|
||||
aria-label={`Copy ${m.email}`}
|
||||
title="Copy email"
|
||||
className="rounded-md p-1.5 text-muted-foreground/70 transition-colors hover:bg-foreground/5 hover:text-foreground"
|
||||
>
|
||||
<Copy className="h-4 w-4" aria-hidden />
|
||||
</button>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{manageOpen ? (
|
||||
<ManageMembersDialog
|
||||
groupId={groupId}
|
||||
open={manageOpen}
|
||||
onOpenChange={setManageOpen}
|
||||
currentIds={members.map((m) => m.clientId)}
|
||||
onSaved={() => {
|
||||
qc.invalidateQueries({ queryKey: ['client-group', groupId, 'members'] });
|
||||
qc.invalidateQueries({ queryKey: ['client-groups'] });
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ManageMembersDialog({
|
||||
groupId,
|
||||
open,
|
||||
onOpenChange,
|
||||
currentIds,
|
||||
onSaved,
|
||||
}: {
|
||||
groupId: string;
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
currentIds: string[];
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set(currentIds));
|
||||
|
||||
const { data, isLoading } = useQuery<{ data: ClientOption[] }>({
|
||||
queryKey: ['clients', 'group-picker'],
|
||||
queryFn: () => apiFetch('/api/v1/clients?limit=1000'),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const clients = data?.data ?? [];
|
||||
const filtered = clients.filter((c) =>
|
||||
`${c.fullName} ${c.primaryEmail ?? ''}`.toLowerCase().includes(search.trim().toLowerCase()),
|
||||
);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch(`/api/v1/client-groups/${groupId}/members`, {
|
||||
method: 'PUT',
|
||||
body: { clientIds: Array.from(selected) },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Members updated');
|
||||
onSaved();
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (err) => toastError(err),
|
||||
});
|
||||
|
||||
function toggle(id: string) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Manage members</DialogTitle>
|
||||
<DialogDescription>
|
||||
Tick the clients who belong in this group. {selected.size} selected.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
placeholder="Search clients…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<div className="max-h-80 space-y-1 overflow-y-auto rounded-lg border border-border p-2">
|
||||
{isLoading ? (
|
||||
<p className="p-2 text-sm text-muted-foreground">Loading clients…</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="p-2 text-sm text-muted-foreground">No matching clients.</p>
|
||||
) : (
|
||||
filtered.map((c) => (
|
||||
<label
|
||||
key={c.id}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted/50"
|
||||
>
|
||||
<Checkbox checked={selected.has(c.id)} onCheckedChange={() => toggle(c.id)} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm text-foreground">{c.fullName}</span>
|
||||
{c.primaryEmail ? (
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{c.primaryEmail}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => save.mutate()} disabled={save.isPending}>
|
||||
Save members
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
170
src/components/client-groups/client-groups-list.tsx
Normal file
170
src/components/client-groups/client-groups-list.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import type { Route } from 'next';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Users } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||
import { EmptyState } from '@/components/shared/empty-state';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
|
||||
interface ClientGroupRow {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
color: string;
|
||||
memberCount: number;
|
||||
}
|
||||
|
||||
export function ClientGroupsList() {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
const qc = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [color, setColor] = useState('#6B7280');
|
||||
|
||||
const { data, isLoading } = useQuery<{ data: ClientGroupRow[] }>({
|
||||
queryKey: ['client-groups'],
|
||||
queryFn: () => apiFetch('/api/v1/client-groups'),
|
||||
});
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch('/api/v1/client-groups', {
|
||||
method: 'POST',
|
||||
body: { name: name.trim(), description: description.trim() || null, color },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Group created');
|
||||
qc.invalidateQueries({ queryKey: ['client-groups'] });
|
||||
setOpen(false);
|
||||
setName('');
|
||||
setDescription('');
|
||||
setColor('#6B7280');
|
||||
},
|
||||
onError: (err) => toastError(err),
|
||||
});
|
||||
|
||||
const groups = data?.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Client Groups"
|
||||
eyebrow="Mailing"
|
||||
description="Group clients into mailing lists. View members, copy their emails, and (once wired) sync to Mailchimp."
|
||||
variant="gradient"
|
||||
actions={
|
||||
<PermissionGate resource="client_groups" action="manage">
|
||||
<Button onClick={() => setOpen(true)}>
|
||||
<Plus className="me-1.5 h-4 w-4" aria-hidden />
|
||||
New group
|
||||
</Button>
|
||||
</PermissionGate>
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||
) : groups.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title="No groups yet"
|
||||
description="Create a group to start organising clients into mailing lists."
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{groups.map((g) => (
|
||||
<Link
|
||||
key={g.id}
|
||||
href={`/${portSlug}/client-groups/${g.id}` as Route}
|
||||
className="group rounded-xl border border-border bg-card p-4 transition-colors hover:border-brand/40 hover:bg-muted/40"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="h-3 w-3 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: g.color }}
|
||||
aria-hidden
|
||||
/>
|
||||
<h3 className="truncate font-medium text-foreground">{g.name}</h3>
|
||||
</div>
|
||||
{g.description ? (
|
||||
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">{g.description}</p>
|
||||
) : null}
|
||||
<p className="mt-3 inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Users className="h-3.5 w-3.5" aria-hidden />
|
||||
{g.memberCount} {g.memberCount === 1 ? 'member' : 'members'}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New client group</DialogTitle>
|
||||
<DialogDescription>A named mailing/segment group for this port.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cg-name">Name</Label>
|
||||
<Input
|
||||
id="cg-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Newsletter subscribers"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cg-desc">Description (optional)</Label>
|
||||
<Input
|
||||
id="cg-desc"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cg-color">Color</Label>
|
||||
<input
|
||||
id="cg-color"
|
||||
type="color"
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
className="h-9 w-16 cursor-pointer rounded-md border border-border bg-background"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => create.mutate()} disabled={!name.trim() || create.isPending}>
|
||||
Create group
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -54,11 +54,22 @@ export function ClientCard({ client, portSlug, onEdit, onArchive }: ClientCardPr
|
||||
|
||||
const interest = client.latestInterest ?? null;
|
||||
const interestCount = client.interestCount ?? 0;
|
||||
const interestBerthLabel = interest
|
||||
? interest.mooringNumber
|
||||
? `Berth ${interest.mooringNumber}`
|
||||
: 'General interest'
|
||||
: null;
|
||||
// Show ALL berths the client has interests in (across every interest),
|
||||
// not just the latest interest's primary mooring — matches the desktop
|
||||
// table's Berths column + the interest header. Cap the inline list so
|
||||
// the card stays compact; overflow folds into a "+N" suffix.
|
||||
const linkedBerths = client.linkedBerths ?? [];
|
||||
const MAX_BERTHS_SHOWN = 4;
|
||||
const shownMoorings = linkedBerths.slice(0, MAX_BERTHS_SHOWN).map((b) => b.mooringNumber);
|
||||
const extraBerths = linkedBerths.length - shownMoorings.length;
|
||||
const interestBerthLabel =
|
||||
shownMoorings.length > 0
|
||||
? `${linkedBerths.length === 1 ? 'Berth' : 'Berths'} ${shownMoorings.join(', ')}${
|
||||
extraBerths > 0 ? ` +${extraBerths}` : ''
|
||||
}`
|
||||
: interest
|
||||
? 'General interest'
|
||||
: null;
|
||||
const interestStageLabel = interest ? stageLabel(interest.stage) : null;
|
||||
const interestStageBadge = interest ? stageBadgeClass(interest.stage) : null;
|
||||
const extraInterests = interestCount > 1 ? interestCount - 1 : 0;
|
||||
|
||||
@@ -3,11 +3,9 @@
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import type { Route } from 'next';
|
||||
import { useState } from 'react';
|
||||
import { Archive, Bell, Mail, Phone, RotateCcw, Trash2 } from 'lucide-react';
|
||||
import { WhatsAppIcon } from '@/components/icons/whatsapp';
|
||||
import { Archive, Bell, RotateCcw, Trash2 } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TagBadge } from '@/components/shared/tag-badge';
|
||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||
@@ -56,18 +54,6 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
||||
const primaryEmail =
|
||||
client.contacts?.find((c) => c.channel === 'email' && c.isPrimary)?.value ??
|
||||
client.contacts?.find((c) => c.channel === 'email')?.value;
|
||||
const primaryPhoneContact =
|
||||
client.contacts?.find((c) => c.channel === 'phone' && c.isPrimary) ??
|
||||
client.contacts?.find((c) => c.channel === 'phone');
|
||||
const primaryPhone = primaryPhoneContact?.value;
|
||||
// wa.me requires the E.164 number without the leading "+". Strip from the
|
||||
// canonical E.164 form when available; otherwise strip non-digits from the
|
||||
// display value as a best-effort fallback.
|
||||
const whatsappNumber = primaryPhoneContact?.valueE164
|
||||
? primaryPhoneContact.valueE164.replace(/^\+/, '')
|
||||
: primaryPhoneContact?.value
|
||||
? primaryPhoneContact.value.replace(/[^\d]/g, '')
|
||||
: null;
|
||||
|
||||
const country = client.nationalityIso ? getCountryName(client.nationalityIso, 'en') : null;
|
||||
const addedLabel = client.createdAt
|
||||
@@ -107,52 +93,11 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-1">
|
||||
{primaryEmail ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
||||
>
|
||||
<a href={`mailto:${primaryEmail}`} aria-label={`Email ${primaryEmail}`}>
|
||||
<Mail />
|
||||
Email
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
{primaryPhone ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
||||
>
|
||||
<a href={`tel:${primaryPhone}`} aria-label={`Call ${primaryPhone}`}>
|
||||
<Phone />
|
||||
Call
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
{whatsappNumber ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
||||
>
|
||||
<a
|
||||
href={`https://wa.me/${whatsappNumber}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`Message ${primaryPhone} on WhatsApp`}
|
||||
>
|
||||
<WhatsAppIcon className="h-4 w-4" />
|
||||
WhatsApp
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
{!isArchived && client.clientPortalEnabled === true ? (
|
||||
{/* CM-4: Email/Call/WhatsApp deep-link pills removed at client
|
||||
request. GDPR export moved to the top-right action cluster.
|
||||
Portal-invite stays as the one primary CTA here. */}
|
||||
{!isArchived && client.clientPortalEnabled === true ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-1">
|
||||
<div className="hidden sm:inline-flex">
|
||||
<PortalInviteButton
|
||||
clientId={client.id}
|
||||
@@ -160,11 +105,8 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
||||
defaultEmail={primaryEmail}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="hidden sm:inline-flex">
|
||||
<GdprExportButton clientId={client.id} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{client.tags && client.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
@@ -179,6 +121,9 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
||||
right perm) permanently-delete. Destructive actions sit out
|
||||
of the primary action flow. */}
|
||||
<div className="flex items-start gap-1">
|
||||
{/* CM-4: GDPR export relocated here as a compact icon trigger,
|
||||
alongside reminder/archive/delete. Self-gates on permission. */}
|
||||
<GdprExportButton clientId={client.id} variant="icon" />
|
||||
{isArchived && (
|
||||
<PermissionGate resource="admin" action="permanently_delete_clients">
|
||||
<button
|
||||
|
||||
@@ -11,6 +11,7 @@ import { RemindersInline } from '@/components/reminders/reminders-inline';
|
||||
import { primaryTimezoneFor } from '@/lib/i18n/timezones';
|
||||
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
|
||||
import { NotesList } from '@/components/shared/notes-list';
|
||||
import { ProxyCard } from '@/components/shared/proxy-card';
|
||||
import type { CountryCode } from '@/lib/i18n/countries';
|
||||
import { ClientInterestsTab } from '@/components/clients/client-interests-tab';
|
||||
import { ClientPipelineSummary } from '@/components/clients/client-pipeline-summary';
|
||||
@@ -156,6 +157,9 @@ function OverviewTab({
|
||||
<ClientPipelineSummary clientId={clientId} variant="panel" />
|
||||
</div>
|
||||
|
||||
{/* CM-9: point-of-contact (default level for the client). */}
|
||||
<ProxyCard entityType="client" entityId={clientId} />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Personal Info */}
|
||||
<div className="space-y-1">
|
||||
|
||||
119
src/components/clients/clients-by-country-page.tsx
Normal file
119
src/components/clients/clients-by-country-page.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import type { Route } from 'next';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Globe } from 'lucide-react';
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { CountryFlag } from '@/components/shared/country-flag';
|
||||
import { getCountryName } from '@/lib/i18n/countries';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ClientsByCountryRow {
|
||||
country: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface ClientsByCountryResponse {
|
||||
data: ClientsByCountryRow[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full per-country breakdown of the active (non-archived) client book — the
|
||||
* "Show all" destination for the dashboard `ClientsByCountryWidget`, which
|
||||
* only shows the top N. Same endpoint (it already returns every row); this
|
||||
* page just renders the complete ranked list. Each row deep-links into the
|
||||
* clients list filtered by that nationality.
|
||||
*/
|
||||
export function ClientsByCountryPage() {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
|
||||
const { data, isLoading } = useQuery<ClientsByCountryResponse>({
|
||||
queryKey: ['dashboard', 'clients-by-country', 'all'],
|
||||
queryFn: () => apiFetch<ClientsByCountryResponse>('/api/v1/dashboard/clients-by-country'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const rows = data?.data ?? [];
|
||||
const total = data?.total ?? rows.reduce((s, r) => s + r.count, 0);
|
||||
const maxCount = rows.reduce((m, r) => Math.max(m, r.count), 0) || 1;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
eyebrow="Clients"
|
||||
title="Clients by country"
|
||||
description="Every country represented in the active client book, ranked by client count. Select a row to view those clients."
|
||||
kpiLine={
|
||||
rows.length > 0
|
||||
? `${total} client${total === 1 ? '' : 's'} across ${rows.length} ${
|
||||
rows.length === 1 ? 'country' : 'countries'
|
||||
}.`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4 sm:p-6">
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-8 w-full" aria-hidden />
|
||||
))}
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="flex h-40 flex-col items-center justify-center gap-2 text-center text-sm text-muted-foreground">
|
||||
<Globe className="size-6" aria-hidden />
|
||||
<p>No clients with a country recorded yet.</p>
|
||||
</div>
|
||||
) : (
|
||||
<ol className="space-y-1">
|
||||
{rows.map((row, i) => {
|
||||
const pct = (row.count / maxCount) * 100;
|
||||
const name = getCountryName(row.country) || row.country;
|
||||
return (
|
||||
<li key={row.country}>
|
||||
<Link
|
||||
href={
|
||||
`/${portSlug}/clients?nationality=${encodeURIComponent(row.country)}` as Route
|
||||
}
|
||||
className="group flex items-center justify-between gap-3 rounded-md px-2 py-2 hover:bg-foreground/5"
|
||||
title={`${row.count} client${row.count === 1 ? '' : 's'} in ${name}`}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<span className="w-6 shrink-0 text-end text-xs tabular-nums text-muted-foreground">
|
||||
{i + 1}
|
||||
</span>
|
||||
<CountryFlag code={row.country} className="h-3.5 w-5" decorative />
|
||||
<span className="truncate text-sm font-medium">{name}</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
<div className="h-1.5 w-32 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={cn('h-full rounded-full bg-brand-500')}
|
||||
style={{ width: `${pct}%` }}
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
<span className="w-10 text-end text-sm tabular-nums text-foreground">
|
||||
{row.count}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -48,7 +48,15 @@ const STATUS_VARIANT: Record<ExportRow['status'], 'secondary' | 'outline' | 'des
|
||||
failed: 'destructive',
|
||||
};
|
||||
|
||||
export function GdprExportButton({ clientId }: { clientId: string }) {
|
||||
export function GdprExportButton({
|
||||
clientId,
|
||||
variant = 'button',
|
||||
}: {
|
||||
clientId: string;
|
||||
/** `button` = standalone outline button (default). `icon` = compact icon-only
|
||||
* trigger for the detail-header top-right action cluster (CM-4). */
|
||||
variant?: 'button' | 'icon';
|
||||
}) {
|
||||
const { can, isSuperAdmin } = usePermissions();
|
||||
const qc = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -110,10 +118,21 @@ export function GdprExportButton({ clientId }: { clientId: string }) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-8">
|
||||
<FileDown className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
GDPR export
|
||||
</Button>
|
||||
{variant === 'icon' ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="GDPR export"
|
||||
title="GDPR export"
|
||||
className="shrink-0 rounded-md p-1.5 text-muted-foreground/70 transition-colors hover:bg-foreground/5 hover:text-foreground"
|
||||
>
|
||||
<FileDown className="size-4" aria-hidden />
|
||||
</button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" className="h-8">
|
||||
<FileDown className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
GDPR export
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
|
||||
@@ -4,7 +4,7 @@ import Link from 'next/link';
|
||||
import type { Route } from 'next';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Globe } from 'lucide-react';
|
||||
import { ArrowRight, Globe } from 'lucide-react';
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
@@ -126,8 +126,14 @@ export function ClientsByCountryWidget({ limit = 8 }: { limit?: number } = {}) {
|
||||
);
|
||||
})}
|
||||
{hiddenCount > 0 ? (
|
||||
<li className="pt-1 text-xs text-muted-foreground">
|
||||
+ {hiddenCount} more {hiddenCount === 1 ? 'country' : 'countries'} not shown.
|
||||
<li className="border-t pt-2 text-right">
|
||||
<Link
|
||||
href={`/${portSlug}/clients/by-country` as Route}
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Show all {rows.length} countries
|
||||
<ArrowRight className="size-3" aria-hidden />
|
||||
</Link>
|
||||
</li>
|
||||
) : null}
|
||||
</ol>
|
||||
|
||||
@@ -164,7 +164,10 @@ export const DASHBOARD_WIDGETS: readonly DashboardWidget[] = [
|
||||
label: 'Pipeline Value',
|
||||
description:
|
||||
'Gross + weighted forecast, broken down by pipeline stage so leadership can see what is near-close vs speculative.',
|
||||
render: (range) => <PipelineValueTile range={range} />,
|
||||
// Current-state snapshot: pipeline value = sum across ALL active deals,
|
||||
// not "added in the selected window". Don't thread the range (UAT
|
||||
// 2026-06-03 — windowing it dropped older deals + confused the headline).
|
||||
render: () => <PipelineValueTile />,
|
||||
// Lives in the chart grid (not the narrow rail) so the per-stage
|
||||
// breakdown rows have room to breathe alongside the headline numbers,
|
||||
// and the rail stays reserved for reminders / alerts / glance tiles.
|
||||
|
||||
@@ -299,7 +299,7 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) {
|
||||
{isComplete && doc.signedFileId ? (
|
||||
<>
|
||||
<Button asChild size="sm">
|
||||
<Link href={`/api/v1/files/${doc.signedFileId}/download`}>
|
||||
<Link href={`/api/v1/files/${doc.signedFileId}/download?redirect=1`}>
|
||||
<Download className="mr-1.5 h-4 w-4" aria-hidden /> Download signed PDF
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@@ -4,7 +4,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { ChevronDown, ChevronRight, FileText, Folder, Lock, Plus, Upload } from 'lucide-react';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Download,
|
||||
FileText,
|
||||
Folder,
|
||||
Lock,
|
||||
Plus,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -16,9 +25,11 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
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 { FilePreviewDialog } from '@/components/files/file-preview-dialog';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
||||
@@ -336,6 +347,30 @@ function FlatFolderListing({
|
||||
const [typeFilter, setTypeFilter] = useState<string | undefined>(undefined);
|
||||
const [expandedDocId, setExpandedDocId] = useState<string | null>(null);
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
// File selected for inline preview. Clicking a file row opens the shared
|
||||
// FilePreviewDialog rather than navigating the browser at the JSON-returning
|
||||
// `/files/[id]/download` endpoint (which used to dump raw `{data:{url}}`).
|
||||
const [previewFile, setPreviewFile] = useState<HubFile | null>(null);
|
||||
|
||||
// Force-download a stored file: the `/download` route returns a presigned
|
||||
// URL (content-disposition=attachment) as a JSON envelope, so we fetch it
|
||||
// then click a hidden anchor. Avoids navigating the tab to the raw JSON.
|
||||
const downloadFile = useCallback(async (file: HubFile) => {
|
||||
try {
|
||||
const { data } = await apiFetch<{ data: { url: string; filename: string } }>(
|
||||
`/api/v1/files/${file.id}/download`,
|
||||
);
|
||||
const a = document.createElement('a');
|
||||
a.href = data.url;
|
||||
a.rel = 'noopener';
|
||||
a.download = file.originalName ?? file.filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
} catch {
|
||||
// apiFetch surfaces its own toast on failure; nothing else to do here.
|
||||
}
|
||||
}, []);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const queryParams = useMemo(() => {
|
||||
@@ -489,9 +524,10 @@ function FlatFolderListing({
|
||||
};
|
||||
|
||||
// Uploaded-file row — simpler than a signature doc since there's no
|
||||
// signer/status concept. Links to the underlying file via download URL
|
||||
// and surfaces an "Uploaded" type pill so the rep distinguishes it
|
||||
// from signature workflows at a glance.
|
||||
// signer/status concept. Clicking the name opens an inline preview
|
||||
// (FilePreviewDialog); a dedicated download button saves the file. An
|
||||
// "Uploaded" type pill distinguishes it from signature workflows.
|
||||
const fileLabel = (file: HubFile) => file.originalName ?? file.filename;
|
||||
const renderFileRow = (file: HubFile) => {
|
||||
return (
|
||||
<li
|
||||
@@ -501,14 +537,14 @@ function FlatFolderListing({
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 px-4 py-3 text-sm sm:grid sm:grid-cols-[auto_1fr_auto_auto_auto_auto] sm:gap-3">
|
||||
{/* Empty action column to align with doc-row layout */}
|
||||
<span className="hidden h-[44px] w-[44px] sm:block" aria-hidden />
|
||||
<a
|
||||
href={`/api/v1/files/${file.id}/download`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="min-w-0 truncate font-medium text-foreground hover:text-brand"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreviewFile(file)}
|
||||
className="min-w-0 truncate text-left font-medium text-foreground hover:text-brand"
|
||||
title={`Preview ${fileLabel(file)}`}
|
||||
>
|
||||
{file.originalName ?? file.filename}
|
||||
</a>
|
||||
{fileLabel(file)}
|
||||
</button>
|
||||
<span className="text-xs text-muted-foreground">Uploaded file</span>
|
||||
<StatusPill status="completed" withDot>
|
||||
Stored
|
||||
@@ -516,9 +552,21 @@ function FlatFolderListing({
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{(file.sizeBytes / 1024).toFixed(0)} KB
|
||||
</span>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{new Date(file.createdAt).toLocaleDateString(undefined)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{new Date(file.createdAt).toLocaleDateString(undefined)}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => void downloadFile(file)}
|
||||
title={`Download ${fileLabel(file)}`}
|
||||
aria-label={`Download ${fileLabel(file)}`}
|
||||
>
|
||||
<Download className="h-4 w-4" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
@@ -526,6 +574,15 @@ function FlatFolderListing({
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<FilePreviewDialog
|
||||
open={!!previewFile}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) setPreviewFile(null);
|
||||
}}
|
||||
fileId={previewFile?.id}
|
||||
fileName={previewFile ? fileLabel(previewFile) : undefined}
|
||||
mimeType={previewFile?.mimeType ?? undefined}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
placeholder="Search by title..."
|
||||
|
||||
@@ -75,7 +75,7 @@ function ReceiptThumbnail({ fileId }: { fileId: string }) {
|
||||
<div className="mt-2 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span className="truncate">{mime || (isError ? 'Receipt' : 'File')}</span>
|
||||
<a
|
||||
href={`/api/v1/files/${fileId}/download`}
|
||||
href={`/api/v1/files/${fileId}/download?redirect=1`}
|
||||
className="inline-flex items-center gap-1 text-primary hover:underline"
|
||||
>
|
||||
<Download className="h-3 w-3" aria-hidden /> Download
|
||||
|
||||
87
src/components/files/docx-viewer.tsx
Normal file
87
src/components/files/docx-viewer.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* In-app .docx viewer.
|
||||
*
|
||||
* Renders Word OOXML (.docx) client-side via `docx-preview` (lazy-loaded
|
||||
* so the ~library cost only lands on routes that actually preview a docx).
|
||||
* We fetch the bytes from our own storage URL and render them in-browser —
|
||||
* deliberately NOT delegating to Microsoft's hosted Office viewer, which
|
||||
* requires a publicly-reachable URL and so can't render documents stored
|
||||
* in our private object store.
|
||||
*
|
||||
* Legacy .doc / .xls / .xlsx are not handled here (docx-preview is OOXML-
|
||||
* Word only); the preview dialog routes those to a download CTA instead.
|
||||
*/
|
||||
export function DocxViewer({ url, fileName }: { url: string; fileName?: string }) {
|
||||
// Key-based remount on url change keeps render state (loading/error +
|
||||
// the imperatively-populated container) re-initialised from scratch,
|
||||
// mirroring PdfViewer.
|
||||
return <DocxViewerBody key={url} url={url} fileName={fileName} />;
|
||||
}
|
||||
|
||||
function DocxViewerBody({ url, fileName }: { url: string; fileName?: string }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function render() {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`Failed to load document (${res.status})`);
|
||||
const blob = await res.blob();
|
||||
if (cancelled) return;
|
||||
const { renderAsync } = await import('docx-preview');
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
await renderAsync(blob, container, undefined, {
|
||||
className: 'docx',
|
||||
inWrapper: true,
|
||||
// Let the document flow to the container width rather than
|
||||
// forcing fixed A4 page metrics that overflow the dialog.
|
||||
ignoreWidth: true,
|
||||
ignoreHeight: true,
|
||||
breakPages: true,
|
||||
});
|
||||
if (!cancelled) setError(null);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to render document');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
void render();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
return (
|
||||
<div className="relative h-full overflow-auto bg-muted/30 p-4">
|
||||
{loading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
||||
Rendering document…
|
||||
</div>
|
||||
)}
|
||||
{error && !loading && (
|
||||
<div className="flex h-full items-center justify-center px-6 text-center text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={containerRef}
|
||||
aria-label={fileName ?? 'Document preview'}
|
||||
className="mx-auto max-w-3xl [&_.docx-wrapper]:bg-transparent [&_.docx-wrapper]:p-0 [&_.docx-wrapper>section.docx]:mx-auto [&_.docx-wrapper>section.docx]:mb-4 [&_.docx-wrapper>section.docx]:bg-white [&_.docx-wrapper>section.docx]:shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { isWordDocx } from '@/lib/constants/file-validation';
|
||||
|
||||
// yet-another-react-lightbox is ~50kb, lazy-load it.
|
||||
const Lightbox = dynamic(() => import('yet-another-react-lightbox'), { ssr: false });
|
||||
@@ -30,6 +31,16 @@ const PdfViewer = dynamic(() => import('./pdf-viewer').then((m) => ({ default: m
|
||||
),
|
||||
});
|
||||
|
||||
// docx-preview is lazy-loaded the same way — only .docx previews pull it in.
|
||||
const DocxViewer = dynamic(() => import('./docx-viewer').then((m) => ({ default: m.DocxViewer })), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
Loading document viewer…
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
interface FilePreviewDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -93,7 +104,7 @@ export function FilePreviewDialog({
|
||||
|
||||
// useQuery replaces the prior useEffect(fetch+setState) pattern. The
|
||||
// request is gated on the dialog being open and a fileId being set.
|
||||
const previewQuery = useQuery<{ data: { url: string } }>({
|
||||
const previewQuery = useQuery<{ data: { url: string; mimeType?: string } }>({
|
||||
queryKey: ['file-preview', fileId],
|
||||
queryFn: () => apiFetch(`/api/v1/files/${fileId}/preview`),
|
||||
enabled: open && !!fileId,
|
||||
@@ -102,7 +113,13 @@ export function FilePreviewDialog({
|
||||
const loading = previewQuery.isLoading;
|
||||
const error = previewQuery.error ? 'Failed to load preview' : null;
|
||||
|
||||
const kind = previewKindFor(mimeType, fileName);
|
||||
// Prefer the caller-supplied mime, but fall back to the server's resolved
|
||||
// mime (getPreviewUrl returns it). Without this, callers that pass only a
|
||||
// display name (e.g. the EOI tab passing "EOI - <client>") or files whose
|
||||
// stored name lacks a `.pdf` extension (migration-backfilled EOIs) fall
|
||||
// through to the "unknown" surface even though the server knows it's a PDF.
|
||||
const resolvedMime = mimeType ?? previewQuery.data?.data.mimeType;
|
||||
const kind = previewKindFor(resolvedMime, fileName);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -110,12 +127,24 @@ export function FilePreviewDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 truncate">
|
||||
<span className="truncate">{fileName ?? 'Preview'}</span>
|
||||
{fileId && (
|
||||
<a
|
||||
href={`/api/v1/files/${fileId}/download?redirect=1`}
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
title="Download"
|
||||
aria-label={`Download ${fileName ?? 'file'}`}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</a>
|
||||
)}
|
||||
{previewUrl && (
|
||||
<a
|
||||
href={previewUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
title="Open in new tab"
|
||||
aria-label="Open in new tab"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
@@ -185,24 +214,34 @@ export function FilePreviewDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && previewUrl && kind === 'office' && (
|
||||
// Office documents render via Microsoft's hosted Office viewer
|
||||
// - public URL only; presigned download URLs include a token
|
||||
// in the query string so they work here even though the file
|
||||
// isn't world-public. The viewer streams the document and
|
||||
// renders a high-fidelity preview without us shipping a
|
||||
// headless LibreOffice. Falls back to "download to view" if
|
||||
// the embed loads but renders nothing (e.g. CORS rejected) -
|
||||
// detection is hard so we just keep the download CTA below.
|
||||
<iframe
|
||||
title={fileName ?? 'Office document preview'}
|
||||
src={`https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(
|
||||
previewUrl,
|
||||
)}`}
|
||||
className="h-full w-full"
|
||||
sandbox="allow-scripts allow-same-origin allow-popups"
|
||||
/>
|
||||
)}
|
||||
{!loading &&
|
||||
!error &&
|
||||
previewUrl &&
|
||||
kind === 'office' &&
|
||||
// Word .docx renders in-browser via docx-preview (fetches the
|
||||
// bytes from our own storage — works with private MinIO/disk).
|
||||
// We do NOT use Microsoft's hosted Office viewer: it requires a
|
||||
// publicly-reachable URL, which our private object store isn't.
|
||||
// Legacy .doc + spreadsheet formats can't be rendered client-
|
||||
// side, so they fall through to a download CTA.
|
||||
(isWordDocx(mimeType, fileName) ? (
|
||||
<DocxViewer url={previewUrl} fileName={fileName} />
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
|
||||
<FileWarning className="size-8 text-muted-foreground" aria-hidden />
|
||||
<p className="text-sm font-medium">In-browser preview isn't available</p>
|
||||
<p className="max-w-xs text-xs text-muted-foreground">
|
||||
This Office format ({mimeType ?? 'unknown'}) can't be rendered in the
|
||||
browser. Download it to view locally.
|
||||
</p>
|
||||
<Button asChild>
|
||||
<a href={previewUrl} download={fileName ?? 'download'}>
|
||||
<Download className="mr-1.5 size-4" aria-hidden />
|
||||
Download
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!loading && !error && previewUrl && kind === 'unknown' && (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
|
||||
|
||||
35
src/components/inquiries/inquiry-card.tsx
Normal file
35
src/components/inquiries/inquiry-card.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { formatDistanceToNowStrict } from 'date-fns';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { KIND_LABELS, TRIAGE_TONE, type InquiryRow } from '@/components/inquiries/inquiry-columns';
|
||||
|
||||
export function InquiryCard({ inquiry, portSlug }: { inquiry: InquiryRow; portSlug: string }) {
|
||||
return (
|
||||
<Link href={`/${portSlug}/inquiries/${inquiry.id}`} className="block">
|
||||
<Card className="transition-shadow hover:shadow-sm">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{inquiry.contactName || '(no name)'}</p>
|
||||
{inquiry.contactEmail ? (
|
||||
<p className="truncate text-sm text-muted-foreground">{inquiry.contactEmail}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Badge className={TRIAGE_TONE[inquiry.triageState]}>{inquiry.triageState}</Badge>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{KIND_LABELS[inquiry.kind]}</span>
|
||||
<span>·</span>
|
||||
<span>
|
||||
{formatDistanceToNowStrict(new Date(inquiry.receivedAt), { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
224
src/components/inquiries/inquiry-columns.tsx
Normal file
224
src/components/inquiries/inquiry-columns.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { format, formatDistanceToNowStrict } from 'date-fns';
|
||||
import { MoreHorizontal, UserCheck, X, ExternalLink } from 'lucide-react';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
export type InquiryKind = 'berth_inquiry' | 'residence_inquiry' | 'contact_form';
|
||||
export type InquiryTriageState = 'open' | 'assigned' | 'converted' | 'dismissed';
|
||||
|
||||
export interface InquiryRow {
|
||||
id: string;
|
||||
kind: InquiryKind;
|
||||
contactName: string | null;
|
||||
contactEmail: string | null;
|
||||
receivedAt: string;
|
||||
triageState: InquiryTriageState;
|
||||
convertedClientId: string | null;
|
||||
convertedInterestId: string | null;
|
||||
sourceIp: string | null;
|
||||
utmSource?: string | null;
|
||||
}
|
||||
|
||||
export const KIND_LABELS: Record<InquiryKind, string> = {
|
||||
berth_inquiry: 'Berth',
|
||||
residence_inquiry: 'Residence',
|
||||
contact_form: 'Contact',
|
||||
};
|
||||
|
||||
const KIND_TONE: Record<InquiryKind, string> = {
|
||||
berth_inquiry: 'bg-blue-100 text-blue-800',
|
||||
residence_inquiry: 'bg-amber-100 text-amber-900',
|
||||
contact_form: 'bg-slate-100 text-slate-800',
|
||||
};
|
||||
|
||||
export const TRIAGE_TONE: Record<InquiryTriageState, string> = {
|
||||
open: 'bg-blue-100 text-blue-800',
|
||||
assigned: 'bg-amber-100 text-amber-900',
|
||||
converted: 'bg-emerald-100 text-emerald-800',
|
||||
dismissed: 'bg-slate-100 text-slate-600',
|
||||
};
|
||||
|
||||
export const TRIAGE_LABELS: Record<InquiryTriageState, string> = {
|
||||
open: 'Open',
|
||||
assigned: 'Assigned',
|
||||
converted: 'Converted',
|
||||
dismissed: 'Dismissed',
|
||||
};
|
||||
|
||||
export const INQUIRY_COLUMN_OPTIONS: Array<{ id: string; label: string }> = [
|
||||
{ id: 'contactEmail', label: 'Email' },
|
||||
{ id: 'kind', label: 'Type' },
|
||||
{ id: 'triageState', label: 'Status' },
|
||||
{ id: 'utmSource', label: 'UTM source' },
|
||||
{ id: 'receivedAt', label: 'Received' },
|
||||
];
|
||||
|
||||
export const INQUIRY_DEFAULT_HIDDEN: string[] = ['utmSource'];
|
||||
|
||||
interface GetColumnsOptions {
|
||||
portSlug: string;
|
||||
onTriage: (row: InquiryRow, state: InquiryTriageState) => void;
|
||||
}
|
||||
|
||||
export function getInquiryColumns({
|
||||
portSlug,
|
||||
onTriage,
|
||||
}: GetColumnsOptions): ColumnDef<InquiryRow, unknown>[] {
|
||||
return [
|
||||
{
|
||||
id: 'contactName',
|
||||
accessorKey: 'contactName',
|
||||
header: 'Name',
|
||||
cell: ({ row }) => (
|
||||
<Link
|
||||
href={`/${portSlug}/inquiries/${row.original.id}`}
|
||||
className="truncate font-medium text-primary hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{row.original.contactName || '(no name)'}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'contactEmail',
|
||||
accessorKey: 'contactEmail',
|
||||
header: 'Email',
|
||||
enableSorting: false,
|
||||
cell: ({ getValue }) => {
|
||||
const email = getValue() as string | null;
|
||||
return email ? (
|
||||
<span className="text-sm">{email}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'kind',
|
||||
accessorKey: 'kind',
|
||||
header: 'Type',
|
||||
cell: ({ getValue }) => {
|
||||
const kind = getValue() as InquiryKind;
|
||||
return <Badge className={KIND_TONE[kind]}>{KIND_LABELS[kind]}</Badge>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'triageState',
|
||||
accessorKey: 'triageState',
|
||||
header: 'Status',
|
||||
cell: ({ row }) => {
|
||||
const state = row.original.triageState;
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Badge className={TRIAGE_TONE[state]}>{TRIAGE_LABELS[state]}</Badge>
|
||||
{row.original.convertedInterestId ? (
|
||||
<Link
|
||||
href={`/${portSlug}/interests/${row.original.convertedInterestId}`}
|
||||
className="text-primary hover:underline text-xs"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
interest →
|
||||
</Link>
|
||||
) : row.original.convertedClientId ? (
|
||||
<Link
|
||||
href={`/${portSlug}/clients/${row.original.convertedClientId}`}
|
||||
className="text-primary hover:underline text-xs"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
client →
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'utmSource',
|
||||
accessorKey: 'utmSource',
|
||||
header: 'UTM source',
|
||||
enableSorting: false,
|
||||
cell: ({ getValue }) => {
|
||||
const utm = getValue() as string | null;
|
||||
return utm ? (
|
||||
<span className="text-sm">{utm}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'receivedAt',
|
||||
accessorKey: 'receivedAt',
|
||||
header: 'Received',
|
||||
cell: ({ getValue }) => {
|
||||
const iso = getValue() as string;
|
||||
const d = new Date(iso);
|
||||
return (
|
||||
<span className="text-muted-foreground text-sm tabular-nums" title={format(d, 'PPpp')}>
|
||||
{formatDistanceToNowStrict(d, { addSuffix: true })}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
enableSorting: false,
|
||||
size: 48,
|
||||
cell: ({ row }) => {
|
||||
const isResolved =
|
||||
row.original.triageState === 'converted' || row.original.triageState === 'dismissed';
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
aria-label={`Row actions for ${row.original.contactName ?? 'inquiry'}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" aria-hidden />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/${portSlug}/inquiries/${row.original.id}`}>
|
||||
<ExternalLink className="mr-2 h-3.5 w-3.5" aria-hidden />
|
||||
Open
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{!isResolved ? (
|
||||
<>
|
||||
<DropdownMenuItem onClick={() => onTriage(row.original, 'assigned')}>
|
||||
<UserCheck className="mr-2 h-3.5 w-3.5" aria-hidden />
|
||||
Assign to me
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onTriage(row.original, 'dismissed')}>
|
||||
<X className="mr-2 h-3.5 w-3.5" aria-hidden />
|
||||
Dismiss
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : (
|
||||
<DropdownMenuItem onClick={() => onTriage(row.original, 'open')}>
|
||||
Reopen
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
119
src/components/inquiries/inquiry-convert-actions.tsx
Normal file
119
src/components/inquiries/inquiry-convert-actions.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { ArrowRight, UserPlus, UserCheck, X } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
|
||||
interface InquiryConvertActionsProps {
|
||||
portSlug: string;
|
||||
inquiry: {
|
||||
id: string;
|
||||
triageState: string;
|
||||
convertedClientId: string | null;
|
||||
convertedInterestId: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export function InquiryConvertActions({ portSlug, inquiry }: InquiryConvertActionsProps) {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['inquiries'] });
|
||||
};
|
||||
|
||||
const convert = useMutation({
|
||||
mutationFn: (target: 'client' | 'interest') =>
|
||||
apiFetch<{ data: { clientId: string; interestId: string | null } }>(
|
||||
`/api/v1/inquiries/${inquiry.id}/convert`,
|
||||
{ method: 'POST', body: { target } },
|
||||
),
|
||||
onSuccess: (res, target) => {
|
||||
invalidate();
|
||||
if (target === 'interest' && res.data.interestId) {
|
||||
toast.success('Converted to interest.');
|
||||
router.push(`/${portSlug}/interests/${res.data.interestId}`);
|
||||
} else {
|
||||
toast.success('Converted to client.');
|
||||
router.push(`/${portSlug}/clients/${res.data.clientId}`);
|
||||
}
|
||||
},
|
||||
onError: (err: unknown) => toastError(err, 'Convert failed'),
|
||||
});
|
||||
|
||||
const triage = useMutation({
|
||||
mutationFn: (state: 'open' | 'assigned' | 'dismissed') =>
|
||||
apiFetch(`/api/v1/inquiries/${inquiry.id}/triage`, { method: 'PATCH', body: { state } }),
|
||||
onSuccess: (_d, state) => {
|
||||
invalidate();
|
||||
toast.success(`Marked ${state}.`);
|
||||
},
|
||||
onError: (err: unknown) => toastError(err, 'Update failed'),
|
||||
});
|
||||
|
||||
const busy = convert.isPending || triage.isPending;
|
||||
const alreadyInterest = Boolean(inquiry.convertedInterestId);
|
||||
|
||||
return (
|
||||
<PermissionGate resource="inquiries" action="manage">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{alreadyInterest ? (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={`/${portSlug}/interests/${inquiry.convertedInterestId}`}>View interest</a>
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" disabled={busy} onClick={() => convert.mutate('interest')}>
|
||||
<ArrowRight className="mr-1.5 h-4 w-4" aria-hidden />
|
||||
Convert to interest
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{inquiry.convertedClientId ? (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={`/${portSlug}/clients/${inquiry.convertedClientId}`}>View client</a>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => convert.mutate('client')}
|
||||
>
|
||||
<UserPlus className="mr-1.5 h-4 w-4" aria-hidden />
|
||||
Convert to client
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{inquiry.triageState === 'open' ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => triage.mutate('assigned')}
|
||||
>
|
||||
<UserCheck className="mr-1.5 h-4 w-4" aria-hidden />
|
||||
Assign to me
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{inquiry.triageState !== 'dismissed' && inquiry.triageState !== 'converted' ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => triage.mutate('dismissed')}
|
||||
>
|
||||
<X className="mr-1.5 h-4 w-4" aria-hidden />
|
||||
Dismiss
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</PermissionGate>
|
||||
);
|
||||
}
|
||||
197
src/components/inquiries/inquiry-detail.tsx
Normal file
197
src/components/inquiries/inquiry-detail.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
import { DetailLayout, type DetailTab } from '@/components/shared/detail-layout';
|
||||
import { DetailNotFound } from '@/components/shared/detail-not-found';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { usePermissions } from '@/hooks/use-permissions';
|
||||
import {
|
||||
KIND_LABELS,
|
||||
TRIAGE_LABELS,
|
||||
TRIAGE_TONE,
|
||||
type InquiryKind,
|
||||
type InquiryTriageState,
|
||||
} from '@/components/inquiries/inquiry-columns';
|
||||
import { InquiryConvertActions } from '@/components/inquiries/inquiry-convert-actions';
|
||||
|
||||
interface InquiryDetailData {
|
||||
id: string;
|
||||
kind: InquiryKind;
|
||||
contactName: string | null;
|
||||
contactEmail: string | null;
|
||||
payload: Record<string, unknown> | null;
|
||||
receivedAt: string;
|
||||
sourceIp: string | null;
|
||||
utmSource: string | null;
|
||||
utmMedium: string | null;
|
||||
utmCampaign: string | null;
|
||||
triageState: InquiryTriageState;
|
||||
triagedAt: string | null;
|
||||
convertedClientId: string | null;
|
||||
convertedInterestId: string | null;
|
||||
convertedClient: { id: string; fullName: string } | null;
|
||||
convertedInterest: { id: string; pipelineStage: string } | null;
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[140px_1fr] gap-2 py-1.5 text-sm">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="min-w-0 break-words">
|
||||
{value || <span className="text-muted-foreground">—</span>}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InquiryDetail({ id }: { id: string }) {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
const { isSuperAdmin } = usePermissions();
|
||||
|
||||
const { data, isLoading, error } = useQuery<InquiryDetailData>({
|
||||
queryKey: ['inquiries', id],
|
||||
queryFn: () =>
|
||||
apiFetch<{ data: InquiryDetailData }>(`/api/v1/inquiries/${id}`).then((r) => r.data),
|
||||
retry: (count, err) => {
|
||||
const status = (err as { status?: number })?.status;
|
||||
return status === 404 || status === 403 ? false : count < 2;
|
||||
},
|
||||
});
|
||||
|
||||
if (error && !isLoading) {
|
||||
const status = (error as { status?: number })?.status;
|
||||
return (
|
||||
<DetailNotFound
|
||||
entity="inquiry"
|
||||
backHref={`/${portSlug}/inquiries`}
|
||||
backLabel="Back to inquiries"
|
||||
status={status}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const p = (data?.payload ?? {}) as Record<string, unknown>;
|
||||
const str = (k: string) => (typeof p[k] === 'string' ? (p[k] as string) : '');
|
||||
// The free-text message a lead left. Website forms use different keys
|
||||
// (contact form -> `comments`; others -> `message`/`comment`), so probe the
|
||||
// common ones and surface it for every inquiry kind.
|
||||
const comment = str('comments') || str('message') || str('comment') || str('notes');
|
||||
|
||||
const tabs: DetailTab[] = [
|
||||
{
|
||||
id: 'overview',
|
||||
label: 'Overview',
|
||||
content: (
|
||||
<div className="max-w-xl">
|
||||
<Row label="Name" value={data?.contactName} />
|
||||
<Row label="Email" value={data?.contactEmail} />
|
||||
<Row label="Phone" value={str('phone')} />
|
||||
{data?.kind === 'residence_inquiry' ? (
|
||||
<Row label="Place of residence" value={str('address')} />
|
||||
) : null}
|
||||
{data?.kind === 'berth_inquiry' ? <Row label="Berth" value={str('berth')} /> : null}
|
||||
{comment ? (
|
||||
<Row label="Message" value={<span className="whitespace-pre-wrap">{comment}</span>} />
|
||||
) : null}
|
||||
<Row label="Type" value={data ? KIND_LABELS[data.kind] : ''} />
|
||||
<Row label="Received" value={data ? format(new Date(data.receivedAt), 'PPpp') : ''} />
|
||||
<Row label="Source IP" value={data?.sourceIp} />
|
||||
<Row label="UTM source" value={data?.utmSource} />
|
||||
<Row label="UTM medium" value={data?.utmMedium} />
|
||||
<Row label="UTM campaign" value={data?.utmCampaign} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'tracking',
|
||||
label: 'Tracking',
|
||||
content: (
|
||||
<div className="max-w-xl">
|
||||
<Row
|
||||
label="Status"
|
||||
value={
|
||||
data ? (
|
||||
<Badge className={TRIAGE_TONE[data.triageState]}>
|
||||
{TRIAGE_LABELS[data.triageState]}
|
||||
</Badge>
|
||||
) : (
|
||||
''
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Row
|
||||
label="Triaged at"
|
||||
value={data?.triagedAt ? format(new Date(data.triagedAt), 'PPpp') : ''}
|
||||
/>
|
||||
<Row
|
||||
label="Converted client"
|
||||
value={
|
||||
data?.convertedClient ? (
|
||||
<a
|
||||
href={`/${portSlug}/clients/${data.convertedClient.id}`}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{data.convertedClient.fullName}
|
||||
</a>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<Row
|
||||
label="Converted interest"
|
||||
value={
|
||||
data?.convertedInterest ? (
|
||||
<a
|
||||
href={`/${portSlug}/interests/${data.convertedInterest.id}`}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
View interest ({data.convertedInterest.pipelineStage})
|
||||
</a>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'payload',
|
||||
label: 'Raw payload',
|
||||
content: (
|
||||
<pre className="max-h-96 overflow-auto rounded-md bg-muted/40 p-3 text-xs">
|
||||
{JSON.stringify(data?.payload ?? {}, null, 2)}
|
||||
</pre>
|
||||
),
|
||||
},
|
||||
].filter((tab) => tab.id !== 'payload' || isSuperAdmin);
|
||||
|
||||
return (
|
||||
<DetailLayout
|
||||
isLoading={isLoading}
|
||||
header={
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-xl font-semibold">{data?.contactName || '(no name)'}</h1>
|
||||
{data ? (
|
||||
<Badge className={TRIAGE_TONE[data.triageState]}>
|
||||
{TRIAGE_LABELS[data.triageState]}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{data ? KIND_LABELS[data.kind] : ''} inquiry
|
||||
{data?.contactEmail ? ` · ${data.contactEmail}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
{data ? <InquiryConvertActions portSlug={portSlug} inquiry={data} /> : null}
|
||||
</div>
|
||||
}
|
||||
tabs={tabs}
|
||||
defaultTab="overview"
|
||||
/>
|
||||
);
|
||||
}
|
||||
33
src/components/inquiries/inquiry-filters.tsx
Normal file
33
src/components/inquiries/inquiry-filters.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { FilterDefinition } from '@/components/shared/filter-bar';
|
||||
|
||||
export const inquiryFilterDefinitions: FilterDefinition[] = [
|
||||
{
|
||||
key: 'search',
|
||||
label: 'Search',
|
||||
type: 'text',
|
||||
placeholder: 'Search name or email…',
|
||||
},
|
||||
{
|
||||
key: 'kind',
|
||||
label: 'Type',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Berth', value: 'berth_inquiry' },
|
||||
{ label: 'Residence', value: 'residence_inquiry' },
|
||||
{ label: 'Contact', value: 'contact_form' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'state',
|
||||
label: 'Status',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Inbox (open + assigned)', value: 'inbox' },
|
||||
{ label: 'Open', value: 'open' },
|
||||
{ label: 'Assigned', value: 'assigned' },
|
||||
{ label: 'Converted', value: 'converted' },
|
||||
{ label: 'Dismissed', value: 'dismissed' },
|
||||
{ label: 'All', value: 'all' },
|
||||
],
|
||||
},
|
||||
];
|
||||
127
src/components/inquiries/inquiry-list.tsx
Normal file
127
src/components/inquiries/inquiry-list.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
|
||||
import { DataTable } from '@/components/shared/data-table';
|
||||
import { FilterBar } from '@/components/shared/filter-bar';
|
||||
import { ColumnPicker } from '@/components/shared/column-picker';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { EmptyState } from '@/components/shared/empty-state';
|
||||
import { TableSkeleton } from '@/components/shared/loading-skeleton';
|
||||
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
||||
import { useTablePreferences } from '@/hooks/use-table-preferences';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
import { inquiryFilterDefinitions } from '@/components/inquiries/inquiry-filters';
|
||||
import {
|
||||
getInquiryColumns,
|
||||
INQUIRY_COLUMN_OPTIONS,
|
||||
INQUIRY_DEFAULT_HIDDEN,
|
||||
type InquiryRow,
|
||||
type InquiryTriageState,
|
||||
} from '@/components/inquiries/inquiry-columns';
|
||||
import { InquiryCard } from '@/components/inquiries/inquiry-card';
|
||||
|
||||
export function InquiryList() {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { setChrome } = useMobileChrome();
|
||||
useEffect(() => {
|
||||
setChrome({ title: 'Inquiries', showBackButton: false });
|
||||
return () => setChrome({ title: null, showBackButton: false });
|
||||
}, [setChrome]);
|
||||
|
||||
const {
|
||||
data,
|
||||
pagination,
|
||||
isLoading,
|
||||
isFetching,
|
||||
sort,
|
||||
setSort,
|
||||
setPage,
|
||||
setPageSize,
|
||||
filters,
|
||||
setFilter,
|
||||
clearFilters,
|
||||
} = usePaginatedQuery<InquiryRow>({
|
||||
queryKey: ['inquiries'],
|
||||
endpoint: '/api/v1/inquiries',
|
||||
initialSort: { field: 'receivedAt', direction: 'desc' },
|
||||
filterDefinitions: inquiryFilterDefinitions,
|
||||
});
|
||||
|
||||
const triageMutation = useMutation({
|
||||
mutationFn: (args: { id: string; state: InquiryTriageState }) =>
|
||||
apiFetch(`/api/v1/inquiries/${args.id}/triage`, {
|
||||
method: 'PATCH',
|
||||
body: { state: args.state },
|
||||
}),
|
||||
onSuccess: (_d, vars) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['inquiries'] });
|
||||
toast.success(`Marked ${vars.state}.`);
|
||||
},
|
||||
onError: (err: unknown) => toastError(err, 'Update failed'),
|
||||
});
|
||||
|
||||
const columns = getInquiryColumns({
|
||||
portSlug,
|
||||
onTriage: (row, state) => triageMutation.mutate({ id: row.id, state }),
|
||||
});
|
||||
|
||||
const { hidden, setHidden } = useTablePreferences('inquiries', INQUIRY_DEFAULT_HIDDEN);
|
||||
const columnVisibility = Object.fromEntries(hidden.map((id) => [id, false]));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title="Inquiries"
|
||||
description="Submissions captured from the public marketing site (berth, residence, and contact forms)."
|
||||
variant="gradient"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<FilterBar
|
||||
filters={inquiryFilterDefinitions}
|
||||
values={filters}
|
||||
onChange={setFilter}
|
||||
onClear={clearFilters}
|
||||
/>
|
||||
<div className="ml-auto flex flex-wrap items-center gap-2">
|
||||
<ColumnPicker columns={INQUIRY_COLUMN_OPTIONS} hidden={hidden} onChange={setHidden} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<TableSkeleton />
|
||||
) : (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
columnVisibility={columnVisibility}
|
||||
data={data}
|
||||
pagination={pagination}
|
||||
onPaginationChange={(p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
}}
|
||||
sort={sort}
|
||||
onSortChange={setSort}
|
||||
isLoading={isFetching && !isLoading}
|
||||
getRowId={(row) => row.id}
|
||||
cardRender={(row) => <InquiryCard inquiry={row.original} portSlug={portSlug} />}
|
||||
emptyState={
|
||||
<EmptyState
|
||||
title="No inquiries found"
|
||||
description="Submissions from the marketing site will appear here."
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { Activity, ExternalLink } from 'lucide-react';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { computeDealHealth, type DealHealthInput } from '@/lib/services/deal-health';
|
||||
import { useFeatureFlag } from '@/hooks/use-feature-flag';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const PULSE_TINT: Record<'cold' | 'warm' | 'hot', string> = {
|
||||
@@ -31,9 +32,13 @@ const PULSE_LABEL: Record<'cold' | 'warm' | 'hot', string> = {
|
||||
*/
|
||||
export function DealPulseChip({ interest }: { interest: DealHealthInput }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
// Master toggle: Admin → Pulse → "Show deal pulse chips" (pulse_enabled).
|
||||
// Defaults ON (chip visible) when the port hasn't set it; hidden only when
|
||||
// explicitly disabled.
|
||||
const pulseEnabled = useFeatureFlag('pulse_enabled', true);
|
||||
|
||||
// Closed / archived deals don't get a pulse - UX would be confusing.
|
||||
if (interest.archivedAt || interest.outcome) return null;
|
||||
// Hidden when the port disabled pulse chips, or for closed/archived deals.
|
||||
if (!pulseEnabled || interest.archivedAt || interest.outcome) return null;
|
||||
|
||||
const health = computeDealHealth(interest);
|
||||
const tint = PULSE_TINT[health.pulse];
|
||||
|
||||
@@ -11,14 +11,11 @@ import {
|
||||
Trophy,
|
||||
XCircle,
|
||||
RefreshCcw,
|
||||
Mail,
|
||||
MessageSquarePlus,
|
||||
Phone,
|
||||
AlarmClock,
|
||||
User,
|
||||
} from 'lucide-react';
|
||||
import { ComposeDialog as ContactLogComposeSheet } from '@/components/interests/interest-contact-log-tab';
|
||||
import { WhatsAppIcon } from '@/components/icons/whatsapp';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -35,6 +32,7 @@ import { AssignedToChip } from '@/components/interests/assigned-to-chip';
|
||||
import { MultiEoiChip } from '@/components/interests/multi-eoi-chip';
|
||||
import { DealPulseChip } from '@/components/interests/deal-pulse-chip';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { useFeatureFlag } from '@/hooks/use-feature-flag';
|
||||
import { formatOutcome } from '@/lib/constants';
|
||||
import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -74,9 +72,9 @@ interface InterestDetailHeaderProps {
|
||||
id: string;
|
||||
clientId: string;
|
||||
clientName: string | null;
|
||||
/** Primary contact channels resolved from the linked client. The header
|
||||
* uses these to render Email / Call / WhatsApp buttons so the rep
|
||||
* doesn't have to navigate to the client page just to reach out. */
|
||||
/** Primary contact channels resolved from the linked client. The
|
||||
* Email/Call/WhatsApp pills were removed (CM-4); these stay on the payload
|
||||
* for downstream reuse (e.g. proxy comms routing, CM-9). */
|
||||
clientPrimaryEmail?: string | null;
|
||||
clientPrimaryPhone?: string | null;
|
||||
clientPrimaryPhoneE164?: string | null;
|
||||
@@ -144,21 +142,13 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
||||
const [logContactOpen, setLogContactOpen] = useState(false);
|
||||
const [reminderOpen, setReminderOpen] = useState(false);
|
||||
// (Upload-paper-signed-EOI dialog moved to the EOI tab.)
|
||||
// CM-5: assignment UI is hidden when the per-port toggle is off (default).
|
||||
const assignmentEnabled = useFeatureFlag('assignment_enabled', false);
|
||||
|
||||
const isArchived = !!interest.archivedAt;
|
||||
const outcomeBadge = resolveOutcomeBadge(interest.outcome);
|
||||
const isClosed = !!interest.outcome;
|
||||
|
||||
// Contact deep-links - resolved from the linked client's primary channels.
|
||||
// wa.me requires the digits-only E.164 number (no leading "+"); fall back to
|
||||
// stripping non-digits from the display value when the canonical form is
|
||||
// missing.
|
||||
const whatsappNumber = interest.clientPrimaryPhoneE164
|
||||
? interest.clientPrimaryPhoneE164.replace(/^\+/, '')
|
||||
: interest.clientPrimaryPhone
|
||||
? interest.clientPrimaryPhone.replace(/[^\d]/g, '')
|
||||
: null;
|
||||
|
||||
const reopenMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch(`/api/v1/interests/${interest.id}/outcome`, { method: 'DELETE', body: {} }),
|
||||
@@ -285,13 +275,15 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
||||
{interest.activeReminderCount}
|
||||
</span>
|
||||
) : null}
|
||||
<PermissionGate resource="interests" action="edit">
|
||||
<AssignedToChip
|
||||
interestId={interest.id}
|
||||
currentAssignedTo={interest.assignedTo ?? null}
|
||||
currentAssignedToName={interest.assignedToName ?? null}
|
||||
/>
|
||||
</PermissionGate>
|
||||
{assignmentEnabled ? (
|
||||
<PermissionGate resource="interests" action="edit">
|
||||
<AssignedToChip
|
||||
interestId={interest.id}
|
||||
currentAssignedTo={interest.assignedTo ?? null}
|
||||
currentAssignedToName={interest.assignedToName ?? null}
|
||||
/>
|
||||
</PermissionGate>
|
||||
) : null}
|
||||
<MultiEoiChip interestId={interest.id} />
|
||||
<DealPulseChip
|
||||
interest={{
|
||||
@@ -340,94 +332,38 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contact deep-links - let the rep email / call / WhatsApp the
|
||||
client without leaving the interest workspace. Resolved from
|
||||
the linked client's primary contact channels (server-side
|
||||
fetch in getInterestById). */}
|
||||
{interest.clientPrimaryEmail ||
|
||||
interest.clientPrimaryPhone ||
|
||||
whatsappNumber ||
|
||||
interest.clientId ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-1">
|
||||
{interest.clientId ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
||||
>
|
||||
<Link
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/${portSlug}/clients/${interest.clientId}` as any}
|
||||
aria-label="Open client page"
|
||||
>
|
||||
<User />
|
||||
Client page
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{interest.clientPrimaryEmail ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
||||
>
|
||||
<a
|
||||
href={`mailto:${interest.clientPrimaryEmail}`}
|
||||
aria-label={`Email ${interest.clientPrimaryEmail}`}
|
||||
>
|
||||
<Mail />
|
||||
Email
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
{interest.clientPrimaryPhone ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
||||
>
|
||||
<a
|
||||
href={`tel:${interest.clientPrimaryPhone}`}
|
||||
aria-label={`Call ${interest.clientPrimaryPhone}`}
|
||||
>
|
||||
<Phone />
|
||||
Call
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
{whatsappNumber ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
||||
>
|
||||
<a
|
||||
href={`https://wa.me/${whatsappNumber}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`Message on WhatsApp`}
|
||||
>
|
||||
<WhatsAppIcon className="h-4 w-4" />
|
||||
WhatsApp
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
{/* CM-4: Email/Call/WhatsApp deep-links removed at client request.
|
||||
Client-page link + Log-contact action stay - the rep can still
|
||||
jump to the client and record outreach without leaving here. */}
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-1">
|
||||
{interest.clientId ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
||||
onClick={() => setLogContactOpen(true)}
|
||||
aria-label="Log a contact for this interest"
|
||||
>
|
||||
<MessageSquarePlus />
|
||||
Log contact
|
||||
<Link
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/${portSlug}/clients/${interest.clientId}` as any}
|
||||
aria-label="Open client page"
|
||||
>
|
||||
<User />
|
||||
Client page
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
||||
onClick={() => setLogContactOpen(true)}
|
||||
aria-label="Log a contact for this interest"
|
||||
>
|
||||
<MessageSquarePlus />
|
||||
Log contact
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top-right actions. Won/Lost are sales-critical and read as text
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
FileSignature,
|
||||
GitBranch,
|
||||
Loader2,
|
||||
Mail,
|
||||
RefreshCw,
|
||||
Upload,
|
||||
XCircle,
|
||||
@@ -122,6 +123,32 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) {
|
||||
// (which the storage backend serves with Content-Disposition=attachment,
|
||||
// forcing a download even when the rep just wants to inspect the PDF).
|
||||
const [previewFile, setPreviewFile] = useState<{ id: string; name?: string } | null>(null);
|
||||
const { confirm, dialog: confirmDialog } = useConfirmation();
|
||||
|
||||
// Manually (re)send the finalized signed PDF to the deal's client.
|
||||
// Lifted to the parent (like the preview dialog) so every row + the
|
||||
// signed-EOI hero share one confirm + handler. Guarded by a confirm so
|
||||
// a stray click can't fire a real client email.
|
||||
const handleSendCopy = useCallback(
|
||||
async (documentId: string) => {
|
||||
const ok = await confirm({
|
||||
title: 'Send signed copy to client?',
|
||||
description: 'Emails the deal’s client the finalized signed PDF as an attachment.',
|
||||
confirmLabel: 'Send copy',
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
const res = await apiFetch<{ data: { recipientEmail: string } }>(
|
||||
`/api/v1/documents/${documentId}/send-signed-copy`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
toast.success(`Signed copy sent to ${res.data.recipientEmail}.`);
|
||||
} catch (err) {
|
||||
toastError(err, 'Failed to send signed copy');
|
||||
}
|
||||
},
|
||||
[confirm],
|
||||
);
|
||||
|
||||
const { data: docsRes, isLoading: docsLoading } = useQuery<{ data: DocumentRow[] }>({
|
||||
queryKey: ['documents', { interestId, documentType: 'eoi' }],
|
||||
@@ -134,6 +161,22 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) {
|
||||
const docs = docsRes?.data ?? [];
|
||||
const activeDoc = useMemo(() => docs.find((d) => ACTIVE_STATUSES.has(d.status)) ?? null, [docs]);
|
||||
const completedDocs = useMemo(() => docs.filter((d) => !ACTIVE_STATUSES.has(d.status)), [docs]);
|
||||
// Most-recent fully-signed EOI. When no EOI is in flight, this becomes
|
||||
// the hero (instead of the generate/upload empty state) so a deal whose
|
||||
// EOI is already done leads with the signed document, per UAT 2026-06-03.
|
||||
const latestSignedDoc = useMemo(() => {
|
||||
return (
|
||||
docs
|
||||
.filter((d) => d.status === 'completed')
|
||||
.sort((a, b) => +new Date(b.createdAt) - +new Date(a.createdAt))[0] ?? null
|
||||
);
|
||||
}, [docs]);
|
||||
// History strip excludes whichever signed doc is shown as the hero so it
|
||||
// isn't listed twice.
|
||||
const historyDocs = useMemo(
|
||||
() => completedDocs.filter((d) => d.id !== latestSignedDoc?.id),
|
||||
[completedDocs, latestSignedDoc],
|
||||
);
|
||||
|
||||
// Pulled at the parent so we can thread the active EOI's signers into the
|
||||
// ExternalEoiUploadDialog as a prefill seed. ActiveEoiCard hits the same
|
||||
@@ -176,6 +219,15 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) {
|
||||
portSlug={portSlug ?? null}
|
||||
onUploadSigned={() => setUploadSignedOpen(true)}
|
||||
onView={(id, name) => setPreviewFile({ id, name })}
|
||||
onSendCopy={handleSendCopy}
|
||||
/>
|
||||
) : latestSignedDoc ? (
|
||||
<SignedEoiCard
|
||||
doc={latestSignedDoc}
|
||||
portSlug={portSlug ?? null}
|
||||
onView={(id, name) => setPreviewFile({ id, name })}
|
||||
onSendCopy={handleSendCopy}
|
||||
onGenerateNew={() => setGenerateOpen(true)}
|
||||
/>
|
||||
) : (
|
||||
<EmptyEoiState
|
||||
@@ -189,18 +241,18 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) {
|
||||
{/* History strip - completed + cancelled EOIs from earlier in the
|
||||
deal's life. Quiet and skimmable; the active document above
|
||||
carries the day-to-day attention. */}
|
||||
{completedDocs.length > 0 && (
|
||||
{historyDocs.length > 0 && (
|
||||
<section className="rounded-lg border bg-background">
|
||||
<header className="flex items-center justify-between border-b px-4 py-2.5">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
EOI history
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{completedDocs.length} {completedDocs.length === 1 ? 'document' : 'documents'}
|
||||
{historyDocs.length} {historyDocs.length === 1 ? 'document' : 'documents'}
|
||||
</span>
|
||||
</header>
|
||||
<ul className="divide-y">
|
||||
{completedDocs.map((d) => (
|
||||
{historyDocs.map((d) => (
|
||||
<li key={d.id} className="flex items-center gap-3 px-4 py-2.5 text-sm">
|
||||
<StatusBadge status={d.status} />
|
||||
<span className="flex-1 truncate font-medium">{d.title}</span>
|
||||
@@ -210,8 +262,11 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) {
|
||||
{d.signedFileId ? (
|
||||
<SignedPdfActions
|
||||
fileId={d.signedFileId}
|
||||
documentId={d.id}
|
||||
isSignedCopySendable={d.status === 'completed'}
|
||||
title={d.title}
|
||||
onView={(id, name) => setPreviewFile({ id, name })}
|
||||
onSendCopy={handleSendCopy}
|
||||
/>
|
||||
) : null}
|
||||
{portSlug && (
|
||||
@@ -272,6 +327,7 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) {
|
||||
fileId={previewFile?.id}
|
||||
fileName={previewFile?.name}
|
||||
/>
|
||||
{confirmDialog}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -283,11 +339,13 @@ function ActiveEoiCard({
|
||||
portSlug,
|
||||
onUploadSigned,
|
||||
onView,
|
||||
onSendCopy,
|
||||
}: {
|
||||
doc: DocumentRow;
|
||||
portSlug: string | null;
|
||||
onUploadSigned: () => void;
|
||||
onView: (fileId: string, fileName?: string) => void;
|
||||
onSendCopy: (documentId: string) => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { confirm, dialog: confirmDialog } = useConfirmation();
|
||||
@@ -614,7 +672,14 @@ function ActiveEoiCard({
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Signed document
|
||||
</h3>
|
||||
<SignedPdfActions fileId={doc.signedFileId} title={doc.title} onView={onView} />
|
||||
<SignedPdfActions
|
||||
fileId={doc.signedFileId}
|
||||
documentId={doc.id}
|
||||
isSignedCopySendable={doc.status === 'completed'}
|
||||
title={doc.title}
|
||||
onView={onView}
|
||||
onSendCopy={onSendCopy}
|
||||
/>
|
||||
</div>
|
||||
<SignedPdfPreview fileId={doc.signedFileId} />
|
||||
</div>
|
||||
@@ -711,6 +776,90 @@ function ActiveEoiCard({
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Signed EOI hero (no active EOI, but one is already signed) ───────────────
|
||||
|
||||
/**
|
||||
* Shown when the deal has a fully-signed EOI and nothing is in flight. Leads
|
||||
* with the signed document (preview + download + send-to-client) instead of
|
||||
* the generate/upload empty state — a deal whose EOI is done shouldn't open
|
||||
* on a big "Generate EOI" CTA. A quiet "Generate new EOI" remains for the
|
||||
* re-issue case.
|
||||
*/
|
||||
function SignedEoiCard({
|
||||
doc,
|
||||
portSlug,
|
||||
onView,
|
||||
onSendCopy,
|
||||
onGenerateNew,
|
||||
}: {
|
||||
doc: DocumentRow;
|
||||
portSlug: string | null;
|
||||
onView: (fileId: string, fileName?: string) => void;
|
||||
onSendCopy: (documentId: string) => void;
|
||||
onGenerateNew: () => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-xl border bg-gradient-brand-soft p-5 shadow-xs">
|
||||
<header className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<CheckCircle2 className="size-4 text-emerald-600" aria-hidden />
|
||||
<h2 className="truncate text-base font-semibold text-foreground">{doc.title}</h2>
|
||||
<StatusBadge status={doc.status} />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Signed · {new Date(doc.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-x-3 gap-y-1">
|
||||
{doc.signedFileId ? (
|
||||
<SignedPdfActions
|
||||
fileId={doc.signedFileId}
|
||||
documentId={doc.id}
|
||||
isSignedCopySendable={doc.status === 'completed'}
|
||||
title={doc.title}
|
||||
onView={onView}
|
||||
onSendCopy={onSendCopy}
|
||||
/>
|
||||
) : null}
|
||||
{portSlug && (
|
||||
<Link
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/${portSlug}/documents/${doc.id}` as any}
|
||||
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
|
||||
>
|
||||
Open in Documents
|
||||
<ExternalLink className="size-3" aria-hidden />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{doc.signedFileId ? (
|
||||
<div className="mt-4 rounded-lg border bg-background p-4">
|
||||
<SignedPdfPreview fileId={doc.signedFileId} />
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-4 rounded-md border border-dashed bg-background p-3 text-xs text-muted-foreground">
|
||||
The signed PDF isn't linked to this EOI yet, so inline preview, download, and send
|
||||
aren't available. Open it in Documents — this lights up once migrated EOIs are
|
||||
reconciled to their signed files.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<footer className="mt-3 flex items-center justify-between gap-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This deal's EOI is signed. Generate a new one only if you need to re-issue it.
|
||||
</p>
|
||||
<Button variant="ghost" size="sm" onClick={onGenerateNew} className="gap-1.5">
|
||||
<FileSignature className="size-4" aria-hidden />
|
||||
Generate new EOI
|
||||
</Button>
|
||||
</footer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline iframe preview of a signed PDF. Fetches a short-lived presigned
|
||||
* URL from `/api/v1/files/[id]/download` and renders the browser's native
|
||||
@@ -822,12 +971,21 @@ function StatusBadge({ status }: { status: DocumentRow['status'] }) {
|
||||
*/
|
||||
function SignedPdfActions({
|
||||
fileId,
|
||||
documentId,
|
||||
isSignedCopySendable = false,
|
||||
title,
|
||||
onView,
|
||||
onSendCopy,
|
||||
}: {
|
||||
fileId: string;
|
||||
/** Document id — required for the "Send to client" action (which targets
|
||||
* the document, not the raw file). */
|
||||
documentId?: string;
|
||||
/** Only show "Send to client" for a fully-completed document. */
|
||||
isSignedCopySendable?: boolean;
|
||||
title?: string;
|
||||
onView: (fileId: string, fileName?: string) => void;
|
||||
onSendCopy?: (documentId: string) => void;
|
||||
}) {
|
||||
const handleDownload = async () => {
|
||||
try {
|
||||
@@ -855,6 +1013,15 @@ function SignedPdfActions({
|
||||
>
|
||||
<Download className="size-3" aria-hidden /> Download
|
||||
</button>
|
||||
{onSendCopy && documentId && isSignedCopySendable ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSendCopy(documentId)}
|
||||
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
<Mail className="size-3" aria-hidden /> Send to client
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -379,7 +379,7 @@ export function InterestList() {
|
||||
type="button"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
aria-label="New interest"
|
||||
className="fixed bottom-[calc(env(safe-area-inset-bottom)+86px)] right-4 z-40 inline-flex h-12 w-12 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-transform hover:scale-105 active:scale-95 lg:hidden"
|
||||
className="fixed bottom-[calc(env(safe-area-inset-bottom)+86px)] right-4 z-40 inline-flex h-12 w-12 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-transform hover:scale-105 active:scale-95 md:hidden"
|
||||
>
|
||||
<Plus className="h-6 w-6" aria-hidden />
|
||||
</button>
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
AccordionTrigger,
|
||||
} from '@/components/ui/accordion';
|
||||
import { NotesList } from '@/components/shared/notes-list';
|
||||
import { ProxyCard } from '@/components/shared/proxy-card';
|
||||
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
||||
import { FieldHistoryProvider, FieldHistoryIcon } from '@/components/shared/field-history';
|
||||
import { ClientChannelEditor } from '@/components/clients/client-channel-editor';
|
||||
@@ -848,7 +849,18 @@ function OverviewTab({
|
||||
deposit_paid: 'deposit',
|
||||
contract: 'contract',
|
||||
};
|
||||
const stageOwnedMilestone = STAGE_TO_MILESTONE[interest.pipelineStage as PipelineStage] ?? null;
|
||||
const stageOwnedMilestoneRaw =
|
||||
STAGE_TO_MILESTONE[interest.pipelineStage as PipelineStage] ?? null;
|
||||
// B2 (2026-06-18): if the stage-owned milestone is already COMPLETE — e.g. a
|
||||
// migrated deal left at stage=eoi with a signed EOI that never auto-advanced —
|
||||
// don't pin it as the current "NEXT STEP". Falling back to null makes phaseFor
|
||||
// use completion ordering, so the signed milestone shows as done/past and the
|
||||
// next incomplete one (Reservation) becomes current. Display-only; the
|
||||
// pipeline_stage column is unchanged.
|
||||
const stageOwnedMilestoneComplete = stageOwnedMilestoneRaw
|
||||
? milestoneCompletion[stageOwnedMilestoneRaw]
|
||||
: false;
|
||||
const stageOwnedMilestone = stageOwnedMilestoneComplete ? null : stageOwnedMilestoneRaw;
|
||||
const stageOwnedIdx = stageOwnedMilestone ? order.indexOf(stageOwnedMilestone) : -1;
|
||||
const phaseFor = (k: (typeof order)[number]): Phase => {
|
||||
// Stage owns this milestone → always current, never collapsed.
|
||||
@@ -1122,6 +1134,9 @@ function OverviewTab({
|
||||
archivedAt={null}
|
||||
/>
|
||||
|
||||
{/* CM-9: per-deal point-of-contact (overrides the client's default). */}
|
||||
<ProxyCard entityType="interest" entityId={interestId} />
|
||||
|
||||
{/* Qualification checklist - surfaces the port's per-port criteria so
|
||||
the rep can mark each one confirmed before the deal advances out
|
||||
of 'enquiry'. Hidden when the port has no enabled criteria. */}
|
||||
|
||||
@@ -20,7 +20,7 @@ import { useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Anchor, Loader2, Plus, Star, Trash2 } from 'lucide-react';
|
||||
import { Anchor, Loader2, Pin, Plus, Star, Trash2 } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -67,9 +67,12 @@ export interface LinkedBerthRow {
|
||||
addedBy: string | null;
|
||||
addedAt: string;
|
||||
notes: string | null;
|
||||
priceOverride: string | null;
|
||||
priceOverrideCurrency: string | null;
|
||||
mooringNumber: string | null;
|
||||
area: string | null;
|
||||
status: string;
|
||||
statusOverrideMode: string | null;
|
||||
lengthFt: string | null;
|
||||
widthFt: string | null;
|
||||
draftFt: string | null;
|
||||
@@ -192,6 +195,24 @@ function useRemoveLink(interestId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// CM-2 Part B: set/clear the deal-specific price override for one berth.
|
||||
function useSetBerthPrice(interestId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (args: { berthId: string; price: number | null }) =>
|
||||
apiFetch(`/api/v1/interests/${interestId}/berths/${args.berthId}/price`, {
|
||||
method: 'PUT',
|
||||
body: { price: args.price },
|
||||
}),
|
||||
onSuccess: (_data, args) => {
|
||||
toast.success(args.price == null ? 'Reverted to list price.' : 'Deal price saved.');
|
||||
qc.invalidateQueries({ queryKey: ['interest-berths', interestId] });
|
||||
qc.invalidateQueries({ queryKey: ['interests', interestId] });
|
||||
},
|
||||
onError: (e: Error) => toastError(e),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Bypass dialog ──────────────────────────────────────────────────────────
|
||||
|
||||
interface BypassDialogProps {
|
||||
@@ -288,9 +309,20 @@ function LinkedBerthRowItem({
|
||||
}: RowProps) {
|
||||
const [bypassOpen, setBypassOpen] = useState(false);
|
||||
const [confirmRemove, setConfirmRemove] = useState(false);
|
||||
const [priceDraft, setPriceDraft] = useState(row.priceOverride ?? '');
|
||||
const setBerthPrice = useSetBerthPrice(interestId);
|
||||
const dims = formatDimensions(row.lengthFt, row.widthFt, row.draftFt);
|
||||
const showBypassControl = eoiStatus === 'signed';
|
||||
|
||||
const commitPrice = () => {
|
||||
const raw = priceDraft.replace(/[,\s]/g, '');
|
||||
const next = raw === '' ? null : Number(raw);
|
||||
if (next !== null && (!Number.isFinite(next) || next < 0)) return; // ignore garbage
|
||||
const prev = row.priceOverride == null ? null : Number(row.priceOverride);
|
||||
if (next === prev) return;
|
||||
setBerthPrice.mutate({ berthId: row.berthId, price: next });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -330,6 +362,15 @@ function LinkedBerthRowItem({
|
||||
EOI bypassed
|
||||
</span>
|
||||
) : null}
|
||||
{row.isSpecificInterest && row.statusOverrideMode === 'manual' ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-md border border-amber-300 bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-900"
|
||||
title={`This berth's status is manually pinned, which overrides "Specifically pitching" on the public map. It will display as "${formatStatus(row.status)}" — not "Under Offer" — until the pin is cleared (edit the berth's status).`}
|
||||
>
|
||||
<Pin className="size-3" aria-hidden />
|
||||
Pin overrides pitch
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{dims ? <div className="text-xs text-muted-foreground">{dims}</div> : null}
|
||||
</div>
|
||||
@@ -400,7 +441,11 @@ function LinkedBerthRowItem({
|
||||
</Tooltip>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{row.isSpecificInterest ? SPECIFIC_CONSEQUENCE_ON : SPECIFIC_CONSEQUENCE_OFF}
|
||||
{row.isSpecificInterest && row.statusOverrideMode === 'manual'
|
||||
? `Overridden: this berth's status is manually pinned, so the public map shows “${formatStatus(row.status)}”, not “Under Offer”. Clear the pin on the berth to let this take effect.`
|
||||
: row.isSpecificInterest
|
||||
? SPECIFIC_CONSEQUENCE_ON
|
||||
: SPECIFIC_CONSEQUENCE_OFF}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
@@ -444,6 +489,34 @@ function LinkedBerthRowItem({
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
{/* CM-2 Part B: deal-specific price. Overrides the berth's list price for
|
||||
this interest only; flows into the EOI/document {{berth.price}} token. */}
|
||||
<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">Deal price</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Overrides the berth's list price for this deal only. Leave blank to use the list
|
||||
price.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
className="w-36 rounded-md border px-2 py-1 text-sm tabular-nums"
|
||||
placeholder="List price"
|
||||
value={priceDraft}
|
||||
disabled={isPending || setBerthPrice.isPending}
|
||||
onChange={(e) => setPriceDraft(e.target.value)}
|
||||
onBlur={commitPrice}
|
||||
aria-label={`Deal price for ${row.mooringNumber ?? row.berthId}`}
|
||||
/>
|
||||
{row.priceOverrideCurrency ? (
|
||||
<span className="text-xs text-muted-foreground">{row.priceOverrideCurrency}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showBypassControl ? (
|
||||
// Bypass section reads as a third toggle-style row: label + description
|
||||
// on the left, action button inline with the description so it doesn't
|
||||
|
||||
@@ -105,7 +105,10 @@ export function InvoiceCard({
|
||||
</DropdownMenuItem>
|
||||
{invoice.pdfFileId ? (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/api/v1/files/${invoice.pdfFileId}/preview`} target="_blank">
|
||||
<Link
|
||||
href={`/api/v1/files/${invoice.pdfFileId}/preview?redirect=1`}
|
||||
target="_blank"
|
||||
>
|
||||
<FileText className="mr-2 h-3.5 w-3.5" />
|
||||
View PDF
|
||||
</Link>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user