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)} /> +
+
+ + +
+
+ +