PlanTempusApp/PlanTempus.Application/wwwroot/ts/modules/reports.ts

795 lines
23 KiB
TypeScript
Raw Normal View History

/**
* 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';
import { createChart } from '@sevenweirdpeople/swp-charting';
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 };
};
}
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;
}
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<HTMLElement> | null = null;
private salesData: SalesDataItem[] = [];
private fuse: Fuse<SalesDataItem> | null = null;
// 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;
// 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 },
};
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<HTMLElement>('swp-card.sales-table swp-data-table-row');
if (this.tableRows?.length) {
this.buildSearchData();
this.initializeFuse();
this.setupListeners();
this.setupFilterListeners();
}
this.setupTabs();
this.setupPeriodSelector();
this.setupChartEvents();
// 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');
}
}
/**
* 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<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;
})
);
}
/**
* 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]');
const statsRows = document.querySelectorAll<HTMLElement>('swp-stats-row[data-for-tab]');
tabs.forEach(t => {
t.classList.toggle('active', t.dataset.tab === targetTab);
});
contents.forEach(content => {
content.classList.toggle('active', content.dataset.tab === targetTab);
});
// Toggle stats rows based on active tab
statsRows.forEach(stats => {
stats.classList.toggle('active', stats.dataset.forTab === targetTab);
});
// 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());
}
}
}
/**
* 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<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 };
}
/**
* 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();
}
/**
* 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');
});
});
}
}