307 lines
12 KiB
JavaScript
307 lines
12 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
|
import { applicationsAPI } from '../../services/api';
|
|
import PropertyMap from '../Maps/PropertyMap';
|
|
import { XMarkIcon, ClockIcon, MapPinIcon, WrenchScrewdriverIcon, BeakerIcon } from '@heroicons/react/24/outline';
|
|
|
|
const ApplicationViewModal = ({ application, propertyDetails, onClose }) => {
|
|
const [applicationLog, setApplicationLog] = useState(null);
|
|
const [sections, setSections] = useState([]);
|
|
const [planDetails, setPlanDetails] = useState(null);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
// Haversine distance between two lat/lng 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; // feet
|
|
};
|
|
|
|
const distanceFeet = (log) => {
|
|
if (!log?.gpsTrack) return 0;
|
|
const stored = log.gpsTrack.totalDistance;
|
|
if (typeof stored === 'number' && stored > 0) {
|
|
// stored value is meters (from execution modal). Convert to feet.
|
|
return stored * 3.28084;
|
|
}
|
|
return computeDistanceFeetFromPoints(log.gpsTrack.points);
|
|
};
|
|
|
|
// 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 totalDistanceFeet = distanceFeet(log);
|
|
const plannedArea = application.totalSectionArea || 0;
|
|
if (totalDistanceFeet === 0 || plannedArea === 0) return 0;
|
|
|
|
// Estimate equipment width based on type (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;
|
|
|
|
// Distance (ft) * Width (ft) = Area (sq ft)
|
|
const theoreticalCoverageArea = totalDistanceFeet * equipmentWidth;
|
|
const coveragePercentage = Math.min((theoreticalCoverageArea / plannedArea) * 100, 100);
|
|
return Math.round(coveragePercentage);
|
|
};
|
|
|
|
useEffect(() => {
|
|
const fetchApplicationData = async () => {
|
|
if (!application?.id) return;
|
|
|
|
try {
|
|
setLoading(true);
|
|
|
|
// Fetch the plan details to get section geometry
|
|
const planResponse = await applicationsAPI.getPlan(application.id);
|
|
const fetchedPlanDetails = planResponse.data.data.plan;
|
|
setPlanDetails(fetchedPlanDetails);
|
|
|
|
if (fetchedPlanDetails.sections) {
|
|
setSections(fetchedPlanDetails.sections);
|
|
}
|
|
|
|
// Try to fetch application logs to get GPS tracking data
|
|
try {
|
|
const logsResponse = await applicationsAPI.getLogs({ planId: application.id });
|
|
const logs = logsResponse.data.data.logs;
|
|
if (logs && logs.length > 0) {
|
|
setApplicationLog(logs[0]); // Get the most recent log
|
|
}
|
|
} catch (error) {
|
|
console.log('No application logs found:', error);
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Failed to fetch application data:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchApplicationData();
|
|
}, [application?.id]);
|
|
|
|
// Calculate map center from sections
|
|
const mapCenter = React.useMemo(() => {
|
|
if (sections.length === 0) return null;
|
|
|
|
let totalLat = 0;
|
|
let totalLng = 0;
|
|
let pointCount = 0;
|
|
|
|
sections.forEach(section => {
|
|
let polygonData = section.polygonData;
|
|
if (typeof polygonData === 'string') {
|
|
try {
|
|
polygonData = JSON.parse(polygonData);
|
|
} catch (e) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (polygonData?.coordinates?.[0]) {
|
|
polygonData.coordinates[0].forEach(([lat, lng]) => {
|
|
totalLat += lat;
|
|
totalLng += lng;
|
|
pointCount++;
|
|
});
|
|
}
|
|
});
|
|
|
|
if (pointCount > 0) {
|
|
return [totalLat / pointCount, totalLng / pointCount];
|
|
}
|
|
return null;
|
|
}, [sections]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
<div className="bg-white rounded-lg p-6 w-full max-w-4xl mx-4 max-h-[90vh] overflow-y-auto">
|
|
<div className="flex items-center justify-center py-8">
|
|
<div className="text-gray-500">Loading application details...</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
<div className="bg-white rounded-lg p-6 w-full max-w-6xl mx-4 max-h-[90vh] overflow-y-auto">
|
|
{/* Header */}
|
|
<div className="flex justify-between items-center mb-6">
|
|
<h2 className="text-2xl font-bold text-gray-900">
|
|
Application Details
|
|
</h2>
|
|
<button
|
|
onClick={onClose}
|
|
className="text-gray-400 hover:text-gray-600"
|
|
>
|
|
<XMarkIcon className="h-6 w-6" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Application Overview */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
|
<div className="bg-gray-50 p-4 rounded-lg">
|
|
<h3 className="font-semibold text-gray-900 mb-3 flex items-center">
|
|
<MapPinIcon className="h-5 w-5 mr-2" />
|
|
Property & Areas
|
|
</h3>
|
|
<div className="space-y-2">
|
|
<p><span className="font-medium">Property:</span> {application.propertyName}</p>
|
|
<p><span className="font-medium">Areas:</span> {application.sectionNames}</p>
|
|
<p><span className="font-medium">Total Area:</span> {application.totalSectionArea?.toLocaleString()} sq ft</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-gray-50 p-4 rounded-lg">
|
|
<h3 className="font-semibold text-gray-900 mb-3 flex items-center">
|
|
<WrenchScrewdriverIcon className="h-5 w-5 mr-2" />
|
|
Equipment & Status
|
|
</h3>
|
|
<div className="space-y-2">
|
|
<p><span className="font-medium">Equipment:</span> {application.equipmentName}</p>
|
|
<p><span className="font-medium">Status:</span>
|
|
<span className={`ml-2 px-2 py-1 text-xs font-medium rounded-full ${
|
|
application.status === 'completed' ? 'bg-green-100 text-green-800' :
|
|
application.status === 'planned' ? 'bg-blue-100 text-blue-800' :
|
|
application.status === 'in_progress' ? 'bg-yellow-100 text-yellow-800' :
|
|
'bg-gray-100 text-gray-800'
|
|
}`}>
|
|
{application.status}
|
|
</span>
|
|
</p>
|
|
<p><span className="font-medium">Planned Date:</span> {new Date(application.plannedDate).toLocaleDateString()}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Products */}
|
|
{planDetails?.products && planDetails.products.length > 0 && (
|
|
<div className="mb-6">
|
|
<h3 className="font-semibold text-gray-900 mb-3 flex items-center">
|
|
<BeakerIcon className="h-5 w-5 mr-2" />
|
|
Products Applied
|
|
</h3>
|
|
<div className="bg-gray-50 rounded-lg overflow-hidden">
|
|
<div className="grid grid-cols-4 gap-4 p-4 border-b text-sm font-medium text-gray-600">
|
|
<div>Product</div>
|
|
<div>Rate</div>
|
|
<div>Amount</div>
|
|
<div>Water</div>
|
|
</div>
|
|
{planDetails.products.map((product, index) => (
|
|
<div key={index} className="grid grid-cols-4 gap-4 p-4 border-b last:border-b-0">
|
|
<div className="font-medium">{product.productName}</div>
|
|
<div>{product.rateAmount} {product.rateUnit}</div>
|
|
<div>{product.calculatedProductAmount?.toFixed(2)} lbs</div>
|
|
<div>{product.calculatedWaterAmount?.toFixed(2)} gal</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* GPS Tracking Information */}
|
|
{applicationLog && (
|
|
<div className="mb-6">
|
|
<h3 className="font-semibold text-gray-900 mb-3 flex items-center">
|
|
<ClockIcon className="h-5 w-5 mr-2" />
|
|
Tracking Information
|
|
</h3>
|
|
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
|
|
<div className="bg-blue-50 p-3 rounded-lg">
|
|
<div className="text-sm text-blue-600 font-medium">Average Speed</div>
|
|
<div className="text-xl font-bold text-blue-900">
|
|
{(applicationLog.gpsTrack?.averageSpeed || applicationLog.averageSpeed || 0).toFixed(1)} mph
|
|
</div>
|
|
</div>
|
|
<div className="bg-green-50 p-3 rounded-lg">
|
|
<div className="text-sm text-green-600 font-medium">GPS Points</div>
|
|
<div className="text-xl font-bold text-green-900">{applicationLog.gpsTrack?.points?.length || 0}</div>
|
|
</div>
|
|
<div className="bg-purple-50 p-3 rounded-lg">
|
|
<div className="text-sm text-purple-600 font-medium">Duration</div>
|
|
<div className="text-xl font-bold text-purple-900">
|
|
{applicationLog.gpsTrack?.duration ? Math.round(applicationLog.gpsTrack.duration / 60) : 0} min
|
|
</div>
|
|
</div>
|
|
<div className="bg-orange-50 p-3 rounded-lg">
|
|
<div className="text-sm text-orange-600 font-medium">Distance</div>
|
|
<div className="text-xl font-bold text-orange-900">
|
|
{Math.round(distanceFeet(applicationLog))} ft
|
|
</div>
|
|
</div>
|
|
<div className="bg-emerald-50 p-3 rounded-lg">
|
|
<div className="text-sm text-emerald-600 font-medium">Coverage</div>
|
|
<div className="text-xl font-bold text-emerald-900">
|
|
{calculateCoverage(application, applicationLog)}%
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Map with GPS Track */}
|
|
<div className="mb-6">
|
|
<h4 className="font-medium mb-2">Application Area & GPS Track</h4>
|
|
<div className="h-96 border rounded-lg overflow-hidden">
|
|
<PropertyMap
|
|
property={propertyDetails}
|
|
sections={sections}
|
|
selectedSections={sections.map(s => s.id) || []}
|
|
mode="view"
|
|
gpsTrack={applicationLog?.gpsTrack?.points || []}
|
|
currentLocation={null}
|
|
center={mapCenter}
|
|
zoom={mapCenter ? 16 : 15}
|
|
editable={false}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Notes */}
|
|
{(application.notes || applicationLog?.notes) && (
|
|
<div className="mb-6">
|
|
<h3 className="font-semibold text-gray-900 mb-2">Notes</h3>
|
|
<div className="bg-gray-50 p-4 rounded-lg">
|
|
<p className="text-gray-700">{application.notes || applicationLog?.notes}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Close Button */}
|
|
<div className="flex justify-end">
|
|
<button
|
|
onClick={onClose}
|
|
className="px-4 py-2 bg-gray-500 text-white rounded hover:bg-gray-600"
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ApplicationViewModal;
|