feat: complete dashboard widgets, fix tooltips, integrate chatbot, and update docs
This commit is contained in:
+160
-152
@@ -4,15 +4,83 @@ 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
|
||||
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 { OwnerIntentFunnel } from '../components/dashboard/OwnerIntentFunnel';
|
||||
import { RevenueByNeighborhood } from '../components/dashboard/RevenueByNeighborhood';
|
||||
import { GoldenLeadsScatter } from '../components/dashboard/GoldenLeadsScatter';
|
||||
import { WeatherRiskGauge } from '../components/dashboard/WeatherRiskGauge';
|
||||
|
||||
const Dashboard = () => {
|
||||
const { properties, meetings } = useMockStore();
|
||||
const { user } = useAuth();
|
||||
|
||||
// --- 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());
|
||||
@@ -24,11 +92,7 @@ const Dashboard = () => {
|
||||
|
||||
// Derived Metrics & Split Schedule
|
||||
const hotLeads = properties.filter(p => p.canvassingStatus === 'Hot Lead');
|
||||
|
||||
// Filter meetings for current user (or fallback to e1 for dev)
|
||||
const allMyMeetings = meetings.filter(m => m.agentId === user?.id || m.agentId === 'e1');
|
||||
|
||||
// Split into Upcoming vs Past
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
@@ -103,7 +167,50 @@ const Dashboard = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main Content Grid */}
|
||||
{/* --- 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>
|
||||
|
||||
{/* Owner Intent Funnel */}
|
||||
<div className="lg:col-span-1 h-80">
|
||||
<OwnerIntentFunnel properties={properties} />
|
||||
</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 */}
|
||||
@@ -145,53 +252,45 @@ const Dashboard = () => {
|
||||
</div>
|
||||
</div>
|
||||
</SpotlightCard>
|
||||
|
||||
{/* Split Schedule System */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
|
||||
{/* Upcoming Meetings */}
|
||||
<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>
|
||||
|
||||
{/* Past / Completed Meetings */}
|
||||
<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>
|
||||
|
||||
{/* Right Col: Weather Widget */}
|
||||
{/* Right Col: Schedule */}
|
||||
<div className="space-y-8">
|
||||
<RealWeatherWidget />
|
||||
<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>
|
||||
@@ -249,95 +348,10 @@ const MetricCard = ({ title, value, trend, icon: Icon, trendColor, bgGlow }) =>
|
||||
</SpotlightCard>
|
||||
);
|
||||
|
||||
const RealWeatherWidget = () => {
|
||||
const [weather, setWeather] = useState(null);
|
||||
const [forecast, setForecast] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [lastUpdated, setLastUpdated] = useState(null);
|
||||
|
||||
const API_KEY = config.weatherApiKey;
|
||||
const CITY = "Plano,US";
|
||||
|
||||
const fetchWeather = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// --- DEMO MODE CHECK ---
|
||||
if (config.isDemoMode(API_KEY)) {
|
||||
// Simulate network delay
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
|
||||
// MOCK DATA
|
||||
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 }
|
||||
};
|
||||
|
||||
// Mock Forecast (Next 15 hours)
|
||||
const mockForecast = Array.from({ length: 5 }).map((_, i) => ({
|
||||
dt: Math.floor(Date.now() / 1000) + (i * 10800), // +3 hours each
|
||||
main: { temp: 75 + (i % 2 === 0 ? 2 : -2) },
|
||||
weather: [{ id: 800 + (i * 10) }] // Varying sunny/cloudy codes
|
||||
}));
|
||||
|
||||
setWeather(mockCurrent);
|
||||
setForecast(mockForecast);
|
||||
setLastUpdated(new Date());
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return; // Exit early
|
||||
}
|
||||
|
||||
// --- REAL API FETCH ---
|
||||
// Fetch Current Weather
|
||||
const currentRes = await fetch(
|
||||
`https://api.openweathermap.org/data/2.5/weather?q=${CITY}&units=imperial&appid=${API_KEY}`
|
||||
);
|
||||
|
||||
if (!currentRes.ok) {
|
||||
const errData = await currentRes.json().catch(() => ({ message: currentRes.statusText }));
|
||||
throw new Error(errData.message || `API Error: ${currentRes.status}`);
|
||||
}
|
||||
|
||||
const currentData = await currentRes.json();
|
||||
|
||||
// Fetch 5 Day / 3 Hour Forecast
|
||||
const forecastRes = await fetch(
|
||||
`https://api.openweathermap.org/data/2.5/forecast?q=${CITY}&units=imperial&appid=${API_KEY}`
|
||||
);
|
||||
|
||||
if (!forecastRes.ok) {
|
||||
const errData = await forecastRes.json().catch(() => ({ message: forecastRes.statusText }));
|
||||
throw new Error(errData.message || `Forecast Error: ${forecastRes.status}`);
|
||||
}
|
||||
|
||||
const forecastData = await forecastRes.json();
|
||||
|
||||
setWeather(currentData);
|
||||
setForecast(forecastData.list?.slice(0, 5) || []);
|
||||
setLastUpdated(new Date());
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error("Weather fetch failed", err);
|
||||
setError(err.message);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchWeather();
|
||||
// Refresh every 10 minutes
|
||||
const interval = setInterval(fetchWeather, 600000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
const RealWeatherWidget = ({ weather, forecast, loading, error, lastUpdated, onRetry }) => {
|
||||
|
||||
// --- Icons Logic ---
|
||||
const getWeatherIcon = (code) => {
|
||||
// OWM codes: https://openweathermap.org/weather-conditions
|
||||
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)]" />;
|
||||
@@ -352,11 +366,11 @@ const RealWeatherWidget = () => {
|
||||
return <Sun size={16} className="text-amber-500 dark:text-white" />;
|
||||
};
|
||||
|
||||
if (loading && !weather) return <SpotlightCard><div className="h-80 flex items-center justify-center text-zinc-500"><Loader2 className="animate-spin mr-3" /> Syncing OWM...</div></SpotlightCard>;
|
||||
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-80 flex flex-col items-center justify-center text-red-500 dark:text-red-400 p-6 text-center">
|
||||
<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">
|
||||
@@ -367,24 +381,23 @@ const RealWeatherWidget = () => {
|
||||
Please configure your <code>VITE_OPENWEATHER_API_KEY</code> in the <code>.env</code> file.
|
||||
</p>
|
||||
)}
|
||||
<button onClick={fetchWeather} 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">
|
||||
<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-80 flex items-center justify-center text-zinc-500">Weather Unavailable</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 (
|
||||
// Added text-zinc-900 to ensure text is visible in light mode
|
||||
<SpotlightCard className="text-zinc-900 dark:text-white overflow-hidden relative border-t-zinc-200 dark:border-t-white/20">
|
||||
<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-8">
|
||||
<div className="flex justify-between items-start mb-8">
|
||||
<div className="relative z-10 p-8 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">
|
||||
@@ -409,15 +422,10 @@ const RealWeatherWidget = () => {
|
||||
<div className="mb-2 transform hover:scale-110 transition-transform duration-500 ease-out">
|
||||
{getWeatherIcon(weather.weather[0].id)}
|
||||
</div>
|
||||
<p className="font-semibold text-zinc-600 dark:text-zinc-200 text-lg capitalize">{weather.weather[0].description}</p>
|
||||
<div className="text-xs text-zinc-400 flex flex-col items-end mt-1 font-mono">
|
||||
<span>H: {Math.round(weather.main.temp_max)}°F L: {Math.round(weather.main.temp_min)}°F</span>
|
||||
<span>Feels: {Math.round(weather.main.feels_like)}°F</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-10">
|
||||
<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" />
|
||||
@@ -435,7 +443,6 @@ const RealWeatherWidget = () => {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-zinc-500 mb-4 ml-1">Forecast (3-Hour Steps)</p>
|
||||
<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);
|
||||
@@ -459,3 +466,4 @@ const RealWeatherWidget = () => {
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user