@liiift-studio/sales-portal 3.1.3 → 3.1.4
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/getYearSales.js +166 -10
- package/api/utils/__tests__/yearSalesAggregation.test.js +313 -0
- package/components/Insights.js +95 -77
- package/components/LicenseTypeList.js +2 -2
- package/components/Sales.js +70 -19
- package/components/TopPerformers.js +4 -4
- package/components/TypefaceList.js +2 -2
- package/components/YearOverview.js +2 -33
- package/package.json +1 -1
package/api/getYearSales.js
CHANGED
|
@@ -1,13 +1,68 @@
|
|
|
1
|
-
// API endpoint for fetching a full year of sales data
|
|
1
|
+
// API endpoint for fetching a full year of sales data with year-level aggregates
|
|
2
2
|
import { authenticateDesigner, processSalesData } from './utils/salesDataProcessor';
|
|
3
3
|
import { sendError, requirePost } from './utils/apiResponse';
|
|
4
4
|
|
|
5
|
-
export const config = { maxDuration:
|
|
5
|
+
export const config = { maxDuration: 800 };
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Extracts tax total from a sale's taxAmounts array
|
|
9
|
+
*/
|
|
10
|
+
function getSaleTax(sale) {
|
|
11
|
+
let tax = 0;
|
|
12
|
+
sale?.taxAmounts?.forEach(t => { tax += t.amount || 0; });
|
|
13
|
+
return tax;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Extracts discount total from a sale's discountAmounts array
|
|
18
|
+
*/
|
|
19
|
+
function getSaleDiscount(sale) {
|
|
20
|
+
let discount = 0;
|
|
21
|
+
sale?.discountAmounts?.forEach(d => { discount += d.amount || 0; });
|
|
22
|
+
return discount;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Extracts refund total from a sale's refunds and dispute data
|
|
27
|
+
*/
|
|
28
|
+
function getSaleRefund(sale) {
|
|
29
|
+
let refund = 0;
|
|
30
|
+
if (sale?.disputed) {
|
|
31
|
+
refund += (sale.totalWithTax || 0) - (sale.total || 0);
|
|
32
|
+
}
|
|
33
|
+
sale?.refunds?.forEach(r => { refund += r.adjustedTotal || 0; });
|
|
34
|
+
return refund;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Extracts typeface title from a sale description
|
|
39
|
+
*/
|
|
40
|
+
function getTypefaceTitle(sale) {
|
|
41
|
+
if (!sale.description) return 'Unknown Typeface';
|
|
42
|
+
let title = sale.description.substring(0, sale.description.indexOf(' ('));
|
|
43
|
+
if (title === '') title = sale.description.substring(0, sale.description.indexOf(' —'));
|
|
44
|
+
if (title === '') title = sale.description.substring(0, sale.description.indexOf('.'));
|
|
45
|
+
if (title === '') title = sale.description;
|
|
46
|
+
return title;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Extracts license type name from a sale description
|
|
51
|
+
*/
|
|
52
|
+
function getLicenseName(sale) {
|
|
53
|
+
const parenthesisMatch = sale.description?.match(/\((.*?)\)/);
|
|
54
|
+
if (parenthesisMatch?.[1]) return parenthesisMatch[1].trim();
|
|
55
|
+
if (sale.description?.includes('—')) {
|
|
56
|
+
const parts = sale.description.split('—');
|
|
57
|
+
if (parts.length > 1) return parts[1].trim();
|
|
58
|
+
}
|
|
59
|
+
if (sale.license) return sale.license;
|
|
60
|
+
return 'Unknown';
|
|
61
|
+
}
|
|
6
62
|
|
|
7
63
|
/**
|
|
8
64
|
* Fetches 12 months of sales data, processes each month, and returns
|
|
9
|
-
* the results grouped by month with
|
|
10
|
-
* This avoids 12 separate API calls from the frontend.
|
|
65
|
+
* the results grouped by month with year-level aggregates for all components.
|
|
11
66
|
*/
|
|
12
67
|
export default async function handler(req, res) {
|
|
13
68
|
if (!requirePost(req, res)) return;
|
|
@@ -26,6 +81,16 @@ export default async function handler(req, res) {
|
|
|
26
81
|
|
|
27
82
|
const months = [];
|
|
28
83
|
|
|
84
|
+
// Year-level aggregation accumulators
|
|
85
|
+
let yearTax = 0;
|
|
86
|
+
let yearDiscounts = 0;
|
|
87
|
+
let yearRefunds = 0;
|
|
88
|
+
let yearOrderCount = 0;
|
|
89
|
+
const yearLocationMap = {};
|
|
90
|
+
const yearTypefaceMap = {};
|
|
91
|
+
const yearLicenseMap = {};
|
|
92
|
+
const processedSalesKeys = new Set();
|
|
93
|
+
|
|
29
94
|
// Process each month sequentially to avoid Stripe rate limiting
|
|
30
95
|
for (let month = 0; month < 12; month++) {
|
|
31
96
|
const monthDate = new Date(Date.UTC(year, month, 1));
|
|
@@ -37,7 +102,7 @@ export default async function handler(req, res) {
|
|
|
37
102
|
admin
|
|
38
103
|
});
|
|
39
104
|
|
|
40
|
-
//
|
|
105
|
+
// Per-month aggregation (existing)
|
|
41
106
|
const typefaceMap = {};
|
|
42
107
|
let monthTotal = 0;
|
|
43
108
|
let shippingTotal = 0;
|
|
@@ -48,13 +113,88 @@ export default async function handler(req, res) {
|
|
|
48
113
|
return;
|
|
49
114
|
}
|
|
50
115
|
|
|
116
|
+
const saleTotal = sale.total || 0;
|
|
117
|
+
const saleTax = getSaleTax(sale);
|
|
118
|
+
const saleDiscount = getSaleDiscount(sale);
|
|
119
|
+
const saleRefund = getSaleRefund(sale);
|
|
120
|
+
|
|
121
|
+
// Per-month typeface chart data (existing behavior)
|
|
51
122
|
const typefaceName = sale.typeface?.title || sale.description?.split(' (')[0] || 'Other';
|
|
52
123
|
if (!typefaceMap[typefaceName]) {
|
|
53
124
|
typefaceMap[typefaceName] = { total: 0, count: 0 };
|
|
54
125
|
}
|
|
55
|
-
typefaceMap[typefaceName].total +=
|
|
126
|
+
typefaceMap[typefaceName].total += saleTotal;
|
|
56
127
|
typefaceMap[typefaceName].count += 1;
|
|
57
|
-
monthTotal +=
|
|
128
|
+
monthTotal += saleTotal;
|
|
129
|
+
|
|
130
|
+
// Year-level aggregates
|
|
131
|
+
yearTax += saleTax;
|
|
132
|
+
yearDiscounts += saleDiscount;
|
|
133
|
+
yearRefunds += saleRefund;
|
|
134
|
+
yearOrderCount += 1;
|
|
135
|
+
|
|
136
|
+
// Deduplicate for year-level detailed aggregation
|
|
137
|
+
const saleKey = `${sale.saleType || ''}-${sale.id}-${sale.lineId || ''}`;
|
|
138
|
+
if (processedSalesKeys.has(saleKey)) return;
|
|
139
|
+
processedSalesKeys.add(saleKey);
|
|
140
|
+
|
|
141
|
+
// Year location data
|
|
142
|
+
const countryCode = sale.paymentMethod?.origin?.country ||
|
|
143
|
+
sale.customerAddress?.country ||
|
|
144
|
+
sale.billingAddress?.country ||
|
|
145
|
+
'Unknown';
|
|
146
|
+
if (!yearLocationMap[countryCode]) {
|
|
147
|
+
yearLocationMap[countryCode] = { revenue: 0, orders: 0 };
|
|
148
|
+
}
|
|
149
|
+
yearLocationMap[countryCode].revenue += saleTotal;
|
|
150
|
+
yearLocationMap[countryCode].orders += 1;
|
|
151
|
+
|
|
152
|
+
// Year typeface data (rich, for TypefaceList/TopPerformers)
|
|
153
|
+
const title = getTypefaceTitle(sale);
|
|
154
|
+
if (!yearTypefaceMap[title]) {
|
|
155
|
+
yearTypefaceMap[title] = {
|
|
156
|
+
title,
|
|
157
|
+
total: 0,
|
|
158
|
+
grossTotal: 0,
|
|
159
|
+
taxTotal: 0,
|
|
160
|
+
discountTotal: 0,
|
|
161
|
+
refundTotal: 0,
|
|
162
|
+
orders: 0,
|
|
163
|
+
author: sale.typeface?.author || sale.author || null,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
const tf = yearTypefaceMap[title];
|
|
167
|
+
tf.total += saleTotal;
|
|
168
|
+
tf.grossTotal += saleTotal;
|
|
169
|
+
tf.taxTotal += saleTax;
|
|
170
|
+
tf.discountTotal += saleDiscount;
|
|
171
|
+
tf.refundTotal += saleRefund;
|
|
172
|
+
tf.orders += 1;
|
|
173
|
+
// Keep the most detailed author info
|
|
174
|
+
if (!tf.author && (sale.typeface?.author || sale.author)) {
|
|
175
|
+
tf.author = sale.typeface?.author || sale.author;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Year license type data (for LicenseTypeList)
|
|
179
|
+
const licenseName = getLicenseName(sale);
|
|
180
|
+
if (!yearLicenseMap[licenseName]) {
|
|
181
|
+
yearLicenseMap[licenseName] = {
|
|
182
|
+
name: licenseName,
|
|
183
|
+
total: 0,
|
|
184
|
+
grossTotal: 0,
|
|
185
|
+
taxTotal: 0,
|
|
186
|
+
discountTotal: 0,
|
|
187
|
+
refundTotal: 0,
|
|
188
|
+
orders: 0,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
const lt = yearLicenseMap[licenseName];
|
|
192
|
+
lt.total += saleTotal;
|
|
193
|
+
lt.grossTotal += saleTotal;
|
|
194
|
+
lt.taxTotal += saleTax;
|
|
195
|
+
lt.discountTotal += saleDiscount;
|
|
196
|
+
lt.refundTotal += saleRefund;
|
|
197
|
+
lt.orders += 1;
|
|
58
198
|
});
|
|
59
199
|
|
|
60
200
|
months.push({
|
|
@@ -69,7 +209,6 @@ export default async function handler(req, res) {
|
|
|
69
209
|
.sort((a, b) => b.total - a.total),
|
|
70
210
|
});
|
|
71
211
|
} catch (monthError) {
|
|
72
|
-
// If a single month fails, include it with zero data rather than failing the whole request
|
|
73
212
|
console.error(`Error processing month ${month + 1}/${year}:`, monthError.message);
|
|
74
213
|
months.push({
|
|
75
214
|
month,
|
|
@@ -84,19 +223,36 @@ export default async function handler(req, res) {
|
|
|
84
223
|
}
|
|
85
224
|
}
|
|
86
225
|
|
|
87
|
-
// Build
|
|
226
|
+
// Build year-level arrays from maps
|
|
88
227
|
const allTypefaces = new Set();
|
|
89
228
|
months.forEach(m => m.typefaces.forEach(t => allTypefaces.add(t.name)));
|
|
90
229
|
|
|
230
|
+
const yearTypefaces = Object.values(yearTypefaceMap)
|
|
231
|
+
.sort((a, b) => b.total - a.total);
|
|
232
|
+
|
|
233
|
+
const yearLicenseTypes = Object.values(yearLicenseMap)
|
|
234
|
+
.sort((a, b) => b.total - a.total);
|
|
235
|
+
|
|
91
236
|
res.status(200).json({
|
|
92
237
|
success: true,
|
|
93
238
|
data: {
|
|
94
239
|
year,
|
|
95
240
|
months,
|
|
96
241
|
allTypefaces: [...allTypefaces].sort(),
|
|
242
|
+
currency: months.find(m => m.currency)?.currency || 'usd',
|
|
243
|
+
|
|
244
|
+
// Existing year totals
|
|
97
245
|
yearTotal: months.reduce((sum, m) => sum + m.total, 0),
|
|
98
246
|
yearShipping: months.reduce((sum, m) => sum + m.shippingTotal, 0),
|
|
99
|
-
|
|
247
|
+
|
|
248
|
+
// New year-level aggregates
|
|
249
|
+
yearTax,
|
|
250
|
+
yearDiscounts,
|
|
251
|
+
yearRefunds,
|
|
252
|
+
yearOrderCount,
|
|
253
|
+
yearLocationData: yearLocationMap,
|
|
254
|
+
yearTypefaces,
|
|
255
|
+
yearLicenseTypes,
|
|
100
256
|
}
|
|
101
257
|
});
|
|
102
258
|
} catch (error) {
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
// Tests for year sales aggregation helper functions
|
|
2
|
+
import { describe, test, expect } from 'vitest';
|
|
3
|
+
|
|
4
|
+
// Since the helpers are inline in getYearSales.js, we replicate them here for testing.
|
|
5
|
+
// If they're ever extracted into a shared util, import them directly instead.
|
|
6
|
+
|
|
7
|
+
function getSaleTax(sale) {
|
|
8
|
+
let tax = 0;
|
|
9
|
+
sale?.taxAmounts?.forEach(t => { tax += t.amount || 0; });
|
|
10
|
+
return tax;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function getSaleDiscount(sale) {
|
|
14
|
+
let discount = 0;
|
|
15
|
+
sale?.discountAmounts?.forEach(d => { discount += d.amount || 0; });
|
|
16
|
+
return discount;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getSaleRefund(sale) {
|
|
20
|
+
let refund = 0;
|
|
21
|
+
if (sale?.disputed) {
|
|
22
|
+
refund += (sale.totalWithTax || 0) - (sale.total || 0);
|
|
23
|
+
}
|
|
24
|
+
sale?.refunds?.forEach(r => { refund += r.adjustedTotal || 0; });
|
|
25
|
+
return refund;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getTypefaceTitle(sale) {
|
|
29
|
+
if (!sale.description) return 'Unknown Typeface';
|
|
30
|
+
let title = sale.description.substring(0, sale.description.indexOf(' ('));
|
|
31
|
+
if (title === '') title = sale.description.substring(0, sale.description.indexOf(' —'));
|
|
32
|
+
if (title === '') title = sale.description.substring(0, sale.description.indexOf('.'));
|
|
33
|
+
if (title === '') title = sale.description;
|
|
34
|
+
return title;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getLicenseName(sale) {
|
|
38
|
+
const parenthesisMatch = sale.description?.match(/\((.*?)\)/);
|
|
39
|
+
if (parenthesisMatch?.[1]) return parenthesisMatch[1].trim();
|
|
40
|
+
if (sale.description?.includes('—')) {
|
|
41
|
+
const parts = sale.description.split('—');
|
|
42
|
+
if (parts.length > 1) return parts[1].trim();
|
|
43
|
+
}
|
|
44
|
+
if (sale.license) return sale.license;
|
|
45
|
+
return 'Unknown';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('getSaleTax', () => {
|
|
49
|
+
test('returns 0 for sale with no taxAmounts', () => {
|
|
50
|
+
expect(getSaleTax({})).toBe(0);
|
|
51
|
+
expect(getSaleTax({ taxAmounts: [] })).toBe(0);
|
|
52
|
+
expect(getSaleTax(null)).toBe(0);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('sums multiple tax amounts', () => {
|
|
56
|
+
const sale = {
|
|
57
|
+
taxAmounts: [
|
|
58
|
+
{ amount: 500 },
|
|
59
|
+
{ amount: 300 },
|
|
60
|
+
]
|
|
61
|
+
};
|
|
62
|
+
expect(getSaleTax(sale)).toBe(800);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('handles missing amount fields', () => {
|
|
66
|
+
const sale = { taxAmounts: [{ amount: 100 }, {}] };
|
|
67
|
+
expect(getSaleTax(sale)).toBe(100);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('getSaleDiscount', () => {
|
|
72
|
+
test('returns 0 for sale with no discounts', () => {
|
|
73
|
+
expect(getSaleDiscount({})).toBe(0);
|
|
74
|
+
expect(getSaleDiscount({ discountAmounts: [] })).toBe(0);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('sums discount amounts', () => {
|
|
78
|
+
const sale = {
|
|
79
|
+
discountAmounts: [
|
|
80
|
+
{ amount: 200 },
|
|
81
|
+
{ amount: 150 },
|
|
82
|
+
]
|
|
83
|
+
};
|
|
84
|
+
expect(getSaleDiscount(sale)).toBe(350);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('getSaleRefund', () => {
|
|
89
|
+
test('returns 0 for sale with no refunds or disputes', () => {
|
|
90
|
+
expect(getSaleRefund({})).toBe(0);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('sums refund adjustedTotals', () => {
|
|
94
|
+
const sale = {
|
|
95
|
+
refunds: [
|
|
96
|
+
{ adjustedTotal: 1000 },
|
|
97
|
+
{ adjustedTotal: 500 },
|
|
98
|
+
]
|
|
99
|
+
};
|
|
100
|
+
expect(getSaleRefund(sale)).toBe(1500);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('adds disputed amount from totalWithTax - total', () => {
|
|
104
|
+
const sale = {
|
|
105
|
+
disputed: true,
|
|
106
|
+
totalWithTax: 5000,
|
|
107
|
+
total: 4200,
|
|
108
|
+
};
|
|
109
|
+
expect(getSaleRefund(sale)).toBe(800);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('combines disputed and refunded amounts', () => {
|
|
113
|
+
const sale = {
|
|
114
|
+
disputed: true,
|
|
115
|
+
totalWithTax: 5000,
|
|
116
|
+
total: 4200,
|
|
117
|
+
refunds: [{ adjustedTotal: 300 }],
|
|
118
|
+
};
|
|
119
|
+
expect(getSaleRefund(sale)).toBe(1100);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe('getTypefaceTitle', () => {
|
|
124
|
+
test('extracts title before parenthesis', () => {
|
|
125
|
+
expect(getTypefaceTitle({ description: 'Berna (Desktop License)' })).toBe('Berna');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('extracts title before em dash', () => {
|
|
129
|
+
expect(getTypefaceTitle({ description: 'Berna — Desktop' })).toBe('Berna');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('extracts title before period', () => {
|
|
133
|
+
expect(getTypefaceTitle({ description: 'Berna.otf' })).toBe('Berna');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('returns full description if no delimiter', () => {
|
|
137
|
+
expect(getTypefaceTitle({ description: 'Berna' })).toBe('Berna');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('returns Unknown Typeface when no description', () => {
|
|
141
|
+
expect(getTypefaceTitle({})).toBe('Unknown Typeface');
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('getLicenseName', () => {
|
|
146
|
+
test('extracts license from parentheses', () => {
|
|
147
|
+
expect(getLicenseName({ description: 'Berna (Desktop License)' })).toBe('Desktop License');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('extracts license after em dash', () => {
|
|
151
|
+
expect(getLicenseName({ description: 'Berna — Web License' })).toBe('Web License');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('uses license property as fallback', () => {
|
|
155
|
+
expect(getLicenseName({ description: 'Berna', license: 'App' })).toBe('App');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('returns Unknown when no license info', () => {
|
|
159
|
+
expect(getLicenseName({ description: 'Berna' })).toBe('Unknown');
|
|
160
|
+
expect(getLicenseName({})).toBe('Unknown');
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe('year-level aggregation logic', () => {
|
|
165
|
+
// Simulate the aggregation loop from getYearSales.js
|
|
166
|
+
function aggregateYearData(monthlySales) {
|
|
167
|
+
let yearTax = 0;
|
|
168
|
+
let yearDiscounts = 0;
|
|
169
|
+
let yearRefunds = 0;
|
|
170
|
+
let yearOrderCount = 0;
|
|
171
|
+
const yearLocationMap = {};
|
|
172
|
+
const yearTypefaceMap = {};
|
|
173
|
+
const yearLicenseMap = {};
|
|
174
|
+
|
|
175
|
+
monthlySales.flat().forEach(sale => {
|
|
176
|
+
if (sale.shippingProvision) return;
|
|
177
|
+
|
|
178
|
+
const saleTotal = sale.total || 0;
|
|
179
|
+
const saleTax = getSaleTax(sale);
|
|
180
|
+
const saleDiscount = getSaleDiscount(sale);
|
|
181
|
+
const saleRefund = getSaleRefund(sale);
|
|
182
|
+
|
|
183
|
+
yearTax += saleTax;
|
|
184
|
+
yearDiscounts += saleDiscount;
|
|
185
|
+
yearRefunds += saleRefund;
|
|
186
|
+
yearOrderCount += 1;
|
|
187
|
+
|
|
188
|
+
// Location
|
|
189
|
+
const countryCode = sale.paymentMethod?.origin?.country || 'Unknown';
|
|
190
|
+
if (!yearLocationMap[countryCode]) {
|
|
191
|
+
yearLocationMap[countryCode] = { revenue: 0, orders: 0 };
|
|
192
|
+
}
|
|
193
|
+
yearLocationMap[countryCode].revenue += saleTotal;
|
|
194
|
+
yearLocationMap[countryCode].orders += 1;
|
|
195
|
+
|
|
196
|
+
// Typeface
|
|
197
|
+
const title = getTypefaceTitle(sale);
|
|
198
|
+
if (!yearTypefaceMap[title]) {
|
|
199
|
+
yearTypefaceMap[title] = { title, total: 0, grossTotal: 0, taxTotal: 0, discountTotal: 0, refundTotal: 0, orders: 0 };
|
|
200
|
+
}
|
|
201
|
+
yearTypefaceMap[title].total += saleTotal;
|
|
202
|
+
yearTypefaceMap[title].grossTotal += saleTotal;
|
|
203
|
+
yearTypefaceMap[title].taxTotal += saleTax;
|
|
204
|
+
yearTypefaceMap[title].discountTotal += saleDiscount;
|
|
205
|
+
yearTypefaceMap[title].refundTotal += saleRefund;
|
|
206
|
+
yearTypefaceMap[title].orders += 1;
|
|
207
|
+
|
|
208
|
+
// License
|
|
209
|
+
const licenseName = getLicenseName(sale);
|
|
210
|
+
if (!yearLicenseMap[licenseName]) {
|
|
211
|
+
yearLicenseMap[licenseName] = { name: licenseName, total: 0, grossTotal: 0, taxTotal: 0, discountTotal: 0, refundTotal: 0, orders: 0 };
|
|
212
|
+
}
|
|
213
|
+
yearLicenseMap[licenseName].total += saleTotal;
|
|
214
|
+
yearLicenseMap[licenseName].grossTotal += saleTotal;
|
|
215
|
+
yearLicenseMap[licenseName].taxTotal += saleTax;
|
|
216
|
+
yearLicenseMap[licenseName].discountTotal += saleDiscount;
|
|
217
|
+
yearLicenseMap[licenseName].refundTotal += saleRefund;
|
|
218
|
+
yearLicenseMap[licenseName].orders += 1;
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
yearTax,
|
|
223
|
+
yearDiscounts,
|
|
224
|
+
yearRefunds,
|
|
225
|
+
yearOrderCount,
|
|
226
|
+
yearLocationData: yearLocationMap,
|
|
227
|
+
yearTypefaces: Object.values(yearTypefaceMap).sort((a, b) => b.total - a.total),
|
|
228
|
+
yearLicenseTypes: Object.values(yearLicenseMap).sort((a, b) => b.total - a.total),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const mockSales = [
|
|
233
|
+
// Month 1
|
|
234
|
+
[
|
|
235
|
+
{
|
|
236
|
+
total: 10000, description: 'Berna (Desktop License)',
|
|
237
|
+
taxAmounts: [{ amount: 500 }], discountAmounts: [], refunds: [],
|
|
238
|
+
paymentMethod: { origin: { country: 'US' } },
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
total: 5000, description: 'Mckl (Web License)',
|
|
242
|
+
taxAmounts: [{ amount: 250 }], discountAmounts: [{ amount: 100 }], refunds: [],
|
|
243
|
+
paymentMethod: { origin: { country: 'GB' } },
|
|
244
|
+
},
|
|
245
|
+
],
|
|
246
|
+
// Month 2
|
|
247
|
+
[
|
|
248
|
+
{
|
|
249
|
+
total: 8000, description: 'Berna (Desktop License)',
|
|
250
|
+
taxAmounts: [{ amount: 400 }], discountAmounts: [], refunds: [{ adjustedTotal: 200 }],
|
|
251
|
+
paymentMethod: { origin: { country: 'US' } },
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
total: 0, shippingProvision: true, // should be skipped
|
|
255
|
+
},
|
|
256
|
+
],
|
|
257
|
+
];
|
|
258
|
+
|
|
259
|
+
test('aggregates year totals correctly', () => {
|
|
260
|
+
const result = aggregateYearData(mockSales);
|
|
261
|
+
|
|
262
|
+
expect(result.yearOrderCount).toBe(3); // 3 non-shipping sales
|
|
263
|
+
expect(result.yearTax).toBe(1150); // 500 + 250 + 400
|
|
264
|
+
expect(result.yearDiscounts).toBe(100);
|
|
265
|
+
expect(result.yearRefunds).toBe(200);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('aggregates location data correctly', () => {
|
|
269
|
+
const result = aggregateYearData(mockSales);
|
|
270
|
+
|
|
271
|
+
expect(result.yearLocationData['US'].revenue).toBe(18000); // 10000 + 8000
|
|
272
|
+
expect(result.yearLocationData['US'].orders).toBe(2);
|
|
273
|
+
expect(result.yearLocationData['GB'].revenue).toBe(5000);
|
|
274
|
+
expect(result.yearLocationData['GB'].orders).toBe(1);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('aggregates typeface data correctly', () => {
|
|
278
|
+
const result = aggregateYearData(mockSales);
|
|
279
|
+
|
|
280
|
+
const berna = result.yearTypefaces.find(t => t.title === 'Berna');
|
|
281
|
+
expect(berna.total).toBe(18000);
|
|
282
|
+
expect(berna.orders).toBe(2);
|
|
283
|
+
expect(berna.taxTotal).toBe(900); // 500 + 400
|
|
284
|
+
expect(berna.refundTotal).toBe(200);
|
|
285
|
+
|
|
286
|
+
const mckl = result.yearTypefaces.find(t => t.title === 'Mckl');
|
|
287
|
+
expect(mckl.total).toBe(5000);
|
|
288
|
+
expect(mckl.orders).toBe(1);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test('aggregates license type data correctly', () => {
|
|
292
|
+
const result = aggregateYearData(mockSales);
|
|
293
|
+
|
|
294
|
+
const desktop = result.yearLicenseTypes.find(l => l.name === 'Desktop License');
|
|
295
|
+
expect(desktop.total).toBe(18000);
|
|
296
|
+
expect(desktop.orders).toBe(2);
|
|
297
|
+
|
|
298
|
+
const web = result.yearLicenseTypes.find(l => l.name === 'Web License');
|
|
299
|
+
expect(web.total).toBe(5000);
|
|
300
|
+
expect(web.orders).toBe(1);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test('skips shipping provisions', () => {
|
|
304
|
+
const result = aggregateYearData(mockSales);
|
|
305
|
+
expect(result.yearOrderCount).toBe(3); // shipping sale not counted
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test('typefaces sorted by total descending', () => {
|
|
309
|
+
const result = aggregateYearData(mockSales);
|
|
310
|
+
expect(result.yearTypefaces[0].title).toBe('Berna'); // 18000 > 5000
|
|
311
|
+
expect(result.yearTypefaces[1].title).toBe('Mckl');
|
|
312
|
+
});
|
|
313
|
+
});
|
package/components/Insights.js
CHANGED
|
@@ -43,89 +43,106 @@ const getCountryName = (code) => {
|
|
|
43
43
|
* @param {Object} props.locationData - Data about sales by location
|
|
44
44
|
* @returns {JSX.Element} Insights component
|
|
45
45
|
*/
|
|
46
|
-
export default function Insights({
|
|
47
|
-
sales = [],
|
|
48
|
-
previousSales = [],
|
|
46
|
+
export default function Insights({
|
|
47
|
+
sales = [],
|
|
48
|
+
previousSales = [],
|
|
49
49
|
loading = false,
|
|
50
50
|
chartState,
|
|
51
51
|
date,
|
|
52
|
-
locationData = {}
|
|
52
|
+
locationData = {},
|
|
53
|
+
viewMode = 'month',
|
|
54
|
+
yearData = null
|
|
53
55
|
}) {
|
|
54
|
-
|
|
56
|
+
const isYearView = viewMode === 'year' && yearData;
|
|
57
|
+
|
|
58
|
+
if (!isYearView && (!sales || !chartState)) {
|
|
55
59
|
return null;
|
|
56
60
|
}
|
|
57
61
|
|
|
58
62
|
// Get month name for display
|
|
59
63
|
const monthName = new Date(date).toLocaleString("en-US", { timeZone: 'UTC', month: "long" });
|
|
60
64
|
const year = new Date(date).getUTCFullYear();
|
|
61
|
-
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
// Net revenue (excluding tax and shipping)
|
|
66
|
-
const netRevenue = (chartState.salesMax - chartState.taxData.at(-1) - chartState.shippingData.at(-1)) || 0;
|
|
67
|
-
|
|
68
|
-
// Gross revenue (including tax and shipping)
|
|
69
|
-
const grossRevenue = chartState.salesMax || 0;
|
|
70
|
-
|
|
71
|
-
const taxTotal = chartState.taxData.at(-1) || 0;
|
|
72
|
-
const shippingTotal = chartState.shippingData.at(-1) || 0;
|
|
73
|
-
|
|
74
|
-
const orderCount = sales.length;
|
|
75
|
-
const averageOrderValue = orderCount ? netRevenue / orderCount : 0;
|
|
76
|
-
// Sum all daily discount amounts to get total discounts for the period
|
|
77
|
-
const discountTotal = chartState.discountLostData.reduce((sum, val) => sum + val, 0) || 0;
|
|
78
|
-
const discountRate = netRevenue > 0 ? (discountTotal / (netRevenue + discountTotal)) * 100 : 0;
|
|
79
|
-
const refundTotal = chartState.refundTotal || 0;
|
|
80
|
-
const refundRate = netRevenue > 0 ? (refundTotal / (netRevenue + refundTotal)) * 100 : 0;
|
|
81
|
-
|
|
82
|
-
// Location data
|
|
83
|
-
const {
|
|
84
|
-
topLocation = 'Unknown',
|
|
85
|
-
topLocationPercentage = 0,
|
|
86
|
-
previousTopLocation = '',
|
|
87
|
-
previousTopLocationPercentage = 0,
|
|
88
|
-
locationChange = null
|
|
89
|
-
} = locationData;
|
|
90
|
-
|
|
91
|
-
// Calculate previous period metrics (if available)
|
|
92
|
-
let previousRevenue = 0;
|
|
93
|
-
let previousOrderCount = 0;
|
|
94
|
-
let previousAvgOrderValue = 0;
|
|
95
|
-
let previousRefundTotal = 0;
|
|
65
|
+
|
|
66
|
+
// Compute metrics based on view mode
|
|
67
|
+
let grossRevenue, netRevenue, orderCount, averageOrderValue, discountTotal, discountRate, refundTotal, refundRate;
|
|
68
|
+
let topLocation = 'Unknown', topLocationPercentage = 0, locationChange = null;
|
|
96
69
|
let hasPreviousData = false;
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
70
|
+
let revenueChange = null, orderCountChange = null, avgOrderValueChange = null, refundChange = null;
|
|
71
|
+
|
|
72
|
+
if (isYearView) {
|
|
73
|
+
// Year view: compute from yearData (values in cents from API)
|
|
74
|
+
grossRevenue = (yearData.yearTotal || 0) / 100;
|
|
75
|
+
const yearTax = (yearData.yearTax || 0) / 100;
|
|
76
|
+
const yearShipping = (yearData.yearShipping || 0) / 100;
|
|
77
|
+
netRevenue = grossRevenue - yearTax - yearShipping;
|
|
78
|
+
orderCount = yearData.yearOrderCount || 0;
|
|
79
|
+
averageOrderValue = orderCount ? netRevenue / orderCount : 0;
|
|
80
|
+
discountTotal = (yearData.yearDiscounts || 0) / 100;
|
|
81
|
+
discountRate = netRevenue > 0 ? (discountTotal / (netRevenue + discountTotal)) * 100 : 0;
|
|
82
|
+
refundTotal = (yearData.yearRefunds || 0) / 100;
|
|
83
|
+
refundRate = netRevenue > 0 ? (refundTotal / (netRevenue + refundTotal)) * 100 : 0;
|
|
84
|
+
|
|
85
|
+
// Derive top location from yearLocationData map
|
|
86
|
+
if (yearData.yearLocationData) {
|
|
87
|
+
let maxRevenue = 0;
|
|
88
|
+
let totalRevenue = 0;
|
|
89
|
+
Object.entries(yearData.yearLocationData).forEach(([code, data]) => {
|
|
90
|
+
totalRevenue += data.revenue || 0;
|
|
91
|
+
if ((data.revenue || 0) > maxRevenue) {
|
|
92
|
+
maxRevenue = data.revenue;
|
|
93
|
+
topLocation = code;
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
topLocationPercentage = totalRevenue > 0 ? (maxRevenue / totalRevenue) * 100 : 0;
|
|
97
|
+
}
|
|
98
|
+
} else {
|
|
99
|
+
// Month view: existing logic
|
|
100
|
+
netRevenue = (chartState.salesMax - chartState.taxData.at(-1) - chartState.shippingData.at(-1)) || 0;
|
|
101
|
+
grossRevenue = chartState.salesMax || 0;
|
|
102
|
+
orderCount = sales.length;
|
|
103
|
+
averageOrderValue = orderCount ? netRevenue / orderCount : 0;
|
|
104
|
+
discountTotal = chartState.discountLostData.reduce((sum, val) => sum + val, 0) || 0;
|
|
105
|
+
discountRate = netRevenue > 0 ? (discountTotal / (netRevenue + discountTotal)) * 100 : 0;
|
|
106
|
+
refundTotal = chartState.refundTotal || 0;
|
|
107
|
+
refundRate = netRevenue > 0 ? (refundTotal / (netRevenue + refundTotal)) * 100 : 0;
|
|
108
|
+
|
|
109
|
+
// Location data from month view
|
|
110
|
+
const locData = locationData || {};
|
|
111
|
+
topLocation = locData.topLocation || 'Unknown';
|
|
112
|
+
topLocationPercentage = locData.topLocationPercentage || 0;
|
|
113
|
+
locationChange = locData.locationChange ?? null;
|
|
114
|
+
|
|
115
|
+
// Calculate previous period metrics (if available)
|
|
116
|
+
let previousRevenue = 0;
|
|
117
|
+
let previousOrderCount = 0;
|
|
118
|
+
let previousAvgOrderValue = 0;
|
|
119
|
+
let previousRefundTotal = 0;
|
|
120
|
+
|
|
121
|
+
if (previousSales && previousSales.length > 0) {
|
|
122
|
+
hasPreviousData = true;
|
|
123
|
+
previousOrderCount = previousSales.length;
|
|
124
|
+
previousRevenue = previousSales.reduce((sum, sale) => {
|
|
125
|
+
const saleTotal = sale.total || 0;
|
|
126
|
+
const saleTax = sale.taxAmount || 0;
|
|
127
|
+
const saleShipping = sale.shippingCost || 0;
|
|
128
|
+
return sum + (saleTotal - saleTax - saleShipping) / 100;
|
|
129
|
+
}, 0);
|
|
130
|
+
previousRefundTotal = previousSales.reduce((sum, sale) => {
|
|
131
|
+
if (sale.refunds && sale.refunds.length > 0) {
|
|
132
|
+
return sum + sale.refunds.reduce((refundSum, refund) => {
|
|
133
|
+
return refundSum + (refund.adjustedTotal || 0);
|
|
134
|
+
}, 0) / 100;
|
|
135
|
+
}
|
|
136
|
+
return sum;
|
|
137
|
+
}, 0);
|
|
138
|
+
previousAvgOrderValue = previousOrderCount ? previousRevenue / previousOrderCount : 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
revenueChange = calculatePercentageChange(grossRevenue, previousRevenue);
|
|
142
|
+
orderCountChange = calculatePercentageChange(orderCount, previousOrderCount);
|
|
143
|
+
avgOrderValueChange = calculatePercentageChange(averageOrderValue, previousAvgOrderValue);
|
|
144
|
+
refundChange = calculatePercentageChange(refundTotal, previousRefundTotal);
|
|
122
145
|
}
|
|
123
|
-
|
|
124
|
-
// Calculate period-over-period changes with our utility function
|
|
125
|
-
const revenueChange = calculatePercentageChange(grossRevenue, previousRevenue);
|
|
126
|
-
const orderCountChange = calculatePercentageChange(orderCount, previousOrderCount);
|
|
127
|
-
const avgOrderValueChange = calculatePercentageChange(averageOrderValue, previousAvgOrderValue);
|
|
128
|
-
const refundChange = calculatePercentageChange(refundTotal, previousRefundTotal);
|
|
129
146
|
|
|
130
147
|
// Helper function to get trend icon
|
|
131
148
|
const getTrendIcon = (change) => {
|
|
@@ -150,21 +167,21 @@ export default function Insights({
|
|
|
150
167
|
title: 'Gross Sales',
|
|
151
168
|
value: formatCurrency(grossRevenue),
|
|
152
169
|
change: revenueChange,
|
|
153
|
-
tooltip:
|
|
170
|
+
tooltip: isYearView ? `${year} year total` : hasPreviousData ? 'vs prior month' : 'No prior data available',
|
|
154
171
|
bgcolor: '#000000',
|
|
155
172
|
},
|
|
156
173
|
{
|
|
157
174
|
title: 'Orders',
|
|
158
175
|
value: orderCount,
|
|
159
176
|
change: orderCountChange,
|
|
160
|
-
tooltip:
|
|
177
|
+
tooltip: isYearView ? `${year} year total` : hasPreviousData ? 'vs prior month' : 'No prior data available',
|
|
161
178
|
bgcolor: '#000000',
|
|
162
179
|
},
|
|
163
180
|
{
|
|
164
181
|
title: 'Avg. Order Value',
|
|
165
182
|
value: formatCurrency(averageOrderValue),
|
|
166
183
|
change: avgOrderValueChange,
|
|
167
|
-
tooltip:
|
|
184
|
+
tooltip: isYearView ? `${year} year average` : hasPreviousData ? 'vs prior month' : 'No prior data available',
|
|
168
185
|
bgcolor: '#000000',
|
|
169
186
|
},
|
|
170
187
|
{
|
|
@@ -184,9 +201,10 @@ export default function Insights({
|
|
|
184
201
|
title: 'Top Location',
|
|
185
202
|
value: topLocation !== 'Unknown' ? `${topLocationPercentage.toFixed(0)}% ${topLocation}` : 'Unknown',
|
|
186
203
|
change: locationChange,
|
|
187
|
-
tooltip:
|
|
188
|
-
|
|
189
|
-
|
|
204
|
+
tooltip: isYearView ? `${year} top market` :
|
|
205
|
+
(locationData?.previousTopLocation ?
|
|
206
|
+
`Previous: ${locationData.previousTopLocation} (${(locationData.previousTopLocationPercentage || 0).toFixed(1)}%)` :
|
|
207
|
+
'No prior data available'),
|
|
190
208
|
bgcolor: '#000000',
|
|
191
209
|
isLocationCard: true,
|
|
192
210
|
locationCode: topLocation,
|
|
@@ -67,8 +67,8 @@ export default function LicenseTypeList({ licenseTypeData, sales, loading, admin
|
|
|
67
67
|
width: '100%',
|
|
68
68
|
margin: "20px 0 10px",
|
|
69
69
|
borderRadius: "4px",
|
|
70
|
-
opacity: !!sales
|
|
71
|
-
pointerEvents: !!sales
|
|
70
|
+
opacity: !!(sales?.length || licenseTypeData?.length) ? 1 : 0.25,
|
|
71
|
+
pointerEvents: !!(sales?.length || licenseTypeData?.length) ? "" : "none",
|
|
72
72
|
border: "1px solid var(--black, black)",
|
|
73
73
|
'&:hover': { boxShadow: "0 0 8px rgba(0,0,0,0.25)" },
|
|
74
74
|
}}
|
package/components/Sales.js
CHANGED
|
@@ -51,6 +51,11 @@ export default function Sales(props) {
|
|
|
51
51
|
const [viewMode, setViewMode] = useState('month'); // 'month' | 'year'
|
|
52
52
|
const [displayLosses, setDisplayLosses] = useState(false);
|
|
53
53
|
|
|
54
|
+
// Year view state
|
|
55
|
+
const [yearData, setYearData] = useState(null);
|
|
56
|
+
const [yearLoading, setYearLoading] = useState(false);
|
|
57
|
+
const [yearError, setYearError] = useState('');
|
|
58
|
+
|
|
54
59
|
// Max retry attempts for failed API calls
|
|
55
60
|
const MAX_RETRIES = 3;
|
|
56
61
|
|
|
@@ -309,6 +314,48 @@ export default function Sales(props) {
|
|
|
309
314
|
loadData();
|
|
310
315
|
}, [designer?.user, designer?.password, designer?.admin, date, enabled]);
|
|
311
316
|
|
|
317
|
+
// Fetch year sales data when in year view mode
|
|
318
|
+
async function fetchYearSales() {
|
|
319
|
+
if (!designer?.user || !designer?.password || !date) return;
|
|
320
|
+
setYearLoading(true);
|
|
321
|
+
setYearError('');
|
|
322
|
+
try {
|
|
323
|
+
const response = await fetchWithRetry(
|
|
324
|
+
'/api/sales-portal/getYearSales',
|
|
325
|
+
{
|
|
326
|
+
method: 'POST',
|
|
327
|
+
headers: { 'Content-Type': 'application/json' },
|
|
328
|
+
body: JSON.stringify({
|
|
329
|
+
user: designer?.user,
|
|
330
|
+
password: designer?.password,
|
|
331
|
+
year: date.getUTCFullYear(),
|
|
332
|
+
admin: designer?.admin
|
|
333
|
+
}),
|
|
334
|
+
},
|
|
335
|
+
'Year sales data'
|
|
336
|
+
);
|
|
337
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
338
|
+
const data = await response.json();
|
|
339
|
+
if (data.success) {
|
|
340
|
+
setYearData(data.data);
|
|
341
|
+
} else {
|
|
342
|
+
setYearError('Error retrieving year sales data');
|
|
343
|
+
}
|
|
344
|
+
} catch (err) {
|
|
345
|
+
if (err.name !== 'AbortError' && err.message !== 'Failed to fetch') {
|
|
346
|
+
setYearError('Error retrieving year sales data');
|
|
347
|
+
console.error(err);
|
|
348
|
+
}
|
|
349
|
+
} finally {
|
|
350
|
+
setYearLoading(false);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
useEffect(() => {
|
|
355
|
+
if (viewMode !== 'year' || !enabled || !date) return;
|
|
356
|
+
fetchYearSales();
|
|
357
|
+
}, [viewMode, designer?.user, designer?.password, designer?.admin, date, enabled]);
|
|
358
|
+
|
|
312
359
|
// Process sales data
|
|
313
360
|
useEffect(() => {
|
|
314
361
|
if (!sales || !date) return;
|
|
@@ -551,23 +598,23 @@ export default function Sales(props) {
|
|
|
551
598
|
display: "inline-block",
|
|
552
599
|
}}
|
|
553
600
|
>
|
|
554
|
-
<Box
|
|
601
|
+
<Box
|
|
555
602
|
component={"span"}
|
|
556
603
|
sx={{
|
|
557
|
-
background: (sales?.reduce((acc, curr) => acc + curr.total, 0) / 100) > 0 ? 'rgba(var(--greenRGB, 0, 255, 0), 0.25)' : "",
|
|
604
|
+
background: ((viewMode === 'year' ? (yearData?.yearTotal || 0) / 100 : sales?.reduce((acc, curr) => acc + curr.total, 0) / 100)) > 0 ? 'rgba(var(--greenRGB, 0, 255, 0), 0.25)' : "",
|
|
558
605
|
padding: "5px 10px 0 10px",
|
|
559
606
|
}}
|
|
560
607
|
>
|
|
561
|
-
{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> : ``}
|
|
562
|
-
{sales ? (
|
|
608
|
+
{sales ? <Box component="span" sx={{ fontVariationSettings: '"wght" 300' }}><Box component="span" sx={{ display: { xs: 'none', md: 'inherit' }, opacity: "0.5" }}>{getCurrencyLabel(viewMode === 'year' ? yearData?.currency : sales[0]?.currency)}</Box>{getCurrencySymbol(viewMode === 'year' ? yearData?.currency : sales[0]?.currency)} </Box> : ``}
|
|
609
|
+
{sales || yearData ? (
|
|
563
610
|
<>
|
|
564
611
|
<span className={`sales-total`} style={{ fontVariationSettings: '"wght" 900' }}>
|
|
565
|
-
{(total).toLocaleString('en-US', { minimumFractionDigits: 2 })}
|
|
612
|
+
{(viewMode === 'year' ? (yearData?.yearTotal || 0) / 100 : total).toLocaleString('en-US', { minimumFractionDigits: 2 })}
|
|
566
613
|
</span>
|
|
567
614
|
</>
|
|
568
615
|
) : `0.00`}
|
|
569
616
|
</Box>
|
|
570
|
-
{previousSales && previousSales.length > 0 && revenueChangePercent && (
|
|
617
|
+
{viewMode !== 'year' && previousSales && previousSales.length > 0 && revenueChangePercent && (
|
|
571
618
|
<Tooltip
|
|
572
619
|
title={`Net Sales (after tax and shipping): ${previousSales && previousSales.length > 0 ? revenueChangePercent.toFixed(1) + '% vs prior month' : 'No prior month data'}`}
|
|
573
620
|
placement="top"
|
|
@@ -814,7 +861,7 @@ export default function Sales(props) {
|
|
|
814
861
|
</Box>
|
|
815
862
|
}
|
|
816
863
|
</Box>
|
|
817
|
-
), [loading, sales, total, designer, date, message, revenueChangePercent, previousSales, retryInfo, viewMode]);
|
|
864
|
+
), [loading, sales, total, designer, date, message, revenueChangePercent, previousSales, retryInfo, viewMode, yearData]);
|
|
818
865
|
|
|
819
866
|
return (
|
|
820
867
|
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
|
@@ -841,10 +888,10 @@ export default function Sales(props) {
|
|
|
841
888
|
{HeaderSection}
|
|
842
889
|
|
|
843
890
|
{/* Sales Data Display */}
|
|
844
|
-
{!!sales.length && (
|
|
891
|
+
{!!(sales.length || (viewMode === 'year' && yearData)) && (
|
|
845
892
|
<>
|
|
846
893
|
<Box
|
|
847
|
-
data-disabled={loading}
|
|
894
|
+
data-disabled={loading || yearLoading}
|
|
848
895
|
className={`sales-data-section ${styles.salesSection}`}
|
|
849
896
|
sx={{
|
|
850
897
|
width: '100%',
|
|
@@ -858,7 +905,9 @@ export default function Sales(props) {
|
|
|
858
905
|
{viewMode === 'year' && date ? (
|
|
859
906
|
<Box className="year-overview-wrapper">
|
|
860
907
|
<YearOverview
|
|
861
|
-
|
|
908
|
+
data={yearData}
|
|
909
|
+
loading={yearLoading}
|
|
910
|
+
error={yearError}
|
|
862
911
|
year={date.getUTCFullYear()}
|
|
863
912
|
onMonthClick={(monthIndex) => {
|
|
864
913
|
const newDate = new Date(Date.UTC(date.getUTCFullYear(), monthIndex, 1));
|
|
@@ -890,19 +939,21 @@ export default function Sales(props) {
|
|
|
890
939
|
chartState={chartState}
|
|
891
940
|
date={date}
|
|
892
941
|
locationData={locationData}
|
|
942
|
+
viewMode={viewMode}
|
|
943
|
+
yearData={yearData}
|
|
893
944
|
/>
|
|
894
945
|
</Box>
|
|
895
946
|
|
|
896
947
|
{/* Top Performers Section */}
|
|
897
|
-
{!!(categories && typefaceData &&
|
|
948
|
+
{!!(categories && (typefaceData?.length || (viewMode === 'year' && yearData?.yearTypefaces?.length))) && (
|
|
898
949
|
<Box className="top-performers-wrapper">
|
|
899
950
|
<TopPerformers
|
|
900
|
-
typefaceData={typefaceData}
|
|
951
|
+
typefaceData={viewMode === 'year' ? yearData?.yearTypefaces : typefaceData}
|
|
901
952
|
designersData={designersData}
|
|
902
|
-
total={total}
|
|
953
|
+
total={viewMode === 'year' ? (yearData?.yearTotal || 0) : total}
|
|
903
954
|
chartState={chartState}
|
|
904
955
|
sales={sales}
|
|
905
|
-
loading={loading}
|
|
956
|
+
loading={viewMode === 'year' ? yearLoading : loading}
|
|
906
957
|
admin={admin}
|
|
907
958
|
/>
|
|
908
959
|
</Box>
|
|
@@ -911,9 +962,9 @@ export default function Sales(props) {
|
|
|
911
962
|
{/* Typeface List */}
|
|
912
963
|
<Box className="typeface-list-wrapper">
|
|
913
964
|
<TypefaceList
|
|
914
|
-
typefaceData={typefaceData}
|
|
965
|
+
typefaceData={viewMode === 'year' ? yearData?.yearTypefaces : typefaceData}
|
|
915
966
|
sales={sales}
|
|
916
|
-
loading={loading}
|
|
967
|
+
loading={viewMode === 'year' ? yearLoading : loading}
|
|
917
968
|
admin={admin}
|
|
918
969
|
/>
|
|
919
970
|
</Box>
|
|
@@ -921,15 +972,15 @@ export default function Sales(props) {
|
|
|
921
972
|
{/* License Type List */}
|
|
922
973
|
<Box className="license-type-list-wrapper">
|
|
923
974
|
<LicenseTypeList
|
|
924
|
-
licenseTypeData={licenseTypeData}
|
|
975
|
+
licenseTypeData={viewMode === 'year' ? yearData?.yearLicenseTypes : licenseTypeData}
|
|
925
976
|
sales={sales}
|
|
926
|
-
loading={loading}
|
|
977
|
+
loading={viewMode === 'year' ? yearLoading : loading}
|
|
927
978
|
admin={admin}
|
|
928
979
|
/>
|
|
929
980
|
</Box>
|
|
930
981
|
|
|
931
982
|
{/* Sales Table Section */}
|
|
932
|
-
{designer && (
|
|
983
|
+
{viewMode !== 'year' && designer && (
|
|
933
984
|
<Box className="sales-table-wrapper">
|
|
934
985
|
<SalesTable
|
|
935
986
|
sales={sales}
|
|
@@ -101,8 +101,8 @@ export default function TopPerformers({
|
|
|
101
101
|
width: '100%',
|
|
102
102
|
margin: "20px 0 10px",
|
|
103
103
|
borderRadius: "4px",
|
|
104
|
-
opacity: !!sales
|
|
105
|
-
pointerEvents: !!sales
|
|
104
|
+
opacity: !!(sales?.length || typefaceData?.length) ? 1 : 0.25,
|
|
105
|
+
pointerEvents: !!(sales?.length || typefaceData?.length) ? "" : "none",
|
|
106
106
|
border: "1px solid var(--black, black)",
|
|
107
107
|
'&:hover': { boxShadow: "0 0 8px rgba(0,0,0,0.25)" },
|
|
108
108
|
}}
|
|
@@ -234,8 +234,8 @@ export default function TopPerformers({
|
|
|
234
234
|
width: '100%',
|
|
235
235
|
margin: "20px 0 10px",
|
|
236
236
|
borderRadius: "4px",
|
|
237
|
-
opacity: !!sales
|
|
238
|
-
pointerEvents: !!sales
|
|
237
|
+
opacity: !!(sales?.length || typefaceData?.length) ? 1 : 0.25,
|
|
238
|
+
pointerEvents: !!(sales?.length || typefaceData?.length) ? "" : "none",
|
|
239
239
|
border: "1px solid var(--black, black)",
|
|
240
240
|
|
|
241
241
|
'&:hover': { boxShadow: "0 0 8px rgba(0,0,0,0.25)" },
|
|
@@ -55,8 +55,8 @@ export default function TypefaceList({ typefaceData, sales, loading, admin }) {
|
|
|
55
55
|
width: '100%',
|
|
56
56
|
margin: "20px 0 10px",
|
|
57
57
|
borderRadius: "4px",
|
|
58
|
-
opacity: !!sales
|
|
59
|
-
pointerEvents: !!sales
|
|
58
|
+
opacity: !!(sales?.length || typefaceData?.length) ? 1 : 0.25,
|
|
59
|
+
pointerEvents: !!(sales?.length || typefaceData?.length) ? "" : "none",
|
|
60
60
|
border: "1px solid var(--black, black)",
|
|
61
61
|
'&:hover': { boxShadow: "0 0 8px rgba(0,0,0,0.25)" },
|
|
62
62
|
}}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Year overview component showing 12-month stacked bar chart by typeface
|
|
2
|
-
import React, {
|
|
2
|
+
import React, { useMemo } from 'react';
|
|
3
3
|
import { Box, Typography, CircularProgress, Tooltip } from '@mui/material';
|
|
4
4
|
import { ResponsiveChartContainer } from '@mui/x-charts/ResponsiveChartContainer';
|
|
5
5
|
import { BarPlot } from '@mui/x-charts/BarChart';
|
|
@@ -34,38 +34,7 @@ const COLORS = [
|
|
|
34
34
|
* @param {number} props.year - Year to display
|
|
35
35
|
* @param {Function} props.onMonthClick - Called when a bar is clicked, receives month index
|
|
36
36
|
*/
|
|
37
|
-
export default function YearOverview({
|
|
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]);
|
|
37
|
+
export default function YearOverview({ data, loading = false, error = '', year, onMonthClick }) {
|
|
69
38
|
|
|
70
39
|
// Build chart series from typeface data
|
|
71
40
|
const { series, xAxisData, topTypefaces } = useMemo(() => {
|