2026-01-22 23:28:33 +01:00
|
|
|
import { createChart } from '@sevenweirdpeople/swp-charting';
|
|
|
|
|
|
2026-01-12 22:10:57 +01:00
|
|
|
/**
|
|
|
|
|
* 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.
|
|
|
|
|
*/
|
|
|
|
|
|
2026-01-22 23:28:33 +01:00
|
|
|
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[];
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-12 22:10:57 +01:00
|
|
|
export class EmployeesController {
|
2026-01-13 22:37:29 +01:00
|
|
|
private ratesSync: RatesSyncController | null = null;
|
2026-01-14 22:47:40 +01:00
|
|
|
private scheduleController: ScheduleController | null = null;
|
2026-01-22 23:28:33 +01:00
|
|
|
private statsController: EmployeeStatsController | null = null;
|
2026-01-12 22:10:57 +01:00
|
|
|
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();
|
2026-01-13 22:37:29 +01:00
|
|
|
this.ratesSync = new RatesSyncController();
|
2026-01-14 22:47:40 +01:00
|
|
|
this.scheduleController = new ScheduleController();
|
2026-01-22 23:28:33 +01:00
|
|
|
this.statsController = new EmployeeStatsController();
|
2026-01-12 22:10:57 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-14 16:53:42 +01:00
|
|
|
const row = target.closest<HTMLElement>('swp-data-table-row[data-employee-detail]');
|
2026-01-12 22:10:57 +01:00
|
|
|
|
|
|
|
|
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) {
|
2026-01-14 18:46:35 +01:00
|
|
|
// 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);
|
2026-01-12 22:10:57 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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) {
|
2026-01-14 18:46:35 +01:00
|
|
|
// 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);
|
2026-01-12 22:10:57 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-13 22:37:29 +01:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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();
|
2026-01-14 18:34:05 +01:00
|
|
|
this.setupDoubleClickToEdit();
|
2026-01-13 22:37:29 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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}`;
|
|
|
|
|
}
|
2026-01-14 18:34:05 +01:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-01-13 22:37:29 +01:00
|
|
|
}
|
2026-01-14 22:47:40 +01:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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');
|
|
|
|
|
|
2026-01-15 17:08:12 +01:00
|
|
|
// Drag scroll works for all schedule scroll containers (Vagtplan + Arbejdstid)
|
|
|
|
|
this.setupDragScroll();
|
|
|
|
|
|
2026-01-14 22:47:40 +01:00
|
|
|
if (!this.scheduleTable) return;
|
|
|
|
|
|
|
|
|
|
this.setupEditModeToggle();
|
|
|
|
|
this.setupCellSelection();
|
|
|
|
|
this.setupStatusOptions();
|
|
|
|
|
this.setupTypeToggle();
|
2026-01-17 15:36:15 +01:00
|
|
|
this.setupTimeRangeEvents();
|
2026-01-14 22:47:40 +01:00
|
|
|
this.setupDrawerSave();
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-15 17:08:12 +01:00
|
|
|
/**
|
|
|
|
|
* 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;
|
2026-01-15 20:49:56 +01:00
|
|
|
let hasDragged = false;
|
2026-01-15 17:08:12 +01:00
|
|
|
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;
|
2026-01-15 20:49:56 +01:00
|
|
|
hasDragged = false;
|
2026-01-15 17:08:12 +01:00
|
|
|
startX = e.clientX;
|
|
|
|
|
scrollLeft = el.scrollLeft;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const onMouseUp = () => {
|
|
|
|
|
isDown = false;
|
|
|
|
|
el.classList.remove('dragging');
|
2026-01-15 20:49:56 +01:00
|
|
|
|
|
|
|
|
// Reset hasDragged after a short delay to allow click events to check it
|
|
|
|
|
setTimeout(() => { hasDragged = false; }, 0);
|
2026-01-15 17:08:12 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const onMouseMove = (e: MouseEvent) => {
|
|
|
|
|
if (!isDown) return;
|
2026-01-15 20:49:56 +01:00
|
|
|
|
2026-01-15 17:08:12 +01:00
|
|
|
const x = e.clientX;
|
2026-01-15 20:49:56 +01:00
|
|
|
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();
|
2026-01-15 17:08:12 +01:00
|
|
|
const walk = (startX - x) * 1.5;
|
|
|
|
|
el.scrollLeft = scrollLeft + walk;
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-15 20:49:56 +01:00
|
|
|
// Prevent click events if we just dragged
|
|
|
|
|
const onClick = (e: MouseEvent) => {
|
|
|
|
|
if (hasDragged) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-15 17:08:12 +01:00
|
|
|
el.addEventListener('mousedown', onMouseDown);
|
|
|
|
|
el.addEventListener('mouseup', onMouseUp);
|
|
|
|
|
el.addEventListener('mouseleave', onMouseUp);
|
|
|
|
|
el.addEventListener('mousemove', onMouseMove);
|
2026-01-15 20:49:56 +01:00
|
|
|
el.addEventListener('click', onClick, true); // capture phase
|
2026-01-15 17:08:12 +01:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-14 22:47:40 +01:00
|
|
|
/**
|
|
|
|
|
* 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 {
|
2026-01-15 20:49:56 +01:00
|
|
|
const timeBadge = cell.querySelector('swp-time-badge');
|
|
|
|
|
if (!timeBadge) return;
|
2026-01-14 22:47:40 +01:00
|
|
|
|
|
|
|
|
let status = 'work';
|
2026-01-15 20:49:56 +01:00
|
|
|
if (timeBadge.classList.contains('off')) status = 'off';
|
|
|
|
|
else if (timeBadge.classList.contains('vacation')) status = 'vacation';
|
|
|
|
|
else if (timeBadge.classList.contains('sick')) status = 'sick';
|
2026-01-14 22:47:40 +01:00
|
|
|
|
|
|
|
|
// 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') {
|
2026-01-15 20:49:56 +01:00
|
|
|
const timeText = timeBadge.textContent?.trim() || '';
|
2026-01-14 22:47:40 +01:00
|
|
|
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
|
2026-01-17 15:36:15 +01:00
|
|
|
* Triggers 'input' event to let ControlsController update the display
|
2026-01-14 22:47:40 +01:00
|
|
|
*/
|
|
|
|
|
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');
|
|
|
|
|
|
2026-01-17 15:36:15 +01:00
|
|
|
if (startInput) {
|
|
|
|
|
startInput.value = String(this.timeToValue(startTime));
|
|
|
|
|
startInput.dispatchEvent(new Event('input', { bubbles: true }));
|
2026-01-14 22:47:40 +01:00
|
|
|
}
|
2026-01-17 15:36:15 +01:00
|
|
|
if (endInput) {
|
|
|
|
|
endInput.value = String(this.timeToValue(endTime));
|
|
|
|
|
endInput.dispatchEvent(new Event('input', { bubbles: true }));
|
2026-01-14 22:47:40 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-17 15:36:15 +01:00
|
|
|
* Setup time range event listener
|
|
|
|
|
* ControlsController handles the slider UI; we just listen for changes
|
2026-01-14 22:47:40 +01:00
|
|
|
*/
|
2026-01-17 15:36:15 +01:00
|
|
|
private setupTimeRangeEvents(): void {
|
2026-01-14 22:47:40 +01:00
|
|
|
const timeRange = document.getElementById('scheduleTimeRange');
|
2026-01-17 15:36:15 +01:00
|
|
|
timeRange?.addEventListener('timerange:change', () => {
|
2026-01-14 22:47:40 +01:00
|
|
|
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 => {
|
2026-01-15 20:49:56 +01:00
|
|
|
const timeDisplay = cell.querySelector('swp-time-badge');
|
2026-01-14 22:47:40 +01:00
|
|
|
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 => {
|
2026-01-15 20:49:56 +01:00
|
|
|
const timeDisplay = cell.querySelector('swp-time-badge');
|
2026-01-14 22:47:40 +01:00
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-14 23:56:53 +01:00
|
|
|
* Open the schedule drawer (no overlay - user can still interact with table)
|
2026-01-14 22:47:40 +01:00
|
|
|
*/
|
|
|
|
|
private openDrawer(): void {
|
2026-01-15 16:59:56 +01:00
|
|
|
const container = document.querySelector('swp-tab-content[data-tab="schedule"] swp-page-container') as HTMLElement;
|
2026-01-15 01:08:49 +01:00
|
|
|
const table = document.getElementById('scheduleTable');
|
2026-01-15 16:59:56 +01:00
|
|
|
|
|
|
|
|
// 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"
|
2026-01-15 01:08:49 +01:00
|
|
|
if (table) {
|
|
|
|
|
const rect = table.getBoundingClientRect();
|
|
|
|
|
table.style.width = `${rect.width}px`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-15 16:59:56 +01:00
|
|
|
// Tilføj klasser med det samme (maxWidth og margin ændres instant)
|
|
|
|
|
this.drawer?.classList.add('open');
|
|
|
|
|
document.body.classList.add('schedule-drawer-open');
|
2026-01-15 01:17:59 +01:00
|
|
|
|
2026-01-15 16:59:56 +01:00
|
|
|
// Animate kun padding fra gemt værdi
|
|
|
|
|
if (container) {
|
2026-01-15 01:17:59 +01:00
|
|
|
container.animate([
|
2026-01-15 16:59:56 +01:00
|
|
|
{ paddingRight: startPadding },
|
|
|
|
|
{ paddingRight: '420px' }
|
2026-01-15 01:17:59 +01:00
|
|
|
], {
|
2026-01-15 16:59:56 +01:00
|
|
|
duration: 200,
|
2026-01-15 01:17:59 +01:00
|
|
|
easing: 'ease',
|
|
|
|
|
fill: 'forwards'
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-01-14 22:47:40 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Close the schedule drawer
|
|
|
|
|
*/
|
|
|
|
|
private closeDrawer(): void {
|
2026-01-15 16:59:56 +01:00
|
|
|
// Luk drawer med det samme (visuelt)
|
|
|
|
|
this.drawer?.classList.remove('open');
|
|
|
|
|
|
2026-01-15 01:17:59 +01:00
|
|
|
const container = document.querySelector('swp-tab-content[data-tab="schedule"] swp-page-container') as HTMLElement;
|
2026-01-15 16:59:56 +01:00
|
|
|
const table = document.getElementById('scheduleTable');
|
|
|
|
|
|
2026-01-15 01:17:59 +01:00
|
|
|
if (container) {
|
|
|
|
|
const animation = container.getAnimations()[0];
|
|
|
|
|
if (animation) {
|
2026-01-15 16:59:56 +01:00
|
|
|
// 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
|
2026-01-15 01:17:59 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-15 16:59:56 +01:00
|
|
|
// Ingen animation, fjern klasser og låst bredde med det samme
|
|
|
|
|
document.body.classList.remove('schedule-drawer-open');
|
2026-01-15 01:08:49 +01:00
|
|
|
if (table) {
|
|
|
|
|
table.style.width = '';
|
|
|
|
|
}
|
2026-01-14 22:47:40 +01:00
|
|
|
}
|
|
|
|
|
}
|
2026-01-22 23:28:33 +01:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|