zmiany
This commit is contained in:
182
web/src/app/features/recurring/recurring.component.ts
Normal file
182
web/src/app/features/recurring/recurring.component.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common';
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { FormArray, FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { CategoriesService } from '../../core/services/categories.service';
|
||||
import { RecurringExpensesService } from '../../core/services/recurring-expenses.service';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
import { UiService } from '../../core/services/ui.service';
|
||||
import type { RecurringExpense } from '../../shared/models';
|
||||
|
||||
const today = () => new Date().toISOString().slice(0, 10);
|
||||
|
||||
@Component({
|
||||
selector: 'app-recurring',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, 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('nav.recurring') }}</h2>
|
||||
<div class="text-secondary">{{ ui.t('recurring.subtitle') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row row-cards">
|
||||
<div class="col-lg-5">
|
||||
<div class="card overflow-hidden">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h3 class="card-title">{{ editingId() ? ui.t('recurring.edit') : ui.t('recurring.new') }}</h3>
|
||||
<div class="btn-list">
|
||||
@if (editingId()) { <button class="btn btn-outline-secondary btn-sm" type="button" (click)="cancelEdit()">{{ ui.t('action.cancelEdit') }}</button> }
|
||||
<button class="btn btn-outline-primary btn-sm" type="button" (click)="runNow()">{{ ui.t('recurring.runNow') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form [formGroup]="form" (ngSubmit)="save()" class="d-grid gap-3">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-8"><label class="form-label">{{ ui.t('expenses.field.title') }}</label><input class="form-control" formControlName="title" /></div>
|
||||
<div class="col-md-4"><label class="form-label">{{ ui.t('expenses.field.amount') }}</label><input class="form-control" type="number" step="0.01" formControlName="amount" /></div>
|
||||
<div class="col-md-6"><label class="form-label">{{ ui.t('expenses.field.category') }}</label><select class="form-select" formControlName="categoryId"><option value="">{{ ui.t('common.select') }}</option>@for (category of categories(); track category.id) { <option [value]="category.id">{{ category.name }}</option> }</select></div>
|
||||
<div class="col-md-6"><label class="form-label">{{ ui.t('expenses.field.status') }}</label><select class="form-select" formControlName="defaultStatus"><option value="DRAFT">{{ ui.t('status.draft') }}</option><option value="PENDING">{{ ui.t('status.pending') }}</option></select></div>
|
||||
<div class="col-md-6"><label class="form-label">{{ ui.t('recurring.frequency') }}</label><select class="form-select" formControlName="frequency"><option value="WEEKLY">{{ ui.t('recurring.weekly') }}</option><option value="MONTHLY">{{ ui.t('recurring.monthly') }}</option><option value="YEARLY">{{ ui.t('recurring.yearly') }}</option></select></div>
|
||||
<div class="col-md-6"><label class="form-label">{{ ui.t('recurring.interval') }}</label><input class="form-control" type="number" min="1" formControlName="intervalValue" /></div>
|
||||
<div class="col-md-4"><label class="form-label">{{ ui.t('recurring.startDate') }}</label><input class="form-control" type="date" formControlName="startDate" /></div>
|
||||
<div class="col-md-4"><label class="form-label">{{ ui.t('recurring.nextRunDate') }}</label><input class="form-control" type="date" formControlName="nextRunDate" /></div>
|
||||
<div class="col-md-4"><label class="form-label">{{ ui.t('recurring.endDate') }}</label><input class="form-control" type="date" formControlName="endDate" /></div>
|
||||
<div class="col-md-6"><label class="form-label">{{ ui.t('recurring.maxOccurrences') }}</label><input class="form-control" type="number" min="1" formControlName="maxOccurrences" /></div>
|
||||
<div class="col-md-6"><label class="form-label">{{ ui.t('expenses.field.merchantName') }}</label><input class="form-control" formControlName="merchant" /></div>
|
||||
<div class="col-md-6"><label class="form-label">{{ ui.t('expenses.field.tags') }}</label><input class="form-control" formControlName="tagsText" /></div>
|
||||
<div class="col-12"><label class="form-label">{{ ui.t('expenses.field.description') }}</label><textarea class="form-control" rows="2" formControlName="description"></textarea></div>
|
||||
</div>
|
||||
|
||||
<div formArrayName="customFields" class="d-grid gap-2">
|
||||
<div class="d-flex justify-content-between align-items-center"><div class="form-label mb-0">{{ ui.t('expenses.field.customFields') }}</div><button class="btn btn-outline-secondary btn-sm" type="button" (click)="addCustomField()">{{ ui.t('action.add') }}</button></div>
|
||||
@for (group of customFields.controls; track $index) {
|
||||
<div [formGroupName]="$index" class="row g-2">
|
||||
<div class="col-sm-5"><input class="form-control" formControlName="key" [placeholder]="ui.t('expenses.field.customKey')" /></div>
|
||||
<div class="col-sm-5"><input class="form-control" formControlName="value" [placeholder]="ui.t('expenses.field.customValue')" /></div>
|
||||
<div class="col-sm-2"><button class="btn btn-outline-danger w-100" type="button" (click)="removeCustomField($index)">{{ ui.t('action.delete') }}</button></div>
|
||||
</div>
|
||||
} @empty {
|
||||
<div class="text-secondary small">{{ ui.t('expenses.noCustomFields') }}</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<label class="form-check"><input class="form-check-input" type="checkbox" formControlName="isActive" /><span class="form-check-label">{{ ui.t('common.active') }}</span></label>
|
||||
<button class="btn btn-success" [disabled]="form.invalid">{{ ui.t('action.save') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-7">
|
||||
<div class="card overflow-hidden">
|
||||
<div class="card-header"><h3 class="card-title">{{ ui.t('recurring.title') }}</h3></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter card-table mb-0">
|
||||
<thead><tr><th>{{ ui.t('table.title') }}</th><th>{{ ui.t('recurring.frequency') }}</th><th>{{ ui.t('recurring.nextRunDate') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
@for (item of items(); track item.id) {
|
||||
<tr>
|
||||
<td>
|
||||
<div class="fw-semibold">{{ item.title }}</div>
|
||||
<div class="text-secondary small">{{ item.category.name }} · {{ item.merchant || ui.t('expenses.noMerchant') }}</div>
|
||||
<div class="text-secondary small">{{ ui.t('recurring.generatedCount') }}: {{ item.generatedCount }} · {{ ui.t('recurring.endDate') }}: {{ item.endDate || ui.t('common.none') }}</div>
|
||||
<div class="mt-1 d-flex gap-1 flex-wrap">@for (tag of item.tags; track tag) { <span class="badge text-bg-secondary">#{{ tag }}</span> }</div>
|
||||
</td>
|
||||
<td>{{ ui.t('recurring.' + item.frequency.toLowerCase()) }}</td>
|
||||
<td>{{ item.nextRunDate | date:'yyyy-MM-dd' }}</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-nowrap"><button class="btn btn-sm btn-outline-primary" type="button" (click)="edit(item)">{{ ui.t('action.edit') }}</button><button class="btn btn-sm btn-outline-danger" type="button" (click)="remove(item)">{{ ui.t('action.delete') }}</button></div></td>
|
||||
</tr>
|
||||
} @empty { <tr><td colspan="5" class="text-secondary">{{ ui.t('common.noData') }}</td></tr> }
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class RecurringComponent implements OnInit {
|
||||
readonly ui = inject(UiService);
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly categoriesService = inject(CategoriesService);
|
||||
private readonly recurringService = inject(RecurringExpensesService);
|
||||
private readonly toast = inject(ToastService);
|
||||
|
||||
readonly categories = this.categoriesService.items;
|
||||
readonly items = signal<RecurringExpense[]>([]);
|
||||
readonly editingId = signal<string | null>(null);
|
||||
|
||||
readonly form = this.fb.nonNullable.group({
|
||||
title: ['', [Validators.required, Validators.minLength(2)]],
|
||||
amount: [0, [Validators.required, Validators.min(0.01)]],
|
||||
categoryId: ['', Validators.required],
|
||||
defaultStatus: ['PENDING' as RecurringExpense['defaultStatus']],
|
||||
frequency: ['MONTHLY' as RecurringExpense['frequency']],
|
||||
intervalValue: [1, [Validators.required, Validators.min(1)]],
|
||||
startDate: [today(), Validators.required],
|
||||
nextRunDate: [today(), Validators.required],
|
||||
endDate: [''],
|
||||
maxOccurrences: [null as number | null],
|
||||
merchant: [''],
|
||||
description: [''],
|
||||
tagsText: [''],
|
||||
isActive: [true],
|
||||
customFields: this.fb.array([])
|
||||
});
|
||||
|
||||
get customFields() { return this.form.controls.customFields as FormArray; }
|
||||
|
||||
ngOnInit() {
|
||||
this.categoriesService.ensureLoaded(true);
|
||||
this.load();
|
||||
}
|
||||
|
||||
load() {
|
||||
this.recurringService.list().subscribe({ next: (response) => this.items.set(response.items) });
|
||||
}
|
||||
|
||||
addCustomField(key = '', value = '') { this.customFields.push(this.fb.group({ key: [key], value: [value] })); }
|
||||
removeCustomField(index: number) { this.customFields.removeAt(index); }
|
||||
|
||||
save() {
|
||||
if (this.form.invalid) return;
|
||||
const raw = this.form.getRawValue();
|
||||
const customEntries = this.customFields.getRawValue().map((item: { key: string; value: string }) => [item.key, item.value] as [string, string]).filter(([key, value]) => Boolean(key && value));
|
||||
const payload = {
|
||||
...raw,
|
||||
categoryId: raw.categoryId,
|
||||
endDate: raw.endDate || null,
|
||||
maxOccurrences: raw.maxOccurrences ? Number(raw.maxOccurrences) : null,
|
||||
tags: raw.tagsText.split(',').map((item) => item.trim()).filter(Boolean),
|
||||
customFields: Object.fromEntries(customEntries)
|
||||
};
|
||||
const request = this.editingId() ? this.recurringService.update(this.editingId()!, payload) : this.recurringService.create(payload);
|
||||
request.subscribe({ next: () => { this.toast.success(this.ui.t('recurring.saved')); this.cancelEdit(); this.load(); }, error: (error) => this.toast.error(error.error?.message ?? this.ui.t('recurring.saveError')) });
|
||||
}
|
||||
|
||||
edit(item: RecurringExpense) {
|
||||
this.editingId.set(item.id);
|
||||
this.customFields.clear();
|
||||
Object.entries(item.customFields || {}).forEach(([key, value]) => this.addCustomField(key, value));
|
||||
this.form.reset({ title: item.title, amount: item.amount, categoryId: item.category.id, defaultStatus: item.defaultStatus, frequency: item.frequency, intervalValue: item.intervalValue, startDate: item.startDate, nextRunDate: item.nextRunDate, endDate: item.endDate || '', maxOccurrences: item.maxOccurrences, merchant: item.merchant || '', description: item.description || '', tagsText: item.tags.join(', '), isActive: item.isActive, customFields: [] as never[] });
|
||||
}
|
||||
|
||||
cancelEdit() {
|
||||
this.editingId.set(null);
|
||||
this.customFields.clear();
|
||||
this.form.reset({ title: '', amount: 0, categoryId: '', defaultStatus: 'PENDING', frequency: 'MONTHLY', intervalValue: 1, startDate: today(), nextRunDate: today(), endDate: '', maxOccurrences: null, merchant: '', description: '', tagsText: '', isActive: true, customFields: [] as never[] });
|
||||
}
|
||||
|
||||
remove(item: RecurringExpense) {
|
||||
this.recurringService.delete(item.id).subscribe({ next: () => { this.toast.success(this.ui.t('recurring.deleted')); this.load(); }, error: (error) => this.toast.error(error.error?.message ?? this.ui.t('recurring.deleteError')) });
|
||||
}
|
||||
|
||||
runNow() {
|
||||
this.recurringService.runNow().subscribe({ next: () => { this.toast.success(this.ui.t('recurring.ran')); this.load(); }, error: (error) => this.toast.error(error.error?.message ?? this.ui.t('toast.error')) });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user