Files
pn-new-crm/src/lib/services/dashboard.service.ts

181 lines
6.1 KiB
TypeScript
Raw Normal View History

import { and, count, desc, eq, isNull, sql } from 'drizzle-orm';
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';
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;
feat(sales): EOI queue route + invoice→deposit auto-advance + won/lost outcomes Three independent strengthenings of the sales spine that the prior coherence sweep made it possible to do cleanly. 1. EOI queue page - Sidebar entry under Documents → "EOI queue". - Route /[port]/documents/eoi renders DocumentsHub with the existing eoi_queue tab pre-selected (filters in-flight EOIs only). - .gitignore: tightened root-only `eoi/` ignore so the documents/eoi route is no longer silently excluded. 2. Invoice ↔ deposit link - invoices.interestId (FK, ON DELETE SET NULL) + invoices.kind ('general' | 'deposit'). Indexed on (port_id, interest_id). - createInvoiceSchema requires interestId when kind === 'deposit'; the service validates the linked interest belongs to the same port before insert. - recordPayment auto-advances pipelineStage to deposit_10pct (via advanceStageIfBehind) when a paid invoice is kind=deposit and has an interestId. No-op if the interest is already further along. - "Create deposit invoice" link added to the Deposit milestone on the interest detail. Links to /invoices/new?interestId=…&kind=deposit; the form prefills the billing entity from the linked interest's client and shows a context banner. 3. Won / lost terminal outcomes - interests.outcome ('won' | 'lost_other_marina' | 'lost_unqualified' | 'lost_no_response' | 'cancelled') + outcomeReason text + outcomeAt timestamp. Indexed on (port_id, outcome). - setInterestOutcome / clearInterestOutcome services + POST/DELETE /api/v1/interests/:id/outcome endpoints (gated by change_stage permission). Setting an outcome moves the interest to `completed` in the same write; clearing reopens to `in_communication` (or a caller-specified stage). - Mark Won / Mark Lost icon buttons on the interest detail header, plus an outcome badge that replaces the stage pill once a terminal outcome is set, plus a Reopen button. - Funnel + dashboard math updated to exclude lost/cancelled outcomes from active calculations (KPIs.activeInterests, pipelineValueUsd, getPipelineCounts, computePipelineFunnel, getRevenueForecast). The funnel now also returns a `lost` summary so callers can surface leakage without polluting conversion percentages. Schema changes shipped via 0019_lazy_vampiro.sql; applied to dev DB manually via psql because drizzle-kit push hits a pre-existing zod parsing issue on the companies index. Dev server may need a restart to flush prepared-statement caches. tsc clean. vitest 832/832 pass. ESLint clean on every file touched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:01:33 +02:00
// "Active" = not archived AND not closed as lost/cancelled. Won interests are
// still counted because they represent revenue. Used everywhere KPIs say
// "active interests" or "pipeline value".
const isActiveInterest = sql`(${interests.outcome} IS NULL OR ${interests.outcome} = 'won')`;
// ─── 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(sales): EOI queue route + invoice→deposit auto-advance + won/lost outcomes Three independent strengthenings of the sales spine that the prior coherence sweep made it possible to do cleanly. 1. EOI queue page - Sidebar entry under Documents → "EOI queue". - Route /[port]/documents/eoi renders DocumentsHub with the existing eoi_queue tab pre-selected (filters in-flight EOIs only). - .gitignore: tightened root-only `eoi/` ignore so the documents/eoi route is no longer silently excluded. 2. Invoice ↔ deposit link - invoices.interestId (FK, ON DELETE SET NULL) + invoices.kind ('general' | 'deposit'). Indexed on (port_id, interest_id). - createInvoiceSchema requires interestId when kind === 'deposit'; the service validates the linked interest belongs to the same port before insert. - recordPayment auto-advances pipelineStage to deposit_10pct (via advanceStageIfBehind) when a paid invoice is kind=deposit and has an interestId. No-op if the interest is already further along. - "Create deposit invoice" link added to the Deposit milestone on the interest detail. Links to /invoices/new?interestId=…&kind=deposit; the form prefills the billing entity from the linked interest's client and shows a context banner. 3. Won / lost terminal outcomes - interests.outcome ('won' | 'lost_other_marina' | 'lost_unqualified' | 'lost_no_response' | 'cancelled') + outcomeReason text + outcomeAt timestamp. Indexed on (port_id, outcome). - setInterestOutcome / clearInterestOutcome services + POST/DELETE /api/v1/interests/:id/outcome endpoints (gated by change_stage permission). Setting an outcome moves the interest to `completed` in the same write; clearing reopens to `in_communication` (or a caller-specified stage). - Mark Won / Mark Lost icon buttons on the interest detail header, plus an outcome badge that replaces the stage pill once a terminal outcome is set, plus a Reopen button. - Funnel + dashboard math updated to exclude lost/cancelled outcomes from active calculations (KPIs.activeInterests, pipelineValueUsd, getPipelineCounts, computePipelineFunnel, getRevenueForecast). The funnel now also returns a `lost` summary so callers can surface leakage without polluting conversion percentages. Schema changes shipped via 0019_lazy_vampiro.sql; applied to dev DB manually via psql because drizzle-kit push hits a pre-existing zod parsing issue on the companies index. Dev server may need a restart to flush prepared-statement caches. tsc clean. vitest 832/832 pass. ESLint clean on every file touched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:01:33 +02:00
.where(and(eq(interests.portId, portId), isNull(interests.archivedAt), isActiveInterest));
// 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),
feat(sales): EOI queue route + invoice→deposit auto-advance + won/lost outcomes Three independent strengthenings of the sales spine that the prior coherence sweep made it possible to do cleanly. 1. EOI queue page - Sidebar entry under Documents → "EOI queue". - Route /[port]/documents/eoi renders DocumentsHub with the existing eoi_queue tab pre-selected (filters in-flight EOIs only). - .gitignore: tightened root-only `eoi/` ignore so the documents/eoi route is no longer silently excluded. 2. Invoice ↔ deposit link - invoices.interestId (FK, ON DELETE SET NULL) + invoices.kind ('general' | 'deposit'). Indexed on (port_id, interest_id). - createInvoiceSchema requires interestId when kind === 'deposit'; the service validates the linked interest belongs to the same port before insert. - recordPayment auto-advances pipelineStage to deposit_10pct (via advanceStageIfBehind) when a paid invoice is kind=deposit and has an interestId. No-op if the interest is already further along. - "Create deposit invoice" link added to the Deposit milestone on the interest detail. Links to /invoices/new?interestId=…&kind=deposit; the form prefills the billing entity from the linked interest's client and shows a context banner. 3. Won / lost terminal outcomes - interests.outcome ('won' | 'lost_other_marina' | 'lost_unqualified' | 'lost_no_response' | 'cancelled') + outcomeReason text + outcomeAt timestamp. Indexed on (port_id, outcome). - setInterestOutcome / clearInterestOutcome services + POST/DELETE /api/v1/interests/:id/outcome endpoints (gated by change_stage permission). Setting an outcome moves the interest to `completed` in the same write; clearing reopens to `in_communication` (or a caller-specified stage). - Mark Won / Mark Lost icon buttons on the interest detail header, plus an outcome badge that replaces the stage pill once a terminal outcome is set, plus a Reopen button. - Funnel + dashboard math updated to exclude lost/cancelled outcomes from active calculations (KPIs.activeInterests, pipelineValueUsd, getPipelineCounts, computePipelineFunnel, getRevenueForecast). The funnel now also returns a `lost` summary so callers can surface leakage without polluting conversion percentages. Schema changes shipped via 0019_lazy_vampiro.sql; applied to dev DB manually via psql because drizzle-kit push hits a pre-existing zod parsing issue on the companies index. Dev server may need a restart to flush prepared-statement caches. tsc clean. vitest 832/832 pass. ESLint clean on every file touched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:01:33 +02:00
isActiveInterest,
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)
feat(sales): EOI queue route + invoice→deposit auto-advance + won/lost outcomes Three independent strengthenings of the sales spine that the prior coherence sweep made it possible to do cleanly. 1. EOI queue page - Sidebar entry under Documents → "EOI queue". - Route /[port]/documents/eoi renders DocumentsHub with the existing eoi_queue tab pre-selected (filters in-flight EOIs only). - .gitignore: tightened root-only `eoi/` ignore so the documents/eoi route is no longer silently excluded. 2. Invoice ↔ deposit link - invoices.interestId (FK, ON DELETE SET NULL) + invoices.kind ('general' | 'deposit'). Indexed on (port_id, interest_id). - createInvoiceSchema requires interestId when kind === 'deposit'; the service validates the linked interest belongs to the same port before insert. - recordPayment auto-advances pipelineStage to deposit_10pct (via advanceStageIfBehind) when a paid invoice is kind=deposit and has an interestId. No-op if the interest is already further along. - "Create deposit invoice" link added to the Deposit milestone on the interest detail. Links to /invoices/new?interestId=…&kind=deposit; the form prefills the billing entity from the linked interest's client and shows a context banner. 3. Won / lost terminal outcomes - interests.outcome ('won' | 'lost_other_marina' | 'lost_unqualified' | 'lost_no_response' | 'cancelled') + outcomeReason text + outcomeAt timestamp. Indexed on (port_id, outcome). - setInterestOutcome / clearInterestOutcome services + POST/DELETE /api/v1/interests/:id/outcome endpoints (gated by change_stage permission). Setting an outcome moves the interest to `completed` in the same write; clearing reopens to `in_communication` (or a caller-specified stage). - Mark Won / Mark Lost icon buttons on the interest detail header, plus an outcome badge that replaces the stage pill once a terminal outcome is set, plus a Reopen button. - Funnel + dashboard math updated to exclude lost/cancelled outcomes from active calculations (KPIs.activeInterests, pipelineValueUsd, getPipelineCounts, computePipelineFunnel, getRevenueForecast). The funnel now also returns a `lost` summary so callers can surface leakage without polluting conversion percentages. Schema changes shipped via 0019_lazy_vampiro.sql; applied to dev DB manually via psql because drizzle-kit push hits a pre-existing zod parsing issue on the companies index. Dev server may need a restart to flush prepared-statement caches. tsc clean. vitest 832/832 pass. ESLint clean on every file touched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:01:33 +02:00
.where(and(eq(interests.portId, portId), isNull(interests.archivedAt), isActiveInterest))
.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)),
});
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
}
}
feat(sales): EOI queue route + invoice→deposit auto-advance + won/lost outcomes Three independent strengthenings of the sales spine that the prior coherence sweep made it possible to do cleanly. 1. EOI queue page - Sidebar entry under Documents → "EOI queue". - Route /[port]/documents/eoi renders DocumentsHub with the existing eoi_queue tab pre-selected (filters in-flight EOIs only). - .gitignore: tightened root-only `eoi/` ignore so the documents/eoi route is no longer silently excluded. 2. Invoice ↔ deposit link - invoices.interestId (FK, ON DELETE SET NULL) + invoices.kind ('general' | 'deposit'). Indexed on (port_id, interest_id). - createInvoiceSchema requires interestId when kind === 'deposit'; the service validates the linked interest belongs to the same port before insert. - recordPayment auto-advances pipelineStage to deposit_10pct (via advanceStageIfBehind) when a paid invoice is kind=deposit and has an interestId. No-op if the interest is already further along. - "Create deposit invoice" link added to the Deposit milestone on the interest detail. Links to /invoices/new?interestId=…&kind=deposit; the form prefills the billing entity from the linked interest's client and shows a context banner. 3. Won / lost terminal outcomes - interests.outcome ('won' | 'lost_other_marina' | 'lost_unqualified' | 'lost_no_response' | 'cancelled') + outcomeReason text + outcomeAt timestamp. Indexed on (port_id, outcome). - setInterestOutcome / clearInterestOutcome services + POST/DELETE /api/v1/interests/:id/outcome endpoints (gated by change_stage permission). Setting an outcome moves the interest to `completed` in the same write; clearing reopens to `in_communication` (or a caller-specified stage). - Mark Won / Mark Lost icon buttons on the interest detail header, plus an outcome badge that replaces the stage pill once a terminal outcome is set, plus a Reopen button. - Funnel + dashboard math updated to exclude lost/cancelled outcomes from active calculations (KPIs.activeInterests, pipelineValueUsd, getPipelineCounts, computePipelineFunnel, getRevenueForecast). The funnel now also returns a `lost` summary so callers can surface leakage without polluting conversion percentages. Schema changes shipped via 0019_lazy_vampiro.sql; applied to dev DB manually via psql because drizzle-kit push hits a pre-existing zod parsing issue on the companies index. Dev server may need a restart to flush prepared-statement caches. tsc clean. vitest 832/832 pass. ESLint clean on every file touched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:01:33 +02:00
// Forecast excludes lost/cancelled — only currently-active or won-out
// interests should affect the weighted pipeline value.
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),
feat(sales): EOI queue route + invoice→deposit auto-advance + won/lost outcomes Three independent strengthenings of the sales spine that the prior coherence sweep made it possible to do cleanly. 1. EOI queue page - Sidebar entry under Documents → "EOI queue". - Route /[port]/documents/eoi renders DocumentsHub with the existing eoi_queue tab pre-selected (filters in-flight EOIs only). - .gitignore: tightened root-only `eoi/` ignore so the documents/eoi route is no longer silently excluded. 2. Invoice ↔ deposit link - invoices.interestId (FK, ON DELETE SET NULL) + invoices.kind ('general' | 'deposit'). Indexed on (port_id, interest_id). - createInvoiceSchema requires interestId when kind === 'deposit'; the service validates the linked interest belongs to the same port before insert. - recordPayment auto-advances pipelineStage to deposit_10pct (via advanceStageIfBehind) when a paid invoice is kind=deposit and has an interestId. No-op if the interest is already further along. - "Create deposit invoice" link added to the Deposit milestone on the interest detail. Links to /invoices/new?interestId=…&kind=deposit; the form prefills the billing entity from the linked interest's client and shows a context banner. 3. Won / lost terminal outcomes - interests.outcome ('won' | 'lost_other_marina' | 'lost_unqualified' | 'lost_no_response' | 'cancelled') + outcomeReason text + outcomeAt timestamp. Indexed on (port_id, outcome). - setInterestOutcome / clearInterestOutcome services + POST/DELETE /api/v1/interests/:id/outcome endpoints (gated by change_stage permission). Setting an outcome moves the interest to `completed` in the same write; clearing reopens to `in_communication` (or a caller-specified stage). - Mark Won / Mark Lost icon buttons on the interest detail header, plus an outcome badge that replaces the stage pill once a terminal outcome is set, plus a Reopen button. - Funnel + dashboard math updated to exclude lost/cancelled outcomes from active calculations (KPIs.activeInterests, pipelineValueUsd, getPipelineCounts, computePipelineFunnel, getRevenueForecast). The funnel now also returns a `lost` summary so callers can surface leakage without polluting conversion percentages. Schema changes shipped via 0019_lazy_vampiro.sql; applied to dev DB manually via psql because drizzle-kit push hits a pre-existing zod parsing issue on the companies index. Dev server may need a restart to flush prepared-statement caches. tsc clean. vitest 832/832 pass. ESLint clean on every file touched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:01:33 +02:00
isActiveInterest,
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);
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;
}