From eaae745c42899be9fbf5faaec5523f419776408f Mon Sep 17 00:00:00 2001 From: "Janus C. H. Knudsen" Date: Thu, 22 Jan 2026 23:28:33 +0100 Subject: [PATCH] Enhances employee statistics page with rich dashboard Refactors employee statistics view with comprehensive charts and tables Adds detailed revenue, utilization, and booking tracking components Introduces dynamic data loading and chart visualization for employee performance --- .../.claude/settings.local.json | 7 + .../EmployeeDetailStats/Default.cshtml | 101 ++++-- .../Features/Reports/Pages/Index.cshtml | 48 +-- .../wwwroot/css/employees.css | 34 ++ .../wwwroot/css/reports.css | 2 +- PlanTempus.Application/wwwroot/css/stats.css | 8 - .../wwwroot/data/employee-stats-data.json | 94 ++++++ .../wwwroot/ts/modules/employees.ts | 319 ++++++++++++++++++ 8 files changed, 543 insertions(+), 70 deletions(-) create mode 100644 PlanTempus.Application/.claude/settings.local.json create mode 100644 PlanTempus.Application/wwwroot/data/employee-stats-data.json diff --git a/PlanTempus.Application/.claude/settings.local.json b/PlanTempus.Application/.claude/settings.local.json new file mode 100644 index 0000000..0a37a01 --- /dev/null +++ b/PlanTempus.Application/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "WebSearch" + ] + } +} diff --git a/PlanTempus.Application/Features/Employees/Components/EmployeeDetailStats/Default.cshtml b/PlanTempus.Application/Features/Employees/Components/EmployeeDetailStats/Default.cshtml index b5a65be..b27ac5a 100644 --- a/PlanTempus.Application/Features/Employees/Components/EmployeeDetailStats/Default.cshtml +++ b/PlanTempus.Application/Features/Employees/Components/EmployeeDetailStats/Default.cshtml @@ -1,35 +1,75 @@ @model PlanTempus.Application.Features.Employees.Components.EmployeeDetailStatsViewModel - - + + + + + 42 + Bookinger denne måned + + + 30.825 kr + Værdi af bookede services + Baseret på 49 bookinger + + + 28.450 kr + Omsætning denne måned + + + 68% + Gengangere + + + + + - @Model.LabelPerformance + Omsætning & Belægningsgrad + 3 måneder bagud · 3 måneder frem - - - @Model.BookingsThisYear - @Model.LabelBookingsThisYear - - - @Model.RevenueThisYear - @Model.LabelRevenueThisYear - - - @Model.Rating - @Model.LabelAvgRating - - - 87% - @Model.LabelOccupancy - - + - + + + + Omsætning (sidste 6 mdr) + + + + Services + + + + Produkter + + + + + + + + + Seneste bookinger + + + + Kunde + Service + Dato + Beløb + + + + + + + @Model.LabelCompletedBookings - + @Model.LabelDate @Model.LabelTime @@ -39,20 +79,7 @@ @Model.LabelAmount @Model.LabelStatus - @foreach (var booking in Model.CompletedBookings) - { - - @booking.Date - @booking.Time - @booking.Customer - @booking.Services - @booking.Duration - @booking.Amount - - @booking.Status - - - } + diff --git a/PlanTempus.Application/Features/Reports/Pages/Index.cshtml b/PlanTempus.Application/Features/Reports/Pages/Index.cshtml index 7c11b11..4728226 100644 --- a/PlanTempus.Application/Features/Reports/Pages/Index.cshtml +++ b/PlanTempus.Application/Features/Reports/Pages/Index.cshtml @@ -79,20 +79,20 @@ - - - Omsætning pr. måned - Sidste 12 måneder - + + + Omsætning pr. måned + Sidste 12 måneder + - - - - Betalingsmetoder - Fordeling - + + + + Betalingsmetoder + Fordeling + - + @@ -415,20 +415,20 @@ - - - Timer pr. uge - Sidste 5 uger - + + + Timer pr. uge + Sidste 5 uger + - - - - Fraværsfordeling - Efter type - + + + + Fraværsfordeling + Efter type + - + diff --git a/PlanTempus.Application/wwwroot/css/employees.css b/PlanTempus.Application/wwwroot/css/employees.css index 894bf1c..b05ff19 100644 --- a/PlanTempus.Application/wwwroot/css/employees.css +++ b/PlanTempus.Application/wwwroot/css/employees.css @@ -564,6 +564,40 @@ swp-data-row.focus-highlight { } } +/* =========================================== + CHART SUBTITLE (for employee stats) + =========================================== */ +swp-chart-subtitle { + display: block; + font-size: var(--font-size-sm); + color: var(--color-text-muted); + margin-top: var(--spacing-1); +} + +/* =========================================== + RECENT BOOKINGS TABLE + =========================================== */ +swp-card.recent-bookings swp-data-table { + grid-template-columns: minmax(100px, 1fr) minmax(80px, 1fr) 90px 80px; +} + +swp-card.recent-bookings swp-data-table-row swp-data-table-cell { + &:first-child { + font-weight: var(--font-weight-medium); + } + + &:nth-child(3) { + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + } + + &:last-child { + font-family: var(--font-mono); + font-size: var(--font-size-sm); + text-align: right; + } +} + /* =========================================== STATS BOOKINGS TABLE =========================================== */ diff --git a/PlanTempus.Application/wwwroot/css/reports.css b/PlanTempus.Application/wwwroot/css/reports.css index b2d78e0..7244152 100644 --- a/PlanTempus.Application/wwwroot/css/reports.css +++ b/PlanTempus.Application/wwwroot/css/reports.css @@ -55,7 +55,7 @@ swp-chart-hint { swp-chart-container { display: block; - height: 240px; + height: 270px; position: relative; } diff --git a/PlanTempus.Application/wwwroot/css/stats.css b/PlanTempus.Application/wwwroot/css/stats.css index 87e16b2..e667b0d 100644 --- a/PlanTempus.Application/wwwroot/css/stats.css +++ b/PlanTempus.Application/wwwroot/css/stats.css @@ -20,17 +20,11 @@ swp-stats-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--card-gap); - margin-bottom: var(--section-gap); &.cols-4 { grid-template-columns: repeat(4, 1fr); } - /* Reset margin when stat-card is inside stats-row */ - & swp-stat-card { - margin-top: 0; - } - /* Tab-based visibility for multi-stat rows */ &[data-for-tab]:not(.active) { display: none; @@ -46,7 +40,6 @@ swp-stat-card { background: var(--color-surface); border-radius: var(--border-radius-lg); padding: var(--card-padding); - margin-top: var(--section-gap); border: 1px solid var(--color-border); } @@ -239,7 +232,6 @@ swp-quick-stat { padding: var(--card-padding); background: var(--color-background-alt); border-radius: var(--radius-md); - border: 1px solid var(--color-border); } swp-quick-stat swp-stat-value { diff --git a/PlanTempus.Application/wwwroot/data/employee-stats-data.json b/PlanTempus.Application/wwwroot/data/employee-stats-data.json new file mode 100644 index 0000000..e6b86ca --- /dev/null +++ b/PlanTempus.Application/wwwroot/data/employee-stats-data.json @@ -0,0 +1,94 @@ +{ + "stats": { + "bookingsThisMonth": 42, + "bookedServicesValue": "30.825 kr", + "bookedServicesSubtitle": "Baseret på 49 bookinger", + "revenueThisMonth": "28.450 kr", + "returnCustomers": "68%" + }, + "revenueUtilization": { + "categories": ["Okt", "Nov", "Dec", "Jan", "Feb", "Mar"], + "actual": { + "revenue": [ + { "x": "Okt", "y": 24500 }, + { "x": "Nov", "y": 28200 }, + { "x": "Dec", "y": 31800 }, + { "x": "Jan", "y": 28450 }, + { "x": "Feb", "y": null }, + { "x": "Mar", "y": null } + ], + "utilization": [ + { "x": "Okt", "y": 72 }, + { "x": "Nov", "y": 78 }, + { "x": "Dec", "y": 85 }, + { "x": "Jan", "y": 68 }, + { "x": "Feb", "y": null }, + { "x": "Mar", "y": null } + ] + }, + "forecast": { + "revenue": [ + { "x": "Okt", "y": null }, + { "x": "Nov", "y": null }, + { "x": "Dec", "y": null }, + { "x": "Jan", "y": null }, + { "x": "Feb", "y": 26000 }, + { "x": "Mar", "y": 29500 } + ], + "utilization": [ + { "x": "Okt", "y": null }, + { "x": "Nov", "y": null }, + { "x": "Dec", "y": null }, + { "x": "Jan", "y": null }, + { "x": "Feb", "y": 65 }, + { "x": "Mar", "y": 72 } + ] + } + }, + "revenue": { + "categories": ["Jul", "Aug", "Sep", "Okt", "Nov", "Dec"], + "series": [ + { + "name": "Services", + "color": "#00897b", + "data": [ + { "x": "Jul", "y": 22000 }, + { "x": "Aug", "y": 28500 }, + { "x": "Sep", "y": 26200 }, + { "x": "Okt", "y": 30500 }, + { "x": "Nov", "y": 27100 }, + { "x": "Dec", "y": 26000 } + ] + }, + { + "name": "Produkter", + "color": "#1976d2", + "data": [ + { "x": "Jul", "y": 2500 }, + { "x": "Aug", "y": 2700 }, + { "x": "Sep", "y": 2600 }, + { "x": "Okt", "y": 2600 }, + { "x": "Nov", "y": 2500 }, + { "x": "Dec", "y": 2450 } + ] + } + ] + }, + "recentBookings": [ + { "customer": "Maria Hansen", "service": "Klip & Farve", "date": "23. dec 2024", "amount": "995 kr" }, + { "customer": "Louise Nielsen", "service": "Balayage", "date": "22. dec 2024", "amount": "1.495 kr" }, + { "customer": "Sofie Andersen", "service": "Dameklip", "date": "22. dec 2024", "amount": "425 kr" }, + { "customer": "Karen Pedersen", "service": "Klip & Farve", "date": "21. dec 2024", "amount": "1.095 kr" }, + { "customer": "Emma Larsen", "service": "Olaplex", "date": "21. dec 2024", "amount": "350 kr" } + ], + "completedBookings": [ + { "date": "23. dec 2024", "time": "10:00", "customer": "Maria Hansen", "services": "Dameklip, Bundfarve", "duration": "2t 30m", "amount": "1.510 kr", "status": "Betalt", "statusClass": "paid" }, + { "date": "23. dec 2024", "time": "13:30", "customer": "Louise Nielsen", "services": "Balayage langt hår, Olaplex", "duration": "3t", "amount": "2.700 kr", "status": "Betalt", "statusClass": "paid" }, + { "date": "22. dec 2024", "time": "09:00", "customer": "Sofie Andersen", "services": "Dameklip", "duration": "1t", "amount": "725 kr", "status": "Betalt", "statusClass": "paid" }, + { "date": "22. dec 2024", "time": "11:00", "customer": "Karen Pedersen", "services": "Striber mellemlangt hår, Klip", "duration": "2t 30m", "amount": "2.390 kr", "status": "Afventer", "statusClass": "pending" }, + { "date": "21. dec 2024", "time": "14:00", "customer": "Emma Larsen", "services": "Olaplex Stand alone", "duration": "1t", "amount": "550 kr", "status": "Betalt", "statusClass": "paid" }, + { "date": "21. dec 2024", "time": "10:00", "customer": "Mette Kristensen", "services": "Herreklip", "duration": "1t", "amount": "645 kr", "status": "Betalt", "statusClass": "paid" }, + { "date": "20. dec 2024", "time": "09:30", "customer": "Anne Thomsen", "services": "Glossing mellemlangt hår", "duration": "1t", "amount": "745 kr", "status": "Betalt", "statusClass": "paid" }, + { "date": "20. dec 2024", "time": "12:00", "customer": "Lise Mortensen", "services": "Dameklip, Farvning vipper & bryn", "duration": "1t 30m", "amount": "1.070 kr", "status": "Betalt", "statusClass": "paid" } + ] +} diff --git a/PlanTempus.Application/wwwroot/ts/modules/employees.ts b/PlanTempus.Application/wwwroot/ts/modules/employees.ts index ee40bd9..75d025d 100644 --- a/PlanTempus.Application/wwwroot/ts/modules/employees.ts +++ b/PlanTempus.Application/wwwroot/ts/modules/employees.ts @@ -1,3 +1,5 @@ +import { createChart } from '@sevenweirdpeople/swp-charting'; + /** * Employees Controller * @@ -6,9 +8,70 @@ * Uses History API for browser back/forward navigation. */ +interface DataPoint { + x: string; + y: number | null; +} + +interface RevenueSeriesConfig { + name: string; + color: string; + data: DataPoint[]; +} + +interface RevenueChartData { + categories: string[]; + series: RevenueSeriesConfig[]; +} + +interface RevenueUtilizationData { + categories: string[]; + actual: { + revenue: DataPoint[]; + utilization: DataPoint[]; + }; + forecast: { + revenue: DataPoint[]; + utilization: DataPoint[]; + }; +} + +interface RecentBooking { + customer: string; + service: string; + date: string; + amount: string; +} + +interface CompletedBooking { + date: string; + time: string; + customer: string; + services: string; + duration: string; + amount: string; + status: string; + statusClass: string; +} + +interface EmployeeStatsData { + stats: { + bookingsThisMonth: number; + bookedServicesValue: string; + bookedServicesSubtitle: string; + revenueThisMonth: string; + returnCustomers: string; + }; + revenueUtilization: RevenueUtilizationData; + revenue: RevenueChartData; + recentBookings: RecentBooking[]; + completedBookings: CompletedBooking[]; +} + export class EmployeesController { private ratesSync: RatesSyncController | null = null; private scheduleController: ScheduleController | null = null; + private statsController: EmployeeStatsController | null = null; private listView: HTMLElement | null = null; private detailView: HTMLElement | null = null; @@ -27,6 +90,7 @@ export class EmployeesController { this.restoreStateFromUrl(); this.ratesSync = new RatesSyncController(); this.scheduleController = new ScheduleController(); + this.statsController = new EmployeeStatsController(); } /** @@ -1058,3 +1122,258 @@ class ScheduleController { } } } + +/** + * Employee Stats Controller + * + * Handles the Statistics tab: + * - Loads data from JSON + * - Initializes charts + * - Populates tables + */ +class EmployeeStatsController { + private data: EmployeeStatsData | null = null; + private dataPromise: Promise | null = null; + private chartsInitialized = false; + private revenueUtilizationChart: ReturnType | null = null; + private revenueChart: ReturnType | null = null; + + constructor() { + this.setupTabListener(); + } + + /** + * Listen for stats tab activation + */ + private setupTabListener(): void { + document.addEventListener('click', (e: Event) => { + const target = e.target as HTMLElement; + const tab = target.closest('swp-tab[data-tab="stats"]'); + + if (tab) { + this.initializeStats(); + } + }); + } + + /** + * Initialize stats when tab is shown + */ + private initializeStats(): void { + this.loadData().then(() => { + if (!this.chartsInitialized) { + this.populateStats(); + this.initializeCharts(); + this.populateTables(); + this.chartsInitialized = true; + } + }); + } + + /** + * Load data from JSON file (cached) + */ + private loadData(): Promise { + if (this.dataPromise) return this.dataPromise; + + this.dataPromise = (async () => { + try { + const response = await fetch('/data/employee-stats-data.json'); + if (!response.ok) return; + this.data = await response.json() as EmployeeStatsData; + } catch { + console.error('Failed to load employee stats data'); + } + })(); + + return this.dataPromise; + } + + /** + * Populate stat card values + */ + private populateStats(): void { + if (!this.data) return; + + const { stats } = this.data; + + const bookingsEl = document.getElementById('statBookingsMonth'); + const bookedValueEl = document.getElementById('statBookedValue'); + const bookedSubtitleEl = document.getElementById('statBookedSubtitle'); + const revenueEl = document.getElementById('statRevenueMonth'); + const returnEl = document.getElementById('statReturnCustomers'); + + if (bookingsEl) bookingsEl.textContent = String(stats.bookingsThisMonth); + if (bookedValueEl) bookedValueEl.textContent = stats.bookedServicesValue; + if (bookedSubtitleEl) bookedSubtitleEl.textContent = stats.bookedServicesSubtitle; + if (revenueEl) revenueEl.textContent = stats.revenueThisMonth; + if (returnEl) returnEl.textContent = stats.returnCustomers; + } + + /** + * Initialize charts + */ + private initializeCharts(): void { + if (!this.data) return; + + this.revenueUtilizationChart = this.initRevenueUtilizationChart(); + this.revenueChart = this.initRevenueChart(); + } + + /** + * Initialize revenue & utilization chart (dual-axis: bars + lines) + */ + private initRevenueUtilizationChart(): ReturnType | null { + const el = document.getElementById('employeeRevenueUtilizationChart'); + if (!el || !this.data?.revenueUtilization) return null; + + const data = this.data.revenueUtilization; + + return createChart(el, { + height: 300, + xAxis: { categories: data.categories }, + yAxis: [ + { min: 0, max: 50000, format: (v: number) => `${(v / 1000).toFixed(0)}k` }, // Left: Revenue + { min: 0, max: 100, format: (v: number) => `${v}%` } // Right: Utilization + ], + series: [ + // Actual revenue (solid bars) + { + name: 'Omsætning', + color: '#3b82f6', + type: 'bar', + yAxisIndex: 0, + unit: 'kr', + data: data.actual.revenue, + bar: { radius: 2 } + }, + // Forecast revenue (transparent bars) + { + name: 'Omsætning (forecast)', + color: '#3b82f6', + type: 'bar', + yAxisIndex: 0, + unit: 'kr', + data: data.forecast.revenue, + bar: { radius: 2, opacity: 0.35 } + }, + // Actual utilization (solid line) + { + name: 'Belægning', + color: '#00897b', + type: 'line', + yAxisIndex: 1, + unit: '%', + data: data.actual.utilization, + line: { width: 2.5 }, + point: { radius: 0 }, + showArea: false + }, + // Forecast utilization (dashed line) + { + name: 'Belægning (forecast)', + color: '#00897b', + type: 'line', + yAxisIndex: 1, + unit: '%', + data: data.forecast.utilization, + line: { width: 2.5, dashArray: '4 4' }, + point: { radius: 0 }, + showArea: false + } + ], + legend: false + }); + } + + /** + * Initialize revenue chart (line chart with area gradient) + */ + private initRevenueChart(): ReturnType | null { + const el = document.getElementById('employeeRevenueChart'); + if (!el || !this.data?.revenue) return null; + + const data = this.data.revenue; + + return createChart(el, { + height: 200, + xAxis: { categories: data.categories }, + yAxis: { format: (v: number) => `${(v / 1000).toFixed(0)}k` }, + series: data.series.map(s => ({ + name: s.name, + color: s.color, + data: s.data, + showArea: true, + area: { gradient: { startOpacity: 0.3, endOpacity: 0.05 } } + })), + legend: false + }); + } + + /** + * Populate tables with data + */ + private populateTables(): void { + if (!this.data) return; + + this.populateRecentBookings(); + this.populateCompletedBookings(); + } + + /** + * Populate recent bookings table + */ + private populateRecentBookings(): void { + if (!this.data) return; + + const table = document.getElementById('recentBookingsTable'); + if (!table) return; + + // Remove existing rows (keep header) + const existingRows = table.querySelectorAll('swp-data-table-row'); + existingRows.forEach(row => row.remove()); + + // Add new rows + this.data.recentBookings.forEach(booking => { + const row = document.createElement('swp-data-table-row'); + row.innerHTML = ` + ${booking.customer} + ${booking.service} + ${booking.date} + ${booking.amount} + `; + table.appendChild(row); + }); + } + + /** + * Populate completed bookings table + */ + private populateCompletedBookings(): void { + if (!this.data) return; + + const table = document.getElementById('completedBookingsTable'); + if (!table) return; + + // Remove existing rows (keep header) + const existingRows = table.querySelectorAll('swp-data-table-row'); + existingRows.forEach(row => row.remove()); + + // Add new rows + this.data.completedBookings.forEach(booking => { + const row = document.createElement('swp-data-table-row'); + row.innerHTML = ` + ${booking.date} + ${booking.time} + ${booking.customer} + ${booking.services} + ${booking.duration} + ${booking.amount} + + ${booking.status} + + `; + table.appendChild(row); + }); + } +}