/** * Reports Controller * * Handles search, filtering, and chart rendering for the Reports page. * Supports range queries on amounts and invoice numbers. */ import Fuse from 'fuse.js'; interface SalesDataItem { index: number; invoice: string; date: string; customer: string; employee: string; services: string; amount: string; payment: string; status: string; } interface ParsedQuery { textQuery: string; minAmount: number | null; maxAmount: number | null; minInvoice: number | null; maxInvoice: number | null; invoicePrefix: string | null; } interface MonthMapping { year: number; month: number; } interface ChartClickEvent extends CustomEvent { detail: { type: 'line' | 'bar' | 'pie'; x?: string; points: Array<{ id?: string; seriesName: string; value: number; color: string; unit?: string; percent?: number }>; }; } interface ChartSelectEvent extends CustomEvent { detail: { type: 'line' | 'bar'; points: Array<{ id?: string; seriesName: string; seriesIndex: number; value: number; color: string; unit?: string }>; bounds: { x1: string; x2: string; y1: number; y2: number }; }; } export class ReportsController { private searchInput: HTMLInputElement | null = null; private dateFromInput: HTMLInputElement | null = null; private dateToInput: HTMLInputElement | null = null; private statusFilter: HTMLSelectElement | null = null; private paymentFilter: HTMLSelectElement | null = null; private tableRows: NodeListOf | null = null; private salesData: SalesDataItem[] = []; private fuse: Fuse | null = null; // Map pie chart series names to payment filter values private readonly paymentMap: Record = { 'Kort': 'card', 'MobilePay': 'mobilepay', 'Kontant': 'cash', 'Faktura': 'invoice', 'Fordelskort': 'giftcard', }; // Map status badge text to filter values private readonly statusMap: Record = { 'Betalt': 'paid', 'Afventer': 'pending', 'Krediteret': 'credited', }; // Map payment badge text to filter values private readonly paymentTextMap: Record = { 'Kort': 'card', 'MobilePay': 'mobilepay', 'Kontant': 'cash', 'Faktura': 'invoice', 'Fordelskort': 'giftcard', }; // Map month names to year/month (based on 2024/2025 fiscal year) private readonly monthMap: Record = { 'Feb': { year: 2024, month: 2 }, 'Mar': { year: 2024, month: 3 }, 'Apr': { year: 2024, month: 4 }, 'Maj': { year: 2024, month: 5 }, 'Jun': { year: 2024, month: 6 }, 'Jul': { year: 2024, month: 7 }, 'Aug': { year: 2024, month: 8 }, 'Sep': { year: 2024, month: 9 }, 'Okt': { year: 2024, month: 10 }, 'Nov': { year: 2024, month: 11 }, 'Dec': { year: 2024, month: 12 }, 'Jan': { year: 2025, month: 1 }, }; constructor() { this.searchInput = document.getElementById('searchInput') as HTMLInputElement | null; this.dateFromInput = document.getElementById('dateFrom') as HTMLInputElement | null; this.dateToInput = document.getElementById('dateTo') as HTMLInputElement | null; this.statusFilter = document.getElementById('statusFilter') as HTMLSelectElement | null; this.paymentFilter = document.getElementById('paymentFilter') as HTMLSelectElement | null; this.tableRows = document.querySelectorAll('swp-card.sales-table swp-data-table-row'); if (this.tableRows?.length) { this.buildSearchData(); this.initializeFuse(); this.setupListeners(); this.setupFilterListeners(); } this.setupTabs(); this.setupChartEvents(); } /** * Build searchable data from table rows */ private buildSearchData(): void { if (!this.tableRows) return; this.salesData = Array.from(this.tableRows).map((row, index) => { const cells = row.querySelectorAll('swp-data-table-cell'); return { index, invoice: cells[0]?.textContent?.trim() || '', date: cells[1]?.textContent?.trim() || '', customer: cells[2]?.textContent?.trim() || '', employee: cells[3]?.textContent?.trim() || '', services: cells[4]?.textContent?.trim() || '', amount: cells[5]?.textContent?.trim() || '', payment: cells[6]?.textContent?.trim() || '', status: cells[7]?.textContent?.trim() || '' }; }); } /** * Initialize Fuse.js for fuzzy text search */ private initializeFuse(): void { this.fuse = new Fuse(this.salesData, { keys: ['invoice', 'customer', 'employee', 'services', 'amount', 'payment', 'status'], threshold: 0.3, ignoreLocation: true }); } /** * Setup search event listener */ private setupListeners(): void { this.searchInput?.addEventListener('input', (e) => this.handleSearch(e)); } /** * Setup filter event listeners (date, status, payment) */ private setupFilterListeners(): void { this.dateFromInput?.addEventListener('change', () => this.applyAllFilters()); this.dateToInput?.addEventListener('change', () => this.applyAllFilters()); this.statusFilter?.addEventListener('change', () => this.applyAllFilters()); this.paymentFilter?.addEventListener('change', () => this.applyAllFilters()); } /** * Apply all filters (search + date + status + payment) */ private applyAllFilters(): void { const searchQuery = this.searchInput?.value.trim() || ''; const dateFrom = this.dateFromInput?.value || ''; const dateTo = this.dateToInput?.value || ''; const statusValue = this.statusFilter?.value || ''; const paymentValue = this.paymentFilter?.value || ''; // Start with all indices let matchedIndices = new Set(this.salesData.map((_, i) => i)); // Apply search filter (includes range queries) if (searchQuery) { const parsed = this.parseRangeQuery(searchQuery); if (parsed.invoicePrefix !== null) { matchedIndices = this.filterByInvoicePrefix(parsed.invoicePrefix); } if (parsed.minInvoice !== null || parsed.maxInvoice !== null) { matchedIndices = this.filterByInvoiceRange(matchedIndices, parsed.minInvoice, parsed.maxInvoice); } if (parsed.minAmount !== null || parsed.maxAmount !== null) { matchedIndices = this.filterByAmountRange(matchedIndices, parsed.minAmount, parsed.maxAmount); } if (parsed.textQuery) { matchedIndices = this.filterByText(matchedIndices, parsed.textQuery); } } // Apply date filter if (dateFrom || dateTo) { matchedIndices = this.filterByDate(matchedIndices, dateFrom, dateTo); } // Apply status filter if (statusValue) { matchedIndices = this.filterByStatus(matchedIndices, statusValue); } // Apply payment filter if (paymentValue) { matchedIndices = this.filterByPayment(matchedIndices, paymentValue); } this.applyFilter(matchedIndices); } /** * Filter by date range */ private filterByDate(indices: Set, fromDate: string, toDate: string): Set { return new Set( [...indices].filter(i => { const dateText = this.salesData[i].date; const rowDate = this.parseDanishDate(dateText); if (!rowDate) return true; // Keep row if date can't be parsed if (fromDate) { const from = new Date(fromDate); if (rowDate < from) return false; } if (toDate) { const to = new Date(toDate); to.setHours(23, 59, 59, 999); // Include the entire end day if (rowDate > to) return false; } return true; }) ); } /** * Parse Danish date format "6. jan 2025" to Date object */ private parseDanishDate(text: string): Date | null { const monthNames: Record = { 'jan': 0, 'feb': 1, 'mar': 2, 'apr': 3, 'maj': 4, 'jun': 5, 'jul': 6, 'aug': 7, 'sep': 8, 'okt': 9, 'nov': 10, 'dec': 11 }; const match = text.match(/(\d+)\.\s*(\w+)\s*(\d{4})/); if (!match) return null; const day = parseInt(match[1], 10); const monthStr = match[2].toLowerCase(); const year = parseInt(match[3], 10); const month = monthNames[monthStr]; if (month === undefined) return null; return new Date(year, month, day); } /** * Filter by status */ private filterByStatus(indices: Set, statusValue: string): Set { return new Set( [...indices].filter(i => { const statusText = this.salesData[i].status.trim(); const mappedStatus = this.statusMap[statusText]; return mappedStatus === statusValue; }) ); } /** * Filter by payment method */ private filterByPayment(indices: Set, paymentValue: string): Set { return new Set( [...indices].filter(i => { const paymentText = this.salesData[i].payment.trim(); // Payment text is like "Kort", "MobilePay", etc. const mappedPayment = this.paymentTextMap[paymentText]; return mappedPayment === paymentValue; }) ); } /** * Setup tab switching functionality */ private setupTabs(): void { const tabs = document.querySelectorAll('swp-tab[data-tab]'); tabs.forEach(tab => { tab.addEventListener('click', () => { const targetTab = tab.dataset.tab; if (targetTab) { this.switchToTab(targetTab); } }); }); } /** * Switch to a specific tab by name */ private switchToTab(targetTab: string): void { const tabs = document.querySelectorAll('swp-tab[data-tab]'); const contents = document.querySelectorAll('swp-tab-content[data-tab]'); tabs.forEach(t => { t.classList.toggle('active', t.dataset.tab === targetTab); }); contents.forEach(content => { content.classList.toggle('active', content.dataset.tab === targetTab); }); } /** * Handle search input */ private handleSearch(_e: Event): void { this.applyAllFilters(); } /** * Show all rows */ private showAllRows(): void { this.tableRows?.forEach(row => row.style.display = ''); } /** * Apply filter to table rows */ private applyFilter(matchedIndices: Set): void { this.tableRows?.forEach((row, index) => { row.style.display = matchedIndices.has(index) ? '' : 'none'; }); } /** * Filter by invoice prefix (e.g., #18 matches #1847, #1846) */ private filterByInvoicePrefix(prefix: string): Set { return new Set( this.salesData .filter(item => { const invoiceDigits = item.invoice.replace(/\D/g, ''); return invoiceDigits.startsWith(prefix); }) .map(item => item.index) ); } /** * Filter by invoice number range */ private filterByInvoiceRange( indices: Set, min: number | null, max: number | null ): Set { return new Set( [...indices].filter(i => { const invoiceNum = this.parseInvoiceNumber(this.salesData[i].invoice); if (invoiceNum === null) return false; if (min !== null && invoiceNum < min) return false; if (max !== null && invoiceNum > max) return false; return true; }) ); } /** * Filter by amount range */ private filterByAmountRange( indices: Set, min: number | null, max: number | null ): Set { return new Set( [...indices].filter(i => { const amount = this.parseAmountFromText(this.salesData[i].amount); if (amount === null) return false; if (min !== null && amount < min) return false; if (max !== null && amount > max) return false; return true; }) ); } /** * Filter by text using Fuse.js */ private filterByText(indices: Set, query: string): Set { if (!this.fuse) return indices; const fuseResults = this.fuse.search(query); const textMatches = new Set(fuseResults.map(r => r.item.index)); return new Set([...indices].filter(i => textMatches.has(i))); } /** * Parse amount string "1.450 kr" -> 1450 */ private parseAmountFromText(text: string): number | null { const match = text.match(/-?([\d.]+)/); if (!match) return null; return parseFloat(match[1].replace(/\./g, '')); } /** * Parse invoice number "#1847" -> 1847 */ private parseInvoiceNumber(text: string): number | null { const match = text.match(/#(\d+)/); return match ? parseInt(match[1], 10) : null; } /** * Parse range operators from query * Supports: * - Amount ranges: >1000, <500, >=500, <=1000, 400-1000 * - Invoice ranges: #>1845, #<1845, #>=1845, #<=1845, #1840-1845 * - Invoice prefix: #1847 (matches invoices starting with "1847") * - Combined: Maria >1000, #>1845 <500 */ private parseRangeQuery(query: string): ParsedQuery { let textQuery = query; let minAmount: number | null = null; let maxAmount: number | null = null; let minInvoice: number | null = null; let maxInvoice: number | null = null; let invoicePrefix: string | null = null; // === INVOICE NUMBER RANGES (with # prefix) === // Match #1840-1845 (range) let match = textQuery.match(/#(\d+)-(\d+)/); if (match) { minInvoice = parseInt(match[1], 10); maxInvoice = parseInt(match[2], 10); textQuery = textQuery.replace(match[0], '').trim(); } // Match #>= match = textQuery.match(/#>=\s*(\d+)/); if (match) { minInvoice = parseInt(match[1], 10); textQuery = textQuery.replace(match[0], '').trim(); } // Match #> (but not #>=) if (!match) { match = textQuery.match(/#>\s*(\d+)/); if (match) { minInvoice = parseInt(match[1], 10) + 1; textQuery = textQuery.replace(match[0], '').trim(); } } // Match #<= match = textQuery.match(/#<=\s*(\d+)/); if (match) { maxInvoice = parseInt(match[1], 10); textQuery = textQuery.replace(match[0], '').trim(); } // Match #< (but not #<=) if (!match) { match = textQuery.match(/#<\s*(\d+)/); if (match) { maxInvoice = parseInt(match[1], 10) - 1; textQuery = textQuery.replace(match[0], '').trim(); } } // Match #1847 (no operator) - prefix match match = textQuery.match(/#(\d+)(?!\d|-)/); if (match) { invoicePrefix = match[1]; textQuery = textQuery.replace(/#\d+/, '').trim(); } // === AMOUNT RANGES (no prefix) === // Match range syntax: 400-1000 match = textQuery.match(/(\d+)-(\d+)/); if (match) { minAmount = parseFloat(match[1]); maxAmount = parseFloat(match[2]); textQuery = textQuery.replace(match[0], '').trim(); } // Match >= match = textQuery.match(/>=\s*(\d+(?:\.\d+)?)/); if (match) { minAmount = parseFloat(match[1]); textQuery = textQuery.replace(match[0], '').trim(); } // Match > (but not >=) match = textQuery.match(/>\s*(\d+(?:\.\d+)?)/); if (match) { minAmount = parseFloat(match[1]) + 0.01; textQuery = textQuery.replace(match[0], '').trim(); } // Match <= match = textQuery.match(/<=\s*(\d+(?:\.\d+)?)/); if (match) { maxAmount = parseFloat(match[1]); textQuery = textQuery.replace(match[0], '').trim(); } // Match < (but not <=) match = textQuery.match(/<\s*(\d+(?:\.\d+)?)/); if (match) { maxAmount = parseFloat(match[1]) - 0.01; textQuery = textQuery.replace(match[0], '').trim(); } return { textQuery, minAmount, maxAmount, minInvoice, maxInvoice, invoicePrefix }; } /** * Setup chart click and selection events */ private setupChartEvents(): void { // Single click on chart document.addEventListener('swp-chart-click', (e: Event) => { const event = e as ChartClickEvent; const { type, x, points } = event.detail; // Bar chart click -> filter by month if (type === 'bar' && x) { const month = this.monthMap[x]; if (month) { this.setDateFilter(month, month); } } // Pie chart click -> filter by payment method if (type === 'pie' && points?.length > 0) { const seriesName = points[0].seriesName; const paymentValue = this.paymentMap[seriesName]; if (paymentValue && this.paymentFilter) { this.paymentFilter.value = paymentValue; this.applyAllFilters(); } } }); // Range selection on bar chart -> filter by multiple months document.addEventListener('swp-chart-select', (e: Event) => { const event = e as ChartSelectEvent; const { type, bounds } = event.detail; if (type !== 'bar' || !bounds) return; const startMonth = this.monthMap[bounds.x1]; const endMonth = this.monthMap[bounds.x2]; if (startMonth && endMonth) { this.setDateFilter(startMonth, endMonth); } }); } /** * Set the date filter inputs to a specific month range */ private setDateFilter(startMonth: MonthMapping, endMonth: MonthMapping): void { if (!this.dateFromInput || !this.dateToInput) return; // Calculate last day of the end month const lastDay = new Date(endMonth.year, endMonth.month, 0).getDate(); // Format dates as YYYY-MM-DD const fromDate = `${startMonth.year}-${String(startMonth.month).padStart(2, '0')}-01`; const toDate = `${endMonth.year}-${String(endMonth.month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`; this.dateFromInput.value = fromDate; this.dateToInput.value = toDate; // Apply filters to update the table this.applyAllFilters(); } }