This commit is contained in:
Jake Kasper
2025-09-05 08:22:34 -04:00
parent 7e05fa7484
commit 0d23bd0dc0
3 changed files with 153 additions and 4 deletions

View File

@@ -101,4 +101,63 @@ router.post('/plans/:id/points', async (req,res,next)=>{
} catch (e) { next(e); } } catch (e) { next(e); }
}); });
// PUT /api/watering/points/:id - update a single point
router.put('/points/:id', async (req, res, next) => {
try {
const pointId = req.params.id;
// Verify ownership by joining plans
const own = await pool.query(
`SELECT wpp.id FROM watering_plan_points wpp
JOIN watering_plans wp ON wpp.plan_id = wp.id
WHERE wpp.id=$1 AND wp.user_id=$2`, [pointId, req.user.id]
);
if (own.rows.length === 0) throw new AppError('Point not found', 404);
const p = req.body || {};
// Recompute coverage if geometry changed
const coverage = computeCoverageSqft({
sprinkler_head_type: p.sprinklerHeadType,
sprinkler_throw_feet: p.throwFeet,
sprinkler_degrees: p.degrees,
sprinkler_length_feet: p.lengthFeet,
sprinkler_width_feet: p.widthFeet
});
const rs = await pool.query(
`UPDATE watering_plan_points SET
lat = COALESCE($1, lat),
lng = COALESCE($2, lng),
duration_minutes = COALESCE($3, duration_minutes),
sprinkler_mount = COALESCE($4, sprinkler_mount),
sprinkler_head_type = COALESCE($5, sprinkler_head_type),
sprinkler_gpm = COALESCE($6, sprinkler_gpm),
sprinkler_throw_feet = COALESCE($7, sprinkler_throw_feet),
sprinkler_degrees = COALESCE($8, sprinkler_degrees),
sprinkler_length_feet = COALESCE($9, sprinkler_length_feet),
sprinkler_width_feet = COALESCE($10, sprinkler_width_feet),
sprinkler_heading_degrees = COALESCE($11, sprinkler_heading_degrees),
coverage_sqft = COALESCE($12, coverage_sqft)
WHERE id=$13 RETURNING *`,
[p.lat, p.lng, p.durationMinutes, p.mountType, p.sprinklerHeadType, p.gpm,
p.throwFeet, p.degrees, p.lengthFeet, p.widthFeet, p.headingDegrees, coverage || null, pointId]
);
res.json({ success:true, data:{ point: rs.rows[0] }});
} catch (e) { next(e); }
});
// DELETE /api/watering/points/:id
router.delete('/points/:id', async (req,res,next)=>{
try {
const pointId = req.params.id;
const own = await pool.query(
`SELECT wpp.id FROM watering_plan_points wpp
JOIN watering_plans wp ON wpp.plan_id = wp.id
WHERE wpp.id=$1 AND wp.user_id=$2`, [pointId, req.user.id]
);
if (own.rows.length === 0) throw new AppError('Point not found', 404);
await pool.query('DELETE FROM watering_plan_points WHERE id=$1', [pointId]);
res.json({ success:true });
} catch (e) { next(e); }
});
module.exports = router; module.exports = router;

View File

