feat: complete dashboard widgets, fix tooltips, integrate chatbot, and update docs

This commit is contained in:
Satyam
2026-02-01 16:00:17 +05:30
parent cfc5c943e5
commit 719172372e
12 changed files with 1269 additions and 296 deletions
+39 -1
View File
@@ -39,7 +39,7 @@ GUIDELINES:
const Chatbot = () => {
const { user } = useAuth();
const { addMeeting, meetings } = useMockStore();
const { addMeeting, meetings, properties } = useMockStore();
const [isOpen, setIsOpen] = useState(false);
const [isMinimized, setIsMinimized] = useState(false);
const [input, setInput] = useState('');
@@ -127,6 +127,44 @@ ${scheduleSummary}`;
systemContext += `\n\nCURRENT USER CONTEXT: Guest (Not Logged In)`;
}
// --- INJECT DASHBOARD DATA ---
// Calculate real-time metrics to give the AI context about the territory
if (properties && properties.length > 0) {
const totalProps = properties.length;
// 1. Golden Leads
const goldenLeads = properties.filter(p =>
p.propertyData.yearBuilt < 2000 && p.propertyData.currentEstimatedMarketValue > 400000
);
const goldenLeadAddresses = goldenLeads.slice(0, 3).map(p => p.propertyData.propertyAddress).join(', ');
// 2. Hot Leads
const hotLeads = properties.filter(p => p.canvassingStatus === 'Hot Lead');
// 3. Roof Health
const roofCounts = properties.reduce((acc, p) => {
const c = p.propertyData.roofCondition || 'Unknown';
acc[c] = (acc[c] || 0) + 1;
return acc;
}, {});
// 4. Intent
const willingToSell = properties.filter(p => p.ownerData.willingToSellProperty).length;
const willingToRent = properties.filter(p => p.ownerData.willingToRentProperty).length;
// 5. Risk Index (simplified calculation for context)
const vulnerableCount = (roofCounts['Fair'] || 0) + (roofCounts['Needs Repair'] || 0);
const riskPercent = Math.round((vulnerableCount / totalProps) * 100);
systemContext += `\n\nLIVE DASHBOARD INTELLIGENCE (Use this to answer questions about leads and territory status):
- Total Properties in Territory: ${totalProps}
- GOLDEN LEADS (High Value + Older Homes): ${goldenLeads.length} found. (Top examples: ${goldenLeadAddresses}...)
- HOT LEADS (Priority for Canvassing): ${hotLeads.length} properties.
- OWNER INTENT: ${willingToSell} owners willing to sell, ${willingToRent} willing to rent.
- ROOF HEALTH BREAKDOWN: Excellent: ${roofCounts['Excellent'] || 0}, Good: ${roofCounts['Good'] || 0}, Fair: ${roofCounts['Fair'] || 0}, Needs Repair: ${roofCounts['Needs Repair'] || 0}.
- RISK ANALYSIS: ${riskPercent}% of territory has vulnerable roofs.`;
}
const chatCompletion = await groq.chat.completions.create({
messages: [
{ role: 'system', content: systemContext },
+69
View File
@@ -0,0 +1,69 @@
import React, { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { Info } from 'lucide-react';
export const InfoTooltip = ({ text }) => {
const [isVisible, setIsVisible] = useState(false);
const [coords, setCoords] = useState({ top: 0, left: 0 });
const triggerRef = useRef(null);
const updatePosition = () => {
if (triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
setCoords({
top: rect.top - 10, // Default to slightly above
left: rect.left + rect.width / 2
});
}
};
const handleMouseEnter = () => {
updatePosition();
setIsVisible(true);
};
const handleMouseLeave = () => {
setIsVisible(false);
};
useEffect(() => {
if (isVisible) {
window.addEventListener('scroll', updatePosition);
window.addEventListener('resize', updatePosition);
}
return () => {
window.removeEventListener('scroll', updatePosition);
window.removeEventListener('resize', updatePosition);
};
}, [isVisible]);
return (
<>
<div
ref={triggerRef}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
className="cursor-help inline-flex items-center justify-center ml-2"
>
<Info size={14} className="text-zinc-400" />
</div>
{isVisible && createPortal(
<div
className="fixed z-[9999] w-48 p-2 bg-zinc-900 text-xs text-zinc-100 rounded-lg shadow-xl border border-zinc-700 text-center pointer-events-none transition-opacity duration-200"
style={{
top: coords.top,
left: coords.left,
transform: 'translate(-50%, -100%)', // Centered and above
opacity: 1
}}
>
{text}
{/* Tiny triangle pointer */}
<div className="absolute left-1/2 -translate-x-1/2 top-full w-0 h-0 border-l-[6px] border-l-transparent border-r-[6px] border-r-transparent border-t-[6px] border-t-zinc-900"></div>
</div>,
document.body
)}
</>
);
};
@@ -0,0 +1,82 @@
import React, { useMemo } from 'react';
import { ScatterChart, Scatter, XAxis, YAxis, Tooltip, ResponsiveContainer, ZAxis } from 'recharts';
import { SpotlightCard } from '../SpotlightCard';
import { InfoTooltip } from '../InfoTooltip';
const CustomTooltip = ({ active, payload }) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
<div className="bg-zinc-900/90 border border-zinc-700/50 p-3 rounded-lg shadow-xl backdrop-blur-md z-50">
<p className="text-zinc-100 font-bold mb-1">{data.address}</p>
<p className="text-zinc-400 text-xs">Built: {data.year}</p>
<p className="text-emerald-400 text-xs">Value: ${data.value.toLocaleString()}</p>
</div>
);
}
return null;
};
export const GoldenLeadsScatter = ({ properties }) => {
const data = useMemo(() => {
return properties.map(p => ({
x: p.propertyData.yearBuilt,
y: p.propertyData.currentEstimatedMarketValue,
z: 100, // bubble size
address: p.propertyData.propertyAddress.split(',')[0],
year: p.propertyData.yearBuilt,
value: p.propertyData.currentEstimatedMarketValue,
isGolden: p.propertyData.yearBuilt < 2000 && p.propertyData.currentEstimatedMarketValue > 400000
}));
}, [properties]);
return (
<SpotlightCard className="h-full flex flex-col">
<div className="p-6 flex-1 flex flex-col">
<div className="mb-4 flex justify-between items-start">
<div>
<h3 className="text-lg font-bold text-zinc-900 dark:text-white flex items-center">
Golden Leads
<InfoTooltip text="High-value properties built before 2000. These are prime candidates for renovation and modernization." />
</h3>
<p className="text-xs text-zinc-500 dark:text-zinc-400">High Value + Older Homes</p>
</div>
<div className="bg-amber-500/10 border border-amber-500/20 text-amber-600 dark:text-amber-400 text-[10px] px-2 py-1 rounded font-bold uppercase tracking-wider">
Opportunity Zone
</div>
</div>
<div className="flex-1 w-full min-h-[200px]">
<ResponsiveContainer width="100%" height="100%">
<ScatterChart
margin={{ top: 20, right: 20, bottom: 20, left: 0 }}
>
<XAxis
type="number"
dataKey="x"
name="Year Built"
domain={['auto', 'auto']}
tick={{ fill: '#71717a', fontSize: 10 }}
tickCount={5}
axisLine={false}
tickLine={false}
/>
<YAxis
type="number"
dataKey="y"
name="Value"
unit="$"
tick={{ fill: '#71717a', fontSize: 10 }}
tickFormatter={(value) => `${value / 1000}k`}
axisLine={false}
tickLine={false}
/>
<Tooltip content={<CustomTooltip />} cursor={{ strokeDasharray: '3 3' }} />
<Scatter name="Properties" data={data} fill="#eab308" shape="circle" />
</ScatterChart>
</ResponsiveContainer>
</div>
</div>
</SpotlightCard>
);
};
@@ -0,0 +1,74 @@
import React, { useMemo } from 'react';
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts';
import { SpotlightCard } from '../SpotlightCard';
import { InfoTooltip } from '../InfoTooltip';
const CustomTooltip = ({ active, payload }) => {
if (active && payload && payload.length) {
return (
<div className="bg-zinc-900/90 border border-zinc-700/50 p-3 rounded-lg shadow-xl backdrop-blur-md">
<p className="text-zinc-100 font-bold mb-1">{payload[0].payload.name}</p>
<p className="text-zinc-400 text-xs">
{payload[0].value} Owners
</p>
</div>
);
}
return null;
};
export const OwnerIntentFunnel = ({ properties }) => {
const data = useMemo(() => {
const total = properties.length;
const willingToSell = properties.filter(p => p.ownerData.willingToSellProperty).length;
const willingToRent = properties.filter(p => p.ownerData.willingToRentProperty).length;
const hotLeads = properties.filter(p => p.canvassingStatus === 'Hot Lead').length;
return [
{ name: 'Total Owners', value: total, color: '#3f3f46' }, // zinc-700
{ name: 'Willing to Sell', value: willingToSell, color: '#8b5cf6' }, // violet-500
{ name: 'Willing to Rent', value: willingToRent, color: '#3b82f6' }, // blue-500
{ name: 'Hot Leads', value: hotLeads, color: '#f97316' }, // orange-500
];
}, [properties]);
return (
<SpotlightCard className="h-full flex flex-col">
<div className="p-6 flex-1 flex flex-col">
<div className="mb-4">
<h3 className="text-lg font-bold text-zinc-900 dark:text-white flex items-center">
Owner Intent
<InfoTooltip text='Classification of owners based on willingness to sell or rent. Prioritize "Hot Leads" for immediate canvassing.' />
</h3>
<p className="text-xs text-zinc-500 dark:text-zinc-400">Conversion pipeline</p>
</div>
<div className="flex-1 w-full min-h-[200px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
layout="vertical"
data={data}
margin={{ top: 0, right: 30, left: 20, bottom: 0 }}
>
<XAxis type="number" hide />
<YAxis
type="category"
dataKey="name"
width={100}
tick={{ fill: '#71717a', fontSize: 11, fontWeight: 600 }}
axisLine={false}
tickLine={false}
/>
<Tooltip content={<CustomTooltip />} cursor={{ fill: 'transparent' }} />
<Bar dataKey="value" radius={[0, 4, 4, 0]} barSize={24}>
{data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</div>
</SpotlightCard>
);
};
@@ -0,0 +1,76 @@
import React, { useMemo } from 'react';
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts';
import { SpotlightCard } from '../SpotlightCard';
import { InfoTooltip } from '../InfoTooltip';
const CustomTooltip = ({ active, payload, label }) => {
if (active && payload && payload.length) {
return (
<div className="bg-zinc-900/90 border border-zinc-700/50 p-3 rounded-lg shadow-xl backdrop-blur-md">
<p className="text-zinc-100 font-bold mb-1">Rating: {label}/10</p>
<p className="text-zinc-400 text-xs">
Potential Value: <span className="text-emerald-400 font-bold">${payload[0].value.toLocaleString()}</span>
</p>
</div>
);
}
return null;
};
export const RevenueByNeighborhood = ({ properties }) => {
const data = useMemo(() => {
// Group by neighborhood rating for now (simulating "Neighborhoods")
const groups = properties.reduce((acc, p) => {
const rating = p.propertyData.neighborhoodRating || 0;
// Summing Potential Revenue (using repair cost as proxy or just market value significance)
// Let's use Renovation Cost to show "Repair Opportunity"
const value = p.propertyData.renovationCost || 0;
acc[rating] = (acc[rating] || 0) + value;
return acc;
}, {});
return Object.keys(groups).map(key => ({
rating: key,
value: groups[key]
})).sort((a, b) => b.rating - a.rating); // Higher rated neighborhoods first
}, [properties]);
return (
<SpotlightCard className="h-full flex flex-col">
<div className="p-6 flex-1 flex flex-col">
<div className="mb-4">
<h3 className="text-lg font-bold text-zinc-900 dark:text-white flex items-center">
Revenue Potential
<InfoTooltip text="Estimated revenue opportunity based on neighborhood rating and average repair costs." />
</h3>
<p className="text-xs text-zinc-500 dark:text-zinc-400">By Neighborhood Rating</p>
</div>
<div className="flex-1 w-full min-h-[200px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={data}
margin={{ top: 10, right: 10, left: -20, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#3f3f46" opacity={0.2} />
<XAxis
dataKey="rating"
tick={{ fill: '#71717a', fontSize: 10 }}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fill: '#71717a', fontSize: 10 }}
axisLine={false}
tickLine={false}
tickFormatter={(value) => `$${value / 1000}k`}
/>
<Tooltip content={<CustomTooltip />} cursor={{ fill: 'rgba(255,255,255,0.05)' }} />
<Bar dataKey="value" fill="#10b981" radius={[4, 4, 0, 0]} barSize={30} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</SpotlightCard>
);
};
@@ -0,0 +1,98 @@
import React, { useMemo } from 'react';
import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import { SpotlightCard } from '../SpotlightCard';
import { InfoTooltip } from '../InfoTooltip';
const COLORS = {
'Excellent': '#10b981', // emerald-500
'Good': '#3b82f6', // blue-500
'Fair': '#eab308', // yellow-500
'Needs Repair': '#ef4444' // red-500
};
const CustomTooltip = ({ active, payload }) => {
if (active && payload && payload.length) {
return (
<div className="bg-zinc-900/90 border border-zinc-700/50 p-3 rounded-lg shadow-xl backdrop-blur-md">
<p className="text-zinc-100 font-bold mb-1">{payload[0].name}</p>
<p className="text-zinc-400 text-xs">
{payload[0].value} Properties ({((payload[0].payload.percent || 0) * 100).toFixed(0)}%)
</p>
</div>
);
}
return null;
};
export const RoofConditionChart = ({ properties }) => {
const data = useMemo(() => {
const counts = properties.reduce((acc, p) => {
const condition = p.propertyData.roofCondition || 'Unknown';
acc[condition] = (acc[condition] || 0) + 1;
return acc;
}, {});
const total = properties.length;
return Object.keys(counts).map(key => ({
name: key,
value: counts[key],
percent: counts[key] / total
}));
}, [properties]);
return (
<SpotlightCard className="h-full flex flex-col">
<div className="p-6 flex-1 flex flex-col">
<div className="flex items-center justify-between mb-2">
<div>
<h3 className="text-lg font-bold text-zinc-900 dark:text-white flex items-center">
Roof Condition
<InfoTooltip text="Breakdown of roof health across all properties. Data derived from recent inspections and aerial imagery." />
</h3>
<p className="text-xs text-zinc-500 dark:text-zinc-400">Territory health snapshot</p>
</div>
{/* Legend / Key - simpler than Recharts legend */}
<div className="flex gap-2">
<div className="flex items-center gap-1 text-[10px] text-zinc-500">
<div className="w-2 h-2 rounded-full bg-emerald-500"></div> Good
</div>
<div className="flex items-center gap-1 text-[10px] text-zinc-500">
<div className="w-2 h-2 rounded-full bg-red-500"></div> Bad
</div>
</div>
</div>
<div className="flex-1 w-full min-h-[250px] relative">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={80}
paddingAngle={5}
dataKey="value"
stroke="none"
>
{data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[entry.name] || '#6b7280'} />
))}
</Pie>
<Tooltip content={<CustomTooltip />} />
</PieChart>
</ResponsiveContainer>
{/* Center Text */}
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="text-center">
<span className="block text-3xl font-black text-zinc-900 dark:text-white">{properties.length}</span>
<span className="text-[10px] uppercase font-bold text-zinc-500 tracking-wider">Total</span>
</div>
</div>
</div>
</div>
</SpotlightCard>
);
};
@@ -0,0 +1,108 @@
import React, { useMemo } from 'react';
import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts';
import { SpotlightCard } from '../SpotlightCard';
import { Wind, AlertTriangle, ShieldCheck } from 'lucide-react';
import { InfoTooltip } from '../InfoTooltip';
export const WeatherRiskGauge = ({ properties, weather }) => {
const riskScore = useMemo(() => {
// 1. Weather Factor (0-50 points)
// Wind > 20mph = 50pts, 0mph = 0pts
let weatherScore = 0;
if (weather && weather.wind) {
weatherScore = Math.min(weather.wind.speed * 2.5, 50);
}
// 2. Territory Vulnerability (0-50 points)
// % of roofs in "Needs Repair" or "Fair"
const vulnerableCount = properties.filter(p =>
['Fair', 'Needs Repair'].includes(p.propertyData.roofCondition)
).length;
const vulnerabilityRatio = vulnerableCount / (properties.length || 1);
const roofScore = vulnerabilityRatio * 50;
return Math.min(Math.round(weatherScore + roofScore), 100);
}, [properties, weather]);
const data = [
{ value: riskScore },
{ value: 100 - riskScore }
];
const getColor = (score) => {
if (score < 30) return '#10b981'; // Green
if (score < 70) return '#eab308'; // Yellow
if (score >= 70) return '#ef4444'; // Red
return '#10b981';
};
const color = getColor(riskScore);
return (
<SpotlightCard className="h-full flex flex-col">
<div className="p-6 flex-1 flex flex-col items-center justify-between text-center relative overflow-hidden">
{/* Background Glow */}
<div className="absolute top-0 left-0 w-full h-full opacity-10 pointer-events-none" style={{ background: `radial-gradient(circle at center, ${color}, transparent)` }} />
<div className="z-10 w-full mb-2">
<h3 className="text-lg font-bold text-zinc-900 dark:text-white flex items-center justify-center gap-2">
{riskScore > 70 ? <AlertTriangle size={18} className="text-red-500" /> : <ShieldCheck size={18} className="text-emerald-500" />}
Risk Index
<InfoTooltip text="Assessment of storm risk based on current wind speed and percentage of vulnerable roofs (Fair/Needs Repair)." />
</h3>
<p className="text-xs text-zinc-500 dark:text-zinc-400">Storm Impact Probability</p>
</div>
<div className="relative w-full h-[140px] flex items-end justify-center">
{/* Speedometer Chart */}
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={data}
cx="50%"
cy="85%" // Shift down to make it a semi-circle
startAngle={180}
endAngle={0}
innerRadius={80}
outerRadius={100}
stroke="none"
paddingAngle={0}
dataKey="value"
>
<Cell fill={color} />
<Cell fill="#27272a" /> {/* zinc-800 for empty part */}
</Pie>
</PieChart>
</ResponsiveContainer>
{/* Score in Center */}
<div className="absolute bottom-0 text-center pb-2">
<div className="text-5xl font-black text-zinc-900 dark:text-white leading-none">
{riskScore}
</div>
<div className="text-[10px] font-bold uppercase tracking-widest text-zinc-500">
/100
</div>
</div>
</div>
<div className="grid grid-cols-2 w-full gap-4 mt-4 z-10">
<div className="bg-zinc-100 dark:bg-white/5 rounded-lg p-2 flex flex-col items-center border border-zinc-200 dark:border-white/5">
<span className="text-[10px] uppercase text-zinc-500 font-bold">Wind Exp.</span>
<span className="text-sm font-bold text-zinc-700 dark:text-zinc-300 flex items-center gap-1">
<Wind size={12} /> {weather?.wind?.speed ? Math.round(weather.wind.speed) : 0} mph
</span>
</div>
<div className="bg-zinc-100 dark:bg-white/5 rounded-lg p-2 flex flex-col items-center border border-zinc-200 dark:border-white/5">
<span className="text-[10px] uppercase text-zinc-500 font-bold">Vulnerable</span>
<span className="text-sm font-bold text-zinc-700 dark:text-zinc-300">
{Math.round((properties.filter(p => ['Fair', 'Needs Repair'].includes(p.propertyData.roofCondition)).length / properties.length) * 100)}%
</span>
</div>
</div>
</div>
</SpotlightCard>
);
};
+52 -12
View File
@@ -165,11 +165,27 @@ function generateEmail(name, company = null) {
return `${first}.${last}@${domain}`;
}
// Weighted Random Helper
function getWeightedRandom(items, weights) {
const totalWeight = weights.reduce((a, b) => a + b, 0);
const random = Math.random() * totalWeight;
let weightSum = 0;
for (let i = 0; i < items.length; i++) {
weightSum += weights[i];
if (random <= weightSum) {
return items[i];
}
}
return items[items.length - 1]; // Fallback
}
function generateProperties() {
return RAW_LOCATIONS.map((loc, index) => {
const isCommercial = loc.type === "Commercial" || loc.type === "Apartments";
const styleIndex = (index * 7) % PROPERTY_STYLES.length;
// Shuffle styles slightly differently than index-based to avoid repeating patterns
const styleIndex = Math.floor(Math.random() * PROPERTY_STYLES.length);
const style = isCommercial ? null : PROPERTY_STYLES[styleIndex];
let ownerName, ownerEmail, occupation, income, employerName;
@@ -191,20 +207,44 @@ function generateProperties() {
income = ["$80k - $120k", "$120k - $180k", "$200k+", "$60k - $90k"][index % 4];
}
const builtYear = isCommercial ? 1995 + (index % 10) : style.yearBase + (index % 15);
const sqft = isCommercial ? 12000 + (index * 500) : style.areaBase + (index * 132);
const value = isCommercial ? 2500000 + (index * 100000) : style.baseValue + (index * 12500);
// Randomize Year and Value to break lines
const yearOffset = Math.floor(Math.random() * 20) - 10; // +/- 10 years
const builtYear = isCommercial ? 1995 + (index % 10) : style.yearBase + yearOffset;
const isRented = index % 3 === 0;
const valueVariance = (Math.random() * 0.4) - 0.2; // +/- 20%
const baseSqft = isCommercial ? 12000 + (index * 500) : style.areaBase;
const sqft = Math.floor(baseSqft * (1 + (valueVariance / 2))); // Size correlates with value but not perfectly
const baseVal = isCommercial ? 2500000 + (index * 100000) : style.baseValue;
const value = Math.floor(baseVal * (1 + valueVariance));
const isRented = Math.random() > 0.7; // 30% rented
const tenantName = isRented ? (isCommercial ? "Multiple Commercial Tenants" : REAL_NAMES[(index + 5) % REAL_NAMES.length]) : null;
const tenantOcc = isRented ? (isCommercial ? "Retail/Office Mix" : OCCUPATIONS[(index + 3) % OCCUPATIONS.length]) : null;
const rentAmount = isRented ? Math.floor(value * 0.008 / 100) * 100 : null;
// Weighted Roof Condition
// 40% Good, 30% Fair, 20% Excellent, 10% Needs Repair
const roofCondition = getWeightedRandom(
["Good", "Fair", "Excellent", "Needs Repair"],
[0.4, 0.3, 0.2, 0.1]
);
// Canvassing Status Randomization
// 60% Neutral, 20% Hot Lead, 10% Customer, 10% Renovated
const status = isCommercial ? "Neutral" : getWeightedRandom(
["Neutral", "Hot Lead", "Customer", "Renovated"],
[0.6, 0.2, 0.1, 0.1]
);
// Neighborhood Rating (Weighted towards high 7-9)
const neighRating = getWeightedRandom([7, 8, 9, 10, 6, 5], [0.3, 0.3, 0.2, 0.1, 0.05, 0.05]);
return {
id: index + 1,
center: [loc.lat, loc.lng],
polygon: generatePolygon(loc.lat, loc.lng),
canvassingStatus: isCommercial ? "Neutral" : style.canvassingStatus,
canvassingStatus: status,
photos: isCommercial ? ["https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?w=800", "https://images.unsplash.com/photo-1542665952-14513db15293?w=800"] : style.photos,
propertyData: {
propertyId: `P-${2600 + index}`,
@@ -217,9 +257,9 @@ function generateProperties() {
numberOfParkingSpaces: isCommercial ? 25 : 2,
currentEstimatedMarketValue: value,
propertyTaxAssessmentValue: Math.floor(value * 0.85),
roofCondition: ["Good", "Fair", "Excellent", "Needs Repair"][index % 4],
roofCondition: roofCondition,
renovationDescription: isCommercial ? "Commercial Maintenance" : style.desc,
renovationCost: Math.floor(value * 0.05),
renovationCost: Math.floor(value * (Math.random() * 0.05 + 0.01)), // 1-6% of value
lastMajorRenovationDate: "2020-05-15",
lastRoofRepairReplacementDate: "2018-02-10",
recentMajorRepairs: "Routine Maintenance",
@@ -230,12 +270,12 @@ function generateProperties() {
currentMonthlyRentAmount: rentAmount,
leaseStartDate: isRented ? "2023-01-01" : null,
leaseEndDate: isRented ? "2025-01-01" : null,
ownerWillingToRent: index % 4 === 0,
ownerWillingToRent: !isRented && Math.random() > 0.6,
expectedMonthlyRentAmount: Math.floor(value * 0.008 / 100) * 100,
rentalAvailabilityDate: null,
rentalTermsPreference: null,
schoolDistrict: "Plano ISD",
neighborhoodRating: 8 + (index % 3),
neighborhoodRating: neighRating,
crimeRateIndex: "Low",
publicTransportationAccess: "Bus Line 204",
propertyConditionRating: isCommercial ? 4 : style.condition,
@@ -260,10 +300,10 @@ function generateProperties() {
creditScoreRange: "720+",
ownershipType: isCommercial ? "Corporate" : "Individual",
ownershipPercentage: "100%",
willingToRentProperty: false,
willingToRentProperty: Math.random() > 0.8,
desiredMonthlyRentalPrice: null,
rentalConditions: null,
willingToSellProperty: index % 5 === 0,
willingToSellProperty: Math.random() > 0.85,
desiredSellingPrice: Math.floor(value * 1.1),
minimumAcceptableSellingPrice: Math.floor(value * 1.05)
}
+160 -152
View File
@@ -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;