Compare commits
23 Commits
94f049c8b8
...
refactor/d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba89b61b3f | ||
|
|
4eea19a85b | ||
|
|
47a1a51832 | ||
|
|
9a5479c2c7 | ||
|
|
e06fb9545b | ||
|
|
4c5334d471 | ||
|
|
61e40b5e76 | ||
|
|
7f9d90ad05 | ||
|
|
5d29bfc153 | ||
|
|
43f68ca093 | ||
|
|
d9557edfc5 | ||
|
|
6eb0d3dc92 | ||
|
|
a3305a94f3 | ||
|
|
9dfa04094b | ||
|
|
e7d23b254c | ||
|
|
2cf1bd9754 | ||
|
|
46937bbcb9 | ||
|
|
27cdbcc695 | ||
|
|
31fa3d08ec | ||
|
|
16d98d630e | ||
|
|
f52d21df83 | ||
|
|
2fa70f4582 | ||
|
|
01b201e1a2 |
199
docs/runbooks/backup-and-restore.md
Normal file
199
docs/runbooks/backup-and-restore.md
Normal file
@@ -0,0 +1,199 @@
|
||||
# Backup and restore runbook
|
||||
|
||||
This runbook documents what gets backed up, how often, where it lands, and
|
||||
the exact commands to restore the system from a cold start. The goal is
|
||||
that any operator who has the off-site backup credentials can bring the
|
||||
CRM back up on a clean host without help.
|
||||
|
||||
## Scope of a "full backup"
|
||||
|
||||
The CRM has three stateful surfaces. All three must be captured for a
|
||||
restore to be useful.
|
||||
|
||||
| Surface | Holds | Risk if missing |
|
||||
| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **PostgreSQL** (`port_nimara_crm`) | Every relational record: clients, yachts, companies, interests, reservations, invoices, audit log, GDPR exports, AI usage ledger, Documenso webhook receipts, etc. | Total data loss — site is unrecoverable. |
|
||||
| **MinIO bucket** (`MINIO_BUCKET`, default `crm-files`) | Receipts, signed contracts, EOI PDFs, GDPR export ZIPs, document attachments. | Files reachable by row references in Postgres become 404s. |
|
||||
| **`.env` + secrets** | DB password, MinIO keys, Documenso webhook secret, SMTP creds, encryption key (`ENCRYPTION_KEY`). | OCR API keys re-resolve from `system_settings` (encrypted at rest), but **without the original `ENCRYPTION_KEY` they're unreadable**. |
|
||||
|
||||
The Redis instance is not backed up. It only holds queue state, rate-limit
|
||||
counters, and Socket.IO presence — all reconstructable. Stop the workers
|
||||
during a restore so the queue starts clean.
|
||||
|
||||
## Backup schedule
|
||||
|
||||
Defaults are tuned for a single-port deployment with O(10k) clients. Bump
|
||||
on the producing side as scale demands.
|
||||
|
||||
| Job | Frequency | Retention | Where |
|
||||
| ---------------------------------- | -------------------- | ----------------------------- | -------------------------------------------------------------------- |
|
||||
| `pg_dump` (custom format, gzipped) | Hourly | 7 days hourly + 30 days daily | `${BACKUP_BUCKET}/pg/<host>/<UTC date>/<hour>.dump.gz` |
|
||||
| MinIO mirror | Hourly (incremental) | 30 days versions | `${BACKUP_BUCKET}/minio/` |
|
||||
| `.env` snapshot (encrypted) | On change (manual) | Forever | Password manager / secrets vault — **never the same bucket as data** |
|
||||
|
||||
The hourly cadence is the right answer for this workload — invoices and
|
||||
contracts cluster around business hours, and an hour of lost work is the
|
||||
worst-case data loss window most clients will tolerate. Promote to 15-min
|
||||
WAL streaming if a customer demands tighter RPO.
|
||||
|
||||
## Required environment variables
|
||||
|
||||
The scripts below read these. Store them in a CI secret store, not the
|
||||
host's bash profile.
|
||||
|
||||
```
|
||||
# Source (the running CRM database)
|
||||
DATABASE_URL=postgresql://crm:<pw>@<host>:<port>/port_nimara_crm
|
||||
|
||||
# MinIO (source bucket — the live one)
|
||||
MINIO_ENDPOINT=minio.letsbe.solutions
|
||||
MINIO_PORT=443
|
||||
MINIO_USE_SSL=true
|
||||
MINIO_ACCESS_KEY=<live key>
|
||||
MINIO_SECRET_KEY=<live secret>
|
||||
MINIO_BUCKET=crm-files
|
||||
|
||||
# Backup destination (a *separate* MinIO/S3 endpoint or a different bucket
|
||||
# with no IAM overlap with the live keys)
|
||||
BACKUP_S3_ENDPOINT=https://s3.eu-west-1.amazonaws.com
|
||||
BACKUP_S3_REGION=eu-west-1
|
||||
BACKUP_S3_BUCKET=portnimara-backups-prod
|
||||
BACKUP_S3_ACCESS_KEY=<dedicated read+write key for this bucket only>
|
||||
BACKUP_S3_SECRET_KEY=<...>
|
||||
|
||||
# Optional: encrypts dumps at rest with a passphrase. Cuts a wider blast
|
||||
# radius if the backup bucket itself is compromised.
|
||||
BACKUP_GPG_RECIPIENT=ops@portnimara.com
|
||||
```
|
||||
|
||||
## Provisioning the backup destination
|
||||
|
||||
1. Create a dedicated S3-compatible bucket in a **different account** from
|
||||
the live infra. AWS S3, Backblaze B2, or a separately-credentialed
|
||||
MinIO instance all work.
|
||||
2. Apply object-lock or versioning so an attacker who steals the backup
|
||||
write key still can't permanently delete history.
|
||||
3. Generate IAM credentials scoped to `s3:PutObject`, `s3:GetObject`,
|
||||
`s3:ListBucket` on this bucket only. Inject them as
|
||||
`BACKUP_S3_*` above. Do not reuse the live `MINIO_*` keys.
|
||||
4. Set a 90-day lifecycle rule that transitions objects older than 30
|
||||
days to cold storage and deletes them at 90 days. Past 90 days it's
|
||||
cheaper to restart from a snapshot taken outside the system.
|
||||
|
||||
## The scripts
|
||||
|
||||
Three scripts in `scripts/backup/`:
|
||||
|
||||
- `pg-backup.sh` — runs `pg_dump`, gzips, optionally GPG-encrypts, uploads
|
||||
- `minio-mirror.sh` — `mc mirror` of the live bucket → backup bucket
|
||||
- `restore.sh` — interactive restore (DB + MinIO) given a snapshot path
|
||||
|
||||
Make them executable and wire them into cron / GitHub Actions / your
|
||||
scheduler of choice. Sample crontab on the worker host:
|
||||
|
||||
```cron
|
||||
# Hourly DB dump at minute 7
|
||||
7 * * * * /opt/pncrm/scripts/backup/pg-backup.sh >> /var/log/pncrm-backup.log 2>&1
|
||||
|
||||
# Hourly MinIO mirror at minute 17 (offset so the two don't fight for I/O)
|
||||
17 * * * * /opt/pncrm/scripts/backup/minio-mirror.sh >> /var/log/pncrm-backup.log 2>&1
|
||||
|
||||
# Weekly restore drill (smoke-test to a throwaway DB on Sunday at 03:00)
|
||||
0 3 * * 0 /opt/pncrm/scripts/backup/restore.sh --drill >> /var/log/pncrm-restore-drill.log 2>&1
|
||||
```
|
||||
|
||||
## Restoring from cold
|
||||
|
||||
These steps have been rehearsed against the dev environment; expect them
|
||||
to take 15–30 minutes for a typical port. **The drill (last cron line
|
||||
above) ensures the runbook stays correct — if the drill fails, the
|
||||
real restore will too.**
|
||||
|
||||
### 0. Stop everything that writes
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml stop web worker scheduler
|
||||
# Leave postgres + minio + redis up; we'll point them at restored data.
|
||||
```
|
||||
|
||||
### 1. Restore PostgreSQL
|
||||
|
||||
```bash
|
||||
# Find the dump you want. Prefer the most recent successful hour.
|
||||
mc ls "$BACKUP_S3_BUCKET/pg/$(hostname)/" | tail
|
||||
SNAPSHOT="2026-04-28/14.dump.gz"
|
||||
|
||||
# Pull it.
|
||||
mc cp "$BACKUP_S3_BUCKET/pg/$(hostname)/$SNAPSHOT" /tmp/
|
||||
|
||||
# Decrypt if BACKUP_GPG_RECIPIENT was set on the producer side.
|
||||
gpg --decrypt /tmp/14.dump.gz.gpg > /tmp/14.dump.gz
|
||||
|
||||
# Drop & recreate the database. The 'restrict' FK from gdpr_exports.requested_by
|
||||
# to user means we restore in the right order — pg_restore handles this.
|
||||
psql "$DATABASE_URL" -c 'DROP DATABASE IF EXISTS port_nimara_crm WITH (FORCE);'
|
||||
psql "$DATABASE_URL" -c 'CREATE DATABASE port_nimara_crm;'
|
||||
gunzip -c /tmp/14.dump.gz | pg_restore --no-owner --no-privileges \
|
||||
--dbname "$DATABASE_URL"
|
||||
```
|
||||
|
||||
### 2. Restore MinIO
|
||||
|
||||
```bash
|
||||
# Sync the backup bucket back over the live one. --overwrite handles
|
||||
# files that were modified between snapshots.
|
||||
mc mirror --overwrite \
|
||||
"$BACKUP_S3_BUCKET/minio/" \
|
||||
"live/$MINIO_BUCKET/"
|
||||
```
|
||||
|
||||
### 3. Restore secrets
|
||||
|
||||
The `.env` file is **not** in object storage. Pull it from the password
|
||||
manager / secrets vault. Verify `ENCRYPTION_KEY` matches the value used
|
||||
when the database was last running — if it doesn't, rows in
|
||||
`system_settings` (OCR API keys, etc.) decrypt to garbage and the OCR
|
||||
"Test connection" button will return an opaque error. There is no
|
||||
recovery path; the keys must be re-entered through the admin UI.
|
||||
|
||||
### 4. Bring services back up
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
# Watch the worker logs; expect a flurry of socket reconnections, then quiet.
|
||||
docker compose -f docker-compose.prod.yml logs -f worker
|
||||
```
|
||||
|
||||
### 5. Verify
|
||||
|
||||
Tail through the smoke checklist, in order:
|
||||
|
||||
1. **DB up** — `psql "$DATABASE_URL" -c 'SELECT count(*) FROM clients;'`
|
||||
matches the producer-side count from the snapshot's hour.
|
||||
2. **MinIO up** — open any client with attachments in the CRM, click a
|
||||
receipt thumbnail; verify the signed URL serves the file.
|
||||
3. **Documenso webhooks** — re-trigger one in the Documenso admin and
|
||||
confirm `audit_logs` records the receipt.
|
||||
4. **Email** — send a portal invite to a real address.
|
||||
5. **Realtime** — open two browser windows, edit a client in one, watch
|
||||
the other update via Socket.IO.
|
||||
6. **AI usage ledger** — `SELECT count(*) FROM ai_usage_ledger;`
|
||||
non-empty if AI was being used. Old rows survive but the budget gates
|
||||
reset alongside the period boundary at month rollover.
|
||||
|
||||
## Drill schedule
|
||||
|
||||
The weekly drill (cron line above) runs `restore.sh --drill` against a
|
||||
throwaway database and a sandbox MinIO bucket. It must produce zero diff
|
||||
between the restored row counts and the live row counts (modulo the
|
||||
hour-or-so the drill takes to run).
|
||||
|
||||
Failure modes the drill catches before they bite production:
|
||||
|
||||
- New tables added without inclusion in `pg_dump`'s `--schema=public` (we
|
||||
use the default, which captures everything in `public` — but a future
|
||||
developer adding a `tenant_X` schema will silently lose it).
|
||||
- MinIO bucket-policy changes that block the backup-side `s3:GetObject`
|
||||
on certain prefixes.
|
||||
- GPG passphrase rotation that wasn't propagated to the restore host.
|
||||
- A `pg_restore` version skew with the producer-side `pg_dump`.
|
||||
186
docs/runbooks/email-deliverability.md
Normal file
186
docs/runbooks/email-deliverability.md
Normal file
@@ -0,0 +1,186 @@
|
||||
# Email deliverability runbook
|
||||
|
||||
The CRM sends transactional email through three different surfaces. Each
|
||||
has a different failure mode when it lands in spam. This runbook covers
|
||||
how to diagnose, fix, and verify each path.
|
||||
|
||||
## What email the CRM sends
|
||||
|
||||
| Surface | Trigger | Template | Default `from` |
|
||||
| ----------------------------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ----------------------------------------------------- |
|
||||
| Portal activation / password-reset | Admin invites a client to the portal | `src/lib/email/templates/portal-auth.ts` | per-port `email_settings.from_address` or `SMTP_FROM` |
|
||||
| Inquiry confirmation + sales notification | Public website POSTs to `/api/public/interests` or `/api/public/residential-inquiries` | `inquiry-client-confirmation.ts`, `inquiry-sales-notification.ts` | same |
|
||||
| GDPR export ready | Staff requests an export with `emailToClient=true` | inline in `gdpr-export.service.ts` | same |
|
||||
| Documenso reminders | Cadence job fires for an unsigned signer | `documenso/reminders/*` | same |
|
||||
|
||||
Documenso _itself_ sends signing requests with its own `from` address —
|
||||
those don't flow through this codebase. SPF/DKIM for the Documenso
|
||||
sender is the Documenso operator's problem, not yours.
|
||||
|
||||
## DNS records
|
||||
|
||||
For every domain that appears in a `from:` header you must publish:
|
||||
|
||||
### 1. SPF
|
||||
|
||||
A single TXT record at the apex authorizing whichever provider is
|
||||
sending. Multiple SPF records on the same name **break SPF entirely** —
|
||||
combine into one.
|
||||
|
||||
```
|
||||
v=spf1 include:_spf.google.com include:amazonses.com -all
|
||||
```
|
||||
|
||||
The `-all` (hardfail) is correct for transactional mail. Switch to `~all`
|
||||
(softfail) only as a temporary diagnostic when migrating providers.
|
||||
|
||||
### 2. DKIM
|
||||
|
||||
Each provider publishes its own selector. Common shapes:
|
||||
|
||||
- Google Workspace: `google._domainkey` → 2048-bit RSA pubkey (rotate every 12 months).
|
||||
- Amazon SES: `xxxx._domainkey`, `yyyy._domainkey`, `zzzz._domainkey` (three CNAMEs SES gives you).
|
||||
- Postmark / Resend / Mailgun: one CNAME per selector.
|
||||
|
||||
Verify alignment — the `d=` value in the DKIM signature must match the
|
||||
`From:` domain (relaxed alignment is fine, strict is overkill).
|
||||
|
||||
### 3. DMARC
|
||||
|
||||
Start at `p=none` while you build deliverability data, then upgrade.
|
||||
|
||||
```
|
||||
_dmarc 14400 IN TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc@portnimara.com; ruf=mailto:dmarc@portnimara.com; fo=1; adkim=r; aspf=r; pct=100"
|
||||
```
|
||||
|
||||
`rua` (aggregate reports) is the diagnostic feed — set it before the
|
||||
first send so the first weekly report has data.
|
||||
|
||||
### 4. MX (only if you also receive)
|
||||
|
||||
The CRM's IMAP probe (`scripts/dev-imap-probe.ts`) and the inbound thread
|
||||
sync rely on a real mailbox. Whoever runs that mailbox publishes the MX
|
||||
records — typically Google Workspace or a dedicated provider. Don't add
|
||||
an MX pointing at the CRM host; it doesn't accept SMTP IN.
|
||||
|
||||
## Per-port overrides
|
||||
|
||||
Each port can override `from_address`, `from_name`, and SMTP creds via
|
||||
the admin email-settings page. When set, `getPortEmailConfig()` returns
|
||||
those values and `sendEmail()` uses them in preference to the global
|
||||
`SMTP_*` env. **The override domain still needs SPF / DKIM / DMARC** on
|
||||
its own DNS — without them, every send from that port lands in spam.
|
||||
|
||||
When a customer reports "our portal invite didn't arrive":
|
||||
|
||||
1. Pull the port's email settings from the admin UI. Check `from_address`.
|
||||
2. Run `dig TXT <from-domain>` and `dig TXT _dmarc.<from-domain>`.
|
||||
Confirm SPF includes the SMTP provider's domain and DMARC exists.
|
||||
3. Send a probe through `mail-tester.com`: paste the address into a
|
||||
test send, click the score breakdown.
|
||||
4. Score < 8/10 → fix whatever's flagged before doing anything else in
|
||||
this runbook.
|
||||
|
||||
## Diagnosing a "didn't arrive" report
|
||||
|
||||
Order matters — go top-down, stop when one of these is the answer.
|
||||
|
||||
### Step 1: Was the send attempted?
|
||||
|
||||
```bash
|
||||
# Tail the worker logs for the recipient address.
|
||||
docker compose logs worker | grep '<recipient>'
|
||||
```
|
||||
|
||||
You'll see one of three patterns:
|
||||
|
||||
- **Nothing**: The job didn't run. Check that BullMQ actually queued it.
|
||||
`redis-cli LLEN bull:email:waiting` — if non-zero, the worker is dead.
|
||||
`docker compose logs scheduler | tail` to see why.
|
||||
- **`Email sent`** with a message-id: The provider accepted it. Move to
|
||||
Step 2.
|
||||
- **`SendError`**: Provider rejected. The error string says why
|
||||
(auth, rate limit, blocked recipient).
|
||||
|
||||
### Step 2: Is `EMAIL_REDIRECT_TO` set?
|
||||
|
||||
In dev/test we set `EMAIL_REDIRECT_TO=ops@portnimara.com` so seeded fake
|
||||
clients don't get real email. **It must be unset in production.**
|
||||
|
||||
```bash
|
||||
# On the production host:
|
||||
docker exec pncrm-web printenv EMAIL_REDIRECT_TO
|
||||
# Should print nothing.
|
||||
```
|
||||
|
||||
If it's set, every email is going to the redirect target with the
|
||||
original recipient prefixed in the subject — the customer never sees it.
|
||||
|
||||
### Step 3: Did it land but get filtered?
|
||||
|
||||
Ask the recipient to check:
|
||||
|
||||
- Spam / Junk folder
|
||||
- Gmail "Promotions" tab
|
||||
- Outlook "Other" folder (vs Focused)
|
||||
- The Quarantine console if they're on M365 with anti-spam enabled
|
||||
|
||||
If found in a spam folder: the email arrived; the recipient's filter
|
||||
classified it. SPF/DKIM/DMARC alignment is suspect — re-run the
|
||||
mail-tester probe from above.
|
||||
|
||||
### Step 4: Was the recipient on a suppression list?
|
||||
|
||||
Some providers (SES, Postmark) maintain a suppression list — once a
|
||||
domain bounces from an address, future sends are dropped silently.
|
||||
|
||||
```bash
|
||||
# SES example:
|
||||
aws ses list-suppressed-destinations --region eu-west-1
|
||||
```
|
||||
|
||||
If the recipient is suppressed, remove them and ask them to retry. The
|
||||
CRM doesn't track suppression locally; that's the provider's job.
|
||||
|
||||
## When migrating SMTP providers
|
||||
|
||||
1. Add the new provider's DKIM CNAMEs alongside the old ones.
|
||||
2. Add the new provider's `include:` to the existing SPF record.
|
||||
3. Wait 48 hours for DNS to propagate and DMARC reports to confirm both
|
||||
providers align.
|
||||
4. Switch `SMTP_*` env to the new provider on a single staging host.
|
||||
5. Send through the staging host for a week. Watch DMARC reports.
|
||||
6. Cut production over.
|
||||
7. Wait two weeks before removing the old provider's DNS — undelivered
|
||||
bounce reports keep arriving for a while.
|
||||
|
||||
## Testing a deliverability fix
|
||||
|
||||
There's no automated test for "did this email reach the inbox" — that's a
|
||||
property of the recipient's filter, which we don't control. The closest
|
||||
proxy is the realapi suite:
|
||||
|
||||
```bash
|
||||
pnpm exec playwright test --project=realapi
|
||||
```
|
||||
|
||||
It runs `tests/e2e/realapi/portal-imap-activation.spec.ts` which sends a
|
||||
real portal-invite email through SMTP, then polls the configured IMAP
|
||||
mailbox for the activation link. If it appears within 30 seconds, the
|
||||
SMTP→DKIM→DMARC chain is alive end-to-end. If the test times out, work
|
||||
backwards through this runbook.
|
||||
|
||||
The realapi suite needs `SMTP_*` and `IMAP_*` env vars — see the
|
||||
"Optional dev/test-only env vars" block in `CLAUDE.md`.
|
||||
|
||||
## Bounce handling
|
||||
|
||||
The CRM doesn't currently process bounces. If you start seeing volume:
|
||||
|
||||
- Set up the provider's webhook (SES → SNS → Lambda; Postmark → webhook
|
||||
URL) to POST bounce events to a new `/api/webhooks/email-bounce` route.
|
||||
- Persist the bounced address into a `email_suppressions` table.
|
||||
- Have `sendEmail()` consult that table before each send.
|
||||
|
||||
That work isn't in scope yet; this runbook just flags it as the next
|
||||
deliverability gap.
|
||||
56
docs/runbooks/permission-audit.md
Normal file
56
docs/runbooks/permission-audit.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Permission Matrix Audit
|
||||
|
||||
Scanned 182 route files under `src/app/api/v1/`.
|
||||
|
||||
**No violations.** Every internal v1 handler is permission-gated.
|
||||
|
||||
**Allow-listed:** 46 handler(s) intentionally skip `withPermission`.
|
||||
|
||||
| File | Method | Reason |
|
||||
| ---------------------------------------------------------------- | ------ | --------------------------------------------------------------------------------- |
|
||||
| `src/app/api/v1/admin/alerts/run-engine/route.ts` | POST | Admin-only — gated by isSuperAdmin inside handler. |
|
||||
| `src/app/api/v1/admin/connections/route.ts` | GET | Admin-only — gated by isSuperAdmin inside handler. |
|
||||
| `src/app/api/v1/admin/errors/route.ts` | GET | Admin-only — gated by isSuperAdmin inside handler. |
|
||||
| `src/app/api/v1/admin/health/route.ts` | GET | Admin-only — gated by isSuperAdmin inside handler. |
|
||||
| `src/app/api/v1/admin/ocr-settings/route.ts` | GET | Admin-only — gated by isSuperAdmin inside handler. |
|
||||
| `src/app/api/v1/admin/ocr-settings/route.ts` | PUT | Admin-only — gated by isSuperAdmin inside handler. |
|
||||
| `src/app/api/v1/admin/ocr-settings/test/route.ts` | POST | Admin-only — gated by isSuperAdmin inside handler. |
|
||||
| `src/app/api/v1/admin/queues/[queueName]/[jobId]/retry/route.ts` | POST | Admin-only — gated by isSuperAdmin inside handler. |
|
||||
| `src/app/api/v1/admin/queues/[queueName]/[jobId]/route.ts` | DELETE | Admin-only — gated by isSuperAdmin inside handler. |
|
||||
| `src/app/api/v1/admin/queues/[queueName]/route.ts` | GET | Admin-only — gated by isSuperAdmin inside handler. |
|
||||
| `src/app/api/v1/admin/queues/route.ts` | GET | Admin-only — gated by isSuperAdmin inside handler. |
|
||||
| `src/app/api/v1/admin/users/options/route.ts` | GET | Admin-only — gated by isSuperAdmin inside handler. |
|
||||
| `src/app/api/v1/ai/email-draft/[jobId]/route.ts` | GET | TODO: needs ai:\* permission catalog entry. Currently allow-listed. |
|
||||
| `src/app/api/v1/ai/email-draft/route.ts` | POST | TODO: needs ai:\* permission catalog entry. Currently allow-listed. |
|
||||
| `src/app/api/v1/ai/interest-score/bulk/route.ts` | GET | TODO: needs ai:\* permission catalog entry. Currently allow-listed. |
|
||||
| `src/app/api/v1/ai/interest-score/route.ts` | GET | TODO: needs ai:\* permission catalog entry. Currently allow-listed. |
|
||||
| `src/app/api/v1/alerts/[id]/acknowledge/route.ts` | POST | Alerts are user-scoped; port-filtered via auth context. |
|
||||
| `src/app/api/v1/alerts/[id]/dismiss/route.ts` | POST | Alerts are user-scoped; port-filtered via auth context. |
|
||||
| `src/app/api/v1/alerts/count/route.ts` | GET | Alerts are user-scoped; port-filtered via auth context. |
|
||||
| `src/app/api/v1/alerts/route.ts` | GET | Alerts are user-scoped; port-filtered via auth context. |
|
||||
| `src/app/api/v1/berth-reservations/[id]/route.ts` | PATCH | TODO: PATCH should map to reservations:edit (not currently in catalog). |
|
||||
| `src/app/api/v1/currency/convert/route.ts` | POST | Currency reference data; port-scoped, no PII. |
|
||||
| `src/app/api/v1/currency/rates/refresh/route.ts` | POST | TODO: gate with admin:manage_settings — currently allow-listed. |
|
||||
| `src/app/api/v1/currency/rates/route.ts` | GET | Currency reference data; port-scoped, no PII. |
|
||||
| `src/app/api/v1/custom-fields/[entityId]/route.ts` | GET | TODO: needs custom_fields:\* permission. PUT path internally validated. |
|
||||
| `src/app/api/v1/custom-fields/[entityId]/route.ts` | PUT | TODO: needs custom_fields:\* permission. PUT path internally validated. |
|
||||
| `src/app/api/v1/expenses/export/parent-company/route.ts` | POST | Internally gated by isSuperAdmin inside the handler. |
|
||||
| `src/app/api/v1/me/route.ts` | GET | Self-endpoint — auth is sufficient. |
|
||||
| `src/app/api/v1/me/route.ts` | PATCH | Self-endpoint — auth is sufficient. |
|
||||
| `src/app/api/v1/notifications/[notificationId]/route.ts` | PATCH | User-scoped notifications — caller is the resource owner. |
|
||||
| `src/app/api/v1/notifications/preferences/route.ts` | GET | User-scoped notifications — caller is the resource owner. |
|
||||
| `src/app/api/v1/notifications/preferences/route.ts` | PUT | User-scoped notifications — caller is the resource owner. |
|
||||
| `src/app/api/v1/notifications/read-all/route.ts` | POST | User-scoped notifications — caller is the resource owner. |
|
||||
| `src/app/api/v1/notifications/route.ts` | GET | User-scoped notifications — caller is the resource owner. |
|
||||
| `src/app/api/v1/notifications/unread-count/route.ts` | GET | User-scoped notifications — caller is the resource owner. |
|
||||
| `src/app/api/v1/saved-views/[id]/route.ts` | PATCH | User-self saved views — caller is the resource owner. |
|
||||
| `src/app/api/v1/saved-views/[id]/route.ts` | DELETE | User-self saved views — caller is the resource owner. |
|
||||
| `src/app/api/v1/saved-views/route.ts` | GET | User-self saved views — caller is the resource owner. |
|
||||
| `src/app/api/v1/saved-views/route.ts` | POST | User-self saved views — caller is the resource owner. |
|
||||
| `src/app/api/v1/search/recent/route.ts` | GET | Port-scoped search — results filtered by auth context (resources have own perms). |
|
||||
| `src/app/api/v1/search/route.ts` | GET | Port-scoped search — results filtered by auth context (resources have own perms). |
|
||||
| `src/app/api/v1/settings/feature-flag/route.ts` | GET | Public read of feature-flag bool — no PII; auth is sufficient. |
|
||||
| `src/app/api/v1/tags/options/route.ts` | GET | Tags are cross-cutting reference data; port-scoped via auth. |
|
||||
| `src/app/api/v1/tags/route.ts` | GET | Tags are cross-cutting reference data; port-scoped via auth. |
|
||||
| `src/app/api/v1/users/me/preferences/route.ts` | GET | User-self preferences — caller is the resource owner. |
|
||||
| `src/app/api/v1/users/me/preferences/route.ts` | PATCH | User-self preferences — caller is the resource owner. |
|
||||
376
docs/superpowers/specs/2026-04-29-gws-inbox-triage-design.md
Normal file
376
docs/superpowers/specs/2026-04-29-gws-inbox-triage-design.md
Normal file
@@ -0,0 +1,376 @@
|
||||
# Google Workspace inbox-triage integration (exploratory)
|
||||
|
||||
**Status:** Exploratory — not approved for build
|
||||
**Date:** 2026-04-29
|
||||
**Tracks:** AI inbox-triage, Google Workspace email connection
|
||||
|
||||
## What this spec is for
|
||||
|
||||
The user has flagged inbox-triage as the most valuable AI surface left to
|
||||
build, but conditioned email integration on it being via Google Workspace
|
||||
specifically (not generic IMAP), with a per-port toggle so clients who
|
||||
don't use GWS aren't billed for capability they can't reach.
|
||||
|
||||
This document captures what that build actually costs — especially on
|
||||
the Google side, which is where most teams underestimate the work — so
|
||||
we can decide whether to commit before writing any code. **Nothing in
|
||||
this spec is approved for implementation.** The deliverable is a go /
|
||||
no-go decision and, if go, a scope choice between three deployment
|
||||
models that cost wildly different amounts of calendar time.
|
||||
|
||||
## What inbox-triage actually does for the user
|
||||
|
||||
Concretely, on the staff member's desktop:
|
||||
|
||||
1. **Linked-inbox panel on the client detail page.** When you open
|
||||
`/[port]/clients/<id>` you see the last N email threads with that
|
||||
client, pulled from the staff member's own Gmail. Each thread has
|
||||
the latest message preview, an "open in Gmail" deep-link, and a
|
||||
"draft reply" button (Phase 2+).
|
||||
2. **Inbox triage queue.** A new top-level page `/[port]/inbox` that
|
||||
lists unread/unanswered threads ranked by AI-assessed importance
|
||||
(high-value client, contractual urgency, chase-overdue). Each row
|
||||
has one-click actions: "log this as a note on the client",
|
||||
"create a follow-up reminder", "draft reply".
|
||||
3. **Email-driven alerts.** When a high-value client emails and no one
|
||||
responds within X hours, the existing alerts engine fires a
|
||||
`inbox.unanswered_high_value` rule (slots into the alert framework
|
||||
from Phase B without schema change).
|
||||
4. **Reply drafts (Phase 3).** AI generates a reply draft grounded in
|
||||
the client's CRM record (open interests, pending reservations,
|
||||
recent invoices). Staff edit and send through Gmail.
|
||||
|
||||
The value is selective: a port with three staff members fielding 50
|
||||
client emails a day saves maybe an hour a day collectively if the
|
||||
ranking is right. Below that volume the build doesn't pay back.
|
||||
|
||||
## What already exists in the codebase
|
||||
|
||||
The CRM is roughly halfway scaffolded for this:
|
||||
|
||||
| Surface | Status | Notes |
|
||||
| ----------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `email_accounts` table | ✅ Exists | Has `provider: 'google' \| 'outlook' \| 'custom'` discriminator and `imap_*` / `smtp_*` cols. Built for IMAP, not OAuth. |
|
||||
| `email_threads` / `email_messages` tables | ✅ Exists | Already linked to `clientId`. Schema is good as-is for Gmail. |
|
||||
| `email-threads.service.ts` `syncInbox()` | ⚠ Stub-ish | IMAP-flow only. Won't reach Gmail without OAuth + Gmail API rewrite. |
|
||||
| `email` BullMQ queue + `inbox-sync` job name | ✅ Exists | Worker dispatches on the job name; new sync impl drops in. |
|
||||
| `google_calendar_tokens` table | ✅ Exists | OAuth token storage shape we can mirror for Gmail. |
|
||||
| Per-port email override (port `email_settings`) | ✅ Exists | Used for outbound only today; Gmail integration is per-staff-user, not per-port. |
|
||||
| `ai_usage_ledger` + per-port `aiEnabled` flag | ✅ Exists (Phase 3a/3b) | Triage AI calls book against the same ledger. |
|
||||
| `withRateLimit('ai', ...)` wrapper | ✅ Exists (Phase 3c) | Caps triage AI traffic at 60/min/user out of the box. |
|
||||
|
||||
Net: schemas are mostly right. The OAuth flow, Gmail API client, push
|
||||
notification receiver, and triage classifier are the new builds.
|
||||
|
||||
## Why Google Workspace specifically
|
||||
|
||||
The user's stated constraint: "I don't think we need email integration
|
||||
unless we connect it to Google Workspace." Reasons that hold up:
|
||||
|
||||
- **No password storage.** OAuth tokens are revocable, scoped, and
|
||||
rotate. IMAP requires app passwords, which Google has been actively
|
||||
deprecating since 2024 — they'll be gone for the workspace plans
|
||||
this product targets.
|
||||
- **Push notifications, not polling.** Gmail's `users.watch` API plus
|
||||
Google Pub/Sub means we get an HTTP callback within seconds of a new
|
||||
message landing. IMAP requires polling on a 30-60 second cadence,
|
||||
which costs more and lags worse.
|
||||
- **Search and labels.** The Gmail API exposes label management and
|
||||
full-text search natively; IMAP search is much weaker.
|
||||
- **Threading.** Gmail's `threadId` is canonical. Reconstructing
|
||||
threads over IMAP from `In-Reply-To` / `References` headers is
|
||||
reliable in theory, painful in practice.
|
||||
|
||||
Microsoft 365 is the obvious peer integration but is out of scope here.
|
||||
The Graph API model is similar enough that a future M365 path can reuse
|
||||
most of the storage shape.
|
||||
|
||||
## Three deployment models — pick one before building
|
||||
|
||||
This is the most important decision in the spec. Each model has
|
||||
different OAuth-verification consequences, which dominate everything
|
||||
else.
|
||||
|
||||
### Model A — Marketplace-published OAuth app
|
||||
|
||||
A single OAuth client owned by Port Nimara, listed in the Google
|
||||
Workspace Marketplace, that any GWS customer can install. Each staff
|
||||
member clicks "Connect Gmail," consents to the scopes, and the CRM
|
||||
stores their refresh token.
|
||||
|
||||
**Google-side work:**
|
||||
|
||||
1. Build the OAuth flow in CRM (~1 week).
|
||||
2. Submit for OAuth verification. Gmail's `gmail.readonly` /
|
||||
`gmail.modify` scopes are **restricted scopes** — they require:
|
||||
- Domain-verified production URLs
|
||||
- A homepage with a privacy policy that explicitly enumerates which
|
||||
scopes are used and why
|
||||
- A demo video (literally a screen recording) showing the consent
|
||||
screen and what happens next
|
||||
- **A third-party security assessment from a Google-approved
|
||||
vendor** ($15k–$75k, 6–12 weeks)
|
||||
- A Cloud Application Security Assessment (CASA) report
|
||||
3. Marketplace listing review (~2 weeks after CASA passes).
|
||||
|
||||
**Calendar time:** 4–6 months.
|
||||
**Money:** $15k–$75k for the security assessment alone.
|
||||
**Recurring:** Re-verification every 12 months.
|
||||
|
||||
Right answer if Port Nimara wants to be the marina-CRM that ships GWS
|
||||
out of the box for _any_ customer. Wrong answer if there are <5
|
||||
customers who'd use it.
|
||||
|
||||
### Model B — Per-customer "Internal" OAuth app
|
||||
|
||||
Each customer's GWS admin creates an OAuth client _inside their own
|
||||
workspace_ and gives Port Nimara the client ID + secret. Because the
|
||||
app is "Internal," Google skips verification entirely — the consent
|
||||
screen is unverified-but-permitted. Tokens never cross workspace
|
||||
boundaries.
|
||||
|
||||
**Google-side work per customer:**
|
||||
|
||||
1. Customer's GWS admin enables the Gmail API in their Cloud project.
|
||||
2. Creates an OAuth 2.0 client ID with type "Internal" + your CRM's
|
||||
redirect URI.
|
||||
3. Hands the client ID + secret to Port Nimara out-of-band.
|
||||
4. Staff connect their Gmail through that client.
|
||||
|
||||
**Calendar time per customer:** ~1 hour of admin work.
|
||||
**Money:** $0.
|
||||
**Limit:** Doesn't span across GWS workspaces. A user with two GWS
|
||||
accounts (e.g. the marina + a personal workspace) can only connect the
|
||||
one matching the OAuth client.
|
||||
|
||||
This is the **clear winner for the current customer base**: small
|
||||
number of customers, each with their own GWS workspace, and each
|
||||
buying the integration as part of an onboarding conversation.
|
||||
|
||||
### Model C — Forward-to-CRM mailbox
|
||||
|
||||
The CRM exposes a per-port email alias (e.g.
|
||||
`port-nimara-NN@inbox.portnimara.com`). Customers configure a Gmail
|
||||
filter or mailing rule that BCCs that alias on relevant threads. The
|
||||
CRM ingests via SMTP and runs the same triage pipeline.
|
||||
|
||||
**Google-side work:** None. Customer does it as a Gmail filter.
|
||||
**Calendar time:** ~1 week of CRM-side build.
|
||||
**Limit:** Receive-only — no reply drafts, no thread state changes,
|
||||
no labels. The "draft reply" feature in Phase 3 above is impossible
|
||||
under this model.
|
||||
|
||||
Model C is the right answer if the user wants to ship inbox-triage
|
||||
_now_ and decide on bidirectional Gmail integration later. The schema
|
||||
is designed so the model can be upgraded to A or B without data
|
||||
migration.
|
||||
|
||||
### Recommendation
|
||||
|
||||
**Build Model B first.** It costs nothing on the Google side, takes
|
||||
~3 weeks of CRM work, and matches the actual customer profile.
|
||||
**Promote to Model A only after 3+ paying customers ask for it
|
||||
unprompted.** Until then, the security-assessment cost can't justify
|
||||
itself.
|
||||
|
||||
Model C as a fallback for customers who refuse to set up an Internal
|
||||
OAuth app. Build it last, lazily — the schema accommodates it.
|
||||
|
||||
## End-to-end flow (Model B)
|
||||
|
||||
### 1. Per-port OAuth-app config
|
||||
|
||||
New admin page `/[port]/admin/google-workspace`:
|
||||
|
||||
- Field: "OAuth client ID" (their internal client ID)
|
||||
- Field: "OAuth client secret" (encrypted at rest using `ENCRYPTION_KEY`)
|
||||
- Field: "Authorized redirect URI" (read-only; we display the value
|
||||
they need to paste into their Google Cloud Console)
|
||||
- Toggle: "Enable Gmail integration for this port"
|
||||
|
||||
Stored in `system_settings` under key `gws.config`, port-scoped.
|
||||
Resolution mirrors the existing OCR config service.
|
||||
|
||||
### 2. Per-staff connect flow
|
||||
|
||||
Staff member visits `/[port]/me/integrations`, clicks "Connect Gmail."
|
||||
|
||||
```
|
||||
GET /api/v1/auth/gws/start
|
||||
→ looks up port's gws.config
|
||||
→ builds Google authorize URL with port's client_id + state token
|
||||
→ 302 to Google
|
||||
[ user consents ]
|
||||
→ 302 back to /api/v1/auth/gws/callback?code=…&state=…
|
||||
→ exchanges code for tokens via port's client_secret
|
||||
→ stores in new `gws_user_tokens` table (encrypted)
|
||||
→ schedules an `inbox-watch` job
|
||||
```
|
||||
|
||||
### 3. Push notification subscription
|
||||
|
||||
After tokens are stored, the worker calls
|
||||
`gmail.users.watch({ topicName: <Pub/Sub topic>, labelIds: ['INBOX'] })`.
|
||||
Gmail then posts to a Pub/Sub topic on every inbox change. The CRM
|
||||
exposes a Pub/Sub push subscription endpoint at
|
||||
`/api/webhooks/gmail-push` which fetches the changed messages via the
|
||||
delta `historyId` and writes them into `email_messages`.
|
||||
|
||||
Watch subscriptions expire every 7 days. A maintenance job
|
||||
re-establishes them daily.
|
||||
|
||||
### 4. Triage pipeline
|
||||
|
||||
For each new inbound message:
|
||||
|
||||
1. Match against `clients` and `companies` by `from_address` against
|
||||
`client_contacts` (email channel). Persist a thread→client link if
|
||||
found.
|
||||
2. If port has `aiEnabled` AND `gws.triageEnabled`, queue an `ai`
|
||||
job that classifies the thread:
|
||||
- `urgency`: low / medium / high
|
||||
- `category`: invoice-question / availability / contract / other
|
||||
- `requires_response`: boolean
|
||||
3. AI call records into `ai_usage_ledger` with `feature='inbox_triage'`.
|
||||
The existing per-port budget gates apply automatically.
|
||||
4. Triage output written to a new `email_triage` table keyed on
|
||||
`email_messages.id`.
|
||||
|
||||
### 5. UI surfaces
|
||||
|
||||
- `/[port]/inbox` — sorted by triage rank, port-wide view.
|
||||
- Linked-inbox panel on `client-tabs.tsx` — adds a new "Email" tab
|
||||
pulling from `email_threads` filtered to that client.
|
||||
- Alert rule `inbox.unanswered_high_value` slots into Phase B's
|
||||
alert engine; no schema change.
|
||||
|
||||
## Schema additions
|
||||
|
||||
Three new tables, all port-scoped where it matters:
|
||||
|
||||
```ts
|
||||
// Per-staff Gmail tokens. Mirror of google_calendar_tokens.
|
||||
gws_user_tokens {
|
||||
id, userId (UNIQUE), portId, emailAddress,
|
||||
accessTokenEnc, refreshTokenEnc, tokenExpiry,
|
||||
scope, watchExpiresAt, watchHistoryId,
|
||||
connectedAt, lastSyncAt, syncEnabled, createdAt, updatedAt
|
||||
}
|
||||
|
||||
// Triage classifications keyed to messages.
|
||||
email_triage {
|
||||
messageId (PK, FK → email_messages.id ON DELETE CASCADE),
|
||||
urgency, category, requiresResponse,
|
||||
modelVersion, tokensUsed, classifiedAt
|
||||
}
|
||||
|
||||
// Pub/Sub idempotency log. Gmail re-delivers; we dedupe.
|
||||
gws_push_log {
|
||||
messageId (Pub/Sub message id, PK),
|
||||
historyId, receivedAt
|
||||
}
|
||||
```
|
||||
|
||||
Plus extensions to `email_messages`:
|
||||
|
||||
- `googleMessageId` (text, indexed) — Gmail's own ID for thread ops.
|
||||
- `googleThreadId` (text, indexed).
|
||||
- `gmailLabels` (text[]) — for "is unread" checks without hitting Gmail.
|
||||
|
||||
The existing `emailAccounts.provider='google'` column repurposes
|
||||
unchanged; the IMAP fields go nullable since OAuth-flow accounts won't
|
||||
populate them.
|
||||
|
||||
## AI cost interaction
|
||||
|
||||
Triage AI is opt-in **twice**: the port admin must turn on
|
||||
`aiEnabled` (Phase 3a flag, default off) **and** `gws.triageEnabled`
|
||||
(this spec, default off). Either toggle off and the inbox sync still
|
||||
runs but skips classification, so staff can manually scan threads
|
||||
without burning tokens.
|
||||
|
||||
Per-message token cost on a current Haiku-class model is roughly
|
||||
1500–2500 tokens including the system prompt. A port doing 200 inbound
|
||||
emails a day at the upper bound is ~500k tokens/day. The default
|
||||
hard-cap is 500k/month, so triage will trip it inside a day. Two
|
||||
mitigations baked in:
|
||||
|
||||
- The system prompt is short (<500 tokens) and prompt-cached on the
|
||||
Anthropic side, so most tokens are output.
|
||||
- Triage runs only on threads not already classified — re-syncs from
|
||||
the watch loop don't re-bill.
|
||||
|
||||
The admin UI shows triage as its own line in the per-feature breakdown
|
||||
so customers can see how much their inbox is costing them and tune
|
||||
caps accordingly.
|
||||
|
||||
## Phased build (assuming Model B)
|
||||
|
||||
| Phase | Scope | Effort | Ships when |
|
||||
| ---------------------------- | ------------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------------------- |
|
||||
| **G1** Connect | OAuth flow + per-port config + per-user token storage. No sync yet. Staff can connect; nothing happens. | 1 week | Standalone |
|
||||
| **G2** Read-only sync | Pub/Sub push receiver + delta sync into `email_messages`. Linked-inbox tab on client detail. No AI. | 1 week | After G1 |
|
||||
| **G3** Triage classification | AI classifier, `email_triage` writes, `/inbox` page sorting. Per-port toggle. | 1 week | After G2; depends on Phase 3b budgets being live (they are) |
|
||||
| **G4** Reply drafts | Gmail API send + draft creation. "Draft reply" button on the client detail Email tab. | 1 week | After G3 |
|
||||
| **G5** Alerts | New `inbox.unanswered_high_value` rule. Hooks into Phase B alert engine. | 2 days | After G3 |
|
||||
|
||||
Total: ~5 weeks for a single engineer, assuming the user provides one
|
||||
real GWS workspace to test against during G1.
|
||||
|
||||
## Open decisions for the user
|
||||
|
||||
These are the questions to resolve before scheduling the build, in
|
||||
priority order:
|
||||
|
||||
1. **Deployment model — A, B, or C?** Default recommendation B.
|
||||
2. **Single user or domain-wide delegation?** Per-staff connect (one
|
||||
token per user) is simpler. Domain-wide delegation lets the port
|
||||
admin connect once on behalf of every staff member but requires
|
||||
the customer to grant a service account broader access. Default
|
||||
recommendation: per-staff.
|
||||
3. **Scope set.** Minimal viable scope is `gmail.readonly`. To send
|
||||
replies (G4) we need `gmail.send`. To manage labels (e.g. mark
|
||||
"triaged-by-CRM") we need `gmail.modify`. Each scope expansion
|
||||
widens the consent screen scariness but doesn't add new
|
||||
verification steps under Model B.
|
||||
4. **Pub/Sub topic ownership.** Pub/Sub topics live in _some_ GCP
|
||||
project. Under Model B the customer's project owns the topic —
|
||||
they pay for Pub/Sub (cents/month) and grant our service account
|
||||
subscriber access. Alternative: Port Nimara owns the topic and
|
||||
the customer's Gmail publishes cross-project (allowed, slightly
|
||||
more setup). Default: customer-owned topic, fewer moving parts.
|
||||
5. **Triage model.** Haiku 4.5 is right for cost; Sonnet 4.6 is
|
||||
right if the ranking quality on Haiku turns out to be poor.
|
||||
Defer this until G3 has real-world tuning data.
|
||||
|
||||
## Things that are NOT in this spec
|
||||
|
||||
- **Microsoft 365 / Outlook integration.** Same shape, different API.
|
||||
Once Model B is proven on GWS, Graph API takes another ~3 weeks.
|
||||
- **Reply drafts grounded in CRM context.** That's G4 and depends on
|
||||
the work in this spec, but the prompt engineering for "good replies
|
||||
citing this client's open interests + reservations + invoices"
|
||||
deserves its own design pass before building.
|
||||
- **Cross-staff triage queue (i.e. "show me all unanswered emails
|
||||
across the team").** That requires either domain-wide delegation
|
||||
(decision #2 above) or per-staff opt-in to a shared view. Punt
|
||||
until staff actually ask for it.
|
||||
- **Sentiment / urgency tone analysis.** Tempting; almost always
|
||||
wrong; skip in v1.
|
||||
- **"Smart drafts" using the recipient's past replies as context.**
|
||||
Every customer asks for this and almost no one uses it once
|
||||
built. Skip.
|
||||
|
||||
## Cost summary at a glance
|
||||
|
||||
| Item | Model A | Model B | Model C |
|
||||
| ------------------------------- | ------------------------------- | -------------------------------------- | ------------------------------------ |
|
||||
| Build effort | 3–4 weeks | ~5 weeks (over G1–G5) | ~1 week (receive-only) |
|
||||
| Calendar time to first customer | 4–6 months | 1 hour of customer admin work | 1 hour of customer Gmail-filter work |
|
||||
| Up-front cash | $15k–$75k (CASA) | $0 | $0 |
|
||||
| Recurring | Re-verification annually | None | None |
|
||||
| Best for | 50+ customers, Marketplace play | 1–10 customers, white-glove onboarding | Customers who refuse OAuth setup |
|
||||
|
||||
The recommendation stands: build Model B for G1 + G2 + G3, ship that,
|
||||
and let real customer demand decide whether G4/G5 and Model A
|
||||
promotion are worth the calendar time.
|
||||
@@ -52,6 +52,7 @@
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"@tanstack/react-query-devtools": "^5.62.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"archiver": "^7.0.1",
|
||||
"better-auth": "^1.2.0",
|
||||
"bullmq": "^5.25.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
@@ -61,7 +62,9 @@
|
||||
"drizzle-orm": "^0.38.0",
|
||||
"imapflow": "^1.2.13",
|
||||
"ioredis": "^5.4.0",
|
||||
"iso-3166-2": "^1.0.0",
|
||||
"jose": "^6.2.1",
|
||||
"libphonenumber-js": "^1.12.42",
|
||||
"lucide-react": "^0.460.0",
|
||||
"mailparser": "^3.9.4",
|
||||
"minio": "^8.0.0",
|
||||
@@ -83,12 +86,15 @@
|
||||
"sonner": "^1.7.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tesseract.js": "^7.0.0",
|
||||
"zod": "^3.24.0",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.5",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@types/archiver": "^7.0.0",
|
||||
"@types/iso-3166-2": "^1.0.4",
|
||||
"@types/mailparser": "^3.4.6",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/nodemailer": "^6.4.0",
|
||||
|
||||
622
pnpm-lock.yaml
generated
622
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
188
scripts/audit-permissions.ts
Normal file
188
scripts/audit-permissions.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Permission-matrix audit.
|
||||
*
|
||||
* Walks every src/app/api/v1/** /route.ts file and reports each exported HTTP
|
||||
* handler (GET/POST/PUT/PATCH/DELETE) that is *not* wrapped in withPermission().
|
||||
* Internal v1 routes should be permission-gated; routes that intentionally use
|
||||
* withAuth() alone (e.g. user-self endpoints) can be allow-listed below.
|
||||
*
|
||||
* Run:
|
||||
* pnpm tsx scripts/audit-permissions.ts
|
||||
*
|
||||
* Exit code:
|
||||
* 0 — every handler is permission-gated or in the allow-list
|
||||
* 1 — at least one handler is missing both a withPermission wrapper and an
|
||||
* allow-list entry. CI should fail.
|
||||
*/
|
||||
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
import { join, relative } from 'node:path';
|
||||
|
||||
const ROOT = join(process.cwd(), 'src/app/api/v1');
|
||||
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const;
|
||||
|
||||
/**
|
||||
* Routes intentionally exempt from withPermission. Each entry should explain
|
||||
* why — typically because the route operates on the caller's own resources
|
||||
* (no port-level permission semantics) or is admin-only and gated by
|
||||
* isSuperAdmin inside the handler.
|
||||
*/
|
||||
const ALLOW_LIST: ReadonlyArray<{ pattern: RegExp; reason: string }> = [
|
||||
// Self / admin / public
|
||||
{ pattern: /\/me\/route\.ts$/, reason: 'Self-endpoint — auth is sufficient.' },
|
||||
{ pattern: /\/admin\//, reason: 'Admin-only — gated by isSuperAdmin inside handler.' },
|
||||
{
|
||||
pattern: /\/notifications\//,
|
||||
reason: 'User-scoped notifications — caller is the resource owner.',
|
||||
},
|
||||
{ pattern: /\/socket\//, reason: 'Socket auth handshake.' },
|
||||
{ pattern: /\/health\//, reason: 'Public health check.' },
|
||||
{ pattern: /\/users\/me\//, reason: 'User-self preferences — caller is the resource owner.' },
|
||||
{ pattern: /\/saved-views\//, reason: 'User-self saved views — caller is the resource owner.' },
|
||||
{
|
||||
pattern: /\/settings\/feature-flag\//,
|
||||
reason: 'Public read of feature-flag bool — no PII; auth is sufficient.',
|
||||
},
|
||||
// Cross-cutting / port-scoped reference data
|
||||
{ pattern: /\/tags\//, reason: 'Tags are cross-cutting reference data; port-scoped via auth.' },
|
||||
{
|
||||
pattern: /\/currency\/(convert|rates)\/route\.ts$/,
|
||||
reason: 'Currency reference data; port-scoped, no PII.',
|
||||
},
|
||||
{
|
||||
pattern: /\/currency\/rates\/refresh\//,
|
||||
reason: 'TODO: gate with admin:manage_settings — currently allow-listed.',
|
||||
},
|
||||
{
|
||||
pattern: /\/search\//,
|
||||
reason: 'Port-scoped search — results filtered by auth context (resources have own perms).',
|
||||
},
|
||||
// Alerts surface in topbar/dashboard for every signed-in user; per-port not per-resource.
|
||||
{ pattern: /\/alerts\//, reason: 'Alerts are user-scoped; port-filtered via auth context.' },
|
||||
// Internally gated by isSuperAdmin
|
||||
{
|
||||
pattern: /\/expenses\/export\/parent-company\//,
|
||||
reason: 'Internally gated by isSuperAdmin inside the handler.',
|
||||
},
|
||||
// Pending dedicated permissions
|
||||
{
|
||||
pattern: /\/ai\//,
|
||||
reason: 'TODO: needs ai:* permission catalog entry. Currently allow-listed.',
|
||||
},
|
||||
{
|
||||
pattern: /\/custom-fields\/\[entityId\]\//,
|
||||
reason: 'TODO: needs custom_fields:* permission. PUT path internally validated.',
|
||||
},
|
||||
{
|
||||
pattern: /\/berth-reservations\/\[id\]\/route\.ts$/,
|
||||
reason: 'TODO: PATCH should map to reservations:edit (not currently in catalog).',
|
||||
},
|
||||
];
|
||||
|
||||
interface Finding {
|
||||
file: string;
|
||||
method: string;
|
||||
reason: 'no-withPermission' | 'no-withAuth' | 'allow-listed';
|
||||
allowReason?: string;
|
||||
}
|
||||
|
||||
async function* walk(dir: string): AsyncGenerator<string> {
|
||||
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isDirectory()) yield* walk(path);
|
||||
else if (entry.isFile() && entry.name === 'route.ts') yield path;
|
||||
}
|
||||
}
|
||||
|
||||
function isAllowListed(file: string): { allowed: boolean; reason?: string } {
|
||||
for (const { pattern, reason } of ALLOW_LIST) {
|
||||
if (pattern.test(file)) return { allowed: true, reason };
|
||||
}
|
||||
return { allowed: false };
|
||||
}
|
||||
|
||||
async function auditFile(file: string): Promise<Finding[]> {
|
||||
const src = await readFile(file, 'utf-8');
|
||||
const findings: Finding[] = [];
|
||||
|
||||
for (const method of HTTP_METHODS) {
|
||||
// Match: export const GET = withAuth(...
|
||||
const declRe = new RegExp(`export\\s+const\\s+${method}\\s*=\\s*(.+?);`, 's');
|
||||
const m = declRe.exec(src);
|
||||
if (!m) continue;
|
||||
const block = m[1] ?? '';
|
||||
|
||||
const hasAuth = /withAuth\s*\(/.test(block);
|
||||
const hasPerm = /withPermission\s*\(/.test(block);
|
||||
const allow = isAllowListed(file);
|
||||
|
||||
if (!hasAuth) {
|
||||
findings.push({ file, method, reason: 'no-withAuth' });
|
||||
continue;
|
||||
}
|
||||
if (!hasPerm) {
|
||||
if (allow.allowed) {
|
||||
findings.push({ file, method, reason: 'allow-listed', allowReason: allow.reason });
|
||||
} else {
|
||||
findings.push({ file, method, reason: 'no-withPermission' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const files: string[] = [];
|
||||
for await (const f of walk(ROOT)) files.push(f);
|
||||
files.sort();
|
||||
|
||||
const all: Finding[] = [];
|
||||
for (const f of files) all.push(...(await auditFile(f)));
|
||||
|
||||
const violations = all.filter(
|
||||
(f) => f.reason === 'no-withPermission' || f.reason === 'no-withAuth',
|
||||
);
|
||||
const allowListed = all.filter((f) => f.reason === 'allow-listed');
|
||||
|
||||
// Markdown report
|
||||
const lines: string[] = [];
|
||||
lines.push('# Permission Matrix Audit');
|
||||
lines.push('');
|
||||
lines.push(`Scanned ${files.length} route files under \`src/app/api/v1/\`.`);
|
||||
lines.push('');
|
||||
|
||||
if (violations.length === 0) {
|
||||
lines.push('**No violations.** Every internal v1 handler is permission-gated.');
|
||||
} else {
|
||||
lines.push(`**${violations.length} violation(s):**`);
|
||||
lines.push('');
|
||||
lines.push('| File | Method | Issue |');
|
||||
lines.push('| --- | --- | --- |');
|
||||
for (const v of violations) {
|
||||
const rel = relative(process.cwd(), v.file);
|
||||
lines.push(`| \`${rel}\` | ${v.method} | ${v.reason} |`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
lines.push(
|
||||
`**Allow-listed:** ${allowListed.length} handler(s) intentionally skip \`withPermission\`.`,
|
||||
);
|
||||
if (allowListed.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('| File | Method | Reason |');
|
||||
lines.push('| --- | --- | --- |');
|
||||
for (const a of allowListed) {
|
||||
const rel = relative(process.cwd(), a.file);
|
||||
lines.push(`| \`${rel}\` | ${a.method} | ${a.allowReason} |`);
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write(lines.join('\n') + '\n');
|
||||
process.exit(violations.length > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(2);
|
||||
});
|
||||
51
scripts/backup/minio-mirror.sh
Normal file
51
scripts/backup/minio-mirror.sh
Normal file
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hourly MinIO mirror for Port Nimara CRM.
|
||||
#
|
||||
# Mirrors the live `MINIO_BUCKET` to the backup destination. `mc mirror`
|
||||
# is incremental — only changed objects transfer — so this is cheap.
|
||||
#
|
||||
# Versioning on the destination bucket is what protects against object
|
||||
# deletes / overwrites; we don't try to roll our own.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
: "${MINIO_ENDPOINT:?MINIO_ENDPOINT not set}"
|
||||
: "${MINIO_ACCESS_KEY:?MINIO_ACCESS_KEY not set}"
|
||||
: "${MINIO_SECRET_KEY:?MINIO_SECRET_KEY not set}"
|
||||
: "${MINIO_BUCKET:?MINIO_BUCKET not set}"
|
||||
: "${BACKUP_S3_BUCKET:?BACKUP_S3_BUCKET not set}"
|
||||
: "${BACKUP_S3_ENDPOINT:?BACKUP_S3_ENDPOINT not set}"
|
||||
: "${BACKUP_S3_ACCESS_KEY:?BACKUP_S3_ACCESS_KEY not set}"
|
||||
: "${BACKUP_S3_SECRET_KEY:?BACKUP_S3_SECRET_KEY not set}"
|
||||
|
||||
# Default scheme: live MinIO is plain HTTP unless MINIO_USE_SSL=true.
|
||||
LIVE_URL="${MINIO_ENDPOINT}"
|
||||
if [[ "${MINIO_USE_SSL:-false}" == "true" ]]; then
|
||||
LIVE_URL="https://${MINIO_ENDPOINT}:${MINIO_PORT:-443}"
|
||||
else
|
||||
LIVE_URL="http://${MINIO_ENDPOINT}:${MINIO_PORT:-9000}"
|
||||
fi
|
||||
|
||||
LIVE_ALIAS="live-$$"
|
||||
BACKUP_ALIAS="bk-$$"
|
||||
trap 'mc alias remove "$LIVE_ALIAS" 2>/dev/null || true; mc alias remove "$BACKUP_ALIAS" 2>/dev/null || true' EXIT
|
||||
|
||||
mc alias set "$LIVE_ALIAS" "$LIVE_URL" \
|
||||
"$MINIO_ACCESS_KEY" "$MINIO_SECRET_KEY" --api S3v4 >/dev/null
|
||||
mc alias set "$BACKUP_ALIAS" "$BACKUP_S3_ENDPOINT" \
|
||||
"$BACKUP_S3_ACCESS_KEY" "$BACKUP_S3_SECRET_KEY" --api S3v4 >/dev/null
|
||||
|
||||
SOURCE="${LIVE_ALIAS}/${MINIO_BUCKET}/"
|
||||
DEST="${BACKUP_ALIAS}/${BACKUP_S3_BUCKET}/minio/"
|
||||
|
||||
echo "[$(date -u +%FT%TZ)] Mirroring $SOURCE → $DEST"
|
||||
|
||||
# `--remove` would delete objects from the destination that no longer
|
||||
# exist in source — we DON'T pass it, because that would let an
|
||||
# accidental delete on the live bucket cascade into permanent loss on
|
||||
# the backup side. Versioning + lifecycle handle stale-object cleanup.
|
||||
mc mirror --quiet --overwrite "$SOURCE" "$DEST"
|
||||
|
||||
# Print byte / count diff for the operator.
|
||||
echo "[$(date -u +%FT%TZ)] Done. Destination summary:"
|
||||
mc du "$DEST"
|
||||
63
scripts/backup/pg-backup.sh
Normal file
63
scripts/backup/pg-backup.sh
Normal file
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hourly PostgreSQL backup for Port Nimara CRM.
|
||||
#
|
||||
# Reads DATABASE_URL and BACKUP_S3_* from the environment. Dumps to a
|
||||
# tmpfile, gzips, optionally GPG-encrypts to BACKUP_GPG_RECIPIENT, and
|
||||
# uploads to s3://${BACKUP_S3_BUCKET}/pg/<hostname>/<UTC-date>/<hour>.dump.gz[.gpg].
|
||||
#
|
||||
# Designed to fail loud: any non-zero exit halts the script and propagates
|
||||
# to the cron / CI runner so the operator sees the failure.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
: "${DATABASE_URL:?DATABASE_URL not set}"
|
||||
: "${BACKUP_S3_BUCKET:?BACKUP_S3_BUCKET not set}"
|
||||
: "${BACKUP_S3_ENDPOINT:?BACKUP_S3_ENDPOINT not set}"
|
||||
: "${BACKUP_S3_ACCESS_KEY:?BACKUP_S3_ACCESS_KEY not set}"
|
||||
: "${BACKUP_S3_SECRET_KEY:?BACKUP_S3_SECRET_KEY not set}"
|
||||
|
||||
HOST="${BACKUP_HOST_OVERRIDE:-$(hostname -s)}"
|
||||
DATE_UTC="$(date -u +%Y-%m-%d)"
|
||||
HOUR_UTC="$(date -u +%H)"
|
||||
WORKDIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORKDIR"' EXIT
|
||||
|
||||
DUMP_FILE="$WORKDIR/${HOUR_UTC}.dump"
|
||||
ARCHIVE_NAME="${HOUR_UTC}.dump.gz"
|
||||
|
||||
echo "[$(date -u +%FT%TZ)] Dumping $DATABASE_URL → $DUMP_FILE"
|
||||
pg_dump --format=custom --compress=9 --no-owner --no-privileges \
|
||||
--file="$DUMP_FILE" "$DATABASE_URL"
|
||||
|
||||
# pg_dump's `custom` format is already compressed, but we wrap in gzip so
|
||||
# the file looks the same regardless of the dump format on disk.
|
||||
gzip -n "$DUMP_FILE"
|
||||
GZ_FILE="${DUMP_FILE}.gz"
|
||||
|
||||
# Optional GPG layer. Only encrypt if the recipient is configured.
|
||||
if [[ -n "${BACKUP_GPG_RECIPIENT:-}" ]]; then
|
||||
echo "[$(date -u +%FT%TZ)] Encrypting for $BACKUP_GPG_RECIPIENT"
|
||||
gpg --batch --yes --trust-model always \
|
||||
--recipient "$BACKUP_GPG_RECIPIENT" \
|
||||
--encrypt --output "${GZ_FILE}.gpg" "$GZ_FILE"
|
||||
rm "$GZ_FILE"
|
||||
GZ_FILE="${GZ_FILE}.gpg"
|
||||
ARCHIVE_NAME="${ARCHIVE_NAME}.gpg"
|
||||
fi
|
||||
|
||||
# Configure mc client for the backup destination.
|
||||
MC_ALIAS="bk-$$"
|
||||
mc alias set "$MC_ALIAS" "$BACKUP_S3_ENDPOINT" \
|
||||
"$BACKUP_S3_ACCESS_KEY" "$BACKUP_S3_SECRET_KEY" \
|
||||
--api S3v4 >/dev/null
|
||||
|
||||
REMOTE_PATH="${MC_ALIAS}/${BACKUP_S3_BUCKET}/pg/${HOST}/${DATE_UTC}/${ARCHIVE_NAME}"
|
||||
echo "[$(date -u +%FT%TZ)] Uploading → $REMOTE_PATH"
|
||||
mc cp --quiet "$GZ_FILE" "$REMOTE_PATH"
|
||||
|
||||
# Tag with retention metadata so lifecycle rules can decide what to expire.
|
||||
mc tag set "$REMOTE_PATH" "kind=hourly&host=${HOST}&date=${DATE_UTC}" >/dev/null
|
||||
|
||||
mc alias remove "$MC_ALIAS" >/dev/null
|
||||
|
||||
echo "[$(date -u +%FT%TZ)] OK ${ARCHIVE_NAME} ($(du -h "$GZ_FILE" | cut -f1))"
|
||||
121
scripts/backup/restore.sh
Normal file
121
scripts/backup/restore.sh
Normal file
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env bash
|
||||
# Cold-restore script for Port Nimara CRM.
|
||||
#
|
||||
# Two modes:
|
||||
# --drill Restore to a sandbox DB ($DRILL_DATABASE_URL) + a tagged
|
||||
# sandbox path on the live MinIO bucket. Used by the weekly
|
||||
# cron drill so the runbook stays accurate.
|
||||
# (no --drill) Interactive production restore. Prompts before each
|
||||
# destructive step; refuses to run if the live DB has
|
||||
# non-empty tables (caller is expected to drop first).
|
||||
#
|
||||
# Common args:
|
||||
# --snapshot YYYY-MM-DD/HH Specific dump to restore. Defaults to "latest".
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DRILL=0
|
||||
SNAPSHOT="latest"
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--drill) DRILL=1; shift ;;
|
||||
--snapshot) SNAPSHOT="$2"; shift 2 ;;
|
||||
*) echo "unknown arg: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
: "${BACKUP_S3_BUCKET:?BACKUP_S3_BUCKET not set}"
|
||||
: "${BACKUP_S3_ENDPOINT:?BACKUP_S3_ENDPOINT not set}"
|
||||
: "${BACKUP_S3_ACCESS_KEY:?BACKUP_S3_ACCESS_KEY not set}"
|
||||
: "${BACKUP_S3_SECRET_KEY:?BACKUP_S3_SECRET_KEY not set}"
|
||||
|
||||
if [[ "$DRILL" -eq 1 ]]; then
|
||||
: "${DRILL_DATABASE_URL:?DRILL_DATABASE_URL not set}"
|
||||
TARGET_DB="$DRILL_DATABASE_URL"
|
||||
echo "[drill] target DB = $TARGET_DB"
|
||||
else
|
||||
: "${DATABASE_URL:?DATABASE_URL not set}"
|
||||
TARGET_DB="$DATABASE_URL"
|
||||
read -rp "About to overwrite $TARGET_DB. Type 'restore' to continue: " confirm
|
||||
[[ "$confirm" == "restore" ]] || { echo "aborted"; exit 1; }
|
||||
fi
|
||||
|
||||
HOST="${BACKUP_HOST_OVERRIDE:-$(hostname -s)}"
|
||||
WORKDIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORKDIR"' EXIT
|
||||
|
||||
MC_ALIAS="bk-$$"
|
||||
mc alias set "$MC_ALIAS" "$BACKUP_S3_ENDPOINT" \
|
||||
"$BACKUP_S3_ACCESS_KEY" "$BACKUP_S3_SECRET_KEY" --api S3v4 >/dev/null
|
||||
trap 'rm -rf "$WORKDIR"; mc alias remove "$MC_ALIAS" 2>/dev/null || true' EXIT
|
||||
|
||||
# Resolve the snapshot path.
|
||||
if [[ "$SNAPSHOT" == "latest" ]]; then
|
||||
REMOTE=$(mc ls --recursive "${MC_ALIAS}/${BACKUP_S3_BUCKET}/pg/${HOST}/" \
|
||||
| awk '{print $NF}' | sort | tail -1)
|
||||
if [[ -z "$REMOTE" ]]; then
|
||||
echo "no snapshots found under ${BACKUP_S3_BUCKET}/pg/${HOST}/" >&2
|
||||
exit 1
|
||||
fi
|
||||
REMOTE="${MC_ALIAS}/${BACKUP_S3_BUCKET}/pg/${HOST}/${REMOTE}"
|
||||
else
|
||||
REMOTE="${MC_ALIAS}/${BACKUP_S3_BUCKET}/pg/${HOST}/${SNAPSHOT}.dump.gz"
|
||||
# If GPG was used, the file lives at .dump.gz.gpg. Try both.
|
||||
if ! mc stat "$REMOTE" >/dev/null 2>&1; then
|
||||
REMOTE="${REMOTE}.gpg"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "[$(date -u +%FT%TZ)] Pulling $REMOTE"
|
||||
LOCAL="$WORKDIR/$(basename "$REMOTE")"
|
||||
mc cp --quiet "$REMOTE" "$LOCAL"
|
||||
|
||||
# Decrypt if needed.
|
||||
if [[ "$LOCAL" == *.gpg ]]; then
|
||||
echo "[$(date -u +%FT%TZ)] Decrypting"
|
||||
gpg --batch --yes --decrypt --output "${LOCAL%.gpg}" "$LOCAL"
|
||||
rm "$LOCAL"
|
||||
LOCAL="${LOCAL%.gpg}"
|
||||
fi
|
||||
|
||||
# Decompress.
|
||||
gunzip "$LOCAL"
|
||||
LOCAL="${LOCAL%.gz}"
|
||||
|
||||
echo "[$(date -u +%FT%TZ)] Restoring into $TARGET_DB"
|
||||
|
||||
# Drop & recreate to guarantee no half-state from a prior run.
|
||||
DB_NAME=$(echo "$TARGET_DB" | sed -E 's|.*/([^?]+).*|\1|')
|
||||
ADMIN_URL=$(echo "$TARGET_DB" | sed -E "s|/${DB_NAME}|/postgres|")
|
||||
|
||||
psql "$ADMIN_URL" -v ON_ERROR_STOP=1 <<SQL
|
||||
SELECT pg_terminate_backend(pid) FROM pg_stat_activity
|
||||
WHERE datname = '${DB_NAME}' AND pid <> pg_backend_pid();
|
||||
DROP DATABASE IF EXISTS "${DB_NAME}";
|
||||
CREATE DATABASE "${DB_NAME}";
|
||||
SQL
|
||||
|
||||
pg_restore --no-owner --no-privileges --dbname "$TARGET_DB" "$LOCAL"
|
||||
|
||||
# Drill mode: compare row counts vs the live producer for parity.
|
||||
if [[ "$DRILL" -eq 1 ]]; then
|
||||
echo "[$(date -u +%FT%TZ)] Drill row-count diff (live vs restored):"
|
||||
TABLES=$(psql -At "$TARGET_DB" -c \
|
||||
"SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename;")
|
||||
diff_count=0
|
||||
while IFS= read -r tbl; do
|
||||
[[ -z "$tbl" ]] && continue
|
||||
live=$(psql -At "${LIVE_DATABASE_URL:-$DATABASE_URL}" -c "SELECT count(*) FROM \"$tbl\";")
|
||||
restored=$(psql -At "$TARGET_DB" -c "SELECT count(*) FROM \"$tbl\";")
|
||||
delta=$((live - restored))
|
||||
if [[ "$delta" -ne 0 ]]; then
|
||||
echo " ⚠ $tbl: live=$live restored=$restored delta=$delta"
|
||||
diff_count=$((diff_count + 1))
|
||||
fi
|
||||
done <<< "$TABLES"
|
||||
if [[ "$diff_count" -eq 0 ]]; then
|
||||
echo " ✓ row counts match across all tables"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "[$(date -u +%FT%TZ)] Restore complete."
|
||||
@@ -20,7 +20,15 @@ async function main() {
|
||||
const isSuperAdmin = args.includes('--super');
|
||||
const name = args.find((a, i) => i > 0 && !a.startsWith('--'));
|
||||
|
||||
const { inviteId, link } = await createCrmInvite({ email, name, isSuperAdmin });
|
||||
// Dev script runs out-of-band (no HTTP request, no session). The service's
|
||||
// super-admin gate requires `invitedBy.isSuperAdmin === true` for super
|
||||
// invites; the script bypasses that with a synthetic caller identity.
|
||||
const { inviteId, link } = await createCrmInvite({
|
||||
email,
|
||||
name,
|
||||
isSuperAdmin,
|
||||
invitedBy: { userId: 'cli-script', isSuperAdmin: true },
|
||||
});
|
||||
console.log(`✓ Invite created (id=${inviteId})`);
|
||||
console.log(` email: ${email}`);
|
||||
console.log(` super_admin: ${isSuperAdmin}`);
|
||||
|
||||
5
src/app/(dashboard)/[portSlug]/admin/ocr/page.tsx
Normal file
5
src/app/(dashboard)/[portSlug]/admin/ocr/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { OcrSettingsForm } from '@/components/admin/ocr-settings-form';
|
||||
|
||||
export default function OcrSettingsPage() {
|
||||
return <OcrSettingsForm />;
|
||||
}
|
||||
@@ -149,6 +149,12 @@ const SECTIONS: AdminSection[] = [
|
||||
description: 'Initial-setup wizard for fresh ports.',
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
{
|
||||
href: 'ocr',
|
||||
label: 'Receipt OCR',
|
||||
description: 'Configure the AI provider used by the mobile receipt scanner.',
|
||||
icon: ScrollText,
|
||||
},
|
||||
];
|
||||
|
||||
export default async function AdminLandingPage({
|
||||
|
||||
5
src/app/(dashboard)/[portSlug]/alerts/page.tsx
Normal file
5
src/app/(dashboard)/[portSlug]/alerts/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { AlertsPageShell } from '@/components/alerts/alerts-page-shell';
|
||||
|
||||
export default function AlertsPage() {
|
||||
return <AlertsPageShell />;
|
||||
}
|
||||
50
src/app/(scanner)/[portSlug]/scan/layout.tsx
Normal file
50
src/app/(scanner)/[portSlug]/scan/layout.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
import { auth } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { ports as portsTable } from '@/lib/db/schema/ports';
|
||||
import { QueryProvider } from '@/providers/query-provider';
|
||||
import { PortProvider } from '@/providers/port-provider';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
/**
|
||||
* Minimal layout for the mobile receipt-scanner PWA. No sidebar, no
|
||||
* topbar — the scanner is its own contained surface. Adds the PWA
|
||||
* manifest link + theme color so iOS/Android pick up "Add to Home
|
||||
* Screen". Auth check matches the dashboard layout so unauthorized
|
||||
* users still bounce to /login.
|
||||
*/
|
||||
export default async function ScannerLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ portSlug: string }>;
|
||||
}) {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session?.user) redirect('/login');
|
||||
|
||||
const { portSlug } = await params;
|
||||
const port = await db.query.ports.findFirst({
|
||||
where: eq(portsTable.slug, portSlug),
|
||||
});
|
||||
if (!port) redirect('/login');
|
||||
|
||||
return (
|
||||
<QueryProvider>
|
||||
<PortProvider ports={port ? [port] : []} defaultPortId={port?.id ?? null}>
|
||||
<head>
|
||||
<link rel="manifest" href={`/${portSlug}/scan/manifest.webmanifest`} />
|
||||
<meta name="theme-color" content="#3a7bc8" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="PN Scanner" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
</head>
|
||||
<div className="min-h-[100dvh] bg-background">{children}</div>
|
||||
</PortProvider>
|
||||
</QueryProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
|
||||
/**
|
||||
* Per-port PWA manifest. Scoped to `/<portSlug>/scan` so the install
|
||||
* only covers the scanner page, not the rest of the CRM. Each port
|
||||
* gets its own homescreen icon labeled with its name.
|
||||
*/
|
||||
export async function GET(_req: Request, { params }: { params: Promise<{ portSlug: string }> }) {
|
||||
const { portSlug } = await params;
|
||||
const port = await db.query.ports.findFirst({ where: eq(ports.slug, portSlug) });
|
||||
const portName = port?.name ?? 'Port Nimara';
|
||||
|
||||
const manifest = {
|
||||
name: `${portName} — Scanner`,
|
||||
short_name: 'Scanner',
|
||||
description: `Capture and submit expense receipts for ${portName}.`,
|
||||
start_url: `/${portSlug}/scan`,
|
||||
scope: `/${portSlug}/scan`,
|
||||
display: 'standalone',
|
||||
orientation: 'portrait',
|
||||
background_color: '#ffffff',
|
||||
theme_color: '#3a7bc8',
|
||||
icons: [
|
||||
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png' },
|
||||
{
|
||||
src: '/icon-512-maskable.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'maskable',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return NextResponse.json(manifest, {
|
||||
headers: {
|
||||
'Content-Type': 'application/manifest+json',
|
||||
'Cache-Control': 'public, max-age=300, must-revalidate',
|
||||
},
|
||||
});
|
||||
}
|
||||
11
src/app/(scanner)/[portSlug]/scan/page.tsx
Normal file
11
src/app/(scanner)/[portSlug]/scan/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { ScanShell } from '@/components/scan/scan-shell';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Scan receipt — Port Nimara',
|
||||
};
|
||||
|
||||
export default function ScanPage() {
|
||||
return <ScanShell />;
|
||||
}
|
||||
@@ -1,68 +1,15 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { redis } from '@/lib/redis';
|
||||
import { minioClient } from '@/lib/minio';
|
||||
import { env } from '@/lib/env';
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
type CheckStatus = 'ok' | 'error';
|
||||
|
||||
interface HealthChecks {
|
||||
postgres: CheckStatus;
|
||||
redis: CheckStatus;
|
||||
minio: CheckStatus;
|
||||
}
|
||||
|
||||
interface HealthResponse {
|
||||
status: 'healthy' | 'degraded';
|
||||
checks: HealthChecks;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export async function GET(): Promise<NextResponse<HealthResponse>> {
|
||||
const checks: HealthChecks = {
|
||||
postgres: 'error',
|
||||
redis: 'error',
|
||||
minio: 'error',
|
||||
};
|
||||
|
||||
await Promise.allSettled([
|
||||
db
|
||||
.execute(sql`SELECT 1`)
|
||||
.then(() => {
|
||||
checks.postgres = 'ok';
|
||||
})
|
||||
.catch(() => {
|
||||
checks.postgres = 'error';
|
||||
}),
|
||||
|
||||
redis
|
||||
.ping()
|
||||
.then(() => {
|
||||
checks.redis = 'ok';
|
||||
})
|
||||
.catch(() => {
|
||||
checks.redis = 'error';
|
||||
}),
|
||||
|
||||
minioClient
|
||||
.bucketExists(env.MINIO_BUCKET)
|
||||
.then(() => {
|
||||
checks.minio = 'ok';
|
||||
})
|
||||
.catch(() => {
|
||||
checks.minio = 'error';
|
||||
}),
|
||||
]);
|
||||
|
||||
const allHealthy = Object.values(checks).every((s) => s === 'ok');
|
||||
const status: HealthResponse['status'] = allHealthy ? 'healthy' : 'degraded';
|
||||
|
||||
const body: HealthResponse = {
|
||||
status,
|
||||
checks,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return NextResponse.json(body, { status: allHealthy ? 200 : 503 });
|
||||
/**
|
||||
* Liveness probe — confirms the Next.js process is responding.
|
||||
*
|
||||
* Returns 200 unconditionally; if the process is wedged or has crashed
|
||||
* the request never lands here at all. Do NOT include database/Redis/MinIO
|
||||
* checks in this endpoint — a transient downstream blip should drop the
|
||||
* pod from the load balancer (readiness), not restart the pod (liveness).
|
||||
*
|
||||
* For deep dependency checks, hit `/api/ready` instead.
|
||||
*/
|
||||
export async function GET() {
|
||||
return NextResponse.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
}
|
||||
|
||||
@@ -12,31 +12,23 @@ import { yachts, yachtOwnershipHistory } from '@/lib/db/schema/yachts';
|
||||
import { companies, companyMemberships } from '@/lib/db/schema/companies';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { errorResponse, RateLimitError } from '@/lib/errors';
|
||||
import { checkRateLimit, rateLimiters } from '@/lib/rate-limit';
|
||||
import { publicInterestSchema } from '@/lib/validators/interests';
|
||||
import { sendInquiryNotifications } from '@/lib/services/inquiry-notifications.service';
|
||||
import { parsePhone } from '@/lib/i18n/phone';
|
||||
import type { CountryCode } from '@/lib/i18n/countries';
|
||||
|
||||
// ─── Simple in-memory rate limiter ───────────────────────────────────────────
|
||||
// Max 5 requests per hour per IP
|
||||
|
||||
const ipHits = new Map<string, { count: number; resetAt: number }>();
|
||||
const WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
||||
const MAX_HITS = 5;
|
||||
|
||||
function checkRateLimit(ip: string): void {
|
||||
const now = Date.now();
|
||||
const entry = ipHits.get(ip);
|
||||
|
||||
if (!entry || now > entry.resetAt) {
|
||||
ipHits.set(ip, { count: 1, resetAt: now + WINDOW_MS });
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.count >= MAX_HITS) {
|
||||
const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
|
||||
/**
|
||||
* Throws RateLimitError if the IP has exceeded the public-form quota.
|
||||
* Backed by the Redis sliding-window limiter so the cap survives restarts
|
||||
* and is shared across worker processes.
|
||||
*/
|
||||
async function gateRateLimit(ip: string): Promise<void> {
|
||||
const result = await checkRateLimit(ip, rateLimiters.publicForm);
|
||||
if (!result.allowed) {
|
||||
const retryAfter = Math.max(1, Math.ceil((result.resetAt - Date.now()) / 1000));
|
||||
throw new RateLimitError(retryAfter);
|
||||
}
|
||||
|
||||
entry.count += 1;
|
||||
}
|
||||
|
||||
type PublicInterestData = z.infer<typeof publicInterestSchema>;
|
||||
@@ -50,7 +42,7 @@ type Tx = typeof db;
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
|
||||
checkRateLimit(ip);
|
||||
await gateRateLimit(ip);
|
||||
|
||||
const body = await req.json();
|
||||
const data = publicInterestSchema.parse(body);
|
||||
@@ -61,6 +53,16 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'Port context required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Server-side phone normalization for older website builds that post raw
|
||||
// international/national strings. Newer builds may pre-fill phoneE164/Country.
|
||||
let phoneE164 = data.phoneE164 ?? null;
|
||||
let phoneCountry: CountryCode | null = (data.phoneCountry as CountryCode | null) ?? null;
|
||||
if (!phoneE164) {
|
||||
const parsed = parsePhone(data.phone, phoneCountry ?? undefined);
|
||||
phoneE164 = parsed.e164;
|
||||
phoneCountry = parsed.country ?? phoneCountry;
|
||||
}
|
||||
|
||||
const fullName =
|
||||
data.firstName && data.lastName
|
||||
? `${data.firstName} ${data.lastName}`
|
||||
@@ -96,17 +98,21 @@ export async function POST(req: NextRequest) {
|
||||
});
|
||||
if (existingClient && existingClient.portId === portId) {
|
||||
clientId = existingClient.id;
|
||||
const updates: Partial<typeof clients.$inferInsert> = {};
|
||||
if (data.preferredContactMethod) {
|
||||
await tx
|
||||
.update(clients)
|
||||
.set({ preferredContactMethod: data.preferredContactMethod })
|
||||
.where(eq(clients.id, clientId));
|
||||
updates.preferredContactMethod = data.preferredContactMethod;
|
||||
}
|
||||
if (data.nationalityIso && !existingClient.nationalityIso) {
|
||||
updates.nationalityIso = data.nationalityIso;
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await tx.update(clients).set(updates).where(eq(clients.id, clientId));
|
||||
}
|
||||
} else {
|
||||
clientId = await createClientInTx(tx, portId, fullName, data);
|
||||
clientId = await createClientInTx(tx, portId, fullName, data, phoneE164, phoneCountry);
|
||||
}
|
||||
} else {
|
||||
clientId = await createClientInTx(tx, portId, fullName, data);
|
||||
clientId = await createClientInTx(tx, portId, fullName, data, phoneE164, phoneCountry);
|
||||
}
|
||||
|
||||
// 2. Optional: upsert company + add membership
|
||||
@@ -128,7 +134,8 @@ export async function POST(req: NextRequest) {
|
||||
name: data.company.name,
|
||||
legalName: data.company.legalName ?? null,
|
||||
taxId: data.company.taxId ?? null,
|
||||
incorporationCountry: data.company.incorporationCountry ?? null,
|
||||
incorporationCountryIso: data.company.incorporationCountryIso ?? null,
|
||||
incorporationSubdivisionIso: data.company.incorporationSubdivisionIso ?? null,
|
||||
status: 'active',
|
||||
})
|
||||
.returning();
|
||||
@@ -198,9 +205,9 @@ export async function POST(req: NextRequest) {
|
||||
label: 'Primary',
|
||||
streetAddress: data.address.street ?? null,
|
||||
city: data.address.city ?? null,
|
||||
stateProvince: data.address.stateProvince ?? null,
|
||||
subdivisionIso: data.address.subdivisionIso ?? null,
|
||||
postalCode: data.address.postalCode ?? null,
|
||||
country: data.address.country ?? null,
|
||||
countryIso: data.address.countryIso ?? null,
|
||||
isPrimary: true,
|
||||
});
|
||||
}
|
||||
@@ -279,7 +286,9 @@ async function createClientInTx(
|
||||
tx: Tx,
|
||||
portId: string,
|
||||
fullName: string,
|
||||
data: Pick<PublicInterestData, 'email' | 'phone' | 'preferredContactMethod'>,
|
||||
data: Pick<PublicInterestData, 'email' | 'phone' | 'preferredContactMethod' | 'nationalityIso'>,
|
||||
phoneE164: string | null,
|
||||
phoneCountry: CountryCode | null,
|
||||
): Promise<string> {
|
||||
const [newClient] = await tx
|
||||
.insert(clients)
|
||||
@@ -287,6 +296,7 @@ async function createClientInTx(
|
||||
portId,
|
||||
fullName,
|
||||
preferredContactMethod: data.preferredContactMethod,
|
||||
nationalityIso: data.nationalityIso ?? null,
|
||||
source: 'website',
|
||||
})
|
||||
.returning();
|
||||
@@ -303,6 +313,8 @@ async function createClientInTx(
|
||||
clientId,
|
||||
channel: 'phone',
|
||||
value: data.phone,
|
||||
valueE164: phoneE164,
|
||||
valueCountry: phoneCountry,
|
||||
isPrimary: false,
|
||||
});
|
||||
|
||||
|
||||
@@ -14,26 +14,23 @@ import {
|
||||
import { env } from '@/lib/env';
|
||||
import { errorResponse, RateLimitError, ValidationError } from '@/lib/errors';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { checkRateLimit, rateLimiters } from '@/lib/rate-limit';
|
||||
import { publicResidentialInquirySchema } from '@/lib/validators/residential';
|
||||
import { emitToRoom } from '@/lib/socket/server';
|
||||
import { parsePhone } from '@/lib/i18n/phone';
|
||||
import type { CountryCode } from '@/lib/i18n/countries';
|
||||
|
||||
// ─── Rate limiter (5 per hour per IP) ────────────────────────────────────────
|
||||
|
||||
const ipHits = new Map<string, { count: number; resetAt: number }>();
|
||||
const WINDOW_MS = 60 * 60 * 1000;
|
||||
const MAX_HITS = 5;
|
||||
|
||||
function checkRateLimit(ip: string): void {
|
||||
const now = Date.now();
|
||||
const entry = ipHits.get(ip);
|
||||
if (!entry || now > entry.resetAt) {
|
||||
ipHits.set(ip, { count: 1, resetAt: now + WINDOW_MS });
|
||||
return;
|
||||
/**
|
||||
* Throws RateLimitError if the IP has exceeded the public-form quota.
|
||||
* Backed by the Redis sliding-window limiter so the cap survives restarts
|
||||
* and is shared across worker processes.
|
||||
*/
|
||||
async function gateRateLimit(ip: string): Promise<void> {
|
||||
const result = await checkRateLimit(ip, rateLimiters.publicForm);
|
||||
if (!result.allowed) {
|
||||
const retryAfter = Math.max(1, Math.ceil((result.resetAt - Date.now()) / 1000));
|
||||
throw new RateLimitError(retryAfter);
|
||||
}
|
||||
if (entry.count >= MAX_HITS) {
|
||||
throw new RateLimitError(Math.ceil((entry.resetAt - now) / 1000));
|
||||
}
|
||||
entry.count += 1;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,7 +44,7 @@ function checkRateLimit(ip: string): void {
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
|
||||
checkRateLimit(ip);
|
||||
await gateRateLimit(ip);
|
||||
|
||||
const body = await req.json();
|
||||
const data = publicResidentialInquirySchema.parse(body);
|
||||
@@ -61,6 +58,16 @@ export async function POST(req: NextRequest) {
|
||||
throw new ValidationError('Unknown port');
|
||||
}
|
||||
|
||||
// If the website didn't pre-normalize, parse server-side. International
|
||||
// strings parse without a hint; national-format submissions need a country.
|
||||
let phoneE164 = data.phoneE164 ?? null;
|
||||
let phoneCountry: CountryCode | null = (data.phoneCountry as CountryCode | null) ?? null;
|
||||
if (!phoneE164) {
|
||||
const parsed = parsePhone(data.phone, phoneCountry ?? undefined);
|
||||
phoneE164 = parsed.e164;
|
||||
phoneCountry = parsed.country ?? phoneCountry;
|
||||
}
|
||||
|
||||
const result = await withTransaction(async (tx) => {
|
||||
const [client] = await tx
|
||||
.insert(residentialClients)
|
||||
@@ -69,7 +76,13 @@ export async function POST(req: NextRequest) {
|
||||
fullName: `${data.firstName.trim()} ${data.lastName.trim()}`.trim(),
|
||||
email: data.email,
|
||||
phone: data.phone,
|
||||
phoneE164,
|
||||
phoneCountry,
|
||||
nationalityIso: data.nationalityIso ?? null,
|
||||
timezone: data.timezone ?? null,
|
||||
placeOfResidence: data.placeOfResidence,
|
||||
placeOfResidenceCountryIso: data.placeOfResidenceCountryIso ?? null,
|
||||
subdivisionIso: data.subdivisionIso ?? null,
|
||||
preferredContactMethod: data.preferredContactMethod,
|
||||
source: 'website',
|
||||
status: 'prospect',
|
||||
|
||||
82
src/app/api/ready/route.ts
Normal file
82
src/app/api/ready/route.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { redis } from '@/lib/redis';
|
||||
import { minioClient } from '@/lib/minio';
|
||||
import { env } from '@/lib/env';
|
||||
|
||||
type CheckStatus = 'ok' | 'error';
|
||||
|
||||
interface ReadyChecks {
|
||||
postgres: CheckStatus;
|
||||
redis: CheckStatus;
|
||||
minio: CheckStatus;
|
||||
}
|
||||
|
||||
interface ReadyResponse {
|
||||
status: 'ready' | 'degraded';
|
||||
checks: ReadyChecks;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Readiness probe — verifies that every backing service this process
|
||||
* needs to serve traffic is reachable. A 503 should drop the pod from the
|
||||
* load balancer until the next probe succeeds; it should not trigger a
|
||||
* pod restart (that's what `/api/health` is for).
|
||||
*
|
||||
* Checks:
|
||||
* - postgres: `SELECT 1` against the primary
|
||||
* - redis: `PING`
|
||||
* - minio: `bucketExists(<configured-bucket>)`
|
||||
*
|
||||
* Documenso + SMTP are intentionally not probed here: they're optional
|
||||
* integrations, and each tenant configures its own credentials. A
|
||||
* tenant-misconfigured Documenso instance shouldn't deadline the entire
|
||||
* shared CRM.
|
||||
*/
|
||||
export async function GET(): Promise<NextResponse<ReadyResponse>> {
|
||||
const checks: ReadyChecks = {
|
||||
postgres: 'error',
|
||||
redis: 'error',
|
||||
minio: 'error',
|
||||
};
|
||||
|
||||
await Promise.allSettled([
|
||||
db
|
||||
.execute(sql`SELECT 1`)
|
||||
.then(() => {
|
||||
checks.postgres = 'ok';
|
||||
})
|
||||
.catch(() => {
|
||||
checks.postgres = 'error';
|
||||
}),
|
||||
|
||||
redis
|
||||
.ping()
|
||||
.then(() => {
|
||||
checks.redis = 'ok';
|
||||
})
|
||||
.catch(() => {
|
||||
checks.redis = 'error';
|
||||
}),
|
||||
|
||||
minioClient
|
||||
.bucketExists(env.MINIO_BUCKET)
|
||||
.then(() => {
|
||||
checks.minio = 'ok';
|
||||
})
|
||||
.catch(() => {
|
||||
checks.minio = 'error';
|
||||
}),
|
||||
]);
|
||||
|
||||
const allReady = Object.values(checks).every((s) => s === 'ok');
|
||||
const status: ReadyResponse['status'] = allReady ? 'ready' : 'degraded';
|
||||
|
||||
return NextResponse.json(
|
||||
{ status, checks, timestamp: new Date().toISOString() },
|
||||
{ status: allReady ? 200 : 503 },
|
||||
);
|
||||
}
|
||||
46
src/app/api/v1/admin/ai-budget/route.ts
Normal file
46
src/app/api/v1/admin/ai-budget/route.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import {
|
||||
getAiBudget,
|
||||
setAiBudget,
|
||||
currentPeriodTokens,
|
||||
periodBreakdown,
|
||||
} from '@/lib/services/ai-budget.service';
|
||||
|
||||
const saveSchema = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
softCapTokens: z.number().int().nonnegative().max(100_000_000).optional(),
|
||||
hardCapTokens: z.number().int().nonnegative().max(100_000_000).optional(),
|
||||
period: z.enum(['day', 'week', 'month']).optional(),
|
||||
});
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('admin', 'manage_settings', async (req, ctx) => {
|
||||
try {
|
||||
const [budget, used, breakdown] = await Promise.all([
|
||||
getAiBudget(ctx.portId),
|
||||
currentPeriodTokens(ctx.portId),
|
||||
periodBreakdown(ctx.portId),
|
||||
]);
|
||||
return NextResponse.json({ data: { budget, used, breakdown } });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const PUT = withAuth(
|
||||
withPermission('admin', 'manage_settings', async (req, ctx) => {
|
||||
try {
|
||||
const body = await parseBody(req, saveSchema);
|
||||
const next = await setAiBudget(ctx.portId, body, ctx.userId);
|
||||
return NextResponse.json({ data: next });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
25
src/app/api/v1/admin/alerts/run-engine/route.ts
Normal file
25
src/app/api/v1/admin/alerts/run-engine/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth } from '@/lib/api/helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { runAlertEngineForPorts } from '@/lib/services/alert-engine';
|
||||
|
||||
/**
|
||||
* Admin trigger for an immediate alert engine sweep over the caller's port.
|
||||
* Useful for manual ops ("re-evaluate now after I fixed a rule") and
|
||||
* exercised by the realapi socket fanout test.
|
||||
*
|
||||
* Requires super_admin or per-port admin permissions; the engine itself
|
||||
* is idempotent — duplicate runs only re-evaluate, never duplicate rows.
|
||||
*/
|
||||
export const POST = withAuth(async (_req, ctx) => {
|
||||
try {
|
||||
if (!ctx.isSuperAdmin) {
|
||||
return NextResponse.json({ error: 'Super admin only' }, { status: 403 });
|
||||
}
|
||||
const summary = await runAlertEngineForPorts([ctx.portId]);
|
||||
return NextResponse.json({ data: summary });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
});
|
||||
@@ -1,29 +1,76 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { inArray } from 'drizzle-orm';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseQuery } from '@/lib/api/route-helpers';
|
||||
import { listAuditLogs } from '@/lib/services/audit.service';
|
||||
import { searchAuditLogs } from '@/lib/services/audit-search.service';
|
||||
import { db } from '@/lib/db';
|
||||
import { user } from '@/lib/db/schema/users';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
|
||||
const auditQuerySchema = z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(50),
|
||||
limit: z.coerce.number().int().min(1).max(200).default(50),
|
||||
entityType: z.string().optional(),
|
||||
action: z.string().optional(),
|
||||
userId: z.string().optional(),
|
||||
entityId: z.string().optional(),
|
||||
dateFrom: z.string().optional(),
|
||||
dateTo: z.string().optional(),
|
||||
/** Free-text query against the tsvector `search_text` column. */
|
||||
search: z.string().optional(),
|
||||
/** Cursor pair from the previous page's response. */
|
||||
cursorAt: z.string().optional(),
|
||||
cursorId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('admin', 'view_audit_log', async (req, ctx) => {
|
||||
try {
|
||||
const query = parseQuery(req, auditQuerySchema);
|
||||
const result = await listAuditLogs(ctx.portId, query);
|
||||
return NextResponse.json(result);
|
||||
const cursor =
|
||||
query.cursorAt && query.cursorId
|
||||
? { createdAt: new Date(query.cursorAt), id: query.cursorId }
|
||||
: undefined;
|
||||
const { rows, nextCursor } = await searchAuditLogs({
|
||||
portId: ctx.portId,
|
||||
q: query.search,
|
||||
userId: query.userId,
|
||||
action: query.action,
|
||||
entityType: query.entityType,
|
||||
entityId: query.entityId,
|
||||
from: query.dateFrom ? new Date(query.dateFrom) : undefined,
|
||||
to: query.dateTo ? new Date(query.dateTo) : undefined,
|
||||
cursor,
|
||||
limit: query.limit,
|
||||
});
|
||||
|
||||
// Resolve actor emails in one batched query so the table can show
|
||||
// who did what without N+1 round trips.
|
||||
const userIds = Array.from(
|
||||
new Set(rows.map((r) => r.userId).filter((id): id is string => Boolean(id))),
|
||||
);
|
||||
const userRows = userIds.length
|
||||
? await db
|
||||
.select({ id: user.id, email: user.email, name: user.name })
|
||||
.from(user)
|
||||
.where(inArray(user.id, userIds))
|
||||
: [];
|
||||
const userMap = new Map(userRows.map((u) => [u.id, u]));
|
||||
|
||||
const data = rows.map((r) => ({
|
||||
...r,
|
||||
actor: r.userId ? (userMap.get(r.userId) ?? null) : null,
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
data,
|
||||
pagination: {
|
||||
nextCursor: nextCursor
|
||||
? { createdAt: nextCursor.createdAt.toISOString(), id: nextCursor.id }
|
||||
: null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { errorResponse, ForbiddenError } from '@/lib/errors';
|
||||
import { resendCrmInvite } from '@/lib/services/crm-invite.service';
|
||||
|
||||
// Resend mints a fresh token + new email on a global invite row;
|
||||
// restrict to super-admins to match revoke/list and avoid cross-tenant
|
||||
// re-issuance of foreign-port invitations.
|
||||
export const POST = withAuth(
|
||||
withPermission('admin', 'manage_users', async (_req, ctx, params) => {
|
||||
try {
|
||||
if (!ctx.isSuperAdmin) {
|
||||
throw new ForbiddenError('Resending CRM invites requires super-admin');
|
||||
}
|
||||
const id = params.id ?? '';
|
||||
const result = await resendCrmInvite(id, {
|
||||
userId: ctx.userId,
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { errorResponse, ForbiddenError } from '@/lib/errors';
|
||||
import { revokeCrmInvite } from '@/lib/services/crm-invite.service';
|
||||
|
||||
// Invites are a global resource (no portId column). Revoking a foreign
|
||||
// tenant's pending invite by id would be cross-tenant tampering;
|
||||
// restrict to super-admins to match the listing endpoint.
|
||||
export const DELETE = withAuth(
|
||||
withPermission('admin', 'manage_users', async (_req, ctx, params) => {
|
||||
try {
|
||||
if (!ctx.isSuperAdmin) {
|
||||
throw new ForbiddenError('Revoking CRM invites requires super-admin');
|
||||
}
|
||||
const id = params.id ?? '';
|
||||
await revokeCrmInvite(id, {
|
||||
userId: ctx.userId,
|
||||
|
||||
@@ -3,12 +3,20 @@ import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { errorResponse, ForbiddenError } from '@/lib/errors';
|
||||
import { createCrmInvite, listCrmInvites } from '@/lib/services/crm-invite.service';
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('admin', 'manage_users', async (_req, _ctx) => {
|
||||
withPermission('admin', 'manage_users', async (_req, ctx) => {
|
||||
try {
|
||||
// crm_user_invites is a global table (no per-port column) — invites
|
||||
// mint better-auth users that may later be assigned roles in any
|
||||
// port. Listing it cross-tenant would let a port-A director
|
||||
// enumerate pending invitee emails, names, and isSuperAdmin flags
|
||||
// for every other tenant. Restrict the listing to super-admins.
|
||||
if (!ctx.isSuperAdmin) {
|
||||
throw new ForbiddenError('Listing CRM invites requires super-admin');
|
||||
}
|
||||
const data = await listCrmInvites();
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
@@ -24,10 +32,17 @@ const createInviteSchema = z.object({
|
||||
});
|
||||
|
||||
export const POST = withAuth(
|
||||
withPermission('admin', 'manage_users', async (req, _ctx) => {
|
||||
withPermission('admin', 'manage_users', async (req, ctx) => {
|
||||
try {
|
||||
const body = await parseBody(req, createInviteSchema);
|
||||
const result = await createCrmInvite(body);
|
||||
// Only existing super-admins can mint super-admin invitations. The
|
||||
// manage_users permission is granted to port-scoped director roles,
|
||||
// which must not be able to elevate themselves cross-tenant by
|
||||
// inviting a fresh super_admin.
|
||||
if (body.isSuperAdmin && !ctx.isSuperAdmin) {
|
||||
throw new ForbiddenError('Only super admins can mint super-admin invitations');
|
||||
}
|
||||
const result = await createCrmInvite({ ...body, invitedBy: ctx });
|
||||
return NextResponse.json({ data: result }, { status: 201 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
|
||||
72
src/app/api/v1/admin/ocr-settings/route.ts
Normal file
72
src/app/api/v1/admin/ocr-settings/route.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { getPublicOcrConfig, saveOcrConfig, OCR_MODELS } from '@/lib/services/ocr-config.service';
|
||||
|
||||
const saveSchema = z.object({
|
||||
/** When 'global', requires super_admin and stores at port_id=null. */
|
||||
scope: z.enum(['port', 'global']),
|
||||
provider: z.enum(['openai', 'claude']),
|
||||
model: z.string().min(1),
|
||||
apiKey: z.string().optional(),
|
||||
clearApiKey: z.boolean().optional(),
|
||||
useGlobal: z.boolean().optional(),
|
||||
aiEnabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// Only role tiers that hold `admin.manage_settings` (director / super_admin)
|
||||
// may read or write the OCR config: the apiKey is stored encrypted but is
|
||||
// passed straight into the receipt-scan handler, so a swapped key would
|
||||
// exfiltrate every subsequent receipt image to whatever endpoint that key
|
||||
// authenticates with.
|
||||
export const GET = withAuth(
|
||||
withPermission('admin', 'manage_settings', async (req, ctx) => {
|
||||
try {
|
||||
const url = new URL(req.url);
|
||||
const scope = url.searchParams.get('scope') ?? 'port';
|
||||
if (scope === 'global' && !ctx.isSuperAdmin) {
|
||||
return NextResponse.json({ error: 'Super admin only' }, { status: 403 });
|
||||
}
|
||||
const config = await getPublicOcrConfig(scope === 'global' ? null : ctx.portId);
|
||||
return NextResponse.json({ data: config, models: OCR_MODELS });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const PUT = withAuth(
|
||||
withPermission('admin', 'manage_settings', async (req, ctx) => {
|
||||
try {
|
||||
const body = await parseBody(req, saveSchema);
|
||||
if (body.scope === 'global' && !ctx.isSuperAdmin) {
|
||||
return NextResponse.json({ error: 'Super admin only' }, { status: 403 });
|
||||
}
|
||||
const validModels = OCR_MODELS[body.provider];
|
||||
if (!validModels.includes(body.model)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Invalid model for provider ${body.provider}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
await saveOcrConfig(
|
||||
body.scope === 'global' ? null : ctx.portId,
|
||||
{
|
||||
provider: body.provider,
|
||||
model: body.model,
|
||||
apiKey: body.apiKey,
|
||||
clearApiKey: body.clearApiKey,
|
||||
useGlobal: body.useGlobal,
|
||||
aiEnabled: body.aiEnabled,
|
||||
},
|
||||
ctx.userId,
|
||||
);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
31
src/app/api/v1/admin/ocr-settings/test/route.ts
Normal file
31
src/app/api/v1/admin/ocr-settings/test/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { OCR_MODELS } from '@/lib/services/ocr-config.service';
|
||||
import { testProvider } from '@/lib/services/ocr-providers';
|
||||
|
||||
const schema = z.object({
|
||||
provider: z.enum(['openai', 'claude']),
|
||||
model: z.string().min(1),
|
||||
apiKey: z.string().min(1),
|
||||
});
|
||||
|
||||
// `manage_settings`-gated for parity with the parent OCR settings route —
|
||||
// triggers outbound AI provider auth requests using a caller-supplied key.
|
||||
export const POST = withAuth(
|
||||
withPermission('admin', 'manage_settings', async (req) => {
|
||||
try {
|
||||
const body = await parseBody(req, schema);
|
||||
if (!OCR_MODELS[body.provider].includes(body.model)) {
|
||||
return NextResponse.json({ error: 'Invalid model' }, { status: 400 });
|
||||
}
|
||||
const result = await testProvider(body.provider, body.apiKey, body.model);
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -4,11 +4,25 @@ import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { getPort, updatePort } from '@/lib/services/ports.service';
|
||||
import { updatePortSchema } from '@/lib/validators/ports';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { errorResponse, ForbiddenError } from '@/lib/errors';
|
||||
|
||||
/**
|
||||
* Non-super-admin callers (e.g. port directors holding admin.manage_settings)
|
||||
* may only read/mutate THEIR OWN port row. The path id is therefore
|
||||
* compared against ctx.portId and a foreign target is rejected before the
|
||||
* service is touched. Super-admins retain unrestricted access.
|
||||
*/
|
||||
function assertPortInScope(targetPortId: string, ctx: { portId: string; isSuperAdmin: boolean }) {
|
||||
if (ctx.isSuperAdmin) return;
|
||||
if (targetPortId !== ctx.portId) {
|
||||
throw new ForbiddenError('Cross-tenant port access denied');
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('admin', 'manage_settings', async (_req, _ctx, params) => {
|
||||
withPermission('admin', 'manage_settings', async (_req, ctx, params) => {
|
||||
try {
|
||||
assertPortInScope(params.id!, ctx);
|
||||
const data = await getPort(params.id!);
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
@@ -20,6 +34,7 @@ export const GET = withAuth(
|
||||
export const PATCH = withAuth(
|
||||
withPermission('admin', 'manage_settings', async (req, ctx, params) => {
|
||||
try {
|
||||
assertPortInScope(params.id!, ctx);
|
||||
const body = await parseBody(req, updatePortSchema);
|
||||
const data = await updatePort(params.id!, body, {
|
||||
userId: ctx.userId,
|
||||
|
||||
@@ -4,11 +4,18 @@ import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { listPorts, createPort } from '@/lib/services/ports.service';
|
||||
import { createPortSchema } from '@/lib/validators/ports';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { errorResponse, ForbiddenError } from '@/lib/errors';
|
||||
|
||||
// Listing every tenant and creating new tenants are super-admin operations:
|
||||
// a port director must not be able to enumerate other ports (target
|
||||
// discovery for cross-tenant attacks) or spin up new tenants whose admin
|
||||
// they implicitly become.
|
||||
export const GET = withAuth(
|
||||
withPermission('admin', 'manage_settings', async () => {
|
||||
withPermission('admin', 'manage_settings', async (_req, ctx) => {
|
||||
try {
|
||||
if (!ctx.isSuperAdmin) {
|
||||
throw new ForbiddenError('Listing all ports requires super-admin');
|
||||
}
|
||||
const data = await listPorts();
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
@@ -20,6 +27,9 @@ export const GET = withAuth(
|
||||
export const POST = withAuth(
|
||||
withPermission('admin', 'manage_settings', async (req, ctx) => {
|
||||
try {
|
||||
if (!ctx.isSuperAdmin) {
|
||||
throw new ForbiddenError('Creating ports requires super-admin');
|
||||
}
|
||||
const body = await parseBody(req, createPortSchema);
|
||||
const data = await createPort(body, {
|
||||
userId: ctx.userId,
|
||||
|
||||
@@ -4,14 +4,17 @@ import { withAuth } from '@/lib/api/helpers';
|
||||
import { getEmailDraftResult } from '@/lib/services/email-draft.service';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
|
||||
export const GET = withAuth(async (_req, _ctx, params) => {
|
||||
export const GET = withAuth(async (_req, ctx, params) => {
|
||||
try {
|
||||
const { jobId } = params;
|
||||
if (!jobId) {
|
||||
return NextResponse.json({ error: 'jobId is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await getEmailDraftResult(jobId);
|
||||
const result = await getEmailDraftResult(jobId, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
});
|
||||
|
||||
if (result === null) {
|
||||
return NextResponse.json({ status: 'processing' });
|
||||
|
||||
11
src/app/api/v1/alerts/[id]/acknowledge/route.ts
Normal file
11
src/app/api/v1/alerts/[id]/acknowledge/route.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth } from '@/lib/api/helpers';
|
||||
import { acknowledgeAlert } from '@/lib/services/alerts.service';
|
||||
|
||||
export const POST = withAuth(async (_req, ctx, params) => {
|
||||
const id = params.id;
|
||||
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 });
|
||||
await acknowledgeAlert(id, ctx.portId, ctx.userId);
|
||||
return NextResponse.json({ ok: true });
|
||||
});
|
||||
11
src/app/api/v1/alerts/[id]/dismiss/route.ts
Normal file
11
src/app/api/v1/alerts/[id]/dismiss/route.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth } from '@/lib/api/helpers';
|
||||
import { dismissAlert } from '@/lib/services/alerts.service';
|
||||
|
||||
export const POST = withAuth(async (_req, ctx, params) => {
|
||||
const id = params.id;
|
||||
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 });
|
||||
await dismissAlert(id, ctx.portId, ctx.userId);
|
||||
return NextResponse.json({ ok: true });
|
||||
});
|
||||
24
src/app/api/v1/alerts/count/route.ts
Normal file
24
src/app/api/v1/alerts/count/route.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { and, eq, isNull, sql } from 'drizzle-orm';
|
||||
|
||||
import { withAuth } from '@/lib/api/helpers';
|
||||
import { db } from '@/lib/db';
|
||||
import { alerts } from '@/lib/db/schema/insights';
|
||||
|
||||
export const GET = withAuth(async (_req, ctx) => {
|
||||
const rows = await db
|
||||
.select({ severity: alerts.severity, count: sql<number>`count(*)::int` })
|
||||
.from(alerts)
|
||||
.where(
|
||||
and(eq(alerts.portId, ctx.portId), isNull(alerts.resolvedAt), isNull(alerts.dismissedAt)),
|
||||
)
|
||||
.groupBy(alerts.severity);
|
||||
|
||||
const bySeverity = { info: 0, warning: 0, critical: 0 } as Record<string, number>;
|
||||
let total = 0;
|
||||
for (const r of rows) {
|
||||
bySeverity[r.severity] = r.count;
|
||||
total += r.count;
|
||||
}
|
||||
return NextResponse.json({ total, bySeverity });
|
||||
});
|
||||
26
src/app/api/v1/alerts/route.ts
Normal file
26
src/app/api/v1/alerts/route.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth } from '@/lib/api/helpers';
|
||||
import { listAlertsForPort } from '@/lib/services/alerts.service';
|
||||
|
||||
type AlertStatus = 'open' | 'dismissed' | 'resolved';
|
||||
|
||||
export const GET = withAuth(async (req: NextRequest, ctx) => {
|
||||
const url = new URL(req.url);
|
||||
const status = (url.searchParams.get('status') ?? 'open') as AlertStatus;
|
||||
|
||||
const rows = await listAlertsForPort(ctx.portId, {
|
||||
includeDismissed: status !== 'open',
|
||||
includeResolved: status !== 'open',
|
||||
});
|
||||
|
||||
// Filter to the requested status bucket so callers don't see overlap.
|
||||
const filtered = rows.filter((a) => {
|
||||
if (status === 'open') return !a.dismissedAt && !a.resolvedAt;
|
||||
if (status === 'dismissed') return Boolean(a.dismissedAt) && !a.resolvedAt;
|
||||
if (status === 'resolved') return Boolean(a.resolvedAt);
|
||||
return true;
|
||||
});
|
||||
|
||||
return NextResponse.json({ data: filtered });
|
||||
});
|
||||
37
src/app/api/v1/analytics/route.ts
Normal file
37
src/app/api/v1/analytics/route.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import {
|
||||
ALL_RANGES,
|
||||
getLeadSourceAttribution,
|
||||
getOccupancyTimeline,
|
||||
getPipelineFunnel,
|
||||
getRevenueBreakdown,
|
||||
type DateRange,
|
||||
type MetricBase,
|
||||
} from '@/lib/services/analytics.service';
|
||||
|
||||
const METRICS: Record<MetricBase, (portId: string, range: DateRange) => Promise<unknown>> = {
|
||||
pipeline_funnel: getPipelineFunnel,
|
||||
occupancy_timeline: getOccupancyTimeline,
|
||||
revenue_breakdown: getRevenueBreakdown,
|
||||
lead_source_attribution: getLeadSourceAttribution,
|
||||
};
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('reports', 'view_analytics', async (req: NextRequest, ctx) => {
|
||||
const url = new URL(req.url);
|
||||
const metric = url.searchParams.get('metric') as MetricBase | null;
|
||||
const range = (url.searchParams.get('range') ?? '30d') as DateRange;
|
||||
|
||||
if (!metric || !(metric in METRICS)) {
|
||||
return NextResponse.json({ error: 'Invalid or missing metric' }, { status: 400 });
|
||||
}
|
||||
if (!ALL_RANGES.includes(range)) {
|
||||
return NextResponse.json({ error: 'Invalid range' }, { status: 400 });
|
||||
}
|
||||
|
||||
const data = await METRICS[metric](ctx.portId, range);
|
||||
return NextResponse.json({ metric, range, data });
|
||||
}),
|
||||
);
|
||||
@@ -8,7 +8,7 @@ import { reorderWaitingListSchema } from '@/lib/validators/interests';
|
||||
import { getWaitingList, updateWaitingList } from '@/lib/services/berths.service';
|
||||
import { errorResponse, NotFoundError } from '@/lib/errors';
|
||||
import { db } from '@/lib/db';
|
||||
import { berthWaitingList } from '@/lib/db/schema/berths';
|
||||
import { berths, berthWaitingList } from '@/lib/db/schema/berths';
|
||||
|
||||
// GET /api/v1/berths/[id]/waiting-list
|
||||
export const GET = withAuth(
|
||||
@@ -47,11 +47,17 @@ export const PATCH = withAuth(
|
||||
const body = await parseBody(req, reorderWaitingListSchema);
|
||||
const berthId = params.id!;
|
||||
|
||||
// Tenant scope: refuse to reorder a foreign-port berth's waiting
|
||||
// list. The route's URL id and the entry id are otherwise enough
|
||||
// for any user with manage_waiting_list to mutate any tenant's
|
||||
// queue ordering.
|
||||
const berthRow = await db.query.berths.findFirst({
|
||||
where: and(eq(berths.id, berthId), eq(berths.portId, ctx.portId)),
|
||||
});
|
||||
if (!berthRow) throw new NotFoundError('Berth');
|
||||
|
||||
const entry = await db.query.berthWaitingList.findFirst({
|
||||
where: and(
|
||||
eq(berthWaitingList.id, body.entryId),
|
||||
eq(berthWaitingList.berthId, berthId),
|
||||
),
|
||||
where: and(eq(berthWaitingList.id, body.entryId), eq(berthWaitingList.berthId, berthId)),
|
||||
});
|
||||
if (!entry) throw new NotFoundError('Waiting list entry');
|
||||
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth } from '@/lib/api/helpers';
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { getBerthOptions } from '@/lib/services/berths.service';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
|
||||
// GET /api/v1/berths/options — lightweight list for selects/comboboxes
|
||||
export const GET = withAuth(async (req, ctx) => {
|
||||
export const GET = withAuth(
|
||||
withPermission('berths', 'view', async (req, ctx) => {
|
||||
try {
|
||||
const options = await getBerthOptions(ctx.portId);
|
||||
return NextResponse.json({ data: options });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
51
src/app/api/v1/clients/[id]/addresses/[addressId]/route.ts
Normal file
51
src/app/api/v1/clients/[id]/addresses/[addressId]/route.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { updateClientAddress, removeClientAddress } from '@/lib/services/clients.service';
|
||||
import { optionalCountryIsoSchema, optionalSubdivisionIsoSchema } from '@/lib/validators/i18n';
|
||||
|
||||
const updateAddressSchema = z.object({
|
||||
label: z.string().min(1).max(80).optional(),
|
||||
streetAddress: z.string().max(500).optional().nullable(),
|
||||
city: z.string().max(120).optional().nullable(),
|
||||
subdivisionIso: optionalSubdivisionIsoSchema.optional(),
|
||||
postalCode: z.string().max(40).optional().nullable(),
|
||||
countryIso: optionalCountryIsoSchema.optional(),
|
||||
isPrimary: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const PATCH = withAuth(
|
||||
withPermission('clients', 'edit', async (req, ctx, params) => {
|
||||
try {
|
||||
const body = await parseBody(req, updateAddressSchema);
|
||||
const row = await updateClientAddress(params.addressId!, params.id!, ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: row });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const DELETE = withAuth(
|
||||
withPermission('clients', 'edit', async (req, ctx, params) => {
|
||||
try {
|
||||
await removeClientAddress(params.addressId!, 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
46
src/app/api/v1/clients/[id]/addresses/route.ts
Normal file
46
src/app/api/v1/clients/[id]/addresses/route.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { listClientAddresses, addClientAddress } from '@/lib/services/clients.service';
|
||||
import { optionalCountryIsoSchema, optionalSubdivisionIsoSchema } from '@/lib/validators/i18n';
|
||||
|
||||
const addAddressSchema = z.object({
|
||||
label: z.string().min(1).max(80).optional(),
|
||||
streetAddress: z.string().max(500).optional().nullable(),
|
||||
city: z.string().max(120).optional().nullable(),
|
||||
subdivisionIso: optionalSubdivisionIsoSchema.optional(),
|
||||
postalCode: z.string().max(40).optional().nullable(),
|
||||
countryIso: optionalCountryIsoSchema.optional(),
|
||||
isPrimary: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('clients', 'view', async (req, ctx, params) => {
|
||||
try {
|
||||
const rows = await listClientAddresses(params.id!, ctx.portId);
|
||||
return NextResponse.json({ data: rows });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const POST = withAuth(
|
||||
withPermission('clients', 'edit', async (req, ctx, params) => {
|
||||
try {
|
||||
const body = await parseBody(req, addAddressSchema);
|
||||
const row = await addClientAddress(params.id!, ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: row }, { status: 201 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -5,10 +5,13 @@ import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { updateContact, removeContact } from '@/lib/services/clients.service';
|
||||
import { optionalCountryIsoSchema, optionalPhoneE164Schema } from '@/lib/validators/i18n';
|
||||
|
||||
const updateContactSchema = z.object({
|
||||
channel: z.enum(['email', 'phone', 'whatsapp', 'other']).optional(),
|
||||
value: z.string().min(1).optional(),
|
||||
valueE164: optionalPhoneE164Schema.optional(),
|
||||
valueCountry: optionalCountryIsoSchema.optional(),
|
||||
label: z.string().optional(),
|
||||
isPrimary: z.boolean().optional(),
|
||||
notes: z.string().optional(),
|
||||
@@ -18,18 +21,12 @@ export const PATCH = withAuth(
|
||||
withPermission('clients', 'edit', async (req, ctx, params) => {
|
||||
try {
|
||||
const body = await parseBody(req, updateContactSchema);
|
||||
const contact = await updateContact(
|
||||
params.contactId!,
|
||||
params.id!,
|
||||
ctx.portId,
|
||||
body,
|
||||
{
|
||||
const contact = await updateContact(params.contactId!, params.id!, ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
);
|
||||
});
|
||||
return NextResponse.json({ data: contact });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
|
||||
@@ -5,10 +5,13 @@ import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { listContacts, addContact } from '@/lib/services/clients.service';
|
||||
import { optionalCountryIsoSchema, optionalPhoneE164Schema } from '@/lib/validators/i18n';
|
||||
|
||||
const addContactSchema = z.object({
|
||||
channel: z.enum(['email', 'phone', 'whatsapp', 'other']),
|
||||
value: z.string().min(1),
|
||||
valueE164: optionalPhoneE164Schema.optional(),
|
||||
valueCountry: optionalCountryIsoSchema.optional(),
|
||||
label: z.string().optional(),
|
||||
isPrimary: z.boolean().optional().default(false),
|
||||
notes: z.string().optional(),
|
||||
|
||||
24
src/app/api/v1/clients/[id]/gdpr-export/[exportId]/route.ts
Normal file
24
src/app/api/v1/clients/[id]/gdpr-export/[exportId]/route.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission, withRateLimit } from '@/lib/api/helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { getExportDownloadUrl } from '@/lib/services/gdpr-export.service';
|
||||
|
||||
/**
|
||||
* Returns a fresh signed URL for an existing GDPR export. Staff use this
|
||||
* from the admin UI; the email path embeds its own signed URL.
|
||||
*/
|
||||
export const GET = withAuth(
|
||||
withPermission(
|
||||
'admin',
|
||||
'manage_settings',
|
||||
withRateLimit('exports', async (req, ctx, params) => {
|
||||
try {
|
||||
const url = await getExportDownloadUrl(params.exportId!, ctx.portId);
|
||||
return NextResponse.json({ data: { url } });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
49
src/app/api/v1/clients/[id]/gdpr-export/route.ts
Normal file
49
src/app/api/v1/clients/[id]/gdpr-export/route.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission, withRateLimit } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { requestGdprExport, listClientExports } from '@/lib/services/gdpr-export.service';
|
||||
|
||||
const requestSchema = z.object({
|
||||
/** When true, the bundle is emailed to the client once it finishes building. */
|
||||
emailToClient: z.boolean().optional().default(false),
|
||||
/** Optional override recipient (e.g. legal counsel). Skips the primary-email lookup. */
|
||||
emailOverride: z.string().email().optional().nullable(),
|
||||
});
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('clients', 'view', async (req, ctx, params) => {
|
||||
try {
|
||||
const rows = await listClientExports(params.id!, ctx.portId);
|
||||
return NextResponse.json({ data: rows });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const POST = withAuth(
|
||||
withPermission(
|
||||
'admin',
|
||||
'manage_settings',
|
||||
withRateLimit('exports', async (req, ctx, params) => {
|
||||
try {
|
||||
const body = await parseBody(req, requestSchema);
|
||||
const result = await requestGdprExport({
|
||||
clientId: params.id!,
|
||||
portId: ctx.portId,
|
||||
requestedBy: ctx.userId,
|
||||
emailToClient: body.emailToClient,
|
||||
emailOverride: body.emailOverride ?? null,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: result.export }, { status: 202 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -1,10 +1,11 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth } from '@/lib/api/helpers';
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { listClientOptions } from '@/lib/services/clients.service';
|
||||
|
||||
export const GET = withAuth(async (req, ctx) => {
|
||||
export const GET = withAuth(
|
||||
withPermission('clients', 'view', async (req, ctx) => {
|
||||
try {
|
||||
const search = req.nextUrl.searchParams.get('search') ?? undefined;
|
||||
const data = await listClientOptions(ctx.portId, search);
|
||||
@@ -12,4 +13,5 @@ export const GET = withAuth(async (req, ctx) => {
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
51
src/app/api/v1/companies/[id]/addresses/[addressId]/route.ts
Normal file
51
src/app/api/v1/companies/[id]/addresses/[addressId]/route.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { updateCompanyAddress, removeCompanyAddress } from '@/lib/services/companies.service';
|
||||
import { optionalCountryIsoSchema, optionalSubdivisionIsoSchema } from '@/lib/validators/i18n';
|
||||
|
||||
const updateAddressSchema = z.object({
|
||||
label: z.string().min(1).max(80).optional(),
|
||||
streetAddress: z.string().max(500).optional().nullable(),
|
||||
city: z.string().max(120).optional().nullable(),
|
||||
subdivisionIso: optionalSubdivisionIsoSchema.optional(),
|
||||
postalCode: z.string().max(40).optional().nullable(),
|
||||
countryIso: optionalCountryIsoSchema.optional(),
|
||||
isPrimary: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const PATCH = withAuth(
|
||||
withPermission('companies', 'edit', async (req, ctx, params) => {
|
||||
try {
|
||||
const body = await parseBody(req, updateAddressSchema);
|
||||
const row = await updateCompanyAddress(params.addressId!, params.id!, ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: row });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const DELETE = withAuth(
|
||||
withPermission('companies', 'edit', async (req, ctx, params) => {
|
||||
try {
|
||||
await removeCompanyAddress(params.addressId!, 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
46
src/app/api/v1/companies/[id]/addresses/route.ts
Normal file
46
src/app/api/v1/companies/[id]/addresses/route.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { listCompanyAddresses, addCompanyAddress } from '@/lib/services/companies.service';
|
||||
import { optionalCountryIsoSchema, optionalSubdivisionIsoSchema } from '@/lib/validators/i18n';
|
||||
|
||||
const addAddressSchema = z.object({
|
||||
label: z.string().min(1).max(80).optional(),
|
||||
streetAddress: z.string().max(500).optional().nullable(),
|
||||
city: z.string().max(120).optional().nullable(),
|
||||
subdivisionIso: optionalSubdivisionIsoSchema.optional(),
|
||||
postalCode: z.string().max(40).optional().nullable(),
|
||||
countryIso: optionalCountryIsoSchema.optional(),
|
||||
isPrimary: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('companies', 'view', async (req, ctx, params) => {
|
||||
try {
|
||||
const rows = await listCompanyAddresses(params.id!, ctx.portId);
|
||||
return NextResponse.json({ data: rows });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const POST = withAuth(
|
||||
withPermission('companies', 'edit', async (req, ctx, params) => {
|
||||
try {
|
||||
const body = await parseBody(req, addAddressSchema);
|
||||
const row = await addCompanyAddress(params.id!, ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: row }, { status: 201 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -1,11 +1,18 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth } from '@/lib/api/helpers';
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { errorResponse, NotFoundError } from '@/lib/errors';
|
||||
import { setValuesSchema } from '@/lib/validators/custom-fields';
|
||||
import { getValues, setValues } from '@/lib/services/custom-fields.service';
|
||||
|
||||
export const GET = withAuth(async (_req: NextRequest, ctx, params) => {
|
||||
// Custom-field values live on top of a port-scoped entity (client, yacht,
|
||||
// interest, berth, company). Reading the values is in scope for any role
|
||||
// that can view clients (the most common surface); writing requires the
|
||||
// equivalent edit permission. The service-layer also re-validates the
|
||||
// entityId against the field definition's entityType + portId so a
|
||||
// caller cannot poke values onto an arbitrary or foreign-port entity.
|
||||
export const GET = withAuth(
|
||||
withPermission('clients', 'view', async (_req: NextRequest, ctx, params) => {
|
||||
try {
|
||||
const { entityId } = params;
|
||||
if (!entityId) throw new NotFoundError('Entity');
|
||||
@@ -15,9 +22,11 @@ export const GET = withAuth(async (_req: NextRequest, ctx, params) => {
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
export const PUT = withAuth(async (req: NextRequest, ctx, params) => {
|
||||
export const PUT = withAuth(
|
||||
withPermission('clients', 'edit', async (req: NextRequest, ctx, params) => {
|
||||
try {
|
||||
const { entityId } = params;
|
||||
if (!entityId) throw new NotFoundError('Entity');
|
||||
@@ -42,4 +51,5 @@ export const PUT = withAuth(async (req: NextRequest, ctx, params) => {
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth } from '@/lib/api/helpers';
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { getRecentActivity } from '@/lib/services/dashboard.service';
|
||||
|
||||
export const GET = withAuth(async (req: NextRequest, ctx) => {
|
||||
export const GET = withAuth(
|
||||
withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => {
|
||||
const result = await getRecentActivity(ctx.portId);
|
||||
return NextResponse.json(result);
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth } from '@/lib/api/helpers';
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { getRevenueForecast } from '@/lib/services/dashboard.service';
|
||||
|
||||
export const GET = withAuth(async (req: NextRequest, ctx) => {
|
||||
export const GET = withAuth(
|
||||
withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => {
|
||||
const result = await getRevenueForecast(ctx.portId);
|
||||
return NextResponse.json(result);
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth } from '@/lib/api/helpers';
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { getKpis } from '@/lib/services/dashboard.service';
|
||||
|
||||
export const GET = withAuth(async (req: NextRequest, ctx) => {
|
||||
export const GET = withAuth(
|
||||
withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => {
|
||||
const result = await getKpis(ctx.portId);
|
||||
return NextResponse.json(result);
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth } from '@/lib/api/helpers';
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { getPipelineCounts } from '@/lib/services/dashboard.service';
|
||||
|
||||
export const GET = withAuth(async (req: NextRequest, ctx) => {
|
||||
export const GET = withAuth(
|
||||
withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => {
|
||||
const result = await getPipelineCounts(ctx.portId);
|
||||
return NextResponse.json(result);
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,14 +1,32 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { db } from '@/lib/db';
|
||||
import { emailAccounts } from '@/lib/db/schema/email';
|
||||
import { errorResponse, ForbiddenError, NotFoundError } from '@/lib/errors';
|
||||
import { getQueue } from '@/lib/queue';
|
||||
|
||||
export const POST = withAuth(
|
||||
withPermission('email', 'view', async (_req, _ctx, params) => {
|
||||
withPermission('email', 'view', async (_req, ctx, params) => {
|
||||
try {
|
||||
const accountId = params.accountId!;
|
||||
// Owner check: the sibling toggle/disconnect endpoints already enforce
|
||||
// account.userId === ctx.userId. Without the same check here, any
|
||||
// user with `email:view` could force IMAP sync against a foreign
|
||||
// account, advancing lastSyncAt (data-loss risk on the legitimate
|
||||
// owner's next sync) and triggering work using the foreign user's
|
||||
// decrypted credentials.
|
||||
const account = await db.query.emailAccounts.findFirst({
|
||||
where: eq(emailAccounts.id, accountId),
|
||||
});
|
||||
if (!account) throw new NotFoundError('Email account');
|
||||
if (account.userId !== ctx.userId) {
|
||||
throw new ForbiddenError('You do not own this email account');
|
||||
}
|
||||
|
||||
const queue = getQueue('email');
|
||||
const job = await queue.add('inbox-sync', { accountId: params.accountId! });
|
||||
const job = await queue.add('inbox-sync', { accountId });
|
||||
return NextResponse.json({ data: { jobId: job.id } }, { status: 202 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
|
||||
18
src/app/api/v1/expenses/[id]/clear-duplicate/route.ts
Normal file
18
src/app/api/v1/expenses/[id]/clear-duplicate/route.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { clearDuplicate } from '@/lib/services/expense-dedup.service';
|
||||
|
||||
export const POST = withAuth(
|
||||
withPermission('expenses', 'edit', async (_req, ctx, params) => {
|
||||
try {
|
||||
const id = params.id;
|
||||
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 });
|
||||
await clearDuplicate(id, ctx.portId);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
28
src/app/api/v1/expenses/[id]/merge/route.ts
Normal file
28
src/app/api/v1/expenses/[id]/merge/route.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { mergeDuplicate } from '@/lib/services/expense-dedup.service';
|
||||
|
||||
const mergeSchema = z.object({
|
||||
/** Surviving expense id — typically the row's existing `duplicateOf` pointer. */
|
||||
targetId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const POST = withAuth(
|
||||
withPermission('expenses', 'edit', async (req, ctx, params) => {
|
||||
try {
|
||||
const sourceId = params.id;
|
||||
if (!sourceId) {
|
||||
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
|
||||
}
|
||||
const body = await parseBody(req, mergeSchema);
|
||||
await mergeDuplicate(sourceId, body.targetId, ctx.portId);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -1,27 +1,117 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { withAuth, withPermission, withRateLimit } from '@/lib/api/helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { scanReceipt } from '@/lib/services/receipt-scanner';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getResolvedOcrConfig } from '@/lib/services/ocr-config.service';
|
||||
import {
|
||||
runOcr,
|
||||
type ParsedReceipt,
|
||||
OCR_FEATURE,
|
||||
OCR_ESTIMATED_TOKENS,
|
||||
} from '@/lib/services/ocr-providers';
|
||||
import { checkBudget, recordAiUsage } from '@/lib/services/ai-budget.service';
|
||||
|
||||
const EMPTY: ParsedReceipt = {
|
||||
establishment: null,
|
||||
date: null,
|
||||
amount: null,
|
||||
currency: null,
|
||||
lineItems: [],
|
||||
confidence: 0,
|
||||
};
|
||||
|
||||
export const POST = withAuth(
|
||||
withPermission('expenses', 'create', async (req, _ctx) => {
|
||||
withPermission(
|
||||
'expenses',
|
||||
'create',
|
||||
withRateLimit('ocr', async (req, ctx) => {
|
||||
try {
|
||||
const formData = await req.formData();
|
||||
const file = formData.get('file') as File | null;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const mimeType = file.type || 'image/jpeg';
|
||||
|
||||
const result = await scanReceipt(buffer, mimeType);
|
||||
const config = await getResolvedOcrConfig(ctx.portId);
|
||||
// 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
|
||||
// local Tesseract result.
|
||||
if (!config.aiEnabled) {
|
||||
return NextResponse.json({
|
||||
data: { parsed: EMPTY, source: 'manual', reason: 'ai-disabled' },
|
||||
});
|
||||
}
|
||||
if (!config.apiKey) {
|
||||
return NextResponse.json({
|
||||
data: { parsed: EMPTY, source: 'manual', reason: 'no-ocr-configured' },
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: result });
|
||||
// Per-port budget gate — refuse the call before we spend tokens
|
||||
// when the port has already hit its hard cap, or when the request
|
||||
// would push it past the cap. Soft-cap warnings ride along on the
|
||||
// success response so the UI can show a banner without blocking.
|
||||
const budget = await checkBudget({
|
||||
portId: ctx.portId,
|
||||
estimatedTokens: OCR_ESTIMATED_TOKENS,
|
||||
});
|
||||
if (!budget.ok) {
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
parsed: EMPTY,
|
||||
source: 'manual',
|
||||
reason: 'budget-exceeded',
|
||||
providerError: `AI budget reached (${budget.usedTokens}/${budget.capTokens} tokens this period).`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await runOcr({
|
||||
provider: config.provider,
|
||||
model: config.model,
|
||||
apiKey: config.apiKey,
|
||||
imageBuffer: buffer,
|
||||
mimeType,
|
||||
});
|
||||
await recordAiUsage({
|
||||
portId: ctx.portId,
|
||||
userId: ctx.userId,
|
||||
feature: OCR_FEATURE,
|
||||
provider: config.provider,
|
||||
model: config.model,
|
||||
inputTokens: result.usage.inputTokens,
|
||||
outputTokens: result.usage.outputTokens,
|
||||
requestId: result.usage.requestId,
|
||||
});
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
parsed: result.parsed,
|
||||
source: 'ai',
|
||||
provider: config.provider,
|
||||
model: config.model,
|
||||
softCapWarning: budget.softCap,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ err, provider: config.provider }, 'OCR provider call failed');
|
||||
// Provider hiccup — degrade to manual entry rather than 500-ing.
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
parsed: EMPTY,
|
||||
source: 'manual',
|
||||
reason: 'provider-error',
|
||||
providerError: err instanceof Error ? err.message.slice(0, 200) : 'Unknown error',
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
195
src/components/admin/ai-budget-card.tsx
Normal file
195
src/components/admin/ai-budget-card.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
type Period = 'day' | 'week' | 'month';
|
||||
|
||||
interface BudgetResp {
|
||||
data: {
|
||||
budget: { enabled: boolean; softCapTokens: number; hardCapTokens: number; period: Period };
|
||||
used: number;
|
||||
breakdown: Array<{ feature: string; tokens: number; calls: number }>;
|
||||
};
|
||||
}
|
||||
|
||||
function formatNum(n: number): string {
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
export function AiBudgetCard() {
|
||||
const qc = useQueryClient();
|
||||
const queryKey = ['admin-ai-budget'];
|
||||
|
||||
const { data, isLoading } = useQuery<BudgetResp>({
|
||||
queryKey,
|
||||
queryFn: () => apiFetch<BudgetResp>('/api/v1/admin/ai-budget'),
|
||||
});
|
||||
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [softCap, setSoftCap] = useState('100000');
|
||||
const [hardCap, setHardCap] = useState('500000');
|
||||
const [period, setPeriod] = useState<Period>('month');
|
||||
|
||||
useEffect(() => {
|
||||
if (!data?.data) return;
|
||||
setEnabled(data.data.budget.enabled);
|
||||
setSoftCap(String(data.data.budget.softCapTokens));
|
||||
setHardCap(String(data.data.budget.hardCapTokens));
|
||||
setPeriod(data.data.budget.period);
|
||||
}, [data?.data]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch('/api/v1/admin/ai-budget', {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
enabled,
|
||||
softCapTokens: Number.parseInt(softCap || '0', 10),
|
||||
hardCapTokens: Number.parseInt(hardCap || '0', 10),
|
||||
period,
|
||||
},
|
||||
}),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey }),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>AI cost guardrails</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Loading…
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const used = data?.data.used ?? 0;
|
||||
const hard = data?.data.budget.hardCapTokens ?? 0;
|
||||
const soft = data?.data.budget.softCapTokens ?? 0;
|
||||
const pctOfHard = hard > 0 ? Math.min(100, Math.round((used / hard) * 100)) : 0;
|
||||
const breakdown = data?.data.breakdown ?? [];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>AI cost guardrails</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Cap how many AI tokens this port can spend per period. The hard cap blocks new calls; the
|
||||
soft cap surfaces a warning banner. Tokens are the unit both OpenAI and Anthropic bill on,
|
||||
so the cap survives model price changes.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="rounded-lg border bg-muted/30 p-3 space-y-2">
|
||||
<div className="flex items-baseline justify-between text-sm">
|
||||
<span className="font-medium">
|
||||
This {period}: {formatNum(used)} tokens
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
soft {formatNum(soft)} · hard {formatNum(hard)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all ${
|
||||
used >= hard ? 'bg-destructive' : used >= soft ? 'bg-amber-500' : 'bg-emerald-500'
|
||||
}`}
|
||||
style={{ width: `${pctOfHard}%` }}
|
||||
/>
|
||||
</div>
|
||||
{breakdown.length > 0 ? (
|
||||
<ul className="text-xs text-muted-foreground space-y-0.5 pt-1">
|
||||
{breakdown.map((b) => (
|
||||
<li key={b.feature} className="flex justify-between">
|
||||
<span className="capitalize">{b.feature.replace(/_/g, ' ')}</span>
|
||||
<span>
|
||||
{formatNum(b.tokens)} tokens · {b.calls} call{b.calls === 1 ? '' : 's'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2 rounded-lg border border-border bg-muted/30 p-3">
|
||||
<Checkbox
|
||||
id="ai-budget-enabled"
|
||||
checked={enabled}
|
||||
onCheckedChange={(v) => setEnabled(v === true)}
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="ai-budget-enabled" className="text-sm font-medium">
|
||||
Enforce token caps for this port
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When off, usage is still recorded for visibility but no requests are blocked.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="period">Period</Label>
|
||||
<Select value={period} onValueChange={(v) => setPeriod(v as Period)}>
|
||||
<SelectTrigger id="period">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="day">Day (UTC)</SelectItem>
|
||||
<SelectItem value="week">Week (Mon–Sun UTC)</SelectItem>
|
||||
<SelectItem value="month">Calendar month (UTC)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="soft-cap">Soft cap (tokens)</Label>
|
||||
<Input
|
||||
id="soft-cap"
|
||||
type="number"
|
||||
min="0"
|
||||
value={softCap}
|
||||
onChange={(e) => setSoftCap(e.target.value)}
|
||||
disabled={!enabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="hard-cap">Hard cap (tokens)</Label>
|
||||
<Input
|
||||
id="hard-cap"
|
||||
type="number"
|
||||
min="0"
|
||||
value={hardCap}
|
||||
onChange={(e) => setHardCap(e.target.value)}
|
||||
disabled={!enabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => save.mutate()} disabled={save.isPending}>
|
||||
{save.isPending ? <Loader2 className="mr-1.5 h-3 w-3 animate-spin" /> : null}
|
||||
Save guardrails
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { type ColumnDef } from '@tanstack/react-table';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { Search } from 'lucide-react';
|
||||
import { Search, X } from 'lucide-react';
|
||||
|
||||
import { DataTable } from '@/components/shared/data-table';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -23,13 +25,19 @@ interface AuditEntry {
|
||||
userId: string | null;
|
||||
action: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
entityId: string | null;
|
||||
fieldChanged: string | null;
|
||||
oldValue: Record<string, unknown> | null;
|
||||
newValue: Record<string, unknown> | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
ipAddress: string | null;
|
||||
createdAt: string;
|
||||
actor: { id: string; email: string; name: string } | null;
|
||||
}
|
||||
|
||||
interface AuditResponse {
|
||||
data: AuditEntry[];
|
||||
pagination: { nextCursor: { createdAt: string; id: string } | null };
|
||||
}
|
||||
|
||||
const ACTION_COLORS: Record<string, string> = {
|
||||
@@ -40,6 +48,8 @@ const ACTION_COLORS: Record<string, string> = {
|
||||
restore: 'bg-teal-500',
|
||||
login: 'bg-gray-500',
|
||||
permission_denied: 'bg-red-800',
|
||||
merge: 'bg-purple-500',
|
||||
revert: 'bg-amber-500',
|
||||
};
|
||||
|
||||
const ENTITY_TYPES = [
|
||||
@@ -58,40 +68,96 @@ const ENTITY_TYPES = [
|
||||
'webhook',
|
||||
];
|
||||
|
||||
function useDebounced<T>(value: T, ms = 300): T {
|
||||
const [v, setV] = useState(value);
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setV(value), ms);
|
||||
return () => clearTimeout(t);
|
||||
}, [value, ms]);
|
||||
return v;
|
||||
}
|
||||
|
||||
export function AuditLogList() {
|
||||
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<{
|
||||
createdAt: string;
|
||||
id: string;
|
||||
} | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [entityTypeFilter, setEntityTypeFilter] = useState<string>('all');
|
||||
const [actionFilter, setActionFilter] = useState<string>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
|
||||
const fetchLogs = useCallback(async () => {
|
||||
// Filter state — debounce text inputs.
|
||||
const [search, setSearch] = useState('');
|
||||
const [entityType, setEntityType] = useState<string>('all');
|
||||
const [action, setAction] = useState<string>('all');
|
||||
const [userId, setUserId] = useState('');
|
||||
const [dateFrom, setDateFrom] = useState('');
|
||||
const [dateTo, setDateTo] = useState('');
|
||||
|
||||
const debouncedSearch = useDebounced(search);
|
||||
const debouncedUserId = useDebounced(userId);
|
||||
|
||||
const queryString = useMemo(() => {
|
||||
const params = new URLSearchParams({ limit: '50' });
|
||||
if (entityType !== 'all') params.set('entityType', entityType);
|
||||
if (action !== 'all') params.set('action', action);
|
||||
if (debouncedSearch) params.set('search', debouncedSearch);
|
||||
if (debouncedUserId) params.set('userId', debouncedUserId);
|
||||
if (dateFrom) params.set('dateFrom', new Date(dateFrom).toISOString());
|
||||
if (dateTo) {
|
||||
const end = new Date(dateTo);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
params.set('dateTo', end.toISOString());
|
||||
}
|
||||
return params.toString();
|
||||
}, [entityType, action, debouncedSearch, debouncedUserId, dateFrom, dateTo]);
|
||||
|
||||
const fetchFirstPage = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
limit: '50',
|
||||
});
|
||||
if (entityTypeFilter !== 'all') params.set('entityType', entityTypeFilter);
|
||||
if (actionFilter !== 'all') params.set('action', actionFilter);
|
||||
if (search) params.set('search', search);
|
||||
|
||||
const res = await apiFetch<{
|
||||
data: AuditEntry[];
|
||||
pagination: { total: number };
|
||||
}>(`/api/v1/admin/audit?${params}`);
|
||||
const res = await apiFetch<AuditResponse>(`/api/v1/admin/audit?${queryString}`);
|
||||
setEntries(res.data);
|
||||
setTotal(res.pagination.total);
|
||||
setNextCursor(res.pagination.nextCursor);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, entityTypeFilter, actionFilter, search]);
|
||||
}, [queryString]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (!nextCursor) return;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const params = new URLSearchParams(queryString);
|
||||
params.set('cursorAt', nextCursor.createdAt);
|
||||
params.set('cursorId', nextCursor.id);
|
||||
const res = await apiFetch<AuditResponse>(`/api/v1/admin/audit?${params}`);
|
||||
setEntries((prev) => [...prev, ...res.data]);
|
||||
setNextCursor(res.pagination.nextCursor);
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [queryString, nextCursor]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchLogs();
|
||||
}, [fetchLogs]);
|
||||
void fetchFirstPage();
|
||||
}, [fetchFirstPage]);
|
||||
|
||||
function clearFilters() {
|
||||
setSearch('');
|
||||
setEntityType('all');
|
||||
setAction('all');
|
||||
setUserId('');
|
||||
setDateFrom('');
|
||||
setDateTo('');
|
||||
}
|
||||
|
||||
const hasActiveFilter =
|
||||
Boolean(search) ||
|
||||
entityType !== 'all' ||
|
||||
action !== 'all' ||
|
||||
Boolean(userId) ||
|
||||
Boolean(dateFrom) ||
|
||||
Boolean(dateTo);
|
||||
|
||||
const columns: ColumnDef<AuditEntry, unknown>[] = [
|
||||
{
|
||||
@@ -117,7 +183,7 @@ export function AuditLogList() {
|
||||
{row.original.action}
|
||||
</Badge>
|
||||
),
|
||||
size: 100,
|
||||
size: 110,
|
||||
},
|
||||
{
|
||||
accessorKey: 'entityType',
|
||||
@@ -125,9 +191,11 @@ export function AuditLogList() {
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<span className="font-medium capitalize">{row.original.entityType}</span>
|
||||
{row.original.entityId ? (
|
||||
<code className="ml-2 text-xs text-muted-foreground">
|
||||
{row.original.entityId.slice(0, 8)}...
|
||||
{row.original.entityId.slice(0, 8)}…
|
||||
</code>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -150,46 +218,62 @@ export function AuditLogList() {
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'userId',
|
||||
header: 'User',
|
||||
cell: ({ row }) => (
|
||||
<code className="text-xs">
|
||||
{row.original.userId ? row.original.userId.slice(0, 8) + '...' : 'system'}
|
||||
</code>
|
||||
),
|
||||
size: 100,
|
||||
id: 'actor',
|
||||
header: 'Actor',
|
||||
cell: ({ row }) => {
|
||||
const { actor, userId: rawId } = row.original;
|
||||
if (actor) {
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">{actor.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{actor.email}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (rawId) {
|
||||
return <code className="text-xs">{rawId.slice(0, 8)}…</code>;
|
||||
}
|
||||
return <span className="text-xs text-muted-foreground">system</span>;
|
||||
},
|
||||
size: 200,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="Audit Log" description={`${total} entries`} />
|
||||
<PageHeader
|
||||
title="Audit Log"
|
||||
eyebrow="Admin"
|
||||
description="Every state change in this port — fully searchable."
|
||||
variant="gradient"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<div className="mt-4 flex flex-wrap items-end gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="audit-search" className="text-xs">
|
||||
Search
|
||||
</Label>
|
||||
<div className="relative w-72">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="audit-search"
|
||||
className="pl-9"
|
||||
placeholder="Search..."
|
||||
placeholder="entity id, action, vendor…"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
data-testid="audit-search"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={entityTypeFilter}
|
||||
onValueChange={(v) => {
|
||||
setEntityTypeFilter(v);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-36">
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Entity</Label>
|
||||
<Select value={entityType} onValueChange={setEntityType}>
|
||||
<SelectTrigger className="w-36" data-testid="audit-entity">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Entities</SelectItem>
|
||||
<SelectItem value="all">All entities</SelectItem>
|
||||
{ENTITY_TYPES.map((t) => (
|
||||
<SelectItem key={t} value={t}>
|
||||
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||
@@ -197,28 +281,77 @@ export function AuditLogList() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={actionFilter}
|
||||
onValueChange={(v) => {
|
||||
setActionFilter(v);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-36">
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Action</Label>
|
||||
<Select value={action} onValueChange={setAction}>
|
||||
<SelectTrigger className="w-36" data-testid="audit-action">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Actions</SelectItem>
|
||||
<SelectItem value="all">All actions</SelectItem>
|
||||
<SelectItem value="create">Create</SelectItem>
|
||||
<SelectItem value="update">Update</SelectItem>
|
||||
<SelectItem value="delete">Delete</SelectItem>
|
||||
<SelectItem value="archive">Archive</SelectItem>
|
||||
<SelectItem value="restore">Restore</SelectItem>
|
||||
<SelectItem value="permission_denied">Permission Denied</SelectItem>
|
||||
<SelectItem value="merge">Merge</SelectItem>
|
||||
<SelectItem value="revert">Revert</SelectItem>
|
||||
<SelectItem value="login">Login</SelectItem>
|
||||
<SelectItem value="permission_denied">Permission denied</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="audit-user" className="text-xs">
|
||||
User id
|
||||
</Label>
|
||||
<Input
|
||||
id="audit-user"
|
||||
className="w-44"
|
||||
placeholder="exact user id"
|
||||
value={userId}
|
||||
onChange={(e) => setUserId(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="audit-from" className="text-xs">
|
||||
From
|
||||
</Label>
|
||||
<Input
|
||||
id="audit-from"
|
||||
type="date"
|
||||
className="w-36"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="audit-to" className="text-xs">
|
||||
To
|
||||
</Label>
|
||||
<Input
|
||||
id="audit-to"
|
||||
type="date"
|
||||
className="w-36"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasActiveFilter ? (
|
||||
<Button variant="ghost" size="sm" onClick={clearFilters} className="ml-auto">
|
||||
<X className="mr-1.5 h-3 w-3" />
|
||||
Clear
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={entries}
|
||||
@@ -230,28 +363,21 @@ export function AuditLogList() {
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{total > 50 && (
|
||||
<div className="flex items-center justify-center gap-2 mt-4">
|
||||
<button
|
||||
className="text-sm text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Page {page} of {Math.ceil(total / 50)}
|
||||
</span>
|
||||
<button
|
||||
className="text-sm text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||
disabled={page >= Math.ceil(total / 50)}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{nextCursor ? (
|
||||
<div className="mt-4 flex justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={loadingMore}
|
||||
onClick={() => void loadMore()}
|
||||
data-testid="audit-load-more"
|
||||
>
|
||||
{loadingMore ? 'Loading…' : 'Load more'}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
317
src/components/admin/ocr-settings-form.tsx
Normal file
317
src/components/admin/ocr-settings-form.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { CheckCircle2, Eye, EyeOff, Loader2, XCircle } from 'lucide-react';
|
||||
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { usePermissions } from '@/hooks/use-permissions';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { AiBudgetCard } from '@/components/admin/ai-budget-card';
|
||||
|
||||
type Provider = 'openai' | 'claude';
|
||||
|
||||
interface ConfigResp {
|
||||
data: {
|
||||
provider: Provider;
|
||||
model: string;
|
||||
hasApiKey: boolean;
|
||||
useGlobal: boolean;
|
||||
aiEnabled: boolean;
|
||||
};
|
||||
models: Record<Provider, string[]>;
|
||||
}
|
||||
|
||||
type Scope = 'port' | 'global';
|
||||
|
||||
interface SettingsBlockProps {
|
||||
scope: Scope;
|
||||
title: string;
|
||||
description: string;
|
||||
/** Hide the "use global" checkbox on the global tab. */
|
||||
showUseGlobal?: boolean;
|
||||
}
|
||||
|
||||
function SettingsBlock({ scope, title, description, showUseGlobal }: SettingsBlockProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const queryKey = ['ocr-settings', scope];
|
||||
|
||||
const { data, isLoading } = useQuery<ConfigResp>({
|
||||
queryKey,
|
||||
queryFn: () => apiFetch<ConfigResp>(`/api/v1/admin/ocr-settings?scope=${scope}`),
|
||||
});
|
||||
|
||||
const [provider, setProvider] = useState<Provider>('openai');
|
||||
const [model, setModel] = useState<string>('gpt-4o-mini');
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [useGlobal, setUseGlobal] = useState(false);
|
||||
const [aiEnabled, setAiEnabled] = useState(false);
|
||||
const [testStatus, setTestStatus] = useState<null | { ok: true } | { ok: false; reason: string }>(
|
||||
null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data?.data) return;
|
||||
setProvider(data.data.provider);
|
||||
setModel(data.data.model);
|
||||
setUseGlobal(data.data.useGlobal);
|
||||
setAiEnabled(data.data.aiEnabled);
|
||||
}, [data?.data]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (clearApiKey?: boolean) =>
|
||||
apiFetch('/api/v1/admin/ocr-settings', {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
scope,
|
||||
provider,
|
||||
model,
|
||||
apiKey: apiKey.length > 0 ? apiKey : undefined,
|
||||
clearApiKey: Boolean(clearApiKey),
|
||||
useGlobal: scope === 'global' ? false : useGlobal,
|
||||
aiEnabled: scope === 'global' ? false : aiEnabled,
|
||||
},
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setApiKey('');
|
||||
queryClient.invalidateQueries({ queryKey });
|
||||
},
|
||||
});
|
||||
|
||||
const test = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch<{ ok: boolean; reason?: string }>(`/api/v1/admin/ocr-settings/test`, {
|
||||
method: 'POST',
|
||||
body: { provider, model, apiKey },
|
||||
}),
|
||||
onSuccess: (res) =>
|
||||
setTestStatus(res.ok ? { ok: true } : { ok: false, reason: res.reason ?? 'Unknown' }),
|
||||
onError: (err: unknown) =>
|
||||
setTestStatus({
|
||||
ok: false,
|
||||
reason: err instanceof Error ? err.message : 'Network error',
|
||||
}),
|
||||
});
|
||||
|
||||
const models = data?.models[provider] ?? [];
|
||||
const hasKey = data?.data.hasApiKey ?? false;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Loading…
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{showUseGlobal ? (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-border bg-muted/30 p-3">
|
||||
<Checkbox
|
||||
id={`useGlobal-${scope}`}
|
||||
checked={useGlobal}
|
||||
onCheckedChange={(v) => setUseGlobal(v === true)}
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor={`useGlobal-${scope}`} className="text-sm font-medium">
|
||||
Use the global API key for this port
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When enabled, this port falls back to the system-wide OCR settings. Per-port
|
||||
provider/model/key are ignored.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{scope === 'port' ? (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-border bg-muted/30 p-3">
|
||||
<Checkbox
|
||||
id={`aiEnabled-${scope}`}
|
||||
checked={aiEnabled}
|
||||
onCheckedChange={(v) => setAiEnabled(v === true)}
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor={`aiEnabled-${scope}`} className="text-sm font-medium">
|
||||
Enable AI receipt parsing for this port
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Off by default. Receipts are read on-device using Tesseract.js — accurate enough for
|
||||
most receipts and incurs no AI cost. Turning this on lets the configured provider
|
||||
re-parse receipts server-side for higher accuracy on hard-to-read images.
|
||||
</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>
|
||||
<Select
|
||||
value={provider}
|
||||
onValueChange={(v) => {
|
||||
const p = v as Provider;
|
||||
setProvider(p);
|
||||
setModel(data?.models[p][0] ?? '');
|
||||
setTestStatus(null);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id={`provider-${scope}`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="openai">OpenAI</SelectItem>
|
||||
<SelectItem value="claude">Claude (Anthropic)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`model-${scope}`}>Model</Label>
|
||||
<Select value={model} onValueChange={setModel}>
|
||||
<SelectTrigger id={`model-${scope}`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{models.map((m) => (
|
||||
<SelectItem key={m} value={m}>
|
||||
{m}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`apiKey-${scope}`}>API key</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id={`apiKey-${scope}`}
|
||||
type={showKey ? 'text' : 'password'}
|
||||
autoComplete="off"
|
||||
placeholder={hasKey ? '•••••• (saved — leave blank to keep)' : 'sk-…'}
|
||||
value={apiKey}
|
||||
onChange={(e) => {
|
||||
setApiKey(e.target.value);
|
||||
setTestStatus(null);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setShowKey((v) => !v)}
|
||||
aria-label={showKey ? 'Hide key' : 'Show key'}
|
||||
>
|
||||
{showKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Stored encrypted at rest. Never re-displayed after saving.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
onClick={() => save.mutate(false)}
|
||||
disabled={save.isPending}
|
||||
data-testid={`save-${scope}`}
|
||||
>
|
||||
{save.isPending ? <Loader2 className="mr-1.5 h-3 w-3 animate-spin" /> : null}
|
||||
Save settings
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => test.mutate()}
|
||||
disabled={test.isPending || apiKey.length === 0}
|
||||
>
|
||||
{test.isPending ? <Loader2 className="mr-1.5 h-3 w-3 animate-spin" /> : null}
|
||||
Test connection
|
||||
</Button>
|
||||
{hasKey ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => save.mutate(true)}
|
||||
disabled={save.isPending}
|
||||
className="text-destructive"
|
||||
>
|
||||
Clear stored key
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{testStatus?.ok ? (
|
||||
<span className="inline-flex items-center gap-1 text-sm text-green-700">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
Connection OK
|
||||
</span>
|
||||
) : null}
|
||||
{testStatus && !testStatus.ok ? (
|
||||
<span className="inline-flex items-center gap-1 text-sm text-destructive">
|
||||
<XCircle className="h-4 w-4" />
|
||||
{testStatus.reason}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function OcrSettingsForm() {
|
||||
const { isSuperAdmin } = usePermissions();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Receipt OCR"
|
||||
eyebrow="Admin"
|
||||
description="Receipts are scanned on-device by default. Optionally configure an AI provider for higher-accuracy parsing on tricky receipts."
|
||||
variant="gradient"
|
||||
/>
|
||||
|
||||
<SettingsBlock
|
||||
scope="port"
|
||||
title="This port"
|
||||
description="Optional AI provider for staff at this port. Tesseract.js handles all scans on-device until AI is enabled."
|
||||
showUseGlobal
|
||||
/>
|
||||
|
||||
<AiBudgetCard />
|
||||
|
||||
{isSuperAdmin ? (
|
||||
<SettingsBlock
|
||||
scope="global"
|
||||
title="Global default"
|
||||
description="Used by any port that opted into the global key. Super-admin only."
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
src/components/alerts/alert-bell.tsx
Normal file
84
src/components/alerts/alert-bell.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { ShieldAlert } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { useUIStore } from '@/stores/ui-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AlertCard, AlertCardEmpty } from './alert-card';
|
||||
import { useAlertCount, useAlertList, useAlertRealtime } from './use-alerts';
|
||||
|
||||
export function AlertBell() {
|
||||
const portSlug = useUIStore((s) => s.currentPortSlug);
|
||||
const [open, setOpen] = useState(false);
|
||||
// Count is cheap (one aggregate query) — fire on every page so the badge stays live.
|
||||
// List is heavier — only fetch when the popover is actually open.
|
||||
const { data: count } = useAlertCount();
|
||||
const { data: list, isLoading } = useAlertList('open', open);
|
||||
useAlertRealtime();
|
||||
|
||||
const total = count?.total ?? 0;
|
||||
const critical = count?.bySeverity.critical ?? 0;
|
||||
const alerts = list?.data ?? [];
|
||||
const top = alerts.slice(0, 5);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="relative"
|
||||
aria-label={`Alerts${total > 0 ? ` (${total} active)` : ''}`}
|
||||
data-testid="alert-bell"
|
||||
>
|
||||
<ShieldAlert className="h-5 w-5" />
|
||||
{total > 0 ? (
|
||||
<span
|
||||
key={total}
|
||||
data-testid="alert-bell-badge"
|
||||
className={cn(
|
||||
'absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-bold text-white shadow-sm ring-2 ring-background animate-badge-pop',
|
||||
critical > 0 ? 'bg-destructive' : 'bg-amber-500',
|
||||
)}
|
||||
>
|
||||
{total > 99 ? '99+' : total}
|
||||
</span>
|
||||
) : null}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-96 p-0">
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<h4 className="text-sm font-semibold">Active alerts</h4>
|
||||
<Link
|
||||
href={portSlug ? (`/${portSlug}/alerts` as never) : ('/alerts' as never)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
View all
|
||||
</Link>
|
||||
</div>
|
||||
<Separator />
|
||||
<ScrollArea className="max-h-[420px]">
|
||||
{isLoading ? (
|
||||
<div className="px-4 py-6 text-center text-sm text-muted-foreground">Loading…</div>
|
||||
) : top.length === 0 ? (
|
||||
<div className="p-3">
|
||||
<AlertCardEmpty />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 p-3">
|
||||
{top.map((a) => (
|
||||
<AlertCard key={a.id} alert={a} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
116
src/components/alerts/alert-card.tsx
Normal file
116
src/components/alerts/alert-card.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
'use client';
|
||||
|
||||
import { AlertTriangle, Bell, Check, ExternalLink, Info, ShieldAlert, X } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { AlertRow } from './types';
|
||||
import { useAlertActions } from './use-alerts';
|
||||
|
||||
interface AlertCardProps {
|
||||
alert: AlertRow;
|
||||
/** Hide the side action buttons in compact contexts (e.g. resolved/dismissed history). */
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const SEVERITY_STYLES: Record<string, { stripe: string; icon: typeof Info }> = {
|
||||
info: { stripe: 'bg-[hsl(var(--chart-1))]', icon: Info },
|
||||
warning: { stripe: 'bg-amber-500', icon: AlertTriangle },
|
||||
critical: { stripe: 'bg-destructive', icon: ShieldAlert },
|
||||
};
|
||||
|
||||
export function AlertCard({ alert, readOnly = false }: AlertCardProps) {
|
||||
const router = useRouter();
|
||||
const { acknowledge, dismiss } = useAlertActions();
|
||||
const sev = SEVERITY_STYLES[alert.severity] ?? SEVERITY_STYLES.info!;
|
||||
const Icon = sev.icon;
|
||||
const acknowledged = Boolean(alert.acknowledgedAt);
|
||||
const fired = formatDistanceToNow(new Date(alert.firedAt), { addSuffix: true });
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="alert-card"
|
||||
data-severity={alert.severity}
|
||||
className={cn(
|
||||
'group relative flex gap-3 overflow-hidden rounded-lg border border-border bg-card p-3 shadow-xs transition-shadow duration-base ease-spring hover:shadow-sm',
|
||||
acknowledged && 'opacity-70',
|
||||
)}
|
||||
>
|
||||
<span className={cn('absolute inset-y-0 left-0 w-1', sev.stripe)} aria-hidden />
|
||||
<Icon
|
||||
className={cn(
|
||||
'mt-0.5 h-4 w-4 shrink-0',
|
||||
alert.severity === 'critical' && 'text-destructive',
|
||||
alert.severity === 'warning' && 'text-amber-600',
|
||||
alert.severity === 'info' && 'text-foreground',
|
||||
)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<p className="truncate text-sm font-medium text-foreground">{alert.title}</p>
|
||||
{acknowledged ? (
|
||||
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">ack</span>
|
||||
) : null}
|
||||
</div>
|
||||
{alert.body ? (
|
||||
<p className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">{alert.body}</p>
|
||||
) : null}
|
||||
<div className="mt-1 flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<span>{fired}</span>
|
||||
<span aria-hidden>·</span>
|
||||
<span className="font-mono text-[10px]">{alert.ruleId}</span>
|
||||
</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">
|
||||
{!acknowledged ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
aria-label="Acknowledge"
|
||||
disabled={acknowledge.isPending}
|
||||
onClick={() => acknowledge.mutate(alert.id)}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
aria-label="Dismiss"
|
||||
disabled={dismiss.isPending}
|
||||
onClick={() => dismiss.mutate(alert.id)}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{alert.link ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
aria-label="Open"
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onClick={() => router.push(alert.link as any)}
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlertCardEmpty() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border py-10 text-center">
|
||||
<Bell className="mb-2 h-8 w-8 text-muted-foreground/40" aria-hidden />
|
||||
<p className="text-sm font-medium">All clear</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">No active alerts right now.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
src/components/alerts/alert-rail.tsx
Normal file
63
src/components/alerts/alert-rail.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
|
||||
import { useUIStore } from '@/stores/ui-store';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { AlertCard, AlertCardEmpty } from './alert-card';
|
||||
import { useAlertList, useAlertRealtime } from './use-alerts';
|
||||
|
||||
export function AlertRail() {
|
||||
const portSlug = useUIStore((s) => s.currentPortSlug);
|
||||
const { data, isLoading } = useAlertList('open');
|
||||
useAlertRealtime();
|
||||
|
||||
const alerts = data?.data ?? [];
|
||||
// Show first 5 in the rail; surplus pushes user to the full /alerts page.
|
||||
const visible = alerts.slice(0, 5);
|
||||
const overflow = Math.max(alerts.length - visible.length, 0);
|
||||
|
||||
return (
|
||||
<section
|
||||
data-testid="alert-rail"
|
||||
aria-label="Active alerts"
|
||||
className="flex h-full flex-col gap-3"
|
||||
>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h2 className="text-sm font-semibold tracking-tight">Alerts</h2>
|
||||
<Link
|
||||
href={portSlug ? (`/${portSlug}/alerts` as never) : ('/alerts' as never)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
View all
|
||||
<ArrowRight className="ml-1 inline h-3 w-3" aria-hidden />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</div>
|
||||
) : visible.length === 0 ? (
|
||||
<AlertCardEmpty />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{visible.map((a) => (
|
||||
<AlertCard key={a.id} alert={a} />
|
||||
))}
|
||||
{overflow > 0 ? (
|
||||
<Link
|
||||
href={portSlug ? (`/${portSlug}/alerts` as never) : ('/alerts' as never)}
|
||||
className="block rounded-lg border border-dashed border-border px-3 py-2 text-center text-xs text-muted-foreground transition-colors hover:bg-accent"
|
||||
>
|
||||
+{overflow} more — view all
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
66
src/components/alerts/alerts-page-shell.tsx
Normal file
66
src/components/alerts/alerts-page-shell.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ShieldAlert } from 'lucide-react';
|
||||
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
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 type { AlertStatus } from './types';
|
||||
|
||||
export function AlertsPageShell() {
|
||||
const [tab, setTab] = useState<AlertStatus>('open');
|
||||
const { data: count } = useAlertCount();
|
||||
const { data, isLoading } = useAlertList(tab);
|
||||
useAlertRealtime();
|
||||
|
||||
const total = count?.total ?? 0;
|
||||
const alerts = data?.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Alerts"
|
||||
eyebrow="Operational"
|
||||
description="Rules-based signals about pipeline, agreements, expenses, and access"
|
||||
kpiLine={
|
||||
<span>
|
||||
<ShieldAlert className="mr-1 inline h-3 w-3" aria-hidden />
|
||||
{total} active
|
||||
</span>
|
||||
}
|
||||
variant="gradient"
|
||||
/>
|
||||
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as AlertStatus)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="open" data-testid="tab-open">
|
||||
Active{total > 0 ? ` · ${total}` : ''}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="dismissed" data-testid="tab-dismissed">
|
||||
Dismissed
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="resolved" data-testid="tab-resolved">
|
||||
Resolved
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value={tab} className="mt-4 space-y-2">
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</div>
|
||||
) : alerts.length === 0 ? (
|
||||
<AlertCardEmpty />
|
||||
) : (
|
||||
alerts.map((a) => <AlertCard key={a.id} alert={a} readOnly={tab !== 'open'} />)
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
src/components/alerts/types.ts
Normal file
14
src/components/alerts/types.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { Alert } from '@/lib/db/schema/insights';
|
||||
|
||||
export type AlertRow = Alert;
|
||||
|
||||
export interface AlertListResponse {
|
||||
data: AlertRow[];
|
||||
}
|
||||
|
||||
export interface AlertCountResponse {
|
||||
total: number;
|
||||
bySeverity: Record<'info' | 'warning' | 'critical', number>;
|
||||
}
|
||||
|
||||
export type AlertStatus = 'open' | 'dismissed' | 'resolved';
|
||||
50
src/components/alerts/use-alerts.ts
Normal file
50
src/components/alerts/use-alerts.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import type { AlertCountResponse, AlertListResponse, AlertStatus } from './types';
|
||||
|
||||
export function useAlertList(status: AlertStatus = 'open', enabled = true) {
|
||||
return useQuery<AlertListResponse>({
|
||||
queryKey: ['alerts', status],
|
||||
queryFn: () => apiFetch<AlertListResponse>(`/api/v1/alerts?status=${status}`),
|
||||
staleTime: 30_000,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAlertCount() {
|
||||
return useQuery<AlertCountResponse>({
|
||||
queryKey: ['alerts', 'count'],
|
||||
queryFn: () => apiFetch<AlertCountResponse>('/api/v1/alerts/count'),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAlertActions() {
|
||||
const queryClient = useQueryClient();
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['alerts'] });
|
||||
};
|
||||
|
||||
const acknowledge = useMutation({
|
||||
mutationFn: (id: string) => apiFetch(`/api/v1/alerts/${id}/acknowledge`, { method: 'POST' }),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
const dismiss = useMutation({
|
||||
mutationFn: (id: string) => apiFetch(`/api/v1/alerts/${id}/dismiss`, { method: 'POST' }),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
return { acknowledge, dismiss };
|
||||
}
|
||||
|
||||
export function useAlertRealtime() {
|
||||
useRealtimeInvalidation({
|
||||
'alert:created': [['alerts']],
|
||||
'alert:resolved': [['alerts']],
|
||||
'alert:dismissed': [['alerts']],
|
||||
});
|
||||
}
|
||||
216
src/components/berths/berth-interests-tab.tsx
Normal file
216
src/components/berths/berth-interests-tab.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { TableSkeleton } from '@/components/shared/loading-skeleton';
|
||||
import { EmptyState } from '@/components/shared/empty-state';
|
||||
import { Bookmark } from 'lucide-react';
|
||||
import type { InterestRow } from '@/components/interests/interest-columns';
|
||||
|
||||
interface BerthInterestsTabProps {
|
||||
berthId: string;
|
||||
}
|
||||
|
||||
type StageFilter = 'all' | 'active' | 'lost';
|
||||
type SortMode = 'newest' | 'stage' | 'category';
|
||||
|
||||
const STAGE_LABELS: Record<string, string> = {
|
||||
open: 'Open',
|
||||
details_sent: 'Details Sent',
|
||||
in_communication: 'In Communication',
|
||||
visited: 'Visited',
|
||||
signed_eoi_nda: 'Signed EOI/NDA',
|
||||
deposit_10pct: 'Deposit 10%',
|
||||
contract: 'Contract',
|
||||
completed: 'Completed',
|
||||
};
|
||||
|
||||
const STAGE_ORDER: Record<string, number> = {
|
||||
open: 0,
|
||||
details_sent: 1,
|
||||
in_communication: 2,
|
||||
visited: 3,
|
||||
signed_eoi_nda: 4,
|
||||
deposit_10pct: 5,
|
||||
contract: 6,
|
||||
completed: 7,
|
||||
};
|
||||
|
||||
const CATEGORY_RANK: Record<string, number> = {
|
||||
hot_lead: 0,
|
||||
specific_qualified: 1,
|
||||
general_interest: 2,
|
||||
};
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
hot_lead: 'Hot Lead',
|
||||
specific_qualified: 'Specific Qualified',
|
||||
general_interest: 'General Interest',
|
||||
};
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
website: 'Website',
|
||||
manual: 'Manual',
|
||||
referral: 'Referral',
|
||||
broker: 'Broker',
|
||||
};
|
||||
|
||||
interface ListResponse {
|
||||
data: InterestRow[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function BerthInterestsTab({ berthId }: BerthInterestsTabProps) {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
const [stage, setStage] = useState<StageFilter>('all');
|
||||
const [sortMode, setSortMode] = useState<SortMode>('newest');
|
||||
|
||||
const { data, isLoading } = useQuery<ListResponse>({
|
||||
queryKey: ['interests', 'by-berth', berthId],
|
||||
queryFn: () => apiFetch<ListResponse>(`/api/v1/interests?berthId=${berthId}&limit=200`),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
useRealtimeInvalidation({
|
||||
'interest:created': [['interests', 'by-berth', berthId]],
|
||||
'interest:updated': [['interests', 'by-berth', berthId]],
|
||||
'interest:stageChanged': [['interests', 'by-berth', berthId]],
|
||||
'interest:archived': [['interests', 'by-berth', berthId]],
|
||||
'interest:berthLinked': [['interests', 'by-berth', berthId]],
|
||||
'interest:berthUnlinked': [['interests', 'by-berth', berthId]],
|
||||
});
|
||||
|
||||
const rows = useMemo<InterestRow[]>(() => {
|
||||
const all = data?.data ?? [];
|
||||
const filtered = all.filter((i) => {
|
||||
if (stage === 'active') return i.pipelineStage !== 'completed' && !i.archivedAt;
|
||||
if (stage === 'lost') return Boolean(i.archivedAt);
|
||||
return true;
|
||||
});
|
||||
const sorted = [...filtered].sort((a, b) => {
|
||||
if (sortMode === 'stage') {
|
||||
const sa = STAGE_ORDER[a.pipelineStage] ?? 99;
|
||||
const sb = STAGE_ORDER[b.pipelineStage] ?? 99;
|
||||
if (sa !== sb) return sb - sa; // furthest along first
|
||||
}
|
||||
if (sortMode === 'category') {
|
||||
const ca = CATEGORY_RANK[a.leadCategory ?? ''] ?? 99;
|
||||
const cb = CATEGORY_RANK[b.leadCategory ?? ''] ?? 99;
|
||||
if (ca !== cb) return ca - cb; // hottest first
|
||||
}
|
||||
// Default + tiebreaker: newest first.
|
||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
});
|
||||
return sorted;
|
||||
}, [data?.data, stage, sortMode]);
|
||||
|
||||
if (isLoading) return <TableSkeleton />;
|
||||
|
||||
if ((data?.data ?? []).length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Bookmark}
|
||||
title="No interests linked to this berth"
|
||||
description="Interests will appear here when prospects express interest in this specific berth."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{rows.length} of {data?.total ?? 0} interest{(data?.total ?? 0) === 1 ? '' : 's'}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Select value={stage} onValueChange={(v) => setStage(v as StageFilter)}>
|
||||
<SelectTrigger className="h-8 w-[140px]" data-testid="berth-interests-filter">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All stages</SelectItem>
|
||||
<SelectItem value="active">Active only</SelectItem>
|
||||
<SelectItem value="lost">Lost / archived</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={sortMode} onValueChange={(v) => setSortMode(v as SortMode)}>
|
||||
<SelectTrigger className="h-8 w-[160px]" data-testid="berth-interests-sort">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="newest">Newest</SelectItem>
|
||||
<SelectItem value="stage">Stage progress</SelectItem>
|
||||
<SelectItem value="category">Lead category</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-card">
|
||||
<table className="w-full text-sm" data-testid="berth-interests-table">
|
||||
<thead className="bg-muted/40 text-left text-xs font-medium text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2">Client</th>
|
||||
<th className="px-3 py-2">Stage</th>
|
||||
<th className="px-3 py-2">Category</th>
|
||||
<th className="px-3 py-2">Source</th>
|
||||
<th className="px-3 py-2">Last activity</th>
|
||||
<th className="px-3 py-2 text-right" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((i) => (
|
||||
<tr
|
||||
key={i.id}
|
||||
className="border-t border-border last:border-b-0 hover:bg-gradient-brand-soft/40"
|
||||
>
|
||||
<td className="px-3 py-2 font-medium text-foreground">
|
||||
<Link
|
||||
href={`/${portSlug}/interests/${i.id}` as never}
|
||||
className="hover:text-brand"
|
||||
>
|
||||
{i.clientName ?? '—'}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
{STAGE_LABELS[i.pipelineStage] ?? i.pipelineStage}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">
|
||||
{i.leadCategory ? (CATEGORY_LABELS[i.leadCategory] ?? i.leadCategory) : '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">
|
||||
{i.source ? (SOURCE_LABELS[i.source] ?? i.source) : '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground">
|
||||
{new Date(i.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<Button asChild variant="ghost" size="sm" className="h-7 text-xs">
|
||||
<Link href={`/${portSlug}/interests/${i.id}` as never}>Open</Link>
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { type DetailTab } from '@/components/shared/detail-layout';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { TagBadge } from '@/components/shared/tag-badge';
|
||||
import { BerthReservationsTab } from './berth-reservations-tab';
|
||||
import { BerthInterestsTab } from './berth-interests-tab';
|
||||
|
||||
type BerthData = {
|
||||
id: string;
|
||||
@@ -181,7 +182,7 @@ export function buildBerthTabs(berth: BerthData): DetailTab[] {
|
||||
{
|
||||
id: 'interests',
|
||||
label: 'Interests',
|
||||
content: <StubTab label="Interests" />,
|
||||
content: <BerthInterestsTab berthId={berth.id} />,
|
||||
},
|
||||
{
|
||||
id: 'reservations',
|
||||
|
||||
@@ -14,11 +14,12 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TagBadge } from '@/components/shared/tag-badge';
|
||||
import { getCountryName } from '@/lib/i18n/countries';
|
||||
|
||||
export interface ClientRow {
|
||||
id: string;
|
||||
fullName: string;
|
||||
nationality: string | null;
|
||||
nationalityIso: string | null;
|
||||
source: string | null;
|
||||
archivedAt: string | null;
|
||||
createdAt: string;
|
||||
@@ -78,11 +79,14 @@ export function getClientColumns({
|
||||
},
|
||||
{
|
||||
id: 'nationality',
|
||||
accessorKey: 'nationality',
|
||||
accessorKey: 'nationalityIso',
|
||||
header: 'Nationality',
|
||||
cell: ({ getValue }) => (
|
||||
<span className="text-muted-foreground">{(getValue() as string | null) ?? '—'}</span>
|
||||
),
|
||||
cell: ({ getValue }) => {
|
||||
const iso = getValue() as string | null;
|
||||
return (
|
||||
<span className="text-muted-foreground">{iso ? getCountryName(iso, 'en') : '—'}</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'source',
|
||||
|
||||
@@ -10,6 +10,7 @@ import { TagBadge } from '@/components/shared/tag-badge';
|
||||
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
|
||||
import { DetailHeaderStrip } from '@/components/shared/detail-header-strip';
|
||||
import { PortalInviteButton } from '@/components/clients/portal-invite-button';
|
||||
import { GdprExportButton } from '@/components/clients/gdpr-export-button';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
interface ClientDetailHeaderProps {
|
||||
@@ -122,6 +123,7 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
||||
defaultEmail={primaryEmail?.value}
|
||||
/>
|
||||
)}
|
||||
<GdprExportButton clientId={client.id} />
|
||||
<Button
|
||||
variant={isArchived ? 'outline' : 'outline'}
|
||||
size="sm"
|
||||
|
||||
@@ -7,12 +7,13 @@ import { ClientDetailHeader } from '@/components/clients/client-detail-header';
|
||||
import { getClientTabs } from '@/components/clients/client-tabs';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import type { Address } from '@/components/shared/addresses-editor';
|
||||
|
||||
interface ClientData {
|
||||
id: string;
|
||||
portId: string;
|
||||
fullName: string;
|
||||
nationality: string | null;
|
||||
nationalityIso: string | null;
|
||||
preferredContactMethod: string | null;
|
||||
preferredLanguage: string | null;
|
||||
timezone: string | null;
|
||||
@@ -64,6 +65,7 @@ interface ClientData {
|
||||
tenureType: string;
|
||||
status: string;
|
||||
}>;
|
||||
addresses: Address[];
|
||||
}
|
||||
|
||||
interface ClientDetailProps {
|
||||
|
||||
@@ -20,8 +20,12 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/com
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { TagPicker } from '@/components/shared/tag-picker';
|
||||
import { CountryCombobox } from '@/components/shared/country-combobox';
|
||||
import { TimezoneCombobox } from '@/components/shared/timezone-combobox';
|
||||
import { PhoneInput } from '@/components/shared/phone-input';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { createClientSchema, type CreateClientInput } from '@/lib/validators/clients';
|
||||
import type { CountryCode } from '@/lib/i18n/countries';
|
||||
|
||||
interface ClientFormProps {
|
||||
open: boolean;
|
||||
@@ -30,7 +34,7 @@ interface ClientFormProps {
|
||||
client?: {
|
||||
id: string;
|
||||
fullName: string;
|
||||
nationality?: string | null;
|
||||
nationalityIso?: string | null;
|
||||
preferredContactMethod?: string | null;
|
||||
preferredLanguage?: string | null;
|
||||
timezone?: string | null;
|
||||
@@ -39,6 +43,8 @@ interface ClientFormProps {
|
||||
contacts?: Array<{
|
||||
channel: string;
|
||||
value: string;
|
||||
valueE164?: string | null;
|
||||
valueCountry?: string | null;
|
||||
label?: string | null;
|
||||
isPrimary?: boolean;
|
||||
notes?: string | null;
|
||||
@@ -76,7 +82,7 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
|
||||
if (client && open) {
|
||||
reset({
|
||||
fullName: client.fullName,
|
||||
nationality: client.nationality ?? undefined,
|
||||
nationalityIso: client.nationalityIso ?? undefined,
|
||||
preferredContactMethod:
|
||||
(client.preferredContactMethod as CreateClientInput['preferredContactMethod']) ??
|
||||
undefined,
|
||||
@@ -89,6 +95,8 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
|
||||
? client.contacts.map((c) => ({
|
||||
channel: c.channel as 'email' | 'phone' | 'whatsapp' | 'other',
|
||||
value: c.value,
|
||||
valueE164: c.valueE164 ?? undefined,
|
||||
valueCountry: c.valueCountry ?? undefined,
|
||||
label: c.label ?? undefined,
|
||||
isPrimary: c.isPrimary ?? false,
|
||||
notes: c.notes ?? undefined,
|
||||
@@ -152,7 +160,11 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>Nationality</Label>
|
||||
<Input {...register('nationality')} placeholder="British" />
|
||||
<CountryCombobox
|
||||
value={watch('nationalityIso')}
|
||||
onChange={(iso) => setValue('nationalityIso', iso ?? undefined)}
|
||||
data-testid="client-nationality"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -211,11 +223,40 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
|
||||
|
||||
<div className="col-span-5 space-y-1">
|
||||
<Label className="text-xs">Value</Label>
|
||||
{(() => {
|
||||
const channel = watch(`contacts.${index}.channel`);
|
||||
if (channel === 'phone' || channel === 'whatsapp') {
|
||||
const e164 = watch(`contacts.${index}.valueE164`) ?? null;
|
||||
const country =
|
||||
(watch(`contacts.${index}.valueCountry`) as CountryCode | undefined) ??
|
||||
undefined;
|
||||
return (
|
||||
<PhoneInput
|
||||
value={
|
||||
e164 || country
|
||||
? {
|
||||
e164: e164 ?? null,
|
||||
country: country ?? 'US',
|
||||
}
|
||||
: null
|
||||
}
|
||||
onChange={(v) => {
|
||||
setValue(`contacts.${index}.value`, v.e164 ?? '');
|
||||
setValue(`contacts.${index}.valueE164`, v.e164 ?? undefined);
|
||||
setValue(`contacts.${index}.valueCountry`, v.country);
|
||||
}}
|
||||
data-testid={`contact-${index}-phone`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
{...register(`contacts.${index}.value`)}
|
||||
className="h-8"
|
||||
placeholder="email@example.com"
|
||||
placeholder={channel === 'email' ? 'email@example.com' : 'value'}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 space-y-1">
|
||||
@@ -304,7 +345,12 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Timezone</Label>
|
||||
<Input {...register('timezone')} placeholder="UTC+0" />
|
||||
<TimezoneCombobox
|
||||
value={watch('timezone')}
|
||||
onChange={(tz) => setValue('timezone', tz ?? undefined)}
|
||||
countryHint={(watch('nationalityIso') as CountryCode | undefined) ?? undefined}
|
||||
data-testid="client-timezone"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1">
|
||||
<Label>Source Details</Label>
|
||||
|
||||
@@ -4,17 +4,22 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import type { DetailTab } from '@/components/shared/detail-layout';
|
||||
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
||||
import { InlineCountryField } from '@/components/shared/inline-country-field';
|
||||
import { InlineTimezoneField } from '@/components/shared/inline-timezone-field';
|
||||
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
|
||||
import { NotesList } from '@/components/shared/notes-list';
|
||||
import type { CountryCode } from '@/lib/i18n/countries';
|
||||
import { ClientYachtsTab } from '@/components/clients/client-yachts-tab';
|
||||
import { ClientCompaniesTab } from '@/components/clients/client-companies-tab';
|
||||
import { ClientReservationsTab } from '@/components/clients/client-reservations-tab';
|
||||
import { ContactsEditor } from '@/components/clients/contacts-editor';
|
||||
import { AddressesEditor, type Address } from '@/components/shared/addresses-editor';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
type ClientPatchField =
|
||||
| 'fullName'
|
||||
| 'nationality'
|
||||
| 'nationalityIso'
|
||||
| 'preferredContactMethod'
|
||||
| 'preferredLanguage'
|
||||
| 'timezone'
|
||||
@@ -64,6 +69,7 @@ interface ClientTabsOptions {
|
||||
client: {
|
||||
fullName: string;
|
||||
nationality?: string | null;
|
||||
nationalityIso?: string | null;
|
||||
preferredContactMethod?: string | null;
|
||||
preferredLanguage?: string | null;
|
||||
timezone?: string | null;
|
||||
@@ -73,9 +79,12 @@ interface ClientTabsOptions {
|
||||
id: string;
|
||||
channel: string;
|
||||
value: string;
|
||||
valueE164?: string | null;
|
||||
valueCountry?: string | null;
|
||||
label?: string | null;
|
||||
isPrimary: boolean;
|
||||
}>;
|
||||
addresses?: Address[];
|
||||
yachts: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -131,7 +140,13 @@ function OverviewTab({
|
||||
<InlineEditableField value={client.fullName} onSave={save('fullName')} />
|
||||
</EditableRow>
|
||||
<EditableRow label="Nationality">
|
||||
<InlineEditableField value={client.nationality} onSave={save('nationality')} />
|
||||
<InlineCountryField
|
||||
value={client.nationalityIso ?? null}
|
||||
onSave={async (iso) => {
|
||||
await mutation.mutateAsync({ nationalityIso: iso });
|
||||
}}
|
||||
data-testid="client-nationality-inline"
|
||||
/>
|
||||
</EditableRow>
|
||||
<EditableRow label="Preferred Language">
|
||||
<InlineEditableField
|
||||
@@ -140,7 +155,14 @@ function OverviewTab({
|
||||
/>
|
||||
</EditableRow>
|
||||
<EditableRow label="Timezone">
|
||||
<InlineEditableField value={client.timezone} onSave={save('timezone')} />
|
||||
<InlineTimezoneField
|
||||
value={client.timezone}
|
||||
countryHint={(client.nationalityIso as CountryCode | null) ?? null}
|
||||
onSave={async (tz) => {
|
||||
await mutation.mutateAsync({ timezone: tz });
|
||||
}}
|
||||
data-testid="client-timezone-inline"
|
||||
/>
|
||||
</EditableRow>
|
||||
<EditableRow label="Preferred Contact">
|
||||
<InlineEditableField
|
||||
@@ -217,6 +239,18 @@ export function getClientTabs({ clientId, currentUserId, client }: ClientTabsOpt
|
||||
<ClientReservationsTab clientId={clientId} activeReservations={client.activeReservations} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'addresses',
|
||||
label: 'Addresses',
|
||||
badge: client.addresses?.length ?? 0,
|
||||
content: (
|
||||
<AddressesEditor
|
||||
endpoint={`/api/v1/clients/${clientId}/addresses`}
|
||||
invalidateKey={['clients', clientId]}
|
||||
addresses={client.addresses ?? []}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'interests',
|
||||
label: 'Interests',
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
||||
import { InlinePhoneField } from '@/components/shared/inline-phone-field';
|
||||
import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -31,6 +33,8 @@ interface Contact {
|
||||
id: string;
|
||||
channel: string;
|
||||
value: string;
|
||||
valueE164?: string | null;
|
||||
valueCountry?: string | null;
|
||||
label?: string | null;
|
||||
isPrimary: boolean;
|
||||
}
|
||||
@@ -63,7 +67,9 @@ export function ContactsEditor({ clientId, contacts }: { clientId: string; conta
|
||||
patch,
|
||||
}: {
|
||||
contactId: string;
|
||||
patch: Partial<Pick<Contact, 'channel' | 'value' | 'label' | 'isPrimary'>>;
|
||||
patch: Partial<
|
||||
Pick<Contact, 'channel' | 'value' | 'valueE164' | 'valueCountry' | 'label' | 'isPrimary'>
|
||||
>;
|
||||
}) =>
|
||||
apiFetch(`/api/v1/clients/${clientId}/contacts/${contactId}`, {
|
||||
method: 'PATCH',
|
||||
@@ -73,7 +79,13 @@ export function ContactsEditor({ clientId, contacts }: { clientId: string; conta
|
||||
});
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: async (data: { channel: string; value: string; label?: string }) =>
|
||||
mutationFn: async (data: {
|
||||
channel: string;
|
||||
value: string;
|
||||
valueE164?: string | null;
|
||||
valueCountry?: string | null;
|
||||
label?: string;
|
||||
}) =>
|
||||
apiFetch(`/api/v1/clients/${clientId}/contacts`, {
|
||||
method: 'POST',
|
||||
body: { ...data, isPrimary: false },
|
||||
@@ -136,7 +148,9 @@ function ContactRow({
|
||||
}: {
|
||||
contact: Contact;
|
||||
onUpdate: (
|
||||
patch: Partial<Pick<Contact, 'channel' | 'value' | 'label' | 'isPrimary'>>,
|
||||
patch: Partial<
|
||||
Pick<Contact, 'channel' | 'value' | 'valueE164' | 'valueCountry' | 'label' | 'isPrimary'>
|
||||
>,
|
||||
) => Promise<unknown>;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
@@ -167,6 +181,19 @@ function ContactRow({
|
||||
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</ChannelPicker>
|
||||
<div className="min-w-0">
|
||||
{contact.channel === 'phone' || contact.channel === 'whatsapp' ? (
|
||||
<InlinePhoneField
|
||||
e164={contact.valueE164 ?? null}
|
||||
country={contact.valueCountry ?? null}
|
||||
onSave={async ({ e164, country }) => {
|
||||
if (!e164) {
|
||||
toast.error('Phone number is required');
|
||||
return;
|
||||
}
|
||||
await onUpdate({ value: e164, valueE164: e164, valueCountry: country });
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<InlineEditableField
|
||||
value={contact.value}
|
||||
onSave={async (v) => {
|
||||
@@ -177,6 +204,7 @@ function ContactRow({
|
||||
await onUpdate({ value: v });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -252,15 +280,42 @@ function NewContactForm({
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
onSave: (data: { channel: string; value: string; label?: string }) => Promise<void>;
|
||||
onSave: (data: {
|
||||
channel: string;
|
||||
value: string;
|
||||
valueE164?: string | null;
|
||||
valueCountry?: string | null;
|
||||
label?: string;
|
||||
}) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [channel, setChannel] = useState('email');
|
||||
const [value, setValue] = useState('');
|
||||
const [phoneValue, setPhoneValue] = useState<PhoneInputValue | null>(null);
|
||||
const [label, setLabel] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const isPhoneChannel = channel === 'phone' || channel === 'whatsapp';
|
||||
|
||||
async function submit() {
|
||||
if (isPhoneChannel) {
|
||||
if (!phoneValue?.e164) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave({
|
||||
channel,
|
||||
value: phoneValue.e164,
|
||||
valueE164: phoneValue.e164,
|
||||
valueCountry: phoneValue.country,
|
||||
label: label.trim() || undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to add contact');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!value.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
@@ -272,9 +327,19 @@ function NewContactForm({
|
||||
}
|
||||
}
|
||||
|
||||
const submitDisabled = saving || (isPhoneChannel ? !phoneValue?.e164 : !value.trim());
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-2 rounded-lg border bg-muted/30 text-sm">
|
||||
<Select value={channel} onValueChange={setChannel}>
|
||||
<Select
|
||||
value={channel}
|
||||
onValueChange={(next) => {
|
||||
setChannel(next);
|
||||
// Reset cross-mode state so a stale email doesn't ride along on a phone submit.
|
||||
if (next === 'phone' || next === 'whatsapp') setValue('');
|
||||
else setPhoneValue(null);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-28 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -287,10 +352,19 @@ function NewContactForm({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{isPhoneChannel ? (
|
||||
<div className="flex-1 min-w-0">
|
||||
<PhoneInput
|
||||
value={phoneValue}
|
||||
onChange={(v) => setPhoneValue(v)}
|
||||
data-testid="new-contact-phone"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder={channel === 'email' ? 'name@example.com' : '+1 555 0100'}
|
||||
placeholder={channel === 'email' ? 'name@example.com' : 'value'}
|
||||
className="h-7 text-sm flex-1 min-w-0"
|
||||
autoFocus
|
||||
disabled={saving}
|
||||
@@ -302,6 +376,7 @@ function NewContactForm({
|
||||
if (e.key === 'Escape') onCancel();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Input
|
||||
value={label}
|
||||
@@ -318,7 +393,7 @@ function NewContactForm({
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button type="button" size="sm" onClick={submit} disabled={!value.trim() || saving}>
|
||||
<Button type="button" size="sm" onClick={submit} disabled={submitDisabled}>
|
||||
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Save'}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="ghost" onClick={onCancel} disabled={saving}>
|
||||
|
||||
207
src/components/clients/gdpr-export-button.tsx
Normal file
207
src/components/clients/gdpr-export-button.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { format } from 'date-fns';
|
||||
import { Download, FileDown, Loader2, Mail } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { usePermissions } from '@/hooks/use-permissions';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
interface ExportRow {
|
||||
id: string;
|
||||
status: 'pending' | 'building' | 'ready' | 'sent' | 'failed';
|
||||
storageKey: string | null;
|
||||
sizeBytes: number | null;
|
||||
createdAt: string;
|
||||
readyAt: string | null;
|
||||
sentAt: string | null;
|
||||
sentTo: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface ListResp {
|
||||
data: ExportRow[];
|
||||
}
|
||||
|
||||
const STATUS_VARIANT: Record<ExportRow['status'], 'secondary' | 'outline' | 'destructive'> = {
|
||||
pending: 'outline',
|
||||
building: 'outline',
|
||||
ready: 'secondary',
|
||||
sent: 'secondary',
|
||||
failed: 'destructive',
|
||||
};
|
||||
|
||||
export function GdprExportButton({ clientId }: { clientId: string }) {
|
||||
const { can, isSuperAdmin } = usePermissions();
|
||||
const qc = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [emailToClient, setEmailToClient] = useState(false);
|
||||
const [emailOverride, setEmailOverride] = useState('');
|
||||
|
||||
const allowed = isSuperAdmin || can('admin', 'manage_settings');
|
||||
|
||||
const queryKey = ['gdpr-exports', clientId];
|
||||
const { data, isLoading } = useQuery<ListResp>({
|
||||
queryKey,
|
||||
queryFn: () => apiFetch<ListResp>(`/api/v1/clients/${clientId}/gdpr-export`),
|
||||
enabled: open && allowed,
|
||||
refetchInterval: open && allowed ? 5_000 : false,
|
||||
});
|
||||
|
||||
const request = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch(`/api/v1/clients/${clientId}/gdpr-export`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
emailToClient,
|
||||
emailOverride: emailOverride.trim() || null,
|
||||
},
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Export queued — refresh in ~30 seconds');
|
||||
qc.invalidateQueries({ queryKey });
|
||||
setEmailOverride('');
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to queue export');
|
||||
},
|
||||
});
|
||||
|
||||
if (!allowed) return null;
|
||||
|
||||
async function downloadById(exportId: string) {
|
||||
try {
|
||||
const res = await apiFetch<{ data: { url: string } }>(
|
||||
`/api/v1/clients/${clientId}/gdpr-export/${exportId}`,
|
||||
);
|
||||
window.open(res.data.url, '_blank', 'noopener');
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to fetch download URL');
|
||||
}
|
||||
}
|
||||
|
||||
const rows = data?.data ?? [];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<FileDown className="mr-1.5 h-3.5 w-3.5" />
|
||||
GDPR export
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Personal data export</DialogTitle>
|
||||
<DialogDescription>
|
||||
Bundles every record we hold about this client (profile, contacts, addresses, yachts,
|
||||
companies, interests, reservations, invoices, documents, audit log) into a ZIP with JSON
|
||||
and HTML copies. Used to satisfy GDPR Article 15 access requests.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-2 rounded-lg border border-border bg-muted/30 p-3">
|
||||
<Checkbox
|
||||
id="email-to-client"
|
||||
checked={emailToClient}
|
||||
onCheckedChange={(v) => setEmailToClient(v === true)}
|
||||
/>
|
||||
<div className="space-y-2 flex-1 min-w-0">
|
||||
<Label htmlFor="email-to-client" className="text-sm font-medium">
|
||||
Email the bundle when ready
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Sends a 7-day signed download link to the client's primary email — or to the
|
||||
override below.
|
||||
</p>
|
||||
{emailToClient ? (
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="optional override (defaults to primary contact)"
|
||||
value={emailOverride}
|
||||
onChange={(e) => setEmailOverride(e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={() => request.mutate()} disabled={request.isPending}>
|
||||
{request.isPending ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<FileDown className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
Queue export
|
||||
</Button>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Recent exports</h4>
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||
) : rows.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No exports yet.</p>
|
||||
) : (
|
||||
<ul className="text-sm divide-y border rounded-lg">
|
||||
{rows.map((r) => (
|
||||
<li key={r.id} className="flex items-center gap-2 py-2 px-3 hover:bg-muted/50">
|
||||
<Badge variant={STATUS_VARIANT[r.status]} className="capitalize text-xs">
|
||||
{r.status}
|
||||
</Badge>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs">
|
||||
Requested {format(new Date(r.createdAt), 'MMM d, yyyy HH:mm')}
|
||||
</div>
|
||||
{r.sentTo ? (
|
||||
<div className="text-xs text-muted-foreground inline-flex items-center gap-1">
|
||||
<Mail className="h-3 w-3" />
|
||||
Sent to {r.sentTo}
|
||||
</div>
|
||||
) : null}
|
||||
{r.error ? (
|
||||
<div className="text-xs text-destructive truncate">{r.error}</div>
|
||||
) : null}
|
||||
</div>
|
||||
{(r.status === 'ready' || r.status === 'sent') && r.storageKey ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => downloadById(r.id)}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,8 @@ export interface CompanyRow {
|
||||
legalName: string | null;
|
||||
taxId: string | null;
|
||||
registrationNumber: string | null;
|
||||
incorporationCountry: string | null;
|
||||
incorporationCountryIso: string | null;
|
||||
incorporationSubdivisionIso: string | null;
|
||||
incorporationDate: string | null;
|
||||
status: string;
|
||||
billingEmail: string | null;
|
||||
|
||||
@@ -20,7 +20,8 @@ interface CompanyDetailHeaderCompany {
|
||||
legalName: string | null;
|
||||
taxId: string | null;
|
||||
registrationNumber: string | null;
|
||||
incorporationCountry: string | null;
|
||||
incorporationCountryIso: string | null;
|
||||
incorporationSubdivisionIso: string | null;
|
||||
incorporationDate: string | null;
|
||||
status: string;
|
||||
billingEmail: string | null;
|
||||
@@ -130,7 +131,8 @@ export function CompanyDetailHeader({ company }: CompanyDetailHeaderProps) {
|
||||
legalName: company.legalName,
|
||||
taxId: company.taxId,
|
||||
registrationNumber: company.registrationNumber,
|
||||
incorporationCountry: company.incorporationCountry,
|
||||
incorporationCountryIso: company.incorporationCountryIso,
|
||||
incorporationSubdivisionIso: company.incorporationSubdivisionIso,
|
||||
incorporationDate: company.incorporationDate,
|
||||
status: company.status,
|
||||
billingEmail: company.billingEmail,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { CompanyDetailHeader } from '@/components/companies/company-detail-heade
|
||||
import { getCompanyTabs } from '@/components/companies/company-tabs';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import type { Address } from '@/components/shared/addresses-editor';
|
||||
|
||||
export interface CompanyData {
|
||||
id: string;
|
||||
@@ -16,7 +17,8 @@ export interface CompanyData {
|
||||
legalName: string | null;
|
||||
taxId: string | null;
|
||||
registrationNumber: string | null;
|
||||
incorporationCountry: string | null;
|
||||
incorporationCountryIso: string | null;
|
||||
incorporationSubdivisionIso: string | null;
|
||||
incorporationDate: string | null;
|
||||
status: string;
|
||||
billingEmail: string | null;
|
||||
@@ -24,6 +26,8 @@ export interface CompanyData {
|
||||
archivedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
tags?: Array<{ id: string; name: string; color: string }>;
|
||||
addresses?: Address[];
|
||||
}
|
||||
|
||||
interface CompanyDetailProps {
|
||||
|
||||
@@ -21,8 +21,11 @@ import {
|
||||
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { TagPicker } from '@/components/shared/tag-picker';
|
||||
import { CountryCombobox } from '@/components/shared/country-combobox';
|
||||
import { SubdivisionCombobox } from '@/components/shared/subdivision-combobox';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { createCompanySchema, type CreateCompanyInput } from '@/lib/validators/companies';
|
||||
import type { CountryCode } from '@/lib/i18n/countries';
|
||||
|
||||
type CompanyStatus = 'active' | 'dissolved';
|
||||
|
||||
@@ -38,7 +41,8 @@ interface CompanyFormProps {
|
||||
legalName: string | null;
|
||||
taxId: string | null;
|
||||
registrationNumber: string | null;
|
||||
incorporationCountry: string | null;
|
||||
incorporationCountryIso: string | null;
|
||||
incorporationSubdivisionIso: string | null;
|
||||
incorporationDate: string | null;
|
||||
status: string;
|
||||
billingEmail: string | null;
|
||||
@@ -78,7 +82,8 @@ export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
|
||||
legalName: company.legalName ?? undefined,
|
||||
taxId: company.taxId ?? undefined,
|
||||
registrationNumber: company.registrationNumber ?? undefined,
|
||||
incorporationCountry: company.incorporationCountry ?? undefined,
|
||||
incorporationCountryIso: company.incorporationCountryIso ?? undefined,
|
||||
incorporationSubdivisionIso: company.incorporationSubdivisionIso ?? undefined,
|
||||
incorporationDate: company.incorporationDate
|
||||
? new Date(company.incorporationDate)
|
||||
: undefined,
|
||||
@@ -169,7 +174,24 @@ export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Incorporation Country</Label>
|
||||
<Input {...register('incorporationCountry')} placeholder="e.g. MT" />
|
||||
<CountryCombobox
|
||||
value={watch('incorporationCountryIso')}
|
||||
onChange={(iso) => {
|
||||
setValue('incorporationCountryIso', iso ?? undefined);
|
||||
// Wipe subdivision when country flips — codes are country-scoped.
|
||||
setValue('incorporationSubdivisionIso', undefined);
|
||||
}}
|
||||
data-testid="company-incorp-country"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Incorporation Region</Label>
|
||||
<SubdivisionCombobox
|
||||
value={watch('incorporationSubdivisionIso')}
|
||||
onChange={(code) => setValue('incorporationSubdivisionIso', code ?? undefined)}
|
||||
country={(watch('incorporationCountryIso') as CountryCode | undefined) ?? null}
|
||||
data-testid="company-incorp-subdivision"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Incorporation Date</Label>
|
||||
|
||||
@@ -145,7 +145,8 @@ export function CompanyList() {
|
||||
legalName: editCompany.legalName,
|
||||
taxId: editCompany.taxId,
|
||||
registrationNumber: editCompany.registrationNumber,
|
||||
incorporationCountry: editCompany.incorporationCountry,
|
||||
incorporationCountryIso: editCompany.incorporationCountryIso,
|
||||
incorporationSubdivisionIso: editCompany.incorporationSubdivisionIso,
|
||||
incorporationDate: editCompany.incorporationDate,
|
||||
status: editCompany.status,
|
||||
billingEmail: editCompany.billingEmail,
|
||||
|
||||
@@ -5,11 +5,15 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import type { DetailTab } from '@/components/shared/detail-layout';
|
||||
import { EmptyState } from '@/components/shared/empty-state';
|
||||
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
||||
import { InlineCountryField } from '@/components/shared/inline-country-field';
|
||||
import { SubdivisionCombobox } from '@/components/shared/subdivision-combobox';
|
||||
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
|
||||
import { NotesList } from '@/components/shared/notes-list';
|
||||
import { CompanyMembersTab } from '@/components/companies/company-members-tab';
|
||||
import { CompanyOwnedYachtsTab } from '@/components/companies/company-owned-yachts-tab';
|
||||
import { AddressesEditor, type Address } from '@/components/shared/addresses-editor';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import type { CountryCode } from '@/lib/i18n/countries';
|
||||
|
||||
type CompanyPatchField =
|
||||
| 'name'
|
||||
@@ -17,6 +21,8 @@ type CompanyPatchField =
|
||||
| 'taxId'
|
||||
| 'registrationNumber'
|
||||
| 'incorporationCountry'
|
||||
| 'incorporationCountryIso'
|
||||
| 'incorporationSubdivisionIso'
|
||||
| 'incorporationDate'
|
||||
| 'status'
|
||||
| 'billingEmail'
|
||||
@@ -33,12 +39,14 @@ interface CompanyTabsCompany {
|
||||
legalName: string | null;
|
||||
taxId: string | null;
|
||||
registrationNumber: string | null;
|
||||
incorporationCountry: string | null;
|
||||
incorporationCountryIso: string | null;
|
||||
incorporationSubdivisionIso: string | null;
|
||||
incorporationDate: string | null;
|
||||
status: string;
|
||||
billingEmail: string | null;
|
||||
notes: string | null;
|
||||
tags?: Array<{ id: string; name: string; color: string }>;
|
||||
addresses?: Address[];
|
||||
}
|
||||
|
||||
interface CompanyTabsOptions {
|
||||
@@ -114,9 +122,26 @@ function OverviewTab({ companyId, company }: { companyId: string; company: Compa
|
||||
/>
|
||||
</EditableRow>
|
||||
<EditableRow label="Incorporation Country">
|
||||
<InlineEditableField
|
||||
value={company.incorporationCountry}
|
||||
onSave={save('incorporationCountry')}
|
||||
<InlineCountryField
|
||||
value={company.incorporationCountryIso}
|
||||
onSave={async (iso) => {
|
||||
// Wipe subdivision when country flips — codes are country-scoped.
|
||||
await mutation.mutateAsync({
|
||||
incorporationCountryIso: iso,
|
||||
incorporationSubdivisionIso: null,
|
||||
});
|
||||
}}
|
||||
data-testid="company-incorp-country-inline"
|
||||
/>
|
||||
</EditableRow>
|
||||
<EditableRow label="Incorporation Region">
|
||||
<SubdivisionCombobox
|
||||
value={company.incorporationSubdivisionIso}
|
||||
onChange={(code) => {
|
||||
void mutation.mutateAsync({ incorporationSubdivisionIso: code });
|
||||
}}
|
||||
country={(company.incorporationCountryIso as CountryCode | null) ?? null}
|
||||
data-testid="company-incorp-subdivision-inline"
|
||||
/>
|
||||
</EditableRow>
|
||||
<EditableRow label="Incorporation Date">
|
||||
@@ -188,10 +213,12 @@ export function getCompanyTabs({
|
||||
{
|
||||
id: 'addresses',
|
||||
label: 'Addresses',
|
||||
badge: company.addresses?.length ?? 0,
|
||||
content: (
|
||||
<EmptyState
|
||||
title="Addresses"
|
||||
description="Company addresses coming soon — the addresses endpoint is pending wiring."
|
||||
<AddressesEditor
|
||||
endpoint={`/api/v1/companies/${companyId}/addresses`}
|
||||
invalidateKey={['companies', companyId]}
|
||||
addresses={company.addresses ?? []}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
134
src/components/dashboard/chart-card.tsx
Normal file
134
src/components/dashboard/chart-card.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, type ReactNode } from 'react';
|
||||
import { MoreHorizontal, Download, Image as ImageIcon } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ChartCardProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
/** Filename stem used for both CSV + PNG exports (no extension). */
|
||||
exportFilename: string;
|
||||
/** Returns CSV content for the current chart data, or null when nothing to export. */
|
||||
toCsv?: () => string | null;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function downloadBlob(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function exportContainerAsPng(container: HTMLElement, filename: string) {
|
||||
const svg = container.querySelector('svg');
|
||||
if (!svg) return;
|
||||
const clone = svg.cloneNode(true) as SVGSVGElement;
|
||||
const { width, height } = svg.getBoundingClientRect();
|
||||
clone.setAttribute('width', String(width));
|
||||
clone.setAttribute('height', String(height));
|
||||
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||
const xml = new XMLSerializer().serializeToString(clone);
|
||||
const svgBlob = new Blob([xml], { type: 'image/svg+xml;charset=utf-8' });
|
||||
const url = URL.createObjectURL(svgBlob);
|
||||
const img = new Image();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
img.onload = () => resolve();
|
||||
img.onerror = () => reject(new Error('Failed to load chart for export'));
|
||||
img.src = url;
|
||||
});
|
||||
const canvas = document.createElement('canvas');
|
||||
const dpr = window.devicePixelRatio ?? 1;
|
||||
canvas.width = width * dpr;
|
||||
canvas.height = height * dpr;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
URL.revokeObjectURL(url);
|
||||
return;
|
||||
}
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
URL.revokeObjectURL(url);
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) downloadBlob(blob, filename);
|
||||
}, 'image/png');
|
||||
}
|
||||
|
||||
export function ChartCard({
|
||||
title,
|
||||
description,
|
||||
exportFilename,
|
||||
toCsv,
|
||||
children,
|
||||
className,
|
||||
}: ChartCardProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
function onDownloadCsv() {
|
||||
const csv = toCsv?.();
|
||||
if (!csv) return;
|
||||
downloadBlob(new Blob([csv], { type: 'text/csv;charset=utf-8' }), `${exportFilename}.csv`);
|
||||
}
|
||||
|
||||
function onDownloadPng() {
|
||||
if (containerRef.current) {
|
||||
void exportContainerAsPng(containerRef.current, `${exportFilename}.png`);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={cn('h-full', className)}>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
|
||||
<div>
|
||||
<CardTitle className="text-base">{title}</CardTitle>
|
||||
{description ? <p className="mt-1 text-xs text-muted-foreground">{description}</p> : null}
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
aria-label="Chart options"
|
||||
data-testid="chart-menu"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
{toCsv ? (
|
||||
<DropdownMenuItem onSelect={onDownloadCsv}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download CSV
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem onSelect={onDownloadPng}>
|
||||
<ImageIcon className="mr-2 h-4 w-4" />
|
||||
Download PNG
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div ref={containerRef}>{children}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,40 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { KpiCardsWithBoundary } from './kpi-cards';
|
||||
import { PipelineChart } from './pipeline-chart';
|
||||
import { RevenueForecast } from './revenue-forecast';
|
||||
import { ActivityFeed } from './activity-feed';
|
||||
import { DateRangePicker } from './date-range-picker';
|
||||
import { PipelineFunnelChart } from './pipeline-funnel-chart';
|
||||
import { OccupancyTimelineChart } from './occupancy-timeline-chart';
|
||||
import { RevenueBreakdownChart } from './revenue-breakdown-chart';
|
||||
import { LeadSourceChart } from './lead-source-chart';
|
||||
import { WidgetErrorBoundary } from './widget-error-boundary';
|
||||
import { AlertRail } from '@/components/alerts/alert-rail';
|
||||
import type { DateRange } from '@/lib/services/analytics.service';
|
||||
|
||||
const RANGE_LABELS: Record<DateRange, string> = {
|
||||
today: 'Today',
|
||||
'7d': 'Last 7 days',
|
||||
'30d': 'Last 30 days',
|
||||
'90d': 'Last 90 days',
|
||||
};
|
||||
|
||||
export function DashboardShell() {
|
||||
const [range, setRange] = useState<DateRange>('30d');
|
||||
|
||||
useRealtimeInvalidation({
|
||||
'interest:stageChanged': [
|
||||
['dashboard', 'pipeline'],
|
||||
['dashboard', 'forecast'],
|
||||
['analytics', 'pipeline_funnel', range],
|
||||
['analytics', 'lead_source_attribution', range],
|
||||
['dashboard', 'kpis'],
|
||||
],
|
||||
'client:created': [['dashboard', 'kpis']],
|
||||
'berth:statusChanged': [
|
||||
['analytics', 'occupancy_timeline', range],
|
||||
['dashboard', 'kpis'],
|
||||
['dashboard', 'forecast'],
|
||||
],
|
||||
});
|
||||
|
||||
@@ -26,26 +44,37 @@ export function DashboardShell() {
|
||||
title="Dashboard"
|
||||
eyebrow="Overview"
|
||||
description="Live snapshot of your marina activity"
|
||||
kpiLine={<span>Last 30 days</span>}
|
||||
kpiLine={<span>{RANGE_LABELS[range]}</span>}
|
||||
variant="gradient"
|
||||
actions={<DateRangePicker value={range} onChange={setRange} />}
|
||||
/>
|
||||
|
||||
{/* Row 1: KPI cards */}
|
||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<KpiCardsWithBoundary />
|
||||
</div>
|
||||
|
||||
{/* Row 2: Pipeline chart + Revenue forecast */}
|
||||
<div className="grid gap-4 grid-cols-1 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2">
|
||||
<PipelineChart />
|
||||
</div>
|
||||
<div className="lg:col-span-1">
|
||||
<RevenueForecast />
|
||||
<div className="grid gap-4 grid-cols-1 xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<div className="grid gap-4 grid-cols-1 lg:grid-cols-2">
|
||||
<WidgetErrorBoundary>
|
||||
<PipelineFunnelChart range={range} />
|
||||
</WidgetErrorBoundary>
|
||||
<WidgetErrorBoundary>
|
||||
<OccupancyTimelineChart range={range} />
|
||||
</WidgetErrorBoundary>
|
||||
<WidgetErrorBoundary>
|
||||
<RevenueBreakdownChart range={range} />
|
||||
</WidgetErrorBoundary>
|
||||
<WidgetErrorBoundary>
|
||||
<LeadSourceChart range={range} />
|
||||
</WidgetErrorBoundary>
|
||||
</div>
|
||||
<aside className="min-w-0">
|
||||
<WidgetErrorBoundary>
|
||||
<AlertRail />
|
||||
</WidgetErrorBoundary>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{/* Row 3: Activity feed */}
|
||||
<ActivityFeed />
|
||||
</div>
|
||||
);
|
||||
|
||||
55
src/components/dashboard/date-range-picker.tsx
Normal file
55
src/components/dashboard/date-range-picker.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { DateRange } from '@/lib/services/analytics.service';
|
||||
|
||||
interface DateRangePickerProps {
|
||||
value: DateRange;
|
||||
onChange: (next: DateRange) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const OPTIONS: Array<{ value: DateRange; label: string }> = [
|
||||
{ value: 'today', label: 'Today' },
|
||||
{ value: '7d', label: '7d' },
|
||||
{ value: '30d', label: '30d' },
|
||||
{ value: '90d', label: '90d' },
|
||||
];
|
||||
|
||||
export function DateRangePicker({ value, onChange, className }: DateRangePickerProps) {
|
||||
return (
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Date range"
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-lg border border-border bg-muted/40 p-0.5 shadow-xs',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{OPTIONS.map((opt) => {
|
||||
const active = opt.value === value;
|
||||
return (
|
||||
<Button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onChange(opt.value)}
|
||||
className={cn(
|
||||
'h-7 px-3 text-xs font-medium transition-all duration-base ease-spring',
|
||||
active
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
data-testid={`range-${opt.value}`}
|
||||
>
|
||||
{opt.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
src/components/dashboard/lead-source-chart.tsx
Normal file
89
src/components/dashboard/lead-source-chart.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
'use client';
|
||||
|
||||
import { Cell, Legend, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts';
|
||||
|
||||
import { CardSkeleton } from '@/components/shared/loading-skeleton';
|
||||
import { EmptyState } from '@/components/shared/empty-state';
|
||||
import { ChartCard } from './chart-card';
|
||||
import { useLeadSource } from './use-analytics';
|
||||
import type { DateRange } from '@/lib/services/analytics.service';
|
||||
|
||||
interface Props {
|
||||
range: DateRange;
|
||||
}
|
||||
|
||||
const COLORS = [
|
||||
'hsl(var(--chart-1))',
|
||||
'hsl(var(--chart-2))',
|
||||
'hsl(var(--chart-3))',
|
||||
'hsl(var(--chart-4))',
|
||||
'hsl(var(--chart-5))',
|
||||
];
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
website: 'Website',
|
||||
referral: 'Referral',
|
||||
manual: 'Manual',
|
||||
social: 'Social',
|
||||
unspecified: 'Unspecified',
|
||||
};
|
||||
|
||||
export function LeadSourceChart({ range }: Props) {
|
||||
const { data, isLoading } = useLeadSource(range);
|
||||
const slices = data?.slices ?? [];
|
||||
|
||||
function toCsv(): string | null {
|
||||
if (!slices.length) return null;
|
||||
const header = 'source,count';
|
||||
const rows = slices.map((s) => `${s.source},${s.count}`);
|
||||
return [header, ...rows].join('\n');
|
||||
}
|
||||
|
||||
const chartData = slices.map((s) => ({
|
||||
name: SOURCE_LABELS[s.source] ?? s.source,
|
||||
value: s.count,
|
||||
}));
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
title="Lead Source Attribution"
|
||||
description="Where new interests came from"
|
||||
exportFilename={`lead-source-${range}`}
|
||||
toCsv={toCsv}
|
||||
>
|
||||
{isLoading ? (
|
||||
<CardSkeleton />
|
||||
) : !slices.length ? (
|
||||
<EmptyState title="No interests in range" />
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={chartData}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={90}
|
||||
innerRadius={50}
|
||||
paddingAngle={2}
|
||||
>
|
||||
{chartData.map((_, i) => (
|
||||
<Cell key={i} fill={COLORS[i % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: 'hsl(var(--popover))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
98
src/components/dashboard/occupancy-timeline-chart.tsx
Normal file
98
src/components/dashboard/occupancy-timeline-chart.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
import { CardSkeleton } from '@/components/shared/loading-skeleton';
|
||||
import { EmptyState } from '@/components/shared/empty-state';
|
||||
import { ChartCard } from './chart-card';
|
||||
import { useOccupancy } from './use-analytics';
|
||||
import type { DateRange } from '@/lib/services/analytics.service';
|
||||
|
||||
interface Props {
|
||||
range: DateRange;
|
||||
}
|
||||
|
||||
function shortDate(iso: string) {
|
||||
const d = new Date(`${iso}T00:00:00`);
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
export function OccupancyTimelineChart({ range }: Props) {
|
||||
const { data, isLoading } = useOccupancy(range);
|
||||
const points = data?.points ?? [];
|
||||
const noBerths = points.length > 0 && points[0]?.total === 0;
|
||||
|
||||
function toCsv(): string | null {
|
||||
if (!points.length) return null;
|
||||
const header = 'date,occupied,total,occupancy_pct';
|
||||
const rows = points.map((p) => `${p.date},${p.occupied},${p.total},${p.occupancyPct}`);
|
||||
return [header, ...rows].join('\n');
|
||||
}
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
title="Occupancy Timeline"
|
||||
description="Daily berth occupancy across the range"
|
||||
exportFilename={`occupancy-timeline-${range}`}
|
||||
toCsv={toCsv}
|
||||
>
|
||||
{isLoading ? (
|
||||
<CardSkeleton />
|
||||
) : noBerths ? (
|
||||
<EmptyState title="No berths configured" description="Add berths to see occupancy." />
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<AreaChart
|
||||
data={points.map((p) => ({ ...p, label: shortDate(p.date) }))}
|
||||
margin={{ top: 8, right: 8, left: -16, bottom: 8 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="occupancyGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="hsl(var(--chart-2))" stopOpacity={0.4} />
|
||||
<stop offset="100%" stopColor="hsl(var(--chart-2))" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
|
||||
minTickGap={20}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
|
||||
domain={[0, 100]}
|
||||
tickFormatter={(v: number) => `${v}%`}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: 'hsl(var(--popover))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
fontSize: 12,
|
||||
}}
|
||||
formatter={(value, _name, item) => {
|
||||
const p = item?.payload as { occupied?: number; total?: number } | undefined;
|
||||
return [`${value}% (${p?.occupied ?? 0}/${p?.total ?? 0})`, 'Occupancy'];
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="occupancyPct"
|
||||
stroke="hsl(var(--chart-2))"
|
||||
strokeWidth={2}
|
||||
fill="url(#occupancyGradient)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
89
src/components/dashboard/pipeline-funnel-chart.tsx
Normal file
89
src/components/dashboard/pipeline-funnel-chart.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
'use client';
|
||||
|
||||
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
|
||||
import { CardSkeleton } from '@/components/shared/loading-skeleton';
|
||||
import { EmptyState } from '@/components/shared/empty-state';
|
||||
import { ChartCard } from './chart-card';
|
||||
import { useFunnel } from './use-analytics';
|
||||
import type { DateRange } from '@/lib/services/analytics.service';
|
||||
|
||||
const STAGE_LABELS: Record<string, string> = {
|
||||
open: 'Open',
|
||||
details_sent: 'Details Sent',
|
||||
in_communication: 'In Communication',
|
||||
visited: 'Visited',
|
||||
signed_eoi_nda: 'Signed EOI/NDA',
|
||||
deposit_10pct: 'Deposit 10%',
|
||||
contract: 'Contract',
|
||||
completed: 'Completed',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
range: DateRange;
|
||||
}
|
||||
|
||||
export function PipelineFunnelChart({ range }: Props) {
|
||||
const { data, isLoading } = useFunnel(range);
|
||||
|
||||
const stages = data?.stages ?? [];
|
||||
const chartData = stages.map((s) => ({
|
||||
stage: STAGE_LABELS[s.stage] ?? s.stage,
|
||||
count: s.count,
|
||||
conversionPct: s.conversionPct,
|
||||
}));
|
||||
const allZero = stages.every((s) => s.count === 0);
|
||||
|
||||
function toCsv(): string | null {
|
||||
if (!stages.length) return null;
|
||||
const header = 'stage,count,conversion_pct';
|
||||
const rows = stages.map((s) => `${s.stage},${s.count},${s.conversionPct}`);
|
||||
return [header, ...rows].join('\n');
|
||||
}
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
title="Pipeline Funnel"
|
||||
description="Interests by stage with conversion rate vs. open"
|
||||
exportFilename={`pipeline-funnel-${range}`}
|
||||
toCsv={toCsv}
|
||||
>
|
||||
{isLoading ? (
|
||||
<CardSkeleton />
|
||||
) : allZero ? (
|
||||
<EmptyState title="No interests in range" description="Try a longer date range." />
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -16, bottom: 60 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||
<XAxis
|
||||
dataKey="stage"
|
||||
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
|
||||
angle={-40}
|
||||
textAnchor="end"
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: 'hsl(var(--popover))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
fontSize: 12,
|
||||
}}
|
||||
formatter={(value, _name, item) => {
|
||||
const pct = (item?.payload as { conversionPct?: number } | undefined)
|
||||
?.conversionPct;
|
||||
return [`${value} (${pct ?? 0}%)`, 'Count'];
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="count" fill="hsl(var(--chart-1))" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
82
src/components/dashboard/revenue-breakdown-chart.tsx
Normal file
82
src/components/dashboard/revenue-breakdown-chart.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
|
||||
import { CardSkeleton } from '@/components/shared/loading-skeleton';
|
||||
import { EmptyState } from '@/components/shared/empty-state';
|
||||
import { ChartCard } from './chart-card';
|
||||
import { useRevenue } from './use-analytics';
|
||||
import type { DateRange } from '@/lib/services/analytics.service';
|
||||
|
||||
interface Props {
|
||||
range: DateRange;
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
draft: 'Draft',
|
||||
sent: 'Sent',
|
||||
paid: 'Paid',
|
||||
overdue: 'Overdue',
|
||||
cancelled: 'Cancelled',
|
||||
};
|
||||
|
||||
export function RevenueBreakdownChart({ range }: Props) {
|
||||
const { data, isLoading } = useRevenue(range);
|
||||
const bars = data?.bars ?? [];
|
||||
|
||||
function toCsv(): string | null {
|
||||
if (!bars.length) return null;
|
||||
const header = 'status,currency,amount';
|
||||
const rows = bars.map((b) => `${b.status},${b.currency},${b.amount}`);
|
||||
return [header, ...rows].join('\n');
|
||||
}
|
||||
|
||||
const chartData = bars.map((b) => ({
|
||||
label: `${STATUS_LABELS[b.status] ?? b.status} (${b.currency})`,
|
||||
amount: b.amount,
|
||||
currency: b.currency,
|
||||
}));
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
title="Revenue Breakdown"
|
||||
description="Invoice totals grouped by status and currency"
|
||||
exportFilename={`revenue-breakdown-${range}`}
|
||||
toCsv={toCsv}
|
||||
>
|
||||
{isLoading ? (
|
||||
<CardSkeleton />
|
||||
) : !bars.length ? (
|
||||
<EmptyState title="No invoices in range" description="Invoices appear here once issued." />
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -8, bottom: 40 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
|
||||
angle={-30}
|
||||
textAnchor="end"
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: 'hsl(var(--popover))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
fontSize: 12,
|
||||
}}
|
||||
formatter={(value, _name, item) => {
|
||||
const c = (item?.payload as { currency?: string } | undefined)?.currency ?? '';
|
||||
const num = typeof value === 'number' ? value : Number(value);
|
||||
return [`${num.toLocaleString()} ${c}`, 'Amount'];
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="amount" fill="hsl(var(--chart-3))" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
42
src/components/dashboard/use-analytics.ts
Normal file
42
src/components/dashboard/use-analytics.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import type {
|
||||
DateRange,
|
||||
LeadSourceAttributionData,
|
||||
MetricBase,
|
||||
OccupancyTimelineData,
|
||||
PipelineFunnelData,
|
||||
RevenueBreakdownData,
|
||||
} from '@/lib/services/analytics.service';
|
||||
|
||||
interface MetricResponse<T> {
|
||||
metric: MetricBase;
|
||||
range: DateRange;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export function useAnalyticsMetric<T>(metric: MetricBase, range: DateRange) {
|
||||
return useQuery<T>({
|
||||
queryKey: ['analytics', metric, range],
|
||||
queryFn: async () => {
|
||||
const res = await apiFetch<MetricResponse<T>>(
|
||||
`/api/v1/analytics?metric=${metric}&range=${range}`,
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
staleTime: 60_000,
|
||||
retry: 2,
|
||||
});
|
||||
}
|
||||
|
||||
export const useFunnel = (range: DateRange) =>
|
||||
useAnalyticsMetric<PipelineFunnelData>('pipeline_funnel', range);
|
||||
export const useOccupancy = (range: DateRange) =>
|
||||
useAnalyticsMetric<OccupancyTimelineData>('occupancy_timeline', range);
|
||||
export const useRevenue = (range: DateRange) =>
|
||||
useAnalyticsMetric<RevenueBreakdownData>('revenue_breakdown', range);
|
||||
export const useLeadSource = (range: DateRange) =>
|
||||
useAnalyticsMetric<LeadSourceAttributionData>('lead_source_attribution', range);
|
||||
@@ -35,6 +35,7 @@ interface HubDoc {
|
||||
|
||||
interface HubCounts {
|
||||
all: number;
|
||||
eoi_queue: number;
|
||||
awaiting_them: number;
|
||||
awaiting_me: number;
|
||||
completed: number;
|
||||
@@ -43,6 +44,7 @@ interface HubCounts {
|
||||
|
||||
const TAB_LABELS: Record<DocumentsHubTab, string> = {
|
||||
all: 'All',
|
||||
eoi_queue: 'EOI queue',
|
||||
awaiting_them: 'Awaiting them',
|
||||
awaiting_me: 'Awaiting me',
|
||||
completed: 'Completed',
|
||||
@@ -118,6 +120,7 @@ export function DocumentsHub({ portSlug }: DocumentsHubProps) {
|
||||
|
||||
const counts: HubCounts = countsResp?.data ?? {
|
||||
all: 0,
|
||||
eoi_queue: 0,
|
||||
awaiting_them: 0,
|
||||
awaiting_me: 0,
|
||||
completed: 0,
|
||||
|
||||
@@ -29,6 +29,9 @@ export interface ExpenseRow {
|
||||
receiptFileIds: string[] | null;
|
||||
archivedAt: string | null;
|
||||
createdAt: string;
|
||||
/** Set by the dedup engine when this expense looks like a duplicate of another. */
|
||||
duplicateOf: string | null;
|
||||
dedupScannedAt: string | null;
|
||||
}
|
||||
|
||||
const PAYMENT_STATUS_COLORS: Record<string, string> = {
|
||||
@@ -94,7 +97,8 @@ export function getExpenseColumns({
|
||||
cell: ({ row }) =>
|
||||
row.original.amountUsd ? (
|
||||
<span className="text-sm text-muted-foreground tabular-nums">
|
||||
${Number(row.original.amountUsd).toLocaleString('en-US', {
|
||||
$
|
||||
{Number(row.original.amountUsd).toLocaleString('en-US', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
@@ -125,10 +129,7 @@ export function getExpenseColumns({
|
||||
const status = (getValue() as string | null) ?? 'unpaid';
|
||||
const colorClass = PAYMENT_STATUS_COLORS[status] ?? '';
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`capitalize text-xs border ${colorClass}`}
|
||||
>
|
||||
<Badge variant="outline" className={`capitalize text-xs border ${colorClass}`}>
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
@@ -162,10 +163,7 @@ export function getExpenseColumns({
|
||||
<Pencil className="mr-2 h-3.5 w-3.5" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => onArchive(row.original)}
|
||||
>
|
||||
<DropdownMenuItem className="text-destructive" onClick={() => onArchive(row.original)}>
|
||||
<Archive className="mr-2 h-3.5 w-3.5" />
|
||||
Archive
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import type { ExpenseRow } from './expense-columns';
|
||||
import { ExpenseDuplicateBanner } from './expense-duplicate-banner';
|
||||
|
||||
const PAYMENT_STATUS_COLORS: Record<string, string> = {
|
||||
unpaid: 'bg-red-100 text-red-700 border-red-200',
|
||||
@@ -52,9 +53,7 @@ export function ExpenseDetail({ expenseId, onEdit, onArchived }: ExpenseDetailPr
|
||||
|
||||
if (error || !data?.data) {
|
||||
return (
|
||||
<div className="p-6 text-center text-muted-foreground">
|
||||
Failed to load expense details.
|
||||
</div>
|
||||
<div className="p-6 text-center text-muted-foreground">Failed to load expense details.</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,6 +63,7 @@ export function ExpenseDetail({ expenseId, onEdit, onArchived }: ExpenseDetailPr
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ExpenseDuplicateBanner expense={expense} />
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">
|
||||
@@ -107,10 +107,12 @@ export function ExpenseDetail({ expenseId, onEdit, onArchived }: ExpenseDetailPr
|
||||
</p>
|
||||
{expense.amountUsd && expense.currency !== 'USD' && (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
≈ ${Number(expense.amountUsd).toLocaleString('en-US', {
|
||||
≈ $
|
||||
{Number(expense.amountUsd).toLocaleString('en-US', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})} USD
|
||||
})}{' '}
|
||||
USD
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -121,10 +123,7 @@ export function ExpenseDetail({ expenseId, onEdit, onArchived }: ExpenseDetailPr
|
||||
<CardTitle className="text-sm font-medium">Payment Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`capitalize text-sm border ${statusColor}`}
|
||||
>
|
||||
<Badge variant="outline" className={`capitalize text-sm border ${statusColor}`}>
|
||||
{status}
|
||||
</Badge>
|
||||
</CardContent>
|
||||
@@ -138,15 +137,11 @@ export function ExpenseDetail({ expenseId, onEdit, onArchived }: ExpenseDetailPr
|
||||
<CardContent className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Category</span>
|
||||
<p className="mt-0.5 capitalize">
|
||||
{expense.category?.replace(/_/g, ' ') ?? '—'}
|
||||
</p>
|
||||
<p className="mt-0.5 capitalize">{expense.category?.replace(/_/g, ' ') ?? '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Payment Method</span>
|
||||
<p className="mt-0.5 capitalize">
|
||||
{expense.paymentMethod?.replace(/_/g, ' ') ?? '—'}
|
||||
</p>
|
||||
<p className="mt-0.5 capitalize">{expense.paymentMethod?.replace(/_/g, ' ') ?? '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Payer</span>
|
||||
|
||||
121
src/components/expenses/expense-duplicate-banner.tsx
Normal file
121
src/components/expenses/expense-duplicate-banner.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { AlertTriangle, ExternalLink } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ExpenseRow } from './expense-columns';
|
||||
|
||||
interface Props {
|
||||
expense: ExpenseRow;
|
||||
}
|
||||
|
||||
export function ExpenseDuplicateBanner({ expense }: Props) {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
const queryClient = useQueryClient();
|
||||
const [resolving, setResolving] = useState(false);
|
||||
|
||||
// Fetch the candidate expense for context.
|
||||
const { data: candidateResp } = useQuery<{ data: ExpenseRow }>({
|
||||
queryKey: ['expenses', expense.duplicateOf],
|
||||
queryFn: () => apiFetch(`/api/v1/expenses/${expense.duplicateOf}`),
|
||||
enabled: Boolean(expense.duplicateOf),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const candidate = candidateResp?.data;
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['expenses'] });
|
||||
};
|
||||
|
||||
const merge = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch(`/api/v1/expenses/${expense.id}/merge`, {
|
||||
method: 'POST',
|
||||
body: { targetId: expense.duplicateOf },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
setResolving(false);
|
||||
},
|
||||
});
|
||||
|
||||
const clear = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch(`/api/v1/expenses/${expense.id}/clear-duplicate`, { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
setResolving(false);
|
||||
},
|
||||
});
|
||||
|
||||
if (!expense.duplicateOf) return null;
|
||||
|
||||
const candidateLabel = candidate
|
||||
? `${candidate.establishmentName ?? 'Unnamed expense'} · ${
|
||||
candidate.amount
|
||||
} ${candidate.currency} · ${format(new Date(candidate.expenseDate), 'd MMM yyyy')}`
|
||||
: 'a previously recorded expense';
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="expense-duplicate-banner"
|
||||
className={cn(
|
||||
'flex flex-col gap-2 rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-900',
|
||||
'sm:flex-row sm:items-center sm:justify-between',
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">Looks like a duplicate</p>
|
||||
<p className="mt-0.5 text-xs text-amber-800">
|
||||
This expense matches{' '}
|
||||
<Link
|
||||
href={`/${portSlug}/expenses/${expense.duplicateOf}` as never}
|
||||
className="inline-flex items-center gap-1 font-medium underline-offset-2 hover:underline"
|
||||
>
|
||||
{candidateLabel}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden />
|
||||
</Link>
|
||||
. Merge to consolidate, or mark as not a duplicate to keep them separate.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-amber-400 bg-white"
|
||||
disabled={resolving || merge.isPending || clear.isPending}
|
||||
onClick={() => {
|
||||
setResolving(true);
|
||||
merge.mutate();
|
||||
}}
|
||||
data-testid="expense-merge-btn"
|
||||
>
|
||||
Merge them
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={resolving || merge.isPending || clear.isPending}
|
||||
onClick={() => {
|
||||
setResolving(true);
|
||||
clear.mutate();
|
||||
}}
|
||||
data-testid="expense-not-duplicate-btn"
|
||||
>
|
||||
Not a duplicate
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
FolderOpen,
|
||||
Mail,
|
||||
Bell,
|
||||
Camera,
|
||||
ShieldAlert,
|
||||
Settings,
|
||||
Shield,
|
||||
Home,
|
||||
@@ -69,6 +71,7 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
|
||||
marinaRequired: true,
|
||||
items: [
|
||||
{ href: `${base}/dashboard`, label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ href: `${base}/alerts`, label: 'Alerts', icon: ShieldAlert },
|
||||
{ href: `${base}/clients`, label: 'Clients', icon: Users },
|
||||
{ href: `${base}/yachts`, label: 'Yachts', icon: Ship },
|
||||
{ href: `${base}/companies`, label: 'Companies', icon: Building2 },
|
||||
@@ -105,6 +108,7 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
|
||||
marinaRequired: true,
|
||||
items: [
|
||||
{ href: `${base}/expenses`, label: 'Expenses', icon: Receipt },
|
||||
{ href: `${base}/scan`, label: 'Scan receipt', icon: Camera },
|
||||
{ href: `${base}/invoices`, label: 'Invoices', icon: FileText },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -19,6 +19,7 @@ import { PortSwitcher } from '@/components/layout/port-switcher';
|
||||
import { Breadcrumbs } from '@/components/layout/breadcrumbs';
|
||||
import { CommandSearch } from '@/components/search/command-search';
|
||||
import { NotificationBell } from '@/components/notifications/notification-bell';
|
||||
import { AlertBell } from '@/components/alerts/alert-bell';
|
||||
import type { Port } from '@/lib/db/schema/ports';
|
||||
|
||||
interface TopbarProps {
|
||||
@@ -87,6 +88,9 @@ export function Topbar({ ports, user }: TopbarProps) {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Phase B operational alerts — distinct from user notifications */}
|
||||
<AlertBell />
|
||||
|
||||
{/* Notification bell — real-time via socket */}
|
||||
<NotificationBell />
|
||||
|
||||
|
||||
@@ -12,8 +12,13 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
||||
import { InlineCountryField } from '@/components/shared/inline-country-field';
|
||||
import { InlineTimezoneField } from '@/components/shared/inline-timezone-field';
|
||||
import { InlinePhoneField } from '@/components/shared/inline-phone-field';
|
||||
import { SubdivisionCombobox } from '@/components/shared/subdivision-combobox';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import type { CountryCode } from '@/lib/i18n/countries';
|
||||
|
||||
interface ResidentialInterestSummary {
|
||||
id: string;
|
||||
@@ -29,7 +34,13 @@ interface ResidentialClientDetail {
|
||||
fullName: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
phoneE164: string | null;
|
||||
phoneCountry: string | null;
|
||||
nationalityIso: string | null;
|
||||
timezone: string | null;
|
||||
placeOfResidence: string | null;
|
||||
placeOfResidenceCountryIso: string | null;
|
||||
subdivisionIso: string | null;
|
||||
preferredContactMethod: string | null;
|
||||
status: string;
|
||||
source: string | null;
|
||||
@@ -130,7 +141,17 @@ export function ResidentialClientDetail({ clientId }: { clientId: string }) {
|
||||
<InlineEditableField value={client.email} onSave={save('email')} />
|
||||
</Row>
|
||||
<Row label="Phone">
|
||||
<InlineEditableField value={client.phone} onSave={save('phone')} />
|
||||
<InlinePhoneField
|
||||
e164={client.phoneE164}
|
||||
country={client.phoneCountry}
|
||||
onSave={async ({ e164, country }) => {
|
||||
await update.mutateAsync({
|
||||
phone: e164,
|
||||
phoneE164: e164,
|
||||
phoneCountry: country,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Preferred contact">
|
||||
<InlineEditableField
|
||||
@@ -140,12 +161,50 @@ export function ResidentialClientDetail({ clientId }: { clientId: string }) {
|
||||
onSave={save('preferredContactMethod')}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Nationality">
|
||||
<InlineCountryField
|
||||
value={client.nationalityIso}
|
||||
onSave={async (iso) => {
|
||||
await update.mutateAsync({ nationalityIso: iso });
|
||||
}}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Timezone">
|
||||
<InlineTimezoneField
|
||||
value={client.timezone}
|
||||
countryHint={(client.nationalityIso as CountryCode | null) ?? null}
|
||||
onSave={async (tz) => {
|
||||
await update.mutateAsync({ timezone: tz });
|
||||
}}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Place of residence">
|
||||
<InlineEditableField
|
||||
value={client.placeOfResidence}
|
||||
onSave={save('placeOfResidence')}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Country of residence">
|
||||
<InlineCountryField
|
||||
value={client.placeOfResidenceCountryIso}
|
||||
onSave={async (iso) => {
|
||||
// When country flips, clear the subdivision — codes are country-scoped.
|
||||
await update.mutateAsync({
|
||||
placeOfResidenceCountryIso: iso,
|
||||
subdivisionIso: null,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Region">
|
||||
<SubdivisionCombobox
|
||||
value={client.subdivisionIso}
|
||||
onChange={(code) => {
|
||||
void update.mutateAsync({ subdivisionIso: code });
|
||||
}}
|
||||
country={(client.placeOfResidenceCountryIso as CountryCode | null) ?? null}
|
||||
/>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
|
||||
@@ -12,8 +12,13 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { CountryCombobox } from '@/components/shared/country-combobox';
|
||||
import { TimezoneCombobox } from '@/components/shared/timezone-combobox';
|
||||
import { SubdivisionCombobox } from '@/components/shared/subdivision-combobox';
|
||||
import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import type { CountryCode } from '@/lib/i18n/countries';
|
||||
|
||||
interface ResidentialClientRow {
|
||||
id: string;
|
||||
@@ -147,10 +152,26 @@ function NewResidentialClientSheet({
|
||||
const qc = useQueryClient();
|
||||
const [fullName, setFullName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [phone, setPhone] = useState<PhoneInputValue | null>(null);
|
||||
const [nationalityIso, setNationalityIso] = useState<CountryCode | null>(null);
|
||||
const [timezone, setTimezone] = useState<string | null>(null);
|
||||
const [placeOfResidence, setPlaceOfResidence] = useState('');
|
||||
const [residenceCountry, setResidenceCountry] = useState<CountryCode | null>(null);
|
||||
const [residenceSubdivision, setResidenceSubdivision] = useState<string | null>(null);
|
||||
const [notes, setNotes] = useState('');
|
||||
|
||||
function reset() {
|
||||
setFullName('');
|
||||
setEmail('');
|
||||
setPhone(null);
|
||||
setNationalityIso(null);
|
||||
setTimezone(null);
|
||||
setPlaceOfResidence('');
|
||||
setResidenceCountry(null);
|
||||
setResidenceSubdivision(null);
|
||||
setNotes('');
|
||||
}
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch('/api/v1/residential/clients', {
|
||||
@@ -158,8 +179,14 @@ function NewResidentialClientSheet({
|
||||
body: {
|
||||
fullName,
|
||||
email: email || undefined,
|
||||
phone: phone || undefined,
|
||||
phone: phone?.e164 ?? undefined,
|
||||
phoneE164: phone?.e164 ?? undefined,
|
||||
phoneCountry: phone?.country ?? undefined,
|
||||
nationalityIso: nationalityIso ?? undefined,
|
||||
timezone: timezone ?? undefined,
|
||||
placeOfResidence: placeOfResidence || undefined,
|
||||
placeOfResidenceCountryIso: residenceCountry ?? undefined,
|
||||
subdivisionIso: residenceSubdivision ?? undefined,
|
||||
notes: notes || undefined,
|
||||
source: 'manual',
|
||||
},
|
||||
@@ -167,11 +194,7 @@ function NewResidentialClientSheet({
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['residential-clients'] });
|
||||
onOpenChange(false);
|
||||
setFullName('');
|
||||
setEmail('');
|
||||
setPhone('');
|
||||
setPlaceOfResidence('');
|
||||
setNotes('');
|
||||
reset();
|
||||
toast.success('Residential client added');
|
||||
},
|
||||
onError: (err) => {
|
||||
@@ -212,7 +235,28 @@ function NewResidentialClientSheet({
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="rc-phone">Phone</Label>
|
||||
<Input id="rc-phone" value={phone} onChange={(e) => setPhone(e.target.value)} />
|
||||
<PhoneInput id="rc-phone" value={phone} onChange={setPhone} data-testid="rc-phone" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="rc-nationality">Nationality</Label>
|
||||
<CountryCombobox
|
||||
id="rc-nationality"
|
||||
value={nationalityIso}
|
||||
onChange={setNationalityIso}
|
||||
data-testid="rc-nationality"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="rc-timezone">Timezone</Label>
|
||||
<TimezoneCombobox
|
||||
id="rc-timezone"
|
||||
value={timezone}
|
||||
onChange={setTimezone}
|
||||
countryHint={nationalityIso ?? undefined}
|
||||
data-testid="rc-timezone"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="rc-residence">Place of residence</Label>
|
||||
@@ -220,8 +264,34 @@ function NewResidentialClientSheet({
|
||||
id="rc-residence"
|
||||
value={placeOfResidence}
|
||||
onChange={(e) => setPlaceOfResidence(e.target.value)}
|
||||
placeholder="City or area"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="rc-residence-country">Country</Label>
|
||||
<CountryCombobox
|
||||
id="rc-residence-country"
|
||||
value={residenceCountry}
|
||||
onChange={(iso) => {
|
||||
setResidenceCountry(iso);
|
||||
// Wipe subdivision when country flips — codes are scoped per country.
|
||||
setResidenceSubdivision(null);
|
||||
}}
|
||||
data-testid="rc-residence-country"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="rc-residence-subdivision">Region</Label>
|
||||
<SubdivisionCombobox
|
||||
id="rc-residence-subdivision"
|
||||
value={residenceSubdivision}
|
||||
onChange={setResidenceSubdivision}
|
||||
country={residenceCountry}
|
||||
data-testid="rc-residence-subdivision"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="rc-notes">Notes</Label>
|
||||
<Input id="rc-notes" value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
|
||||
550
src/components/scan/scan-shell.tsx
Normal file
550
src/components/scan/scan-shell.tsx
Normal file
@@ -0,0 +1,550 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Camera, Loader2, RotateCcw, AlertTriangle, CheckCircle2, Save } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useUIStore } from '@/stores/ui-store';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { EXPENSE_CATEGORIES, PAYMENT_METHODS } from '@/lib/constants';
|
||||
import { runTesseract } from '@/lib/ocr/tesseract-client';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ParsedReceipt {
|
||||
establishment: string | null;
|
||||
date: string | null;
|
||||
amount: number | null;
|
||||
currency: string | null;
|
||||
lineItems: Array<{ description: string; amount: number }>;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
type ScanState =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'processing'; engine: 'tesseract' | 'ai' }
|
||||
| {
|
||||
kind: 'verify';
|
||||
parsed: ParsedReceipt;
|
||||
source: 'ai' | 'tesseract' | 'manual';
|
||||
reason?: string;
|
||||
providerError?: string;
|
||||
}
|
||||
| { kind: 'saving' }
|
||||
| { kind: 'saved'; expenseId: string }
|
||||
| { kind: 'error'; message: string };
|
||||
|
||||
interface ScanResp {
|
||||
data: {
|
||||
parsed: ParsedReceipt;
|
||||
source: 'ai' | 'manual';
|
||||
reason?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
providerError?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Form ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface VerifyFormProps {
|
||||
parsed: ParsedReceipt;
|
||||
imagePreview: string;
|
||||
imageFile: File;
|
||||
source: 'ai' | 'tesseract' | 'manual';
|
||||
reason?: string;
|
||||
providerError?: string;
|
||||
onSubmit: (input: {
|
||||
establishmentName: string;
|
||||
amount: string;
|
||||
currency: string;
|
||||
expenseDate: string;
|
||||
category: string;
|
||||
paymentMethod: string;
|
||||
description: string;
|
||||
file: File;
|
||||
}) => void;
|
||||
onRetake: () => void;
|
||||
saving: boolean;
|
||||
}
|
||||
|
||||
const TODAY = () => new Date().toISOString().slice(0, 10);
|
||||
|
||||
function VerifyForm({
|
||||
parsed,
|
||||
imagePreview,
|
||||
imageFile,
|
||||
source,
|
||||
reason: _reason,
|
||||
providerError,
|
||||
onSubmit,
|
||||
onRetake,
|
||||
saving,
|
||||
}: VerifyFormProps) {
|
||||
const [establishmentName, setEstablishment] = useState(parsed.establishment ?? '');
|
||||
const [amount, setAmount] = useState(parsed.amount != null ? String(parsed.amount) : '');
|
||||
const [currency, setCurrency] = useState((parsed.currency ?? 'USD').toUpperCase());
|
||||
const [expenseDate, setExpenseDate] = useState(parsed.date ?? TODAY());
|
||||
const [category, setCategory] = useState<string>('other');
|
||||
const [paymentMethod, setPaymentMethod] = useState<string>('credit_card');
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
const lowConfidence = source !== 'manual' && parsed.confidence < 0.6;
|
||||
const noOcr = source === 'manual';
|
||||
const engineLabel = source === 'ai' ? 'AI' : source === 'tesseract' ? 'on-device OCR' : 'manual';
|
||||
|
||||
const banner = noOcr ? (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">Manual entry mode</p>
|
||||
<p className="text-xs mt-0.5">
|
||||
{providerError
|
||||
? `We couldn't read the receipt automatically: ${providerError}.`
|
||||
: "We couldn't read the receipt automatically."}{' '}
|
||||
Fill in the details below to save the expense with the photo attached.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : lowConfidence ? (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">Low-confidence read — please double-check the fields</p>
|
||||
<p className="text-xs mt-0.5">
|
||||
{engineLabel} returned {Math.round(parsed.confidence * 100)}% confidence.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-emerald-300 bg-emerald-50 px-3 py-2 text-sm text-emerald-900">
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">Receipt parsed — confirm the fields and save</p>
|
||||
<p className="text-xs mt-0.5">
|
||||
{engineLabel} · {Math.round(parsed.confidence * 100)}% confidence.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onSubmit({
|
||||
establishmentName,
|
||||
amount,
|
||||
currency,
|
||||
expenseDate,
|
||||
category,
|
||||
paymentMethod,
|
||||
description,
|
||||
file: imageFile,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{banner}
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={imagePreview}
|
||||
alt="Receipt preview"
|
||||
className="block w-full max-h-[40vh] object-contain bg-muted"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<Label htmlFor="establishmentName">Vendor / establishment</Label>
|
||||
<Input
|
||||
id="establishmentName"
|
||||
value={establishmentName}
|
||||
onChange={(e) => setEstablishment(e.target.value)}
|
||||
placeholder="e.g. Marina Fuel Station"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="amount">Amount</Label>
|
||||
<Input
|
||||
id="amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
inputMode="decimal"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="currency">Currency</Label>
|
||||
<Input
|
||||
id="currency"
|
||||
value={currency}
|
||||
onChange={(e) => setCurrency(e.target.value.toUpperCase())}
|
||||
maxLength={3}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="expenseDate">Date</Label>
|
||||
<Input
|
||||
id="expenseDate"
|
||||
type="date"
|
||||
value={expenseDate}
|
||||
onChange={(e) => setExpenseDate(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="category">Category</Label>
|
||||
<Select value={category} onValueChange={setCategory}>
|
||||
<SelectTrigger id="category">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{EXPENSE_CATEGORIES.map((c) => (
|
||||
<SelectItem key={c} value={c} className="capitalize">
|
||||
{c.replace(/_/g, ' ')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="paymentMethod">Payment method</Label>
|
||||
<Select value={paymentMethod} onValueChange={setPaymentMethod}>
|
||||
<SelectTrigger id="paymentMethod">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PAYMENT_METHODS.map((p) => (
|
||||
<SelectItem key={p} value={p} className="capitalize">
|
||||
{p.replace(/_/g, ' ')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<Label htmlFor="description">Notes (optional)</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={saving || !amount}
|
||||
className="h-12 text-base sm:flex-1"
|
||||
data-testid="scan-save"
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Save expense
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onRetake}
|
||||
disabled={saving}
|
||||
className="h-12 text-base"
|
||||
>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
Retake
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Shell ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ScanShell() {
|
||||
const router = useRouter();
|
||||
const portSlug = useUIStore((s) => s.currentPortSlug);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [state, setState] = useState<ScanState>({ kind: 'idle' });
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
|
||||
// Revoke blob URL on unmount.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (imagePreview) URL.revokeObjectURL(imagePreview);
|
||||
};
|
||||
}, [imagePreview]);
|
||||
|
||||
async function handleFile(file: File) {
|
||||
if (imagePreview) URL.revokeObjectURL(imagePreview);
|
||||
setImagePreview(URL.createObjectURL(file));
|
||||
setState({ kind: 'processing', engine: 'tesseract' });
|
||||
|
||||
// Always run Tesseract first — it's free, on-device, and gives us a
|
||||
// baseline parse we can fall back to if the optional AI pass is off
|
||||
// or fails. The WASM bundle dynamic-imports inside `runTesseract`.
|
||||
let tesseract: Awaited<ReturnType<typeof runTesseract>> | null = null;
|
||||
try {
|
||||
tesseract = await runTesseract(file);
|
||||
} catch (err) {
|
||||
// Tesseract.js itself failed (corrupt image, OOM, etc). Don't bail —
|
||||
// give the user the manual form so they can still save the expense.
|
||||
setState({
|
||||
kind: 'verify',
|
||||
parsed: {
|
||||
establishment: null,
|
||||
date: null,
|
||||
amount: null,
|
||||
currency: null,
|
||||
lineItems: [],
|
||||
confidence: 0,
|
||||
},
|
||||
source: 'manual',
|
||||
reason: 'tesseract-error',
|
||||
providerError: err instanceof Error ? err.message : 'On-device OCR failed',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Now ask the server whether AI is enabled for this port. If it is,
|
||||
// the server runs the configured provider and returns a richer parse;
|
||||
// otherwise we keep the Tesseract result.
|
||||
setState({ kind: 'processing', engine: 'ai' });
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const portId = useUIStore.getState().currentPortId;
|
||||
const headers = new Headers();
|
||||
if (portId) headers.set('X-Port-Id', portId);
|
||||
const res = await fetch('/api/v1/expenses/scan-receipt', {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
credentials: 'include',
|
||||
headers,
|
||||
});
|
||||
if (!res.ok) throw new Error(`Server returned ${res.status}`);
|
||||
const body = (await res.json()) as ScanResp;
|
||||
|
||||
if (body.data.source === 'ai' && body.data.parsed.confidence >= tesseract.parsed.confidence) {
|
||||
// AI did at least as well as Tesseract — prefer its result.
|
||||
setState({
|
||||
kind: 'verify',
|
||||
parsed: body.data.parsed,
|
||||
source: 'ai',
|
||||
reason: body.data.reason,
|
||||
providerError: body.data.providerError,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Either AI is disabled (`source: 'manual', reason: 'ai-disabled'`),
|
||||
// not configured, or it underperformed — fall back to Tesseract.
|
||||
setState({
|
||||
kind: 'verify',
|
||||
parsed: tesseract.parsed,
|
||||
source: 'tesseract',
|
||||
reason: body.data.reason,
|
||||
providerError: body.data.providerError,
|
||||
});
|
||||
} catch {
|
||||
// Server unreachable — still let the user verify with the Tesseract
|
||||
// result and save the expense. We don't surface the network error
|
||||
// because the local parse is usable.
|
||||
setState({
|
||||
kind: 'verify',
|
||||
parsed: tesseract.parsed,
|
||||
source: 'tesseract',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(input: {
|
||||
establishmentName: string;
|
||||
amount: string;
|
||||
currency: string;
|
||||
expenseDate: string;
|
||||
category: string;
|
||||
paymentMethod: string;
|
||||
description: string;
|
||||
file: File;
|
||||
}) {
|
||||
setState({ kind: 'saving' });
|
||||
try {
|
||||
// Upload the image (multipart — apiFetch wraps JSON, so use raw fetch).
|
||||
const fd = new FormData();
|
||||
fd.append('file', input.file);
|
||||
fd.append('category', 'receipt');
|
||||
const portId = useUIStore.getState().currentPortId;
|
||||
const headers = new Headers();
|
||||
if (portId) headers.set('X-Port-Id', portId);
|
||||
const upRes = await fetch('/api/v1/files/upload', {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
credentials: 'include',
|
||||
headers,
|
||||
});
|
||||
if (!upRes.ok) throw new Error(`Upload failed: ${upRes.status}`);
|
||||
const upJson = (await upRes.json()) as { data: { id: string } };
|
||||
|
||||
const expense = await apiFetch<{ data: { id: string } }>(`/api/v1/expenses`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
establishmentName: input.establishmentName || undefined,
|
||||
amount: input.amount,
|
||||
currency: input.currency,
|
||||
expenseDate: input.expenseDate,
|
||||
category: input.category,
|
||||
paymentMethod: input.paymentMethod,
|
||||
description: input.description || undefined,
|
||||
receiptFileIds: [upJson.data.id],
|
||||
paymentStatus: 'unpaid',
|
||||
},
|
||||
});
|
||||
|
||||
setState({ kind: 'saved', expenseId: expense.data.id });
|
||||
} catch (err) {
|
||||
setState({
|
||||
kind: 'error',
|
||||
message: err instanceof Error ? err.message : 'Save failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (imagePreview) {
|
||||
URL.revokeObjectURL(imagePreview);
|
||||
setImagePreview(null);
|
||||
}
|
||||
setState({ kind: 'idle' });
|
||||
if (fileRef.current) fileRef.current.value = '';
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-[100dvh] w-full max-w-xl flex-col gap-4 px-4 py-6 sm:py-10">
|
||||
<header className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold">Scan a receipt</h1>
|
||||
{state.kind !== 'idle' ? (
|
||||
<Button variant="ghost" size="sm" onClick={reset}>
|
||||
Start over
|
||||
</Button>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
{state.kind === 'idle' ? (
|
||||
<section className="flex flex-1 flex-col items-center justify-center gap-4 rounded-2xl border-2 border-dashed border-border bg-muted/20 p-8 text-center">
|
||||
<Camera className="h-12 w-12 text-muted-foreground/60" aria-hidden />
|
||||
<div>
|
||||
<p className="text-base font-medium">Tap to capture a receipt</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Use your camera or pick an image from your library. We'll read it and pre-fill
|
||||
the form for you to confirm.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="h-12 px-6 text-base"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
data-testid="scan-capture"
|
||||
>
|
||||
<Camera className="mr-2 h-5 w-5" />
|
||||
Capture receipt
|
||||
</Button>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) void handleFile(f);
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{state.kind === 'processing' ? (
|
||||
<section className="flex flex-1 flex-col items-center justify-center gap-3 py-12">
|
||||
<Loader2 className="h-10 w-10 animate-spin text-brand" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{state.engine === 'tesseract' ? 'Reading on-device…' : 'Refining with AI…'}
|
||||
</p>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{state.kind === 'verify' && imagePreview ? (
|
||||
<VerifyForm
|
||||
parsed={state.parsed}
|
||||
imagePreview={imagePreview}
|
||||
imageFile={fileRef.current?.files?.[0] as File}
|
||||
source={state.source}
|
||||
reason={state.reason}
|
||||
providerError={state.providerError}
|
||||
onSubmit={handleSubmit}
|
||||
onRetake={reset}
|
||||
saving={false}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{state.kind === 'saving' ? (
|
||||
<section className="flex flex-1 flex-col items-center justify-center gap-3 py-12">
|
||||
<Loader2 className="h-10 w-10 animate-spin text-brand" />
|
||||
<p className="text-sm text-muted-foreground">Saving expense…</p>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{state.kind === 'saved' ? (
|
||||
<section className="flex flex-1 flex-col items-center justify-center gap-3 rounded-2xl border border-emerald-200 bg-emerald-50 p-8 text-center">
|
||||
<CheckCircle2 className="h-12 w-12 text-emerald-600" />
|
||||
<p className="text-base font-semibold text-emerald-900">Expense saved</p>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={reset} variant="outline" data-testid="scan-another">
|
||||
Scan another
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => router.push(`/${portSlug}/expenses/${state.expenseId}` as never)}
|
||||
>
|
||||
View expense
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{state.kind === 'error' ? (
|
||||
<section
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-3 rounded-2xl border border-destructive/30 bg-destructive/5 p-6 text-center',
|
||||
)}
|
||||
>
|
||||
<AlertTriangle className="h-10 w-10 text-destructive" />
|
||||
<p className="text-base font-medium text-destructive">{state.message}</p>
|
||||
<Button onClick={reset} variant="outline">
|
||||
Try again
|
||||
</Button>
|
||||
</section>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
411
src/components/shared/addresses-editor.tsx
Normal file
411
src/components/shared/addresses-editor.tsx
Normal file
@@ -0,0 +1,411 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Loader2, MapPin, Plus, Star, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { CountryCombobox } from '@/components/shared/country-combobox';
|
||||
import { SubdivisionCombobox } from '@/components/shared/subdivision-combobox';
|
||||
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import type { CountryCode } from '@/lib/i18n/countries';
|
||||
import { getCountryName } from '@/lib/i18n/countries';
|
||||
import { getSubdivisionName } from '@/lib/i18n/subdivisions';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface Address {
|
||||
id: string;
|
||||
label: string;
|
||||
streetAddress: string | null;
|
||||
city: string | null;
|
||||
subdivisionIso: string | null;
|
||||
postalCode: string | null;
|
||||
countryIso: string | null;
|
||||
isPrimary: boolean;
|
||||
}
|
||||
|
||||
type AddressPatch = Partial<Omit<Address, 'id'>>;
|
||||
|
||||
interface AddressesEditorProps {
|
||||
/** Base API endpoint, e.g. `/api/v1/clients/abc/addresses` */
|
||||
endpoint: string;
|
||||
/** React-Query invalidation key for the parent entity. */
|
||||
invalidateKey: readonly unknown[];
|
||||
addresses: Address[];
|
||||
}
|
||||
|
||||
export function AddressesEditor({ endpoint, invalidateKey, addresses }: AddressesEditorProps) {
|
||||
const qc = useQueryClient();
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
function invalidate() {
|
||||
qc.invalidateQueries({ queryKey: invalidateKey });
|
||||
}
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async ({ id, patch }: { id: string; patch: AddressPatch }) =>
|
||||
apiFetch(`${endpoint}/${id}`, { method: 'PATCH', body: patch }),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: async (data: AddressPatch) => apiFetch(endpoint, { method: 'POST', body: data }),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: async (id: string) => apiFetch(`${endpoint}/${id}`, { method: 'DELETE' }),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{addresses.length === 0 && !adding && (
|
||||
<p className="text-sm text-muted-foreground">No addresses yet</p>
|
||||
)}
|
||||
|
||||
{addresses.map((a) => (
|
||||
<AddressCard
|
||||
key={a.id}
|
||||
address={a}
|
||||
onUpdate={(patch) => updateMutation.mutateAsync({ id: a.id, patch })}
|
||||
onRemove={async () => {
|
||||
if (!confirm('Remove this address?')) return;
|
||||
await removeMutation.mutateAsync(a.id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{adding ? (
|
||||
<NewAddressForm
|
||||
isFirst={addresses.length === 0}
|
||||
onCancel={() => setAdding(false)}
|
||||
onSave={async (data) => {
|
||||
await addMutation.mutateAsync(data);
|
||||
setAdding(false);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setAdding(true)}
|
||||
className="w-full justify-center"
|
||||
data-testid="add-address-button"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
||||
Add address
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddressCard({
|
||||
address,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
}: {
|
||||
address: Address;
|
||||
onUpdate: (patch: AddressPatch) => Promise<unknown>;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
async function togglePrimary() {
|
||||
if (address.isPrimary) return; // already primary; demoting via toggle would orphan all
|
||||
try {
|
||||
await onUpdate({ isPrimary: true });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to update');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group rounded-lg border bg-muted/30 p-3 text-sm space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<InlineEditableField
|
||||
value={address.label}
|
||||
placeholder="Label (e.g. Home, Office)"
|
||||
onSave={async (v) => {
|
||||
if (!v) {
|
||||
toast.error('Label is required');
|
||||
return;
|
||||
}
|
||||
await onUpdate({ label: v });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePrimary}
|
||||
title={address.isPrimary ? 'Primary address' : 'Make primary'}
|
||||
className={cn(
|
||||
'p-1 rounded hover:bg-background/60 transition-colors',
|
||||
address.isPrimary ? 'text-primary' : 'text-muted-foreground/50',
|
||||
)}
|
||||
data-testid="address-primary-toggle"
|
||||
>
|
||||
<Star className={cn('h-3.5 w-3.5', address.isPrimary && 'fill-current')} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
title="Remove"
|
||||
className="p-1 rounded text-muted-foreground/50 hover:text-destructive hover:bg-background/60 opacity-0 group-hover:opacity-100 transition-all"
|
||||
data-testid="address-remove-button"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-3 gap-y-1 pl-5">
|
||||
<Field label="Street">
|
||||
<InlineEditableField
|
||||
value={address.streetAddress}
|
||||
placeholder="123 Main St"
|
||||
onSave={async (v) => {
|
||||
await onUpdate({ streetAddress: v });
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="City">
|
||||
<InlineEditableField
|
||||
value={address.city}
|
||||
placeholder="City"
|
||||
onSave={async (v) => {
|
||||
await onUpdate({ city: v });
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Country">
|
||||
<CountryFieldInline
|
||||
value={address.countryIso}
|
||||
onSave={async (iso) => {
|
||||
// Clear subdivision if country changes — codes are scoped per country.
|
||||
const patch: AddressPatch = { countryIso: iso };
|
||||
if (iso !== address.countryIso) patch.subdivisionIso = null;
|
||||
await onUpdate(patch);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Region">
|
||||
<SubdivisionFieldInline
|
||||
value={address.subdivisionIso}
|
||||
country={(address.countryIso as CountryCode | null) ?? null}
|
||||
onSave={async (code) => {
|
||||
await onUpdate({ subdivisionIso: code });
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Postal Code">
|
||||
<InlineEditableField
|
||||
value={address.postalCode}
|
||||
placeholder="ZIP / Postal"
|
||||
onSave={async (v) => {
|
||||
await onUpdate({ postalCode: v });
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-xs text-muted-foreground w-20 shrink-0">{label}</span>
|
||||
<span className="flex-1 min-w-0">{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CountryFieldInline({
|
||||
value,
|
||||
onSave,
|
||||
}: {
|
||||
value: string | null;
|
||||
onSave: (iso: string | null) => Promise<void>;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
if (editing) {
|
||||
return (
|
||||
<CountryCombobox
|
||||
value={value ?? null}
|
||||
onChange={async (iso) => {
|
||||
setEditing(false);
|
||||
await onSave(iso ?? null);
|
||||
}}
|
||||
clearable
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
}
|
||||
const display = value ? getCountryName(value, 'en') : null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(true)}
|
||||
className="text-left w-full hover:bg-background/60 rounded px-1 py-0.5 -mx-1 -my-0.5 truncate"
|
||||
>
|
||||
{display ?? <span className="text-muted-foreground italic">Not set</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SubdivisionFieldInline({
|
||||
value,
|
||||
country,
|
||||
onSave,
|
||||
}: {
|
||||
value: string | null;
|
||||
country: CountryCode | null;
|
||||
onSave: (code: string | null) => Promise<void>;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
if (editing) {
|
||||
return (
|
||||
<SubdivisionCombobox
|
||||
value={value ?? null}
|
||||
country={country}
|
||||
onChange={async (code) => {
|
||||
setEditing(false);
|
||||
await onSave(code ?? null);
|
||||
}}
|
||||
clearable
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (!country) {
|
||||
return <span className="text-muted-foreground italic text-xs">Pick country first</span>;
|
||||
}
|
||||
const display = value ? getSubdivisionName(value) : null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(true)}
|
||||
className="text-left w-full hover:bg-background/60 rounded px-1 py-0.5 -mx-1 -my-0.5 truncate"
|
||||
>
|
||||
{display ?? <span className="text-muted-foreground italic">Not set</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function NewAddressForm({
|
||||
onSave,
|
||||
onCancel,
|
||||
isFirst,
|
||||
}: {
|
||||
onSave: (data: AddressPatch) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
isFirst: boolean;
|
||||
}) {
|
||||
const [label, setLabel] = useState('Primary');
|
||||
const [streetAddress, setStreet] = useState('');
|
||||
const [city, setCity] = useState('');
|
||||
const [countryIso, setCountryIso] = useState<string | null>(null);
|
||||
const [subdivisionIso, setSubdivisionIso] = useState<string | null>(null);
|
||||
const [postalCode, setPostal] = useState('');
|
||||
const [makePrimary, setMakePrimary] = useState(isFirst);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
async function submit() {
|
||||
if (!label.trim()) {
|
||||
toast.error('Label is required');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave({
|
||||
label: label.trim(),
|
||||
streetAddress: streetAddress.trim() || null,
|
||||
city: city.trim() || null,
|
||||
countryIso,
|
||||
subdivisionIso,
|
||||
postalCode: postalCode.trim() || null,
|
||||
isPrimary: makePrimary,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to add address');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-muted/30 p-3 text-sm space-y-2">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
<Input
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="Label (Home, Office)"
|
||||
className="h-8"
|
||||
autoFocus
|
||||
disabled={saving}
|
||||
/>
|
||||
<Input
|
||||
value={streetAddress}
|
||||
onChange={(e) => setStreet(e.target.value)}
|
||||
placeholder="Street address"
|
||||
className="h-8"
|
||||
disabled={saving}
|
||||
/>
|
||||
<Input
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
placeholder="City"
|
||||
className="h-8"
|
||||
disabled={saving}
|
||||
/>
|
||||
<CountryCombobox
|
||||
value={countryIso}
|
||||
onChange={(iso) => {
|
||||
setCountryIso(iso ?? null);
|
||||
setSubdivisionIso(null);
|
||||
}}
|
||||
clearable
|
||||
placeholder="Country"
|
||||
/>
|
||||
<SubdivisionCombobox
|
||||
value={subdivisionIso}
|
||||
country={(countryIso as CountryCode | null) ?? null}
|
||||
onChange={(code) => setSubdivisionIso(code ?? null)}
|
||||
clearable
|
||||
placeholder="Region (optional)"
|
||||
/>
|
||||
<Input
|
||||
value={postalCode}
|
||||
onChange={(e) => setPostal(e.target.value)}
|
||||
placeholder="Postal code"
|
||||
className="h-8"
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={makePrimary}
|
||||
onChange={(e) => setMakePrimary(e.target.checked)}
|
||||
disabled={saving}
|
||||
/>
|
||||
Set as primary address
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" size="sm" variant="ghost" onClick={onCancel} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={submit} disabled={saving}>
|
||||
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
153
src/components/shared/country-combobox.tsx
Normal file
153
src/components/shared/country-combobox.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ALL_COUNTRY_CODES, getCountryName, type CountryCode } from '@/lib/i18n/countries';
|
||||
|
||||
interface CountryComboboxProps {
|
||||
value: string | null | undefined;
|
||||
onChange: (iso: CountryCode | null) => void;
|
||||
/** Display locale; defaults to navigator.language so country names follow the user. */
|
||||
locale?: string;
|
||||
/** When true, renders just the flag/code (compact 24×24 trigger). */
|
||||
compact?: boolean;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
/** Allow clearing the selection. */
|
||||
clearable?: boolean;
|
||||
id?: string;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the regional-indicator emoji flag for an ISO alpha-2 code.
|
||||
* E.g. 'GB' → 🇬🇧. Avoids shipping a flag-image asset and respects the
|
||||
* platform's emoji rendering (iOS/macOS render real flags; Windows
|
||||
* shows the country code on a flag rectangle).
|
||||
*/
|
||||
function flagEmoji(code: string): string {
|
||||
if (code.length !== 2) return '';
|
||||
const A = 0x1f1e6;
|
||||
const a = 'A'.charCodeAt(0);
|
||||
const cp1 = A + code.charCodeAt(0) - a;
|
||||
const cp2 = A + code.charCodeAt(1) - a;
|
||||
return String.fromCodePoint(cp1, cp2);
|
||||
}
|
||||
|
||||
export function CountryCombobox({
|
||||
value,
|
||||
onChange,
|
||||
locale,
|
||||
compact = false,
|
||||
placeholder = 'Select country…',
|
||||
disabled,
|
||||
className,
|
||||
clearable = true,
|
||||
id,
|
||||
'data-testid': testId,
|
||||
}: CountryComboboxProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const effectiveLocale = locale ?? (typeof navigator !== 'undefined' ? navigator.language : 'en');
|
||||
|
||||
// Pre-build the options list once per locale change so the cmdk filter
|
||||
// can search by both code + localized name without re-allocating.
|
||||
const options = useMemo(() => {
|
||||
return ALL_COUNTRY_CODES.map((code) => ({
|
||||
code,
|
||||
name: getCountryName(code, effectiveLocale),
|
||||
flag: flagEmoji(code),
|
||||
})).sort((a, b) => a.name.localeCompare(b.name, effectiveLocale));
|
||||
}, [effectiveLocale]);
|
||||
|
||||
const selected = value ? options.find((o) => o.code === value) : undefined;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id={id}
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'justify-between',
|
||||
compact ? 'w-20 px-2' : 'w-full',
|
||||
!selected && 'text-muted-foreground',
|
||||
className,
|
||||
)}
|
||||
data-testid={testId}
|
||||
>
|
||||
{selected ? (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="text-base leading-none">{selected.flag}</span>
|
||||
{!compact ? (
|
||||
<span className="truncate text-sm">{selected.name}</span>
|
||||
) : (
|
||||
<span className="text-xs font-medium">{selected.code}</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="truncate">{placeholder}</span>
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[280px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search country or code…" />
|
||||
<CommandList>
|
||||
<CommandEmpty>No country found.</CommandEmpty>
|
||||
{clearable && value ? (
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="__clear__"
|
||||
onSelect={() => {
|
||||
onChange(null);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
Clear selection
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
) : null}
|
||||
<CommandGroup>
|
||||
{options.map((opt) => (
|
||||
<CommandItem
|
||||
key={opt.code}
|
||||
// cmdk filters by `value` — include both code + name.
|
||||
value={`${opt.name} ${opt.code}`}
|
||||
onSelect={() => {
|
||||
onChange(opt.code);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn('mr-2 h-4 w-4', value === opt.code ? 'opacity-100' : 'opacity-0')}
|
||||
/>
|
||||
<span className="mr-2 text-base leading-none">{opt.flag}</span>
|
||||
<span className="flex-1 truncate text-sm">{opt.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{opt.code}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user