Refactors reports page search and filtering functionality

Moves search and filtering logic from inline script to a dedicated TypeScript module

Improves code organization by creating a ReportsController with:
- Enhanced search capabilities
- Advanced range query parsing
- Flexible filtering mechanisms

Removes inline JavaScript and integrates modular approach in the application
This commit is contained in:
Janus C. H. Knudsen 2026-01-21 21:49:10 +01:00
parent 405dabeb34
commit 2f92b0eb7b
3 changed files with 364 additions and 182 deletions

View file

@ -391,188 +391,6 @@
@section Scripts { @section Scripts {
<script type="module"> <script type="module">
import { createChart } from '/lib/swp-charting/dist/swp-charting.js'; import { createChart } from '/lib/swp-charting/dist/swp-charting.js';
import Fuse from '/lib/fuse/fuse.mjs';
// === SEARCH FUNCTIONALITY ===
const searchInput = document.getElementById('searchInput');
const tableRows = document.querySelectorAll('swp-card.sales-table swp-data-table-row');
// Build searchable data from table rows
const salesData = Array.from(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
const fuse = new Fuse(salesData, {
keys: ['invoice', 'customer', 'employee', 'services', 'amount', 'payment', 'status'],
threshold: 0.3,
ignoreLocation: true
});
// Parse amount string "1.450 kr" -> 1450
function parseAmountFromText(text) {
const match = text.match(/-?([\d.]+)/);
if (!match) return null;
// Remove thousand separators (Danish format uses . for thousands)
return parseFloat(match[1].replace(/\./g, ''));
}
// Parse invoice number "#1847" -> 1847
function parseInvoiceNumber(text) {
const match = text.match(/#(\d+)/);
return match ? parseInt(match[1], 10) : null;
}
// Parse range operators from query (supports both amount and invoice ranges)
function parseRangeQuery(query) {
let textQuery = query;
let minAmount = null;
let maxAmount = null;
let minInvoice = null;
let maxInvoice = null;
// === INVOICE NUMBER RANGES (with # prefix) ===
// Match #1840-1845
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 #>=)
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 #<=)
match = textQuery.match(/#<\s*(\d+)/);
if (match) {
maxInvoice = parseInt(match[1], 10) - 1;
textQuery = textQuery.replace(match[0], '').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 };
}
// Search handler with range support
searchInput.addEventListener('input', (e) => {
const query = e.target.value.trim();
if (!query) {
// Show all rows
tableRows.forEach(row => row.style.display = '');
return;
}
const { textQuery, minAmount, maxAmount, minInvoice, maxInvoice } = parseRangeQuery(query);
// Start with all indices
let matchedIndices = new Set(salesData.map((_, i) => i));
// Apply invoice number range filter
if (minInvoice !== null || maxInvoice !== null) {
matchedIndices = new Set(
salesData
.filter((item) => {
const invoiceNum = parseInvoiceNumber(item.invoice);
if (invoiceNum === null) return false;
if (minInvoice !== null && invoiceNum < minInvoice) return false;
if (maxInvoice !== null && invoiceNum > maxInvoice) return false;
return true;
})
.map(item => item.index)
);
}
// Apply amount range filter
if (minAmount !== null || maxAmount !== null) {
matchedIndices = new Set(
[...matchedIndices].filter(i => {
const amount = parseAmountFromText(salesData[i].amount);
if (amount === null) return false;
if (minAmount !== null && amount < minAmount) return false;
if (maxAmount !== null && amount > maxAmount) return false;
return true;
})
);
}
// Apply Fuse.js text search if there's remaining text
if (textQuery) {
const fuseResults = fuse.search(textQuery);
const textMatches = new Set(fuseResults.map(r => r.item.index));
matchedIndices = new Set([...matchedIndices].filter(i => textMatches.has(i)));
}
tableRows.forEach((row, index) => {
row.style.display = matchedIndices.has(index) ? '' : 'none';
});
});
// Revenue bar chart // Revenue bar chart
createChart(document.getElementById('revenueChart'), { createChart(document.getElementById('revenueChart'), {

View file

@ -15,6 +15,7 @@ import { ControlsController } from './modules/controls';
import { ServicesController } from './modules/services'; import { ServicesController } from './modules/services';
import { CustomersController } from './modules/customers'; import { CustomersController } from './modules/customers';
import { TrackingController } from './modules/tracking'; import { TrackingController } from './modules/tracking';
import { ReportsController } from './modules/reports';
/** /**
* Main application class * Main application class
@ -31,6 +32,7 @@ export class App {
readonly services: ServicesController; readonly services: ServicesController;
readonly customers: CustomersController; readonly customers: CustomersController;
readonly tracking: TrackingController; readonly tracking: TrackingController;
readonly reports: ReportsController;
constructor() { constructor() {
// Initialize controllers // Initialize controllers
@ -45,6 +47,7 @@ export class App {
this.services = new ServicesController(); this.services = new ServicesController();
this.customers = new CustomersController(); this.customers = new CustomersController();
this.tracking = new TrackingController(); this.tracking = new TrackingController();
this.reports = new ReportsController();
} }
} }

View file

@ -0,0 +1,361 @@
/**
* 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<HTMLElement> | null = null;
private salesData: SalesDataItem[] = [];
private fuse: Fuse<SalesDataItem> | null = null;
constructor() {
this.searchInput = document.getElementById('searchInput') as HTMLInputElement | null;
this.tableRows = document.querySelectorAll<HTMLElement>('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<HTMLElement>('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<HTMLElement>('swp-tab[data-tab]');
const contents = document.querySelectorAll<HTMLElement>('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<number>): 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<number> {
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<number>,
min: number | null,
max: number | null
): Set<number> {
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<number>,
min: number | null,
max: number | null
): Set<number> {
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<number>, query: string): Set<number> {
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 };
}
}