import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common'; import { Component, OnInit, computed, inject, signal } from '@angular/core'; import { FormArray, FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { ImageCroppedEvent, ImageCropperComponent } from 'ngx-image-cropper'; import { CategoriesService } from '../../core/services/categories.service'; import { ExpensesService } from '../../core/services/expenses.service'; import { MerchantsService } from '../../core/services/merchants.service'; import { ToastService } from '../../core/services/toast.service'; import { UiService } from '../../core/services/ui.service'; import type { DuplicateGroup, Expense, Merchant, Proof } from '../../shared/models'; const formatLocalDate = (date: Date) => { const year = date.getFullYear(); const month = `${date.getMonth() + 1}`.padStart(2, '0'); const day = `${date.getDate()}`.padStart(2, '0'); return `${year}-${month}-${day}`; }; const today = formatLocalDate(new Date()); @Component({ selector: 'app-expenses', standalone: true, imports: [CommonModule, ReactiveFormsModule, CurrencyPipe, DatePipe, ImageCropperComponent], template: ` @if (duplicateGroups().length) {
{{ ui.t('expenses.duplicatesTitle') }}
@for (group of duplicateGroups().slice(0, 3); track group.source.id) {
{{ group.source.title }} · {{ group.matches.length }} {{ ui.t('expenses.potentialMatches') }}
}
}

{{ editingExpenseId() ? ui.t('expenses.edit') : ui.t('expenses.new') }}

@if (editingExpenseId()) { }
@if (submitted() && expenseForm.invalid) {
{{ ui.t('expenses.requiredHint') }}
}
{{ ui.t('expenses.field.customFields') }}
@for (group of customFields.controls; track $index) {
} @empty {
{{ ui.t('expenses.noCustomFields') }}
}
@if (!editingExpenseId()) {
@if (showCropper()) {
{{ ui.t('expenses.field.crop') }}
} @if (croppedPreview()) {
{{ ui.t('expenses.field.cropPreview') }}
} @if (selectedFiles().length) {
{{ ui.t('expenses.attachmentsSelected') }}
@for (file of selectedFiles(); track file.name + $index) { {{ file.name }} }
}
}

{{ ui.t('expenses.filters') }}

