feat(analytics): add pure selectors for pipeline, revenue, commission, sub performance, storm

This commit is contained in:
Satyam Rastogi
2026-05-29 16:46:57 +05:30
parent 4857033994
commit 8f5a32179d
+94
View File
@@ -0,0 +1,94 @@
// src/data/selectors.js
// Pure analytics derived from the live store arrays. No React; no imports from mockStore.
// Callers pass the arrays they already have from useMockStore(); these functions never read context.
// This single-source-of-truth is what guarantees dashboards can never drift from the records:
// every KPI is computed here, and the same function feeds every screen + the reconciliation checks.
// Open pre-sale pipeline stages, in funnel order. Won/in-progress/complete are projects, not pipeline.
export const PRESALE_STAGES = ['kc_new', 'kc_contacted', 'kc_appt', 'kc_estimate'];
const sum = (arr, f) => arr.reduce((s, x) => s + (f(x) || 0), 0);
const isPresale = (l) => PRESALE_STAGES.includes(l.columnId);
// Funnel: count + $ value at each open stage.
export function pipelineFunnel(kanbanLeads = []) {
return PRESALE_STAGES.map((stage) => {
const leads = kanbanLeads.filter((l) => l.columnId === stage);
return { stage, count: leads.length, value: sum(leads, (l) => l.value) };
});
}
// Total open pipeline $ = Σ value of pre-sale leads.
export function pipelineValue(kanbanLeads = []) {
return sum(kanbanLeads.filter(isPresale), (l) => l.value);
}
// Win rate % over DECIDED leads: won (has projectId) vs lost (kc_lost | outcome 'lost').
export function winRate(kanbanLeads = []) {
const won = kanbanLeads.filter((l) => !!l.projectId).length;
const lost = kanbanLeads.filter((l) => l.columnId === 'kc_lost' || l.outcome === 'lost').length;
const decided = won + lost;
return { won, lost, decided, rate: decided ? Math.round((won / decided) * 100) : 0 };
}
// Revenue recognized = Σ client payments received (paymentSchedule status 'paid'); cost = Σ actualCost.
export function revenueSummary(projects = []) {
const revenue = sum(projects, (p) =>
sum((p.paymentSchedule || []).filter((ps) => ps.status === 'paid'), (ps) => ps.amount));
const cost = sum(projects, (p) => Number(p.actualCost ?? p.spent ?? 0));
const commissions = sum(projects, (p) => Number(p.commission || 0));
return { revenue, cost, grossProfit: revenue - cost, commissions, netProfit: revenue - cost - commissions };
}
// Commission grouped by the project's sales rep. resolvePerson(id)->{name} optional, for display.
export function commissionByRep(projects = [], resolvePerson) {
const byRep = {};
for (const p of projects) {
const repId = p.salesRepId
|| (p.teamMembers || []).find((t) => /Sales|Estimator/i.test(t.jobRole || ''))?.userId;
if (!repId) continue;
byRep[repId] = (byRep[repId] || 0) + Number(p.commission || 0);
}
return Object.entries(byRep)
.map(([repId, total]) => ({ repId, name: resolvePerson?.(repId)?.name || repId, total }))
.sort((a, b) => b.total - a.total);
}
// Subcontractor performance: project count + total fees + task count, per sub.
// tasks = MOCK_SUBCONTRACTOR_TASKS; resolvePerson optional for names.
export function subcontractorPerformance(projects = [], tasks = [], resolvePerson) {
const acc = {};
for (const p of projects) {
for (const subId of p.subcontractorIds || []) {
acc[subId] = acc[subId] || { subId, projects: 0, tasks: 0, fees: 0 };
acc[subId].projects += 1;
}
}
for (const t of tasks) {
const id = t.subcontractorId;
if (!id) continue;
acc[id] = acc[id] || { subId: id, projects: 0, tasks: 0, fees: 0 };
acc[id].tasks += 1;
acc[id].fees += sum(t.fees || [], (f) => f.amount);
}
return Object.values(acc)
.map((r) => ({ ...r, name: resolvePerson?.(r.subId)?.name || r.subId }))
.sort((a, b) => b.projects - a.projects);
}
// Storm attribution: share of pipeline value and recognized revenue traceable to storm-sourced leads.
export function stormAttribution(kanbanLeads = [], projects = []) {
const stormLeadIds = new Set(kanbanLeads.filter((l) => l.stormSource).map((l) => l.id));
const stormPipeline = sum(kanbanLeads.filter((l) => isPresale(l) && l.stormSource), (l) => l.value);
const totalPipeline = pipelineValue(kanbanLeads);
const stormProjects = projects.filter((p) => stormLeadIds.has(p.sourceLeadId));
const stormRevenue = revenueSummary(stormProjects).revenue;
const totalRevenue = revenueSummary(projects).revenue;
return {
stormLeadCount: stormLeadIds.size,
stormPipeline, totalPipeline,
pipelinePct: totalPipeline ? Math.round((stormPipeline / totalPipeline) * 100) : 0,
stormRevenue, totalRevenue,
revenuePct: totalRevenue ? Math.round((stormRevenue / totalRevenue) * 100) : 0,
};
}