373 lines
15 KiB
TypeScript
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;
|
|
}
|
|
}
|