@liiift-studio/sales-portal 2.0.0 → 3.0.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.
@@ -22,22 +22,46 @@ export default async function handler(req, res) {
22
22
  // Fetch all balance transactions for the period
23
23
  const balanceTransactions = await fetchAllBalanceTransactions(timeRange);
24
24
 
25
- // Calculate balance change from charges and refunds only (what sales data tracks)
25
+ // Build detailed reconciliation data
26
26
  const RECONCILIATION_TYPES = ['charge', 'refund'];
27
- let totalBalanceChange = 0;
27
+ let rawTotal = 0; // Accumulate as float, round once at the end
28
+ let chargeCount = 0;
29
+ let refundCount = 0;
30
+ let chargeTotal = 0;
31
+ let refundTotal = 0;
32
+ let settlementCurrency = null;
33
+ let hasCurrencyConversion = false;
34
+ let pendingCount = 0;
28
35
  const flaggedTransactions = [];
29
36
 
30
37
  balanceTransactions.forEach(transaction => {
31
- if (transaction.status === 'pending') return;
38
+ if (!settlementCurrency && transaction.currency) {
39
+ settlementCurrency = transaction.currency;
40
+ }
41
+
42
+ if (transaction.status === 'pending') {
43
+ pendingCount++;
44
+ return;
45
+ }
46
+
47
+ const rate = transaction.exchange_rate || 1;
48
+ if (rate !== 1) hasCurrencyConversion = true;
49
+ const originalAmount = transaction.amount / rate;
32
50
 
33
51
  if (RECONCILIATION_TYPES.includes(transaction.type)) {
34
- totalBalanceChange += transaction.amount;
52
+ rawTotal += originalAmount;
53
+ if (transaction.type === 'charge') {
54
+ chargeCount++;
55
+ chargeTotal += originalAmount;
56
+ } else {
57
+ refundCount++;
58
+ refundTotal += originalAmount;
59
+ }
35
60
  } else if (transaction.type !== 'payout' && transaction.type !== 'stripe_fee') {
36
- // Collect disputes, adjustments, connect transfers for review
37
61
  flaggedTransactions.push({
38
62
  id: transaction.id,
39
63
  type: transaction.type,
40
- amount: transaction.amount,
64
+ amount: Math.round(originalAmount),
41
65
  created: transaction.created,
42
66
  description: transaction.description,
43
67
  source: transaction.source,
@@ -45,11 +69,23 @@ export default async function handler(req, res) {
45
69
  }
46
70
  });
47
71
 
72
+ // Round the accumulated total once to minimize rounding error
73
+ const totalBalanceChange = Math.round(rawTotal);
74
+
48
75
  return res.status(200).json({
49
76
  success: true,
50
77
  data: {
51
78
  totalBalanceChange,
52
79
  flaggedTransactions,
80
+ diagnostics: {
81
+ chargeCount,
82
+ refundCount,
83
+ chargeTotal: Math.round(chargeTotal),
84
+ refundTotal: Math.round(refundTotal),
85
+ pendingCount,
86
+ settlementCurrency,
87
+ hasCurrencyConversion,
88
+ },
53
89
  transactions: balanceTransactions
54
90
  }
55
91
  });
@@ -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
+ }
@@ -94,6 +94,7 @@ function processLineItem({ line, invoice, associatedOrder, associatedTypeface, i
94
94
  disputeDeduction: disputeAmount || 0,
95
95
  invoiceTotal: invoice.total_excluding_tax,
96
96
  invoiceTotalWithTax: invoice.total,
97
+ chargeAmount: invoice.charge?.amount || invoice.total,
97
98
  discountAmounts: line.discount_amounts,
98
99
  taxAmounts: line.tax_amounts,
99
100
  stripeFees: totalFees,
@@ -140,7 +141,8 @@ function processLineItem({ line, invoice, associatedOrder, associatedTypeface, i
140
141
  orderNumber: associatedOrder?.orderNumber || null,
141
142
  author: associatedTypeface?.author || null,
142
143
  typeface: associatedTypeface || null,
143
- saleType: "invoice"
144
+ saleType: "invoice",
145
+ currency: invoice.currency || 'usd'
144
146
  };
145
147
  }
146
148
 
@@ -177,6 +179,7 @@ function processShippingCost({ invoice, associatedOrder, totalFee = null, paymen
177
179
  disputeDeduction: disputeAmount || 0,
178
180
  invoiceTotal: invoice.total_excluding_tax,
179
181
  invoiceTotalWithTax: invoice.total,
182
+ chargeAmount: invoice.charge?.amount || invoice.total,
180
183
  discountAmounts: null,
181
184
  stripeFees: totalFees,
182
185
  disputed,
@@ -230,7 +233,8 @@ function processShippingCost({ invoice, associatedOrder, totalFee = null, paymen
230
233
  orderNumber: associatedOrder?.orderNumber || null,
231
234
  author: null,
232
235
  typeface: null,
233
- saleType: "invoice"
236
+ saleType: "invoice",
237
+ currency: invoice.currency || 'usd'
234
238
  };
235
239
  }
236
240
 
@@ -235,6 +235,7 @@ function processPaymentItem({ item = {}, payment, associatedOrder, associatedTyp
235
235
  disputeDeduction: disputeAmount || 0,
236
236
  invoiceTotal: payment.amount,
237
237
  invoiceTotalWithTax: payment.amount,
238
+ chargeAmount: payment.latest_charge?.amount || payment.amount,
238
239
  discountAmounts: [],
239
240
  stripeFees: totalFees,
240
241
  disputed,
@@ -283,7 +284,8 @@ function processPaymentItem({ item = {}, payment, associatedOrder, associatedTyp
283
284
  orderNumber: associatedOrder?.orderNumber || null,
284
285
  author: associatedTypeface?.author || null,
285
286
  typeface: associatedTypeface || null,
286
- saleType: "paymentIntent"
287
+ saleType: "paymentIntent",
288
+ currency: payment.currency || 'usd'
287
289
  };
288
290
  }
289
291
 
@@ -319,6 +321,7 @@ function processShippingCost({ payment, associatedOrder, totalFee = null }) {
319
321
  disputeDeduction: disputeAmount || 0,
320
322
  invoiceTotal: payment.amount,
321
323
  invoiceTotalWithTax: payment.amount,
324
+ chargeAmount: payment.latest_charge?.amount || payment.amount,
322
325
  discountAmounts: [],
323
326
  stripeFees: totalFees,
324
327
  disputed,
@@ -359,7 +362,8 @@ function processShippingCost({ payment, associatedOrder, totalFee = null }) {
359
362
  orderNumber: associatedOrder?.orderNumber || null,
360
363
  author: null,
361
364
  typeface: null,
362
- saleType: "paymentIntent"
365
+ saleType: "paymentIntent",
366
+ currency: payment.currency || 'usd'
363
367
  };
364
368
  }
365
369
 
@@ -505,25 +505,21 @@ export function DateRangeSalesTable({ designer, admin, loading, updateLoadingSta
505
505
  {/* Reconciliation Check - Only show for admin users */}
506
506
  {admin && dateRangeSales.length > 0 && (
507
507
  <Box sx={{ width: '100%', mb: 2 }}>
508
- <Box
509
- sx={{
510
- display: 'inline-block',
511
- gap: 2,
512
- p: 2,
513
- ml: -2,
514
- borderRadius: '4px',
515
- border: reconciliationData.isReconciled
516
- ? '2px solid var(--green, green)'
517
- : '2px solid var(--red, red)',
518
- color: reconciliationData.isReconciled
519
- ? 'var(--green, green)'
520
- : 'var(--red, red)',
521
- backgroundColor: 'rgba(255, 255, 255, 0.9)',
522
- filter: 'invert(0) !important',
523
- '& *': { filter: 'invert(0) !important' },
524
- }}
525
- >
526
-
508
+ <Box sx={{
509
+ display: 'inline-block',
510
+ p: 2,
511
+ ml: -2,
512
+ borderRadius: '4px',
513
+ border: reconciliationData.isReconciled
514
+ ? '2px solid var(--green, green)'
515
+ : '2px solid var(--red, red)',
516
+ color: reconciliationData.isReconciled
517
+ ? 'var(--green, green)'
518
+ : 'var(--red, red)',
519
+ backgroundColor: 'rgba(255, 255, 255, 0.9)',
520
+ filter: 'invert(0) !important',
521
+ '& *': { filter: 'invert(0) !important' },
522
+ }}>
527
523
  {reconciliationData.error ? (
528
524
  <Typography variant="body1" sx={{ color: 'var(--red, red)' }}>
529
525
  <WarningIcon fontSize="small" sx={{ mr: 1 }} />
@@ -532,37 +528,24 @@ export function DateRangeSalesTable({ designer, admin, loading, updateLoadingSta
532
528
  ) : reconciliationData.isLoading ? (
533
529
  <Typography variant="body1"><CircularProgress size={24} sx={{ mr: 1 }} /> Checking reconciliation...</Typography>
534
530
  ) : reconciliationData.isReconciled ? (
535
- <Typography variant="body1" sx={{ fontWeight: 'bold'}}>
531
+ <Typography variant="body1" sx={{ fontWeight: 'bold', display: 'flex', alignItems: 'center', gap: 1 }}>
536
532
  <CheckCircleIcon /> Reconciled
533
+ {reconciliationData.percentOff !== 0 && (
534
+ <span style={{ fontWeight: 'normal', opacity: 0.7 }}>
535
+ ({Math.abs(reconciliationData.percentOff).toFixed(2)}% variance)
536
+ </span>
537
+ )}
537
538
  </Typography>
538
539
  ) : (
539
- <Box sx={{ display: 'flex', alignItems: 'center' }}>
540
- <Tooltip title="The difference between Stripe balance change and gross sales">
541
- <Typography variant="body1" sx={{ fontWeight: 'bold', display: 'flex', alignItems: 'center' }}>
542
- <WarningIcon/> Needs Reconciliation (Difference: ${(Math.abs(reconciliationData.difference) / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })})
543
- </Typography>
544
- </Tooltip>
545
- <Tooltip
546
- title={
547
- <React.Fragment>
548
- <Typography variant="body2" sx={{ fontWeight: 'bold' }}>Possible causes:</Typography>
549
- <Typography variant="body2">• This could be due to a sale being refunded outside your selected date range. Check for the same difference in other periods.</Typography>
550
- </React.Fragment>
551
- }
552
- placement="right"
553
- arrow
554
- >
555
- <IconButton size="small" sx={{ ml: 1, color: 'inherit' }}>
556
- <InfoIcon fontSize="small" />
557
- </IconButton>
558
- </Tooltip>
559
- </Box>
540
+ <Typography variant="body1" sx={{ fontWeight: 'bold', display: 'flex', alignItems: 'center', gap: 1 }}>
541
+ <WarningIcon /> Needs Reconciliation — ${(Math.abs(reconciliationData.difference) / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })} off ({Math.abs(reconciliationData.percentOff).toFixed(2)}%)
542
+ </Typography>
560
543
  )}
561
-
562
- {!reconciliationData.isLoading && (
544
+
545
+ {!reconciliationData.isLoading && !reconciliationData.error && (
563
546
  <>
564
547
  <Typography variant="body1">
565
- <strong>Stripe Balance (charges + refunds):</strong> ${(reconciliationData.totalBalanceChange / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}
548
+ <strong>Stripe Balance:</strong> ${(reconciliationData.totalBalanceChange / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}
566
549
  </Typography>
567
550
  <Typography variant="body1">
568
551
  <strong>Calculated Gross:</strong> ${(reconciliationData.grossSales / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}
@@ -570,24 +553,39 @@ export function DateRangeSalesTable({ designer, admin, loading, updateLoadingSta
570
553
  <span style={{ opacity: 0.6 }}> (incl. ${(reconciliationData.shippingTotal / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })} shipping)</span>
571
554
  )}
572
555
  </Typography>
573
- </>
574
- )}
575
556
 
576
- {reconciliationData.flaggedTransactions?.length > 0 && (
577
- <Box sx={{ mt: 1 }}>
578
- <Tooltip title="View disputes, adjustments, and other flagged transactions">
579
- <Button
580
- size="small"
581
- startIcon={<InfoIcon />}
582
- onClick={() => setFlaggedOpen(true)}
583
- sx={{ color: 'inherit', textTransform: 'none' }}
584
- >
585
- {reconciliationData.flaggedTransactions.length} flagged transaction{reconciliationData.flaggedTransactions.length !== 1 ? 's' : ''}
586
- </Button>
587
- </Tooltip>
588
- </Box>
589
- )}
557
+ {reconciliationData.checks?.length > 0 && (
558
+ <Box sx={{ mt: 2, borderTop: '1px solid currentColor', pt: 1, opacity: 0.9 }}>
559
+ {reconciliationData.checks.map((check, i) => (
560
+ <Tooltip key={i} title={check.tip || ''} placement="right" arrow>
561
+ <Typography variant="body2" sx={{
562
+ display: 'flex', alignItems: 'center', gap: 0.5, py: 0.25,
563
+ cursor: check.tip ? 'help' : 'default',
564
+ }}>
565
+ <span style={{ fontSize: '0.9em' }}>
566
+ {check.status === 'pass' ? '✓' : check.status === 'fail' ? '✗' : check.status === 'warn' ? '' : ''}
567
+ </span>
568
+ <strong>{check.label}:</strong> {check.detail}
569
+ </Typography>
570
+ </Tooltip>
571
+ ))}
572
+ </Box>
573
+ )}
590
574
 
575
+ {reconciliationData.flaggedTransactions?.length > 0 && (
576
+ <Box sx={{ mt: 1 }}>
577
+ <Button
578
+ size="small"
579
+ startIcon={<InfoIcon />}
580
+ onClick={() => setFlaggedOpen(true)}
581
+ sx={{ color: 'inherit', textTransform: 'none' }}
582
+ >
583
+ View {reconciliationData.flaggedTransactions.length} flagged transaction{reconciliationData.flaggedTransactions.length !== 1 ? 's' : ''}
584
+ </Button>
585
+ </Box>
586
+ )}
587
+ </>
588
+ )}
591
589
  </Box>
592
590
  </Box>
593
591
  )}
@@ -1,23 +1,23 @@
1
- // Login form modal component for sales portal authentication
1
+ // Login form component for sales portal authentication
2
2
  import React, { useEffect, useState } from 'react';
3
- import { Typography, Input, Button, Box, Dialog, DialogContent } from '@mui/material';
3
+ import { Typography, Input, Button, Box } from '@mui/material';
4
4
  import packageJson from '../package.json';
5
5
 
6
6
  const { version } = packageJson;
7
7
 
8
8
  /**
9
- * LoginForm modal component for designer authentication
9
+ * LoginForm component for designer authentication
10
+ * Renders as an inline centered card (not a modal overlay) so the
11
+ * site header and footer remain accessible.
10
12
  * @param {Object} props Component props
11
- * @param {boolean} props.open Whether the modal is open
12
- * @param {Function} props.onClose Callback when the modal is closed
13
- * @param {Function} props.onLoginSuccess Callback when login succeeds, receives { designers, admin, user, password }
14
- * @param {Function} props.renderLoader Optional render prop for custom loader component
15
- * @param {string} props.apiEndpoint Optional custom API endpoint (default: '/api/sales-portal/getDesignerInfo')
16
- * @param {Object} props.texts Optional text customization { title, description, subtitle, buttonLabel }
13
+ * @param {boolean} props.open Whether the form is visible
14
+ * @param {Function} props.onLoginSuccess Callback when login succeeds
15
+ * @param {Function} props.renderLoader Optional render prop for custom loader
16
+ * @param {string} props.apiEndpoint Optional custom API endpoint
17
+ * @param {Object} props.texts Optional text customization
17
18
  */
18
19
  export default function LoginForm({
19
20
  open = true,
20
- onClose,
21
21
  onLoginSuccess,
22
22
  renderLoader,
23
23
  apiEndpoint = '/api/sales-portal/getDesignerInfo',
@@ -72,7 +72,7 @@ export default function LoginForm({
72
72
  }
73
73
  };
74
74
 
75
- // Clear message when inputs change or modal reopens
75
+ // Clear message when inputs change or form reopens
76
76
  useEffect(() => {
77
77
  setMessage('');
78
78
  }, [password, user]);
@@ -88,32 +88,27 @@ export default function LoginForm({
88
88
  }
89
89
  };
90
90
 
91
+ if (!open) return null;
92
+
91
93
  return (
92
- <Dialog
93
- open={open}
94
- onClose={onClose}
95
- maxWidth="md"
96
- fullWidth
97
- PaperProps={{
98
- sx: {
99
- borderRadius: '12px',
100
- overflow: 'hidden',
101
- m: { xs: 2, sm: 4 },
102
- maxHeight: 'calc(100vh - 64px)',
103
- }
104
- }}
105
- slotProps={{
106
- backdrop: {
107
- sx: {
108
- backgroundColor: 'rgba(0, 0, 0, 0.7)',
109
- backdropFilter: 'blur(4px)',
110
- }
111
- }
112
- }}
113
- >
94
+ <Box sx={{
95
+ display: 'flex',
96
+ justifyContent: 'center',
97
+ alignItems: 'center',
98
+ minHeight: 'calc(100vh - var(--headerHeight, 80px))',
99
+ px: { xs: 2, sm: 4 },
100
+ py: { xs: 4, sm: 8 },
101
+ }}>
114
102
  {loading && renderLoader && renderLoader({ fill: true, position: 'fixed', height: '100%' })}
115
103
 
116
- <DialogContent sx={{ p: 0 }}>
104
+ <Box sx={{
105
+ width: '100%',
106
+ maxWidth: '860px',
107
+ borderRadius: '12px',
108
+ overflow: 'hidden',
109
+ boxShadow: '0 8px 32px rgba(0, 0, 0, 0.12)',
110
+ bgcolor: 'var(--white, white)',
111
+ }}>
117
112
  <Box sx={{
118
113
  display: 'flex',
119
114
  flexDirection: { xs: 'column', sm: 'row' },
@@ -256,7 +251,7 @@ export default function LoginForm({
256
251
  </Box>
257
252
  </Box>
258
253
  </Box>
259
- </DialogContent>
260
- </Dialog>
254
+ </Box>
255
+ </Box>
261
256
  );
262
257
  }
@@ -15,8 +15,10 @@ import dayjs from 'dayjs';
15
15
  import utc from 'dayjs/plugin/utc';
16
16
  import { processChartData, processTypefaceData, processDesignersData, generateSeriesData, processLicenseTypeData } from '../utils/salesDataProcessing';
17
17
  import countryCodes from '../data/countryCode.json';
18
+ import { getCurrencySymbol, getCurrencyLabel } from '../utils/currencyUtils';
18
19
  import { SalesTable } from './SalesTable';
19
20
  import { DateRangeSalesTable } from './DateRangeSalesTable';
21
+ import YearOverview from './YearOverview';
20
22
  import SalesChart from './SalesChart';
21
23
  import TopPerformers from './TopPerformers';
22
24
  import TypefaceList from './TypefaceList';
@@ -46,6 +48,7 @@ export default function Sales(props) {
46
48
  const [previousSalesError, setPreviousSalesError] = useState('');
47
49
  const [retryInfo, setRetryInfo] = useState({ retrying: false, attempt: 0, label: '' });
48
50
  const [date, setDate] = useState(null);
51
+ const [viewMode, setViewMode] = useState('month'); // 'month' | 'year'
49
52
  const [displayLosses, setDisplayLosses] = useState(false);
50
53
 
51
54
  // Max retry attempts for failed API calls
@@ -522,7 +525,7 @@ export default function Sales(props) {
522
525
  padding: "5px 10px 0 10px",
523
526
  }}
524
527
  >
525
- {sales ? <Box component="span" sx={{ fontVariationSettings: '"wght" 300' }}><Box component="span" sx={{ display: { xs: 'none', md: 'inherit' }, opacity: "0.5" }}>USD</Box>$ </Box> : ``}
528
+ {sales ? <Box component="span" sx={{ fontVariationSettings: '"wght" 300' }}><Box component="span" sx={{ display: { xs: 'none', md: 'inherit' }, opacity: "0.5" }}>{getCurrencyLabel(sales[0]?.currency)}</Box>{getCurrencySymbol(sales[0]?.currency)} </Box> : ``}
526
529
  {sales ? (
527
530
  <>
528
531
  <span className={`sales-total`} style={{ fontVariationSettings: '"wght" 900' }}>
@@ -670,6 +673,37 @@ export default function Sales(props) {
670
673
  </IconButton>
671
674
  </Tooltip>
672
675
  )}
676
+ {/* Month/Year toggle */}
677
+ <Box sx={{
678
+ display: 'flex',
679
+ ml: 1,
680
+ border: '1px solid var(--black, #1a1a1a)',
681
+ borderRadius: '4px',
682
+ overflow: 'hidden',
683
+ }}>
684
+ {['month', 'year'].map(mode => (
685
+ <Box
686
+ key={mode}
687
+ onClick={() => setViewMode(mode)}
688
+ sx={{
689
+ px: 1.5,
690
+ py: 0.5,
691
+ cursor: 'pointer',
692
+ fontSize: '0.75rem',
693
+ fontWeight: viewMode === mode ? 'bold' : 'normal',
694
+ bgcolor: viewMode === mode ? 'var(--black, #1a1a1a)' : 'transparent',
695
+ color: viewMode === mode ? 'var(--white, white)' : 'inherit',
696
+ textTransform: 'uppercase',
697
+ letterSpacing: '0.05em',
698
+ '&:hover': {
699
+ bgcolor: viewMode === mode ? 'var(--black, #1a1a1a)' : 'rgba(0,0,0,0.05)',
700
+ },
701
+ }}
702
+ >
703
+ {mode === 'month' ? 'Mo' : 'Yr'}
704
+ </Box>
705
+ ))}
706
+ </Box>
673
707
  </Box>
674
708
  </Box>
675
709
 
@@ -723,7 +757,7 @@ export default function Sales(props) {
723
757
  </Box>
724
758
  }
725
759
  </Box>
726
- ), [loading, sales, total, designer, date, message, revenueChangePercent, previousSales, retryInfo]);
760
+ ), [loading, sales, total, designer, date, message, revenueChangePercent, previousSales, retryInfo, viewMode]);
727
761
 
728
762
  return (
729
763
  <LocalizationProvider dateAdapter={AdapterDayjs}>
@@ -749,8 +783,23 @@ export default function Sales(props) {
749
783
  <Box sx={{ display: 'flex', flexWrap: 'wrap', width: '100%' }}>
750
784
  {HeaderSection}
751
785
 
752
- {/* Sales Data Display */}
753
- {!!sales.length && (
786
+ {/* Year Overview */}
787
+ {viewMode === 'year' && date && (
788
+ <Box sx={{ width: '100%', px: { xs: 2, md: 0 } }}>
789
+ <YearOverview
790
+ designer={designer}
791
+ year={date.getUTCFullYear()}
792
+ onMonthClick={(monthIndex) => {
793
+ const newDate = new Date(Date.UTC(date.getUTCFullYear(), monthIndex, 1));
794
+ setDate(newDate);
795
+ setViewMode('month');
796
+ }}
797
+ />
798
+ </Box>
799
+ )}
800
+
801
+ {/* Monthly Sales Data Display */}
802
+ {viewMode === 'month' && !!sales.length && (
754
803
  <>
755
804
 
756
805
  <Box
@@ -67,7 +67,7 @@ export default function SalesPortalPage({
67
67
  const isLoggedIn = !!designers;
68
68
 
69
69
  const content = (
70
- <Box sx={{ minHeight: '100vh' }}>
70
+ <Box sx={{ minHeight: 'calc(100vh - var(--headerHeight, 80px))' }}>
71
71
  {/* Login Modal */}
72
72
  <LoginForm
73
73
  open={!isLoggedIn}