watering
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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'); }
|
||||||
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user