dashboard
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
MapIcon,
|
||||
@@ -10,10 +10,21 @@ import {
|
||||
PlusIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
import LoadingSpinner from '../../components/UI/LoadingSpinner';
|
||||
import { usersAPI, applicationsAPI, mowingAPI, propertiesAPI, weatherAPI } from '../../services/api';
|
||||
|
||||
const Dashboard = () => {
|
||||
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 = [
|
||||
{
|
||||
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',
|
||||
value: '3',
|
||||
change: '+1',
|
||||
changeType: 'positive',
|
||||
value: String(userStats?.totalProperties ?? 0),
|
||||
change: '',
|
||||
changeType: 'neutral',
|
||||
icon: MapIcon,
|
||||
},
|
||||
{
|
||||
name: 'This Month\'s Applications',
|
||||
value: '12',
|
||||
change: '+4',
|
||||
changeType: 'positive',
|
||||
name: "This Month's Applications",
|
||||
value: String(thisMonthApplications ?? 0),
|
||||
change: '',
|
||||
changeType: 'neutral',
|
||||
icon: CalendarDaysIcon,
|
||||
},
|
||||
{
|
||||
name: 'Equipment Items',
|
||||
value: '8',
|
||||
change: '+2',
|
||||
changeType: 'positive',
|
||||
value: String(userStats?.totalEquipment ?? 0),
|
||||
change: '',
|
||||
changeType: 'neutral',
|
||||
icon: WrenchScrewdriverIcon,
|
||||
},
|
||||
{
|
||||
name: 'Products Used',
|
||||
value: '15',
|
||||
change: '+3',
|
||||
changeType: 'positive',
|
||||
name: 'Lawn Sections',
|
||||
value: String(userStats?.totalSections ?? 0),
|
||||
change: '',
|
||||
changeType: 'neutral',
|
||||
icon: BeakerIcon,
|
||||
},
|
||||
];
|
||||
]), [userStats, thisMonthApplications]);
|
||||
|
||||
const recentActivity = [
|
||||
{
|
||||
id: 1,
|
||||
type: 'application',
|
||||
title: 'Applied fertilizer to Front Yard',
|
||||
property: 'Main Property',
|
||||
date: '2 hours ago',
|
||||
status: 'completed',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'plan',
|
||||
title: 'Scheduled weed control treatment',
|
||||
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 = useMemo(() => (
|
||||
upcoming.slice(0, 5).map(plan => ({
|
||||
id: plan.id,
|
||||
title: plan.productDetails?.length ? `Apply ${plan.productDetails.map(p=>p.productName).join(', ')}` : (plan.equipment_name ? `Run ${plan.equipment_name}` : 'Planned application'),
|
||||
property: `${plan.property_name}${plan.section_names ? ' - ' + plan.section_names : ''}`,
|
||||
date: plan.plannedDate ? new Date(plan.plannedDate).toLocaleString() : '',
|
||||
priority: (() => {
|
||||
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';
|
||||
})()
|
||||
}))
|
||||
), [upcoming]);
|
||||
|
||||
const upcomingTasks = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Pre-emergent herbicide application',
|
||||
property: 'Main Property - Front Yard',
|
||||
date: 'March 15, 2024',
|
||||
weather: 'Partly cloudy, 65°F',
|
||||
priority: 'high',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Spring fertilizer application',
|
||||
property: 'Side Property - Back Lawn',
|
||||
date: 'March 20, 2024',
|
||||
weather: 'Sunny, 72°F',
|
||||
priority: 'medium',
|
||||
},
|
||||
];
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
const load = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const [userStatsRes, appsStatsRes, propsRes] = await Promise.all([
|
||||
usersAPI.getStats(),
|
||||
applicationsAPI.getStats({ year: new Date().getFullYear() }),
|
||||
propertiesAPI.getAll()
|
||||
]);
|
||||
|
||||
if (!isMounted) return;
|
||||
setUserStats(userStatsRes.data?.data?.stats || null);
|
||||
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 (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
@@ -134,6 +196,13 @@ const Dashboard = () => {
|
||||
</p>
|
||||
</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 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
{stats.map((stat) => (
|
||||
@@ -146,11 +215,13 @@ const Dashboard = () => {
|
||||
<p className="text-sm font-medium text-gray-500">{stat.name}</p>
|
||||
<div className="flex items-baseline">
|
||||
<p className="text-2xl font-semibold text-gray-900">{stat.value}</p>
|
||||
<span className={`ml-2 text-sm font-medium ${
|
||||
stat.changeType === 'positive' ? 'text-green-600' : 'text-red-600'
|
||||
}`}>
|
||||
{stat.change}
|
||||
</span>
|
||||
{stat.change && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -201,7 +272,7 @@ const Dashboard = () => {
|
||||
</Link>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{recentActivity.map((activity) => (
|
||||
{recent.map((activity) => (
|
||||
<div key={activity.id} className="flex items-start space-x-3">
|
||||
<div className={`flex-shrink-0 p-1 rounded-full ${
|
||||
activity.status === 'completed' ? 'bg-green-100' : 'bg-blue-100'
|
||||
@@ -215,7 +286,7 @@ const Dashboard = () => {
|
||||
{activity.property && (
|
||||
<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>
|
||||
))}
|
||||
@@ -242,10 +313,7 @@ const Dashboard = () => {
|
||||
<p className="text-xs text-gray-500 mt-1">{task.property}</p>
|
||||
<div className="flex items-center mt-2 space-x-4">
|
||||
<span className="text-xs text-gray-500">{task.date}</span>
|
||||
<div className="flex items-center space-x-1">
|
||||
<CloudIcon className="h-3 w-3 text-gray-400" />
|
||||
<span className="text-xs text-gray-500">{task.weather}</span>
|
||||
</div>
|
||||
{/* Weather summary for the specific task could be added here */}
|
||||
</div>
|
||||
</div>
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
@@ -277,28 +345,42 @@ const Dashboard = () => {
|
||||
Detailed forecast
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<CloudIcon className="h-8 w-8 text-blue-500" />
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-gray-900">72°F</p>
|
||||
<p className="text-sm text-gray-500">Partly cloudy</p>
|
||||
{!defaultProperty && (
|
||||
<div className="text-sm text-gray-600">Add a property with coordinates to see local weather.</div>
|
||||
)}
|
||||
{defaultProperty && !weather && (
|
||||
<div className="text-sm text-gray-600">Weather unavailable. Configure WEATHER_API_KEY to enable.</div>
|
||||
)}
|
||||
{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 className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">Wind:</span>
|
||||
<span className="text-sm font-medium text-gray-900">5 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">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 className="mt-4 p-3 bg-green-50 rounded-lg">
|
||||
<p className="text-sm text-green-800">
|
||||
{`Location: ${weather.location?.propertyName}`}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user