diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index 77b9f5f..4568674 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -10,7 +10,13 @@
"Bash(node:*)",
"Bash(npx esbuild:*)",
"mcp__ide__getDiagnostics",
- "Bash(grep:*)"
+ "Bash(grep:*)",
+ "Bash(dir /s /b \"C:\\\\Users\\\\Janus Knudsen\\\\source\\\\swp-repos\\\\Calendar\\\\*rapport*.html\")",
+ "Bash(dir /s /b \"C:\\\\Users\\\\Janus Knudsen\\\\source\\\\swp-repos\\\\Calendar\\\\wwwroot\\\\*.html\")",
+ "Bash(dir /s /b \"C:\\\\Users\\\\Janus Knudsen\\\\source\\\\swp-repos\\\\*rapport*.html\")",
+ "Bash(dir /s /b \"C:\\\\Users\\\\Janus Knudsen\\\\source\\\\swp-repos\\\\Calendar\\\\wwwroot\\\\poc*.html\")",
+ "Bash(Get-ChildItem:*)",
+ "Bash(Select-Object -ExpandProperty FullName)"
]
}
}
diff --git a/PlanTempus.Application/Features/Reports/Pages/Index.cshtml b/PlanTempus.Application/Features/Reports/Pages/Index.cshtml
index d51ba43..1759269 100644
--- a/PlanTempus.Application/Features/Reports/Pages/Index.cshtml
+++ b/PlanTempus.Application/Features/Reports/Pages/Index.cshtml
@@ -373,17 +373,126 @@
-
-
-
-
- Timerapport
-
-
-
-
- Timerapport kommer snart
-
+
+
+
+
+
+
+
+
+
+ Medarbejder
+
+
+
+
+
+
+
+ 320 t
+ Planlagte timer
+
+
+ 24 t
+ Fravær total
+
+
+ 8 t
+ Overarbejde
+
+
+ 7.5%
+ Fraværsprocent
+
+
+
+
+
+
+
+ Timer pr. uge
+ Sidste 5 uger
+
+
+
+
+
+ Fraværsfordeling
+ Efter type
+
+
+
+
+
+
+
+
+
+ Medarbejder
+ Planlagt
+ Fravær
+ Syg
+ Ferie
+ Fri
+ Overarbejde
+ Fraværs-%
+
+
+
+ Anna Jensen
+ 80 t
+ 4 t
+ 0 t
+ 4 t
+ 0 t
+ 2 t
+ 5.0%
+
+
+
+ Martin Nielsen
+ 80 t
+ 8 t
+ 8 t
+ 0 t
+ 0 t
+ 0 t
+ 10.0%
+
+
+
+ Sofie Larsen
+ 80 t
+ 4 t
+ 0 t
+ 0 t
+ 4 t
+ 4 t
+ 5.0%
+
+
+
+ Peter Hansen
+ 80 t
+ 8 t
+ 4 t
+ 4 t
+ 0 t
+ 2 t
+ 10.0%
+
+
+
+ Viser 4 medarbejdere
+ Total: 320 t planlagt, 24 t fravær, 8 t overarbejde
+
@@ -436,5 +545,111 @@
tooltip: true,
legend: { position: 'right', align: 'center' }
});
+
+ // Hours per week grouped bar chart (Timerapport)
+ createChart(document.getElementById('hoursChart'), {
+ height: 240,
+ xAxis: { categories: ['Uge 48', 'Uge 49', 'Uge 50', 'Uge 51', 'Uge 52'] },
+ yAxis: { format: (v) => v + ' t' },
+ series: [
+ {
+ name: 'Anna Jensen',
+ color: '#00897b',
+ type: 'bar',
+ data: [
+ { x: 'Uge 48', y: 32 },
+ { x: 'Uge 49', y: 40 },
+ { x: 'Uge 50', y: 38 },
+ { x: 'Uge 51', y: 40 },
+ { x: 'Uge 52', y: 20 }
+ ]
+ },
+ {
+ name: 'Martin Nielsen',
+ color: '#3b82f6',
+ type: 'bar',
+ data: [
+ { x: 'Uge 48', y: 30 },
+ { x: 'Uge 49', y: 40 },
+ { x: 'Uge 50', y: 35 },
+ { x: 'Uge 51', y: 40 },
+ { x: 'Uge 52', y: 16 }
+ ]
+ },
+ {
+ name: 'Sofie Larsen',
+ color: '#8b5cf6',
+ type: 'bar',
+ data: [
+ { x: 'Uge 48', y: 28 },
+ { x: 'Uge 49', y: 36 },
+ { x: 'Uge 50', y: 40 },
+ { x: 'Uge 51', y: 40 },
+ { x: 'Uge 52', y: 18 }
+ ]
+ },
+ {
+ name: 'Peter Hansen',
+ color: '#f59e0b',
+ type: 'bar',
+ data: [
+ { x: 'Uge 48', y: 34 },
+ { x: 'Uge 49', y: 38 },
+ { x: 'Uge 50', y: 32 },
+ { x: 'Uge 51', y: 40 },
+ { x: 'Uge 52', y: 14 }
+ ]
+ }
+ ],
+ legend: { position: 'bottom', align: 'center', gap: 0 }
+ });
+
+ // Absence distribution pie chart (Timerapport)
+ createChart(document.getElementById('absenceChart'), {
+ height: 240,
+ series: [
+ {
+ name: 'Syg',
+ color: '#e53935',
+ type: 'pie',
+ unit: ' t',
+ data: [
+ { x: 'Martin Nielsen', y: 8 },
+ { x: 'Peter Hansen', y: 4 }
+ ],
+ pie: { innerRadius: 25, outerRadius: 90 }
+ },
+ {
+ name: 'Ferie',
+ color: '#f59e0b',
+ type: 'pie',
+ unit: ' t',
+ data: [
+ { x: 'Anna Jensen', y: 4 },
+ { x: 'Peter Hansen', y: 4 }
+ ],
+ pie: { innerRadius: 25, outerRadius: 90 }
+ },
+ {
+ name: 'Fri',
+ color: '#8b5cf6',
+ type: 'pie',
+ unit: ' t',
+ data: [
+ { x: 'Sofie Larsen', y: 4 }
+ ],
+ pie: { innerRadius: 25, outerRadius: 90 }
+ }
+ ],
+ legend: { position: 'right', align: 'center' }
+ });
+
+ // Period selector functionality
+ document.querySelectorAll('swp-period-selector button').forEach(btn => {
+ btn.addEventListener('click', () => {
+ document.querySelectorAll('swp-period-selector button').forEach(b => b.classList.remove('active'));
+ btn.classList.add('active');
+ });
+ });
}
diff --git a/PlanTempus.Application/wwwroot/css/reports.css b/PlanTempus.Application/wwwroot/css/reports.css
index 9d22215..b2d78e0 100644
--- a/PlanTempus.Application/wwwroot/css/reports.css
+++ b/PlanTempus.Application/wwwroot/css/reports.css
@@ -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
=========================================== */
diff --git a/PlanTempus.Application/wwwroot/ts/modules/reports.ts b/PlanTempus.Application/wwwroot/ts/modules/reports.ts
index cb0de79..29aeff4 100644
--- a/PlanTempus.Application/wwwroot/ts/modules/reports.ts
+++ b/PlanTempus.Application/wwwroot/ts/modules/reports.ts
@@ -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 | null = null;
private salesData: SalesDataItem[] = [];
private fuse: Fuse | null = null;
+ // Map pie chart series names to payment filter values
+ private readonly paymentMap: Record = {
+ 'Kort': 'card',
+ 'MobilePay': 'mobilepay',
+ 'Kontant': 'cash',
+ 'Faktura': 'invoice',
+ 'Fordelskort': 'giftcard',
+ };
+
+ // Map status badge text to filter values
+ private readonly statusMap: Record = {
+ 'Betalt': 'paid',
+ 'Afventer': 'pending',
+ 'Krediteret': 'credited',
+ };
+
+ // Map payment badge text to filter values
+ private readonly paymentTextMap: Record = {
+ '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 = {
+ '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('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, fromDate: string, toDate: string): Set {
+ 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 = {
+ '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, statusValue: string): Set {
+ 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, paymentValue: string): Set {
+ 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();
+ }
}