2026-03-26 12:06:18 +01:00
|
|
|
import { and, count, desc, eq, 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';
|
|
|
|
|
import { interests } from '@/lib/db/schema/interests';
|
|
|
|
|
import { berths } from '@/lib/db/schema/berths';
|
|
|
|
|
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';
|
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)
|
|
|
|
|
.where(and(eq(interests.portId, portId), isNull(interests.archivedAt)));
|
|
|
|
|
|
|
|
|
|
// Pipeline value: SUM berths.price via JOIN from non-archived interests with berthId
|
|
|
|
|
const pipelineRows = await db
|
|
|
|
|
.select({ price: berths.price })
|
|
|
|
|
.from(interests)
|
|
|
|
|
.innerJoin(berths, eq(interests.berthId, berths.id))
|
|
|
|
|
.where(
|
|
|
|
|
and(
|
|
|
|
|
eq(interests.portId, portId),
|
|
|
|
|
isNull(interests.archivedAt),
|
|
|
|
|
sql`${interests.berthId} IS NOT NULL`,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const pipelineValueUsd = pipelineRows.reduce((acc, row) => {
|
|
|
|
|
return acc + (row.price ? parseFloat(String(row.price)) : 0);
|
|
|
|
|
}, 0);
|
|
|
|
|
|
|
|
|
|
// Occupancy rate: (sold + under_offer) / total * 100
|
|
|
|
|
const allBerthsRows = await db
|
|
|
|
|
.select({ status: berths.status })
|
|
|
|
|
.from(berths)
|
|
|
|
|
.where(eq(berths.portId, portId));
|
|
|
|
|
|
|
|
|
|
const totalBerths = allBerthsRows.length;
|
|
|
|
|
const occupiedBerths = allBerthsRows.filter(
|
|
|
|
|
(b) => b.status === 'sold' || b.status === 'under_offer',
|
|
|
|
|
).length;
|
|
|
|
|
const occupancyRate = totalBerths > 0 ? (occupiedBerths / totalBerths) * 100 : 0;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
totalClients: totalClientsRow?.value ?? 0,
|
|
|
|
|
activeInterests: activeInterestsRow?.value ?? 0,
|
|
|
|
|
pipelineValueUsd,
|
|
|
|
|
occupancyRate,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Pipeline Counts ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function getPipelineCounts(portId: string) {
|
|
|
|
|
const rows = await db
|
|
|
|
|
.select({
|
|
|
|
|
stage: interests.pipelineStage,
|
|
|
|
|
count: sql<number>`count(*)::int`,
|
|
|
|
|
})
|
|
|
|
|
.from(interests)
|
|
|
|
|
.where(and(eq(interests.portId, portId), isNull(interests.archivedAt)))
|
|
|
|
|
.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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fetch all non-archived interests with a linked berth and its price
|
|
|
|
|
const interestRows = await db
|
|
|
|
|
.select({
|
|
|
|
|
id: interests.id,
|
|
|
|
|
pipelineStage: interests.pipelineStage,
|
|
|
|
|
berthPrice: berths.price,
|
|
|
|
|
})
|
|
|
|
|
.from(interests)
|
|
|
|
|
.innerJoin(berths, eq(interests.berthId, berths.id))
|
|
|
|
|
.where(
|
|
|
|
|
and(
|
|
|
|
|
eq(interests.portId, portId),
|
|
|
|
|
isNull(interests.archivedAt),
|
|
|
|
|
sql`${interests.berthId} IS NOT NULL`,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Build stageBreakdown
|
|
|
|
|
const stageMap: Record<string, { count: number; weightedValue: number }> = {};
|
|
|
|
|
|
|
|
|
|
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]) {
|
|
|
|
|
stageMap[stage] = { count: 0, weightedValue: 0 };
|
|
|
|
|
}
|
|
|
|
|
stageMap[stage]!.count += 1;
|
|
|
|
|
stageMap[stage]!.weightedValue += weighted;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const stageBreakdown = PIPELINE_STAGES.map((stage) => ({
|
|
|
|
|
stage,
|
|
|
|
|
count: stageMap[stage]?.count ?? 0,
|
|
|
|
|
weightedValue: stageMap[stage]?.weightedValue ?? 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 {
|
|
|
|
|
totalWeightedValue,
|
|
|
|
|
stageBreakdown,
|
|
|
|
|
weightsSource,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── 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,
|
|
|
|
|
metadata: auditLogs.metadata,
|
|
|
|
|
createdAt: auditLogs.createdAt,
|
|
|
|
|
})
|
|
|
|
|
.from(auditLogs)
|
|
|
|
|
.where(eq(auditLogs.portId, portId))
|
|
|
|
|
.orderBy(desc(auditLogs.createdAt))
|
|
|
|
|
.limit(limit);
|
|
|
|
|
|
|
|
|
|
return rows;
|
|
|
|
|
}
|