Clean up build errors, better mobile styles for Black Friday, remove cherry box orders and add profit/cogs charts
This commit is contained in:
@@ -13,6 +13,13 @@ const {
|
||||
const TIMEZONE = 'America/New_York';
|
||||
const BUSINESS_DAY_START_HOUR = timeHelpers?.BUSINESS_DAY_START_HOUR ?? 1;
|
||||
|
||||
// Cherry Box order types to exclude when excludeCherryBox=true is passed
|
||||
// 3 = cherrybox_subscription, 4 = cherrybox_sending, 5 = cherrybox_subscription_renew, 7 = cherrybox_refund
|
||||
const EXCLUDED_ORDER_TYPES = [3, 4, 5, 7];
|
||||
const getCherryBoxClause = (exclude) => exclude ? `order_type NOT IN (${EXCLUDED_ORDER_TYPES.join(', ')})` : '1=1';
|
||||
const getCherryBoxClauseAliased = (alias, exclude) => exclude ? `${alias}.order_type NOT IN (${EXCLUDED_ORDER_TYPES.join(', ')})` : '1=1';
|
||||
const parseBoolParam = (value) => value === 'true' || value === '1';
|
||||
|
||||
// Image URL generation utility
|
||||
const getImageUrls = (pid, iid = 1) => {
|
||||
const imageUrlBase = 'https://sbing.com/i/products/0000/';
|
||||
@@ -39,14 +46,15 @@ router.get('/stats', async (req, res) => {
|
||||
|
||||
try {
|
||||
const mainOperation = async () => {
|
||||
const { timeRange, startDate, endDate } = req.query;
|
||||
console.log(`[STATS] Getting DB connection...`);
|
||||
const { timeRange, startDate, endDate, excludeCherryBox } = req.query;
|
||||
const excludeCB = parseBoolParam(excludeCherryBox);
|
||||
console.log(`[STATS] Getting DB connection... (excludeCherryBox: ${excludeCB})`);
|
||||
const { connection, release } = await getDbConnection();
|
||||
console.log(`[STATS] DB connection obtained in ${Date.now() - startTime}ms`);
|
||||
|
||||
const { whereClause, params, dateRange } = getTimeRangeConditions(timeRange, startDate, endDate);
|
||||
|
||||
// Main order stats query
|
||||
// Main order stats query (optionally excludes Cherry Box orders)
|
||||
const mainStatsQuery = `
|
||||
SELECT
|
||||
COUNT(*) as orderCount,
|
||||
@@ -61,32 +69,32 @@ router.get('/stats', async (req, res) => {
|
||||
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
|
||||
WHERE order_status > 15 AND ${whereClause}
|
||||
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
|
||||
`;
|
||||
|
||||
const [mainStats] = await connection.execute(mainStatsQuery, params);
|
||||
const stats = mainStats[0];
|
||||
|
||||
// Refunds query
|
||||
// Refunds query (optionally excludes Cherry Box orders)
|
||||
const refundsQuery = `
|
||||
SELECT
|
||||
COUNT(*) as refundCount,
|
||||
ABS(SUM(payment_amount)) as refundTotal
|
||||
FROM order_payment op
|
||||
JOIN _order o ON op.order_id = o.order_id
|
||||
WHERE payment_amount < 0 AND o.order_status > 15 AND ${whereClause.replace('date_placed', 'o.date_placed')}
|
||||
WHERE payment_amount < 0 AND o.order_status > 15 AND ${getCherryBoxClauseAliased('o', excludeCB)} AND ${whereClause.replace('date_placed', 'o.date_placed')}
|
||||
`;
|
||||
|
||||
const [refundStats] = await connection.execute(refundsQuery, params);
|
||||
|
||||
// Best revenue day query
|
||||
// Best revenue day query (optionally excludes Cherry Box orders)
|
||||
const bestDayQuery = `
|
||||
SELECT
|
||||
DATE(date_placed) as date,
|
||||
SUM(summary_total) as revenue,
|
||||
COUNT(*) as orders
|
||||
FROM _order
|
||||
WHERE order_status > 15 AND ${whereClause}
|
||||
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
|
||||
GROUP BY DATE(date_placed)
|
||||
ORDER BY revenue DESC
|
||||
LIMIT 1
|
||||
@@ -94,7 +102,7 @@ router.get('/stats', async (req, res) => {
|
||||
|
||||
const [bestDayResult] = await connection.execute(bestDayQuery, params);
|
||||
|
||||
// Peak hour query (for single day periods)
|
||||
// Peak hour query (for single day periods, optionally excludes Cherry Box orders)
|
||||
let peakHour = null;
|
||||
if (['today', 'yesterday'].includes(timeRange)) {
|
||||
const peakHourQuery = `
|
||||
@@ -102,7 +110,7 @@ router.get('/stats', async (req, res) => {
|
||||
HOUR(date_placed) as hour,
|
||||
COUNT(*) as count
|
||||
FROM _order
|
||||
WHERE order_status > 15 AND ${whereClause}
|
||||
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
|
||||
GROUP BY HOUR(date_placed)
|
||||
ORDER BY count DESC
|
||||
LIMIT 1
|
||||
@@ -122,7 +130,7 @@ router.get('/stats', async (req, res) => {
|
||||
}
|
||||
|
||||
// Brands and categories query - simplified for now since we don't have the category tables
|
||||
// We'll use a simple approach without company table for now
|
||||
// We'll use a simple approach without company table for now (optionally excludes Cherry Box orders)
|
||||
const brandsQuery = `
|
||||
SELECT
|
||||
'Various Brands' as brandName,
|
||||
@@ -132,13 +140,13 @@ router.get('/stats', async (req, res) => {
|
||||
FROM order_items oi
|
||||
JOIN _order o ON oi.order_id = o.order_id
|
||||
JOIN products p ON oi.prod_pid = p.pid
|
||||
WHERE o.order_status > 15 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')}
|
||||
HAVING revenue > 0
|
||||
`;
|
||||
|
||||
const [brandsResult] = await connection.execute(brandsQuery, params);
|
||||
|
||||
// For categories, we'll use a simplified approach
|
||||
// For categories, we'll use a simplified approach (optionally excludes Cherry Box orders)
|
||||
const categoriesQuery = `
|
||||
SELECT
|
||||
'General' as categoryName,
|
||||
@@ -148,13 +156,13 @@ router.get('/stats', async (req, res) => {
|
||||
FROM order_items oi
|
||||
JOIN _order o ON oi.order_id = o.order_id
|
||||
JOIN products p ON oi.prod_pid = p.pid
|
||||
WHERE o.order_status > 15 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')}
|
||||
HAVING revenue > 0
|
||||
`;
|
||||
|
||||
const [categoriesResult] = await connection.execute(categoriesQuery, params);
|
||||
|
||||
// Shipping locations query
|
||||
// Shipping locations query (optionally excludes Cherry Box orders)
|
||||
const shippingQuery = `
|
||||
SELECT
|
||||
ship_country,
|
||||
@@ -162,7 +170,7 @@ router.get('/stats', async (req, res) => {
|
||||
ship_method_selected,
|
||||
COUNT(*) as count
|
||||
FROM _order
|
||||
WHERE order_status IN (100, 92) AND ${whereClause}
|
||||
WHERE order_status IN (100, 92) AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
|
||||
GROUP BY ship_country, ship_state, ship_method_selected
|
||||
`;
|
||||
|
||||
@@ -171,13 +179,13 @@ router.get('/stats', async (req, res) => {
|
||||
// Process shipping data
|
||||
const shippingStats = processShippingData(shippingResult, stats.shippedCount);
|
||||
|
||||
// Order value range query
|
||||
// Order value range query (optionally excludes Cherry Box orders)
|
||||
const orderRangeQuery = `
|
||||
SELECT
|
||||
MIN(summary_total) as smallest,
|
||||
MAX(summary_total) as largest
|
||||
FROM _order
|
||||
WHERE order_status > 15 AND ${whereClause}
|
||||
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
|
||||
`;
|
||||
|
||||
const [orderRangeResult] = await connection.execute(orderRangeQuery, params);
|
||||
@@ -189,7 +197,7 @@ router.get('/stats', async (req, res) => {
|
||||
}
|
||||
|
||||
// Previous period comparison data
|
||||
const prevPeriodData = await getPreviousPeriodData(connection, timeRange, startDate, endDate);
|
||||
const prevPeriodData = await getPreviousPeriodData(connection, timeRange, startDate, endDate, excludeCB);
|
||||
|
||||
const response = {
|
||||
timeRange: dateRange,
|
||||
@@ -316,13 +324,14 @@ router.get('/stats', async (req, res) => {
|
||||
router.get('/stats/details', async (req, res) => {
|
||||
let release;
|
||||
try {
|
||||
const { timeRange, startDate, endDate, metric, daily } = req.query;
|
||||
const { timeRange, startDate, endDate, metric, daily, excludeCherryBox } = req.query;
|
||||
const excludeCB = parseBoolParam(excludeCherryBox);
|
||||
const { connection, release: releaseConn } = await getDbConnection();
|
||||
release = releaseConn;
|
||||
|
||||
const { whereClause, params } = getTimeRangeConditions(timeRange, startDate, endDate);
|
||||
|
||||
// Daily breakdown query
|
||||
// Daily breakdown query (optionally excludes Cherry Box orders)
|
||||
const dailyQuery = `
|
||||
SELECT
|
||||
DATE(date_placed) as date,
|
||||
@@ -331,7 +340,7 @@ router.get('/stats/details', async (req, res) => {
|
||||
AVG(summary_total) as averageOrderValue,
|
||||
SUM(stats_prod_pieces) as itemCount
|
||||
FROM _order
|
||||
WHERE order_status > 15 AND ${whereClause}
|
||||
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
|
||||
GROUP BY DATE(date_placed)
|
||||
ORDER BY DATE(date_placed)
|
||||
`;
|
||||
@@ -359,7 +368,7 @@ router.get('/stats/details', async (req, res) => {
|
||||
prevParams = [prevStart.toISOString(), prevEnd.toISOString()];
|
||||
}
|
||||
|
||||
// Get previous period daily data
|
||||
// Get previous period daily data (optionally excludes Cherry Box orders)
|
||||
const prevQuery = `
|
||||
SELECT
|
||||
DATE(date_placed) as date,
|
||||
@@ -367,7 +376,7 @@ router.get('/stats/details', async (req, res) => {
|
||||
SUM(summary_total) as prevRevenue,
|
||||
AVG(summary_total) as prevAvgOrderValue
|
||||
FROM _order
|
||||
WHERE order_status > 15 AND ${prevWhereClause}
|
||||
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${prevWhereClause}
|
||||
GROUP BY DATE(date_placed)
|
||||
`;
|
||||
|
||||
@@ -424,7 +433,8 @@ router.get('/stats/details', async (req, res) => {
|
||||
router.get('/financials', async (req, res) => {
|
||||
let release;
|
||||
try {
|
||||
const { timeRange, startDate, endDate } = req.query;
|
||||
const { timeRange, startDate, endDate, excludeCherryBox } = req.query;
|
||||
const excludeCB = parseBoolParam(excludeCherryBox);
|
||||
const { connection, release: releaseConn } = await getDbConnection();
|
||||
release = releaseConn;
|
||||
|
||||
@@ -450,7 +460,7 @@ router.get('/financials', async (req, res) => {
|
||||
});
|
||||
|
||||
const [totalsRows] = await connection.execute(
|
||||
buildFinancialTotalsQuery(financialWhere),
|
||||
buildFinancialTotalsQuery(financialWhere, excludeCB),
|
||||
params
|
||||
);
|
||||
|
||||
@@ -462,7 +472,7 @@ router.get('/financials', async (req, res) => {
|
||||
});
|
||||
|
||||
const [trendRows] = await connection.execute(
|
||||
buildFinancialTrendQuery(financialWhere),
|
||||
buildFinancialTrendQuery(financialWhere, excludeCB),
|
||||
params
|
||||
);
|
||||
|
||||
@@ -489,7 +499,7 @@ router.get('/financials', async (req, res) => {
|
||||
});
|
||||
const prevWhere = previousRange.whereClause.replace(/date_placed/g, 'date_change');
|
||||
const [previousRows] = await connection.execute(
|
||||
buildFinancialTotalsQuery(prevWhere),
|
||||
buildFinancialTotalsQuery(prevWhere, excludeCB),
|
||||
previousRange.params
|
||||
);
|
||||
previousTotals = normalizeFinancialTotals(previousRows[0]);
|
||||
@@ -549,12 +559,14 @@ router.get('/financials', async (req, res) => {
|
||||
router.get('/products', async (req, res) => {
|
||||
let release;
|
||||
try {
|
||||
const { timeRange, startDate, endDate } = req.query;
|
||||
const { timeRange, startDate, endDate, excludeCherryBox } = req.query;
|
||||
const excludeCB = parseBoolParam(excludeCherryBox);
|
||||
const { connection, release: releaseConn } = await getDbConnection();
|
||||
release = releaseConn;
|
||||
|
||||
const { whereClause, params } = getTimeRangeConditions(timeRange, startDate, endDate);
|
||||
|
||||
// Products query (optionally excludes Cherry Box orders)
|
||||
const productsQuery = `
|
||||
SELECT
|
||||
p.pid,
|
||||
@@ -566,7 +578,7 @@ router.get('/products', async (req, res) => {
|
||||
FROM order_items oi
|
||||
JOIN _order o ON oi.order_id = o.order_id
|
||||
JOIN products p ON oi.prod_pid = p.pid
|
||||
WHERE o.order_status > 15 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 p.pid, p.description
|
||||
ORDER BY totalRevenue DESC
|
||||
LIMIT 500
|
||||
@@ -609,7 +621,8 @@ router.get('/products', async (req, res) => {
|
||||
router.get('/projection', async (req, res) => {
|
||||
let release;
|
||||
try {
|
||||
const { timeRange, startDate, endDate } = req.query;
|
||||
const { timeRange, startDate, endDate, excludeCherryBox } = req.query;
|
||||
const excludeCB = parseBoolParam(excludeCherryBox);
|
||||
|
||||
// Only provide projections for incomplete periods
|
||||
if (!['today', 'thisWeek', 'thisMonth'].includes(timeRange)) {
|
||||
@@ -622,19 +635,20 @@ router.get('/projection', async (req, res) => {
|
||||
// Get current period data
|
||||
const { whereClause, params } = getTimeRangeConditions(timeRange, startDate, endDate);
|
||||
|
||||
// Current period query (optionally excludes Cherry Box orders)
|
||||
const currentQuery = `
|
||||
SELECT
|
||||
SUM(summary_total) as currentRevenue,
|
||||
COUNT(*) as currentOrders
|
||||
FROM _order
|
||||
WHERE order_status > 15 AND ${whereClause}
|
||||
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
|
||||
`;
|
||||
|
||||
const [currentResult] = await connection.execute(currentQuery, params);
|
||||
const current = currentResult[0];
|
||||
|
||||
// Get historical data for the same period type
|
||||
const historicalQuery = await getHistoricalProjectionData(connection, timeRange);
|
||||
const historicalQuery = await getHistoricalProjectionData(connection, timeRange, excludeCB);
|
||||
|
||||
// Calculate projection based on current progress and historical patterns
|
||||
const periodProgress = calculatePeriodProgress(timeRange);
|
||||
@@ -765,7 +779,24 @@ function calculatePeriodProgress(timeRange) {
|
||||
return Math.min(100, Math.max(0, (elapsed / total) * 100));
|
||||
}
|
||||
|
||||
function buildFinancialTotalsQuery(whereClause) {
|
||||
function buildFinancialTotalsQuery(whereClause, excludeCherryBox = false) {
|
||||
// Optionally join to _order to exclude Cherry Box orders
|
||||
if (excludeCherryBox) {
|
||||
return `
|
||||
SELECT
|
||||
COALESCE(SUM(r.sale_amount), 0) as grossSales,
|
||||
COALESCE(SUM(r.refund_amount), 0) as refunds,
|
||||
COALESCE(SUM(r.shipping_collected_amount + r.small_order_fee_amount + r.rush_fee_amount), 0) as shippingFees,
|
||||
COALESCE(SUM(r.tax_collected_amount), 0) as taxCollected,
|
||||
COALESCE(SUM(r.discount_total_amount), 0) as discounts,
|
||||
COALESCE(SUM(r.cogs_amount), 0) as cogs
|
||||
FROM report_sales_data r
|
||||
JOIN _order o ON r.order_id = o.order_id
|
||||
WHERE ${whereClause.replace(/date_change/g, 'r.date_change')}
|
||||
AND r.action IN (1, 2, 3)
|
||||
AND ${getCherryBoxClauseAliased('o', true)}
|
||||
`;
|
||||
}
|
||||
return `
|
||||
SELECT
|
||||
COALESCE(SUM(sale_amount), 0) as grossSales,
|
||||
@@ -780,8 +811,31 @@ function buildFinancialTotalsQuery(whereClause) {
|
||||
`;
|
||||
}
|
||||
|
||||
function buildFinancialTrendQuery(whereClause) {
|
||||
function buildFinancialTrendQuery(whereClause, excludeCherryBox = false) {
|
||||
const businessDayOffset = BUSINESS_DAY_START_HOUR;
|
||||
// Optionally join to _order to exclude Cherry Box orders
|
||||
if (excludeCherryBox) {
|
||||
return `
|
||||
SELECT
|
||||
DATE_FORMAT(
|
||||
DATE_SUB(r.date_change, INTERVAL ${businessDayOffset} HOUR),
|
||||
'%Y-%m-%d'
|
||||
) as businessDate,
|
||||
SUM(r.sale_amount) as grossSales,
|
||||
SUM(r.refund_amount) as refunds,
|
||||
SUM(r.shipping_collected_amount + r.small_order_fee_amount + r.rush_fee_amount) as shippingFees,
|
||||
SUM(r.tax_collected_amount) as taxCollected,
|
||||
SUM(r.discount_total_amount) as discounts,
|
||||
SUM(r.cogs_amount) as cogs
|
||||
FROM report_sales_data r
|
||||
JOIN _order o ON r.order_id = o.order_id
|
||||
WHERE ${whereClause.replace(/date_change/g, 'r.date_change')}
|
||||
AND r.action IN (1, 2, 3)
|
||||
AND ${getCherryBoxClauseAliased('o', true)}
|
||||
GROUP BY businessDate
|
||||
ORDER BY businessDate ASC
|
||||
`;
|
||||
}
|
||||
return `
|
||||
SELECT
|
||||
DATE_FORMAT(
|
||||
@@ -940,7 +994,7 @@ function getPreviousPeriodRange(timeRange, startDate, endDate) {
|
||||
return getTimeRangeConditions('custom', prevStart.toISOString(), prevEnd.toISOString());
|
||||
}
|
||||
|
||||
async function getPreviousPeriodData(connection, timeRange, startDate, endDate) {
|
||||
async function getPreviousPeriodData(connection, timeRange, startDate, endDate, excludeCherryBox = false) {
|
||||
// Calculate previous period dates
|
||||
let prevWhereClause, prevParams;
|
||||
|
||||
@@ -962,13 +1016,14 @@ async function getPreviousPeriodData(connection, timeRange, startDate, endDate)
|
||||
prevParams = [prevStart.toISOString(), prevEnd.toISOString()];
|
||||
}
|
||||
|
||||
// Previous period query (optionally excludes Cherry Box orders)
|
||||
const prevQuery = `
|
||||
SELECT
|
||||
COUNT(*) as orderCount,
|
||||
SUM(summary_total) as revenue,
|
||||
AVG(summary_total) as averageOrderValue
|
||||
FROM _order
|
||||
WHERE order_status > 15 AND ${prevWhereClause}
|
||||
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCherryBox)} AND ${prevWhereClause}
|
||||
`;
|
||||
|
||||
const [prevResult] = await connection.execute(prevQuery, prevParams);
|
||||
@@ -994,8 +1049,8 @@ function getPreviousTimeRange(timeRange) {
|
||||
return map[timeRange] || timeRange;
|
||||
}
|
||||
|
||||
async function getHistoricalProjectionData(connection, timeRange) {
|
||||
// Get historical data for projection calculations
|
||||
async function getHistoricalProjectionData(connection, timeRange, excludeCherryBox = false) {
|
||||
// Get historical data for projection calculations (optionally excludes Cherry Box orders)
|
||||
// This is a simplified version - you could make this more sophisticated
|
||||
const historicalQuery = `
|
||||
SELECT
|
||||
@@ -1003,6 +1058,7 @@ async function getHistoricalProjectionData(connection, timeRange) {
|
||||
COUNT(*) as orders
|
||||
FROM _order
|
||||
WHERE order_status > 15
|
||||
AND ${getCherryBoxClause(excludeCherryBox)}
|
||||
AND date_placed >= DATE_SUB(NOW(), INTERVAL 30 DAY)
|
||||
AND date_placed < DATE_SUB(NOW(), INTERVAL 1 DAY)
|
||||
`;
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
@@ -30,23 +29,20 @@ import {
|
||||
Tooltip as RechartsTooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Legend,
|
||||
TooltipProps,
|
||||
} from "recharts";
|
||||
import {
|
||||
RefreshCw,
|
||||
TrendingUp,
|
||||
Sparkles,
|
||||
DollarSign,
|
||||
ShoppingBag,
|
||||
Percent,
|
||||
ArrowUpRight,
|
||||
ArrowDownRight,
|
||||
Trophy,
|
||||
Activity,
|
||||
Clock3,
|
||||
Zap,
|
||||
Users,
|
||||
Wallet,
|
||||
Package,
|
||||
} from "lucide-react";
|
||||
import { acotService } from "@/services/dashboard/acotService";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -472,9 +468,11 @@ const CustomTooltip = ({ active, payload, label }: TooltipProps<ValueType, NameT
|
||||
const isRevenue = name.includes("revenue");
|
||||
const isMargin = name.includes("margin");
|
||||
const isAov = name.includes("aov");
|
||||
const isProfit = name.includes("profit");
|
||||
const isCogs = name.includes("cogs");
|
||||
|
||||
let valueFormatted = "";
|
||||
if (isRevenue || isAov) valueFormatted = formatCurrency(Number(entry.value));
|
||||
if (isRevenue || isAov || isProfit || isCogs) valueFormatted = formatCurrency(Number(entry.value));
|
||||
else if (isMargin) valueFormatted = `${Number(entry.value).toFixed(1)}%`;
|
||||
else valueFormatted = formatNumber(Number(entry.value));
|
||||
|
||||
@@ -523,7 +521,7 @@ export function BlackFridayDashboard() {
|
||||
[currentYear]
|
||||
);
|
||||
|
||||
const [selectedYears, setSelectedYears] = useState<number[]>(
|
||||
const [selectedYears] = useState<number[]>(
|
||||
availableYears.slice(0, 6)
|
||||
);
|
||||
const [dataByYear, setDataByYear] = useState<Record<number, YearMetrics>>({});
|
||||
@@ -564,11 +562,13 @@ export function BlackFridayDashboard() {
|
||||
metric: "revenue",
|
||||
eventType: "PLACED_ORDER",
|
||||
daily: true,
|
||||
excludeCherryBox: true,
|
||||
}),
|
||||
acotService.getFinancials({
|
||||
timeRange: "custom",
|
||||
startDate: params.startDate,
|
||||
endDate: params.endDate,
|
||||
excludeCherryBox: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -722,6 +722,8 @@ export function BlackFridayDashboard() {
|
||||
point[`${year}-orders`] = day?.orders ?? 0;
|
||||
point[`${year}-margin`] = day?.margin ?? null;
|
||||
point[`${year}-aov`] = day?.avgOrderValue ?? 0;
|
||||
point[`${year}-profit`] = day?.profit ?? 0;
|
||||
point[`${year}-cogs`] = day?.cogs ?? 0;
|
||||
point[`color-${year}`] = COLOR_PALETTE[colorIndex % COLOR_PALETTE.length];
|
||||
});
|
||||
return point;
|
||||
@@ -733,16 +735,6 @@ export function BlackFridayDashboard() {
|
||||
return bucketDebug.slice(0, 200); // keep overlay readable
|
||||
}, [bucketDebug]);
|
||||
|
||||
const toggleYear = (year: number) => {
|
||||
setSelectedYears((prev) => {
|
||||
if (prev.includes(year)) {
|
||||
if (prev.length === 1) return prev; // keep at least one year selected
|
||||
return prev.filter((y) => y !== year);
|
||||
}
|
||||
return [...prev, year].sort((a, b) => b - a);
|
||||
});
|
||||
};
|
||||
|
||||
const getStrokeForYear = (year: number) => {
|
||||
const index = sortedYears.indexOf(year);
|
||||
return COLOR_PALETTE[index % COLOR_PALETTE.length];
|
||||
@@ -755,46 +747,9 @@ export function BlackFridayDashboard() {
|
||||
const renderLiveHeader = () => (
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex h-3 w-3">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-3 w-3 bg-emerald-500"></span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-emerald-600 dark:text-emerald-400">LIVE</span>
|
||||
</div>
|
||||
<div className="h-4 w-px bg-border" />
|
||||
<h1 className="text-xl font-bold tracking-tight">Black Friday {currentYear}</h1>
|
||||
<span className="text-sm text-muted-foreground">{currentYearData?.range.label}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 bg-muted/60 px-1 py-0.5 rounded-md border">
|
||||
{availableYears.map((year) => (
|
||||
<Button
|
||||
key={year}
|
||||
size="sm"
|
||||
variant={selectedYears.includes(year) ? (year === currentYear ? "default" : "secondary") : "ghost"}
|
||||
onClick={() => toggleYear(year)}
|
||||
className={cn(
|
||||
"h-6 px-2 text-xs font-medium rounded transition-all",
|
||||
year === currentYear && selectedYears.includes(year) && "bg-emerald-600 hover:bg-emerald-700",
|
||||
!selectedYears.includes(year) && "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{year}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setRefreshToken((token) => token + 1)}
|
||||
disabled={loading}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<RefreshCw className={cn("h-3.5 w-3.5", loading && "animate-spin")} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -920,7 +875,7 @@ export function BlackFridayDashboard() {
|
||||
if (!currentYearData) return null;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-6 gap-2">
|
||||
<div className="grid grid-cols-3 lg:grid-cols-6 gap-2">
|
||||
{currentYearData.days.map((day, idx) => {
|
||||
const lastYearDay = lastYearData?.days[idx];
|
||||
const change = lastYearDay ? percentChange(day.revenue, lastYearDay.revenue) : null;
|
||||
@@ -1014,19 +969,19 @@ export function BlackFridayDashboard() {
|
||||
);
|
||||
};
|
||||
|
||||
// Compact charts - 2x2 grid
|
||||
// Compact charts - 3x2 grid (Revenue/Profit on top, COGS/Margin in middle, Orders/AOV on bottom)
|
||||
const renderCharts = () => (
|
||||
<div className="grid gap-3 grid-cols-1 lg:grid-cols-2">
|
||||
<div className="grid gap-3 grid-cols-1 md:grid-cols-2">
|
||||
{/* Revenue Chart */}
|
||||
<Card className="border-muted">
|
||||
<CardHeader className="pb-2 pt-3 px-4">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
Revenue by Day
|
||||
Revenue
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-2 pt-0">
|
||||
<div className="h-[180px] w-full">
|
||||
<div className="h-[160px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData} margin={{ top: 5, right: 10, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsl(var(--border))" />
|
||||
@@ -1064,16 +1019,16 @@ export function BlackFridayDashboard() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Orders Chart */}
|
||||
{/* Profit Chart */}
|
||||
<Card className="border-muted">
|
||||
<CardHeader className="pb-2 pt-3 px-4">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<ShoppingBag className="h-4 w-4 text-muted-foreground" />
|
||||
Orders by Day
|
||||
<Wallet className="h-4 w-4 text-emerald-500" />
|
||||
Profit
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-2 pt-0">
|
||||
<div className="h-[180px] w-full">
|
||||
<div className="h-[160px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData} margin={{ top: 5, right: 10, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsl(var(--border))" />
|
||||
@@ -1085,11 +1040,11 @@ export function BlackFridayDashboard() {
|
||||
dy={5}
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={(value) => formatNumber(Number(value))}
|
||||
tickFormatter={(value) => `$${(value as number / 1000).toFixed(0)}k`}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: "hsl(var(--muted-foreground))", fontSize: 10 }}
|
||||
width={40}
|
||||
width={45}
|
||||
/>
|
||||
<RechartsTooltip content={<CustomTooltip />} />
|
||||
{sortedYears.map((year) => (
|
||||
@@ -1097,7 +1052,54 @@ export function BlackFridayDashboard() {
|
||||
key={year}
|
||||
type="monotone"
|
||||
name={`${year}`}
|
||||
dataKey={`${year}-orders`}
|
||||
dataKey={`${year}-profit`}
|
||||
stroke={getStrokeForYear(year)}
|
||||
strokeWidth={year === currentYear ? 2.5 : 1.5}
|
||||
strokeOpacity={year === currentYear ? 1 : 0.6}
|
||||
dot={year === currentYear ? { r: 3, strokeWidth: 2, fill: "hsl(var(--background))" } : false}
|
||||
activeDot={{ r: 4, strokeWidth: 0 }}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* COGS Chart */}
|
||||
<Card className="border-muted">
|
||||
<CardHeader className="pb-2 pt-3 px-4">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Package className="h-4 w-4 text-muted-foreground" />
|
||||
COGS
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-2 pt-0">
|
||||
<div className="h-[160px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData} margin={{ top: 5, right: 10, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsl(var(--border))" />
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: "hsl(var(--muted-foreground))", fontSize: 10 }}
|
||||
dy={5}
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={(value) => `$${(value as number / 1000).toFixed(0)}k`}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: "hsl(var(--muted-foreground))", fontSize: 10 }}
|
||||
width={45}
|
||||
/>
|
||||
<RechartsTooltip content={<CustomTooltip />} />
|
||||
{sortedYears.map((year) => (
|
||||
<Line
|
||||
key={year}
|
||||
type="monotone"
|
||||
name={`${year}`}
|
||||
dataKey={`${year}-cogs`}
|
||||
stroke={getStrokeForYear(year)}
|
||||
strokeWidth={year === currentYear ? 2.5 : 1.5}
|
||||
strokeOpacity={year === currentYear ? 1 : 0.6}
|
||||
@@ -1116,11 +1118,11 @@ export function BlackFridayDashboard() {
|
||||
<CardHeader className="pb-2 pt-3 px-4">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Percent className="h-4 w-4 text-muted-foreground" />
|
||||
Margin by Day
|
||||
Margin
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-2 pt-0">
|
||||
<div className="h-[180px] w-full">
|
||||
<div className="h-[160px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData} margin={{ top: 5, right: 10, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsl(var(--border))" />
|
||||
@@ -1158,16 +1160,63 @@ export function BlackFridayDashboard() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Orders Chart */}
|
||||
<Card className="border-muted">
|
||||
<CardHeader className="pb-2 pt-3 px-4">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<ShoppingBag className="h-4 w-4 text-muted-foreground" />
|
||||
Orders
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-2 pt-0">
|
||||
<div className="h-[160px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData} margin={{ top: 5, right: 10, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsl(var(--border))" />
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: "hsl(var(--muted-foreground))", fontSize: 10 }}
|
||||
dy={5}
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={(value) => formatNumber(Number(value))}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: "hsl(var(--muted-foreground))", fontSize: 10 }}
|
||||
width={40}
|
||||
/>
|
||||
<RechartsTooltip content={<CustomTooltip />} />
|
||||
{sortedYears.map((year) => (
|
||||
<Line
|
||||
key={year}
|
||||
type="monotone"
|
||||
name={`${year}`}
|
||||
dataKey={`${year}-orders`}
|
||||
stroke={getStrokeForYear(year)}
|
||||
strokeWidth={year === currentYear ? 2.5 : 1.5}
|
||||
strokeOpacity={year === currentYear ? 1 : 0.6}
|
||||
dot={year === currentYear ? { r: 3, strokeWidth: 2, fill: "hsl(var(--background))" } : false}
|
||||
activeDot={{ r: 4, strokeWidth: 0 }}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* AOV Chart */}
|
||||
<Card className="border-muted">
|
||||
<CardHeader className="pb-2 pt-3 px-4">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Zap className="h-4 w-4 text-muted-foreground" />
|
||||
AOV by Day
|
||||
AOV
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-2 pt-0">
|
||||
<div className="h-[180px] w-full">
|
||||
<div className="h-[160px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData} margin={{ top: 5, right: 10, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsl(var(--border))" />
|
||||
@@ -1365,7 +1414,7 @@ export function BlackFridayDashboard() {
|
||||
|
||||
{/* Charts with legend */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col lg:flex-row items-center justify-between">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Daily Trends</h3>
|
||||
{renderLegend()}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user