diff --git a/backend/src/routes/nozzles.js b/backend/src/routes/nozzles.js index 80b16c0..193f0e2 100644 --- a/backend/src/routes/nozzles.js +++ b/backend/src/routes/nozzles.js @@ -7,51 +7,72 @@ const { AppError } = require('../middleware/errorHandler'); const router = express.Router(); // @route GET /api/nozzles/types -// @desc Get all nozzle types +// @desc Get all nozzle types with flow rate data // @access Private router.get('/types', async (req, res, next) => { try { - const { manufacturer, droplet_size, spray_pattern } = req.query; + const { manufacturer, droplet_size, spray_pattern, orifice_size } = req.query; - let query = 'SELECT * FROM nozzle_types WHERE 1=1'; + let query = ` + SELECT nt.*, + json_agg( + json_build_object( + 'pressure_psi', nfr.pressure_psi, + 'flow_rate_gpm', nfr.flow_rate_gpm + ) ORDER BY nfr.pressure_psi + ) FILTER (WHERE nfr.id IS NOT NULL) as flow_rates + FROM nozzle_types nt + LEFT JOIN nozzle_flow_rates nfr ON nt.id = nfr.nozzle_type_id + WHERE 1=1 + `; const queryParams = []; if (manufacturer) { queryParams.push(manufacturer); - query += ` AND manufacturer ILIKE $${queryParams.length}`; + query += ` AND nt.manufacturer ILIKE $${queryParams.length}`; } if (droplet_size) { queryParams.push(droplet_size); - query += ` AND droplet_size = $${queryParams.length}`; + query += ` AND nt.droplet_size = $${queryParams.length}`; } if (spray_pattern) { queryParams.push(spray_pattern); - query += ` AND spray_pattern = $${queryParams.length}`; + query += ` AND nt.spray_pattern = $${queryParams.length}`; + } + + if (orifice_size) { + queryParams.push(orifice_size); + query += ` AND nt.orifice_size = $${queryParams.length}`; } - query += ' ORDER BY manufacturer, name'; + query += ' GROUP BY nt.id ORDER BY nt.manufacturer, nt.name'; const result = await pool.query(query, queryParams); - // Group by manufacturer for easier frontend handling + // Group by manufacturer and droplet size for easier frontend handling const nozzlesByManufacturer = result.rows.reduce((acc, nozzle) => { const manufacturer = nozzle.manufacturer || 'Unknown'; if (!acc[manufacturer]) { - acc[manufacturer] = []; + acc[manufacturer] = {}; } - acc[manufacturer].push({ + if (!acc[manufacturer][nozzle.droplet_size]) { + acc[manufacturer][nozzle.droplet_size] = []; + } + acc[manufacturer][nozzle.droplet_size].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, + material: nozzle.material, + threadSize: nozzle.thread_size, + colorCode: nozzle.color_code, + flowRates: nozzle.flow_rates || [], createdAt: nozzle.created_at }); return acc; @@ -67,10 +88,12 @@ router.get('/types', async (req, res, next) => { 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, + material: nt.material, + threadSize: nt.thread_size, + colorCode: nt.color_code, + flowRates: nt.flow_rates || [], createdAt: nt.created_at })), nozzlesByManufacturer @@ -81,6 +104,63 @@ router.get('/types', async (req, res, next) => { } }); +// @route GET /api/nozzles/flow-rate/:nozzleTypeId/:pressurePsi +// @desc Get flow rate for specific nozzle at specific pressure +// @access Private +router.get('/flow-rate/:nozzleTypeId/:pressurePsi', validateParams(idParamSchema), async (req, res, next) => { + try { + const { nozzleTypeId, pressurePsi } = req.params; + + // Get exact match or interpolate between pressures + const result = await pool.query( + `SELECT nfr.pressure_psi, nfr.flow_rate_gpm, nt.name, nt.manufacturer + FROM nozzle_flow_rates nfr + JOIN nozzle_types nt ON nfr.nozzle_type_id = nt.id + WHERE nfr.nozzle_type_id = $1 + ORDER BY ABS(nfr.pressure_psi - $2) + LIMIT 2`, + [nozzleTypeId, pressurePsi] + ); + + if (result.rows.length === 0) { + throw new AppError('Flow rate data not available for this nozzle', 404); + } + + let flowRate; + if (result.rows.length === 1 || result.rows[0].pressure_psi === parseInt(pressurePsi)) { + // Exact match or only one data point + flowRate = parseFloat(result.rows[0].flow_rate_gpm); + } else { + // Interpolate between two points + const p1 = result.rows[0]; + const p2 = result.rows[1]; + const pressure = parseInt(pressurePsi); + + if (p1.pressure_psi === p2.pressure_psi) { + flowRate = parseFloat(p1.flow_rate_gpm); + } else { + // Linear interpolation + const slope = (p2.flow_rate_gpm - p1.flow_rate_gpm) / (p2.pressure_psi - p1.pressure_psi); + flowRate = parseFloat(p1.flow_rate_gpm) + slope * (pressure - p1.pressure_psi); + flowRate = Math.round(flowRate * 1000) / 1000; // Round to 3 decimal places + } + } + + res.json({ + success: true, + data: { + nozzleTypeId: parseInt(nozzleTypeId), + pressurePsi: parseInt(pressurePsi), + flowRateGpm: flowRate, + nozzleName: result.rows[0].name, + manufacturer: result.rows[0].manufacturer + } + }); + } catch (error) { + next(error); + } +}); + // @route GET /api/nozzles // @desc Get all user's nozzles // @access Private @@ -500,4 +580,250 @@ router.delete('/assignments/:assignmentId', validateParams(idParamSchema), async } }); +// PUMP ASSIGNMENT ROUTES + +// @route GET /api/nozzles/equipment/:equipmentId/pump +// @desc Get pump assigned to sprayer +// @access Private +router.get('/equipment/:equipmentId/pump', 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 epa.*, + pump_eq.custom_name as pump_name, pump_eq.manufacturer as pump_manufacturer, + pump_eq.model as pump_model, pump_eq.max_gpm, pump_eq.max_psi + FROM equipment_pump_assignments epa + JOIN user_equipment pump_eq ON epa.pump_equipment_id = pump_eq.id + WHERE epa.sprayer_equipment_id = $1 AND epa.is_active = true`, + [equipmentId] + ); + + res.json({ + success: true, + data: { + pumpAssignment: result.rows.length > 0 ? { + id: result.rows[0].id, + sprayerEquipmentId: result.rows[0].sprayer_equipment_id, + pumpEquipmentId: result.rows[0].pump_equipment_id, + pumpName: result.rows[0].pump_name, + pumpManufacturer: result.rows[0].pump_manufacturer, + pumpModel: result.rows[0].pump_model, + maxGpm: parseFloat(result.rows[0].max_gpm) || null, + maxPsi: parseFloat(result.rows[0].max_psi) || null, + assignedDate: result.rows[0].assigned_date, + notes: result.rows[0].notes, + isActive: result.rows[0].is_active + } : null + } + }); + } catch (error) { + next(error); + } +}); + +// @route POST /api/nozzles/equipment/:equipmentId/pump +// @desc Assign pump to sprayer +// @access Private +router.post('/equipment/:equipmentId/pump', validateParams(idParamSchema), async (req, res, next) => { + try { + const sprayerEquipmentId = req.params.equipmentId; + const { pumpEquipmentId, notes } = req.body; + + // Verify both equipment belong to user + const equipmentCheck = await pool.query( + 'SELECT id, category_id FROM user_equipment WHERE id IN ($1, $2) AND user_id = $3', + [sprayerEquipmentId, pumpEquipmentId, req.user.id] + ); + + if (equipmentCheck.rows.length !== 2) { + throw new AppError('Equipment not found', 404); + } + + // Deactivate any existing pump assignments for this sprayer + await pool.query( + 'UPDATE equipment_pump_assignments SET is_active = false WHERE sprayer_equipment_id = $1', + [sprayerEquipmentId] + ); + + // Create new assignment + const result = await pool.query( + `INSERT INTO equipment_pump_assignments + (sprayer_equipment_id, pump_equipment_id, notes) + VALUES ($1, $2, $3) + RETURNING *`, + [sprayerEquipmentId, pumpEquipmentId, notes] + ); + + const assignment = result.rows[0]; + + res.status(201).json({ + success: true, + message: 'Pump assigned to sprayer successfully', + data: { + assignment: { + id: assignment.id, + sprayerEquipmentId: assignment.sprayer_equipment_id, + pumpEquipmentId: assignment.pump_equipment_id, + assignedDate: assignment.assigned_date, + notes: assignment.notes, + isActive: assignment.is_active + } + } + }); + } catch (error) { + next(error); + } +}); + +// @route DELETE /api/nozzles/equipment/:equipmentId/pump +// @desc Remove pump assignment from sprayer +// @access Private +router.delete('/equipment/:equipmentId/pump', validateParams(idParamSchema), async (req, res, next) => { + try { + const sprayerEquipmentId = req.params.equipmentId; + + // Verify sprayer belongs to user + const equipmentCheck = await pool.query( + 'SELECT id FROM user_equipment WHERE id = $1 AND user_id = $2', + [sprayerEquipmentId, req.user.id] + ); + + if (equipmentCheck.rows.length === 0) { + throw new AppError('Equipment not found', 404); + } + + await pool.query( + 'UPDATE equipment_pump_assignments SET is_active = false WHERE sprayer_equipment_id = $1', + [sprayerEquipmentId] + ); + + res.json({ + success: true, + message: 'Pump assignment removed successfully' + }); + } catch (error) { + next(error); + } +}); + +// NOZZLE CONFIGURATION ROUTES + +// @route GET /api/nozzles/equipment/:equipmentId/configurations +// @desc Get nozzle configurations for equipment +// @access Private +router.get('/equipment/:equipmentId/configurations', 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 enc.*, + COUNT(cna.id) as nozzle_count + FROM equipment_nozzle_configurations enc + LEFT JOIN configuration_nozzle_assignments cna ON enc.id = cna.configuration_id + WHERE enc.user_equipment_id = $1 + GROUP BY enc.id + ORDER BY enc.is_default DESC, enc.configuration_name`, + [equipmentId] + ); + + res.json({ + success: true, + data: { + configurations: result.rows.map(config => ({ + id: config.id, + userEquipmentId: config.user_equipment_id, + configurationName: config.configuration_name, + isDefault: config.is_default, + nozzleCount: parseInt(config.nozzle_count), + createdAt: config.created_at, + updatedAt: config.updated_at + })) + } + }); + } catch (error) { + next(error); + } +}); + +// @route GET /api/nozzles/configurations/:configId/details +// @desc Get detailed nozzle configuration with assignments +// @access Private +router.get('/configurations/:configId/details', validateParams(idParamSchema), async (req, res, next) => { + try { + const configId = req.params.configId; + + // Verify configuration belongs to user's equipment + const configCheck = await pool.query( + `SELECT enc.* FROM equipment_nozzle_configurations enc + JOIN user_equipment ue ON enc.user_equipment_id = ue.id + WHERE enc.id = $1 AND ue.user_id = $2`, + [configId, req.user.id] + ); + + if (configCheck.rows.length === 0) { + throw new AppError('Configuration not found', 404); + } + + const result = await pool.query( + `SELECT cna.*, nt.name, nt.manufacturer, nt.orifice_size, nt.spray_angle, + nt.droplet_size, nt.spray_pattern, nt.color_code, + nfr.flow_rate_gpm + FROM configuration_nozzle_assignments cna + JOIN nozzle_types nt ON cna.nozzle_type_id = nt.id + LEFT JOIN nozzle_flow_rates nfr ON nt.id = nfr.nozzle_type_id AND nfr.pressure_psi = cna.operating_pressure_psi + WHERE cna.configuration_id = $1 + ORDER BY cna.position`, + [configId] + ); + + res.json({ + success: true, + data: { + configuration: configCheck.rows[0], + assignments: result.rows.map(item => ({ + id: item.id, + configurationId: item.configuration_id, + nozzleTypeId: item.nozzle_type_id, + position: item.position, + quantity: item.quantity, + operatingPressurePsi: item.operating_pressure_psi, + notes: item.notes, + nozzleName: item.name, + manufacturer: item.manufacturer, + orificeSize: item.orifice_size, + sprayAngle: item.spray_angle, + dropletSize: item.droplet_size, + sprayPattern: item.spray_pattern, + colorCode: item.color_code, + flowRateGpm: parseFloat(item.flow_rate_gpm) || null, + createdAt: item.created_at + })) + } + }); + } catch (error) { + next(error); + } +}); + module.exports = router; \ No newline at end of file diff --git a/database/migrations/fix_sprayer_pump_nozzle_relationships.sql b/database/migrations/fix_sprayer_pump_nozzle_relationships.sql new file mode 100644 index 0000000..2ef75bb --- /dev/null +++ b/database/migrations/fix_sprayer_pump_nozzle_relationships.sql @@ -0,0 +1,269 @@ +-- Fix Sprayer-Pump-Nozzle Relationships +-- This creates proper equipment connections and comprehensive nozzle database + +-- Step 1: Create equipment_pump_assignments table (sprayers can have pumps) +CREATE TABLE IF NOT EXISTS equipment_pump_assignments ( + id SERIAL PRIMARY KEY, + sprayer_equipment_id INTEGER REFERENCES user_equipment(id) ON DELETE CASCADE, + pump_equipment_id INTEGER REFERENCES user_equipment(id) ON DELETE CASCADE, + assigned_date DATE DEFAULT CURRENT_DATE, + is_active BOOLEAN DEFAULT true, + notes TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(sprayer_equipment_id, pump_equipment_id) +); + +-- Step 2: Update nozzle_types table with comprehensive flow rate data +DROP TABLE IF EXISTS nozzle_types CASCADE; +CREATE TABLE nozzle_types ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL UNIQUE, + manufacturer VARCHAR(100) NOT NULL, + model VARCHAR(100), + orifice_size VARCHAR(20) NOT NULL, + spray_angle INTEGER NOT NULL, + droplet_size VARCHAR(50) NOT NULL, -- fine, medium, coarse, very_coarse, extremely_coarse + spray_pattern VARCHAR(50) NOT NULL, -- flat_fan, hollow_cone, full_cone, flooding + material VARCHAR(50), -- ceramic, stainless_steel, hardened_steel, polymer + thread_size VARCHAR(20), -- 1/4", 3/8", 1/2" + color_code VARCHAR(20), -- TeeJet color coding + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Step 3: Create nozzle flow rate data table (pressure vs flow rate charts) +CREATE TABLE IF NOT EXISTS nozzle_flow_rates ( + id SERIAL PRIMARY KEY, + nozzle_type_id INTEGER REFERENCES nozzle_types(id) ON DELETE CASCADE, + pressure_psi INTEGER NOT NULL, + flow_rate_gpm DECIMAL(6, 3) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(nozzle_type_id, pressure_psi) +); + +-- Step 4: Insert comprehensive TeeJet nozzle database +INSERT INTO nozzle_types (name, manufacturer, model, orifice_size, spray_angle, droplet_size, spray_pattern, material, thread_size, color_code) VALUES +-- XR Extended Range Flat Fan Nozzles (Fine droplets) +('XR8001', 'TeeJet', 'XR8001VK', '01', 80, 'fine', 'flat_fan', 'stainless_steel', '1/4"', 'Orange'), +('XR8002', 'TeeJet', 'XR8002VK', '02', 80, 'fine', 'flat_fan', 'stainless_steel', '1/4"', 'Yellow'), +('XR8003', 'TeeJet', 'XR8003VK', '03', 80, 'fine', 'flat_fan', 'stainless_steel', '1/4"', 'Blue'), +('XR8004', 'TeeJet', 'XR8004VK', '04', 80, 'fine', 'flat_fan', 'stainless_steel', '1/4"', 'Red'), +('XR8005', 'TeeJet', 'XR8005VK', '05', 80, 'fine', 'flat_fan', 'stainless_steel', '1/4"', 'Brown'), +('XR8006', 'TeeJet', 'XR8006VK', '06', 80, 'fine', 'flat_fan', 'stainless_steel', '1/4"', 'Gray'), +('XR8008', 'TeeJet', 'XR8008VK', '08', 80, 'fine', 'flat_fan', 'stainless_steel', '1/4"', 'White'), + +-- AIXR Air Induction Extended Range (Medium droplets - drift reduction) +('AIXR11001', 'TeeJet', 'AIXR11001', '01', 110, 'medium', 'flat_fan', 'polymer', '1/4"', 'Orange'), +('AIXR11002', 'TeeJet', 'AIXR11002', '02', 110, 'medium', 'flat_fan', 'polymer', '1/4"', 'Yellow'), +('AIXR11003', 'TeeJet', 'AIXR11003', '03', 110, 'medium', 'flat_fan', 'polymer', '1/4"', 'Blue'), +('AIXR11004', 'TeeJet', 'AIXR11004', '04', 110, 'medium', 'flat_fan', 'polymer', '1/4"', 'Red'), +('AIXR11005', 'TeeJet', 'AIXR11005', '05', 110, 'medium', 'flat_fan', 'polymer', '1/4"', 'Brown'), +('AIXR11006', 'TeeJet', 'AIXR11006', '06', 110, 'medium', 'flat_fan', 'polymer', '1/4"', 'Gray'), +('AIXR11008', 'TeeJet', 'AIXR11008', '08', 110, 'medium', 'flat_fan', 'polymer', '1/4"', 'White'), + +-- TTI TurboDrop (Very coarse droplets - maximum drift reduction) +('TTI11002', 'TeeJet', 'TTI11002', '02', 110, 'very_coarse', 'flat_fan', 'polymer', '1/4"', 'Yellow'), +('TTI11003', 'TeeJet', 'TTI11003', '03', 110, 'very_coarse', 'flat_fan', 'polymer', '1/4"', 'Blue'), +('TTI11004', 'TeeJet', 'TTI11004', '04', 110, 'very_coarse', 'flat_fan', 'polymer', '1/4"', 'Red'), +('TTI11005', 'TeeJet', 'TTI11005', '05', 110, 'very_coarse', 'flat_fan', 'polymer', '1/4"', 'Brown'), +('TTI11006', 'TeeJet', 'TTI11006', '06', 110, 'very_coarse', 'flat_fan', 'polymer', '1/4"', 'Gray'), + +-- AI TeeJet Air Induction (Coarse droplets) +('AI11002', 'TeeJet', 'AI11002-VS', '02', 110, 'coarse', 'flat_fan', 'stainless_steel', '1/4"', 'Yellow'), +('AI11003', 'TeeJet', 'AI11003-VS', '03', 110, 'coarse', 'flat_fan', 'stainless_steel', '1/4"', 'Blue'), +('AI11004', 'TeeJet', 'AI11004-VS', '04', 110, 'coarse', 'flat_fan', 'stainless_steel', '1/4"', 'Red'), +('AI11005', 'TeeJet', 'AI11005-VS', '05', 110, 'coarse', 'flat_fan', 'stainless_steel', '1/4"', 'Brown'), + +-- DG DropGuard (Extremely coarse droplets) +('DG11002', 'TeeJet', 'DG11002-VS', '02', 110, 'extremely_coarse', 'flat_fan', 'stainless_steel', '1/4"', 'Yellow'), +('DG11003', 'TeeJet', 'DG11003-VS', '03', 110, 'extremely_coarse', 'flat_fan', 'stainless_steel', '1/4"', 'Blue'), +('DG11004', 'TeeJet', 'DG11004-VS', '04', 110, 'extremely_coarse', 'flat_fan', 'stainless_steel', '1/4"', 'Red'), + +-- Hollow Cone Nozzles +('D2-23', 'TeeJet', 'D2-23', '2', 80, 'fine', 'hollow_cone', 'hardened_steel', '1/4"', 'Yellow'), +('D3-25', 'TeeJet', 'D3-25', '3', 80, 'fine', 'hollow_cone', 'hardened_steel', '1/4"', 'Blue'), +('D4-25', 'TeeJet', 'D4-25', '4', 80, 'fine', 'hollow_cone', 'hardened_steel', '1/4"', 'Red'), + +-- Full Cone Nozzles +('TXA8002VK', 'TeeJet', 'TXA8002VK', '02', 80, 'medium', 'full_cone', 'stainless_steel', '1/4"', 'Yellow'), +('TXA8004VK', 'TeeJet', 'TXA8004VK', '04', 80, 'medium', 'full_cone', 'stainless_steel', '1/4"', 'Red'), +('TXA8006VK', 'TeeJet', 'TXA8006VK', '06', 80, 'medium', 'full_cone', 'stainless_steel', '1/4"', 'Gray'); + +-- Step 5: Insert flow rate data based on TeeJet charts +-- XR Series Flow Rates (GPM at different PSI) +INSERT INTO nozzle_flow_rates (nozzle_type_id, pressure_psi, flow_rate_gpm) VALUES +-- XR8001 +((SELECT id FROM nozzle_types WHERE name = 'XR8001'), 15, 0.10), +((SELECT id FROM nozzle_types WHERE name = 'XR8001'), 20, 0.11), +((SELECT id FROM nozzle_types WHERE name = 'XR8001'), 30, 0.14), +((SELECT id FROM nozzle_types WHERE name = 'XR8001'), 40, 0.16), +((SELECT id FROM nozzle_types WHERE name = 'XR8001'), 60, 0.20), +((SELECT id FROM nozzle_types WHERE name = 'XR8001'), 80, 0.23), + +-- XR8002 +((SELECT id FROM nozzle_types WHERE name = 'XR8002'), 15, 0.20), +((SELECT id FROM nozzle_types WHERE name = 'XR8002'), 20, 0.23), +((SELECT id FROM nozzle_types WHERE name = 'XR8002'), 30, 0.28), +((SELECT id FROM nozzle_types WHERE name = 'XR8002'), 40, 0.32), +((SELECT id FROM nozzle_types WHERE name = 'XR8002'), 60, 0.39), +((SELECT id FROM nozzle_types WHERE name = 'XR8002'), 80, 0.45), + +-- XR8003 +((SELECT id FROM nozzle_types WHERE name = 'XR8003'), 15, 0.30), +((SELECT id FROM nozzle_types WHERE name = 'XR8003'), 20, 0.35), +((SELECT id FROM nozzle_types WHERE name = 'XR8003'), 30, 0.42), +((SELECT id FROM nozzle_types WHERE name = 'XR8003'), 40, 0.49), +((SELECT id FROM nozzle_types WHERE name = 'XR8003'), 60, 0.60), +((SELECT id FROM nozzle_types WHERE name = 'XR8003'), 80, 0.69), + +-- XR8004 +((SELECT id FROM nozzle_types WHERE name = 'XR8004'), 15, 0.40), +((SELECT id FROM nozzle_types WHERE name = 'XR8004'), 20, 0.46), +((SELECT id FROM nozzle_types WHERE name = 'XR8004'), 30, 0.56), +((SELECT id FROM nozzle_types WHERE name = 'XR8004'), 40, 0.65), +((SELECT id FROM nozzle_types WHERE name = 'XR8004'), 60, 0.80), +((SELECT id FROM nozzle_types WHERE name = 'XR8004'), 80, 0.92), + +-- XR8005 +((SELECT id FROM nozzle_types WHERE name = 'XR8005'), 15, 0.50), +((SELECT id FROM nozzle_types WHERE name = 'XR8005'), 20, 0.58), +((SELECT id FROM nozzle_types WHERE name = 'XR8005'), 30, 0.71), +((SELECT id FROM nozzle_types WHERE name = 'XR8005'), 40, 0.82), +((SELECT id FROM nozzle_types WHERE name = 'XR8005'), 60, 1.00), +((SELECT id FROM nozzle_types WHERE name = 'XR8005'), 80, 1.15), + +-- XR8006 +((SELECT id FROM nozzle_types WHERE name = 'XR8006'), 15, 0.60), +((SELECT id FROM nozzle_types WHERE name = 'XR8006'), 20, 0.69), +((SELECT id FROM nozzle_types WHERE name = 'XR8006'), 30, 0.85), +((SELECT id FROM nozzle_types WHERE name = 'XR8006'), 40, 0.98), +((SELECT id FROM nozzle_types WHERE name = 'XR8006'), 60, 1.20), +((SELECT id FROM nozzle_types WHERE name = 'XR8006'), 80, 1.38), + +-- XR8008 +((SELECT id FROM nozzle_types WHERE name = 'XR8008'), 15, 0.80), +((SELECT id FROM nozzle_types WHERE name = 'XR8008'), 20, 0.92), +((SELECT id FROM nozzle_types WHERE name = 'XR8008'), 30, 1.13), +((SELECT id FROM nozzle_types WHERE name = 'XR8008'), 40, 1.30), +((SELECT id FROM nozzle_types WHERE name = 'XR8008'), 60, 1.60), +((SELECT id FROM nozzle_types WHERE name = 'XR8008'), 80, 1.84), + +-- AIXR Series Flow Rates +-- AIXR11002 +((SELECT id FROM nozzle_types WHERE name = 'AIXR11002'), 15, 0.20), +((SELECT id FROM nozzle_types WHERE name = 'AIXR11002'), 20, 0.23), +((SELECT id FROM nozzle_types WHERE name = 'AIXR11002'), 30, 0.28), +((SELECT id FROM nozzle_types WHERE name = 'AIXR11002'), 40, 0.32), +((SELECT id FROM nozzle_types WHERE name = 'AIXR11002'), 60, 0.39), +((SELECT id FROM nozzle_types WHERE name = 'AIXR11002'), 80, 0.45), + +-- AIXR11003 +((SELECT id FROM nozzle_types WHERE name = 'AIXR11003'), 15, 0.30), +((SELECT id FROM nozzle_types WHERE name = 'AIXR11003'), 20, 0.35), +((SELECT id FROM nozzle_types WHERE name = 'AIXR11003'), 30, 0.42), +((SELECT id FROM nozzle_types WHERE name = 'AIXR11003'), 40, 0.49), +((SELECT id FROM nozzle_types WHERE name = 'AIXR11003'), 60, 0.60), +((SELECT id FROM nozzle_types WHERE name = 'AIXR11003'), 80, 0.69), + +-- AIXR11004 +((SELECT id FROM nozzle_types WHERE name = 'AIXR11004'), 15, 0.40), +((SELECT id FROM nozzle_types WHERE name = 'AIXR11004'), 20, 0.46), +((SELECT id FROM nozzle_types WHERE name = 'AIXR11004'), 30, 0.56), +((SELECT id FROM nozzle_types WHERE name = 'AIXR11004'), 40, 0.65), +((SELECT id FROM nozzle_types WHERE name = 'AIXR11004'), 60, 0.80), +((SELECT id FROM nozzle_types WHERE name = 'AIXR11004'), 80, 0.92), + +-- AIXR11005 +((SELECT id FROM nozzle_types WHERE name = 'AIXR11005'), 15, 0.50), +((SELECT id FROM nozzle_types WHERE name = 'AIXR11005'), 20, 0.58), +((SELECT id FROM nozzle_types WHERE name = 'AIXR11005'), 30, 0.71), +((SELECT id FROM nozzle_types WHERE name = 'AIXR11005'), 40, 0.82), +((SELECT id FROM nozzle_types WHERE name = 'AIXR11005'), 60, 1.00), +((SELECT id FROM nozzle_types WHERE name = 'AIXR11005'), 80, 1.15), + +-- TTI Series Flow Rates (similar to AIXR but slightly different due to design) +-- TTI11002 +((SELECT id FROM nozzle_types WHERE name = 'TTI11002'), 15, 0.18), +((SELECT id FROM nozzle_types WHERE name = 'TTI11002'), 20, 0.21), +((SELECT id FROM nozzle_types WHERE name = 'TTI11002'), 30, 0.26), +((SELECT id FROM nozzle_types WHERE name = 'TTI11002'), 40, 0.30), +((SELECT id FROM nozzle_types WHERE name = 'TTI11002'), 60, 0.37), +((SELECT id FROM nozzle_types WHERE name = 'TTI11002'), 80, 0.42), + +-- TTI11003 +((SELECT id FROM nozzle_types WHERE name = 'TTI11003'), 15, 0.28), +((SELECT id FROM nozzle_types WHERE name = 'TTI11003'), 20, 0.32), +((SELECT id FROM nozzle_types WHERE name = 'TTI11003'), 30, 0.39), +((SELECT id FROM nozzle_types WHERE name = 'TTI11003'), 40, 0.45), +((SELECT id FROM nozzle_types WHERE name = 'TTI11003'), 60, 0.55), +((SELECT id FROM nozzle_types WHERE name = 'TTI11003'), 80, 0.64); + +-- Step 6: Update user_nozzles to include current assignment info +ALTER TABLE user_nozzles +ADD COLUMN IF NOT EXISTS current_equipment_id INTEGER REFERENCES user_equipment(id), +ADD COLUMN IF NOT EXISTS current_position VARCHAR(50), +ADD COLUMN IF NOT EXISTS last_assigned_date DATE; + +-- Step 7: Create equipment_nozzle_configurations table (for application planning) +CREATE TABLE IF NOT EXISTS equipment_nozzle_configurations ( + id SERIAL PRIMARY KEY, + user_equipment_id INTEGER REFERENCES user_equipment(id) ON DELETE CASCADE, + configuration_name VARCHAR(255) NOT NULL, + is_default BOOLEAN DEFAULT false, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Step 8: Create configuration_nozzle_assignments (specific nozzle setups) +CREATE TABLE IF NOT EXISTS configuration_nozzle_assignments ( + id SERIAL PRIMARY KEY, + configuration_id INTEGER REFERENCES equipment_nozzle_configurations(id) ON DELETE CASCADE, + nozzle_type_id INTEGER REFERENCES nozzle_types(id) ON DELETE CASCADE, + position VARCHAR(50) NOT NULL, -- boom_left_1, boom_right_1, center, etc. + quantity INTEGER DEFAULT 1, + operating_pressure_psi INTEGER, + notes TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(configuration_id, position) +); + +-- Step 9: Add triggers for updated_at +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_equipment_pump_assignments_updated_at') THEN + CREATE TRIGGER update_equipment_pump_assignments_updated_at + BEFORE UPDATE ON equipment_pump_assignments + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_equipment_nozzle_configurations_updated_at') THEN + CREATE TRIGGER update_equipment_nozzle_configurations_updated_at + BEFORE UPDATE ON equipment_nozzle_configurations + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + END IF; +END $$; + +-- Step 10: Create indexes +CREATE INDEX IF NOT EXISTS idx_equipment_pump_assignments_sprayer ON equipment_pump_assignments(sprayer_equipment_id); +CREATE INDEX IF NOT EXISTS idx_equipment_pump_assignments_pump ON equipment_pump_assignments(pump_equipment_id); +CREATE INDEX IF NOT EXISTS idx_nozzle_flow_rates_type ON nozzle_flow_rates(nozzle_type_id); +CREATE INDEX IF NOT EXISTS idx_nozzle_flow_rates_pressure ON nozzle_flow_rates(pressure_psi); +CREATE INDEX IF NOT EXISTS idx_user_nozzles_equipment ON user_nozzles(current_equipment_id); +CREATE INDEX IF NOT EXISTS idx_configuration_nozzle_assignments_config ON configuration_nozzle_assignments(configuration_id); +CREATE INDEX IF NOT EXISTS idx_configuration_nozzle_assignments_nozzle ON configuration_nozzle_assignments(nozzle_type_id); + +-- Migration completed successfully +SELECT 'Sprayer-Pump-Nozzle relationship 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 b99ae6a..b662c94 100644 --- a/frontend/src/pages/Equipment/Equipment.js +++ b/frontend/src/pages/Equipment/Equipment.js @@ -24,6 +24,10 @@ const Equipment = () => { const [searchTerm, setSearchTerm] = useState(''); const [showInactive, setShowInactive] = useState(false); const [activeTab, setActiveTab] = useState('all'); + const [showPumpAssignments, setShowPumpAssignments] = useState(false); + const [showNozzleConfigs, setShowNozzleConfigs] = useState(false); + const [selectedSprayerForPump, setSelectedSprayerForPump] = useState(null); + const [selectedSprayerForNozzles, setSelectedSprayerForNozzles] = useState(null); useEffect(() => { fetchData(); @@ -106,6 +110,16 @@ const Equipment = () => { } }; + const handleManagePumpAssignments = (sprayer) => { + setSelectedSprayerForPump(sprayer); + setShowPumpAssignments(true); + }; + + const handleManageNozzleConfigs = (sprayer) => { + setSelectedSprayerForNozzles(sprayer); + setShowNozzleConfigs(true); + }; + // Filter equipment based on search term and active tab const filteredEquipment = equipment.filter(item => { const matchesSearch = searchTerm === '' || @@ -149,6 +163,24 @@ const Equipment = () => {
+ {item.categoryName === 'Sprayer' && ( + <> + + + + )}
); }; @@ -936,4 +993,333 @@ const EquipmentFormModal = ({ isEdit, equipment, categories, equipmentTypes, onS ); }; +// Pump Assignment Modal Component +const PumpAssignmentModal = ({ sprayer, equipment, onClose }) => { + const [assignments, setAssignments] = useState([]); + const [availablePumps, setAvailablePumps] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedPump, setSelectedPump] = useState(''); + + useEffect(() => { + fetchPumpAssignments(); + }, [sprayer.id]); + + const fetchPumpAssignments = async () => { + try { + setLoading(true); + const [assignmentsResponse, availablePumpsResponse] = await Promise.all([ + nozzlesAPI.getPumpAssignments(sprayer.id), + Promise.resolve({ data: { data: { pumps: equipment } } }) + ]); + + setAssignments(assignmentsResponse.data.data.assignments || []); + setAvailablePumps(availablePumpsResponse.data.data.pumps || []); + } catch (error) { + console.error('Failed to fetch pump assignments:', error); + toast.error('Failed to load pump assignments'); + } finally { + setLoading(false); + } + }; + + const handleAssignPump = async () => { + if (!selectedPump) return; + + try { + await nozzlesAPI.assignPump(sprayer.id, selectedPump); + toast.success('Pump assigned successfully'); + setSelectedPump(''); + fetchPumpAssignments(); + } catch (error) { + console.error('Failed to assign pump:', error); + toast.error('Failed to assign pump'); + } + }; + + const handleUnassignPump = async (assignmentId) => { + try { + await nozzlesAPI.unassignPump(assignmentId); + toast.success('Pump unassigned successfully'); + fetchPumpAssignments(); + } catch (error) { + console.error('Failed to unassign pump:', error); + toast.error('Failed to unassign pump'); + } + }; + + return ( +
+
+

