mowing
This commit is contained in:
@@ -18,6 +18,7 @@ const productSpreaderSettingsRoutes = require('./routes/productSpreaderSettings'
|
|||||||
const weatherRoutes = require('./routes/weather');
|
const weatherRoutes = require('./routes/weather');
|
||||||
const weatherPublicRoutes = require('./routes/weatherPublic');
|
const weatherPublicRoutes = require('./routes/weatherPublic');
|
||||||
const adminRoutes = require('./routes/admin');
|
const adminRoutes = require('./routes/admin');
|
||||||
|
const mowingRoutes = require('./routes/mowing');
|
||||||
|
|
||||||
const { errorHandler } = require('./middleware/errorHandler');
|
const { errorHandler } = require('./middleware/errorHandler');
|
||||||
const { authenticateToken } = require('./middleware/auth');
|
const { authenticateToken } = require('./middleware/auth');
|
||||||
@@ -100,6 +101,7 @@ app.use('/api/equipment', authenticateToken, equipmentRoutes);
|
|||||||
app.use('/api/nozzles', authenticateToken, nozzleRoutes);
|
app.use('/api/nozzles', authenticateToken, nozzleRoutes);
|
||||||
app.use('/api/products', authenticateToken, productRoutes);
|
app.use('/api/products', authenticateToken, productRoutes);
|
||||||
app.use('/api/applications', authenticateToken, applicationRoutes);
|
app.use('/api/applications', authenticateToken, applicationRoutes);
|
||||||
|
app.use('/api/mowing', authenticateToken, mowingRoutes);
|
||||||
app.use('/api/spreader-settings', authenticateToken, spreaderSettingsRoutes);
|
app.use('/api/spreader-settings', authenticateToken, spreaderSettingsRoutes);
|
||||||
app.use('/api/product-spreader-settings', authenticateToken, productSpreaderSettingsRoutes);
|
app.use('/api/product-spreader-settings', authenticateToken, productSpreaderSettingsRoutes);
|
||||||
app.use('/api/weather', authenticateToken, weatherRoutes);
|
app.use('/api/weather', authenticateToken, weatherRoutes);
|
||||||
|
|||||||
129
backend/src/routes/mowing.js
Normal file
129
backend/src/routes/mowing.js
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
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 router = express.Router();
|
||||||
|
|
||||||
|
// POST /api/mowing/sessions - create a completed mowing session
|
||||||
|
router.post('/sessions', validateRequest(mowingSessionSchema), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
propertyId,
|
||||||
|
lawnSectionIds,
|
||||||
|
equipmentId,
|
||||||
|
cutHeightInches,
|
||||||
|
direction,
|
||||||
|
gpsTrack,
|
||||||
|
averageSpeed,
|
||||||
|
durationSeconds,
|
||||||
|
totalDistanceMeters,
|
||||||
|
areaCoveredSqft,
|
||||||
|
notes
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// Verify ownership of property, sections, equipment
|
||||||
|
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 sections = await pool.query(
|
||||||
|
`SELECT ls.id FROM lawn_sections ls JOIN properties p ON ls.property_id=p.id
|
||||||
|
WHERE ls.id = ANY($1::int[]) AND p.user_id=$2`,
|
||||||
|
[lawnSectionIds, req.user.id]
|
||||||
|
);
|
||||||
|
if (sections.rows.length !== lawnSectionIds.length) throw new AppError('One or more sections 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_sessions
|
||||||
|
(user_id, property_id, equipment_id, cut_height_inches, direction, gps_track,
|
||||||
|
duration_seconds, total_distance_meters, average_speed_mph, area_covered_sqft, notes)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
req.user.id,
|
||||||
|
propertyId,
|
||||||
|
equipmentId,
|
||||||
|
cutHeightInches,
|
||||||
|
direction,
|
||||||
|
JSON.stringify(gpsTrack),
|
||||||
|
durationSeconds,
|
||||||
|
totalDistanceMeters,
|
||||||
|
averageSpeed,
|
||||||
|
areaCoveredSqft || null,
|
||||||
|
notes || null
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const session = ins.rows[0];
|
||||||
|
for (const sid of lawnSectionIds) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO mowing_session_sections (session_id, lawn_section_id) VALUES ($1,$2)`,
|
||||||
|
[session.id, sid]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await client.query('COMMIT');
|
||||||
|
return res.status(201).json({ success: true, data: { session: { id: session.id } } });
|
||||||
|
} catch (e) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/mowing/sessions - list sessions for user
|
||||||
|
router.get('/sessions', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const result = 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: { sessions: result.rows } });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/mowing/sessions/:id - details
|
||||||
|
router.get('/sessions/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const sessionRes = 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.id=$1 AND ms.user_id=$2`,
|
||||||
|
[id, req.user.id]
|
||||||
|
);
|
||||||
|
if (sessionRes.rows.length === 0) throw new AppError('Session not found', 404);
|
||||||
|
const sectionsRes = await pool.query(
|
||||||
|
`SELECT lss.lawn_section_id, ls.name, ls.area, ls.polygon_data
|
||||||
|
FROM mowing_session_sections lss
|
||||||
|
JOIN lawn_sections ls ON lss.lawn_section_id=ls.id
|
||||||
|
WHERE lss.session_id=$1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
res.json({ success: true, data: { session: sessionRes.rows[0], sections: sectionsRes.rows } });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
||||||
@@ -180,6 +180,21 @@ const applicationLogSchema = Joi.object({
|
|||||||
}).or('productId', 'userProductId')).min(1).required()
|
}).or('productId', 'userProductId')).min(1).required()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Mowing session validation
|
||||||
|
const mowingSessionSchema = 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(),
|
||||||
|
cutHeightInches: Joi.number().positive().precision(2).required(),
|
||||||
|
direction: Joi.string().valid('N_S','E_W','NE_SW','NW_SE','CIRCULAR').required(),
|
||||||
|
gpsTrack: Joi.object().required(),
|
||||||
|
averageSpeed: Joi.number().positive().required(),
|
||||||
|
durationSeconds: Joi.number().integer().positive().required(),
|
||||||
|
totalDistanceMeters: Joi.number().positive().required(),
|
||||||
|
areaCoveredSqft: Joi.number().positive().optional(),
|
||||||
|
notes: Joi.string().allow('').optional()
|
||||||
|
});
|
||||||
|
|
||||||
// Validation middleware
|
// Validation middleware
|
||||||
const validateRequest = (schema) => {
|
const validateRequest = (schema) => {
|
||||||
return (req, res, next) => {
|
return (req, res, next) => {
|
||||||
@@ -219,6 +234,7 @@ module.exports = {
|
|||||||
userProductSchema,
|
userProductSchema,
|
||||||
applicationPlanSchema,
|
applicationPlanSchema,
|
||||||
applicationLogSchema,
|
applicationLogSchema,
|
||||||
|
mowingSessionSchema,
|
||||||
idParamSchema,
|
idParamSchema,
|
||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
|
|||||||
28
database/migrations/V3__add_mowing_sessions.sql
Normal file
28
database/migrations/V3__add_mowing_sessions.sql
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
-- Mowing sessions to track mowing activity with GPS
|
||||||
|
CREATE TABLE IF NOT EXISTS mowing_sessions (
|
||||||
|
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),
|
||||||
|
cut_height_inches DECIMAL(4,2),
|
||||||
|
direction VARCHAR(20) CHECK (direction IN ('N_S','E_W','NE_SW','NW_SE','CIRCULAR')),
|
||||||
|
session_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
gps_track JSON,
|
||||||
|
duration_seconds INTEGER,
|
||||||
|
total_distance_meters DECIMAL(10,2),
|
||||||
|
average_speed_mph DECIMAL(5,2),
|
||||||
|
area_covered_sqft DECIMAL(12,2),
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS mowing_session_sections (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
session_id INTEGER REFERENCES mowing_sessions(id) ON DELETE CASCADE,
|
||||||
|
lawn_section_id INTEGER REFERENCES lawn_sections(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mowing_sessions_user_id ON mowing_sessions(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mowing_sessions_property_id ON mowing_sessions(property_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mowing_session_sections_session_id ON mowing_session_sections(session_id);
|
||||||
|
|
||||||
@@ -26,6 +26,7 @@ import ApplicationPlan from './pages/Applications/ApplicationPlan';
|
|||||||
import ApplicationLog from './pages/Applications/ApplicationLog';
|
import ApplicationLog from './pages/Applications/ApplicationLog';
|
||||||
import History from './pages/History/History';
|
import History from './pages/History/History';
|
||||||
import Weather from './pages/Weather/Weather';
|
import Weather from './pages/Weather/Weather';
|
||||||
|
import Mowing from './pages/Mowing/Mowing';
|
||||||
import Profile from './pages/Profile/Profile';
|
import Profile from './pages/Profile/Profile';
|
||||||
|
|
||||||
// Admin pages
|
// Admin pages
|
||||||
@@ -240,6 +241,16 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/mowing"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Layout>
|
||||||
|
<Mowing />
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/profile"
|
path="/profile"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -65,6 +65,12 @@ const Layout = ({ children }) => {
|
|||||||
icon: CalendarDaysIcon,
|
icon: CalendarDaysIcon,
|
||||||
iconSolid: CalendarIconSolid,
|
iconSolid: CalendarIconSolid,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Mowing',
|
||||||
|
href: '/mowing',
|
||||||
|
icon: ClockIcon,
|
||||||
|
iconSolid: ClockIconSolid,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'History',
|
name: 'History',
|
||||||
href: '/history',
|
href: '/history',
|
||||||
|
|||||||
193
frontend/src/pages/Mowing/Mowing.js
Normal file
193
frontend/src/pages/Mowing/Mowing.js
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { propertiesAPI, equipmentAPI } from '../../services/api';
|
||||||
|
import { applicationsAPI } from '../../services/api';
|
||||||
|
import { weatherAPI } from '../../services/api';
|
||||||
|
import { apiClient } 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' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 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 [gpsTrack, setGpsTrack] = useState([]);
|
||||||
|
const [startTime, setStartTime] = useState(null);
|
||||||
|
const [totalDistance, setTotalDistance] = useState(0);
|
||||||
|
const [averageSpeed, setAverageSpeed] = useState(0);
|
||||||
|
const [watchId, setWatchId] = 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 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; }
|
||||||
|
setTracking(true);
|
||||||
|
setStartTime(new Date());
|
||||||
|
setGpsTrack([]);
|
||||||
|
setTotalDistance(0);
|
||||||
|
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);
|
||||||
|
setTotalDistance((d) => d + meters);
|
||||||
|
const seconds = (new Date(pos.timestamp) - startTime) / 1000;
|
||||||
|
if (seconds > 0) setAverageSpeed((( (d + meters) / 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);
|
||||||
|
};
|
||||||
|
|
||||||
|
const complete = async () => {
|
||||||
|
stop();
|
||||||
|
try {
|
||||||
|
const durationSeconds = startTime ? Math.round((new Date() - startTime)/1000) : 0;
|
||||||
|
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,
|
||||||
|
notes: ''
|
||||||
|
};
|
||||||
|
const resp = await apiClient.post('/mowing/sessions', payload);
|
||||||
|
toast.success('Mowing session saved');
|
||||||
|
// reset
|
||||||
|
setGpsTrack([]); setStartTime(null); setTotalDistance(0); setAverageSpeed(0);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
toast.error(e.response?.data?.message || 'Failed to save session');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 mb-4">Mowing Tracker</h1>
|
||||||
|
<div className="bg-white p-4 rounded shadow mb-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<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 className="bg-white p-4 rounded shadow mb-4">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{!tracking ? (
|
||||||
|
<button className="btn-primary" onClick={start}>Start Tracking</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Mowing;
|
||||||
|
|
||||||
@@ -5,7 +5,7 @@ import toast from 'react-hot-toast';
|
|||||||
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:5000/api';
|
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:5000/api';
|
||||||
|
|
||||||
// Create axios instance
|
// Create axios instance
|
||||||
const apiClient = axios.create({
|
export const apiClient = axios.create({
|
||||||
baseURL: API_BASE_URL,
|
baseURL: API_BASE_URL,
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
Reference in New Issue
Block a user