|
|
|
|
@@ -11,7 +11,10 @@ import {
|
|
|
|
|
SwatchIcon,
|
|
|
|
|
PencilIcon
|
|
|
|
|
} from '@heroicons/react/24/outline';
|
|
|
|
|
import { propertiesAPI } from '../../services/api';
|
|
|
|
|
import { propertiesAPI, applicationsAPI, mowingAPI } from '../../services/api';
|
|
|
|
|
import ApplicationViewModal from '../../components/Applications/ApplicationViewModal';
|
|
|
|
|
import MowingSessionViewModal from '../../components/Mowing/MowingSessionViewModal';
|
|
|
|
|
import { EyeIcon, CalendarIcon, WrenchScrewdriverIcon } from '@heroicons/react/24/outline';
|
|
|
|
|
import LoadingSpinner from '../../components/UI/LoadingSpinner';
|
|
|
|
|
import toast from 'react-hot-toast';
|
|
|
|
|
import 'leaflet/dist/leaflet.css';
|
|
|
|
|
@@ -302,10 +305,45 @@ const PropertyDetail = () => {
|
|
|
|
|
const [editingSection, setEditingSection] = useState(null);
|
|
|
|
|
const [showEditModal, setShowEditModal] = useState(false);
|
|
|
|
|
|
|
|
|
|
// Recent history state for this property
|
|
|
|
|
const [completedApplications, setCompletedApplications] = useState([]);
|
|
|
|
|
const [applicationLogs, setApplicationLogs] = useState([]);
|
|
|
|
|
const [mowingLogs, setMowingLogs] = useState([]);
|
|
|
|
|
const [historyLoading, setHistoryLoading] = useState(false);
|
|
|
|
|
const [viewingApplication, setViewingApplication] = useState(null);
|
|
|
|
|
const [viewingMowingSession, setViewingMowingSession] = useState(null);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
fetchPropertyDetails();
|
|
|
|
|
}, [id]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
|
|
|
|
|
|
// Load recent history when property is available
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!property?.id) return;
|
|
|
|
|
const loadHistory = async () => {
|
|
|
|
|
try {
|
|
|
|
|
setHistoryLoading(true);
|
|
|
|
|
const [completedRes, archivedRes, logsRes, mowRes] = await Promise.all([
|
|
|
|
|
applicationsAPI.getPlans({ status: 'completed', property_id: property.id }),
|
|
|
|
|
applicationsAPI.getPlans({ status: 'archived', property_id: property.id }),
|
|
|
|
|
applicationsAPI.getLogs({ property_id: property.id }),
|
|
|
|
|
mowingAPI.getLogs({ property_id: property.id })
|
|
|
|
|
]);
|
|
|
|
|
const completedPlans = completedRes.data?.data?.plans || [];
|
|
|
|
|
const archivedPlans = archivedRes.data?.data?.plans || [];
|
|
|
|
|
setCompletedApplications([...(completedPlans||[]), ...(archivedPlans||[])]);
|
|
|
|
|
setApplicationLogs(logsRes.data?.data?.logs || []);
|
|
|
|
|
setMowingLogs(mowRes.data?.data?.logs || []);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.warn('Failed to load property history', e?.response?.data || e.message);
|
|
|
|
|
setCompletedApplications([]);
|
|
|
|
|
setApplicationLogs([]);
|
|
|
|
|
setMowingLogs([]);
|
|
|
|
|
} finally { setHistoryLoading(false); }
|
|
|
|
|
};
|
|
|
|
|
loadHistory();
|
|
|
|
|
}, [property?.id]);
|
|
|
|
|
|
|
|
|
|
// Handle keyboard shortcuts for polygon drawing
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const handleKeyPress = (e) => {
|
|
|
|
|
@@ -494,6 +532,55 @@ const PropertyDetail = () => {
|
|
|
|
|
return lawnSections.reduce((total, section) => total + section.area, 0);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// History helpers
|
|
|
|
|
const getApplicationType = (app) => {
|
|
|
|
|
if (!app.productDetails || app.productDetails.length === 0) return null;
|
|
|
|
|
return app.productDetails[0].type;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const calculateCoverage = (application, log) => {
|
|
|
|
|
if (!log?.gpsTrack?.points && !log?.gps_track?.points) return 0;
|
|
|
|
|
const storedMeters = typeof (log.gpsTrack?.totalDistance || log.gps_track?.totalDistance) === 'number' ? (log.gpsTrack?.totalDistance || log.gps_track?.totalDistance) : 0;
|
|
|
|
|
const totalDistanceFeet = storedMeters * 3.28084;
|
|
|
|
|
const plannedArea = application.totalSectionArea || application.total_section_area || 0;
|
|
|
|
|
if (totalDistanceFeet === 0 || plannedArea === 0) return 0;
|
|
|
|
|
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;
|
|
|
|
|
return Math.min(Math.round((theoreticalCoverageArea / plannedArea) * 100), 100);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const calculateMowingCoverage = (log) => {
|
|
|
|
|
const meters = (log.total_distance_meters || log.gpsTrack?.totalDistance || log.gps_track?.totalDistance || 0) || 0;
|
|
|
|
|
const totalDistanceFeet = meters * 3.28084;
|
|
|
|
|
const plannedArea = Number(log.total_area || 0);
|
|
|
|
|
const widthInches = parseFloat(log.cutting_width_inches || 0);
|
|
|
|
|
const widthFeet = isNaN(widthInches) ? 0 : (widthInches / 12);
|
|
|
|
|
if (totalDistanceFeet === 0 || plannedArea === 0 || widthFeet === 0) return 0;
|
|
|
|
|
const theoreticalCoverageArea = totalDistanceFeet * widthFeet;
|
|
|
|
|
return Math.min(100, Math.round((theoreticalCoverageArea / plannedArea) * 100));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const unifiedHistory = (() => {
|
|
|
|
|
const apps = (completedApplications||[]).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 };
|
|
|
|
|
});
|
|
|
|
|
const mows = (mowingLogs||[]).map((log) => {
|
|
|
|
|
const dateStr = log.session_date || log.created_at;
|
|
|
|
|
const date = dateStr ? new Date(dateStr) : new Date(0);
|
|
|
|
|
return { kind: 'mowing', date, log };
|
|
|
|
|
});
|
|
|
|
|
const items = [...apps, ...mows];
|
|
|
|
|
items.sort((a,b)=> b.date - a.date);
|
|
|
|
|
return items.slice(0, 10);
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
if (loading) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="p-6">
|
|
|
|
|
@@ -704,6 +791,95 @@ const PropertyDetail = () => {
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Recent History */}
|
|
|
|
|
<div className="mt-8">
|
|
|
|
|
<h2 className="text-lg font-semibold mb-3">Recent History</h2>
|
|
|
|
|
{historyLoading ? (
|
|
|
|
|
<div className="card p-4"><div className="text-gray-600">Loading…</div></div>
|
|
|
|
|
) : unifiedHistory.length === 0 ? (
|
|
|
|
|
<div className="card p-4 text-gray-600">No history yet for this property.</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="grid gap-4">
|
|
|
|
|
{unifiedHistory.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">
|
|
|
|
|
<div className="flex justify-between items-start">
|
|
|
|
|
<div className="flex-1">
|
|
|
|
|
<div className="flex items-center justify-between mb-4">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<h3 className="text-lg font-semibold text-gray-900">{application.propertyName} - {application.sectionNames}</h3>
|
|
|
|
|
<span className="px-2 py-0.5 text-xs rounded-full bg-indigo-100 text-indigo-800">Application</span>
|
|
|
|
|
</div>
|
|
|
|
|
<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>
|
|
|
|
|
{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>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<button onClick={()=> setViewingApplication(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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
const log = item.log;
|
|
|
|
|
const durationMin = Math.round((log.duration_seconds || log.durationSeconds || log.gpsTrack?.duration || log.gps_track?.duration || 0)/60);
|
|
|
|
|
const distFeet = Math.round(((log.total_distance_meters || log.gpsTrack?.totalDistance || log.gps_track?.totalDistance || 0)*3.28084)||0);
|
|
|
|
|
return (
|
|
|
|
|
<div key={`mow-${log.id}`} className="bg-white p-6 rounded-lg shadow">
|
|
|
|
|
<div className="flex justify-between items-start">
|
|
|
|
|
<div className="flex-1">
|
|
|
|
|
<div className="flex items-center justify-between mb-4">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<h3 className="text-lg font-semibold text-gray-900">{log.property_name} - {log.section_names}</h3>
|
|
|
|
|
<span className="px-2 py-0.5 text-xs rounded-full bg-green-100 text-green-800">Mowing</span>
|
|
|
|
|
</div>
|
|
|
|
|
<span className="px-3 py-1 text-sm font-medium rounded-full bg-green-100 text-green-800">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" />{log.property_name}</div>
|
|
|
|
|
<div className="flex items-center text-sm text-gray-600"><WrenchScrewdriverIcon className="h-4 w-4 mr-2" />{log.equipment_name || 'Mower'}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<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">{durationMin} 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 || log.gps_track?.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">{distFeet} 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">{calculateMowingCoverage(log)}%</div></div>
|
|
|
|
|
</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>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Name Modal */}
|
|
|
|
|
{showNameModal && (
|
|
|
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center" style={{ zIndex: 9999 }}>
|
|
|
|
|
@@ -806,6 +982,21 @@ const PropertyDetail = () => {
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* View Modals for History */}
|
|
|
|
|
{viewingApplication && (
|
|
|
|
|
<ApplicationViewModal
|
|
|
|
|
application={viewingApplication}
|
|
|
|
|
propertyDetails={property}
|
|
|
|
|
onClose={() => setViewingApplication(null)}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
{viewingMowingSession && (
|
|
|
|
|
<MowingSessionViewModal
|
|
|
|
|
session={viewingMowingSession}
|
|
|
|
|
onClose={() => setViewingMowingSession(null)}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|