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,9 @@
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { authInterceptor } from './core/interceptors/auth.interceptor';
export const appConfig: ApplicationConfig = {
providers: [provideRouter(routes), provideHttpClient(withInterceptors([authInterceptor]))]
};

31
web/src/app/app.routes.ts Normal file
View File

@@ -0,0 +1,31 @@
import { Routes } from '@angular/router';
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 { CategoriesComponent } from './features/categories/categories.component';
import { DashboardComponent } from './features/dashboard/dashboard.component';
import { ExpensesComponent } from './features/expenses/expenses.component';
import { MerchantsComponent } from './features/merchants/merchants.component';
import { ReportsComponent } from './features/reports/reports.component';
import { StatsComponent } from './features/stats/stats.component';
import { ShellComponent } from './layout/shell.component';
export const routes: Routes = [
{ path: 'login', component: LoginComponent },
{
path: '',
component: ShellComponent,
canActivate: [authGuard],
children: [
{ path: '', component: DashboardComponent },
{ path: 'expenses', component: ExpensesComponent },
{ path: 'stats', component: StatsComponent },
{ path: 'merchants', component: MerchantsComponent },
{ path: 'reports', component: ReportsComponent },
{ path: 'categories', component: CategoriesComponent },
{ path: 'admin', component: AdminComponent, canActivate: [adminGuard] }
]
},
{ path: '**', redirectTo: '' }
];

27
web/src/app/app.ts Normal file
View File

@@ -0,0 +1,27 @@
import { Component, inject } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { AuthService } from './core/services/auth.service';
import { AppSettingsService } from './core/services/app-settings.service';
import { ToastOutletComponent } from './shared/ui/toast-outlet.component';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, ToastOutletComponent],
template: `
<router-outlet></router-outlet>
<app-toast-outlet></app-toast-outlet>
`
})
export class App {
private readonly auth = inject(AuthService);
private readonly appSettings = inject(AppSettingsService);
constructor() {
this.appSettings.loadPublic().subscribe({ error: () => undefined });
if (this.auth.isAuthenticated()) {
this.auth.fetchMe().subscribe({ error: () => this.auth.logout() });
}
}
}

View File

@@ -0,0 +1,4 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from '../services/auth.service';
export const adminGuard: CanActivateFn = () => { const auth = inject(AuthService); const router = inject(Router); return auth.isAdmin() ? true : router.createUrlTree(['/']); };

View File

@@ -0,0 +1,4 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from '../services/auth.service';
export const authGuard: CanActivateFn = () => { const auth = inject(AuthService); const router = inject(Router); return auth.isAuthenticated() ? true : router.createUrlTree(['/login']); };

View File

@@ -0,0 +1,4 @@
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthService } from '../services/auth.service';
export const authInterceptor: HttpInterceptorFn = (req, next) => { const authService = inject(AuthService); const token = authService.token(); return next(token ? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }) : req); };

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

View File

