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

+
+
+ + setEditName(e.target.value)} /> +
+
+ + setEditGrass(e.target.value)} placeholder="e.g., Kentucky Bluegrass, Perennial Ryegrass" /> +
+
+
+ + +
+
+
+ )} +
+ ); +}; + +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),