3import { useState, useEffect } from 'react';
4import { apiClient } from '@/lib/client/apiClient';
5import { fieldpineApi } from "@/lib/client/fieldpineApi";
7interface CustomerData {
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);
42 const [fromDate, setFromDate] = useState(() => {
43 const yesterday = new Date();
44 yesterday.setDate(yesterday.getDate() - 1);
45 return yesterday.toISOString().split('T')[0];
47 const [toDate, setToDate] = useState(() => {
48 const today = new Date();
49 return today.toISOString().split('T')[0];
51 const [selectedLocation, setSelectedLocation] = useState<number>(0);
52 const [maxRows, setMaxRows] = useState(200);
54 // Load locations on mount
59 // Load report when filters change
61 if (locations.length > 0) {
64 }, [fromDate, toDate, selectedLocation, locations]);
66 const loadLocations = async () => {
68 const apiKey = sessionStorage.getItem("fieldpine_apikey");
71 const response = await fieldpineApi({
72 endpoint: '/buck?3=retailmax.elink.locations',
77 setLocations(response.DATS);
80 console.error('Error loading locations:', error);
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}`;
92 const loadReport = async () => {
93 if (!fromDate || !toDate) return;
97 const apiKey = sessionStorage.getItem("fieldpine_apikey");
103 const fromFormatted = formatDateForAPI(fromDate);
104 const toFormatted = formatDateForAPI(toDate);
106 let locationPred = '';
107 if (selectedLocation > 0) {
108 locationPred = `&9=f100,0,${selectedLocation}`;
111 const maxRowPred = maxRows > 0 ? `&8=${maxRows}` : '';
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}`;
115 const response = await fieldpineApi({
120 if (response?.APPD) {
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);
132 gp = ((sales - lastcost) * 100) / sales;
137 avesale = sales / numsales;
140 let itemspersale = 0;
142 itemspersale = units / numsales;
146 cid: Number(rec.f140 || 0),
147 name: rec.f2140 || '',
151 waccost: Number(rec.f103 || 0),
154 itemspersale: itemspersale,
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)
167 setTotalPeriodRev(totRev);
169 // Calculate percent of total for each customer
170 customers.forEach(c => {
172 c.percenttotal = (c.sales / totRev) * 100;
176 setCustomerData(customers);
181 console.error('Error loading report:', error);
187 const handleSort = (column: keyof CustomerData) => {
188 if (sortColumn === column) {
189 setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
191 setSortColumn(column);
192 setSortDirection('desc');
196 const sortedData = [...customerData].sort((a, b) => {
197 let aVal = a[sortColumn];
198 let bVal = b[sortColumn];
200 if (typeof aVal === 'string') aVal = aVal.toLowerCase();
201 if (typeof bVal === 'string') bVal = bVal.toLowerCase();
203 if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1;
204 if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1;
208 const SortIndicator = ({ column }: { column: keyof CustomerData }) => {
209 if (sortColumn !== column) return null;
210 return <span className="ml-1">{sortDirection === 'asc' ? '▲' : '▼'}</span>;
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';
220 <div className="flex items-center gap-2">
221 <div className="w-16 bg-surface-2 rounded-full h-4">
223 className={`${colorClass} h-full rounded-full transition-all`}
224 style={{ width: `${percentage}%` }}
227 <span className="text-sm">{value.toFixed(2)}%</span>
232 const SalesPctBar = ({ value }: { value: number }) => {
233 const percentage = Math.max(0, Math.min(100, Math.round(value)));
235 <div className="flex items-center gap-2">
236 <div className="w-16 bg-surface-2 rounded-full h-4">
238 className="bg-success h-full rounded-full transition-all"
239 style={{ width: `${percentage}%` }}
242 <span className="text-sm">{value.toFixed(2)}%</span>
247 const QuartileBox = ({ customer }: { customer: CustomerData }) => {
248 if (customer.numsales < 3) return null;
250 const { v11000, v11001, v11002, v11003, v11004, v11005, v11006 } = customer;
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)}`;
254 const range = v11006 - v11005;
255 const getPosition = (val: number) => {
256 if (range === 0) return 50;
257 return ((val - v11005) / range) * 100;
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);
267 <div className="relative w-48 h-4" title={tooltip}>
270 className="absolute top-1/2 h-0.5 bg-muted/50"
273 width: `${maxPos - minPos}%`,
274 transform: 'translateY(-50%)'
278 {/* Box (Q1 to Q3) */}
280 className="absolute top-0 h-full bg-brand/30 border border-brand"
283 width: `${q3Pos - q1Pos}%`
289 className="absolute top-0 h-full w-0.5 bg-red-600"
290 style={{ left: `${medPos}%` }}
293 {/* Min whisker cap */}
295 className="absolute top-0 h-full w-0.5 bg-muted"
296 style={{ left: `${minPos}%` }}
299 {/* Max whisker cap */}
301 className="absolute top-0 h-full w-0.5 bg-muted"
302 style={{ left: `${maxPos}%` }}
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}`;
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>
326 <div className="bg-white p-4 rounded-lg shadow mb-6">
327 <div className="flex flex-wrap gap-4 items-end">
329 <label className="block text-sm font-medium mb-1">From (including)</label>
333 onChange={(e) => setFromDate(e.target.value)}
334 className="border rounded px-3 py-2"
339 <label className="block text-sm font-medium mb-1">To (excluding)</label>
343 onChange={(e) => setToDate(e.target.value)}
344 className="border rounded px-3 py-2"
349 <label className="block text-sm font-medium mb-1">Max Rows</label>
353 onChange={(e) => setMaxRows(Number(e.target.value))}
354 className="border rounded px-3 py-2 w-24"
359 <label className="block text-sm font-medium mb-1">Store</label>
361 value={selectedLocation}
362 onChange={(e) => setSelectedLocation(Number(e.target.value))}
363 className="border rounded px-3 py-2"
365 <option value="0">All Stores</option>
366 {locations.map(loc => (
367 <option key={loc.f100} value={loc.f100}>{loc.f101}</option>
375 className="bg-brand text-white px-6 py-2 rounded hover:bg-brand/90 disabled:bg-muted/50"
377 {isLoading ? 'Loading...' : 'Run Report'}
382 {/* Loading Indicator */}
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>
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">
397 className="px-4 py-3 text-left text-sm font-medium cursor-pointer hover:bg-surface-2"
398 onClick={() => handleSort('cid')}
400 Cid# <SortIndicator column="cid" />
403 className="px-4 py-3 text-left text-sm font-medium cursor-pointer hover:bg-surface-2"
404 onClick={() => handleSort('name')}
406 Name <SortIndicator column="name" />
409 className="px-4 py-3 text-right text-sm font-medium cursor-pointer hover:bg-surface-2"
410 onClick={() => handleSort('numsales')}
412 #Sales <SortIndicator column="numsales" />
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"
419 % of Sales Rev <SortIndicator column="percenttotal" />
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"
426 Items Purchased <SortIndicator column="units" />
429 className="px-4 py-3 text-right text-sm font-medium cursor-pointer hover:bg-surface-2"
430 onClick={() => handleSort('sales')}
432 Sales <SortIndicator column="sales" />
435 className="px-4 py-3 text-right text-sm font-medium cursor-pointer hover:bg-surface-2"
436 onClick={() => handleSort('lastcost')}
438 Last Cost <SortIndicator column="lastcost" />
441 className="px-4 py-3 text-left text-sm font-medium cursor-pointer hover:bg-surface-2"
442 onClick={() => handleSort('gp')}
444 GP% <SortIndicator column="gp" />
447 className="px-4 py-3 text-left text-sm font-medium"
448 title="Spread of total value of sales"
454 <tbody className="divide-y">
455 {sortedData.length === 0 && !isLoading ? (
457 <td colSpan={9} className="px-4 py-8 text-center text-muted">
458 No customer data found for the selected period
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} />
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">
473 href={buildSalesLink(customer)}
475 rel="noopener noreferrer"
476 className="text-brand hover:underline"
478 {new Intl.NumberFormat('en-AU', {
481 }).format(customer.sales)}
484 <td className="px-4 py-3 text-sm text-right">
485 {new Intl.NumberFormat('en-AU', {
488 }).format(customer.lastcost)}
490 <td className="px-4 py-3 text-sm">
491 <GPBar value={customer.gp} />
493 <td className="px-4 py-3 text-sm">
494 <QuartileBox customer={customer} />
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', {
510 }).format(totalPeriodRev)}