+ Pump Assignments - {sprayer.customName || sprayer.typeName} +

+ + {loading ? ( + + ) : ( + <> + {/* Add New Pump Assignment */} +
+

Assign New Pump

+
+ + +
+
+ + {/* Current Assignments */} +
+

Current Pump Assignments

+ {assignments.length === 0 ? ( +

No pumps assigned to this sprayer

+ ) : ( + assignments.map(assignment => ( +
+
+
+ {assignment.pump?.customName || assignment.pump?.typeName} +
+ {assignment.pump?.manufacturer && ( +
+ {assignment.pump.manufacturer} {assignment.pump.model} +
+ )} + {assignment.pump?.maxGpm && ( +
+ Max Flow: {assignment.pump.maxGpm} GPM +
+ )} +
+ Assigned: {new Date(assignment.assignedDate).toLocaleDateString()} +
+
+ +
+ )) + )} +
+ + )} + +
+ +
+
+
+ ); +}; + +// Nozzle Configuration Modal Component +const NozzleConfigurationModal = ({ sprayer, onClose }) => { + const [configurations, setConfigurations] = useState([]); + const [nozzleTypes, setNozzleTypes] = useState([]); + const [userNozzles, setUserNozzles] = useState([]); + const [loading, setLoading] = useState(true); + const [showAddConfig, setShowAddConfig] = useState(false); + const [configForm, setConfigForm] = useState({ + userNozzleId: '', + position: '', + quantityAssigned: 1 + }); + + useEffect(() => { + fetchNozzleConfigs(); + }, [sprayer.id]); + + const fetchNozzleConfigs = async () => { + try { + setLoading(true); + const [configsResponse, typesResponse, userNozzlesResponse] = await Promise.all([ + nozzlesAPI.getNozzleConfigurations(sprayer.id), + nozzlesAPI.getNozzleTypes(), + nozzlesAPI.getUserNozzles() + ]); + + setConfigurations(configsResponse.data.data.configurations || []); + setNozzleTypes(typesResponse.data.data.nozzleTypes || []); + setUserNozzles(userNozzlesResponse.data.data.userNozzles || []); + } catch (error) { + console.error('Failed to fetch nozzle configurations:', error); + toast.error('Failed to load nozzle configurations'); + } finally { + setLoading(false); + } + }; + + const handleAddConfiguration = async () => { + try { + await nozzlesAPI.addNozzleConfiguration(sprayer.id, configForm); + toast.success('Nozzle configuration added'); + setShowAddConfig(false); + setConfigForm({ userNozzleId: '', position: '', quantityAssigned: 1 }); + fetchNozzleConfigs(); + } catch (error) { + console.error('Failed to add configuration:', error); + toast.error('Failed to add configuration'); + } + }; + + const handleRemoveConfiguration = async (configId) => { + try { + await nozzlesAPI.removeNozzleConfiguration(configId); + toast.success('Nozzle configuration removed'); + fetchNozzleConfigs(); + } catch (error) { + console.error('Failed to remove configuration:', error); + toast.error('Failed to remove configuration'); + } + }; + + return ( +
+
+

