From 822c200d86a5f5c0709c86e78c3c204f1e43afb2 Mon Sep 17 00:00:00 2001 From: Jake Kasper Date: Fri, 29 Aug 2025 08:28:30 -0400 Subject: [PATCH] admin --- backend/src/routes/admin.js | 296 +++++++++++++++++++++ frontend/src/pages/Admin/AdminEquipment.js | 58 +++- frontend/src/pages/Admin/AdminProducts.js | 83 +++++- frontend/src/services/api.js | 6 + 4 files changed, 429 insertions(+), 14 deletions(-) diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index c759491..682d364 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -526,4 +526,300 @@ router.get('/system/health', async (req, res, next) => { } }); +// @route GET /api/admin/products/user +// @desc Get all user products for admin management +// @access Private (Admin) +router.get('/products/user', async (req, res, next) => { + try { + const { search, category, user_id } = req.query; + + let whereConditions = []; + let queryParams = []; + let paramCount = 0; + + if (search) { + paramCount++; + whereConditions.push(`(up.custom_name ILIKE $${paramCount} OR up.custom_brand ILIKE $${paramCount} OR u.email ILIKE $${paramCount})`); + queryParams.push(`%${search}%`); + } + + if (category) { + paramCount++; + whereConditions.push(`up.category_id = $${paramCount}`); + queryParams.push(category); + } + + if (user_id) { + paramCount++; + whereConditions.push(`up.user_id = $${paramCount}`); + queryParams.push(user_id); + } + + const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(' AND ')}` : ''; + + const result = await pool.query(` + SELECT up.*, u.email as user_email, u.first_name, u.last_name, + pc.name as category_name, p.name as base_product_name, p.brand as base_product_brand, + COUNT(pss.id) as spreader_settings_count, + COUNT(DISTINCT app.id) as usage_count + FROM user_products up + JOIN users u ON up.user_id = u.id + LEFT JOIN product_categories pc ON up.category_id = pc.id + LEFT JOIN products p ON up.product_id = p.id + LEFT JOIN product_spreader_settings pss ON up.id = pss.user_product_id + LEFT JOIN application_plan_products app ON up.id = app.user_product_id + ${whereClause} + GROUP BY up.id, u.email, u.first_name, u.last_name, pc.name, p.name, p.brand + ORDER BY u.email, up.custom_name + `, queryParams); + + res.json({ + success: true, + data: { + userProducts: result.rows.map(product => ({ + id: product.id, + userId: product.user_id, + userEmail: product.user_email, + userName: `${product.first_name} ${product.last_name}`, + baseProductId: product.product_id, + baseProductName: product.base_product_name, + baseProductBrand: product.base_product_brand, + customName: product.custom_name, + customBrand: product.custom_brand, + categoryId: product.category_id, + categoryName: product.category_name, + productType: product.custom_product_type, + activeIngredients: product.custom_active_ingredients, + description: product.custom_description, + rateAmount: product.custom_rate_amount, + rateUnit: product.custom_rate_unit, + notes: product.notes, + spreaderSettingsCount: parseInt(product.spreader_settings_count), + usageCount: parseInt(product.usage_count), + createdAt: product.created_at, + updatedAt: product.updated_at + })) + } + }); + } catch (error) { + next(error); + } +}); + +// @route POST /api/admin/products/user/:id/promote +// @desc Promote user product to shared product +// @access Private (Admin) +router.post('/products/user/:id/promote', validateParams(idParamSchema), async (req, res, next) => { + try { + const userProductId = req.params.id; + + // Get the user product + const userProductResult = await pool.query( + `SELECT up.*, pc.name as category_name + FROM user_products up + LEFT JOIN product_categories pc ON up.category_id = pc.id + WHERE up.id = $1`, + [userProductId] + ); + + if (userProductResult.rows.length === 0) { + throw new AppError('User product not found', 404); + } + + const userProduct = userProductResult.rows[0]; + + // Create new shared product + const newProductResult = await pool.query(` + INSERT INTO products (name, brand, category_id, product_type, active_ingredients, description) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING * + `, [ + userProduct.custom_name, + userProduct.custom_brand || 'Generic', + userProduct.category_id, + userProduct.custom_product_type, + userProduct.custom_active_ingredients, + userProduct.custom_description + ]); + + const newProduct = newProductResult.rows[0]; + + // Create default rate if custom rate exists + if (userProduct.custom_rate_amount && userProduct.custom_rate_unit) { + await pool.query(` + INSERT INTO product_rates (product_id, application_type, rate_amount, rate_unit, notes) + VALUES ($1, $2, $3, $4, $5) + `, [ + newProduct.id, + userProduct.custom_product_type, + userProduct.custom_rate_amount, + userProduct.custom_rate_unit, + `Promoted from user product: ${userProduct.notes || ''}` + ]); + } + + res.json({ + success: true, + message: `User product "${userProduct.custom_name}" promoted to shared product`, + data: { + newProduct: { + id: newProduct.id, + name: newProduct.name, + brand: newProduct.brand, + categoryId: newProduct.category_id, + productType: newProduct.product_type, + activeIngredients: newProduct.active_ingredients, + description: newProduct.description + } + } + }); + } catch (error) { + next(error); + } +}); + +// @route GET /api/admin/equipment/user +// @desc Get all user equipment for admin management +// @access Private (Admin) +router.get('/equipment/user', async (req, res, next) => { + try { + const { search, category, user_id } = req.query; + + let whereConditions = []; + let queryParams = []; + let paramCount = 0; + + if (search) { + paramCount++; + whereConditions.push(`(ue.custom_name ILIKE $${paramCount} OR ue.manufacturer ILIKE $${paramCount} OR u.email ILIKE $${paramCount})`); + queryParams.push(`%${search}%`); + } + + if (category) { + paramCount++; + whereConditions.push(`ue.category_id = $${paramCount}`); + queryParams.push(category); + } + + if (user_id) { + paramCount++; + whereConditions.push(`ue.user_id = $${paramCount}`); + queryParams.push(user_id); + } + + const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(' AND ')}` : ''; + + const result = await pool.query(` + SELECT ue.*, u.email as user_email, u.first_name, u.last_name, + ec.name as category_name, et.name as type_name, et.manufacturer as type_manufacturer, et.model as type_model, + COUNT(DISTINCT nc.id) as nozzle_configurations, + COUNT(DISTINCT app.id) as usage_count + FROM user_equipment ue + JOIN users u ON ue.user_id = u.id + LEFT JOIN equipment_categories ec ON ue.category_id = ec.id + LEFT JOIN equipment_types et ON ue.equipment_type_id = et.id + LEFT JOIN nozzle_configurations nc ON ue.id = nc.sprayer_id + LEFT JOIN application_plans app ON ue.id = app.equipment_id + ${whereClause} + GROUP BY ue.id, u.email, u.first_name, u.last_name, ec.name, et.name, et.manufacturer, et.model + ORDER BY u.email, ue.custom_name + `, queryParams); + + res.json({ + success: true, + data: { + userEquipment: result.rows.map(equipment => ({ + id: equipment.id, + userId: equipment.user_id, + userEmail: equipment.user_email, + userName: `${equipment.first_name} ${equipment.last_name}`, + equipmentTypeId: equipment.equipment_type_id, + categoryId: equipment.category_id, + typeName: equipment.type_name, + typeManufacturer: equipment.type_manufacturer, + typeModel: equipment.type_model, + categoryName: equipment.category_name, + customName: equipment.custom_name, + manufacturer: equipment.manufacturer, + model: equipment.model, + // Spreader fields + capacityLbs: parseFloat(equipment.capacity_lbs) || null, + spreaderType: equipment.spreader_type, + spreadWidth: parseFloat(equipment.spread_width) || null, + // Sprayer fields + tankCapacityGallons: parseFloat(equipment.tank_capacity_gallons) || null, + sprayerType: equipment.sprayer_type, + boomWidth: parseFloat(equipment.boom_width) || null, + numberOfNozzles: parseInt(equipment.number_of_nozzles) || null, + // Common fields + yearManufactured: parseInt(equipment.year_manufactured) || null, + serialNumber: equipment.serial_number, + notes: equipment.notes, + isActive: equipment.is_active, + nozzleConfigurations: parseInt(equipment.nozzle_configurations), + usageCount: parseInt(equipment.usage_count), + createdAt: equipment.created_at, + updatedAt: equipment.updated_at + })) + } + }); + } catch (error) { + next(error); + } +}); + +// @route POST /api/admin/equipment/user/:id/promote +// @desc Promote user equipment to shared equipment type +// @access Private (Admin) +router.post('/equipment/user/:id/promote', validateParams(idParamSchema), async (req, res, next) => { + try { + const userEquipmentId = req.params.id; + + // Get the user equipment + const userEquipmentResult = await pool.query( + `SELECT ue.*, ec.name as category_name + FROM user_equipment ue + LEFT JOIN equipment_categories ec ON ue.category_id = ec.id + WHERE ue.id = $1`, + [userEquipmentId] + ); + + if (userEquipmentResult.rows.length === 0) { + throw new AppError('User equipment not found', 404); + } + + const userEquipment = userEquipmentResult.rows[0]; + + // Create new shared equipment type + const newEquipmentTypeResult = await pool.query(` + INSERT INTO equipment_types (name, manufacturer, model, category_id) + VALUES ($1, $2, $3, $4) + RETURNING * + `, [ + userEquipment.custom_name, + userEquipment.manufacturer || 'Generic', + userEquipment.model || 'Standard', + userEquipment.category_id + ]); + + const newEquipmentType = newEquipmentTypeResult.rows[0]; + + res.json({ + success: true, + message: `User equipment "${userEquipment.custom_name}" promoted to shared equipment type`, + data: { + newEquipmentType: { + id: newEquipmentType.id, + name: newEquipmentType.name, + manufacturer: newEquipmentType.manufacturer, + model: newEquipmentType.model, + categoryId: newEquipmentType.category_id + } + } + }); + } catch (error) { + next(error); + } +}); + module.exports = router; \ No newline at end of file diff --git a/frontend/src/pages/Admin/AdminEquipment.js b/frontend/src/pages/Admin/AdminEquipment.js index cfd7b06..8d9c5d7 100644 --- a/frontend/src/pages/Admin/AdminEquipment.js +++ b/frontend/src/pages/Admin/AdminEquipment.js @@ -5,7 +5,8 @@ import { PlusIcon, PencilIcon, TrashIcon, - ExclamationTriangleIcon + ExclamationTriangleIcon, + ArrowUpIcon } from '@heroicons/react/24/outline'; import { adminAPI, equipmentAPI } from '../../services/api'; import LoadingSpinner from '../../components/UI/LoadingSpinner'; @@ -13,11 +14,13 @@ import toast from 'react-hot-toast'; const AdminEquipment = () => { const [equipment, setEquipment] = useState([]); + const [userEquipment, setUserEquipment] = useState([]); const [equipmentTypes, setEquipmentTypes] = useState([]); const [loading, setLoading] = useState(true); const [searchTerm, setSearchTerm] = useState(''); const [typeFilter, setTypeFilter] = useState('all'); const [categoryFilter, setCategoryFilter] = useState('all'); + const [equipmentTypeFilter, setEquipmentTypeFilter] = useState('all'); const [selectedEquipment, setSelectedEquipment] = useState(null); const [showCreateModal, setShowCreateModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false); @@ -39,12 +42,14 @@ const AdminEquipment = () => { const fetchData = async () => { try { setLoading(true); - const [equipmentResponse, typesResponse] = await Promise.all([ + const [equipmentResponse, userEquipmentResponse, typesResponse] = await Promise.all([ equipmentAPI.getAll(), + adminAPI.getAllUserEquipment(), equipmentAPI.getTypes() ]); setEquipment(equipmentResponse.data.data.equipment || []); + setUserEquipment(userEquipmentResponse.data.data.userEquipment || []); setEquipmentTypes(typesResponse.data.data.equipmentTypes || []); } catch (error) { console.error('Failed to fetch equipment:', error); @@ -95,6 +100,17 @@ const AdminEquipment = () => { } }; + const handlePromoteToShared = async (userEquipment) => { + try { + await adminAPI.promoteUserEquipment(userEquipment.id); + toast.success(`"${userEquipment.customName}" promoted to shared equipment type`); + fetchData(); // Refresh the data + } catch (error) { + console.error('Failed to promote equipment:', error); + toast.error('Failed to promote equipment to shared'); + } + }; + const resetForm = () => { setFormData({ customName: '', @@ -122,8 +138,14 @@ const AdminEquipment = () => { setShowEditModal(true); }; + // Combine shared equipment (converted to user equipment format) and user equipment + const allEquipment = [ + ...(equipmentTypeFilter === 'custom' ? [] : equipment.map(e => ({ ...e, isShared: true, userName: 'System', userEmail: 'system@turftracker.com' }))), + ...(equipmentTypeFilter === 'shared' ? [] : userEquipment.map(e => ({ ...e, isShared: false }))) + ]; + // Filter equipment based on filters - const filteredEquipment = equipment.filter(equip => { + const filteredEquipment = allEquipment.filter(equip => { const matchesSearch = !searchTerm || (equip.customName && equip.customName.toLowerCase().includes(searchTerm.toLowerCase())) || (equip.manufacturer && equip.manufacturer.toLowerCase().includes(searchTerm.toLowerCase())) || @@ -280,7 +302,7 @@ const AdminEquipment = () => { {/* Search and Filters */} -
+
{ +
@@ -372,11 +403,26 @@ const AdminEquipment = () => {
-
{equip.userFirstName} {equip.userLastName}
-
{equip.userEmail}
+ {equip.isShared ? ( + System + ) : ( +
+
{equip.userName}
+
{equip.userEmail}
+
+ )}
+ {!equip.isShared && ( + + )}
@@ -402,6 +431,12 @@ const AdminProducts = () => { Source + + Owner + + + Usage + Actions @@ -427,13 +462,13 @@ const AdminProducts = () => { - {product.productType || product.customProductType} + {product.productType} @@ -448,8 +483,40 @@ const AdminProducts = () => { {product.isShared ? 'Shared' : 'Custom'} + + {product.isShared ? ( + System + ) : ( +
+
{product.userName}
+
{product.userEmail}
+
+ )} + + + {product.isShared ? ( +
+
{product.rateCount || 0} rates
+
{product.usageCount || 0} users
+
+ ) : ( +
+
{product.spreaderSettingsCount || 0} spreader settings
+
{product.usageCount || 0} applications
+
+ )} +
+ {!product.isShared && ( + + )}