boundary entries updates
This commit is contained in:
@@ -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); }
|
||||
});
|
||||
|
||||
@@ -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<coords.length;i++){
|
||||
const [pl,pg]=filtered[filtered.length-1];
|
||||
const [cl,cg]=coords[i];
|
||||
if (toMeters(pl,pg,cl,cg) >= 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<filtered.length-1;i++){
|
||||
const a=filtered[i-1], b=filtered[i], c=filtered[i+1];
|
||||
if (angleDeg(a,b,c) >= 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;i<pts.length-1;i++){
|
||||
const d = perpDist(pts[i], pts[0], pts[pts.length-1]);
|
||||
if (d>dmax){ 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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/properties"
|
||||
element={
|
||||
<ProtectedRoute adminOnly>
|
||||
<Layout>
|
||||
<AdminProperties />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Error Routes */}
|
||||
<Route path="/unauthorized" element={<Unauthorized />} />
|
||||
|
||||
@@ -107,6 +107,11 @@ const Layout = ({ children }) => {
|
||||
href: '/admin/equipment',
|
||||
icon: WrenchScrewdriverIcon,
|
||||
},
|
||||
{
|
||||
name: 'Manage Properties',
|
||||
href: '/admin/properties',
|
||||
icon: MapIcon,
|
||||
},
|
||||
];
|
||||
|
||||
const handleLogout = () => {
|
||||
|
||||
191
frontend/src/pages/Admin/AdminProperties.js
Normal file
191
frontend/src/pages/Admin/AdminProperties.js
Normal file
@@ -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 (<div className="p-6"><LoadingSpinner /></div>);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h1 className="text-2xl font-bold">Admin: Properties</h1>
|
||||
<input className="input w-64" placeholder="Search by name/address/email" value={search} onChange={(e)=> setSearch(e.target.value)} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="card">
|
||||
<h3 className="font-semibold mb-2">All Properties</h3>
|
||||
<div className="max-h-[60vh] overflow-auto divide-y">
|
||||
{properties.map(p=> (
|
||||
<div key={p.id} className={`p-3 hover:bg-gray-50 cursor-pointer ${selected?.id===p.id? 'bg-blue-50':''}`} onClick={()=> openProperty(p.id)}>
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<div className="font-medium">{p.name}</div>
|
||||
<div className="text-xs text-gray-600">{p.userEmail}</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-700">{p.sectionCount} sections • {(p.calculatedArea||0).toLocaleString()} sq ft</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{p.address}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
{!selected ? (
|
||||
<div className="text-gray-500">Select a property to view details</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<div>
|
||||
<h3 className="font-semibold">{selected.name}</h3>
|
||||
<div className="text-xs text-gray-600">{selected.userEmail}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-64 rounded overflow-hidden mb-3">
|
||||
<MapContainer center={[selected.latitude||39.8, selected.longitude||-98.6]} zoom={16} style={{height:'100%', width:'100%'}}>
|
||||
<TileLayer attribution='© Esri' url="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}" />
|
||||
{(selected.sections||[]).map(s=> (
|
||||
<Polygon key={s.id} positions={s.polygonData?.coordinates?.[0]||[]} pathOptions={{ color: s.id===editingGeomId? '#f59e0b':'#10b981', weight: 2, fillOpacity: .2 }} />
|
||||
))}
|
||||
{editingGeomId && editedCoords.map((c, idx)=> (
|
||||
<Marker
|
||||
key={`ed-${idx}`}
|
||||
position={c}
|
||||
draggable
|
||||
eventHandlers={{ dragend: (e)=> {
|
||||
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(`
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="6" cy="6" r="5" fill="#f59e0b" stroke="white" stroke-width="2"/>
|
||||
</svg>
|
||||
`),
|
||||
iconSize: [12,12], iconAnchor: [6,6]
|
||||
})}
|
||||
>
|
||||
<Popup>
|
||||
<div className="text-xs">Point {idx+1}</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
</MapContainer>
|
||||
</div>
|
||||
{editingGeomId && (
|
||||
<div className="mb-3 flex gap-2">
|
||||
<button className="btn-primary" onClick={saveGeometry}>Save Geometry</button>
|
||||
<button className="btn-secondary" onClick={cancelGeometry}>Cancel</button>
|
||||
</div>
|
||||
)}
|
||||
<h4 className="font-medium mb-2">Sections</h4>
|
||||
<div className="space-y-2 max-h-[40vh] overflow-auto">
|
||||
{(selected.sections||[]).map(s=> (
|
||||
<div key={s.id} className="p-2 border rounded flex justify-between items-center">
|
||||
<div>
|
||||
<div className="font-medium text-sm">{s.name}</div>
|
||||
<div className="text-xs text-gray-600">{(s.area||0).toLocaleString()} sq ft</div>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
{s.captureMethod && (
|
||||
<span className={`px-1.5 py-0.5 text-[10px] rounded ${s.captureMethod==='gps_points'?'bg-blue-100 text-blue-800': s.captureMethod==='gps_trace'?'bg-yellow-100 text-yellow-800':'bg-gray-100 text-gray-700'}`}>{s.captureMethod}</span>
|
||||
)}
|
||||
{s.captureMeta?.pointsCount && (
|
||||
<span className="text-[10px] text-gray-600">{s.captureMeta.pointsCount} pts</span>
|
||||
)}
|
||||
{s.captureMeta?.accuracyLast && (
|
||||
<span className="text-[10px] text-gray-600">±{Math.round(s.captureMeta.accuracyLast)} m</span>
|
||||
)}
|
||||
{s.captureMeta?.totalDistanceMeters && (
|
||||
<span className="text-[10px] text-gray-600">{Math.round(s.captureMeta.totalDistanceMeters*3.28084)} ft walked</span>
|
||||
)}
|
||||
</div>
|
||||
{(s.grassTypes?.length>0 || s.grassType) && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{(s.grassTypes?.length? s.grassTypes : (s.grassType||'').split(',').map(x=>x.trim()).filter(Boolean)).map((g,i)=>(<span key={i} className="px-1.5 py-0.5 text-[10px] rounded bg-green-100 text-green-800">{g}</span>))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button className="btn-secondary" onClick={()=> beginEditSection(s)}>Edit</button>
|
||||
<button className="btn-secondary" onClick={()=> beginEditGeometry(s)}>Edit Geometry</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editingSection && (
|
||||
<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-lg">
|
||||
<h3 className="font-semibold mb-3">Edit Section</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="label">Name</label>
|
||||
<input className="input" value={editName} onChange={(e)=> setEditName(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Grass Types (comma separated)</label>
|
||||
<input className="input" value={editGrass} onChange={(e)=> setEditGrass(e.target.value)} placeholder="e.g., Kentucky Bluegrass, Perennial Ryegrass" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button className="btn-secondary" onClick={()=> setEditingSection(null)}>Cancel</button>
|
||||
<button className="btn-primary" onClick={saveSection}>Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminProperties;
|
||||
@@ -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<next.length;i++) dist += haversine(next[i-1][0], next[i-1][1], next[i][0], next[i][1]);
|
||||
setGpsDistance(dist);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const clearGpsPoints = () => {
|
||||
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 = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsDrawing(!isDrawing)}
|
||||
className={`btn-primary flex items-center gap-2 ${isDrawing ? 'bg-red-600 hover:bg-red-700' : ''}`}
|
||||
>
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
{isDrawing ? 'Cancel Drawing' : 'Add Lawn Section'}
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setIsDrawing(!isDrawing)}
|
||||
className={`btn-primary flex items-center gap-2 ${isDrawing ? 'bg-red-600 hover:bg-red-700' : ''}`}
|
||||
>
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
{isDrawing ? 'Cancel Drawing' : 'Tap-to-Draw'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Toggle GPS points mode; ensure trace mode off
|
||||
setIsGPSTraceMode(false);
|
||||
setIsTracing(false);
|
||||
if (gpsWatchId) { navigator.geolocation.clearWatch(gpsWatchId); setGpsWatchId(null); }
|
||||
setIsGPSPointsMode(v=>!v);
|
||||
if (!isGPSPointsMode) clearGpsPoints();
|
||||
}}
|
||||
className={`btn-secondary flex items-center gap-2 ${isGPSPointsMode ? 'ring-2 ring-blue-300' : ''}`}
|
||||
>
|
||||
{isGPSPointsMode ? 'Exit GPS Points' : 'GPS Points'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Toggle trace mode; ensure points mode off
|
||||
setIsGPSPointsMode(false);
|
||||
if (!isGPSTraceMode) clearGpsPoints();
|
||||
if (isGPSTraceMode && gpsWatchId) { navigator.geolocation.clearWatch(gpsWatchId); setGpsWatchId(null); setIsTracing(false); }
|
||||
setIsGPSTraceMode(v=>!v);
|
||||
}}
|
||||
className={`btn-secondary flex items-center gap-2 ${isGPSTraceMode ? 'ring-2 ring-blue-300' : ''}`}
|
||||
>
|
||||
{isGPSTraceMode ? 'Exit Trace' : 'Trace Boundary'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
@@ -749,6 +886,26 @@ const PropertyDetail = () => {
|
||||
currentColor={currentColor}
|
||||
/>
|
||||
)}
|
||||
{/* GPS trace overlays */}
|
||||
{(isGPSPointsMode || isGPSTraceMode) && gpsTracePoints.length > 0 && (
|
||||
<>
|
||||
<Polyline positions={gpsTracePoints} pathOptions={{ color: '#2563eb', weight: 3 }} />
|
||||
{gpsTracePoints.map((p, i) => (
|
||||
<Marker
|
||||
key={`g${i}`}
|
||||
position={p}
|
||||
icon={new Icon({
|
||||
iconUrl: 'data:image/svg+xml;base64,' + btoa(`
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg">
|
||||
${i===0 && isSnapPreview ? '<circle cx="7" cy="7" r="6" fill="#fde68a" stroke="#f59e0b" stroke-width="2"/>' : '<circle cx="7" cy="7" r="6" fill="#2563eb" stroke="white" stroke-width="2"/>'}
|
||||
</svg>
|
||||
`),
|
||||
iconSize: [14,14], iconAnchor: [7,7]
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</MapContainer>
|
||||
</div>
|
||||
|
||||
@@ -768,6 +925,74 @@ const PropertyDetail = () => {
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{isGPSPointsMode && (
|
||||
<div className="p-4 bg-green-50 border-t">
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-green-900">
|
||||
<span className="font-semibold">GPS Points Mode</span>
|
||||
<span>Points: {gpsTracePoints.length}</span>
|
||||
<span>Distance: {(gpsDistance * 3.28084).toFixed(0)} ft</span>
|
||||
{gpsAccuracy != null && <span>Accuracy: ±{Math.round(gpsAccuracy)} m</span>}
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2 items-center">
|
||||
<button className="btn-primary" onClick={markCurrentPoint}>Mark Point</button>
|
||||
<button className="btn-secondary" onClick={undoLastPoint} disabled={gpsTracePoints.length===0}>Undo</button>
|
||||
<button className="btn-secondary" onClick={clearGpsPoints} disabled={gpsTracePoints.length===0}>Clear</button>
|
||||
<button className="btn-primary" onClick={completeTracing} disabled={gpsTracePoints.length < 3}>Complete Boundary</button>
|
||||
{isSnapPreview && <span className="text-xs text-amber-700 bg-amber-100 px-2 py-1 rounded">Snap to start available</span>}
|
||||
</div>
|
||||
<p className="text-xs text-green-700 mt-2">Walk to each corner, tap Mark Point, then Complete. You can refine points afterward.</p>
|
||||
</div>
|
||||
)}
|
||||
{isGPSTraceMode && (
|
||||
<div className="p-4 bg-green-50 border-t">
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-green-900">
|
||||
<span className="font-semibold">Trace Mode</span>
|
||||
<span>Points: {gpsTracePoints.length}</span>
|
||||
<span>Distance: {(gpsDistance * 3.28084).toFixed(0)} ft</span>
|
||||
{gpsAccuracy != null && <span>Accuracy: ±{Math.round(gpsAccuracy)} m</span>}
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2 items-center">
|
||||
{!isTracing ? (
|
||||
<button className="btn-primary" onClick={() => {
|
||||
if (!navigator.geolocation) { toast.error('GPS not available'); return; }
|
||||
const id = navigator.geolocation.watchPosition((pos) => {
|
||||
const { latitude, longitude, accuracy } = pos.coords;
|
||||
setGpsAccuracy(accuracy || null);
|
||||
setGpsTracePoints(prev => {
|
||||
const normalized = acceptAndNormalizePoint(latitude, longitude, accuracy, prev);
|
||||
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;
|
||||
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 });
|
||||
setGpsWatchId(id);
|
||||
setIsTracing(true);
|
||||
}}>Start</button>
|
||||
) : (
|
||||
<button className="btn-secondary" onClick={() => {
|
||||
if (gpsWatchId) { navigator.geolocation.clearWatch(gpsWatchId); setGpsWatchId(null); }
|
||||
setIsTracing(false);
|
||||
}}>Pause</button>
|
||||
)}
|
||||
<button className="btn-secondary" onClick={() => { if (gpsWatchId) { navigator.geolocation.clearWatch(gpsWatchId); setGpsWatchId(null);} setIsTracing(false); clearGpsPoints(); }} disabled={gpsTracePoints.length===0}>Clear</button>
|
||||
<button className="btn-primary" onClick={completeTracing} disabled={gpsTracePoints.length < 3}>Complete Boundary</button>
|
||||
{isSnapPreview && <span className="text-xs text-amber-700 bg-amber-100 px-2 py-1 rounded">Snap to start available</span>}
|
||||
</div>
|
||||
<p className="text-xs text-green-700 mt-2">Walk the boundary to trace it. Pause as needed, then Complete to create the area.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user