PlanTempusApp/PlanTempus.Application/wwwroot/ts/modules/employees.ts
Janus C. H. Knudsen eaae745c42 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
2026-01-22 23:28:33 +01:00

1379 lines
42 KiB
TypeScript

import { createChart } from '@sevenweirdpeople/swp-charting';
/**
* Employees Controller
*
* Handles content swap between list view and detail view,
* plus tab switching within each view.
* 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;
constructor() {
this.listView = document.getElementById('employees-list-view');
this.detailView = document.getElementById('employee-detail-view');
// Only initialize if we're on the employees page
if (!this.listView) return;
this.setupListTabs();
this.setupDetailTabs();
this.setupChevronNavigation();
this.setupBackNavigation();
this.setupHistoryNavigation();
this.restoreStateFromUrl();
this.ratesSync = new RatesSyncController();
this.scheduleController = new ScheduleController();
this.statsController = new EmployeeStatsController();
}
/**
* Setup popstate listener for browser back/forward
*/
private setupHistoryNavigation(): void {
window.addEventListener('popstate', (e: PopStateEvent) => {
if (e.state?.employeeKey) {
this.showDetailViewInternal(e.state.employeeKey);
} else {
this.showListViewInternal();
}
});
}
/**
* Restore view state from URL on page load
*/
private restoreStateFromUrl(): void {
const hash = window.location.hash;
if (hash.startsWith('#employee-')) {
const employeeKey = hash.substring(1); // Remove #
this.showDetailViewInternal(employeeKey);
}
}
/**
* Setup tab switching for the list view
*/
private setupListTabs(): void {
if (!this.listView) return;
const tabs = this.listView.querySelectorAll<HTMLElement>('swp-tab-bar > swp-tab[data-tab]');
tabs.forEach(tab => {
tab.addEventListener('click', () => {
const targetTab = tab.dataset.tab;
if (targetTab) {
this.switchTab(this.listView!, targetTab);
}
});
});
}
/**
* Setup tab switching for the detail view
*/
private setupDetailTabs(): void {
if (!this.detailView) return;
const tabs = this.detailView.querySelectorAll<HTMLElement>('swp-tab-bar > swp-tab[data-tab]');
tabs.forEach(tab => {
tab.addEventListener('click', () => {
const targetTab = tab.dataset.tab;
if (targetTab) {
this.switchTab(this.detailView!, targetTab);
}
});
});
}
/**
* Switch to a specific tab within a container
*/
private switchTab(container: HTMLElement, targetTab: string): void {
const tabs = container.querySelectorAll<HTMLElement>('swp-tab-bar > swp-tab[data-tab]');
const contents = container.querySelectorAll<HTMLElement>('swp-tab-content[data-tab]');
tabs.forEach(t => {
t.classList.toggle('active', t.dataset.tab === targetTab);
});
contents.forEach(content => {
content.classList.toggle('active', content.dataset.tab === targetTab);
});
}
/**
* Setup row click to show detail view
* Ignores clicks on action buttons
*/
private setupChevronNavigation(): void {
document.addEventListener('click', (e: Event) => {
const target = e.target as HTMLElement;
// Ignore clicks on action buttons
if (target.closest('swp-icon-btn') || target.closest('swp-table-actions')) {
return;
}
const row = target.closest<HTMLElement>('swp-data-table-row[data-employee-detail]');
if (row) {
const employeeKey = row.dataset.employeeDetail;
if (employeeKey) {
this.showDetailView(employeeKey);
}
}
});
}
/**
* Setup back button to return to list view
*/
private setupBackNavigation(): void {
document.addEventListener('click', (e: Event) => {
const target = e.target as HTMLElement;
const backLink = target.closest<HTMLElement>('[data-employee-back]');
if (backLink) {
this.showListView();
}
});
}
/**
* Show the detail view and hide list view (with history push)
*/
private showDetailView(employeeKey: string): void {
// Push state to history
history.pushState(
{ employeeKey },
'',
`#${employeeKey}`
);
this.showDetailViewInternal(employeeKey);
}
/**
* Show detail view without modifying history (for popstate)
*/
private showDetailViewInternal(employeeKey: string): void {
if (this.listView && this.detailView) {
// Fade out list view
this.listView.classList.add('view-fade-out');
// After fade, switch views
setTimeout(() => {
this.listView!.style.display = 'none';
this.listView!.classList.remove('view-fade-out');
// Show detail view with fade in
this.detailView!.style.display = 'block';
this.detailView!.classList.add('view-fade-out');
this.detailView!.dataset.employee = employeeKey;
// Reset to first tab
this.switchTab(this.detailView!, 'general');
// Trigger fade in
requestAnimationFrame(() => {
this.detailView!.classList.remove('view-fade-out');
});
}, 100);
}
}
/**
* Show the list view and hide detail view (with history push)
*/
private showListView(): void {
// Push state to history (clear hash)
history.pushState(
{},
'',
window.location.pathname
);
this.showListViewInternal();
}
/**
* Show list view without modifying history (for popstate)
*/
private showListViewInternal(): void {
if (this.listView && this.detailView) {
// Fade out detail view
this.detailView.classList.add('view-fade-out');
// After fade, switch views
setTimeout(() => {
this.detailView!.style.display = 'none';
this.detailView!.classList.remove('view-fade-out');
// Show list view with fade in
this.listView!.style.display = 'block';
this.listView!.classList.add('view-fade-out');
// Trigger fade in
requestAnimationFrame(() => {
this.listView!.classList.remove('view-fade-out');
});
}, 100);
}
}
}
/**
* Rates Sync Controller
*
* Syncs changes between the rates drawer and the salary tab cards.
* Uses ID-based lookups:
* - Checkbox: id="rate-{key}-enabled"
* - Text input: id="rate-{key}"
* - Card row: id="card-{key}"
*/
class RatesSyncController {
private drawer: HTMLElement | null = null;
constructor() {
this.drawer = document.getElementById('rates-drawer');
if (!this.drawer) return;
this.setupCheckboxListeners();
this.setupInputListeners();
this.setupDoubleClickToEdit();
}
/**
* Extract rate key from checkbox ID (e.g., "rate-normal-enabled" → "normal")
*/
private extractRateKey(checkboxId: string): string | null {
const match = checkboxId.match(/^rate-(.+)-enabled$/);
return match ? match[1] : null;
}
/**
* Setup checkbox change listeners in drawer
*/
private setupCheckboxListeners(): void {
if (!this.drawer) return;
this.drawer.addEventListener('change', (e: Event) => {
const target = e.target as HTMLInputElement;
if (target.type !== 'checkbox' || !target.id) return;
const rateKey = this.extractRateKey(target.id);
if (!rateKey) return;
const isChecked = target.checked;
const row = target.closest<HTMLElement>('swp-data-row');
if (!row) return;
// Toggle disabled class in drawer row
const label = row.querySelector('swp-data-label');
const input = row.querySelector('swp-data-input');
if (label) label.classList.toggle('disabled', !isChecked);
if (input) input.classList.toggle('disabled', !isChecked);
// Toggle visibility in card
this.toggleCardRow(rateKey, isChecked);
// If enabling, also sync the current value
if (isChecked) {
const textInput = document.getElementById(`rate-${rateKey}`) as HTMLInputElement | null;
if (textInput) {
this.syncValueToCard(rateKey, textInput.value);
}
}
});
}
/**
* Setup input change listeners in drawer
*/
private setupInputListeners(): void {
if (!this.drawer) return;
this.drawer.addEventListener('input', (e: Event) => {
const target = e.target as HTMLInputElement;
if (target.type !== 'text' || !target.id) return;
// Extract rate key from input ID (e.g., "rate-normal" → "normal")
const match = target.id.match(/^rate-(.+)$/);
if (!match) return;
const rateKey = match[1];
// Skip if this matches the checkbox pattern
if (rateKey.endsWith('-enabled')) return;
this.syncValueToCard(rateKey, target.value);
});
}
/**
* Toggle card row visibility by ID
*/
private toggleCardRow(rateKey: string, visible: boolean): void {
const cardRow = document.getElementById(`card-${rateKey}`);
if (cardRow) {
cardRow.style.display = visible ? '' : 'none';
}
}
/**
* Format number with 2 decimals using Danish locale (comma as decimal separator)
*/
private formatNumber(value: string): string {
// Parse the input (handle both dot and comma as decimal separator)
const normalized = value.replace(',', '.');
const num = parseFloat(normalized);
if (isNaN(num)) return value;
// Format with 2 decimals and comma as decimal separator
return num.toFixed(2).replace('.', ',');
}
/**
* Sync value from drawer to card by ID
*/
private syncValueToCard(rateKey: string, value: string): void {
const cardInput = document.getElementById(`value-${rateKey}`) as HTMLInputElement | null;
if (!cardInput) return;
// Get the unit from drawer input container
const textInput = document.getElementById(`rate-${rateKey}`);
const inputContainer = textInput?.closest('swp-data-input');
const unit = inputContainer?.textContent?.trim().replace(value, '').trim() || 'kr';
// Format with 2 decimals
const formattedValue = this.formatNumber(value);
cardInput.value = `${formattedValue} ${unit}`;
}
/**
* Setup double-click on salary card inputs to open drawer and focus field
*/
private setupDoubleClickToEdit(): void {
document.addEventListener('dblclick', (e: Event) => {
const target = e.target as HTMLElement;
const input = target.closest<HTMLInputElement>('input[id^="value-"]');
if (!input || !input.id) return;
// Extract key from value-{key}
const match = input.id.match(/^value-(.+)$/);
if (!match) return;
const rateKey = match[1];
this.openDrawerAndFocus(rateKey);
});
}
/**
* Open drawer and focus the corresponding field with highlight
*/
private openDrawerAndFocus(rateKey: string): void {
// Open the drawer
const trigger = document.querySelector<HTMLElement>('[data-drawer-trigger="rates-drawer"]');
trigger?.click();
// Wait for drawer to open, then focus field
requestAnimationFrame(() => {
const drawerInput = document.getElementById(`rate-${rateKey}`) as HTMLInputElement | null;
if (!drawerInput) return;
// Focus the input
drawerInput.focus();
drawerInput.select();
// Add highlight to row
const row = drawerInput.closest<HTMLElement>('swp-data-row');
if (row) {
row.classList.add('focus-highlight');
// Remove class after animation
setTimeout(() => row.classList.remove('focus-highlight'), 1000);
}
});
}
}
/**
* Schedule Controller
*
* Handles work schedule (vagtplan) functionality:
* - Edit mode toggle
* - Cell selection (single, ctrl+click, shift+click)
* - Drawer interaction
* - Time range slider
* - Status options
*/
class ScheduleController {
private isEditMode = false;
private selectedCells: HTMLElement[] = [];
private anchorCell: HTMLElement | null = null;
private drawer: HTMLElement | null = null;
private editBtn: HTMLElement | null = null;
private scheduleTable: HTMLElement | null = null;
constructor() {
this.drawer = document.getElementById('schedule-drawer');
this.editBtn = document.getElementById('scheduleEditBtn');
this.scheduleTable = document.getElementById('scheduleTable');
// Drag scroll works for all schedule scroll containers (Vagtplan + Arbejdstid)
this.setupDragScroll();
if (!this.scheduleTable) return;
this.setupEditModeToggle();
this.setupCellSelection();
this.setupStatusOptions();
this.setupTypeToggle();
this.setupTimeRangeEvents();
this.setupDrawerSave();
}
/**
* Setup drag-to-scroll on schedule scroll containers
*/
private setupDragScroll(): void {
const scrollContainers = document.querySelectorAll('swp-schedule-scroll');
scrollContainers.forEach(container => {
const el = container as HTMLElement;
let isDown = false;
let hasDragged = false;
let startX = 0;
let scrollLeft = 0;
const onMouseDown = (e: MouseEvent) => {
// Don't drag if clicking on interactive elements
if ((e.target as HTMLElement).closest('swp-btn, input, select')) return;
isDown = true;
hasDragged = false;
startX = e.clientX;
scrollLeft = el.scrollLeft;
};
const onMouseUp = () => {
isDown = false;
el.classList.remove('dragging');
// Reset hasDragged after a short delay to allow click events to check it
setTimeout(() => { hasDragged = false; }, 0);
};
const onMouseMove = (e: MouseEvent) => {
if (!isDown) return;
const x = e.clientX;
const diff = Math.abs(startX - x);
// Only start dragging after moving 5px (allows clicks/double-clicks)
if (!hasDragged && diff > 5) {
hasDragged = true;
el.classList.add('dragging');
}
if (!hasDragged) return;
e.preventDefault();
const walk = (startX - x) * 1.5;
el.scrollLeft = scrollLeft + walk;
};
// Prevent click events if we just dragged
const onClick = (e: MouseEvent) => {
if (hasDragged) {
e.preventDefault();
e.stopPropagation();
}
};
el.addEventListener('mousedown', onMouseDown);
el.addEventListener('mouseup', onMouseUp);
el.addEventListener('mouseleave', onMouseUp);
el.addEventListener('mousemove', onMouseMove);
el.addEventListener('click', onClick, true); // capture phase
});
}
/**
* Setup edit mode toggle button
*/
private setupEditModeToggle(): void {
this.editBtn?.addEventListener('click', () => {
this.isEditMode = !this.isEditMode;
document.body.classList.toggle('schedule-edit-mode', this.isEditMode);
if (this.editBtn) {
const icon = this.editBtn.querySelector('i');
const text = this.editBtn.childNodes[this.editBtn.childNodes.length - 1];
if (this.isEditMode) {
icon?.classList.replace('ph-pencil-simple', 'ph-check');
if (text && text.nodeType === Node.TEXT_NODE) {
text.textContent = ' Færdig';
}
this.openDrawer();
this.showEmptyState();
} else {
icon?.classList.replace('ph-check', 'ph-pencil-simple');
if (text && text.nodeType === Node.TEXT_NODE) {
text.textContent = ' Rediger';
}
this.closeDrawer();
this.clearSelection();
}
}
});
}
/**
* Setup cell click selection
*/
private setupCellSelection(): void {
if (!this.scheduleTable) return;
const dayCells = this.scheduleTable.querySelectorAll<HTMLElement>('swp-schedule-cell.day');
dayCells.forEach(cell => {
// Double-click to enter edit mode
cell.addEventListener('dblclick', () => {
if (!this.isEditMode) {
this.isEditMode = true;
document.body.classList.add('schedule-edit-mode');
if (this.editBtn) {
const icon = this.editBtn.querySelector('i');
const text = this.editBtn.childNodes[this.editBtn.childNodes.length - 1];
icon?.classList.replace('ph-pencil-simple', 'ph-check');
if (text && text.nodeType === Node.TEXT_NODE) {
text.textContent = ' Færdig';
}
}
this.openDrawer();
this.clearSelection();
cell.classList.add('selected');
this.selectedCells = [cell];
this.anchorCell = cell;
this.updateDrawerFields();
}
});
// Click selection in edit mode
cell.addEventListener('click', (e: MouseEvent) => {
if (!this.isEditMode) return;
if (e.shiftKey && this.anchorCell) {
// Shift+click: range selection
this.selectRange(this.anchorCell, cell);
} else if (e.ctrlKey || e.metaKey) {
// Ctrl/Cmd+click: toggle selection
if (cell.classList.contains('selected')) {
cell.classList.remove('selected');
this.selectedCells = this.selectedCells.filter(c => c !== cell);
} else {
cell.classList.add('selected');
this.selectedCells.push(cell);
this.anchorCell = cell;
}
} else {
// Single click: replace selection
this.clearSelection();
cell.classList.add('selected');
this.selectedCells = [cell];
this.anchorCell = cell;
}
this.updateDrawerFields();
});
});
}
/**
* Select a range of cells between anchor and target
*/
private selectRange(anchor: HTMLElement, target: HTMLElement): void {
if (!this.scheduleTable) return;
const allDayCells = Array.from(this.scheduleTable.querySelectorAll<HTMLElement>('swp-schedule-cell.day'));
const anchorIdx = allDayCells.indexOf(anchor);
const targetIdx = allDayCells.indexOf(target);
// Calculate grid positions (7 columns for days)
const anchorRow = Math.floor(anchorIdx / 7);
const anchorCol = anchorIdx % 7;
const targetRow = Math.floor(targetIdx / 7);
const targetCol = targetIdx % 7;
const minRow = Math.min(anchorRow, targetRow);
const maxRow = Math.max(anchorRow, targetRow);
const minCol = Math.min(anchorCol, targetCol);
const maxCol = Math.max(anchorCol, targetCol);
this.clearSelection();
allDayCells.forEach((c, idx) => {
const row = Math.floor(idx / 7);
const col = idx % 7;
if (row >= minRow && row <= maxRow && col >= minCol && col <= maxCol) {
c.classList.add('selected');
this.selectedCells.push(c);
}
});
}
/**
* Clear all selected cells
*/
private clearSelection(): void {
this.selectedCells.forEach(c => c.classList.remove('selected'));
this.selectedCells = [];
}
/**
* Get initials from name
*/
private getInitials(name: string): string {
return name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
}
/**
* Show empty state in drawer
*/
private showEmptyState(): void {
const employeeDisplay = document.getElementById('scheduleFieldEmployee');
const avatar = document.getElementById('scheduleFieldAvatar');
const employeeName = document.getElementById('scheduleFieldEmployeeName');
const dateField = document.getElementById('scheduleFieldDate');
const weekdayField = document.getElementById('scheduleFieldWeekday');
const drawerBody = this.drawer?.querySelector('swp-drawer-body') as HTMLElement;
const drawerFooter = this.drawer?.querySelector('swp-drawer-footer') as HTMLElement;
if (employeeDisplay) employeeDisplay.classList.add('empty');
if (employeeDisplay) employeeDisplay.classList.remove('multi');
if (avatar) avatar.textContent = '?';
if (employeeName) employeeName.textContent = 'Vælg celle...';
if (dateField) dateField.textContent = '—';
if (weekdayField) weekdayField.textContent = '—';
if (drawerBody) {
drawerBody.style.opacity = '0.5';
drawerBody.style.pointerEvents = 'none';
}
if (drawerFooter) drawerFooter.style.display = 'none';
}
/**
* Show edit state in drawer
*/
private showEditState(): void {
const drawerBody = this.drawer?.querySelector('swp-drawer-body') as HTMLElement;
const drawerFooter = this.drawer?.querySelector('swp-drawer-footer') as HTMLElement;
if (drawerBody) {
drawerBody.style.opacity = '1';
drawerBody.style.pointerEvents = 'auto';
}
if (drawerFooter) drawerFooter.style.display = 'flex';
}
/**
* Update drawer fields based on selected cells
*/
private updateDrawerFields(): void {
if (this.selectedCells.length === 0) {
this.showEmptyState();
return;
}
this.showEditState();
const employeeDisplay = document.getElementById('scheduleFieldEmployee');
const avatar = document.getElementById('scheduleFieldAvatar');
const employeeName = document.getElementById('scheduleFieldEmployeeName');
const dateField = document.getElementById('scheduleFieldDate');
const weekdayField = document.getElementById('scheduleFieldWeekday');
employeeDisplay?.classList.remove('empty', 'multi');
if (this.selectedCells.length === 1) {
const cell = this.selectedCells[0];
const name = cell.dataset.employee || '';
const date = cell.dataset.date || '';
const dayName = this.getDayName(cell.dataset.day || '');
if (employeeName) employeeName.textContent = name;
if (avatar) avatar.textContent = this.getInitials(name);
if (dateField) dateField.textContent = this.formatDate(date);
if (weekdayField) weekdayField.textContent = dayName;
this.prefillFormFromCell(cell);
} else {
const employees = [...new Set(this.selectedCells.map(c => c.dataset.employee))];
const days = [...new Set(this.selectedCells.map(c => c.dataset.day))];
if (employees.length === 1) {
if (employeeName) employeeName.textContent = employees[0] || '';
if (avatar) avatar.textContent = this.getInitials(employees[0] || '');
if (dateField) dateField.textContent = `${this.selectedCells.length} dage valgt`;
} else {
if (employeeName) employeeName.textContent = `${this.selectedCells.length} valgt`;
if (avatar) avatar.textContent = String(this.selectedCells.length);
employeeDisplay?.classList.add('multi');
if (dateField) dateField.textContent = `${employees.length} medarbejdere, ${days.length} dage`;
}
if (days.length === 1) {
if (weekdayField) weekdayField.textContent = this.getDayName(days[0] || '');
} else {
if (weekdayField) weekdayField.textContent = 'Flere dage';
}
this.resetFormToDefault();
}
}
/**
* Format ISO date to display format
*/
private formatDate(isoDate: string): string {
const date = new Date(isoDate);
return `${date.getDate()}/${date.getMonth() + 1}/${date.getFullYear()}`;
}
/**
* Get full day name from short name
*/
private getDayName(shortName: string): string {
const dayMap: Record<string, string> = {
'Man': 'Mandag', 'Tir': 'Tirsdag', 'Ons': 'Onsdag',
'Tor': 'Torsdag', 'Fre': 'Fredag', 'Lør': 'Lørdag', 'Søn': 'Søndag'
};
return dayMap[shortName] || shortName;
}
/**
* Prefill form from cell data
*/
private prefillFormFromCell(cell: HTMLElement): void {
const timeBadge = cell.querySelector('swp-time-badge');
if (!timeBadge) return;
let status = 'work';
if (timeBadge.classList.contains('off')) status = 'off';
else if (timeBadge.classList.contains('vacation')) status = 'vacation';
else if (timeBadge.classList.contains('sick')) status = 'sick';
// Update status options
document.querySelectorAll('#scheduleStatusOptions swp-status-option').forEach(opt => {
opt.classList.toggle('selected', (opt as HTMLElement).dataset.status === status);
});
// Show/hide time row
const timeRow = document.getElementById('scheduleTimeRow');
if (timeRow) timeRow.style.display = status === 'work' ? 'flex' : 'none';
// Parse time if work status
if (status === 'work') {
const timeText = timeBadge.textContent?.trim() || '';
const timeMatch = timeText.match(/(\d{2}:\d{2})\s*-\s*(\d{2}:\d{2})/);
if (timeMatch) {
this.setTimeRange(timeMatch[1], timeMatch[2]);
}
}
// Reset type to template
document.querySelectorAll('#scheduleTypeOptions swp-toggle-option').forEach(opt => {
opt.classList.toggle('selected', (opt as HTMLElement).dataset.value === 'template');
});
const repeatGroup = document.getElementById('scheduleRepeatGroup');
if (repeatGroup) repeatGroup.style.display = 'block';
}
/**
* Reset form to default values
*/
private resetFormToDefault(): void {
document.querySelectorAll('#scheduleStatusOptions swp-status-option').forEach(opt => {
opt.classList.toggle('selected', (opt as HTMLElement).dataset.status === 'work');
});
const timeRow = document.getElementById('scheduleTimeRow');
if (timeRow) timeRow.style.display = 'flex';
this.setTimeRange('09:00', '17:00');
document.querySelectorAll('#scheduleTypeOptions swp-toggle-option').forEach(opt => {
opt.classList.toggle('selected', (opt as HTMLElement).dataset.value === 'template');
});
const repeatGroup = document.getElementById('scheduleRepeatGroup');
if (repeatGroup) repeatGroup.style.display = 'block';
const noteField = document.getElementById('scheduleFieldNote') as HTMLInputElement;
if (noteField) noteField.value = '';
}
/**
* Setup status options click handlers
*/
private setupStatusOptions(): void {
document.querySelectorAll('#scheduleStatusOptions swp-status-option').forEach(option => {
option.addEventListener('click', () => {
document.querySelectorAll('#scheduleStatusOptions swp-status-option').forEach(o =>
o.classList.remove('selected')
);
option.classList.add('selected');
const status = (option as HTMLElement).dataset.status;
const timeRow = document.getElementById('scheduleTimeRow');
if (timeRow) timeRow.style.display = status === 'work' ? 'flex' : 'none';
this.updateSelectedCellsStatus();
});
});
}
/**
* Setup type toggle (single/repeat)
*/
private setupTypeToggle(): void {
document.querySelectorAll('#scheduleTypeOptions swp-toggle-option').forEach(option => {
option.addEventListener('click', () => {
document.querySelectorAll('#scheduleTypeOptions swp-toggle-option').forEach(o =>
o.classList.remove('selected')
);
option.classList.add('selected');
const isTemplate = (option as HTMLElement).dataset.value === 'template';
const repeatGroup = document.getElementById('scheduleRepeatGroup');
if (repeatGroup) repeatGroup.style.display = isTemplate ? 'block' : 'none';
});
});
}
/**
* Convert slider value to time string
*/
private valueToTime(value: number): string {
const totalMinutes = (value * 15) + (6 * 60);
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
}
/**
* Convert time string to slider value
*/
private timeToValue(timeStr: string): number {
const [hours, minutes] = timeStr.split(':').map(Number);
const totalMinutes = hours * 60 + minutes;
return Math.round((totalMinutes - 6 * 60) / 15);
}
/**
* Set time range slider values
* Triggers 'input' event to let ControlsController update the display
*/
private setTimeRange(startTime: string, endTime: string): void {
const timeRange = document.getElementById('scheduleTimeRange');
if (!timeRange) return;
const startInput = timeRange.querySelector<HTMLInputElement>('.range-start');
const endInput = timeRange.querySelector<HTMLInputElement>('.range-end');
if (startInput) {
startInput.value = String(this.timeToValue(startTime));
startInput.dispatchEvent(new Event('input', { bubbles: true }));
}
if (endInput) {
endInput.value = String(this.timeToValue(endTime));
endInput.dispatchEvent(new Event('input', { bubbles: true }));
}
}
/**
* Setup time range event listener
* ControlsController handles the slider UI; we just listen for changes
*/
private setupTimeRangeEvents(): void {
const timeRange = document.getElementById('scheduleTimeRange');
timeRange?.addEventListener('timerange:change', () => {
this.updateSelectedCellsTime();
});
}
/**
* Update selected cells with current time
*/
private updateSelectedCellsTime(): void {
const selectedStatus = document.querySelector('#scheduleStatusOptions swp-status-option.selected') as HTMLElement;
const status = selectedStatus?.dataset.status || 'work';
if (status !== 'work') return;
const timeRange = document.getElementById('scheduleTimeRange');
if (!timeRange) return;
const startInput = timeRange.querySelector<HTMLInputElement>('.range-start');
const endInput = timeRange.querySelector<HTMLInputElement>('.range-end');
if (!startInput || !endInput) return;
const startTime = this.valueToTime(parseInt(startInput.value));
const endTime = this.valueToTime(parseInt(endInput.value));
const formattedTime = `${startTime} - ${endTime}`;
this.selectedCells.forEach(cell => {
const timeDisplay = cell.querySelector('swp-time-badge');
if (timeDisplay && !timeDisplay.classList.contains('off') &&
!timeDisplay.classList.contains('vacation') &&
!timeDisplay.classList.contains('sick')) {
timeDisplay.textContent = formattedTime;
}
});
}
/**
* Update selected cells with current status
*/
private updateSelectedCellsStatus(): void {
const selectedStatus = document.querySelector('#scheduleStatusOptions swp-status-option.selected') as HTMLElement;
const status = selectedStatus?.dataset.status || 'work';
const timeRange = document.getElementById('scheduleTimeRange');
const startInput = timeRange?.querySelector<HTMLInputElement>('.range-start');
const endInput = timeRange?.querySelector<HTMLInputElement>('.range-end');
const startTime = startInput ? this.valueToTime(parseInt(startInput.value)) : '09:00';
const endTime = endInput ? this.valueToTime(parseInt(endInput.value)) : '17:00';
this.selectedCells.forEach(cell => {
const timeDisplay = cell.querySelector('swp-time-badge');
if (!timeDisplay) return;
timeDisplay.classList.remove('off', 'off-override', 'vacation', 'sick');
switch (status) {
case 'work':
timeDisplay.textContent = `${startTime} - ${endTime}`;
break;
case 'off':
timeDisplay.classList.add('off');
timeDisplay.textContent = '—';
break;
case 'vacation':
timeDisplay.classList.add('vacation');
timeDisplay.textContent = 'Ferie';
break;
case 'sick':
timeDisplay.classList.add('sick');
timeDisplay.textContent = 'Syg';
break;
}
});
}
/**
* Setup drawer save button
*/
private setupDrawerSave(): void {
const saveBtn = document.getElementById('scheduleDrawerSave');
saveBtn?.addEventListener('click', () => {
// Changes are already applied in real-time
this.clearSelection();
this.showEmptyState();
});
}
/**
* Open the schedule drawer (no overlay - user can still interact with table)
*/
private openDrawer(): void {
const container = document.querySelector('swp-tab-content[data-tab="schedule"] swp-page-container') as HTMLElement;
const table = document.getElementById('scheduleTable');
// Gem nuværende padding FØR klasser tilføjes
const startPadding = container ? getComputedStyle(container).paddingRight : '0px';
// Lås tabelbredde før drawer åbner for at undgå "hop"
if (table) {
const rect = table.getBoundingClientRect();
table.style.width = `${rect.width}px`;
}
// Tilføj klasser med det samme (maxWidth og margin ændres instant)
this.drawer?.classList.add('open');
document.body.classList.add('schedule-drawer-open');
// Animate kun padding fra gemt værdi
if (container) {
container.animate([
{ paddingRight: startPadding },
{ paddingRight: '420px' }
], {
duration: 200,
easing: 'ease',
fill: 'forwards'
});
}
}
/**
* Close the schedule drawer
*/
private closeDrawer(): void {
// Luk drawer med det samme (visuelt)
this.drawer?.classList.remove('open');
const container = document.querySelector('swp-tab-content[data-tab="schedule"] swp-page-container') as HTMLElement;
const table = document.getElementById('scheduleTable');
if (container) {
const animation = container.getAnimations()[0];
if (animation) {
// Afspil animationen baglæns
animation.reverse();
animation.onfinish = () => {
animation.cancel();
// Fjern klasser og låst bredde når animation er færdig
document.body.classList.remove('schedule-drawer-open');
if (table) {
table.style.width = '';
}
};
return; // Exit early - cleanup happens in onfinish
}
}
// Ingen animation, fjern klasser og låst bredde med det samme
document.body.classList.remove('schedule-drawer-open');
if (table) {
table.style.width = '';
}
}
}
/**
* 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<void> | null = null;
private chartsInitialized = false;
private revenueUtilizationChart: ReturnType<typeof createChart> | null = null;
private revenueChart: ReturnType<typeof createChart> | 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<HTMLElement>('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<void> {
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<typeof createChart> | 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<typeof createChart> | 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 = `
<swp-data-table-cell>${booking.customer}</swp-data-table-cell>
<swp-data-table-cell>${booking.service}</swp-data-table-cell>
<swp-data-table-cell>${booking.date}</swp-data-table-cell>
<swp-data-table-cell>${booking.amount}</swp-data-table-cell>
`;
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 = `
<swp-data-table-cell>${booking.date}</swp-data-table-cell>
<swp-data-table-cell>${booking.time}</swp-data-table-cell>
<swp-data-table-cell>${booking.customer}</swp-data-table-cell>
<swp-data-table-cell>${booking.services}</swp-data-table-cell>
<swp-data-table-cell>${booking.duration}</swp-data-table-cell>
<swp-data-table-cell>${booking.amount}</swp-data-table-cell>
<swp-data-table-cell>
<swp-status-badge class="${booking.statusClass}">${booking.status}</swp-status-badge>
</swp-data-table-cell>
`;
table.appendChild(row);
});
}
}