watering attempt 1

This commit is contained in:
Jake Kasper
2025-09-04 12:46:56 -05:00
parent e4524432e7
commit 610131e5c2
7 changed files with 404 additions and 0 deletions

View File

@@ -27,6 +27,7 @@ import ApplicationLog from './pages/Applications/ApplicationLog';
import History from './pages/History/History';
import Weather from './pages/Weather/Weather';
import Mowing from './pages/Mowing/Mowing';
import Watering from './pages/Watering/Watering';
import Profile from './pages/Profile/Profile';
// Admin pages
@@ -256,6 +257,16 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/watering"
element={
<ProtectedRoute>
<Layout>
<Watering />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/profile"
element={

View File

@@ -0,0 +1,217 @@
import React, { useEffect, useMemo, useState } from 'react';
import { MapContainer, TileLayer, Polygon, Marker, Circle, Rectangle, useMapEvents } from 'react-leaflet';
import 'leaflet/dist/leaflet.css';
import { propertiesAPI, wateringAPI } 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 [points, setPoints] = useState([]);
const [guiding, setGuiding] = useState(false);
const [guideIndex, setGuideIndex] = useState(0);
const [currentPos, setCurrentPos] = useState(null);
const [watchId, setWatchId] = useState(null);
useEffect(() => { (async () => {
try { const r = await propertiesAPI.getAll(); setProperties(r.data?.data?.properties||[]); }
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); setSections(p.sections||[]); }
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: `${selectedProperty.name} - Sprinklers` }); setPlan(r.data?.data?.plan); return r.data?.data?.plan; }
catch(e){ toast.error('Failed to create plan'); return null; }
};
const onPlace = async (latlng) => {
const p = await ensurePlan(); if (!p) return;
try {
const payload = {
lat: latlng[0], lng: latlng[1], durationMinutes: sprinklerForm.durationMinutes,
mountType: sprinklerForm.mount,
sprinklerHeadType: sprinklerForm.type,
gpm: sprinklerForm.gpm,
throwFeet: sprinklerForm.throwFeet,
degrees: sprinklerForm.degrees,
lengthFeet: sprinklerForm.lengthFeet,
widthFeet: sprinklerForm.widthFeet
};
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 [selectedProperty.latitude, selectedProperty.longitude];
if (sections?.length){ const s=sections[0]; const c=s.polygonData?.coordinates?.[0]?.[0]; if (c) return [c[0], c[1]]; }
return [39.8,-98.6];
}, [selectedProperty, sections]);
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;
};
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">
<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">Sprinkler Settings</div>
<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">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">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">
<div className="font-medium mb-2">Plan Points</div>
<ul className="text-sm space-y-1 max-h-64 overflow-auto">
{points.map(pt => (
<li key={pt.id}>#{pt.sequence}: {Number(pt.lat).toFixed(5)}, {Number(pt.lng).toFixed(5)} {pt.duration_minutes} min</li>
))}
{points.length===0 && <li className="text-gray-500">No points yet</li>}
</ul>
</div>
</div>
<div className="lg:col-span-3">
<div className="card p-0" style={{height:'70vh'}}>
<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">
{!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%'}}>
<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} />}
{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)
});
return (
<React.Fragment key={pt.id}>
<Marker position={[pt.lat, pt.lng]} />
{cov?.kind==='circle' && (
<Circle center={[pt.lat, pt.lng]} radius={cov.radius*0.3048} pathOptions={{ color:'#2563eb', fillOpacity:0.2 }} />
)}
{cov?.kind==='rect' && (
<Rectangle bounds={[[pt.lat - (cov.width/2)*0.00000274, pt.lng - (cov.length/2)*0.0000036], [pt.lat + (cov.width/2)*0.00000274, pt.lng + (cov.length/2)*0.0000036]]} pathOptions={{ color:'#2563eb', fillOpacity:0.2 }} />
)}
</React.Fragment>
);
})}
</MapContainer>
</div>
</div>
</div>
</div>
);
};
export default Watering;

View File

@@ -225,6 +225,14 @@ export const weatherAPI = {
apiClient.get(`/weather/conditions/suitable/${propertyId}`, { params }),
};
// Watering API endpoints
export const wateringAPI = {
getPlans: (params) => apiClient.get('/watering/plans', { params }),
createPlan: (payload) => apiClient.post('/watering/plans', payload),
getPlanPoints: (planId) => apiClient.get(`/watering/plans/${planId}/points`),
addPlanPoint: (planId, payload) => apiClient.post(`/watering/plans/${planId}/points`, payload),
};
// Admin API endpoints
export const adminAPI = {
getDashboard: () => apiClient.get('/admin/dashboard'),