feat(dashboards): wire owner + admin KPIs to computed selectors (pipeline, funnel, win-rate, revenue, commission, storm)

This commit is contained in:
Satyam Rastogi
2026-05-29 16:50:19 +05:30
parent 8f5a32179d
commit dd781f4692
2 changed files with 274 additions and 5 deletions
+135 -5
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState, useMemo } from 'react';
import { useMockStore } from '../data/mockStore'; import { useMockStore } from '../data/mockStore';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
@@ -8,6 +8,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { SpotlightCard } from '../components/SpotlightCard'; import { SpotlightCard } from '../components/SpotlightCard';
import { config } from '../config/env'; import { config } from '../config/env';
import { pipelineValue, pipelineFunnel, winRate, revenueSummary, stormAttribution } from '../data/selectors';
// New Widget Imports // New Widget Imports
import { RoofConditionChart } from '../components/dashboard/RoofConditionChart'; import { RoofConditionChart } from '../components/dashboard/RoofConditionChart';
@@ -21,10 +22,19 @@ import AnimatedNumber from '../components/AnimatedNumber';
import Loader from '../components/Loader'; import Loader from '../components/Loader';
const Dashboard = () => { const Dashboard = () => {
const { properties, meetings } = useMockStore(); const { properties, meetings, projects, kanbanLeads, resolvePerson } = useMockStore();
const { user } = useAuth(); const { user } = useAuth();
const [isLoading, setIsLoading] = useState(true); 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(() => { useEffect(() => {
// Simulate initial data fetch // Simulate initial data fetch
const timer = setTimeout(() => setIsLoading(false), 800); const timer = setTimeout(() => setIsLoading(false), 800);
@@ -155,9 +165,9 @@ const Dashboard = () => {
{/* Top Metrics Grid */} {/* Top Metrics Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<MetricCard <MetricCard
title="Volume Closed" title="Recognized Revenue"
value="$4.2M" value={`$${(analytics.rev.revenue / 1000).toFixed(0)}K`}
trend="+12% vs last month" trend={`Gross profit $${(analytics.rev.grossProfit / 1000).toFixed(0)}K`}
icon={DollarSign} icon={DollarSign}
trendColor="text-emerald-600 dark:text-emerald-300" trendColor="text-emerald-600 dark:text-emerald-300"
bgGlow="bg-emerald-500/20" bgGlow="bg-emerald-500/20"
@@ -188,6 +198,126 @@ const Dashboard = () => {
/> />
</div> </div>
{/* --- COMPANY-WIDE ANALYTICS (ADMIN) --- */}
{user?.role === 'ADMIN' && (
<div className="space-y-6">
{/* Section header */}
<div className="flex items-center space-x-3">
<ShieldCheck size={18} className="text-blue-500" />
<h2 className="text-lg font-bold text-zinc-900 dark:text-white/90 tracking-tight">Company-Wide Analytics</h2>
<span className="text-[10px] uppercase font-bold tracking-wider text-zinc-500 dark:text-zinc-400 bg-zinc-100 dark:bg-white/5 px-2.5 py-1 rounded-full border border-zinc-200 dark:border-white/10">Admin View</span>
</div>
{/* Row 1: Pipeline + Win Rate + Storm */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Open Pipeline */}
<SpotlightCard className="p-6">
<div className="flex justify-between items-start mb-4">
<p className="text-xs font-bold uppercase tracking-widest text-zinc-500 dark:text-zinc-400">Open Pipeline</p>
<span className="text-[10px] uppercase font-bold tracking-wider text-blue-600 dark:text-blue-300 bg-blue-50 dark:bg-blue-500/10 px-2.5 py-1 rounded-full border border-blue-100 dark:border-blue-500/20">
{analytics.funnel.reduce((s, f) => s + f.count, 0)} leads
</span>
</div>
<div className="text-3xl font-extrabold text-zinc-900 dark:text-white tracking-tight mb-4">
${(analytics.pipeline / 1000).toFixed(0)}K
</div>
{/* Mini funnel bars */}
<div className="space-y-2">
{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 (
<div key={f.stage} className="flex items-center space-x-2">
<span className="text-[10px] text-zinc-500 dark:text-zinc-400 w-20 shrink-0 font-medium">{STAGE_LABELS[f.stage]}</span>
<div className="flex-1 bg-zinc-100 dark:bg-white/5 rounded-full h-1.5 border border-zinc-200 dark:border-white/5">
<div
className="h-1.5 rounded-full bg-gradient-to-r from-blue-500 to-blue-400"
style={{ width: `${pct}%` }}
/>
</div>
<span className="text-[10px] font-bold text-zinc-600 dark:text-zinc-300 w-4 text-right">{f.count}</span>
</div>
);
})}
</div>
</SpotlightCard>
{/* Win Rate */}
<SpotlightCard className="p-6">
<p className="text-xs font-bold uppercase tracking-widest text-zinc-500 dark:text-zinc-400 mb-4">Win Rate</p>
<div className="flex items-end space-x-2 mb-4">
<span className="text-5xl font-extrabold text-zinc-900 dark:text-white tracking-tighter">{analytics.win.rate}</span>
<span className="text-xl font-bold text-zinc-400 dark:text-zinc-500 mb-1">%</span>
</div>
<div className="w-full bg-zinc-100 dark:bg-white/5 rounded-full h-2 border border-zinc-200 dark:border-white/5 mb-4">
<div
className="h-2 rounded-full bg-gradient-to-r from-emerald-500 to-emerald-400"
style={{ width: `${analytics.win.rate}%` }}
/>
</div>
<div className="flex justify-between text-[10px] font-bold uppercase tracking-wider">
<span className="text-emerald-600 dark:text-emerald-400">{analytics.win.won} Won</span>
<span className="text-zinc-400 dark:text-zinc-500">{analytics.win.decided} Decided</span>
<span className="text-red-500 dark:text-red-400">{analytics.win.lost} Lost</span>
</div>
</SpotlightCard>
{/* Storm Attribution */}
<SpotlightCard className="p-6">
<p className="text-xs font-bold uppercase tracking-widest text-zinc-500 dark:text-zinc-400 mb-4">Storm-Attributed Revenue</p>
<div className="flex items-end space-x-2 mb-4">
<span className="text-5xl font-extrabold text-zinc-900 dark:text-white tracking-tighter">{analytics.storm.revenuePct}</span>
<span className="text-xl font-bold text-zinc-400 dark:text-zinc-500 mb-1">%</span>
</div>
<div className="w-full bg-zinc-100 dark:bg-white/5 rounded-full h-2 border border-zinc-200 dark:border-white/5 mb-4">
<div
className="h-2 rounded-full bg-gradient-to-r from-sky-500 to-cyan-400"
style={{ width: `${analytics.storm.revenuePct}%` }}
/>
</div>
<div className="flex justify-between text-[10px] font-bold uppercase tracking-wider">
<span className="text-sky-600 dark:text-sky-400">{analytics.storm.stormLeadCount} Storm Leads</span>
<span className="text-zinc-400 dark:text-zinc-500">${(analytics.storm.stormRevenue / 1000).toFixed(0)}K of ${(analytics.storm.totalRevenue / 1000).toFixed(0)}K</span>
</div>
</SpotlightCard>
</div>
{/* Row 2: Revenue / Profit breakdown */}
<SpotlightCard className="p-6">
<p className="text-xs font-bold uppercase tracking-widest text-zinc-500 dark:text-zinc-400 mb-5">P&amp;L Summary</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
<div>
<p className="text-[10px] uppercase font-bold tracking-widest text-zinc-400 dark:text-zinc-500 mb-1">Revenue</p>
<p className="text-2xl font-extrabold text-zinc-900 dark:text-white">${(analytics.rev.revenue / 1000).toFixed(0)}K</p>
</div>
<div>
<p className="text-[10px] uppercase font-bold tracking-widest text-zinc-400 dark:text-zinc-500 mb-1">Gross Profit</p>
<p className={`text-2xl font-extrabold ${analytics.rev.grossProfit >= 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-500 dark:text-red-400'}`}>
${(analytics.rev.grossProfit / 1000).toFixed(0)}K
</p>
</div>
<div>
<p className="text-[10px] uppercase font-bold tracking-widest text-zinc-400 dark:text-zinc-500 mb-1">Commissions</p>
<p className="text-2xl font-extrabold text-amber-600 dark:text-amber-400">${(analytics.rev.commissions / 1000).toFixed(0)}K</p>
</div>
<div>
<p className="text-[10px] uppercase font-bold tracking-widest text-zinc-400 dark:text-zinc-500 mb-1">Net Profit</p>
<p className={`text-2xl font-extrabold ${analytics.rev.netProfit >= 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-500 dark:text-red-400'}`}>
${(analytics.rev.netProfit / 1000).toFixed(0)}K
</p>
</div>
</div>
</SpotlightCard>
</div>
)}
{/* --- ANALYTICS ROW 1: Territory Health & Risk --- */} {/* --- ANALYTICS ROW 1: Territory Health & Risk --- */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Roof Condition - Donut */} {/* Roof Condition - Donut */}
+139
View File
@@ -2,6 +2,7 @@ import React, { useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/AuthContext';
import { useMockStore } from '../../data/mockStore'; import { useMockStore } from '../../data/mockStore';
import { pipelineValue, pipelineFunnel, winRate, revenueSummary, commissionByRep, stormAttribution } from '../../data/selectors';
import FinancialKPICards from '../../components/owner/FinancialKPICards'; import FinancialKPICards from '../../components/owner/FinancialKPICards';
import UrgentItemsPanel from '../../components/owner/UrgentItemsPanel'; import UrgentItemsPanel from '../../components/owner/UrgentItemsPanel';
import FinancialDetailsModal from '../../components/owner/FinancialDetailsModal'; import FinancialDetailsModal from '../../components/owner/FinancialDetailsModal';
@@ -26,6 +27,7 @@ const OwnerSnapshot = () => {
roleCommissionOverrides, orgMembers, roleCommissionOverrides, orgMembers,
addJobTeamMember, removeJobTeamMember, updateJobTeamMember, addJobTeamMember, removeJobTeamMember, updateJobTeamMember,
subcontractorTasks, subcontractors, subcontractorTasks, subcontractors,
kanbanLeads, resolvePerson,
} = useMockStore(); } = useMockStore();
// Find the current owner profile // Find the current owner profile
@@ -38,6 +40,19 @@ const OwnerSnapshot = () => {
projects.filter(p => p.ownerId === user?.id) projects.filter(p => p.ownerId === user?.id)
, [projects, user]); , [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 // Modal States
const [financialModal, setFinancialModal] = useState({ isOpen: false, type: 'revenue' }); const [financialModal, setFinancialModal] = useState({ isOpen: false, type: 'revenue' });
const [actionModal, setActionModal] = useState({ isOpen: false, filter: 'all' }); const [actionModal, setActionModal] = useState({ isOpen: false, filter: 'all' });
@@ -217,6 +232,130 @@ const OwnerSnapshot = () => {
<FinancialKPICards onCardClick={handleFinancialClick} ownerId={user?.id} /> <FinancialKPICards onCardClick={handleFinancialClick} ownerId={user?.id} />
</section> </section>
{/* ── Pipeline Analytics ── */}
<section>
<h2 className="text-xl font-bold text-zinc-900 dark:text-white/90 mb-4 flex items-center">
<span className="w-2 h-8 bg-blue-500 rounded-full mr-3"></span>
Pipeline Analytics
</h2>
{/* Pipeline KPI tiles */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4 mb-6">
{[
{ 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) => (
<SpotlightCard key={i} className="p-4">
<div className={`inline-flex px-2 py-0.5 rounded-lg text-xs font-bold uppercase tracking-wide mb-2 ${kpi.bg} ${kpi.color}`}>
{kpi.label}
</div>
<div className={`text-2xl font-extrabold ${kpi.color}`}>{kpi.value}</div>
</SpotlightCard>
))}
</div>
{/* Pipeline funnel + Commission by Rep + Storm attribution */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Pipeline Funnel */}
<SpotlightCard className="p-5">
<h3 className="text-base font-bold text-zinc-900 dark:text-white mb-1">Pipeline Funnel</h3>
<p className="text-xs text-zinc-500 dark:text-zinc-400 mb-4">Pre-sale leads by stage</p>
<div className="space-y-3">
{analytics.funnel.map((row) => {
const maxVal = Math.max(...analytics.funnel.map(r => r.value), 1);
const pct = Math.round((row.value / maxVal) * 100);
return (
<div key={row.stage}>
<div className="flex justify-between text-xs mb-1">
<span className="font-semibold text-zinc-700 dark:text-zinc-300">{STAGE_LABELS[row.stage] || row.stage}</span>
<span className="text-zinc-500 dark:text-zinc-400">
{row.count} lead{row.count !== 1 ? 's' : ''} · {formatCurrency(row.value)}
</span>
</div>
<div className="h-2 rounded-full bg-zinc-200 dark:bg-zinc-700 overflow-hidden">
<div
className="h-full rounded-full bg-blue-500 transition-all duration-500"
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
})}
</div>
</SpotlightCard>
{/* Commission by Rep */}
<SpotlightCard className="p-5">
<h3 className="text-base font-bold text-zinc-900 dark:text-white mb-1">Commission by Rep</h3>
<p className="text-xs text-zinc-500 dark:text-zinc-400 mb-4">Earned on owner projects</p>
<div className="space-y-2">
{analytics.commissions.length > 0 ? analytics.commissions.map((rep, i) => (
<div key={rep.repId} className="flex items-center justify-between p-2.5 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-200 dark:border-white/5">
<div className="flex items-center gap-2">
<span className="w-5 h-5 rounded-full bg-emerald-100 dark:bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 text-[10px] font-bold flex items-center justify-center">
{i + 1}
</span>
<span className="text-sm font-semibold text-zinc-800 dark:text-zinc-200 truncate max-w-[110px]">{rep.name}</span>
</div>
<span className="text-sm font-bold text-emerald-600 dark:text-emerald-400">{formatCurrency(rep.total)}</span>
</div>
)) : (
<p className="text-sm text-zinc-400 dark:text-zinc-500 italic">No commission data for current projects.</p>
)}
</div>
</SpotlightCard>
{/* Storm Attribution */}
<SpotlightCard className="p-5">
<h3 className="text-base font-bold text-zinc-900 dark:text-white mb-1">Storm Attribution</h3>
<p className="text-xs text-zinc-500 dark:text-zinc-400 mb-4">Storm-sourced share of pipeline &amp; revenue</p>
<div className="space-y-4">
<div>
<div className="flex justify-between text-xs mb-1">
<span className="font-semibold text-zinc-700 dark:text-zinc-300">Pipeline</span>
<span className="text-zinc-500 dark:text-zinc-400">{analytics.storm.pipelinePct}% storm</span>
</div>
<div className="h-2 rounded-full bg-zinc-200 dark:bg-zinc-700 overflow-hidden">
<div
className="h-full rounded-full bg-amber-500 transition-all duration-500"
style={{ width: `${analytics.storm.pipelinePct}%` }}
/>
</div>
<div className="flex justify-between text-[10px] text-zinc-400 mt-1">
<span>{formatCurrency(analytics.storm.stormPipeline)} storm</span>
<span>{formatCurrency(analytics.storm.totalPipeline)} total</span>
</div>
</div>
<div>
<div className="flex justify-between text-xs mb-1">
<span className="font-semibold text-zinc-700 dark:text-zinc-300">Revenue</span>
<span className="text-zinc-500 dark:text-zinc-400">{analytics.storm.revenuePct}% storm</span>
</div>
<div className="h-2 rounded-full bg-zinc-200 dark:bg-zinc-700 overflow-hidden">
<div
className="h-full rounded-full bg-orange-500 transition-all duration-500"
style={{ width: `${analytics.storm.revenuePct}%` }}
/>
</div>
<div className="flex justify-between text-[10px] text-zinc-400 mt-1">
<span>{formatCurrency(analytics.storm.stormRevenue)} storm</span>
<span>{formatCurrency(analytics.storm.totalRevenue)} total</span>
</div>
</div>
<div className="pt-1 border-t border-zinc-200 dark:border-white/5 flex items-center gap-2 text-xs text-zinc-500 dark:text-zinc-400">
<AlertTriangle size={12} className="text-amber-500 shrink-0" />
<span>{analytics.storm.stormLeadCount} storm lead{analytics.storm.stormLeadCount !== 1 ? 's' : ''} tracked</span>
</div>
</div>
</SpotlightCard>
</div>
</section>
{/* Charts Row */} {/* Charts Row */}
<section className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <section className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Budget vs Actual */} {/* Budget vs Actual */}