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(); + } }