first commit

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

View File

@@ -0,0 +1,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 });
}
}

View 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
});
}
}

View 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);
}
}

View 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)))
);
}
}

View 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); }
}

View 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)))
);
}
}

View 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`, {});
}
}

View 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 }); }
}

View 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';
}
}

View 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';
}
}