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

408 lines
12 KiB
TypeScript
Raw Normal View History

/**
2026-01-11 21:42:24 +01:00
* Cash Controller
*
* 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:42:24 +01:00
export class CashController {
// 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();
this.setupZReportButtons();
}
/**
* 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]');
// 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);
// Setup Enter key navigation between fields
this.setupEnterNavigation([payoutsInput, toBankInput, actualCashInput]);
// Initial calculation
calculate();
}
/**
* 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();
}
}
});
});
}
/**
* 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);
}
/**
* Update difference box with color coding
*/
private updateDifference(actual: number, expected: number): 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 (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 {
2026-01-11 21:42:24 +01:00
const rows = document.querySelectorAll<HTMLElement>('swp-cash-table-row[data-id]:not(.draft-row)');
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}"]`);
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 => {
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}"]`);
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
*/
private setupDraftRowClick(): void {
2026-01-11 21:42:24 +01:00
const draftRow = document.querySelector<HTMLElement>('swp-cash-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');
});
}
/**
* Setup Z-report PDF download buttons
*/
private setupZReportButtons(): void {
const buttons = document.querySelectorAll<HTMLElement>('[data-zreport]');
buttons.forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const reportId = btn.dataset.zreport;
if (reportId) {
window.open(`/kasse/z-rapport/${reportId}`, '_blank');
}
});
});
}
}