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 productAdminRoutes = require('./routes/productAdmin');
|
||||
const categoryAdminRoutes = require('./routes/categoryAdmin'); // Add category admin routes
|
||||
const usersAdminRoutes = require('./routes/userAdmin');
|
||||
const ordersAdminRoutes = require('./routes/orderAdmin');
|
||||
// Create Express app
|
||||
const app = express();
|
||||
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('/api/uploads', 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'))) {
|
||||
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}`
|
||||
});
|
||||
});
|
||||
|
||||
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
|
||||
app.post('/api/image/product', adminAuthMiddleware(pool, query), upload.single('image'), (req, res) => {
|
||||
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
|
||||
if (storedCode !== code) {
|
||||
return res.status(400).json({
|
||||
|
|
@ -208,7 +214,7 @@ module.exports = (pool, query) => {
|
|||
try {
|
||||
// Verify the API key against email
|
||||
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]
|
||||
);
|
||||
|
||||
|
|
@ -218,6 +224,12 @@ module.exports = (pool, query) => {
|
|||
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({
|
||||
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-devtools": "^5.12.2",
|
||||
"axios": "^1.6.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-redux": "^9.0.2",
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ const AdminDashboardPage = lazy(() => import('./pages/Admin/DashboardPage'));
|
|||
const AdminProductsPage = lazy(() => import('./pages/Admin/ProductsPage'));
|
||||
const AdminProductEditPage = lazy(() => import('./pages/Admin/ProductEditPage'));
|
||||
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'));
|
||||
|
||||
// Loading component for suspense fallback
|
||||
|
|
@ -73,6 +75,8 @@ function App() {
|
|||
<Route path="products/:id" element={<AdminProductEditPage />} />
|
||||
<Route path="products/new" element={<AdminProductEditPage />} />
|
||||
<Route path="categories" element={<AdminCategoriesPage />} />
|
||||
<Route path="customers" element={<AdminCustomersPage />} />
|
||||
<Route path="orders" element={<AdminOrdersPage />} />
|
||||
</Route>
|
||||
|
||||
{/* 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'],
|
||||
host: '0.0.0.0', // Required for Docker
|
||||
host: '0.0.0.0',
|
||||
port: 3000,
|
||||
watch: {
|
||||
usePolling: true, // Required for Docker volumes
|
||||
usePolling: true,
|
||||
},
|
||||
hmr: {
|
||||
clientPort: 3000, // Match with the exposed port
|
||||
clientPort: 3000,
|
||||
host: 'localhost',
|
||||
}
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue