3import { useState, useEffect } from 'react';
4import { apiClient } from '@/lib/client/apiClient';
5import { fieldpineApi } from "@/lib/client/fieldpineApi";
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);
44 const [fromDate, setFromDate] = useState(() => {
46 d.setDate(1); // First of month
47 return d.toISOString().split('T')[0];
49 const [toDate, setToDate] = useState(() => {
50 const today = new Date();
51 return today.toISOString().split('T')[0];
53 const [selectedLocation, setSelectedLocation] = useState<number>(0);
54 const [maxRows, setMaxRows] = useState(200);
56 // Load locations on mount
61 // Load report when filters change
63 if (locations.length > 0) {
66 }, [fromDate, toDate, selectedLocation, locations]);
68 const loadLocations = async () => {
70 const apiKey = sessionStorage.getItem("fieldpine_apikey");
73 const response = await fieldpineApi({
74 endpoint: '/buck?3=retailmax.elink.locations',
79 setLocations(response.DATS);
82 console.error('Error loading locations:', error);
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}`;
94 const loadReport = async () => {
95 if (!fromDate || !toDate) return;
99 const apiKey = sessionStorage.getItem("fieldpine_apikey");
105 const fromFormatted = formatDateForAPI(fromDate);
106 const toFormatted = formatDateForAPI(toDate);
108 let locationPred = '';
109 if (selectedLocation > 0) {
110 locationPred = `&9=f100,0,${selectedLocation}`;
113 const maxRowPred = maxRows > 0 ? `&8=${maxRows}` : '';
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}`;
118 const response = await fieldpineApi({
123 if (response?.APPD) {
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);
135 gp = ((sales - lastcost) * 100) / sales;
140 avesale = sales / numsales;
143 let itemspersale = 0;
145 itemspersale = units / numsales;
149 accid: Number(rec.f1006 || 0),
150 name: rec.f2006 || '',
153 salesIncTax: Number(rec.f121 || 0),
155 waccost: Number(rec.f103 || 0),
158 itemspersale: itemspersale,
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)
172 setTotalPeriodRev(totRev);
174 // Calculate percent of total for each account
175 accounts.forEach(a => {
177 a.percenttotal = (a.sales / totRev) * 100;
181 setAccountData(accounts);
186 console.error('Error loading report:', error);
192 const handleSort = (column: keyof AccountData) => {
193 if (sortColumn === column) {
194 setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
196 setSortColumn(column);
197 setSortDirection('desc');
201 const sortedData = [...accountData].sort((a, b) => {
202 let aVal = a[sortColumn];
203 let bVal = b[sortColumn];
205 if (typeof aVal === 'string') aVal = aVal.toLowerCase();
206 if (typeof bVal === 'string') bVal = bVal.toLowerCase();
208 if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1;
209 if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1;
213 const SortIndicator = ({ column }: { column: keyof AccountData }) => {
214 if (sortColumn !== column) return null;
215 return <span className="ml-1">{sortDirection === 'asc' ? '▲' : '▼'}</span>;
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';
225 <div className="flex items-center gap-2">
226 <div className="w-16 bg-surface-2 rounded-full h-4">
228 className={`${colorClass} h-full rounded-full transition-all`}
229 style={{ width: `${percentage}%` }}
232 <span className="text-sm">{value.toFixed(2)}%</span>
237 const SalesPctBar = ({ value }: { value: number }) => {
238 const percentage = Math.max(0, Math.min(100, Math.round(value)));
240 <div className="flex items-center gap-2">
241 <div className="w-16 bg-surface-2 rounded-full h-4">
243 className="bg-success h-full rounded-full transition-all"
244 style={{ width: `${percentage}%` }}
247 <span className="text-sm">{value.toFixed(2)}%</span>
252 const QuartileBox = ({ account }: { account: AccountData }) => {
253 if (account.numsales < 3) return null;
255 const { v11000, v11001, v11002, v11003, v11004, v11005, v11006 } = account;
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)}`;
259 const range = v11006 - v11005;
260 const getPosition = (val: number) => {
261 if (range === 0) return 50;
262 return ((val - v11005) / range) * 100;
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);
272 <div className="relative w-48 h-4" title={tooltip}>
275 className="absolute top-1/2 h-0.5 bg-muted/50"
278 width: `${maxPos - minPos}%`,
279 transform: 'translateY(-50%)'
283 {/* Box (Q1 to Q3) */}
285 className="absolute top-0 h-full bg-brand/30 border border-brand"
288 width: `${q3Pos - q1Pos}%`
294 className="absolute top-0 h-full w-0.5 bg-red-600"
295 style={{ left: `${medPos}%` }}
298 {/* Min whisker cap */}
300 className="absolute top-0 h-full w-0.5 bg-muted"
301 style={{ left: `${minPos}%` }}
304 {/* Max whisker cap */}
306 className="absolute top-0 h-full w-0.5 bg-muted"
307 style={{ left: `${maxPos}%` }}
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}`;
323 const formatDate = (dateStr: string): string => {
324 if (!dateStr) return '';
325 const date = new Date(dateStr);
326 return date.toLocaleDateString('en-AU', {
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>
341 <div className="bg-surface p-4 rounded-lg shadow mb-6">
342 <div className="flex flex-wrap gap-4 items-end">
344 <label className="block text-sm font-medium mb-1">From (including)</label>
348 onChange={(e) => setFromDate(e.target.value)}
349 className="border rounded px-3 py-2"
354 <label className="block text-sm font-medium mb-1">To (excluding)</label>
358 onChange={(e) => setToDate(e.target.value)}
359 className="border rounded px-3 py-2"
364 <label className="block text-sm font-medium mb-1">Max Rows</label>
368 onChange={(e) => setMaxRows(Number(e.target.value))}
369 className="border rounded px-3 py-2 w-24"
374 <label className="block text-sm font-medium mb-1">Store</label>
376 value={selectedLocation}
377 onChange={(e) => setSelectedLocation(Number(e.target.value))}
378 className="border rounded px-3 py-2"
380 <option value="0">All Stores</option>
381 {locations.map(loc => (
382 <option key={loc.f100} value={loc.f100}>{loc.f101}</option>
390 className="bg-brand text-white px-6 py-2 rounded hover:bg-brand2 disabled:bg-muted/50"
392 {isLoading ? 'Loading...' : 'Run Report'}
397 {/* Loading Indicator */}
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>
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">
412 className="px-4 py-3 text-left text-sm font-medium cursor-pointer hover:bg-surface-2"
413 onClick={() => handleSort('accid')}
415 Accid# <SortIndicator column="accid" />
418 className="px-4 py-3 text-left text-sm font-medium cursor-pointer hover:bg-surface-2"
419 onClick={() => handleSort('name')}
421 Name <SortIndicator column="name" />
424 className="px-4 py-3 text-right text-sm font-medium cursor-pointer hover:bg-surface-2"
425 onClick={() => handleSort('numsales')}
427 #Sales <SortIndicator column="numsales" />
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"
434 % of Sales Rev <SortIndicator column="percenttotal" />
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"
441 Items Purchased <SortIndicator column="units" />
444 className="px-4 py-3 text-right text-sm font-medium cursor-pointer hover:bg-surface-2"
445 onClick={() => handleSort('sales')}
447 Sales <SortIndicator column="sales" />
450 className="px-4 py-3 text-right text-sm font-medium cursor-pointer hover:bg-surface-2"
451 onClick={() => handleSort('lastSaleDate')}
453 Last Sale <SortIndicator column="lastSaleDate" />
456 className="px-4 py-3 text-right text-sm font-medium cursor-pointer hover:bg-surface-2"
457 onClick={() => handleSort('lastcost')}
459 Last Cost <SortIndicator column="lastcost" />
462 className="px-4 py-3 text-left text-sm font-medium cursor-pointer hover:bg-surface-2"
463 onClick={() => handleSort('gp')}
465 GP% <SortIndicator column="gp" />
468 className="px-4 py-3 text-left text-sm font-medium"
469 title="Spread of total value of sales"
475 <tbody className="divide-y">
476 {sortedData.length === 0 && !isLoading ? (
478 <td colSpan={10} className="px-4 py-8 text-center text-muted">
479 No account data found for the selected period
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} />
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">
494 href={buildSalesLink(account)}
496 rel="noopener noreferrer"
497 className="text-brand hover:underline"
499 {new Intl.NumberFormat('en-AU', {
502 }).format(account.sales)}
505 <td className="px-4 py-3 text-sm text-right">
506 {formatDate(account.lastSaleDate)}
508 <td className="px-4 py-3 text-sm text-right">
509 {new Intl.NumberFormat('en-AU', {
512 }).format(account.lastcost)}
514 <td className="px-4 py-3 text-sm">
515 <GPBar value={account.gp} />
517 <td className="px-4 py-3 text-sm">
518 <QuartileBox account={account} />
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', {
534 }).format(totalPeriodRev)}