spreader stuff

This commit is contained in:
Jake Kasper
2025-08-24 13:41:12 -04:00
parent 3ad4782021
commit 229454c466
10 changed files with 616 additions and 17 deletions

View File

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

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

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

View File

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

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

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

View File

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

View File

@@ -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',

View File

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

View File

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