@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.
- package/api/getBalanceTransactions.js +42 -6
- package/api/getYearSales.js +105 -0
- package/api/utils/processors/invoiceProcessor.js +6 -2
- package/api/utils/processors/paymentProcessor.js +6 -2
- package/components/DateRangeSalesTable.js +58 -60
- package/components/LoginForm.js +31 -36
- package/components/Sales.js +53 -4
- package/components/SalesPortalPage.js +1 -1
- package/components/SalesTable.js +56 -57
- package/components/YearOverview.js +243 -0
- package/hooks/__tests__/useReconciliation.test.js +21 -16
- package/hooks/calculateGrossSales.js +12 -4
- package/hooks/useReconciliation.js +106 -10
- package/index.js +1 -0
- package/package.json +1 -1
- package/utils/currencyUtils.js +57 -32
|
@@ -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
|
-
//
|
|
25
|
+
// Build detailed reconciliation data
|
|
26
26
|
const RECONCILIATION_TYPES = ['charge', 'refund'];
|
|
27
|
-
let
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
-
<
|
|
540
|
-
<
|
|
541
|
-
|
|
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
|
|
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
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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
|
)}
|
package/components/LoginForm.js
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
|
-
// Login form
|
|
1
|
+
// Login form component for sales portal authentication
|
|
2
2
|
import React, { useEffect, useState } from 'react';
|
|
3
|
-
import { Typography, Input, Button, Box
|
|
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
|
|
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
|
|
12
|
-
* @param {Function} props.
|
|
13
|
-
* @param {Function} props.
|
|
14
|
-
* @param {
|
|
15
|
-
* @param {
|
|
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
|
|
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
|
-
<
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
<
|
|
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
|
-
</
|
|
260
|
-
</
|
|
254
|
+
</Box>
|
|
255
|
+
</Box>
|
|
261
256
|
);
|
|
262
257
|
}
|
package/components/Sales.js
CHANGED
|
@@ -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" }}>
|
|
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
|
-
{/*
|
|
753
|
-
{
|
|
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}
|