From e7cbaf844f391a342b857ccfd3f1c0e882927a28 Mon Sep 17 00:00:00 2001 From: Jake Kasper Date: Wed, 3 Sep 2025 10:56:17 -0400 Subject: [PATCH] seed stuff --- backend/src/routes/admin.js | 22 ++--- backend/src/routes/applications.js | 26 +++++- backend/src/routes/properties.js | 39 +++++--- backend/src/utils/validation.js | 9 +- ...s_jsonb_seed_products_and_seeding_type.sql | 41 +++++++++ .../Applications/ApplicationExecutionModal.js | 26 ++++++ frontend/src/pages/Products/Products.js | 46 +++++++++- .../src/pages/Properties/PropertyDetail.js | 90 ++++++++++++++++++- 8 files changed, 271 insertions(+), 28 deletions(-) create mode 100644 database/migrations/VXX__grass_types_jsonb_seed_products_and_seeding_type.sql diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 1acd5d5..90b4d58 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -406,7 +406,7 @@ router.get('/products', async (req, res, next) => { // @access Private (Admin) router.post('/products', validateRequest(productSchema), async (req, res, next) => { try { - const { name, brand, categoryId, productType, activeIngredients, description } = req.body; + const { name, brand, categoryId, productType, activeIngredients, description, seedBlend } = req.body; // Check if category exists const categoryCheck = await pool.query( @@ -419,10 +419,10 @@ router.post('/products', validateRequest(productSchema), async (req, res, next) } const result = await pool.query( - `INSERT INTO products (name, brand, category_id, product_type, active_ingredients, description) - VALUES ($1, $2, $3, $4, $5, $6) + `INSERT INTO products (name, brand, category_id, product_type, active_ingredients, description, seed_blend) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`, - [name, brand, categoryId, productType, activeIngredients, description] + [name, brand, categoryId, productType, activeIngredients, description, seedBlend ? JSON.stringify(seedBlend) : null] ); const product = result.rows[0]; @@ -439,7 +439,8 @@ router.post('/products', validateRequest(productSchema), async (req, res, next) productType: product.product_type, activeIngredients: product.active_ingredients, description: product.description, - createdAt: product.created_at + createdAt: product.created_at, + seedBlend: product.seed_blend } } }); @@ -454,7 +455,7 @@ router.post('/products', validateRequest(productSchema), async (req, res, next) router.put('/products/:id', validateParams(idParamSchema), validateRequest(productSchema), async (req, res, next) => { try { const productId = req.params.id; - const { name, brand, categoryId, productType, activeIngredients, description } = req.body; + const { name, brand, categoryId, productType, activeIngredients, description, seedBlend } = req.body; // Check if product exists const productCheck = await pool.query( @@ -479,10 +480,10 @@ router.put('/products/:id', validateParams(idParamSchema), validateRequest(produ const result = await pool.query( `UPDATE products SET name = $1, brand = $2, category_id = $3, product_type = $4, - active_ingredients = $5, description = $6 - WHERE id = $7 + active_ingredients = $5, description = $6, seed_blend = $7 + WHERE id = $8 RETURNING *`, - [name, brand, categoryId, productType, activeIngredients, description, productId] + [name, brand, categoryId, productType, activeIngredients, description, seedBlend ? JSON.stringify(seedBlend) : null, productId] ); const product = result.rows[0]; @@ -498,6 +499,7 @@ router.put('/products/:id', validateParams(idParamSchema), validateRequest(produ categoryId: product.category_id, productType: product.product_type, activeIngredients: product.active_ingredients, + seedBlend: product.seed_blend, description: product.description, createdAt: product.created_at } @@ -1120,4 +1122,4 @@ router.put('/settings', async (req, res, next) => { } }); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/backend/src/routes/applications.js b/backend/src/routes/applications.js index 6a14d33..f67edd5 100644 --- a/backend/src/routes/applications.js +++ b/backend/src/routes/applications.js @@ -989,6 +989,7 @@ router.get('/logs', async (req, res, next) => { averageSpeed: parseFloat(log.average_speed), areaCovered: parseFloat(log.area_covered), notes: log.notes, + seedingType: log.seeding_type, sectionName: log.section_name, sectionArea: parseFloat(log.section_area), propertyName: log.property_name, @@ -1020,7 +1021,8 @@ router.post('/logs', validateRequest(applicationLogSchema), async (req, res, nex averageSpeed, areaCovered, notes, - products + products, + seedingType } = req.body; // Start transaction @@ -1083,6 +1085,8 @@ router.post('/logs', validateRequest(applicationLogSchema), async (req, res, nex // Add products to log console.log('Adding products to log:', products); + // Track seed product names for lawn section update + const seedProductNames = []; for (const product of products) { const { productId, @@ -1109,6 +1113,17 @@ router.post('/logs', validateRequest(applicationLogSchema), async (req, res, nex ); console.log('Product inserted successfully'); + + // Determine if this product is of type 'seed' and capture its name + try { + if (productId) { + const pr = await client.query('SELECT name, product_type FROM products WHERE id=$1', [productId]); + if (pr.rows[0]?.product_type === 'seed') seedProductNames.push(pr.rows[0].name); + } else if (userProductId) { + const upr = await client.query('SELECT custom_name, custom_product_type FROM user_products WHERE id=$1 AND user_id=$2', [userProductId, req.user.id]); + if (upr.rows[0]?.custom_product_type === 'seed') seedProductNames.push(upr.rows[0].custom_name); + } + } catch (e) { console.warn('Seed product detection failed', e.message); } } // If this was from a plan, mark the plan as completed @@ -1119,6 +1134,15 @@ router.post('/logs', validateRequest(applicationLogSchema), async (req, res, nex ); } + // If seed was applied, update the lawn section grass type to reflect seeds + if (seedProductNames.length > 0) { + const blendLabel = seedProductNames.join(' + '); + const suffix = seedingType === 'new_seed' ? ' (New Seed)' : (seedingType === 'overseed' ? ' (Overseed)' : ''); + try { + await client.query('UPDATE lawn_sections SET grass_type=$1, updated_at=CURRENT_TIMESTAMP WHERE id=$2', [blendLabel + suffix, lawnSectionId]); + } catch (e) { console.warn('Failed to update grass_type for section', lawnSectionId, e.message); } + } + await client.query('COMMIT'); res.status(201).json({ diff --git a/backend/src/routes/properties.js b/backend/src/routes/properties.js index ee8fada..5872fd8 100644 --- a/backend/src/routes/properties.js +++ b/backend/src/routes/properties.js @@ -110,7 +110,7 @@ router.get('/:id', validateParams(idParamSchema), async (req, res, next) => { // Get lawn sections const sectionsResult = await pool.query( - 'SELECT * FROM lawn_sections WHERE property_id = $1 ORDER BY name', + 'SELECT *, grass_types FROM lawn_sections WHERE property_id = $1 ORDER BY name', [propertyId] ); @@ -132,6 +132,7 @@ router.get('/:id', validateParams(idParamSchema), async (req, res, next) => { area: parseFloat(section.area), polygonData: section.polygon_data, grassType: section.grass_type, + grassTypes: section.grass_types || null, soilType: section.soil_type, createdAt: section.created_at, updatedAt: section.updated_at @@ -277,7 +278,7 @@ router.delete('/:id', validateParams(idParamSchema), async (req, res, next) => { router.post('/:id/sections', validateParams(idParamSchema), validateRequest(lawnSectionSchema), async (req, res, next) => { try { const propertyId = req.params.id; - const { name, area, polygonData, grassType, soilType } = req.body; + const { name, area, polygonData, grassType, grassTypes, soilType } = req.body; // Check if property exists and belongs to user const propertyResult = await pool.query( @@ -299,10 +300,18 @@ router.post('/:id/sections', validateParams(idParamSchema), validateRequest(lawn } const result = await pool.query( - `INSERT INTO lawn_sections (property_id, name, area, polygon_data, grass_type, soil_type) - VALUES ($1, $2, $3, $4, $5, $6) + `INSERT INTO lawn_sections (property_id, name, area, polygon_data, grass_type, grass_types, soil_type) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`, - [propertyId, name, calculatedArea, JSON.stringify(polygonData), grassType, soilType] + [ + propertyId, + name, + calculatedArea, + JSON.stringify(polygonData), + grassType || (Array.isArray(grassTypes) ? grassTypes.join(', ') : null), + grassTypes ? JSON.stringify(grassTypes) : null, + soilType + ] ); const section = result.rows[0]; @@ -317,6 +326,7 @@ router.post('/:id/sections', validateParams(idParamSchema), validateRequest(lawn area: parseFloat(section.area), polygonData: section.polygon_data, grassType: section.grass_type, + grassTypes: section.grass_types, soilType: section.soil_type, createdAt: section.created_at, updatedAt: section.updated_at @@ -334,7 +344,7 @@ router.post('/:id/sections', validateParams(idParamSchema), validateRequest(lawn router.put('/:propertyId/sections/:sectionId', async (req, res, next) => { try { const { propertyId, sectionId } = req.params; - const { name, area, polygonData, grassType, soilType } = req.body; + const { name, area, polygonData, grassType, grassTypes, soilType } = req.body; // Check if section exists and user owns the property const checkResult = await pool.query( @@ -357,10 +367,18 @@ router.put('/:propertyId/sections/:sectionId', async (req, res, next) => { const result = await pool.query( `UPDATE lawn_sections - SET name = $1, area = $2, polygon_data = $3, grass_type = $4, soil_type = $5, updated_at = CURRENT_TIMESTAMP - WHERE id = $6 + SET name = $1, area = $2, polygon_data = $3, grass_type = $4, grass_types=$5, soil_type = $6, updated_at = CURRENT_TIMESTAMP + WHERE id = $7 RETURNING *`, - [name, calculatedArea, JSON.stringify(polygonData), grassType, soilType, sectionId] + [ + name, + calculatedArea, + JSON.stringify(polygonData), + grassType || (Array.isArray(grassTypes) ? grassTypes.join(', ') : null), + grassTypes ? JSON.stringify(grassTypes) : null, + soilType, + sectionId + ] ); const section = result.rows[0]; @@ -375,6 +393,7 @@ router.put('/:propertyId/sections/:sectionId', async (req, res, next) => { area: parseFloat(section.area), polygonData: section.polygon_data, grassType: section.grass_type, + grassTypes: section.grass_types, soilType: section.soil_type, createdAt: section.created_at, updatedAt: section.updated_at @@ -429,4 +448,4 @@ router.delete('/:propertyId/sections/:sectionId', async (req, res, next) => { } }); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/backend/src/utils/validation.js b/backend/src/utils/validation.js index e2e9fed..3b9e9f7 100644 --- a/backend/src/utils/validation.js +++ b/backend/src/utils/validation.js @@ -63,9 +63,13 @@ const productSchema = Joi.object({ name: Joi.string().max(255).required(), brand: Joi.string().max(100), categoryId: Joi.number().integer().positive().required(), - productType: Joi.string().valid('granular', 'liquid').required(), + productType: Joi.string().valid('granular', 'liquid', 'seed').required(), activeIngredients: Joi.string(), - description: Joi.string() + description: Joi.string(), + seedBlend: Joi.alternatives().try(Joi.array().items(Joi.object({ + cultivar: Joi.string().max(200).required(), + percent: Joi.number().min(0).max(100).required() + })), Joi.object()).optional() }); const productRateSchema = Joi.object({ @@ -169,6 +173,7 @@ const applicationLogSchema = Joi.object({ averageSpeed: Joi.number().positive(), areaCovered: Joi.number().positive(), notes: Joi.string(), + seedingType: Joi.string().valid('overseed','new_seed').allow(null).optional(), products: Joi.array().items(Joi.object({ productId: Joi.number().integer().positive(), userProductId: Joi.number().integer().positive(), diff --git a/database/migrations/VXX__grass_types_jsonb_seed_products_and_seeding_type.sql b/database/migrations/VXX__grass_types_jsonb_seed_products_and_seeding_type.sql new file mode 100644 index 0000000..24b49b2 --- /dev/null +++ b/database/migrations/VXX__grass_types_jsonb_seed_products_and_seeding_type.sql @@ -0,0 +1,41 @@ +-- Migration: add JSONB grass types to lawn_sections, enable seed products, and store seeding type on logs +-- 1) Lawn sections: add grass_types JSONB and backfill from grass_type CSV +ALTER TABLE lawn_sections + ADD COLUMN IF NOT EXISTS grass_types JSONB; + +-- Backfill: split grass_type CSV to JSON array when present +UPDATE lawn_sections +SET grass_types = ( + CASE + WHEN grass_type IS NULL OR trim(grass_type) = '' THEN NULL + ELSE ( + SELECT jsonb_agg(trim(x)) FROM ( + SELECT regexp_split_to_table(grass_type, '\s*,\s*') AS x + ) s WHERE trim(x) <> '' + ) + END +) +WHERE grass_types IS NULL; + +-- 2) Products: allow 'seed' type and add seed_blend JSONB +DO $$ +DECLARE cname text; +BEGIN + SELECT conname INTO cname + FROM pg_constraint + WHERE conrelid = 'products'::regclass AND contype='c' AND conname LIKE '%product_type%'; + IF cname IS NOT NULL THEN + EXECUTE format('ALTER TABLE products DROP CONSTRAINT %I', cname); + END IF; +END $$; + +ALTER TABLE products + ADD CONSTRAINT products_product_type_check CHECK (product_type IN ('granular','liquid','seed')); + +ALTER TABLE products + ADD COLUMN IF NOT EXISTS seed_blend JSONB; + +-- 3) Application logs: store seeding type +ALTER TABLE application_logs + ADD COLUMN IF NOT EXISTS seeding_type VARCHAR(20) CHECK (seeding_type IN ('overseed','new_seed')); + diff --git a/frontend/src/components/Applications/ApplicationExecutionModal.js b/frontend/src/components/Applications/ApplicationExecutionModal.js index 0741fb8..e2846d1 100644 --- a/frontend/src/components/Applications/ApplicationExecutionModal.js +++ b/frontend/src/components/Applications/ApplicationExecutionModal.js @@ -20,6 +20,7 @@ const ApplicationExecutionModal = ({ application, propertyDetails, onClose, onCo const [sections, setSections] = useState([]); const [mapCenter, setMapCenter] = useState(null); const [planDetails, setPlanDetails] = useState(null); + const [seedingType, setSeedingType] = useState('overseed'); // Debug: Log the application data to understand structure useEffect(() => { @@ -114,6 +115,11 @@ const ApplicationExecutionModal = ({ application, propertyDetails, onClose, onCo return 3.0; // 3.0 mph default target }, [application]); + // Determine if this is a seed application based on plan details + const isSeedApplication = useMemo(() => { + return (planDetails?.products || []).some(p => (p.productType || '').toLowerCase() === 'seed'); + }, [planDetails?.products]); + const speedStatus = useMemo(() => { if (!targetSpeed || !currentSpeed) return 'normal'; @@ -301,6 +307,9 @@ const ApplicationExecutionModal = ({ application, propertyDetails, onClose, onCo notes: `Application completed via mobile tracking. Duration: ${Math.round(duration/60)} minutes, Distance: ${(totalDistance * 3.28084).toFixed(0)} ft, Points: ${gpsTrack.length}`, products: validProducts }; + if (isSeedApplication) { + logData.seedingType = seedingType; + } // Debug: log the exact data being sent console.log('Sending log data:', JSON.stringify(logData, null, 2)); @@ -453,6 +462,23 @@ const ApplicationExecutionModal = ({ application, propertyDetails, onClose, onCo + {/* Seeding options for seed products */} + {isSeedApplication && ( +
+

Seeding Details

+
+ + +
+
+ )} + {/* Tracking Stats */} {isTracking && (
diff --git a/frontend/src/pages/Products/Products.js b/frontend/src/pages/Products/Products.js index 20e7501..a072b97 100644 --- a/frontend/src/pages/Products/Products.js +++ b/frontend/src/pages/Products/Products.js @@ -458,6 +458,7 @@ const CreateProductModal = ({ onSubmit, onCancel, sharedProducts, categories }) }); const [spreaderSettings, setSpreaderSettings] = useState([]); + const [seedBlend, setSeedBlend] = useState([]); // [{cultivar:'', percent:0}] const [availableSpreaders, setAvailableSpreaders] = useState([]); const [loadingSpreaders, setLoadingSpreaders] = useState(false); const [newSpreaderSetting, setNewSpreaderSetting] = useState({ @@ -543,6 +544,11 @@ const CreateProductModal = ({ onSubmit, onCancel, sharedProducts, categories }) spreaderSettings: formData.productType === 'granular' ? spreaderSettings : [] }; + // If this is a seed product and user entered a blend, pack it into activeIngredients as JSON + if (formData.productType === 'seed' && Array.isArray(seedBlend) && seedBlend.length > 0) { + submitData.activeIngredients = JSON.stringify({ seedBlend }); + } + onSubmit(submitData); }; @@ -618,6 +624,10 @@ const CreateProductModal = ({ onSubmit, onCancel, sharedProducts, categories })
+ {formData.productType === 'seed' && ( + + )} +
{ + const [rows, setRows] = React.useState(value || []); + React.useEffect(()=>{ onChange && onChange(rows); }, [rows]); + const addRow = () => setRows([...(rows||[]), { cultivar: '', percent: '' }]); + const updateRow = (i, field, v) => setRows((rows||[]).map((r,idx)=> idx===i? { ...r, [field]: v } : r)); + const removeRow = (i) => setRows((rows||[]).filter((_,idx)=> idx!==i)); + const total = (rows||[]).reduce((s,r)=> s + (parseFloat(r.percent)||0), 0); + return ( +
+
+ + +
+ {(rows||[]).length === 0 ? ( +
No cultivars added
+ ) : ( +
+ {(rows||[]).map((row, i) => ( +
+ updateRow(i,'cultivar', e.target.value)} /> +
+ updateRow(i,'percent', e.target.value)} /> +
+ +
+ ))} +
Total: {total.toFixed(1)}%
+
+ )} +
+ ); +}; + +export default Products; diff --git a/frontend/src/pages/Properties/PropertyDetail.js b/frontend/src/pages/Properties/PropertyDetail.js index 2a63962..5800dcc 100644 --- a/frontend/src/pages/Properties/PropertyDetail.js +++ b/frontend/src/pages/Properties/PropertyDetail.js @@ -19,6 +19,52 @@ import LoadingSpinner from '../../components/UI/LoadingSpinner'; import toast from 'react-hot-toast'; import 'leaflet/dist/leaflet.css'; +// Simple tag input and suggestion components for grass types +const COOL_SEASON_GRASSES = [ + 'Turf Type Tall Fescue', + 'Kentucky Bluegrass', + 'Perennial Ryegrass', + 'Fine Fescue', + 'Creeping Red Fescue', + 'Chewings Fescue', + 'Hard Fescue', + 'Annual Ryegrass' +]; + +const TagInput = ({ value = [], onChange }) => { + const [input, setInput] = useState(''); + const add = (val) => { const v = val.trim(); if (!v) return; if (!value.includes(v)) onChange([...(value||[]), v]); setInput(''); }; + return ( +
+
+ {(value||[]).map((t) => ( + + {t} + + + ))} +
+ setInput(e.target.value)} + onKeyDown={(e)=> { if (e.key==='Enter'){ e.preventDefault(); add(input); } }} + /> +
+ ); +}; + +const SuggestionChips = ({ onPick }) => ( +
+ {COOL_SEASON_GRASSES.map(g => ( + + ))} +
+); + // Fix for default markers delete Icon.Default.prototype._getIconUrl; Icon.Default.mergeOptions({ @@ -302,8 +348,10 @@ const PropertyDetail = () => { const [showNameModal, setShowNameModal] = useState(false); const [pendingSection, setPendingSection] = useState(null); const [sectionName, setSectionName] = useState(''); + const [sectionGrassTypes, setSectionGrassTypes] = useState([]); const [editingSection, setEditingSection] = useState(null); const [showEditModal, setShowEditModal] = useState(false); + const [editGrassTypes, setEditGrassTypes] = useState([]); // Recent history state for this property const [completedApplications, setCompletedApplications] = useState([]); @@ -376,7 +424,9 @@ const PropertyDetail = () => { name: section.name, coordinates: section.polygonData?.coordinates?.[0] || [], color: section.polygonData?.color || SECTION_COLORS[0], - area: section.area + area: section.area, + grassType: section.grassType || '', + grassTypes: section.grassTypes || null })); setLawnSections(convertedSections); } @@ -417,7 +467,8 @@ const PropertyDetail = () => { coordinates: [pendingSection.coordinates], color: pendingSection.color }, - grassType: null, + grassType: sectionGrassTypes.join(', '), + grassTypes: sectionGrassTypes, soilType: null }; @@ -438,6 +489,7 @@ const PropertyDetail = () => { // Reset and cycle color setSectionName(''); + setSectionGrassTypes([]); setPendingSection(null); setShowNameModal(false); const nextIndex = (SECTION_COLORS.findIndex(c => c.value === currentColor.value) + 1) % SECTION_COLORS.length; @@ -466,6 +518,8 @@ const PropertyDetail = () => { setSectionName(section.name); setCurrentColor(section.color); setShowEditModal(true); + const existing = (section.grassType || '').split(',').map(s=>s.trim()).filter(Boolean); + setEditGrassTypes(existing); }; const saveEditedSection = async () => { @@ -482,7 +536,8 @@ const PropertyDetail = () => { coordinates: [editingSection.coordinates], color: currentColor }, - grassType: null, + grassType: editGrassTypes.join(', '), + grassTypes: editGrassTypes, soilType: null }; @@ -501,6 +556,7 @@ const PropertyDetail = () => { setSectionName(''); setEditingSection(null); setShowEditModal(false); + setEditGrassTypes([]); } catch (error) { console.error('Failed to update section:', error); toast.error('Failed to update section. Please try again.'); @@ -516,7 +572,8 @@ const PropertyDetail = () => { coordinates: [updatedSection.coordinates], color: updatedSection.color }, - grassType: null, + grassType: (updatedSection.grassType || ''), + grassTypes: (updatedSection.grassTypes || null), soilType: null }; @@ -767,6 +824,13 @@ const PropertyDetail = () => {

{section.name}

{section.area.toLocaleString()} sq ft

+ {(section.grassTypes && section.grassTypes.length > 0) || section.grassType ? ( +
+ {((section.grassTypes && section.grassTypes.length > 0) ? section.grassTypes : (section.grassType||'').split(',').map(s=>s.trim()).filter(Boolean)).map((g, idx)=>( + {g} + ))} +
+ ) : null}
@@ -901,6 +965,14 @@ const PropertyDetail = () => { /> {pendingSection?.area.toLocaleString()} sq ft
+
+ + + setSectionGrassTypes(prev=> prev.includes(v)? prev : [...prev, v])} /> +
+
+ + + setEditGrassTypes(prev=> prev.includes(v)? prev : [...prev, v])} /> +
{ setShowEditModal(false); setEditingSection(null); setSectionName(''); + setEditGrassTypes([]); }} className="btn-secondary flex-1" >