first commit
This commit is contained in:
29
web/src/app/core/services/admin.service.ts
Normal file
29
web/src/app/core/services/admin.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 { AppSettings, User } from '../../shared/models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AdminService {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
getSettings() {
|
||||
return this.http.get<{ item: AppSettings }>(`${environment.apiBaseUrl}/admin/settings`);
|
||||
}
|
||||
|
||||
updateSettings(payload: Partial<AppSettings>) {
|
||||
return this.http.put<{ item: AppSettings }>(`${environment.apiBaseUrl}/admin/settings`, payload);
|
||||
}
|
||||
|
||||
listUsers() {
|
||||
return this.http.get<{ items: User[] }>(`${environment.apiBaseUrl}/admin/users`);
|
||||
}
|
||||
|
||||
updateUser(id: string, payload: Partial<User>) {
|
||||
return this.http.patch<{ item: User }>(`${environment.apiBaseUrl}/admin/users/${id}`, payload);
|
||||
}
|
||||
|
||||
testSmtp(to: string) {
|
||||
return this.http.post<{ message: string }>(`${environment.apiBaseUrl}/admin/test-smtp`, { to });
|
||||
}
|
||||
}
|
||||
40
web/src/app/core/services/app-settings.service.ts
Normal file
40
web/src/app/core/services/app-settings.service.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { tap } from 'rxjs/operators';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import type { AppSettings } from '../../shared/models';
|
||||
|
||||
export interface PublicAppConfig {
|
||||
appName: string;
|
||||
registrationEnabled: boolean;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AppSettingsService {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
readonly publicConfig = signal<PublicAppConfig>({
|
||||
appName: 'Expense Control',
|
||||
registrationEnabled: true
|
||||
});
|
||||
|
||||
readonly settings = signal<AppSettings | null>(null);
|
||||
readonly appName = computed(() => this.settings()?.appName || this.publicConfig().appName || 'Expense Control');
|
||||
readonly registrationEnabled = computed(
|
||||
() => this.settings()?.registrationEnabled ?? this.publicConfig().registrationEnabled ?? true
|
||||
);
|
||||
|
||||
loadPublic() {
|
||||
return this.http
|
||||
.get<PublicAppConfig>(`${environment.apiBaseUrl}/auth/config`)
|
||||
.pipe(tap((config) => this.publicConfig.set(config)));
|
||||
}
|
||||
|
||||
applySettings(item: AppSettings) {
|
||||
this.settings.set(item);
|
||||
this.publicConfig.set({
|
||||
appName: item.appName,
|
||||
registrationEnabled: item.registrationEnabled
|
||||
});
|
||||
}
|
||||
}
|
||||
69
web/src/app/core/services/auth.service.ts
Normal file
69
web/src/app/core/services/auth.service.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { tap } from 'rxjs/operators';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import type { User } from '../../shared/models';
|
||||
import type { PublicAppConfig } from './app-settings.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly tokenKey = 'expense-control-token';
|
||||
private readonly userKey = 'expense-control-user';
|
||||
|
||||
readonly token = signal<string | null>(localStorage.getItem(this.tokenKey));
|
||||
readonly currentUser = signal<User | null>(this.readUser());
|
||||
readonly isAuthenticated = computed(() => Boolean(this.token()));
|
||||
readonly isAdmin = computed(() => this.currentUser()?.role === 'ADMIN');
|
||||
|
||||
private readUser(): User | null {
|
||||
const raw = localStorage.getItem(this.userKey);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as User;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
login(payload: { email: string; password: string }) {
|
||||
return this.http
|
||||
.post<{ token: string; user: User }>(`${environment.apiBaseUrl}/auth/login`, payload)
|
||||
.pipe(tap((response) => this.persistSession(response.token, response.user)));
|
||||
}
|
||||
|
||||
register(payload: { fullName: string; email: string; password: string }) {
|
||||
return this.http
|
||||
.post<{ token: string; user: User }>(`${environment.apiBaseUrl}/auth/register`, payload)
|
||||
.pipe(tap((response) => this.persistSession(response.token, response.user)));
|
||||
}
|
||||
|
||||
fetchMe() {
|
||||
return this.http
|
||||
.get<{ user: User }>(`${environment.apiBaseUrl}/auth/me`)
|
||||
.pipe(tap((response) => this.setUser(response.user)));
|
||||
}
|
||||
|
||||
getPublicConfig() {
|
||||
return this.http.get<PublicAppConfig>(`${environment.apiBaseUrl}/auth/config`);
|
||||
}
|
||||
|
||||
logout() {
|
||||
localStorage.removeItem(this.tokenKey);
|
||||
localStorage.removeItem(this.userKey);
|
||||
this.token.set(null);
|
||||
this.currentUser.set(null);
|
||||
}
|
||||
|
||||
private persistSession(token: string, user: User) {
|
||||
localStorage.setItem(this.tokenKey, token);
|
||||
localStorage.setItem(this.userKey, JSON.stringify(user));
|
||||
this.token.set(token);
|
||||
this.currentUser.set(user);
|
||||
}
|
||||
|
||||
private setUser(user: User) {
|
||||
localStorage.setItem(this.userKey, JSON.stringify(user));
|
||||
this.currentUser.set(user);
|
||||
}
|
||||
}
|
||||
52
web/src/app/core/services/categories.service.ts
Normal file
52
web/src/app/core/services/categories.service.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { tap } from 'rxjs/operators';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import type { Category } from '../../shared/models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class CategoriesService {
|
||||
private readonly http = inject(HttpClient);
|
||||
readonly items = signal<Category[]>([]);
|
||||
private loaded = false;
|
||||
|
||||
ensureLoaded(force = false) {
|
||||
if (this.loaded && !force) return;
|
||||
this.list(force).subscribe({ error: () => undefined });
|
||||
}
|
||||
|
||||
list(force = false) {
|
||||
return this.http.get<{ items: Category[] }>(`${environment.apiBaseUrl}/categories`).pipe(
|
||||
tap((response) => {
|
||||
this.items.set(response.items);
|
||||
if (response.items.length || force) this.loaded = true;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
create(payload: { name: string; color: string }) {
|
||||
return this.http.post<{ item: Category }>(`${environment.apiBaseUrl}/categories`, payload).pipe(
|
||||
tap((response) => {
|
||||
this.items.update((items) => [...items, response.item].sort((a, b) => a.name.localeCompare(b.name, 'pl')));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
update(id: string, payload: { name: string; color: string }) {
|
||||
return this.http.put<{ item: Category }>(`${environment.apiBaseUrl}/categories/${id}`, payload).pipe(
|
||||
tap((response) => {
|
||||
this.items.update((items) =>
|
||||
items
|
||||
.map((item) => (item.id === id ? response.item : item))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'pl'))
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
delete(id: string) {
|
||||
return this.http.delete<void>(`${environment.apiBaseUrl}/categories/${id}`).pipe(
|
||||
tap(() => this.items.update((items) => items.filter((item) => item.id !== id)))
|
||||
);
|
||||
}
|
||||
}
|
||||
13
web/src/app/core/services/expenses.service.ts
Normal file
13
web/src/app/core/services/expenses.service.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
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';
|
||||
@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); }
|
||||
}
|
||||
52
web/src/app/core/services/merchants.service.ts
Normal file
52
web/src/app/core/services/merchants.service.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { tap } from 'rxjs/operators';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import type { Merchant } from '../../shared/models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MerchantsService {
|
||||
private readonly http = inject(HttpClient);
|
||||
readonly items = signal<Merchant[]>([]);
|
||||
private loaded = false;
|
||||
|
||||
ensureLoaded(force = false) {
|
||||
if (this.loaded && !force) return;
|
||||
this.list(force).subscribe({ error: () => undefined });
|
||||
}
|
||||
|
||||
list(force = false) {
|
||||
return this.http.get<{ items: Merchant[] }>(`${environment.apiBaseUrl}/merchants`).pipe(
|
||||
tap((response) => {
|
||||
this.items.set(response.items);
|
||||
if (response.items.length || force) this.loaded = true;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
create(payload: { name: string; kind: Merchant['kind']; notes?: string | null; isActive?: boolean }) {
|
||||
return this.http.post<{ item: Merchant }>(`${environment.apiBaseUrl}/merchants`, payload).pipe(
|
||||
tap((response) => {
|
||||
this.items.update((items) => [...items, response.item].sort((a, b) => a.name.localeCompare(b.name, 'pl')));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
update(id: string, payload: { name: string; kind: Merchant['kind']; notes?: string | null; isActive?: boolean }) {
|
||||
return this.http.put<{ item: Merchant }>(`${environment.apiBaseUrl}/merchants/${id}`, payload).pipe(
|
||||
tap((response) => {
|
||||
this.items.update((items) =>
|
||||
items
|
||||
.map((item) => (item.id === id ? response.item : item))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'pl'))
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
delete(id: string) {
|
||||
return this.http.delete<void>(`${environment.apiBaseUrl}/merchants/${id}`).pipe(
|
||||
tap(() => this.items.update((items) => items.filter((item) => item.id !== id)))
|
||||
);
|
||||
}
|
||||
}
|
||||
28
web/src/app/core/services/reports.service.ts
Normal file
28
web/src/app/core/services/reports.service.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import type { ReportPreferences, StatsResponse } from '../../shared/models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ReportsService {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
getPreferences() {
|
||||
return this.http.get<{ item: ReportPreferences }>(`${environment.apiBaseUrl}/reports/preferences`);
|
||||
}
|
||||
|
||||
updatePreferences(payload: ReportPreferences) {
|
||||
return this.http.put<{ item: ReportPreferences }>(`${environment.apiBaseUrl}/reports/preferences`, payload);
|
||||
}
|
||||
|
||||
preview(payload?: ReportPreferences) {
|
||||
return this.http.post<{ range: { startDate: string; endDate: string; label: string }; summary: StatsResponse; html: string }>(
|
||||
`${environment.apiBaseUrl}/reports/preview`,
|
||||
payload ?? {}
|
||||
);
|
||||
}
|
||||
|
||||
send() {
|
||||
return this.http.post<{ message: string; sentTo: string }>(`${environment.apiBaseUrl}/reports/send`, {});
|
||||
}
|
||||
}
|
||||
9
web/src/app/core/services/stats.service.ts
Normal file
9
web/src/app/core/services/stats.service.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import type { 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 }); }
|
||||
}
|
||||
44
web/src/app/core/services/toast.service.ts
Normal file
44
web/src/app/core/services/toast.service.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
export type ToastItem = {
|
||||
id: number;
|
||||
title: string;
|
||||
message: string;
|
||||
tone: 'success' | 'danger' | 'warning' | 'info';
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ToastService {
|
||||
readonly items = signal<ToastItem[]>([]);
|
||||
private counter = 0;
|
||||
|
||||
show(message: string, tone: ToastItem['tone'] = 'info', title = this.defaultTitle(tone)) {
|
||||
const id = ++this.counter;
|
||||
this.items.update((items) => [...items, { id, title, message, tone }]);
|
||||
setTimeout(() => this.dismiss(id), 4200);
|
||||
}
|
||||
|
||||
dismiss(id: number) {
|
||||
this.items.update((items) => items.filter((item) => item.id !== id));
|
||||
}
|
||||
|
||||
success(message: string, title = 'Gotowe') {
|
||||
this.show(message, 'success', title);
|
||||
}
|
||||
|
||||
error(message: string, title = 'Błąd') {
|
||||
this.show(message, 'danger', title);
|
||||
}
|
||||
|
||||
warning(message: string, title = 'Uwaga') {
|
||||
this.show(message, 'warning', title);
|
||||
}
|
||||
|
||||
info(message: string, title = 'Informacja') {
|
||||
this.show(message, 'info', title);
|
||||
}
|
||||
|
||||
private defaultTitle(tone: ToastItem['tone']) {
|
||||
return tone === 'success' ? 'Gotowe' : tone === 'danger' ? 'Błąd' : tone === 'warning' ? 'Uwaga' : 'Informacja';
|
||||
}
|
||||
}
|
||||
145
web/src/app/core/services/ui.service.ts
Normal file
145
web/src/app/core/services/ui.service.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { Injectable, computed, effect, inject, signal } from '@angular/core';
|
||||
|
||||
export type UiTheme = 'light' | 'dark';
|
||||
export type UiLanguage = 'pl' | 'en';
|
||||
|
||||
const translations: Record<UiLanguage, Record<string, string>> = {
|
||||
pl: {
|
||||
'app.name': 'Expense Control',
|
||||
'nav.dashboard': 'Dashboard',
|
||||
'nav.expenses': 'Wydatki',
|
||||
'nav.stats': 'Statystyki',
|
||||
'nav.merchants': 'Kontrahenci',
|
||||
'nav.reports': 'Raporty',
|
||||
'nav.categories': 'Kategorie',
|
||||
'nav.admin': 'Administracja',
|
||||
'action.logout': 'Wyloguj',
|
||||
'action.addExpense': 'Dodaj wydatek',
|
||||
'action.openReports': 'Raporty',
|
||||
'action.login': 'Zaloguj się',
|
||||
'action.loggingIn': 'Logowanie...',
|
||||
'action.createAccount': 'Utwórz konto',
|
||||
'action.creatingAccount': 'Tworzenie konta...',
|
||||
'action.loginMode': 'Logowanie',
|
||||
'action.registerMode': 'Rejestracja',
|
||||
'theme.label': 'Motyw',
|
||||
'theme.dark': 'Ciemny',
|
||||
'theme.light': 'Jasny',
|
||||
'lang.label': 'Język',
|
||||
'lang.pl': 'Polski',
|
||||
'lang.en': 'English',
|
||||
'login.email': 'E-mail',
|
||||
'login.password': 'Hasło',
|
||||
'login.fullName': 'Imię i nazwisko',
|
||||
'dashboard.section': 'Panel wydatków',
|
||||
'dashboard.subtitle': 'Szybki podgląd wydatków, kontrahentów i raportów SMTP.',
|
||||
'dashboard.total': 'Suma miesiąca',
|
||||
'dashboard.count': 'Liczba wydatków',
|
||||
'dashboard.avg': 'Średnia',
|
||||
'dashboard.top': 'Największa kategoria',
|
||||
'dashboard.share': 'Udział kategorii',
|
||||
'dashboard.areas': 'Najmocniejsze obszary kosztów',
|
||||
'dashboard.recent': 'Ostatnie wydatki',
|
||||
'common.none': 'Brak',
|
||||
'common.noData': 'Brak danych.',
|
||||
'common.noExpenses': 'Brak wydatków.',
|
||||
'common.noCategories': 'Brak kategorii.'
|
||||
},
|
||||
en: {
|
||||
'app.name': 'Expense Control',
|
||||
'nav.dashboard': 'Dashboard',
|
||||
'nav.expenses': 'Expenses',
|
||||
'nav.stats': 'Statistics',
|
||||
'nav.merchants': 'Partners',
|
||||
'nav.reports': 'Reports',
|
||||
'nav.categories': 'Categories',
|
||||
'nav.admin': 'Admin',
|
||||
'action.logout': 'Sign out',
|
||||
'action.addExpense': 'Add expense',
|
||||
'action.openReports': 'Reports',
|
||||
'action.login': 'Sign in',
|
||||
'action.loggingIn': 'Signing in...',
|
||||
'action.createAccount': 'Create account',
|
||||
'action.creatingAccount': 'Creating account...',
|
||||
'action.loginMode': 'Sign in',
|
||||
'action.registerMode': 'Register',
|
||||
'theme.label': 'Theme',
|
||||
'theme.dark': 'Dark',
|
||||
'theme.light': 'Light',
|
||||
'lang.label': 'Language',
|
||||
'lang.pl': 'Polish',
|
||||
'lang.en': 'English',
|
||||
'login.email': 'Email',
|
||||
'login.password': 'Password',
|
||||
'login.fullName': 'Full name',
|
||||
'dashboard.section': 'Expense overview',
|
||||
'dashboard.subtitle': 'Fast access to expenses, partners and SMTP reports.',
|
||||
'dashboard.total': 'Month total',
|
||||
'dashboard.count': 'Expense count',
|
||||
'dashboard.avg': 'Average',
|
||||
'dashboard.top': 'Top category',
|
||||
'dashboard.share': 'Category share',
|
||||
'dashboard.areas': 'Top cost areas',
|
||||
'dashboard.recent': 'Recent expenses',
|
||||
'common.none': 'None',
|
||||
'common.noData': 'No data.',
|
||||
'common.noExpenses': 'No expenses.',
|
||||
'common.noCategories': 'No categories.'
|
||||
}
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class UiService {
|
||||
private readonly document = inject(DOCUMENT);
|
||||
readonly theme = signal<UiTheme>(this.readTheme());
|
||||
readonly language = signal<UiLanguage>(this.readLanguage());
|
||||
readonly resolvedTheme = computed<'light' | 'dark'>(() => this.theme());
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const resolved = this.resolvedTheme();
|
||||
const html = this.document.documentElement;
|
||||
const body = this.document.body;
|
||||
html.setAttribute('data-bs-theme', resolved);
|
||||
html.setAttribute('lang', this.language());
|
||||
body.setAttribute('data-bs-theme', resolved);
|
||||
try {
|
||||
window.localStorage.setItem('expense-control-theme', this.theme());
|
||||
window.localStorage.setItem('expense-control-lang', this.language());
|
||||
} catch {}
|
||||
});
|
||||
}
|
||||
|
||||
setTheme(theme: UiTheme) {
|
||||
this.theme.set(theme);
|
||||
}
|
||||
|
||||
toggleTheme() {
|
||||
this.theme.set(this.theme() === 'dark' ? 'light' : 'dark');
|
||||
}
|
||||
|
||||
setLanguage(language: UiLanguage) {
|
||||
this.language.set(language);
|
||||
}
|
||||
|
||||
t(key: string) {
|
||||
return translations[this.language()][key] ?? translations.pl[key] ?? key;
|
||||
}
|
||||
|
||||
private readTheme(): UiTheme {
|
||||
try {
|
||||
const stored = window.localStorage.getItem('expense-control-theme');
|
||||
if (stored === 'light' || stored === 'dark') return stored;
|
||||
} catch {}
|
||||
return 'dark';
|
||||
}
|
||||
|
||||
private readLanguage(): UiLanguage {
|
||||
try {
|
||||
const stored = window.localStorage.getItem('expense-control-lang');
|
||||
if (stored === 'pl' || stored === 'en') return stored;
|
||||
} catch {}
|
||||
return 'pl';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user