Low inventory notification support
This commit is contained in:
parent
d9953baa19
commit
119e9adfec
7 changed files with 565 additions and 44 deletions
|
|
@ -10,6 +10,10 @@ const adminAuthMiddleware = require('./middleware/adminAuth');
|
||||||
const settingsAdminRoutes = require('./routes/settingsAdmin');
|
const settingsAdminRoutes = require('./routes/settingsAdmin');
|
||||||
const SystemSettings = require('./models/SystemSettings');
|
const SystemSettings = require('./models/SystemSettings');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
// services
|
||||||
|
|
||||||
|
const notificationService = require('./services/notificationService');
|
||||||
|
|
||||||
|
|
||||||
// routes
|
// routes
|
||||||
const stripePaymentRoutes = require('./routes/stripePayment');
|
const stripePaymentRoutes = require('./routes/stripePayment');
|
||||||
|
|
@ -96,7 +100,28 @@ pool.connect()
|
||||||
console.error('Error loading settings from database:', error);
|
console.error('Error loading settings from database:', error);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(err => console.error('Database connection error:', err));
|
.catch(err => console.error('Database connection error:', err))
|
||||||
|
.finally((() => {
|
||||||
|
|
||||||
|
let timeInterval = 60 * 1000;
|
||||||
|
if (config.environment === 'prod') {
|
||||||
|
console.log('Setting up scheduled tasks for production environment, 30 mins');
|
||||||
|
timeInterval = 30 * 60 * 1000;
|
||||||
|
}else {
|
||||||
|
console.log('Setting up scheduled tasks for development environment, 2 mins');
|
||||||
|
timeInterval = 2 * 60 * 1000;
|
||||||
|
}
|
||||||
|
// Process stock notifications every 30 minutes
|
||||||
|
setInterval(async () => {
|
||||||
|
try {
|
||||||
|
console.log('Processing low stock notifications...');
|
||||||
|
const processedCount = await notificationService.processLowStockNotifications(pool, query);
|
||||||
|
console.log(`Processed ${processedCount} low stock notifications`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error processing low stock notifications:', error);
|
||||||
|
}
|
||||||
|
}, timeInterval);
|
||||||
|
}));
|
||||||
|
|
||||||
|
|
||||||
// Handle SSL proxy headers
|
// Handle SSL proxy headers
|
||||||
|
|
|
||||||
|
|
@ -811,6 +811,36 @@ module.exports = (pool, query, authMiddleware) => {
|
||||||
|
|
||||||
await client.query('COMMIT');
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
for (const item of cartItemsResult.rows) {
|
||||||
|
// Get product details including notification settings
|
||||||
|
const productWithNotification = await client.query(
|
||||||
|
`SELECT stock_quantity, stock_notification
|
||||||
|
FROM products
|
||||||
|
WHERE id = $1 AND stock_notification IS NOT NULL`,
|
||||||
|
[item.product_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (productWithNotification.rows.length > 0) {
|
||||||
|
const product = productWithNotification.rows[0];
|
||||||
|
|
||||||
|
// Check if notification is enabled and stock is below threshold
|
||||||
|
if (product.stock_notification &&
|
||||||
|
product.stock_notification.enabled === true &&
|
||||||
|
product.stock_notification.threshold &&
|
||||||
|
product.stock_quantity <= product.stock_notification.threshold) {
|
||||||
|
|
||||||
|
// Log the notification - it will be picked up by the notification service
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO notification_logs
|
||||||
|
(order_id, notification_type, sent_at, status)
|
||||||
|
VALUES ($1, $2, NOW(), $3)`,
|
||||||
|
[orderId, 'low_stock_alert', 'pending']
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Low stock notification queued for product ${item.product_id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Order completed successfully',
|
message: 'Order completed successfully',
|
||||||
|
|
|
||||||
|
|
@ -152,6 +152,57 @@ module.exports = (pool, query, authMiddleware) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post('/:id/stock-notification', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { enabled, email, threshold } = 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 product exists
|
||||||
|
const productCheck = await query(
|
||||||
|
'SELECT * FROM products WHERE id = $1',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (productCheck.rows.length === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: true,
|
||||||
|
message: 'Product not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store notification settings as JSONB
|
||||||
|
const notificationSettings = {
|
||||||
|
enabled,
|
||||||
|
email: email || null,
|
||||||
|
threshold: threshold || 0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update product with notification settings
|
||||||
|
const result = await query(
|
||||||
|
`UPDATE products
|
||||||
|
SET stock_notification = $1
|
||||||
|
WHERE id = $2
|
||||||
|
RETURNING *`,
|
||||||
|
[JSON.stringify(notificationSettings), id]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: 'Stock notification settings updated successfully',
|
||||||
|
product: result.rows[0]
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Update an existing product
|
// Update an existing product
|
||||||
router.put('/:id', async (req, res, next) => {
|
router.put('/:id', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
174
backend/src/services/notificationService.js
Normal file
174
backend/src/services/notificationService.js
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
// Create a new file: src/services/notificationService.js
|
||||||
|
|
||||||
|
const nodemailer = require('nodemailer');
|
||||||
|
const config = require('../config');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for handling notifications including stock alerts
|
||||||
|
*/
|
||||||
|
const notificationService = {
|
||||||
|
/**
|
||||||
|
* Create email transporter
|
||||||
|
* @returns {Object} Configured nodemailer transporter
|
||||||
|
*/
|
||||||
|
createTransporter() {
|
||||||
|
return nodemailer.createTransport({
|
||||||
|
host: config.email.host,
|
||||||
|
port: config.email.port,
|
||||||
|
auth: {
|
||||||
|
user: config.email.user,
|
||||||
|
pass: config.email.pass
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process pending low stock notifications
|
||||||
|
* @param {Object} pool - Database connection pool
|
||||||
|
* @param {Function} query - Database query function
|
||||||
|
* @returns {Promise<number>} Number of notifications processed
|
||||||
|
*/
|
||||||
|
async processLowStockNotifications(pool, query) {
|
||||||
|
const client = await pool.connect();
|
||||||
|
let processedCount = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Get pending low stock notifications
|
||||||
|
const pendingNotifications = await client.query(`
|
||||||
|
SELECT id
|
||||||
|
FROM notification_logs
|
||||||
|
WHERE notification_type = 'low_stock_alert'
|
||||||
|
AND status = 'pending'
|
||||||
|
LIMIT 50
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (pendingNotifications.rows.length === 0) {
|
||||||
|
await client.query('COMMIT');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get products with current stock below threshold
|
||||||
|
const lowStockProducts = await client.query(`
|
||||||
|
SELECT
|
||||||
|
p.id,
|
||||||
|
p.name,
|
||||||
|
p.stock_quantity,
|
||||||
|
p.stock_notification
|
||||||
|
FROM products p
|
||||||
|
WHERE
|
||||||
|
p.stock_notification IS NOT NULL
|
||||||
|
AND p.stock_notification->>'enabled' = 'true'
|
||||||
|
AND p.stock_notification->>'email' IS NOT NULL
|
||||||
|
AND p.stock_quantity <= (p.stock_notification->>'threshold')::int
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (lowStockProducts.rows.length === 0) {
|
||||||
|
// Mark notifications as processed with no action
|
||||||
|
const notificationIds = pendingNotifications.rows.map(n => n.id);
|
||||||
|
await client.query(`
|
||||||
|
UPDATE notification_logs
|
||||||
|
SET status = 'completed', error_message = 'No eligible products found'
|
||||||
|
WHERE id = ANY($1)
|
||||||
|
`, [notificationIds]);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize email transporter
|
||||||
|
const transporter = this.createTransporter();
|
||||||
|
|
||||||
|
// Send notifications for each low stock product
|
||||||
|
for (const product of lowStockProducts.rows) {
|
||||||
|
const notification = JSON.parse(product.stock_notification);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Send email notification
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: config.email.reply,
|
||||||
|
to: notification.email,
|
||||||
|
subject: `Low Stock Alert: ${product.name}`,
|
||||||
|
html: this.generateLowStockEmailTemplate(product)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mark one notification as processed
|
||||||
|
if (pendingNotifications.rows[processedCount]) {
|
||||||
|
await client.query(`
|
||||||
|
UPDATE notification_logs
|
||||||
|
SET status = 'success'
|
||||||
|
WHERE id = $1
|
||||||
|
`, [pendingNotifications.rows[processedCount].id]);
|
||||||
|
|
||||||
|
processedCount++;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error sending low stock notification for product ${product.id}:`, error);
|
||||||
|
|
||||||
|
// Mark notification as failed
|
||||||
|
if (pendingNotifications.rows[processedCount]) {
|
||||||
|
await client.query(`
|
||||||
|
UPDATE notification_logs
|
||||||
|
SET status = 'failed', error_message = $2
|
||||||
|
WHERE id = $1
|
||||||
|
`, [pendingNotifications.rows[processedCount].id, error.message]);
|
||||||
|
|
||||||
|
processedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
return processedCount;
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
console.error('Error processing low stock notifications:', error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate email template for low stock notification
|
||||||
|
* @param {Object} product - Product with low stock
|
||||||
|
* @returns {string} HTML email template
|
||||||
|
*/
|
||||||
|
generateLowStockEmailTemplate(product) {
|
||||||
|
const stockNotification = JSON.parse(product.stock_notification);
|
||||||
|
const threshold = stockNotification.threshold || 0;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<div style="background-color: #f8f8f8; padding: 20px; text-align: center;">
|
||||||
|
<h1 style="color: #ff6b6b;">Low Stock Alert</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="padding: 20px;">
|
||||||
|
<p>Hello,</p>
|
||||||
|
|
||||||
|
<p>This is an automated notification to inform you that the following product is running low on stock:</p>
|
||||||
|
|
||||||
|
<div style="background-color: #f8f8f8; padding: 15px; margin: 20px 0; border-left: 4px solid #ff6b6b;">
|
||||||
|
<h3 style="margin-top: 0;">${product.name}</h3>
|
||||||
|
<p><strong>Current Stock:</strong> ${product.stock_quantity}</p>
|
||||||
|
<p><strong>Threshold:</strong> ${threshold}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>You might want to restock this item soon to avoid running out of inventory.</p>
|
||||||
|
|
||||||
|
<div style="margin-top: 30px; border-top: 1px solid #eee; padding-top: 20px;">
|
||||||
|
<p>This is an automated notification. You received this because you set up stock notifications for this product.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background-color: #333; color: white; padding: 15px; text-align: center; font-size: 12px;">
|
||||||
|
<p>© ${new Date().getFullYear()} Rocks, Bones & Sticks. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = notificationService;
|
||||||
52
db/init/14-product-notifications.sql
Normal file
52
db/init/14-product-notifications.sql
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
ALTER TABLE products ADD COLUMN IF NOT EXISTS stock_notification JSONB;
|
||||||
|
|
||||||
|
-- Create a function to send email notifications when stock drops below threshold
|
||||||
|
CREATE OR REPLACE FUNCTION notify_low_stock()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
-- Check if notification is enabled and new stock is below threshold
|
||||||
|
IF (NEW.stock_notification IS NOT NULL AND
|
||||||
|
NEW.stock_notification->>'enabled' = 'true' AND
|
||||||
|
NEW.stock_notification->>'email' IS NOT NULL AND
|
||||||
|
(NEW.stock_notification->>'threshold')::int > 0 AND
|
||||||
|
NEW.stock_quantity <= (NEW.stock_notification->>'threshold')::int AND
|
||||||
|
(OLD.stock_quantity IS NULL OR OLD.stock_quantity > (NEW.stock_notification->>'threshold')::int)) THEN
|
||||||
|
|
||||||
|
-- Insert notification record into a notification log table
|
||||||
|
INSERT INTO notification_logs (
|
||||||
|
order_id, -- Using NULL as this isn't tied to a specific order
|
||||||
|
notification_type,
|
||||||
|
sent_at,
|
||||||
|
status
|
||||||
|
) VALUES (
|
||||||
|
NULL,
|
||||||
|
'low_stock_alert',
|
||||||
|
NOW(),
|
||||||
|
'pending'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Note: The actual email sending will be handled by a backend process
|
||||||
|
-- that periodically checks for pending notifications
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Create trigger to check stock level on update
|
||||||
|
CREATE TRIGGER check_stock_level_on_update
|
||||||
|
AFTER UPDATE OF stock_quantity ON products
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION notify_low_stock();
|
||||||
|
|
||||||
|
-- Create trigger to check stock level on insert (though less common)
|
||||||
|
CREATE TRIGGER check_stock_level_on_insert
|
||||||
|
AFTER INSERT ON products
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION notify_low_stock();
|
||||||
|
-- Add stock_notification column to products table
|
||||||
|
ALTER TABLE products ADD COLUMN IF NOT EXISTS stock_notification JSONB;
|
||||||
|
|
||||||
|
-- Create index for faster lookups of products with notifications
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_products_stock_notification ON products ((stock_notification IS NOT NULL))
|
||||||
|
WHERE stock_notification IS NOT NULL;
|
||||||
|
|
@ -1,15 +1,16 @@
|
||||||
ROCKS/
|
Rocks/
|
||||||
├── backend/
|
├── backend/
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── services/
|
│ │ ├── services/
|
||||||
│ │ │ ├── shippingService.js
|
│ │ │ └── notificationService.js
|
||||||
|
│ │ │ └── shippingService.js
|
||||||
│ │ ├── routes/
|
│ │ ├── routes/
|
||||||
|
│ │ │ ├── cart.js
|
||||||
|
│ │ │ ├── stripePayment.js
|
||||||
│ │ │ ├── shipping.js
|
│ │ │ ├── shipping.js
|
||||||
│ │ │ ├── settingsAdmin.js
|
│ │ │ ├── settingsAdmin.js
|
||||||
│ │ │ ├── cart.js
|
|
||||||
│ │ │ ├── userOrders.js
|
│ │ │ ├── userOrders.js
|
||||||
│ │ │ ├── orderAdmin.js
|
│ │ │ ├── orderAdmin.js
|
||||||
│ │ │ ├── stripePayment.js
|
|
||||||
│ │ │ ├── auth.js
|
│ │ │ ├── auth.js
|
||||||
│ │ │ ├── userAdmin.js
|
│ │ │ ├── userAdmin.js
|
||||||
│ │ │ ├── products.js
|
│ │ │ ├── products.js
|
||||||
|
|
@ -24,16 +25,16 @@ ROCKS/
|
||||||
│ │ │ ├── auth.js
|
│ │ │ ├── auth.js
|
||||||
│ │ │ └── adminAuth.js
|
│ │ │ └── adminAuth.js
|
||||||
│ │ └── db/
|
│ │ └── db/
|
||||||
│ │ └── index.js
|
│ │ ├── index.js
|
||||||
│ ├── index.js
|
│ │ ├── index.js
|
||||||
│ ├── config.js
|
│ │ └── config.js
|
||||||
│ ├── node_modules/
|
│ ├── node_modules/
|
||||||
│ ├── public/
|
│ ├── public/
|
||||||
│ │ └── uploads/
|
│ │ └── uploads/
|
||||||
│ │ └── products/
|
│ │ └── products/
|
||||||
|
│ ├── .env
|
||||||
│ ├── package.json
|
│ ├── package.json
|
||||||
│ ├── package-lock.json
|
│ ├── package-lock.json
|
||||||
│ ├── .env
|
|
||||||
│ ├── Dockerfile
|
│ ├── Dockerfile
|
||||||
│ ├── README.md
|
│ ├── README.md
|
||||||
│ └── .gitignore
|
│ └── .gitignore
|
||||||
|
|
@ -109,32 +110,34 @@ ROCKS/
|
||||||
│ │ ├── App.jsx
|
│ │ ├── App.jsx
|
||||||
│ │ ├── config.js
|
│ │ ├── config.js
|
||||||
│ │ └── main.jsx
|
│ │ └── main.jsx
|
||||||
│ ├── public/
|
│ └── public/
|
||||||
│ │ ├── favicon.svg
|
│ ├── favicon.svg
|
||||||
│ │ └── index.html
|
|
||||||
│ ├── package-lock.json
|
│ ├── package-lock.json
|
||||||
│ ├── package.json
|
│ ├── package.json
|
||||||
│ ├── vite.config.js
|
│ ├── vite.config.js
|
||||||
│ ├── Dockerfile
|
│ ├── Dockerfile
|
||||||
│ ├── nginx.conf
|
│ ├── nginx.conf
|
||||||
|
│ ├── index.html
|
||||||
│ ├── README.md
|
│ ├── README.md
|
||||||
│ ├── .env
|
│ ├── .env
|
||||||
│ └── setup-frontend.sh
|
│ └── setup-frontend.sh
|
||||||
├── db/
|
├── db/
|
||||||
│ └── init/
|
│ ├── init/
|
||||||
│ ├── 12-shipping-orders.sql
|
│ │ ├── 14-product-notifications.sql
|
||||||
│ ├── 09-system-settings.sql
|
│ │ ├── 13-cart-metadata.sql
|
||||||
│ ├── 11-notifications.sql
|
│ │ ├── 12-shipping-orders.sql
|
||||||
│ ├── 10-payment.sql
|
│ │ ├── 09-system-settings.sql
|
||||||
│ ├── 08-create-email.sql
|
│ │ ├── 11-notifications.sql
|
||||||
│ ├── 07-user-keys.sql
|
│ │ ├── 10-payment.sql
|
||||||
│ ├── 06-product-categories.sql
|
│ │ ├── 08-create-email.sql
|
||||||
│ ├── 05-admin-role.sql
|
│ │ ├── 07-user-keys.sql
|
||||||
│ ├── 02-seed.sql
|
│ │ ├── 06-product-categories.sql
|
||||||
│ ├── 04-product-images.sql
|
│ │ ├── 05-admin-role.sql
|
||||||
│ ├── 03-api-key.sql
|
│ │ ├── 02-seed.sql
|
||||||
│ └── 01-schema.sql
|
│ │ ├── 04-product-images.sql
|
||||||
├── test/
|
│ │ ├── 03-api-key.sql
|
||||||
|
│ │ └── 01-schema.sql
|
||||||
|
│ └── test/
|
||||||
├── fileStructure.txt
|
├── fileStructure.txt
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
└── .gitignore
|
└── .gitignore
|
||||||
|
|
@ -17,12 +17,17 @@ import {
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
Alert,
|
Alert,
|
||||||
Snackbar,
|
Snackbar,
|
||||||
Autocomplete
|
Autocomplete,
|
||||||
|
FormControlLabel,
|
||||||
|
Checkbox,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { useNavigate, useParams, useLocation } from 'react-router-dom';
|
import { useNavigate, useParams, useLocation } from 'react-router-dom';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
import SaveIcon from '@mui/icons-material/Save';
|
import SaveIcon from '@mui/icons-material/Save';
|
||||||
|
import NotificationsActiveIcon from '@mui/icons-material/NotificationsActive';
|
||||||
import ImageUploader from '@components/ImageUploader';
|
import ImageUploader from '@components/ImageUploader';
|
||||||
import apiClient from '@services/api';
|
import apiClient from '@services/api';
|
||||||
import { useAuth } from '@hooks/reduxHooks';
|
import { useAuth } from '@hooks/reduxHooks';
|
||||||
|
|
@ -32,8 +37,9 @@ const ProductEditPage = () => {
|
||||||
const id = pathname.split('/').pop();
|
const id = pathname.split('/').pop();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { apiKey } = useAuth();
|
const { apiKey, userData } = useAuth();
|
||||||
const isNewProduct = id === 'new';
|
const isNewProduct = id === 'new';
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
|
|
@ -53,6 +59,11 @@ const ProductEditPage = () => {
|
||||||
images: []
|
images: []
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Notification state
|
||||||
|
const [notificationEnabled, setNotificationEnabled] = useState(false);
|
||||||
|
const [notificationEmail, setNotificationEmail] = useState('');
|
||||||
|
const [stockThreshold, setStockThreshold] = useState(1);
|
||||||
|
|
||||||
// Validation state
|
// Validation state
|
||||||
const [errors, setErrors] = useState({});
|
const [errors, setErrors] = useState({});
|
||||||
|
|
||||||
|
|
@ -144,6 +155,27 @@ const ProductEditPage = () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Save stock notification settings
|
||||||
|
const saveStockNotification = useMutation({
|
||||||
|
mutationFn: async (notificationData) => {
|
||||||
|
return await apiClient.post(`/admin/products/${id}/stock-notification`, notificationData);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
setNotification({
|
||||||
|
open: true,
|
||||||
|
message: 'Stock notification settings saved!',
|
||||||
|
severity: 'success'
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setNotification({
|
||||||
|
open: true,
|
||||||
|
message: `Failed to save notification settings: ${error.message}`,
|
||||||
|
severity: 'error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Handle form changes
|
// Handle form changes
|
||||||
const handleChange = (e) => {
|
const handleChange = (e) => {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
|
|
@ -165,6 +197,27 @@ const ProductEditPage = () => {
|
||||||
setFormData(prev => ({ ...prev, images: newImages }));
|
setFormData(prev => ({ ...prev, images: newImages }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle notification checkbox change
|
||||||
|
const handleNotificationToggle = (event) => {
|
||||||
|
setNotificationEnabled(event.target.checked);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle notification email change
|
||||||
|
const handleNotificationEmailChange = (e) => {
|
||||||
|
setNotificationEmail(e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle stock threshold change
|
||||||
|
const handleStockThresholdChange = (e) => {
|
||||||
|
let value = parseInt(e.target.value);
|
||||||
|
if (isNaN(value) || value < 1) {
|
||||||
|
value = 1;
|
||||||
|
} else if (value > parseInt(formData.stockQuantity)) {
|
||||||
|
value = parseInt(formData.stockQuantity);
|
||||||
|
}
|
||||||
|
setStockThreshold(value);
|
||||||
|
};
|
||||||
|
|
||||||
// Validate form
|
// Validate form
|
||||||
const validateForm = () => {
|
const validateForm = () => {
|
||||||
const newErrors = {};
|
const newErrors = {};
|
||||||
|
|
@ -205,6 +258,27 @@ const ProductEditPage = () => {
|
||||||
newErrors.heightCm = 'Height must be a positive number';
|
newErrors.heightCm = 'Height must be a positive number';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate notification settings if enabled
|
||||||
|
if (notificationEnabled) {
|
||||||
|
if (!notificationEmail) {
|
||||||
|
newErrors.notificationEmail = 'Email is required for notifications';
|
||||||
|
} else {
|
||||||
|
// Basic email validation
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(notificationEmail)) {
|
||||||
|
newErrors.notificationEmail = 'Please enter a valid email address';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stockThreshold) {
|
||||||
|
newErrors.stockThreshold = 'Threshold is required for notifications';
|
||||||
|
} else if (isNaN(stockThreshold) || stockThreshold < 1) {
|
||||||
|
newErrors.stockThreshold = 'Threshold must be a positive number';
|
||||||
|
} else if (formData.stockQuantity && stockThreshold > parseInt(formData.stockQuantity)) {
|
||||||
|
newErrors.stockThreshold = `Threshold cannot exceed current stock (${formData.stockQuantity})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setErrors(newErrors);
|
setErrors(newErrors);
|
||||||
return Object.keys(newErrors).length === 0;
|
return Object.keys(newErrors).length === 0;
|
||||||
};
|
};
|
||||||
|
|
@ -234,10 +308,35 @@ const ProductEditPage = () => {
|
||||||
tags: formData.tags.map(tag => typeof tag === 'string' ? tag : tag.name)
|
tags: formData.tags.map(tag => typeof tag === 'string' ? tag : tag.name)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Add notification data if enabled
|
||||||
|
if (notificationEnabled && !isNewProduct) {
|
||||||
|
productData.stockNotification = {
|
||||||
|
enabled: true,
|
||||||
|
email: notificationEmail,
|
||||||
|
threshold: stockThreshold
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (isNewProduct) {
|
if (isNewProduct) {
|
||||||
createProduct.mutate(productData);
|
createProduct.mutate(productData);
|
||||||
} else {
|
} else {
|
||||||
updateProduct.mutate({ id, productData });
|
updateProduct.mutate({ id, productData });
|
||||||
|
|
||||||
|
// Save notification settings separately
|
||||||
|
if (notificationEnabled) {
|
||||||
|
saveStockNotification.mutate({
|
||||||
|
enabled: true,
|
||||||
|
email: notificationEmail,
|
||||||
|
threshold: stockThreshold
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Disable notifications if checkbox is unchecked
|
||||||
|
saveStockNotification.mutate({
|
||||||
|
enabled: false,
|
||||||
|
email: '',
|
||||||
|
threshold: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -270,8 +369,21 @@ const ProductEditPage = () => {
|
||||||
displayOrder: img.displayOrder || img.display_order || 0
|
displayOrder: img.displayOrder || img.display_order || 0
|
||||||
})) : []
|
})) : []
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Load notification settings if available
|
||||||
|
if (product.stock_notification) {
|
||||||
|
setNotificationEnabled(product.stock_notification.enabled);
|
||||||
|
setNotificationEmail(product.stock_notification.email || userData?.email || '');
|
||||||
|
setStockThreshold(product.stock_notification.threshold || 1);
|
||||||
|
} else {
|
||||||
|
// Default to admin's email
|
||||||
|
setNotificationEmail(userData?.email || '');
|
||||||
}
|
}
|
||||||
}, [product, isNewProduct]);
|
} else {
|
||||||
|
// For new products, set default notification email to admin's email
|
||||||
|
setNotificationEmail(userData?.email || '');
|
||||||
|
}
|
||||||
|
}, [product, isNewProduct, userData]);
|
||||||
|
|
||||||
// Format tag objects for Autocomplete component
|
// Format tag objects for Autocomplete component
|
||||||
const formatTags = () => {
|
const formatTags = () => {
|
||||||
|
|
@ -280,7 +392,7 @@ const ProductEditPage = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const isLoading = categoriesLoading || tagsLoading || (productLoading && !isNewProduct);
|
const isLoading = categoriesLoading || tagsLoading || (productLoading && !isNewProduct);
|
||||||
const isSaving = createProduct.isLoading || updateProduct.isLoading;
|
const isSaving = createProduct.isLoading || updateProduct.isLoading || saveStockNotification.isLoading;
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -403,6 +515,80 @@ const ProductEditPage = () => {
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
{/* Stock Notification Section */}
|
||||||
|
{!isNewProduct && (
|
||||||
|
<>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Stock Level Notifications
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Card variant="outlined" sx={{ bgcolor: 'background.paper' }}>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||||
|
<NotificationsActiveIcon color="primary" sx={{ mr: 1 }} />
|
||||||
|
<Typography variant="subtitle1">
|
||||||
|
Get notified when stock is running low
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={notificationEnabled}
|
||||||
|
onChange={handleNotificationToggle}
|
||||||
|
name="notificationEnabled"
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Enable stock level notifications"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{notificationEnabled && (
|
||||||
|
<Grid container spacing={2} sx={{ mt: 1 }}>
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Notification Email"
|
||||||
|
name="notificationEmail"
|
||||||
|
type="email"
|
||||||
|
value={notificationEmail}
|
||||||
|
onChange={handleNotificationEmailChange}
|
||||||
|
error={!!errors.notificationEmail}
|
||||||
|
helperText={errors.notificationEmail}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Stock Threshold"
|
||||||
|
name="stockThreshold"
|
||||||
|
type="number"
|
||||||
|
value={stockThreshold}
|
||||||
|
onChange={handleStockThresholdChange}
|
||||||
|
error={!!errors.stockThreshold}
|
||||||
|
helperText={errors.stockThreshold || "You'll be notified when stock falls below this number"}
|
||||||
|
required
|
||||||
|
InputProps={{
|
||||||
|
inputProps: {
|
||||||
|
min: 1,
|
||||||
|
max: formData.stockQuantity ? parseInt(formData.stockQuantity) : 999
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
<Divider sx={{ my: 2 }} />
|
<Divider sx={{ my: 2 }} />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue