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