diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index 1162e26..790d868 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useMemo } from 'react'; import { useMockStore } from '../data/mockStore'; import { useAuth } from '../context/AuthContext'; import { Link } from 'react-router-dom'; @@ -8,6 +8,7 @@ import { } from 'lucide-react'; import { SpotlightCard } from '../components/SpotlightCard'; import { config } from '../config/env'; +import { pipelineValue, pipelineFunnel, winRate, revenueSummary, stormAttribution } from '../data/selectors'; // New Widget Imports import { RoofConditionChart } from '../components/dashboard/RoofConditionChart'; @@ -21,10 +22,19 @@ import AnimatedNumber from '../components/AnimatedNumber'; import Loader from '../components/Loader'; const Dashboard = () => { - const { properties, meetings } = useMockStore(); + const { properties, meetings, projects, kanbanLeads, resolvePerson } = useMockStore(); const { user } = useAuth(); const [isLoading, setIsLoading] = useState(true); + // Company-wide analytics (admin sees ALL projects + ALL kanbanLeads — no owner scoping) + const analytics = useMemo(() => ({ + pipeline: pipelineValue(kanbanLeads), + funnel: pipelineFunnel(kanbanLeads), + win: winRate(kanbanLeads), + rev: revenueSummary(projects), + storm: stormAttribution(kanbanLeads, projects), + }), [kanbanLeads, projects]); + useEffect(() => { // Simulate initial data fetch const timer = setTimeout(() => setIsLoading(false), 800); @@ -155,9 +165,9 @@ const Dashboard = () => { {/* Top Metrics Grid */}
{ />
+ {/* --- COMPANY-WIDE ANALYTICS (ADMIN) --- */} + {user?.role === 'ADMIN' && ( +
+ {/* Section header */} +
+ +

Company-Wide Analytics

+ Admin View +
+ + {/* Row 1: Pipeline + Win Rate + Storm */} +
+ + {/* Open Pipeline */} + +
+

Open Pipeline

+ + {analytics.funnel.reduce((s, f) => s + f.count, 0)} leads + +
+
+ ${(analytics.pipeline / 1000).toFixed(0)}K +
+ {/* Mini funnel bars */} +
+ {analytics.funnel.map((f) => { + const STAGE_LABELS = { + kc_new: 'New', + kc_contacted: 'Contacted', + kc_appt: 'Appointment', + kc_estimate: 'Estimate', + }; + const maxCount = Math.max(...analytics.funnel.map(s => s.count), 1); + const pct = Math.round((f.count / maxCount) * 100); + return ( +
+ {STAGE_LABELS[f.stage]} +
+
+
+ {f.count} +
+ ); + })} +
+ + + {/* Win Rate */} + +

Win Rate

+
+ {analytics.win.rate} + % +
+
+
+
+
+ {analytics.win.won} Won + {analytics.win.decided} Decided + {analytics.win.lost} Lost +
+ + + {/* Storm Attribution */} + +

Storm-Attributed Revenue

+
+ {analytics.storm.revenuePct} + % +
+
+
+
+
+ {analytics.storm.stormLeadCount} Storm Leads + ${(analytics.storm.stormRevenue / 1000).toFixed(0)}K of ${(analytics.storm.totalRevenue / 1000).toFixed(0)}K +
+ +
+ + {/* Row 2: Revenue / Profit breakdown */} + +

P&L Summary

+
+
+

Revenue

+

${(analytics.rev.revenue / 1000).toFixed(0)}K

+
+
+

Gross Profit

+

= 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-500 dark:text-red-400'}`}> + ${(analytics.rev.grossProfit / 1000).toFixed(0)}K +

+
+
+

Commissions

+

${(analytics.rev.commissions / 1000).toFixed(0)}K

+
+
+

Net Profit

+

= 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-500 dark:text-red-400'}`}> + ${(analytics.rev.netProfit / 1000).toFixed(0)}K +

+
+
+
+
+ )} + {/* --- ANALYTICS ROW 1: Territory Health & Risk --- */}
{/* Roof Condition - Donut */} diff --git a/src/pages/owner/OwnerSnapshot.jsx b/src/pages/owner/OwnerSnapshot.jsx index 48e9e4b..6d9931d 100644 --- a/src/pages/owner/OwnerSnapshot.jsx +++ b/src/pages/owner/OwnerSnapshot.jsx @@ -2,6 +2,7 @@ import React, { useState, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { useAuth } from '../../context/AuthContext'; import { useMockStore } from '../../data/mockStore'; +import { pipelineValue, pipelineFunnel, winRate, revenueSummary, commissionByRep, stormAttribution } from '../../data/selectors'; import FinancialKPICards from '../../components/owner/FinancialKPICards'; import UrgentItemsPanel from '../../components/owner/UrgentItemsPanel'; import FinancialDetailsModal from '../../components/owner/FinancialDetailsModal'; @@ -26,6 +27,7 @@ const OwnerSnapshot = () => { roleCommissionOverrides, orgMembers, addJobTeamMember, removeJobTeamMember, updateJobTeamMember, subcontractorTasks, subcontractors, + kanbanLeads, resolvePerson, } = useMockStore(); // Find the current owner profile @@ -38,6 +40,19 @@ const OwnerSnapshot = () => { projects.filter(p => p.ownerId === user?.id) , [projects, user]); + // Pure-analytics derived from selectors.js + const analytics = useMemo(() => ({ + pipeline: pipelineValue(kanbanLeads), + funnel: pipelineFunnel(kanbanLeads), + win: winRate(kanbanLeads), + rev: revenueSummary(ownerProjects), + commissions: commissionByRep(ownerProjects, resolvePerson), + storm: stormAttribution(kanbanLeads, ownerProjects), + }), [kanbanLeads, ownerProjects, resolvePerson]); + + // Stage display labels for the pipeline funnel + const STAGE_LABELS = { kc_new: 'New', kc_contacted: 'Contacted', kc_appt: 'Appt', kc_estimate: 'Estimate' }; + // Modal States const [financialModal, setFinancialModal] = useState({ isOpen: false, type: 'revenue' }); const [actionModal, setActionModal] = useState({ isOpen: false, filter: 'all' }); @@ -217,6 +232,130 @@ const OwnerSnapshot = () => { + {/* ── Pipeline Analytics ── */} +
+

+ + Pipeline Analytics +

+ + {/* Pipeline KPI tiles */} +
+ {[ + { label: 'Open Pipeline', value: formatCurrency(analytics.pipeline), color: 'text-blue-500', bg: 'bg-blue-50 dark:bg-blue-500/10' }, + { label: 'Win Rate', value: `${analytics.win.rate}%`, color: 'text-emerald-500', bg: 'bg-emerald-50 dark:bg-emerald-500/10' }, + { label: 'Recognized Revenue', value: formatCurrency(analytics.rev.revenue), color: 'text-violet-500', bg: 'bg-violet-50 dark:bg-violet-500/10' }, + { label: 'Gross Profit', value: formatCurrency(analytics.rev.grossProfit), color: 'text-amber-500', bg: 'bg-amber-50 dark:bg-amber-500/10' }, + { label: 'Net Profit', value: formatCurrency(analytics.rev.netProfit), color: 'text-teal-500', bg: 'bg-teal-50 dark:bg-teal-500/10' }, + ].map((kpi, i) => ( + +
+ {kpi.label} +
+
{kpi.value}
+
+ ))} +
+ + {/* Pipeline funnel + Commission by Rep + Storm attribution */} +
+ + {/* Pipeline Funnel */} + +

Pipeline Funnel

+

Pre-sale leads by stage

+
+ {analytics.funnel.map((row) => { + const maxVal = Math.max(...analytics.funnel.map(r => r.value), 1); + const pct = Math.round((row.value / maxVal) * 100); + return ( +
+
+ {STAGE_LABELS[row.stage] || row.stage} + + {row.count} lead{row.count !== 1 ? 's' : ''} · {formatCurrency(row.value)} + +
+
+
+
+
+ ); + })} +
+ + + {/* Commission by Rep */} + +

Commission by Rep

+

Earned on owner projects

+
+ {analytics.commissions.length > 0 ? analytics.commissions.map((rep, i) => ( +
+
+ + {i + 1} + + {rep.name} +
+ {formatCurrency(rep.total)} +
+ )) : ( +

No commission data for current projects.

+ )} +
+
+ + {/* Storm Attribution */} + +

Storm Attribution

+

Storm-sourced share of pipeline & revenue

+
+
+
+ Pipeline + {analytics.storm.pipelinePct}% storm +
+
+
+
+
+ {formatCurrency(analytics.storm.stormPipeline)} storm + {formatCurrency(analytics.storm.totalPipeline)} total +
+
+
+
+ Revenue + {analytics.storm.revenuePct}% storm +
+
+
+
+
+ {formatCurrency(analytics.storm.stormRevenue)} storm + {formatCurrency(analytics.storm.totalRevenue)} total +
+
+
+ + {analytics.storm.stormLeadCount} storm lead{analytics.storm.stormLeadCount !== 1 ? 's' : ''} tracked +
+
+ + +
+
+ {/* Charts Row */}
{/* Budget vs Actual */}