3import { useState, useEffect } from "react";
4import { apiClient } from "@/lib/client/apiClient";
49export default function BasketSizesPage() {
50 const [data, setData] = useState<BasketRow[]>([]);
51 const [loading, setLoading] = useState(false);
52 const [sortColumn, setSortColumn] = useState<keyof BasketRow>("name");
53 const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
56 const [fromDate, setFromDate] = useState(() => {
58 d.setMonth(0, 1); // Jan 1st
59 return d.toISOString().split("T")[0];
61 const [toDate, setToDate] = useState(() => {
63 d.setDate(d.getDate() + 1);
64 return d.toISOString().split("T")[0];
66 const [productFilter, setProductFilter] = useState("");
67 const [splitBy, setSplitBy] = useState("1"); // 1=Store, 2=Teller, 3=Department
70 const [locations, setLocations] = useState<Location[]>([]);
71 const [staff, setStaff] = useState<Staff[]>([]);
72 const [departments, setDepartments] = useState<Department[]>([]);
73 const [loadingProgress, setLoadingProgress] = useState("");
75 // Load locations and staff on mount
78 apiClient.getLocations(),
81 .then(([locData, staffData]) => {
82 if (locData.success && (locData.data as any)?.data?.Location) {
83 setLocations((locData.data as any).data.Location);
85 if (staffData.success && (staffData.data as any)?.data?.Staff) {
86 setStaff((staffData.data as any).data.Staff);
89 .catch((err) => console.error("Error loading initial data:", err));
92 const formatDateForAPI = (dateStr: string) => {
93 const [year, month, day] = dateStr.split("-");
94 return `${day}-${month}-${year}`;
97 const handleSort = (column: keyof BasketRow) => {
98 if (sortColumn === column) {
99 setSortDirection(sortDirection === "asc" ? "desc" : "asc");
101 setSortColumn(column);
102 setSortDirection("asc");
106 const sortedData = [...data].sort((a, b) => {
107 const aVal = a[sortColumn];
108 const bVal = b[sortColumn];
110 if (typeof aVal === "number" && typeof bVal === "number") {
111 return sortDirection === "asc" ? aVal - bVal : bVal - aVal;
114 const aStr = String(aVal);
115 const bStr = String(bVal);
116 return sortDirection === "asc"
117 ? aStr.localeCompare(bStr)
118 : bStr.localeCompare(aStr);
121 const SortIndicator = ({ column }: { column: keyof BasketRow }) => {
122 if (sortColumn !== column) return null;
123 return <span className="ml-1">{sortDirection === "asc" ? "▲" : "▼"}</span>;
126 const processBasketData = (rr: any): Omit<BasketRow, "id" | "name"> | null => {
127 const cnt1 = Number(rr.f10001 || 0);
128 const cnt2 = Number(rr.f10002 || 0);
129 const cnt3 = Number(rr.f10003 || 0);
130 const cnt4 = Number(rr.f10004 || 0);
131 const cnt5 = Number(rr.f10005 || 0);
132 const cnt6 = Number(rr.f10006 || 0);
133 const cnt7 = Number(rr.f10007 || 0);
134 const cnt8 = Number(rr.f10008 || 0);
135 const cnt9 = Number(rr.f10009 || 0);
136 const cnt10 = Number(rr.f10010 || 0);
137 const cnt11 = Number(rr.f10011 || 0);
140 cnt1 + cnt2 + cnt3 + cnt4 + cnt5 + cnt6 + cnt7 + cnt8 + cnt9 + cnt10 + cnt11;
142 if (totalSales === 0) return null;
157 pct1: (cnt1 * 100) / totalSales,
158 pct2: (cnt2 * 100) / totalSales,
159 pct3: (cnt3 * 100) / totalSales,
160 pct4: (cnt4 * 100) / totalSales,
161 pct5: (cnt5 * 100) / totalSales,
162 pct6: (cnt6 * 100) / totalSales,
163 pct7: (cnt7 * 100) / totalSales,
164 pct8: (cnt8 * 100) / totalSales,
165 pct9: (cnt9 * 100) / totalSales,
166 pct10: (cnt10 * 100) / totalSales,
167 pct11: (cnt11 * 100) / totalSales,
171 const loadReport = async () => {
173 setLoadingProgress("Initializing...");
176 const fromDateAPI = formatDateForAPI(fromDate);
177 const toDateAPI = formatDateForAPI(toDate);
179 // Build product filter if provided
181 // Note: productFilter would need to be a product ID for the API
182 // The autocomplete functionality is placeholder for now
185 const splitType = Number(splitBy);
187 if (splitType === 1) {
189 const results: BasketRow[] = [];
192 for (const loc of locations) {
193 setLoadingProgress(`Processing ${loc.Name} (${completed + 1}/${locations.length})`);
196 `f110,4,${fromDateAPI}`,
197 `f110,1,${toDateAPI}`,
198 `f100,0,${loc.Locid}`
202 const params = new URLSearchParams({
203 format: '2,1,location',
206 ...filters.reduce((acc, f, i) => ({ ...acc, [`filters[${i}]`]: f }), {})
208 const response = await apiClient.request(`/v1/reports/sales-totals?${params.toString()}`);
210 if (response.success && (response.data as any)?.APPD && Array.isArray((response.data as any).APPD)) {
211 for (const rr of (response.data as any).APPD) {
212 const basketData = processBasketData(rr);
223 console.error(`Error loading data for ${loc.Name}:`, err);
230 } else if (splitType === 2) {
232 const results: BasketRow[] = [];
235 for (const teller of staff) {
236 setLoadingProgress(`Processing ${teller.Name} (${completed + 1}/${staff.length})`);
239 `f110,4,${fromDateAPI}`,
240 `f110,1,${toDateAPI}`,
241 `f130,0,${teller.Tid}`
245 const params = new URLSearchParams({
246 format: '2,1,location',
249 ...filters.reduce((acc, f, i) => ({ ...acc, [`filters[${i}]`]: f }), {})
251 const response = await apiClient.request(`/v1/reports/sales-totals?${params.toString()}`);
253 if (response.success && (response.data as any)?.APPD && Array.isArray((response.data as any).APPD)) {
254 for (const rr of (response.data as any).APPD) {
255 const basketData = processBasketData(rr);
266 console.error(`Error loading data for ${teller.Name}:`, err);
273 } else if (splitType === 3) {
274 // Split by Department
275 // Load departments if not already loaded
276 if (departments.length === 0) {
277 const deptResponse = await apiClient.getDepartments();
278 if (deptResponse.success && (deptResponse.data as any)?.data?.Department) {
279 setDepartments((deptResponse.data as any).data.Department);
281 setLoadingProgress("");
282 // Call loadReport again after departments are loaded
283 setTimeout(() => loadReport(), 0);
288 const results: BasketRow[] = [];
291 for (const dept of departments) {
293 `Processing ${dept.Description} (${completed + 1}/${departments.length})`
297 `f110,4,${fromDateAPI}`,
298 `f110,1,${toDateAPI}`,
299 `f1002,0,${dept.Depid}`
303 const params = new URLSearchParams({
304 format: '2,1,location',
307 ...filters.reduce((acc, f, i) => ({ ...acc, [`filters[${i}]`]: f }), {})
309 const response = await apiClient.request(`/v1/reports/sales-totals?${params.toString()}`);
311 if (response.success && (response.data as any)?.APPD && Array.isArray((response.data as any).APPD)) {
312 for (const rr of (response.data as any).APPD) {
313 const basketData = processBasketData(rr);
317 name: dept.Description,
324 console.error(`Error loading data for ${dept.Description}:`, err);
333 console.error("Error loading report:", error);
336 setLoadingProgress("");
340 // Simple bar chart visualization
341 const BasketChart = ({ row }: { row: BasketRow }) => {
355 const maxCount = Math.max(...counts);
358 <div className="flex items-end gap-0.5 h-8 w-32">
359 {counts.map((count, idx) => {
360 const height = maxCount > 0 ? (count / maxCount) * 100 : 0;
364 className="flex-1 bg-brand rounded-t"
365 style={{ height: `${height}%` }}
366 title={`${idx + 1} ${idx === 10 ? "+" : ""} items: ${count} sales`}
375 <div className="min-h-screen bg-bg p-6">
376 <div className="max-w-[1800px] mx-auto">
378 <div className="mb-6">
379 <h1 className="text-3xl font-bold text-text mb-2">Basket Analysis</h1>
380 <p className="text-muted">
381 This shows the spread of number of items in a customer basket. If you select a single
382 product, it shows the spread for all sales that contain that item. This allows you to
383 see the upsell effectiveness of a single product. The percentage shown refers to the
384 number of sales that have that many items in a customer basket. Modifiers are not
385 included in basket counts.
390 <div className="bg-surface rounded-lg shadow p-6 mb-6">
391 <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
393 <label className="block text-sm font-medium text-text mb-1">
399 onChange={(e) => setFromDate(e.target.value)}
400 className="w-full px-3 py-2 border border-border rounded-md"
405 <label className="block text-sm font-medium text-text mb-1">
411 onChange={(e) => setToDate(e.target.value)}
412 className="w-full px-3 py-2 border border-border rounded-md"
417 <label className="block text-sm font-medium text-text mb-1">
418 With Product (optional)
422 value={productFilter}
423 onChange={(e) => setProductFilter(e.target.value)}
424 placeholder="Product name (autocomplete disabled)"
425 className="w-full px-3 py-2 border border-border rounded-md"
431 <label className="block text-sm font-medium text-text mb-1">Split By</label>
434 onChange={(e) => setSplitBy(e.target.value)}
435 className="w-full px-3 py-2 border border-border rounded-md"
437 <option value="1">Store</option>
438 <option value="2">Teller</option>
439 <option value="3">Department</option>
444 <div className="mt-4">
448 className="px-6 py-2 bg-brand text-white rounded-md hover:bg-brand/90 disabled:bg-muted/50"
450 {loading ? "Loading..." : "Submit"}
455 {/* Loading Progress */}
456 {loadingProgress && (
457 <div className="bg-surface rounded-lg shadow p-4 mb-6">
458 <div className="flex items-center gap-2">
459 <div className="animate-spin rounded-full h-5 w-5 border-b-2 border-info"></div>
460 <span className="text-text">{loadingProgress}</span>
465 {/* Results Table */}
466 {!loading && data.length > 0 && (
467 <div className="bg-surface rounded-lg shadow overflow-hidden">
468 <div className="overflow-x-auto">
469 <table className="min-w-full divide-y divide-border">
470 <thead className="bg-surface-2">
473 onClick={() => handleSort("name")}
474 className="px-4 py-3 text-left text-xs font-medium text-text uppercase tracking-wider cursor-pointer hover:bg-surface-2"
476 {splitBy === "1" ? "Store" : splitBy === "2" ? "Teller" : "Department"}
477 <SortIndicator column="name" />
480 onClick={() => handleSort("totalSales")}
481 className="px-4 py-3 text-left text-xs font-medium text-text uppercase tracking-wider cursor-pointer hover:bg-surface-2"
484 <SortIndicator column="totalSales" />
487 onClick={() => handleSort("pct1")}
488 className="px-4 py-3 text-left text-xs font-medium text-text uppercase tracking-wider cursor-pointer hover:bg-surface-2"
491 <SortIndicator column="pct1" />
494 onClick={() => handleSort("pct2")}
495 className="px-4 py-3 text-left text-xs font-medium text-text uppercase tracking-wider cursor-pointer hover:bg-surface-2"
498 <SortIndicator column="pct2" />
501 onClick={() => handleSort("pct3")}
502 className="px-4 py-3 text-left text-xs font-medium text-text uppercase tracking-wider cursor-pointer hover:bg-surface-2"
505 <SortIndicator column="pct3" />
508 onClick={() => handleSort("pct4")}
509 className="px-4 py-3 text-left text-xs font-medium text-text uppercase tracking-wider cursor-pointer hover:bg-surface-2"
512 <SortIndicator column="pct4" />
515 onClick={() => handleSort("pct5")}
516 className="px-4 py-3 text-left text-xs font-medium text-text uppercase tracking-wider cursor-pointer hover:bg-surface-2"
519 <SortIndicator column="pct5" />
522 onClick={() => handleSort("pct6")}
523 className="px-4 py-3 text-left text-xs font-medium text-text uppercase tracking-wider cursor-pointer hover:bg-surface-2"
526 <SortIndicator column="pct6" />
529 onClick={() => handleSort("pct7")}
530 className="px-4 py-3 text-left text-xs font-medium text-text uppercase tracking-wider cursor-pointer hover:bg-surface-2"
533 <SortIndicator column="pct7" />
536 onClick={() => handleSort("pct8")}
537 className="px-4 py-3 text-left text-xs font-medium text-text uppercase tracking-wider cursor-pointer hover:bg-surface-2"
540 <SortIndicator column="pct8" />
543 onClick={() => handleSort("pct9")}
544 className="px-4 py-3 text-left text-xs font-medium text-text uppercase tracking-wider cursor-pointer hover:bg-surface-2"
547 <SortIndicator column="pct9" />
550 onClick={() => handleSort("pct10")}
551 className="px-4 py-3 text-left text-xs font-medium text-text uppercase tracking-wider cursor-pointer hover:bg-surface-2"
554 <SortIndicator column="pct10" />
557 onClick={() => handleSort("pct11")}
558 className="px-4 py-3 text-left text-xs font-medium text-text uppercase tracking-wider cursor-pointer hover:bg-surface-2"
561 <SortIndicator column="pct11" />
563 <th className="px-4 py-3 text-left text-xs font-medium text-text uppercase tracking-wider">
568 <tbody className="bg-surface divide-y divide-border">
569 {sortedData.map((row, idx) => (
570 <tr key={idx} className={idx % 2 === 0 ? "bg-surface" : "bg-surface-2"}>
571 <td className="px-4 py-3 text-sm text-text">{row.name}</td>
572 <td className="px-4 py-3 text-sm text-text">{row.totalSales}</td>
573 <td className="px-4 py-3 text-sm text-text">{row.pct1.toFixed(1)}%</td>
574 <td className="px-4 py-3 text-sm text-text">{row.pct2.toFixed(1)}%</td>
575 <td className="px-4 py-3 text-sm text-text">{row.pct3.toFixed(1)}%</td>
576 <td className="px-4 py-3 text-sm text-text">{row.pct4.toFixed(1)}%</td>
577 <td className="px-4 py-3 text-sm text-text">{row.pct5.toFixed(1)}%</td>
578 <td className="px-4 py-3 text-sm text-text">{row.pct6.toFixed(1)}%</td>
579 <td className="px-4 py-3 text-sm text-text">{row.pct7.toFixed(1)}%</td>
580 <td className="px-4 py-3 text-sm text-text">{row.pct8.toFixed(1)}%</td>
581 <td className="px-4 py-3 text-sm text-text">{row.pct9.toFixed(1)}%</td>
582 <td className="px-4 py-3 text-sm text-text">{row.pct10.toFixed(1)}%</td>
583 <td className="px-4 py-3 text-sm text-text">{row.pct11.toFixed(1)}%</td>
584 <td className="px-4 py-3">
585 <BasketChart row={row} />
596 {!loading && data.length === 0 && (
597 <div className="bg-surface rounded-lg shadow p-8 text-center text-muted">
598 No data found. Click Submit to load the report.