@for (item of expenses(); track item.id) { } @empty { }
{{ ui.t('table.title') }}{{ ui.t('expenses.field.status') }}{{ ui.t('table.amount') }}
{{ item.title }} @if (item.possibleDuplicate || item.duplicateStatus) { {{ duplicateLabel(item) }} } @if (item.recurringSourceId) { {{ ui.t('recurring.badge') }} }
{{ item.expenseDate | date:'yyyy-MM-dd' }} · {{ item.category.name }} · {{ item.merchant || ui.t('expenses.noMerchant') }}
@if (item.tags.length) {
@for (tag of item.tags; track tag) { #{{ tag }} }
} @if (customFieldEntries(item).length) {
@for (field of customFieldEntries(item); track field[0]) { {{ field[0] }}: {{ field[1] }} }
} @if (item.proofs.length) {
@for (proof of item.proofs; track proof.id) { }
}
{{ ui.t('status.' + item.status.toLowerCase()) }} {{ item.amount | currency:'PLN':'symbol':'1.2-2' }}
@if (item.possibleDuplicate && item.duplicateStatus !== 'CONFIRMED') { } @if (item.possibleDuplicate && item.duplicateStatus !== 'DISMISSED') { } @if (item.duplicateStatus === 'DISMISSED' || item.duplicateStatus === 'CONFIRMED') { }
{{ ui.t('expenses.noItems') }}
@if (merchantModalOpen()) { } @if (proofPreview()) { } ` }) export class ExpensesComponent implements OnInit { readonly ui = inject(UiService); private readonly fb = inject(FormBuilder); private readonly categoriesService = inject(CategoriesService); private readonly merchantsService = inject(MerchantsService); private readonly expensesService = inject(ExpensesService); private readonly toast = inject(ToastService); readonly categories = this.categoriesService.items; readonly merchants = this.merchantsService.items; readonly expenses = signal([]); readonly duplicateGroups = signal([]); readonly selectedMerchantId = signal(''); readonly editingExpenseId = signal(null); readonly saving = signal(false); readonly submitted = signal(false); readonly merchantModalOpen = signal(false); readonly proofPreview = signal(null); readonly selectedFiles = signal([]); readonly imageChangedEvent = signal(null); readonly croppedFile = signal(null); readonly croppedPreview = signal(null); readonly showCropper = signal(false); readonly expenseForm = this.fb.nonNullable.group({ title: ['', [Validators.required, Validators.minLength(2)]], amount: [0, [Validators.required, Validators.min(0.01)]], expenseDate: [today, Validators.required], categoryId: ['', Validators.required], merchant: [''], paymentMethod: [''], description: [''], status: ['PENDING'], tagsText: [''], proofType: ['RECEIPT'], proofLabel: [''], proofNote: [''], customFields: this.fb.array([]) }); readonly filterForm = this.fb.nonNullable.group({ startDate: [''], endDate: [''], categoryId: [''], search: [''], status: [''], tags: [''], duplicatesOnly: [false] }); readonly merchantForm = this.fb.nonNullable.group({ name: ['', [Validators.required, Validators.minLength(2)]], kind: ['MERCHANT' as Merchant['kind'], Validators.required], notes: [''] }); get customFields() { return this.expenseForm.controls.customFields as FormArray; } readonly activeMerchants = computed(() => this.merchants().filter((item) => item.isActive)); ngOnInit() { this.categoriesService.ensureLoaded(true); this.merchantsService.ensureLoaded(true); this.loadExpenses(); this.loadDuplicates(); } addCustomField(key = '', value = '') { this.customFields.push(this.fb.group({ key: [key], value: [value] })); } removeCustomField(index: number) { this.customFields.removeAt(index); } customFieldEntries(item: Expense) { return Object.entries(item.customFields || {}); } loadExpenses() { const raw = this.filterForm.getRawValue(); this.expensesService.list({ startDate: raw.startDate || undefined, endDate: raw.endDate || undefined, categoryId: raw.categoryId || undefined, search: raw.search || undefined, status: raw.status || undefined, tags: raw.tags || undefined, duplicatesOnly: raw.duplicatesOnly || undefined }).subscribe({ next: (response) => this.expenses.set(response.items) }); } loadDuplicates() { this.expensesService.duplicates().subscribe({ next: (response) => this.duplicateGroups.set(response.items) }); } resetFilters() { this.filterForm.reset({ startDate: '', endDate: '', categoryId: '', search: '', status: '', tags: '', duplicatesOnly: false }); this.loadExpenses(); } selectMerchant(id: string) { this.selectedMerchantId.set(id); const merchant = this.merchants().find((item) => item.id === id); this.expenseForm.patchValue({ merchant: merchant?.name ?? '' }); } openMerchantModal() { this.merchantForm.reset({ name: '', kind: 'MERCHANT', notes: '' }); this.merchantModalOpen.set(true); } saveMerchant() { if (this.merchantForm.invalid) return; this.merchantsService.create({ ...this.merchantForm.getRawValue(), isActive: true }).subscribe({ next: (response) => { this.toast.success(this.ui.t('merchant.added')); this.merchantModalOpen.set(false); this.selectedMerchantId.set(response.item.id); this.expenseForm.patchValue({ merchant: response.item.name }); }, error: (error) => this.toast.error(error.error?.message ?? this.ui.t('merchant.saveError')) }); } onProofSelected(event: Event) { const input = event.target as HTMLInputElement; const files = Array.from(input.files ?? []); this.selectedFiles.set(files); this.croppedFile.set(null); this.croppedPreview.set(null); this.imageChangedEvent.set(event); this.showCropper.set(files.length === 1 && files[0].type.startsWith('image/')); } onImageCropped(event: ImageCroppedEvent) { if (!event.blob) return; const file = new File([event.blob], `proof-${Date.now()}.png`, { type: 'image/png' }); this.croppedFile.set(file); this.croppedPreview.set(event.objectUrl ?? null); } submitExpense(forcedStatus?: Expense['status']) { this.submitted.set(true); this.expenseForm.markAllAsTouched(); if (this.expenseForm.invalid) return; const raw = this.expenseForm.getRawValue(); const customFieldEntries = this.customFields.getRawValue().map((item: { key: string; value: string }) => [item.key, item.value] as [string, string]).filter(([key, value]) => Boolean(key && value)); const customFields = Object.fromEntries(customFieldEntries); const tags = raw.tagsText.split(',').map((item) => item.trim()).filter(Boolean); const status = forcedStatus ?? (raw.status as Expense['status']); this.saving.set(true); if (this.editingExpenseId()) { this.expensesService.update(this.editingExpenseId()!, { title: raw.title, amount: raw.amount, expenseDate: raw.expenseDate, categoryId: raw.categoryId, merchant: raw.merchant, paymentMethod: raw.paymentMethod as Expense['paymentMethod'], description: raw.description, currency: 'PLN', status, tags, customFields }).subscribe({ next: (response) => { this.finishSave(response.warnings); this.toast.success(this.ui.t('expenses.saved')); this.cancelEdit(); }, error: (error) => { this.saving.set(false); this.toast.error(error.error?.message ?? this.ui.t('expenses.saveError')); } }); return; } const formData = new FormData(); formData.set('title', raw.title); formData.set('amount', String(raw.amount)); formData.set('expenseDate', raw.expenseDate); formData.set('categoryId', raw.categoryId); formData.set('merchant', raw.merchant); formData.set('paymentMethod', raw.paymentMethod); formData.set('description', raw.description); formData.set('currency', 'PLN'); formData.set('status', status); formData.set('tags', JSON.stringify(tags)); formData.set('customFields', JSON.stringify(customFields)); formData.set('proofType', raw.proofType); formData.set('proofLabel', raw.proofLabel); formData.set('proofNote', raw.proofNote); const selected = this.selectedFiles(); if (this.croppedFile()) { formData.append('proofFiles', this.croppedFile()!); selected.slice(1).forEach((file) => formData.append('proofFiles', file)); } else { selected.forEach((file) => formData.append('proofFiles', file)); } this.expensesService.create(formData).subscribe({ next: (response) => { this.finishSave(response.warnings); this.toast.success(status === 'DRAFT' ? this.ui.t('expenses.draftSaved') : this.ui.t('expenses.added')); }, error: (error) => { this.saving.set(false); this.toast.error(error.error?.message ?? this.ui.t('expenses.addError')); } }); } private finishSave(warnings?: string[]) { this.saving.set(false); this.submitted.set(false); warnings?.forEach((warning) => this.toast.warning(warning)); this.resetForm(); this.loadExpenses(); this.loadDuplicates(); } startEdit(item: Expense) { this.editingExpenseId.set(item.id); this.submitted.set(false); this.customFields.clear(); Object.entries(item.customFields || {}).forEach(([key, value]) => this.addCustomField(key, value)); this.expenseForm.patchValue({ title: item.title, amount: item.amount, expenseDate: item.expenseDate, categoryId: item.category.id, merchant: item.merchant ?? '', paymentMethod: item.paymentMethod ?? '', description: item.description ?? '', status: item.status, tagsText: (item.tags || []).join(', '), proofType: 'RECEIPT', proofLabel: '', proofNote: '' }); } cancelEdit() { this.editingExpenseId.set(null); this.submitted.set(false); this.resetForm(); } private resetForm() { this.customFields.clear(); this.expenseForm.reset({ title: '', amount: 0, expenseDate: today, categoryId: '', merchant: '', paymentMethod: '', description: '', status: 'PENDING', tagsText: '', proofType: 'RECEIPT', proofLabel: '', proofNote: '', customFields: [] as never[] }); this.selectedMerchantId.set(''); this.selectedFiles.set([]); this.croppedFile.set(null); this.croppedPreview.set(null); this.showCropper.set(false); } removeExpense(item: Expense) { this.expensesService.delete(item.id).subscribe({ next: () => { this.toast.success(this.ui.t('expenses.deleted')); this.loadExpenses(); this.loadDuplicates(); }, error: (error) => this.toast.error(error.error?.message ?? this.ui.t('expenses.deleteError')) }); } 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.loadExpenses(); this.loadDuplicates(); }, error: (error) => this.toast.error(error.error?.message ?? this.ui.t('toast.error')) }); } openProof(proof: Proof) { this.proofPreview.set(proof); } closeMerchantModal() { this.merchantModalOpen.set(false); } closeProofPreview() { this.proofPreview.set(null); } isPdf(proof: Proof) { return (proof.mimeType || '').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'); } }