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