792 lines
43 KiB
JavaScript
792 lines
43 KiB
JavaScript
import React, { useEffect, useMemo, useState } from 'react';
|
||
import { MapContainer, TileLayer, Polygon, Marker, Circle, Rectangle, Popup, useMapEvents, useMap } 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 [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: 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;
|
||
};
|
||
|
||
// 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">
|
||
{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.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,
|
||
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">In‑Ground</option>
|
||
<option value="above_ground">Above‑Ground</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 (0‑360)</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 (0‑359, 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);
|
||
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 && 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 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>
|
||
) : (
|
||
<>
|
||
<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(points.length-1, i+1))} disabled={guideIndex>=points.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} />}
|
||
{/* 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.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>
|
||
)}
|
||
{points.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); 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>
|
||
);
|
||
};
|