diff --git a/src/data/selectors.js b/src/data/selectors.js new file mode 100644 index 0000000..2a6ad2a --- /dev/null +++ b/src/data/selectors.js @@ -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, + }; +}