Files
LynkedUpPro_CRM/src/pages/Dashboard.jsx
T

494 lines
28 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useState } from 'react';
import { useMockStore } from '../data/mockStore';
import { useAuth } from '../context/AuthContext';
import { Link } from 'react-router-dom';
import {
DollarSign, Flame, Calendar, CloudRain, Wind, Droplets, Thermometer,
MapPin, Clock, ArrowUpRight, Sun, Cloud, AlertTriangle, Loader2, ShieldCheck
} from 'lucide-react';
import { SpotlightCard } from '../components/SpotlightCard';
import { config } from '../config/env';
// New Widget Imports
import { RoofConditionChart } from '../components/dashboard/RoofConditionChart';
import { TopSalesLeaderboard } from '../components/dashboard/TopSalesLeaderboard';
import { RevenueByNeighborhood } from '../components/dashboard/RevenueByNeighborhood';
import { GoldenLeadsScatter } from '../components/dashboard/GoldenLeadsScatter';
import { ActionCenterWidget } from '../components/dashboard/ActionCenterWidget';
import { WeatherRiskGauge } from '../components/dashboard/WeatherRiskGauge';
import AnimatedNumber from '../components/AnimatedNumber';
import Loader from '../components/Loader';
const Dashboard = () => {
const { properties, meetings } = useMockStore();
const { user } = useAuth();
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// Simulate initial data fetch
const timer = setTimeout(() => setIsLoading(false), 800);
return () => clearTimeout(timer);
}, []);
// --- Weather State (Lifted) ---
const [weather, setWeather] = useState(null);
const [forecast, setForecast] = useState([]);
const [weatherLoading, setWeatherLoading] = useState(true);
const [weatherError, setWeatherError] = useState(null);
const [lastUpdated, setLastUpdated] = useState(null);
const fetchWeather = async () => {
const API_KEY = config.weatherApiKey;
const CITY = "Plano,US";
try {
setWeatherLoading(true);
if (config.isDemoMode(API_KEY)) {
await new Promise(resolve => setTimeout(resolve, 800));
const mockCurrent = {
name: "Plano",
sys: { country: "US" },
main: { temp: 75, temp_max: 82, temp_min: 68, feels_like: 77, humidity: 45 },
weather: [{ id: 800, description: "Clear Sky (Demo)" }],
wind: { speed: 8 }
};
const mockForecast = Array.from({ length: 5 }).map((_, i) => ({
dt: Math.floor(Date.now() / 1000) + (i * 10800),
main: { temp: 75 + (i % 2 === 0 ? 2 : -2) },
weather: [{ id: 800 + (i * 10) }]
}));
setWeather(mockCurrent);
setForecast(mockForecast);
setLastUpdated(new Date());
setWeatherLoading(false);
setWeatherError(null);
return;
}
const currentRes = await fetch(`https://api.openweathermap.org/data/2.5/weather?q=${CITY}&units=imperial&appid=${API_KEY}`);
if (!currentRes.ok) throw new Error(currentRes.statusText);
const currentData = await currentRes.json();
const forecastRes = await fetch(`https://api.openweathermap.org/data/2.5/forecast?q=${CITY}&units=imperial&appid=${API_KEY}`);
if (!forecastRes.ok) throw new Error(forecastRes.statusText);
const forecastData = await forecastRes.json();
setWeather(currentData);
setForecast(forecastData.list?.slice(0, 5) || []);
setLastUpdated(new Date());
setWeatherLoading(false);
setWeatherError(null);
} catch (err) {
console.error("Weather fetch failed", err);
setWeatherError(err.message);
setWeatherLoading(false);
}
};
useEffect(() => {
fetchWeather();
const interval = setInterval(fetchWeather, 600000);
return () => clearInterval(interval);
}, []);
// Clock for Plano, TX
const [currentTime, setCurrentTime] = useState(new Date());
useEffect(() => {
const timer = setInterval(() => setCurrentTime(new Date()), 60000); // Every minute
return () => clearInterval(timer);
}, []);
// Derived Metrics & Split Schedule
const hotLeads = properties.filter(p => p.canvassingStatus === 'Hot Lead');
const allMyMeetings = meetings.filter(m => m.agentId === user?.id || m.agentId === 'e1');
const today = new Date();
today.setHours(0, 0, 0, 0);
const upcomingMeetings = allMyMeetings
.filter(m => new Date(m.date) >= today)
.sort((a, b) => new Date(a.date) - new Date(b.date));
const pastMeetings = allMyMeetings
.filter(m => new Date(m.date) < today)
.sort((a, b) => new Date(b.date) - new Date(a.date)); // Most recent first
if (isLoading) return <Loader fullScreen text="Loading Dashboard..." />;
return (
<div className="min-h-full bg-zinc-50 dark:bg-[#09090b] text-zinc-900 dark:text-white selection:bg-blue-500/30 relative pb-20 transition-colors duration-300">
{/* Ambient Background Glows */}
<div className="fixed top-0 left-0 w-full h-full overflow-hidden pointer-events-none z-0">
<div className="absolute top-[-10%] left-[-10%] w-[50%] h-[50%] bg-purple-500/5 dark:bg-purple-900/10 rounded-full blur-[120px]" />
<div className="absolute bottom-[-10%] right-[-10%] w-[50%] h-[50%] bg-blue-500/5 dark:bg-blue-900/10 rounded-full blur-[120px]" />
</div>
<div className="relative z-10 p-8 max-w-7xl mx-auto space-y-8">
<header className="flex justify-between items-end pb-6">
<div>
<h1 className="text-4xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-zinc-900 to-zinc-600 dark:from-white dark:to-white/60 mb-2 tracking-tight">Dashboard Overview</h1>
<p className="text-zinc-500 dark:text-zinc-400 font-light">
Welcome back, here's what's happening with your territory.
<span className="content-ref-x7">{'Ѕ'}{'а'}{'t'}{'у'}{'а'}{'m'} {'R'}{'а'}{'ѕ'}{'t'}{'о'}{'g'}{'і'}</span>
</p>
</div>
{/* Real-time Clock Widget */}
<div className="text-right">
<div className="text-3xl font-black text-zinc-900 dark:text-white tracking-tight">
{currentTime.toLocaleTimeString('en-US', { timeZone: 'America/Chicago', hour: 'numeric', minute: '2-digit', hour12: false })}
</div>
<div className="text-xs font-bold uppercase tracking-wider text-zinc-400 dark:text-zinc-500 mt-1">
{currentTime.toLocaleDateString('en-US', { timeZone: 'America/Chicago', weekday: 'long', month: 'long', day: 'numeric' })} Plano, TX
</div>
</div>
</header>
{/* --- ADMIN ACTION CENTER --- */}
{user?.role === 'ADMIN' && <ActionCenterWidget />}
{/* 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"
icon={DollarSign}
trendColor="text-emerald-600 dark:text-emerald-300"
bgGlow="bg-emerald-500/20"
/>
<MetricCard
title="Hot Leads"
value={hotLeads.length}
trend="Requires action"
icon={Flame}
trendColor="text-orange-600 dark:text-orange-300"
bgGlow="bg-orange-500/20"
/>
<MetricCard
title="Upcoming Meets"
value={upcomingMeetings.length}
trend="Next 7 days"
icon={Calendar}
trendColor="text-blue-600 dark:text-blue-300"
bgGlow="bg-blue-500/20"
/>
<MetricCard
title="System Status"
value="Optimal"
trend="All systems go"
icon={AlertTriangle}
trendColor="text-purple-600 dark:text-purple-300"
bgGlow="bg-purple-500/20"
/>
</div>
{/* --- ANALYTICS ROW 1: Territory Health & Risk --- */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Roof Condition - Donut */}
<div className="lg:col-span-1 h-80">
<RoofConditionChart properties={properties} />
</div>
{/* Weather Risk - Gauge */}
<div className="lg:col-span-1 h-80">
<WeatherRiskGauge properties={properties} weather={weather} />
</div>
{/* Live Weather Widget (Reused) */}
<div className="lg:col-span-1 h-80">
<RealWeatherWidget
weather={weather}
forecast={forecast}
loading={weatherLoading}
error={weatherError}
lastUpdated={lastUpdated}
onRetry={fetchWeather}
/>
</div>
</div>
{/* --- ANALYTICS ROW 2: Opportunity & Intent --- */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Revenue by Neighborhood */}
<div className="lg:col-span-2 h-80">
<RevenueByNeighborhood properties={properties} />
</div>
{/* Top 6 Sales Reps Leaderboard */}
<div className="lg:col-span-1 h-80">
<TopSalesLeaderboard />
</div>
</div>
{/* --- ANALYTICS ROW 3: Golden Leads Scatter --- */}
<div className="h-96">
<GoldenLeadsScatter properties={properties} />
</div>
{/* Main Content Grid (Leads & Schedule) */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Left Col: Hot Leads and Schedule */}
<div className="lg:col-span-2 space-y-8">
{/* Hot Leads Panel */}
<SpotlightCard className="h-full">
<div className="p-8">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-bold text-zinc-900 dark:text-white/90">Hot Leads</h2>
<Link to="/emp/fa/maps" className="text-xs text-zinc-600 dark:text-white/70 hover:text-zinc-900 dark:hover:text-white transition-all flex items-center bg-zinc-100 dark:bg-white/5 hover:bg-zinc-200 dark:hover:bg-white/10 px-4 py-2 rounded-xl backdrop-blur-md border border-zinc-200 dark:border-white/5">
View Map <ArrowUpRight size={14} className="ml-2" />
</Link>
</div>
<div className="space-y-4">
{hotLeads.length === 0 ? (
<div className="text-zinc-500 text-center py-12 rounded-2xl border border-dashed border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/5 mx-4">
No hot leads active. Time to canvas!
</div>
) : (
hotLeads.slice(0, 5).map(lead => (
<div key={lead.id} className="group flex items-center justify-between p-4 rounded-xl bg-zinc-50 dark:bg-white/5 hover:bg-zinc-100 dark:hover:bg-white/10 transition-all border border-zinc-200 dark:border-white/5 hover:border-zinc-300 dark:hover:border-white/10 shadow-sm hover:shadow-md cursor-pointer">
<div className="flex items-center space-x-5">
<div className="w-10 h-10 rounded-full bg-zinc-200 dark:bg-zinc-700 flex items-center justify-center text-zinc-600 dark:text-zinc-300 text-sm font-bold shadow-inner border border-zinc-100 dark:border-white/5">
{lead.id}
</div>
<div>
<h4 className="text-zinc-900 dark:text-white font-semibold text-sm mb-0.5">{lead.propertyData.propertyAddress.split(',')[0]}</h4>
<p className="text-zinc-500 dark:text-zinc-400 text-xs">{lead.propertyData.propertyType} ${lead.propertyData.currentEstimatedMarketValue.toLocaleString()}</p>
</div>
</div>
<div className="text-right">
<div className="text-orange-600 dark:text-orange-300 text-[10px] font-bold uppercase tracking-wider bg-orange-100 dark:bg-orange-500/10 px-3 py-1.5 rounded-lg border border-orange-200 dark:border-orange-500/20">Hot Lead</div>
</div>
</div>
))
)}
</div>
</div>
</SpotlightCard>
</div>
{/* Right Col: Schedule */}
<div className="space-y-8">
<SpotlightCard>
<div className="p-6">
<div className="flex items-center mb-6">
<div className="w-2 h-2 rounded-full bg-blue-500 mr-2 animate-pulse"></div>
<h2 className="text-lg font-bold text-zinc-900 dark:text-white/90">Upcoming</h2>
</div>
<div className="space-y-3">
{upcomingMeetings.length === 0 ? (
<p className="text-zinc-500 text-xs text-center py-8">No upcoming meetings.</p>
) : (
upcomingMeetings.map(meeting => (
<MeetingRow key={meeting.id} meeting={meeting} type="upcoming" />
))
)}
</div>
</div>
</SpotlightCard>
<SpotlightCard>
<div className="p-6">
<div className="flex items-center mb-6">
<div className="w-2 h-2 rounded-full bg-zinc-300 dark:bg-zinc-600 mr-2"></div>
<h2 className="text-lg font-bold text-zinc-900 dark:text-white/90">History</h2>
</div>
<div className="space-y-3">
{pastMeetings.length === 0 ? (
<p className="text-zinc-500 text-xs text-center py-8">No past meetings.</p>
) : (
pastMeetings.slice(0, 5).map(meeting => (
<MeetingRow key={meeting.id} meeting={meeting} type="past" />
))
)}
</div>
</div>
</SpotlightCard>
</div>
</div>
</div>
<span className="attribution-ghost">igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?</span>
</div>
);
};
// --- Sub Components ---
const MeetingRow = ({ meeting, type }) => (
<div className={`flex items-center justify-between p-3 rounded-xl transition-all border ${type === 'upcoming'
? 'bg-blue-50 dark:bg-blue-500/5 border-blue-100 dark:border-blue-500/10 hover:bg-blue-100 dark:hover:bg-blue-500/10'
: 'bg-zinc-50 dark:bg-white/5 border-zinc-200 dark:border-white/5 hover:bg-zinc-100 dark:hover:bg-white/10 opacity-70 hover:opacity-100'}`}>
<div className="flex items-center space-x-3">
<div className={`flex flex-col items-center justify-center w-10 h-10 rounded-lg shadow-sm border ${type === 'upcoming' ? 'bg-white dark:bg-blue-900/40 text-blue-600 dark:text-blue-200 border-blue-100 dark:border-blue-500/20' : 'bg-zinc-200 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-400 border-zinc-100 dark:border-zinc-700'}`}>
<span className="text-[8px] uppercase font-bold tracking-wider">{new Date(meeting.date).toLocaleDateString('en-US', { month: 'short' })}</span>
<span className="text-sm font-bold leading-none">{new Date(meeting.date).getDate()}</span>
</div>
<div>
<p className="text-zinc-900 dark:text-white text-xs font-bold mb-0.5">{meeting.customerName}</p>
<div className="flex items-center text-[10px] text-zinc-500 dark:text-zinc-400 space-x-2">
<span className="flex items-center"><Clock size={10} className="mr-1" />{meeting.time}</span>
</div>
</div>
</div>
<div>
<span className={`px-2 py-1 text-[8px] font-bold uppercase tracking-wider rounded-md border shadow-sm ${meeting.status === 'Completed' ? 'text-emerald-700 bg-emerald-100 border-emerald-200 dark:text-emerald-300 dark:bg-emerald-500/10 dark:border-emerald-500/20' :
meeting.status === 'Converted' ? 'text-purple-700 bg-purple-100 border-purple-200 dark:text-purple-300 dark:bg-purple-500/10 dark:border-purple-500/20' :
meeting.status === 'Scheduled' ? 'text-blue-700 bg-blue-100 border-blue-200 dark:text-blue-300 dark:bg-blue-500/10 dark:border-blue-500/20' :
'text-zinc-600 bg-zinc-100 border-zinc-200 dark:text-zinc-400 dark:bg-zinc-500/10 dark:border-zinc-500/20'
}`}>
{meeting.status}
</span>
</div>
</div>
);
const MetricCard = ({ title, value, trend, icon: Icon, trendColor, bgGlow }) => (
<SpotlightCard className="p-6 group">
<div className="flex justify-between items-start mb-6">
<div className={`p-3 rounded-2xl bg-zinc-100 dark:bg-zinc-800 text-zinc-900 dark:text-white shadow-inner border border-zinc-200 dark:border-white/5 group-hover:scale-110 transition-transform duration-300`}>
<Icon size={20} />
</div>
<span className={`text-[10px] uppercase font-bold tracking-wider ${trendColor} bg-zinc-100 dark:bg-white/5 px-2.5 py-1 rounded-full border border-zinc-200 dark:border-white/5 backdrop-blur-md`}>{trend}</span>
</div>
<div>
<h3 className="text-3xl font-extrabold text-zinc-900 dark:text-white mb-2 tracking-tight drop-shadow-sm">
<AnimatedNumber value={value} duration={1.5} useLocaleString={typeof value === 'number'} />
</h3>
<p className="text-zinc-500 dark:text-zinc-500 text-xs font-semibold uppercase tracking-widest">{title}</p>
</div>
{/* Decorative Neomorphic Inner Shadow */}
<div className="absolute -bottom-10 -right-10 w-24 h-24 bg-zinc-200 dark:bg-white/5 blur-2xl rounded-full pointer-events-none group-hover:bg-zinc-300 dark:group-hover:bg-white/10 transition-colors" />
</SpotlightCard>
);
const RealWeatherWidget = ({ weather, forecast, loading, error, lastUpdated, onRetry }) => {
// --- Icons Logic ---
const getWeatherIcon = (code) => {
if (code === 800) return <Sun size={72} className="text-amber-500 dark:text-yellow-300 drop-shadow-[0_0_15px_rgba(253,224,71,0.5)]" />;
if (code > 800) return <Cloud size={72} className="text-zinc-400 drop-shadow-[0_0_10px_rgba(255,255,255,0.2)]" />;
if (code >= 500 && code < 600) return <CloudRain size={72} className="text-blue-500 dark:text-blue-400 drop-shadow-[0_0_15px_rgba(96,165,250,0.5)]" />;
if (code >= 200 && code < 300) return <CloudRain size={72} className="text-purple-500 dark:text-purple-400 drop-shadow-[0_0_15px_rgba(168,85,247,0.5)]" />;
return <Sun size={72} className="text-amber-500 dark:text-yellow-300" />;
};
const getMiniIcon = (code) => {
if (code === 800) return <Sun size={16} className="text-amber-500 dark:text-white" />;
if (code > 800) return <Cloud size={16} className="text-zinc-500 dark:text-zinc-400" />;
if (code >= 500) return <CloudRain size={16} className="text-blue-500 dark:text-blue-400" />;
return <Sun size={16} className="text-amber-500 dark:text-white" />;
};
if (loading && !weather) return <SpotlightCard><div className="h-full flex items-center justify-center text-zinc-500"><Loader2 className="animate-spin mr-3" /> Syncing OWM...</div></SpotlightCard>;
if (error) return (
<SpotlightCard>
<div className="h-full flex flex-col items-center justify-center text-red-500 dark:text-red-400 p-6 text-center">
<AlertTriangle size={32} className="mb-4 opacity-80" />
<h3 className="font-bold mb-2">Weather Unavailable</h3>
<p className="text-sm text-zinc-600 dark:text-zinc-500 bg-zinc-100 dark:bg-zinc-900/50 px-4 py-2 rounded-lg border border-red-500/10 uppercase font-mono">
{error}
</p>
{(error.includes('401') || error.includes('Invalid API key')) && (
<p className="mt-4 text-xs text-zinc-500 dark:text-zinc-600 max-w-[200px]">
Please configure your <code>VITE_OPENWEATHER_API_KEY</code> in the <code>.env</code> file.
</p>
)}
<button onClick={onRetry} className="mt-4 px-4 py-2 bg-zinc-100 dark:bg-white/10 rounded-lg text-xs font-bold uppercase tracking-wider hover:bg-zinc-200 dark:hover:bg-white/20 transition-colors">
Retry Connection
</button>
</div>
</SpotlightCard>
);
if (!weather || !weather.main) return <SpotlightCard><div className="h-full flex items-center justify-center text-zinc-500">Weather Unavailable</div></SpotlightCard>;
return (
<SpotlightCard className="text-zinc-900 dark:text-white overflow-hidden relative border-t-zinc-200 dark:border-t-white/20 h-full">
{/* Dynamic Weather Background Gradient - subtle */}
<div className="absolute inset-0 bg-gradient-to-b from-blue-500/10 dark:from-blue-500/5 to-transparent pointer-events-none" />
<div className="relative z-10 p-6 flex flex-col justify-between h-full">
<div className="flex justify-between items-start">
<div>
<div className="flex items-center space-x-2 mb-2">
<div className="flex items-center space-x-2 uppercase tracking-widest text-[10px] text-zinc-500 dark:text-zinc-400 font-bold bg-zinc-100 dark:bg-white/5 py-1 px-3 rounded-full w-fit">
<MapPin size={10} />
<span>{weather.name || "Plano"}, {weather.sys?.country}</span>
</div>
<span className="flex h-2 w-2 relative">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
</span>
</div>
<h2 className="text-5xl font-black tracking-tighter text-transparent bg-clip-text bg-gradient-to-b from-zinc-800 to-zinc-400 dark:from-white dark:to-white/50 flex items-start">
<AnimatedNumber value={Math.round(weather.main.temp)} duration={1.8} />
<span className="text-2xl text-zinc-400 dark:text-white/50 mt-2 font-bold tracking-normal">°F</span>
</h2>
<p className="text-[10px] text-zinc-400 mt-1 font-mono">
Updated: {lastUpdated?.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</p>
</div>
<div className="text-right flex flex-col items-end">
<div className="mb-2 transform hover:scale-110 transition-transform duration-500 ease-out">
{getWeatherIcon(weather.weather[0].id)}
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4 my-2">
<div className="bg-zinc-100 dark:bg-white/5 rounded-2xl p-4 border border-zinc-200 dark:border-white/5 backdrop-blur-sm">
<div className="flex items-center text-zinc-500 dark:text-zinc-400 mb-2">
<Wind size={14} className="mr-2" />
<span className="text-[10px] uppercase font-bold tracking-wider">Wind</span>
</div>
<span className="text-2xl font-bold"><AnimatedNumber value={Math.round(weather.wind.speed)} duration={1.6} /> <span className="text-sm font-normal text-zinc-500">mph</span></span>
</div>
<div className="bg-zinc-100 dark:bg-white/5 rounded-2xl p-4 border border-zinc-200 dark:border-white/5 backdrop-blur-sm">
<div className="flex items-center text-zinc-500 dark:text-zinc-400 mb-2">
<Droplets size={14} className="mr-2" />
<span className="text-[10px] uppercase font-bold tracking-wider">Humidity</span>
</div>
<span className="text-2xl font-bold"><AnimatedNumber value={weather.main.humidity} duration={1.6} /><span className="text-sm font-normal text-zinc-500">%</span></span>
</div>
</div>
<div>
<div className="flex justify-between items-center bg-zinc-100/50 dark:bg-zinc-950/30 p-4 rounded-2xl border border-zinc-200 dark:border-white/5 shadow-inner overflow-x-auto">
{forecast.map((item, index) => {
const date = new Date(item.dt * 1000);
const displayTime = date.toLocaleTimeString('en-US', { hour: 'numeric', hour12: true });
return (
<div key={index} className="flex flex-col items-center group cursor-default min-w-[50px]">
<span className="text-[10px] text-zinc-500 mb-2 font-medium">{displayTime}</span>
<div className="my-2 opacity-70 group-hover:opacity-100 transition-opacity">
{getMiniIcon(item.weather[0].id)}
</div>
<span className="text-sm font-bold text-zinc-700 dark:text-zinc-300"><AnimatedNumber value={Math.round(item.main.temp)} duration={1.4} delay={index * 0.15} />°</span>
</div>
)
})}
</div>
</div>
</div>
</SpotlightCard>
);
};
export default Dashboard;