added email user support, user disabling and internal notes

This commit is contained in:
2ManyProjects 2025-04-25 13:51:16 -05:00
parent 84b3ac3603
commit 57ce946666
15 changed files with 4574 additions and 834 deletions

View file

@ -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);

View 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,

View 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;
};

View 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
View 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;

View 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'
);

File diff suppressed because it is too large Load diff

View file

@ -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",

View file

@ -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 */}

View 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;

View 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'
);
}
});
};

View 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;

View 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;

View 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' };
}
}
};

View file

@ -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',
}
},