sdrgs
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
Reference in New Issue
Block a user