import { CommonModule } from '@angular/common'; import { Component, OnInit, computed, inject, signal } from '@angular/core'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; 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 { 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, RouterLink, ImageCropperComponent], template: `

{{ 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() && editingProofs().length) {
{{ ui.t('expenses.existingProofs') }}
@for (proof of editingProofs(); track proof.id) {
}
} @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 }} }
}
@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); private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly sanitizer = inject(DomSanitizer); readonly categories = this.categoriesService.items; readonly merchants = this.merchantsService.items; readonly selectedMerchantId = signal(''); readonly editingExpenseId = signal(null); readonly saving = signal(false); readonly submitted = signal(false); readonly merchantModalOpen = signal(false); 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 selectedFiles = signal([]); readonly imageChangedEvent = signal(null); readonly croppedFile = signal(null); readonly croppedPreview = signal(null); readonly showCropper = signal(false); readonly editingProofs = signal([]); readonly removedProofIds = signal([]); 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 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.route.queryParamMap.subscribe((params) => { const editId = params.get('edit'); if (editId) this.loadExpenseForEdit(editId); else this.cancelEdit(false); }); } addCustomField(key = '', value = '') { this.customFields.push(this.fb.group({ key: [key], value: [value] })); } removeCustomField(index: number) { this.customFields.removeAt(index); } private loadExpenseForEdit(id: string) { this.expensesService.getById(id).subscribe({ next: (response) => this.startEdit(response.item), error: (error) => { this.toast.error(error.error?.message ?? this.ui.t('expenses.saveError')); this.cancelEdit(); } }); } 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); } markProofForRemoval(proof: Proof) { this.removedProofIds.update((ids) => Array.from(new Set([...ids, proof.id]))); this.editingProofs.update((items) => items.filter((item) => item.id !== proof.id)); } 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); 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); formData.set('removeProofIds', JSON.stringify(this.removedProofIds())); 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)); } const request = this.editingExpenseId() ? this.expensesService.update(this.editingExpenseId()!, formData) : this.expensesService.create(formData); request.subscribe({ next: (response) => { this.saving.set(false); this.submitted.set(false); response.warnings?.forEach((warning) => this.toast.warning(warning)); const wasEditing = Boolean(this.editingExpenseId()); this.toast.success(wasEditing ? this.ui.t('expenses.saved') : status === 'DRAFT' ? this.ui.t('expenses.draftSaved') : this.ui.t('expenses.added')); this.resetForm(); if (wasEditing) this.router.navigate(['/expenses/add']); }, error: (error) => { this.saving.set(false); this.toast.error(error.error?.message ?? (this.editingExpenseId() ? this.ui.t('expenses.saveError') : this.ui.t('expenses.addError'))); } }); } startEdit(item: Expense) { this.editingExpenseId.set(item.id); this.editingProofs.set(item.proofs || []); this.removedProofIds.set([]); 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(navigate = true) { this.editingExpenseId.set(null); this.submitted.set(false); this.resetForm(); if (navigate) this.router.navigate(['/expenses/add']); } 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.imageChangedEvent.set(null); this.showCropper.set(false); this.editingProofs.set([]); this.removedProofIds.set([]); } openProof(proof: Proof) { this.proofPreview.set(proof); } closeMerchantModal() { this.merchantModalOpen.set(false); } closeProofPreview() { this.proofPreview.set(null); } isPdf(proof: Proof) { return (proof.mimeType || '').toLowerCase().includes('pdf'); } }