This commit is contained in:
Jake Kasper
2025-08-26 07:29:10 -05:00
parent 0e7f6c32f4
commit 05b4334ac6
2 changed files with 270 additions and 22 deletions

View File

@@ -216,12 +216,14 @@ router.get('/plans/:id', validateParams(idParamSchema), async (req, res, next) =
`SELECT ap.*, ls.name as section_name, ls.area as section_area, ls.polygon_data,
p.id as property_id, p.name as property_name, p.address as property_address,
ue.id as equipment_id, ue.custom_name as equipment_name,
et.name as equipment_type, et.category as equipment_category
et.name as equipment_type, et.category as equipment_category,
nz.id as nozzle_id, nz.custom_name as nozzle_name, nz.flow_rate_gpm, nz.spray_angle
FROM application_plans ap
JOIN lawn_sections ls ON ap.lawn_section_id = ls.id
JOIN properties p ON ls.property_id = p.id
LEFT JOIN user_equipment ue ON ap.equipment_id = ue.id
LEFT JOIN equipment_types et ON ue.equipment_type_id = et.id
LEFT JOIN user_equipment nz ON ap.nozzle_id = nz.id
WHERE ap.id = $1 AND ap.user_id = $2`,
[planId, req.user.id]
);
@@ -271,6 +273,12 @@ router.get('/plans/:id', validateParams(idParamSchema), async (req, res, next) =
type: plan.equipment_type,
category: plan.equipment_category
},
nozzle: plan.nozzle_id ? {
id: plan.nozzle_id,
name: plan.nozzle_name,
flowRateGpm: plan.flow_rate_gpm,
sprayAngle: plan.spray_angle
} : null,
products: productsResult.rows.map(product => ({
id: product.id,
productId: product.product_id,

View File

@@ -536,6 +536,19 @@ const ApplicationPlanModal = ({
editingPlan
}) => {
const [loadingProperty, setLoadingProperty] = useState(false);
const [showSpreaderSettingPrompt, setShowSpreaderSettingPrompt] = useState(false);
const [missingSpreaderSetting, setMissingSpreaderSetting] = useState({
productId: null,
userProductId: null,
equipmentId: null,
productName: '',
equipmentName: ''
});
const [newSpreaderSetting, setNewSpreaderSetting] = useState({
settingValue: '',
rateDescription: '',
notes: ''
});
const [planData, setPlanData] = useState({
propertyId: '',
@@ -550,33 +563,87 @@ const ApplicationPlanModal = ({
notes: ''
});
// Reset form when modal opens fresh (not editing)
useEffect(() => {
if (!editingPlan) {
setPlanData({
propertyId: '',
selectedAreas: [],
productId: '',
selectedProduct: null,
selectedProducts: [],
applicationType: '',
equipmentId: '',
nozzleId: '',
plannedDate: '',
notes: ''
});
}
}, [editingPlan]);
// Initialize form with editing data
useEffect(() => {
if (editingPlan && products.length > 0) {
const propertyId = editingPlan.section?.propertyId || editingPlan.property?.id;
// Find the product from the plans products array
const planProduct = editingPlan.products?.[0];
let selectedProduct = null;
if (planProduct) {
if (planProduct.productId) {
selectedProduct = products.find(p => p.uniqueId === `shared_${planProduct.productId}`);
} else if (planProduct.userProductId) {
selectedProduct = products.find(p => p.uniqueId === `user_${planProduct.userProductId}`);
// Determine application type from the first product
const firstProduct = editingPlan.products?.[0];
const applicationType = firstProduct?.productType === 'granular' ? 'granular' : 'liquid';
// Handle different application types
if (applicationType === 'granular') {
// Granular - single product
let selectedProduct = null;
if (firstProduct) {
if (firstProduct.productId) {
selectedProduct = products.find(p => p.uniqueId === `shared_${firstProduct.productId}`);
} else if (firstProduct.userProductId) {
selectedProduct = products.find(p => p.uniqueId === `user_${firstProduct.userProductId}`);
}
}
}
setPlanData({
propertyId: propertyId?.toString() || '',
selectedAreas: [editingPlan.section?.id],
productId: selectedProduct?.uniqueId || '',
selectedProduct: selectedProduct,
applicationType: planProduct?.applicationType || '',
equipmentId: editingPlan.equipment?.id?.toString() || '',
nozzleId: editingPlan.nozzle?.id?.toString() || '',
plannedDate: editingPlan.plannedDate ? new Date(editingPlan.plannedDate).toISOString().split('T')[0] : '',
notes: editingPlan.notes || ''
});
setPlanData({
propertyId: propertyId?.toString() || '',
selectedAreas: [editingPlan.section?.id],
productId: selectedProduct?.uniqueId || '',
selectedProduct: selectedProduct,
selectedProducts: [],
applicationType: 'granular',
equipmentId: editingPlan.equipment?.id?.toString() || '',
nozzleId: editingPlan.nozzle?.id?.toString() || '',
plannedDate: editingPlan.plannedDate ? new Date(editingPlan.plannedDate).toISOString().split('T')[0] : '',
notes: editingPlan.notes || ''
});
} else {
// Liquid - multiple products (tank mix)
const selectedProducts = editingPlan.products?.map(product => {
let foundProduct = null;
if (product.productId) {
foundProduct = products.find(p => p.uniqueId === `shared_${product.productId}`);
} else if (product.userProductId) {
foundProduct = products.find(p => p.uniqueId === `user_${product.userProductId}`);
}
return {
product: foundProduct,
rateAmount: product.rateAmount || 1,
rateUnit: product.rateUnit || 'oz/1000 sq ft'
};
}).filter(item => item.product) || [];
setPlanData({
propertyId: propertyId?.toString() || '',
selectedAreas: [editingPlan.section?.id],
productId: '',
selectedProduct: null,
selectedProducts: selectedProducts,
applicationType: 'liquid',
equipmentId: editingPlan.equipment?.id?.toString() || '',
nozzleId: editingPlan.nozzle?.id?.toString() || '',
plannedDate: editingPlan.plannedDate ? new Date(editingPlan.plannedDate).toISOString().split('T')[0] : '',
notes: editingPlan.notes || ''
});
}
// Only fetch property details if we don't already have them
if (propertyId && (!selectedPropertyDetails || selectedPropertyDetails.id !== propertyId)) {
@@ -595,6 +662,91 @@ const ApplicationPlanModal = ({
}
};
// Check for spreader settings when granular product + equipment selected
const checkSpreaderSetting = async (product, equipmentId) => {
if (!product || !equipmentId ||
(product.productType !== 'granular' && product.customProductType !== 'granular')) {
return;
}
const selectedEquipment = equipment.find(eq => eq.id === parseInt(equipmentId));
if (!selectedEquipment || selectedEquipment.categoryName !== 'Spreader') {
return;
}
try {
// Check if spreader setting exists
const productApiId = product.isShared ? product.id : product.id;
const endpoint = product.isShared
? `/api/product-spreader-settings/product/${productApiId}`
: `/api/product-spreader-settings/user-product/${productApiId}`;
const response = await fetch(endpoint, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('authToken')}`
}
});
if (response.ok) {
const data = await response.json();
const settings = data.data?.settings || [];
// Check if there's a setting for this specific equipment
const existingSetting = settings.find(setting =>
setting.equipmentId === selectedEquipment.id
);
if (!existingSetting) {
// No setting found - prompt user to create one
setMissingSpreaderSetting({
productId: product.isShared ? product.id : null,
userProductId: product.isShared ? null : product.id,
equipmentId: selectedEquipment.id,
productName: product.customName || product.name,
equipmentName: selectedEquipment.customName
});
setShowSpreaderSettingPrompt(true);
}
}
} catch (error) {
console.error('Failed to check spreader settings:', error);
}
};
// Save new spreader setting
const saveSpreaderSetting = async () => {
try {
const settingData = {
...(missingSpreaderSetting.productId && { productId: missingSpreaderSetting.productId }),
...(missingSpreaderSetting.userProductId && { userProductId: missingSpreaderSetting.userProductId }),
equipmentId: missingSpreaderSetting.equipmentId,
settingValue: newSpreaderSetting.settingValue,
rateDescription: newSpreaderSetting.rateDescription || null,
notes: newSpreaderSetting.notes || null
};
const response = await fetch('/api/product-spreader-settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('authToken')}`
},
body: JSON.stringify(settingData)
});
if (response.ok) {
toast.success('Spreader setting saved successfully');
setShowSpreaderSettingPrompt(false);
setNewSpreaderSetting({ settingValue: '', rateDescription: '', notes: '' });
} else {
toast.error('Failed to save spreader setting');
}
} catch (error) {
console.error('Failed to save spreader setting:', error);
toast.error('Failed to save spreader setting');
}
};
// Filter equipment based on application type
const availableEquipment = equipment.filter(eq => {
@@ -798,6 +950,10 @@ const ApplicationPlanModal = ({
productId: e.target.value,
selectedProduct: selectedProduct
});
// Check for spreader settings when granular product and spreader are both selected
if (selectedProduct && planData.equipmentId) {
checkSpreaderSetting(selectedProduct, planData.equipmentId);
}
}}
required
>
@@ -951,7 +1107,14 @@ const ApplicationPlanModal = ({
<select
className="input"
value={planData.equipmentId}
onChange={(e) => setPlanData({ ...planData, equipmentId: e.target.value })}
onChange={(e) => {
const newEquipmentId = e.target.value;
setPlanData({ ...planData, equipmentId: newEquipmentId });
// Check for spreader settings when granular product and spreader are both selected
if (planData.applicationType === 'granular' && planData.selectedProduct && newEquipmentId) {
checkSpreaderSetting(planData.selectedProduct, newEquipmentId);
}
}}
required
>
<option value="">Select equipment...</option>
@@ -1041,6 +1204,83 @@ const ApplicationPlanModal = ({
</button>
</div>
</form>
{/* Spreader Setting Prompt Modal */}
{showSpreaderSettingPrompt && (
<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-md">
<h3 className="text-lg font-semibold mb-4">Spreader Setting Required</h3>
<p className="text-sm text-gray-600 mb-4">
No spreader setting found for <strong>{missingSpreaderSetting.productName}</strong> with <strong>{missingSpreaderSetting.equipmentName}</strong>.
<br />Please enter the spreader setting to continue.
</p>
<div className="space-y-4">
<div>
<label className="label">Spreader Setting *</label>
<input
type="text"
className="input"
value={newSpreaderSetting.settingValue}
onChange={(e) => setNewSpreaderSetting({
...newSpreaderSetting,
settingValue: e.target.value
})}
placeholder="e.g., 3.5, B, 4"
required
/>
</div>
<div>
<label className="label">Rate Description (Optional)</label>
<input
type="text"
className="input"
value={newSpreaderSetting.rateDescription}
onChange={(e) => setNewSpreaderSetting({
...newSpreaderSetting,
rateDescription: e.target.value
})}
placeholder="e.g., 2 lbs per 1000 sq ft"
/>
</div>
<div>
<label className="label">Notes (Optional)</label>
<textarea
className="input"
rows="2"
value={newSpreaderSetting.notes}
onChange={(e) => setNewSpreaderSetting({
...newSpreaderSetting,
notes: e.target.value
})}
placeholder="Additional notes or instructions"
/>
</div>
</div>
<div className="flex gap-3 mt-6">
<button
onClick={saveSpreaderSetting}
className="btn-primary flex-1"
disabled={!newSpreaderSetting.settingValue}
>
Save Setting
</button>
<button
onClick={() => {
setShowSpreaderSettingPrompt(false);
setNewSpreaderSetting({ settingValue: '', rateDescription: '', notes: '' });
}}
className="btn-secondary flex-1"
>
Cancel
</button>
</div>
</div>
</div>
)}
</div>
</div>
);