feat: complete dashboard widgets, fix tooltips, integrate chatbot, and update docs
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user