From 6bf68364ec9c22408865a0aa6337c1ded145eeed Mon Sep 17 00:00:00 2001 From: Jake Kasper Date: Thu, 4 Sep 2025 15:13:57 -0400 Subject: [PATCH] watering 2 --- backend/src/routes/equipment.js | 34 ++++- backend/src/routes/watering.js | 7 +- .../V12__watering_point_heading.sql | 6 + frontend/src/components/Layout/Layout.js | 12 ++ frontend/src/pages/Equipment/Equipment.js | 81 +++++++++++- frontend/src/pages/Watering/Watering.js | 123 ++++++++++++++++-- 6 files changed, 239 insertions(+), 24 deletions(-) create mode 100644 database/migrations/V12__watering_point_heading.sql diff --git a/backend/src/routes/equipment.js b/backend/src/routes/equipment.js index 7646f41..fa8b333 100644 --- a/backend/src/routes/equipment.js +++ b/backend/src/routes/equipment.js @@ -167,6 +167,15 @@ router.get('/', async (req, res, next) => { material: item.material, colorCode: item.color_code, quantityOwned: item.quantity_owned, + // Sprinkler fields + sprinklerMount: item.sprinkler_mount, + sprinklerHeadType: item.sprinkler_head_type, + sprinklerGpm: item.sprinkler_gpm ? parseFloat(item.sprinkler_gpm) : null, + sprinklerThrowFeet: item.sprinkler_throw_feet ? parseFloat(item.sprinkler_throw_feet) : null, + sprinklerDegrees: item.sprinkler_degrees, + sprinklerLengthFeet: item.sprinkler_length_feet ? parseFloat(item.sprinkler_length_feet) : null, + sprinklerWidthFeet: item.sprinkler_width_feet ? parseFloat(item.sprinkler_width_feet) : null, + sprinklerCoverageSqft: item.sprinkler_coverage_sqft ? parseFloat(item.sprinkler_coverage_sqft) : null, // General fields purchaseDate: item.purchase_date, purchasePrice: parseFloat(item.purchase_price) || null, @@ -297,7 +306,16 @@ router.get('/:id', validateParams(idParamSchema), async (req, res, next) => { notes: item.notes, isActive: item.is_active, createdAt: item.created_at, - updatedAt: item.updated_at + updatedAt: item.updated_at, + // Sprinkler fields + sprinklerMount: item.sprinkler_mount, + sprinklerHeadType: item.sprinkler_head_type, + sprinklerGpm: item.sprinkler_gpm ? parseFloat(item.sprinkler_gpm) : null, + sprinklerThrowFeet: item.sprinkler_throw_feet ? parseFloat(item.sprinkler_throw_feet) : null, + sprinklerDegrees: item.sprinkler_degrees, + sprinklerLengthFeet: item.sprinkler_length_feet ? parseFloat(item.sprinkler_length_feet) : null, + sprinklerWidthFeet: item.sprinkler_width_feet ? parseFloat(item.sprinkler_width_feet) : null, + sprinklerCoverageSqft: item.sprinkler_coverage_sqft ? parseFloat(item.sprinkler_coverage_sqft) : null } } }); @@ -405,8 +423,9 @@ router.post('/', async (req, res, next) => { tool_type, working_width_inches, pump_type, max_gpm, max_psi, power_source, orifice_size, spray_angle, flow_rate_gpm, droplet_size, spray_pattern, pressure_range_psi, thread_size, material, color_code, quantity_owned, - purchase_date, purchase_price, notes) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38) + purchase_date, purchase_price, notes, + sprinkler_mount, sprinkler_head_type, sprinkler_gpm, sprinkler_throw_feet, sprinkler_degrees, sprinkler_length_feet, sprinkler_width_feet) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45) RETURNING *`, [ req.user.id, equipmentTypeId, finalCategoryId, customName, manufacturer, model, @@ -416,7 +435,8 @@ router.post('/', async (req, res, next) => { toolType, workingWidthInches, pumpType, maxGpm, maxPsi, powerSource, orificeSize, sprayAngle, flowRateGpm, dropletSize, sprayPattern, pressureRangePsi, threadSize, material, colorCode, quantityOwned, - purchaseDate, purchasePrice, notes + purchaseDate, purchasePrice, notes, + sprinklerMount || null, sprinklerHeadType || null, sprinklerGpm || null, sprinklerThrowFeet || null, sprinklerDegrees || null, sprinklerLengthFeet || null, sprinklerWidthFeet || null ] ); @@ -533,6 +553,7 @@ router.put('/:id', validateParams(idParamSchema), async (req, res, next) => { pump_type = $21, max_gpm = $22, max_psi = $23, power_source = $24, orifice_size = $25, spray_angle = $26, flow_rate_gpm = $27, droplet_size = $28, spray_pattern = $29, pressure_range_psi = $30, thread_size = $31, material = $32, color_code = $33, quantity_owned = $34, purchase_date = $35, purchase_price = $36, notes = $37, is_active = $38, + sprinkler_mount = $40, sprinkler_head_type = $41, sprinkler_gpm = $42, sprinkler_throw_feet = $43, sprinkler_degrees = $44, sprinkler_length_feet = $45, sprinkler_width_feet = $46, updated_at = CURRENT_TIMESTAMP WHERE id = $39 RETURNING *`, @@ -545,7 +566,8 @@ router.put('/:id', validateParams(idParamSchema), async (req, res, next) => { pumpType, maxGpm, maxPsi, powerSource, orificeSize, sprayAngle, flowRateGpm, dropletSize, sprayPattern, pressureRangePsi, threadSize, material, colorCode, quantityOwned, purchaseDate, purchasePrice, notes, isActive !== undefined ? isActive : true, - equipmentId + equipmentId, + sprinklerMount || null, sprinklerHeadType || null, sprinklerGpm || null, sprinklerThrowFeet || null, sprinklerDegrees || null, sprinklerLengthFeet || null, sprinklerWidthFeet || null ] ); @@ -645,4 +667,4 @@ router.delete('/:id', validateParams(idParamSchema), async (req, res, next) => { } }); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/backend/src/routes/watering.js b/backend/src/routes/watering.js index b329a74..1248de3 100644 --- a/backend/src/routes/watering.js +++ b/backend/src/routes/watering.js @@ -91,15 +91,14 @@ router.post('/plans/:id/points', async (req,res,next)=>{ 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 *`, + sprinkler_gpm, sprinkler_throw_feet, sprinkler_degrees, sprinkler_length_feet, sprinkler_width_feet, coverage_sqft, sprinkler_heading_degrees) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) 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] + payload.lengthFeet||null, payload.widthFeet||null, coverage, payload.headingDegrees||null] ); res.status(201).json({ success:true, data:{ point: ins.rows[0] }}); } catch (e) { next(e); } }); module.exports = router; - diff --git a/database/migrations/V12__watering_point_heading.sql b/database/migrations/V12__watering_point_heading.sql new file mode 100644 index 0000000..3deae66 --- /dev/null +++ b/database/migrations/V12__watering_point_heading.sql @@ -0,0 +1,6 @@ +-- Add heading for sprinkler sectors on watering plan points +ALTER TABLE watering_plan_points + ADD COLUMN IF NOT EXISTS sprinkler_heading_degrees INTEGER; + +SELECT 'Added sprinkler_heading_degrees to watering_plan_points' as migration_status; + diff --git a/frontend/src/components/Layout/Layout.js b/frontend/src/components/Layout/Layout.js index 7792a8d..48a7159 100644 --- a/frontend/src/components/Layout/Layout.js +++ b/frontend/src/components/Layout/Layout.js @@ -66,6 +66,12 @@ const Layout = ({ children }) => { icon: CalendarDaysIcon, iconSolid: CalendarIconSolid, }, + { + name: 'Watering', + href: '/watering', + icon: CloudIcon, + iconSolid: CloudIconSolid, + }, { name: 'Mowing', href: '/mowing', @@ -84,6 +90,12 @@ const Layout = ({ children }) => { icon: CloudIcon, iconSolid: CloudIconSolid, }, + { + name: 'Watering', + href: '/watering', + icon: CloudIcon, + iconSolid: CloudIconSolid, + }, ]; const adminNavigation = [ diff --git a/frontend/src/pages/Equipment/Equipment.js b/frontend/src/pages/Equipment/Equipment.js index 5bcf957..dfe1e88 100644 --- a/frontend/src/pages/Equipment/Equipment.js +++ b/frontend/src/pages/Equipment/Equipment.js @@ -246,6 +246,18 @@ const Equipment = () => { {item.boomSections &&

Boom Sections: {item.boomSections}

} ); + case 'sprinkler': + return ( + <> + {item.sprinklerMount &&

Mount: {item.sprinklerMount.replace('_',' ')}

} + {item.sprinklerHeadType &&

Head: {item.sprinklerHeadType.replace('_',' ')}

} + {item.sprinklerGpm &&

GPM: {item.sprinklerGpm}

} + {item.sprinklerThrowFeet &&

Throw: {item.sprinklerThrowFeet} ft

} + {item.sprinklerDegrees &&

Degrees: {item.sprinklerDegrees}°

} + {item.sprinklerLengthFeet &&

Length: {item.sprinklerLengthFeet} ft

} + {item.sprinklerWidthFeet &&

Width: {item.sprinklerWidthFeet} ft

} + + ); case 'pump': return ( <> @@ -281,6 +293,7 @@ const Equipment = () => { 'Mower': 'bg-green-100 text-green-800', 'Spreader': 'bg-orange-100 text-orange-800', 'Sprayer': 'bg-blue-100 text-blue-800', + 'Sprinkler': 'bg-cyan-100 text-cyan-800', 'Nozzle': 'bg-teal-100 text-teal-800', 'Pump': 'bg-purple-100 text-purple-800', 'Aerator': 'bg-yellow-100 text-yellow-800', @@ -529,6 +542,14 @@ const EquipmentFormModal = ({ isEdit, equipment, categories, equipmentTypes, onS material: equipment?.material || '', colorCode: equipment?.colorCode || '', quantityOwned: equipment?.quantityOwned || 1, + // Sprinkler fields + sprinklerMount: equipment?.sprinklerMount || 'above_ground', + sprinklerHeadType: equipment?.sprinklerHeadType || 'rotor_impact', + sprinklerGpm: equipment?.sprinklerGpm || '', + sprinklerThrowFeet: equipment?.sprinklerThrowFeet || '', + sprinklerDegrees: equipment?.sprinklerDegrees || 360, + sprinklerLengthFeet: equipment?.sprinklerLengthFeet || '', + sprinklerWidthFeet: equipment?.sprinklerWidthFeet || '', // General fields purchaseDate: equipment?.purchaseDate || '', purchasePrice: equipment?.purchasePrice || '', @@ -587,6 +608,14 @@ const EquipmentFormModal = ({ isEdit, equipment, categories, equipmentTypes, onS material: formData.material || null, colorCode: formData.colorCode || null, quantityOwned: formData.quantityOwned ? parseInt(formData.quantityOwned) : null, + // Sprinkler fields + sprinklerMount: formData.sprinklerMount || null, + sprinklerHeadType: formData.sprinklerHeadType || null, + sprinklerGpm: formData.sprinklerGpm ? parseFloat(formData.sprinklerGpm) : null, + sprinklerThrowFeet: formData.sprinklerThrowFeet ? parseFloat(formData.sprinklerThrowFeet) : null, + sprinklerDegrees: formData.sprinklerDegrees ? parseInt(formData.sprinklerDegrees) : null, + sprinklerLengthFeet: formData.sprinklerLengthFeet ? parseFloat(formData.sprinklerLengthFeet) : null, + sprinklerWidthFeet: formData.sprinklerWidthFeet ? parseFloat(formData.sprinklerWidthFeet) : null, purchaseDate: formData.purchaseDate || null, purchasePrice: formData.purchasePrice ? parseFloat(formData.purchasePrice) : null, notes: formData.notes || null, @@ -782,6 +811,56 @@ const EquipmentFormModal = ({ isEdit, equipment, categories, equipmentTypes, onS ); + case 'sprinkler': + return ( + <> +
+
+ + +
+
+ + +
+
+ {(formData.sprinklerHeadType === 'rotor_impact' || formData.sprinklerHeadType === 'spray_fixed') && ( +
+
+ + setFormData({ ...formData, sprinklerGpm: e.target.value })} /> +
+
+ + setFormData({ ...formData, sprinklerThrowFeet: e.target.value })} /> +
+
+ + setFormData({ ...formData, sprinklerDegrees: e.target.value })} /> +
+
+ )} + {formData.sprinklerHeadType === 'oscillating_fan' && ( +
+
+ + setFormData({ ...formData, sprinklerLengthFeet: e.target.value })} /> +
+
+ + setFormData({ ...formData, sprinklerWidthFeet: e.target.value })} /> +
+
+ )} + + ); case 'pump': return ( @@ -1491,4 +1570,4 @@ const NozzleConfigurationModal = ({ sprayer, onClose }) => { ); }; -export default Equipment; \ No newline at end of file +export default Equipment; diff --git a/frontend/src/pages/Watering/Watering.js b/frontend/src/pages/Watering/Watering.js index f93fad8..dcc172a 100644 --- a/frontend/src/pages/Watering/Watering.js +++ b/frontend/src/pages/Watering/Watering.js @@ -1,7 +1,7 @@ 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 { propertiesAPI, wateringAPI, equipmentAPI } from '../../services/api'; import toast from 'react-hot-toast'; const SprinklerPlacement = ({ onPlace }) => { @@ -43,14 +43,25 @@ const Watering = () => { const [guideIndex, setGuideIndex] = useState(0); const [currentPos, setCurrentPos] = useState(null); const [watchId, setWatchId] = useState(null); + const [sprinklers, setSprinklers] = useState([]); + const [selectedSprinklerId, setSelectedSprinklerId] = useState(''); useEffect(() => { (async () => { - try { const r = await propertiesAPI.getAll(); setProperties(r.data?.data?.properties||[]); } + 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); setSections(p.sections||[]); } + 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); + } catch(e){ toast.error('Failed to load property'); } }; @@ -64,15 +75,26 @@ const Watering = () => { 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: sprinklerForm.mount, - sprinklerHeadType: sprinklerForm.type, - gpm: sprinklerForm.gpm, - throwFeet: sprinklerForm.throwFeet, - degrees: sprinklerForm.degrees, - lengthFeet: sprinklerForm.lengthFeet, - widthFeet: sprinklerForm.widthFeet + 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 }; const r = await wateringAPI.addPlanPoint(p.id, payload); setPoints(prev => [...prev, r.data?.data?.point]); @@ -82,8 +104,11 @@ const Watering = () => { }; 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]]; } + 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]); @@ -106,6 +131,48 @@ const Watering = () => { 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 (

Watering - Sprinklers

@@ -120,6 +187,26 @@ const Watering = () => {
Sprinkler Settings
+
Choose Saved Sprinkler
+
Mount
setSprinklerForm({...sprinklerForm, throwFeet: parseFloat(e.target.value)})} />
Degrees (0‑360)
setSprinklerForm({...sprinklerForm, degrees: parseInt(e.target.value||'0',10)})} /> +
Heading (0‑359, 0 = East)
+ setSprinklerForm({...sprinklerForm, headingDegrees: (parseInt(e.target.value||'0',10)||0)%360})} /> ) : ( <> @@ -148,6 +237,10 @@ const Watering = () => { setSprinklerForm({...sprinklerForm, widthFeet: parseFloat(e.target.value)})} /> )} +
Target Depth (inches)
+ setTargetInches(parseFloat(e.target.value||'0'))} /> +
Suggested runtime: {suggestMinutes} minutes {sprinklerForm.gpm? '' : '(enter GPM to calculate)'} +
Run Duration (minutes)
setSprinklerForm({...sprinklerForm, durationMinutes: parseInt(e.target.value||'0',10)})} /> @@ -198,7 +291,11 @@ const Watering = () => { {cov?.kind==='circle' && ( - + cov.degrees && cov.degrees < 360 ? ( + + ) : ( + + ) )} {cov?.kind==='rect' && (