From b7ceed70a58bcc7ea1311c3f0f561ade3e8fe2a4 Mon Sep 17 00:00:00 2001 From: Jake Kasper Date: Fri, 22 Aug 2025 09:17:22 -0400 Subject: [PATCH] equipment stuff --- backend/src/app.js | 2 + backend/src/routes/equipment.js | 567 ++++++----- backend/src/routes/nozzles.js | 503 ++++++++++ database/init.sql | 173 +++- .../comprehensive_equipment_upgrade.sql | 200 ++++ frontend/src/pages/Equipment/Equipment.js | 933 +++++++++++++++++- frontend/src/services/api.js | 19 +- 7 files changed, 2137 insertions(+), 260 deletions(-) create mode 100644 backend/src/routes/nozzles.js create mode 100644 database/migrations/comprehensive_equipment_upgrade.sql diff --git a/backend/src/app.js b/backend/src/app.js index 8fe5c75..b454727 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -10,6 +10,7 @@ const authRoutes = require('./routes/auth'); const userRoutes = require('./routes/users'); const propertyRoutes = require('./routes/properties'); const equipmentRoutes = require('./routes/equipment'); +const nozzleRoutes = require('./routes/nozzles'); const productRoutes = require('./routes/products'); const applicationRoutes = require('./routes/applications'); const weatherRoutes = require('./routes/weather'); @@ -82,6 +83,7 @@ app.use('/api/auth', authLimiter, authRoutes); app.use('/api/users', authenticateToken, userRoutes); app.use('/api/properties', authenticateToken, propertyRoutes); app.use('/api/equipment', authenticateToken, equipmentRoutes); +app.use('/api/nozzles', authenticateToken, nozzleRoutes); app.use('/api/products', authenticateToken, productRoutes); app.use('/api/applications', authenticateToken, applicationRoutes); app.use('/api/weather', authenticateToken, weatherRoutes); diff --git a/backend/src/routes/equipment.js b/backend/src/routes/equipment.js index 2865b9c..7bbc171 100644 --- a/backend/src/routes/equipment.js +++ b/backend/src/routes/equipment.js @@ -1,28 +1,66 @@ const express = require('express'); const pool = require('../config/database'); const { validateRequest, validateParams } = require('../utils/validation'); -const { equipmentSchema, idParamSchema } = require('../utils/validation'); +const { idParamSchema } = require('../utils/validation'); const { AppError } = require('../middleware/errorHandler'); const router = express.Router(); +// @route GET /api/equipment/categories +// @desc Get all equipment categories +// @access Private +router.get('/categories', async (req, res, next) => { + try { + const result = await pool.query( + 'SELECT * FROM equipment_categories ORDER BY name' + ); + + res.json({ + success: true, + data: { + categories: result.rows + } + }); + } catch (error) { + next(error); + } +}); + // @route GET /api/equipment/types // @desc Get all equipment types // @access Private router.get('/types', async (req, res, next) => { try { - const result = await pool.query( - 'SELECT * FROM equipment_types ORDER BY category, name' - ); + const { category_id } = req.query; + + let query = ` + SELECT et.*, ec.name as category_name, ec.description as category_description + FROM equipment_types et + JOIN equipment_categories ec ON et.category_id = ec.id + `; + const queryParams = []; + + if (category_id) { + query += ' WHERE et.category_id = $1'; + queryParams.push(category_id); + } + + query += ' ORDER BY ec.name, et.name'; + + const result = await pool.query(query, queryParams); const equipmentByCategory = result.rows.reduce((acc, equipment) => { - if (!acc[equipment.category]) { - acc[equipment.category] = []; + const categoryName = equipment.category_name; + if (!acc[categoryName]) { + acc[categoryName] = []; } - acc[equipment.category].push({ + acc[categoryName].push({ id: equipment.id, name: equipment.name, - category: equipment.category, + manufacturer: equipment.manufacturer, + model: equipment.model, + categoryId: equipment.category_id, + categoryName: equipment.category_name, createdAt: equipment.created_at }); return acc; @@ -31,7 +69,16 @@ router.get('/types', async (req, res, next) => { res.json({ success: true, data: { - equipmentTypes: result.rows, + equipmentTypes: result.rows.map(et => ({ + id: et.id, + name: et.name, + manufacturer: et.manufacturer, + model: et.model, + categoryId: et.category_id, + categoryName: et.category_name, + categoryDescription: et.category_description, + createdAt: et.created_at + })), equipmentByCategory } }); @@ -45,14 +92,31 @@ router.get('/types', async (req, res, next) => { // @access Private router.get('/', async (req, res, next) => { try { - const result = await pool.query( - `SELECT ue.*, et.name as type_name, et.category - FROM user_equipment ue - JOIN equipment_types et ON ue.equipment_type_id = et.id - WHERE ue.user_id = $1 - ORDER BY et.category, et.name`, - [req.user.id] - ); + const { category_id, is_active } = req.query; + + let query = ` + SELECT ue.*, et.name as type_name, et.manufacturer as type_manufacturer, et.model as type_model, + ec.name as category_name + FROM user_equipment ue + LEFT JOIN equipment_types et ON ue.equipment_type_id = et.id + LEFT JOIN equipment_categories ec ON ue.category_id = ec.id + WHERE ue.user_id = $1 + `; + const queryParams = [req.user.id]; + + if (category_id) { + queryParams.push(category_id); + query += ` AND ue.category_id = $${queryParams.length}`; + } + + if (is_active !== undefined) { + queryParams.push(is_active === 'true'); + query += ` AND ue.is_active = $${queryParams.length}`; + } + + query += ' ORDER BY ec.name, COALESCE(ue.custom_name, et.name)'; + + const result = await pool.query(query, queryParams); res.json({ success: true, @@ -60,14 +124,43 @@ router.get('/', async (req, res, next) => { equipment: result.rows.map(item => ({ id: item.id, equipmentTypeId: item.equipment_type_id, + categoryId: item.category_id, typeName: item.type_name, - category: item.category, + typeManufacturer: item.type_manufacturer, + typeModel: item.type_model, + categoryName: item.category_name, customName: item.custom_name, - tankSize: parseFloat(item.tank_size), - pumpGpm: parseFloat(item.pump_gpm), - nozzleGpm: parseFloat(item.nozzle_gpm), - nozzleCount: item.nozzle_count, - spreaderWidth: parseFloat(item.spreader_width), + manufacturer: item.manufacturer, + model: item.model, + // Spreader fields + capacityLbs: parseFloat(item.capacity_lbs) || null, + spreaderType: item.spreader_type, + spreadWidth: parseFloat(item.spread_width) || null, + // Sprayer fields + tankSizeGallons: parseFloat(item.tank_size_gallons) || null, + sprayerType: item.sprayer_type, + sprayWidthFeet: parseFloat(item.spray_width_feet) || null, + pumpGpm: parseFloat(item.pump_gpm) || null, + pumpPsi: parseFloat(item.pump_psi) || null, + boomSections: item.boom_sections, + // Mower fields + mowerStyle: item.mower_style, + cuttingWidthInches: parseFloat(item.cutting_width_inches) || null, + engineHp: parseFloat(item.engine_hp) || null, + fuelType: item.fuel_type, + // Tool fields + toolType: item.tool_type, + workingWidthInches: parseFloat(item.working_width_inches) || null, + // Pump fields + pumpType: item.pump_type, + maxGpm: parseFloat(item.max_gpm) || null, + maxPsi: parseFloat(item.max_psi) || null, + powerSource: item.power_source, + // General fields + purchaseDate: item.purchase_date, + purchasePrice: parseFloat(item.purchase_price) || null, + notes: item.notes, + isActive: item.is_active, createdAt: item.created_at, updatedAt: item.updated_at })) @@ -86,9 +179,11 @@ router.get('/:id', validateParams(idParamSchema), async (req, res, next) => { const equipmentId = req.params.id; const result = await pool.query( - `SELECT ue.*, et.name as type_name, et.category + `SELECT ue.*, et.name as type_name, et.manufacturer as type_manufacturer, et.model as type_model, + ec.name as category_name FROM user_equipment ue - JOIN equipment_types et ON ue.equipment_type_id = et.id + LEFT JOIN equipment_types et ON ue.equipment_type_id = et.id + LEFT JOIN equipment_categories ec ON ue.category_id = ec.id WHERE ue.id = $1 AND ue.user_id = $2`, [equipmentId, req.user.id] ); @@ -105,14 +200,43 @@ router.get('/:id', validateParams(idParamSchema), async (req, res, next) => { equipment: { id: item.id, equipmentTypeId: item.equipment_type_id, + categoryId: item.category_id, typeName: item.type_name, - category: item.category, + typeManufacturer: item.type_manufacturer, + typeModel: item.type_model, + categoryName: item.category_name, customName: item.custom_name, - tankSize: parseFloat(item.tank_size), - pumpGpm: parseFloat(item.pump_gpm), - nozzleGpm: parseFloat(item.nozzle_gpm), - nozzleCount: item.nozzle_count, - spreaderWidth: parseFloat(item.spreader_width), + manufacturer: item.manufacturer, + model: item.model, + // Spreader fields + capacityLbs: parseFloat(item.capacity_lbs) || null, + spreaderType: item.spreader_type, + spreadWidth: parseFloat(item.spread_width) || null, + // Sprayer fields + tankSizeGallons: parseFloat(item.tank_size_gallons) || null, + sprayerType: item.sprayer_type, + sprayWidthFeet: parseFloat(item.spray_width_feet) || null, + pumpGpm: parseFloat(item.pump_gpm) || null, + pumpPsi: parseFloat(item.pump_psi) || null, + boomSections: item.boom_sections, + // Mower fields + mowerStyle: item.mower_style, + cuttingWidthInches: parseFloat(item.cutting_width_inches) || null, + engineHp: parseFloat(item.engine_hp) || null, + fuelType: item.fuel_type, + // Tool fields + toolType: item.tool_type, + workingWidthInches: parseFloat(item.working_width_inches) || null, + // Pump fields + pumpType: item.pump_type, + maxGpm: parseFloat(item.max_gpm) || null, + maxPsi: parseFloat(item.max_psi) || null, + powerSource: item.power_source, + // General fields + purchaseDate: item.purchase_date, + purchasePrice: parseFloat(item.purchase_price) || null, + notes: item.notes, + isActive: item.is_active, createdAt: item.created_at, updatedAt: item.updated_at } @@ -126,47 +250,99 @@ router.get('/:id', validateParams(idParamSchema), async (req, res, next) => { // @route POST /api/equipment // @desc Add new equipment // @access Private -router.post('/', validateRequest(equipmentSchema), async (req, res, next) => { +router.post('/', async (req, res, next) => { try { - const { - equipmentTypeId, - customName, - tankSize, - pumpGpm, - nozzleGpm, - nozzleCount, - spreaderWidth + const { + equipmentTypeId, + categoryId, + customName, + manufacturer, + model, + // Spreader specific fields + capacityLbs, + spreaderType, + spreadWidth, + // Sprayer specific fields + tankSizeGallons, + sprayerType, + sprayWidthFeet, + pumpGpm, + pumpPsi, + boomSections, + // Mower specific fields + mowerStyle, + cuttingWidthInches, + engineHp, + fuelType, + // Tool specific fields + toolType, + workingWidthInches, + // Pump specific fields + pumpType, + maxGpm, + maxPsi, + powerSource, + // General fields + purchaseDate, + purchasePrice, + notes } = req.body; - // Verify equipment type exists - const typeCheck = await pool.query( - 'SELECT id, name, category FROM equipment_types WHERE id = $1', - [equipmentTypeId] - ); - - if (typeCheck.rows.length === 0) { - throw new AppError('Equipment type not found', 404); + // Validate required fields + if (!categoryId && !equipmentTypeId) { + throw new AppError('Either category or equipment type is required', 400); } - const equipmentType = typeCheck.rows[0]; + // If equipmentTypeId is provided, verify it exists and get category + let finalCategoryId = categoryId; + let equipmentType = null; + + if (equipmentTypeId) { + const typeCheck = await pool.query( + 'SELECT id, name, category_id, manufacturer, model FROM equipment_types WHERE id = $1', + [equipmentTypeId] + ); - // Validate required fields based on equipment type - if (equipmentType.category === 'sprayer') { - if (!tankSize || !pumpGpm || !nozzleGpm || !nozzleCount) { - throw new AppError('Tank size, pump GPM, nozzle GPM, and nozzle count are required for sprayers', 400); + if (typeCheck.rows.length === 0) { + throw new AppError('Equipment type not found', 404); } + + equipmentType = typeCheck.rows[0]; + finalCategoryId = equipmentType.category_id; } - if (equipmentType.category === 'spreader' && !spreaderWidth) { - throw new AppError('Spreader width is required for spreaders', 400); + // Verify category exists + if (finalCategoryId) { + const categoryCheck = await pool.query( + 'SELECT id, name FROM equipment_categories WHERE id = $1', + [finalCategoryId] + ); + + if (categoryCheck.rows.length === 0) { + throw new AppError('Category not found', 404); + } } const result = await pool.query( `INSERT INTO user_equipment - (user_id, equipment_type_id, custom_name, tank_size, pump_gpm, nozzle_gpm, nozzle_count, spreader_width) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + (user_id, equipment_type_id, category_id, custom_name, manufacturer, model, + capacity_lbs, spreader_type, spread_width, + tank_size_gallons, sprayer_type, spray_width_feet, pump_gpm, pump_psi, boom_sections, + mower_style, cutting_width_inches, engine_hp, fuel_type, + tool_type, working_width_inches, + pump_type, max_gpm, max_psi, power_source, + 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) RETURNING *`, - [req.user.id, equipmentTypeId, customName, tankSize, pumpGpm, nozzleGpm, nozzleCount, spreaderWidth] + [ + req.user.id, equipmentTypeId, finalCategoryId, customName, manufacturer, model, + capacityLbs, spreaderType, spreadWidth, + tankSizeGallons, sprayerType, sprayWidthFeet, pumpGpm, pumpPsi, boomSections, + mowerStyle, cuttingWidthInches, engineHp, fuelType, + toolType, workingWidthInches, + pumpType, maxGpm, maxPsi, powerSource, + purchaseDate, purchasePrice, notes + ] ); const equipment = result.rows[0]; @@ -178,14 +354,33 @@ router.post('/', validateRequest(equipmentSchema), async (req, res, next) => { equipment: { id: equipment.id, equipmentTypeId: equipment.equipment_type_id, - typeName: equipmentType.name, - category: equipmentType.category, + categoryId: equipment.category_id, customName: equipment.custom_name, - tankSize: parseFloat(equipment.tank_size), - pumpGpm: parseFloat(equipment.pump_gpm), - nozzleGpm: parseFloat(equipment.nozzle_gpm), - nozzleCount: equipment.nozzle_count, - spreaderWidth: parseFloat(equipment.spreader_width), + manufacturer: equipment.manufacturer, + model: equipment.model, + capacityLbs: parseFloat(equipment.capacity_lbs) || null, + spreaderType: equipment.spreader_type, + spreadWidth: parseFloat(equipment.spread_width) || null, + tankSizeGallons: parseFloat(equipment.tank_size_gallons) || null, + sprayerType: equipment.sprayer_type, + sprayWidthFeet: parseFloat(equipment.spray_width_feet) || null, + pumpGpm: parseFloat(equipment.pump_gpm) || null, + pumpPsi: parseFloat(equipment.pump_psi) || null, + boomSections: equipment.boom_sections, + mowerStyle: equipment.mower_style, + cuttingWidthInches: parseFloat(equipment.cutting_width_inches) || null, + engineHp: parseFloat(equipment.engine_hp) || null, + fuelType: equipment.fuel_type, + toolType: equipment.tool_type, + workingWidthInches: parseFloat(equipment.working_width_inches) || null, + pumpType: equipment.pump_type, + maxGpm: parseFloat(equipment.max_gpm) || null, + maxPsi: parseFloat(equipment.max_psi) || null, + powerSource: equipment.power_source, + purchaseDate: equipment.purchase_date, + purchasePrice: parseFloat(equipment.purchase_price) || null, + notes: equipment.notes, + isActive: equipment.is_active, createdAt: equipment.created_at, updatedAt: equipment.updated_at } @@ -199,17 +394,38 @@ router.post('/', validateRequest(equipmentSchema), async (req, res, next) => { // @route PUT /api/equipment/:id // @desc Update equipment // @access Private -router.put('/:id', validateParams(idParamSchema), validateRequest(equipmentSchema), async (req, res, next) => { +router.put('/:id', validateParams(idParamSchema), async (req, res, next) => { try { const equipmentId = req.params.id; - const { - equipmentTypeId, - customName, - tankSize, - pumpGpm, - nozzleGpm, - nozzleCount, - spreaderWidth + const { + equipmentTypeId, + categoryId, + customName, + manufacturer, + model, + capacityLbs, + spreaderType, + spreadWidth, + tankSizeGallons, + sprayerType, + sprayWidthFeet, + pumpGpm, + pumpPsi, + boomSections, + mowerStyle, + cuttingWidthInches, + engineHp, + fuelType, + toolType, + workingWidthInches, + pumpType, + maxGpm, + maxPsi, + powerSource, + purchaseDate, + purchasePrice, + notes, + isActive } = req.body; // Check if equipment exists and belongs to user @@ -222,36 +438,28 @@ router.put('/:id', validateParams(idParamSchema), validateRequest(equipmentSchem throw new AppError('Equipment not found', 404); } - // Verify equipment type exists - const typeCheck = await pool.query( - 'SELECT id, name, category FROM equipment_types WHERE id = $1', - [equipmentTypeId] - ); - - if (typeCheck.rows.length === 0) { - throw new AppError('Equipment type not found', 404); - } - - const equipmentType = typeCheck.rows[0]; - - // Validate required fields based on equipment type - if (equipmentType.category === 'sprayer') { - if (!tankSize || !pumpGpm || !nozzleGpm || !nozzleCount) { - throw new AppError('Tank size, pump GPM, nozzle GPM, and nozzle count are required for sprayers', 400); - } - } - - if (equipmentType.category === 'spreader' && !spreaderWidth) { - throw new AppError('Spreader width is required for spreaders', 400); - } - const result = await pool.query( `UPDATE user_equipment - SET equipment_type_id = $1, custom_name = $2, tank_size = $3, pump_gpm = $4, - nozzle_gpm = $5, nozzle_count = $6, spreader_width = $7, updated_at = CURRENT_TIMESTAMP - WHERE id = $8 + SET equipment_type_id = $1, category_id = $2, custom_name = $3, manufacturer = $4, model = $5, + capacity_lbs = $6, spreader_type = $7, spread_width = $8, + tank_size_gallons = $9, sprayer_type = $10, spray_width_feet = $11, pump_gpm = $12, pump_psi = $13, boom_sections = $14, + mower_style = $15, cutting_width_inches = $16, engine_hp = $17, fuel_type = $18, + tool_type = $19, working_width_inches = $20, + pump_type = $21, max_gpm = $22, max_psi = $23, power_source = $24, + purchase_date = $25, purchase_price = $26, notes = $27, is_active = $28, + updated_at = CURRENT_TIMESTAMP + WHERE id = $29 RETURNING *`, - [equipmentTypeId, customName, tankSize, pumpGpm, nozzleGpm, nozzleCount, spreaderWidth, equipmentId] + [ + equipmentTypeId, categoryId, customName, manufacturer, model, + capacityLbs, spreaderType, spreadWidth, + tankSizeGallons, sprayerType, sprayWidthFeet, pumpGpm, pumpPsi, boomSections, + mowerStyle, cuttingWidthInches, engineHp, fuelType, + toolType, workingWidthInches, + pumpType, maxGpm, maxPsi, powerSource, + purchaseDate, purchasePrice, notes, isActive !== undefined ? isActive : true, + equipmentId + ] ); const equipment = result.rows[0]; @@ -263,14 +471,33 @@ router.put('/:id', validateParams(idParamSchema), validateRequest(equipmentSchem equipment: { id: equipment.id, equipmentTypeId: equipment.equipment_type_id, - typeName: equipmentType.name, - category: equipmentType.category, + categoryId: equipment.category_id, customName: equipment.custom_name, - tankSize: parseFloat(equipment.tank_size), - pumpGpm: parseFloat(equipment.pump_gpm), - nozzleGpm: parseFloat(equipment.nozzle_gpm), - nozzleCount: equipment.nozzle_count, - spreaderWidth: parseFloat(equipment.spreader_width), + manufacturer: equipment.manufacturer, + model: equipment.model, + capacityLbs: parseFloat(equipment.capacity_lbs) || null, + spreaderType: equipment.spreader_type, + spreadWidth: parseFloat(equipment.spread_width) || null, + tankSizeGallons: parseFloat(equipment.tank_size_gallons) || null, + sprayerType: equipment.sprayer_type, + sprayWidthFeet: parseFloat(equipment.spray_width_feet) || null, + pumpGpm: parseFloat(equipment.pump_gpm) || null, + pumpPsi: parseFloat(equipment.pump_psi) || null, + boomSections: equipment.boom_sections, + mowerStyle: equipment.mower_style, + cuttingWidthInches: parseFloat(equipment.cutting_width_inches) || null, + engineHp: parseFloat(equipment.engine_hp) || null, + fuelType: equipment.fuel_type, + toolType: equipment.tool_type, + workingWidthInches: parseFloat(equipment.working_width_inches) || null, + pumpType: equipment.pump_type, + maxGpm: parseFloat(equipment.max_gpm) || null, + maxPsi: parseFloat(equipment.max_psi) || null, + powerSource: equipment.power_source, + purchaseDate: equipment.purchase_date, + purchasePrice: parseFloat(equipment.purchase_price) || null, + notes: equipment.notes, + isActive: equipment.is_active, createdAt: equipment.created_at, updatedAt: equipment.updated_at } @@ -321,128 +548,4 @@ router.delete('/:id', validateParams(idParamSchema), async (req, res, next) => { } }); -// @route GET /api/equipment/:id/calculations -// @desc Get application calculations for equipment -// @access Private -router.get('/:id/calculations', validateParams(idParamSchema), async (req, res, next) => { - try { - const equipmentId = req.params.id; - const { area, rateAmount, rateUnit } = req.query; - - if (!area || !rateAmount || !rateUnit) { - throw new AppError('Area, rate amount, and rate unit are required for calculations', 400); - } - - // Get equipment details - const equipmentResult = await pool.query( - `SELECT ue.*, et.category - FROM user_equipment ue - JOIN equipment_types et ON ue.equipment_type_id = et.id - WHERE ue.id = $1 AND ue.user_id = $2`, - [equipmentId, req.user.id] - ); - - if (equipmentResult.rows.length === 0) { - throw new AppError('Equipment not found', 404); - } - - const equipment = equipmentResult.rows[0]; - const targetArea = parseFloat(area); - const rate = parseFloat(rateAmount); - - let calculations = {}; - - if (equipment.category === 'sprayer') { - // Liquid application calculations - const tankSize = parseFloat(equipment.tank_size); - const pumpGpm = parseFloat(equipment.pump_gpm); - const nozzleGpm = parseFloat(equipment.nozzle_gpm); - const nozzleCount = parseInt(equipment.nozzle_count); - - // Calculate total nozzle output - const totalNozzleGpm = nozzleGpm * nozzleCount; - - let productAmount, waterAmount, targetSpeed; - - if (rateUnit.includes('gal/1000sqft') || rateUnit.includes('gal/acre')) { - // Gallons per area - calculate water volume needed - const multiplier = rateUnit.includes('acre') ? targetArea / 43560 : targetArea / 1000; - waterAmount = rate * multiplier; - productAmount = 0; // Pure water application - } else if (rateUnit.includes('oz/gal/1000sqft')) { - // Ounces per gallon per 1000 sqft - const waterGallonsNeeded = targetArea / 1000; // 1 gallon per 1000 sqft default - productAmount = rate * waterGallonsNeeded; - waterAmount = waterGallonsNeeded * 128; // Convert to ounces - } else { - // Default liquid calculation - productAmount = rate * (targetArea / 1000); - waterAmount = tankSize * 128; // Tank capacity in ounces - } - - // Calculate target speed (assuming 20 foot spray width as default) - const sprayWidth = 20; // feet - const minutesToCover = waterAmount / (totalNozzleGpm * 128); // Convert GPM to oz/min - const distanceFeet = targetArea / sprayWidth; - targetSpeed = (distanceFeet / minutesToCover) * (60 / 5280); // Convert to MPH - - calculations = { - productAmount: Math.round(productAmount * 100) / 100, - waterAmount: Math.round(waterAmount * 100) / 100, - targetSpeed: Math.round(targetSpeed * 100) / 100, - tankCount: Math.ceil(waterAmount / (tankSize * 128)), - applicationType: 'liquid', - unit: rateUnit.includes('oz') ? 'oz' : 'gal' - }; - } else if (equipment.category === 'spreader') { - // Granular application calculations - const spreaderWidth = parseFloat(equipment.spreader_width); - - let productAmount, targetSpeed; - - if (rateUnit.includes('lbs/1000sqft')) { - productAmount = rate * (targetArea / 1000); - } else if (rateUnit.includes('lbs/acre')) { - productAmount = rate * (targetArea / 43560); - } else { - productAmount = rate * (targetArea / 1000); // Default to per 1000 sqft - } - - // Calculate target speed (assuming 3 MPH walking speed as baseline) - const baselineSpeed = 3; // MPH - const minutesToSpread = 60; // Assume 1 hour coverage - const distanceFeet = targetArea / spreaderWidth; - targetSpeed = (distanceFeet / (minutesToSpread * 60)) * (60 / 5280); // Convert to MPH - - calculations = { - productAmount: Math.round(productAmount * 100) / 100, - targetSpeed: Math.round(targetSpeed * 100) / 100, - applicationType: 'granular', - unit: 'lbs', - coverageTime: Math.round((targetArea / (spreaderWidth * baselineSpeed * 5280 / 60)) * 100) / 100 - }; - } - - res.json({ - success: true, - data: { - calculations, - equipment: { - id: equipment.id, - category: equipment.category, - tankSize: equipment.tank_size, - spreaderWidth: equipment.spreader_width - }, - inputs: { - area: targetArea, - rate: rate, - unit: rateUnit - } - } - }); - } catch (error) { - next(error); - } -}); - module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/nozzles.js b/backend/src/routes/nozzles.js new file mode 100644 index 0000000..80b16c0 --- /dev/null +++ b/backend/src/routes/nozzles.js @@ -0,0 +1,503 @@ +const express = require('express'); +const pool = require('../config/database'); +const { validateRequest, validateParams } = require('../utils/validation'); +const { idParamSchema } = require('../utils/validation'); +const { AppError } = require('../middleware/errorHandler'); + +const router = express.Router(); + +// @route GET /api/nozzles/types +// @desc Get all nozzle types +// @access Private +router.get('/types', async (req, res, next) => { + try { + const { manufacturer, droplet_size, spray_pattern } = req.query; + + let query = 'SELECT * FROM nozzle_types WHERE 1=1'; + const queryParams = []; + + if (manufacturer) { + queryParams.push(manufacturer); + query += ` AND manufacturer ILIKE $${queryParams.length}`; + } + + if (droplet_size) { + queryParams.push(droplet_size); + query += ` AND droplet_size = $${queryParams.length}`; + } + + if (spray_pattern) { + queryParams.push(spray_pattern); + query += ` AND spray_pattern = $${queryParams.length}`; + } + + query += ' ORDER BY manufacturer, name'; + + const result = await pool.query(query, queryParams); + + // Group by manufacturer for easier frontend handling + const nozzlesByManufacturer = result.rows.reduce((acc, nozzle) => { + const manufacturer = nozzle.manufacturer || 'Unknown'; + if (!acc[manufacturer]) { + acc[manufacturer] = []; + } + acc[manufacturer].push({ + id: nozzle.id, + name: nozzle.name, + manufacturer: nozzle.manufacturer, + model: nozzle.model, + orificeSize: nozzle.orifice_size, + sprayAngle: nozzle.spray_angle, + flowRateGpm: parseFloat(nozzle.flow_rate_gpm), + dropletSize: nozzle.droplet_size, + sprayPattern: nozzle.spray_pattern, + pressureRangePsi: nozzle.pressure_range_psi, + createdAt: nozzle.created_at + }); + return acc; + }, {}); + + res.json({ + success: true, + data: { + nozzleTypes: result.rows.map(nt => ({ + id: nt.id, + name: nt.name, + manufacturer: nt.manufacturer, + model: nt.model, + orificeSize: nt.orifice_size, + sprayAngle: nt.spray_angle, + flowRateGpm: parseFloat(nt.flow_rate_gpm), + dropletSize: nt.droplet_size, + sprayPattern: nt.spray_pattern, + pressureRangePsi: nt.pressure_range_psi, + createdAt: nt.created_at + })), + nozzlesByManufacturer + } + }); + } catch (error) { + next(error); + } +}); + +// @route GET /api/nozzles +// @desc Get all user's nozzles +// @access Private +router.get('/', async (req, res, next) => { + try { + const result = await pool.query( + `SELECT un.*, nt.name as type_name, nt.manufacturer as type_manufacturer, + nt.model as type_model, nt.orifice_size, nt.spray_angle, nt.flow_rate_gpm, + nt.droplet_size, nt.spray_pattern, nt.pressure_range_psi + FROM user_nozzles un + LEFT JOIN nozzle_types nt ON un.nozzle_type_id = nt.id + WHERE un.user_id = $1 + ORDER BY nt.manufacturer, nt.name, un.custom_name`, + [req.user.id] + ); + + res.json({ + success: true, + data: { + nozzles: result.rows.map(item => ({ + id: item.id, + nozzleTypeId: item.nozzle_type_id, + typeName: item.type_name, + typeManufacturer: item.type_manufacturer, + typeModel: item.type_model, + orificeSize: item.orifice_size, + sprayAngle: item.spray_angle, + flowRateGpm: parseFloat(item.flow_rate_gpm), + dropletSize: item.droplet_size, + sprayPattern: item.spray_pattern, + pressureRangePsi: item.pressure_range_psi, + customName: item.custom_name, + quantity: item.quantity, + condition: item.condition, + purchaseDate: item.purchase_date, + notes: item.notes, + createdAt: item.created_at, + updatedAt: item.updated_at + })) + } + }); + } catch (error) { + next(error); + } +}); + +// @route GET /api/nozzles/:id +// @desc Get single user nozzle +// @access Private +router.get('/:id', validateParams(idParamSchema), async (req, res, next) => { + try { + const nozzleId = req.params.id; + + const result = await pool.query( + `SELECT un.*, nt.name as type_name, nt.manufacturer as type_manufacturer, + nt.model as type_model, nt.orifice_size, nt.spray_angle, nt.flow_rate_gpm, + nt.droplet_size, nt.spray_pattern, nt.pressure_range_psi + FROM user_nozzles un + LEFT JOIN nozzle_types nt ON un.nozzle_type_id = nt.id + WHERE un.id = $1 AND un.user_id = $2`, + [nozzleId, req.user.id] + ); + + if (result.rows.length === 0) { + throw new AppError('Nozzle not found', 404); + } + + const item = result.rows[0]; + + res.json({ + success: true, + data: { + nozzle: { + id: item.id, + nozzleTypeId: item.nozzle_type_id, + typeName: item.type_name, + typeManufacturer: item.type_manufacturer, + typeModel: item.type_model, + orificeSize: item.orifice_size, + sprayAngle: item.spray_angle, + flowRateGpm: parseFloat(item.flow_rate_gpm), + dropletSize: item.droplet_size, + sprayPattern: item.spray_pattern, + pressureRangePsi: item.pressure_range_psi, + customName: item.custom_name, + quantity: item.quantity, + condition: item.condition, + purchaseDate: item.purchase_date, + notes: item.notes, + createdAt: item.created_at, + updatedAt: item.updated_at + } + } + }); + } catch (error) { + next(error); + } +}); + +// @route POST /api/nozzles +// @desc Add new nozzle to user's inventory +// @access Private +router.post('/', async (req, res, next) => { + try { + const { + nozzleTypeId, + customName, + quantity, + condition, + purchaseDate, + notes + } = req.body; + + // Validate nozzle type exists if provided + if (nozzleTypeId) { + const typeCheck = await pool.query( + 'SELECT id, name, manufacturer FROM nozzle_types WHERE id = $1', + [nozzleTypeId] + ); + + if (typeCheck.rows.length === 0) { + throw new AppError('Nozzle type not found', 404); + } + } + + // Either nozzleTypeId or customName is required + if (!nozzleTypeId && !customName) { + throw new AppError('Either nozzle type or custom name is required', 400); + } + + const result = await pool.query( + `INSERT INTO user_nozzles + (user_id, nozzle_type_id, custom_name, quantity, condition, purchase_date, notes) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING *`, + [req.user.id, nozzleTypeId, customName, quantity || 1, condition || 'good', purchaseDate, notes] + ); + + const nozzle = result.rows[0]; + + res.status(201).json({ + success: true, + message: 'Nozzle added successfully', + data: { + nozzle: { + id: nozzle.id, + nozzleTypeId: nozzle.nozzle_type_id, + customName: nozzle.custom_name, + quantity: nozzle.quantity, + condition: nozzle.condition, + purchaseDate: nozzle.purchase_date, + notes: nozzle.notes, + createdAt: nozzle.created_at, + updatedAt: nozzle.updated_at + } + } + }); + } catch (error) { + next(error); + } +}); + +// @route PUT /api/nozzles/:id +// @desc Update user nozzle +// @access Private +router.put('/:id', validateParams(idParamSchema), async (req, res, next) => { + try { + const nozzleId = req.params.id; + const { + nozzleTypeId, + customName, + quantity, + condition, + purchaseDate, + notes + } = req.body; + + // Check if nozzle exists and belongs to user + const checkResult = await pool.query( + 'SELECT id FROM user_nozzles WHERE id = $1 AND user_id = $2', + [nozzleId, req.user.id] + ); + + if (checkResult.rows.length === 0) { + throw new AppError('Nozzle not found', 404); + } + + const result = await pool.query( + `UPDATE user_nozzles + SET nozzle_type_id = $1, custom_name = $2, quantity = $3, condition = $4, + purchase_date = $5, notes = $6, updated_at = CURRENT_TIMESTAMP + WHERE id = $7 + RETURNING *`, + [nozzleTypeId, customName, quantity, condition, purchaseDate, notes, nozzleId] + ); + + const nozzle = result.rows[0]; + + res.json({ + success: true, + message: 'Nozzle updated successfully', + data: { + nozzle: { + id: nozzle.id, + nozzleTypeId: nozzle.nozzle_type_id, + customName: nozzle.custom_name, + quantity: nozzle.quantity, + condition: nozzle.condition, + purchaseDate: nozzle.purchase_date, + notes: nozzle.notes, + createdAt: nozzle.created_at, + updatedAt: nozzle.updated_at + } + } + }); + } catch (error) { + next(error); + } +}); + +// @route DELETE /api/nozzles/:id +// @desc Delete user nozzle +// @access Private +router.delete('/:id', validateParams(idParamSchema), async (req, res, next) => { + try { + const nozzleId = req.params.id; + + // Check if nozzle exists and belongs to user + const checkResult = await pool.query( + 'SELECT id FROM user_nozzles WHERE id = $1 AND user_id = $2', + [nozzleId, req.user.id] + ); + + if (checkResult.rows.length === 0) { + throw new AppError('Nozzle not found', 404); + } + + // Check if nozzle is assigned to any equipment + const assignmentCheck = await pool.query( + 'SELECT COUNT(*) as count FROM equipment_nozzle_assignments WHERE user_nozzle_id = $1', + [nozzleId] + ); + + if (parseInt(assignmentCheck.rows[0].count) > 0) { + throw new AppError('Cannot delete nozzle that is assigned to equipment', 400); + } + + await pool.query('DELETE FROM user_nozzles WHERE id = $1', [nozzleId]); + + res.json({ + success: true, + message: 'Nozzle deleted successfully' + }); + } catch (error) { + next(error); + } +}); + +// @route GET /api/nozzles/equipment/:equipmentId/assignments +// @desc Get nozzle assignments for specific equipment +// @access Private +router.get('/equipment/:equipmentId/assignments', validateParams(idParamSchema), async (req, res, next) => { + try { + const equipmentId = req.params.equipmentId; + + // Verify equipment belongs to user + const equipmentCheck = await pool.query( + 'SELECT id FROM user_equipment WHERE id = $1 AND user_id = $2', + [equipmentId, req.user.id] + ); + + if (equipmentCheck.rows.length === 0) { + throw new AppError('Equipment not found', 404); + } + + const result = await pool.query( + `SELECT ena.*, un.custom_name as nozzle_custom_name, un.quantity as nozzle_total_quantity, + nt.name as nozzle_type_name, nt.manufacturer, nt.model, nt.orifice_size, + nt.spray_angle, nt.flow_rate_gpm, nt.droplet_size, nt.spray_pattern + FROM equipment_nozzle_assignments ena + JOIN user_nozzles un ON ena.user_nozzle_id = un.id + LEFT JOIN nozzle_types nt ON un.nozzle_type_id = nt.id + WHERE ena.user_equipment_id = $1 + ORDER BY ena.position, nt.manufacturer, nt.name`, + [equipmentId] + ); + + res.json({ + success: true, + data: { + assignments: result.rows.map(item => ({ + id: item.id, + userEquipmentId: item.user_equipment_id, + userNozzleId: item.user_nozzle_id, + position: item.position, + quantityAssigned: item.quantity_assigned, + assignedDate: item.assigned_date, + nozzleCustomName: item.nozzle_custom_name, + nozzleTotalQuantity: item.nozzle_total_quantity, + nozzleTypeName: item.nozzle_type_name, + manufacturer: item.manufacturer, + model: item.model, + orificeSize: item.orifice_size, + sprayAngle: item.spray_angle, + flowRateGpm: parseFloat(item.flow_rate_gpm), + dropletSize: item.droplet_size, + sprayPattern: item.spray_pattern, + createdAt: item.created_at + })) + } + }); + } catch (error) { + next(error); + } +}); + +// @route POST /api/nozzles/equipment/:equipmentId/assignments +// @desc Assign nozzle to equipment +// @access Private +router.post('/equipment/:equipmentId/assignments', validateParams(idParamSchema), async (req, res, next) => { + try { + const equipmentId = req.params.equipmentId; + const { userNozzleId, position, quantityAssigned } = req.body; + + // Verify equipment belongs to user + const equipmentCheck = await pool.query( + 'SELECT id FROM user_equipment WHERE id = $1 AND user_id = $2', + [equipmentId, req.user.id] + ); + + if (equipmentCheck.rows.length === 0) { + throw new AppError('Equipment not found', 404); + } + + // Verify nozzle belongs to user + const nozzleCheck = await pool.query( + 'SELECT id, quantity FROM user_nozzles WHERE id = $1 AND user_id = $2', + [userNozzleId, req.user.id] + ); + + if (nozzleCheck.rows.length === 0) { + throw new AppError('Nozzle not found', 404); + } + + const nozzle = nozzleCheck.rows[0]; + + // Check if enough nozzles are available + const assignedCount = await pool.query( + 'SELECT COALESCE(SUM(quantity_assigned), 0) as total_assigned FROM equipment_nozzle_assignments WHERE user_nozzle_id = $1', + [userNozzleId] + ); + + const totalAssigned = parseInt(assignedCount.rows[0].total_assigned); + const requestedQuantity = quantityAssigned || 1; + + if (totalAssigned + requestedQuantity > nozzle.quantity) { + throw new AppError(`Not enough nozzles available. Available: ${nozzle.quantity - totalAssigned}, Requested: ${requestedQuantity}`, 400); + } + + const result = await pool.query( + `INSERT INTO equipment_nozzle_assignments + (user_equipment_id, user_nozzle_id, position, quantity_assigned) + VALUES ($1, $2, $3, $4) + RETURNING *`, + [equipmentId, userNozzleId, position || 'center', requestedQuantity] + ); + + const assignment = result.rows[0]; + + res.status(201).json({ + success: true, + message: 'Nozzle assigned to equipment successfully', + data: { + assignment: { + id: assignment.id, + userEquipmentId: assignment.user_equipment_id, + userNozzleId: assignment.user_nozzle_id, + position: assignment.position, + quantityAssigned: assignment.quantity_assigned, + assignedDate: assignment.assigned_date, + createdAt: assignment.created_at + } + } + }); + } catch (error) { + next(error); + } +}); + +// @route DELETE /api/nozzles/assignments/:assignmentId +// @desc Remove nozzle assignment from equipment +// @access Private +router.delete('/assignments/:assignmentId', validateParams(idParamSchema), async (req, res, next) => { + try { + const assignmentId = req.params.assignmentId; + + // Verify assignment belongs to user's equipment + const checkResult = await pool.query( + `SELECT ena.id FROM equipment_nozzle_assignments ena + JOIN user_equipment ue ON ena.user_equipment_id = ue.id + WHERE ena.id = $1 AND ue.user_id = $2`, + [assignmentId, req.user.id] + ); + + if (checkResult.rows.length === 0) { + throw new AppError('Assignment not found', 404); + } + + await pool.query('DELETE FROM equipment_nozzle_assignments WHERE id = $1', [assignmentId]); + + res.json({ + success: true, + message: 'Nozzle assignment removed successfully' + }); + } catch (error) { + next(error); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/database/init.sql b/database/init.sql index 2872857..454786b 100644 --- a/database/init.sql +++ b/database/init.sql @@ -41,29 +41,107 @@ CREATE TABLE lawn_sections ( updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); +-- Equipment categories master table +CREATE TABLE equipment_categories ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL UNIQUE, + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + -- Equipment types master table (shared across all users) CREATE TABLE equipment_types ( id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL UNIQUE, - category VARCHAR(100) NOT NULL, -- mower, trimmer, spreader, sprayer, aerator, dethatcher + category_id INTEGER REFERENCES equipment_categories(id), + manufacturer VARCHAR(100), + model VARCHAR(100), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); --- User equipment table +-- User equipment table - comprehensive equipment management CREATE TABLE user_equipment ( id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, equipment_type_id INTEGER REFERENCES equipment_types(id), + category_id INTEGER REFERENCES equipment_categories(id), custom_name VARCHAR(255), - tank_size DECIMAL(8, 2), -- gallons (for sprayers) - pump_gpm DECIMAL(8, 2), -- gallons per minute (for sprayers) - nozzle_gpm DECIMAL(8, 2), -- gallons per minute per nozzle - nozzle_count INTEGER, -- number of nozzles - spreader_width DECIMAL(8, 2), -- width in feet (for spreaders) + manufacturer VARCHAR(100), + model VARCHAR(100), + -- Spreader specific fields + capacity_lbs DECIMAL(8, 2), + spreader_type VARCHAR(50) CHECK (spreader_type IN ('walk_behind', 'pull_behind', 'handheld')), + spread_width DECIMAL(8, 2), + -- Sprayer specific fields + tank_size_gallons DECIMAL(8, 2), + sprayer_type VARCHAR(50) CHECK (sprayer_type IN ('tow_behind', 'mower_mounted', 'ride_on', 'walk_behind', 'hand_pump')), + spray_width_feet DECIMAL(8, 2), + pump_gpm DECIMAL(8, 2), + pump_psi DECIMAL(8, 2), + boom_sections INTEGER, + -- Mower specific fields + mower_style VARCHAR(50) CHECK (mower_style IN ('push', 'self_propelled', 'zero_turn', 'lawn_tractor', 'riding')), + cutting_width_inches DECIMAL(6, 2), + engine_hp DECIMAL(6, 2), + fuel_type VARCHAR(30), + -- Tool specific fields (aerator, dethatcher, scarifier) + tool_type VARCHAR(50) CHECK (tool_type IN ('walk_behind', 'tow_behind', 'handheld')), + working_width_inches DECIMAL(6, 2), + -- Pump specific fields + pump_type VARCHAR(50), + max_gpm DECIMAL(8, 2), + max_psi DECIMAL(8, 2), + power_source VARCHAR(50), -- electric, gas, pto, etc. + -- General fields + purchase_date DATE, + purchase_price DECIMAL(10, 2), + notes TEXT, + is_active BOOLEAN DEFAULT true, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); +-- Nozzle types master table +CREATE TABLE nozzle_types ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + manufacturer VARCHAR(100), + model VARCHAR(100), + orifice_size VARCHAR(20), + spray_angle INTEGER, + flow_rate_gpm DECIMAL(6, 3), + droplet_size VARCHAR(50), -- fine, medium, coarse, very_coarse, extremely_coarse + spray_pattern VARCHAR(50), -- flat_fan, hollow_cone, full_cone, flooding + pressure_range_psi VARCHAR(50), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- User's nozzle inventory +CREATE TABLE user_nozzles ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + nozzle_type_id INTEGER REFERENCES nozzle_types(id), + custom_name VARCHAR(255), + quantity INTEGER DEFAULT 1, + condition VARCHAR(50) DEFAULT 'good', -- excellent, good, fair, poor, needs_replacement + purchase_date DATE, + notes TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Equipment nozzle assignments (which nozzles are on which sprayers) +CREATE TABLE equipment_nozzle_assignments ( + id SERIAL PRIMARY KEY, + user_equipment_id INTEGER REFERENCES user_equipment(id) ON DELETE CASCADE, + user_nozzle_id INTEGER REFERENCES user_nozzles(id) ON DELETE CASCADE, + position VARCHAR(50), -- left, right, center, boom_1, boom_2, etc. + quantity_assigned INTEGER DEFAULT 1, + assigned_date DATE DEFAULT CURRENT_DATE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_equipment_id, user_nozzle_id, position) +); + -- Product categories CREATE TABLE product_categories ( id SERIAL PRIMARY KEY, @@ -181,22 +259,72 @@ CREATE TABLE weather_data ( created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); +-- Insert equipment categories +INSERT INTO equipment_categories (name, description) VALUES + ('Mower', 'Lawn mowing equipment'), + ('Spreader', 'Granular product application equipment'), + ('Sprayer', 'Liquid product application equipment'), + ('Nozzle', 'Spray nozzles and tips'), + ('Pump', 'Water and chemical pumps'), + ('Aerator', 'Soil aeration equipment'), + ('Dethatcher', 'Thatch removal equipment'), + ('Scarifier', 'Soil scarification equipment'), + ('Trimmer', 'Edge and trim equipment'), + ('Other', 'Miscellaneous lawn care equipment'); + -- Insert default equipment types -INSERT INTO equipment_types (name, category) VALUES - ('Walk-behind Mower', 'mower'), - ('Riding Mower', 'mower'), - ('Zero-turn Mower', 'mower'), - ('String Trimmer', 'trimmer'), - ('Backpack Sprayer', 'sprayer'), - ('Pull-behind Sprayer', 'sprayer'), - ('Boom Sprayer', 'sprayer'), - ('Broadcast Spreader', 'spreader'), - ('Drop Spreader', 'spreader'), - ('Hand Spreader', 'spreader'), - ('Core Aerator', 'aerator'), - ('Spike Aerator', 'aerator'), - ('Dethatcher', 'dethatcher'), - ('Power Rake', 'dethatcher'); +INSERT INTO equipment_types (name, category_id) VALUES + -- Mowers + ('Walk-behind Mower', 1), + ('Self-Propelled Mower', 1), + ('Zero-turn Mower', 1), + ('Lawn Tractor', 1), + ('Riding Mower', 1), + -- Spreaders + ('Broadcast Spreader', 2), + ('Drop Spreader', 2), + ('Hand Spreader', 2), + ('Pull-behind Spreader', 2), + -- Sprayers + ('Backpack Sprayer', 3), + ('Pull-behind Sprayer', 3), + ('Boom Sprayer', 3), + ('Hand Pump Sprayer', 3), + ('Ride-on Sprayer', 3), + ('Mower-mounted Sprayer', 3), + -- Pumps + ('Centrifugal Pump', 5), + ('Diaphragm Pump', 5), + ('Roller Pump', 5), + ('Gear Pump', 5), + -- Aerators + ('Core Aerator', 6), + ('Spike Aerator', 6), + ('Plug Aerator', 6), + -- Dethatchers + ('Power Rake', 7), + ('Vertical Mower', 7), + ('Dethatcher', 7), + -- Scarifiers + ('Walk-behind Scarifier', 8), + ('Tow-behind Scarifier', 8), + -- Trimmers + ('String Trimmer', 9), + ('Brush Cutter', 9), + ('Edger', 9); + +-- Insert common nozzle types +INSERT INTO nozzle_types (name, manufacturer, orifice_size, spray_angle, flow_rate_gpm, droplet_size, spray_pattern, pressure_range_psi) VALUES + ('XR8002', 'TeeJet', '02', 80, 0.20, 'fine', 'flat_fan', '15-60'), + ('XR8003', 'TeeJet', '03', 80, 0.30, 'fine', 'flat_fan', '15-60'), + ('XR8004', 'TeeJet', '04', 80, 0.40, 'fine', 'flat_fan', '15-60'), + ('XR8005', 'TeeJet', '05', 80, 0.50, 'fine', 'flat_fan', '15-60'), + ('AIXR11002', 'TeeJet', '02', 110, 0.20, 'medium', 'flat_fan', '15-60'), + ('AIXR11003', 'TeeJet', '03', 110, 0.30, 'medium', 'flat_fan', '15-60'), + ('AIXR11004', 'TeeJet', '04', 110, 0.40, 'medium', 'flat_fan', '15-60'), + ('TTI11002', 'TeeJet', '02', 110, 0.20, 'very_coarse', 'flat_fan', '15-60'), + ('TTI11003', 'TeeJet', '03', 110, 0.30, 'very_coarse', 'flat_fan', '15-60'), + ('TTI11004', 'TeeJet', '04', 110, 0.40, 'very_coarse', 'flat_fan', '15-60'); -- Insert product categories INSERT INTO product_categories (name, description) VALUES @@ -248,5 +376,6 @@ CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECU CREATE TRIGGER update_properties_updated_at BEFORE UPDATE ON properties FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); CREATE TRIGGER update_lawn_sections_updated_at BEFORE UPDATE ON lawn_sections FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); CREATE TRIGGER update_user_equipment_updated_at BEFORE UPDATE ON user_equipment FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +CREATE TRIGGER update_user_nozzles_updated_at BEFORE UPDATE ON user_nozzles FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); CREATE TRIGGER update_user_products_updated_at BEFORE UPDATE ON user_products FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); CREATE TRIGGER update_application_plans_updated_at BEFORE UPDATE ON application_plans FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); \ No newline at end of file diff --git a/database/migrations/comprehensive_equipment_upgrade.sql b/database/migrations/comprehensive_equipment_upgrade.sql new file mode 100644 index 0000000..38d31a7 --- /dev/null +++ b/database/migrations/comprehensive_equipment_upgrade.sql @@ -0,0 +1,200 @@ +-- Comprehensive Equipment System Migration +-- This migration transforms the basic equipment system into a comprehensive one +-- supporting spreaders, sprayers, nozzles, pumps, mowers, and lawn tools + +-- Step 1: Create new equipment categories table +CREATE TABLE IF NOT EXISTS equipment_categories ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL UNIQUE, + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Step 2: Insert equipment categories +INSERT INTO equipment_categories (name, description) VALUES + ('Mower', 'Lawn mowing equipment'), + ('Spreader', 'Granular product application equipment'), + ('Sprayer', 'Liquid product application equipment'), + ('Nozzle', 'Spray nozzles and tips'), + ('Pump', 'Water and chemical pumps'), + ('Aerator', 'Soil aeration equipment'), + ('Dethatcher', 'Thatch removal equipment'), + ('Scarifier', 'Soil scarification equipment'), + ('Trimmer', 'Edge and trim equipment'), + ('Other', 'Miscellaneous lawn care equipment') +ON CONFLICT (name) DO NOTHING; + +-- Step 3: Add category_id to equipment_types if it doesn't exist +ALTER TABLE equipment_types +ADD COLUMN IF NOT EXISTS category_id INTEGER REFERENCES equipment_categories(id), +ADD COLUMN IF NOT EXISTS manufacturer VARCHAR(100), +ADD COLUMN IF NOT EXISTS model VARCHAR(100); + +-- Step 4: Update existing equipment_types with category_id based on legacy category field +UPDATE equipment_types SET category_id = ( + SELECT id FROM equipment_categories + WHERE LOWER(equipment_categories.name) = LOWER(equipment_types.category) +) WHERE category_id IS NULL; + +-- Step 5: Add comprehensive fields to user_equipment table +ALTER TABLE user_equipment +ADD COLUMN IF NOT EXISTS category_id INTEGER REFERENCES equipment_categories(id), +ADD COLUMN IF NOT EXISTS manufacturer VARCHAR(100), +ADD COLUMN IF NOT EXISTS model VARCHAR(100), +-- Spreader specific fields +ADD COLUMN IF NOT EXISTS capacity_lbs DECIMAL(8, 2), +ADD COLUMN IF NOT EXISTS spreader_type VARCHAR(50) CHECK (spreader_type IN ('walk_behind', 'pull_behind', 'handheld')), +ADD COLUMN IF NOT EXISTS spread_width DECIMAL(8, 2), +-- Sprayer specific fields (rename existing fields) +ADD COLUMN IF NOT EXISTS tank_size_gallons DECIMAL(8, 2), +ADD COLUMN IF NOT EXISTS sprayer_type VARCHAR(50) CHECK (sprayer_type IN ('tow_behind', 'mower_mounted', 'ride_on', 'walk_behind', 'hand_pump')), +ADD COLUMN IF NOT EXISTS spray_width_feet DECIMAL(8, 2), +ADD COLUMN IF NOT EXISTS pump_psi DECIMAL(8, 2), +ADD COLUMN IF NOT EXISTS boom_sections INTEGER, +-- Mower specific fields +ADD COLUMN IF NOT EXISTS mower_style VARCHAR(50) CHECK (mower_style IN ('push', 'self_propelled', 'zero_turn', 'lawn_tractor', 'riding')), +ADD COLUMN IF NOT EXISTS cutting_width_inches DECIMAL(6, 2), +ADD COLUMN IF NOT EXISTS engine_hp DECIMAL(6, 2), +ADD COLUMN IF NOT EXISTS fuel_type VARCHAR(30), +-- Tool specific fields (aerator, dethatcher, scarifier) +ADD COLUMN IF NOT EXISTS tool_type VARCHAR(50) CHECK (tool_type IN ('walk_behind', 'tow_behind', 'handheld')), +ADD COLUMN IF NOT EXISTS working_width_inches DECIMAL(6, 2), +-- Pump specific fields +ADD COLUMN IF NOT EXISTS pump_type VARCHAR(50), +ADD COLUMN IF NOT EXISTS max_gpm DECIMAL(8, 2), +ADD COLUMN IF NOT EXISTS max_psi DECIMAL(8, 2), +ADD COLUMN IF NOT EXISTS power_source VARCHAR(50), +-- General fields +ADD COLUMN IF NOT EXISTS purchase_date DATE, +ADD COLUMN IF NOT EXISTS purchase_price DECIMAL(10, 2), +ADD COLUMN IF NOT EXISTS is_active BOOLEAN DEFAULT true; + +-- Step 6: Migrate existing data to new fields +UPDATE user_equipment +SET + tank_size_gallons = tank_size, + spread_width = spreader_width +WHERE tank_size_gallons IS NULL AND tank_size IS NOT NULL + OR spread_width IS NULL AND spreader_width IS NOT NULL; + +-- Set category_id based on equipment_type +UPDATE user_equipment SET category_id = ( + SELECT et.category_id FROM equipment_types et + WHERE et.id = user_equipment.equipment_type_id +) WHERE category_id IS NULL; + +-- Step 7: Create nozzle types master table +CREATE TABLE IF NOT EXISTS nozzle_types ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + manufacturer VARCHAR(100), + model VARCHAR(100), + orifice_size VARCHAR(20), + spray_angle INTEGER, + flow_rate_gpm DECIMAL(6, 3), + droplet_size VARCHAR(50), -- fine, medium, coarse, very_coarse, extremely_coarse + spray_pattern VARCHAR(50), -- flat_fan, hollow_cone, full_cone, flooding + pressure_range_psi VARCHAR(50), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Step 8: Insert common nozzle types +INSERT INTO nozzle_types (name, manufacturer, orifice_size, spray_angle, flow_rate_gpm, droplet_size, spray_pattern, pressure_range_psi) VALUES + ('XR8002', 'TeeJet', '02', 80, 0.20, 'fine', 'flat_fan', '15-60'), + ('XR8003', 'TeeJet', '03', 80, 0.30, 'fine', 'flat_fan', '15-60'), + ('XR8004', 'TeeJet', '04', 80, 0.40, 'fine', 'flat_fan', '15-60'), + ('XR8005', 'TeeJet', '05', 80, 0.50, 'fine', 'flat_fan', '15-60'), + ('AIXR11002', 'TeeJet', '02', 110, 0.20, 'medium', 'flat_fan', '15-60'), + ('AIXR11003', 'TeeJet', '03', 110, 0.30, 'medium', 'flat_fan', '15-60'), + ('AIXR11004', 'TeeJet', '04', 110, 0.40, 'medium', 'flat_fan', '15-60'), + ('TTI11002', 'TeeJet', '02', 110, 0.20, 'very_coarse', 'flat_fan', '15-60'), + ('TTI11003', 'TeeJet', '03', 110, 0.30, 'very_coarse', 'flat_fan', '15-60'), + ('TTI11004', 'TeeJet', '04', 110, 0.40, 'very_coarse', 'flat_fan', '15-60') +ON CONFLICT DO NOTHING; + +-- Step 9: Create user nozzles table +CREATE TABLE IF NOT EXISTS user_nozzles ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + nozzle_type_id INTEGER REFERENCES nozzle_types(id), + custom_name VARCHAR(255), + quantity INTEGER DEFAULT 1, + condition VARCHAR(50) DEFAULT 'good', -- excellent, good, fair, poor, needs_replacement + purchase_date DATE, + notes TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Step 10: Create equipment nozzle assignments table +CREATE TABLE IF NOT EXISTS equipment_nozzle_assignments ( + id SERIAL PRIMARY KEY, + user_equipment_id INTEGER REFERENCES user_equipment(id) ON DELETE CASCADE, + user_nozzle_id INTEGER REFERENCES user_nozzles(id) ON DELETE CASCADE, + position VARCHAR(50), -- left, right, center, boom_1, boom_2, etc. + quantity_assigned INTEGER DEFAULT 1, + assigned_date DATE DEFAULT CURRENT_DATE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_equipment_id, user_nozzle_id, position) +); + +-- Step 11: Add new equipment types that weren't in the original seed +INSERT INTO equipment_types (name, category_id) VALUES + -- Additional Mowers + ('Self-Propelled Mower', (SELECT id FROM equipment_categories WHERE name = 'Mower')), + ('Lawn Tractor', (SELECT id FROM equipment_categories WHERE name = 'Mower')), + -- Additional Spreaders + ('Pull-behind Spreader', (SELECT id FROM equipment_categories WHERE name = 'Spreader')), + -- Additional Sprayers + ('Hand Pump Sprayer', (SELECT id FROM equipment_categories WHERE name = 'Sprayer')), + ('Ride-on Sprayer', (SELECT id FROM equipment_categories WHERE name = 'Sprayer')), + ('Mower-mounted Sprayer', (SELECT id FROM equipment_categories WHERE name = 'Sprayer')), + -- Pumps + ('Centrifugal Pump', (SELECT id FROM equipment_categories WHERE name = 'Pump')), + ('Diaphragm Pump', (SELECT id FROM equipment_categories WHERE name = 'Pump')), + ('Roller Pump', (SELECT id FROM equipment_categories WHERE name = 'Pump')), + ('Gear Pump', (SELECT id FROM equipment_categories WHERE name = 'Pump')), + -- Additional Aerators + ('Plug Aerator', (SELECT id FROM equipment_categories WHERE name = 'Aerator')), + -- Dethatchers + ('Vertical Mower', (SELECT id FROM equipment_categories WHERE name = 'Dethatcher')), + -- Scarifiers + ('Walk-behind Scarifier', (SELECT id FROM equipment_categories WHERE name = 'Scarifier')), + ('Tow-behind Scarifier', (SELECT id FROM equipment_categories WHERE name = 'Scarifier')), + -- Additional Trimmers + ('Brush Cutter', (SELECT id FROM equipment_categories WHERE name = 'Trimmer')), + ('Edger', (SELECT id FROM equipment_categories WHERE name = 'Trimmer')) +ON CONFLICT (name) DO NOTHING; + +-- Step 12: Create triggers for updated_at fields +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- Add trigger for user_nozzles if it doesn't exist +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_user_nozzles_updated_at') THEN + CREATE TRIGGER update_user_nozzles_updated_at + BEFORE UPDATE ON user_nozzles + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + END IF; +END +$$; + +-- Step 13: Create indexes for better performance +CREATE INDEX IF NOT EXISTS idx_user_equipment_category_id ON user_equipment(category_id); +CREATE INDEX IF NOT EXISTS idx_user_equipment_user_category ON user_equipment(user_id, category_id); +CREATE INDEX IF NOT EXISTS idx_equipment_types_category_id ON equipment_types(category_id); +CREATE INDEX IF NOT EXISTS idx_user_nozzles_user_id ON user_nozzles(user_id); +CREATE INDEX IF NOT EXISTS idx_user_nozzles_type_id ON user_nozzles(nozzle_type_id); +CREATE INDEX IF NOT EXISTS idx_equipment_nozzle_assignments_equipment ON equipment_nozzle_assignments(user_equipment_id); +CREATE INDEX IF NOT EXISTS idx_equipment_nozzle_assignments_nozzle ON equipment_nozzle_assignments(user_nozzle_id); + +-- Migration completed successfully +SELECT 'Equipment system migration completed successfully!' as migration_status; \ No newline at end of file diff --git a/frontend/src/pages/Equipment/Equipment.js b/frontend/src/pages/Equipment/Equipment.js index bde0968..b99ae6a 100644 --- a/frontend/src/pages/Equipment/Equipment.js +++ b/frontend/src/pages/Equipment/Equipment.js @@ -1,11 +1,936 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; +import { + PlusIcon, + MagnifyingGlassIcon, + FunnelIcon, + WrenchScrewdriverIcon, + TrashIcon, + PencilIcon, + EyeIcon +} from '@heroicons/react/24/outline'; +import { equipmentAPI, nozzlesAPI } from '../../services/api'; +import LoadingSpinner from '../../components/UI/LoadingSpinner'; +import toast from 'react-hot-toast'; const Equipment = () => { + const [equipment, setEquipment] = useState([]); + const [categories, setCategories] = useState([]); + const [equipmentTypes, setEquipmentTypes] = useState([]); + const [loading, setLoading] = useState(true); + const [showCreateForm, setShowCreateForm] = useState(false); + const [showEditForm, setShowEditForm] = useState(false); + const [editingEquipment, setEditingEquipment] = useState(null); + const [selectedCategory, setSelectedCategory] = useState(''); + const [searchTerm, setSearchTerm] = useState(''); + const [showInactive, setShowInactive] = useState(false); + const [activeTab, setActiveTab] = useState('all'); + + useEffect(() => { + fetchData(); + }, []); + + const fetchData = async () => { + try { + setLoading(true); + const [equipmentResponse, categoriesResponse, typesResponse] = await Promise.all([ + equipmentAPI.getAll({ + category_id: selectedCategory, + is_active: !showInactive + }), + equipmentAPI.getCategories(), + equipmentAPI.getTypes() + ]); + + setEquipment(equipmentResponse.data.data.equipment || []); + setCategories(categoriesResponse.data.data.categories || []); + setEquipmentTypes(typesResponse.data.data.equipmentTypes || []); + } catch (error) { + console.error('Failed to fetch equipment:', error); + toast.error('Failed to load equipment'); + setEquipment([]); + setCategories([]); + setEquipmentTypes([]); + } finally { + setLoading(false); + } + }; + + const handleSearch = (e) => { + setSearchTerm(e.target.value); + }; + + const handleFilterChange = () => { + fetchData(); + }; + + const handleCreateEquipment = async (equipmentData) => { + try { + await equipmentAPI.create(equipmentData); + toast.success('Equipment created successfully!'); + setShowCreateForm(false); + fetchData(); + } catch (error) { + console.error('Failed to create equipment:', error); + toast.error('Failed to create equipment'); + } + }; + + const handleEditEquipment = (equipment) => { + setEditingEquipment(equipment); + setShowEditForm(true); + }; + + const handleUpdateEquipment = async (equipmentData) => { + try { + await equipmentAPI.update(editingEquipment.id, equipmentData); + toast.success('Equipment updated successfully!'); + setShowEditForm(false); + setEditingEquipment(null); + fetchData(); + } catch (error) { + console.error('Failed to update equipment:', error); + toast.error('Failed to update equipment'); + } + }; + + const handleDeleteEquipment = async (equipmentId) => { + if (window.confirm('Are you sure you want to delete this equipment?')) { + try { + await equipmentAPI.delete(equipmentId); + toast.success('Equipment deleted successfully'); + fetchData(); + } catch (error) { + console.error('Failed to delete equipment:', error); + toast.error('Failed to delete equipment'); + } + } + }; + + // Filter equipment based on search term and active tab + const filteredEquipment = equipment.filter(item => { + const matchesSearch = searchTerm === '' || + item.customName?.toLowerCase().includes(searchTerm.toLowerCase()) || + item.typeName?.toLowerCase().includes(searchTerm.toLowerCase()) || + item.manufacturer?.toLowerCase().includes(searchTerm.toLowerCase()) || + item.model?.toLowerCase().includes(searchTerm.toLowerCase()); + + if (activeTab === 'all') return matchesSearch; + return matchesSearch && item.categoryName?.toLowerCase() === activeTab.toLowerCase(); + }); + + // Get unique category names for tabs + const categoryTabs = ['all', ...new Set(equipment.map(item => item.categoryName).filter(Boolean))]; + + const EquipmentCard = ({ item }) => ( +
+
+
+
+ +
+
+

