equipment stuff
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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
|
|
||||||
FROM user_equipment ue
|
let query = `
|
||||||
JOIN equipment_types et ON ue.equipment_type_id = et.id
|
SELECT ue.*, et.name as type_name, et.manufacturer as type_manufacturer, et.model as type_model,
|
||||||
WHERE ue.user_id = $1
|
ec.name as category_name
|
||||||
ORDER BY et.category, et.name`,
|
FROM user_equipment ue
|
||||||
[req.user.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
|
||||||
|
`;
|
||||||
|
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,47 +250,99 @@ 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,
|
||||||
customName,
|
categoryId,
|
||||||
tankSize,
|
customName,
|
||||||
pumpGpm,
|
manufacturer,
|
||||||
nozzleGpm,
|
model,
|
||||||
nozzleCount,
|
// Spreader specific fields
|
||||||
spreaderWidth
|
capacityLbs,
|
||||||
|
spreaderType,
|
||||||
|
spreadWidth,
|
||||||
|
// Sprayer specific fields
|
||||||
|
tankSizeGallons,
|
||||||
|
sprayerType,
|
||||||
|
sprayWidthFeet,
|
||||||
|
pumpGpm,
|
||||||
|
pumpPsi,
|
||||||
|
boomSections,
|
||||||
|
// Mower specific fields
|
||||||
|
mowerStyle,
|
||||||
|
cuttingWidthInches,
|
||||||
|
engineHp,
|
||||||
|
fuelType,
|
||||||
|
// Tool specific fields
|
||||||
|
toolType,
|
||||||
|
workingWidthInches,
|
||||||
|
// Pump specific fields
|
||||||
|
pumpType,
|
||||||
|
maxGpm,
|
||||||
|
maxPsi,
|
||||||
|
powerSource,
|
||||||
|
// General fields
|
||||||
|
purchaseDate,
|
||||||
|
purchasePrice,
|
||||||
|
notes
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
// Verify equipment type exists
|
// Validate required fields
|
||||||
const typeCheck = await pool.query(
|
if (!categoryId && !equipmentTypeId) {
|
||||||
'SELECT id, name, category FROM equipment_types WHERE id = $1',
|
throw new AppError('Either category or equipment type is required', 400);
|
||||||
[equipmentTypeId]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (typeCheck.rows.length === 0) {
|
|
||||||
throw new AppError('Equipment type not found', 404);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const equipmentType = typeCheck.rows[0];
|
// If equipmentTypeId is provided, verify it exists and get category
|
||||||
|
let finalCategoryId = categoryId;
|
||||||
|
let equipmentType = null;
|
||||||
|
|
||||||
|
if (equipmentTypeId) {
|
||||||
|
const typeCheck = await pool.query(
|
||||||
|
'SELECT id, name, category_id, manufacturer, model FROM equipment_types WHERE id = $1',
|
||||||
|
[equipmentTypeId]
|
||||||
|
);
|
||||||
|
|
||||||
// Validate required fields based on equipment type
|
if (typeCheck.rows.length === 0) {
|
||||||
if (equipmentType.category === 'sprayer') {
|
throw new AppError('Equipment type not found', 404);
|
||||||
if (!tankSize || !pumpGpm || !nozzleGpm || !nozzleCount) {
|
|
||||||
throw new AppError('Tank size, pump GPM, nozzle GPM, and nozzle count are required for sprayers', 400);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
equipmentType = typeCheck.rows[0];
|
||||||
|
finalCategoryId = equipmentType.category_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
customName,
|
categoryId,
|
||||||
tankSize,
|
customName,
|
||||||
pumpGpm,
|
manufacturer,
|
||||||
nozzleGpm,
|
model,
|
||||||
nozzleCount,
|
capacityLbs,
|
||||||
spreaderWidth
|
spreaderType,
|
||||||
|
spreadWidth,
|
||||||
|
tankSizeGallons,
|
||||||
|
sprayerType,
|
||||||
|
sprayWidthFeet,
|
||||||
|
pumpGpm,
|
||||||
|
pumpPsi,
|
||||||
|
boomSections,
|
||||||
|
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;
|
||||||
503
backend/src/routes/nozzles.js
Normal file
503
backend/src/routes/nozzles.js
Normal 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;
|
||||||
@@ -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();
|
||||||
200
database/migrations/comprehensive_equipment_upgrade.sql
Normal file
200
database/migrations/comprehensive_equipment_upgrade.sql
Normal 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;
|
||||||
@@ -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 (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex justify-center items-center h-64">
|
||||||
|
<LoadingSpinner size="lg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
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-between items-center mb-6">
|
||||||
<div className="card">
|
<div>
|
||||||
<p className="text-gray-600">Equipment management coming soon...</p>
|
<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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
Reference in New Issue
Block a user