This commit is contained in:
Jake Kasper
2025-08-29 08:41:43 -04:00
parent cb160f21cc
commit e5275398ea
3 changed files with 256 additions and 17 deletions

View File

@@ -859,12 +859,11 @@ router.get('/products/user/:id/spreader-settings', validateParams(idParamSchema)
const userProductId = req.params.id; const userProductId = req.params.id;
const result = await pool.query(` const result = await pool.query(`
SELECT pss.*, ue.custom_name as equipment_name, ss.brand_name, ss.model, ss.setting_number SELECT pss.*, ue.custom_name as equipment_name, ue.manufacturer, ue.model as equipment_model
FROM product_spreader_settings pss FROM product_spreader_settings pss
JOIN user_equipment ue ON pss.user_equipment_id = ue.id LEFT JOIN user_equipment ue ON pss.equipment_id = ue.id
LEFT JOIN spreader_settings ss ON pss.spreader_setting_id = ss.id
WHERE pss.user_product_id = $1 WHERE pss.user_product_id = $1
ORDER BY ue.custom_name, ss.brand_name ORDER BY ue.custom_name NULLS LAST, pss.spreader_brand, pss.spreader_model NULLS LAST, pss.setting_value
`, [userProductId]); `, [userProductId]);
res.json({ res.json({
@@ -873,14 +872,14 @@ router.get('/products/user/:id/spreader-settings', validateParams(idParamSchema)
spreaderSettings: result.rows.map(setting => ({ spreaderSettings: result.rows.map(setting => ({
id: setting.id, id: setting.id,
userProductId: setting.user_product_id, userProductId: setting.user_product_id,
userEquipmentId: setting.user_equipment_id, equipmentId: setting.equipment_id,
equipmentName: setting.equipment_name, equipmentName: setting.equipment_name,
spreaderSettingId: setting.spreader_setting_id, equipmentManufacturer: setting.manufacturer,
brandName: setting.brand_name, equipmentModel: setting.equipment_model,
model: setting.model, spreaderBrand: setting.spreader_brand,
settingNumber: setting.setting_number, spreaderModel: setting.spreader_model,
customRate: setting.custom_rate ? parseFloat(setting.custom_rate) : null, settingValue: setting.setting_value,
customRateUnit: setting.custom_rate_unit, rateDescription: setting.rate_description,
notes: setting.notes, notes: setting.notes,
createdAt: setting.created_at createdAt: setting.created_at
})) }))
@@ -891,4 +890,81 @@ router.get('/products/user/:id/spreader-settings', validateParams(idParamSchema)
} }
}); });
// @route POST /api/admin/products/user/:id/spreader-settings
// @desc Add spreader setting for a user product
// @access Private (Admin)
router.post('/products/user/:id/spreader-settings', validateParams(idParamSchema), async (req, res, next) => {
try {
const userProductId = req.params.id;
const { equipmentId, spreaderBrand, spreaderModel, settingValue, rateDescription, notes } = req.body;
// Verify the user product exists
const productCheck = await pool.query(
'SELECT id FROM user_products WHERE id = $1',
[userProductId]
);
if (productCheck.rows.length === 0) {
throw new AppError('User product not found', 404);
}
const result = await pool.query(`
INSERT INTO product_spreader_settings
(user_product_id, equipment_id, spreader_brand, spreader_model, setting_value, rate_description, notes)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *
`, [userProductId, equipmentId, spreaderBrand, spreaderModel, settingValue, rateDescription, notes]);
const setting = result.rows[0];
res.status(201).json({
success: true,
message: 'Spreader setting added successfully',
data: {
spreaderSetting: {
id: setting.id,
userProductId: setting.user_product_id,
equipmentId: setting.equipment_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 DELETE /api/admin/products/user/spreader-settings/:id
// @desc Delete spreader setting
// @access Private (Admin)
router.delete('/products/user/spreader-settings/:id', validateParams(idParamSchema), async (req, res, next) => {
try {
const settingId = req.params.id;
// Check if setting exists
const settingCheck = await pool.query(
'SELECT id FROM product_spreader_settings WHERE id = $1',
[settingId]
);
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; module.exports = router;

View File

@@ -29,6 +29,15 @@ const AdminProducts = () => {
const [showDeleteModal, setShowDeleteModal] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showDetailsModal, setShowDetailsModal] = useState(false); const [showDetailsModal, setShowDetailsModal] = useState(false);
const [productDetails, setProductDetails] = useState({ rates: [], spreaderSettings: [] }); const [productDetails, setProductDetails] = useState({ rates: [], spreaderSettings: [] });
const [showAddSettingForm, setShowAddSettingForm] = useState(false);
const [newSettingData, setNewSettingData] = useState({
equipmentId: '',
spreaderBrand: '',
spreaderModel: '',
settingValue: '',
rateDescription: '',
notes: ''
});
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: '',
brand: '', brand: '',
@@ -150,6 +159,40 @@ const AdminProducts = () => {
} }
}; };
const handleAddSpreaderSetting = async (e) => {
e.preventDefault();
try {
await adminAPI.addUserProductSpreaderSetting(selectedProduct.id, newSettingData);
toast.success('Spreader setting added successfully');
setShowAddSettingForm(false);
setNewSettingData({
equipmentId: '',
spreaderBrand: '',
spreaderModel: '',
settingValue: '',
rateDescription: '',
notes: ''
});
// Refresh the settings
showProductDetails(selectedProduct);
} catch (error) {
console.error('Failed to add spreader setting:', error);
toast.error('Failed to add spreader setting');
}
};
const handleDeleteSpreaderSetting = async (settingId) => {
try {
await adminAPI.deleteUserProductSpreaderSetting(settingId);
toast.success('Spreader setting deleted successfully');
// Refresh the settings
showProductDetails(selectedProduct);
} catch (error) {
console.error('Failed to delete spreader setting:', error);
toast.error('Failed to delete spreader setting');
}
};
const resetForm = () => { const resetForm = () => {
setFormData({ setFormData({
name: '', name: '',
@@ -697,29 +740,39 @@ const AdminProducts = () => {
<tr> <tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Equipment</th> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Equipment</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Spreader</th> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Spreader</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Setting</th> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Setting Value</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Custom Rate</th> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Rate Description</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Notes</th> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Notes</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody className="bg-white divide-y divide-gray-200"> <tbody className="bg-white divide-y divide-gray-200">
{productDetails.spreaderSettings.map((setting, index) => ( {productDetails.spreaderSettings.map((setting, index) => (
<tr key={index} className="hover:bg-gray-50"> <tr key={index} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900"> <td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{setting.equipmentName} {setting.equipmentName || 'Generic Equipment'}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{setting.brandName} {setting.model} {setting.spreaderBrand} {setting.spreaderModel}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{setting.settingNumber || 'N/A'} {setting.settingValue || 'N/A'}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{setting.customRate ? `${setting.customRate} ${setting.customRateUnit}` : 'Standard'} {setting.rateDescription || 'Not specified'}
</td> </td>
<td className="px-6 py-4 text-sm text-gray-900"> <td className="px-6 py-4 text-sm text-gray-900">
{setting.notes || 'No notes'} {setting.notes || 'No notes'}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<button
onClick={() => handleDeleteSpreaderSetting(setting.id)}
className="text-red-600 hover:text-red-900"
title="Delete Setting"
>
<TrashIcon className="h-4 w-4" />
</button>
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -728,6 +781,105 @@ const AdminProducts = () => {
) : ( ) : (
<p className="text-gray-500 text-center py-8">No spreader settings configured for this product.</p> <p className="text-gray-500 text-center py-8">No spreader settings configured for this product.</p>
)} )}
{/* Add Setting Button */}
<div className="mt-4">
{!showAddSettingForm ? (
<button
onClick={() => setShowAddSettingForm(true)}
className="btn-primary flex items-center text-sm"
>
<PlusIcon className="h-4 w-4 mr-1" />
Add Spreader Setting
</button>
) : (
<div className="border rounded-lg p-4 bg-gray-50">
<h5 className="text-sm font-semibold text-gray-800 mb-3">Add New Spreader Setting</h5>
<form onSubmit={handleAddSpreaderSetting} className="space-y-3">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Spreader Brand</label>
<input
type="text"
required
value={newSettingData.spreaderBrand}
onChange={(e) => setNewSettingData({ ...newSettingData, spreaderBrand: e.target.value })}
className="w-full px-2 py-1 text-sm border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="e.g. Scotts, TruGreen"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Spreader Model</label>
<input
type="text"
value={newSettingData.spreaderModel}
onChange={(e) => setNewSettingData({ ...newSettingData, spreaderModel: e.target.value })}
className="w-full px-2 py-1 text-sm border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="e.g. EdgeGuard, DLX"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Setting Value</label>
<input
type="text"
value={newSettingData.settingValue}
onChange={(e) => setNewSettingData({ ...newSettingData, settingValue: e.target.value })}
className="w-full px-2 py-1 text-sm border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="e.g. 3.5, M, Large"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Rate Description</label>
<input
type="text"
value={newSettingData.rateDescription}
onChange={(e) => setNewSettingData({ ...newSettingData, rateDescription: e.target.value })}
className="w-full px-2 py-1 text-sm border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="e.g. 2.5 lbs/1000 sq ft"
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Notes</label>
<textarea
value={newSettingData.notes}
onChange={(e) => setNewSettingData({ ...newSettingData, notes: e.target.value })}
className="w-full px-2 py-1 text-sm border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500"
rows="2"
placeholder="Additional notes or instructions"
/>
</div>
<div className="flex justify-end space-x-2">
<button
type="button"
onClick={() => {
setShowAddSettingForm(false);
setNewSettingData({
equipmentId: '',
spreaderBrand: '',
spreaderModel: '',
settingValue: '',
rateDescription: '',
notes: ''
});
}}
className="px-3 py-1 text-xs text-gray-700 bg-gray-200 rounded hover:bg-gray-300"
>
Cancel
</button>
<button
type="submit"
className="px-3 py-1 text-xs text-white bg-blue-600 rounded hover:bg-blue-700"
>
Add Setting
</button>
</div>
</form>
</div>
)}
</div>
</div> </div>
)} )}
@@ -737,6 +889,15 @@ const AdminProducts = () => {
setShowDetailsModal(false); setShowDetailsModal(false);
setSelectedProduct(null); setSelectedProduct(null);
setProductDetails({ rates: [], spreaderSettings: [] }); setProductDetails({ rates: [], spreaderSettings: [] });
setShowAddSettingForm(false);
setNewSettingData({
equipmentId: '',
spreaderBrand: '',
spreaderModel: '',
settingValue: '',
rateDescription: '',
notes: ''
});
}} }}
className="px-4 py-2 text-sm text-gray-700 bg-gray-200 rounded hover:bg-gray-300" className="px-4 py-2 text-sm text-gray-700 bg-gray-200 rounded hover:bg-gray-300"
> >

View File

@@ -216,6 +216,8 @@ export const adminAPI = {
promoteUserProduct: (id) => apiClient.post(`/admin/products/user/${id}/promote`), promoteUserProduct: (id) => apiClient.post(`/admin/products/user/${id}/promote`),
getProductRates: (id) => apiClient.get(`/admin/products/${id}/rates`), getProductRates: (id) => apiClient.get(`/admin/products/${id}/rates`),
getUserProductSpreaderSettings: (id) => apiClient.get(`/admin/products/user/${id}/spreader-settings`), getUserProductSpreaderSettings: (id) => apiClient.get(`/admin/products/user/${id}/spreader-settings`),
addUserProductSpreaderSetting: (id, settingData) => apiClient.post(`/admin/products/user/${id}/spreader-settings`, settingData),
deleteUserProductSpreaderSetting: (id) => apiClient.delete(`/admin/products/user/spreader-settings/${id}`),
// Equipment management // Equipment management
getAllUserEquipment: (params) => apiClient.get('/admin/equipment/user', { params }), getAllUserEquipment: (params) => apiClient.get('/admin/equipment/user', { params }),