first commit

This commit is contained in:
Mateusz Gruszczyński
2026-04-05 13:40:27 +02:00
commit 9a6e77a5fc
89 changed files with 18276 additions and 0 deletions

View File

@@ -0,0 +1,507 @@
import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common';
import { Component, OnInit, inject, signal } from '@angular/core';
import { 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 type { Expense, Merchant, Proof } from '../../shared/models';
const today = new Date().toISOString().slice(0, 10);
@Component({
selector: 'app-expenses',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, CurrencyPipe, DatePipe, ImageCropperComponent],
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">Wydatki</h2>
<div class="text-secondary">Dodawaj wydatki, zapisuj potwierdzenia i wybieraj kontrahentów z listy.</div>
</div>
</div>
</div>
<div class="row row-cards align-items-start">
<div class="col-xl-7">
<div class="card overflow-hidden">
<div class="card-header d-flex justify-content-between align-items-center">
<h3 class="card-title">{{ editingExpenseId() ? 'Edytuj wydatek' : 'Nowy wydatek' }}</h3>
@if (editingExpenseId()) {
<button class="btn btn-outline-secondary btn-sm" type="button" (click)="cancelEdit()">Anuluj edycję</button>
}
</div>
<div class="card-body">
<form [formGroup]="expenseForm" (ngSubmit)="submitExpense()" class="d-grid gap-3">
<div class="row g-3">
<div class="col-md-7">
<label class="form-label">Tytuł</label>
<input class="form-control" formControlName="title" />
</div>
<div class="col-md-5">
<label class="form-label">Kwota</label>
<input class="form-control" type="number" step="0.01" formControlName="amount" />
</div>
<div class="col-md-4">
<label class="form-label">Data</label>
<input class="form-control" type="date" formControlName="expenseDate" />
</div>
<div class="col-md-4">
<label class="form-label">Kategoria</label>
<select class="form-select" formControlName="categoryId">
<option value="">Wybierz</option>
@for (category of categories(); track category.id) {
<option [value]="category.id">{{ category.name }}</option>
}
</select>
</div>
<div class="col-md-4">
<label class="form-label">Płatność</label>
<select class="form-select" formControlName="paymentMethod">
<option value="">Brak</option>
<option value="CARD">Karta</option>
<option value="CASH">Gotówka</option>
<option value="TRANSFER">Przelew</option>
<option value="BLIK">BLIK</option>
<option value="OTHER">Inne</option>
</select>
</div>
<div class="col-md-9">
<label class="form-label">Kontrahent</label>
<div class="input-group">
<select class="form-select" [value]="selectedMerchantId()" (change)="selectMerchant($any($event.target).value)">
<option value="">Własny wpis</option>
@for (item of activeMerchants(); track item.id) {
<option [value]="item.id">{{ item.name }}</option>
}
</select>
<button class="btn btn-outline-primary" type="button" (click)="openMerchantModal()">Dodaj</button>
</div>
</div>
<div class="col-md-3">
<label class="form-label">Nazwa w wydatku</label>
<input class="form-control" formControlName="merchant" />
</div>
<div class="col-12">
<label class="form-label">Opis</label>
<textarea class="form-control" rows="3" formControlName="description"></textarea>
</div>
</div>
@if (!editingExpenseId()) {
<div class="card bg-body-tertiary overflow-hidden">
<div class="card-body d-grid gap-3">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Typ potwierdzenia</label>
<select class="form-select" formControlName="proofType">
<option value="RECEIPT">Paragon</option>
<option value="INVOICE">Faktura</option>
<option value="NOTE">Notatka</option>
<option value="BANK_STATEMENT">Wyciąg</option>
<option value="OTHER">Inne</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label">Etykieta</label>
<input class="form-control" formControlName="proofLabel" />
</div>
<div class="col-md-4">
<label class="form-label">Plik</label>
<input class="form-control" type="file" accept="image/*,.pdf" (change)="onProofSelected($event)" />
</div>
<div class="col-12">
<label class="form-label">Notatka do potwierdzenia</label>
<textarea class="form-control" rows="2" formControlName="proofNote"></textarea>
</div>
</div>
@if (showCropper()) {
<div>
<div class="form-label">Kadrowanie</div>
<image-cropper [imageChangedEvent]="imageChangedEvent()" [maintainAspectRatio]="false" format="png" (imageCropped)="onImageCropped($event)"></image-cropper>
</div>
}
@if (croppedPreview()) {
<div>
<div class="form-label">Podgląd po cropie</div>
<img class="img-fluid rounded" [src]="croppedPreview()" alt="Podgląd" />
</div>
}
</div>
</div>
}
<button class="btn btn-success d-inline-flex align-items-center justify-content-center gap-2" [disabled]="expenseForm.invalid || saving()">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 12l5 5l10 -10"/></svg>
<span>{{ saving() ? 'Zapisywanie...' : (editingExpenseId() ? 'Zapisz zmiany' : 'Dodaj wydatek') }}</span>
</button>
</form>
</div>
</div>
</div>
<div class="col-xl-5">
<div class="card sticky-top overflow-hidden" style="top: 1rem;">
<div class="card-header"><h3 class="card-title">Filtry i ostatnie wydatki</h3></div>
<div class="card-body">
<form [formGroup]="filterForm" (ngSubmit)="loadExpenses()" class="row g-2 mb-4">
<div class="col-6"><input class="form-control" type="date" formControlName="startDate" /></div>
<div class="col-6"><input class="form-control" type="date" formControlName="endDate" /></div>
<div class="col-12">
<select class="form-select" formControlName="categoryId">
<option value="">Wszystkie kategorie</option>
@for (category of categories(); track category.id) {
<option [value]="category.id">{{ category.name }}</option>
}
</select>
</div>
<div class="col-12"><input class="form-control" formControlName="search" placeholder="Szukaj" /></div>
<div class="col-12 d-flex gap-2">
<button class="btn btn-primary flex-fill">Filtruj</button>
<button class="btn btn-outline-secondary" type="button" (click)="resetFilters()">Reset</button>
</div>
</form>
@if (expenses().length) {
<div class="list-group list-group-flush">
@for (expense of expenses(); track expense.id) {
<div class="list-group-item px-0">
<div class="d-flex justify-content-between gap-3">
<div>
<div class="fw-semibold">{{ expense.title }}</div>
<div class="small text-secondary">{{ expense.merchant || 'Brak kontrahenta' }} • {{ expense.expenseDate | date:'shortDate' }}</div>
<div class="small text-secondary">{{ expense.category.name }}</div>
</div>
<div class="text-end">
<div class="fw-bold">{{ expense.amount | currency:expense.currency:'symbol':'1.2-2' }}</div>
<div class="btn-list justify-content-end mt-2">
<button class="btn btn-outline-primary btn-sm" type="button" (click)="startEdit(expense)">Edytuj</button>
<button class="btn btn-outline-danger btn-sm" type="button" (click)="removeExpense(expense)">Usuń</button>
</div>
</div>
</div>
@if (expense.proofs.length) {
<div class="btn-list mt-3">
@for (proof of expense.proofs; track proof.id) {
<button class="btn btn-outline-info btn-sm" type="button" (click)="openProof(proof)">
{{ proof.label || proof.originalName || 'Potwierdzenie' }}
</button>
}
</div>
}
</div>
}
</div>
} @else {
<div class="alert alert-warning mb-0">Brak wydatków do wyświetlenia.</div>
}
</div>
</div>
</div>
</div>
@if (merchantModalOpen()) {
<div class="modal modal-blur fade show d-block" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Nowy kontrahent</h5>
<button class="btn-close" type="button" (click)="closeMerchantModal()"></button>
</div>
<form [formGroup]="merchantForm" (ngSubmit)="saveMerchant()">
<div class="modal-body">
<div class="d-grid gap-3">
<div>
<label class="form-label">Nazwa</label>
<input class="form-control" formControlName="name" />
</div>
<div>
<label class="form-label">Typ</label>
<select class="form-select" formControlName="kind">
<option value="MERCHANT">Sprzedawca</option>
<option value="SERVICE_PROVIDER">Usługodawca</option>
<option value="OTHER">Inny</option>
</select>
</div>
<div>
<label class="form-label">Notatki</label>
<textarea class="form-control" rows="3" formControlName="notes"></textarea>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost-secondary" type="button" (click)="closeMerchantModal()">Anuluj</button>
<button class="btn btn-success" [disabled]="merchantForm.invalid">Zapisz kontrahenta</button>
</div>
</form>
</div>
</div>
</div>
<div class="modal-backdrop fade show"></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 || 'Potwierdzenie' }}</h5>
<button class="btn-close" type="button" (click)="closeProofPreview()"></button>
</div>
<div class="modal-body">
@if ((proofPreview()?.mimeType || '').includes('pdf')) {
<embed [attr.src]="proofPreview()?.fileUrl" type="application/pdf" style="width:100%;height:75vh;" />
} @else {
<img class="img-fluid" [src]="proofPreview()?.fileUrl" alt="Potwierdzenie" />
}
</div>
</div>
</div>
</div>
<div class="modal-backdrop fade show"></div>
}
`
})
export class ExpensesComponent implements OnInit {
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<Expense[]>([]);
readonly selectedMerchantId = signal('');
readonly editingExpenseId = signal<string | null>(null);
readonly saving = signal(false);
readonly merchantModalOpen = signal(false);
readonly proofPreview = signal<Proof | null>(null);
readonly imageChangedEvent = signal<Event | null>(null);
readonly croppedFile = signal<File | null>(null);
readonly croppedPreview = signal<string | null>(null);
readonly showCropper = signal(false);
readonly expenseForm = this.fb.nonNullable.group({
title: ['', [Validators.required, Validators.minLength(2)]],
amount: [0],
expenseDate: [today, Validators.required],
categoryId: ['', Validators.required],
merchant: [''],
paymentMethod: [''],
description: [''],
proofType: ['RECEIPT'],
proofLabel: [''],
proofNote: ['']
});
readonly filterForm = this.fb.nonNullable.group({
startDate: [''],
endDate: [''],
categoryId: [''],
search: ['']
});
readonly merchantForm = this.fb.nonNullable.group({
name: ['', [Validators.required, Validators.minLength(2)]],
kind: ['MERCHANT' as Merchant['kind'], Validators.required],
notes: ['']
});
ngOnInit() {
this.categoriesService.ensureLoaded(true);
this.merchantsService.ensureLoaded(true);
this.loadExpenses();
}
activeMerchants() {
return this.merchants().filter((item) => item.isActive);
}
loadExpenses() {
this.expensesService.list(this.filterForm.getRawValue()).subscribe({
next: (response) => this.expenses.set(response.items)
});
}
resetFilters() {
this.filterForm.reset({ startDate: '', endDate: '', categoryId: '', search: '' });
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('Kontrahent został dodany.');
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 ?? 'Nie udało się dodać kontrahenta.')
});
}
onProofSelected(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0] ?? null;
this.croppedFile.set(file);
this.croppedPreview.set(null);
this.imageChangedEvent.set(event);
if (file && file.type.startsWith('image/')) {
this.showCropper.set(true);
} else {
this.showCropper.set(false);
}
}
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() {
if (this.expenseForm.invalid) return;
const raw = this.expenseForm.getRawValue();
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'
})
.subscribe({
next: () => {
this.saving.set(false);
this.toast.success('Wydatek został zapisany.');
this.cancelEdit();
this.loadExpenses();
},
error: (error) => {
this.saving.set(false);
this.toast.error(error.error?.message ?? 'Nie udało się zapisać wydatku.');
}
});
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('proofType', raw.proofType);
formData.set('proofLabel', raw.proofLabel);
formData.set('proofNote', raw.proofNote);
if (this.croppedFile()) formData.set('proofFile', this.croppedFile()!);
this.expensesService.create(formData).subscribe({
next: () => {
this.saving.set(false);
this.toast.success('Wydatek został dodany.');
this.expenseForm.reset({
title: '',
amount: 0,
expenseDate: today,
categoryId: '',
merchant: '',
paymentMethod: '',
description: '',
proofType: 'RECEIPT',
proofLabel: '',
proofNote: ''
});
this.selectedMerchantId.set('');
this.croppedFile.set(null);
this.croppedPreview.set(null);
this.showCropper.set(false);
this.loadExpenses();
},
error: (error) => {
this.saving.set(false);
this.toast.error(error.error?.message ?? 'Nie udało się dodać wydatku.');
}
});
}
startEdit(item: Expense) {
this.editingExpenseId.set(item.id);
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 ?? ''
});
}
cancelEdit() {
this.editingExpenseId.set(null);
this.expenseForm.reset({
title: '',
amount: 0,
expenseDate: today,
categoryId: '',
merchant: '',
paymentMethod: '',
description: '',
proofType: 'RECEIPT',
proofLabel: '',
proofNote: ''
});
}
removeExpense(item: Expense) {
this.expensesService.delete(item.id).subscribe({
next: () => {
this.toast.success('Wydatek został usunięty.');
this.loadExpenses();
},
error: (error) => this.toast.error(error.error?.message ?? 'Nie udało się usunąć wydatku.')
});
}
openProof(proof: Proof) {
this.proofPreview.set(proof);
}
closeMerchantModal() {
this.merchantModalOpen.set(false);
}
closeProofPreview() {
this.proofPreview.set(null);
}
}