@liiift-studio/sales-portal 2.3.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/SETUP.md +1 -1
- package/api/getYearSales.js +105 -0
- package/api/utils/processors/invoiceProcessor.js +2 -2
- package/components/DateRangeSalesTable.js +1 -1
- package/components/Sales.js +112 -30
- package/components/SalesPortalPage.js +1 -1
- package/components/SalesTable.js +1 -1
- package/components/YearOverview.js +243 -0
- package/index.js +1 -0
- package/package.json +1 -1
- package/styles/sales-portal.module.scss +1 -1
- package/styles/sales-portal.theme.js +1 -1
package/README.md
CHANGED
package/SETUP.md
CHANGED
|
@@ -39,7 +39,7 @@ export default function SalesPortal() {
|
|
|
39
39
|
title: 'Sales Portal',
|
|
40
40
|
description: 'Track your typeface sales performance and revenue.',
|
|
41
41
|
subtitle: 'All sales are reviewed internally before payouts.',
|
|
42
|
-
dashboardTitle: '
|
|
42
|
+
dashboardTitle: 'Sales',
|
|
43
43
|
dashboardSubtitle: 'by designer'
|
|
44
44
|
}}
|
|
45
45
|
/>
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// API endpoint for fetching a full year of sales data in a single request
|
|
2
|
+
import { authenticateDesigner, processSalesData } from './utils/salesDataProcessor';
|
|
3
|
+
import { sendError, requirePost } from './utils/apiResponse';
|
|
4
|
+
|
|
5
|
+
export const config = { maxDuration: 300 };
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Fetches 12 months of sales data, processes each month, and returns
|
|
9
|
+
* the results grouped by month with typeface breakdowns.
|
|
10
|
+
* This avoids 12 separate API calls from the frontend.
|
|
11
|
+
*/
|
|
12
|
+
export default async function handler(req, res) {
|
|
13
|
+
if (!requirePost(req, res)) return;
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const { user, password, year, admin } = req.body;
|
|
17
|
+
|
|
18
|
+
if (!year) {
|
|
19
|
+
return sendError(res, 400, 'Year is required');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const designer = await authenticateDesigner(user, password);
|
|
23
|
+
if (!designer) {
|
|
24
|
+
return sendError(res, 401, 'Looks like there was an issue finding the account.');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const months = [];
|
|
28
|
+
|
|
29
|
+
// Process each month sequentially to avoid Stripe rate limiting
|
|
30
|
+
for (let month = 0; month < 12; month++) {
|
|
31
|
+
const monthDate = new Date(Date.UTC(year, month, 1));
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const sales = await processSalesData({
|
|
35
|
+
date: monthDate.toISOString(),
|
|
36
|
+
designer,
|
|
37
|
+
admin
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Group sales by typeface for the stacked chart
|
|
41
|
+
const typefaceMap = {};
|
|
42
|
+
let monthTotal = 0;
|
|
43
|
+
let shippingTotal = 0;
|
|
44
|
+
|
|
45
|
+
sales.forEach(sale => {
|
|
46
|
+
if (sale.shippingProvision) {
|
|
47
|
+
shippingTotal += sale.total || 0;
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const typefaceName = sale.typeface?.title || sale.description?.split(' (')[0] || 'Other';
|
|
52
|
+
if (!typefaceMap[typefaceName]) {
|
|
53
|
+
typefaceMap[typefaceName] = { total: 0, count: 0 };
|
|
54
|
+
}
|
|
55
|
+
typefaceMap[typefaceName].total += sale.total || 0;
|
|
56
|
+
typefaceMap[typefaceName].count += 1;
|
|
57
|
+
monthTotal += sale.total || 0;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
months.push({
|
|
61
|
+
month,
|
|
62
|
+
year,
|
|
63
|
+
total: monthTotal,
|
|
64
|
+
shippingTotal,
|
|
65
|
+
salesCount: sales.length,
|
|
66
|
+
currency: sales[0]?.currency || 'usd',
|
|
67
|
+
typefaces: Object.entries(typefaceMap)
|
|
68
|
+
.map(([name, data]) => ({ name, ...data }))
|
|
69
|
+
.sort((a, b) => b.total - a.total),
|
|
70
|
+
});
|
|
71
|
+
} catch (monthError) {
|
|
72
|
+
// If a single month fails, include it with zero data rather than failing the whole request
|
|
73
|
+
console.error(`Error processing month ${month + 1}/${year}:`, monthError.message);
|
|
74
|
+
months.push({
|
|
75
|
+
month,
|
|
76
|
+
year,
|
|
77
|
+
total: 0,
|
|
78
|
+
shippingTotal: 0,
|
|
79
|
+
salesCount: 0,
|
|
80
|
+
currency: 'usd',
|
|
81
|
+
typefaces: [],
|
|
82
|
+
error: monthError.message,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Build a list of all typefaces across the year for consistent chart series
|
|
88
|
+
const allTypefaces = new Set();
|
|
89
|
+
months.forEach(m => m.typefaces.forEach(t => allTypefaces.add(t.name)));
|
|
90
|
+
|
|
91
|
+
res.status(200).json({
|
|
92
|
+
success: true,
|
|
93
|
+
data: {
|
|
94
|
+
year,
|
|
95
|
+
months,
|
|
96
|
+
allTypefaces: [...allTypefaces].sort(),
|
|
97
|
+
yearTotal: months.reduce((sum, m) => sum + m.total, 0),
|
|
98
|
+
yearShipping: months.reduce((sum, m) => sum + m.shippingTotal, 0),
|
|
99
|
+
currency: months.find(m => m.currency)?.currency || 'usd',
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
} catch (error) {
|
|
103
|
+
return sendError(res, 500, 'Failed to fetch year sales data', error);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -17,8 +17,8 @@ import { isTestSale } from '../clients';
|
|
|
17
17
|
function findMatches({ invoice, line = {}, sanitySales = [] }) {
|
|
18
18
|
// Find matching order
|
|
19
19
|
const associatedOrder =
|
|
20
|
-
(invoice?.id && sanitySales.find(order => order
|
|
21
|
-
(invoice?.payment_intent && sanitySales.find(order => order
|
|
20
|
+
(invoice?.id && sanitySales.find(order => order?.orderStatus?.invoiceId === invoice.id)) ||
|
|
21
|
+
(invoice?.payment_intent && sanitySales.find(order => order?.orderStatus?.paymentIntentId === invoice.payment_intent)) ||
|
|
22
22
|
(invoice?.customer?.email && sanitySales.find(order => {
|
|
23
23
|
const orderDate = new Date(order._createdAt);
|
|
24
24
|
const invoiceDate = new Date(invoice.created * 1000);
|
|
@@ -514,7 +514,7 @@ export function DateRangeSalesTable({ designer, admin, loading, updateLoadingSta
|
|
|
514
514
|
? '2px solid var(--green, green)'
|
|
515
515
|
: '2px solid var(--red, red)',
|
|
516
516
|
color: reconciliationData.isReconciled
|
|
517
|
-
? 'var(--
|
|
517
|
+
? 'var(--black, #1a1a1a)'
|
|
518
518
|
: 'var(--red, red)',
|
|
519
519
|
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
|
520
520
|
filter: 'invert(0) !important',
|
package/components/Sales.js
CHANGED
|
@@ -18,6 +18,7 @@ import countryCodes from '../data/countryCode.json';
|
|
|
18
18
|
import { getCurrencySymbol, getCurrencyLabel } from '../utils/currencyUtils';
|
|
19
19
|
import { SalesTable } from './SalesTable';
|
|
20
20
|
import { DateRangeSalesTable } from './DateRangeSalesTable';
|
|
21
|
+
import YearOverview from './YearOverview';
|
|
21
22
|
import SalesChart from './SalesChart';
|
|
22
23
|
import TopPerformers from './TopPerformers';
|
|
23
24
|
import TypefaceList from './TypefaceList';
|
|
@@ -47,6 +48,7 @@ export default function Sales(props) {
|
|
|
47
48
|
const [previousSalesError, setPreviousSalesError] = useState('');
|
|
48
49
|
const [retryInfo, setRetryInfo] = useState({ retrying: false, attempt: 0, label: '' });
|
|
49
50
|
const [date, setDate] = useState(null);
|
|
51
|
+
const [viewMode, setViewMode] = useState('month'); // 'month' | 'year'
|
|
50
52
|
const [displayLosses, setDisplayLosses] = useState(false);
|
|
51
53
|
|
|
52
54
|
// Max retry attempts for failed API calls
|
|
@@ -201,6 +203,11 @@ export default function Sales(props) {
|
|
|
201
203
|
},
|
|
202
204
|
'Sales data'
|
|
203
205
|
);
|
|
206
|
+
|
|
207
|
+
if (!response.ok) {
|
|
208
|
+
throw new Error(`HTTP ${response.status}`);
|
|
209
|
+
}
|
|
210
|
+
|
|
204
211
|
const data = await response.json();
|
|
205
212
|
|
|
206
213
|
if (data.success) {
|
|
@@ -255,6 +262,10 @@ export default function Sales(props) {
|
|
|
255
262
|
'Previous sales data'
|
|
256
263
|
);
|
|
257
264
|
|
|
265
|
+
if (!response.ok) {
|
|
266
|
+
throw new Error(`HTTP ${response.status}`);
|
|
267
|
+
}
|
|
268
|
+
|
|
258
269
|
const data = await response.json();
|
|
259
270
|
|
|
260
271
|
if (data.success) {
|
|
@@ -312,8 +323,27 @@ export default function Sales(props) {
|
|
|
312
323
|
const processedLicenseTypeData = processLicenseTypeData(sales);
|
|
313
324
|
setLicenseTypeData(processedLicenseTypeData);
|
|
314
325
|
|
|
315
|
-
// Set total sales
|
|
316
|
-
const
|
|
326
|
+
// Set total sales from unique invoice amounts (accounts for discounts correctly)
|
|
327
|
+
const seenInvoices = new Set();
|
|
328
|
+
let totalRevenue = 0;
|
|
329
|
+
sales.forEach(s => {
|
|
330
|
+
if (s.shippingProvision) return;
|
|
331
|
+
const sid = s.id;
|
|
332
|
+
if (sid && !seenInvoices.has(sid)) {
|
|
333
|
+
seenInvoices.add(sid);
|
|
334
|
+
totalRevenue += (s.invoiceTotal || s.total || 0) / 100;
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
// Subtract refunds
|
|
338
|
+
const seenRefunds = new Set();
|
|
339
|
+
sales.forEach(s => {
|
|
340
|
+
s.refunds?.forEach(r => {
|
|
341
|
+
if (r.id && !seenRefunds.has(r.id)) {
|
|
342
|
+
seenRefunds.add(r.id);
|
|
343
|
+
totalRevenue -= (r.total || 0) / 100;
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
});
|
|
317
347
|
setTotal(totalRevenue);
|
|
318
348
|
|
|
319
349
|
// Calculate revenue change percentage compared to previous period
|
|
@@ -565,10 +595,14 @@ export default function Sales(props) {
|
|
|
565
595
|
data-disabled={loading}
|
|
566
596
|
>
|
|
567
597
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
568
|
-
<IconButton
|
|
598
|
+
<IconButton
|
|
569
599
|
onClick={() => {
|
|
570
600
|
const newDate = new Date(date);
|
|
571
|
-
|
|
601
|
+
if (viewMode === 'year') {
|
|
602
|
+
newDate.setUTCFullYear(date.getUTCFullYear() - 1);
|
|
603
|
+
} else {
|
|
604
|
+
newDate.setUTCMonth(date.getUTCMonth() - 1);
|
|
605
|
+
}
|
|
572
606
|
setDate(newDate);
|
|
573
607
|
}}
|
|
574
608
|
size="small"
|
|
@@ -577,19 +611,23 @@ export default function Sales(props) {
|
|
|
577
611
|
<ChevronLeftIcon />
|
|
578
612
|
</IconButton>
|
|
579
613
|
<DatePicker
|
|
580
|
-
label='Month/Year (UTC)'
|
|
581
|
-
views={['month', 'year']}
|
|
582
|
-
format=
|
|
614
|
+
label={viewMode === 'year' ? 'Year (UTC)' : 'Month/Year (UTC)'}
|
|
615
|
+
views={viewMode === 'year' ? ['year'] : ['month', 'year']}
|
|
616
|
+
format={viewMode === 'year' ? 'YYYY' : 'MMM YYYY'}
|
|
583
617
|
formatDensity="dense"
|
|
584
618
|
slotProps={{ textField: { variant: "filled" } }}
|
|
585
619
|
sx={{ "& *": { borderRadius: "4px" } }}
|
|
586
620
|
value={dayjs.utc(date)}
|
|
587
621
|
onChange={(newValue) => setDate(newValue.toDate())}
|
|
588
622
|
/>
|
|
589
|
-
<IconButton
|
|
623
|
+
<IconButton
|
|
590
624
|
onClick={() => {
|
|
591
625
|
const newDate = new Date(date);
|
|
592
|
-
|
|
626
|
+
if (viewMode === 'year') {
|
|
627
|
+
newDate.setUTCFullYear(date.getUTCFullYear() + 1);
|
|
628
|
+
} else {
|
|
629
|
+
newDate.setUTCMonth(date.getUTCMonth() + 1);
|
|
630
|
+
}
|
|
593
631
|
setDate(newDate);
|
|
594
632
|
}}
|
|
595
633
|
size="small"
|
|
@@ -671,6 +709,37 @@ export default function Sales(props) {
|
|
|
671
709
|
</IconButton>
|
|
672
710
|
</Tooltip>
|
|
673
711
|
)}
|
|
712
|
+
{/* Month/Year toggle */}
|
|
713
|
+
<Box sx={{
|
|
714
|
+
display: 'flex',
|
|
715
|
+
ml: 1,
|
|
716
|
+
border: '1px solid var(--black, #1a1a1a)',
|
|
717
|
+
borderRadius: '4px',
|
|
718
|
+
overflow: 'hidden',
|
|
719
|
+
}}>
|
|
720
|
+
{['month', 'year'].map(mode => (
|
|
721
|
+
<Box
|
|
722
|
+
key={mode}
|
|
723
|
+
onClick={() => setViewMode(mode)}
|
|
724
|
+
sx={{
|
|
725
|
+
px: 1.5,
|
|
726
|
+
py: 0.5,
|
|
727
|
+
cursor: 'pointer',
|
|
728
|
+
fontSize: '0.75rem',
|
|
729
|
+
fontWeight: viewMode === mode ? 'bold' : 'normal',
|
|
730
|
+
bgcolor: viewMode === mode ? 'var(--black, #1a1a1a)' : 'transparent',
|
|
731
|
+
color: viewMode === mode ? 'var(--white, white)' : 'inherit',
|
|
732
|
+
textTransform: 'uppercase',
|
|
733
|
+
letterSpacing: '0.05em',
|
|
734
|
+
'&:hover': {
|
|
735
|
+
bgcolor: viewMode === mode ? 'var(--black, #1a1a1a)' : 'rgba(0,0,0,0.05)',
|
|
736
|
+
},
|
|
737
|
+
}}
|
|
738
|
+
>
|
|
739
|
+
{mode === 'month' ? 'Mo' : 'Yr'}
|
|
740
|
+
</Box>
|
|
741
|
+
))}
|
|
742
|
+
</Box>
|
|
674
743
|
</Box>
|
|
675
744
|
</Box>
|
|
676
745
|
|
|
@@ -724,7 +793,7 @@ export default function Sales(props) {
|
|
|
724
793
|
</Box>
|
|
725
794
|
}
|
|
726
795
|
</Box>
|
|
727
|
-
), [loading, sales, total, designer, date, message, revenueChangePercent, previousSales, retryInfo]);
|
|
796
|
+
), [loading, sales, total, designer, date, message, revenueChangePercent, previousSales, retryInfo, viewMode]);
|
|
728
797
|
|
|
729
798
|
return (
|
|
730
799
|
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
|
@@ -753,7 +822,6 @@ export default function Sales(props) {
|
|
|
753
822
|
{/* Sales Data Display */}
|
|
754
823
|
{!!sales.length && (
|
|
755
824
|
<>
|
|
756
|
-
|
|
757
825
|
<Box
|
|
758
826
|
data-disabled={loading}
|
|
759
827
|
className={`sales-data-section ${styles.salesSection}`}
|
|
@@ -765,18 +833,32 @@ export default function Sales(props) {
|
|
|
765
833
|
}}
|
|
766
834
|
>
|
|
767
835
|
|
|
768
|
-
{/* Chart Section */}
|
|
769
|
-
|
|
770
|
-
<
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
836
|
+
{/* Chart Section — Year or Month */}
|
|
837
|
+
{viewMode === 'year' && date ? (
|
|
838
|
+
<Box className="year-overview-wrapper">
|
|
839
|
+
<YearOverview
|
|
840
|
+
designer={designer}
|
|
841
|
+
year={date.getUTCFullYear()}
|
|
842
|
+
onMonthClick={(monthIndex) => {
|
|
843
|
+
const newDate = new Date(Date.UTC(date.getUTCFullYear(), monthIndex, 1));
|
|
844
|
+
setDate(newDate);
|
|
845
|
+
setViewMode('month');
|
|
846
|
+
}}
|
|
847
|
+
/>
|
|
848
|
+
</Box>
|
|
849
|
+
) : (
|
|
850
|
+
<Box className="sales-chart-wrapper">
|
|
851
|
+
<SalesChart
|
|
852
|
+
sales={sales}
|
|
853
|
+
chartState={chartState}
|
|
854
|
+
seriesData={seriesData}
|
|
855
|
+
displayLosses={displayLosses}
|
|
856
|
+
setDisplayLosses={setDisplayLosses}
|
|
857
|
+
date={date}
|
|
858
|
+
loading={loading}
|
|
859
|
+
/>
|
|
860
|
+
</Box>
|
|
861
|
+
)}
|
|
780
862
|
|
|
781
863
|
{/* Summary Dashboard */}
|
|
782
864
|
<Box className="summary-cards-wrapper">
|
|
@@ -828,7 +910,7 @@ export default function Sales(props) {
|
|
|
828
910
|
{/* Sales Table Section */}
|
|
829
911
|
{designer && (
|
|
830
912
|
<Box className="sales-table-wrapper">
|
|
831
|
-
<SalesTable
|
|
913
|
+
<SalesTable
|
|
832
914
|
sales={sales}
|
|
833
915
|
designer={designer}
|
|
834
916
|
admin={admin}
|
|
@@ -841,12 +923,12 @@ export default function Sales(props) {
|
|
|
841
923
|
</Box>
|
|
842
924
|
|
|
843
925
|
{/* Date Range Sales Table Section */}
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
926
|
+
<DateRangeSalesTable
|
|
927
|
+
designer={designer}
|
|
928
|
+
admin={admin}
|
|
929
|
+
loading={loadingStates.dateRangeSalesData}
|
|
930
|
+
updateLoadingState={updateLoadingState}
|
|
931
|
+
/>
|
|
850
932
|
</>
|
|
851
933
|
)}
|
|
852
934
|
|
package/components/SalesTable.js
CHANGED
|
@@ -173,7 +173,7 @@ export function SalesTable({ sales = [], designer = {}, admin = false, loading =
|
|
|
173
173
|
? '2px solid var(--green, green)'
|
|
174
174
|
: '2px solid var(--red, red)',
|
|
175
175
|
color: reconciliationData.isReconciled
|
|
176
|
-
? 'var(--
|
|
176
|
+
? 'var(--black, #1a1a1a)'
|
|
177
177
|
: 'var(--red, red)',
|
|
178
178
|
}}>
|
|
179
179
|
{reconciliationData.isLoading ? (
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// Year overview component showing 12-month stacked bar chart by typeface
|
|
2
|
+
import React, { useState, useEffect, useMemo } from 'react';
|
|
3
|
+
import { Box, Typography, CircularProgress, Tooltip } from '@mui/material';
|
|
4
|
+
import { ResponsiveChartContainer } from '@mui/x-charts/ResponsiveChartContainer';
|
|
5
|
+
import { BarPlot } from '@mui/x-charts/BarChart';
|
|
6
|
+
import { ChartsXAxis } from '@mui/x-charts/ChartsXAxis';
|
|
7
|
+
import { ChartsYAxis } from '@mui/x-charts/ChartsYAxis';
|
|
8
|
+
import { ChartsTooltip } from '@mui/x-charts/ChartsTooltip';
|
|
9
|
+
import { ChartsAxisHighlight } from '@mui/x-charts/ChartsAxisHighlight';
|
|
10
|
+
import { getCurrencySymbol } from '../utils/currencyUtils';
|
|
11
|
+
|
|
12
|
+
const MONTH_LABELS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
13
|
+
|
|
14
|
+
// Color palette for typeface segments
|
|
15
|
+
const COLORS = [
|
|
16
|
+
'var(--black, #1a1a1a)',
|
|
17
|
+
'var(--green, #4caf50)',
|
|
18
|
+
'#2196f3',
|
|
19
|
+
'#ff9800',
|
|
20
|
+
'#9c27b0',
|
|
21
|
+
'#f44336',
|
|
22
|
+
'#00bcd4',
|
|
23
|
+
'#795548',
|
|
24
|
+
'#607d8b',
|
|
25
|
+
'#e91e63',
|
|
26
|
+
'#3f51b5',
|
|
27
|
+
'#cddc39',
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Year overview component
|
|
32
|
+
* @param {Object} props
|
|
33
|
+
* @param {Object} props.designer - Designer data with user/password
|
|
34
|
+
* @param {number} props.year - Year to display
|
|
35
|
+
* @param {Function} props.onMonthClick - Called when a bar is clicked, receives month index
|
|
36
|
+
*/
|
|
37
|
+
export default function YearOverview({ designer, year, onMonthClick }) {
|
|
38
|
+
const [data, setData] = useState(null);
|
|
39
|
+
const [loading, setLoading] = useState(false);
|
|
40
|
+
const [error, setError] = useState('');
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (!designer?.user || !designer?.password || !year) return;
|
|
44
|
+
|
|
45
|
+
setLoading(true);
|
|
46
|
+
setError('');
|
|
47
|
+
|
|
48
|
+
fetch('/api/sales-portal/getYearSales', {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
headers: { 'Content-Type': 'application/json' },
|
|
51
|
+
body: JSON.stringify({
|
|
52
|
+
user: designer.user,
|
|
53
|
+
password: designer.password,
|
|
54
|
+
year,
|
|
55
|
+
admin: designer.admin || false,
|
|
56
|
+
}),
|
|
57
|
+
})
|
|
58
|
+
.then(res => res.json())
|
|
59
|
+
.then(res => {
|
|
60
|
+
if (res.success) {
|
|
61
|
+
setData(res.data);
|
|
62
|
+
} else {
|
|
63
|
+
setError(res.message || 'Failed to load year data');
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
.catch(err => setError(err.message))
|
|
67
|
+
.finally(() => setLoading(false));
|
|
68
|
+
}, [designer?.user, designer?.password, designer?.admin, year]);
|
|
69
|
+
|
|
70
|
+
// Build chart series from typeface data
|
|
71
|
+
const { series, xAxisData, topTypefaces } = useMemo(() => {
|
|
72
|
+
if (!data?.months) return { series: [], xAxisData: [], topTypefaces: [] };
|
|
73
|
+
|
|
74
|
+
// Get top typefaces by total revenue across the year, cap at 12 for readability
|
|
75
|
+
const typefaceTotals = {};
|
|
76
|
+
data.months.forEach(m => {
|
|
77
|
+
m.typefaces.forEach(t => {
|
|
78
|
+
typefaceTotals[t.name] = (typefaceTotals[t.name] || 0) + t.total;
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const sorted = Object.entries(typefaceTotals)
|
|
83
|
+
.sort((a, b) => b[1] - a[1]);
|
|
84
|
+
|
|
85
|
+
// Top N get individual colors, rest grouped as "Other"
|
|
86
|
+
const MAX_SERIES = 10;
|
|
87
|
+
const topNames = sorted.slice(0, MAX_SERIES).map(([name]) => name);
|
|
88
|
+
const hasOther = sorted.length > MAX_SERIES;
|
|
89
|
+
|
|
90
|
+
const chartSeries = topNames.map((name, i) => ({
|
|
91
|
+
type: 'bar',
|
|
92
|
+
data: data.months.map(m => {
|
|
93
|
+
const tf = m.typefaces.find(t => t.name === name);
|
|
94
|
+
return (tf?.total || 0) / 100;
|
|
95
|
+
}),
|
|
96
|
+
label: name,
|
|
97
|
+
stack: 'total',
|
|
98
|
+
color: COLORS[i % COLORS.length],
|
|
99
|
+
}));
|
|
100
|
+
|
|
101
|
+
if (hasOther) {
|
|
102
|
+
chartSeries.push({
|
|
103
|
+
type: 'bar',
|
|
104
|
+
data: data.months.map(m => {
|
|
105
|
+
const otherTotal = m.typefaces
|
|
106
|
+
.filter(t => !topNames.includes(t.name))
|
|
107
|
+
.reduce((sum, t) => sum + t.total, 0);
|
|
108
|
+
return otherTotal / 100;
|
|
109
|
+
}),
|
|
110
|
+
label: 'Other',
|
|
111
|
+
stack: 'total',
|
|
112
|
+
color: '#e0e0e0',
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
series: chartSeries,
|
|
118
|
+
xAxisData: MONTH_LABELS,
|
|
119
|
+
topTypefaces: sorted.slice(0, MAX_SERIES),
|
|
120
|
+
};
|
|
121
|
+
}, [data]);
|
|
122
|
+
|
|
123
|
+
if (loading) {
|
|
124
|
+
return (
|
|
125
|
+
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 300, gap: 2 }}>
|
|
126
|
+
<CircularProgress size={24} />
|
|
127
|
+
<Typography variant="body1">Loading {year} sales data...</Typography>
|
|
128
|
+
</Box>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (error) {
|
|
133
|
+
return (
|
|
134
|
+
<Box sx={{ p: 4, textAlign: 'center' }}>
|
|
135
|
+
<Typography variant="body1" sx={{ color: 'var(--red, red)' }}>{error}</Typography>
|
|
136
|
+
</Box>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (!data) return null;
|
|
141
|
+
|
|
142
|
+
const symbol = getCurrencySymbol(data.currency);
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<Box sx={{ width: '100%' }}>
|
|
146
|
+
{/* Year total */}
|
|
147
|
+
<Typography variant="h6" sx={{ mb: 2, opacity: 0.7 }}>
|
|
148
|
+
{year} Total: {symbol}{(data.yearTotal / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}
|
|
149
|
+
{data.yearShipping > 0 && (
|
|
150
|
+
<span style={{ opacity: 0.5, fontSize: '0.8em' }}>
|
|
151
|
+
{' '}(+ {symbol}{(data.yearShipping / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })} shipping)
|
|
152
|
+
</span>
|
|
153
|
+
)}
|
|
154
|
+
</Typography>
|
|
155
|
+
|
|
156
|
+
{/* Stacked bar chart */}
|
|
157
|
+
{series.length > 0 ? (
|
|
158
|
+
<Box sx={{ width: '100%', height: { xs: 250, sm: 350 } }}>
|
|
159
|
+
<ResponsiveChartContainer
|
|
160
|
+
series={series}
|
|
161
|
+
xAxis={[{
|
|
162
|
+
data: xAxisData,
|
|
163
|
+
scaleType: 'band',
|
|
164
|
+
id: 'months',
|
|
165
|
+
}]}
|
|
166
|
+
yAxis={[{
|
|
167
|
+
id: 'revenue',
|
|
168
|
+
valueFormatter: (v) => `${symbol}${v?.toLocaleString() || 0}`,
|
|
169
|
+
}]}
|
|
170
|
+
>
|
|
171
|
+
<BarPlot
|
|
172
|
+
onItemClick={(event, barData) => {
|
|
173
|
+
if (onMonthClick && barData?.dataIndex !== undefined) {
|
|
174
|
+
onMonthClick(barData.dataIndex);
|
|
175
|
+
}
|
|
176
|
+
}}
|
|
177
|
+
/>
|
|
178
|
+
<ChartsXAxis axisId="months" />
|
|
179
|
+
<ChartsYAxis axisId="revenue" />
|
|
180
|
+
<ChartsTooltip />
|
|
181
|
+
<ChartsAxisHighlight x="band" />
|
|
182
|
+
</ResponsiveChartContainer>
|
|
183
|
+
</Box>
|
|
184
|
+
) : (
|
|
185
|
+
<Typography variant="body2" sx={{ textAlign: 'center', py: 4, opacity: 0.5 }}>
|
|
186
|
+
No sales data for {year}
|
|
187
|
+
</Typography>
|
|
188
|
+
)}
|
|
189
|
+
|
|
190
|
+
{/* Legend */}
|
|
191
|
+
{topTypefaces.length > 0 && (
|
|
192
|
+
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, mt: 2 }}>
|
|
193
|
+
{topTypefaces.map(([name, total], i) => (
|
|
194
|
+
<Tooltip key={name} title={`${symbol}${(total / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}`}>
|
|
195
|
+
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, cursor: 'default' }}>
|
|
196
|
+
<Box sx={{
|
|
197
|
+
width: 12,
|
|
198
|
+
height: 12,
|
|
199
|
+
borderRadius: '2px',
|
|
200
|
+
bgcolor: COLORS[i % COLORS.length],
|
|
201
|
+
}} />
|
|
202
|
+
<Typography variant="caption">{name}</Typography>
|
|
203
|
+
</Box>
|
|
204
|
+
</Tooltip>
|
|
205
|
+
))}
|
|
206
|
+
</Box>
|
|
207
|
+
)}
|
|
208
|
+
|
|
209
|
+
{/* Monthly breakdown table */}
|
|
210
|
+
<Box sx={{ mt: 4, opacity: 0.8 }}>
|
|
211
|
+
{data.months.map((m, i) => (
|
|
212
|
+
<Box
|
|
213
|
+
key={i}
|
|
214
|
+
onClick={() => onMonthClick && onMonthClick(i)}
|
|
215
|
+
sx={{
|
|
216
|
+
display: 'flex',
|
|
217
|
+
justifyContent: 'space-between',
|
|
218
|
+
py: 0.75,
|
|
219
|
+
px: 1,
|
|
220
|
+
borderBottom: '1px solid rgba(0,0,0,0.06)',
|
|
221
|
+
cursor: 'pointer',
|
|
222
|
+
'&:hover': { bgcolor: 'rgba(0,0,0,0.03)' },
|
|
223
|
+
borderRadius: '4px',
|
|
224
|
+
}}
|
|
225
|
+
>
|
|
226
|
+
<Typography variant="body2">
|
|
227
|
+
{MONTH_LABELS[i]} {year}
|
|
228
|
+
{m.error && <span style={{ color: 'var(--red, red)', marginLeft: 8 }}>error</span>}
|
|
229
|
+
</Typography>
|
|
230
|
+
<Box sx={{ display: 'flex', gap: 3 }}>
|
|
231
|
+
<Typography variant="body2" sx={{ opacity: 0.5 }}>
|
|
232
|
+
{m.salesCount} sale{m.salesCount !== 1 ? 's' : ''}
|
|
233
|
+
</Typography>
|
|
234
|
+
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>
|
|
235
|
+
{symbol}{(m.total / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}
|
|
236
|
+
</Typography>
|
|
237
|
+
</Box>
|
|
238
|
+
</Box>
|
|
239
|
+
))}
|
|
240
|
+
</Box>
|
|
241
|
+
</Box>
|
|
242
|
+
);
|
|
243
|
+
}
|
package/index.js
CHANGED
|
@@ -10,6 +10,7 @@ export { default as Sales } from './components/Sales.js';
|
|
|
10
10
|
export { SalesTable } from './components/SalesTable.js';
|
|
11
11
|
export { DateRangeSalesTable } from './components/DateRangeSalesTable.js';
|
|
12
12
|
export { default as SalesChart } from './components/SalesChart.js';
|
|
13
|
+
export { default as YearOverview } from './components/YearOverview.js';
|
|
13
14
|
export { default as SummaryCards } from './components/SummaryCards.js';
|
|
14
15
|
export { default as TopPerformers } from './components/TopPerformers.js';
|
|
15
16
|
export { default as TypefaceList } from './components/TypefaceList.js';
|
package/package.json
CHANGED
|
@@ -27,7 +27,7 @@ let size = createTheme({
|
|
|
27
27
|
let typography = createTheme({
|
|
28
28
|
|
|
29
29
|
typography: {
|
|
30
|
-
fontFamily: '
|
|
30
|
+
fontFamily: 'inherit, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
|
31
31
|
h1: {
|
|
32
32
|
maxWidth: '95%',
|
|
33
33
|
fontStyle: 'normal',
|