watering
This commit is contained in:
@@ -101,4 +101,63 @@ router.post('/plans/:id/points', async (req,res,next)=>{
|
||||
} 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;
|
||||
|
||||
@@ -45,6 +45,10 @@ const Watering = () => {
|
||||
const [watchId, setWatchId] = useState(null);
|
||||
const [sprinklers, setSprinklers] = 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 () => {
|
||||
try {
|
||||
@@ -255,10 +259,57 @@ const Watering = () => {
|
||||
<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}>#{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>}
|
||||
</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 className="lg:col-span-3">
|
||||
@@ -267,7 +318,10 @@ const Watering = () => {
|
||||
<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</>
|
||||
) : '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 ? (
|
||||
<button className="btn-secondary" onClick={startGuidance} disabled={points.length===0}>Start Guidance</button>
|
||||
) : (
|
||||
@@ -281,7 +335,11 @@ const Watering = () => {
|
||||
</div>
|
||||
<MapContainer center={center} zoom={18} style={{height:'100%', width:'100%'}}>
|
||||
<CenterOnChange center={center} />
|
||||
{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)=> (
|
||||
<Polygon key={s.id} positions={s.polygonData?.coordinates?.[0]||[]} pathOptions={{ color:'#16a34a', weight:2, fillOpacity:0.1 }} />
|
||||
))}
|
||||
@@ -296,7 +354,15 @@ const Watering = () => {
|
||||
});
|
||||
return (
|
||||
<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.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 }} />
|
||||
@@ -328,3 +394,25 @@ const 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'); }
|
||||
};
|
||||
|
||||
@@ -231,6 +231,8 @@ export const wateringAPI = {
|
||||
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),
|
||||
updatePoint: (pointId, payload) => apiClient.put(`/watering/points/${pointId}`, payload),
|
||||
deletePoint: (pointId) => apiClient.delete(`/watering/points/${pointId}`),
|
||||
};
|
||||
|
||||
// Admin API endpoints
|
||||
|
||||
Reference in New Issue
Block a user