added email user support, user disabling and internal notes
This commit is contained in:
parent
84b3ac3603
commit
57ce946666
15 changed files with 4574 additions and 834 deletions
|
|
@ -15,6 +15,8 @@ const authRoutes = require('./routes/auth');
|
||||||
const cartRoutes = require('./routes/cart');
|
const cartRoutes = require('./routes/cart');
|
||||||
const productAdminRoutes = require('./routes/productAdmin');
|
const productAdminRoutes = require('./routes/productAdmin');
|
||||||
const categoryAdminRoutes = require('./routes/categoryAdmin'); // Add category admin routes
|
const categoryAdminRoutes = require('./routes/categoryAdmin'); // Add category admin routes
|
||||||
|
const usersAdminRoutes = require('./routes/userAdmin');
|
||||||
|
const ordersAdminRoutes = require('./routes/orderAdmin');
|
||||||
// Create Express app
|
// Create Express app
|
||||||
const app = express();
|
const app = express();
|
||||||
const port = config.port || 4000;
|
const port = config.port || 4000;
|
||||||
|
|
@ -89,6 +91,7 @@ app.use('/images', express.static(path.join(__dirname, '../public/uploads')));
|
||||||
app.use('/uploads', express.static(path.join(__dirname, '../public/uploads')));
|
app.use('/uploads', express.static(path.join(__dirname, '../public/uploads')));
|
||||||
app.use('/api/uploads', express.static(path.join(__dirname, '../public/uploads')));
|
app.use('/api/uploads', express.static(path.join(__dirname, '../public/uploads')));
|
||||||
app.use('/api/images', express.static(path.join(__dirname, '../public/uploads')));
|
app.use('/api/images', express.static(path.join(__dirname, '../public/uploads')));
|
||||||
|
|
||||||
if (!fs.existsSync(path.join(__dirname, '../public/uploads'))) {
|
if (!fs.existsSync(path.join(__dirname, '../public/uploads'))) {
|
||||||
fs.mkdirSync(path.join(__dirname, '../public/uploads'), { recursive: true });
|
fs.mkdirSync(path.join(__dirname, '../public/uploads'), { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
@ -120,7 +123,8 @@ app.post('/api/image/upload', upload.single('image'), (req, res) => {
|
||||||
imagePath: `/uploads/${req.file.filename}`
|
imagePath: `/uploads/${req.file.filename}`
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
app.use('/api/admin/users', usersAdminRoutes(pool, query, adminAuthMiddleware(pool, query)));
|
||||||
|
app.use('/api/admin/orders', ordersAdminRoutes(pool, query, adminAuthMiddleware(pool, query)));
|
||||||
// Admin-only product image upload
|
// Admin-only product image upload
|
||||||
app.post('/api/image/product', adminAuthMiddleware(pool, query), upload.single('image'), (req, res) => {
|
app.post('/api/image/product', adminAuthMiddleware(pool, query), upload.single('image'), (req, res) => {
|
||||||
console.log('/api/image/product', req.file);
|
console.log('/api/image/product', req.file);
|
||||||
|
|
|
||||||
|
|
@ -143,8 +143,14 @@ module.exports = (pool, query) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { code: storedCode, expires_at, is_used, id: userId } = userResult.rows[0];
|
const { code: storedCode, expires_at, is_used, id: userId, is_disabled } = userResult.rows[0];
|
||||||
|
// Check if account is disabled
|
||||||
|
if (is_disabled) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: true,
|
||||||
|
message: 'Your account has been disabled. Please contact support for assistance.'
|
||||||
|
});
|
||||||
|
}
|
||||||
// Check code
|
// Check code
|
||||||
if (storedCode !== code) {
|
if (storedCode !== code) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
|
|
@ -208,7 +214,7 @@ module.exports = (pool, query) => {
|
||||||
try {
|
try {
|
||||||
// Verify the API key against email
|
// Verify the API key against email
|
||||||
const result = await query(
|
const result = await query(
|
||||||
'SELECT id, email, first_name, last_name, is_admin FROM users WHERE api_key = $1 AND email = $2',
|
'SELECT id, email, first_name, last_name, is_admin, is_disabled FROM users WHERE api_key = $1 AND email = $2',
|
||||||
[apiKey, email]
|
[apiKey, email]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -218,6 +224,12 @@ module.exports = (pool, query) => {
|
||||||
message: 'Invalid API key'
|
message: 'Invalid API key'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (result.rows[0].is_disabled) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: true,
|
||||||
|
message: 'Your account has been disabled. Please contact support for assistance.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
valid: true,
|
valid: true,
|
||||||
|
|
|
||||||
148
backend/src/routes/orderAdmin.js
Normal file
148
backend/src/routes/orderAdmin.js
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
module.exports = (pool, query, authMiddleware) => {
|
||||||
|
// Apply authentication middleware to all routes
|
||||||
|
router.use(authMiddleware);
|
||||||
|
|
||||||
|
// Get all orders
|
||||||
|
router.get('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
// Check if user is admin
|
||||||
|
if (!req.user.is_admin) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: true,
|
||||||
|
message: 'Admin access required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query(`
|
||||||
|
SELECT o.*,
|
||||||
|
u.email, u.first_name, u.last_name,
|
||||||
|
COUNT(oi.id) AS item_count
|
||||||
|
FROM orders o
|
||||||
|
JOIN users u ON o.user_id = u.id
|
||||||
|
LEFT JOIN order_items oi ON o.id = oi.order_id
|
||||||
|
GROUP BY o.id, u.id
|
||||||
|
ORDER BY o.created_at DESC
|
||||||
|
`);
|
||||||
|
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get single order with items
|
||||||
|
router.get('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
// Check if user is admin
|
||||||
|
if (!req.user.is_admin) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: true,
|
||||||
|
message: 'Admin access required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get order details
|
||||||
|
const orderResult = await query(`
|
||||||
|
SELECT o.*, u.email, u.first_name, u.last_name
|
||||||
|
FROM orders o
|
||||||
|
JOIN users u ON o.user_id = u.id
|
||||||
|
WHERE o.id = $1
|
||||||
|
`, [id]);
|
||||||
|
|
||||||
|
if (orderResult.rows.length === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: true,
|
||||||
|
message: 'Order not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get order items with product details
|
||||||
|
const itemsResult = await query(`
|
||||||
|
SELECT oi.*,
|
||||||
|
p.name as product_name,
|
||||||
|
p.description as product_description,
|
||||||
|
pc.name as product_category,
|
||||||
|
(
|
||||||
|
SELECT json_agg(
|
||||||
|
json_build_object(
|
||||||
|
'id', pi.id,
|
||||||
|
'path', pi.image_path,
|
||||||
|
'isPrimary', pi.is_primary,
|
||||||
|
'displayOrder', pi.display_order
|
||||||
|
) ORDER BY pi.display_order
|
||||||
|
)
|
||||||
|
FROM product_images pi
|
||||||
|
WHERE pi.product_id = p.id
|
||||||
|
) AS product_images
|
||||||
|
FROM order_items oi
|
||||||
|
JOIN products p ON oi.product_id = p.id
|
||||||
|
JOIN product_categories pc ON p.category_id = pc.id
|
||||||
|
WHERE oi.order_id = $1
|
||||||
|
`, [id]);
|
||||||
|
|
||||||
|
// Combine order with items
|
||||||
|
const order = {
|
||||||
|
...orderResult.rows[0],
|
||||||
|
items: itemsResult.rows
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(order);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update order status
|
||||||
|
router.patch('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { status } = req.body;
|
||||||
|
|
||||||
|
// Check if user is admin
|
||||||
|
if (!req.user.is_admin) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: true,
|
||||||
|
message: 'Admin access required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate status
|
||||||
|
const validStatuses = ['pending', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded'];
|
||||||
|
if (!validStatuses.includes(status)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: true,
|
||||||
|
message: `Invalid status. Must be one of: ${validStatuses.join(', ')}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update order status
|
||||||
|
const result = await query(`
|
||||||
|
UPDATE orders
|
||||||
|
SET status = $1, updated_at = NOW()
|
||||||
|
WHERE id = $2
|
||||||
|
RETURNING *
|
||||||
|
`, [status, id]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: true,
|
||||||
|
message: 'Order not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: 'Order status updated successfully',
|
||||||
|
order: result.rows[0]
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
};
|
||||||
199
backend/src/routes/userAdmin.js
Normal file
199
backend/src/routes/userAdmin.js
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
const express = require('express');
|
||||||
|
const nodemailer = require('nodemailer');
|
||||||
|
const config = require('../config');
|
||||||
|
const router = express.Router();
|
||||||
|
const createTransporter = () => {
|
||||||
|
return nodemailer.createTransport({
|
||||||
|
host: config.email.host,
|
||||||
|
port: config.email.port,
|
||||||
|
auth: {
|
||||||
|
user: config.email.user,
|
||||||
|
pass: config.email.pass
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
module.exports = (pool, query, authMiddleware) => {
|
||||||
|
// Apply authentication middleware to all routes
|
||||||
|
router.use(authMiddleware);
|
||||||
|
|
||||||
|
// Get all users
|
||||||
|
router.get('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
// Check if user is admin
|
||||||
|
if (!req.user.is_admin) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: true,
|
||||||
|
message: 'Admin access required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query(`
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
email,
|
||||||
|
first_name,
|
||||||
|
last_name,
|
||||||
|
is_admin,
|
||||||
|
is_disabled,
|
||||||
|
internal_notes,
|
||||||
|
created_at,
|
||||||
|
last_login
|
||||||
|
FROM users
|
||||||
|
ORDER BY last_login DESC NULLS LAST
|
||||||
|
`);
|
||||||
|
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get single user
|
||||||
|
router.get('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
// Check if user is admin
|
||||||
|
if (!req.user.is_admin) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: true,
|
||||||
|
message: 'Admin access required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query(`
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
email,
|
||||||
|
first_name,
|
||||||
|
last_name,
|
||||||
|
is_admin,
|
||||||
|
is_disabled,
|
||||||
|
internal_notes,
|
||||||
|
created_at,
|
||||||
|
last_login
|
||||||
|
FROM users
|
||||||
|
WHERE id = $1
|
||||||
|
`, [id]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: true,
|
||||||
|
message: 'User not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(result.rows[0]);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update user (admin can update is_disabled and internal_notes)
|
||||||
|
router.patch('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { is_disabled, internal_notes } = req.body;
|
||||||
|
|
||||||
|
// Check if user is admin
|
||||||
|
if (!req.user.is_admin) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: true,
|
||||||
|
message: 'Admin access required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user exists
|
||||||
|
const userCheck = await query('SELECT * FROM users WHERE id = $1', [id]);
|
||||||
|
|
||||||
|
if (userCheck.rows.length === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: true,
|
||||||
|
message: 'User not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update only allowed fields
|
||||||
|
const result = await query(`
|
||||||
|
UPDATE users
|
||||||
|
SET
|
||||||
|
is_disabled = $1,
|
||||||
|
internal_notes = $2
|
||||||
|
WHERE id = $3
|
||||||
|
RETURNING id, email, first_name, last_name, is_admin, is_disabled, internal_notes
|
||||||
|
`, [
|
||||||
|
is_disabled !== undefined ? is_disabled : userCheck.rows[0].is_disabled,
|
||||||
|
internal_notes !== undefined ? internal_notes : userCheck.rows[0].internal_notes,
|
||||||
|
id
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: 'User updated successfully',
|
||||||
|
user: result.rows[0]
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send email to user
|
||||||
|
router.post('/send-email', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { to, name, subject, message } = req.body;
|
||||||
|
|
||||||
|
// Check if user is admin
|
||||||
|
if (!req.user.is_admin) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: true,
|
||||||
|
message: 'Admin access required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!to || !subject || !message) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: true,
|
||||||
|
message: 'Email, subject, and message are required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create email transporter (using the same transporter from auth.js)
|
||||||
|
const transporter = createTransporter();
|
||||||
|
|
||||||
|
// Send email
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: config.email.reply,
|
||||||
|
to: to,
|
||||||
|
subject: subject,
|
||||||
|
html: `
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h2>Message from ${config.site.domain}</h2>
|
||||||
|
<p>Dear ${name},</p>
|
||||||
|
<div style="padding: 15px; background-color: #f7f7f7; border-radius: 5px;">
|
||||||
|
${message.replace(/\n/g, '<br>')}
|
||||||
|
</div>
|
||||||
|
<p style="margin-top: 20px; font-size: 12px; color: #666;">
|
||||||
|
This email was sent from the admin panel of ${config.site.domain}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log the email sending (optional)
|
||||||
|
await query(
|
||||||
|
'INSERT INTO email_logs (recipient, subject, sent_by) VALUES ($1, $2, $3)',
|
||||||
|
[to, subject, req.user.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Email sent successfully'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Email sending error:', error);
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
};
|
||||||
2
db/init/07-user-keys.sql
Normal file
2
db/init/07-user-keys.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_disabled BOOLEAN DEFAULT FALSE;
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS internal_notes TEXT;
|
||||||
9
db/init/08-create-email.sql
Normal file
9
db/init/08-create-email.sql
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS email_logs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
recipient VARCHAR(255) NOT NULL,
|
||||||
|
subject VARCHAR(255) NOT NULL,
|
||||||
|
sent_by UUID NOT NULL REFERENCES users(id),
|
||||||
|
sent_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
ip_address VARCHAR(50),
|
||||||
|
status VARCHAR(50) DEFAULT 'sent'
|
||||||
|
);
|
||||||
3698
frontend/package-lock.json
generated
3698
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -19,6 +19,7 @@
|
||||||
"@tanstack/react-query": "^5.12.2",
|
"@tanstack/react-query": "^5.12.2",
|
||||||
"@tanstack/react-query-devtools": "^5.12.2",
|
"@tanstack/react-query-devtools": "^5.12.2",
|
||||||
"axios": "^1.6.2",
|
"axios": "^1.6.2",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-redux": "^9.0.2",
|
"react-redux": "^9.0.2",
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ const AdminDashboardPage = lazy(() => import('./pages/Admin/DashboardPage'));
|
||||||
const AdminProductsPage = lazy(() => import('./pages/Admin/ProductsPage'));
|
const AdminProductsPage = lazy(() => import('./pages/Admin/ProductsPage'));
|
||||||
const AdminProductEditPage = lazy(() => import('./pages/Admin/ProductEditPage'));
|
const AdminProductEditPage = lazy(() => import('./pages/Admin/ProductEditPage'));
|
||||||
const AdminCategoriesPage = lazy(() => import('./pages/Admin/CategoriesPage'));
|
const AdminCategoriesPage = lazy(() => import('./pages/Admin/CategoriesPage'));
|
||||||
|
const AdminCustomersPage = lazy(() => import('./pages/Admin/CustomersPage'));
|
||||||
|
const AdminOrdersPage = lazy(() => import('./pages/Admin/OrdersPage'));
|
||||||
const NotFoundPage = lazy(() => import('./pages/NotFoundPage'));
|
const NotFoundPage = lazy(() => import('./pages/NotFoundPage'));
|
||||||
|
|
||||||
// Loading component for suspense fallback
|
// Loading component for suspense fallback
|
||||||
|
|
@ -73,6 +75,8 @@ function App() {
|
||||||
<Route path="products/:id" element={<AdminProductEditPage />} />
|
<Route path="products/:id" element={<AdminProductEditPage />} />
|
||||||
<Route path="products/new" element={<AdminProductEditPage />} />
|
<Route path="products/new" element={<AdminProductEditPage />} />
|
||||||
<Route path="categories" element={<AdminCategoriesPage />} />
|
<Route path="categories" element={<AdminCategoriesPage />} />
|
||||||
|
<Route path="customers" element={<AdminCustomersPage />} />
|
||||||
|
<Route path="orders" element={<AdminOrdersPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* Catch-all route for 404s */}
|
{/* Catch-all route for 404s */}
|
||||||
|
|
|
||||||
150
frontend/src/components/EmailDialog.jsx
Normal file
150
frontend/src/components/EmailDialog.jsx
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
Alert,
|
||||||
|
Typography
|
||||||
|
} from '@mui/material';
|
||||||
|
import { useSendEmail } from '../hooks/adminHooks';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email Dialog component for sending emails to customers
|
||||||
|
* @param {Object} props - Component props
|
||||||
|
* @param {boolean} props.open - Whether the dialog is open
|
||||||
|
* @param {Function} props.onClose - Function to call when dialog is closed
|
||||||
|
* @param {Object} props.recipient - Recipient user object (includes email, first_name, last_name)
|
||||||
|
*/
|
||||||
|
const EmailDialog = ({ open, onClose, recipient }) => {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
subject: '',
|
||||||
|
message: ''
|
||||||
|
});
|
||||||
|
const [formErrors, setFormErrors] = useState({});
|
||||||
|
|
||||||
|
// Send email mutation
|
||||||
|
const sendEmail = useSendEmail();
|
||||||
|
|
||||||
|
// Handle form changes
|
||||||
|
const handleChange = (e) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormData(prev => ({ ...prev, [name]: value }));
|
||||||
|
|
||||||
|
// Clear validation error
|
||||||
|
if (formErrors[name]) {
|
||||||
|
setFormErrors(prev => ({ ...prev, [name]: '' }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle form submission
|
||||||
|
const handleSubmit = () => {
|
||||||
|
// Validate form
|
||||||
|
const errors = {};
|
||||||
|
if (!formData.subject.trim()) {
|
||||||
|
errors.subject = 'Subject is required';
|
||||||
|
}
|
||||||
|
if (!formData.message.trim()) {
|
||||||
|
errors.message = 'Message is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(errors).length > 0) {
|
||||||
|
setFormErrors(errors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit form
|
||||||
|
sendEmail.mutate({
|
||||||
|
to: recipient.email,
|
||||||
|
name: `${recipient.first_name} ${recipient.last_name}`,
|
||||||
|
subject: formData.subject,
|
||||||
|
message: formData.message
|
||||||
|
}, {
|
||||||
|
onSuccess: () => {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle dialog close
|
||||||
|
const handleClose = () => {
|
||||||
|
// Reset form
|
||||||
|
setFormData({
|
||||||
|
subject: '',
|
||||||
|
message: ''
|
||||||
|
});
|
||||||
|
setFormErrors({});
|
||||||
|
|
||||||
|
// Close dialog
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
|
||||||
|
<DialogTitle>
|
||||||
|
Send Email to Customer
|
||||||
|
{recipient && (
|
||||||
|
<Typography variant="subtitle1">
|
||||||
|
Recipient: {recipient.first_name} {recipient.last_name} ({recipient.email})
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
{sendEmail.isError && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
|
{sendEmail.error?.message || 'Failed to send email. Please try again.'}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
margin="dense"
|
||||||
|
label="Subject"
|
||||||
|
name="subject"
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
value={formData.subject}
|
||||||
|
onChange={handleChange}
|
||||||
|
error={!!formErrors.subject}
|
||||||
|
helperText={formErrors.subject}
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
margin="dense"
|
||||||
|
label="Message"
|
||||||
|
name="message"
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={8}
|
||||||
|
variant="outlined"
|
||||||
|
value={formData.message}
|
||||||
|
onChange={handleChange}
|
||||||
|
error={!!formErrors.message}
|
||||||
|
helperText={formErrors.message}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={handleClose}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
disabled={sendEmail.isLoading}
|
||||||
|
>
|
||||||
|
{sendEmail.isLoading ? (
|
||||||
|
<CircularProgress size={24} sx={{ mr: 1 }} />
|
||||||
|
) : null}
|
||||||
|
Send Email
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EmailDialog;
|
||||||
120
frontend/src/hooks/adminHooks.js
Normal file
120
frontend/src/hooks/adminHooks.js
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { adminUserService, adminOrderService } from '../services/adminService';
|
||||||
|
import { useNotification } from './reduxHooks';
|
||||||
|
|
||||||
|
// ========== USER HOOKS ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for sending an email to a user
|
||||||
|
*/
|
||||||
|
export const useSendEmail = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const notification = useNotification();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (emailData) => adminUserService.sendEmail(emailData),
|
||||||
|
onSuccess: () => {
|
||||||
|
notification.showNotification('Email sent successfully', 'success');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
notification.showNotification(
|
||||||
|
error.message || 'Failed to send email',
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for fetching all users (admin only)
|
||||||
|
*/
|
||||||
|
export const useAdminUsers = () => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['admin-users'],
|
||||||
|
queryFn: adminUserService.getAllUsers
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for fetching a single user by ID (admin only)
|
||||||
|
* @param {string} id - User ID
|
||||||
|
*/
|
||||||
|
export const useAdminUser = (id) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['admin-user', id],
|
||||||
|
queryFn: () => adminUserService.getUserById(id),
|
||||||
|
enabled: !!id
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for updating a user (admin only)
|
||||||
|
*/
|
||||||
|
export const useUpdateUser = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const notification = useNotification();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, data }) => adminUserService.updateUser(id, data),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-user', data.user.id] });
|
||||||
|
notification.showNotification('User updated successfully', 'success');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
notification.showNotification(
|
||||||
|
error.message || 'Failed to update user',
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========== ORDER HOOKS ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for fetching all orders (admin only)
|
||||||
|
*/
|
||||||
|
export const useAdminOrders = () => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['admin-orders'],
|
||||||
|
queryFn: adminOrderService.getAllOrders
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for fetching a single order by ID (admin only)
|
||||||
|
* @param {string} id - Order ID
|
||||||
|
* @param {Object} options - Additional options
|
||||||
|
*/
|
||||||
|
export const useAdminOrder = (id, options = {}) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['admin-order', id],
|
||||||
|
queryFn: () => adminOrderService.getOrderById(id),
|
||||||
|
enabled: !!id && (options.enabled !== undefined ? options.enabled : true)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for updating an order status (admin only)
|
||||||
|
*/
|
||||||
|
export const useUpdateOrderStatus = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const notification = useNotification();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ orderId, status }) => adminOrderService.updateOrderStatus(orderId, status),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-orders'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-order', data.order.id] });
|
||||||
|
notification.showNotification('Order status updated successfully', 'success');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
notification.showNotification(
|
||||||
|
error.message || 'Failed to update order status',
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
379
frontend/src/pages/Admin/CustomersPage.jsx
Normal file
379
frontend/src/pages/Admin/CustomersPage.jsx
Normal file
|
|
@ -0,0 +1,379 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Paper,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TablePagination,
|
||||||
|
IconButton,
|
||||||
|
TextField,
|
||||||
|
InputAdornment,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
Alert,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogContentText,
|
||||||
|
DialogTitle,
|
||||||
|
Button,
|
||||||
|
Switch,
|
||||||
|
FormControlLabel,
|
||||||
|
Tooltip
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
Search as SearchIcon,
|
||||||
|
Edit as EditIcon,
|
||||||
|
Clear as ClearIcon,
|
||||||
|
Mail as MailIcon,
|
||||||
|
CheckCircle as ActiveIcon,
|
||||||
|
Cancel as DisabledIcon
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { useAdminUsers, useUpdateUser } from '@hooks/adminHooks';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import EmailDialog from '@components/EmailDialog';
|
||||||
|
|
||||||
|
const AdminCustomersPage = () => {
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [rowsPerPage, setRowsPerPage] = useState(10);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||||
|
const [emailDialogOpen, setEmailDialogOpen] = useState(false);
|
||||||
|
const [currentUser, setCurrentUser] = useState(null);
|
||||||
|
const [emailRecipient, setEmailRecipient] = useState(null);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
is_disabled: false,
|
||||||
|
internal_notes: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
// React Query hooks
|
||||||
|
const { data: users, isLoading, error } = useAdminUsers();
|
||||||
|
const updateUserMutation = useUpdateUser();
|
||||||
|
|
||||||
|
// Filter users by search term
|
||||||
|
const filteredUsers = users ? users.filter(user => {
|
||||||
|
const searchTerm = search.toLowerCase();
|
||||||
|
return (
|
||||||
|
user.email.toLowerCase().includes(searchTerm) ||
|
||||||
|
(user.first_name && user.first_name.toLowerCase().includes(searchTerm)) ||
|
||||||
|
(user.last_name && user.last_name.toLowerCase().includes(searchTerm))
|
||||||
|
);
|
||||||
|
}) : [];
|
||||||
|
|
||||||
|
// Paginated users
|
||||||
|
const paginatedUsers = filteredUsers.slice(
|
||||||
|
page * rowsPerPage,
|
||||||
|
page * rowsPerPage + rowsPerPage
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle search change
|
||||||
|
const handleSearchChange = (event) => {
|
||||||
|
setSearch(event.target.value);
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clear search
|
||||||
|
const handleClearSearch = () => {
|
||||||
|
setSearch('');
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle page change
|
||||||
|
const handleChangePage = (event, newPage) => {
|
||||||
|
setPage(newPage);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle rows per page change
|
||||||
|
const handleChangeRowsPerPage = (event) => {
|
||||||
|
setRowsPerPage(parseInt(event.target.value, 10));
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle edit dialog open
|
||||||
|
const handleOpenEditDialog = (user) => {
|
||||||
|
setCurrentUser(user);
|
||||||
|
setFormData({
|
||||||
|
is_disabled: user.is_disabled,
|
||||||
|
internal_notes: user.internal_notes || ''
|
||||||
|
});
|
||||||
|
setEditDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle edit dialog close
|
||||||
|
const handleCloseEditDialog = () => {
|
||||||
|
setCurrentUser(null);
|
||||||
|
setEditDialogOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle email dialog open
|
||||||
|
const handleOpenEmailDialog = (user) => {
|
||||||
|
setEmailRecipient(user);
|
||||||
|
setEmailDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle email dialog close
|
||||||
|
const handleCloseEmailDialog = () => {
|
||||||
|
setEmailRecipient(null);
|
||||||
|
setEmailDialogOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle form changes
|
||||||
|
const handleFormChange = (e) => {
|
||||||
|
const { name, value, checked } = e.target;
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[name]: name === 'is_disabled' ? checked : value
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle save user
|
||||||
|
const handleSaveUser = () => {
|
||||||
|
if (currentUser) {
|
||||||
|
updateUserMutation.mutate({
|
||||||
|
id: currentUser.id,
|
||||||
|
data: formData
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format date
|
||||||
|
const formatDate = (dateString) => {
|
||||||
|
if (!dateString) return 'Never';
|
||||||
|
try {
|
||||||
|
return format(new Date(dateString), 'MMM d, yyyy h:mm a');
|
||||||
|
} catch (error) {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Alert severity="error" sx={{ my: 2 }}>
|
||||||
|
Error loading customers: {error.message}
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h4" component="h1" gutterBottom>
|
||||||
|
Customers
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Search Box */}
|
||||||
|
<Paper sx={{ p: 2, mb: 3 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
placeholder="Search by name or email..."
|
||||||
|
value={search}
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<SearchIcon />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
endAdornment: search && (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<IconButton size="small" onClick={handleClearSearch}>
|
||||||
|
<ClearIcon />
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Users Table */}
|
||||||
|
<Paper>
|
||||||
|
<TableContainer>
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Name</TableCell>
|
||||||
|
<TableCell>Email</TableCell>
|
||||||
|
<TableCell>Status</TableCell>
|
||||||
|
<TableCell>Last Login</TableCell>
|
||||||
|
<TableCell>Joined</TableCell>
|
||||||
|
<TableCell>Notes</TableCell>
|
||||||
|
<TableCell align="right">Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{paginatedUsers.length > 0 ? (
|
||||||
|
paginatedUsers.map((user) => (
|
||||||
|
<TableRow key={user.id}>
|
||||||
|
<TableCell>
|
||||||
|
{user.first_name} {user.last_name}
|
||||||
|
{user.is_admin && (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label="Admin"
|
||||||
|
color="primary"
|
||||||
|
sx={{ ml: 1 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{user.email}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{user.is_disabled ? (
|
||||||
|
<Chip
|
||||||
|
icon={<DisabledIcon />}
|
||||||
|
label="Disabled"
|
||||||
|
color="error"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Chip
|
||||||
|
icon={<ActiveIcon />}
|
||||||
|
label="Active"
|
||||||
|
color="success"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{formatDate(user.last_login)}</TableCell>
|
||||||
|
<TableCell>{formatDate(user.created_at)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{user.internal_notes ? (
|
||||||
|
<Tooltip title={user.internal_notes}>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
maxWidth: 150,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user.internal_notes}
|
||||||
|
</Typography>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
No notes
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<IconButton
|
||||||
|
onClick={() => handleOpenEditDialog(user)}
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
<EditIcon />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => handleOpenEmailDialog(user)}
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
<MailIcon />
|
||||||
|
</IconButton>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} align="center">
|
||||||
|
<Typography variant="body1" py={2}>
|
||||||
|
{search ? 'No customers match your search.' : 'No customers found.'}
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
<TablePagination
|
||||||
|
rowsPerPageOptions={[5, 10, 25, 50]}
|
||||||
|
component="div"
|
||||||
|
count={filteredUsers.length}
|
||||||
|
rowsPerPage={rowsPerPage}
|
||||||
|
page={page}
|
||||||
|
onPageChange={handleChangePage}
|
||||||
|
onRowsPerPageChange={handleChangeRowsPerPage}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Edit User Dialog */}
|
||||||
|
<Dialog open={editDialogOpen} onClose={handleCloseEditDialog} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>
|
||||||
|
{currentUser && `Edit Customer: ${currentUser.first_name} ${currentUser.last_name}`}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
{currentUser && (
|
||||||
|
<Box sx={{ pt: 1 }}>
|
||||||
|
<Typography variant="subtitle1" gutterBottom>
|
||||||
|
Email: {currentUser.email}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={formData.is_disabled}
|
||||||
|
onChange={handleFormChange}
|
||||||
|
name="is_disabled"
|
||||||
|
color="error"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={formData.is_disabled ? "Account is disabled" : "Account is active"}
|
||||||
|
sx={{ my: 2, display: 'block' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
name="internal_notes"
|
||||||
|
label="Internal Notes"
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={4}
|
||||||
|
value={formData.internal_notes}
|
||||||
|
onChange={handleFormChange}
|
||||||
|
placeholder="Add internal notes about this customer (not visible to the customer)"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ mt: 2 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogContentText variant="caption" sx={{ mt: 1 }}>
|
||||||
|
Last login: {formatDate(currentUser.last_login)}
|
||||||
|
</DialogContentText>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={handleCloseEditDialog}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSaveUser}
|
||||||
|
variant="contained"
|
||||||
|
disabled={updateUserMutation.isLoading}
|
||||||
|
>
|
||||||
|
{updateUserMutation.isLoading ? <CircularProgress size={24} /> : 'Save Changes'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Email Dialog */}
|
||||||
|
<EmailDialog
|
||||||
|
open={emailDialogOpen}
|
||||||
|
onClose={handleCloseEmailDialog}
|
||||||
|
recipient={emailRecipient}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminCustomersPage;
|
||||||
562
frontend/src/pages/Admin/OrdersPage.jsx
Normal file
562
frontend/src/pages/Admin/OrdersPage.jsx
Normal file
|
|
@ -0,0 +1,562 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Paper,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TablePagination,
|
||||||
|
IconButton,
|
||||||
|
TextField,
|
||||||
|
InputAdornment,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
Alert,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
Button,
|
||||||
|
Grid,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
Divider,
|
||||||
|
MenuItem,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
Select,
|
||||||
|
Tooltip
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
Search as SearchIcon,
|
||||||
|
Clear as ClearIcon,
|
||||||
|
Visibility as ViewIcon,
|
||||||
|
LocalShipping as ShippingIcon
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import apiClient from '../../services/api';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import ProductImage from '../../components/ProductImage';
|
||||||
|
|
||||||
|
const AdminOrdersPage = () => {
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [rowsPerPage, setRowsPerPage] = useState(10);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [viewDialogOpen, setViewDialogOpen] = useState(false);
|
||||||
|
const [selectedOrder, setSelectedOrder] = useState(null);
|
||||||
|
const [orderDetails, setOrderDetails] = useState(null);
|
||||||
|
const [statusDialogOpen, setStatusDialogOpen] = useState(false);
|
||||||
|
const [newStatus, setNewStatus] = useState('');
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Fetch orders
|
||||||
|
const {
|
||||||
|
data: orders,
|
||||||
|
isLoading,
|
||||||
|
error
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['admin-orders', search],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await apiClient.get('/admin/orders');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch single order
|
||||||
|
const {
|
||||||
|
isLoading: isLoadingOrderDetails,
|
||||||
|
refetch: fetchOrderDetails
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['admin-order-details', selectedOrder?.id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!selectedOrder?.id) return null;
|
||||||
|
|
||||||
|
const response = await apiClient.get(`/admin/orders/${selectedOrder.id}`);
|
||||||
|
setOrderDetails(response.data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
enabled: false // Only run when manually triggered
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update order status mutation
|
||||||
|
const updateOrderStatus = useMutation({
|
||||||
|
mutationFn: async ({ orderId, status }) => {
|
||||||
|
const response = await apiClient.patch(`/admin/orders/${orderId}`, { status });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-orders'] });
|
||||||
|
if (selectedOrder) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-order-details', selectedOrder.id] });
|
||||||
|
}
|
||||||
|
setStatusDialogOpen(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter orders by search term
|
||||||
|
const filteredOrders = orders ? orders.filter(order => {
|
||||||
|
const searchTerm = search.toLowerCase();
|
||||||
|
return (
|
||||||
|
order.id.toLowerCase().includes(searchTerm) ||
|
||||||
|
(order.email && order.email.toLowerCase().includes(searchTerm)) ||
|
||||||
|
(order.first_name && order.first_name.toLowerCase().includes(searchTerm)) ||
|
||||||
|
(order.last_name && order.last_name.toLowerCase().includes(searchTerm)) ||
|
||||||
|
(order.status && order.status.toLowerCase().includes(searchTerm))
|
||||||
|
);
|
||||||
|
}) : [];
|
||||||
|
|
||||||
|
// Paginated orders
|
||||||
|
const paginatedOrders = filteredOrders.slice(
|
||||||
|
page * rowsPerPage,
|
||||||
|
page * rowsPerPage + rowsPerPage
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle search change
|
||||||
|
const handleSearchChange = (event) => {
|
||||||
|
setSearch(event.target.value);
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clear search
|
||||||
|
const handleClearSearch = () => {
|
||||||
|
setSearch('');
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle page change
|
||||||
|
const handleChangePage = (event, newPage) => {
|
||||||
|
setPage(newPage);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle rows per page change
|
||||||
|
const handleChangeRowsPerPage = (event) => {
|
||||||
|
setRowsPerPage(parseInt(event.target.value, 10));
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle view order details
|
||||||
|
const handleViewOrder = async (order) => {
|
||||||
|
setSelectedOrder(order);
|
||||||
|
setViewDialogOpen(true);
|
||||||
|
await fetchOrderDetails();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close view dialog
|
||||||
|
const handleCloseViewDialog = () => {
|
||||||
|
setViewDialogOpen(false);
|
||||||
|
setOrderDetails(null);
|
||||||
|
setSelectedOrder(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format date
|
||||||
|
const formatDate = (dateString) => {
|
||||||
|
if (!dateString) return '';
|
||||||
|
try {
|
||||||
|
return format(new Date(dateString), 'MMM d, yyyy h:mm a');
|
||||||
|
} catch (error) {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle opening status change dialog
|
||||||
|
const handleOpenStatusDialog = (order) => {
|
||||||
|
setSelectedOrder(order);
|
||||||
|
setNewStatus(order.status);
|
||||||
|
setStatusDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle status change
|
||||||
|
const handleStatusChange = (event) => {
|
||||||
|
setNewStatus(event.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle save status
|
||||||
|
const handleSaveStatus = () => {
|
||||||
|
if (selectedOrder && newStatus) {
|
||||||
|
updateOrderStatus.mutate({
|
||||||
|
orderId: selectedOrder.id,
|
||||||
|
status: newStatus
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to get status chip color
|
||||||
|
const getStatusColor = (status) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'pending':
|
||||||
|
return 'default';
|
||||||
|
case 'processing':
|
||||||
|
return 'primary';
|
||||||
|
case 'shipped':
|
||||||
|
return 'info';
|
||||||
|
case 'delivered':
|
||||||
|
return 'success';
|
||||||
|
case 'cancelled':
|
||||||
|
return 'error';
|
||||||
|
case 'refunded':
|
||||||
|
return 'warning';
|
||||||
|
default:
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Alert severity="error" sx={{ my: 2 }}>
|
||||||
|
Error loading orders: {error.message}
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h4" component="h1" gutterBottom>
|
||||||
|
Orders
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Search Box */}
|
||||||
|
<Paper sx={{ p: 2, mb: 3 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
placeholder="Search orders by ID, customer name, email, or status..."
|
||||||
|
value={search}
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<SearchIcon />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
endAdornment: search && (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<IconButton size="small" onClick={handleClearSearch}>
|
||||||
|
<ClearIcon />
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Orders Table */}
|
||||||
|
<Paper>
|
||||||
|
<TableContainer>
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Order ID</TableCell>
|
||||||
|
<TableCell>Customer</TableCell>
|
||||||
|
<TableCell>Date</TableCell>
|
||||||
|
<TableCell>Status</TableCell>
|
||||||
|
<TableCell align="right">Total</TableCell>
|
||||||
|
<TableCell>Items</TableCell>
|
||||||
|
<TableCell align="right">Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{paginatedOrders.length > 0 ? (
|
||||||
|
paginatedOrders.map((order) => (
|
||||||
|
<TableRow key={order.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>
|
||||||
|
{order.id.substring(0, 8)}...
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{order.first_name} {order.last_name}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{order.email}
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{formatDate(order.created_at)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
|
label={order.status}
|
||||||
|
color={getStatusColor(order.status)}
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleOpenStatusDialog(order)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
${parseFloat(order.total_amount).toFixed(2)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{order.item_count || 0}</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<Tooltip title="View Details">
|
||||||
|
<IconButton
|
||||||
|
onClick={() => handleViewOrder(order)}
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
<ViewIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Change Status">
|
||||||
|
<IconButton
|
||||||
|
onClick={() => handleOpenStatusDialog(order)}
|
||||||
|
color="secondary"
|
||||||
|
>
|
||||||
|
<ShippingIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} align="center">
|
||||||
|
<Typography variant="body1" py={2}>
|
||||||
|
{search ? 'No orders match your search.' : 'No orders found.'}
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
<TablePagination
|
||||||
|
rowsPerPageOptions={[5, 10, 25, 50]}
|
||||||
|
component="div"
|
||||||
|
count={filteredOrders.length}
|
||||||
|
rowsPerPage={rowsPerPage}
|
||||||
|
page={page}
|
||||||
|
onPageChange={handleChangePage}
|
||||||
|
onRowsPerPageChange={handleChangeRowsPerPage}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* View Order Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={viewDialogOpen}
|
||||||
|
onClose={handleCloseViewDialog}
|
||||||
|
maxWidth="md"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<DialogTitle>
|
||||||
|
Order Details
|
||||||
|
{selectedOrder && (
|
||||||
|
<Typography variant="caption" display="block" sx={{ mt: 1 }}>
|
||||||
|
Order ID: {selectedOrder.id}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent dividers>
|
||||||
|
{isLoadingOrderDetails ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
) : orderDetails ? (
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
{/* Order Summary */}
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<Card variant="outlined">
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Order Summary
|
||||||
|
</Typography>
|
||||||
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid item xs={4}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Date:
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={8}>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{formatDate(orderDetails.created_at)}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={4}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Status:
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={8}>
|
||||||
|
<Chip
|
||||||
|
label={orderDetails.status}
|
||||||
|
color={getStatusColor(orderDetails.status)}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={4}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Total:
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={8}>
|
||||||
|
<Typography variant="body2" fontWeight="bold">
|
||||||
|
${parseFloat(orderDetails.total_amount).toFixed(2)}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Customer Info */}
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<Card variant="outlined">
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Customer Information
|
||||||
|
</Typography>
|
||||||
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
|
||||||
|
<Typography variant="body1">
|
||||||
|
{orderDetails.first_name} {orderDetails.last_name}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||||
|
{orderDetails.email}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="subtitle2" gutterBottom>
|
||||||
|
Shipping Address:
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ whiteSpace: 'pre-line' }}>
|
||||||
|
{orderDetails.shipping_address}
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Order Items */}
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Card variant="outlined">
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Order Items
|
||||||
|
</Typography>
|
||||||
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
|
||||||
|
<List>
|
||||||
|
{orderDetails.items && orderDetails.items.map((item, index) => (
|
||||||
|
<React.Fragment key={item.id}>
|
||||||
|
<ListItem alignItems="flex-start">
|
||||||
|
<Grid container spacing={2} alignItems="center">
|
||||||
|
<Grid item xs={2} sm={1}>
|
||||||
|
<ProductImage
|
||||||
|
images={item.product_images}
|
||||||
|
alt={item.product_name}
|
||||||
|
sx={{ width: 50, height: 50, borderRadius: 1 }}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={10} sm={7}>
|
||||||
|
<ListItemText
|
||||||
|
primary={item.product_name}
|
||||||
|
secondary={
|
||||||
|
<React.Fragment>
|
||||||
|
<Typography variant="caption" component="span" color="text.primary">
|
||||||
|
Category: {item.product_category}
|
||||||
|
</Typography>
|
||||||
|
<br />
|
||||||
|
<Typography variant="caption" component="span">
|
||||||
|
{item.product_description && item.product_description.substring(0, 100)}
|
||||||
|
{item.product_description && item.product_description.length > 100 ? '...' : ''}
|
||||||
|
</Typography>
|
||||||
|
</React.Fragment>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={4} sm={2} textAlign="center">
|
||||||
|
<Typography variant="body2">
|
||||||
|
${parseFloat(item.price_at_purchase).toFixed(2)} × {item.quantity}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={8} sm={2} textAlign="right">
|
||||||
|
<Typography variant="body1" fontWeight="bold">
|
||||||
|
${(parseFloat(item.price_at_purchase) * item.quantity).toFixed(2)}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</ListItem>
|
||||||
|
{index < orderDetails.items.length - 1 && <Divider />}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
) : (
|
||||||
|
<Typography color="error">Failed to load order details</Typography>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedOrder) {
|
||||||
|
handleOpenStatusDialog(selectedOrder);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
color="primary"
|
||||||
|
startIcon={<ShippingIcon />}
|
||||||
|
>
|
||||||
|
Change Status
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleCloseViewDialog}>Close</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Change Status Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={statusDialogOpen}
|
||||||
|
onClose={() => setStatusDialogOpen(false)}
|
||||||
|
>
|
||||||
|
<DialogTitle>Change Order Status</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<FormControl fullWidth sx={{ mt: 2 }}>
|
||||||
|
<InputLabel id="status-select-label">Status</InputLabel>
|
||||||
|
<Select
|
||||||
|
labelId="status-select-label"
|
||||||
|
value={newStatus}
|
||||||
|
label="Status"
|
||||||
|
onChange={handleStatusChange}
|
||||||
|
>
|
||||||
|
<MenuItem value="pending">Pending</MenuItem>
|
||||||
|
<MenuItem value="processing">Processing</MenuItem>
|
||||||
|
<MenuItem value="shipped">Shipped</MenuItem>
|
||||||
|
<MenuItem value="delivered">Delivered</MenuItem>
|
||||||
|
<MenuItem value="cancelled">Cancelled</MenuItem>
|
||||||
|
<MenuItem value="refunded">Refunded</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setStatusDialogOpen(false)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSaveStatus}
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
disabled={updateOrderStatus.isLoading}
|
||||||
|
>
|
||||||
|
{updateOrderStatus.isLoading ? <CircularProgress size={24} /> : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminOrdersPage;
|
||||||
106
frontend/src/services/adminService.js
Normal file
106
frontend/src/services/adminService.js
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import apiClient from './api';
|
||||||
|
|
||||||
|
export const adminUserService = {
|
||||||
|
/**
|
||||||
|
* Get all users (admin only)
|
||||||
|
* @returns {Promise} Promise with the API response
|
||||||
|
*/
|
||||||
|
getAllUsers: async () => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get('/admin/users');
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single user by ID (admin only)
|
||||||
|
* @param {string} id - User ID
|
||||||
|
* @returns {Promise} Promise with the API response
|
||||||
|
*/
|
||||||
|
getUserById: async (id) => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/admin/users/${id}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a user (admin only)
|
||||||
|
* @param {string} id - User ID
|
||||||
|
* @param {Object} userData - User data to update
|
||||||
|
* @returns {Promise} Promise with the API response
|
||||||
|
*/
|
||||||
|
updateUser: async (id, userData) => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.patch(`/admin/users/${id}`, userData);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Send an email to a user (admin only)
|
||||||
|
* @param {Object} emailData - Email data
|
||||||
|
* @param {string} emailData.to - Recipient email
|
||||||
|
* @param {string} emailData.name - Recipient name
|
||||||
|
* @param {string} emailData.subject - Email subject
|
||||||
|
* @param {string} emailData.message - Email message
|
||||||
|
* @returns {Promise} Promise with the API response
|
||||||
|
*/
|
||||||
|
sendEmail: async (emailData) => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post('/admin/users/send-email', emailData);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const adminOrderService = {
|
||||||
|
/**
|
||||||
|
* Get all orders (admin only)
|
||||||
|
* @returns {Promise} Promise with the API response
|
||||||
|
*/
|
||||||
|
getAllOrders: async () => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get('/admin/orders');
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single order by ID (admin only)
|
||||||
|
* @param {string} id - Order ID
|
||||||
|
* @returns {Promise} Promise with the API response
|
||||||
|
*/
|
||||||
|
getOrderById: async (id) => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/admin/orders/${id}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an order's status (admin only)
|
||||||
|
* @param {string} id - Order ID
|
||||||
|
* @param {string} status - New order status
|
||||||
|
* @returns {Promise} Promise with the API response
|
||||||
|
*/
|
||||||
|
updateOrderStatus: async (id, status) => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.patch(`/admin/orders/${id}`, { status });
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -30,13 +30,13 @@ export default defineConfig({
|
||||||
// }
|
// }
|
||||||
// },
|
// },
|
||||||
allowedHosts: ['localhost', 'rocks.2many.ca'],
|
allowedHosts: ['localhost', 'rocks.2many.ca'],
|
||||||
host: '0.0.0.0', // Required for Docker
|
host: '0.0.0.0',
|
||||||
port: 3000,
|
port: 3000,
|
||||||
watch: {
|
watch: {
|
||||||
usePolling: true, // Required for Docker volumes
|
usePolling: true,
|
||||||
},
|
},
|
||||||
hmr: {
|
hmr: {
|
||||||
clientPort: 3000, // Match with the exposed port
|
clientPort: 3000,
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue