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:
parent
405dabeb34
commit
2f92b0eb7b
3 changed files with 364 additions and 182 deletions
|
|
@ -15,6 +15,7 @@ import { ControlsController } from './modules/controls';
|
|||
import { ServicesController } from './modules/services';
|
||||
import { CustomersController } from './modules/customers';
|
||||
import { TrackingController } from './modules/tracking';
|
||||
import { ReportsController } from './modules/reports';
|
||||
|
||||
/**
|
||||
* Main application class
|
||||
|
|
@ -31,6 +32,7 @@ export class App {
|
|||
readonly services: ServicesController;
|
||||
readonly customers: CustomersController;
|
||||
readonly tracking: TrackingController;
|
||||
readonly reports: ReportsController;
|
||||
|
||||
constructor() {
|
||||
// Initialize controllers
|
||||
|
|
@ -45,6 +47,7 @@ export class App {
|
|||
this.services = new ServicesController();
|
||||
this.customers = new CustomersController();
|
||||
this.tracking = new TrackingController();
|
||||
this.reports = new ReportsController();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
361
PlanTempus.Application/wwwroot/ts/modules/reports.ts
Normal file
361
PlanTempus.Application/wwwroot/ts/modules/reports.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue