Initial commit: Port Nimara CRM (Layers 0-4)
Some checks failed
Build & Push Docker Images / build-and-push (push) Has been cancelled
Build & Push Docker Images / deploy (push) Has been cancelled
Build & Push Docker Images / lint (push) Has been cancelled

Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 11:52:51 +01:00
commit 67d7e6e3d5
572 changed files with 86496 additions and 0 deletions

46
.env.example Normal file
View File

@@ -0,0 +1,46 @@
# Database
DATABASE_URL=postgresql://crm:changeme@localhost:5432/port_nimara_crm
# Redis
REDIS_URL=redis://:changeme@localhost:6379
# Auth
BETTER_AUTH_SECRET=change-me-to-a-random-string-at-least-32-chars
BETTER_AUTH_URL=http://localhost:3000
CSRF_SECRET=change-me-to-a-random-string-at-least-32-chars
# MinIO
MINIO_ENDPOINT=localhost
MINIO_PORT=9000
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET=crm-files
MINIO_USE_SSL=false
# Documenso
DOCUMENSO_API_URL=https://documenso.example.com/api/v1
DOCUMENSO_API_KEY=your-documenso-api-key
DOCUMENSO_WEBHOOK_SECRET=your-webhook-secret-min-16-chars
# Email (SMTP)
SMTP_HOST=mail.portnimara.com
SMTP_PORT=587
# Encryption (64-char hex string for AES-256)
EMAIL_CREDENTIAL_KEY=0000000000000000000000000000000000000000000000000000000000000000
# Google OAuth (optional)
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# OpenAI (optional)
OPENAI_API_KEY=
# App
APP_URL=http://localhost:3000
PUBLIC_SITE_URL=https://portnimara.com
NODE_ENV=development
LOG_LEVEL=info
# Next.js public
NEXT_PUBLIC_APP_URL=http://localhost:3000

View File

@@ -0,0 +1,95 @@
name: Build & Push Docker Images
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
REGISTRY: code.letsbe.solutions
IMAGE_APP: letsbe/pn-new-crm/crm-app
IMAGE_WORKER: letsbe/pn-new-crm/crm-worker
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install pnpm
run: corepack enable && corepack prepare pnpm@latest --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm lint
- name: Type check
run: pnpm exec tsc --noEmit
build-and-push:
runs-on: ubuntu-latest
needs: lint
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build & push crm-app
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_APP }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_APP }}:${{ github.sha }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_APP }}:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_APP }}:buildcache,mode=max
- name: Build & push crm-worker
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.worker
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_WORKER }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_WORKER }}:${{ github.sha }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_WORKER }}:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_WORKER }}:buildcache,mode=max
deploy:
runs-on: ubuntu-latest
needs: build-and-push
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Deploy to server via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
cd ${{ secrets.DEPLOY_PATH }}
docker login ${{ env.REGISTRY }} -u ${{ secrets.REGISTRY_USER }} -p ${{ secrets.REGISTRY_TOKEN }}
docker compose -f docker-compose.prod.yml pull crm-app crm-worker
docker compose -f docker-compose.prod.yml up -d --no-deps crm-app crm-worker
docker image prune -f

18
.gitignore vendored Normal file
View File

@@ -0,0 +1,18 @@
node_modules/
.next/
.nuxt/
.worktrees/
.env
.env.local
.env.production
*.pem
*.key
drizzle/*.sql
coverage/
.turbo/
out/
test-results/
playwright-report/
nginx/certs/
tsconfig.tsbuildinfo
.playwright-mcp/

11
.husky/pre-commit.bak Normal file
View File

@@ -0,0 +1,11 @@
pnpm exec lint-staged
# Verify no .env files staged
if git diff --cached --name-only | grep -qE '\.env($|\.)'; then
echo "❌ .env files must not be committed"
exit 1
fi
# Scan for potential secrets
if git diff --cached -U0 | grep -qiE '(password|secret|api_key|access_key)\s*[:=]\s*["\x27][A-Za-z0-9+/=]{16,}'; then
echo "⚠️ Possible hardcoded secret detected. Review staged changes."
exit 1
fi

4
.lintstagedrc.json Normal file
View File

@@ -0,0 +1,4 @@
{
"*.{ts,tsx}": ["eslint --fix", "prettier --write"],
"*.{json,md,css}": ["prettier --write"]
}

7
.prettierrc Normal file
View File

@@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"tabWidth": 2,
"printWidth": 100
}

View File

@@ -0,0 +1,273 @@
# Port Nimara CRM — Consolidated System Specification
**Compiled:** 2026-03-11
**Sources:** Claude Code audit + Codex audit of `client-portal/`
This document merges both independent audit outputs into a single authoritative reference for the rebuild.
---
## 1. System Architecture
### 1.1 Current Stack
| Layer | Technology | Role |
| -------------- | ------------------------------------------------------ | -------------------------------------------------------------------------------- |
| Framework | Nuxt 3 SPA (`ssr: false`) + Nitro server | Client shell, server API, scheduled tasks |
| Database | NocoDB REST API | System of record for interests, berths, expenses, invoices, settings, audit logs |
| Auth | Keycloak OIDC | SSO, refresh tokens, role/group extraction via JWT `groups` claim |
| File Storage | MinIO (S3-compatible) | Documents, EOI PDFs, expense exports, cached email JSON/attachments |
| E-Signatures | Documenso (self-hosted at `signatures.portnimara.dev`) | EOI generation, 3-party signing, webhook lifecycle, signed PDF retrieval |
| Outbound Email | SMTP via Nodemailer | User mailbox sending + reminder notifications (separate SMTP config) |
| Inbound Email | IMAP | Thread sync, cached mail archive, sales inbox PDF harvesting |
| Currency | Frankfurter API | Exchange rates with local file-based cache fallback |
| Automations | automation.portnimara.com | No-code webhooks for request forms and sales handoffs |
| Deployment | Docker (node:20-alpine) + Gitea CI/CD | Builds `.output`, pushes image on `main` pushes — no tests in pipeline |
### 1.2 Runtime Dependencies (33 production, 6 dev)
Key packages: `@nuxt/ui` ^3.2.0, `@pdfme/common` + `@pdfme/generator` ^5.4.0, `@pinia/nuxt`, `chart.js`, `date-fns`, `imap`, `lodash-es`, `lucide-vue-next`, `mailparser`, `minio`, `node-cron`, `nodemailer`, `nuxt-directus` (legacy, unused), `pdfkit`, `sharp`, `vue-chartjs`, `vue-toastification`, `vuetify-nuxt-module` (legacy).
Notable: `@nuxt/ui` and Vuetify coexist alongside a custom Maritime Design System. The frontend is mid-migration. `nuxt-directus` is installed but auth/data paths use Keycloak and NocoDB.
### 1.3 External Service Configuration
| Service | Config Source | Credential Status |
| ------------------------- | ---------------------------------------------------------- | --------------------------------------------------------- |
| NocoDB | `NUXT_NOCODB_URL`, `NUXT_NOCODB_TOKEN` | Environment variables (OK) |
| Keycloak | Hardcoded realm/client URLs + `KEYCLOAK_CLIENT_SECRET` env | Mixed — URLs hardcoded, secret in env |
| MinIO | Runtime config in `nuxt.config.ts` | **CRITICAL: Access key + secret key hardcoded in source** |
| Documenso | Env-driven base URL/API key/template IDs/webhook secret | Environment variables (OK) |
| SMTP/IMAP (user) | Per-user credentials cached in-memory (encrypted) | In-memory only — lost on restart |
| SMTP/IMAP (sales) | `process-sales-eois.ts` | **CRITICAL: Password hardcoded in source** |
| SMTP (reminders) | `NUXT_REMINDER_SMTP_*` env vars | Environment variables (OK) |
| automation.portnimara.com | Hardcoded webhook URLs in 3 handlers | No auth beyond URL secrecy |
### 1.4 Background Tasks
| Task | Schedule | Mechanism |
| ------------------------------- | --------------------------------------------------------- | -------------------------------------- |
| Pending notification processing | Every 30 seconds | Nitro experimental task (active) |
| Signature polling fallback | Every 5 minutes (default) | `setInterval` in server plugin |
| EOI reminders | Every 10 minutes (check), fires at 09:00/16:00 Paris time | `setInterval`**currently disabled** |
| Sales email processing | Manual trigger via internal API | Task file exists but not scheduled |
| Currency rate refresh | Manual/startup | Task file exists |
---
## 2. Data Model
### 2.1 Entity Relationship Map
| Relationship | Cardinality | Storage | Notes |
| -------------------------------- | ------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| Interests ↔ Berths (committed) | Many-to-many | NocoDB link field `cj7v7bb9pa5eyo3` / reverse `c7q2z2rb27c1cb5` | Core berth assignment. Linking auto-moves berth to `Under Offer` |
| Interests ↔ Berths (recommended) | Many-to-many | NocoDB link field `cgthyq2e95ajc52` | Suggestions separate from committed links |
| Expenses → Invoices | Many-to-one (soft) | `Expenses.invoice_id` + denormalized `Invoices.expense_ids` (comma-separated) | No relational constraint; manual sync required |
| Interests → EOI Documents | Attachment array | `Interests.EOI Document` field | Not modeled as own table |
| Interests → Documenso | Soft one-to-one | `Interests.documensoID` | Remote document can drift from local state |
| Email threads | Object storage only | MinIO `client-emails` bucket as JSON | No NocoDB table stores thread records |
| Users/Roles | External only | Keycloak JWT groups | No app-managed user table |
### 2.2 Core Tables
**Interests** (table: `mbs9hjauug4eseo`) — ~60 fields including:
- Identity: Full Name, Email Address, Phone Number, Address
- Vessel: Yacht Name, Length, Width, Depth, Berth Size Desired
- Pipeline: Sales Process Level (8 stages), Lead Category, Source, Date Added
- EOI State: EOI Status, documensoID, 6 signature link fields, 6 embedded signature link fields, EOI Client/Developer/Oscar Links
- Timing: EOI Time Sent, Time LOI Sent, Request Form Sent
- Milestones: Berth Info Sent Status, Contract Sent Status, Deposit 10% Status, Contract Status
- Notification machinery: ~15 fields for webhook locks, cooldowns, signature timestamps, notification tracking, pending email flags
- Links: Berths (many-to-many), Berth Recommendations (many-to-many)
**Berths** (table: `mczgos9hr3oa9qc`) — Physical marina berths:
- Identity: Mooring Number, Area, Status (Available/Under Offer/Sold)
- Dimensions: Length, Width, Draft, Water Depth, Nominal Boat Size (imperial + metric pairs)
- Infrastructure: Side Pontoon, Power Capacity, Voltage, Mooring Type, Access, Cleat/Bollard Type/Capacity
- Commercial: Price, Bow Facing
- Override: status_override_mode, status_last_modified_at
- Links: Interested Parties (reverse many-to-many)
**Expenses** (table: `mxfcefkk4dqs6uq`) — Operational costs:
- Core: Establishment Name, Price, currency, Payment Method, Category, Payer, Time, Contents
- Receipts: NocoDB attachment field
- Payment: payment_status, payment_date, payment_method, payment_reference, payment_notes
- Link: invoice_id (back-reference)
**Invoices** (table: `mvyvz0lpc30p01s`) — Generated from expense groups:
- Core: invoice_number (INV-YYYYMM-###), client_name, billing_email, due_date, payment_terms, currency, total_amount
- Payment: status, payment_status, payment_date, payment_method, payment_reference
- Links: expense_ids (comma-separated string — integrity risk)
- Output: pdf_path
**Singleton tables:** Reminder Settings (`mfdltoib4bji21u`), Alert Settings (`m5xl992f4i6e9q7`), Audit Logs (`audit_logs`)
### 2.3 Non-Table Entities
- **EOI documents**: Dual storage — MinIO files under `EOIs/` prefix AND Documenso remote documents keyed by `Interests.documensoID`. The `EOI Document` field stores arrays of file metadata, not normalized child rows.
- **Email threads**: JSON objects and attachments in MinIO `client-emails` bucket under `interest-<id>/...`. Not indexed in NocoDB.
- **Users/roles**: No app-managed user table. Identity from `nuxt-oidc-auth` cookie + Keycloak JWT `groups` claim.
### 2.4 Data Integrity Risks
1. All relationships enforced by application code only — NocoDB provides no transaction boundary
2. `Invoices.expense_ids` (comma-separated) and `Expenses.invoice_id` can drift out of sync
3. EOI state spans three systems (NocoDB fields, Documenso documents, MinIO files) — deleting any layer orphans the others
4. Signature/reminder state is 15+ nullable text fields instead of a workflow/history table
5. Audit logging inconsistent: some domains write to `audit_logs`, others only `console.log()`
6. Invoice deletion handler calls `getExpenses()` with wrong filter shape — linked expenses missed and left orphaned
7. `updateSignatureTimestamps()` returns empty update object — timestamps can remain stale
8. File audit logging functions never write to the real `audit_logs` table
---
## 3. Business Workflows
### 3.1 Authentication
- **Primary**: Keycloak OIDC Authorization Code flow via `nuxt-oidc-auth` cookie
- **Session caching**: 3-min TTL + 5-min grace, 1s rate limit between checks, request deduplication
- **Circuit breaker**: 5 failures → open state, 60s reset, 3 retries with exponential backoff
- **Roles**: admin > sales > user (from Keycloak `groups` claim)
- **Internal auth**: Static header `x-tag: 094ut234` for server-to-server calls (weak)
- **Dev bypass**: `NUXT_PUBLIC_DEV_AUTH_BYPASS=true` skips ALL auth — exposed as public runtime config
### 3.2 Interest Lifecycle (Sales Pipeline)
**8 stages**: Open → Details Sent → In Communication → Visited → Signed EOI and NDA → 10% Deposit → Contract → Completed
- Create: Whitelist filter on fields, date format conversion (dd-mm-yyyy → yyyy-mm-dd), audit logged
- Update: 3 retries with exponential backoff on 404, monitored fetch wrapper triggers alerts
- Berth linking: Auto-moves berth status to `Under Offer`; unlinking only resets to `Available` when berth is still `Under Offer`, interest not at high-close state, and no other interested parties remain
- Frontend auto-promotion: Entering yacht dimensions auto-upgrades Sales Process Level from General to Specific Qualified Interest
- Duplicate detection: Blocking by name prefix/email domain/phone prefix, then scoring by same email (1.0), same phone (1.0), similar name+address (0.8). Master selected by completeness + recency.
### 3.3 EOI/Signature Workflow (Documenso)
**3-party sequential signing**: Client (order 1) → Developer (order 2) → Sales/Approver (order 3)
- **Generation**: Requires populated client name, email, yacht name, L×W×D, and ≥1 linked berth. Blocked if manual EOI docs exist. Uses `@pdfme/generator` with Documenso template. Creates document via API, assigns 3 recipients, stores 12+ signing URL fields on Interest.
- **Webhooks**: `DOCUMENT_SIGNED` → deduplication via signature hash + in-memory lock → queues notification flag (developer or sales) → background task sends email every 30s. `DOCUMENT_COMPLETED` → downloads signed PDF → emails all 3 parties → stores in MinIO under `EOIs/{Client_Name}/`.
- **Fallback polling**: Every 5 minutes, checks all interests with documensoID against Documenso API, constructs synthetic webhook payloads.
- **Reminders**: Time-gated (09:00/16:00 Paris), per-interest `reminder_enabled` toggle, system-wide cooldown window. Currently disabled in production.
- **Manual upload**: Bypasses Documenso entirely — immediately sets `EOI Status = Signed` and `Sales Process Level = Signed EOI and NDA`.
### 3.4 Email System
- **Outbound**: Nodemailer SMTP with per-user encrypted credentials (in-memory cache, lost on restart). Sent emails stored in MinIO. HTML templates inlined as string literals in 4+ files.
- **Inbound**: IMAP sync with connection pool. Threads cached as JSON in MinIO `client-emails` bucket. Two parallel implementations exist (pool-based + standalone). V1 and V2 fetch endpoints.
- **Sales inbox**: Dedicated polling endpoint with hardcoded credentials harvests EOI-related PDFs.
### 3.5 Expense/Invoice Workflow
- **Expenses**: CRUD with receipt upload (MinIO), filtering by date/payer/category/payment status, CSV and PDF export. Export computes EUR subtotal + 5% processing fee. N+1 fetch patterns for related records.
- **Invoices**: Created from grouped expenses with auto-generated `INV-YYYYMM-###` numbers. Payment terms: immediate/net10/net15/net30/net45/net60. PDF generation applies 2% discount for net10 terms. Cascading payment status updates attempt manual rollback on failure. Invoice deletion has a bug that can miss linked expenses.
### 3.6 File Management
MinIO-backed file browser with upload/download/preview/rename/delete and folder management. Email attachments surfaced alongside stored documents. Presigned URLs for downloads. Audit logging defined but only prints to console (never writes to `audit_logs` table).
---
## 4. Security & Technical Debt
### 4.1 Critical (fix before any production traffic)
| Issue | Location | Impact |
| --------------------------------- | ------------------------------------------------ | ------------------------------------------------------------ |
| Hardcoded MinIO credentials | `nuxt.config.ts` lines 159161 | Full object storage access to anyone with repo access |
| Hardcoded sales email credentials | `server/api/email/process-sales-eois.ts` line 27 | Full mailbox access exposed in source |
| Dev auth bypass as public config | `nuxt.config.ts` `NUXT_PUBLIC_DEV_AUTH_BYPASS` | Entire CRM accessible without auth if flag set in production |
### 4.2 High
| Issue | Location |
| -------------------------------------------------- | ---------------------------------------------------------------- |
| Trivially guessable internal auth tag (`094ut234`) | Task endpoints, internal API calls |
| TLS certificate verification disabled | `email-utils.ts`, `fetch-thread.ts`, `send.ts` |
| Debug endpoints expose config without admin guard | `server/api/debug/*` |
| Destructive test endpoints in production | `test-eoi-cleanup.ts`, `test-berth-connection.ts` |
| N+1 query patterns on berth endpoints | `get-all-interest-berths.ts`, `get-berth-interested-parties.ts` |
| ~400 lines of duplicate EOI generation code | `generate-eoi-document.ts` + `generate-quick-eoi.ts` |
| No database migration strategy | All table IDs hardcoded as string literals |
| No transaction support (NocoDB REST API) | All multi-step write operations |
| Inconsistent error response shapes | Mixed `throw createError()` and `return { success: false }` |
| Frontend route auth metadata inconsistency | `roles` vs `auth.roles` — middleware only checks `to.meta.roles` |
| Mixed auth systems (Keycloak + leftover Directus) | Multiple components still use `useDirectusUser()` |
| Invoice deletion filter bug | `[id].delete.ts` calls `getExpenses()` with wrong filter shape |
### 4.3 Medium
No rate limiting, dual design system (Vuetify + Maritime), critical state in process memory only (webhook store, IMAP pool, credential cache), disabled scheduled reminders, inconsistent API design patterns, silent failures in core operations, retry without circuit breaker, inconsistent ID types, orphaned Documenso references, no caching + unbounded list fetches, blocking I/O in request handlers, weak TypeScript typing (`any` throughout), dead/unused code, EOI webhook timestamp persistence returning empty objects, file audit logging only printing to console.
### 4.4 Low
Reminder settings default to test mode, excessive console logging, naming inconsistencies (`documeso.ts` missing 'n'), missing input validation, ~16 mockup/abandoned pages in production source.
---
## 5. API Surface
The current system has ~100 endpoints mixing RPC-style (`/api/create-interest`), REST-style (`/api/invoices/[id].get.ts`), and ad-hoc patterns. No consistent pagination, error response shape, or input validation.
### Codex proposed clean API (~40 endpoints)
Resource-oriented surface replacing the current ~100:
- **Auth**: `/auth/login`, `/auth/logout`, `/auth/refresh`, `/auth/session`
- **Interests**: Standard REST + `/interests/{id}/berths`, `/interests/{id}/recommendations`, `/interests/{id}/status-transitions`, `/interests/{id}/eoi-documents/*`
- **Berths**: `GET /berths`, `GET /berths/{id}`, `PATCH /berths/{id}`
- **Expenses**: Standard REST + `/expenses/{id}/payments`, `/expenses/export/csv`, `/expenses/export/pdf`
- **Invoices**: Standard REST + `/invoices/{id}/payments`
- **Files**: Standard REST + `/files/{id}/download`, `/files/{id}/preview`
- **Email**: `/email/threads`, `/email/threads/{id}`, `/email/messages`, `/email/connections/test`
- **Admin**: `/admin/audit-logs`, `/admin/duplicate-jobs`, `/admin/settings/reminders`, `/admin/settings/alerts`
---
## 6. Frontend Architecture
### 6.1 Component Systems
- **Maritime Design System** (new): CSS custom properties (design tokens) for colors, typography, spacing, glassmorphism effects. Components: MaritimeButton, MaritimeCard, MaritimeInput, MaritimeModal, etc. Feature-flagged rollout.
- **Vuetify** (legacy): Still loaded globally. Competing styles increase bundle size.
- **Nuxt UI** (v3): Also present — three UI systems coexisting.
### 6.2 State Management
- One Pinia store (`expenses`) with 5-min cache TTL, optimistic updates, rollback on failure
- All other state: page-level refs, composable-level refs, `nuxtApp.payload.data`
- Auth state flows through middleware → payload → 3 different composables reading same data
### 6.3 Pages to Drop in Rebuild
~16 mockup/abandoned/test pages: `interest-list-mockup.vue`, `files-mockup.vue`, `expense-mockup.vue`, `dropdown-demo.vue`, `dropdown-test.vue`, `sidebar-test.vue`, `client-support.vue`, `data.vue`, `interest-analytics.vue`, `interest-berth-list.vue`, `interest-emails.vue`, `interest-eoi-queue.vue`, `portnimaraAI.vue`, `site.vue`, `social-media.vue`, `expenses-old.vue`
### 6.4 Iframe Embeds (9 pages)
Metabase (analytics, data), NocoDB views (EOI queue, berth gallery), webmail, Port Nimara AI, site analytics (Umami), social media marketing, client support. Recommendation: replace NocoDB views + webmail with native CRM features; keep 5-6 as iframes.
---
## 7. Critical Business Rules to Preserve
These rules are encoded in the current system (sometimes in both frontend and backend) and must carry forward:
1. Linking a berth to an interest auto-moves `Berth.Status` from `Available` to `Under Offer`
2. Unlinking a berth only resets to `Available` when: berth is still `Under Offer`, interest not at high-close state, and no other interested parties remain
3. Creating/editing an interest with yacht dimensions can auto-promote sales level from General to Specific Qualified Interest (frontend logic)
4. Quick EOI generation requires: populated client name, email, yacht name, L×W×D, and ≥1 linked berth
5. EOI generation blocked if manual/uploaded EOI documents already exist on the Interest
6. Generated EOIs set `EOI Status = Waiting for Signatures` and `Sales Process Level = EOI and NDA Sent`
7. Manual uploaded EOI documents immediately set `EOI Status = Signed` and `Sales Process Level = Signed EOI and NDA`
8. Documenso completion sends finalized PDF to client + developer + sales, stores signed PDF in MinIO, stamps `all_signed_notified_at`
9. Reminder sending gated by per-interest `reminder_enabled` + system-wide cooldown window
10. Expense exports compute EUR subtotal + 5% processing fee
11. PDF invoice generation applies 2% discount for `net10` payment terms before any optional fee
12. Invoice creation must update both Invoice record and every linked Expense back-reference (with rollback attempt on failure)
13. Roles enforced from Keycloak `groups` — no app-managed role table

155
02-FEATURE-INVENTORY.md Normal file
View File

@@ -0,0 +1,155 @@
# Port Nimara CRM — Feature Inventory: Keep / Cut / Rethink
**Compiled:** 2026-03-11
Each feature is categorized as **KEEP** (carry forward as-is or with minor cleanup), **RETHINK** (keep the functionality but redesign the implementation), or **CUT** (drop entirely from the rebuild). A rationale is provided for every decision.
---
## 1. Core Domain Features
| Feature | Verdict | Rationale |
| ------------------------------------------------------------------ | ----------- | ------------------------------------------------------------------------------------------------------------------------- |
| Interest CRUD (create, read, update, delete) | **KEEP** | Heart of the CRM. Whitelist filtering and audit logging are good patterns. |
| Interest list with search, filter, sort | **RETHINK** | Keep functionality. Replace unbounded fetch (limit: 1000) with proper cursor-based pagination and server-side search. |
| Interest detail view with tabs | **KEEP** | Core UI pattern. Rebuild with Maritime-only components. |
| Sales Process Level pipeline (8 stages) | **KEEP** | Proven workflow. Model transitions explicitly (state machine) instead of raw field mutation. |
| Frontend auto-promotion (yacht dims → Specific Qualified Interest) | **RETHINK** | Business rule should live server-side only. Currently duplicated in frontend and backend — single source of truth needed. |
| Berth CRUD and specifications | **KEEP** | Essential. Imperial + metric pairs are correct for the marina domain. |
| Berth area/status filtering | **KEEP** | Core navigation pattern for sales team. |
| Berth-to-interest linking/unlinking | **KEEP** | Critical workflow with auto-status rules. Keep the business rules, improve with database-level constraints. |
| Berth auto-status (Available → Under Offer on link) | **KEEP** | Essential business rule. Enforce at database/service layer, not scattered across endpoints. |
| Berth status override mode | **KEEP** | Manual override capability is important for edge cases. |
| Berth recommendations (separate from committed links) | **KEEP** | Useful sales workflow distinction. Implement as proper join table. |
| Duplicate detection (interests) | **RETHINK** | Useful feature. Replace Levenshtein blocking approach with database-level fuzzy matching (pg_trgm). Simplify scoring. |
| Duplicate merging (interests) | **RETHINK** | Keep merge capability. Add preview/dry-run mode. Improve rollback handling (currently fragile). |
| Duplicate detection (expenses) | **KEEP** | Same approach as interests. Rebuild with improved matching. |
## 2. EOI / Signature Features
| Feature | Verdict | Rationale |
| ------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| EOI document generation via Documenso | **KEEP** | Critical business process. Consolidate two duplicate ~400-line implementations into single `EOIService`. |
| 3-party sequential signing (Client → Developer → Sales) | **KEEP** | Core legal workflow. Preserve exact signing order and notification chain. |
| EOI signature tracking | **RETHINK** | Keep tracking. Replace 15+ nullable text fields on Interest with dedicated `signature_events` table. |
| Documenso webhook handler | **KEEP** | Deduplication via signature hashing and locking is well-designed. Extract into testable service module. |
| Background notification processing (30s poll) | **RETHINK** | Keep the queued notification pattern. Replace Nitro experimental task with BullMQ job. Reduce to event-driven rather than polling. |
| Fallback signature polling (5-min) | **KEEP** | Important resilience pattern for missed webhooks. Keep as scheduled job. |
| EOI reminder system (morning/afternoon) | **RETHINK** | Currently disabled in production. Re-evaluate need with users. If keeping, implement with proper job queue. |
| Manual EOI upload (bypass Documenso) | **KEEP** | Business need for pre-signed documents. Keep the immediate status promotion. |
| EOI document validation (Documenso sync check) | **RETHINK** | Manual-trigger only. Automate as periodic reconciliation job. |
| EOI delete/cleanup | **KEEP** | Needed for error recovery. Ensure it cleans up all three stores (NocoDB, Documenso, MinIO). |
| Signing link QR codes | **KEEP** | Useful for in-person signing scenarios at the marina. |
| Embedded signing links (iframe-based) | **RETHINK** | Evaluate if direct Documenso links are sufficient vs. embedding in CRM. |
## 3. Email Features
| Feature | Verdict | Rationale |
| ---------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Email composer with HTML signature | **RETHINK** | Keep functionality. Replace raw Nodemailer with transactional email service (Resend/SendGrid) for outbound. Extract HTML templates to template files. |
| Email thread viewer (IMAP sync) | **RETHINK** | Keep functionality. Single IMAP implementation (eliminate duplicate pool/standalone). Move sync to background worker. Store thread metadata in database, not just MinIO JSON. |
| Email attachment support (MinIO storage) | **KEEP** | Works well. Ensure consistent namespace in MinIO. |
| Sales inbox PDF harvesting | **RETHINK** | Keep if actively used. Remove hardcoded credentials. Move to background worker with proper scheduling. |
| Per-user mailbox credential storage | **RETHINK** | Currently in-memory (lost on restart). Move to encrypted database storage or Redis with proper key management. |
| Inline HTML email templates (4+ files) | **CUT** | Replace with proper template system (MJML or html template files with variable substitution). |
| V1 + V2 email fetch endpoints | **CUT** | Consolidate into single implementation. |
## 4. Expense / Invoice Features
| Feature | Verdict | Rationale |
| -------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------- |
| Expense CRUD with receipt upload | **KEEP** | Core operational feature. |
| Expense filtering (date, payer, category, payment) | **KEEP** | Essential for operations team. |
| Expense CSV export | **KEEP** | Business reporting need. Fix EUR subtotal + 5% processing fee logic. |
| Expense PDF export | **KEEP** | Business reporting need. Fix 2% net10 discount logic. |
| Expense mark-paid (single + bulk) | **KEEP** | Essential workflow. |
| Invoice generation from expenses | **RETHINK** | Keep functionality. Replace comma-separated `expense_ids` with proper join table. Add database transaction support. |
| Invoice payment tracking | **KEEP** | Core billing workflow. |
| Invoice PDF generation | **RETHINK** | Keep. Standardize on single PDF library (@pdfme). Move templates external. |
| Orphaned invoice cleanup (admin) | **CUT** | Should be unnecessary with proper relational integrity. Keep a reconciliation check as admin tool. |
| Currency conversion (Frankfurter API) | **KEEP** | Needed for multi-currency expenses. Add proper in-memory caching instead of file-based. |
## 5. File Management Features
| Feature | Verdict | Rationale |
| ------------------------------------------ | ----------- | ----------------------------------------------------------------------- |
| File upload/download via MinIO | **KEEP** | Works well. Move credentials to env vars. |
| File browser with folder management | **KEEP** | Core feature for document access. |
| File rename/delete | **KEEP** | Standard file operations. |
| File preview (presigned URLs) | **KEEP** | Good pattern. |
| Email attachments surfaced in file browser | **KEEP** | Useful cross-reference for sales team. |
| File audit logging | **RETHINK** | Currently only prints to console. Wire up to actual `audit_logs` table. |
## 6. Admin Features
| Feature | Verdict | Rationale |
| ----------------------------------------------------------------- | ----------- | ---------------------------------------------------------------- |
| Audit log viewer | **KEEP** | Essential compliance feature. Expand coverage to all domains. |
| Alert monitoring system (per-type counters, cooldowns) | **KEEP** | Valuable operational feature. Enhance with dashboard. |
| Reminder settings management | **KEEP** | Needed if reminder system is re-enabled. |
| Alert settings management | **KEEP** | Operational configuration. |
| System health dashboard | **RETHINK** | Currently basic stats. Build proper task execution dashboard. |
| Webhook event monitor | **RETHINK** | Currently in-memory only (lost on restart). Persist to database. |
| Debug endpoints (NocoDB config, OIDC session, connectivity tests) | **CUT** | Security risk. Replace with admin-only health check endpoint. |
| Destructive test endpoints (EOI cleanup, berth connection test) | **CUT** | No place in production. Remove entirely. |
## 7. Auth / Security Features
| Feature | Verdict | Rationale |
| ------------------------------------ | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| Keycloak OIDC SSO | **KEEP** | Works well. Clean up implementation (single composable, remove dev bypass from production). |
| Role-based access (admin/sales/user) | **KEEP** | Correct model for the organization. |
| Session caching with TTL + jitter | **KEEP** | Good performance pattern. |
| Circuit breaker on Keycloak calls | **KEEP** | Important resilience pattern. |
| Dev auth bypass | **RETHINK** | Move to server-only config. Add build-time assertion preventing production use. |
| Internal service auth (x-tag header) | **CUT** | Replace with proper HMAC with rotatable env-var secret. |
| Feature flag system | **RETHINK** | The Vuetify→Maritime migration use case is moot. Keep the pattern (dependency chains, rollout %, user targeting) for future feature rollouts. |
## 8. UI / Frontend Features
| Feature | Verdict | Rationale |
| --------------------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------- |
| Maritime Design System (glassmorphism tokens) | **KEEP** | Distinctive visual identity. Reimplement as Tailwind CSS theme extensions. |
| Vuetify components | **CUT** | Drop entirely. Maritime-only in rebuild. |
| Nuxt UI v3 components | **RETHINK** | Evaluate overlap with Headless UI / Radix Vue. Pick one component library. |
| Dashboard layout (sidebar, mobile nav) | **RETHINK** | Rebuild cleanly with single component system. Current version has ~40% duplicate code for dual-system support. |
| Toast notifications | **KEEP** | Standard UX pattern. |
| PWA (workbox, auto-update, 20s sync) | **RETHINK** | Evaluate if sales team actually needs offline/install. If yes, reduce sync frequency. If no, cut. |
| Chart.js visualizations | **KEEP** | Useful for analytics views. |
## 9. Iframe Embeds
| Feature | Verdict | Rationale |
| ---------------------------- | ----------- | ----------------------------------------------------------------------------------------------- |
| Metabase analytics dashboard | **RETHINK** | Keep as iframe OR build native charts. Custom charts integrate better with CRM design language. |
| NocoDB EOI queue view | **CUT** | Replace with native CRM page. NocoDB views bypass auth and don't match UI. |
| NocoDB berth gallery view | **CUT** | Replace with native CRM berth browser. |
| Webmail iframe | **CUT** | Replace with native email UI (EmailCommunication component already exists). |
| Port Nimara AI | **KEEP** | Separate service, keep as iframe. |
| Site analytics (Umami) | **KEEP** | Read-only, low priority. Keep as iframe. |
| Social media marketing | **KEEP** | Third-party service. Keep as iframe. |
| Client support ticketing | **KEEP** | Separate system. Keep as iframe. |
## 10. Infrastructure / Architecture
| Feature | Verdict | Rationale |
| ------------------------------------------------------------ | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| NocoDB as primary database | **CUT** | Replace with PostgreSQL + Drizzle ORM. Gains: transactions, migrations, JOINs, type safety, foreign keys. NocoDB can optionally remain as admin read view. |
| Single Nuxt container deployment | **KEEP** | Simple, effective for team size. Add Redis sidecar for sessions/jobs/caching. |
| Docker + Gitea CI/CD | **RETHINK** | Keep. Add test stage before image publish. |
| Nitro experimental tasks + node-cron | **CUT** | Replace with BullMQ + Redis for proper job tracking, retries, dead letter queues. |
| In-memory state (webhook store, credential cache, IMAP pool) | **CUT** | Replace with Redis for shared state that survives restarts. |
| Console.log-based logging | **CUT** | Replace with structured logger (consola with levels). |
| Hardcoded NocoDB table IDs throughout code | **CUT** | Moot with PostgreSQL migration. If keeping NocoDB: centralize in single config module. |
---
## Summary Counts
| Verdict | Count |
| ----------- | ----- |
| **KEEP** | 38 |
| **RETHINK** | 27 |
| **CUT** | 16 |
The rebuild preserves ~80% of current functionality (keep + rethink) while cutting dead code, security risks, and architectural debt.

View File

@@ -0,0 +1,329 @@
# Port Nimara CRM — Architecture Decision Record
**Compiled:** 2026-03-11
**Status:** Draft — requires review and sign-off before implementation begins
Each decision is numbered for reference. Decisions marked **[NEEDS INPUT]** require Matt's explicit choice before proceeding.
---
## ADR-001: Replace NocoDB with PostgreSQL + Drizzle ORM
**Status:** Recommended
**Context:** NocoDB serves as both database and admin UI, accessed entirely via REST API. This causes: no transactions (manual rollback code everywhere), no JOINs (N+1 queries), no migrations (schema changes invisible), no type safety (field names as scattered strings), no foreign key constraints (data integrity enforced by application code only). The invoice deletion filter bug and expense_ids comma-separated string are direct consequences.
**Decision:** PostgreSQL as primary database. Drizzle ORM for type-safe schema definition, migrations, and query building.
**Consequences:**
- Every API endpoint must be rewritten against the ORM instead of raw REST calls
- Data migration from NocoDB required (export JSON → transform → seed PostgreSQL)
- NocoDB can optionally remain connected to PostgreSQL as a read-only admin viewer
- Gains: real transactions, JOINs, migrations tracked in version control, foreign key constraints, pg_trgm for fuzzy duplicate detection, proper pagination with cursors
**[NEEDS INPUT]:** Do you want to keep NocoDB connected as an admin read view on top of PostgreSQL, or remove it entirely?
---
## ADR-002: Keep Keycloak for Authentication
**Status:** Accepted
**Context:** Keycloak OIDC works correctly. The circuit breaker, session caching, and token refresh patterns are good. Problems are implementation-level (dev bypass exposed publicly, 3 redundant auth composables) not architectural.
**Decision:** Keep Keycloak. Clean up implementation:
- Single `useAuth()` composable replacing the current three
- Dev bypass moved to server-only config with build-time production guard
- Remove all `useDirectusUser()` references
- Internal service auth replaced with proper HMAC using env-var secret
**Consequences:**
- Same Keycloak realm and client configuration can be reused
- No user migration needed — Keycloak is the identity source of truth
- Auth flow tested in Phase 1 of rebuild
---
## ADR-003: Keep Documenso for E-Signatures
**Status:** Accepted
**Context:** The 3-party EOI signing workflow is a critical legal process that works end-to-end. Webhook handling includes proper deduplication (signature hashing), locking, and notification chaining. Two duplicate ~400-line implementations exist but the underlying logic is sound.
**Decision:** Keep Documenso (self-hosted). Consolidate into a single `EOIService` module with:
- Single document generation path (eliminating duplicate code)
- Recipient configuration from database/environment (not hardcoded)
- Signature events stored as first-class database records (replacing 15+ nullable fields)
- Template-based PDF generation with external template files
**Consequences:**
- Pin Documenso version and add integration tests for webhook handler
- Normalize `eoi_documents`, `eoi_recipients`, and `signature_events` as proper database tables
- Webhook idempotency at database layer (replacing in-memory store)
---
## ADR-004: Keep MinIO for File Storage
**Status:** Accepted
**Context:** S3-compatible object storage via MinIO is solid infrastructure. Presigned URLs, organized prefix-based storage, and the file browser pattern all work well.
**Decision:** Keep MinIO. Improvements:
- ALL credentials via environment variables (rotate current hardcoded keys immediately)
- Database-backed file metadata (instead of relying solely on MinIO listing)
- Formalized storage namespaces: separate prefixes or buckets for documents, EOIs, email attachments, exports
- Proper audit trail (current file audit functions only print to console)
**Consequences:**
- File metadata table in PostgreSQL enables search, filtering, and relationship tracking
- MinIO credentials must be rotated before rebuild goes live
---
## ADR-005: UI Framework — Tailwind CSS + Component Library
**Status:** Recommended
**Context:** Three UI systems currently coexist: Vuetify (legacy), Maritime Design System (custom CSS tokens), and Nuxt UI v3. The dual-system support adds ~30-40% code volume to templates. Maritime design tokens (colors, typography, spacing, glassmorphism) provide the visual identity.
**Decision:** Tailwind CSS as the styling foundation. Maritime design tokens become Tailwind theme extensions. Drop Vuetify entirely.
**[NEEDS INPUT]:** Component library choice:
- **Option A: Nuxt UI v3** — Already partially in use. Built on Radix Vue. Tight Nuxt integration. Opinionated but productive.
- **Option B: Headless UI (or Radix Vue directly)** — More control over styling. Less opinionated. More work for common patterns.
- **Option C: shadcn-vue** — Copy-paste components. Full ownership. Good Tailwind integration.
**Consequences:**
- Maritime glassmorphism aesthetic preserved as Tailwind utilities/theme
- All Vuetify imports, theme config, and feature flag toggle code eliminated
- Single component library used consistently throughout
---
## ADR-006: State Management — Pinia + TanStack Query
**Status:** Recommended
**Context:** Currently one Pinia store (expenses) with good patterns (5-min cache, optimistic updates, rollback). All other state is scattered across page-level refs, composable-level refs, and payload data. Auth state read from 3 different composables.
**Decision:** Pinia stores for domain state (auth, UI preferences). TanStack Query (VueQuery) for all server state (interests, berths, expenses, invoices, files).
**Consequences:**
- Automatic cache invalidation and background refetching
- Built-in loading/error states per query
- Optimistic updates with rollback
- Eliminates manual cache TTL patterns
- Consistent data fetching pattern across all domains
---
## ADR-007: API Design — REST with Zod Validation
**Status:** Recommended
**Context:** Current API mixes RPC-style, REST-style, and ad-hoc patterns (~100 endpoints). No input validation. Inconsistent error responses. No pagination.
**Decision:** Consistent REST conventions. Reduce from ~100 to ~40-50 well-designed endpoints. Standardize on:
- Resource-oriented URLs: `GET /api/interests`, `POST /api/interests`, `PATCH /api/interests/:id`
- Zod schemas for ALL request validation (body, query, params)
- Standard error response: `{ error: { code, message, details? } }` with proper HTTP status codes
- Cursor-based pagination for all list endpoints
- Status transitions as explicit actions: `POST /api/interests/:id/transitions` instead of raw field mutation
**[NEEDS INPUT]:** Do you want to add OpenAPI/Swagger auto-generated documentation? Useful for future integrations but adds setup overhead.
**Consequences:**
- Cleaner, documentable API surface
- Type-safe request/response contracts
- Proper pagination from day one (no more unbounded fetches)
---
## ADR-008: Email Architecture — Hybrid Approach
**Status:** Recommended
**Context:** Current email system has two parallel IMAP implementations, hardcoded credentials, inline HTML templates in 4+ files, TLS verification disabled, and in-memory credential cache lost on restart.
**Decision:**
- **Outbound (transactional):** Dedicated email service (Resend or SendGrid) for CRM notifications, reminders, EOI emails. Template management via service. Better deliverability and analytics.
- **Outbound (user mailbox):** Keep SMTP for sending-as-user capability. Store credentials encrypted in database (not in-memory).
- **Inbound:** Keep IMAP sync for sales inbox and user thread viewing. Move to background worker (not blocking HTTP requests). Store thread metadata in PostgreSQL.
- **Templates:** Extract all inline HTML to MJML template files compiled at build time.
**[NEEDS INPUT]:** Which transactional email service? Resend (simpler, developer-focused) vs. SendGrid (more established, more features)?
**Consequences:**
- Single IMAP implementation (eliminate V1/V2 duplication)
- All credentials in encrypted database storage or env vars
- TLS verification enabled everywhere
- Email thread metadata queryable via PostgreSQL
---
## ADR-009: Job Queue — BullMQ + Redis
**Status:** Recommended
**Context:** Background tasks currently use Nitro experimental tasks and `setInterval` with node-cron. Task results are not tracked. Failures require log analysis. In-memory state (webhook event store, credential cache) lost on restart.
**Decision:** BullMQ with Redis for all background processing:
- Notification processing (currently 30s poll → event-driven via queue)
- Signature polling fallback (keep as scheduled job)
- EOI reminders (if re-enabled)
- Email sync (background worker)
- Currency rate refresh
Redis also serves as:
- Session store (enables horizontal scaling)
- Cache layer (replacing file-based and in-memory caches)
- Webhook event deduplication store
**Consequences:**
- Proper job tracking: start time, end time, success/failure, error details
- Built-in retry with exponential backoff and dead letter queue
- Admin dashboard for task monitoring
- Multi-instance deployment possible (no more single-container state dependency)
---
## ADR-010: Deployment — Single Container + Redis Sidecar
**Status:** Recommended
**Context:** Current single Nuxt container works but holds all state in memory. Docker + Gitea CI/CD pipeline has no test stage.
**Decision:** Keep single Nuxt container for app (Nitro serves both API and SPA). Add Redis container as sidecar. Add PostgreSQL (or connect to existing managed instance).
**Improvements to CI/CD:**
- Add test stage (Vitest unit + Playwright E2E) before image publish
- Environment-specific builds (dev/staging/production)
- Health check endpoint for container orchestration
**[NEEDS INPUT]:** Where does PostgreSQL run? Options:
- **Managed service** (e.g., on the same host, or cloud-managed) — less ops burden
- **Another Docker container** — simpler initial setup, more ops responsibility
- **Existing infrastructure** — do you already have a PostgreSQL instance anywhere?
**Consequences:**
- Horizontal scaling possible with Redis handling shared state
- Tests run before every deployment
- Container health checks enable automatic restart on failure
---
## ADR-011: Testing Strategy
**Status:** Recommended
**Context:** Currently one test file exists (`session-manager.test.ts`). No integration or E2E tests. CI/CD publishes without running any tests.
**Decision:**
- **Vitest** for unit tests: Services (EOIService, email, invoice generation), business rule validation, utility functions
- **Playwright** for E2E tests: Critical workflows (interest creation, berth linking, EOI generation, expense management)
- **Integration tests**: Documenso webhook handler, database migrations, auth flow
- Test coverage targets: 80% for services, 100% for business rules (the 13 critical rules listed in the spec)
**Consequences:**
- CI/CD blocks deployment on test failure
- Regression protection for business rules
- Confidence in refactoring
---
## ADR-012: Logging and Monitoring
**Status:** Recommended
**Context:** Nearly every endpoint uses `console.log()` with no level distinction. Debug blocks shipped in production. Audit logging inconsistent (some to database, some to console only).
**Decision:**
- **Structured logger**: `consola` with level control (debug/info/warn/error)
- **Audit logging**: All domain operations write to `audit_logs` table consistently
- **Keep custom alert system**: Per-type failure counters, cooldowns, admin notifications — enhance with dashboard
- **Remove all debug blocks**: No `console.log("DEBUGGING")` in production
**Consequences:**
- Log levels configurable per environment
- Audit trail complete and queryable
- Alert dashboard gives ops team visibility into system health
---
## Decision Summary
| # | Decision | Status |
| ------- | ----------------------------------------- | --------------------------------------------------- |
| ADR-001 | PostgreSQL + Drizzle ORM (replace NocoDB) | Recommended — **needs input on NocoDB admin view** |
| ADR-002 | Keep Keycloak | Accepted |
| ADR-003 | Keep Documenso | Accepted |
| ADR-004 | Keep MinIO | Accepted |
| ADR-005 | Tailwind CSS + component library | Recommended — **needs input on component library** |
| ADR-006 | Pinia + TanStack Query | Recommended |
| ADR-007 | REST + Zod validation | Recommended — **needs input on OpenAPI docs** |
| ADR-008 | Hybrid email (service + IMAP) | Recommended — **needs input on email service** |
| ADR-009 | BullMQ + Redis | Recommended |
| ADR-010 | Single container + Redis sidecar | Recommended — **needs input on PostgreSQL hosting** |
| ADR-011 | Vitest + Playwright testing | Recommended |
| ADR-012 | Structured logging + consistent audit | Recommended |
---
## Proposed Technology Stack
| Layer | Current | Rebuild |
| ---------------- | -------------------------------------- | --------------------------------------------- |
| Framework | Nuxt 3 | Nuxt 3 (latest) |
| UI | Vuetify + Maritime CSS + Nuxt UI | Tailwind CSS + shadcn/ui + Maritime tokens |
| State | Mixed (1 Pinia store + refs + payload) | Pinia + TanStack Query |
| Database | NocoDB (REST API) | PostgreSQL + Drizzle ORM |
| File Storage | MinIO | MinIO (credentials in env) |
| Auth | Keycloak OIDC | Keycloak OIDC (cleaned up) |
| Signing | Documenso | Documenso (consolidated service) |
| Email (outbound) | Nodemailer direct SMTP | Transactional service + SMTP for user mailbox |
| Email (inbound) | IMAP (dual implementation) | IMAP (single, background worker) |
| PDF | PDFKit + @pdfme | @pdfme only |
| Jobs | Nitro tasks + node-cron + setInterval | BullMQ + Redis |
| Cache | File-based + in-memory | Redis |
| Icons | Lucide + mdi | Lucide only |
| Logging | console.log | consola (structured) |
| Testing | None | Vitest + Playwright |
---
## Proposed Build Phases
**Phase 1 — Foundation (weeks 1-2):** Nuxt 3 + TypeScript strict, PostgreSQL + Drizzle schema, Keycloak auth (single composable), Tailwind + Maritime tokens, layout shell
**Phase 2 — Core CRUD (weeks 3-4):** Interest management, berth management, berth-interest linking, RBAC
**Phase 3 — Business Workflows (weeks 5-7):** EOI/Documenso integration, email composer + thread viewer, expense management, invoice creation, file management
**Phase 4 — Admin & Operations (week 8):** Admin dashboard, audit logging, alert/reminder settings, scheduled background tasks
**Phase 5 — Polish & Migration (weeks 9-10):** Data migration from NocoDB → PostgreSQL, performance optimization, PWA (if keeping), testing suite, security hardening

View File

@@ -0,0 +1,316 @@
# Port Nimara CRM — Full-Stack Architecture Comparison
**Compiled:** 2026-03-11
**Context:** AI-first development (Claude Code + Codex), self-hosted Docker, real-time updates required, PostgreSQL + Drizzle ORM already decided, website is separate codebase needing public API
---
## Constraints (apply to ALL options)
These are fixed regardless of framework choice:
| Constraint | Detail |
| ------------ | -------------------------------------------------------------------------------- |
| Database | PostgreSQL + Drizzle ORM |
| Auth | Keycloak OIDC (existing infrastructure) |
| File storage | MinIO S3 (existing infrastructure) |
| E-signatures | Documenso (existing infrastructure, self-hosted) |
| Deployment | Docker containers, self-hosted server, Gitea CI/CD |
| Real-time | Live updates required (berth status changes, interest updates, signature events) |
| Public API | REST endpoints for website berth map + interest registration |
| Development | AI-assisted (Claude Code / Codex writing ~90%+ of code) |
| Team size | Solo developer (Matt) + AI tools |
---
## Option A: Next.js (React) + tRPC
### The Stack
| Layer | Technology |
| ---------------- | ------------------------------------------------------------ |
| Framework | Next.js 15 (App Router) |
| Language | TypeScript (strict) |
| UI library | React 19 |
| Styling | Tailwind CSS + Maritime design tokens |
| Components | shadcn/ui (copy-paste, Radix primitives, fully customizable) |
| Internal API | tRPC v11 (type-safe, no API routes to define for CRM pages) |
| Public API | Next.js Route Handlers (REST endpoints for website) |
| Real-time | tRPC subscriptions over WebSocket (via `ws` or `uWebSocket`) |
| State | TanStack Query (server state) + Zustand (UI state) |
| ORM | Drizzle ORM |
| Jobs | BullMQ + Redis |
| Containerization | Docker (node:20-alpine) + nginx reverse proxy |
### How it handles your CRM use cases
**Interest CRUD**: tRPC procedure `interests.create` / `interests.update` / `interests.list` — fully type-safe from database schema to UI component. Change a field in Drizzle schema, TypeScript immediately flags every component that references it.
**Real-time berth status**: tRPC subscription `berths.onStatusChange` — when a berth is linked to an interest and auto-moves to "Under Offer", all connected clients receive the update via WebSocket. No polling needed.
**EOI/Documenso workflow**: Server-side tRPC procedures handle the full lifecycle. Webhook endpoint is a standard Next.js Route Handler (REST, since Documenso calls it externally).
**Public berth map API**: Standard Next.js Route Handlers — `GET /api/public/berths` and `POST /api/public/interests`. These are separate from tRPC and serve the website.
**Spec sheet import (AI-assisted)**: Upload endpoint receives PDF/Excel, server-side processing with SheetJS (Excel) or PDF extraction, AI API call to interpret/map columns, preview diff shown to admin, confirmation triggers bulk upsert via Drizzle.
**Docker deployment**: Official self-hosting guide. Standalone output mode produces a minimal Node.js server. Requires nginx reverse proxy for production (handles TLS, rate limiting, slow connections). Redis sidecar for BullMQ + session cache.
### Assessment
| Dimension | Rating | Notes |
| ----------------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| AI code generation quality | **Excellent** | Largest training corpus. Claude Code is most fluent with React/Next.js. shadcn/ui has massive example coverage. |
| Component ecosystem | **Excellent** | Deepest of any framework. Every library has a React-first version. TanStack Table, React Hook Form, Recharts, etc. |
| tRPC integration | **Excellent** | First-class support. Most tRPC examples are Next.js. |
| Real-time (WebSocket) | **Good** | Works but requires separate WebSocket server alongside Next.js (Vercel doesn't support WS, but irrelevant for self-hosting). Needs `next-ws` or custom server. |
| Self-hosted Docker | **Good** | Works well with standalone output mode. Requires nginx reverse proxy. More configuration than Nuxt/SvelteKit. |
| Bundle size / performance | **Adequate** | React runtime is larger than alternatives. For an internal CRM used by a small team, this is irrelevant. |
| Learning curve (reading code) | **Moderate** | JSX, hooks, useEffect patterns. React has more "gotchas" than Vue/Svelte but AI handles them well. |
| Long-term maintenance | **Excellent** | Backed by Vercel. Massive community. Won't disappear. |
**Pros:**
- AI tools generate the most reliable code in this ecosystem
- Largest component library ecosystem by far
- tRPC + Drizzle + Next.js is an extremely well-documented combination
- shadcn/ui gives you beautiful, accessible components you fully own and can restyle to Maritime
- TanStack Query handles caching, optimistic updates, background refresh out of the box
- Most Stack Overflow answers, most GitHub examples, most blog posts
**Cons:**
- React's mental model (hooks, closures, re-renders) can be confusing when reading/debugging
- WebSocket support needs custom server setup (not complex, but not built-in)
- Next.js is optimized for Vercel — self-hosting works but you miss some platform features
- Heavier runtime than Svelte (irrelevant for small team internal tool)
- App Router patterns still evolving — some tutorials use old Pages Router
---
## Option B: Nuxt 3 (Vue) + tRPC
### The Stack
| Layer | Technology |
| ---------------- | ------------------------------------------------------------------- |
| Framework | Nuxt 3 (latest) |
| Language | TypeScript (strict) |
| UI library | Vue 3 (Composition API) |
| Styling | Tailwind CSS + Maritime design tokens |
| Components | Nuxt UI v3 (Radix Vue based, Tailwind-native) OR Radix Vue + custom |
| Internal API | trpc-nuxt (community adapter) |
| Public API | Nitro server routes (REST endpoints for website) |
| Real-time | Nitro WebSocket (experimental) or Socket.io alongside Nitro |
| State | VueQuery / TanStack Query Vue (server state) + Pinia (UI state) |
| ORM | Drizzle ORM |
| Jobs | BullMQ + Redis |
| Containerization | Docker (node:20-alpine), Nitro handles both API + SPA |
### How it handles your CRM use cases
**Interest CRUD**: trpc-nuxt procedures with same type-safety pattern as Next.js. Vue's Composition API (`ref`, `computed`, `watch`) is arguably more intuitive than React hooks for someone reading code.
**Real-time berth status**: Nitro's experimental WebSocket support (CrossWasm v1 in Nitro v3). Less mature than the Next.js WebSocket ecosystem. Alternatively, run Socket.io alongside Nitro — works but adds complexity.
**EOI/Documenso workflow**: Nitro server routes handle webhooks. Same pattern as current system (Nitro is what you already have).
**Public berth map API**: Nitro server routes — cleaner than Next.js Route Handlers. Nitro is genuinely good at this.
**Spec sheet import**: Same approach as Next.js — server-side processing with AI interpretation.
**Docker deployment**: Nitro's output is a self-contained Node.js server. No reverse proxy strictly required (though recommended). Simpler Docker setup than Next.js.
### Assessment
| Dimension | Rating | Notes |
| ----------------------------- | ------------- | ---------------------------------------------------------------------------------------------------------------- |
| AI code generation quality | **Good** | Well-represented in training data. Slightly fewer examples than React. Claude Code handles Vue/Nuxt competently. |
| Component ecosystem | **Good** | Nuxt UI v3 is excellent and built specifically for Nuxt. Fewer third-party options than React. |
| tRPC integration | **Adequate** | Community adapter (trpc-nuxt). Works but fewer examples, less battle-tested than Next.js tRPC. |
| Real-time (WebSocket) | **Adequate** | Nitro WebSocket is experimental. Works with adapter-node but less mature. Socket.io fallback is proven. |
| Self-hosted Docker | **Excellent** | Nitro produces clean standalone output. Simplest Docker setup of the three. |
| Bundle size / performance | **Good** | Smaller than React, larger than Svelte. |
| Learning curve (reading code) | **Easy** | Vue's template syntax is the most readable. `<script setup>` is clean. Closest to plain HTML. |
| Long-term maintenance | **Good** | Backed by Nuxt Labs. Strong community. Stable but smaller than React ecosystem. |
**Pros:**
- You already have familiarity with the patterns from the current codebase
- Vue's template syntax is the most human-readable (easiest to understand when reviewing AI output)
- Nitro server is genuinely excellent for API routes and deployment flexibility
- Nuxt UI v3 is tightly integrated and well-maintained by the Nuxt team
- Simplest Docker deployment (Nitro just works)
- Pinia + VueQuery is a clean state management story
**Cons:**
- Smaller AI training corpus than React — more edge cases where Claude Code may hallucinate APIs
- tRPC support is community-maintained, not official
- WebSocket/real-time support is less mature than alternatives
- Fewer third-party component libraries to choose from
- Risk of repeating patterns from the current codebase rather than improving them
---
## Option C: SvelteKit + tRPC
### The Stack
| Layer | Technology |
| ---------------- | --------------------------------------------------------------- |
| Framework | SvelteKit 2 |
| Language | TypeScript (strict) |
| UI library | Svelte 5 (runes) |
| Styling | Tailwind CSS + Maritime design tokens |
| Components | Skeleton UI or shadcn-svelte (Bits UI based) |
| Internal API | trpc-sveltekit (community adapter) |
| Public API | SvelteKit server routes (REST endpoints for website) |
| Real-time | trpc-sveltekit experimental WS or Socket.io sidecar |
| State | TanStack Query Svelte (server state) + Svelte stores (UI state) |
| ORM | Drizzle ORM |
| Jobs | BullMQ + Redis |
| Containerization | Docker (node:20-alpine) via adapter-node |
### How it handles your CRM use cases
**Interest CRUD**: trpc-sveltekit procedures. Svelte's reactivity model is the simplest of the three — `$state` rune replaces React's `useState`, Vue's `ref`. Less boilerplate.
**Real-time berth status**: Experimental WebSocket via trpc-sveltekit (only works with adapter-node). Less mature than both Next.js and Nuxt options.
**EOI/Documenso workflow**: SvelteKit server routes. Clean pattern but fewer examples specific to webhook handling.
**Public berth map API**: SvelteKit server routes. Works well, similar to Nuxt's Nitro routes.
**Spec sheet import**: Same approach as others. Fewer AI-processing library examples in Svelte ecosystem.
**Docker deployment**: adapter-node produces a clean, lightweight Node server. Smallest footprint of the three.
### Assessment
| Dimension | Rating | Notes |
| ----------------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------- |
| AI code generation quality | **Adequate** | Smallest training corpus. Svelte 5 runes are new — AI tools may generate Svelte 4 patterns. More manual correction needed. |
| Component ecosystem | **Limited** | Skeleton UI and shadcn-svelte exist but are less mature. Many libraries are React/Vue only. |
| tRPC integration | **Adequate** | Community adapter. WebSocket support explicitly marked "experimental". |
| Real-time (WebSocket) | **Limited** | SvelteKit doesn't natively support WebSockets. Experimental via adapter-node only. Most constrained option. |
| Self-hosted Docker | **Excellent** | Smallest container image. Lowest resource usage. Cleanest adapter-node output. |
| Bundle size / performance | **Excellent** | 40-60% smaller bundles than React. Fastest runtime. Lowest memory usage. |
| Learning curve (reading code) | **Easy** | Most intuitive syntax. Closest to vanilla HTML/JS. Svelte 5 runes are clean. |
| Long-term maintenance | **Good** | Strong community, Vercel backing. Smaller ecosystem means fewer resources if you hit edge cases. |
**Pros:**
- Smallest bundle size and lowest resource usage (best for self-hosting efficiency)
- Most intuitive syntax — easiest to read and understand as a non-programmer
- Svelte 5 runes are the cleanest reactivity model available
- Lightest Docker footprint
- Developer experience is genuinely the best of the three for humans
**Cons:**
- Smallest AI training corpus — Claude Code and Codex will produce less reliable code, especially for complex patterns
- Svelte 5 is recent — AI tools may mix up Svelte 4 and Svelte 5 syntax
- Weakest real-time/WebSocket story — experimental only
- Smallest component ecosystem — you'll build more from scratch
- Fewer Stack Overflow answers and GitHub examples to draw from when stuck
- Some React-only libraries have no Svelte equivalent (form builders, advanced table components, etc.)
---
## Option D: Next.js (React) + REST (no tRPC)
Same as Option A but replacing tRPC with traditional REST API routes. Included because tRPC adds complexity and you may prefer a simpler architecture.
### Differences from Option A
| Aspect | tRPC (Option A) | REST (Option D) |
| ---------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------- |
| API definition | Procedures on server, auto-typed on client | Route Handlers on server, manual fetch + types on client |
| Type safety | Automatic end-to-end | Manual (Zod schemas shared, but not enforced across boundary) |
| Code generation | Less code (no fetch wrappers needed) | More code (API client layer, type definitions) |
| Public API for website | Separate REST routes alongside tRPC | Same REST routes serve both CRM and website |
| Real-time | tRPC subscriptions (WebSocket) | Socket.io or Server-Sent Events (separate from API) |
| External consumers | tRPC only works with TypeScript clients | REST works with any client (mobile app, website, third-party) |
| Complexity | Higher (tRPC + REST coexist for different consumers) | Lower (one pattern for everything) |
| Debugging | tRPC calls don't show in browser Network tab as clean REST calls | Standard HTTP requests, easy to inspect |
### Assessment
| Dimension | Rating | Notes |
| -------------------------- | ------------- | ---------------------------------------------------------------------------------------- |
| AI code generation quality | **Excellent** | Even more training data for REST patterns than tRPC. |
| Simplicity | **Excellent** | One pattern. No tRPC learning curve. Every endpoint is a standard HTTP route. |
| Type safety | **Good** | Zod validation + shared types. Not automatic like tRPC but works well. |
| Real-time | **Good** | Socket.io alongside Next.js is well-documented. Or Server-Sent Events for simpler cases. |
| External API consumers | **Excellent** | Same API serves CRM, website, and any future mobile app or integration. |
| OpenAPI compatibility | **Excellent** | REST routes can auto-generate OpenAPI docs. tRPC cannot. |
**Pros:**
- Simplest mental model — everything is HTTP requests
- One API pattern instead of tRPC + REST coexisting
- Easier to debug (browser Network tab shows clean requests)
- Same API serves CRM and website (no separate public routes needed)
- If you ever build a mobile app or external integration, the API is ready
- More AI training examples for REST than tRPC
**Cons:**
- More boilerplate (API client layer, manual type sharing)
- No automatic type safety across client-server boundary
- Need to manually keep request/response types in sync
- Real-time requires a separate Socket.io or SSE layer
---
## Head-to-Head Comparison
| Factor | Next.js + tRPC (A) | Nuxt 3 + tRPC (B) | SvelteKit + tRPC (C) | Next.js + REST (D) |
| ----------------------------- | ------------------ | ----------------- | -------------------- | ------------------ |
| **AI code gen reliability** | ★★★★★ | ★★★★ | ★★★ | ★★★★★ |
| **Component ecosystem** | ★★★★★ | ★★★★ | ★★★ | ★★★★★ |
| **Real-time maturity** | ★★★★ | ★★★ | ★★ | ★★★★ |
| **Docker self-hosting** | ★★★★ | ★★★★★ | ★★★★★ | ★★★★ |
| **Type safety (end-to-end)** | ★★★★★ | ★★★★ | ★★★★ | ★★★ |
| **Code readability** | ★★★ | ★★★★ | ★★★★★ | ★★★ |
| **Architecture simplicity** | ★★★ | ★★★ | ★★★ | ★★★★★ |
| **Bundle size / performance** | ★★★ | ★★★★ | ★★★★★ | ★★★ |
| **Public API flexibility** | ★★★ | ★★★★ | ★★★ | ★★★★★ |
| **Long-term ecosystem** | ★★★★★ | ★★★★ | ★★★★ | ★★★★★ |
---
## Recommendation Matrix
**If AI code generation reliability is your top priority** → Option A or D (Next.js)
**If deployment simplicity matters most** → Option B (Nuxt) or C (SvelteKit)
**If you want the simplest architecture to understand and maintain** → Option D (Next.js + REST)
**If you want maximum type safety with minimum code** → Option A (Next.js + tRPC)
**If performance and resource efficiency matter most** → Option C (SvelteKit)
---
## Author's Recommendation
**Option D (Next.js + REST)** is the strongest fit for your specific situation. Here's why:
1. **AI-first development**: Next.js React has the deepest training corpus. REST has even more examples than tRPC. You're optimizing for AI output quality, and this combination maximizes it.
2. **Architectural simplicity**: tRPC is elegant but adds a second API paradigm alongside the REST routes you need for the website anyway. With REST-only, every endpoint follows the same pattern — simpler for AI tools to generate consistently and simpler for you to understand.
3. **Real-time**: Socket.io alongside Next.js is battle-tested and well-documented. Simpler than tRPC subscriptions, which require a custom WebSocket server.
4. **Public API**: The same REST endpoints serve the CRM frontend, the website berth map, and any future integration. No separate public routes to maintain.
5. **Future-proofing**: If you ever need a mobile app, an external integration, or OpenAPI documentation, REST is ready. tRPC locks you into TypeScript clients.
6. **Self-hosting**: Next.js standalone output in Docker behind nginx is well-documented. Not the simplest (Nuxt wins there), but the trade-off for the ecosystem advantages is worth it.
The one thing you'd give up vs. Option A is automatic end-to-end type safety. But with Zod schemas shared between server and client plus TanStack Query's typed hooks, the gap is small in practice.
**Runner-up**: Option A (Next.js + tRPC) if you value automatic type safety highly and don't mind two API paradigms coexisting.

View File

@@ -0,0 +1,358 @@
# Port Nimara CRM — Final Architecture Decisions
**Compiled:** 2026-03-11
**Status:** All decisions finalized and approved
---
## Summary of Decisions
| # | Decision | Choice |
| ------- | ------------------- | ------------------------------------------------------------------- |
| ADR-001 | Database | PostgreSQL + Drizzle ORM. NocoDB dropped entirely. |
| ADR-002 | Framework | Next.js (React) + REST API + Tailwind CSS |
| ADR-003 | Authentication | Better Auth (integrated). Keycloak dropped entirely. |
| ADR-004 | E-Signatures | Documenso (keep, self-hosted) |
| ADR-005 | File Storage | MinIO (keep, credentials moved to env vars) |
| ADR-006 | Transactional Email | Poste.io at mail.portnimara.com (existing infrastructure) |
| ADR-007 | User Mailbox Email | SMTP/IMAP with provider presets (Google Workspace, Outlook, Custom) |
| ADR-008 | Real-time | Socket.io (WebSocket alongside Next.js) |
| ADR-009 | API Documentation | OpenAPI/Swagger auto-generated from Zod schemas (public endpoints) |
| ADR-010 | Job Queue | BullMQ + Redis |
| ADR-011 | PostgreSQL Hosting | Docker container on same VPS, automated nightly backups to MinIO |
| ADR-012 | Multi-tenancy | Port-scoped data isolation with global + per-port role system |
---
## ADR-001: PostgreSQL + Drizzle ORM (NocoDB Dropped Entirely)
**Status:** Accepted
**Context:** NocoDB served as both database and admin UI via REST API. Caused: no transactions, no JOINs, no migrations, no type safety, N+1 queries, manual rollback code, data consistency bugs. Updating records was slow and cumbersome. The website berth map currently reads directly from NocoDB.
**Decision:** PostgreSQL as the single source of truth. Drizzle ORM for type-safe schema, migrations, and queries. NocoDB removed entirely — no shadow copy, no dual-write.
**Migration path:**
- Website berth map reads from CRM's public REST API (`GET /api/public/berths`) instead of NocoDB
- Interest registration from website posts to `POST /api/public/interests`
- Spec sheet import (PDF/Excel) via AI-assisted parsing flow in the CRM admin
- Export NocoDB data → transform → seed PostgreSQL → verify → decommission NocoDB
**Consequences:**
- Every API endpoint rewritten against Drizzle ORM
- Real transactions for multi-step operations (invoice creation, EOI generation, berth linking)
- Proper foreign key constraints and referential integrity
- Database migrations tracked in version control
- AI-assisted berth spec import replaces manual NocoDB field editing
---
## ADR-002: Next.js (React) + REST API + Tailwind CSS
**Status:** Accepted
**Context:** Evaluated four architecture options: Next.js + tRPC, Nuxt 3 + tRPC, SvelteKit + tRPC, and Next.js + REST. Development is AI-first (Claude Code / Codex writing ~90%+ of code). Self-hosted Docker deployment required. Real-time updates needed.
**Decision:** Next.js 15 (App Router) with REST API endpoints, Tailwind CSS styling with Port Nimara design tokens (see `15-DESIGN-TOKENS.md`), and shadcn/ui as the component library foundation. Rich text editing via TipTap (headless, ProseMirror-based). See `14-TECHNICAL-DECISIONS.md` for full dependency list.
**Rationale:**
- React/Next.js has the largest AI training corpus — Claude Code and Codex produce the most reliable code
- REST provides one consistent API pattern for both CRM frontend and external consumers (website berth map)
- No tRPC means no dual-paradigm complexity (tRPC + REST coexisting)
- Same REST endpoints serve CRM, website, and any future mobile app or integration
- Largest component ecosystem of any framework
- Self-hosting with standalone output mode + nginx reverse proxy is well-documented
**Full stack:**
| Layer | Technology |
| ---------------- | ---------------------------------------------------------------------------- |
| Framework | Next.js 15 (App Router) |
| Language | TypeScript (strict) |
| UI | React 19 |
| Styling | Tailwind CSS + Maritime design tokens |
| Components | shadcn/ui (Radix primitives, fully customizable) — **confirmed** |
| Rich text editor | TipTap (headless, ProseMirror-based, extension architecture) — **confirmed** |
| API | REST with Zod validation |
| Public API docs | OpenAPI/Swagger auto-generated from Zod schemas |
| Real-time | Socket.io (WebSocket) |
| Server state | TanStack Query |
| UI state | Zustand |
| ORM | Drizzle ORM |
| Database | PostgreSQL |
| Jobs | BullMQ + Redis |
| Deployment | Docker (node:20-alpine) + nginx reverse proxy |
---
## ADR-003: Better Auth (Keycloak Dropped)
**Status:** Accepted
**Context:** Keycloak worked but added a separate service to maintain, required redirect to external login page (disjointed UX), and the current implementation had critical security issues (dev bypass, weak internal auth). The CRM needs: integrated login page matching CRM styling, admin-created user accounts, email-based password set/reset, future 2FA support, multi-port tenancy with granular role permissions.
**Decision:** Better Auth running inside the Next.js app. No separate auth service. Keycloak decommissioned.
**What Better Auth handles:**
- Password hashing (argon2/bcrypt)
- Session cookies (httpOnly, secure, CSRF-protected)
- Login/logout flows
- Password set/reset via email (sent through Poste.io)
- Future 2FA (TOTP plugin)
**What the app handles (custom PostgreSQL tables):**
- Multi-port tenancy
- Custom role definitions with granular permissions
- Port-scoped role assignments
- Global vs. per-port role overrides
**User account lifecycle:**
1. Super admin creates user account in CRM admin panel
2. Admin assigns user to one or more ports with specific roles
3. System sends "Set your password" email via Poste.io
4. User clicks link, sets password, can now log in
5. Password resets follow the same email flow
**Multi-tenancy data model:**
```
users (managed by Better Auth)
├── id, email, password_hash, name, is_super_admin
ports
├── id, name, slug, settings
roles
├── id, name, permissions (JSON), is_global
├── Example: "Sales Manager" with {view_interests: true, delete_interests: false, export_expenses: true, ...}
port_role_overrides (optional per-port tweaks to global roles)
├── port_id, role_id, permission_overrides (JSON)
├── Example: Port B overrides "Sales Manager" to add {manage_expenses: true}
user_port_roles (who can access what)
├── user_id, port_id, role_id
├── Example: Sarah → Port Nimara → Sales Manager
├── Example: Sarah → Port B → Sales Manager
├── Example: Tom → Port Nimara → Read Only
```
**Authorization flow:**
1. User logs in (Better Auth validates credentials, creates session)
2. App checks `user_port_roles` — which ports can this user access?
3. If multiple ports → show port switcher
4. Every API request includes current port context (header or cookie)
5. Every database query scoped to current port
6. Permission check: load user's role at current port → check against role permissions (with port override if exists)
7. Super admin bypasses port scoping — sees all data across all ports
---
## ADR-004: Keep Documenso
**Status:** Accepted (unchanged from original ADR)
**Decision:** Keep Documenso (self-hosted). Consolidate two duplicate implementations into single `EOIService`. Normalize signature events as first-class database tables.
---
## ADR-005: Keep MinIO
**Status:** Accepted (unchanged from original ADR)
**Decision:** Keep MinIO. Credentials moved to environment variables. Database-backed file metadata in PostgreSQL. Formalized storage namespaces. Also serves as backup destination for PostgreSQL dumps.
---
## ADR-006: Transactional Email via Poste.io
**Status:** Accepted
**Context:** Existing Poste.io mail server at mail.portnimara.com, currently used for noreply@portnimara.com. No need for an external email service.
**Decision:** All system-generated email (password resets, EOI notifications, signature reminders, admin alerts) sent via Nodemailer pointed at Poste.io SMTP. Email templates extracted to proper template files (MJML or HTML with variable substitution).
**Consequences:**
- No external email service dependency
- No sending limits
- Self-hosted infrastructure consistent with everything else
- Need to ensure DKIM/SPF records are properly configured for deliverability
---
## ADR-007: User Mailbox via SMTP/IMAP with Provider Presets
**Status:** Accepted
**Context:** Team uses Google Workspace for email. May switch to Outlook in the future. Need a provider-agnostic solution.
**Decision:** Generic SMTP/IMAP connection in CRM settings with provider presets:
| Preset | SMTP Server | Port | IMAP Server | Port |
| ----------------------- | ------------------ | ------------- | --------------------- | ------------- |
| Google Workspace | smtp.gmail.com | 587 (TLS) | imap.gmail.com | 993 (TLS) |
| Outlook / Microsoft 365 | smtp.office365.com | 587 (TLS) | outlook.office365.com | 993 (TLS) |
| Custom | User-provided | User-provided | User-provided | User-provided |
User selects preset (auto-fills server/port), enters email + App Password, credentials stored encrypted in PostgreSQL. Switching providers is just changing preset and entering new credentials.
**Future upgrade path:** Google OAuth / Microsoft OAuth as alternative connection method (no App Password needed). Same underlying Nodemailer/IMAP code — only credential acquisition changes.
---
## ADR-008: Real-time via Socket.io
**Status:** Accepted
**Context:** CRM needs live updates — berth status changes, interest updates, signature events visible to all connected users without page refresh.
**Decision:** Socket.io alongside Next.js. Events emitted server-side when data changes (berth linked → emit `berth:statusChanged`, interest updated → emit `interest:updated`). Client subscribes to relevant channels. Port-scoped — users only receive events for their current port.
**Consequences:**
- Redis adapter for Socket.io enables multi-instance scaling
- WebSocket connection established on app load, scoped to user's current port
- Event-driven updates for: berth status, interest changes, EOI signature progress, new email threads
---
## ADR-009: OpenAPI for Public API
**Status:** Accepted
**Context:** Website berth map and interest registration form need documented API endpoints. Future mobile apps or integrations may consume the same API.
**Decision:** Auto-generate OpenAPI/Swagger documentation from Zod schemas for public-facing endpoints. Available at `/api-docs`. Internal CRM endpoints documented optionally.
**Setup:** `swagger-jsdoc` or `next-swagger-doc` with Zod-to-OpenAPI bridge. Near-zero ongoing maintenance since docs stay in sync with validation schemas.
---
## ADR-010: Job Queue via BullMQ + Redis
**Status:** Accepted (unchanged from original ADR)
**Decision:** BullMQ + Redis replaces Nitro experimental tasks and setInterval/node-cron. All background jobs (notification processing, signature polling, EOI reminders, email sync, currency refresh, database backups) run through BullMQ with proper tracking, retries, and dead letter queue.
Redis also serves as: Socket.io adapter (multi-instance), session cache, and general application cache.
---
## ADR-011: PostgreSQL in Docker on Same VPS
**Status:** Accepted
**Context:** All services (MinIO, Documenso, Poste.io, CRM) run as Docker containers on a single VPS. Data volume is small (hundreds of berths, hundreds to low thousands of interests).
**Decision:** PostgreSQL as a Docker container in the same Docker Compose stack. Named volume for data persistence. Automated nightly backups via `pg_dump` cron job, backup files stored in MinIO.
**Consequences:**
- Simplest deployment — same pattern as all other services
- Named Docker volume survives container restarts and upgrades
- MinIO backup provides off-disk redundancy
- When multi-port grows to significant scale, can migrate to dedicated database server without application changes (just update connection string)
---
## ADR-012: Multi-Port Tenancy
**Status:** Accepted
**Context:** Port Nimara CRM will expand to manage multiple marina ports. Each port has its own dataset (berths, interests, expenses, invoices). Users can be assigned to one or more ports with different roles at each.
**Decision:** Port-scoped data isolation at the database level. Every core table has a `port_id` foreign key. Every query filters by current port context. Role system supports global templates with per-port overrides.
**Data scoping:**
- Every record (berth, interest, expense, invoice, file, audit log) has `port_id`
- API middleware extracts current port from request context
- All Drizzle queries include `.where(eq(table.portId, currentPortId))`
- Super admin can query across ports (unified dashboard, future feature)
**Role system:**
- Global roles defined once, available across all ports
- Per-port overrides allow customization without duplicating role definitions
- New ports inherit all global roles automatically
- Admin at each port can assign users to roles within their port
- Super admin manages global roles and cross-port user assignments
---
## Final Technology Stack
| Layer | Current (v1) | Rebuild (v2) |
| ------------------- | -------------------------------------- | ---------------------------------------------------------- |
| Framework | Nuxt 3 (Vue 3) | **Next.js 15 (React 19)** |
| UI | Vuetify + Maritime CSS + Nuxt UI | **Tailwind CSS + shadcn/ui + Maritime tokens** |
| Server State | Mixed (1 Pinia store + refs + payload) | **TanStack Query** |
| UI State | Scattered refs | **Zustand** |
| API | ~100 mixed REST/RPC endpoints | **~247 REST endpoints + Zod validation** |
| Public API docs | None | **OpenAPI/Swagger (auto-generated)** |
| Real-time | None | **Socket.io** |
| Database | NocoDB (REST API) | **PostgreSQL + Drizzle ORM** |
| Auth | Keycloak OIDC (external) | **Better Auth (integrated)** |
| File Storage | MinIO (hardcoded creds) | **MinIO (env vars, DB-backed metadata)** |
| E-Signatures | Documenso | **Documenso (consolidated EOIService)** |
| Transactional Email | Raw SMTP (mixed configs) | **Nodemailer → Poste.io** |
| User Email | IMAP/SMTP (dual impl, in-memory creds) | **IMAP/SMTP with provider presets (encrypted DB storage)** |
| Email Templates | Inline HTML strings | **MJML template files** |
| PDF | PDFKit + @pdfme | **@pdfme only** |
| Jobs | Nitro tasks + node-cron + setInterval | **BullMQ + Redis** |
| Cache | File-based + in-memory | **Redis** |
| Icons | Lucide + mdi | **Lucide only** |
| Logging | console.log | **Structured logger (pino or consola)** |
| Testing | None | **Vitest + Playwright** |
| Deployment | Docker + Gitea CI/CD (no tests) | **Docker + Gitea CI/CD (with test stage)** |
| Multi-tenancy | None | **Port-scoped with global + per-port roles** |
| Spec Import | Manual NocoDB editing | **AI-assisted PDF/Excel parsing** |
---
## Docker Compose Services (Production)
```
services:
crm-app # Next.js (CRM frontend + API + Socket.io)
postgres # PostgreSQL database
redis # BullMQ jobs, Socket.io adapter, cache, sessions
minio # S3-compatible file storage (existing)
documenso # E-signature service (existing)
nginx # Reverse proxy, TLS termination, rate limiting
```
Existing services (separate from CRM stack):
- Poste.io at mail.portnimara.com (transactional email)
- Gitea (CI/CD, git hosting)
---
## Build Phases (Revised)
**Phase 1 — Foundation (weeks 1-2)**
1. Next.js 15 project with TypeScript strict mode
2. PostgreSQL + Drizzle ORM with initial schema (ports, users, roles, permissions)
3. Better Auth integration (login, logout, password set/reset, session management)
4. Tailwind CSS with Maritime design tokens
5. Multi-port middleware (port context in every request)
6. Layout shell (sidebar, port switcher, mobile nav, routing)
7. Redis + Socket.io setup
**Phase 2 — Core CRUD (weeks 3-4)** 8. Berth management (list, detail, status, specs) 9. Interest management (list, detail, create, edit, delete) 10. Berth-to-interest linking with auto-status rules 11. Role-based access control (permission checks on every endpoint + UI) 12. Public API for website berth map + interest registration 13. OpenAPI documentation for public endpoints
**Phase 3 — Business Workflows (weeks 5-7)** 14. EOI document generation (consolidated EOIService) + Documenso integration 15. Email composer + thread viewer (SMTP/IMAP with provider presets) 16. Expense management with TanStack Query 17. Invoice creation and payment tracking 18. File management (MinIO + DB-backed metadata) 19. AI-assisted berth spec sheet import (PDF/Excel)
**Phase 4 — Admin & Operations (week 8)** 20. Admin dashboard (user management, role builder, port management) 21. Audit logging (all domains, consistent) 22. Alert settings and monitoring dashboard 23. Reminder/notification system 24. BullMQ job dashboard
**Phase 5 — Polish & Migration (weeks 9-10)** 25. Data migration from NocoDB → PostgreSQL 26. Real-time events (Socket.io) for all live-update scenarios 27. Performance optimization (pagination, query optimization) 28. Testing suite (Vitest unit + Playwright E2E) 29. Security hardening (rate limiting, CSP headers, input sanitization) 30. Docker Compose production configuration with nginx

879
06-MASTER-FEATURE-SPEC.md Normal file
View File

@@ -0,0 +1,879 @@
# Port Nimara CRM — Master Feature Specification (V1)
**Compiled:** 2026-03-11
**Status:** Final — all features confirmed for V1 (single release, no phased rollout)
**Constraint:** "There is no v1 or v2. It's ONLY v1." — Every feature listed here ships in the one and only build.
---
## Table of Contents
1. [Client Management](#1-client-management)
2. [Interest Management](#2-interest-management)
3. [Berth Management](#3-berth-management)
4. [EOI & Document Signing](#4-eoi--document-signing)
5. [Expenses & Invoicing](#5-expenses--invoicing)
6. [File Management](#6-file-management)
7. [Email System](#7-email-system)
8. [Dashboard & Analytics](#8-dashboard--analytics)
9. [Reminders & Google Calendar Integration](#9-reminders--google-calendar-integration)
10. [Notification Center](#10-notification-center)
11. [Search & Filtering](#11-search--filtering)
12. [Admin Panel](#12-admin-panel)
13. [Audit System](#13-audit-system)
14. [Multi-Port Tenancy](#14-multi-port-tenancy)
15. [Authentication & Authorization](#15-authentication--authorization)
16. [Public API & Website Integration](#16-public-api--website-integration)
17. [AI-Assisted Features](#17-ai-assisted-features)
18. [Data Import/Export Center](#18-data-importexport-center)
19. [Webhooks](#19-webhooks)
20. [Scheduled Reports](#20-scheduled-reports)
21. [System Monitoring & Alerts](#21-system-monitoring--alerts)
22. [Client Portal](#22-client-portal)
23. [UX & Quality of Life](#23-ux--quality-of-life)
---
## 1. Client Management
Clients are the anchor records in the system. A client is a person or entity interested in one or more berths.
### 1.1 Client Record
- **Core fields:** Full name, company/entity name (optional), nationality
- **Multi-contact support:** Each client can have multiple contact entries, each with:
- Channel: email, phone, WhatsApp, other
- Value: the actual address/number
- Label: primary, secondary, work, personal, broker, assistant
- A client can have multiple entries per channel (e.g., two phone numbers, three emails)
- **Proxy/representative support:** A client record can be marked as a proxy. Fields for:
- Proxy type: broker, representative, family member, legal counsel, other
- Actual owner name (if known — may be learned later)
- Relationship notes
- **Vessel details:** Yacht name, length, width, draft (imperial + metric), berth size desired
- **Communication preferences:** Preferred contact method, preferred language, timezone
- **Tags/labels:** Color-coded, user-defined tags (VIP, Broker-referred, Returning client, etc.)
- **Source tracking:** How the client was acquired (website registration, manual entry, referral, broker)
- **Internal notes:** Freeform timestamped notes thread (see Section 1.3)
- **Activity timeline:** Chronological feed of all events (see Section 1.4)
- **Files:** Client-grouped file storage (see Section 6)
- **Relationship mapping:** Links between clients (referral chains, broker → client, family connections)
### 1.2 Duplicate Detection & Merging
- **Auto-merge on website registration:** When a website registration comes in with an email matching an existing client, the system automatically creates a new Interest under the existing client record (not a new client).
- **Fuzzy match alerts:** When a new client is created (manually or via website) and fuzzy matching detects potential duplicates (similar name + phone, similar name + address), the system creates a merge alert for manual review.
- **Manual merge tool:** Select two client records → preview side-by-side → choose which fields to keep from each → merge into one record. All interests, files, notes, and timeline entries transfer to the surviving record. The merged-away record becomes a redirect entry in the audit log.
- **Merge rules:** Same email = auto-merge (new interest). Everything else = alert for manual decision.
### 1.3 Internal Notes / Comments
- Freeform timestamped notes on each client record
- Any user can add notes
- Notes are permanent (editable by author within 15 minutes, then locked)
- Supports @mentions of other CRM users
- Notes appear in the activity timeline
### 1.4 Activity Timeline
- Chronological feed on each client record showing every event:
- Status changes (on any linked interest)
- Emails sent/received
- EOIs generated, sent, signed
- Documents uploaded/signed
- Berths linked/unlinked
- Notes added
- Files uploaded
- Invoices created
- Reminders created/completed
- Manual edits to any field (from audit log)
- Filterable by event type
- Pulled from the audit log — no separate storage needed
### 1.5 Client Relationship Mapping
- Visual/list view of relationships between clients
- Relationship types: referred by, broker for, family member of, same vessel, custom
- When viewing a client, related clients are shown with relationship context
- Helps salesperson identify referral networks and broker portfolios
---
## 2. Interest Management
An Interest represents a client's interest in a specific berth (or general interest with no berth assigned yet).
### 2.1 Interest Record
- **Link to client:** Every interest belongs to one client. One client can have multiple interests.
- **Link to berth:** Optional — interests can exist without a berth assignment (general inquiry)
- **Pipeline stage:** Open → Details Sent → In Communication → Visited → Signed EOI and NDA → 10% Deposit → Contract → Completed
- **Stage flexibility:** All stages are manually overridable. The system never forces a workflow. Salesperson can skip stages, move backwards, or manually set any status at any time.
- **Automated convenience:** Berth status transitions use a configurable rules engine (auto, suggest via toast prompt, or off — admin-configurable per trigger action). Pipeline stage auto-advances on key events (e.g., EOI send) but can always be manually overridden.
- **Lead category:** General Interest, Specific Qualified Interest, Hot Lead, etc.
- **Source:** Website, manual, referral, broker
- **EOI tracking:** EOI status, Documenso document ID, signing links, signature timestamps
- **Contract tracking:** Contract status, deposit status, reservation agreement status
- **Milestone tracking:** Berth info sent, contract sent, deposit received, contract executed
- **Date tracking:** Date added, last contacted, last status change, all milestone dates
- **Internal notes:** Separate from client notes — interest-specific notes thread
- **Activity timeline:** Interest-specific timeline (subset of client timeline filtered to this interest)
### 2.2 UI Presentation
- **Single interest:** When a client has one interest, the client and interest details display inline as one unified view.
- **Multiple interests:** When a client has multiple interests, each interest appears as an expandable section within the client view. Client details at the top, interests below as tabs or accordions.
- **Interest list view:** Standalone list of all interests across all clients, filterable by pipeline stage, berth, date range, salesperson, source, tags.
### 2.3 Pipeline View
- Kanban board view of interests grouped by pipeline stage
- Drag-and-drop to move between stages (with confirmation for significant stage changes)
- Card shows: client name, berth (if assigned), days in stage, next action due
- Filterable by berth area, date range, salesperson
### 2.4 Waiting List
- When a berth is sold/contracted, other clients interested in it can be placed on a waiting list
- Waiting list tracks: position, date added, priority (normal/high), notification preference
- If a deal falls through and the berth becomes available again, the system alerts everyone on the waiting list in order
- Waiting list visible on the berth detail view
---
## 3. Berth Management
### 3.1 Berth Record
All fields from the existing berths CSV, organized:
- **Identity:** Mooring Number, Area, Status (Available / Under Offer / Sold)
- **Dimensions (imperial + metric):** Length, Width, Draft, Nominal Boat Size
- **Water:** Water Depth, Water Depth Is Minimum (flag)
- **Infrastructure:** Side Pontoon, Power Capacity, Voltage, Mooring Type, Cleat Type, Cleat Capacity, Bollard Type, Bollard Capacity, Access
- **Commercial:** Price, Bow Facing
- **Map data:** SVG path coordinates, x, y, transform, fontSize (for interactive berth map)
- **Status control:** Manual override always available. Automatic transitions governed by configurable rules engine (see BR-001 in Business Rules). `status_last_modified_at` tracked.
- **Berth Approved:** Approval flag
- **Tenure:** Tenure type (permanent / fixed-term), term length (years), start date, expiration date
- **Linked interests:** All interests assigned to this berth
- **Waiting list:** Ordered list of clients waiting for this berth
- **Maintenance log:** See Section 3.5
### 3.2 Berth Display — Three-Panel Layout
- **Left panel — Smart list:** Berths grouped by status (Under Offer first, then Available, then Sold). Within each group, sorted by area then mooring number. Each list item shows: mooring number, area, status, nominal size, price. Clicking selects and opens detail panel.
- **Right panel — Detail view:** Full berth specs in collapsible sections (dimensions, infrastructure, commercial, linked interests, waiting list, maintenance log, files). All info at a glance with ability to expand sections for detail.
- **Top/toggleable — Interactive map:** Same berth map as the website, color-coded by status (green = available, orange = under offer, red = sold). Clicking a berth on the map selects it in the list and opens its detail. Collapsible to maximize list/detail space.
### 3.3 Berth Recommendation Engine
- Input: client's vessel dimensions (length, width, draft) + desired amenities
- Matching logic: find berths where berth dimensions accommodate the vessel with appropriate clearance, then rank by amenity match (power, access, mooring type, etc.)
- Uses all available berth data for matching — no need to ask for more from the client
- Results shown as a ranked list with match score and reasons
- Salesperson can assign recommended berths to the interest with one click
### 3.4 Berth Comparison View
- Select 2-3 berths for side-by-side comparison
- Shows: dimensions, price, amenities, availability, infrastructure specs
- Exportable as a PDF to send to the client
- Accessible from the berth list or from within an interest record
### 3.5 Berth Maintenance Log
- Per-berth log of maintenance, repairs, and inspections
- Fields: date, description, cost, photos (stored in MinIO), responsible party, category (routine, repair, inspection, upgrade)
- Important for a luxury marina — berth condition matters for sales
### 3.6 Berth Availability Calendar — **CUT FROM V1** (Post-V1)
> **Deferred:** This feature is lowest priority and not needed for V1 launch. Tenure data, expiry dates, and expiry notification checks remain in the schema and background jobs — only the Gantt-style visualization UI is deferred.
- ~~Timeline/Gantt-style view showing berth occupancy over time~~
- ~~For fixed-term tenure berths: shows contract period and approaching expirations~~
- ~~Flags upcoming expirations (configurable warning period, e.g., 6 months before)~~
- ~~Integrates with waiting list — when a term is approaching expiry, alerts waiting list clients~~
- ~~For permanent berths: shows as permanently occupied (no expiry)~~
---
## 4. EOI & Document Signing
### 4.1 EOI Generation
- **Prerequisites for generation:** Client name, email, yacht name, length × width × draft, at least one linked berth
- **Generation blocked if:** Manual/uploaded EOI documents already exist on the interest (unless overridden)
- **Process:** Generate EOI PDF from template (@pdfme) → create Documenso document → assign 3 signers (client, developer, sales/approver) in sequential order → store signing URLs on interest record
- **Auto-status:** Sets EOI Status = "Waiting for Signatures" and advances pipeline stage to "EOI and NDA Sent"
### 4.2 Signature Tracking
- **Webhook-driven:** Documenso webhooks notify on each signature event
- **Deduplication:** Signature hash + in-memory lock prevents double processing
- **Notification chain:** Client signs → notify developer. Developer signs → notify sales. All sign → download completed PDF → email all parties → store in MinIO
- **Fallback polling:** Every 6 hours, poll Documenso API for interests with pending signatures (rare safety net — primary mechanism is instant Documenso webhooks)
- **Reminders:** Time-gated reminders (configurable send windows), per-interest toggle, system-wide cooldown
### 4.3 Manual Upload
- Upload pre-signed documents directly, bypassing Documenso entirely
- Immediately sets EOI Status = "Signed" and advances pipeline to "Signed EOI and NDA"
- Used when client signs physical documents or has their own signing process
### 4.4 Generic Document Signing
- Not just EOIs — any document can be uploaded and sent through Documenso for signing
- Contracts, NDAs, reservation agreements, custom documents
- Same tracking infrastructure as EOIs: webhook events, status tracking, signed PDF storage
- Document type is tagged (EOI, contract, NDA, other) for filtering and organization
### 4.5 Pre-filled Data Collection Forms
- Branded, web-accessible forms that the salesperson can send to clients
- Pre-filled with known data from the client/interest record
- Client fills in missing fields, corrects existing data
- On submission, data flows back into the CRM and updates the relevant records
- Form link is generated per-client with a secure token
- Used for: EOI data collection, supplemental information gathering, general intake
### 4.6 Document Templates
- Reusable templates for standard correspondence and documents beyond EOIs/contracts
- Template types: welcome letters, berth handover checklists, acknowledgment letters, standard correspondence, custom
- Merge fields: client name, berth details, dates, amounts, and any other CRM data auto-filled into the template
- Admin creates and manages templates in the admin panel
- Users generate documents from templates by selecting a template and a client/interest/berth — merge fields auto-populate
- Generated documents can be: downloaded as PDF, sent via email, sent for signing via Documenso, or stored in the client's file folder
- Template editor: TipTap rich text editor with merge field insertion (dropdown to pick field, inserted as `{{client.full_name}}` style tokens via custom TipTap extension)
### 4.7 Record PDF Export
- One-click PDF export of any individual record view
- Client summary PDF: client details, contact info, vessel details, linked interests with status, recent activity, files list
- Berth spec sheet PDF: all berth specifications, dimensions, infrastructure, pricing, current status, linked interests
- Interest summary PDF: client info, berth info, pipeline stage, EOI status, milestones, notes, timeline
- Invoice PDF: already covered in Section 5.2
- Useful for: meetings, offline reference, sharing with parent company, printing
- Export button available on every record detail view
- Styled with port branding (logo, colors) from port settings
---
## 5. Expenses & Invoicing
### 5.1 Expense Management
- **CRUD:** Create, read, update, delete expenses
- **Fields:** Establishment name, amount, currency, payment method, category, payer, date/time, description/contents, receipt image(s)
- **Receipt scanner integration:** Standalone PWA at `/scan` (see Section 17.2). Mobile-first tool where user takes a photo of a receipt, AI extracts: establishment name, amount, currency, date, line items. Handles currency conversion (ECD local currency, USD primary business currency, EUR/GBP from international clients). Can be added to Apple home screen as a dedicated app.
- **Multi-currency:** Expenses can be in any currency. System stores original currency + amount and converted USD equivalent. Exchange rates from Frankfurter API with local cache fallback.
- **Payment tracking:** Payment status, date, method, reference, notes
- **Filtering:** By date range, payer, category, payment status, currency
- **Export:** CSV export, PDF export with receipt images. Groupable by category, payer, date range, invoice.
- **Parent company export:** Dedicated export format bundling expenses with receipt images for parent company reporting. Includes EUR subtotal + 5% processing fee calculation.
### 5.2 Invoice System
- **Full invoicing:** Not just expense grouping — proper invoice generation with configurable billing target
- **Auto-numbering:** INV-YYYYMM-### format
- **Invoice fields:** Invoice number, client/company name, billing email, billing address, due date, payment terms, currency, line items, subtotal, fees/discounts, total
- **Payment terms:** Immediate, Net 10, Net 15, Net 30, Net 45, Net 60
- **Discount rules:** 2% discount for Net 10 terms (configurable)
- **PDF generation:** Professional invoice PDF with line items, totals, payment instructions
- **Payment tracking:** Status (draft, sent, paid, overdue, cancelled), payment date, method, reference
- **Expense linking:** Invoices can be created from grouped expenses, with bidirectional linking maintained
- **Email integration:** Send invoices via email directly from CRM
- **Overdue alerts:** Configurable alerts when invoices pass their due date
---
## 6. File Management
### 6.1 Storage Structure
MinIO (S3-compatible) with client-grouped folder structure:
```
/clients/{client_id}/
├── eoi/ # EOI documents (generated + signed)
├── contracts/ # Contracts, NDAs, reservation agreements
├── images/ # Client/vessel photos
├── receipts/ # Expense receipts
├── correspondence/ # Email attachments, letters
└── misc/ # Anything else
```
### 6.2 File Operations
- Upload (drag-and-drop, multi-file), download, preview (PDF, images), rename, delete
- Folder management: create, rename, delete folders
- File metadata stored in PostgreSQL: filename, path, size, MIME type, uploaded by, upload date, client_id, category
- Presigned URLs for secure downloads
### 6.3 Migration from Existing Storage
- Current S3 files are not organized by client
- AI-assisted migration script to: scan all existing files → identify which client they belong to (from filenames, metadata, content) → reorganize into the new client-grouped structure
- Runs as a one-time migration job with manual review for uncertain matches
### 6.4 File Audit
- All file operations (upload, download, delete, rename, move) logged in the audit system
- File access history visible per file
---
## 7. Email System
**Priority level:** Deprioritized. Built but not a focus area. Salesperson uses personal email/WhatsApp for client communication. CRM handles system-generated emails only as the primary use case.
### 7.1 System-Generated Email (Primary)
- Sent via Nodemailer → Poste.io SMTP (noreply@portnimara.com)
- Templates: password set/reset, EOI signature reminders, form links, follow-up reminders, invoice delivery, system alerts, waiting list notifications
- MJML template files with variable substitution (not inline HTML strings)
- Queued via BullMQ for reliable delivery
### 7.2 User Mailbox (Secondary — Nice to Have)
- Per-user SMTP/IMAP connection with provider presets (Google Workspace, Outlook, Custom)
- Compose and send emails from within CRM
- Email thread viewer showing conversation history
- Sent/received emails linked to client records and visible in activity timeline
- Credentials stored encrypted in PostgreSQL (not in-memory)
---
## 8. Dashboard & Analytics
### 8.1 Main Dashboard (Home Screen)
- Pipeline overview: interest count and value by stage, visual funnel
- Conversion rates: interest → EOI → contract → completed (with trend lines)
- Berth occupancy: available vs under offer vs sold (pie/donut chart), by area
- Revenue: pipeline value (weighted by stage probability), realized revenue
- Recent activity: last 10 actions across the system
- Upcoming reminders: next 5 due CRM reminders + upcoming Google Calendar events for the current user
- Overdue items: unsigned EOIs, overdue invoices, overdue reminders
### 8.2 Revenue Forecasting
- Weighted pipeline value by stage (configurable probability per stage)
- Best case / likely case / worst case scenarios
- Trend over time (monthly/quarterly)
- Breakdown by area, berth type, client source
### 8.3 Berth Analytics
- Occupancy rates over time
- Average time from interest to signed contract
- Most popular berth areas/sizes
- Price distribution analysis
- Waiting list depth per berth
### 8.4 Expense Analytics
- Monthly expense trends by category
- Payer breakdown
- Currency distribution
- Budget vs actual (if budget targets are configured)
### 8.5 Port Comparison (Super Admin)
- Side-by-side metrics across ports (future — when multi-port is active)
- Unified pipeline view
- Cross-port revenue summary
---
## 9. Reminders & Google Calendar Integration
CRM-native reminders with optional Google Calendar sync. The salesperson can create reminders/follow-ups that live inside the CRM, and optionally push them to their connected Google Calendar. No full calendar built into the CRM — the display shows CRM reminders alongside synced Google Calendar events in a unified list.
### 9.1 CRM Reminders
- Linked to: a client, an interest, a berth, or standalone
- Fields: title, note (optional), due date/time, priority (low/medium/high/urgent), assigned to, status (pending/snoozed/completed/dismissed)
- **No recurring reminders** — lightweight by design, salesperson creates new reminders as needed
- Overdue reminders trigger in-app notifications
- Quick create from any entity detail page (client, interest, berth) — reminder auto-linked to that entity
- Reminders appear in: dashboard "Upcoming Reminders" widget, entity detail pages (sidebar or tab), notification center when due/overdue
### 9.2 Follow-up Reminders (Auto-Generated)
- Configurable per interest record: "remind me to follow up in X days if no activity"
- Auto-creates a CRM reminder when the inactivity threshold fires
- Salesperson can snooze (pushes due date forward), dismiss, or complete the reminder
- System-wide reminder settings in admin panel (send windows, cooldowns)
### 9.3 Google Calendar Integration
- **Per-user OAuth 2.0 connection** via Google Calendar API v3 (googleapis Node.js library)
- Each CRM user can connect their Google Calendar from Settings → Calendar
- Scopes: `calendar.events` (read/write), `calendar.readonly`, `calendar.calendarlist.readonly`
- Encrypted refresh tokens stored in `google_calendar_tokens` table
- **Calendar selection:** On connect, fetch the user's calendar list (via `calendarList.list`) and let them choose which calendar to sync with (e.g., "Business", "CRM", or their primary). Stored as `calendar_id` on the token record. Can be changed later in Settings.
- **Push to Calendar:** When creating/editing a CRM reminder, toggle "Add to Google Calendar" → creates a Google Calendar event on the selected calendar with title, time, and a link back to the CRM entity
- **Pull from Calendar:** Multiple sync triggers ensure freshness:
1. **Background poll:** Every 30 minutes for all connected users (BullMQ recurring job)
2. **On login:** When a user with a connected calendar logs in, trigger an immediate sync
3. **On navigation:** When a user navigates to any page displaying calendar data (dashboard, reminders list, client detail with reminders), trigger a sync if last sync was > 5 minutes ago
- All sync strategies fetch upcoming events (next 14 days) from the selected calendar into the CRM for display alongside CRM reminders
- **Two-way sync:** If a Google Calendar event pushed from CRM is deleted/moved in Google, the next sync updates the CRM reminder accordingly (time change → update `due_at`, deletion → mark as `dismissed`)
- **Display:** CRM reminders and Google Calendar events shown in a unified "Upcoming" list on the dashboard and entity detail pages. Google Calendar events are visually distinguished (calendar icon badge)
- **Disconnect:** User can disconnect Google Calendar at any time. CRM reminders remain unaffected; synced events are removed from display.
---
## 10. Notification Center
### 10.1 In-App Notifications
- Bell icon in the top navigation bar with unread count badge
- Notification types:
- Reminder due / overdue
- New website registration
- EOI signature event (signed, completed)
- New email received (if user mailbox connected)
- Duplicate client alert
- Invoice overdue
- Waiting list notification (berth became available)
- System alert (job failure, background job error)
- Follow-up reminder auto-created
- Berth tenure expiring
- Each notification: icon, title, description, timestamp, read/unread state, link to relevant record
- Mark as read individually or bulk "mark all as read"
- Notification preferences per user: which types to receive, which to suppress
### 10.2 Delivery Channels
- In-app notification (always)
- Email notification (configurable per notification type, sent via Poste.io)
- Future: push notifications (when mobile app is built)
---
## 11. Search & Filtering
### 11.1 Global Search
- Single search bar in the top navigation (focus on Cmd+K or Ctrl+K)
- Searches across: clients, berths, interests, notes, documents, invoices, expenses
- Fuzzy matching for names and text content
- Recent searches remembered
- Results grouped by entity type with quick-jump links
### 11.2 Saved Filters / Custom Views
- On any list view (clients, interests, berths, expenses, invoices), users can configure filters and save them as named views
- Examples: "Available berths in Area A", "Clients with unsigned EOIs", "Overdue invoices"
- Views are per-user but can be shared with other users
- Saved views appear as tabs or dropdown on the list page
### 11.3 Bulk Operations
- Select multiple records in any list view
- Available actions: bulk status change, bulk tag assignment, bulk email send, bulk export (CSV/PDF), bulk delete (with confirmation)
- Operations run as background jobs via BullMQ with progress indicator
---
## 12. Admin Panel
### 12.1 Three Admin Levels
- **Super Admin (Matt):** Everything. All configurations, all ports, all settings, system-level operations.
- **Director:** Operational admin. User management within their port, port-scoped audit logs, reminder/alert configuration. No system-level settings, no role definitions, no cross-port access (unless assigned).
- **Everyone else:** Custom role-based permissions defined by the role builder.
### 12.2 User Management
- Create user accounts (admin creates, system sends "set password" email)
- Assign users to ports with specific roles
- Deactivate/reactivate user accounts
- View user activity (last login, actions performed)
- Password reset (send reset email)
### 12.3 Role Builder
- Define roles with granular permissions
- Permission categories: clients (view, create, edit, delete), interests (view, create, edit, delete, change stage), berths (view, edit), expenses (view, create, edit, delete, export), invoices (view, create, edit, delete, send), files (view, upload, delete), email (view, send), admin (user management, audit log, settings), reports (view, export)
- Global roles (apply to all ports by default)
- Per-port role overrides (tweak a global role for a specific port)
- Role assignment: user → port → role
### 12.4 Port Management
- Create, edit, deactivate ports
- Port settings: name, slug, branding (logo, colors), default currency, timezone
- Port-specific configurations (reminder schedules, alert thresholds)
- Onboarding wizard for new ports: step-by-step setup of port details → berth import → user creation → role assignment → branding
### 12.5 System Configuration
- Email settings (Poste.io SMTP config, template management)
- Documenso connection settings
- MinIO connection settings
- Currency settings (primary currency, exchange rate refresh schedule)
- Backup settings (schedule, retention policy)
- Webhook configuration (see Section 19)
---
## 13. Audit System
### 13.1 Deep Audit Log
- Every data change across every entity type logged
- Fields: timestamp, user, action (create/update/delete), entity type, entity ID, field changed, old value, new value, IP address, port context
- Searchable by: user, entity type, entity ID, date range, action type
- Filterable and exportable (CSV)
### 13.2 Undo Capability
- Super admin can revert specific changes from the audit log
- Revert creates a new audit log entry ("Reverted change X by user Y")
- Undo available for: field value changes, status changes, berth assignments
- Undo NOT available for: file deletions (files already removed from storage), sent emails, signed documents
- Confirmation required before any undo operation
### 13.3 Audit Views
- System-wide audit log (super admin only)
- Port-scoped audit log (director level)
- Per-record audit trail (visible on each client/interest/berth detail view)
- Per-user audit trail (what actions did this user take)
---
## 14. Multi-Port Tenancy
### 14.1 Data Isolation
- Every core table has a `port_id` foreign key
- Every query scoped to current port context
- Super admin can view/query across all ports
- No data leakage between ports for regular users
### 14.2 Port Switcher & Single-Port Mode
- **Single-port mode:** When only one port is active (`SELECT count(*) FROM ports WHERE is_active = true` = 1), ALL multi-port UI is hidden:
- Port switcher dropdown hidden
- "Port" column hidden in admin tables (users, berths, interests, etc.)
- Port selection step hidden in entity creation forms
- Port-scoped filter dropdowns hidden from list views
- Port context set automatically behind the scenes (no user interaction needed)
- Admin settings that are "per-port" still work — they just apply to the only port
- **Multi-port mode:** When 2+ active ports exist, full multi-port UI appears:
- Users assigned to multiple ports see a port switcher in the navigation
- Switching ports changes all data context — lists, dashboards, search results
- Current port indicated in the navigation
- Port columns and filters visible throughout the app
- **Transition:** If a super admin creates a second port, multi-port UI automatically appears on next page load for all users. No restart or setting toggle needed — purely derived from active port count.
### 14.3 Global vs. Per-Port
- Global roles: defined once, available at all ports
- Per-port role overrides: customize a global role for a specific port
- Global settings: system-wide defaults
- Per-port settings: override any global setting at the port level
- New ports inherit all global configurations automatically
---
## 15. Authentication & Authorization
### 15.1 Better Auth Integration
- Integrated login page matching CRM styling (no redirect to external auth service)
- Password hashing (argon2/bcrypt)
- Session cookies (httpOnly, secure, CSRF-protected)
- Login/logout flows
### 15.2 Account Lifecycle
1. Super admin creates user account in CRM admin panel
2. Admin assigns user to port(s) with role(s)
3. System sends "Set your password" email via Poste.io
4. User clicks link, sets password, can log in
5. Password reset follows same email flow
### 15.3 Authorization Flow
1. User logs in → Better Auth validates credentials → session created
2. App checks user_port_roles → which ports?
3. Multiple ports → port switcher
4. Every API request includes current port context
5. Every DB query scoped to current port
6. Permission check: user's role at current port → check against permissions (with port override if exists)
7. Super admin bypasses all port scoping
### 15.4 Future: 2FA
- Better Auth TOTP plugin available for future implementation
- Not required for V1 but architecture supports it
---
## 16. Public API & Website Integration
### 16.1 Public Berth API
- `GET /api/public/berths` — returns all berths with status and map data for the website berth map
- `GET /api/public/berths/:id` — single berth details for the website detail view
- No authentication required
- OpenAPI/Swagger documentation at `/api-docs`
### 16.2 Interest Registration
- `POST /api/public/interests` — website form submits new interest registrations
- Triggers: duplicate detection check, notification to salesperson, auto-assign to pipeline stage "Open"
- Rate limited to prevent abuse
### 16.3 Form Submission
- `POST /api/public/forms/:token` — pre-filled data collection form submissions from clients
- Token-authenticated (one-time or time-limited tokens per form link)
---
## 17. AI-Assisted Features
### 17.1 Berth Spec Sheet Import
- Upload PDF or Excel spec sheet for a berth or batch of berths
- AI interprets the document: identifies columns/fields, maps to database schema, handles varying formats
- Preview screen shows AI's interpretation for human review before committing
- Supports: different column orders, missing columns, unit variations, format differences between marina providers
### 17.2 Receipt Scanner (Standalone PWA)
- **Standalone PWA route:** Available at `/scan` (short, easy-to-remember URL). Also accessible via `/expenses/scan` which redirects.
- **PWA manifest:** `display: "standalone"`, custom icon, "Port Nimara Scanner" name — when added to Apple home screen (or Android), opens as a standalone app with no browser chrome
- **Service worker:** Caches the scanner UI shell for instant load. If offline, photos are queued locally and uploaded when connectivity returns (offline queueing with IndexedDB).
- **Minimal UI:** Camera viewfinder → capture → AI processing spinner → review extracted data → save. No sidebar, no navigation — just the scanner flow. "Back to CRM" link for returning to the full app.
- **AI extraction:** Extracts establishment name, total amount, currency, date, line items (if readable)
- **Currency conversion:** Detects currency from receipt, converts to USD at current exchange rate
- **User reviews** and confirms/corrects extracted data before saving as expense
- **Auth:** Requires login (session cookie). If not logged in, redirects to a minimal login page then back to `/scan`.
- **Easy access:** Team can bookmark `crm.portnimara.dev/scan` or add to home screen — works like a dedicated receipt scanning app
### 17.3 Berth Recommendation Engine
- Input: client's vessel dimensions + amenity preferences
- Uses all available berth data for matching
- Ranking factors: dimensional fit (with clearance margins), power/voltage match, access type preference, mooring type, water depth adequacy, price range
- Returns ranked list with match score and match reasons per berth
### 17.4 S3 File Migration
- One-time AI-assisted reorganization of existing MinIO files
- Scans existing files → identifies client association (from filenames, metadata, content) → proposes new location in client-grouped structure
- Manual review for uncertain matches before executing moves
---
## 18. Data Import/Export Center
### 18.1 Import
- CSV/Excel import for: clients, interests, berths, expenses
- Column mapping interface: upload file → map source columns to CRM fields → preview → import
- Validation on import: required fields, data types, duplicate detection
- Import history log
### 18.2 Export
- Export any entity list to CSV or Excel
- Configurable columns: choose which fields to include
- Filtered export: apply current list filters before exporting
- Scheduled exports (see Section 20)
### 18.3 Migration Tools
- NocoDB → PostgreSQL migration: extract all data from NocoDB tables, transform to new schema, seed PostgreSQL, verify, decommission NocoDB
- Runs as a managed process with progress tracking and rollback capability
---
## 19. Webhooks
### 19.1 Outbound Webhooks
- Admin-configurable: "when X happens, POST to this URL"
- Available events: new client, new interest, interest stage change, EOI signed, EOI completed, berth status change, invoice created, invoice paid, expense created, new website registration
- Webhook management: create, edit, delete, enable/disable, view delivery logs
- Retry logic: 3 attempts with exponential backoff, then dead letter
- Payload includes: event type, timestamp, full entity data, port context
- Signature verification: HMAC signing for webhook payloads
### 19.2 Use Cases
- Connect CRM to Zapier, n8n, or custom integrations
- Notify external systems of pipeline changes
- Trigger website updates beyond the berth map
- Future: connect to accounting software, property management systems
---
## 20. Scheduled Reports
### 20.1 Report Configuration
- Admin creates report schedules: what report, what frequency, who receives it
- Report types: pipeline summary, expense summary, berth occupancy, activity log, overdue items, revenue forecast
- Frequency: daily, weekly, monthly, or custom cron
- Delivery: email (PDF attachment) via Poste.io
- Recipients: any CRM user or external email address
### 20.2 Report Content
- Each report type has a template
- Reports generated as PDF
- Include relevant charts, tables, and summary statistics
- Date range automatically set based on frequency (e.g., weekly report covers last 7 days)
---
## 21. System Monitoring & Alerts
### 21.1 Alert Monitoring Dashboard
- Per-type failure counters for background jobs (email, signature polling, backups, etc.)
- Cooldown system: don't spam admin with repeated alerts for the same issue
- Health check indicators for all services: PostgreSQL, Redis, MinIO, Documenso, Poste.io, Socket.io
- BullMQ job dashboard: queue sizes, failed jobs, processing times, dead letter queue
### 21.2 Admin Notifications
- System alerts → notification center + email to super admin
- Configurable thresholds: "alert me if more than X failures in Y minutes"
- Alert categories: service down, job failure, backup failure, disk space, unusual activity
### 21.3 System Backup & Restore
- Automated nightly PostgreSQL backup via pg_dump → stored in MinIO
- Manual backup trigger from admin panel
- Backup download from admin panel
- Restore interface for super admin (upload backup → preview → restore)
- Backup retention policy (configurable: keep last N days/weeks)
---
## 22. Client Portal
**Status:** Build but deprioritize. Include in V1 but at the bottom of the implementation order.
- Separate lightweight view for clients to log in
- Client sees: their berth details, outstanding documents to sign, invoice history, uploaded files
- Reduces back-and-forth with salesperson for status checks
- Authentication: separate from CRM auth (client accounts created/managed from CRM side)
- Read-only except for: document signing, form submissions, file uploads requested by salesperson
---
## 23. UX & Quality of Life
### 23.1 Mobile-Responsive Design
- Full CRM usable on tablet and phone
- Receipt scanner is mobile-primary
- Responsive layouts using Tailwind breakpoints
- Touch-friendly interaction targets
### 23.2 Dark Mode
- Toggle in user settings
- Tailwind dark mode classes
- Persists per user
### 23.3 Quick Notes / Scratchpad
- Personal scratchpad per user, not tied to any record
- For jotting notes during calls before associating with a client
- Notes can be dragged/moved onto a client record later
- Persists between sessions
### 23.4 Tags / Labels System
- Color-coded tags applicable to any entity (clients, interests, berths)
- User-defined: create, rename, delete, change color
- Filterable in all list views
- Port-scoped (each port has its own tag set)
### 23.5 Archiving
- Archive completed/lost/expired records instead of deleting
- Archived records move out of active views
- Searchable and restorable from archive
- Separate "Archive" view in each section
### 23.6 Multi-Currency Price Book
- Berth prices displayed in USD (primary)
- Configurable exchange rates for display purposes (EUR, GBP, ECD)
- Auto-update rates from Frankfurter API or manually set by admin
- When viewing a berth, see price in all configured currencies
- Useful for international clients
### 23.7 Audit Export for Parent Company
- Dedicated "parent company report" generator
- Bundles: expenses with receipts, revenue summary, occupancy stats, activity summary
- Configurable: choose what sections to include
- One-click generation as a downloadable PDF/ZIP package
---
## Appendix A: Feature Count Summary
| Category | Feature Count |
| ---------------------- | ------------------------- |
| Client Management | 5 features |
| Interest Management | 4 features |
| Berth Management | 6 features |
| EOI & Document Signing | 7 features |
| Expenses & Invoicing | 2 features |
| File Management | 4 features |
| Email System | 2 features |
| Dashboard & Analytics | 5 features |
| Reminders & Calendar | 3 features |
| Notification Center | 2 features |
| Search & Filtering | 3 features |
| Admin Panel | 5 features |
| Audit System | 3 features |
| Multi-Port Tenancy | 3 features |
| Auth & Authorization | 4 features |
| Public API | 3 features |
| AI-Assisted | 4 features |
| Import/Export | 3 features |
| Webhooks | 1 feature |
| Scheduled Reports | 2 features |
| System Monitoring | 3 features |
| Client Portal | 1 feature (deprioritized) |
| UX & Quality of Life | 7 features |
| **TOTAL** | **~82 features** |
---
## Appendix B: Decisions Log
Key decisions made during feature review interviews:
1. NocoDB dropped entirely — PostgreSQL is sole database
2. Keycloak dropped entirely — Better Auth integrated into Next.js
3. Client and Interest are separate entities (client is anchor, interest tracks per-berth pipeline)
4. System never forces a workflow — all pipeline stages manually overridable
5. Email deprioritized — salesperson uses personal email/WhatsApp, CRM handles system-generated only
6. WhatsApp integration not possible — salesperson uses personal account
7. Receipt scanner (existing mobile tool) integrated into CRM expense flow
8. File storage reorganized by client with AI-assisted migration of existing files
9. Three admin levels: super admin (Matt), director (operational admin), everyone else (role-based)
10. Duplicate detection: same-email auto-merges, fuzzy matches alert for manual review
11. "No v1 or v2" — everything ships in one build
12. Client portal built but deprioritized in implementation order
13. Keyboard shortcuts cut from scope
14. Berth tenure tracking: permanent and fixed-term (typically 5-year) with expiration monitoring
15. Marina is in Anguilla — ECD local currency, USD primary business currency

1094
07-DATABASE-SCHEMA.md Normal file

File diff suppressed because it is too large Load Diff

575
08-API-ENDPOINT-CATALOG.md Normal file
View File

@@ -0,0 +1,575 @@
# Port Nimara CRM — API Endpoint Catalog
**Compiled:** 2026-03-11
**Pattern:** REST with Zod validation, JSON responses
**Auth:** Better Auth session cookies (internal), API key (webhooks), none (public)
**Docs:** OpenAPI/Swagger auto-generated at `/api-docs` for public endpoints
---
## Conventions
- All endpoints prefixed with `/api/`
- Port context from `X-Port-Id` header or session (middleware extracts and validates)
- Standard response shape: `{ data, meta?, error? }`
- Pagination: `?page=1&limit=25` → response includes `meta: { total, page, limit, pages }`
- Sorting: `?sort=field&order=asc|desc`
- Filtering: `?field=value` or `?field[op]=value` (e.g., `?status=available`, `?created_at[gte]=2025-01-01`)
- All write endpoints return the created/updated entity
- All delete endpoints return `{ success: true }`
- Error shape: `{ error: { code, message, details? } }`
- HTTP status codes: 200 (success), 201 (created), 400 (validation), 401 (unauthorized), 403 (forbidden), 404 (not found), 409 (conflict), 500 (server error)
---
## 1. Public API (No Auth)
Served to the website and external consumers. OpenAPI documented.
| Method | Path | Description |
| ------ | -------------------------- | -------------------------------------------------------------- |
| GET | `/api/public/berths` | List all berths with status and map data for website berth map |
| GET | `/api/public/berths/:id` | Single berth details for website detail view |
| POST | `/api/public/interests` | Website interest registration form submission |
| POST | `/api/public/forms/:token` | Pre-filled data collection form submission |
| GET | `/api/public/forms/:token` | Get form template + prefilled data for rendering |
Rate limited. CORS configured for allowed origins.
---
## 2. Authentication
| Method | Path | Description |
| ------ | ---------------------------------- | --------------------------------------------------------- |
| POST | `/api/auth/login` | Email + password login → session cookie |
| POST | `/api/auth/logout` | Destroy session |
| GET | `/api/auth/session` | Get current session (user info, port access, permissions) |
| POST | `/api/auth/password/set` | Set password from email link (token + new password) |
| POST | `/api/auth/password/reset-request` | Request password reset email |
| POST | `/api/auth/password/reset` | Reset password from email link (token + new password) |
---
## 3. Clients
| Method | Path | Description |
| ------ | --------------------------------------- | -------------------------------------------------------------------- |
| GET | `/api/clients` | List clients (paginated, filterable, sortable) |
| POST | `/api/clients` | Create client |
| GET | `/api/clients/:id` | Get client with contacts, tags, interest count |
| PATCH | `/api/clients/:id` | Update client fields |
| DELETE | `/api/clients/:id` | Soft delete (archive) client |
| POST | `/api/clients/:id/restore` | Restore archived client |
| GET | `/api/clients/:id/interests` | List all interests for this client |
| GET | `/api/clients/:id/timeline` | Activity timeline for this client |
| GET | `/api/clients/:id/files` | Files belonging to this client |
| GET | `/api/clients/:id/notes` | Notes on this client |
| POST | `/api/clients/:id/notes` | Add note to client |
| PATCH | `/api/clients/:id/notes/:noteId` | Edit note (within 15-min window) |
| GET | `/api/clients/:id/relationships` | Client relationships |
| POST | `/api/clients/:id/relationships` | Create relationship between clients |
| DELETE | `/api/clients/:id/relationships/:relId` | Remove relationship |
| POST | `/api/clients/merge` | Merge two clients (body: { surviving_id, merged_id, field_choices }) |
| GET | `/api/clients/duplicates` | List pending duplicate alerts |
| POST | `/api/clients/duplicates/:id/dismiss` | Dismiss a duplicate alert |
### Client Contacts
| Method | Path | Description |
| ------ | -------------------------------------- | -------------------- |
| GET | `/api/clients/:id/contacts` | List contact entries |
| POST | `/api/clients/:id/contacts` | Add contact entry |
| PATCH | `/api/clients/:id/contacts/:contactId` | Update contact entry |
| DELETE | `/api/clients/:id/contacts/:contactId` | Delete contact entry |
### Client Tags
| Method | Path | Description |
| ------ | ------------------------------ | ---------------------- |
| POST | `/api/clients/:id/tags` | Add tag(s) to client |
| DELETE | `/api/clients/:id/tags/:tagId` | Remove tag from client |
---
## 4. Interests
| Method | Path | Description |
| ------ | --------------------------------------------- | -------------------------------------------------------------------------------- |
| GET | `/api/interests` | List all interests (paginated, filterable by stage, berth, client, source, tags) |
| POST | `/api/interests` | Create interest (linked to client) |
| GET | `/api/interests/:id` | Get interest detail with client, berth, documents, milestones |
| PATCH | `/api/interests/:id` | Update interest fields |
| DELETE | `/api/interests/:id` | Soft delete (archive) interest |
| POST | `/api/interests/:id/restore` | Restore archived interest |
| PATCH | `/api/interests/:id/stage` | Change pipeline stage (with validation and auto-actions) |
| POST | `/api/interests/:id/berth` | Link berth to interest (auto-updates berth status) |
| DELETE | `/api/interests/:id/berth` | Unlink berth from interest (conditionally resets berth status) |
| GET | `/api/interests/:id/recommendations` | Get berth recommendations for this interest |
| POST | `/api/interests/:id/recommendations/generate` | Generate AI berth recommendations |
| POST | `/api/interests/:id/recommendations` | Add manual berth recommendation |
| GET | `/api/interests/:id/timeline` | Activity timeline for this interest |
| GET | `/api/interests/:id/notes` | Notes on this interest |
| POST | `/api/interests/:id/notes` | Add note to interest |
| PATCH | `/api/interests/:id/notes/:noteId` | Edit note (within 15-min window) |
| GET | `/api/interests/:id/documents` | Documents linked to this interest |
### Interest Tags
| Method | Path | Description |
| ------ | -------------------------------- | ------------------------ |
| POST | `/api/interests/:id/tags` | Add tag(s) to interest |
| DELETE | `/api/interests/:id/tags/:tagId` | Remove tag from interest |
---
## 5. Berths
| Method | Path | Description |
| ------- | ---------------------------------------------------- | --------------------------------------------------------------------- |
| GET | `/api/berths` | List berths (paginated, filterable by status, area, availability) |
| GET | `/api/berths/:id` | Get berth detail with linked interests, waiting list, maintenance log |
| PATCH | `/api/berths/:id` | Update berth fields |
| GET | `/api/berths/:id/map-data` | Get map rendering data |
| PATCH | `/api/berths/:id/map-data` | Update map rendering data |
| GET | `/api/berths/:id/interests` | List interests linked to this berth |
| GET | `/api/berths/:id/waiting-list` | Get waiting list for this berth |
| POST | `/api/berths/:id/waiting-list` | Add client to waiting list |
| PATCH | `/api/berths/:id/waiting-list/:entryId` | Update waiting list entry (position, priority) |
| DELETE | `/api/berths/:id/waiting-list/:entryId` | Remove from waiting list |
| GET | `/api/berths/:id/maintenance` | List maintenance log entries |
| POST | `/api/berths/:id/maintenance` | Add maintenance log entry |
| PATCH | `/api/berths/:id/maintenance/:logId` | Update maintenance entry |
| DELETE | `/api/berths/:id/maintenance/:logId` | Delete maintenance entry |
| POST | `/api/berths/:id/maintenance/:logId/photos` | Upload photos to maintenance log entry |
| DELETE | `/api/berths/:id/maintenance/:logId/photos/:photoId` | Remove photo from maintenance log |
| GET | `/api/berths/compare` | Compare berths (query: `?ids=id1,id2,id3`) |
| POST | `/api/berths/compare/export` | Export comparison as PDF |
| ~~GET~~ | ~~`/api/berths/availability-calendar`~~ | **CUT FROM V1** — Timeline view data deferred to post-V1 |
| POST | `/api/berths/import` | AI-assisted berth spec import (upload file) |
| GET | `/api/berths/import/:jobId` | Check import job status / get preview |
| POST | `/api/berths/import/:jobId/confirm` | Confirm and commit spec import |
### Berth Tags
| Method | Path | Description |
| ------ | ----------------------------- | --------------------- |
| POST | `/api/berths/:id/tags` | Add tag(s) to berth |
| DELETE | `/api/berths/:id/tags/:tagId` | Remove tag from berth |
---
## 6. Documents & Signing
| Method | Path | Description |
| ------ | ---------------------------------- | ----------------------------------------------------------------------- |
| GET | `/api/documents` | List documents (filterable by type, status, client, interest) |
| POST | `/api/documents` | Create document record |
| GET | `/api/documents/:id` | Get document detail with signers and events |
| PATCH | `/api/documents/:id` | Update document |
| DELETE | `/api/documents/:id` | Delete document |
| POST | `/api/documents/:id/send` | Send document for signing via Documenso |
| POST | `/api/documents/:id/upload-signed` | Upload manually signed document |
| POST | `/api/documents/:id/remind` | Send signing reminder |
| GET | `/api/documents/:id/signers` | List signers and their status |
| GET | `/api/documents/:id/events` | List signature events |
| POST | `/api/documents/generate-eoi` | Generate EOI from interest (auto-creates document + sends to Documenso) |
| POST | `/api/webhooks/documenso` | Documenso webhook receiver (signature events) |
### Document Templates
| Method | Path | Description |
| ------ | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| GET | `/api/document-templates` | List document templates (filterable by type, active status) |
| POST | `/api/document-templates` | Create document template |
| GET | `/api/document-templates/:id` | Get template detail with merge field definitions |
| PATCH | `/api/document-templates/:id` | Update template |
| DELETE | `/api/document-templates/:id` | Delete template |
| GET | `/api/document-templates/merge-fields` | List all available merge fields by source (client, interest, berth, port) |
| POST | `/api/document-templates/:id/generate` | Generate document from template (body: { clientId?, interestId?, berthId? }) — resolves merge fields, returns PDF |
| POST | `/api/document-templates/:id/generate-and-send` | Generate + email to client |
| POST | `/api/document-templates/:id/generate-and-sign` | Generate + send for Documenso signing |
### Record PDF Export
| Method | Path | Description |
| ------ | ------------------------------- | -------------------------------------- |
| POST | `/api/clients/:id/export-pdf` | Export client summary as branded PDF |
| POST | `/api/berths/:id/export-pdf` | Export berth spec sheet as branded PDF |
| POST | `/api/interests/:id/export-pdf` | Export interest summary as branded PDF |
---
## 7. Expenses
| Method | Path | Description |
| ------ | ------------------------------------- | ---------------------------------------------------------------------- |
| GET | `/api/expenses` | List expenses (paginated, filterable by date, category, payer, status) |
| POST | `/api/expenses` | Create expense |
| GET | `/api/expenses/:id` | Get expense detail |
| PATCH | `/api/expenses/:id` | Update expense |
| DELETE | `/api/expenses/:id` | Soft delete expense |
| POST | `/api/expenses/scan-receipt` | Upload receipt image → AI extraction → return parsed data |
| POST | `/api/expenses/export/csv` | Export filtered expenses as CSV |
| POST | `/api/expenses/export/pdf` | Export filtered expenses as PDF with receipt images |
| POST | `/api/expenses/export/parent-company` | Parent company export (expenses + receipts + EUR subtotal + 5% fee) |
---
## 8. Invoices
| Method | Path | Description |
| ------ | -------------------------------------- | ------------------------------------------------------------- |
| GET | `/api/invoices` | List invoices (paginated, filterable by status, date, client) |
| POST | `/api/invoices` | Create invoice (from line items or from grouped expenses) |
| GET | `/api/invoices/:id` | Get invoice detail with line items and linked expenses |
| PATCH | `/api/invoices/:id` | Update invoice |
| DELETE | `/api/invoices/:id` | Soft delete invoice |
| POST | `/api/invoices/:id/send` | Send invoice via email |
| POST | `/api/invoices/:id/generate-pdf` | Generate/regenerate invoice PDF |
| PATCH | `/api/invoices/:id/payment` | Record payment against invoice |
| GET | `/api/invoices/:id/line-items` | List line items |
| POST | `/api/invoices/:id/line-items` | Add line item |
| PATCH | `/api/invoices/:id/line-items/:itemId` | Update line item |
| DELETE | `/api/invoices/:id/line-items/:itemId` | Delete line item |
---
## 9. Files
| Method | Path | Description |
| ------ | -------------------------- | ------------------------------------------------- |
| GET | `/api/files` | List files (filterable by client, category, type) |
| POST | `/api/files/upload` | Upload file(s) to MinIO + create metadata |
| GET | `/api/files/:id` | Get file metadata |
| GET | `/api/files/:id/download` | Get presigned download URL |
| GET | `/api/files/:id/preview` | Get presigned preview URL (for PDFs/images) |
| PATCH | `/api/files/:id` | Update file metadata (rename, recategorize) |
| DELETE | `/api/files/:id` | Delete file (removes from MinIO + metadata) |
| POST | `/api/files/folders` | Create folder |
| PATCH | `/api/files/folders/:path` | Rename folder |
| DELETE | `/api/files/folders/:path` | Delete folder |
---
## 10. Email
| Method | Path | Description |
| ------ | ------------------------------ | ----------------------------------------- |
| GET | `/api/email/accounts` | List user's email accounts |
| POST | `/api/email/accounts` | Configure email account (SMTP/IMAP) |
| PATCH | `/api/email/accounts/:id` | Update email account settings |
| DELETE | `/api/email/accounts/:id` | Remove email account |
| POST | `/api/email/accounts/:id/test` | Test SMTP/IMAP connection |
| GET | `/api/email/threads` | List email threads (filterable by client) |
| GET | `/api/email/threads/:id` | Get thread with all messages |
| POST | `/api/email/send` | Compose and send email |
| POST | `/api/email/sync` | Trigger manual email sync |
---
## 11. Reminders
| Method | Path | Description |
| ------ | ----------------------------- | ---------------------------------------------------------------------------------- |
| GET | `/api/reminders` | List reminders (filterable by status, assignee, due date, priority, linked entity) |
| POST | `/api/reminders` | Create reminder (optional: `sync_to_calendar: true` to push to Google Calendar) |
| GET | `/api/reminders/:id` | Get reminder detail |
| PATCH | `/api/reminders/:id` | Update reminder (updates Google Calendar event if synced) |
| DELETE | `/api/reminders/:id` | Delete reminder (removes Google Calendar event if synced) |
| POST | `/api/reminders/:id/complete` | Mark reminder as completed |
| POST | `/api/reminders/:id/snooze` | Snooze reminder (body: `{ snooze_until }`) |
| POST | `/api/reminders/:id/dismiss` | Dismiss reminder |
| GET | `/api/reminders/my` | Current user's reminders |
| GET | `/api/reminders/overdue` | Overdue reminders |
| GET | `/api/reminders/upcoming` | Unified upcoming view: CRM reminders + Google Calendar events (next 14 days) |
## 11b. Google Calendar Integration
| Method | Path | Description |
| ------ | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| GET | `/api/calendar/auth-url` | Get Google OAuth 2.0 authorization URL |
| GET | `/api/calendar/callback` | OAuth callback — exchange code for tokens, store encrypted |
| GET | `/api/calendar/status` | Current user's calendar connection status (connected, calendar name, last sync) |
| GET | `/api/calendar/calendars` | List user's available Google Calendars (for calendar selection) |
| PATCH | `/api/calendar/settings` | Update calendar settings (selected calendar_id, sync_enabled) |
| POST | `/api/calendar/disconnect` | Disconnect Google Calendar (revoke token, clear cache) |
| POST | `/api/calendar/sync` | Trigger sync of Google Calendar events (called manually, on login, or on navigation to calendar-displaying pages if stale > 5 min) |
---
## 12. Notifications
| Method | Path | Description |
| ------ | ---------------------------------- | ----------------------------------------------- |
| GET | `/api/notifications` | List notifications for current user (paginated) |
| GET | `/api/notifications/unread-count` | Get unread notification count |
| PATCH | `/api/notifications/:id/read` | Mark notification as read |
| POST | `/api/notifications/mark-all-read` | Mark all as read |
| GET | `/api/notifications/preferences` | Get notification preferences |
| PATCH | `/api/notifications/preferences` | Update notification preferences |
---
## 13. Dashboard & Analytics
| Method | Path | Description |
| ------ | ------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| GET | `/api/dashboard` | Main dashboard data (pipeline, occupancy, revenue, recent activity, upcoming reminders + calendar events, overdue items) |
| GET | `/api/analytics/pipeline` | Pipeline analytics (counts/values by stage, conversion rates) |
| GET | `/api/analytics/revenue` | Revenue analytics (forecast, trends, breakdown) |
| GET | `/api/analytics/berths` | Berth analytics (occupancy, popularity, time-to-close) |
| GET | `/api/analytics/expenses` | Expense analytics (trends, categories, budgets) |
---
## 14. Search
| Method | Path | Description |
| ------ | -------------------- | --------------------------------------------------------------- |
| GET | `/api/search` | Global search across all entity types (query: `?q=search+term`) |
| GET | `/api/search/recent` | Recent searches for current user |
---
## 15. Tags
| Method | Path | Description |
| ------ | --------------- | ------------------------------ |
| GET | `/api/tags` | List all tags for current port |
| POST | `/api/tags` | Create tag |
| PATCH | `/api/tags/:id` | Update tag (name, color) |
| DELETE | `/api/tags/:id` | Delete tag |
---
## 16. Saved Views
| Method | Path | Description |
| ------ | ---------------------- | ------------------------------------------------------------- |
| GET | `/api/saved-views` | List saved views for current user (filterable by entity type) |
| POST | `/api/saved-views` | Create saved view |
| PATCH | `/api/saved-views/:id` | Update saved view |
| DELETE | `/api/saved-views/:id` | Delete saved view |
---
## 17. Scratchpad
| Method | Path | Description |
| ------ | -------------------------- | ------------------------------------ |
| GET | `/api/scratchpad` | List current user's scratchpad notes |
| POST | `/api/scratchpad` | Create scratchpad note |
| PATCH | `/api/scratchpad/:id` | Update note content |
| DELETE | `/api/scratchpad/:id` | Delete note |
| POST | `/api/scratchpad/:id/link` | Link note to a client record |
---
## 18. Import/Export
| Method | Path | Description |
| ------ | ------------------------- | ------------------------------------------------------------ |
| POST | `/api/import/upload` | Upload CSV/Excel for import (returns column mapping preview) |
| POST | `/api/import/preview` | Preview import with column mapping applied |
| POST | `/api/import/execute` | Execute import |
| GET | `/api/import/history` | List past imports |
| POST | `/api/export/:entityType` | Export entity list as CSV/Excel (body: filters, columns) |
---
## 19. Admin — User Management
| Method | Path | Description |
| ------ | ------------------------------------- | ------------------------------------------------- |
| GET | `/api/admin/users` | List all users |
| POST | `/api/admin/users` | Create user (sends "set password" email) |
| GET | `/api/admin/users/:id` | Get user detail with port assignments |
| PATCH | `/api/admin/users/:id` | Update user (activate/deactivate, update profile) |
| POST | `/api/admin/users/:id/reset-password` | Send password reset email |
| GET | `/api/admin/users/:id/activity` | User's recent activity |
---
## 20. Admin — Roles
| Method | Path | Description |
| ------ | ---------------------------------------- | --------------------------------------- |
| GET | `/api/admin/roles` | List all roles |
| POST | `/api/admin/roles` | Create role |
| GET | `/api/admin/roles/:id` | Get role detail with permissions |
| PATCH | `/api/admin/roles/:id` | Update role |
| DELETE | `/api/admin/roles/:id` | Delete role (blocked if users assigned) |
| GET | `/api/admin/roles/:id/overrides` | List per-port overrides for this role |
| POST | `/api/admin/roles/:id/overrides` | Create per-port override |
| PATCH | `/api/admin/roles/:id/overrides/:portId` | Update per-port override |
| DELETE | `/api/admin/roles/:id/overrides/:portId` | Remove per-port override |
---
## 21. Admin — Ports
| Method | Path | Description |
| ------ | ------------------------------------ | ----------------------------- |
| GET | `/api/admin/ports` | List all ports |
| POST | `/api/admin/ports` | Create port |
| GET | `/api/admin/ports/:id` | Get port detail |
| PATCH | `/api/admin/ports/:id` | Update port settings |
| POST | `/api/admin/ports/:id/assign-user` | Assign user to port with role |
| DELETE | `/api/admin/ports/:id/users/:userId` | Remove user from port |
---
## 22. Admin — Audit Log
| Method | Path | Description |
| ------ | ---------------------------------- | ---------------------------------------------------------------------------- |
| GET | `/api/admin/audit-logs` | List audit log entries (paginated, filterable by user, entity, action, date) |
| GET | `/api/admin/audit-logs/:id` | Get audit log entry detail (before/after values) |
| POST | `/api/admin/audit-logs/:id/revert` | Revert a specific change (super admin only) |
| POST | `/api/admin/audit-logs/export` | Export audit logs as CSV |
---
## 23. Admin — System
| Method | Path | Description |
| ------ | --------------------------------- | -------------------------------------- |
| GET | `/api/admin/settings` | Get all system settings |
| PATCH | `/api/admin/settings` | Update system settings |
| GET | `/api/admin/settings/:portId` | Get port-specific settings |
| PATCH | `/api/admin/settings/:portId` | Update port-specific settings |
| GET | `/api/admin/health` | System health check (all services) |
| POST | `/api/admin/backup` | Trigger manual database backup |
| GET | `/api/admin/backups` | List available backups |
| GET | `/api/admin/backups/:id/download` | Download backup file |
| POST | `/api/admin/backups/:id/restore` | Restore from backup (super admin only) |
---
## 24. Admin — Webhooks
| Method | Path | Description |
| ------ | ------------------------------------ | ---------------------------------- |
| GET | `/api/admin/webhooks` | List webhook configurations |
| POST | `/api/admin/webhooks` | Create webhook |
| GET | `/api/admin/webhooks/:id` | Get webhook detail |
| PATCH | `/api/admin/webhooks/:id` | Update webhook |
| DELETE | `/api/admin/webhooks/:id` | Delete webhook |
| GET | `/api/admin/webhooks/:id/deliveries` | List delivery log for this webhook |
| POST | `/api/admin/webhooks/:id/test` | Send test webhook payload |
---
## 25. Admin — Scheduled Reports
| Method | Path | Description |
| ------ | ---------------------------- | ---------------------------------- |
| GET | `/api/admin/reports` | List scheduled reports |
| POST | `/api/admin/reports` | Create scheduled report |
| GET | `/api/admin/reports/:id` | Get report detail |
| PATCH | `/api/admin/reports/:id` | Update report schedule/config |
| DELETE | `/api/admin/reports/:id` | Delete scheduled report |
| POST | `/api/admin/reports/:id/run` | Manually trigger report generation |
---
## 26. Admin — Custom Fields
| Method | Path | Description |
| ------ | ------------------------------ | --------------------------------------------------------- |
| GET | `/api/admin/custom-fields` | List custom field definitions (filterable by entity type) |
| POST | `/api/admin/custom-fields` | Create custom field definition |
| PATCH | `/api/admin/custom-fields/:id` | Update custom field definition |
| DELETE | `/api/admin/custom-fields/:id` | Delete custom field definition |
---
## 27. Admin — Forms
| Method | Path | Description |
| ------ | ------------------------------------------- | ------------------------------------------ |
| GET | `/api/admin/forms` | List form templates |
| POST | `/api/admin/forms` | Create form template |
| GET | `/api/admin/forms/:id` | Get form template detail |
| PATCH | `/api/admin/forms/:id` | Update form template |
| DELETE | `/api/admin/forms/:id` | Delete form template |
| GET | `/api/admin/forms/:id/submissions` | List submissions for this form |
| POST | `/api/admin/forms/:id/expire/:submissionId` | Manually expire a form submission link |
| POST | `/api/admin/forms/:id/regenerate` | Regenerate form link for a client/interest |
---
## 28. Admin — Monitoring
| Method | Path | Description |
| ------ | ----------------------------------------- | -------------------------------------------------- |
| GET | `/api/admin/jobs` | BullMQ job dashboard data (queues, counts, failed) |
| GET | `/api/admin/jobs/:queueName` | Jobs in a specific queue |
| POST | `/api/admin/jobs/:queueName/:jobId/retry` | Retry a failed job |
| DELETE | `/api/admin/jobs/:queueName/:jobId` | Delete a job |
| GET | `/api/admin/alerts` | Active system alerts |
| PATCH | `/api/admin/alerts/:id/acknowledge` | Acknowledge an alert |
---
## 29. Currency
| Method | Path | Description |
| ------ | ----------------------------- | ------------------------------------------------- |
| GET | `/api/currency/rates` | Get current exchange rates |
| POST | `/api/currency/rates/refresh` | Force refresh exchange rates from Frankfurter API |
| POST | `/api/currency/convert` | Convert amount between currencies |
| PATCH | `/api/currency/rates/:pair` | Manually override an exchange rate |
---
## 30. Bulk Operations
| Method | Path | Description |
| ------ | ------------------------- | ----------------------------------------------------------- |
| POST | `/api/bulk/status-change` | Bulk status change (body: { entity_type, ids, new_status }) |
| POST | `/api/bulk/tag` | Bulk tag assignment (body: { entity_type, ids, tag_ids }) |
| POST | `/api/bulk/export` | Bulk export (body: { entity_type, ids, format }) |
| POST | `/api/bulk/delete` | Bulk soft delete (body: { entity_type, ids }) |
---
## Endpoint Count Summary
| Section | Endpoints |
| --------------------- | ------------------ |
| Public API | 5 |
| Authentication | 6 |
| Clients | 22 |
| Interests | 19 |
| Berths | 22 |
| Documents | 12 |
| Document Templates | 9 |
| Record PDF Export | 3 |
| Expenses | 9 |
| Invoices | 12 |
| Files | 10 |
| Email | 9 |
| Reminders | 11 |
| Google Calendar | 7 |
| Notifications | 6 |
| Dashboard & Analytics | 5 |
| Search | 2 |
| Tags | 4 |
| Saved Views | 4 |
| Scratchpad | 5 |
| Import/Export | 5 |
| Admin — Users | 6 |
| Admin — Roles | 9 |
| Admin — Ports | 6 |
| Admin — Audit | 4 |
| Admin — System | 9 |
| Admin — Webhooks | 7 |
| Admin — Reports | 6 |
| Admin — Custom Fields | 4 |
| Admin — Forms | 6 |
| Admin — Monitoring | 6 |
| Currency | 4 |
| Bulk Operations | 4 |
| **TOTAL** | **~256 endpoints** |

504
09-BUSINESS-RULES.md Normal file
View File

@@ -0,0 +1,504 @@
# Port Nimara CRM — Business Rules Specification
**Compiled:** 2026-03-11
**Core Principle:** The system never forces a workflow. All automated actions are convenience features that can be manually overridden by the salesperson at any time.
---
## 1. Berth Status Rules
### BR-001: Configurable Berth Status Transition Rules
Berth status transitions are **admin-configurable** via a rules engine stored in `system_settings` (key: `berth_status_rules`, per-port). Each rule defines: a trigger action, a target status, and a mode.
**Modes:**
- **auto** — Status changes silently with an audit log entry. No user interaction needed.
- **suggest** — A toast/confirmation prompt appears: "Change berth B-12 status to Under Offer?" — salesperson accepts or dismisses. If dismissed, status stays unchanged.
- **off** — No status change, no prompt. The trigger is ignored.
**Default rules (shipped out of the box, admin can modify):**
| Trigger Action | Default Mode | Target Status | Notes |
| ------------------------------------------------------------ | ------------ | ------------- | ------------------------------------------------ |
| First active interest linked to berth | **suggest** | `under_offer` | Only fires when berth is currently `available` |
| All active interests unlinked/archived from berth | **suggest** | `available` | Only fires when berth is currently `under_offer` |
| EOI sent on any linked interest | **auto** | `under_offer` | Sending an EOI signals real engagement |
| EOI fully signed on any linked interest | **auto** | `under_offer` | Keeps under_offer (doesn't escalate yet) |
| Deposit received (interest reaches `deposit_10pct`) | **suggest** | `sold` | Big moment — prompt to confirm |
| Contract signed (interest reaches `contract` or `completed`) | **suggest** | `sold` | Final confirmation |
| Interest manually archived (sole link to berth) | **suggest** | `available` | Same as unlink |
**Admin configuration UI:** Settings → Berth Status Rules page. Table with columns: Trigger, Mode (dropdown: auto/suggest/off), Target Status (dropdown: available/under_offer/sold). Admin can also add custom rules for future trigger types.
**Rule evaluation order:** Rules are evaluated in the order listed above. First matching rule wins. If multiple rules match the same action, only the first applies.
**Manual override always available:** Regardless of rule configuration, the salesperson can manually set any berth status at any time from the berth detail panel. Manual changes are never blocked by the rules engine — rules only govern _automatic_ or _suggested_ transitions.
**Audit trail:** Every status change (auto, suggested-and-accepted, or manual) is logged in the audit log with: old status, new status, trigger action (or "manual"), rule mode used, and the user who accepted/performed it.
### BR-002: Berth Status on Website Map
- Public API serves berth status directly. Website map color-codes: green = available, orange = under_offer, red = sold.
- Status changes propagate to the website in real-time via the public API (no caching on berth status).
### BR-003: Berth Tenure Expiration
- **Trigger:** Daily job checks all fixed-term berths
- **Condition:** Berth `tenure_end_date` is within configurable warning period (default: 6 months)
- **Action:** Create notification for admin. If waiting list entries exist, create notifications for waiting list clients.
- **On expiration:** Berth does NOT auto-change status. Admin must manually decide next steps.
---
## 2. Interest Pipeline Rules
### BR-010: Pipeline Stages
Ordered stages: `open``details_sent``in_communication``visited``signed_eoi_nda``deposit_10pct``contract``completed`
- All forward and backward transitions are allowed
- Salesperson can set any stage at any time (no enforced progression)
- Stage changes are logged in the audit log with before/after values
### BR-011: Auto-Promote on Vessel Dimensions
- **Trigger:** Interest updated with yacht length, width, and draft values (all three populated)
- **Condition:** Current `lead_category` is `general_interest`
- **Action:** Auto-promote `lead_category` to `specific_qualified`
- **Override:** Category can be manually changed back
### BR-012: Auto-Stage on EOI Send
- **Trigger:** EOI document generated and sent via Documenso
- **Action:** Set `eoi_status` = `waiting_for_signatures`, advance `pipeline_stage` to `signed_eoi_nda` (if not already at or past that stage)
- **Override:** Stage can be manually changed
### BR-013: Auto-Stage on Manual EOI Upload
- **Trigger:** Manually uploaded signed EOI document
- **Action:** Set `eoi_status` = `signed`, advance `pipeline_stage` to `signed_eoi_nda`
- **Override:** Stage can be manually changed
### BR-014: Interest Archiving
- Archived interests are excluded from: active pipeline views, dashboard metrics, berth occupancy counts, auto-status calculations
- Archived interests remain searchable via archive view and global search
- Archiving an interest that is the sole link to a berth triggers BR-002 (berth status reset check)
---
## 3. EOI & Document Rules
### BR-020: EOI Generation Prerequisites
Generation is blocked unless ALL of:
1. Client has: full name, at least one email contact
2. Interest has: yacht name, yacht length, yacht width, yacht draft
3. At least one berth linked to the interest
4. No existing manual/uploaded EOI documents on the interest (unless user explicitly overrides)
### BR-021: EOI Signing Order
- Sequential 3-party signing: Client (order 1) → Developer (order 2) → Sales/Approver (order 3)
- Each signer receives notification only after the previous signer completes
- If any signer declines, the document is marked as `declined` and the interest's `eoi_status` updates accordingly
### BR-022: EOI Completion
- **Trigger:** All signers have signed (Documenso `DOCUMENT_COMPLETED` event)
- **Actions:**
1. Download completed signed PDF from Documenso
2. Store in MinIO under client's EOI folder
3. Email signed PDF to all three parties
4. Set `eoi_status` = `signed`
5. Timestamp `all_signed_notified_at`
### BR-023: Signature Reminders
- Gated by: per-interest `reminder_enabled` toggle AND system-wide cooldown window
- Send window: configurable hours (e.g., 09:00-16:00 in port's timezone)
- Cooldown: minimum time between reminders for the same document (configurable, default 24h)
- Delivered via Poste.io transactional email
### BR-024: Document Deduplication
- Documenso webhook events deduplicated via `signature_hash` unique index on `document_events`
- Prevents double-processing when webhooks are retried or fallback polling fires simultaneously
### BR-025: Generic Document Signing
- Any document type (not just EOIs) can be sent through Documenso
- Same webhook infrastructure handles all document types
- Document type tag determines which pipeline auto-actions fire (only EOI type triggers pipeline stage changes)
---
## 4. Client & Duplicate Rules
### BR-030: Website Registration Duplicate Check
- **Trigger:** New interest registration from website (`POST /api/public/interests`)
- **Check:** Does the submitted email match any existing `client_contacts` entry where `channel = 'email'`?
- **Match found:** Create new interest under existing client. Do NOT create new client. Notify salesperson of new interest on existing client.
- **No match:** Create new client + new interest. Run fuzzy duplicate check (BR-031).
### BR-031: Fuzzy Duplicate Detection
- **Trigger:** New client created (manual or website)
- **Matching criteria (scored):**
- Same email across any contact entry: score 1.0 (this triggers auto-merge per BR-030)
- Same phone number: score 0.9
- Similar name (Levenshtein distance < 3) + same phone: score 0.8
- Similar name + similar address: score 0.7
- **Threshold:** Score ≥ 0.7 creates a duplicate alert for manual review
- **Below threshold:** No alert
### BR-032: Client Merge
- Surviving client keeps all their own data
- Merged client's data fills in any blank fields on the surviving record
- All of the merged client's interests, notes, files, timeline entries, and relationships transfer to the surviving client
- The merge is logged in `client_merge_log` with full detail of which fields came from which record
- The merged client record is deleted after transfer
- Merge is reversible from the audit log (super admin only)
### BR-033: Notes Edit Window
- Notes (on clients or interests) are editable by the author for 15 minutes after creation
- After 15 minutes, notes are locked (`is_locked = true`)
- Locked notes cannot be edited or deleted (except by super admin via audit log revert)
---
## 5. Financial Rules
### BR-040: Expense Currency Conversion
- All expenses store: original `amount` + `currency`, plus `amount_usd` (converted) and `exchange_rate` used
- Conversion performed at time of creation using current rate from `currency_rates` table
- Primary business currency: USD
- Local currency: ECD (Eastern Caribbean Dollar)
- Common client currencies: EUR, GBP
- If rate unavailable, expense is saved without conversion and flagged for manual rate entry
### BR-041: Invoice Auto-Numbering
- Format: `INV-YYYYMM-###`
- Sequential within each port per month
- Example: INV-202603-001, INV-202603-002
- Counter resets each month
### BR-042: Invoice Net 10 Discount
- When `payment_terms` = `net10`, apply 2% discount on subtotal
- Discount calculated automatically on invoice creation/update
- `discount_pct` = 2, `discount_amount` = subtotal × 0.02
- Configurable: the 2% rate can be changed in system settings
### BR-043: Parent Company Export
- Expense export for parent company includes:
- All expenses in selection with receipt images
- EUR subtotal (all expenses converted to EUR)
- 5% processing fee on the EUR subtotal
- Configurable: the 5% rate can be changed in system settings
### BR-044: Invoice Overdue Detection
- **Trigger:** Daily job checks all invoices with status `sent`
- **Condition:** `due_date` < today
- **Action:** Update status to `overdue`, create notification for invoice creator and admin
### BR-045: Invoice-Expense Integrity
- When creating an invoice from expenses, both the invoice and each linked expense are updated in a single database transaction
- If any part fails, the entire operation rolls back
- Bidirectional link maintained via `invoice_expenses` junction table (no comma-separated IDs)
---
## 6. Notification Rules
### BR-050: Notification Creation
Notifications are created by the system (not users). Events that trigger notifications:
| Event | Recipient(s) | Type |
| ------------------------------------- | ---------------------------------------------- | ------------------- |
| New website registration | Salesperson(s) at port | `new_registration` |
| Reminder due today | Reminder assignee | `reminder_due` |
| Reminder overdue | Reminder assignee + creator | `reminder_overdue` |
| EOI signer completed | Next signer + interest owner | `eoi_signed` |
| All signers completed | All signers + interest owner | `eoi_completed` |
| Duplicate client detected | All users with `clients.create` permission | `duplicate_alert` |
| Invoice overdue | Invoice creator + admin | `invoice_overdue` |
| Berth became available (waiting list) | Waiting list clients (via email) + salesperson | `waiting_list` |
| Background job failure | Super admin | `system_alert` |
| Follow-up reminder auto-created | Interest owner / assigned salesperson | `follow_up_created` |
| Berth tenure expiring | Admin | `tenure_expiring` |
### BR-051: Notification Preferences
- Each user can configure per-notification-type: in-app (on/off), email (on/off)
- System alerts to super admin are always delivered (cannot be suppressed)
- Defaults: all notification types on for both in-app and email
### BR-052: Notification Cooldown
- Notifications of the same type for the same entity are throttled
- Default cooldown: 1 hour (configurable per notification type)
- Prevents notification spam for rapid successive events
---
## 7. Reminder & Calendar Rules
### BR-060: Follow-Up Reminder (Auto-Generated)
- **Trigger:** Interest has `reminder_enabled = true` and `reminder_days` set
- **Check:** Has there been any activity (note, status change, email, call log) on this interest in the last `reminder_days` days?
- **No activity:** Create a CRM reminder "Follow up with {client name}" assigned to the interest's salesperson (marked `auto_generated = true`), and create a notification
- **Activity found:** Reset the timer. Check again in `reminder_days` days.
- **Schedule:** Checked hourly via BullMQ recurring job
- **Google Calendar:** Auto-generated follow-up reminders are NOT automatically pushed to Google Calendar (salesperson can manually toggle sync on each one)
### BR-061: Google Calendar Sync
- **Push:** When a reminder is created/updated with `sync_to_calendar = true`, create/update a Google Calendar event via the API. Store the `google_calendar_event_id` on the reminder record.
- **Pull:** Three sync triggers: (1) Background poll every 30 minutes for all connected users, (2) On user login if calendar is connected, (3) On navigation to any calendar-displaying page (dashboard, reminders, client detail) if last sync > 5 min ago. All syncs fetch upcoming events (next 14 days) and upsert into `google_calendar_cache`.
- **Two-way detection:** On each pull sync, if a CRM-pushed event was deleted or moved in Google Calendar, update the corresponding CRM reminder's `due_at` or mark as `dismissed`.
- **Token refresh:** If access token is expired, use refresh token to obtain a new one. If refresh fails (revoked), mark the connection as disconnected and notify the user.
- **Scope:** Calendar sync is per-user, not per-port. A user's calendar connection works across all ports they have access to.
### BR-062: Reminder Snooze
- **Action:** Salesperson clicks "Snooze" on a reminder → selects snooze duration (1 hour, 4 hours, tomorrow, next week, custom)
- **Effect:** Reminder status changes to `snoozed`, `snoozed_until` is set. Reminder resurfaces as `pending` when the snooze time passes.
- **Google Calendar:** If synced, the Google Calendar event is updated to the new time.
---
## 8. Multi-Tenancy Rules
### BR-070: Port Scoping
- Every database query for port-scoped tables MUST include `WHERE port_id = :currentPortId`
- Middleware extracts current port from session/header and injects into request context
- Drizzle query builder enforced via wrapper functions: `withPortScope(query, portId)`
- Missing port context = 400 error (never fall through to unscoped query)
### BR-071: Super Admin Bypass
- Super admin (`is_super_admin = true`) can:
- Query across all ports (omit port_id filter)
- Access any port's admin panel
- Manage global roles and system settings
- View unified cross-port dashboard
- Still respects audit logging (all super admin actions logged)
### BR-072: Port Switcher & Single-Port Mode
- **Single-port mode:** When only 1 active port exists, all multi-port UI is hidden (port switcher, port columns in tables, port selection on entity forms, port filter dropdowns). Port context set automatically behind the scenes.
- **Multi-port mode:** When 2+ active ports exist, users with roles at multiple ports see a port switcher. Switching port changes all data context immediately.
- Mode is derived dynamically from `SELECT count(*) FROM ports WHERE is_active = true` — no manual toggle needed.
- Current port stored in session, not URL (avoid URL manipulation)
- Port switch logged in audit log
### BR-073: New Port Setup
- Creating a new port auto-inherits: all global roles, all global system settings
- Port-specific overrides start empty (inherit globals until overridden)
- Onboarding wizard guides: port details → berth import → user assignment → branding
---
## 9. Audit & Undo Rules
### BR-080: Audit Log Completeness
- Every create, update, delete, archive, restore, merge, login, logout action is logged
- Update logs include: field name, old value (JSON), new value (JSON)
- No exceptions — even bulk operations log individual changes
### BR-081: Undo Eligibility
- **Undoable:** Field value changes, status changes, berth link/unlink, tag changes, role changes
- **Not undoable:** File deletions (file removed from storage), sent emails, signed documents, completed Documenso flows
- Undo creates a new audit entry with `revert_of` pointing to the original change
- Undo only available to super admin
### BR-082: Audit Retention
- Audit logs are never automatically deleted
- Exportable as CSV for archival
- Future: configurable retention policy (archive logs older than X years)
---
## 10. File Management Rules
### BR-090: Client-Grouped Storage
- Files uploaded via a client context are stored in `/clients/{client_id}/{category}/`
- Category determined by upload context (EOI page → eoi folder, expense → receipts folder)
- Files uploaded without client context go to `/port/{port_id}/general/`
### BR-091: File Deletion
- File deletion removes: MinIO object + `files` table entry
- Deletion logged in audit log (with filename and path for reference)
- Files referenced by other records (e.g., receipt on an expense, PDF on an invoice) cannot be deleted until the reference is removed
### BR-092: S3 Migration
- One-time migration job: scan all existing MinIO files → attempt to match to clients → propose reorganization
- Uncertain matches flagged for manual review
- Migration runs as a BullMQ job with progress tracking
- Rollback: original paths stored in migration log, can undo file moves
---
## 11. Search Rules
### BR-100: Global Search Scope
- Searches: client full name, company name, email, phone, yacht name; berth mooring number, area; interest pipeline stage; document titles; invoice numbers; expense establishment names; note content
- Results scoped to current port (unless super admin doing cross-port search)
- Archived records included in results but marked as archived
- Results ranked by relevance with entity type grouping
### BR-101: Duplicate Search on Create
- Before creating a new client, the create form runs a real-time search to show potential matches
- Uses same fuzzy matching as BR-031 but displays as suggestions, not blocks
- User can choose to proceed with creation or select an existing client
---
## 12. Webhook Rules
### BR-110: Webhook Delivery
- Webhooks fire asynchronously via BullMQ (do not block the triggering operation)
- Retry: 3 attempts with exponential backoff (1s, 10s, 100s)
- After 3 failures: move to dead letter queue, create system alert
- Payload signed with HMAC-SHA256 using the webhook's secret (header: `X-CRM-Signature`)
### BR-111: Webhook Events
Events that can trigger webhooks:
`client.created`, `client.updated`, `interest.created`, `interest.updated`, `interest.stage_changed`, `berth.status_changed`, `document.signed`, `document.completed`, `invoice.created`, `invoice.paid`, `expense.created`, `registration.new`
### BR-112: Webhook Port Scoping
- Webhooks are port-scoped: each webhook belongs to a specific port and only fires for events within that port
- Super admin cross-port actions trigger the webhook for the port where the data resides (not the super admin's "home" port)
- Webhook `port_id` is set on creation and cannot be changed (create a new webhook for a different port)
---
## 13. Scheduled Report Rules
### BR-120: Report Generation
- Reports generated as PDF files via BullMQ scheduled job
- Date range automatically calculated: daily (last 24h), weekly (last 7 days), monthly (last calendar month)
- Generated PDFs stored temporarily in MinIO, attached to email, then cleaned up after 7 days
- If generation fails, retry once, then create system alert
### BR-121: Report Delivery
- Sent via Poste.io to all configured recipients
- Both CRM users (by user ID → email) and external email addresses supported
- Delivery failures logged but do not prevent delivery to other recipients
---
## 14. Form & Credential Rules
### BR-130: Form Expiry
- Form submission tokens have a configurable expiry (default: 7 days)
- **Trigger:** Hourly background job checks `form_submissions` for `status = 'pending'` AND `expires_at < now()`
- **Action:** Set status to `expired`. Expired forms return a friendly "this link has expired" page.
- Salesperson can regenerate a new form link for the same client/interest
### BR-131: Email Account Credential Security
- User email credentials encrypted with AES-256-GCM before storage in `email_accounts.credentials_enc`
- Encryption key stored in environment variable (never in database or source code)
- Credentials decrypted only at point of use (SMTP/IMAP connection), never logged or exposed in API responses
- On credential update, old credentials are overwritten (not versioned)
- Failed IMAP/SMTP connection after 3 consecutive attempts disables the account and notifies the user
### BR-132: Custom Field Validation
- Required custom fields are enforced on entity create/update
- Select-type fields validate against defined options
- Deleting a custom field definition also deletes all corresponding values (cascade)
- Custom fields are included in export operations
### BR-133: Milestone Auto-Population
- When a document of type `eoi` is sent: auto-set `interest.date_eoi_sent` (if not already set)
- When a document of type `eoi` is completed (all signed): auto-set `interest.date_eoi_signed`
- When a document of type `contract` is sent: auto-set `interest.date_contract_sent`
- When a document of type `contract` is completed: auto-set `interest.date_contract_signed`
- All auto-populated dates are overridable by manual edit
### BR-134: Waiting List Priority Notification Order
- When a berth becomes available (status changes to `available` from `under_offer` or `sold`):
- High-priority waiting list clients are notified first
- Within same priority level, notify in position order (first in line first)
- Notifications staggered: 1 hour delay between each client notification to allow first responders to claim
- First client to respond and confirm interest gets the berth linked (salesperson confirms manually)
---
## 15. Document Template Rules
### BR-140: Merge Field Resolution
- When generating a document from a template, all `{{entity.field}}` tokens are resolved against the provided context (client, interest, berth, port)
- Missing required context (e.g., template references `{{berth.mooring_number}}` but no berth provided) → return validation error listing missing data
- Optional merge fields that resolve to null/empty are replaced with empty string (not left as raw tokens)
- Merge fields support nested access: `{{client.contacts.primary_email}}`, `{{interest.berth.mooring_number}}`
### BR-141: Template Management
- Only users with `admin.manage_forms` permission can create, edit, or delete templates
- All users with `documents.create` permission can generate documents from templates
- Deleting a template does NOT delete previously generated documents — they are standalone files
- Templates are port-scoped (each port has its own template library)
### BR-142: Generated Document Storage
- Documents generated from templates are stored as PDF files in MinIO under the client's correspondence folder
- A `documents` table entry is created with `document_type = 'template_generated'` and reference to the source template
- Generated documents appear in the client's file list and document history
---
## 16. Record PDF Export Rules
### BR-150: PDF Branding
- All record export PDFs include the port's logo and primary color from `ports` settings
- If no logo configured, a text-only header with the port name is used
- PDF layout is consistent across all record types (header, content sections, footer with generation timestamp)
### BR-151: PDF Content Scope
- Client PDF: includes all non-archived data — contacts, vessel details, linked interests with current status, recent 20 activity entries, files list (names only, not embedded)
- Berth PDF: includes full spec sheet data, current status, pricing in all configured currencies, linked interests summary, maintenance log summary
- Interest PDF: includes client info, berth info, pipeline stage, EOI/contract status, all milestones, all notes, recent 20 timeline entries
- Custom fields are included in exports for the relevant entity type
### BR-152: PDF Generation
- PDFs generated server-side via @pdfme (same library used for EOI generation)
- Generation runs synchronously for single-record exports (no background job needed — fast enough)
- If generation fails, return error to user (no silent failure)

446
10-AUTH-AND-PERMISSIONS.md Normal file
View File

@@ -0,0 +1,446 @@
# Port Nimara CRM — Authentication & Permission Flows
**Compiled:** 2026-03-11
**Auth Library:** Better Auth (integrated into Next.js)
**Session:** httpOnly secure cookies with CSRF protection
---
## 1. Authentication Flows
### 1.1 Login Flow
```
User enters email + password on /login
→ POST /api/auth/login { email, password }
→ Better Auth validates credentials (argon2/bcrypt hash comparison)
→ On success:
→ Create session in PostgreSQL (via Better Auth)
→ Set httpOnly secure cookie with session ID
→ Load user_port_roles to determine accessible ports
→ If single port: redirect to dashboard for that port
→ If multiple ports: redirect to port selector
→ If no ports assigned: show "Contact your administrator" message
→ On failure:
→ Return 401 with generic "Invalid credentials" (no info leak)
→ Rate limit: 5 failed attempts per email per 15 minutes → temporary lockout
```
### 1.2 Session Management
```
Every authenticated request:
→ Middleware reads session cookie
→ Validates session in PostgreSQL (via Better Auth)
→ Extracts user ID from session
→ Loads user_port_roles for current port (from X-Port-Id header or session)
→ Attaches to request context: { userId, portId, role, permissions }
→ If session expired or invalid: return 401
```
Session configuration:
- Session duration: 24 hours (configurable)
- Session refresh: on every request within last 25% of duration
- Concurrent sessions: allowed (multiple devices)
- Session revocation: logout destroys session, admin can revoke all sessions for a user
### 1.3 Password Set (First Login)
```
Admin creates user account
→ System generates secure token (UUID + HMAC)
→ Token stored in DB with expiry (48 hours)
→ Email sent via Poste.io: "Set your password" with link to /auth/set-password?token=xxx
→ User clicks link → /auth/set-password page
→ POST /api/auth/password/set { token, password, password_confirm }
→ Validate token (not expired, not used)
→ Hash password with argon2
→ Update user record
→ Invalidate token
→ Redirect to /login
```
### 1.4 Password Reset
```
User clicks "Forgot password" on /login
→ POST /api/auth/password/reset-request { email }
→ If email exists: generate token, send email
→ If email doesn't exist: return same success response (no info leak)
→ Email sent via Poste.io: "Reset your password" with link
→ Same flow as 1.3 from the link click onward
```
### 1.5 Logout
```
User clicks logout
→ POST /api/auth/logout
→ Destroy session in PostgreSQL
→ Clear session cookie
→ Redirect to /login
```
---
## 2. Authorization Model
### 2.1 Three-Tier Access Levels
```
TIER 1: Super Admin (is_super_admin = true on user_profiles)
├── Bypasses ALL permission checks
├── Bypasses port scoping (can see all ports)
├── Can manage global roles, system settings, all ports
├── Actions still logged in audit log
└── Currently: Matt only
TIER 2: Director (system role "director")
├── Operational admin within assigned port(s)
├── Can: manage users within their port, view port-scoped audit logs,
│ configure reminder settings/alerts, manage most operational settings
├── Cannot: modify system-level settings, define/modify roles,
│ access ports they're not assigned to, restore backups
└── Subject to normal permission checks for non-admin operations
TIER 3: Role-Based (all other users)
├── Permissions defined by their role at their current port
├── Role is a JSON permission map with boolean flags
└── Cannot access admin panel (unless role explicitly grants admin permissions)
```
### 2.2 Permission Structure
Permissions are stored as a JSON object on each role. Structure:
```json
{
"clients": {
"view": true,
"create": true,
"edit": true,
"delete": false,
"merge": false,
"export": true
},
"interests": {
"view": true,
"create": true,
"edit": true,
"delete": false,
"change_stage": true,
"generate_eoi": true,
"export": true
},
"berths": {
"view": true,
"edit": false,
"import": false,
"manage_waiting_list": true
},
"documents": {
"view": true,
"create": true,
"send_for_signing": true,
"upload_signed": true,
"delete": false
},
"expenses": {
"view": true,
"create": true,
"edit": true,
"delete": false,
"export": true,
"scan_receipt": true
},
"invoices": {
"view": true,
"create": true,
"edit": true,
"delete": false,
"send": true,
"record_payment": true,
"export": true
},
"files": {
"view": true,
"upload": true,
"delete": false,
"manage_folders": false
},
"email": {
"view": true,
"send": true,
"configure_account": true
},
"reminders": {
"view_own": true,
"view_all": false,
"create": true,
"edit_own": true,
"edit_all": false,
"assign_others": false
},
"calendar": {
"connect": true,
"view_events": true
},
"reports": {
"view_dashboard": true,
"view_analytics": true,
"export": true
},
"document_templates": {
"view": true,
"generate": true,
"manage": false
},
"admin": {
"manage_users": false,
"view_audit_log": false,
"manage_settings": false,
"manage_webhooks": false,
"manage_reports": false,
"manage_custom_fields": false,
"manage_forms": false,
"manage_tags": true,
"system_backup": false
}
}
```
### 2.3 Permission Resolution
```
For any action by user U at port P:
1. If U.is_super_admin → ALLOW (always)
2. Load role R from user_port_roles WHERE user_id = U AND port_id = P
→ If no role found → DENY (user has no access to this port)
3. Load base permissions from roles WHERE id = R
→ permissions = R.permissions
4. Check for port override:
→ Load port_role_overrides WHERE port_id = P AND role_id = R
→ If override exists: deep merge override.permission_overrides INTO permissions
(override values replace base values for matching keys)
5. Check required permission for the action:
→ e.g., "clients.edit" for editing a client
→ If permissions["clients"]["edit"] === true → ALLOW
→ Otherwise → DENY (403 Forbidden)
```
### 2.4 Pre-Built System Roles
Created on system initialization (is_system = true, cannot be deleted):
| Role | Description | Key Permissions |
| --------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------- |
| `super_admin` | Full system access | All permissions true (redundant with is_super_admin flag, exists for role system completeness) |
| `director` | Operational admin | All operational permissions true, admin.manage_users true, admin.view_audit_log true. No system-level admin. |
| `sales_manager` | Full sales access | All client/interest/document/expense/invoice permissions, reminders.view_all and assign_others, calendar.connect, reports full access |
| `sales_agent` | Standard sales | View/create/edit clients and interests, send documents, manage own reminders, calendar.connect. No delete, no admin. |
| `viewer` | Read-only access | All view permissions true, everything else false |
These can be customized (permissions changed) but not deleted. Admins can create additional custom roles.
---
## 3. Middleware Implementation
### 3.1 Auth Middleware (every route except /api/public/\*)
```typescript
// Pseudocode for Next.js middleware
async function authMiddleware(req) {
// 1. Skip for public routes
if (req.path.startsWith('/api/public/')) return next();
if (req.path.startsWith('/auth/')) return next();
// 2. Validate session
const session = await betterAuth.getSession(req);
if (!session) return Response.json({ error: 'Unauthorized' }, { status: 401 });
// 3. Load user profile
const profile = await db.query.userProfiles.findFirst({
where: eq(userProfiles.userId, session.userId),
});
if (!profile || !profile.isActive)
return Response.json({ error: 'Account disabled' }, { status: 403 });
// 4. Determine port context
const portId = req.headers.get('X-Port-Id') || session.currentPortId;
if (!portId && !profile.isSuperAdmin) {
return Response.json({ error: 'Port context required' }, { status: 400 });
}
// 5. Load role for this port (skip for super admin)
let permissions = null;
if (!profile.isSuperAdmin) {
const portRole = await db.query.userPortRoles.findFirst({
where: and(eq(userPortRoles.userId, session.userId), eq(userPortRoles.portId, portId)),
with: { role: true },
});
if (!portRole) return Response.json({ error: 'No access to this port' }, { status: 403 });
// 6. Apply port overrides
permissions = { ...portRole.role.permissions };
const override = await db.query.portRoleOverrides.findFirst({
where: and(
eq(portRoleOverrides.portId, portId),
eq(portRoleOverrides.roleId, portRole.roleId),
),
});
if (override) {
permissions = deepMerge(permissions, override.permissionOverrides);
}
}
// 7. Attach to request context
req.ctx = {
userId: session.userId,
portId,
isSuperAdmin: profile.isSuperAdmin,
permissions, // null for super admin (bypasses checks)
profile,
};
return next();
}
```
### 3.2 Permission Check Helper
```typescript
function requirePermission(ctx, resource: string, action: string) {
if (ctx.isSuperAdmin) return; // always allowed
const resourcePerms = ctx.permissions?.[resource];
if (!resourcePerms || !resourcePerms[action]) {
throw new ForbiddenError(`Missing permission: ${resource}.${action}`);
}
}
// Usage in API route:
export async function PATCH(req) {
requirePermission(req.ctx, 'clients', 'edit');
// ... proceed with edit
}
```
### 3.3 Port Scoping Helper
```typescript
function withPortScope<T>(query: DrizzleQuery<T>, portId: string) {
// Adds WHERE port_id = portId to any query
// Used on every database query for port-scoped tables
return query.where(eq(table.portId, portId));
}
// Super admin cross-port query:
function withOptionalPortScope<T>(query: DrizzleQuery<T>, ctx: RequestContext) {
if (ctx.isSuperAdmin && !ctx.portId) return query; // no scope
return withPortScope(query, ctx.portId);
}
```
---
## 4. API Route Protection Map
| Route Pattern | Auth | Permission Required |
| -------------------------------------------- | --------------- | ---------------------------------------------------------------------------------- |
| `/api/public/*` | None | None |
| `/api/auth/*` | None (pre-auth) | None |
| `/api/clients` GET | Session | `clients.view` |
| `/api/clients` POST | Session | `clients.create` |
| `/api/clients/:id` PATCH | Session | `clients.edit` |
| `/api/clients/:id` DELETE | Session | `clients.delete` |
| `/api/clients/merge` POST | Session | `clients.merge` |
| `/api/interests` GET | Session | `interests.view` |
| `/api/interests` POST | Session | `interests.create` |
| `/api/interests/:id` PATCH | Session | `interests.edit` |
| `/api/interests/:id/stage` PATCH | Session | `interests.change_stage` |
| `/api/documents/generate-eoi` POST | Session | `interests.generate_eoi` |
| `/api/berths` GET | Session | `berths.view` |
| `/api/berths/:id` PATCH | Session | `berths.edit` |
| `/api/berths/import` POST | Session | `berths.import` |
| `/api/expenses` GET | Session | `expenses.view` |
| `/api/expenses` POST | Session | `expenses.create` |
| `/api/expenses/scan-receipt` POST | Session | `expenses.scan_receipt` |
| `/api/expenses/export/*` POST | Session | `expenses.export` |
| `/api/invoices` GET | Session | `invoices.view` |
| `/api/invoices` POST | Session | `invoices.create` |
| `/api/invoices/:id/send` POST | Session | `invoices.send` |
| `/api/invoices/:id/payment` PATCH | Session | `invoices.record_payment` |
| `/api/files` GET | Session | `files.view` |
| `/api/files/upload` POST | Session | `files.upload` |
| `/api/files/:id` DELETE | Session | `files.delete` |
| `/api/document-templates` GET | Session | `document_templates.view` |
| `/api/document-templates` POST | Session | `document_templates.manage` |
| `/api/document-templates/:id` PATCH | Session | `document_templates.manage` |
| `/api/document-templates/:id` DELETE | Session | `document_templates.manage` |
| `/api/document-templates/:id/generate*` POST | Session | `document_templates.generate` |
| `/api/clients/:id/export-pdf` POST | Session | `clients.view` |
| `/api/berths/:id/export-pdf` POST | Session | `berths.view` |
| `/api/interests/:id/export-pdf` POST | Session | `interests.view` |
| `/api/email/*` | Session | `email.*` (various) |
| `/api/reminders` GET | Session | `reminders.view_own` (or `reminders.view_all` for all) |
| `/api/reminders/:id/complete` POST | Session | `reminders.edit_own` (own) or `reminders.edit_all` (others') |
| `/api/calendar/*` | Session | `calendar.connect` (for auth/disconnect), `calendar.view_events` (for sync/status) |
| `/api/admin/users/*` | Session | `admin.manage_users` |
| `/api/admin/roles/*` | Session | Super admin only |
| `/api/admin/ports/*` | Session | Super admin only |
| `/api/admin/audit-logs/*` | Session | `admin.view_audit_log` |
| `/api/admin/audit-logs/:id/revert` POST | Session | Super admin only |
| `/api/admin/settings` GET/PATCH | Session | `admin.manage_settings` |
| `/api/admin/backup/*` | Session | Super admin only |
| `/api/admin/webhooks/*` | Session | `admin.manage_webhooks` |
| `/api/admin/reports/*` | Session | `admin.manage_reports` |
| `/api/admin/custom-fields/*` | Session | `admin.manage_custom_fields` |
| `/api/admin/health` GET | Session | `admin.manage_settings` |
| `/api/admin/jobs/*` | Session | Super admin only |
---
## 5. Security Hardening
### 5.1 Rate Limiting
- Login: 5 attempts per email per 15 minutes
- Public API: 60 requests per minute per IP
- Authenticated API: 300 requests per minute per user
- File upload: 10 uploads per minute per user
- Bulk operations: 5 per minute per user
### 5.2 Session Security
- Cookies: httpOnly, secure, SameSite=Strict
- CSRF token in all state-changing requests
- Session ID rotated on privilege escalation (login, port switch)
### 5.3 Password Requirements
- Minimum 8 characters
- At least 1 uppercase, 1 lowercase, 1 number
- Checked against common password list (top 10,000)
- Hashed with argon2id
### 5.4 Headers
- Content-Security-Policy (strict)
- X-Frame-Options: DENY
- X-Content-Type-Options: nosniff
- Strict-Transport-Security (HSTS)
- X-XSS-Protection: 1; mode=block
### 5.5 Input Validation
- All inputs validated with Zod schemas before processing
- SQL injection prevented by Drizzle ORM parameterized queries
- XSS prevented by React's default escaping + CSP headers
- File upload: MIME type validation, size limits (configurable, default 50MB)

View File

@@ -0,0 +1,271 @@
# Port Nimara CRM — Real-Time Events & Background Jobs
**Compiled:** 2026-03-11
**Real-Time:** Socket.io (WebSocket alongside Next.js)
**Job Queue:** BullMQ + Redis
**Transport:** Redis adapter for Socket.io (multi-instance ready)
---
## 1. Socket.io Architecture
### 1.1 Connection
```
Client connects on app load:
→ Authenticate via session cookie (same as HTTP auth)
→ Join room: port:{portId} (scoped to current port)
→ Join room: user:{userId} (for personal notifications)
→ If super admin with cross-port view: join room: global
```
### 1.2 Room Structure
| Room | Members | Purpose |
| ----------------------- | ---------------------------------------- | -------------------------------------------------------------- |
| `port:{portId}` | All users at that port | Port-scoped data updates |
| `user:{userId}` | Single user (all their tabs/devices) | Personal notifications, reminder alerts, calendar sync updates |
| `global` | Super admin(s) in cross-port mode | System alerts, cross-port updates |
| `berth:{berthId}` | Users viewing a specific berth detail | Granular berth updates |
| `client:{clientId}` | Users viewing a specific client detail | Granular client updates |
| `interest:{interestId}` | Users viewing a specific interest detail | Granular interest updates |
---
## 2. Real-Time Event Catalog
### 2.1 Berth Events
| Event | Room | Payload | Trigger |
| -------------------------- | ---------------------------------- | ------------------------------------------------ | ------------------------------------------ |
| `berth:statusChanged` | `port:{portId}` | `{ berthId, oldStatus, newStatus, triggeredBy }` | Berth status updated (manual or auto) |
| `berth:updated` | `port:{portId}`, `berth:{berthId}` | `{ berthId, changedFields }` | Any berth field updated |
| `berth:waitingListChanged` | `berth:{berthId}` | `{ berthId, action, entry }` | Waiting list entry added/removed/reordered |
| `berth:maintenanceAdded` | `berth:{berthId}` | `{ berthId, logEntry }` | New maintenance log entry |
### 2.2 Client Events
| Event | Room | Payload | Trigger |
| -------------------------- | ------------------------------------ | ------------------------------------------- | ---------------------------- |
| `client:created` | `port:{portId}` | `{ clientId, clientName, source }` | New client created |
| `client:updated` | `port:{portId}`, `client:{clientId}` | `{ clientId, changedFields }` | Client fields updated |
| `client:archived` | `port:{portId}` | `{ clientId }` | Client archived |
| `client:restored` | `port:{portId}` | `{ clientId }` | Client restored from archive |
| `client:merged` | `port:{portId}` | `{ survivingId, mergedId }` | Clients merged |
| `client:noteAdded` | `client:{clientId}` | `{ clientId, noteId, authorName, preview }` | New note added |
| `client:duplicateDetected` | `port:{portId}` | `{ clientAId, clientBId, score, reason }` | Duplicate alert created |
### 2.3 Interest Events
| Event | Room | Payload | Trigger |
| ------------------------ | ---------------------------------------- | ------------------------------------------------------------- | ---------------------------- |
| `interest:created` | `port:{portId}` | `{ interestId, clientId, berthId, source }` | New interest created |
| `interest:updated` | `port:{portId}`, `interest:{interestId}` | `{ interestId, changedFields }` | Interest fields updated |
| `interest:stageChanged` | `port:{portId}` | `{ interestId, oldStage, newStage, clientName, berthNumber }` | Pipeline stage changed |
| `interest:berthLinked` | `port:{portId}` | `{ interestId, berthId }` | Berth linked to interest |
| `interest:berthUnlinked` | `port:{portId}` | `{ interestId, berthId }` | Berth unlinked from interest |
| `interest:archived` | `port:{portId}` | `{ interestId }` | Interest archived |
| `interest:noteAdded` | `interest:{interestId}` | `{ interestId, noteId, authorName, preview }` | Note added to interest |
### 2.4 Document Events
| Event | Room | Payload | Trigger |
| ----------------------- | ---------------------------------------- | ---------------------------------------------------------- | --------------------------- |
| `document:created` | `port:{portId}` | `{ documentId, type, interestId }` | Document record created |
| `document:sent` | `port:{portId}` | `{ documentId, type, signerCount }` | Document sent for signing |
| `document:signed` | `port:{portId}`, `interest:{interestId}` | `{ documentId, signerName, signerRole, remainingSigners }` | Individual signer completed |
| `document:completed` | `port:{portId}`, `interest:{interestId}` | `{ documentId, type, interestId, clientName }` | All signers completed |
| `document:expired` | `port:{portId}` | `{ documentId }` | Document expired |
| `document:reminderSent` | `interest:{interestId}` | `{ documentId, recipientEmail }` | Signing reminder sent |
### 2.5 Financial Events
| Event | Room | Payload | Trigger |
| ----------------- | --------------- | ------------------------------------------------- | ---------------------- |
| `expense:created` | `port:{portId}` | `{ expenseId, amount, currency, category }` | Expense created |
| `expense:updated` | `port:{portId}` | `{ expenseId, changedFields }` | Expense updated |
| `invoice:created` | `port:{portId}` | `{ invoiceId, invoiceNumber, total, clientName }` | Invoice created |
| `invoice:sent` | `port:{portId}` | `{ invoiceId, invoiceNumber, recipientEmail }` | Invoice emailed |
| `invoice:paid` | `port:{portId}` | `{ invoiceId, invoiceNumber, amount }` | Payment recorded |
| `invoice:overdue` | `port:{portId}` | `{ invoiceId, invoiceNumber, daysPastDue }` | Invoice became overdue |
### 2.6 Reminder & Calendar Events
| Event | Room | Payload | Trigger |
| ----------------------- | ------------------------------------ | ------------------------------------------ | ------------------------------------- |
| `reminder:created` | `user:{assigneeId}`, `port:{portId}` | `{ reminderId, title, assignedTo, dueAt }` | Reminder created |
| `reminder:updated` | `user:{assigneeId}` | `{ reminderId, changedFields }` | Reminder updated |
| `reminder:completed` | `user:{assigneeId}`, `port:{portId}` | `{ reminderId, title, completedBy }` | Reminder completed |
| `reminder:overdue` | `user:{assigneeId}` | `{ reminderId, title, dueAt }` | Reminder became overdue |
| `reminder:snoozed` | `user:{assigneeId}` | `{ reminderId, snoozedUntil }` | Reminder snoozed |
| `calendar:synced` | `user:{userId}` | `{ eventCount, lastSyncAt }` | Google Calendar sync completed |
| `calendar:disconnected` | `user:{userId}` | `{ reason }` | Google Calendar token revoked/expired |
### 2.7 Notification Events
| Event | Room | Payload | Trigger |
| -------------------------- | --------------- | ---------------------------------------------------- | ------------------------ |
| `notification:new` | `user:{userId}` | `{ notificationId, type, title, description, link }` | New notification created |
| `notification:unreadCount` | `user:{userId}` | `{ count }` | Unread count changed |
### 2.8 System Events
| Event | Room | Payload | Trigger |
| ------------------ | --------------- | --------------------------------------------------- | ------------------------ |
| `system:alert` | `global` | `{ alertType, message, severity }` | System alert triggered |
| `system:jobFailed` | `global` | `{ queueName, jobId, error }` | Background job failure |
| `registration:new` | `port:{portId}` | `{ clientId, interestId, clientName, berthNumber }` | New website registration |
### 2.9 Recommendation & Misc Events
| Event | Room | Payload | Trigger |
| ----------------------------------- | ---------------------------------------- | ------------------------------------------------ | ------------------------------------------------ |
| `interest:recommendationsGenerated` | `interest:{interestId}`, `port:{portId}` | `{ interestId, count, topBerthId }` | AI berth recommendations generated |
| `interest:recommendationAdded` | `interest:{interestId}` | `{ interestId, berthId, source, matchScore }` | Manual recommendation added |
| `interest:leadCategoryChanged` | `port:{portId}`, `interest:{interestId}` | `{ interestId, oldCategory, newCategory, auto }` | Lead category changed (including auto-promotion) |
| `file:uploaded` | `client:{clientId}`, `port:{portId}` | `{ fileId, filename, clientId, category }` | File uploaded |
| `file:deleted` | `client:{clientId}` | `{ fileId, filename }` | File deleted |
---
## 3. Background Jobs (BullMQ)
### 3.1 Queue Structure
| Queue | Concurrency | Description |
| --------------- | ----------- | ------------------------------------------------------------------------ |
| `email` | 5 | All email sending (transactional + user) |
| `documents` | 3 | Documenso API interactions (create, poll, download) |
| `notifications` | 10 | In-app notification creation and email notification sending |
| `import` | 1 | Data import jobs (CSV, Excel, berth specs) |
| `export` | 2 | Data export jobs (CSV, PDF, parent company report) |
| `reports` | 1 | Scheduled report generation |
| `webhooks` | 5 | Outbound webhook delivery |
| `maintenance` | 1 | System maintenance (backups, cleanup, rate refresh) |
| `ai` | 2 | AI-assisted tasks (receipt scanning, berth spec parsing, file migration) |
| `bulk` | 2 | Bulk operations (status change, tag, delete) |
### 3.2 Recurring Jobs
| Job | Queue | Schedule | Description |
| ------------------------ | --------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `signature-poll` | `documents` | Every 6 hours | Rare fallback poll of Documenso for interests with pending signatures. Primary mechanism is Documenso webhooks (instant). This job is a safety net only — catches any edge case where a webhook was lost. |
| `reminder-check` | `notifications` | Every hour | Check interests with reminder_enabled for inactivity → create follow-up reminders |
| `reminder-overdue-check` | `notifications` | Every 15 minutes | Check for reminders past due_at → create overdue notifications |
| `calendar-sync` | `maintenance` | Every 30 minutes | Background poll for all connected Google Calendar users: fetch upcoming events (14 days), upsert into google_calendar_cache, detect moved/deleted CRM-pushed events. Note: additional event-driven syncs fire on user login and on navigation to calendar-displaying pages (if last sync > 5 min ago) |
| `invoice-overdue-check` | `notifications` | Daily at 08:00 | Check for overdue invoices → update status, create notifications |
| `tenure-expiry-check` | `notifications` | Daily at 08:00 | Check for berth tenure approaching expiry → create notifications |
| `currency-refresh` | `maintenance` | Every 6 hours | Refresh exchange rates from Frankfurter API |
| `database-backup` | `maintenance` | Daily at 02:00 | pg_dump → store in MinIO |
| `backup-cleanup` | `maintenance` | Weekly (Sunday 03:00) | Delete backups older than retention period (default: 30 days, configurable in system_settings) |
| `session-cleanup` | `maintenance` | Daily at 04:00 | Remove expired Better Auth sessions |
| `report-scheduler` | `reports` | Every minute | Check scheduled_reports for reports due to run → enqueue generation |
| `notification-digest` | `email` | Configurable per user | Send batched email digest of unread notifications |
| `temp-file-cleanup` | `maintenance` | Daily at 05:00 | Clean up temporary files (report PDFs > 7 days, orphaned uploads) |
| `form-expiry-check` | `maintenance` | Hourly | Mark expired form submissions as expired (BR-130) |
### 3.3 Job Retry & Error Handling
Default retry configuration:
- **Attempts:** 3
- **Backoff:** Exponential (1s, 10s, 100s)
- **Dead letter:** After all retries exhausted, move to dead letter queue
- **Alert:** Dead letter creates system alert notification for super admin
Per-queue overrides:
| Queue | Max Attempts | Notes |
| ------------- | ------------ | ---------------------------------------------- |
| `email` | 5 | Email delivery can have transient failures |
| `webhooks` | 3 | Standard exponential backoff |
| `documents` | 5 | Documenso API can be temporarily unavailable |
| `import` | 1 | Imports are idempotent — user retries manually |
| `maintenance` | 3 | Backup failures alert immediately |
### 3.4 Job Priority
BullMQ supports job priority (lower number = higher priority):
| Priority | Jobs |
| ----------- | ----------------------------------------------------------------- |
| 1 (highest) | Password reset emails, system alerts |
| 2 | Signature webhooks, EOI reminders, reminder overdue checks |
| 3 (default) | Standard emails, notifications, webhook deliveries, calendar sync |
| 4 | Export jobs, report generation |
| 5 (lowest) | Data imports, AI tasks, backup cleanup |
---
## 4. Event Flow Examples
### 4.1 Website Interest Registration
```
Client submits form on website
→ POST /api/public/interests
→ Create client (or find existing via email)
→ Create interest record
→ Run duplicate detection (BR-030, BR-031)
→ Emit Socket.io: registration:new to port:{portId}
→ Enqueue notification job: notify salesperson(s)
→ If duplicate detected: enqueue notification job: duplicate alert
→ Return 201 to website
```
### 4.2 EOI Signing Flow
```
Salesperson generates EOI
→ POST /api/documents/generate-eoi
→ Generate PDF via @pdfme
→ Enqueue documents job: create Documenso document + assign signers
→ Update interest: eoi_status = waiting_for_signatures
→ Emit: document:sent, interest:stageChanged
→ Enqueue email job: send signing link to first signer (client)
Client signs
→ Documenso webhook: DOCUMENT_SIGNED
→ POST /api/webhooks/documenso
→ Deduplicate (signature_hash check)
→ Update document_signers record
→ Emit: document:signed to port + interest rooms
→ Enqueue notification: notify next signer (developer) + interest owner
→ Enqueue email: send signing link to developer
Developer signs → same flow → notify sales/approver
All sign (DOCUMENT_COMPLETED)
→ Enqueue documents job: download signed PDF from Documenso
→ Store in MinIO under client's EOI folder
→ Update document status = completed, interest eoi_status = signed
→ Emit: document:completed, interest:stageChanged
→ Enqueue email job: send signed PDF to all 3 parties
→ Enqueue notification: EOI completed
```
### 4.3 Berth Link with Auto-Status
```
Salesperson links berth to interest
→ POST /api/interests/:id/berth { berthId }
→ In transaction:
→ Create interest-berth link
→ If berth.status = 'available': update to 'under_offer' (BR-001)
→ Emit: interest:berthLinked
→ If status changed: emit berth:statusChanged (updates website map in real-time)
→ Audit log: interest updated + berth status changed
```
---
## 5. Redis Usage Summary
| Purpose | Redis Feature | Details |
| ------------------- | ------------------- | ------------------------------------------------------------------- |
| BullMQ job queues | Lists + Sorted Sets | 10 queues, recurring + one-off jobs |
| Socket.io adapter | Pub/Sub | Cross-instance message relay |
| Session cache | Key-Value | Better Auth session acceleration |
| Rate limiting | Sorted Sets | Per-IP and per-user request counting |
| Application cache | Key-Value with TTL | Exchange rates (6h), dashboard aggregates (5m), search results (1m) |
| Deduplication locks | Key-Value with TTL | Webhook dedup, notification cooldowns |

View File

@@ -0,0 +1,761 @@
# Port Nimara CRM — Implementation Sequence & Dependency Map
**Compiled:** 2026-03-11
**Timeline:** 1 month (target)
**Executor:** Claude Code (via Git worktrees for parallel streams)
**Planners:** Codex AND Claude Code — both produce competing medium-level implementation plans per layer/stream. Matt reviews both proposals and selects the best approach (or merges the best parts of each) before development begins.
**Constraint:** Team actively uses current system daily. No partial rollout — everything ships together. NocoDB migration is the final step, executed over a weekend.
**Security posture:** ALL data in this system is highly sensitive — wealthy individuals' personal information, financial records, legal documents, vessel details, internal communications. Security is not a polish layer — it is baked into every layer from Day 1. See "Security Requirements" section below.
---
## Timeline Overview
| Phase | Duration | What |
| --------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| **Planning** (us + Codex + Claude Code) | Days 15 | Finish high-level docs (us), Codex AND Claude Code each produce competing medium-level implementation plans. Matt reviews and selects. |
| **Layer 0: Foundation** | Days 69 | Project scaffolding, database, auth, base layout |
| **Layer 1: Core CRUD** | Days 1014 | Clients, berths, interests — the three pillars |
| **Layer 2: Business Workflows** | Days 1519 | EOI/signing, expenses/invoices, email, files |
| **Layer 3: Operations & Features** | Days 2023 | Reminders, calendar integration, notifications, search, dashboard, admin |
| **Layer 4: Advanced & Polish** | Days 2427 | New features (webhooks, templates, AI, import/export), dark mode, mobile, QoL |
| **Layer 5: Testing & Hardening** | Days 2528 | Vitest + Playwright, security hardening, performance (overlaps with Layer 4) |
| **Migration Weekend** | Days 2930 | NocoDB data migration, cutover, smoke testing |
---
## Dependency Graph (Visual)
```
LAYER 0: FOUNDATION (sequential — everything depends on this)
├── Project scaffold (Next.js 15, TypeScript, Tailwind, Docker Compose)
├── Database (PostgreSQL + Drizzle ORM — all 49 tables)
├── Auth (Better Auth + multi-port middleware + role system)
├── Infrastructure (Redis, Socket.io, BullMQ, MinIO connection)
└── Layout shell (sidebar, port switcher, navigation, base pages)
LAYER 1: CORE CRUD (3 parallel worktrees)
├── [Stream A] Client management (CRUD, contacts, relationships, notes, tags, duplicate detection)
├── [Stream B] Berth management (CRUD, map data, specs, tags, comparison view, tenure tracking)
└── [Stream C] Auth admin (user management, role builder, port management, onboarding wizard)
LAYER 2: BUSINESS WORKFLOWS (4 parallel worktrees, depends on Layer 1)
├── [Stream A] Interest management (CRUD, pipeline, berth linking, recommendations, waiting list)
├── [Stream B] EOI & Document signing (Documenso integration, 3-party signing, webhook receiver)
├── [Stream C] Expenses & Invoices (CRUD, receipt scanner, invoice generation, payment tracking, exports)
└── [Stream D] File management (MinIO integration, client folders, upload/download/preview)
LAYER 3: OPERATIONS (4 parallel worktrees, depends on Layers 1-2)
├── [Stream A] Email system (SMTP/IMAP, provider presets, thread viewer, composer)
├── [Stream B] Reminders, calendar integration & notification system (reminders, Google Calendar sync, notification center, preferences)
├── [Stream C] Search & views (global search, saved filters, bulk operations)
└── [Stream D] Dashboard & analytics (charts, pipeline summary, revenue forecast, upcoming reminders + calendar events)
LAYER 4: ADVANCED FEATURES (5 parallel worktrees, depends on Layer 3)
├── [Stream A] Webhooks & scheduled reports
├── [Stream B] Document templates & record PDF export
├── [Stream C] AI features (berth spec import, recommendation engine, S3 file migration)
├── [Stream D] Data import/export center & custom fields
└── [Stream E] UX polish (dark mode, mobile responsive, scratchpad, archiving, multi-currency)
LAYER 5: HARDENING (2 parallel worktrees, overlaps with Layer 4)
├── [Stream A] Testing (Vitest unit tests, Playwright E2E tests)
└── [Stream B] Security audit & performance (penetration testing mindset, final hardening pass)
LAYER 6: MIGRATION & CUTOVER (sequential, final weekend)
├── NocoDB data export + transform + PostgreSQL seed
├── MinIO file reorganization (AI-assisted)
├── Smoke testing + security verification
├── DNS/proxy switchover
└── Old system decommission
```
---
## Security Requirements (Mandatory — Every Layer)
**This system stores data about high-net-worth individuals: personal identities, financial records, legal agreements, vessel ownership, and private communications. A breach would expose extremely sensitive information. Security is not optional and not a final polish step — it is a hard requirement enforced at every layer from Day 1.**
### Threat Model
| Threat | Risk | Impact |
| ---------------------------------- | ------ | --------------------------------------------------------------------------------- |
| Unauthorized access to client PII | HIGH | Exposure of wealthy individuals' personal data, yacht ownership, contact details |
| Financial data exposure | HIGH | Expense records, invoice amounts, payment details, banking references |
| Legal document leakage | HIGH | Signed EOIs, contracts, NDAs — legally binding documents with personal signatures |
| Internal communication exposure | MEDIUM | Email threads, notes, internal assessments of clients |
| Session hijacking | HIGH | Attacker gains full CRM access with victim's permissions |
| Privilege escalation | HIGH | Regular user gains admin access, sees cross-port data |
| Data exfiltration via API | HIGH | Bulk extraction of client records through API abuse |
| Injection attacks (SQL, XSS, SSRF) | HIGH | Code execution, data theft, internal network access |
| Insider threat | MEDIUM | Disgruntled employee exports or leaks client data |
| Supply chain attack | LOW | Compromised npm package or Docker image |
### Security Architecture (Built Into Layer 0)
**Encryption at rest:**
- PostgreSQL: `pgcrypto` extension enabled, sensitive columns (email credentials) encrypted with AES-256-GCM before storage
- MinIO: server-side encryption enabled (SSE-S3) — all stored files encrypted on disk
- Redis: password-protected, bound to Docker internal network only (no external access)
- Backups: pg_dump output encrypted before storage in MinIO
**Encryption in transit:**
- TLS 1.3 enforced on nginx (no TLS 1.0/1.1)
- All inter-service communication over Docker internal network (not exposed to host)
- HSTS header with 1-year max-age + includeSubDomains
- MinIO, PostgreSQL, Redis connections use TLS within Docker network where supported
**Authentication hardening:**
- Argon2id password hashing (not bcrypt — Argon2id is the current recommendation)
- Minimum password length: 12 characters
- Rate limiting on login: 5 attempts per 15 minutes per IP, then temporary lockout
- Rate limiting on password reset: 3 requests per hour per email
- Session cookies: httpOnly, secure, SameSite=Strict, CSRF token required on all mutations
- Session timeout: configurable (default 8 hours idle, 24 hours absolute)
- Session invalidation on password change (all other sessions terminated)
- No session data in URL parameters, ever
**Authorization enforcement:**
- Every API endpoint checks permissions via middleware BEFORE any business logic executes
- Port scoping enforced at the database query level (Drizzle query builder), not just application level
- Super admin actions logged with elevated audit detail
- No "god mode" endpoints — super admin still goes through permission checks (just passes them all)
- Object-level authorization: verify the requested entity belongs to the user's current port
**Input validation:**
- Every API endpoint validates input through Zod schemas — no raw user input reaches the database
- Zod schemas define maximum lengths, allowed characters, format patterns for every field
- File uploads: mime type validation (allowlist), file size limits, filename sanitization, malware scanning consideration
- No user input interpolated into SQL (Drizzle ORM handles parameterization)
- HTML sanitization on any rich text input (document templates, notes) to prevent stored XSS
**Output security:**
- API error responses never leak internal details (no stack traces, no SQL errors, no file paths in production)
- Paginated responses capped at configurable max (default 100 records) — no unlimited bulk extraction
- Presigned MinIO URLs expire after 15 minutes (configurable)
- Sensitive fields stripped from API responses where not needed (e.g., email credentials never returned)
**Audit logging (every layer):**
- Every data mutation (create, update, delete, archive, restore, merge) logged to audit_logs
- Every authentication event logged (login, logout, failed login, password change, session creation)
- Every file operation logged (upload, download, delete, rename)
- Every permission-denied event logged (who tried to access what they shouldn't have)
- Audit logs are append-only — no endpoint exists to delete audit entries
- Audit logs include: timestamp, user ID, IP address, user agent, action, entity type, entity ID, old value, new value, port context
**Network security (Docker/nginx):**
- PostgreSQL, Redis, MinIO not exposed to host — only accessible via Docker internal network
- Only nginx exposes port 443 (HTTPS) to the outside world
- nginx rate limiting: global request limit + per-endpoint overrides for sensitive routes
- CORS: strict origin allowlist (CRM domain + website domain only)
- Content Security Policy (CSP) header: restrict script sources, frame ancestors, connect sources
- X-Frame-Options: DENY
- X-Content-Type-Options: nosniff
- Referrer-Policy: strict-origin-when-cross-origin
**Secrets management:**
- All secrets in environment variables (never in source code, never in database)
- .env files excluded from git (enforced in .gitignore + pre-commit hook)
- Docker secrets or bind-mounted env files in production (no env vars visible in `docker inspect`)
- Credential rotation plan documented: which secrets, how to rotate, what breaks if expired
**Data protection policies:**
- Client data export requires `admin.export` permission
- Bulk export operations logged with full detail (who exported what, how many records, when)
- Data deletion is soft-delete only (archived_at) — hard deletion requires super admin + audit entry
- Database backups encrypted and stored in MinIO with 30-day retention
- No client PII in log files (structured logger configured to redact sensitive fields)
### Per-Layer Security Checklist
Every layer's implementation plan (from both Codex and Claude Code) must address:
- [ ] **Layer 0:** TLS configuration, auth hardening, session security, CSRF protection, security headers, Docker network isolation, secrets management, encrypted backup pipeline, audit log middleware
- [ ] **Layer 1:** Input validation on all CRUD endpoints, object-level authorization (entity belongs to port), audit logging for all mutations, XSS prevention in notes/tags, rate limiting on public API
- [ ] **Layer 2:** Documenso webhook signature verification, email credential encryption/decryption, file upload validation and sanitization, receipt image processing isolation, presigned URL expiry, EOI document integrity (no tampering after generation)
- [ ] **Layer 3:** Email content sanitization (inbound HTML emails), reminder permission checks, Google Calendar OAuth token encryption, notification delivery authorization (no sending notifications to wrong users), search result scoping (never return cross-port data), dashboard data scoped to port
- [ ] **Layer 4:** Webhook payload signing (HMAC-SHA256), scheduled report delivery authorization, template merge field injection prevention (no executing code via merge fields), import file validation (CSV/Excel injection prevention), custom field value sanitization
- [ ] **Layer 5:** Security audit — attempt to break every endpoint: auth bypass, privilege escalation, cross-port data leaks, SQL injection, XSS, CSRF, IDOR (insecure direct object reference), rate limit bypass. Fix everything found.
## Layer 0: Foundation
**Duration:** 4 days
**Parallelism:** None — this is the shared base that everything builds on
**Must be complete before Layer 1 begins**
### 0.1 Project Scaffolding (Day 1)
- Initialize Next.js 15 (App Router) project with TypeScript strict mode
- Configure Tailwind CSS with Port Nimara design tokens from `15-DESIGN-TOKENS.md` (brand colors, semantic tokens, typography scale, spacing system)
- Set up shadcn/ui component library
- Docker Compose: `crm-app`, `postgres`, `redis`, `minio`, `documenso`, `nginx`
- Environment variable structure (.env.example with all service connections)
- ESLint + Prettier configuration
- Gitea CI/CD pipeline configuration (build + test stages)
- Project directory structure:
```
src/
├── app/ # Next.js App Router pages
├── components/ # Shared UI components
│ ├── ui/ # shadcn/ui base components
│ └── domain/ # Business-specific components
├── lib/ # Utilities, helpers, constants
│ ├── db/ # Drizzle schema + connection
│ ├── auth/ # Better Auth config
│ ├── services/ # Business logic services
│ ├── queue/ # BullMQ job definitions
│ └── socket/ # Socket.io setup
├── hooks/ # Custom React hooks
└── types/ # TypeScript type definitions
```
### 0.2 Database Layer (Days 12)
- PostgreSQL container with named volume
- Drizzle ORM configuration (connection, config file, migration directory)
- **All 49 table schemas** defined in Drizzle (matching 07-DATABASE-SCHEMA.md exactly)
- Run initial migration to create all tables
- Seed data: default super admin user, default global roles (super_admin, director, sales, readonly), default port (Port Nimara)
- Database utility functions: transaction wrapper, soft-delete helper, port-scoped query builder
### 0.3 Authentication (Day 2)
- Better Auth integration (installed, configured with PostgreSQL adapter)
- Login page (email + password form, CRM-styled — not a redirect)
- Session management (httpOnly cookies, CSRF protection)
- Password set flow (admin creates user → email sent → user sets password)
- Password reset flow (request → email → reset page)
- Auth middleware for API routes (extract session, validate, attach user context)
- Multi-port middleware (extract `X-Port-Id` header, validate user has access, attach port context)
- Super admin bypass (can access all ports)
### 0.4 Infrastructure (Day 3)
- Redis connection (shared across BullMQ, Socket.io adapter, session cache)
- Socket.io server alongside Next.js (port-scoped rooms, user rooms)
- Socket.io client provider (React context, auto-connect on auth, auto-join port room)
- BullMQ queue definitions (10 queues as defined in 11-REALTIME-AND-BACKGROUND-JOBS.md)
- BullMQ worker scaffold (one worker per queue, empty processors to be filled per feature)
- MinIO client initialization (env-based credentials)
- Structured logger (pino) with request ID tracking
- Error handling middleware (consistent error response shape)
### 0.5 Layout Shell (Days 34)
- App layout with sidebar navigation
- Sidebar sections: Dashboard, Clients, Interests, Berths, Expenses, Invoices, Files, Email, Reminders, Admin
- Port switcher component (hidden in single-port mode; shown only when 2+ active ports exist and user has multi-port access)
- Top bar with: user avatar, notification bell (placeholder), search trigger (placeholder), dark mode toggle (placeholder)
- Mobile-responsive nav (hamburger → slide-out sidebar)
- TanStack Query provider with default configuration
- Zustand store for UI state (sidebar collapsed, current port, user preferences)
- Breadcrumb component
- Loading states (skeleton screens)
- Empty states (consistent "no data" patterns)
- Toast notification system
- Confirmation dialog component (used for destructive actions)
**Layer 0 Deliverable:** A running Next.js app with auth, an empty database with all tables, a working layout, and all infrastructure connected. You can log in, see the sidebar, switch ports, and navigate to empty pages.
---
## Layer 1: Core CRUD
**Duration:** 5 days
**Parallelism:** 3 worktrees (Streams A, B, C)
**Depends on:** Layer 0 complete
### Stream A: Client Management (5 days)
**Priority:** HIGH — clients are the anchor entity. Interests and everything else reference clients.
Build order:
1. **Client list page** — paginated table with search, sort, filter by name/nationality/source/archived status
2. **Client detail page** — full record view with all fields, edit inline
3. **Client create/edit forms** — Zod-validated, handles proxy/representative fields
4. **Multi-contact management** — add/edit/remove contact entries (email, phone, WhatsApp, other) with labels and primary flag
5. **Client relationships** — link clients to each other (referral, broker, family, same vessel)
6. **Client notes** — timestamped notes with @mention support, 15-min edit window, then lock
7. **Client tags** — assign/remove color-coded tags, tag filter on list view
8. **Duplicate detection** — on create: same-email auto-merge, fuzzy name/phone match alert with merge UI
9. **Client merge** — select master record, merge contacts/notes/relationships/interests, log to merge_log
10. **Activity timeline** — aggregated feed of all actions related to a client (from audit log)
11. **Audit trail tab** — per-client audit log viewer
**API endpoints:** All 22 client endpoints from 08-API-ENDPOINT-CATALOG.md
**Socket.io events:** `client:created`, `client:updated`, `client:archived`, `client:merged`
**Audit logging:** Every create/update/delete/merge writes to audit_logs
### Stream B: Berth Management (5 days)
**Priority:** HIGH — berths are the product being sold. Website berth map depends on public API.
Build order:
1. **Berth list page** — table with filter by area, status, size range, price range
2. **Berth detail page** — full spec sheet view with all 30+ fields
3. **Berth create/edit forms** — Zod-validated, dual unit display (imperial + metric)
4. **Berth map visualization** — SVG-based interactive map using berth_map_data (click berth → detail)
5. **Berth map data management** — admin can update SVG coordinates per berth
6. **Berth tags** — assign/remove tags, filter on list
7. **Berth comparison view** — select 2-3 berths, side-by-side spec comparison, export as PDF
8. **Berth tenure tracking** — permanent vs fixed-term fields, expiry date display, tenure status indicators
9. **Berth maintenance log** — CRUD for maintenance/repair/inspection entries with photo upload
10. **Berth gallery** — photo gallery per berth from maintenance log photos and uploaded images
11. **Public API** — `GET /api/public/berths` and `GET /api/public/berths/:id` (no auth, CORS, rate limited)
12. ~~**Berth availability calendar**~~ — **CUT FROM V1** (tenure data and expiry checks remain; only the Gantt-style UI is deferred to post-V1)
**API endpoints:** All 22 berth endpoints + 2 public endpoints
**Socket.io events:** `berth:statusChanged`, `berth:updated`, `berth:maintenanceAdded`
**Audit logging:** All berth mutations
### Stream C: Auth Admin (5 days)
**Priority:** HIGH — admin must be able to manage users and roles before the team can use the system.
Build order:
1. **User list page** — all users with status, last login, assigned ports/roles
2. **User create flow** — admin enters name + email → system creates account → sends "set password" email via Poste.io
3. **User edit** — update details, deactivate/reactivate
4. **User port assignment** — assign user to port(s) with role(s), remove assignments
5. **Role list page** — all roles with permission summary
6. **Role builder** — create/edit roles with granular permission toggles (matching 10-AUTH-AND-PERMISSIONS.md permission structure)
7. **Port role overrides** — customize a global role for a specific port
8. **Port management** — create/edit/deactivate ports, port settings (name, slug, branding, currency, timezone)
9. **Permission enforcement** — middleware checks on every API route (matching route protection map from doc 10)
10. **Permission-aware UI** — hide/disable UI elements based on user's effective permissions at current port
11. **System settings page** — key-value settings management (email config, Documenso connection, MinIO connection, backup settings)
**API endpoints:** Admin user (6), admin roles (9), admin ports (6), admin system (9) = 30 endpoints
**Critical dependency:** Poste.io SMTP must be configured for "set password" emails
**Layer 1 Deliverable:** You can manage clients (full CRUD with contacts, notes, tags, duplicate detection, merge), manage berths (full spec sheets, map, comparison, maintenance), manage users/roles/ports, and the public berth API works for the website.
---
## Layer 2: Business Workflows
**Duration:** 5 days
**Parallelism:** 4 worktrees (Streams A, B, C, D)
**Depends on:** Layer 1 Streams A + B complete (clients and berths must exist)
### Stream A: Interest Management (5 days)
**Priority:** CRITICAL — this is the core sales pipeline. The team tracks every prospect through interests.
Build order:
1. **Interest list page** — table with filter by pipeline stage, lead category, berth, client, date range
2. **Interest detail page** — full record view with pipeline stage, berth link, EOI status, milestones
3. **Interest create** — select client (or create inline), optional berth link, set initial stage
4. **Interest edit** — update all fields, manual stage override (system never forces workflow)
5. **Pipeline board view** — Kanban-style drag-and-drop across 8 pipeline stages
6. **Berth linking** — link interest to berth → configurable status rules engine fires (suggest/auto/off per BR-001). Default: suggest "Change to Under Offer?"
7. **Berth unlinking** — configurable rules engine fires (suggest/auto/off). Default: suggest "Reset to Available?" if no other active interests linked
8. **Interest notes** — timestamped notes with @mentions, edit window
9. **Interest tags** — assign/remove tags
10. **Interest milestones** — display and manual edit of all date fields (first contact, EOI sent, signed, etc.)
11. **Berth recommendation engine** — input vessel dimensions → rank berths by fit, power, access, price (BR-050)
12. **Recommendation display** — show ranked berth suggestions with match scores on interest detail
13. **Waiting list** — per-berth queue with position, priority, notification preferences (BR-060 through BR-063)
14. **Lead category management** — manual + auto-classification (general, specific qualified, hot lead)
15. **Public interest registration** — `POST /api/public/interests` (website form → auto-create interest, trigger notifications)
**API endpoints:** All 19 interest endpoints + 1 public endpoint
**Socket.io events:** `interest:created`, `interest:updated`, `interest:stageChanged`, `interest:recommendationsGenerated`, `interest:leadCategoryChanged`
**Business rules:** BR-001 through BR-013, BR-050 through BR-063
### Stream B: EOI & Document Signing (5 days)
**Priority:** CRITICAL — EOI generation and signing is a daily workflow.
Build order:
1. **Document list page** — all documents filterable by type, status, client, interest
2. **Document detail page** — status, signers, signature events timeline
3. **EOI generation** — from interest: validate required data (client name, email, yacht, linked berth) → generate PDF via @pdfme → create Documenso document → assign 3 signers → store signing URLs
4. **Documenso webhook receiver** — `POST /api/webhooks/documenso` with deduplication (signature hash), process DOCUMENT_SIGNED and DOCUMENT_COMPLETED events
5. **Signature status tracking** — real-time updates via Socket.io when webhook fires
6. **Signed PDF retrieval** — on DOCUMENT_COMPLETED: download from Documenso, store in MinIO, email all parties
7. **Manual document upload** — bypass Documenso, upload signed doc directly → update statuses
8. **Signing reminders** — configurable reminder schedule, time-gated (morning/afternoon), per-interest toggle
9. **Signing progress UI** — embedded signing URLs, status per signer, resend capability
10. **Document events log** — created, sent, viewed, signed, completed, expired, reminder_sent
11. **Milestone auto-population** — BR-133: EOI sent → auto-set date_eoi_sent, completed → date_eoi_signed, same for contracts
**API endpoints:** All 12 document endpoints + Documenso webhook
**Socket.io events:** `document:sent`, `document:signed`, `document:completed`
**BullMQ jobs:** Signature polling fallback (every 6 hours — rare safety net, webhooks are primary), EOI reminder check (every 10 min)
### Stream C: Expenses & Invoices (5 days)
**Priority:** HIGH — expense tracking and invoice generation used daily.
Build order:
1. **Expense list page** — filterable by date range, payer, category, payment status, currency
2. **Expense create/edit** — all fields, receipt image upload to MinIO
3. **Receipt scanner (standalone PWA)** — `/scan` route with PWA manifest (`display: "standalone"`), service worker (offline queueing via IndexedDB), minimal UI (no sidebar/nav chrome): capture photo → AI extraction (establishment, amount, currency, date, items) → review → save. `/expenses/scan` redirects to `/scan`.
4. **Multi-currency handling** — store original currency + USD conversion, exchange rate from Frankfurter API
5. **Expense payment tracking** — status, date, method, reference, notes
6. **Expense export** — CSV export, PDF export with receipt images, parent company export (EUR subtotal + 5% fee)
7. **Invoice list page** — filterable by status, date, client
8. **Invoice creation** — select expenses → generate invoice with auto-number (INV-YYYYMM-###), line items, payment terms
9. **Invoice detail/edit** — full view with line items, discount, fees, total calculation
10. **Invoice PDF generation** — @pdfme styled PDF, stored in MinIO
11. **Invoice payment tracking** — status updates, payment recording
12. **Invoice send** — email invoice PDF to billing email via Poste.io
**API endpoints:** Expenses (9) + Invoices (12) = 21 endpoints
**BullMQ jobs:** Currency refresh (every 6 hours)
### Stream D: File Management (5 days)
**Priority:** HIGH — files are attached to everything (clients, expenses, EOIs).
Build order:
1. **File browser page** — MinIO-backed, organized by client folders
2. **File upload** — drag-and-drop + click upload, progress indicator, auto-categorize by context
3. **File download** — presigned MinIO URLs
4. **File preview** — inline preview for images and PDFs
5. **File rename/delete** — with confirmation dialogs
6. **Folder management** — create/rename folders, move files between folders
7. **Client file tab** — on client detail page, show all files for this client
8. **File categories** — eoi, contract, image, receipt, correspondence, misc
9. **File audit logging** — all upload/download/delete operations logged
10. **Socket.io events** — `file:uploaded`, `file:deleted` for real-time updates
**API endpoints:** All 10 file endpoints
**Layer 2 Deliverable:** Full sales pipeline (interests with berth linking, EOI generation, Documenso signing, milestone tracking), expense management with receipt scanner, invoice generation, and file management. This replicates the core of the current system.
---
## Layer 3: Operations & Features
**Duration:** 4 days
**Parallelism:** 4 worktrees
**Depends on:** Layer 2 complete
### Stream A: Email System (4 days)
Build order:
1. **Email account setup** — connect SMTP/IMAP with provider presets (Google, Outlook, custom), encrypted credential storage
2. **IMAP sync** — background job to fetch and store email threads linked to clients (by email address matching)
3. **Email thread viewer** — conversation view on client detail page
4. **Email composer** — TipTap rich text editor (full toolbar + merge fields), send via user's SMTP, auto-link to client thread
5. **Email templates** — MJML template files for system emails (password set/reset, EOI notifications, follow-up reminders, invoice)
**API endpoints:** All 9 email endpoints
**BullMQ jobs:** Email sync (configurable interval)
### Stream B: Reminders, Calendar & Notifications (4 days)
Build order:
1. **Reminder CRUD** — create, edit, complete, snooze, dismiss reminders linked to client/interest/berth
2. **Reminder list page** — filterable by assignee, status, priority, due date, entity. Unified view with Google Calendar events.
3. **Reminder overdue check** — BullMQ job checks for overdue reminders, creates notifications
4. **Follow-up auto-reminder** — BullMQ hourly job checks interest inactivity → creates auto-generated reminders
5. **Google Calendar OAuth** — connect/disconnect flow, calendar list selection, encrypted token storage
6. **Google Calendar sync** — BullMQ background poll (every 30 min) + event-driven sync on login and navigation to calendar-displaying pages (if stale > 5 min). Fetches events, upserts cache, detects CRM-pushed event changes
7. **Push to Calendar** — on reminder create/update with sync toggle, create/update Google Calendar event
8. **Notification center** — bell icon dropdown with unread count, mark as read, link to entity
9. **Notification preferences** — per-user, per-type toggles (in-app, email)
10. **Email notification delivery** — BullMQ sends notification emails via Poste.io
11. **Real-time notifications** — Socket.io pushes new notifications and calendar sync updates instantly
**API endpoints:** Reminders (11) + Google Calendar (7) + Notifications (6) = 24 endpoints
**BullMQ jobs:** Reminder check (hourly), reminder overdue check (every 15 min), calendar sync (every 30 min + event-driven on login/navigation), invoice overdue check (daily), tenure expiry check (daily)
### Stream C: Search & Views (4 days)
Build order:
1. **Global search** — search across clients, interests, berths, expenses, invoices — results grouped by entity type
2. **Search UI** — Cmd+K trigger, recent searches, quick-jump links
3. **Saved filters/views** — on any list page: configure filters → save as named view → reuse
4. **Shared views** — views can be shared with other users
5. **Bulk operations** — select multiple records → bulk status change, tag assignment, export, delete
6. **Bulk job processing** — BullMQ handles bulk ops with progress indicator
**API endpoints:** Search (2) + Saved Views (4) + Bulk (4) = 10 endpoints
### Stream D: Dashboard & Analytics (4 days)
Build order:
1. **Dashboard page** — port-scoped overview landing page
2. **Pipeline summary widget** — count and value per pipeline stage, bar/funnel chart
3. **Recent activity feed** — latest actions across all entities
4. **Berth occupancy widget** — available/under offer/sold breakdown, visual map summary
5. **Expense summary widget** — month-over-month, by category, by payer
6. **Revenue forecast** — projected based on pipeline stages with weighted probabilities
7. **Upcoming reminders + calendar widget** — CRM reminders and Google Calendar events in a unified list, tenure expirations, invoice due dates
8. **Overdue items widget** — overdue reminders, overdue invoices, expired EOIs
**API endpoints:** Dashboard (5) endpoints
**Layer 3 Deliverable:** Email integration, CRM reminders with Google Calendar integration, notification system, global search, saved views, bulk operations, and a full analytics dashboard. The system is now feature-complete for daily use.
---
## Layer 4: Advanced Features & Polish
**Duration:** 4 days
**Parallelism:** 5 worktrees
**Depends on:** Layer 3 complete
### Stream A: Webhooks & Scheduled Reports (4 days)
1. Webhook CRUD (admin: create, edit, delete, enable/disable)
2. Webhook delivery engine (BullMQ, HMAC signing, retry logic, dead letter)
3. Webhook delivery log viewer
4. Scheduled report configuration (admin: create schedule, select type, add recipients)
5. Report generation engine (BullMQ, PDF output, email delivery)
6. Report types: pipeline summary, expense summary, berth occupancy, activity log, overdue items, revenue forecast
### Stream B: Document Templates & PDF Export (4 days)
1. Template CRUD (admin: create/edit/delete templates with TipTap rich text editor)
2. Merge field insertion UI (dropdown to pick field, inserts `{{token}}`)
3. Template generation (select template + context → resolve merge fields → generate PDF)
4. Generated document delivery (download, email, sign via Documenso, store in client files)
5. Record PDF export — client summary, berth spec sheet, interest summary (branded with port logo/colors)
### Stream C: AI Features (4 days)
1. Berth spec sheet import (upload PDF/Excel → AI parsing → preview → confirm → save)
2. Import job tracking (background processing with status updates)
3. S3 file migration tool (scan existing MinIO files → AI-assisted client association → preview → execute)
4. Recommendation engine refinement (additional ranking factors, manual recommendation support)
### Stream D: Data Import/Export & Custom Fields (4 days)
1. CSV/Excel import for clients, interests, berths, expenses (column mapping UI, validation, duplicate detection)
2. Import history log
3. Entity list export (CSV/Excel with configurable columns)
4. Custom field definitions (admin: create per entity type — text, number, date, boolean, select)
5. Custom field rendering on entity forms and detail pages
6. Custom fields in exports
### Stream E: UX Polish (4 days)
1. Dark mode (Tailwind dark classes, toggle in settings, persisted per user)
2. Mobile-responsive refinement (all pages usable on phone/tablet, touch targets)
3. Quick notes/scratchpad (personal notes, drag onto client record)
4. Archiving (archive button on all entity types, archive view in each section)
5. Multi-currency price book (berth prices in USD/EUR/GBP/ECD, auto-convert)
6. Tags system polish (color picker, rename, delete with cascade)
7. Berth availability/tenure calendar (timeline view of tenure periods)
8. Audit export for parent company (bundled report: expenses + receipts + revenue + occupancy)
9. Onboarding wizard for new ports (step-by-step setup flow)
**Layer 4 Deliverable:** All ~82 features implemented.
---
## Layer 5: Testing & Hardening
**Duration:** 4 days (overlaps with Layer 4, starts Day 25)
**Parallelism:** 2 worktrees
### Stream A: Testing (4 days)
1. Vitest setup with test utilities (mock auth, mock port context, test database)
2. Unit tests for all business rule functions (BR-001 through BR-152)
3. Unit tests for permission resolution algorithm
4. API integration tests for critical workflows (create interest → link berth → generate EOI → receive webhook → complete)
5. Playwright E2E setup
6. E2E tests for: login flow, client CRUD, interest pipeline, EOI signing, expense creation, invoice generation
7. CI pipeline integration (tests run on every push)
### Stream B: Security & Performance (4 days)
1. Rate limiting on all public endpoints + auth endpoints (Redis-backed)
2. CSP headers, CORS configuration, security headers via nginx
3. Input sanitization audit (all user inputs pass through Zod, no raw SQL, no XSS)
4. Audit logging final pass (verify every entity type + every action type writes to audit_logs)
5. Query performance audit (N+1 detection, add missing indexes, optimize slow queries)
6. Socket.io connection limits and authentication
7. BullMQ job monitoring (dead letter queue alerting, queue size alerting)
8. System monitoring dashboard (admin: health checks for all services, job dashboard)
---
## Layer 6: Migration & Cutover
**Duration:** 2 days (weekend)
**Parallelism:** None — sequential, careful execution
### Pre-Migration (Week Before)
- Run migration script against a TEST copy of NocoDB data
- Verify all data transforms correctly
- Test the full cutover process end-to-end in staging
- Prepare rollback plan (keep NocoDB running, DNS switch back if needed)
### Migration Weekend
**Friday evening:**
1. Announce maintenance window to team
2. Set current CRM to read-only (if possible) or note the freeze time
3. Take final NocoDB data snapshot
**Saturday morning:** 4. Run NocoDB → PostgreSQL migration script:
- Export all Interests (with all 60+ fields) → transform to `clients` + `client_contacts` + `interests` + `interest_notes`
- Export all Berths → transform to `berths` + `berth_map_data`
- Export all Expenses → transform to `expenses`
- Export all Invoices → transform to `invoices` + `invoice_line_items` + `invoice_expenses`
- Export Audit Logs → transform to `audit_logs`
- Export Reminder/Alert Settings → transform to `system_settings`
5. Run MinIO file reorganization (AI-assisted: scan files → associate with clients → move to new folder structure)
6. Verify data counts match (record counts per entity, spot-check key records)
**Saturday afternoon:** 7. Create user accounts in new system (Better Auth) 8. Assign users to Port Nimara with roles 9. Send "set password" emails to all users 10. Configure Poste.io connection 11. Verify Documenso connection 12. Verify MinIO connection
**Sunday:** 13. Full smoke test — walk through every major workflow: - Log in → navigate dashboard → view client list → open client → check contacts, notes, files - Open interest → check pipeline stage → check linked berth → verify EOI docs - Create test expense → generate test invoice → verify PDF - Search for a client → verify results - Check berth map → verify public API 14. Update website to point at new public API (`GET /api/public/berths`) 15. Update DNS/nginx to point at new CRM app 16. Decommission NocoDB (keep backup, don't delete for 30 days)
**Monday:** 17. Team logs in, sets passwords, starts using new system 18. Matt + dev available for rapid hotfixes if issues found 19. Monitor system alerts, check for errors
---
## Critical Path
The critical path (longest sequential chain determining minimum build time) is:
```
Layer 0 Foundation (4 days)
→ Layer 1 Stream A: Clients (5 days)
→ Layer 2 Stream A: Interests (5 days) [depends on clients + berths]
→ Layer 3 Stream D: Dashboard (4 days) [depends on all entities]
→ Layer 4 any stream (4 days)
→ Layer 5 testing (4 days)
= 26 days minimum + 2 day migration = 28 days
```
This fits within 1 month. The parallel worktrees compress work that would otherwise be sequential — without parallelism, this would take 3+ months.
---
## Risk Mitigations
| Risk | Mitigation |
| -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| Layer 0 takes longer than 4 days | Database schema is pre-defined (49 tables). Auth and layout are well-documented. Minimal design decisions left. |
| Worktree merge conflicts | Each stream touches different files/directories. Merge at layer boundaries, not mid-stream. |
| Documenso integration breaks | Test against existing Documenso instance early (Day 15). Keep manual upload fallback. |
| NocoDB data is messier than expected | Run migration script on test copy during Week 3 (before migration weekend). Fix transforms early. |
| Team can't adapt to new system | UI mirrors current system workflows. Same pipeline stages, same EOI flow, same expense structure. |
| Performance issues at scale | Data volume is small (hundreds of berths, thousands of interests). Optimize only if needed. |
| AI features (spec import, recommendations) prove complex | These are in Layer 4. If they slip, everything else already works. Can be post-launch additions. |
---
## Competing Medium-Level Plans (Codex vs Claude Code)
For each Layer and Stream in this document, **both Codex and Claude Code independently produce a medium-level implementation plan**. Matt reviews both proposals, selects the best approach, or merges the best parts of each. This ensures:
- Two different "perspectives" on how to approach each problem
- Codex may propose more structured/formal patterns, Claude Code may propose more pragmatic/battle-tested patterns
- Blind spots from one planner are caught by the other
- Matt gets a real choice, not a rubber stamp
**ALL plans must be produced and approved BEFORE any development begins.**
### What Each Plan Must Cover
Both Codex and Claude Code produce plans with these sections:
1. **Approach** — how to tackle the stream: build order within the stream, key integration points, architectural patterns chosen, and why
2. **Data flow** — for each major feature: user action → API → service → database → real-time event → UI update
3. **Security implementation** — how this stream implements the security requirements below: input validation approach, authorization checks, data protection, audit logging, and any stream-specific threats
4. **Edge cases & failure modes** — race conditions, validation quirks, service integration gotchas (Documenso API behavior, MinIO presigned URL expiry, IMAP connection failures, etc.), and how each is handled
5. **Cross-stream integration points** — where this stream's code touches another stream's code, what interfaces/contracts must be respected, what shared utilities are consumed or produced
6. **Acceptance criteria** — what "done" looks like for each feature, including security verification
7. **Merge strategy** — how this stream's worktree merges back to main, in what order relative to other streams, and what integration tests to run post-merge
### Review Process
1. Codex produces its plan for a Layer/Stream
2. Claude Code produces its competing plan for the same Layer/Stream (independently — not having seen Codex's plan)
3. Matt reviews both side-by-side
4. Matt selects one, or tells Claude Code / Codex to merge specific parts
5. Final approved plan becomes the implementation spec
6. Only then does Claude Code begin building
---
## Client Portal
**Status:** Built last, deprioritized. If time permits within the month, implement basic client portal. If not, it ships in a fast follow-up (not a separate "v2" — just a deferred feature).
Build:
1. Separate auth flow (client accounts created from CRM admin)
2. Client dashboard (their berths, outstanding documents, invoice history, uploaded files)
3. Document signing (embedded Documenso view)
4. Form submissions (data collection forms from CRM)
5. File upload (requested by salesperson)
---
## Appendix: Feature-to-Layer Mapping
Every feature from 06-MASTER-FEATURE-SPEC.md mapped to its implementation layer:
| Feature Spec Section | Layer | Stream |
| ------------------------------ | --------------------------------- | ------ |
| 1. Client Management | L1 | A |
| 2. Interest Management | L2 | A |
| 3. Berth Management | L1 | B |
| 4.14.5 EOI & Document Signing | L2 | B |
| 4.6 Document Templates | L4 | B |
| 4.7 Record PDF Export | L4 | B |
| 5. Expenses & Invoicing | L2 | C |
| 6. File Management | L2 | D |
| 7. Email System | L3 | A |
| 8. Dashboard & Analytics | L3 | D |
| 9. Reminders & Calendar | L3 | B |
| 10. Notification Center | L3 | B |
| 11.1 Global Search | L3 | C |
| 11.2 Saved Filters | L3 | C |
| 11.3 Bulk Operations | L3 | C |
| 12. Admin Panel | L1 | C |
| 13. Audit System | L0 (middleware) + L5 (final pass) | |
| 14. Multi-Port Tenancy | L0 | — |
| 15. Auth & Authorization | L0 + L1C | — |
| 16. Public API | L1 | B |
| 17.1 Berth Spec Import | L4 | C |
| 17.2 Receipt Scanner | L2 | C |
| 17.3 Recommendation Engine | L2 | A |
| 17.4 S3 File Migration | L4 | C |
| 18. Data Import/Export | L4 | D |
| 19. Webhooks | L4 | A |
| 20. Scheduled Reports | L4 | A |
| 21. System Monitoring | L5 | B |
| 22. Client Portal | L4+ (deprioritized) | — |
| 23.1 Mobile Responsive | L4 | E |
| 23.2 Dark Mode | L4 | E |
| 23.3 Quick Notes | L4 | E |
| 23.4 Tags | L1 | A+B |
| 23.5 Archiving | L4 | E |
| 23.6 Multi-Currency | L4 | E |
| 23.7 Audit Export | L4 | E |

762
13-UI-PAGE-MAP.md Normal file
View File

@@ -0,0 +1,762 @@
# Port Nimara CRM — UI Page Map & Navigation Structure
**Compiled:** 2026-03-11
**Framework:** Next.js 15 App Router
**Component Library:** shadcn/ui + Tailwind CSS
**Routing:** File-based via `src/app/` directory
---
## Navigation Architecture
### Global Shell (persistent on all authenticated pages)
```
┌─────────────────────────────────────────────────────────────┐
│ [Logo/Port Name] [Port Switcher ▾] [⌘K Search] [🔔 3] [👤 Matt ▾] │
├──────────┬──────────────────────────────────────────────────┤
│ Sidebar │ │
│ │ Page Content │
│ Dashboard│ │
│ Clients │ │
│ Interests│ │
│ Berths │ │
│ Expenses │ │
│ Invoices │ │
│ Files │ │
│ Email │ │
│ Reminders│ │
│ ──────── │ │
│ Admin ▾ │ │
│ │ │
│ [Collapse│ │
│ Toggle] │ │
└──────────┴──────────────────────────────────────────────────┘
```
**Top bar components:**
- Port name + logo (from port settings branding)
- Port switcher dropdown (hidden entirely in single-port mode; visible only when 2+ active ports exist and user has access to multiple ports)
- Global search trigger (Cmd+K / Ctrl+K → modal)
- Notification bell with unread count badge → dropdown panel
- User avatar + name → dropdown: Profile, Dark Mode toggle, Scratchpad, Logout
**Sidebar:**
- Collapsible (persisted in user prefs via Zustand)
- Collapsed state shows icons only with tooltips
- Active page highlighted
- Admin section expandable submenu (only visible to users with any admin permission)
- Mobile: hamburger icon → slide-out drawer
---
## Route Map
### Auth Pages (unauthenticated — no shell)
| Route | Page | Description |
| ---------------------- | -------------- | ---------------------------------------------------------------------------------------------------- |
| `/login` | Login | Email + password form, "Forgot password?" link, CRM-styled (maritime branding, port logo) |
| `/auth/set-password` | Set Password | Token-validated form: new password + confirm. Used for first-time login after admin creates account. |
| `/auth/reset-password` | Reset Password | Token-validated form: new password + confirm. Used from "forgot password" email link. |
**Layout:** Centered card on full-screen branded background. No sidebar, no top bar.
---
### User Settings (accessible from user avatar menu)
| Route | Page | Description |
| ------------------------- | ------------------------ | --------------------------------------------------- |
| `/settings/profile` | My Profile | Edit own name, avatar, timezone, preferred language |
| `/settings/notifications` | Notification Preferences | Per-notification-type delivery channel toggles |
#### `/settings/notifications` — Notification Preferences
- **Table:** Notification type (rows) × delivery channel (columns: In-App, Email)
- **Notification types:** Reminder due, reminder overdue, new website registration, EOI signature event, new email received, duplicate client alert, invoice overdue, waiting list notification, system alert, follow-up auto-created, tenure expiring
- **Per-type toggles:** In-app (always on, read-only), Email (toggle on/off)
- **Save button** applies changes
---
### Dashboard
| Route | Page | Description |
| ----- | --------- | ---------------------------------- |
| `/` | Dashboard | Home screen — port-scoped overview |
**Page contents:**
- **Pipeline funnel widget** — interest count and weighted value per pipeline stage (bar or funnel chart). Click a stage → navigates to `/interests?stage={stage}`
- **Berth occupancy widget** — available / under offer / sold breakdown (donut chart). Click a status → navigates to `/berths?status={status}`
- **Revenue forecast widget** — weighted pipeline value with best/likely/worst scenarios, trend line over time
- **Expense summary widget** — month-over-month spend by category (bar chart)
- **Recent activity feed** — last 10 actions across all entities (from audit log). Each entry links to the relevant record.
- **Upcoming reminders & events** — unified list of next 5 CRM reminders + upcoming Google Calendar events (next 7 days). CRM reminders show linked entity; Calendar events show a Google Calendar badge. Link to `/reminders` for full view.
- **Overdue items panel** — unsigned EOIs, overdue invoices, overdue reminders, expiring tenures. Each item links to its record.
- **Timeline strip** — upcoming key dates: reminder due dates, tenure expirations, invoice due dates, Google Calendar events (horizontal scrollable timeline at top or bottom)
**Permission:** `reports.view_dashboard` (all roles by default)
---
### Client Management
| Route | Page | Description |
| -------------------- | ------------- | --------------------------------------------------- |
| `/clients` | Client List | Paginated table of all clients |
| `/clients/new` | Create Client | New client form |
| `/clients/[id]` | Client Detail | Full client record with tabbed sections |
| `/clients/[id]/edit` | Edit Client | Edit client fields (could also be inline on detail) |
#### `/clients` — Client List
- **Table columns:** Name, company, nationality, source, tags, # interests, last activity, created date
- **Filters:** Search (name/email/phone), nationality, source, tags, archived (toggle), date range
- **Sort:** Name, created date, last activity
- **Saved views:** Dropdown of saved filter configurations, "Save current view" button, "Manage Views" link → modal: rename, delete, share/unshare saved views
- **Bulk actions toolbar** (appears when rows selected): Bulk tag, bulk export (CSV/PDF), bulk archive
- **Actions per row:** View, Edit, Archive
- **"+ New Client" button** → `/clients/new`
#### `/clients/new` — Create Client
- **Sections:**
- Basic info: Full name, company/entity (optional), nationality
- Contact info: Add multiple contacts (channel + value + label), at least one required
- Vessel details: Yacht name, length, width, draft (dual unit input), berth size desired
- Proxy/representative: Toggle, proxy type, actual owner name, relationship notes
- Communication preferences: Preferred method, language, timezone
- Source: Website / manual / referral / broker (+ referrer link)
- Tags: Tag selector (multi-select from existing tags or create new)
- **On submit:** Duplicate detection runs (same email → auto-merge, fuzzy match → alert modal)
- **Cancel** → back to `/clients`
#### `/clients/[id]` — Client Detail
- **Header area:** Client name, company, tags, status badges, quick actions (Edit, Archive, Export PDF, Merge)
- **Tabs:**
| Tab | Contents |
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Overview** | Core fields (name, company, nationality, vessel, proxy info, communication prefs, source), contact cards |
| **Relationships** | Visual/list view of related clients. Relationship types: referred by, broker for, family member, same vessel, custom. Add/remove relationships. Shows referral networks and broker portfolios. |
| **Interests** | List of all interests for this client. Single interest → inline expanded. Multiple → accordion or sub-table. Each links to `/interests/[id]`. "+ New Interest" button. |
| **Activity** | Chronological timeline feed (from audit log). Filterable by event type. |
| **Notes** | Timestamped notes thread. New note input with @mention support. Edit (within 15 min) / locked indicator. |
| **Files** | Client-scoped file browser (MinIO). Upload button, folder tree, file cards with preview/download. |
| **Emails** | Email threads linked to this client (from IMAP sync). Compose new button. |
| **Documents** | EOIs, contracts, NDAs linked through interests. Status indicators (pending, signed, completed). |
| **Invoices** | Invoices billed to this client. Status badges. Link to invoice detail. |
| **Audit Trail** | Raw audit log entries for this client and all related entities. Filterable by action type, date range. |
---
### Interest Management
| Route | Page | Description |
| ----------------- | --------------- | ------------------------------------------------- |
| `/interests` | Interest List | Table + pipeline (Kanban) views |
| `/interests/new` | Create Interest | New interest form (often initiated from a client) |
| `/interests/[id]` | Interest Detail | Full interest record |
#### `/interests` — Interest List
- **View toggle:** Table view ↔ Pipeline (Kanban) view
- **Table view:**
- Columns: Client name, berth (if linked), pipeline stage, lead category, EOI status, last activity, date created
- Filters: Stage, berth, client, lead category, source, tags, date range, assigned salesperson
- Sort: Date created, last activity, stage, client name
- **Pipeline (Kanban) view:**
- 8 columns (one per pipeline stage): Open → Details Sent → In Communication → Visited → Signed EOI and NDA → 10% Deposit → Contract → Completed
- Cards show: client name, berth, days in stage, next action indicator
- Drag-and-drop between stages (confirmation dialog for significant changes)
- Filters same as table view (applied to both views)
- **Saved views, bulk actions** same pattern as clients
#### `/interests/new` — Create Interest
- **Fields:**
- Client selector (search/select existing or "+ New Client" inline)
- Berth link (optional — search/select berth)
- Initial pipeline stage (default: Open)
- Lead category
- Source
- Notes (initial note)
- Tags
#### `/interests/[id]` — Interest Detail
- **Header:** Client name → link to client, berth → link to berth (if linked), pipeline stage badge (with manual override dropdown), lead category, EOI status badge
- **Quick actions:** Generate EOI, Link Berth, Change Stage, Export PDF, Archive
- **Sections / Tabs:**
| Tab | Contents |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Overview** | Pipeline stage (with stage history), berth details (inline if linked), vessel info (from client), milestone dates (first contact, EOI sent, signed, deposit, contract, completed), lead category, source |
| **EOI & Documents** | EOI section: generate button (if prerequisites met), signing status per signer, signing URLs, reminder controls. Documents list: all docs on this interest (EOI, contracts, NDAs) with status. Upload manual doc button. |
| **Recommendations** | Berth recommendation panel: vessel dimensions summary, "Run Recommendation" button → ranked berth list with match scores. Assign berth from recommendation. |
| **Waiting List** | If berth is linked and client is on waiting list: position, priority, notification pref. If berth has a waiting list: show queue. |
| **Notes** | Interest-specific notes thread (separate from client notes). Same @mention / edit window behavior. |
| **Activity** | Interest-specific timeline (subset of client timeline filtered to this interest). |
| **Audit Trail** | Audit log for this interest record. |
---
### Berth Management
| Route | Page | Description |
| ---------------------- | ------------------------- | ----------------------------------------------------------------- |
| `/berths` | Berth Explorer | Three-panel layout: map + list + detail |
| `/berths/new` | Create Berth | New berth form (admin/super admin) |
| `/berths/[id]` | Berth Detail | Full berth specs (also shown in right panel of explorer) |
| `/berths/compare` | Berth Comparison | Side-by-side comparison of 2-3 berths |
| ~~`/berths/calendar`~~ | ~~Availability Calendar~~ | **CUT FROM V1** — Gantt-style tenure timeline deferred to post-V1 |
#### `/berths` — Berth Explorer (Three-Panel Layout)
```
┌──────────────────────────────────────────────────────┐
│ [Interactive Berth Map] [Collapse Map ▲]│
│ SVG map color-coded: green=available, orange=under │
│ offer, red=sold. Click berth → selects in list. │
├─────────────────┬────────────────────────────────────┤
│ Smart List │ Detail Panel │
│ ───────────── │ ─────────────── │
│ Grouped by │ Full berth specs │
│ status: │ (collapsible sections): │
│ Under Offer │ - Dimensions │
│ Available │ - Infrastructure │
│ Sold │ - Commercial │
│ │ - Linked interests │
│ Filter: area, │ - Waiting list │
│ size, price │ - Maintenance log │
│ │ - Files / Gallery │
│ [+ New Berth] │ - Tenure info │
│ │ [Edit] [Compare] [Export PDF] │
└─────────────────┴────────────────────────────────────┘
```
- **Map panel:** Toggleable (collapse to maximize list/detail). SVG with berth outlines colored by status. Hover shows tooltip (mooring #, area, status, size). Click selects berth.
- **List panel:** Grouped by status (Under Offer first, then Available, then Sold). Each item: mooring number, area, nominal size, price. Search/filter by area, size range, price range, status.
- **Detail panel:** Full spec sheet. Collapsible sections for each data group. Action buttons: Edit, Compare (adds to comparison), Export PDF, View on map.
- **Waiting List section:** Ordered queue of clients with position, priority (normal/high), date added, notification preference. Actions: reorder, change priority, update notification pref, remove entry. "Add to waiting list" button (client selector).
- **Maintenance Log section:** Chronological log of maintenance/repair/inspection entries. Each entry: date, description, cost, category (routine/repair/inspection/upgrade), photos (thumbnail gallery), responsible party. "+ Add Entry" form with photo upload. Edit/delete existing entries.
#### `/berths/compare` — Berth Comparison
- Select 2-3 berths (from berth list or via URL params)
- Side-by-side table: rows are spec fields, columns are berths
- Highlights differences
- Export as PDF button (branded with port logo/colors)
#### `/berths/calendar` — Availability Calendar — **CUT FROM V1**
> **Deferred to post-V1.** Tenure data and expiry checks remain active (see BR-003 tenure expiry, `tenure-expiry-check` background job). Only the Gantt-style visualization UI is deferred.
~~- Gantt-style horizontal timeline~~
~~- Rows = berths, bars = tenure periods~~
~~- Color: active tenure (blue), expiring soon (amber), expired (red), available (green)~~
~~- Configurable time range (3 months, 6 months, 1 year, 5 years)~~
~~- Click a tenure bar → links to interest or client detail~~
~~- Flags upcoming expirations (configurable warning threshold, set in port settings)~~
~~- Legend: color key for tenure status types~~
~~- Warning threshold control: dropdown to set how far in advance expirations are flagged (1/3/6/12 months)~~
---
### Expenses
| Route | Page | Description |
| ---------------- | --------------------- | ------------------------------------------------------------------------------------------------------ |
| `/expenses` | Expense List | Paginated table of all expenses |
| `/expenses/new` | Create Expense | New expense form with receipt upload |
| `/expenses/[id]` | Expense Detail | Full expense record with receipt images |
| `/scan` | Receipt Scanner (PWA) | Standalone PWA — camera → AI extraction → save expense. Also accessible via `/expenses/scan` redirect. |
#### `/expenses` — Expense List
- **Table columns:** Date, establishment, amount (original currency + USD), category, payer, payment status, receipt indicator
- **Filters:** Date range, payer, category, payment status, currency, amount range
- **Sort:** Date, amount, establishment
- **Export controls:** CSV export, PDF export (with receipts), parent company export (EUR + 5% fee)
- **Bulk actions:** Bulk export, bulk payment status update, create invoice from selected
#### `/expenses/new` — Create Expense
- **Fields:** Establishment name, amount, currency (dropdown), date/time, category, payer, description, payment method, payment status
- **Receipt upload:** Drag-and-drop or camera capture, multiple images allowed
- **"Scan Receipt" button** → redirects to `/scan` (standalone PWA receipt scanner)
#### `/scan` — Receipt Scanner (Standalone PWA)
- **Standalone PWA:** `manifest.json` with `display: "standalone"`, custom icon ("Port Nimara Scanner"). Addable to Apple home screen / Android — opens without browser chrome.
- **Service worker:** Caches UI shell for instant load. Offline: photos queued in IndexedDB, uploaded on reconnect.
- **Easy URL:** `crm.portnimara.dev/scan` — short and bookmarkable. `/expenses/scan` redirects here.
- **Minimal UI:** No sidebar or navigation chrome. Camera viewfinder (or file upload for desktop) → Capture → AI processing spinner → Review extracted data (establishment, amount, currency, date, items) → Edit/correct → Save as expense → "Scan Another" or "Back to CRM"
- **Multi-currency indicator:** Shows detected currency + USD conversion at current rate
- **Auth:** Session-based. If not logged in, shows minimal login form then returns to scanner.
---
### Invoices
| Route | Page | Description |
| ---------------- | -------------- | ---------------------------------------------------- |
| `/invoices` | Invoice List | Paginated table of all invoices |
| `/invoices/new` | Create Invoice | Invoice builder (select expenses, configure billing) |
| `/invoices/[id]` | Invoice Detail | Full invoice with line items, payment tracking |
#### `/invoices` — Invoice List
- **Table columns:** Invoice # (INV-YYYYMM-###), client/company, total, currency, status (draft/sent/paid/overdue/cancelled), due date, created date
- **Filters:** Status, client, date range, amount range, currency
- **Status badges color-coded:** Draft (gray), Sent (blue), Paid (green), Overdue (red), Cancelled (strikethrough)
#### `/invoices/new` — Create Invoice
- **Step 1:** Select billing target (client or company, billing email, billing address)
- **Step 2:** Add line items — inline editable table: description, quantity, unit price, amount (auto-calculated). "Add Row" button for manual items. "Import from Expenses" button → modal showing un-invoiced expenses with checkboxes, select → auto-creates line items.
- **Step 3:** Configure: payment terms (Immediate/Net 10/15/30/45/60), currency, discount (% or fixed), additional fees, notes
- **Preview:** Live invoice preview as you configure
- **Save as draft** or **Save & Send**
#### `/invoices/[id]` — Invoice Detail
- **Header:** Invoice #, status badge, client/company, total, due date
- **Line items table:** Description, quantity, unit price, amount
- **Totals section:** Subtotal, discount, fees, total
- **Payment tracking:** Status, payment date, method, reference, notes. "Record Payment" button.
- **Actions:** Send (email PDF), Download PDF, Edit (if draft), Mark as Paid, Cancel
- **Linked expenses:** List of expenses included in this invoice
---
### File Management
| Route | Page | Description |
| -------- | ------------ | ---------------------------------------------- |
| `/files` | File Browser | MinIO-backed file explorer organized by client |
#### `/files` — File Browser
- **Left panel:** Folder tree (clients as top-level folders, sub-folders: eoi, contracts, images, receipts, correspondence, misc)
- **Right panel:** File grid/list for selected folder
- **File cards:** Thumbnail (images/PDFs), filename, size, uploaded date, uploaded by
- **Actions per file:** Preview (inline modal for images/PDFs), Download, Rename, Delete (confirmation), Move
- **Upload:** Drag-and-drop zone + click upload. Context-aware (if in a client's eoi folder, auto-categorizes)
- **Folder management:** Create folder, rename folder, delete folder (with contents warning)
- **Search:** Search files by name within current context
---
### Email System
| Route | Page | Description |
| ----------------- | -------------- | ----------------------------------------------- |
| `/email` | Email Inbox | Connected email threads (if mailbox configured) |
| `/email/settings` | Email Settings | IMAP/SMTP configuration |
#### `/email` — Email Inbox
- **If no mailbox configured:** Prompt to set up in `/email/settings`. Show system-sent emails log instead.
- **If configured:**
- Left panel: Thread list (newest first), search, filter by linked client
- Right panel: Thread detail (conversation view, newest at bottom)
- Compose button → TipTap rich text editor modal (recipients, subject, body, merge fields, attachments from MinIO)
- Threads auto-linked to clients by email address matching
- Unlinked threads shown in a separate "Unlinked" section
#### `/email/settings` — Email Settings
- Provider presets: Google Workspace, Outlook, Custom
- SMTP configuration: host, port, username, password (encrypted), TLS toggle
- IMAP configuration: host, port, username, password (encrypted), TLS toggle
- Test connection button
- Sync frequency setting
---
### Reminders
| Route | Page | Description |
| ------------ | -------------------- | ------------------------------------------------------ |
| `/reminders` | Reminders & Upcoming | Unified list of CRM reminders + Google Calendar events |
#### `/reminders` — Reminders & Upcoming
- **Unified list view:** CRM reminders and Google Calendar events interleaved chronologically
- Toggle: My Reminders ↔ All Reminders (requires `reminders.view_all`)
- CRM reminders: Title, linked entity (client/interest/berth), priority (color-coded), due date, assigned to, status badge
- Google Calendar events: Title, time, location (if any), calendar badge icon to distinguish from CRM reminders
- Filters: Source (CRM only / Calendar only / Both), status (pending/snoozed/completed/dismissed), priority, assignee, entity type, due date range, overdue toggle
- Sort: Due date (default), priority, created date
- **Quick create:** "+ New Reminder" → inline form or modal (title, note, due date/time, priority, assignee, linked entity, "Add to Google Calendar" toggle)
- **Reminder detail:** Click reminder → slide-out panel with full fields, edit capability, snooze/complete/dismiss buttons
- **Snooze options:** 1 hour, 4 hours, tomorrow morning, next week, custom date/time
- **Calendar connection banner:** If Google Calendar not connected, show a subtle banner: "Connect Google Calendar to see your events alongside reminders" → link to `/settings/calendar`
- **Google Calendar event detail:** Click calendar event → read-only panel showing event details, link to open in Google Calendar
### User Settings — Calendar
| Route | Page | Description |
| -------------------- | ----------------- | -------------------------------------------- |
| `/settings/calendar` | Calendar Settings | Google Calendar connection and configuration |
#### `/settings/calendar` — Calendar Settings
- **Connection status:** Connected / Not connected, with Google account email displayed
- **Connect button:** Initiates Google OAuth flow → redirects to Google consent screen → returns with token
- **Calendar selector:** Dropdown listing all user's Google Calendars (fetched via `calendarList.list`). User picks which calendar to sync with (e.g., "Business", "CRM", "Primary").
- **Sync toggle:** Enable/disable background sync
- **Last synced:** Timestamp of most recent sync
- **Manual sync:** "Sync now" button to trigger immediate pull
- **Disconnect:** Button to revoke connection and clear cached events
---
### Admin Panel
| Route | Page | Description |
| ----------------------- | ---------------------- | --------------------------------------------- |
| `/admin` | Admin Overview | Admin landing with section links |
| `/admin/users` | User Management | User list + create/edit |
| `/admin/users/new` | Create User | New user form |
| `/admin/users/[id]` | User Detail | User profile, assigned ports/roles, activity |
| `/admin/roles` | Role Management | Role list + builder |
| `/admin/roles/new` | Create Role | Role builder with permission toggles |
| `/admin/roles/[id]` | Edit Role | Edit existing role permissions |
| `/admin/ports` | Port Management | Port list + settings |
| `/admin/ports/new` | Create Port | New port setup (or onboarding wizard) |
| `/admin/ports/[id]` | Port Settings | Per-port configuration |
| `/admin/audit` | Audit Log | System-wide audit log viewer |
| `/admin/settings` | System Settings | Global system configuration |
| `/admin/webhooks` | Webhook Management | Webhook CRUD + delivery logs |
| `/admin/webhooks/new` | Create Webhook | New webhook configuration |
| `/admin/webhooks/[id]` | Webhook Detail | Edit webhook + view delivery history |
| `/admin/reports` | Scheduled Reports | Report schedule management |
| `/admin/reports/new` | Create Report Schedule | Configure new scheduled report |
| `/admin/custom-fields` | Custom Fields | Custom field definitions per entity type |
| `/admin/templates` | Document Templates | Template CRUD with TipTap rich text editor |
| `/admin/templates/new` | Create Template | New template with merge field insertion |
| `/admin/templates/[id]` | Edit Template | Edit existing template |
| `/admin/forms` | Form Management | Data collection form builder + submissions |
| `/admin/forms/new` | Create Form | Build a pre-filled data collection form |
| `/admin/forms/[id]` | Form Detail | Edit form + view submissions |
| `/admin/import` | Data Import | CSV/Excel import center + import history |
| `/admin/monitoring` | System Health | Service health dashboard + BullMQ job monitor |
| `/admin/backup` | Backup & Restore | Database backup management |
| `/admin/tags` | Tag Management | Manage tags across entity types |
| `/admin/onboarding` | Port Onboarding Wizard | Step-by-step new port setup |
#### `/admin/users` — User Management
- **Table:** Name, email, status (active/inactive), last login, ports assigned, roles
- **Actions:** Create, edit, deactivate/reactivate, reset password
- **User detail page (`/admin/users/[id]`):**
- Full profile: name, email, status, created date, last login
- Port assignments: table of assigned ports with role per port, add/remove assignments
- Active sessions: list of current sessions (device/browser, IP, last active). "Revoke" button per session, "Revoke All Sessions" button.
- Activity history: last N actions from audit log filterable by date range
#### `/admin/roles` — Role Management
- **Role list:** Name, description, system role flag, # users assigned
- **Role builder:**
- Permission grid: categories on the left (clients, interests, berths, etc.), permission flags as toggles
- Visual grouping by domain
- "Clone from" dropdown to start from an existing role
- Preview: "What can this role do?" summary
#### `/admin/ports` — Port Management
- **Port list:** Name, slug, status (active/inactive), # users, # berths
- **Port settings:**
- Basic: Name, slug, timezone, default currency
- Branding: Logo upload, primary/secondary color pickers → stored in port settings
- Operational: Follow-up reminder schedules, alert thresholds, EOI reminder settings
- Role overrides: Per-port permission tweaks for global roles
#### `/admin/audit` — Audit Log
- **Filterable table:** Timestamp, user, action (create/update/delete/archive/restore/merge), entity type, entity ID, field changed, old value → new value
- **Filters:** User, entity type, action type, date range, entity ID
- **Export:** CSV
- **Revert button** (super admin only) on individual entries
#### `/admin/settings` — System Settings
- **Sections:**
- **Berth Status Rules:** Table of trigger→mode→target status rules. Each row: Trigger description (read-only), Mode dropdown (auto/suggest/off), Target Status dropdown (available/under_offer/sold). "Reset to Defaults" button. Per-port if multi-port.
- Email: Poste.io SMTP config, test send
- Documenso: API URL, API key, test connection
- MinIO: Endpoint, access key, secret key, bucket, test connection
- Currency: Primary currency, exchange rate refresh interval, Frankfurter API status, current rates table (auto-updated + manual override per pair), "Refresh Now" button
- Backup: Schedule (cron), retention days, last backup status
- Security: Session timeout, login rate limits, password requirements
- Expiration alerts: Default warning threshold for tenure expirations (months before)
#### `/admin/webhooks` — Webhook Management
- **Webhook list:** Name, target URL, events subscribed, enabled/disabled toggle, last delivery status
- **Create/edit:** Target URL, secret key (for HMAC signing), event selector (checkboxes), enable/disable
- **"Test Webhook" button:** Sends a test payload to the target URL, shows response status/body inline
- **Delivery log per webhook:** Timestamp, event type, response status, response time, payload preview, retry count
#### `/admin/reports` — Scheduled Reports
- **Report list:** Name, type, frequency, recipients, last sent, next send, enabled toggle
- **Create/edit:** Report type (dropdown), frequency (daily/weekly/monthly/cron), enable/disable
- **Recipient selector:** Multi-select from CRM users + free-text entry for external email addresses. Shows recipient list with remove buttons.
#### `/admin/custom-fields` — Custom Fields
- **Per entity type tabs:** Clients, Interests, Berths, Expenses
- **Field list:** Name, type (text/number/date/boolean/select), required flag, sort order
- **Create field:** Name, type, options (for select type), required, default value
#### `/admin/templates` — Document Templates
- **Template list:** Name, type (welcome letter, handover checklist, etc.), last modified, active toggle
- **Template editor:**
- Rich text editor (**TipTap** — confirmed, with table + merge field extensions)
- Merge field insertion toolbar: dropdown showing available fields organized by entity (client.full_name, berth.mooring_number, etc.), click to insert `{{token}}`
- Preview with sample data
- Save / activate / deactivate
#### `/admin/forms` — Form Management
- **Form list:** Name, linked entity type, status (active/inactive), submissions count, last submitted, created date
- **Create/edit form:**
- Form name, description
- Entity type: client data collection, interest data collection, general intake
- Field selector: choose which CRM fields appear on the form, set which are pre-filled vs. editable
- Branding preview: shows form as client will see it (port logo/colors)
- Generate link: creates per-client secure token URL
- **Submissions view per form:** Table of submissions with date, client, status (pending review / applied / rejected). Click submission → diff view showing what data the client provided vs. current CRM data. "Apply" button to merge submitted data into CRM records.
#### `/admin/tags` — Tag Management
- **Entity type tabs:** Clients, Interests, Berths (each port has its own tag set)
- **Tag list per type:** Tag name, color swatch, usage count (how many records tagged)
- **Create tag:** Name input + color picker
- **Edit tag:** Rename, change color
- **Delete tag:** Confirmation with cascade warning ("This tag is used on X records. Removing it will untag all of them.")
- **Drag to reorder** (sort order for display in tag selectors)
#### `/admin/import` — Data Import
- **Import wizard:**
- Step 1: Select entity type (clients, interests, berths, expenses)
- Step 2: Upload CSV/Excel file
- Step 3: Column mapping (source columns → CRM fields, auto-mapped where names match)
- Step 4: Validation preview (valid rows, error rows, duplicate alerts)
- Step 5: Import + progress bar
- **Import history:** Past imports with record counts, errors, date, user
#### `/admin/monitoring` — System Health
- **Service health cards:** PostgreSQL, Redis, MinIO, Documenso, Poste.io, Socket.io — each with green/yellow/red indicator, last check time, response time
- **BullMQ dashboard:** Queue cards (one per queue: email, documents, reminders, calendar-sync, backups, etc.) showing pending/active/completed/failed counts. Click queue → drill-down job list with: job ID, status, data preview, created/processed timestamps. Actions per job: retry, remove. Dead letter queue section with bulk retry/clear.
- **Alert configuration:** Per-category alert rules (service down, job failure, backup failure, disk space, unusual activity). Each rule: threshold (e.g., >5 failures), time window (e.g., in 10 minutes), notification targets (super admin email + in-app). Enable/disable per rule.
- **System info:** Disk usage, memory, uptime (non-sensitive info only)
#### `/admin/backup` — Backup & Restore
- **Backup list:** Date, size, type (scheduled/manual), status
- **Manual backup trigger button**
- **Download backup** button per entry
- **Restore:** Upload backup → preview (record counts) → confirm → restore (super admin only)
- **Retention settings:** Keep last N days
---
### Global Overlays & Modals
These UI elements appear on top of any page:
| Component | Trigger | Description |
| ------------------------------ | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Global Search Modal** | Cmd+K / Ctrl+K or search icon | Full-screen modal: search input, results grouped by entity type (clients, berths, interests, invoices, expenses, documents), recent searches. Enter or click → navigates to record. |
| **Notification Panel** | Bell icon click | Slide-out right panel or dropdown: notification list ordered by recency. Each: icon, title, description, timestamp, read/unread dot, click → navigates to record. "Mark all as read" button. |
| **Scratchpad** | User menu → Scratchpad | Slide-out panel: personal notes area. Persisted per user. Notes can be dragged to a client record (opens a "move to client" modal). |
| **Confirmation Dialog** | Any destructive action | Modal: "Are you sure?" with context (what's being deleted/archived). Confirm / Cancel. |
| **Quick Note Modal** | "Add Note" button on any entity | Modal: text area with @mention support, entity context pre-filled. Save → adds to entity's notes. |
| **Duplicate Alert Modal** | On client creation (fuzzy match detected) | Modal: shows potential duplicate(s) side-by-side. Options: "Merge with existing", "Create anyway", "Cancel". |
| **Merge Preview Modal** | From client detail → Merge action | Modal: two records side-by-side, field-by-field selection of which values to keep, preview of merged result. Confirm → merge. |
| **EOI Generation Wizard** | "Generate EOI" button on interest | Multi-step modal: Step 1 verify client data → Step 2 verify berth data → Step 3 select signers → Step 4 confirm → generate. |
| **Berth Recommendation Panel** | "Run Recommendation" on interest | Panel/modal: shows vessel dimensions, ranked berth results with match scores and reasons. "Assign" button per result. |
| **Bulk Operation Progress** | After triggering bulk action | Toast/modal: progress bar, record count, success/error count. Stays until dismissed or auto-closes on completion. |
---
### Client Portal (Separate Auth Context)
| Route | Page | Description |
| ----------------------------- | -------------------- | ---------------------------------------------- |
| `/portal/login` | Portal Login | Client authentication (separate from CRM auth) |
| `/portal` | Portal Dashboard | Client's berths, pending documents, invoices |
| `/portal/documents` | My Documents | Documents awaiting signature, signed documents |
| `/portal/documents/[id]/sign` | Sign Document | Embedded Documenso signing view |
| `/portal/invoices` | My Invoices | Invoice history with status |
| `/portal/files` | My Files | Files shared by salesperson, upload area |
| `/portal/forms/[token]` | Data Collection Form | Pre-filled form for client to complete |
**Layout:** Simplified layout — no sidebar, just a top bar with portal logo and client name. Clean, focused UI for non-CRM users.
---
### Public Pages (No Authentication)
| Route | Page | Description |
| -------------------------- | ----------------- | --------------------------------------------------------- |
| `/api/public/berths` | — | JSON API endpoint (no UI) |
| `/api/public/berths/:id` | — | JSON API endpoint (no UI) |
| `/api/public/interests` | — | JSON API endpoint (POST, no UI) |
| `/api/public/forms/:token` | Public Form | Pre-filled data collection form (branded, minimal layout) |
| `/api-docs` | API Documentation | OpenAPI/Swagger UI for the public API |
---
## Page Count Summary
| Section | Page Count |
| ---------------------- | ------------------------------------- |
| Auth pages | 3 |
| User settings | 2 |
| Dashboard | 1 |
| Client management | 4 |
| Interest management | 3 |
| Berth management | 5 |
| Expenses | 4 |
| Invoices | 3 |
| File management | 1 |
| Email | 2 |
| Reminders | 1 |
| Calendar Settings | 1 |
| Admin panel | 27 (includes forms, tags detail) |
| Global overlays/modals | 10 components |
| Client portal | 7 |
| Public pages | 2 (with UI) |
| **TOTAL** | **~66 pages + 10 overlay components** |
---
## Responsive Breakpoints
| Breakpoint | Layout Adaptation |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Desktop (≥1280px)** | Full three-panel layouts (berths), sidebar expanded, all columns visible |
| **Tablet (7681279px)** | Sidebar collapsed to icons, detail panels become full-page navigation, berth map toggles to separate tab, table columns reduced |
| **Mobile (< 768px)** | Sidebar → hamburger slide-out drawer, all views single-column, receipt scanner optimized for camera, touch-friendly tap targets (min 44px), swipe gestures for pipeline cards |
---
## Navigation Flow Diagram
```
Login ──→ Dashboard
┌─────────┼──────────┬──────────┬──────────┬──────────┐
▼ ▼ ▼ ▼ ▼ ▼
Clients Interests Berths Expenses Invoices Reminders
│ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼
Detail Detail Explorer Detail Detail Upcoming List
│ │ │ │
│ ┌───┘ │ │
│ ▼ │ │
│ EOI/Signing │ │
│ Recommendations │ │
│ ▼ │
│ Compare/Calendar │
│ │
└──── Files ◄─── linked ───────┘
Email
┌────────┘
Admin Panel ──→ Users / Roles / Ports / Audit / Settings
Webhooks / Reports / Custom Fields / Templates
Import / Monitoring / Backup / Tags / Onboarding
```
**Cross-linking patterns:**
- Client detail → links to interests, files, emails, invoices, documents
- Interest detail → links to client, berth, EOI documents, recommendations
- Berth detail → links to interests, waiting list entries (which link to clients)
- Expense detail → links to invoice (if invoiced), receipt files
- Invoice detail → links to client, linked expenses
- Activity timelines → every entry links to the relevant record
- Notification items → each links to the triggering record
- Dashboard widgets → each links to the relevant list/filter view
---
## State Management Summary
| State Type | Technology | Scope |
| ---------------------------------------------- | ---------------------------------------------- | --------------------------------------------- |
| **Server state** (entities, lists) | TanStack Query | Cache + refetch on focus/stale |
| **UI state** (sidebar, modals, view prefs) | Zustand store | Per-session, persisted subset to localStorage |
| **Form state** | React Hook Form + Zod | Per-form, validated on submit |
| **Real-time updates** | Socket.io events → TanStack Query invalidation | Port-scoped rooms |
| **URL state** (filters, pagination, view mode) | URL search params (nuqs or manual) | Shareable/bookmarkable |
**Real-time update flow:**
1. User A creates a client → API saves → Socket.io emits `client:created` to port room
2. User B's browser receives event → TanStack Query cache invalidated for client list
3. User B's client list auto-refetches → new client appears without page refresh
4. If User B is on the dashboard → activity feed also refreshes
---
## Appendix: Route-to-Feature Spec Cross-Reference
| Feature Spec Section | Primary Routes |
| ----------------------------- | ---------------------------------------------------------------------------------------------- |
| 1. Client Management | `/clients`, `/clients/new`, `/clients/[id]` |
| 2. Interest Management | `/interests`, `/interests/new`, `/interests/[id]` |
| 3. Berth Management | `/berths`, `/berths/compare` (~~`/berths/calendar`~~ cut from V1) |
| 4.14.5 EOI & Signing | `/interests/[id]` (EOI tab), EOI Generation modal |
| 4.5 Data Collection Forms | `/admin/forms`, `/admin/forms/new`, `/admin/forms/[id]`, `/api/public/forms/:token` |
| 4.6 Document Templates | `/admin/templates`, `/admin/templates/new`, `/admin/templates/[id]` |
| 4.7 Record PDF Export | Export buttons on `/clients/[id]`, `/berths/[id]`, `/interests/[id]` |
| 5. Expenses & Invoicing | `/expenses`, `/expenses/new`, `/expenses/scan`, `/invoices`, `/invoices/new`, `/invoices/[id]` |
| 6. File Management | `/files`, file tabs on client detail |
| 7. Email System | `/email`, `/email/settings` |
| 8. Dashboard & Analytics | `/` |
| 9. Reminders & Calendar | `/reminders`, `/settings/calendar` |
| 10. Notification Center | Notification bell overlay |
| 11.1 Global Search | Cmd+K overlay |
| 11.2 Saved Filters | Filter controls on all list pages |
| 11.3 Bulk Operations | Bulk action toolbar on all list pages |
| 12. Admin Panel | `/admin/*` (all sub-routes) |
| 13. Audit System | `/admin/audit`, audit trail tabs on entity detail pages |
| 14. Multi-Port Tenancy | Port switcher in top bar |
| 15. Auth & Authorization | `/login`, `/auth/*`, middleware (invisible) |
| 16. Public API | `/api/public/*`, `/api-docs` |
| 17.1 Berth Spec Import | `/admin/import` (berth-specific flow) |
| 17.2 Receipt Scanner (PWA) | `/scan` (standalone PWA, also `/expenses/scan` redirect) |
| 17.3 Recommendation Engine | `/interests/[id]` (Recommendations tab) |
| 17.4 S3 File Migration | One-time admin script (no permanent UI — progress in `/admin/monitoring`) |
| 18. Data Import/Export | `/admin/import`, export buttons on all list pages |
| 19. Webhooks | `/admin/webhooks`, `/admin/webhooks/new`, `/admin/webhooks/[id]` |
| 20. Scheduled Reports | `/admin/reports`, `/admin/reports/new` |
| 21. System Monitoring | `/admin/monitoring`, `/admin/backup` |
| 22. Client Portal | `/portal/*` |
| 23.1 Mobile Responsive | Responsive breakpoints (all pages) |
| 23.2 Dark Mode | User menu toggle (all pages) |
| 23.3 Quick Notes | Scratchpad overlay |
| 10.2 Notification Preferences | `/settings/notifications` |
| 23.4 Tags | `/admin/tags`, tag components on entity forms/detail |
| 23.5 Archiving | Archive actions on all entity detail/list pages |
| 23.6 Multi-Currency | Currency display on berth detail, expense forms |
| 23.7 Audit Export | Export button in `/admin/audit` or dedicated modal |

158
14-TECHNICAL-DECISIONS.md Normal file
View File

@@ -0,0 +1,158 @@
# 14 — Technical Decisions (Locked)
> **Status:** All decisions locked as of 2026-03-12. This document is the single source of truth for every dependency, library, and tooling choice in the Port Nimara CRM V1 rebuild.
---
## 1. Framework & Runtime
| Decision | Choice | Notes |
| --------------- | ---------------------------- | ----------------------------------------------------------- |
| Framework | **Next.js 15** (App Router) | React 19, standalone output mode, `next start` behind nginx |
| Language | **TypeScript** (strict mode) | `strict: true` in tsconfig, no `any` escape hatches |
| Runtime | **Node.js 20 LTS** | Docker base image: `node:20-alpine` |
| Package manager | **pnpm** | Faster installs, strict dependency resolution |
## 2. UI Layer
| Decision | Choice | Notes |
| ---------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Styling | **Tailwind CSS 4** + Port Nimara design tokens | Custom theme from brand guidelines. Full token system in `15-DESIGN-TOKENS.md` |
| Typography | **Inter** (UI), **Georgia** (formal docs), **JetBrains Mono** (data) | Inter via Google Fonts. Brand fonts (Bill Corporate, Adobe Garamond) reserved for marketing materials — CRM uses approved in-house alternatives per brand guide p.14 |
| Component library | **shadcn/ui** | Copy-paste components (not npm package). Built on Radix UI primitives + Tailwind. ~50 components. Run `npx shadcn@latest add <component>` to scaffold into `components/ui/`. Fully customizable — we own the source. |
| Icons | **Lucide React** | Tree-shakeable, consistent stroke style, bundled with shadcn/ui |
| Data tables | **TanStack Table** via shadcn DataTable | Server-side pagination, sorting, filtering. Column definitions per entity. |
| Forms | **React Hook Form + Zod** via shadcn Form | Zod schemas shared between client validation and API endpoint validation |
| Toasts / notifications | **Sonner** via shadcn Toast | Stacked toasts, auto-dismiss, action buttons |
| Command palette | **shadcn Command** (⌘K) | cmdk under the hood. Global search, quick navigation, actions. |
| Rich text editor | **TipTap** | Headless, ProseMirror-based, extension architecture. React integration via `@tiptap/react`. Used in 3 contexts (see Section 8). Pre-built shadcn integration available ("Minimal Tiptap"). |
| Charts | **Recharts** | For dashboard widgets and report visualizations |
| Date handling | **date-fns** | Lightweight, tree-shakeable. No moment.js. |
### 2.1 TipTap Configuration (3 contexts)
1. **Email composer** — Full toolbar: bold, italic, underline, lists, links, images. Merge field insertion via custom extension (or repurposed `@tiptap/extension-mention`). HTML output for email body.
2. **Document template editor** — Same as email composer plus: table support (`@tiptap/extension-table`), page break hints, merge field tokens rendered as styled inline chips.
3. **Notes fields** — Lightweight config: bold, italic, lists, links only. No toolbar — slash commands or floating menu. Markdown-like shortcuts enabled.
### 2.2 shadcn/ui Components Expected in Use
Core set (installed during Layer 0):
`Button`, `Input`, `Label`, `Select`, `Textarea`, `Checkbox`, `RadioGroup`, `Switch`, `Dialog`, `Sheet`, `DropdownMenu`, `Command`, `Tabs`, `Table`, `Card`, `Badge`, `Avatar`, `Tooltip`, `Popover`, `Calendar`, `DatePicker`, `Form`, `Toast` (Sonner), `Skeleton`, `Separator`, `ScrollArea`, `AlertDialog`, `Accordion`, `Breadcrumb`, `NavigationMenu`, `Pagination`, `Progress`, `Slider`
## 3. Data Layer
| Decision | Choice | Notes |
| ---------- | ----------------- | ------------------------------------------------------------------------- |
| Database | **PostgreSQL 16** | Docker container, named volume, same Compose stack |
| ORM | **Drizzle ORM** | Type-safe, SQL-like syntax, push-based migrations. Schema in `db/schema/` |
| Validation | **Zod** | Shared schemas: API validation + form validation + Drizzle type inference |
| Caching | **Redis 7** | Session store, BullMQ backing store, rate limiting, Socket.io adapter |
## 4. Authentication & Authorization
| Decision | Choice | Notes |
| ---------------- | --------------------------------------------------- | ----------------------------------------------------- |
| Auth library | **Better Auth** | Session-based auth with RBAC. No Keycloak dependency. |
| Session store | **Redis** | `better-auth/plugins/redis` for session persistence |
| Password hashing | **Argon2** (via Better Auth defaults) | |
| RBAC | 4 roles: `super_admin`, `admin`, `manager`, `agent` | Permissions defined per-role in `lib/permissions.ts` |
## 5. Real-time & Background Jobs
| Decision | Choice | Notes |
| ------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| WebSocket | **Socket.io** | Real-time notifications, live updates, presence. Redis adapter for scaling. |
| Job queue | **BullMQ** | Redis-backed. Recurring jobs (calendar sync, email sync, backups), event-driven jobs (EOI generation, webhook processing). Dashboard at `/admin/queues`. |
| Job dashboard | **bull-board** | Embedded in admin panel for queue monitoring |
## 6. File Storage & Documents
| Decision | Choice | Notes |
| -------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| Object storage | **MinIO** (existing self-hosted instance, older version) | S3-compatible. Credentials in env vars. SSE-S3 encryption at rest. DB-backed file metadata in `files` table. |
| E-signatures | **Documenso** (self-hosted) | Webhooks primary (instant). BullMQ fallback poll every 6 hours (rare safety net). |
| PDF generation | **@pdfme** | Template-based PDF generation. Branded layouts with port logo/colors. |
| Receipt OCR | **OpenAI Vision API** | Via standalone PWA at `/scan`. Offline queueing with IndexedDB. |
## 7. Email
| Decision | Choice | Notes |
| ---------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| SMTP relay | **Poste.io** (self-hosted) | Per-user encrypted SMTP credentials stored in DB |
| Sending | **Nodemailer** | Direct SMTP send via user's configured account |
| IMAP sync | **imapflow** | Background job syncs threads, metadata in PostgreSQL, raw emails in MinIO |
| System email templates | **MJML** | Compiled to HTML at build time. Templates for: password set/reset, EOI notifications, follow-up reminders, invoices |
## 8. External APIs
| Decision | Choice | Notes |
| ---------------- | --------------------- | ---------------------------------------------------------------------------------- |
| Google Calendar | **googleapis** | OAuth2 flow, 3 sync triggers (30min poll, on-login, on-navigation if stale > 5min) |
| Currency rates | **Frankfurter API** | Free, no API key, ECB rates. Cached daily. |
| AI (receipt OCR) | **OpenAI Vision API** | Only AI dependency. Scoped to receipt scanning. |
## 9. Testing
| Decision | Choice | Notes |
| ------------------ | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Unit / integration | **Vitest** | Modern Jest replacement. Same `describe/it/expect` API. Natively supports TypeScript/ESM. Built on Vite — significantly faster. Next.js officially recommends it. |
| E2E | **Playwright** | Real browser automation. V1 scope: 56 critical workflow tests only. |
| Component tests | **Skipped for V1** | Vitest covers logic; Playwright covers critical flows. Component-level tests deferred. |
### 9.1 Vitest Coverage Targets (V1)
- All business rule functions in `lib/business-rules/`
- All Zod validation schemas
- API endpoint handlers (happy path + key error cases)
- Utility functions (date formatting, currency conversion, permission checks)
- Service layer functions (EOI generation, invoice calculation, email sending)
### 9.2 Playwright E2E Tests (V1)
56 critical workflows:
1. Login → dashboard loads → navigate to clients
2. Create client → link interest → generate EOI → verify PDF
3. Create expense → upload receipt → verify in expense list
4. Email compose → send → verify in thread
5. Admin: create user → assign role → verify permissions
6. Berth management: create berth → assign client → verify status transitions
## 10. DevOps & Infrastructure
| Decision | Choice | Notes |
| ---------------- | ------------------- | ----------------------------------------------------------------------- |
| Containerization | **Docker Compose** | Services: `crm-app`, `postgres`, `redis`, `minio`, `documenso`, `nginx` |
| Reverse proxy | **nginx** | TLS termination, rate limiting, static asset caching |
| CI/CD | **GitHub Actions** | Lint → type-check → Vitest → build → deploy |
| Backups | **pg_dump → MinIO** | Nightly at 02:00, encrypted, 30-day retention |
## 11. Server-side State & API
| Decision | Choice | Notes |
| ------------ | --------------------- | --------------------------------------------------------------------------- |
| API style | **REST** | Consistent pattern for CRM frontend, website berth map, future integrations |
| Server state | **TanStack Query** | Client-side caching, automatic revalidation, optimistic updates |
| Client state | **Zustand** | Minimal UI state (sidebar collapse, active port, theme) |
| API docs | **OpenAPI / Swagger** | Auto-generated from Zod schemas. Available at `/api/docs` in dev. |
---
## Decision Log
| Date | Decision | Rationale |
| ---------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------- |
| 2026-02-xx | Next.js 15 over Nuxt 3 / SvelteKit | Largest AI training corpus, best Claude Code fluency |
| 2026-02-xx | REST over tRPC | One API pattern for all consumers, no dual-paradigm |
| 2026-02-xx | PostgreSQL + Drizzle over NocoDB | Relational integrity, type-safe ORM, proper migrations |
| 2026-02-xx | Better Auth over Keycloak | Simpler, no external service, built-in RBAC |
| 2026-02-xx | Keep MinIO | Working infrastructure, S3-compatible, no migration needed |
| 2026-03-12 | shadcn/ui confirmed | Copy-paste ownership, Radix primitives, Claude Code fluent |
| 2026-03-12 | TipTap for rich text | Headless ProseMirror, extension architecture, shadcn integration exists |
| 2026-03-12 | Vitest + Playwright | Vitest for unit/integration (fast, TS-native), Playwright for 5-6 critical E2E |
| 2026-03-12 | Skip component tests V1 | Logic covered by Vitest, flows covered by Playwright |
| 2026-03-12 | MinIO stays (older version) | Self-hosted instance works, no migration overhead |
| 2026-03-12 | Port Nimara design tokens from brand guide | Full token system in `15-DESIGN-TOKENS.md`, replaces generic "Maritime tokens" |
| 2026-03-12 | Inter as CRM UI font | Brand guide approves Arial for in-house; Inter is the modern web equivalent, shadcn default |
| 2026-03-12 | Georgia for formal generated docs | Brand guide secondary font (Adobe Garamond) is commercial; Georgia is the approved in-house serif |

537
15-DESIGN-TOKENS.md Normal file
View File

@@ -0,0 +1,537 @@
# 15 — Design Tokens & CRM Theme
> **Source of truth:** Port Nimara Brand Guidelines (30-page PDF) + CRM-specific extensions for UI states, data visualization, and accessibility.
---
## 1. Brand Color Palette (from Guidelines)
### 1.1 Primary Colors
| Pantone | HEX | RGB | Role in brand |
| ----------- | --------- | ------------- | ----------------------------------------------- |
| **PMS 553** | `#1e2844` | 30, 40, 68 | Dark navy — headings, sidebar, dark backgrounds |
| **PMS 660** | `#3a7bc8` | 58, 123, 200 | Brand blue — logo, primary accent |
| **Black** | `#000000` | 0, 0, 0 | Text, logo variant |
| **White** | `#ffffff` | 255, 255, 255 | Backgrounds, reversed logo |
### 1.2 Secondary Colors
| Pantone | HEX | RGB | Role in brand |
| ------------ | --------- | ------------- | ----------------------------------- |
| **PMS 7485** | `#dae3c1` | 218, 227, 193 | Sage — soft accent, backgrounds |
| **PMS 344** | `#add5b3` | 173, 213, 179 | Mint — fresh accent, success hint |
| **PMS 5493** | `#83aab1` | 131, 170, 177 | Teal — muted accent, secondary info |
| **PMS 2725** | `#685aa3` | 104, 90, 163 | Purple — premium accent, highlights |
### 1.3 Brand Tint Ladder (from guidelines — 80%, 60%, 40%, 20% of each primary)
| Base | 80% | 60% | 40% | 20% |
| --------- | --------- | --------- | --------- | --------- |
| `#1e2844` | `#474e66` | `#71768a` | `#9ea1af` | `#cdcfd6` |
| `#3a7bc8` | `#6196d3` | `#89b0de` | `#b1cbe9` | `#d8e5f4` |
| `#000000` | `#333333` | `#666666` | `#999999` | `#cccccc` |
---
## 2. CRM Semantic Color Tokens
These map brand colors to UI purpose. Every component references semantic tokens, never raw hex values.
### 2.1 Light Mode (default)
```
/* === BACKGROUNDS === */
--background: #ffffff /* Page background */
--background-secondary: #f8f9fa /* Subtle section backgrounds (cards, sidebars) */
--background-tertiary: #f1f3f5 /* Inset panels, table header rows */
--background-brand: #3a7bc8 /* Brand-colored backgrounds (header bar, CTA buttons) */
--background-brand-dark: #1e2844 /* Dark brand sections (login page, onboarding) */
--background-muted: #d8e5f4 /* PMS 660 at 20% — subtle brand tint */
/* === TEXT === */
--text-primary: #1e2844 /* Primary body text — dark brand base */
--text-secondary: #474e66 /* Secondary/muted text — 80% of PMS 553 */
--text-tertiary: #71768a /* Placeholder text, captions — 60% of PMS 553 */
--text-on-brand: #ffffff /* Text on brand-colored backgrounds */
--text-on-dark: #ffffff /* Text on dark backgrounds */
--text-link: #3a7bc8 /* Hyperlinks — brand blue */
/* === BORDERS === */
--border: #cdcfd6 /* Default border — 20% of PMS 553 */
--border-strong: #9ea1af /* Emphasized border — 40% of PMS 553 */
--border-focus: #3a7bc8 /* Focus ring — brand blue */
--border-brand: #3a7bc8 /* Brand accent borders */
/* === INTERACTIVE === */
--primary: #3a7bc8 /* Primary button, active nav, selected tab */
--primary-hover: #2f6ab5 /* Primary hover (10% darker) */
--primary-active: #255a9e /* Primary pressed */
--primary-foreground: #ffffff /* Text on primary buttons */
--secondary: #1e2844 /* Secondary button fill */
--secondary-hover: #171f35 /* Secondary hover */
--secondary-foreground: #ffffff /* Text on secondary buttons */
--accent: #83aab1 /* Accent highlights — PMS 5493 teal */
--accent-hover: #6f959c /* Accent hover */
--accent-foreground: #ffffff /* Text on accent */
--ghost-hover: #f1f3f5 /* Ghost/outline button hover */
--muted: #f1f3f5 /* Muted/disabled backgrounds */
--muted-foreground: #71768a /* Muted text */
/* === STATUS === */
--success: #2d8a4e /* Confirmed, active, paid, signed */
--success-bg: #e8f5e9 /* Success background tint */
--success-border: #a5d6a7 /* Success border */
--warning: #e6a817 /* Expiring soon, pending, needs attention */
--warning-bg: #fff8e1 /* Warning background tint */
--warning-border: #ffe082 /* Warning border */
--error: #d32f2f /* Overdue, failed, rejected, expired */
--error-bg: #ffebee /* Error background tint */
--error-border: #ef9a9a /* Error border */
--info: #3a7bc8 /* Informational — uses brand blue */
--info-bg: #d8e5f4 /* Info background — brand blue 20% */
--info-border: #89b0de /* Info border — brand blue 60% */
/* === SIDEBAR / NAVIGATION === */
--sidebar-bg: #1e2844 /* Dark brand sidebar */
--sidebar-text: #cdcfd6 /* Sidebar text — 20% of PMS 553 (light) */
--sidebar-text-active: #ffffff /* Active nav item text */
--sidebar-icon: #83aab1 /* Nav icons — teal accent */
--sidebar-icon-active: #3a7bc8 /* Active nav icon — brand blue */
--sidebar-hover: #171f35 /* Sidebar hover background */
--sidebar-active: #3a7bc810 /* Brand blue at 6% opacity — subtle highlight */
--sidebar-divider: #474e66 /* Sidebar section dividers */
/* === DATA VISUALIZATION (6-color sequence) === */
--chart-1: #3a7bc8 /* Brand blue */
--chart-2: #1e2844 /* Dark brand */
--chart-3: #83aab1 /* Teal */
--chart-4: #685aa3 /* Purple */
--chart-5: #add5b3 /* Mint */
--chart-6: #dae3c1 /* Sage */
```
### 2.2 Dark Mode
The CRM is primarily a daytime work tool, but dark mode is supported for preference and low-light marina office use.
```
/* === BACKGROUNDS === */
--background: #131a2c /* Darkened navy base */
--background-secondary: #192239 /* Card/sidebar backgrounds */
--background-tertiary: #1e2844 /* PMS 553 as surface */
--background-brand: #3a7bc8 /* Brand blue stays consistent */
--background-brand-dark: #101625 /* Even darker navy */
/* === TEXT === */
--text-primary: #e8ece9 /* Light text on dark backgrounds */
--text-secondary: #9ea1af /* Secondary text */
--text-tertiary: #71768a /* Muted text */
--text-on-brand: #ffffff
--text-link: #6196d3 /* Lightened brand blue for readability */
/* === BORDERS === */
--border: #2d3c66 /* Subtle dark border */
--border-strong: #474e66 /* Emphasized */
--border-focus: #6196d3 /* Focus ring */
/* === INTERACTIVE === */
--primary: #4a8ad4 /* Slightly lightened for dark bg contrast */
--primary-hover: #6196d3
--primary-active: #3a7bc8
/* === STATUS (brightened for dark mode readability) === */
--success: #4caf50
--success-bg: #1b3d1e
--warning: #ffca28
--warning-bg: #3d3417
--error: #ef5350
--error-bg: #3d1a1a
--info: #6196d3
--info-bg: #1a2d3d
```
---
## 3. Typography
### 3.1 Brand Fonts (from guidelines)
| Role | Font | License | Notes |
| ---------------------- | --------------- | -------------------- | ---------------------------------------------------------------- |
| **Primary** | Bill Corporate | Commercial (MyFonts) | Outward-facing: marketing, website, PDFs, printed correspondence |
| **Secondary** | Adobe Garamond | Commercial (Adobe) | Captions only, outward-facing |
| **Default (in-house)** | Arial / Georgia | System fonts | Word documents, emails, internal communications |
### 3.2 CRM Font Strategy
The CRM is an **internal tool** — not outward-facing marketing collateral. Per the brand guidelines, Arial and Georgia are the approved default typefaces for in-house communications.
For a modern web application, we use **Inter** as the primary UI font. Inter is the de facto standard for web applications (used by Vercel, GitHub, Linear, etc.), is visually close to Arial (sans-serif, clean, neutral), and has excellent screen readability at all sizes. It's also the default font recommended by shadcn/ui.
| CRM Role | Font | Fallback | Notes |
| ------------------------------------------- | --------------------------------- | --------------------------------------------- | ----------------------------------------------- |
| **UI (body, labels, buttons)** | Inter | `system-ui, -apple-system, Arial, sans-serif` | Google Fonts or self-hosted |
| **Headings (page titles, section headers)** | Inter | Same fallback chain | Semi-bold (600) or Bold (700) weight |
| **Data (tables, numbers, code)** | `Inter Tight` or `JetBrains Mono` | `ui-monospace, monospace` | For tabular data alignment, code snippets |
| **Generated PDFs** | Arial | Helvetica, sans-serif | Brand-compliant for generated letters, invoices |
| **Generated formal documents** | Georgia | `Times New Roman, serif` | Brand-compliant for formal correspondence |
### 3.3 Type Scale (Tailwind classes)
```
/* Using Tailwind's default scale with Inter */
text-xs: 0.75rem / 1rem /* 12px — fine print, timestamps */
text-sm: 0.875rem / 1.25rem /* 14px — table cells, secondary info, form labels */
text-base: 1rem / 1.5rem /* 16px — body text, descriptions */
text-lg: 1.125rem / 1.75rem /* 18px — card titles, sub-headings */
text-xl: 1.25rem / 1.75rem /* 20px — section headings */
text-2xl: 1.5rem / 2rem /* 24px — page titles */
text-3xl: 1.875rem / 2.25rem /* 30px — dashboard hero numbers */
```
### 3.4 Font Weights
```
font-normal: 400 /* Body text, descriptions */
font-medium: 500 /* Labels, table headers, nav items */
font-semibold: 600 /* Section headings, card titles, important values */
font-bold: 700 /* Page titles, dashboard hero numbers */
```
---
## 4. Spacing & Layout
### 4.1 Base Grid
The CRM uses Tailwind's default 4px base unit system. Key spacing tokens:
```
space-1: 0.25rem (4px) /* Tight padding (badge internal) */
space-2: 0.5rem (8px) /* Compact spacing (between inline elements) */
space-3: 0.75rem (12px) /* Form field internal padding */
space-4: 1rem (16px) /* Standard padding (cards, sections) */
space-5: 1.25rem (20px) /* Comfortable gaps */
space-6: 1.5rem (24px) /* Section separators */
space-8: 2rem (32px) /* Major section gaps */
space-10: 2.5rem (40px) /* Page-level margins */
space-12: 3rem (48px) /* Dashboard widget gaps */
```
### 4.2 Container Widths
```
max-w-screen-sm: 640px /* Login page, modal content */
max-w-screen-md: 768px /* Narrow forms, PWA scanner */
max-w-screen-lg: 1024px /* Standard content area */
max-w-screen-xl: 1280px /* Wide tables, dashboard */
max-w-screen-2xl: 1536px /* Full-width admin views */
```
### 4.3 Sidebar
```
sidebar-width-collapsed: 64px /* Icon-only sidebar */
sidebar-width-expanded: 256px /* Full sidebar with labels */
sidebar-transition: 200ms ease-in-out
```
---
## 5. Border Radius
Rounded corners give the maritime/luxury feel without being overly playful.
```
rounded-sm: 0.25rem (4px) /* Subtle rounding — tags, badges */
rounded: 0.375rem (6px) /* Default — buttons, inputs, cards */
rounded-md: 0.5rem (8px) /* Slightly more — dialogs, dropdowns */
rounded-lg: 0.75rem (12px) /* Prominent — dashboard cards, modals */
rounded-xl: 1rem (16px) /* Feature cards, hero elements */
rounded-full: 9999px /* Avatars, status dots, icon buttons */
```
---
## 6. Shadows & Elevation
Three shadow levels, using the dark brand color for a cohesive feel:
```
shadow-sm: 0 1px 2px rgba(30, 40, 68, 0.06) /* Subtle lift — cards at rest */
shadow: 0 1px 3px rgba(30, 40, 68, 0.10), 0 1px 2px rgba(30, 40, 68, 0.06) /* Default — raised cards, buttons */
shadow-md: 0 4px 6px rgba(30, 40, 68, 0.10), 0 2px 4px rgba(30, 40, 68, 0.06) /* Hover state, dropdowns */
shadow-lg: 0 10px 15px rgba(30, 40, 68, 0.10), 0 4px 6px rgba(30, 40, 68, 0.05) /* Modals, dialogs, sheets */
shadow-xl: 0 20px 25px rgba(30, 40, 68, 0.10), 0 8px 10px rgba(30, 40, 68, 0.04) /* Toast notifications */
```
---
## 7. Wave Pattern (CSS)
The brand's wave pattern (from guidelines p18-19) can be subtly referenced in the UI:
```css
/* Subtle wave divider — used on login page, onboarding, section breaks */
.wave-divider {
background-image: url('data:image/svg+xml,...'); /* SVG wave in brand blue */
background-repeat: repeat-x;
height: 24px;
opacity: 0.15;
}
/* Wave watermark — login page background */
.wave-watermark {
background-image: repeating-linear-gradient(
135deg,
transparent,
transparent 10px,
rgba(58, 123, 200, 0.03) 10px,
rgba(58, 123, 200, 0.03) 20px
);
}
```
---
## 8. Component-Specific Tokens
### 8.1 Berth Status Colors
These are critical CRM semantics — each berth status gets a distinct color from the brand palette:
| Status | Color | HEX | Token |
| ----------- | -------------------- | --------- | --------------------- |
| Available | Mint (PMS 344) | `#add5b3` | `--berth-available` |
| Occupied | Brand blue (PMS 660) | `#3a7bc8` | `--berth-occupied` |
| Reserved | Purple (PMS 2725) | `#685aa3` | `--berth-reserved` |
| Maintenance | Warning amber | `#e6a817` | `--berth-maintenance` |
| Unavailable | Muted grey | `#999999` | `--berth-unavailable` |
### 8.2 EOI / Interest Stage Colors
| Stage | Color | HEX |
| ----------- | -------------------- | --------- |
| Lead | Sage (PMS 7485) | `#dae3c1` |
| Contacted | Teal (PMS 5493) | `#83aab1` |
| Qualified | Brand blue (PMS 660) | `#3a7bc8` |
| Negotiating | Purple (PMS 2725) | `#685aa3` |
| Won | Success green | `#2d8a4e` |
| Lost | Error red | `#d32f2f` |
| On Hold | Warning amber | `#e6a817` |
### 8.3 Priority / Urgency Badges
| Level | Color | Text color |
| -------- | -------------------------- | ---------------- |
| Low | `#dae3c1` (sage) | `#1e2844` (dark) |
| Medium | `#d8e5f4` (brand blue 20%) | `#3a7bc8` |
| High | `#fff8e1` (warning bg) | `#e6a817` |
| Critical | `#ffebee` (error bg) | `#d32f2f` |
---
## 9. Tailwind Config (actual `tailwind.config.ts`)
```typescript
import type { Config } from 'tailwindcss';
export default {
darkMode: 'class',
content: ['./src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
// Brand primaries
brand: {
DEFAULT: '#3a7bc8', // PMS 660 — primary blue
dark: '#1e2844', // PMS 553 — dark base
50: '#d8e5f4', // 20%
100: '#b1cbe9', // 40%
200: '#89b0de', // 60%
300: '#6196d3', // 80%
400: '#3a7bc8', // 100%
500: '#2f6ab5', // hover
600: '#255a9e', // active
700: '#1c4a87', // dark
},
navy: {
DEFAULT: '#1e2844', // PMS 553
50: '#cdcfd6', // 20%
100: '#9ea1af', // 40%
200: '#71768a', // 60%
300: '#474e66', // 80%
400: '#1e2844', // 100%
500: '#171f35', // darker (hover)
600: '#101625', // darkest
},
// Secondary palette
sage: {
DEFAULT: '#dae3c1', // PMS 7485
light: '#edf1e2',
dark: '#b8c49e',
},
mint: {
DEFAULT: '#add5b3', // PMS 344
light: '#d6ead9',
dark: '#7dba85',
},
teal: {
DEFAULT: '#83aab1', // PMS 5493
light: '#b1cdd2',
dark: '#5a8a92',
},
purple: {
DEFAULT: '#685aa3', // PMS 2725
light: '#a49ac6',
dark: '#4d4280',
},
// Status colors
success: {
DEFAULT: '#2d8a4e',
bg: '#e8f5e9',
border: '#a5d6a7',
},
warning: {
DEFAULT: '#e6a817',
bg: '#fff8e1',
border: '#ffe082',
},
error: {
DEFAULT: '#d32f2f',
bg: '#ffebee',
border: '#ef9a9a',
},
// Sidebar
sidebar: {
DEFAULT: '#1e2844',
text: '#cdcfd6',
hover: '#171f35',
active: '#3a7bc8',
divider: '#474e66',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', '-apple-system', 'Arial', 'sans-serif'],
mono: ['JetBrains Mono', 'ui-monospace', 'monospace'],
// For generated formal documents only
serif: ['Georgia', 'Times New Roman', 'serif'],
},
boxShadow: {
sm: '0 1px 2px rgba(30, 40, 68, 0.06)',
DEFAULT: '0 1px 3px rgba(30, 40, 68, 0.10), 0 1px 2px rgba(30, 40, 68, 0.06)',
md: '0 4px 6px rgba(30, 40, 68, 0.10), 0 2px 4px rgba(30, 40, 68, 0.06)',
lg: '0 10px 15px rgba(30, 40, 68, 0.10), 0 4px 6px rgba(30, 40, 68, 0.05)',
xl: '0 20px 25px rgba(30, 40, 68, 0.10), 0 8px 10px rgba(30, 40, 68, 0.04)',
},
borderRadius: {
sm: '0.25rem',
DEFAULT: '0.375rem',
md: '0.5rem',
lg: '0.75rem',
xl: '1rem',
},
width: {
sidebar: '256px',
'sidebar-collapsed': '64px',
},
},
},
plugins: [require('tailwindcss-animate')],
} satisfies Config;
```
---
## 10. CSS Variables (for shadcn/ui compatibility)
shadcn/ui uses CSS custom properties in HSL format. Here are the Port Nimara brand values converted:
```css
@layer base {
:root {
/* shadcn/ui variable format: H S% L% */
--background: 0 0% 100%; /* #ffffff */
--foreground: 224 39% 19%; /* #1e2844 */
--card: 0 0% 100%;
--card-foreground: 224 39% 19%;
--popover: 0 0% 100%;
--popover-foreground: 224 39% 19%;
--primary: 213 55% 56%; /* #3a7bc8 */
--primary-foreground: 0 0% 100%;
--secondary: 224 39% 19%; /* #1e2844 */
--secondary-foreground: 0 0% 100%;
--muted: 210 11% 96%; /* #f1f3f5 */
--muted-foreground: 228 10% 49%; /* #71768a */
--accent: 190 18% 60%; /* #83aab1 */
--accent-foreground: 0 0% 100%;
--destructive: 0 65% 51%; /* #d32f2f */
--destructive-foreground: 0 0% 100%;
--border: 227 10% 82%; /* #cdcfd6 */
--input: 227 10% 82%;
--ring: 213 55% 56%; /* #3a7bc8 focus ring */
--radius: 0.375rem;
/* Sidebar (using dark navy) */
--sidebar-background: 224 39% 19%;
--sidebar-foreground: 227 10% 82%;
--sidebar-primary: 213 55% 56%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 224 39% 15%;
--sidebar-accent-foreground: 227 10% 82%;
--sidebar-border: 226 18% 34%;
--sidebar-ring: 213 55% 56%;
/* Chart colors for Recharts */
--chart-1: 213 55% 56%; /* Brand blue */
--chart-2: 224 39% 19%; /* Dark navy */
--chart-3: 190 18% 60%; /* Teal */
--chart-4: 254 29% 50%; /* Purple */
--chart-5: 130 30% 76%; /* Mint */
--chart-6: 75 30% 82%; /* Sage */
}
.dark {
--background: 224 40% 12%;
--foreground: 227 10% 91%;
--card: 224 39% 19%;
--card-foreground: 227 10% 91%;
--popover: 224 39% 19%;
--popover-foreground: 227 10% 91%;
--primary: 213 52% 62%;
--primary-foreground: 0 0% 100%;
--secondary: 224 39% 22%;
--secondary-foreground: 227 10% 82%;
--muted: 224 39% 18%;
--muted-foreground: 228 10% 49%;
--accent: 190 18% 50%;
--accent-foreground: 0 0% 100%;
--destructive: 0 72% 63%;
--destructive-foreground: 0 0% 100%;
--border: 224 35% 28%;
--input: 224 35% 28%;
--ring: 213 52% 62%;
}
}
```
---
## 11. Accessibility Notes
- **WCAG AA contrast ratios** verified for all text/background combinations:
- `#1e2844` on `#ffffff`**14.6:1** (passes AAA)
- `#ffffff` on `#3a7bc8`**4.3:1** (passes AA for large text; use semibold 16px+ or 14px bold)
- `#ffffff` on `#1e2844`**14.6:1** (passes AAA)
- `#474e66` on `#ffffff`**8.2:1** (passes AAA)
- `#71768a` on `#ffffff`**4.5:1** (passes AA)
- **Focus rings:** 2px solid `#3a7bc8` with 2px offset — clearly visible on both light and dark backgrounds
- **Status colors:** Never rely on color alone — always pair with icons (checkmark, warning triangle, X circle) and text labels
- **Color-blind safe:** The berth status palette uses distinct hue families (green, blue, purple, yellow, grey) that remain distinguishable under protanopia, deuteranopia, and tritanopia

28
Dockerfile Normal file
View File

@@ -0,0 +1,28 @@
# Stage 1: Install dependencies
FROM node:20-alpine AS deps
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod=false
# Stage 2: Build the application
FROM node:20-alpine AS builder
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build
# Stage 3: Production runner
FROM node:20-alpine AS runner
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]

7
Dockerfile.dev Normal file
View File

@@ -0,0 +1,7 @@
FROM node:20-alpine
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
EXPOSE 3000
CMD ["pnpm", "dev"]

15
Dockerfile.worker Normal file
View File

@@ -0,0 +1,15 @@
# Stage 1: Install production dependencies
FROM node:20-alpine AS deps
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod
# Stage 2: Production runner
FROM node:20-alpine AS runner
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 worker
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY dist/worker.js ./worker.js
USER worker
CMD ["node", "worker.js"]

628
NOCODB-MIGRATION-MAPPING.md Normal file
View File

@@ -0,0 +1,628 @@
# NocoDB → PostgreSQL Migration Mapping
**Purpose:** Field-by-field mapping from the current NocoDB tables to the new PostgreSQL schema, including transformation rules, data quality fixes, deduplication logic, and migration order.
**References:** `07-DATABASE-SCHEMA.md`, `IMPL-L6-MIGRATION.md`, annotated source `utils/types.ts`, `server/utils/nocodb.ts`, `server/utils/nocodb-invoice.ts`
---
## Source System Summary
The current NocoDB system has four primary tables:
| NocoDB Table | Table ID | Est. Records | Destination Tables |
| ------------ | ----------------- | ------------ | -------------------------------------------------------------------------------------------- |
| Interests | `mbs9hjauug4eseo` | ~200500 | `clients`, `client_contacts`, `interests`, `interest_notes`, `documents`, `document_signers` |
| Berths | `mczgos9hr3oa9qc` | ~100200 | `berths`, `berth_map_data` |
| Expenses | `mxfcefkk4dqs6uq` | ~5002000 | `expenses`, `files` (receipts) |
| Invoices | `mvyvz0lpc30p01s` | ~200500 | `invoices`, `invoice_line_items`, `invoice_expenses` |
**Key structural change:** NocoDB's Interests table is a "mega-table" that combines client identity data, contact details, vessel specifications, pipeline tracking, milestone dates, and document references into a single 60+ field row. The new schema normalizes this into separate `clients`, `client_contacts`, `interests`, `interest_notes`, and `documents` tables.
---
## Migration Order (Respects Foreign Keys)
```
1. ports ← Seed from L0 (already exists)
2. clients ← Derived from NocoDB Interests (dedup)
3. client_contacts ← Derived from NocoDB Interests (dedup)
4. berths ← From NocoDB Berths
5. berth_map_data ← From NocoDB Berths (if SVG data exists)
6. interests ← From NocoDB Interests (references clients + berths)
7. interest_notes ← From NocoDB Interests "Extra Comments" field
8. documents ← From NocoDB Interests (EOI/contract references)
9. document_signers ← From NocoDB Interests (signing link fields)
10. expenses ← From NocoDB Expenses
11. files ← From MinIO scan (receipts, EOI PDFs, contracts)
12. invoices ← From NocoDB Invoices
13. invoice_line_items ← Extracted from NocoDB Invoice data
14. invoice_expenses ← Cross-reference invoices ↔ expenses
15. tags ← Derived from any categorical data worth tagging
16. audit_logs ← Synthetic historical entries (created_at events)
```
---
## Table 1: NocoDB Interests → `clients`
### Deduplication Strategy
The Interests table has one row per interest, but multiple interests can belong to the same client. Client identity must be deduplicated before insertion.
**Primary match key:** `Email Address` (case-insensitive, trimmed)
**Secondary match key:** `Full Name` (normalized) + `Place of Residence` (when email is null)
**On conflict:** Merge contacts, keep earliest `Created At`, concatenate notes with separator
```
normalizeClientName(name: string): string
→ trim whitespace
→ collapse multiple spaces
→ title case
→ strip honorifics (Mr., Mrs., Dr., Capt., etc.)
→ return normalized name
```
### Field Mapping
| NocoDB Interests Field | Type (NocoDB) | → | New Table.Column | Type (PG) | Transformation |
| -------------------------- | ------------- | --- | ---------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `Full Name` | string | → | `clients.full_name` | TEXT | `normalizeClientName()` — trim, title-case, strip honorifics |
| — (no company field) | — | → | `clients.company_name` | TEXT | NULL — NocoDB has no company field; leave empty |
| — (no nationality) | — | → | `clients.nationality` | TEXT | NULL |
| `Yacht Name` | string | → | `clients.yacht_name` | TEXT | Trim, title-case |
| `Length` | string | → | `clients.yacht_length_ft` | NUMERIC | `parseFloat()` — NocoDB stores as string. If contains "m" suffix, convert m→ft (×3.28084). If contains "ft", strip suffix. |
| `Width` | string | → | `clients.yacht_width_ft` | NUMERIC | Same string→number parsing as Length |
| `Depth` | string | → | `clients.yacht_draft_ft` | NUMERIC | Same string→number parsing as Length |
| `Length` | string | → | `clients.yacht_length_m` | NUMERIC | If originally in ft, convert ft→m (÷3.28084). If in m, use directly. |
| `Width` | string | → | `clients.yacht_width_m` | NUMERIC | Same dual-unit logic |
| `Depth` | string | → | `clients.yacht_draft_m` | NUMERIC | Same dual-unit logic |
| `Berth Size Desired` | string | → | `clients.berth_size_desired` | TEXT | Direct copy, trim |
| `Contact Method Preferred` | string | → | `clients.preferred_contact_method` | TEXT | Map: `"Email"``"email"`, `"Phone"``"phone"`, `"WhatsApp"``"whatsapp"`. Default `"email"`. |
| `Source` | string | → | `clients.source` | TEXT | Map: `"Website"``"website"`, `"Referral"``"referral"`, `"Direct"``"manual"`, `"Broker"``"broker"`. Default `"manual"`. |
| `Place of Residence` | string | → | `clients.source_details` | TEXT | Store as additional context (may indicate referral origin) |
| `Created At` | datetime | → | `clients.created_at` | TIMESTAMPTZ | Use earliest `Created At` from all interests belonging to this client |
| `Created At` | datetime | → | `clients.updated_at` | TIMESTAMPTZ | Use latest `Updated At` from all interests belonging to this client |
| — | — | → | `clients.port_id` | UUID | Constant: Port Nimara UUID (from seed data) |
| — | — | → | `clients.is_proxy` | BOOLEAN | `false` (no proxy data in NocoDB) |
| — | — | → | `clients.archived_at` | TIMESTAMPTZ | NULL |
**Unit parsing helper:**
```typescript
function parseDimension(value: string | null): { ft: number | null; m: number | null } {
if (!value || value.trim() === '') return { ft: null, m: null };
const cleaned = value.replace(/['"]/g, '').trim();
const num = parseFloat(cleaned);
if (isNaN(num)) return { ft: null, m: null };
if (cleaned.toLowerCase().endsWith('m')) {
return { ft: Math.round(num * 3.28084 * 100) / 100, m: num };
}
// Default: assume feet (most common in NocoDB data)
return { ft: num, m: Math.round((num / 3.28084) * 100) / 100 };
}
```
---
## Table 2: NocoDB Interests → `client_contacts`
Each unique client may have up to 3 contacts extracted from the Interests table. Deduplicate across all interests belonging to the same client.
| NocoDB Interests Field | → | New Table.Column | Type (PG) | Transformation |
| ---------------------- | --- | ---------------------------- | --------- | ------------------------------------------------------------------------------- |
| `Email Address` | → | `client_contacts.value` | TEXT | Trim, lowercase. Channel = `"email"`. Label = `"primary"`. |
| `Phone Number` | → | `client_contacts.value` | TEXT | Trim, normalize to E.164 if possible. Channel = `"phone"`. Label = `"primary"`. |
| `Address` | → | `client_contacts.value` | TEXT | Trim. Channel = `"other"`. Label = `"address"`. |
| — | → | `client_contacts.client_id` | UUID | From dedup map: NocoDB Interest → client UUID |
| — | → | `client_contacts.is_primary` | BOOLEAN | `true` for first email, first phone. `false` for subsequent. |
| — | → | `client_contacts.channel` | TEXT | Determined by source field (email/phone/other) |
**Deduplication rules:**
- If the same email appears across multiple interests for the same client → create one contact record
- If different emails appear across interests for the same client → create multiple records, first is `is_primary=true`
- Skip empty/null values
---
## Table 3: NocoDB Interests → `interests`
One NocoDB Interest row = one new `interests` row (after client dedup, the interest still exists as a pipeline record).
| NocoDB Interests Field | Type (NocoDB) | → | New Table.Column | Type (PG) | Transformation |
| -------------------------- | ------------- | --- | ------------------------------ | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Id` | number | → | `interests.metadata` | JSONB | Store as `{ "nocodb_id": value }` for reference |
| — | — | → | `interests.client_id` | UUID | From client dedup map |
| `Berths_id` | number | → | `interests.berth_id` | UUID | From berth ID map (NocoDB numeric ID → new UUID). Nullable if `Berths_id` is null. |
| `Sales Process Level` | string | → | `interests.pipeline_stage` | TEXT | **See Pipeline Stage Mapping below** |
| `Lead Category` | string | → | `interests.lead_category` | TEXT | Map: `"General"``"general_interest"`, `"Specific"``"specific_qualified"`, `"Hot"``"hot_lead"`. |
| `Source` | string | → | `interests.source` | TEXT | Same mapping as clients.source |
| `EOI Status` | string | → | `interests.eoi_status` | TEXT | Map: `"Pending"``"waiting_for_signatures"`, `"Signed"``"signed"`, `"Expired"``"expired"`, `"Sent"``"waiting_for_signatures"`. NULL if empty. |
| `documensoID` | string | → | `interests.documenso_id` | TEXT | Direct copy. Trim. |
| `Contract Status` | string | → | `interests.contract_status` | TEXT | Lowercase, snake_case conversion |
| `Deposit 10% Status` | string | → | `interests.deposit_status` | TEXT | Lowercase, snake_case conversion |
| `Date Added` | datetime | → | `interests.date_first_contact` | TIMESTAMPTZ | Parse date string → ISO 8601 |
| `EOI Time Sent` | datetime | → | `interests.date_eoi_sent` | TIMESTAMPTZ | Parse date string → ISO 8601 |
| `Time LOI Sent` | datetime | → | `interests.date_eoi_signed` | TIMESTAMPTZ | NocoDB uses "LOI" but this is actually EOI signed date. Parse date → ISO 8601. |
| `Contract Sent Status` | string | → | `interests.date_contract_sent` | TIMESTAMPTZ | If status indicates "Sent", use `Updated At` as approximate date. Otherwise NULL. |
| `Extra Comments` | string | → | `interests.notes` | TEXT | Direct copy, trim |
| `Berth Number` | string | → | — | — | Cross-reference field; use for berth_id resolution if `Berths_id` is null |
| `Request More Information` | boolean | → | `interests.metadata` | JSONB | Store in metadata: `{ "request_more_info": true/false }` |
| `Request Form Sent` | string | → | `interests.metadata` | JSONB | Store in metadata |
| `Borth Info Sent Status` | string | → | `interests.metadata` | JSONB | Store in metadata (note: field name has typo "Borth") |
| `Created At` | datetime | → | `interests.created_at` | TIMESTAMPTZ | Direct copy |
| — | — | → | `interests.port_id` | UUID | Constant: Port Nimara UUID |
| — | — | → | `interests.archived_at` | TIMESTAMPTZ | NULL |
| — | — | → | `interests.reminder_enabled` | BOOLEAN | `false` (configure post-migration) |
### Pipeline Stage Mapping
| NocoDB `Sales Process Level` | → | New `pipeline_stage` | Notes |
| --------------------------------- | --- | -------------------- | -------------------------------------- |
| `General Qualified Interest` | → | `open` | Broadest inquiry stage |
| `Specific Qualified Interest` | → | `details_sent` | Client interested in specific berth(s) |
| `EOI and NDA Sent` | → | `in_communication` | Active document exchange |
| `Signed EOI and NDA` | → | `signed_eoi_nda` | EOI/NDA completed |
| `Made Reservation` | → | `deposit_10pct` | Reservation ≈ deposit stage |
| `Contract Negotiation` | → | `contract` | Active contract discussion |
| `Contract Negotiations Finalized` | → | `contract` | Same stage, later substatus |
| `Contract Signed` | → | `completed` | Deal closed |
| NULL or empty | → | `open` | Default for missing data |
**Important:** The NocoDB schema uses 8 stage names while the new schema uses 8 stage codes. "Contract Negotiation" and "Contract Negotiations Finalized" both map to `contract` — distinguish them via `date_contract_sent` (null = negotiation, not null = finalized).
---
## Table 4: NocoDB Interests → `interest_notes`
The `Extra Comments` field on each Interest becomes an `interest_notes` record if it contains content.
| Source | → | New Table.Column | Transformation |
| ------------------------------- | --- | ---------------------------- | --------------------------------------------- |
| `Extra Comments` (if non-empty) | → | `interest_notes.content` | Trim. Prefix with `"[Migrated from NocoDB] "` |
| — | → | `interest_notes.author_id` | System migration user ID |
| — | → | `interest_notes.interest_id` | From interest UUID map |
| — | → | `interest_notes.is_locked` | `true` (historical, non-editable) |
| `Created At` | → | `interest_notes.created_at` | From parent interest `Created At` |
---
## Table 5: NocoDB Interests → `documents` + `document_signers`
Interests with EOI/contract document references create `documents` and `document_signers` records.
**Trigger:** Any interest where `EOI Document` is not null, or `LOI-NDA Document` is not null, or `documensoID` is not null.
| NocoDB Interests Field | → | New Table.Column | Transformation |
| ---------------------- | --- | ------------------------- | ---------------------------------------------------------------------------------- |
| `EOI Document` | → | `documents.file_id` | Match to MinIO file → `files` table entry |
| `LOI-NDA Document` | → | `documents.file_id` | Match to MinIO file → `files` table entry |
| `documensoID` | → | `documents.documenso_id` | Direct copy |
| `EOI Status` | → | `documents.status` | Map: `"Signed"``"completed"`, `"Pending"``"sent"`, `"Expired"``"expired"` |
| — | → | `documents.document_type` | `"eoi"` for EOI documents, `"nda"` for LOI-NDA documents |
| — | → | `documents.port_id` | Port Nimara UUID |
| — | → | `documents.interest_id` | From interest UUID map |
| — | → | `documents.client_id` | From client UUID map |
| — | → | `documents.title` | Generate: `"EOI - {client_name} - {berth_number}"` |
| — | → | `documents.created_by` | System migration user ID |
**Signing links → `document_signers`:**
NocoDB stores up to 9 signing link fields per interest. These map to `document_signers` entries.
| NocoDB Field Pattern | → | `document_signers` Column | Transformation |
| ------------------------- | --- | ------------------------- | ------------------------------------------------------- |
| Signing link fields (1-9) | → | `signer_email` | Extract email from Documenso signing URL if available |
| — | → | `signer_role` | `"client"` (default for most), infer from context |
| — | → | `signing_order` | 1-based index of the link field |
| — | → | `status` | If EOI Status = "Signed" → `"signed"`, else `"pending"` |
| — | → | `signing_url` | Direct copy of Documenso signing link URL |
---
## Table 6: NocoDB Berths → `berths`
One NocoDB Berth row = one new `berths` row. Berths have cleaner data than Interests.
| NocoDB Berths Field | Type (NocoDB) | → | New Table.Column | Type (PG) | Transformation |
| -------------------------- | ------------- | --- | ------------------------------------------ | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `Id` | number | → | — | — | Store in berth ID map: NocoDB ID → new UUID |
| `Mooring Number` | string | → | `berths.mooring_number` | TEXT | Direct copy, trim |
| `Area` | string (enum) | → | `berths.area` | TEXT | Direct copy. NocoDB enum A-E; new system supports A-G. Validate. |
| `Status` | string (enum) | → | `berths.status` | TEXT | Map: `"Available"``"available"`, `"UnderOffer"``"under_offer"`, `"Sold"``"sold"`. Add missing: `"Reserved"``"under_offer"`, `"Not Available"``"sold"`, `"Draft"``"available"`. |
| `status_override_mode` | boolean | → | — | — | Not migrated (new system handles status rules differently) |
| `Nominal Boat Size` | string | → | `berths.nominal_boat_size` | TEXT | Direct copy (already in ft format) |
| `Nominal Boat Size_metric` | string | → | `berths.nominal_boat_size_m` | TEXT | Direct copy (m format) |
| `Length` | number | → | `berths.length_ft` | NUMERIC | Direct copy (NocoDB berths store as numbers, not strings) |
| `Length_metric` | number | → | `berths.length_m` | NUMERIC | Direct copy. If missing, calculate: `length_ft / 3.28084` |
| `Width` | number | → | `berths.width_ft` | NUMERIC | Direct copy |
| `Width_metric` | number | → | `berths.width_m` | NUMERIC | Direct copy. If missing, calculate. |
| `Draft` | number | → | `berths.draft_ft` | NUMERIC | Direct copy |
| `Draft_metric` | number | → | `berths.draft_m` | NUMERIC | Direct copy. If missing, calculate. |
| `Water Depth` | number | → | `berths.water_depth` | NUMERIC | Direct copy (ft) |
| `Water Depth_metric` | number | → | `berths.water_depth_m` | NUMERIC | Direct copy. If missing, calculate. |
| `Side Pontoon` | string (enum) | → | `berths.side_pontoon` | TEXT | Lowercase: `"Port"``"port"`, `"Starboard"``"starboard"`, `"Both"``"both"` |
| `Power Capacity` | string | → | `berths.power_capacity` | TEXT | Direct copy |
| `Voltage` | string | → | `berths.voltage` | TEXT | Direct copy |
| `Mooring Type` | string (enum) | → | `berths.mooring_type` | TEXT | Lowercase snake_case: `"Med Mooring"``"med_mooring"`, `"Alongside"``"alongside"` |
| `Cleat Type/Capacity` | string | → | `berths.cleat_type` + `cleat_capacity` | TEXT | Split on delimiter if combined. NocoDB may store as single field or separate enum. |
| `Bollard Type/Capacity` | string | → | `berths.bollard_type` + `bollard_capacity` | TEXT | Same split logic |
| `Bow Facing` | string | → | `berths.bow_facing` | TEXT | Direct copy, lowercase |
| `Access` | string (enum) | → | `berths.access` | TEXT | Lowercase: `"Walk On"``"walk_on"`, `"Dinghy"``"dinghy"` |
| `Price` | number | → | `berths.price` | NUMERIC | Direct copy |
| — | — | → | `berths.price_currency` | TEXT | Default `"USD"` |
| — | — | → | `berths.tenure_type` | TEXT | Default `"permanent"` (most Port Nimara berths are permanent tenure) |
| `Created At` | datetime | → | `berths.created_at` | TIMESTAMPTZ | Direct copy |
| `Updated At` | datetime | → | `berths.updated_at` | TIMESTAMPTZ | Direct copy |
| — | — | → | `berths.port_id` | UUID | Constant: Port Nimara UUID |
| — | — | → | `berths.berth_approved` | BOOLEAN | `true` (all existing berths are approved) |
| `Interested Parties` | link field | → | — | — | Not migrated directly; relationship is rebuilt via `interests.berth_id` |
### Berth Area Expansion
NocoDB BerthArea enum: `A | B | C | D | E`
New system supports: `A | B | C | D | E | F | G`
Berths in areas F and G exist in production but the NocoDB enum doesn't define them. The extraction script should treat area as a free-text field and not validate against the old enum.
### Berth Status Mapping
| NocoDB Status | → | New Status | Notes |
| --------------- | --- | ------------- | -------------------------------------------- |
| `Available` | → | `available` | |
| `UnderOffer` | → | `under_offer` | |
| `Sold` | → | `sold` | |
| `Reserved` | → | `under_offer` | NocoDB may have this in data but not in enum |
| `Not Available` | → | `sold` | Treat as sold/occupied |
| `Draft` | → | `available` | Draft berths become available |
| NULL or empty | → | `available` | Default |
---
## Table 7: NocoDB Berths → `berth_map_data`
If the NocoDB berths table or a related data source contains SVG positioning data for the marina map, extract it.
| Source | → | New Table.Column | Transformation |
| ------------------------- | --- | -------------------------- | ------------------- |
| SVG path data (if exists) | → | `berth_map_data.svg_path` | Direct copy |
| X coordinate | → | `berth_map_data.x` | Direct copy |
| Y coordinate | → | `berth_map_data.y` | Direct copy |
| Transform data | → | `berth_map_data.transform` | Direct copy |
| — | → | `berth_map_data.berth_id` | From berth UUID map |
**Note:** The current berth map may be rendered from a static SVG with hardcoded positions. If no per-berth coordinate data exists in NocoDB, the `berth_map_data` table will be populated manually post-migration or via the L4 berth spec import tool.
---
## Table 8: NocoDB Expenses → `expenses`
One NocoDB Expense row = one new `expenses` row.
| NocoDB Expenses Field | Type (NocoDB) | → | New Table.Column | Type (PG) | Transformation |
| --------------------- | ------------------ | --- | ----------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `Id` | number | → | — | — | Store in expense ID map: NocoDB ID → new UUID |
| `Establishment Name` | string | → | `expenses.establishment_name` | TEXT | Trim |
| `Price` | string | → | `expenses.amount` | NUMERIC | `parseFloat()` — NocoDB stores price as STRING. Strip currency symbols ($, €, £). Strip commas. Parse to number. |
| `currency` | string | → | `expenses.currency` | TEXT | Uppercase: `"usd"``"USD"`, `"eur"``"EUR"`. Default `"USD"` if missing. |
| — | — | → | `expenses.amount_usd` | NUMERIC | If currency ≠ USD, convert using historical exchange rate for the expense date. Otherwise same as amount. |
| `Payment Method` | string (enum) | → | `expenses.payment_method` | TEXT | Lowercase snake_case: `"Credit Card"``"credit_card"`, `"Cash"``"cash"`, `"Bank Transfer"``"bank_transfer"` |
| `Category` | string (enum) | → | `expenses.category` | TEXT | Lowercase snake_case: `"Fuel"``"fuel"`, `"Maintenance"``"maintenance"`, `"Office Supplies"``"office_supplies"`, etc. |
| `Payer` | string | → | `expenses.payer` | TEXT | Trim |
| `Time` | datetime | → | `expenses.expense_date` | TIMESTAMPTZ | Parse date string → ISO 8601 |
| `Contents` | string | → | `expenses.description` | TEXT | Trim |
| `Receipt` | file attachment(s) | → | `expenses.receipt_file_ids` | UUID[] | For each receipt file: create `files` table entry, store MinIO path. Collect UUIDs into array. |
| `Paid` | boolean | → | `expenses.payment_status` | TEXT | `true``"paid"`, `false``"unpaid"`, NULL → `"unpaid"` |
| `payment_date` | date (optional) | → | `expenses.payment_date` | DATE | Direct copy if present |
| `payment_reference` | string (optional) | → | `expenses.payment_reference` | TEXT | Direct copy if present |
| `payment_notes` | string (optional) | → | `expenses.payment_notes` | TEXT | Direct copy if present |
| `CreatedAt` | datetime | → | `expenses.created_at` | TIMESTAMPTZ | Direct copy. Note: NocoDB uses `CreatedAt` (camelCase), not `Created At` (spaced). |
| `UpdatedAt` | datetime | → | `expenses.updated_at` | TIMESTAMPTZ | Direct copy |
| — | — | → | `expenses.port_id` | UUID | Constant: Port Nimara UUID |
| — | — | → | `expenses.created_by` | TEXT | System migration user ID |
| — | — | → | `expenses.archived_at` | TIMESTAMPTZ | NULL |
**Price parsing helper:**
```typescript
function parsePrice(value: string | null): number | null {
if (!value || value.trim() === '') return null;
// Strip currency symbols and thousand separators
const cleaned = value.replace(/[$€£¥,\s]/g, '').trim();
const num = parseFloat(cleaned);
return isNaN(num) ? null : Math.round(num * 100) / 100;
}
```
**Expense category normalization:**
```typescript
const CATEGORY_MAP: Record<string, string> = {
Fuel: 'fuel',
Maintenance: 'maintenance',
'Office Supplies': 'office_supplies',
Utilities: 'utilities',
'Marina Operations': 'marina_operations',
Travel: 'travel',
Equipment: 'equipment',
Insurance: 'insurance',
Legal: 'legal',
Other: 'other',
};
function normalizeCategory(value: string | null): string {
if (!value) return 'other';
return CATEGORY_MAP[value.trim()] ?? value.toLowerCase().replace(/\s+/g, '_');
}
```
---
## Table 9: NocoDB Invoices → `invoices`
| NocoDB Invoices Field | Type (NocoDB) | → | New Table.Column | Type (PG) | Transformation |
| --------------------- | ------------- | --- | -------------------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `Id` | number | → | — | — | Store in invoice ID map |
| Invoice number | string | → | `invoices.invoice_number` | TEXT | Preserve existing numbering (format: `INV-YYYYMM-XYZ`). Verify uniqueness. |
| Client reference | string/link | → | `invoices.client_name` | TEXT | Resolve client name from interest/client data |
| Client email | string | → | `invoices.billing_email` | TEXT | From client contacts |
| Client address | string | → | `invoices.billing_address` | TEXT | From client contacts |
| Due date | date | → | `invoices.due_date` | DATE | Direct copy |
| Currency | string | → | `invoices.currency` | TEXT | Uppercase. Default `"USD"` |
| Subtotal | number | → | `invoices.subtotal` | NUMERIC | Direct copy or calculate from line items |
| Discount | number | → | `invoices.discount_pct` + `discount_amount` | NUMERIC | Parse from invoice data |
| Fee | number | → | `invoices.fee_pct` + `fee_amount` | NUMERIC | Parse from invoice data |
| Total | number | → | `invoices.total` | NUMERIC | Direct copy or calculate: `subtotal - discount + fee` |
| Status | string | → | `invoices.status` | TEXT | Map: `"Draft"``"draft"`, `"Sent"``"sent"`, `"Paid"``"paid"`, `"Overdue"``"overdue"`, `"Cancelled"``"cancelled"` |
| Payment info | object | → | `invoices.payment_status`, `payment_date`, `payment_method`, `payment_reference` | mixed | Extract from payment fields |
| PDF reference | file | → | `invoices.pdf_file_id` | UUID | Create `files` table entry for stored PDF |
| Notes | string | → | `invoices.notes` | TEXT | Trim |
| Created at | datetime | → | `invoices.created_at` | TIMESTAMPTZ | Direct copy |
| — | — | → | `invoices.port_id` | UUID | Constant: Port Nimara UUID |
| — | — | → | `invoices.created_by` | TEXT | System migration user ID |
| — | — | → | `invoices.payment_terms` | TEXT | Default `"net30"` (can be refined post-migration) |
### Invoice → Line Items
NocoDB invoices may store line item data inline or as linked records. Each expense linked to an invoice becomes an `invoice_line_items` entry.
| Source | → | New Table.Column | Transformation |
| ------------------- | --- | -------------------------------- | ------------------------------------------------------ |
| Expense description | → | `invoice_line_items.description` | From linked expense `Contents` / `Establishment Name` |
| Quantity | → | `invoice_line_items.quantity` | Default `1` (NocoDB doesn't track quantity separately) |
| Amount | → | `invoice_line_items.unit_price` | From linked expense `Price` |
| — | → | `invoice_line_items.total` | `quantity × unit_price` |
| — | → | `invoice_line_items.sort_order` | Auto-increment from 0 |
### Invoice ↔ Expense Junction
For each expense linked to an invoice in NocoDB, create an `invoice_expenses` junction record:
| Source | → | New Table | Transformation |
| ----------------- | --- | ----------------------------- | --------------------- |
| Invoice NocoDB ID | → | `invoice_expenses.invoice_id` | From invoice UUID map |
| Expense NocoDB ID | → | `invoice_expenses.expense_id` | From expense UUID map |
---
## Table 10: MinIO Files → `files`
Scan MinIO storage and create `files` table entries. Map files to clients where possible.
| MinIO Metadata | → | New Table.Column | Transformation |
| ------------------ | --- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| Object key (path) | → | `files.storage_path` | Direct copy (current path). Will be reorganized post-load. |
| — | → | `files.storage_bucket` | `"crm-files"` |
| Filename from path | → | `files.filename` | Extract from object key |
| Original filename | → | `files.original_name` | Same as filename (original names may not be preserved in MinIO) |
| Content-Type | → | `files.mime_type` | From MinIO object metadata |
| Content-Length | → | `files.size_bytes` | From MinIO object metadata |
| Client association | → | `files.client_id` | AI-assisted matching: filename patterns, folder structure, PDF text extraction. Log unmatched to CSV. |
| File category | → | `files.category` | Infer from filename/path: `*eoi*``"eoi"`, `*contract*``"contract"`, `*receipt*``"receipt"`, `*nda*``"eoi"`, else `"misc"` |
| — | → | `files.port_id` | Port Nimara UUID |
| — | → | `files.uploaded_by` | System migration user ID |
**New MinIO path structure (post-migration reorganization):**
```
{portSlug}/clients/{clientId}/eoi/filename.pdf
{portSlug}/clients/{clientId}/contracts/filename.pdf
{portSlug}/clients/{clientId}/receipts/filename.jpg
{portSlug}/clients/{clientId}/correspondence/filename.pdf
{portSlug}/clients/{clientId}/misc/filename.pdf
{portSlug}/invoices/{invoiceId}/invoice.pdf
{portSlug}/expenses/{expenseId}/receipt.jpg
```
---
## Data Quality Issues & Fixes
### Issue 1: String Dimensions on Interests
**Problem:** Interest table stores vessel dimensions (Length, Width, Depth) as strings. Values may include unit suffixes ("45ft", "12m"), be empty strings, or contain non-numeric characters.
**Fix:** `parseDimension()` helper (defined above) handles all cases. Log any unparseable values to `migration-warnings.csv`.
### Issue 2: Mixed Naming Conventions
**Problem:** NocoDB fields use inconsistent naming: `"Full Name"` (Title Spaced), `"documensoID"` (camelCase), `"status_override_mode"` (snake_case), `CreatedAt` (PascalCase on Expenses vs `Created At` on Interests).
**Fix:** Access each field by its exact NocoDB name. The extraction layer handles the naming; transformation maps to consistent snake_case.
### Issue 3: LOI → EOI Terminology
**Problem:** The codebase has remnants of "LOI" (Letter of Intent) terminology that was renamed to "EOI" (Expression of Interest). Fields like `LOI-NDA Document` and `Time LOI Sent` actually refer to EOI documents.
**Fix:** Map all LOI references to EOI in the new schema. `LOI-NDA Document``documents` with `document_type = "eoi"`. `Time LOI Sent``interests.date_eoi_signed`.
### Issue 4: Incomplete Enums
**Problem:** BerthStatus enum (`Available | UnderOffer | Sold`) is missing `Reserved`, `Not Available`, `Draft` which may exist in production data. BerthArea enum covers A-E but production has F and G.
**Fix:** Status mapping table handles all known values (see Table 6). Area is treated as free text. Any unrecognized values are logged and defaulted.
### Issue 5: Price as String on Expenses
**Problem:** Expense `Price` field is typed as `string` in the NocoDB interface. Values may contain currency symbols, commas, or be formatted inconsistently.
**Fix:** `parsePrice()` helper strips non-numeric characters and parses to NUMERIC. NULL values logged.
### Issue 6: Berth References
**Problem:** Interests reference berths via both `Berths_id` (numeric link field) and `Berth Number` (string display field). These may be inconsistent.
**Fix:** Primary resolution via `Berths_id` → berth UUID map. Fallback: match `Berth Number` string against `berths.mooring_number`. If both fail, set `berth_id = NULL` and log.
### Issue 7: Invoice Number Collision Risk
**Problem:** The current `generateInvoiceNumber()` function uses format `INV-YYYYMM-XYZ` with a random 3-char suffix, which has collision risk.
**Fix:** During migration, preserve all existing invoice numbers exactly. Post-migration, the new system uses a sequential counter per port per month, eliminating collision risk.
### Issue 8: Duplicate Clients
**Problem:** The same person may appear as multiple NocoDB Interest rows with slightly different name spellings or different email addresses.
**Fix:** Three-pass dedup:
1. Exact email match (case-insensitive) → same client
2. Fuzzy name match (Levenshtein distance ≤ 2 on normalized name) + same residence → candidate merge (log for manual review)
3. Remaining → create as separate clients (can be merged manually in new system)
All fuzzy-match candidates logged to `dedup-candidates.csv` for Matt's review before committing.
---
## Synthetic Records (Created During Migration)
These records don't exist in NocoDB but are generated to provide a complete dataset in the new system:
| Record Type | Source | Logic |
| ------------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `audit_logs` (historical) | All migrated records | For each migrated entity, create a synthetic `action: "create"` audit log entry with `created_at` matching the original record's creation date, `user_id` = system migration user, `metadata = { "source": "nocodb_migration" }` |
| `tags` | Lead categories, sources | Create tags for common categories: "Hot Lead", "Website Lead", "Referral", "Broker", etc. |
| `client_tags` | Interest `Lead Category` | Auto-tag clients based on their highest lead category across all interests |
| `interest_tags` | Interest `Source` | Auto-tag interests by source |
| `client_notes` | Dedup merge info | When two interests are merged into one client, create a note documenting the merge |
---
## ID Mapping Tables (In-Memory During Migration)
The migration script maintains these maps for cross-referencing:
```typescript
interface MigrationMaps {
// NocoDB numeric ID → new PostgreSQL UUID
clientMap: Map<string, string>; // emailOrNameKey → client UUID
berthMap: Map<number, string>; // NocoDB Berth Id → berth UUID
interestMap: Map<number, string>; // NocoDB Interest Id → interest UUID
expenseMap: Map<number, string>; // NocoDB Expense Id → expense UUID
invoiceMap: Map<number, string>; // NocoDB Invoice Id → invoice UUID
// Reverse lookups
interestToClient: Map<number, string>; // NocoDB Interest Id → client UUID
berthNumberToId: Map<string, string>; // Mooring number string → berth UUID
}
```
---
## Validation Checklist
After migration completes, run these validation checks:
| Check | Query | Expected |
| --------------------- | ------------------------------------------------------------------------------------------------------- | --------------------------------------------- |
| Client count | `SELECT COUNT(*) FROM clients` | ≤ NocoDB interest count (dedup reduces) |
| Contact count | `SELECT COUNT(*) FROM client_contacts` | ≥ client count (most clients have ≥1 contact) |
| Interest count | `SELECT COUNT(*) FROM interests` | = NocoDB interest count (1:1) |
| Berth count | `SELECT COUNT(*) FROM berths` | = NocoDB berth count (1:1) |
| Expense count | `SELECT COUNT(*) FROM expenses` | = NocoDB expense count (1:1) |
| Invoice count | `SELECT COUNT(*) FROM invoices` | = NocoDB invoice count (1:1) |
| Orphan interests | `SELECT COUNT(*) FROM interests WHERE client_id NOT IN (SELECT id FROM clients)` | 0 |
| Orphan contacts | `SELECT COUNT(*) FROM client_contacts WHERE client_id NOT IN (SELECT id FROM clients)` | 0 |
| NULL berth refs | `SELECT COUNT(*) FROM interests WHERE berth_id IS NOT NULL AND berth_id NOT IN (SELECT id FROM berths)` | 0 |
| Invoice-expense links | `SELECT COUNT(*) FROM invoice_expenses WHERE expense_id NOT IN (SELECT id FROM expenses)` | 0 |
| File records | `SELECT COUNT(*) FROM files` | ≥ (receipt files + EOI docs + contract docs) |
| Pipeline distribution | `SELECT pipeline_stage, COUNT(*) FROM interests GROUP BY 1` | Reasonable distribution, no unexpected NULLs |
| Duplicate emails | `SELECT value, COUNT(*) FROM client_contacts WHERE channel='email' GROUP BY 1 HAVING COUNT(*) > 1` | Review each — may be valid (shared email) |
| Amount sanity | `SELECT COUNT(*) FROM expenses WHERE amount <= 0` | 0 (or review outliers) |
---
## Migration Script Configuration
```typescript
// scripts/migrate-nocodb/config.ts
export const MIGRATION_CONFIG = {
// NocoDB connection
nocodb: {
apiUrl: process.env.NOCODB_API_URL, // e.g., 'https://nocodb.portnimara.com'
token: process.env.NOCODB_AUTH_TOKEN,
projectId: process.env.NOCODB_PROJECT_ID,
tables: {
interests: 'mbs9hjauug4eseo',
berths: 'mczgos9hr3oa9qc',
expenses: 'mxfcefkk4dqs6uq',
invoices: 'mvyvz0lpc30p01s',
},
},
// Target PostgreSQL
postgres: {
connectionString: process.env.DATABASE_URL,
},
// MinIO
minio: {
endpoint: process.env.MINIO_ENDPOINT,
accessKey: process.env.MINIO_ACCESS_KEY,
secretKey: process.env.MINIO_SECRET_KEY,
bucket: 'crm-files',
},
// Migration defaults
defaults: {
portId: process.env.PORT_NIMARA_UUID, // seeded in L0
portSlug: 'port-nimara',
migrationUserId: 'system-migration',
defaultCurrency: 'USD',
defaultTimezone: 'America/Anguilla',
},
// Deduplication thresholds
dedup: {
nameLevenshteinThreshold: 2, // max edit distance for fuzzy name match
requireResidenceMatch: true, // secondary match requires same residence
logCandidates: true, // write fuzzy candidates to CSV
},
// Output files
output: {
warningsFile: 'migration-warnings.csv', // unparseable values, unexpected data
dedupCandidatesFile: 'dedup-candidates.csv', // fuzzy match candidates for review
unmatchedFilesFile: 'unmatched-files.csv', // MinIO files that couldn't be linked to a client
validationReport: 'validation-report.json', // final counts and check results
},
};
```
---
## Post-Migration Manual Tasks
| Task | Owner | When |
| ------------------------------------------------------------------------------- | ----- | ------------------------- |
| Review `dedup-candidates.csv` — approve or reject client merges | Matt | Saturday morning |
| Review `unmatched-files.csv` — manually assign files to clients or mark as misc | Matt | Saturday morning |
| Review `migration-warnings.csv` — fix any data issues | Matt | Saturday morning |
| Spot-check 5 high-profile clients — verify all data migrated correctly | Matt | Saturday after validation |
| Spot-check 5 berths — verify dimensions, status, map data | Matt | Saturday after validation |
| Spot-check 3 invoices — verify amounts, line items, linked expenses | Matt | Saturday after validation |
| Create user accounts and assign roles in new system | Matt | Saturday afternoon |
| Configure SMTP (Poste.io) connection | Matt | Saturday afternoon |
| Test Documenso API connection | Matt | Saturday afternoon |
| Populate `berth_map_data` if not extracted from NocoDB | Matt | Post-migration week |

328
PROGRESS.md Normal file
View File

@@ -0,0 +1,328 @@
# Port Nimara CRM - Project Progress
**Last updated:** 2026-03-26
**Repo:** https://code.letsbe.solutions/letsbe/pn-new-crm
**Domain:** pn.letsbe.solutions
**Stack:** Next.js 15 + TypeScript + Tailwind + Drizzle ORM + PostgreSQL + Redis + BullMQ + MinIO + Socket.io
---
## What's Been Built (Layers 0-4 Complete)
### Layer 0: Foundation (DONE)
- [x] Next.js 15 project scaffold with TypeScript strict mode
- [x] Tailwind CSS + shadcn/ui component library (full set in `src/components/ui/`)
- [x] PostgreSQL via Drizzle ORM - full schema across 12 schema files
- `berths.ts`, `clients.ts`, `documents.ts`, `email.ts`, `financial.ts`, `interests.ts`, `operations.ts`, `ports.ts`, `relations.ts`, `system.ts`, `users.ts` + `index.ts`
- [x] Better Auth integration with multi-port middleware (`src/middleware.ts`)
- [x] Redis connection (`src/lib/redis.ts`)
- [x] BullMQ queue system with 8 workers: `document-signing.ts`, `email-sync.ts`, `email.ts`, `import.ts`, `maintenance.ts`, `notifications.ts`, `reports.ts`, `webhooks.ts`
- [x] MinIO file storage service (`src/lib/services/storage.ts`, `files.ts`)
- [x] Socket.io real-time server (`src/lib/socket/server.ts`, `events.ts`)
- [x] Docker setup: `Dockerfile` (multi-stage app), `Dockerfile.worker`, `Dockerfile.dev`, `docker-compose.yml`, `docker-compose.dev.yml`, `docker-compose.prod.yml`
- [x] Dashboard layout shell with sidebar, port switcher, navigation (`src/app/(dashboard)/layout.tsx`)
- [x] Auth pages: login, reset-password, set-password
- [x] Seed script (`src/lib/db/seed.ts`)
- [x] ESLint + Prettier + Husky + lint-staged
- [x] Health check endpoint (`/api/health`)
- [x] Rate limiting (`src/lib/rate-limit.ts`)
- [x] Encryption utilities (`src/lib/utils/encryption.ts`)
- [x] Zod validators for all entities (17 validator files in `src/lib/validators/`)
### Layer 1: Core CRUD (DONE)
- [x] **Client Management** - Full CRUD, contacts, relationships, notes, tags, duplicate detection
- Pages: list (`/clients`), detail (`/clients/[clientId]`)
- API: full REST at `/api/v1/clients/...` (CRUD, contacts, notes, relationships, tags, restore, export-pdf, options)
- Service: `clients.service.ts`
- Components: `src/components/clients/`
- [x] **Berth Management** - Full CRUD, specs, tags, status management, waiting list, maintenance
- Pages: list (`/berths`), detail (`/berths/[berthId]`)
- API: full REST at `/api/v1/berths/...` (CRUD, status, tags, waiting-list, maintenance, export-pdf, options)
- Service: `berths.service.ts`, `berth-rules-engine.ts`
- Components: `src/components/berths/`
- [x] **Auth Admin** - User management, role builder, port management
- Pages: admin/users, admin/roles, admin/ports, admin/onboarding
- API: `/api/v1/admin/users/`, `/api/v1/admin/roles/`
### Layer 2: Business Workflows (DONE)
- [x] **Interest/Pipeline Management** - CRUD, pipeline stages, berth linking, recommendations, scoring, timeline
- Pages: list (`/interests`), detail (`/interests/[interestId]`)
- API: full REST at `/api/v1/interests/...` (CRUD, stage, berth, notes, tags, recommendations, timeline, restore, export-pdf)
- Services: `interests.service.ts`, `interest-scoring.service.ts`, `recommendations.ts`
- Components: `src/components/interests/`
- [x] **Documents & EOI** - Documenso integration, 3-party signing, webhook receiver, document templates
- Pages: `/documents`
- API: `/api/v1/documents/...`, `/api/v1/document-templates/...`, `/api/webhooks/documenso/`
- Services: `documents.service.ts`, `document-templates.service.ts`, `document-templates.ts`, `documenso-client.ts`, `documenso-webhook.ts`, `document-reminders.ts`
- Components: `src/components/documents/`
- [x] **Expenses & Invoices** - CRUD, receipt scanner (AI), invoice generation, payment tracking, CSV/PDF export
- Pages: `/expenses`, `/expenses/[id]`, `/expenses/scan`, `/invoices`, `/invoices/[id]`, `/invoices/new`
- API: `/api/v1/expenses/...` (CRUD, scan-receipt, export CSV/PDF/parent-company), `/api/v1/invoices/...` (CRUD, generate-pdf, payment, send)
- Services: `expenses.ts`, `invoices.ts`, `expense-export.ts`, `receipt-scanner.ts`
- Components: `src/components/expenses/`, `src/components/invoices/`
- [x] **File Management** - MinIO integration, folder management, upload/download/preview
- API: `/api/v1/files/...` (CRUD, upload, download, preview, folders)
- Components: `src/components/files/`
- Store: `src/stores/file-browser-store.ts`
### Layer 3: Operations (DONE)
- [x] **Email System** - SMTP/IMAP, accounts, thread viewer, composer, AI drafts
- Pages: `/email`
- API: `/api/v1/email/...` (accounts, sync, threads, compose), `/api/v1/ai/email-draft/...`
- Services: `email-accounts.service.ts`, `email-compose.service.ts`, `email-draft.service.ts`, `email-threads.service.ts`
- Components: `src/components/email/`
- [x] **Notifications** - Notification center, preferences, real-time via Socket.io
- Pages: (integrated in layout)
- API: `/api/v1/notifications/...` (CRUD, preferences, read-all, unread-count)
- Service: `notifications.service.ts`
- Components: `src/components/notifications/`
- [x] **Reminders** - Reminder pages
- Pages: `/reminders`
- [x] **Search** - Global search (inline in topbar), saved views
- API: `/api/v1/search/...`, `/api/v1/saved-views/...`
- Service: `search.service.ts`, `saved-views.service.ts`
- Components: `src/components/search/`
- [x] **Dashboard & Analytics** - KPIs, pipeline summary, revenue forecast, activity feed
- Pages: `/[portSlug]` (dashboard)
- API: `/api/v1/dashboard/...` (kpis, pipeline, forecast, activity)
- Service: `dashboard.service.ts`
- Components: `src/components/dashboard/`
### Layer 4: Advanced Features (DONE)
- [x] **Webhooks** - CRUD, delivery tracking, event mapping, test delivery, secret regeneration
- Pages: admin/webhooks
- API: `/api/v1/admin/webhooks/...`
- Services: `webhooks.service.ts`, `webhook-dispatch.ts`, `webhook-event-map.ts`
- [x] **Document Templates** - Template management with versioning, preview, rollback
- Pages: admin/templates
- API: `/api/v1/admin/templates/...`
- Service: `document-templates.service.ts`
- [x] **AI Features** - Interest scoring, AI email drafts, receipt scanning
- API: `/api/v1/ai/...`
- Uses OpenAI SDK
- [x] **Custom Fields** - Dynamic custom fields for entities
- Pages: admin/custom-fields
- API: `/api/v1/admin/custom-fields/...`, `/api/v1/custom-fields/...`
- Service: `custom-fields.service.ts`
- [x] **Reports** - Report generation with download, scheduled reports
- Pages: `/reports`, admin/reports
- API: `/api/v1/reports/...`
- Services: `reports.service.ts`, `report-generators.ts`
- [x] **Admin Tools** - Audit log, backup, import, settings, monitoring, forms, tags
- Pages: full admin section
- API: `/api/v1/admin/...` (connections, errors, health, queues)
- Service: `system-monitoring.service.ts`
- [x] **Client Portal** (separate Nuxt app in `client-portal/`)
- Pages: portal login, verify, dashboard, documents, interests, invoices
- API: `/api/portal/...` (auth, dashboard, documents, interests, invoices)
- Service: `portal.service.ts`
- [x] **Currency** - Multi-currency with conversion rates
- API: `/api/v1/currency/...`
- Service: `currency.ts`
- [x] **Tags** - Cross-entity tagging system
- API: `/api/v1/tags/...`
- Service: `tags.service.ts`
- [x] **Record Export** - PDF export for clients, berths, interests
- Service: `record-export.ts`
- [x] **Settings & Feature Flags**
- Pages: `/settings`
- API: `/api/v1/settings/feature-flag/`
### Testing (Layer 5 - Partially Done)
- [x] 25 E2E smoke tests (Playwright) covering all major flows
- [x] 5 integration tests (Vitest): CRUD audit, custom fields, pipeline transitions, port scoping, webhook delivery
- [x] 15 unit tests (Vitest): validators, encryption, security, audit, interest scoring, query plans, webhook events, etc.
- [x] Test helpers and factories (`tests/helpers/factories.ts`)
- [x] Playwright config with global setup
### Infrastructure (DONE)
- [x] Gitea Actions CI/CD workflow (`.gitea/workflows/build.yml`)
- Lint + type-check on PR
- Docker build + push to Gitea Container Registry on main
- SSH deploy to production server
- [x] Production docker-compose (`docker-compose.prod.yml`) using registry images
- [x] Nginx config for Docker (`nginx/nginx.conf`)
- [x] Nginx config for host/certbot (`nginx/pn.letsbe.solutions.conf`)
- [x] Environment variable template (`.env.example`)
---
## Project Stats
| Metric | Count |
| -------------------- | ----- |
| TypeScript/TSX files | 461 |
| React components | 133 |
| API route handlers | 120+ |
| DB schema files | 12 |
| Service files | 38 |
| Validator files | 17 |
| BullMQ workers | 8 |
| E2E tests | 25 |
| Integration tests | 5 |
| Unit tests | 15 |
| Pages (dashboard) | 37 |
| Pages (portal) | 6 |
| Pages (auth) | 3 |
---
## What's Left To Do
### Priority 1: Deployment & Go-Live
- [ ] Push to Gitea and verify CI/CD pipeline builds
- [ ] Set up server: install Docker, nginx, configure DNS for `pn.letsbe.solutions`
- [ ] Run `certbot --nginx -d pn.letsbe.solutions` for SSL
- [ ] Configure production `.env` on server
- [ ] Run database migrations (`pnpm db:push`)
- [ ] Run seed data (`pnpm db:seed`)
- [ ] Verify all services start and health check passes
### Priority 2: Testing & Hardening (Layer 5 - Remaining)
- [ ] Run full E2E test suite against deployed instance and fix failures
- [ ] Run full unit/integration test suite and fix failures
- [ ] Security hardening pass (review SECURITY-GUIDELINES.md checklist)
- [ ] Performance audit (Core Web Vitals, API response times)
- [ ] Load testing with realistic data volumes
### Priority 3: Migration (Layer 6)
- [ ] NocoDB data export scripts
- [ ] Data transformation/mapping (see `NOCODB-MIGRATION-MAPPING.md`)
- [ ] PostgreSQL data import
- [ ] MinIO file reorganization
- [ ] Smoke test migrated data
- [ ] DNS/proxy switchover from old system
- [ ] Old system decommission
### Priority 4: Polish & Nice-to-Haves
- [ ] Dark mode toggle (infrastructure exists via `next-themes`)
- [ ] Mobile responsive polish pass
- [ ] Google Calendar integration for reminders
- [ ] Bulk operations UI refinement
- [ ] Email template system (MJML-based)
- [ ] Client portal deployment as separate service or subdomain
---
## Key Architecture Decisions
| Decision | Choice | Rationale |
| ------------------ | ------------------------- | ------------------------------------------------ |
| Framework | Next.js 15 (App Router) | Server Components, streaming, typed routes |
| ORM | Drizzle | Type-safe, lightweight, PostgreSQL-native |
| Auth | Better Auth | Multi-tenant, role-based, session management |
| State | Zustand + React Query | Zustand for UI state, RQ for server state |
| Queue | BullMQ + Redis | Background jobs, email sync, reports |
| Storage | MinIO | S3-compatible, self-hosted, encrypted |
| Real-time | Socket.io + Redis adapter | Notifications, live updates |
| UI | shadcn/ui + Tailwind | Accessible, customizable components |
| Signing | Documenso | Self-hosted document signing |
| AI | OpenAI | Receipt scanning, interest scoring, email drafts |
| Container Registry | Gitea built-in | Same host as git, simpler auth |
---
## File Structure Overview
```
src/
app/
(auth)/ # Login, reset-password, set-password
(dashboard)/ # Main CRM - all [portSlug] scoped pages
(portal)/ # Client-facing portal
api/ # All API routes (v1/, portal/, webhooks/, auth/, health/)
components/
admin/ # Admin panel components
berths/ # Berth management UI
clients/ # Client management UI
dashboard/ # Dashboard widgets
documents/ # Document management UI
email/ # Email client UI
expenses/ # Expense tracking UI
files/ # File browser UI
interests/ # Interest/pipeline UI
invoices/ # Invoice management UI
layout/ # Sidebar, topbar, navigation
notifications/ # Notification center
portal/ # Client portal components
reports/ # Report views
search/ # Global search
shared/ # Shared components
ui/ # shadcn/ui base components
hooks/ # React hooks
jobs/ # BullMQ job definitions
lib/
auth/ # Better Auth config
db/ # Drizzle schema, migrations, seed
queue/ # Queue definitions, workers
services/ # Business logic (38 service files)
validators/ # Zod schemas (17 validator files)
socket/ # Socket.io config
providers/ # React context providers
stores/ # Zustand stores
types/ # TypeScript type definitions
```
---
## How to Continue Development
```bash
# Clone and install
git clone https://code.letsbe.solutions/letsbe/pn-new-crm.git
cd pn-new-crm
pnpm install
# Start dev environment (needs Docker for PostgreSQL + Redis)
docker compose -f docker-compose.dev.yml up -d
cp .env.example .env # then edit with real values
pnpm dev
# Run tests
pnpm exec vitest run
pnpm exec playwright test
# Build for production
pnpm build
# Database operations
pnpm db:push # Push schema to database
pnpm db:generate # Generate migration files
pnpm db:seed # Seed with sample data
pnpm db:studio # Open Drizzle Studio GUI
```
---
## Spec Documents Reference
| Doc | Purpose |
| ------------------------------------ | ---------------------------------- |
| `01-CONSOLIDATED-SYSTEM-SPEC.md` | Full system specification |
| `02-FEATURE-INVENTORY.md` | Feature keep/cut/rethink decisions |
| `03-ARCHITECTURE-DECISIONS.md` | Architecture exploration |
| `04-ARCHITECTURE-COMPARISON.md` | Stack comparison |
| `05-FINAL-ARCHITECTURE-DECISIONS.md` | Final architecture choices |
| `06-MASTER-FEATURE-SPEC.md` | Detailed feature specifications |
| `07-DATABASE-SCHEMA.md` | Database schema design |
| `08-API-ENDPOINT-CATALOG.md` | Full API endpoint list |
| `09-BUSINESS-RULES.md` | Business logic rules |
| `10-AUTH-AND-PERMISSIONS.md` | Auth & permission model |
| `11-REALTIME-AND-BACKGROUND-JOBS.md` | Socket.io & BullMQ design |
| `12-IMPLEMENTATION-SEQUENCE.md` | Build order & dependencies |
| `13-UI-PAGE-MAP.md` | Page layout specifications |
| `14-TECHNICAL-DECISIONS.md` | Technical trade-off decisions |
| `15-DESIGN-TOKENS.md` | Design system tokens |
| `NOCODB-MIGRATION-MAPPING.md` | NocoDB to PostgreSQL mapping |
| `SECURITY-GUIDELINES.md` | Security requirements |

409
SECURITY-GUIDELINES.md Normal file
View File

@@ -0,0 +1,409 @@
# Port Nimara CRM — Security Guidelines
**Status:** Mandatory for all development. Every checklist item applies to every layer.
**Context:** This system stores personally identifiable information for ultra-high-net-worth individuals — yacht owners, their brokers, legal counsel, and family members. A data breach would be catastrophic for the business and for the individuals involved. Security is not a nice-to-have; it is a hard requirement on every line of code.
---
## 1. Authentication & Session Security
### 1.1 Password Handling
- [ ] Passwords hashed with **Argon2id** via Better Auth defaults (never bcrypt, never SHA-\*)
- [ ] Minimum password length: **12 characters** (enforced in Zod schema AND server-side)
- [ ] Password complexity: at least 1 uppercase, 1 lowercase, 1 digit, 1 special character
- [ ] Password set/reset tokens: UUID + HMAC, **48-hour expiry**, single-use, stored hashed in DB
- [ ] No password in URL parameters, query strings, or logs
- [ ] Rate limit login: **5 failed attempts per email per 15 minutes** → temporary lockout with generic error
- [ ] Rate limit password reset requests: **3 per email per hour**
### 1.2 Session Management
- [ ] Sessions stored in **PostgreSQL** via Better Auth (not JWT, not localStorage)
- [ ] Session cookie: `httpOnly`, `secure`, `sameSite=strict`
- [ ] Session duration: **24 hours**, refresh on activity within last 25%
- [ ] CSRF token on every state-mutating request (Better Auth provides this)
- [ ] Logout destroys server-side session AND clears cookie
- [ ] Admin can revoke all sessions for a user
### 1.3 Authentication Responses
- [ ] Failed login returns **generic "Invalid credentials"** — never reveal whether email exists
- [ ] Password reset for non-existent email returns **same success response** as valid email
- [ ] No user enumeration via any endpoint (signup, reset, login)
---
## 2. Authorization & Access Control
### 2.1 Port Scoping (Multi-Tenancy Isolation)
- [ ] **Every database query** includes `WHERE port_id = :currentPortId`
- [ ] Port ID comes from **authenticated session context**, never from request body/params
- [ ] No endpoint allows cross-port data access (except super_admin)
- [ ] Port scoping enforced at the **service layer**, not just the API layer
- [ ] Drizzle queries use a helper: `withPortScope(query, portId)` or equivalent pattern
### 2.2 RBAC Enforcement
- [ ] Permission checks on **every API endpoint** via middleware
- [ ] Middleware pattern: `authenticate → extractPort → checkPermission → handler`
- [ ] Permission checks use the role's JSON permission map, not hardcoded role names
- [ ] Super admin bypasses permission checks but is **still subject to audit logging**
- [ ] Port role overrides are applied after global role permissions
### 2.3 Resource-Level Access
- [ ] Users can only access records within their assigned port
- [ ] Reminder `view_own` vs `view_all` enforced at query level
- [ ] File downloads verify the requesting user has access to the parent entity (client, expense, etc.)
- [ ] Document signing URLs are **time-limited** and **single-use** per signer
---
## 3. Input Validation & Sanitization
### 3.1 API Input Validation
- [ ] **Every API endpoint** validates input with a Zod schema — no exceptions
- [ ] Zod schemas validate types, lengths, formats, and allowed values
- [ ] Validation happens **before any database query or business logic**
- [ ] Validation errors return 400 with field-level error messages (no internal details)
- [ ] File upload validation: check MIME type (allowlist), max size (10MB default), filename sanitization
### 3.2 SQL Injection Prevention
- [ ] **All queries use Drizzle ORM** parameterized queries — no raw SQL string concatenation
- [ ] If raw SQL is ever needed (full-text search, complex aggregations): use `sql.raw()` with `sql.placeholder()` only
- [ ] Search inputs passed through `sanitizeSearchTerm()` before inclusion in tsvector queries
### 3.3 XSS Prevention
- [ ] Rich text content (TipTap output, email bodies) sanitized with **DOMPurify** before storage AND before rendering
- [ ] DOMPurify allowlist: basic formatting tags only (b, i, u, em, strong, p, br, ul, ol, li, a, h1-h6, table, tr, td, th, blockquote, code, pre)
- [ ] Links in rich text: `rel="noopener noreferrer"` enforced
- [ ] User-generated content rendered via React (auto-escaped by default) — no `dangerouslySetInnerHTML` except for DOMPurify-sanitized content
- [ ] File names displayed in UI are always escaped
### 3.4 Path Traversal Prevention
- [ ] MinIO object keys constructed from UUIDs, never from user input
- [ ] File upload original names stored in DB but **never used as storage paths**
- [ ] Storage path format: `{portSlug}/{entity}/{entityId}/{uuid}.{ext}`
---
## 4. Data Protection
### 4.1 Encryption at Rest
- [ ] PostgreSQL: disk-level encryption via Docker volume (host responsibility)
- [ ] MinIO: **SSE-S3 encryption** enabled on all buckets
- [ ] Email account credentials (SMTP/IMAP passwords): encrypted with **AES-256-GCM** before storage in `email_accounts.credentials_enc`
- [ ] Google Calendar OAuth tokens: encrypted with **pgcrypto** in `google_calendar_tokens`
- [ ] Webhook signing secrets: stored encrypted, decrypted only at delivery time
### 4.2 Encryption in Transit
- [ ] **TLS 1.3** on all external connections (nginx terminates)
- [ ] Internal Docker network: services communicate over Docker bridge (not exposed to host)
- [ ] MinIO connections from app: use HTTPS if MinIO has TLS configured, otherwise Docker-internal HTTP is acceptable
- [ ] HSTS header: `max-age=31536000; includeSubDomains`
### 4.3 Sensitive Data Handling
- [ ] **Never log** passwords, tokens, API keys, or email credentials
- [ ] **Never log** full client PII (names, emails, phone numbers, addresses) — log entity IDs only
- [ ] Error responses to clients: generic messages only, no stack traces, no internal paths
- [ ] Database error messages caught and replaced with generic 500 responses
- [ ] Audit log `old_value`/`new_value` for sensitive fields (email, phone): store hashed or masked versions
### 4.4 PII Minimization
- [ ] API responses return only fields the requesting user is authorized to see
- [ ] List endpoints return summary fields; detail endpoints return full records
- [ ] Export endpoints respect the user's permission level
- [ ] Search results do not include archived records unless explicitly requested
---
## 5. Audit Logging
### 5.1 What Must Be Logged
- [ ] **Every** create, update, delete, archive, restore operation on every entity
- [ ] Client merge operations with full merge details
- [ ] Login, logout, failed login attempts
- [ ] Permission changes (role assignments, role modifications)
- [ ] Document signing events (sent, viewed, signed, completed, expired)
- [ ] File uploads and downloads
- [ ] Export operations (who exported what, when)
- [ ] Admin settings changes
- [ ] Bulk operations (what was selected, what action was taken)
### 5.2 Audit Log Format
```typescript
{
port_id: UUID, // null for system events
user_id: string, // null for system-generated
action: string, // create, update, delete, archive, restore, merge, login, logout, revert
entity_type: string, // client, interest, berth, expense, invoice, file, user, role, etc.
entity_id: UUID,
field_changed: string, // for updates: which field
old_value: JSONB, // previous value (masked for sensitive fields)
new_value: JSONB, // new value (masked for sensitive fields)
ip_address: string, // from request
user_agent: string, // from request
metadata: JSONB // extra context (e.g., source: "nocodb_migration")
}
```
### 5.3 Audit Helper Pattern
```typescript
// Every service function should use this pattern:
async function updateClient(portId: string, clientId: string, data: UpdateClientInput, userId: string) {
const existing = await getClient(portId, clientId);
const updated = await db.update(clients).set(data).where(...).returning();
await auditLog({
portId,
userId,
action: 'update',
entityType: 'client',
entityId: clientId,
changes: diffFields(existing, updated), // generates field_changed + old_value + new_value per field
});
return updated;
}
```
---
## 6. API Security
### 6.1 Rate Limiting
- [ ] **nginx layer:** global rate limits per IP
- Auth endpoints: 5 requests/minute
- Public API: 30 requests/minute
- Authenticated API: 120 requests/minute
- File upload: 10 requests/minute
- [ ] **Application layer:** Redis sliding window per authenticated user
- Configurable per-endpoint (default: 60 requests/minute)
- Rate limit headers: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`
### 6.2 Security Headers (nginx)
```nginx
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header X-XSS-Protection "0" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' wss:; frame-ancestors 'none';" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
```
### 6.3 CORS
- [ ] Allowed origins: only the CRM domain and the public website domain
- [ ] No wildcard (`*`) origins
- [ ] Credentials: `true` (for cookie-based auth)
- [ ] Allowed methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
- [ ] Preflight cache: 1 hour
### 6.4 Request Size Limits
- [ ] JSON body: **1MB** max
- [ ] File upload: **50MB** max (configurable per endpoint)
- [ ] URL length: **2048 characters** max
---
## 7. External Service Security
### 7.1 MinIO
- [ ] Access credentials in environment variables only (never in code, never in DB)
- [ ] Bucket policy: private by default, pre-signed URLs for client access (15-minute expiry)
- [ ] Object key format: no user-controllable path components
- [ ] SSE-S3 encryption enabled
### 7.2 Documenso
- [ ] API key in environment variable
- [ ] Webhook signature verification on every inbound webhook (reject if invalid)
- [ ] Signing URLs: generated per-signer, time-limited by Documenso
- [ ] Document status changes only accepted via verified webhooks (not client requests)
### 7.3 Google Calendar OAuth
- [ ] OAuth tokens encrypted at rest (pgcrypto)
- [ ] Refresh tokens rotated on each use
- [ ] Token scope: calendar read/write only (minimum scope)
- [ ] Revoke tokens on user request or account deactivation
### 7.4 OpenAI Vision API
- [ ] API key in environment variable
- [ ] Receipt images sent for OCR only — no PII fields sent to OpenAI
- [ ] Response validation: don't trust extracted values blindly, show user for confirmation
### 7.5 Frankfurter API
- [ ] No API key needed (public)
- [ ] Cache responses (24 hours) — don't hit on every request
- [ ] Fallback to last cached rate if API is down
---
## 8. Socket.io Security
- [ ] Socket connections require valid session cookie (same auth as HTTP)
- [ ] Max **10 connections per user** (prevent resource exhaustion)
- [ ] Message size limit: **1MB**
- [ ] Room authorization: user can only join rooms for their assigned port
- [ ] Room naming: `port:{portId}`, `user:{userId}` — portId verified against user's assignments
- [ ] No sensitive data in Socket.io payloads — send event type + entity ID, client fetches details via API
- [ ] Connection timeout: 30 seconds idle → disconnect
---
## 9. BullMQ Security
- [ ] Job data: never include raw credentials or tokens — reference by encrypted DB record ID
- [ ] Failed job data: scrub sensitive fields before storing in Redis
- [ ] Dead letter queue: alert on accumulation (>10 jobs) via notification system
- [ ] Job retry limits: max 3 retries with exponential backoff
- [ ] Bull Board admin dashboard: protected by super_admin or director role check
---
## 10. File Upload Security
- [ ] MIME type validation: **allowlist only**
- Images: `image/jpeg`, `image/png`, `image/gif`, `image/webp`
- Documents: `application/pdf`, `application/msword`, `application/vnd.openxmlformats-officedocument.wordprocessingml.document`
- Spreadsheets: `application/vnd.ms-excel`, `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`
- [ ] File extension validation: must match MIME type
- [ ] Max file size: **50MB** (configurable)
- [ ] Filename sanitization: strip path separators, null bytes, unicode control characters
- [ ] Storage filename: UUID-based (original name stored in DB only)
- [ ] Virus scanning: defer to V2 (document in risk register)
- [ ] No direct file serving — always through pre-signed URLs or API proxy with auth check
---
## 11. Environment & Secrets Management
- [ ] All secrets in **environment variables** (`.env` files, never committed to git)
- [ ] `.env.example` with placeholder values (no real secrets)
- [ ] `.gitignore` includes: `.env`, `.env.local`, `.env.production`, `*.pem`, `*.key`
- [ ] Docker Compose uses `env_file` directive
- [ ] No secrets in Dockerfile build args
- [ ] Database connection string: `?sslmode=disable` acceptable for Docker-internal, `?sslmode=require` for any external connection
### Required Environment Variables
```
# Database
DATABASE_URL=postgresql://user:pass@postgres:5432/portnimara
# Redis
REDIS_URL=redis://redis:6379
# Auth
BETTER_AUTH_SECRET=<32+ random bytes, base64>
CSRF_SECRET=<32+ random bytes, base64>
# MinIO
MINIO_ENDPOINT=minio:9000
MINIO_ACCESS_KEY=<access key>
MINIO_SECRET_KEY=<secret key>
# Documenso
DOCUMENSO_API_URL=https://documenso.portnimara.com/api/v1
DOCUMENSO_API_KEY=<api key>
DOCUMENSO_WEBHOOK_SECRET=<webhook signing secret>
# Email encryption
EMAIL_CREDENTIAL_KEY=<32-byte AES key, hex>
# Google OAuth
GOOGLE_CLIENT_ID=<client id>
GOOGLE_CLIENT_SECRET=<client secret>
# OpenAI (receipt OCR only)
OPENAI_API_KEY=<api key>
# App
APP_URL=https://crm.portnimara.com
PUBLIC_SITE_URL=https://portnimara.com
NODE_ENV=production
```
---
## 12. Dependency Security
- [ ] `pnpm audit` run in CI — fail build on high/critical vulnerabilities
- [ ] Lockfile (`pnpm-lock.yaml`) always committed
- [ ] No `*` version ranges in package.json — pin major+minor (`^` is OK, `*` is not)
- [ ] Docker base image: `node:20-alpine` (minimal attack surface)
- [ ] No unnecessary OS packages in Docker image
---
## 13. Error Handling Security
### What the client sees:
```json
// 400 — Validation error
{ "error": "Validation failed", "details": [{ "field": "email", "message": "Invalid email format" }] }
// 401 — Not authenticated
{ "error": "Authentication required" }
// 403 — Not authorized
{ "error": "Insufficient permissions" }
// 404 — Not found (also used for unauthorized access to existing resources)
{ "error": "Resource not found" }
// 429 — Rate limited
{ "error": "Too many requests", "retryAfter": 60 }
// 500 — Server error
{ "error": "Internal server error" }
```
### What the client NEVER sees:
- Stack traces
- Database error messages
- Internal file paths
- SQL queries
- Environment variable values
- Other users' IDs or data
---
## 14. Pre-Deployment Security Checklist
Before ANY deployment:
- [ ] All environment variables set (no defaults that work in production)
- [ ] `NODE_ENV=production`
- [ ] Debug/dev endpoints disabled
- [ ] API docs endpoint (`/api/docs`) disabled in production (or protected behind auth)
- [ ] Default/seed admin password changed
- [ ] HTTPS enforced (HTTP redirects to HTTPS)
- [ ] Database backups configured and tested
- [ ] Audit log writing verified (test a mutation, check the log)
- [ ] Rate limiting verified (test with rapid requests)
- [ ] CORS verified (test from unauthorized origin)
- [ ] File upload limits verified (test with oversized file)

1
client-portal Submodule

Submodule client-portal added at e2d31815cf

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,859 @@
# Layer 6: Migration & Cutover — Competing Plan (Claude Code)
**Scope:** NocoDB → PostgreSQL data migration script (ETL), MinIO file reorganization, post-migration search index rebuild, user provisioning, smoke testing, DNS cutover, and rollback plan.
**Duration:** 3 days (1 day script development during the week + 2-day weekend cutover)
**Depends on:** L0L5 complete and tested. The new CRM must be fully functional and passing all tests before migration begins.
---
## 1. Baseline Critique
### What's Good
1. **ETL structure is correct** — Extract/Transform/Load/Validate is the right pattern. The folder layout with separate phases is clean.
2. **Rollback plan is conservative and sensible** — Keeping NocoDB running for 30 days, DNS-based rollback. This is the right approach.
3. **Weekend cutover timing** — Low-traffic period, gives time for smoke testing before Monday.
4. **Validation checklist is thorough** — Count comparisons, orphan detection, FK integrity, pipeline distribution, amount sanity checks.
5. **Post-migration manual tasks list** — Clear ownership and timing for human review steps.
6. **NocoDB-MIGRATION-MAPPING.md is comprehensive** — Field-by-field mapping with transformation rules, data quality fixes, and dedup logic is excellent reference material.
### What Needs Fixing
7. **No dry-run mode** — The script has no `--dry-run` flag. You need to run the full ETL pipeline without actually inserting data to catch transformation errors before the real migration. This is critical for confidence.
8. **No delta/incremental support** — The test run happens Wednesday/Thursday but the real migration happens Saturday. Any data entered in NocoDB between Wednesday and Saturday would be missed. Need a way to identify and migrate new/changed records.
9. **Transaction handling too coarse** — Loading all clients in one giant transaction means one bad record rolls back everything. With ~500 clients, a single malformed email could fail the entire batch. Better to use per-record error handling: try each record, log failures, continue.
10. **`rollback.ts` is listed but never defined** — The file tree shows `rollback.ts` but the baseline never describes what it does. The rollback plan is a manual process (DNS switch), not a script.
11. **Missing tsvector search index rebuild** — After loading data, the `search_vector` tsvector columns on clients, interests, and berths need to be populated. The baseline mentions this at 11:00 Saturday but doesn't show the SQL or mechanism.
12. **Synthetic audit logs lack required fields**`ip_address` and `user_agent` should have placeholder values for migration entries (`'0.0.0.0'` and `'migration-script/1.0'`), not be left null.
13. **No post-load berth status reconciliation** — After loading interests with berth_id, some berths should be `under_offer` or `sold` based on their linked interests. The migration should reconcile berth statuses against the loaded interest data, not leave them at whatever NocoDB had (which may be inconsistent).
14. **No file integrity verification** — After MinIO file reorganization, there's no checksum comparison to verify files weren't corrupted during copy.
15. **Extraction is sequential** — With only 4 NocoDB tables, parallel extraction would cut the extraction phase time in half.
16. **AI-assisted file matching referenced from L4 Stream C** — This tool may or may not exist in the blessed plan. The migration script needs a standalone file-matching strategy that doesn't depend on L4's AI features.
17. **Missing migration report** — After the migration completes, there should be a comprehensive report (JSON + human-readable) documenting exactly what was migrated, what was skipped, what needs manual review.
---
## 2. Implementation Plan
### Pre-Migration Script Development (1 day during the week)
#### Migration Script Structure
```
scripts/migrate-nocodb/
├── index.ts # Main orchestrator with CLI flags
├── config.ts # Connection config, constants, thresholds
├── modes.ts # --dry-run, --full, --delta, --validate-only
├── extract/
│ ├── interests.ts # Extract NocoDB Interests (mega-table)
│ ├── berths.ts # Extract NocoDB Berths
│ ├── expenses.ts # Extract NocoDB Expenses
│ ├── invoices.ts # Extract NocoDB Invoices
│ └── files.ts # Scan MinIO bucket for file inventory
├── transform/
│ ├── clients.ts # Interests → clients + client_contacts (dedup)
│ ├── interests.ts # Interests → interests + interest_notes
│ ├── berths.ts # Berths → berths + berth_map_data
│ ├── documents.ts # Interests → documents + document_signers
│ ├── expenses.ts # Expenses → expenses
│ ├── invoices.ts # Invoices → invoices + line_items + invoice_expenses
│ ├── files.ts # MinIO scan → files table records
│ └── synthetic.ts # Audit logs, tags, client_tags
├── load/
│ ├── database.ts # Insert into PostgreSQL (per-record error handling)
│ ├── files.ts # Copy MinIO files to new structure
│ └── search-indexes.ts # Rebuild tsvector search columns
├── validate/
│ ├── counts.ts # Compare record counts source vs target
│ ├── spot-check.ts # Verify specific known records
│ ├── integrity.ts # FK constraint validation, orphan detection
│ ├── status-reconcile.ts # Reconcile berth statuses with loaded interests
│ └── file-integrity.ts # Checksum comparison for reorganized files
├── report.ts # Generate migration report (JSON + text)
└── helpers/
├── parse-dimension.ts # parseDimension() — string → { ft, m }
├── parse-price.ts # parsePrice() — string → number
├── normalize-name.ts # normalizeClientName() — trim, title-case, strip honorifics
├── normalize-category.ts # normalizeCategory() — NocoDB enum → snake_case
├── stage-mapping.ts # NocoDB sales process level → pipeline_stage
└── dedup.ts # Client deduplication logic
```
#### CLI Modes
```typescript
// scripts/migrate-nocodb/index.ts
import { program } from 'commander';
program
.option('--dry-run', 'Run extraction + transformation + validation without loading')
.option('--full', 'Run full migration (extract → transform → load → validate)')
.option('--delta', 'Migrate only records created/updated after last full migration')
.option('--validate-only', 'Run validation checks against already-loaded data')
.option('--skip-files', 'Skip MinIO file reorganization')
.option('--verbose', 'Log every record transformation')
.parse();
```
**`--dry-run`:** Extracts from NocoDB, transforms all records, runs validation on the transformed data (count checks, dedup candidates, data quality warnings), writes report — but never touches PostgreSQL or reorganizes MinIO files. This is the safest way to verify the transformation logic.
**`--delta`:** Queries NocoDB for records where `Updated At > lastMigrationTimestamp`. Only migrates new/changed records. Used for the Wednesday→Saturday gap. New clients from new interests are created; existing clients with new interests get the interest added.
**`--validate-only`:** Runs all validation checks against the already-loaded PostgreSQL data. Useful for post-load spot-checking without re-running the full migration.
#### Extraction (Parallel)
```typescript
// scripts/migrate-nocodb/extract/index.ts
export async function extractAll(): Promise<ExtractedData> {
// Run all 4 extractions in parallel — independent NocoDB API calls
const [interests, berths, expenses, invoices] = await Promise.all([
extractInterests(),
extractBerths(),
extractExpenses(),
extractInvoices(),
]);
// Sequential: file scan depends on nothing but is I/O heavy
const files = await scanMinIOFiles();
return { interests, berths, expenses, invoices, files };
}
```
Each extractor follows the same pattern:
```typescript
export async function extractInterests(): Promise<NocoDB_Interest[]> {
const records: NocoDB_Interest[] = [];
let offset = 0;
const limit = 200; // Larger batch size for faster extraction
while (true) {
const response = await fetch(
`${NOCODB_API}/api/v1/db/data/noco/${projectId}/${TABLE_IDS.interests}?offset=${offset}&limit=${limit}&sort=-CreatedAt`,
{ headers: { 'xc-auth': NOCODB_TOKEN } },
);
if (!response.ok) throw new Error(`NocoDB extraction failed: ${response.status}`);
const data = await response.json();
records.push(...data.list);
logger.info(`Extracted ${records.length} interests...`);
if (data.list.length < limit) break;
offset += limit;
}
return records;
}
```
Delta mode adds a `where` clause:
```typescript
// For --delta mode:
const where = `(UpdatedAt,gt,${lastMigrationTimestamp})`;
const url = `${NOCODB_API}/...?offset=${offset}&limit=${limit}&where=${encodeURIComponent(where)}`;
```
#### Transformation
All transformation functions follow the pattern documented in `NOCODB-MIGRATION-MAPPING.md`. Key additions:
**Client deduplication with confidence scoring:**
```typescript
// scripts/migrate-nocodb/helpers/dedup.ts
interface DedupResult {
/** Unique clients after dedup */
clients: TransformedClient[];
/** Client contacts (deduplicated per client) */
contacts: TransformedClientContact[];
/** Map: NocoDB Interest ID → client UUID */
interestToClientMap: Map<number, string>;
/** Exact email matches that were auto-merged */
autoMerged: Array<{ email: string; interestIds: number[]; clientId: string }>;
/** Fuzzy name matches for manual review */
fuzzyCandidates: Array<{
nameA: string;
nameB: string;
similarity: number;
interestIds: number[];
}>;
}
export function deduplicateClients(interests: NocoDB_Interest[]): DedupResult {
// Pass 1: Group by exact email (case-insensitive, trimmed)
const emailGroups = new Map<string, NocoDB_Interest[]>();
// ... group interests by normalized email
// Pass 2: Within each email group, merge into single client
// Keep earliest created_at, latest updated_at, merge all contacts
// Pass 3: For interests without email, try fuzzy name match
// Levenshtein distance ≤ 2 on normalizeClientName() + same residence
// These are CANDIDATES only — logged to dedup-candidates.csv for manual review
// Pass 4: Remaining unmatched interests → create new clients
}
```
**Pipeline stage mapping** (using exact values from BR-010):
```typescript
// scripts/migrate-nocodb/helpers/stage-mapping.ts
export const STAGE_MAP: Record<string, string> = {
'General Qualified Interest': 'open',
'Specific Qualified Interest': 'details_sent',
'EOI and NDA Sent': 'in_communication',
'Signed EOI and NDA': 'signed_eoi_nda',
'Made Reservation': 'deposit_10pct',
'Contract Negotiation': 'contract',
'Contract Negotiations Finalized': 'contract',
'Contract Signed': 'completed',
};
export function mapPipelineStage(nocodbStage: string | null): string {
if (!nocodbStage || nocodbStage.trim() === '') return 'open';
const mapped = STAGE_MAP[nocodbStage.trim()];
if (!mapped) {
logger.warn(`Unknown pipeline stage: "${nocodbStage}" — defaulting to "open"`);
return 'open';
}
return mapped;
}
```
#### Load Phase (Per-Record Error Handling)
```typescript
// scripts/migrate-nocodb/load/database.ts
interface LoadResult {
entity: string;
total: number;
success: number;
failed: number;
errors: Array<{ recordIndex: number; nocodbId: number | string; error: string }>;
}
export async function loadClients(clients: TransformedClient[]): Promise<LoadResult> {
const result: LoadResult = {
entity: 'clients',
total: clients.length,
success: 0,
failed: 0,
errors: [],
};
for (let i = 0; i < clients.length; i++) {
try {
await db.insert(clientsTable).values(clients[i]);
result.success++;
} catch (error) {
result.failed++;
result.errors.push({
recordIndex: i,
nocodbId: clients[i].metadata?.nocodb_id ?? 'unknown',
error: error instanceof Error ? error.message : String(error),
});
logger.error(`Failed to load client ${i}: ${error}`);
// Continue — don't abort the entire batch
}
}
return result;
}
```
**Load order (respects FK constraints, matches NOCODB-MIGRATION-MAPPING.md):**
```typescript
export async function loadAll(data: TransformedData): Promise<MigrationReport> {
const results: LoadResult[] = [];
// Phase 1: Independent entities (no FK dependencies on each other)
results.push(await loadClients(data.clients));
results.push(await loadClientContacts(data.clientContacts));
results.push(await loadBerths(data.berths));
results.push(await loadBerthMapData(data.berthMapData));
// Phase 2: Entities that reference Phase 1
results.push(await loadInterests(data.interests));
results.push(await loadInterestNotes(data.interestNotes));
results.push(await loadDocuments(data.documents));
results.push(await loadDocumentSigners(data.documentSigners));
// Phase 3: Financial entities
results.push(await loadExpenses(data.expenses));
results.push(await loadFiles(data.files));
results.push(await loadInvoices(data.invoices));
results.push(await loadInvoiceLineItems(data.invoiceLineItems));
results.push(await loadInvoiceExpenses(data.invoiceExpenses));
// Phase 4: Synthetic/derived records
results.push(await loadTags(data.tags));
results.push(await loadClientTags(data.clientTags));
results.push(await loadInterestTags(data.interestTags));
results.push(await loadAuditLogs(data.syntheticAuditLogs));
return generateReport(results);
}
```
#### Search Index Rebuild
```typescript
// scripts/migrate-nocodb/load/search-indexes.ts
export async function rebuildSearchIndexes(): Promise<void> {
logger.info('Rebuilding tsvector search indexes...');
// Clients: search on full_name, company_name, yacht_name, email, phone
await db.execute(sql`
UPDATE clients SET search_vector =
setweight(to_tsvector('english', coalesce(full_name, '')), 'A') ||
setweight(to_tsvector('english', coalesce(company_name, '')), 'B') ||
setweight(to_tsvector('english', coalesce(yacht_name, '')), 'C')
WHERE port_id = ${PORT_NIMARA_ID}
`);
// Berths: search on mooring_number, area, berth_type
await db.execute(sql`
UPDATE berths SET search_vector =
setweight(to_tsvector('english', coalesce(mooring_number, '')), 'A') ||
setweight(to_tsvector('english', coalesce(area, '')), 'B') ||
setweight(to_tsvector('english', coalesce(berth_type, '')), 'C')
WHERE port_id = ${PORT_NIMARA_ID}
`);
// Interests: search on client name (via join), berth number, notes
// This uses the service layer's existing search vector update function
await db.execute(sql`
UPDATE interests i SET search_vector =
setweight(to_tsvector('english', coalesce(c.full_name, '')), 'A') ||
setweight(to_tsvector('english', coalesce(b.mooring_number, '')), 'B')
FROM clients c LEFT JOIN berths b ON b.id = i.berth_id
WHERE c.id = i.client_id AND i.port_id = ${PORT_NIMARA_ID}
`);
// Also rebuild pg_trgm indexes (these are handled by GIN indexes, just need data present)
logger.info('Search indexes rebuilt.');
}
```
#### Berth Status Reconciliation
```typescript
// scripts/migrate-nocodb/validate/status-reconcile.ts
export async function reconcileBerthStatuses(): Promise<ReconcileReport> {
// After loading all interests, verify berth statuses make sense:
// 1. Berths with active interests (non-archived) in stages beyond 'details_sent'
// should be 'under_offer' or 'sold'
// 2. Berths with completed interests (stage = 'completed') should be 'sold'
// 3. Berths with no linked interests should be 'available' or 'maintenance'
const mismatches: Array<{
berthId: string;
currentStatus: string;
expectedStatus: string;
reason: string;
}> = [];
// Query: berths where status is 'available' but has active interests
const availableWithInterests = await db.execute(sql`
SELECT b.id, b.mooring_number, b.status, i.pipeline_stage
FROM berths b
INNER JOIN interests i ON i.berth_id = b.id AND i.archived_at IS NULL
WHERE b.port_id = ${PORT_NIMARA_ID}
AND b.status = 'available'
AND i.pipeline_stage NOT IN ('open', 'details_sent')
`);
for (const row of availableWithInterests.rows) {
mismatches.push({
berthId: row.id,
currentStatus: 'available',
expectedStatus: 'under_offer',
reason: `Has active interest at stage "${row.pipeline_stage}"`,
});
}
// Log mismatches but don't auto-fix — Matt reviews and decides
return { mismatches, autoFixApplied: false };
}
```
#### File Reorganization (with Integrity Check)
```typescript
// scripts/migrate-nocodb/load/files.ts
export async function reorganizeFiles(
fileMappings: FileMapping[],
options: { dryRun: boolean },
): Promise<FileReorgResult> {
const result: FileReorgResult = { copied: 0, failed: 0, checksumMismatches: 0, errors: [] };
for (const mapping of fileMappings) {
try {
if (options.dryRun) {
// Just verify source exists
await minioClient.statObject(mapping.sourceBucket, mapping.sourcePath);
result.copied++;
continue;
}
// 1. Get source object metadata (for checksum)
const sourceStat = await minioClient.statObject(mapping.sourceBucket, mapping.sourcePath);
// 2. Copy to new location
await minioClient.copyObject(
mapping.targetBucket,
mapping.targetPath,
`/${mapping.sourceBucket}/${mapping.sourcePath}`,
);
// 3. Verify copy integrity (size match)
const targetStat = await minioClient.statObject(mapping.targetBucket, mapping.targetPath);
if (sourceStat.size !== targetStat.size) {
result.checksumMismatches++;
result.errors.push({
file: mapping.sourcePath,
error: `Size mismatch: source=${sourceStat.size}, target=${targetStat.size}`,
});
continue;
}
result.copied++;
} catch (error) {
result.failed++;
result.errors.push({
file: mapping.sourcePath,
error: error instanceof Error ? error.message : String(error),
});
// Continue — don't fail entire file reorganization for one file
}
}
return result;
}
```
**File-to-client matching (standalone, no L4 AI dependency):**
```typescript
// Simple rule-based matching — no AI needed for this data volume
function matchFileToClient(
filePath: string,
clients: TransformedClient[],
interests: TransformedInterest[],
): string | null {
const filename = path.basename(filePath).toLowerCase();
// Rule 1: If path contains a known client name or email
for (const client of clients) {
const nameLower = client.full_name.toLowerCase().replace(/\s+/g, '_');
if (filePath.toLowerCase().includes(nameLower)) return client.id;
}
// Rule 2: If path contains a known berth number + interest context
// e.g., "eoi_A12_JohnDoe.pdf" → find interest for berth A12
// Rule 3: If file is a receipt linked from NocoDB expense
// Already matched during expense transformation
// No match → return null (logged to unmatched-files.csv)
return null;
}
```
#### Migration Report
```typescript
// scripts/migrate-nocodb/report.ts
interface MigrationReport {
timestamp: string;
duration: string;
mode: 'dry-run' | 'full' | 'delta';
extraction: {
interests: number;
berths: number;
expenses: number;
invoices: number;
files: number;
};
transformation: {
clients: { total: number; deduped: number; autoMerged: number; fuzzyCandidates: number };
contacts: number;
interests: number;
berths: number;
documents: number;
expenses: number;
invoices: number;
lineItems: number;
files: { matched: number; unmatched: number };
};
load: {
results: LoadResult[];
totalSuccess: number;
totalFailed: number;
};
validation: {
countChecks: ValidationCheck[];
integrityChecks: ValidationCheck[];
statusReconciliation: ReconcileReport;
fileIntegrity: FileReorgResult;
allPassed: boolean;
};
reviewFiles: {
dedupCandidates: string; // path to CSV
unmatchedFiles: string; // path to CSV
warnings: string; // path to CSV
};
}
export function generateReport(data: MigrationReport): void {
// Write JSON report
fs.writeFileSync('migration-report.json', JSON.stringify(data, null, 2));
// Write human-readable summary
const summary = formatTextReport(data);
fs.writeFileSync('migration-report.txt', summary);
console.log(summary);
}
```
#### Synthetic Audit Logs
```typescript
// scripts/migrate-nocodb/transform/synthetic.ts
export function generateSyntheticAuditLogs(
clients: TransformedClient[],
interests: TransformedInterest[],
berths: TransformedBerth[],
expenses: TransformedExpense[],
invoices: TransformedInvoice[],
): AuditLog[] {
const logs: AuditLog[] = [];
// For each migrated entity, create a synthetic "create" audit entry
for (const client of clients) {
logs.push({
port_id: PORT_NIMARA_ID,
user_id: 'system-migration',
action: 'create',
entity_type: 'client',
entity_id: client.id,
field_changed: null,
old_value: null,
new_value: null,
ip_address: '0.0.0.0', // Placeholder for migration
user_agent: 'migration-script/1.0',
metadata: { source: 'nocodb_migration', nocodb_id: client.metadata?.nocodb_id },
created_at: client.created_at, // Use original creation timestamp
});
}
// Same for interests, berths, expenses, invoices...
return logs;
}
```
---
### Cutover Weekend Execution
#### Friday Evening — Freeze & Final Snapshot
| Time | Action | Owner | Notes |
| ----- | ----------------------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------ |
| 17:00 | Email team: "CRM maintenance this weekend. Stop entering data in NocoDB by 18:00." | Matt | Give 1-hour warning |
| 18:00 | **Data freeze.** No new entries in NocoDB after this point. | Team | Record exact freeze timestamp |
| 18:15 | Run `--delta` migration (Wednesday→Friday changes) against test DB. Verify. | Script | Catches any gap from test run |
| 18:30 | Take final NocoDB API snapshot: extract all 4 tables to JSON files | Script | `node scripts/migrate-nocodb --mode=extract --output=./snapshots/final/` |
| 18:45 | Take final MinIO file inventory (list all objects with sizes) | Script | Save as `final-file-inventory.csv` |
| 19:00 | Backup current PostgreSQL (empty CRM schema — just in case) | pg_dump | Save as `pre-migration-backup.sql.gz` |
| 19:15 | Run `--dry-run` on final snapshot | Script | Verify no new transformation errors |
| 19:30 | Review dry-run report. If clean → done for Friday. If issues → fix scripts tonight. | Matt + Dev | Go/no-go decision |
#### Saturday Morning — Data Migration
| Time | Action | Owner | Verify |
| ----- | ---------------------------------------------------------------- | ------ | -------------------------------------- |
| 08:00 | Run full migration: `node scripts/migrate-nocodb --mode=full` | Script | Watch console output |
| 08:05 | — Extraction completes (~2 min for this data volume) | | Record counts match expected |
| 08:10 | — Transformation completes (~1 min) | | Dedup candidates logged |
| 08:15 | — Loading completes (~5 min) | | Success/failure counts |
| 08:20 | — Search indexes rebuilt | | tsvector columns populated |
| 08:25 | — Validation suite runs | | All checks pass |
| 08:30 | Review migration report | Matt | Check failed records, dedup candidates |
| 08:45 | Review `dedup-candidates.csv` — approve or reject fuzzy merges | Matt | ~5-10 candidates expected |
| 09:00 | Run berth status reconciliation | Script | Review mismatches |
| 09:15 | Fix any identified issues (manual data corrections) | Matt | Direct SQL or API calls |
| 09:30 | Run MinIO file reorganization (if not skipped) | Script | File count and size verification |
| 10:00 | Review `unmatched-files.csv` — manually assign or accept as misc | Matt | ~10-20 files expected |
| 10:15 | Run `--validate-only` — final validation pass | Script | All checks green |
| 10:30 | **Migration data phase complete** | | Go/no-go for user setup |
**Total estimated time: ~2.5 hours** (generous — actual data migration should take 10-15 minutes for this volume: ~500 interests, ~200 berths, ~2000 expenses, ~500 invoices)
#### Saturday Afternoon — System Setup
| Time | Action | Owner | Verify |
| ----- | -------------------------------------------------------------------------------------------------------------- | ------ | ----------------------------- |
| 13:00 | Create user accounts in Better Auth via admin panel | Matt | Users listed |
| 13:15 | Assign users to Port Nimara with roles (super_admin for Matt, director for ops, sales_manager/agent for sales) | Matt | Permission check |
| 13:30 | Send "set password" emails to all users | System | Emails received |
| 14:00 | Configure Poste.io SMTP connection in admin settings | Matt | Test email sends successfully |
| 14:30 | Verify Documenso API connection | Matt | Health check passes |
| 15:00 | Verify MinIO connection + presigned URLs work | Matt | Test file download |
| 15:30 | Configure system_settings: berth status rules, reminder defaults, EOI reminder settings | Matt | Settings saved |
| 16:00 | Configure currency rates (USD primary, ECD pegged at 2.70) | Matt | Rates display on berth page |
| 16:30 | Verify public API: `GET /api/public/berths` returns correct data | Matt | JSON response with berth data |
| 17:00 | **System setup complete** | | Ready for smoke testing |
#### Sunday — Smoke Test & Go Live
| Time | Action | Owner | Verify |
| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------------------------------------------------- |
| 09:00 | **Smoke 1: Login & Dashboard** — Login as Matt → dashboard loads → widgets show correct data → pipeline summary matches expected | Matt | Data present and correct |
| 09:20 | **Smoke 2: Client Browse** — Navigate to clients → scroll list → open a known high-profile client → verify contacts, notes, interests, files all migrated | Matt | All client data present |
| 09:40 | **Smoke 3: Interest Pipeline** — Open pipeline board → verify interests at correct stages → click interest → verify linked berth, milestones, EOI documents | Matt | Stages correct |
| 10:00 | **Smoke 4: Berth Data** — Navigate to berths → select berth → verify specs (LOA, beam, draft, price) → check berth map renders | Matt | Specs accurate |
| 10:20 | **Smoke 5: Financial Data** — Navigate to expenses → verify count and amounts → open an invoice → verify line items and total | Matt | Amounts correct |
| 10:40 | **Smoke 6: Create New Record** — Create a test client → create interest → link berth → verify audit log → delete test data | Matt | New records work |
| 11:00 | **Smoke 7: Email** — Compose test email → send → verify delivery → check thread | Matt | Email works |
| 11:20 | **Smoke 8: Search** — Cmd+K → search for known client name → verify results → search for berth number → verify | Matt | Search works |
| 11:40 | **Smoke 9: Notifications** — Trigger a test reminder → check notification bell → verify toast | Matt | Notifications work |
| 12:00 | Lunch break | | All smokes passed |
| 13:00 | Update website public API endpoint to point at new CRM | Matt | Website berth map updates |
| 13:30 | Update DNS/nginx to point CRM domain at new application | Matt | CRM accessible at production URL |
| 14:00 | Final verification: access CRM at production URL, run through one more smoke test | Matt | All green |
| 14:30 | **GO LIVE DECISION** | Matt | If all pass → live. If critical issue → rollback. |
#### Monday — Day 1 Production
| Time | Action | Owner |
| ----------- | ----------------------------------------------------------------------------- | ----- |
| 07:30 | Check system health dashboard, review overnight alerts | Matt |
| 08:00 | Team logs in, sets passwords | Team |
| 08:30 | Quick 15-min walkthrough of new system for team (screen share) | Matt |
| 08:3017:00 | Dev on standby for rapid hotfixes | Dev |
| 12:00 | Mid-day check: review audit logs for errors, check BullMQ queues | Matt |
| 17:00 | End-of-day: review full day's audit logs, check data integrity, note feedback | Matt |
---
### Rollback Plan
#### Decision Matrix
| Severity | Example | Action | Max Fix Time |
| -------------- | ---------------------------------------------------- | -------------------------------------------- | ---------------- |
| Cosmetic | UI alignment, wrong color, missing icon | Fix and deploy. No rollback. | — |
| Minor data | One client missing a note, wrong category on expense | Fix via direct SQL. No rollback. | 30 min |
| Feature broken | Can't create invoices, email not sending | Attempt fix. If unfixable → rollback. | 2 hours |
| Data loss | Client records missing, expense amounts wrong | **Immediate rollback.** Investigate offline. | 0 (rollback now) |
| Auth broken | Can't login, session issues, permission errors | Attempt fix. If unfixable → rollback. | 1 hour |
| Critical | Database corruption, application won't start | **Immediate rollback.** | 0 (rollback now) |
#### Rollback Execution Steps
1. **Switch DNS/nginx** back to NocoDB system (update nginx upstream, reload — takes 30 seconds)
2. **Notify team** via email: "Use the old system until further notice. Do NOT use the new CRM."
3. **NocoDB was never turned off** — it's running on separate infrastructure with pre-freeze data
4. **Any data entered in new CRM after go-live** — document what was entered and manually re-enter in NocoDB
5. **Investigate the issue** in the new CRM (it's still running, just not DNS-pointed)
6. **Fix, re-test, schedule second cutover** (next weekend)
**Key safety:** NocoDB runs in parallel for 30 days post-migration. DNS switching is instant and reversible. No data is destroyed.
---
### Post-Migration Cleanup (Week 1)
| Task | When | Owner |
| ------------------------------------------------------------- | ------- | ----- |
| Verify all users have logged in and set passwords | Day 2 | Matt |
| Review audit logs for errors or unusual patterns | Day 23 | Matt |
| Collect team feedback on new system (quick survey or chat) | Day 3 | Matt |
| Address quick-fix issues from feedback | Day 35 | Dev |
| Monitor BullMQ queues daily for failed jobs | Daily | Matt |
| Verify automated backup ran at 02:00 and is downloadable | Day 2 | Matt |
| Delete old MinIO file paths (originals that were reorganized) | Day 7 | Dev |
| Run full database backup and download a copy off-server | Day 7 | Matt |
| Confirm Google Calendar sync works for any connected user | Day 3 | Matt |
| Review scratchpad/notes workflow with sales team | Day 5 | Matt |
### Post-Migration Cleanup (Week 24)
| Task | When | Owner |
| ------------------------------------------------------ | ------- | ----- |
| Monitor system health dashboard weekly | Weekly | Matt |
| Address any remaining feedback items | Ongoing | Dev |
| Verify currency rate auto-refresh is working | Day 14 | Matt |
| Run audit log export for first month | Day 30 | Matt |
| **Decommission NocoDB** (stop container, archive data) | Day 30+ | Matt |
---
## 3. Acceptance Criteria
### Migration Script (AC-L6-01 through AC-L6-10)
1. `--dry-run` mode completes without touching PostgreSQL or MinIO, produces full transformation report
2. `--full` mode extracts all 4 NocoDB tables, transforms, loads, and validates
3. `--delta` mode migrates only records modified after a given timestamp
4. `--validate-only` mode runs all validation checks against existing data
5. Client deduplication correctly merges interests by exact email match
6. Fuzzy name matches logged to `dedup-candidates.csv` for manual review (not auto-merged)
7. Per-record error handling: a single bad record does not fail the entire entity batch
8. Migration report generated (JSON + text) with full statistics
9. Pipeline stages mapped correctly from NocoDB values to BR-010 stages
10. Synthetic audit logs created for all migrated entities with `source: "nocodb_migration"` metadata
### Data Integrity (AC-L6-11 through AC-L6-18)
11. Client count ≤ NocoDB interest count (dedup reduces)
12. Interest count = NocoDB interest count (1:1 mapping)
13. Berth count = NocoDB berth count (1:1 mapping)
14. Expense count = NocoDB expense count (1:1 mapping)
15. Invoice count = NocoDB invoice count (1:1 mapping)
16. Zero orphan records (no FK violations): interests.client_id, interests.berth_id, invoice_expenses
17. tsvector search indexes populated on all clients, interests, and berths
18. Berth status reconciliation report generated — mismatches identified for manual review
### File Migration (AC-L6-19 through AC-L6-22)
19. All MinIO files cataloged in `files` table with correct metadata
20. Files matched to clients where possible, unmatched files logged to CSV
21. File reorganization preserves original file content (size verification)
22. New MinIO path follows `{portSlug}/{entity}/{entityId}/{uuid}.{ext}` convention
### Cutover (AC-L6-23 through AC-L6-28)
23. User accounts created with correct roles and port assignments
24. "Set password" emails delivered to all users
25. Poste.io SMTP connection verified (test email sends)
26. Documenso API connection verified (health check passes)
27. Public API returns correct berth data at production URL
28. All 9 smoke tests pass before go-live decision
### Rollback (AC-L6-29 through AC-L6-31)
29. NocoDB remains running on separate infrastructure throughout migration
30. DNS/nginx rollback tested and documented (can switch in < 1 minute)
31. Rollback decision matrix documented with clear severity criteria
---
## 4. Self-Review Checklist
### Script Quality
- [ ] Migration script runs with `tsx` (TypeScript execution) — no build step needed
- [ ] All NocoDB field names match the actual table structure (verify against NocoDB API explorer)
- [ ] Transformation helpers have unit tests: `parseDimension`, `parsePrice`, `normalizeClientName`, `mapPipelineStage`
- [ ] Error messages include NocoDB record IDs for easy debugging
- [ ] CLI modes are mutually exclusive and well-documented
- [ ] Configuration is entirely environment-variable based (no hardcoded secrets)
### Data Safety
- [ ] No data is deleted from NocoDB at any point
- [ ] MinIO file reorganization copies first, verifies, then deletes originals only on Day 7 (not during migration)
- [ ] `--dry-run` truly doesn't modify any external state
- [ ] Per-record error handling prevents cascade failures
- [ ] All PostgreSQL inserts use the Drizzle ORM (no raw SQL string concatenation)
- [ ] Sensitive fields (email, phone) in audit logs are masked per SECURITY-GUIDELINES.md
### Validation
- [ ] Count validation covers all 6 entity types (clients, contacts, interests, berths, expenses, invoices)
- [ ] Orphan detection covers all FK relationships
- [ ] Spot-check queries are parameterized (not hardcoded record IDs — configurable)
- [ ] Berth status reconciliation runs but doesn't auto-fix (human review)
- [ ] File integrity check compares sizes of source and target
### Process
- [ ] Team notification email drafted and ready
- [ ] Smoke test checklist covers all major features (login, browse, create, search, email, notifications)
- [ ] Rollback procedure documented with timing and responsible parties
- [ ] Post-migration task list assigned to specific owners with dates
- [ ] NocoDB decommission scheduled for Day 30 (not earlier)
---
## Codex Addenda — Merged from Competing Plan Review
### 1. Migration Artifacts as First-Class Outputs
Treat migration artifacts as **first-class outputs**, not transient logs. Every run produces an immutable, timestamped output directory:
```
scripts/migrate-nocodb/output/YYYYMMDD-HHMM/
raw/ # Raw NocoDB JSON/CSV snapshots
normalized/ # Transformed data ready for load
id-maps/ # Source→target ID mappings (JSON)
reports/
extract-counts.json
migration-warnings.csv
dedup-candidates.csv
unmatched-files.csv
validation-report.json
smoke-test.json
```
### 2. ID Maps Outside Application Schema
Source-to-target ID maps are stored in artifact JSON files, **not** as metadata columns in the target schema. The locked schema does not include source-ID columns on most entities. Preserve original references only in `id-maps/*.json` for traceability.
### 3. Dry-Run Timing at Day -5
Start dry-run rehearsals at **Day -5** (not day-of). Time the full cutover script, including file copy, and write the final operator checklist based on actual measured durations. File migration is the slowest step and must be timed in rehearsal.
### 4. Entity-Group Transactions
Each entity group loads in its own transaction. If a group fails, the run stops and the database is reset before retry. Load order:
1. `ports` seed verified
2. `clients``client_contacts`
3. `berths``berth_map_data`
4. `interests``interest_notes`
5. `documents``document_signers`
6. `expenses`
7. `files`
8. `invoices``invoice_line_items``invoice_expenses`
9. `tags` and tag junctions
10. Synthetic `audit_logs`
### 5. Resumable File Copy Manifest
File copy failures should produce a **resumable manifest** so the entire data load does not need to rerun for a small number of file issues. Do not delete original MinIO objects during the migration window — copy first, validate, then schedule cleanup after the go-live safety window.
### 6. Dedup Conflict Handling
Client dedup conflicts are never silently merged beyond the approved rules. Uncertain matches go to `dedup-candidates.csv` for manual review. If a berth reference cannot be resolved, the interest loads with `berth_id = null` and a warning artifact.
### 7. Rollback Semantics
Rollback does **not** attempt to reverse-write into NocoDB. It restores traffic to the old system and treats any new-system data as discardable cutover attempts. If the team enters data into the old system after freeze, the cutover is invalid and must be restarted from a new final snapshot.
### 8. User Bootstrap
User bootstrap is manual or admin-driven because Better Auth users do not come from NocoDB. All post-migration "create user", "assign role", and "set password email" steps are audited.

19
components.json Normal file
View File

@@ -0,0 +1,19 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app/globals.css",
"baseColor": "slate",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}

390
design-system-preview.html Normal file
View File

@@ -0,0 +1,390 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Port Nimara CRM — Design System Preview</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--navy: #1e2844;
--brand: #3a7bc8;
--navy-80: #474e66;
--navy-60: #71768a;
--navy-40: #9ea1af;
--navy-20: #cdcfd6;
--brand-80: #6196d3;
--brand-60: #89b0de;
--brand-40: #b1cbe9;
--brand-20: #d8e5f4;
--sage: #dae3c1;
--mint: #add5b3;
--teal: #83aab1;
--purple: #685aa3;
--success: #2d8a4e;
--warning: #e6a817;
--error: #d32f2f;
--bg: #ffffff;
--bg-secondary: #f8f9fa;
--bg-tertiary: #f1f3f5;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Inter', system-ui, -apple-system, Arial, sans-serif; color: var(--navy); background: var(--bg); line-height: 1.5; }
.page { max-width: 1280px; margin: 0 auto; padding: 40px 24px; }
h1 { font-size: 30px; font-weight: 700; margin-bottom: 8px; }
h2 { font-size: 20px; font-weight: 600; margin: 40px 0 16px; padding-bottom: 8px; border-bottom: 2px solid var(--navy-20); }
h3 { font-size: 16px; font-weight: 600; margin: 24px 0 12px; }
p { color: var(--navy-80); margin-bottom: 16px; font-size: 14px; }
.subtitle { color: var(--navy-60); font-size: 14px; }
/* Color Grid */
.color-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 16px; margin: 16px 0; }
.color-card { border-radius: 8px; overflow: hidden; border: 1px solid var(--navy-20); }
.color-swatch { height: 80px; }
.color-info { padding: 10px 12px; font-size: 12px; }
.color-info strong { display: block; font-size: 13px; margin-bottom: 2px; }
.color-info code { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--navy-60); }
/* Type Scale */
.type-row { display: flex; align-items: baseline; gap: 16px; padding: 10px 0; border-bottom: 1px solid var(--bg-tertiary); }
.type-label { width: 80px; font-size: 11px; color: var(--navy-60); font-family: monospace; flex-shrink: 0; }
/* Button Grid */
.btn-grid { display: flex; flex-wrap: wrap; gap: 12px; margin: 16px 0; align-items: center; }
.btn { display: inline-flex; align-items: center; justify-content: center; padding: 8px 20px; border-radius: 6px; font-size: 14px; font-weight: 500; font-family: inherit; cursor: pointer; border: none; transition: all 150ms; }
.btn-primary { background: var(--brand); color: white; }
.btn-primary:hover { background: #2f6ab5; }
.btn-secondary { background: var(--navy); color: white; }
.btn-secondary:hover { background: #171f35; }
.btn-outline { background: transparent; color: var(--navy); border: 1.5px solid var(--navy-20); }
.btn-outline:hover { background: var(--bg-tertiary); border-color: var(--navy-40); }
.btn-ghost { background: transparent; color: var(--brand); }
.btn-ghost:hover { background: var(--brand-20); }
.btn-destructive { background: var(--error); color: white; }
.btn-sm { padding: 5px 12px; font-size: 13px; }
.btn-lg { padding: 11px 28px; font-size: 15px; }
/* Badge */
.badge { display: inline-flex; align-items: center; padding: 2px 10px; border-radius: 9999px; font-size: 12px; font-weight: 500; }
/* Card */
.card { background: white; border: 1px solid var(--navy-20); border-radius: 12px; padding: 20px; box-shadow: 0 1px 3px rgba(30,40,68,0.10), 0 1px 2px rgba(30,40,68,0.06); }
/* Table */
.demo-table { width: 100%; border-collapse: collapse; font-size: 14px; margin: 16px 0; }
.demo-table th { text-align: left; padding: 10px 12px; background: var(--bg-tertiary); font-weight: 500; font-size: 13px; color: var(--navy-80); border-bottom: 2px solid var(--navy-20); }
.demo-table td { padding: 10px 12px; border-bottom: 1px solid var(--bg-tertiary); }
.demo-table tr:hover td { background: var(--brand-20); }
/* Status dot */
.status-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
/* Input */
.input { padding: 8px 12px; border: 1.5px solid var(--navy-20); border-radius: 6px; font-size: 14px; font-family: inherit; color: var(--navy); width: 240px; outline: none; transition: border 150ms; }
.input:focus { border-color: var(--brand); box-shadow: 0 0 0 3px rgba(58,123,200,0.15); }
.input::placeholder { color: var(--navy-60); }
.label { display: block; font-size: 13px; font-weight: 500; margin-bottom: 6px; }
/* Sidebar Preview */
.sidebar-preview { width: 256px; background: var(--navy); border-radius: 12px; padding: 20px 0; color: var(--navy-20); }
.sidebar-logo { padding: 0 20px 20px; border-bottom: 1px solid var(--navy-80); margin-bottom: 8px; }
.sidebar-logo span { font-size: 15px; font-weight: 600; color: white; letter-spacing: 0.5px; }
.sidebar-item { display: flex; align-items: center; gap: 10px; padding: 9px 20px; font-size: 14px; cursor: pointer; transition: background 150ms; }
.sidebar-item:hover { background: #171f35; }
.sidebar-item.active { background: rgba(58,123,200,0.12); color: white; }
.sidebar-item .icon { width: 18px; height: 18px; border-radius: 3px; background: var(--teal); opacity: 0.7; flex-shrink: 0; }
.sidebar-item.active .icon { background: var(--brand); opacity: 1; }
.sidebar-section { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.8px; padding: 16px 20px 6px; color: var(--navy-60); }
/* Dashboard Card */
.stat-card { background: white; border: 1px solid var(--navy-20); border-radius: 12px; padding: 20px; }
.stat-card .stat-label { font-size: 13px; color: var(--navy-60); margin-bottom: 4px; }
.stat-card .stat-value { font-size: 30px; font-weight: 700; color: var(--navy); }
.stat-card .stat-change { font-size: 12px; margin-top: 4px; }
.stat-change.up { color: var(--success); }
.stat-change.down { color: var(--error); }
/* Flex helpers */
.flex { display: flex; }
.flex-wrap { flex-wrap: wrap; }
.gap-4 { gap: 16px; }
.gap-3 { gap: 12px; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }
/* Toast Preview */
.toast { display: flex; align-items: center; gap: 10px; padding: 12px 16px; border-radius: 8px; font-size: 13px; box-shadow: 0 10px 15px rgba(30,40,68,0.10); border: 1px solid; max-width: 360px; }
.toast-success { background: #e8f5e9; border-color: #a5d6a7; color: #1b5e20; }
.toast-warning { background: #fff8e1; border-color: #ffe082; color: #e65100; }
.toast-error { background: #ffebee; border-color: #ef9a9a; color: #b71c1c; }
.toast-info { background: var(--brand-20); border-color: var(--brand-60); color: #1a4e8a; }
/* Berth status bar */
.berth-bar { display: flex; border-radius: 6px; overflow: hidden; height: 32px; margin: 16px 0; }
.berth-bar div { display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 600; color: white; }
@media (max-width: 768px) {
.grid-2, .grid-4 { grid-template-columns: 1fr; }
.sidebar-preview { width: 100%; }
}
</style>
</head>
<body>
<div class="page">
<h1>Port Nimara CRM — Design System</h1>
<p class="subtitle">Based on the official Port Nimara Brand Guidelines. All tokens derived from PMS 553, PMS 660, and the secondary palette.</p>
<!-- PRIMARY COLORS -->
<h2>1. Primary Brand Colors</h2>
<div class="color-grid">
<div class="color-card">
<div class="color-swatch" style="background:#1e2844"></div>
<div class="color-info"><strong>PMS 553 — Navy</strong><code>#1e2844</code><br>Headings, sidebar, dark backgrounds</div>
</div>
<div class="color-card">
<div class="color-swatch" style="background:#3a7bc8"></div>
<div class="color-info"><strong>PMS 660 — Brand Blue</strong><code>#3a7bc8</code><br>Primary actions, links, focus</div>
</div>
<div class="color-card">
<div class="color-swatch" style="background:#000000"></div>
<div class="color-info"><strong>Black</strong><code>#000000</code><br>Maximum contrast text</div>
</div>
<div class="color-card">
<div class="color-swatch" style="background:#ffffff; border-bottom: 1px solid #cdcfd6"></div>
<div class="color-info"><strong>White</strong><code>#ffffff</code><br>Page backgrounds</div>
</div>
</div>
<!-- NAVY TINTS -->
<h3>Navy Tint Ladder (PMS 553)</h3>
<div class="color-grid">
<div class="color-card"><div class="color-swatch" style="background:#1e2844"></div><div class="color-info"><strong>100%</strong><code>#1e2844</code></div></div>
<div class="color-card"><div class="color-swatch" style="background:#474e66"></div><div class="color-info"><strong>80%</strong><code>#474e66</code></div></div>
<div class="color-card"><div class="color-swatch" style="background:#71768a"></div><div class="color-info"><strong>60%</strong><code>#71768a</code></div></div>
<div class="color-card"><div class="color-swatch" style="background:#9ea1af"></div><div class="color-info"><strong>40%</strong><code>#9ea1af</code></div></div>
<div class="color-card"><div class="color-swatch" style="background:#cdcfd6"></div><div class="color-info"><strong>20%</strong><code>#cdcfd6</code></div></div>
</div>
<h3>Brand Blue Tint Ladder (PMS 660)</h3>
<div class="color-grid">
<div class="color-card"><div class="color-swatch" style="background:#3a7bc8"></div><div class="color-info"><strong>100%</strong><code>#3a7bc8</code></div></div>
<div class="color-card"><div class="color-swatch" style="background:#6196d3"></div><div class="color-info"><strong>80%</strong><code>#6196d3</code></div></div>
<div class="color-card"><div class="color-swatch" style="background:#89b0de"></div><div class="color-info"><strong>60%</strong><code>#89b0de</code></div></div>
<div class="color-card"><div class="color-swatch" style="background:#b1cbe9"></div><div class="color-info"><strong>40%</strong><code>#b1cbe9</code></div></div>
<div class="color-card"><div class="color-swatch" style="background:#d8e5f4"></div><div class="color-info"><strong>20%</strong><code>#d8e5f4</code></div></div>
</div>
<!-- SECONDARY COLORS -->
<h2>2. Secondary Palette</h2>
<div class="color-grid">
<div class="color-card"><div class="color-swatch" style="background:#dae3c1"></div><div class="color-info"><strong>PMS 7485 — Sage</strong><code>#dae3c1</code><br>Soft backgrounds, low priority</div></div>
<div class="color-card"><div class="color-swatch" style="background:#add5b3"></div><div class="color-info"><strong>PMS 344 — Mint</strong><code>#add5b3</code><br>Available, success hints</div></div>
<div class="color-card"><div class="color-swatch" style="background:#83aab1"></div><div class="color-info"><strong>PMS 5493 — Teal</strong><code>#83aab1</code><br>Secondary info, nav icons</div></div>
<div class="color-card"><div class="color-swatch" style="background:#685aa3"></div><div class="color-info"><strong>PMS 2725 — Purple</strong><code>#685aa3</code><br>Premium, reserved, highlights</div></div>
</div>
<!-- STATUS COLORS -->
<h2>3. Status Colors</h2>
<div class="color-grid">
<div class="color-card"><div class="color-swatch" style="background:#2d8a4e"></div><div class="color-info"><strong>Success</strong><code>#2d8a4e</code><br>Active, paid, confirmed, signed</div></div>
<div class="color-card"><div class="color-swatch" style="background:#e6a817"></div><div class="color-info"><strong>Warning</strong><code>#e6a817</code><br>Pending, expiring, attention</div></div>
<div class="color-card"><div class="color-swatch" style="background:#d32f2f"></div><div class="color-info"><strong>Error</strong><code>#d32f2f</code><br>Overdue, failed, rejected</div></div>
<div class="color-card"><div class="color-swatch" style="background:#3a7bc8"></div><div class="color-info"><strong>Info</strong><code>#3a7bc8</code><br>Informational (brand blue)</div></div>
</div>
<!-- BERTH STATUS -->
<h3>Berth Status Colors</h3>
<div class="berth-bar">
<div style="background:#add5b3; width:30%; color:#1e2844">Available</div>
<div style="background:#3a7bc8; width:35%">Occupied</div>
<div style="background:#685aa3; width:15%">Reserved</div>
<div style="background:#e6a817; width:10%; color:#1e2844">Maint.</div>
<div style="background:#999; width:10%">N/A</div>
</div>
<!-- TYPOGRAPHY -->
<h2>4. Typography (Inter)</h2>
<div style="margin: 16px 0">
<div class="type-row"><span class="type-label">text-3xl</span><span style="font-size:30px;font-weight:700">Dashboard overview</span></div>
<div class="type-row"><span class="type-label">text-2xl</span><span style="font-size:24px;font-weight:700">Client Details</span></div>
<div class="type-row"><span class="type-label">text-xl</span><span style="font-size:20px;font-weight:600">Berth Management</span></div>
<div class="type-row"><span class="type-label">text-lg</span><span style="font-size:18px;font-weight:600">Recent Activity</span></div>
<div class="type-row"><span class="type-label">text-base</span><span style="font-size:16px">This is standard body text used throughout the CRM interface.</span></div>
<div class="type-row"><span class="type-label">text-sm</span><span style="font-size:14px;color:#474e66">Table cell content, secondary descriptions, form labels</span></div>
<div class="type-row"><span class="type-label">text-xs</span><span style="font-size:12px;color:#71768a">Timestamps, fine print, metadata</span></div>
</div>
<!-- BUTTONS -->
<h2>5. Buttons</h2>
<div class="btn-grid">
<button class="btn btn-primary">Primary</button>
<button class="btn btn-secondary">Secondary</button>
<button class="btn btn-outline">Outline</button>
<button class="btn btn-ghost">Ghost</button>
<button class="btn btn-destructive">Destructive</button>
</div>
<h3>Sizes</h3>
<div class="btn-grid">
<button class="btn btn-primary btn-sm">Small</button>
<button class="btn btn-primary">Default</button>
<button class="btn btn-primary btn-lg">Large</button>
</div>
<!-- BADGES -->
<h2>6. Badges & Status Indicators</h2>
<div class="flex flex-wrap gap-3" style="display:flex;flex-wrap:wrap;gap:10px;margin:16px 0">
<span class="badge" style="background:#e8f5e9;color:#1b5e20"><span class="status-dot" style="background:#2d8a4e"></span>Active</span>
<span class="badge" style="background:#fff8e1;color:#e65100"><span class="status-dot" style="background:#e6a817"></span>Pending</span>
<span class="badge" style="background:#ffebee;color:#b71c1c"><span class="status-dot" style="background:#d32f2f"></span>Overdue</span>
<span class="badge" style="background:var(--brand-20);color:#1a4e8a"><span class="status-dot" style="background:var(--brand)"></span>In Progress</span>
<span class="badge" style="background:#f3f0fa;color:#4d4280"><span class="status-dot" style="background:var(--purple)"></span>Reserved</span>
<span class="badge" style="background:#edf6ee;color:#1b5e20"><span class="status-dot" style="background:var(--mint)"></span>Available</span>
<span class="badge" style="background:var(--bg-tertiary);color:var(--navy-60)"><span class="status-dot" style="background:#999"></span>Inactive</span>
</div>
<!-- INTEREST STAGE PIPELINE -->
<h3>Interest Stage Pipeline</h3>
<div style="display:flex;gap:4px;margin:16px 0">
<div style="flex:1;padding:8px 12px;background:var(--sage);border-radius:6px 0 0 6px;text-align:center;font-size:12px;font-weight:600;color:var(--navy)">Lead</div>
<div style="flex:1;padding:8px 12px;background:var(--teal);text-align:center;font-size:12px;font-weight:600;color:white">Contacted</div>
<div style="flex:1;padding:8px 12px;background:var(--brand);text-align:center;font-size:12px;font-weight:600;color:white">Qualified</div>
<div style="flex:1;padding:8px 12px;background:var(--purple);text-align:center;font-size:12px;font-weight:600;color:white">Negotiating</div>
<div style="flex:1;padding:8px 12px;background:var(--success);border-radius:0 6px 6px 0;text-align:center;font-size:12px;font-weight:600;color:white">Won</div>
</div>
<!-- FORM ELEMENTS -->
<h2>7. Form Elements</h2>
<div class="grid-2" style="margin:16px 0">
<div>
<label class="label">Client Name</label>
<input class="input" placeholder="Enter client name..." style="width:100%">
</div>
<div>
<label class="label">Email Address</label>
<input class="input" type="email" placeholder="client@example.com" style="width:100%">
</div>
</div>
<!-- CARDS -->
<h2>8. Cards & Dashboard Stats</h2>
<div class="grid-4" style="margin:16px 0">
<div class="stat-card">
<div class="stat-label">Active Clients</div>
<div class="stat-value">247</div>
<div class="stat-change up">+12 this month</div>
</div>
<div class="stat-card">
<div class="stat-label">Berth Occupancy</div>
<div class="stat-value">84%</div>
<div class="stat-change up">+3% vs last quarter</div>
</div>
<div class="stat-card">
<div class="stat-label">Open Interests</div>
<div class="stat-value">38</div>
<div class="stat-change down">-5 vs last month</div>
</div>
<div class="stat-card">
<div class="stat-label">Revenue (MTD)</div>
<div class="stat-value">€1.2M</div>
<div class="stat-change up">+8% vs target</div>
</div>
</div>
<!-- TABLE -->
<h2>9. Data Table</h2>
<div style="border:1px solid var(--navy-20);border-radius:12px;overflow:hidden;margin:16px 0">
<table class="demo-table">
<thead>
<tr><th>Client</th><th>Berth</th><th>Status</th><th>Tenure Expires</th><th>Annual Fee</th></tr>
</thead>
<tbody>
<tr>
<td><strong>Harrison Yacht Group</strong></td>
<td>A-14</td>
<td><span class="badge" style="background:#e8f5e9;color:#1b5e20"><span class="status-dot" style="background:#2d8a4e"></span>Active</span></td>
<td>Dec 2027</td>
<td>€185,000</td>
</tr>
<tr>
<td><strong>Azure Marine Ltd</strong></td>
<td>B-07</td>
<td><span class="badge" style="background:#fff8e1;color:#e65100"><span class="status-dot" style="background:#e6a817"></span>Expiring</span></td>
<td>Apr 2026</td>
<td>€142,000</td>
</tr>
<tr>
<td><strong>Mediterranean Charters</strong></td>
<td>C-22</td>
<td><span class="badge" style="background:var(--brand-20);color:#1a4e8a"><span class="status-dot" style="background:var(--brand)"></span>Negotiating</span></td>
<td></td>
<td>€210,000</td>
</tr>
<tr>
<td><strong>Pacific Ventures</strong></td>
<td>A-03</td>
<td><span class="badge" style="background:#ffebee;color:#b71c1c"><span class="status-dot" style="background:#d32f2f"></span>Overdue</span></td>
<td>Jan 2026</td>
<td>€165,000</td>
</tr>
</tbody>
</table>
</div>
<!-- TOAST NOTIFICATIONS -->
<h2>10. Toast Notifications</h2>
<div style="display:flex;flex-direction:column;gap:10px;margin:16px 0">
<div class="toast toast-success">&#10003;&nbsp; EOI document sent to Harrison Yacht Group for signing</div>
<div class="toast toast-warning">&#9888;&nbsp; Azure Marine tenure expires in 45 days</div>
<div class="toast toast-error">&#10007;&nbsp; Failed to sync calendar — check Google API connection</div>
<div class="toast toast-info">&#8505;&nbsp; 3 new emails received for Mediterranean Charters</div>
</div>
<!-- SIDEBAR -->
<h2>11. Sidebar Navigation</h2>
<div style="margin:16px 0">
<div class="sidebar-preview">
<div class="sidebar-logo">
<span>PORT NIMARA</span>
</div>
<div class="sidebar-section">Main</div>
<div class="sidebar-item active"><div class="icon"></div>Dashboard</div>
<div class="sidebar-item"><div class="icon"></div>Clients</div>
<div class="sidebar-item"><div class="icon"></div>Berths</div>
<div class="sidebar-item"><div class="icon"></div>Interests</div>
<div class="sidebar-section">Operations</div>
<div class="sidebar-item"><div class="icon"></div>Email</div>
<div class="sidebar-item"><div class="icon"></div>Documents</div>
<div class="sidebar-item"><div class="icon"></div>Expenses</div>
<div class="sidebar-item"><div class="icon"></div>Files</div>
<div class="sidebar-section">Admin</div>
<div class="sidebar-item"><div class="icon"></div>Settings</div>
</div>
</div>
<!-- SHADOWS -->
<h2>12. Elevation & Shadows</h2>
<div style="display:flex;gap:24px;margin:16px 0;flex-wrap:wrap">
<div style="width:140px;height:80px;border-radius:8px;background:white;display:flex;align-items:center;justify-content:center;font-size:12px;color:var(--navy-60);box-shadow:0 1px 2px rgba(30,40,68,0.06)">shadow-sm</div>
<div style="width:140px;height:80px;border-radius:8px;background:white;display:flex;align-items:center;justify-content:center;font-size:12px;color:var(--navy-60);box-shadow:0 1px 3px rgba(30,40,68,0.10), 0 1px 2px rgba(30,40,68,0.06)">shadow</div>
<div style="width:140px;height:80px;border-radius:8px;background:white;display:flex;align-items:center;justify-content:center;font-size:12px;color:var(--navy-60);box-shadow:0 4px 6px rgba(30,40,68,0.10), 0 2px 4px rgba(30,40,68,0.06)">shadow-md</div>
<div style="width:140px;height:80px;border-radius:8px;background:white;display:flex;align-items:center;justify-content:center;font-size:12px;color:var(--navy-60);box-shadow:0 10px 15px rgba(30,40,68,0.10), 0 4px 6px rgba(30,40,68,0.05)">shadow-lg</div>
<div style="width:140px;height:80px;border-radius:8px;background:white;display:flex;align-items:center;justify-content:center;font-size:12px;color:var(--navy-60);box-shadow:0 20px 25px rgba(30,40,68,0.10), 0 8px 10px rgba(30,40,68,0.04)">shadow-xl</div>
</div>
<!-- PRIORITY BADGES -->
<h2>13. Priority Badges</h2>
<div style="display:flex;gap:10px;margin:16px 0">
<span class="badge" style="background:var(--sage);color:var(--navy)">Low</span>
<span class="badge" style="background:var(--brand-20);color:var(--brand)">Medium</span>
<span class="badge" style="background:#fff8e1;color:#e6a817;font-weight:600">High</span>
<span class="badge" style="background:#ffebee;color:#d32f2f;font-weight:600">Critical</span>
</div>
<div style="margin-top:48px;padding-top:24px;border-top:2px solid var(--navy-20);font-size:12px;color:var(--navy-60)">
Port Nimara CRM Design System &mdash; Generated from Brand Guidelines PDF &mdash; All color values sourced from official Pantone specifications
</div>
</div>
</body>
</html>

25
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,25 @@
services:
postgres:
ports:
- "5433:5432"
redis:
ports:
- "6379:6379"
crm-app:
build:
context: .
dockerfile: Dockerfile.dev
ports:
- "3000:3000"
volumes:
- .:/app
- /app/node_modules
command: pnpm dev
crm-worker:
profiles: ["worker"] # Optional in dev — server.ts runs workers inline
nginx:
profiles: ["nginx"] # Skip nginx in dev

71
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,71 @@
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: port_nimara_crm
POSTGRES_USER: ${DB_USER:-crm}
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
- ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/01-init.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-crm} -d port_nimara_crm"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
networks:
- internal
redis:
image: redis:7-alpine
command: redis-server --requirepass ${REDIS_PASSWORD} --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes:
- redisdata:/data
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
networks:
- internal
crm-app:
image: code.letsbe.solutions/letsbe/pn-new-crm/crm-app:latest
env_file: .env
ports:
- "7100:3000"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"]
interval: 15s
timeout: 5s
retries: 3
restart: unless-stopped
networks:
- internal
crm-worker:
image: code.letsbe.solutions/letsbe/pn-new-crm/crm-worker:latest
env_file: .env
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped
networks:
- internal
volumes:
pgdata:
redisdata:
networks:
internal:
driver: bridge

83
docker-compose.yml Normal file
View File

@@ -0,0 +1,83 @@
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: port_nimara_crm
POSTGRES_USER: ${DB_USER:-crm}
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
- ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/01-init.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-crm} -d port_nimara_crm"]
interval: 10s
timeout: 5s
retries: 5
networks:
- internal
redis:
image: redis:7-alpine
command: redis-server --requirepass ${REDIS_PASSWORD} --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes:
- redisdata:/data
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 10s
timeout: 5s
retries: 5
networks:
- internal
crm-app:
build:
context: .
dockerfile: Dockerfile
env_file: .env
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"]
interval: 15s
timeout: 5s
retries: 3
networks:
- internal
crm-worker:
build:
context: .
dockerfile: Dockerfile.worker
env_file: .env
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks:
- internal
nginx:
image: nginx:alpine
ports:
- "443:443"
- "80:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/certs:/etc/nginx/certs:ro
depends_on:
crm-app:
condition: service_healthy
networks:
- internal
volumes:
pgdata:
redisdata:
networks:
internal:
driver: bridge

3
docker/postgres/init.sql Normal file
View File

@@ -0,0 +1,3 @@
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pg_trgm";

12
drizzle.config.ts Normal file
View File

@@ -0,0 +1,12 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/lib/db/schema',
out: './src/lib/db/migrations',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
verbose: true,
strict: true,
});

28
eslint.config.mjs Normal file
View File

@@ -0,0 +1,28 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript", "prettier"),
{
rules: {
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_" },
],
},
},
{
ignores: ["client-portal/**"],
},
];
export default eslintConfig;

595
mockup-A-sidebar-dark.html Normal file
View File

@@ -0,0 +1,595 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Port Nimara CRM — Mockup A: Dark Sidebar + Light Content</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--navy: #1e2844;
--navy-80: #474e66;
--navy-60: #71768a;
--navy-40: #9ea1af;
--navy-20: #cdcfd6;
--brand-blue: #3a7bc8;
--brand-blue-hover: #2f6ab5;
--brand-blue-80: #6196d3;
--brand-blue-60: #89b0de;
--brand-blue-20: #d8e5f4;
--teal: #83aab1;
--purple: #685aa3;
--mint: #add5b3;
--sage: #dae3c1;
--success: #2d8a4e;
--success-bg: #e8f5e9;
--warning: #e6a817;
--warning-bg: #fff8e1;
--error: #d32f2f;
--error-bg: #ffebee;
--sidebar-hover: #171f35;
--bg: #ffffff;
--bg-secondary: #f8f9fa;
--bg-tertiary: #f1f3f5;
--border: #cdcfd6;
--shadow-sm: 0 1px 2px rgba(30,40,68,0.06);
--shadow: 0 1px 3px rgba(30,40,68,0.10), 0 1px 2px rgba(30,40,68,0.06);
--shadow-md: 0 4px 6px rgba(30,40,68,0.07), 0 2px 4px rgba(30,40,68,0.06);
--shadow-lg: 0 10px 15px rgba(30,40,68,0.10), 0 4px 6px rgba(30,40,68,0.05);
--radius: 8px;
--radius-sm: 6px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Inter', -apple-system, sans-serif; background: var(--bg-secondary); color: var(--navy); }
/* === LAYOUT === */
.app { display: flex; min-height: 100vh; }
/* === SIDEBAR === */
.sidebar {
width: 260px;
background: var(--navy);
color: var(--navy-20);
display: flex;
flex-direction: column;
position: fixed;
top: 0;
left: 0;
bottom: 0;
z-index: 10;
}
.sidebar-logo {
padding: 24px 20px;
border-bottom: 1px solid var(--navy-80);
display: flex;
align-items: center;
gap: 12px;
}
.sidebar-logo .logo-mark {
width: 36px; height: 36px;
background: var(--brand-blue);
border-radius: 8px;
display: flex; align-items: center; justify-content: center;
font-weight: 700; font-size: 16px; color: #fff;
}
.sidebar-logo .logo-text {
font-size: 15px; font-weight: 600; color: #fff;
line-height: 1.2;
}
.sidebar-logo .logo-text span { display: block; font-size: 11px; font-weight: 400; color: var(--navy-40); }
.sidebar-nav { flex: 1; padding: 16px 12px; overflow-y: auto; }
.nav-section { margin-bottom: 24px; }
.nav-section-title {
font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 1.2px;
color: var(--navy-60); padding: 0 8px; margin-bottom: 8px;
}
.nav-item {
display: flex; align-items: center; gap: 10px;
padding: 9px 12px; border-radius: var(--radius-sm);
font-size: 13.5px; font-weight: 500; color: var(--navy-20);
cursor: pointer; transition: all 0.15s;
margin-bottom: 2px;
}
.nav-item:hover { background: var(--sidebar-hover); color: #fff; }
.nav-item.active {
background: rgba(58,123,200,0.12); color: #fff;
border-left: 3px solid var(--brand-blue);
padding-left: 9px;
}
.nav-item .icon { width: 18px; text-align: center; color: var(--teal); font-size: 14px; }
.nav-item.active .icon { color: var(--brand-blue); }
.nav-item .badge {
margin-left: auto; background: var(--brand-blue); color: #fff;
font-size: 10px; font-weight: 600; padding: 2px 7px; border-radius: 10px;
}
.sidebar-footer {
padding: 16px 20px; border-top: 1px solid var(--navy-80);
display: flex; align-items: center; gap: 10px;
}
.sidebar-footer .avatar {
width: 32px; height: 32px; border-radius: 50%; background: var(--brand-blue);
display: flex; align-items: center; justify-content: center;
font-size: 12px; font-weight: 600; color: #fff;
}
.sidebar-footer .user-info { font-size: 12px; }
.sidebar-footer .user-info .name { font-weight: 600; color: #fff; }
.sidebar-footer .user-info .role { color: var(--navy-40); }
/* === MAIN CONTENT === */
.main { flex: 1; margin-left: 260px; }
.topbar {
background: #fff; border-bottom: 1px solid var(--border);
padding: 16px 32px; display: flex; align-items: center; justify-content: space-between;
position: sticky; top: 0; z-index: 5;
}
.topbar h1 { font-size: 20px; font-weight: 700; color: var(--navy); }
.topbar-right { display: flex; align-items: center; gap: 16px; }
.search-box {
background: var(--bg-tertiary); border: 1px solid var(--border);
border-radius: var(--radius); padding: 8px 14px 8px 36px;
font-size: 13px; color: var(--navy); width: 260px;
outline: none; font-family: inherit;
}
.search-box:focus { border-color: var(--brand-blue); background: #fff; box-shadow: 0 0 0 3px rgba(58,123,200,0.1); }
.icon-btn {
width: 36px; height: 36px; border-radius: 50%; background: var(--bg-tertiary);
border: 1px solid var(--border); display: flex; align-items: center; justify-content: center;
cursor: pointer; color: var(--navy-60); font-size: 14px; transition: all 0.15s;
}
.icon-btn:hover { background: var(--navy); color: #fff; border-color: var(--navy); }
.icon-btn .dot {
position: absolute; top: -2px; right: -2px; width: 8px; height: 8px;
background: var(--error); border-radius: 50%; border: 2px solid #fff;
}
/* === DASHBOARD CONTENT === */
.content { padding: 28px 32px; }
/* Welcome banner */
.welcome-banner {
background: linear-gradient(135deg, var(--navy) 0%, #2a3a5e 100%);
border-radius: var(--radius); padding: 28px 32px;
color: #fff; margin-bottom: 28px; position: relative; overflow: hidden;
}
.welcome-banner::after {
content: ''; position: absolute; right: -30px; top: -30px;
width: 200px; height: 200px; border-radius: 50%;
background: rgba(58,123,200,0.15);
}
.welcome-banner h2 { font-size: 22px; font-weight: 700; margin-bottom: 6px; }
.welcome-banner p { font-size: 14px; color: var(--navy-20); max-width: 500px; }
/* KPI Cards */
.kpi-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; margin-bottom: 28px; }
.kpi-card {
background: #fff; border-radius: var(--radius); padding: 22px 24px;
border: 1px solid var(--border); box-shadow: var(--shadow-sm);
transition: all 0.2s;
}
.kpi-card:hover { box-shadow: var(--shadow-md); transform: translateY(-1px); }
.kpi-card .kpi-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.kpi-card .kpi-icon {
width: 40px; height: 40px; border-radius: 10px;
display: flex; align-items: center; justify-content: center; font-size: 18px;
}
.kpi-card .kpi-trend { font-size: 12px; font-weight: 600; padding: 3px 8px; border-radius: 20px; }
.kpi-card .kpi-trend.up { background: var(--success-bg); color: var(--success); }
.kpi-card .kpi-trend.down { background: var(--error-bg); color: var(--error); }
.kpi-card .kpi-trend.neutral { background: var(--bg-tertiary); color: var(--navy-60); }
.kpi-card .kpi-value { font-size: 28px; font-weight: 700; color: var(--navy); margin-bottom: 4px; }
.kpi-card .kpi-label { font-size: 13px; color: var(--navy-60); }
.kpi-icon.blue { background: var(--brand-blue-20); color: var(--brand-blue); }
.kpi-icon.navy { background: rgba(30,40,68,0.08); color: var(--navy); }
.kpi-icon.teal { background: rgba(131,170,177,0.15); color: var(--teal); }
.kpi-icon.purple { background: rgba(104,90,163,0.12); color: var(--purple); }
/* Two-column layout */
.dashboard-grid { display: grid; grid-template-columns: 2fr 1fr; gap: 24px; margin-bottom: 28px; }
/* Card */
.card {
background: #fff; border-radius: var(--radius); border: 1px solid var(--border);
box-shadow: var(--shadow-sm); overflow: hidden;
}
.card-header {
padding: 18px 24px; border-bottom: 1px solid var(--border);
display: flex; align-items: center; justify-content: space-between;
}
.card-header h3 { font-size: 15px; font-weight: 600; color: var(--navy); }
.card-header .card-action {
font-size: 12px; font-weight: 500; color: var(--brand-blue); cursor: pointer;
text-decoration: none;
}
.card-header .card-action:hover { text-decoration: underline; }
.card-body { padding: 20px 24px; }
/* Chart area placeholder */
.chart-area {
height: 220px; border-radius: var(--radius-sm);
background: linear-gradient(180deg, var(--brand-blue-20) 0%, rgba(58,123,200,0.03) 100%);
display: flex; align-items: flex-end; justify-content: space-around;
padding: 0 16px 16px;
}
.chart-bar {
width: 28px; border-radius: 4px 4px 0 0; transition: all 0.3s;
position: relative;
}
.chart-bar:hover { opacity: 0.8; }
.chart-bar .chart-label {
position: absolute; bottom: -22px; left: 50%; transform: translateX(-50%);
font-size: 10px; color: var(--navy-60); white-space: nowrap;
}
/* Recent activity list */
.activity-list { list-style: none; }
.activity-item {
display: flex; align-items: flex-start; gap: 12px;
padding: 14px 0; border-bottom: 1px solid var(--bg-tertiary);
}
.activity-item:last-child { border-bottom: none; }
.activity-dot {
width: 8px; height: 8px; border-radius: 50%; margin-top: 5px; flex-shrink: 0;
}
.activity-dot.blue { background: var(--brand-blue); }
.activity-dot.green { background: var(--success); }
.activity-dot.orange { background: var(--warning); }
.activity-dot.purple { background: var(--purple); }
.activity-content { flex: 1; }
.activity-content .activity-text { font-size: 13px; color: var(--navy); line-height: 1.5; }
.activity-content .activity-text strong { font-weight: 600; }
.activity-content .activity-time { font-size: 11px; color: var(--navy-40); margin-top: 2px; }
/* Occupancy section */
.occupancy-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
.occupancy-card {
background: var(--bg-secondary); border-radius: var(--radius-sm); padding: 16px;
text-align: center;
}
.occupancy-card .occ-value { font-size: 24px; font-weight: 700; margin-bottom: 2px; }
.occupancy-card .occ-label { font-size: 11px; color: var(--navy-60); margin-bottom: 10px; }
.progress-bar { height: 6px; background: var(--bg-tertiary); border-radius: 3px; overflow: hidden; }
.progress-bar .fill { height: 100%; border-radius: 3px; }
/* Table */
.data-table { width: 100%; border-collapse: collapse; }
.data-table th {
text-align: left; font-size: 11px; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.5px; color: var(--navy-60); padding: 10px 16px;
background: var(--bg-secondary); border-bottom: 1px solid var(--border);
}
.data-table td {
padding: 12px 16px; font-size: 13px; border-bottom: 1px solid var(--bg-tertiary);
color: var(--navy);
}
.data-table tr:hover td { background: rgba(58,123,200,0.03); }
.status-badge {
display: inline-block; padding: 3px 10px; border-radius: 20px;
font-size: 11px; font-weight: 600;
}
.status-badge.active { background: var(--success-bg); color: var(--success); }
.status-badge.pending { background: var(--warning-bg); color: var(--warning); }
.status-badge.overdue { background: var(--error-bg); color: var(--error); }
.status-badge.draft { background: var(--bg-tertiary); color: var(--navy-60); }
/* Berth map */
.berth-visual {
display: grid; grid-template-columns: repeat(8, 1fr); gap: 6px; padding: 4px 0;
}
.berth-slot {
aspect-ratio: 1; border-radius: 4px; display: flex; align-items: center; justify-content: center;
font-size: 9px; font-weight: 600; color: #fff; cursor: pointer; transition: all 0.15s;
}
.berth-slot:hover { transform: scale(1.08); }
.berth-slot.occupied { background: var(--brand-blue); }
.berth-slot.available { background: var(--mint); color: var(--navy); }
.berth-slot.reserved { background: var(--purple); }
.berth-slot.maintenance { background: var(--navy-40); }
.berth-legend {
display: flex; gap: 16px; margin-top: 12px; padding-top: 12px;
border-top: 1px solid var(--bg-tertiary);
}
.berth-legend-item { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--navy-60); }
.berth-legend-item .dot { width: 10px; height: 10px; border-radius: 3px; }
/* Quick Actions */
.quick-actions { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
.quick-action-btn {
display: flex; align-items: center; gap: 10px;
padding: 12px 14px; border-radius: var(--radius-sm);
border: 1px solid var(--border); background: #fff;
font-size: 13px; font-weight: 500; color: var(--navy);
cursor: pointer; transition: all 0.15s;
}
.quick-action-btn:hover { border-color: var(--brand-blue); background: rgba(58,123,200,0.03); }
.quick-action-btn .qa-icon {
width: 32px; height: 32px; border-radius: 8px;
display: flex; align-items: center; justify-content: center;
font-size: 14px;
}
/* Bottom row */
.bottom-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
/* Style label */
.mockup-label {
position: fixed; bottom: 16px; right: 16px; z-index: 100;
background: var(--navy); color: #fff; padding: 8px 16px;
border-radius: var(--radius); font-size: 12px; font-weight: 600;
box-shadow: var(--shadow-lg);
}
.mockup-label span { color: var(--brand-blue-60); font-weight: 400; }
</style>
</head>
<body>
<div class="app">
<!-- SIDEBAR -->
<aside class="sidebar">
<div class="sidebar-logo">
<div class="logo-mark">PN</div>
<div class="logo-text">Port Nimara<span>Marina CRM</span></div>
</div>
<nav class="sidebar-nav">
<div class="nav-section">
<div class="nav-section-title">Main</div>
<div class="nav-item active">
<span class="icon">&#9632;</span> Dashboard
</div>
<div class="nav-item">
<span class="icon">&#9830;</span> Clients
<span class="badge">248</span>
</div>
<div class="nav-item">
<span class="icon">&#9875;</span> Berths
</div>
<div class="nav-item">
<span class="icon">&#9993;</span> Interests
<span class="badge">12</span>
</div>
</div>
<div class="nav-section">
<div class="nav-section-title">Operations</div>
<div class="nav-item">
<span class="icon">&#9998;</span> Contracts
</div>
<div class="nav-item">
<span class="icon">&#9733;</span> Invoicing
</div>
<div class="nav-item">
<span class="icon">&#128196;</span> Documents
</div>
<div class="nav-item">
<span class="icon">&#128295;</span> Maintenance
</div>
</div>
<div class="nav-section">
<div class="nav-section-title">Insights</div>
<div class="nav-item">
<span class="icon">&#128200;</span> Reports
</div>
<div class="nav-item">
<span class="icon">&#9881;</span> Settings
</div>
</div>
</nav>
<div class="sidebar-footer">
<div class="avatar">MC</div>
<div class="user-info">
<div class="name">Matt C.</div>
<div class="role">Administrator</div>
</div>
</div>
</aside>
<!-- MAIN -->
<div class="main">
<header class="topbar">
<h1>Dashboard</h1>
<div class="topbar-right">
<input class="search-box" type="text" placeholder="Search clients, berths, contracts...">
<div class="icon-btn" style="position:relative;">&#128276;<span class="dot" style="position:absolute;top:-1px;right:-1px;width:8px;height:8px;background:#d32f2f;border-radius:50%;border:2px solid #fff;"></span></div>
<div class="icon-btn">&#43;</div>
</div>
</header>
<div class="content">
<!-- Welcome -->
<div class="welcome-banner">
<h2>Good morning, Matt</h2>
<p>You have 3 contracts awaiting signature and 2 berth inquiries pending review. Occupancy is trending up this quarter.</p>
</div>
<!-- KPI Row -->
<div class="kpi-grid">
<div class="kpi-card">
<div class="kpi-header">
<div class="kpi-icon blue">&#9875;</div>
<span class="kpi-trend up">&#9650; 4.2%</span>
</div>
<div class="kpi-value">87%</div>
<div class="kpi-label">Berth Occupancy</div>
</div>
<div class="kpi-card">
<div class="kpi-header">
<div class="kpi-icon navy">&#9830;</div>
<span class="kpi-trend up">&#9650; 12</span>
</div>
<div class="kpi-value">248</div>
<div class="kpi-label">Active Clients</div>
</div>
<div class="kpi-card">
<div class="kpi-header">
<div class="kpi-icon teal">&#128176;</div>
<span class="kpi-trend up">&#9650; 8.1%</span>
</div>
<div class="kpi-value">$2.4M</div>
<div class="kpi-label">Revenue YTD</div>
</div>
<div class="kpi-card">
<div class="kpi-header">
<div class="kpi-icon purple">&#9998;</div>
<span class="kpi-trend neutral">&#8212; 0</span>
</div>
<div class="kpi-value">14</div>
<div class="kpi-label">Open Interests</div>
</div>
</div>
<!-- Chart + Activity -->
<div class="dashboard-grid">
<div class="card">
<div class="card-header">
<h3>Monthly Revenue</h3>
<a class="card-action">View report &rarr;</a>
</div>
<div class="card-body">
<div class="chart-area">
<div class="chart-bar" style="height:45%;background:var(--brand-blue-60);"><span class="chart-label">Sep</span></div>
<div class="chart-bar" style="height:55%;background:var(--brand-blue-60);"><span class="chart-label">Oct</span></div>
<div class="chart-bar" style="height:50%;background:var(--brand-blue-60);"><span class="chart-label">Nov</span></div>
<div class="chart-bar" style="height:65%;background:var(--brand-blue-60);"><span class="chart-label">Dec</span></div>
<div class="chart-bar" style="height:75%;background:var(--brand-blue);"><span class="chart-label">Jan</span></div>
<div class="chart-bar" style="height:80%;background:var(--brand-blue);"><span class="chart-label">Feb</span></div>
<div class="chart-bar" style="height:90%;background:var(--brand-blue);"><span class="chart-label">Mar</span></div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3>Recent Activity</h3>
<a class="card-action">View all &rarr;</a>
</div>
<div class="card-body" style="padding-top:8px;">
<ul class="activity-list">
<li class="activity-item">
<span class="activity-dot green"></span>
<div class="activity-content">
<div class="activity-text"><strong>Marcus Thompson</strong> signed berth lease B-14</div>
<div class="activity-time">12 min ago</div>
</div>
</li>
<li class="activity-item">
<span class="activity-dot blue"></span>
<div class="activity-content">
<div class="activity-text">New interest from <strong>Alessandra Voss</strong> — 60ft vessel</div>
<div class="activity-time">1 hour ago</div>
</div>
</li>
<li class="activity-item">
<span class="activity-dot orange"></span>
<div class="activity-content">
<div class="activity-text">Invoice #1047 for <strong>Reef Holdings Ltd</strong> overdue</div>
<div class="activity-time">3 hours ago</div>
</div>
</li>
<li class="activity-item">
<span class="activity-dot purple"></span>
<div class="activity-content">
<div class="activity-text">Contract renewal reminder — <strong>J. Beaumont</strong></div>
<div class="activity-time">Yesterday</div>
</div>
</li>
<li class="activity-item">
<span class="activity-dot green"></span>
<div class="activity-content">
<div class="activity-text">Payment received from <strong>Windward Capital</strong> — $42,500</div>
<div class="activity-time">Yesterday</div>
</div>
</li>
</ul>
</div>
</div>
</div>
<!-- Berths + Quick Actions -->
<div class="dashboard-grid">
<div class="card">
<div class="card-header">
<h3>Berth Map — Marina A</h3>
<a class="card-action">Full map &rarr;</a>
</div>
<div class="card-body">
<div class="berth-visual">
<div class="berth-slot occupied">A1</div>
<div class="berth-slot occupied">A2</div>
<div class="berth-slot available">A3</div>
<div class="berth-slot occupied">A4</div>
<div class="berth-slot reserved">A5</div>
<div class="berth-slot occupied">A6</div>
<div class="berth-slot occupied">A7</div>
<div class="berth-slot maintenance">A8</div>
<div class="berth-slot occupied">B1</div>
<div class="berth-slot available">B2</div>
<div class="berth-slot occupied">B3</div>
<div class="berth-slot occupied">B4</div>
<div class="berth-slot occupied">B5</div>
<div class="berth-slot reserved">B6</div>
<div class="berth-slot available">B7</div>
<div class="berth-slot occupied">B8</div>
<div class="berth-slot occupied">C1</div>
<div class="berth-slot occupied">C2</div>
<div class="berth-slot occupied">C3</div>
<div class="berth-slot available">C4</div>
<div class="berth-slot occupied">C5</div>
<div class="berth-slot occupied">C6</div>
<div class="berth-slot maintenance">C7</div>
<div class="berth-slot occupied">C8</div>
</div>
<div class="berth-legend">
<div class="berth-legend-item"><div class="dot" style="background:var(--brand-blue);"></div> Occupied</div>
<div class="berth-legend-item"><div class="dot" style="background:var(--mint);"></div> Available</div>
<div class="berth-legend-item"><div class="dot" style="background:var(--purple);"></div> Reserved</div>
<div class="berth-legend-item"><div class="dot" style="background:var(--navy-40);"></div> Maintenance</div>
</div>
<div class="occupancy-grid" style="margin-top:16px;">
<div class="occupancy-card">
<div class="occ-value" style="color:var(--brand-blue);">17</div>
<div class="occ-label">Occupied</div>
<div class="progress-bar"><div class="fill" style="width:71%;background:var(--brand-blue);"></div></div>
</div>
<div class="occupancy-card">
<div class="occ-value" style="color:var(--success);">4</div>
<div class="occ-label">Available</div>
<div class="progress-bar"><div class="fill" style="width:17%;background:var(--success);"></div></div>
</div>
</div>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:24px;">
<div class="card">
<div class="card-header"><h3>Quick Actions</h3></div>
<div class="card-body">
<div class="quick-actions">
<div class="quick-action-btn"><div class="qa-icon" style="background:var(--brand-blue-20);color:var(--brand-blue);">+</div> New Client</div>
<div class="quick-action-btn"><div class="qa-icon" style="background:rgba(104,90,163,0.12);color:var(--purple);">&#9998;</div> New Contract</div>
<div class="quick-action-btn"><div class="qa-icon" style="background:var(--success-bg);color:var(--success);">$</div> New Invoice</div>
<div class="quick-action-btn"><div class="qa-icon" style="background:rgba(131,170,177,0.15);color:var(--teal);">&#9875;</div> Berth Assign</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header"><h3>Upcoming Renewals</h3></div>
<div class="card-body" style="padding:0;">
<table class="data-table">
<thead><tr><th>Client</th><th>Expires</th><th>Status</th></tr></thead>
<tbody>
<tr><td><strong>J. Beaumont</strong></td><td>Mar 28</td><td><span class="status-badge pending">Due soon</span></td></tr>
<tr><td><strong>Reef Holdings</strong></td><td>Apr 05</td><td><span class="status-badge active">Sent</span></td></tr>
<tr><td><strong>K. Nakamura</strong></td><td>Apr 12</td><td><span class="status-badge draft">Draft</span></td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="mockup-label">Mockup A <span>— Dark Sidebar + Light Content</span></div>
</body>
</html>

532
mockup-B-light-theme.html Normal file
View File

@@ -0,0 +1,532 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Port Nimara CRM — Mockup B: Full Light Theme</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--navy: #1e2844;
--navy-80: #474e66;
--navy-60: #71768a;
--navy-40: #9ea1af;
--navy-20: #cdcfd6;
--brand-blue: #3a7bc8;
--brand-blue-hover: #2f6ab5;
--brand-blue-80: #6196d3;
--brand-blue-60: #89b0de;
--brand-blue-20: #d8e5f4;
--teal: #83aab1;
--teal-light: rgba(131,170,177,0.10);
--purple: #685aa3;
--mint: #add5b3;
--sage: #dae3c1;
--success: #2d8a4e;
--success-bg: #e8f5e9;
--warning: #e6a817;
--warning-bg: #fff8e1;
--error: #d32f2f;
--error-bg: #ffebee;
--bg: #ffffff;
--bg-secondary: #f8f9fa;
--bg-tertiary: #f1f3f5;
--border: #e2e4e8;
--border-strong: #cdcfd6;
--shadow-sm: 0 1px 2px rgba(30,40,68,0.05);
--shadow: 0 1px 3px rgba(30,40,68,0.08), 0 1px 2px rgba(30,40,68,0.04);
--shadow-md: 0 4px 6px rgba(30,40,68,0.06), 0 2px 4px rgba(30,40,68,0.04);
--radius: 10px;
--radius-sm: 6px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Inter', -apple-system, sans-serif; background: var(--bg-secondary); color: var(--navy); }
/* === LAYOUT === */
.app { display: flex; min-height: 100vh; }
/* === LIGHT SIDEBAR === */
.sidebar {
width: 256px;
background: #fff;
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
position: fixed;
top: 0; left: 0; bottom: 0;
z-index: 10;
}
.sidebar-logo {
padding: 22px 20px;
border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 12px;
}
.sidebar-logo .logo-mark {
width: 38px; height: 38px;
background: var(--navy);
border-radius: 10px;
display: flex; align-items: center; justify-content: center;
font-weight: 700; font-size: 15px; color: #fff;
}
.sidebar-logo .logo-text {
font-size: 15px; font-weight: 700; color: var(--navy);
line-height: 1.2;
}
.sidebar-logo .logo-text span { display: block; font-size: 11px; font-weight: 400; color: var(--navy-60); }
.sidebar-nav { flex: 1; padding: 20px 14px; overflow-y: auto; }
.nav-section { margin-bottom: 28px; }
.nav-section-title {
font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 1.2px;
color: var(--navy-40); padding: 0 10px; margin-bottom: 8px;
}
.nav-item {
display: flex; align-items: center; gap: 10px;
padding: 10px 12px; border-radius: var(--radius-sm);
font-size: 13.5px; font-weight: 500; color: var(--navy-60);
cursor: pointer; transition: all 0.15s; margin-bottom: 2px;
}
.nav-item:hover { background: var(--bg-tertiary); color: var(--navy); }
.nav-item.active {
background: var(--brand-blue-20); color: var(--brand-blue);
font-weight: 600;
}
.nav-item .icon { width: 18px; text-align: center; font-size: 14px; }
.nav-item.active .icon { color: var(--brand-blue); }
.nav-item .badge {
margin-left: auto; background: var(--brand-blue);
color: #fff; font-size: 10px; font-weight: 600;
padding: 2px 7px; border-radius: 10px;
}
.sidebar-footer {
padding: 16px 20px; border-top: 1px solid var(--border);
display: flex; align-items: center; gap: 10px;
}
.sidebar-footer .avatar {
width: 32px; height: 32px; border-radius: 50%; background: var(--navy);
display: flex; align-items: center; justify-content: center;
font-size: 12px; font-weight: 600; color: #fff;
}
.sidebar-footer .user-info { font-size: 12px; }
.sidebar-footer .user-info .name { font-weight: 600; color: var(--navy); }
.sidebar-footer .user-info .role { color: var(--navy-40); }
/* === MAIN === */
.main { flex: 1; margin-left: 256px; }
.topbar {
background: #fff; border-bottom: 1px solid var(--border);
padding: 14px 32px; display: flex; align-items: center; justify-content: space-between;
position: sticky; top: 0; z-index: 5;
}
.topbar-left { display: flex; align-items: center; gap: 16px; }
.topbar-left h1 { font-size: 20px; font-weight: 700; color: var(--navy); }
.topbar-left .breadcrumb { font-size: 12px; color: var(--navy-40); }
.topbar-right { display: flex; align-items: center; gap: 12px; }
.search-box {
background: var(--bg-secondary); border: 1px solid var(--border);
border-radius: 20px; padding: 8px 16px;
font-size: 13px; color: var(--navy); width: 280px;
outline: none; font-family: inherit;
}
.search-box:focus { border-color: var(--brand-blue); background: #fff; box-shadow: 0 0 0 3px rgba(58,123,200,0.08); }
.btn-primary {
background: var(--brand-blue); color: #fff; border: none;
padding: 8px 18px; border-radius: 20px; font-size: 13px;
font-weight: 600; cursor: pointer; font-family: inherit; transition: all 0.15s;
}
.btn-primary:hover { background: var(--brand-blue-hover); }
.icon-btn {
width: 36px; height: 36px; border-radius: 50%; background: var(--bg-secondary);
border: 1px solid var(--border); display: flex; align-items: center; justify-content: center;
cursor: pointer; color: var(--navy-60); font-size: 14px; transition: all 0.15s; position: relative;
}
.icon-btn:hover { background: var(--bg-tertiary); color: var(--navy); }
.notif-dot {
position: absolute; top: -1px; right: -1px; width: 8px; height: 8px;
background: var(--error); border-radius: 50%; border: 2px solid #fff;
}
/* === CONTENT === */
.content { padding: 28px 32px; }
/* Greeting */
.greeting { margin-bottom: 28px; }
.greeting h2 { font-size: 24px; font-weight: 700; color: var(--navy); margin-bottom: 4px; }
.greeting p { font-size: 14px; color: var(--navy-60); }
/* Tab bar */
.tab-bar { display: flex; gap: 4px; margin-bottom: 24px; border-bottom: 2px solid var(--border); }
.tab-item {
padding: 10px 20px; font-size: 13px; font-weight: 500; color: var(--navy-60);
cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -2px; transition: all 0.15s;
}
.tab-item:hover { color: var(--navy); }
.tab-item.active { color: var(--brand-blue); border-bottom-color: var(--brand-blue); font-weight: 600; }
/* KPI Cards - pastel colored tops */
.kpi-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 18px; margin-bottom: 28px; }
.kpi-card {
background: #fff; border-radius: var(--radius); overflow: hidden;
border: 1px solid var(--border); box-shadow: var(--shadow-sm); transition: all 0.2s;
}
.kpi-card:hover { box-shadow: var(--shadow-md); transform: translateY(-1px); }
.kpi-card .kpi-accent { height: 4px; }
.kpi-card .kpi-body { padding: 20px 22px; }
.kpi-card .kpi-label { font-size: 12px; color: var(--navy-60); margin-bottom: 8px; font-weight: 500; }
.kpi-card .kpi-row { display: flex; align-items: baseline; justify-content: space-between; }
.kpi-card .kpi-value { font-size: 28px; font-weight: 700; color: var(--navy); }
.kpi-card .kpi-trend { font-size: 12px; font-weight: 600; }
.kpi-card .kpi-trend.up { color: var(--success); }
.kpi-card .kpi-trend.down { color: var(--error); }
.kpi-card .kpi-sub { font-size: 11px; color: var(--navy-40); margin-top: 6px; }
/* Two col */
.dashboard-grid { display: grid; grid-template-columns: 1.6fr 1fr; gap: 24px; margin-bottom: 28px; }
/* Card */
.card {
background: #fff; border-radius: var(--radius); border: 1px solid var(--border);
box-shadow: var(--shadow-sm); overflow: hidden;
}
.card-header {
padding: 18px 22px; border-bottom: 1px solid var(--border);
display: flex; align-items: center; justify-content: space-between;
}
.card-header h3 { font-size: 14px; font-weight: 600; color: var(--navy); }
.card-header .card-action { font-size: 12px; font-weight: 500; color: var(--brand-blue); cursor: pointer; }
.card-body { padding: 20px 22px; }
/* Revenue chart */
.chart-container { height: 200px; display: flex; align-items: flex-end; gap: 4px; padding-bottom: 28px; position: relative; }
.chart-col { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 6px; }
.chart-fill { width: 100%; border-radius: 6px 6px 0 0; transition: all 0.3s; min-height: 4px; }
.chart-lbl { font-size: 10px; color: var(--navy-40); }
.chart-grid-line {
position: absolute; left: 0; right: 0; border-top: 1px dashed var(--border);
}
/* Donut placeholder */
.donut-container { display: flex; align-items: center; gap: 24px; }
.donut-svg { width: 120px; height: 120px; flex-shrink: 0; }
.donut-legend { flex: 1; }
.donut-legend-item {
display: flex; align-items: center; gap: 8px; font-size: 12px;
color: var(--navy); padding: 6px 0;
}
.donut-legend-item .dot { width: 10px; height: 10px; border-radius: 3px; flex-shrink: 0; }
.donut-legend-item .value { margin-left: auto; font-weight: 600; }
/* Table */
.data-table { width: 100%; border-collapse: collapse; }
.data-table th {
text-align: left; font-size: 11px; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.5px; color: var(--navy-40); padding: 10px 16px;
border-bottom: 1px solid var(--border);
}
.data-table td {
padding: 13px 16px; font-size: 13px; border-bottom: 1px solid var(--bg-tertiary);
}
.data-table tr:hover td { background: rgba(58,123,200,0.02); }
.client-cell { display: flex; align-items: center; gap: 10px; }
.client-cell .initials {
width: 30px; height: 30px; border-radius: 50%; display: flex;
align-items: center; justify-content: center; font-size: 11px; font-weight: 600; color: #fff;
}
.status-badge {
display: inline-block; padding: 3px 10px; border-radius: 20px;
font-size: 11px; font-weight: 600;
}
.status-badge.active { background: var(--success-bg); color: var(--success); }
.status-badge.pending { background: var(--warning-bg); color: var(--warning); }
.status-badge.overdue { background: var(--error-bg); color: var(--error); }
.status-badge.draft { background: var(--bg-tertiary); color: var(--navy-60); }
.status-badge.interest { background: var(--brand-blue-20); color: var(--brand-blue); }
/* Activity timeline */
.timeline { position: relative; padding-left: 20px; }
.timeline::before {
content: ''; position: absolute; left: 5px; top: 4px; bottom: 4px;
width: 2px; background: var(--border);
}
.timeline-item { position: relative; padding-bottom: 18px; }
.timeline-item:last-child { padding-bottom: 0; }
.timeline-item .tl-dot {
position: absolute; left: -20px; top: 4px;
width: 12px; height: 12px; border-radius: 50%; border: 2px solid #fff;
}
.timeline-item .tl-content { font-size: 13px; color: var(--navy); line-height: 1.5; }
.timeline-item .tl-content strong { font-weight: 600; }
.timeline-item .tl-time { font-size: 11px; color: var(--navy-40); margin-top: 2px; }
/* Weather widget */
.weather-card {
background: linear-gradient(135deg, var(--brand-blue-20) 0%, rgba(131,170,177,0.12) 100%);
border-radius: var(--radius); padding: 20px; border: 1px solid var(--border);
}
.weather-card .wc-location { font-size: 12px; color: var(--navy-60); margin-bottom: 4px; }
.weather-card .wc-temp { font-size: 36px; font-weight: 700; color: var(--navy); }
.weather-card .wc-desc { font-size: 13px; color: var(--navy-80); }
.weather-card .wc-detail { font-size: 11px; color: var(--navy-60); margin-top: 8px; }
/* Bottom grid */
.bottom-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
.mockup-label {
position: fixed; bottom: 16px; right: 16px; z-index: 100;
background: var(--navy); color: #fff; padding: 8px 16px;
border-radius: var(--radius); font-size: 12px; font-weight: 600;
box-shadow: 0 4px 12px rgba(30,40,68,0.2);
}
.mockup-label span { color: var(--brand-blue-60); font-weight: 400; }
</style>
</head>
<body>
<div class="app">
<!-- LIGHT SIDEBAR -->
<aside class="sidebar">
<div class="sidebar-logo">
<div class="logo-mark">PN</div>
<div class="logo-text">Port Nimara<span>Marina CRM</span></div>
</div>
<nav class="sidebar-nav">
<div class="nav-section">
<div class="nav-section-title">Main</div>
<div class="nav-item active"><span class="icon">&#9632;</span> Dashboard</div>
<div class="nav-item"><span class="icon">&#9830;</span> Clients <span class="badge">248</span></div>
<div class="nav-item"><span class="icon">&#9875;</span> Berths</div>
<div class="nav-item"><span class="icon">&#9993;</span> Interests <span class="badge">12</span></div>
</div>
<div class="nav-section">
<div class="nav-section-title">Operations</div>
<div class="nav-item"><span class="icon">&#9998;</span> Contracts</div>
<div class="nav-item"><span class="icon">&#9733;</span> Invoicing</div>
<div class="nav-item"><span class="icon">&#128196;</span> Documents</div>
<div class="nav-item"><span class="icon">&#128295;</span> Maintenance</div>
</div>
<div class="nav-section">
<div class="nav-section-title">Insights</div>
<div class="nav-item"><span class="icon">&#128200;</span> Reports</div>
<div class="nav-item"><span class="icon">&#9881;</span> Settings</div>
</div>
</nav>
<div class="sidebar-footer">
<div class="avatar">MC</div>
<div class="user-info">
<div class="name">Matt C.</div>
<div class="role">Administrator</div>
</div>
</div>
</aside>
<!-- MAIN -->
<div class="main">
<header class="topbar">
<div class="topbar-left">
<h1>Dashboard</h1>
</div>
<div class="topbar-right">
<input class="search-box" type="text" placeholder="Search anything...">
<div class="icon-btn">&#128276;<span class="notif-dot"></span></div>
<button class="btn-primary">+ New</button>
</div>
</header>
<div class="content">
<div class="greeting">
<h2>Good morning, Matt</h2>
<p>Here's what's happening at Port Nimara today.</p>
</div>
<div class="tab-bar">
<div class="tab-item active">Overview</div>
<div class="tab-item">Financial</div>
<div class="tab-item">Berths</div>
<div class="tab-item">Clients</div>
</div>
<!-- KPIs with colored accent bars -->
<div class="kpi-grid">
<div class="kpi-card">
<div class="kpi-accent" style="background:var(--brand-blue);"></div>
<div class="kpi-body">
<div class="kpi-label">Berth Occupancy</div>
<div class="kpi-row">
<div class="kpi-value">87%</div>
<div class="kpi-trend up">+4.2%</div>
</div>
<div class="kpi-sub">21 of 24 berths occupied</div>
</div>
</div>
<div class="kpi-card">
<div class="kpi-accent" style="background:var(--navy);"></div>
<div class="kpi-body">
<div class="kpi-label">Active Clients</div>
<div class="kpi-row">
<div class="kpi-value">248</div>
<div class="kpi-trend up">+12</div>
</div>
<div class="kpi-sub">vs 236 last quarter</div>
</div>
</div>
<div class="kpi-card">
<div class="kpi-accent" style="background:var(--teal);"></div>
<div class="kpi-body">
<div class="kpi-label">Revenue YTD</div>
<div class="kpi-row">
<div class="kpi-value">$2.4M</div>
<div class="kpi-trend up">+8.1%</div>
</div>
<div class="kpi-sub">Target: $3.2M</div>
</div>
</div>
<div class="kpi-card">
<div class="kpi-accent" style="background:var(--purple);"></div>
<div class="kpi-body">
<div class="kpi-label">Open Interests</div>
<div class="kpi-row">
<div class="kpi-value">14</div>
<div class="kpi-trend up">+5</div>
</div>
<div class="kpi-sub">3 high-priority</div>
</div>
</div>
</div>
<!-- Chart + Donut -->
<div class="dashboard-grid">
<div class="card">
<div class="card-header">
<h3>Revenue Trend</h3>
<a class="card-action">Export &rarr;</a>
</div>
<div class="card-body">
<div class="chart-container">
<div class="chart-col"><div class="chart-fill" style="height:35%;background:var(--brand-blue-20);"></div><span class="chart-lbl">Sep</span></div>
<div class="chart-col"><div class="chart-fill" style="height:42%;background:var(--brand-blue-20);"></div><span class="chart-lbl">Oct</span></div>
<div class="chart-col"><div class="chart-fill" style="height:38%;background:var(--brand-blue-20);"></div><span class="chart-lbl">Nov</span></div>
<div class="chart-col"><div class="chart-fill" style="height:52%;background:var(--brand-blue-20);"></div><span class="chart-lbl">Dec</span></div>
<div class="chart-col"><div class="chart-fill" style="height:65%;background:var(--brand-blue-60);"></div><span class="chart-lbl">Jan</span></div>
<div class="chart-col"><div class="chart-fill" style="height:72%;background:var(--brand-blue);"></div><span class="chart-lbl">Feb</span></div>
<div class="chart-col"><div class="chart-fill" style="height:85%;background:var(--brand-blue);border:2px solid var(--navy);"></div><span class="chart-lbl">Mar</span></div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3>Berth Status</h3>
<a class="card-action">Details &rarr;</a>
</div>
<div class="card-body">
<div class="donut-container">
<svg class="donut-svg" viewBox="0 0 120 120">
<circle cx="60" cy="60" r="50" fill="none" stroke="#e2e4e8" stroke-width="16"/>
<!-- Occupied: 71% -->
<circle cx="60" cy="60" r="50" fill="none" stroke="#3a7bc8" stroke-width="16"
stroke-dasharray="223 91" stroke-dashoffset="79" stroke-linecap="round"/>
<!-- Reserved: 12% -->
<circle cx="60" cy="60" r="50" fill="none" stroke="#685aa3" stroke-width="16"
stroke-dasharray="38 276" stroke-dashoffset="-144" stroke-linecap="round"/>
<!-- Maintenance: 8% -->
<circle cx="60" cy="60" r="50" fill="none" stroke="#9ea1af" stroke-width="16"
stroke-dasharray="25 289" stroke-dashoffset="-182" stroke-linecap="round"/>
<text x="60" y="56" text-anchor="middle" font-size="22" font-weight="700" fill="#1e2844">87%</text>
<text x="60" y="72" text-anchor="middle" font-size="10" fill="#71768a">occupied</text>
</svg>
<div class="donut-legend">
<div class="donut-legend-item"><div class="dot" style="background:var(--brand-blue);"></div> Occupied <span class="value">17</span></div>
<div class="donut-legend-item"><div class="dot" style="background:var(--mint);"></div> Available <span class="value">4</span></div>
<div class="donut-legend-item"><div class="dot" style="background:var(--purple);"></div> Reserved <span class="value">2</span></div>
<div class="donut-legend-item"><div class="dot" style="background:var(--navy-40);"></div> Maintenance <span class="value">1</span></div>
</div>
</div>
</div>
</div>
</div>
<!-- Bottom: Recent Clients + Activity -->
<div class="bottom-grid">
<div class="card">
<div class="card-header">
<h3>Recent Clients</h3>
<a class="card-action">View all &rarr;</a>
</div>
<div class="card-body" style="padding:0;">
<table class="data-table">
<thead><tr><th>Client</th><th>Berth</th><th>Status</th><th>Value</th></tr></thead>
<tbody>
<tr>
<td><div class="client-cell"><div class="initials" style="background:var(--brand-blue);">MT</div><div><strong>Marcus Thompson</strong><br><span style="font-size:11px;color:var(--navy-40);">72ft Sunseeker</span></div></div></td>
<td>B-14</td>
<td><span class="status-badge active">Active</span></td>
<td style="font-weight:600;">$86,400</td>
</tr>
<tr>
<td><div class="client-cell"><div class="initials" style="background:var(--purple);">AV</div><div><strong>Alessandra Voss</strong><br><span style="font-size:11px;color:var(--navy-40);">60ft Azimut</span></div></div></td>
<td></td>
<td><span class="status-badge interest">Interest</span></td>
<td style="font-weight:600;">TBD</td>
</tr>
<tr>
<td><div class="client-cell"><div class="initials" style="background:var(--teal);">RH</div><div><strong>Reef Holdings Ltd</strong><br><span style="font-size:11px;color:var(--navy-40);">85ft Benetti</span></div></div></td>
<td>A-02</td>
<td><span class="status-badge overdue">Overdue</span></td>
<td style="font-weight:600;">$124,000</td>
</tr>
<tr>
<td><div class="client-cell"><div class="initials" style="background:var(--navy);">JB</div><div><strong>J. Beaumont</strong><br><span style="font-size:11px;color:var(--navy-40);">55ft Princess</span></div></div></td>
<td>C-05</td>
<td><span class="status-badge pending">Renewal</span></td>
<td style="font-weight:600;">$67,200</td>
</tr>
</tbody>
</table>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:24px;">
<div class="card">
<div class="card-header"><h3>Activity</h3><a class="card-action">View all &rarr;</a></div>
<div class="card-body" style="padding-top:12px;">
<div class="timeline">
<div class="timeline-item">
<div class="tl-dot" style="background:var(--success);"></div>
<div class="tl-content"><strong>Marcus Thompson</strong> signed berth lease B-14</div>
<div class="tl-time">12 min ago</div>
</div>
<div class="timeline-item">
<div class="tl-dot" style="background:var(--brand-blue);"></div>
<div class="tl-content">New interest from <strong>Alessandra Voss</strong></div>
<div class="tl-time">1 hour ago</div>
</div>
<div class="timeline-item">
<div class="tl-dot" style="background:var(--warning);"></div>
<div class="tl-content">Invoice #1047 overdue — <strong>Reef Holdings</strong></div>
<div class="tl-time">3 hours ago</div>
</div>
<div class="timeline-item">
<div class="tl-dot" style="background:var(--purple);"></div>
<div class="tl-content">Contract renewal due — <strong>J. Beaumont</strong></div>
<div class="tl-time">Yesterday</div>
</div>
</div>
</div>
</div>
<div class="weather-card">
<div class="wc-location">Anguilla, BWI</div>
<div class="wc-temp">82°F</div>
<div class="wc-desc">Partly cloudy, calm seas</div>
<div class="wc-detail">Wind: E 8 kts &middot; Tide: High at 2:15 PM</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="mockup-label">Mockup B <span>— Full Light Theme</span></div>
</body>
</html>

505
mockup-C-bold-modern.html Normal file
View File

@@ -0,0 +1,505 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Port Nimara CRM — Mockup C: Bold Modern</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
:root {
--navy: #1e2844;
--navy-80: #474e66;
--navy-60: #71768a;
--navy-40: #9ea1af;
--navy-20: #cdcfd6;
--brand-blue: #3a7bc8;
--brand-blue-hover: #2f6ab5;
--brand-blue-80: #6196d3;
--brand-blue-60: #89b0de;
--brand-blue-20: #d8e5f4;
--teal: #83aab1;
--purple: #685aa3;
--mint: #add5b3;
--sage: #dae3c1;
--success: #2d8a4e;
--success-bg: #e8f5e9;
--warning: #e6a817;
--warning-bg: #fff8e1;
--error: #d32f2f;
--error-bg: #ffebee;
--bg: #f4f6f9;
--bg-card: #ffffff;
--border: #e2e4e8;
--shadow-sm: 0 1px 3px rgba(30,40,68,0.06);
--shadow: 0 2px 8px rgba(30,40,68,0.08);
--shadow-md: 0 4px 12px rgba(30,40,68,0.10);
--shadow-lg: 0 8px 24px rgba(30,40,68,0.12);
--radius: 12px;
--radius-sm: 8px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Inter', -apple-system, sans-serif; background: var(--bg); color: var(--navy); }
/* === COLLAPSED ICON SIDEBAR === */
.sidebar {
width: 72px; background: var(--navy);
display: flex; flex-direction: column; align-items: center;
position: fixed; top: 0; left: 0; bottom: 0; z-index: 10;
padding: 16px 0;
}
.sidebar-logo {
width: 44px; height: 44px; background: var(--brand-blue);
border-radius: 12px; display: flex; align-items: center; justify-content: center;
font-weight: 800; font-size: 16px; color: #fff; margin-bottom: 28px;
}
.sidebar-icons { flex: 1; display: flex; flex-direction: column; gap: 6px; width: 100%; padding: 0 12px; }
.sidebar-icon-item {
width: 48px; height: 44px; border-radius: 10px;
display: flex; align-items: center; justify-content: center;
color: var(--navy-40); font-size: 18px; cursor: pointer; transition: all 0.15s;
position: relative;
}
.sidebar-icon-item:hover { background: rgba(255,255,255,0.08); color: #fff; }
.sidebar-icon-item.active {
background: rgba(58,123,200,0.2); color: var(--brand-blue);
}
.sidebar-icon-item .s-badge {
position: absolute; top: 4px; right: 4px; width: 16px; height: 16px;
background: var(--brand-blue); color: #fff; font-size: 9px; font-weight: 700;
border-radius: 50%; display: flex; align-items: center; justify-content: center;
}
.sidebar-footer-icon {
width: 36px; height: 36px; border-radius: 50%; background: var(--brand-blue);
display: flex; align-items: center; justify-content: center;
font-size: 13px; font-weight: 700; color: #fff; margin-top: 12px;
}
/* === MAIN === */
.main { margin-left: 72px; }
/* Top header bar with brand blue gradient */
.header-bar {
background: linear-gradient(135deg, var(--navy) 0%, #253660 50%, var(--brand-blue) 100%);
padding: 24px 36px 80px;
color: #fff;
}
.header-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; }
.header-top .h-greeting { font-size: 13px; color: rgba(255,255,255,0.6); }
.header-top .h-title { font-size: 26px; font-weight: 800; margin-top: 2px; }
.header-actions { display: flex; align-items: center; gap: 12px; }
.search-box-dark {
background: rgba(255,255,255,0.12); border: 1px solid rgba(255,255,255,0.15);
border-radius: 10px; padding: 10px 16px; font-size: 13px;
color: #fff; width: 300px; outline: none; font-family: inherit;
}
.search-box-dark::placeholder { color: rgba(255,255,255,0.5); }
.search-box-dark:focus { background: rgba(255,255,255,0.18); border-color: rgba(255,255,255,0.3); }
.btn-white {
background: #fff; color: var(--navy); border: none;
padding: 10px 20px; border-radius: 10px; font-size: 13px;
font-weight: 600; cursor: pointer; font-family: inherit; transition: all 0.15s;
}
.btn-white:hover { background: rgba(255,255,255,0.9); transform: translateY(-1px); }
.icon-btn-ghost {
width: 40px; height: 40px; border-radius: 10px;
background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.15);
display: flex; align-items: center; justify-content: center;
color: #fff; font-size: 16px; cursor: pointer; position: relative;
}
.icon-btn-ghost:hover { background: rgba(255,255,255,0.18); }
.notif-dot {
position: absolute; top: 6px; right: 6px; width: 7px; height: 7px;
background: #ff5252; border-radius: 50%;
}
/* === CONTENT (overlapping cards) === */
.content { padding: 0 36px 36px; margin-top: -56px; position: relative; z-index: 2; }
/* KPI cards that overlap the header */
.kpi-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 18px; margin-bottom: 28px; }
.kpi-card {
background: var(--bg-card); border-radius: var(--radius); padding: 24px;
box-shadow: var(--shadow); transition: all 0.2s; position: relative; overflow: hidden;
}
.kpi-card:hover { box-shadow: var(--shadow-md); transform: translateY(-2px); }
.kpi-card .kpi-accent-bar {
position: absolute; top: 0; left: 0; right: 0; height: 3px;
}
.kpi-card .kpi-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.kpi-card .kpi-icon {
width: 44px; height: 44px; border-radius: 12px;
display: flex; align-items: center; justify-content: center; font-size: 20px;
}
.kpi-card .kpi-trend { font-size: 12px; font-weight: 700; display: flex; align-items: center; gap: 4px; }
.kpi-card .kpi-trend.up { color: var(--success); }
.kpi-card .kpi-trend.down { color: var(--error); }
.kpi-card .kpi-value { font-size: 32px; font-weight: 800; color: var(--navy); letter-spacing: -0.5px; }
.kpi-card .kpi-label { font-size: 13px; color: var(--navy-60); margin-top: 4px; font-weight: 500; }
/* Grid layout */
.dashboard-row { display: grid; grid-template-columns: 1fr 1fr; gap: 22px; margin-bottom: 22px; }
.dashboard-row.three-col { grid-template-columns: 1.4fr 1fr 0.8fr; }
/* Card */
.card {
background: var(--bg-card); border-radius: var(--radius); box-shadow: var(--shadow-sm);
overflow: hidden;
}
.card-header {
padding: 20px 24px 16px; display: flex; align-items: center; justify-content: space-between;
}
.card-header h3 { font-size: 15px; font-weight: 700; color: var(--navy); }
.card-header .tab-pills { display: flex; gap: 4px; }
.tab-pill {
padding: 5px 12px; border-radius: 20px; font-size: 11px; font-weight: 600;
cursor: pointer; transition: all 0.15s; border: none; font-family: inherit;
}
.tab-pill.active { background: var(--navy); color: #fff; }
.tab-pill:not(.active) { background: var(--bg); color: var(--navy-60); }
.tab-pill:not(.active):hover { background: var(--border); }
.card-action { font-size: 12px; font-weight: 600; color: var(--brand-blue); cursor: pointer; }
.card-body { padding: 0 24px 24px; }
/* Chart bars - rounded gradient */
.chart-flex { display: flex; align-items: flex-end; gap: 8px; height: 180px; padding-bottom: 28px; }
.chart-col { flex: 1; display: flex; flex-direction: column; align-items: center; }
.chart-bar {
width: 100%; border-radius: 8px 8px 0 0; transition: all 0.3s; position: relative;
}
.chart-bar:hover { opacity: 0.85; }
.chart-lbl { font-size: 10px; color: var(--navy-40); margin-top: 8px; }
/* Horizontal progress bars */
.h-progress-list { display: flex; flex-direction: column; gap: 16px; }
.h-progress-item {}
.h-progress-header { display: flex; justify-content: space-between; margin-bottom: 6px; }
.h-progress-header .label { font-size: 13px; font-weight: 500; color: var(--navy); }
.h-progress-header .value { font-size: 13px; font-weight: 700; color: var(--navy); }
.h-bar { height: 8px; background: var(--bg); border-radius: 4px; overflow: hidden; }
.h-bar .fill { height: 100%; border-radius: 4px; }
/* Client rows */
.client-list {}
.client-row {
display: flex; align-items: center; gap: 14px;
padding: 14px 0; border-bottom: 1px solid rgba(0,0,0,0.04);
}
.client-row:last-child { border-bottom: none; }
.client-avatar {
width: 40px; height: 40px; border-radius: 12px;
display: flex; align-items: center; justify-content: center;
font-size: 14px; font-weight: 700; color: #fff;
}
.client-info { flex: 1; }
.client-info .c-name { font-size: 13px; font-weight: 600; color: var(--navy); }
.client-info .c-detail { font-size: 11px; color: var(--navy-60); margin-top: 1px; }
.client-amount { font-size: 14px; font-weight: 700; color: var(--navy); }
.status-dot { width: 8px; height: 8px; border-radius: 50%; }
/* Map grid */
.marina-grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 6px; }
.marina-slot {
aspect-ratio: 1.4; border-radius: 6px; display: flex; align-items: center; justify-content: center;
font-size: 10px; font-weight: 700; cursor: pointer; transition: all 0.15s;
color: #fff;
}
.marina-slot:hover { transform: scale(1.06); box-shadow: var(--shadow-sm); }
.marina-slot.occ { background: linear-gradient(135deg, var(--brand-blue), var(--brand-blue-80)); }
.marina-slot.avail { background: linear-gradient(135deg, var(--mint), #8fc99b); color: var(--navy); }
.marina-slot.res { background: linear-gradient(135deg, var(--purple), #7e6fb8); }
.marina-slot.maint { background: var(--navy-40); }
.marina-legend {
display: flex; gap: 14px; margin-top: 14px;
}
.marina-legend-item { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--navy-60); }
.marina-legend-item .mdot { width: 10px; height: 10px; border-radius: 4px; }
/* Activity feed */
.feed-item {
display: flex; gap: 12px; padding: 12px 0;
border-bottom: 1px solid rgba(0,0,0,0.04);
}
.feed-item:last-child { border-bottom: none; }
.feed-icon {
width: 32px; height: 32px; border-radius: 8px;
display: flex; align-items: center; justify-content: center;
font-size: 14px; flex-shrink: 0;
}
.feed-text { font-size: 13px; color: var(--navy); line-height: 1.5; }
.feed-text strong { font-weight: 600; }
.feed-time { font-size: 11px; color: var(--navy-40); margin-top: 2px; }
/* Status badge */
.status-badge {
display: inline-block; padding: 4px 12px; border-radius: 20px;
font-size: 11px; font-weight: 700;
}
.status-badge.active { background: var(--success-bg); color: var(--success); }
.status-badge.pending { background: var(--warning-bg); color: var(--warning); }
.status-badge.overdue { background: var(--error-bg); color: var(--error); }
.mockup-label {
position: fixed; bottom: 16px; right: 16px; z-index: 100;
background: var(--navy); color: #fff; padding: 8px 16px;
border-radius: 10px; font-size: 12px; font-weight: 600;
box-shadow: var(--shadow-lg);
}
.mockup-label span { color: var(--brand-blue-60); font-weight: 400; }
</style>
</head>
<body>
<!-- ICON SIDEBAR -->
<aside class="sidebar">
<div class="sidebar-logo">PN</div>
<div class="sidebar-icons">
<div class="sidebar-icon-item active">&#9632;</div>
<div class="sidebar-icon-item">&#9830;<span class="s-badge">5</span></div>
<div class="sidebar-icon-item">&#9875;</div>
<div class="sidebar-icon-item">&#9993;</div>
<div class="sidebar-icon-item">&#9998;</div>
<div class="sidebar-icon-item">&#9733;</div>
<div class="sidebar-icon-item">&#128196;</div>
<div class="sidebar-icon-item">&#128200;</div>
<div class="sidebar-icon-item">&#9881;</div>
</div>
<div class="sidebar-footer-icon">MC</div>
</aside>
<div class="main">
<!-- GRADIENT HEADER -->
<div class="header-bar">
<div class="header-top">
<div>
<div class="h-greeting">Welcome back, Matt</div>
<div class="h-title">Marina Dashboard</div>
</div>
<div class="header-actions">
<input class="search-box-dark" type="text" placeholder="Search clients, berths, contracts...">
<div class="icon-btn-ghost">&#128276;<span class="notif-dot"></span></div>
<button class="btn-white">+ New Client</button>
</div>
</div>
</div>
<!-- CONTENT -->
<div class="content">
<!-- KPIs overlapping header -->
<div class="kpi-grid">
<div class="kpi-card">
<div class="kpi-accent-bar" style="background:linear-gradient(90deg,var(--brand-blue),var(--brand-blue-80));"></div>
<div class="kpi-top">
<div class="kpi-icon" style="background:var(--brand-blue-20);color:var(--brand-blue);">&#9875;</div>
<div class="kpi-trend up">&#9650; 4.2%</div>
</div>
<div class="kpi-value">87%</div>
<div class="kpi-label">Berth Occupancy</div>
</div>
<div class="kpi-card">
<div class="kpi-accent-bar" style="background:linear-gradient(90deg,var(--navy),var(--navy-80));"></div>
<div class="kpi-top">
<div class="kpi-icon" style="background:rgba(30,40,68,0.08);color:var(--navy);">&#9830;</div>
<div class="kpi-trend up">&#9650; 12</div>
</div>
<div class="kpi-value">248</div>
<div class="kpi-label">Active Clients</div>
</div>
<div class="kpi-card">
<div class="kpi-accent-bar" style="background:linear-gradient(90deg,var(--teal),var(--mint));"></div>
<div class="kpi-top">
<div class="kpi-icon" style="background:rgba(131,170,177,0.12);color:var(--teal);">&#128176;</div>
<div class="kpi-trend up">&#9650; 8.1%</div>
</div>
<div class="kpi-value">$2.4M</div>
<div class="kpi-label">Revenue YTD</div>
</div>
<div class="kpi-card">
<div class="kpi-accent-bar" style="background:linear-gradient(90deg,var(--purple),#8b7ec4);"></div>
<div class="kpi-top">
<div class="kpi-icon" style="background:rgba(104,90,163,0.1);color:var(--purple);">&#9993;</div>
<div class="kpi-trend up">&#9650; 5</div>
</div>
<div class="kpi-value">14</div>
<div class="kpi-label">Open Interests</div>
</div>
</div>
<!-- Revenue + Occupancy breakdown -->
<div class="dashboard-row three-col">
<div class="card">
<div class="card-header">
<h3>Revenue</h3>
<div class="tab-pills">
<button class="tab-pill">Week</button>
<button class="tab-pill active">Month</button>
<button class="tab-pill">Year</button>
</div>
</div>
<div class="card-body">
<div class="chart-flex">
<div class="chart-col"><div class="chart-bar" style="height:35%;background:linear-gradient(180deg,var(--brand-blue-60),var(--brand-blue-20));"></div><span class="chart-lbl">Sep</span></div>
<div class="chart-col"><div class="chart-bar" style="height:42%;background:linear-gradient(180deg,var(--brand-blue-60),var(--brand-blue-20));"></div><span class="chart-lbl">Oct</span></div>
<div class="chart-col"><div class="chart-bar" style="height:38%;background:linear-gradient(180deg,var(--brand-blue-60),var(--brand-blue-20));"></div><span class="chart-lbl">Nov</span></div>
<div class="chart-col"><div class="chart-bar" style="height:50%;background:linear-gradient(180deg,var(--brand-blue-60),var(--brand-blue-20));"></div><span class="chart-lbl">Dec</span></div>
<div class="chart-col"><div class="chart-bar" style="height:65%;background:linear-gradient(180deg,var(--brand-blue),var(--brand-blue-60));"></div><span class="chart-lbl">Jan</span></div>
<div class="chart-col"><div class="chart-bar" style="height:75%;background:linear-gradient(180deg,var(--brand-blue),var(--brand-blue-60));"></div><span class="chart-lbl">Feb</span></div>
<div class="chart-col"><div class="chart-bar" style="height:90%;background:linear-gradient(180deg,var(--navy),var(--brand-blue));"></div><span class="chart-lbl">Mar</span></div>
</div>
</div>
</div>
<div class="card">
<div class="card-header"><h3>Occupancy Breakdown</h3></div>
<div class="card-body">
<div class="h-progress-list">
<div class="h-progress-item">
<div class="h-progress-header"><span class="label">Occupied</span><span class="value">17 berths</span></div>
<div class="h-bar"><div class="fill" style="width:71%;background:linear-gradient(90deg,var(--brand-blue),var(--brand-blue-80));"></div></div>
</div>
<div class="h-progress-item">
<div class="h-progress-header"><span class="label">Available</span><span class="value">4 berths</span></div>
<div class="h-bar"><div class="fill" style="width:17%;background:linear-gradient(90deg,var(--mint),#8fc99b);"></div></div>
</div>
<div class="h-progress-item">
<div class="h-progress-header"><span class="label">Reserved</span><span class="value">2 berths</span></div>
<div class="h-bar"><div class="fill" style="width:8%;background:linear-gradient(90deg,var(--purple),#8b7ec4);"></div></div>
</div>
<div class="h-progress-item">
<div class="h-progress-header"><span class="label">Maintenance</span><span class="value">1 berth</span></div>
<div class="h-bar"><div class="fill" style="width:4%;background:var(--navy-40);"></div></div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header"><h3>Pending</h3></div>
<div class="card-body">
<div style="display:flex;flex-direction:column;gap:14px;">
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px;background:var(--warning-bg);border-radius:var(--radius-sm);">
<div><div style="font-size:13px;font-weight:600;color:var(--navy);">3 Contracts</div><div style="font-size:11px;color:var(--navy-60);">Awaiting signature</div></div>
<span style="font-size:18px;">&#9998;</span>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px;background:var(--error-bg);border-radius:var(--radius-sm);">
<div><div style="font-size:13px;font-weight:600;color:var(--navy);">1 Invoice</div><div style="font-size:11px;color:var(--navy-60);">Overdue 14 days</div></div>
<span style="font-size:18px;">&#128176;</span>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px;background:var(--brand-blue-20);border-radius:var(--radius-sm);">
<div><div style="font-size:13px;font-weight:600;color:var(--navy);">2 Interests</div><div style="font-size:11px;color:var(--navy-60);">Needs follow-up</div></div>
<span style="font-size:18px;">&#9993;</span>
</div>
</div>
</div>
</div>
</div>
<!-- Marina Map + Clients + Activity -->
<div class="dashboard-row three-col">
<div class="card">
<div class="card-header">
<h3>Marina Map</h3>
<a class="card-action">Expand &rarr;</a>
</div>
<div class="card-body">
<div class="marina-grid">
<div class="marina-slot occ">A1</div>
<div class="marina-slot occ">A2</div>
<div class="marina-slot avail">A3</div>
<div class="marina-slot occ">A4</div>
<div class="marina-slot res">A5</div>
<div class="marina-slot occ">A6</div>
<div class="marina-slot occ">B1</div>
<div class="marina-slot avail">B2</div>
<div class="marina-slot occ">B3</div>
<div class="marina-slot occ">B4</div>
<div class="marina-slot occ">B5</div>
<div class="marina-slot res">B6</div>
<div class="marina-slot occ">C1</div>
<div class="marina-slot occ">C2</div>
<div class="marina-slot occ">C3</div>
<div class="marina-slot avail">C4</div>
<div class="marina-slot occ">C5</div>
<div class="marina-slot maint">C6</div>
<div class="marina-slot avail">D1</div>
<div class="marina-slot occ">D2</div>
<div class="marina-slot occ">D3</div>
<div class="marina-slot occ">D4</div>
<div class="marina-slot occ">D5</div>
<div class="marina-slot occ">D6</div>
</div>
<div class="marina-legend">
<div class="marina-legend-item"><div class="mdot" style="background:var(--brand-blue);"></div> Occupied (17)</div>
<div class="marina-legend-item"><div class="mdot" style="background:var(--mint);"></div> Available (4)</div>
<div class="marina-legend-item"><div class="mdot" style="background:var(--purple);"></div> Reserved (2)</div>
<div class="marina-legend-item"><div class="mdot" style="background:var(--navy-40);"></div> Maint (1)</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header"><h3>Top Clients</h3><a class="card-action">View all</a></div>
<div class="card-body" style="padding-top:4px;">
<div class="client-list">
<div class="client-row">
<div class="client-avatar" style="background:linear-gradient(135deg,var(--brand-blue),var(--navy));">MT</div>
<div class="client-info"><div class="c-name">Marcus Thompson</div><div class="c-detail">72ft &middot; B-14 &middot; Active</div></div>
<div class="client-amount">$86.4K</div>
</div>
<div class="client-row">
<div class="client-avatar" style="background:linear-gradient(135deg,var(--teal),var(--navy));">RH</div>
<div class="client-info"><div class="c-name">Reef Holdings Ltd</div><div class="c-detail">85ft &middot; A-02 &middot; <span style="color:var(--error);">Overdue</span></div></div>
<div class="client-amount">$124K</div>
</div>
<div class="client-row">
<div class="client-avatar" style="background:linear-gradient(135deg,var(--purple),var(--navy));">JB</div>
<div class="client-info"><div class="c-name">J. Beaumont</div><div class="c-detail">55ft &middot; C-05 &middot; Renewal</div></div>
<div class="client-amount">$67.2K</div>
</div>
<div class="client-row">
<div class="client-avatar" style="background:linear-gradient(135deg,var(--mint),var(--teal));">WC</div>
<div class="client-info"><div class="c-name">Windward Capital</div><div class="c-detail">90ft &middot; A-06 &middot; Active</div></div>
<div class="client-amount">$142K</div>
</div>
<div class="client-row">
<div class="client-avatar" style="background:linear-gradient(135deg,var(--brand-blue),var(--purple));">AV</div>
<div class="client-info"><div class="c-name">Alessandra Voss</div><div class="c-detail">60ft &middot; New interest</div></div>
<div class="client-amount">TBD</div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header"><h3>Activity</h3></div>
<div class="card-body" style="padding-top:4px;">
<div class="feed-item">
<div class="feed-icon" style="background:var(--success-bg);color:var(--success);">&#10003;</div>
<div><div class="feed-text"><strong>Thompson</strong> signed B-14</div><div class="feed-time">12 min</div></div>
</div>
<div class="feed-item">
<div class="feed-icon" style="background:var(--brand-blue-20);color:var(--brand-blue);">&#9993;</div>
<div><div class="feed-text">New interest — <strong>Voss</strong></div><div class="feed-time">1 hr</div></div>
</div>
<div class="feed-item">
<div class="feed-icon" style="background:var(--error-bg);color:var(--error);">!</div>
<div><div class="feed-text">Invoice #1047 overdue</div><div class="feed-time">3 hrs</div></div>
</div>
<div class="feed-item">
<div class="feed-icon" style="background:rgba(104,90,163,0.1);color:var(--purple);">&#8635;</div>
<div><div class="feed-text"><strong>Beaumont</strong> renewal</div><div class="feed-time">Yesterday</div></div>
</div>
<div class="feed-item">
<div class="feed-icon" style="background:var(--success-bg);color:var(--success);">$</div>
<div><div class="feed-text"><strong>Windward</strong> paid $42.5K</div><div class="feed-time">Yesterday</div></div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="mockup-label">Mockup C <span>— Bold Modern</span></div>
</body>
</html>

5
next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

23
next.config.ts Normal file
View File

@@ -0,0 +1,23 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
output: 'standalone',
serverExternalPackages: [
'pino',
'pino-pretty',
'bullmq',
'ioredis',
'minio',
'postgres',
'better-auth',
'nodemailer',
],
images: {
remotePatterns: [{ protocol: 'https', hostname: '*.portnimara.com' }],
},
experimental: {
typedRoutes: true,
},
};
export default nextConfig;

View File

@@ -0,0 +1,10 @@
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
proxy_connect_timeout 10s;

138
nginx/nginx.conf Normal file
View File

@@ -0,0 +1,138 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
server_tokens off;
# Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml text/javascript application/json
application/javascript application/xml+rss application/atom+xml
image/svg+xml;
# Client body size limit (50MB for file uploads)
client_max_body_size 50m;
# URL length limit
large_client_header_buffers 4 8k;
# Rate limiting zones
limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=auth:10m rate=1r/s;
limit_req_zone $binary_remote_addr zone=upload:10m rate=5r/s;
# SSL session cache
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
ssl_session_tickets off;
# HTTP → HTTPS redirect
server {
listen 80;
server_name _;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name _;
# TLS configuration — TLS 1.2 minimum, TLS 1.3 preferred
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers off;
ssl_stapling on;
ssl_stapling_verify on;
# Security headers (per SECURITY-GUIDELINES.md § 6.2)
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header X-XSS-Protection "0" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' wss:; frame-ancestors 'none';" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# CORS headers
set $cors_origin "";
if ($http_origin ~* "^https://(crm\.portnimara\.com|portnimara\.com)$") {
set $cors_origin $http_origin;
}
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials "true" always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization, X-Requested-With" always;
add_header Access-Control-Max-Age "3600" always;
# Preflight
if ($request_method = OPTIONS) {
return 204;
}
# Auth endpoints — strict rate limit (1r/s → ~5 req/min burst)
location ~ ^/api/auth/ {
limit_req zone=auth burst=5 nodelay;
limit_req_status 429;
proxy_pass http://crm-app:3000;
include /etc/nginx/conf.d/proxy_params.conf;
}
# File upload endpoints — dedicated rate limit
location ~ ^/api/v1/files {
limit_req zone=upload burst=10 nodelay;
limit_req_status 429;
client_max_body_size 50m;
proxy_pass http://crm-app:3000;
proxy_request_buffering off;
include /etc/nginx/conf.d/proxy_params.conf;
}
# WebSocket upgrade for Socket.io
location /socket.io/ {
limit_req zone=general burst=20 nodelay;
proxy_pass http://crm-app:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
# All other traffic — general rate limit
location / {
limit_req zone=general burst=20 nodelay;
limit_req_status 429;
proxy_pass http://crm-app:3000;
include /etc/nginx/conf.d/proxy_params.conf;
}
}
}

View File

@@ -0,0 +1,109 @@
upstream crm_backend {
server 127.0.0.1:7100;
}
# Rate limiting zones
limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=auth:10m rate=1r/s;
limit_req_zone $binary_remote_addr zone=upload:10m rate=5r/s;
server {
listen 80;
listen [::]:80;
server_name pn.letsbe.solutions;
# Allow certbot ACME challenges
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# Security headers
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header X-XSS-Protection "0" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# Client body size limit (50MB for file uploads)
client_max_body_size 50m;
# URL length limit
large_client_header_buffers 4 8k;
# Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml text/javascript application/json
application/javascript application/xml+rss application/atom+xml
image/svg+xml;
# Auth endpoints - strict rate limit
location ~ ^/api/auth/ {
limit_req zone=auth burst=5 nodelay;
limit_req_status 429;
proxy_pass http://crm_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
proxy_connect_timeout 10s;
}
# File upload endpoints - dedicated rate limit
location ~ ^/api/v1/files {
limit_req zone=upload burst=10 nodelay;
limit_req_status 429;
client_max_body_size 50m;
proxy_pass http://crm_backend;
proxy_request_buffering off;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
proxy_connect_timeout 10s;
}
# WebSocket upgrade for Socket.io
location /socket.io/ {
limit_req zone=general burst=20 nodelay;
proxy_pass http://crm_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
# All other traffic - general rate limit
location / {
limit_req zone=general burst=20 nodelay;
limit_req_status 429;
proxy_pass http://crm_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
proxy_connect_timeout 10s;
}
}

106
package.json Normal file
View File

@@ -0,0 +1,106 @@
{
"name": "port-nimara-crm",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"format": "prettier --write \"src/**/*.{ts,tsx,json,css}\"",
"db:generate": "drizzle-kit generate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"db:seed": "tsx src/lib/db/seed.ts",
"prepare": "husky"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^3.9.0",
"@pdfme/common": "^5.5.8",
"@pdfme/generator": "^5.5.8",
"@pdfme/schemas": "^5.5.8",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@socket.io/redis-adapter": "^8.3.0",
"@tanstack/react-query": "^5.62.0",
"@tanstack/react-query-devtools": "^5.62.0",
"@tanstack/react-table": "^8.21.3",
"better-auth": "^1.2.0",
"bullmq": "^5.25.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"drizzle-orm": "^0.38.0",
"imapflow": "^1.2.13",
"ioredis": "^5.4.0",
"jose": "^6.2.1",
"lucide-react": "^0.460.0",
"mailparser": "^3.9.4",
"minio": "^8.0.0",
"next": "15.1.0",
"next-themes": "^0.4.0",
"nodemailer": "^6.9.0",
"openai": "^6.27.0",
"pino": "^9.5.0",
"pino-pretty": "^13.0.0",
"postgres": "^3.4.0",
"react": "^19.0.0",
"react-day-picker": "^9.14.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.0",
"recharts": "^3.8.0",
"socket.io": "^4.8.0",
"socket.io-client": "^4.8.0",
"sonner": "^1.7.0",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.24.0",
"zustand": "^5.0.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.5",
"@playwright/test": "^1.58.2",
"@types/mailparser": "^3.4.6",
"@types/node": "^22.0.0",
"@types/nodemailer": "^6.4.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitest/coverage-v8": "^4.1.0",
"autoprefixer": "^10.4.27",
"dotenv": "^17.3.1",
"drizzle-kit": "^0.30.0",
"eslint": "^9.0.0",
"eslint-config-next": "15.1.0",
"eslint-config-prettier": "^9.1.0",
"husky": "^9.1.0",
"lint-staged": "^15.2.0",
"postcss": "^8.4.0",
"prettier": "^3.4.0",
"tailwindcss": "^3.4.0",
"tsx": "^4.19.0",
"typescript": "^5.7.0",
"vitest": "^4.1.0"
}
}

40
playwright.config.ts Normal file
View File

@@ -0,0 +1,40 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e/smoke',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: 0,
workers: 1, // Sequential — so tests can build on each other
reporter: [['list'], ['html', { open: 'never' }]],
timeout: 60_000,
expect: { timeout: 10_000 },
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
actionTimeout: 15_000,
navigationTimeout: 30_000,
},
projects: [
{
name: 'setup',
testMatch: /global-setup\.ts/,
},
{
name: 'smoke',
testMatch: /\d{2}-.*\.spec\.ts/,
dependencies: ['setup'],
use: {
...devices['Desktop Chrome'],
viewport: { width: 1440, height: 900 },
},
},
],
// Don't start the dev server — we expect it to already be running
webServer: undefined,
});

10840
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

9
postcss.config.mjs Normal file
View File

@@ -0,0 +1,9 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;

21
src/app/(auth)/layout.tsx Normal file
View File

@@ -0,0 +1,21 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: {
default: 'Sign In',
template: '%s | Port Nimara CRM',
},
};
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return (
<div
className="min-h-screen flex items-center justify-center wave-watermark"
style={{ backgroundColor: '#1e2844' }}
>
<div className="w-full max-w-md px-4">
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,118 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { toast } from 'sonner';
import { authClient } from '@/lib/auth/client';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
const loginSchema = z.object({
email: z.string().email('Please enter a valid email address'),
password: z.string().min(1, 'Password is required'),
});
type LoginFormData = z.infer<typeof loginSchema>;
export default function LoginPage() {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
});
async function onSubmit(data: LoginFormData) {
setIsLoading(true);
try {
const result = await authClient.signIn.email({
email: data.email,
password: data.password,
});
if (result.error) {
toast.error(result.error.message ?? 'Invalid email or password');
return;
}
router.push('/dashboard');
} catch {
toast.error('Something went wrong. Please try again.');
} finally {
setIsLoading(false);
}
}
return (
<div
className="min-h-screen flex items-center justify-center px-4"
style={{ backgroundColor: '#1e2844' }}
>
<Card className="w-full max-w-md">
<CardHeader className="space-y-1 text-center pb-6">
<h1 className="text-2xl font-bold tracking-tight text-foreground">Port Nimara</h1>
<p className="text-sm text-muted-foreground">Marina CRM</p>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
autoComplete="email"
placeholder="you@example.com"
disabled={isLoading}
className={cn(errors.email && 'border-destructive focus-visible:ring-destructive')}
{...register('email')}
/>
{errors.email && (
<p className="text-sm text-destructive">{errors.email.message}</p>
)}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<Link
href="/reset-password"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Forgot password?
</Link>
</div>
<Input
id="password"
type="password"
autoComplete="current-password"
disabled={isLoading}
className={cn(
errors.password && 'border-destructive focus-visible:ring-destructive',
)}
{...register('password')}
/>
{errors.password && (
<p className="text-sm text-destructive">{errors.password.message}</p>
)}
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Signing in…' : 'Sign in'}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,117 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
const resetSchema = z.object({
email: z.string().email('Please enter a valid email address'),
});
type ResetFormData = z.infer<typeof resetSchema>;
export default function ResetPasswordPage() {
const [submitted, setSubmitted] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<ResetFormData>({
resolver: zodResolver(resetSchema),
});
async function onSubmit(data: ResetFormData) {
setIsLoading(true);
try {
// Always show the same success message regardless of whether the email exists.
await fetch('/api/auth/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: data.email }),
});
setSubmitted(true);
} catch {
toast.error('Something went wrong. Please try again.');
} finally {
setIsLoading(false);
}
}
return (
<div
className="min-h-screen flex items-center justify-center px-4"
style={{ backgroundColor: '#1e2844' }}
>
<Card className="w-full max-w-md">
<CardHeader className="space-y-1 text-center pb-6">
<h1 className="text-2xl font-bold tracking-tight text-foreground">Port Nimara</h1>
<p className="text-sm text-muted-foreground">Reset your password</p>
</CardHeader>
<CardContent>
{submitted ? (
<div className="space-y-4 text-center">
<div className="space-y-2">
<p className="font-medium text-foreground">Check your email</p>
<p className="text-sm text-muted-foreground">
If an account exists for that email address, we have sent a password reset link.
Please check your inbox and spam folder.
</p>
</div>
<Link
href="/login"
className="inline-block text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Back to sign in
</Link>
</div>
) : (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
autoComplete="email"
placeholder="you@example.com"
disabled={isLoading}
className={cn(
errors.email && 'border-destructive focus-visible:ring-destructive',
)}
{...register('email')}
/>
{errors.email && (
<p className="text-sm text-destructive">{errors.email.message}</p>
)}
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Sending…' : 'Send reset link'}
</Button>
<p className="text-center text-sm text-muted-foreground">
Remember your password?{' '}
<Link
href="/login"
className="text-foreground underline-offset-4 hover:underline"
>
Sign in
</Link>
</p>
</form>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,176 @@
'use client';
import { useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { toast } from 'sonner';
import { CheckCircle2, Circle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
const passwordSchema = z
.object({
password: z
.string()
.min(12, 'Must be at least 12 characters')
.regex(/[A-Z]/, 'Must contain an uppercase letter')
.regex(/[a-z]/, 'Must contain a lowercase letter')
.regex(/[0-9]/, 'Must contain a number')
.regex(/[^A-Za-z0-9]/, 'Must contain a special character'),
confirmPassword: z.string().min(1, 'Please confirm your password'),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
type SetPasswordFormData = z.infer<typeof passwordSchema>;
type Requirement = {
label: string;
test: (value: string) => boolean;
};
const requirements: Requirement[] = [
{ label: 'At least 12 characters', test: (v) => v.length >= 12 },
{ label: 'Uppercase letter', test: (v) => /[A-Z]/.test(v) },
{ label: 'Lowercase letter', test: (v) => /[a-z]/.test(v) },
{ label: 'Number', test: (v) => /[0-9]/.test(v) },
{ label: 'Special character', test: (v) => /[^A-Za-z0-9]/.test(v) },
];
export default function SetPasswordPage() {
const router = useRouter();
const searchParams = useSearchParams();
const token = searchParams.get('token');
const [isLoading, setIsLoading] = useState(false);
const [passwordValue, setPasswordValue] = useState('');
const {
register,
handleSubmit,
formState: { errors },
} = useForm<SetPasswordFormData>({
resolver: zodResolver(passwordSchema),
});
async function onSubmit(data: SetPasswordFormData) {
if (!token) {
toast.error('Invalid or missing reset token. Please request a new password reset link.');
return;
}
setIsLoading(true);
try {
const response = await fetch('/api/auth/set-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, password: data.password }),
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
toast.error(body.message ?? 'Failed to set password. Please try again.');
return;
}
toast.success('Password set successfully. You can now sign in.');
router.push('/login');
} catch {
toast.error('Something went wrong. Please try again.');
} finally {
setIsLoading(false);
}
}
return (
<div
className="min-h-screen flex items-center justify-center px-4"
style={{ backgroundColor: '#1e2844' }}
>
<Card className="w-full max-w-md">
<CardHeader className="space-y-1 text-center pb-6">
<h1 className="text-2xl font-bold tracking-tight text-foreground">Port Nimara</h1>
<p className="text-sm text-muted-foreground">Set your password</p>
</CardHeader>
<CardContent>
{!token ? (
<p className="text-center text-sm text-destructive">
Invalid or missing token. Please request a new password reset link.
</p>
) : (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
<div className="space-y-2">
<Label htmlFor="password">New Password</Label>
<Input
id="password"
type="password"
autoComplete="new-password"
disabled={isLoading}
className={cn(
errors.password && 'border-destructive focus-visible:ring-destructive',
)}
{...register('password', {
onChange: (e) => setPasswordValue(e.target.value),
})}
/>
{errors.password && (
<p className="text-sm text-destructive">{errors.password.message}</p>
)}
<ul className="space-y-1 pt-1">
{requirements.map((req) => {
const met = req.test(passwordValue);
return (
<li
key={req.label}
className={cn(
'flex items-center gap-2 text-xs',
met ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground',
)}
>
{met ? (
<CheckCircle2 className="h-3.5 w-3.5 shrink-0" />
) : (
<Circle className="h-3.5 w-3.5 shrink-0" />
)}
{req.label}
</li>
);
})}
</ul>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<Input
id="confirmPassword"
type="password"
autoComplete="new-password"
disabled={isLoading}
className={cn(
errors.confirmPassword &&
'border-destructive focus-visible:ring-destructive',
)}
{...register('confirmPassword')}
/>
{errors.confirmPassword && (
<p className="text-sm text-destructive">{errors.confirmPassword.message}</p>
)}
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Setting password…' : 'Set password'}
</Button>
</form>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,16 @@
export default function AuditLogPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Audit Log</h1>
<p className="text-muted-foreground">Review system activity and changes</p>
</div>
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 2</p>
<p className="text-sm text-muted-foreground">
This feature will be implemented in the next phase.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,16 @@
export default function BackupManagementPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Backup Management</h1>
<p className="text-muted-foreground">Manage system backups and restoration</p>
</div>
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 4</p>
<p className="text-sm text-muted-foreground">
This feature will be implemented in the next phase.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import { CustomFieldsManager } from '@/components/admin/custom-fields/custom-fields-manager';
export default function CustomFieldsPage() {
return <CustomFieldsManager />;
}

View File

@@ -0,0 +1,16 @@
export default function FormTemplatesPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Form Templates</h1>
<p className="text-muted-foreground">Create and manage intake form templates</p>
</div>
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 3</p>
<p className="text-sm text-muted-foreground">
This feature will be implemented in the next phase.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,16 @@
export default function DataImportPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Data Import</h1>
<p className="text-muted-foreground">Import data from external sources</p>
</div>
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 4</p>
<p className="text-sm text-muted-foreground">
This feature will be implemented in the next phase.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,19 @@
import { QueueDetailTable } from '@/components/admin/queue-detail-table';
interface QueueDetailPageProps {
params: Promise<{ portSlug: string; queueName: string }>;
}
export default async function QueueDetailPage({ params }: QueueDetailPageProps) {
const { queueName } = await params;
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground capitalize">{queueName} Queue</h1>
<p className="text-muted-foreground">Inspect and manage jobs in the {queueName} queue</p>
</div>
<QueueDetailTable queueName={queueName} />
</div>
);
}

View File

@@ -0,0 +1,5 @@
import { SystemMonitoringDashboard } from '@/components/admin/system-monitoring-dashboard';
export default function MonitoringPage() {
return <SystemMonitoringDashboard />;
}

View File

@@ -0,0 +1,16 @@
export default function OnboardingPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Onboarding</h1>
<p className="text-muted-foreground">Guided setup for new port configurations</p>
</div>
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 4</p>
<p className="text-sm text-muted-foreground">
This feature will be implemented in the next phase.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,16 @@
export default function PortManagementPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Port Management</h1>
<p className="text-muted-foreground">Manage port locations and configurations</p>
</div>
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 2</p>
<p className="text-sm text-muted-foreground">
This feature will be implemented in the next phase.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,16 @@
export default function ScheduledReportsPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Scheduled Reports</h1>
<p className="text-muted-foreground">Configure and manage automated report delivery</p>
</div>
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 3</p>
<p className="text-sm text-muted-foreground">
This feature will be implemented in the next phase.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,16 @@
export default function RoleManagementPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Role Management</h1>
<p className="text-muted-foreground">Configure roles and permissions</p>
</div>
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 2</p>
<p className="text-sm text-muted-foreground">
This feature will be implemented in the next phase.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,16 @@
export default function SystemSettingsPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground">System Settings</h1>
<p className="text-muted-foreground">Configure system-wide settings</p>
</div>
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 2</p>
<p className="text-sm text-muted-foreground">
This feature will be implemented in the next phase.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import { TagList } from '@/components/admin/tags/tag-list';
export default function TagManagementPage() {
return <TagList />;
}

View File

@@ -0,0 +1,5 @@
import { TemplateList } from '@/components/admin/document-templates/template-list';
export default function DocumentTemplatesPage() {
return <TemplateList />;
}

View File

@@ -0,0 +1,16 @@
export default function UserManagementPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground">User Management</h1>
<p className="text-muted-foreground">Manage user accounts and access</p>
</div>
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 2</p>
<p className="text-sm text-muted-foreground">
This feature will be implemented in the next phase.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,250 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { apiFetch } from '@/lib/api/client';
import { WebhookForm } from '@/components/admin/webhooks/webhook-form';
import { WebhookDeliveryLog } from '@/components/admin/webhooks/webhook-delivery-log';
import { WebhookSecretDisplay } from '@/components/admin/webhooks/webhook-secret-display';
interface Webhook {
id: string;
name: string;
url: string;
events: string[];
isActive: boolean;
secretMasked: string;
createdAt: string;
}
export default function WebhooksPage() {
const [webhooks, setWebhooks] = useState<Webhook[]>([]);
const [loading, setLoading] = useState(true);
const [formOpen, setFormOpen] = useState(false);
const [editTarget, setEditTarget] = useState<Webhook | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Webhook | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [regenerating, setRegenerating] = useState<string | null>(null);
const [newSecret, setNewSecret] = useState<{ webhookId: string; secret: string; masked: string } | null>(null);
const loadWebhooks = useCallback(async () => {
try {
const result = await apiFetch<{ data: Webhook[] }>('/api/v1/admin/webhooks');
setWebhooks(result.data);
} catch {
// ignore
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadWebhooks();
}, [loadWebhooks]);
async function handleDelete() {
if (!deleteTarget) return;
try {
await apiFetch(`/api/v1/admin/webhooks/${deleteTarget.id}`, { method: 'DELETE' });
setDeleteTarget(null);
void loadWebhooks();
} catch {
// ignore
}
}
async function handleRegenerate(webhookId: string) {
setRegenerating(webhookId);
try {
const result = await apiFetch<{ data: { secret: string; secretMasked: string } }>(
`/api/v1/admin/webhooks/${webhookId}/regenerate-secret`,
{ method: 'POST' },
);
setNewSecret({ webhookId, secret: result.data.secret, masked: result.data.secretMasked });
void loadWebhooks();
} catch {
// ignore
} finally {
setRegenerating(null);
}
}
async function handleToggleActive(webhook: Webhook) {
try {
await apiFetch(`/api/v1/admin/webhooks/${webhook.id}`, {
method: 'PATCH',
body: { isActive: !webhook.isActive },
});
void loadWebhooks();
} catch {
// ignore
}
}
function toggleExpand(id: string) {
setExpandedId((prev) => (prev === id ? null : id));
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Webhooks</h1>
<p className="text-muted-foreground">Configure outgoing webhook integrations</p>
</div>
<Button onClick={() => { setEditTarget(null); setFormOpen(true); }}>
Add Webhook
</Button>
</div>
{loading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : webhooks.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">No webhooks configured</p>
<p className="text-sm text-muted-foreground mt-1">
Add a webhook to receive real-time notifications of CRM events.
</p>
<Button className="mt-4" onClick={() => { setEditTarget(null); setFormOpen(true); }}>
Add Webhook
</Button>
</div>
) : (
<div className="space-y-2">
{webhooks.map((webhook) => (
<div key={webhook.id} className="rounded-lg border bg-card">
<div className="flex items-center gap-4 p-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium truncate">{webhook.name}</span>
<Badge variant={webhook.isActive ? 'default' : 'secondary'}>
{webhook.isActive ? 'Active' : 'Inactive'}
</Badge>
</div>
<p className="text-xs text-muted-foreground font-mono truncate mt-0.5">
{webhook.url}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{webhook.events.length} event{webhook.events.length !== 1 ? 's' : ''}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => handleToggleActive(webhook)}
>
{webhook.isActive ? 'Disable' : 'Enable'}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => { setEditTarget(webhook); setFormOpen(true); }}
>
Edit
</Button>
<Button
variant="ghost"
size="sm"
className="text-destructive"
onClick={() => setDeleteTarget(webhook)}
>
Delete
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => toggleExpand(webhook.id)}
>
{expandedId === webhook.id ? 'Collapse' : 'Details'}
</Button>
</div>
</div>
{expandedId === webhook.id && (
<div className="border-t px-4 py-4 space-y-6">
{/* Events */}
<div>
<h3 className="text-sm font-medium mb-2">Subscribed Events</h3>
<div className="flex flex-wrap gap-1">
{webhook.events.map((e) => (
<Badge key={e} variant="outline" className="font-mono text-xs">
{e}
</Badge>
))}
</div>
</div>
{/* Secret */}
<div>
<h3 className="text-sm font-medium mb-2">Signing Secret</h3>
{newSecret?.webhookId === webhook.id ? (
<WebhookSecretDisplay
plaintext={newSecret.secret}
masked={newSecret.masked}
/>
) : (
<WebhookSecretDisplay masked={webhook.secretMasked} />
)}
<Button
variant="outline"
size="sm"
className="mt-2"
disabled={regenerating === webhook.id}
onClick={() => handleRegenerate(webhook.id)}
>
{regenerating === webhook.id ? 'Regenerating...' : 'Regenerate Secret'}
</Button>
</div>
{/* Delivery Log */}
<div>
<h3 className="text-sm font-medium mb-2">Delivery Log</h3>
<WebhookDeliveryLog webhookId={webhook.id} />
</div>
</div>
)}
</div>
))}
</div>
)}
<WebhookForm
open={formOpen}
onOpenChange={setFormOpen}
webhook={editTarget}
onSuccess={loadWebhooks}
/>
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Webhook</AlertDialogTitle>
<AlertDialogDescription>
Delete &quot;{deleteTarget?.name}&quot;? This will also delete all delivery history. This action
cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground">
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -0,0 +1,10 @@
import { BerthDetail } from '@/components/berths/berth-detail';
interface BerthPageProps {
params: Promise<{ portSlug: string; berthId: string }>;
}
export default async function BerthPage({ params }: BerthPageProps) {
const { berthId } = await params;
return <BerthDetail berthId={berthId} />;
}

View File

@@ -0,0 +1,5 @@
import { BerthList } from '@/components/berths/berth-list';
export default function BerthsPage() {
return <BerthList />;
}

View File

@@ -0,0 +1,16 @@
import { ClientDetail } from '@/components/clients/client-detail';
import { auth } from '@/lib/auth';
import { headers } from 'next/headers';
interface ClientDetailPageProps {
params: Promise<{ clientId: string }>;
}
export default async function ClientDetailPage({ params }: ClientDetailPageProps) {
const { clientId } = await params;
const session = await auth.api.getSession({ headers: await headers() });
const currentUserId = session?.user?.id;
return <ClientDetail clientId={clientId} currentUserId={currentUserId} />;
}

View File

@@ -0,0 +1,5 @@
import { ClientList } from '@/components/clients/client-list';
export default function ClientsPage() {
return <ClientList />;
}

View File

@@ -0,0 +1,144 @@
'use client';
import { useState } from 'react';
import { useParams } from 'next/navigation';
import { Grid, List, Upload } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { PageHeader } from '@/components/shared/page-header';
import { PermissionGate } from '@/components/shared/permission-gate';
import { FileGrid } from '@/components/files/file-grid';
import { FolderTree } from '@/components/files/folder-tree';
import { FileUploadZone } from '@/components/files/file-upload-zone';
import { FilePreviewDialog } from '@/components/files/file-preview-dialog';
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { useFileBrowserStore } from '@/stores/file-browser-store';
import { apiFetch } from '@/lib/api/client';
import type { FileRow } from '@/components/files/file-grid';
export default function DocumentsPage() {
const params = useParams<{ portSlug: string }>();
const queryClient = useQueryClient();
const { viewMode, setViewMode, currentFolder, setCurrentFolder } = useFileBrowserStore();
const [showUpload, setShowUpload] = useState(false);
const [previewFile, setPreviewFile] = useState<FileRow | null>(null);
const [renameFile, setRenameFile] = useState<FileRow | null>(null);
const { data, isLoading } = usePaginatedQuery<FileRow & { storagePath: string }>({
queryKey: ['files'],
endpoint: '/api/v1/files',
filterDefinitions: [],
});
useRealtimeInvalidation({
'file:uploaded': [['files']],
'file:updated': [['files']],
'file:deleted': [['files']],
});
const filesInFolder = currentFolder
? data.filter((f) => f.storagePath?.includes(currentFolder))
: data;
const handleDownload = async (file: FileRow) => {
try {
const res = await apiFetch<{ data: { url: string; filename: string } }>(
`/api/v1/files/${file.id}/download`,
);
const a = document.createElement('a');
a.href = res.data.url;
a.download = res.data.filename;
a.click();
} catch {
// silent
}
};
const handleDelete = async (file: FileRow) => {
if (!confirm(`Delete "${file.filename}"? This cannot be undone.`)) return;
try {
await apiFetch(`/api/v1/files/${file.id}`, { method: 'DELETE' });
queryClient.invalidateQueries({ queryKey: ['files'] });
} catch {
// silent
}
};
return (
<div className="flex h-full flex-col gap-4">
<PageHeader
title="Documents"
description="Store and manage port documents and attachments"
actions={
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={() => setViewMode(viewMode === 'grid' ? 'list' : 'grid')}
>
{viewMode === 'grid' ? (
<List className="h-4 w-4" />
) : (
<Grid className="h-4 w-4" />
)}
</Button>
<PermissionGate resource="files" action="upload">
<Button size="sm" onClick={() => setShowUpload((v) => !v)}>
<Upload className="mr-1.5 h-4 w-4" />
Upload
</Button>
</PermissionGate>
</div>
}
/>
{showUpload && (
<PermissionGate resource="files" action="upload">
<FileUploadZone
onUploadComplete={() => {
queryClient.invalidateQueries({ queryKey: ['files'] });
setShowUpload(false);
}}
/>
</PermissionGate>
)}
<div className="flex flex-1 gap-4 overflow-hidden">
{/* Folder tree sidebar */}
<aside className="w-48 shrink-0 overflow-y-auto rounded-lg border bg-card p-2">
<p className="mb-1 px-2 text-xs font-medium text-muted-foreground uppercase tracking-wide">
Folders
</p>
<FolderTree
files={data}
currentFolder={currentFolder}
onFolderSelect={setCurrentFolder}
/>
</aside>
{/* Main content */}
<main className="flex-1 overflow-y-auto rounded-lg border bg-card p-4">
<FileGrid
files={filesInFolder}
onDownload={handleDownload}
onPreview={setPreviewFile}
onRename={setRenameFile}
onDelete={handleDelete}
isLoading={isLoading}
/>
</main>
</div>
<FilePreviewDialog
open={!!previewFile}
onOpenChange={(open) => !open && setPreviewFile(null)}
fileId={previewFile?.id}
fileName={previewFile?.filename}
mimeType={previewFile?.mimeType ?? undefined}
/>
</div>
);
}

View File

@@ -0,0 +1,16 @@
export default function EmailPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Email</h1>
<p className="text-muted-foreground">Send and manage client communications</p>
</div>
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 3</p>
<p className="text-sm text-muted-foreground">
This feature will be implemented in the next phase.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,40 @@
'use client';
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { ExpenseDetail } from '@/components/expenses/expense-detail';
import { ExpenseFormDialog } from '@/components/expenses/expense-form-dialog';
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api/client';
import type { ExpenseRow } from '@/components/expenses/expense-columns';
export default function ExpenseDetailPage() {
const params = useParams<{ portSlug: string; id: string }>();
const router = useRouter();
const [editOpen, setEditOpen] = useState(false);
const { data } = useQuery<{ data: ExpenseRow }>({
queryKey: ['expenses', params.id],
queryFn: () => apiFetch(`/api/v1/expenses/${params.id}`),
enabled: !!params.id,
});
return (
<div className="max-w-3xl mx-auto">
<ExpenseDetail
expenseId={params.id}
onEdit={() => setEditOpen(true)}
onArchived={() => router.push(`/${params.portSlug}/expenses`)}
/>
{data?.data && (
<ExpenseFormDialog
open={editOpen}
onOpenChange={setEditOpen}
expense={data.data}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,185 @@
'use client';
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { Plus, Download, FileText, FileSpreadsheet } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { DataTable } from '@/components/shared/data-table';
import { FilterBar } from '@/components/shared/filter-bar';
import { PageHeader } from '@/components/shared/page-header';
import { EmptyState } from '@/components/shared/empty-state';
import { TableSkeleton } from '@/components/shared/loading-skeleton';
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
import { PermissionGate } from '@/components/shared/permission-gate';
import { ExpenseFormDialog } from '@/components/expenses/expense-form-dialog';
import { expenseFilterDefinitions } from '@/components/expenses/expense-filters';
import { getExpenseColumns, type ExpenseRow } from '@/components/expenses/expense-columns';
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { apiFetch } from '@/lib/api/client';
export default function ExpensesPage() {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [editExpense, setEditExpense] = useState<ExpenseRow | null>(null);
const [archiveExpense, setArchiveExpense] = useState<ExpenseRow | null>(null);
const {
data,
pagination,
isLoading,
isFetching,
sort,
setSort,
setPage,
setPageSize,
filters,
setFilter,
clearFilters,
} = usePaginatedQuery<ExpenseRow>({
queryKey: ['expenses'],
endpoint: '/api/v1/expenses',
filterDefinitions: expenseFilterDefinitions,
});
useRealtimeInvalidation({
'expense:created': [['expenses']],
'expense:updated': [['expenses']],
'expense:archived': [['expenses']],
});
const archiveMutation = useMutation({
mutationFn: (id: string) =>
apiFetch(`/api/v1/expenses/${id}`, { method: 'DELETE' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['expenses'] });
setArchiveExpense(null);
},
});
async function handleExport(type: 'csv' | 'pdf') {
const res = await fetch(`/api/v1/expenses/export/${type}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(filters),
credentials: 'include',
});
if (!res.ok) return;
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `expenses.${type}`;
a.click();
URL.revokeObjectURL(url);
}
const columns = getExpenseColumns({
portSlug,
onEdit: (expense) => setEditExpense(expense),
onArchive: (expense) => setArchiveExpense(expense),
});
return (
<div className="space-y-4">
<PageHeader
title="Expenses"
description="Track and manage port expenses"
actions={
<div className="flex items-center gap-2">
<PermissionGate resource="expenses" action="view">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<Download className="mr-1.5 h-4 w-4" />
Export
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleExport('csv')}>
<FileSpreadsheet className="mr-2 h-4 w-4" />
Export CSV
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleExport('pdf')}>
<FileText className="mr-2 h-4 w-4" />
Export PDF
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</PermissionGate>
<PermissionGate resource="expenses" action="create">
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="mr-1.5 h-4 w-4" />
New Expense
</Button>
</PermissionGate>
</div>
}
/>
<FilterBar
filters={expenseFilterDefinitions}
values={filters}
onChange={setFilter}
onClear={clearFilters}
/>
{isLoading ? (
<TableSkeleton />
) : (
<DataTable
columns={columns}
data={data}
pagination={pagination}
onPaginationChange={(p, ps) => {
setPage(p);
setPageSize(ps);
}}
sort={sort}
onSortChange={setSort}
isLoading={isFetching && !isLoading}
getRowId={(row) => row.id}
emptyState={
<EmptyState
title="No expenses found"
description="Get started by adding your first expense."
action={{ label: 'New Expense', onClick: () => setCreateOpen(true) }}
/>
}
/>
)}
<ExpenseFormDialog open={createOpen} onOpenChange={setCreateOpen} />
{editExpense && (
<ExpenseFormDialog
open={!!editExpense}
onOpenChange={(open) => !open && setEditExpense(null)}
expense={editExpense}
/>
)}
<ArchiveConfirmDialog
open={!!archiveExpense}
onOpenChange={(open) => !open && setArchiveExpense(null)}
entityName={archiveExpense?.establishmentName ?? 'this expense'}
entityType="Expense"
isArchived={false}
onConfirm={() => archiveExpense && archiveMutation.mutate(archiveExpense.id)}
isLoading={archiveMutation.isPending}
/>
</div>
);
}

View File

@@ -0,0 +1,252 @@
'use client';
import { useState, useRef } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useMutation } from '@tanstack/react-query';
import { Upload, Loader2, Camera, ScanLine } 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 { apiFetch } from '@/lib/api/client';
import { EXPENSE_CATEGORIES } from '@/lib/constants';
interface ScanResult {
establishment: string | null;
date: string | null;
amount: number | null;
currency: string | null;
lineItems: Array<{ description: string; amount: number }>;
confidence: number;
}
export default function ScanReceiptPage() {
const params = useParams<{ portSlug: string }>();
const router = useRouter();
const fileInputRef = useRef<HTMLInputElement>(null);
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
// Editable fields from scan
const [establishment, setEstablishment] = useState('');
const [amount, setAmount] = useState('');
const [currency, setCurrency] = useState('USD');
const [date, setDate] = useState('');
const [category, setCategory] = useState('');
const scanMutation = useMutation({
mutationFn: async (file: File) => {
const formData = new FormData();
formData.append('file', file);
const res = await fetch('/api/v1/expenses/scan-receipt', {
method: 'POST',
body: formData,
credentials: 'include',
});
if (!res.ok) throw new Error('Scan failed');
return res.json() as Promise<{ data: ScanResult }>;
},
onSuccess: (response) => {
const result = response.data;
setScanResult(result);
if (result.establishment) setEstablishment(result.establishment);
if (result.amount) setAmount(String(result.amount));
if (result.currency) setCurrency(result.currency);
if (result.date) setDate(result.date.split('T')[0] ?? result.date);
},
});
const saveMutation = useMutation({
mutationFn: () =>
apiFetch('/api/v1/expenses', {
method: 'POST',
body: {
establishmentName: establishment,
amount: Number(amount),
currency,
category: category || undefined,
expenseDate: date ? new Date(date) : new Date(),
paymentStatus: 'unpaid',
},
}),
onSuccess: () => {
router.push(`/${params.portSlug}/expenses`);
},
});
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
const url = URL.createObjectURL(file);
setPreviewUrl(url);
scanMutation.mutate(file);
}
return (
<div className="max-w-2xl mx-auto space-y-6">
<div>
<h1 className="text-2xl font-bold">Scan Receipt</h1>
<p className="text-muted-foreground mt-1">
Upload a receipt image and we will extract the expense details automatically.
</p>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<ScanLine className="h-4 w-4" />
Upload Receipt
</CardTitle>
</CardHeader>
<CardContent>
<div
className="border-2 border-dashed rounded-lg p-8 text-center cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => fileInputRef.current?.click()}
>
{previewUrl ? (
<img
src={previewUrl}
alt="Receipt preview"
className="max-h-64 mx-auto rounded object-contain"
/>
) : (
<div className="space-y-2">
<Upload className="h-8 w-8 mx-auto text-muted-foreground" />
<p className="text-sm text-muted-foreground">
Click to upload or drag and drop
</p>
<p className="text-xs text-muted-foreground">
JPEG, PNG, WebP up to 10MB
</p>
</div>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFileChange}
/>
{scanMutation.isPending && (
<div className="flex items-center justify-center gap-2 mt-4 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">Scanning receipt...</span>
</div>
)}
</CardContent>
</Card>
{(scanResult || scanMutation.isSuccess) && (
<Card>
<CardHeader>
<CardTitle className="text-base">
Extracted Details
{scanResult && (
<span className="text-sm font-normal text-muted-foreground ml-2">
(confidence: {Math.round((scanResult.confidence ?? 0) * 100)}%)
</span>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="scan-amount">Amount</Label>
<Input
id="scan-amount"
type="number"
step="0.01"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="0.00"
/>
</div>
<div className="space-y-1">
<Label htmlFor="scan-currency">Currency</Label>
<Input
id="scan-currency"
value={currency}
onChange={(e) => setCurrency(e.target.value.toUpperCase())}
maxLength={3}
placeholder="USD"
/>
</div>
</div>
<div className="space-y-1">
<Label htmlFor="scan-establishment">Establishment</Label>
<Input
id="scan-establishment"
value={establishment}
onChange={(e) => setEstablishment(e.target.value)}
placeholder="Establishment name"
/>
</div>
<div className="space-y-1">
<Label htmlFor="scan-date">Date</Label>
<Input
id="scan-date"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="scan-category">Category</Label>
<Select value={category} onValueChange={setCategory}>
<SelectTrigger id="scan-category">
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent>
{EXPENSE_CATEGORIES.map((cat) => (
<SelectItem key={cat} value={cat}>
{cat.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{saveMutation.isError && (
<p className="text-sm text-destructive">
{(saveMutation.error as Error).message}
</p>
)}
<div className="flex gap-2 pt-2">
<Button
variant="outline"
onClick={() => router.push(`/${params.portSlug}/expenses`)}
>
Cancel
</Button>
<Button
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending || !amount}
>
{saveMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Save as Expense
</Button>
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { InterestDetail } from '@/components/interests/interest-detail';
import { auth } from '@/lib/auth';
import { headers } from 'next/headers';
interface InterestDetailPageProps {
params: Promise<{ interestId: string }>;
}
export default async function InterestDetailPage({ params }: InterestDetailPageProps) {
const { interestId } = await params;
const session = await auth.api.getSession({ headers: await headers() });
const currentUserId = session?.user?.id;
return <InterestDetail interestId={interestId} currentUserId={currentUserId} />;
}

View File

@@ -0,0 +1,5 @@
import { InterestList } from '@/components/interests/interest-list';
export default function InterestsPage() {
return <InterestList />;
}

View File

@@ -0,0 +1,15 @@
import { use } from 'react';
import { InvoiceDetail } from '@/components/invoices/invoice-detail';
interface InvoiceDetailPageProps {
params: Promise<{ portSlug: string; id: string }>;
}
export default function InvoiceDetailPage({ params }: InvoiceDetailPageProps) {
const { id } = use(params);
return (
<div className="max-w-4xl mx-auto space-y-6">
<InvoiceDetail invoiceId={id} />
</div>
);
}

View File

@@ -0,0 +1,388 @@
'use client';
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useForm, FormProvider } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery } from '@tanstack/react-query';
import { ChevronLeft, ChevronRight, Check, Loader2 } 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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { InvoiceLineItems } from '@/components/invoices/invoice-line-items';
import { apiFetch } from '@/lib/api/client';
import { createInvoiceSchema, type CreateInvoiceInput } from '@/lib/validators/invoices';
const PAYMENT_TERMS = [
{ label: 'Immediate', value: 'immediate' },
{ label: 'Net 10', value: 'net10' },
{ label: 'Net 15', value: 'net15' },
{ label: 'Net 30', value: 'net30' },
{ label: 'Net 45', value: 'net45' },
{ label: 'Net 60', value: 'net60' },
];
const STEPS = [
{ id: 1, label: 'Client Info' },
{ id: 2, label: 'Line Items' },
{ id: 3, label: 'Review' },
];
export default function NewInvoicePage() {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const router = useRouter();
const [step, setStep] = useState(1);
const methods = useForm<CreateInvoiceInput>({
resolver: zodResolver(createInvoiceSchema),
defaultValues: {
paymentTerms: 'net30',
currency: 'USD',
lineItems: [],
expenseIds: [],
},
});
const { register, handleSubmit, watch, setValue, formState: { errors } } = methods;
const watchedValues = watch();
const lineItems = watchedValues.lineItems ?? [];
const subtotal = lineItems.reduce(
(sum, li) => sum + (Number(li.quantity) || 0) * (Number(li.unitPrice) || 0),
0,
);
const isNet10 = watchedValues.paymentTerms === 'net10';
const discountPct = isNet10 ? 2 : 0;
const discountAmount = (subtotal * discountPct) / 100;
const total = subtotal - discountAmount;
const createMutation = useMutation({
mutationFn: (data: CreateInvoiceInput) =>
apiFetch('/api/v1/invoices', {
method: 'POST',
body: data,
}),
onSuccess: (res: any) => {
const id = res?.data?.id;
if (id) {
router.push(`/${portSlug}/invoices/${id}`);
} else {
router.push(`/${portSlug}/invoices`);
}
},
});
async function goNext() {
if (step === 1) {
const valid = await methods.trigger([
'clientName',
'billingEmail',
'billingAddress',
'dueDate',
'paymentTerms',
'currency',
]);
if (valid) setStep(2);
} else if (step === 2) {
setStep(3);
}
}
function goBack() {
setStep((s) => Math.max(1, s - 1));
}
function onSubmit(data: CreateInvoiceInput) {
createMutation.mutate(data);
}
return (
<div className="max-w-2xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="sm"
onClick={() => router.push(`/${portSlug}/invoices`)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<h1 className="text-xl font-semibold">New Invoice</h1>
</div>
{/* Step indicator */}
<div className="flex items-center gap-2">
{STEPS.map((s, idx) => (
<div key={s.id} className="flex items-center gap-2">
<div
className={`flex items-center justify-center w-7 h-7 rounded-full text-xs font-medium ${
step > s.id
? 'bg-primary text-primary-foreground'
: step === s.id
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground'
}`}
>
{step > s.id ? <Check className="h-3.5 w-3.5" /> : s.id}
</div>
<span
className={`text-sm ${
step === s.id ? 'font-medium' : 'text-muted-foreground'
}`}
>
{s.label}
</span>
{idx < STEPS.length - 1 && (
<div className="w-8 h-px bg-border mx-1" />
)}
</div>
))}
</div>
<FormProvider {...methods}>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Step 1: Client Info */}
{step === 1 && (
<Card>
<CardHeader>
<CardTitle className="text-base">Client Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1">
<Label htmlFor="clientName">
Client Name <span className="text-destructive">*</span>
</Label>
<Input
id="clientName"
{...register('clientName')}
placeholder="Client or company name"
/>
{errors.clientName && (
<p className="text-xs text-destructive">{errors.clientName.message}</p>
)}
</div>
<div className="space-y-1">
<Label htmlFor="billingEmail">Billing Email</Label>
<Input
id="billingEmail"
type="email"
{...register('billingEmail')}
placeholder="billing@example.com"
/>
{errors.billingEmail && (
<p className="text-xs text-destructive">{errors.billingEmail.message}</p>
)}
</div>
<div className="space-y-1">
<Label htmlFor="billingAddress">Billing Address</Label>
<Textarea
id="billingAddress"
{...register('billingAddress')}
placeholder="Address"
rows={3}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<Label htmlFor="dueDate">
Due Date <span className="text-destructive">*</span>
</Label>
<Input
id="dueDate"
type="date"
{...register('dueDate')}
/>
{errors.dueDate && (
<p className="text-xs text-destructive">{errors.dueDate.message}</p>
)}
</div>
<div className="space-y-1">
<Label>Payment Terms</Label>
<Select
defaultValue="net30"
onValueChange={(v) => setValue('paymentTerms', v as any)}
>
<SelectTrigger>
<SelectValue placeholder="Select terms" />
</SelectTrigger>
<SelectContent>
{PAYMENT_TERMS.map((t) => (
<SelectItem key={t.value} value={t.value}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1">
<Label>Currency</Label>
<Input
{...register('currency')}
placeholder="USD"
maxLength={3}
className="uppercase w-24"
/>
</div>
<div className="space-y-1">
<Label htmlFor="notes">Notes</Label>
<Textarea
id="notes"
{...register('notes')}
placeholder="Payment instructions or notes..."
rows={3}
/>
</div>
</CardContent>
</Card>
)}
{/* Step 2: Line Items */}
{step === 2 && (
<Card>
<CardHeader>
<CardTitle className="text-base">Line Items</CardTitle>
</CardHeader>
<CardContent>
<InvoiceLineItems name="lineItems" />
{errors.lineItems && !Array.isArray(errors.lineItems) && (
<p className="text-xs text-destructive mt-2">
{(errors.lineItems as any).message}
</p>
)}
{errors.root && (
<p className="text-xs text-destructive mt-2">{errors.root.message}</p>
)}
</CardContent>
</Card>
)}
{/* Step 3: Review */}
{step === 3 && (
<Card>
<CardHeader>
<CardTitle className="text-base">Review & Create</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Client</span>
<p className="font-medium mt-0.5">{watchedValues.clientName}</p>
</div>
<div>
<span className="text-muted-foreground">Due Date</span>
<p className="font-medium mt-0.5">{watchedValues.dueDate}</p>
</div>
<div>
<span className="text-muted-foreground">Payment Terms</span>
<p className="font-medium mt-0.5 capitalize">
{watchedValues.paymentTerms}
</p>
</div>
<div>
<span className="text-muted-foreground">Currency</span>
<p className="font-medium mt-0.5">{watchedValues.currency}</p>
</div>
</div>
{lineItems.length > 0 && (
<div className="border rounded-md p-3 space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Line Items
</p>
{lineItems.map((li, i) => (
<div key={i} className="flex justify-between text-sm">
<span>{li.description}</span>
<span className="tabular-nums">
{(Number(li.quantity) * Number(li.unitPrice)).toFixed(2)}{' '}
{watchedValues.currency}
</span>
</div>
))}
</div>
)}
<div className="border rounded-md p-3 space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Subtotal</span>
<span className="tabular-nums">
{subtotal.toFixed(2)} {watchedValues.currency}
</span>
</div>
{isNet10 && (
<div className="flex justify-between text-green-600">
<span>Net 10 Discount (~2%)</span>
<span className="tabular-nums">
-{discountAmount.toFixed(2)} {watchedValues.currency}
</span>
</div>
)}
<div className="flex justify-between font-semibold border-t pt-2 mt-1">
<span>Total</span>
<span className="tabular-nums">
{total.toFixed(2)} {watchedValues.currency}
</span>
</div>
</div>
{createMutation.isError && (
<p className="text-sm text-destructive">
Failed to create invoice. Please try again.
</p>
)}
</CardContent>
</Card>
)}
{/* Navigation */}
<div className="flex items-center justify-between">
<Button
type="button"
variant="outline"
onClick={goBack}
disabled={step === 1}
>
<ChevronLeft className="mr-1.5 h-4 w-4" />
Back
</Button>
{step < 3 ? (
<Button type="button" onClick={goNext}>
Next
<ChevronRight className="ml-1.5 h-4 w-4" />
</Button>
) : (
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
) : (
<Check className="mr-1.5 h-4 w-4" />
)}
Create Invoice
</Button>
)}
</div>
</form>
</FormProvider>
</div>
);
}

View File

@@ -0,0 +1,190 @@
'use client';
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { Plus, Trash2 } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { DataTable } from '@/components/shared/data-table';
import { FilterBar } from '@/components/shared/filter-bar';
import { PageHeader } from '@/components/shared/page-header';
import { EmptyState } from '@/components/shared/empty-state';
import { TableSkeleton } from '@/components/shared/loading-skeleton';
import { PermissionGate } from '@/components/shared/permission-gate';
import { invoiceFilterDefinitions } from '@/components/invoices/invoice-filters';
import { getInvoiceColumns, type InvoiceRow } from '@/components/invoices/invoice-columns';
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { apiFetch } from '@/lib/api/client';
const STATUS_TABS = [
{ label: 'All', value: '' },
{ label: 'Draft', value: 'draft' },
{ label: 'Sent', value: 'sent' },
{ label: 'Paid', value: 'paid' },
{ label: 'Overdue', value: 'overdue' },
] as const;
export default function InvoicesPage() {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const router = useRouter();
const queryClient = useQueryClient();
const [deleteTarget, setDeleteTarget] = useState<InvoiceRow | null>(null);
const {
data,
pagination,
isLoading,
isFetching,
sort,
setSort,
setPage,
setPageSize,
filters,
setFilter,
clearFilters,
} = usePaginatedQuery<InvoiceRow>({
queryKey: ['invoices'],
endpoint: '/api/v1/invoices',
filterDefinitions: invoiceFilterDefinitions,
});
const activeStatus = (filters.status as string) ?? '';
useRealtimeInvalidation({
'invoice:created': [['invoices']],
'invoice:updated': [['invoices']],
'invoice:sent': [['invoices']],
'invoice:paid': [['invoices']],
'invoice:overdue': [['invoices']],
});
const deleteMutation = useMutation({
mutationFn: (id: string) =>
apiFetch(`/api/v1/invoices/${id}`, { method: 'DELETE' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['invoices'] });
setDeleteTarget(null);
},
});
const sendMutation = useMutation({
mutationFn: (id: string) =>
apiFetch(`/api/v1/invoices/${id}/send`, { method: 'POST' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['invoices'] });
},
});
const columns = getInvoiceColumns({
portSlug,
onSend: (invoice) => sendMutation.mutate(invoice.id),
onRecordPayment: (invoice) =>
router.push(`/${portSlug}/invoices/${invoice.id}?tab=payment`),
onDelete: (invoice) => setDeleteTarget(invoice),
});
return (
<div className="space-y-4">
<PageHeader
title="Invoices"
description="Create and manage port invoices"
actions={
<PermissionGate resource="invoices" action="create">
<Button size="sm" onClick={() => router.push(`/${portSlug}/invoices/new`)}>
<Plus className="mr-1.5 h-4 w-4" />
New Invoice
</Button>
</PermissionGate>
}
/>
{/* Status quick-filter tabs */}
<div className="flex items-center gap-1 border-b">
{STATUS_TABS.map((tab) => (
<button
key={tab.value}
onClick={() => setFilter('status', tab.value || undefined)}
className={`px-3 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${
activeStatus === tab.value
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
{tab.label}
</button>
))}
</div>
<FilterBar
filters={invoiceFilterDefinitions.filter((f) => f.key !== 'status')}
values={filters}
onChange={setFilter}
onClear={clearFilters}
/>
{isLoading ? (
<TableSkeleton />
) : (
<DataTable
columns={columns}
data={data}
pagination={pagination}
onPaginationChange={(p, ps) => {
setPage(p);
setPageSize(ps);
}}
sort={sort}
onSortChange={setSort}
isLoading={isFetching && !isLoading}
getRowId={(row) => row.id}
emptyState={
<EmptyState
title="No invoices found"
description="Get started by creating your first invoice."
action={{
label: 'New Invoice',
onClick: () => router.push(`/${portSlug}/invoices/new`),
}}
/>
}
/>
)}
{/* Delete confirmation */}
{deleteTarget && (
<div className="fixed inset-0 bg-background/80 backdrop-blur-sm z-50 flex items-center justify-center">
<div className="bg-background border rounded-lg shadow-lg p-6 max-w-sm w-full space-y-4">
<h3 className="font-semibold">Delete Invoice?</h3>
<p className="text-sm text-muted-foreground">
This will permanently delete invoice{' '}
<span className="font-mono font-medium">{deleteTarget.invoiceNumber}</span>.
This action cannot be undone.
</p>
<div className="flex items-center gap-2 justify-end">
<Button
variant="outline"
size="sm"
onClick={() => setDeleteTarget(null)}
>
Cancel
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => deleteMutation.mutate(deleteTarget.id)}
disabled={deleteMutation.isPending}
>
<Trash2 className="mr-1.5 h-4 w-4" />
Delete
</Button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,5 @@
import { DashboardShell } from '@/components/dashboard/dashboard-shell';
export default function DashboardPage() {
return <DashboardShell />;
}

View File

@@ -0,0 +1,16 @@
export default function RemindersPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Reminders</h1>
<p className="text-muted-foreground">Manage tasks and follow-up reminders</p>
</div>
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 2</p>
<p className="text-sm text-muted-foreground">
This feature will be implemented in the next phase.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import { ReportsPageClient } from '@/components/reports/reports-page-client';
export default function ReportsPage() {
return <ReportsPageClient />;
}

View File

@@ -0,0 +1,16 @@
export default function SettingsPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Settings</h1>
<p className="text-muted-foreground">Manage your account and port preferences</p>
</div>
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 2</p>
<p className="text-sm text-muted-foreground">
This feature will be implemented in the next phase.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,46 @@
import { redirect } from 'next/navigation';
import { headers } from 'next/headers';
import { eq } from 'drizzle-orm';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { userPortRoles } from '@/lib/db/schema/users';
import { QueryProvider } from '@/providers/query-provider';
import { SocketProvider } from '@/providers/socket-provider';
import { PortProvider } from '@/providers/port-provider';
import { PermissionsProvider } from '@/providers/permissions-provider';
import { Sidebar } from '@/components/layout/sidebar';
import { Topbar } from '@/components/layout/topbar';
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) redirect('/login');
// Load user's port assignments for PortProvider
const portRoles = await db.query.userPortRoles.findMany({
where: eq(userPortRoles.userId, session.user.id),
with: { port: true, role: true },
});
const ports = portRoles.map((pr) => pr.port);
return (
<QueryProvider>
<PortProvider ports={ports} defaultPortId={portRoles[0]?.port.id ?? null}>
<PermissionsProvider>
<SocketProvider>
<div className="flex h-screen overflow-hidden bg-background">
<Sidebar portRoles={portRoles} />
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
<Topbar ports={ports} />
<main className="flex-1 overflow-y-auto bg-background p-6">
{children}
</main>
</div>
</div>
</SocketProvider>
</PermissionsProvider>
</PortProvider>
</QueryProvider>
);
}

View File

@@ -0,0 +1,61 @@
import type { Metadata } from 'next';
import { redirect } from 'next/navigation';
import { getPortalSession } from '@/lib/portal/auth';
import { getPortalDashboard } from '@/lib/services/portal.service';
import { PortalHeader } from '@/components/portal/portal-header';
import { PortalNav } from '@/components/portal/portal-nav';
export const metadata: Metadata = {
title: {
default: 'Client Portal',
template: '%s | Client Portal',
},
};
const PUBLIC_PORTAL_PATHS = ['/portal/login', '/portal/verify'];
export default async function PortalLayout({
children,
}: {
children: React.ReactNode;
}) {
// This layout wraps all portal routes including login/verify
// We can't easily check pathname in a server layout, so we attempt
// to get the session and pass it down — login/verify pages handle their own
// redirect logic independently.
const session = await getPortalSession().catch(() => null);
// For authenticated routes we need client info for the header.
// If session is absent, children (login/verify pages) handle their own redirect.
let clientName = '';
let portName = 'Client Portal';
let portLogoUrl: string | null = null;
if (session) {
const dashboard = await getPortalDashboard(session.clientId, session.portId).catch(() => null);
if (dashboard) {
clientName = dashboard.client.fullName;
portName = dashboard.port.name;
portLogoUrl = dashboard.port.logoUrl;
}
}
return (
<div className="min-h-screen bg-gray-50">
{session && (
<>
<PortalHeader
portName={portName}
portLogoUrl={portLogoUrl}
clientName={clientName}
/>
<PortalNav />
</>
)}
<main className={session ? 'max-w-5xl mx-auto px-4 sm:px-6 py-8' : ''}>
{children}
</main>
</div>
);
}

View File

@@ -0,0 +1,65 @@
import { redirect } from 'next/navigation';
import { Anchor, FileText, Receipt } from 'lucide-react';
import type { Metadata } from 'next';
import { getPortalSession } from '@/lib/portal/auth';
import { getPortalDashboard } from '@/lib/services/portal.service';
import { PortalCard } from '@/components/portal/portal-card';
export const metadata: Metadata = { title: 'Dashboard' };
export default async function PortalDashboardPage() {
const session = await getPortalSession();
if (!session) redirect('/portal/login');
const dashboard = await getPortalDashboard(session.clientId, session.portId);
if (!dashboard) redirect('/portal/login');
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold text-gray-900">
Welcome back, {dashboard.client.fullName.split(' ')[0]}
</h1>
{dashboard.client.companyName && (
<p className="text-gray-500 mt-0.5">{dashboard.client.companyName}</p>
)}
{dashboard.client.yachtName && (
<p className="text-sm text-gray-400 mt-0.5">Vessel: {dashboard.client.yachtName}</p>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<PortalCard
title="Berth Interests"
value={dashboard.counts.interests}
description="Your berth enquiries and applications"
icon={Anchor}
href="/portal/interests"
/>
<PortalCard
title="Documents"
value={dashboard.counts.documents}
description="Contracts, EOIs and signed agreements"
icon={FileText}
href="/portal/documents"
/>
<PortalCard
title="Invoices"
value={dashboard.counts.invoices}
description="Billing statements and payment history"
icon={Receipt}
href="/portal/invoices"
/>
</div>
<div className="bg-white rounded-lg border p-6">
<h2 className="text-sm font-medium text-gray-700 mb-1">Need assistance?</h2>
<p className="text-sm text-gray-500">
Contact the {dashboard.port.name} team directly. This portal provides a read-only view
of your account. All changes must be made through your port contact.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,48 @@
'use client';
import { useState } from 'react';
import { Download, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface DocumentDownloadButtonProps {
documentId: string;
}
export function DocumentDownloadButton({ documentId }: DocumentDownloadButtonProps) {
const [loading, setLoading] = useState(false);
async function handleDownload() {
setLoading(true);
try {
const res = await fetch(`/api/portal/documents/${documentId}/download`);
if (!res.ok) {
alert('Unable to download document. Please try again.');
return;
}
const data = await res.json() as { url: string };
window.open(data.url, '_blank', 'noopener,noreferrer');
} catch {
alert('Unable to download document. Please check your connection.');
} finally {
setLoading(false);
}
}
return (
<Button
variant="outline"
size="sm"
onClick={handleDownload}
disabled={loading}
>
{loading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<>
<Download className="h-3.5 w-3.5 mr-1.5" />
Download
</>
)}
</Button>
);
}

View File

@@ -0,0 +1,123 @@
import { redirect } from 'next/navigation';
import { FileText } from 'lucide-react';
import type { Metadata } from 'next';
import { getPortalSession } from '@/lib/portal/auth';
import { getClientDocuments } from '@/lib/services/portal.service';
import { Badge } from '@/components/ui/badge';
import { DocumentDownloadButton } from './document-download-button';
export const metadata: Metadata = { title: 'Documents' };
const DOC_TYPE_LABELS: Record<string, string> = {
eoi: 'Expression of Interest',
contract: 'Contract',
nda: 'NDA',
reservation_agreement: 'Reservation Agreement',
other: 'Document',
};
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
draft: 'secondary',
sent: 'default',
partially_signed: 'default',
completed: 'outline',
expired: 'destructive',
cancelled: 'destructive',
};
export default async function PortalDocumentsPage() {
const session = await getPortalSession();
if (!session) redirect('/portal/login');
const documents = await getClientDocuments(session.clientId, session.portId);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold text-gray-900">Documents</h1>
<p className="text-sm text-gray-500 mt-1">
Your contracts, EOIs, and signed agreements
</p>
</div>
{documents.length === 0 ? (
<div className="bg-white rounded-lg border p-12 text-center">
<FileText className="h-10 w-10 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500 font-medium">No documents on file</p>
<p className="text-sm text-gray-400 mt-1">
Documents shared with you will appear here.
</p>
</div>
) : (
<div className="space-y-3">
{documents.map((doc) => (
<div
key={doc.id}
className="bg-white rounded-lg border p-5"
>
<div className="flex items-start gap-4">
<FileText className="h-5 w-5 text-gray-400 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-900 truncate">{doc.title}</p>
<p className="text-sm text-gray-500 mt-0.5">
{DOC_TYPE_LABELS[doc.documentType] ?? doc.documentType}
</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Badge variant={STATUS_COLORS[doc.status] ?? 'default'}>
{doc.status.replace(/_/g, ' ')}
</Badge>
</div>
</div>
{doc.signers.length > 0 && (
<div className="mt-3 space-y-1">
<p className="text-xs text-gray-400 font-medium uppercase tracking-wide">
Signers
</p>
{doc.signers.map((signer, idx) => (
<div key={idx} className="flex items-center gap-2 text-sm">
<span
className={
signer.status === 'signed'
? 'text-green-600'
: signer.status === 'declined'
? 'text-red-500'
: 'text-gray-500'
}
>
{signer.status === 'signed' ? '✓' : signer.status === 'declined' ? '✗' : '○'}
</span>
<span className="text-gray-700">{signer.signerName}</span>
<span className="text-gray-400 capitalize">
({signer.signerRole.replace(/_/g, ' ')})
</span>
</div>
))}
</div>
)}
<div className="flex items-center justify-between mt-3">
<p className="text-xs text-gray-400">
{new Date(doc.createdAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</p>
{(doc.hasSignedFile || doc.status === 'completed') && (
<DocumentDownloadButton documentId={doc.id} />
)}
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,111 @@
import { redirect } from 'next/navigation';
import { Anchor } from 'lucide-react';
import type { Metadata } from 'next';
import { getPortalSession } from '@/lib/portal/auth';
import { getClientInterests } from '@/lib/services/portal.service';
import { Badge } from '@/components/ui/badge';
export const metadata: Metadata = { title: 'Interests' };
const STAGE_LABELS: Record<string, string> = {
open: 'Open',
details_sent: 'Details Sent',
in_communication: 'In Communication',
visited: 'Visited',
signed_eoi_nda: 'EOI / NDA Signed',
deposit_10pct: 'Deposit Received',
contract: 'Contract Stage',
completed: 'Completed',
};
const STAGE_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
open: 'secondary',
details_sent: 'secondary',
in_communication: 'default',
visited: 'default',
signed_eoi_nda: 'default',
deposit_10pct: 'default',
contract: 'default',
completed: 'outline',
};
export default async function PortalInterestsPage() {
const session = await getPortalSession();
if (!session) redirect('/portal/login');
const interests = await getClientInterests(session.clientId, session.portId);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold text-gray-900">Berth Interests</h1>
<p className="text-sm text-gray-500 mt-1">
Your berth enquiries and applications
</p>
</div>
{interests.length === 0 ? (
<div className="bg-white rounded-lg border p-12 text-center">
<Anchor className="h-10 w-10 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500 font-medium">No interests on file</p>
<p className="text-sm text-gray-400 mt-1">
Contact your port representative to discuss available berths.
</p>
</div>
) : (
<div className="space-y-3">
{interests.map((interest) => (
<div
key={interest.id}
className="bg-white rounded-lg border p-5"
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
{interest.berthMooringNumber ? (
<span className="font-medium text-gray-900">
Berth {interest.berthMooringNumber}
</span>
) : (
<span className="font-medium text-gray-900">General Interest</span>
)}
{interest.berthArea && (
<span className="text-sm text-gray-400"> {interest.berthArea}</span>
)}
</div>
{interest.leadCategory && (
<p className="text-sm text-gray-500 capitalize">
{interest.leadCategory.replace(/_/g, ' ')}
</p>
)}
<div className="flex flex-wrap gap-2 mt-2 text-xs text-gray-400">
{interest.dateFirstContact && (
<span>
First contact:{' '}
{new Date(interest.dateFirstContact).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</span>
)}
{interest.eoiStatus && (
<span>EOI: {interest.eoiStatus.replace(/_/g, ' ')}</span>
)}
{interest.contractStatus && (
<span>Contract: {interest.contractStatus.replace(/_/g, ' ')}</span>
)}
</div>
</div>
<Badge variant={STAGE_COLORS[interest.pipelineStage] ?? 'default'}>
{STAGE_LABELS[interest.pipelineStage] ?? interest.pipelineStage}
</Badge>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,103 @@
import { redirect } from 'next/navigation';
import { Receipt } from 'lucide-react';
import type { Metadata } from 'next';
import { getPortalSession } from '@/lib/portal/auth';
import { getClientInvoices } from '@/lib/services/portal.service';
import { Badge } from '@/components/ui/badge';
export const metadata: Metadata = { title: 'Invoices' };
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
draft: 'secondary',
sent: 'default',
paid: 'outline',
overdue: 'destructive',
cancelled: 'destructive',
};
function formatCurrency(amount: string, currency: string): string {
const num = parseFloat(amount);
if (isNaN(num)) return `${currency} ${amount}`;
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
minimumFractionDigits: 2,
}).format(num);
}
export default async function PortalInvoicesPage() {
const session = await getPortalSession();
if (!session) redirect('/portal/login');
const invoices = await getClientInvoices(session.clientId, session.portId);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold text-gray-900">Invoices</h1>
<p className="text-sm text-gray-500 mt-1">
Your billing statements and payment history
</p>
</div>
{invoices.length === 0 ? (
<div className="bg-white rounded-lg border p-12 text-center">
<Receipt className="h-10 w-10 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500 font-medium">No invoices on file</p>
<p className="text-sm text-gray-400 mt-1">
Invoices will appear here once issued by the port.
</p>
</div>
) : (
<div className="space-y-3">
{invoices.map((invoice) => (
<div
key={invoice.id}
className="bg-white rounded-lg border p-5"
>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3">
<p className="font-medium text-gray-900">{invoice.invoiceNumber}</p>
<Badge variant={STATUS_COLORS[invoice.status] ?? 'default'}>
{invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1)}
</Badge>
</div>
<div className="flex flex-wrap gap-4 mt-2 text-sm text-gray-500">
<span>
Due:{' '}
{new Date(invoice.dueDate).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</span>
{invoice.paymentDate && (
<span className="text-green-600">
Paid:{' '}
{new Date(invoice.paymentDate).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</span>
)}
</div>
</div>
<div className="text-right flex-shrink-0">
<p className="text-lg font-semibold text-gray-900">
{formatCurrency(invoice.total, invoice.currency)}
</p>
{invoice.paymentStatus && invoice.paymentStatus !== 'unpaid' && (
<p className="text-sm text-gray-400 capitalize">{invoice.paymentStatus}</p>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

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