watering attempt 1
This commit is contained in:
@@ -19,6 +19,7 @@ const weatherRoutes = require('./routes/weather');
|
||||
const weatherPublicRoutes = require('./routes/weatherPublic');
|
||||
const adminRoutes = require('./routes/admin');
|
||||
const mowingRoutes = require('./routes/mowing');
|
||||
const wateringRoutes = require('./routes/watering');
|
||||
|
||||
const { errorHandler } = require('./middleware/errorHandler');
|
||||
const { authenticateToken } = require('./middleware/auth');
|
||||
@@ -106,6 +107,7 @@ app.use('/api/nozzles', authenticateToken, nozzleRoutes);
|
||||
app.use('/api/products', authenticateToken, productRoutes);
|
||||
app.use('/api/applications', authenticateToken, applicationRoutes);
|
||||
app.use('/api/mowing', authenticateToken, mowingRoutes);
|
||||
app.use('/api/watering', authenticateToken, wateringRoutes);
|
||||
app.use('/api/spreader-settings', authenticateToken, spreaderSettingsRoutes);
|
||||
app.use('/api/product-spreader-settings', authenticateToken, productSpreaderSettingsRoutes);
|
||||
app.use('/api/weather', authenticateToken, weatherRoutes);
|
||||
|
||||
105
backend/src/routes/watering.js
Normal file
105
backend/src/routes/watering.js
Normal file
@@ -0,0 +1,105 @@
|
||||
const express = require('express');
|
||||
const pool = require('../config/database');
|
||||
const { AppError } = require('../middleware/errorHandler');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Compute coverage area in sqft (server-side helper)
|
||||
const computeCoverageSqft = (point) => {
|
||||
const {
|
||||
sprinkler_head_type,
|
||||
sprinkler_throw_feet,
|
||||
sprinkler_degrees,
|
||||
sprinkler_length_feet,
|
||||
sprinkler_width_feet
|
||||
} = point;
|
||||
if (sprinkler_head_type === 'rotor_impact' || sprinkler_head_type === 'spray_fixed') {
|
||||
const r = Number(sprinkler_throw_feet || 0);
|
||||
const deg = Math.min(360, Math.max(0, Number(sprinkler_degrees || 360)));
|
||||
const area = Math.PI * r * r * (deg / 360);
|
||||
return Math.round(area * 100) / 100;
|
||||
}
|
||||
if (sprinkler_head_type === 'oscillating_fan') {
|
||||
const L = Number(sprinkler_length_feet || 0);
|
||||
const W = Number(sprinkler_width_feet || 0);
|
||||
const area = L * W;
|
||||
return Math.round(area * 100) / 100;
|
||||
}
|
||||
if (sprinkler_head_type === 'drip') {
|
||||
return 0; // coverage represented differently; skip for now
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
// GET /api/watering/plans?property_id=...
|
||||
router.get('/plans', async (req, res, next) => {
|
||||
try {
|
||||
const { property_id } = req.query;
|
||||
const rows = await pool.query(
|
||||
`SELECT * FROM watering_plans WHERE user_id=$1 ${property_id? 'AND property_id=$2':''} ORDER BY created_at DESC`,
|
||||
property_id? [req.user.id, property_id] : [req.user.id]
|
||||
);
|
||||
res.json({ success:true, data:{ plans: rows.rows }});
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// POST /api/watering/plans
|
||||
router.post('/plans', async (req, res, next) => {
|
||||
try {
|
||||
const { propertyId, name, notes } = req.body;
|
||||
if (!propertyId || !name) throw new AppError('propertyId and name required', 400);
|
||||
// Verify property ownership
|
||||
const pr = await pool.query('SELECT id FROM properties WHERE id=$1 AND user_id=$2', [propertyId, req.user.id]);
|
||||
if (pr.rows.length === 0) throw new AppError('Property not found', 404);
|
||||
|
||||
const ins = await pool.query(
|
||||
`INSERT INTO watering_plans(user_id, property_id, name, notes) VALUES ($1,$2,$3,$4) RETURNING *`,
|
||||
[req.user.id, propertyId, name, notes||null]
|
||||
);
|
||||
res.status(201).json({ success:true, data:{ plan: ins.rows[0] }});
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// GET /api/watering/plans/:id/points
|
||||
router.get('/plans/:id/points', async (req, res, next) => {
|
||||
try {
|
||||
const planId = req.params.id;
|
||||
const ch = await pool.query('SELECT id FROM watering_plans WHERE id=$1 AND user_id=$2',[planId, req.user.id]);
|
||||
if (ch.rows.length===0) throw new AppError('Plan not found',404);
|
||||
const rs = await pool.query('SELECT * FROM watering_plan_points WHERE plan_id=$1 ORDER BY sequence', [planId]);
|
||||
res.json({ success:true, data:{ points: rs.rows }});
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// POST /api/watering/plans/:id/points
|
||||
router.post('/plans/:id/points', async (req,res,next)=>{
|
||||
try {
|
||||
const planId = req.params.id;
|
||||
const ch = await pool.query('SELECT id, property_id FROM watering_plans WHERE id=$1 AND user_id=$2',[planId, req.user.id]);
|
||||
if (ch.rows.length===0) throw new AppError('Plan not found',404);
|
||||
const seqrs = await pool.query('SELECT COALESCE(MAX(sequence),0)+1 as next FROM watering_plan_points WHERE plan_id=$1',[planId]);
|
||||
const sequence = seqrs.rows[0].next;
|
||||
const payload = req.body || {};
|
||||
const point = {
|
||||
sprinkler_head_type: payload.sprinklerHeadType,
|
||||
sprinkler_throw_feet: payload.throwFeet,
|
||||
sprinkler_degrees: payload.degrees,
|
||||
sprinkler_length_feet: payload.lengthFeet,
|
||||
sprinkler_width_feet: payload.widthFeet
|
||||
};
|
||||
const coverage = computeCoverageSqft(point);
|
||||
const ins = await pool.query(
|
||||
`INSERT INTO watering_plan_points
|
||||
(plan_id, sequence, lat, lng, duration_minutes, sprinkler_mount, sprinkler_head_type,
|
||||
sprinkler_gpm, sprinkler_throw_feet, sprinkler_degrees, sprinkler_length_feet, sprinkler_width_feet, coverage_sqft)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING *`,
|
||||
[planId, sequence, payload.lat, payload.lng, payload.durationMinutes||0, payload.mountType||null,
|
||||
payload.sprinklerHeadType||null, payload.gpm||null, payload.throwFeet||null, payload.degrees||null,
|
||||
payload.lengthFeet||null, payload.widthFeet||null, coverage]
|
||||
);
|
||||
res.status(201).json({ success:true, data:{ point: ins.rows[0] }});
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
27
database/migrations/V10__sprinkler_equipment.sql
Normal file
27
database/migrations/V10__sprinkler_equipment.sql
Normal file
@@ -0,0 +1,27 @@
|
||||
-- Add sprinkler-specific fields to user_equipment
|
||||
ALTER TABLE user_equipment
|
||||
ADD COLUMN IF NOT EXISTS sprinkler_mount VARCHAR(20) CHECK (sprinkler_mount IN ('in_ground','above_ground')),
|
||||
ADD COLUMN IF NOT EXISTS sprinkler_head_type VARCHAR(30) CHECK (sprinkler_head_type IN ('rotor_impact','oscillating_fan','spray_fixed','drip')),
|
||||
ADD COLUMN IF NOT EXISTS sprinkler_gpm DECIMAL(8,2),
|
||||
ADD COLUMN IF NOT EXISTS sprinkler_throw_feet DECIMAL(8,2),
|
||||
ADD COLUMN IF NOT EXISTS sprinkler_degrees INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS sprinkler_length_feet DECIMAL(8,2),
|
||||
ADD COLUMN IF NOT EXISTS sprinkler_width_feet DECIMAL(8,2),
|
||||
ADD COLUMN IF NOT EXISTS sprinkler_coverage_sqft DECIMAL(10,2);
|
||||
|
||||
-- Ensure a Sprinkler category and type exist
|
||||
DO $$
|
||||
DECLARE cid INT; tid INT;
|
||||
BEGIN
|
||||
SELECT id INTO cid FROM equipment_categories WHERE name ILIKE 'Sprinkler' LIMIT 1;
|
||||
IF cid IS NULL THEN
|
||||
INSERT INTO equipment_categories(name, description) VALUES ('Sprinkler','Watering sprinklers') RETURNING id INTO cid;
|
||||
END IF;
|
||||
SELECT id INTO tid FROM equipment_types WHERE name ILIKE 'Sprinkler' LIMIT 1;
|
||||
IF tid IS NULL THEN
|
||||
INSERT INTO equipment_types(name, category_id) VALUES ('Sprinkler', cid);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
SELECT 'Sprinkler equipment fields added' as migration_status;
|
||||
|
||||
34
database/migrations/V11__watering_plans.sql
Normal file
34
database/migrations/V11__watering_plans.sql
Normal file
@@ -0,0 +1,34 @@
|
||||
-- Watering plans for guiding sprinkler placement and timing
|
||||
CREATE TABLE IF NOT EXISTS watering_plans (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
property_id INTEGER REFERENCES properties(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS watering_plan_points (
|
||||
id SERIAL PRIMARY KEY,
|
||||
plan_id INTEGER REFERENCES watering_plans(id) ON DELETE CASCADE,
|
||||
sequence INTEGER NOT NULL,
|
||||
lat DECIMAL(10,8) NOT NULL,
|
||||
lng DECIMAL(11,8) NOT NULL,
|
||||
duration_minutes INTEGER DEFAULT 0,
|
||||
sprinkler_mount VARCHAR(20) CHECK (sprinkler_mount IN ('in_ground','above_ground')),
|
||||
sprinkler_head_type VARCHAR(30) CHECK (sprinkler_head_type IN ('rotor_impact','oscillating_fan','spray_fixed','drip')),
|
||||
sprinkler_gpm DECIMAL(8,2),
|
||||
sprinkler_throw_feet DECIMAL(8,2),
|
||||
sprinkler_degrees INTEGER,
|
||||
sprinkler_length_feet DECIMAL(8,2),
|
||||
sprinkler_width_feet DECIMAL(8,2),
|
||||
coverage_sqft DECIMAL(10,2),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_watering_plans_user ON watering_plans(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_watering_points_plan ON watering_plan_points(plan_id);
|
||||
|
||||
SELECT 'Watering plans tables created' as migration_status;
|
||||
|
||||
@@ -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={
|
||||
|
||||
217
frontend/src/pages/Watering/Watering.js
Normal file
217
frontend/src/pages/Watering/Watering.js
Normal 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">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">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;
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user