From 9a1640af3707dd0e5c745d788dc07002c05c0bba Mon Sep 17 00:00:00 2001
From: Jake Kasper
Date: Thu, 4 Sep 2025 07:29:41 -0500
Subject: [PATCH] boundary entries updates
---
backend/src/routes/admin.js | 69 +++++
backend/src/routes/properties.js | 97 ++++++-
.../V8__capture_method_on_lawn_sections.sql | 5 +
frontend/src/App.js | 11 +
frontend/src/components/Layout/Layout.js | 5 +
frontend/src/pages/Admin/AdminProperties.js | 191 ++++++++++++++
.../src/pages/Properties/PropertyDetail.js | 243 +++++++++++++++++-
frontend/src/services/api.js | 4 +
8 files changed, 607 insertions(+), 18 deletions(-)
create mode 100644 database/migrations/V8__capture_method_on_lawn_sections.sql
create mode 100644 frontend/src/pages/Admin/AdminProperties.js
diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js
index 388c843..7b81f66 100644
--- a/backend/src/routes/admin.js
+++ b/backend/src/routes/admin.js
@@ -1124,3 +1124,72 @@ router.put('/settings', async (req, res, next) => {
});
module.exports = router;
+// ----- Properties management -----
+// List properties (with user info)
+router.get('/properties', async (req, res, next) => {
+ try {
+ const { search, user_id } = req.query;
+ let where = [];
+ let params = [];
+ let n = 0;
+ if (user_id) { n++; where.push(`p.user_id=$${n}`); params.push(user_id); }
+ if (search) { n++; where.push(`(p.name ILIKE $${n} OR p.address ILIKE $${n} OR u.email ILIKE $${n})`); params.push(`%${search}%`); }
+ const whereClause = where.length ? `WHERE ${where.join(' AND ')}` : '';
+ const rs = await pool.query(`
+ SELECT p.*, u.email, u.first_name, u.last_name,
+ COUNT(ls.id) as section_count, COALESCE(SUM(ls.area),0) as calculated_area
+ FROM properties p
+ JOIN users u ON p.user_id=u.id
+ LEFT JOIN lawn_sections ls ON ls.property_id=p.id
+ ${whereClause}
+ GROUP BY p.id, u.email, u.first_name, u.last_name
+ ORDER BY p.created_at DESC
+ `, params);
+ res.json({ success: true, data: { properties: rs.rows.map(r=>({
+ id: r.id, userId: r.user_id, userEmail: r.email, userName: `${r.first_name} ${r.last_name}`,
+ name: r.name, address: r.address, latitude: parseFloat(r.latitude), longitude: parseFloat(r.longitude),
+ totalArea: parseFloat(r.total_area), calculatedArea: parseFloat(r.calculated_area), sectionCount: parseInt(r.section_count),
+ createdAt: r.created_at, updatedAt: r.updated_at
+ })) }});
+ } catch (e) { next(e); }
+});
+
+// Get property with sections (admin override)
+router.get('/properties/:id', validateParams(idParamSchema), async (req, res, next) => {
+ try {
+ const { id } = req.params;
+ const pr = await pool.query(`SELECT p.*, u.email FROM properties p JOIN users u ON p.user_id=u.id WHERE p.id=$1`, [id]);
+ if (pr.rows.length===0) throw new AppError('Property not found', 404);
+ const srs = await pool.query(`SELECT * FROM lawn_sections WHERE property_id=$1 ORDER BY name`, [id]);
+ const p = pr.rows[0];
+ res.json({ success:true, data:{ property: {
+ id: p.id, userId: p.user_id, userEmail: p.email, name: p.name, address: p.address,
+ latitude: parseFloat(p.latitude), longitude: parseFloat(p.longitude), totalArea: parseFloat(p.total_area),
+ createdAt: p.created_at, updatedAt: p.updated_at,
+ sections: srs.rows.map(s=>({ id:s.id, name:s.name, area: parseFloat(s.area), polygonData: s.polygon_data, grassType: s.grass_type, grassTypes: s.grass_types, captureMethod: s.capture_method, captureMeta: s.capture_meta, createdAt:s.created_at, updatedAt:s.updated_at }))
+ }}});
+ } catch (e) { next(e); }
+});
+
+// Update a lawn section (admin override)
+router.put('/properties/:propertyId/sections/:sectionId', validateParams(idParamSchema), async (req, res, next) => {
+ try {
+ const { propertyId, sectionId } = req.params;
+ const { name, area, polygonData, grassType, grassTypes, captureMethod, captureMeta } = req.body;
+ const own = await pool.query('SELECT id FROM properties WHERE id=$1', [propertyId]);
+ if (own.rows.length===0) throw new AppError('Property not found', 404);
+ // optional sanitize lite
+ let poly = polygonData;
+ try {
+ const coords = polygonData?.coordinates?.[0] || [];
+ let filtered = coords.filter(Boolean);
+ if (filtered.length>=3) poly = { ...polygonData, coordinates: [filtered] };
+ } catch {}
+ const rs = await pool.query(`UPDATE lawn_sections SET name=$1, area=$2, polygon_data=$3, grass_type=$4, grass_types=$5, capture_method=$6, capture_meta=$7, updated_at=CURRENT_TIMESTAMP WHERE id=$8 AND property_id=$9 RETURNING *`, [
+ name, area, JSON.stringify(poly), grassType || (Array.isArray(grassTypes)? grassTypes.join(', '): null), grassTypes? JSON.stringify(grassTypes): null, captureMethod||null, captureMeta? JSON.stringify(captureMeta): null, sectionId, propertyId
+ ]);
+ if (rs.rows.length===0) throw new AppError('Section not found', 404);
+ const s=rs.rows[0];
+ res.json({ success:true, data:{ section:{ id:s.id, name:s.name, area: parseFloat(s.area), polygonData: s.polygon_data, grassType: s.grass_type, grassTypes: s.grass_types, captureMethod: s.capture_method, createdAt:s.created_at, updatedAt:s.updated_at }}});
+ } catch (e) { next(e); }
+});
diff --git a/backend/src/routes/properties.js b/backend/src/routes/properties.js
index 5872fd8..987a85c 100644
--- a/backend/src/routes/properties.js
+++ b/backend/src/routes/properties.js
@@ -3,6 +3,7 @@ const pool = require('../config/database');
const { validateRequest, validateParams } = require('../utils/validation');
const { propertySchema, lawnSectionSchema, idParamSchema } = require('../utils/validation');
const { AppError } = require('../middleware/errorHandler');
+// Geometry helpers for sanitization and simplification are defined below
const router = express.Router();
@@ -50,6 +51,65 @@ const calculatePolygonArea = (coordinates) => {
return areaInSquareFeet;
};
+// ---- Geometry sanitization & simplification helpers ----
+const toMeters = (lat1, lng1, lat2, lng2) => {
+ const toRad = (d)=> (d*Math.PI)/180; const R=6371000;
+ const dLat=toRad(lat2-lat1), dLng=toRad(lng2-lng1);
+ const a=Math.sin(dLat/2)**2 + Math.cos(toRad(lat1))*Math.cos(toRad(lat2))*Math.sin(dLng/2)**2;
+ return 2*R*Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
+};
+
+const sanitizePolygon = (coords) => {
+ const MIN_MOVE_M = 0.5;
+ const COLINEAR_EPS_DEG = 3;
+ if (!Array.isArray(coords) || coords.length < 3) return coords || [];
+ let filtered = [coords[0]];
+ for (let i=1;i= MIN_MOVE_M) filtered.push(coords[i]);
+ }
+ const angleDeg = (a,b,c) => {
+ const v1=[b[0]-a[0], b[1]-a[1]]; const v2=[c[0]-b[0], c[1]-b[1]];
+ const dot=v1[0]*v2[0]+v1[1]*v2[1]; const m1=Math.hypot(v1[0],v1[1]); const m2=Math.hypot(v2[0],v2[1]);
+ if (m1===0||m2===0) return 180;
+ const cos= Math.min(1, Math.max(-1, dot/(m1*m2)));
+ return Math.acos(cos)*180/Math.PI;
+ };
+ if (filtered.length > 3) {
+ const out=[filtered[0]];
+ for (let i=1;i= COLINEAR_EPS_DEG) out.push(b);
+ }
+ out.push(filtered[filtered.length-1]);
+ filtered = out;
+ }
+ // RDP simplification with small epsilon (~2m in lat/long degrees)
+ const EPS_DEG = 0.00002;
+ const perpDist = (p, a, b) => {
+ const [x0,y0]=p, [x1,y1]=a, [x2,y2]=b;
+ const num = Math.abs((y2-y1)*x0 - (x2-x1)*y0 + x2*y1 - y2*x1);
+ const den = Math.hypot(y2-y1, x2-x1) || 1e-9;
+ return num/den;
+ };
+ const rdp = (pts, eps) => {
+ if (pts.length < 3) return pts;
+ let dmax=0, idx=0;
+ for (let i=1;idmax){ dmax=d; idx=i; }
+ }
+ if (dmax>eps){
+ const res1 = rdp(pts.slice(0, idx+1), eps);
+ const res2 = rdp(pts.slice(idx), eps);
+ return res1.slice(0, -1).concat(res2);
+ } else { return [pts[0], pts[pts.length-1]]; }
+ };
+ try { filtered = rdp(filtered, EPS_DEG); } catch {}
+ return filtered;
+};
+
// @route GET /api/properties
// @desc Get all properties for current user
// @access Private
@@ -278,7 +338,7 @@ router.delete('/:id', validateParams(idParamSchema), async (req, res, next) => {
router.post('/:id/sections', validateParams(idParamSchema), validateRequest(lawnSectionSchema), async (req, res, next) => {
try {
const propertyId = req.params.id;
- const { name, area, polygonData, grassType, grassTypes, soilType } = req.body;
+ const { name, area, polygonData, grassType, grassTypes, soilType, captureMethod, captureMeta } = req.body;
// Check if property exists and belongs to user
const propertyResult = await pool.query(
@@ -299,18 +359,27 @@ router.post('/:id/sections', validateParams(idParamSchema), validateRequest(lawn
console.log('Calculated area:', calculatedArea);
}
+ // Sanitize polygon before storing
+ let poly = polygonData;
+ try {
+ const cleaned = sanitizePolygon(polygonData?.coordinates?.[0] || []);
+ poly = { ...polygonData, coordinates: [cleaned] };
+ } catch {}
+
const result = await pool.query(
- `INSERT INTO lawn_sections (property_id, name, area, polygon_data, grass_type, grass_types, soil_type)
- VALUES ($1, $2, $3, $4, $5, $6, $7)
+ `INSERT INTO lawn_sections (property_id, name, area, polygon_data, grass_type, grass_types, soil_type, capture_method, capture_meta)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING *`,
[
propertyId,
name,
calculatedArea,
- JSON.stringify(polygonData),
+ JSON.stringify(poly),
grassType || (Array.isArray(grassTypes) ? grassTypes.join(', ') : null),
grassTypes ? JSON.stringify(grassTypes) : null,
- soilType
+ soilType,
+ captureMethod || null,
+ captureMeta ? JSON.stringify(captureMeta) : null
]
);
@@ -327,6 +396,7 @@ router.post('/:id/sections', validateParams(idParamSchema), validateRequest(lawn
polygonData: section.polygon_data,
grassType: section.grass_type,
grassTypes: section.grass_types,
+ captureMethod: section.capture_method,
soilType: section.soil_type,
createdAt: section.created_at,
updatedAt: section.updated_at
@@ -344,7 +414,7 @@ router.post('/:id/sections', validateParams(idParamSchema), validateRequest(lawn
router.put('/:propertyId/sections/:sectionId', async (req, res, next) => {
try {
const { propertyId, sectionId } = req.params;
- const { name, area, polygonData, grassType, grassTypes, soilType } = req.body;
+ const { name, area, polygonData, grassType, grassTypes, soilType, captureMethod, captureMeta } = req.body;
// Check if section exists and user owns the property
const checkResult = await pool.query(
@@ -365,18 +435,26 @@ router.put('/:propertyId/sections/:sectionId', async (req, res, next) => {
calculatedArea = calculatePolygonArea(polygonData.coordinates[0]);
}
+ let upoly = polygonData;
+ try {
+ const cleaned = sanitizePolygon(polygonData?.coordinates?.[0] || []);
+ upoly = { ...polygonData, coordinates: [cleaned] };
+ } catch {}
+
const result = await pool.query(
`UPDATE lawn_sections
- SET name = $1, area = $2, polygon_data = $3, grass_type = $4, grass_types=$5, soil_type = $6, updated_at = CURRENT_TIMESTAMP
- WHERE id = $7
+ SET name = $1, area = $2, polygon_data = $3, grass_type = $4, grass_types=$5, soil_type = $6, capture_method=$7, capture_meta=$8, updated_at = CURRENT_TIMESTAMP
+ WHERE id = $9
RETURNING *`,
[
name,
calculatedArea,
- JSON.stringify(polygonData),
+ JSON.stringify(upoly),
grassType || (Array.isArray(grassTypes) ? grassTypes.join(', ') : null),
grassTypes ? JSON.stringify(grassTypes) : null,
soilType,
+ captureMethod || null,
+ captureMeta ? JSON.stringify(captureMeta) : null,
sectionId
]
);
@@ -394,6 +472,7 @@ router.put('/:propertyId/sections/:sectionId', async (req, res, next) => {
polygonData: section.polygon_data,
grassType: section.grass_type,
grassTypes: section.grass_types,
+ captureMethod: section.capture_method,
soilType: section.soil_type,
createdAt: section.created_at,
updatedAt: section.updated_at
diff --git a/database/migrations/V8__capture_method_on_lawn_sections.sql b/database/migrations/V8__capture_method_on_lawn_sections.sql
new file mode 100644
index 0000000..bc26adf
--- /dev/null
+++ b/database/migrations/V8__capture_method_on_lawn_sections.sql
@@ -0,0 +1,5 @@
+-- Add capture method and metadata on lawn sections for provenance
+ALTER TABLE lawn_sections
+ ADD COLUMN IF NOT EXISTS capture_method VARCHAR(20) CHECK (capture_method IN ('tap','gps_points','gps_trace')),
+ ADD COLUMN IF NOT EXISTS capture_meta JSONB;
+
diff --git a/frontend/src/App.js b/frontend/src/App.js
index d3b3d86..e3df9ae 100644
--- a/frontend/src/App.js
+++ b/frontend/src/App.js
@@ -34,6 +34,7 @@ import AdminDashboard from './pages/Admin/AdminDashboard';
import AdminUsers from './pages/Admin/AdminUsers';
import AdminProducts from './pages/Admin/AdminProducts';
import AdminEquipment from './pages/Admin/AdminEquipment';
+import AdminProperties from './pages/Admin/AdminProperties';
// Error pages
import NotFound from './pages/Error/NotFound';
@@ -303,6 +304,16 @@ function App() {
}
/>
+
+
+
+
+
+ }
+ />
{/* Error Routes */}
} />
diff --git a/frontend/src/components/Layout/Layout.js b/frontend/src/components/Layout/Layout.js
index d2aba69..7792a8d 100644
--- a/frontend/src/components/Layout/Layout.js
+++ b/frontend/src/components/Layout/Layout.js
@@ -107,6 +107,11 @@ const Layout = ({ children }) => {
href: '/admin/equipment',
icon: WrenchScrewdriverIcon,
},
+ {
+ name: 'Manage Properties',
+ href: '/admin/properties',
+ icon: MapIcon,
+ },
];
const handleLogout = () => {
diff --git a/frontend/src/pages/Admin/AdminProperties.js b/frontend/src/pages/Admin/AdminProperties.js
new file mode 100644
index 0000000..32ac6e2
--- /dev/null
+++ b/frontend/src/pages/Admin/AdminProperties.js
@@ -0,0 +1,191 @@
+import React, { useEffect, useState } from 'react';
+import { adminAPI } from '../../services/api';
+import LoadingSpinner from '../../components/UI/LoadingSpinner';
+import { MapContainer, TileLayer, Polygon, Marker, Popup } from 'react-leaflet';
+import { Icon } from 'leaflet';
+
+const AdminProperties = () => {
+ const [loading, setLoading] = useState(true);
+ const [properties, setProperties] = useState([]);
+ const [search, setSearch] = useState('');
+ const [selected, setSelected] = useState(null);
+ const [editingSection, setEditingSection] = useState(null);
+ const [editName, setEditName] = useState('');
+ const [editGrass, setEditGrass] = useState('');
+ const [editingGeomId, setEditingGeomId] = useState(null);
+ const [editedCoords, setEditedCoords] = useState([]);
+
+ const load = async () => {
+ try{
+ setLoading(true);
+ const rs = await adminAPI.getProperties({ search });
+ setProperties(rs.data?.data?.properties || []);
+ }finally{ setLoading(false);} }
+
+ useEffect(()=>{ load(); }, [search]);
+
+ const openProperty = async (id) => {
+ const rs = await adminAPI.getProperty(id);
+ setSelected(rs.data?.data?.property || null);
+ };
+
+ const beginEditSection = (s) => { setEditingSection(s); setEditName(s.name); setEditGrass(s.grassType || (s.grassTypes||[]).join(', ')); };
+ const beginEditGeometry = (s) => { setEditingGeomId(s.id); setEditedCoords(s.polygonData?.coordinates?.[0] || []); };
+ const saveSection = async () => {
+ const grassTypes = editGrass.split(',').map(x=>x.trim()).filter(Boolean);
+ const payload = { name: editName, area: editingSection.area, polygonData: editingSection.polygonData, grassType: grassTypes.join(', '), grassTypes };
+ await adminAPI.updateSectionAdmin(selected.id, editingSection.id, payload);
+ // refresh selected property
+ await openProperty(selected.id);
+ setEditingSection(null);
+ };
+ const saveGeometry = async () => {
+ const section = (selected.sections||[]).find(s=> s.id===editingGeomId);
+ const poly = { ...(section.polygonData||{}), coordinates: [editedCoords] };
+ await adminAPI.updateSectionAdmin(selected.id, editingGeomId, { name: section.name, area: section.area, polygonData: poly, grassType: section.grassType, grassTypes: section.grassTypes });
+ await openProperty(selected.id);
+ setEditingGeomId(null);
+ setEditedCoords([]);
+ };
+ const cancelGeometry = () => { setEditingGeomId(null); setEditedCoords([]); };
+
+ if (loading) return (
);
+
+ return (
+
+
+
Admin: Properties
+ setSearch(e.target.value)} />
+
+
+
+
All Properties
+
+ {properties.map(p=> (
+
openProperty(p.id)}>
+
+
+
{p.name}
+
{p.userEmail}
+
+
{p.sectionCount} sections • {(p.calculatedArea||0).toLocaleString()} sq ft
+
+
{p.address}
+
+ ))}
+
+
+
+
+ {!selected ? (
+
Select a property to view details
+ ) : (
+
+
+
+
{selected.name}
+
{selected.userEmail}
+
+
+
+
+
+ {(selected.sections||[]).map(s=> (
+
+ ))}
+ {editingGeomId && editedCoords.map((c, idx)=> (
+ {
+ const { lat, lng } = e.target.getLatLng();
+ setEditedCoords(prev=> prev.map((p,i)=> i===idx? [lat,lng]: p));
+ }}}
+ icon={new Icon({
+ iconUrl: 'data:image/svg+xml;base64,' + btoa(`
+
+ `),
+ iconSize: [12,12], iconAnchor: [6,6]
+ })}
+ >
+
+ Point {idx+1}
+
+
+ ))}
+
+
+ {editingGeomId && (
+
+
+
+
+ )}
+
Sections
+
+ {(selected.sections||[]).map(s=> (
+
+
+
{s.name}
+
{(s.area||0).toLocaleString()} sq ft
+
+ {s.captureMethod && (
+ {s.captureMethod}
+ )}
+ {s.captureMeta?.pointsCount && (
+ {s.captureMeta.pointsCount} pts
+ )}
+ {s.captureMeta?.accuracyLast && (
+ ±{Math.round(s.captureMeta.accuracyLast)} m
+ )}
+ {s.captureMeta?.totalDistanceMeters && (
+ {Math.round(s.captureMeta.totalDistanceMeters*3.28084)} ft walked
+ )}
+
+ {(s.grassTypes?.length>0 || s.grassType) && (
+
+ {(s.grassTypes?.length? s.grassTypes : (s.grassType||'').split(',').map(x=>x.trim()).filter(Boolean)).map((g,i)=>({g}))}
+
+ )}
+
+
+
+
+
+
+ ))}
+
+
+ )}
+
+
+
+ {editingSection && (
+
+
+
Edit Section
+
+
+
+
+
+
+
+ )}
+
+ );
+};
+
+export default AdminProperties;
diff --git a/frontend/src/pages/Properties/PropertyDetail.js b/frontend/src/pages/Properties/PropertyDetail.js
index ae9d4bf..b177aab 100644
--- a/frontend/src/pages/Properties/PropertyDetail.js
+++ b/frontend/src/pages/Properties/PropertyDetail.js
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
-import { MapContainer, TileLayer, Marker, Popup, Polygon, useMapEvents, useMap } from 'react-leaflet';
+import { MapContainer, TileLayer, Marker, Popup, Polygon, Polyline, useMapEvents, useMap } from 'react-leaflet';
import { Icon } from 'leaflet';
import * as turf from '@turf/turf';
import {
@@ -344,6 +344,15 @@ const PropertyDetail = () => {
const [lawnSections, setLawnSections] = useState([]);
const [loading, setLoading] = useState(true);
const [isDrawing, setIsDrawing] = useState(false);
+ // GPS capture modes
+ const [isGPSPointsMode, setIsGPSPointsMode] = useState(false); // mark-at-location
+ const [isGPSTraceMode, setIsGPSTraceMode] = useState(false); // continuous trace
+ const [isTracing, setIsTracing] = useState(false);
+ const [gpsWatchId, setGpsWatchId] = useState(null);
+ const [gpsTracePoints, setGpsTracePoints] = useState([]);
+ const [gpsDistance, setGpsDistance] = useState(0);
+ const [gpsAccuracy, setGpsAccuracy] = useState(null);
+ const [isSnapPreview, setIsSnapPreview] = useState(false);
const [currentColor, setCurrentColor] = useState(SECTION_COLORS[0]);
const [showNameModal, setShowNameModal] = useState(false);
const [pendingSection, setPendingSection] = useState(null);
@@ -453,6 +462,101 @@ const PropertyDetail = () => {
setIsDrawing(false);
};
+ // Haversine distance in meters
+ const haversine = (lat1, lon1, lat2, lon2) => {
+ const toRad = (d) => (d * Math.PI) / 180;
+ const R = 6371000;
+ const dLat = toRad(lat2 - lat1);
+ const dLon = toRad(lon2 - lon1);
+ const a = Math.sin(dLat/2)**2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon/2)**2;
+ return 2 * R * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
+ };
+
+ // GPS filtering and snapping (defaults)
+ const ACCURACY_MAX_METERS = 15; // Ignore points with accuracy worse than this
+ const MIN_MOVE_METERS = 2; // Ignore points if user hasn't moved this far from last point
+ const SNAP_METERS = 5; // Snap to starting point when within this distance
+
+ const acceptAndNormalizePoint = (lat, lng, accuracy, currentPoints) => {
+ if (accuracy != null && accuracy > ACCURACY_MAX_METERS) {
+ toast("GPS accuracy too low (" + Math.round(accuracy) + "m). Waiting for better fix…");
+ return null;
+ }
+ // Movement threshold
+ if (currentPoints.length > 0) {
+ const [llat, llng] = currentPoints[currentPoints.length - 1];
+ const moved = haversine(llat, llng, lat, lng);
+ if (moved < MIN_MOVE_METERS) {
+ // Too close to last point
+ return null;
+ }
+ }
+ // Snap to starting point if near
+ if (currentPoints.length >= 2) {
+ const [slat, slng] = currentPoints[0];
+ const dStart = haversine(slat, slng, lat, lng);
+ if (dStart <= SNAP_METERS) {
+ return [slat, slng];
+ }
+ }
+ return [lat, lng];
+ };
+
+ // GPS point collection (mark-at-location)
+ const markCurrentPoint = () => {
+ if (!navigator.geolocation) { toast.error('GPS not available'); return; }
+ navigator.geolocation.getCurrentPosition((pos) => {
+ const { latitude, longitude, accuracy } = pos.coords;
+ setGpsAccuracy(accuracy || null);
+ setGpsTracePoints(prev => {
+ const normalized = acceptAndNormalizePoint(latitude, longitude, accuracy, prev);
+ // For preview: check proximity to start even if skipping
+ if (prev.length >= 2) {
+ const [slat, slng] = prev[0];
+ const dStart = haversine(slat, slng, latitude, longitude);
+ setIsSnapPreview(dStart <= SNAP_METERS);
+ } else { setIsSnapPreview(false); }
+ if (!normalized) return prev; // skip
+ const next = [...prev, normalized];
+ if (next.length > 1) {
+ const [pl, pg] = next[next.length - 2];
+ setGpsDistance(d => d + haversine(pl, pg, normalized[0], normalized[1]));
+ }
+ return next;
+ });
+ }, (err)=>{
+ console.warn('GPS error', err?.message);
+ toast.error('GPS error: ' + (err?.message || 'unknown'));
+ }, { enableHighAccuracy: true, maximumAge: 1000, timeout: 10000 });
+ };
+
+ const undoLastPoint = () => {
+ setGpsTracePoints(prev => {
+ if (prev.length <= 0) return prev;
+ const next = prev.slice(0, -1);
+ // Recompute distance
+ let dist = 0;
+ for (let i=1;i {
+ setGpsTracePoints([]);
+ setGpsDistance(0);
+ setGpsAccuracy(null);
+ };
+
+ const completeTracing = () => {
+ if (gpsTracePoints.length < 3) { toast.error('Need at least 3 points to create an area'); return; }
+ // Close polygon by ensuring first == last handled in save
+ handlePolygonComplete(gpsTracePoints);
+ setIsGPSTraceMode(false);
+ setIsGPSPointsMode(false);
+ clearGpsPoints();
+ };
+
const saveLawnSection = async () => {
if (!sectionName.trim()) {
toast.error('Please enter a section name');
@@ -469,7 +573,13 @@ const PropertyDetail = () => {
},
grassType: sectionGrassTypes.join(', '),
grassTypes: sectionGrassTypes,
- soilType: null
+ soilType: null,
+ captureMethod: isGPSPointsMode ? 'gps_points' : (isGPSTraceMode ? 'gps_trace' : 'tap'),
+ captureMeta: {
+ accuracyLast: gpsAccuracy,
+ totalDistanceMeters: gpsDistance,
+ pointsCount: pendingSection.coordinates?.length || 0
+ }
};
const response = await propertiesAPI.createSection(id, sectionData);
@@ -700,13 +810,40 @@ const PropertyDetail = () => {
)}
-
+
+
+
+
+
@@ -749,6 +886,26 @@ const PropertyDetail = () => {
currentColor={currentColor}
/>
)}
+ {/* GPS trace overlays */}
+ {(isGPSPointsMode || isGPSTraceMode) && gpsTracePoints.length > 0 && (
+ <>
+
+ {gpsTracePoints.map((p, i) => (
+
+ ${i===0 && isSnapPreview ? '' : ''}
+
+ `),
+ iconSize: [14,14], iconAnchor: [7,7]
+ })}
+ />
+ ))}
+ >
+ )}
@@ -768,6 +925,74 @@ const PropertyDetail = () => {
)}
+ {isGPSPointsMode && (
+
+
+ GPS Points Mode
+ Points: {gpsTracePoints.length}
+ Distance: {(gpsDistance * 3.28084).toFixed(0)} ft
+ {gpsAccuracy != null && Accuracy: ±{Math.round(gpsAccuracy)} m}
+
+
+
+
+
+
+ {isSnapPreview && Snap to start available}
+
+
Walk to each corner, tap Mark Point, then Complete. You can refine points afterward.
+
+ )}
+ {isGPSTraceMode && (
+
+
+ Trace Mode
+ Points: {gpsTracePoints.length}
+ Distance: {(gpsDistance * 3.28084).toFixed(0)} ft
+ {gpsAccuracy != null && Accuracy: ±{Math.round(gpsAccuracy)} m}
+
+
+ {!isTracing ? (
+
+ ) : (
+
+ )}
+
+
+ {isSnapPreview && Snap to start available}
+
+
Walk the boundary to trace it. Pause as needed, then Complete to create the area.
+
+ )}
diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js
index d51515d..e484ef8 100644
--- a/frontend/src/services/api.js
+++ b/frontend/src/services/api.js
@@ -220,6 +220,10 @@ export const adminAPI = {
updateProduct: (id, productData) => apiClient.put(`/admin/products/${id}`, productData),
deleteProduct: (id) => apiClient.delete(`/admin/products/${id}`),
promoteUserProduct: (id) => apiClient.post(`/admin/products/user/${id}/promote`),
+ // Properties management
+ getProperties: (params) => apiClient.get('/admin/properties', { params }),
+ getProperty: (id) => apiClient.get(`/admin/properties/${id}`),
+ updateSectionAdmin: (propertyId, sectionId, payload) => apiClient.put(`/admin/properties/${propertyId}/sections/${sectionId}`, payload),
getProductRates: (id) => apiClient.get(`/admin/products/${id}/rates`),
getUserProductSpreaderSettings: (id) => apiClient.get(`/admin/products/user/${id}/spreader-settings`),
addUserProductSpreaderSetting: (id, settingData) => apiClient.post(`/admin/products/user/${id}/spreader-settings`, settingData),