Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9643cf191f | |||
| 76a8836769 |
@@ -0,0 +1,53 @@
|
|||||||
|
-- Migration: Create import_audit_log table
|
||||||
|
-- Permanent audit trail of all product import submissions sent to the API
|
||||||
|
-- Run this against your PostgreSQL database
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS import_audit_log (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
|
||||||
|
-- Who initiated the import
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
username VARCHAR(255),
|
||||||
|
|
||||||
|
-- What was submitted
|
||||||
|
product_count INTEGER NOT NULL,
|
||||||
|
request_payload JSONB NOT NULL, -- The exact JSON array of products sent to the API
|
||||||
|
environment VARCHAR(10) NOT NULL, -- 'dev' or 'prod'
|
||||||
|
target_endpoint VARCHAR(255), -- The API URL that was called
|
||||||
|
use_test_data_source BOOLEAN DEFAULT FALSE,
|
||||||
|
|
||||||
|
-- What came back
|
||||||
|
success BOOLEAN NOT NULL,
|
||||||
|
response_payload JSONB, -- Full API response
|
||||||
|
error_message TEXT, -- Extracted error message on failure
|
||||||
|
created_count INTEGER DEFAULT 0, -- Number of products successfully created
|
||||||
|
errored_count INTEGER DEFAULT 0, -- Number of products that errored
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
session_id INTEGER, -- Optional link to the import_session used (if any)
|
||||||
|
duration_ms INTEGER, -- How long the API call took
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for looking up logs by user
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_import_audit_log_user_id
|
||||||
|
ON import_audit_log (user_id);
|
||||||
|
|
||||||
|
-- Index for filtering by success/failure
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_import_audit_log_success
|
||||||
|
ON import_audit_log (success);
|
||||||
|
|
||||||
|
-- Index for time-based queries
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_import_audit_log_created_at
|
||||||
|
ON import_audit_log (created_at DESC);
|
||||||
|
|
||||||
|
-- Composite index for user + time queries
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_import_audit_log_user_created
|
||||||
|
ON import_audit_log (user_id, created_at DESC);
|
||||||
|
|
||||||
|
COMMENT ON TABLE import_audit_log IS 'Permanent audit log of all product import API submissions';
|
||||||
|
COMMENT ON COLUMN import_audit_log.request_payload IS 'Exact JSON products array sent to the external API';
|
||||||
|
COMMENT ON COLUMN import_audit_log.response_payload IS 'Full response received from the external API';
|
||||||
|
COMMENT ON COLUMN import_audit_log.environment IS 'dev or prod - which API endpoint was targeted';
|
||||||
|
COMMENT ON COLUMN import_audit_log.session_id IS 'Optional reference to import_sessions.id if session was active';
|
||||||
|
COMMENT ON COLUMN import_audit_log.duration_ms IS 'Round-trip time of the API call in milliseconds';
|
||||||
@@ -989,6 +989,79 @@ router.get('/best-sellers', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /dashboard/year-revenue-estimate
|
||||||
|
// Returns YTD actual revenue + rest-of-year forecast revenue for a full-year estimate
|
||||||
|
router.get('/year-revenue-estimate', async (req, res) => {
|
||||||
|
const now = new Date();
|
||||||
|
const yearStart = `${now.getFullYear()}-01-01`;
|
||||||
|
const todayISO = now.toISOString().split('T')[0];
|
||||||
|
const yearEndISO = `${now.getFullYear()}-12-31`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// YTD actual revenue from orders
|
||||||
|
const { rows: [ytd] } = await executeQuery(`
|
||||||
|
SELECT COALESCE(SUM(price * quantity), 0) AS revenue
|
||||||
|
FROM orders
|
||||||
|
WHERE date >= $1 AND date <= $2 AND canceled = false
|
||||||
|
`, [yearStart, todayISO]);
|
||||||
|
|
||||||
|
// Forecast horizon
|
||||||
|
const { rows: [horizonRow] } = await executeQuery(
|
||||||
|
`SELECT MAX(forecast_date) AS max_date FROM product_forecasts`
|
||||||
|
);
|
||||||
|
const forecastHorizonISO = horizonRow?.max_date
|
||||||
|
? (horizonRow.max_date instanceof Date
|
||||||
|
? horizonRow.max_date.toISOString().split('T')[0]
|
||||||
|
: horizonRow.max_date)
|
||||||
|
: todayISO;
|
||||||
|
|
||||||
|
const clampedEnd = yearEndISO <= forecastHorizonISO ? yearEndISO : forecastHorizonISO;
|
||||||
|
|
||||||
|
// Forecast revenue from tomorrow to clamped end
|
||||||
|
const { rows: [forecast] } = await executeQuery(`
|
||||||
|
SELECT COALESCE(SUM(pf.forecast_revenue), 0) AS revenue
|
||||||
|
FROM product_forecasts pf
|
||||||
|
JOIN product_metrics pm ON pm.pid = pf.pid
|
||||||
|
WHERE pm.is_visible = true
|
||||||
|
AND pf.forecast_date > $1 AND pf.forecast_date <= $2
|
||||||
|
`, [todayISO, clampedEnd]);
|
||||||
|
|
||||||
|
let eoyForecastRevenue = parseFloat(forecast.revenue) || 0;
|
||||||
|
|
||||||
|
// If forecast doesn't cover full year, extrapolate remaining days
|
||||||
|
if (yearEndISO > forecastHorizonISO) {
|
||||||
|
const { rows: [tailRow] } = await executeQuery(`
|
||||||
|
SELECT AVG(daily_rev) AS avg_daily FROM (
|
||||||
|
SELECT forecast_date, SUM(pf.forecast_revenue) AS daily_rev
|
||||||
|
FROM product_forecasts pf
|
||||||
|
JOIN product_metrics pm ON pm.pid = pf.pid
|
||||||
|
WHERE pm.is_visible = true
|
||||||
|
AND pf.forecast_date > ($1::date - INTERVAL '7 days')
|
||||||
|
AND pf.forecast_date <= $1
|
||||||
|
GROUP BY forecast_date
|
||||||
|
) sub
|
||||||
|
`, [forecastHorizonISO]);
|
||||||
|
|
||||||
|
const baselineDaily = parseFloat(tailRow?.avg_daily) || 0;
|
||||||
|
const horizonDate = new Date(forecastHorizonISO + 'T00:00:00');
|
||||||
|
const yearEnd = new Date(yearEndISO + 'T00:00:00');
|
||||||
|
const extraDays = Math.round((yearEnd - horizonDate) / (1000 * 60 * 60 * 24));
|
||||||
|
eoyForecastRevenue += baselineDaily * extraDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ytdRevenue = parseFloat(ytd.revenue) || 0;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
ytdRevenue,
|
||||||
|
eoyForecastRevenue,
|
||||||
|
yearTotal: ytdRevenue + eoyForecastRevenue,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching year revenue estimate:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch year revenue estimate' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// GET /dashboard/sales/metrics
|
// GET /dashboard/sales/metrics
|
||||||
// Returns sales metrics for specified period
|
// Returns sales metrics for specified period
|
||||||
router.get('/sales/metrics', async (req, res) => {
|
router.get('/sales/metrics', async (req, res) => {
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Create a new audit log entry
|
||||||
|
router.post('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
user_id,
|
||||||
|
username,
|
||||||
|
product_count,
|
||||||
|
request_payload,
|
||||||
|
environment,
|
||||||
|
target_endpoint,
|
||||||
|
use_test_data_source,
|
||||||
|
success,
|
||||||
|
response_payload,
|
||||||
|
error_message,
|
||||||
|
created_count,
|
||||||
|
errored_count,
|
||||||
|
session_id,
|
||||||
|
duration_ms,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!user_id) {
|
||||||
|
return res.status(400).json({ error: 'user_id is required' });
|
||||||
|
}
|
||||||
|
if (!request_payload) {
|
||||||
|
return res.status(400).json({ error: 'request_payload is required' });
|
||||||
|
}
|
||||||
|
if (typeof success !== 'boolean') {
|
||||||
|
return res.status(400).json({ error: 'success (boolean) is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = req.app.locals.pool;
|
||||||
|
if (!pool) {
|
||||||
|
throw new Error('Database pool not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query(`
|
||||||
|
INSERT INTO import_audit_log (
|
||||||
|
user_id,
|
||||||
|
username,
|
||||||
|
product_count,
|
||||||
|
request_payload,
|
||||||
|
environment,
|
||||||
|
target_endpoint,
|
||||||
|
use_test_data_source,
|
||||||
|
success,
|
||||||
|
response_payload,
|
||||||
|
error_message,
|
||||||
|
created_count,
|
||||||
|
errored_count,
|
||||||
|
session_id,
|
||||||
|
duration_ms
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||||
|
RETURNING id, created_at
|
||||||
|
`, [
|
||||||
|
user_id,
|
||||||
|
username || null,
|
||||||
|
product_count || 0,
|
||||||
|
JSON.stringify(request_payload),
|
||||||
|
environment || 'prod',
|
||||||
|
target_endpoint || null,
|
||||||
|
use_test_data_source || false,
|
||||||
|
success,
|
||||||
|
response_payload ? JSON.stringify(response_payload) : null,
|
||||||
|
error_message || null,
|
||||||
|
created_count || 0,
|
||||||
|
errored_count || 0,
|
||||||
|
session_id || null,
|
||||||
|
duration_ms || null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.status(201).json(result.rows[0]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating import audit log:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Failed to create import audit log',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// List audit log entries (with pagination)
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { user_id, limit = 50, offset = 0, success: successFilter } = req.query;
|
||||||
|
|
||||||
|
const pool = req.app.locals.pool;
|
||||||
|
if (!pool) {
|
||||||
|
throw new Error('Database pool not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const conditions = [];
|
||||||
|
const params = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (user_id) {
|
||||||
|
conditions.push(`user_id = $${paramIndex++}`);
|
||||||
|
params.push(user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successFilter !== undefined) {
|
||||||
|
conditions.push(`success = $${paramIndex++}`);
|
||||||
|
params.push(successFilter === 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.length > 0
|
||||||
|
? `WHERE ${conditions.join(' AND ')}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
const countResult = await pool.query(
|
||||||
|
`SELECT COUNT(*) FROM import_audit_log ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get paginated results (exclude large payload columns in list view)
|
||||||
|
const dataParams = [...params, parseInt(limit, 10), parseInt(offset, 10)];
|
||||||
|
const result = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
user_id,
|
||||||
|
username,
|
||||||
|
product_count,
|
||||||
|
environment,
|
||||||
|
target_endpoint,
|
||||||
|
use_test_data_source,
|
||||||
|
success,
|
||||||
|
error_message,
|
||||||
|
created_count,
|
||||||
|
errored_count,
|
||||||
|
session_id,
|
||||||
|
duration_ms,
|
||||||
|
created_at
|
||||||
|
FROM import_audit_log
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $${paramIndex++} OFFSET $${paramIndex++}
|
||||||
|
`, dataParams);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
total: parseInt(countResult.rows[0].count, 10),
|
||||||
|
entries: result.rows,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching import audit log:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Failed to fetch import audit log',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get a single audit log entry (with full payloads)
|
||||||
|
router.get('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const pool = req.app.locals.pool;
|
||||||
|
if (!pool) {
|
||||||
|
throw new Error('Database pool not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
'SELECT * FROM import_audit_log WHERE id = $1',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Audit log entry not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(result.rows[0]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching import audit log entry:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Failed to fetch audit log entry',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error handling middleware
|
||||||
|
router.use((err, req, res, next) => {
|
||||||
|
console.error('Import audit log route error:', err);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Internal server error',
|
||||||
|
details: err.message
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -194,7 +194,7 @@ const processUploadedImage = async (filePath, mimetype) => {
|
|||||||
legacyWarnings.push(message);
|
legacyWarnings.push(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
const format = (baseMetadata.format || '').toLowerCase();
|
let format = (baseMetadata.format || '').toLowerCase();
|
||||||
if (format === 'gif') {
|
if (format === 'gif') {
|
||||||
if (metadata.size > MAX_IMAGE_SIZE_BYTES) {
|
if (metadata.size > MAX_IMAGE_SIZE_BYTES) {
|
||||||
const message = `GIF optimization is limited; resulting size is ${bytesToMegabytes(metadata.size)}MB (target 5MB).`;
|
const message = `GIF optimization is limited; resulting size is ${bytesToMegabytes(metadata.size)}MB (target 5MB).`;
|
||||||
@@ -211,6 +211,16 @@ const processUploadedImage = async (filePath, mimetype) => {
|
|||||||
return { notices, warnings: legacyWarnings, metadata, finalSize: metadata.size };
|
return { notices, warnings: legacyWarnings, metadata, finalSize: metadata.size };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TIFF: convert to JPEG (don't store TIFF files)
|
||||||
|
let convertedFromTiff = false;
|
||||||
|
if (format === 'tiff') {
|
||||||
|
convertedFromTiff = true;
|
||||||
|
format = 'jpeg';
|
||||||
|
const message = 'Converted from TIFF to JPEG.';
|
||||||
|
notices.push({ message, level: 'info', code: 'converted_from_tiff', source: 'server' });
|
||||||
|
legacyWarnings.push(message);
|
||||||
|
}
|
||||||
|
|
||||||
const supportsQuality = ['jpeg', 'jpg', 'webp'].includes(format);
|
const supportsQuality = ['jpeg', 'jpg', 'webp'].includes(format);
|
||||||
let targetQuality = supportsQuality ? 90 : undefined;
|
let targetQuality = supportsQuality ? 90 : undefined;
|
||||||
let finalQuality = undefined;
|
let finalQuality = undefined;
|
||||||
@@ -343,8 +353,8 @@ const processUploadedImage = async (filePath, mimetype) => {
|
|||||||
});
|
});
|
||||||
legacyWarnings.push(message);
|
legacyWarnings.push(message);
|
||||||
}
|
}
|
||||||
} else if (shouldConvertToRgb) {
|
} else if (shouldConvertToRgb || convertedFromTiff) {
|
||||||
const { data, info } = await encode({ width: currentWidth, height: currentHeight });
|
const { data, info } = await encode({ width: currentWidth, height: currentHeight, quality: targetQuality });
|
||||||
mutated = true;
|
mutated = true;
|
||||||
finalBuffer = data;
|
finalBuffer = data;
|
||||||
finalInfo = info;
|
finalInfo = info;
|
||||||
@@ -363,6 +373,15 @@ const processUploadedImage = async (filePath, mimetype) => {
|
|||||||
metadata.optimizedSize = metadata.size;
|
metadata.optimizedSize = metadata.size;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rename TIFF files to .jpg after conversion
|
||||||
|
let newFilePath = null;
|
||||||
|
if (convertedFromTiff) {
|
||||||
|
newFilePath = filePath.replace(/\.tiff?$/i, '.jpg');
|
||||||
|
if (newFilePath !== filePath) {
|
||||||
|
await fsp.rename(filePath, newFilePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
metadata.convertedToRgb = shouldConvertToRgb && mutated;
|
metadata.convertedToRgb = shouldConvertToRgb && mutated;
|
||||||
metadata.resized = resized;
|
metadata.resized = resized;
|
||||||
if (finalQuality) {
|
if (finalQuality) {
|
||||||
@@ -396,6 +415,7 @@ const processUploadedImage = async (filePath, mimetype) => {
|
|||||||
warnings: legacyWarnings,
|
warnings: legacyWarnings,
|
||||||
metadata,
|
metadata,
|
||||||
finalSize: finalBuffer.length,
|
finalSize: finalBuffer.length,
|
||||||
|
newFilePath,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -419,6 +439,7 @@ const storage = multer.diskStorage({
|
|||||||
case 'image/png': fileExt = '.png'; break;
|
case 'image/png': fileExt = '.png'; break;
|
||||||
case 'image/gif': fileExt = '.gif'; break;
|
case 'image/gif': fileExt = '.gif'; break;
|
||||||
case 'image/webp': fileExt = '.webp'; break;
|
case 'image/webp': fileExt = '.webp'; break;
|
||||||
|
case 'image/tiff': fileExt = '.tif'; break;
|
||||||
default: fileExt = '.jpg'; // Default to jpg
|
default: fileExt = '.jpg'; // Default to jpg
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -436,7 +457,7 @@ const upload = multer({
|
|||||||
},
|
},
|
||||||
fileFilter: function (req, file, cb) {
|
fileFilter: function (req, file, cb) {
|
||||||
// Accept only image files
|
// Accept only image files
|
||||||
const filetypes = /jpeg|jpg|png|gif|webp/;
|
const filetypes = /jpeg|jpg|png|gif|webp|tiff?/;
|
||||||
const mimetype = filetypes.test(file.mimetype);
|
const mimetype = filetypes.test(file.mimetype);
|
||||||
const extname = filetypes.test(path.extname(file.originalname).toLowerCase());
|
const extname = filetypes.test(path.extname(file.originalname).toLowerCase());
|
||||||
|
|
||||||
@@ -633,13 +654,19 @@ router.post('/upload-image', upload.single('image'), async (req, res) => {
|
|||||||
const processingResult = await processUploadedImage(filePath, req.file.mimetype);
|
const processingResult = await processUploadedImage(filePath, req.file.mimetype);
|
||||||
req.file.size = processingResult.finalSize;
|
req.file.size = processingResult.finalSize;
|
||||||
|
|
||||||
|
// If TIFF was converted to JPG, update filename to match the renamed file
|
||||||
|
const effectivePath = processingResult.newFilePath || filePath;
|
||||||
|
if (processingResult.newFilePath) {
|
||||||
|
req.file.filename = path.basename(processingResult.newFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
// Create URL for the uploaded file - using an absolute URL with domain
|
// Create URL for the uploaded file - using an absolute URL with domain
|
||||||
// This will generate a URL like: https://acot.site/uploads/products/filename.jpg
|
// This will generate a URL like: https://acot.site/uploads/products/filename.jpg
|
||||||
const baseUrl = 'https://tools.acherryontop.com';
|
const baseUrl = 'https://tools.acherryontop.com';
|
||||||
const imageUrl = `${baseUrl}/uploads/products/${req.file.filename}`;
|
const imageUrl = `${baseUrl}/uploads/products/${req.file.filename}`;
|
||||||
|
|
||||||
// Schedule this image for deletion in 24 hours
|
// Schedule this image for deletion in 24 hours
|
||||||
scheduleImageDeletion(req.file.filename, filePath);
|
scheduleImageDeletion(req.file.filename, effectivePath);
|
||||||
|
|
||||||
// Return success response with image URL
|
// Return success response with image URL
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
@@ -1308,8 +1335,11 @@ const PRODUCT_SELECT = `
|
|||||||
pls.date_sold AS date_last_sold,
|
pls.date_sold AS date_last_sold,
|
||||||
IF(p.tax_code IS NULL, '', CAST(p.tax_code AS CHAR)) AS tax_code,
|
IF(p.tax_code IS NULL, '', CAST(p.tax_code AS CHAR)) AS tax_code,
|
||||||
CAST(p.size_cat AS CHAR) AS size_cat,
|
CAST(p.size_cat AS CHAR) AS size_cat,
|
||||||
CAST(p.shipping_restrictions AS CHAR) AS shipping_restrictions
|
CAST(p.shipping_restrictions AS CHAR) AS shipping_restrictions,
|
||||||
|
IF(DATEDIFF(NOW(), p.date_ol) <= 45 AND p.notnew = 0 AND (si_feed.all IS NULL OR si_feed.all != 2), 1, 0) AS is_new,
|
||||||
|
IF(si_feed.all = 2, 1, 0) AS is_preorder
|
||||||
FROM products p
|
FROM products p
|
||||||
|
LEFT JOIN shop_inventory si_feed ON p.pid = si_feed.pid AND si_feed.store = 0
|
||||||
LEFT JOIN product_current_prices pcp ON p.pid = pcp.pid AND pcp.active = 1
|
LEFT JOIN product_current_prices pcp ON p.pid = pcp.pid AND pcp.active = 1
|
||||||
LEFT JOIN supplier_item_data sid ON p.pid = sid.pid
|
LEFT JOIN supplier_item_data sid ON p.pid = sid.pid
|
||||||
LEFT JOIN suppliers s ON sid.supplier_id = s.supplierid
|
LEFT JOIN suppliers s ON sid.supplier_id = s.supplierid
|
||||||
@@ -1334,7 +1364,7 @@ router.get('/line-products', async (req, res) => {
|
|||||||
where += ' AND p.subline = ?';
|
where += ' AND p.subline = ?';
|
||||||
params.push(Number(subline));
|
params.push(Number(subline));
|
||||||
}
|
}
|
||||||
const query = `${PRODUCT_SELECT} ${where} GROUP BY p.pid ORDER BY p.description`;
|
const query = `${PRODUCT_SELECT} ${where} GROUP BY p.pid ORDER BY IF(p.date_ol != '0000-00-00 00:00:00', p.date_ol, p.date_created) DESC, p.description`;
|
||||||
const [results] = await connection.query(query, params);
|
const [results] = await connection.query(query, params);
|
||||||
res.json(results);
|
res.json(results);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1501,7 +1531,7 @@ router.get('/path-products', async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'No valid filters found in path' });
|
return res.status(400).json({ error: 'No valid filters found in path' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const query = `${PRODUCT_SELECT} WHERE ${whereParts.join(' AND ')} GROUP BY p.pid ORDER BY p.description`;
|
const query = `${PRODUCT_SELECT} WHERE ${whereParts.join(' AND ')} GROUP BY p.pid ORDER BY IF(p.date_ol != '0000-00-00 00:00:00', p.date_ol, p.date_created) DESC, p.description`;
|
||||||
const [results] = await connection.query(query, params);
|
const [results] = await connection.query(query, params);
|
||||||
res.json(results);
|
res.json(results);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1552,6 +1582,60 @@ router.get('/product-images/:pid', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Batch fetch product images for multiple PIDs
|
||||||
|
router.get('/product-images-batch', async (req, res) => {
|
||||||
|
const { pids } = req.query;
|
||||||
|
if (!pids) {
|
||||||
|
return res.status(400).json({ error: 'pids query parameter is required' });
|
||||||
|
}
|
||||||
|
const pidList = String(pids).split(',').map(Number).filter(n => n > 0);
|
||||||
|
if (pidList.length === 0) {
|
||||||
|
return res.json({});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { connection } = await getDbConnection();
|
||||||
|
const placeholders = pidList.map(() => '?').join(',');
|
||||||
|
const [rows] = await connection.query(
|
||||||
|
`SELECT pid, iid, type, width, height, \`order\`, hidden FROM product_images WHERE pid IN (${placeholders}) ORDER BY \`order\` DESC, type`,
|
||||||
|
pidList
|
||||||
|
);
|
||||||
|
|
||||||
|
const typeMap = { 1: 'o', 2: 'l', 3: 't', 4: '100x100', 5: '175x175', 6: '300x300', 7: '600x600', 8: '500x500', 9: '150x150' };
|
||||||
|
const result = {};
|
||||||
|
for (const pid of pidList) {
|
||||||
|
result[pid] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const typeName = typeMap[row.type];
|
||||||
|
if (!typeName) continue;
|
||||||
|
const pid = row.pid;
|
||||||
|
if (!result[pid]) result[pid] = {};
|
||||||
|
if (!result[pid][row.iid]) {
|
||||||
|
result[pid][row.iid] = { iid: row.iid, order: row.order, hidden: !!row.hidden, sizes: {} };
|
||||||
|
}
|
||||||
|
const padded = String(pid).padStart(10, '0');
|
||||||
|
const pathPrefix = `${padded.substring(0, 4)}/${padded.substring(4, 7)}/`;
|
||||||
|
result[pid][row.iid].sizes[typeName] = {
|
||||||
|
width: row.width,
|
||||||
|
height: row.height,
|
||||||
|
url: `https://sbing.com/i/products/${pathPrefix}${pid}-${typeName}-${row.iid}.jpg`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert each pid's iid map to sorted array
|
||||||
|
const output = {};
|
||||||
|
for (const pid of pidList) {
|
||||||
|
output[pid] = Object.values(result[pid] || {}).sort((a, b) => b.order - a.order);
|
||||||
|
}
|
||||||
|
res.json(output);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching batch product images:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch product images' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const UPC_SUPPLIER_PREFIX_LEADING_DIGIT = '4';
|
const UPC_SUPPLIER_PREFIX_LEADING_DIGIT = '4';
|
||||||
const UPC_MAX_SEQUENCE = 99999;
|
const UPC_MAX_SEQUENCE = 99999;
|
||||||
const UPC_RESERVATION_TTL = 5 * 60 * 1000; // 5 minutes
|
const UPC_RESERVATION_TTL = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const vendorsAggregateRouter = require('./routes/vendorsAggregate');
|
|||||||
const brandsAggregateRouter = require('./routes/brandsAggregate');
|
const brandsAggregateRouter = require('./routes/brandsAggregate');
|
||||||
const htsLookupRouter = require('./routes/hts-lookup');
|
const htsLookupRouter = require('./routes/hts-lookup');
|
||||||
const importSessionsRouter = require('./routes/import-sessions');
|
const importSessionsRouter = require('./routes/import-sessions');
|
||||||
|
const importAuditLogRouter = require('./routes/import-audit-log');
|
||||||
const newsletterRouter = require('./routes/newsletter');
|
const newsletterRouter = require('./routes/newsletter');
|
||||||
|
|
||||||
// Get the absolute path to the .env file
|
// Get the absolute path to the .env file
|
||||||
@@ -133,6 +134,7 @@ async function startServer() {
|
|||||||
app.use('/api/reusable-images', reusableImagesRouter);
|
app.use('/api/reusable-images', reusableImagesRouter);
|
||||||
app.use('/api/hts-lookup', htsLookupRouter);
|
app.use('/api/hts-lookup', htsLookupRouter);
|
||||||
app.use('/api/import-sessions', importSessionsRouter);
|
app.use('/api/import-sessions', importSessionsRouter);
|
||||||
|
app.use('/api/import-audit-log', importAuditLogRouter);
|
||||||
app.use('/api/newsletter', newsletterRouter);
|
app.use('/api/newsletter', newsletterRouter);
|
||||||
|
|
||||||
// Basic health check route
|
// Basic health check route
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Calendar as CalendarComponent } from '@/components/ui/calendaredit';
|
import { Calendar as CalendarComponent } from '@/components/ui/calendaredit';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
@@ -34,8 +35,44 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
|
|||||||
const [prevTime, setPrevTime] = useState(getTimeComponents(new Date()));
|
const [prevTime, setPrevTime] = useState(getTimeComponents(new Date()));
|
||||||
const [isTimeChanging, setIsTimeChanging] = useState(false);
|
const [isTimeChanging, setIsTimeChanging] = useState(false);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const [weather, setWeather] = useState(null);
|
const { data: weatherData } = useQuery({
|
||||||
const [forecast, setForecast] = useState(null);
|
queryKey: ["weather-current-forecast"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const API_KEY = import.meta.env.VITE_OPENWEATHER_API_KEY;
|
||||||
|
const [weatherResponse, forecastResponse] = await Promise.all([
|
||||||
|
fetch(
|
||||||
|
`https://api.openweathermap.org/data/2.5/weather?lat=43.63507&lon=-84.18995&appid=${API_KEY}&units=imperial`
|
||||||
|
),
|
||||||
|
fetch(
|
||||||
|
`https://api.openweathermap.org/data/2.5/forecast?lat=43.63507&lon=-84.18995&appid=${API_KEY}&units=imperial`
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const weather = await weatherResponse.json();
|
||||||
|
const forecastData = await forecastResponse.json();
|
||||||
|
|
||||||
|
const dailyForecasts = forecastData.list.reduce((acc, item) => {
|
||||||
|
const date = new Date(item.dt * 1000).toLocaleDateString();
|
||||||
|
if (!acc[date]) {
|
||||||
|
acc[date] = {
|
||||||
|
...item,
|
||||||
|
precipitation: item.rain?.['3h'] || item.snow?.['3h'] || 0,
|
||||||
|
pop: item.pop * 100,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return {
|
||||||
|
weather,
|
||||||
|
forecast: Object.values(dailyForecasts).slice(0, 5),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
refetchInterval: 300000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const weather = weatherData?.weather ?? null;
|
||||||
|
const forecast = weatherData?.forecast ?? null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTimeout(() => setMounted(true), 150);
|
setTimeout(() => setMounted(true), 150);
|
||||||
@@ -55,48 +92,6 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
|
|||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [prevTime]);
|
}, [prevTime]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchWeatherData = async () => {
|
|
||||||
try {
|
|
||||||
const API_KEY = import.meta.env.VITE_OPENWEATHER_API_KEY;
|
|
||||||
const [weatherResponse, forecastResponse] = await Promise.all([
|
|
||||||
fetch(
|
|
||||||
`https://api.openweathermap.org/data/2.5/weather?lat=43.63507&lon=-84.18995&appid=${API_KEY}&units=imperial`
|
|
||||||
),
|
|
||||||
fetch(
|
|
||||||
`https://api.openweathermap.org/data/2.5/forecast?lat=43.63507&lon=-84.18995&appid=${API_KEY}&units=imperial`
|
|
||||||
)
|
|
||||||
]);
|
|
||||||
|
|
||||||
const weatherData = await weatherResponse.json();
|
|
||||||
const forecastData = await forecastResponse.json();
|
|
||||||
|
|
||||||
setWeather(weatherData);
|
|
||||||
|
|
||||||
// Process forecast data to get daily forecasts with precipitation
|
|
||||||
const dailyForecasts = forecastData.list.reduce((acc, item) => {
|
|
||||||
const date = new Date(item.dt * 1000).toLocaleDateString();
|
|
||||||
if (!acc[date]) {
|
|
||||||
acc[date] = {
|
|
||||||
...item,
|
|
||||||
precipitation: item.rain?.['3h'] || item.snow?.['3h'] || 0,
|
|
||||||
pop: item.pop * 100 // Probability of precipitation as percentage
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
setForecast(Object.values(dailyForecasts).slice(0, 5));
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching weather:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchWeatherData();
|
|
||||||
const weatherTimer = setInterval(fetchWeatherData, 300000);
|
|
||||||
return () => clearInterval(weatherTimer);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
function getTimeComponents(date) {
|
function getTimeComponents(date) {
|
||||||
let hours = date.getHours();
|
let hours = date.getHours();
|
||||||
const minutes = date.getMinutes();
|
const minutes = date.getMinutes();
|
||||||
@@ -242,7 +237,7 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
|
|||||||
const WeatherDetails = () => (
|
const WeatherDetails = () => (
|
||||||
<div className="space-y-4 p-3">
|
<div className="space-y-4 p-3">
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
<Card className="bg-slate-700/50 backdrop-blur-sm border-white/[0.06] p-2">
|
<Card className="bg-slate-700/50 backdrop-blur-xl border-white/[0.08] ring-1 ring-white/[0.05] p-2">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<ThermometerSun className="w-5 h-5 text-yellow-300" />
|
<ThermometerSun className="w-5 h-5 text-yellow-300" />
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
@@ -252,7 +247,7 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="bg-slate-700/50 backdrop-blur-sm border-white/[0.06] p-2">
|
<Card className="bg-slate-700/50 backdrop-blur-xl border-white/[0.08] ring-1 ring-white/[0.05] p-2">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<ThermometerSnowflake className="w-5 h-5 text-blue-300" />
|
<ThermometerSnowflake className="w-5 h-5 text-blue-300" />
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
@@ -262,7 +257,7 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="bg-slate-700/50 backdrop-blur-sm border-white/[0.06] p-2">
|
<Card className="bg-slate-700/50 backdrop-blur-xl border-white/[0.08] ring-1 ring-white/[0.05] p-2">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Droplets className="w-5 h-5 text-blue-300" />
|
<Droplets className="w-5 h-5 text-blue-300" />
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
@@ -272,7 +267,7 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="bg-slate-700/50 backdrop-blur-sm border-white/[0.06] p-2">
|
<Card className="bg-slate-700/50 backdrop-blur-xl border-white/[0.08] ring-1 ring-white/[0.05] p-2">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Wind className="w-5 h-5 text-slate-300" />
|
<Wind className="w-5 h-5 text-slate-300" />
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
@@ -282,7 +277,7 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="bg-slate-700/50 backdrop-blur-sm border-white/[0.06] p-2">
|
<Card className="bg-slate-700/50 backdrop-blur-xl border-white/[0.08] ring-1 ring-white/[0.05] p-2">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Sunrise className="w-5 h-5 text-yellow-300" />
|
<Sunrise className="w-5 h-5 text-yellow-300" />
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
@@ -292,7 +287,7 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="bg-slate-700/50 backdrop-blur-sm border-white/[0.06] p-2">
|
<Card className="bg-slate-700/50 backdrop-blur-xl border-white/[0.08] ring-1 ring-white/[0.05] p-2">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Sunset className="w-5 h-5 text-orange-300" />
|
<Sunset className="w-5 h-5 text-orange-300" />
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
@@ -314,7 +309,7 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
|
|||||||
key={index}
|
key={index}
|
||||||
className={cn(
|
className={cn(
|
||||||
getWeatherBackground(day.weather[0].id, isNight),
|
getWeatherBackground(day.weather[0].id, isNight),
|
||||||
"p-2"
|
"p-2 border-white/[0.08] ring-1 ring-white/[0.05]"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center gap-1">
|
<div className="flex flex-col items-center gap-1">
|
||||||
@@ -365,23 +360,23 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
|
|||||||
{/* Time Display */}
|
{/* Time Display */}
|
||||||
<Card className="bg-gradient-to-br mb-[7px] from-indigo-900/70 to-blue-800/50 backdrop-blur-xl border-white/[0.08] ring-1 ring-white/[0.05] shadow-lg shadow-black/20 px-1 py-2 w-full hover:scale-[1.02] transition-transform duration-300">
|
<Card className="bg-gradient-to-br mb-[7px] from-indigo-900/70 to-blue-800/50 backdrop-blur-xl border-white/[0.08] ring-1 ring-white/[0.05] shadow-lg shadow-black/20 px-1 py-2 w-full hover:scale-[1.02] transition-transform duration-300">
|
||||||
<CardContent className="p-3 h-[106px] flex items-center">
|
<CardContent className="p-3 h-[106px] flex items-center">
|
||||||
<div className="flex justify-center items-baseline w-full">
|
<div className="flex justify-center items-baseline w-full tracking-tighter">
|
||||||
<div className={`transition-opacity duration-200 ${isTimeChanging ? 'opacity-60' : 'opacity-100'}`}>
|
<div className={`transition-opacity duration-200 ${isTimeChanging ? 'opacity-60' : 'opacity-100'}`}>
|
||||||
<span className="text-6xl font-bold text-white">{hours}</span>
|
<span className="text-7xl font-light text-white">{hours}</span>
|
||||||
<span className="text-6xl font-bold text-white">:</span>
|
<span className="text-7xl font-light text-white">:</span>
|
||||||
<span className="text-6xl font-bold text-white">{minutes}</span>
|
<span className="text-7xl font-light text-white">{minutes}</span>
|
||||||
<span className="text-lg font-medium text-white/90 ml-1">{ampm}</span>
|
<span className="text-lg font-light text-white/90 ml-1">{ampm}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Date and Weather Display */}
|
{/* Date and Weather Display */}
|
||||||
<div className="h-[125px] mb-[6px] grid grid-cols-2 gap-2 w-full">
|
<div className="h-[121px] mb-[7px] grid grid-cols-2 gap-2 w-full">
|
||||||
<Card className="h-full bg-gradient-to-br from-violet-900/70 to-purple-800/50 backdrop-blur-xl border-white/[0.08] ring-1 ring-white/[0.05] shadow-lg shadow-black/20 flex items-center justify-center">
|
<Card className="h-full bg-gradient-to-br from-violet-900/70 to-purple-800/50 backdrop-blur-xl border-white/[0.08] ring-1 ring-white/[0.05] shadow-lg shadow-black/20 flex items-center justify-center">
|
||||||
<CardContent className="h-full p-0">
|
<CardContent className="h-full p-0">
|
||||||
<div className="flex flex-col items-center justify-center h-full">
|
<div className="flex flex-col items-center justify-center h-full">
|
||||||
<span className="text-6xl font-bold text-white">
|
<span className="text-6xl font-light text-white tracking-tighter">
|
||||||
{dateInfo.day}
|
{dateInfo.day}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-bold text-white mt-2">
|
<span className="text-sm font-bold text-white mt-2">
|
||||||
@@ -404,7 +399,7 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
|
|||||||
<CardContent className="h-full p-3">
|
<CardContent className="h-full p-3">
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
{getWeatherIcon(weather.weather[0]?.id, datetime)}
|
{getWeatherIcon(weather.weather[0]?.id, datetime)}
|
||||||
<span className="text-3xl font-bold ml-1 mt-2 text-white">
|
<span className="text-3xl font-normal ml-1 mt-2 text-white">
|
||||||
{Math.round(weather.main.temp)}°
|
{Math.round(weather.main.temp)}°
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -417,7 +412,7 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
|
|||||||
</Card>
|
</Card>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
className="w-[450px] bg-gradient-to-br from-slate-800/90 to-slate-700/80 backdrop-blur-xl border-white/[0.08] ring-1 ring-white/[0.05]"
|
className="w-[450px] bg-gradient-to-br from-slate-800/90 to-slate-700/80 backdrop-blur-xl border-white/[0.08] ring-1 ring-white/[0.05] shadow-lg shadow-black/20"
|
||||||
align="start"
|
align="start"
|
||||||
side="right"
|
side="right"
|
||||||
sideOffset={10}
|
sideOffset={10}
|
||||||
@@ -441,21 +436,29 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Calendar Display */}
|
{/* Calendar Display */}
|
||||||
<Card className="w-full bg-slate-800/50 backdrop-blur-xl border-white/[0.08] ring-1 ring-white/[0.05] shadow-lg shadow-black/20">
|
<Card className="w-full h-[208px] bg-gradient-to-br from-slate-900 to-slate-800/70 backdrop-blur-xl border-white/[0.08] ring-1 ring-white/[0.05] shadow-lg shadow-black/20">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<CalendarComponent
|
<CalendarComponent
|
||||||
selected={datetime}
|
selected={datetime}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
classNames={{
|
classNames={{
|
||||||
caption_label: "text-lg font-medium text-white",
|
caption: "flex justify-center relative items-center mt-1 mb-2",
|
||||||
|
caption_label: "text-sm font-medium text-white",
|
||||||
nav_button: cn(
|
nav_button: cn(
|
||||||
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors",
|
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors",
|
||||||
"hover:bg-white/10 h-6 w-6 bg-transparent p-0 text-slate-300 hover:text-white"
|
"hover:bg-white/10 h-5 w-5 bg-transparent p-0 text-slate-300 hover:text-white"
|
||||||
|
),
|
||||||
|
table: "w-full border-collapse",
|
||||||
|
head_cell: "text-slate-400 rounded-md font-normal text-[0.65rem] w-full mb-0.5",
|
||||||
|
row: "flex w-full",
|
||||||
|
cell: cn(
|
||||||
|
"w-full relative p-0 text-center text-xs focus-within:relative focus-within:z-20",
|
||||||
|
"[&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50",
|
||||||
|
"[&:has([aria-selected])]:rounded-md"
|
||||||
),
|
),
|
||||||
head_cell: "text-slate-400 rounded-md w-6 font-normal text-[0.7rem] w-full",
|
|
||||||
day: cn(
|
day: cn(
|
||||||
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors",
|
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors",
|
||||||
"hover:bg-white/10 h-6 w-6 p-0 font-normal text-xs text-slate-200 aria-selected:opacity-100"
|
"hover:bg-white/10 h-6 w-full p-0 font-normal text-xs text-slate-200 aria-selected:opacity-100"
|
||||||
),
|
),
|
||||||
day_selected: "bg-indigo-500/60 text-white hover:bg-indigo-500/70 focus:bg-indigo-500/70",
|
day_selected: "bg-indigo-500/60 text-white hover:bg-indigo-500/70 focus:bg-indigo-500/70",
|
||||||
day_today: "bg-white/10 text-white font-semibold",
|
day_today: "bg-white/10 text-white font-semibold",
|
||||||
|
|||||||
@@ -416,17 +416,15 @@ const ShippingInfo = ({ details }) => (
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const EventDialog = ({ event, children }) => {
|
const EventDialog = ({ event, children, scale }) => {
|
||||||
const eventType = EVENT_TYPES[event.metric_id];
|
const eventType = EVENT_TYPES[event.metric_id];
|
||||||
if (!eventType) return children;
|
if (!eventType) return children;
|
||||||
|
|
||||||
const details = event.event_properties || {};
|
const details = event.event_properties || {};
|
||||||
const Icon = EVENT_ICONS[event.metric_id] || Package;
|
const Icon = EVENT_ICONS[event.metric_id] || Package;
|
||||||
|
|
||||||
return (
|
const dialogInner = (
|
||||||
<Dialog>
|
<>
|
||||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
|
||||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
|
|
||||||
<DialogHeader className="border-b border-border px-6 py-4">
|
<DialogHeader className="border-b border-border px-6 py-4">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
{Icon && <Icon className={`h-5 w-5 ${eventType.textColor}`} />}
|
{Icon && <Icon className={`h-5 w-5 ${eventType.textColor}`} />}
|
||||||
@@ -797,6 +795,24 @@ const EventDialog = ({ event, children }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||||
|
<DialogContent className={scale
|
||||||
|
? "w-[80vw] h-[80vh] max-w-none p-0 overflow-hidden"
|
||||||
|
: "max-w-2xl max-h-[85vh] overflow-hidden flex flex-col"
|
||||||
|
}>
|
||||||
|
{scale ? (
|
||||||
|
<div
|
||||||
|
className="origin-top-left flex flex-col overflow-auto"
|
||||||
|
style={{ transform: `scale(${scale})`, width: `${100 / scale}%`, height: `${100 / scale}%` }}
|
||||||
|
>
|
||||||
|
{dialogInner}
|
||||||
|
</div>
|
||||||
|
) : dialogInner}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { TrendingUp, DollarSign, Percent, Briefcase } from "lucide-react";
|
||||||
|
import { DashboardMultiStatCardMini } from "@/components/dashboard/shared";
|
||||||
|
import { acotService } from "@/services/dashboard/acotService";
|
||||||
|
import config from "@/config";
|
||||||
|
|
||||||
|
const fmtK = (value) => {
|
||||||
|
if (!value && value !== 0) return "$0";
|
||||||
|
if (value >= 1000000) return `$${(value / 1000000).toFixed(1)}M`;
|
||||||
|
if (value >= 1000) return `$${(value / 1000).toFixed(0)}K`;
|
||||||
|
return `$${value.toFixed(0)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtDollars = (value) => {
|
||||||
|
if (!value && value !== 0) return "$0";
|
||||||
|
return `$${Math.round(value).toLocaleString("en-US")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtYear = (value) => {
|
||||||
|
if (!value && value !== 0) return "$0";
|
||||||
|
if (value >= 1000000) return `$${(value / 1000000).toFixed(2)}M`;
|
||||||
|
if (value >= 1000) return `$${(value / 1000).toFixed(0)}K`;
|
||||||
|
return `$${value.toFixed(0)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TrendSub = ({ label, trend, suffix = "%", invert = false, decimals = 0 }) => {
|
||||||
|
if (label == null) return null;
|
||||||
|
const isPositive = trend > 0;
|
||||||
|
const isGood = invert ? !isPositive : isPositive;
|
||||||
|
const arrow = trend > 0 ? "\u2191" : trend < 0 ? "\u2193" : "";
|
||||||
|
const trendColor =
|
||||||
|
trend == null || Math.abs(trend) < 0.1
|
||||||
|
? ""
|
||||||
|
: isGood
|
||||||
|
? "text-emerald-300"
|
||||||
|
: "text-rose-300";
|
||||||
|
const formatted = decimals > 0
|
||||||
|
? Math.abs(trend).toFixed(decimals)
|
||||||
|
: Math.abs(Math.round(trend));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="text-sm font-semibold text-gray-200">
|
||||||
|
{label}
|
||||||
|
{trend != null && Math.abs(trend) >= 0.1 && (
|
||||||
|
<span className={`ml-1.5 ${trendColor}`}>
|
||||||
|
{arrow}{formatted}{suffix}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MiniBusinessMetrics = () => {
|
||||||
|
// 30d forecast revenue
|
||||||
|
const { data: forecastData, isLoading: forecastLoading } = useQuery({
|
||||||
|
queryKey: ["mini-forecast-30d"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await fetch(`${config.apiUrl}/dashboard/forecast/metrics`);
|
||||||
|
if (!response.ok) throw new Error("Failed to fetch forecast");
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
refetchInterval: 600000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Year revenue estimate (YTD + rest-of-year forecast)
|
||||||
|
const { data: yearData, isLoading: yearLoading } = useQuery({
|
||||||
|
queryKey: ["mini-year-estimate"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await fetch(`${config.apiUrl}/dashboard/year-revenue-estimate`);
|
||||||
|
if (!response.ok) throw new Error("Failed to fetch year estimate");
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
refetchInterval: 600000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Avg revenue per day (last 30 days via ACOT stats)
|
||||||
|
const { data: revenueData, isLoading: revenueLoading } = useQuery({
|
||||||
|
queryKey: ["mini-avg-revenue-30d"],
|
||||||
|
queryFn: () =>
|
||||||
|
acotService.getStatsDetails({
|
||||||
|
timeRange: "last30days",
|
||||||
|
metric: "revenue",
|
||||||
|
daily: true,
|
||||||
|
}),
|
||||||
|
refetchInterval: 300000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Financials for profit margin (last 30 days)
|
||||||
|
const { data: financialData, isLoading: financialLoading } = useQuery({
|
||||||
|
queryKey: ["mini-financials-30d"],
|
||||||
|
queryFn: () => acotService.getFinancials({ timeRange: "last30days" }),
|
||||||
|
refetchInterval: 300000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Payroll FTE — use employee-metrics with timeRange for true 30d window
|
||||||
|
const { data: fteData, isLoading: payrollLoading } = useQuery({
|
||||||
|
queryKey: ["mini-fte-30d"],
|
||||||
|
queryFn: () => acotService.getEmployeeMetrics({ timeRange: "last30days" }),
|
||||||
|
refetchInterval: 300000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const loading =
|
||||||
|
forecastLoading || yearLoading || revenueLoading || financialLoading || payrollLoading;
|
||||||
|
|
||||||
|
// --- Avg revenue per day ---
|
||||||
|
let avgRevPerDay = 0;
|
||||||
|
let prevAvgRevPerDay = 0;
|
||||||
|
let revTrend = 0;
|
||||||
|
if (revenueData?.stats) {
|
||||||
|
const stats = Array.isArray(revenueData.stats) ? revenueData.stats : [];
|
||||||
|
if (stats.length > 0) {
|
||||||
|
avgRevPerDay = stats.reduce((sum, d) => sum + (d.revenue || 0), 0) / stats.length;
|
||||||
|
prevAvgRevPerDay =
|
||||||
|
stats.reduce((sum, d) => sum + (d.prevRevenue || 0), 0) / stats.length;
|
||||||
|
revTrend =
|
||||||
|
prevAvgRevPerDay > 0
|
||||||
|
? ((avgRevPerDay - prevAvgRevPerDay) / prevAvgRevPerDay) * 100
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Profit margin ---
|
||||||
|
let margin = 0;
|
||||||
|
let prevMargin = null;
|
||||||
|
let marginTrend = null;
|
||||||
|
if (financialData?.totals) {
|
||||||
|
const t = financialData.totals;
|
||||||
|
if (Number.isFinite(t.margin)) {
|
||||||
|
margin = t.margin;
|
||||||
|
} else {
|
||||||
|
const income =
|
||||||
|
(t.grossSales || 0) - (t.refunds || 0) - (t.discounts || 0) + (t.shippingFees || 0);
|
||||||
|
const profit = income - (t.cogs || 0);
|
||||||
|
margin = income > 0 ? (profit / income) * 100 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prev = financialData.previousTotals;
|
||||||
|
if (prev) {
|
||||||
|
if (Number.isFinite(prev.margin)) {
|
||||||
|
prevMargin = prev.margin;
|
||||||
|
} else {
|
||||||
|
const prevIncome =
|
||||||
|
(prev.grossSales || 0) -
|
||||||
|
(prev.refunds || 0) -
|
||||||
|
(prev.discounts || 0) +
|
||||||
|
(prev.shippingFees || 0);
|
||||||
|
const prevProfit = prevIncome - (prev.cogs || 0);
|
||||||
|
prevMargin = prevIncome > 0 ? (prevProfit / prevIncome) * 100 : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (financialData.comparison?.margin?.absolute != null) {
|
||||||
|
marginTrend = financialData.comparison.margin.absolute;
|
||||||
|
} else if (prevMargin != null) {
|
||||||
|
marginTrend = margin - prevMargin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Payroll FTE (true 30d window from employee-metrics) ---
|
||||||
|
const fte = fteData?.totals?.fte ?? 0;
|
||||||
|
const prevFte = fteData?.previousTotals?.fte ?? null;
|
||||||
|
const fteTrend = fteData?.comparison?.fte?.percentage ?? null;
|
||||||
|
|
||||||
|
const ready =
|
||||||
|
!loading &&
|
||||||
|
forecastData &&
|
||||||
|
yearData &&
|
||||||
|
revenueData?.stats &&
|
||||||
|
financialData?.totals &&
|
||||||
|
fteData?.totals;
|
||||||
|
|
||||||
|
const entries = ready
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
icon: TrendingUp,
|
||||||
|
iconBg: "bg-blue-400",
|
||||||
|
label: "Forecast",
|
||||||
|
value: fmtK(forecastData.forecastRevenue),
|
||||||
|
sub: (
|
||||||
|
<span className="text-sm font-semibold text-gray-200">
|
||||||
|
{fmtYear(yearData.yearTotal)} for year
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: DollarSign,
|
||||||
|
iconBg: "bg-emerald-400",
|
||||||
|
label: "Avg Revenue/Day",
|
||||||
|
value: fmtDollars(avgRevPerDay),
|
||||||
|
sub: (
|
||||||
|
<TrendSub
|
||||||
|
label={`Prev: ${fmtDollars(prevAvgRevPerDay)}`}
|
||||||
|
trend={revTrend}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Percent,
|
||||||
|
iconBg: "bg-purple-400",
|
||||||
|
label: "Profit Margin",
|
||||||
|
value: `${margin.toFixed(1)}%`,
|
||||||
|
sub:
|
||||||
|
prevMargin != null ? (
|
||||||
|
<TrendSub
|
||||||
|
label={`Prev: ${prevMargin.toFixed(1)}%`}
|
||||||
|
trend={marginTrend}
|
||||||
|
suffix="pp"
|
||||||
|
decimals={1}
|
||||||
|
/>
|
||||||
|
) : undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Briefcase,
|
||||||
|
iconBg: "bg-orange-400",
|
||||||
|
label: "Payroll FTE",
|
||||||
|
value: fte.toFixed(1),
|
||||||
|
sub:
|
||||||
|
prevFte != null ? (
|
||||||
|
<TrendSub
|
||||||
|
label={`Prev: ${prevFte.toFixed(1)}`}
|
||||||
|
trend={fteTrend}
|
||||||
|
/>
|
||||||
|
) : undefined,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardMultiStatCardMini
|
||||||
|
title="30 Days"
|
||||||
|
entries={entries}
|
||||||
|
gradient="custom"
|
||||||
|
className="bg-gradient-to-br from-indigo-900/80 to-indigo-700/40"
|
||||||
|
loading={!ready}
|
||||||
|
skeletonRows={4}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MiniBusinessMetrics;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -135,7 +136,7 @@ const EventCard = ({ event }) => {
|
|||||||
const details = event.event_properties || {};
|
const details = event.event_properties || {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EventDialog event={event}>
|
<EventDialog event={event} scale={1.75}>
|
||||||
<Card className={`w-[230px] border-white/[0.08] ring-1 ring-white/[0.05] shrink-0 hover:brightness-110 hover:ring-white/[0.12] cursor-pointer transition-all h-[125px] bg-gradient-to-br ${eventType.gradient} backdrop-blur-xl shadow-lg shadow-black/20`}>
|
<Card className={`w-[230px] border-white/[0.08] ring-1 ring-white/[0.05] shrink-0 hover:brightness-110 hover:ring-white/[0.12] cursor-pointer transition-all h-[125px] bg-gradient-to-br ${eventType.gradient} backdrop-blur-xl shadow-lg shadow-black/20`}>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 px-3 py-2 pb-0">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 px-3 py-2 pb-0">
|
||||||
<div className="flex items-baseline justify-between w-full pr-1">
|
<div className="flex items-baseline justify-between w-full pr-1">
|
||||||
@@ -317,14 +318,28 @@ const DEFAULT_METRICS = Object.values(METRIC_IDS);
|
|||||||
const MiniEventFeed = ({
|
const MiniEventFeed = ({
|
||||||
selectedMetrics = DEFAULT_METRICS,
|
selectedMetrics = DEFAULT_METRICS,
|
||||||
}) => {
|
}) => {
|
||||||
const [events, setEvents] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
const [lastUpdate, setLastUpdate] = useState(null);
|
|
||||||
const scrollRef = useRef(null);
|
const scrollRef = useRef(null);
|
||||||
const [showLeftArrow, setShowLeftArrow] = useState(false);
|
const [showLeftArrow, setShowLeftArrow] = useState(false);
|
||||||
const [showRightArrow, setShowRightArrow] = useState(false);
|
const [showRightArrow, setShowRightArrow] = useState(false);
|
||||||
|
|
||||||
|
const { data: events = [], isLoading: loading, error } = useQuery({
|
||||||
|
queryKey: ["mini-event-feed", selectedMetrics],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await axios.get("/api/klaviyo/events/feed", {
|
||||||
|
params: {
|
||||||
|
timeRange: "today",
|
||||||
|
metricIds: JSON.stringify(selectedMetrics),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return (response.data.data || []).map((event) => ({
|
||||||
|
...event,
|
||||||
|
datetime: event.attributes?.datetime || event.datetime,
|
||||||
|
event_properties: event.attributes?.event_properties || {}
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
refetchInterval: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
if (scrollRef.current) {
|
if (scrollRef.current) {
|
||||||
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
|
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
|
||||||
@@ -351,62 +366,23 @@ const MiniEventFeed = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchEvents = useCallback(async () => {
|
// Scroll to end when events load/change
|
||||||
try {
|
useEffect(() => {
|
||||||
setError(null);
|
if (scrollRef.current && events.length > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
if (events.length === 0) {
|
scrollRef.current?.scrollTo({
|
||||||
setLoading(true);
|
left: scrollRef.current.scrollWidth,
|
||||||
}
|
behavior: 'instant'
|
||||||
|
});
|
||||||
const response = await axios.get("/api/klaviyo/events/feed", {
|
handleScroll();
|
||||||
params: {
|
}, 0);
|
||||||
timeRange: "today",
|
|
||||||
metricIds: JSON.stringify(selectedMetrics),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const processedEvents = (response.data.data || []).map((event) => ({
|
|
||||||
...event,
|
|
||||||
datetime: event.attributes?.datetime || event.datetime,
|
|
||||||
event_properties: event.attributes?.event_properties || {}
|
|
||||||
}));
|
|
||||||
|
|
||||||
setEvents(processedEvents);
|
|
||||||
setLastUpdate(new Date());
|
|
||||||
|
|
||||||
// Scroll to the right after events are loaded
|
|
||||||
if (scrollRef.current) {
|
|
||||||
setTimeout(() => {
|
|
||||||
scrollRef.current.scrollTo({
|
|
||||||
left: scrollRef.current.scrollWidth,
|
|
||||||
behavior: 'instant'
|
|
||||||
});
|
|
||||||
handleScroll();
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching events:", error);
|
|
||||||
setError(error.message);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
}, [selectedMetrics]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchEvents();
|
|
||||||
const interval = setInterval(fetchEvents, 30000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [fetchEvents]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
handleScroll();
|
|
||||||
}, [events]);
|
}, [events]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-0 left-0 right-0">
|
<div className="fixed bottom-0 left-0 right-0">
|
||||||
<Card className="rounded-none bg-slate-900/80 backdrop-blur-xl border-0 border-t border-white/[0.08] shadow-[0_-8px_30px_rgba(0,0,0,0.3)]">
|
<Card className="rounded-none bg-slate-900/80 backdrop-blur-xl border-0 border-t border-white/50 shadow-[0_-8px_30px_rgba(0,0,0,0.3)]">
|
||||||
<div className="px-1 pt-2 pb-3 relative">
|
<div className=" pt-2 pb-3 relative">
|
||||||
{/* Left fade edge */}
|
{/* Left fade edge */}
|
||||||
{showLeftArrow && (
|
{showLeftArrow && (
|
||||||
<div
|
<div
|
||||||
@@ -424,11 +400,11 @@ const MiniEventFeed = ({
|
|||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
className="overflow-x-auto overflow-y-hidden [&::-webkit-scrollbar]:hidden [-ms-overflow-style:'none'] [scrollbar-width:'none']"
|
className="overflow-x-auto overflow-y-hidden [&::-webkit-scrollbar]:hidden [-ms-overflow-style:'none'] [scrollbar-width:'none']"
|
||||||
>
|
>
|
||||||
<div className="flex flex-row gap-3 px-4" style={{ width: 'max-content' }}>
|
<div className="flex flex-row gap-2.5 px-2.5" style={{ width: 'max-content' }}>
|
||||||
{loading && !events.length ? (
|
{loading && !events.length ? (
|
||||||
<LoadingState />
|
<LoadingState />
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<DashboardErrorState error={`Failed to load event feed: ${error}`} className="mx-4" />
|
<DashboardErrorState error={`Failed to load event feed: ${error?.message}`} className="mx-4" />
|
||||||
) : !events || events.length === 0 ? (
|
) : !events || events.length === 0 ? (
|
||||||
<div className="px-4">
|
<div className="px-4">
|
||||||
<EmptyState />
|
<EmptyState />
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Truck, Warehouse, ShoppingBag, AlertTriangle } from "lucide-react";
|
||||||
|
import { DashboardMultiStatCardMini } from "@/components/dashboard/shared";
|
||||||
|
import { acotService } from "@/services/dashboard/acotService";
|
||||||
|
import config from "@/config";
|
||||||
|
|
||||||
|
const fmtCurrency = (value) => {
|
||||||
|
if (!value && value !== 0) return "$0";
|
||||||
|
if (value >= 1000000) return `$${(value / 1000000).toFixed(1)}M`;
|
||||||
|
if (value >= 1000) return `$${(value / 1000).toFixed(0)}K`;
|
||||||
|
return `$${value.toFixed(0)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtNum = (value) => {
|
||||||
|
if (!value && value !== 0) return "0";
|
||||||
|
if (value >= 1000) return `${(value / 1000).toFixed(1)}K`;
|
||||||
|
return value.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const MiniInventorySnapshot = () => {
|
||||||
|
// Operations (shipped/picked today)
|
||||||
|
const { data: opsData, isLoading: opsLoading } = useQuery({
|
||||||
|
queryKey: ["mini-ops-today"],
|
||||||
|
queryFn: () => acotService.getOperationsMetrics({ timeRange: "today" }),
|
||||||
|
refetchInterval: 120000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stock metrics
|
||||||
|
const { data: stockData, isLoading: stockLoading } = useQuery({
|
||||||
|
queryKey: ["mini-stock-metrics"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await fetch(`${config.apiUrl}/dashboard/stock/metrics`);
|
||||||
|
if (!response.ok) throw new Error("Failed to fetch stock metrics");
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
refetchInterval: 300000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Replenishment metrics
|
||||||
|
const { data: replenishData, isLoading: replenishLoading } = useQuery({
|
||||||
|
queryKey: ["mini-replenish-metrics"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await fetch(`${config.apiUrl}/dashboard/replenishment/metrics`);
|
||||||
|
if (!response.ok) throw new Error("Failed to fetch replenishment");
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
refetchInterval: 300000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Overstock metrics
|
||||||
|
const { data: overstockData, isLoading: overstockLoading } = useQuery({
|
||||||
|
queryKey: ["mini-overstock-metrics"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await fetch(`${config.apiUrl}/dashboard/overstock/metrics`);
|
||||||
|
if (!response.ok) throw new Error("Failed to fetch overstock");
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
refetchInterval: 300000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const loading = opsLoading || stockLoading || replenishLoading || overstockLoading;
|
||||||
|
const ready = !loading && opsData?.totals && stockData && replenishData && overstockData;
|
||||||
|
|
||||||
|
const t = opsData?.totals;
|
||||||
|
|
||||||
|
const entries = ready
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
icon: Truck,
|
||||||
|
iconBg: "bg-sky-400",
|
||||||
|
label: "Shipped",
|
||||||
|
value: `${fmtNum(t.ordersShipped)}`,
|
||||||
|
sub: `${fmtNum(t.piecesPicked)} pcs picked`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Warehouse,
|
||||||
|
iconBg: "bg-emerald-400",
|
||||||
|
label: "Stock Value",
|
||||||
|
value: fmtCurrency(stockData.totalStockCost),
|
||||||
|
sub: `${fmtNum(stockData.productsInStock)} products`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: ShoppingBag,
|
||||||
|
iconBg: "bg-amber-400",
|
||||||
|
label: "Replenish Units",
|
||||||
|
value: `${fmtNum(replenishData.unitsToReplenish)}`,
|
||||||
|
sub: `${fmtCurrency(replenishData.replenishmentCost)} cost`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: AlertTriangle,
|
||||||
|
iconBg: overstockData.overstockedProducts > 0 ? "bg-rose-400" : "bg-violet-400",
|
||||||
|
label: "Overstocked SKUs",
|
||||||
|
value: `${fmtNum(overstockData.overstockedProducts)}`,
|
||||||
|
sub: `${fmtCurrency(overstockData.totalExcessCost)} cost`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardMultiStatCardMini
|
||||||
|
title="Today"
|
||||||
|
entries={entries}
|
||||||
|
gradient="amber"
|
||||||
|
loading={!ready}
|
||||||
|
skeletonRows={4}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MiniInventorySnapshot;
|
||||||
@@ -1,22 +1,20 @@
|
|||||||
import React, { useState, useEffect, useCallback } from "react";
|
import React from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { acotService } from "@/services/dashboard/acotService";
|
import { acotService } from "@/services/dashboard/acotService";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
Card,
|
AreaChart,
|
||||||
CardContent,
|
Area,
|
||||||
} from "@/components/ui/card";
|
|
||||||
import {
|
|
||||||
LineChart,
|
|
||||||
Line,
|
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
CartesianGrid,
|
|
||||||
Tooltip,
|
Tooltip,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
import { AlertCircle, PiggyBank, Truck } from "lucide-react";
|
import { AlertCircle, PiggyBank, ShoppingCart } from "lucide-react";
|
||||||
import { formatCurrency, processData } from "./SalesChart.jsx";
|
import { formatCurrency } from "./SalesChart.jsx";
|
||||||
import { METRIC_COLORS } from "@/lib/dashboard/designTokens";
|
import { PHASE_CONFIG, PHASE_KEYS_WITH_UNKNOWN as PHASE_KEYS } from "@/utils/lifecyclePhases";
|
||||||
|
import config from "@/config";
|
||||||
import {
|
import {
|
||||||
DashboardStatCardMini,
|
DashboardStatCardMini,
|
||||||
DashboardStatCardMiniSkeleton,
|
DashboardStatCardMiniSkeleton,
|
||||||
@@ -24,116 +22,80 @@ import {
|
|||||||
TOOLTIP_THEMES,
|
TOOLTIP_THEMES,
|
||||||
} from "@/components/dashboard/shared";
|
} from "@/components/dashboard/shared";
|
||||||
|
|
||||||
// Brighter chart colors for dark glass backgrounds
|
const formatCompactCurrency = (value) => {
|
||||||
const MINI_CHART_COLORS = {
|
if (!value || isNaN(value)) return "$0";
|
||||||
revenue: "#34d399", // emerald-400
|
if (value >= 1_000_000) return `$${(value / 1_000_000).toFixed(1)}M`;
|
||||||
orders: "#60a5fa", // blue-400
|
if (value >= 1_000) return `$${(value / 1_000).toFixed(1)}k`;
|
||||||
|
return `$${Math.round(value)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatFullCurrency = (value) => {
|
||||||
|
if (!value || isNaN(value)) return "$0";
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const MiniSalesChart = ({ className = "" }) => {
|
const MiniSalesChart = ({ className = "" }) => {
|
||||||
const [data, setData] = useState([]);
|
const { data: revenueStats, isLoading: statsLoading } = useQuery({
|
||||||
const [loading, setLoading] = useState(true);
|
queryKey: ["mini-sales-stats-30d"],
|
||||||
const [error, setError] = useState(null);
|
queryFn: async () => {
|
||||||
const [visibleMetrics, setVisibleMetrics] = useState({
|
|
||||||
revenue: true,
|
|
||||||
orders: true
|
|
||||||
});
|
|
||||||
const [summaryStats, setSummaryStats] = useState({
|
|
||||||
totalRevenue: 0,
|
|
||||||
totalOrders: 0,
|
|
||||||
prevRevenue: 0,
|
|
||||||
prevOrders: 0,
|
|
||||||
periodProgress: 100
|
|
||||||
});
|
|
||||||
const [projection, setProjection] = useState(null);
|
|
||||||
const [projectionLoading, setProjectionLoading] = useState(false);
|
|
||||||
|
|
||||||
const fetchProjection = useCallback(async () => {
|
|
||||||
if (summaryStats.periodProgress >= 100) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setProjectionLoading(true);
|
|
||||||
const response = await acotService.getProjection({ timeRange: "last30days" });
|
|
||||||
setProjection(response);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error loading projection:", error);
|
|
||||||
} finally {
|
|
||||||
setProjectionLoading(false);
|
|
||||||
}
|
|
||||||
}, [summaryStats.periodProgress]);
|
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const response = await acotService.getStatsDetails({
|
const response = await acotService.getStatsDetails({
|
||||||
timeRange: "last30days",
|
timeRange: "last30days",
|
||||||
metric: "revenue",
|
metric: "revenue",
|
||||||
daily: true,
|
daily: true,
|
||||||
});
|
});
|
||||||
|
if (!response.stats) throw new Error("Invalid response format");
|
||||||
|
const stats = Array.isArray(response.stats) ? response.stats : [];
|
||||||
|
return stats.reduce(
|
||||||
|
(acc, day) => ({
|
||||||
|
totalRevenue: acc.totalRevenue + (Number(day.revenue) || 0),
|
||||||
|
totalOrders: acc.totalOrders + (Number(day.orders) || 0),
|
||||||
|
prevRevenue: acc.prevRevenue + (Number(day.prevRevenue) || 0),
|
||||||
|
prevOrders: acc.prevOrders + (Number(day.prevOrders) || 0),
|
||||||
|
periodProgress: day.periodProgress || 100,
|
||||||
|
}),
|
||||||
|
{ totalRevenue: 0, totalOrders: 0, prevRevenue: 0, prevOrders: 0, periodProgress: 100 }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
refetchInterval: 300000,
|
||||||
|
});
|
||||||
|
|
||||||
if (!response.stats) {
|
const summaryStats = revenueStats ?? {
|
||||||
throw new Error("Invalid response format");
|
totalRevenue: 0, totalOrders: 0, prevRevenue: 0, prevOrders: 0, periodProgress: 100,
|
||||||
}
|
};
|
||||||
|
|
||||||
const stats = Array.isArray(response.stats)
|
const { data: projection } = useQuery({
|
||||||
? response.stats
|
queryKey: ["mini-sales-projection-30d"],
|
||||||
: [];
|
queryFn: () => acotService.getProjection({ timeRange: "last30days" }),
|
||||||
|
enabled: summaryStats.periodProgress < 100,
|
||||||
|
refetchInterval: 300000,
|
||||||
|
});
|
||||||
|
|
||||||
const processedData = processData(stats);
|
const { data: chartData, isLoading: chartLoading, error } = useQuery({
|
||||||
|
queryKey: ["mini-sales-chart-30d"],
|
||||||
// Calculate totals and growth
|
queryFn: async () => {
|
||||||
const totals = stats.reduce((acc, day) => ({
|
const now = new Date();
|
||||||
totalRevenue: acc.totalRevenue + (Number(day.revenue) || 0),
|
const thirtyDaysAgo = new Date(now);
|
||||||
totalOrders: acc.totalOrders + (Number(day.orders) || 0),
|
thirtyDaysAgo.setDate(now.getDate() - 30);
|
||||||
prevRevenue: acc.prevRevenue + (Number(day.prevRevenue) || 0),
|
const params = new URLSearchParams({
|
||||||
prevOrders: acc.prevOrders + (Number(day.prevOrders) || 0),
|
startDate: thirtyDaysAgo.toISOString(),
|
||||||
periodProgress: day.periodProgress || 100,
|
endDate: now.toISOString(),
|
||||||
}), {
|
|
||||||
totalRevenue: 0,
|
|
||||||
totalOrders: 0,
|
|
||||||
prevRevenue: 0,
|
|
||||||
prevOrders: 0,
|
|
||||||
periodProgress: 100
|
|
||||||
});
|
});
|
||||||
|
const response = await fetch(`${config.apiUrl}/dashboard/sales/metrics?${params}`);
|
||||||
setData(processedData);
|
if (!response.ok) throw new Error("Failed to fetch sales metrics");
|
||||||
setSummaryStats(totals);
|
return response.json();
|
||||||
setError(null);
|
},
|
||||||
|
refetchInterval: 300000,
|
||||||
// Fetch projection if needed
|
});
|
||||||
if (totals.periodProgress < 100) {
|
|
||||||
fetchProjection();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching data:", error);
|
|
||||||
setError(error.message);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [fetchProjection]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchData();
|
|
||||||
const intervalId = setInterval(fetchData, 300000);
|
|
||||||
return () => clearInterval(intervalId);
|
|
||||||
}, [fetchData]);
|
|
||||||
|
|
||||||
const formatXAxis = (value) => {
|
const formatXAxis = (value) => {
|
||||||
if (!value) return "";
|
if (!value) return "";
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
return date.toLocaleDateString([], {
|
return date.toLocaleDateString([], { month: "numeric", day: "numeric" });
|
||||||
month: "short",
|
|
||||||
day: "numeric"
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleMetric = (metric) => {
|
|
||||||
setVisibleMetrics(prev => ({
|
|
||||||
...prev,
|
|
||||||
[metric]: !prev[metric]
|
|
||||||
}));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -141,16 +103,16 @@ const MiniSalesChart = ({ className = "" }) => {
|
|||||||
<Alert variant="destructive" className="bg-white/10 backdrop-blur-sm">
|
<Alert variant="destructive" className="bg-white/10 backdrop-blur-sm">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
<AlertTitle>Error</AlertTitle>
|
<AlertTitle>Error</AlertTitle>
|
||||||
<AlertDescription>Failed to load sales data: {error}</AlertDescription>
|
<AlertDescription>Failed to load sales data: {error.message}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to calculate trend values (positive = up, negative = down)
|
|
||||||
const getRevenueTrendValue = () => {
|
const getRevenueTrendValue = () => {
|
||||||
const current = summaryStats.periodProgress < 100
|
const current =
|
||||||
? (projection?.projectedRevenue || summaryStats.totalRevenue)
|
summaryStats.periodProgress < 100
|
||||||
: summaryStats.totalRevenue;
|
? projection?.projectedRevenue || summaryStats.totalRevenue
|
||||||
|
: summaryStats.totalRevenue;
|
||||||
if (!summaryStats.prevRevenue) return 0;
|
if (!summaryStats.prevRevenue) return 0;
|
||||||
return ((current - summaryStats.prevRevenue) / summaryStats.prevRevenue) * 100;
|
return ((current - summaryStats.prevRevenue) / summaryStats.prevRevenue) * 100;
|
||||||
};
|
};
|
||||||
@@ -162,30 +124,18 @@ const MiniSalesChart = ({ className = "" }) => {
|
|||||||
return ((current - summaryStats.prevOrders) / summaryStats.prevOrders) * 100;
|
return ((current - summaryStats.prevOrders) / summaryStats.prevOrders) * 100;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading && !data) {
|
const dailyPhase = chartData?.dailySalesByPhase || [];
|
||||||
return (
|
const phaseBreakdown = chartData?.phaseBreakdown || [];
|
||||||
<div className="space-y-2">
|
|
||||||
{/* Stat Cards */}
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<DashboardStatCardMiniSkeleton gradient="slate" />
|
|
||||||
<DashboardStatCardMiniSkeleton gradient="slate" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Chart Card */}
|
const activePhases = phaseBreakdown
|
||||||
<Card className="bg-gradient-to-br from-slate-800/70 to-slate-700/50 backdrop-blur-xl border-white/[0.08] ring-1 ring-white/[0.05] shadow-lg shadow-black/20">
|
.filter((p) => p.revenue > 0)
|
||||||
<CardContent className="p-4">
|
.sort((a, b) => b.revenue - a.revenue);
|
||||||
<ChartSkeleton height="sm" withCard={false} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{/* Stat Cards */}
|
{/* Stat Cards */}
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{loading && !data?.length ? (
|
{statsLoading ? (
|
||||||
<>
|
<>
|
||||||
<DashboardStatCardMiniSkeleton gradient="slate" />
|
<DashboardStatCardMiniSkeleton gradient="slate" />
|
||||||
<DashboardStatCardMiniSkeleton gradient="slate" />
|
<DashboardStatCardMiniSkeleton gradient="slate" />
|
||||||
@@ -200,19 +150,15 @@ const MiniSalesChart = ({ className = "" }) => {
|
|||||||
icon={PiggyBank}
|
icon={PiggyBank}
|
||||||
iconBackground="bg-emerald-400"
|
iconBackground="bg-emerald-400"
|
||||||
gradient="slate"
|
gradient="slate"
|
||||||
className={!visibleMetrics.revenue ? 'opacity-50' : ''}
|
|
||||||
onClick={() => toggleMetric('revenue')}
|
|
||||||
/>
|
/>
|
||||||
<DashboardStatCardMini
|
<DashboardStatCardMini
|
||||||
title="30 Days Orders"
|
title="30 Days Orders"
|
||||||
value={summaryStats.totalOrders.toLocaleString()}
|
value={summaryStats.totalOrders.toLocaleString()}
|
||||||
subtitle={`Prev: ${summaryStats.prevOrders.toLocaleString()}`}
|
subtitle={`Prev: ${summaryStats.prevOrders.toLocaleString()}`}
|
||||||
trend={{ value: getOrdersTrendValue() }}
|
trend={{ value: getOrdersTrendValue() }}
|
||||||
icon={Truck}
|
icon={ShoppingCart}
|
||||||
iconBackground="bg-blue-400"
|
iconBackground="bg-blue-400"
|
||||||
gradient="slate"
|
gradient="slate"
|
||||||
className={!visibleMetrics.orders ? 'opacity-50' : ''}
|
|
||||||
onClick={() => toggleMetric('orders')}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -221,100 +167,155 @@ const MiniSalesChart = ({ className = "" }) => {
|
|||||||
{/* Chart Card */}
|
{/* Chart Card */}
|
||||||
<Card className="bg-gradient-to-br from-slate-800/70 to-slate-700/50 backdrop-blur-xl border-white/[0.08] ring-1 ring-white/[0.05] shadow-lg shadow-black/20">
|
<Card className="bg-gradient-to-br from-slate-800/70 to-slate-700/50 backdrop-blur-xl border-white/[0.08] ring-1 ring-white/[0.05] shadow-lg shadow-black/20">
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="h-[216px]">
|
{chartLoading ? (
|
||||||
{loading && !data?.length ? (
|
<>
|
||||||
<ChartSkeleton height="sm" withCard={false} />
|
<div className="h-[180px]">
|
||||||
) : (
|
<ChartSkeleton type="area" height="sm" withCard={false} />
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
</div>
|
||||||
<LineChart
|
<div className="mt-2 space-y-1.5">
|
||||||
data={data}
|
<div className="h-2 rounded-full bg-white/[0.06] animate-pulse" />
|
||||||
margin={{ top: 0, right: -30, left: -5, bottom: -10 }}
|
<div className="flex justify-center gap-x-3 gap-y-0.5">
|
||||||
>
|
{[48, 40, 56, 52, 44].map((w, i) => (
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.15)" />
|
<div key={i} className="flex items-center gap-1">
|
||||||
<XAxis
|
<div className="h-2 w-2 rounded-sm bg-white/[0.08] animate-pulse" />
|
||||||
dataKey="timestamp"
|
<div className="h-2.5 rounded bg-white/[0.06] animate-pulse" style={{ width: w }} />
|
||||||
tickFormatter={formatXAxis}
|
</div>
|
||||||
className="text-xs"
|
))}
|
||||||
tick={{ fill: "#e2e8f0" }}
|
</div>
|
||||||
/>
|
</div>
|
||||||
{visibleMetrics.revenue && (
|
</>
|
||||||
<YAxis
|
) : (
|
||||||
yAxisId="revenue"
|
<>
|
||||||
tickFormatter={(value) => formatCurrency(value, false)}
|
<div className="h-[180px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart
|
||||||
|
data={dailyPhase}
|
||||||
|
margin={{ top: 0, right: 0, left: -5, bottom: -10 }}
|
||||||
|
>
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tickFormatter={formatXAxis}
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
tick={{ fill: "#e2e8f0" }}
|
tick={{ fill: "#e2e8f0" }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
{visibleMetrics.orders && (
|
|
||||||
<YAxis
|
<YAxis
|
||||||
yAxisId="orders"
|
tickFormatter={formatCompactCurrency}
|
||||||
orientation="right"
|
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
tick={{ fill: "#e2e8f0" }}
|
tick={{ fill: "#e2e8f0" }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
/>
|
/>
|
||||||
)}
|
<Tooltip
|
||||||
<Tooltip
|
content={({ active, payload }) => {
|
||||||
content={({ active, payload }) => {
|
if (!active || !payload?.length) return null;
|
||||||
if (active && payload && payload.length) {
|
const dateStr = payload[0]?.payload?.date;
|
||||||
const timestamp = new Date(payload[0].payload.timestamp);
|
const date = dateStr ? new Date(dateStr) : null;
|
||||||
const styles = TOOLTIP_THEMES.stone;
|
const styles = TOOLTIP_THEMES.stone;
|
||||||
|
const items = payload
|
||||||
|
.filter((entry) => entry.value > 0)
|
||||||
|
.sort((a, b) => b.value - a.value);
|
||||||
|
const total = items.reduce((sum, entry) => sum + (entry.value || 0), 0);
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<p className={styles.header}>
|
{date && (
|
||||||
{timestamp.toLocaleDateString([], {
|
<p className={styles.header}>
|
||||||
weekday: "short",
|
{date.toLocaleDateString([], {
|
||||||
month: "short",
|
weekday: "short",
|
||||||
day: "numeric"
|
month: "short",
|
||||||
})}
|
day: "numeric",
|
||||||
</p>
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
{payload
|
{items.map((entry, index) => {
|
||||||
.filter(entry => visibleMetrics[entry.dataKey])
|
const cfg = PHASE_CONFIG[entry.dataKey] || {};
|
||||||
.map((entry, index) => (
|
return (
|
||||||
<div key={index} className={styles.row}>
|
<div key={index} className={styles.row}>
|
||||||
<span className={styles.name}>
|
<span className="flex items-center gap-1.5">
|
||||||
{entry.name}
|
<span
|
||||||
|
className="inline-block h-2 w-2 rounded-full shrink-0"
|
||||||
|
style={{ backgroundColor: entry.color }}
|
||||||
|
/>
|
||||||
|
<span className={styles.name}>
|
||||||
|
{cfg.label || entry.name}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className={styles.value}>
|
<span className={styles.value}>
|
||||||
{entry.dataKey === 'revenue'
|
{formatFullCurrency(entry.value)}
|
||||||
? formatCurrency(entry.value)
|
|
||||||
: entry.value.toLocaleString()}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
{items.length > 1 && (
|
||||||
|
<div className={`${styles.row} border-t border-white/10 pt-1 mt-1`}>
|
||||||
|
<span className={styles.name}>Total</span>
|
||||||
|
<span className={styles.value}>
|
||||||
|
{formatFullCurrency(total)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}}
|
||||||
return null;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{visibleMetrics.revenue && (
|
|
||||||
<Line
|
|
||||||
yAxisId="revenue"
|
|
||||||
type="monotone"
|
|
||||||
dataKey="revenue"
|
|
||||||
name="Revenue"
|
|
||||||
stroke={MINI_CHART_COLORS.revenue}
|
|
||||||
strokeWidth={2.5}
|
|
||||||
dot={false}
|
|
||||||
/>
|
/>
|
||||||
)}
|
{PHASE_KEYS.map((phase) => {
|
||||||
{visibleMetrics.orders && (
|
const cfg = PHASE_CONFIG[phase];
|
||||||
<Line
|
return (
|
||||||
yAxisId="orders"
|
<Area
|
||||||
type="monotone"
|
key={phase}
|
||||||
dataKey="orders"
|
type="monotone"
|
||||||
name="Orders"
|
dataKey={phase}
|
||||||
stroke={MINI_CHART_COLORS.orders}
|
name={phase}
|
||||||
strokeWidth={2.5}
|
stackId="a"
|
||||||
dot={false}
|
stroke={cfg.color}
|
||||||
/>
|
fill={cfg.color}
|
||||||
)}
|
fillOpacity={0.7}
|
||||||
</LineChart>
|
/>
|
||||||
</ResponsiveContainer>
|
);
|
||||||
)}
|
})}
|
||||||
</div>
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Phase breakdown bar + legend */}
|
||||||
|
{activePhases.length > 0 && (
|
||||||
|
<div className="mt-2 space-y-1.5">
|
||||||
|
{(() => {
|
||||||
|
let pos = 0;
|
||||||
|
const stops = activePhases.flatMap((p) => {
|
||||||
|
const cfg = PHASE_CONFIG[p.phase] || { color: "#94A3B8" };
|
||||||
|
const start = pos;
|
||||||
|
pos += p.percentage;
|
||||||
|
return [`${cfg.color} ${start}%`, `${cfg.color} ${pos}%`];
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="h-2 rounded-full ring-1 ring-white/20"
|
||||||
|
style={{ background: `linear-gradient(to right, ${stops.join(', ')})` }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
<div className="flex justify-center gap-x-3">
|
||||||
|
{activePhases.slice(0, 5).map((p) => {
|
||||||
|
const cfg = PHASE_CONFIG[p.phase] || { label: p.phase, color: "#94A3B8" };
|
||||||
|
return (
|
||||||
|
<span key={p.phase} className="flex items-center gap-1 text-[11.5px] text-slate-200/80 whitespace-nowrap">
|
||||||
|
<span
|
||||||
|
className="inline-block h-2 w-2 rounded-sm shrink-0"
|
||||||
|
style={{ backgroundColor: cfg.color }}
|
||||||
|
/>
|
||||||
|
{cfg.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,26 +1,19 @@
|
|||||||
import React, { useState, useEffect, useCallback } from "react";
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { acotService } from "@/services/dashboard/acotService";
|
import { acotService } from "@/services/dashboard/acotService";
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { DateTime } from "luxon";
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|
||||||
import {
|
import {
|
||||||
DollarSign,
|
DollarSign,
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
Package,
|
|
||||||
AlertCircle,
|
|
||||||
CircleDollarSign,
|
CircleDollarSign,
|
||||||
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { processBasicData } from "./RealtimeAnalytics";
|
||||||
|
|
||||||
// Import the detail view components and utilities from StatCards
|
// Import the detail view components and utilities from StatCards
|
||||||
import {
|
import {
|
||||||
@@ -58,16 +51,45 @@ const MiniStatCards = ({
|
|||||||
description = "",
|
description = "",
|
||||||
compact = false,
|
compact = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [stats, setStats] = useState(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
const [lastUpdate, setLastUpdate] = useState(null);
|
|
||||||
const [timeRange, setTimeRange] = useState(initialTimeRange);
|
const [timeRange, setTimeRange] = useState(initialTimeRange);
|
||||||
const [selectedMetric, setSelectedMetric] = useState(null);
|
const [selectedMetric, setSelectedMetric] = useState(null);
|
||||||
const [detailDataLoading, setDetailDataLoading] = useState({});
|
const [detailDataLoading, setDetailDataLoading] = useState({});
|
||||||
const [detailData, setDetailData] = useState({});
|
const [detailData, setDetailData] = useState({});
|
||||||
const [projection, setProjection] = useState(null);
|
|
||||||
const [projectionLoading, setProjectionLoading] = useState(false);
|
// Main stats query
|
||||||
|
const statsParams = timeRange === "custom" ? { startDate, endDate } : { timeRange };
|
||||||
|
const { data: stats, isLoading: loading, error: statsError } = useQuery({
|
||||||
|
queryKey: ["mini-stat-cards", timeRange, startDate, endDate],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await acotService.getStats(statsParams);
|
||||||
|
return response.stats;
|
||||||
|
},
|
||||||
|
refetchInterval: timeRange === "today" ? 60000 : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Projection query (depends on stats)
|
||||||
|
const { data: projection, isLoading: projectionLoading } = useQuery({
|
||||||
|
queryKey: ["mini-stat-projection", timeRange, startDate, endDate],
|
||||||
|
queryFn: () => acotService.getProjection(statsParams),
|
||||||
|
enabled: stats?.periodProgress != null && stats.periodProgress < 100,
|
||||||
|
refetchInterval: timeRange === "today" ? 60000 : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Realtime users query
|
||||||
|
const { data: realtimeData = { last30MinUsers: 0, last5MinUsers: 0 }, isLoading: realtimeLoading } = useQuery({
|
||||||
|
queryKey: ["mini-realtime-users"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await fetch("/api/dashboard-analytics/realtime/basic", {
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error("Failed to fetch realtime");
|
||||||
|
const result = await response.json();
|
||||||
|
return processBasicData(result.data);
|
||||||
|
},
|
||||||
|
refetchInterval: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const error = statsError?.message ?? null;
|
||||||
|
|
||||||
// Reuse the trend calculation functions
|
// Reuse the trend calculation functions
|
||||||
const calculateTrend = useCallback((current, previous) => {
|
const calculateTrend = useCallback((current, previous) => {
|
||||||
@@ -125,93 +147,6 @@ const MiniStatCards = ({
|
|||||||
return calculateTrend(stats.averageOrderValue, stats.prevPeriodAOV);
|
return calculateTrend(stats.averageOrderValue, stats.prevPeriodAOV);
|
||||||
}, [stats, calculateTrend]);
|
}, [stats, calculateTrend]);
|
||||||
|
|
||||||
// Initial load effect
|
|
||||||
useEffect(() => {
|
|
||||||
let isMounted = true;
|
|
||||||
|
|
||||||
const loadData = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setStats(null);
|
|
||||||
|
|
||||||
const params =
|
|
||||||
timeRange === "custom" ? { startDate, endDate } : { timeRange };
|
|
||||||
const response = await acotService.getStats(params);
|
|
||||||
|
|
||||||
if (!isMounted) return;
|
|
||||||
|
|
||||||
setStats(response.stats);
|
|
||||||
setLastUpdate(DateTime.now().setZone("America/New_York"));
|
|
||||||
setError(null);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error loading data:", error);
|
|
||||||
if (isMounted) {
|
|
||||||
setError(error.message);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (isMounted) {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadData();
|
|
||||||
return () => {
|
|
||||||
isMounted = false;
|
|
||||||
};
|
|
||||||
}, [timeRange, startDate, endDate]);
|
|
||||||
|
|
||||||
// Load smart projection separately
|
|
||||||
useEffect(() => {
|
|
||||||
let isMounted = true;
|
|
||||||
|
|
||||||
const loadProjection = async () => {
|
|
||||||
if (!stats?.periodProgress || stats.periodProgress >= 100) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setProjectionLoading(true);
|
|
||||||
const params =
|
|
||||||
timeRange === "custom" ? { startDate, endDate } : { timeRange };
|
|
||||||
const response = await acotService.getProjection(params);
|
|
||||||
|
|
||||||
if (!isMounted) return;
|
|
||||||
setProjection(response);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error loading projection:", error);
|
|
||||||
} finally {
|
|
||||||
if (isMounted) {
|
|
||||||
setProjectionLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadProjection();
|
|
||||||
return () => {
|
|
||||||
isMounted = false;
|
|
||||||
};
|
|
||||||
}, [timeRange, startDate, endDate, stats?.periodProgress]);
|
|
||||||
|
|
||||||
// Auto-refresh for 'today' view
|
|
||||||
useEffect(() => {
|
|
||||||
if (timeRange !== "today") return;
|
|
||||||
|
|
||||||
const interval = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
const [statsResponse, projectionResponse] = await Promise.all([
|
|
||||||
acotService.getStats({ timeRange: "today" }),
|
|
||||||
acotService.getProjection({ timeRange: "today" }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
setStats(statsResponse.stats);
|
|
||||||
setProjection(projectionResponse);
|
|
||||||
setLastUpdate(DateTime.now().setZone("America/New_York"));
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error auto-refreshing stats:", error);
|
|
||||||
}
|
|
||||||
}, 60000);
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [timeRange]);
|
|
||||||
|
|
||||||
// Add function to fetch detail data
|
// Add function to fetch detail data
|
||||||
const fetchDetailData = useCallback(
|
const fetchDetailData = useCallback(
|
||||||
@@ -262,13 +197,13 @@ const MiniStatCards = ({
|
|||||||
preloadData();
|
preloadData();
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
if (loading && !stats) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-4 gap-2">
|
<div className="grid grid-cols-4 gap-2">
|
||||||
<DashboardStatCardMiniSkeleton gradient="emerald" className="h-[150px]" />
|
<DashboardStatCardMiniSkeleton gradient="emerald" className="h-[150px]" />
|
||||||
<DashboardStatCardMiniSkeleton gradient="blue" className="h-[150px]" />
|
<DashboardStatCardMiniSkeleton gradient="blue" className="h-[150px]" />
|
||||||
<DashboardStatCardMiniSkeleton gradient="violet" className="h-[150px]" />
|
<DashboardStatCardMiniSkeleton gradient="violet" className="h-[150px]" />
|
||||||
<DashboardStatCardMiniSkeleton gradient="orange" className="h-[150px]" />
|
<DashboardStatCardMiniSkeleton gradient="sky" className="h-[150px]" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -342,14 +277,18 @@ const MiniStatCards = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<DashboardStatCardMini
|
<DashboardStatCardMini
|
||||||
title="Shipped Today"
|
title="Live Users 5 Min"
|
||||||
value={stats?.shipping?.shippedCount || 0}
|
value={realtimeLoading ? "..." : realtimeData.last5MinUsers}
|
||||||
subtitle={`${stats?.shipping?.locations?.total || 0} locations`}
|
subtitle={
|
||||||
icon={Package}
|
realtimeLoading
|
||||||
iconBackground="bg-orange-400"
|
? "Loading..."
|
||||||
gradient="orange"
|
: `${realtimeData.last30MinUsers} last 30 minutes`
|
||||||
|
}
|
||||||
|
icon={Users}
|
||||||
|
iconBackground="bg-sky-400"
|
||||||
|
gradient="sky"
|
||||||
className="h-[150px]"
|
className="h-[150px]"
|
||||||
onClick={() => setSelectedMetric("shipping")}
|
loading={realtimeLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
/**
|
||||||
|
* DashboardMultiStatCardMini
|
||||||
|
*
|
||||||
|
* A compact card that displays multiple stats vertically, styled to match
|
||||||
|
* DashboardStatCardMini's typography and layout. Use for panels like
|
||||||
|
* Operations Today or Inventory Snapshot where several related metrics
|
||||||
|
* live inside a single gradient card.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { type GradientVariant } from "./DashboardStatCardMini";
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// TYPES
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface MultiStatEntry {
|
||||||
|
/** Lucide icon component */
|
||||||
|
icon: LucideIcon;
|
||||||
|
/** Tailwind bg class for the icon circle (e.g. "bg-emerald-400") */
|
||||||
|
iconBg: string;
|
||||||
|
/** Short uppercase label */
|
||||||
|
label: string;
|
||||||
|
/** Main display value */
|
||||||
|
value: string | number;
|
||||||
|
/** Optional secondary line below value (string or JSX for trend indicators) */
|
||||||
|
sub?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardMultiStatCardMiniProps {
|
||||||
|
/** Optional card title shown above stats */
|
||||||
|
title?: string;
|
||||||
|
/** Array of stats to display vertically */
|
||||||
|
entries: MultiStatEntry[];
|
||||||
|
/** Gradient preset */
|
||||||
|
gradient?: GradientVariant;
|
||||||
|
/** Additional className */
|
||||||
|
className?: string;
|
||||||
|
/** Loading state — shows skeletons for this many rows */
|
||||||
|
loading?: boolean;
|
||||||
|
/** Number of skeleton rows to show when loading (defaults to entries length or 3) */
|
||||||
|
skeletonRows?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// GRADIENT PRESETS (mirrors DashboardStatCardMini)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const GRADIENT_PRESETS: Record<GradientVariant, string> = {
|
||||||
|
slate: "bg-gradient-to-br from-slate-800/80 to-slate-700/60",
|
||||||
|
emerald: "bg-gradient-to-br from-emerald-900/80 to-emerald-700/40",
|
||||||
|
blue: "bg-gradient-to-br from-blue-900/80 to-blue-700/40",
|
||||||
|
purple: "bg-gradient-to-br from-purple-900/80 to-purple-700/40",
|
||||||
|
violet: "bg-gradient-to-br from-violet-900/80 to-violet-700/40",
|
||||||
|
amber: "bg-gradient-to-br from-amber-800/80 to-amber-600/40",
|
||||||
|
orange: "bg-gradient-to-br from-orange-900/80 to-orange-700/40",
|
||||||
|
rose: "bg-gradient-to-br from-rose-900/80 to-rose-700/40",
|
||||||
|
cyan: "bg-gradient-to-br from-cyan-900/80 to-cyan-700/40",
|
||||||
|
sky: "bg-gradient-to-br from-sky-900/80 to-sky-700/40",
|
||||||
|
custom: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// INTERNAL COMPONENTS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const StatRow: React.FC<MultiStatEntry> = ({ icon: Icon, iconBg, label, value, sub }) => (
|
||||||
|
<div className="flex-1 flex items-center justify-between min-h-0">
|
||||||
|
<div className="flex flex-col justify-center">
|
||||||
|
<span className="text-xs font-medium text-gray-100 uppercase tracking-wide mb-0.5">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<div className="text-2xl font-extrabold text-white leading-tight">
|
||||||
|
{typeof value === "number" ? value.toLocaleString() : value}
|
||||||
|
</div>
|
||||||
|
{sub && (
|
||||||
|
<div className="text-sm font-semibold text-gray-200 mt-0.5">{sub}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="relative p-2">
|
||||||
|
<div className={cn("absolute inset-0 rounded-full", iconBg)} />
|
||||||
|
<Icon className="h-4 w-4 text-white relative" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SkeletonRow: React.FC = () => (
|
||||||
|
<div className="flex-1 flex items-center justify-between min-h-0">
|
||||||
|
<div className="flex flex-col justify-center">
|
||||||
|
<div className="h-3 w-16 bg-white/20 animate-pulse rounded mb-1" />
|
||||||
|
<div className="h-7 w-14 bg-white/20 animate-pulse rounded mb-0.5" />
|
||||||
|
<div className="h-4 w-20 bg-white/10 animate-pulse rounded" />
|
||||||
|
</div>
|
||||||
|
<div className="h-8 w-8 bg-white/20 animate-pulse rounded-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// MAIN COMPONENT
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const DashboardMultiStatCardMini: React.FC<DashboardMultiStatCardMiniProps> = ({
|
||||||
|
title,
|
||||||
|
entries,
|
||||||
|
gradient = "slate",
|
||||||
|
className,
|
||||||
|
loading = false,
|
||||||
|
skeletonRows,
|
||||||
|
}) => {
|
||||||
|
const gradientClass = gradient === "custom" ? "" : GRADIENT_PRESETS[gradient];
|
||||||
|
const rowCount = skeletonRows ?? (entries.length || 3);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
gradientClass,
|
||||||
|
"backdrop-blur-xl border-white/[0.08] ring-1 ring-white/[0.05] shadow-lg shadow-black/20 h-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CardContent className="p-4 h-full flex flex-col gap-1">
|
||||||
|
{title && (
|
||||||
|
<span className="text-xs font-medium text-gray-100 uppercase tracking-wide mb-1 border-b">
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
{Array.from({ length: rowCount }, (_, i) => (
|
||||||
|
<SkeletonRow key={i} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
entries.map((entry, i) => <StatRow key={i} {...entry} />)
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DashboardMultiStatCardMini;
|
||||||
@@ -244,7 +244,7 @@ export const DashboardStatCardMini: React.FC<DashboardStatCardMiniProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{(subtitle || trend) && (
|
{(subtitle || trend) && (
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 mt-3">
|
<div className="flex flex-wrap items-center justify-between gap-0 mt-3">
|
||||||
{subtitle && (
|
{subtitle && (
|
||||||
<span className="text-sm font-semibold text-gray-200">
|
<span className="text-sm font-semibold text-gray-200">
|
||||||
{subtitle}
|
{subtitle}
|
||||||
|
|||||||
@@ -99,6 +99,12 @@ export {
|
|||||||
type GradientVariant,
|
type GradientVariant,
|
||||||
} from "./DashboardStatCardMini";
|
} from "./DashboardStatCardMini";
|
||||||
|
|
||||||
|
export {
|
||||||
|
DashboardMultiStatCardMini,
|
||||||
|
type DashboardMultiStatCardMiniProps,
|
||||||
|
type MultiStatEntry,
|
||||||
|
} from "./DashboardMultiStatCardMini";
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// CHART TOOLTIPS
|
// CHART TOOLTIPS
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -192,11 +192,13 @@ export function ProductEditForm({
|
|||||||
product,
|
product,
|
||||||
fieldOptions,
|
fieldOptions,
|
||||||
layoutMode,
|
layoutMode,
|
||||||
|
initialImages,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
product: SearchProduct;
|
product: SearchProduct;
|
||||||
fieldOptions: FieldOptions;
|
fieldOptions: FieldOptions;
|
||||||
layoutMode: LayoutMode;
|
layoutMode: LayoutMode;
|
||||||
|
initialImages?: ProductImage[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [lineOptions, setLineOptions] = useState<LineOption[]>([]);
|
const [lineOptions, setLineOptions] = useState<LineOption[]>([]);
|
||||||
@@ -260,19 +262,24 @@ export function ProductEditForm({
|
|||||||
originalValuesRef.current = { ...formValues };
|
originalValuesRef.current = { ...formValues };
|
||||||
reset(formValues);
|
reset(formValues);
|
||||||
|
|
||||||
// Fetch images and categories with abort support
|
// Fetch categories (and images if not pre-fetched) with abort support
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const { signal } = controller;
|
const { signal } = controller;
|
||||||
|
|
||||||
setIsLoadingImages(true);
|
if (initialImages) {
|
||||||
axios
|
setProductImages(initialImages);
|
||||||
.get(`/api/import/product-images/${product.pid}`, { signal })
|
originalImagesRef.current = initialImages;
|
||||||
.then((res) => {
|
} else {
|
||||||
setProductImages(res.data);
|
setIsLoadingImages(true);
|
||||||
originalImagesRef.current = res.data;
|
axios
|
||||||
})
|
.get(`/api/import/product-images/${product.pid}`, { signal })
|
||||||
.catch((e) => { if (!axios.isCancel(e)) toast.error("Failed to load product images"); })
|
.then((res) => {
|
||||||
.finally(() => setIsLoadingImages(false));
|
setProductImages(res.data);
|
||||||
|
originalImagesRef.current = res.data;
|
||||||
|
})
|
||||||
|
.catch((e) => { if (!axios.isCancel(e)) toast.error("Failed to load product images"); })
|
||||||
|
.finally(() => setIsLoadingImages(false));
|
||||||
|
}
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.get(`/api/import/product-categories/${product.pid}`, { signal })
|
.get(`/api/import/product-categories/${product.pid}`, { signal })
|
||||||
@@ -299,6 +306,14 @@ export function ProductEditForm({
|
|||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [product, reset]);
|
}, [product, reset]);
|
||||||
|
|
||||||
|
// Apply batch-fetched images when they arrive after mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialImages && productImages.length === 0 && !isLoadingImages) {
|
||||||
|
setProductImages(initialImages);
|
||||||
|
originalImagesRef.current = initialImages;
|
||||||
|
}
|
||||||
|
}, [initialImages]);
|
||||||
|
|
||||||
// Load lines when company changes (cached across forms)
|
// Load lines when company changes (cached across forms)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!watchCompany) {
|
if (!watchCompany) {
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export interface SearchProduct {
|
|||||||
tax_code?: string;
|
tax_code?: string;
|
||||||
size_cat?: string;
|
size_cat?: string;
|
||||||
shipping_restrictions?: string;
|
shipping_restrictions?: string;
|
||||||
|
is_new?: number;
|
||||||
|
is_preorder?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FieldOption {
|
export interface FieldOption {
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ export const GenericDropzone = ({
|
|||||||
}: GenericDropzoneProps) => {
|
}: GenericDropzoneProps) => {
|
||||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||||
accept: {
|
accept: {
|
||||||
'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp']
|
'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp', '.tif', '.tiff']
|
||||||
},
|
},
|
||||||
onDrop,
|
onDrop,
|
||||||
multiple: true
|
multiple: true
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ interface ImageDropzoneProps {
|
|||||||
export const ImageDropzone = ({ onDrop }: ImageDropzoneProps) => {
|
export const ImageDropzone = ({ onDrop }: ImageDropzoneProps) => {
|
||||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||||
accept: {
|
accept: {
|
||||||
'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp']
|
'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp', '.tif', '.tiff']
|
||||||
},
|
},
|
||||||
onDrop: (acceptedFiles) => {
|
onDrop: (acceptedFiles) => {
|
||||||
onDrop(acceptedFiles);
|
onDrop(acceptedFiles);
|
||||||
|
|||||||
@@ -776,7 +776,7 @@ export default function BulkEdit() {
|
|||||||
{isLoadingProducts && (
|
{isLoadingProducts && (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
Loading new products...
|
Loading products...
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -792,7 +792,7 @@ export default function BulkEdit() {
|
|||||||
{isLoadingProducts && (
|
{isLoadingProducts && (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
Loading pre-order products...
|
Loading products...
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -801,7 +801,7 @@ export default function BulkEdit() {
|
|||||||
{isLoadingProducts && (
|
{isLoadingProducts && (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
Loading hidden recently-created products...
|
Loading products...
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { Separator } from "@/components/ui/separator";
|
|||||||
import type { Data, DataValue, FieldType, Result, SubmitOptions } from "@/components/product-import/types";
|
import type { Data, DataValue, FieldType, Result, SubmitOptions } from "@/components/product-import/types";
|
||||||
import { BASE_IMPORT_FIELDS, type ImportFieldKey } from "@/components/product-import/config";
|
import { BASE_IMPORT_FIELDS, type ImportFieldKey } from "@/components/product-import/config";
|
||||||
import { submitNewProducts, type SubmitNewProductsResponse } from "@/services/apiv2";
|
import { submitNewProducts, type SubmitNewProductsResponse } from "@/services/apiv2";
|
||||||
|
import { createImportAuditLog } from "@/services/importAuditLogApi";
|
||||||
import { AuthContext } from "@/contexts/AuthContext";
|
import { AuthContext } from "@/contexts/AuthContext";
|
||||||
import { TemplateForm } from "@/components/templates/TemplateForm";
|
import { TemplateForm } from "@/components/templates/TemplateForm";
|
||||||
|
|
||||||
@@ -521,9 +522,18 @@ export function Import() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleData = async (data: ImportResult, _file: File, submitOptions: SubmitOptions): Promise<boolean> => {
|
const handleData = async (data: ImportResult, _file: File, submitOptions: SubmitOptions): Promise<boolean> => {
|
||||||
|
// Hoist for audit logging in catch block
|
||||||
|
const targetEnvironment = submitOptions?.targetEnvironment ?? "prod";
|
||||||
|
const useTestDataSource = Boolean(submitOptions?.useTestDataSource);
|
||||||
|
const targetEndpoint = targetEnvironment === "dev"
|
||||||
|
? "/apiv2-test/product/setup_new"
|
||||||
|
: "/apiv2/product/setup_new";
|
||||||
|
let formattedRows: NormalizedProduct[] = [];
|
||||||
|
let startTime = performance.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const rows = ((data.all?.length ? data.all : data.validData) ?? []) as Data<string>[];
|
const rows = ((data.all?.length ? data.all : data.validData) ?? []) as Data<string>[];
|
||||||
const formattedRows: NormalizedProduct[] = rows.map((row) => {
|
formattedRows = rows.map((row) => {
|
||||||
const baseValues = importFields.reduce((acc, field) => {
|
const baseValues = importFields.reduce((acc, field) => {
|
||||||
const rawRow = row as Record<string, DataValue>;
|
const rawRow = row as Record<string, DataValue>;
|
||||||
const fieldKey = field.key as ImportFieldKey;
|
const fieldKey = field.key as ImportFieldKey;
|
||||||
@@ -582,12 +592,14 @@ export function Import() {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
startTime = performance.now();
|
||||||
const response = await submitNewProducts({
|
const response = await submitNewProducts({
|
||||||
products: formattedRows,
|
products: formattedRows,
|
||||||
environment: submitOptions?.targetEnvironment ?? "prod",
|
environment: targetEnvironment,
|
||||||
useTestDataSource: Boolean(submitOptions?.useTestDataSource),
|
useTestDataSource,
|
||||||
employeeId: user?.id ?? undefined,
|
employeeId: user?.id ?? undefined,
|
||||||
});
|
});
|
||||||
|
const durationMs = Math.round(performance.now() - startTime);
|
||||||
|
|
||||||
const isSuccess = response.success;
|
const isSuccess = response.success;
|
||||||
const defaultFailureMessage = "Failed to submit products. Please review and try again.";
|
const defaultFailureMessage = "Failed to submit products. Please review and try again.";
|
||||||
@@ -620,6 +632,24 @@ export function Import() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Audit log — fire-and-forget, never blocks the UI
|
||||||
|
const auditPayload = extractBackendPayload(normalizedResponse.data);
|
||||||
|
createImportAuditLog({
|
||||||
|
user_id: user?.id ?? 0,
|
||||||
|
username: user?.username,
|
||||||
|
product_count: formattedRows.length,
|
||||||
|
request_payload: formattedRows,
|
||||||
|
environment: targetEnvironment,
|
||||||
|
target_endpoint: targetEndpoint,
|
||||||
|
use_test_data_source: useTestDataSource,
|
||||||
|
success: isSuccess,
|
||||||
|
response_payload: normalizedResponse,
|
||||||
|
error_message: isSuccess ? undefined : (resolvedFailureMessage ?? defaultFailureMessage),
|
||||||
|
created_count: auditPayload.created.length,
|
||||||
|
errored_count: auditPayload.errored.length,
|
||||||
|
duration_ms: durationMs,
|
||||||
|
});
|
||||||
|
|
||||||
setResumeStepState(undefined);
|
setResumeStepState(undefined);
|
||||||
setImportOutcome({
|
setImportOutcome({
|
||||||
submittedProducts: formattedRows.map((product) => ({ ...product })),
|
submittedProducts: formattedRows.map((product) => ({ ...product })),
|
||||||
@@ -641,6 +671,20 @@ export function Import() {
|
|||||||
|
|
||||||
return isSuccess;
|
return isSuccess;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// Audit log for thrown errors (network failures, parse errors, etc.)
|
||||||
|
createImportAuditLog({
|
||||||
|
user_id: user?.id ?? 0,
|
||||||
|
username: user?.username,
|
||||||
|
product_count: formattedRows.length,
|
||||||
|
request_payload: formattedRows,
|
||||||
|
environment: targetEnvironment,
|
||||||
|
target_endpoint: targetEndpoint,
|
||||||
|
use_test_data_source: useTestDataSource,
|
||||||
|
success: false,
|
||||||
|
error_message: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
duration_ms: Math.round(performance.now() - startTime),
|
||||||
|
});
|
||||||
|
|
||||||
console.error("Import error:", error);
|
console.error("Import error:", error);
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
error instanceof Error ? error.message : "Failed to import data. Please try again.";
|
error instanceof Error ? error.message : "Failed to import data. Please try again.";
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
import {
|
import {
|
||||||
Pagination,
|
Pagination,
|
||||||
PaginationContent,
|
PaginationContent,
|
||||||
@@ -20,7 +22,7 @@ import {
|
|||||||
import { ProductSearch } from "@/components/product-editor/ProductSearch";
|
import { ProductSearch } from "@/components/product-editor/ProductSearch";
|
||||||
import { ProductEditForm, LAYOUT_ICONS } from "@/components/product-editor/ProductEditForm";
|
import { ProductEditForm, LAYOUT_ICONS } from "@/components/product-editor/ProductEditForm";
|
||||||
import type { LayoutMode } from "@/components/product-editor/ProductEditForm";
|
import type { LayoutMode } from "@/components/product-editor/ProductEditForm";
|
||||||
import type { SearchProduct, FieldOptions, FieldOption, LineOption, LandingExtra } from "@/components/product-editor/types";
|
import type { SearchProduct, FieldOptions, FieldOption, LineOption, LandingExtra, ProductImage } from "@/components/product-editor/types";
|
||||||
import { ExternalLink } from "lucide-react";
|
import { ExternalLink } from "lucide-react";
|
||||||
|
|
||||||
const PER_PAGE = 20;
|
const PER_PAGE = 20;
|
||||||
@@ -169,16 +171,56 @@ export default function ProductEditor() {
|
|||||||
const [landingExtras, setLandingExtras] = useState<Record<string, LandingExtra[]>>({});
|
const [landingExtras, setLandingExtras] = useState<Record<string, LandingExtra[]>>({});
|
||||||
const [isLoadingExtras, setIsLoadingExtras] = useState(false);
|
const [isLoadingExtras, setIsLoadingExtras] = useState(false);
|
||||||
const [activeLandingItem, setActiveLandingItem] = useState<string | null>(null);
|
const [activeLandingItem, setActiveLandingItem] = useState<string | null>(null);
|
||||||
|
const [viewingFeaturedExtra, setViewingFeaturedExtra] = useState<LandingExtra | null>(null);
|
||||||
|
const [newFeedOnly, setNewFeedOnly] = useState(false);
|
||||||
|
const [preorderFeedOnly, setPreorderFeedOnly] = useState(false);
|
||||||
|
const [lineNewOnly, setLineNewOnly] = useState(false);
|
||||||
|
const [linePreorderOnly, setLinePreorderOnly] = useState(false);
|
||||||
|
|
||||||
// Abort controller for cancelling in-flight product requests
|
// Abort controller for cancelling in-flight product requests
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
const totalPages = Math.ceil(allProducts.length / PER_PAGE);
|
const filteredProducts = useMemo(() => {
|
||||||
|
if (viewingFeaturedExtra && activeTab === "new" && newFeedOnly) {
|
||||||
|
return allProducts.filter((p) => p.is_new);
|
||||||
|
}
|
||||||
|
if (viewingFeaturedExtra && activeTab === "preorder" && preorderFeedOnly) {
|
||||||
|
return allProducts.filter((p) => p.is_preorder);
|
||||||
|
}
|
||||||
|
if (activeTab === "by-line" && (lineNewOnly || linePreorderOnly)) {
|
||||||
|
return allProducts.filter((p) =>
|
||||||
|
(lineNewOnly && p.is_new) || (linePreorderOnly && p.is_preorder)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return allProducts;
|
||||||
|
}, [allProducts, viewingFeaturedExtra, activeTab, newFeedOnly, preorderFeedOnly, lineNewOnly, linePreorderOnly]);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(filteredProducts.length / PER_PAGE);
|
||||||
const products = useMemo(
|
const products = useMemo(
|
||||||
() => allProducts.slice((page - 1) * PER_PAGE, page * PER_PAGE),
|
() => filteredProducts.slice((page - 1) * PER_PAGE, page * PER_PAGE),
|
||||||
[allProducts, page]
|
[filteredProducts, page]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Batch-fetch images for the current page of products
|
||||||
|
const [batchImages, setBatchImages] = useState<Record<number, ProductImage[]>>({});
|
||||||
|
useEffect(() => {
|
||||||
|
if (products.length === 0) return;
|
||||||
|
const pids = products.map((p) => p.pid);
|
||||||
|
const controller = new AbortController();
|
||||||
|
axios
|
||||||
|
.get("/api/import/product-images-batch", {
|
||||||
|
params: { pids: pids.join(",") },
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
setBatchImages((prev) => ({ ...prev, ...res.data }));
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (!axios.isCancel(e)) console.error("Failed to batch-load images", e);
|
||||||
|
});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [products]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
axios
|
axios
|
||||||
.get("/api/import/field-options")
|
.get("/api/import/field-options")
|
||||||
@@ -308,6 +350,9 @@ export default function ProductEditor() {
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
abortRef.current = controller;
|
abortRef.current = controller;
|
||||||
setActiveLandingItem(extra.path);
|
setActiveLandingItem(extra.path);
|
||||||
|
setViewingFeaturedExtra(extra);
|
||||||
|
setNewFeedOnly(false);
|
||||||
|
setPreorderFeedOnly(false);
|
||||||
setAllProducts([]);
|
setAllProducts([]);
|
||||||
setIsLoadingProducts(true);
|
setIsLoadingProducts(true);
|
||||||
try {
|
try {
|
||||||
@@ -331,6 +376,11 @@ export default function ProductEditor() {
|
|||||||
setActiveTab(tab);
|
setActiveTab(tab);
|
||||||
setQueryStatus(null);
|
setQueryStatus(null);
|
||||||
setQueryId("");
|
setQueryId("");
|
||||||
|
setViewingFeaturedExtra(null);
|
||||||
|
setNewFeedOnly(false);
|
||||||
|
setPreorderFeedOnly(false);
|
||||||
|
setLineNewOnly(false);
|
||||||
|
setLinePreorderOnly(false);
|
||||||
if (tab === "new" && loadedTab !== "new") {
|
if (tab === "new" && loadedTab !== "new") {
|
||||||
setLoadedTab("new");
|
setLoadedTab("new");
|
||||||
loadFeedProducts("new-products", "new");
|
loadFeedProducts("new-products", "new");
|
||||||
@@ -356,6 +406,8 @@ export default function ProductEditor() {
|
|||||||
abortRef.current = controller;
|
abortRef.current = controller;
|
||||||
setAllProducts([]);
|
setAllProducts([]);
|
||||||
setIsLoadingProducts(true);
|
setIsLoadingProducts(true);
|
||||||
|
setLineNewOnly(false);
|
||||||
|
setLinePreorderOnly(false);
|
||||||
try {
|
try {
|
||||||
const params: Record<string, string> = { company: lineCompany, line: lineLine };
|
const params: Record<string, string> = { company: lineCompany, line: lineLine };
|
||||||
if (lineSubline) params.subline = lineSubline;
|
if (lineSubline) params.subline = lineSubline;
|
||||||
@@ -634,10 +686,26 @@ export default function ProductEditor() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{renderLandingExtras("new")}
|
{renderLandingExtras("new")}
|
||||||
|
{viewingFeaturedExtra && activeTab === "new" && !isLoadingProducts && allProducts.length > 0 && !allProducts.every((p) => p.is_new) && (
|
||||||
|
<div className="flex items-center space-x-2 mb-3">
|
||||||
|
<Switch
|
||||||
|
id="new-only"
|
||||||
|
checked={newFeedOnly}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setNewFeedOnly(checked);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="new-only" className="text-sm text-muted-foreground">Show only new products</Label>
|
||||||
|
{newFeedOnly && (
|
||||||
|
<span className="text-xs text-muted-foreground">{filteredProducts.length} of {allProducts.length}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{isLoadingProducts && (
|
{isLoadingProducts && (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
Loading new products...
|
Loading products...
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -650,10 +718,26 @@ export default function ProductEditor() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{renderLandingExtras("preorder")}
|
{renderLandingExtras("preorder")}
|
||||||
|
{viewingFeaturedExtra && activeTab === "preorder" && !isLoadingProducts && allProducts.length > 0 && !allProducts.every((p) => p.is_preorder) && (
|
||||||
|
<div className="flex items-center space-x-2 mb-3">
|
||||||
|
<Switch
|
||||||
|
id="preorder-only"
|
||||||
|
checked={preorderFeedOnly}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setPreorderFeedOnly(checked);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="preorder-only" className="text-sm text-muted-foreground">Show only pre-order products</Label>
|
||||||
|
{preorderFeedOnly && (
|
||||||
|
<span className="text-xs text-muted-foreground">{filteredProducts.length} of {allProducts.length}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{isLoadingProducts && (
|
{isLoadingProducts && (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
Loading pre-order products...
|
Loading products...
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -662,7 +746,7 @@ export default function ProductEditor() {
|
|||||||
{isLoadingProducts && (
|
{isLoadingProducts && (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
Loading hidden recently-created products...
|
Loading products...
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -709,7 +793,40 @@ export default function ProductEditor() {
|
|||||||
{isLoadingProducts && (
|
{isLoadingProducts && (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-3">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-3">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
Loading line products...
|
Loading products...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isLoadingProducts && allProducts.length > 0 && activeTab === "by-line" && (allProducts.some((p) => p.is_new && !p.is_preorder) || allProducts.some((p) => p.is_preorder)) && (
|
||||||
|
<div className="flex items-center gap-4 mt-3">
|
||||||
|
{!allProducts.every((p) => p.is_new) && allProducts.some((p) => p.is_new) && (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
id="line-new-only"
|
||||||
|
checked={lineNewOnly}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setLineNewOnly(checked);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="line-new-only" className="text-sm text-muted-foreground">Show only new products</Label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!allProducts.every((p) => p.is_preorder) && allProducts.some((p) => p.is_preorder) && (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
id="line-preorder-only"
|
||||||
|
checked={linePreorderOnly}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setLinePreorderOnly(checked);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="line-preorder-only" className="text-sm text-muted-foreground">Show only pre-order</Label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(lineNewOnly || linePreorderOnly) && (
|
||||||
|
<span className="text-xs text-muted-foreground">{filteredProducts.length} of {allProducts.length}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -725,6 +842,7 @@ export default function ProductEditor() {
|
|||||||
product={product}
|
product={product}
|
||||||
fieldOptions={fieldOptions}
|
fieldOptions={fieldOptions}
|
||||||
layoutMode={layoutMode}
|
layoutMode={layoutMode}
|
||||||
|
initialImages={batchImages[product.pid]}
|
||||||
onClose={() => handleRemoveProduct(product.pid)}
|
onClose={() => handleRemoveProduct(product.pid)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ import LockButton from "@/components/dashboard/LockButton";
|
|||||||
import PinProtection from "@/components/dashboard/PinProtection";
|
import PinProtection from "@/components/dashboard/PinProtection";
|
||||||
import DateTimeWeatherDisplay from "@/components/dashboard/DateTime";
|
import DateTimeWeatherDisplay from "@/components/dashboard/DateTime";
|
||||||
import MiniStatCards from "@/components/dashboard/MiniStatCards";
|
import MiniStatCards from "@/components/dashboard/MiniStatCards";
|
||||||
import MiniRealtimeAnalytics from "@/components/dashboard/MiniRealtimeAnalytics";
|
|
||||||
import MiniSalesChart from "@/components/dashboard/MiniSalesChart";
|
import MiniSalesChart from "@/components/dashboard/MiniSalesChart";
|
||||||
import MiniEventFeed from "@/components/dashboard/MiniEventFeed";
|
import MiniEventFeed from "@/components/dashboard/MiniEventFeed";
|
||||||
|
// @ts-expect-error - JSX component without type declarations
|
||||||
|
import MiniBusinessMetrics from "@/components/dashboard/MiniBusinessMetrics";
|
||||||
|
// @ts-expect-error - JSX component without type declarations
|
||||||
|
import MiniInventorySnapshot from "@/components/dashboard/MiniInventorySnapshot";
|
||||||
|
|
||||||
// Pin Protected Layout
|
// Pin Protected Layout
|
||||||
const PinProtectedLayout = ({ children }: { children: React.ReactNode }) => {
|
const PinProtectedLayout = ({ children }: { children: React.ReactNode }) => {
|
||||||
@@ -30,7 +33,7 @@ const PinProtectedLayout = ({ children }: { children: React.ReactNode }) => {
|
|||||||
const SmallLayout = () => {
|
const SmallLayout = () => {
|
||||||
const DATETIME_SCALE = 2;
|
const DATETIME_SCALE = 2;
|
||||||
const STATS_SCALE = 1.65;
|
const STATS_SCALE = 1.65;
|
||||||
const ANALYTICS_SCALE = 1.65;
|
const PANELS_SCALE = 1.65;
|
||||||
const SALES_SCALE = 1.65;
|
const SALES_SCALE = 1.65;
|
||||||
const FEED_SCALE = 1.65;
|
const FEED_SCALE = 1.65;
|
||||||
|
|
||||||
@@ -86,15 +89,22 @@ const SmallLayout = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mini Realtime Analytics */}
|
{/* Operations + Inventory Panels */}
|
||||||
<div className="-mt-1">
|
<div className="h-full">
|
||||||
<div style={{
|
<div className="h-full" style={{
|
||||||
transform: `scale(${ANALYTICS_SCALE})`,
|
transform: `scale(${PANELS_SCALE})`,
|
||||||
transformOrigin: 'top left',
|
transformOrigin: 'top left',
|
||||||
width: `${100/ANALYTICS_SCALE}%`,
|
width: `${100/PANELS_SCALE}%`,
|
||||||
height: `${100/ANALYTICS_SCALE}%`
|
height: '100%',
|
||||||
}}>
|
}}>
|
||||||
<MiniRealtimeAnalytics />
|
<div className="flex gap-2 h-full">
|
||||||
|
<div className="flex-1 min-w-0 h-full">
|
||||||
|
<MiniBusinessMetrics />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0 h-full">
|
||||||
|
<MiniInventorySnapshot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Import Audit Log API Service
|
||||||
|
*
|
||||||
|
* Logs every product import submission to a permanent audit trail.
|
||||||
|
* Fire-and-forget by default — callers should not block on the result.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BASE_URL = '/api/import-audit-log';
|
||||||
|
|
||||||
|
export interface ImportAuditLogEntry {
|
||||||
|
user_id: number;
|
||||||
|
username?: string;
|
||||||
|
product_count: number;
|
||||||
|
request_payload: unknown;
|
||||||
|
environment: 'dev' | 'prod';
|
||||||
|
target_endpoint?: string;
|
||||||
|
use_test_data_source?: boolean;
|
||||||
|
success: boolean;
|
||||||
|
response_payload?: unknown;
|
||||||
|
error_message?: string;
|
||||||
|
created_count?: number;
|
||||||
|
errored_count?: number;
|
||||||
|
session_id?: number | null;
|
||||||
|
duration_ms?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an audit log entry to the backend.
|
||||||
|
* Designed to be fire-and-forget — errors are logged but never thrown
|
||||||
|
* so that a logging failure never blocks the user's import flow.
|
||||||
|
*/
|
||||||
|
export async function createImportAuditLog(entry: ImportAuditLogEntry): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fetch(BASE_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(entry),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Never throw — audit logging should not disrupt the import flow
|
||||||
|
console.error('Failed to write import audit log:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user