@liiift-studio/sales-portal 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +461 -0
- package/SETUP.md +230 -0
- package/api/getAnalytics.d.ts +38 -0
- package/api/getAnalytics.js +346 -0
- package/api/getBalanceTransactions.d.ts +29 -0
- package/api/getBalanceTransactions.js +125 -0
- package/api/getDesignerInfo.d.ts +37 -0
- package/api/getDesignerInfo.js +98 -0
- package/api/getDesigners.d.ts +28 -0
- package/api/getDesigners.js +63 -0
- package/api/getPreviousSales.d.ts +23 -0
- package/api/getPreviousSales.js +82 -0
- package/api/getSales.d.ts +29 -0
- package/api/getSales.js +50 -0
- package/api/getSalesRange.d.ts +23 -0
- package/api/getSalesRange.js +58 -0
- package/api/utils/authMiddleware.js +84 -0
- package/api/utils/dateUtils.js +69 -0
- package/api/utils/feeCalculator.js +148 -0
- package/api/utils/processors/invoiceProcessor.js +337 -0
- package/api/utils/processors/paymentProcessor.js +462 -0
- package/api/utils/salesDataProcessing.js +596 -0
- package/api/utils/salesDataProcessor.js +224 -0
- package/api/utils/stripeFetcher.js +248 -0
- package/components/DateRangeSalesTable.js +1072 -0
- package/components/DebugValues.js +48 -0
- package/components/LicenseTypeList.js +193 -0
- package/components/LoginForm.js +219 -0
- package/components/PeriodComparison.js +501 -0
- package/components/Sales.js +773 -0
- package/components/SalesChart.js +307 -0
- package/components/SalesPortalPage.js +147 -0
- package/components/SalesTable.js +677 -0
- package/components/SummaryCards.js +345 -0
- package/components/TopPerformers.js +331 -0
- package/components/TypefaceList.js +154 -0
- package/components/table-columns.js +70 -0
- package/components/table-row-cells.js +295 -0
- package/data/countryCode.json +318 -0
- package/hooks/useSalesDateQuery.d.ts +20 -0
- package/hooks/useSalesDateQuery.js +71 -0
- package/index.d.ts +172 -0
- package/index.js +33 -0
- package/package.json +87 -0
- package/styles/sales-portal.module.scss +383 -0
- package/styles/sales-portal.theme.d.ts +5 -0
- package/styles/sales-portal.theme.js +799 -0
- package/utils/currencyUtils.d.ts +20 -0
- package/utils/currencyUtils.js +79 -0
- package/utils/salesDataProcessing.d.ts +44 -0
- package/utils/salesDataProcessing.js +596 -0
- package/utils/useSalesDateQuery.js +71 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
// Summary dashboard cards displaying key sales metrics at a glance with country code tooltips
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import {
|
|
4
|
+
Grid,
|
|
5
|
+
Box,
|
|
6
|
+
Typography,
|
|
7
|
+
Paper,
|
|
8
|
+
Tooltip,
|
|
9
|
+
useMediaQuery,
|
|
10
|
+
useTheme
|
|
11
|
+
} from '@mui/material';
|
|
12
|
+
import TrendingUpIcon from '@mui/icons-material/TrendingUp';
|
|
13
|
+
import TrendingDownIcon from '@mui/icons-material/TrendingDown';
|
|
14
|
+
import TrendingFlatIcon from '@mui/icons-material/TrendingFlat';
|
|
15
|
+
import styles from '../styles/sales-portal.module.scss';
|
|
16
|
+
import DebugValues from './DebugValues';
|
|
17
|
+
import { formatCurrency, calculatePercentageChange, formatPercentage } from '../utils/currencyUtils';
|
|
18
|
+
import countryCodes from '../data/countryCode.json';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Gets the full country name from a country code
|
|
22
|
+
* @param {string} code - The country code (e.g., 'US', 'GB')
|
|
23
|
+
* @returns {string} The full country name or the original code if not found
|
|
24
|
+
*/
|
|
25
|
+
const getCountryName = (code) => {
|
|
26
|
+
if (!code || code === 'Unknown') return 'Unknown';
|
|
27
|
+
|
|
28
|
+
// If it's already a full name (more than 2 chars), return as is
|
|
29
|
+
if (code.length > 2) return code;
|
|
30
|
+
|
|
31
|
+
// Find the country in the countryCodes array
|
|
32
|
+
const country = countryCodes.find(c => c.code === code);
|
|
33
|
+
return country ? country.label : code;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Summary dashboard component showing key sales metrics with period-over-period comparison
|
|
38
|
+
* @param {Object} props - Component props
|
|
39
|
+
* @param {Array} props.sales - Current period sales data
|
|
40
|
+
* @param {Array} props.previousSales - Previous period sales data for comparison
|
|
41
|
+
* @param {boolean} props.loading - Loading state
|
|
42
|
+
* @param {Object} props.chartState - Chart data state
|
|
43
|
+
* @param {Date} props.date - Current date being displayed
|
|
44
|
+
* @param {Object} props.locationData - Data about sales by location
|
|
45
|
+
* @returns {JSX.Element} Summary cards component
|
|
46
|
+
*/
|
|
47
|
+
export default function SummaryCards({
|
|
48
|
+
sales = [],
|
|
49
|
+
previousSales = [],
|
|
50
|
+
loading = false,
|
|
51
|
+
chartState,
|
|
52
|
+
date,
|
|
53
|
+
locationData = {}
|
|
54
|
+
}) {
|
|
55
|
+
if (!sales || !chartState) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Get month name for display
|
|
60
|
+
const monthName = new Date(date).toLocaleString("en-US", { timeZone: 'UTC', month: "long" });
|
|
61
|
+
const year = new Date(date).getUTCFullYear();
|
|
62
|
+
|
|
63
|
+
// NOTE: Values from chartState are transformed in the processChartData function in salesDataProcessing.js
|
|
64
|
+
// Values from the API are in cents, but processChartData divides them by 100 for display
|
|
65
|
+
|
|
66
|
+
// Net revenue (excluding tax and shipping)
|
|
67
|
+
const netRevenue = (chartState.salesMax - chartState.taxData.at(-1) - chartState.shippingData.at(-1)) || 0;
|
|
68
|
+
|
|
69
|
+
// Gross revenue (including tax and shipping)
|
|
70
|
+
const grossRevenue = chartState.salesMax || 0;
|
|
71
|
+
|
|
72
|
+
const taxTotal = chartState.taxData.at(-1) || 0;
|
|
73
|
+
const shippingTotal = chartState.shippingData.at(-1) || 0;
|
|
74
|
+
|
|
75
|
+
const orderCount = sales.length;
|
|
76
|
+
const averageOrderValue = orderCount ? netRevenue / orderCount : 0;
|
|
77
|
+
// Sum all daily discount amounts to get total discounts for the period
|
|
78
|
+
const discountTotal = chartState.discountLostData.reduce((sum, val) => sum + val, 0) || 0;
|
|
79
|
+
const discountRate = netRevenue > 0 ? (discountTotal / (netRevenue + discountTotal)) * 100 : 0;
|
|
80
|
+
const refundTotal = chartState.refundTotal || 0;
|
|
81
|
+
const refundRate = netRevenue > 0 ? (refundTotal / (netRevenue + refundTotal)) * 100 : 0;
|
|
82
|
+
|
|
83
|
+
// Location data
|
|
84
|
+
const {
|
|
85
|
+
topLocation = 'Unknown',
|
|
86
|
+
topLocationPercentage = 0,
|
|
87
|
+
previousTopLocation = '',
|
|
88
|
+
previousTopLocationPercentage = 0,
|
|
89
|
+
locationChange = null
|
|
90
|
+
} = locationData;
|
|
91
|
+
|
|
92
|
+
// Calculate previous period metrics (if available)
|
|
93
|
+
let previousRevenue = 0;
|
|
94
|
+
let previousOrderCount = 0;
|
|
95
|
+
let previousAvgOrderValue = 0;
|
|
96
|
+
let previousRefundTotal = 0;
|
|
97
|
+
let hasPreviousData = false;
|
|
98
|
+
|
|
99
|
+
if (previousSales && previousSales.length > 0) {
|
|
100
|
+
hasPreviousData = true;
|
|
101
|
+
previousOrderCount = previousSales.length;
|
|
102
|
+
|
|
103
|
+
// Important: Raw sale data is in cents, but after applying processChartData,
|
|
104
|
+
// the previous period data would be in dollars.
|
|
105
|
+
// Here we're calculating manually, so we need to convert the values ourselves
|
|
106
|
+
previousRevenue = previousSales.reduce((sum, sale) => {
|
|
107
|
+
const saleTotal = sale.total || 0;
|
|
108
|
+
const saleTax = sale.taxAmount || 0;
|
|
109
|
+
const saleShipping = sale.shippingCost || 0;
|
|
110
|
+
return sum + (saleTotal - saleTax - saleShipping) / 100; // Convert cents to dollars
|
|
111
|
+
}, 0);
|
|
112
|
+
|
|
113
|
+
previousRefundTotal = previousSales.reduce((sum, sale) => {
|
|
114
|
+
if (sale.refunds && sale.refunds.length > 0) {
|
|
115
|
+
return sum + sale.refunds.reduce((refundSum, refund) => {
|
|
116
|
+
return refundSum + (refund.adjustedTotal || 0);
|
|
117
|
+
}, 0) / 100; // Convert cents to dollars
|
|
118
|
+
}
|
|
119
|
+
return sum;
|
|
120
|
+
}, 0);
|
|
121
|
+
|
|
122
|
+
previousAvgOrderValue = previousOrderCount ? previousRevenue / previousOrderCount : 0;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Calculate period-over-period changes with our utility function
|
|
126
|
+
const revenueChange = calculatePercentageChange(grossRevenue, previousRevenue);
|
|
127
|
+
const orderCountChange = calculatePercentageChange(orderCount, previousOrderCount);
|
|
128
|
+
const avgOrderValueChange = calculatePercentageChange(averageOrderValue, previousAvgOrderValue);
|
|
129
|
+
const refundChange = calculatePercentageChange(refundTotal, previousRefundTotal);
|
|
130
|
+
|
|
131
|
+
// Helper function to get trend icon
|
|
132
|
+
const getTrendIcon = (change) => {
|
|
133
|
+
if (change === null) return null;
|
|
134
|
+
|
|
135
|
+
// For refund metrics, lower is better
|
|
136
|
+
const isRefundMetric = false; // Set to true for refund metrics
|
|
137
|
+
|
|
138
|
+
if (Math.abs(change) < 1) return <TrendingFlatIcon sx={{ mx: 1 }} />;
|
|
139
|
+
|
|
140
|
+
if ((change > 0 && !isRefundMetric) || (change < 0 && isRefundMetric)) {
|
|
141
|
+
return <TrendingUpIcon sx={{ mx: 1, fontSize: '1em', verticalAlign: 'middle', color: 'var(--green)' }} />;
|
|
142
|
+
} else {
|
|
143
|
+
return <TrendingDownIcon sx={{ mx: 1, fontSize: '1em', verticalAlign: 'middle', color: 'var(--red)' }} />;
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
// Define metric cards
|
|
149
|
+
const metricCards = [
|
|
150
|
+
{
|
|
151
|
+
title: 'Gross Sales',
|
|
152
|
+
value: formatCurrency(grossRevenue),
|
|
153
|
+
change: revenueChange,
|
|
154
|
+
tooltip: hasPreviousData ? `Previous: ${formatCurrency(previousRevenue)}` : 'No prior data available',
|
|
155
|
+
bgcolor: 'var(--black, black)',
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
title: 'Orders',
|
|
159
|
+
value: orderCount,
|
|
160
|
+
change: orderCountChange,
|
|
161
|
+
tooltip: hasPreviousData ? `Previous: ${previousOrderCount}` : 'No prior data available',
|
|
162
|
+
bgcolor: 'var(--black, black)',
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
title: 'Avg. Order Value',
|
|
166
|
+
value: formatCurrency(averageOrderValue),
|
|
167
|
+
change: avgOrderValueChange,
|
|
168
|
+
tooltip: hasPreviousData ? `Previous: ${formatCurrency(previousAvgOrderValue)}` : 'No prior data available',
|
|
169
|
+
bgcolor: 'var(--black, black)',
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
title: 'Discount Rate',
|
|
173
|
+
value: `${discountRate.toFixed(1)}%`,
|
|
174
|
+
tooltip: `Total discounts: ${formatCurrency(discountTotal)}`,
|
|
175
|
+
bgcolor: 'var(--black, black)',
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
title: 'Refund Rate',
|
|
179
|
+
value: `${refundRate.toFixed(1)}%`,
|
|
180
|
+
change: refundChange,
|
|
181
|
+
tooltip: `Total refunds: ${formatCurrency(refundTotal)}`,
|
|
182
|
+
bgcolor: 'var(--black, black)',
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
title: 'Top Location',
|
|
186
|
+
value: topLocation !== 'Unknown' ? `${topLocationPercentage.toFixed(0)}% ${topLocation}` : 'Unknown',
|
|
187
|
+
change: locationChange,
|
|
188
|
+
tooltip: previousTopLocation ?
|
|
189
|
+
`Previous top location: ${previousTopLocation} (${previousTopLocationPercentage.toFixed(1)}%)` :
|
|
190
|
+
'No prior data available',
|
|
191
|
+
bgcolor: 'var(--black, black)',
|
|
192
|
+
isLocationCard: true,
|
|
193
|
+
locationCode: topLocation,
|
|
194
|
+
}
|
|
195
|
+
];
|
|
196
|
+
|
|
197
|
+
// Use MUI's useMediaQuery hook for responsive design
|
|
198
|
+
const theme = useTheme();
|
|
199
|
+
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
|
200
|
+
const isTablet = useMediaQuery(theme.breakpoints.down('md'));
|
|
201
|
+
|
|
202
|
+
// Function to render the location card value with tooltip
|
|
203
|
+
const renderLocationValue = (locationCode, value) => {
|
|
204
|
+
if (locationCode === 'Unknown' || !locationCode) {
|
|
205
|
+
return value;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// If the location code is 2 characters (standard ISO country code)
|
|
209
|
+
if (locationCode.length === 2) {
|
|
210
|
+
// Extract percentage and code parts
|
|
211
|
+
const parts = value.split(' ');
|
|
212
|
+
const percentage = parts[0];
|
|
213
|
+
const code = parts[1];
|
|
214
|
+
|
|
215
|
+
const countryName = getCountryName(code);
|
|
216
|
+
|
|
217
|
+
return (
|
|
218
|
+
<>
|
|
219
|
+
{percentage}{' '}
|
|
220
|
+
<Tooltip
|
|
221
|
+
title={`${code}: ${countryName}`}
|
|
222
|
+
placement="top"
|
|
223
|
+
arrow
|
|
224
|
+
>
|
|
225
|
+
<span style={{ color: "var(--white, white)", borderBottom: '2px solid rgba(255,255,255,.45)', cursor: 'help' }}>{code}</span>
|
|
226
|
+
</Tooltip>
|
|
227
|
+
</>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return value;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
return (
|
|
235
|
+
<Grid
|
|
236
|
+
container
|
|
237
|
+
spacing={isMobile ? 1 : 2}
|
|
238
|
+
sx={{
|
|
239
|
+
mt: 2,
|
|
240
|
+
position: 'relative',
|
|
241
|
+
'&[data-loading="true"]': {
|
|
242
|
+
opacity: 0.7,
|
|
243
|
+
pointerEvents: 'none'
|
|
244
|
+
}
|
|
245
|
+
}}
|
|
246
|
+
data-disabled={loading}
|
|
247
|
+
data-loading={loading}
|
|
248
|
+
>
|
|
249
|
+
{/* Debug component */}
|
|
250
|
+
<DebugValues
|
|
251
|
+
totalRevenue={netRevenue}
|
|
252
|
+
grossRevenue={grossRevenue}
|
|
253
|
+
previousRevenue={previousRevenue}
|
|
254
|
+
revenueChange={revenueChange}
|
|
255
|
+
chartState={chartState}
|
|
256
|
+
/>
|
|
257
|
+
|
|
258
|
+
{/* Period header - removing CircularProgress as we use loading attribute instead */}
|
|
259
|
+
|
|
260
|
+
{/* Metric cards */}
|
|
261
|
+
{metricCards.map((card, index) => (
|
|
262
|
+
<Grid key={`metric-${index}`} item xs={6} md={4}>
|
|
263
|
+
<Tooltip
|
|
264
|
+
title={card.tooltip}
|
|
265
|
+
placement="top"
|
|
266
|
+
arrow
|
|
267
|
+
>
|
|
268
|
+
<Paper
|
|
269
|
+
elevation={0}
|
|
270
|
+
sx={{
|
|
271
|
+
p: 4,
|
|
272
|
+
textAlign: 'center',
|
|
273
|
+
borderRadius: '4px',
|
|
274
|
+
border: '1px solid rgba(255, 255, 255, 0.12)',
|
|
275
|
+
backgroundColor: card.bgcolor,
|
|
276
|
+
'&:hover': {
|
|
277
|
+
boxShadow: '0 4px 8px rgba(0, 0, 0, 0.3)',
|
|
278
|
+
transform: 'translateY(-2px)',
|
|
279
|
+
transition: 'all 0.3s ease'
|
|
280
|
+
}
|
|
281
|
+
}}
|
|
282
|
+
>
|
|
283
|
+
<Box sx={{
|
|
284
|
+
display: 'flex',
|
|
285
|
+
alignItems: 'center',
|
|
286
|
+
justifyContent: 'center',
|
|
287
|
+
}}>
|
|
288
|
+
<Typography
|
|
289
|
+
variant="h6" color="rgba(255, 255, 255, 0.7)"
|
|
290
|
+
sx={{
|
|
291
|
+
whiteSpace: 'nowrap',
|
|
292
|
+
textOverflow: 'ellipsis',
|
|
293
|
+
overflow: 'hidden',
|
|
294
|
+
}}
|
|
295
|
+
>
|
|
296
|
+
{card.title}
|
|
297
|
+
</Typography>
|
|
298
|
+
</Box>
|
|
299
|
+
<Box sx={{
|
|
300
|
+
display: 'flex',
|
|
301
|
+
alignItems: 'center',
|
|
302
|
+
justifyContent: 'center',
|
|
303
|
+
}}>
|
|
304
|
+
<Typography
|
|
305
|
+
variant={isMobile ? "h6" : "h5"}
|
|
306
|
+
fontWeight="bold"
|
|
307
|
+
sx={{
|
|
308
|
+
fontSize: {
|
|
309
|
+
xs: '1.1rem',
|
|
310
|
+
sm: '1.3rem',
|
|
311
|
+
md: '1.5rem'
|
|
312
|
+
},
|
|
313
|
+
color: 'rgba(255, 255, 255, 0.95)'
|
|
314
|
+
}}
|
|
315
|
+
>
|
|
316
|
+
{card.isLocationCard ? renderLocationValue(card.locationCode, card.value) : card.value}
|
|
317
|
+
</Typography>
|
|
318
|
+
</Box>
|
|
319
|
+
|
|
320
|
+
<Box sx={{
|
|
321
|
+
display: 'flex',
|
|
322
|
+
alignItems: 'center',
|
|
323
|
+
justifyContent: 'center',
|
|
324
|
+
}}>
|
|
325
|
+
<Typography
|
|
326
|
+
variant="caption"
|
|
327
|
+
sx={{
|
|
328
|
+
mt: 0.5,
|
|
329
|
+
opacity: (card.change !== null && card.change !== undefined) ? 1 : 0.25,
|
|
330
|
+
color: card.change > 0 ? 'var(--green, #4caf50)' :
|
|
331
|
+
card.change < 0 ? 'var(--red, #f44336)' :
|
|
332
|
+
'rgba(255, 255, 255, 0.6)'
|
|
333
|
+
}}
|
|
334
|
+
>
|
|
335
|
+
{(card.change !== null && card.change !== undefined) ? `${formatPercentage(card.change)} vs prior` : <span> </span>}
|
|
336
|
+
{card.change !== undefined && getTrendIcon(card.change)}
|
|
337
|
+
</Typography>
|
|
338
|
+
</Box>
|
|
339
|
+
</Paper>
|
|
340
|
+
</Tooltip>
|
|
341
|
+
</Grid>
|
|
342
|
+
))}
|
|
343
|
+
</Grid>
|
|
344
|
+
);
|
|
345
|
+
}
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
// Top performers component displaying top performing typefaces and designers
|
|
2
|
+
import React, { useState } from 'react';
|
|
3
|
+
import { Grid, Typography, Box, Tooltip, Select, MenuItem, FormControl, InputLabel, Stack } from '@mui/material';
|
|
4
|
+
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
|
|
5
|
+
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
|
|
6
|
+
import SortIcon from '@mui/icons-material/Sort';
|
|
7
|
+
// Top performers component displaying top performing typefaces and designers
|
|
8
|
+
import styles from '../styles/sales-portal.module.scss';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Top performers section showing top typefaces and designers with sortable data
|
|
12
|
+
*/
|
|
13
|
+
export default function TopPerformers({
|
|
14
|
+
typefaceData,
|
|
15
|
+
designersData,
|
|
16
|
+
total,
|
|
17
|
+
chartState,
|
|
18
|
+
sales,
|
|
19
|
+
loading,
|
|
20
|
+
admin
|
|
21
|
+
}) {
|
|
22
|
+
// State for sorting typefaces
|
|
23
|
+
const [typefaceSortKey, setTypefaceSortKey] = useState('percentage'); // 'percentage', 'orders', 'title'
|
|
24
|
+
const [typefaceSortDirection, setTypefaceSortDirection] = useState('desc'); // 'asc', 'desc'
|
|
25
|
+
|
|
26
|
+
// State for sorting designers
|
|
27
|
+
const [designerSortKey, setDesignerSortKey] = useState('percentage'); // 'percentage', 'orders', 'name'
|
|
28
|
+
const [designerSortDirection, setDesignerSortDirection] = useState('desc'); // 'asc', 'desc'
|
|
29
|
+
|
|
30
|
+
// Sort typefaces based on the current sort config
|
|
31
|
+
const sortedTypefaces = React.useMemo(() => {
|
|
32
|
+
if (!typefaceData) return [];
|
|
33
|
+
|
|
34
|
+
const sortableData = [...typefaceData];
|
|
35
|
+
|
|
36
|
+
return sortableData.sort((a, b) => {
|
|
37
|
+
if (typefaceSortKey === 'percentage') {
|
|
38
|
+
// Sort by percentage of total sales
|
|
39
|
+
const valueA = a.total / total;
|
|
40
|
+
const valueB = b.total / total;
|
|
41
|
+
return typefaceSortDirection === 'asc' ? valueA - valueB : valueB - valueA;
|
|
42
|
+
}
|
|
43
|
+
else if (typefaceSortKey === 'orders') {
|
|
44
|
+
// Sort by order count
|
|
45
|
+
return typefaceSortDirection === 'asc' ? a.orders - b.orders : b.orders - a.orders;
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
// Sort by title alphabetically
|
|
49
|
+
return typefaceSortDirection === 'asc'
|
|
50
|
+
? a.title.localeCompare(b.title)
|
|
51
|
+
: b.title.localeCompare(a.title);
|
|
52
|
+
}
|
|
53
|
+
}).slice(0, 3); // Get top 3
|
|
54
|
+
}, [typefaceData, typefaceSortKey, typefaceSortDirection, total]);
|
|
55
|
+
|
|
56
|
+
// Sort designers based on the current sort config
|
|
57
|
+
const sortedDesigners = React.useMemo(() => {
|
|
58
|
+
if (!designersData) return [];
|
|
59
|
+
|
|
60
|
+
const sortableData = [...designersData];
|
|
61
|
+
|
|
62
|
+
return sortableData.sort((a, b) => {
|
|
63
|
+
if (designerSortKey === 'percentage') {
|
|
64
|
+
// Sort by percentage of total sales
|
|
65
|
+
const valueA = a.total / total;
|
|
66
|
+
const valueB = b.total / total;
|
|
67
|
+
return designerSortDirection === 'asc' ? valueA - valueB : valueB - valueA;
|
|
68
|
+
}
|
|
69
|
+
else if (designerSortKey === 'orders') {
|
|
70
|
+
// Sort by order count
|
|
71
|
+
return designerSortDirection === 'asc' ? a.orders - b.orders : b.orders - a.orders;
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
// Sort by name alphabetically
|
|
75
|
+
const nameA = a?.firstName && a?.lastName ? `${a.firstName} ${a.lastName}` : a?._id || '';
|
|
76
|
+
const nameB = b?.firstName && b?.lastName ? `${b.firstName} ${b.lastName}` : b?._id || '';
|
|
77
|
+
return designerSortDirection === 'asc'
|
|
78
|
+
? nameA.localeCompare(nameB)
|
|
79
|
+
: nameB.localeCompare(nameA);
|
|
80
|
+
}
|
|
81
|
+
}).slice(0, 3); // Get top 3
|
|
82
|
+
}, [designersData, designerSortKey, designerSortDirection, total]);
|
|
83
|
+
|
|
84
|
+
// Toggle sort direction
|
|
85
|
+
const toggleSortDirection = (currentType) => {
|
|
86
|
+
if (currentType === 'typeface') {
|
|
87
|
+
setTypefaceSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
|
|
88
|
+
} else {
|
|
89
|
+
setDesignerSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
return (
|
|
93
|
+
<>
|
|
94
|
+
|
|
95
|
+
{/* Top Performing Designers */}
|
|
96
|
+
<Grid container className='designers-section'>
|
|
97
|
+
<Grid
|
|
98
|
+
item
|
|
99
|
+
xs={12}
|
|
100
|
+
data-disabled={loading}
|
|
101
|
+
data-loading={loading}
|
|
102
|
+
sx={{
|
|
103
|
+
margin: "20px 0 10px",
|
|
104
|
+
borderRadius: "4px",
|
|
105
|
+
flex: "none",
|
|
106
|
+
opacity: !!sales.length ? 1 : 0.25,
|
|
107
|
+
pointerEvents: !!sales.length ? "" : "none",
|
|
108
|
+
border: "1px solid var(--black, black)",
|
|
109
|
+
'&:hover': { boxShadow: "0 0 8px rgba(0,0,0,0.25)" },
|
|
110
|
+
}}
|
|
111
|
+
>
|
|
112
|
+
<Box sx={{
|
|
113
|
+
background: "rgba(var(--blackRGB, 0,0,0), 1)",
|
|
114
|
+
color: "var(--white, white)",
|
|
115
|
+
padding: "5px 10px 10px",
|
|
116
|
+
display: "flex",
|
|
117
|
+
justifyContent: "space-between",
|
|
118
|
+
alignItems: "center"
|
|
119
|
+
}}>
|
|
120
|
+
<Typography variant='h5' sx={{color: "var(--white, white)"}}>
|
|
121
|
+
<strong>Designers</strong>
|
|
122
|
+
</Typography>
|
|
123
|
+
<Stack direction="row" spacing={2} alignItems="center">
|
|
124
|
+
|
|
125
|
+
<FormControl variant="filled" size="small" sx={{
|
|
126
|
+
minWidth: 120,
|
|
127
|
+
'& .MuiFilledInput-root': { color: "var(--white, white)" },
|
|
128
|
+
'& .MuiFormLabel-root': { color: 'rgba(255,255,255,0.7)' },
|
|
129
|
+
'& .MuiSelect-icon': { display: 'none' }
|
|
130
|
+
}}>
|
|
131
|
+
<InputLabel id="designer-sort-label">Sort by</InputLabel>
|
|
132
|
+
<Select
|
|
133
|
+
labelId="designer-sort-label"
|
|
134
|
+
value={designerSortKey}
|
|
135
|
+
onChange={(e) => setDesignerSortKey(e.target.value)}
|
|
136
|
+
label="Sort by"
|
|
137
|
+
>
|
|
138
|
+
<MenuItem value="percentage">Percentage</MenuItem>
|
|
139
|
+
<MenuItem value="orders">Orders</MenuItem>
|
|
140
|
+
<MenuItem value="name">Name</MenuItem>
|
|
141
|
+
</Select>
|
|
142
|
+
</FormControl>
|
|
143
|
+
<Tooltip
|
|
144
|
+
title={`Sort ${designerSortDirection === 'asc' ? 'Descending' : 'Ascending'}`}
|
|
145
|
+
>
|
|
146
|
+
<Box
|
|
147
|
+
onClick={() => toggleSortDirection('designer')}
|
|
148
|
+
sx={{
|
|
149
|
+
cursor: 'pointer',
|
|
150
|
+
display: { xs: 'none', sm: 'flex' },
|
|
151
|
+
alignItems: 'center',
|
|
152
|
+
justifyContent: 'center',
|
|
153
|
+
padding: '6px 10px',
|
|
154
|
+
color: "var(--white, white)",
|
|
155
|
+
}}
|
|
156
|
+
>
|
|
157
|
+
{designerSortDirection === 'asc' ? <ArrowUpwardIcon /> : <ArrowDownwardIcon />}
|
|
158
|
+
</Box>
|
|
159
|
+
</Tooltip>
|
|
160
|
+
</Stack>
|
|
161
|
+
</Box>
|
|
162
|
+
<Box sx={{
|
|
163
|
+
background: "rgba(var(--blackRGB, 0,0,0), 0.06)",
|
|
164
|
+
padding: "10px 10px 20px",
|
|
165
|
+
}}>
|
|
166
|
+
{
|
|
167
|
+
sortedDesigners.map((designer, i) => {
|
|
168
|
+
return (
|
|
169
|
+
<Box
|
|
170
|
+
key={`designer-total-${i}`}
|
|
171
|
+
sx={{
|
|
172
|
+
paddingBottom: "10px",
|
|
173
|
+
}}
|
|
174
|
+
>
|
|
175
|
+
<Typography
|
|
176
|
+
variant='h5'
|
|
177
|
+
sx={{
|
|
178
|
+
whiteSpace: "nowrap",
|
|
179
|
+
overflow: "hidden",
|
|
180
|
+
textOverflow: "ellipsis",
|
|
181
|
+
paddingBottom: "0.5em"
|
|
182
|
+
}}>
|
|
183
|
+
{designer?.firstName && designer?.lastName ?
|
|
184
|
+
`${designer.firstName} ${designer.lastName}`
|
|
185
|
+
:
|
|
186
|
+
designer?._id
|
|
187
|
+
}
|
|
188
|
+
<span className={styles.earningContainer}>
|
|
189
|
+
<strong> {(designer.total / (total)).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</strong>%
|
|
190
|
+
</span>
|
|
191
|
+
<span style={{ opacity: "0.5", textTransform: "none" }}> ({designer.orders} Orders for a total of </span>
|
|
192
|
+
<span style={{ opacity: "0.5" }}> USD</span>$<strong>{(designer.total / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}</strong>
|
|
193
|
+
<span style={{ opacity: "0.5", textTransform: "none" }}>)</span>
|
|
194
|
+
</Typography>
|
|
195
|
+
|
|
196
|
+
{/* Individual typefaces for this designer */}
|
|
197
|
+
{typefaceData
|
|
198
|
+
?.filter(typeface => typeface.author._id === designer._id)
|
|
199
|
+
.map((typeface, j) => (
|
|
200
|
+
<Typography
|
|
201
|
+
key={`designer-typeface-${i}-${j}`}
|
|
202
|
+
variant='h6'
|
|
203
|
+
sx={{
|
|
204
|
+
whiteSpace: "nowrap",
|
|
205
|
+
overflow: "hidden",
|
|
206
|
+
textOverflow: "ellipsis",
|
|
207
|
+
}}>
|
|
208
|
+
{typeface.title}
|
|
209
|
+
<span className={styles.earningContainer}>
|
|
210
|
+
<strong> {(typeface.total / (total + chartState.refundTotal)).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</strong>%
|
|
211
|
+
</span>
|
|
212
|
+
<span style={{ opacity: "0.5", textTransform: "none" }}> ({typeface.orders} Orders for a total of </span>
|
|
213
|
+
<span style={{ opacity: "0.5" }}> USD</span>$<strong>{(typeface.total / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}</strong>
|
|
214
|
+
<span style={{ opacity: "0.5", textTransform: "none" }}>)</span>
|
|
215
|
+
</Typography>
|
|
216
|
+
))}
|
|
217
|
+
</Box>
|
|
218
|
+
)
|
|
219
|
+
})
|
|
220
|
+
}
|
|
221
|
+
</Box>
|
|
222
|
+
</Grid>
|
|
223
|
+
</Grid>
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
{/* Top Performing products */}
|
|
227
|
+
<Grid container className='top-performers-section'>
|
|
228
|
+
<Grid
|
|
229
|
+
item
|
|
230
|
+
xs={12}
|
|
231
|
+
data-disabled={loading}
|
|
232
|
+
data-loading={loading}
|
|
233
|
+
sx={{
|
|
234
|
+
margin: "20px 0 10px",
|
|
235
|
+
borderRadius: "4px",
|
|
236
|
+
flex: "none",
|
|
237
|
+
opacity: !!sales.length ? 1 : 0.25,
|
|
238
|
+
pointerEvents: !!sales.length ? "" : "none",
|
|
239
|
+
border: "1px solid var(--black, black)",
|
|
240
|
+
|
|
241
|
+
'&:hover': { boxShadow: "0 0 8px rgba(0,0,0,0.25)" },
|
|
242
|
+
}}
|
|
243
|
+
>
|
|
244
|
+
<Box sx={{
|
|
245
|
+
background: "rgba(var(--blackRGB, 0,0,0), 1)",
|
|
246
|
+
color: "var(--white, white)",
|
|
247
|
+
padding: "5px 10px 10px",
|
|
248
|
+
display: "flex",
|
|
249
|
+
justifyContent: "space-between",
|
|
250
|
+
alignItems: "center"
|
|
251
|
+
}}>
|
|
252
|
+
<Typography variant='h5'>
|
|
253
|
+
<strong>Top Performers</strong>
|
|
254
|
+
</Typography>
|
|
255
|
+
<Stack direction="row" spacing={2} alignItems="center">
|
|
256
|
+
|
|
257
|
+
<FormControl
|
|
258
|
+
variant="filled"
|
|
259
|
+
size="small"
|
|
260
|
+
sx={{
|
|
261
|
+
minWidth: 120,
|
|
262
|
+
'& .MuiFilledInput-root': { color: "var(--white, white)" },
|
|
263
|
+
'& .MuiFormLabel-root': { color: 'rgba(255,255,255,0.7)' },
|
|
264
|
+
'& .MuiSelect-icon': { display: 'none' },
|
|
265
|
+
}}>
|
|
266
|
+
<InputLabel id="typeface-sort-label">Sort by</InputLabel>
|
|
267
|
+
<Select
|
|
268
|
+
labelId="typeface-sort-label"
|
|
269
|
+
value={typefaceSortKey}
|
|
270
|
+
onChange={(e) => setTypefaceSortKey(e.target.value)}
|
|
271
|
+
label="Sort by"
|
|
272
|
+
|
|
273
|
+
>
|
|
274
|
+
<MenuItem value="percentage">Percentage</MenuItem>
|
|
275
|
+
<MenuItem value="orders">Orders</MenuItem>
|
|
276
|
+
<MenuItem value="title">Name</MenuItem>
|
|
277
|
+
</Select>
|
|
278
|
+
</FormControl>
|
|
279
|
+
<Tooltip
|
|
280
|
+
title={`Sort ${typefaceSortDirection === 'asc' ? 'Descending' : 'Ascending'}`}
|
|
281
|
+
>
|
|
282
|
+
<Box
|
|
283
|
+
onClick={() => toggleSortDirection('typeface')}
|
|
284
|
+
sx={{
|
|
285
|
+
cursor: 'pointer',
|
|
286
|
+
display: { xs: 'none', sm: 'flex' },
|
|
287
|
+
alignItems: 'center',
|
|
288
|
+
justifyContent: 'center',
|
|
289
|
+
padding: '6px 10px',
|
|
290
|
+
color: "var(--white, white)",
|
|
291
|
+
}}
|
|
292
|
+
>
|
|
293
|
+
{typefaceSortDirection === 'asc' ? <ArrowUpwardIcon /> : <ArrowDownwardIcon />}
|
|
294
|
+
</Box>
|
|
295
|
+
</Tooltip>
|
|
296
|
+
</Stack>
|
|
297
|
+
</Box>
|
|
298
|
+
<Box sx={{
|
|
299
|
+
background: "rgba(var(--blackRGB, 0,0,0), 0.06)",
|
|
300
|
+
padding: "10px"
|
|
301
|
+
}}>
|
|
302
|
+
{
|
|
303
|
+
sortedTypefaces.map((typeface, i) => {
|
|
304
|
+
return (
|
|
305
|
+
<Typography
|
|
306
|
+
key={`typeface-total-${i}`}
|
|
307
|
+
variant='h5'
|
|
308
|
+
sx={{
|
|
309
|
+
whiteSpace: "nowrap",
|
|
310
|
+
overflow: "hidden",
|
|
311
|
+
textOverflow: "ellipsis",
|
|
312
|
+
paddingBottom: "0.5em"
|
|
313
|
+
}}>
|
|
314
|
+
{typeface.title}
|
|
315
|
+
<span className={styles.earningContainer}>
|
|
316
|
+
<strong> {(typeface.total / (total)).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</strong>%
|
|
317
|
+
</span>
|
|
318
|
+
<span style={{ opacity: "0.5", textTransform: "none" }}> ({typeface.orders} Orders for a total of </span>
|
|
319
|
+
<span style={{ opacity: "0.5" }}> USD</span>$<strong>{(typeface.total / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}</strong>
|
|
320
|
+
<span style={{ opacity: "0.5", textTransform: "none" }}>)</span>
|
|
321
|
+
</Typography>
|
|
322
|
+
)
|
|
323
|
+
})
|
|
324
|
+
}
|
|
325
|
+
</Box>
|
|
326
|
+
</Grid>
|
|
327
|
+
</Grid>
|
|
328
|
+
|
|
329
|
+
</>
|
|
330
|
+
);
|
|
331
|
+
}
|