Stat cards fixes, mini component tweaks
This commit is contained in:
1106
docs/prod_registry.class.php
Normal file
1106
docs/prod_registry.class.php
Normal file
File diff suppressed because it is too large
Load Diff
@@ -55,6 +55,7 @@ router.get('/stats', async (req, res) => {
|
|||||||
const { whereClause, params, dateRange } = getTimeRangeConditions(timeRange, startDate, endDate);
|
const { whereClause, params, dateRange } = getTimeRangeConditions(timeRange, startDate, endDate);
|
||||||
|
|
||||||
// Main order stats query (optionally excludes Cherry Box orders)
|
// Main order stats query (optionally excludes Cherry Box orders)
|
||||||
|
// Note: order_status > 15 excludes cancelled (15), so cancelled stats are queried separately
|
||||||
const mainStatsQuery = `
|
const mainStatsQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*) as orderCount,
|
COUNT(*) as orderCount,
|
||||||
@@ -64,10 +65,7 @@ router.get('/stats', async (req, res) => {
|
|||||||
AVG(stats_prod_pieces) as averageItemsPerOrder,
|
AVG(stats_prod_pieces) as averageItemsPerOrder,
|
||||||
SUM(CASE WHEN stats_waiting_preorder > 0 THEN 1 ELSE 0 END) as preOrderCount,
|
SUM(CASE WHEN stats_waiting_preorder > 0 THEN 1 ELSE 0 END) as preOrderCount,
|
||||||
SUM(CASE WHEN ship_method_selected = 'localpickup' THEN 1 ELSE 0 END) as localPickupCount,
|
SUM(CASE WHEN ship_method_selected = 'localpickup' THEN 1 ELSE 0 END) as localPickupCount,
|
||||||
SUM(CASE WHEN ship_method_selected = 'holdit' THEN 1 ELSE 0 END) as onHoldCount,
|
SUM(CASE WHEN ship_method_selected = 'holdit' THEN 1 ELSE 0 END) as onHoldCount
|
||||||
SUM(CASE WHEN order_status IN (100, 92) THEN 1 ELSE 0 END) as shippedCount,
|
|
||||||
SUM(CASE WHEN order_status = 15 THEN 1 ELSE 0 END) as cancelledCount,
|
|
||||||
SUM(CASE WHEN order_status = 15 THEN summary_total ELSE 0 END) as cancelledTotal
|
|
||||||
FROM _order
|
FROM _order
|
||||||
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
|
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
|
||||||
`;
|
`;
|
||||||
@@ -75,6 +73,21 @@ router.get('/stats', async (req, res) => {
|
|||||||
const [mainStats] = await connection.execute(mainStatsQuery, params);
|
const [mainStats] = await connection.execute(mainStatsQuery, params);
|
||||||
const stats = mainStats[0];
|
const stats = mainStats[0];
|
||||||
|
|
||||||
|
// Cancelled orders query - uses date_cancelled instead of date_placed
|
||||||
|
// Shows orders cancelled during the selected period, regardless of when they were placed
|
||||||
|
const cancelledQuery = `
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as cancelledCount,
|
||||||
|
SUM(summary_total) as cancelledTotal
|
||||||
|
FROM _order
|
||||||
|
WHERE order_status = 15
|
||||||
|
AND ${getCherryBoxClause(excludeCB)}
|
||||||
|
AND ${whereClause.replace('date_placed', 'date_cancelled')}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [cancelledResult] = await connection.execute(cancelledQuery, params);
|
||||||
|
const cancelledStats = cancelledResult[0] || { cancelledCount: 0, cancelledTotal: 0 };
|
||||||
|
|
||||||
// Refunds query (optionally excludes Cherry Box orders)
|
// Refunds query (optionally excludes Cherry Box orders)
|
||||||
const refundsQuery = `
|
const refundsQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
@@ -87,6 +100,19 @@ router.get('/stats', async (req, res) => {
|
|||||||
|
|
||||||
const [refundStats] = await connection.execute(refundsQuery, params);
|
const [refundStats] = await connection.execute(refundsQuery, params);
|
||||||
|
|
||||||
|
// Shipped orders query - uses date_shipped instead of date_placed
|
||||||
|
// This counts orders that were SHIPPED during the selected period, regardless of when they were placed
|
||||||
|
const shippedQuery = `
|
||||||
|
SELECT COUNT(*) as shippedCount
|
||||||
|
FROM _order
|
||||||
|
WHERE order_status IN (92, 95, 100)
|
||||||
|
AND ${getCherryBoxClause(excludeCB)}
|
||||||
|
AND ${whereClause.replace('date_placed', 'date_shipped')}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [shippedResult] = await connection.execute(shippedQuery, params);
|
||||||
|
const shippedCount = parseInt(shippedResult[0]?.shippedCount || 0);
|
||||||
|
|
||||||
// Best revenue day query (optionally excludes Cherry Box orders)
|
// Best revenue day query (optionally excludes Cherry Box orders)
|
||||||
const bestDayQuery = `
|
const bestDayQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
@@ -102,7 +128,7 @@ router.get('/stats', async (req, res) => {
|
|||||||
|
|
||||||
const [bestDayResult] = await connection.execute(bestDayQuery, params);
|
const [bestDayResult] = await connection.execute(bestDayQuery, params);
|
||||||
|
|
||||||
// Peak hour query (for single day periods, optionally excludes Cherry Box orders)
|
// Peak hour query - uses selected time range for the card value
|
||||||
let peakHour = null;
|
let peakHour = null;
|
||||||
if (['today', 'yesterday'].includes(timeRange)) {
|
if (['today', 'yesterday'].includes(timeRange)) {
|
||||||
const peakHourQuery = `
|
const peakHourQuery = `
|
||||||
@@ -123,46 +149,100 @@ router.get('/stats', async (req, res) => {
|
|||||||
date.setHours(hour, 0, 0);
|
date.setHours(hour, 0, 0);
|
||||||
peakHour = {
|
peakHour = {
|
||||||
hour,
|
hour,
|
||||||
count: peakHourResult[0].count,
|
count: parseInt(peakHourResult[0].count),
|
||||||
displayHour: date.toLocaleString("en-US", { hour: "numeric", hour12: true })
|
displayHour: date.toLocaleString("en-US", { hour: "numeric", hour12: true })
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Brands and categories query - simplified for now since we don't have the category tables
|
// Hourly breakdown for detail chart - always rolling 24 hours (like revenue/orders use 30 days)
|
||||||
// We'll use a simple approach without company table for now (optionally excludes Cherry Box orders)
|
// Returns data ordered chronologically: [24hrs ago, 23hrs ago, ..., 1hr ago, current hour]
|
||||||
|
let hourlyOrders = null;
|
||||||
|
if (['today', 'yesterday'].includes(timeRange)) {
|
||||||
|
// Get hourly counts AND current hour from MySQL to avoid timezone mismatch
|
||||||
|
const hourlyQuery = `
|
||||||
|
SELECT
|
||||||
|
HOUR(date_placed) as hour,
|
||||||
|
COUNT(*) as count,
|
||||||
|
HOUR(NOW()) as currentHour
|
||||||
|
FROM _order
|
||||||
|
WHERE order_status > 15
|
||||||
|
AND ${getCherryBoxClause(excludeCB)}
|
||||||
|
AND date_placed >= NOW() - INTERVAL 24 HOUR
|
||||||
|
GROUP BY HOUR(date_placed)
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [hourlyResult] = await connection.execute(hourlyQuery);
|
||||||
|
|
||||||
|
// Get current hour from MySQL (same timezone as the WHERE clause)
|
||||||
|
const currentHour = hourlyResult.length > 0 ? parseInt(hourlyResult[0].currentHour) : new Date().getHours();
|
||||||
|
|
||||||
|
// Build map of hour -> count
|
||||||
|
const hourCounts = {};
|
||||||
|
hourlyResult.forEach(row => {
|
||||||
|
hourCounts[parseInt(row.hour)] = parseInt(row.count);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build array in chronological order starting from (currentHour + 1) which is 24 hours ago
|
||||||
|
hourlyOrders = [];
|
||||||
|
for (let i = 0; i < 24; i++) {
|
||||||
|
const hour = (currentHour + 1 + i) % 24; // Start from 24hrs ago, end at current hour
|
||||||
|
hourlyOrders.push({
|
||||||
|
hour,
|
||||||
|
count: hourCounts[hour] || 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Brands query - products.company links to product_categories.cat_id for brand name
|
||||||
|
// Only include products that have a brand assigned (INNER JOIN)
|
||||||
const brandsQuery = `
|
const brandsQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
'Various Brands' as brandName,
|
pc.cat_id as catId,
|
||||||
|
pc.name as brandName,
|
||||||
COUNT(DISTINCT oi.order_id) as orderCount,
|
COUNT(DISTINCT oi.order_id) as orderCount,
|
||||||
SUM(oi.qty_ordered) as itemCount,
|
SUM(oi.qty_ordered) as itemCount,
|
||||||
SUM(oi.qty_ordered * oi.prod_price) as revenue
|
SUM(oi.qty_ordered * oi.prod_price) as revenue
|
||||||
FROM order_items oi
|
FROM order_items oi
|
||||||
JOIN _order o ON oi.order_id = o.order_id
|
JOIN _order o ON oi.order_id = o.order_id
|
||||||
JOIN products p ON oi.prod_pid = p.pid
|
JOIN products p ON oi.prod_pid = p.pid
|
||||||
|
JOIN product_categories pc ON p.company = pc.cat_id
|
||||||
WHERE o.order_status > 15 AND ${getCherryBoxClauseAliased('o', excludeCB)} AND ${whereClause.replace('date_placed', 'o.date_placed')}
|
WHERE o.order_status > 15 AND ${getCherryBoxClauseAliased('o', excludeCB)} AND ${whereClause.replace('date_placed', 'o.date_placed')}
|
||||||
|
GROUP BY pc.cat_id, pc.name
|
||||||
HAVING revenue > 0
|
HAVING revenue > 0
|
||||||
|
ORDER BY revenue DESC
|
||||||
|
LIMIT 100
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const [brandsResult] = await connection.execute(brandsQuery, params);
|
const [brandsResult] = await connection.execute(brandsQuery, params);
|
||||||
|
|
||||||
// For categories, we'll use a simplified approach (optionally excludes Cherry Box orders)
|
// Categories query - uses product_category_index to get category assignments
|
||||||
|
// Only include categories with valid types (no NULL/uncategorized)
|
||||||
const categoriesQuery = `
|
const categoriesQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
'General' as categoryName,
|
pc.cat_id as catId,
|
||||||
|
pc.name as categoryName,
|
||||||
COUNT(DISTINCT oi.order_id) as orderCount,
|
COUNT(DISTINCT oi.order_id) as orderCount,
|
||||||
SUM(oi.qty_ordered) as itemCount,
|
SUM(oi.qty_ordered) as itemCount,
|
||||||
SUM(oi.qty_ordered * oi.prod_price) as revenue
|
SUM(oi.qty_ordered * oi.prod_price) as revenue
|
||||||
FROM order_items oi
|
FROM order_items oi
|
||||||
JOIN _order o ON oi.order_id = o.order_id
|
JOIN _order o ON oi.order_id = o.order_id
|
||||||
JOIN products p ON oi.prod_pid = p.pid
|
JOIN products p ON oi.prod_pid = p.pid
|
||||||
WHERE o.order_status > 15 AND ${getCherryBoxClauseAliased('o', excludeCB)} AND ${whereClause.replace('date_placed', 'o.date_placed')}
|
JOIN product_category_index pci ON p.pid = pci.pid
|
||||||
|
JOIN product_categories pc ON pci.cat_id = pc.cat_id
|
||||||
|
WHERE o.order_status > 15
|
||||||
|
AND ${getCherryBoxClauseAliased('o', excludeCB)}
|
||||||
|
AND ${whereClause.replace('date_placed', 'o.date_placed')}
|
||||||
|
AND pc.type IN (10, 20, 11, 21, 12, 13)
|
||||||
|
GROUP BY pc.cat_id, pc.name
|
||||||
HAVING revenue > 0
|
HAVING revenue > 0
|
||||||
|
ORDER BY revenue DESC
|
||||||
|
LIMIT 100
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const [categoriesResult] = await connection.execute(categoriesQuery, params);
|
const [categoriesResult] = await connection.execute(categoriesQuery, params);
|
||||||
|
|
||||||
// Shipping locations query (optionally excludes Cherry Box orders)
|
// Shipping locations query - uses date_shipped to match shippedCount
|
||||||
const shippingQuery = `
|
const shippingQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
ship_country,
|
ship_country,
|
||||||
@@ -170,19 +250,22 @@ router.get('/stats', async (req, res) => {
|
|||||||
ship_method_selected,
|
ship_method_selected,
|
||||||
COUNT(*) as count
|
COUNT(*) as count
|
||||||
FROM _order
|
FROM _order
|
||||||
WHERE order_status IN (100, 92) AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
|
WHERE order_status IN (92, 95, 100)
|
||||||
|
AND ${getCherryBoxClause(excludeCB)}
|
||||||
|
AND ${whereClause.replace('date_placed', 'date_shipped')}
|
||||||
GROUP BY ship_country, ship_state, ship_method_selected
|
GROUP BY ship_country, ship_state, ship_method_selected
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const [shippingResult] = await connection.execute(shippingQuery, params);
|
const [shippingResult] = await connection.execute(shippingQuery, params);
|
||||||
|
|
||||||
// Process shipping data
|
// Process shipping data
|
||||||
const shippingStats = processShippingData(shippingResult, stats.shippedCount);
|
const shippingStats = processShippingData(shippingResult, shippedCount);
|
||||||
|
|
||||||
// Order value range query (optionally excludes Cherry Box orders)
|
// Order value range query (optionally excludes Cherry Box orders)
|
||||||
|
// Excludes $0 orders from min calculation
|
||||||
const orderRangeQuery = `
|
const orderRangeQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
MIN(summary_total) as smallest,
|
MIN(CASE WHEN summary_total > 0 THEN summary_total END) as smallest,
|
||||||
MAX(summary_total) as largest
|
MAX(summary_total) as largest
|
||||||
FROM _order
|
FROM _order
|
||||||
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
|
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
|
||||||
@@ -226,7 +309,7 @@ router.get('/stats', async (req, res) => {
|
|||||||
|
|
||||||
// Shipping
|
// Shipping
|
||||||
shipping: {
|
shipping: {
|
||||||
shippedCount: parseInt(stats.shippedCount || 0),
|
shippedCount: parseInt(shippedCount || 0),
|
||||||
locations: shippingStats.locations,
|
locations: shippingStats.locations,
|
||||||
methodStats: shippingStats.methods
|
methodStats: shippingStats.methods
|
||||||
},
|
},
|
||||||
@@ -235,6 +318,7 @@ router.get('/stats', async (req, res) => {
|
|||||||
brands: {
|
brands: {
|
||||||
total: brandsResult.length,
|
total: brandsResult.length,
|
||||||
list: brandsResult.slice(0, 50).map(brand => ({
|
list: brandsResult.slice(0, 50).map(brand => ({
|
||||||
|
id: brand.catId,
|
||||||
name: brand.brandName,
|
name: brand.brandName,
|
||||||
count: parseInt(brand.itemCount),
|
count: parseInt(brand.itemCount),
|
||||||
revenue: parseFloat(brand.revenue)
|
revenue: parseFloat(brand.revenue)
|
||||||
@@ -244,6 +328,7 @@ router.get('/stats', async (req, res) => {
|
|||||||
categories: {
|
categories: {
|
||||||
total: categoriesResult.length,
|
total: categoriesResult.length,
|
||||||
list: categoriesResult.slice(0, 50).map(category => ({
|
list: categoriesResult.slice(0, 50).map(category => ({
|
||||||
|
id: category.catId,
|
||||||
name: category.categoryName,
|
name: category.categoryName,
|
||||||
count: parseInt(category.itemCount),
|
count: parseInt(category.itemCount),
|
||||||
revenue: parseFloat(category.revenue)
|
revenue: parseFloat(category.revenue)
|
||||||
@@ -257,8 +342,8 @@ router.get('/stats', async (req, res) => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
canceledOrders: {
|
canceledOrders: {
|
||||||
total: parseFloat(stats.cancelledTotal || 0),
|
total: parseFloat(cancelledStats.cancelledTotal || 0),
|
||||||
count: parseInt(stats.cancelledCount || 0)
|
count: parseInt(cancelledStats.cancelledCount || 0)
|
||||||
},
|
},
|
||||||
|
|
||||||
// Best day
|
// Best day
|
||||||
@@ -270,6 +355,7 @@ router.get('/stats', async (req, res) => {
|
|||||||
|
|
||||||
// Peak hour (for single days)
|
// Peak hour (for single days)
|
||||||
peakOrderHour: peakHour,
|
peakOrderHour: peakHour,
|
||||||
|
hourlyOrders: hourlyOrders, // Array of 24 hourly order counts for the detail chart
|
||||||
|
|
||||||
// Order value range
|
// Order value range
|
||||||
orderValueRange: orderRangeResult.length > 0 ? {
|
orderValueRange: orderRangeResult.length > 0 ? {
|
||||||
@@ -324,13 +410,125 @@ router.get('/stats', async (req, res) => {
|
|||||||
router.get('/stats/details', async (req, res) => {
|
router.get('/stats/details', async (req, res) => {
|
||||||
let release;
|
let release;
|
||||||
try {
|
try {
|
||||||
const { timeRange, startDate, endDate, metric, daily, excludeCherryBox } = req.query;
|
const { timeRange, startDate, endDate, metric, daily, excludeCherryBox, orderType, eventType } = req.query;
|
||||||
const excludeCB = parseBoolParam(excludeCherryBox);
|
const excludeCB = parseBoolParam(excludeCherryBox);
|
||||||
const { connection, release: releaseConn } = await getDbConnection();
|
const { connection, release: releaseConn } = await getDbConnection();
|
||||||
release = releaseConn;
|
release = releaseConn;
|
||||||
|
|
||||||
const { whereClause, params } = getTimeRangeConditions(timeRange, startDate, endDate);
|
const { whereClause, params } = getTimeRangeConditions(timeRange, startDate, endDate);
|
||||||
|
|
||||||
|
// Handle special event types (refunds, cancellations)
|
||||||
|
if (eventType === 'PAYMENT_REFUNDED') {
|
||||||
|
// Refunds query - from order_payment table
|
||||||
|
const refundsQuery = `
|
||||||
|
SELECT
|
||||||
|
DATE(op.payment_date) as date,
|
||||||
|
COUNT(*) as count,
|
||||||
|
ABS(SUM(op.payment_amount)) as total
|
||||||
|
FROM order_payment op
|
||||||
|
JOIN _order o ON op.order_id = o.order_id
|
||||||
|
WHERE op.payment_amount < 0
|
||||||
|
AND o.order_status > 15
|
||||||
|
AND ${getCherryBoxClauseAliased('o', excludeCB)}
|
||||||
|
AND ${whereClause.replace('date_placed', 'op.payment_date')}
|
||||||
|
GROUP BY DATE(op.payment_date)
|
||||||
|
ORDER BY DATE(op.payment_date)
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [refundResults] = await connection.execute(refundsQuery, params);
|
||||||
|
|
||||||
|
// Format matches what frontend expects: day.refunds.total, day.refunds.count
|
||||||
|
const stats = refundResults.map(day => ({
|
||||||
|
timestamp: day.date,
|
||||||
|
date: day.date,
|
||||||
|
refunds: {
|
||||||
|
total: parseFloat(day.total || 0),
|
||||||
|
count: parseInt(day.count || 0),
|
||||||
|
reasons: {}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (release) release();
|
||||||
|
return res.json({ stats });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventType === 'CANCELED_ORDER') {
|
||||||
|
// Cancellations query - uses date_cancelled to show when orders were actually cancelled
|
||||||
|
const cancelQuery = `
|
||||||
|
SELECT
|
||||||
|
DATE(date_cancelled) as date,
|
||||||
|
COUNT(*) as count,
|
||||||
|
SUM(summary_total) as total
|
||||||
|
FROM _order
|
||||||
|
WHERE order_status = 15
|
||||||
|
AND ${getCherryBoxClause(excludeCB)}
|
||||||
|
AND ${whereClause.replace('date_placed', 'date_cancelled')}
|
||||||
|
GROUP BY DATE(date_cancelled)
|
||||||
|
ORDER BY DATE(date_cancelled)
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [cancelResults] = await connection.execute(cancelQuery, params);
|
||||||
|
|
||||||
|
// Format matches what frontend expects: day.canceledOrders.total, day.canceledOrders.count
|
||||||
|
const stats = cancelResults.map(day => ({
|
||||||
|
timestamp: day.date,
|
||||||
|
date: day.date,
|
||||||
|
canceledOrders: {
|
||||||
|
total: parseFloat(day.total || 0),
|
||||||
|
count: parseInt(day.count || 0),
|
||||||
|
reasons: {}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (release) release();
|
||||||
|
return res.json({ stats });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventType === 'PLACED_ORDER') {
|
||||||
|
// Order range query - daily min/max/average order values
|
||||||
|
const orderRangeQuery = `
|
||||||
|
SELECT
|
||||||
|
DATE(date_placed) as date,
|
||||||
|
COUNT(*) as orders,
|
||||||
|
MIN(CASE WHEN summary_total > 0 THEN summary_total END) as smallest,
|
||||||
|
MAX(summary_total) as largest,
|
||||||
|
AVG(summary_total) as averageOrderValue
|
||||||
|
FROM _order
|
||||||
|
WHERE order_status > 15
|
||||||
|
AND ${getCherryBoxClause(excludeCB)}
|
||||||
|
AND ${whereClause}
|
||||||
|
GROUP BY DATE(date_placed)
|
||||||
|
ORDER BY DATE(date_placed)
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [orderRangeResults] = await connection.execute(orderRangeQuery, params);
|
||||||
|
|
||||||
|
// Format matches what frontend OrderRangeDetails expects
|
||||||
|
const stats = orderRangeResults.map(day => ({
|
||||||
|
timestamp: day.date,
|
||||||
|
date: day.date,
|
||||||
|
orders: parseInt(day.orders || 0),
|
||||||
|
orderValueRange: {
|
||||||
|
smallest: parseFloat(day.smallest || 0),
|
||||||
|
largest: parseFloat(day.largest || 0)
|
||||||
|
},
|
||||||
|
averageOrderValue: parseFloat(day.averageOrderValue || 0)
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (release) release();
|
||||||
|
return res.json({ stats });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build order type filter based on orderType parameter
|
||||||
|
let orderTypeFilter = '';
|
||||||
|
if (orderType === 'pre_orders') {
|
||||||
|
orderTypeFilter = 'AND stats_waiting_preorder > 0';
|
||||||
|
} else if (orderType === 'local_pickup') {
|
||||||
|
orderTypeFilter = "AND ship_method_selected = 'localpickup'";
|
||||||
|
} else if (orderType === 'on_hold') {
|
||||||
|
orderTypeFilter = "AND ship_method_selected = 'holdit'";
|
||||||
|
}
|
||||||
|
|
||||||
// Daily breakdown query (optionally excludes Cherry Box orders)
|
// Daily breakdown query (optionally excludes Cherry Box orders)
|
||||||
const dailyQuery = `
|
const dailyQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
@@ -340,7 +538,7 @@ router.get('/stats/details', async (req, res) => {
|
|||||||
AVG(summary_total) as averageOrderValue,
|
AVG(summary_total) as averageOrderValue,
|
||||||
SUM(stats_prod_pieces) as itemCount
|
SUM(stats_prod_pieces) as itemCount
|
||||||
FROM _order
|
FROM _order
|
||||||
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
|
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause} ${orderTypeFilter}
|
||||||
GROUP BY DATE(date_placed)
|
GROUP BY DATE(date_placed)
|
||||||
ORDER BY DATE(date_placed)
|
ORDER BY DATE(date_placed)
|
||||||
`;
|
`;
|
||||||
@@ -376,7 +574,7 @@ router.get('/stats/details', async (req, res) => {
|
|||||||
SUM(summary_total) as prevRevenue,
|
SUM(summary_total) as prevRevenue,
|
||||||
AVG(summary_total) as prevAvgOrderValue
|
AVG(summary_total) as prevAvgOrderValue
|
||||||
FROM _order
|
FROM _order
|
||||||
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${prevWhereClause}
|
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${prevWhereClause} ${orderTypeFilter}
|
||||||
GROUP BY DATE(date_placed)
|
GROUP BY DATE(date_placed)
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -619,18 +817,23 @@ router.get('/products', async (req, res) => {
|
|||||||
|
|
||||||
// Projection endpoint - replaces /api/klaviyo/events/projection
|
// Projection endpoint - replaces /api/klaviyo/events/projection
|
||||||
router.get('/projection', async (req, res) => {
|
router.get('/projection', async (req, res) => {
|
||||||
|
const startTime = Date.now();
|
||||||
let release;
|
let release;
|
||||||
try {
|
try {
|
||||||
const { timeRange, startDate, endDate, excludeCherryBox } = req.query;
|
const { timeRange, startDate, endDate, excludeCherryBox } = req.query;
|
||||||
const excludeCB = parseBoolParam(excludeCherryBox);
|
const excludeCB = parseBoolParam(excludeCherryBox);
|
||||||
|
console.log(`[PROJECTION] Starting request for timeRange: ${timeRange}`);
|
||||||
|
|
||||||
// Only provide projections for incomplete periods
|
// Only provide projections for incomplete periods
|
||||||
if (!['today', 'thisWeek', 'thisMonth'].includes(timeRange)) {
|
if (!['today', 'thisWeek', 'thisMonth'].includes(timeRange)) {
|
||||||
return res.json({ projectedRevenue: 0, confidence: 0 });
|
return res.json({ projectedRevenue: 0, confidence: 0, method: 'none' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { connection, release: releaseConn } = await getDbConnection();
|
const { connection, release: releaseConn } = await getDbConnection();
|
||||||
release = releaseConn;
|
release = releaseConn;
|
||||||
|
console.log(`[PROJECTION] DB connection obtained in ${Date.now() - startTime}ms`);
|
||||||
|
|
||||||
|
const now = DateTime.now().setZone(TIMEZONE);
|
||||||
|
|
||||||
// Get current period data
|
// Get current period data
|
||||||
const { whereClause, params } = getTimeRangeConditions(timeRange, startDate, endDate);
|
const { whereClause, params } = getTimeRangeConditions(timeRange, startDate, endDate);
|
||||||
@@ -646,23 +849,42 @@ router.get('/projection', async (req, res) => {
|
|||||||
|
|
||||||
const [currentResult] = await connection.execute(currentQuery, params);
|
const [currentResult] = await connection.execute(currentQuery, params);
|
||||||
const current = currentResult[0];
|
const current = currentResult[0];
|
||||||
|
console.log(`[PROJECTION] Current period data fetched in ${Date.now() - startTime}ms`);
|
||||||
|
|
||||||
// Get historical data for the same period type
|
// Fetch pattern data in parallel for performance
|
||||||
const historicalQuery = await getHistoricalProjectionData(connection, timeRange, excludeCB);
|
const patternStart = Date.now();
|
||||||
|
const [hourlyPattern, dayOfWeekPattern, dailyStats] = await Promise.all([
|
||||||
|
getHourlyRevenuePattern(connection, excludeCB),
|
||||||
|
getDayOfWeekRevenuePattern(connection, excludeCB),
|
||||||
|
getAverageDailyRevenue(connection, excludeCB)
|
||||||
|
]);
|
||||||
|
console.log(`[PROJECTION] Pattern data fetched in ${Date.now() - patternStart}ms`);
|
||||||
|
|
||||||
// Calculate projection based on current progress and historical patterns
|
// Calculate period progress (for logging/debugging)
|
||||||
const periodProgress = calculatePeriodProgress(timeRange);
|
const periodProgress = calculatePeriodProgress(timeRange);
|
||||||
|
|
||||||
|
// Calculate pattern-based projection
|
||||||
const projection = calculateSmartProjection(
|
const projection = calculateSmartProjection(
|
||||||
|
timeRange,
|
||||||
parseFloat(current.currentRevenue || 0),
|
parseFloat(current.currentRevenue || 0),
|
||||||
parseInt(current.currentOrders || 0),
|
parseInt(current.currentOrders || 0),
|
||||||
periodProgress,
|
periodProgress,
|
||||||
historicalQuery
|
hourlyPattern,
|
||||||
|
dayOfWeekPattern,
|
||||||
|
dailyStats,
|
||||||
|
now
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Add some useful debug info
|
||||||
|
projection.periodProgress = periodProgress;
|
||||||
|
projection.currentRevenue = parseFloat(current.currentRevenue || 0);
|
||||||
|
projection.currentOrders = parseInt(current.currentOrders || 0);
|
||||||
|
|
||||||
|
console.log(`[PROJECTION] Request completed in ${Date.now() - startTime}ms - method: ${projection.method}, projected: $${projection.projectedRevenue?.toFixed(2)}`);
|
||||||
res.json(projection);
|
res.json(projection);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in /projection:', error);
|
console.error(`[PROJECTION] Error after ${Date.now() - startTime}ms:`, error);
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
} finally {
|
} finally {
|
||||||
// Release connection back to pool
|
// Release connection back to pool
|
||||||
@@ -725,7 +947,7 @@ function processShippingData(shippingResult, totalShipped) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
locations: {
|
locations: {
|
||||||
total: totalShipped,
|
total: Object.keys(states).length, // Count of unique states/regions shipped to
|
||||||
byCountry: Object.entries(countries)
|
byCountry: Object.entries(countries)
|
||||||
.map(([country, count]) => ({
|
.map(([country, count]) => ({
|
||||||
country,
|
country,
|
||||||
@@ -1049,40 +1271,491 @@ function getPreviousTimeRange(timeRange) {
|
|||||||
return map[timeRange] || timeRange;
|
return map[timeRange] || timeRange;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getHistoricalProjectionData(connection, timeRange, excludeCherryBox = false) {
|
/**
|
||||||
// Get historical data for projection calculations (optionally excludes Cherry Box orders)
|
* Get hourly revenue distribution pattern from last 8 weeks (same day of week)
|
||||||
// This is a simplified version - you could make this more sophisticated
|
* Returns array of 24 objects with hour and avgShare (0-1 representing % of daily revenue)
|
||||||
const historicalQuery = `
|
* Optimized: Uses JOIN instead of correlated subquery for O(n) instead of O(n²)
|
||||||
|
*/
|
||||||
|
async function getHourlyRevenuePattern(connection, excludeCherryBox = false) {
|
||||||
|
const now = DateTime.now().setZone(TIMEZONE);
|
||||||
|
const dayOfWeek = now.weekday; // 1=Monday, 7=Sunday (Luxon)
|
||||||
|
const mysqlDayOfWeek = dayOfWeek === 7 ? 1 : dayOfWeek + 1;
|
||||||
|
|
||||||
|
// Step 1: Get daily totals and hourly breakdowns in one efficient query
|
||||||
|
const query = `
|
||||||
SELECT
|
SELECT
|
||||||
SUM(summary_total) as revenue,
|
hourly.hour_of_day,
|
||||||
COUNT(*) as orders
|
AVG(hourly.hour_revenue / daily.daily_revenue) as avgShare
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
DATE(date_placed) as order_date,
|
||||||
|
HOUR(date_placed) as hour_of_day,
|
||||||
|
SUM(summary_total) as hour_revenue
|
||||||
|
FROM _order
|
||||||
|
WHERE order_status > 15
|
||||||
|
AND ${getCherryBoxClause(excludeCherryBox)}
|
||||||
|
AND date_placed >= DATE_SUB(NOW(), INTERVAL 8 WEEK)
|
||||||
|
AND date_placed < DATE(NOW())
|
||||||
|
AND DAYOFWEEK(date_placed) = ?
|
||||||
|
GROUP BY DATE(date_placed), HOUR(date_placed)
|
||||||
|
) hourly
|
||||||
|
JOIN (
|
||||||
|
SELECT
|
||||||
|
DATE(date_placed) as order_date,
|
||||||
|
SUM(summary_total) as daily_revenue
|
||||||
|
FROM _order
|
||||||
|
WHERE order_status > 15
|
||||||
|
AND ${getCherryBoxClause(excludeCherryBox)}
|
||||||
|
AND date_placed >= DATE_SUB(NOW(), INTERVAL 8 WEEK)
|
||||||
|
AND date_placed < DATE(NOW())
|
||||||
|
AND DAYOFWEEK(date_placed) = ?
|
||||||
|
GROUP BY DATE(date_placed)
|
||||||
|
HAVING daily_revenue > 0
|
||||||
|
) daily ON hourly.order_date = daily.order_date
|
||||||
|
GROUP BY hourly.hour_of_day
|
||||||
|
ORDER BY hourly.hour_of_day
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [result] = await connection.execute(query, [mysqlDayOfWeek, mysqlDayOfWeek]);
|
||||||
|
|
||||||
|
// Convert to a full 24-hour array, filling gaps with 0
|
||||||
|
const hourlyPattern = Array(24).fill(0).map((_, i) => ({ hour: i, avgShare: 0 }));
|
||||||
|
result.forEach(row => {
|
||||||
|
hourlyPattern[row.hour_of_day] = {
|
||||||
|
hour: row.hour_of_day,
|
||||||
|
avgShare: parseFloat(row.avgShare) || 0
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Normalize so shares sum to 1.0
|
||||||
|
const totalShare = hourlyPattern.reduce((sum, h) => sum + h.avgShare, 0);
|
||||||
|
if (totalShare > 0) {
|
||||||
|
hourlyPattern.forEach(h => h.avgShare = h.avgShare / totalShare);
|
||||||
|
}
|
||||||
|
|
||||||
|
return hourlyPattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get day-of-week revenue distribution pattern from last 8 weeks
|
||||||
|
* Returns array of 7 objects with dayOfWeek (1-7, Sunday=1) and avgShare
|
||||||
|
* Optimized: Uses JOIN instead of correlated subquery
|
||||||
|
*/
|
||||||
|
async function getDayOfWeekRevenuePattern(connection, excludeCherryBox = false) {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
daily.day_of_week,
|
||||||
|
AVG(daily.day_revenue / weekly.weekly_revenue) as avgShare
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
YEARWEEK(date_placed, 0) as year_week,
|
||||||
|
DAYOFWEEK(date_placed) as day_of_week,
|
||||||
|
SUM(summary_total) as day_revenue
|
||||||
|
FROM _order
|
||||||
|
WHERE order_status > 15
|
||||||
|
AND ${getCherryBoxClause(excludeCherryBox)}
|
||||||
|
AND date_placed >= DATE_SUB(NOW(), INTERVAL 8 WEEK)
|
||||||
|
AND date_placed < DATE(NOW())
|
||||||
|
GROUP BY YEARWEEK(date_placed, 0), DAYOFWEEK(date_placed)
|
||||||
|
) daily
|
||||||
|
JOIN (
|
||||||
|
SELECT
|
||||||
|
YEARWEEK(date_placed, 0) as year_week,
|
||||||
|
SUM(summary_total) as weekly_revenue
|
||||||
|
FROM _order
|
||||||
|
WHERE order_status > 15
|
||||||
|
AND ${getCherryBoxClause(excludeCherryBox)}
|
||||||
|
AND date_placed >= DATE_SUB(NOW(), INTERVAL 8 WEEK)
|
||||||
|
AND date_placed < DATE(NOW())
|
||||||
|
GROUP BY YEARWEEK(date_placed, 0)
|
||||||
|
HAVING weekly_revenue > 0
|
||||||
|
) weekly ON daily.year_week = weekly.year_week
|
||||||
|
GROUP BY daily.day_of_week
|
||||||
|
ORDER BY daily.day_of_week
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [result] = await connection.execute(query);
|
||||||
|
|
||||||
|
// Convert to array indexed by MySQL day of week (1=Sunday, 2=Monday, etc.)
|
||||||
|
const weekPattern = Array(8).fill(0).map((_, i) => ({ dayOfWeek: i, avgShare: 0 }));
|
||||||
|
result.forEach(row => {
|
||||||
|
weekPattern[row.day_of_week] = {
|
||||||
|
dayOfWeek: row.day_of_week,
|
||||||
|
avgShare: parseFloat(row.avgShare) || 0
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Normalize (indices 1-7 are used, 0 is unused)
|
||||||
|
const totalShare = weekPattern.slice(1).reduce((sum, d) => sum + d.avgShare, 0);
|
||||||
|
if (totalShare > 0) {
|
||||||
|
weekPattern.forEach(d => { if (d.dayOfWeek > 0) d.avgShare = d.avgShare / totalShare; });
|
||||||
|
}
|
||||||
|
|
||||||
|
return weekPattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get average daily revenue for projection (last 30 days, excluding today)
|
||||||
|
* Also gets same-day-of-week stats for more accurate confidence calculation
|
||||||
|
*/
|
||||||
|
async function getAverageDailyRevenue(connection, excludeCherryBox = false) {
|
||||||
|
const now = DateTime.now().setZone(TIMEZONE);
|
||||||
|
const mysqlDayOfWeek = now.weekday === 7 ? 1 : now.weekday + 1;
|
||||||
|
|
||||||
|
// Get both overall 30-day stats AND same-day-of-week stats
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
AVG(daily_revenue) as avgDailyRevenue,
|
||||||
|
STDDEV(daily_revenue) as stdDev,
|
||||||
|
COUNT(*) as dayCount,
|
||||||
|
(
|
||||||
|
SELECT AVG(day_rev) FROM (
|
||||||
|
SELECT SUM(summary_total) as day_rev
|
||||||
|
FROM _order
|
||||||
|
WHERE order_status > 15
|
||||||
|
AND ${getCherryBoxClause(excludeCherryBox)}
|
||||||
|
AND date_placed >= DATE_SUB(NOW(), INTERVAL 8 WEEK)
|
||||||
|
AND date_placed < DATE(NOW())
|
||||||
|
AND DAYOFWEEK(date_placed) = ${mysqlDayOfWeek}
|
||||||
|
GROUP BY DATE(date_placed)
|
||||||
|
) same_day
|
||||||
|
) as sameDayAvg,
|
||||||
|
(
|
||||||
|
SELECT STDDEV(day_rev) FROM (
|
||||||
|
SELECT SUM(summary_total) as day_rev
|
||||||
|
FROM _order
|
||||||
|
WHERE order_status > 15
|
||||||
|
AND ${getCherryBoxClause(excludeCherryBox)}
|
||||||
|
AND date_placed >= DATE_SUB(NOW(), INTERVAL 8 WEEK)
|
||||||
|
AND date_placed < DATE(NOW())
|
||||||
|
AND DAYOFWEEK(date_placed) = ${mysqlDayOfWeek}
|
||||||
|
GROUP BY DATE(date_placed)
|
||||||
|
) same_day_std
|
||||||
|
) as sameDayStdDev,
|
||||||
|
(
|
||||||
|
SELECT COUNT(*) FROM (
|
||||||
|
SELECT DATE(date_placed) as d
|
||||||
|
FROM _order
|
||||||
|
WHERE order_status > 15
|
||||||
|
AND ${getCherryBoxClause(excludeCherryBox)}
|
||||||
|
AND date_placed >= DATE_SUB(NOW(), INTERVAL 8 WEEK)
|
||||||
|
AND date_placed < DATE(NOW())
|
||||||
|
AND DAYOFWEEK(date_placed) = ${mysqlDayOfWeek}
|
||||||
|
GROUP BY DATE(date_placed)
|
||||||
|
) same_day_count
|
||||||
|
) as sameDayCount
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
DATE(date_placed) as order_date,
|
||||||
|
SUM(summary_total) as daily_revenue
|
||||||
FROM _order
|
FROM _order
|
||||||
WHERE order_status > 15
|
WHERE order_status > 15
|
||||||
AND ${getCherryBoxClause(excludeCherryBox)}
|
AND ${getCherryBoxClause(excludeCherryBox)}
|
||||||
AND date_placed >= DATE_SUB(NOW(), INTERVAL 30 DAY)
|
AND date_placed >= DATE_SUB(NOW(), INTERVAL 30 DAY)
|
||||||
AND date_placed < DATE_SUB(NOW(), INTERVAL 1 DAY)
|
AND date_placed < DATE(NOW())
|
||||||
|
GROUP BY DATE(date_placed)
|
||||||
|
) daily_totals
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const [result] = await connection.execute(historicalQuery);
|
const [result] = await connection.execute(query);
|
||||||
return result;
|
const row = result[0] || {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
avgDailyRevenue: parseFloat(row.avgDailyRevenue) || 0,
|
||||||
|
stdDev: parseFloat(row.stdDev) || 0,
|
||||||
|
dayCount: parseInt(row.dayCount) || 0,
|
||||||
|
sameDayAvg: parseFloat(row.sameDayAvg) || 0,
|
||||||
|
sameDayStdDev: parseFloat(row.sameDayStdDev) || 0,
|
||||||
|
sameDayCount: parseInt(row.sameDayCount) || 0
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function calculateSmartProjection(currentRevenue, currentOrders, periodProgress, historicalData) {
|
/**
|
||||||
|
* Calculate meaningful confidence score based on multiple factors
|
||||||
|
* Returns score between 0-1 and breakdown of contributing factors
|
||||||
|
*/
|
||||||
|
function calculateConfidence({
|
||||||
|
expectedProgress,
|
||||||
|
currentRevenue,
|
||||||
|
patternProjection,
|
||||||
|
historicalDailyAvg,
|
||||||
|
sameDayStdDev,
|
||||||
|
sameDayCount,
|
||||||
|
stdDev,
|
||||||
|
dayCount
|
||||||
|
}) {
|
||||||
|
const factors = {};
|
||||||
|
|
||||||
|
// Factor 1: Time Progress (0-0.3)
|
||||||
|
// More time elapsed = more data = higher confidence
|
||||||
|
// Scales from 0 at 0% to 0.3 at 100%
|
||||||
|
factors.timeProgress = Math.min(0.3, expectedProgress * 0.35);
|
||||||
|
|
||||||
|
// Factor 2: Historical Predictability via Coefficient of Variation (0-0.35)
|
||||||
|
// CV = stdDev / mean - lower is more predictable
|
||||||
|
// Use same-day-of-week stats if available (more relevant)
|
||||||
|
const relevantStdDev = sameDayStdDev || stdDev || 0;
|
||||||
|
const relevantAvg = historicalDailyAvg || 1;
|
||||||
|
const cv = relevantStdDev / relevantAvg;
|
||||||
|
|
||||||
|
// CV of 0.1 (10% variation) = very predictable = full points
|
||||||
|
// CV of 0.5 (50% variation) = unpredictable = minimal points
|
||||||
|
// Scale: CV 0.1 -> 0.35, CV 0.3 -> 0.15, CV 0.5+ -> 0.05
|
||||||
|
if (cv <= 0.1) {
|
||||||
|
factors.predictability = 0.35;
|
||||||
|
} else if (cv <= 0.5) {
|
||||||
|
factors.predictability = Math.max(0.05, 0.35 - (cv - 0.1) * 0.75);
|
||||||
|
} else {
|
||||||
|
factors.predictability = 0.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Factor 3: Tracking Accuracy (0-0.25)
|
||||||
|
// How well is today tracking the expected pattern?
|
||||||
|
// If we're at 40% progress with 38-42% of expected revenue, that's good
|
||||||
|
if (expectedProgress > 0.05 && historicalDailyAvg > 0) {
|
||||||
|
const expectedRevenueSoFar = historicalDailyAvg * expectedProgress;
|
||||||
|
const trackingRatio = currentRevenue / expectedRevenueSoFar;
|
||||||
|
|
||||||
|
// Perfect tracking (ratio = 1.0) = full points
|
||||||
|
// 20% off (ratio 0.8 or 1.2) = partial points
|
||||||
|
// 50%+ off = minimal points
|
||||||
|
const deviation = Math.abs(1 - trackingRatio);
|
||||||
|
if (deviation <= 0.1) {
|
||||||
|
factors.tracking = 0.25;
|
||||||
|
} else if (deviation <= 0.3) {
|
||||||
|
factors.tracking = 0.25 - (deviation - 0.1) * 0.5;
|
||||||
|
} else if (deviation <= 0.5) {
|
||||||
|
factors.tracking = 0.15 - (deviation - 0.3) * 0.4;
|
||||||
|
} else {
|
||||||
|
factors.tracking = 0.05;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Not enough progress to judge tracking
|
||||||
|
factors.tracking = 0.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Factor 4: Data Quality (0-0.1)
|
||||||
|
// More historical data points = more reliable pattern
|
||||||
|
const dataPoints = sameDayCount || Math.floor(dayCount / 7) || 0;
|
||||||
|
// 8 weeks of same-day data = full points, less = proportionally less
|
||||||
|
factors.dataQuality = Math.min(0.1, (dataPoints / 8) * 0.1);
|
||||||
|
|
||||||
|
// Calculate total confidence score
|
||||||
|
const score = Math.min(0.95, Math.max(0.1,
|
||||||
|
factors.timeProgress +
|
||||||
|
factors.predictability +
|
||||||
|
factors.tracking +
|
||||||
|
factors.dataQuality
|
||||||
|
));
|
||||||
|
|
||||||
|
return { score, factors };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate pattern-based projection for different time ranges
|
||||||
|
*/
|
||||||
|
function calculateSmartProjection(
|
||||||
|
timeRange,
|
||||||
|
currentRevenue,
|
||||||
|
currentOrders,
|
||||||
|
periodProgress,
|
||||||
|
hourlyPattern,
|
||||||
|
dayOfWeekPattern,
|
||||||
|
dailyStats,
|
||||||
|
now
|
||||||
|
) {
|
||||||
if (periodProgress >= 100) {
|
if (periodProgress >= 100) {
|
||||||
return { projectedRevenue: currentRevenue, projectedOrders: currentOrders, confidence: 1.0 };
|
return { projectedRevenue: currentRevenue, projectedOrders: currentOrders, confidence: 1.0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simple linear projection with confidence based on how much of the period has elapsed
|
const currentHour = now.hour;
|
||||||
const projectedRevenue = currentRevenue / (periodProgress / 100);
|
const currentDayOfWeek = now.weekday === 7 ? 1 : now.weekday + 1; // Convert to MySQL day (1=Sunday)
|
||||||
const projectedOrders = Math.round(currentOrders / (periodProgress / 100));
|
|
||||||
|
|
||||||
// Confidence increases with more data (higher period progress)
|
if (timeRange === 'today') {
|
||||||
const confidence = Math.min(0.95, Math.max(0.1, periodProgress / 100));
|
// Calculate expected progress based on hourly pattern
|
||||||
|
// Sum up shares for all hours up to and including current hour
|
||||||
|
let expectedProgress = 0;
|
||||||
|
for (let h = 0; h <= currentHour; h++) {
|
||||||
|
expectedProgress += hourlyPattern[h]?.avgShare || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust for partial hour (how far through current hour we are)
|
||||||
|
const minuteProgress = now.minute / 60;
|
||||||
|
const currentHourShare = hourlyPattern[currentHour]?.avgShare || 0;
|
||||||
|
expectedProgress = expectedProgress - currentHourShare + (currentHourShare * minuteProgress);
|
||||||
|
|
||||||
|
// Avoid division by zero and handle edge cases
|
||||||
|
if (expectedProgress <= 0.01) {
|
||||||
|
// Very early in day, use linear projection with low confidence
|
||||||
|
const linearProjection = currentRevenue / Math.max(periodProgress / 100, 0.01);
|
||||||
|
return {
|
||||||
|
projectedRevenue: linearProjection,
|
||||||
|
projectedOrders: Math.round(currentOrders / Math.max(periodProgress / 100, 0.01)),
|
||||||
|
confidence: 0.1,
|
||||||
|
method: 'linear_fallback'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const patternProjection = currentRevenue / expectedProgress;
|
||||||
|
|
||||||
|
// Blend with historical average for stability early in day
|
||||||
|
// Use same-day-of-week average if available, otherwise fall back to overall average
|
||||||
|
const historicalDailyAvg = dailyStats.sameDayAvg || dailyStats.avgDailyRevenue || patternProjection;
|
||||||
|
const actualWeight = Math.pow(expectedProgress, 0.8); // More weight to actual as day progresses
|
||||||
|
const projectedRevenue = (patternProjection * actualWeight) + (historicalDailyAvg * (1 - actualWeight));
|
||||||
|
|
||||||
|
// Calculate meaningful confidence based on multiple factors
|
||||||
|
const confidence = calculateConfidence({
|
||||||
|
expectedProgress,
|
||||||
|
currentRevenue,
|
||||||
|
patternProjection,
|
||||||
|
historicalDailyAvg,
|
||||||
|
sameDayStdDev: dailyStats.sameDayStdDev,
|
||||||
|
sameDayCount: dailyStats.sameDayCount,
|
||||||
|
stdDev: dailyStats.stdDev,
|
||||||
|
dayCount: dailyStats.dayCount
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
projectedRevenue,
|
projectedRevenue,
|
||||||
projectedOrders,
|
projectedOrders: Math.round(currentOrders / expectedProgress),
|
||||||
confidence
|
confidence: confidence.score,
|
||||||
|
confidenceFactors: confidence.factors,
|
||||||
|
method: 'hourly_pattern',
|
||||||
|
debug: { expectedProgress, actualWeight, patternProjection, historicalDailyAvg }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timeRange === 'thisWeek') {
|
||||||
|
// Calculate revenue expected so far this week based on day-of-week pattern
|
||||||
|
// And project remaining days
|
||||||
|
|
||||||
|
// Days completed so far (Sunday = day 1 in MySQL)
|
||||||
|
// If today is Tuesday (MySQL day 3), completed days are Sunday(1) and Monday(2)
|
||||||
|
let expectedProgressSoFar = 0;
|
||||||
|
for (let d = 1; d < currentDayOfWeek; d++) {
|
||||||
|
expectedProgressSoFar += dayOfWeekPattern[d]?.avgShare || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add partial progress through today using hourly pattern
|
||||||
|
let todayExpectedProgress = 0;
|
||||||
|
for (let h = 0; h <= currentHour; h++) {
|
||||||
|
todayExpectedProgress += hourlyPattern[h]?.avgShare || 0;
|
||||||
|
}
|
||||||
|
const minuteProgress = now.minute / 60;
|
||||||
|
const currentHourShare = hourlyPattern[currentHour]?.avgShare || 0;
|
||||||
|
todayExpectedProgress = todayExpectedProgress - currentHourShare + (currentHourShare * minuteProgress);
|
||||||
|
|
||||||
|
// Add today's partial contribution
|
||||||
|
const todayFullShare = dayOfWeekPattern[currentDayOfWeek]?.avgShare || (1/7);
|
||||||
|
expectedProgressSoFar += todayFullShare * todayExpectedProgress;
|
||||||
|
|
||||||
|
// Avoid division by zero
|
||||||
|
if (expectedProgressSoFar <= 0.01) {
|
||||||
|
return {
|
||||||
|
projectedRevenue: currentRevenue / Math.max(periodProgress / 100, 0.01),
|
||||||
|
projectedOrders: Math.round(currentOrders / Math.max(periodProgress / 100, 0.01)),
|
||||||
|
confidence: 0.1,
|
||||||
|
method: 'linear_fallback'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectedWeekRevenue = currentRevenue / expectedProgressSoFar;
|
||||||
|
const projectedWeekOrders = Math.round(currentOrders / expectedProgressSoFar);
|
||||||
|
|
||||||
|
// Calculate meaningful confidence
|
||||||
|
const historicalWeeklyAvg = dailyStats.avgDailyRevenue * 7;
|
||||||
|
const confidence = calculateConfidence({
|
||||||
|
expectedProgress: expectedProgressSoFar,
|
||||||
|
currentRevenue,
|
||||||
|
patternProjection: projectedWeekRevenue,
|
||||||
|
historicalDailyAvg: historicalWeeklyAvg,
|
||||||
|
sameDayStdDev: dailyStats.stdDev * Math.sqrt(7), // Approximate weekly stdDev
|
||||||
|
sameDayCount: Math.floor(dailyStats.dayCount / 7),
|
||||||
|
stdDev: dailyStats.stdDev * Math.sqrt(7),
|
||||||
|
dayCount: dailyStats.dayCount
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
projectedRevenue: projectedWeekRevenue,
|
||||||
|
projectedOrders: projectedWeekOrders,
|
||||||
|
confidence: confidence.score,
|
||||||
|
confidenceFactors: confidence.factors,
|
||||||
|
method: 'weekly_pattern',
|
||||||
|
debug: { expectedProgressSoFar, currentDayOfWeek, todayExpectedProgress }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timeRange === 'thisMonth') {
|
||||||
|
// For month projection, use days elapsed and average daily revenue
|
||||||
|
const currentDay = now.day;
|
||||||
|
const daysInMonth = now.daysInMonth;
|
||||||
|
|
||||||
|
// Calculate average daily revenue so far this month
|
||||||
|
const daysElapsed = currentDay - 1; // Full days completed
|
||||||
|
|
||||||
|
// Add partial progress through today
|
||||||
|
let todayExpectedProgress = 0;
|
||||||
|
for (let h = 0; h <= currentHour; h++) {
|
||||||
|
todayExpectedProgress += hourlyPattern[h]?.avgShare || 0;
|
||||||
|
}
|
||||||
|
const minuteProgress = now.minute / 60;
|
||||||
|
const currentHourShare = hourlyPattern[currentHour]?.avgShare || 0;
|
||||||
|
todayExpectedProgress = todayExpectedProgress - currentHourShare + (currentHourShare * minuteProgress);
|
||||||
|
|
||||||
|
const effectiveDaysElapsed = daysElapsed + todayExpectedProgress;
|
||||||
|
|
||||||
|
if (effectiveDaysElapsed <= 0.1) {
|
||||||
|
// Very early in month, use historical average
|
||||||
|
const projectedRevenue = dailyStats.avgDailyRevenue * daysInMonth;
|
||||||
|
return {
|
||||||
|
projectedRevenue,
|
||||||
|
projectedOrders: Math.round(currentOrders / Math.max(periodProgress / 100, 0.01)),
|
||||||
|
confidence: 0.15,
|
||||||
|
method: 'historical_monthly'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate implied daily rate from current data
|
||||||
|
const impliedDailyRate = currentRevenue / effectiveDaysElapsed;
|
||||||
|
|
||||||
|
// Blend with historical average (more weight to actual data as month progresses)
|
||||||
|
const actualWeight = Math.min(0.9, effectiveDaysElapsed / 10); // Full weight after ~10 days
|
||||||
|
const blendedDailyRate = (impliedDailyRate * actualWeight) + (dailyStats.avgDailyRevenue * (1 - actualWeight));
|
||||||
|
|
||||||
|
const projectedMonthRevenue = blendedDailyRate * daysInMonth;
|
||||||
|
const projectedMonthOrders = Math.round((currentOrders / effectiveDaysElapsed) * daysInMonth);
|
||||||
|
|
||||||
|
// Calculate meaningful confidence
|
||||||
|
const historicalMonthlyAvg = dailyStats.avgDailyRevenue * daysInMonth;
|
||||||
|
const confidence = calculateConfidence({
|
||||||
|
expectedProgress: effectiveDaysElapsed / daysInMonth,
|
||||||
|
currentRevenue,
|
||||||
|
patternProjection: projectedMonthRevenue,
|
||||||
|
historicalDailyAvg: historicalMonthlyAvg,
|
||||||
|
sameDayStdDev: dailyStats.stdDev * Math.sqrt(daysInMonth),
|
||||||
|
sameDayCount: 1, // Only ~1 month of same-month data typically
|
||||||
|
stdDev: dailyStats.stdDev * Math.sqrt(daysInMonth),
|
||||||
|
dayCount: dailyStats.dayCount
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
projectedRevenue: projectedMonthRevenue,
|
||||||
|
projectedOrders: projectedMonthOrders,
|
||||||
|
confidence: confidence.score,
|
||||||
|
confidenceFactors: confidence.factors,
|
||||||
|
method: 'monthly_blend',
|
||||||
|
debug: { effectiveDaysElapsed, daysInMonth, impliedDailyRate, blendedDailyRate }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for any other case
|
||||||
|
const linearProjection = currentRevenue / (periodProgress / 100);
|
||||||
|
return {
|
||||||
|
projectedRevenue: linearProjection,
|
||||||
|
projectedOrders: Math.round(currentOrders / (periodProgress / 100)),
|
||||||
|
confidence: Math.min(0.95, Math.max(0.1, periodProgress / 100)),
|
||||||
|
method: 'linear_fallback'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import {
|
import {
|
||||||
Package,
|
Package,
|
||||||
Truck,
|
Truck,
|
||||||
@@ -16,7 +15,6 @@ import {
|
|||||||
XCircle,
|
XCircle,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
Activity,
|
Activity,
|
||||||
AlertCircle,
|
|
||||||
FileText,
|
FileText,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
@@ -66,7 +64,7 @@ const EVENT_TYPES = {
|
|||||||
gradient: "from-red-800 to-red-700",
|
gradient: "from-red-800 to-red-700",
|
||||||
},
|
},
|
||||||
[METRIC_IDS.PAYMENT_REFUNDED]: {
|
[METRIC_IDS.PAYMENT_REFUNDED]: {
|
||||||
label: "Payment Refunded",
|
label: "Payment Refund",
|
||||||
color: "bg-orange-200",
|
color: "bg-orange-200",
|
||||||
textColor: "text-orange-50",
|
textColor: "text-orange-50",
|
||||||
iconColor: "text-orange-800",
|
iconColor: "text-orange-800",
|
||||||
@@ -94,22 +92,22 @@ const EVENT_ICONS = {
|
|||||||
const LoadingState = () => (
|
const LoadingState = () => (
|
||||||
<div className="flex gap-3 px-4">
|
<div className="flex gap-3 px-4">
|
||||||
{[...Array(6)].map((_, i) => (
|
{[...Array(6)].map((_, i) => (
|
||||||
<Card key={i} className="w-[210px] h-[125px] shrink-0 bg-gradient-to-br from-gray-900 to-gray-800 backdrop-blur-sm">
|
<Card key={i} className="w-[210px] h-[125px] shrink-0 bg-gradient-to-br from-gray-900 to-gray-800 backdrop-blur-md border-white/10">
|
||||||
<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">
|
||||||
<Skeleton className="h-4 w-20 bg-gray-700" />
|
<Skeleton className="h-3 w-20 bg-white/20" />
|
||||||
<Skeleton className="h-3 w-14 bg-gray-700" />
|
<Skeleton className="h-3 w-14 bg-white/20" />
|
||||||
</div>
|
</div>
|
||||||
<div className="relative p-2">
|
<div className="relative p-2">
|
||||||
<div className="absolute inset-0 rounded-full bg-gray-300" />
|
<div className="absolute inset-0 rounded-full bg-white/20" />
|
||||||
<Skeleton className="h-4 w-4 bg-gray-700 relative rounded-full" />
|
<Skeleton className="h-4 w-4 bg-white/10 relative rounded-full" />
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-3 pt-1">
|
<CardContent className="p-3 pt-1">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Skeleton className="h-7 w-36 bg-gray-700" />
|
<Skeleton className="h-7 w-36 bg-white/20" />
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Skeleton className="h-4 w-28 bg-gray-700" />
|
<Skeleton className="h-4 w-28 bg-white/10" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -120,12 +118,12 @@ const LoadingState = () => (
|
|||||||
|
|
||||||
// Empty State Component
|
// Empty State Component
|
||||||
const EmptyState = () => (
|
const EmptyState = () => (
|
||||||
<Card className="w-[210px] h-[125px] bg-gradient-to-br from-gray-900 to-gray-800 backdrop-blur-sm">
|
<Card className="w-[210px] h-[125px] bg-gradient-to-br from-gray-900 to-gray-800 backdrop-blur-md border-white/10">
|
||||||
<CardContent className="flex flex-col items-center justify-center h-full text-center p-4">
|
<CardContent className="flex flex-col items-center justify-center h-full text-center p-4">
|
||||||
<div className="bg-gray-800 rounded-full p-2 mb-2">
|
<div className="bg-white/10 rounded-full p-2 mb-2">
|
||||||
<Activity className="h-4 w-4 text-gray-400" />
|
<Activity className="h-4 w-4 text-gray-300" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-400 font-medium">
|
<p className="text-xs font-medium text-gray-300 uppercase tracking-wide">
|
||||||
No recent activity
|
No recent activity
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -141,14 +139,14 @@ const EventCard = ({ event }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<EventDialog event={event}>
|
<EventDialog event={event}>
|
||||||
<Card className={`w-[210px] border-none shrink-0 hover:brightness-110 cursor-pointer transition-colors h-[125px] bg-gradient-to-br ${eventType.gradient} backdrop-blur-sm`}>
|
<Card className={`w-[230px] border-white/10 shrink-0 hover:brightness-110 cursor-pointer transition-all h-[125px] bg-gradient-to-br ${eventType.gradient} backdrop-blur-md`}>
|
||||||
<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">
|
||||||
<CardTitle className={`text-sm font-bold ${eventType.textColor}`}>
|
<CardTitle className={`text-xs font-medium ${eventType.textColor} uppercase tracking-wide`}>
|
||||||
{eventType.label}
|
{eventType.label}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
{event.datetime && (
|
{event.datetime && (
|
||||||
<CardDescription className={`text-xs ${eventType.textColor} opacity-80`}>
|
<CardDescription className={`text-xs ${eventType.textColor} opacity-70`}>
|
||||||
{format(new Date(event.datetime), "h:mm a")}
|
{format(new Date(event.datetime), "h:mm a")}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ const MiniRealtimeAnalytics = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading && !basicData.byMinute?.length) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="grid grid-cols-2 gap-2 mt-1 mb-2">
|
<div className="grid grid-cols-2 gap-2 mt-1 mb-2">
|
||||||
@@ -141,18 +141,18 @@ const MiniRealtimeAnalytics = () => {
|
|||||||
<DashboardStatCardMini
|
<DashboardStatCardMini
|
||||||
title="Last 30 Minutes"
|
title="Last 30 Minutes"
|
||||||
value={basicData.last30MinUsers}
|
value={basicData.last30MinUsers}
|
||||||
description="Active users"
|
subtitle="Active users"
|
||||||
gradient="sky"
|
gradient="sky"
|
||||||
icon={Users}
|
icon={Users}
|
||||||
iconBackground="bg-sky-300"
|
iconBackground="bg-sky-400"
|
||||||
/>
|
/>
|
||||||
<DashboardStatCardMini
|
<DashboardStatCardMini
|
||||||
title="Last 5 Minutes"
|
title="Last 5 Minutes"
|
||||||
value={basicData.last5MinUsers}
|
value={basicData.last5MinUsers}
|
||||||
description="Active users"
|
subtitle="Active users"
|
||||||
gradient="sky"
|
gradient="sky"
|
||||||
icon={Activity}
|
icon={Activity}
|
||||||
iconBackground="bg-sky-300"
|
iconBackground="bg-sky-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -140,31 +140,20 @@ const MiniSalesChart = ({ className = "" }) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to calculate trend direction
|
// Helper to calculate trend values (positive = up, negative = down)
|
||||||
const getRevenueTrend = () => {
|
|
||||||
const current = summaryStats.periodProgress < 100
|
|
||||||
? (projection?.projectedRevenue || summaryStats.totalRevenue)
|
|
||||||
: summaryStats.totalRevenue;
|
|
||||||
return current >= summaryStats.prevRevenue ? "up" : "down";
|
|
||||||
};
|
|
||||||
|
|
||||||
const getRevenueTrendValue = () => {
|
const getRevenueTrendValue = () => {
|
||||||
const current = summaryStats.periodProgress < 100
|
const current = summaryStats.periodProgress < 100
|
||||||
? (projection?.projectedRevenue || summaryStats.totalRevenue)
|
? (projection?.projectedRevenue || summaryStats.totalRevenue)
|
||||||
: summaryStats.totalRevenue;
|
: summaryStats.totalRevenue;
|
||||||
return `${Math.abs(Math.round((current - summaryStats.prevRevenue) / summaryStats.prevRevenue * 100))}%`;
|
if (!summaryStats.prevRevenue) return 0;
|
||||||
};
|
return ((current - summaryStats.prevRevenue) / summaryStats.prevRevenue) * 100;
|
||||||
|
|
||||||
const getOrdersTrend = () => {
|
|
||||||
const projected = Math.round(summaryStats.totalOrders * (100 / summaryStats.periodProgress));
|
|
||||||
const current = summaryStats.periodProgress < 100 ? projected : summaryStats.totalOrders;
|
|
||||||
return current >= summaryStats.prevOrders ? "up" : "down";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getOrdersTrendValue = () => {
|
const getOrdersTrendValue = () => {
|
||||||
const projected = Math.round(summaryStats.totalOrders * (100 / summaryStats.periodProgress));
|
const projected = Math.round(summaryStats.totalOrders * (100 / summaryStats.periodProgress));
|
||||||
const current = summaryStats.periodProgress < 100 ? projected : summaryStats.totalOrders;
|
const current = summaryStats.periodProgress < 100 ? projected : summaryStats.totalOrders;
|
||||||
return `${Math.abs(Math.round((current - summaryStats.prevOrders) / summaryStats.prevOrders * 100))}%`;
|
if (!summaryStats.prevOrders) return 0;
|
||||||
|
return ((current - summaryStats.prevOrders) / summaryStats.prevOrders) * 100;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading && !data) {
|
if (loading && !data) {
|
||||||
@@ -190,7 +179,7 @@ const MiniSalesChart = ({ className = "" }) => {
|
|||||||
<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 ? (
|
{loading && !data?.length ? (
|
||||||
<>
|
<>
|
||||||
<DashboardStatCardMiniSkeleton gradient="slate" />
|
<DashboardStatCardMiniSkeleton gradient="slate" />
|
||||||
<DashboardStatCardMiniSkeleton gradient="slate" />
|
<DashboardStatCardMiniSkeleton gradient="slate" />
|
||||||
@@ -200,13 +189,10 @@ const MiniSalesChart = ({ className = "" }) => {
|
|||||||
<DashboardStatCardMini
|
<DashboardStatCardMini
|
||||||
title="30 Days Revenue"
|
title="30 Days Revenue"
|
||||||
value={formatCurrency(summaryStats.totalRevenue, false)}
|
value={formatCurrency(summaryStats.totalRevenue, false)}
|
||||||
description={`Prev: ${formatCurrency(summaryStats.prevRevenue, false)}`}
|
subtitle={`Prev: ${formatCurrency(summaryStats.prevRevenue, false)}`}
|
||||||
trend={{
|
trend={{ value: getRevenueTrendValue() }}
|
||||||
direction: getRevenueTrend(),
|
|
||||||
value: getRevenueTrendValue(),
|
|
||||||
}}
|
|
||||||
icon={PiggyBank}
|
icon={PiggyBank}
|
||||||
iconBackground="bg-emerald-300"
|
iconBackground="bg-emerald-400"
|
||||||
gradient="slate"
|
gradient="slate"
|
||||||
className={!visibleMetrics.revenue ? 'opacity-50' : ''}
|
className={!visibleMetrics.revenue ? 'opacity-50' : ''}
|
||||||
onClick={() => toggleMetric('revenue')}
|
onClick={() => toggleMetric('revenue')}
|
||||||
@@ -214,13 +200,10 @@ const MiniSalesChart = ({ className = "" }) => {
|
|||||||
<DashboardStatCardMini
|
<DashboardStatCardMini
|
||||||
title="30 Days Orders"
|
title="30 Days Orders"
|
||||||
value={summaryStats.totalOrders.toLocaleString()}
|
value={summaryStats.totalOrders.toLocaleString()}
|
||||||
description={`Prev: ${summaryStats.prevOrders.toLocaleString()}`}
|
subtitle={`Prev: ${summaryStats.prevOrders.toLocaleString()}`}
|
||||||
trend={{
|
trend={{ value: getOrdersTrendValue() }}
|
||||||
direction: getOrdersTrend(),
|
|
||||||
value: getOrdersTrendValue(),
|
|
||||||
}}
|
|
||||||
icon={Truck}
|
icon={Truck}
|
||||||
iconBackground="bg-blue-300"
|
iconBackground="bg-blue-400"
|
||||||
gradient="slate"
|
gradient="slate"
|
||||||
className={!visibleMetrics.orders ? 'opacity-50' : ''}
|
className={!visibleMetrics.orders ? 'opacity-50' : ''}
|
||||||
onClick={() => toggleMetric('orders')}
|
onClick={() => toggleMetric('orders')}
|
||||||
@@ -233,7 +216,7 @@ const MiniSalesChart = ({ className = "" }) => {
|
|||||||
<Card className="bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm">
|
<Card className="bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm">
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="h-[216px]">
|
<div className="h-[216px]">
|
||||||
{loading ? (
|
{loading && !data?.length ? (
|
||||||
<ChartSkeleton height="sm" withCard={false} />
|
<ChartSkeleton height="sm" withCard={false} />
|
||||||
) : (
|
) : (
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import {
|
|||||||
ShippingDetails,
|
ShippingDetails,
|
||||||
DetailDialog,
|
DetailDialog,
|
||||||
formatCurrency,
|
formatCurrency,
|
||||||
formatPercentage,
|
|
||||||
} from "./StatCards";
|
} from "./StatCards";
|
||||||
import {
|
import {
|
||||||
DashboardStatCardMini,
|
DashboardStatCardMini,
|
||||||
@@ -112,8 +111,14 @@ const MiniStatCards = ({
|
|||||||
|
|
||||||
const calculateOrderTrend = useCallback(() => {
|
const calculateOrderTrend = useCallback(() => {
|
||||||
if (!stats?.prevPeriodOrders) return null;
|
if (!stats?.prevPeriodOrders) return null;
|
||||||
return calculateTrend(stats.orderCount, stats.prevPeriodOrders);
|
|
||||||
}, [stats, calculateTrend]);
|
// If period is incomplete, use projected orders for fair comparison
|
||||||
|
const currentOrders = stats.periodProgress < 100
|
||||||
|
? (projection?.projectedOrders || Math.round(stats.orderCount / (stats.periodProgress / 100)))
|
||||||
|
: stats.orderCount;
|
||||||
|
|
||||||
|
return calculateTrend(currentOrders, stats.prevPeriodOrders);
|
||||||
|
}, [stats, projection, calculateTrend]);
|
||||||
|
|
||||||
const calculateAOVTrend = useCallback(() => {
|
const calculateAOVTrend = useCallback(() => {
|
||||||
if (!stats?.prevPeriodAOV) return null;
|
if (!stats?.prevPeriodAOV) return null;
|
||||||
@@ -284,18 +289,18 @@ const MiniStatCards = ({
|
|||||||
<DashboardStatCardMini
|
<DashboardStatCardMini
|
||||||
title="Today's Revenue"
|
title="Today's Revenue"
|
||||||
value={formatCurrency(stats?.revenue || 0)}
|
value={formatCurrency(stats?.revenue || 0)}
|
||||||
description={
|
subtitle={
|
||||||
stats?.periodProgress < 100
|
stats?.periodProgress < 100
|
||||||
? `Proj: ${formatCurrency(projection?.projectedRevenue || stats.projectedRevenue)}`
|
? `Proj: ${formatCurrency(projection?.projectedRevenue || stats.projectedRevenue)}`
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
trend={
|
trend={
|
||||||
revenueTrend?.trend && !projectionLoading
|
revenueTrend?.trend && !projectionLoading
|
||||||
? { direction: revenueTrend.trend, value: formatPercentage(revenueTrend.value) }
|
? { value: revenueTrend.trend === "up" ? revenueTrend.value : -revenueTrend.value }
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
icon={DollarSign}
|
icon={DollarSign}
|
||||||
iconBackground="bg-emerald-300"
|
iconBackground="bg-emerald-400"
|
||||||
gradient="emerald"
|
gradient="emerald"
|
||||||
className="h-[150px]"
|
className="h-[150px]"
|
||||||
onClick={() => setSelectedMetric("revenue")}
|
onClick={() => setSelectedMetric("revenue")}
|
||||||
@@ -304,14 +309,16 @@ const MiniStatCards = ({
|
|||||||
<DashboardStatCardMini
|
<DashboardStatCardMini
|
||||||
title="Today's Orders"
|
title="Today's Orders"
|
||||||
value={stats?.orderCount}
|
value={stats?.orderCount}
|
||||||
description={`${stats?.itemCount} total items`}
|
subtitle={`${stats?.itemCount} total items`}
|
||||||
trend={
|
trend={
|
||||||
orderTrend?.trend
|
projectionLoading && stats?.periodProgress < 100
|
||||||
? { direction: orderTrend.trend, value: formatPercentage(orderTrend.value) }
|
? undefined
|
||||||
|
: orderTrend?.trend
|
||||||
|
? { value: orderTrend.trend === "up" ? orderTrend.value : -orderTrend.value }
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
icon={ShoppingCart}
|
icon={ShoppingCart}
|
||||||
iconBackground="bg-blue-300"
|
iconBackground="bg-blue-400"
|
||||||
gradient="blue"
|
gradient="blue"
|
||||||
className="h-[150px]"
|
className="h-[150px]"
|
||||||
onClick={() => setSelectedMetric("orders")}
|
onClick={() => setSelectedMetric("orders")}
|
||||||
@@ -321,14 +328,14 @@ const MiniStatCards = ({
|
|||||||
title="Today's AOV"
|
title="Today's AOV"
|
||||||
value={stats?.averageOrderValue?.toFixed(2)}
|
value={stats?.averageOrderValue?.toFixed(2)}
|
||||||
valuePrefix="$"
|
valuePrefix="$"
|
||||||
description={`${stats?.averageItemsPerOrder?.toFixed(1)} items per order`}
|
subtitle={`${stats?.averageItemsPerOrder?.toFixed(1)} items/order`}
|
||||||
trend={
|
trend={
|
||||||
aovTrend?.trend
|
aovTrend?.trend
|
||||||
? { direction: aovTrend.trend, value: formatPercentage(aovTrend.value) }
|
? { value: aovTrend.trend === "up" ? aovTrend.value : -aovTrend.value }
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
icon={CircleDollarSign}
|
icon={CircleDollarSign}
|
||||||
iconBackground="bg-violet-300"
|
iconBackground="bg-violet-400"
|
||||||
gradient="violet"
|
gradient="violet"
|
||||||
className="h-[150px]"
|
className="h-[150px]"
|
||||||
onClick={() => setSelectedMetric("average_order")}
|
onClick={() => setSelectedMetric("average_order")}
|
||||||
@@ -337,9 +344,9 @@ const MiniStatCards = ({
|
|||||||
<DashboardStatCardMini
|
<DashboardStatCardMini
|
||||||
title="Shipped Today"
|
title="Shipped Today"
|
||||||
value={stats?.shipping?.shippedCount || 0}
|
value={stats?.shipping?.shippedCount || 0}
|
||||||
description={`${stats?.shipping?.locations?.total || 0} locations`}
|
subtitle={`${stats?.shipping?.locations?.total || 0} locations`}
|
||||||
icon={Package}
|
icon={Package}
|
||||||
iconBackground="bg-orange-300"
|
iconBackground="bg-orange-400"
|
||||||
gradient="orange"
|
gradient="orange"
|
||||||
className="h-[150px]"
|
className="h-[150px]"
|
||||||
onClick={() => setSelectedMetric("shipping")}
|
onClick={() => setSelectedMetric("shipping")}
|
||||||
|
|||||||
@@ -237,8 +237,7 @@ const OrdersDetails = ({ data }) => {
|
|||||||
dataKey="orders"
|
dataKey="orders"
|
||||||
name="Orders"
|
name="Orders"
|
||||||
type="bar"
|
type="bar"
|
||||||
color="
|
color="hsl(221.2 83.2% 53.3%)"
|
||||||
"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -376,7 +375,7 @@ const BrandsCategoriesDetails = ({ data }) => {
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody className="max-h-[40vh] md:max-h-[60vh] overflow-auto">
|
<TableBody className="max-h-[40vh] md:max-h-[60vh] overflow-auto">
|
||||||
{brandsList.map((brand) => (
|
{brandsList.map((brand) => (
|
||||||
<TableRow key={brand.name}>
|
<TableRow key={brand.id || brand.name}>
|
||||||
<TableCell className="font-medium">{brand.name}</TableCell>
|
<TableCell className="font-medium">{brand.name}</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
{brand.count?.toLocaleString()}
|
{brand.count?.toLocaleString()}
|
||||||
@@ -407,7 +406,7 @@ const BrandsCategoriesDetails = ({ data }) => {
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody className="max-h-[40vh] md:max-h-[60vh] overflow-auto">
|
<TableBody className="max-h-[40vh] md:max-h-[60vh] overflow-auto">
|
||||||
{categoriesList.map((category) => (
|
{categoriesList.map((category) => (
|
||||||
<TableRow key={category.name}>
|
<TableRow key={category.id || category.name}>
|
||||||
<TableCell className="font-medium">{category.name}</TableCell>
|
<TableCell className="font-medium">{category.name}</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
{category.count?.toLocaleString()}
|
{category.count?.toLocaleString()}
|
||||||
@@ -563,9 +562,9 @@ const OrderTypeDetails = ({ data, type }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const timeSeriesData = data.map((day) => ({
|
const timeSeriesData = data.map((day) => ({
|
||||||
timestamp: day.timestamp,
|
timestamp: day.timestamp || day.date,
|
||||||
count: day.count,
|
count: day.count ?? day.orders, // Backend returns 'orders'
|
||||||
value: day.value,
|
value: day.value ?? day.revenue, // Backend returns 'revenue'
|
||||||
percentage: day.percentage,
|
percentage: day.percentage,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -623,10 +622,11 @@ const PeakHourDetails = ({ data }) => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// hourlyOrders is now an array of {hour, count} objects in chronological order (rolling 24hrs)
|
||||||
const hourlyData =
|
const hourlyData =
|
||||||
data[0]?.hourlyOrders?.map((count, hour) => ({
|
data[0]?.hourlyOrders?.map((item) => ({
|
||||||
timestamp: hour, // Use raw hour number for x-axis
|
timestamp: item.hour, // The actual hour (0-23)
|
||||||
orders: count,
|
orders: item.count,
|
||||||
})) || [];
|
})) || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -996,13 +996,11 @@ const StatCards = ({
|
|||||||
const [lastUpdate, setLastUpdate] = 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 [dateRange, setDateRange] = useState(null);
|
|
||||||
const [detailDataLoading, setDetailDataLoading] = useState({});
|
const [detailDataLoading, setDetailDataLoading] = useState({});
|
||||||
const [detailData, setDetailData] = useState({});
|
const [detailData, setDetailData] = useState({});
|
||||||
const [isInitialLoad, setIsInitialLoad] = useState(true);
|
|
||||||
const [projection, setProjection] = useState(null);
|
const [projection, setProjection] = useState(null);
|
||||||
const [projectionLoading, setProjectionLoading] = useState(false);
|
const [projectionLoading, setProjectionLoading] = useState(false);
|
||||||
const { setCacheData, getCacheData, clearCache } = useDataCache();
|
const { setCacheData, getCacheData } = useDataCache();
|
||||||
|
|
||||||
// Function to determine if we should use last30days for trend charts
|
// Function to determine if we should use last30days for trend charts
|
||||||
const shouldUseLast30Days = useCallback(
|
const shouldUseLast30Days = useCallback(
|
||||||
@@ -1218,8 +1216,14 @@ const StatCards = ({
|
|||||||
|
|
||||||
const calculateOrderTrend = useCallback(() => {
|
const calculateOrderTrend = useCallback(() => {
|
||||||
if (!stats?.prevPeriodOrders) return null;
|
if (!stats?.prevPeriodOrders) return null;
|
||||||
return calculateTrend(stats.orderCount, stats.prevPeriodOrders);
|
|
||||||
}, [stats, calculateTrend]);
|
// If period is incomplete, use projected orders for fair comparison
|
||||||
|
const currentOrders = stats.periodProgress < 100
|
||||||
|
? (projection?.projectedOrders || Math.round(stats.orderCount / (stats.periodProgress / 100)))
|
||||||
|
: stats.orderCount;
|
||||||
|
|
||||||
|
return calculateTrend(currentOrders, stats.prevPeriodOrders);
|
||||||
|
}, [stats, projection, calculateTrend]);
|
||||||
|
|
||||||
const calculateAOVTrend = useCallback(() => {
|
const calculateAOVTrend = useCallback(() => {
|
||||||
if (!stats?.prevPeriodAOV) return null;
|
if (!stats?.prevPeriodAOV) return null;
|
||||||
@@ -1242,7 +1246,6 @@ const StatCards = ({
|
|||||||
|
|
||||||
if (!isMounted) return;
|
if (!isMounted) return;
|
||||||
|
|
||||||
setDateRange(response.timeRange);
|
|
||||||
setStats(response.stats);
|
setStats(response.stats);
|
||||||
setLastUpdate(DateTime.now().setZone("America/New_York"));
|
setLastUpdate(DateTime.now().setZone("America/New_York"));
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -1257,7 +1260,6 @@ const StatCards = ({
|
|||||||
} finally {
|
} finally {
|
||||||
if (isMounted) {
|
if (isMounted) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setIsInitialLoad(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1321,69 +1323,30 @@ const StatCards = ({
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [timeRange]);
|
}, [timeRange]);
|
||||||
|
|
||||||
// Modified AsyncDetailView component
|
// Fetch detail data when a metric is selected (if not already cached)
|
||||||
const AsyncDetailView = memo(({ metric, type, orderCount }) => {
|
useEffect(() => {
|
||||||
const detailTimeRange = shouldUseLast30Days(metric)
|
if (!selectedMetric) return;
|
||||||
|
|
||||||
|
// Skip metrics that use stats directly instead of fetched detail data
|
||||||
|
if (["brands_categories", "shipping", "peak_hour"].includes(selectedMetric)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailTimeRange = shouldUseLast30Days(selectedMetric)
|
||||||
? "last30days"
|
? "last30days"
|
||||||
: timeRange;
|
: timeRange;
|
||||||
const cachedData =
|
const cachedData = detailData[selectedMetric] || getCacheData(detailTimeRange, selectedMetric);
|
||||||
detailData[metric] || getCacheData(detailTimeRange, metric);
|
const isLoading = detailDataLoading[selectedMetric];
|
||||||
const isLoading = detailDataLoading[metric];
|
|
||||||
const isOrderTypeMetric = [
|
|
||||||
"pre_orders",
|
|
||||||
"local_pickup",
|
|
||||||
"on_hold",
|
|
||||||
].includes(metric);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let isMounted = true;
|
|
||||||
|
|
||||||
const loadData = async () => {
|
|
||||||
if (!cachedData && !isLoading) {
|
if (!cachedData && !isLoading) {
|
||||||
// Pass type only for order type metrics
|
const isOrderTypeMetric = ["pre_orders", "local_pickup", "on_hold"].includes(selectedMetric);
|
||||||
const data = await fetchDetailData(
|
fetchDetailData(selectedMetric, isOrderTypeMetric ? selectedMetric : undefined);
|
||||||
metric,
|
|
||||||
isOrderTypeMetric ? metric : undefined
|
|
||||||
);
|
|
||||||
if (!isMounted) return;
|
|
||||||
// The state updates are handled in fetchDetailData
|
|
||||||
}
|
}
|
||||||
};
|
}, [selectedMetric, timeRange, shouldUseLast30Days, detailData, detailDataLoading, getCacheData, fetchDetailData]);
|
||||||
|
|
||||||
loadData();
|
// Modified getDetailComponent to use memoized components
|
||||||
return () => {
|
const getDetailComponent = useCallback(() => {
|
||||||
isMounted = false;
|
if (!selectedMetric || !stats) {
|
||||||
};
|
|
||||||
}, [metric, timeRange, isOrderTypeMetric]); // Depend on isOrderTypeMetric
|
|
||||||
|
|
||||||
if (isLoading || (!cachedData && !error)) {
|
|
||||||
switch (metric) {
|
|
||||||
case "revenue":
|
|
||||||
case "orders":
|
|
||||||
case "average_order":
|
|
||||||
return <ChartSkeleton type="line" height="default" withCard={false} />;
|
|
||||||
case "refunds":
|
|
||||||
case "cancellations":
|
|
||||||
case "order_range":
|
|
||||||
case "pre_orders":
|
|
||||||
case "local_pickup":
|
|
||||||
case "on_hold":
|
|
||||||
return <ChartSkeleton type="bar" height="default" withCard={false} />;
|
|
||||||
case "brands_categories":
|
|
||||||
case "shipping":
|
|
||||||
return <TableSkeleton rows={8} columns={3} />;
|
|
||||||
case "peak_hour":
|
|
||||||
return <ChartSkeleton type="bar" height="default" withCard={false} />;
|
|
||||||
default:
|
|
||||||
return <div className="text-muted-foreground">Loading...</div>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!cachedData && error) {
|
|
||||||
return <DashboardErrorState error={`Failed to load stats: ${error}`} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!cachedData) {
|
|
||||||
return (
|
return (
|
||||||
<DashboardEmptyState
|
<DashboardEmptyState
|
||||||
title="No data available"
|
title="No data available"
|
||||||
@@ -1393,57 +1356,22 @@ const StatCards = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (metric) {
|
|
||||||
case "revenue":
|
|
||||||
return <MemoizedRevenueDetails data={cachedData} />;
|
|
||||||
case "orders":
|
|
||||||
return <MemoizedOrdersDetails data={cachedData} />;
|
|
||||||
case "average_order":
|
|
||||||
return (
|
|
||||||
<MemoizedAverageOrderDetails
|
|
||||||
data={cachedData}
|
|
||||||
orderCount={orderCount}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case "refunds":
|
|
||||||
return <MemoizedRefundDetails data={cachedData} />;
|
|
||||||
case "cancellations":
|
|
||||||
return <MemoizedCancellationsDetails data={cachedData} />;
|
|
||||||
case "order_range":
|
|
||||||
return <MemoizedOrderRangeDetails data={cachedData} />;
|
|
||||||
case "pre_orders":
|
|
||||||
case "local_pickup":
|
|
||||||
case "on_hold":
|
|
||||||
return <MemoizedOrderTypeDetails data={cachedData} type={type} />;
|
|
||||||
default:
|
|
||||||
return (
|
|
||||||
<div className="text-muted-foreground">Invalid metric selected.</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
AsyncDetailView.displayName = "AsyncDetailView";
|
|
||||||
|
|
||||||
// Modified getDetailComponent to use memoized components
|
|
||||||
const getDetailComponent = useCallback(() => {
|
|
||||||
if (!selectedMetric || !stats) {
|
|
||||||
return (
|
|
||||||
<div className="text-muted-foreground">
|
|
||||||
No data available for the selected time range.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = detailData[selectedMetric];
|
const data = detailData[selectedMetric];
|
||||||
const isLoading = detailDataLoading[selectedMetric];
|
const isLoading = detailDataLoading[selectedMetric];
|
||||||
const isOrderTypeMetric = [
|
|
||||||
"pre_orders",
|
|
||||||
"local_pickup",
|
|
||||||
"on_hold",
|
|
||||||
].includes(selectedMetric);
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <ChartSkeleton height="default" withCard={false} />;
|
// Show metric-specific loading skeletons
|
||||||
|
switch (selectedMetric) {
|
||||||
|
case "brands_categories":
|
||||||
|
case "shipping":
|
||||||
|
return <TableSkeleton rows={8} columns={3} />;
|
||||||
|
case "revenue":
|
||||||
|
case "orders":
|
||||||
|
case "average_order":
|
||||||
|
return <ChartSkeleton type="line" height="default" withCard={false} />;
|
||||||
|
default:
|
||||||
|
return <ChartSkeleton type="bar" height="default" withCard={false} />;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (selectedMetric) {
|
switch (selectedMetric) {
|
||||||
@@ -1659,7 +1587,7 @@ const StatCards = ({
|
|||||||
projectionLoading && stats?.periodProgress < 100
|
projectionLoading && stats?.periodProgress < 100
|
||||||
? undefined
|
? undefined
|
||||||
: revenueTrend?.value
|
: revenueTrend?.value
|
||||||
? { value: revenueTrend.value, moreIsBetter: revenueTrend.trend === "up" }
|
? { value: revenueTrend.trend === "up" ? revenueTrend.value : -revenueTrend.value, moreIsBetter: true }
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
icon={DollarSign}
|
icon={DollarSign}
|
||||||
@@ -1672,7 +1600,13 @@ const StatCards = ({
|
|||||||
title="Orders"
|
title="Orders"
|
||||||
value={stats?.orderCount}
|
value={stats?.orderCount}
|
||||||
subtitle={`${stats?.itemCount} total items`}
|
subtitle={`${stats?.itemCount} total items`}
|
||||||
trend={orderTrend?.value ? { value: orderTrend.value, moreIsBetter: orderTrend.trend === "up" } : undefined}
|
trend={
|
||||||
|
projectionLoading && stats?.periodProgress < 100
|
||||||
|
? undefined
|
||||||
|
: orderTrend?.value
|
||||||
|
? { value: orderTrend.trend === "up" ? orderTrend.value : -orderTrend.value, moreIsBetter: true }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
icon={ShoppingCart}
|
icon={ShoppingCart}
|
||||||
iconColor="blue"
|
iconColor="blue"
|
||||||
onClick={() => setSelectedMetric("orders")}
|
onClick={() => setSelectedMetric("orders")}
|
||||||
@@ -1684,7 +1618,7 @@ const StatCards = ({
|
|||||||
value={stats?.averageOrderValue?.toFixed(2)}
|
value={stats?.averageOrderValue?.toFixed(2)}
|
||||||
valuePrefix="$"
|
valuePrefix="$"
|
||||||
subtitle={`${stats?.averageItemsPerOrder?.toFixed(1)} items per order`}
|
subtitle={`${stats?.averageItemsPerOrder?.toFixed(1)} items per order`}
|
||||||
trend={aovTrend?.value ? { value: aovTrend.value, moreIsBetter: aovTrend.trend === "up" } : undefined}
|
trend={aovTrend?.value ? { value: aovTrend.trend === "up" ? aovTrend.value : -aovTrend.value, moreIsBetter: true } : undefined}
|
||||||
icon={CircleDollarSign}
|
icon={CircleDollarSign}
|
||||||
iconColor="purple"
|
iconColor="purple"
|
||||||
onClick={() => setSelectedMetric("average_order")}
|
onClick={() => setSelectedMetric("average_order")}
|
||||||
@@ -1714,7 +1648,9 @@ const StatCards = ({
|
|||||||
<DashboardStatCard
|
<DashboardStatCard
|
||||||
title="Pre-Orders"
|
title="Pre-Orders"
|
||||||
value={
|
value={
|
||||||
((stats?.orderTypes?.preOrders?.count / stats?.orderCount) * 100)?.toFixed(1) || "0"
|
stats?.orderCount > 0
|
||||||
|
? ((stats?.orderTypes?.preOrders?.count / stats?.orderCount) * 100).toFixed(1)
|
||||||
|
: "0"
|
||||||
}
|
}
|
||||||
valueSuffix="%"
|
valueSuffix="%"
|
||||||
subtitle={`${stats?.orderTypes?.preOrders?.count || 0} orders`}
|
subtitle={`${stats?.orderTypes?.preOrders?.count || 0} orders`}
|
||||||
@@ -1727,7 +1663,9 @@ const StatCards = ({
|
|||||||
<DashboardStatCard
|
<DashboardStatCard
|
||||||
title="Local Pickup"
|
title="Local Pickup"
|
||||||
value={
|
value={
|
||||||
((stats?.orderTypes?.localPickup?.count / stats?.orderCount) * 100)?.toFixed(1) || "0"
|
stats?.orderCount > 0
|
||||||
|
? ((stats?.orderTypes?.localPickup?.count / stats?.orderCount) * 100).toFixed(1)
|
||||||
|
: "0"
|
||||||
}
|
}
|
||||||
valueSuffix="%"
|
valueSuffix="%"
|
||||||
subtitle={`${stats?.orderTypes?.localPickup?.count || 0} orders`}
|
subtitle={`${stats?.orderTypes?.localPickup?.count || 0} orders`}
|
||||||
@@ -1740,7 +1678,9 @@ const StatCards = ({
|
|||||||
<DashboardStatCard
|
<DashboardStatCard
|
||||||
title="On Hold"
|
title="On Hold"
|
||||||
value={
|
value={
|
||||||
((stats?.orderTypes?.heldItems?.count / stats?.orderCount) * 100)?.toFixed(1) || "0"
|
stats?.orderCount > 0
|
||||||
|
? ((stats?.orderTypes?.heldItems?.count / stats?.orderCount) * 100).toFixed(1)
|
||||||
|
: "0"
|
||||||
}
|
}
|
||||||
valueSuffix="%"
|
valueSuffix="%"
|
||||||
subtitle={`${stats?.orderTypes?.heldItems?.count || 0} orders`}
|
subtitle={`${stats?.orderTypes?.heldItems?.count || 0} orders`}
|
||||||
|
|||||||
@@ -10,12 +10,18 @@
|
|||||||
* value="$12,345"
|
* value="$12,345"
|
||||||
* gradient="emerald"
|
* gradient="emerald"
|
||||||
* icon={DollarSign}
|
* icon={DollarSign}
|
||||||
|
* trend={{ value: 12.5, label: "vs last month" }}
|
||||||
* />
|
* />
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { TrendingUp, TrendingDown, type LucideIcon } from "lucide-react";
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { ArrowUp, ArrowDown, Minus, Info, type LucideIcon } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -35,6 +41,17 @@ export type GradientVariant =
|
|||||||
| "sky"
|
| "sky"
|
||||||
| "custom";
|
| "custom";
|
||||||
|
|
||||||
|
export interface TrendProps {
|
||||||
|
/** The percentage or absolute change value */
|
||||||
|
value: number;
|
||||||
|
/** Optional label to show after the trend (e.g., "vs last month") */
|
||||||
|
label?: string;
|
||||||
|
/** Whether a higher value is better (affects color). Defaults to true. */
|
||||||
|
moreIsBetter?: boolean;
|
||||||
|
/** Suffix for the trend value (defaults to "%"). Use "" for no suffix. */
|
||||||
|
suffix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DashboardStatCardMiniProps {
|
export interface DashboardStatCardMiniProps {
|
||||||
/** Card title/label */
|
/** Card title/label */
|
||||||
title: string;
|
title: string;
|
||||||
@@ -44,13 +61,10 @@ export interface DashboardStatCardMiniProps {
|
|||||||
valuePrefix?: string;
|
valuePrefix?: string;
|
||||||
/** Optional suffix for the value (e.g., "%") */
|
/** Optional suffix for the value (e.g., "%") */
|
||||||
valueSuffix?: string;
|
valueSuffix?: string;
|
||||||
/** Optional description text or element */
|
/** Optional subtitle or description (can be string or JSX) */
|
||||||
description?: React.ReactNode;
|
subtitle?: React.ReactNode;
|
||||||
/** Trend direction and value */
|
/** Optional trend indicator */
|
||||||
trend?: {
|
trend?: TrendProps;
|
||||||
direction: "up" | "down";
|
|
||||||
value: string;
|
|
||||||
};
|
|
||||||
/** Optional icon component */
|
/** Optional icon component */
|
||||||
icon?: LucideIcon;
|
icon?: LucideIcon;
|
||||||
/** Icon background color class (e.g., "bg-emerald-500/20") */
|
/** Icon background color class (e.g., "bg-emerald-500/20") */
|
||||||
@@ -61,6 +75,12 @@ export interface DashboardStatCardMiniProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
/** Click handler */
|
/** Click handler */
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
/** Loading state */
|
||||||
|
loading?: boolean;
|
||||||
|
/** Tooltip text shown via info icon next to title */
|
||||||
|
tooltip?: string;
|
||||||
|
/** Additional content to render below the main value */
|
||||||
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -81,6 +101,53 @@ const GRADIENT_PRESETS: Record<GradientVariant, string> = {
|
|||||||
custom: "",
|
custom: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// HELPER COMPONENTS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get trend colors optimized for dark gradient backgrounds
|
||||||
|
*/
|
||||||
|
const getTrendColors = (value: number, moreIsBetter: boolean = true): string => {
|
||||||
|
const isPositive = value > 0;
|
||||||
|
const isGood = moreIsBetter ? isPositive : !isPositive;
|
||||||
|
|
||||||
|
if (value === 0) {
|
||||||
|
return "text-gray-400";
|
||||||
|
}
|
||||||
|
return isGood ? "text-emerald-400" : "text-rose-400";
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TrendIndicatorProps {
|
||||||
|
value: number;
|
||||||
|
label?: string;
|
||||||
|
moreIsBetter?: boolean;
|
||||||
|
suffix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TrendIndicator: React.FC<TrendIndicatorProps> = ({
|
||||||
|
value,
|
||||||
|
label,
|
||||||
|
moreIsBetter = true,
|
||||||
|
suffix = "%",
|
||||||
|
}) => {
|
||||||
|
const colorClass = getTrendColors(value, moreIsBetter);
|
||||||
|
const IconComponent = value > 0 ? ArrowUp : value < 0 ? ArrowDown : Minus;
|
||||||
|
|
||||||
|
// Format the value - round to integer for compact display (preserves sign for negatives)
|
||||||
|
const formattedValue = Math.round(value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={cn("flex items-center gap-0.5 text-sm font-semibold", colorClass)}>
|
||||||
|
<IconComponent className="h-4 w-4" />
|
||||||
|
{value > 0 ? "+" : ""}
|
||||||
|
{formattedValue}
|
||||||
|
{suffix}
|
||||||
|
{label && <span className="text-gray-300 font-normal ml-1">{label}</span>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// MAIN COMPONENT
|
// MAIN COMPONENT
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -90,16 +157,41 @@ export const DashboardStatCardMini: React.FC<DashboardStatCardMiniProps> = ({
|
|||||||
value,
|
value,
|
||||||
valuePrefix,
|
valuePrefix,
|
||||||
valueSuffix,
|
valueSuffix,
|
||||||
description,
|
subtitle,
|
||||||
trend,
|
trend,
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
iconBackground,
|
iconBackground,
|
||||||
gradient = "slate",
|
gradient = "slate",
|
||||||
className,
|
className,
|
||||||
onClick,
|
onClick,
|
||||||
|
loading = false,
|
||||||
|
tooltip,
|
||||||
|
children,
|
||||||
}) => {
|
}) => {
|
||||||
const gradientClass = gradient === "custom" ? "" : GRADIENT_PRESETS[gradient];
|
const gradientClass = gradient === "custom" ? "" : GRADIENT_PRESETS[gradient];
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
gradientClass,
|
||||||
|
"backdrop-blur-md border-white/10",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 px-4 py-3">
|
||||||
|
<div className="h-4 w-20 bg-white/20 animate-pulse rounded" />
|
||||||
|
{Icon && <div className="h-9 w-9 bg-white/20 animate-pulse rounded-full" />}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-4 pt-1">
|
||||||
|
<div className="h-9 w-28 bg-white/20 animate-pulse rounded mb-2" />
|
||||||
|
{subtitle && <div className="h-4 w-24 bg-white/10 animate-pulse rounded" />}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -110,10 +202,28 @@ export const DashboardStatCardMini: React.FC<DashboardStatCardMiniProps> = ({
|
|||||||
)}
|
)}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 px-4 py-3">
|
||||||
<CardTitle className="text-sm font-bold text-gray-100">
|
<div className="flex items-center gap-1.5">
|
||||||
|
<CardTitle className="text-xs font-medium text-gray-100 uppercase tracking-wide">
|
||||||
{title}
|
{title}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
|
{tooltip && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-gray-400 hover:text-gray-200 transition-colors"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Info className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="max-w-xs">
|
||||||
|
<p className="text-sm">{tooltip}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{Icon && (
|
{Icon && (
|
||||||
<div className="relative p-2">
|
<div className="relative p-2">
|
||||||
{iconBackground && (
|
{iconBackground && (
|
||||||
@@ -121,11 +231,11 @@ export const DashboardStatCardMini: React.FC<DashboardStatCardMiniProps> = ({
|
|||||||
className={cn("absolute inset-0 rounded-full", iconBackground)}
|
className={cn("absolute inset-0 rounded-full", iconBackground)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Icon className="h-5 w-5 text-white relative" />
|
<Icon className="h-4 w-4 text-white relative" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-4 pt-0">
|
<CardContent className="p-4 pt-1">
|
||||||
<div className="text-3xl font-extrabold text-white">
|
<div className="text-3xl font-extrabold text-white">
|
||||||
{valuePrefix}
|
{valuePrefix}
|
||||||
{typeof value === "number" ? value.toLocaleString() : value}
|
{typeof value === "number" ? value.toLocaleString() : value}
|
||||||
@@ -133,32 +243,24 @@ export const DashboardStatCardMini: React.FC<DashboardStatCardMiniProps> = ({
|
|||||||
<span className="text-xl text-gray-300">{valueSuffix}</span>
|
<span className="text-xl text-gray-300">{valueSuffix}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{(description || trend) && (
|
{(subtitle || trend) && (
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<div className="flex flex-wrap items-center justify-between gap-2 mt-3">
|
||||||
{trend && (
|
{subtitle && (
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-1 text-sm font-semibold",
|
|
||||||
trend.direction === "up"
|
|
||||||
? "text-emerald-300"
|
|
||||||
: "text-rose-300"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{trend.direction === "up" ? (
|
|
||||||
<TrendingUp className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<TrendingDown className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
{trend.value}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{description && (
|
|
||||||
<span className="text-sm font-semibold text-gray-200">
|
<span className="text-sm font-semibold text-gray-200">
|
||||||
{description}
|
{subtitle}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{trend && (
|
||||||
|
<TrendIndicator
|
||||||
|
value={trend.value}
|
||||||
|
label={trend.label}
|
||||||
|
moreIsBetter={trend.moreIsBetter}
|
||||||
|
suffix={trend.suffix}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{children}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@@ -170,12 +272,14 @@ export const DashboardStatCardMini: React.FC<DashboardStatCardMiniProps> = ({
|
|||||||
|
|
||||||
export interface DashboardStatCardMiniSkeletonProps {
|
export interface DashboardStatCardMiniSkeletonProps {
|
||||||
gradient?: GradientVariant;
|
gradient?: GradientVariant;
|
||||||
|
hasIcon?: boolean;
|
||||||
|
hasSubtitle?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DashboardStatCardMiniSkeleton: React.FC<
|
export const DashboardStatCardMiniSkeleton: React.FC<
|
||||||
DashboardStatCardMiniSkeletonProps
|
DashboardStatCardMiniSkeletonProps
|
||||||
> = ({ gradient = "slate", className }) => {
|
> = ({ gradient = "slate", hasIcon = true, hasSubtitle = true, className }) => {
|
||||||
const gradientClass = gradient === "custom" ? "" : GRADIENT_PRESETS[gradient];
|
const gradientClass = gradient === "custom" ? "" : GRADIENT_PRESETS[gradient];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -186,13 +290,13 @@ export const DashboardStatCardMiniSkeleton: React.FC<
|
|||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 px-4 py-3">
|
||||||
<div className="h-4 w-20 bg-white/20 animate-pulse rounded" />
|
<div className="h-4 w-20 bg-white/20 animate-pulse rounded" />
|
||||||
<div className="h-9 w-9 bg-white/20 animate-pulse rounded-full" />
|
{hasIcon && <div className="h-9 w-9 bg-white/20 animate-pulse rounded-full" />}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-4 pt-0">
|
<CardContent className="p-4 pt-1">
|
||||||
<div className="h-9 w-28 bg-white/20 animate-pulse rounded mb-2" />
|
<div className="h-9 w-28 bg-white/20 animate-pulse rounded mb-2" />
|
||||||
<div className="h-4 w-24 bg-white/10 animate-pulse rounded" />
|
{hasSubtitle && <div className="h-4 w-24 bg-white/10 animate-pulse rounded" />}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
#!/bin/zsh
|
|
||||||
|
|
||||||
#Clear previous mount in case it’s still there
|
|
||||||
umount '/Users/matt/Dev/inventory/inventory-server'
|
|
||||||
|
|
||||||
#Mount
|
|
||||||
sshfs matt@dashboard.kent.pw:/var/www/html/inventory -p 22122 '/Users/matt/Dev/inventory/inventory-server/'
|
|
||||||
Reference in New Issue
Block a user