/** * 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; } export class ReportsController { private searchInput: HTMLInputElement | null = null; private tableRows: NodeListOf | null = null; private salesData: SalesDataItem[] = []; private fuse: Fuse | null = null; constructor() { this.searchInput = document.getElementById('searchInput') as HTMLInputElement | null; this.tableRows = document.querySelectorAll('swp-card.sales-table swp-data-table-row'); if (this.searchInput && this.tableRows?.length) { this.buildSearchData(); this.initializeFuse(); this.setupListeners(); } this.setupTabs(); } /** * 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 event listeners */ private setupListeners(): void { this.searchInput?.addEventListener('input', (e) => this.handleSearch(e)); } /** * 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 { const target = e.target as HTMLInputElement; const query = target.value.trim(); if (!query) { this.showAllRows(); return; } const parsed = this.parseRangeQuery(query); let matchedIndices = new Set(this.salesData.map((_, i) => i)); // Apply invoice prefix filter if (parsed.invoicePrefix !== null) { matchedIndices = this.filterByInvoicePrefix(parsed.invoicePrefix); } // Apply invoice number range filter if (parsed.minInvoice !== null || parsed.maxInvoice !== null) { matchedIndices = this.filterByInvoiceRange(matchedIndices, parsed.minInvoice, parsed.maxInvoice); } // Apply amount range filter if (parsed.minAmount !== null || parsed.maxAmount !== null) { matchedIndices = this.filterByAmountRange(matchedIndices, parsed.minAmount, parsed.maxAmount); } // Apply Fuse.js text search if (parsed.textQuery) { matchedIndices = this.filterByText(matchedIndices, parsed.textQuery); } this.applyFilter(matchedIndices); } /** * 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 }; } }