+ Nozzle Configuration - {sprayer.customName || sprayer.typeName} +

+ + {loading ? ( + + ) : ( + <> + {/* Add Configuration Button */} +
+ +
+ + {/* Add Configuration Form */} + {showAddConfig && ( +
+

Add Nozzle Configuration

+
+ + setConfigForm({ ...configForm, position: e.target.value })} + /> + setConfigForm({ ...configForm, quantityAssigned: parseInt(e.target.value) })} + /> +
+
+ + +
+
+ )} + + {/* Current Configurations */} +
+

Current Nozzle Configurations

+ {configurations.length === 0 ? ( +

No nozzles configured for this sprayer

+ ) : ( +
+ {configurations.map(config => ( +
+
+
+ {config.userNozzle?.nozzleType?.name} +
+ +
+
+
Position: {config.position}
+
Quantity: {config.quantityAssigned}
+ {config.userNozzle?.nozzleType?.manufacturer && ( +
Manufacturer: {config.userNozzle.nozzleType.manufacturer}
+ )} + {config.userNozzle?.nozzleType?.orificeSize && ( +
Orifice: {config.userNozzle.nozzleType.orificeSize}
+ )} + {config.userNozzle?.nozzleType?.sprayAngle && ( +
Spray Angle: {config.userNozzle.nozzleType.sprayAngle}°
+ )} + {config.userNozzle?.nozzleType?.dropletSize && ( +
Droplet Size: {config.userNozzle.nozzleType.dropletSize}
+ )} +
+ Assigned: {new Date(config.assignedDate).toLocaleDateString()} +
+
+
+ ))} +
+ )} +
+ + )} + +
+ +
+
+
+ ); +}; + export default Equipment; \ No newline at end of file diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index fb0c4fb..323dc71 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -109,8 +109,22 @@ export const nozzlesAPI = { create: (nozzleData) => apiClient.post('/nozzles', nozzleData), update: (id, nozzleData) => apiClient.put(`/nozzles/${id}`, nozzleData), delete: (id) => apiClient.delete(`/nozzles/${id}`), + getNozzleTypes: () => apiClient.get('/nozzles/types'), + getUserNozzles: () => apiClient.get('/nozzles/user'), + calculateFlowRate: (nozzleId, pressure) => apiClient.get(`/nozzles/${nozzleId}/flow-rate`, { params: { pressure } }), + + // Pump assignments + getPumpAssignments: (sprayerId) => apiClient.get(`/nozzles/sprayer/${sprayerId}/pump-assignments`), + assignPump: (sprayerId, pumpId) => apiClient.post(`/nozzles/sprayer/${sprayerId}/pump-assignments`, { pumpId }), + unassignPump: (assignmentId) => apiClient.delete(`/nozzles/pump-assignments/${assignmentId}`), + + // Nozzle configurations + getNozzleConfigurations: (sprayerId) => apiClient.get(`/nozzles/sprayer/${sprayerId}/nozzle-configurations`), + addNozzleConfiguration: (sprayerId, configData) => apiClient.post(`/nozzles/sprayer/${sprayerId}/nozzle-configurations`, configData), + removeNozzleConfiguration: (configId) => apiClient.delete(`/nozzles/nozzle-configurations/${configId}`), + + // Legacy endpoints for compatibility getTypes: (params) => apiClient.get('/nozzles/types', { params }), - // Equipment-nozzle assignments getAssignments: (equipmentId) => apiClient.get(`/nozzles/equipment/${equipmentId}/assignments`), assignToEquipment: (equipmentId, assignmentData) => apiClient.post(`/nozzles/equipment/${equipmentId}/assignments`, assignmentData), removeAssignment: (assignmentId) => apiClient.delete(`/nozzles/assignments/${assignmentId}`),