Files
turftracker/frontend/src/pages/Watering/Watering.js
Jake Kasper da612539e6 asdfsafd
2025-09-05 11:20:40 -04:00

834 lines
46 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useMemo, useState } from 'react';
import { MapContainer, TileLayer, Polygon, Marker, Circle, Rectangle, Popup, useMapEvents, useMap, Polyline } 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';
const SprinklerPlacement = ({ onPlace }) => {
useMapEvents({
click(e) { onPlace([e.latlng.lat, e.latlng.lng]); }
});
return null;
};
const computeCoverage = (sprinkler) => {
if (sprinkler.type === 'rotor_impact' || sprinkler.type === 'spray_fixed') {
return { kind: 'circle', radius: sprinkler.throwFeet, degrees: sprinkler.degrees || 360 };
}
if (sprinkler.type === 'oscillating_fan') {
const L = sprinkler.lengthFeet || 0; const W = sprinkler.widthFeet || 0;
return { kind: 'rect', length: L, width: W };
}
return null;
};
const Watering = () => {
const [properties, setProperties] = useState([]);
const [selectedProperty, setSelectedProperty] = useState(null);
const [sections, setSections] = useState([]);
const [placing, setPlacing] = useState(false);
const [sprinklerForm, setSprinklerForm] = useState({
mount: 'above_ground',
type: 'rotor_impact',
gpm: 2.5,
throwFeet: 20,
degrees: 360,
lengthFeet: 30,
widthFeet: 20,
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);
const [currentPos, setCurrentPos] = useState(null);
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 sortedPoints = useMemo(() => {
return [...points].sort((a,b) => (Number(a.sequence||0)) - (Number(b.sequence||0)) || (Number(a.id)-Number(b.id)));
}, [points]);
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 <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)
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)
});
};
// Optimistic local patch + save to server
const applyLocalPatch = (id, patch) => {
setPoints(prev => prev.map(p => p.id === id ? { ...p,
sprinkler_degrees: patch.degrees !== undefined ? patch.degrees : p.sprinkler_degrees,
sprinkler_heading_degrees: patch.headingDegrees !== undefined ? patch.headingDegrees : p.sprinkler_heading_degrees,
sprinkler_throw_feet: patch.throwFeet !== undefined ? patch.throwFeet : p.sprinkler_throw_feet,
sprinkler_length_feet: patch.lengthFeet !== undefined ? patch.lengthFeet : p.sprinkler_length_feet,
sprinkler_width_feet: patch.widthFeet !== undefined ? patch.widthFeet : p.sprinkler_width_feet,
duration_minutes: patch.durationMinutes !== undefined ? patch.durationMinutes : p.duration_minutes,
lat: patch.lat !== undefined ? patch.lat : p.lat,
lng: patch.lng !== undefined ? patch.lng : p.lng
} : p));
};
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));
} catch(e){ toast.error('Failed to update point'); }
};
const nudgePointLocation = (pt, dxFeet, dyFeet) => {
const lat = Number(pt.lat), lng = Number(pt.lng);
const Rlat = 111320; // meters per degree lat
const Rlng = Math.cos(lat*Math.PI/180)*111320; // meters per degree lng
const dxm = dxFeet*0.3048; // east-west
const dym = dyFeet*0.3048; // north-south
const newLat = lat + (dym / Rlat);
const newLng = lng + (dxm / Rlng);
updatePointField(pt.id, { lat: newLat, lng: newLng });
};
const adjustHeading = (pt, delta) => {
const cur = Number(pt.sprinkler_heading_degrees || 0);
let next = (cur + delta) % 360; if (next < 0) next += 360;
updatePointField(pt.id, { headingDegrees: next });
};
useEffect(() => { (async () => {
try {
const [pr, eq] = await Promise.all([propertiesAPI.getAll(), equipmentAPI.getAll()]);
setProperties(pr.data?.data?.properties||[]);
const list = (eq.data?.data?.equipment||[]).filter(e => (e.categoryName||'').toLowerCase()==='sprinkler');
setSprinklers(list);
}
catch(e){ toast.error('Failed to load properties'); }
})(); }, []);
const loadProperty = async (pid) => {
try {
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'); }
};
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: `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 {
const eq = sprinklers.find(s=> s.id === (selectedSprinklerId? parseInt(selectedSprinklerId): -1));
const base = eq ? {
mount: eq.sprinklerMount || sprinklerForm.mount,
type: eq.sprinklerHeadType || sprinklerForm.type,
gpm: eq.sprinklerGpm || sprinklerForm.gpm,
throwFeet: eq.sprinklerThrowFeet || sprinklerForm.throwFeet,
degrees: (eq.sprinklerDegrees !== undefined && eq.sprinklerDegrees !== null) ? eq.sprinklerDegrees : sprinklerForm.degrees,
lengthFeet: eq.sprinklerLengthFeet || sprinklerForm.lengthFeet,
widthFeet: eq.sprinklerWidthFeet || sprinklerForm.widthFeet
} : sprinklerForm;
const payload = {
lat: latlng[0], lng: latlng[1], durationMinutes: sprinklerForm.durationMinutes,
mountType: base.mount,
sprinklerHeadType: base.type,
gpm: base.gpm,
throwFeet: base.throwFeet,
degrees: base.degrees,
lengthFeet: base.lengthFeet,
widthFeet: base.widthFeet,
headingDegrees: sprinklerForm.headingDegrees || 0,
equipmentId: eq ? eq.id : null,
equipmentName: eq ? (eq.customName || `${eq.manufacturer||''} ${eq.model||''}`.trim()) : null
};
const r = await wateringAPI.addPlanPoint(p.id, payload);
setPoints(prev => [...prev, r.data?.data?.point]);
toast.success('Sprinkler location added');
setPlacing(false);
} catch(e){ toast.error('Failed to add sprinkler point'); }
};
const center = useMemo(() => {
if (selectedProperty?.latitude && selectedProperty?.longitude) return [Number(selectedProperty.latitude), Number(selectedProperty.longitude)];
if (sections?.length){
const pts = sections.flatMap(s => (s.polygonData?.coordinates?.[0]||[]));
if (pts.length){ const lat = pts.reduce((a,p)=> a + Number(p[0]), 0) / pts.length; const lng = pts.reduce((a,p)=> a + Number(p[1]), 0) / pts.length; return [lat, lng]; }
}
return [39.8,-98.6];
}, [selectedProperty, sections]);
const CenterOnChange = ({ center }) => {
const map = useMap();
useEffect(() => { if (center && Array.isArray(center)) map.setView(center); }, [center, map]);
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; }
setGuiding(true); setGuideIndex(0);
const id = navigator.geolocation.watchPosition(pos => {
setCurrentPos({ lat: pos.coords.latitude, lng: pos.coords.longitude });
}, err => { console.warn(err); toast.error('GPS error'); }, { enableHighAccuracy:true, maximumAge: 1000, timeout: 10000 });
setWatchId(id);
};
const stopGuidance = () => { if (watchId){ navigator.geolocation.clearWatch(watchId); setWatchId(null);} setGuiding(false); };
const distanceFeet = (a,b) => {
const R=6371000; const toRad=d=>d*Math.PI/180;
const dLat=toRad(b.lat-a.lat); const dLng=toRad(b.lng-a.lng);
const A=Math.sin(dLat/2)**2 + Math.cos(toRad(a.lat))*Math.cos(toRad(b.lat))*Math.sin(dLng/2)**2;
return (2*R*Math.atan2(Math.sqrt(A),Math.sqrt(1-A)))*3.28084;
};
const bearingDegrees = (a,b) => {
const toRad = d=> d*Math.PI/180, toDeg = r=> (r*180/Math.PI+360)%360;
const lat1 = toRad(a.lat), lat2 = toRad(b.lat);
const dLng = toRad(b.lng - a.lng);
const y = Math.sin(dLng) * Math.cos(lat2);
const x = Math.cos(lat1)*Math.sin(lat2) - Math.sin(lat1)*Math.cos(lat2)*Math.cos(dLng);
return Math.round(toDeg(Math.atan2(y,x)));
};
// Build sector polygon (approx) for Leaflet
const sectorPolygon = (center, radiusFeet, startDeg, endDeg, steps=60) => {
const [clat, clng] = [Number(center.lat), Number(center.lng)];
const Rlat = 111320; // meters per degree lat
const Rlng = Math.cos(clat*Math.PI/180)*111320; // meters per degree lng
const rm = radiusFeet*0.3048; // meters
const pts = [];
const start = startDeg*Math.PI/180; const end = endDeg*Math.PI/180;
const span = end - start;
const n = Math.max(8, Math.round(steps*Math.abs(span)/(2*Math.PI)));
pts.push([clat, clng]);
for (let i=0;i<=n;i++){
const ang = start + (span*i/n);
const dx = rm * Math.cos(ang);
const dy = rm * Math.sin(ang);
const lat = clat + (dy / Rlat);
const lng = clng + (dx / Rlng);
pts.push([lat, lng]);
}
pts.push([clat, clng]);
return pts;
};
const coverageSqft = useMemo(() => {
const type = sprinklerForm.type;
if (type==='rotor_impact' || type==='spray_fixed') {
const r = Number(sprinklerForm.throwFeet||0); const deg=Number(sprinklerForm.degrees||360);
return Math.PI * r * r * (deg/360);
}
if (type==='oscillating_fan') {
return Number(sprinklerForm.lengthFeet||0) * Number(sprinklerForm.widthFeet||0);
}
return 0;
}, [sprinklerForm]);
const [targetInches, setTargetInches] = useState(0.5);
const suggestMinutes = useMemo(() => {
const gpm = Number(sprinklerForm.gpm||0);
if (!gpm || !coverageSqft || !targetInches) return 0;
const gallonsNeeded = coverageSqft * targetInches * 0.623; // gal per sqft-inch
return Math.ceil((gallonsNeeded / gpm));
}, [sprinklerForm.gpm, coverageSqft, targetInches]);
return (
<div className="p-6">
<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="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">
{sortedPoints.map((pt, idx) => (
<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">#{idx+1}</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.equipment_name || (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">
{selectedPoint?.equipment_name ? (<>
<span className="font-semibold">{selectedPoint.equipment_name}</span>
</>) : null}
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">
<label className="block text-sm font-medium mb-1">Property</label>
<select className="input" value={selectedProperty?.id||''} onChange={(e)=> loadProperty(parseInt(e.target.value))}>
<option value="">Select property</option>
{properties.map(p=> (<option key={p.id} value={p.id}>{p.name}</option>))}
</select>
</div>
<div className="card space-y-2">
<div className="font-medium">Plan</div>
<select className="input w-full" 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>
<div className="flex gap-2 items-center flex-wrap">
<button className="btn-secondary" disabled={!selectedProperty} onClick={async ()=>{
const name = `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>
{plan && (
<button className="btn-secondary" onClick={async ()=>{
const name = `${plan.name} (Copy ${new Date().toLocaleDateString()})`;
const rs = await wateringAPI.duplicatePlan(plan.id, { name });
const np = rs.data?.data?.plan; setPlan(np);
const pl = await wateringAPI.getPlans({ property_id: selectedProperty.id });
setPlans(pl.data?.data?.plans || []);
const pts = await wateringAPI.getPlanPoints(np.id);
setPoints(pts.data?.data?.points || []);
}}>Duplicate</button>
)}
{plan && (
<button className="btn-secondary text-red-600 border-red-300" onClick={async ()=>{
if (!window.confirm('Delete this plan and all points?')) return;
await wateringAPI.deletePlan(plan.id);
const pl = await wateringAPI.getPlans({ property_id: selectedProperty.id });
const list = pl.data?.data?.plans || [];
setPlans(list);
setPlan(list[0] || null);
if (list[0]){
const rs = await wateringAPI.getPlanPoints(list[0].id);
setPoints(rs.data?.data?.points || []);
} else {
setPoints([]);
}
}}>Delete</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 space-y-2">
<div className="font-medium">Sprinkler Settings</div>
<div className="text-sm">Choose Saved Sprinkler</div>
<select className="input" value={selectedSprinklerId} onChange={(e)=> {
const val=e.target.value; setSelectedSprinklerId(val);
const eq = sprinklers.find(s=> s.id === parseInt(val));
if (eq) {
setSprinklerForm(sf=> ({...sf,
mount: eq.sprinklerMount || sf.mount,
type: eq.sprinklerHeadType || sf.type,
gpm: eq.sprinklerGpm || sf.gpm,
throwFeet: eq.sprinklerThrowFeet || sf.throwFeet,
degrees: (eq.sprinklerDegrees !== undefined && eq.sprinklerDegrees !== null) ? eq.sprinklerDegrees : sf.degrees,
lengthFeet: eq.sprinklerLengthFeet || sf.lengthFeet,
widthFeet: eq.sprinklerWidthFeet || sf.widthFeet
}));
}
}}>
<option value="">-- none --</option>
{sprinklers.map(s=> (
<option key={s.id} value={s.id}>{s.customName || `${s.manufacturer||''} ${s.model||''}`.trim()}</option>
))}
</select>
{(!selectedSprinklerId) && (
<SaveSprinklerInline
current={sprinklerForm}
onSaved={async (newEq)=>{
// refresh list
const eq = await equipmentAPI.getAll();
const list = (eq.data?.data?.equipment||[]).filter(e => (e.categoryName||'').toLowerCase()==='sprinkler');
setSprinklers(list);
setSelectedSprinklerId(String(newEq.id));
}}
/>
)}
<div className="text-sm">Mount</div>
<select className="input" value={sprinklerForm.mount} onChange={e=> setSprinklerForm({...sprinklerForm, mount:e.target.value})}>
<option value="in_ground">InGround</option>
<option value="above_ground">AboveGround</option>
</select>
<div className="text-sm">Type</div>
<select className="input" value={sprinklerForm.type} onChange={e=> setSprinklerForm({...sprinklerForm, type:e.target.value})}>
<option value="rotor_impact">Rotor/Impact</option>
<option value="oscillating_fan">Oscillating/Fan</option>
<option value="spray_fixed">Spray (Fixed)</option>
</select>
{sprinklerForm.type === 'rotor_impact' || sprinklerForm.type === 'spray_fixed' ? (
<>
<div className="text-sm">GPM</div>
<input type="number" step="0.1" className="input" value={sprinklerForm.gpm} onChange={e=> setSprinklerForm({...sprinklerForm, gpm: parseFloat(e.target.value)})} />
<div className="text-sm">Throw distance (ft)</div>
<input type="number" step="0.1" className="input" value={sprinklerForm.throwFeet} onChange={e=> setSprinklerForm({...sprinklerForm, throwFeet: parseFloat(e.target.value)})} />
<div className="text-sm">Degrees (0360)</div>
<input type="number" className="input" value={sprinklerForm.degrees} onChange={e=> setSprinklerForm({...sprinklerForm, degrees: parseInt(e.target.value||'0',10)})} />
<div className="text-sm">Heading (0359, 0 = East)</div>
<input type="number" className="input" value={sprinklerForm.headingDegrees||0} onChange={e=> setSprinklerForm({...sprinklerForm, headingDegrees: (parseInt(e.target.value||'0',10)||0)%360})} />
</>
) : (
<>
<div className="text-sm">Length (ft)</div>
<input type="number" step="0.1" className="input" value={sprinklerForm.lengthFeet} onChange={e=> setSprinklerForm({...sprinklerForm, lengthFeet: parseFloat(e.target.value)})} />
<div className="text-sm">Width (ft)</div>
<input type="number" step="0.1" className="input" value={sprinklerForm.widthFeet} onChange={e=> setSprinklerForm({...sprinklerForm, widthFeet: parseFloat(e.target.value)})} />
</>
)}
<div className="text-sm">Target Depth (inches)</div>
<input type="number" step="0.1" className="input" value={targetInches} onChange={e=> setTargetInches(parseFloat(e.target.value||'0'))} />
<div className="text-xs text-gray-600">Suggested runtime: {suggestMinutes} minutes {sprinklerForm.gpm? '' : '(enter GPM to calculate)'}
</div>
<div className="text-sm">Run Duration (minutes)</div>
<input type="number" className="input" value={sprinklerForm.durationMinutes} onChange={e=> setSprinklerForm({...sprinklerForm, durationMinutes: parseInt(e.target.value||'0',10)})} />
<button className="btn-primary w-full" disabled={!selectedProperty} onClick={()=> setPlacing(true)}>Place Sprinkler on Map</button>
</div>
<div className="card">
{selectedPoint && editForm && (
<div className="space-y-2">
<div className="font-medium">Edit Selected</div>
<div>
<label className="text-xs">Saved Sprinkler</label>
<div className="flex gap-2 items-center">
<select className="input flex-1" value={selectedPoint?.equipment_id || ''} onChange={async (e)=>{
const val = e.target.value;
if (!val) {
await updatePointField(selectedPointId, { equipmentId: null, equipmentName: null });
return;
}
const eq = sprinklers.find(s=> s.id === parseInt(val));
if (eq) {
await updatePointField(selectedPointId, {
equipmentId: eq.id,
equipmentName: eq.customName || `${eq.manufacturer||''} ${eq.model||''}`.trim()
});
}
}}>
<option value="">-- none --</option>
{sprinklers.map(s=> (
<option key={s.id} value={s.id}>{s.customName || `${s.manufacturer||''} ${s.model||''}`.trim()}</option>
))}
</select>
{selectedPoint?.equipment_id && (
<button className="btn-secondary" onClick={async ()=>{
await updatePointField(selectedPointId, { equipmentId: null, equipmentName: null });
}}>Unlink</button>
)}
</div>
</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);
if (plan) {
const rs = await wateringAPI.getPlanPoints(plan.id);
setPoints(rs.data?.data?.points || []);
} else {
setPoints(prev=> prev.filter(p=> p.id!==selectedPointId));
}
setSelectedPointId(null); setEditForm(null);
}}>Delete</button>
</div>
</div>
)}
{!selectedPoint && <div className="text-sm text-gray-500">Select a point to edit</div>}
</div>
</div>
<div className="lg:col-span-3">
<div className="card p-0" style={{height:'70vh', position:'relative'}}>
<div className="flex items-center justify-between p-3 border-b">
<div className="text-sm text-gray-700">{guiding && currentPos && sortedPoints[guideIndex] ? (()=>{
const tgt = {lat:Number(sortedPoints[guideIndex].lat), lng:Number(sortedPoints[guideIndex].lng)};
const dist = distanceFeet(currentPos, tgt);
const brg = bearingDegrees(currentPos, tgt);
if (dist <= 15) {
return <span className="text-green-700 font-semibold">Arrived at #{guideIndex+1} {dist.toFixed(0)} ft</span>;
}
return <> {brg}° {dist.toFixed(0)} ft Point #{guideIndex+1}</>;
})() : 'Map'}</div>
<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={sortedPoints.length===0}>Start Guidance</button>
) : (
<>
<button className="btn-secondary" onClick={()=> setGuideIndex(i=> Math.max(0, i-1))} disabled={guideIndex===0}>Prev</button>
<button className="btn-secondary" onClick={()=> setGuideIndex(i=> Math.min(sortedPoints.length-1, i+1))} disabled={guideIndex>=sortedPoints.length-1}>Next</button>
<button className="btn-primary" onClick={stopGuidance}>Stop</button>
</>
)}
</div>
</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 }} />
))}
{placing && <SprinklerPlacement onPlace={onPlace} />}
{guiding && currentPos && sortedPoints[guideIndex] && (
<>
{/* Current location marker */}
<Marker position={[currentPos.lat, currentPos.lng]} icon={markerIcon('#0ea5e9')} />
{/* Line to target */}
<Polyline positions={[[currentPos.lat, currentPos.lng], [Number(sortedPoints[guideIndex].lat), Number(sortedPoints[guideIndex].lng)]]} pathOptions={{ color:'#0ea5e9', dashArray:'6 6' }} />
{/* Arrival ring */}
<Circle center={[Number(sortedPoints[guideIndex].lat), Number(sortedPoints[guideIndex].lng)]} radius={15*0.3048} pathOptions={{ color:'#22c55e', fillOpacity:0.15, weight:2 }} />
</>
)}
{/* Legend overlay */}
{sortedPoints.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">
{sortedPoints.map((pt, idx) => (
<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">#{idx+1}</span>
<span className="text-gray-700">{pt.equipment_name || (pt.sprinkler_head_type||'-').replace('_',' ')}</span>
{pt.sprinkler_gpm ? (<span className="text-gray-600"> {Number(pt.sprinkler_gpm)} gpm</span>) : null}
{pt.duration_minutes ? (<span className="text-gray-600"> {pt.duration_minutes} min</span>) : null}
</li>
))}
</ul>
</div>
</div>
)}
{sortedPoints.map(pt => {
const cov = computeCoverage({
type: pt.sprinkler_head_type,
throwFeet: parseFloat(pt.sprinkler_throw_feet||0),
degrees: parseInt(pt.sprinkler_degrees||360,10),
lengthFeet: parseFloat(pt.sprinkler_length_feet||0),
widthFeet: parseFloat(pt.sprinkler_width_feet||0)
});
const color = pointColor(pt);
return (
<React.Fragment key={pt.id}>
<Marker position={[Number(pt.lat), Number(pt.lng)]} draggable={true} icon={markerIcon(color)}
eventHandlers={{
dragend: (e)=>{
const ll = e.target.getLatLng();
updatePointField(pt.id, { lat: ll.lat, lng: ll.lng });
},
click: ()=> onSelectPoint(pt)
}}
>
<Popup>
<div className="space-y-2 text-sm">
<div className="font-medium">{pt.equipment_name || '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">
<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, -1)}>-1°</button>
<span className="px-2">{Number(pt.sprinkler_heading_degrees||0)}°</span>
<button className="px-2 py-1 border rounded" onClick={()=> adjustHeading(pt, +1)}>+1°</button>
<button className="px-2 py-1 border rounded" onClick={()=> adjustHeading(pt, +10)}>+10°</button>
</div>
<div className="grid grid-cols-3 gap-2 items-center">
<div></div>
<button className="px-2 py-1 border rounded" onClick={()=> nudgePointLocation(pt, 0, +1)}></button>
<div></div>
<button className="px-2 py-1 border rounded" onClick={()=> nudgePointLocation(pt, -1, 0)}></button>
<div className="text-center text-xs">Move 1 ft</div>
<button className="px-2 py-1 border rounded" onClick={()=> nudgePointLocation(pt, +1, 0)}></button>
<div></div>
<button className="px-2 py-1 border rounded" onClick={()=> nudgePointLocation(pt, 0, -1)}></button>
<div></div>
</div>
<div className="flex gap-2 pt-1">
<button className="btn-secondary" onClick={async ()=>{
await wateringAPI.deletePoint(pt.id);
if (plan) {
const rs = await wateringAPI.getPlanPoints(plan.id);
setPoints(rs.data?.data?.points || []);
} else {
setPoints(prev=> prev.filter(p=> p.id!==pt.id));
}
}}>Delete</button>
</div>
</div>
</Popup>
</Marker>
{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, fillOpacity:0.2 }} />
) : (
<Circle center={[pt.lat, pt.lng]} radius={cov.radius*0.3048} pathOptions={{ color, fillOpacity:0.2 }} />
)
)}
{cov?.kind==='rect' && (()=>{
const clat=Number(pt.lat), clng=Number(pt.lng);
const L=cov.length, W=cov.width; const hdg=(Number(pt.sprinkler_heading_degrees||0))*Math.PI/180;
const Rlat=111320, Rlng=Math.cos(clat*Math.PI/180)*111320;
const hf=L/2*0.3048, wf=W/2*0.3048;
const corners=[{x:hf,y:wf},{x:-hf,y:wf},{x:-hf,y:-wf},{x:hf,y:-wf}].map(({x,y})=>{
const xr=x*Math.cos(hdg)-y*Math.sin(hdg);
const yr=x*Math.sin(hdg)+y*Math.cos(hdg);
const lat=clat + (yr/Rlat);
const lng=clng + (xr/Rlng);
return [lat,lng];
});
const color = pointColor(pt);
return <Polygon positions={corners} pathOptions={{ color, fillOpacity:0.2 }} />;
})()}
</React.Fragment>
);
})}
</MapContainer>
</div>
</div>
</div>
</div>
);
};
export default Watering;
// Inline component to save current sprinkler settings as user equipment
const SaveSprinklerInline = ({ current, onSaved }) => {
const [name, setName] = useState('');
const [saving, setSaving] = useState(false);
const save = async () => {
if (!name.trim()) { toast.error('Enter a name'); return; }
setSaving(true);
try {
// Find Sprinkler category id
const cats = await equipmentAPI.getCategories();
const list = cats.data?.data?.categories || [];
const spr = list.find(c => (c.name||'').toLowerCase() === 'sprinkler');
if (!spr) { toast.error('Sprinkler category not found'); setSaving(false); return; }
const payload = {
categoryId: spr.id,
customName: name.trim(),
// store sprinkler-specific fields
sprinklerMount: current.mount,
sprinklerHeadType: current.type,
sprinklerGpm: current.gpm,
sprinklerThrowFeet: current.throwFeet,
sprinklerDegrees: current.degrees,
sprinklerLengthFeet: current.lengthFeet,
sprinklerWidthFeet: current.widthFeet,
notes: 'Saved from Watering settings',
};
const rs = await equipmentAPI.create(payload);
const eq = rs.data?.data?.equipment;
toast.success('Sprinkler saved');
onSaved && onSaved(eq);
setName('');
} catch(e){
toast.error('Failed to save sprinkler');
} finally { setSaving(false); }
};
return (
<div className="mt-2 p-2 border rounded bg-gray-50 space-y-2">
<div className="text-sm">Save current settings as equipment</div>
<div className="flex gap-2">
<input className="input flex-1" placeholder="Name (e.g., Front Yard Rotor)" value={name} onChange={e=> setName(e.target.value)} />
<button className="btn-secondary" disabled={saving} onClick={save}>{saving? 'Saving...':'Save'}</button>
</div>
</div>
);
};