Files
LynkedUpPro_CRM/src/data/selectors.js
T

222 lines
11 KiB
JavaScript

// 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 },
};
}