Adds time reporting UI with filtering and charts

Implements comprehensive hours report tab with:
- Period and employee filtering
- Statistical overview cards
- Hours per week and absence distribution charts
- Detailed employee hours table

Enhances reports page interactivity and data visualization
This commit is contained in:
Janus C. H. Knudsen 2026-01-21 22:45:47 +01:00
parent 2f92b0eb7b
commit 0144e1ae17
4 changed files with 605 additions and 49 deletions

View file

@ -1,9 +1,10 @@
/**
* Reports - Statistik og Rapporter
*
* Feature-specific styling for reports pages.
* Feature-specific styling for reports pages (Salgsrapport, Timerapport).
* Reuses: swp-stats-row (stats.css), swp-stat-card (stats.css),
* swp-tab-bar (tabs.css), swp-data-table (components.css)
* swp-tab-bar (tabs.css), swp-data-table (components.css),
* swp-status-badge (components.css), swp-card (components.css)
*/
/* ===========================================
@ -359,6 +360,100 @@ swp-page-btn {
}
}
/* ===========================================
PERIOD SELECTOR (for time reports)
=========================================== */
swp-period-selector {
display: flex;
background: var(--color-background-alt);
border-radius: var(--radius-md);
padding: var(--spacing-1);
& button {
padding: var(--spacing-3) var(--spacing-5);
font-size: var(--font-size-md);
font-weight: var(--font-weight-medium);
font-family: var(--font-family);
border: none;
background: transparent;
color: var(--color-text-secondary);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition-fast);
&:hover {
color: var(--color-text);
}
&.active {
background: var(--color-teal);
color: white;
}
}
}
/* ===========================================
HOURS TABLE - Grid columns
=========================================== */
swp-card.hours-table {
padding: 0;
overflow: hidden;
}
swp-card.hours-table swp-data-table {
grid-template-columns: 200px repeat(7, 1fr);
}
swp-card.hours-table swp-data-table-header {
padding: var(--spacing-4) var(--card-padding);
}
swp-card.hours-table swp-data-table-row {
padding: var(--spacing-5) var(--card-padding);
}
swp-card.hours-table swp-data-table-cell.name {
font-weight: var(--font-weight-medium);
}
swp-card.hours-table swp-data-table-cell.number {
font-family: var(--font-mono);
font-size: var(--font-size-sm);
}
swp-card.hours-table swp-data-table-cell.danger {
color: var(--color-red);
}
swp-card.hours-table swp-data-table-cell.warning {
color: var(--color-amber);
}
swp-card.hours-table swp-data-table-cell.purple {
color: var(--color-purple);
}
/* ===========================================
STATUS BADGE ADDITIONS (absence percentages)
=========================================== */
swp-status-badge.low {
background: var(--bg-green-strong);
color: var(--color-green);
font-family: var(--font-mono);
}
swp-status-badge.medium {
background: var(--bg-amber-strong);
color: var(--color-amber);
font-family: var(--font-mono);
}
swp-status-badge.high {
background: var(--bg-red-strong);
color: var(--color-red);
font-family: var(--font-mono);
}
/* ===========================================
RESPONSIVE
=========================================== */

View file

@ -28,23 +28,95 @@ interface ParsedQuery {
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 };
};
}
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;
// 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.searchInput && this.tableRows?.length) {
if (this.tableRows?.length) {
this.buildSearchData();
this.initializeFuse();
this.setupListeners();
this.setupFilterListeners();
}
this.setupTabs();
this.setupChartEvents();
}
/**
@ -81,12 +153,149 @@ export class ReportsController {
}
/**
* Setup event listeners
* 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
*/
@ -122,39 +331,8 @@ export class ReportsController {
/**
* 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);
private handleSearch(_e: Event): void {
this.applyAllFilters();
}
/**
@ -358,4 +536,66 @@ export class ReportsController {
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();
}
}