// 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 }; } // Earned P&L on a COST-TO-COST percentage-of-completion basis (the construction-accounting standard): // for each job the earned fraction = cost incurred / projected total cost (committedCost), so revenue // and commission are recognized at the job's TRUE margin. This avoids the distortion of comparing // work-%-recognized revenue against front-loaded cost (which made gross profit spuriously thin/negative // mid-project). A genuinely over-budget job (committed > budget) still earns < its cost → real loss. // `received` is also exposed for any cash-basis view that wants money actually collected. export function revenueSummary(projects = []) { let revenue = 0, cost = 0, commissions = 0, received = 0; for (const p of projects) { const budget = Number(p.approvedBudget ?? p.budget ?? 0); const ac = Number(p.actualCost ?? p.spent ?? 0); const committed = Number(p.committedCost ?? ac) || ac; // earned fraction: cost-to-cost (capped at 1); fall back to work-% only if no committed cost exists. const pct = committed > 0 ? Math.min(ac / committed, 1) : Math.min(Math.max(Number(p.completionPercentage || 0) / 100, 0), 1); revenue += Math.round(pct * budget); cost += ac; commissions += Math.round(pct * Number(p.commission || 0)); received += sum((p.paymentSchedule || []).filter((ps) => ps.status === 'paid'), (ps) => ps.amount); } return { revenue, received, 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, }; } // Owner Financial-Overview metrics, each carrying the underlying line items so the dashboard // cards can drill down to the real records. Pass `today` (ISO date) to flag payouts due soon. // - collected: cash received from clients (paid paymentSchedule entries) // - outstandingAR: client money still owed (unpaid paymentSchedule entries) // - pendingPayouts: money owed to vendors (pending invoices); dueSoon = within 7 days of `today` // - vendorSpend: cash paid out to vendors (paid invoices) export function financialOverview(projects = [], today) { const now = today ? new Date(today) : null; const within7 = (d) => { if (!now || !d) return false; const diff = (new Date(d) - now) / 86400000; return diff >= 0 && diff <= 7; }; const collected = { total: 0, items: [] }; const outstandingAR = { total: 0, count: 0, items: [] }; const pendingPayouts = { total: 0, dueSoonCount: 0, dueSoonTotal: 0, items: [] }; const vendorSpend = { total: 0, items: [] }; for (const p of projects) { const project = p.customerName || p.projectType || p.id; for (const ps of p.paymentSchedule || []) { if (ps.status === 'paid') { collected.total += ps.amount || 0; collected.items.push({ projectId: p.id, project, label: ps.milestone, amount: ps.amount || 0, date: ps.paidDate || ps.dueDate }); } else { outstandingAR.total += ps.amount || 0; outstandingAR.count += 1; outstandingAR.items.push({ projectId: p.id, project, label: ps.milestone, amount: ps.amount || 0, dueDate: ps.dueDate }); } } for (const inv of p.invoices || []) { if (inv.status === 'pending') { pendingPayouts.total += inv.amount || 0; pendingPayouts.items.push({ projectId: p.id, project, label: inv.submittedBy, amount: inv.amount || 0, dueDate: inv.dueDate }); if (within7(inv.dueDate)) { pendingPayouts.dueSoonCount += 1; pendingPayouts.dueSoonTotal += inv.amount || 0; } } else if (inv.status === 'paid') { vendorSpend.total += inv.amount || 0; vendorSpend.items.push({ projectId: p.id, project, label: inv.submittedBy, amount: inv.amount || 0, date: inv.datePaid }); } } } return { collected, outstandingAR, pendingPayouts, vendorSpend }; } // Top performers leaderboard — credits each project to its closer (canvasser/sales rep on the team), // ranked by sales (Σ contract value). Returns [{ id, name, deals, sales, commission }]. export function repLeaderboard(projects = [], resolvePerson) { const by = {}; for (const p of projects) { const members = p.teamMembers || []; const closer = members.find((t) => /Canvasser|Sales|Estimator/i.test(t.jobRole || '')) || members.find((t) => /FIELD_AGENT|SALES/i.test(t.orgRole || '')); const id = closer?.userId || p.salesRepId; if (!id) continue; const name = closer?.name || resolvePerson?.(id)?.name || id; by[id] = by[id] || { id, name, deals: 0, sales: 0, commission: 0 }; by[id].deals += 1; by[id].sales += Number(p.approvedBudget ?? p.budget ?? 0); by[id].commission += Number(p.commission || 0); } return Object.values(by).sort((a, b) => b.sales - a.sales); } // Projects a subcontractor is involved in — union of (a) projects listing them in subcontractorIds // and (b) projects their tasks reference. Returns light summaries for a "My Projects" list. export function subcontractorProjects(projects = [], tasks = [], subId) { const fromTasks = new Set(tasks.filter((t) => t.subcontractorId === subId && t.projectId).map((t) => t.projectId)); return projects .filter((p) => (p.subcontractorIds || []).includes(subId) || fromTasks.has(p.id)) .map((p) => { const myTasks = tasks.filter((t) => t.subcontractorId === subId && t.projectId === p.id); return { id: p.id, address: p.address, customerName: p.customerName, projectType: p.projectType, status: p.status, phase: p.phase, completionPercentage: p.completionPercentage, taskCount: myTasks.length, openTaskCount: myTasks.filter((t) => !['Completed', 'Cancelled'].includes(t.status)).length, }; }); } // The PERMITTED SUBSET of a single project for a subcontractor. Deliberately omits owner-private // fields (budget, committedCost, margins, commission, other subs, all invoices, estimates, // changeOrders, risks). Includes the schedule (milestones) read-only + the sub's own tasks/payments. // This selector is the single guard that keeps owner-private data out of the subcontractor view. export function scopedProjectForSub(project, subId, tasks = []) { if (!project) return null; const myTasks = tasks.filter((t) => t.subcontractorId === subId && t.projectId === project.id); const sumFees = (t) => (t.fees || []).reduce((s, f) => s + (f.amount || 0), 0); const sumExpenses = (t) => (t.expenses || []).reduce((s, e) => s + (e.amount || 0), 0); const myEarnings = myTasks.reduce((s, t) => s + sumFees(t) + sumExpenses(t), 0); const paid = myTasks.filter((t) => t.paymentStatus === 'Paid').reduce((s, t) => s + sumFees(t) + sumExpenses(t), 0); return { id: project.id, address: project.address, customerName: project.customerName, projectType: project.projectType, status: project.status, phase: project.phase, lifecycleStage: project.lifecycleStage, completionPercentage: project.completionPercentage, startDate: project.startDate, endDate: project.endDate, // schedule (read-only) — names/dates/status only; no costs milestones: (project.milestones || []).map((m) => ({ id: m.id, name: m.name, dueDate: m.dueDate, status: m.status })), // the sub's own scope on this job myTasks: myTasks.map((t) => ({ id: t.id, title: t.title, status: t.status, dueDate: t.dueDate, category: t.category })), myPayments: { earnings: myEarnings, paid, unpaid: myEarnings - paid }, }; }