EverydayTech Platform - Developer Reference
Complete Source Code Documentation - All Applications
Loading...
Searching...
No Matches
page.tsx
Go to the documentation of this file.
1"use client";
2
3import { useState, useEffect } from 'react';
4import { apiClient } from '@/lib/client/apiClient';
5import { fieldpineApi } from "@/lib/client/fieldpineApi";
6
7interface GroupData {
8 groupValue: string;
9 numsales: number;
10 sales: number;
11 lastcost: number;
12 waccost: number;
13 units: number;
14 avesale: number;
15 itemspersale: number;
16 gp: number;
17 percenttotal: number;
18 v11000: number;
19 v11001: number;
20 v11002: number;
21 v11003: number;
22 v11004: number;
23 v11005: number;
24 v11006: number;
25}
26
27interface Location {
28 f100: number;
29 f101: string;
30}
31
32const groupOptions = [
33 { value: '', label: '-- Select Group Method' },
34 { value: 'customers.city', label: 'Customers City' },
35 { value: 'customers.postcode', label: 'Customers Postcode' },
36 { value: 'customers.priceband', label: 'Customers Priceband' },
37 { value: 'customers.keywords', label: 'Customers Keywords' },
38 { value: 'customers.referredby', label: 'Customers Referred By' },
39 { value: 'accounts.city', label: 'Account Holders City' },
40 { value: 'accounts.postcode', label: 'Account Holders Postcode' },
41 { value: 'accounts.creditlimit', label: 'Account Holders Credit Limit' },
42 { value: 'products.cartid', label: 'Printer Cartridge' },
43 { value: 'salelines.cashitemname', label: 'Cash Item Name' }
44];
45
46export default function SalesByXPage() {
47 const [groupData, setGroupData] = useState<GroupData[]>([]);
48 const [locations, setLocations] = useState<Location[]>([]);
49 const [isLoading, setIsLoading] = useState(false);
50 const [sortColumn, setSortColumn] = useState<keyof GroupData>('sales');
51 const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
52 const [totalPeriodRev, setTotalPeriodRev] = useState(0);
53
54 // Filter states
55 const [groupBy, setGroupBy] = useState('');
56 const [fromDate, setFromDate] = useState(() => {
57 const d = new Date();
58 d.setMonth(0, 1); // January 1st
59 return d.toISOString().split('T')[0];
60 });
61 const [toDate, setToDate] = useState(() => {
62 const today = new Date();
63 return today.toISOString().split('T')[0];
64 });
65 const [selectedLocation, setSelectedLocation] = useState<number>(0);
66 const [maxRows, setMaxRows] = useState(200);
67
68 // Load locations on mount
69 useEffect(() => {
70 loadLocations();
71 }, []);
72
73 // Load report when filters change
74 useEffect(() => {
75 if (locations.length > 0 && groupBy) {
76 loadReport();
77 }
78 }, [fromDate, toDate, selectedLocation, groupBy, locations]);
79
80 const loadLocations = async () => {
81 try {
82 const apiKey = sessionStorage.getItem("fieldpine_apikey");
83 if (!apiKey) return;
84
85 const response = await fieldpineApi({
86 endpoint: '/buck?3=retailmax.elink.locations',
87 apiKey: apiKey
88 });
89
90 if (response?.DATS) {
91 setLocations(response.DATS);
92 }
93 } catch (error) {
94 console.error('Error loading locations:', error);
95 }
96 };
97
98 const formatDateForAPI = (dateStr: string): string => {
99 const date = new Date(dateStr);
100 const day = date.getDate();
101 const month = date.getMonth() + 1;
102 const year = date.getFullYear();
103 return `${day}-${month}-${year}`;
104 };
105
106 const loadReport = async () => {
107 if (!fromDate || !toDate || !groupBy) return;
108
109 setIsLoading(true);
110 try {
111 const apiKey = sessionStorage.getItem("fieldpine_apikey");
112 if (!apiKey) {
113 setIsLoading(false);
114 return;
115 }
116
117 const fromFormatted = formatDateForAPI(fromDate);
118 const toFormatted = formatDateForAPI(toDate);
119
120 let locationPred = '';
121 if (selectedLocation > 0) {
122 locationPred = `&9=f100,0,${selectedLocation}`;
123 }
124
125 const maxRowPred = maxRows > 0 ? `&8=${maxRows}` : '';
126
127 const endpoint = `/buck?3=retailmax.elink.sale.totals.group&10=11000&15=2,1,${groupBy}&13=120&9=f110,4,${fromFormatted}&9=f110,1,${toFormatted}${maxRowPred}${locationPred}`;
128
129 const response = await fieldpineApi({
130 endpoint: endpoint,
131 apiKey: apiKey
132 });
133
134 if (response?.APPD) {
135 let totRev = 0;
136 const groups: GroupData[] = response.APPD.map((rec: any) => {
137 const sales = Number(rec.f120 || 0);
138 const lastcost = Number(rec.f102 || 0);
139 const units = Number(rec.f107 || 0);
140 const numsales = Number(rec.f109 || 0);
141
142 totRev += sales;
143
144 let gp = 0;
145 if (sales !== 0) {
146 gp = ((sales - lastcost) * 100) / sales;
147 }
148
149 let avesale = 0;
150 if (numsales > 0) {
151 avesale = sales / numsales;
152 }
153
154 let itemspersale = 0;
155 if (numsales > 0) {
156 itemspersale = units / numsales;
157 }
158
159 return {
160 groupValue: rec.f149 || '',
161 numsales: numsales,
162 sales: sales,
163 lastcost: lastcost,
164 waccost: Number(rec.f103 || 0),
165 units: units,
166 avesale: avesale,
167 itemspersale: itemspersale,
168 gp: gp,
169 percenttotal: 0,
170 v11000: Number(rec.f11000 || 0),
171 v11001: Number(rec.f11001 || 0),
172 v11002: Number(rec.f11002 || 0),
173 v11003: Number(rec.f11003 || 0),
174 v11004: Number(rec.f11004 || 0),
175 v11005: Number(rec.f11005 || 0),
176 v11006: Number(rec.f11006 || 0)
177 };
178 });
179
180 setTotalPeriodRev(totRev);
181
182 // Calculate percent of total for each group
183 groups.forEach(g => {
184 if (totRev > 0) {
185 g.percenttotal = (g.sales / totRev) * 100;
186 }
187 });
188
189 setGroupData(groups);
190 } else {
191 setGroupData([]);
192 }
193 } catch (error) {
194 console.error('Error loading report:', error);
195 setGroupData([]);
196 }
197 setIsLoading(false);
198 };
199
200 const handleSort = (column: keyof GroupData) => {
201 if (sortColumn === column) {
202 setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
203 } else {
204 setSortColumn(column);
205 setSortDirection('desc');
206 }
207 };
208
209 const sortedData = [...groupData].sort((a, b) => {
210 let aVal = a[sortColumn];
211 let bVal = b[sortColumn];
212
213 if (typeof aVal === 'string') aVal = aVal.toLowerCase();
214 if (typeof bVal === 'string') bVal = bVal.toLowerCase();
215
216 if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1;
217 if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1;
218 return 0;
219 });
220
221 const SortIndicator = ({ column }: { column: keyof GroupData }) => {
222 if (sortColumn !== column) return null;
223 return <span className="ml-1">{sortDirection === 'asc' ? '▲' : '▼'}</span>;
224 };
225
226 const GPBar = ({ value }: { value: number }) => {
227 const percentage = Math.max(0, Math.min(100, Math.round(value)));
228 let colorClass = 'bg-brand';
229 if (percentage < 40) colorClass = 'bg-danger';
230 if (percentage > 60) colorClass = 'bg-success';
231
232 return (
233 <div className="flex items-center gap-2">
234 <div className="w-16 bg-surface-2 rounded-full h-4">
235 <div
236 className={`${colorClass} h-full rounded-full transition-all`}
237 style={{ width: `${percentage}%` }}
238 ></div>
239 </div>
240 <span className="text-sm">{value.toFixed(2)}%</span>
241 </div>
242 );
243 };
244
245 const SalesPctBar = ({ value }: { value: number }) => {
246 const percentage = Math.max(0, Math.min(100, Math.round(value)));
247 return (
248 <div className="flex items-center gap-2">
249 <div className="w-16 bg-surface-2 rounded-full h-4">
250 <div
251 className="bg-success h-full rounded-full transition-all"
252 style={{ width: `${percentage}%` }}
253 ></div>
254 </div>
255 <span className="text-sm">{value.toFixed(2)}%</span>
256 </div>
257 );
258 };
259
260 const QuartileBox = ({ group }: { group: GroupData }) => {
261 if (group.numsales < 3) return null;
262
263 const { v11000, v11001, v11002, v11003, v11004, v11005, v11006 } = group;
264
265 const tooltip = `Sales Values: Smallest ${v11000.toFixed(1)} / Lower Qtr ${v11001.toFixed(1)} / Average ${v11002.toFixed(1)} / Upper Qtr ${v11003.toFixed(1)} / Maximum ${v11004.toFixed(1)} / Plotting Min ${v11005.toFixed(1)} / Plotting Max ${v11006.toFixed(1)}`;
266
267 const range = v11006 - v11005;
268 const getPosition = (val: number) => {
269 if (range === 0) return 50;
270 return ((val - v11005) / range) * 100;
271 };
272
273 const minPos = getPosition(v11000);
274 const q1Pos = getPosition(v11001);
275 const medPos = getPosition(v11002);
276 const q3Pos = getPosition(v11003);
277 const maxPos = getPosition(v11004);
278
279 return (
280 <div className="relative w-48 h-4" title={tooltip}>
281 {/* Whisker line */}
282 <div
283 className="absolute top-1/2 h-0.5 bg-muted/50"
284 style={{
285 left: `${minPos}%`,
286 width: `${maxPos - minPos}%`,
287 transform: 'translateY(-50%)'
288 }}
289 ></div>
290
291 {/* Box (Q1 to Q3) */}
292 <div
293 className="absolute top-0 h-full bg-brand/30 border border-brand"
294 style={{
295 left: `${q1Pos}%`,
296 width: `${q3Pos - q1Pos}%`
297 }}
298 ></div>
299
300 {/* Median line */}
301 <div
302 className="absolute top-0 h-full w-0.5 bg-red-600"
303 style={{ left: `${medPos}%` }}
304 ></div>
305
306 {/* Min whisker cap */}
307 <div
308 className="absolute top-0 h-full w-0.5 bg-muted"
309 style={{ left: `${minPos}%` }}
310 ></div>
311
312 {/* Max whisker cap */}
313 <div
314 className="absolute top-0 h-full w-0.5 bg-muted"
315 style={{ left: `${maxPos}%` }}
316 ></div>
317 </div>
318 );
319 };
320
321 return (
322 <div className="p-6">
323 <div className="mb-6">
324 <h1 className="text-3xl font-bold mb-2">Sales by X</h1>
325 <p className="text-muted text-sm">
326 This report allows you to report sales by a range of obscure methods.
327 Start by selecting a "group by" method to lump all sales with that value in common together.
328 </p>
329 </div>
330
331 {/* Filter Form */}
332 <div className="bg-surface p-4 rounded-lg shadow mb-6">
333 <div className="flex flex-wrap gap-4 items-end">
334 <div>
335 <label className="block text-sm font-medium mb-1">Group By</label>
336 <select
337 value={groupBy}
338 onChange={(e) => setGroupBy(e.target.value)}
339 className="border rounded px-3 py-2 min-w-[200px]"
340 >
341 {groupOptions.map(opt => (
342 <option key={opt.value} value={opt.value}>{opt.label}</option>
343 ))}
344 </select>
345 </div>
346
347 <div>
348 <label className="block text-sm font-medium mb-1">From (including)</label>
349 <input
350 type="date"
351 value={fromDate}
352 onChange={(e) => setFromDate(e.target.value)}
353 className="border rounded px-3 py-2"
354 />
355 </div>
356
357 <div>
358 <label className="block text-sm font-medium mb-1">To (excluding)</label>
359 <input
360 type="date"
361 value={toDate}
362 onChange={(e) => setToDate(e.target.value)}
363 className="border rounded px-3 py-2"
364 />
365 </div>
366
367 <div>
368 <label className="block text-sm font-medium mb-1">Max Rows</label>
369 <input
370 type="number"
371 value={maxRows}
372 onChange={(e) => setMaxRows(Number(e.target.value))}
373 className="border rounded px-3 py-2 w-24"
374 />
375 </div>
376
377 <div>
378 <label className="block text-sm font-medium mb-1">Filter</label>
379 <select
380 value={selectedLocation}
381 onChange={(e) => setSelectedLocation(Number(e.target.value))}
382 className="border rounded px-3 py-2"
383 >
384 <option value="0">All Stores</option>
385 {locations.map(loc => (
386 <option key={loc.f100} value={loc.f100}>{loc.f101}</option>
387 ))}
388 </select>
389 </div>
390
391 <button
392 onClick={loadReport}
393 disabled={isLoading || !groupBy}
394 className="bg-brand text-white px-6 py-2 rounded hover:bg-brand2 disabled:bg-muted/50"
395 >
396 {isLoading ? 'Loading...' : 'Run Report'}
397 </button>
398 </div>
399 </div>
400
401 {/* Loading Indicator */}
402 {isLoading && (
403 <div className="flex items-center gap-2 mb-4">
404 <div className="animate-spin rounded-full h-5 w-5 border-b-2 border-info"></div>
405 <span>Working...</span>
406 </div>
407 )}
408
409 {/* Results Table */}
410 <div className="bg-surface rounded-lg shadow overflow-hidden">
411 <div className="overflow-x-auto">
412 <table className="w-full">
413 <thead className="bg-surface-2 border-b">
414 <tr>
415 <th
416 className="px-4 py-3 text-left text-sm font-medium cursor-pointer hover:bg-surface-2"
417 onClick={() => handleSort('groupValue')}
418 >
419 Group Value <SortIndicator column="groupValue" />
420 </th>
421 <th
422 className="px-4 py-3 text-right text-sm font-medium cursor-pointer hover:bg-surface-2"
423 onClick={() => handleSort('numsales')}
424 >
425 #Sales <SortIndicator column="numsales" />
426 </th>
427 <th
428 className="px-4 py-3 text-left text-sm font-medium cursor-pointer hover:bg-surface-2"
429 onClick={() => handleSort('percenttotal')}
430 >
431 % of Sales Rev <SortIndicator column="percenttotal" />
432 </th>
433 <th
434 className="px-4 py-3 text-right text-sm font-medium cursor-pointer hover:bg-surface-2"
435 onClick={() => handleSort('units')}
436 >
437 Items Sold <SortIndicator column="units" />
438 </th>
439 <th
440 className="px-4 py-3 text-right text-sm font-medium cursor-pointer hover:bg-surface-2"
441 onClick={() => handleSort('sales')}
442 >
443 Sales <SortIndicator column="sales" />
444 </th>
445 <th
446 className="px-4 py-3 text-right text-sm font-medium cursor-pointer hover:bg-surface-2"
447 onClick={() => handleSort('avesale')}
448 >
449 Avg. Sale$ <SortIndicator column="avesale" />
450 </th>
451 <th
452 className="px-4 py-3 text-right text-sm font-medium cursor-pointer hover:bg-surface-2"
453 onClick={() => handleSort('itemspersale')}
454 >
455 Avg #Items/Sale <SortIndicator column="itemspersale" />
456 </th>
457 <th
458 className="px-4 py-3 text-right text-sm font-medium cursor-pointer hover:bg-surface-2"
459 onClick={() => handleSort('lastcost')}
460 >
461 Last Cost <SortIndicator column="lastcost" />
462 </th>
463 <th
464 className="px-4 py-3 text-left text-sm font-medium cursor-pointer hover:bg-surface-2"
465 onClick={() => handleSort('gp')}
466 >
467 GP% <SortIndicator column="gp" />
468 </th>
469 <th
470 className="px-4 py-3 text-left text-sm font-medium"
471 title="Spread of total value of sales"
472 >
473 Quartiles
474 </th>
475 </tr>
476 </thead>
477 <tbody className="divide-y">
478 {!groupBy ? (
479 <tr>
480 <td colSpan={10} className="px-4 py-8 text-center text-muted">
481 Please select a "Group By" method to view results
482 </td>
483 </tr>
484 ) : sortedData.length === 0 && !isLoading ? (
485 <tr>
486 <td colSpan={10} className="px-4 py-8 text-center text-muted">
487 No data found for the selected criteria
488 </td>
489 </tr>
490 ) : (
491 sortedData.map((group, idx) => (
492 <tr key={idx} className="hover:bg-surface-2">
493 <td className="px-4 py-3 text-sm">{group.groupValue || '(empty)'}</td>
494 <td className="px-4 py-3 text-sm text-right">{group.numsales.toLocaleString()}</td>
495 <td className="px-4 py-3 text-sm">
496 <SalesPctBar value={group.percenttotal} />
497 </td>
498 <td className="px-4 py-3 text-sm text-right">{group.units.toLocaleString()}</td>
499 <td className="px-4 py-3 text-sm text-right">
500 {new Intl.NumberFormat('en-AU', {
501 style: 'currency',
502 currency: 'AUD'
503 }).format(group.sales)}
504 </td>
505 <td className="px-4 py-3 text-sm text-right">
506 {new Intl.NumberFormat('en-AU', {
507 style: 'currency',
508 currency: 'AUD'
509 }).format(group.avesale)}
510 </td>
511 <td className="px-4 py-3 text-sm text-right">
512 {group.itemspersale.toFixed(1)}
513 </td>
514 <td className="px-4 py-3 text-sm text-right">
515 {new Intl.NumberFormat('en-AU', {
516 style: 'currency',
517 currency: 'AUD'
518 }).format(group.lastcost)}
519 </td>
520 <td className="px-4 py-3 text-sm">
521 <GPBar value={group.gp} />
522 </td>
523 <td className="px-4 py-3 text-sm">
524 <QuartileBox group={group} />
525 </td>
526 </tr>
527 ))
528 )}
529 </tbody>
530 </table>
531 </div>
532 </div>
533
534 {/* Footer Summary */}
535 {totalPeriodRev > 0 && (
536 <div className="mt-4 text-right text-sm text-muted">
537 Total Period Revenue: {new Intl.NumberFormat('en-AU', {
538 style: 'currency',
539 currency: 'AUD'
540 }).format(totalPeriodRev)}
541 </div>
542 )}
543 </div>
544 );
545}