diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 4568674..a1ab67c 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -16,7 +16,8 @@ "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)" + "Bash(Select-Object -ExpandProperty FullName)", + "Bash(npm run build:*)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index 0a06e64..b61619d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,46 +12,18 @@ PlanTempus is a .NET 9 web application built with ASP.NET Core Razor Pages. It u ## Build and Development Commands -### Prerequisites -- .NET 9.0 SDK or later -- PostgreSQL database - -### Common Commands - -```bash -# Build the solution -dotnet build - -# Run the main application -dotnet run --project Application/PlanTempus.Application.csproj - -# Run with specific launch profile -dotnet run --project Application/PlanTempus.Application.csproj --launch-profile https - -# Run tests -dotnet test - -# Run specific test project -dotnet test Tests/PlanTempus.X.TDD.csproj -dotnet test PlanTempus.X.BDD/PlanTempus.X.BDD.csproj - -# Clean build artifacts -dotnet clean - -# Restore dependencies -dotnet restore -``` - ### TypeScript/Frontend Development -The application uses esbuild for TypeScript compilation. From the Application directory: +**IMPORTANT:** All TypeScript/frontend commands must be run from `PlanTempus.Application/` folder, not from the solution root. ```bash +cd PlanTempus.Application + # Install npm dependencies npm install -# Build TypeScript (requires custom build script setup) -# Note: No npm scripts are currently defined in package.json +# Build TypeScript +npm run build ``` ## Architecture Overview diff --git a/PlanTempus.Application/Features/Reports/Pages/Index.cshtml b/PlanTempus.Application/Features/Reports/Pages/Index.cshtml index 74c5f36..7c11b11 100644 --- a/PlanTempus.Application/Features/Reports/Pages/Index.cshtml +++ b/PlanTempus.Application/Features/Reports/Pages/Index.cshtml @@ -496,160 +496,3 @@ - -@section Scripts { - -} diff --git a/PlanTempus.Application/build.js b/PlanTempus.Application/build.js deleted file mode 100644 index 4aa944e..0000000 --- a/PlanTempus.Application/build.js +++ /dev/null @@ -1,24 +0,0 @@ -import * as esbuild from 'esbuild'; - -async function build() { - try { - await esbuild.build({ - entryPoints: ['wwwroot/ts/app.ts'], - bundle: true, - outfile: 'wwwroot/js/app.js', - format: 'esm', - sourcemap: 'inline', - target: 'es2020', - minify: false, - keepNames: true, - platform: 'browser' - }); - - console.log('App bundle created: wwwroot/js/app.js'); - } catch (error) { - console.error('Build failed:', error); - process.exit(1); - } -} - -build(); diff --git a/PlanTempus.Application/package-lock.json b/PlanTempus.Application/package-lock.json index e546fd1..603c53b 100644 --- a/PlanTempus.Application/package-lock.json +++ b/PlanTempus.Application/package-lock.json @@ -5,7 +5,7 @@ "packages": { "": { "dependencies": { - "@sevenweirdpeople/swp-charting": "^0.2.2", + "@sevenweirdpeople/swp-charting": "^0.2.5", "fuse.js": "^7.1.0" }, "devDependencies": { @@ -485,9 +485,9 @@ } }, "node_modules/@sevenweirdpeople/swp-charting": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@sevenweirdpeople/swp-charting/-/swp-charting-0.2.2.tgz", - "integrity": "sha512-q9p7TOSMAq6I0t6jGEWpmjR7l2H8q8G0TnXbIpDutCz5a2JEqMDFe0NGBGcCwze2rvvRnRvCz8P2zGMQlHmphw==", + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@sevenweirdpeople/swp-charting/-/swp-charting-0.2.5.tgz", + "integrity": "sha512-bQa5FtAXsTjjFxsE79sD1+A74R7f9YgVp5fC1fsiHoaLXmapDEO2dWuGX/MQ8rEChDZFyN1ZlkV+OLUs6qtfZw==", "license": "MIT" }, "node_modules/ansi-regex": { diff --git a/PlanTempus.Application/package.json b/PlanTempus.Application/package.json index ce08276..97b5fd0 100644 --- a/PlanTempus.Application/package.json +++ b/PlanTempus.Application/package.json @@ -5,10 +5,11 @@ "purgecss": "^6.0.0" }, "scripts": { + "build": "esbuild wwwroot/ts/app.ts --bundle --format=esm --outfile=wwwroot/js/app.js --sourcemap=inline --target=es2020 --keep-names --platform=browser", "analyze-css": "node analyze-css.js" }, "dependencies": { - "@sevenweirdpeople/swp-charting": "^0.2.2", + "@sevenweirdpeople/swp-charting": "^0.2.5", "fuse.js": "^7.1.0" } } diff --git a/PlanTempus.Application/wwwroot/data/reports-data.json b/PlanTempus.Application/wwwroot/data/reports-data.json new file mode 100644 index 0000000..d9a36c5 --- /dev/null +++ b/PlanTempus.Application/wwwroot/data/reports-data.json @@ -0,0 +1,48 @@ +{ + "revenue": { + "series": [{ + "name": "Omsætning", + "color": "#00897b", + "type": "bar", + "unit": " kr", + "data": [ + { "x": "Feb", "y": 142500 }, + { "x": "Mar", "y": 168200 }, + { "x": "Apr", "y": 155800 }, + { "x": "Maj", "y": 178400 }, + { "x": "Jun", "y": 145600 }, + { "x": "Jul", "y": 98200 }, + { "x": "Aug", "y": 134500 }, + { "x": "Sep", "y": 189300 }, + { "x": "Okt", "y": 201400 }, + { "x": "Nov", "y": 178900 }, + { "x": "Dec", "y": 245600 }, + { "x": "Jan", "y": 187230 } + ] + }] + }, + "payment": { + "series": [ + { "name": "Kort", "color": "#1976d2", "type": "pie", "data": [{ "x": "", "y": 892400 }], "unit": " kr", "pie": { "innerRadius": 40, "outerRadius": 90 } }, + { "name": "MobilePay", "color": "#5C6BC0", "type": "pie", "data": [{ "x": "", "y": 445200 }], "unit": " kr", "pie": { "innerRadius": 40, "outerRadius": 90 } }, + { "name": "Kontant", "color": "#43a047", "type": "pie", "data": [{ "x": "", "y": 234800 }], "unit": " kr", "pie": { "innerRadius": 40, "outerRadius": 90 } }, + { "name": "Faktura", "color": "#f59e0b", "type": "pie", "data": [{ "x": "", "y": 278500 }], "unit": " kr", "pie": { "innerRadius": 40, "outerRadius": 90 } }, + { "name": "Fordelskort", "color": "#8b5cf6", "type": "pie", "data": [{ "x": "", "y": 74700 }], "unit": " kr", "pie": { "innerRadius": 40, "outerRadius": 90 } } + ] + }, + "hours": { + "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 }] } + ] + }, + "absence": { + "series": [ + { "name": "Syg", "color": "#e53935", "type": "pie", "unit": " t", "data": [{ "x": "Martin Nielsen", "y": 8 }, { "x": "Peter Hansen", "y": 4 }], "pie": { "innerRadius": 40, "outerRadius": 90 } }, + { "name": "Ferie", "color": "#f59e0b", "type": "pie", "unit": " t", "data": [{ "x": "Anna Jensen", "y": 4 }, { "x": "Peter Hansen", "y": 4 }], "pie": { "innerRadius": 40, "outerRadius": 90 } }, + { "name": "Fri", "color": "#8b5cf6", "type": "pie", "unit": " t", "data": [{ "x": "Sofie Larsen", "y": 4 }], "pie": { "innerRadius": 40, "outerRadius": 90 } } + ] + } +} diff --git a/PlanTempus.Application/wwwroot/ts/app.ts b/PlanTempus.Application/wwwroot/ts/app.ts index 0b05786..db2d6f7 100644 --- a/PlanTempus.Application/wwwroot/ts/app.ts +++ b/PlanTempus.Application/wwwroot/ts/app.ts @@ -62,7 +62,7 @@ let app: App; function init(): void { app = new App(); - // Expose to window for debugging + // Expose app to window for debugging if (typeof window !== 'undefined') { (window as unknown as { app: App }).app = app; } diff --git a/PlanTempus.Application/wwwroot/ts/modules/reports.ts b/PlanTempus.Application/wwwroot/ts/modules/reports.ts index 0f12a5b..3c5f29e 100644 --- a/PlanTempus.Application/wwwroot/ts/modules/reports.ts +++ b/PlanTempus.Application/wwwroot/ts/modules/reports.ts @@ -6,6 +6,7 @@ */ import Fuse from 'fuse.js'; +import { createChart } from '@sevenweirdpeople/swp-charting'; interface SalesDataItem { index: number; @@ -49,6 +50,31 @@ interface ChartSelectEvent extends CustomEvent { }; } +interface DataPoint { + x: string; + y: number; +} + +interface SeriesConfig { + name: string; + color: string; + type: 'bar' | 'pie' | 'line'; + data: DataPoint[]; + unit?: string; + pie?: { innerRadius: number; outerRadius: number }; +} + +interface ChartDataConfig { + series: SeriesConfig[]; +} + +interface ReportsData { + revenue: ChartDataConfig; + payment: ChartDataConfig; + hours: ChartDataConfig; + absence: ChartDataConfig; +} + export class ReportsController { private searchInput: HTMLInputElement | null = null; private dateFromInput: HTMLInputElement | null = null; @@ -59,6 +85,17 @@ export class ReportsController { private salesData: SalesDataItem[] = []; private fuse: Fuse | null = null; + // Chart references for lazy initialization + private revenueChart: ReturnType | null = null; + private paymentChart: ReturnType | null = null; + private hoursChart: ReturnType | null = null; + private absenceChart: ReturnType | null = null; + private salesChartsInitialized = false; + private hoursChartsInitialized = false; + + // Chart data loaded from JSON + private chartData: ReportsData | null = null; + // Map pie chart series names to payment filter values private readonly paymentMap: Record = { 'Kort': 'card', @@ -116,7 +153,26 @@ export class ReportsController { } this.setupTabs(); + this.setupPeriodSelector(); this.setupChartEvents(); + + // Load chart data from JSON and initialize charts + this.loadChartData().then(() => { + this.initializeSalesCharts(); + }); + } + + /** + * Load chart data from JSON file + */ + private async loadChartData(): Promise { + try { + const response = await fetch('/data/reports-data.json'); + if (!response.ok) return; + this.chartData = await response.json() as ReportsData; + } catch { + console.error('Failed to load reports chart data'); + } } /** @@ -332,6 +388,21 @@ export class ReportsController { statsRows.forEach(stats => { stats.classList.toggle('active', stats.dataset.forTab === targetTab); }); + + // Lazy-init charts for the active tab + if (targetTab === 'sales') { + if (this.chartData) { + this.initializeSalesCharts(); + } else { + this.loadChartData().then(() => this.initializeSalesCharts()); + } + } else if (targetTab === 'hours') { + if (this.chartData) { + this.initializeHoursCharts(); + } else { + this.loadChartData().then(() => this.initializeHoursCharts()); + } + } } /** @@ -604,4 +675,120 @@ export class ReportsController { // Apply filters to update the table this.applyAllFilters(); } + + /** + * Initialize sales tab charts (lazy, only when visible) + */ + private initializeSalesCharts(): void { + if (this.salesChartsInitialized) return; + this.revenueChart = this.initRevenueChart(); + this.paymentChart = this.initPaymentChart(); + this.salesChartsInitialized = true; + } + + /** + * Initialize hours tab charts (lazy, only when visible) + */ + private initializeHoursCharts(): void { + if (this.hoursChartsInitialized) return; + this.hoursChart = this.initHoursChart(); + this.absenceChart = this.initAbsenceChart(); + this.hoursChartsInitialized = true; + } + + /** + * Initialize revenue bar chart (Salgsrapport) + */ + private initRevenueChart(): ReturnType | null { + const el = document.getElementById('revenueChart'); + if (!el || !this.chartData?.revenue) return null; + + const series = this.chartData.revenue.series; + if (series.length === 0) return null; + + const categories = series[0].data.map(p => p.x); + + return createChart(el, { + deferRender: true, + height: 240, + xAxis: { categories }, + yAxis: { + format: (v: number) => `${Math.round(v / 1000)}k` + }, + series: series + }); + } + + /** + * Initialize payment methods pie chart (Salgsrapport) + */ + private initPaymentChart(): ReturnType | null { + const el = document.getElementById('paymentChart'); + if (!el || !this.chartData?.payment) return null; + + const series = this.chartData.payment.series; + if (series.length === 0) return null; + + return createChart(el, { + deferRender: true, + height: 240, + series: series, + tooltip: true, + legend: { position: 'right', align: 'center' } + }); + } + + /** + * Initialize hours per week bar chart (Timerapport) + */ + private initHoursChart(): ReturnType | null { + const el = document.getElementById('hoursChart'); + if (!el || !this.chartData?.hours) return null; + + const series = this.chartData.hours.series; + if (series.length === 0) return null; + + // Extract categories from first series + const categories = series[0]?.data.map(p => p.x) || []; + + return createChart(el, { + deferRender: true, + height: 240, + xAxis: { categories }, + yAxis: { format: (v: number) => v + ' t' }, + series: series, + legend: { position: 'bottom', align: 'center', gap: 0 } + }); + } + + /** + * Initialize absence distribution pie chart (Timerapport) + */ + private initAbsenceChart(): ReturnType | null { + const el = document.getElementById('absenceChart'); + if (!el || !this.chartData?.absence) return null; + + const series = this.chartData.absence.series; + if (series.length === 0) return null; + + return createChart(el, { + deferRender: true, + height: 240, + series: series, + legend: { position: 'right', align: 'center' } + }); + } + + /** + * Setup period selector functionality (Timerapport) + */ + private setupPeriodSelector(): void { + const buttons = document.querySelectorAll('swp-period-selector button'); + buttons.forEach(btn => { + btn.addEventListener('click', () => { + buttons.forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + }); + }); + } } diff --git a/README.md b/README.md deleted file mode 100644 index 03e1d4a..0000000 --- a/README.md +++ /dev/null @@ -1 +0,0 @@ -# PlanTempus diff --git a/global.json b/global.json deleted file mode 100644 index f4fd385..0000000 --- a/global.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "sdk": { - "version": "9.0.0", - "rollForward": "latestMajor", - "allowPrerelease": true - } -} \ No newline at end of file diff --git a/qodana.yaml b/qodana.yaml deleted file mode 100644 index 178e06f..0000000 --- a/qodana.yaml +++ /dev/null @@ -1,29 +0,0 @@ -#-------------------------------------------------------------------------------# -# Qodana analysis is configured by qodana.yaml file # -# https://www.jetbrains.com/help/qodana/qodana-yaml.html # -#-------------------------------------------------------------------------------# -version: "1.0" - -#Specify IDE code to run analysis without container (Applied in CI/CD pipeline) -ide: QDNET - -#Specify inspection profile for code analysis -profile: - name: qodana.starter - -#Enable inspections -#include: -# - name: - -#Disable inspections -#exclude: -# - name: -# paths: -# - - -#Execute shell command before Qodana execution (Applied in CI/CD pipeline) -#bootstrap: sh ./prepare-qodana.sh - -#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline) -#plugins: -# - id: #(plugin id can be found at https://plugins.jetbrains.com)