diff --git a/backend/src/routes/watering.js b/backend/src/routes/watering.js index f765494..ec198af 100644 --- a/backend/src/routes/watering.js +++ b/backend/src/routes/watering.js @@ -102,6 +102,15 @@ router.post('/plans/:id/points', async (req,res,next)=>{ 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 || {}; + let equipment_id = null, equipment_name = null; + if (payload.equipmentId) { + // verify ownership, otherwise ignore silently + const eq = await pool.query('SELECT id, custom_name, manufacturer, model FROM user_equipment WHERE id=$1 AND user_id=$2', [payload.equipmentId, req.user.id]); + if (eq.rows.length) { + equipment_id = eq.rows[0].id; + equipment_name = payload.equipmentName || eq.rows[0].custom_name || `${eq.rows[0].manufacturer||''} ${eq.rows[0].model||''}`.trim() || null; + } + } const point = { sprinkler_head_type: payload.sprinklerHeadType, sprinkler_throw_feet: payload.throwFeet, @@ -113,11 +122,13 @@ 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, sprinkler_heading_degrees) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) RETURNING *`, + sprinkler_gpm, sprinkler_throw_feet, sprinkler_degrees, sprinkler_length_feet, sprinkler_width_feet, coverage_sqft, sprinkler_heading_degrees, + equipment_id, equipment_name) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) 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.headingDegrees||null] + payload.lengthFeet||null, payload.widthFeet||null, coverage, payload.headingDegrees||null, + equipment_id, equipment_name] ); res.status(201).json({ success:true, data:{ point: ins.rows[0] }}); } catch (e) { next(e); } @@ -158,10 +169,12 @@ router.put('/points/:id', async (req, res, next) => { sprinkler_length_feet = COALESCE($9, sprinkler_length_feet), sprinkler_width_feet = COALESCE($10, sprinkler_width_feet), sprinkler_heading_degrees = COALESCE($11, sprinkler_heading_degrees), - coverage_sqft = COALESCE($12, coverage_sqft) - WHERE id=$13 RETURNING *`, + coverage_sqft = COALESCE($12, coverage_sqft), + equipment_id = COALESCE($13, equipment_id), + equipment_name = COALESCE($14, equipment_name) + WHERE id=$15 RETURNING *`, [p.lat, p.lng, p.durationMinutes, p.mountType, p.sprinklerHeadType, p.gpm, - p.throwFeet, p.degrees, p.lengthFeet, p.widthFeet, p.headingDegrees, coverage || null, pointId] + p.throwFeet, p.degrees, p.lengthFeet, p.widthFeet, p.headingDegrees, coverage || null, p.equipmentId || null, p.equipmentName || null, pointId] ); res.json({ success:true, data:{ point: rs.rows[0] }}); } catch (e) { next(e); } @@ -182,4 +195,44 @@ router.delete('/points/:id', async (req,res,next)=>{ } catch (e) { next(e); } }); +// DELETE /api/watering/plans/:id +router.delete('/plans/:id', async (req, res, next) => { + try { + const planId = req.params.id; + const own = await pool.query('SELECT id FROM watering_plans WHERE id=$1 AND user_id=$2', [planId, req.user.id]); + if (own.rows.length === 0) throw new AppError('Plan not found', 404); + await pool.query('DELETE FROM watering_plans WHERE id=$1', [planId]); + res.json({ success: true }); + } catch (e) { next(e); } +}); + module.exports = router; +// Duplicate a watering plan with all points +router.post('/plans/:id/duplicate', async (req, res, next) => { + try { + const planId = req.params.id; + const own = await pool.query('SELECT * FROM watering_plans WHERE id=$1 AND user_id=$2', [planId, req.user.id]); + if (own.rows.length === 0) throw new AppError('Plan not found', 404); + const src = own.rows[0]; + const newName = (req.body && req.body.name) || `${src.name} (Copy ${new Date().toLocaleDateString()})`; + const ins = await pool.query( + `INSERT INTO watering_plans(user_id, property_id, name, notes) + VALUES ($1,$2,$3,$4) RETURNING *`, + [req.user.id, src.property_id, newName, src.notes] + ); + const newPlan = ins.rows[0]; + // Copy points + 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, sprinkler_heading_degrees, equipment_id, equipment_name) + SELECT $1, 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, sprinkler_heading_degrees, equipment_id, equipment_name + FROM watering_plan_points WHERE plan_id=$2`, + [newPlan.id, planId] + ); + res.status(201).json({ success: true, data: { plan: newPlan } }); + } catch (e) { next(e); } +}); diff --git a/database/migrations/V13__watering_point_equipment.sql b/database/migrations/V13__watering_point_equipment.sql new file mode 100644 index 0000000..b55a06b --- /dev/null +++ b/database/migrations/V13__watering_point_equipment.sql @@ -0,0 +1,7 @@ +-- Link watering plan points to saved equipment and store name snapshot +ALTER TABLE watering_plan_points + ADD COLUMN IF NOT EXISTS equipment_id INTEGER REFERENCES user_equipment(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS equipment_name VARCHAR(255); + +SELECT 'Added equipment_id and equipment_name to watering_plan_points' as migration_status; + diff --git a/frontend/src/pages/Watering/Watering.js b/frontend/src/pages/Watering/Watering.js index c60a6c7..eb0c42d 100644 --- a/frontend/src/pages/Watering/Watering.js +++ b/frontend/src/pages/Watering/Watering.js @@ -222,7 +222,9 @@ const Watering = () => { degrees: base.degrees, lengthFeet: base.lengthFeet, widthFeet: base.widthFeet, - headingDegrees: sprinklerForm.headingDegrees || 0 + 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]); @@ -331,7 +333,7 @@ const Watering = () => { {Number(pt.lat).toFixed(5)}, {Number(pt.lng).toFixed(5)} - {(pt.sprinkler_head_type||'-').replace('_',' ')} + {pt.equipment_name || (pt.sprinkler_head_type||'-').replace('_',' ')} {pt.sprinkler_throw_feet ? ` • ${Number(pt.sprinkler_throw_feet)}ft` : ''} {pt.sprinkler_degrees ? ` • ${pt.sprinkler_degrees}°` : ''} @@ -343,6 +345,9 @@ const Watering = () => {
Selected Details
+ {selectedPoint?.equipment_name ? (<> + {selectedPoint.equipment_name} • + ) : null} Type: {editForm.sprinklerHeadType || '-'} • GPM: {editForm.gpm || '-'} • Duration: {editForm.durationMinutes||0}m
{(editForm.sprinklerHeadType==='rotor_impact' || editForm.sprinklerHeadType==='spray_fixed') ? ( @@ -367,6 +372,33 @@ const Watering = () => { const pl = await wateringAPI.getPlans({ property_id: selectedProperty.id }); setPlans(pl.data?.data?.plans || []); setPoints([]); }}>New + {plan && ( + + )} + {plan && ( + + )}
{plan && (
@@ -410,6 +442,18 @@ const Watering = () => { ))} + {(!selectedSprinklerId) && ( + { + // 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)); + }} + /> + )}
Mount
{ + 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() + }); + } + }}> + + {sprinklers.map(s=> ( + + ))} + + {selectedPoint?.equipment_id && ( + + )} +
+ {(editForm.sprinklerHeadType==='rotor_impact' || editForm.sprinklerHeadType==='spray_fixed') ? ( <> @@ -537,7 +610,9 @@ const Watering = () => {
  • #{pt.sequence} - {(pt.sprinkler_head_type||'-').replace('_',' ')} + {pt.equipment_name || (pt.sprinkler_head_type||'-').replace('_',' ')} + {pt.sprinkler_gpm ? (• {Number(pt.sprinkler_gpm)} gpm) : null} + {pt.duration_minutes ? (• {pt.duration_minutes} min) : null}
  • ))} @@ -566,7 +641,7 @@ const Watering = () => { >
    -
    Adjust Sprinkler
    +
    {pt.equipment_name || 'Adjust Sprinkler'}
    {(pt.sprinkler_head_type==='rotor_impact' || pt.sprinkler_head_type==='spray_fixed') && ( <>
    @@ -666,3 +741,51 @@ const Watering = () => { }; 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 ( +
    +
    Save current settings as equipment
    +
    + setName(e.target.value)} /> + +
    +
    + ); +}; diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 1782a4f..b8bb657 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -230,6 +230,8 @@ export const wateringAPI = { getPlans: (params) => apiClient.get('/watering/plans', { params }), updatePlan: (planId, payload) => apiClient.put(`/watering/plans/${planId}`, payload), createPlan: (payload) => apiClient.post('/watering/plans', payload), + deletePlan: (planId) => apiClient.delete(`/watering/plans/${planId}`), + duplicatePlan: (planId, payload) => apiClient.post(`/watering/plans/${planId}/duplicate`, payload), getPlanPoints: (planId) => apiClient.get(`/watering/plans/${planId}/points`), addPlanPoint: (planId, payload) => apiClient.post(`/watering/plans/${planId}/points`, payload), updatePoint: (pointId, payload) => apiClient.put(`/watering/points/${pointId}`, payload),