multiarea

This commit is contained in:
Jake Kasper
2025-08-26 07:46:37 -05:00
parent 24c0fa7b86
commit 7983503e5e
4 changed files with 251 additions and 274 deletions

View File

@@ -70,21 +70,25 @@ router.get('/plans', async (req, res, next) => {
const whereClause = whereConditions.join(' AND ');
const result = await pool.query(
`SELECT ap.*, ls.name as section_name, ls.area as section_area,
`SELECT ap.*,
STRING_AGG(DISTINCT ls.name, ', ') as section_names,
SUM(ls.area) as total_section_area,
p.name as property_name, p.address as property_address,
ue.custom_name as equipment_name, et.name as equipment_type,
COUNT(app.id) as product_count,
COUNT(DISTINCT app.id) as product_count,
SUM(app.calculated_product_amount) as total_product_amount,
MAX(app.calculated_water_amount) as total_water_amount,
AVG(app.target_speed_mph) as avg_speed_mph
AVG(app.target_speed_mph) as avg_speed_mph,
COUNT(DISTINCT aps.lawn_section_id) as section_count
FROM application_plans ap
JOIN lawn_sections ls ON ap.lawn_section_id = ls.id
JOIN application_plan_sections aps ON ap.id = aps.plan_id
JOIN lawn_sections ls ON aps.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 application_plan_products app ON ap.id = app.plan_id
WHERE ${whereClause}
GROUP BY ap.id, ls.name, ls.area, p.name, p.address, ue.custom_name, et.name
GROUP BY ap.id, p.name, p.address, ue.custom_name, et.name
ORDER BY ap.planned_date DESC, ap.created_at DESC`,
queryParams
);
@@ -176,8 +180,9 @@ router.get('/plans', async (req, res, next) => {
status: plan.status,
plannedDate: plan.planned_date,
notes: plan.notes,
sectionName: plan.section_name,
sectionArea: parseFloat(plan.section_area),
sectionNames: plan.section_names, // Multiple section names comma-separated
sectionCount: parseInt(plan.section_count),
totalSectionArea: parseFloat(plan.total_section_area),
propertyName: plan.property_name,
propertyAddress: plan.property_address,
equipmentName: plan.equipment_name || plan.equipment_type,
@@ -211,23 +216,35 @@ router.get('/plans/:id', validateParams(idParamSchema), async (req, res, next) =
try {
const planId = req.params.id;
// Get plan details
// Get plan details with all sections
const planResult = await pool.query(
`SELECT ap.*, ls.name as section_name, ls.area as section_area, ls.polygon_data,
`SELECT ap.*,
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,
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 application_plan_sections aps ON ap.id = aps.plan_id
JOIN lawn_sections ls ON aps.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`,
WHERE ap.id = $1 AND ap.user_id = $2
LIMIT 1`,
[planId, req.user.id]
);
// Get sections for this plan separately
const sectionsResult = await pool.query(
`SELECT ls.id, ls.name, ls.area, ls.polygon_data
FROM application_plan_sections aps
JOIN lawn_sections ls ON aps.lawn_section_id = ls.id
WHERE aps.plan_id = $1
ORDER BY ls.name`,
[planId]
);
if (planResult.rows.length === 0) {
throw new AppError('Application plan not found', 404);
}
@@ -248,6 +265,15 @@ router.get('/plans/:id', validateParams(idParamSchema), async (req, res, next) =
[planId]
);
const sections = sectionsResult.rows.map(section => ({
id: section.id,
name: section.name,
area: parseFloat(section.area),
polygonData: section.polygon_data
}));
const totalArea = sections.reduce((sum, section) => sum + section.area, 0);
res.json({
success: true,
data: {
@@ -256,12 +282,8 @@ router.get('/plans/:id', validateParams(idParamSchema), async (req, res, next) =
status: plan.status,
plannedDate: plan.planned_date,
notes: plan.notes,
section: {
id: plan.lawn_section_id,
name: plan.section_name,
area: parseFloat(plan.section_area),
polygonData: plan.polygon_data
},
sections: sections, // Array of sections instead of single section
totalArea: totalArea,
property: {
id: plan.property_id,
name: plan.property_name,
@@ -309,6 +331,7 @@ router.post('/plans', validateRequest(applicationPlanSchema), async (req, res, n
try {
const {
lawnSectionId,
lawnSectionIds, // New multi-area support
equipmentId,
nozzleId,
plannedDate,
@@ -319,26 +342,36 @@ router.post('/plans', validateRequest(applicationPlanSchema), async (req, res, n
nozzle
} = req.body;
// Handle both single and multiple lawn sections
const sectionIds = lawnSectionIds || [lawnSectionId];
// Start transaction
const client = await pool.connect();
try {
await client.query('BEGIN');
// Verify lawn section belongs to user
// Verify all lawn sections belong to user and are from same property
const sectionCheck = await client.query(
`SELECT ls.id, ls.area, p.user_id
`SELECT ls.id, ls.area, p.user_id, p.id as property_id
FROM lawn_sections ls
JOIN properties p ON ls.property_id = p.id
WHERE ls.id = $1 AND p.user_id = $2`,
[lawnSectionId, req.user.id]
WHERE ls.id = ANY($1) AND p.user_id = $2`,
[sectionIds, req.user.id]
);
if (sectionCheck.rows.length === 0) {
throw new AppError('Lawn section not found', 404);
if (sectionCheck.rows.length !== sectionIds.length) {
throw new AppError('One or more lawn sections not found', 404);
}
const section = sectionCheck.rows[0];
// Ensure all sections are from the same property
const propertyIds = [...new Set(sectionCheck.rows.map(row => row.property_id))];
if (propertyIds.length > 1) {
throw new AppError('All sections must be from the same property', 400);
}
const sections = sectionCheck.rows;
const totalArea = areaSquareFeet || sections.reduce((sum, section) => sum + parseFloat(section.area), 0);
// Verify equipment belongs to user and get equipment details with category
const equipmentCheck = await client.query(
@@ -371,25 +404,34 @@ router.post('/plans', validateRequest(applicationPlanSchema), async (req, res, n
console.log('Creating plan with data:', {
userId: req.user.id,
lawnSectionId,
sectionIds,
equipmentId,
nozzleId,
plannedDate,
notes
});
// Create application plan
// Create application plan (no longer has lawn_section_id column)
const planResult = await client.query(
`INSERT INTO application_plans (user_id, lawn_section_id, equipment_id, nozzle_id, planned_date, notes)
VALUES ($1, $2, $3, $4, $5, $6)
`INSERT INTO application_plans (user_id, equipment_id, nozzle_id, planned_date, notes)
VALUES ($1, $2, $3, $4, $5)
RETURNING *`,
[req.user.id, lawnSectionId, equipmentId, nozzleId, plannedDate, notes]
[req.user.id, equipmentId, nozzleId, plannedDate, notes]
);
const plan = planResult.rows[0];
// Calculate shared water amount and speed for liquid applications
const sectionArea = areaSquareFeet || parseFloat(section.area);
// Create section associations in junction table
for (const sectionId of sectionIds) {
await client.query(
`INSERT INTO application_plan_sections (plan_id, lawn_section_id)
VALUES ($1, $2)`,
[plan.id, sectionId]
);
}
// Calculate shared water amount and speed for liquid applications using total area
const sectionArea = totalArea;
const firstProduct = products[0];
const isLiquid = firstProduct.applicationType === 'liquid';
@@ -539,13 +581,14 @@ router.post('/plans', validateRequest(applicationPlanSchema), async (req, res, n
});
// @route PUT /api/applications/plans/:id
// @desc Update application plan
// @desc Update application plan (supports multiple areas)
// @access Private
router.put('/plans/:id', validateParams(idParamSchema), validateRequest(applicationPlanSchema), async (req, res, next) => {
try {
const planId = req.params.id;
const {
lawnSectionId,
lawnSectionIds, // New multi-area support
equipmentId,
nozzleId,
plannedDate,
@@ -556,6 +599,9 @@ router.put('/plans/:id', validateParams(idParamSchema), validateRequest(applicat
nozzle
} = req.body;
// Handle both single and multiple lawn sections
const sectionIds = lawnSectionIds || [lawnSectionId];
const client = await pool.connect();
try {
@@ -571,20 +617,27 @@ router.put('/plans/:id', validateParams(idParamSchema), validateRequest(applicat
throw new AppError('Application plan not found', 404);
}
// Verify lawn section belongs to user
// Verify all new lawn sections belong to user and are from same property
const sectionCheck = await client.query(
`SELECT ls.id, ls.area, p.user_id
`SELECT ls.id, ls.area, p.user_id, p.id as property_id
FROM lawn_sections ls
JOIN properties p ON ls.property_id = p.id
WHERE ls.id = $1 AND p.user_id = $2`,
[lawnSectionId, req.user.id]
WHERE ls.id = ANY($1) AND p.user_id = $2`,
[sectionIds, req.user.id]
);
if (sectionCheck.rows.length === 0) {
throw new AppError('Lawn section not found', 404);
if (sectionCheck.rows.length !== sectionIds.length) {
throw new AppError('One or more lawn sections not found', 404);
}
const section = sectionCheck.rows[0];
// Ensure all sections are from the same property
const propertyIds = [...new Set(sectionCheck.rows.map(row => row.property_id))];
if (propertyIds.length > 1) {
throw new AppError('All sections must be from the same property', 400);
}
const sections = sectionCheck.rows;
const totalArea = areaSquareFeet || sections.reduce((sum, section) => sum + parseFloat(section.area), 0);
// Verify equipment belongs to user and get equipment details
const equipmentCheck = await client.query(
@@ -615,23 +668,34 @@ router.put('/plans/:id', validateParams(idParamSchema), validateRequest(applicat
}
}
// Update application plan
// Update application plan (no longer has lawn_section_id column)
const updateResult = await client.query(
`UPDATE application_plans
SET lawn_section_id = $1, equipment_id = $2, nozzle_id = $3,
planned_date = $4, notes = $5, updated_at = CURRENT_TIMESTAMP
WHERE id = $6
SET equipment_id = $1, nozzle_id = $2,
planned_date = $3, notes = $4, updated_at = CURRENT_TIMESTAMP
WHERE id = $5
RETURNING *`,
[lawnSectionId, equipmentId, nozzleId, plannedDate, notes, planId]
[equipmentId, nozzleId, plannedDate, notes, planId]
);
const plan = updateResult.rows[0];
// Update section associations - delete old ones and add new ones
await client.query('DELETE FROM application_plan_sections WHERE plan_id = $1', [planId]);
for (const sectionId of sectionIds) {
await client.query(
`INSERT INTO application_plan_sections (plan_id, lawn_section_id)
VALUES ($1, $2)`,
[plan.id, sectionId]
);
}
// Delete existing products
await client.query('DELETE FROM application_plan_products WHERE plan_id = $1', [planId]);
// Calculate shared water amount and speed for liquid applications
const sectionArea = areaSquareFeet || parseFloat(section.area);
// Calculate shared water amount and speed for liquid applications using total area
const sectionArea = totalArea;
const firstProduct = products[0];
const isLiquid = firstProduct.applicationType === 'liquid';

View File

@@ -123,7 +123,8 @@ const userProductSchema = Joi.object({
// Application validation schemas
const applicationPlanSchema = Joi.object({
lawnSectionId: Joi.number().integer().positive().required(),
lawnSectionId: Joi.number().integer().positive().optional(), // Keep for backward compatibility
lawnSectionIds: Joi.array().items(Joi.number().integer().positive()).min(1).optional(), // New multi-area support
equipmentId: Joi.number().integer().positive().required(),
nozzleId: Joi.number().integer().positive().optional(),
plannedDate: Joi.date().required(),
@@ -150,7 +151,7 @@ const applicationPlanSchema = Joi.object({
rateUnit: Joi.string().max(50).required(),
applicationType: Joi.string().valid('liquid', 'granular').optional()
})).min(1).required()
});
}).or('lawnSectionId', 'lawnSectionIds'); // At least one lawn section parameter is required
const applicationLogSchema = Joi.object({
planId: Joi.number().integer().positive(),

View File

@@ -0,0 +1,24 @@
-- Migration: Support multiple lawn sections per application plan
-- This allows a single plan to cover multiple areas of a property
-- Create junction table for plan-section relationships
CREATE TABLE application_plan_sections (
id SERIAL PRIMARY KEY,
plan_id INTEGER REFERENCES application_plans(id) ON DELETE CASCADE,
lawn_section_id INTEGER REFERENCES lawn_sections(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(plan_id, lawn_section_id)
);
-- Migrate existing data from application_plans.lawn_section_id to the junction table
INSERT INTO application_plan_sections (plan_id, lawn_section_id)
SELECT id, lawn_section_id
FROM application_plans
WHERE lawn_section_id IS NOT NULL;
-- Remove the lawn_section_id column from application_plans (it's now in the junction table)
ALTER TABLE application_plans DROP COLUMN lawn_section_id;
-- Create index for better performance on lookups
CREATE INDEX idx_application_plan_sections_plan_id ON application_plan_sections(plan_id);
CREATE INDEX idx_application_plan_sections_section_id ON application_plan_sections(lawn_section_id);

View File

@@ -398,21 +398,23 @@ const Applications = () => {
onSubmit={async (planData) => {
try {
if (editingPlan) {
// Edit existing plan - handle multiple areas
if (planData.selectedAreas.length === 1) {
// Single area - update existing plan normally
const selectedArea = selectedPropertyDetails.sections.find(s => s.id === planData.selectedAreas[0]);
const areaSquareFeet = selectedArea?.area || 0;
// Edit existing plan - backend now supports multiple areas natively
const selectedEquipment = equipment.find(eq => eq.id === parseInt(planData.equipmentId));
const selectedNozzle = planData.nozzleId ? nozzles.find(n => n.id === parseInt(planData.nozzleId)) : null;
// Calculate total area for all selected areas
const totalAreaSquareFeet = planData.selectedAreas.reduce((total, areaId) => {
const area = selectedPropertyDetails.sections.find(s => s.id === areaId);
return total + (area?.area || 0);
}, 0);
const planPayload = {
lawnSectionId: parseInt(planData.selectedAreas[0]),
lawnSectionIds: planData.selectedAreas.map(id => parseInt(id)), // Send multiple areas
equipmentId: parseInt(planData.equipmentId),
...(planData.applicationType === 'liquid' && planData.nozzleId && { nozzleId: parseInt(planData.nozzleId) }),
plannedDate: planData.plannedDate || new Date().toISOString().split('T')[0],
notes: planData.notes || '',
areaSquareFeet: areaSquareFeet,
areaSquareFeet: totalAreaSquareFeet,
equipment: {
id: selectedEquipment?.id,
categoryName: selectedEquipment?.categoryName,
@@ -451,136 +453,25 @@ const Applications = () => {
};
await applicationsAPI.updatePlan(editingPlan.id, planPayload);
toast.success('Application plan updated successfully');
toast.success(`Application plan updated successfully for ${planData.selectedAreas.length} area(s)`);
} else {
// Multiple areas - update existing plan for first area, create new plans for additional areas
// Create new plan - backend now supports multiple areas in single plan
const selectedEquipment = equipment.find(eq => eq.id === parseInt(planData.equipmentId));
const selectedNozzle = planData.nozzleId ? nozzles.find(n => n.id === parseInt(planData.nozzleId)) : null;
// Calculate total area for all selected areas (for quantity calculations)
// Calculate total area for all selected areas
const totalAreaSquareFeet = planData.selectedAreas.reduce((total, areaId) => {
const area = selectedPropertyDetails.sections.find(s => s.id === areaId);
return total + (area?.area || 0);
}, 0);
// Update existing plan with first selected area but use total area for calculations
const updatePayload = {
lawnSectionId: parseInt(planData.selectedAreas[0]),
equipmentId: parseInt(planData.equipmentId),
...(planData.applicationType === 'liquid' && planData.nozzleId && { nozzleId: parseInt(planData.nozzleId) }),
plannedDate: planData.plannedDate || new Date().toISOString().split('T')[0],
notes: planData.notes || '',
areaSquareFeet: totalAreaSquareFeet, // Use total area for proper quantity calculation
equipment: {
id: selectedEquipment?.id,
categoryName: selectedEquipment?.categoryName,
tankSizeGallons: selectedEquipment?.tankSizeGallons,
pumpGpm: selectedEquipment?.pumpGpm,
sprayWidthFeet: selectedEquipment?.sprayWidthFeet,
capacityLbs: selectedEquipment?.capacityLbs,
spreadWidth: selectedEquipment?.spreadWidth
},
...(planData.applicationType === 'liquid' && selectedNozzle && {
nozzle: {
id: selectedNozzle.id,
flowRateGpm: selectedNozzle.flowRateGpm,
sprayAngle: selectedNozzle.sprayAngle
}
}),
products: planData.applicationType === 'liquid'
? planData.selectedProducts.map(item => ({
...(item.product?.isShared
? { productId: parseInt(item.product.id) }
: { userProductId: parseInt(item.product.id) }
),
rateAmount: parseFloat(item.rateAmount || 1),
rateUnit: item.rateUnit || 'oz/1000 sq ft',
applicationType: planData.applicationType
}))
: [{
...(planData.selectedProduct?.isShared
? { productId: parseInt(planData.selectedProduct.id) }
: { userProductId: parseInt(planData.selectedProduct.id) }
),
rateAmount: parseFloat(planData.selectedProduct?.customRateAmount || planData.selectedProduct?.rateAmount || 1),
rateUnit: planData.selectedProduct?.customRateUnit || planData.selectedProduct?.rateUnit || 'per 1000sqft',
applicationType: planData.applicationType
}]
};
await applicationsAPI.updatePlan(editingPlan.id, updatePayload);
// Create new plans for additional areas
const additionalAreas = planData.selectedAreas.slice(1);
const additionalPlanPromises = additionalAreas.map(async (areaId) => {
const selectedArea = selectedPropertyDetails.sections.find(s => s.id === areaId);
const areaSquareFeet = selectedArea?.area || 0;
const planPayload = {
lawnSectionId: parseInt(areaId),
equipmentId: parseInt(planData.equipmentId),
...(planData.applicationType === 'liquid' && planData.nozzleId && { nozzleId: parseInt(planData.nozzleId) }),
plannedDate: planData.plannedDate || new Date().toISOString().split('T')[0],
notes: planData.notes || '',
areaSquareFeet: areaSquareFeet, // Individual area for this plan
equipment: {
id: selectedEquipment?.id,
categoryName: selectedEquipment?.categoryName,
tankSizeGallons: selectedEquipment?.tankSizeGallons,
pumpGpm: selectedEquipment?.pumpGpm,
sprayWidthFeet: selectedEquipment?.sprayWidthFeet,
capacityLbs: selectedEquipment?.capacityLbs,
spreadWidth: selectedEquipment?.spreadWidth
},
...(planData.applicationType === 'liquid' && selectedNozzle && {
nozzle: {
id: selectedNozzle.id,
flowRateGpm: selectedNozzle.flowRateGpm,
sprayAngle: selectedNozzle.sprayAngle
}
}),
products: planData.applicationType === 'liquid'
? planData.selectedProducts.map(item => ({
...(item.product?.isShared
? { productId: parseInt(item.product.id) }
: { userProductId: parseInt(item.product.id) }
),
rateAmount: parseFloat(item.rateAmount || 1),
rateUnit: item.rateUnit || 'oz/1000 sq ft',
applicationType: planData.applicationType
}))
: [{
...(planData.selectedProduct?.isShared
? { productId: parseInt(planData.selectedProduct.id) }
: { userProductId: parseInt(planData.selectedProduct.id) }
),
rateAmount: parseFloat(planData.selectedProduct?.customRateAmount || planData.selectedProduct?.rateAmount || 1),
rateUnit: planData.selectedProduct?.customRateUnit || planData.selectedProduct?.rateUnit || 'per 1000sqft',
applicationType: planData.applicationType
}]
};
return applicationsAPI.createPlan(planPayload);
});
await Promise.all(additionalPlanPromises);
toast.success(`Application plan updated and ${additionalAreas.length} additional plan(s) created for new areas`);
}
} else {
// Create new plan(s)
const planPromises = planData.selectedAreas.map(async (areaId) => {
const selectedArea = selectedPropertyDetails.sections.find(s => s.id === areaId);
const areaSquareFeet = selectedArea?.area || 0;
const selectedEquipment = equipment.find(eq => eq.id === parseInt(planData.equipmentId));
const selectedNozzle = planData.nozzleId ? nozzles.find(n => n.id === parseInt(planData.nozzleId)) : null;
const planPayload = {
lawnSectionId: parseInt(areaId),
lawnSectionIds: planData.selectedAreas.map(id => parseInt(id)), // Send multiple areas
equipmentId: parseInt(planData.equipmentId),
...(planData.applicationType === 'liquid' && planData.nozzleId && { nozzleId: parseInt(planData.nozzleId) }),
plannedDate: new Date().toISOString().split('T')[0],
notes: planData.notes || '',
areaSquareFeet: areaSquareFeet,
areaSquareFeet: totalAreaSquareFeet,
equipment: {
id: selectedEquipment?.id,
categoryName: selectedEquipment?.categoryName,
@@ -618,11 +509,8 @@ const Applications = () => {
}]
};
return applicationsAPI.createPlan(planPayload);
});
await Promise.all(planPromises);
toast.success(`Created ${planData.selectedAreas.length} application plan(s) successfully`);
await applicationsAPI.createPlan(planPayload);
toast.success(`Created application plan for ${planData.selectedAreas.length} area(s) successfully`);
}
setShowPlanForm(false);
@@ -720,7 +608,7 @@ const ApplicationPlanModal = ({
setPlanData({
propertyId: propertyId?.toString() || '',
selectedAreas: [editingPlan.section?.id].filter(Boolean), // Allow adding more areas
selectedAreas: editingPlan.sections?.map(s => s.id) || [], // Handle multiple areas
productId: selectedProduct?.uniqueId || '',
selectedProduct: selectedProduct,
selectedProducts: [],
@@ -749,7 +637,7 @@ const ApplicationPlanModal = ({
setPlanData({
propertyId: propertyId?.toString() || '',
selectedAreas: [editingPlan.section?.id].filter(Boolean), // Allow adding more areas
selectedAreas: editingPlan.sections?.map(s => s.id) || [], // Handle multiple areas
productId: '',
selectedProduct: null,
selectedProducts: selectedProducts,