3import { useState, useEffect, useCallback } from 'react';
4import { apiClient } from '@/lib/client/apiClient';
5import { useStore } from '@/contexts/StoreContext';
20interface SalesCubeItem {
21 f100: number; // Location ID
22 f101: number; // Department ID
23 f102: string; // Date info (format: "something|something|day")
24 f201: number; // Sales amount
25 f202?: number; // Cost/GP amount
28interface DailyTotals {
30 c1: number; // Recharge Toner
31 c2: number; // Recharge Ink
32 c3: number; // Compatible CW Toner
33 c4: number; // Compatible Other Toner
34 c5: number; // Compatible CW Inks
35 c6: number; // Compatible Other Inks
36 c7: number; // New Toner
37 c8: number; // New Ink
40 c11: number; // Hardware & Software
41 c12: number; // H&S Gross Profit
42 c13: number; // Excluded Sales
47export default function FranchiseReportPage() {
48 const { session } = useStore();
49 const [selectedMonth, setSelectedMonth] = useState<number>(new Date().getMonth() + 1);
50 const [selectedYear, setSelectedYear] = useState<number>(new Date().getFullYear());
51 const [locations, setLocations] = useState<Location[]>([]);
52 const [departments, setDepartments] = useState<Department[]>([]);
53 const [currentTab, setCurrentTab] = useState<number>(0);
54 const [currentLocId, setCurrentLocId] = useState<number>(0);
55 const [currentLocName, setCurrentLocName] = useState<string>('All Stores');
56 const [dailyTotals, setDailyTotals] = useState<DailyTotals>({});
57 const [columnTotals, setColumnTotals] = useState<number[]>(Array(14).fill(0));
58 const [loading, setLoading] = useState(false);
59 const [salesCube, setSalesCube] = useState<any>(null);
62 'January', 'February', 'March', 'April', 'May', 'June',
63 'July', 'August', 'September', 'October', 'November', 'December'
66 // Initialize location for POS users to current store
69 const isPosUser = session.store.type !== 'management';
71 setCurrentLocId(Number(session.store.id) || 0);
72 setCurrentLocName(session.store.name || 'Current Store');
77 const loadLocations = useCallback(async () => {
79 console.log('[Franchise Report] Loading locations...');
80 const response = await apiClient.get('/api/v1/locations');
81 console.log('[Franchise Report] Locations response:', response);
82 if (response && Array.isArray(response)) {
83 setLocations(response);
84 console.log('[Franchise Report] Loaded locations:', response.length);
87 console.error('[Franchise Report] Error loading locations:', error);
91 const loadDepartments = useCallback(async () => {
93 console.log('[Franchise Report] Loading departments...');
94 const response = await apiClient.get('/api/v1/departments');
95 console.log('[Franchise Report] Departments response:', response);
96 if (response && Array.isArray(response)) {
97 setDepartments(response);
98 console.log('[Franchise Report] Loaded departments:', response.length);
101 console.error('[Franchise Report] Error loading departments:', error);
105 const runReport = useCallback(async () => {
106 console.log('[Franchise Report] Running report...', { selectedMonth, selectedYear });
109 // Build date range in the format expected by BUCK API
110 const fromDate = `1-${selectedMonth}-${selectedYear}`;
111 let nextMonth = selectedMonth + 1;
112 let nextYear = selectedYear;
113 if (nextMonth === 13) {
117 const toDate = `1-${nextMonth}-${nextYear}`;
120 console.log('[Franchise Report] Fetching sales cube...', { fromDate, toDate });
122 // The sales-cube API route will handle the BUCK formatting
123 // Pass mode=5 and date range
124 const response = await apiClient.get(
125 `/v1/reports/sales-cube?mode=5&fromDate=${fromDate}&toDate=${toDate}`
127 console.log('[Franchise Report] Sales cube response:', response);
129 if (response?.APPD && response.APPD.length > 0) {
130 setSalesCube(response);
131 processSalesCube(response.APPD);
132 console.log('[Franchise Report] Processed sales cube data');
134 console.warn('[Franchise Report] No sales data found for this period');
135 // Initialize empty data so the table shows zeros
136 setSalesCube(response);
137 processSalesCube([]);
140 console.error('[Franchise Report] Error loading sales data:', error);
144 }, [selectedMonth, selectedYear]);
146 // Load locations and departments on mount
150 }, [loadLocations, loadDepartments]);
152 // Auto-run report when locations and departments are loaded
154 if (locations.length > 0 && departments.length > 0) {
155 console.log('[Franchise Report] Auto-running report with', locations.length, 'locations and', departments.length, 'departments');
158 }, [locations.length, departments.length, selectedMonth, selectedYear, runReport]);
160 const processSalesCube = (cubeData: SalesCubeItem[]) => {
161 console.log('[processSalesCube] Processing cube data', {
162 itemCount: cubeData.length,
165 sessionStoreId: session?.store?.id,
166 isPosUser: session?.store?.type !== 'management'
169 // Initialize daily totals
170 const dailyData: DailyTotals = {};
171 for (let day = 1; day <= 31; day++) {
173 c1: 0, c2: 0, c3: 0, c4: 0, c5: 0, c6: 0, c7: 0, c8: 0,
174 c9: 0, c10: 0, c11: 0, c12: 0, c13: 0, c12_errors: 0
178 // Determine which locations to include
179 let matchLocs: number[] = [];
180 if (currentLocId < 0) {
181 const loc = locations.find(l => l.f100 === currentLocId);
183 matchLocs = loc.f182.split('|').map(Number);
187 // Process each cube item
188 cubeData.forEach((item: SalesCubeItem) => {
189 const depId = item.f101;
190 const dayOfMonth = parseInt(item.f102.split('|')[2]);
191 const amount = item.f201;
192 const cost = item.f202 || 0;
194 console.log('[processSalesCube] Processing item', {
195 locationId: item.f100,
203 // Check if this location should be included
204 // For POS users on "Current Store" tab (currentTab === 0), currentLocId will be their store ID
205 // For management on "All Stores" tab, currentLocId will be 0
206 const includeLocation =
207 currentLocId === 0 ||
208 currentLocId === item.f100 ||
209 (matchLocs.length > 0 && matchLocs.includes(item.f100));
211 if (!includeLocation) return;
212 if (dayOfMonth < 1 || dayOfMonth > 31) return;
214 // Map department to column based on department ID
215 // Fieldpine uses LocAll arrays which map to display columns differently
216 let columnKey: keyof Omit<DailyTotals[number], 'c12_errors'> | null = null;
219 case 1: columnKey = 'c3'; break; // Dept 1 → LocAll3 → Compatible CW Toner
220 case 2: columnKey = 'c5'; break; // Dept 2 → LocAll5 → Compatible CW Inks
221 case 3: columnKey = 'c1'; break; // Dept 3 → LocAll1 → Recharge Toner
222 case 4: columnKey = 'c2'; break; // Dept 4 → LocAll2 → Recharge Ink
223 case 5: columnKey = 'c9'; break; // Dept 5 → LocAll9 → Other/Paper
231 columnKey = 'c11'; break; // → LocAll11 → Hardware & Software
232 case 8: columnKey = 'c7'; break; // Dept 8 → LocAll7 → New Toner
233 case 10: columnKey = 'c8'; break; // Dept 10 → LocAll8 → New Ink
234 case 13: columnKey = 'c9'; break; // Dept 13 → LocAll9 → Other
235 case 14: columnKey = 'c10'; break; // Dept 14 → LocAll10 → R&M
237 columnKey = 'c13'; break; // → LocAll13 → Excluded
240 console.log('[processSalesCube] Mapped dept', depId, 'to column', columnKey, 'amount', amount);
243 dailyData[dayOfMonth][columnKey] += amount;
245 // Handle Hardware/Software gross profit
246 if (columnKey === 'c11') {
247 dailyData[dayOfMonth].c12 += cost;
248 if (cost <= 0 || cost > amount) {
249 dailyData[dayOfMonth].c12_errors++;
255 // Calculate hardware gross profit (c11 - c12)
256 Object.keys(dailyData).forEach(day => {
257 const d = parseInt(day);
258 dailyData[d].c12 = dailyData[d].c11 - dailyData[d].c12;
261 // Calculate column totals
262 const totals = Array(14).fill(0);
263 Object.values(dailyData).forEach(day => {
273 totals[10] += day.c10;
274 totals[11] += day.c11;
275 totals[12] += day.c12;
276 totals[13] += day.c13;
280 totals[0] = totals[1] + totals[2] + totals[3] + totals[4] + totals[5] +
281 totals[6] + totals[7] + totals[8] + totals[9] + totals[10] + totals[11];
283 setDailyTotals(dailyData);
284 setColumnTotals(totals);
287 const switchLocation = (tabIndex: number) => {
288 setCurrentTab(tabIndex);
290 // For POS users, "All Stores" should be the current store
291 const isPosUser = session?.store?.type !== 'management';
293 if (tabIndex === 0) {
295 // Set to current store for POS users
296 setCurrentLocId(Number(session?.store?.id) || 0);
297 setCurrentLocName(session?.store?.name || 'Current Store');
299 // Set to all stores for management users
301 setCurrentLocName('All Stores');
303 } else if (tabIndex - 1 < locations.length) {
304 const loc = locations[tabIndex - 1];
305 setCurrentLocId(loc.f100);
306 setCurrentLocName(loc.f101);
309 // Re-process with new location filter
310 if (salesCube?.APPD) {
311 processSalesCube(salesCube.APPD);
315 const getDaysInMonth = (month: number, year: number) => {
316 return new Date(year, month, 0).getDate();
319 const getDayName = (day: number, month: number, year: number) => {
320 const date = new Date(year, month - 1, day);
321 const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
322 return days[date.getDay()];
325 const isWeekend = (day: number, month: number, year: number) => {
326 const date = new Date(year, month - 1, day);
327 const dayOfWeek = date.getDay();
328 return dayOfWeek === 0 || dayOfWeek === 6;
331 const formatCurrency = (value: number) => {
332 return value.toFixed(2);
335 // Calculate totals for bottom section
336 const totalInk = columnTotals[2] + columnTotals[5] + columnTotals[6] + columnTotals[8];
337 const totalToner = columnTotals[1] + columnTotals[3] + columnTotals[4] + columnTotals[7];
338 const totalHardSoft = columnTotals[11];
339 const totalOther = columnTotals[9] + columnTotals[10];
340 const totalHSGP = columnTotals[12];
341 const grandTotal = columnTotals[0];
343 const franchiseFee = grandTotal * 0.06;
344 const franchiseFeeGST = franchiseFee * 0.10;
345 const franchiseFeeTotal = franchiseFee + franchiseFeeGST;
347 const adLevy = grandTotal * 0.03;
348 const adLevyGST = adLevy * 0.10;
349 const adLevyTotal = adLevy + adLevyGST;
351 const daysInMonth = getDaysInMonth(selectedMonth, selectedYear);
354 <div className="p-6">
363 print-color-adjust: exact;
364 -webkit-print-color-adjust: exact;
368 overflow: visible !important;
369 max-height: none !important;
370 height: auto !important;
374 display: none !important;
378 width: 100% !important;
379 max-width: 100% !important;
380 box-shadow: none !important;
381 border-radius: 0 !important;
382 overflow: visible !important;
383 max-height: none !important;
384 height: auto !important;
388 display: block !important;
389 box-shadow: none !important;
390 border-radius: 0 !important;
391 margin-top: 10pt !important;
392 page-break-inside: avoid;
396 font-size: 7pt !important;
397 width: 100% !important;
401 padding: 1px 3px !important;
405 page-break-inside: avoid;
411 <div className="mb-6">
412 <div className="mb-4">
413 <h1 className="text-2xl font-bold text-text">Franchise Report</h1>
414 <p className="text-sm text-muted">Report Version 1 Sep 2023</p>
416 <p className="text-sm text-danger font-semibold mb-4">
417 Please validate this report is correct before using results.
422 <div className="bg-surface p-4 rounded-lg shadow border border-border mb-6 print-hide">
423 <div className="flex items-center gap-4">
425 value={selectedMonth}
426 onChange={(e) => setSelectedMonth(Number(e.target.value))}
427 className="border border-border rounded px-3 py-2 bg-surface text-text focus:ring-2 focus:ring-brand"
429 {monthNames.map((name, idx) => (
430 <option key={idx} value={idx + 1}>{name}</option>
437 onChange={(e) => setSelectedYear(Number(e.target.value))}
438 className="border border-border rounded px-3 py-2 w-24 bg-surface text-text focus:ring-2 focus:ring-brand"
444 className="bg-brand text-white px-4 py-2 rounded hover:opacity-90 disabled:opacity-50"
446 {loading ? 'Loading...' : 'Run'}
450 onClick={() => window.print()}
451 className="bg-brand text-white px-4 py-2 rounded hover:opacity-90"
458 {/* Location Tabs */}
459 <div className="mb-6 print-hide">
460 <div className="flex gap-2 flex-wrap border-b border-border">
462 onClick={() => switchLocation(0)}
463 className={`px-4 py-2 ${
465 ? 'border-b-2 border-brand font-semibold text-brand'
466 : 'text-muted hover:text-text'
469 {session?.store?.type === 'management' ? 'All Stores' : 'Current Store'}
471 {locations.map((loc, idx) => (
474 onClick={() => switchLocation(idx + 1)}
475 className={`px-4 py-2 ${
476 currentTab === idx + 1
477 ? 'border-b-2 border-brand font-semibold text-brand'
478 : 'text-muted hover:text-text'
479 } ${loc.f180 === 1 ? 'text-sm' : ''}`}
488 <div className="bg-surface rounded-lg shadow overflow-auto border border-border mb-6 max-h-[600px] print-full-width">
489 <table className="w-full text-sm border-collapse">
491 <tr className="bg-surface-2 border-b-2 border-border">
492 <th rowSpan={2} className="border border-border px-2 py-2 text-center align-bottom">
495 <th colSpan={2} className="border border-border px-2 py-1 text-center">
498 <th colSpan={4} className="border border-border px-2 py-1 text-center">
501 <th colSpan={2} className="border border-border px-2 py-1 text-center">
504 <th colSpan={2} className="border border-border px-2 py-1 text-center">
507 <th rowSpan={2} className="border border-border px-2 py-2 text-center align-bottom">
508 Hardware &<br />Software
510 <th rowSpan={2} className="border border-border px-2 py-2 text-center align-bottom">
511 H&S<br />Gross Profit
513 <th rowSpan={2} className="border border-border px-2 py-2 text-center align-bottom">
516 <th rowSpan={2} className="border border-border px-2 py-2 text-center align-bottom">
520 <tr className="bg-surface-2 border-b-2 border-border">
521 <th className="border border-border px-2 py-1 text-center">Toner</th>
522 <th className="border border-border px-2 py-1 text-center">Ink</th>
523 <th className="border border-border px-2 py-1 text-center">CW Toner</th>
524 <th className="border border-border px-2 py-1 text-center">Other Toner</th>
525 <th className="border border-border px-2 py-1 text-center">CW Inks</th>
526 <th className="border border-border px-2 py-1 text-center">Other Inks</th>
527 <th className="border border-border px-2 py-1 text-center">Toner</th>
528 <th className="border border-border px-2 py-1 text-center">Ink</th>
529 <th className="border border-border px-2 py-1 text-center">Other</th>
530 <th className="border border-border px-2 py-1 text-center">R&M</th>
534 {Array.from({ length: daysInMonth }, (_, i) => i + 1).map(day => {
535 const dayData = dailyTotals[day] || {
536 c1: 0, c2: 0, c3: 0, c4: 0, c5: 0, c6: 0, c7: 0, c8: 0,
537 c9: 0, c10: 0, c11: 0, c12: 0, c13: 0, c12_errors: 0
539 const weekend = isWeekend(day, selectedMonth, selectedYear);
540 const dayName = getDayName(day, selectedMonth, selectedYear);
541 const totalForDay = dayData.c1 + dayData.c2 + dayData.c3 + dayData.c4 +
542 dayData.c5 + dayData.c6 + dayData.c7 + dayData.c8 +
543 dayData.c9 + dayData.c10 + dayData.c11;
546 <tr key={day} className={weekend ? 'bg-surface-2/50' : 'bg-surface'}>
547 <td className="border border-border px-2 py-1 text-right text-text">
550 <td className="border border-border px-2 py-1 text-right text-text">{formatCurrency(dayData.c1)}</td>
551 <td className="border border-border px-2 py-1 text-right text-text">{formatCurrency(dayData.c2)}</td>
552 <td className="border border-border px-2 py-1 text-right text-text">{formatCurrency(dayData.c3)}</td>
553 <td className="border border-border px-2 py-1 text-right text-text">{formatCurrency(dayData.c4)}</td>
554 <td className="border border-border px-2 py-1 text-right text-text">{formatCurrency(dayData.c5)}</td>
555 <td className="border border-border px-2 py-1 text-right text-text">{formatCurrency(dayData.c6)}</td>
556 <td className="border border-border px-2 py-1 text-right text-text">{formatCurrency(dayData.c7)}</td>
557 <td className="border border-border px-2 py-1 text-right text-text">{formatCurrency(dayData.c8)}</td>
558 <td className="border border-border px-2 py-1 text-right text-text">{formatCurrency(dayData.c9)}</td>
559 <td className="border border-border px-2 py-1 text-right text-text">{formatCurrency(dayData.c10)}</td>
560 <td className="border border-border px-2 py-1 text-right text-text">{formatCurrency(dayData.c11)}</td>
562 className={`border border-border px-2 py-1 text-right text-text ${
563 dayData.c12_errors > 0 ? 'bg-danger/20' : ''
566 {formatCurrency(dayData.c12)}
568 <td className="border border-border px-2 py-1 text-right text-text">{formatCurrency(dayData.c13)}</td>
569 <td className="border border-border px-2 py-1 text-right font-semibold text-text">
570 {formatCurrency(totalForDay)}
577 <tr className="bg-surface-2 font-bold border-t-2 border-border">
578 <td className="border border-border px-2 py-2 text-right text-text">Total</td>
579 <td className="border border-border px-2 py-2 text-right text-text">{formatCurrency(columnTotals[1])}</td>
580 <td className="border border-border px-2 py-2 text-right text-text">{formatCurrency(columnTotals[2])}</td>
581 <td className="border border-border px-2 py-2 text-right text-text">{formatCurrency(columnTotals[3])}</td>
582 <td className="border border-border px-2 py-2 text-right text-text">{formatCurrency(columnTotals[4])}</td>
583 <td className="border border-border px-2 py-2 text-right text-text">{formatCurrency(columnTotals[5])}</td>
584 <td className="border border-border px-2 py-2 text-right text-text">{formatCurrency(columnTotals[6])}</td>
585 <td className="border border-border px-2 py-2 text-right text-text">{formatCurrency(columnTotals[7])}</td>
586 <td className="border border-border px-2 py-2 text-right text-text">{formatCurrency(columnTotals[8])}</td>
587 <td className="border border-border px-2 py-2 text-right text-text">{formatCurrency(columnTotals[9])}</td>
588 <td className="border border-border px-2 py-2 text-right text-text">{formatCurrency(columnTotals[10])}</td>
589 <td className="border border-border px-2 py-2 text-right text-text">{formatCurrency(columnTotals[11])}</td>
590 <td className="border border-border px-2 py-2 text-right text-text">{formatCurrency(columnTotals[12])}</td>
591 <td className="border border-border px-2 py-2 text-right text-text">{formatCurrency(columnTotals[13])}</td>
592 <td className="border border-border px-2 py-2 text-right text-text">{formatCurrency(grandTotal)}</td>
598 {/* Summary Calculations */}
599 <div className="bg-surface p-6 rounded-lg shadow border border-border print-show">
600 <table className="w-full max-w-2xl">
603 <td className="py-2">Total Ink Sales</td>
605 <td className="text-right font-semibold">{formatCurrency(totalInk)}</td>
608 <td className="py-2">Total Toner Sales</td>
610 <td className="text-right font-semibold">{formatCurrency(totalToner)}</td>
613 <td className="py-2">Total Hard/Software Sales</td>
615 <td className="text-right font-semibold italic">{formatCurrency(totalHardSoft)}</td>
618 <td className="py-2">Total Other and R&M Sales</td>
620 <td className="text-right font-semibold">{formatCurrency(totalOther)}</td>
623 <td className="py-2">Total H&S Gross Profit</td>
625 <td className="text-right font-semibold">{formatCurrency(totalHSGP)}</td>
628 <td className="py-2"></td>
629 <td className="bg-surface-2 px-3 text-text">Total Excluding GST</td>
630 <td className="bg-surface-2 text-right font-bold px-3 text-text">{formatCurrency(grandTotal)}</td>
634 <td colSpan={3} className="py-4"><hr /></td>
638 <td className="py-2">Franchise Fee</td>
639 <td className="bg-surface-2 px-3 text-text">{formatCurrency(grandTotal)} x 6%</td>
640 <td className="text-right font-semibold">{formatCurrency(franchiseFee)}</td>
644 <td className="px-3">Plus GST 10%</td>
645 <td className="text-right font-semibold">{formatCurrency(franchiseFeeGST)}</td>
649 <td className="px-3">Total Payable</td>
650 <td className="text-right font-bold text-lg">$ {formatCurrency(franchiseFeeTotal)}</td>
654 <td colSpan={3} className="py-4"><hr /></td>
658 <td className="py-2">Advertising Levy</td>
659 <td className="bg-surface-2 px-3 text-text">{formatCurrency(grandTotal)} x 3%</td>
660 <td className="text-right font-semibold">{formatCurrency(adLevy)}</td>
664 <td className="px-3">Plus GST 10%</td>
665 <td className="text-right font-semibold">{formatCurrency(adLevyGST)}</td>
669 <td className="px-3">Total Payable</td>
670 <td className="text-right font-bold text-lg">$ {formatCurrency(adLevyTotal)}</td>
680 print-color-adjust: exact;
681 -webkit-print-color-adjust: exact;
684 display: none !important;