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 React, { 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 StoreKPI {
13 storeId: number;
14 storeName: string;
15 sales: number;
16 lastCost: number;
17 wacCost: number;
18 gpPercent: number;
19 numSales: number;
20 numUnits: number;
21 avgRev: number;
22}
23
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);
31
32 // Set default dates
33 useEffect(() => {
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]);
38
39 // Today
40 setToDate(now.toISOString().split('T')[0]);
41 }, []);
42
43 const formatDateForAPI = (date: string) => {
44 if (!date) return '';
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()}`;
48 };
49
50 const formatCurrency = (value: number) => {
51 return new Intl.NumberFormat('en-NZ', {
52 style: 'currency',
53 currency: 'NZD',
54 minimumFractionDigits: 2,
55 maximumFractionDigits: 2
56 }).format(value);
57 };
58
59 const formatNumber = (value: number) => {
60 return new Intl.NumberFormat('en-NZ', {
61 minimumFractionDigits: 0,
62 maximumFractionDigits: 0
63 }).format(value);
64 };
65
66 const loadReport = async () => {
67 const apiKey = sessionStorage.getItem("fieldpine_apikey");
68 if (!apiKey) {
69 alert("Please log in first");
70 return;
71 }
72
73 if (!fromDate || !toDate) {
74 alert("Please select date range");
75 return;
76 }
77
78 setLoading(true);
79 setStoreKPIs([]);
80
81 try {
82 // First, load all locations
83 const locationsResponse = await fieldpineApi({
84 endpoint: '/BUCK?3=retailmax.elink.locations',
85 apiKey: apiKey
86 });
87
88 if (!locationsResponse?.data?.DATS || !Array.isArray(locationsResponse.data.DATS)) {
89 alert('Error: No locations/stores defined. Please create store definitions.');
90 setLoading(false);
91 return;
92 }
93
94 const locations: Location[] = locationsResponse.data.DATS;
95 const kpis: StoreKPI[] = [];
96
97 // Build date predicates
98 const startDate = formatDateForAPI(fromDate);
99 const endDate = formatDateForAPI(toDate);
100
101 // Build sales type predicate
102 let salesTypePred = '';
103 if (Number(salesType) > 0) {
104 salesTypePred = `&9=f1001,=,${salesType}`;
105 }
106
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 || '');
111
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}`,
114 apiKey: apiKey
115 });
116
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);
120
121 // Only include stores with sales
122 if (sales !== 0) {
123 const lastCost = Number(record.f102 || 0);
124 const units = Number(record.f107 || 0);
125
126 // Calculate GP%
127 let gpPercent = 0;
128 if (sales !== 0) {
129 gpPercent = ((sales - lastCost) * 100) / sales;
130 }
131
132 // Calculate average revenue
133 let avgRev = 0;
134 if (units !== 0) {
135 avgRev = sales / units;
136 }
137
138 kpis.push({
139 storeId: locationId,
140 storeName: locationName,
141 sales: sales,
142 lastCost: lastCost,
143 wacCost: Number(record.f103 || 0),
144 gpPercent: gpPercent,
145 numSales: Number(record.f105 || 0),
146 numUnits: units,
147 avgRev: avgRev
148 });
149 }
150 }
151 }
152 }
153
154 setStoreKPIs(kpis);
155 } catch (error) {
156 console.error('Error loading KPI report:', error);
157 alert('Error loading report. Please try again.');
158 } finally {
159 setLoading(false);
160 }
161 };
162
163 const handleSort = (key: keyof StoreKPI) => {
164 let direction: 'asc' | 'desc' = 'asc';
165 if (sortConfig && sortConfig.key === key && sortConfig.direction === 'asc') {
166 direction = 'desc';
167 }
168 setSortConfig({ key, direction });
169 };
170
171 const getSortedKPIs = () => {
172 if (!sortConfig) return storeKPIs;
173
174 return [...storeKPIs].sort((a, b) => {
175 const aVal = a[sortConfig.key];
176 const bVal = b[sortConfig.key];
177
178 if (aVal === undefined || aVal === null) return 1;
179 if (bVal === undefined || bVal === null) return -1;
180
181 if (typeof aVal === 'number' && typeof bVal === 'number') {
182 return sortConfig.direction === 'asc' ? aVal - bVal : bVal - aVal;
183 }
184
185 const aStr = String(aVal).toLowerCase();
186 const bStr = String(bVal).toLowerCase();
187
188 if (aStr < bStr) return sortConfig.direction === 'asc' ? -1 : 1;
189 if (aStr > bStr) return sortConfig.direction === 'asc' ? 1 : -1;
190 return 0;
191 });
192 };
193
194 const sortedKPIs = getSortedKPIs();
195
196 return (
197 <div className="p-6">
198 <h1 className="text-3xl font-bold mb-6">Sales KPI Report</h1>
199
200 {/* Filters */}
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">
203 <div>
204 <label className="block text-sm font-medium mb-1">From (including)</label>
205 <input
206 type="date"
207 value={fromDate}
208 onChange={(e) => setFromDate(e.target.value)}
209 className="border rounded px-3 py-2"
210 />
211 </div>
212
213 <div>
214 <label className="block text-sm font-medium mb-1">To (excluding)</label>
215 <input
216 type="date"
217 value={toDate}
218 onChange={(e) => setToDate(e.target.value)}
219 className="border rounded px-3 py-2"
220 />
221 </div>
222
223 <div>
224 <label className="block text-sm font-medium mb-1">Display</label>
225 <select
226 value={salesType}
227 onChange={(e) => setSalesType(e.target.value)}
228 className="border rounded px-3 py-2"
229 >
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>
234 </select>
235 </div>
236
237 <div>
238 <button
239 type="submit"
240 disabled={loading}
241 className="bg-brand text-white px-6 py-2 rounded hover:bg-brand2 disabled:bg-muted/50"
242 >
243 {loading ? 'Loading...' : 'Submit'}
244 </button>
245 </div>
246 </form>
247 </div>
248
249 {/* Loading Indicator */}
250 {loading && (
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>
254 </div>
255 )}
256
257 {/* Results Table */}
258 {sortedKPIs.length > 0 ? (
259 <>
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">
264 <tr>
265 <th
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"
268 >
269 Store {sortConfig?.key === 'storeName' && (sortConfig.direction === 'asc' ? '↑' : '↓')}
270 </th>
271 <th
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"
274 >
275 Sales {sortConfig?.key === 'sales' && (sortConfig.direction === 'asc' ? '↑' : '↓')}
276 </th>
277 <th
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"
280 >
281 Last Cost {sortConfig?.key === 'lastCost' && (sortConfig.direction === 'asc' ? '↑' : '↓')}
282 </th>
283 <th
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"
286 >
287 GP% {sortConfig?.key === 'gpPercent' && (sortConfig.direction === 'asc' ? '↑' : '↓')}
288 </th>
289 <th
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"
292 >
293 Num Sales {sortConfig?.key === 'numSales' && (sortConfig.direction === 'asc' ? '↑' : '↓')}
294 </th>
295 <th
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"
298 >
299 Num Units {sortConfig?.key === 'numUnits' && (sortConfig.direction === 'asc' ? '↑' : '↓')}
300 </th>
301 <th
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"
304 >
305 Avg Rev {sortConfig?.key === 'avgRev' && (sortConfig.direction === 'asc' ? '↑' : '↓')}
306 </th>
307 </tr>
308 </thead>
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">
313 {kpi.storeName}
314 </td>
315 <td className="px-6 py-4 whitespace-nowrap text-sm text-right">
316 <a
317 href={`/report/pos/sales/fieldpine/salesflat_review.htm?sdt=${fromDate}&edt=${toDate}&loc=${kpi.storeId}`}
318 target="_blank"
319 className="text-brand hover:underline font-mono"
320 >
321 {formatCurrency(kpi.sales)}
322 </a>
323 </td>
324 <td className="px-6 py-4 whitespace-nowrap text-sm text-text text-right font-mono">
325 {formatCurrency(kpi.lastCost)}
326 </td>
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">
333 <div
334 className="bg-success h-full rounded-full transition-all"
335 style={{ width: `${Math.min(Math.max(kpi.gpPercent, 0), 100)}%` }}
336 ></div>
337 </div>
338 <span className="text-xs font-medium w-10 text-right">
339 {kpi.gpPercent.toFixed(0)}%
340 </span>
341 </div>
342 </div>
343 )}
344 </div>
345 </td>
346 <td className="px-6 py-4 whitespace-nowrap text-sm text-text text-right font-mono">
347 {formatNumber(kpi.numSales)}
348 </td>
349 <td className="px-6 py-4 whitespace-nowrap text-sm text-text text-right font-mono">
350 {formatNumber(kpi.numUnits)}
351 </td>
352 <td className="px-6 py-4 whitespace-nowrap text-sm text-text text-right font-mono">
353 {formatCurrency(kpi.avgRev)}
354 </td>
355 </tr>
356 ))}
357 </tbody>
358 </table>
359 </div>
360 </div>
361
362 <p className="mt-4 text-sm text-muted text-center">
363 Stores with no sales are not shown. Sales column is tax exclusive.
364 </p>
365 </>
366 ) : (
367 !loading && (
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.
370 </div>
371 )
372 )}
373 </div>
374 );
375}