This commit is contained in:
Mateusz Gruszczyński
2026-04-07 10:06:48 +02:00
parent deaa6dfe43
commit ca9c78d88d
36 changed files with 1801 additions and 503 deletions

View File

@@ -0,0 +1,551 @@
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],
template: `
<div class="page-header d-print-none mb-3 ec-page-header">
<div class="row align-items-center g-3">
<div class="col">
<h2 class="page-title mb-1">{{ ui.t('expenses.title') }}</h2>
<div class="text-secondary">{{ ui.t('expenses.listSubtitle') }}</div>
</div>
</div>
</div>
<div class="mb-3">
<nav class="nav nav-pills gap-2">
<a class="nav-link" [routerLink]="['/expenses/add']">{{ ui.t('action.addExpense') }}</a>
<a class="nav-link active" [routerLink]="['/expenses/list']">{{ ui.t('expenses.listTitle') }}</a>
</nav>
</div>
@if (duplicateGroups().length) {
<div class="alert alert-warning">
<div class="fw-semibold mb-2">{{ ui.t('expenses.duplicatesTitle') }}</div>
<div class="d-grid gap-1">
@for (group of duplicateGroups().slice(0, 3); track group.source.id) {
<div>{{ group.source.title }} · {{ group.matches.length }} {{ ui.t('expenses.potentialMatches') }}</div>
}
</div>
</div>
}
<div class="card overflow-hidden mb-3">
<div class="card-header d-flex justify-content-between align-items-center gap-2 flex-wrap">
<h3 class="card-title mb-0">{{ ui.t('expenses.filters') }}</h3>
@if (hasActiveFilters()) {
<span class="badge text-bg-primary">{{ ui.t('action.filter') }}</span>
}
</div>
<div class="card-body">
<form [formGroup]="filterForm" (ngSubmit)="applyFilters()" class="row g-3 align-items-end">
<div class="col-sm-6 col-lg-2"><label class="form-label">{{ ui.t('stats.from') }}</label><input class="form-control" type="date" formControlName="startDate" /></div>
<div class="col-sm-6 col-lg-2"><label class="form-label">{{ ui.t('stats.to') }}</label><input class="form-control" type="date" formControlName="endDate" /></div>
<div class="col-sm-6 col-lg-2"><label class="form-label">{{ ui.t('expenses.field.category') }}</label><select class="form-select" formControlName="categoryId"><option value="">{{ ui.t('expenses.allCategories') }}</option>@for (category of categories(); track category.id) { <option [value]="category.id">{{ category.name }}</option> }</select></div>
<div class="col-sm-6 col-lg-2"><label class="form-label">{{ ui.t('expenses.field.status') }}</label><select class="form-select" formControlName="status"><option value="">{{ ui.t('common.none') }}</option><option value="DRAFT">{{ ui.t('status.draft') }}</option><option value="PENDING">{{ ui.t('status.pending') }}</option><option value="APPROVED">{{ ui.t('status.approved') }}</option><option value="REJECTED">{{ ui.t('status.rejected') }}</option></select></div>
<div class="col-sm-6 col-lg-2"><label class="form-label">{{ ui.t('expenses.field.tags') }}</label><input class="form-control" formControlName="tags" /></div>
<div class="col-sm-6 col-lg-2"><label class="form-label">{{ ui.t('expenses.search') }}</label><input class="form-control" formControlName="search" /></div>
<div class="col-12"><label class="form-check"><input class="form-check-input" type="checkbox" formControlName="duplicatesOnly" /><span class="form-check-label">{{ ui.t('expenses.duplicatesOnly') }}</span></label></div>
<div class="col-12 d-flex gap-2 flex-wrap"><button class="btn btn-primary" type="submit">{{ ui.t('action.filter') }}</button><button class="btn btn-outline-secondary" type="button" (click)="resetFilters()">{{ ui.t('action.reset') }}</button></div>
</form>
</div>
</div>
<div class="card overflow-hidden">
<div class="card-header d-flex justify-content-between align-items-center gap-2 flex-wrap">
<div>
<h3 class="card-title mb-0">{{ ui.t('expenses.listTitle') }}</h3>
<div class="small text-secondary">{{ ui.t('expenses.totalItems') }}: {{ pagination().total }}</div>
</div>
<div class="d-flex align-items-center gap-2 flex-wrap">
<select class="form-select form-select-sm w-auto" [value]="pagination().pageSize" (change)="changePageSize($any($event.target).value)">
@for (size of pageSizeOptions; track size) {
<option [value]="size">{{ size }} / {{ ui.t('expenses.perPage') }}</option>
}
</select>
</div>
</div>
@if (selectedIds().length) {
<div class="card-body border-bottom bg-body-tertiary py-3">
<div class="d-flex justify-content-between align-items-center gap-2 flex-wrap">
<div class="fw-semibold">{{ ui.t('expenses.selectedCount') }}: {{ selectedIds().length }}</div>
<div class="btn-list flex-wrap">
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="clearSelection()">{{ ui.t('action.clearSelection') }}</button>
<button class="btn btn-sm btn-outline-warning" type="button" (click)="bulkUpdateStatus('PENDING')">{{ ui.t('status.pending') }}</button>
<button class="btn btn-sm btn-outline-success" type="button" (click)="bulkUpdateStatus('APPROVED')">{{ ui.t('status.approved') }}</button>
<button class="btn btn-sm btn-outline-danger" type="button" (click)="bulkDelete()">{{ ui.t('action.delete') }}</button>
</div>
</div>
</div>
}
<div class="table-responsive">
<table class="table table-vcenter card-table mb-0">
<thead>
<tr>
<th class="w-1">
<input class="form-check-input" type="checkbox" [checked]="allVisibleSelected()" [indeterminate]="someVisibleSelected()" (change)="toggleAllVisible($any($event.target).checked)" />
</th>
<th><button class="btn btn-link p-0 text-decoration-none fw-semibold" type="button" (click)="setSort('expenseDate')">{{ ui.t('expenses.field.date') }} {{ sortIndicator('expenseDate') }}</button></th>
<th><button class="btn btn-link p-0 text-decoration-none fw-semibold" type="button" (click)="setSort('title')">{{ ui.t('table.title') }} {{ sortIndicator('title') }}</button></th>
<th><button class="btn btn-link p-0 text-decoration-none fw-semibold" type="button" (click)="setSort('category')">{{ ui.t('expenses.field.category') }} {{ sortIndicator('category') }}</button></th>
<th><button class="btn btn-link p-0 text-decoration-none fw-semibold" type="button" (click)="setSort('status')">{{ ui.t('expenses.field.status') }} {{ sortIndicator('status') }}</button></th>
<th class="text-end"><button class="btn btn-link p-0 text-decoration-none fw-semibold" type="button" (click)="setSort('amount')">{{ ui.t('table.amount') }} {{ sortIndicator('amount') }}</button></th>
<th class="text-end">{{ ui.t('table.actions') }}</th>
</tr>
</thead>
<tbody>
@for (item of expenses(); track item.id) {
<tr>
<td><input class="form-check-input" type="checkbox" [checked]="isSelected(item.id)" (change)="toggleSelection(item.id, $any($event.target).checked)" /></td>
<td>{{ item.expenseDate | date:'yyyy-MM-dd' }}</td>
<td>
<div class="fw-semibold d-flex align-items-center gap-2 flex-wrap">
<a class="link-body-emphasis text-decoration-none" [routerLink]="['/expenses', item.id]">{{ item.title }}</a>
@if (item.possibleDuplicate || item.duplicateStatus) {
<span class="badge" [ngClass]="duplicateBadgeClass(item)">{{ duplicateLabel(item) }}</span>
}
@if (item.recurringSourceId) {
<span class="badge text-bg-info">{{ ui.t('recurring.badge') }}</span>
}
</div>
<div class="text-secondary small">{{ item.merchant || ui.t('expenses.noMerchant') }}</div>
@if (item.tags.length) { <div class="mt-1 d-flex flex-wrap gap-1">@for (tag of item.tags; track tag) { <span class="badge text-bg-secondary">#{{ tag }}</span> }</div> }
@if (customFieldEntries(item).length) { <div class="small text-secondary mt-1">@for (field of customFieldEntries(item); track field[0]) { <span class="me-2">{{ field[0] }}: {{ field[1] }}</span> }</div> }
@if (item.proofs.length) { <div class="mt-2 d-flex flex-wrap gap-2">@for (proof of item.proofs; track proof.id) { <button class="btn btn-sm btn-outline-secondary" type="button" (click)="openProof(proof)">{{ proof.label || proof.originalName || ui.t('expenses.proof') }}</button> }</div> }
</td>
<td>{{ item.category.name }}</td>
<td>
<div class="d-grid gap-2">
<span class="badge d-inline-flex align-items-center justify-content-center" [ngClass]="statusBadgeClass(item.status)">{{ ui.t('status.' + item.status.toLowerCase()) }}</span>
<select class="form-select form-select-sm" [value]="item.status" [disabled]="statusSavingId() === item.id" (change)="quickChangeStatus(item, $any($event.target).value)">
<option value="DRAFT">{{ ui.t('status.draft') }}</option>
<option value="PENDING">{{ ui.t('status.pending') }}</option>
<option value="APPROVED">{{ ui.t('status.approved') }}</option>
<option value="REJECTED">{{ ui.t('status.rejected') }}</option>
</select>
</div>
</td>
<td class="text-end">{{ item.amount | currency:'PLN':'symbol':'1.2-2' }}</td>
<td class="text-end">
<div class="btn-list justify-content-end flex-wrap">
<a class="btn btn-sm btn-outline-secondary" [routerLink]="['/expenses', item.id]">{{ ui.t('action.view') }}</a>
@if (item.possibleDuplicate && item.duplicateStatus !== 'CONFIRMED') {
<button class="btn btn-sm btn-outline-success" type="button" (click)="reviewDuplicate(item, 'CONFIRM')">OK</button>
}
@if (item.possibleDuplicate && item.duplicateStatus !== 'DISMISSED') {
<button class="btn btn-sm btn-outline-warning" type="button" (click)="reviewDuplicate(item, 'DISMISS')">X</button>
}
@if (item.duplicateStatus === 'DISMISSED' || item.duplicateStatus === 'CONFIRMED') {
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="reviewDuplicate(item, 'REOPEN')">↺</button>
}
<button class="btn btn-sm btn-outline-primary" type="button" (click)="startEdit(item)">{{ ui.t('action.edit') }}</button>
<button class="btn btn-sm btn-outline-danger" type="button" (click)="removeExpense(item)">{{ ui.t('action.delete') }}</button>
</div>
</td>
</tr>
} @empty { <tr><td colspan="7" class="text-secondary">{{ ui.t('expenses.noItems') }}</td></tr> }
</tbody>
</table>
</div>
<div class="card-footer d-flex justify-content-between align-items-center gap-2 flex-wrap">
<div class="small text-secondary">{{ pageStart() }}-{{ pageEnd() }} / {{ pagination().total }}</div>
<div class="btn-group">
<button class="btn btn-outline-secondary btn-sm" type="button" [disabled]="!pagination().hasPrev" (click)="changePage(1)">«</button>
<button class="btn btn-outline-secondary btn-sm" type="button" [disabled]="!pagination().hasPrev" (click)="changePage(pagination().page - 1)"></button>
<button class="btn btn-outline-secondary btn-sm" type="button" disabled>{{ pagination().page }} / {{ pagination().totalPages }}</button>
<button class="btn btn-outline-secondary btn-sm" type="button" [disabled]="!pagination().hasNext" (click)="changePage(pagination().page + 1)"></button>
<button class="btn btn-outline-secondary btn-sm" type="button" [disabled]="!pagination().hasNext" (click)="changePage(pagination().totalPages)">»</button>
</div>
</div>
</div>
@if (proofPreview()) {
<div class="modal modal-blur fade show d-block" tabindex="-1">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ proofPreview()?.label || proofPreview()?.originalName || ui.t('expenses.proof') }}</h5>
<button class="btn-close ec-modal-close" type="button" (click)="closeProofPreview()"></button>
</div>
<div class="modal-body ec-proof-modal-body">
@if (isPdf(proofPreview()!)) {
<iframe class="ec-proof-frame" [src]="proofPreviewUrl()"></iframe>
} @else {
<img class="img-fluid ec-proof-preview" [src]="proofPreview()?.previewUrl || proofPreview()?.fileUrl" [alt]="ui.t('expenses.proof')" />
}
</div>
</div>
</div>
</div>
<div class="modal-backdrop fade show" (click)="closeProofPreview()"></div>
}
`
})
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;
}
}