import React from 'react'; import superagent from 'superagent'; import { Header, Segment, Form, Button, Grid, Dropdown, Statistic, Icon, Checkbox } from 'semantic-ui-react'; import { ComposedChart, BarChart, Bar, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; import { format, subDays, subMonths, startOfDay, endOfDay, parseISO } from 'date-fns'; import BigShipperWrapper from './BigShipperWrapper.jsx'; import { splitUpperCamelCase } from '../CloudApi/ApiUtils.js'; // Status colors for the stacked bar chart const STATUS_COLORS = { Shipped: '#21ba45', InTransit: '#2185d0', Delivered: '#00b5ad', PackedAndLabelled: '#fbbd08', WaitingForPickup: '#6495ed', Picking: '#a333c8', ReadyToPack: '#e03997', Packing: '#6435c9', PrepareShippingLabel: '#a5673f', PreparingToShip: '#767676', Init: '#999999', DeliveryFailed: '#db2828', Cancelled: '#a04050', Unknown: '#cccccc' }; // Product type colors for the stacked bar chart (different palette from status colors) const PRODUCT_TYPE_COLORS = { // Main headsets - warm colors Beyond2: '#e74c3c', Beyond2Eye: '#c0392b', Beyond2EyeVRChat: '#9b59b6', BigscreenBeyondV1: '#d35400', // Cyberbox bundles - blues and teals B2ClearCyberbox: '#3498db', B2BlackCyberbox: '#2c3e50', B2OrangeCyberbox: '#e67e22', B2PurpleCyberbox: '#8e44ad', B2ClearCyberboxMini: '#5dade2', B2BlackCyberboxMini: '#566573', B2OrangeCyberboxMini: '#f39c12', B2PurpleCyberboxMini: '#a569bd', B2ClearCyberboxV2: '#85c1e9', B2BlackCyberboxV2: '#7f8c8d', B2OrangeCyberboxV2: '#fad7a0', B2PurpleCyberboxV2: '#d7bde2', // Eye-tracking cyberboxes B2EyeClearCyberbox: '#1abc9c', B2EyeBlackCyberbox: '#17202a', B2EyeOrangeCyberbox: '#dc7633', B2EyePurpleCyberbox: '#76448a', B2EyeClearCyberboxMini: '#48c9b0', B2EyeBlackCyberboxMini: '#2e4053', B2EyeOrangeCyberboxMini: '#eb984e', B2EyePurpleCyberboxMini: '#af7ac5', B2EyeClearCyberboxV2: '#a3e4d7', B2EyeBlackCyberboxV2: '#515a5a', B2EyeOrangeCyberboxV2: '#f5cba7', B2EyePurpleCyberboxV2: '#d2b4de', // Cushions - greens CustomCushion: '#27ae60', BeyondCushionV1: '#2ecc71', ReplacementBeyondCushionV1: '#58d68d', GenericCushion: '#82e0aa', // Prescription lenses - yellows PrescriptionLenses: '#f1c40f', PrescriptionLensInserts: '#f4d03f', // Audio - purples AudioStrapV1: '#9b59b6', AudioStrap: '#a569bd', // Accessories - grays and browns BeyondFibreOpticCableV1: '#95a5a6', BeyondLinkBoxV1: '#7f8c8d', BeyondSoftStrapV1: '#bdc3c7', B2AccessoriesBundle: '#aab7b8', BeyondLinkBoxV2: '#99a3a4', BeyondFibreOpticCableV2: '#b2babb', BeyondSoftStrapV2: '#d5dbdb', // Replacements - muted versions ReplacementBigscreenBeyondV1: '#e59866', ReplacementBeyondFibreOpticCableV1: '#abb2b9', ReplacementBeyondLinkBoxV1: '#909497', ReplacementBeyondSoftStrapV1: '#ccd1d1', // Shells B2ShellClear: '#ecf0f1', B2ShellBlack: '#2c3e50', B2ShellOrange: '#e67e22', B2ShellYellow: '#f1c40f', B2ShellVRChatPurple: '#8e44ad', // Other items UniversalHalo: '#16a085', HaloMount: '#1abc9c', CoverShell: '#34495e', BeyondCyberboxV1: '#5d6d7e', IPDDiscoveryUnitV1: '#6c3483', Beyond2IPDTool: '#7d3c98', StorageCan: '#a6acaf', Merchandise: '#f06292', BigscreenRacingGloves: '#e53935', UniversalLightFitSeal: '#4db6ac', // Upgrades Beyond2Upgrade: '#ff7043', Beyond2EyeUpgrade: '#ff5722', // Fallbacks Unknown: '#cccccc', Other: '#888888' }; const MOVING_AVG_COLOR = '#ff6b6b'; const VIEW_MODE_OPTIONS = [ { key: 'status', text: 'By Status', value: 'status' }, { key: 'productType', text: 'By Product Type', value: 'productType' } ]; const RESOLUTION_OPTIONS = [ { key: 'hour', text: 'Hourly', value: 'hour' }, { key: 'day', text: 'Daily', value: 'day' }, { key: 'week', text: 'Weekly', value: 'week' }, { key: 'month', text: 'Monthly', value: 'month' } ]; const MOVING_AVG_WINDOW_OPTIONS = [ { key: '3', text: '3-period', value: 3 }, { key: '5', text: '5-period', value: 5 }, { key: '7', text: '7-period', value: 7 }, { key: '14', text: '14-period', value: 14 } ]; const PRESET_RANGES = [ { key: '7d', text: 'Last 7 Days', days: 7, resolution: 'day' }, { key: '14d', text: 'Last 14 Days', days: 14, resolution: 'day' }, { key: '30d', text: 'Last 30 Days', days: 30, resolution: 'day' }, { key: '3m', text: 'Last 3 Months', months: 3, resolution: 'week' }, { key: '6m', text: 'Last 6 Months', months: 6, resolution: 'week' }, { key: '1y', text: 'Last Year', months: 12, resolution: 'month' } ]; export default class BigShipmentsStats extends React.Component { constructor(props) { super(props); const today = new Date(); const thirtyDaysAgo = subDays(today, 29); this.state = { loading: false, chartData: [], startDate: format(thirtyDaysAgo, 'yyyy-MM-dd'), endDate: format(today, 'yyyy-MM-dd'), resolution: 'day', totalShipments: 0, categoryTotals: {}, allCategories: [], activePreset: '30d', showMovingAvg: true, movingAvgWindow: 3, viewMode: 'productType', copyConfirmation: null }; } async componentDidMount() { await this.fetchStats(); } /** * Calculate moving average for an array of data points * @param {Array} data - Array of chart data objects * @param {number} window - Window size for moving average * @returns {Array} - Data with movingAvg property added */ calculateMovingAverage(data, window) { return data.map((item, index) => { // Calculate the start index for the window const startIdx = Math.max(0, index - window + 1); const windowData = data.slice(startIdx, index + 1); // Calculate average of totals in the window const sum = windowData.reduce((acc, d) => acc + d.total, 0); const avg = sum / windowData.length; return { ...item, movingAvg: Math.round(avg * 100) / 100 }; }); } async fetchStats() { this.setState({ loading: true, error: null }); try { const { startDate, endDate, resolution, movingAvgWindow, viewMode } = this.state; const start = startOfDay(parseISO(startDate)); const end = endOfDay(parseISO(endDate)); const minCreatedAt = start.getTime(); const maxCreatedAt = end.getTime(); const groupByParam = viewMode === 'productType' ? '&groupBy=productType' : ''; const res = await superagent.get( `/api/admin/shipper/shipments/stats?minCreatedAt=${minCreatedAt}&maxCreatedAt=${maxCreatedAt}&resolution=${resolution}${groupByParam}` ); const { items, totals } = res.body; // Get the category key based on view mode const categoryKey = viewMode === 'productType' ? 'productType' : 'status'; const totalsKey = viewMode === 'productType' ? 'byProductType' : 'byStatus'; // Get all unique categories from the response const allCategories = [...new Set(items.map(item => item[categoryKey]))].sort(); // Create a map of period -> { category: count, ... } const dataByPeriod = {}; items.forEach(item => { if (!dataByPeriod[item.period]) { dataByPeriod[item.period] = {}; } dataByPeriod[item.period][item[categoryKey]] = item.count; }); // Convert to chart data format const periods = Object.keys(dataByPeriod).sort(); let chartData = periods.map(period => { const periodData = { period: period, displayPeriod: this.formatPeriodLabel(period, resolution), total: 0 }; allCategories.forEach(category => { const count = dataByPeriod[period][category] || 0; periodData[category] = count; periodData.total += count; }); return periodData; }); // Calculate moving average chartData = this.calculateMovingAverage(chartData, movingAvgWindow); this.setState({ chartData, totalShipments: totals.total, categoryTotals: totals[totalsKey] || {}, allCategories, loading: false }); } catch (e) { console.error('Error fetching shipment stats:', e); this.setState({ loading: false, error: e.message || 'Failed to fetch shipment stats' }); } } formatPeriodLabel(period, resolution) { switch (resolution) { case 'hour': // Format: "2024-01-15 14:00" -> "Jan 15 2pm" const hourMatch = period.match(/(\d{4})-(\d{2})-(\d{2}) (\d{2}):00/); if (hourMatch) { const date = new Date(parseInt(hourMatch[1]), parseInt(hourMatch[2]) - 1, parseInt(hourMatch[3]), parseInt(hourMatch[4])); return format(date, 'MMM d ha'); } return period; case 'day': // Format: "2024-01-15" -> "Jan 15" const dayMatch = period.match(/(\d{4})-(\d{2})-(\d{2})/); if (dayMatch) { const date = new Date(parseInt(dayMatch[1]), parseInt(dayMatch[2]) - 1, parseInt(dayMatch[3])); return format(date, 'MMM d'); } return period; case 'week': // Format: "2024-W01" -> "W1 '24" const weekMatch = period.match(/(\d{4})-W(\d{2})/); if (weekMatch) { return `W${parseInt(weekMatch[2])} '${weekMatch[1].slice(2)}`; } return period; case 'month': // Format: "2024-01" -> "Jan '24" const monthMatch = period.match(/(\d{4})-(\d{2})/); if (monthMatch) { const date = new Date(parseInt(monthMatch[1]), parseInt(monthMatch[2]) - 1, 1); return format(date, "MMM ''yy"); } return period; default: return period; } } handleStartDateChange = (e) => { this.setState({ startDate: e.target.value, activePreset: null }); } handleEndDateChange = (e) => { this.setState({ endDate: e.target.value, activePreset: null }); } handleResolutionChange = (e, { value }) => { this.setState({ resolution: value, activePreset: null }, () => this.fetchStats()); } handleViewModeChange = (e, { value }) => { this.setState({ viewMode: value }, () => this.fetchStats()); } handleBarClick = (data) => { if (data && data.activePayload && data.activeLabel) { const { viewMode, chartData } = this.state; const groupByLabel = viewMode === 'productType' ? 'Product Type' : 'Status'; const label = data.activeLabel; // Get the data for this period const periodData = chartData.find(d => d.displayPeriod === label); if (!periodData) return; // Build the entries from the payload (excluding movingAvg) const entries = data.activePayload .filter(entry => entry.dataKey !== 'movingAvg' && entry.value > 0) .reverse(); const total = entries.reduce((sum, entry) => sum + (entry.value || 0), 0); let markdown = `**Shipments for ${label}**\n`; markdown += `_(Grouped by ${groupByLabel})_\n\n`; entries.forEach(entry => { markdown += `- **${splitUpperCamelCase(entry.name)}**: ${entry.value.toLocaleString()}\n`; }); markdown += `\n**Total: ${total.toLocaleString()}**`; navigator.clipboard.writeText(markdown).then(() => { this.setState({ copyConfirmation: label }); setTimeout(() => this.setState({ copyConfirmation: null }), 2000); }); } } handleMovingAvgToggle = () => { this.setState(prevState => ({ showMovingAvg: !prevState.showMovingAvg })); } handleMovingAvgWindowChange = (e, { value }) => { this.setState({ movingAvgWindow: value }, () => { // Recalculate moving average with new window const { chartData } = this.state; const updatedData = this.calculateMovingAverage( chartData.map(d => ({ ...d, movingAvg: undefined })), value ); this.setState({ chartData: updatedData }); }); } handleApplyDateRange = async () => { await this.fetchStats(); } handlePresetRange = (preset) => { const today = new Date(); let startDate; if (preset.days) { startDate = subDays(today, preset.days - 1); } else if (preset.months) { startDate = subMonths(today, preset.months); } this.setState({ startDate: format(startDate, 'yyyy-MM-dd'), endDate: format(today, 'yyyy-MM-dd'), resolution: preset.resolution, activePreset: preset.key }, () => this.fetchStats()); } renderCustomTooltip = ({ active, payload, label }) => { if (active && payload && payload.length) { // Separate the moving average from category data const movingAvgEntry = payload.find(entry => entry.dataKey === 'movingAvg'); const categoryEntries = payload.filter(entry => entry.dataKey !== 'movingAvg' && entry.value > 0); // Reverse to match visual stacking order (top of stack = top of list) const orderedEntries = [...categoryEntries].reverse(); const total = categoryEntries.reduce((sum, entry) => sum + (entry.value || 0), 0); return (

{label}

{orderedEntries.map((entry, index) => (
{splitUpperCamelCase(entry.name)} {entry.value}
))}
Total {total}
{movingAvgEntry && (
{this.state.movingAvgWindow}-period Avg {movingAvgEntry.value}
)}
Click to copy
); } return null; } getColorForCategory(category) { const { viewMode } = this.state; if (viewMode === 'productType') { return PRODUCT_TYPE_COLORS[category] || '#888'; } return STATUS_COLORS[category] || '#888'; } renderCategorySidebar() { const { categoryTotals } = this.state; const sortedCategories = Object.entries(categoryTotals).sort((a, b) => b[1] - a[1]); return (
Breakdown
{sortedCategories.map(([category, count]) => { const bgColor = this.getColorForCategory(category); return (
{splitUpperCamelCase(category)} {count.toLocaleString()}
); })}
); } render() { const { chartData, loading, startDate, endDate, resolution, totalShipments, allCategories, activePreset, error, showMovingAvg, movingAvgWindow, viewMode } = this.state; const viewModeLabel = viewMode === 'productType' ? 'Product Type' : 'Status'; return (
Quick Select
{PRESET_RANGES.map(preset => ( ))}
Custom Range
{error && ( Error: {error} )}
Shipments by {viewModeLabel} ({resolution.charAt(0).toUpperCase() + resolution.slice(1)})
{showMovingAvg && ( )}
{totalShipments.toLocaleString()} Total Shipments
{allCategories.map(category => ( ))} {showMovingAvg && ( )}
{this.renderCategorySidebar()}
{this.state.copyConfirmation && (
Copied data for {this.state.copyConfirmation}
)}
); } }