diff --git a/backend/src/routes/watering.js b/backend/src/routes/watering.js
index 1d29563..f765494 100644
--- a/backend/src/routes/watering.js
+++ b/backend/src/routes/watering.js
@@ -60,6 +60,28 @@ router.post('/plans', async (req, res, next) => {
} catch (e) { next(e); }
});
+// PUT /api/watering/plans/:id - rename/update notes
+router.put('/plans/:id', async (req, res, next) => {
+ try {
+ const planId = req.params.id;
+ // Verify ownership
+ const own = await pool.query('SELECT id FROM watering_plans WHERE id=$1 AND user_id=$2', [planId, req.user.id]);
+ if (own.rows.length === 0) throw new AppError('Plan not found', 404);
+
+ const { name, notes } = req.body || {};
+ const rs = await pool.query(
+ `UPDATE watering_plans
+ SET name = COALESCE($1, name),
+ notes = COALESCE($2, notes),
+ updated_at = NOW()
+ WHERE id=$3
+ RETURNING *`,
+ [name, notes, planId]
+ );
+ res.json({ success: true, data: { plan: rs.rows[0] } });
+ } catch (e) { next(e); }
+});
+
// GET /api/watering/plans/:id/points
router.get('/plans/:id/points', async (req, res, next) => {
try {
diff --git a/frontend/src/pages/Watering/Watering.js b/frontend/src/pages/Watering/Watering.js
index 4aac85d..c60a6c7 100644
--- a/frontend/src/pages/Watering/Watering.js
+++ b/frontend/src/pages/Watering/Watering.js
@@ -1,5 +1,6 @@
import React, { useEffect, useMemo, useState } from 'react';
import { MapContainer, TileLayer, Polygon, Marker, Circle, Rectangle, Popup, useMapEvents, useMap } from 'react-leaflet';
+import { Icon } from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { propertiesAPI, wateringAPI, equipmentAPI } from '../../services/api';
import toast from 'react-hot-toast';
@@ -38,6 +39,7 @@ const Watering = () => {
durationMinutes: 60
});
const [plan, setPlan] = useState(null);
+ const [plans, setPlans] = useState([]);
const [points, setPoints] = useState([]);
const [guiding, setGuiding] = useState(false);
const [guideIndex, setGuideIndex] = useState(0);
@@ -49,6 +51,26 @@ const Watering = () => {
const selectedPoint = useMemo(()=> points.find(p=> p.id===selectedPointId), [points, selectedPointId]);
const [editForm, setEditForm] = useState(null);
const [satellite, setSatellite] = useState(false);
+ const [rename, setRename] = useState('');
+
+ // Distinct colors for points
+ const palette = ['#ef4444','#f59e0b','#10b981','#3b82f6','#8b5cf6','#ec4899','#14b8a6','#84cc16','#f97316','#06b6d4','#a855f7','#22c55e'];
+ const pointColor = (pt) => {
+ const idx = points.findIndex(p=> p.id===pt.id);
+ return palette[(idx>=0? idx:0) % palette.length];
+ };
+ const iconCache = useMemo(()=> ({}), []);
+ const markerIcon = (color) => {
+ if (iconCache[color]) return iconCache[color];
+ const svg = `\n `;
+ const ic = new Icon({
+ iconUrl: 'data:image/svg+xml;base64,' + btoa(svg),
+ iconSize: [16,16],
+ iconAnchor: [8,8]
+ });
+ iconCache[color] = ic;
+ return ic;
+ };
// Select a point helper (for popup+state sync)
const onSelectPoint = (pt) => {
@@ -83,6 +105,17 @@ const Watering = () => {
const updatePointField = async (id, patch) => {
try {
applyLocalPatch(id, patch);
+ if (id === selectedPointId) {
+ setEditForm(prev => prev ? {
+ ...prev,
+ degrees: patch.degrees !== undefined ? patch.degrees : prev.degrees,
+ headingDegrees: patch.headingDegrees !== undefined ? patch.headingDegrees : prev.headingDegrees,
+ throwFeet: patch.throwFeet !== undefined ? patch.throwFeet : prev.throwFeet,
+ lengthFeet: patch.lengthFeet !== undefined ? patch.lengthFeet : prev.lengthFeet,
+ widthFeet: patch.widthFeet !== undefined ? patch.widthFeet : prev.widthFeet,
+ durationMinutes: patch.durationMinutes !== undefined ? patch.durationMinutes : prev.durationMinutes,
+ } : prev);
+ }
const r = await wateringAPI.updatePoint(id, patch);
const np = r.data?.data?.point;
setPoints(prev => prev.map(p=> p.id===id? np: p));
@@ -121,6 +154,19 @@ const Watering = () => {
const r = await propertiesAPI.getById(pid); const p = r.data?.data?.property; setSelectedProperty(p);
const secs = (p.sections||[]).map(s => ({ ...s, polygonData: typeof s.polygonData === 'string' ? JSON.parse(s.polygonData) : s.polygonData }));
setSections(secs);
+ // Load existing watering plans for this property
+ const pl = await wateringAPI.getPlans({ property_id: pid });
+ const plansList = pl.data?.data?.plans || [];
+ setPlans(plansList);
+ if (plansList.length) {
+ const sel = plansList[0];
+ setPlan(sel);
+ const pts = await wateringAPI.getPlanPoints(sel.id);
+ setPoints(pts.data?.data?.points || []);
+ } else {
+ setPlan(null);
+ setPoints([]);
+ }
}
catch(e){ toast.error('Failed to load property'); }
};
@@ -128,10 +174,32 @@ const Watering = () => {
const ensurePlan = async () => {
if (plan) return plan;
if (!selectedProperty) { toast.error('Select a property first'); return null; }
- try { const r = await wateringAPI.createPlan({ propertyId: selectedProperty.id, name: `${selectedProperty.name} - Sprinklers` }); setPlan(r.data?.data?.plan); return r.data?.data?.plan; }
+ try {
+ const r = await wateringAPI.createPlan({ propertyId: selectedProperty.id, name: `${selectedProperty.name} - Sprinklers` });
+ const newPlan = r.data?.data?.plan;
+ setPlan(newPlan);
+ // refresh list and clear points
+ const pl = await wateringAPI.getPlans({ property_id: selectedProperty.id });
+ setPlans(pl.data?.data?.plans || []);
+ setPoints([]);
+ return newPlan;
+ }
catch(e){ toast.error('Failed to create plan'); return null; }
};
+ const selectPlan = async (planId) => {
+ const p = (plans||[]).find(pl=> pl.id === parseInt(planId));
+ setPlan(p || null);
+ if (p) {
+ try {
+ const rs = await wateringAPI.getPlanPoints(p.id);
+ setPoints(rs.data?.data?.points || []);
+ } catch(e){ toast.error('Failed to load plan points'); setPoints([]); }
+ } else {
+ setPoints([]);
+ }
+ };
+
const onPlace = async (latlng) => {
const p = await ensurePlan(); if (!p) return;
try {
@@ -178,6 +246,10 @@ const Watering = () => {
return null;
};
+ useEffect(() => {
+ setRename(plan?.name || '');
+ }, [plan]);
+
const startGuidance = () => {
if (points.length === 0) { toast.error('Add at least one point'); return; }
if (!navigator.geolocation) { toast.error('GPS not available'); return; }
@@ -246,6 +318,69 @@ const Watering = () => {
Watering - Sprinklers
+
+
Plan Points
+
+ {points.map(pt => (
+ - onSelectPoint(pt)}>
+
+
+ #{pt.sequence}
+ {Number(pt.lat).toFixed(5)}, {Number(pt.lng).toFixed(5)}
+
+
+ {(pt.sprinkler_head_type||'-').replace('_',' ')}
+ {pt.sprinkler_throw_feet ? ` • ${Number(pt.sprinkler_throw_feet)}ft` : ''}
+ {pt.sprinkler_degrees ? ` • ${pt.sprinkler_degrees}°` : ''}
+
+
+ ))}
+ {points.length===0 && - No points yet
}
+
+ {selectedPoint && editForm && (
+
+
Selected Details
+
+ Type: {editForm.sprinklerHeadType || '-'} • GPM: {editForm.gpm || '-'} • Duration: {editForm.durationMinutes||0}m
+
+ {(editForm.sprinklerHeadType==='rotor_impact' || editForm.sprinklerHeadType==='spray_fixed') ? (
+
Throw: {editForm.throwFeet||0} ft • Degrees: {editForm.degrees||0}° • Heading: {editForm.headingDegrees||0}°
+ ) : (
+
Length: {editForm.lengthFeet||0} ft • Width: {editForm.widthFeet||0} ft • Heading: {editForm.headingDegrees||0}°
+ )}
+
+ )}
+
+
+
Plan
+
+
+
+
+ {plan && (
+
+ setRename(e.target.value)} />
+
+
+ )}
+
-
Plan Points
-
- {points.map(pt => (
- - onSelectPoint(pt)}>
- #{pt.sequence}: {Number(pt.lat).toFixed(5)}, {Number(pt.lng).toFixed(5)} • {pt.duration_minutes} min
-
- ))}
- {points.length===0 && - No points yet
}
-
{selectedPoint && editForm && (
-
+
Edit Selected
{(editForm.sprinklerHeadType==='rotor_impact' || editForm.sprinklerHeadType==='spray_fixed') ? (
<>
@@ -368,10 +492,11 @@ const Watering = () => {
)}
+ {!selectedPoint &&
Select a point to edit
}
-
+
{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>
@@ -402,6 +527,23 @@ const Watering = () => {
))}
{placing &&
}
+ {/* Legend overlay */}
+ {points.length > 0 && (
+
+
+
Legend
+
+ {points.map(pt => (
+ -
+
+ #{pt.sequence}
+ {(pt.sprinkler_head_type||'-').replace('_',' ')}
+
+ ))}
+
+
+
+ )}
{points.map(pt => {
const cov = computeCoverage({
type: pt.sprinkler_head_type,
@@ -410,9 +552,10 @@ const Watering = () => {
lengthFeet: parseFloat(pt.sprinkler_length_feet||0),
widthFeet: parseFloat(pt.sprinkler_width_feet||0)
});
+ const color = pointColor(pt);
return (
- {
const ll = e.target.getLatLng();
@@ -424,6 +567,46 @@ const Watering = () => {
Adjust Sprinkler
+ {(pt.sprinkler_head_type==='rotor_impact' || pt.sprinkler_head_type==='spray_fixed') && (
+ <>
+
+ Degrees:
+
+
+ {Number(pt.sprinkler_degrees||0)}°
+
+
+
+
+ Throw:
+
+
+ {Number(pt.sprinkler_throw_feet||0)} ft
+
+
+
+ >
+ )}
+ {pt.sprinkler_head_type==='oscillating_fan' && (
+ <>
+
+ Length:
+
+
+ {Number(pt.sprinkler_length_feet||0)} ft
+
+
+
+
+ Width:
+
+
+ {Number(pt.sprinkler_width_feet||0)} ft
+
+
+
+ >
+ )}
Heading:
@@ -451,9 +634,9 @@ const Watering = () => {
{cov?.kind==='circle' && (
cov.degrees && cov.degrees < 360 ? (
-
+
) : (
-
+
)
)}
{cov?.kind==='rect' && (()=>{
@@ -468,7 +651,8 @@ const Watering = () => {
const lng=clng + (xr/Rlng);
return [lat,lng];
});
- return
;
+ const color = pointColor(pt);
+ return
;
})()}
);
diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js
index 9eb920c..1782a4f 100644
--- a/frontend/src/services/api.js
+++ b/frontend/src/services/api.js
@@ -228,6 +228,7 @@ export const weatherAPI = {
// Watering API endpoints
export const wateringAPI = {
getPlans: (params) => apiClient.get('/watering/plans', { params }),
+ updatePlan: (planId, payload) => apiClient.put(`/watering/plans/${planId}`, payload),
createPlan: (payload) => apiClient.post('/watering/plans', payload),
getPlanPoints: (planId) => apiClient.get(`/watering/plans/${planId}/points`),
addPlanPoint: (planId, payload) => apiClient.post(`/watering/plans/${planId}/points`, payload),