@@ -0,0 +1,242 @@
import { CommonModule, DatePipe } from '@angular/common';
import { Component, OnInit, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { AdminService } from '../../core/services/admin.service';
import { AppSettingsService } from '../../core/services/app-settings.service';
import { ToastService } from '../../core/services/toast.service';
import type { AppSettings, User } from '../../shared/models';
@Component({
selector: 'app-admin',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, 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">Administracja</h2>
<div class="text-secondary">Ustawienia aplikacji, SMTP oraz zarządzanie użytkownikami.</div>
</div>
</div>
</div>
<div class="row row-cards align-items-start">
<div class="col-xl-5">
<div class="card pv-card overflow-hidden">
<div class="card-header"><h3 class="card-title">Ustawienia aplikacji</h3></div>
<div class="card-body">
<form [formGroup]="form" (ngSubmit)="save()" class="d-grid gap-3">
<div>
<label class="form-label">Nazwa aplikacji</label>
<input class="form-control" formControlName="appName" />
</div>
<div class="row g-3">
<div class="col-md-6"><label class="form-label">Domyślna waluta</label><input class="form-control" formControlName="defaultCurrency" /></div>
<div class="col-md-6"><label class="form-label">Typy potwierdzeń</label><input class="form-control" formControlName="allowedProofTypes" /></div>
</div>
<label class="form-check">
<input class="form-check-input" type="checkbox" formControlName="registrationEnabled" />
<span class="form-check-label">Włącz rejestrację</span>
</label>
<hr class="my-2" />
<div class="fw-semibold">SMTP</div>
<label class="form-check">
<input class="form-check-input" type="checkbox" formControlName="smtpEnabled" />
<span class="form-check-label">Włącz SMTP</span>
</label>
<div class="row g-3">
<div class="col-md-7"><label class="form-label">Host</label><input class="form-control" formControlName="smtpHost" /></div>
<div class="col-md-5"><label class="form-label">Port</label><input class="form-control" type="number" formControlName="smtpPort" /></div>
<div class="col-md-6"><label class="form-label">Użytkownik</label><input class="form-control" formControlName="smtpUser" /></div>
<div class="col-md-6"><label class="form-label">Hasło</label><input class="form-control" type="password" formControlName="smtpPassword" /></div>
<div class="col-md-6"><label class="form-label">Nazwa nadawcy</label><input class="form-control" formControlName="smtpFromName" /></div>
<div class="col-md-6"><label class="form-label">E-mail nadawcy</label><input class="form-control" formControlName="smtpFromEmail" /></div>
</div>
<label class="form-check">
<input class="form-check-input" type="checkbox" formControlName="smtpSecure" />
<span class="form-check-label">Bezpieczne połączenie</span>
</label>
<div class="btn-list flex-wrap">
<button class="btn btn-success d-inline-flex align-items-center gap-2" [disabled]="form.invalid || saving()">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 12l5 5l10 -10"/></svg>
<span>Zapisz</span>
</button>
<button class="btn btn-outline-info" type="button" (click)="sendTest()">Test SMTP</button>
</div>
</form>
</div>
</div>
</div>
<div class="col-xl-7">
<div class="card pv-card overflow-hidden">
<div class="card-header d-flex justify-content-between align-items-center">
<h3 class="card-title">Użytkownicy</h3>
<span class="badge bg-dark-lt">{{ users().length }}</span>
</div>
<div class="table-responsive">
<table class="table table-vcenter card-table mb-0">
<thead><tr><th>Użytkownik</th><th>Rola</th><th>Status</th><th>Data</th><th class="w-1"></th></tr></thead>
<tbody>
@for (user of users(); track user.id) {
<tr>
<td>
<div class="fw-semibold">{{ user.fullName }}</div>
<div class="small text-secondary">{{ user.email }}</div>
</td>
<td>{{ user.role }}</td>
<td>
<span class="badge" [class.bg-success]="user.isActive" [class.bg-secondary]="!user.isActive">
{{ user.isActive ? 'Aktywny' : 'Zablokowany' }}
</span>
</td>
<td>{{ user.createdAt | date:'short' }}</td>
<td>
<div class="btn-list flex-nowrap">
<button class="btn btn-outline-warning btn-sm" type="button" (click)="toggleRole(user)">
{{ user.role === 'ADMIN' ? 'Ustaw USER' : 'Ustaw ADMIN' }}
</button>
<button class="btn btn-sm" [class.btn-danger]="user.isActive" [class.btn-success]="!user.isActive" type="button" (click)="toggleActive(user)">
{{ user.isActive ? 'Zablokuj' : 'Odblokuj' }}
</button>
</div>
</td>
</tr>
} @empty {
<tr><td colspan="5" class="text-secondary">Brak użytkowników.</td></tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
`
})
export class AdminComponent implements OnInit {
private readonly fb = inject(FormBuilder);
private readonly admin = inject(AdminService);
private readonly appSettings = inject(AppSettingsService);
private readonly toast = inject(ToastService);
readonly users = signal<User[]>([]);
readonly settings = signal<AppSettings | null>(null);
readonly saving = signal(false);
readonly form = this.fb.nonNullable.group({
appName: ['', [Validators.required, Validators.minLength(2)]],
defaultCurrency: ['PLN', Validators.required],
allowedProofTypes: ['RECEIPT,INVOICE,NOTE,BANK_STATEMENT,OTHER', Validators.required],
registrationEnabled: [true],
smtpEnabled: [false],
smtpHost: [''],
smtpPort: [587],
smtpSecure: [false],
smtpUser: [''],
smtpPassword: [''],
smtpFromName: [''],
smtpFromEmail: ['']
});
ngOnInit() {
this.load();
}
load() {
this.admin.getSettings().subscribe({
next: (response) => {
this.settings.set(response.item);
this.appSettings.applySettings(response.item);
this.form.reset({
appName: response.item.appName,
defaultCurrency: response.item.defaultCurrency,
allowedProofTypes: response.item.allowedProofTypes.join(','),
registrationEnabled: response.item.registrationEnabled,
smtpEnabled: response.item.smtpEnabled,
smtpHost: response.item.smtpHost ?? '',
smtpPort: response.item.smtpPort,
smtpSecure: response.item.smtpSecure,
smtpUser: response.item.smtpUser ?? '',
smtpPassword: response.item.smtpPassword ?? '',
smtpFromName: response.item.smtpFromName ?? '',
smtpFromEmail: response.item.smtpFromEmail ?? ''
});
}
});
this.admin.listUsers().subscribe({ next: (response) => this.users.set(response.items) });
}
save() {
if (this.form.invalid) return;
this.saving.set(true);
const raw = this.form.getRawValue();
this.admin
.updateSettings({
appName: raw.appName,
defaultCurrency: raw.defaultCurrency,
registrationEnabled: raw.registrationEnabled,
allowedProofTypes: raw.allowedProofTypes.split(',').map((item) => item.trim()).filter(Boolean),
uiPreferences: { theme: 'dark', density: 'comfortable', defaultStatsPeriod: 'month' },
smtpEnabled: raw.smtpEnabled,
smtpHost: raw.smtpHost || null,
smtpPort: Number(raw.smtpPort),
smtpSecure: raw.smtpSecure,
smtpUser: raw.smtpUser || null,
smtpPassword: raw.smtpPassword || null,
smtpFromName: raw.smtpFromName || null,
smtpFromEmail: raw.smtpFromEmail || null
})
.subscribe({
next: (response) => {
this.saving.set(false);
this.settings.set(response.item);
this.appSettings.applySettings(response.item);
this.toast.success('Ustawienia zapisane.');
},
error: (error) => {
this.saving.set(false);
this.toast.error(error.error?.message ?? 'Nie udało się zapisać ustawień.');
}
});
}
sendTest() {
const to = this.form.getRawValue().smtpFromEmail;
if (!to) {
this.toast.error('Uzupełnij e-mail nadawcy.');
return;
}
this.admin.testSmtp(to).subscribe({
next: () => this.toast.success('Wiadomość testowa została wysłana.'),
error: (error) => this.toast.error(error.error?.message ?? 'Nie udało się wysłać testu SMTP.')
});
}
toggleRole(user: User) {
this.admin.updateUser(user.id, { role: user.role === 'ADMIN' ? 'USER' : 'ADMIN' }).subscribe({
next: (response) => {
this.users.update((items) => items.map((item) => (item.id === user.id ? response.item : item)));
this.toast.success('Rola została zaktualizowana.');
},
error: (error) => this.toast.error(error.error?.message ?? 'Nie udało się zmienić roli.')
});
}
toggleActive(user: User) {
this.admin.updateUser(user.id, { isActive: !user.isActive }).subscribe({
next: (response) => {
this.users.update((items) => items.map((item) => (item.id === user.id ? response.item : item)));
this.toast.success('Status konta został zaktualizowany.');
},
error: (error) => this.toast.error(error.error?.message ?? 'Nie udało się zmienić statusu.')
});
}
}

View File

@@ -0,0 +1,186 @@
import { CommonModule } from '@angular/common';
import { Component, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { AppSettingsService } from '../../core/services/app-settings.service';
import { AuthService } from '../../core/services/auth.service';
import { ToastService } from '../../core/services/toast.service';
import { UiService } from '../../core/services/ui.service';
@Component({
selector: 'app-login',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
template: `
<div class="page page-center login-page-shell">
<div class="container py-4">
<div class="row justify-content-center align-items-stretch g-4 login-layout">
<div class="col-12 col-md-10 col-lg-7 col-xl-5">
<div class="card card-md login-card login-card-enhanced">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start gap-3 mb-4 flex-wrap">
<div>
<h1 class="h2 mb-1">{{ appSettings.appName() }}</h1>
<div class="text-secondary">{{ mode() === 'login' ? loginSubtitle() : registerSubtitle() }}</div>
</div>
<div class="d-grid gap-2 login-toolbar-controls">
<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')">
{{ ui.t('lang.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')">
{{ ui.t('lang.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>
</nav>
</div>
</div>
<form [formGroup]="form" (ngSubmit)="submit()" class="login-input-stack">
@if (mode() === 'register') {
<div>
<label class="form-label">{{ ui.t('login.fullName') }}</label>
<input class="form-control form-control-lg" formControlName="fullName" autocomplete="name" />
</div>
}
<div>
<label class="form-label">{{ ui.t('login.email') }}</label>
<input class="form-control form-control-lg" formControlName="email" autocomplete="username" />
</div>
<div>
<label class="form-label">{{ ui.t('login.password') }}</label>
<input class="form-control form-control-lg" type="password" formControlName="password" autocomplete="current-password" />
</div>
<button class="btn btn-primary btn-lg w-100 login-submit-button" [disabled]="form.invalid || loading()">
<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="M9 8l0 -2a2 2 0 1 1 4 0v2"/><path d="M5 8h14l0 12h-14z"/><path d="M12 12l0 .01"/></svg>
{{ loading() ? (mode() === 'login' ? ui.t('action.loggingIn') : ui.t('action.creatingAccount')) : (mode() === 'login' ? ui.t('action.login') : ui.t('action.createAccount')) }}
</button>
</form>
@if (appSettings.registrationEnabled()) {
<div class="login-footer-note d-flex justify-content-between align-items-center gap-2 flex-wrap">
<span>{{ mode() === 'login' ? switchToRegisterLabel() : switchToLoginLabel() }}</span>
<button class="btn btn-ghost-primary btn-sm" type="button" (click)="mode.set(mode() === 'login' ? 'register' : 'login')">
{{ mode() === 'login' ? ui.t('action.registerMode') : ui.t('action.loginMode') }}
</button>
</div>
}
</div>
</div>
</div>
</div>
</div>
</div>
`
})
export class LoginComponent {
private readonly fb = inject(FormBuilder);
private readonly auth = inject(AuthService);
private readonly router = inject(Router);
private readonly toast = inject(ToastService);
readonly ui = inject(UiService);
readonly appSettings = inject(AppSettingsService);
readonly loading = signal(false);
readonly mode = signal<'login' | 'register'>('login');
readonly form = this.fb.nonNullable.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(6)]],
fullName: ['']
});
constructor() {
this.appSettings.loadPublic().subscribe({ error: () => undefined });
}
submit() {
if (this.form.invalid) return;
this.loading.set(true);
const raw = this.form.getRawValue();
if (this.mode() === 'login') {
this.auth.login({ email: raw.email, password: raw.password }).subscribe({
next: () => {
this.loading.set(false);
this.router.navigate(['/']);
},
error: (error) => {
this.loading.set(false);
this.toast.error(error.error?.message ?? 'Nie udało się zalogować.');
}
});
return;
}
this.auth.register({ email: raw.email, password: raw.password, fullName: raw.fullName || raw.email }).subscribe({
next: () => {
this.loading.set(false);
this.toast.success('Konto zostało utworzone.');
this.mode.set('login');
},
error: (error) => {
this.loading.set(false);
this.toast.error(error.error?.message ?? 'Nie udało się utworzyć konta.');
}
});
}
loginSubtitle() {
return this.ui.language() === 'pl'
? 'Zaloguj się, aby zarządzać wydatkami, kontrahentami i raportami.'
: 'Sign in to manage expenses, merchants and reports.';
}
registerSubtitle() {
return this.ui.language() === 'pl'
? 'Utwórz konto i zacznij zbierać potwierdzenia oraz statystyki.'
: 'Create an account and start collecting proofs and analytics.';
}
switchToRegisterLabel() {
return this.ui.language() === 'pl' ? 'Nie masz konta?' : 'Need an account?';
}
switchToLoginLabel() {
return this.ui.language() === 'pl' ? 'Masz już konto?' : 'Already registered?';
}
}

View File

@@ -0,0 +1,151 @@
import { CommonModule } from '@angular/common';
import { Component, OnInit, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { CategoriesService } from '../../core/services/categories.service';
import { ToastService } from '../../core/services/toast.service';
import type { Category } from '../../shared/models';
const presets = ['#b91c1c', '#2563eb', '#0891b2', '#16a34a', '#7c3aed', '#f59e0b', '#475569'];
@Component({
selector: 'app-categories',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
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">Kategorie</h2>
<div class="text-secondary">Zarządzaj kategoriami systemowymi i własnymi dla raportów oraz wydatków.</div>
</div>
</div>
</div>
<div class="row row-cards align-items-start">
<div class="col-lg-4">
<div class="card pv-card overflow-hidden">
<div class="card-header"><h3 class="card-title">{{ editingId ? 'Edytuj kategorię' : 'Nowa kategoria' }}</h3></div>
<div class="card-body">
<form [formGroup]="form" (ngSubmit)="submit()" class="d-grid gap-3">
<div>
<label class="form-label">Nazwa</label>
<input class="form-control" formControlName="name" />
</div>
<div>
<label class="form-label">Kolor</label>
<div class="input-group">
<span class="input-group-text p-1"><span class="ec-color-swatch" [style.background]="form.getRawValue().color"></span></span>
<input class="form-control" formControlName="color" />
<input class="form-control form-control-color" type="color" formControlName="color" style="max-width: 4.25rem;" />
</div>
<div class="d-flex flex-wrap gap-2 mt-2">
@for (preset of presets; track preset) {
<button class="btn btn-sm btn-outline-primary p-1" type="button" (click)="pickColor(preset)">
<span class="ec-color-swatch" [style.background]="preset"></span>
</button>
}
</div>
</div>
<div class="btn-list">
<button class="btn btn-success d-inline-flex align-items-center gap-2" [disabled]="form.invalid">
<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>{{ editingId ? 'Zapisz' : 'Dodaj' }}</span>
</button>
@if (editingId) {
<button class="btn btn-outline-secondary" type="button" (click)="reset()">Anuluj</button>
}
</div>
</form>
</div>
</div>
</div>
<div class="col-lg-8">
<div class="card pv-card overflow-hidden">
<div class="card-header"><h3 class="card-title">Kategorie</h3></div>
<div class="table-responsive">
<table class="table table-vcenter card-table mb-0">
<thead><tr><th>Nazwa</th><th>Typ</th><th class="w-1"></th></tr></thead>
<tbody>
@for (item of items(); track item.id) {
<tr>
<td>
<span class="badge me-2" [style.background]="item.color">&nbsp;</span>
{{ item.name }}
</td>
<td>{{ item.isSystem ? 'Systemowa' : 'Własna' }}</td>
<td>
<div class="btn-list flex-nowrap">
@if (!item.isSystem) {
<button class="btn btn-outline-primary btn-sm" type="button" (click)="edit(item)">Edytuj</button>
<button class="btn btn-outline-danger btn-sm" type="button" (click)="remove(item)">Usuń</button>
}
</div>
</td>
</tr>
} @empty {
<tr><td colspan="3" class="text-secondary">Brak kategorii.</td></tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
`
})
export class CategoriesComponent implements OnInit {
private readonly fb = inject(FormBuilder);
private readonly categories = inject(CategoriesService);
private readonly toast = inject(ToastService);
readonly items = this.categories.items;
readonly presets = presets;
editingId: string | null = null;
readonly form = this.fb.nonNullable.group({
name: ['', [Validators.required, Validators.minLength(2)]],
color: ['#2563eb', Validators.required]
});
ngOnInit() {
this.categories.ensureLoaded(true);
}
pickColor(color: string) {
this.form.patchValue({ color });
}
submit() {
if (this.form.invalid) return;
const payload = this.form.getRawValue();
const request = this.editingId ? this.categories.update(this.editingId, payload) : this.categories.create(payload);
request.subscribe({
next: () => {
this.toast.success(this.editingId ? 'Kategoria została zapisana.' : 'Kategoria została dodana.');
this.reset();
},
error: (error) => this.toast.error(error.error?.message ?? 'Nie udało się zapisać kategorii.')
});
}
edit(item: Category) {
this.editingId = item.id;
this.form.patchValue({ name: item.name, color: item.color });
}
reset() {
this.editingId = null;
this.form.reset({ name: '', color: '#2563eb' });
}
remove(item: Category) {
this.categories.delete(item.id).subscribe({
next: () => this.toast.success('Kategoria została usunięta.'),
error: (error) => this.toast.error(error.error?.message ?? 'Nie udało się usunąć kategorii.')
});
}
}

View File

@@ -0,0 +1,209 @@
import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common';
import { Component, 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 { ExpensesService } from '../../core/services/expenses.service';
import { StatsService } from '../../core/services/stats.service';
import type { Expense, StatsResponse } from '../../shared/models';
Chart.register(DoughnutController, ArcElement, Tooltip, Legend);
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [CommonModule, CurrencyPipe, DatePipe, RouterLink],
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 class="text-secondary">{{ ui.t('dashboard.subtitle') }}</div>
</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>
</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="display-6 fw-bold mb-2">{{ stats?.total || 0 | currency:'PLN':'symbol':'1.2-2' }}</div>
<div class="text-secondary">{{ ui.t('dashboard.subtitle') }}</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="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">Miesięczny przekrój kosztów według kategorii.</div>
</div>
</div>
<div class="card-body">
@if (hasCategoryData()) {
<div class="ec-chart-wrap ec-chart-wrap-sm">
<canvas id="dashboardCategoryChart"></canvas>
</div>
} @else {
<div class="alert alert-info mb-0">Brak danych do pokazania wykresu kategorii.</div>
}
</div>
</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>
<h3 class="card-title">{{ ui.t('dashboard.areas') }}</h3>
<div class="ec-card-header-muted">Najważniejsze obszary kosztowe w aktualnym okresie.</div>
</div>
</div>
<div class="table-responsive">
<table class="table table-vcenter card-table table-striped mb-0">
<thead>
<tr><th>Kategoria</th><th class="text-end">Kwota</th><th class="text-end">Liczba</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-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">Ostatnio dodane pozycje wraz z kontrahentami.</div>
</div>
</div>
@if (recentExpenses.length) {
<div class="table-responsive">
<table class="table table-vcenter card-table table-striped mb-0">
<thead>
<tr><th>Tytuł</th><th>Kontrahent</th><th>Data</th><th class="text-end">Kwota</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 {
readonly auth = inject(AuthService);
readonly ui = inject(UiService);
private readonly expensesService = inject(ExpensesService);
private readonly statsService = inject(StatsService);
recentExpenses: Expense[] = [];
stats: StatsResponse | null = null;
private categoryChart?: Chart;
ngOnInit() {
const now = new Date();
const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString().slice(0, 10);
const end = now.toISOString().slice(0, 10);
this.expensesService.list({ startDate: start, endDate: end }).subscribe({
next: (response) => (this.recentExpenses = response.items.slice(0, 8))
});
this.statsService.overview({ startDate: start, endDate: end, bucket: 'month' }).subscribe({
next: (response) => {
this.stats = response;
setTimeout(() => this.renderChart(), 0);
}
});
}
hasCategoryData() {
return Boolean(this.stats?.byCategory?.length);
}
private renderChart() {
const canvas = document.getElementById('dashboardCategoryChart') as HTMLCanvasElement | null;
if (!canvas || !this.stats?.byCategory.length) {
this.categoryChart?.destroy();
return;
}
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) }]
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '66%',
plugins: { legend: { position: 'bottom' } }
}
});
}
}

View File

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

View File

@@ -0,0 +1,170 @@
import { CommonModule } from '@angular/common';
import { Component, OnInit, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { MerchantsService } from '../../core/services/merchants.service';
import { ToastService } from '../../core/services/toast.service';
import type { Merchant } from '../../shared/models';
@Component({
selector: 'app-merchants',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
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">Kontrahenci</h2>
<div class="text-secondary">Zapisani sprzedawcy i usługodawcy do szybkiego wyboru przy wydatkach.</div>
</div>
<div class="col-12 col-xl d-flex justify-content-xl-end">
<div class="ec-page-header-actions">
<button class="btn btn-success d-inline-flex align-items-center gap-2" type="button" (click)="openCreate()">
<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>Dodaj kontrahenta</span>
</button>
</div>
</div>
</div>
</div>
<div class="card pv-card overflow-hidden">
<div class="table-responsive">
<table class="table table-vcenter card-table mb-0">
<thead>
<tr><th>Nazwa</th><th>Typ</th><th>Status</th><th>Notatki</th><th class="w-1"></th></tr>
</thead>
<tbody>
@for (item of items(); track item.id) {
<tr>
<td>{{ item.name }}</td>
<td>{{ labelKind(item.kind) }}</td>
<td><span class="badge" [class.bg-success]="item.isActive" [class.bg-secondary]="!item.isActive">{{ item.isActive ? 'Aktywny' : 'Ukryty' }}</span></td>
<td>{{ item.notes || 'Brak' }}</td>
<td>
<div class="btn-list flex-nowrap">
<button class="btn btn-outline-primary btn-sm" type="button" (click)="openEdit(item)">Edytuj</button>
<button class="btn btn-outline-danger btn-sm" type="button" (click)="remove(item)">Usuń</button>
</div>
</td>
</tr>
} @empty {
<tr><td colspan="5" class="text-secondary">Brak zapisanych kontrahentów.</td></tr>
}
</tbody>
</table>
</div>
</div>
@if (modalOpen()) {
<div class="modal modal-blur fade show d-block" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ editingId() ? 'Edytuj kontrahenta' : 'Nowy kontrahent' }}</h5>
<button type="button" class="btn-close" (click)="closeModal()"></button>
</div>
<form [formGroup]="form" (ngSubmit)="submit()">
<div class="modal-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Nazwa</label>
<input class="form-control" formControlName="name" />
</div>
<div class="col-md-6">
<label class="form-label">Typ</label>
<select class="form-select" formControlName="kind">
<option value="MERCHANT">Sprzedawca</option>
<option value="SERVICE_PROVIDER">Usługodawca</option>
<option value="OTHER">Inny</option>
</select>
</div>
<div class="col-12">
<label class="form-label">Notatki</label>
<textarea class="form-control" rows="4" formControlName="notes"></textarea>
</div>
<div class="col-12">
<label class="form-check">
<input class="form-check-input" type="checkbox" formControlName="isActive" />
<span class="form-check-label">Pokazuj na listach wyboru</span>
</label>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost-secondary" type="button" (click)="closeModal()">Anuluj</button>
<button class="btn btn-success" [disabled]="form.invalid">{{ editingId() ? 'Zapisz' : 'Dodaj' }}</button>
</div>
</form>
</div>
</div>
</div>
<div class="modal-backdrop fade show"></div>
}
`
})
export class MerchantsComponent implements OnInit {
private readonly fb = inject(FormBuilder);
private readonly merchants = inject(MerchantsService);
private readonly toast = inject(ToastService);
readonly items = this.merchants.items;
readonly modalOpen = signal(false);
readonly editingId = signal<string | null>(null);
readonly form = this.fb.nonNullable.group({
name: ['', [Validators.required, Validators.minLength(2)]],
kind: ['MERCHANT' as Merchant['kind'], Validators.required],
notes: [''],
isActive: [true]
});
ngOnInit() {
this.merchants.ensureLoaded(true);
}
labelKind(kind: Merchant['kind']) {
return kind === 'SERVICE_PROVIDER' ? 'Usługodawca' : kind === 'MERCHANT' ? 'Sprzedawca' : 'Inny';
}
openCreate() {
this.editingId.set(null);
this.form.reset({ name: '', kind: 'MERCHANT', notes: '', isActive: true });
this.modalOpen.set(true);
}
openEdit(item: Merchant) {
this.editingId.set(item.id);
this.form.reset({
name: item.name,
kind: item.kind,
notes: item.notes ?? '',
isActive: item.isActive
});
this.modalOpen.set(true);
}
closeModal() {
this.modalOpen.set(false);
}
submit() {
if (this.form.invalid) return;
const payload = this.form.getRawValue();
const request = this.editingId() ? this.merchants.update(this.editingId()!, payload) : this.merchants.create(payload);
request.subscribe({
next: () => {
this.toast.success(this.editingId() ? 'Kontrahent został zapisany.' : 'Kontrahent został dodany.');
this.closeModal();
},
error: (error) => this.toast.error(error.error?.message ?? 'Nie udało się zapisać kontrahenta.')
});
}
remove(item: Merchant) {
this.merchants.delete(item.id).subscribe({
next: () => this.toast.success('Kontrahent został usunięty.'),
error: (error) => this.toast.error(error.error?.message ?? 'Nie udało się usunąć kontrahenta.')
});
}
}

View File

@@ -0,0 +1,160 @@
import { CommonModule } from '@angular/common';
import { Component, OnInit, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { CategoriesService } from '../../core/services/categories.service';
import { ReportsService } from '../../core/services/reports.service';
import { ToastService } from '../../core/services/toast.service';
import type { ReportPreferences, StatsResponse } from '../../shared/models';
import { CategoryPickerComponent } from '../../shared/ui/category-picker.component';
@Component({
selector: 'app-reports',
standalone: true,
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">Raporty</h2>
<div class="text-secondary">Skonfiguruj raporty SMTP, podgląd i ręczne wysyłanie podsumowań.</div>
</div>
</div>
</div>
<div class="row row-cards">
<div class="col-lg-5">
<div class="card pv-card overflow-hidden">
<div class="card-header"><h3 class="card-title">Raporty e-mail</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">Włącz raporty</span>
</label>
<div>
<label class="form-label">Częstotliwość</label>
<select class="form-select" formControlName="frequency">
<option value="monthly">Miesięczna</option>
<option value="yearly">Roczna</option>
<option value="threshold">Po przekroczeniu progu</option>
</select>
</div>
<div>
<label class="form-label">Adres docelowy</label>
<input class="form-control" formControlName="sendToEmail" />
</div>
<div>
<label class="form-label">Próg kwotowy</label>
<input class="form-control" type="number" step="0.01" formControlName="thresholdAmount" />
</div>
<div>
<label class="form-label">Kategorie raportu</label>
<app-category-picker
[items]="categories()"
[selectedIds]="form.getRawValue().categoryIds"
placeholder="Wszystkie kategorie"
(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>Zapisz</span>
</button>
<button class="btn btn-outline-info" type="button" (click)="preview()">Odśwież podgląd</button>
<button class="btn btn-warning" type="button" (click)="send()">Wyślij teraz</button>
</div>
</form>
</div>
</div>
</div>
<div class="col-lg-7">
<div class="card pv-card overflow-hidden">
<div class="card-header"><h3 class="card-title">Podgląd raportu</h3></div>
<div class="card-body">
@if (summary()) {
<div class="row g-3 mb-4">
<div class="col-sm-4"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">Suma</div><div class="h1">{{ summary()!.total.toFixed(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">Liczba</div><div class="h1">{{ summary()!.count }}</div></div></div></div>
<div class="col-sm-4"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">Średnia</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>
</div>
</div>
</div>
`
})
export class ReportsComponent implements OnInit {
private readonly fb = inject(FormBuilder);
private readonly categoriesService = inject(CategoriesService);
private readonly reports = inject(ReportsService);
private readonly toast = inject(ToastService);
readonly categories = this.categoriesService.items;
readonly html = signal('<div class="text-secondary">Brak danych raportu.</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[]]
});
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();
}
});
}
setCategoryIds(categoryIds: string[]) {
this.form.patchValue({ categoryIds });
}
save() {
if (this.form.invalid) return;
const payload: ReportPreferences = this.form.getRawValue();
this.reports.updatePreferences(payload).subscribe({
next: () => {
this.toast.success('Ustawienia raportów zapisane.');
this.preview();
},
error: (error) => this.toast.error(error.error?.message ?? 'Nie udało się zapisać raportów.')
});
}
preview() {
const payload: ReportPreferences = this.form.getRawValue();
this.reports.preview(payload).subscribe({
next: (response) => {
this.html.set(response.html);
this.summary.set(response.summary);
},
error: (error) => this.toast.error(error.error?.message ?? 'Nie udało się pobrać podglądu.')
});
}
send() {
this.reports.send().subscribe({
next: (response) => this.toast.success(`Raport wysłano na ${response.sentTo}.`),
error: (error) => this.toast.error(error.error?.message ?? 'Nie udało się wysłać raportu.')
});
}
}

