399 lines
11 KiB
TypeScript
399 lines
11 KiB
TypeScript
/**
|
|
* 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.
|
|
*/
|
|
|
|
export class EmployeesController {
|
|
private ratesSync: RatesSyncController | 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();
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
});
|
|
}
|
|
}
|