This commit is contained in:
Jake Kasper
2025-09-05 10:33:58 -04:00
parent af10a60e17
commit e691172a9c
3 changed files with 225 additions and 18 deletions

View File

@@ -60,6 +60,28 @@ router.post('/plans', async (req, res, next) => {
} catch (e) { next(e); } } 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 // GET /api/watering/plans/:id/points
router.get('/plans/:id/points', async (req, res, next) => { router.get('/plans/:id/points', async (req, res, next) => {
try { try {

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { MapContainer, TileLayer, Polygon, Marker, Circle, Rectangle, Popup, useMapEvents, useMap } from 'react-leaflet'; import { MapContainer, TileLayer, Polygon, Marker, Circle, Rectangle, Popup, useMapEvents, useMap } from 'react-leaflet';
import { Icon } from 'leaflet';
import 'leaflet/dist/leaflet.css'; import 'leaflet/dist/leaflet.css';
import { propertiesAPI, wateringAPI, equipmentAPI } from '../../services/api'; import { propertiesAPI, wateringAPI, equipmentAPI } from '../../services/api';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -38,6 +39,7 @@ const Watering = () => {
durationMinutes: 60 durationMinutes: 60
}); });
const [plan, setPlan] = useState(null); const [plan, setPlan] = useState(null);
const [plans, setPlans] = useState([]);
const [points, setPoints] = useState([]); const [points, setPoints] = useState([]);
const [guiding, setGuiding] = useState(false); const [guiding, setGuiding] = useState(false);
const [guideIndex, setGuideIndex] = useState(0); const [guideIndex, setGuideIndex] = useState(0);
@@ -49,6 +51,26 @@ const Watering = () => {
const selectedPoint = useMemo(()=> points.find(p=> p.id===selectedPointId), [points, selectedPointId]); const selectedPoint = useMemo(()=> points.find(p=> p.id===selectedPointId), [points, selectedPointId]);
const [editForm, setEditForm] = useState(null); const [editForm, setEditForm] = useState(null);
const [satellite, setSatellite] = useState(false); 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 <svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">\n <circle cx="8" cy="8" r="6.5" fill="${color}" stroke="white" stroke-width="2"/>\n </svg>`;
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) // Select a point helper (for popup+state sync)
const onSelectPoint = (pt) => { const onSelectPoint = (pt) => {
@@ -83,6 +105,17 @@ const Watering = () => {
const updatePointField = async (id, patch) => { const updatePointField = async (id, patch) => {
try { try {
applyLocalPatch(id, patch); 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 r = await wateringAPI.updatePoint(id, patch);
const np = r.data?.data?.point; const np = r.data?.data?.point;
setPoints(prev => prev.map(p=> p.id===id? np: p)); 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 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 })); const secs = (p.sections||[]).map(s => ({ ...s, polygonData: typeof s.polygonData === 'string' ? JSON.parse(s.polygonData) : s.polygonData }));
setSections(secs); 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'); } catch(e){ toast.error('Failed to load property'); }
}; };
@@ -128,10 +174,32 @@ const Watering = () => {
const ensurePlan = async () => { const ensurePlan = async () => {
if (plan) return plan; if (plan) return plan;
if (!selectedProperty) { toast.error('Select a property first'); return null; } 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; } 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 onPlace = async (latlng) => {
const p = await ensurePlan(); if (!p) return; const p = await ensurePlan(); if (!p) return;
try { try {
@@ -178,6 +246,10 @@ const Watering = () => {
return null; return null;
}; };
useEffect(() => {
setRename(plan?.name || '');
}, [plan]);
const startGuidance = () => { const startGuidance = () => {
if (points.length === 0) { toast.error('Add at least one point'); return; } if (points.length === 0) { toast.error('Add at least one point'); return; }
if (!navigator.geolocation) { toast.error('GPS not available'); return; } if (!navigator.geolocation) { toast.error('GPS not available'); return; }
@@ -246,6 +318,69 @@ const Watering = () => {
<h1 className="text-2xl font-bold mb-4">Watering - Sprinklers</h1> <h1 className="text-2xl font-bold mb-4">Watering - Sprinklers</h1>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-1 lg:grid-cols-4 gap-4">
<div className="lg:col-span-1 space-y-4"> <div className="lg:col-span-1 space-y-4">
<div className="card">
<div className="font-medium mb-2">Plan Points</div>
<ul className="text-sm space-y-1 max-h-64 overflow-auto">
{points.map(pt => (
<li key={pt.id}
className={(pt.id===selectedPointId? 'bg-blue-50 ' : '') + 'rounded px-2 py-1 hover:bg-gray-50 cursor-pointer flex items-center gap-2 justify-between'}
onClick={()=> onSelectPoint(pt)}>
<span className="flex items-center gap-2">
<span style={{backgroundColor: pointColor(pt)}} className="inline-block w-3 h-3 rounded-full"></span>
<span className="font-mono text-xs">#{pt.sequence}</span>
<span className="text-xs">{Number(pt.lat).toFixed(5)}, {Number(pt.lng).toFixed(5)}</span>
</span>
<span className="text-[11px] text-gray-600">
{(pt.sprinkler_head_type||'-').replace('_',' ')}
{pt.sprinkler_throw_feet ? `${Number(pt.sprinkler_throw_feet)}ft` : ''}
{pt.sprinkler_degrees ? `${pt.sprinkler_degrees}°` : ''}
</span>
</li>
))}
{points.length===0 && <li className="text-gray-500">No points yet</li>}
</ul>
{selectedPoint && editForm && (
<div className="mt-3 border-t pt-3 space-y-2">
<div className="font-medium">Selected Details</div>
<div className="text-xs text-gray-700">
Type: {editForm.sprinklerHeadType || '-'} GPM: {editForm.gpm || '-'} Duration: {editForm.durationMinutes||0}m
</div>
{(editForm.sprinklerHeadType==='rotor_impact' || editForm.sprinklerHeadType==='spray_fixed') ? (
<div className="text-xs text-gray-700">Throw: {editForm.throwFeet||0} ft Degrees: {editForm.degrees||0}° Heading: {editForm.headingDegrees||0}°</div>
) : (
<div className="text-xs text-gray-700">Length: {editForm.lengthFeet||0} ft Width: {editForm.widthFeet||0} ft Heading: {editForm.headingDegrees||0}°</div>
)}
</div>
)}
</div>
<div className="card space-y-2">
<div className="font-medium">Plan</div>
<div className="flex gap-2 items-center">
<select className="input flex-1" value={plan?.id || ''} onChange={(e)=> selectPlan(e.target.value)} disabled={!selectedProperty}>
<option value="">-- Select plan --</option>
{plans.map(pl => (<option key={pl.id} value={pl.id}>{pl.name}</option>))}
</select>
<button className="btn-secondary" disabled={!selectedProperty} onClick={async ()=>{
const name = `${selectedProperty?.name || 'Property'} - Sprinklers ${new Date().toLocaleDateString()}`;
const r = await wateringAPI.createPlan({ propertyId: selectedProperty.id, name });
const np = r.data?.data?.plan; setPlan(np);
const pl = await wateringAPI.getPlans({ property_id: selectedProperty.id });
setPlans(pl.data?.data?.plans || []); setPoints([]);
}}>New</button>
</div>
{plan && (
<div className="flex gap-2 mt-2">
<input className="input flex-1" placeholder="Rename plan" value={rename} onChange={(e)=> setRename(e.target.value)} />
<button className="btn-primary" onClick={async ()=>{
if (!rename.trim()) return;
const rs = await wateringAPI.updatePlan(plan.id, { name: rename.trim() });
const up = rs.data?.data?.plan; setPlan(up);
const pl = await wateringAPI.getPlans({ property_id: selectedProperty.id });
setPlans(pl.data?.data?.plans || []);
}}>Save</button>
</div>
)}
</div>
<div className="card"> <div className="card">
<label className="block text-sm font-medium mb-1">Property</label> <label className="block text-sm font-medium mb-1">Property</label>
<select className="input" value={selectedProperty?.id||''} onChange={(e)=> loadProperty(parseInt(e.target.value))}> <select className="input" value={selectedProperty?.id||''} onChange={(e)=> loadProperty(parseInt(e.target.value))}>
@@ -314,19 +449,8 @@ const Watering = () => {
<button className="btn-primary w-full" disabled={!selectedProperty} onClick={()=> setPlacing(true)}>Place Sprinkler on Map</button> <button className="btn-primary w-full" disabled={!selectedProperty} onClick={()=> setPlacing(true)}>Place Sprinkler on Map</button>
</div> </div>
<div className="card"> <div className="card">
<div className="font-medium mb-2">Plan Points</div>
<ul className="text-sm space-y-1 max-h-64 overflow-auto">
{points.map(pt => (
<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>}
</ul>
{selectedPoint && editForm && ( {selectedPoint && editForm && (
<div className="mt-3 border-t pt-3 space-y-2"> <div className="space-y-2">
<div className="font-medium">Edit Selected</div> <div className="font-medium">Edit Selected</div>
{(editForm.sprinklerHeadType==='rotor_impact' || editForm.sprinklerHeadType==='spray_fixed') ? ( {(editForm.sprinklerHeadType==='rotor_impact' || editForm.sprinklerHeadType==='spray_fixed') ? (
<> <>
@@ -368,10 +492,11 @@ const Watering = () => {
</div> </div>
</div> </div>
)} )}
{!selectedPoint && <div className="text-sm text-gray-500">Select a point to edit</div>}
</div> </div>
</div> </div>
<div className="lg:col-span-3"> <div className="lg:col-span-3">
<div className="card p-0" style={{height:'70vh'}}> <div className="card p-0" style={{height:'70vh', position:'relative'}}>
<div className="flex items-center justify-between p-3 border-b"> <div className="flex items-center justify-between p-3 border-b">
<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</>
@@ -402,6 +527,23 @@ const Watering = () => {
<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 }} />
))} ))}
{placing && <SprinklerPlacement onPlace={onPlace} />} {placing && <SprinklerPlacement onPlace={onPlace} />}
{/* Legend overlay */}
{points.length > 0 && (
<div style={{ position:'absolute', right:10, bottom:10, zIndex:1000 }}>
<div className="bg-white/90 border rounded shadow p-2 max-h-56 overflow-auto text-xs">
<div className="font-medium mb-1">Legend</div>
<ul className="space-y-1">
{points.map(pt => (
<li key={`lg-${pt.id}`} className="flex items-center gap-2">
<span style={{backgroundColor: pointColor(pt)}} className="inline-block w-3 h-3 rounded-full"></span>
<span className="font-mono">#{pt.sequence}</span>
<span className="text-gray-700">{(pt.sprinkler_head_type||'-').replace('_',' ')}</span>
</li>
))}
</ul>
</div>
</div>
)}
{points.map(pt => { {points.map(pt => {
const cov = computeCoverage({ const cov = computeCoverage({
type: pt.sprinkler_head_type, type: pt.sprinkler_head_type,
@@ -410,9 +552,10 @@ const Watering = () => {
lengthFeet: parseFloat(pt.sprinkler_length_feet||0), lengthFeet: parseFloat(pt.sprinkler_length_feet||0),
widthFeet: parseFloat(pt.sprinkler_width_feet||0) widthFeet: parseFloat(pt.sprinkler_width_feet||0)
}); });
const color = pointColor(pt);
return ( return (
<React.Fragment key={pt.id}> <React.Fragment key={pt.id}>
<Marker position={[Number(pt.lat), Number(pt.lng)]} draggable={true} <Marker position={[Number(pt.lat), Number(pt.lng)]} draggable={true} icon={markerIcon(color)}
eventHandlers={{ eventHandlers={{
dragend: (e)=>{ dragend: (e)=>{
const ll = e.target.getLatLng(); const ll = e.target.getLatLng();
@@ -424,6 +567,46 @@ const Watering = () => {
<Popup> <Popup>
<div className="space-y-2 text-sm"> <div className="space-y-2 text-sm">
<div className="font-medium">Adjust Sprinkler</div> <div className="font-medium">Adjust Sprinkler</div>
{(pt.sprinkler_head_type==='rotor_impact' || pt.sprinkler_head_type==='spray_fixed') && (
<>
<div className="flex items-center gap-2">
<span className="text-xs">Degrees:</span>
<button className="px-2 py-1 border rounded" onClick={()=> updatePointField(pt.id, { degrees: Math.max(0, Math.min(360, Number(pt.sprinkler_degrees||0) - 10)) })}>-10°</button>
<button className="px-2 py-1 border rounded" onClick={()=> updatePointField(pt.id, { degrees: Math.max(0, Math.min(360, Number(pt.sprinkler_degrees||0) - 1)) })}>-1°</button>
<span className="px-2">{Number(pt.sprinkler_degrees||0)}°</span>
<button className="px-2 py-1 border rounded" onClick={()=> updatePointField(pt.id, { degrees: Math.max(0, Math.min(360, Number(pt.sprinkler_degrees||0) + 1)) })}>+1°</button>
<button className="px-2 py-1 border rounded" onClick={()=> updatePointField(pt.id, { degrees: Math.max(0, Math.min(360, Number(pt.sprinkler_degrees||0) + 10)) })}>+10°</button>
</div>
<div className="flex items-center gap-2">
<span className="text-xs">Throw:</span>
<button className="px-2 py-1 border rounded" onClick={()=> updatePointField(pt.id, { throwFeet: Math.max(0, Number(pt.sprinkler_throw_feet||0) - 5) })}>-5ft</button>
<button className="px-2 py-1 border rounded" onClick={()=> updatePointField(pt.id, { throwFeet: Math.max(0, Number(pt.sprinkler_throw_feet||0) - 1) })}>-1ft</button>
<span className="px-2">{Number(pt.sprinkler_throw_feet||0)} ft</span>
<button className="px-2 py-1 border rounded" onClick={()=> updatePointField(pt.id, { throwFeet: Math.max(0, Number(pt.sprinkler_throw_feet||0) + 1) })}>+1ft</button>
<button className="px-2 py-1 border rounded" onClick={()=> updatePointField(pt.id, { throwFeet: Math.max(0, Number(pt.sprinkler_throw_feet||0) + 5) })}>+5ft</button>
</div>
</>
)}
{pt.sprinkler_head_type==='oscillating_fan' && (
<>
<div className="flex items-center gap-2">
<span className="text-xs">Length:</span>
<button className="px-2 py-1 border rounded" onClick={()=> updatePointField(pt.id, { lengthFeet: Math.max(0, Number(pt.sprinkler_length_feet||0) - 5) })}>-5ft</button>
<button className="px-2 py-1 border rounded" onClick={()=> updatePointField(pt.id, { lengthFeet: Math.max(0, Number(pt.sprinkler_length_feet||0) - 1) })}>-1ft</button>
<span className="px-2">{Number(pt.sprinkler_length_feet||0)} ft</span>
<button className="px-2 py-1 border rounded" onClick={()=> updatePointField(pt.id, { lengthFeet: Math.max(0, Number(pt.sprinkler_length_feet||0) + 1) })}>+1ft</button>
<button className="px-2 py-1 border rounded" onClick={()=> updatePointField(pt.id, { lengthFeet: Math.max(0, Number(pt.sprinkler_length_feet||0) + 5) })}>+5ft</button>
</div>
<div className="flex items-center gap-2">
<span className="text-xs">Width:</span>
<button className="px-2 py-1 border rounded" onClick={()=> updatePointField(pt.id, { widthFeet: Math.max(0, Number(pt.sprinkler_width_feet||0) - 5) })}>-5ft</button>
<button className="px-2 py-1 border rounded" onClick={()=> updatePointField(pt.id, { widthFeet: Math.max(0, Number(pt.sprinkler_width_feet||0) - 1) })}>-1ft</button>
<span className="px-2">{Number(pt.sprinkler_width_feet||0)} ft</span>
<button className="px-2 py-1 border rounded" onClick={()=> updatePointField(pt.id, { widthFeet: Math.max(0, Number(pt.sprinkler_width_feet||0) + 1) })}>+1ft</button>
<button className="px-2 py-1 border rounded" onClick={()=> updatePointField(pt.id, { widthFeet: Math.max(0, Number(pt.sprinkler_width_feet||0) + 5) })}>+5ft</button>
</div>
</>
)}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs">Heading:</span> <span className="text-xs">Heading:</span>
<button className="px-2 py-1 border rounded" onClick={()=> adjustHeading(pt, -10)}>-10°</button> <button className="px-2 py-1 border rounded" onClick={()=> adjustHeading(pt, -10)}>-10°</button>
@@ -451,9 +634,9 @@ const Watering = () => {
</Marker> </Marker>
{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, fillOpacity:0.2 }} />
) : ( ) : (
<Circle center={[pt.lat, pt.lng]} radius={cov.radius*0.3048} pathOptions={{ color:'#2563eb', fillOpacity:0.2 }} /> <Circle center={[pt.lat, pt.lng]} radius={cov.radius*0.3048} pathOptions={{ color, fillOpacity:0.2 }} />
) )
)} )}
{cov?.kind==='rect' && (()=>{ {cov?.kind==='rect' && (()=>{
@@ -468,7 +651,8 @@ const Watering = () => {
const lng=clng + (xr/Rlng); const lng=clng + (xr/Rlng);
return [lat,lng]; return [lat,lng];
}); });
return <Polygon positions={corners} pathOptions={{ color:'#2563eb', fillOpacity:0.2 }} />; const color = pointColor(pt);
return <Polygon positions={corners} pathOptions={{ color, fillOpacity:0.2 }} />;
})()} })()}
</React.Fragment> </React.Fragment>
); );

View File

@@ -228,6 +228,7 @@ export const weatherAPI = {
// Watering API endpoints // Watering API endpoints
export const wateringAPI = { export const wateringAPI = {
getPlans: (params) => apiClient.get('/watering/plans', { params }), getPlans: (params) => apiClient.get('/watering/plans', { params }),
updatePlan: (planId, payload) => apiClient.put(`/watering/plans/${planId}`, payload),
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),