PlanTempusApp/PlanTempus.Application/wwwroot/ts/modules/employees.ts

370 lines
10 KiB
TypeScript
Raw Normal View History

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.
*/
export class EmployeesController {
private ratesSync: RatesSyncController | 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();
this.ratesSync = new RatesSyncController();
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;
}
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) {
this.listView.style.display = 'none';
this.detailView.style.display = 'block';
this.detailView.dataset.employee = employeeKey;
// Reset to first tab
this.switchTab(this.detailView, 'general');
}
}
/**
* 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) {
this.detailView.style.display = 'none';
this.listView.style.display = 'block';
}
}
}
/**
* 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);
}
});
}
}