806 lines
34 KiB
JavaScript
806 lines
34 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
|
import {
|
|
ClockIcon,
|
|
MapPinIcon,
|
|
WrenchScrewdriverIcon,
|
|
BeakerIcon,
|
|
EyeIcon,
|
|
CalendarIcon,
|
|
ChartBarIcon
|
|
} from '@heroicons/react/24/outline';
|
|
import { applicationsAPI, mowingAPI } from '../../services/api';
|
|
import LoadingSpinner from '../../components/UI/LoadingSpinner';
|
|
import ApplicationViewModal from '../../components/Applications/ApplicationViewModal';
|
|
import MowingSessionViewModal from '../../components/Mowing/MowingSessionViewModal';
|
|
import toast from 'react-hot-toast';
|
|
|
|
const History = () => {
|
|
// State for applications and UI
|
|
const [completedApplications, setCompletedApplications] = useState([]);
|
|
const [applicationLogs, setApplicationLogs] = useState([]);
|
|
const [mowingLogs, setMowingLogs] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [showViewModal, setShowViewModal] = useState(false);
|
|
const [viewingApplication, setViewingApplication] = useState(null);
|
|
const [viewingMowingSession, setViewingMowingSession] = useState(null);
|
|
const [selectedPropertyDetails, setSelectedPropertyDetails] = useState(null);
|
|
const [dateFilter, setDateFilter] = useState('all'); // all, today, week, month, custom
|
|
const [dateRangeStart, setDateRangeStart] = useState('');
|
|
const [dateRangeEnd, setDateRangeEnd] = useState('');
|
|
const [sortBy, setSortBy] = useState('date'); // date, area, duration
|
|
const [statusFilter, setStatusFilter] = useState('all'); // all, completed, archived
|
|
const [propertyFilter, setPropertyFilter] = useState('all');
|
|
const [selectedProducts, setSelectedProducts] = useState([]);
|
|
const [applicationTypeFilter, setApplicationTypeFilter] = useState('all'); // all, granular, liquid, mowing
|
|
const [showFilters, setShowFilters] = useState(false);
|
|
const [showProductDropdown, setShowProductDropdown] = useState(false);
|
|
|
|
// Haversine in meters
|
|
const haversineMeters = (lat1, lng1, lat2, lng2) => {
|
|
const R = 6371e3;
|
|
const toRad = (d) => (d * Math.PI) / 180;
|
|
const dLat = toRad(lat2 - lat1);
|
|
const dLng = toRad(lng2 - lng1);
|
|
const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
|
|
return 2 * R * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
};
|
|
|
|
const computeDistanceFeetFromPoints = (points = []) => {
|
|
if (!Array.isArray(points) || points.length < 2) return 0;
|
|
let meters = 0;
|
|
for (let i = 1; i < points.length; i++) {
|
|
const p1 = points[i - 1];
|
|
const p2 = points[i];
|
|
meters += haversineMeters(p1.lat, p1.lng, p2.lat, p2.lng);
|
|
}
|
|
return meters * 3.28084;
|
|
};
|
|
|
|
// Calculate coverage percentage based on GPS tracking and equipment specifications
|
|
const calculateCoverage = (application, log) => {
|
|
if (!log?.gpsTrack?.points || log.gpsTrack.points.length < 2) return 0;
|
|
|
|
const storedMeters = typeof log.gpsTrack.totalDistance === 'number' ? log.gpsTrack.totalDistance : 0;
|
|
const totalDistanceFeet = storedMeters > 0 ? storedMeters * 3.28084 : computeDistanceFeetFromPoints(log.gpsTrack.points);
|
|
const plannedArea = application.totalSectionArea || 0;
|
|
if (totalDistanceFeet === 0 || plannedArea === 0) return 0;
|
|
|
|
// Estimate equipment width in feet
|
|
let equipmentWidth = 4;
|
|
const equipmentName = application.equipmentName?.toLowerCase() || '';
|
|
if (equipmentName.includes('spreader')) equipmentWidth = 12;
|
|
else if (equipmentName.includes('sprayer')) equipmentWidth = 20;
|
|
else if (equipmentName.includes('mower')) equipmentWidth = 6;
|
|
|
|
const theoreticalCoverageArea = totalDistanceFeet * equipmentWidth;
|
|
const coveragePercentage = Math.min((theoreticalCoverageArea / plannedArea) * 100, 100);
|
|
return Math.round(coveragePercentage);
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchHistoryData();
|
|
}, []);
|
|
|
|
// Close dropdown when clicking outside
|
|
useEffect(() => {
|
|
const handleClickOutside = (event) => {
|
|
if (showProductDropdown && !event.target.closest('.relative')) {
|
|
setShowProductDropdown(false);
|
|
}
|
|
};
|
|
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => {
|
|
document.removeEventListener('mousedown', handleClickOutside);
|
|
};
|
|
}, [showProductDropdown]);
|
|
|
|
const fetchHistoryData = async () => {
|
|
try {
|
|
setLoading(true);
|
|
|
|
// Fetch completed and archived applications
|
|
const [completedResponse, archivedResponse] = await Promise.all([
|
|
applicationsAPI.getPlans({ status: 'completed' }),
|
|
applicationsAPI.getPlans({ status: 'archived' })
|
|
]);
|
|
|
|
const completedPlans = completedResponse.data.data.plans || [];
|
|
const archivedPlans = archivedResponse.data.data.plans || [];
|
|
const allHistoryApplications = [...completedPlans, ...archivedPlans];
|
|
|
|
|
|
// Fetch application logs for additional details
|
|
const logsResponse = await applicationsAPI.getLogs();
|
|
const logs = logsResponse.data.data.logs || [];
|
|
|
|
setCompletedApplications(allHistoryApplications);
|
|
setApplicationLogs(logs);
|
|
|
|
// Fetch mowing sessions/logs
|
|
try {
|
|
const mowingRes = await mowingAPI.getLogs();
|
|
setMowingLogs(mowingRes.data?.data?.logs || []);
|
|
} catch (e) {
|
|
console.warn('Failed to load mowing logs', e?.response?.data || e.message);
|
|
setMowingLogs([]);
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Failed to fetch history data:', error);
|
|
toast.error('Failed to load application history');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleViewApplication = async (application) => {
|
|
try {
|
|
setViewingApplication(application);
|
|
setShowViewModal(true);
|
|
} catch (error) {
|
|
console.error('Failed to load application details:', error);
|
|
toast.error('Failed to load application details');
|
|
}
|
|
};
|
|
|
|
// Get application type from first product in productDetails
|
|
const getApplicationType = (app) => {
|
|
if (!app.productDetails || app.productDetails.length === 0) return null;
|
|
return app.productDetails[0].type; // granular or liquid
|
|
};
|
|
|
|
// Get unique values for filter options - dynamically filter based on other applied filters
|
|
const getFilteredApplicationsForOptions = () => {
|
|
return completedApplications.filter(app => {
|
|
// Apply all filters except products to get dynamic product list
|
|
if (dateFilter !== 'all') {
|
|
const appDate = new Date(app.plannedDate);
|
|
const now = new Date();
|
|
|
|
switch (dateFilter) {
|
|
case 'today':
|
|
if (appDate.toDateString() !== now.toDateString()) return false;
|
|
break;
|
|
case 'week':
|
|
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
if (appDate < weekAgo) return false;
|
|
break;
|
|
case 'month':
|
|
const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
|
if (appDate < monthAgo) return false;
|
|
break;
|
|
case 'custom':
|
|
if (dateRangeStart) {
|
|
const startDate = new Date(dateRangeStart);
|
|
startDate.setHours(0, 0, 0, 0);
|
|
if (appDate < startDate) return false;
|
|
}
|
|
if (dateRangeEnd) {
|
|
const endDate = new Date(dateRangeEnd);
|
|
endDate.setHours(23, 59, 59, 999);
|
|
if (appDate > endDate) return false;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (statusFilter !== 'all' && app.status !== statusFilter) return false;
|
|
if (propertyFilter !== 'all' && app.propertyName !== propertyFilter) return false;
|
|
if (applicationTypeFilter !== 'all' && getApplicationType(app) !== applicationTypeFilter) return false;
|
|
|
|
return true;
|
|
});
|
|
};
|
|
|
|
const filteredForOptions = getFilteredApplicationsForOptions();
|
|
|
|
const uniqueProperties = [...new Set([
|
|
...completedApplications.map(app => app.propertyName),
|
|
...mowingLogs.map(log => log.property_name)
|
|
])].filter(Boolean);
|
|
const uniqueProducts = [...new Set(
|
|
filteredForOptions.flatMap(app =>
|
|
app.productDetails ? app.productDetails.map(p => p.name) : []
|
|
)
|
|
)].filter(Boolean).sort();
|
|
|
|
// Filter applications based on all filters
|
|
const filteredApplications = completedApplications.filter(app => {
|
|
|
|
// Date filter
|
|
if (dateFilter !== 'all') {
|
|
const appDate = new Date(app.plannedDate);
|
|
const now = new Date();
|
|
|
|
switch (dateFilter) {
|
|
case 'today':
|
|
if (appDate.toDateString() !== now.toDateString()) return false;
|
|
break;
|
|
case 'week':
|
|
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
if (appDate < weekAgo) return false;
|
|
break;
|
|
case 'month':
|
|
const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
|
if (appDate < monthAgo) return false;
|
|
break;
|
|
case 'custom':
|
|
if (dateRangeStart) {
|
|
const startDate = new Date(dateRangeStart);
|
|
startDate.setHours(0, 0, 0, 0);
|
|
if (appDate < startDate) return false;
|
|
}
|
|
if (dateRangeEnd) {
|
|
const endDate = new Date(dateRangeEnd);
|
|
endDate.setHours(23, 59, 59, 999);
|
|
if (appDate > endDate) return false;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Status filter
|
|
if (statusFilter !== 'all' && app.status !== statusFilter) return false;
|
|
|
|
// Property filter
|
|
if (propertyFilter !== 'all' && app.propertyName !== propertyFilter) return false;
|
|
|
|
// Product filter - multi-select
|
|
if (selectedProducts.length > 0) {
|
|
if (!app.productDetails || !app.productDetails.some(p => selectedProducts.includes(p.name))) return false;
|
|
}
|
|
|
|
// Application type filter
|
|
if (applicationTypeFilter === 'mowing') return false; // hide app items when filtering for mowing only
|
|
if (applicationTypeFilter !== 'all' && applicationTypeFilter !== 'mowing' && getApplicationType(app) !== applicationTypeFilter) return false;
|
|
|
|
return true;
|
|
});
|
|
|
|
// Sort applications
|
|
const sortedApplications = [...filteredApplications].sort((a, b) => {
|
|
switch (sortBy) {
|
|
case 'date':
|
|
return new Date(b.plannedDate) - new Date(a.plannedDate);
|
|
case 'area':
|
|
return (b.totalSectionArea || 0) - (a.totalSectionArea || 0);
|
|
case 'duration':
|
|
const logA = applicationLogs.find(log => log.planId === a.id);
|
|
const logB = applicationLogs.find(log => log.planId === b.id);
|
|
return (logB?.gpsTrack?.duration || 0) - (logA?.gpsTrack?.duration || 0);
|
|
default:
|
|
return 0;
|
|
}
|
|
});
|
|
|
|
// Calculate summary statistics
|
|
const totalApplications = completedApplications.length;
|
|
const totalAreaTreated = completedApplications.reduce((sum, app) => sum + (app.totalSectionArea || 0), 0);
|
|
const totalDuration = applicationLogs.reduce((sum, log) => sum + (log.gpsTrack?.duration || 0), 0);
|
|
// Derive filtered + sorted mowing logs using shared filters (date/property)
|
|
const filteredMowingLogs = (mowingLogs || []).filter((log) => {
|
|
// Date filter (use session_date when available, fallback to created_at)
|
|
if (dateFilter !== 'all') {
|
|
const dStr = log.session_date || log.created_at;
|
|
const logDate = dStr ? new Date(dStr) : null;
|
|
if (!logDate) return false;
|
|
const now = new Date();
|
|
switch (dateFilter) {
|
|
case 'today':
|
|
if (logDate.toDateString() !== now.toDateString()) return false;
|
|
break;
|
|
case 'week': {
|
|
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
if (logDate < weekAgo) return false;
|
|
break;
|
|
}
|
|
case 'month': {
|
|
const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
|
if (logDate < monthAgo) return false;
|
|
break;
|
|
}
|
|
case 'custom':
|
|
if (dateRangeStart) {
|
|
const startDate = new Date(dateRangeStart);
|
|
startDate.setHours(0, 0, 0, 0);
|
|
if (logDate < startDate) return false;
|
|
}
|
|
if (dateRangeEnd) {
|
|
const endDate = new Date(dateRangeEnd);
|
|
endDate.setHours(23, 59, 59, 999);
|
|
if (logDate > endDate) return false;
|
|
}
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Property filter (property_name from API)
|
|
if (propertyFilter !== 'all' && (log.property_name || '') !== propertyFilter) return false;
|
|
|
|
return true;
|
|
});
|
|
|
|
const sortedMowingLogs = [...filteredMowingLogs].sort((a, b) => {
|
|
const aDate = a.session_date || a.created_at || 0;
|
|
const bDate = b.session_date || b.created_at || 0;
|
|
return new Date(bDate) - new Date(aDate);
|
|
});
|
|
|
|
const totalMowingSessions = sortedMowingLogs.length;
|
|
|
|
// Build unified history list (applications + mowing) sorted by date
|
|
const unifiedHistoryItems = (() => {
|
|
// Application items
|
|
const apps = [...filteredApplications].map((application) => {
|
|
const log = applicationLogs.find((l) => l.planId === application.id);
|
|
const dateStr = log?.applicationDate || application.plannedDate || application.updatedAt || application.createdAt;
|
|
const date = dateStr ? new Date(dateStr) : new Date(0);
|
|
return { kind: 'application', date, application, log };
|
|
});
|
|
// Mowing items (already date-filtered and property-filtered above)
|
|
const mows = [...sortedMowingLogs].map((log) => {
|
|
const dateStr = log.session_date || log.created_at;
|
|
const date = dateStr ? new Date(dateStr) : new Date(0);
|
|
return { kind: 'mowing', date, log };
|
|
});
|
|
|
|
let items = [...apps, ...mows];
|
|
|
|
// Status filter: mowing sessions are treated as completed
|
|
if (statusFilter === 'archived') {
|
|
items = items.filter((it) => it.kind === 'application' && it.application.status === 'archived');
|
|
} else if (statusFilter === 'completed') {
|
|
items = items.filter((it) => (it.kind === 'application' && it.application.status === 'completed') || it.kind === 'mowing');
|
|
}
|
|
|
|
// Application Type filter across unified list
|
|
if (applicationTypeFilter === 'granular' || applicationTypeFilter === 'liquid') {
|
|
items = items.filter((it) => it.kind === 'application' && getApplicationType(it.application) === applicationTypeFilter);
|
|
} else if (applicationTypeFilter === 'mowing') {
|
|
items = items.filter((it) => it.kind === 'mowing');
|
|
}
|
|
|
|
// Sort newest first
|
|
items.sort((a, b) => b.date - a.date);
|
|
return items;
|
|
})();
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="p-6">
|
|
<LoadingSpinner />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="p-6">
|
|
<div className="flex justify-between items-center mb-6">
|
|
<h1 className="text-2xl font-bold text-gray-900">History</h1>
|
|
<button
|
|
onClick={fetchHistoryData}
|
|
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
|
>
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
|
|
{/* Summary Statistics */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
|
<div className="bg-blue-50 p-4 rounded-lg">
|
|
<div className="flex items-center">
|
|
<ChartBarIcon className="h-8 w-8 text-blue-600" />
|
|
<div className="ml-3">
|
|
<p className="text-sm text-blue-600 font-medium">Total Applications</p>
|
|
<p className="text-2xl font-bold text-blue-900">{totalApplications}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-green-50 p-4 rounded-lg">
|
|
<div className="flex items-center">
|
|
<MapPinIcon className="h-8 w-8 text-green-600" />
|
|
<div className="ml-3">
|
|
<p className="text-sm text-green-600 font-medium">Total Area Treated</p>
|
|
<p className="text-2xl font-bold text-green-900">
|
|
{Math.round(totalAreaTreated / 1000)}k sq ft
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-purple-50 p-4 rounded-lg">
|
|
<div className="flex items-center">
|
|
<ClockIcon className="h-8 w-8 text-purple-600" />
|
|
<div className="ml-3">
|
|
<p className="text-sm text-purple-600 font-medium">Total Time</p>
|
|
<p className="text-2xl font-bold text-purple-900">
|
|
{Math.round(totalDuration / 3600)}h
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filters and Sort */}
|
|
<div className="bg-white rounded-lg shadow mb-6">
|
|
<div className="p-4 border-b border-gray-200">
|
|
<button
|
|
onClick={() => setShowFilters(!showFilters)}
|
|
className="flex items-center justify-between w-full text-left"
|
|
>
|
|
<h3 className="text-lg font-medium text-gray-900">Filters & Sorting</h3>
|
|
<svg
|
|
className={`h-5 w-5 transform transition-transform ${showFilters ? 'rotate-180' : ''}`}
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{showFilters && (
|
|
<div className="p-4">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Time Period
|
|
</label>
|
|
<select
|
|
value={dateFilter}
|
|
onChange={(e) => setDateFilter(e.target.value)}
|
|
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
>
|
|
<option value="all">All Time</option>
|
|
<option value="today">Today</option>
|
|
<option value="week">Last Week</option>
|
|
<option value="month">Last Month</option>
|
|
<option value="custom">Custom Range</option>
|
|
</select>
|
|
|
|
{/* Date Range Inputs */}
|
|
{dateFilter === 'custom' && (
|
|
<div className="mt-2 grid grid-cols-2 gap-2">
|
|
<div>
|
|
<label className="block text-xs text-gray-600 mb-1">From</label>
|
|
<input
|
|
type="date"
|
|
value={dateRangeStart}
|
|
onChange={(e) => setDateRangeStart(e.target.value)}
|
|
className="w-full border border-gray-300 rounded px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs text-gray-600 mb-1">To</label>
|
|
<input
|
|
type="date"
|
|
value={dateRangeEnd}
|
|
onChange={(e) => setDateRangeEnd(e.target.value)}
|
|
className="w-full border border-gray-300 rounded px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Status
|
|
</label>
|
|
<select
|
|
value={statusFilter}
|
|
onChange={(e) => setStatusFilter(e.target.value)}
|
|
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
>
|
|
<option value="all">All Status</option>
|
|
<option value="completed">Completed</option>
|
|
<option value="archived">Archived</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Property
|
|
</label>
|
|
<select
|
|
value={propertyFilter}
|
|
onChange={(e) => setPropertyFilter(e.target.value)}
|
|
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
>
|
|
<option value="all">All Properties</option>
|
|
{uniqueProperties.map(property => (
|
|
<option key={property} value={property}>{property}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Products
|
|
</label>
|
|
<div className="relative">
|
|
<button
|
|
onClick={() => setShowProductDropdown(!showProductDropdown)}
|
|
className="w-full border border-gray-300 rounded px-3 py-2 text-sm text-left focus:outline-none focus:ring-2 focus:ring-blue-500 flex items-center justify-between"
|
|
>
|
|
<span>
|
|
{selectedProducts.length === 0
|
|
? 'All Products'
|
|
: `${selectedProducts.length} selected`
|
|
}
|
|
</span>
|
|
<svg className={`h-4 w-4 transform transition-transform ${showProductDropdown ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</button>
|
|
|
|
{showProductDropdown && (
|
|
<div className="absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-auto">
|
|
{uniqueProducts.length === 0 ? (
|
|
<div className="px-3 py-2 text-sm text-gray-500">No products available</div>
|
|
) : (
|
|
<>
|
|
<div className="px-3 py-2 border-b">
|
|
<button
|
|
onClick={() => {
|
|
if (selectedProducts.length === uniqueProducts.length) {
|
|
setSelectedProducts([]);
|
|
} else {
|
|
setSelectedProducts([...uniqueProducts]);
|
|
}
|
|
}}
|
|
className="text-xs text-blue-600 hover:text-blue-800"
|
|
>
|
|
{selectedProducts.length === uniqueProducts.length ? 'Deselect All' : 'Select All'}
|
|
</button>
|
|
</div>
|
|
{uniqueProducts.map(product => (
|
|
<label key={product} className="flex items-center px-3 py-2 hover:bg-gray-50 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedProducts.includes(product)}
|
|
onChange={(e) => {
|
|
if (e.target.checked) {
|
|
setSelectedProducts([...selectedProducts, product]);
|
|
} else {
|
|
setSelectedProducts(selectedProducts.filter(p => p !== product));
|
|
}
|
|
}}
|
|
className="mr-2"
|
|
/>
|
|
<span className="text-sm">{product}</span>
|
|
</label>
|
|
))}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Application Type
|
|
</label>
|
|
<select
|
|
value={applicationTypeFilter}
|
|
onChange={(e) => setApplicationTypeFilter(e.target.value)}
|
|
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
>
|
|
<option value="all">All Types</option>
|
|
<option value="granular">Granular</option>
|
|
<option value="liquid">Liquid</option>
|
|
<option value="mowing">Mowing</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Sort By
|
|
</label>
|
|
<select
|
|
value={sortBy}
|
|
onChange={(e) => setSortBy(e.target.value)}
|
|
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
>
|
|
<option value="date">Date</option>
|
|
<option value="area">Area Size</option>
|
|
<option value="duration">Duration</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 pt-4 border-t border-gray-200">
|
|
<button
|
|
onClick={() => {
|
|
setDateFilter('all');
|
|
setDateRangeStart('');
|
|
setDateRangeEnd('');
|
|
setStatusFilter('all');
|
|
setPropertyFilter('all');
|
|
setSelectedProducts([]);
|
|
setApplicationTypeFilter('all');
|
|
setSortBy('date');
|
|
setShowProductDropdown(false);
|
|
}}
|
|
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 border border-gray-300 rounded hover:bg-gray-50"
|
|
>
|
|
Clear All Filters
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Unified History List */}
|
|
{unifiedHistoryItems.length === 0 ? (
|
|
<div className="text-center py-12">
|
|
<CalendarIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
|
<h3 className="text-lg font-medium text-gray-900 mb-2">No history items</h3>
|
|
<p className="text-gray-500">Try adjusting your filters or date range.</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid gap-4">
|
|
{unifiedHistoryItems.map((item) => {
|
|
if (item.kind === 'application') {
|
|
const application = item.application;
|
|
const log = item.log;
|
|
return (
|
|
<div key={`app-${application.id}`} className="bg-white p-6 rounded-lg shadow hover:shadow-lg transition-shadow">
|
|
<div className="flex justify-between items-start">
|
|
<div className="flex-1">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="text-lg font-semibold text-gray-900">
|
|
{application.propertyName} - {application.sectionNames}
|
|
</h3>
|
|
<span className={`px-3 py-1 text-sm font-medium rounded-full ${
|
|
application.status === 'archived'
|
|
? 'bg-gray-100 text-gray-800'
|
|
: 'bg-green-100 text-green-800'
|
|
}`}>
|
|
{application.status === 'archived' ? 'Archived' : 'Completed'}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
|
<div className="flex items-center text-sm text-gray-600">
|
|
<CalendarIcon className="h-4 w-4 mr-2" />
|
|
{new Date(item.date).toLocaleString()}
|
|
</div>
|
|
<div className="flex items-center text-sm text-gray-600">
|
|
<MapPinIcon className="h-4 w-4 mr-2" />
|
|
{application.propertyName}
|
|
</div>
|
|
<div className="flex items-center text-sm text-gray-600">
|
|
<WrenchScrewdriverIcon className="h-4 w-4 mr-2" />
|
|
{application.equipmentName}
|
|
</div>
|
|
</div>
|
|
|
|
{/* GPS Tracking Stats */}
|
|
{log?.gpsTrack && (
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 mb-4">
|
|
<div className="bg-blue-50 p-2 rounded text-center">
|
|
<div className="text-xs text-blue-600 font-medium">Duration</div>
|
|
<div className="text-sm font-bold text-blue-900">
|
|
{Math.round((log.gpsTrack?.duration || 0) / 60)} min
|
|
</div>
|
|
</div>
|
|
<div className="bg-green-50 p-2 rounded text-center">
|
|
<div className="text-xs text-green-600 font-medium">GPS Points</div>
|
|
<div className="text-sm font-bold text-green-900">
|
|
{log.gpsTrack?.points?.length || 0}
|
|
</div>
|
|
</div>
|
|
<div className="bg-purple-50 p-2 rounded text-center">
|
|
<div className="text-xs text-purple-600 font-medium">Distance</div>
|
|
<div className="text-sm font-bold text-purple-900">
|
|
{Math.round(log.gpsTrack?.totalDistance || 0)} ft
|
|
</div>
|
|
</div>
|
|
<div className="bg-orange-50 p-2 rounded text-center">
|
|
<div className="text-xs text-orange-600 font-medium">Coverage</div>
|
|
<div className="text-sm font-bold text-orange-900">
|
|
{calculateCoverage(application, log)}%
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Products */}
|
|
{application.productDetails && application.productDetails.length > 0 && (
|
|
<div className="mb-4">
|
|
<div className="flex items-center text-sm text-gray-600 mb-2">
|
|
<BeakerIcon className="h-4 w-4 mr-2" />
|
|
Products Applied:
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{application.productDetails.map((product, index) => (
|
|
<span
|
|
key={index}
|
|
className="px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs"
|
|
>
|
|
{product.name} ({product.rateAmount} {product.rateUnit})
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{application.notes && (
|
|
<p className="text-sm text-gray-600 mb-4">
|
|
<strong>Notes:</strong> {application.notes}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="ml-4">
|
|
<button
|
|
onClick={() => handleViewApplication(application)}
|
|
className="p-2 text-indigo-600 hover:text-indigo-800 hover:bg-indigo-50 rounded"
|
|
title="View details"
|
|
>
|
|
<EyeIcon className="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Mowing entry
|
|
const log = item.log;
|
|
const durationMin = Math.round((log.duration_seconds || log.durationSeconds || log.gpsTrack?.duration || 0) / 60);
|
|
const avg = (log.average_speed_mph || log.averageSpeed || 0).toFixed?.(1) || Number(log.averageSpeed || 0).toFixed(1);
|
|
const distFeet = Math.round(((log.total_distance_meters || log.gpsTrack?.totalDistance || 0) * 3.28084) || 0);
|
|
return (
|
|
<div key={`mow-${log.id}`} className="bg-white p-6 rounded-lg shadow hover:shadow-lg transition-shadow">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<div className="font-medium text-gray-900">{log.property_name || 'Property'}</div>
|
|
<div className="text-sm text-gray-600">{new Date(item.date).toLocaleString()} • {log.equipment_name || 'Mower'} • {avg} mph • {distFeet} ft • {durationMin} min</div>
|
|
</div>
|
|
<button
|
|
onClick={() => setViewingMowingSession(log)}
|
|
className="p-2 text-indigo-600 hover:text-indigo-800 hover:bg-indigo-50 rounded"
|
|
title="View mowing session"
|
|
>
|
|
<EyeIcon className="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Application View Modal */}
|
|
{showViewModal && viewingApplication && (
|
|
<ApplicationViewModal
|
|
application={viewingApplication}
|
|
propertyDetails={selectedPropertyDetails}
|
|
onClose={() => {
|
|
setShowViewModal(false);
|
|
setViewingApplication(null);
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Mowing Session View Modal */}
|
|
{viewingMowingSession && (
|
|
<MowingSessionViewModal
|
|
session={viewingMowingSession}
|
|
onClose={() => setViewingMowingSession(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default History;
|