3import { useState, useEffect } from 'react';
4import { apiClient } from '@/lib/client/apiClient';
5import { fieldpineApi } from "@/lib/client/fieldpineApi";
25 months: { [key: number]: number };
33export default function SalesByMonthPage() {
34 const [locations, setLocations] = useState<Location[]>([]);
35 const [departments, setDepartments] = useState<Department[]>([]);
36 const [suppliers, setSuppliers] = useState<Supplier[]>([]);
38 const [periodEnd, setPeriodEnd] = useState(() => {
40 return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
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);
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');
54 const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
62 const loadLocations = async () => {
64 const apiKey = sessionStorage.getItem("fieldpine_apikey");
67 const response = await fieldpineApi({
68 endpoint: '/buck?3=retailmax.elink.locations',
73 setLocations(response.DATS);
76 console.error('Error loading locations:', error);
80 const loadDepartments = async () => {
82 const apiKey = sessionStorage.getItem("fieldpine_apikey");
85 const response = await fieldpineApi({
86 endpoint: '/buck?3=retailmax.elink.departments',
91 setDepartments(response.DATS);
94 console.error('Error loading departments:', error);
98 const loadSuppliers = async () => {
100 const apiKey = sessionStorage.getItem("fieldpine_apikey");
103 const response = await fieldpineApi({
104 endpoint: '/buck?3=retailmax.elink.suppliers',
109 setSuppliers(response.DATS);
112 console.error('Error loading suppliers:', error);
116 const loadReport = async () => {
119 const apiKey = sessionStorage.getItem("fieldpine_apikey");
121 console.error("No API key found");
126 // Parse period end (format: YYYY-MM)
127 const [year, month] = periodEnd.split('-').map(Number);
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}`;
135 // Add location filter
136 if (selectedLocation !== '0' && selectedLocation !== '') {
137 exPreds += `&9=f100,0,${selectedLocation}`;
140 // Add supplier filter
141 if (selectedSupplier !== '0' && selectedSupplier !== '') {
142 exPreds += `&9=f1003,0,${selectedSupplier}`;
145 // Add department filter
146 if (selectedDepartment !== '0' && selectedDepartment !== '') {
147 exPreds += `&9=f1002,0,${selectedDepartment}`;
150 // Account payments filter
151 if (!includeAccountPayments) {
152 exPreds += '&9=f1008,5,0';
155 const endpoint = `/buck?3=retailmax.elink.sale.cube&100=6&101=${statistic}&9=f110,4,${fromDate}&9=f110,1,${toDate}${exPreds}`;
157 const response = await fieldpineApi({
162 if (response.APPD && response.APPD.length > 0) {
164 const dataMap: { [key: string]: MonthData } = {};
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';
172 if (!dataMap[locid]) {
175 storename: storeName,
185 const data = dataMap[locid];
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;
195 data.smallest = Math.min(data.smallest, value);
196 data.largest = Math.max(data.largest, value);
202 // Calculate averages
203 Object.values(dataMap).forEach(data => {
204 if (data.monthCount > 0) {
205 data.average = data.total / data.monthCount;
207 if (data.smallest === Infinity) data.smallest = 0;
210 setStoreData(Object.values(dataMap));
215 console.error('Error loading report:', error);
222 const formatValue = (value: number | undefined): string => {
223 if (!value || value === 0) return '';
225 if (statistic === 'qty') {
226 return value.toFixed(0);
228 return new Intl.NumberFormat('en-NZ', {
231 minimumFractionDigits: 2
236 const handleSort = (column: string) => {
237 if (sortColumn === column) {
238 setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
240 setSortColumn(column);
241 setSortDirection('asc');
245 const sortedData = [...storeData].sort((a, b) => {
249 if (sortColumn === '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;
257 aVal = (a as any)[sortColumn] || 0;
258 bVal = (b as any)[sortColumn] || 0;
261 if (typeof aVal === 'number' && typeof bVal === 'number') {
262 return sortDirection === 'asc' ? aVal - bVal : bVal - aVal;
265 return sortDirection === 'asc'
266 ? String(aVal).localeCompare(String(bVal))
267 : String(bVal).localeCompare(String(aVal));
270 const SortIcon = ({ column }: { column: string }) => {
271 if (sortColumn !== column) return <span className="text-muted/50">⇅</span>;
272 return <span>{sortDirection === 'asc' ? '↑' : '↓'}</span>;
275 // Get the 12 months to display based on period end
276 const getMonthHeaders = () => {
277 const [year, month] = periodEnd.split('-').map(Number);
280 for (let i = 0; i < 12; i++) {
287 headers.push({ month: m, year: y, label: `${monthNames[m - 1]}-${y}` });
293 const monthHeaders = getMonthHeaders();
295 // Find the highest value for each store
296 const getHighlightedMonth = (store: MonthData): number => {
300 Object.entries(store.months).forEach(([monthNum, value]) => {
303 maxMonth = Number(monthNum);
311 <div className="p-6">
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
318 <div className="text-sm text-muted">
319 <a href="/pages/reports/sales-reports" className="text-brand hover:underline">Sales</a>
321 <span>Sales by Month</span>
326 <div className="bg-surface rounded-lg shadow p-6 mb-6">
327 <div className="flex flex-wrap items-end gap-4 mb-4">
329 <label className="block text-sm font-medium text-text mb-2">
335 onChange={(e) => setPeriodEnd(e.target.value)}
336 className="border rounded px-3 py-2"
341 <label className="block text-sm font-medium text-text mb-2">
346 onChange={(e) => setStatistic(e.target.value)}
347 className="border rounded px-3 py-2"
349 <option value="qty">Quantity</option>
350 <option value="tax_totex">Revenue ex Tax</option>
351 <option value="tax_totinc">Revenue incl Tax</option>
356 <label className="block text-sm font-medium text-text mb-2">
360 value={selectedLocation}
361 onChange={(e) => setSelectedLocation(e.target.value)}
362 className="border rounded px-3 py-2"
364 <option value="0">All Stores</option>
365 {locations.map(loc => (
366 <option key={loc.f100} value={loc.f100}>
374 <label className="block text-sm font-medium text-text mb-2">
378 value={selectedDepartment}
379 onChange={(e) => setSelectedDepartment(e.target.value)}
380 className="border rounded px-3 py-2"
382 <option value="0">All Departments</option>
383 {departments.map(dept => (
384 <option key={dept.f100} value={dept.f100}>
388 <option value="without">Without Department</option>
393 <label className="block text-sm font-medium text-text mb-2">
397 value={selectedSupplier}
398 onChange={(e) => setSelectedSupplier(e.target.value)}
399 className="border rounded px-3 py-2"
401 <option value="0">All Suppliers</option>
402 {suppliers.map(sup => (
403 <option key={sup.f100} value={sup.f100}>
410 <div className="flex items-center">
411 <label className="flex items-center gap-2">
414 checked={includeAccountPayments}
415 onChange={(e) => setIncludeAccountPayments(e.target.checked)}
418 <span className="text-sm">Include Account Payments</span>
426 className="bg-brand text-white px-6 py-2 rounded hover:bg-brand2 disabled:bg-muted/50"
428 {loading ? 'Loading...' : 'Load Period'}
434 {/* Results Table */}
435 <div className="bg-surface rounded-lg shadow overflow-hidden">
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>
443 <div className="overflow-x-auto">
444 <table className="w-full text-sm">
445 <thead className="bg-[var(--brand)] text-surface sticky top-0">
448 className="px-3 py-2 text-left font-semibold cursor-pointer hover:bg-surface-2/80 whitespace-nowrap"
449 onClick={() => handleSort('locid')}
451 Id# <SortIcon column="locid" />
454 className="px-3 py-2 text-left font-semibold cursor-pointer hover:bg-surface-2/80 whitespace-nowrap"
455 onClick={() => handleSort('storename')}
457 Store Name <SortIcon column="storename" />
459 {monthHeaders.map((header, 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}`)}
465 {header.label} <SortIcon column={`month_${header.month}`} />
469 className="px-3 py-2 text-right font-semibold cursor-pointer hover:bg-surface-2/80 whitespace-nowrap"
470 onClick={() => handleSort('total')}
472 Total <SortIcon column="total" />
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"
479 Average <SortIcon column="average" />
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"
486 Smallest <SortIcon column="smallest" />
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"
493 Largest <SortIcon column="largest" />
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"
500 Month Count <SortIcon column="monthCount" />
504 <tbody className="divide-y divide-border">
505 {sortedData.length === 0 ? (
507 <td colSpan={17} className="px-4 py-8 text-center text-muted">
508 No sales data found for the selected period
512 sortedData.map((store, idx) => {
513 const highlightedMonth = getHighlightedMonth(store);
515 <tr key={idx} className={idx % 2 === 0 ? 'bg-surface' : 'bg-surface-2'}>
516 <td className="px-3 py-2">
519 <td className="px-3 py-2 font-medium whitespace-nowrap">
522 {monthHeaders.map((header, mIdx) => {
523 const value = store.months[header.month];
524 const isHighlighted = header.month === highlightedMonth && value > 0;
528 className={`px-3 py-2 text-right whitespace-nowrap ${isHighlighted ? 'bg-green-100' : ''}`}
534 <td className="px-3 py-2 text-right font-semibold whitespace-nowrap">
535 {formatValue(store.total)}
537 <td className="px-3 py-2 text-right whitespace-nowrap">
538 {formatValue(store.average)}
540 <td className="px-3 py-2 text-right whitespace-nowrap">
541 {formatValue(store.smallest)}
543 <td className="px-3 py-2 text-right whitespace-nowrap">
544 {formatValue(store.largest)}
546 <td className="px-3 py-2 text-right">
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.