mowing modal
This commit is contained in:
@@ -2,7 +2,7 @@ const express = require('express');
|
|||||||
const pool = require('../config/database');
|
const pool = require('../config/database');
|
||||||
const { AppError } = require('../middleware/errorHandler');
|
const { AppError } = require('../middleware/errorHandler');
|
||||||
const { validateRequest } = require('../utils/validation');
|
const { validateRequest } = require('../utils/validation');
|
||||||
const { mowingSessionSchema } = require('../utils/validation');
|
const { mowingSessionSchema, mowingPlanSchema } = require('../utils/validation');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -127,3 +127,100 @@ router.get('/sessions/:id', async (req, res, next) => {
|
|||||||
|
|
||||||
module.exports = router;
|
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); }
|
||||||
|
});
|
||||||
|
|||||||
@@ -195,6 +195,16 @@ const mowingSessionSchema = Joi.object({
|
|||||||
notes: Joi.string().allow('').optional()
|
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
|
// Validation middleware
|
||||||
const validateRequest = (schema) => {
|
const validateRequest = (schema) => {
|
||||||
return (req, res, next) => {
|
return (req, res, next) => {
|
||||||
@@ -235,6 +245,7 @@ module.exports = {
|
|||||||
applicationPlanSchema,
|
applicationPlanSchema,
|
||||||
applicationLogSchema,
|
applicationLogSchema,
|
||||||
mowingSessionSchema,
|
mowingSessionSchema,
|
||||||
|
mowingPlanSchema,
|
||||||
idParamSchema,
|
idParamSchema,
|
||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
|
|||||||
24
database/migrations/V4__add_mowing_plans.sql
Normal file
24
database/migrations/V4__add_mowing_plans.sql
Normal file
@@ -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);
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
@@ -1,258 +1,78 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { propertiesAPI, equipmentAPI, apiClient } from '../../services/api';
|
import { mowingAPI } from '../../services/api';
|
||||||
import * as turf from '@turf/turf';
|
import LoadingSpinner from '../../components/UI/LoadingSpinner';
|
||||||
|
import MowingPlanModal from '../../components/Mowing/MowingPlanModal';
|
||||||
|
import MowingExecutionModal from '../../components/Mowing/MowingExecutionModal';
|
||||||
import toast from 'react-hot-toast';
|
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 Mowing = () => {
|
||||||
const [properties, setProperties] = useState([]);
|
const [plans, setPlans] = useState([]);
|
||||||
const [equipment, setEquipment] = useState([]);
|
const [loading, setLoading] = useState(true);
|
||||||
const [selectedProperty, setSelectedProperty] = useState('');
|
const [showPlanModal, setShowPlanModal] = useState(false);
|
||||||
const [sections, setSections] = useState([]);
|
const [execPlan, setExecPlan] = useState(null);
|
||||||
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);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchPlans = async () => {
|
||||||
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 () => {
|
|
||||||
try {
|
try {
|
||||||
if ('wakeLock' in navigator) {
|
setLoading(true);
|
||||||
wakeLockRef.current = await navigator.wakeLock.request('screen');
|
const r = await mowingAPI.getPlans();
|
||||||
}
|
setPlans(r.data.data.plans || []);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Ignore if unsupported
|
toast.error('Failed to load mowing plans');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const releaseWakeLock = async () => {
|
useEffect(() => { fetchPlans(); }, []);
|
||||||
try {
|
|
||||||
await wakeLockRef.current?.release();
|
|
||||||
} catch {}
|
|
||||||
wakeLockRef.current = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const start = () => {
|
if (loading) return (<div className="p-6"><LoadingSpinner /></div>);
|
||||||
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]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">Mowing Tracker</h1>
|
<div className="flex justify-between items-center mb-6">
|
||||||
<div className="bg-white p-4 rounded shadow mb-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
<h1 className="text-2xl font-bold text-gray-900">Mowing</h1>
|
||||||
<div>
|
<button className="btn-primary" onClick={()=> setShowPlanModal(true)}>New Plan</button>
|
||||||
<label className="block text-sm mb-1">Property</label>
|
|
||||||
<select className="w-full border rounded px-2 py-2" value={selectedProperty} onChange={(e)=>{setSelectedProperty(e.target.value); setSelectedSections([]);}}>
|
|
||||||
<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">Areas</label>
|
|
||||||
<select multiple className="w-full border rounded px-2 py-2 h-28" value={selectedSections} onChange={(e)=> setSelectedSections(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={selectedEquipment} onChange={(e)=> setSelectedEquipment(e.target.value)}>
|
|
||||||
<option value="">Select…</option>
|
|
||||||
{equipment.map(e=> <option key={e.id} value={e.id}>{e.customName || e.manufacturer || 'Mower'} {e.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={cutHeight} onChange={(e)=> setCutHeight(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>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white p-4 rounded shadow mb-4">
|
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||||
<div className="flex gap-3">
|
<div className="grid grid-cols-7 gap-4 p-4 border-b text-sm font-medium text-gray-600">
|
||||||
{!tracking ? (
|
<div>Property</div>
|
||||||
<>
|
<div>Areas</div>
|
||||||
<button className="btn-primary" onClick={start}>{isPaused ? 'Resume' : 'Start'} Tracking</button>
|
<div>Date</div>
|
||||||
{gpsTrack.length > 0 && (
|
<div>Mower</div>
|
||||||
<button className="btn-secondary" onClick={() => { setGpsTrack([]); setStartTime(null); setTotalDistance(0); totalDistanceRef.current = 0; setAverageSpeed(0); setIsPaused(false); }}>Restart</button>
|
<div>Cut Height</div>
|
||||||
|
<div>Status</div>
|
||||||
|
<div>Actions</div>
|
||||||
|
</div>
|
||||||
|
{plans.length === 0 ? (
|
||||||
|
<div className="p-4 text-gray-600">No mowing plans yet.</div>
|
||||||
|
) : plans.map((p) => (
|
||||||
|
<div key={p.id} className="grid grid-cols-7 gap-4 p-4 border-b text-sm items-center">
|
||||||
|
<div className="font-medium">{p.property_name}</div>
|
||||||
|
<div className="truncate" title={p.section_names}>{p.section_names}</div>
|
||||||
|
<div>{new Date(p.planned_date).toLocaleDateString()}</div>
|
||||||
|
<div>{p.equipment_name || '—'}</div>
|
||||||
|
<div>{p.cut_height_inches}"</div>
|
||||||
|
<div><span className={`px-2 py-1 rounded text-xs ${p.status==='completed'?'bg-green-100 text-green-800': p.status==='planned'?'bg-blue-100 text-blue-800': p.status==='archived'?'bg-gray-100 text-gray-800':'bg-yellow-100 text-yellow-800'}`}>{p.status}</span></div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button className="btn-secondary" onClick={()=> setExecPlan(p)}>Execute</button>
|
||||||
|
{p.status !== 'archived' && (
|
||||||
|
<button className="px-3 py-1 text-xs rounded border" onClick={async ()=>{ await mowingAPI.updatePlanStatus(p.id, 'archived'); fetchPlans(); }}>Archive</button>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<>
|
))}
|
||||||
<button className="btn-secondary" onClick={stop}>Pause</button>
|
|
||||||
<button className="btn-primary" onClick={complete}>Complete</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 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>
|
||||||
|
|
||||||
|
{showPlanModal && (
|
||||||
|
<MowingPlanModal onClose={()=> setShowPlanModal(false)} onCreated={fetchPlans} />
|
||||||
|
)}
|
||||||
|
{execPlan && (
|
||||||
|
<MowingExecutionModal plan={execPlan} onClose={()=> setExecPlan(null)} onComplete={fetchPlans} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Mowing;
|
export default Mowing;
|
||||||
|
|
||||||
|
|||||||
@@ -235,6 +235,18 @@ export const adminAPI = {
|
|||||||
updateSettings: (settings) => apiClient.put('/admin/settings', settings),
|
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
|
// Utility functions
|
||||||
export const handleApiError = (error, defaultMessage = 'An error occurred') => {
|
export const handleApiError = (error, defaultMessage = 'An error occurred') => {
|
||||||
if (error.response?.data?.message) {
|
if (error.response?.data?.message) {
|
||||||
|
|||||||
Reference in New Issue
Block a user