dashboard

This commit is contained in:
Jake Kasper
2025-09-04 08:57:58 -05:00
parent 658b5bb44d
commit 6d20918bb7

View File

@@ -1,4 +1,4 @@
import React from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { import {
MapIcon, MapIcon,
@@ -10,10 +10,21 @@ import {
PlusIcon, PlusIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import { useAuth } from '../../hooks/useAuth'; import { useAuth } from '../../hooks/useAuth';
import LoadingSpinner from '../../components/UI/LoadingSpinner';
import { usersAPI, applicationsAPI, mowingAPI, propertiesAPI, weatherAPI } from '../../services/api';
const Dashboard = () => { const Dashboard = () => {
const { user } = useAuth(); const { user } = useAuth();
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [userStats, setUserStats] = useState(null);
const [appsStats, setAppsStats] = useState(null);
const [recent, setRecent] = useState([]);
const [upcoming, setUpcoming] = useState([]);
const [weather, setWeather] = useState(null);
const [defaultProperty, setDefaultProperty] = useState(null);
const quickActions = [ const quickActions = [
{ {
name: 'Add Property', name: 'Add Property',
@@ -45,82 +56,133 @@ const Dashboard = () => {
}, },
]; ];
const stats = [ const thisMonthApplications = useMemo(() => {
if (!appsStats?.monthlyBreakdown) return 0;
const m = new Date().getMonth() + 1; // 1-12
const entry = appsStats.monthlyBreakdown.find(x => x.month === m);
return entry ? entry.applications : 0;
}, [appsStats]);
const stats = useMemo(() => ([
{ {
name: 'Properties', name: 'Properties',
value: '3', value: String(userStats?.totalProperties ?? 0),
change: '+1', change: '',
changeType: 'positive', changeType: 'neutral',
icon: MapIcon, icon: MapIcon,
}, },
{ {
name: 'This Month\'s Applications', name: "This Month's Applications",
value: '12', value: String(thisMonthApplications ?? 0),
change: '+4', change: '',
changeType: 'positive', changeType: 'neutral',
icon: CalendarDaysIcon, icon: CalendarDaysIcon,
}, },
{ {
name: 'Equipment Items', name: 'Equipment Items',
value: '8', value: String(userStats?.totalEquipment ?? 0),
change: '+2', change: '',
changeType: 'positive', changeType: 'neutral',
icon: WrenchScrewdriverIcon, icon: WrenchScrewdriverIcon,
}, },
{ {
name: 'Products Used', name: 'Lawn Sections',
value: '15', value: String(userStats?.totalSections ?? 0),
change: '+3', change: '',
changeType: 'positive', changeType: 'neutral',
icon: BeakerIcon, icon: BeakerIcon,
}, },
]; ]), [userStats, thisMonthApplications]);
const recentActivity = [ const upcomingTasks = useMemo(() => (
{ upcoming.slice(0, 5).map(plan => ({
id: 1, id: plan.id,
type: 'application', title: plan.productDetails?.length ? `Apply ${plan.productDetails.map(p=>p.productName).join(', ')}` : (plan.equipment_name ? `Run ${plan.equipment_name}` : 'Planned application'),
title: 'Applied fertilizer to Front Yard', property: `${plan.property_name}${plan.section_names ? ' - ' + plan.section_names : ''}`,
property: 'Main Property', date: plan.plannedDate ? new Date(plan.plannedDate).toLocaleString() : '',
date: '2 hours ago', priority: (() => {
status: 'completed', if (!plan.plannedDate) return 'low';
}, const days = (new Date(plan.plannedDate) - new Date()) / (1000*60*60*24);
{ return days <= 2 ? 'high' : days <= 7 ? 'medium' : 'low';
id: 2, })()
type: 'plan', }))
title: 'Scheduled weed control treatment', ), [upcoming]);
property: 'Back Yard',
date: 'Tomorrow at 9:00 AM',
status: 'planned',
},
{
id: 3,
type: 'equipment',
title: 'Added new broadcast spreader',
property: null,
date: '3 days ago',
status: 'completed',
},
];
const upcomingTasks = [ useEffect(() => {
{ let isMounted = true;
id: 1, const load = async () => {
title: 'Pre-emergent herbicide application', try {
property: 'Main Property - Front Yard', setLoading(true);
date: 'March 15, 2024', setError(null);
weather: 'Partly cloudy, 65°F',
priority: 'high', const [userStatsRes, appsStatsRes, propsRes] = await Promise.all([
}, usersAPI.getStats(),
{ applicationsAPI.getStats({ year: new Date().getFullYear() }),
id: 2, propertiesAPI.getAll()
title: 'Spring fertilizer application', ]);
property: 'Side Property - Back Lawn',
date: 'March 20, 2024', if (!isMounted) return;
weather: 'Sunny, 72°F', setUserStats(userStatsRes.data?.data?.stats || null);
priority: 'medium', setAppsStats(appsStatsRes.data?.data || null);
},
]; // Upcoming plans
const upcomingRes = await applicationsAPI.getPlans({ upcoming: true, status: 'planned' });
if (!isMounted) return;
const upcomingPlans = upcomingRes.data?.data?.plans || [];
setUpcoming(upcomingPlans);
// Recent activity: combine applications + mowing
const [appLogsRes, mowingLogsRes] = await Promise.all([
applicationsAPI.getLogs({ limit: 5 }),
mowingAPI.getLogs({ limit: 5 })
]);
if (!isMounted) return;
const appLogs = (appLogsRes.data?.data?.logs || []).map(l => ({
id: `app-${l.id}`,
type: 'application',
title: `${l.productNames?.length ? 'Applied ' + l.productNames.join(', ') : 'Application'} on ${l.section_names || 'section'}`,
property: l.property_name,
date: l.applicationDate || l.createdAt,
status: 'completed'
}));
const mowLogs = (mowingLogsRes.data?.data?.logs || []).map(l => ({
id: `mow-${l.id}`,
type: 'mowing',
title: `Mowed ${l.section_names || l.property_name || ''}`.trim(),
property: l.property_name,
date: l.session_date || l.created_at,
status: 'completed'
}));
const combined = [...appLogs, ...mowLogs]
.filter(i => i.date)
.sort((a,b)=> new Date(b.date) - new Date(a.date))
.slice(0,5)
.map(i => ({ ...i, dateText: new Date(i.date).toLocaleString() }));
setRecent(combined);
// Weather: pick first property with coordinates
const props = propsRes.data?.data?.properties || [];
const primary = props.find(p => p.latitude && p.longitude) || props[0];
setDefaultProperty(primary || null);
if (primary) {
try {
const w = await weatherAPI.getCurrent(primary.id);
if (!isMounted) return;
setWeather(w.data?.data?.weather || null);
} catch (_) {
setWeather(null);
}
}
} catch (e) {
if (!isMounted) return;
setError(e?.response?.data?.message || e.message || 'Failed to load dashboard');
} finally {
if (isMounted) setLoading(false);
}
};
load();
return () => { isMounted = false; };
}, []);
return ( return (
<div className="p-6 max-w-7xl mx-auto"> <div className="p-6 max-w-7xl mx-auto">
@@ -134,6 +196,13 @@ const Dashboard = () => {
</p> </p>
</div> </div>
{error && (
<div className="mb-6 rounded-md bg-red-50 p-4 text-sm text-red-800 border border-red-100">{error}</div>
)}
{loading && (
<div className="mb-6 flex items-center gap-3 text-gray-600"><LoadingSpinner size="sm" /> Loading dashboard</div>
)}
{/* Stats Grid */} {/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{stats.map((stat) => ( {stats.map((stat) => (
@@ -146,11 +215,13 @@ const Dashboard = () => {
<p className="text-sm font-medium text-gray-500">{stat.name}</p> <p className="text-sm font-medium text-gray-500">{stat.name}</p>
<div className="flex items-baseline"> <div className="flex items-baseline">
<p className="text-2xl font-semibold text-gray-900">{stat.value}</p> <p className="text-2xl font-semibold text-gray-900">{stat.value}</p>
<span className={`ml-2 text-sm font-medium ${ {stat.change && (
stat.changeType === 'positive' ? 'text-green-600' : 'text-red-600' <span className={`ml-2 text-sm font-medium ${
}`}> stat.changeType === 'positive' ? 'text-green-600' : stat.changeType === 'negative' ? 'text-red-600' : 'text-gray-500'
{stat.change} }`}>
</span> {stat.change}
</span>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -201,7 +272,7 @@ const Dashboard = () => {
</Link> </Link>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
{recentActivity.map((activity) => ( {recent.map((activity) => (
<div key={activity.id} className="flex items-start space-x-3"> <div key={activity.id} className="flex items-start space-x-3">
<div className={`flex-shrink-0 p-1 rounded-full ${ <div className={`flex-shrink-0 p-1 rounded-full ${
activity.status === 'completed' ? 'bg-green-100' : 'bg-blue-100' activity.status === 'completed' ? 'bg-green-100' : 'bg-blue-100'
@@ -215,7 +286,7 @@ const Dashboard = () => {
{activity.property && ( {activity.property && (
<p className="text-xs text-gray-500">{activity.property}</p> <p className="text-xs text-gray-500">{activity.property}</p>
)} )}
<p className="text-xs text-gray-400">{activity.date}</p> <p className="text-xs text-gray-400">{activity.dateText}</p>
</div> </div>
</div> </div>
))} ))}
@@ -242,10 +313,7 @@ const Dashboard = () => {
<p className="text-xs text-gray-500 mt-1">{task.property}</p> <p className="text-xs text-gray-500 mt-1">{task.property}</p>
<div className="flex items-center mt-2 space-x-4"> <div className="flex items-center mt-2 space-x-4">
<span className="text-xs text-gray-500">{task.date}</span> <span className="text-xs text-gray-500">{task.date}</span>
<div className="flex items-center space-x-1"> {/* Weather summary for the specific task could be added here */}
<CloudIcon className="h-3 w-3 text-gray-400" />
<span className="text-xs text-gray-500">{task.weather}</span>
</div>
</div> </div>
</div> </div>
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${ <span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
@@ -277,28 +345,42 @@ const Dashboard = () => {
Detailed forecast Detailed forecast
</Link> </Link>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> {!defaultProperty && (
<div className="flex items-center space-x-3"> <div className="text-sm text-gray-600">Add a property with coordinates to see local weather.</div>
<CloudIcon className="h-8 w-8 text-blue-500" /> )}
<div> {defaultProperty && !weather && (
<p className="text-2xl font-semibold text-gray-900">72°F</p> <div className="text-sm text-gray-600">Weather unavailable. Configure WEATHER_API_KEY to enable.</div>
<p className="text-sm text-gray-500">Partly cloudy</p> )}
{weather && (
<>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="flex items-center space-x-3">
{weather.current?.icon ? (
<img alt="icon" className="h-10 w-10" src={`/api/weather/icon/${weather.current.icon}?size=2x`} />
) : (
<CloudIcon className="h-8 w-8 text-blue-500" />
)}
<div>
<p className="text-2xl font-semibold text-gray-900">{Math.round(weather.current?.temperature)}°F</p>
<p className="text-sm text-gray-500 capitalize">{weather.current?.conditions}</p>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500">Wind:</span>
<span className="text-sm font-medium text-gray-900">{Math.round(weather.current?.windSpeed || 0)} mph</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500">Humidity:</span>
<span className="text-sm font-medium text-gray-900">{Math.round(weather.current?.humidity || 0)}%</span>
</div>
</div> </div>
</div> <div className="mt-4 p-3 bg-green-50 rounded-lg">
<div className="flex items-center justify-between"> <p className="text-sm text-green-800">
<span className="text-sm text-gray-500">Wind:</span> {`Location: ${weather.location?.propertyName}`}
<span className="text-sm font-medium text-gray-900">5 mph</span> </p>
</div> </div>
<div className="flex items-center justify-between"> </>
<span className="text-sm text-gray-500">Humidity:</span> )}
<span className="text-sm font-medium text-gray-900">45%</span>
</div>
</div>
<div className="mt-4 p-3 bg-green-50 rounded-lg">
<p className="text-sm text-green-800">
Good conditions for lawn applications today
</p>
</div>
</div> </div>
</div> </div>
</div> </div>