2026-05-12 14:50:58 +02:00
|
|
|
import { and, count, desc, eq, inArray, isNull, sql } from 'drizzle-orm';
|
Initial commit: Port Nimara CRM (Layers 0-4)
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>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
import { db } from '@/lib/db';
|
|
|
|
|
import { clients } from '@/lib/db/schema/clients';
|
2026-05-12 14:50:58 +02:00
|
|
|
import { yachts } from '@/lib/db/schema/yachts';
|
|
|
|
|
import { companies } from '@/lib/db/schema/companies';
|
2026-05-05 02:41:52 +02:00
|
|
|
import { interests, interestBerths } from '@/lib/db/schema/interests';
|
Initial commit: Port Nimara CRM (Layers 0-4)
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>
2026-03-26 11:52:51 +01:00
|
|
|
import { berths } from '@/lib/db/schema/berths';
|
2026-05-12 14:50:58 +02:00
|
|
|
import { invoices, expenses } from '@/lib/db/schema/financial';
|
|
|
|
|
import { documents } from '@/lib/db/schema/documents';
|
|
|
|
|
import { reminders } from '@/lib/db/schema/operations';
|
2026-05-14 15:19:38 +02:00
|
|
|
import { ports } from '@/lib/db/schema/ports';
|
Initial commit: Port Nimara CRM (Layers 0-4)
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>
2026-03-26 11:52:51 +01:00
|
|
|
import { systemSettings, auditLogs } from '@/lib/db/schema/system';
|
refactor(sales): consolidate pipeline stages + wire EOI auto-advance
The 8→9 stage refresh from earlier today only updated constants.ts and the DB —
20 component/service files still hardcoded the old enum, leaving labels blank,
filter dropdowns wrong, kanban columns mismatched, and the analytics funnel
silently dropping new-stage rows. The platform also never advanced
pipelineStage on EOI lifecycle events: documents.service.ts wrote eoiStatus
but left the user-visible stage stuck.
This commit closes both gaps:
1. Single source of truth in src/lib/constants.ts — adds STAGE_LABELS,
STAGE_BADGE, STAGE_DOT, STAGE_WEIGHTS, STAGE_TRANSITIONS plus
stageLabel / stageBadgeClass / stageDotClass / safeStage /
canTransitionStage helpers. components/clients/pipeline-constants.ts
becomes a re-export shim so existing imports keep working.
2. 18 stale-enum surfaces migrated — interest list (table, card, filters,
form, stage picker), pipeline board, client card, berth interests tab,
portal client interests page, dashboard pipeline / funnel / revenue-
forecast charts, settings pipeline_weights default, dashboard.service
weights, analytics.service funnel stages, alert-rules stale-interest
filter, interest-scoring stage rank.
3. Documents tab wired into interest detail — replaced the placeholder in
interest-tabs.tsx with InterestDocumentsTab + InterestFilesTab so the
EOI launcher is back where salespeople work.
4. Auto-advance — new advanceStageIfBehind() in interests.service.ts
(forward-only, no-op if interest is already past the target). Called
from documents.service.ts on send (→ eoi_sent), Documenso completed
webhook (→ eoi_signed), and manual signed-EOI upload (→ eoi_signed).
5. Transition guard — canTransitionStage() blocks egregious skips
(e.g. completed → open, open → contract_signed). Enforced in
changeInterestStage before the DB write.
Tests updated to reflect the 9-stage model. tsc clean, vitest 832/832,
ESLint clean on every file touched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:33:53 +02:00
|
|
|
import { PIPELINE_STAGES, STAGE_WEIGHTS } from '@/lib/constants';
|
feat(active-interest): canonical predicate + fix stale getHotDeals rank
Extract activeInterestsWhere(portId) as the single source of truth for
"active interest" SQL filtering: scoped + archived_at IS NULL + outcome
IS NULL. Won deals are now CLOSED, not active — pre-2026-05-14 the
dashboard used a permissive `outcome IS NULL OR outcome = 'won'` that
double-counted won revenue against the in-flight pipeline.
Locked in PRE-DEPLOY-PLAN § 1.1.2.
Bonus catch: getHotDeals rank-CASE referenced the OLD 9-stage pipeline
names ('completed', 'contract_signed', 'contract_sent', 'deposit_10pct',
'eoi_signed', 'eoi_sent', 'in_communication', 'details_sent'). Every
row hit the ELSE 0 branch under the new 7-stage model, collapsing
ordering to updatedAt only — the widget silently stopped surfacing
"closest to closing". Rebuilt the rank ladder against the current
canonical stages (enquiry → ... → contract).
Tests: 2 unit tests assert the predicate's compiled SQL contains
"archived_at" IS NULL + "outcome" IS NULL, and never the legacy 'won'
literal.
Remaining sweep targets queued for the next commit:
- client-archive-dossier.service.ts
- client-restore.service.ts
- client-archive.service.ts
- reminders.service.ts
- berths.service.ts (recommender feasibility)
- interests.service.ts
- report-generators.ts
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:53:58 +02:00
|
|
|
import { activeInterestsWhere } from '@/lib/services/active-interest';
|
2026-05-14 15:19:38 +02:00
|
|
|
import { convert as convertCurrency } from '@/lib/services/currency';
|
Initial commit: Port Nimara CRM (Layers 0-4)
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>
2026-03-26 11:52:51 +01:00
|
|
|
|
refactor(sales): consolidate pipeline stages + wire EOI auto-advance
The 8→9 stage refresh from earlier today only updated constants.ts and the DB —
20 component/service files still hardcoded the old enum, leaving labels blank,
filter dropdowns wrong, kanban columns mismatched, and the analytics funnel
silently dropping new-stage rows. The platform also never advanced
pipelineStage on EOI lifecycle events: documents.service.ts wrote eoiStatus
but left the user-visible stage stuck.
This commit closes both gaps:
1. Single source of truth in src/lib/constants.ts — adds STAGE_LABELS,
STAGE_BADGE, STAGE_DOT, STAGE_WEIGHTS, STAGE_TRANSITIONS plus
stageLabel / stageBadgeClass / stageDotClass / safeStage /
canTransitionStage helpers. components/clients/pipeline-constants.ts
becomes a re-export shim so existing imports keep working.
2. 18 stale-enum surfaces migrated — interest list (table, card, filters,
form, stage picker), pipeline board, client card, berth interests tab,
portal client interests page, dashboard pipeline / funnel / revenue-
forecast charts, settings pipeline_weights default, dashboard.service
weights, analytics.service funnel stages, alert-rules stale-interest
filter, interest-scoring stage rank.
3. Documents tab wired into interest detail — replaced the placeholder in
interest-tabs.tsx with InterestDocumentsTab + InterestFilesTab so the
EOI launcher is back where salespeople work.
4. Auto-advance — new advanceStageIfBehind() in interests.service.ts
(forward-only, no-op if interest is already past the target). Called
from documents.service.ts on send (→ eoi_sent), Documenso completed
webhook (→ eoi_signed), and manual signed-EOI upload (→ eoi_signed).
5. Transition guard — canTransitionStage() blocks egregious skips
(e.g. completed → open, open → contract_signed). Enforced in
changeInterestStage before the DB write.
Tests updated to reflect the 9-stage model. tsc clean, vitest 832/832,
ESLint clean on every file touched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:33:53 +02:00
|
|
|
const DEFAULT_PIPELINE_WEIGHTS: Record<string, number> = STAGE_WEIGHTS;
|
Initial commit: Port Nimara CRM (Layers 0-4)
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>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
// ─── KPIs ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function getKpis(portId: string) {
|
|
|
|
|
const [totalClientsRow] = await db
|
|
|
|
|
.select({ value: count() })
|
|
|
|
|
.from(clients)
|
|
|
|
|
.where(and(eq(clients.portId, portId), isNull(clients.archivedAt)));
|
|
|
|
|
|
|
|
|
|
const [activeInterestsRow] = await db
|
|
|
|
|
.select({ value: count() })
|
|
|
|
|
.from(interests)
|
feat(active-interest): canonical predicate + fix stale getHotDeals rank
Extract activeInterestsWhere(portId) as the single source of truth for
"active interest" SQL filtering: scoped + archived_at IS NULL + outcome
IS NULL. Won deals are now CLOSED, not active — pre-2026-05-14 the
dashboard used a permissive `outcome IS NULL OR outcome = 'won'` that
double-counted won revenue against the in-flight pipeline.
Locked in PRE-DEPLOY-PLAN § 1.1.2.
Bonus catch: getHotDeals rank-CASE referenced the OLD 9-stage pipeline
names ('completed', 'contract_signed', 'contract_sent', 'deposit_10pct',
'eoi_signed', 'eoi_sent', 'in_communication', 'details_sent'). Every
row hit the ELSE 0 branch under the new 7-stage model, collapsing
ordering to updatedAt only — the widget silently stopped surfacing
"closest to closing". Rebuilt the rank ladder against the current
canonical stages (enquiry → ... → contract).
Tests: 2 unit tests assert the predicate's compiled SQL contains
"archived_at" IS NULL + "outcome" IS NULL, and never the legacy 'won'
literal.
Remaining sweep targets queued for the next commit:
- client-archive-dossier.service.ts
- client-restore.service.ts
- client-archive.service.ts
- reminders.service.ts
- berths.service.ts (recommender feasibility)
- interests.service.ts
- report-generators.ts
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:53:58 +02:00
|
|
|
.where(activeInterestsWhere(portId));
|
Initial commit: Port Nimara CRM (Layers 0-4)
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>
2026-03-26 11:52:51 +01:00
|
|
|
|
2026-05-14 15:19:38 +02:00
|
|
|
// Pipeline value: SUM each berth's price ONCE regardless of how many
|
|
|
|
|
// active interests reference it. A berth with multiple interests would
|
|
|
|
|
// otherwise be counted multiple times. Reads the primary-berth link
|
2026-05-05 02:41:52 +02:00
|
|
|
// via interest_berths (plan §3.4).
|
2026-05-14 15:19:38 +02:00
|
|
|
//
|
|
|
|
|
// Currency: convert each berth's price from its own `priceCurrency` to
|
|
|
|
|
// the port's `defaultCurrency` via the currency.service rate table.
|
|
|
|
|
// Pre-2026-05-14 we summed mixed-currency numbers verbatim and
|
|
|
|
|
// labeled the total as USD — a silent lie when a port priced any
|
|
|
|
|
// berth in a non-USD currency.
|
|
|
|
|
const [portRow] = await db
|
|
|
|
|
.select({ defaultCurrency: ports.defaultCurrency })
|
|
|
|
|
.from(ports)
|
|
|
|
|
.where(eq(ports.id, portId));
|
|
|
|
|
const targetCurrency = portRow?.defaultCurrency ?? 'USD';
|
|
|
|
|
|
Initial commit: Port Nimara CRM (Layers 0-4)
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>
2026-03-26 11:52:51 +01:00
|
|
|
const pipelineRows = await db
|
2026-05-14 15:19:38 +02:00
|
|
|
.selectDistinct({
|
|
|
|
|
berthId: interestBerths.berthId,
|
|
|
|
|
price: berths.price,
|
|
|
|
|
priceCurrency: berths.priceCurrency,
|
|
|
|
|
})
|
Initial commit: Port Nimara CRM (Layers 0-4)
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>
2026-03-26 11:52:51 +01:00
|
|
|
.from(interests)
|
2026-05-05 02:41:52 +02:00
|
|
|
.innerJoin(
|
|
|
|
|
interestBerths,
|
|
|
|
|
and(eq(interestBerths.interestId, interests.id), eq(interestBerths.isPrimary, true)),
|
|
|
|
|
)
|
|
|
|
|
.innerJoin(berths, eq(interestBerths.berthId, berths.id))
|
feat(active-interest): canonical predicate + fix stale getHotDeals rank
Extract activeInterestsWhere(portId) as the single source of truth for
"active interest" SQL filtering: scoped + archived_at IS NULL + outcome
IS NULL. Won deals are now CLOSED, not active — pre-2026-05-14 the
dashboard used a permissive `outcome IS NULL OR outcome = 'won'` that
double-counted won revenue against the in-flight pipeline.
Locked in PRE-DEPLOY-PLAN § 1.1.2.
Bonus catch: getHotDeals rank-CASE referenced the OLD 9-stage pipeline
names ('completed', 'contract_signed', 'contract_sent', 'deposit_10pct',
'eoi_signed', 'eoi_sent', 'in_communication', 'details_sent'). Every
row hit the ELSE 0 branch under the new 7-stage model, collapsing
ordering to updatedAt only — the widget silently stopped surfacing
"closest to closing". Rebuilt the rank ladder against the current
canonical stages (enquiry → ... → contract).
Tests: 2 unit tests assert the predicate's compiled SQL contains
"archived_at" IS NULL + "outcome" IS NULL, and never the legacy 'won'
literal.
Remaining sweep targets queued for the next commit:
- client-archive-dossier.service.ts
- client-restore.service.ts
- client-archive.service.ts
- reminders.service.ts
- berths.service.ts (recommender feasibility)
- interests.service.ts
- report-generators.ts
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:53:58 +02:00
|
|
|
.where(activeInterestsWhere(portId));
|
Initial commit: Port Nimara CRM (Layers 0-4)
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>
2026-03-26 11:52:51 +01:00
|
|
|
|
2026-05-14 15:19:38 +02:00
|
|
|
let pipelineValue = 0;
|
|
|
|
|
for (const row of pipelineRows) {
|
|
|
|
|
if (!row.price) continue;
|
|
|
|
|
const amount = parseFloat(String(row.price));
|
|
|
|
|
if (!Number.isFinite(amount) || amount === 0) continue;
|
|
|
|
|
const sourceCurrency = (row.priceCurrency ?? targetCurrency).toUpperCase();
|
|
|
|
|
if (sourceCurrency === targetCurrency.toUpperCase()) {
|
|
|
|
|
pipelineValue += amount;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
const converted = await convertCurrency(amount, sourceCurrency, targetCurrency);
|
|
|
|
|
if (converted) {
|
|
|
|
|
pipelineValue += converted.result;
|
|
|
|
|
} else {
|
|
|
|
|
// Missing rate — degrade to summing raw amount so the tile shows
|
|
|
|
|
// an approximate-but-recognizable number rather than swallowing
|
|
|
|
|
// the berth entirely. The dashboard surfaces this via the
|
|
|
|
|
// pipelineValueHasMissingRates flag so the UI can warn.
|
|
|
|
|
pipelineValue += amount;
|
|
|
|
|
}
|
|
|
|
|
}
|
Initial commit: Port Nimara CRM (Layers 0-4)
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>
2026-03-26 11:52:51 +01:00
|
|
|
|
2026-05-14 15:19:38 +02:00
|
|
|
// Occupancy rate: berths with `status='sold'` / total * 100. Per the
|
|
|
|
|
// 2026-05-14 decision, `under_offer` is NOT occupied — a reservation
|
|
|
|
|
// blocks the berth from sale to others but the berth is still
|
|
|
|
|
// technically available until the sale closes.
|
Initial commit: Port Nimara CRM (Layers 0-4)
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>
2026-03-26 11:52:51 +01:00
|
|
|
const allBerthsRows = await db
|
|
|
|
|
.select({ status: berths.status })
|
|
|
|
|
.from(berths)
|
fix(P1): soft-archive berths instead of hard-delete — F5
Pre-audit, DELETE /api/v1/berths/[id] called `db.delete()` which
permanently dropped the row, cascade-vanished `interest_berths` links,
broke historical audit references, and could 404 the public feed mid-
customer-inquiry. The `berths.archived_at` column existed in the schema
but was never written.
Changes:
- `archiveBerth(id, portId, { reason }, meta)` is the new canonical
soft-archive. Requires a reason (min 5 chars). Blocks when an
active interest still depends on the berth (forces the rep to
resolve the deal first). Audit-logs the old status + reason.
- `restoreBerth(...)` reverses it.
- DELETE route now accepts `{ reason }` and routes to archiveBerth.
- New POST /api/v1/berths/[id]/restore.
- `getBerthOptions` + dashboard occupancy / status-distribution
queries gain `isNull(berths.archivedAt)` so archived moorings
don't show up in pickers or skew metrics.
- Legacy `deleteBerth(...)` kept as a thin wrapper around archiveBerth
so import sites we haven't migrated still work — labeled @deprecated.
Verified live:
- DELETE w/o reason → 400 (validation)
- DELETE w/ "x" → 400 "Reason must be ≥ 5 characters"
- DELETE w/ proper reason → 204, row archived, reason persisted
- DELETE twice → 409 "Berth is already archived"
- POST /restore → 204, archived_at cleared
Follow-up (deferred): apply isNull(archivedAt) to recommendations.ts,
alert-rules.ts, portal.service.ts, report-generators.ts, berth-rules-
engine.ts. The current set covers the visible surfaces; the rest are
secondary aggregators.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:49:43 +02:00
|
|
|
// F5: archived berths excluded so retired moorings don't dilute denominator.
|
|
|
|
|
.where(and(eq(berths.portId, portId), isNull(berths.archivedAt)));
|
Initial commit: Port Nimara CRM (Layers 0-4)
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>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
const totalBerths = allBerthsRows.length;
|
2026-05-14 15:19:38 +02:00
|
|
|
const occupiedBerths = allBerthsRows.filter((b) => b.status === 'sold').length;
|
Initial commit: Port Nimara CRM (Layers 0-4)
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>
2026-03-26 11:52:51 +01:00
|
|
|
const occupancyRate = totalBerths > 0 ? (occupiedBerths / totalBerths) * 100 : 0;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
totalClients: totalClientsRow?.value ?? 0,
|
|
|
|
|
activeInterests: activeInterestsRow?.value ?? 0,
|
2026-05-14 15:19:38 +02:00
|
|
|
pipelineValue,
|
|
|
|
|
pipelineValueCurrency: targetCurrency,
|
Initial commit: Port Nimara CRM (Layers 0-4)
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>
2026-03-26 11:52:51 +01:00
|
|
|
occupancyRate,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Pipeline Counts ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function getPipelineCounts(portId: string) {
|
|
|
|
|
const rows = await db
|
|
|
|
|
.select({
|
|
|
|
|
stage: interests.pipelineStage,
|
|
|
|
|
count: sql<number>`count(*)::int`,
|
|
|
|
|
})
|
|
|
|
|
.from(interests)
|
feat(active-interest): canonical predicate + fix stale getHotDeals rank
Extract activeInterestsWhere(portId) as the single source of truth for
"active interest" SQL filtering: scoped + archived_at IS NULL + outcome
IS NULL. Won deals are now CLOSED, not active — pre-2026-05-14 the
dashboard used a permissive `outcome IS NULL OR outcome = 'won'` that
double-counted won revenue against the in-flight pipeline.
Locked in PRE-DEPLOY-PLAN § 1.1.2.
Bonus catch: getHotDeals rank-CASE referenced the OLD 9-stage pipeline
names ('completed', 'contract_signed', 'contract_sent', 'deposit_10pct',
'eoi_signed', 'eoi_sent', 'in_communication', 'details_sent'). Every
row hit the ELSE 0 branch under the new 7-stage model, collapsing
ordering to updatedAt only — the widget silently stopped surfacing
"closest to closing". Rebuilt the rank ladder against the current
canonical stages (enquiry → ... → contract).
Tests: 2 unit tests assert the predicate's compiled SQL contains
"archived_at" IS NULL + "outcome" IS NULL, and never the legacy 'won'
literal.
Remaining sweep targets queued for the next commit:
- client-archive-dossier.service.ts
- client-restore.service.ts
- client-archive.service.ts
- reminders.service.ts
- berths.service.ts (recommender feasibility)
- interests.service.ts
- report-generators.ts
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:53:58 +02:00
|
|
|
.where(activeInterestsWhere(portId))
|
Initial commit: Port Nimara CRM (Layers 0-4)
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>
2026-03-26 11:52:51 +01:00
|
|
|
.groupBy(interests.pipelineStage);
|
|
|
|
|
|
|
|
|
|
const countsByStage = Object.fromEntries(rows.map((r) => [r.stage, r.count]));
|
|
|
|
|
|
|
|
|
|
return PIPELINE_STAGES.map((stage) => ({
|
|
|
|
|
stage,
|
|
|
|
|
count: countsByStage[stage] ?? 0,
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Revenue Forecast ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function getRevenueForecast(portId: string) {
|
|
|
|
|
// Load weights from systemSettings
|
|
|
|
|
let weights: Record<string, number> = DEFAULT_PIPELINE_WEIGHTS;
|
|
|
|
|
let weightsSource: 'db' | 'default' = 'default';
|
|
|
|
|
|
|
|
|
|
const settingRow = await db.query.systemSettings.findFirst({
|
refactor(sales): consolidate pipeline stages + wire EOI auto-advance
The 8→9 stage refresh from earlier today only updated constants.ts and the DB —
20 component/service files still hardcoded the old enum, leaving labels blank,
filter dropdowns wrong, kanban columns mismatched, and the analytics funnel
silently dropping new-stage rows. The platform also never advanced
pipelineStage on EOI lifecycle events: documents.service.ts wrote eoiStatus
but left the user-visible stage stuck.
This commit closes both gaps:
1. Single source of truth in src/lib/constants.ts — adds STAGE_LABELS,
STAGE_BADGE, STAGE_DOT, STAGE_WEIGHTS, STAGE_TRANSITIONS plus
stageLabel / stageBadgeClass / stageDotClass / safeStage /
canTransitionStage helpers. components/clients/pipeline-constants.ts
becomes a re-export shim so existing imports keep working.
2. 18 stale-enum surfaces migrated — interest list (table, card, filters,
form, stage picker), pipeline board, client card, berth interests tab,
portal client interests page, dashboard pipeline / funnel / revenue-
forecast charts, settings pipeline_weights default, dashboard.service
weights, analytics.service funnel stages, alert-rules stale-interest
filter, interest-scoring stage rank.
3. Documents tab wired into interest detail — replaced the placeholder in
interest-tabs.tsx with InterestDocumentsTab + InterestFilesTab so the
EOI launcher is back where salespeople work.
4. Auto-advance — new advanceStageIfBehind() in interests.service.ts
(forward-only, no-op if interest is already past the target). Called
from documents.service.ts on send (→ eoi_sent), Documenso completed
webhook (→ eoi_signed), and manual signed-EOI upload (→ eoi_signed).
5. Transition guard — canTransitionStage() blocks egregious skips
(e.g. completed → open, open → contract_signed). Enforced in
changeInterestStage before the DB write.
Tests updated to reflect the 9-stage model. tsc clean, vitest 832/832,
ESLint clean on every file touched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:33:53 +02:00
|
|
|
where: and(eq(systemSettings.key, 'pipeline_weights'), eq(systemSettings.portId, portId)),
|
Initial commit: Port Nimara CRM (Layers 0-4)
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>
2026-03-26 11:52:51 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (settingRow?.value) {
|
|
|
|
|
try {
|
|
|
|
|
const parsed = settingRow.value as Record<string, number>;
|
|
|
|
|
if (typeof parsed === 'object' && parsed !== null) {
|
|
|
|
|
weights = parsed;
|
|
|
|
|
weightsSource = 'db';
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// Fall through to defaults
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-04 22:57:01 +02:00
|
|
|
// Forecast excludes lost/cancelled - only currently-active or won-out
|
2026-05-05 02:41:52 +02:00
|
|
|
// interests should affect the weighted pipeline value. Reads the
|
|
|
|
|
// primary-berth link via interest_berths (plan §3.4).
|
Initial commit: Port Nimara CRM (Layers 0-4)
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>
2026-03-26 11:52:51 +01:00
|
|
|
const interestRows = await db
|
|
|
|
|
.select({
|
|
|
|
|
id: interests.id,
|
|
|
|
|
pipelineStage: interests.pipelineStage,
|
|
|
|
|
berthPrice: berths.price,
|
|
|
|
|
})
|
|
|
|
|
.from(interests)
|
2026-05-05 02:41:52 +02:00
|
|
|
.innerJoin(
|
|
|
|
|
interestBerths,
|
|
|
|
|
and(eq(interestBerths.interestId, interests.id), eq(interestBerths.isPrimary, true)),
|
|
|
|
|
)
|
|
|
|
|
.innerJoin(berths, eq(interestBerths.berthId, berths.id))
|
feat(active-interest): canonical predicate + fix stale getHotDeals rank
Extract activeInterestsWhere(portId) as the single source of truth for
"active interest" SQL filtering: scoped + archived_at IS NULL + outcome
IS NULL. Won deals are now CLOSED, not active — pre-2026-05-14 the
dashboard used a permissive `outcome IS NULL OR outcome = 'won'` that
double-counted won revenue against the in-flight pipeline.
Locked in PRE-DEPLOY-PLAN § 1.1.2.
Bonus catch: getHotDeals rank-CASE referenced the OLD 9-stage pipeline
names ('completed', 'contract_signed', 'contract_sent', 'deposit_10pct',
'eoi_signed', 'eoi_sent', 'in_communication', 'details_sent'). Every
row hit the ELSE 0 branch under the new 7-stage model, collapsing
ordering to updatedAt only — the widget silently stopped surfacing
"closest to closing". Rebuilt the rank ladder against the current
canonical stages (enquiry → ... → contract).
Tests: 2 unit tests assert the predicate's compiled SQL contains
"archived_at" IS NULL + "outcome" IS NULL, and never the legacy 'won'
literal.
Remaining sweep targets queued for the next commit:
- client-archive-dossier.service.ts
- client-restore.service.ts
- client-archive.service.ts
- reminders.service.ts
- berths.service.ts (recommender feasibility)
- interests.service.ts
- report-generators.ts
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:53:58 +02:00
|
|
|
.where(activeInterestsWhere(portId));
|
Initial commit: Port Nimara CRM (Layers 0-4)
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>
2026-03-26 11:52:51 +01:00
|
|
|
|
2026-05-20 15:56:11 +02:00
|
|
|
// Build stageBreakdown — gross value, weighted value, per-stage weight,
|
|
|
|
|
// and `dealsMissingPrice` (deals whose primary berth has no/zero price)
|
|
|
|
|
// all surface to callers. The dashboard tile shows a warning chip when
|
|
|
|
|
// any deals in a stage are missing a berth price so the $0 line item
|
|
|
|
|
// doesn't read as legitimate.
|
|
|
|
|
const stageMap: Record<
|
|
|
|
|
string,
|
|
|
|
|
{ count: number; grossValue: number; weightedValue: number; dealsMissingPrice: number }
|
|
|
|
|
> = {};
|
Initial commit: Port Nimara CRM (Layers 0-4)
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>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
for (const row of interestRows) {
|
|
|
|
|
const stage = row.pipelineStage ?? 'open';
|
|
|
|
|
const price = row.berthPrice ? parseFloat(String(row.berthPrice)) : 0;
|
|
|
|
|
const weight = weights[stage] ?? 0;
|
|
|
|
|
const weighted = price * weight;
|
|
|
|
|
|
|
|
|
|
if (!stageMap[stage]) {
|
2026-05-20 15:56:11 +02:00
|
|
|
stageMap[stage] = { count: 0, grossValue: 0, weightedValue: 0, dealsMissingPrice: 0 };
|
Initial commit: Port Nimara CRM (Layers 0-4)
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>
2026-03-26 11:52:51 +01:00
|
|
|
}
|
|
|
|
|
stageMap[stage]!.count += 1;
|
2026-05-20 15:56:11 +02:00
|
|
|
stageMap[stage]!.grossValue += price;
|
Initial commit: Port Nimara CRM (Layers 0-4)
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>
2026-03-26 11:52:51 +01:00
|
|
|
stageMap[stage]!.weightedValue += weighted;
|
2026-05-20 15:56:11 +02:00
|
|
|
if (!(price > 0)) stageMap[stage]!.dealsMissingPrice += 1;
|
Initial commit: Port Nimara CRM (Layers 0-4)
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>
2026-03-26 11:52:51 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const stageBreakdown = PIPELINE_STAGES.map((stage) => ({
|
|
|
|
|
stage,
|
|
|
|
|
count: stageMap[stage]?.count ?? 0,
|
2026-05-20 15:56:11 +02:00
|
|
|
grossValue: stageMap[stage]?.grossValue ?? 0,
|
Initial commit: Port Nimara CRM (Layers 0-4)
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>
2026-03-26 11:52:51 +01:00
|
|
|
weightedValue: stageMap[stage]?.weightedValue ?? 0,
|
2026-05-20 15:56:11 +02:00
|
|
|
weight: weights[stage] ?? 0,
|
|
|
|
|
dealsMissingPrice: stageMap[stage]?.dealsMissingPrice ?? 0,
|
Initial commit: Port Nimara CRM (Layers 0-4)
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>
2026-03-26 11:52:51 +01:00
|
|
|
}));
|
|
|
|
|
|
2026-05-20 15:56:11 +02:00
|
|
|
const totalGrossValue = stageBreakdown.reduce((acc, s) => acc + s.grossValue, 0);
|
refactor(sales): consolidate pipeline stages + wire EOI auto-advance
The 8→9 stage refresh from earlier today only updated constants.ts and the DB —
20 component/service files still hardcoded the old enum, leaving labels blank,
filter dropdowns wrong, kanban columns mismatched, and the analytics funnel
silently dropping new-stage rows. The platform also never advanced
pipelineStage on EOI lifecycle events: documents.service.ts wrote eoiStatus
but left the user-visible stage stuck.
This commit closes both gaps:
1. Single source of truth in src/lib/constants.ts — adds STAGE_LABELS,
STAGE_BADGE, STAGE_DOT, STAGE_WEIGHTS, STAGE_TRANSITIONS plus
stageLabel / stageBadgeClass / stageDotClass / safeStage /
canTransitionStage helpers. components/clients/pipeline-constants.ts
becomes a re-export shim so existing imports keep working.
2. 18 stale-enum surfaces migrated — interest list (table, card, filters,
form, stage picker), pipeline board, client card, berth interests tab,
portal client interests page, dashboard pipeline / funnel / revenue-
forecast charts, settings pipeline_weights default, dashboard.service
weights, analytics.service funnel stages, alert-rules stale-interest
filter, interest-scoring stage rank.
3. Documents tab wired into interest detail — replaced the placeholder in
interest-tabs.tsx with InterestDocumentsTab + InterestFilesTab so the
EOI launcher is back where salespeople work.
4. Auto-advance — new advanceStageIfBehind() in interests.service.ts
(forward-only, no-op if interest is already past the target). Called
from documents.service.ts on send (→ eoi_sent), Documenso completed
webhook (→ eoi_signed), and manual signed-EOI upload (→ eoi_signed).
5. Transition guard — canTransitionStage() blocks egregious skips
(e.g. completed → open, open → contract_signed). Enforced in
changeInterestStage before the DB write.
Tests updated to reflect the 9-stage model. tsc clean, vitest 832/832,
ESLint clean on every file touched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:33:53 +02:00
|
|
|
const totalWeightedValue = stageBreakdown.reduce((acc, s) => acc + s.weightedValue, 0);
|
Initial commit: Port Nimara CRM (Layers 0-4)
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>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
return {
|
2026-05-20 15:56:11 +02:00
|
|
|
totalGrossValue,
|
Initial commit: Port Nimara CRM (Layers 0-4)
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>
2026-03-26 11:52:51 +01:00
|
|
|
totalWeightedValue,
|
|
|
|
|
stageBreakdown,
|
|
|
|
|
weightsSource,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 14:50:58 +02:00
|
|
|
// ─── Compact widget queries ───────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Berth status split for the donut widget. Returns counts plus the total
|
|
|
|
|
* so the chart can show "12 of 47 sold" alongside the segment percentage.
|
|
|
|
|
*/
|
|
|
|
|
export async function getBerthStatusDistribution(portId: string) {
|
|
|
|
|
const rows = await db
|
|
|
|
|
.select({ status: berths.status, c: sql<number>`count(*)::int` })
|
|
|
|
|
.from(berths)
|
fix(P1): soft-archive berths instead of hard-delete — F5
Pre-audit, DELETE /api/v1/berths/[id] called `db.delete()` which
permanently dropped the row, cascade-vanished `interest_berths` links,
broke historical audit references, and could 404 the public feed mid-
customer-inquiry. The `berths.archived_at` column existed in the schema
but was never written.
Changes:
- `archiveBerth(id, portId, { reason }, meta)` is the new canonical
soft-archive. Requires a reason (min 5 chars). Blocks when an
active interest still depends on the berth (forces the rep to
resolve the deal first). Audit-logs the old status + reason.
- `restoreBerth(...)` reverses it.
- DELETE route now accepts `{ reason }` and routes to archiveBerth.
- New POST /api/v1/berths/[id]/restore.
- `getBerthOptions` + dashboard occupancy / status-distribution
queries gain `isNull(berths.archivedAt)` so archived moorings
don't show up in pickers or skew metrics.
- Legacy `deleteBerth(...)` kept as a thin wrapper around archiveBerth
so import sites we haven't migrated still work — labeled @deprecated.
Verified live:
- DELETE w/o reason → 400 (validation)
- DELETE w/ "x" → 400 "Reason must be ≥ 5 characters"
- DELETE w/ proper reason → 204, row archived, reason persisted
- DELETE twice → 409 "Berth is already archived"
- POST /restore → 204, archived_at cleared
Follow-up (deferred): apply isNull(archivedAt) to recommendations.ts,
alert-rules.ts, portal.service.ts, report-generators.ts, berth-rules-
engine.ts. The current set covers the visible surfaces; the rest are
secondary aggregators.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:49:43 +02:00
|
|
|
.where(and(eq(berths.portId, portId), isNull(berths.archivedAt)))
|
2026-05-12 14:50:58 +02:00
|
|
|
.groupBy(berths.status);
|
|
|
|
|
|
|
|
|
|
const counts: Record<string, number> = {};
|
|
|
|
|
for (const r of rows) counts[r.status] = r.c;
|
|
|
|
|
const total = Object.values(counts).reduce((a, b) => a + b, 0);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
total,
|
|
|
|
|
available: counts['available'] ?? 0,
|
|
|
|
|
underOffer: counts['under_offer'] ?? 0,
|
|
|
|
|
sold: counts['sold'] ?? 0,
|
|
|
|
|
maintenance: counts['maintenance'] ?? 0,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Top 5 active interests closest to closing — ranked by pipeline stage
|
|
|
|
|
* (further = closer to closing) with most-recent activity as a
|
|
|
|
|
* tiebreaker. Surfaces the deals reps should actually be chasing on the
|
|
|
|
|
* dashboard without making them open the pipeline board.
|
|
|
|
|
*/
|
|
|
|
|
export async function getHotDeals(portId: string, limit = 5) {
|
feat(active-interest): canonical predicate + fix stale getHotDeals rank
Extract activeInterestsWhere(portId) as the single source of truth for
"active interest" SQL filtering: scoped + archived_at IS NULL + outcome
IS NULL. Won deals are now CLOSED, not active — pre-2026-05-14 the
dashboard used a permissive `outcome IS NULL OR outcome = 'won'` that
double-counted won revenue against the in-flight pipeline.
Locked in PRE-DEPLOY-PLAN § 1.1.2.
Bonus catch: getHotDeals rank-CASE referenced the OLD 9-stage pipeline
names ('completed', 'contract_signed', 'contract_sent', 'deposit_10pct',
'eoi_signed', 'eoi_sent', 'in_communication', 'details_sent'). Every
row hit the ELSE 0 branch under the new 7-stage model, collapsing
ordering to updatedAt only — the widget silently stopped surfacing
"closest to closing". Rebuilt the rank ladder against the current
canonical stages (enquiry → ... → contract).
Tests: 2 unit tests assert the predicate's compiled SQL contains
"archived_at" IS NULL + "outcome" IS NULL, and never the legacy 'won'
literal.
Remaining sweep targets queued for the next commit:
- client-archive-dossier.service.ts
- client-restore.service.ts
- client-archive.service.ts
- reminders.service.ts
- berths.service.ts (recommender feasibility)
- interests.service.ts
- report-generators.ts
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:53:58 +02:00
|
|
|
// Stage rank: bigger = closer to closing. Mirrors the 7-stage pipeline
|
|
|
|
|
// shipped 2026-05-14 (pipeline-refactor wave). Nurturing is a holding
|
|
|
|
|
// pen below qualified — supply-constrained ports flip deals there when
|
|
|
|
|
// they can't progress. Won/lost/cancelled outcomes are filtered out via
|
|
|
|
|
// `outcome IS NULL` below, so they don't need a rank slot.
|
2026-05-12 14:50:58 +02:00
|
|
|
const rank = sql<number>`CASE ${interests.pipelineStage}
|
feat(active-interest): canonical predicate + fix stale getHotDeals rank
Extract activeInterestsWhere(portId) as the single source of truth for
"active interest" SQL filtering: scoped + archived_at IS NULL + outcome
IS NULL. Won deals are now CLOSED, not active — pre-2026-05-14 the
dashboard used a permissive `outcome IS NULL OR outcome = 'won'` that
double-counted won revenue against the in-flight pipeline.
Locked in PRE-DEPLOY-PLAN § 1.1.2.
Bonus catch: getHotDeals rank-CASE referenced the OLD 9-stage pipeline
names ('completed', 'contract_signed', 'contract_sent', 'deposit_10pct',
'eoi_signed', 'eoi_sent', 'in_communication', 'details_sent'). Every
row hit the ELSE 0 branch under the new 7-stage model, collapsing
ordering to updatedAt only — the widget silently stopped surfacing
"closest to closing". Rebuilt the rank ladder against the current
canonical stages (enquiry → ... → contract).
Tests: 2 unit tests assert the predicate's compiled SQL contains
"archived_at" IS NULL + "outcome" IS NULL, and never the legacy 'won'
literal.
Remaining sweep targets queued for the next commit:
- client-archive-dossier.service.ts
- client-restore.service.ts
- client-archive.service.ts
- reminders.service.ts
- berths.service.ts (recommender feasibility)
- interests.service.ts
- report-generators.ts
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:53:58 +02:00
|
|
|
WHEN 'contract' THEN 7
|
|
|
|
|
WHEN 'deposit_paid' THEN 6
|
|
|
|
|
WHEN 'reservation' THEN 5
|
|
|
|
|
WHEN 'eoi' THEN 4
|
|
|
|
|
WHEN 'qualified' THEN 3
|
|
|
|
|
WHEN 'nurturing' THEN 2
|
|
|
|
|
WHEN 'enquiry' THEN 1
|
2026-05-12 14:50:58 +02:00
|
|
|
ELSE 0
|
|
|
|
|
END`;
|
|
|
|
|
|
|
|
|
|
const rows = await db
|
|
|
|
|
.select({
|
|
|
|
|
id: interests.id,
|
|
|
|
|
stage: interests.pipelineStage,
|
|
|
|
|
clientName: clients.fullName,
|
|
|
|
|
mooring: berths.mooringNumber,
|
|
|
|
|
lastContact: interests.dateLastContact,
|
|
|
|
|
updatedAt: interests.updatedAt,
|
|
|
|
|
rank,
|
|
|
|
|
})
|
|
|
|
|
.from(interests)
|
|
|
|
|
.innerJoin(clients, eq(interests.clientId, clients.id))
|
|
|
|
|
.leftJoin(
|
|
|
|
|
interestBerths,
|
|
|
|
|
and(eq(interestBerths.interestId, interests.id), eq(interestBerths.isPrimary, true)),
|
|
|
|
|
)
|
|
|
|
|
.leftJoin(berths, eq(interestBerths.berthId, berths.id))
|
feat(active-interest): canonical predicate + fix stale getHotDeals rank
Extract activeInterestsWhere(portId) as the single source of truth for
"active interest" SQL filtering: scoped + archived_at IS NULL + outcome
IS NULL. Won deals are now CLOSED, not active — pre-2026-05-14 the
dashboard used a permissive `outcome IS NULL OR outcome = 'won'` that
double-counted won revenue against the in-flight pipeline.
Locked in PRE-DEPLOY-PLAN § 1.1.2.
Bonus catch: getHotDeals rank-CASE referenced the OLD 9-stage pipeline
names ('completed', 'contract_signed', 'contract_sent', 'deposit_10pct',
'eoi_signed', 'eoi_sent', 'in_communication', 'details_sent'). Every
row hit the ELSE 0 branch under the new 7-stage model, collapsing
ordering to updatedAt only — the widget silently stopped surfacing
"closest to closing". Rebuilt the rank ladder against the current
canonical stages (enquiry → ... → contract).
Tests: 2 unit tests assert the predicate's compiled SQL contains
"archived_at" IS NULL + "outcome" IS NULL, and never the legacy 'won'
literal.
Remaining sweep targets queued for the next commit:
- client-archive-dossier.service.ts
- client-restore.service.ts
- client-archive.service.ts
- reminders.service.ts
- berths.service.ts (recommender feasibility)
- interests.service.ts
- report-generators.ts
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:53:58 +02:00
|
|
|
.where(activeInterestsWhere(portId))
|
2026-05-12 14:50:58 +02:00
|
|
|
.orderBy(desc(rank), desc(interests.updatedAt))
|
|
|
|
|
.limit(limit);
|
|
|
|
|
|
|
|
|
|
return rows.map((r) => ({
|
|
|
|
|
id: r.id,
|
|
|
|
|
stage: r.stage,
|
|
|
|
|
clientName: r.clientName,
|
|
|
|
|
mooringNumber: r.mooring,
|
|
|
|
|
lastContact: r.lastContact ? r.lastContact.toISOString() : null,
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Source-conversion breakdown for the marketing widget. Returns per-
|
|
|
|
|
* source totals (active + won + lost) and a derived conversion rate so
|
|
|
|
|
* reps see which channels deliver buyers vs tire-kickers — orthogonal
|
|
|
|
|
* to the existing "lead source attribution" chart which only counts
|
|
|
|
|
* inbound volume.
|
|
|
|
|
*/
|
|
|
|
|
export async function getSourceConversion(portId: string) {
|
|
|
|
|
const rows = await db
|
|
|
|
|
.select({
|
|
|
|
|
source: interests.source,
|
|
|
|
|
total: sql<number>`count(*)::int`,
|
|
|
|
|
won: sql<number>`sum(case when ${interests.outcome} = 'won' then 1 else 0 end)::int`,
|
|
|
|
|
lost: sql<number>`sum(case when ${interests.outcome} = 'lost' then 1 else 0 end)::int`,
|
|
|
|
|
})
|
|
|
|
|
.from(interests)
|
|
|
|
|
.where(and(eq(interests.portId, portId), isNull(interests.archivedAt)))
|
|
|
|
|
.groupBy(interests.source);
|
|
|
|
|
|
|
|
|
|
return rows
|
|
|
|
|
.filter((r) => r.source)
|
|
|
|
|
.map((r) => ({
|
|
|
|
|
source: r.source!,
|
|
|
|
|
total: r.total,
|
|
|
|
|
won: r.won,
|
|
|
|
|
lost: r.lost,
|
|
|
|
|
conversionRate: r.total > 0 ? r.won / r.total : 0,
|
|
|
|
|
}))
|
|
|
|
|
.sort((a, b) => b.total - a.total);
|
|
|
|
|
}
|
|
|
|
|
|
Initial commit: Port Nimara CRM (Layers 0-4)
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>
2026-03-26 11:52:51 +01:00
|
|
|
// ─── Recent Activity ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function getRecentActivity(portId: string, limit = 20) {
|
|
|
|
|
const rows = await db
|
|
|
|
|
.select({
|
|
|
|
|
id: auditLogs.id,
|
|
|
|
|
action: auditLogs.action,
|
|
|
|
|
entityType: auditLogs.entityType,
|
|
|
|
|
entityId: auditLogs.entityId,
|
|
|
|
|
userId: auditLogs.userId,
|
2026-05-12 14:50:58 +02:00
|
|
|
fieldChanged: auditLogs.fieldChanged,
|
|
|
|
|
oldValue: auditLogs.oldValue,
|
|
|
|
|
newValue: auditLogs.newValue,
|
Initial commit: Port Nimara CRM (Layers 0-4)
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>
2026-03-26 11:52:51 +01:00
|
|
|
metadata: auditLogs.metadata,
|
|
|
|
|
createdAt: auditLogs.createdAt,
|
|
|
|
|
})
|
|
|
|
|
.from(auditLogs)
|
|
|
|
|
.where(eq(auditLogs.portId, portId))
|
|
|
|
|
.orderBy(desc(auditLogs.createdAt))
|
|
|
|
|
.limit(limit);
|
|
|
|
|
|
2026-05-12 14:50:58 +02:00
|
|
|
// Resolve a human label per row (client name, yacht name, invoice number,
|
|
|
|
|
// …). The dashboard widget previously rendered the bare UUID prefix which
|
|
|
|
|
// told reps nothing about which entity was touched. We batch one SELECT
|
|
|
|
|
// per entityType, capping at the row set's natural size (<= `limit`).
|
|
|
|
|
const byType = new Map<string, Set<string>>();
|
|
|
|
|
for (const r of rows) {
|
|
|
|
|
if (!r.entityId) continue;
|
|
|
|
|
if (!byType.has(r.entityType)) byType.set(r.entityType, new Set());
|
|
|
|
|
byType.get(r.entityType)!.add(r.entityId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const labels = new Map<string, string>(); // `${type}:${id}` → label
|
|
|
|
|
|
|
|
|
|
async function loadLabels<T extends { id: string }>(
|
|
|
|
|
type: string,
|
|
|
|
|
fetcher: (ids: string[]) => Promise<T[]>,
|
|
|
|
|
pick: (row: T) => string,
|
|
|
|
|
) {
|
|
|
|
|
const ids = Array.from(byType.get(type) ?? []);
|
|
|
|
|
if (ids.length === 0) return;
|
|
|
|
|
const fetched = await fetcher(ids);
|
|
|
|
|
for (const row of fetched) labels.set(`${type}:${row.id}`, pick(row));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await Promise.all([
|
|
|
|
|
loadLabels(
|
|
|
|
|
'client',
|
|
|
|
|
(ids) =>
|
|
|
|
|
db
|
|
|
|
|
.select({ id: clients.id, name: clients.fullName })
|
|
|
|
|
.from(clients)
|
|
|
|
|
.where(and(eq(clients.portId, portId), inArray(clients.id, ids))),
|
|
|
|
|
(r) => r.name,
|
|
|
|
|
),
|
|
|
|
|
loadLabels(
|
|
|
|
|
'yacht',
|
|
|
|
|
(ids) =>
|
|
|
|
|
db
|
|
|
|
|
.select({ id: yachts.id, name: yachts.name })
|
|
|
|
|
.from(yachts)
|
|
|
|
|
.where(and(eq(yachts.portId, portId), inArray(yachts.id, ids))),
|
|
|
|
|
(r) => r.name,
|
|
|
|
|
),
|
|
|
|
|
loadLabels(
|
|
|
|
|
'company',
|
|
|
|
|
(ids) =>
|
|
|
|
|
db
|
|
|
|
|
.select({ id: companies.id, name: companies.name })
|
|
|
|
|
.from(companies)
|
|
|
|
|
.where(and(eq(companies.portId, portId), inArray(companies.id, ids))),
|
|
|
|
|
(r) => r.name,
|
|
|
|
|
),
|
|
|
|
|
loadLabels(
|
|
|
|
|
'interest',
|
|
|
|
|
(ids) =>
|
|
|
|
|
db
|
|
|
|
|
.select({ id: interests.id, clientName: clients.fullName })
|
|
|
|
|
.from(interests)
|
|
|
|
|
.innerJoin(clients, eq(interests.clientId, clients.id))
|
|
|
|
|
.where(and(eq(interests.portId, portId), inArray(interests.id, ids))),
|
|
|
|
|
(r) => r.clientName,
|
|
|
|
|
),
|
|
|
|
|
loadLabels(
|
|
|
|
|
'berth',
|
|
|
|
|
(ids) =>
|
|
|
|
|
db
|
|
|
|
|
.select({ id: berths.id, mooring: berths.mooringNumber })
|
|
|
|
|
.from(berths)
|
|
|
|
|
.where(and(eq(berths.portId, portId), inArray(berths.id, ids))),
|
|
|
|
|
(r) => `Berth ${r.mooring}`,
|
|
|
|
|
),
|
|
|
|
|
loadLabels(
|
|
|
|
|
'invoice',
|
|
|
|
|
(ids) =>
|
|
|
|
|
db
|
|
|
|
|
.select({ id: invoices.id, num: invoices.invoiceNumber })
|
|
|
|
|
.from(invoices)
|
|
|
|
|
.where(and(eq(invoices.portId, portId), inArray(invoices.id, ids))),
|
|
|
|
|
(r) => r.num,
|
|
|
|
|
),
|
|
|
|
|
loadLabels(
|
|
|
|
|
'expense',
|
|
|
|
|
(ids) =>
|
|
|
|
|
db
|
|
|
|
|
.select({
|
|
|
|
|
id: expenses.id,
|
|
|
|
|
desc: expenses.description,
|
|
|
|
|
vendor: expenses.establishmentName,
|
|
|
|
|
})
|
|
|
|
|
.from(expenses)
|
|
|
|
|
.where(and(eq(expenses.portId, portId), inArray(expenses.id, ids))),
|
|
|
|
|
(r) => r.desc ?? r.vendor ?? 'Expense',
|
|
|
|
|
),
|
|
|
|
|
loadLabels(
|
|
|
|
|
'document',
|
|
|
|
|
(ids) =>
|
|
|
|
|
db
|
|
|
|
|
.select({ id: documents.id, title: documents.title })
|
|
|
|
|
.from(documents)
|
|
|
|
|
.where(and(eq(documents.portId, portId), inArray(documents.id, ids))),
|
|
|
|
|
(r) => r.title,
|
|
|
|
|
),
|
|
|
|
|
loadLabels(
|
|
|
|
|
'reminder',
|
|
|
|
|
(ids) =>
|
|
|
|
|
db
|
|
|
|
|
.select({ id: reminders.id, title: reminders.title })
|
|
|
|
|
.from(reminders)
|
|
|
|
|
.where(and(eq(reminders.portId, portId), inArray(reminders.id, ids))),
|
|
|
|
|
(r) => r.title,
|
|
|
|
|
),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return rows.map((r) => ({
|
|
|
|
|
...r,
|
|
|
|
|
label: r.entityId ? (labels.get(`${r.entityType}:${r.entityId}`) ?? null) : null,
|
|
|
|
|
}));
|
Initial commit: Port Nimara CRM (Layers 0-4)
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>
2026-03-26 11:52:51 +01:00
|
|
|
}
|