PlanTempusApp/PlanTempus.Application/wwwroot/ts/modules/kasse.ts
Janus C. H. Knudsen 754681059d Adds Kasse (Cash Register) module and related components
Introduces comprehensive cash management functionality with multiple view components for tracking daily transactions, filtering, and reconciliation

Implements:
- Cash calculation and difference tracking
- Dynamic tab switching
- Checkbox selection and row expansion
- Date filtering and approval mechanisms
2026-01-11 21:08:56 +01:00

370 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Kasse Controller
*
* Handles tab switching, cash calculations, and form interactions
* for the Kasse (Cash Register) page.
*/
export class KasseController {
// 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]');
const statsBars = document.querySelectorAll<HTMLElement>('swp-kasse-stats[data-for-tab]');
// 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);
// Initial calculation
calculate();
}
/**
* 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
this.updateDifference(actual, expectedCash, actualCashInput.value);
}
/**
* Update difference box with color coding
*/
private updateDifference(actual: number, expected: number, rawValue: string): void {
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');
if (actual === 0 && rawValue === '') {
// No input yet
value.textContent = ' kr';
box.classList.add('neutral');
} else if (diff > 0) {
// 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 {
const rows = document.querySelectorAll<HTMLElement>('swp-kasse-table-row[data-id]:not(.draft-row)');
rows.forEach(row => {
const rowId = row.getAttribute('data-id');
if (!rowId) return;
const detail = document.querySelector<HTMLElement>(`swp-kasse-row-detail[data-for="${rowId}"]`);
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
document.querySelectorAll('swp-kasse-table-row.expanded').forEach(r => {
if (r !== row) {
const otherId = r.getAttribute('data-id');
if (otherId) {
const otherDetail = document.querySelector<HTMLElement>(`swp-kasse-row-detail[data-for="${otherId}"]`);
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');
}
}
/**
* Setup draft row click to navigate to Kasseafstemning tab
*/
private setupDraftRowClick(): void {
const draftRow = document.querySelector<HTMLElement>('swp-kasse-table-row.draft-row');
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');
});
}
}