feat(dashboards): wire owner + admin KPIs to computed selectors (pipeline, funnel, win-rate, revenue, commission, storm)
This commit is contained in:
+135
-5
@@ -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 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<MetricCard
|
||||
title="Volume Closed"
|
||||
value="$4.2M"
|
||||
trend="+12% vs last month"
|
||||
title="Recognized Revenue"
|
||||
value={`$${(analytics.rev.revenue / 1000).toFixed(0)}K`}
|
||||
trend={`Gross profit $${(analytics.rev.grossProfit / 1000).toFixed(0)}K`}
|
||||
icon={DollarSign}
|
||||
trendColor="text-emerald-600 dark:text-emerald-300"
|
||||
bgGlow="bg-emerald-500/20"
|
||||
@@ -188,6 +198,126 @@ const Dashboard = () => {
|
||||
/>
|
||||
</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&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 --- */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Roof Condition - Donut */}
|
||||
|
||||
@@ -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 = () => {
|
||||
<FinancialKPICards onCardClick={handleFinancialClick} ownerId={user?.id} />
|
||||
</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 & 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 */}
|
||||
<section className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Budget vs Actual */}
|
||||
|
||||
Reference in New Issue
Block a user