Refactor frontend build and chart initialization

Moves chart data to JSON file for better separation of concerns
Implements lazy chart initialization in reports module
Updates build script and npm dependencies
Removes hardcoded chart scripts from Razor page
This commit is contained in:
Janus C. H. Knudsen 2026-01-22 16:32:46 +01:00
parent 097fe7f912
commit b921e26e48
12 changed files with 249 additions and 258 deletions

View file

@ -496,160 +496,3 @@
</swp-card>
</swp-page-container>
</swp-tab-content>
@section Scripts {
<script type="module">
import { createChart } from '/lib/swp-charting/dist/swp-charting.js';
// Revenue bar chart
createChart(document.getElementById('revenueChart'), {
height: 240,
xAxis: {
categories: ['Feb', 'Mar', 'Apr', 'Maj', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dec', 'Jan']
},
yAxis: {
format: (v) => `${Math.round(v / 1000)}k`
},
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 methods pie chart
createChart(document.getElementById('paymentChart'), {
height: 240,
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: 178500 }], unit: ' kr', pie: { innerRadius: 40, outerRadius: 90 } },
{ name: 'Fordelskort', color: '#8b5cf6', type: 'pie', data: [{ x: '', y: 74700 }], unit: ' kr', pie: { innerRadius: 40, outerRadius: 90 } }
],
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');
});
});
</script>
}

View file

@ -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();

View file

@ -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": {

View file

@ -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"
}
}

View file

@ -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 } }
]
}
}

View file

@ -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;
}

View file

@ -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<SalesDataItem> | null = null;
// Chart references for lazy initialization
private revenueChart: ReturnType<typeof createChart> | null = null;
private paymentChart: ReturnType<typeof createChart> | null = null;
private hoursChart: ReturnType<typeof createChart> | null = null;
private absenceChart: ReturnType<typeof createChart> | 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<string, string> = {
'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<void> {
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<typeof createChart> | 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<typeof createChart> | 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<typeof createChart> | 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<typeof createChart> | 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<HTMLButtonElement>('swp-period-selector button');
buttons.forEach(btn => {
btn.addEventListener('click', () => {
buttons.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
}
}