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>
1991 lines
78 KiB
Markdown
1991 lines
78 KiB
Markdown
# Layer 4: Advanced Features — Competing Plan (Claude Code)
|
||
|
||
**Scope:** Outbound webhooks, scheduled reports, data import/export, custom fields, AI features (berth spec import, recommendation refinement), UX polish (dark mode, mobile responsive, scratchpad, archiving, multi-currency, tags), system monitoring & admin tools, parent company export.
|
||
|
||
**Duration:** 7 days across 4 parallel streams
|
||
|
||
**Depends on:** L0 (foundation), L1 (CRUD), L2 (business workflows), L3 (operations)
|
||
|
||
---
|
||
|
||
## 1. Baseline Critique
|
||
|
||
### What's Good
|
||
|
||
1. **Webhook delivery engine design** — HMAC-SHA256 signing, exponential backoff, dead letter queue. Solid pattern.
|
||
2. **CSV import wizard UX** — 5-step flow (upload → map → preview → configure → execute) is the right approach.
|
||
3. **Custom field dynamic validation** — Type-switch validator pattern is clean and extensible.
|
||
4. **Recommendation engine scoring factors** — 7 weighted factors for berth matching is comprehensive.
|
||
|
||
### What Needs Fixing
|
||
|
||
5. **Wrong route paths (again)** — Every UI reference uses `src/app/(crm)/admin/...` instead of `src/app/(dashboard)/[portSlug]/admin/...`. Port-scoped routes require the `[portSlug]` dynamic segment — this is the same error across all baseline layers.
|
||
|
||
6. **Wrong component paths** — Uses `src/components/domain/{entity}/` throughout. The project structure is flat: `src/components/{entity}/`. No `domain/` subdirectory.
|
||
|
||
7. **Dark mode colors don't match design tokens** — Baseline invents its own dark mode palette (`#0f1729`, `#1a2340`, `#243050`) instead of using the tokens from `15-DESIGN-TOKENS.md` §2.2 (`#131a2c`, `#192239`, `#1e2844`). Design tokens are the source of truth — don't reinvent them.
|
||
|
||
8. **S3 file migration tool doesn't belong in L4** — This is a one-time NocoDB→PostgreSQL migration operation. It belongs in L6 (Migration), not in the ongoing CRM feature set. Including it in L4 conflates migration tooling with production features.
|
||
|
||
9. **System monitoring entirely omitted** — Master Feature Spec §21 defines: alert monitoring dashboard, admin notifications with configurable thresholds, automated backups with admin UI. Database schema has the tables. API catalog has the endpoints (§28). Baseline ignores all of it.
|
||
|
||
10. **Client portal not addressed** — Master Feature Spec §22 says "Build but deprioritize. Include in V1." The baseline doesn't scope it at all — not even a stub or deferred plan.
|
||
|
||
11. **Unrealistic 4-day timeline** — 5 parallel streams each running 4 days = 20 developer-days of work for a single developer. Phase 2 has one coding agent. Even with overlap, 4 days for this scope is fiction.
|
||
|
||
12. **No @pdfme for report PDF generation** — Baseline mentions "generate PDF" for scheduled reports but never specifies the library. `14-TECHNICAL-DECISIONS.md` locks `@pdfme` as the PDF generation tool. Reports must use it.
|
||
|
||
13. **Missing audit logging in admin services** — Webhook CRUD, custom field CRUD, scheduled report CRUD, system settings changes — none of these write to `audit_logs` in the baseline.
|
||
|
||
14. **Webhook secret stored in plaintext** — Schema has `secret TEXT` on `webhooks` table. Baseline stores HMAC secrets as plaintext. These should be encrypted with AES-256-GCM (same as email credentials) since they're signing secrets per `SECURITY-GUIDELINES.md` §4.1.
|
||
|
||
15. **Missing `next_run_at` calculation** — Schema has `next_run_at` on `scheduled_reports` but baseline never shows how it's calculated from the cron expression. Need a cron parser library.
|
||
|
||
16. **exceljs not in tech decisions** — Baseline uses `exceljs` for Excel export but `14-TECHNICAL-DECISIONS.md` doesn't list it. Need to either justify adding it or use an alternative.
|
||
|
||
---
|
||
|
||
## 2. Implementation Plan
|
||
|
||
### Stream A: Webhooks & Scheduled Reports (Days 1–3)
|
||
|
||
#### Day 1: Webhook CRUD + Admin UI
|
||
|
||
**Service:** `src/lib/services/webhooks.ts`
|
||
|
||
```typescript
|
||
import { z } from 'zod';
|
||
|
||
/** All webhook-subscribable events, matching real-time event catalog */
|
||
export const WEBHOOK_EVENTS = [
|
||
'client.created',
|
||
'client.updated',
|
||
'client.archived',
|
||
'client.merged',
|
||
'interest.created',
|
||
'interest.stageChanged',
|
||
'interest.berthLinked',
|
||
'berth.statusChanged',
|
||
'berth.updated',
|
||
'document.sent',
|
||
'document.signed',
|
||
'document.completed',
|
||
'document.expired',
|
||
'expense.created',
|
||
'expense.updated',
|
||
'invoice.created',
|
||
'invoice.sent',
|
||
'invoice.paid',
|
||
'invoice.overdue',
|
||
'registration.new',
|
||
] as const;
|
||
|
||
export type WebhookEvent = (typeof WEBHOOK_EVENTS)[number];
|
||
|
||
/**
|
||
* Create a new webhook configuration.
|
||
* @param portId - Port scope
|
||
* @param userId - Creating user for audit
|
||
* @param data - Webhook configuration
|
||
* @returns Created webhook (secret excluded from response)
|
||
* @throws ValidationError if URL invalid or events empty
|
||
*/
|
||
export async function createWebhook(
|
||
portId: string,
|
||
userId: string,
|
||
data: CreateWebhookInput,
|
||
): Promise<WebhookResponse>;
|
||
|
||
/**
|
||
* List all webhooks for a port.
|
||
* @param portId - Port scope
|
||
* @returns Array of webhooks (secrets masked)
|
||
*/
|
||
export async function listWebhooks(portId: string): Promise<WebhookResponse[]>;
|
||
|
||
/**
|
||
* Get webhook detail by ID.
|
||
* @param portId - Port scope
|
||
* @param webhookId - Webhook ID
|
||
* @returns Webhook detail (secret masked)
|
||
* @throws NotFoundError if webhook doesn't exist in port
|
||
*/
|
||
export async function getWebhook(portId: string, webhookId: string): Promise<WebhookResponse>;
|
||
|
||
/**
|
||
* Update webhook configuration.
|
||
* @param portId - Port scope
|
||
* @param webhookId - Webhook ID
|
||
* @param userId - Updating user for audit
|
||
* @param data - Fields to update
|
||
* @returns Updated webhook
|
||
* @throws NotFoundError if webhook doesn't exist in port
|
||
*/
|
||
export async function updateWebhook(
|
||
portId: string,
|
||
webhookId: string,
|
||
userId: string,
|
||
data: UpdateWebhookInput,
|
||
): Promise<WebhookResponse>;
|
||
|
||
/**
|
||
* Delete webhook and all delivery history.
|
||
* @param portId - Port scope
|
||
* @param webhookId - Webhook ID
|
||
* @param userId - Deleting user for audit
|
||
* @throws NotFoundError if webhook doesn't exist in port
|
||
*/
|
||
export async function deleteWebhook(
|
||
portId: string,
|
||
webhookId: string,
|
||
userId: string,
|
||
): Promise<void>;
|
||
|
||
/**
|
||
* Regenerate the HMAC signing secret for a webhook.
|
||
* @returns New secret (shown once, then masked)
|
||
*/
|
||
export async function regenerateSecret(
|
||
portId: string,
|
||
webhookId: string,
|
||
userId: string,
|
||
): Promise<{ secret: string }>;
|
||
|
||
/**
|
||
* List delivery log entries for a webhook.
|
||
* @param portId - Port scope
|
||
* @param webhookId - Webhook ID
|
||
* @param pagination - Page, limit, date range filter
|
||
* @returns Paginated delivery records
|
||
*/
|
||
export async function listDeliveries(
|
||
portId: string,
|
||
webhookId: string,
|
||
pagination: PaginationInput & { from?: Date; to?: Date },
|
||
): Promise<PaginatedResponse<WebhookDeliveryResponse>>;
|
||
|
||
/**
|
||
* Send a test webhook payload simulating a specific event.
|
||
* @param portId - Port scope
|
||
* @param webhookId - Webhook ID
|
||
* @param eventType - Which event to simulate
|
||
* @returns Delivery result (status code, response time)
|
||
*/
|
||
export async function sendTestWebhook(
|
||
portId: string,
|
||
webhookId: string,
|
||
eventType: WebhookEvent,
|
||
): Promise<{ statusCode: number; responseTimeMs: number; deliveryId: string }>;
|
||
```
|
||
|
||
**Secret handling:**
|
||
|
||
- On creation, generate 32-byte random secret → encrypt with AES-256-GCM using `WEBHOOK_SECRET_KEY` env var → store ciphertext in `webhooks.secret`
|
||
- Display plaintext secret once on creation (modal with copy button + "This secret won't be shown again" warning)
|
||
- On API response, mask secret: `"wh_sk_...abc"` (first 5 + last 3 chars)
|
||
- `regenerateSecret()` generates new secret, encrypts, replaces old
|
||
|
||
**Zod schemas:** `src/lib/validators/webhooks.ts`
|
||
|
||
```typescript
|
||
export const createWebhookSchema = z.object({
|
||
name: z.string().min(1).max(100),
|
||
url: z.string().url().max(2048),
|
||
events: z.array(z.enum(WEBHOOK_EVENTS)).min(1).max(WEBHOOK_EVENTS.length),
|
||
is_active: z.boolean().default(true),
|
||
});
|
||
|
||
export const updateWebhookSchema = z.object({
|
||
name: z.string().min(1).max(100).optional(),
|
||
url: z.string().url().max(2048).optional(),
|
||
events: z.array(z.enum(WEBHOOK_EVENTS)).min(1).optional(),
|
||
is_active: z.boolean().optional(),
|
||
});
|
||
```
|
||
|
||
**API routes:**
|
||
|
||
| Method | Path | Handler |
|
||
| ------ | ------------------------------------- | --------------------------------------------- |
|
||
| GET | `/api/admin/webhooks` | `listWebhooks(portId)` |
|
||
| POST | `/api/admin/webhooks` | `createWebhook(portId, userId, body)` |
|
||
| GET | `/api/admin/webhooks/[id]` | `getWebhook(portId, id)` |
|
||
| PATCH | `/api/admin/webhooks/[id]` | `updateWebhook(portId, id, userId, body)` |
|
||
| DELETE | `/api/admin/webhooks/[id]` | `deleteWebhook(portId, id, userId)` |
|
||
| GET | `/api/admin/webhooks/[id]/deliveries` | `listDeliveries(portId, id, query)` |
|
||
| POST | `/api/admin/webhooks/[id]/test` | `sendTestWebhook(portId, id, body.eventType)` |
|
||
|
||
Middleware chain: `withAuth → withPermission('webhooks', 'manage') → handler`
|
||
|
||
All routes at `src/app/api/admin/webhooks/route.ts` and `src/app/api/admin/webhooks/[id]/route.ts`, `src/app/api/admin/webhooks/[id]/deliveries/route.ts`, `src/app/api/admin/webhooks/[id]/test/route.ts`.
|
||
|
||
**Admin UI:**
|
||
|
||
`src/app/(dashboard)/[portSlug]/admin/webhooks/page.tsx`:
|
||
|
||
- Table: name, URL (truncated), events count, status (active/inactive badge), last delivery status
|
||
- "Create webhook" button → sheet/dialog
|
||
- Row click → detail page
|
||
|
||
`src/app/(dashboard)/[portSlug]/admin/webhooks/[id]/page.tsx`:
|
||
|
||
- Webhook detail with edit form
|
||
- Event type checkboxes (grouped by entity: Client, Interest, Berth, Document, Financial, System)
|
||
- Delivery log table below: timestamp, event type, status badge (success/failed/pending), response code, response time
|
||
- "Send test" button with event type dropdown
|
||
- "Regenerate secret" button with confirmation dialog
|
||
- "Delete" button with confirmation
|
||
|
||
**Components:**
|
||
|
||
- `src/components/admin/webhook-form.tsx` — Create/edit form (React Hook Form + Zod)
|
||
- `src/components/admin/webhook-delivery-log.tsx` — Paginated delivery table with expandable row for response body
|
||
- `src/components/admin/webhook-event-selector.tsx` — Grouped checkbox grid for event selection
|
||
|
||
**shadcn components used:** Card, Table, Badge, Sheet, Dialog, AlertDialog, Checkbox, Input, Button, Switch (for active toggle), Select (event type for test), Tooltip, ScrollArea
|
||
|
||
#### Day 2: Webhook Delivery Engine
|
||
|
||
**Dispatch service:** `src/lib/services/webhook-dispatch.ts`
|
||
|
||
```typescript
|
||
/**
|
||
* Dispatch a webhook event to all matching active webhooks for a port.
|
||
* Called from event emitters throughout the service layer.
|
||
* @param portId - Port scope
|
||
* @param event - Event type
|
||
* @param payload - Event data (entity ID, changed fields, etc.)
|
||
*/
|
||
export async function dispatchWebhookEvent(
|
||
portId: string,
|
||
event: WebhookEvent,
|
||
payload: Record<string, unknown>,
|
||
): Promise<void> {
|
||
// 1. Query active webhooks for this port where events array contains event
|
||
// 2. For each matching webhook, enqueue a BullMQ job on 'webhooks' queue
|
||
// 3. Job data: { webhookId, event, payload, deliveryId }
|
||
// 4. No blocking — fire and forget via queue
|
||
}
|
||
```
|
||
|
||
**BullMQ job processor:** `src/jobs/webhook-delivery.ts`
|
||
|
||
```typescript
|
||
/**
|
||
* Process a webhook delivery job.
|
||
* Signs payload with HMAC-SHA256, sends POST request, records result.
|
||
*/
|
||
export async function processWebhookDelivery(job: Job<WebhookDeliveryJobData>): Promise<void> {
|
||
// 1. Fetch webhook config (decrypt secret)
|
||
// 2. Build delivery payload:
|
||
// {
|
||
// id: deliveryId (UUID),
|
||
// event: 'client.created',
|
||
// timestamp: ISO8601,
|
||
// port_id: portId,
|
||
// data: { ...entity data }
|
||
// }
|
||
// 3. Sign: HMAC-SHA256(secret, JSON.stringify(payload))
|
||
// 4. POST to webhook.url with headers:
|
||
// X-Webhook-Id: webhookId
|
||
// X-Webhook-Event: event
|
||
// X-Webhook-Signature: sha256=<hex signature>
|
||
// X-Webhook-Delivery: deliveryId
|
||
// Content-Type: application/json
|
||
// User-Agent: PortNimara-CRM/1.0
|
||
// 5. Timeout: 10 seconds
|
||
// 6. Record result in webhook_deliveries:
|
||
// - 2xx → status: 'success', response_status, delivered_at
|
||
// - non-2xx → status: 'failed', response_status, response_body (first 1KB)
|
||
// - network error → status: 'failed', response_body: error message
|
||
// 7. On final retry failure → status: 'dead_letter'
|
||
// → emit system:alert to global room
|
||
// → create notification for super admin
|
||
}
|
||
```
|
||
|
||
**Queue config:**
|
||
|
||
- Queue: `webhooks`
|
||
- Concurrency: 5
|
||
- Max attempts: 3
|
||
- Backoff: exponential (30s, 300s, 3000s) — longer than default since external services may be temporarily down
|
||
- Priority: 3 (default)
|
||
|
||
**Integration points — add `dispatchWebhookEvent()` calls to:**
|
||
|
||
| Service | Events |
|
||
| -------------------------------- | ---------------------------------------------------------------------------- |
|
||
| `src/lib/services/clients.ts` | `client.created`, `client.updated`, `client.archived`, `client.merged` |
|
||
| `src/lib/services/interests.ts` | `interest.created`, `interest.stageChanged`, `interest.berthLinked` |
|
||
| `src/lib/services/berths.ts` | `berth.statusChanged`, `berth.updated` |
|
||
| `src/lib/services/documents.ts` | `document.sent`, `document.signed`, `document.completed`, `document.expired` |
|
||
| `src/lib/services/expenses.ts` | `expense.created`, `expense.updated` |
|
||
| `src/lib/services/invoices.ts` | `invoice.created`, `invoice.sent`, `invoice.paid`, `invoice.overdue` |
|
||
| `src/lib/services/public-api.ts` | `registration.new` |
|
||
|
||
Each dispatch call is non-blocking (enqueues to BullMQ). The service layer does:
|
||
|
||
```typescript
|
||
// At end of createClient():
|
||
await dispatchWebhookEvent(portId, 'client.created', { clientId, clientName, source });
|
||
```
|
||
|
||
**Edge cases:**
|
||
|
||
- Webhook URL unreachable: recorded as failed, retried per backoff schedule
|
||
- Webhook deleted while jobs pending: job processor checks webhook still exists, skips if deleted
|
||
- Circular webhooks (webhook triggers event that triggers same webhook): impossible because webhook dispatch is outbound-only, doesn't process inbound
|
||
- Payload size: cap at 64KB per delivery payload to prevent memory issues
|
||
|
||
#### Day 3: Scheduled Reports Configuration + Generation
|
||
|
||
**Service:** `src/lib/services/scheduled-reports.ts`
|
||
|
||
```typescript
|
||
export const REPORT_TYPES = [
|
||
'pipeline_summary',
|
||
'expense_summary',
|
||
'berth_occupancy',
|
||
'activity_log',
|
||
'overdue_items',
|
||
'revenue_forecast',
|
||
] as const;
|
||
|
||
export type ReportType = (typeof REPORT_TYPES)[number];
|
||
|
||
/**
|
||
* Create a scheduled report configuration.
|
||
* @param portId - Port scope
|
||
* @param userId - Creating user for audit
|
||
* @param data - Report config (type, schedule, recipients)
|
||
* @returns Created report with next_run_at calculated
|
||
* @throws ValidationError if cron invalid or recipients empty
|
||
*/
|
||
export async function createReport(
|
||
portId: string,
|
||
userId: string,
|
||
data: CreateReportInput,
|
||
): Promise<ScheduledReportResponse>;
|
||
|
||
/**
|
||
* List all scheduled reports for a port.
|
||
*/
|
||
export async function listReports(portId: string): Promise<ScheduledReportResponse[]>;
|
||
|
||
/**
|
||
* Update report configuration.
|
||
* Recalculates next_run_at if schedule changed.
|
||
*/
|
||
export async function updateReport(
|
||
portId: string,
|
||
reportId: string,
|
||
userId: string,
|
||
data: UpdateReportInput,
|
||
): Promise<ScheduledReportResponse>;
|
||
|
||
/**
|
||
* Delete report and all recipients.
|
||
*/
|
||
export async function deleteReport(portId: string, reportId: string, userId: string): Promise<void>;
|
||
|
||
/**
|
||
* Manually trigger report generation.
|
||
* Enqueues a one-off report generation job.
|
||
* @returns Job ID for progress tracking
|
||
*/
|
||
export async function triggerReport(
|
||
portId: string,
|
||
reportId: string,
|
||
userId: string,
|
||
): Promise<{ jobId: string }>;
|
||
|
||
/**
|
||
* Calculate next run time from cron expression.
|
||
* Uses `cron-parser` library.
|
||
*/
|
||
export function calculateNextRun(cronExpression: string): Date;
|
||
```
|
||
|
||
**Cron handling:**
|
||
|
||
- Use `cron-parser` npm package (lightweight, no eval, MIT license) to parse cron expressions
|
||
- Pre-built schedule options in UI: daily at 8am, weekly Monday 8am, monthly 1st at 8am, custom cron
|
||
- `next_run_at` recalculated every time the report runs or schedule changes
|
||
- `report-scheduler` recurring job (every minute) queries: `WHERE is_active = true AND next_run_at <= now()`
|
||
|
||
**Zod schemas:** `src/lib/validators/scheduled-reports.ts`
|
||
|
||
```typescript
|
||
export const createReportSchema = z.object({
|
||
name: z.string().min(1).max(100),
|
||
report_type: z.enum(REPORT_TYPES),
|
||
schedule: z.string().refine(isValidCron, 'Invalid cron expression'),
|
||
recipients: z
|
||
.array(
|
||
z.object({
|
||
email: z.string().email(),
|
||
user_id: z.string().optional(),
|
||
}),
|
||
)
|
||
.min(1)
|
||
.max(20),
|
||
config: z.record(z.unknown()).default({}),
|
||
});
|
||
|
||
function isValidCron(expr: string): boolean {
|
||
try {
|
||
parseCron(expr);
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
```
|
||
|
||
**Report generators:** `src/lib/services/report-generators/`
|
||
|
||
One file per report type:
|
||
|
||
- `pipeline-summary.ts` — Interests by stage, conversion rates, new vs closed this period, top berths by interest count
|
||
- `expense-summary.ts` — Expenses by category, monthly totals, budget vs actual, top 10 expenses
|
||
- `berth-occupancy.ts` — Occupied vs available vs maintenance, occupancy %, berth utilization by type
|
||
- `activity-log.ts` — Actions by user, entity type breakdown, busiest days/hours
|
||
- `overdue-items.ts` — Overdue invoices, overdue reminders, unsigned documents past deadline
|
||
- `revenue-forecast.ts` — Pipeline value by stage × probability, expected revenue by month, historical comparison
|
||
|
||
Each generator follows the pattern:
|
||
|
||
```typescript
|
||
export interface ReportGenerator {
|
||
/**
|
||
* Gather data and generate report content.
|
||
* @param portId - Port scope
|
||
* @param dateRange - Report period
|
||
* @param config - Report-specific config (from scheduled_reports.config JSONB)
|
||
* @returns Report data suitable for PDF template
|
||
*/
|
||
generate(
|
||
portId: string,
|
||
dateRange: DateRange,
|
||
config: Record<string, unknown>,
|
||
): Promise<ReportData>;
|
||
}
|
||
```
|
||
|
||
**PDF generation:** Use `@pdfme` (locked dependency) with report-specific templates:
|
||
|
||
- Each report type has a `@pdfme` template defining layout (cover page with port branding, tables, summary boxes)
|
||
- Template includes: port logo, report title, date range, generation timestamp
|
||
- `src/lib/services/report-pdf.ts` — takes `ReportData` + template → PDF buffer
|
||
|
||
**BullMQ job processor:** `src/jobs/report-generation.ts`
|
||
|
||
- Queue: `reports`, concurrency: 1
|
||
- Steps: query data → generate PDF → upload to MinIO (`{portSlug}/reports/{reportId}/{timestamp}.pdf`) → email PDF to recipients → update `last_run_at` + calculate `next_run_at`
|
||
- Emit Socket.io event to port room when complete: `{ reportId, reportName, downloadUrl }`
|
||
- On failure: create system alert notification
|
||
|
||
**API routes:**
|
||
|
||
| Method | Path | Handler |
|
||
| ------ | ----------------------------- | ---------------------------------------- |
|
||
| GET | `/api/admin/reports` | `listReports(portId)` |
|
||
| POST | `/api/admin/reports` | `createReport(portId, userId, body)` |
|
||
| GET | `/api/admin/reports/[id]` | `getReport(portId, id)` |
|
||
| PATCH | `/api/admin/reports/[id]` | `updateReport(portId, id, userId, body)` |
|
||
| DELETE | `/api/admin/reports/[id]` | `deleteReport(portId, id, userId)` |
|
||
| POST | `/api/admin/reports/[id]/run` | `triggerReport(portId, id, userId)` |
|
||
|
||
Route files at `src/app/api/admin/reports/route.ts` and `src/app/api/admin/reports/[id]/route.ts`, `src/app/api/admin/reports/[id]/run/route.ts`.
|
||
|
||
Middleware: `withAuth → withPermission('reports', 'manage') → handler`
|
||
|
||
**Admin UI:**
|
||
|
||
`src/app/(dashboard)/[portSlug]/admin/reports/page.tsx`:
|
||
|
||
- Table: name, type, schedule (human-readable), next run, last run, status (active/inactive), recipients count
|
||
- Create button → sheet with form
|
||
- Row actions: edit, run now, delete
|
||
|
||
`src/components/admin/report-form.tsx`:
|
||
|
||
- Report name, type dropdown, schedule selector (preset + custom cron input), recipient email list (autocomplete CRM users + manual email entry)
|
||
- Config section that changes based on report type (e.g., expense summary can filter by category)
|
||
|
||
**shadcn components:** Card, Table, Badge, Sheet, Select, Input, Button, Calendar (date range for manual run)
|
||
|
||
---
|
||
|
||
### Stream B: Data Import/Export (Days 2–4)
|
||
|
||
#### Day 2 (of stream): CSV/Excel Upload + Column Mapping
|
||
|
||
**Service:** `src/lib/services/data-import.ts`
|
||
|
||
```typescript
|
||
export const IMPORTABLE_ENTITIES = ['clients', 'interests', 'berths', 'expenses'] as const;
|
||
export type ImportableEntity = (typeof IMPORTABLE_ENTITIES)[number];
|
||
|
||
/**
|
||
* Parse uploaded file and detect columns.
|
||
* @param portId - Port scope
|
||
* @param userId - Uploading user
|
||
* @param file - Uploaded file buffer
|
||
* @param entityType - Target entity type
|
||
* @returns Import session with detected columns and auto-mappings
|
||
* @throws ValidationError if file format invalid or too large
|
||
*/
|
||
export async function uploadForImport(
|
||
portId: string,
|
||
userId: string,
|
||
file: { buffer: Buffer; mimetype: string; originalname: string },
|
||
entityType: ImportableEntity,
|
||
): Promise<ImportSession>;
|
||
|
||
/**
|
||
* Preview import with user-confirmed column mappings.
|
||
* Validates all rows and detects duplicates.
|
||
* @param portId - Port scope
|
||
* @param sessionId - Import session ID
|
||
* @param mappings - Column-to-field mappings
|
||
* @returns Preview with valid/error/duplicate counts and sample data
|
||
*/
|
||
export async function previewImport(
|
||
portId: string,
|
||
sessionId: string,
|
||
mappings: ColumnMapping[],
|
||
): Promise<ImportPreview>;
|
||
|
||
/**
|
||
* Execute import as BullMQ job.
|
||
* @param portId - Port scope
|
||
* @param userId - Executing user
|
||
* @param sessionId - Import session ID
|
||
* @param options - Duplicate handling strategy
|
||
* @returns Job ID for progress tracking
|
||
*/
|
||
export async function executeImport(
|
||
portId: string,
|
||
userId: string,
|
||
sessionId: string,
|
||
options: { skipDuplicates: boolean; updateDuplicates: boolean },
|
||
): Promise<{ jobId: string }>;
|
||
|
||
/**
|
||
* List past import operations for a port.
|
||
*/
|
||
export async function listImportHistory(
|
||
portId: string,
|
||
pagination: PaginationInput,
|
||
): Promise<PaginatedResponse<ImportHistoryEntry>>;
|
||
```
|
||
|
||
**File parsing:**
|
||
|
||
- CSV: use `papaparse` (already available, lightweight)
|
||
- Excel: use `xlsx` (SheetJS) for read-only parsing — lighter than `exceljs` for import. `xlsx` is a read/write library that doesn't require native deps.
|
||
- Auto-detect format from MIME type: `text/csv` → papaparse, `application/vnd.openxmlformats*` → xlsx
|
||
- Extract headers from first row
|
||
- Auto-map headers to entity fields using fuzzy matching:
|
||
```typescript
|
||
// "First Name" → first_name, "E-mail" → email, "Phone Number" → phone
|
||
function autoMapColumn(header: string, entityFields: string[]): string | null {
|
||
const normalized = header.toLowerCase().replace(/[^a-z0-9]/g, '_');
|
||
return entityFields.find((f) => f === normalized || levenshtein(f, normalized) <= 2) ?? null;
|
||
}
|
||
```
|
||
|
||
**Import session storage:** Store in Redis with 1-hour TTL (parsed data, mappings, preview results). Key: `import:{sessionId}`. Avoids re-parsing on each step.
|
||
|
||
**Zod schemas:** `src/lib/validators/data-import.ts`
|
||
|
||
```typescript
|
||
export const uploadImportSchema = z.object({
|
||
entityType: z.enum(IMPORTABLE_ENTITIES),
|
||
});
|
||
|
||
export const previewImportSchema = z.object({
|
||
sessionId: z.string().uuid(),
|
||
mappings: z.array(
|
||
z.object({
|
||
sourceColumn: z.string(),
|
||
targetField: z.string(),
|
||
transform: z.enum(['none', 'trim', 'lowercase', 'uppercase', 'date_parse']).default('none'),
|
||
}),
|
||
),
|
||
});
|
||
|
||
export const executeImportSchema = z.object({
|
||
sessionId: z.string().uuid(),
|
||
skipDuplicates: z.boolean().default(true),
|
||
updateDuplicates: z.boolean().default(false),
|
||
});
|
||
```
|
||
|
||
**API routes:**
|
||
|
||
| Method | Path | Handler |
|
||
| ------ | --------------------- | -------------------------------------------------------- |
|
||
| POST | `/api/import/upload` | `uploadForImport(portId, userId, file, body.entityType)` |
|
||
| POST | `/api/import/preview` | `previewImport(portId, body.sessionId, body.mappings)` |
|
||
| POST | `/api/import/execute` | `executeImport(portId, userId, body.sessionId, body)` |
|
||
| GET | `/api/import/history` | `listImportHistory(portId, query)` |
|
||
|
||
Route files at `src/app/api/import/upload/route.ts`, `src/app/api/import/preview/route.ts`, `src/app/api/import/execute/route.ts`, `src/app/api/import/history/route.ts`.
|
||
|
||
Middleware: `withAuth → withPermission('import', 'manage') → handler`
|
||
|
||
**BullMQ job:** `src/jobs/import-process.ts`
|
||
|
||
- Queue: `import`, concurrency: 1
|
||
- Priority: 5 (lowest)
|
||
- Max attempts: 1 (user retries manually)
|
||
- Progress updates via Socket.io: `{ sessionId, processed, total, errors }`
|
||
- Each row inserted in transaction → if row fails, skip and record error → continue
|
||
- On completion: store import summary in `import_history` table (or a dedicated metadata table in Redis, exported to audit_logs)
|
||
- Audit log: one entry per import with metadata `{ source: 'csv_import', filename, rowCount, successCount, errorCount }`
|
||
|
||
**Duplicate detection during import:**
|
||
|
||
- Clients: exact email match → flag as duplicate
|
||
- Berths: exact mooring_number match → flag as duplicate
|
||
- Interests: client_id + berth_id match → flag as duplicate
|
||
- Expenses: no dedup (each row is unique)
|
||
|
||
#### Day 3 (of stream): Entity Export + Import History UI
|
||
|
||
**Export service:** `src/lib/services/data-export.ts`
|
||
|
||
```typescript
|
||
export const EXPORTABLE_ENTITIES = [
|
||
'clients',
|
||
'interests',
|
||
'berths',
|
||
'expenses',
|
||
'invoices',
|
||
] as const;
|
||
|
||
/**
|
||
* Export entity list with current filters and selected columns.
|
||
* @param portId - Port scope
|
||
* @param userId - Exporting user (for audit)
|
||
* @param entityType - Which entity to export
|
||
* @param options - Filters, columns, format, optional entity IDs for selection
|
||
* @returns Job ID for async export
|
||
*/
|
||
export async function exportEntities(
|
||
portId: string,
|
||
userId: string,
|
||
entityType: ExportableEntity,
|
||
options: ExportOptions,
|
||
): Promise<{ jobId: string }>;
|
||
```
|
||
|
||
**BullMQ job:** `src/jobs/export-process.ts`
|
||
|
||
- Queue: `export`, concurrency: 2
|
||
- Priority: 4
|
||
- Steps:
|
||
1. Query entities with filters + port scope
|
||
2. Include custom field values as additional columns (if custom fields exist for entity type)
|
||
3. Generate file:
|
||
- CSV: `papaparse` unparse
|
||
- Excel: `xlsx` (SheetJS) write — creates workbook with headers, auto-width columns, styled header row
|
||
4. Upload to MinIO: `{portSlug}/exports/{entityType}-{timestamp}.{ext}`
|
||
5. Generate presigned URL (15-min expiry)
|
||
6. Emit Socket.io event to `user:{userId}`: `{ jobId, downloadUrl, filename }`
|
||
7. Create notification with download link
|
||
8. Audit log: `{ action: 'export', entityType, rowCount, format, userId }`
|
||
|
||
**API route:**
|
||
|
||
| Method | Path | Handler |
|
||
| ------ | -------------------------- | -------------------------------------------------- |
|
||
| POST | `/api/export/[entityType]` | `exportEntities(portId, userId, entityType, body)` |
|
||
|
||
Route at `src/app/api/export/[entityType]/route.ts`.
|
||
|
||
Middleware: `withAuth → withPermission(entityType, 'export') → handler`
|
||
|
||
**Zod schema:** `src/lib/validators/data-export.ts`
|
||
|
||
```typescript
|
||
export const exportSchema = z.object({
|
||
filters: z.record(z.unknown()).default({}),
|
||
columns: z.array(z.string()).min(1),
|
||
format: z.enum(['csv', 'xlsx']),
|
||
entityIds: z.array(z.string().uuid()).optional(), // if set, export only these
|
||
});
|
||
```
|
||
|
||
#### Day 4 (of stream): Import Wizard UI + Export Integration
|
||
|
||
**Import wizard page:** `src/app/(dashboard)/[portSlug]/admin/import/page.tsx`
|
||
|
||
5-step wizard using shadcn Tabs or a custom stepper:
|
||
|
||
1. **Select & Upload** — Entity type dropdown + file drag-and-drop zone (shadcn's dropzone pattern)
|
||
2. **Map Columns** — Two-column layout: source columns (left) ↔ target fields (right, dropdown). Auto-mapped columns pre-selected with green check. Unmapped columns highlighted yellow.
|
||
3. **Preview** — Summary cards: ✓ Valid rows, ⚠ Errors, 🔄 Duplicates. Expandable error table with row number, field, error message. Sample of first 5 transformed rows.
|
||
4. **Configure** — Duplicate handling: skip / update existing. Confirm button with total count.
|
||
5. **Progress & Results** — Progress bar (updated via Socket.io). On completion: success count, error count, download error report CSV.
|
||
|
||
**Components:**
|
||
|
||
- `src/components/admin/import-wizard.tsx` — Multi-step wizard container
|
||
- `src/components/admin/column-mapper.tsx` — Drag-to-map or dropdown column mapping
|
||
- `src/components/admin/import-preview.tsx` — Preview table with validation results
|
||
- `src/components/admin/import-history-table.tsx` — Past imports with stats
|
||
|
||
**Export integration on list pages:**
|
||
|
||
- Add export dropdown button to all entity list page toolbars (clients, interests, berths, expenses, invoices)
|
||
- Dropdown: "Export as CSV" / "Export as Excel"
|
||
- Uses current active filters and visible columns as defaults
|
||
- Option to export selected rows only (if bulk selection active) or all matching
|
||
- Shows toast notification "Export started" → updates to "Export ready — Download" with link
|
||
|
||
**TanStack Query keys:**
|
||
|
||
```typescript
|
||
// Import
|
||
['import', 'session', sessionId][('import', 'preview', sessionId)][('import', 'history', portId)][ // Import session data // Preview results // Import history list
|
||
// Export
|
||
('export', 'job', jobId)
|
||
]; // Export job status
|
||
```
|
||
|
||
**State management:**
|
||
|
||
- Import wizard state: local React state (useState) for step tracking, mappings, preview data
|
||
- No Zustand needed — wizard is self-contained, state doesn't persist across navigation
|
||
|
||
**shadcn components:** Card, Table, Badge, Button, Select, Progress, Alert, Tabs (for stepper), DropdownMenu (export format), Tooltip
|
||
|
||
---
|
||
|
||
### Stream C: Custom Fields & Tags (Days 3–5)
|
||
|
||
#### Day 3 (of stream): Custom Field Definitions
|
||
|
||
**Service:** `src/lib/services/custom-fields.ts`
|
||
|
||
```typescript
|
||
export const CUSTOM_FIELD_TYPES = ['text', 'number', 'date', 'boolean', 'select'] as const;
|
||
export const CUSTOM_FIELD_ENTITIES = ['client', 'interest', 'berth'] as const;
|
||
|
||
/**
|
||
* List custom field definitions for a port, optionally filtered by entity type.
|
||
* @param portId - Port scope
|
||
* @param entityType - Optional entity type filter
|
||
* @returns Definitions sorted by sort_order
|
||
*/
|
||
export async function listDefinitions(
|
||
portId: string,
|
||
entityType?: CustomFieldEntity,
|
||
): Promise<CustomFieldDefinition[]>;
|
||
|
||
/**
|
||
* Create a custom field definition.
|
||
* @throws ConflictError if field_name already exists for port+entity_type
|
||
*/
|
||
export async function createDefinition(
|
||
portId: string,
|
||
userId: string,
|
||
data: CreateFieldInput,
|
||
): Promise<CustomFieldDefinition>;
|
||
|
||
/**
|
||
* Update a custom field definition.
|
||
* Cannot change field_type after creation (would break existing values).
|
||
* @throws ValidationError if attempting to change field_type
|
||
*/
|
||
export async function updateDefinition(
|
||
portId: string,
|
||
fieldId: string,
|
||
userId: string,
|
||
data: UpdateFieldInput,
|
||
): Promise<CustomFieldDefinition>;
|
||
|
||
/**
|
||
* Delete a custom field definition and all associated values.
|
||
* @returns Count of deleted values for confirmation messaging
|
||
*/
|
||
export async function deleteDefinition(
|
||
portId: string,
|
||
fieldId: string,
|
||
userId: string,
|
||
): Promise<{ deletedValueCount: number }>;
|
||
|
||
/**
|
||
* Get all custom field values for a specific entity instance.
|
||
* @param entityId - The entity (client/interest/berth) UUID
|
||
* @param portId - Port scope (for definition lookup)
|
||
* @returns Array of { definition, value } pairs
|
||
*/
|
||
export async function getValues(entityId: string, portId: string): Promise<CustomFieldWithValue[]>;
|
||
|
||
/**
|
||
* Set (upsert) custom field values for an entity.
|
||
* Validates each value against its field type.
|
||
* @throws ValidationError if value doesn't match field type
|
||
*/
|
||
export async function setValues(
|
||
entityId: string,
|
||
portId: string,
|
||
userId: string,
|
||
values: { fieldId: string; value: unknown }[],
|
||
): Promise<void>;
|
||
```
|
||
|
||
**Value validation:**
|
||
|
||
```typescript
|
||
function validateCustomFieldValue(
|
||
definition: CustomFieldDefinition,
|
||
value: unknown,
|
||
): string | null {
|
||
if (value === null || value === undefined) {
|
||
return definition.is_required ? 'This field is required' : null;
|
||
}
|
||
switch (definition.field_type) {
|
||
case 'text':
|
||
if (typeof value !== 'string') return 'Must be text';
|
||
if (value.length > 1000) return 'Maximum 1000 characters';
|
||
return null;
|
||
case 'number':
|
||
if (typeof value !== 'number' || isNaN(value)) return 'Must be a number';
|
||
return null;
|
||
case 'date':
|
||
if (typeof value !== 'string' || isNaN(Date.parse(value))) return 'Must be a valid date';
|
||
return null;
|
||
case 'boolean':
|
||
if (typeof value !== 'boolean') return 'Must be true or false';
|
||
return null;
|
||
case 'select':
|
||
if (!definition.select_options?.includes(value as string)) {
|
||
return `Must be one of: ${definition.select_options?.join(', ')}`;
|
||
}
|
||
return null;
|
||
default:
|
||
return 'Unknown field type';
|
||
}
|
||
}
|
||
```
|
||
|
||
**Drizzle queries:**
|
||
|
||
```typescript
|
||
// Get values with definitions in one query (join)
|
||
const valuesWithDefs = await db
|
||
.select()
|
||
.from(customFieldValues)
|
||
.innerJoin(customFieldDefinitions, eq(customFieldValues.fieldId, customFieldDefinitions.id))
|
||
.where(and(eq(customFieldValues.entityId, entityId), eq(customFieldDefinitions.portId, portId)))
|
||
.orderBy(customFieldDefinitions.sortOrder);
|
||
|
||
// Upsert value
|
||
await db
|
||
.insert(customFieldValues)
|
||
.values({ fieldId, entityId, value: JSON.stringify(value) })
|
||
.onConflictDoUpdate({
|
||
target: [customFieldValues.fieldId, customFieldValues.entityId],
|
||
set: { value: JSON.stringify(value), updatedAt: new Date() },
|
||
});
|
||
```
|
||
|
||
**Zod schemas:** `src/lib/validators/custom-fields.ts`
|
||
|
||
```typescript
|
||
export const createFieldSchema = z
|
||
.object({
|
||
entity_type: z.enum(CUSTOM_FIELD_ENTITIES),
|
||
field_name: z
|
||
.string()
|
||
.min(1)
|
||
.max(50)
|
||
.regex(/^[a-z_][a-z0-9_]*$/, 'Must be snake_case'),
|
||
field_label: z.string().min(1).max(100),
|
||
field_type: z.enum(CUSTOM_FIELD_TYPES),
|
||
select_options: z.array(z.string().min(1).max(100)).min(1).max(50).optional(),
|
||
is_required: z.boolean().default(false),
|
||
sort_order: z.number().int().min(0).default(0),
|
||
})
|
||
.refine(
|
||
(data) =>
|
||
data.field_type !== 'select' || (data.select_options && data.select_options.length > 0),
|
||
{ message: 'Select fields must have at least one option', path: ['select_options'] },
|
||
);
|
||
|
||
export const updateFieldSchema = z.object({
|
||
field_label: z.string().min(1).max(100).optional(),
|
||
select_options: z.array(z.string().min(1).max(100)).optional(),
|
||
is_required: z.boolean().optional(),
|
||
sort_order: z.number().int().min(0).optional(),
|
||
// field_type intentionally omitted — cannot be changed
|
||
});
|
||
|
||
export const setValuesSchema = z.object({
|
||
values: z.array(
|
||
z.object({
|
||
fieldId: z.string().uuid(),
|
||
value: z.unknown(), // validated dynamically against field type
|
||
}),
|
||
),
|
||
});
|
||
```
|
||
|
||
**API routes:**
|
||
|
||
| Method | Path | Handler |
|
||
| ------ | ------------------------------- | -------------------------------------------- |
|
||
| GET | `/api/admin/custom-fields` | `listDefinitions(portId, query.entityType)` |
|
||
| POST | `/api/admin/custom-fields` | `createDefinition(portId, userId, body)` |
|
||
| PATCH | `/api/admin/custom-fields/[id]` | `updateDefinition(portId, id, userId, body)` |
|
||
| DELETE | `/api/admin/custom-fields/[id]` | `deleteDefinition(portId, id, userId)` |
|
||
|
||
Custom field values are read/written through the entity's own API:
|
||
|
||
- `GET /api/v1/clients/[id]` → includes `customFields` array in response
|
||
- `PATCH /api/v1/clients/[id]` → accepts `customFields` in body → calls `setValues()`
|
||
|
||
Route files at `src/app/api/admin/custom-fields/route.ts` and `src/app/api/admin/custom-fields/[id]/route.ts`.
|
||
|
||
Middleware: `withAuth → withPermission('custom_fields', 'manage') → handler`
|
||
|
||
**Admin UI:** `src/app/(dashboard)/[portSlug]/admin/custom-fields/page.tsx`
|
||
|
||
- Grouped by entity type (Tabs: Clients | Interests | Berths)
|
||
- Table within each tab: field name, label, type, required badge, sort order, actions
|
||
- Create button → dialog with form
|
||
- Edit inline or via dialog
|
||
- Delete with confirmation showing "This will delete X values across Y records"
|
||
- Drag-to-reorder (sort_order)
|
||
|
||
**Components:**
|
||
|
||
- `src/components/admin/custom-field-form.tsx` — Create/edit field definition form
|
||
- `src/components/admin/custom-field-list.tsx` — Sortable list with drag handles
|
||
|
||
#### Day 4 (of stream): Custom Field Rendering + Tags Polish
|
||
|
||
**Dynamic custom field renderer:** `src/components/shared/custom-fields-section.tsx`
|
||
|
||
```typescript
|
||
interface CustomFieldsSectionProps {
|
||
entityType: 'client' | 'interest' | 'berth';
|
||
entityId: string;
|
||
portId: string;
|
||
readOnly?: boolean;
|
||
}
|
||
```
|
||
|
||
- Fetches definitions for entity type + current values for entity ID
|
||
- Renders form controls based on `field_type`:
|
||
- `text` → shadcn Input
|
||
- `number` → shadcn Input with `type="number"`
|
||
- `date` → shadcn DatePicker
|
||
- `boolean` → shadcn Switch
|
||
- `select` → shadcn Select with options from `select_options`
|
||
- Required fields show asterisk and validation
|
||
- Auto-saves on blur (debounced 500ms) — same pattern as inline editing in L1
|
||
- Collapsible section titled "Custom Fields" on entity detail pages
|
||
- Empty state: "No custom fields configured. Admins can add fields in Settings."
|
||
|
||
**Integration with entity detail pages:**
|
||
|
||
- `src/components/clients/client-detail.tsx` → add `<CustomFieldsSection entityType="client" entityId={id} />`
|
||
- `src/components/interests/interest-detail.tsx` → add `<CustomFieldsSection entityType="interest" entityId={id} />`
|
||
- `src/components/berths/berth-detail.tsx` → add `<CustomFieldsSection entityType="berth" entityId={id} />`
|
||
|
||
**Custom fields in exports:**
|
||
|
||
- Update `data-export.ts` job processor to:
|
||
1. Fetch custom field definitions for entity type
|
||
2. For each exported row, join custom field values
|
||
3. Append custom field columns after standard columns
|
||
4. Column headers use `field_label`
|
||
|
||
**Custom fields in saved views:**
|
||
|
||
- Update saved view `column_config` to support `custom:{fieldId}` column identifiers
|
||
- Column selector on list pages shows custom fields in a separate "Custom Fields" group
|
||
- Sort/filter by custom fields: basic support (text/number sort, exact match filter)
|
||
|
||
**Tags polish:** `src/lib/services/tags.ts` (updates to L1 implementation)
|
||
|
||
```typescript
|
||
/**
|
||
* Update tag color.
|
||
* @param portId - Port scope
|
||
* @param tagId - Tag ID
|
||
* @param color - New hex color
|
||
*/
|
||
export async function updateTagColor(
|
||
portId: string,
|
||
tagId: string,
|
||
userId: string,
|
||
color: string,
|
||
): Promise<Tag>;
|
||
|
||
/**
|
||
* Rename a tag. Name must be unique within port.
|
||
* @throws ConflictError if new name already exists
|
||
*/
|
||
export async function renameTag(
|
||
portId: string,
|
||
tagId: string,
|
||
userId: string,
|
||
name: string,
|
||
): Promise<Tag>;
|
||
|
||
/**
|
||
* Delete tag and remove all entity associations.
|
||
* @returns Count of affected entities
|
||
*/
|
||
export async function deleteTag(
|
||
portId: string,
|
||
tagId: string,
|
||
userId: string,
|
||
): Promise<{ affectedCount: number }>;
|
||
|
||
/**
|
||
* Merge two tags into one. All entities tagged with sourceTag get re-tagged with targetTag.
|
||
* Source tag is deleted after merge.
|
||
* @returns Count of re-tagged entities
|
||
*/
|
||
export async function mergeTags(
|
||
portId: string,
|
||
sourceTagId: string,
|
||
targetTagId: string,
|
||
userId: string,
|
||
): Promise<{ mergedCount: number }>;
|
||
```
|
||
|
||
**Tag UI enhancements:**
|
||
|
||
- `src/components/shared/tag-badge.tsx` — Already exists from L1. Add: color dot/background based on tag color
|
||
- `src/components/shared/tag-color-picker.tsx` — 12 preset colors + custom hex input (shadcn Popover with color swatches)
|
||
- `src/components/admin/tag-manager.tsx` — Admin page for tag management: rename, change color, delete, merge
|
||
- Delete confirmation: "This tag is used on X clients, Y interests, Z berths. Deleting will remove it from all."
|
||
- Merge dialog: select source and target tags, show counts, confirm
|
||
|
||
**Tag admin page:** `src/app/(dashboard)/[portSlug]/admin/tags/page.tsx`
|
||
|
||
**TanStack Query keys:**
|
||
|
||
```typescript
|
||
['custom-fields', 'definitions', portId, entityType][('custom-fields', 'values', entityId)][
|
||
('tags', portId)
|
||
]; // All tags for port
|
||
```
|
||
|
||
---
|
||
|
||
### Stream D: UX Polish, System Monitoring & Admin (Days 4–7)
|
||
|
||
#### Day 4 (of stream): Dark Mode
|
||
|
||
**Implementation using `15-DESIGN-TOKENS.md` §2.2 (exact token values):**
|
||
|
||
`src/app/globals.css` — extend existing CSS custom properties:
|
||
|
||
```css
|
||
/* Dark mode token overrides — values from 15-DESIGN-TOKENS.md §2.2 */
|
||
[data-theme='dark'] {
|
||
/* Backgrounds */
|
||
--background: #131a2c;
|
||
--background-secondary: #192239;
|
||
--background-tertiary: #1e2844;
|
||
--background-brand: #3a7bc8;
|
||
--background-brand-dark: #101625;
|
||
|
||
/* Text */
|
||
--text-primary: #e8ece9;
|
||
--text-secondary: #9ea1af;
|
||
--text-tertiary: #71768a;
|
||
--text-on-brand: #ffffff;
|
||
--text-link: #6196d3;
|
||
|
||
/* Borders */
|
||
--border: #2d3c66;
|
||
--border-strong: #474e66;
|
||
--border-focus: #6196d3;
|
||
|
||
/* Interactive */
|
||
--primary: #4a8ad4;
|
||
--primary-hover: #6196d3;
|
||
--primary-active: #3a7bc8;
|
||
|
||
/* Status — brightened for dark bg readability */
|
||
--success: #4caf50;
|
||
--success-bg: #1b3d1e;
|
||
--warning: #ffca28;
|
||
--warning-bg: #3d3417;
|
||
--error: #ef5350;
|
||
--error-bg: #3d1a1a;
|
||
--info: #6196d3;
|
||
--info-bg: #1a2d3d;
|
||
|
||
/* Sidebar — already dark in light mode, minimal change */
|
||
--sidebar-bg: #101625;
|
||
--sidebar-hover: #131a2c;
|
||
}
|
||
```
|
||
|
||
**Theme store:** `src/lib/stores/theme-store.ts`
|
||
|
||
```typescript
|
||
import { create } from 'zustand';
|
||
import { persist } from 'zustand/middleware';
|
||
|
||
interface ThemeStore {
|
||
theme: 'light' | 'dark' | 'system';
|
||
setTheme: (theme: 'light' | 'dark' | 'system') => void;
|
||
resolvedTheme: () => 'light' | 'dark';
|
||
}
|
||
|
||
export const useThemeStore = create<ThemeStore>()(
|
||
persist(
|
||
(set, get) => ({
|
||
theme: 'system',
|
||
setTheme: (theme) => {
|
||
set({ theme });
|
||
applyTheme(theme);
|
||
},
|
||
resolvedTheme: () => {
|
||
const { theme } = get();
|
||
if (theme === 'system') {
|
||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||
}
|
||
return theme;
|
||
},
|
||
}),
|
||
{ name: 'theme-preference' },
|
||
),
|
||
);
|
||
|
||
function applyTheme(theme: 'light' | 'dark' | 'system') {
|
||
const resolved =
|
||
theme === 'system'
|
||
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
||
? 'dark'
|
||
: 'light'
|
||
: theme;
|
||
document.documentElement.setAttribute('data-theme', resolved);
|
||
}
|
||
```
|
||
|
||
- Also persist to `user_profiles.preferences` JSONB on the server (PATCH on change, so it follows the user across devices)
|
||
- On initial page load: read from localStorage first (instant, no flash), then sync with server preference
|
||
- System preference listener: `matchMedia('(prefers-color-scheme: dark)').addEventListener('change', ...)` to update when OS theme changes
|
||
|
||
**Theme toggle:** Added to user dropdown menu in topbar header:
|
||
|
||
- Icon: Sun/Moon/Monitor (Lucide: `Sun`, `Moon`, `Monitor`)
|
||
- Three-option toggle: Light / Dark / System
|
||
- Also available in user settings page
|
||
|
||
**Audit all components** for hardcoded colors:
|
||
|
||
- All shadcn components already use CSS variables → work automatically
|
||
- Custom components from L1-L3 should use semantic tokens (`text-primary`, `bg-secondary`, etc.)
|
||
- Check and fix: chart colors, status badges, pipeline stage colors, sidebar active states
|
||
- Charts (Recharts): use CSS variable values programmatically via `getComputedStyle()`
|
||
|
||
#### Day 5 (of stream): Mobile Responsive + Scratchpad
|
||
|
||
**Mobile responsive audit:**
|
||
|
||
Priority breakpoints: `sm` (640px), `md` (768px), `lg` (1024px)
|
||
|
||
Key responsive fixes:
|
||
|
||
1. **Sidebar** — Already collapsible from L0. On `< md`: auto-collapse to icon-only, hamburger button in topbar. Overlay mode on mobile (Sheet component).
|
||
2. **Data tables** — On `< lg`: horizontal scroll with `ScrollArea`. On `< sm`: switch to card layout using `DataTableMobileView` component.
|
||
3. **Forms** — On `< md`: single-column layout. Multi-column grids collapse to stack.
|
||
4. **Pipeline board** — On `< md`: horizontal scroll with snap-to-card. Touch-friendly drag.
|
||
5. **Dashboard widgets** — On `< md`: single-column stack. Widgets full-width.
|
||
6. **Dialogs/modals** — On `< sm`: full-screen via `Dialog` with `className="sm:max-w-[425px] max-sm:h-full max-sm:max-h-full max-sm:rounded-none"`.
|
||
7. **Touch targets** — Minimum 44×44px on all interactive elements. Audit buttons, links, checkboxes.
|
||
|
||
**Components:**
|
||
|
||
- `src/components/shared/responsive-table.tsx` — Wrapper that switches between table and card layout based on breakpoint
|
||
- `src/components/shared/mobile-sheet-sidebar.tsx` — Sheet-based sidebar overlay for mobile
|
||
|
||
**Scratchpad:**
|
||
|
||
**Service:** `src/lib/services/scratchpad.ts`
|
||
|
||
```typescript
|
||
/**
|
||
* List current user's scratchpad notes.
|
||
* Only returns unlinked notes (linked notes have moved to client notes).
|
||
* Ordered by updated_at DESC.
|
||
*/
|
||
export async function listNotes(userId: string): Promise<ScratchpadNote[]>;
|
||
|
||
/**
|
||
* Create a scratchpad note.
|
||
* @param userId - Note owner
|
||
* @param content - Note text content
|
||
*/
|
||
export async function createNote(userId: string, content: string): Promise<ScratchpadNote>;
|
||
|
||
/**
|
||
* Update note content.
|
||
* @throws NotFoundError if note doesn't belong to user
|
||
*/
|
||
export async function updateNote(
|
||
userId: string,
|
||
noteId: string,
|
||
content: string,
|
||
): Promise<ScratchpadNote>;
|
||
|
||
/**
|
||
* Delete a scratchpad note.
|
||
* @throws NotFoundError if note doesn't belong to user
|
||
*/
|
||
export async function deleteNote(userId: string, noteId: string): Promise<void>;
|
||
|
||
/**
|
||
* Link a scratchpad note to a client record.
|
||
* Creates a client_notes entry with the scratchpad content, then marks the scratchpad note as linked.
|
||
* @param userId - Note owner
|
||
* @param noteId - Scratchpad note ID
|
||
* @param clientId - Target client
|
||
* @param portId - Port scope (for client validation)
|
||
* @returns The created client note
|
||
*/
|
||
export async function linkToClient(
|
||
userId: string,
|
||
noteId: string,
|
||
clientId: string,
|
||
portId: string,
|
||
): Promise<ClientNote>;
|
||
```
|
||
|
||
Note: Scratchpad notes are user-scoped (no port_id in schema — `scratchpad_notes` table has `user_id` only). This is correct per the spec: personal notes that can later be linked to a port-scoped client.
|
||
|
||
**API routes:**
|
||
|
||
| Method | Path | Handler |
|
||
| ------ | --------------------------- | ------------------------------------------------- |
|
||
| GET | `/api/scratchpad` | `listNotes(userId)` |
|
||
| POST | `/api/scratchpad` | `createNote(userId, body.content)` |
|
||
| PATCH | `/api/scratchpad/[id]` | `updateNote(userId, id, body.content)` |
|
||
| DELETE | `/api/scratchpad/[id]` | `deleteNote(userId, id)` |
|
||
| POST | `/api/scratchpad/[id]/link` | `linkToClient(userId, id, body.clientId, portId)` |
|
||
|
||
Route files at `src/app/api/scratchpad/route.ts` and `src/app/api/scratchpad/[id]/route.ts`, `src/app/api/scratchpad/[id]/link/route.ts`.
|
||
|
||
Middleware: `withAuth → handler` (no permission check — scratchpad is user-scoped, every authenticated user can use it)
|
||
|
||
**Zod schemas:** `src/lib/validators/scratchpad.ts`
|
||
|
||
```typescript
|
||
export const createNoteSchema = z.object({
|
||
content: z.string().min(1).max(5000),
|
||
});
|
||
|
||
export const updateNoteSchema = z.object({
|
||
content: z.string().min(1).max(5000),
|
||
});
|
||
|
||
export const linkNoteSchema = z.object({
|
||
clientId: z.string().uuid(),
|
||
});
|
||
```
|
||
|
||
**UI:**
|
||
|
||
`src/components/shared/scratchpad-panel.tsx`:
|
||
|
||
- Slide-out Sheet from sidebar (trigger: notepad icon in sidebar footer + keyboard shortcut Alt+N)
|
||
- Note cards: editable text area, timestamp, character count
|
||
- "Link to client" button on each note → opens client search dialog → select client → confirms → note moves to client notes
|
||
- Create new note: text area at top with "Add" button
|
||
- Auto-save on blur (debounced 500ms)
|
||
- Empty state: "Jot down quick notes during calls. Link them to clients later."
|
||
|
||
**Zustand slice:** Add to existing UI store:
|
||
|
||
```typescript
|
||
scratchpadOpen: boolean;
|
||
toggleScratchpad: () => void;
|
||
```
|
||
|
||
**TanStack Query keys:**
|
||
|
||
```typescript
|
||
['scratchpad', userId]; // User's notes
|
||
```
|
||
|
||
**shadcn components:** Sheet, Textarea, Button, ScrollArea, Dialog (client search for linking)
|
||
|
||
#### Day 6 (of stream): Archiving, Multi-Currency, System Monitoring
|
||
|
||
**Archiving consistency:**
|
||
|
||
All entities already have `archived_at` columns from L0 schema. Ensure consistent behavior:
|
||
|
||
1. **Archive/restore buttons** — On every entity detail page (clients, interests, berths, expenses, invoices). Archive = set `archived_at = now()`. Restore = set `archived_at = null`.
|
||
|
||
2. **List page filtering** — All list endpoints already support `?includeArchived=true`. Default: exclude archived. Add "Show archived" toggle on list page toolbar.
|
||
|
||
3. **Visual indicator** — Archived records show a yellow "Archived" badge in lists and detail views.
|
||
|
||
4. **Bulk archive** — Uses existing bulk operations from L3. `POST /api/bulk/status-change` with action `archive`.
|
||
|
||
5. **Cascade rules:**
|
||
- Archiving a client does NOT archive their interests (interests may be shared with other entities)
|
||
- Archiving an interest does NOT affect the linked berth
|
||
- Archiving a berth triggers berth status rules engine (BR-001) if applicable
|
||
|
||
**Multi-currency price book:**
|
||
|
||
**Service:** `src/lib/services/currency.ts`
|
||
|
||
```typescript
|
||
/**
|
||
* Get current exchange rates from cache.
|
||
* Rates are refreshed every 6 hours by maintenance job.
|
||
*/
|
||
export async function getRates(): Promise<CurrencyRate[]>;
|
||
|
||
/**
|
||
* Convert amount between currencies using cached rates.
|
||
* @throws NotFoundError if rate pair not cached
|
||
*/
|
||
export async function convert(amount: number, from: string, to: string): Promise<number>;
|
||
|
||
/**
|
||
* Force refresh rates from Frankfurter API.
|
||
* Updates currency_rates table.
|
||
* Falls back to last cached rate if API is unreachable.
|
||
*/
|
||
export async function refreshRates(): Promise<{ updated: number; source: string }>;
|
||
|
||
/**
|
||
* Manually override a rate pair.
|
||
* Sets source='manual' to distinguish from API rates.
|
||
*/
|
||
export async function overrideRate(
|
||
baseCurrency: string,
|
||
targetCurrency: string,
|
||
rate: number,
|
||
userId: string,
|
||
): Promise<CurrencyRate>;
|
||
```
|
||
|
||
**Currency pairs:** USD (primary), EUR, GBP, ECD (local Anguilla currency). Only need 3 pairs: USD→EUR, USD→GBP, USD→ECD.
|
||
|
||
**Frankfurter API integration:**
|
||
|
||
- Endpoint: `https://api.frankfurter.app/latest?from=USD&to=EUR,GBP`
|
||
- ECD: fixed rate (1 USD = 2.70 ECD — Eastern Caribbean Dollar is pegged to USD)
|
||
- Cache in Redis (6h TTL) AND `currency_rates` table (persistent)
|
||
- If API unreachable: use last cached rate, log warning
|
||
|
||
**Berth detail page enhancement:**
|
||
|
||
- Show price in all configured currencies below the primary USD amount:
|
||
```
|
||
Annual Rate: $45,000 USD
|
||
€41,400 EUR | £36,000 GBP | $121,500 ECD
|
||
```
|
||
- `src/components/berths/berth-price-display.tsx` — Multi-currency price component
|
||
|
||
**API routes:**
|
||
|
||
| Method | Path | Handler |
|
||
| ------ | ----------------------------- | --------------------------------------------------------- |
|
||
| GET | `/api/currency/rates` | `getRates()` |
|
||
| POST | `/api/currency/rates/refresh` | `refreshRates()` |
|
||
| POST | `/api/currency/convert` | `convert(body.amount, body.from, body.to)` |
|
||
| PATCH | `/api/currency/rates/[pair]` | `overrideRate(pair.base, pair.target, body.rate, userId)` |
|
||
|
||
Middleware: GET is `withAuth`, POST/PATCH is `withAuth → withPermission('currency', 'manage')`
|
||
|
||
**System monitoring:**
|
||
|
||
**Service:** `src/lib/services/system-monitoring.ts`
|
||
|
||
```typescript
|
||
/**
|
||
* Get BullMQ dashboard data: queue names, counts (waiting, active, completed, failed, delayed).
|
||
*/
|
||
export async function getJobDashboard(): Promise<QueueStatus[]>;
|
||
|
||
/**
|
||
* Get jobs in a specific queue with pagination.
|
||
*/
|
||
export async function getQueueJobs(
|
||
queueName: string,
|
||
status: 'waiting' | 'active' | 'completed' | 'failed' | 'delayed',
|
||
pagination: PaginationInput,
|
||
): Promise<PaginatedResponse<JobEntry>>;
|
||
|
||
/**
|
||
* Retry a failed job.
|
||
*/
|
||
export async function retryJob(queueName: string, jobId: string, userId: string): Promise<void>;
|
||
|
||
/**
|
||
* Delete a job.
|
||
*/
|
||
export async function deleteJob(queueName: string, jobId: string, userId: string): Promise<void>;
|
||
|
||
/**
|
||
* System health check: ping PostgreSQL, Redis, MinIO, Documenso.
|
||
*/
|
||
export async function healthCheck(): Promise<HealthStatus>;
|
||
|
||
/**
|
||
* List active system alerts (dead letter jobs, failed backups, etc.).
|
||
*/
|
||
export async function listAlerts(portId?: string): Promise<SystemAlert[]>;
|
||
|
||
/**
|
||
* Acknowledge a system alert.
|
||
*/
|
||
export async function acknowledgeAlert(alertId: string, userId: string): Promise<void>;
|
||
```
|
||
|
||
**API routes (from catalog §28):**
|
||
|
||
| Method | Path | Handler |
|
||
| ------ | ------------------------------------------- | ------------------------------------- |
|
||
| GET | `/api/admin/jobs` | `getJobDashboard()` |
|
||
| GET | `/api/admin/jobs/[queueName]` | `getQueueJobs(queueName, query)` |
|
||
| POST | `/api/admin/jobs/[queueName]/[jobId]/retry` | `retryJob(queueName, jobId, userId)` |
|
||
| DELETE | `/api/admin/jobs/[queueName]/[jobId]` | `deleteJob(queueName, jobId, userId)` |
|
||
| GET | `/api/admin/alerts` | `listAlerts(portId)` |
|
||
| PATCH | `/api/admin/alerts/[id]/acknowledge` | `acknowledgeAlert(id, userId)` |
|
||
|
||
Also from catalog §23:
|
||
|
||
| Method | Path | Handler |
|
||
| ------ | ---------------------------------- | -------------------------------------- |
|
||
| GET | `/api/admin/health` | `healthCheck()` |
|
||
| POST | `/api/admin/backup` | Trigger manual pg_dump |
|
||
| GET | `/api/admin/backups` | List available backups from MinIO |
|
||
| GET | `/api/admin/backups/[id]/download` | Presigned URL for backup download |
|
||
| POST | `/api/admin/backups/[id]/restore` | Restore from backup (super_admin only) |
|
||
|
||
Middleware: `withAuth → withPermission('system', 'manage') → handler` (super_admin or director only)
|
||
|
||
**System monitoring UI:**
|
||
|
||
`src/app/(dashboard)/[portSlug]/admin/monitoring/page.tsx`:
|
||
|
||
- **Health panel** — Service status indicators: PostgreSQL ✓, Redis ✓, MinIO ✓, Documenso ✓ (green/red dots with last check time)
|
||
- **Queue overview** — Cards per queue: name, waiting count, active count, failed count. Click to expand.
|
||
- **Queue detail** — Table of jobs with: ID, status, created, processed, data preview. Actions: retry, delete.
|
||
- **Alerts panel** — Active system alerts with acknowledge button. Shows: alert type, message, severity, timestamp.
|
||
|
||
`src/app/(dashboard)/[portSlug]/admin/backups/page.tsx`:
|
||
|
||
- Table of available backups: filename, size, created_at
|
||
- "Create backup" button → triggers manual pg_dump
|
||
- Download button per backup (presigned URL)
|
||
- Restore button (super_admin only) with multi-step confirmation
|
||
|
||
**Components:**
|
||
|
||
- `src/components/admin/service-health-card.tsx` — Green/red status indicator per service
|
||
- `src/components/admin/queue-overview.tsx` — Queue stats cards
|
||
- `src/components/admin/queue-detail-table.tsx` — Jobs table with actions
|
||
- `src/components/admin/system-alerts-list.tsx` — Alert cards with acknowledge
|
||
|
||
Note: We use bull-board's data API directly rather than embedding its UI. This gives us full control over styling (matching our design system) while leveraging bull-board's queue introspection capabilities.
|
||
|
||
#### Day 7 (of stream): Parent Company Export + AI Features + Final Polish
|
||
|
||
**Parent company export:**
|
||
|
||
**Service:** `src/lib/services/parent-company-export.ts`
|
||
|
||
```typescript
|
||
/**
|
||
* Generate the parent company audit report as a downloadable PDF.
|
||
* @param portId - Port scope
|
||
* @param userId - Requesting user (for audit)
|
||
* @param dateRange - Report period
|
||
* @returns Download URL for generated PDF
|
||
*/
|
||
export async function generateParentCompanyReport(
|
||
portId: string,
|
||
userId: string,
|
||
dateRange: { from: Date; to: Date },
|
||
): Promise<{ downloadUrl: string; filename: string }>;
|
||
```
|
||
|
||
Report sections:
|
||
|
||
1. **Cover page** — Port logo, report title, date range, generation date
|
||
2. **Executive summary** — Revenue total, expense total, net, berth occupancy %, pipeline count
|
||
3. **Expense detail** — Table grouped by category, with receipt thumbnails (from MinIO)
|
||
4. **Revenue breakdown** — Invoices paid in period, by client, by berth
|
||
5. **Berth occupancy** — Available/occupied/maintenance counts, occupancy percentage
|
||
6. **Pipeline summary** — Interests by stage, new inquiries in period
|
||
7. **Fee calculation** — EUR subtotal + 5% management fee
|
||
|
||
PDF generated via `@pdfme` with a dedicated report template. Uploaded to MinIO, presigned URL returned.
|
||
|
||
**API route:** `POST /api/admin/parent-company-export` → `{ from: Date, to: Date }`
|
||
|
||
**UI:** Button in admin section → date range picker (shadcn Calendar with range mode) → "Generate Report" → progress → download link.
|
||
|
||
**AI features (berth spec import):**
|
||
|
||
**Service:** `src/lib/services/berth-import.ts`
|
||
|
||
```typescript
|
||
/**
|
||
* Upload a berth specification document for AI-assisted parsing.
|
||
* @param portId - Port scope
|
||
* @param userId - Uploading user
|
||
* @param file - PDF or Excel file with berth specs
|
||
* @returns Job ID for tracking parse progress
|
||
*/
|
||
export async function uploadBerthSpec(
|
||
portId: string,
|
||
userId: string,
|
||
file: { buffer: Buffer; mimetype: string; originalname: string },
|
||
): Promise<{ jobId: string }>;
|
||
|
||
/**
|
||
* Get parsed berth data from AI processing (for user review).
|
||
* @returns Parsed berths with confidence scores per field
|
||
*/
|
||
export async function getParsedBerths(jobId: string): Promise<ParsedBerthResult>;
|
||
|
||
/**
|
||
* Confirm and create berths from parsed spec data.
|
||
* @param portId - Port scope
|
||
* @param userId - Confirming user
|
||
* @param jobId - Parse job ID
|
||
* @param berths - User-reviewed/corrected berth data
|
||
*/
|
||
export async function confirmBerthImport(
|
||
portId: string,
|
||
userId: string,
|
||
jobId: string,
|
||
berths: CreateBerthInput[],
|
||
): Promise<{ createdCount: number }>;
|
||
```
|
||
|
||
- BullMQ job on `ai` queue (concurrency: 2, priority: 5)
|
||
- For PDF: send pages to OpenAI Vision API (`gpt-4o`) for table extraction
|
||
- For Excel: parse directly with `xlsx`, less AI needed
|
||
- AI prompt extracts: mooring_number, berth_type, max_loa, max_beam, max_draft, annual_rate, status
|
||
- Each parsed field gets a confidence score (high/medium/low) based on AI response
|
||
- User reviews parsed results in a confirmation table, can edit before confirming
|
||
- On confirm: creates berths using existing `createBerth()` service (so all validation, audit logging, etc. applies)
|
||
|
||
**Recommendation engine refinement** (enhancement to L2 implementation):
|
||
|
||
- Add "Add manual recommendation" button to interest detail → search berth → add with notes
|
||
- Manual recommendations stored in `berth_recommendations` with `source = 'manual'`
|
||
- Manual recommendations display at top of recommendation list with "Manual" badge
|
||
- "Re-generate" button: re-runs recommendation scoring with current vessel specs
|
||
|
||
**Onboarding wizard** (simplified for V1):
|
||
|
||
`src/components/admin/onboarding-wizard.tsx`:
|
||
|
||
- Only triggered when super_admin creates a new port
|
||
- 4 steps (simplified from baseline's 5):
|
||
1. **Port basics** — Name, slug, logo upload, timezone, default currency
|
||
2. **Initial berths** — Upload berth spec (reuses AI berth import) OR skip
|
||
3. **Add users** — Invite team members with role assignment
|
||
4. **Review & activate** — Summary, activate port
|
||
- Not triggered for first-time setup (that's a deployment concern, not an app feature)
|
||
|
||
---
|
||
|
||
## 3. Code-Ready Details
|
||
|
||
### Middleware Chain
|
||
|
||
All L4 admin routes:
|
||
|
||
```
|
||
withAuth → extractPort → withPermission(resource, action) → handler
|
||
```
|
||
|
||
Specific permission requirements:
|
||
|
||
| Resource | Action | Allowed Roles |
|
||
| --------------- | -------- | ------------------------------------ |
|
||
| `webhooks` | `manage` | super_admin, director |
|
||
| `reports` | `manage` | super_admin, director |
|
||
| `import` | `manage` | super_admin, director, sales_manager |
|
||
| `custom_fields` | `manage` | super_admin, director |
|
||
| `system` | `manage` | super_admin, director |
|
||
| `currency` | `manage` | super_admin, director |
|
||
|
||
Export permission uses the entity's own `export` action:
|
||
| `clients` | `export` | super_admin, director, sales_manager |
|
||
| `expenses` | `export` | super_admin, director |
|
||
|
||
Scratchpad has no permission check — all authenticated users.
|
||
|
||
### TanStack Query Key Map
|
||
|
||
```typescript
|
||
// Webhooks
|
||
['webhooks', portId][('webhooks', portId, webhookId)][ // List // Detail
|
||
('webhooks', portId, webhookId, 'deliveries')
|
||
][ // Delivery log
|
||
// Reports
|
||
('reports', portId)
|
||
][('reports', portId, reportId)][ // List // Detail
|
||
// Import/Export
|
||
('import', 'session', sessionId)
|
||
][('import', 'preview', sessionId)][('import', 'history', portId)][('export', 'job', jobId)][
|
||
// Custom Fields
|
||
('custom-fields', 'definitions', portId, entityType)
|
||
][('custom-fields', 'values', entityId)][
|
||
// Tags
|
||
('tags', portId)
|
||
][
|
||
// Scratchpad
|
||
('scratchpad', userId)
|
||
][
|
||
// Currency
|
||
('currency', 'rates')
|
||
][
|
||
// System
|
||
('system', 'health')
|
||
][('system', 'jobs')][('system', 'jobs', queueName)][('system', 'alerts')][('system', 'backups')];
|
||
```
|
||
|
||
### Zustand Slices (additions)
|
||
|
||
```typescript
|
||
// Theme (new store)
|
||
useThemeStore: { theme, setTheme, resolvedTheme }
|
||
|
||
// UI store additions
|
||
scratchpadOpen: boolean;
|
||
toggleScratchpad: () => void;
|
||
```
|
||
|
||
### Socket.io Events (L4)
|
||
|
||
| Event | Room | Payload | Trigger |
|
||
| ------------------ | --------------- | ---------------------------------------------- | ------------------------------------------ |
|
||
| `export:completed` | `user:{userId}` | `{ jobId, downloadUrl, filename, entityType }` | Export file ready |
|
||
| `import:progress` | `user:{userId}` | `{ sessionId, processed, total, errors }` | Import row processed |
|
||
| `import:completed` | `user:{userId}` | `{ sessionId, successCount, errorCount }` | Import finished |
|
||
| `report:generated` | `port:{portId}` | `{ reportId, reportName, downloadUrl }` | Scheduled report ready |
|
||
| `system:alert` | `global` | `{ alertType, message, severity }` | System alert (dead letter, backup failure) |
|
||
|
||
### BullMQ Queues (L4 additions)
|
||
|
||
| Queue | Job Types | Concurrency | Max Attempts | Backoff |
|
||
| ---------- | ------------------ | ----------- | ------------ | ----------------------------- |
|
||
| `webhooks` | Webhook delivery | 5 | 3 | Exponential: 30s, 300s, 3000s |
|
||
| `reports` | Report generation | 1 | 3 | Exponential: 1s, 10s, 100s |
|
||
| `import` | CSV/Excel import | 1 | 1 | None (manual retry) |
|
||
| `export` | CSV/Excel export | 2 | 3 | Exponential: 1s, 10s, 100s |
|
||
| `ai` | Berth spec parsing | 2 | 2 | Fixed: 30s |
|
||
|
||
### Environment Variables (L4 additions)
|
||
|
||
```
|
||
WEBHOOK_SECRET_KEY=<32-byte AES key, hex> # For encrypting webhook signing secrets
|
||
```
|
||
|
||
No other new env vars needed — OpenAI key already exists for receipt OCR (L2).
|
||
|
||
### shadcn Components Used in L4
|
||
|
||
Already installed (from L0): Button, Input, Label, Select, Textarea, Checkbox, Switch, Dialog, Sheet, DropdownMenu, Tabs, Table, Card, Badge, Tooltip, Popover, Calendar, Form, Toast (Sonner), Skeleton, ScrollArea, AlertDialog, Progress, Separator
|
||
|
||
No new shadcn components needed for L4.
|
||
|
||
### CSS/Tailwind Patterns
|
||
|
||
Dark mode: All components use CSS custom properties. Theme toggle applies `data-theme="dark"` to `<html>`. No Tailwind `dark:` prefix needed — the CSS variable approach handles it automatically.
|
||
|
||
Mobile responsive: Use Tailwind responsive prefixes (`sm:`, `md:`, `lg:`) with mobile-first approach:
|
||
|
||
```html
|
||
<!-- Example: form grid that stacks on mobile -->
|
||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">...</div>
|
||
```
|
||
|
||
---
|
||
|
||
## 4. Acceptance Criteria
|
||
|
||
### Webhooks (AC-L4-01 through AC-L4-12)
|
||
|
||
1. Admin can create a webhook with name, URL, and selected events; signing secret is shown once on creation
|
||
2. Admin can update webhook name, URL, events, and active/inactive status
|
||
3. Admin can delete a webhook; all delivery history is cascade-deleted
|
||
4. Admin can regenerate a webhook signing secret
|
||
5. When a subscribed event fires, a delivery is enqueued to BullMQ within 100ms (non-blocking)
|
||
6. Webhook delivery includes HMAC-SHA256 signature in `X-Webhook-Signature` header
|
||
7. Webhook delivery retries 3 times with exponential backoff (30s, 300s, 3000s)
|
||
8. Failed delivery after all retries creates a system alert notification for super admin
|
||
9. Delivery log shows: timestamp, event type, status (success/failed/dead_letter), response code, response time
|
||
10. Test webhook sends a simulated event payload and records the delivery result
|
||
11. Webhook signing secrets are encrypted at rest with AES-256-GCM
|
||
12. All webhook CRUD operations write to audit_logs
|
||
|
||
### Scheduled Reports (AC-L4-13 through AC-L4-22)
|
||
|
||
13. Admin can create a scheduled report with type, cron schedule, and email recipients
|
||
14. Six report types available: pipeline summary, expense summary, berth occupancy, activity log, overdue items, revenue forecast
|
||
15. Report `next_run_at` is correctly calculated from cron expression and updates after each run
|
||
16. Report-scheduler recurring job (every minute) picks up due reports and enqueues generation
|
||
17. Generated reports are PDF files using @pdfme templates with port branding
|
||
18. Generated PDF is uploaded to MinIO and emailed to all recipients
|
||
19. Admin can manually trigger a report and download the result
|
||
20. Admin can update report schedule, recipients, and active status
|
||
21. Admin can delete a scheduled report
|
||
22. All report CRUD operations write to audit_logs
|
||
|
||
### Data Import (AC-L4-23 through AC-L4-30)
|
||
|
||
23. Import wizard accepts CSV and Excel files for clients, interests, berths, expenses
|
||
24. Column auto-mapping suggests target fields using fuzzy header matching
|
||
25. User can manually adjust column mappings using dropdown selectors
|
||
26. Preview step shows valid, error, and duplicate row counts with expandable error details
|
||
27. Duplicate detection identifies: exact email match (clients), mooring_number match (berths)
|
||
28. User can choose to skip or update duplicates
|
||
29. Import executes as BullMQ job with progress updates via Socket.io
|
||
30. Import history page shows past imports with success/error/total counts
|
||
|
||
### Data Export (AC-L4-31 through AC-L4-35)
|
||
|
||
31. Export button available on all entity list pages (clients, interests, berths, expenses, invoices)
|
||
32. Export supports CSV and Excel formats
|
||
33. Export respects current filters and visible columns as defaults
|
||
34. Export includes custom field values as additional columns when custom fields exist
|
||
35. Export job completes and notifies user with download link via Socket.io
|
||
|
||
### Custom Fields (AC-L4-36 through AC-L4-44)
|
||
|
||
36. Admin can create custom field definitions for clients, interests, and berths
|
||
37. Field types supported: text, number, date, boolean, select (with configurable options)
|
||
38. Field names must be unique per port + entity type
|
||
39. Field type cannot be changed after creation
|
||
40. Custom fields render dynamically on entity detail pages with appropriate form controls
|
||
41. Custom field values auto-save on blur with debounced upsert
|
||
42. Required custom fields show validation error when empty
|
||
43. Custom fields appear in entity exports as additional columns
|
||
44. Custom fields available in saved view column configuration
|
||
|
||
### Tags Polish (AC-L4-45 through AC-L4-48)
|
||
|
||
45. Admin can rename tags; name must remain unique within port
|
||
46. Admin can change tag color using 12 presets or custom hex input
|
||
47. Admin can delete a tag; confirmation shows count of affected entities
|
||
48. Admin can merge two tags; all entity associations transfer to target tag
|
||
|
||
### Dark Mode (AC-L4-49 through AC-L4-53)
|
||
|
||
49. Theme toggle (light/dark/system) in user dropdown menu
|
||
50. Dark mode uses exact color values from `15-DESIGN-TOKENS.md` §2.2
|
||
51. Theme preference persisted to localStorage (instant) and server (synced)
|
||
52. System preference option follows OS dark/light mode
|
||
53. All components render correctly in dark mode with no hardcoded colors
|
||
|
||
### Mobile Responsive (AC-L4-54 through AC-L4-58)
|
||
|
||
54. Sidebar collapses to hamburger menu on screens < 768px
|
||
55. Data tables scroll horizontally or switch to card layout on screens < 640px
|
||
56. Forms switch to single-column layout on screens < 768px
|
||
57. All touch targets are minimum 44×44px
|
||
58. Pipeline board scrolls horizontally with snap on mobile
|
||
|
||
### Scratchpad (AC-L4-59 through AC-L4-63)
|
||
|
||
59. Scratchpad panel slides out from sidebar footer icon
|
||
60. User can create, edit, and delete quick notes
|
||
61. Notes auto-save on blur with 500ms debounce
|
||
62. User can link a note to a client; note content moves to client notes
|
||
63. Keyboard shortcut Alt+N creates a new scratchpad note
|
||
|
||
### Archiving (AC-L4-64 through AC-L4-67)
|
||
|
||
64. Archive/restore buttons on all entity detail pages
|
||
65. Archived entities hidden from default list views
|
||
66. "Show archived" toggle on list pages includes archived records with visual indicator
|
||
67. Bulk archive available via bulk operations toolbar
|
||
|
||
### Multi-Currency (AC-L4-68 through AC-L4-72)
|
||
|
||
68. Exchange rates refresh every 6 hours from Frankfurter API
|
||
69. If API unreachable, last cached rate is used (with stale warning in admin)
|
||
70. ECD rate is fixed at 2.70 per USD (pegged currency)
|
||
71. Berth detail page shows price in USD, EUR, GBP, and ECD
|
||
72. Admin can manually override exchange rates
|
||
|
||
### System Monitoring (AC-L4-73 through AC-L4-79)
|
||
|
||
73. System health endpoint returns status of PostgreSQL, Redis, MinIO, Documenso
|
||
74. Job dashboard shows all BullMQ queues with waiting/active/failed counts
|
||
75. Admin can view, retry, and delete individual jobs
|
||
76. System alerts surface dead letter jobs and backup failures
|
||
77. Admin can acknowledge alerts
|
||
78. Manual backup trigger creates pg_dump and stores in MinIO
|
||
79. Backup list shows available backups with download links
|
||
|
||
### Parent Company Export (AC-L4-80 through AC-L4-82)
|
||
|
||
80. Admin can generate a parent company report for a date range
|
||
81. Report PDF includes: expenses with receipts, revenue, occupancy, pipeline summary, EUR subtotal + 5% fee
|
||
82. Report uploaded to MinIO with presigned download URL
|
||
|
||
### AI Features (AC-L4-83 through AC-L4-87)
|
||
|
||
83. Berth spec import accepts PDF or Excel files for AI parsing
|
||
84. Parsed berths show confidence scores; user can review and correct before confirming
|
||
85. Confirmed berths are created using standard berth creation flow (with validation + audit)
|
||
86. Manual recommendations can be added to interests alongside AI recommendations
|
||
87. Recommendation re-generation updates scores based on current vessel specs
|
||
|
||
---
|
||
|
||
## 5. Self-Review Checklist
|
||
|
||
### Security
|
||
|
||
- [ ] Webhook signing secrets encrypted at rest (AES-256-GCM) — never stored or logged in plaintext
|
||
- [ ] Webhook delivery payloads don't include sensitive PII (phone, email of clients) — use entity IDs only
|
||
- [ ] Import file uploads validated: MIME type allowlist, max size, filename sanitization
|
||
- [ ] Import session data in Redis has TTL (1 hour) — no stale data accumulation
|
||
- [ ] Export files stored in MinIO with presigned URLs (15-min expiry) — no permanent public URLs
|
||
- [ ] System monitoring endpoints restricted to super_admin/director roles
|
||
- [ ] Backup restore restricted to super_admin only
|
||
- [ ] Custom field values validated against field type before storage
|
||
- [ ] No raw SQL in any L4 service — all queries via Drizzle ORM
|
||
- [ ] Dark mode doesn't leak light-mode-only colors in any component
|
||
|
||
### Data Integrity
|
||
|
||
- [ ] All admin CRUD operations (webhooks, reports, custom fields, tags) write to audit_logs
|
||
- [ ] Import operations create audit log entries with metadata (filename, row count, entity type)
|
||
- [ ] Export operations create audit log entries (who exported what, when)
|
||
- [ ] Webhook deletion cascades to webhook_deliveries (per schema ON DELETE CASCADE)
|
||
- [ ] Custom field deletion cascades to custom_field_values (per schema ON DELETE CASCADE)
|
||
- [ ] Tag merge re-assigns all entity associations before deleting source tag
|
||
- [ ] Tag deletion cascades via junction tables (client_tags, interest_tags, berth_tags ON DELETE CASCADE)
|
||
- [ ] Currency rate overrides preserve the `source: 'manual'` flag for audit trail
|
||
- [ ] Parent company export includes only data within the specified date range
|
||
|
||
### Port Scoping
|
||
|
||
- [ ] Webhook CRUD scoped to portId from session context
|
||
- [ ] Scheduled report CRUD scoped to portId
|
||
- [ ] Import operations scoped to portId (all created records get correct port_id)
|
||
- [ ] Custom field definitions scoped to portId
|
||
- [ ] Tags scoped to portId (uniqueness constraint is port_id + name)
|
||
- [ ] Currency rates are global (not port-scoped) — correct per schema
|
||
- [ ] Scratchpad notes are user-scoped (not port-scoped) — correct per schema
|
||
- [ ] System monitoring accessible to admin roles regardless of port context
|
||
|
||
### UX
|
||
|
||
- [ ] Dark mode toggle responds instantly (localStorage first, server sync async)
|
||
- [ ] No flash of wrong theme on page load (theme applied in `<head>` script)
|
||
- [ ] Mobile sidebar overlay closes when navigating to a page
|
||
- [ ] Import wizard preserves state across steps (back/forward navigation)
|
||
- [ ] Export progress notification persists until user dismisses or downloads
|
||
- [ ] Scratchpad panel doesn't interfere with sidebar navigation
|
||
- [ ] Custom field section collapses cleanly when no fields defined
|
||
|
||
### Performance
|
||
|
||
- [ ] Webhook dispatch is non-blocking (enqueue to BullMQ, don't await delivery)
|
||
- [ ] Report generation runs on `reports` queue (concurrency: 1) to avoid memory spikes from PDF generation
|
||
- [ ] Import uses streaming parse for large files (papaparse supports streaming)
|
||
- [ ] Export uses cursor-based pagination for large entity sets (don't load all into memory)
|
||
- [ ] Custom field values loaded in single JOIN query with definitions (not N+1)
|
||
- [ ] Currency rates cached in Redis (6h TTL) — API call only when cache misses or forced refresh
|
||
- [ ] System health check has 5-second timeout per service (don't block on unresponsive service)
|
||
|
||
### Testing
|
||
|
||
- [ ] Webhook delivery engine: test HMAC signature generation, retry logic, dead letter handling
|
||
- [ ] Import: test CSV parsing, Excel parsing, column auto-mapping, duplicate detection, error rows
|
||
- [ ] Export: test CSV and Excel output format, custom field inclusion, filter application
|
||
- [ ] Custom fields: test type validation for all 5 types, required field enforcement, upsert behavior
|
||
- [ ] Tags: test merge (source tag deleted, associations transferred), delete cascade
|
||
- [ ] Dark mode: visual regression test (Playwright screenshot comparison, if feasible)
|
||
- [ ] Currency conversion: test with known rates, test manual override, test API fallback
|
||
- [ ] Scratchpad link-to-client: verify client note created, scratchpad note marked as linked
|
||
|
||
### Integration
|
||
|
||
- [ ] Webhook dispatch calls added to all 18 event sources across service layer
|
||
- [ ] Custom field section renders on client, interest, and berth detail pages
|
||
- [ ] Export includes custom fields for all exportable entity types
|
||
- [ ] Saved views support custom field columns
|
||
- [ ] Dark mode renders correctly on all pages from L0-L3
|
||
- [ ] Mobile responsive works on all pages from L0-L3
|
||
- [ ] Archiving behavior consistent across all entity types
|
||
|
||
---
|
||
|
||
## Codex Addenda — Merged from Competing Plan Review
|
||
|
||
### 1. Webhook Event Name Translation (Dot-Style)
|
||
|
||
Internal socket events use camelCase (e.g., `interest:stageChanged`), but outbound webhook events must use **dot-style** names per BR-111/BR-112. Build an explicit translation map:
|
||
|
||
| Internal Event | Outbound Webhook Event |
|
||
| ----------------------- | ------------------------ |
|
||
| `interest:stageChanged` | `interest.stage_changed` |
|
||
| `client:created` | `client.created` |
|
||
| `registration:new` | `registration.new` |
|
||
| `document:signed` | `document.signed` |
|
||
| `invoice:paid` | `invoice.paid` |
|
||
|
||
Implement this as `src/modules/webhooks/event-map.ts`.
|
||
|
||
### 2. Report/Import History via Audit Logs (No New Tables)
|
||
|
||
Report run history and import history should be stored as `audit_logs` entries:
|
||
|
||
- Report runs: `entity_type='scheduled_report'`, `action='run'`, metadata contains file path, recipients, and result
|
||
- Import sessions: `entity_type='import'`, metadata contains `sessionId`
|
||
- Export history: `entity_type='export'`
|
||
|
||
Do not add `report_runs` or `import_history` tables unless Matt explicitly blesses a schema change.
|
||
|
||
### 3. TipTap-to-@pdfme Serializer Risk
|
||
|
||
The TipTap-to-@pdfme conversion is the **hardest implementation detail** in L4. The editor must stay within a constrained formatting subset or the serializer will become a rewrite sink. Define the supported node list upfront and reject unsupported nodes at validation time, not at generation time.
|
||
|
||
### 4. Custom Field Immutability
|
||
|
||
Custom field `fieldType` cannot change after records exist with values for that field. Only `fieldLabel`, `isRequired`, `sortOrder`, and `selectOptions` can be modified post-creation. Deleting a custom field definition cascade-deletes all values and requires explicit confirmation.
|
||
|
||
### 5. Webhook Secret Auto-Generation
|
||
|
||
If a webhook secret is blank on create, the server generates one and shows it **once** in the UI response. After that, the secret is masked in all GET responses.
|
||
|
||
### 6. Port-Scoped Webhook Firing
|
||
|
||
Port-scoped webhooks fire for the data port even when the actor is a super admin working cross-port. The webhook context is the affected port, not the actor's session port.
|
||
|
||
### 7. Form Expiry UX
|
||
|
||
Expired public forms return a **dedicated expired-state page**, not a generic 404, per BR-130.
|
||
|
||
### 8. AI Job Safety
|
||
|
||
- AI (OpenAI) job outputs are stored in BullMQ job data plus MinIO temp artifacts; do not add permanent AI-result tables in V1.
|
||
- File matching suggestions should use existing `pg_trgm` on `clients.full_name` before invoking any AI call.
|
||
- PDF parsing can return partial rows; review UI must allow delete and correction, not only confirm.
|