Moves chart data to JSON file for better separation of concerns Implements lazy chart initialization in reports module Updates build script and npm dependencies Removes hardcoded chart scripts from Razor page
794 lines
23 KiB
TypeScript
794 lines
23 KiB
TypeScript
/**
|
|
* 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');
|
|
});
|
|
});
|
|
}
|
|
}
|