@@ -45,6 +45,10 @@ const Watering = () => {
const [watchId, setWatchId] = useState(null); const [watchId, setWatchId] = useState(null);
const [sprinklers, setSprinklers] = useState([]); const [sprinklers, setSprinklers] = useState([]);
const [selectedSprinklerId, setSelectedSprinklerId] = useState(''); const [selectedSprinklerId, setSelectedSprinklerId] = useState('');
const [selectedPointId, setSelectedPointId] = useState(null);
const selectedPoint = useMemo(()=> points.find(p=> p.id===selectedPointId), [points, selectedPointId]);
const [editForm, setEditForm] = useState(null);
const [satellite, setSatellite] = useState(false);
useEffect(() => { (async () => { useEffect(() => { (async () => {
try { try {
@@ -255,10 +259,57 @@ const Watering = () => {
<div className="font-medium mb-2">Plan Points</div> <div className="font-medium mb-2">Plan Points</div>
<ul className="text-sm space-y-1 max-h-64 overflow-auto"> <ul className="text-sm space-y-1 max-h-64 overflow-auto">
{points.map(pt => ( {points.map(pt => (
<li key={pt.id}>#{pt.sequence}: {Number(pt.lat).toFixed(5)}, {Number(pt.lng).toFixed(5)} {pt.duration_minutes} min</li> <li key={pt.id}
className={(pt.id===selectedPointId? 'bg-blue-50 ' : '') + 'rounded px-2 py-1 hover:bg-gray-50 cursor-pointer'}
onClick={()=> onSelectPoint(pt)}>
#{pt.sequence}: {Number(pt.lat).toFixed(5)}, {Number(pt.lng).toFixed(5)} {pt.duration_minutes} min
</li>
))} ))}
{points.length===0 && <li className="text-gray-500">No points yet</li>} {points.length===0 && <li className="text-gray-500">No points yet</li>}
</ul> </ul>
{selectedPoint && editForm && (
<div className="mt-3 border-t pt-3 space-y-2">
<div className="font-medium">Edit Selected</div>
{(editForm.sprinklerHeadType==='rotor_impact' || editForm.sprinklerHeadType==='spray_fixed') ? (
<>
<label className="text-xs">Degrees</label>
<input type="number" className="input" value={editForm.degrees}
onChange={(e)=> setEditForm({...editForm, degrees: parseInt(e.target.value||'0',10)})} />
<label className="text-xs">Heading</label>
<input type="number" className="input" value={editForm.headingDegrees}
onChange={(e)=> setEditForm({...editForm, headingDegrees: parseInt(e.target.value||'0',10)%360})} />
<label className="text-xs">Throw (ft)</label>
<input type="number" step="0.1" className="input" value={editForm.throwFeet}
onChange={(e)=> setEditForm({...editForm, throwFeet: parseFloat(e.target.value||'0')})} />
</>
) : (
<>
<label className="text-xs">Length (ft)</label>
<input type="number" step="0.1" className="input" value={editForm.lengthFeet}
onChange={(e)=> setEditForm({...editForm, lengthFeet: parseFloat(e.target.value||'0')})} />
<label className="text-xs">Width (ft)</label>
<input type="number" step="0.1" className="input" value={editForm.widthFeet}
onChange={(e)=> setEditForm({...editForm, widthFeet: parseFloat(e.target.value||'0')})} />
<label className="text-xs">Heading</label>
<input type="number" className="input" value={editForm.headingDegrees}
onChange={(e)=> setEditForm({...editForm, headingDegrees: parseInt(e.target.value||'0',10)%360})} />
</>
)}
<label className="text-xs">Duration (min)</label>
<input type="number" className="input" value={editForm.durationMinutes}
onChange={(e)=> setEditForm({...editForm, durationMinutes: parseInt(e.target.value||'0',10)})} />
<div className="flex gap-2">
<button className="btn-primary flex-1" onClick={async ()=>{
await updatePointField(selectedPointId, editForm);
}}>Save</button>
<button className="btn-secondary" onClick={async ()=>{
await wateringAPI.deletePoint(selectedPointId);
setPoints(prev=> prev.filter(p=> p.id!==selectedPointId));
setSelectedPointId(null); setEditForm(null);
}}>Delete</button>
</div>
</div>
)}
</div> </div>
</div> </div>
<div className="lg:col-span-3"> <div className="lg:col-span-3">
@@ -267,7 +318,10 @@ const Watering = () => {
<div className="text-sm text-gray-700">{guiding && currentPos && points[guideIndex] ? ( <div className="text-sm text-gray-700">{guiding && currentPos && points[guideIndex] ? (
<>Go to point #{points[guideIndex].sequence}: {distanceFeet(currentPos, {lat: Number(points[guideIndex].lat), lng: Number(points[guideIndex].lng)}).toFixed(0)} ft away</> <>Go to point #{points[guideIndex].sequence}: {distanceFeet(currentPos, {lat: Number(points[guideIndex].lat), lng: Number(points[guideIndex].lng)}).toFixed(0)} ft away</>
) : 'Map'}</div> ) : 'Map'}</div>
<div className="flex gap-2"> <div className="flex gap-2 items-center">
<label className="text-xs flex items-center gap-1">
<input type="checkbox" checked={satellite} onChange={(e)=> setSatellite(e.target.checked)} /> Satellite
</label>
{!guiding ? ( {!guiding ? (
<button className="btn-secondary" onClick={startGuidance} disabled={points.length===0}>Start Guidance</button> <button className="btn-secondary" onClick={startGuidance} disabled={points.length===0}>Start Guidance</button>
) : ( ) : (
@@ -281,7 +335,11 @@ const Watering = () => {
</div> </div>
<MapContainer center={center} zoom={18} style={{height:'100%', width:'100%'}}> <MapContainer center={center} zoom={18} style={{height:'100%', width:'100%'}}>
<CenterOnChange center={center} /> <CenterOnChange center={center} />
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" /> {satellite ? (
<TileLayer url="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}" />
) : (
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
)}
{sections.map((s)=> ( {sections.map((s)=> (
<Polygon key={s.id} positions={s.polygonData?.coordinates?.[0]||[]} pathOptions={{ color:'#16a34a', weight:2, fillOpacity:0.1 }} /> <Polygon key={s.id} positions={s.polygonData?.coordinates?.[0]||[]} pathOptions={{ color:'#16a34a', weight:2, fillOpacity:0.1 }} />
))} ))}
@@ -296,7 +354,15 @@ const Watering = () => {
}); });
return ( return (
<React.Fragment key={pt.id}> <React.Fragment key={pt.id}>
<Marker position={[pt.lat, pt.lng]} /> <Marker position={[pt.lat, pt.lng]} draggable={true}
eventHandlers={{
dragend: (e)=>{
const ll = e.target.getLatLng();
updatePointField(pt.id, { lat: ll.lat, lng: ll.lng });
},
click: ()=> onSelectPoint(pt)
}}
/>
{cov?.kind==='circle' && ( {cov?.kind==='circle' && (
cov.degrees && cov.degrees < 360 ? ( cov.degrees && cov.degrees < 360 ? (
<Polygon positions={sectorPolygon({lat:Number(pt.lat),lng:Number(pt.lng)}, cov.radius, (pt.sprinkler_heading_degrees||0), (pt.sprinkler_heading_degrees||0)+cov.degrees)} pathOptions={{ color:'#2563eb', fillOpacity:0.2 }} /> <Polygon positions={sectorPolygon({lat:Number(pt.lat),lng:Number(pt.lng)}, cov.radius, (pt.sprinkler_heading_degrees||0), (pt.sprinkler_heading_degrees||0)+cov.degrees)} pathOptions={{ color:'#2563eb', fillOpacity:0.2 }} />
@@ -328,3 +394,25 @@ const Watering = () => {
}; };
export default Watering; export default Watering;
const onSelectPoint = (pt) => {
setSelectedPointId(pt.id);
setEditForm({
durationMinutes: pt.duration_minutes || 0,
mountType: pt.sprinkler_mount,
sprinklerHeadType: pt.sprinkler_head_type,
gpm: Number(pt.sprinkler_gpm||0),
throwFeet: Number(pt.sprinkler_throw_feet||0),
degrees: Number(pt.sprinkler_degrees||360),
lengthFeet: Number(pt.sprinkler_length_feet||0),
widthFeet: Number(pt.sprinkler_width_feet||0),
headingDegrees: Number(pt.sprinkler_heading_degrees||0)
});
};
const updatePointField = async (id, patch) => {
try {
const r = await wateringAPI.updatePoint(id, patch);
const np = r.data?.data?.point;
setPoints(prev => prev.map(p=> p.id===id? np: p));
} catch(e){ toast.error('Failed to update point'); }
};

View File

@@ -231,6 +231,8 @@ export const wateringAPI = {
createPlan: (payload) => apiClient.post('/watering/plans', payload), createPlan: (payload) => apiClient.post('/watering/plans', payload),
getPlanPoints: (planId) => apiClient.get(`/watering/plans/${planId}/points`), getPlanPoints: (planId) => apiClient.get(`/watering/plans/${planId}/points`),
addPlanPoint: (planId, payload) => apiClient.post(`/watering/plans/${planId}/points`, payload), addPlanPoint: (planId, payload) => apiClient.post(`/watering/plans/${planId}/points`, payload),
updatePoint: (pointId, payload) => apiClient.put(`/watering/points/${pointId}`, payload),
deletePoint: (pointId) => apiClient.delete(`/watering/points/${pointId}`),
}; };
// Admin API endpoints // Admin API endpoints