2026-01-11 21:08:56 +01:00
|
|
|
/**
|
2026-01-11 21:42:24 +01:00
|
|
|
* Cash Controller
|
2026-01-11 21:08:56 +01:00
|
|
|
*
|
|
|
|
|
* Handles tab switching, cash calculations, and form interactions
|
2026-01-11 21:42:24 +01:00
|
|
|
* for the Cash Register page.
|
2026-01-11 21:08:56 +01:00
|
|
|
*/
|
|
|
|
|
|
2026-01-11 21:42:24 +01:00
|
|
|
export class CashController {
|
2026-01-11 21:08:56 +01:00
|
|
|
// Base values (from system - would come from server in real app)
|
|
|
|
|
private readonly startBalance = 2000;
|
|
|
|
|
private readonly cashSales = 3540;
|
|
|
|
|
|
|
|
|
|
constructor() {
|
|
|
|
|
this.setupTabs();
|
|
|
|
|
this.setupCashCalculation();
|
|
|
|
|
this.setupCheckboxSelection();
|
|
|
|
|
this.setupApprovalCheckbox();
|
|
|
|
|
this.setupDateFilters();
|
|
|
|
|
this.setupRowToggle();
|
|
|
|
|
this.setupDraftRowClick();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Setup tab switching functionality
|
|
|
|
|
*/
|
|
|
|
|
private setupTabs(): void {
|
|
|
|
|
const tabs = document.querySelectorAll<HTMLElement>('swp-tab[data-tab]');
|
|
|
|
|
|
|
|
|
|
tabs.forEach(tab => {
|
|
|
|
|
tab.addEventListener('click', () => {
|
|
|
|
|
const targetTab = tab.dataset.tab;
|
|
|
|
|
if (targetTab) {
|
|
|
|
|
this.switchToTab(targetTab);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Switch to a specific tab by name
|
|
|
|
|
*/
|
|
|
|
|
private switchToTab(targetTab: string): void {
|
|
|
|
|
const tabs = document.querySelectorAll<HTMLElement>('swp-tab[data-tab]');
|
|
|
|
|
const contents = document.querySelectorAll<HTMLElement>('swp-tab-content[data-tab]');
|
2026-01-11 21:42:24 +01:00
|
|
|
const statsBars = document.querySelectorAll<HTMLElement>('swp-cash-stats[data-for-tab]');
|
2026-01-11 21:08:56 +01:00
|
|
|
|
|
|
|
|
// Update tab states
|
|
|
|
|
tabs.forEach(t => {
|
|
|
|
|
if (t.dataset.tab === targetTab) {
|
|
|
|
|
t.classList.add('active');
|
|
|
|
|
} else {
|
|
|
|
|
t.classList.remove('active');
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Update content visibility
|
|
|
|
|
contents.forEach(content => {
|
|
|
|
|
if (content.dataset.tab === targetTab) {
|
|
|
|
|
content.classList.add('active');
|
|
|
|
|
} else {
|
|
|
|
|
content.classList.remove('active');
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Update stats bar visibility
|
|
|
|
|
statsBars.forEach(stats => {
|
|
|
|
|
if (stats.dataset.forTab === targetTab) {
|
|
|
|
|
stats.classList.add('active');
|
|
|
|
|
} else {
|
|
|
|
|
stats.classList.remove('active');
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Setup cash calculation with real-time updates
|
|
|
|
|
*/
|
|
|
|
|
private setupCashCalculation(): void {
|
|
|
|
|
const payoutsInput = document.getElementById('payouts') as HTMLInputElement;
|
|
|
|
|
const toBankInput = document.getElementById('toBank') as HTMLInputElement;
|
|
|
|
|
const actualCashInput = document.getElementById('actualCash') as HTMLInputElement;
|
|
|
|
|
|
|
|
|
|
if (!payoutsInput || !toBankInput || !actualCashInput) return;
|
|
|
|
|
|
|
|
|
|
const calculate = () => this.calculateCash(payoutsInput, toBankInput, actualCashInput);
|
|
|
|
|
|
|
|
|
|
payoutsInput.addEventListener('input', calculate);
|
|
|
|
|
toBankInput.addEventListener('input', calculate);
|
|
|
|
|
actualCashInput.addEventListener('input', calculate);
|
|
|
|
|
|
2026-01-19 14:23:41 +01:00
|
|
|
// Setup Enter key navigation between fields
|
|
|
|
|
this.setupEnterNavigation([payoutsInput, toBankInput, actualCashInput]);
|
|
|
|
|
|
2026-01-11 21:08:56 +01:00
|
|
|
// Initial calculation
|
|
|
|
|
calculate();
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-19 14:23:41 +01:00
|
|
|
/**
|
|
|
|
|
* Setup Enter key to move focus to next input field
|
|
|
|
|
*/
|
|
|
|
|
private setupEnterNavigation(inputs: HTMLInputElement[]): void {
|
|
|
|
|
inputs.forEach((input, index) => {
|
|
|
|
|
input.addEventListener('keydown', (e) => {
|
|
|
|
|
if (e.key === 'Enter') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
const nextIndex = index + 1;
|
|
|
|
|
if (nextIndex < inputs.length) {
|
|
|
|
|
inputs[nextIndex].focus();
|
|
|
|
|
inputs[nextIndex].select();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-11 21:08:56 +01:00
|
|
|
/**
|
|
|
|
|
* Calculate expected cash and difference
|
|
|
|
|
*/
|
|
|
|
|
private calculateCash(
|
|
|
|
|
payoutsInput: HTMLInputElement,
|
|
|
|
|
toBankInput: HTMLInputElement,
|
|
|
|
|
actualCashInput: HTMLInputElement
|
|
|
|
|
): void {
|
|
|
|
|
const payouts = this.parseNumber(payoutsInput.value);
|
|
|
|
|
const toBank = this.parseNumber(toBankInput.value);
|
|
|
|
|
const actual = this.parseNumber(actualCashInput.value);
|
|
|
|
|
|
|
|
|
|
// Expected = start + sales - payouts - to bank
|
|
|
|
|
const expectedCash = this.startBalance + this.cashSales - payouts - toBank;
|
|
|
|
|
|
|
|
|
|
const expectedElement = document.getElementById('expectedCash');
|
|
|
|
|
if (expectedElement) {
|
|
|
|
|
expectedElement.textContent = this.formatNumber(expectedCash);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Calculate and display difference
|
2026-01-19 14:23:41 +01:00
|
|
|
this.updateDifference(actual, expectedCash);
|
2026-01-11 21:08:56 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Update difference box with color coding
|
|
|
|
|
*/
|
2026-01-19 14:23:41 +01:00
|
|
|
private updateDifference(actual: number, expected: number): void {
|
2026-01-11 21:08:56 +01:00
|
|
|
const box = document.getElementById('differenceBox');
|
|
|
|
|
const value = document.getElementById('differenceValue');
|
|
|
|
|
if (!box || !value) return;
|
|
|
|
|
|
|
|
|
|
const diff = actual - expected;
|
|
|
|
|
|
|
|
|
|
// Remove all state classes
|
|
|
|
|
box.classList.remove('positive', 'negative', 'neutral');
|
|
|
|
|
|
2026-01-19 14:23:41 +01:00
|
|
|
if (diff > 0) {
|
2026-01-11 21:08:56 +01:00
|
|
|
// More cash than expected
|
|
|
|
|
value.textContent = '+' + this.formatNumber(diff) + ' kr';
|
|
|
|
|
box.classList.add('positive');
|
|
|
|
|
} else if (diff < 0) {
|
|
|
|
|
// Less cash than expected
|
|
|
|
|
value.textContent = this.formatNumber(diff) + ' kr';
|
|
|
|
|
box.classList.add('negative');
|
|
|
|
|
} else {
|
|
|
|
|
// Exact match
|
|
|
|
|
value.textContent = '0,00 kr';
|
|
|
|
|
box.classList.add('neutral');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Setup checkbox selection for table rows
|
|
|
|
|
*/
|
|
|
|
|
private setupCheckboxSelection(): void {
|
|
|
|
|
const selectAll = document.getElementById('selectAll') as HTMLInputElement;
|
|
|
|
|
const rowCheckboxes = document.querySelectorAll<HTMLInputElement>('.row-select');
|
|
|
|
|
const exportBtn = document.getElementById('exportBtn') as HTMLButtonElement;
|
|
|
|
|
const selectionCount = document.getElementById('selectionCount');
|
|
|
|
|
|
|
|
|
|
if (!selectAll || !exportBtn || !selectionCount) return;
|
|
|
|
|
|
|
|
|
|
const updateSelection = () => {
|
|
|
|
|
const checked = document.querySelectorAll<HTMLInputElement>('.row-select:checked');
|
|
|
|
|
const count = checked.length;
|
|
|
|
|
|
|
|
|
|
selectionCount.textContent = count === 0 ? '0 valgt' : `${count} valgt`;
|
|
|
|
|
exportBtn.disabled = count === 0;
|
|
|
|
|
|
|
|
|
|
// Update select all state
|
|
|
|
|
selectAll.checked = count === rowCheckboxes.length && count > 0;
|
|
|
|
|
selectAll.indeterminate = count > 0 && count < rowCheckboxes.length;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
selectAll.addEventListener('change', () => {
|
|
|
|
|
rowCheckboxes.forEach(cb => cb.checked = selectAll.checked);
|
|
|
|
|
updateSelection();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
rowCheckboxes.forEach(cb => {
|
|
|
|
|
cb.addEventListener('change', updateSelection);
|
|
|
|
|
// Stop click from bubbling to row
|
|
|
|
|
cb.addEventListener('click', e => e.stopPropagation());
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Setup approval checkbox to enable/disable approve button
|
|
|
|
|
*/
|
|
|
|
|
private setupApprovalCheckbox(): void {
|
|
|
|
|
const checkbox = document.getElementById('confirmCheckbox') as HTMLInputElement;
|
|
|
|
|
const approveBtn = document.getElementById('approveBtn') as HTMLButtonElement;
|
|
|
|
|
|
|
|
|
|
if (!checkbox || !approveBtn) return;
|
|
|
|
|
|
|
|
|
|
checkbox.addEventListener('change', () => {
|
|
|
|
|
approveBtn.disabled = !checkbox.checked;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Setup date filter defaults (last 30 days)
|
|
|
|
|
*/
|
|
|
|
|
private setupDateFilters(): void {
|
|
|
|
|
const dateFrom = document.getElementById('dateFrom') as HTMLInputElement;
|
|
|
|
|
const dateTo = document.getElementById('dateTo') as HTMLInputElement;
|
|
|
|
|
|
|
|
|
|
if (!dateFrom || !dateTo) return;
|
|
|
|
|
|
|
|
|
|
const today = new Date();
|
|
|
|
|
const thirtyDaysAgo = new Date(today);
|
|
|
|
|
thirtyDaysAgo.setDate(today.getDate() - 30);
|
|
|
|
|
|
|
|
|
|
dateTo.value = this.formatDateISO(today);
|
|
|
|
|
dateFrom.value = this.formatDateISO(thirtyDaysAgo);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Format number as Danish currency
|
|
|
|
|
*/
|
|
|
|
|
private formatNumber(num: number): string {
|
|
|
|
|
return num.toLocaleString('da-DK', {
|
|
|
|
|
minimumFractionDigits: 2,
|
|
|
|
|
maximumFractionDigits: 2
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Parse Danish number format
|
|
|
|
|
*/
|
|
|
|
|
private parseNumber(str: string): number {
|
|
|
|
|
if (!str) return 0;
|
|
|
|
|
return parseFloat(str.replace(/\./g, '').replace(',', '.')) || 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Format date as ISO string (YYYY-MM-DD)
|
|
|
|
|
*/
|
|
|
|
|
private formatDateISO(date: Date): string {
|
|
|
|
|
return date.toISOString().split('T')[0];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Setup row toggle for expandable details
|
|
|
|
|
*/
|
|
|
|
|
private setupRowToggle(): void {
|
2026-01-11 21:42:24 +01:00
|
|
|
const rows = document.querySelectorAll<HTMLElement>('swp-cash-table-row[data-id]:not(.draft-row)');
|
2026-01-11 21:08:56 +01:00
|
|
|
|
|
|
|
|
rows.forEach(row => {
|
|
|
|
|
const rowId = row.getAttribute('data-id');
|
|
|
|
|
if (!rowId) return;
|
|
|
|
|
|
2026-01-11 21:42:24 +01:00
|
|
|
const detail = document.querySelector<HTMLElement>(`swp-cash-row-detail[data-for="${rowId}"]`);
|
2026-01-11 21:08:56 +01:00
|
|
|
if (!detail) return;
|
|
|
|
|
|
|
|
|
|
row.addEventListener('click', (e) => {
|
|
|
|
|
// Don't toggle if clicking on checkbox
|
|
|
|
|
if ((e.target as HTMLElement).closest('input[type="checkbox"]')) return;
|
|
|
|
|
|
|
|
|
|
const icon = row.querySelector('swp-row-toggle i');
|
|
|
|
|
const isExpanded = row.classList.contains('expanded');
|
|
|
|
|
|
|
|
|
|
// Close other expanded rows
|
2026-01-11 21:42:24 +01:00
|
|
|
document.querySelectorAll('swp-cash-table-row.expanded').forEach(r => {
|
2026-01-11 21:08:56 +01:00
|
|
|
if (r !== row) {
|
|
|
|
|
const otherId = r.getAttribute('data-id');
|
|
|
|
|
if (otherId) {
|
2026-01-11 21:42:24 +01:00
|
|
|
const otherDetail = document.querySelector<HTMLElement>(`swp-cash-row-detail[data-for="${otherId}"]`);
|
2026-01-11 21:08:56 +01:00
|
|
|
const otherIcon = r.querySelector('swp-row-toggle i');
|
|
|
|
|
if (otherDetail && otherIcon) {
|
|
|
|
|
this.collapseRow(r, otherDetail, otherIcon as HTMLElement);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Toggle current row
|
|
|
|
|
if (isExpanded) {
|
|
|
|
|
this.collapseRow(row, detail, icon);
|
|
|
|
|
} else {
|
|
|
|
|
this.expandRow(row, detail, icon);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Expand a row with animation
|
|
|
|
|
*/
|
|
|
|
|
private expandRow(row: Element, detail: HTMLElement, icon: Element | null): void {
|
|
|
|
|
row.classList.add('expanded');
|
|
|
|
|
detail.classList.add('expanded');
|
|
|
|
|
|
|
|
|
|
// Animate icon rotation
|
|
|
|
|
icon?.animate([
|
|
|
|
|
{ transform: 'rotate(0deg)' },
|
|
|
|
|
{ transform: 'rotate(90deg)' }
|
|
|
|
|
], {
|
|
|
|
|
duration: 200,
|
|
|
|
|
easing: 'ease-out',
|
|
|
|
|
fill: 'forwards'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Animate detail expansion
|
|
|
|
|
const content = detail.querySelector('swp-row-detail-content') as HTMLElement;
|
|
|
|
|
if (content) {
|
|
|
|
|
const height = content.offsetHeight;
|
|
|
|
|
detail.animate([
|
|
|
|
|
{ height: '0px', opacity: 0 },
|
|
|
|
|
{ height: `${height}px`, opacity: 1 }
|
|
|
|
|
], {
|
|
|
|
|
duration: 250,
|
|
|
|
|
easing: 'ease-out',
|
|
|
|
|
fill: 'forwards'
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Collapse a row with animation
|
|
|
|
|
*/
|
|
|
|
|
private collapseRow(row: Element, detail: HTMLElement, icon: Element | null): void {
|
|
|
|
|
// Animate icon rotation
|
|
|
|
|
icon?.animate([
|
|
|
|
|
{ transform: 'rotate(90deg)' },
|
|
|
|
|
{ transform: 'rotate(0deg)' }
|
|
|
|
|
], {
|
|
|
|
|
duration: 200,
|
|
|
|
|
easing: 'ease-out',
|
|
|
|
|
fill: 'forwards'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Animate detail collapse
|
|
|
|
|
const content = detail.querySelector('swp-row-detail-content') as HTMLElement;
|
|
|
|
|
if (content) {
|
|
|
|
|
const height = content.offsetHeight;
|
|
|
|
|
const animation = detail.animate([
|
|
|
|
|
{ height: `${height}px`, opacity: 1 },
|
|
|
|
|
{ height: '0px', opacity: 0 }
|
|
|
|
|
], {
|
|
|
|
|
duration: 200,
|
|
|
|
|
easing: 'ease-out',
|
|
|
|
|
fill: 'forwards'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
animation.onfinish = () => {
|
|
|
|
|
row.classList.remove('expanded');
|
|
|
|
|
detail.classList.remove('expanded');
|
|
|
|
|
};
|
|
|
|
|
} else {
|
|
|
|
|
row.classList.remove('expanded');
|
|
|
|
|
detail.classList.remove('expanded');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-11 21:42:24 +01:00
|
|
|
* Setup draft row click to navigate to reconciliation tab
|
2026-01-11 21:08:56 +01:00
|
|
|
*/
|
|
|
|
|
private setupDraftRowClick(): void {
|
2026-01-11 21:42:24 +01:00
|
|
|
const draftRow = document.querySelector<HTMLElement>('swp-cash-table-row.draft-row');
|
2026-01-11 21:08:56 +01:00
|
|
|
if (!draftRow) return;
|
|
|
|
|
|
|
|
|
|
draftRow.style.cursor = 'pointer';
|
|
|
|
|
draftRow.addEventListener('click', (e) => {
|
|
|
|
|
// Don't navigate if clicking on checkbox
|
|
|
|
|
if ((e.target as HTMLElement).closest('input[type="checkbox"]')) return;
|
|
|
|
|
|
|
|
|
|
this.switchToTab('afstemning');
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|