zsdfv
This commit is contained in:
148
frontend/src/components/Mowing/MowingSessionViewModal.js
Normal file
148
frontend/src/components/Mowing/MowingSessionViewModal.js
Normal file
@@ -0,0 +1,148 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { mowingAPI, propertiesAPI } from '../../services/api';
|
||||
import PropertyMap from '../Maps/PropertyMap';
|
||||
import { XMarkIcon, ClockIcon, MapPinIcon, WrenchScrewdriverIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
const MowingSessionViewModal = ({ session, onClose }) => {
|
||||
const [sections, setSections] = useState([]);
|
||||
const [sessionDetails, setSessionDetails] = useState(session);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const toFeet = (meters) => (meters || 0) * 3.28084;
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
if (!session?.id) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
// Pull full session with related sections
|
||||
const r = await mowingAPI.getSession(session.id);
|
||||
const s = r.data?.data?.session || session;
|
||||
const secs = r.data?.data?.sections || [];
|
||||
setSessionDetails(s);
|
||||
if (secs.length) {
|
||||
setSections(secs);
|
||||
} else if (s.property_id) {
|
||||
const pr = await propertiesAPI.getById(s.property_id);
|
||||
setSections(pr.data?.data?.property?.sections || []);
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback: try property
|
||||
try {
|
||||
if (session.property_id) {
|
||||
const pr = await propertiesAPI.getById(session.property_id);
|
||||
setSections(pr.data?.data?.property?.sections || []);
|
||||
}
|
||||
} catch {}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, [session?.id, session?.property_id]);
|
||||
|
||||
// Normalize gps track
|
||||
const gpsTrack = useMemo(() => {
|
||||
const g = sessionDetails?.gps_track || sessionDetails?.gpsTrack || {};
|
||||
return g.points || [];
|
||||
}, [sessionDetails]);
|
||||
|
||||
const mapCenter = useMemo(() => {
|
||||
if (!sections.length) return null;
|
||||
let sumLat = 0, sumLng = 0, count = 0;
|
||||
sections.forEach(sec => {
|
||||
let poly = sec.polygonData || sec.polygon_data;
|
||||
if (typeof poly === 'string') { try { poly = JSON.parse(poly); } catch { poly = null; } }
|
||||
if (poly?.coordinates?.[0]) {
|
||||
poly.coordinates[0].forEach(([lat, lng]) => { sumLat += lat; sumLng += lng; count++; });
|
||||
}
|
||||
});
|
||||
return count ? [sumLat / count, sumLng / count] : 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="py-8 text-center text-gray-600">Loading mowing session…</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">Mowing Session Details</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<XMarkIcon className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 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-1 text-sm">
|
||||
<p><span className="font-medium">Property:</span> {sessionDetails.property_name || session.property_name}</p>
|
||||
<p><span className="font-medium">Areas:</span> {sessionDetails.section_names || session.section_names || sections.map(s=>s.name).join(', ')}</p>
|
||||
<p><span className="font-medium">Cut Height:</span> {Number(sessionDetails.cut_height_inches || session.cut_height_inches).toFixed(2)}"</p>
|
||||
<p><span className="font-medium">Direction:</span> {sessionDetails.direction || session.direction}</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 & Stats
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div><span className="font-medium">Mower:</span> {sessionDetails.equipment_name || session.equipment_name}</div>
|
||||
<div><span className="font-medium">Avg Speed:</span> {(sessionDetails.average_speed_mph || sessionDetails.averageSpeed || 0).toFixed?.(1) || Number(sessionDetails.averageSpeed || 0).toFixed(1)} mph</div>
|
||||
<div><span className="font-medium">Duration:</span> {Math.round((sessionDetails.duration_seconds || sessionDetails.durationSeconds || 0)/60)} min</div>
|
||||
<div><span className="font-medium">Distance:</span> {Math.round(toFeet(sessionDetails.total_distance_meters || (sessionDetails.gps_track?.totalDistance || 0)))} ft</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Map */}
|
||||
<div className="mb-6">
|
||||
<h4 className="font-medium mb-2">Mowed Areas & GPS Track</h4>
|
||||
<div className="h-96 border rounded-lg overflow-hidden">
|
||||
<PropertyMap
|
||||
sections={sections}
|
||||
selectedSections={sections.map(s => s.id)}
|
||||
mode="view"
|
||||
gpsTrack={gpsTrack}
|
||||
center={mapCenter}
|
||||
zoom={mapCenter ? 16 : 15}
|
||||
direction={sessionDetails.direction || session.direction}
|
||||
editable={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
{sessionDetails.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">{sessionDetails.notes}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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 MowingSessionViewModal;
|
||||
|
||||
@@ -8,18 +8,21 @@ import {
|
||||
CalendarIcon,
|
||||
ChartBarIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { applicationsAPI } from '../../services/api';
|
||||
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('');
|
||||
@@ -114,6 +117,15 @@ const History = () => {
|
||||
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');
|
||||
@@ -262,6 +274,7 @@ const History = () => {
|
||||
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);
|
||||
const totalMowingSessions = mowingLogs.length;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -665,6 +678,37 @@ const History = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mowing History */}
|
||||
{mowingLogs.length > 0 && (
|
||||
<div className="bg-white rounded-lg shadow mb-8">
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-medium text-gray-900">Mowing History</h3>
|
||||
<p className="text-sm text-gray-500">{totalMowingSessions} session{totalMowingSessions!==1?'s':''}</p>
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{mowingLogs.map((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={log.id} className="flex items-center justify-between p-4">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{log.property_name || 'Property'}</div>
|
||||
<div className="text-sm text-gray-600">{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"
|
||||
>
|
||||
<EyeIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Application View Modal */}
|
||||
{showViewModal && viewingApplication && (
|
||||
<ApplicationViewModal
|
||||
@@ -676,6 +720,14 @@ const History = () => {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mowing Session View Modal */}
|
||||
{viewingMowingSession && (
|
||||
<MowingSessionViewModal
|
||||
session={viewingMowingSession}
|
||||
onClose={() => setViewingMowingSession(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { mowingAPI } from '../../services/api';
|
||||
import LoadingSpinner from '../../components/UI/LoadingSpinner';
|
||||
import MowingPlanModal from '../../components/Mowing/MowingPlanModal';
|
||||
import MowingExecutionModal from '../../components/Mowing/MowingExecutionModal';
|
||||
import MowingSessionViewModal from '../../components/Mowing/MowingSessionViewModal';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const Mowing = () => {
|
||||
@@ -10,12 +11,19 @@ const Mowing = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showPlanModal, setShowPlanModal] = useState(false);
|
||||
const [execPlan, setExecPlan] = useState(null);
|
||||
const [sessions, setSessions] = useState([]);
|
||||
const [viewSession, setViewSession] = useState(null);
|
||||
|
||||
const fetchPlans = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const r = await mowingAPI.getPlans();
|
||||
setPlans(r.data.data.plans || []);
|
||||
// also load recent sessions
|
||||
try {
|
||||
const s = await mowingAPI.getLogs();
|
||||
setSessions(s.data?.data?.logs || []);
|
||||
} catch {}
|
||||
} catch (e) {
|
||||
toast.error('Failed to load mowing plans');
|
||||
} finally {
|
||||
@@ -70,9 +78,41 @@ const Mowing = () => {
|
||||
{execPlan && (
|
||||
<MowingExecutionModal plan={execPlan} onClose={()=> setExecPlan(null)} onComplete={fetchPlans} />
|
||||
)}
|
||||
|
||||
{/* Recent Sessions */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-lg font-semibold mb-3">Recent Sessions</h2>
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div className="grid grid-cols-6 gap-4 p-4 border-b text-sm font-medium text-gray-600">
|
||||
<div>Property</div>
|
||||
<div>Mower</div>
|
||||
<div>Cut Height</div>
|
||||
<div>Avg Speed</div>
|
||||
<div>Distance</div>
|
||||
<div>Actions</div>
|
||||
</div>
|
||||
{sessions.length === 0 ? (
|
||||
<div className="p-4 text-gray-600">No sessions yet.</div>
|
||||
) : sessions.map((s) => (
|
||||
<div key={s.id} className="grid grid-cols-6 gap-4 p-4 border-b text-sm items-center">
|
||||
<div className="font-medium">{s.property_name}</div>
|
||||
<div>{s.equipment_name || '—'}</div>
|
||||
<div>{Number(s.cut_height_inches || 0).toFixed(2)}"</div>
|
||||
<div>{(s.average_speed_mph || s.averageSpeed || 0).toFixed?.(1) || Number(s.averageSpeed || 0).toFixed(1)} mph</div>
|
||||
<div>{Math.round(((s.total_distance_meters || s.gpsTrack?.totalDistance || 0) * 3.28084) || 0)} ft</div>
|
||||
<div>
|
||||
<button className="btn-secondary" onClick={()=> setViewSession(s)}>View</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{viewSession && (
|
||||
<MowingSessionViewModal session={viewSession} onClose={()=> setViewSession(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Mowing;
|
||||
|
||||
|
||||
@@ -245,6 +245,7 @@ export const mowingAPI = {
|
||||
// Logs/Sessions
|
||||
createLog: (data) => apiClient.post('/mowing/sessions', data),
|
||||
getLogs: () => apiClient.get('/mowing/logs'),
|
||||
getSession: (id) => apiClient.get(`/mowing/sessions/${id}`),
|
||||
};
|
||||
|
||||
// Utility functions
|
||||
|
||||
Reference in New Issue
Block a user