zmiany
This commit is contained in:
@@ -3,10 +3,14 @@ import { adminGuard } from './core/guards/admin.guard';
|
||||
import { authGuard } from './core/guards/auth.guard';
|
||||
import { AdminComponent } from './features/admin/admin.component';
|
||||
import { LoginComponent } from './features/auth/login.component';
|
||||
import { BudgetsComponent } from './features/budgets/budgets.component';
|
||||
import { CashflowComponent } from './features/cashflow/cashflow.component';
|
||||
import { CategoriesComponent } from './features/categories/categories.component';
|
||||
import { DashboardComponent } from './features/dashboard/dashboard.component';
|
||||
import { ExpensesComponent } from './features/expenses/expenses.component';
|
||||
import { IntegrationsComponent } from './features/integrations/integrations.component';
|
||||
import { MerchantsComponent } from './features/merchants/merchants.component';
|
||||
import { RecurringComponent } from './features/recurring/recurring.component';
|
||||
import { ReportsComponent } from './features/reports/reports.component';
|
||||
import { StatsComponent } from './features/stats/stats.component';
|
||||
import { ShellComponent } from './layout/shell.component';
|
||||
@@ -21,9 +25,13 @@ export const routes: Routes = [
|
||||
{ path: '', component: DashboardComponent },
|
||||
{ path: 'expenses', component: ExpensesComponent },
|
||||
{ path: 'stats', component: StatsComponent },
|
||||
{ path: 'cashflow', component: CashflowComponent },
|
||||
{ path: 'budgets', component: BudgetsComponent },
|
||||
{ path: 'recurring', component: RecurringComponent },
|
||||
{ path: 'merchants', component: MerchantsComponent },
|
||||
{ path: 'reports', component: ReportsComponent },
|
||||
{ path: 'categories', component: CategoriesComponent },
|
||||
{ path: 'integrations', component: IntegrationsComponent },
|
||||
{ path: 'admin', component: AdminComponent, canActivate: [adminGuard] }
|
||||
]
|
||||
},
|
||||
|
||||
27
web/src/app/core/services/budgets.service.ts
Normal file
27
web/src/app/core/services/budgets.service.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import type { Budget, BudgetListResponse } from '../../shared/models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class BudgetsService {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
list(month?: string) {
|
||||
let params = new HttpParams();
|
||||
if (month) params = params.set('month', month);
|
||||
return this.http.get<BudgetListResponse>(`${environment.apiBaseUrl}/budgets`, { params });
|
||||
}
|
||||
|
||||
create(payload: { month: string; name?: string; amount: number; categoryId?: string | null; alertThresholds: number[]; isActive: boolean }) {
|
||||
return this.http.post<{ item: Budget }>(`${environment.apiBaseUrl}/budgets`, payload);
|
||||
}
|
||||
|
||||
update(id: string, payload: { month: string; name?: string; amount: number; categoryId?: string | null; alertThresholds: number[]; isActive: boolean }) {
|
||||
return this.http.put<{ item: Budget }>(`${environment.apiBaseUrl}/budgets/${id}`, payload);
|
||||
}
|
||||
|
||||
delete(id: string) {
|
||||
return this.http.delete<void>(`${environment.apiBaseUrl}/budgets/${id}`);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,41 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import type { Expense, Proof } from '../../shared/models';
|
||||
import type { DuplicateGroup, Expense, Proof } from '../../shared/models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ExpensesService {
|
||||
private readonly http = inject(HttpClient);
|
||||
list(filters: { startDate?: string; endDate?: string; categoryId?: string; search?: string } = {}) { let params = new HttpParams(); Object.entries(filters).forEach(([key, value]) => { if (value) params = params.set(key, value); }); return this.http.get<{ items: Expense[] }>(`${environment.apiBaseUrl}/expenses`, { params }); }
|
||||
create(formData: FormData) { return this.http.post<{ item: Expense }>(`${environment.apiBaseUrl}/expenses`, formData); }
|
||||
update(id: string, payload: Partial<Expense> & { categoryId: string }) { return this.http.put<{ item: Expense }>(`${environment.apiBaseUrl}/expenses/${id}`, payload); }
|
||||
delete(id: string) { return this.http.delete<void>(`${environment.apiBaseUrl}/expenses/${id}`); }
|
||||
addProof(id: string, formData: FormData) { return this.http.post<{ proof: Proof; expense: Expense }>(`${environment.apiBaseUrl}/expenses/${id}/proofs`, formData); }
|
||||
|
||||
list(filters: { startDate?: string; endDate?: string; categoryId?: string; search?: string; status?: string; tags?: string; duplicatesOnly?: boolean } = {}) {
|
||||
let params = new HttpParams();
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') params = params.set(key, String(value));
|
||||
});
|
||||
return this.http.get<{ items: Expense[] }>(`${environment.apiBaseUrl}/expenses`, { params });
|
||||
}
|
||||
|
||||
duplicates() {
|
||||
return this.http.get<{ items: DuplicateGroup[] }>(`${environment.apiBaseUrl}/expenses/duplicates`);
|
||||
}
|
||||
|
||||
create(formData: FormData) {
|
||||
return this.http.post<{ item: Expense; warnings?: string[] }>(`${environment.apiBaseUrl}/expenses`, formData);
|
||||
}
|
||||
|
||||
update(id: string, payload: Partial<Expense> & { categoryId: string }) {
|
||||
return this.http.put<{ item: Expense; warnings?: string[] }>(`${environment.apiBaseUrl}/expenses/${id}`, payload);
|
||||
}
|
||||
|
||||
reviewDuplicate(id: string, action: 'CONFIRM' | 'DISMISS' | 'REOPEN') {
|
||||
return this.http.post<{ item: Expense }>(`${environment.apiBaseUrl}/expenses/${id}/duplicate-review`, { action });
|
||||
}
|
||||
|
||||
delete(id: string) {
|
||||
return this.http.delete<void>(`${environment.apiBaseUrl}/expenses/${id}`);
|
||||
}
|
||||
|
||||
addProof(id: string, formData: FormData) {
|
||||
return this.http.post<{ proofs: Proof[]; expense: Expense }>(`${environment.apiBaseUrl}/expenses/${id}/proofs`, formData);
|
||||
}
|
||||
}
|
||||
|
||||
29
web/src/app/core/services/recurring-expenses.service.ts
Normal file
29
web/src/app/core/services/recurring-expenses.service.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import type { RecurringExpense } from '../../shared/models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class RecurringExpensesService {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
list() {
|
||||
return this.http.get<{ items: RecurringExpense[] }>(`${environment.apiBaseUrl}/recurring-expenses`);
|
||||
}
|
||||
|
||||
create(payload: Partial<RecurringExpense> & { categoryId: string }) {
|
||||
return this.http.post<{ item: RecurringExpense }>(`${environment.apiBaseUrl}/recurring-expenses`, payload);
|
||||
}
|
||||
|
||||
update(id: string, payload: Partial<RecurringExpense> & { categoryId: string }) {
|
||||
return this.http.put<{ item: RecurringExpense }>(`${environment.apiBaseUrl}/recurring-expenses/${id}`, payload);
|
||||
}
|
||||
|
||||
delete(id: string) {
|
||||
return this.http.delete<void>(`${environment.apiBaseUrl}/recurring-expenses/${id}`);
|
||||
}
|
||||
|
||||
runNow() {
|
||||
return this.http.post<{ message: string }>(`${environment.apiBaseUrl}/recurring-expenses/run`, {});
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import type { ReportPreferences, StatsResponse } from '../../shared/models';
|
||||
|
||||
@@ -25,4 +25,12 @@ export class ReportsService {
|
||||
send() {
|
||||
return this.http.post<{ message: string; sentTo: string }>(`${environment.apiBaseUrl}/reports/send`, {});
|
||||
}
|
||||
|
||||
export(filters: { format: 'csv' | 'json' | 'html' | 'pdf'; startDate?: string; endDate?: string; categoryIds?: string; status?: string; tag?: string }) {
|
||||
let params = new HttpParams().set('format', filters.format);
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (key !== 'format' && value) params = params.set(key, value);
|
||||
});
|
||||
return this.http.get(`${environment.apiBaseUrl}/reports/export`, { params, responseType: 'blob' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import type { Expense, ShoppingListExpenseItem, ShoppingListIntegrationSettings, ShoppingListRef, ShoppingListSummary } from '../../shared/models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ShoppingListIntegrationService {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
getSettings() {
|
||||
return this.http.get<{ item: ShoppingListIntegrationSettings }>(`${environment.apiBaseUrl}/integrations/shopping-list`);
|
||||
}
|
||||
|
||||
updateSettings(payload: { enabled: boolean; baseUrl?: string | null; apiToken?: string; authMode: 'bearer' | 'x-api-token' | 'both'; ownerId?: string | null; defaultListId?: string | null }) {
|
||||
return this.http.put<{ item: ShoppingListIntegrationSettings }>(`${environment.apiBaseUrl}/integrations/shopping-list`, payload);
|
||||
}
|
||||
|
||||
test() {
|
||||
return this.http.post<{ ok: boolean; payload: unknown }>(`${environment.apiBaseUrl}/integrations/shopping-list/test`, {});
|
||||
}
|
||||
|
||||
summary(filters: { start_date?: string; end_date?: string; list_id?: string; owner_id?: string } = {}) {
|
||||
let params = new HttpParams();
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value) params = params.set(key, value);
|
||||
});
|
||||
return this.http.get<ShoppingListSummary>(`${environment.apiBaseUrl}/integrations/shopping-list/summary`, { params });
|
||||
}
|
||||
|
||||
latest(filters: { start_date?: string; end_date?: string; list_id?: string; owner_id?: string; limit?: number } = {}) {
|
||||
let params = new HttpParams();
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') params = params.set(key, String(value));
|
||||
});
|
||||
return this.http.get<{ items?: ShoppingListExpenseItem[]; data?: ShoppingListExpenseItem[] }>(`${environment.apiBaseUrl}/integrations/shopping-list/latest`, { params });
|
||||
}
|
||||
|
||||
lists(filters: { owner_id?: string; limit?: number } = {}) {
|
||||
let params = new HttpParams();
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') params = params.set(key, String(value));
|
||||
});
|
||||
return this.http.get<{ items?: ShoppingListRef[]; data?: ShoppingListRef[] }>(`${environment.apiBaseUrl}/integrations/shopping-list/lists`, { params });
|
||||
}
|
||||
|
||||
listExpenses(id: string | number, limit = 50) {
|
||||
const params = new HttpParams().set('limit', String(limit));
|
||||
return this.http.get<{ items?: ShoppingListExpenseItem[]; data?: ShoppingListExpenseItem[] }>(`${environment.apiBaseUrl}/integrations/shopping-list/lists/${id}/expenses`, { params });
|
||||
}
|
||||
|
||||
importList(payload: {
|
||||
listId: string | number;
|
||||
listTitle?: string | null;
|
||||
listCreatedAt?: string | null;
|
||||
categoryId: string;
|
||||
status: 'DRAFT' | 'PENDING';
|
||||
merchant?: string | null;
|
||||
title?: string | null;
|
||||
description?: string | null;
|
||||
expenseDate?: string | null;
|
||||
tags?: string[];
|
||||
}) {
|
||||
return this.http.post<{ item: Expense; warnings?: string[] }>(`${environment.apiBaseUrl}/integrations/shopping-list/import-list`, payload);
|
||||
}
|
||||
|
||||
importItem(payload: {
|
||||
expenseId?: string | number | null;
|
||||
listId?: string | number | null;
|
||||
listTitle?: string | null;
|
||||
categoryId: string;
|
||||
status: 'DRAFT' | 'PENDING';
|
||||
title: string;
|
||||
amount: number;
|
||||
expenseDate: string;
|
||||
merchant?: string | null;
|
||||
ownerName?: string | null;
|
||||
description?: string | null;
|
||||
tags?: string[];
|
||||
}) {
|
||||
return this.http.post<{ item: Expense; warnings?: string[] }>(`${environment.apiBaseUrl}/integrations/shopping-list/import-item`, payload);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,21 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import type { StatsResponse } from '../../shared/models';
|
||||
import type { CashflowResponse, StatsResponse } from '../../shared/models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class StatsService {
|
||||
private readonly http = inject(HttpClient);
|
||||
overview(filters: { startDate?: string; endDate?: string; categoryIds?: string; bucket?: 'month' | 'quarter' | 'year' }) { let params = new HttpParams(); Object.entries(filters).forEach(([key, value]) => { if (value) params = params.set(key, value); }); return this.http.get<StatsResponse>(`${environment.apiBaseUrl}/statistics/overview`, { params }); }
|
||||
|
||||
overview(filters: { startDate?: string; endDate?: string; categoryIds?: string; bucket?: 'month' | 'quarter' | 'year'; tag?: string; status?: string }) {
|
||||
let params = new HttpParams();
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value) params = params.set(key, value);
|
||||
});
|
||||
return this.http.get<StatsResponse>(`${environment.apiBaseUrl}/statistics/overview`, { params });
|
||||
}
|
||||
|
||||
cashflow() {
|
||||
return this.http.get<CashflowResponse>(`${environment.apiBaseUrl}/statistics/cashflow`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ const translations: Record<UiLanguage, Record<string, string>> = {
|
||||
'action.unblock': 'Odblokuj',
|
||||
'action.setUser': 'Ustaw USER',
|
||||
'action.setAdmin': 'Ustaw ADMIN',
|
||||
'action.import': 'Importuj',
|
||||
|
||||
'theme.label': 'Motyw',
|
||||
'theme.dark': 'Ciemny',
|
||||
@@ -227,6 +228,87 @@ const translations: Record<UiLanguage, Record<string, string>> = {
|
||||
'admin.statusUpdated': 'Status konta został zaktualizowany.',
|
||||
'admin.statusError': 'Nie udało się zmienić statusu.',
|
||||
|
||||
|
||||
'nav.cashflow': 'Cashflow',
|
||||
'nav.budgets': 'Budżety',
|
||||
'nav.recurring': 'Cykliczne',
|
||||
|
||||
'action.saveDraft': 'Zapisz szkic',
|
||||
|
||||
'status.draft': 'Szkic',
|
||||
'status.pending': 'Oczekuje',
|
||||
'status.approved': 'Zatwierdzony',
|
||||
'status.rejected': 'Odrzucony',
|
||||
|
||||
'dashboard.cashflowHint': 'Przegląd kosztów, budżetów, duplikatów i przyszłych obciążeń.',
|
||||
'dashboard.budgetUsage': 'Wykorzystanie budżetu',
|
||||
|
||||
'expenses.field.status': 'Status',
|
||||
'expenses.field.tags': 'Tagi',
|
||||
'expenses.field.customFields': 'Własne pola',
|
||||
'expenses.field.customKey': 'Nazwa pola',
|
||||
'expenses.field.customValue': 'Wartość',
|
||||
'expenses.tagPlaceholder': 'np. projekt-x, marketing',
|
||||
'expenses.noCustomFields': 'Brak własnych pól.',
|
||||
'expenses.attachmentsSelected': 'Wybrane załączniki',
|
||||
'expenses.duplicatesTitle': 'Wykryte potencjalne duplikaty',
|
||||
'expenses.potentialMatches': 'podobnych pozycji',
|
||||
'expenses.duplicatesOnly': 'Pokaż tylko potencjalne duplikaty',
|
||||
'expenses.duplicate': 'Duplikat',
|
||||
'expenses.draftSaved': 'Szkic wydatku został zapisany.',
|
||||
|
||||
'stats.tags': 'Analiza tagów',
|
||||
|
||||
'reports.exportTitle': 'Eksport raportów',
|
||||
'reports.exportError': 'Nie udało się wyeksportować raportu.',
|
||||
|
||||
'budget.title': 'Budżety miesięczne',
|
||||
'budget.subtitle': 'Limity miesięczne ogólne i per kategoria z alertami zużycia.',
|
||||
'budget.new': 'Nowy budżet',
|
||||
'budget.edit': 'Edytuj budżet',
|
||||
'budget.month': 'Miesiąc',
|
||||
'budget.name': 'Nazwa',
|
||||
'budget.amount': 'Kwota budżetu',
|
||||
'budget.category': 'Kategoria',
|
||||
'budget.overall': 'Budżet ogólny',
|
||||
'budget.thresholds': 'Progi alertów',
|
||||
'budget.total': 'Łączny budżet',
|
||||
'budget.spent': 'Wydano',
|
||||
'budget.usage': 'Zużycie',
|
||||
'budget.alerts': 'Alerty budżetowe',
|
||||
'budget.saved': 'Budżet został zapisany.',
|
||||
'budget.saveError': 'Nie udało się zapisać budżetu.',
|
||||
'budget.deleted': 'Budżet został usunięty.',
|
||||
'budget.deleteError': 'Nie udało się usunąć budżetu.',
|
||||
|
||||
'recurring.title': 'Cykliczne wydatki',
|
||||
'recurring.subtitle': 'Szablony kosztów generowanych automatycznie w czasie.',
|
||||
'recurring.new': 'Nowy harmonogram',
|
||||
'recurring.edit': 'Edytuj harmonogram',
|
||||
'recurring.frequency': 'Częstotliwość',
|
||||
'recurring.weekly': 'Co tydzień',
|
||||
'recurring.monthly': 'Co miesiąc',
|
||||
'recurring.yearly': 'Co rok',
|
||||
'recurring.interval': 'Interwał',
|
||||
'recurring.startDate': 'Data startu',
|
||||
'recurring.nextRunDate': 'Następne utworzenie',
|
||||
'recurring.runNow': 'Uruchom teraz',
|
||||
'recurring.saved': 'Harmonogram został zapisany.',
|
||||
'recurring.saveError': 'Nie udało się zapisać harmonogramu.',
|
||||
'recurring.deleted': 'Harmonogram został usunięty.',
|
||||
'recurring.deleteError': 'Nie udało się usunąć harmonogramu.',
|
||||
'recurring.ran': 'Cykliczne wydatki zostały przetworzone.',
|
||||
'recurring.badge': 'Cykliczny',
|
||||
|
||||
'cashflow.subtitle': 'Rzeczywiste koszty, budżet, prognoza i najbliższe cykliczne obciążenia.',
|
||||
'cashflow.actual': 'Rzeczywiste koszty',
|
||||
'cashflow.budget': 'Budżet',
|
||||
'cashflow.forecast': 'Prognoza miesiąca',
|
||||
'cashflow.pending': 'Do akceptacji',
|
||||
'cashflow.duplicates': 'Duplikaty',
|
||||
'cashflow.trend': 'Trend cashflow',
|
||||
'cashflow.statusSummary': 'Statusy wydatków',
|
||||
'cashflow.upcomingRecurring': 'Nadchodzące cykliczne',
|
||||
'common.none': 'Brak',
|
||||
'common.select': 'Wybierz',
|
||||
'common.noData': 'Brak danych.',
|
||||
@@ -237,12 +319,67 @@ const translations: Record<UiLanguage, Record<string, string>> = {
|
||||
'common.blocked': 'Zablokowany',
|
||||
'common.selected': 'OK',
|
||||
|
||||
'nav.integrations': 'Integracje',
|
||||
|
||||
'action.testConnection': 'Test połączenia',
|
||||
'action.refresh': 'Odśwież',
|
||||
|
||||
'expenses.duplicateDismissed': 'Duplikat został odrzucony.',
|
||||
'expenses.duplicateConfirmed': 'Wydatek został oznaczony jako potwierdzony duplikat.',
|
||||
'expenses.duplicateReopened': 'Sprawdzenie duplikatu zostało przywrócone.',
|
||||
'expenses.duplicateStatus.open': 'Do sprawdzenia',
|
||||
'expenses.duplicateStatus.confirmed': 'Potwierdzony',
|
||||
'expenses.duplicateStatus.dismissed': 'Odrzucony',
|
||||
|
||||
'recurring.endDate': 'Data końcowa',
|
||||
'recurring.maxOccurrences': 'Maks. liczba utworzeń',
|
||||
'recurring.generatedCount': 'Utworzono',
|
||||
|
||||
'integrations.title': 'Integracje',
|
||||
'integrations.subtitle': 'Połączenia per użytkownik z zewnętrznymi źródłami danych oraz import historyczny.',
|
||||
'integrations.shoppingList': 'Lista zakupów API',
|
||||
'integrations.enabled': 'Włącz integrację dla tego użytkownika',
|
||||
'integrations.baseUrl': 'URL API',
|
||||
'integrations.apiToken': 'Token API',
|
||||
'integrations.keepToken': 'Zostaw puste, aby zachować obecny token.',
|
||||
'integrations.authMode': 'Tryb autoryzacji',
|
||||
'integrations.ownerId': 'Domyślny owner ID',
|
||||
'integrations.defaultListId': 'Domyślne list ID',
|
||||
'integrations.history': 'Import historyczny',
|
||||
'integrations.period': 'Miesiąc / rok',
|
||||
'integrations.limit': 'Limit rekordów',
|
||||
'integrations.summary': 'Podsumowanie zewnętrzne',
|
||||
'integrations.latest': 'Wydatki z wybranego okresu',
|
||||
'integrations.lists': 'Listy zakupowe z okresu',
|
||||
'integrations.listExpenses': 'Pozycje wybranej listy',
|
||||
'integrations.importTitle': 'Import do lokalnych wydatków',
|
||||
'integrations.importSelectedList': 'Importuj wybraną listę jako 1 wydatek',
|
||||
'integrations.selectListHint': 'Wybierz listę po lewej, aby podejrzeć pozycje i zaimportować całą listę lub pojedyncze wydatki.',
|
||||
'integrations.selectedListSummary': 'Pozycje / suma',
|
||||
'integrations.tags': 'Tagi importu',
|
||||
'integrations.tagsHint': 'Oddzielaj tagi przecinkami.',
|
||||
'integrations.externalSpend': 'Suma zewnętrzna',
|
||||
'integrations.externalCount': 'Rekordy zewnętrzne',
|
||||
'integrations.notConfigured': 'Skonfiguruj integrację i zapisz ustawienia, aby pobrać dane.',
|
||||
'integrations.saveSuccess': 'Ustawienia integracji zostały zapisane.',
|
||||
'integrations.saveError': 'Nie udało się zapisać ustawień integracji.',
|
||||
'integrations.testSuccess': 'Połączenie z zewnętrznym API działa.',
|
||||
'integrations.testError': 'Nie udało się połączyć z zewnętrznym API.',
|
||||
'integrations.loadError': 'Nie udało się pobrać danych integracji.',
|
||||
'integrations.importListSuccess': 'Lista zakupowa została zaimportowana jako lokalny wydatek.',
|
||||
'integrations.importItemSuccess': 'Pozycja z listy zakupowej została zaimportowana.',
|
||||
'integrations.importError': 'Nie udało się zaimportować danych z list zakupowych.',
|
||||
|
||||
'dashboard.externalSpend': 'Zewnętrzna suma',
|
||||
'dashboard.externalRecords': 'Zewnętrzne rekordy',
|
||||
|
||||
'table.title': 'Tytuł',
|
||||
'table.merchant': 'Kontrahent',
|
||||
'table.date': 'Data',
|
||||
'table.amount': 'Kwota',
|
||||
'table.count': 'Liczba',
|
||||
'table.category': 'Kategoria',
|
||||
'table.actions': 'Akcje',
|
||||
|
||||
'toast.ready': 'Gotowe',
|
||||
'toast.error': 'Błąd',
|
||||
@@ -288,6 +425,7 @@ const translations: Record<UiLanguage, Record<string, string>> = {
|
||||
'action.unblock': 'Unblock',
|
||||
'action.setUser': 'Set USER',
|
||||
'action.setAdmin': 'Set ADMIN',
|
||||
'action.import': 'Import',
|
||||
|
||||
'theme.label': 'Theme',
|
||||
'theme.dark': 'Dark',
|
||||
@@ -471,6 +609,87 @@ const translations: Record<UiLanguage, Record<string, string>> = {
|
||||
'admin.statusUpdated': 'Account status updated successfully.',
|
||||
'admin.statusError': 'Failed to change the account status.',
|
||||
|
||||
|
||||
'nav.cashflow': 'Cashflow',
|
||||
'nav.budgets': 'Budgets',
|
||||
'nav.recurring': 'Recurring',
|
||||
|
||||
'action.saveDraft': 'Save draft',
|
||||
|
||||
'status.draft': 'Draft',
|
||||
'status.pending': 'Pending',
|
||||
'status.approved': 'Approved',
|
||||
'status.rejected': 'Rejected',
|
||||
|
||||
'dashboard.cashflowHint': 'Overview of spend, budgets, duplicates, and upcoming recurring charges.',
|
||||
'dashboard.budgetUsage': 'Budget usage',
|
||||
|
||||
'expenses.field.status': 'Status',
|
||||
'expenses.field.tags': 'Tags',
|
||||
'expenses.field.customFields': 'Custom fields',
|
||||
'expenses.field.customKey': 'Field name',
|
||||
'expenses.field.customValue': 'Value',
|
||||
'expenses.tagPlaceholder': 'e.g. project-x, marketing',
|
||||
'expenses.noCustomFields': 'No custom fields.',
|
||||
'expenses.attachmentsSelected': 'Selected attachments',
|
||||
'expenses.duplicatesTitle': 'Potential duplicates detected',
|
||||
'expenses.potentialMatches': 'similar entries',
|
||||
'expenses.duplicatesOnly': 'Show only potential duplicates',
|
||||
'expenses.duplicate': 'Duplicate',
|
||||
'expenses.draftSaved': 'Expense draft was saved.',
|
||||
|
||||
'stats.tags': 'Tag analysis',
|
||||
|
||||
'reports.exportTitle': 'Report export',
|
||||
'reports.exportError': 'Failed to export the report.',
|
||||
|
||||
'budget.title': 'Monthly budgets',
|
||||
'budget.subtitle': 'Monthly limits overall and per category with usage alerts.',
|
||||
'budget.new': 'New budget',
|
||||
'budget.edit': 'Edit budget',
|
||||
'budget.month': 'Month',
|
||||
'budget.name': 'Name',
|
||||
'budget.amount': 'Budget amount',
|
||||
'budget.category': 'Category',
|
||||
'budget.overall': 'Overall budget',
|
||||
'budget.thresholds': 'Alert thresholds',
|
||||
'budget.total': 'Total budget',
|
||||
'budget.spent': 'Spent',
|
||||
'budget.usage': 'Usage',
|
||||
'budget.alerts': 'Budget alerts',
|
||||
'budget.saved': 'Budget was saved.',
|
||||
'budget.saveError': 'Failed to save budget.',
|
||||
'budget.deleted': 'Budget was deleted.',
|
||||
'budget.deleteError': 'Failed to delete budget.',
|
||||
|
||||
'recurring.title': 'Recurring expenses',
|
||||
'recurring.subtitle': 'Templates for costs generated automatically over time.',
|
||||
'recurring.new': 'New schedule',
|
||||
'recurring.edit': 'Edit schedule',
|
||||
'recurring.frequency': 'Frequency',
|
||||
'recurring.weekly': 'Weekly',
|
||||
'recurring.monthly': 'Monthly',
|
||||
'recurring.yearly': 'Yearly',
|
||||
'recurring.interval': 'Interval',
|
||||
'recurring.startDate': 'Start date',
|
||||
'recurring.nextRunDate': 'Next run date',
|
||||
'recurring.runNow': 'Run now',
|
||||
'recurring.saved': 'Recurring schedule was saved.',
|
||||
'recurring.saveError': 'Failed to save recurring schedule.',
|
||||
'recurring.deleted': 'Recurring schedule was deleted.',
|
||||
'recurring.deleteError': 'Failed to delete recurring schedule.',
|
||||
'recurring.ran': 'Recurring expenses were processed.',
|
||||
'recurring.badge': 'Recurring',
|
||||
|
||||
'cashflow.subtitle': 'Actual spend, budget, forecast, and upcoming recurring charges.',
|
||||
'cashflow.actual': 'Actual spend',
|
||||
'cashflow.budget': 'Budget',
|
||||
'cashflow.forecast': 'Month forecast',
|
||||
'cashflow.pending': 'Pending approval',
|
||||
'cashflow.duplicates': 'Duplicates',
|
||||
'cashflow.trend': 'Cashflow trend',
|
||||
'cashflow.statusSummary': 'Expense statuses',
|
||||
'cashflow.upcomingRecurring': 'Upcoming recurring',
|
||||
'common.none': 'None',
|
||||
'common.select': 'Select',
|
||||
'common.noData': 'No data.',
|
||||
@@ -481,12 +700,67 @@ const translations: Record<UiLanguage, Record<string, string>> = {
|
||||
'common.blocked': 'Blocked',
|
||||
'common.selected': 'OK',
|
||||
|
||||
'nav.integrations': 'Integrations',
|
||||
|
||||
'action.testConnection': 'Test connection',
|
||||
'action.refresh': 'Refresh',
|
||||
|
||||
'expenses.duplicateDismissed': 'Duplicate flag was dismissed.',
|
||||
'expenses.duplicateConfirmed': 'Expense was marked as a confirmed duplicate.',
|
||||
'expenses.duplicateReopened': 'Duplicate review was reopened.',
|
||||
'expenses.duplicateStatus.open': 'Needs review',
|
||||
'expenses.duplicateStatus.confirmed': 'Confirmed',
|
||||
'expenses.duplicateStatus.dismissed': 'Dismissed',
|
||||
|
||||
'recurring.endDate': 'End date',
|
||||
'recurring.maxOccurrences': 'Max occurrences',
|
||||
'recurring.generatedCount': 'Generated',
|
||||
|
||||
'integrations.title': 'Integrations',
|
||||
'integrations.subtitle': 'Per-user connections to external data sources with historical backfill.',
|
||||
'integrations.shoppingList': 'Shopping list API',
|
||||
'integrations.enabled': 'Enable integration for this user',
|
||||
'integrations.baseUrl': 'API URL',
|
||||
'integrations.apiToken': 'API token',
|
||||
'integrations.keepToken': 'Leave blank to keep the current token.',
|
||||
'integrations.authMode': 'Authorization mode',
|
||||
'integrations.ownerId': 'Default owner ID',
|
||||
'integrations.defaultListId': 'Default list ID',
|
||||
'integrations.history': 'Historical import',
|
||||
'integrations.period': 'Month / year',
|
||||
'integrations.limit': 'Record limit',
|
||||
'integrations.summary': 'External summary',
|
||||
'integrations.latest': 'Expenses for selected period',
|
||||
'integrations.lists': 'Shopping lists for period',
|
||||
'integrations.listExpenses': 'Entries for selected list',
|
||||
'integrations.importTitle': 'Import into local expenses',
|
||||
'integrations.importSelectedList': 'Import selected list as 1 expense',
|
||||
'integrations.selectListHint': 'Select a list on the left to preview entries and import the whole list or individual expenses.',
|
||||
'integrations.selectedListSummary': 'Entries / total',
|
||||
'integrations.tags': 'Import tags',
|
||||
'integrations.tagsHint': 'Separate tags with commas.',
|
||||
'integrations.externalSpend': 'External spend',
|
||||
'integrations.externalCount': 'External records',
|
||||
'integrations.notConfigured': 'Configure the integration and save settings to load data.',
|
||||
'integrations.saveSuccess': 'Integration settings were saved.',
|
||||
'integrations.saveError': 'Failed to save integration settings.',
|
||||
'integrations.testSuccess': 'Connection to the external API works.',
|
||||
'integrations.testError': 'Failed to connect to the external API.',
|
||||
'integrations.loadError': 'Failed to load integration data.',
|
||||
'integrations.importListSuccess': 'The shopping list was imported as a local expense.',
|
||||
'integrations.importItemSuccess': 'The shopping list entry was imported.',
|
||||
'integrations.importError': 'Failed to import data from the shopping list API.',
|
||||
|
||||
'dashboard.externalSpend': 'External spend',
|
||||
'dashboard.externalRecords': 'External records',
|
||||
|
||||
'table.title': 'Title',
|
||||
'table.merchant': 'Merchant',
|
||||
'table.date': 'Date',
|
||||
'table.amount': 'Amount',
|
||||
'table.count': 'Count',
|
||||
'table.category': 'Category',
|
||||
'table.actions': 'Actions',
|
||||
|
||||
'toast.ready': 'Done',
|
||||
'toast.error': 'Error',
|
||||
|
||||
130
web/src/app/features/budgets/budgets.component.ts
Normal file
130
web/src/app/features/budgets/budgets.component.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { CommonModule, CurrencyPipe } from '@angular/common';
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { BudgetsService } from '../../core/services/budgets.service';
|
||||
import { CategoriesService } from '../../core/services/categories.service';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
import { UiService } from '../../core/services/ui.service';
|
||||
import type { Budget } from '../../shared/models';
|
||||
|
||||
const currentMonth = () => {
|
||||
const date = new Date();
|
||||
return `${date.getFullYear()}-${`${date.getMonth() + 1}`.padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-budgets',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, CurrencyPipe],
|
||||
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.budgets') }}</h2>
|
||||
<div class="text-secondary">{{ ui.t('budget.subtitle') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row row-cards">
|
||||
<div class="col-lg-4">
|
||||
<div class="card overflow-hidden">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h3 class="card-title">{{ editingId() ? ui.t('budget.edit') : ui.t('budget.new') }}</h3>
|
||||
@if (editingId()) {
|
||||
<button class="btn btn-outline-secondary btn-sm" type="button" (click)="cancelEdit()">{{ ui.t('action.cancelEdit') }}</button>
|
||||
}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form [formGroup]="form" (ngSubmit)="save()" class="d-grid gap-3">
|
||||
<div><label class="form-label">{{ ui.t('budget.month') }}</label><input class="form-control" type="month" formControlName="month" /></div>
|
||||
<div><label class="form-label">{{ ui.t('budget.name') }}</label><input class="form-control" formControlName="name" /></div>
|
||||
<div><label class="form-label">{{ ui.t('budget.amount') }}</label><input class="form-control" type="number" step="0.01" formControlName="amount" /></div>
|
||||
<div>
|
||||
<label class="form-label">{{ ui.t('budget.category') }}</label>
|
||||
<select class="form-select" formControlName="categoryId">
|
||||
<option value="">{{ ui.t('budget.overall') }}</option>
|
||||
@for (category of categories(); track category.id) { <option [value]="category.id">{{ category.name }}</option> }
|
||||
</select>
|
||||
</div>
|
||||
<div><label class="form-label">{{ ui.t('budget.thresholds') }}</label><input class="form-control" formControlName="thresholdsText" /></div>
|
||||
<label class="form-check mb-0"><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-8">
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-sm-4"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('budget.total') }}</div><div class="display-6">{{ summary()?.totalBudget || 0 | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
|
||||
<div class="col-sm-4"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('budget.spent') }}</div><div class="display-6">{{ summary()?.totalSpent || 0 | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
|
||||
<div class="col-sm-4"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('budget.alerts') }}</div><div class="display-6">{{ summary()?.alerts?.length || 0 }}</div></div></div></div>
|
||||
</div>
|
||||
|
||||
@if (summary()?.alerts?.length) {
|
||||
<div class="alert alert-warning">
|
||||
<div class="fw-semibold mb-2">{{ ui.t('budget.alerts') }}</div>
|
||||
<div class="d-grid gap-1">@for (alert of summary()!.alerts; track alert.budgetId) { <div>{{ alert.message }}</div> }</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="card overflow-hidden">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h3 class="card-title">{{ ui.t('budget.title') }}</h3>
|
||||
<input class="form-control form-control-sm" style="max-width: 10rem" type="month" [value]="selectedMonth()" (change)="changeMonth($any($event.target).value)" />
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter card-table mb-0">
|
||||
<thead><tr><th>{{ ui.t('budget.name') }}</th><th>{{ ui.t('table.category') }}</th><th>{{ ui.t('budget.usage') }}</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.name || item.category?.name || ui.t('budget.overall') }}</div><div class="progress progress-sm mt-2"><div class="progress-bar" [style.width.%]="item.usagePercent > 100 ? 100 : item.usagePercent"></div></div></td>
|
||||
<td>{{ item.category?.name || ui.t('budget.overall') }}</td>
|
||||
<td><span class="badge" [ngClass]="item.alertLevel ? 'text-bg-warning' : 'text-bg-success'">{{ item.usagePercent }}%</span></td>
|
||||
<td class="text-end"><div>{{ item.spent | currency:'PLN':'symbol':'1.2-2' }}</div><div class="text-secondary small">/ {{ item.amount | currency:'PLN':'symbol':'1.2-2' }}</div></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 BudgetsComponent implements OnInit {
|
||||
readonly ui = inject(UiService);
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly categoriesService = inject(CategoriesService);
|
||||
private readonly budgetsService = inject(BudgetsService);
|
||||
private readonly toast = inject(ToastService);
|
||||
|
||||
readonly categories = this.categoriesService.items;
|
||||
readonly items = signal<Budget[]>([]);
|
||||
readonly summary = signal<{ totalBudget: number; totalSpent: number; alerts: Array<{ budgetId: string; message: string; usagePercent: number; level: number }> } | null>(null);
|
||||
readonly selectedMonth = signal(currentMonth());
|
||||
readonly editingId = signal<string | null>(null);
|
||||
|
||||
readonly form = this.fb.nonNullable.group({ month: [currentMonth(), Validators.required], name: [''], amount: [0, [Validators.required, Validators.min(0.01)]], categoryId: [''], thresholdsText: ['80,100'], isActive: [true] });
|
||||
|
||||
ngOnInit() { this.categoriesService.ensureLoaded(true); this.load(); }
|
||||
|
||||
changeMonth(month: string) { this.selectedMonth.set(month || currentMonth()); this.load(); }
|
||||
|
||||
load() { this.budgetsService.list(this.selectedMonth()).subscribe({ next: (response) => { this.items.set(response.items); this.summary.set(response.summary); } }); }
|
||||
|
||||
save() {
|
||||
if (this.form.invalid) return;
|
||||
const raw = this.form.getRawValue();
|
||||
const payload = { month: raw.month, name: raw.name || undefined, amount: raw.amount, categoryId: raw.categoryId || null, alertThresholds: raw.thresholdsText.split(',').map((item) => Number(item.trim())).filter((item) => Number.isFinite(item) && item > 0), isActive: raw.isActive };
|
||||
const request = this.editingId() ? this.budgetsService.update(this.editingId()!, payload) : this.budgetsService.create(payload);
|
||||
request.subscribe({ next: () => { this.toast.success(this.ui.t('budget.saved')); this.cancelEdit(); this.load(); }, error: (error) => this.toast.error(error.error?.message ?? this.ui.t('budget.saveError')) });
|
||||
}
|
||||
|
||||
edit(item: Budget) { this.editingId.set(item.id); this.form.reset({ month: item.month, name: item.name || '', amount: item.amount, categoryId: item.category?.id || '', thresholdsText: (item.alertThresholds || [80, 100]).join(','), isActive: item.isActive }); }
|
||||
cancelEdit() { this.editingId.set(null); this.form.reset({ month: this.selectedMonth(), name: '', amount: 0, categoryId: '', thresholdsText: '80,100', isActive: true }); }
|
||||
remove(item: Budget) { this.budgetsService.delete(item.id).subscribe({ next: () => { this.toast.success(this.ui.t('budget.deleted')); this.load(); }, error: (error) => this.toast.error(error.error?.message ?? this.ui.t('budget.deleteError')) }); }
|
||||
}
|
||||
87
web/src/app/features/cashflow/cashflow.component.ts
Normal file
87
web/src/app/features/cashflow/cashflow.component.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common';
|
||||
import { AfterViewChecked, Component, OnDestroy, OnInit, inject, signal } from '@angular/core';
|
||||
import { Chart, LineController, LineElement, PointElement, CategoryScale, LinearScale, Tooltip, Legend } from 'chart.js';
|
||||
import { StatsService } from '../../core/services/stats.service';
|
||||
import { UiService } from '../../core/services/ui.service';
|
||||
import type { CashflowResponse } from '../../shared/models';
|
||||
|
||||
Chart.register(LineController, LineElement, PointElement, CategoryScale, LinearScale, Tooltip, Legend);
|
||||
|
||||
@Component({
|
||||
selector: 'app-cashflow',
|
||||
standalone: true,
|
||||
imports: [CommonModule, 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.cashflow') }}</h2><div class="text-secondary">{{ ui.t('cashflow.subtitle') }}</div></div></div>
|
||||
</div>
|
||||
|
||||
<div class="row row-cards">
|
||||
<div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('cashflow.actual') }}</div><div class="display-6">{{ data()?.actualCurrent || 0 | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
|
||||
<div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('cashflow.budget') }}</div><div class="display-6">{{ data()?.totalBudget || 0 | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
|
||||
<div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('cashflow.forecast') }}</div><div class="display-6">{{ data()?.forecastCurrentMonth || 0 | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
|
||||
<div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('cashflow.pending') }}</div><div class="display-6">{{ data()?.pendingApproval || 0 }}</div></div></div></div>
|
||||
|
||||
<div class="col-lg-8 d-flex align-items-stretch">
|
||||
<div class="card pv-card h-100 w-100 overflow-hidden">
|
||||
<div class="card-header"><h3 class="card-title">{{ ui.t('cashflow.trend') }}</h3></div>
|
||||
<div class="card-body"><div class="ec-chart-wrap"><canvas id="cashflowTrendChart"></canvas></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4 d-flex align-items-stretch">
|
||||
<div class="card pv-card h-100 w-100 overflow-hidden">
|
||||
<div class="card-header"><h3 class="card-title">{{ ui.t('budget.alerts') }}</h3></div>
|
||||
<div class="card-body d-grid gap-2">
|
||||
@for (alert of data()?.alerts || []; track alert.id) {
|
||||
<div class="alert alert-warning mb-0 py-2 px-3">{{ alert.name }} · {{ alert.usagePercent }}%</div>
|
||||
} @empty {
|
||||
<div class="text-secondary">{{ ui.t('common.noData') }}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6">
|
||||
<div class="card overflow-hidden">
|
||||
<div class="card-header"><h3 class="card-title">{{ ui.t('cashflow.statusSummary') }}</h3></div>
|
||||
<div class="table-responsive"><table class="table table-vcenter card-table mb-0"><thead><tr><th>{{ ui.t('expenses.field.status') }}</th><th class="text-end">{{ ui.t('table.count') }}</th></tr></thead><tbody>@for (item of data()?.statusSummary || []; track item.status) { <tr><td>{{ ui.t('status.' + item.status.toLowerCase()) }}</td><td class="text-end">{{ item.count }}</td></tr> } @empty { <tr><td colspan="2" class="text-secondary">{{ ui.t('common.noData') }}</td></tr> }</tbody></table></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<div class="card overflow-hidden">
|
||||
<div class="card-header"><h3 class="card-title">{{ ui.t('cashflow.upcomingRecurring') }}</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('table.date') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th></tr></thead><tbody>@for (item of data()?.upcomingRecurring || []; track item.id) { <tr><td>{{ item.title }}</td><td>{{ item.nextRunDate | date:'yyyy-MM-dd' }}</td><td class="text-end">{{ item.amount | currency:'PLN':'symbol':'1.2-2' }}</td></tr> } @empty { <tr><td colspan="3" class="text-secondary">{{ ui.t('common.noData') }}</td></tr> }</tbody></table></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class CashflowComponent implements OnInit, AfterViewChecked, OnDestroy {
|
||||
readonly ui = inject(UiService);
|
||||
private readonly statsService = inject(StatsService);
|
||||
readonly data = signal<CashflowResponse | null>(null);
|
||||
private chart?: Chart;
|
||||
private chartPending = false;
|
||||
|
||||
ngOnInit() { this.statsService.cashflow().subscribe({ next: (response) => { this.data.set(response); this.chartPending = true; } }); }
|
||||
ngAfterViewChecked() { if (this.chartPending) { this.chartPending = false; this.renderChart(); } }
|
||||
ngOnDestroy() { this.chart?.destroy(); }
|
||||
|
||||
private renderChart() {
|
||||
const canvas = document.getElementById('cashflowTrendChart') as HTMLCanvasElement | null;
|
||||
const data = this.data();
|
||||
if (!canvas || !data?.trend?.length) return;
|
||||
this.chart?.destroy();
|
||||
this.chart = new Chart(canvas, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: data.trend.map((item) => item.label),
|
||||
datasets: [
|
||||
{ label: this.ui.t('cashflow.actual'), data: data.trend.map((item) => item.actual), borderColor: '#206bc4', backgroundColor: '#206bc4', tension: 0.25 },
|
||||
{ label: this.ui.t('cashflow.budget'), data: data.trend.map((item) => item.budget), borderColor: '#2fb344', backgroundColor: '#2fb344', tension: 0.25 }
|
||||
]
|
||||
},
|
||||
options: { maintainAspectRatio: false, plugins: { legend: { position: 'bottom', labels: { color: '#9ca3af' } } }, scales: { x: { ticks: { color: '#9ca3af' } }, y: { ticks: { color: '#9ca3af' } } } }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,16 @@
|
||||
import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common';
|
||||
import { AfterViewChecked, Component, OnDestroy, OnInit, inject } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { Chart, DoughnutController, ArcElement, Tooltip, Legend } from 'chart.js';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { UiService } from '../../core/services/ui.service';
|
||||
import { Chart, ArcElement, DoughnutController, Legend, Tooltip } from 'chart.js';
|
||||
import { ExpensesService } from '../../core/services/expenses.service';
|
||||
import { ShoppingListIntegrationService } from '../../core/services/shopping-list-integration.service';
|
||||
import { StatsService } from '../../core/services/stats.service';
|
||||
import type { Expense, StatsResponse } from '../../shared/models';
|
||||
import { UiService } from '../../core/services/ui.service';
|
||||
import type { CashflowResponse, Expense, ShoppingListSummary, StatsResponse } from '../../shared/models';
|
||||
|
||||
Chart.register(DoughnutController, ArcElement, Tooltip, Legend);
|
||||
|
||||
const DASHBOARD_CACHE_KEY = 'expense-control-dashboard-v6';
|
||||
const chartPalette = ['#206bc4', '#2fb344', '#f59f00', '#d63939', '#9b4dca', '#4263eb', '#0ca678', '#e8590c'];
|
||||
const DASHBOARD_CACHE_KEY = 'expense-control-dashboard-cache';
|
||||
|
||||
const formatLocalDate = (date: Date) => {
|
||||
const year = date.getFullYear();
|
||||
@@ -21,83 +20,42 @@ const formatLocalDate = (date: Date) => {
|
||||
};
|
||||
|
||||
const getMonthRange = () => {
|
||||
const now = new Date();
|
||||
const today = new Date();
|
||||
return {
|
||||
start: formatLocalDate(new Date(now.getFullYear(), now.getMonth(), 1)),
|
||||
end: formatLocalDate(now)
|
||||
start: formatLocalDate(new Date(today.getFullYear(), today.getMonth(), 1)),
|
||||
end: formatLocalDate(today)
|
||||
};
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
standalone: true,
|
||||
imports: [CommonModule, CurrencyPipe, DatePipe, RouterLink],
|
||||
imports: [CommonModule, 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">{{ auth.currentUser()?.fullName }}</h2>
|
||||
</div>
|
||||
<div class="col-12 col-xl d-flex justify-content-xl-end">
|
||||
<div class="ec-page-header-actions">
|
||||
<a class="btn btn-success d-inline-flex align-items-center gap-2" routerLink="/expenses">
|
||||
<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="M12 5l0 14"/><path d="M5 12l14 0"/></svg>
|
||||
<span>{{ ui.t('action.addExpense') }}</span>
|
||||
</a>
|
||||
</div>
|
||||
<h2 class="page-title mb-1">{{ ui.t('nav.dashboard') }}</h2>
|
||||
<div class="text-secondary">{{ ui.t('dashboard.cashflowHint') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row row-cards g-3">
|
||||
<div class="col-12">
|
||||
<div class="card pv-card pv-hero-card overflow-hidden">
|
||||
<div class="card-body">
|
||||
<div class="row align-items-center g-3">
|
||||
<div class="col-lg-7">
|
||||
<div class="text-secondary text-uppercase small fw-semibold mb-2">{{ ui.t('dashboard.total') }}</div>
|
||||
<div class="display-6 fw-bold mb-0">{{ (stats?.total || 0) | currency:'PLN':'symbol':'1.2-2' }}</div>
|
||||
</div>
|
||||
<div class="col-lg-5">
|
||||
<div class="row g-3">
|
||||
<div class="col-6">
|
||||
<div class="border rounded-3 p-3 h-100 ec-metric-card">
|
||||
<div class="text-secondary small">{{ ui.t('dashboard.count') }}</div>
|
||||
<div class="h2 mb-0">{{ stats?.count || 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="border rounded-3 p-3 h-100 ec-metric-card">
|
||||
<div class="text-secondary small">{{ ui.t('dashboard.avg') }}</div>
|
||||
<div class="h2 mb-0">{{ (stats?.average || 0) | currency:'PLN':'symbol':'1.2-2' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="border rounded-3 p-3 h-100 ec-metric-card">
|
||||
<div class="text-secondary small">{{ ui.t('dashboard.top') }}</div>
|
||||
<div class="h3 mb-0">{{ stats?.topCategory?.categoryName || ui.t('common.none') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row row-cards">
|
||||
<div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('dashboard.total') }}</div><div class="display-6">{{ (stats?.total || 0) | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
|
||||
<div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('dashboard.count') }}</div><div class="display-6">{{ stats?.count || 0 }}</div></div></div></div>
|
||||
<div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('dashboard.budgetUsage') }}</div><div class="display-6">{{ cashflow?.budgetUsagePercent || 0 }}%</div></div></div></div>
|
||||
<div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('cashflow.forecast') }}</div><div class="display-6">{{ (cashflow?.forecastCurrentMonth || 0) | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
|
||||
|
||||
<div class="col-lg-5 d-flex align-items-stretch">
|
||||
<div class="card pv-card w-100 overflow-hidden">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h3 class="card-title">{{ ui.t('dashboard.share') }}</h3>
|
||||
<div class="ec-card-header-muted">{{ ui.t('dashboard.shareHint') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('dashboard.externalSpend') }}</div><div class="display-6">{{ externalAmount() | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
|
||||
<div class="col-md-6"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('dashboard.externalRecords') }}</div><div class="display-6">{{ externalCount() }}</div></div></div></div>
|
||||
|
||||
<div class="col-lg-7 d-flex align-items-stretch">
|
||||
<div class="card pv-card h-100 w-100 overflow-hidden">
|
||||
<div class="card-header"><h3 class="card-title">{{ ui.t('dashboard.share') }}</h3></div>
|
||||
<div class="card-body">
|
||||
@if (hasCategoryData()) {
|
||||
<div class="ec-chart-wrap ec-chart-wrap-sm">
|
||||
<canvas id="dashboardCategoryChart"></canvas>
|
||||
</div>
|
||||
@if (stats?.byCategory?.length) {
|
||||
<div class="ec-chart-wrap"><canvas id="dashboardCategoryChart"></canvas></div>
|
||||
} @else {
|
||||
<div class="alert alert-info mb-0">{{ ui.t('dashboard.noChartData') }}</div>
|
||||
}
|
||||
@@ -105,26 +63,63 @@ const getMonthRange = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-7 d-flex align-items-stretch">
|
||||
<div class="card pv-card w-100 overflow-hidden">
|
||||
<div class="card-header">
|
||||
<div class="col-lg-5 d-flex align-items-stretch">
|
||||
<div class="card pv-card h-100 w-100 overflow-hidden">
|
||||
<div class="card-header"><h3 class="card-title">{{ ui.t('nav.cashflow') }}</h3></div>
|
||||
<div class="card-body d-grid gap-3">
|
||||
<div class="d-flex justify-content-between"><span class="text-secondary">{{ ui.t('cashflow.actual') }}</span><strong>{{ (cashflow?.actualCurrent || 0) | currency:'PLN':'symbol':'1.2-2' }}</strong></div>
|
||||
<div class="d-flex justify-content-between"><span class="text-secondary">{{ ui.t('cashflow.budget') }}</span><strong>{{ (cashflow?.totalBudget || 0) | currency:'PLN':'symbol':'1.2-2' }}</strong></div>
|
||||
<div class="d-flex justify-content-between"><span class="text-secondary">{{ ui.t('cashflow.pending') }}</span><strong>{{ cashflow?.pendingApproval || 0 }}</strong></div>
|
||||
<div class="d-flex justify-content-between"><span class="text-secondary">{{ ui.t('cashflow.duplicates') }}</span><strong>{{ cashflow?.duplicateCount || 0 }}</strong></div>
|
||||
<div>
|
||||
<h3 class="card-title">{{ ui.t('dashboard.areas') }}</h3>
|
||||
<div class="ec-card-header-muted">{{ ui.t('dashboard.areasHint') }}</div>
|
||||
<div class="form-label mb-2">{{ ui.t('budget.alerts') }}</div>
|
||||
<div class="d-grid gap-2">
|
||||
@for (alert of cashflow?.alerts || []; track alert.id) {
|
||||
<div class="alert alert-warning mb-0 py-2 px-3">{{ alert.name }} · {{ alert.usagePercent }}%</div>
|
||||
} @empty {
|
||||
<div class="text-secondary">{{ ui.t('common.noData') }}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6">
|
||||
<div class="card overflow-hidden">
|
||||
<div class="card-header"><h3 class="card-title">{{ ui.t('dashboard.recent') }}</h3></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter card-table table-striped mb-0">
|
||||
<thead>
|
||||
<tr><th>{{ ui.t('table.category') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th><th class="text-end">{{ ui.t('table.count') }}</th></tr>
|
||||
</thead>
|
||||
<table class="table table-vcenter card-table mb-0">
|
||||
<thead><tr><th>{{ ui.t('table.title') }}</th><th>{{ ui.t('table.category') }}</th><th>{{ ui.t('expenses.field.status') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th></tr></thead>
|
||||
<tbody>
|
||||
@for (row of stats?.byCategory || []; track row.categoryId) {
|
||||
@for (item of recentExpenses; track item.id) {
|
||||
<tr>
|
||||
<td>{{ row.categoryName }}</td>
|
||||
<td class="text-end">{{ row.total | currency:'PLN':'symbol':'1.2-2' }}</td>
|
||||
<td class="text-end">{{ row.count }}</td>
|
||||
<td>
|
||||
<div class="fw-semibold">{{ item.title }}</div>
|
||||
<div class="text-secondary small">{{ item.merchant || ui.t('expenses.noMerchant') }}</div>
|
||||
</td>
|
||||
<td>{{ item.category.name }}</td>
|
||||
<td><span class="badge" [ngClass]="statusBadgeClass(item.status)">{{ ui.t('status.' + item.status.toLowerCase()) }}</span></td>
|
||||
<td class="text-end">{{ item.amount | currency:'PLN':'symbol':'1.2-2' }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr><td colspan="4" class="text-secondary">{{ ui.t('common.noExpenses') }}</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6">
|
||||
<div class="card overflow-hidden">
|
||||
<div class="card-header"><h3 class="card-title">{{ ui.t('cashflow.upcomingRecurring') }}</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('table.date') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th></tr></thead>
|
||||
<tbody>
|
||||
@for (item of cashflow?.upcomingRecurring || []; track item.id) {
|
||||
<tr><td>{{ item.title }}</td><td>{{ item.nextRunDate | date:'yyyy-MM-dd' }}</td><td class="text-end">{{ item.amount | currency:'PLN':'symbol':'1.2-2' }}</td></tr>
|
||||
} @empty {
|
||||
<tr><td colspan="3" class="text-secondary">{{ ui.t('common.noData') }}</td></tr>
|
||||
}
|
||||
@@ -133,51 +128,19 @@ const getMonthRange = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="card pv-card overflow-hidden">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h3 class="card-title">{{ ui.t('dashboard.recent') }}</h3>
|
||||
<div class="ec-card-header-muted">{{ ui.t('dashboard.recentHint') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@if (recentExpenses.length) {
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter card-table table-striped mb-0">
|
||||
<thead>
|
||||
<tr><th>{{ ui.t('table.title') }}</th><th>{{ ui.t('table.merchant') }}</th><th>{{ ui.t('table.date') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (item of recentExpenses; track item.id) {
|
||||
<tr>
|
||||
<td>{{ item.title }}</td>
|
||||
<td>{{ item.merchant || ui.t('common.none') }}</td>
|
||||
<td>{{ item.expenseDate | date:'shortDate' }}</td>
|
||||
<td class="text-end">{{ item.amount | currency:item.currency:'symbol':'1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="card-body">
|
||||
<div class="alert alert-warning mb-0">{{ ui.t('common.noExpenses') }}</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class DashboardComponent implements OnInit, AfterViewChecked, OnDestroy {
|
||||
readonly auth = inject(AuthService);
|
||||
readonly ui = inject(UiService);
|
||||
private readonly expensesService = inject(ExpensesService);
|
||||
private readonly statsService = inject(StatsService);
|
||||
private readonly shoppingIntegration = inject(ShoppingListIntegrationService);
|
||||
|
||||
recentExpenses: Expense[] = [];
|
||||
stats: StatsResponse | null = null;
|
||||
cashflow: CashflowResponse | null = null;
|
||||
externalSummary: ShoppingListSummary | null = null;
|
||||
private categoryChart?: Chart;
|
||||
private chartPending = false;
|
||||
|
||||
@@ -197,27 +160,24 @@ export class DashboardComponent implements OnInit, AfterViewChecked, OnDestroy {
|
||||
this.categoryChart?.destroy();
|
||||
}
|
||||
|
||||
hasCategoryData() {
|
||||
return Boolean(this.stats?.byCategory?.length);
|
||||
loadDashboard() {
|
||||
const range = getMonthRange();
|
||||
this.expensesService.list().subscribe({ next: (response) => { this.recentExpenses = response.items.slice(0, 8); this.persistCache(); } });
|
||||
this.statsService.overview({ startDate: range.start, endDate: range.end, bucket: 'month' }).subscribe({ next: (response) => { this.stats = response; this.chartPending = true; this.persistCache(); } });
|
||||
this.statsService.cashflow().subscribe({ next: (response) => { this.cashflow = response; this.persistCache(); } });
|
||||
this.shoppingIntegration.summary({ start_date: range.start, end_date: range.end }).subscribe({ next: (response) => { this.externalSummary = response; this.persistCache(); }, error: () => { this.externalSummary = this.externalSummary ?? null; } });
|
||||
}
|
||||
|
||||
private loadDashboard() {
|
||||
const range = getMonthRange();
|
||||
externalAmount() { return Number(this.externalSummary?.total ?? this.externalSummary?.amount ?? this.externalSummary?.meta?.total_amount ?? 0); }
|
||||
externalCount() { return Number(this.externalSummary?.count ?? this.externalSummary?.records ?? this.externalSummary?.meta?.total_count ?? 0); }
|
||||
|
||||
this.expensesService.list().subscribe({
|
||||
next: (response) => {
|
||||
this.recentExpenses = response.items.slice(0, 8);
|
||||
this.persistCache();
|
||||
}
|
||||
});
|
||||
|
||||
this.statsService.overview({ startDate: range.start, endDate: range.end, bucket: 'month' }).subscribe({
|
||||
next: (response) => {
|
||||
this.stats = response;
|
||||
this.chartPending = true;
|
||||
this.persistCache();
|
||||
}
|
||||
});
|
||||
statusBadgeClass(status: string) {
|
||||
return {
|
||||
DRAFT: 'text-bg-secondary',
|
||||
PENDING: 'text-bg-warning',
|
||||
APPROVED: 'text-bg-success',
|
||||
REJECTED: 'text-bg-danger'
|
||||
}[status] || 'text-bg-secondary';
|
||||
}
|
||||
|
||||
private renderChart() {
|
||||
@@ -228,35 +188,18 @@ export class DashboardComponent implements OnInit, AfterViewChecked, OnDestroy {
|
||||
}
|
||||
|
||||
const colors = this.stats.byCategory.map((_, index) => chartPalette[index % chartPalette.length]);
|
||||
|
||||
this.categoryChart?.destroy();
|
||||
this.categoryChart = new Chart(canvas, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: this.stats.byCategory.map((item) => item.categoryName),
|
||||
datasets: [
|
||||
{
|
||||
data: this.stats.byCategory.map((item) => item.total),
|
||||
backgroundColor: colors,
|
||||
borderColor: '#ffffff',
|
||||
hoverOffset: 10
|
||||
}
|
||||
]
|
||||
datasets: [{ data: this.stats.byCategory.map((item) => item.total), backgroundColor: colors, borderColor: '#ffffff', hoverOffset: 10 }]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: '66%',
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
boxWidth: 10,
|
||||
color: '#9ca3af'
|
||||
}
|
||||
}
|
||||
}
|
||||
plugins: { legend: { position: 'bottom', labels: { usePointStyle: true, boxWidth: 10, color: '#9ca3af' } } }
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -265,16 +208,18 @@ export class DashboardComponent implements OnInit, AfterViewChecked, OnDestroy {
|
||||
try {
|
||||
const raw = localStorage.getItem(DASHBOARD_CACHE_KEY);
|
||||
if (!raw) return;
|
||||
const parsed = JSON.parse(raw) as { recentExpenses?: Expense[]; stats?: StatsResponse | null };
|
||||
const parsed = JSON.parse(raw) as { recentExpenses?: Expense[]; stats?: StatsResponse | null; cashflow?: CashflowResponse | null; externalSummary?: ShoppingListSummary | null };
|
||||
this.recentExpenses = parsed.recentExpenses ?? [];
|
||||
this.stats = parsed.stats ?? null;
|
||||
this.cashflow = parsed.cashflow ?? null;
|
||||
this.externalSummary = parsed.externalSummary ?? null;
|
||||
this.chartPending = Boolean(this.stats?.byCategory?.length);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private persistCache() {
|
||||
try {
|
||||
localStorage.setItem(DASHBOARD_CACHE_KEY, JSON.stringify({ recentExpenses: this.recentExpenses, stats: this.stats }));
|
||||
localStorage.setItem(DASHBOARD_CACHE_KEY, JSON.stringify({ recentExpenses: this.recentExpenses, stats: this.stats, cashflow: this.cashflow, externalSummary: this.externalSummary }));
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common';
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
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 { Expense, Merchant, Proof } from '../../shared/models';
|
||||
import type { DuplicateGroup, Expense, Merchant, Proof } from '../../shared/models';
|
||||
|
||||
const formatLocalDate = (date: Date) => {
|
||||
const year = date.getFullYear();
|
||||
@@ -24,269 +24,150 @@ const today = formatLocalDate(new Date());
|
||||
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">{{ ui.t('expenses.title') }}</h2>
|
||||
<div class="text-secondary">{{ ui.t('expenses.subtitle') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<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.subtitle') }}</div></div></div>
|
||||
</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="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() ? ui.t('expenses.edit') : ui.t('expenses.new') }}</h3>
|
||||
@if (editingExpenseId()) {
|
||||
<button class="btn btn-outline-secondary btn-sm" type="button" (click)="cancelEdit()">{{ ui.t('action.cancelEdit') }}</button>
|
||||
}
|
||||
</div>
|
||||
<div class="card-header d-flex justify-content-between align-items-center"><h3 class="card-title">{{ editingExpenseId() ? ui.t('expenses.edit') : ui.t('expenses.new') }}</h3>@if (editingExpenseId()) { <button class="btn btn-outline-secondary btn-sm" type="button" (click)="cancelEdit()">{{ ui.t('action.cancelEdit') }}</button> }</div>
|
||||
<div class="card-body">
|
||||
<form [formGroup]="expenseForm" (ngSubmit)="submitExpense()" class="d-grid gap-3" novalidate>
|
||||
@if (submitted() && expenseForm.invalid) {
|
||||
<div class="alert alert-danger mb-0">{{ ui.t('expenses.requiredHint') }}</div>
|
||||
}
|
||||
@if (submitted() && expenseForm.invalid) { <div class="alert alert-danger mb-0">{{ ui.t('expenses.requiredHint') }}</div> }
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-7">
|
||||
<label class="form-label">{{ ui.t('expenses.field.title') }} <span class="text-danger">*</span></label>
|
||||
<input class="form-control" formControlName="title" [class.is-invalid]="expenseForm.controls.title.invalid && (expenseForm.controls.title.touched || submitted())" />
|
||||
@if (expenseForm.controls.title.invalid && (expenseForm.controls.title.touched || submitted())) {
|
||||
<div class="invalid-feedback d-block">{{ ui.t('expenses.validation.title') }}</div>
|
||||
}
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label class="form-label">{{ ui.t('expenses.field.amount') }} <span class="text-danger">*</span></label>
|
||||
<input class="form-control" type="number" step="0.01" formControlName="amount" [class.is-invalid]="expenseForm.controls.amount.invalid && (expenseForm.controls.amount.touched || submitted())" />
|
||||
@if (expenseForm.controls.amount.invalid && (expenseForm.controls.amount.touched || submitted())) {
|
||||
<div class="invalid-feedback d-block">{{ ui.t('expenses.validation.amount') }}</div>
|
||||
}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">{{ ui.t('expenses.field.date') }} <span class="text-danger">*</span></label>
|
||||
<input class="form-control" type="date" formControlName="expenseDate" [class.is-invalid]="expenseForm.controls.expenseDate.invalid && (expenseForm.controls.expenseDate.touched || submitted())" />
|
||||
@if (expenseForm.controls.expenseDate.invalid && (expenseForm.controls.expenseDate.touched || submitted())) {
|
||||
<div class="invalid-feedback d-block">{{ ui.t('expenses.validation.date') }}</div>
|
||||
}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">{{ ui.t('expenses.field.category') }} <span class="text-danger">*</span></label>
|
||||
<select class="form-select" formControlName="categoryId" [class.is-invalid]="expenseForm.controls.categoryId.invalid && (expenseForm.controls.categoryId.touched || submitted())">
|
||||
<option value="">{{ ui.t('common.select') }}</option>
|
||||
@for (category of categories(); track category.id) {
|
||||
<option [value]="category.id">{{ category.name }}</option>
|
||||
}
|
||||
</select>
|
||||
@if (expenseForm.controls.categoryId.invalid && (expenseForm.controls.categoryId.touched || submitted())) {
|
||||
<div class="invalid-feedback d-block">{{ ui.t('expenses.validation.category') }}</div>
|
||||
}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">{{ ui.t('expenses.field.payment') }}</label>
|
||||
<select class="form-select" formControlName="paymentMethod">
|
||||
<option value="">{{ ui.t('expenses.payment.none') }}</option>
|
||||
<option value="CARD">{{ ui.t('expenses.payment.card') }}</option>
|
||||
<option value="CASH">{{ ui.t('expenses.payment.cash') }}</option>
|
||||
<option value="TRANSFER">{{ ui.t('expenses.payment.transfer') }}</option>
|
||||
<option value="BLIK">BLIK</option>
|
||||
<option value="OTHER">{{ ui.t('expenses.payment.other') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-9">
|
||||
<label class="form-label">{{ ui.t('expenses.field.merchantPicker') }}</label>
|
||||
<div class="input-group">
|
||||
<select class="form-select" [value]="selectedMerchantId()" (change)="selectMerchant($any($event.target).value)">
|
||||
<option value="">{{ ui.t('expenses.customEntry') }}</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()">{{ ui.t('action.add') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">{{ ui.t('expenses.field.merchantName') }}</label>
|
||||
<input class="form-control" formControlName="merchant" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">{{ ui.t('expenses.field.description') }}</label>
|
||||
<textarea class="form-control" rows="3" formControlName="description"></textarea>
|
||||
</div>
|
||||
<div class="col-md-7"><label class="form-label">{{ ui.t('expenses.field.title') }} <span class="text-danger">*</span></label><input class="form-control" formControlName="title" [class.is-invalid]="expenseForm.controls.title.invalid && (expenseForm.controls.title.touched || submitted())" /></div>
|
||||
<div class="col-md-5"><label class="form-label">{{ ui.t('expenses.field.amount') }} <span class="text-danger">*</span></label><input class="form-control" type="number" step="0.01" formControlName="amount" [class.is-invalid]="expenseForm.controls.amount.invalid && (expenseForm.controls.amount.touched || submitted())" /></div>
|
||||
<div class="col-md-4"><label class="form-label">{{ ui.t('expenses.field.date') }} <span class="text-danger">*</span></label><input class="form-control" type="date" formControlName="expenseDate" /></div>
|
||||
<div class="col-md-4"><label class="form-label">{{ ui.t('expenses.field.category') }} <span class="text-danger">*</span></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-4"><label class="form-label">{{ ui.t('expenses.field.status') }}</label><select class="form-select" formControlName="status"><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-md-5"><label class="form-label">{{ ui.t('expenses.field.payment') }}</label><select class="form-select" formControlName="paymentMethod"><option value="">{{ ui.t('expenses.payment.none') }}</option><option value="CARD">{{ ui.t('expenses.payment.card') }}</option><option value="CASH">{{ ui.t('expenses.payment.cash') }}</option><option value="TRANSFER">{{ ui.t('expenses.payment.transfer') }}</option><option value="BLIK">BLIK</option><option value="OTHER">{{ ui.t('expenses.payment.other') }}</option></select></div>
|
||||
<div class="col-md-7"><label class="form-label">{{ ui.t('expenses.field.merchantPicker') }}</label><div class="input-group"><select class="form-select" [value]="selectedMerchantId()" (change)="selectMerchant($any($event.target).value)"><option value="">{{ ui.t('expenses.customEntry') }}</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()">{{ ui.t('action.add') }}</button></div></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" [placeholder]="ui.t('expenses.tagPlaceholder')" /></div>
|
||||
<div class="col-12"><label class="form-label">{{ ui.t('expenses.field.description') }}</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">{{ ui.t('expenses.field.proofType') }}</label>
|
||||
<select class="form-select" formControlName="proofType">
|
||||
<option value="RECEIPT">{{ ui.t('proof.receipt') }}</option>
|
||||
<option value="INVOICE">{{ ui.t('proof.invoice') }}</option>
|
||||
<option value="NOTE">{{ ui.t('proof.note') }}</option>
|
||||
<option value="BANK_STATEMENT">{{ ui.t('proof.statement') }}</option>
|
||||
<option value="OTHER">{{ ui.t('proof.other') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">{{ ui.t('expenses.field.proofLabel') }}</label>
|
||||
<input class="form-control" formControlName="proofLabel" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">{{ ui.t('expenses.field.file') }}</label>
|
||||
<input class="form-control" type="file" accept="image/*,.pdf" (change)="onProofSelected($event)" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">{{ ui.t('expenses.field.proofNote') }}</label>
|
||||
<textarea class="form-control" rows="2" formControlName="proofNote"></textarea>
|
||||
</div>
|
||||
<div class="card bg-body-tertiary overflow-hidden"><div class="card-body d-grid gap-3">
|
||||
<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>
|
||||
<div formArrayName="customFields" class="d-grid gap-2">
|
||||
@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>
|
||||
|
||||
@if (showCropper()) {
|
||||
<div>
|
||||
<div class="form-label">{{ ui.t('expenses.field.crop') }}</div>
|
||||
<image-cropper [imageChangedEvent]="imageChangedEvent()" [maintainAspectRatio]="false" format="png" (imageCropped)="onImageCropped($event)"></image-cropper>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (croppedPreview()) {
|
||||
<div>
|
||||
<div class="form-label">{{ ui.t('expenses.field.cropPreview') }}</div>
|
||||
<img class="img-fluid rounded" [src]="croppedPreview()" [alt]="ui.t('expenses.field.cropPreview')" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @empty {
|
||||
<div class="text-secondary small">{{ ui.t('expenses.noCustomFields') }}</div>
|
||||
}
|
||||
</div>
|
||||
</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">{{ ui.t('expenses.field.proofType') }}</label><select class="form-select" formControlName="proofType"><option value="RECEIPT">{{ ui.t('proof.receipt') }}</option><option value="INVOICE">{{ ui.t('proof.invoice') }}</option><option value="NOTE">{{ ui.t('proof.note') }}</option><option value="BANK_STATEMENT">{{ ui.t('proof.statement') }}</option><option value="OTHER">{{ ui.t('proof.other') }}</option></select></div>
|
||||
<div class="col-md-4"><label class="form-label">{{ ui.t('expenses.field.proofLabel') }}</label><input class="form-control" formControlName="proofLabel" /></div>
|
||||
<div class="col-md-4"><label class="form-label">{{ ui.t('expenses.field.file') }}</label><input class="form-control" type="file" accept="image/*,.pdf" multiple (change)="onProofSelected($event)" /></div>
|
||||
<div class="col-12"><label class="form-label">{{ ui.t('expenses.field.proofNote') }}</label><textarea class="form-control" rows="2" formControlName="proofNote"></textarea></div>
|
||||
</div>
|
||||
@if (showCropper()) {
|
||||
<div><div class="form-label">{{ ui.t('expenses.field.crop') }}</div><image-cropper [imageChangedEvent]="imageChangedEvent()" [maintainAspectRatio]="false" format="png" (imageCropped)="onImageCropped($event)"></image-cropper></div>
|
||||
}
|
||||
@if (croppedPreview()) {
|
||||
<div><div class="form-label">{{ ui.t('expenses.field.cropPreview') }}</div><img class="img-fluid rounded" [src]="croppedPreview()" [alt]="ui.t('expenses.field.cropPreview')" /></div>
|
||||
}
|
||||
@if (selectedFiles().length) {
|
||||
<div><div class="form-label">{{ ui.t('expenses.attachmentsSelected') }}</div><div class="d-flex flex-wrap gap-2">@for (file of selectedFiles(); track file.name + $index) { <span class="badge text-bg-secondary">{{ file.name }}</span> }</div></div>
|
||||
}
|
||||
</div></div>
|
||||
}
|
||||
|
||||
<button class="btn btn-success d-inline-flex align-items-center justify-content-center gap-2" [disabled]="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() ? ui.t('expenses.saving') : (editingExpenseId() ? ui.t('action.saveChanges') : ui.t('action.addExpense')) }}</span>
|
||||
</button>
|
||||
<div class="btn-list flex-wrap">
|
||||
<button class="btn btn-outline-secondary" type="button" (click)="submitExpense('DRAFT')" [disabled]="saving()">{{ ui.t('action.saveDraft') }}</button>
|
||||
<button class="btn btn-success" [disabled]="saving()">{{ saving() ? ui.t('expenses.saving') : ui.t('action.save') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-5">
|
||||
<div class="card sticky-top overflow-hidden" style="top: 1rem;">
|
||||
<div class="card overflow-hidden mb-3">
|
||||
<div class="card-header"><h3 class="card-title">{{ ui.t('expenses.filters') }}</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="">{{ ui.t('expenses.allCategories') }}</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]="ui.t('expenses.search')" /></div>
|
||||
<div class="col-12 d-flex gap-2">
|
||||
<button class="btn btn-primary flex-fill">{{ ui.t('action.filter') }}</button>
|
||||
<button class="btn btn-outline-secondary" type="button" (click)="resetFilters()">{{ ui.t('action.reset') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="card-body"><form [formGroup]="filterForm" (ngSubmit)="loadExpenses()" class="row g-3 align-items-end">
|
||||
<div class="col-sm-6"><label class="form-label">{{ ui.t('stats.from') }}</label><input class="form-control" type="date" formControlName="startDate" /></div>
|
||||
<div class="col-sm-6"><label class="form-label">{{ ui.t('stats.to') }}</label><input class="form-control" type="date" formControlName="endDate" /></div>
|
||||
<div class="col-sm-6"><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"><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"><label class="form-label">{{ ui.t('expenses.field.tags') }}</label><input class="form-control" formControlName="tags" /></div>
|
||||
<div class="col-sm-6"><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>
|
||||
|
||||
@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 || ui.t('expenses.noMerchant') }} • {{ 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)">{{ ui.t('action.edit') }}</button>
|
||||
<button class="btn btn-outline-danger btn-sm" type="button" (click)="removeExpense(expense)">{{ ui.t('action.delete') }}</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 || ui.t('expenses.proof') }}
|
||||
</button>
|
||||
<div class="card overflow-hidden">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter card-table mb-0">
|
||||
<thead><tr><th>{{ ui.t('table.title') }}</th><th>{{ ui.t('expenses.field.status') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
@for (item of expenses(); track item.id) {
|
||||
<tr>
|
||||
<td>
|
||||
<div class="fw-semibold d-flex align-items-center gap-2 flex-wrap">
|
||||
{{ item.title }}
|
||||
@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>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="alert alert-warning mb-0">{{ ui.t('expenses.noItems') }}</div>
|
||||
}
|
||||
<div class="text-secondary small">{{ item.expenseDate | date:'yyyy-MM-dd' }} · {{ item.category.name }} · {{ 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><span class="badge" [ngClass]="statusBadgeClass(item.status)">{{ ui.t('status.' + item.status.toLowerCase()) }}</span></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">
|
||||
@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="4" class="text-secondary">{{ ui.t('expenses.noItems') }}</td></tr> }
|
||||
</tbody>
|
||||
</table>
|
||||
</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">{{ ui.t('merchant.new') }}</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">{{ ui.t('merchant.name') }}</label>
|
||||
<input class="form-control" formControlName="name" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">{{ ui.t('merchant.type') }}</label>
|
||||
<select class="form-select" formControlName="kind">
|
||||
<option value="MERCHANT">{{ ui.t('merchant.kind.merchant') }}</option>
|
||||
<option value="SERVICE_PROVIDER">{{ ui.t('merchant.kind.service') }}</option>
|
||||
<option value="OTHER">{{ ui.t('merchant.kind.other') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">{{ ui.t('merchant.notes') }}</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()">{{ ui.t('action.cancel') }}</button>
|
||||
<button class="btn btn-success" [disabled]="merchantForm.invalid">{{ ui.t('action.saveMerchant') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show"></div>
|
||||
<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">{{ ui.t('merchant.new') }}</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">{{ ui.t('merchant.name') }}</label><input class="form-control" formControlName="name" /></div><div><label class="form-label">{{ ui.t('merchant.type') }}</label><select class="form-select" formControlName="kind"><option value="MERCHANT">{{ ui.t('merchant.kind.merchant') }}</option><option value="SERVICE_PROVIDER">{{ ui.t('merchant.kind.service') }}</option><option value="OTHER">{{ ui.t('merchant.kind.other') }}</option></select></div><div><label class="form-label">{{ ui.t('merchant.notes') }}</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()">{{ ui.t('action.cancel') }}</button><button class="btn btn-success" [disabled]="merchantForm.invalid">{{ ui.t('action.saveMerchant') }}</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 || ui.t('expenses.proof') }}</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]="ui.t('expenses.proof')" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show"></div>
|
||||
<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" type="button" (click)="closeProofPreview()"></button></div><div class="modal-body">@if (isPdf(proofPreview()!)) { <embed [attr.src]="proofPreview()?.fileUrl" type="application/pdf" style="width:100%;height:75vh;" /> } @else { <img class="img-fluid" [src]="proofPreview()?.fileUrl" [alt]="ui.t('expenses.proof')" /> }</div></div></div></div><div class="modal-backdrop fade show"></div>
|
||||
}
|
||||
`
|
||||
})
|
||||
@@ -301,13 +182,14 @@ export class ExpensesComponent implements OnInit {
|
||||
readonly categories = this.categoriesService.items;
|
||||
readonly merchants = this.merchantsService.items;
|
||||
readonly expenses = signal<Expense[]>([]);
|
||||
readonly duplicateGroups = signal<DuplicateGroup[]>([]);
|
||||
readonly selectedMerchantId = signal('');
|
||||
readonly editingExpenseId = signal<string | null>(null);
|
||||
readonly saving = signal(false);
|
||||
readonly submitted = signal(false);
|
||||
readonly merchantModalOpen = signal(false);
|
||||
readonly proofPreview = signal<Proof | null>(null);
|
||||
|
||||
readonly selectedFiles = signal<File[]>([]);
|
||||
readonly imageChangedEvent = signal<Event | null>(null);
|
||||
readonly croppedFile = signal<File | null>(null);
|
||||
readonly croppedPreview = signal<string | null>(null);
|
||||
@@ -321,42 +203,42 @@ export class ExpensesComponent implements OnInit {
|
||||
merchant: [''],
|
||||
paymentMethod: [''],
|
||||
description: [''],
|
||||
status: ['PENDING'],
|
||||
tagsText: [''],
|
||||
proofType: ['RECEIPT'],
|
||||
proofLabel: [''],
|
||||
proofNote: ['']
|
||||
proofNote: [''],
|
||||
customFields: this.fb.array([])
|
||||
});
|
||||
|
||||
readonly filterForm = this.fb.nonNullable.group({
|
||||
startDate: [''],
|
||||
endDate: [''],
|
||||
categoryId: [''],
|
||||
search: ['']
|
||||
});
|
||||
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: [''] });
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
activeMerchants() {
|
||||
return this.merchants().filter((item) => item.isActive);
|
||||
}
|
||||
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() {
|
||||
this.expensesService.list(this.filterForm.getRawValue()).subscribe({
|
||||
next: (response) => this.expenses.set(response.items)
|
||||
});
|
||||
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: '' });
|
||||
this.filterForm.reset({ startDate: '', endDate: '', categoryId: '', search: '', status: '', tags: '', duplicatesOnly: false });
|
||||
this.loadExpenses();
|
||||
}
|
||||
|
||||
@@ -386,16 +268,12 @@ export class ExpensesComponent implements OnInit {
|
||||
|
||||
onProofSelected(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0] ?? null;
|
||||
this.croppedFile.set(file);
|
||||
const files = Array.from(input.files ?? []);
|
||||
this.selectedFiles.set(files);
|
||||
this.croppedFile.set(null);
|
||||
this.croppedPreview.set(null);
|
||||
this.imageChangedEvent.set(event);
|
||||
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
this.showCropper.set(true);
|
||||
} else {
|
||||
this.showCropper.set(false);
|
||||
}
|
||||
this.showCropper.set(files.length === 1 && files[0].type.startsWith('image/'));
|
||||
}
|
||||
|
||||
onImageCropped(event: ImageCroppedEvent) {
|
||||
@@ -405,41 +283,30 @@ export class ExpensesComponent implements OnInit {
|
||||
this.croppedPreview.set(event.objectUrl ?? null);
|
||||
}
|
||||
|
||||
submitExpense() {
|
||||
submitExpense(forcedStatus?: Expense['status']) {
|
||||
this.submitted.set(true);
|
||||
this.expenseForm.markAllAsTouched();
|
||||
this.expenseForm.updateValueAndValidity();
|
||||
|
||||
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'
|
||||
})
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.saving.set(false);
|
||||
this.submitted.set(false);
|
||||
this.toast.success(this.ui.t('expenses.saved'));
|
||||
this.cancelEdit();
|
||||
this.loadExpenses();
|
||||
},
|
||||
error: (error) => {
|
||||
this.saving.set(false);
|
||||
this.toast.error(error.error?.message ?? this.ui.t('expenses.saveError'));
|
||||
}
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -452,33 +319,25 @@ export class ExpensesComponent implements OnInit {
|
||||
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);
|
||||
if (this.croppedFile()) formData.set('proofFile', this.croppedFile()!);
|
||||
|
||||
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: () => {
|
||||
this.saving.set(false);
|
||||
this.submitted.set(false);
|
||||
this.toast.success(this.ui.t('expenses.added'));
|
||||
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();
|
||||
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);
|
||||
@@ -487,35 +346,37 @@ export class ExpensesComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
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.expenseForm.patchValue({
|
||||
title: item.title,
|
||||
amount: item.amount,
|
||||
expenseDate: item.expenseDate,
|
||||
categoryId: item.category.id,
|
||||
merchant: item.merchant ?? '',
|
||||
paymentMethod: item.paymentMethod ?? '',
|
||||
description: item.description ?? ''
|
||||
});
|
||||
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.expenseForm.reset({
|
||||
title: '',
|
||||
amount: 0,
|
||||
expenseDate: today,
|
||||
categoryId: '',
|
||||
merchant: '',
|
||||
paymentMethod: '',
|
||||
description: '',
|
||||
proofType: 'RECEIPT',
|
||||
proofLabel: '',
|
||||
proofNote: ''
|
||||
});
|
||||
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) {
|
||||
@@ -523,20 +384,43 @@ export class ExpensesComponent implements OnInit {
|
||||
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'))
|
||||
});
|
||||
}
|
||||
|
||||
openProof(proof: Proof) {
|
||||
this.proofPreview.set(proof);
|
||||
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'))
|
||||
});
|
||||
}
|
||||
|
||||
closeMerchantModal() {
|
||||
this.merchantModalOpen.set(false);
|
||||
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<string, string>)[status] || 'text-bg-secondary';
|
||||
}
|
||||
|
||||
closeProofPreview() {
|
||||
this.proofPreview.set(null);
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
541
web/src/app/features/integrations/integrations.component.ts
Normal file
541
web/src/app/features/integrations/integrations.component.ts
Normal file
@@ -0,0 +1,541 @@
|
||||
import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common';
|
||||
import { Component, OnInit, computed, inject, signal } from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { CategoriesService } from '../../core/services/categories.service';
|
||||
import { ShoppingListIntegrationService } from '../../core/services/shopping-list-integration.service';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
import { UiService } from '../../core/services/ui.service';
|
||||
import type { Category, ShoppingListExpenseItem, ShoppingListRef, ShoppingListSummary } from '../../shared/models';
|
||||
|
||||
const currentMonth = () => new Date().toISOString().slice(0, 7);
|
||||
const today = () => new Date().toISOString().slice(0, 10);
|
||||
const monthRange = (period: string) => {
|
||||
const safe = /^\d{4}-\d{2}$/.test(period) ? period : currentMonth();
|
||||
const [yearText, monthText] = safe.split('-');
|
||||
const year = Number(yearText);
|
||||
const month = Number(monthText);
|
||||
const nextMonth = month === 12 ? new Date(year + 1, 0, 1) : new Date(year, month, 1);
|
||||
const end = new Date(nextMonth.getTime() - 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
return { start: `${safe}-01`, end };
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-integrations',
|
||||
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('integrations.title') }}</h2>
|
||||
<div class="text-secondary">{{ ui.t('integrations.subtitle') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row row-cards mb-3">
|
||||
<div class="col-lg-5">
|
||||
<div class="card overflow-hidden h-100">
|
||||
<div class="card-header"><h3 class="card-title">{{ ui.t('integrations.shoppingList') }}</h3></div>
|
||||
<div class="card-body">
|
||||
<form [formGroup]="form" (ngSubmit)="save()" class="d-grid gap-3">
|
||||
<label class="form-check">
|
||||
<input class="form-check-input" type="checkbox" formControlName="enabled" />
|
||||
<span class="form-check-label">{{ ui.t('integrations.enabled') }}</span>
|
||||
</label>
|
||||
<div>
|
||||
<label class="form-label">{{ ui.t('integrations.baseUrl') }}</label>
|
||||
<input class="form-control" formControlName="baseUrl" placeholder="https://host.example.com" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">{{ ui.t('integrations.apiToken') }}</label>
|
||||
<input class="form-control" formControlName="apiToken" type="password" />
|
||||
<div class="form-hint">{{ ui.t('integrations.keepToken') }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">{{ ui.t('integrations.authMode') }}</label>
|
||||
<select class="form-select" formControlName="authMode">
|
||||
<option value="both">Bearer + X-API-Token</option>
|
||||
<option value="bearer">Bearer</option>
|
||||
<option value="x-api-token">X-API-Token</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">{{ ui.t('integrations.ownerId') }}</label>
|
||||
<input class="form-control" formControlName="ownerId" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">{{ ui.t('integrations.defaultListId') }}</label>
|
||||
<input class="form-control" formControlName="defaultListId" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-list flex-wrap">
|
||||
<button class="btn btn-success" [disabled]="form.invalid">{{ ui.t('action.save') }}</button>
|
||||
<button class="btn btn-outline-info" type="button" (click)="test()">{{ ui.t('action.testConnection') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-7">
|
||||
<div class="card overflow-hidden h-100">
|
||||
<div class="card-header"><h3 class="card-title">{{ ui.t('integrations.history') }}</h3></div>
|
||||
<div class="card-body d-grid gap-3">
|
||||
<form [formGroup]="historyForm" class="row g-3 align-items-end">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">{{ ui.t('integrations.period') }}</label>
|
||||
<input class="form-control" type="month" formControlName="period" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">{{ ui.t('integrations.limit') }}</label>
|
||||
<input class="form-control" type="number" min="1" max="200" formControlName="limit" />
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<div class="btn-list justify-content-md-end">
|
||||
<button class="btn btn-primary" type="button" (click)="refresh()">{{ ui.t('action.refresh') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="row row-cards">
|
||||
<div class="col-md-6">
|
||||
<div class="card overflow-hidden">
|
||||
<div class="card-body">
|
||||
<div class="text-secondary">{{ ui.t('integrations.externalSpend') }}</div>
|
||||
<div class="display-6">{{ summaryAmount() | currency:'PLN':'symbol':'1.2-2' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card overflow-hidden">
|
||||
<div class="card-body">
|
||||
<div class="text-secondary">{{ ui.t('integrations.externalCount') }}</div>
|
||||
<div class="display-6">{{ summaryCount() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border rounded-3 p-3 bg-body-tertiary">
|
||||
@if (configured()) {
|
||||
<div class="small text-secondary mb-2">{{ ui.t('integrations.summary') }}</div>
|
||||
<pre class="mb-0 small">{{ summaryText() }}</pre>
|
||||
} @else {
|
||||
<div class="text-secondary">{{ ui.t('integrations.notConfigured') }}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row row-cards mb-3">
|
||||
<div class="col-lg-4">
|
||||
<div class="card overflow-hidden h-100">
|
||||
<div class="card-header"><h3 class="card-title">{{ ui.t('integrations.lists') }}</h3></div>
|
||||
<div class="list-group list-group-flush">
|
||||
@for (item of visibleLists(); track item.id) {
|
||||
<button class="list-group-item list-group-item-action text-start" type="button" [class.active]="isSelectedList(item)" (click)="selectList(item)">
|
||||
<div class="d-flex justify-content-between gap-2 align-items-start">
|
||||
<div>
|
||||
<div class="fw-semibold">{{ listTitle(item) }}</div>
|
||||
<div class="text-secondary small">#{{ item.id }} · {{ listOwner(item) || ui.t('common.none') }}</div>
|
||||
</div>
|
||||
<span class="badge text-bg-secondary">{{ listCreatedAt(item) | date:'yyyy-MM-dd' }}</span>
|
||||
</div>
|
||||
</button>
|
||||
} @empty {
|
||||
<div class="list-group-item text-secondary">{{ ui.t('common.noData') }}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-8">
|
||||
<div class="card overflow-hidden h-100">
|
||||
<div class="card-header"><h3 class="card-title">{{ ui.t('integrations.importTitle') }}</h3></div>
|
||||
<div class="card-body d-grid gap-3">
|
||||
<form [formGroup]="importForm" class="row g-3">
|
||||
<div class="col-md-5">
|
||||
<label class="form-label">{{ ui.t('expenses.field.category') }}</label>
|
||||
<select class="form-select" formControlName="categoryId">
|
||||
<option value="">{{ ui.t('common.select') }}</option>
|
||||
@for (item of categories(); track item.id) {
|
||||
<option [value]="item.id">{{ item.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">{{ ui.t('expenses.field.status') }}</label>
|
||||
<select class="form-select" formControlName="status">
|
||||
<option value="DRAFT">{{ ui.t('status.draft') }}</option>
|
||||
<option value="PENDING">{{ ui.t('status.pending') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">{{ ui.t('table.merchant') }}</label>
|
||||
<input class="form-control" formControlName="merchant" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">{{ ui.t('integrations.tags') }}</label>
|
||||
<input class="form-control" formControlName="tags" />
|
||||
<div class="form-hint">{{ ui.t('integrations.tagsHint') }}</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@if (selectedList()) {
|
||||
<div class="border rounded-3 p-3">
|
||||
<div class="d-flex justify-content-between gap-3 flex-wrap align-items-start">
|
||||
<div>
|
||||
<div class="fw-semibold">{{ listTitle(selectedList()!) }}</div>
|
||||
<div class="text-secondary small">#{{ selectedList()!.id }} · {{ listCreatedAt(selectedList()!) | date:'yyyy-MM-dd' }}</div>
|
||||
<div class="text-secondary small">{{ ui.t('integrations.selectedListSummary') }}: {{ selectedListCount() }} / {{ selectedListTotal() | currency:'PLN':'symbol':'1.2-2' }}</div>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="button" [disabled]="importForm.invalid || selectedListCount() === 0" (click)="importSelectedList()">
|
||||
{{ ui.t('integrations.importSelectedList') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="alert alert-info mb-0">{{ ui.t('integrations.selectListHint') }}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row row-cards">
|
||||
<div class="col-lg-6">
|
||||
<div class="card overflow-hidden h-100">
|
||||
<div class="card-header"><h3 class="card-title">{{ ui.t('integrations.latest') }}</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('table.date') }}</th>
|
||||
<th class="text-end">{{ ui.t('table.amount') }}</th>
|
||||
<th class="text-end">{{ ui.t('table.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (item of latestExpenses(); track $index) {
|
||||
<tr>
|
||||
<td>
|
||||
<div class="fw-semibold">{{ itemTitle(item) }}</div>
|
||||
<div class="text-secondary small">{{ listTitle(item.list) }} · {{ ownerName(item) || ui.t('common.none') }}</div>
|
||||
</td>
|
||||
<td>{{ itemDate(item) }}</td>
|
||||
<td class="text-end">{{ itemAmount(item) | number:'1.2-2' }}</td>
|
||||
<td class="text-end"><button class="btn btn-sm btn-outline-primary" type="button" [disabled]="importForm.invalid" (click)="importItem(item)">{{ ui.t('action.import') }}</button></td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr><td colspan="4" class="text-secondary">{{ ui.t('common.noData') }}</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6">
|
||||
<div class="card overflow-hidden h-100">
|
||||
<div class="card-header"><h3 class="card-title">{{ ui.t('integrations.listExpenses') }}</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('table.date') }}</th>
|
||||
<th class="text-end">{{ ui.t('table.amount') }}</th>
|
||||
<th class="text-end">{{ ui.t('table.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (item of selectedListExpenses(); track $index) {
|
||||
<tr>
|
||||
<td>
|
||||
<div class="fw-semibold">{{ itemTitle(item) }}</div>
|
||||
<div class="text-secondary small">{{ ownerName(item) || ui.t('common.none') }}</div>
|
||||
</td>
|
||||
<td>{{ itemDate(item) }}</td>
|
||||
<td class="text-end">{{ itemAmount(item) | number:'1.2-2' }}</td>
|
||||
<td class="text-end"><button class="btn btn-sm btn-outline-primary" type="button" [disabled]="importForm.invalid" (click)="importItem(item)">{{ ui.t('action.import') }}</button></td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr><td colspan="4" class="text-secondary">{{ ui.t('common.noData') }}</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class IntegrationsComponent implements OnInit {
|
||||
readonly ui = inject(UiService);
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly integration = inject(ShoppingListIntegrationService);
|
||||
private readonly categoriesService = inject(CategoriesService);
|
||||
private readonly toast = inject(ToastService);
|
||||
|
||||
readonly categories = this.categoriesService.items;
|
||||
readonly configured = signal(false);
|
||||
readonly summary = signal<ShoppingListSummary | null>(null);
|
||||
readonly allLists = signal<ShoppingListRef[]>([]);
|
||||
readonly latestExpenses = signal<ShoppingListExpenseItem[]>([]);
|
||||
readonly selectedList = signal<ShoppingListRef | null>(null);
|
||||
readonly selectedListExpenses = signal<ShoppingListExpenseItem[]>([]);
|
||||
|
||||
readonly form = this.fb.nonNullable.group({
|
||||
enabled: [false],
|
||||
baseUrl: ['', Validators.required],
|
||||
apiToken: [''],
|
||||
authMode: ['both' as 'bearer' | 'x-api-token' | 'both', Validators.required],
|
||||
ownerId: [''],
|
||||
defaultListId: ['']
|
||||
});
|
||||
|
||||
readonly historyForm = this.fb.nonNullable.group({
|
||||
period: [currentMonth(), Validators.required],
|
||||
limit: [50, [Validators.required, Validators.min(1), Validators.max(200)]]
|
||||
});
|
||||
|
||||
readonly importForm = this.fb.nonNullable.group({
|
||||
categoryId: ['', Validators.required],
|
||||
status: ['PENDING' as 'DRAFT' | 'PENDING', Validators.required],
|
||||
merchant: ['Shopping list API'],
|
||||
tags: ['shopping-list, external-import']
|
||||
});
|
||||
|
||||
readonly summaryAmount = computed(() => Number(this.summary()?.total ?? this.summary()?.amount ?? this.summary()?.meta?.total_amount ?? 0));
|
||||
readonly summaryCount = computed(() => Number(this.summary()?.count ?? this.summary()?.records ?? this.summary()?.meta?.total_count ?? 0));
|
||||
readonly summaryText = computed(() => JSON.stringify(this.summary(), null, 2));
|
||||
readonly selectedListTotal = computed(() => this.selectedListExpenses().reduce((sum: number, item: ShoppingListExpenseItem) => sum + this.itemAmount(item), 0));
|
||||
readonly selectedListCount = computed(() => this.selectedListExpenses().length);
|
||||
|
||||
ngOnInit() {
|
||||
this.categoriesService.list().subscribe({
|
||||
next: (response: { items: Category[] }) => {
|
||||
const first = response.items[0];
|
||||
if (first && !this.importForm.controls.categoryId.value) this.importForm.controls.categoryId.setValue(first.id);
|
||||
},
|
||||
error: () => undefined
|
||||
});
|
||||
|
||||
this.integration.getSettings().subscribe({
|
||||
next: (response: { item: { enabled: boolean; baseUrl: string; hasToken: boolean; authMode: 'bearer' | 'x-api-token' | 'both'; ownerId: string | null; defaultListId: string | null } }) => {
|
||||
const item = response.item;
|
||||
this.form.reset({
|
||||
enabled: item.enabled,
|
||||
baseUrl: item.baseUrl || '',
|
||||
apiToken: '',
|
||||
authMode: item.authMode,
|
||||
ownerId: item.ownerId || '',
|
||||
defaultListId: item.defaultListId || ''
|
||||
});
|
||||
this.configured.set(Boolean(item.enabled && item.baseUrl && item.hasToken));
|
||||
if (this.configured()) this.refresh();
|
||||
},
|
||||
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('integrations.loadError'))
|
||||
});
|
||||
}
|
||||
|
||||
save() {
|
||||
if (this.form.invalid) return;
|
||||
const raw = this.form.getRawValue();
|
||||
this.integration
|
||||
.updateSettings({
|
||||
enabled: raw.enabled,
|
||||
baseUrl: raw.baseUrl || null,
|
||||
apiToken: raw.apiToken || undefined,
|
||||
authMode: raw.authMode,
|
||||
ownerId: raw.ownerId || null,
|
||||
defaultListId: raw.defaultListId || null
|
||||
})
|
||||
.subscribe({
|
||||
next: (response: { item: { enabled: boolean; baseUrl: string; hasToken: boolean } }) => {
|
||||
this.configured.set(Boolean(response.item.enabled && response.item.baseUrl && response.item.hasToken));
|
||||
this.toast.success(this.ui.t('integrations.saveSuccess'));
|
||||
if (this.configured()) this.refresh();
|
||||
},
|
||||
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('integrations.saveError'))
|
||||
});
|
||||
}
|
||||
|
||||
test() {
|
||||
this.integration.test().subscribe({
|
||||
next: () => this.toast.success(this.ui.t('integrations.testSuccess')),
|
||||
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('integrations.testError'))
|
||||
});
|
||||
}
|
||||
|
||||
refresh() {
|
||||
if (!this.configured()) return;
|
||||
const raw = this.form.getRawValue();
|
||||
const history = this.historyForm.getRawValue();
|
||||
const range = monthRange(history.period);
|
||||
const filters = {
|
||||
start_date: range.start,
|
||||
end_date: range.end,
|
||||
owner_id: raw.ownerId || undefined,
|
||||
list_id: raw.defaultListId || undefined
|
||||
};
|
||||
|
||||
this.integration.summary(filters).subscribe({
|
||||
next: (response: ShoppingListSummary) => this.summary.set(response),
|
||||
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('integrations.loadError'))
|
||||
});
|
||||
|
||||
this.integration.latest({ ...filters, limit: history.limit }).subscribe({
|
||||
next: (response: { items?: ShoppingListExpenseItem[]; data?: ShoppingListExpenseItem[] }) => this.latestExpenses.set(this.pickItems<ShoppingListExpenseItem>(response)),
|
||||
error: () => this.latestExpenses.set([])
|
||||
});
|
||||
|
||||
this.integration.lists({ owner_id: raw.ownerId || undefined, limit: 200 }).subscribe({
|
||||
next: (response: { items?: ShoppingListRef[]; data?: ShoppingListRef[] }) => {
|
||||
const items = this.pickItems<ShoppingListRef>(response);
|
||||
this.allLists.set(items);
|
||||
const visible = this.visibleLists(items);
|
||||
const currentId = String(this.selectedList()?.id ?? '');
|
||||
const nextSelected = visible.find((item) => String(item.id) === currentId) ?? visible[0] ?? null;
|
||||
this.selectedList.set(nextSelected);
|
||||
if (nextSelected) {
|
||||
this.loadListExpenses(nextSelected);
|
||||
} else {
|
||||
this.selectedListExpenses.set([]);
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
this.allLists.set([]);
|
||||
this.selectedList.set(null);
|
||||
this.selectedListExpenses.set([]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
visibleLists(source?: ShoppingListRef[]) {
|
||||
const period = this.historyForm.controls.period.value;
|
||||
const items = source ?? this.allLists();
|
||||
return items.filter((item) => {
|
||||
const createdAt = this.listCreatedAt(item);
|
||||
return createdAt ? createdAt.slice(0, 7) === period : true;
|
||||
});
|
||||
}
|
||||
|
||||
selectList(item: ShoppingListRef) {
|
||||
this.selectedList.set(item);
|
||||
this.loadListExpenses(item);
|
||||
}
|
||||
|
||||
importSelectedList() {
|
||||
const list = this.selectedList();
|
||||
if (!list || this.importForm.invalid) return;
|
||||
const raw = this.importForm.getRawValue();
|
||||
this.integration
|
||||
.importList({
|
||||
listId: list.id,
|
||||
listTitle: this.listTitle(list),
|
||||
listCreatedAt: this.listCreatedAt(list),
|
||||
categoryId: raw.categoryId,
|
||||
status: raw.status,
|
||||
merchant: raw.merchant || this.listTitle(list),
|
||||
tags: this.normalizedTags()
|
||||
})
|
||||
.subscribe({
|
||||
next: (response: { item: unknown; warnings?: string[] }) => {
|
||||
this.toast.success(this.ui.t('integrations.importListSuccess'));
|
||||
this.emitWarnings(response.warnings);
|
||||
},
|
||||
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('integrations.importError'))
|
||||
});
|
||||
}
|
||||
|
||||
importItem(item: ShoppingListExpenseItem) {
|
||||
if (this.importForm.invalid) return;
|
||||
const raw = this.importForm.getRawValue();
|
||||
this.integration
|
||||
.importItem({
|
||||
expenseId: item.expense_id ?? item.id ?? null,
|
||||
listId: item.list?.id ?? this.selectedList()?.id ?? null,
|
||||
listTitle: this.listTitle(item.list ?? this.selectedList()),
|
||||
categoryId: raw.categoryId,
|
||||
status: raw.status,
|
||||
title: this.itemTitle(item),
|
||||
amount: this.itemAmount(item),
|
||||
expenseDate: this.itemDate(item),
|
||||
merchant: raw.merchant || this.listTitle(item.list ?? this.selectedList()),
|
||||
ownerName: this.ownerName(item),
|
||||
tags: this.normalizedTags()
|
||||
})
|
||||
.subscribe({
|
||||
next: (response: { item: unknown; warnings?: string[] }) => {
|
||||
this.toast.success(this.ui.t('integrations.importItemSuccess'));
|
||||
this.emitWarnings(response.warnings);
|
||||
},
|
||||
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('integrations.importError'))
|
||||
});
|
||||
}
|
||||
|
||||
isSelectedList(item: ShoppingListRef) {
|
||||
return String(this.selectedList()?.id ?? '') === String(item.id);
|
||||
}
|
||||
|
||||
listTitle(item?: ShoppingListRef | null) {
|
||||
return item?.title || item?.name || (item?.id !== undefined ? String(item.id) : '-');
|
||||
}
|
||||
|
||||
listOwner(item?: ShoppingListRef | null) {
|
||||
return item?.owner?.username || item?.owner?.fullName || item?.owner?.name || item?.owner?.email || null;
|
||||
}
|
||||
|
||||
listCreatedAt(item?: ShoppingListRef | null) {
|
||||
return item?.created_at || null;
|
||||
}
|
||||
|
||||
itemTitle(item: ShoppingListExpenseItem) {
|
||||
return item.title || item.name || item.list?.title || item.list?.name || `Expense #${item.expense_id ?? item.id ?? '-'}`;
|
||||
}
|
||||
|
||||
itemDate(item: ShoppingListExpenseItem) {
|
||||
return (item.expense_date || item.added_at || item.created_at || today()).slice(0, 10);
|
||||
}
|
||||
|
||||
itemAmount(item: ShoppingListExpenseItem) {
|
||||
return Number(item.amount ?? item.total ?? 0);
|
||||
}
|
||||
|
||||
ownerName(item: ShoppingListExpenseItem) {
|
||||
return item.owner?.fullName || item.owner?.name || item.owner?.username || item.owner?.email || null;
|
||||
}
|
||||
|
||||
private loadListExpenses(item: ShoppingListRef) {
|
||||
const limit = this.historyForm.controls.limit.value;
|
||||
this.integration.listExpenses(item.id, limit).subscribe({
|
||||
next: (response: { items?: ShoppingListExpenseItem[]; data?: ShoppingListExpenseItem[] }) => this.selectedListExpenses.set(this.pickItems<ShoppingListExpenseItem>(response)),
|
||||
error: () => this.selectedListExpenses.set([])
|
||||
});
|
||||
}
|
||||
|
||||
private normalizedTags() {
|
||||
return Array.from(
|
||||
new Set(
|
||||
this.importForm.controls.tags.value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private emitWarnings(warnings?: string[]) {
|
||||
(warnings ?? []).forEach((message) => this.toast.warning(message));
|
||||
}
|
||||
|
||||
private pickItems<T extends { id?: string | number }>(response: { items?: T[]; data?: T[] }) {
|
||||
return response.items ?? response.data ?? [];
|
||||
}
|
||||
}
|
||||
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')) });
|
||||
}
|
||||
}
|
||||
@@ -14,56 +14,35 @@ import { CategoryPickerComponent } from '../../shared/ui/category-picker.compone
|
||||
imports: [CommonModule, ReactiveFormsModule, CategoryPickerComponent],
|
||||
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('reports.title') }}</h2>
|
||||
<div class="text-secondary">{{ ui.t('reports.subtitle') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row align-items-center g-3"><div class="col"><h2 class="page-title mb-1">{{ ui.t('reports.title') }}</h2><div class="text-secondary">{{ ui.t('reports.subtitle') }}</div></div></div>
|
||||
</div>
|
||||
|
||||
<div class="row row-cards">
|
||||
<div class="col-lg-5">
|
||||
<div class="card pv-card overflow-visible">
|
||||
<div class="card pv-card overflow-visible mb-3">
|
||||
<div class="card-header"><h3 class="card-title">{{ ui.t('reports.emailTitle') }}</h3></div>
|
||||
<div class="card-body">
|
||||
<form [formGroup]="form" (ngSubmit)="save()" class="d-grid gap-3">
|
||||
<label class="form-check">
|
||||
<input class="form-check-input" type="checkbox" formControlName="enabled" />
|
||||
<span class="form-check-label">{{ ui.t('reports.enable') }}</span>
|
||||
</label>
|
||||
<div>
|
||||
<label class="form-label">{{ ui.t('reports.frequency') }}</label>
|
||||
<select class="form-select" formControlName="frequency">
|
||||
<option value="monthly">{{ ui.t('reports.frequency.monthly') }}</option>
|
||||
<option value="yearly">{{ ui.t('reports.frequency.yearly') }}</option>
|
||||
<option value="threshold">{{ ui.t('reports.frequency.threshold') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">{{ ui.t('reports.targetEmail') }}</label>
|
||||
<input class="form-control" formControlName="sendToEmail" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">{{ ui.t('reports.threshold') }}</label>
|
||||
<input class="form-control" type="number" step="0.01" formControlName="thresholdAmount" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">{{ ui.t('reports.categories') }}</label>
|
||||
<app-category-picker
|
||||
[items]="categories()"
|
||||
[selectedIds]="form.getRawValue().categoryIds"
|
||||
[placeholder]="ui.t('expenses.allCategories')"
|
||||
(changed)="setCategoryIds($event)"></app-category-picker>
|
||||
</div>
|
||||
<div class="btn-list flex-wrap">
|
||||
<button class="btn btn-success d-inline-flex align-items-center gap-2" [disabled]="form.invalid" type="submit">
|
||||
<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>{{ ui.t('action.save') }}</span>
|
||||
</button>
|
||||
<button class="btn btn-outline-info" type="button" (click)="preview()">{{ ui.t('action.refreshPreview') }}</button>
|
||||
<button class="btn btn-warning" type="button" (click)="send()">{{ ui.t('action.sendNow') }}</button>
|
||||
</div>
|
||||
<label class="form-check"><input class="form-check-input" type="checkbox" formControlName="enabled" /><span class="form-check-label">{{ ui.t('reports.enable') }}</span></label>
|
||||
<div><label class="form-label">{{ ui.t('reports.frequency') }}</label><select class="form-select" formControlName="frequency"><option value="monthly">{{ ui.t('reports.frequency.monthly') }}</option><option value="yearly">{{ ui.t('reports.frequency.yearly') }}</option><option value="threshold">{{ ui.t('reports.frequency.threshold') }}</option></select></div>
|
||||
<div><label class="form-label">{{ ui.t('reports.targetEmail') }}</label><input class="form-control" formControlName="sendToEmail" /></div>
|
||||
<div><label class="form-label">{{ ui.t('reports.threshold') }}</label><input class="form-control" type="number" step="0.01" formControlName="thresholdAmount" /></div>
|
||||
<div><label class="form-label">{{ ui.t('reports.categories') }}</label><app-category-picker [items]="categories()" [selectedIds]="form.getRawValue().categoryIds" [placeholder]="ui.t('expenses.allCategories')" (changed)="setCategoryIds($event)"></app-category-picker></div>
|
||||
<div class="btn-list flex-wrap"><button class="btn btn-success" [disabled]="form.invalid" type="submit">{{ ui.t('action.save') }}</button><button class="btn btn-outline-info" type="button" (click)="preview()">{{ ui.t('action.refreshPreview') }}</button><button class="btn btn-warning" type="button" (click)="send()">{{ ui.t('action.sendNow') }}</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card pv-card overflow-hidden">
|
||||
<div class="card-header"><h3 class="card-title">{{ ui.t('reports.exportTitle') }}</h3></div>
|
||||
<div class="card-body">
|
||||
<form [formGroup]="exportForm" class="row g-3 align-items-end">
|
||||
<div class="col-md-6"><label class="form-label">{{ ui.t('stats.from') }}</label><input class="form-control" type="date" formControlName="startDate" /></div>
|
||||
<div class="col-md-6"><label class="form-label">{{ ui.t('stats.to') }}</label><input class="form-control" type="date" formControlName="endDate" /></div>
|
||||
<div class="col-md-6"><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-md-6"><label class="form-label">{{ ui.t('expenses.field.tags') }}</label><input class="form-control" formControlName="tag" [placeholder]="ui.t('expenses.tagPlaceholder')" /></div>
|
||||
<div class="col-12"><label class="form-label">{{ ui.t('reports.categories') }}</label><app-category-picker [items]="categories()" [selectedIds]="exportForm.getRawValue().categoryIds" [placeholder]="ui.t('expenses.allCategories')" (changed)="setExportCategoryIds($event)"></app-category-picker></div>
|
||||
<div class="col-12"><div class="btn-list flex-wrap"><button class="btn btn-outline-primary" type="button" (click)="download('csv')">CSV</button><button class="btn btn-outline-primary" type="button" (click)="download('json')">JSON</button><button class="btn btn-outline-primary" type="button" (click)="download('html')">HTML</button><button class="btn btn-outline-primary" type="button" (click)="download('pdf')">PDF</button></div></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,11 +59,7 @@ import { CategoryPickerComponent } from '../../shared/ui/category-picker.compone
|
||||
<div class="col-sm-4"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('stats.average') }}</div><div class="h1">{{ summary()!.average.toFixed(2) }}</div></div></div></div>
|
||||
</div>
|
||||
}
|
||||
<div class="card bg-body-tertiary overflow-hidden">
|
||||
<div class="card-body">
|
||||
<div [innerHTML]="html()"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-body-tertiary overflow-hidden"><div class="card-body"><div [innerHTML]="html()"></div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -102,59 +77,33 @@ export class ReportsComponent implements OnInit {
|
||||
readonly html = signal(`<div class="text-secondary">${this.ui.t('reports.noData')}</div>`);
|
||||
readonly summary = signal<StatsResponse | null>(null);
|
||||
|
||||
readonly form = this.fb.nonNullable.group({
|
||||
enabled: [false],
|
||||
frequency: ['monthly' as ReportPreferences['frequency'], Validators.required],
|
||||
sendToEmail: ['', Validators.required],
|
||||
thresholdAmount: [0],
|
||||
categoryIds: [[] as string[]]
|
||||
});
|
||||
readonly form = this.fb.nonNullable.group({ enabled: [false], frequency: ['monthly' as ReportPreferences['frequency'], Validators.required], sendToEmail: ['', Validators.required], thresholdAmount: [0], categoryIds: [[] as string[]] });
|
||||
readonly exportForm = this.fb.nonNullable.group({ startDate: [''], endDate: [''], status: [''], tag: [''], categoryIds: [[] as string[]] });
|
||||
|
||||
ngOnInit() {
|
||||
this.categoriesService.ensureLoaded(true);
|
||||
this.reports.getPreferences().subscribe({
|
||||
next: (response) => {
|
||||
this.form.reset({
|
||||
enabled: response.item.enabled,
|
||||
frequency: response.item.frequency,
|
||||
sendToEmail: response.item.sendToEmail ?? '',
|
||||
thresholdAmount: response.item.thresholdAmount,
|
||||
categoryIds: response.item.categoryIds ?? []
|
||||
});
|
||||
this.preview();
|
||||
}
|
||||
});
|
||||
this.reports.getPreferences().subscribe({ next: (response) => { this.form.reset({ enabled: response.item.enabled, frequency: response.item.frequency, sendToEmail: response.item.sendToEmail ?? '', thresholdAmount: response.item.thresholdAmount, categoryIds: response.item.categoryIds ?? [] }); this.preview(); } });
|
||||
}
|
||||
|
||||
setCategoryIds(categoryIds: string[]) {
|
||||
this.form.patchValue({ categoryIds });
|
||||
}
|
||||
setCategoryIds(categoryIds: string[]) { this.form.patchValue({ categoryIds }); }
|
||||
setExportCategoryIds(categoryIds: string[]) { this.exportForm.patchValue({ categoryIds }); }
|
||||
|
||||
save() {
|
||||
if (this.form.invalid) return;
|
||||
this.reports.updatePreferences(this.form.getRawValue()).subscribe({
|
||||
next: () => {
|
||||
this.toast.success(this.ui.t('reports.saved'));
|
||||
this.preview();
|
||||
save() { if (this.form.invalid) return; this.reports.updatePreferences(this.form.getRawValue()).subscribe({ next: () => { this.toast.success(this.ui.t('reports.saved')); this.preview(); }, error: (error) => this.toast.error(error.error?.message ?? this.ui.t('reports.saveError')) }); }
|
||||
preview() { this.reports.preview(this.form.getRawValue()).subscribe({ next: (response) => { this.summary.set(response.summary); this.html.set(response.html || `<div class="text-secondary">${this.ui.t('reports.noData')}</div>`); }, error: (error) => this.toast.error(error.error?.message ?? this.ui.t('reports.previewError')) }); }
|
||||
send() { this.reports.send().subscribe({ next: (response) => this.toast.success(this.ui.t('reports.sentTo', { email: response.sentTo })), error: (error) => this.toast.error(error.error?.message ?? this.ui.t('reports.sendError')) }); }
|
||||
|
||||
download(format: 'csv' | 'json' | 'html' | 'pdf') {
|
||||
const raw = this.exportForm.getRawValue();
|
||||
this.reports.export({ format, startDate: raw.startDate || undefined, endDate: raw.endDate || undefined, status: raw.status || undefined, tag: raw.tag || undefined, categoryIds: raw.categoryIds.join(',') || undefined }).subscribe({
|
||||
next: (blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = `expense-report.${format}`;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('reports.saveError'))
|
||||
});
|
||||
}
|
||||
|
||||
preview() {
|
||||
this.reports.preview(this.form.getRawValue()).subscribe({
|
||||
next: (response) => {
|
||||
this.summary.set(response.summary);
|
||||
this.html.set(response.html || `<div class="text-secondary">${this.ui.t('reports.noData')}</div>`);
|
||||
},
|
||||
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('reports.previewError'))
|
||||
});
|
||||
}
|
||||
|
||||
send() {
|
||||
this.reports.send().subscribe({
|
||||
next: (response) => this.toast.success(this.ui.t('reports.sentTo', { email: response.sentTo })),
|
||||
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('reports.sendError'))
|
||||
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('reports.exportError'))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +1,14 @@
|
||||
import { CommonModule, CurrencyPipe } from '@angular/common';
|
||||
import { AfterViewChecked, Component, OnDestroy, OnInit, inject, signal } from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
|
||||
import {
|
||||
Chart,
|
||||
DoughnutController,
|
||||
ArcElement,
|
||||
Tooltip,
|
||||
Legend,
|
||||
LineController,
|
||||
LineElement,
|
||||
PointElement,
|
||||
CategoryScale,
|
||||
LinearScale
|
||||
} from 'chart.js';
|
||||
import { Chart, DoughnutController, ArcElement, Tooltip, Legend, LineController, LineElement, PointElement, CategoryScale, LinearScale } from 'chart.js';
|
||||
import { CategoriesService } from '../../core/services/categories.service';
|
||||
import { StatsService } from '../../core/services/stats.service';
|
||||
import { UiService } from '../../core/services/ui.service';
|
||||
import type { StatsResponse } from '../../shared/models';
|
||||
import { CategoryPickerComponent } from '../../shared/ui/category-picker.component';
|
||||
|
||||
Chart.register(
|
||||
DoughnutController,
|
||||
ArcElement,
|
||||
Tooltip,
|
||||
Legend,
|
||||
LineController,
|
||||
LineElement,
|
||||
PointElement,
|
||||
CategoryScale,
|
||||
LinearScale
|
||||
);
|
||||
|
||||
Chart.register(DoughnutController, ArcElement, Tooltip, Legend, LineController, LineElement, PointElement, CategoryScale, LinearScale);
|
||||
const chartPalette = ['#206bc4', '#2fb344', '#f59f00', '#d63939', '#9b4dca', '#4263eb', '#0ca678', '#e8590c'];
|
||||
|
||||
@Component({
|
||||
@@ -38,105 +16,28 @@ const chartPalette = ['#206bc4', '#2fb344', '#f59f00', '#d63939', '#9b4dca', '#4
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, CurrencyPipe, CategoryPickerComponent],
|
||||
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('stats.title') }}</h2>
|
||||
<div class="text-secondary">{{ ui.t('stats.subtitle') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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('stats.title') }}</h2><div class="text-secondary">{{ ui.t('stats.subtitle') }}</div></div></div></div>
|
||||
|
||||
<div class="row row-cards">
|
||||
<div class="col-12">
|
||||
<div class="card overflow-visible">
|
||||
<div class="card-body">
|
||||
<form [formGroup]="form" (ngSubmit)="load()" class="row g-3 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">{{ ui.t('stats.period') }}</label>
|
||||
<select class="form-select" formControlName="bucket">
|
||||
<option value="month">{{ ui.t('stats.period.month') }}</option>
|
||||
<option value="quarter">{{ ui.t('stats.period.quarter') }}</option>
|
||||
<option value="year">{{ ui.t('stats.period.year') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3"><label class="form-label">{{ ui.t('stats.from') }}</label><input class="form-control" type="date" formControlName="startDate" /></div>
|
||||
<div class="col-md-3"><label class="form-label">{{ ui.t('stats.to') }}</label><input class="form-control" type="date" formControlName="endDate" /></div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">{{ ui.t('reports.categories') }}</label>
|
||||
<app-category-picker
|
||||
[items]="categories()"
|
||||
[selectedIds]="form.getRawValue().categoryIds"
|
||||
[placeholder]="ui.t('expenses.allCategories')"
|
||||
(changed)="setCategoryIds($event)"></app-category-picker>
|
||||
</div>
|
||||
<div class="col-12 d-flex gap-2 flex-wrap">
|
||||
<button class="btn btn-success d-inline-flex align-items-center gap-2" type="submit">
|
||||
<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>{{ ui.t('action.show') }}</span>
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" type="button" (click)="reset()">{{ ui.t('action.reset') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12"><div class="card overflow-visible"><div class="card-body"><form [formGroup]="form" (ngSubmit)="load()" class="row g-3 align-items-end">
|
||||
<div class="col-md-2"><label class="form-label">{{ ui.t('stats.period') }}</label><select class="form-select" formControlName="bucket"><option value="month">{{ ui.t('stats.period.month') }}</option><option value="quarter">{{ ui.t('stats.period.quarter') }}</option><option value="year">{{ ui.t('stats.period.year') }}</option></select></div>
|
||||
<div class="col-md-2"><label class="form-label">{{ ui.t('stats.from') }}</label><input class="form-control" type="date" formControlName="startDate" /></div>
|
||||
<div class="col-md-2"><label class="form-label">{{ ui.t('stats.to') }}</label><input class="form-control" type="date" formControlName="endDate" /></div>
|
||||
<div class="col-md-3"><label class="form-label">{{ ui.t('reports.categories') }}</label><app-category-picker [items]="categories()" [selectedIds]="form.getRawValue().categoryIds" [placeholder]="ui.t('expenses.allCategories')" (changed)="setCategoryIds($event)"></app-category-picker></div>
|
||||
<div class="col-md-1"><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-md-2"><label class="form-label">{{ ui.t('expenses.field.tags') }}</label><input class="form-control" formControlName="tag" [placeholder]="ui.t('expenses.tagPlaceholder')" /></div>
|
||||
<div class="col-12 d-flex gap-2 flex-wrap"><button class="btn btn-success" type="submit">{{ ui.t('action.show') }}</button><button class="btn btn-outline-secondary" type="button" (click)="reset()">{{ ui.t('action.reset') }}</button></div>
|
||||
</form></div></div></div>
|
||||
|
||||
<div class="col-md-4"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('stats.sum') }}</div><div class="display-6">{{ (stats()?.total || 0) | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
|
||||
<div class="col-md-4"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('dashboard.count') }}</div><div class="display-6">{{ stats()?.count || 0 }}</div></div></div></div>
|
||||
<div class="col-md-4"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('stats.average') }}</div><div class="display-6">{{ (stats()?.average || 0) | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
|
||||
|
||||
<div class="col-lg-6 d-flex align-items-stretch">
|
||||
<div class="card pv-card h-100 w-100 overflow-hidden">
|
||||
<div class="card-header"><h3 class="card-title">{{ ui.t('stats.share') }}</h3></div>
|
||||
<div class="card-body">
|
||||
@if (hasCategoryData()) {
|
||||
<div class="ec-chart-wrap ec-chart-wrap-sm">
|
||||
<canvas id="statsCategoryChart"></canvas>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="alert alert-info mb-0">{{ ui.t('stats.noCategoryChart') }}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6 d-flex align-items-stretch"><div class="card pv-card h-100 w-100 overflow-hidden"><div class="card-header"><h3 class="card-title">{{ ui.t('stats.share') }}</h3></div><div class="card-body">@if (hasCategoryData()) { <div class="ec-chart-wrap ec-chart-wrap-sm"><canvas id="statsCategoryChart"></canvas></div> } @else { <div class="alert alert-info mb-0">{{ ui.t('stats.noCategoryChart') }}</div> }</div></div></div>
|
||||
<div class="col-lg-6 d-flex align-items-stretch"><div class="card pv-card h-100 w-100 overflow-hidden"><div class="card-header"><h3 class="card-title">{{ ui.t('stats.trend') }}</h3></div><div class="card-body">@if (hasTimelineData()) { <div class="ec-chart-wrap ec-chart-wrap-sm"><canvas id="statsLineChart"></canvas></div> } @else { <div class="alert alert-info mb-0">{{ ui.t('stats.noTrendChart') }}</div> }</div></div></div>
|
||||
|
||||
<div class="col-lg-6 d-flex align-items-stretch">
|
||||
<div class="card pv-card h-100 w-100 overflow-hidden">
|
||||
<div class="card-header"><h3 class="card-title">{{ ui.t('stats.trend') }}</h3></div>
|
||||
<div class="card-body">
|
||||
@if (hasTimelineData()) {
|
||||
<div class="ec-chart-wrap ec-chart-wrap-sm">
|
||||
<canvas id="statsLineChart"></canvas>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="alert alert-info mb-0">{{ ui.t('stats.noTrendChart') }}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="card overflow-hidden">
|
||||
<div class="card-header"><h3 class="card-title">{{ ui.t('stats.breakdown') }}</h3></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter card-table mb-0">
|
||||
<thead><tr><th>{{ ui.t('table.category') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th><th class="text-end">{{ ui.t('table.count') }}</th></tr></thead>
|
||||
<tbody>
|
||||
@for (row of stats()?.byCategory || []; track row.categoryId) {
|
||||
<tr>
|
||||
<td>{{ row.categoryName }}</td>
|
||||
<td class="text-end">{{ row.total | currency:'PLN':'symbol':'1.2-2' }}</td>
|
||||
<td class="text-end">{{ row.count }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr><td colspan="3" class="text-secondary">{{ ui.t('common.noData') }}</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6"><div class="card overflow-hidden"><div class="card-header"><h3 class="card-title">{{ ui.t('stats.breakdown') }}</h3></div><div class="table-responsive"><table class="table table-vcenter card-table mb-0"><thead><tr><th>{{ ui.t('table.category') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th><th class="text-end">{{ ui.t('table.count') }}</th></tr></thead><tbody>@for (row of stats()?.byCategory || []; track row.categoryId) { <tr><td>{{ row.categoryName }}</td><td class="text-end">{{ row.total | currency:'PLN':'symbol':'1.2-2' }}</td><td class="text-end">{{ row.count }}</td></tr> } @empty { <tr><td colspan="3" class="text-secondary">{{ ui.t('common.noData') }}</td></tr> }</tbody></table></div></div></div>
|
||||
<div class="col-lg-6"><div class="card overflow-hidden"><div class="card-header"><h3 class="card-title">{{ ui.t('stats.tags') }}</h3></div><div class="table-responsive"><table class="table table-vcenter card-table mb-0"><thead><tr><th>{{ ui.t('expenses.field.tags') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th></tr></thead><tbody>@for (row of stats()?.byTag || []; track row.tag) { <tr><td>#{{ row.tag }}</td><td class="text-end">{{ row.total | currency:'PLN':'symbol':'1.2-2' }}</td></tr> } @empty { <tr><td colspan="2" class="text-secondary">{{ ui.t('common.noData') }}</td></tr> }</tbody></table></div></div></div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
@@ -152,63 +53,21 @@ export class StatsComponent implements OnInit, AfterViewChecked, OnDestroy {
|
||||
private lineChart?: Chart;
|
||||
private chartsPending = false;
|
||||
|
||||
readonly form = this.fb.nonNullable.group({
|
||||
bucket: ['month' as 'month' | 'quarter' | 'year'],
|
||||
startDate: [''],
|
||||
endDate: [''],
|
||||
categoryIds: [[] as string[]]
|
||||
});
|
||||
readonly form = this.fb.nonNullable.group({ bucket: ['month' as 'month' | 'quarter' | 'year'], startDate: [''], endDate: [''], categoryIds: [[] as string[]], status: [''], tag: [''] });
|
||||
|
||||
ngOnInit() {
|
||||
this.categoriesService.ensureLoaded(true);
|
||||
this.load();
|
||||
}
|
||||
|
||||
ngAfterViewChecked() {
|
||||
if (this.chartsPending) {
|
||||
this.chartsPending = false;
|
||||
this.renderCharts();
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.categoryChart?.destroy();
|
||||
this.lineChart?.destroy();
|
||||
}
|
||||
|
||||
setCategoryIds(categoryIds: string[]) {
|
||||
this.form.patchValue({ categoryIds });
|
||||
}
|
||||
ngOnInit() { this.categoriesService.ensureLoaded(true); this.load(); }
|
||||
ngAfterViewChecked() { if (this.chartsPending) { this.chartsPending = false; this.renderCharts(); } }
|
||||
ngOnDestroy() { this.categoryChart?.destroy(); this.lineChart?.destroy(); }
|
||||
setCategoryIds(categoryIds: string[]) { this.form.patchValue({ categoryIds }); }
|
||||
hasCategoryData() { return Boolean(this.stats()?.byCategory?.length); }
|
||||
hasTimelineData() { return Boolean(this.stats()?.timeline?.length); }
|
||||
|
||||
load() {
|
||||
const raw = this.form.getRawValue();
|
||||
this.statsService
|
||||
.overview({
|
||||
startDate: raw.startDate || undefined,
|
||||
endDate: raw.endDate || undefined,
|
||||
categoryIds: raw.categoryIds.join(',') || undefined,
|
||||
bucket: raw.bucket
|
||||
})
|
||||
.subscribe({
|
||||
next: (response) => {
|
||||
this.stats.set(response);
|
||||
this.chartsPending = true;
|
||||
}
|
||||
});
|
||||
this.statsService.overview({ startDate: raw.startDate || undefined, endDate: raw.endDate || undefined, categoryIds: raw.categoryIds.join(',') || undefined, bucket: raw.bucket, status: raw.status || undefined, tag: raw.tag || undefined }).subscribe({ next: (response) => { this.stats.set(response); this.chartsPending = true; } });
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.form.reset({ bucket: 'month', startDate: '', endDate: '', categoryIds: [] });
|
||||
this.load();
|
||||
}
|
||||
|
||||
hasCategoryData() {
|
||||
return Boolean(this.stats()?.byCategory?.length);
|
||||
}
|
||||
|
||||
hasTimelineData() {
|
||||
return Boolean(this.stats()?.timeline?.length);
|
||||
}
|
||||
reset() { this.form.reset({ bucket: 'month', startDate: '', endDate: '', categoryIds: [], status: '', tag: '' }); this.load(); }
|
||||
|
||||
private renderCharts() {
|
||||
const current = this.stats();
|
||||
@@ -218,82 +77,12 @@ export class StatsComponent implements OnInit, AfterViewChecked, OnDestroy {
|
||||
if (categoryCanvas && current?.byCategory?.length) {
|
||||
const colors = current.byCategory.map((_, index) => chartPalette[index % chartPalette.length]);
|
||||
this.categoryChart?.destroy();
|
||||
this.categoryChart = new Chart(categoryCanvas, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: current.byCategory.map((item) => item.categoryName),
|
||||
datasets: [
|
||||
{
|
||||
data: current.byCategory.map((item) => item.total),
|
||||
backgroundColor: colors,
|
||||
borderColor: '#ffffff',
|
||||
hoverOffset: 10
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
cutout: '64%',
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
boxWidth: 10,
|
||||
color: '#9ca3af'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.categoryChart?.destroy();
|
||||
}
|
||||
this.categoryChart = new Chart(categoryCanvas, { type: 'doughnut', data: { labels: current.byCategory.map((item) => item.categoryName), datasets: [{ data: current.byCategory.map((item) => item.total), backgroundColor: colors, borderColor: '#ffffff', hoverOffset: 10 }] }, options: { maintainAspectRatio: false, cutout: '64%', plugins: { legend: { position: 'bottom', labels: { usePointStyle: true, boxWidth: 10, color: '#9ca3af' } } } } });
|
||||
} else this.categoryChart?.destroy();
|
||||
|
||||
if (lineCanvas && current?.timeline?.length) {
|
||||
this.lineChart?.destroy();
|
||||
this.lineChart = new Chart(lineCanvas, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: current.timeline.map((item) => item.label),
|
||||
datasets: [
|
||||
{
|
||||
label: this.ui.t('stats.expensesLabel'),
|
||||
data: current.timeline.map((item) => item.total),
|
||||
tension: 0.35,
|
||||
borderColor: '#206bc4',
|
||||
backgroundColor: 'rgba(32,107,196,0.18)',
|
||||
pointBackgroundColor: '#2fb344',
|
||||
pointBorderColor: '#ffffff',
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6,
|
||||
fill: true
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: '#9ca3af'
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: { color: '#9ca3af' },
|
||||
grid: { color: 'rgba(148,163,184,0.16)' }
|
||||
},
|
||||
y: {
|
||||
ticks: { color: '#9ca3af' },
|
||||
grid: { color: 'rgba(148,163,184,0.16)' }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.lineChart?.destroy();
|
||||
}
|
||||
this.lineChart = new Chart(lineCanvas, { type: 'line', data: { labels: current.timeline.map((item) => item.label), datasets: [{ label: this.ui.t('stats.expensesLabel'), data: current.timeline.map((item) => item.total), borderColor: '#206bc4', backgroundColor: '#206bc4', tension: 0.3 }] }, options: { maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { ticks: { color: '#9ca3af' } }, y: { ticks: { color: '#9ca3af' } } } } });
|
||||
} else this.lineChart?.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,88 +14,36 @@ import { UiService } from '../core/services/ui.service';
|
||||
<header class="navbar navbar-expand-md d-print-none pv-navbar">
|
||||
<div class="container-xl gap-3">
|
||||
<div class="navbar-brand navbar-brand-autodark fw-bold">{{ appSettings.appName() }}</div>
|
||||
|
||||
<div class="ms-auto d-flex align-items-center gap-2 flex-wrap justify-content-end">
|
||||
<nav class="nav nav-segmented ec-segmented-control" role="tablist" [attr.aria-label]="ui.t('lang.label')">
|
||||
<button class="nav-link"
|
||||
type="button"
|
||||
role="tab"
|
||||
[class.active]="ui.language() === 'pl'"
|
||||
[attr.aria-selected]="ui.language() === 'pl'"
|
||||
[attr.aria-current]="ui.language() === 'pl' ? 'page' : null"
|
||||
(click)="ui.setLanguage('pl')">PL</button>
|
||||
<button class="nav-link"
|
||||
type="button"
|
||||
role="tab"
|
||||
[class.active]="ui.language() === 'en'"
|
||||
[attr.aria-selected]="ui.language() === 'en'"
|
||||
[attr.aria-current]="ui.language() === 'en' ? 'page' : null"
|
||||
(click)="ui.setLanguage('en')">EN</button>
|
||||
<button class="nav-link" type="button" role="tab" [class.active]="ui.language() === 'pl'" (click)="ui.setLanguage('pl')">PL</button>
|
||||
<button class="nav-link" type="button" role="tab" [class.active]="ui.language() === 'en'" (click)="ui.setLanguage('en')">EN</button>
|
||||
</nav>
|
||||
|
||||
<nav class="nav nav-segmented ec-segmented-control" role="tablist" [attr.aria-label]="ui.t('theme.label')">
|
||||
<button class="nav-link d-inline-flex align-items-center gap-2"
|
||||
type="button"
|
||||
role="tab"
|
||||
[class.active]="ui.theme() === 'dark'"
|
||||
[attr.aria-selected]="ui.theme() === 'dark'"
|
||||
[attr.aria-current]="ui.theme() === 'dark' ? 'page' : null"
|
||||
(click)="ui.setTheme('dark')">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-sm" width="16" height="16" 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="M12 3c.132 0 .263 0 .393 .007a8.5 8.5 0 0 0 0 16.986a9 9 0 1 1 -.393 -17z"/></svg>
|
||||
<span>{{ ui.t('theme.dark') }}</span>
|
||||
</button>
|
||||
<button class="nav-link d-inline-flex align-items-center gap-2"
|
||||
type="button"
|
||||
role="tab"
|
||||
[class.active]="ui.theme() === 'light'"
|
||||
[attr.aria-selected]="ui.theme() === 'light'"
|
||||
[attr.aria-current]="ui.theme() === 'light' ? 'page' : null"
|
||||
(click)="ui.setTheme('light')">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-sm" width="16" height="16" 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="M12 3c.132 0 .263 0 .393 .007a9 9 0 1 0 0 17.986a9 9 0 0 0 -.393 -17.993z"/><path d="M12 3v1"/><path d="M12 20v1"/><path d="M3 12h1"/><path d="M20 12h1"/><path d="M5.6 5.6l.7 .7"/><path d="M17.7 17.7l.7 .7"/><path d="M17.7 6.3l.7 -.7"/><path d="M6.3 17.7l-.7 .7"/></svg>
|
||||
<span>{{ ui.t('theme.light') }}</span>
|
||||
</button>
|
||||
<button class="nav-link" type="button" role="tab" [class.active]="ui.theme() === 'dark'" (click)="ui.setTheme('dark')">{{ ui.t('theme.dark') }}</button>
|
||||
<button class="nav-link" type="button" role="tab" [class.active]="ui.theme() === 'light'" (click)="ui.setTheme('light')">{{ ui.t('theme.light') }}</button>
|
||||
</nav>
|
||||
|
||||
<div class="pv-navbar-user text-end me-1">
|
||||
<div class="fw-semibold">{{ auth.currentUser()?.fullName }}</div>
|
||||
<div class="small text-secondary">{{ auth.currentUser()?.email }}</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-danger btn-sm d-inline-flex align-items-center gap-2 px-3 flex-shrink-0" type="button" (click)="logout()">
|
||||
<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="M14 8v-2a2 2 0 0 0 -2 -2h-6a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h6a2 2 0 0 0 2 -2v-2"/><path d="M9 12h12l-3 -3"/><path d="M18 15l3 -3"/></svg>
|
||||
<span>{{ ui.t('action.logout') }}</span>
|
||||
</button>
|
||||
<div class="pv-navbar-user text-end me-1"><div class="fw-semibold">{{ auth.currentUser()?.fullName }}</div><div class="small text-secondary">{{ auth.currentUser()?.email }}</div></div>
|
||||
<button class="btn btn-danger btn-sm px-3 flex-shrink-0" type="button" (click)="logout()">{{ ui.t('action.logout') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="pv-subnav">
|
||||
<div class="container-xl">
|
||||
<div class="pv-subnav-shell">
|
||||
<div class="pv-subnav-main">
|
||||
<nav class="pv-subnav-tabs nav nav-pills">
|
||||
<a class="nav-link" routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">{{ ui.t('nav.dashboard') }}</a>
|
||||
<a class="nav-link" routerLink="/expenses" routerLinkActive="active">{{ ui.t('nav.expenses') }}</a>
|
||||
<a class="nav-link" routerLink="/stats" routerLinkActive="active">{{ ui.t('nav.stats') }}</a>
|
||||
<a class="nav-link" routerLink="/merchants" routerLinkActive="active">{{ ui.t('nav.merchants') }}</a>
|
||||
<a class="nav-link" routerLink="/reports" routerLinkActive="active">{{ ui.t('nav.reports') }}</a>
|
||||
<a class="nav-link" routerLink="/categories" routerLinkActive="active">{{ ui.t('nav.categories') }}</a>
|
||||
@if (auth.isAdmin()) {
|
||||
<a class="nav-link" routerLink="/admin" routerLinkActive="active">{{ ui.t('nav.admin') }}</a>
|
||||
}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pv-subnav"><div class="container-xl"><div class="pv-subnav-shell"><div class="pv-subnav-main"><nav class="pv-subnav-tabs nav nav-pills flex-wrap gap-1">
|
||||
<a class="nav-link" routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">{{ ui.t('nav.dashboard') }}</a>
|
||||
<a class="nav-link" routerLink="/expenses" routerLinkActive="active">{{ ui.t('nav.expenses') }}</a>
|
||||
<a class="nav-link" routerLink="/stats" routerLinkActive="active">{{ ui.t('nav.stats') }}</a>
|
||||
<a class="nav-link" routerLink="/cashflow" routerLinkActive="active">{{ ui.t('nav.cashflow') }}</a>
|
||||
<a class="nav-link" routerLink="/budgets" routerLinkActive="active">{{ ui.t('nav.budgets') }}</a>
|
||||
<a class="nav-link" routerLink="/recurring" routerLinkActive="active">{{ ui.t('nav.recurring') }}</a>
|
||||
<a class="nav-link" routerLink="/merchants" routerLinkActive="active">{{ ui.t('nav.merchants') }}</a>
|
||||
<a class="nav-link" routerLink="/reports" routerLinkActive="active">{{ ui.t('nav.reports') }}</a>
|
||||
<a class="nav-link" routerLink="/categories" routerLinkActive="active">{{ ui.t('nav.categories') }}</a>
|
||||
<a class="nav-link" routerLink="/integrations" routerLinkActive="active">{{ ui.t('nav.integrations') }}</a>
|
||||
@if (auth.isAdmin()) { <a class="nav-link" routerLink="/admin" routerLinkActive="active">{{ ui.t('nav.admin') }}</a> }
|
||||
</nav></div></div></div></div>
|
||||
|
||||
<div class="page-wrapper">
|
||||
<div class="page-body">
|
||||
<div class="container-xl">
|
||||
<router-outlet></router-outlet>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-wrapper"><div class="page-body"><div class="container-xl"><router-outlet></router-outlet></div></div></div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
@@ -104,9 +52,5 @@ export class ShellComponent {
|
||||
readonly ui = inject(UiService);
|
||||
readonly appSettings = inject(AppSettingsService);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
logout() {
|
||||
this.auth.logout();
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
logout() { this.auth.logout(); this.router.navigate(['/login']); }
|
||||
}
|
||||
|
||||
@@ -39,6 +39,9 @@ export interface Proof {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export type ExpenseStatus = 'DRAFT' | 'PENDING' | 'APPROVED' | 'REJECTED';
|
||||
export type DuplicateStatus = 'OPEN' | 'CONFIRMED' | 'DISMISSED';
|
||||
|
||||
export interface Expense {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -48,13 +51,24 @@ export interface Expense {
|
||||
merchant: string | null;
|
||||
paymentMethod: 'CARD' | 'CASH' | 'TRANSFER' | 'BLIK' | 'OTHER' | null;
|
||||
currency: string;
|
||||
status: ExpenseStatus;
|
||||
tags: string[];
|
||||
customFields: Record<string, string>;
|
||||
possibleDuplicate: boolean;
|
||||
duplicateStatus: DuplicateStatus | null;
|
||||
duplicateReviewedAt: string | null;
|
||||
recurringSourceId: string | null;
|
||||
category: Category;
|
||||
proofs: Proof[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface DuplicateGroup {
|
||||
source: Expense;
|
||||
matches: Expense[];
|
||||
}
|
||||
|
||||
export interface StatsResponse {
|
||||
total: number;
|
||||
count: number;
|
||||
@@ -62,6 +76,72 @@ export interface StatsResponse {
|
||||
topCategory: { categoryId: string; categoryName: string; total: number; count: number } | null;
|
||||
byCategory: Array<{ categoryId: string; categoryName: string; total: number; count: number }>;
|
||||
timeline: Array<{ label: string; total: number }>;
|
||||
byTag?: Array<{ tag: string; total: number }>;
|
||||
byStatus?: Array<{ status: string; count: number }>;
|
||||
}
|
||||
|
||||
export interface CashflowResponse {
|
||||
currentMonth: string;
|
||||
actualCurrent: number;
|
||||
totalBudget: number;
|
||||
budgetUsagePercent: number;
|
||||
duplicateCount: number;
|
||||
pendingApproval: number;
|
||||
forecastCurrentMonth: number;
|
||||
trend: Array<{ label: string; actual: number; budget: number }>;
|
||||
alerts: Array<{ id: string; name: string; usagePercent: number; spent: number; amount: number }>;
|
||||
upcomingRecurring: Array<{ id: string; title: string; amount: number; nextRunDate: string; frequency: string }>;
|
||||
statusSummary: Array<{ status: string; count: number }>;
|
||||
}
|
||||
|
||||
export interface Budget {
|
||||
id: string;
|
||||
month: string;
|
||||
name: string | null;
|
||||
amount: number;
|
||||
spent: number;
|
||||
usagePercent: number;
|
||||
alertLevel: number | null;
|
||||
alertThresholds: number[];
|
||||
isActive: boolean;
|
||||
category: Category | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface BudgetListResponse {
|
||||
month: string;
|
||||
items: Budget[];
|
||||
summary: {
|
||||
totalBudget: number;
|
||||
totalSpent: number;
|
||||
alerts: Array<{ budgetId: string; message: string; usagePercent: number; level: number }>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RecurringExpense {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
amount: number;
|
||||
merchant: string | null;
|
||||
paymentMethod: 'CARD' | 'CASH' | 'TRANSFER' | 'BLIK' | 'OTHER' | null;
|
||||
currency: string;
|
||||
frequency: 'WEEKLY' | 'MONTHLY' | 'YEARLY';
|
||||
intervalValue: number;
|
||||
startDate: string;
|
||||
nextRunDate: string;
|
||||
lastRunDate: string | null;
|
||||
endDate: string | null;
|
||||
maxOccurrences: number | null;
|
||||
generatedCount: number;
|
||||
defaultStatus: 'DRAFT' | 'PENDING';
|
||||
tags: string[];
|
||||
customFields: Record<string, string>;
|
||||
isActive: boolean;
|
||||
category: Category;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
@@ -90,3 +170,61 @@ export interface ReportPreferences {
|
||||
sendToEmail: string | null;
|
||||
categoryIds: string[];
|
||||
}
|
||||
|
||||
export interface ShoppingListIntegrationSettings {
|
||||
enabled: boolean;
|
||||
baseUrl: string;
|
||||
hasToken: boolean;
|
||||
authMode: 'bearer' | 'x-api-token' | 'both';
|
||||
ownerId: string | null;
|
||||
defaultListId: string | null;
|
||||
}
|
||||
|
||||
export interface ShoppingListSummary {
|
||||
total?: number;
|
||||
amount?: number;
|
||||
count?: number;
|
||||
records?: number;
|
||||
meta?: { total_amount?: number; total_count?: number; returned_count?: number; [key: string]: unknown };
|
||||
lists?: Array<{ id?: string | number; title?: string; name?: string; total_amount?: number; total?: number; expense_count?: number; count?: number }>;
|
||||
totals?: Array<{ list_id?: string | number; listId?: string | number; name?: string; total?: number; amount?: number; count?: number }>;
|
||||
aggregates?: Array<{ list_id?: string | number; listId?: string | number; name?: string; total?: number; amount?: number; count?: number }>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ShoppingListRef {
|
||||
id: string | number;
|
||||
name?: string;
|
||||
title?: string;
|
||||
created_at?: string;
|
||||
categories?: string[];
|
||||
owner_id?: string | number;
|
||||
ownerId?: string | number;
|
||||
owner?: { id?: string | number; username?: string; name?: string; fullName?: string; email?: string };
|
||||
is_active?: boolean;
|
||||
is_archived?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ShoppingListExpenseItem {
|
||||
id?: string | number;
|
||||
expense_id?: string | number;
|
||||
title?: string;
|
||||
name?: string;
|
||||
amount?: number;
|
||||
total?: number;
|
||||
created_at?: string;
|
||||
added_at?: string;
|
||||
expense_date?: string;
|
||||
receipt_filename?: string;
|
||||
list?: ShoppingListRef;
|
||||
owner?: { id?: string | number; username?: string; name?: string; fullName?: string; email?: string };
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ShoppingListTemplate {
|
||||
id: string;
|
||||
name?: string;
|
||||
title?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -395,8 +395,16 @@ body {
|
||||
|
||||
|
||||
|
||||
.badge {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.badge * {
|
||||
color: inherit !important;
|
||||
}
|
||||
|
||||
.ec-picker-badge {
|
||||
color: var(--tblr-body-color);
|
||||
color: #fff !important;
|
||||
background: rgba(var(--tblr-secondary-rgb), 0.16);
|
||||
border: 1px solid rgba(var(--tblr-secondary-rgb), 0.18);
|
||||
}
|
||||
@@ -407,8 +415,12 @@ body {
|
||||
border: 1px solid rgba(255, 255, 255, 0.28);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .badge {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .ec-picker-badge {
|
||||
color: #f8fafc;
|
||||
color: #fff !important;
|
||||
background: rgba(248, 250, 252, 0.12);
|
||||
border-color: rgba(248, 250, 252, 0.18);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user