3import React, { useState, useEffect } from 'react';
4import { apiClient } from '@/lib/client/apiClient';
5import { fieldpineApi } from "@/lib/client/fieldpineApi";
24export default function SalesKPIPage() {
25 const [fromDate, setFromDate] = useState('');
26 const [toDate, setToDate] = useState('');
27 const [salesType, setSalesType] = useState('0');
28 const [loading, setLoading] = useState(false);
29 const [storeKPIs, setStoreKPIs] = useState<StoreKPI[]>([]);
30 const [sortConfig, setSortConfig] = useState<{ key: keyof StoreKPI; direction: 'asc' | 'desc' } | null>(null);
34 // Start of current month
35 const now = new Date();
36 const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
37 setFromDate(startOfMonth.toISOString().split('T')[0]);
40 setToDate(now.toISOString().split('T')[0]);
43 const formatDateForAPI = (date: string) => {
45 const d = new Date(date + 'T00:00:00');
46 const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
47 return `${d.getDate()}${months[d.getMonth()]}${d.getFullYear()}`;
50 const formatCurrency = (value: number) => {
51 return new Intl.NumberFormat('en-NZ', {
54 minimumFractionDigits: 2,
55 maximumFractionDigits: 2
59 const formatNumber = (value: number) => {
60 return new Intl.NumberFormat('en-NZ', {
61 minimumFractionDigits: 0,
62 maximumFractionDigits: 0
66 const loadReport = async () => {
67 const apiKey = sessionStorage.getItem("fieldpine_apikey");
69 alert("Please log in first");
73 if (!fromDate || !toDate) {
74 alert("Please select date range");
82 // First, load all locations
83 const locationsResponse = await fieldpineApi({
84 endpoint: '/BUCK?3=retailmax.elink.locations',
88 if (!locationsResponse?.data?.DATS || !Array.isArray(locationsResponse.data.DATS)) {
89 alert('Error: No locations/stores defined. Please create store definitions.');
94 const locations: Location[] = locationsResponse.data.DATS;
95 const kpis: StoreKPI[] = [];
97 // Build date predicates
98 const startDate = formatDateForAPI(fromDate);
99 const endDate = formatDateForAPI(toDate);
101 // Build sales type predicate
102 let salesTypePred = '';
103 if (Number(salesType) > 0) {
104 salesTypePred = `&9=f1001,=,${salesType}`;
107 // Load sales data for each location
108 for (const location of locations) {
109 const locationId = Number(location.f100 || 0);
110 const locationName = String(location.f101 || '');
112 const salesResponse = await fieldpineApi({
113 endpoint: `/BUCK?3=retailmax.elink.sale.totals&15=2,1,location&13=120&9=f110,4,${startDate}&9=f110,1,${endDate}&9=f100,0,${locationId}${salesTypePred}`,
117 if (salesResponse?.data?.APPD && Array.isArray(salesResponse.data.APPD)) {
118 for (const record of salesResponse.data.APPD) {
119 const sales = Number(record.f120 || 0);
121 // Only include stores with sales
123 const lastCost = Number(record.f102 || 0);
124 const units = Number(record.f107 || 0);
129 gpPercent = ((sales - lastCost) * 100) / sales;
132 // Calculate average revenue
135 avgRev = sales / units;
140 storeName: locationName,
143 wacCost: Number(record.f103 || 0),
144 gpPercent: gpPercent,
145 numSales: Number(record.f105 || 0),
156 console.error('Error loading KPI report:', error);
157 alert('Error loading report. Please try again.');
163 const handleSort = (key: keyof StoreKPI) => {
164 let direction: 'asc' | 'desc' = 'asc';
165 if (sortConfig && sortConfig.key === key && sortConfig.direction === 'asc') {
168 setSortConfig({ key, direction });
171 const getSortedKPIs = () => {
172 if (!sortConfig) return storeKPIs;
174 return [...storeKPIs].sort((a, b) => {
175 const aVal = a[sortConfig.key];
176 const bVal = b[sortConfig.key];
178 if (aVal === undefined || aVal === null) return 1;
179 if (bVal === undefined || bVal === null) return -1;
181 if (typeof aVal === 'number' && typeof bVal === 'number') {
182 return sortConfig.direction === 'asc' ? aVal - bVal : bVal - aVal;
185 const aStr = String(aVal).toLowerCase();
186 const bStr = String(bVal).toLowerCase();
188 if (aStr < bStr) return sortConfig.direction === 'asc' ? -1 : 1;
189 if (aStr > bStr) return sortConfig.direction === 'asc' ? 1 : -1;
194 const sortedKPIs = getSortedKPIs();
197 <div className="p-6">
198 <h1 className="text-3xl font-bold mb-6">Sales KPI Report</h1>
201 <div className="bg-surface p-4 rounded-lg shadow mb-6">
202 <form onSubmit={(e) => { e.preventDefault(); loadReport(); }} className="flex flex-wrap items-end gap-4">
204 <label className="block text-sm font-medium mb-1">From (including)</label>
208 onChange={(e) => setFromDate(e.target.value)}
209 className="border rounded px-3 py-2"
214 <label className="block text-sm font-medium mb-1">To (excluding)</label>
218 onChange={(e) => setToDate(e.target.value)}
219 className="border rounded px-3 py-2"
224 <label className="block text-sm font-medium mb-1">Display</label>
227 onChange={(e) => setSalesType(e.target.value)}
228 className="border rounded px-3 py-2"
230 <option value="0">All Sales</option>
231 <option value="2">Web Sales Only</option>
232 <option value="258">Franchise Web Sales Only</option>
233 <option value="4">Wholesale Sales Only</option>
241 className="bg-brand text-white px-6 py-2 rounded hover:bg-brand2 disabled:bg-muted/50"
243 {loading ? 'Loading...' : 'Submit'}
249 {/* Loading Indicator */}
251 <div className="mb-4 p-3 bg-info/10 border border-info/30 rounded flex items-center gap-2">
252 <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-info"></div>
253 <span>Working...</span>
257 {/* Results Table */}
258 {sortedKPIs.length > 0 ? (
260 <div className="bg-surface rounded-lg shadow overflow-hidden">
261 <div className="overflow-x-auto">
262 <table className="min-w-full divide-y divide-border">
263 <thead className="bg-surface-2">
266 onClick={() => handleSort('storeName')}
267 className="px-6 py-3 text-left text-xs font-medium text-muted uppercase tracking-wider cursor-pointer hover:bg-surface-2/80"
269 Store {sortConfig?.key === 'storeName' && (sortConfig.direction === 'asc' ? '↑' : '↓')}
272 onClick={() => handleSort('sales')}
273 className="px-6 py-3 text-right text-xs font-medium text-muted uppercase tracking-wider cursor-pointer hover:bg-surface-2/80"
275 Sales {sortConfig?.key === 'sales' && (sortConfig.direction === 'asc' ? '↑' : '↓')}
278 onClick={() => handleSort('lastCost')}
279 className="px-6 py-3 text-right text-xs font-medium text-muted uppercase tracking-wider cursor-pointer hover:bg-surface-2/80"
281 Last Cost {sortConfig?.key === 'lastCost' && (sortConfig.direction === 'asc' ? '↑' : '↓')}
284 onClick={() => handleSort('gpPercent')}
285 className="px-6 py-3 text-center text-xs font-medium text-muted uppercase tracking-wider cursor-pointer hover:bg-surface-2/80"
287 GP% {sortConfig?.key === 'gpPercent' && (sortConfig.direction === 'asc' ? '↑' : '↓')}
290 onClick={() => handleSort('numSales')}
291 className="px-6 py-3 text-right text-xs font-medium text-muted uppercase tracking-wider cursor-pointer hover:bg-surface-2/80"
293 Num Sales {sortConfig?.key === 'numSales' && (sortConfig.direction === 'asc' ? '↑' : '↓')}
296 onClick={() => handleSort('numUnits')}
297 className="px-6 py-3 text-right text-xs font-medium text-muted uppercase tracking-wider cursor-pointer hover:bg-surface-2/80"
299 Num Units {sortConfig?.key === 'numUnits' && (sortConfig.direction === 'asc' ? '↑' : '↓')}
302 onClick={() => handleSort('avgRev')}
303 className="px-6 py-3 text-right text-xs font-medium text-muted uppercase tracking-wider cursor-pointer hover:bg-surface-2/80"
305 Avg Rev {sortConfig?.key === 'avgRev' && (sortConfig.direction === 'asc' ? '↑' : '↓')}
309 <tbody className="bg-surface divide-y divide-border">
310 {sortedKPIs.map((kpi, idx) => (
311 <tr key={idx} className="hover:bg-surface-2">
312 <td className="px-6 py-4 whitespace-nowrap text-sm text-text">
315 <td className="px-6 py-4 whitespace-nowrap text-sm text-right">
317 href={`/report/pos/sales/fieldpine/salesflat_review.htm?sdt=${fromDate}&edt=${toDate}&loc=${kpi.storeId}`}
319 className="text-brand hover:underline font-mono"
321 {formatCurrency(kpi.sales)}
324 <td className="px-6 py-4 whitespace-nowrap text-sm text-text text-right font-mono">
325 {formatCurrency(kpi.lastCost)}
327 <td className="px-6 py-4 whitespace-nowrap text-sm">
328 <div className="flex items-center justify-center">
329 {kpi.gpPercent > 0 && (
330 <div className="w-full max-w-[120px]">
331 <div className="flex items-center gap-2">
332 <div className="flex-1 bg-surface-2 rounded-full h-4 overflow-hidden">
334 className="bg-success h-full rounded-full transition-all"
335 style={{ width: `${Math.min(Math.max(kpi.gpPercent, 0), 100)}%` }}
338 <span className="text-xs font-medium w-10 text-right">
339 {kpi.gpPercent.toFixed(0)}%
346 <td className="px-6 py-4 whitespace-nowrap text-sm text-text text-right font-mono">
347 {formatNumber(kpi.numSales)}
349 <td className="px-6 py-4 whitespace-nowrap text-sm text-text text-right font-mono">
350 {formatNumber(kpi.numUnits)}
352 <td className="px-6 py-4 whitespace-nowrap text-sm text-text text-right font-mono">
353 {formatCurrency(kpi.avgRev)}
362 <p className="mt-4 text-sm text-muted text-center">
363 Stores with no sales are not shown. Sales column is tax exclusive.
368 <div className="bg-surface rounded-lg shadow p-8 text-center text-muted">
369 No data available. Select date range and click Submit to load KPI report.