@liiift-studio/sales-portal 3.1.2 → 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/getSales.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { authenticateDesigner, processSalesData } from './utils/salesDataProcessor';
3
3
  import { sendError, requirePost } from './utils/apiResponse';
4
4
 
5
- export const config = { maxDuration: 300 };
5
+ export const config = { maxDuration: 800 };
6
6
 
7
7
  export default async function handler(req, res) {
8
8
  if (!requirePost(req, res)) return;
@@ -1,13 +1,68 @@
1
- // API endpoint for fetching a full year of sales data in a single request
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: 300 };
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 typeface breakdowns.
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
- // Group sales by typeface for the stacked chart
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 += sale.total || 0;
126
+ typefaceMap[typefaceName].total += saleTotal;
56
127
  typefaceMap[typefaceName].count += 1;
57
- monthTotal += sale.total || 0;
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 a list of all typefaces across the year for consistent chart series
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
- currency: months.find(m => m.currency)?.currency || 'usd',
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
+ });
@@ -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
- if (!sales || !chartState) {
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
- // NOTE: Values from chartState are transformed in the processChartData function in salesDataProcessing.js
63
- // Values from the API are in cents, but processChartData divides them by 100 for display
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
- if (previousSales && previousSales.length > 0) {
99
- hasPreviousData = true;
100
- previousOrderCount = previousSales.length;
101
-
102
- // Important: Raw sale data is in cents, but after applying processChartData,
103
- // the previous period data would be in dollars.
104
- // Here we're calculating manually, so we need to convert the values ourselves
105
- previousRevenue = previousSales.reduce((sum, sale) => {
106
- const saleTotal = sale.total || 0;
107
- const saleTax = sale.taxAmount || 0;
108
- const saleShipping = sale.shippingCost || 0;
109
- return sum + (saleTotal - saleTax - saleShipping) / 100; // Convert cents to dollars
110
- }, 0);
111
-
112
- previousRefundTotal = previousSales.reduce((sum, sale) => {
113
- if (sale.refunds && sale.refunds.length > 0) {
114
- return sum + sale.refunds.reduce((refundSum, refund) => {
115
- return refundSum + (refund.adjustedTotal || 0);
116
- }, 0) / 100; // Convert cents to dollars
117
- }
118
- return sum;
119
- }, 0);
120
-
121
- previousAvgOrderValue = previousOrderCount ? previousRevenue / previousOrderCount : 0;
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: hasPreviousData ? `Previous: ${formatCurrency(previousRevenue)}` : 'No prior data available',
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: hasPreviousData ? `Previous: ${previousOrderCount}` : 'No prior data available',
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: hasPreviousData ? `Previous: ${formatCurrency(previousAvgOrderValue)}` : 'No prior data available',
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: previousTopLocation ?
188
- `Previous top location: ${previousTopLocation} (${previousTopLocationPercentage.toFixed(1)}%)` :
189
- 'No prior data available',
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,
@@ -259,7 +277,7 @@ export default function Insights({
259
277
 
260
278
  {/* Metric cards */}
261
279
  {metricCards.map((card, index) => (
262
- <Box key={`metric-${index}`} sx={{ width: { xs: '100%', sm: 'calc(50% - 8px)', md: 'calc(33.33% - 11px)' } }}>
280
+ <Box key={`metric-${index}`} sx={{ flex: { xs: '1 1 100%', sm: '1 1 calc(50% - 8px)', md: '1 1 calc(33.33% - 11px)' } }}>
263
281
  <Tooltip
264
282
  title={card.tooltip}
265
283
  placement="top"
@@ -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.length ? 1 : 0.25,
71
- pointerEvents: !!sales.length ? "" : "none",
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
  }}
@@ -1,6 +1,8 @@
1
1
  // Login form component for sales portal authentication
2
2
  import React, { useEffect, useState } from 'react';
3
- import { Typography, Input, Button, Box } from '@mui/material';
3
+ import { Typography, Input, Button, Box, IconButton, InputAdornment } from '@mui/material';
4
+ import VisibilityIcon from '@mui/icons-material/Visibility';
5
+ import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
4
6
  import packageJson from '../package.json';
5
7
 
6
8
  const { version } = packageJson;
@@ -27,6 +29,7 @@ export default function LoginForm({
27
29
  const [password, setPassword] = useState('');
28
30
  const [message, setMessage] = useState('');
29
31
  const [loading, setLoading] = useState(false);
32
+ const [showPassword, setShowPassword] = useState(false);
30
33
 
31
34
  const {
32
35
  title = 'Sales Portal',
@@ -128,7 +131,7 @@ export default function LoginForm({
128
131
  {title}
129
132
  </Typography>
130
133
 
131
- <Box sx={{ px: { xs: 4, sm: 6 } }}>
134
+ <Box sx={{ px: { xs: 0, sm: 6 } }}>
132
135
  <Input
133
136
  placeholder="Email"
134
137
  onChange={(e) => setUser(e.target.value)}
@@ -160,9 +163,21 @@ export default function LoginForm({
160
163
  placeholder="Password"
161
164
  onChange={(e) => setPassword(e.target.value)}
162
165
  value={password}
163
- type="password"
166
+ type={showPassword ? 'text' : 'password'}
164
167
  autoComplete="current-password"
165
168
  onKeyPress={handleKeyPress}
169
+ endAdornment={
170
+ <InputAdornment position="end">
171
+ <IconButton
172
+ onClick={() => setShowPassword(!showPassword)}
173
+ edge="end"
174
+ sx={{ color: 'var(--grey800, #666)', mr: 0.5 }}
175
+ aria-label={showPassword ? 'Hide password' : 'Show password'}
176
+ >
177
+ {showPassword ? <VisibilityOffIcon /> : <VisibilityIcon />}
178
+ </IconButton>
179
+ </InputAdornment>
180
+ }
166
181
  sx={{
167
182
  m: 0,
168
183
  border: 'none',
@@ -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
- designer={designer}
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 && typefaceData.length) && (
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.length ? 1 : 0.25,
105
- pointerEvents: !!sales.length ? "" : "none",
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.length ? 1 : 0.25,
238
- pointerEvents: !!sales.length ? "" : "none",
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.length ? 1 : 0.25,
59
- pointerEvents: !!sales.length ? "" : "none",
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, { useState, useEffect, useMemo } from '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({ designer, year, onMonthClick }) {
38
- const [data, setData] = useState(null);
39
- const [loading, setLoading] = useState(false);
40
- const [error, setError] = useState('');
41
-
42
- useEffect(() => {
43
- if (!designer?.user || !designer?.password || !year) return;
44
-
45
- setLoading(true);
46
- setError('');
47
-
48
- fetch('/api/sales-portal/getYearSales', {
49
- method: 'POST',
50
- headers: { 'Content-Type': 'application/json' },
51
- body: JSON.stringify({
52
- user: designer.user,
53
- password: designer.password,
54
- year,
55
- admin: designer.admin || false,
56
- }),
57
- })
58
- .then(res => res.json())
59
- .then(res => {
60
- if (res.success) {
61
- setData(res.data);
62
- } else {
63
- setError(res.message || 'Failed to load year data');
64
- }
65
- })
66
- .catch(err => setError(err.message))
67
- .finally(() => setLoading(false));
68
- }, [designer?.user, designer?.password, designer?.admin, year]);
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(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liiift-studio/sales-portal",
3
- "version": "3.1.2",
3
+ "version": "3.1.4",
4
4
  "description": "Centralized sales portal package for Liiift Studio projects",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",