2026-01-21 21:49:10 +01:00
|
|
|
/**
|
|
|
|
|
* 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';
|
2026-01-22 16:32:46 +01:00
|
|
|
import { createChart } from '@sevenweirdpeople/swp-charting';
|
2026-01-21 21:49:10 +01:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-21 22:45:47 +01:00
|
|
|
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 };
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-22 16:32:46 +01:00
|
|
|
interface DataPoint {
|
|
|
|
|
x: string;
|
|
|
|
|
y: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface SeriesConfig {
|
|
|
|
|
name: string;
|
|
|
|
|
color: string;
|
|
|
|
|
type: 'bar' | 'pie' | 'line';
|
|
|
|
|
data: DataPoint[];
|
|
|
|
|
unit?: string;
|
|
|
|
|
pie?: { innerRadius: number; outerRadius: number };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ChartDataConfig {
|
|
|
|
|
series: SeriesConfig[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ReportsData {
|
|
|
|
|
revenue: ChartDataConfig;
|
|
|
|
|
payment: ChartDataConfig;
|
|
|
|
|
hours: ChartDataConfig;
|
|
|
|
|
absence: ChartDataConfig;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-21 21:49:10 +01:00
|
|
|
export class ReportsController {
|
|
|
|
|
private searchInput: HTMLInputElement | null = null;
|
2026-01-21 22:45:47 +01:00
|
|
|
private dateFromInput: HTMLInputElement | null = null;
|
|
|
|
|
private dateToInput: HTMLInputElement | null = null;
|
|
|
|
|
private statusFilter: HTMLSelectElement | null = null;
|
|
|
|
|
private paymentFilter: HTMLSelectElement | null = null;
|
2026-01-21 21:49:10 +01:00
|
|
|
private tableRows: NodeListOf<HTMLElement> | null = null;
|
|
|
|
|
private salesData: SalesDataItem[] = [];
|
|
|
|
|
private fuse: Fuse<SalesDataItem> | null = null;
|
|
|
|
|
|
2026-01-22 16:32:46 +01:00
|
|
|
// Chart references for lazy initialization
|
|
|
|
|
private revenueChart: ReturnType<typeof createChart> | null = null;
|
|
|
|
|
private paymentChart: ReturnType<typeof createChart> | null = null;
|
|
|
|
|
private hoursChart: ReturnType<typeof createChart> | null = null;
|
|
|
|
|
private absenceChart: ReturnType<typeof createChart> | null = null;
|
|
|
|
|
private salesChartsInitialized = false;
|
|
|
|
|
private hoursChartsInitialized = false;
|
|
|
|
|
|
|
|
|
|
// Chart data loaded from JSON
|
|
|
|
|
private chartData: ReportsData | null = null;
|
|
|
|
|
|
2026-01-21 22:45:47 +01:00
|
|
|
// Map pie chart series names to payment filter values
|
|
|
|
|
private readonly paymentMap: Record<string, string> = {
|
|
|
|
|
'Kort': 'card',
|
|
|
|
|
'MobilePay': 'mobilepay',
|
|
|
|
|
'Kontant': 'cash',
|
|
|
|
|
'Faktura': 'invoice',
|
|
|
|
|
'Fordelskort': 'giftcard',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Map status badge text to filter values
|
|
|
|
|
private readonly statusMap: Record<string, string> = {
|
|
|
|
|
'Betalt': 'paid',
|
|
|
|
|
'Afventer': 'pending',
|
|
|
|
|
'Krediteret': 'credited',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Map payment badge text to filter values
|
|
|
|
|
private readonly paymentTextMap: Record<string, string> = {
|
|
|
|
|
'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<string, MonthMapping> = {
|
|
|
|
|
'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 },
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-21 21:49:10 +01:00
|
|
|
constructor() {
|
|
|
|
|
this.searchInput = document.getElementById('searchInput') as HTMLInputElement | null;
|
2026-01-21 22:45:47 +01:00
|
|
|
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;
|
2026-01-21 21:49:10 +01:00
|
|
|
this.tableRows = document.querySelectorAll<HTMLElement>('swp-card.sales-table swp-data-table-row');
|
|
|
|
|
|
2026-01-21 22:45:47 +01:00
|
|
|
if (this.tableRows?.length) {
|
2026-01-21 21:49:10 +01:00
|
|
|
this.buildSearchData();
|
|
|
|
|
this.initializeFuse();
|
|
|
|
|
this.setupListeners();
|
2026-01-21 22:45:47 +01:00
|
|
|
this.setupFilterListeners();
|
2026-01-21 21:49:10 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.setupTabs();
|
2026-01-22 16:32:46 +01:00
|
|
|
this.setupPeriodSelector();
|
2026-01-21 22:45:47 +01:00
|
|
|
this.setupChartEvents();
|
2026-01-22 16:32:46 +01:00
|
|
|
|
|
|
|
|
// Load chart data from JSON and initialize charts
|
|
|
|
|
this.loadChartData().then(() => {
|
|
|
|
|
this.initializeSalesCharts();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Load chart data from JSON file
|
|
|
|
|
*/
|
|
|
|
|
private async loadChartData(): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch('/data/reports-data.json');
|
|
|
|
|
if (!response.ok) return;
|
|
|
|
|
this.chartData = await response.json() as ReportsData;
|
|
|
|
|
} catch {
|
|
|
|
|
console.error('Failed to load reports chart data');
|
|
|
|
|
}
|
2026-01-21 21:49:10 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-21 22:45:47 +01:00
|
|
|
* Setup search event listener
|
2026-01-21 21:49:10 +01:00
|
|
|
*/
|
|
|
|
|
private setupListeners(): void {
|
|
|
|
|
this.searchInput?.addEventListener('input', (e) => this.handleSearch(e));
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-21 22:45:47 +01:00
|
|
|
/**
|
|
|
|
|
* 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<number>, fromDate: string, toDate: string): Set<number> {
|
|
|
|
|
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<string, number> = {
|
|
|
|
|
'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<number>, statusValue: string): Set<number> {
|
|
|
|
|
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<number>, paymentValue: string): Set<number> {
|
|
|
|
|
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;
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-21 21:49:10 +01:00
|
|
|
/**
|
|
|
|
|
* 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]');
|
2026-01-22 00:19:19 +01:00
|
|
|
const statsRows = document.querySelectorAll<HTMLElement>('swp-stats-row[data-for-tab]');
|
2026-01-21 21:49:10 +01:00
|
|
|
|
|
|
|
|
tabs.forEach(t => {
|
|
|
|
|
t.classList.toggle('active', t.dataset.tab === targetTab);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
contents.forEach(content => {
|
|
|
|
|
content.classList.toggle('active', content.dataset.tab === targetTab);
|
|
|
|
|
});
|
2026-01-22 00:19:19 +01:00
|
|
|
|
|
|
|
|
// Toggle stats rows based on active tab
|
|
|
|
|
statsRows.forEach(stats => {
|
|
|
|
|
stats.classList.toggle('active', stats.dataset.forTab === targetTab);
|
|
|
|
|
});
|
2026-01-22 16:32:46 +01:00
|
|
|
|
|
|
|
|
// Lazy-init charts for the active tab
|
|
|
|
|
if (targetTab === 'sales') {
|
|
|
|
|
if (this.chartData) {
|
|
|
|
|
this.initializeSalesCharts();
|
|
|
|
|
} else {
|
|
|
|
|
this.loadChartData().then(() => this.initializeSalesCharts());
|
|
|
|
|
}
|
|
|
|
|
} else if (targetTab === 'hours') {
|
|
|
|
|
if (this.chartData) {
|
|
|
|
|
this.initializeHoursCharts();
|
|
|
|
|
} else {
|
|
|
|
|
this.loadChartData().then(() => this.initializeHoursCharts());
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-21 21:49:10 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Handle search input
|
|
|
|
|
*/
|
2026-01-21 22:45:47 +01:00
|
|
|
private handleSearch(_e: Event): void {
|
|
|
|
|
this.applyAllFilters();
|
2026-01-21 21:49:10 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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 };
|
|
|
|
|
}
|
2026-01-21 22:45:47 +01:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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();
|
|
|
|
|
}
|
2026-01-22 16:32:46 +01:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Initialize sales tab charts (lazy, only when visible)
|
|
|
|
|
*/
|
|
|
|
|
private initializeSalesCharts(): void {
|
|
|
|
|
if (this.salesChartsInitialized) return;
|
|
|
|
|
this.revenueChart = this.initRevenueChart();
|
|
|
|
|
this.paymentChart = this.initPaymentChart();
|
|
|
|
|
this.salesChartsInitialized = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Initialize hours tab charts (lazy, only when visible)
|
|
|
|
|
*/
|
|
|
|
|
private initializeHoursCharts(): void {
|
|
|
|
|
if (this.hoursChartsInitialized) return;
|
|
|
|
|
this.hoursChart = this.initHoursChart();
|
|
|
|
|
this.absenceChart = this.initAbsenceChart();
|
|
|
|
|
this.hoursChartsInitialized = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Initialize revenue bar chart (Salgsrapport)
|
|
|
|
|
*/
|
|
|
|
|
private initRevenueChart(): ReturnType<typeof createChart> | null {
|
|
|
|
|
const el = document.getElementById('revenueChart');
|
|
|
|
|
if (!el || !this.chartData?.revenue) return null;
|
|
|
|
|
|
|
|
|
|
const series = this.chartData.revenue.series;
|
|
|
|
|
if (series.length === 0) return null;
|
|
|
|
|
|
|
|
|
|
const categories = series[0].data.map(p => p.x);
|
|
|
|
|
|
|
|
|
|
return createChart(el, {
|
|
|
|
|
deferRender: true,
|
|
|
|
|
height: 240,
|
|
|
|
|
xAxis: { categories },
|
|
|
|
|
yAxis: {
|
|
|
|
|
format: (v: number) => `${Math.round(v / 1000)}k`
|
|
|
|
|
},
|
|
|
|
|
series: series
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Initialize payment methods pie chart (Salgsrapport)
|
|
|
|
|
*/
|
|
|
|
|
private initPaymentChart(): ReturnType<typeof createChart> | null {
|
|
|
|
|
const el = document.getElementById('paymentChart');
|
|
|
|
|
if (!el || !this.chartData?.payment) return null;
|
|
|
|
|
|
|
|
|
|
const series = this.chartData.payment.series;
|
|
|
|
|
if (series.length === 0) return null;
|
|
|
|
|
|
|
|
|
|
return createChart(el, {
|
|
|
|
|
deferRender: true,
|
|
|
|
|
height: 240,
|
|
|
|
|
series: series,
|
|
|
|
|
tooltip: true,
|
|
|
|
|
legend: { position: 'right', align: 'center' }
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Initialize hours per week bar chart (Timerapport)
|
|
|
|
|
*/
|
|
|
|
|
private initHoursChart(): ReturnType<typeof createChart> | null {
|
|
|
|
|
const el = document.getElementById('hoursChart');
|
|
|
|
|
if (!el || !this.chartData?.hours) return null;
|
|
|
|
|
|
|
|
|
|
const series = this.chartData.hours.series;
|
|
|
|
|
if (series.length === 0) return null;
|
|
|
|
|
|
|
|
|
|
// Extract categories from first series
|
|
|
|
|
const categories = series[0]?.data.map(p => p.x) || [];
|
|
|
|
|
|
|
|
|
|
return createChart(el, {
|
|
|
|
|
deferRender: true,
|
|
|
|
|
height: 240,
|
|
|
|
|
xAxis: { categories },
|
|
|
|
|
yAxis: { format: (v: number) => v + ' t' },
|
|
|
|
|
series: series,
|
|
|
|
|
legend: { position: 'bottom', align: 'center', gap: 0 }
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Initialize absence distribution pie chart (Timerapport)
|
|
|
|
|
*/
|
|
|
|
|
private initAbsenceChart(): ReturnType<typeof createChart> | null {
|
|
|
|
|
const el = document.getElementById('absenceChart');
|
|
|
|
|
if (!el || !this.chartData?.absence) return null;
|
|
|
|
|
|
|
|
|
|
const series = this.chartData.absence.series;
|
|
|
|
|
if (series.length === 0) return null;
|
|
|
|
|
|
|
|
|
|
return createChart(el, {
|
|
|
|
|
deferRender: true,
|
|
|
|
|
height: 240,
|
|
|
|
|
series: series,
|
|
|
|
|
legend: { position: 'right', align: 'center' }
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Setup period selector functionality (Timerapport)
|
|
|
|
|
*/
|
|
|
|
|
private setupPeriodSelector(): void {
|
|
|
|
|
const buttons = document.querySelectorAll<HTMLButtonElement>('swp-period-selector button');
|
|
|
|
|
buttons.forEach(btn => {
|
|
|
|
|
btn.addEventListener('click', () => {
|
|
|
|
|
buttons.forEach(b => b.classList.remove('active'));
|
|
|
|
|
btn.classList.add('active');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-01-21 21:49:10 +01:00
|
|
|
}
|