equipment stuff

This commit is contained in:
Jake Kasper
2025-08-22 09:17:22 -04:00
parent f95422325c
commit b7ceed70a5
7 changed files with 2137 additions and 260 deletions

View File

@@ -10,6 +10,7 @@ const authRoutes = require('./routes/auth');
const userRoutes = require('./routes/users'); const userRoutes = require('./routes/users');
const propertyRoutes = require('./routes/properties'); const propertyRoutes = require('./routes/properties');
const equipmentRoutes = require('./routes/equipment'); const equipmentRoutes = require('./routes/equipment');
const nozzleRoutes = require('./routes/nozzles');
const productRoutes = require('./routes/products'); const productRoutes = require('./routes/products');
const applicationRoutes = require('./routes/applications'); const applicationRoutes = require('./routes/applications');
const weatherRoutes = require('./routes/weather'); const weatherRoutes = require('./routes/weather');
@@ -82,6 +83,7 @@ app.use('/api/auth', authLimiter, authRoutes);
app.use('/api/users', authenticateToken, userRoutes); app.use('/api/users', authenticateToken, userRoutes);
app.use('/api/properties', authenticateToken, propertyRoutes); app.use('/api/properties', authenticateToken, propertyRoutes);
app.use('/api/equipment', authenticateToken, equipmentRoutes); app.use('/api/equipment', authenticateToken, equipmentRoutes);
app.use('/api/nozzles', authenticateToken, nozzleRoutes);
app.use('/api/products', authenticateToken, productRoutes); app.use('/api/products', authenticateToken, productRoutes);
app.use('/api/applications', authenticateToken, applicationRoutes); app.use('/api/applications', authenticateToken, applicationRoutes);
app.use('/api/weather', authenticateToken, weatherRoutes); app.use('/api/weather', authenticateToken, weatherRoutes);

View File

@@ -1,28 +1,66 @@
const express = require('express'); const express = require('express');
const pool = require('../config/database'); const pool = require('../config/database');
const { validateRequest, validateParams } = require('../utils/validation'); const { validateRequest, validateParams } = require('../utils/validation');
const { equipmentSchema, idParamSchema } = require('../utils/validation'); const { idParamSchema } = require('../utils/validation');
const { AppError } = require('../middleware/errorHandler'); const { AppError } = require('../middleware/errorHandler');
const router = express.Router(); 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 // @route GET /api/equipment/types
// @desc Get all equipment types // @desc Get all equipment types
// @access Private // @access Private
router.get('/types', async (req, res, next) => { router.get('/types', async (req, res, next) => {
try { try {
const result = await pool.query( const { category_id } = req.query;
'SELECT * FROM equipment_types ORDER BY category, name'
); 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) => { const equipmentByCategory = result.rows.reduce((acc, equipment) => {
if (!acc[equipment.category]) { const categoryName = equipment.category_name;
acc[equipment.category] = []; if (!acc[categoryName]) {
acc[categoryName] = [];
} }
acc[equipment.category].push({ acc[categoryName].push({
id: equipment.id, id: equipment.id,
name: equipment.name, name: equipment.name,
category: equipment.category, manufacturer: equipment.manufacturer,
model: equipment.model,
categoryId: equipment.category_id,
categoryName: equipment.category_name,
createdAt: equipment.created_at createdAt: equipment.created_at
}); });
return acc; return acc;
@@ -31,7 +69,16 @@ router.get('/types', async (req, res, next) => {
res.json({ res.json({
success: true, success: true,
data: { 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 equipmentByCategory
} }
}); });
@@ -45,14 +92,31 @@ router.get('/types', async (req, res, next) => {
// @access Private // @access Private
router.get('/', async (req, res, next) => { router.get('/', async (req, res, next) => {
try { try {
const result = await pool.query( const { category_id, is_active } = req.query;
`SELECT ue.*, et.name as type_name, et.category
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 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.user_id = $1 WHERE ue.user_id = $1
ORDER BY et.category, et.name`, `;
[req.user.id] 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({ res.json({
success: true, success: true,
@@ -60,14 +124,43 @@ router.get('/', async (req, res, next) => {
equipment: result.rows.map(item => ({ equipment: result.rows.map(item => ({
id: item.id, id: item.id,
equipmentTypeId: item.equipment_type_id, equipmentTypeId: item.equipment_type_id,
categoryId: item.category_id,
typeName: item.type_name, typeName: item.type_name,
category: item.category, typeManufacturer: item.type_manufacturer,
typeModel: item.type_model,
categoryName: item.category_name,
customName: item.custom_name, customName: item.custom_name,
tankSize: parseFloat(item.tank_size), manufacturer: item.manufacturer,
pumpGpm: parseFloat(item.pump_gpm), model: item.model,
nozzleGpm: parseFloat(item.nozzle_gpm), // Spreader fields
nozzleCount: item.nozzle_count, capacityLbs: parseFloat(item.capacity_lbs) || null,
spreaderWidth: parseFloat(item.spreader_width), 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, createdAt: item.created_at,
updatedAt: item.updated_at updatedAt: item.updated_at
})) }))
@@ -86,9 +179,11 @@ router.get('/:id', validateParams(idParamSchema), async (req, res, next) => {
const equipmentId = req.params.id; const equipmentId = req.params.id;
const result = await pool.query( 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 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`, WHERE ue.id = $1 AND ue.user_id = $2`,
[equipmentId, req.user.id] [equipmentId, req.user.id]
); );
@@ -105,14 +200,43 @@ router.get('/:id', validateParams(idParamSchema), async (req, res, next) => {
equipment: { equipment: {
id: item.id, id: item.id,
equipmentTypeId: item.equipment_type_id, equipmentTypeId: item.equipment_type_id,
categoryId: item.category_id,
typeName: item.type_name, typeName: item.type_name,
category: item.category, typeManufacturer: item.type_manufacturer,
typeModel: item.type_model,
categoryName: item.category_name,
customName: item.custom_name, customName: item.custom_name,
tankSize: parseFloat(item.tank_size), manufacturer: item.manufacturer,
pumpGpm: parseFloat(item.pump_gpm), model: item.model,
nozzleGpm: parseFloat(item.nozzle_gpm), // Spreader fields
nozzleCount: item.nozzle_count, capacityLbs: parseFloat(item.capacity_lbs) || null,
spreaderWidth: parseFloat(item.spreader_width), 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, createdAt: item.created_at,
updatedAt: item.updated_at updatedAt: item.updated_at
} }
@@ -126,21 +250,56 @@ router.get('/:id', validateParams(idParamSchema), async (req, res, next) => {
// @route POST /api/equipment // @route POST /api/equipment
// @desc Add new equipment // @desc Add new equipment
// @access Private // @access Private
router.post('/', validateRequest(equipmentSchema), async (req, res, next) => { router.post('/', async (req, res, next) => {
try { try {
const { const {
equipmentTypeId, equipmentTypeId,
categoryId,
customName, customName,
tankSize, manufacturer,
model,
// Spreader specific fields
capacityLbs,
spreaderType,
spreadWidth,
// Sprayer specific fields
tankSizeGallons,
sprayerType,
sprayWidthFeet,
pumpGpm, pumpGpm,
nozzleGpm, pumpPsi,
nozzleCount, boomSections,
spreaderWidth // 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; } = req.body;
// Verify equipment type exists // Validate required fields
if (!categoryId && !equipmentTypeId) {
throw new AppError('Either category or equipment type is required', 400);
}
// If equipmentTypeId is provided, verify it exists and get category
let finalCategoryId = categoryId;
let equipmentType = null;
if (equipmentTypeId) {
const typeCheck = await pool.query( const typeCheck = await pool.query(
'SELECT id, name, category FROM equipment_types WHERE id = $1', 'SELECT id, name, category_id, manufacturer, model FROM equipment_types WHERE id = $1',
[equipmentTypeId] [equipmentTypeId]
); );
@@ -148,25 +307,42 @@ router.post('/', validateRequest(equipmentSchema), async (req, res, next) => {
throw new AppError('Equipment type not found', 404); throw new AppError('Equipment type not found', 404);
} }
const equipmentType = typeCheck.rows[0]; equipmentType = typeCheck.rows[0];
finalCategoryId = equipmentType.category_id;
// 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) { // Verify category exists
throw new AppError('Spreader width is required for spreaders', 400); 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( const result = await pool.query(
`INSERT INTO user_equipment `INSERT INTO user_equipment
(user_id, equipment_type_id, custom_name, tank_size, pump_gpm, nozzle_gpm, nozzle_count, spreader_width) (user_id, equipment_type_id, category_id, custom_name, manufacturer, model,
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) 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 *`, 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]; const equipment = result.rows[0];
@@ -178,14 +354,33 @@ router.post('/', validateRequest(equipmentSchema), async (req, res, next) => {
equipment: { equipment: {
id: equipment.id, id: equipment.id,
equipmentTypeId: equipment.equipment_type_id, equipmentTypeId: equipment.equipment_type_id,
typeName: equipmentType.name, categoryId: equipment.category_id,
category: equipmentType.category,
customName: equipment.custom_name, customName: equipment.custom_name,
tankSize: parseFloat(equipment.tank_size), manufacturer: equipment.manufacturer,
pumpGpm: parseFloat(equipment.pump_gpm), model: equipment.model,
nozzleGpm: parseFloat(equipment.nozzle_gpm), capacityLbs: parseFloat(equipment.capacity_lbs) || null,
nozzleCount: equipment.nozzle_count, spreaderType: equipment.spreader_type,
spreaderWidth: parseFloat(equipment.spreader_width), 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, createdAt: equipment.created_at,
updatedAt: equipment.updated_at updatedAt: equipment.updated_at
} }
@@ -199,17 +394,38 @@ router.post('/', validateRequest(equipmentSchema), async (req, res, next) => {
// @route PUT /api/equipment/:id // @route PUT /api/equipment/:id
// @desc Update equipment // @desc Update equipment
// @access Private // @access Private
router.put('/:id', validateParams(idParamSchema), validateRequest(equipmentSchema), async (req, res, next) => { router.put('/:id', validateParams(idParamSchema), async (req, res, next) => {
try { try {
const equipmentId = req.params.id; const equipmentId = req.params.id;
const { const {
equipmentTypeId, equipmentTypeId,
categoryId,
customName, customName,
tankSize, manufacturer,
model,
capacityLbs,
spreaderType,
spreadWidth,
tankSizeGallons,
sprayerType,
sprayWidthFeet,
pumpGpm, pumpGpm,
nozzleGpm, pumpPsi,
nozzleCount, boomSections,
spreaderWidth mowerStyle,
cuttingWidthInches,
engineHp,
fuelType,
toolType,
workingWidthInches,
pumpType,
maxGpm,
maxPsi,
powerSource,
purchaseDate,
purchasePrice,
notes,
isActive
} = req.body; } = req.body;
// Check if equipment exists and belongs to user // 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); 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( const result = await pool.query(
`UPDATE user_equipment `UPDATE user_equipment
SET equipment_type_id = $1, custom_name = $2, tank_size = $3, pump_gpm = $4, SET equipment_type_id = $1, category_id = $2, custom_name = $3, manufacturer = $4, model = $5,
nozzle_gpm = $5, nozzle_count = $6, spreader_width = $7, updated_at = CURRENT_TIMESTAMP capacity_lbs = $6, spreader_type = $7, spread_width = $8,
WHERE id = $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 *`, 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]; const equipment = result.rows[0];
@@ -263,14 +471,33 @@ router.put('/:id', validateParams(idParamSchema), validateRequest(equipmentSchem
equipment: { equipment: {
id: equipment.id, id: equipment.id,
equipmentTypeId: equipment.equipment_type_id, equipmentTypeId: equipment.equipment_type_id,
typeName: equipmentType.name, categoryId: equipment.category_id,
category: equipmentType.category,
customName: equipment.custom_name, customName: equipment.custom_name,
tankSize: parseFloat(equipment.tank_size), manufacturer: equipment.manufacturer,
pumpGpm: parseFloat(equipment.pump_gpm), model: equipment.model,
nozzleGpm: parseFloat(equipment.nozzle_gpm), capacityLbs: parseFloat(equipment.capacity_lbs) || null,
nozzleCount: equipment.nozzle_count, spreaderType: equipment.spreader_type,
spreaderWidth: parseFloat(equipment.spreader_width), 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, createdAt: equipment.created_at,
updatedAt: equipment.updated_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; module.exports = router;

View File

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

View File

@@ -41,29 +41,107 @@ CREATE TABLE lawn_sections (
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 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) -- Equipment types master table (shared across all users)
CREATE TABLE equipment_types ( CREATE TABLE equipment_types (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE, 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 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
); );
-- User equipment table -- User equipment table - comprehensive equipment management
CREATE TABLE user_equipment ( CREATE TABLE user_equipment (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
equipment_type_id INTEGER REFERENCES equipment_types(id), equipment_type_id INTEGER REFERENCES equipment_types(id),
category_id INTEGER REFERENCES equipment_categories(id),
custom_name VARCHAR(255), custom_name VARCHAR(255),
tank_size DECIMAL(8, 2), -- gallons (for sprayers) manufacturer VARCHAR(100),
pump_gpm DECIMAL(8, 2), -- gallons per minute (for sprayers) model VARCHAR(100),
nozzle_gpm DECIMAL(8, 2), -- gallons per minute per nozzle -- Spreader specific fields
nozzle_count INTEGER, -- number of nozzles capacity_lbs DECIMAL(8, 2),
spreader_width DECIMAL(8, 2), -- width in feet (for spreaders) 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, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_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 -- Product categories
CREATE TABLE product_categories ( CREATE TABLE product_categories (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
@@ -181,22 +259,72 @@ CREATE TABLE weather_data (
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 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 default equipment types
INSERT INTO equipment_types (name, category) VALUES INSERT INTO equipment_types (name, category_id) VALUES
('Walk-behind Mower', 'mower'), -- Mowers
('Riding Mower', 'mower'), ('Walk-behind Mower', 1),
('Zero-turn Mower', 'mower'), ('Self-Propelled Mower', 1),
('String Trimmer', 'trimmer'), ('Zero-turn Mower', 1),
('Backpack Sprayer', 'sprayer'), ('Lawn Tractor', 1),
('Pull-behind Sprayer', 'sprayer'), ('Riding Mower', 1),
('Boom Sprayer', 'sprayer'), -- Spreaders
('Broadcast Spreader', 'spreader'), ('Broadcast Spreader', 2),
('Drop Spreader', 'spreader'), ('Drop Spreader', 2),
('Hand Spreader', 'spreader'), ('Hand Spreader', 2),
('Core Aerator', 'aerator'), ('Pull-behind Spreader', 2),
('Spike Aerator', 'aerator'), -- Sprayers
('Dethatcher', 'dethatcher'), ('Backpack Sprayer', 3),
('Power Rake', 'dethatcher'); ('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 product categories
INSERT INTO product_categories (name, description) VALUES 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_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_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_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_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(); CREATE TRIGGER update_application_plans_updated_at BEFORE UPDATE ON application_plans FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();

View File

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

View File

@@ -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 = () => {
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 }) => (
<div className="card">
<div className="flex justify-between items-start mb-3">
<div className="flex items-start gap-3">
<div className="p-2 bg-blue-100 rounded-lg">
<WrenchScrewdriverIcon className="h-5 w-5 text-blue-600" />
</div>
<div>
<h3 className="font-semibold text-gray-900">
{item.customName || item.typeName}
</h3>
{item.manufacturer && item.model && (
<p className="text-sm text-gray-600">{item.manufacturer} {item.model}</p>
)}
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium mt-1 ${
getCategoryColor(item.categoryName)
}`}>
{item.categoryName}
</span>
{!item.isActive && (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium mt-1 ml-2 bg-red-100 text-red-800">
Inactive
</span>
)}
</div>
</div>
<div className="flex gap-1">
<button
onClick={() => handleEditEquipment(item)}
className="p-1 text-gray-400 hover:text-blue-600"
title="Edit equipment"
>
<PencilIcon className="h-4 w-4" />
</button>
<button
onClick={() => handleDeleteEquipment(item.id)}
className="p-1 text-gray-400 hover:text-red-600"
title="Delete equipment"
>
<TrashIcon className="h-4 w-4" />
</button>
</div>
</div>
{/* Equipment-specific details */}
<div className="space-y-2 text-sm text-gray-600">
{renderEquipmentDetails(item)}
</div>
{item.notes && (
<p className="text-sm text-gray-600 mt-3">
<strong>Notes:</strong> {item.notes}
</p>
)}
{item.purchaseDate && (
<p className="text-xs text-gray-500 mt-2">
Purchased: {new Date(item.purchaseDate).toLocaleDateString()}
</p>
)}
</div>
);
const renderEquipmentDetails = (item) => {
switch (item.categoryName?.toLowerCase()) {
case 'mower':
return (
<>
{item.mowerStyle && <p><strong>Style:</strong> {item.mowerStyle.replace('_', ' ')}</p>}
{item.cuttingWidthInches && <p><strong>Cutting Width:</strong> {item.cuttingWidthInches}"</p>}
{item.engineHp && <p><strong>Engine:</strong> {item.engineHp} HP</p>}
{item.fuelType && <p><strong>Fuel:</strong> {item.fuelType}</p>}
</>
);
case 'spreader':
return (
<>
{item.spreaderType && <p><strong>Type:</strong> {item.spreaderType.replace('_', ' ')}</p>}
{item.capacityLbs && <p><strong>Capacity:</strong> {item.capacityLbs} lbs</p>}
{item.spreadWidth && <p><strong>Spread Width:</strong> {item.spreadWidth} ft</p>}
</>
);
case 'sprayer':
return (
<>
{item.sprayerType && <p><strong>Type:</strong> {item.sprayerType.replace('_', ' ')}</p>}
{item.tankSizeGallons && <p><strong>Tank Size:</strong> {item.tankSizeGallons} gal</p>}
{item.sprayWidthFeet && <p><strong>Spray Width:</strong> {item.sprayWidthFeet} ft</p>}
{item.pumpGpm && <p><strong>Pump:</strong> {item.pumpGpm} GPM</p>}
{item.boomSections && <p><strong>Boom Sections:</strong> {item.boomSections}</p>}
</>
);
case 'pump':
return (
<>
{item.pumpType && <p><strong>Type:</strong> {item.pumpType}</p>}
{item.maxGpm && <p><strong>Max Flow:</strong> {item.maxGpm} GPM</p>}
{item.maxPsi && <p><strong>Max Pressure:</strong> {item.maxPsi} PSI</p>}
{item.powerSource && <p><strong>Power:</strong> {item.powerSource}</p>}
</>
);
default:
return (
<>
{item.toolType && <p><strong>Type:</strong> {item.toolType.replace('_', ' ')}</p>}
{item.workingWidthInches && <p><strong>Working Width:</strong> {item.workingWidthInches}"</p>}
</>
);
}
};
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 (
<div className="p-6"> <div className="p-6">
<h1 className="text-2xl font-bold text-gray-900 mb-6">Equipment</h1> <div className="flex justify-center items-center h-64">
<div className="card"> <LoadingSpinner size="lg" />
<p className="text-gray-600">Equipment management coming soon...</p> </div>
</div>
);
}
return (
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Equipment</h1>
<p className="text-gray-600">Manage your lawn care equipment inventory</p>
</div>
<button
onClick={() => setShowCreateForm(true)}
className="btn-primary flex items-center gap-2"
>
<PlusIcon className="h-5 w-5" />
Add Equipment
</button>
</div>
{/* Search and Filters */}
<div className="card mb-6">
<div className="flex flex-wrap gap-4">
{/* Search */}
<div className="flex-1 min-w-64">
<div className="relative">
<MagnifyingGlassIcon className="h-5 w-5 absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" />
<input
type="text"
className="input pl-10"
placeholder="Search equipment..."
value={searchTerm}
onChange={handleSearch}
/>
</div>
</div>
{/* Category Filter */}
<div className="min-w-48">
<select
className="input"
value={selectedCategory}
onChange={(e) => {
setSelectedCategory(e.target.value);
handleFilterChange();
}}
>
<option value="">All Categories</option>
{categories.map((category) => (
<option key={category.id} value={category.id}>
{category.name}
</option>
))}
</select>
</div>
{/* Show Inactive Toggle */}
<div className="flex items-center">
<label className="flex items-center">
<input
type="checkbox"
checked={showInactive}
onChange={(e) => {
setShowInactive(e.target.checked);
handleFilterChange();
}}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="ml-2 text-sm text-gray-700">Show inactive</span>
</label>
</div>
</div>
</div>
{/* Category Tabs */}
<div className="mb-6">
<div className="border-b border-gray-200">
<nav className="-mb-px flex overflow-x-auto">
{categoryTabs.map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`py-2 px-4 border-b-2 font-medium text-sm whitespace-nowrap ${
activeTab === tab
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
{tab === 'all' ? 'All Equipment' : tab}
{tab !== 'all' && (
<span className="ml-2 bg-gray-100 text-gray-600 px-2 py-1 rounded-full text-xs">
{equipment.filter(item => item.categoryName === tab).length}
</span>
)}
{tab === 'all' && (
<span className="ml-2 bg-gray-100 text-gray-600 px-2 py-1 rounded-full text-xs">
{equipment.length}
</span>
)}
</button>
))}
</nav>
</div>
</div>
{/* Equipment Grid */}
{filteredEquipment.length === 0 ? (
<div className="card text-center py-12">
<WrenchScrewdriverIcon className="h-16 w-16 text-gray-300 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">No Equipment Found</h3>
<p className="text-gray-600 mb-6">
{searchTerm || selectedCategory
? 'Try adjusting your search or filters'
: 'Start building your equipment inventory'
}
</p>
{!searchTerm && !selectedCategory && (
<button
onClick={() => setShowCreateForm(true)}
className="btn-primary"
>
Add Your First Equipment
</button>
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredEquipment.map((item) => (
<EquipmentCard key={item.id} item={item} />
))}
</div>
)}
{/* Create Equipment Form Modal */}
{showCreateForm && (
<EquipmentFormModal
isEdit={false}
equipment={null}
categories={categories}
equipmentTypes={equipmentTypes}
onSubmit={handleCreateEquipment}
onCancel={() => setShowCreateForm(false)}
/>
)}
{/* Edit Equipment Form Modal */}
{showEditForm && editingEquipment && (
<EquipmentFormModal
isEdit={true}
equipment={editingEquipment}
categories={categories}
equipmentTypes={equipmentTypes}
onSubmit={handleUpdateEquipment}
onCancel={() => {
setShowEditForm(false);
setEditingEquipment(null);
}}
/>
)}
</div>
);
};
// 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 (
<>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Mower Style *</label>
<select
className="input"
value={formData.mowerStyle}
onChange={(e) => setFormData({ ...formData, mowerStyle: e.target.value })}
required
>
<option value="push">Push</option>
<option value="self_propelled">Self-Propelled</option>
<option value="zero_turn">Zero Turn</option>
<option value="lawn_tractor">Lawn Tractor</option>
<option value="riding">Riding Mower</option>
</select>
</div>
<div>
<label className="label">Cutting Width (inches)</label>
<input
type="number"
step="0.1"
className="input"
value={formData.cuttingWidthInches}
onChange={(e) => setFormData({ ...formData, cuttingWidthInches: e.target.value })}
placeholder="21"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Engine HP</label>
<input
type="number"
step="0.1"
className="input"
value={formData.engineHp}
onChange={(e) => setFormData({ ...formData, engineHp: e.target.value })}
placeholder="6.5"
/>
</div>
<div>
<label className="label">Fuel Type</label>
<select
className="input"
value={formData.fuelType}
onChange={(e) => setFormData({ ...formData, fuelType: e.target.value })}
>
<option value="">Select fuel type</option>
<option value="gasoline">Gasoline</option>
<option value="diesel">Diesel</option>
<option value="electric">Electric</option>
<option value="battery">Battery</option>
<option value="propane">Propane</option>
</select>
</div>
</div>
</>
);
case 'spreader':
return (
<>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Spreader Type *</label>
<select
className="input"
value={formData.spreaderType}
onChange={(e) => setFormData({ ...formData, spreaderType: e.target.value })}
required
>
<option value="walk_behind">Walk Behind</option>
<option value="pull_behind">Pull Behind</option>
<option value="handheld">Handheld</option>
</select>
</div>
<div>
<label className="label">Capacity (lbs)</label>
<input
type="number"
step="0.1"
className="input"
value={formData.capacityLbs}
onChange={(e) => setFormData({ ...formData, capacityLbs: e.target.value })}
placeholder="50"
/>
</div>
</div>
<div>
<label className="label">Spread Width (feet)</label>
<input
type="number"
step="0.1"
className="input"
value={formData.spreadWidth}
onChange={(e) => setFormData({ ...formData, spreadWidth: e.target.value })}
placeholder="8"
/>
</div>
</>
);
case 'sprayer':
return (
<>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Sprayer Type *</label>
<select
className="input"
value={formData.sprayerType}
onChange={(e) => setFormData({ ...formData, sprayerType: e.target.value })}
required
>
<option value="walk_behind">Walk Behind</option>
<option value="tow_behind">Tow Behind</option>
<option value="mower_mounted">Mower Mounted</option>
<option value="ride_on">Ride On</option>
<option value="hand_pump">Hand Pump</option>
</select>
</div>
<div>
<label className="label">Tank Size (gallons)</label>
<input
type="number"
step="0.1"
className="input"
value={formData.tankSizeGallons}
onChange={(e) => setFormData({ ...formData, tankSizeGallons: e.target.value })}
placeholder="25"
/>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="label">Spray Width (feet)</label>
<input
type="number"
step="0.1"
className="input"
value={formData.sprayWidthFeet}
onChange={(e) => setFormData({ ...formData, sprayWidthFeet: e.target.value })}
placeholder="10"
/>
</div>
<div>
<label className="label">Pump GPM</label>
<input
type="number"
step="0.1"
className="input"
value={formData.pumpGpm}
onChange={(e) => setFormData({ ...formData, pumpGpm: e.target.value })}
placeholder="2.5"
/>
</div>
<div>
<label className="label">Pump PSI</label>
<input
type="number"
className="input"
value={formData.pumpPsi}
onChange={(e) => setFormData({ ...formData, pumpPsi: e.target.value })}
placeholder="60"
/>
</div>
</div>
<div>
<label className="label">Boom Sections</label>
<input
type="number"
className="input"
value={formData.boomSections}
onChange={(e) => setFormData({ ...formData, boomSections: e.target.value })}
placeholder="3"
/>
</div>
</>
);
case 'pump':
return (
<>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Pump Type</label>
<input
type="text"
className="input"
value={formData.pumpType}
onChange={(e) => setFormData({ ...formData, pumpType: e.target.value })}
placeholder="Centrifugal, Diaphragm, etc."
/>
</div>
<div>
<label className="label">Power Source</label>
<select
className="input"
value={formData.powerSource}
onChange={(e) => setFormData({ ...formData, powerSource: e.target.value })}
>
<option value="">Select power source</option>
<option value="electric">Electric</option>
<option value="gasoline">Gasoline</option>
<option value="diesel">Diesel</option>
<option value="pto">PTO</option>
<option value="hydraulic">Hydraulic</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Max GPM</label>
<input
type="number"
step="0.1"
className="input"
value={formData.maxGpm}
onChange={(e) => setFormData({ ...formData, maxGpm: e.target.value })}
placeholder="10"
/>
</div>
<div>
<label className="label">Max PSI</label>
<input
type="number"
className="input"
value={formData.maxPsi}
onChange={(e) => setFormData({ ...formData, maxPsi: e.target.value })}
placeholder="100"
/>
</div>
</div>
</>
);
default:
return (
<>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Tool Type</label>
<select
className="input"
value={formData.toolType}
onChange={(e) => setFormData({ ...formData, toolType: e.target.value })}
>
<option value="walk_behind">Walk Behind</option>
<option value="tow_behind">Tow Behind</option>
<option value="handheld">Handheld</option>
</select>
</div>
<div>
<label className="label">Working Width (inches)</label>
<input
type="number"
step="0.1"
className="input"
value={formData.workingWidthInches}
onChange={(e) => setFormData({ ...formData, workingWidthInches: e.target.value })}
placeholder="18"
/>
</div>
</div>
</>
);
}
};
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">
{isEdit ? 'Edit Equipment' : 'Add New Equipment'}
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Basic Information */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Equipment Type (Optional)</label>
<select
className="input"
value={formData.equipmentTypeId}
onChange={(e) => {
const selectedType = equipmentTypes.find(type => type.id === parseInt(e.target.value));
setFormData({
...formData,
equipmentTypeId: e.target.value,
categoryId: selectedType?.categoryId || formData.categoryId
});
}}
>
<option value="">Select from database...</option>
{equipmentTypes.map((type) => (
<option key={type.id} value={type.id}>
{type.name} {type.manufacturer && `- ${type.manufacturer}`}
</option>
))}
</select>
</div>
<div>
<label className="label">Category *</label>
<select
className="input"
value={formData.categoryId}
onChange={(e) => setFormData({ ...formData, categoryId: e.target.value })}
required={!formData.equipmentTypeId}
>
<option value="">Select category...</option>
{categories.map((category) => (
<option key={category.id} value={category.id}>
{category.name}
</option>
))}
</select>
</div>
</div>
<div>
<label className="label">Equipment Name *</label>
<input
type="text"
className="input"
value={formData.customName}
onChange={(e) => setFormData({ ...formData, customName: e.target.value })}
placeholder="My Lawn Mower, Office Spreader, etc."
required={!formData.equipmentTypeId}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Manufacturer</label>
<input
type="text"
className="input"
value={formData.manufacturer}
onChange={(e) => setFormData({ ...formData, manufacturer: e.target.value })}
placeholder="Toro, John Deere, etc."
/>
</div>
<div>
<label className="label">Model</label>
<input
type="text"
className="input"
value={formData.model}
onChange={(e) => setFormData({ ...formData, model: e.target.value })}
placeholder="Model number"
/>
</div>
</div>
{/* Category-specific fields */}
{formData.categoryId && renderCategorySpecificFields()}
{/* Purchase Information */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Purchase Date</label>
<input
type="date"
className="input"
value={formData.purchaseDate}
onChange={(e) => setFormData({ ...formData, purchaseDate: e.target.value })}
/>
</div>
<div>
<label className="label">Purchase Price</label>
<input
type="number"
step="0.01"
className="input"
value={formData.purchasePrice}
onChange={(e) => setFormData({ ...formData, purchasePrice: e.target.value })}
placeholder="0.00"
/>
</div>
</div>
{/* Notes and Status */}
<div>
<label className="label">Notes</label>
<textarea
className="input"
rows="3"
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
placeholder="Maintenance notes, special instructions, etc."
/>
</div>
{isEdit && (
<div className="flex items-center">
<label className="flex items-center">
<input
type="checkbox"
checked={formData.isActive}
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="ml-2 text-sm text-gray-700">Equipment is active</span>
</label>
</div>
)}
<div className="flex gap-3 pt-4">
<button type="submit" className="btn-primary flex-1">
{isEdit ? 'Update Equipment' : 'Create Equipment'}
</button>
<button
type="button"
onClick={onCancel}
className="btn-secondary flex-1"
>
Cancel
</button>
</div>
</form>
</div> </div>
</div> </div>
); );

View File

@@ -92,15 +92,30 @@ export const propertiesAPI = {
// Equipment API endpoints // Equipment API endpoints
export const equipmentAPI = { export const equipmentAPI = {
getAll: () => apiClient.get('/equipment'), getAll: (params) => apiClient.get('/equipment', { params }),
getById: (id) => apiClient.get(`/equipment/${id}`), getById: (id) => apiClient.get(`/equipment/${id}`),
create: (equipmentData) => apiClient.post('/equipment', equipmentData), create: (equipmentData) => apiClient.post('/equipment', equipmentData),
update: (id, equipmentData) => apiClient.put(`/equipment/${id}`, equipmentData), update: (id, equipmentData) => apiClient.put(`/equipment/${id}`, equipmentData),
delete: (id) => apiClient.delete(`/equipment/${id}`), delete: (id) => apiClient.delete(`/equipment/${id}`),
getTypes: () => apiClient.get('/equipment/types'), getCategories: () => apiClient.get('/equipment/categories'),
getTypes: (params) => apiClient.get('/equipment/types', { params }),
getCalculations: (id, params) => apiClient.get(`/equipment/${id}/calculations`, { params }), getCalculations: (id, params) => apiClient.get(`/equipment/${id}/calculations`, { params }),
}; };
// Nozzles API endpoints
export const nozzlesAPI = {
getAll: () => apiClient.get('/nozzles'),
getById: (id) => apiClient.get(`/nozzles/${id}`),
create: (nozzleData) => apiClient.post('/nozzles', nozzleData),
update: (id, nozzleData) => apiClient.put(`/nozzles/${id}`, nozzleData),
delete: (id) => apiClient.delete(`/nozzles/${id}`),
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}`),
};
// Products API endpoints // Products API endpoints
export const productsAPI = { export const productsAPI = {
getAll: (params) => apiClient.get('/products', { params }), getAll: (params) => apiClient.get('/products', { params }),