mowing modal
This commit is contained in:
141
frontend/src/components/Mowing/MowingExecutionModal.js
Normal file
141
frontend/src/components/Mowing/MowingExecutionModal.js
Normal file
@@ -0,0 +1,141 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { propertiesAPI, mowingAPI } from '../../services/api';
|
||||
import PropertyMap from '../Maps/PropertyMap';
|
||||
import * as turf from '@turf/turf';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const MowingExecutionModal = ({ plan, onClose, onComplete }) => {
|
||||
const [sections, setSections] = useState([]);
|
||||
const [tracking, setTracking] = useState(false);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [gpsTrack, setGpsTrack] = useState([]);
|
||||
const [startTime, setStartTime] = useState(null);
|
||||
const [totalDistance, setTotalDistance] = useState(0);
|
||||
const totalDistanceRef = useRef(0);
|
||||
const [averageSpeed, setAverageSpeed] = useState(0);
|
||||
const [watchId, setWatchId] = useState(null);
|
||||
const wakeLockRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const r = await mowingAPI.getPlan(plan.id);
|
||||
setSections(r.data.data.sections || []);
|
||||
} catch {}
|
||||
};
|
||||
load();
|
||||
}, [plan?.id]);
|
||||
|
||||
const toRad = (d) => (d * Math.PI) / 180;
|
||||
const haversineMeters = (a, b) => {
|
||||
const R = 6371e3; const dLat = toRad(b.lat-a.lat); const dLng = toRad(b.lng-a.lng);
|
||||
const A = Math.sin(dLat/2)**2 + Math.cos(toRad(a.lat))*Math.cos(toRad(b.lat))*Math.sin(dLng/2)**2;
|
||||
return 2 * R * Math.atan2(Math.sqrt(A), Math.sqrt(1-A));
|
||||
};
|
||||
|
||||
const requestWakeLock = async () => { try { if ('wakeLock' in navigator) wakeLockRef.current = await navigator.wakeLock.request('screen'); } catch {} };
|
||||
const releaseWakeLock = async () => { try { await wakeLockRef.current?.release(); } catch {}; wakeLockRef.current = null; };
|
||||
|
||||
const start = () => {
|
||||
if (!navigator.geolocation) { toast.error('GPS not available'); return; }
|
||||
requestWakeLock();
|
||||
setTracking(true); setIsPaused(false);
|
||||
if (!startTime) setStartTime(new Date());
|
||||
const id = navigator.geolocation.watchPosition((pos) => {
|
||||
const { latitude, longitude, speed } = pos.coords;
|
||||
const pt = { lat: latitude, lng: longitude, timestamp: new Date(pos.timestamp).toISOString(), speed: speed || 0 };
|
||||
setGpsTrack(prev => {
|
||||
if (prev.length > 0) {
|
||||
const meters = haversineMeters(prev[prev.length-1], pt);
|
||||
const newTotal = totalDistanceRef.current + meters; totalDistanceRef.current = newTotal; setTotalDistance(newTotal);
|
||||
const seconds = startTime ? ((new Date(pos.timestamp)-startTime)/1000) : 0; if (seconds>0) setAverageSpeed((newTotal/seconds)*2.237);
|
||||
}
|
||||
return [...prev, pt];
|
||||
});
|
||||
}, (err)=> toast.error('GPS error: '+err.message), { enableHighAccuracy: true, timeout: 5000, maximumAge: 1000 });
|
||||
setWatchId(id);
|
||||
};
|
||||
|
||||
const pause = () => { if (watchId) navigator.geolocation.clearWatch(watchId); setWatchId(null); setTracking(false); setIsPaused(true); releaseWakeLock(); };
|
||||
|
||||
const complete = async () => {
|
||||
pause();
|
||||
try {
|
||||
const durationSeconds = startTime ? Math.round((new Date()-startTime)/1000) : 0;
|
||||
// coverage via mower deck width
|
||||
let areaCoveredSqft = null; const widthInches = plan.cutting_width_inches || plan.cut_height_inches ? null : null; // if provided later
|
||||
try {
|
||||
const line = turf.lineString(gpsTrack.map(p => [p.lng, p.lat]));
|
||||
const bufferKm = ( (plan.equipment_cut_width_inches || 0) / 12 / 2) * 0.3048 / 1000; // if present
|
||||
if (bufferKm>0) {
|
||||
const swath = turf.buffer(line, bufferKm, { units: 'kilometers' });
|
||||
const polys = sections.map(s=>{ let poly=s.polygonData; if (typeof poly==='string'){ try{ poly=JSON.parse(poly);}catch{return null;} } if (!poly?.coordinates?.[0]) return null; const coords=poly.coordinates[0].map(([lat,lng])=>[lng,lat]); return turf.polygon([coords]); }).filter(Boolean);
|
||||
if (polys.length){ const union=polys.reduce((a,c)=>a? turf.union(a,c):c,null); if (union){ const ov=turf.intersect(swath, union); if (ov){ const sqm=turf.area(ov); areaCoveredSqft=Math.round(sqm/0.092903); }}}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const payload = {
|
||||
propertyId: plan.property_id,
|
||||
lawnSectionIds: sections.map(s=>s.id),
|
||||
equipmentId: plan.equipment_id,
|
||||
cutHeightInches: plan.cut_height_inches,
|
||||
direction: plan.direction,
|
||||
gpsTrack: { points: gpsTrack, totalDistance: Math.round(totalDistance*100)/100, duration: durationSeconds, averageSpeed: Math.round(averageSpeed*100)/100 },
|
||||
averageSpeed: Math.max(averageSpeed, 0.1),
|
||||
durationSeconds,
|
||||
totalDistanceMeters: Math.round(totalDistance*100)/100,
|
||||
areaCoveredSqft,
|
||||
notes: ''
|
||||
};
|
||||
await mowingAPI.createLog(payload);
|
||||
await mowingAPI.updatePlanStatus(plan.id, 'completed');
|
||||
toast.success('Mowing session saved');
|
||||
onComplete?.();
|
||||
onClose();
|
||||
} catch (e) { toast.error(e.response?.data?.message || 'Failed to save session'); }
|
||||
};
|
||||
|
||||
useEffect(() => () => { if (watchId) navigator.geolocation.clearWatch(watchId); releaseWakeLock(); }, [watchId]);
|
||||
|
||||
const center = (() => {
|
||||
let totalLat=0,totalLng=0,count=0; sections.forEach(s=>{ let poly=s.polygonData; if (typeof poly==='string'){ try{poly=JSON.parse(poly);}catch{return;} } if (poly?.coordinates?.[0]){ poly.coordinates[0].forEach(([lat,lng])=>{ totalLat+=lat; totalLng+=lng; count++;});}}); return count? [totalLat/count, totalLng/count]: null; })();
|
||||
|
||||
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 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-xl font-semibold">Execute Mowing</h3>
|
||||
<button onClick={onClose} className="text-gray-500 hover:text-gray-700">✕</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-4 text-sm mb-4">
|
||||
<div><span className="font-medium">Property:</span> {plan.property_name}</div>
|
||||
<div><span className="font-medium">Areas:</span> {plan.section_names}</div>
|
||||
<div><span className="font-medium">Mower:</span> {plan.equipment_name}</div>
|
||||
<div><span className="font-medium">Cut Height:</span> {plan.cut_height_inches}"</div>
|
||||
</div>
|
||||
<div className="h-80 border rounded mb-4">
|
||||
<PropertyMap property={null} sections={sections} selectedSections={sections.map(s=>s.id)} mode="execution" gpsTrack={gpsTrack} currentLocation={null} center={center} zoom={center?16:15} />
|
||||
</div>
|
||||
<div className="flex gap-3 mb-4">
|
||||
{!tracking ? (
|
||||
<button className="btn-primary" onClick={start}>{isPaused ? 'Resume' : 'Start'}</button>
|
||||
) : (
|
||||
<>
|
||||
<button className="btn-secondary" onClick={pause}>Pause</button>
|
||||
<button className="btn-primary" onClick={complete}>Complete</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-4 text-sm">
|
||||
<div><span className="font-medium">Points:</span> {gpsTrack.length}</div>
|
||||
<div><span className="font-medium">Distance:</span> {(totalDistance*3.28084).toFixed(0)} ft</div>
|
||||
<div><span className="font-medium">Avg Speed:</span> {averageSpeed.toFixed(1)} mph</div>
|
||||
<div><span className="font-medium">Duration:</span> {startTime ? Math.round((new Date()-startTime)/60000) : 0} min</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MowingExecutionModal;
|
||||
|
||||
114
frontend/src/components/Mowing/MowingPlanModal.js
Normal file
114
frontend/src/components/Mowing/MowingPlanModal.js
Normal file
@@ -0,0 +1,114 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { propertiesAPI, equipmentAPI, mowingAPI } from '../../services/api';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const directionOptions = [
|
||||
{ value: 'N_S', label: 'North to South' },
|
||||
{ value: 'E_W', label: 'East to West' },
|
||||
{ value: 'NE_SW', label: 'NE to SW' },
|
||||
{ value: 'NW_SE', label: 'NW to SE' },
|
||||
{ value: 'CIRCULAR', label: 'Circular' },
|
||||
];
|
||||
|
||||
const MowingPlanModal = ({ onClose, onCreated }) => {
|
||||
const [properties, setProperties] = useState([]);
|
||||
const [mowers, setMowers] = useState([]);
|
||||
const [sections, setSections] = useState([]);
|
||||
const [propertyId, setPropertyId] = useState('');
|
||||
const [lawnSectionIds, setLawnSectionIds] = useState([]);
|
||||
const [equipmentId, setEquipmentId] = useState('');
|
||||
const [plannedDate, setPlannedDate] = useState(new Date().toISOString().slice(0,10));
|
||||
const [cutHeightInches, setCutHeightInches] = useState(3.0);
|
||||
const [direction, setDirection] = useState('N_S');
|
||||
const [notes, setNotes] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const [props, eq] = await Promise.all([propertiesAPI.getAll(), equipmentAPI.getAll()]);
|
||||
setProperties(props.data.data.properties || []);
|
||||
const m = (eq.data.data.equipment || []).filter(e => (e.categoryName || '').toLowerCase().includes('mower'));
|
||||
setMowers(m);
|
||||
} catch (e) { toast.error('Failed to load data'); }
|
||||
};
|
||||
load();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const loadSections = async () => {
|
||||
if (!propertyId) { setSections([]); return; }
|
||||
try { const r = await propertiesAPI.getById(propertyId); setSections(r.data.data.property.sections || []);} catch {
|
||||
setSections([]);
|
||||
}
|
||||
};
|
||||
loadSections();
|
||||
}, [propertyId]);
|
||||
|
||||
const create = async () => {
|
||||
try {
|
||||
if (!propertyId || lawnSectionIds.length === 0 || !equipmentId) { toast.error('Missing fields'); return; }
|
||||
await mowingAPI.createPlan({ propertyId: Number(propertyId), lawnSectionIds: lawnSectionIds.map(Number), equipmentId: Number(equipmentId), plannedDate, cutHeightInches: Number(cutHeightInches), direction, notes });
|
||||
toast.success('Mowing plan created');
|
||||
onCreated?.();
|
||||
onClose();
|
||||
} catch (e) { toast.error(e.response?.data?.message || 'Failed to create plan'); }
|
||||
};
|
||||
|
||||
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-3xl max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-xl font-semibold">New Mowing Plan</h3>
|
||||
<button onClick={onClose} className="text-gray-500 hover:text-gray-700">✕</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm mb-1">Property</label>
|
||||
<select className="w-full border rounded px-2 py-2" value={propertyId} onChange={(e)=>{setPropertyId(e.target.value); setLawnSectionIds([]);}}>
|
||||
<option value="">Select…</option>
|
||||
{properties.map(p=> <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm mb-1">Planned Date</label>
|
||||
<input type="date" value={plannedDate} onChange={(e)=> setPlannedDate(e.target.value)} className="w-full border rounded px-2 py-2" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm mb-1">Areas</label>
|
||||
<select multiple className="w-full border rounded px-2 py-2 h-32" value={lawnSectionIds} onChange={(e)=> setLawnSectionIds(Array.from(e.target.selectedOptions).map(o=>o.value))}>
|
||||
{sections.map(s=> <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm mb-1">Mower</label>
|
||||
<select className="w-full border rounded px-2 py-2" value={equipmentId} onChange={(e)=> setEquipmentId(e.target.value)}>
|
||||
<option value="">Select…</option>
|
||||
{mowers.map(m=> <option key={m.id} value={m.id}>{m.customName || m.manufacturer} {m.model}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm mb-1">Cut Height (in)</label>
|
||||
<input type="number" step="0.25" className="w-full border rounded px-2 py-2" value={cutHeightInches} onChange={(e)=> setCutHeightInches(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm mb-1">Direction</label>
|
||||
<select className="w-full border rounded px-2 py-2" value={direction} onChange={(e)=> setDirection(e.target.value)}>
|
||||
{directionOptions.map(d=> <option key={d.value} value={d.value}>{d.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm mb-1">Notes</label>
|
||||
<textarea className="w-full border rounded px-2 py-2" rows={3} value={notes} onChange={(e)=> setNotes(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button className="btn-secondary" onClick={onClose}>Cancel</button>
|
||||
<button className="btn-primary" onClick={create}>Create Plan</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MowingPlanModal;
|
||||
|
||||
Reference in New Issue
Block a user