admin
This commit is contained in:
@@ -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;
|
module.exports = router;
|
||||||
@@ -5,7 +5,8 @@ import {
|
|||||||
PlusIcon,
|
PlusIcon,
|
||||||
PencilIcon,
|
PencilIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
ExclamationTriangleIcon
|
ExclamationTriangleIcon,
|
||||||
|
ArrowUpIcon
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import { adminAPI, equipmentAPI } from '../../services/api';
|
import { adminAPI, equipmentAPI } from '../../services/api';
|
||||||
import LoadingSpinner from '../../components/UI/LoadingSpinner';
|
import LoadingSpinner from '../../components/UI/LoadingSpinner';
|
||||||
@@ -13,11 +14,13 @@ import toast from 'react-hot-toast';
|
|||||||
|
|
||||||
const AdminEquipment = () => {
|
const AdminEquipment = () => {
|
||||||
const [equipment, setEquipment] = useState([]);
|
const [equipment, setEquipment] = useState([]);
|
||||||
|
const [userEquipment, setUserEquipment] = useState([]);
|
||||||
const [equipmentTypes, setEquipmentTypes] = useState([]);
|
const [equipmentTypes, setEquipmentTypes] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [typeFilter, setTypeFilter] = useState('all');
|
const [typeFilter, setTypeFilter] = useState('all');
|
||||||
const [categoryFilter, setCategoryFilter] = useState('all');
|
const [categoryFilter, setCategoryFilter] = useState('all');
|
||||||
|
const [equipmentTypeFilter, setEquipmentTypeFilter] = useState('all');
|
||||||
const [selectedEquipment, setSelectedEquipment] = useState(null);
|
const [selectedEquipment, setSelectedEquipment] = useState(null);
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
const [showEditModal, setShowEditModal] = useState(false);
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
@@ -39,12 +42,14 @@ const AdminEquipment = () => {
|
|||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const [equipmentResponse, typesResponse] = await Promise.all([
|
const [equipmentResponse, userEquipmentResponse, typesResponse] = await Promise.all([
|
||||||
equipmentAPI.getAll(),
|
equipmentAPI.getAll(),
|
||||||
|
adminAPI.getAllUserEquipment(),
|
||||||
equipmentAPI.getTypes()
|
equipmentAPI.getTypes()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setEquipment(equipmentResponse.data.data.equipment || []);
|
setEquipment(equipmentResponse.data.data.equipment || []);
|
||||||
|
setUserEquipment(userEquipmentResponse.data.data.userEquipment || []);
|
||||||
setEquipmentTypes(typesResponse.data.data.equipmentTypes || []);
|
setEquipmentTypes(typesResponse.data.data.equipmentTypes || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch equipment:', 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 = () => {
|
const resetForm = () => {
|
||||||
setFormData({
|
setFormData({
|
||||||
customName: '',
|
customName: '',
|
||||||
@@ -122,8 +138,14 @@ const AdminEquipment = () => {
|
|||||||
setShowEditModal(true);
|
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
|
// Filter equipment based on filters
|
||||||
const filteredEquipment = equipment.filter(equip => {
|
const filteredEquipment = allEquipment.filter(equip => {
|
||||||
const matchesSearch = !searchTerm ||
|
const matchesSearch = !searchTerm ||
|
||||||
(equip.customName && equip.customName.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
(equip.customName && equip.customName.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
||||||
(equip.manufacturer && equip.manufacturer.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
(equip.manufacturer && equip.manufacturer.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
||||||
@@ -280,7 +302,7 @@ const AdminEquipment = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search and Filters */}
|
{/* Search and Filters */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<MagnifyingGlassIcon className="h-5 w-5 absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" />
|
<MagnifyingGlassIcon className="h-5 w-5 absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" />
|
||||||
<input
|
<input
|
||||||
@@ -313,6 +335,15 @@ const AdminEquipment = () => {
|
|||||||
<option value="aerator">Aerators</option>
|
<option value="aerator">Aerators</option>
|
||||||
<option value="seeder">Seeders</option>
|
<option value="seeder">Seeders</option>
|
||||||
</select>
|
</select>
|
||||||
|
<select
|
||||||
|
value={equipmentTypeFilter}
|
||||||
|
onChange={(e) => setEquipmentTypeFilter(e.target.value)}
|
||||||
|
className="px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="all">All Equipment</option>
|
||||||
|
<option value="shared">Shared Only</option>
|
||||||
|
<option value="custom">Custom Only</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -372,11 +403,26 @@ const AdminEquipment = () => {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="text-sm text-gray-900">{equip.userFirstName} {equip.userLastName}</div>
|
{equip.isShared ? (
|
||||||
<div className="text-sm text-gray-500">{equip.userEmail}</div>
|
<span className="text-gray-500">System</span>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-900">{equip.userName}</div>
|
||||||
|
<div className="text-sm text-gray-500">{equip.userEmail}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
|
{!equip.isShared && (
|
||||||
|
<button
|
||||||
|
onClick={() => handlePromoteToShared(equip)}
|
||||||
|
className="text-green-600 hover:text-green-900"
|
||||||
|
title="Promote to Shared Equipment Type"
|
||||||
|
>
|
||||||
|
<ArrowUpIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => openEditModal(equip)}
|
onClick={() => openEditModal(equip)}
|
||||||
className="text-indigo-600 hover:text-indigo-900"
|
className="text-indigo-600 hover:text-indigo-900"
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import {
|
|||||||
PencilIcon,
|
PencilIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
TagIcon
|
TagIcon,
|
||||||
|
ArrowUpIcon
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import { adminAPI, productsAPI } from '../../services/api';
|
import { adminAPI, productsAPI } from '../../services/api';
|
||||||
import LoadingSpinner from '../../components/UI/LoadingSpinner';
|
import LoadingSpinner from '../../components/UI/LoadingSpinner';
|
||||||
@@ -20,6 +21,7 @@ const AdminProducts = () => {
|
|||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [categoryFilter, setCategoryFilter] = useState('all');
|
const [categoryFilter, setCategoryFilter] = useState('all');
|
||||||
const [typeFilter, setTypeFilter] = useState('all');
|
const [typeFilter, setTypeFilter] = useState('all');
|
||||||
|
const [productTypeFilter, setProductTypeFilter] = useState('all');
|
||||||
const [selectedProduct, setSelectedProduct] = useState(null);
|
const [selectedProduct, setSelectedProduct] = useState(null);
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
const [showEditModal, setShowEditModal] = useState(false);
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
@@ -41,17 +43,21 @@ const AdminProducts = () => {
|
|||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const [productsResponse, categoriesResponse] = await Promise.all([
|
const [productsResponse, userProductsResponse, categoriesResponse] = await Promise.all([
|
||||||
adminAPI.getProducts({
|
adminAPI.getProducts({
|
||||||
search: searchTerm,
|
search: searchTerm,
|
||||||
category: categoryFilter !== 'all' ? categoryFilter : '',
|
category: categoryFilter !== 'all' ? categoryFilter : '',
|
||||||
type: typeFilter !== 'all' ? typeFilter : ''
|
type: typeFilter !== 'all' ? typeFilter : ''
|
||||||
}),
|
}),
|
||||||
|
adminAPI.getAllUserProducts({
|
||||||
|
search: searchTerm,
|
||||||
|
category: categoryFilter !== 'all' ? categoryFilter : ''
|
||||||
|
}),
|
||||||
productsAPI.getCategories()
|
productsAPI.getCategories()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setProducts(productsResponse.data.data.products || []);
|
setProducts(productsResponse.data.data.products || []);
|
||||||
setUserProducts([]); // Admin can only manage shared products for now
|
setUserProducts(userProductsResponse.data.data.userProducts || []);
|
||||||
setCategories(categoriesResponse.data.data.categories || []);
|
setCategories(categoriesResponse.data.data.categories || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch products:', error);
|
console.error('Failed to fetch products:', error);
|
||||||
@@ -106,6 +112,17 @@ const AdminProducts = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePromoteToShared = async (userProduct) => {
|
||||||
|
try {
|
||||||
|
await adminAPI.promoteUserProduct(userProduct.id);
|
||||||
|
toast.success(`"${userProduct.customName}" promoted to shared product`);
|
||||||
|
fetchData(); // Refresh the data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to promote product:', error);
|
||||||
|
toast.error('Failed to promote product to shared');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setFormData({
|
setFormData({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -161,8 +178,11 @@ const AdminProducts = () => {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only show shared products for admin management
|
// Combine shared and user products based on filter
|
||||||
const allProducts = products.map(p => ({ ...p, isShared: true }));
|
const allProducts = [
|
||||||
|
...(productTypeFilter === 'custom' ? [] : products.map(p => ({ ...p, isShared: true }))),
|
||||||
|
...(productTypeFilter === 'shared' ? [] : userProducts.map(p => ({ ...p, isShared: false })))
|
||||||
|
];
|
||||||
|
|
||||||
const ProductForm = ({ onSubmit, submitText }) => (
|
const ProductForm = ({ onSubmit, submitText }) => (
|
||||||
<form onSubmit={onSubmit} className="space-y-4">
|
<form onSubmit={onSubmit} className="space-y-4">
|
||||||
@@ -377,6 +397,15 @@ const AdminProducts = () => {
|
|||||||
<option value="seed">Seed</option>
|
<option value="seed">Seed</option>
|
||||||
<option value="powder">Powder</option>
|
<option value="powder">Powder</option>
|
||||||
</select>
|
</select>
|
||||||
|
<select
|
||||||
|
value={productTypeFilter}
|
||||||
|
onChange={(e) => setProductTypeFilter(e.target.value)}
|
||||||
|
className="px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="all">All Products</option>
|
||||||
|
<option value="shared">Shared Only</option>
|
||||||
|
<option value="custom">Custom Only</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -402,6 +431,12 @@ const AdminProducts = () => {
|
|||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Source
|
Source
|
||||||
</th>
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Owner
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Usage
|
||||||
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Actions
|
Actions
|
||||||
</th>
|
</th>
|
||||||
@@ -427,13 +462,13 @@ const AdminProducts = () => {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<span className={`px-2 py-1 text-xs font-medium rounded-full ${
|
<span className={`px-2 py-1 text-xs font-medium rounded-full ${
|
||||||
(product.productType || product.customProductType) === 'granular'
|
(product.productType || product.productType) === 'granular'
|
||||||
? 'bg-blue-100 text-blue-800'
|
? 'bg-blue-100 text-blue-800'
|
||||||
: (product.productType || product.customProductType) === 'liquid'
|
: (product.productType || product.productType) === 'liquid'
|
||||||
? 'bg-green-100 text-green-800'
|
? 'bg-green-100 text-green-800'
|
||||||
: 'bg-gray-100 text-gray-800'
|
: 'bg-gray-100 text-gray-800'
|
||||||
}`}>
|
}`}>
|
||||||
{product.productType || product.customProductType}
|
{product.productType}
|
||||||
</span>
|
</span>
|
||||||
</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">
|
||||||
@@ -448,8 +483,40 @@ const AdminProducts = () => {
|
|||||||
{product.isShared ? 'Shared' : 'Custom'}
|
{product.isShared ? 'Shared' : 'Custom'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{product.isShared ? (
|
||||||
|
<span className="text-gray-500">System</span>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{product.userName}</div>
|
||||||
|
<div className="text-xs text-gray-500">{product.userEmail}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{product.isShared ? (
|
||||||
|
<div>
|
||||||
|
<div>{product.rateCount || 0} rates</div>
|
||||||
|
<div className="text-xs text-gray-500">{product.usageCount || 0} users</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div>{product.spreaderSettingsCount || 0} spreader settings</div>
|
||||||
|
<div className="text-xs text-gray-500">{product.usageCount || 0} applications</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
|
{!product.isShared && (
|
||||||
|
<button
|
||||||
|
onClick={() => handlePromoteToShared(product)}
|
||||||
|
className="text-green-600 hover:text-green-900"
|
||||||
|
title="Promote to Shared Product"
|
||||||
|
>
|
||||||
|
<ArrowUpIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => openEditModal(product)}
|
onClick={() => openEditModal(product)}
|
||||||
className="text-indigo-600 hover:text-indigo-900"
|
className="text-indigo-600 hover:text-indigo-900"
|
||||||
|
|||||||
@@ -209,9 +209,15 @@ export const adminAPI = {
|
|||||||
|
|
||||||
// Products management
|
// Products management
|
||||||
getProducts: (params) => apiClient.get('/admin/products', { params }),
|
getProducts: (params) => apiClient.get('/admin/products', { params }),
|
||||||
|
getAllUserProducts: (params) => apiClient.get('/admin/products/user', { params }),
|
||||||
createProduct: (productData) => apiClient.post('/admin/products', productData),
|
createProduct: (productData) => apiClient.post('/admin/products', productData),
|
||||||
updateProduct: (id, productData) => apiClient.put(`/admin/products/${id}`, productData),
|
updateProduct: (id, productData) => apiClient.put(`/admin/products/${id}`, productData),
|
||||||
deleteProduct: (id) => apiClient.delete(`/admin/products/${id}`),
|
deleteProduct: (id) => apiClient.delete(`/admin/products/${id}`),
|
||||||
|
promoteUserProduct: (id) => apiClient.post(`/admin/products/user/${id}/promote`),
|
||||||
|
|
||||||
|
// Equipment management
|
||||||
|
getAllUserEquipment: (params) => apiClient.get('/admin/equipment/user', { params }),
|
||||||
|
promoteUserEquipment: (id) => apiClient.post(`/admin/equipment/user/${id}/promote`),
|
||||||
|
|
||||||
// System health
|
// System health
|
||||||
getSystemHealth: () => apiClient.get('/admin/system/health'),
|
getSystemHealth: () => apiClient.get('/admin/system/health'),
|
||||||
|
|||||||
Reference in New Issue
Block a user