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