+ {item.customName || item.typeName} +

+ {item.manufacturer && item.model && ( +

{item.manufacturer} {item.model}

+ )} + + {item.categoryName} + + {!item.isActive && ( + + Inactive + + )} +
+
+ +
+ + +
+
+ + {/* Equipment-specific details */} +
+ {renderEquipmentDetails(item)} +
+ + {item.notes && ( +

+ Notes: {item.notes} +

+ )} + + {item.purchaseDate && ( +

+ Purchased: {new Date(item.purchaseDate).toLocaleDateString()} +

+ )} +
+ ); + + const renderEquipmentDetails = (item) => { + switch (item.categoryName?.toLowerCase()) { + case 'mower': + return ( + <> + {item.mowerStyle &&

Style: {item.mowerStyle.replace('_', ' ')}

} + {item.cuttingWidthInches &&

Cutting Width: {item.cuttingWidthInches}"

} + {item.engineHp &&

Engine: {item.engineHp} HP

} + {item.fuelType &&

Fuel: {item.fuelType}

} + + ); + case 'spreader': + return ( + <> + {item.spreaderType &&

Type: {item.spreaderType.replace('_', ' ')}

} + {item.capacityLbs &&

Capacity: {item.capacityLbs} lbs

} + {item.spreadWidth &&

Spread Width: {item.spreadWidth} ft

} + + ); + case 'sprayer': + return ( + <> + {item.sprayerType &&

Type: {item.sprayerType.replace('_', ' ')}

} + {item.tankSizeGallons &&

Tank Size: {item.tankSizeGallons} gal

} + {item.sprayWidthFeet &&

Spray Width: {item.sprayWidthFeet} ft

} + {item.pumpGpm &&

Pump: {item.pumpGpm} GPM

} + {item.boomSections &&

Boom Sections: {item.boomSections}

} + + ); + case 'pump': + return ( + <> + {item.pumpType &&

Type: {item.pumpType}

} + {item.maxGpm &&

Max Flow: {item.maxGpm} GPM

} + {item.maxPsi &&

Max Pressure: {item.maxPsi} PSI

} + {item.powerSource &&

Power: {item.powerSource}

} + + ); + default: + return ( + <> + {item.toolType &&

Type: {item.toolType.replace('_', ' ')}

} + {item.workingWidthInches &&

Working Width: {item.workingWidthInches}"

} + + ); + } + }; + + const getCategoryColor = (categoryName) => { + const colors = { + 'Mower': 'bg-green-100 text-green-800', + 'Spreader': 'bg-orange-100 text-orange-800', + 'Sprayer': 'bg-blue-100 text-blue-800', + 'Pump': 'bg-purple-100 text-purple-800', + 'Aerator': 'bg-yellow-100 text-yellow-800', + 'Dethatcher': 'bg-red-100 text-red-800', + 'Scarifier': 'bg-pink-100 text-pink-800', + 'Trimmer': 'bg-indigo-100 text-indigo-800', + }; + return colors[categoryName] || 'bg-gray-100 text-gray-800'; + }; + + if (loading) { + return ( +
+
+ +
+
+ ); + } + return (
-

Equipment

-
-

Equipment management coming soon...

+
+
+

Equipment

+

Manage your lawn care equipment inventory

+
+ +
+ + {/* Search and Filters */} +
+
+ {/* Search */} +
+
+ + +
+
+ + {/* Category Filter */} +
+ +
+ + {/* Show Inactive Toggle */} +
+ +
+
+
+ + {/* Category Tabs */} +
+
+ +
+
+ + {/* Equipment Grid */} + {filteredEquipment.length === 0 ? ( +
+ +

No Equipment Found

+

+ {searchTerm || selectedCategory + ? 'Try adjusting your search or filters' + : 'Start building your equipment inventory' + } +

+ {!searchTerm && !selectedCategory && ( + + )} +
+ ) : ( +
+ {filteredEquipment.map((item) => ( + + ))} +
+ )} + + {/* Create Equipment Form Modal */} + {showCreateForm && ( + setShowCreateForm(false)} + /> + )} + + {/* Edit Equipment Form Modal */} + {showEditForm && editingEquipment && ( + { + setShowEditForm(false); + setEditingEquipment(null); + }} + /> + )} +
+ ); +}; + +// Equipment Form Modal Component (Create/Edit) +const EquipmentFormModal = ({ isEdit, equipment, categories, equipmentTypes, onSubmit, onCancel }) => { + const [formData, setFormData] = useState({ + equipmentTypeId: equipment?.equipmentTypeId || '', + categoryId: equipment?.categoryId || '', + customName: equipment?.customName || '', + manufacturer: equipment?.manufacturer || '', + model: equipment?.model || '', + // Spreader fields + capacityLbs: equipment?.capacityLbs || '', + spreaderType: equipment?.spreaderType || 'walk_behind', + spreadWidth: equipment?.spreadWidth || '', + // Sprayer fields + tankSizeGallons: equipment?.tankSizeGallons || '', + sprayerType: equipment?.sprayerType || 'walk_behind', + sprayWidthFeet: equipment?.sprayWidthFeet || '', + pumpGpm: equipment?.pumpGpm || '', + pumpPsi: equipment?.pumpPsi || '', + boomSections: equipment?.boomSections || '', + // Mower fields + mowerStyle: equipment?.mowerStyle || 'push', + cuttingWidthInches: equipment?.cuttingWidthInches || '', + engineHp: equipment?.engineHp || '', + fuelType: equipment?.fuelType || '', + // Tool fields + toolType: equipment?.toolType || 'walk_behind', + workingWidthInches: equipment?.workingWidthInches || '', + // Pump fields + pumpType: equipment?.pumpType || '', + maxGpm: equipment?.maxGpm || '', + maxPsi: equipment?.maxPsi || '', + powerSource: equipment?.powerSource || '', + // General fields + purchaseDate: equipment?.purchaseDate || '', + purchasePrice: equipment?.purchasePrice || '', + notes: equipment?.notes || '', + isActive: equipment?.isActive !== undefined ? equipment.isActive : true + }); + + const selectedCategory = categories.find(cat => cat.id === parseInt(formData.categoryId)); + + const handleSubmit = (e) => { + e.preventDefault(); + + if (!formData.categoryId && !formData.equipmentTypeId) { + toast.error('Please select a category or equipment type'); + return; + } + + if (!formData.customName && !formData.equipmentTypeId) { + toast.error('Please enter a name or select an equipment type'); + return; + } + + const submitData = { + equipmentTypeId: formData.equipmentTypeId ? parseInt(formData.equipmentTypeId) : null, + categoryId: formData.categoryId ? parseInt(formData.categoryId) : null, + customName: formData.customName || null, + manufacturer: formData.manufacturer || null, + model: formData.model || null, + capacityLbs: formData.capacityLbs ? parseFloat(formData.capacityLbs) : null, + spreaderType: formData.spreaderType || null, + spreadWidth: formData.spreadWidth ? parseFloat(formData.spreadWidth) : null, + tankSizeGallons: formData.tankSizeGallons ? parseFloat(formData.tankSizeGallons) : null, + sprayerType: formData.sprayerType || null, + sprayWidthFeet: formData.sprayWidthFeet ? parseFloat(formData.sprayWidthFeet) : null, + pumpGpm: formData.pumpGpm ? parseFloat(formData.pumpGpm) : null, + pumpPsi: formData.pumpPsi ? parseFloat(formData.pumpPsi) : null, + boomSections: formData.boomSections ? parseInt(formData.boomSections) : null, + mowerStyle: formData.mowerStyle || null, + cuttingWidthInches: formData.cuttingWidthInches ? parseFloat(formData.cuttingWidthInches) : null, + engineHp: formData.engineHp ? parseFloat(formData.engineHp) : null, + fuelType: formData.fuelType || null, + toolType: formData.toolType || null, + workingWidthInches: formData.workingWidthInches ? parseFloat(formData.workingWidthInches) : null, + pumpType: formData.pumpType || null, + maxGpm: formData.maxGpm ? parseFloat(formData.maxGpm) : null, + maxPsi: formData.maxPsi ? parseFloat(formData.maxPsi) : null, + powerSource: formData.powerSource || null, + purchaseDate: formData.purchaseDate || null, + purchasePrice: formData.purchasePrice ? parseFloat(formData.purchasePrice) : null, + notes: formData.notes || null, + isActive: formData.isActive + }; + + onSubmit(submitData); + }; + + const renderCategorySpecificFields = () => { + const categoryName = selectedCategory?.name?.toLowerCase(); + + switch (categoryName) { + case 'mower': + return ( + <> +
+
+ + +
+
+ + setFormData({ ...formData, cuttingWidthInches: e.target.value })} + placeholder="21" + /> +
+
+
+
+ + setFormData({ ...formData, engineHp: e.target.value })} + placeholder="6.5" + /> +
+
+ + +
+
+ + ); + + case 'spreader': + return ( + <> +
+
+ + +
+
+ + setFormData({ ...formData, capacityLbs: e.target.value })} + placeholder="50" + /> +
+
+
+ + setFormData({ ...formData, spreadWidth: e.target.value })} + placeholder="8" + /> +
+ + ); + + case 'sprayer': + return ( + <> +
+
+ + +
+
+ + setFormData({ ...formData, tankSizeGallons: e.target.value })} + placeholder="25" + /> +
+
+
+
+ + setFormData({ ...formData, sprayWidthFeet: e.target.value })} + placeholder="10" + /> +
+
+ + setFormData({ ...formData, pumpGpm: e.target.value })} + placeholder="2.5" + /> +
+
+ + setFormData({ ...formData, pumpPsi: e.target.value })} + placeholder="60" + /> +
+
+
+ + setFormData({ ...formData, boomSections: e.target.value })} + placeholder="3" + /> +
+ + ); + + case 'pump': + return ( + <> +
+
+ + setFormData({ ...formData, pumpType: e.target.value })} + placeholder="Centrifugal, Diaphragm, etc." + /> +
+
+ + +
+
+
+
+ + setFormData({ ...formData, maxGpm: e.target.value })} + placeholder="10" + /> +
+
+ + setFormData({ ...formData, maxPsi: e.target.value })} + placeholder="100" + /> +
+
+ + ); + + default: + return ( + <> +
+
+ + +
+
+ + setFormData({ ...formData, workingWidthInches: e.target.value })} + placeholder="18" + /> +
+
+ + ); + } + }; + + return ( +
+
+

+ {isEdit ? 'Edit Equipment' : 'Add New Equipment'} +

+ +
+ {/* Basic Information */} +
+
+ + +
+
+ + +
+
+ +
+ + setFormData({ ...formData, customName: e.target.value })} + placeholder="My Lawn Mower, Office Spreader, etc." + required={!formData.equipmentTypeId} + /> +
+ +
+
+ + setFormData({ ...formData, manufacturer: e.target.value })} + placeholder="Toro, John Deere, etc." + /> +
+
+ + setFormData({ ...formData, model: e.target.value })} + placeholder="Model number" + /> +
+
+ + {/* Category-specific fields */} + {formData.categoryId && renderCategorySpecificFields()} + + {/* Purchase Information */} +
+
+ + setFormData({ ...formData, purchaseDate: e.target.value })} + /> +
+
+ + setFormData({ ...formData, purchasePrice: e.target.value })} + placeholder="0.00" + /> +
+
+ + {/* Notes and Status */} +
+ +