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([]); readonly duplicateGroups = signal([]); readonly proofPreview = signal(null); readonly proofPreviewUrl = computed(() => { 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(null); readonly pagination = signal({ page: 1, pageSize: 20, total: 0, totalPages: 1, hasPrev: false, hasNext: false }); readonly pageSizeOptions = [10, 20, 50]; readonly sortBy = signal('expenseDate'); readonly sortDir = signal<'asc' | 'desc'>('desc'); readonly selectedIds = signal([]); 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 = {}) { 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 = {}) { 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)[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)[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; } }