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 Location {
8 f100: number;
9 f101: string;
10}
11
12interface Department {
13 f100: number;
14 f101: string;
15}
16
17interface Supplier {
18 f100: number;
19 f101: string;
20}
21
22interface MonthData {
23 locid: string;
24 storename: string;
25 months: { [key: number]: number };
26 total: number;
27 average: number;
28 smallest: number;
29 largest: number;
30 monthCount: number;
31}
32
33export default function SalesByMonthPage() {
34 const [locations, setLocations] = useState<Location[]>([]);
35 const [departments, setDepartments] = useState<Department[]>([]);
36 const [suppliers, setSuppliers] = useState<Supplier[]>([]);
37
38 const [periodEnd, setPeriodEnd] = useState(() => {
39 const d = new Date();
40 return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
41 });
42
43 const [selectedLocation, setSelectedLocation] = useState('0');
44 const [selectedDepartment, setSelectedDepartment] = useState('0');
45 const [selectedSupplier, setSelectedSupplier] = useState('0');
46 const [statistic, setStatistic] = useState('tax_totex');
47 const [includeAccountPayments, setIncludeAccountPayments] = useState(false);
48
49 const [storeData, setStoreData] = useState<MonthData[]>([]);
50 const [loading, setLoading] = useState(false);
51 const [sortColumn, setSortColumn] = useState<string>('storename');
52 const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
53
54 const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
55
56 useEffect(() => {
57 loadLocations();
58 loadDepartments();
59 loadSuppliers();
60 }, []);
61
62 const loadLocations = async () => {
63 try {
64 const apiKey = sessionStorage.getItem("fieldpine_apikey");
65 if (!apiKey) return;
66
67 const response = await fieldpineApi({
68 endpoint: '/buck?3=retailmax.elink.locations',
69 apiKey
70 });
71
72 if (response.DATS) {
73 setLocations(response.DATS);
74 }
75 } catch (error) {
76 console.error('Error loading locations:', error);
77 }
78 };
79
80 const loadDepartments = 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.departments',
87 apiKey
88 });
89
90 if (response.DATS) {
91 setDepartments(response.DATS);
92 }
93 } catch (error) {
94 console.error('Error loading departments:', error);
95 }
96 };
97
98 const loadSuppliers = async () => {
99 try {
100 const apiKey = sessionStorage.getItem("fieldpine_apikey");
101 if (!apiKey) return;
102
103 const response = await fieldpineApi({
104 endpoint: '/buck?3=retailmax.elink.suppliers',
105 apiKey
106 });
107
108 if (response.DATS) {
109 setSuppliers(response.DATS);
110 }
111 } catch (error) {
112 console.error('Error loading suppliers:', error);
113 }
114 };
115
116 const loadReport = async () => {
117 setLoading(true);
118 try {
119 const apiKey = sessionStorage.getItem("fieldpine_apikey");
120 if (!apiKey) {
121 console.error("No API key found");
122 setLoading(false);
123 return;
124 }
125
126 // Parse period end (format: YYYY-MM)
127 const [year, month] = periodEnd.split('-').map(Number);
128
129 // Calculate period start (12 months back)
130 const fromDate = `01-${String(month).padStart(2, '0')}-${year - 1}`;
131 const toDate = `01-${String(month).padStart(2, '0')}-${year}`;
132
133 let exPreds = '';
134
135 // Add location filter
136 if (selectedLocation !== '0' && selectedLocation !== '') {
137 exPreds += `&9=f100,0,${selectedLocation}`;
138 }
139
140 // Add supplier filter
141 if (selectedSupplier !== '0' && selectedSupplier !== '') {
142 exPreds += `&9=f1003,0,${selectedSupplier}`;
143 }
144
145 // Add department filter
146 if (selectedDepartment !== '0' && selectedDepartment !== '') {
147 exPreds += `&9=f1002,0,${selectedDepartment}`;
148 }
149
150 // Account payments filter
151 if (!includeAccountPayments) {
152 exPreds += '&9=f1008,5,0';
153 }
154
155 const endpoint = `/buck?3=retailmax.elink.sale.cube&100=6&101=${statistic}&9=f110,4,${fromDate}&9=f110,1,${toDate}${exPreds}`;
156
157 const response = await fieldpineApi({
158 endpoint,
159 apiKey
160 });
161
162 if (response.APPD && response.APPD.length > 0) {
163 // Process the data
164 const dataMap: { [key: string]: MonthData } = {};
165
166 response.APPD.forEach((item: any) => {
167 const locid = String(item.f100 || '0');
168 const monthStr = item.f101 || '';
169 const value = Number(item.f201 || 0);
170 const storeName = item.f1003 || 'Unknown Store';
171
172 if (!dataMap[locid]) {
173 dataMap[locid] = {
174 locid,
175 storename: storeName,
176 months: {},
177 total: 0,
178 average: 0,
179 smallest: Infinity,
180 largest: 0,
181 monthCount: 0
182 };
183 }
184
185 const data = dataMap[locid];
186
187 // Parse month from format "YYYY|M|D"
188 const monthParts = monthStr.split('|');
189 if (monthParts.length >= 2) {
190 const monthNum = Number(monthParts[1]);
191 data.months[monthNum] = value;
192
193 if (value !== 0) {
194 data.monthCount++;
195 data.smallest = Math.min(data.smallest, value);
196 data.largest = Math.max(data.largest, value);
197 }
198 data.total += value;
199 }
200 });
201
202 // Calculate averages
203 Object.values(dataMap).forEach(data => {
204 if (data.monthCount > 0) {
205 data.average = data.total / data.monthCount;
206 }
207 if (data.smallest === Infinity) data.smallest = 0;
208 });
209
210 setStoreData(Object.values(dataMap));
211 } else {
212 setStoreData([]);
213 }
214 } catch (error) {
215 console.error('Error loading report:', error);
216 setStoreData([]);
217 } finally {
218 setLoading(false);
219 }
220 };
221
222 const formatValue = (value: number | undefined): string => {
223 if (!value || value === 0) return '';
224
225 if (statistic === 'qty') {
226 return value.toFixed(0);
227 } else {
228 return new Intl.NumberFormat('en-NZ', {
229 style: 'currency',
230 currency: 'NZD',
231 minimumFractionDigits: 2
232 }).format(value);
233 }
234 };
235
236 const handleSort = (column: string) => {
237 if (sortColumn === column) {
238 setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
239 } else {
240 setSortColumn(column);
241 setSortDirection('asc');
242 }
243 };
244
245 const sortedData = [...storeData].sort((a, b) => {
246 let aVal: any;
247 let bVal: any;
248
249 if (sortColumn === 'storename') {
250 aVal = a.storename;
251 bVal = b.storename;
252 } else if (sortColumn.startsWith('month_')) {
253 const monthNum = Number(sortColumn.split('_')[1]);
254 aVal = a.months[monthNum] || 0;
255 bVal = b.months[monthNum] || 0;
256 } else {
257 aVal = (a as any)[sortColumn] || 0;
258 bVal = (b as any)[sortColumn] || 0;
259 }
260
261 if (typeof aVal === 'number' && typeof bVal === 'number') {
262 return sortDirection === 'asc' ? aVal - bVal : bVal - aVal;
263 }
264
265 return sortDirection === 'asc'
266 ? String(aVal).localeCompare(String(bVal))
267 : String(bVal).localeCompare(String(aVal));
268 });
269
270 const SortIcon = ({ column }: { column: string }) => {
271 if (sortColumn !== column) return <span className="text-muted/50">⇅</span>;
272 return <span>{sortDirection === 'asc' ? '↑' : '↓'}</span>;
273 };
274
275 // Get the 12 months to display based on period end
276 const getMonthHeaders = () => {
277 const [year, month] = periodEnd.split('-').map(Number);
278 const headers = [];
279
280 for (let i = 0; i < 12; i++) {
281 let m = month + i;
282 let y = year - 1;
283 if (m > 12) {
284 m -= 12;
285 y++;
286 }
287 headers.push({ month: m, year: y, label: `${monthNames[m - 1]}-${y}` });
288 }
289
290 return headers;
291 };
292
293 const monthHeaders = getMonthHeaders();
294
295 // Find the highest value for each store
296 const getHighlightedMonth = (store: MonthData): number => {
297 let max = 0;
298 let maxMonth = 0;
299
300 Object.entries(store.months).forEach(([monthNum, value]) => {
301 if (value > max) {
302 max = value;
303 maxMonth = Number(monthNum);
304 }
305 });
306
307 return maxMonth;
308 };
309
310 return (
311 <div className="p-6">
312 {/* Header */}
313 <div className="mb-6">
314 <h1 className="text-3xl font-bold mb-2">Sales by Month 📅</h1>
315 <p className="text-sm text-muted mb-2">
316 Monthly sales summary across the year
317 </p>
318 <div className="text-sm text-muted">
319 <a href="/pages/reports/sales-reports" className="text-brand hover:underline">Sales</a>
320 {' > '}
321 <span>Sales by Month</span>
322 </div>
323 </div>
324
325 {/* Filters */}
326 <div className="bg-surface rounded-lg shadow p-6 mb-6">
327 <div className="flex flex-wrap items-end gap-4 mb-4">
328 <div>
329 <label className="block text-sm font-medium text-text mb-2">
330 Period End:
331 </label>
332 <input
333 type="month"
334 value={periodEnd}
335 onChange={(e) => setPeriodEnd(e.target.value)}
336 className="border rounded px-3 py-2"
337 />
338 </div>
339
340 <div>
341 <label className="block text-sm font-medium text-text mb-2">
342 Statistic:
343 </label>
344 <select
345 value={statistic}
346 onChange={(e) => setStatistic(e.target.value)}
347 className="border rounded px-3 py-2"
348 >
349 <option value="qty">Quantity</option>
350 <option value="tax_totex">Revenue ex Tax</option>
351 <option value="tax_totinc">Revenue incl Tax</option>
352 </select>
353 </div>
354
355 <div>
356 <label className="block text-sm font-medium text-text mb-2">
357 Store:
358 </label>
359 <select
360 value={selectedLocation}
361 onChange={(e) => setSelectedLocation(e.target.value)}
362 className="border rounded px-3 py-2"
363 >
364 <option value="0">All Stores</option>
365 {locations.map(loc => (
366 <option key={loc.f100} value={loc.f100}>
367 {loc.f101}
368 </option>
369 ))}
370 </select>
371 </div>
372
373 <div>
374 <label className="block text-sm font-medium text-text mb-2">
375 Department:
376 </label>
377 <select
378 value={selectedDepartment}
379 onChange={(e) => setSelectedDepartment(e.target.value)}
380 className="border rounded px-3 py-2"
381 >
382 <option value="0">All Departments</option>
383 {departments.map(dept => (
384 <option key={dept.f100} value={dept.f100}>
385 {dept.f101}
386 </option>
387 ))}
388 <option value="without">Without Department</option>
389 </select>
390 </div>
391
392 <div>
393 <label className="block text-sm font-medium text-text mb-2">
394 Supplier:
395 </label>
396 <select
397 value={selectedSupplier}
398 onChange={(e) => setSelectedSupplier(e.target.value)}
399 className="border rounded px-3 py-2"
400 >
401 <option value="0">All Suppliers</option>
402 {suppliers.map(sup => (
403 <option key={sup.f100} value={sup.f100}>
404 {sup.f101}
405 </option>
406 ))}
407 </select>
408 </div>
409
410 <div className="flex items-center">
411 <label className="flex items-center gap-2">
412 <input
413 type="checkbox"
414 checked={includeAccountPayments}
415 onChange={(e) => setIncludeAccountPayments(e.target.checked)}
416 className="rounded"
417 />
418 <span className="text-sm">Include Account Payments</span>
419 </label>
420 </div>
421
422 <div>
423 <button
424 onClick={loadReport}
425 disabled={loading}
426 className="bg-brand text-white px-6 py-2 rounded hover:bg-brand2 disabled:bg-muted/50"
427 >
428 {loading ? 'Loading...' : 'Load Period'}
429 </button>
430 </div>
431 </div>
432 </div>
433
434 {/* Results Table */}
435 <div className="bg-surface rounded-lg shadow overflow-hidden">
436 {loading ? (
437 <div className="p-8 text-center">
438 <div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-info"></div>
439 <p className="mt-2 text-muted">Loading sales data...</p>
440 </div>
441 ) : (
442 <>
443 <div className="overflow-x-auto">
444 <table className="w-full text-sm">
445 <thead className="bg-[var(--brand)] text-surface sticky top-0">
446 <tr>
447 <th
448 className="px-3 py-2 text-left font-semibold cursor-pointer hover:bg-surface-2/80 whitespace-nowrap"
449 onClick={() => handleSort('locid')}
450 >
451 Id# <SortIcon column="locid" />
452 </th>
453 <th
454 className="px-3 py-2 text-left font-semibold cursor-pointer hover:bg-surface-2/80 whitespace-nowrap"
455 onClick={() => handleSort('storename')}
456 >
457 Store Name <SortIcon column="storename" />
458 </th>
459 {monthHeaders.map((header, idx) => (
460 <th
461 key={idx}
462 className="px-3 py-2 text-right font-semibold cursor-pointer hover:bg-surface-2/80 whitespace-nowrap"
463 onClick={() => handleSort(`month_${header.month}`)}
464 >
465 {header.label} <SortIcon column={`month_${header.month}`} />
466 </th>
467 ))}
468 <th
469 className="px-3 py-2 text-right font-semibold cursor-pointer hover:bg-surface-2/80 whitespace-nowrap"
470 onClick={() => handleSort('total')}
471 >
472 Total <SortIcon column="total" />
473 </th>
474 <th
475 className="px-3 py-2 text-right font-semibold cursor-pointer hover:bg-surface-2/80 whitespace-nowrap"
476 onClick={() => handleSort('average')}
477 title="Average of non zero months"
478 >
479 Average <SortIcon column="average" />
480 </th>
481 <th
482 className="px-3 py-2 text-right font-semibold cursor-pointer hover:bg-surface-2/80 whitespace-nowrap"
483 onClick={() => handleSort('smallest')}
484 title="Smallest value seen over year"
485 >
486 Smallest <SortIcon column="smallest" />
487 </th>
488 <th
489 className="px-3 py-2 text-right font-semibold cursor-pointer hover:bg-surface-2/80 whitespace-nowrap"
490 onClick={() => handleSort('largest')}
491 title="Largest value seen over year"
492 >
493 Largest <SortIcon column="largest" />
494 </th>
495 <th
496 className="px-3 py-2 text-right font-semibold cursor-pointer hover:bg-surface-2/80 whitespace-nowrap"
497 onClick={() => handleSort('monthCount')}
498 title="How many months have values other than zero"
499 >
500 Month Count <SortIcon column="monthCount" />
501 </th>
502 </tr>
503 </thead>
504 <tbody className="divide-y divide-border">
505 {sortedData.length === 0 ? (
506 <tr>
507 <td colSpan={17} className="px-4 py-8 text-center text-muted">
508 No sales data found for the selected period
509 </td>
510 </tr>
511 ) : (
512 sortedData.map((store, idx) => {
513 const highlightedMonth = getHighlightedMonth(store);
514 return (
515 <tr key={idx} className={idx % 2 === 0 ? 'bg-surface' : 'bg-surface-2'}>
516 <td className="px-3 py-2">
517 {store.locid}
518 </td>
519 <td className="px-3 py-2 font-medium whitespace-nowrap">
520 {store.storename}
521 </td>
522 {monthHeaders.map((header, mIdx) => {
523 const value = store.months[header.month];
524 const isHighlighted = header.month === highlightedMonth && value > 0;
525 return (
526 <td
527 key={mIdx}
528 className={`px-3 py-2 text-right whitespace-nowrap ${isHighlighted ? 'bg-green-100' : ''}`}
529 >
530 {formatValue(value)}
531 </td>
532 );
533 })}
534 <td className="px-3 py-2 text-right font-semibold whitespace-nowrap">
535 {formatValue(store.total)}
536 </td>
537 <td className="px-3 py-2 text-right whitespace-nowrap">
538 {formatValue(store.average)}
539 </td>
540 <td className="px-3 py-2 text-right whitespace-nowrap">
541 {formatValue(store.smallest)}
542 </td>
543 <td className="px-3 py-2 text-right whitespace-nowrap">
544 {formatValue(store.largest)}
545 </td>
546 <td className="px-3 py-2 text-right">
547 {store.monthCount}
548 </td>
549 </tr>
550 );
551 })
552 )}
553 </tbody>
554 </table>
555 </div>
556
557 {storeData.length > 0 && (
558 <div className="px-4 py-3 bg-surface-2 border-t text-sm text-muted">
559 Displaying {storeData.length} store{storeData.length !== 1 ? 's' : ''}.
560 Highest value per store highlighted in green.
561 </div>
562 )}
563 </>
564 )}
565 </div>
566 </div>
567 );
568}