Adds time reporting UI with filtering and charts
Implements comprehensive hours report tab with: - Period and employee filtering - Statistical overview cards - Hours per week and absence distribution charts - Detailed employee hours table Enhances reports page interactivity and data visualization
This commit is contained in:
parent
2f92b0eb7b
commit
0144e1ae17
4 changed files with 605 additions and 49 deletions
|
|
@ -10,7 +10,13 @@
|
||||||
"Bash(node:*)",
|
"Bash(node:*)",
|
||||||
"Bash(npx esbuild:*)",
|
"Bash(npx esbuild:*)",
|
||||||
"mcp__ide__getDiagnostics",
|
"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)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -373,17 +373,126 @@
|
||||||
<!-- Tab Content: Timerapport -->
|
<!-- Tab Content: Timerapport -->
|
||||||
<swp-tab-content data-tab="hours">
|
<swp-tab-content data-tab="hours">
|
||||||
<swp-page-container>
|
<swp-page-container>
|
||||||
<swp-card>
|
<!-- Filter Bar -->
|
||||||
<swp-card-header>
|
<swp-filter-bar>
|
||||||
<swp-card-title>
|
<swp-period-selector>
|
||||||
<i class="ph ph-clock"></i>
|
<button data-period="week">Uge</button>
|
||||||
<span>Timerapport</span>
|
<button data-period="month" class="active">Måned</button>
|
||||||
</swp-card-title>
|
<button data-period="quarter">Kvartal</button>
|
||||||
</swp-card-header>
|
<button data-period="year">År</button>
|
||||||
<swp-empty-state>
|
</swp-period-selector>
|
||||||
<i class="ph ph-clock-counter-clockwise"></i>
|
<swp-filter-group>
|
||||||
<span>Timerapport kommer snart</span>
|
<swp-filter-label>Medarbejder</swp-filter-label>
|
||||||
</swp-empty-state>
|
<select id="employeeFilter">
|
||||||
|
<option value="">Alle medarbejdere</option>
|
||||||
|
<option value="anna">Anna Jensen</option>
|
||||||
|
<option value="martin">Martin Nielsen</option>
|
||||||
|
<option value="sofie">Sofie Larsen</option>
|
||||||
|
<option value="peter">Peter Hansen</option>
|
||||||
|
</select>
|
||||||
|
</swp-filter-group>
|
||||||
|
</swp-filter-bar>
|
||||||
|
|
||||||
|
<!-- Stats Bar -->
|
||||||
|
<swp-stats-row class="cols-4">
|
||||||
|
<swp-stat-card class="highlight">
|
||||||
|
<swp-stat-value>320 t</swp-stat-value>
|
||||||
|
<swp-stat-label>Planlagte timer</swp-stat-label>
|
||||||
|
</swp-stat-card>
|
||||||
|
<swp-stat-card class="danger">
|
||||||
|
<swp-stat-value>24 t</swp-stat-value>
|
||||||
|
<swp-stat-label>Fravær total</swp-stat-label>
|
||||||
|
</swp-stat-card>
|
||||||
|
<swp-stat-card class="warning">
|
||||||
|
<swp-stat-value>8 t</swp-stat-value>
|
||||||
|
<swp-stat-label>Overarbejde</swp-stat-label>
|
||||||
|
</swp-stat-card>
|
||||||
|
<swp-stat-card>
|
||||||
|
<swp-stat-value>7.5%</swp-stat-value>
|
||||||
|
<swp-stat-label>Fraværsprocent</swp-stat-label>
|
||||||
|
</swp-stat-card>
|
||||||
|
</swp-stats-row>
|
||||||
|
|
||||||
|
<!-- Charts Grid -->
|
||||||
|
<swp-charts-grid>
|
||||||
|
<swp-chart-card>
|
||||||
|
<swp-chart-header>
|
||||||
|
<swp-chart-title>Timer pr. uge</swp-chart-title>
|
||||||
|
<swp-chart-hint>Sidste 5 uger</swp-chart-hint>
|
||||||
|
</swp-chart-header>
|
||||||
|
<swp-chart-container id="hoursChart"></swp-chart-container>
|
||||||
|
</swp-chart-card>
|
||||||
|
<swp-chart-card>
|
||||||
|
<swp-chart-header>
|
||||||
|
<swp-chart-title>Fraværsfordeling</swp-chart-title>
|
||||||
|
<swp-chart-hint>Efter type</swp-chart-hint>
|
||||||
|
</swp-chart-header>
|
||||||
|
<swp-chart-container id="absenceChart"></swp-chart-container>
|
||||||
|
</swp-chart-card>
|
||||||
|
</swp-charts-grid>
|
||||||
|
|
||||||
|
<!-- Hours Table -->
|
||||||
|
<swp-card class="hours-table">
|
||||||
|
<swp-data-table>
|
||||||
|
<swp-data-table-header>
|
||||||
|
<swp-data-table-cell>Medarbejder</swp-data-table-cell>
|
||||||
|
<swp-data-table-cell class="right">Planlagt</swp-data-table-cell>
|
||||||
|
<swp-data-table-cell class="right">Fravær</swp-data-table-cell>
|
||||||
|
<swp-data-table-cell class="right">Syg</swp-data-table-cell>
|
||||||
|
<swp-data-table-cell class="right">Ferie</swp-data-table-cell>
|
||||||
|
<swp-data-table-cell class="right">Fri</swp-data-table-cell>
|
||||||
|
<swp-data-table-cell class="right">Overarbejde</swp-data-table-cell>
|
||||||
|
<swp-data-table-cell class="right">Fraværs-%</swp-data-table-cell>
|
||||||
|
</swp-data-table-header>
|
||||||
|
<!-- Row 1 -->
|
||||||
|
<swp-data-table-row>
|
||||||
|
<swp-data-table-cell class="name">Anna Jensen</swp-data-table-cell>
|
||||||
|
<swp-data-table-cell class="number right">80 t</swp-data-table-cell>
|
||||||
|
<swp-data-table-cell class="number right">4 t</swp-data-table-cell>
|
||||||
|
<swp-data-table-cell class="number right danger">0 t</swp-data-table-cell>
|
||||||
|
<swp-data-table-cell class="number right warning">4 t</swp-data-table-cell>
|
||||||
|
<swp-data-table-cell class="number right purple">0 t</swp-data-table-cell>
|
||||||
|
<swp-data-table-cell class="number right">2 t</swp-data-table-cell>
|
||||||
|
<swp-data-table-cell class="right"><swp-status-badge class="low">5.0%</swp-status-badge></swp-data-table-cell>
|
||||||
|
</swp-data-table-row>
|
||||||
|
<!-- Row 2 -->
|
||||||
|
<swp-data-table-row>
|
||||||
|
<swp-data-table-cell class="name">Martin Nielsen</swp-data-table-cell>
|
||||||
|
<swp-data-table-cell class="number right">80 t</swp-data-table-cell>
|
||||||
|
<swp-data-table-cell class="number right">8 t</swp-data-table-cell>
|
||||||
|
<swp-data-table-cell class="number right danger">8 t</swp-data-table-cell>
|
||||||
|
<swp-data-table-cell class="number right warning">0 t</swp-data-table-cell>
|
||||||
|
<swp-data-table-cell class="number right purple">0 t</swp-data-table-cell>
|
||||||
|
<swp-data-table-cell class="number right">0 t</swp-data-table-cell>
|
||||||
|
<swp-data-table-cell class="right"><swp-status-badge class="medium">10.0%</swp-status-badge></swp-data-table-cell>
|
||||||
|
</swp-data-table-row>
|
||||||
|
<!-- Row 3 -->
|
||||||
|
<swp-data-table-row>
|
||||||
|
<swp-data-table-cell class="name">Sofie Larsen</swp-data-table-cell>
|
||||||
|
<swp-data-table-cell class="number right">80 t</swp-data-table-cell>
|
||||||
|
<swp-data-table-cell class="number right">4 t</swp-data-table-cell>
|
||||||
|
<swp-data-table-cell class="number right danger">0 t</swp-data-table-cell>
|
||||||
|
<swp-data-table-cell class="number right warning">0 t</swp-data-table-cell>
|
||||||
|
<swp-data-table-cell class="number right purple">4 t</swp-data-table-cell>
|
||||||
|
<swp-data-table-cell class="number right">4 t</swp-data-table-cell>
|
||||||
|
<swp-data-table-cell class="right"><swp-status-badge class="low">5.0%</swp-status-badge></swp-data-table-cell>
|
||||||
|
</swp-data-table-row>
|
||||||
|
<!-- Row 4 -->
|
||||||
|
<swp-data-table-row>
|
||||||
|
<swp-data-table-cell class="name">Peter Hansen</swp-data-table-cell>
|
||||||
|
<swp-data-table-cell class="number right">80 t</swp-data-table-cell>
|
||||||
|
<swp-data-table-cell class="number right">8 t</swp-data-table-cell>
|
||||||
|
<swp-data-table-cell class="number right danger">4 t</swp-data-table-cell>
|
||||||
|
<swp-data-table-cell class="number right warning">4 t</swp-data-table-cell>
|
||||||
|
<swp-data-table-cell class="number right purple">0 t</swp-data-table-cell>
|
||||||
|
<swp-data-table-cell class="number right">2 t</swp-data-table-cell>
|
||||||
|
<swp-data-table-cell class="right"><swp-status-badge class="medium">10.0%</swp-status-badge></swp-data-table-cell>
|
||||||
|
</swp-data-table-row>
|
||||||
|
</swp-data-table>
|
||||||
|
<swp-table-footer>
|
||||||
|
<span>Viser 4 medarbejdere</span>
|
||||||
|
<span>Total: 320 t planlagt, 24 t fravær, 8 t overarbejde</span>
|
||||||
|
</swp-table-footer>
|
||||||
</swp-card>
|
</swp-card>
|
||||||
</swp-page-container>
|
</swp-page-container>
|
||||||
</swp-tab-content>
|
</swp-tab-content>
|
||||||
|
|
@ -436,5 +545,111 @@
|
||||||
tooltip: true,
|
tooltip: true,
|
||||||
legend: { position: 'right', align: 'center' }
|
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>
|
</script>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
/**
|
/**
|
||||||
* Reports - Statistik og Rapporter
|
* 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),
|
* 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
|
RESPONSIVE
|
||||||
=========================================== */
|
=========================================== */
|
||||||
|
|
|
||||||
|
|
@ -28,23 +28,95 @@ interface ParsedQuery {
|
||||||
invoicePrefix: string | null;
|
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 {
|
export class ReportsController {
|
||||||
private searchInput: HTMLInputElement | null = null;
|
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<HTMLElement> | null = null;
|
private tableRows: NodeListOf<HTMLElement> | null = null;
|
||||||
private salesData: SalesDataItem[] = [];
|
private salesData: SalesDataItem[] = [];
|
||||||
private fuse: Fuse<SalesDataItem> | null = null;
|
private fuse: Fuse<SalesDataItem> | null = null;
|
||||||
|
|
||||||
|
// Map pie chart series names to payment filter values
|
||||||
|
private readonly paymentMap: Record<string, string> = {
|
||||||
|
'Kort': 'card',
|
||||||
|
'MobilePay': 'mobilepay',
|
||||||
|
'Kontant': 'cash',
|
||||||
|
'Faktura': 'invoice',
|
||||||
|
'Fordelskort': 'giftcard',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Map status badge text to filter values
|
||||||
|
private readonly statusMap: Record<string, string> = {
|
||||||
|
'Betalt': 'paid',
|
||||||
|
'Afventer': 'pending',
|
||||||
|
'Krediteret': 'credited',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Map payment badge text to filter values
|
||||||
|
private readonly paymentTextMap: Record<string, string> = {
|
||||||
|
'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<string, MonthMapping> = {
|
||||||
|
'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() {
|
constructor() {
|
||||||
this.searchInput = document.getElementById('searchInput') as HTMLInputElement | null;
|
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<HTMLElement>('swp-card.sales-table swp-data-table-row');
|
this.tableRows = document.querySelectorAll<HTMLElement>('swp-card.sales-table swp-data-table-row');
|
||||||
|
|
||||||
if (this.searchInput && this.tableRows?.length) {
|
if (this.tableRows?.length) {
|
||||||
this.buildSearchData();
|
this.buildSearchData();
|
||||||
this.initializeFuse();
|
this.initializeFuse();
|
||||||
this.setupListeners();
|
this.setupListeners();
|
||||||
|
this.setupFilterListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setupTabs();
|
this.setupTabs();
|
||||||
|
this.setupChartEvents();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -81,12 +153,149 @@ export class ReportsController {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Setup event listeners
|
* Setup search event listener
|
||||||
*/
|
*/
|
||||||
private setupListeners(): void {
|
private setupListeners(): void {
|
||||||
this.searchInput?.addEventListener('input', (e) => this.handleSearch(e));
|
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<number>, fromDate: string, toDate: string): Set<number> {
|
||||||
|
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<string, number> = {
|
||||||
|
'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<number>, statusValue: string): Set<number> {
|
||||||
|
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<number>, paymentValue: string): Set<number> {
|
||||||
|
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
|
* Setup tab switching functionality
|
||||||
*/
|
*/
|
||||||
|
|
@ -122,39 +331,8 @@ export class ReportsController {
|
||||||
/**
|
/**
|
||||||
* Handle search input
|
* Handle search input
|
||||||
*/
|
*/
|
||||||
private handleSearch(e: Event): void {
|
private handleSearch(_e: Event): void {
|
||||||
const target = e.target as HTMLInputElement;
|
this.applyAllFilters();
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -358,4 +536,66 @@ export class ReportsController {
|
||||||
|
|
||||||
return { textQuery, minAmount, maxAmount, minInvoice, maxInvoice, invoicePrefix };
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue