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 */}
|
||||
|
||||
Reference in New Issue
Block a user