spreader stuff
This commit is contained in:
@@ -13,6 +13,8 @@ const equipmentRoutes = require('./routes/equipment');
|
|||||||
const nozzleRoutes = require('./routes/nozzles');
|
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 spreaderSettingsRoutes = require('./routes/spreaderSettings');
|
||||||
|
const productSpreaderSettingsRoutes = require('./routes/productSpreaderSettings');
|
||||||
const weatherRoutes = require('./routes/weather');
|
const weatherRoutes = require('./routes/weather');
|
||||||
const adminRoutes = require('./routes/admin');
|
const adminRoutes = require('./routes/admin');
|
||||||
|
|
||||||
@@ -86,6 +88,8 @@ app.use('/api/equipment', authenticateToken, equipmentRoutes);
|
|||||||
app.use('/api/nozzles', authenticateToken, nozzleRoutes);
|
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/spreader-settings', authenticateToken, spreaderSettingsRoutes);
|
||||||
|
app.use('/api/product-spreader-settings', authenticateToken, productSpreaderSettingsRoutes);
|
||||||
app.use('/api/weather', authenticateToken, weatherRoutes);
|
app.use('/api/weather', authenticateToken, weatherRoutes);
|
||||||
app.use('/api/admin', authenticateToken, adminRoutes);
|
app.use('/api/admin', authenticateToken, adminRoutes);
|
||||||
|
|
||||||
|
|||||||
241
backend/src/routes/productSpreaderSettings.js
Normal file
241
backend/src/routes/productSpreaderSettings.js
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const pool = require('../config/database');
|
||||||
|
const { validateRequest, validateParams } = require('../utils/validation');
|
||||||
|
const { AppError } = require('../middleware/errorHandler');
|
||||||
|
const Joi = require('joi');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Validation schemas
|
||||||
|
const spreaderSettingSchema = Joi.object({
|
||||||
|
productId: Joi.number().integer().positive().optional(),
|
||||||
|
userProductId: Joi.number().integer().positive().optional(),
|
||||||
|
spreaderBrand: Joi.string().max(100).required(),
|
||||||
|
spreaderModel: Joi.string().max(100).optional(),
|
||||||
|
settingValue: Joi.string().max(20).required(),
|
||||||
|
rateDescription: Joi.string().max(200).optional(),
|
||||||
|
notes: Joi.string().optional()
|
||||||
|
}).xor('productId', 'userProductId'); // Must have either productId or userProductId, but not both
|
||||||
|
|
||||||
|
const idParamSchema = Joi.object({
|
||||||
|
id: Joi.number().integer().positive().required()
|
||||||
|
});
|
||||||
|
|
||||||
|
// @route GET /api/product-spreader-settings/product/:productId
|
||||||
|
// @desc Get spreader settings for a specific product
|
||||||
|
// @access Private
|
||||||
|
router.get('/product/:productId', validateParams(idParamSchema), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const productId = req.params.productId;
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT * FROM product_spreader_settings
|
||||||
|
WHERE product_id = $1
|
||||||
|
ORDER BY spreader_brand, spreader_model NULLS LAST, setting_value`,
|
||||||
|
[productId]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
settings: result.rows.map(row => ({
|
||||||
|
id: row.id,
|
||||||
|
spreaderBrand: row.spreader_brand,
|
||||||
|
spreaderModel: row.spreader_model,
|
||||||
|
settingValue: row.setting_value,
|
||||||
|
rateDescription: row.rate_description,
|
||||||
|
notes: row.notes,
|
||||||
|
createdAt: row.created_at
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// @route GET /api/product-spreader-settings/user-product/:userProductId
|
||||||
|
// @desc Get spreader settings for a specific user product
|
||||||
|
// @access Private
|
||||||
|
router.get('/user-product/:userProductId', validateParams(idParamSchema), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const userProductId = req.params.userProductId;
|
||||||
|
|
||||||
|
// Verify the user product belongs to the requesting user
|
||||||
|
const productCheck = await pool.query(
|
||||||
|
'SELECT id FROM user_products WHERE id = $1 AND user_id = $2',
|
||||||
|
[userProductId, req.user.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (productCheck.rows.length === 0) {
|
||||||
|
throw new AppError('User product not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT * FROM product_spreader_settings
|
||||||
|
WHERE user_product_id = $1
|
||||||
|
ORDER BY spreader_brand, spreader_model NULLS LAST, setting_value`,
|
||||||
|
[userProductId]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
settings: result.rows.map(row => ({
|
||||||
|
id: row.id,
|
||||||
|
spreaderBrand: row.spreader_brand,
|
||||||
|
spreaderModel: row.spreader_model,
|
||||||
|
settingValue: row.setting_value,
|
||||||
|
rateDescription: row.rate_description,
|
||||||
|
notes: row.notes,
|
||||||
|
createdAt: row.created_at
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// @route POST /api/product-spreader-settings
|
||||||
|
// @desc Add spreader setting to product
|
||||||
|
// @access Private
|
||||||
|
router.post('/', validateRequest(spreaderSettingSchema), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { productId, userProductId, spreaderBrand, spreaderModel, settingValue, rateDescription, notes } = req.body;
|
||||||
|
|
||||||
|
// If it's a user product, verify ownership
|
||||||
|
if (userProductId) {
|
||||||
|
const productCheck = await pool.query(
|
||||||
|
'SELECT id FROM user_products WHERE id = $1 AND user_id = $2',
|
||||||
|
[userProductId, req.user.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (productCheck.rows.length === 0) {
|
||||||
|
throw new AppError('User product not found', 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
`INSERT INTO product_spreader_settings
|
||||||
|
(product_id, user_product_id, spreader_brand, spreader_model, setting_value, rate_description, notes)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING *`,
|
||||||
|
[productId, userProductId, spreaderBrand, spreaderModel, settingValue, rateDescription, notes]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setting = result.rows[0];
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Spreader setting added successfully',
|
||||||
|
data: {
|
||||||
|
setting: {
|
||||||
|
id: setting.id,
|
||||||
|
spreaderBrand: setting.spreader_brand,
|
||||||
|
spreaderModel: setting.spreader_model,
|
||||||
|
settingValue: setting.setting_value,
|
||||||
|
rateDescription: setting.rate_description,
|
||||||
|
notes: setting.notes,
|
||||||
|
createdAt: setting.created_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// @route PUT /api/product-spreader-settings/:id
|
||||||
|
// @desc Update spreader setting
|
||||||
|
// @access Private
|
||||||
|
router.put('/:id', validateParams(idParamSchema), validateRequest(spreaderSettingSchema), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const settingId = req.params.id;
|
||||||
|
const { productId, userProductId, spreaderBrand, spreaderModel, settingValue, rateDescription, notes } = req.body;
|
||||||
|
|
||||||
|
// Check if setting exists and user has permission to edit it
|
||||||
|
let checkQuery;
|
||||||
|
let checkParams;
|
||||||
|
|
||||||
|
if (userProductId) {
|
||||||
|
checkQuery = `
|
||||||
|
SELECT pss.* FROM product_spreader_settings pss
|
||||||
|
JOIN user_products up ON pss.user_product_id = up.id
|
||||||
|
WHERE pss.id = $1 AND up.user_id = $2
|
||||||
|
`;
|
||||||
|
checkParams = [settingId, req.user.id];
|
||||||
|
} else {
|
||||||
|
// For shared products, any authenticated user can edit (you might want to restrict this)
|
||||||
|
checkQuery = 'SELECT * FROM product_spreader_settings WHERE id = $1';
|
||||||
|
checkParams = [settingId];
|
||||||
|
}
|
||||||
|
|
||||||
|
const settingCheck = await pool.query(checkQuery, checkParams);
|
||||||
|
|
||||||
|
if (settingCheck.rows.length === 0) {
|
||||||
|
throw new AppError('Spreader setting not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE product_spreader_settings
|
||||||
|
SET spreader_brand = $1, spreader_model = $2, setting_value = $3,
|
||||||
|
rate_description = $4, notes = $5
|
||||||
|
WHERE id = $6
|
||||||
|
RETURNING *`,
|
||||||
|
[spreaderBrand, spreaderModel, settingValue, rateDescription, notes, settingId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setting = result.rows[0];
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Spreader setting updated successfully',
|
||||||
|
data: {
|
||||||
|
setting: {
|
||||||
|
id: setting.id,
|
||||||
|
spreaderBrand: setting.spreader_brand,
|
||||||
|
spreaderModel: setting.spreader_model,
|
||||||
|
settingValue: setting.setting_value,
|
||||||
|
rateDescription: setting.rate_description,
|
||||||
|
notes: setting.notes,
|
||||||
|
updatedAt: setting.updated_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// @route DELETE /api/product-spreader-settings/:id
|
||||||
|
// @desc Delete spreader setting
|
||||||
|
// @access Private
|
||||||
|
router.delete('/:id', validateParams(idParamSchema), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const settingId = req.params.id;
|
||||||
|
|
||||||
|
// Check if setting exists and user has permission to delete it
|
||||||
|
const settingCheck = await pool.query(
|
||||||
|
`SELECT pss.* FROM product_spreader_settings pss
|
||||||
|
LEFT JOIN user_products up ON pss.user_product_id = up.id
|
||||||
|
WHERE pss.id = $1 AND (pss.product_id IS NOT NULL OR up.user_id = $2)`,
|
||||||
|
[settingId, req.user.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (settingCheck.rows.length === 0) {
|
||||||
|
throw new AppError('Spreader setting not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query('DELETE FROM product_spreader_settings WHERE id = $1', [settingId]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Spreader setting deleted successfully'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
93
backend/src/routes/spreaderSettings.js
Normal file
93
backend/src/routes/spreaderSettings.js
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const pool = require('../config/database');
|
||||||
|
const { AppError } = require('../middleware/errorHandler');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// @route GET /api/spreader-settings
|
||||||
|
// @desc Get all spreader settings
|
||||||
|
// @access Private
|
||||||
|
router.get('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT * FROM spreader_settings
|
||||||
|
ORDER BY spreader_brand, spreader_model NULLS LAST, setting_value`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Group by brand for easier frontend consumption
|
||||||
|
const groupedSettings = result.rows.reduce((acc, setting) => {
|
||||||
|
const brand = setting.spreader_brand;
|
||||||
|
if (!acc[brand]) {
|
||||||
|
acc[brand] = [];
|
||||||
|
}
|
||||||
|
acc[brand].push({
|
||||||
|
id: setting.id,
|
||||||
|
model: setting.spreader_model,
|
||||||
|
setting: setting.setting_value,
|
||||||
|
description: setting.application_rate_description
|
||||||
|
});
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
settings: result.rows,
|
||||||
|
groupedSettings
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// @route GET /api/spreader-settings/brands
|
||||||
|
// @desc Get list of spreader brands
|
||||||
|
// @access Private
|
||||||
|
router.get('/brands', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT DISTINCT spreader_brand as brand,
|
||||||
|
COUNT(*) as setting_count
|
||||||
|
FROM spreader_settings
|
||||||
|
GROUP BY spreader_brand
|
||||||
|
ORDER BY spreader_brand`
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
brands: result.rows
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// @route GET /api/spreader-settings/:brand
|
||||||
|
// @desc Get settings for a specific brand
|
||||||
|
// @access Private
|
||||||
|
router.get('/:brand', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const brand = req.params.brand;
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT * FROM spreader_settings
|
||||||
|
WHERE spreader_brand = $1
|
||||||
|
ORDER BY spreader_model NULLS LAST, setting_value`,
|
||||||
|
[brand]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
brand,
|
||||||
|
settings: result.rows
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -133,7 +133,8 @@ function calculateGranularApplication(areaSquareFeet, rateAmount, rateUnit, equi
|
|||||||
console.log(`Calculating granular application:
|
console.log(`Calculating granular application:
|
||||||
Area: ${areaSquareFeet} sq ft (${areaAcres.toFixed(3)} acres, ${area1000sqft.toFixed(1)} x 1000sqft)
|
Area: ${areaSquareFeet} sq ft (${areaAcres.toFixed(3)} acres, ${area1000sqft.toFixed(1)} x 1000sqft)
|
||||||
Rate: ${rateAmount} ${rateUnit}
|
Rate: ${rateAmount} ${rateUnit}
|
||||||
Equipment: ${equipment?.categoryName}`);
|
Equipment: ${equipment?.categoryName} (${equipment?.spreaderBrand || 'unknown brand'})
|
||||||
|
Spreader Setting: ${equipment?.spreaderSetting || 'not specified'}`);
|
||||||
|
|
||||||
// Calculate product amount based on rate unit
|
// Calculate product amount based on rate unit
|
||||||
if (rateUnit.includes('lbs/1000sqft') || rateUnit.includes('lbs per 1000sqft') || rateUnit.includes('lb/1000sqft')) {
|
if (rateUnit.includes('lbs/1000sqft') || rateUnit.includes('lbs per 1000sqft') || rateUnit.includes('lb/1000sqft')) {
|
||||||
@@ -145,6 +146,17 @@ function calculateGranularApplication(areaSquareFeet, rateAmount, rateUnit, equi
|
|||||||
} else if (rateUnit.includes('oz/1000sqft') || rateUnit.includes('oz per 1000sqft')) {
|
} else if (rateUnit.includes('oz/1000sqft') || rateUnit.includes('oz per 1000sqft')) {
|
||||||
// Rate is ounces per 1000 square feet, convert to pounds
|
// Rate is ounces per 1000 square feet, convert to pounds
|
||||||
productPounds = (area1000sqft * rateAmount) / 16; // 16 oz = 1 lb
|
productPounds = (area1000sqft * rateAmount) / 16; // 16 oz = 1 lb
|
||||||
|
} else if (rateUnit.includes('covers') || rateUnit.includes('coverage')) {
|
||||||
|
// Handle bag coverage rates like "50 lb covers 16,000 sq ft"
|
||||||
|
// Format: "weight coverage_area" (e.g., "50 16000")
|
||||||
|
// rateAmount should be the coverage area per unit weight
|
||||||
|
const coveragePerPound = rateAmount; // sq ft per pound
|
||||||
|
productPounds = areaSquareFeet / coveragePerPound;
|
||||||
|
console.log(`Bag coverage calculation: ${areaSquareFeet} sq ft / ${coveragePerPound} sq ft per lb = ${productPounds.toFixed(2)} lbs`);
|
||||||
|
} else if (rateUnit.includes('lb covers') || rateUnit.includes('lbs cover')) {
|
||||||
|
// Alternative format: "1 lb covers X sq ft"
|
||||||
|
const coveragePerPound = rateAmount;
|
||||||
|
productPounds = areaSquareFeet / coveragePerPound;
|
||||||
} else {
|
} else {
|
||||||
// Fallback: assume rate is per 1000 sq ft
|
// Fallback: assume rate is per 1000 sq ft
|
||||||
productPounds = area1000sqft * rateAmount;
|
productPounds = area1000sqft * rateAmount;
|
||||||
|
|||||||
31
database/migrations/add_spreader_settings.sql
Normal file
31
database/migrations/add_spreader_settings.sql
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
-- Add spreader settings for granular applications
|
||||||
|
-- This allows storing different spreader settings for various equipment brands
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS spreader_settings (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
spreader_brand VARCHAR(100) NOT NULL,
|
||||||
|
spreader_model VARCHAR(100),
|
||||||
|
setting_value VARCHAR(10) NOT NULL,
|
||||||
|
application_rate_description VARCHAR(200),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Add some common spreader settings from the LESCO example
|
||||||
|
INSERT INTO spreader_settings (spreader_brand, spreader_model, setting_value, application_rate_description) VALUES
|
||||||
|
('LESCO', 'All Rotary Models', '#14', '1 lb nitrogen per 1000 sq ft'),
|
||||||
|
('PermaGreen', NULL, '14', '1 lb nitrogen per 1000 sq ft'),
|
||||||
|
('Cyclone', NULL, '4', '1 lb nitrogen per 1000 sq ft'),
|
||||||
|
('Spyker', NULL, '4', '1 lb nitrogen per 1000 sq ft'),
|
||||||
|
('Z-Spray', NULL, '4', '1 lb nitrogen per 1000 sq ft'),
|
||||||
|
('Vicon', 'LESCO Pendulum', '20', '1 lb nitrogen per 1000 sq ft');
|
||||||
|
|
||||||
|
-- Add spreader setting field to user equipment for spreaders
|
||||||
|
ALTER TABLE user_equipment
|
||||||
|
ADD COLUMN IF NOT EXISTS spreader_setting VARCHAR(10),
|
||||||
|
ADD COLUMN IF NOT EXISTS spreader_brand VARCHAR(100);
|
||||||
|
|
||||||
|
-- Create index for better performance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_spreader_settings_brand ON spreader_settings(spreader_brand);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_equipment_spreader ON user_equipment(spreader_brand);
|
||||||
|
|
||||||
|
SELECT 'Added spreader settings support successfully!' as migration_status;
|
||||||
56
database/migrations/user_spreader_system.sql
Normal file
56
database/migrations/user_spreader_system.sql
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
-- Update user equipment for better spreader support
|
||||||
|
-- Note: we already have 'manufacturer' and 'model' columns, so let's use those
|
||||||
|
-- Add brand as alias for manufacturer for consistency with new API
|
||||||
|
ALTER TABLE user_equipment
|
||||||
|
ADD COLUMN IF NOT EXISTS brand VARCHAR(100),
|
||||||
|
ADD COLUMN IF NOT EXISTS notes TEXT;
|
||||||
|
|
||||||
|
-- Copy existing manufacturer data to brand field for consistency
|
||||||
|
UPDATE user_equipment SET brand = manufacturer WHERE brand IS NULL AND manufacturer IS NOT NULL;
|
||||||
|
|
||||||
|
-- Create table to store product-specific spreader settings
|
||||||
|
-- This links products to specific spreader settings
|
||||||
|
CREATE TABLE IF NOT EXISTS product_spreader_settings (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
product_id INTEGER REFERENCES products(id) ON DELETE CASCADE,
|
||||||
|
user_product_id INTEGER REFERENCES user_products(id) ON DELETE CASCADE,
|
||||||
|
spreader_brand VARCHAR(100) NOT NULL,
|
||||||
|
spreader_model VARCHAR(100),
|
||||||
|
setting_value VARCHAR(20) NOT NULL,
|
||||||
|
rate_description VARCHAR(200), -- e.g., "1 lb nitrogen per 1000 sq ft"
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
-- Ensure we have either product_id or user_product_id, but not both
|
||||||
|
CHECK (
|
||||||
|
(product_id IS NOT NULL AND user_product_id IS NULL) OR
|
||||||
|
(product_id IS NULL AND user_product_id IS NOT NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create indexes for better performance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_product_spreader_settings_product ON product_spreader_settings(product_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_product_spreader_settings_user_product ON product_spreader_settings(user_product_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_product_spreader_settings_brand ON product_spreader_settings(spreader_brand);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_equipment_brand_model ON user_equipment(brand, model);
|
||||||
|
|
||||||
|
-- Add trigger to update updated_at timestamp
|
||||||
|
CREATE OR REPLACE FUNCTION update_product_spreader_settings_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ language 'plpgsql';
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS update_product_spreader_settings_updated_at_trigger ON product_spreader_settings;
|
||||||
|
CREATE TRIGGER update_product_spreader_settings_updated_at_trigger
|
||||||
|
BEFORE UPDATE ON product_spreader_settings
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE PROCEDURE update_product_spreader_settings_updated_at();
|
||||||
|
|
||||||
|
-- Drop the old spreader_settings table since we're using a different approach
|
||||||
|
DROP TABLE IF EXISTS spreader_settings;
|
||||||
|
|
||||||
|
SELECT 'Updated spreader system for user-defined spreaders and product settings!' as migration_status;
|
||||||
@@ -275,7 +275,7 @@ const Applications = () => {
|
|||||||
const planPayload = {
|
const planPayload = {
|
||||||
lawnSectionId: parseInt(planData.selectedAreas[0]),
|
lawnSectionId: parseInt(planData.selectedAreas[0]),
|
||||||
equipmentId: parseInt(planData.equipmentId),
|
equipmentId: parseInt(planData.equipmentId),
|
||||||
nozzleId: planData.nozzleId ? parseInt(planData.nozzleId) : null,
|
...(planData.applicationType === 'liquid' && planData.nozzleId && { nozzleId: parseInt(planData.nozzleId) }),
|
||||||
plannedDate: planData.plannedDate || new Date().toISOString().split('T')[0],
|
plannedDate: planData.plannedDate || new Date().toISOString().split('T')[0],
|
||||||
notes: planData.notes || '',
|
notes: planData.notes || '',
|
||||||
areaSquareFeet: areaSquareFeet,
|
areaSquareFeet: areaSquareFeet,
|
||||||
@@ -288,11 +288,13 @@ const Applications = () => {
|
|||||||
capacityLbs: selectedEquipment?.capacityLbs,
|
capacityLbs: selectedEquipment?.capacityLbs,
|
||||||
spreadWidth: selectedEquipment?.spreadWidth
|
spreadWidth: selectedEquipment?.spreadWidth
|
||||||
},
|
},
|
||||||
nozzle: selectedNozzle ? {
|
...(planData.applicationType === 'liquid' && selectedNozzle && {
|
||||||
id: selectedNozzle.id,
|
nozzle: {
|
||||||
flowRateGpm: selectedNozzle.flowRateGpm,
|
id: selectedNozzle.id,
|
||||||
sprayAngle: selectedNozzle.sprayAngle
|
flowRateGpm: selectedNozzle.flowRateGpm,
|
||||||
} : null,
|
sprayAngle: selectedNozzle.sprayAngle
|
||||||
|
}
|
||||||
|
}),
|
||||||
products: [{
|
products: [{
|
||||||
...(planData.selectedProduct?.isShared
|
...(planData.selectedProduct?.isShared
|
||||||
? { productId: parseInt(planData.selectedProduct.id) }
|
? { productId: parseInt(planData.selectedProduct.id) }
|
||||||
@@ -317,7 +319,7 @@ const Applications = () => {
|
|||||||
const planPayload = {
|
const planPayload = {
|
||||||
lawnSectionId: parseInt(areaId),
|
lawnSectionId: parseInt(areaId),
|
||||||
equipmentId: parseInt(planData.equipmentId),
|
equipmentId: parseInt(planData.equipmentId),
|
||||||
nozzleId: planData.nozzleId ? parseInt(planData.nozzleId) : null,
|
...(planData.applicationType === 'liquid' && planData.nozzleId && { nozzleId: parseInt(planData.nozzleId) }),
|
||||||
plannedDate: new Date().toISOString().split('T')[0],
|
plannedDate: new Date().toISOString().split('T')[0],
|
||||||
notes: planData.notes || '',
|
notes: planData.notes || '',
|
||||||
areaSquareFeet: areaSquareFeet,
|
areaSquareFeet: areaSquareFeet,
|
||||||
@@ -330,11 +332,13 @@ const Applications = () => {
|
|||||||
capacityLbs: selectedEquipment?.capacityLbs,
|
capacityLbs: selectedEquipment?.capacityLbs,
|
||||||
spreadWidth: selectedEquipment?.spreadWidth
|
spreadWidth: selectedEquipment?.spreadWidth
|
||||||
},
|
},
|
||||||
nozzle: selectedNozzle ? {
|
...(planData.applicationType === 'liquid' && selectedNozzle && {
|
||||||
id: selectedNozzle.id,
|
nozzle: {
|
||||||
flowRateGpm: selectedNozzle.flowRateGpm,
|
id: selectedNozzle.id,
|
||||||
sprayAngle: selectedNozzle.sprayAngle
|
flowRateGpm: selectedNozzle.flowRateGpm,
|
||||||
} : null,
|
sprayAngle: selectedNozzle.sprayAngle
|
||||||
|
}
|
||||||
|
}),
|
||||||
products: [{
|
products: [{
|
||||||
...(planData.selectedProduct?.isShared
|
...(planData.selectedProduct?.isShared
|
||||||
? { productId: parseInt(planData.selectedProduct.id) }
|
? { productId: parseInt(planData.selectedProduct.id) }
|
||||||
|
|||||||
@@ -493,6 +493,7 @@ const EquipmentFormModal = ({ isEdit, equipment, categories, equipmentTypes, onS
|
|||||||
customName: equipment?.customName || '',
|
customName: equipment?.customName || '',
|
||||||
manufacturer: equipment?.manufacturer || '',
|
manufacturer: equipment?.manufacturer || '',
|
||||||
model: equipment?.model || '',
|
model: equipment?.model || '',
|
||||||
|
notes: equipment?.notes || '',
|
||||||
// Spreader fields
|
// Spreader fields
|
||||||
capacityLbs: equipment?.capacityLbs || '',
|
capacityLbs: equipment?.capacityLbs || '',
|
||||||
spreaderType: equipment?.spreaderType || 'walk_behind',
|
spreaderType: equipment?.spreaderType || 'walk_behind',
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
TrashIcon,
|
TrashIcon,
|
||||||
PencilIcon
|
PencilIcon
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import { productsAPI } from '../../services/api';
|
import { productsAPI, productSpreaderSettingsAPI } from '../../services/api';
|
||||||
import LoadingSpinner from '../../components/UI/LoadingSpinner';
|
import LoadingSpinner from '../../components/UI/LoadingSpinner';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
@@ -67,8 +67,27 @@ const Products = () => {
|
|||||||
|
|
||||||
const handleCreateProduct = async (productData) => {
|
const handleCreateProduct = async (productData) => {
|
||||||
try {
|
try {
|
||||||
await productsAPI.createUserProduct(productData);
|
// Create the product first
|
||||||
toast.success('Custom product created successfully!');
|
const response = await productsAPI.createUserProduct(productData);
|
||||||
|
const createdProduct = response.data.data.product;
|
||||||
|
|
||||||
|
// Save spreader settings if any
|
||||||
|
if (productData.spreaderSettings && productData.spreaderSettings.length > 0) {
|
||||||
|
const settingPromises = productData.spreaderSettings.map(setting =>
|
||||||
|
productSpreaderSettingsAPI.create({
|
||||||
|
userProductId: createdProduct.id,
|
||||||
|
spreaderBrand: setting.spreaderBrand,
|
||||||
|
spreaderModel: setting.spreaderModel || null,
|
||||||
|
settingValue: setting.settingValue,
|
||||||
|
rateDescription: setting.rateDescription || null,
|
||||||
|
notes: setting.notes || null
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(settingPromises);
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(`Custom product created successfully${productData.spreaderSettings?.length ? ` with ${productData.spreaderSettings.length} spreader setting(s)` : ''}!`);
|
||||||
setShowCreateForm(false);
|
setShowCreateForm(false);
|
||||||
fetchData();
|
fetchData();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -397,6 +416,35 @@ const CreateProductModal = ({ onSubmit, onCancel, sharedProducts, categories })
|
|||||||
notes: ''
|
notes: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [spreaderSettings, setSpreaderSettings] = useState([]);
|
||||||
|
const [newSpreaderSetting, setNewSpreaderSetting] = useState({
|
||||||
|
spreaderBrand: '',
|
||||||
|
spreaderModel: '',
|
||||||
|
settingValue: '',
|
||||||
|
rateDescription: '',
|
||||||
|
notes: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const addSpreaderSetting = () => {
|
||||||
|
if (!newSpreaderSetting.spreaderBrand || !newSpreaderSetting.settingValue) {
|
||||||
|
toast.error('Please enter spreader brand and setting value');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSpreaderSettings([...spreaderSettings, { ...newSpreaderSetting, id: Date.now() }]);
|
||||||
|
setNewSpreaderSetting({
|
||||||
|
spreaderBrand: '',
|
||||||
|
spreaderModel: '',
|
||||||
|
settingValue: '',
|
||||||
|
rateDescription: '',
|
||||||
|
notes: ''
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeSpreaderSetting = (id) => {
|
||||||
|
setSpreaderSettings(spreaderSettings.filter(setting => setting.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
const handleSubmit = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@@ -416,7 +464,8 @@ const CreateProductModal = ({ onSubmit, onCancel, sharedProducts, categories })
|
|||||||
customName: formData.customName || null,
|
customName: formData.customName || null,
|
||||||
customRateAmount: formData.customRateAmount ? parseFloat(formData.customRateAmount) : null,
|
customRateAmount: formData.customRateAmount ? parseFloat(formData.customRateAmount) : null,
|
||||||
customRateUnit: formData.customRateUnit || null,
|
customRateUnit: formData.customRateUnit || null,
|
||||||
notes: formData.notes || null
|
notes: formData.notes || null,
|
||||||
|
spreaderSettings: formData.productType === 'granular' ? spreaderSettings : []
|
||||||
};
|
};
|
||||||
|
|
||||||
onSubmit(submitData);
|
onSubmit(submitData);
|
||||||
@@ -547,6 +596,98 @@ const CreateProductModal = ({ onSubmit, onCancel, sharedProducts, categories })
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Spreader Settings for Granular Products */}
|
||||||
|
{formData.productType === 'granular' && (
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<h4 className="text-md font-semibold text-gray-900 mb-3">Spreader Settings</h4>
|
||||||
|
<p className="text-sm text-gray-600 mb-4">
|
||||||
|
Add spreader settings for different brands/models. This helps determine the correct spreader dial setting when applying this product.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* List existing spreader settings */}
|
||||||
|
{spreaderSettings.length > 0 && (
|
||||||
|
<div className="space-y-2 mb-4">
|
||||||
|
<label className="label">Added Settings:</label>
|
||||||
|
{spreaderSettings.map((setting) => (
|
||||||
|
<div key={setting.id} className="flex items-center justify-between bg-gray-50 p-3 rounded-lg">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium">
|
||||||
|
{setting.spreaderBrand} {setting.spreaderModel && `${setting.spreaderModel}`} - Setting: {setting.settingValue}
|
||||||
|
</div>
|
||||||
|
{setting.rateDescription && (
|
||||||
|
<div className="text-sm text-gray-600">{setting.rateDescription}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeSpreaderSetting(setting.id)}
|
||||||
|
className="text-red-600 hover:text-red-800"
|
||||||
|
>
|
||||||
|
<TrashIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add new spreader setting form */}
|
||||||
|
<div className="bg-blue-50 p-4 rounded-lg space-y-3">
|
||||||
|
<h5 className="text-sm font-semibold text-blue-900">Add Spreader Setting</h5>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="label text-xs">Spreader Brand *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input text-sm"
|
||||||
|
value={newSpreaderSetting.spreaderBrand}
|
||||||
|
onChange={(e) => setNewSpreaderSetting({ ...newSpreaderSetting, spreaderBrand: e.target.value })}
|
||||||
|
placeholder="LESCO, PermaGreen, Cyclone, etc."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label text-xs">Model (Optional)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input text-sm"
|
||||||
|
value={newSpreaderSetting.spreaderModel}
|
||||||
|
onChange={(e) => setNewSpreaderSetting({ ...newSpreaderSetting, spreaderModel: e.target.value })}
|
||||||
|
placeholder="Model name/number"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="label text-xs">Setting Value *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input text-sm"
|
||||||
|
value={newSpreaderSetting.settingValue}
|
||||||
|
onChange={(e) => setNewSpreaderSetting({ ...newSpreaderSetting, settingValue: e.target.value })}
|
||||||
|
placeholder="#14, 4, 20, etc."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label text-xs">Rate Description</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input text-sm"
|
||||||
|
value={newSpreaderSetting.rateDescription}
|
||||||
|
onChange={(e) => setNewSpreaderSetting({ ...newSpreaderSetting, rateDescription: e.target.value })}
|
||||||
|
placeholder="e.g., 1 lb N per 1000 sq ft"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addSpreaderSetting}
|
||||||
|
className="btn-primary text-sm px-3 py-1"
|
||||||
|
>
|
||||||
|
Add Setting
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Notes</label>
|
<label className="label">Notes</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@@ -181,6 +181,22 @@ export const applicationsAPI = {
|
|||||||
getStats: (params) => apiClient.get('/applications/stats', { params }),
|
getStats: (params) => apiClient.get('/applications/stats', { params }),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Spreader Settings API endpoints
|
||||||
|
export const spreaderSettingsAPI = {
|
||||||
|
getAll: () => apiClient.get('/spreader-settings'),
|
||||||
|
getBrands: () => apiClient.get('/spreader-settings/brands'),
|
||||||
|
getByBrand: (brand) => apiClient.get(`/spreader-settings/${brand}`),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Product Spreader Settings API endpoints
|
||||||
|
export const productSpreaderSettingsAPI = {
|
||||||
|
getByProduct: (productId) => apiClient.get(`/product-spreader-settings/product/${productId}`),
|
||||||
|
getByUserProduct: (userProductId) => apiClient.get(`/product-spreader-settings/user-product/${userProductId}`),
|
||||||
|
create: (settingData) => apiClient.post('/product-spreader-settings', settingData),
|
||||||
|
update: (id, settingData) => apiClient.put(`/product-spreader-settings/${id}`, settingData),
|
||||||
|
delete: (id) => apiClient.delete(`/product-spreader-settings/${id}`),
|
||||||
|
};
|
||||||
|
|
||||||
// Weather API endpoints
|
// Weather API endpoints
|
||||||
export const weatherAPI = {
|
export const weatherAPI = {
|
||||||
getCurrent: (propertyId) => apiClient.get(`/weather/${propertyId}`),
|
getCurrent: (propertyId) => apiClient.get(`/weather/${propertyId}`),
|
||||||
|
|||||||
Reference in New Issue
Block a user