Enhances user experience by implementing smooth container resize animation when opening and closing the schedule drawer Uses Web Animations API to dynamically adjust page container styling with transition effects
1082 lines
34 KiB
TypeScript
1082 lines
34 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 scheduleController: ScheduleController | 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();
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
|
||
// Time range constants
|
||
private readonly TIME_RANGE_MAX = 60; // 15 hours (06:00-21:00) * 4 intervals
|
||
|
||
constructor() {
|
||
this.drawer = document.getElementById('schedule-drawer');
|
||
this.editBtn = document.getElementById('scheduleEditBtn');
|
||
this.scheduleTable = document.getElementById('scheduleTable');
|
||
|
||
if (!this.scheduleTable) return;
|
||
|
||
this.setupEditModeToggle();
|
||
this.setupCellSelection();
|
||
this.setupStatusOptions();
|
||
this.setupTypeToggle();
|
||
this.setupTimeRangeSlider();
|
||
this.setupDrawerSave();
|
||
}
|
||
|
||
/**
|
||
* 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 timeDisplay = cell.querySelector('swp-time-display');
|
||
if (!timeDisplay) return;
|
||
|
||
let status = 'work';
|
||
if (timeDisplay.classList.contains('off')) status = 'off';
|
||
else if (timeDisplay.classList.contains('vacation')) status = 'vacation';
|
||
else if (timeDisplay.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 = timeDisplay.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
|
||
*/
|
||
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));
|
||
if (endInput) endInput.value = String(this.timeToValue(endTime));
|
||
|
||
this.updateTimeRangeDisplay(timeRange);
|
||
}
|
||
|
||
/**
|
||
* Update time range display
|
||
*/
|
||
private updateTimeRangeDisplay(container: HTMLElement): void {
|
||
const startInput = container.querySelector<HTMLInputElement>('.range-start');
|
||
const endInput = container.querySelector<HTMLInputElement>('.range-end');
|
||
const fill = container.querySelector<HTMLElement>('swp-time-range-fill');
|
||
const timesEl = container.querySelector('swp-time-range-times');
|
||
const durationEl = container.querySelector('swp-time-range-duration');
|
||
|
||
if (!startInput || !endInput) return;
|
||
|
||
let startVal = parseInt(startInput.value);
|
||
let endVal = parseInt(endInput.value);
|
||
|
||
// Ensure start doesn't exceed end
|
||
if (startVal > endVal) {
|
||
if (startInput === document.activeElement) {
|
||
startInput.value = String(endVal);
|
||
startVal = endVal;
|
||
} else {
|
||
endInput.value = String(startVal);
|
||
endVal = startVal;
|
||
}
|
||
}
|
||
|
||
// Update fill bar
|
||
if (fill) {
|
||
const startPercent = (startVal / this.TIME_RANGE_MAX) * 100;
|
||
const endPercent = (endVal / this.TIME_RANGE_MAX) * 100;
|
||
fill.style.left = startPercent + '%';
|
||
fill.style.width = (endPercent - startPercent) + '%';
|
||
}
|
||
|
||
// Calculate duration
|
||
const durationMinutes = (endVal - startVal) * 15;
|
||
const durationHours = durationMinutes / 60;
|
||
const durationText = durationHours % 1 === 0
|
||
? `${durationHours} timer`
|
||
: `${durationHours.toFixed(1).replace('.', ',')} timer`;
|
||
|
||
if (timesEl) timesEl.textContent = `${this.valueToTime(startVal)} – ${this.valueToTime(endVal)}`;
|
||
if (durationEl) durationEl.textContent = durationText;
|
||
}
|
||
|
||
/**
|
||
* Setup time range slider
|
||
*/
|
||
private setupTimeRangeSlider(): void {
|
||
const timeRange = document.getElementById('scheduleTimeRange');
|
||
if (!timeRange) return;
|
||
|
||
const startInput = timeRange.querySelector<HTMLInputElement>('.range-start');
|
||
const endInput = timeRange.querySelector<HTMLInputElement>('.range-end');
|
||
const fill = timeRange.querySelector<HTMLElement>('swp-time-range-fill');
|
||
const track = timeRange.querySelector<HTMLElement>('swp-time-range-track');
|
||
|
||
this.updateTimeRangeDisplay(timeRange);
|
||
|
||
startInput?.addEventListener('input', () => {
|
||
this.updateTimeRangeDisplay(timeRange);
|
||
this.updateSelectedCellsTime();
|
||
});
|
||
|
||
endInput?.addEventListener('input', () => {
|
||
this.updateTimeRangeDisplay(timeRange);
|
||
this.updateSelectedCellsTime();
|
||
});
|
||
|
||
// Drag fill bar to move entire range
|
||
if (fill && track && startInput && endInput) {
|
||
let isDragging = false;
|
||
let dragStartX = 0;
|
||
let dragStartValues = { start: 0, end: 0 };
|
||
|
||
fill.addEventListener('mousedown', (e: MouseEvent) => {
|
||
isDragging = true;
|
||
dragStartX = e.clientX;
|
||
dragStartValues.start = parseInt(startInput.value);
|
||
dragStartValues.end = parseInt(endInput.value);
|
||
e.preventDefault();
|
||
});
|
||
|
||
document.addEventListener('mousemove', (e: MouseEvent) => {
|
||
if (!isDragging) return;
|
||
|
||
const sliderWidth = track.offsetWidth;
|
||
const deltaX = e.clientX - dragStartX;
|
||
const deltaValue = Math.round((deltaX / sliderWidth) * this.TIME_RANGE_MAX);
|
||
|
||
const duration = dragStartValues.end - dragStartValues.start;
|
||
let newStart = dragStartValues.start + deltaValue;
|
||
let newEnd = dragStartValues.end + deltaValue;
|
||
|
||
if (newStart < 0) {
|
||
newStart = 0;
|
||
newEnd = duration;
|
||
}
|
||
if (newEnd > this.TIME_RANGE_MAX) {
|
||
newEnd = this.TIME_RANGE_MAX;
|
||
newStart = this.TIME_RANGE_MAX - duration;
|
||
}
|
||
|
||
startInput.value = String(newStart);
|
||
endInput.value = String(newEnd);
|
||
this.updateTimeRangeDisplay(timeRange);
|
||
this.updateSelectedCellsTime();
|
||
});
|
||
|
||
document.addEventListener('mouseup', () => {
|
||
isDragging = false;
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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-display');
|
||
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-display');
|
||
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 {
|
||
// Lås tabelbredde før drawer åbner for at undgå "hop"
|
||
const table = document.getElementById('scheduleTable');
|
||
if (table) {
|
||
const rect = table.getBoundingClientRect();
|
||
table.style.width = `${rect.width}px`;
|
||
}
|
||
|
||
// Animate container med Web Animations API
|
||
const container = document.querySelector('swp-tab-content[data-tab="schedule"] swp-page-container') as HTMLElement;
|
||
if (container) {
|
||
const currentStyles = getComputedStyle(container);
|
||
const currentMaxWidth = currentStyles.maxWidth;
|
||
const currentMargin = currentStyles.margin;
|
||
const currentPaddingRight = currentStyles.paddingRight;
|
||
|
||
container.animate([
|
||
{ maxWidth: currentMaxWidth, margin: currentMargin, paddingRight: currentPaddingRight },
|
||
{ maxWidth: 'none', margin: '0px', paddingRight: '420px' }
|
||
], {
|
||
duration: 300,
|
||
easing: 'ease',
|
||
fill: 'forwards'
|
||
});
|
||
}
|
||
|
||
this.drawer?.classList.add('open');
|
||
document.body.classList.add('schedule-drawer-open');
|
||
}
|
||
|
||
/**
|
||
* Close the schedule drawer
|
||
*/
|
||
private closeDrawer(): void {
|
||
const container = document.querySelector('swp-tab-content[data-tab="schedule"] swp-page-container') as HTMLElement;
|
||
if (container) {
|
||
// Hent nuværende computed styles for animation
|
||
const animation = container.getAnimations()[0];
|
||
if (animation) {
|
||
animation.cancel();
|
||
}
|
||
}
|
||
|
||
// Fjern låst bredde så tabellen kan tilpasse sig igen
|
||
const table = document.getElementById('scheduleTable');
|
||
if (table) {
|
||
table.style.width = '';
|
||
}
|
||
|
||
this.drawer?.classList.remove('open');
|
||
document.body.classList.remove('schedule-drawer-open');
|
||
}
|
||
}
|