diff --git a/backend/src/routes/mowing.js b/backend/src/routes/mowing.js
index 89a8344..c101ecf 100644
--- a/backend/src/routes/mowing.js
+++ b/backend/src/routes/mowing.js
@@ -2,7 +2,7 @@ const express = require('express');
const pool = require('../config/database');
const { AppError } = require('../middleware/errorHandler');
const { validateRequest } = require('../utils/validation');
-const { mowingSessionSchema } = require('../utils/validation');
+const { mowingSessionSchema, mowingPlanSchema } = require('../utils/validation');
const router = express.Router();
@@ -127,3 +127,100 @@ router.get('/sessions/:id', async (req, res, next) => {
module.exports = router;
+// ----- Plans -----
+router.post('/plans', validateRequest(mowingPlanSchema), async (req, res, next) => {
+ try {
+ const { propertyId, lawnSectionIds, equipmentId, plannedDate, cutHeightInches, direction, notes } = req.body;
+ // Ownership checks
+ const prop = await pool.query('SELECT id FROM properties WHERE id=$1 AND user_id=$2', [propertyId, req.user.id]);
+ if (prop.rows.length === 0) throw new AppError('Property not found', 404);
+ const equip = await pool.query('SELECT id FROM user_equipment WHERE id=$1 AND user_id=$2', [equipmentId, req.user.id]);
+ if (equip.rows.length === 0) throw new AppError('Equipment not found', 404);
+ const client = await pool.connect();
+ try {
+ await client.query('BEGIN');
+ const ins = await client.query(
+ `INSERT INTO mowing_plans (user_id, property_id, equipment_id, planned_date, cut_height_inches, direction, notes)
+ VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
+ [req.user.id, propertyId, equipmentId, plannedDate, cutHeightInches, direction, notes || null]
+ );
+ const plan = ins.rows[0];
+ for (const sid of lawnSectionIds) {
+ await client.query(`INSERT INTO mowing_plan_sections (plan_id, lawn_section_id) VALUES ($1,$2)`, [plan.id, sid]);
+ }
+ await client.query('COMMIT');
+ res.status(201).json({ success: true, data: { plan: { id: plan.id } } });
+ } catch (e) {
+ await client.query('ROLLBACK');
+ throw e;
+ } finally {
+ client.release();
+ }
+ } catch (error) { next(error); }
+});
+
+router.get('/plans', async (req, res, next) => {
+ try {
+ const rows = await pool.query(
+ `SELECT mp.*, p.name as property_name, ue.custom_name as equipment_name,
+ STRING_AGG(ls.name, ', ') as section_names,
+ SUM(ls.area) as total_area
+ FROM mowing_plans mp
+ JOIN properties p ON mp.property_id=p.id
+ LEFT JOIN user_equipment ue ON mp.equipment_id=ue.id
+ JOIN mowing_plan_sections mps ON mp.id=mps.plan_id
+ JOIN lawn_sections ls ON mps.lawn_section_id=ls.id
+ WHERE mp.user_id=$1
+ GROUP BY mp.id, p.name, ue.custom_name
+ ORDER BY mp.planned_date DESC, mp.created_at DESC
+ LIMIT 200`,
+ [req.user.id]
+ );
+ res.json({ success: true, data: { plans: rows.rows } });
+ } catch (error) { next(error); }
+});
+
+router.get('/plans/:id', async (req, res, next) => {
+ try {
+ const { id } = req.params;
+ const planRes = await pool.query(
+ `SELECT mp.*, p.name as property_name, ue.custom_name as equipment_name
+ FROM mowing_plans mp
+ JOIN properties p ON mp.property_id=p.id
+ LEFT JOIN user_equipment ue ON mp.equipment_id=ue.id
+ WHERE mp.id=$1 AND mp.user_id=$2`, [id, req.user.id]);
+ if (planRes.rows.length === 0) throw new AppError('Plan not found', 404);
+ const sectionsRes = await pool.query(
+ `SELECT ls.id, ls.name, ls.area, ls.polygon_data
+ FROM mowing_plan_sections mps
+ JOIN lawn_sections ls ON mps.lawn_section_id=ls.id
+ WHERE mps.plan_id=$1`, [id]);
+ res.json({ success: true, data: { plan: planRes.rows[0], sections: sectionsRes.rows } });
+ } catch (error) { next(error); }
+});
+
+router.put('/plans/:id/status', async (req, res, next) => {
+ try {
+ const { id } = req.params; const { status } = req.body;
+ const ok = ['planned','in_progress','completed','archived'];
+ if (!ok.includes(status)) throw new AppError('Invalid status', 400);
+ const upd = await pool.query(`UPDATE mowing_plans SET status=$1, updated_at=CURRENT_TIMESTAMP WHERE id=$2 AND user_id=$3`, [status, id, req.user.id]);
+ if (upd.rowCount === 0) throw new AppError('Plan not found', 404);
+ res.json({ success: true });
+ } catch (error) { next(error); }
+});
+
+// Alias /logs to sessions to be consistent with Applications
+router.get('/logs', async (req, res, next) => {
+ try {
+ const rs = await pool.query(
+ `SELECT ms.*, p.name as property_name, ue.custom_name as equipment_name
+ FROM mowing_sessions ms
+ JOIN properties p ON ms.property_id=p.id
+ LEFT JOIN user_equipment ue ON ms.equipment_id=ue.id
+ WHERE ms.user_id=$1
+ ORDER BY ms.created_at DESC
+ LIMIT 200`, [req.user.id]);
+ res.json({ success: true, data: { logs: rs.rows } });
+ } catch (error) { next(error); }
+});
diff --git a/backend/src/utils/validation.js b/backend/src/utils/validation.js
index 2d9dd02..e2e9fed 100644
--- a/backend/src/utils/validation.js
+++ b/backend/src/utils/validation.js
@@ -195,6 +195,16 @@ const mowingSessionSchema = Joi.object({
notes: Joi.string().allow('').optional()
});
+const mowingPlanSchema = Joi.object({
+ propertyId: Joi.number().integer().positive().required(),
+ lawnSectionIds: Joi.array().items(Joi.number().integer().positive()).min(1).required(),
+ equipmentId: Joi.number().integer().positive().required(),
+ plannedDate: Joi.date().required(),
+ cutHeightInches: Joi.number().positive().precision(2).required(),
+ direction: Joi.string().valid('N_S','E_W','NE_SW','NW_SE','CIRCULAR').required(),
+ notes: Joi.string().allow('').optional()
+});
+
// Validation middleware
const validateRequest = (schema) => {
return (req, res, next) => {
@@ -235,6 +245,7 @@ module.exports = {
applicationPlanSchema,
applicationLogSchema,
mowingSessionSchema,
+ mowingPlanSchema,
idParamSchema,
// Middleware
diff --git a/database/migrations/V4__add_mowing_plans.sql b/database/migrations/V4__add_mowing_plans.sql
new file mode 100644
index 0000000..4497e26
--- /dev/null
+++ b/database/migrations/V4__add_mowing_plans.sql
@@ -0,0 +1,24 @@
+-- Mowing plans for plan/execute workflow
+CREATE TABLE IF NOT EXISTS mowing_plans (
+ id SERIAL PRIMARY KEY,
+ user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
+ property_id INTEGER REFERENCES properties(id) ON DELETE CASCADE,
+ equipment_id INTEGER REFERENCES user_equipment(id),
+ planned_date DATE NOT NULL,
+ cut_height_inches DECIMAL(4,2),
+ direction VARCHAR(20) CHECK (direction IN ('N_S','E_W','NE_SW','NW_SE','CIRCULAR')),
+ status VARCHAR(20) DEFAULT 'planned' CHECK (status IN ('planned','in_progress','completed','archived')),
+ notes TEXT,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE TABLE IF NOT EXISTS mowing_plan_sections (
+ id SERIAL PRIMARY KEY,
+ plan_id INTEGER REFERENCES mowing_plans(id) ON DELETE CASCADE,
+ lawn_section_id INTEGER REFERENCES lawn_sections(id) ON DELETE CASCADE
+);
+
+CREATE INDEX IF NOT EXISTS idx_mowing_plans_user_id ON mowing_plans(user_id);
+CREATE INDEX IF NOT EXISTS idx_mowing_plan_sections_plan_id ON mowing_plan_sections(plan_id);
+
diff --git a/frontend/src/components/Mowing/MowingExecutionModal.js b/frontend/src/components/Mowing/MowingExecutionModal.js
new file mode 100644
index 0000000..e7928cd
--- /dev/null
+++ b/frontend/src/components/Mowing/MowingExecutionModal.js
@@ -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 (
+
+
+
+
Execute Mowing
+
+
+
+
Property: {plan.property_name}
+
Areas: {plan.section_names}
+
Mower: {plan.equipment_name}
+
Cut Height: {plan.cut_height_inches}"
+
+
+
s.id)} mode="execution" gpsTrack={gpsTrack} currentLocation={null} center={center} zoom={center?16:15} />
+
+
+ {!tracking ? (
+
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+
Points: {gpsTrack.length}
+
Distance: {(totalDistance*3.28084).toFixed(0)} ft
+
Avg Speed: {averageSpeed.toFixed(1)} mph
+
Duration: {startTime ? Math.round((new Date()-startTime)/60000) : 0} min
+
+
+
+ );
+};
+
+export default MowingExecutionModal;
+
diff --git a/frontend/src/components/Mowing/MowingPlanModal.js b/frontend/src/components/Mowing/MowingPlanModal.js
new file mode 100644
index 0000000..4d75ef5
--- /dev/null
+++ b/frontend/src/components/Mowing/MowingPlanModal.js
@@ -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 (
+
+
+
+
New Mowing Plan
+
+
+
+
+
+
+
+
+
+ setPlannedDate(e.target.value)} className="w-full border rounded px-2 py-2" />
+
+
+
+
+
+
+
+
+
+
+
+ setCutHeightInches(e.target.value)} />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default MowingPlanModal;
+
diff --git a/frontend/src/pages/Mowing/Mowing.js b/frontend/src/pages/Mowing/Mowing.js
index ae59bab..47993af 100644
--- a/frontend/src/pages/Mowing/Mowing.js
+++ b/frontend/src/pages/Mowing/Mowing.js
@@ -1,258 +1,78 @@
-import React, { useEffect, useRef, useState } from 'react';
-import { propertiesAPI, equipmentAPI, apiClient } from '../../services/api';
-import * as turf from '@turf/turf';
+import React, { useEffect, useState } from 'react';
+import { mowingAPI } from '../../services/api';
+import LoadingSpinner from '../../components/UI/LoadingSpinner';
+import MowingPlanModal from '../../components/Mowing/MowingPlanModal';
+import MowingExecutionModal from '../../components/Mowing/MowingExecutionModal';
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' },
-];
-
-// Lightweight execution component (reuses GPS loop philosophy from applications)
const Mowing = () => {
- const [properties, setProperties] = useState([]);
- const [equipment, setEquipment] = useState([]);
- const [selectedProperty, setSelectedProperty] = useState('');
- const [sections, setSections] = useState([]);
- const [selectedSections, setSelectedSections] = useState([]);
- const [selectedEquipment, setSelectedEquipment] = useState('');
- const [cutHeight, setCutHeight] = useState(3.0);
- const [direction, setDirection] = useState('N_S');
- const [tracking, setTracking] = useState(false);
- const [isPaused, setIsPaused] = useState(false);
- const [gpsTrack, setGpsTrack] = useState([]);
- const [startTime, setStartTime] = useState(null);
- const [totalDistance, setTotalDistance] = useState(0); // meters
- const totalDistanceRef = useRef(0);
- const [averageSpeed, setAverageSpeed] = useState(0); // mph
- const [watchId, setWatchId] = useState(null);
- const wakeLockRef = useRef(null);
+ const [plans, setPlans] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [showPlanModal, setShowPlanModal] = useState(false);
+ const [execPlan, setExecPlan] = useState(null);
- useEffect(() => {
- const load = async () => {
- try {
- const [props, equip] = await Promise.all([
- propertiesAPI.getAll(),
- equipmentAPI.getAll(),
- ]);
- setProperties(props.data.data.properties || []);
- const eq = (equip.data.data.equipment || []).filter((e) =>
- (e.categoryName || '').toLowerCase().includes('mower')
- );
- setEquipment(eq);
- } catch (e) {
- toast.error('Failed to load planning data');
- }
- };
- load();
- }, []);
-
- useEffect(() => {
- const loadSections = async () => {
- if (!selectedProperty) { setSections([]); return; }
- try {
- const resp = await propertiesAPI.getById(selectedProperty);
- setSections(resp.data.data.property.sections || []);
- } catch (e) {
- toast.error('Failed to load sections');
- }
- };
- loadSections();
- }, [selectedProperty]);
-
- 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 () => {
+ const fetchPlans = async () => {
try {
- if ('wakeLock' in navigator) {
- wakeLockRef.current = await navigator.wakeLock.request('screen');
- }
+ setLoading(true);
+ const r = await mowingAPI.getPlans();
+ setPlans(r.data.data.plans || []);
} catch (e) {
- // Ignore if unsupported
+ toast.error('Failed to load mowing plans');
+ } finally {
+ setLoading(false);
}
};
- const releaseWakeLock = async () => {
- try {
- await wakeLockRef.current?.release();
- } catch {}
- wakeLockRef.current = null;
- };
+ useEffect(() => { fetchPlans(); }, []);
- const start = () => {
- if (!selectedProperty || selectedSections.length === 0 || !selectedEquipment) {
- toast.error('Select property, sections and mower first');
- return;
- }
- if (!navigator.geolocation) { toast.error('GPS not available'); return; }
- requestWakeLock();
- setTracking(true);
- setIsPaused(false);
- // Resume if we already have a track
- if (!startTime) setStartTime(new Date());
- const id = navigator.geolocation.watchPosition((pos) => {
- const { latitude, longitude, speed } = pos.coords;
- const point = { 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], point);
- 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, point];
- });
- }, (err) => {
- toast.error('GPS error: ' + err.message);
- }, { enableHighAccuracy: true, timeout: 5000, maximumAge: 1000 });
- setWatchId(id);
- };
-
- const stop = () => {
- if (watchId) navigator.geolocation.clearWatch(watchId);
- setWatchId(null);
- setTracking(false);
- setIsPaused(true);
- releaseWakeLock();
- };
-
- const complete = async () => {
- stop();
- try {
- const durationSeconds = startTime ? Math.round((new Date() - startTime)/1000) : 0;
- // Compute coverage using mower deck width if available
- const mower = equipment.find(e => String(e.id) === String(selectedEquipment));
- const widthInches = mower?.cuttingWidthInches || mower?.workingWidthInches || null;
- let areaCoveredSqft = null;
- if (widthInches && gpsTrack.length > 1 && sections.length > 0) {
- try {
- const line = turf.lineString(gpsTrack.map(p => [p.lng, p.lat]));
- const bufferKm = (widthInches / 12 / 2) * 0.3048 / 1000; // inches -> ft -> meters -> km
- const swath = turf.buffer(line, bufferKm, { units: 'kilometers' });
- const plannedPolys = sections.map(s => {
- let poly = s.polygonData;
- if (typeof poly === 'string') { try { poly = JSON.parse(poly);} catch { poly = null; } }
- if (!poly?.coordinates?.[0]) return null;
- const coords = poly.coordinates[0].map(([lat,lng]) => [lng,lat]);
- return turf.polygon([coords]);
- }).filter(Boolean);
- if (plannedPolys.length) {
- const plannedUnion = plannedPolys.reduce((acc,cur)=> acc? turf.union(acc,cur):cur, null);
- if (plannedUnion) {
- const overlap = turf.intersect(swath, plannedUnion);
- if (overlap) {
- const sqm = turf.area(overlap);
- areaCoveredSqft = Math.round((sqm / 0.092903));
- }
- }
- }
- } catch (e) {
- // ignore coverage calculation errors
- }
- }
- const payload = {
- propertyId: Number(selectedProperty),
- lawnSectionIds: selectedSections.map(Number),
- equipmentId: Number(selectedEquipment),
- cutHeightInches: Number(cutHeight),
- 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: ''
- };
- const resp = await apiClient.post('/mowing/sessions', payload);
- toast.success('Mowing session saved');
- // reset
- setGpsTrack([]); setStartTime(null); setTotalDistance(0); totalDistanceRef.current = 0; setAverageSpeed(0); setIsPaused(false);
- } catch (e) {
- console.error(e);
- toast.error(e.response?.data?.message || 'Failed to save session');
- }
- };
-
- useEffect(() => {
- return () => { // cleanup on unmount
- if (watchId) navigator.geolocation.clearWatch(watchId);
- releaseWakeLock();
- };
- }, [watchId]);
+ if (loading) return (
);
return (
-
Mowing Tracker
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- setCutHeight(e.target.value)} />
-
-
-
-
-
+
+
Mowing
+
-
-
- {!tracking ? (
- <>
-
- {gpsTrack.length > 0 && (
-
+
+
+
Property
+
Areas
+
Date
+
Mower
+
Cut Height
+
Status
+
Actions
+
+ {plans.length === 0 ? (
+
No mowing plans yet.
+ ) : plans.map((p) => (
+
+
{p.property_name}
+
{p.section_names}
+
{new Date(p.planned_date).toLocaleDateString()}
+
{p.equipment_name || '—'}
+
{p.cut_height_inches}"
+
{p.status}
+
+
+ {p.status !== 'archived' && (
+
)}
- >
- ) : (
- <>
-
-
- >
- )}
-
-
-
Points: {gpsTrack.length}
-
Distance: {(totalDistance*3.28084).toFixed(0)} ft
-
Avg Speed: {averageSpeed.toFixed(1)} mph
-
Duration: {startTime ? Math.round((new Date()-startTime)/60000) : 0} min
-
+
+
+ ))}
+
+ {showPlanModal && (
+
setShowPlanModal(false)} onCreated={fetchPlans} />
+ )}
+ {execPlan && (
+ setExecPlan(null)} onComplete={fetchPlans} />
+ )}
);
};
export default Mowing;
+
diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js
index 0db5621..a0bb04d 100644
--- a/frontend/src/services/api.js
+++ b/frontend/src/services/api.js
@@ -235,6 +235,18 @@ export const adminAPI = {
updateSettings: (settings) => apiClient.put('/admin/settings', settings),
};
+// Mowing API endpoints
+export const mowingAPI = {
+ // Plans
+ getPlans: () => apiClient.get('/mowing/plans'),
+ getPlan: (id) => apiClient.get(`/mowing/plans/${id}`),
+ createPlan: (data) => apiClient.post('/mowing/plans', data),
+ updatePlanStatus: (id, status) => apiClient.put(`/mowing/plans/${id}/status`, { status }),
+ // Logs/Sessions
+ createLog: (data) => apiClient.post('/mowing/sessions', data),
+ getLogs: () => apiClient.get('/mowing/logs'),
+};
+
// Utility functions
export const handleApiError = (error, defaultMessage = 'An error occurred') => {
if (error.response?.data?.message) {