View File

@@ -0,0 +1,233 @@
import { CommonModule, CurrencyPipe } from '@angular/common';
import { Component, 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 { CategoriesService } from '../../core/services/categories.service';
import { StatsService } from '../../core/services/stats.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
);
@Component({
selector: 'app-stats',
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">Statystyki</h2>
<div class="text-secondary">Analiza miesięczna, kwartalna i roczna z podziałem na kategorie i zakres dat.</div>
</div>
</div>
</div>
<div class="row row-cards">
<div class="col-12">
<div class="card overflow-hidden">
<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">Okres</label>
<select class="form-select" formControlName="bucket">
<option value="month">Miesięczny</option>
<option value="quarter">Kwartalny</option>
<option value="year">Roczny</option>
</select>
</div>
<div class="col-md-3"><label class="form-label">Od</label><input class="form-control" type="date" formControlName="startDate" /></div>
<div class="col-md-3"><label class="form-label">Do</label><input class="form-control" type="date" formControlName="endDate" /></div>
<div class="col-md-3">
<label class="form-label">Kategorie</label>
<app-category-picker
[items]="categories()"
[selectedIds]="form.getRawValue().categoryIds"
placeholder="Wszystkie kategorie"
(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>Pokaż</span>
</button>
<button class="btn btn-outline-secondary" type="button" (click)="reset()">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">Suma</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">Liczba</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">Średnia</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">Udział kategorii</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">Brak danych do wykresu kategorii.</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">Trend wydatków</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">Brak danych do wykresu trendu.</div>
}
</div>
</div>
</div>
<div class="col-12">
<div class="card overflow-hidden">
<div class="card-header"><h3 class="card-title">Podział kategorii</h3></div>
<div class="table-responsive">
<table class="table table-vcenter card-table mb-0">
<thead><tr><th>Kategoria</th><th class="text-end">Kwota</th><th class="text-end">Liczba</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">Brak danych.</td></tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
`
})
export class StatsComponent implements OnInit {
private readonly fb = inject(FormBuilder);
private readonly categoriesService = inject(CategoriesService);
private readonly statsService = inject(StatsService);
readonly categories = this.categoriesService.items;
readonly stats = signal<StatsResponse | null>(null);
private categoryChart?: Chart;
private lineChart?: Chart;
readonly form = this.fb.nonNullable.group({
bucket: ['month' as 'month' | 'quarter' | 'year'],
startDate: [''],
endDate: [''],
categoryIds: [[] as string[]]
});
ngOnInit() {
this.categoriesService.ensureLoaded(true);
this.load();
}
setCategoryIds(categoryIds: string[]) {
this.form.patchValue({ categoryIds });
}
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);
setTimeout(() => this.renderCharts(), 0);
}
});
}
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);
}
private renderCharts() {
const current = this.stats();
const categoryCanvas = document.getElementById('statsCategoryChart') as HTMLCanvasElement | null;
const lineCanvas = document.getElementById('statsLineChart') as HTMLCanvasElement | null;
if (categoryCanvas && current?.byCategory?.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) }]
},
options: {
maintainAspectRatio: false,
cutout: '64%',
plugins: { legend: { position: 'bottom' } }
}
});
} 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: 'Wydatki', data: current.timeline.map((item) => item.total), tension: 0.35 }]
},
options: { maintainAspectRatio: false }
});
} else {
this.lineChart?.destroy();
}
}
}

View File

@@ -0,0 +1,112 @@
import { CommonModule } from '@angular/common';
import { Component, inject } from '@angular/core';
import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { AuthService } from '../core/services/auth.service';
import { AppSettingsService } from '../core/services/app-settings.service';
import { UiService } from '../core/services/ui.service';
@Component({
selector: 'app-shell',
standalone: true,
imports: [CommonModule, RouterOutlet, RouterLink, RouterLinkActive],
template: `
<div class="page">
<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>
</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>
</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>
</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="page-wrapper">
<div class="page-body">
<div class="container-xl">
<router-outlet></router-outlet>
</div>
</div>
</div>
</div>
`
})
export class ShellComponent {
readonly auth = inject(AuthService);
readonly ui = inject(UiService);
readonly appSettings = inject(AppSettingsService);
private readonly router = inject(Router);
logout() {
this.auth.logout();
this.router.navigate(['/login']);
}
}

View File

@@ -0,0 +1,92 @@
export interface User {
id: string;
fullName: string;
email: string;
role: 'ADMIN' | 'USER';
isActive: boolean;
defaultCurrency: string;
reportPreferences?: ReportPreferences;
createdAt: string;
}
export interface Category {
id: string;
name: string;
color: string;
isSystem: boolean;
ownerId: string | null;
}
export interface Merchant {
id: string;
name: string;
kind: 'MERCHANT' | 'SERVICE_PROVIDER' | 'OTHER';
notes: string | null;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface Proof {
id: string;
type: 'RECEIPT' | 'INVOICE' | 'NOTE' | 'BANK_STATEMENT' | 'OTHER';
label: string | null;
note: string | null;
originalName: string | null;
mimeType: string | null;
fileSize: number | null;
fileUrl: string | null;
createdAt: string;
}
export interface Expense {
id: string;
title: string;
description: string | null;
amount: number;
expenseDate: string;
merchant: string | null;
paymentMethod: 'CARD' | 'CASH' | 'TRANSFER' | 'BLIK' | 'OTHER' | null;
currency: string;
possibleDuplicate: boolean;
category: Category;
proofs: Proof[];
createdAt: string;
updatedAt: string;
}
export interface StatsResponse {
total: number;
count: number;
average: number;
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 }>;
}
export interface AppSettings {
id: string;
appName: string;
defaultCurrency: string;
registrationEnabled: boolean;
allowedProofTypes: string[];
uiPreferences: Record<string, string | number | boolean>;
smtpEnabled: boolean;
smtpHost: string | null;
smtpPort: number;
smtpSecure: boolean;
smtpUser: string | null;
smtpPassword: string | null;
smtpFromName: string | null;
smtpFromEmail: string | null;
createdAt: string;
updatedAt: string;
}
export interface ReportPreferences {
enabled: boolean;
frequency: 'monthly' | 'yearly' | 'threshold';
thresholdAmount: number;
sendToEmail: string | null;
categoryIds: string[];
}

View File

@@ -0,0 +1,79 @@
import { CommonModule } from '@angular/common';
import { Component, HostListener, computed, input, output, signal } from '@angular/core';
import type { Category } from '../models';
@Component({
selector: 'app-category-picker',
standalone: true,
imports: [CommonModule],
template: `
<div class="dropdown w-100">
<button class="form-select text-start d-flex align-items-center justify-content-between gap-2" type="button" (click)="toggle($event)">
<span class="d-flex flex-wrap gap-2 align-items-center">
@if (selectedItems().length) {
@for (item of selectedItems(); track item.id) {
<span class="badge text-bg-dark d-inline-flex align-items-center gap-1">
<span class="badge rounded-pill" [style.background]="item.color">&nbsp;</span>
{{ item.name }}
</span>
}
} @else {
<span class="text-secondary">{{ placeholder() }}</span>
}
</span>
<span class="text-secondary small">{{ selectedItems().length ? selectedItems().length : '' }}</span>
</button>
@if (open()) {
<div class="dropdown-menu show w-100 p-2 shadow-sm">
<div class="d-grid gap-1" style="max-height: 18rem; overflow: auto;">
@for (item of items(); track item.id) {
<label class="dropdown-item rounded-2 d-flex align-items-center justify-content-between gap-3" (click)="$event.stopPropagation()">
<span class="d-flex align-items-center gap-2">
<input class="form-check-input m-0" type="checkbox" [checked]="isSelected(item.id)" (change)="toggleItem(item.id)" />
<span class="badge rounded-pill" [style.background]="item.color">&nbsp;</span>
<span>{{ item.name }}</span>
</span>
@if (isSelected(item.id)) {
<span class="badge text-bg-success">OK</span>
}
</label>
} @empty {
<div class="dropdown-item text-secondary">Brak kategorii.</div>
}
</div>
</div>
}
</div>
`
})
export class CategoryPickerComponent {
readonly items = input<Category[]>([]);
readonly selectedIds = input<string[]>([]);
readonly placeholder = input('Wybierz kategorie');
readonly changed = output<string[]>();
readonly open = signal(false);
readonly selectedItems = computed(() => this.items().filter((item) => this.selectedIds().includes(item.id)));
toggle(event?: Event) {
event?.stopPropagation();
this.open.update((value) => !value);
}
isSelected(id: string) {
return this.selectedIds().includes(id);
}
toggleItem(id: string) {
const next = this.isSelected(id)
? this.selectedIds().filter((item) => item !== id)
: [...this.selectedIds(), id];
this.changed.emit(next);
}
@HostListener('document:click')
closeOnOutsideClick() {
this.open.set(false);
}
}

View File

@@ -0,0 +1,43 @@
import { CommonModule } from '@angular/common';
import { Component, inject } from '@angular/core';
import { ToastItem, ToastService } from '../../core/services/toast.service';
@Component({
selector: 'app-toast-outlet',
standalone: true,
imports: [CommonModule],
template: `
<div class="toast-host position-fixed top-0 end-0 p-3">
@for (item of toast.items(); track item.id) {
<div class="toast show ec-toast"
role="alert"
aria-live="assertive"
aria-atomic="true"
[class.ec-toast-success]="item.tone === 'success'"
[class.ec-toast-danger]="item.tone === 'danger'"
[class.ec-toast-warning]="item.tone === 'warning'"
[class.ec-toast-info]="item.tone === 'info'">
<div class="toast-header">
<span class="ec-toast-dot me-2" [ngClass]="toneDotClass(item)"></span>
<strong class="me-auto">{{ item.title }}</strong>
<button type="button" class="btn-close" aria-label="Close" (click)="toast.dismiss(item.id)"></button>
</div>
<div class="toast-body">{{ item.message }}</div>
</div>
}
</div>
`
})
export class ToastOutletComponent {
readonly toast = inject(ToastService);
toneDotClass(item: ToastItem) {
return item.tone === 'success'
? 'bg-success'
: item.tone === 'danger'
? 'bg-danger'
: item.tone === 'warning'
? 'bg-warning'
: 'bg-info';
}
}