update equipment stuff

This commit is contained in:
Jake Kasper
2025-08-22 09:41:59 -04:00
parent 96fe83412a
commit f20c23ffc7
4 changed files with 1010 additions and 15 deletions

View File

@@ -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}`;
}
query += ' ORDER BY manufacturer, name';
if (orifice_size) {
queryParams.push(orifice_size);
query += ` AND nt.orifice_size = $${queryParams.length}`;
}
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;

View File

@@ -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;

View File

@@ -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 = () => {
</div>
<div className="flex gap-1">
{item.categoryName === 'Sprayer' && (
<>
<button
onClick={() => handleManagePumpAssignments(item)}
className="p-1 text-gray-400 hover:text-purple-600"
title="Manage pump assignments"
>
<WrenchScrewdriverIcon className="h-4 w-4" />
</button>
<button
onClick={() => handleManageNozzleConfigs(item)}
className="p-1 text-gray-400 hover:text-green-600"
title="Configure nozzles"
>
<EyeIcon className="h-4 w-4" />
</button>
</>
)}
<button
onClick={() => handleEditEquipment(item)}
className="p-1 text-gray-400 hover:text-blue-600"
@@ -412,6 +444,31 @@ const Equipment = () => {
}}
/>
)}
{/* Pump Assignment Modal */}
{showPumpAssignments && selectedSprayerForPump && (
<PumpAssignmentModal
sprayer={selectedSprayerForPump}
equipment={equipment.filter(e => e.categoryName === 'Pump')}
onClose={() => {
setShowPumpAssignments(false);
setSelectedSprayerForPump(null);
fetchData();
}}
/>
)}
{/* Nozzle Configuration Modal */}
{showNozzleConfigs && selectedSprayerForNozzles && (
<NozzleConfigurationModal
sprayer={selectedSprayerForNozzles}
onClose={() => {
setShowNozzleConfigs(false);
setSelectedSprayerForNozzles(null);
fetchData();
}}
/>
)}
</div>
);
};
@@ -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 (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<h3 className="text-lg font-semibold mb-4">
Pump Assignments - {sprayer.customName || sprayer.typeName}
</h3>
{loading ? (
<LoadingSpinner />
) : (
<>
{/* Add New Pump Assignment */}
<div className="mb-6 p-4 bg-gray-50 rounded-lg">
<h4 className="font-medium mb-3">Assign New Pump</h4>
<div className="flex gap-3">
<select
className="input flex-1"
value={selectedPump}
onChange={(e) => setSelectedPump(e.target.value)}
>
<option value="">Select a pump...</option>
{availablePumps.map(pump => (
<option key={pump.id} value={pump.id}>
{pump.customName || pump.typeName}
{pump.manufacturer && ` - ${pump.manufacturer}`}
{pump.maxGpm && ` (${pump.maxGpm} GPM)`}
</option>
))}
</select>
<button
onClick={handleAssignPump}
disabled={!selectedPump}
className="btn-primary"
>
Assign
</button>
</div>
</div>
{/* Current Assignments */}
<div className="space-y-3">
<h4 className="font-medium">Current Pump Assignments</h4>
{assignments.length === 0 ? (
<p className="text-gray-500">No pumps assigned to this sprayer</p>
) : (
assignments.map(assignment => (
<div key={assignment.id} className="flex justify-between items-center p-3 bg-gray-50 rounded-lg">
<div>
<div className="font-medium">
{assignment.pump?.customName || assignment.pump?.typeName}
</div>
{assignment.pump?.manufacturer && (
<div className="text-sm text-gray-600">
{assignment.pump.manufacturer} {assignment.pump.model}
</div>
)}
{assignment.pump?.maxGpm && (
<div className="text-sm text-gray-600">
Max Flow: {assignment.pump.maxGpm} GPM
</div>
)}
<div className="text-xs text-gray-500">
Assigned: {new Date(assignment.assignedDate).toLocaleDateString()}
</div>
</div>
<button
onClick={() => handleUnassignPump(assignment.id)}
className="text-red-600 hover:text-red-800"
>
<TrashIcon className="h-5 w-5" />
</button>
</div>
))
)}
</div>
</>
)}
<div className="flex gap-3 pt-6 mt-6 border-t">
<button onClick={onClose} className="btn-secondary flex-1">
Close
</button>
</div>
</div>
</div>
);
};
// 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 (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-4xl max-h-[90vh] overflow-y-auto">
<h3 className="text-lg font-semibold mb-4">
Nozzle Configuration - {sprayer.customName || sprayer.typeName}
</h3>
{loading ? (
<LoadingSpinner />
) : (
<>
{/* Add Configuration Button */}
<div className="mb-6">
<button
onClick={() => setShowAddConfig(true)}
className="btn-primary flex items-center gap-2"
>
<PlusIcon className="h-5 w-5" />
Add Nozzle Configuration
</button>
</div>
{/* Add Configuration Form */}
{showAddConfig && (
<div className="mb-6 p-4 bg-gray-50 rounded-lg">
<h4 className="font-medium mb-3">Add Nozzle Configuration</h4>
<div className="grid grid-cols-3 gap-3">
<select
className="input"
value={configForm.userNozzleId}
onChange={(e) => setConfigForm({ ...configForm, userNozzleId: e.target.value })}
>
<option value="">Select nozzle...</option>
{userNozzles.map(nozzle => (
<option key={nozzle.id} value={nozzle.id}>
{nozzle.nozzleType?.name} ({nozzle.quantity} available)
</option>
))}
</select>
<input
type="text"
className="input"
placeholder="Position (e.g., left, right, boom_1)"
value={configForm.position}
onChange={(e) => setConfigForm({ ...configForm, position: e.target.value })}
/>
<input
type="number"
className="input"
placeholder="Quantity"
min="1"
value={configForm.quantityAssigned}
onChange={(e) => setConfigForm({ ...configForm, quantityAssigned: parseInt(e.target.value) })}
/>
</div>
<div className="flex gap-3 mt-3">
<button onClick={handleAddConfiguration} className="btn-primary">
Add Configuration
</button>
<button
onClick={() => setShowAddConfig(false)}
className="btn-secondary"
>
Cancel
</button>
</div>
</div>
)}
{/* Current Configurations */}
<div className="space-y-3">
<h4 className="font-medium">Current Nozzle Configurations</h4>
{configurations.length === 0 ? (
<p className="text-gray-500">No nozzles configured for this sprayer</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{configurations.map(config => (
<div key={config.id} className="p-4 bg-gray-50 rounded-lg">
<div className="flex justify-between items-start mb-2">
<div className="font-medium">
{config.userNozzle?.nozzleType?.name}
</div>
<button
onClick={() => handleRemoveConfiguration(config.id)}
className="text-red-600 hover:text-red-800"
>
<TrashIcon className="h-4 w-4" />
</button>
</div>
<div className="text-sm text-gray-600 space-y-1">
<div><strong>Position:</strong> {config.position}</div>
<div><strong>Quantity:</strong> {config.quantityAssigned}</div>
{config.userNozzle?.nozzleType?.manufacturer && (
<div><strong>Manufacturer:</strong> {config.userNozzle.nozzleType.manufacturer}</div>
)}
{config.userNozzle?.nozzleType?.orificeSize && (
<div><strong>Orifice:</strong> {config.userNozzle.nozzleType.orificeSize}</div>
)}
{config.userNozzle?.nozzleType?.sprayAngle && (
<div><strong>Spray Angle:</strong> {config.userNozzle.nozzleType.sprayAngle}°</div>
)}
{config.userNozzle?.nozzleType?.dropletSize && (
<div><strong>Droplet Size:</strong> {config.userNozzle.nozzleType.dropletSize}</div>
)}
<div className="text-xs text-gray-500">
Assigned: {new Date(config.assignedDate).toLocaleDateString()}
</div>
</div>
</div>
))}
</div>
)}
</div>
</>
)}
<div className="flex gap-3 pt-6 mt-6 border-t">
<button onClick={onClose} className="btn-secondary flex-1">
Close
</button>
</div>
</div>
</div>
);
};
export default Equipment;

View File

@@ -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}`),