Files
expense-control/web/src/app/features/expenses/expense-list.component.ts
Mateusz Gruszczyński 57cc30427a split html's
2026-04-08 11:08:41 +02:00

373 lines
15 KiB
TypeScript

import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common';
import { Component, OnInit, computed, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { CategoriesService } from '../../core/services/categories.service';
import { ExpensesService } from '../../core/services/expenses.service';
import { ToastService } from '../../core/services/toast.service';
import { UiService } from '../../core/services/ui.service';
import type { DuplicateGroup, Expense, PaginationMeta, Proof } from '../../shared/models';
type SortColumn = 'expenseDate' | 'title' | 'amount' | 'status' | 'category';
type ListState = {
startDate: string;
endDate: string;
categoryId: string;
search: string;
status: string;
tags: string;
duplicatesOnly: boolean;
page: number;
pageSize: number;
sortBy: SortColumn;
sortDir: 'asc' | 'desc';
};
const defaultState: ListState = {
startDate: '',
endDate: '',
categoryId: '',
search: '',
status: '',
tags: '',
duplicatesOnly: false,
page: 1,
pageSize: 20,
sortBy: 'expenseDate',
sortDir: 'desc'
};
@Component({
selector: 'app-expense-list',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, RouterLink, CurrencyPipe, DatePipe],
templateUrl: './expense-list.component.html'
})
export class ExpenseListComponent implements OnInit {
readonly ui = inject(UiService);
private readonly fb = inject(FormBuilder);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
private readonly sanitizer = inject(DomSanitizer);
private readonly categoriesService = inject(CategoriesService);
private readonly expensesService = inject(ExpensesService);
private readonly toast = inject(ToastService);
readonly categories = this.categoriesService.items;
readonly expenses = signal<Expense[]>([]);
readonly duplicateGroups = signal<DuplicateGroup[]>([]);
readonly proofPreview = signal<Proof | null>(null);
readonly proofPreviewUrl = computed<SafeResourceUrl | null>(() => {
const proof = this.proofPreview();
if (!proof || !this.isPdf(proof)) return null;
const previewUrl = proof.previewUrl || proof.fileUrl;
if (!previewUrl) return null;
const suffix = previewUrl.includes('#') ? '' : '#toolbar=0&navpanes=0&scrollbar=1&view=FitH';
return this.sanitizer.bypassSecurityTrustResourceUrl(`${previewUrl}${suffix}`);
});
readonly statusSavingId = signal<string | null>(null);
readonly pagination = signal<PaginationMeta>({ page: 1, pageSize: 20, total: 0, totalPages: 1, hasPrev: false, hasNext: false });
readonly pageSizeOptions = [10, 20, 50];
readonly sortBy = signal<SortColumn>('expenseDate');
readonly sortDir = signal<'asc' | 'desc'>('desc');
readonly selectedIds = signal<string[]>([]);
readonly visibleIds = computed(() => this.expenses().map((item) => item.id));
readonly allVisibleSelected = computed(() => this.visibleIds().length > 0 && this.visibleIds().every((id) => this.selectedIds().includes(id)));
readonly someVisibleSelected = computed(() => !this.allVisibleSelected() && this.visibleIds().some((id) => this.selectedIds().includes(id)));
readonly filterForm = this.fb.nonNullable.group({ startDate: [''], endDate: [''], categoryId: [''], search: [''], status: [''], tags: [''], duplicatesOnly: [false] });
ngOnInit() {
this.categoriesService.ensureLoaded(true);
this.route.queryParamMap.subscribe((params) => {
const state: ListState = {
startDate: params.get('startDate') ?? defaultState.startDate,
endDate: params.get('endDate') ?? defaultState.endDate,
categoryId: params.get('categoryId') ?? defaultState.categoryId,
search: params.get('search') ?? defaultState.search,
status: params.get('status') ?? defaultState.status,
tags: params.get('tags') ?? defaultState.tags,
duplicatesOnly: ['1', 'true'].includes((params.get('duplicatesOnly') ?? '').toLowerCase()),
page: this.parsePositiveInt(params.get('page'), defaultState.page),
pageSize: this.parsePositiveInt(params.get('pageSize'), defaultState.pageSize),
sortBy: this.parseSortColumn(params.get('sortBy')),
sortDir: params.get('sortDir') === 'asc' ? 'asc' : 'desc'
};
this.filterForm.patchValue({
startDate: state.startDate,
endDate: state.endDate,
categoryId: state.categoryId,
search: state.search,
status: state.status,
tags: state.tags,
duplicatesOnly: state.duplicatesOnly
}, { emitEvent: false });
this.sortBy.set(state.sortBy);
this.sortDir.set(state.sortDir);
this.pagination.update((current) => ({ ...current, page: state.page, pageSize: state.pageSize }));
this.loadExpenses(state);
this.loadDuplicates();
});
}
customFieldEntries(item: Expense) { return Object.entries(item.customFields || {}); }
hasActiveFilters() {
const raw = this.filterForm.getRawValue();
return Boolean(raw.startDate || raw.endDate || raw.categoryId || raw.search || raw.status || raw.tags || raw.duplicatesOnly);
}
private loadExpenses(state: ListState) {
this.expensesService.list({
startDate: state.startDate || undefined,
endDate: state.endDate || undefined,
categoryId: state.categoryId || undefined,
search: state.search || undefined,
status: state.status || undefined,
tags: state.tags || undefined,
duplicatesOnly: state.duplicatesOnly || undefined,
page: state.page,
pageSize: state.pageSize,
sortBy: state.sortBy,
sortDir: state.sortDir
}).subscribe({
next: (response) => {
this.expenses.set(response.items);
this.pagination.set(response.pagination ?? { page: state.page, pageSize: state.pageSize, total: response.items.length, totalPages: 1, hasPrev: false, hasNext: false });
this.selectedIds.update((ids) => ids.filter((id) => response.items.some((item) => item.id === id)));
}
});
}
private loadDuplicates() {
this.expensesService.duplicates().subscribe({ next: (response) => this.duplicateGroups.set(response.items) });
}
private buildQueryParams(overrides: Partial<ListState> = {}) {
const raw = this.filterForm.getRawValue();
const state: ListState = {
startDate: raw.startDate,
endDate: raw.endDate,
categoryId: raw.categoryId,
search: raw.search,
status: raw.status,
tags: raw.tags,
duplicatesOnly: raw.duplicatesOnly,
page: this.pagination().page,
pageSize: this.pagination().pageSize,
sortBy: this.sortBy(),
sortDir: this.sortDir(),
...overrides
};
return {
startDate: state.startDate || null,
endDate: state.endDate || null,
categoryId: state.categoryId || null,
search: state.search || null,
status: state.status || null,
tags: state.tags || null,
duplicatesOnly: state.duplicatesOnly ? '1' : null,
page: state.page !== defaultState.page ? state.page : null,
pageSize: state.pageSize !== defaultState.pageSize ? state.pageSize : null,
sortBy: state.sortBy !== defaultState.sortBy ? state.sortBy : null,
sortDir: state.sortDir !== defaultState.sortDir ? state.sortDir : null
};
}
private updateUrl(overrides: Partial<ListState> = {}) {
this.router.navigate([], {
relativeTo: this.route,
queryParams: this.buildQueryParams(overrides),
replaceUrl: true
});
}
applyFilters() {
this.clearSelection();
this.updateUrl({ page: 1 });
}
resetFilters() {
this.filterForm.reset({ startDate: '', endDate: '', categoryId: '', search: '', status: '', tags: '', duplicatesOnly: false });
this.clearSelection();
this.updateUrl({ ...defaultState });
}
setSort(column: SortColumn) {
if (this.sortBy() === column) this.sortDir.set(this.sortDir() === 'asc' ? 'desc' : 'asc');
else {
this.sortBy.set(column);
this.sortDir.set(column === 'amount' || column === 'title' || column === 'category' ? 'asc' : 'desc');
}
this.clearSelection();
this.updateUrl({ page: 1, sortBy: this.sortBy(), sortDir: this.sortDir() });
}
sortIndicator(column: SortColumn) {
if (this.sortBy() !== column) return '';
return this.sortDir() === 'asc' ? '↑' : '↓';
}
changePage(page: number) {
if (page < 1 || page > this.pagination().totalPages) return;
this.updateUrl({ page });
}
changePageSize(value: string | number) {
const pageSize = Number(value);
if (!Number.isFinite(pageSize) || pageSize <= 0) return;
this.clearSelection();
this.updateUrl({ page: 1, pageSize });
}
pageStart() {
if (!this.pagination().total) return 0;
return (this.pagination().page - 1) * this.pagination().pageSize + 1;
}
pageEnd() {
if (!this.pagination().total) return 0;
return Math.min(this.pagination().page * this.pagination().pageSize, this.pagination().total);
}
isSelected(id: string) { return this.selectedIds().includes(id); }
toggleSelection(id: string, checked: boolean) {
this.selectedIds.update((ids) => checked ? Array.from(new Set([...ids, id])) : ids.filter((item) => item !== id));
}
toggleAllVisible(checked: boolean) {
const visibleIds = this.visibleIds();
this.selectedIds.update((ids) => {
if (checked) return Array.from(new Set([...ids, ...visibleIds]));
return ids.filter((id) => !visibleIds.includes(id));
});
}
clearSelection() { this.selectedIds.set([]); }
startEdit(item: Expense) {
this.router.navigate(['/expenses/add'], { queryParams: { edit: item.id } });
}
removeExpense(item: Expense) {
this.expensesService.delete(item.id).subscribe({
next: () => {
this.toast.success(this.ui.t('expenses.deleted'));
this.clearSelection();
this.loadExpensesFromCurrentRoute();
this.loadDuplicates();
},
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('expenses.deleteError'))
});
}
quickChangeStatus(item: Expense, nextStatus: string) {
if (!nextStatus || nextStatus === item.status) return;
this.statusSavingId.set(item.id);
this.expensesService.updateStatus(item.id, nextStatus as Expense['status']).subscribe({
next: () => {
this.toast.success(this.ui.t('expenses.statusUpdated'));
this.statusSavingId.set(null);
this.loadExpensesFromCurrentRoute();
this.loadDuplicates();
},
error: (error) => {
this.statusSavingId.set(null);
this.toast.error(error.error?.message ?? this.ui.t('expenses.statusUpdateError'));
this.loadExpensesFromCurrentRoute();
}
});
}
bulkUpdateStatus(status: Expense['status']) {
if (!this.selectedIds().length) return;
this.expensesService.bulkUpdateStatus(this.selectedIds(), status).subscribe({
next: () => {
this.toast.success(this.ui.t('expenses.bulkUpdated'));
this.clearSelection();
this.loadExpensesFromCurrentRoute();
this.loadDuplicates();
},
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('expenses.bulkActionError'))
});
}
bulkDelete() {
if (!this.selectedIds().length) return;
if (!globalThis.confirm(this.ui.t('expenses.bulkDeleteConfirm'))) return;
this.expensesService.bulkDelete(this.selectedIds()).subscribe({
next: () => {
this.toast.success(this.ui.t('expenses.bulkDeleted'));
this.clearSelection();
this.loadExpensesFromCurrentRoute();
this.loadDuplicates();
},
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('expenses.bulkActionError'))
});
}
reviewDuplicate(item: Expense, action: 'CONFIRM' | 'DISMISS' | 'REOPEN') {
this.expensesService.reviewDuplicate(item.id, action).subscribe({
next: () => {
if (action === 'CONFIRM') this.toast.success(this.ui.t('expenses.duplicateConfirmed'));
if (action === 'DISMISS') this.toast.success(this.ui.t('expenses.duplicateDismissed'));
if (action === 'REOPEN') this.toast.success(this.ui.t('expenses.duplicateReopened'));
this.loadExpensesFromCurrentRoute();
this.loadDuplicates();
},
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('toast.error'))
});
}
openProof(proof: Proof) { this.proofPreview.set(proof); }
closeProofPreview() { this.proofPreview.set(null); }
isPdf(proof: Proof) { return (proof.mimeType || '').toLowerCase().includes('pdf'); }
statusBadgeClass(status: string) {
return ({ DRAFT: 'text-bg-secondary', PENDING: 'text-bg-warning', APPROVED: 'text-bg-success', REJECTED: 'text-bg-danger' } as Record<string, string>)[status] || 'text-bg-secondary';
}
duplicateBadgeClass(item: Expense) {
const state = item.duplicateStatus ?? (item.possibleDuplicate ? 'OPEN' : null);
return ({ OPEN: 'text-bg-warning', CONFIRMED: 'text-bg-danger', DISMISSED: 'text-bg-secondary' } as Record<string, string>)[state || 'OPEN'] || 'text-bg-warning';
}
duplicateLabel(item: Expense) {
const state = item.duplicateStatus ?? (item.possibleDuplicate ? 'OPEN' : null);
if (state === 'CONFIRMED') return this.ui.t('expenses.duplicateStatus.confirmed');
if (state === 'DISMISSED') return this.ui.t('expenses.duplicateStatus.dismissed');
return this.ui.t('expenses.duplicateStatus.open');
}
private loadExpensesFromCurrentRoute() {
const params = this.route.snapshot.queryParamMap;
this.loadExpenses({
startDate: params.get('startDate') ?? defaultState.startDate,
endDate: params.get('endDate') ?? defaultState.endDate,
categoryId: params.get('categoryId') ?? defaultState.categoryId,
search: params.get('search') ?? defaultState.search,
status: params.get('status') ?? defaultState.status,
tags: params.get('tags') ?? defaultState.tags,
duplicatesOnly: ['1', 'true'].includes((params.get('duplicatesOnly') ?? '').toLowerCase()),
page: this.parsePositiveInt(params.get('page'), this.pagination().page),
pageSize: this.parsePositiveInt(params.get('pageSize'), this.pagination().pageSize),
sortBy: this.parseSortColumn(params.get('sortBy')),
sortDir: params.get('sortDir') === 'asc' ? 'asc' : 'desc'
});
}
private parsePositiveInt(value: string | null, fallback: number) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? Math.trunc(parsed) : fallback;
}
private parseSortColumn(value: string | null): SortColumn {
return (['expenseDate', 'title', 'amount', 'status', 'category'] as const).includes((value ?? '') as SortColumn)
? (value as SortColumn)
: defaultState.sortBy;
}
}