split html's

This commit is contained in:
Mateusz Gruszczyński
2026-04-08 11:08:41 +02:00
parent f0f20e416e
commit 57cc30427a
38 changed files with 1625 additions and 1643 deletions

4
api/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "expense-control-api", "name": "expense-control-api",
"version": "1.0.0", "version": "0.0.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "expense-control-api", "name": "expense-control-api",
"version": "1.0.0", "version": "0.0.1",
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"cors": "^2.8.5", "cors": "^2.8.5",

4
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "expense-control-web", "name": "expense-control-web",
"version": "0.0.0", "version": "0.0.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "expense-control-web", "name": "expense-control-web",
"version": "0.0.0", "version": "0.0.1",
"dependencies": { "dependencies": {
"@angular/common": "^21.2.7", "@angular/common": "^21.2.7",
"@angular/compiler": "^21.2.7", "@angular/compiler": "^21.2.7",

2
web/src/app/app.html Normal file
View File

@@ -0,0 +1,2 @@
<router-outlet></router-outlet>
<app-toast-outlet></app-toast-outlet>

View File

@@ -8,10 +8,7 @@ import { ToastOutletComponent } from './shared/ui/toast-outlet.component';
selector: 'app-root', selector: 'app-root',
standalone: true, standalone: true,
imports: [RouterOutlet, ToastOutletComponent], imports: [RouterOutlet, ToastOutletComponent],
template: ` templateUrl: './app.html'
<router-outlet></router-outlet>
<app-toast-outlet></app-toast-outlet>
`
}) })
export class App { export class App {
private readonly auth = inject(AuthService); private readonly auth = inject(AuthService);

View File

@@ -0,0 +1,211 @@
<div class="page-header d-print-none mb-3 ec-page-header">
<div class="row align-items-center g-3">
<div class="col">
<h2 class="page-title mb-1">{{ ui.t('admin.title') }}</h2>
<div class="text-secondary">{{ ui.t('admin.subtitle') }}</div>
</div>
</div>
</div>
@if (systemInfo()) {
<div class="row row-cards mb-3">
<div class="col-12">
<div class="card pv-card overflow-hidden ec-accent-card ec-accent-card-info">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<div>
<h3 class="card-title mb-1">{{ ui.t('admin.techTitle') }}</h3>
<div class="text-secondary small">{{ ui.t('admin.techSubtitle') }}</div>
</div>
<span class="badge bg-info">{{ systemInfo()!.environment }}</span>
</div>
<div class="card-body">
<div class="row g-3 mb-3">
<div class="col-sm-6 col-xl-3"><div class="ec-stat-tile"><div class="ec-stat-label">{{ ui.t('admin.appVersion') }}</div><div class="ec-stat-value">{{ systemInfo()!.suiteVersion }}</div></div></div>
<div class="col-sm-6 col-xl-3"><div class="ec-stat-tile"><div class="ec-stat-label">API</div><div class="ec-stat-value">{{ systemInfo()!.apiVersion }}</div></div></div>
<div class="col-sm-6 col-xl-3"><div class="ec-stat-tile"><div class="ec-stat-label">Web</div><div class="ec-stat-value">{{ systemInfo()!.webVersion }}</div></div></div>
<div class="col-sm-6 col-xl-3"><div class="ec-stat-tile"><div class="ec-stat-label">Node.js</div><div class="ec-stat-value">{{ systemInfo()!.nodeVersion }}</div></div></div>
</div>
<div class="row g-3">
<div class="col-lg-7">
<div class="table-responsive">
<table class="table table-sm table-vcenter mb-0">
<tbody>
<tr><td class="text-secondary">{{ ui.t('admin.database') }}</td><td class="fw-semibold">{{ systemInfo()!.database }}</td></tr>
<tr><td class="text-secondary">Upload dir</td><td class="fw-semibold text-break">{{ systemInfo()!.uploadDir }}</td></tr>
<tr><td class="text-secondary">{{ ui.t('admin.registration') }}</td><td><span class="badge" [class.bg-success]="systemInfo()!.registrationEnabled" [class.bg-secondary]="!systemInfo()!.registrationEnabled">{{ systemInfo()!.registrationEnabled ? ui.t('common.active') : ui.t('common.blocked') }}</span></td></tr>
<tr><td class="text-secondary">SMTP</td><td><span class="badge" [class.bg-success]="systemInfo()!.smtpConfigured" [class.bg-warning]="!systemInfo()!.smtpConfigured">{{ systemInfo()!.smtpConfigured ? ui.t('admin.smtpReady') : ui.t('admin.smtpNotReady') }}</span></td></tr>
<tr><td class="text-secondary">API base</td><td class="fw-semibold">{{ systemInfo()!.sources.apiBasePath }}</td></tr>
<tr><td class="text-secondary">{{ ui.t('table.date') }}</td><td class="fw-semibold">{{ systemInfo()!.checkedAt | date:'yyyy-MM-dd HH:mm:ss' }}</td></tr>
</tbody>
</table>
</div>
</div>
<div class="col-lg-5">
<div class="row g-2">
<div class="col-6"><div class="ec-mini-kpi"><span>{{ ui.t('admin.kpi.users') }}</span><strong>{{ systemInfo()!.counters.users }}</strong></div></div>
<div class="col-6"><div class="ec-mini-kpi"><span>{{ ui.t('admin.kpi.expenses') }}</span><strong>{{ systemInfo()!.counters.expenses }}</strong></div></div>
<div class="col-6"><div class="ec-mini-kpi"><span>{{ ui.t('admin.kpi.categories') }}</span><strong>{{ systemInfo()!.counters.categories }}</strong></div></div>
<div class="col-6"><div class="ec-mini-kpi"><span>{{ ui.t('admin.kpi.merchants') }}</span><strong>{{ systemInfo()!.counters.merchants }}</strong></div></div>
<div class="col-6"><div class="ec-mini-kpi"><span>{{ ui.t('admin.kpi.budgets') }}</span><strong>{{ systemInfo()!.counters.budgets }}</strong></div></div>
<div class="col-6"><div class="ec-mini-kpi"><span>{{ ui.t('admin.kpi.recurring') }}</span><strong>{{ systemInfo()!.counters.recurring }}</strong></div></div>
<div class="col-12"><div class="ec-mini-kpi"><span>{{ ui.t('admin.kpi.integrations') }}</span><strong>{{ systemInfo()!.counters.shoppingIntegrations }}</strong></div></div>
</div>
<div class="mt-3 d-flex gap-2 flex-wrap">
<a class="btn btn-outline-primary btn-sm" href="https://git.linuxiarz.pl/gru/expense-control" target="_blank" rel="noreferrer">{{ ui.t('footer.source') }}</a>
<a class="btn btn-outline-secondary btn-sm" href="https://git.linuxiarz.pl/gru/lista_zakupowa_live" target="_blank" rel="noreferrer">{{ ui.t('footer.shoppingSource') }}</a>
</div>
</div>
</div>
</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">{{ ui.t('admin.settings') }}</h3></div>
<div class="card-body">
<form [formGroup]="form" (ngSubmit)="save()" class="d-grid gap-3">
<div>
<label class="form-label">{{ ui.t('admin.appName') }}</label>
<input class="form-control" formControlName="appName" />
</div>
<div class="row g-3">
<div class="col-md-6"><label class="form-label">{{ ui.t('admin.defaultCurrency') }}</label><input class="form-control" formControlName="defaultCurrency" /></div>
<div class="col-md-6"><label class="form-label">{{ ui.t('admin.allowedProofTypes') }}</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">{{ ui.t('admin.registration') }}</span>
</label>
<hr class="my-2" />
<div class="fw-semibold">{{ ui.t('admin.smtp') }}</div>
<label class="form-check">
<input class="form-check-input" type="checkbox" formControlName="smtpEnabled" />
<span class="form-check-label">{{ ui.t('admin.smtpEnabled') }}</span>
</label>
<div class="row g-3">
<div class="col-md-7"><label class="form-label">{{ ui.t('admin.host') }}</label><input class="form-control" formControlName="smtpHost" /></div>
<div class="col-md-5"><label class="form-label">{{ ui.t('admin.port') }}</label><input class="form-control" type="number" formControlName="smtpPort" /></div>
<div class="col-md-6"><label class="form-label">{{ ui.t('admin.user') }}</label><input class="form-control" formControlName="smtpUser" /></div>
<div class="col-md-6"><label class="form-label">{{ ui.t('admin.password') }}</label><input class="form-control" type="password" formControlName="smtpPassword" /></div>
<div class="col-md-6"><label class="form-label">{{ ui.t('admin.fromName') }}</label><input class="form-control" formControlName="smtpFromName" /></div>
<div class="col-md-6"><label class="form-label">{{ ui.t('admin.fromEmail') }}</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">{{ ui.t('admin.secureConnection') }}</span>
</label>
<div class="small text-secondary">{{ ui.t('admin.smtpHint') }}</div>
<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>{{ ui.t('action.save') }}</span>
</button>
<button class="btn btn-outline-info" type="button" (click)="sendTest()">{{ ui.t('action.testSmtp') }}</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">{{ ui.t('admin.users') }}</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>{{ ui.t('admin.userLabel') }}</th><th>{{ ui.t('admin.role') }}</th><th>{{ ui.t('admin.status') }}</th><th>{{ ui.t('admin.integrationsAccess') }}</th><th>{{ ui.t('admin.date') }}</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 ? ui.t('common.active') : ui.t('common.blocked') }}
</span>
</td>
<td><span class="badge" [class.bg-success]="user.integrationsEnabled" [class.bg-secondary]="!user.integrationsEnabled">{{ user.integrationsEnabled ? ui.t('common.active') : ui.t('common.blocked') }}</span></td>
<td>{{ user.createdAt | date:'short' }}</td>
<td>
<div class="btn-list flex-wrap justify-content-end">
<button class="btn btn-outline-secondary btn-sm" type="button" (click)="startEdit(user)">{{ ui.t('action.edit') }}</button>
<button class="btn btn-outline-warning btn-sm" type="button" (click)="toggleRole(user)">
{{ user.role === 'ADMIN' ? ui.t('action.setUser') : ui.t('action.setAdmin') }}
</button>
<button class="btn btn-sm" [class.btn-danger]="user.isActive" [class.btn-success]="!user.isActive" type="button" (click)="toggleActive(user)">
{{ user.isActive ? ui.t('action.block') : ui.t('action.unblock') }}
</button>
<button class="btn btn-sm" [class.btn-outline-primary]="!user.integrationsEnabled" [class.btn-primary]="user.integrationsEnabled" type="button" (click)="toggleIntegrations(user)">
{{ user.integrationsEnabled ? ui.t('action.disableIntegrations') : ui.t('action.enableIntegrations') }}
</button>
</div>
</td>
</tr>
@if (editingUserId() === user.id) {
<tr>
<td colspan="6" class="bg-body-secondary">
<form [formGroup]="editUserForm" (ngSubmit)="saveUser()" class="row g-3 p-2">
<div class="col-md-6"><label class="form-label">{{ ui.t('login.fullName') }}</label><input class="form-control" formControlName="fullName" /></div>
<div class="col-md-6"><label class="form-label">{{ ui.t('login.email') }}</label><input class="form-control" formControlName="email" /></div>
<div class="col-md-4"><label class="form-label">{{ ui.t('admin.password') }}</label><input class="form-control" type="password" formControlName="password" /></div>
<div class="col-md-4"><label class="form-label">{{ ui.t('admin.role') }}</label><select class="form-select" formControlName="role"><option value="USER">USER</option><option value="ADMIN">ADMIN</option></select></div>
<div class="col-md-4"><label class="form-label">{{ ui.t('admin.defaultCurrency') }}</label><input class="form-control" formControlName="defaultCurrency" /></div>
<div class="col-12 small text-secondary">{{ ui.t('admin.passwordHint') }}</div>
<div class="col-12 d-flex gap-3 flex-wrap">
<label class="form-check mb-0"><input class="form-check-input" type="checkbox" formControlName="isActive" /><span class="form-check-label">{{ ui.t('common.active') }}</span></label>
<label class="form-check mb-0"><input class="form-check-input" type="checkbox" formControlName="integrationsEnabled" /><span class="form-check-label">{{ ui.t('admin.integrationsAccess') }}</span></label>
</div>
<div class="col-12 d-flex gap-2 flex-wrap">
<button class="btn btn-primary btn-sm" [disabled]="editUserForm.invalid || userSaving()">{{ ui.t('action.saveChanges') }}</button>
<button class="btn btn-outline-secondary btn-sm" type="button" (click)="cancelEdit()">{{ ui.t('action.cancel') }}</button>
</div>
</form>
</td>
</tr>
}
} @empty {
<tr><td colspan="6" class="text-secondary">{{ ui.t('admin.noUsers') }}</td></tr>
}
</tbody>
</table>
</div>
</div>
<div class="card pv-card overflow-hidden mt-3">
<div class="card-header"><h3 class="card-title">{{ ui.t('admin.newUser') }}</h3></div>
<div class="card-body">
<form [formGroup]="createUserForm" (ngSubmit)="createUser()" class="row g-3">
<div class="col-12"><label class="form-label">{{ ui.t('login.fullName') }}</label><input class="form-control" formControlName="fullName" /></div>
<div class="col-12"><label class="form-label">{{ ui.t('login.email') }}</label><input class="form-control" formControlName="email" /></div>
<div class="col-12"><label class="form-label">{{ ui.t('admin.password') }}</label><input class="form-control" type="password" formControlName="password" /></div>
<div class="col-md-6"><label class="form-label">{{ ui.t('admin.role') }}</label><select class="form-select" formControlName="role"><option value="USER">USER</option><option value="ADMIN">ADMIN</option></select></div>
<div class="col-md-6"><label class="form-label">{{ ui.t('admin.defaultCurrency') }}</label><input class="form-control" formControlName="defaultCurrency" /></div>
<div class="col-12 d-flex gap-3 flex-wrap">
<label class="form-check mb-0"><input class="form-check-input" type="checkbox" formControlName="isActive" /><span class="form-check-label">{{ ui.t('common.active') }}</span></label>
<label class="form-check mb-0"><input class="form-check-input" type="checkbox" formControlName="integrationsEnabled" /><span class="form-check-label">{{ ui.t('admin.integrationsAccess') }}</span></label>
</div>
<div class="col-12">
<button class="btn btn-primary" [disabled]="createUserForm.invalid || userSaving()">{{ ui.t('action.addUser') }}</button>
</div>
</form>
</div>
</div>
</div>
</div>

View File

@@ -11,219 +11,7 @@ import type { AdminSystemInfo, AppSettings, User } from '../../shared/models';
selector: 'app-admin', selector: 'app-admin',
standalone: true, standalone: true,
imports: [CommonModule, ReactiveFormsModule, DatePipe], imports: [CommonModule, ReactiveFormsModule, DatePipe],
template: ` templateUrl: './admin.component.html'
<div class="page-header d-print-none mb-3 ec-page-header">
<div class="row align-items-center g-3">
<div class="col">
<h2 class="page-title mb-1">{{ ui.t('admin.title') }}</h2>
<div class="text-secondary">{{ ui.t('admin.subtitle') }}</div>
</div>
</div>
</div>
@if (systemInfo()) {
<div class="row row-cards mb-3">
<div class="col-12">
<div class="card pv-card overflow-hidden ec-accent-card ec-accent-card-info">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<div>
<h3 class="card-title mb-1">{{ ui.t('admin.techTitle') }}</h3>
<div class="text-secondary small">{{ ui.t('admin.techSubtitle') }}</div>
</div>
<span class="badge bg-info">{{ systemInfo()!.environment }}</span>
</div>
<div class="card-body">
<div class="row g-3 mb-3">
<div class="col-sm-6 col-xl-3"><div class="ec-stat-tile"><div class="ec-stat-label">{{ ui.t('admin.appVersion') }}</div><div class="ec-stat-value">{{ systemInfo()!.suiteVersion }}</div></div></div>
<div class="col-sm-6 col-xl-3"><div class="ec-stat-tile"><div class="ec-stat-label">API</div><div class="ec-stat-value">{{ systemInfo()!.apiVersion }}</div></div></div>
<div class="col-sm-6 col-xl-3"><div class="ec-stat-tile"><div class="ec-stat-label">Web</div><div class="ec-stat-value">{{ systemInfo()!.webVersion }}</div></div></div>
<div class="col-sm-6 col-xl-3"><div class="ec-stat-tile"><div class="ec-stat-label">Node.js</div><div class="ec-stat-value">{{ systemInfo()!.nodeVersion }}</div></div></div>
</div>
<div class="row g-3">
<div class="col-lg-7">
<div class="table-responsive">
<table class="table table-sm table-vcenter mb-0">
<tbody>
<tr><td class="text-secondary">{{ ui.t('admin.database') }}</td><td class="fw-semibold">{{ systemInfo()!.database }}</td></tr>
<tr><td class="text-secondary">Upload dir</td><td class="fw-semibold text-break">{{ systemInfo()!.uploadDir }}</td></tr>
<tr><td class="text-secondary">{{ ui.t('admin.registration') }}</td><td><span class="badge" [class.bg-success]="systemInfo()!.registrationEnabled" [class.bg-secondary]="!systemInfo()!.registrationEnabled">{{ systemInfo()!.registrationEnabled ? ui.t('common.active') : ui.t('common.blocked') }}</span></td></tr>
<tr><td class="text-secondary">SMTP</td><td><span class="badge" [class.bg-success]="systemInfo()!.smtpConfigured" [class.bg-warning]="!systemInfo()!.smtpConfigured">{{ systemInfo()!.smtpConfigured ? ui.t('admin.smtpReady') : ui.t('admin.smtpNotReady') }}</span></td></tr>
<tr><td class="text-secondary">API base</td><td class="fw-semibold">{{ systemInfo()!.sources.apiBasePath }}</td></tr>
<tr><td class="text-secondary">{{ ui.t('table.date') }}</td><td class="fw-semibold">{{ systemInfo()!.checkedAt | date:'yyyy-MM-dd HH:mm:ss' }}</td></tr>
</tbody>
</table>
</div>
</div>
<div class="col-lg-5">
<div class="row g-2">
<div class="col-6"><div class="ec-mini-kpi"><span>{{ ui.t('admin.kpi.users') }}</span><strong>{{ systemInfo()!.counters.users }}</strong></div></div>
<div class="col-6"><div class="ec-mini-kpi"><span>{{ ui.t('admin.kpi.expenses') }}</span><strong>{{ systemInfo()!.counters.expenses }}</strong></div></div>
<div class="col-6"><div class="ec-mini-kpi"><span>{{ ui.t('admin.kpi.categories') }}</span><strong>{{ systemInfo()!.counters.categories }}</strong></div></div>
<div class="col-6"><div class="ec-mini-kpi"><span>{{ ui.t('admin.kpi.merchants') }}</span><strong>{{ systemInfo()!.counters.merchants }}</strong></div></div>
<div class="col-6"><div class="ec-mini-kpi"><span>{{ ui.t('admin.kpi.budgets') }}</span><strong>{{ systemInfo()!.counters.budgets }}</strong></div></div>
<div class="col-6"><div class="ec-mini-kpi"><span>{{ ui.t('admin.kpi.recurring') }}</span><strong>{{ systemInfo()!.counters.recurring }}</strong></div></div>
<div class="col-12"><div class="ec-mini-kpi"><span>{{ ui.t('admin.kpi.integrations') }}</span><strong>{{ systemInfo()!.counters.shoppingIntegrations }}</strong></div></div>
</div>
<div class="mt-3 d-flex gap-2 flex-wrap">
<a class="btn btn-outline-primary btn-sm" href="https://git.linuxiarz.pl/gru/expense-control" target="_blank" rel="noreferrer">{{ ui.t('footer.source') }}</a>
<a class="btn btn-outline-secondary btn-sm" href="https://git.linuxiarz.pl/gru/lista_zakupowa_live" target="_blank" rel="noreferrer">{{ ui.t('footer.shoppingSource') }}</a>
</div>
</div>
</div>
</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">{{ ui.t('admin.settings') }}</h3></div>
<div class="card-body">
<form [formGroup]="form" (ngSubmit)="save()" class="d-grid gap-3">
<div>
<label class="form-label">{{ ui.t('admin.appName') }}</label>
<input class="form-control" formControlName="appName" />
</div>
<div class="row g-3">
<div class="col-md-6"><label class="form-label">{{ ui.t('admin.defaultCurrency') }}</label><input class="form-control" formControlName="defaultCurrency" /></div>
<div class="col-md-6"><label class="form-label">{{ ui.t('admin.allowedProofTypes') }}</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">{{ ui.t('admin.registration') }}</span>
</label>
<hr class="my-2" />
<div class="fw-semibold">{{ ui.t('admin.smtp') }}</div>
<label class="form-check">
<input class="form-check-input" type="checkbox" formControlName="smtpEnabled" />
<span class="form-check-label">{{ ui.t('admin.smtpEnabled') }}</span>
</label>
<div class="row g-3">
<div class="col-md-7"><label class="form-label">{{ ui.t('admin.host') }}</label><input class="form-control" formControlName="smtpHost" /></div>
<div class="col-md-5"><label class="form-label">{{ ui.t('admin.port') }}</label><input class="form-control" type="number" formControlName="smtpPort" /></div>
<div class="col-md-6"><label class="form-label">{{ ui.t('admin.user') }}</label><input class="form-control" formControlName="smtpUser" /></div>
<div class="col-md-6"><label class="form-label">{{ ui.t('admin.password') }}</label><input class="form-control" type="password" formControlName="smtpPassword" /></div>
<div class="col-md-6"><label class="form-label">{{ ui.t('admin.fromName') }}</label><input class="form-control" formControlName="smtpFromName" /></div>
<div class="col-md-6"><label class="form-label">{{ ui.t('admin.fromEmail') }}</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">{{ ui.t('admin.secureConnection') }}</span>
</label>
<div class="small text-secondary">{{ ui.t('admin.smtpHint') }}</div>
<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>{{ ui.t('action.save') }}</span>
</button>
<button class="btn btn-outline-info" type="button" (click)="sendTest()">{{ ui.t('action.testSmtp') }}</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">{{ ui.t('admin.users') }}</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>{{ ui.t('admin.userLabel') }}</th><th>{{ ui.t('admin.role') }}</th><th>{{ ui.t('admin.status') }}</th><th>{{ ui.t('admin.integrationsAccess') }}</th><th>{{ ui.t('admin.date') }}</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 ? ui.t('common.active') : ui.t('common.blocked') }}
</span>
</td>
<td><span class="badge" [class.bg-success]="user.integrationsEnabled" [class.bg-secondary]="!user.integrationsEnabled">{{ user.integrationsEnabled ? ui.t('common.active') : ui.t('common.blocked') }}</span></td>
<td>{{ user.createdAt | date:'short' }}</td>
<td>
<div class="btn-list flex-wrap justify-content-end">
<button class="btn btn-outline-secondary btn-sm" type="button" (click)="startEdit(user)">{{ ui.t('action.edit') }}</button>
<button class="btn btn-outline-warning btn-sm" type="button" (click)="toggleRole(user)">
{{ user.role === 'ADMIN' ? ui.t('action.setUser') : ui.t('action.setAdmin') }}
</button>
<button class="btn btn-sm" [class.btn-danger]="user.isActive" [class.btn-success]="!user.isActive" type="button" (click)="toggleActive(user)">
{{ user.isActive ? ui.t('action.block') : ui.t('action.unblock') }}
</button>
<button class="btn btn-sm" [class.btn-outline-primary]="!user.integrationsEnabled" [class.btn-primary]="user.integrationsEnabled" type="button" (click)="toggleIntegrations(user)">
{{ user.integrationsEnabled ? ui.t('action.disableIntegrations') : ui.t('action.enableIntegrations') }}
</button>
</div>
</td>
</tr>
@if (editingUserId() === user.id) {
<tr>
<td colspan="6" class="bg-body-secondary">
<form [formGroup]="editUserForm" (ngSubmit)="saveUser()" class="row g-3 p-2">
<div class="col-md-6"><label class="form-label">{{ ui.t('login.fullName') }}</label><input class="form-control" formControlName="fullName" /></div>
<div class="col-md-6"><label class="form-label">{{ ui.t('login.email') }}</label><input class="form-control" formControlName="email" /></div>
<div class="col-md-4"><label class="form-label">{{ ui.t('admin.password') }}</label><input class="form-control" type="password" formControlName="password" /></div>
<div class="col-md-4"><label class="form-label">{{ ui.t('admin.role') }}</label><select class="form-select" formControlName="role"><option value="USER">USER</option><option value="ADMIN">ADMIN</option></select></div>
<div class="col-md-4"><label class="form-label">{{ ui.t('admin.defaultCurrency') }}</label><input class="form-control" formControlName="defaultCurrency" /></div>
<div class="col-12 small text-secondary">{{ ui.t('admin.passwordHint') }}</div>
<div class="col-12 d-flex gap-3 flex-wrap">
<label class="form-check mb-0"><input class="form-check-input" type="checkbox" formControlName="isActive" /><span class="form-check-label">{{ ui.t('common.active') }}</span></label>
<label class="form-check mb-0"><input class="form-check-input" type="checkbox" formControlName="integrationsEnabled" /><span class="form-check-label">{{ ui.t('admin.integrationsAccess') }}</span></label>
</div>
<div class="col-12 d-flex gap-2 flex-wrap">
<button class="btn btn-primary btn-sm" [disabled]="editUserForm.invalid || userSaving()">{{ ui.t('action.saveChanges') }}</button>
<button class="btn btn-outline-secondary btn-sm" type="button" (click)="cancelEdit()">{{ ui.t('action.cancel') }}</button>
</div>
</form>
</td>
</tr>
}
} @empty {
<tr><td colspan="6" class="text-secondary">{{ ui.t('admin.noUsers') }}</td></tr>
}
</tbody>
</table>
</div>
</div>
<div class="card pv-card overflow-hidden mt-3">
<div class="card-header"><h3 class="card-title">{{ ui.t('admin.newUser') }}</h3></div>
<div class="card-body">
<form [formGroup]="createUserForm" (ngSubmit)="createUser()" class="row g-3">
<div class="col-12"><label class="form-label">{{ ui.t('login.fullName') }}</label><input class="form-control" formControlName="fullName" /></div>
<div class="col-12"><label class="form-label">{{ ui.t('login.email') }}</label><input class="form-control" formControlName="email" /></div>
<div class="col-12"><label class="form-label">{{ ui.t('admin.password') }}</label><input class="form-control" type="password" formControlName="password" /></div>
<div class="col-md-6"><label class="form-label">{{ ui.t('admin.role') }}</label><select class="form-select" formControlName="role"><option value="USER">USER</option><option value="ADMIN">ADMIN</option></select></div>
<div class="col-md-6"><label class="form-label">{{ ui.t('admin.defaultCurrency') }}</label><input class="form-control" formControlName="defaultCurrency" /></div>
<div class="col-12 d-flex gap-3 flex-wrap">
<label class="form-check mb-0"><input class="form-check-input" type="checkbox" formControlName="isActive" /><span class="form-check-label">{{ ui.t('common.active') }}</span></label>
<label class="form-check mb-0"><input class="form-check-input" type="checkbox" formControlName="integrationsEnabled" /><span class="form-check-label">{{ ui.t('admin.integrationsAccess') }}</span></label>
</div>
<div class="col-12">
<button class="btn btn-primary" [disabled]="createUserForm.invalid || userSaving()">{{ ui.t('action.addUser') }}</button>
</div>
</form>
</div>
</div>
</div>
</div>
`
}) })
export class AdminComponent implements OnInit { export class AdminComponent implements OnInit {
readonly ui = inject(UiService); readonly ui = inject(UiService);

View File

@@ -0,0 +1,81 @@
<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-flex gap-2">
<button class="btn btn-icon btn-ghost-secondary"
type="button"
[attr.aria-label]="ui.t('theme.label')"
[attr.title]="ui.t('theme.label')"
(click)="ui.toggleTheme()">
@if (ui.theme() === 'dark') {
<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 3l0 1"/><path d="M12 20l0 1"/><path d="M3 12l1 0"/><path d="M20 12l1 0"/><path d="M5.6 5.6l.7 .7"/><path d="M18.4 18.4l.7 .7"/><path d="M18.4 5.6l-.7 .7"/><path d="M5.6 18.4l-.7 .7"/><path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"/></svg>
} @else {
<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 3c.132 0 .263 0 .393 .007a9 9 0 1 0 0 17.986a9 9 0 0 1 -.393 -17.993z"/></svg>
}
</button>
<button class="btn btn-icon btn-ghost-secondary"
type="button"
[attr.aria-label]="currentLanguageLabel()"
[attr.title]="currentLanguageLabel()"
(click)="toggleLanguage()">
<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="M4 5h7"/><path d="M7 4c0 4.846 0 7 .5 8"/><path d="M10 8l-3 4l-3 -4"/><path d="M19 22l0 -3"/><path d="M17 19h4"/><path d="M20 19l-3 -7l-3 7"/><path d="M11 19l4 0"/></svg>
</button>
</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>
@if (errorMessage()) {
<div class="alert alert-danger py-2 mb-0">{{ errorMessage() }}</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="M15 15l6 6"/><path d="M4 11a7 7 0 1 1 14 0a7 7 0 0 1 -14 0"/></svg>
{{ loading() ? (mode() === 'login' ? ui.t('action.loggingIn') : ui.t('action.creatingAccount')) : (mode() === 'login' ? ui.t('action.login') : ui.t('action.createAccount')) }}
</button>
</form>
<div class="login-footer-note">
{{ mode() === 'login' ? ui.t('login.footer') : ui.t('register.footer') }}
</div>
@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)="switchMode()">
{{ mode() === 'login' ? ui.t('action.registerMode') : ui.t('action.loginMode') }}
</button>
</div>
}
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -11,89 +11,7 @@ import { UiService } from '../../core/services/ui.service';
selector: 'app-login', selector: 'app-login',
standalone: true, standalone: true,
imports: [CommonModule, ReactiveFormsModule], imports: [CommonModule, ReactiveFormsModule],
template: ` templateUrl: './login.component.html'
<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-flex gap-2">
<button class="btn btn-icon btn-ghost-secondary"
type="button"
[attr.aria-label]="ui.t('theme.label')"
[attr.title]="ui.t('theme.label')"
(click)="ui.toggleTheme()">
@if (ui.theme() === 'dark') {
<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 3l0 1"/><path d="M12 20l0 1"/><path d="M3 12l1 0"/><path d="M20 12l1 0"/><path d="M5.6 5.6l.7 .7"/><path d="M18.4 18.4l.7 .7"/><path d="M18.4 5.6l-.7 .7"/><path d="M5.6 18.4l-.7 .7"/><path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"/></svg>
} @else {
<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 3c.132 0 .263 0 .393 .007a9 9 0 1 0 0 17.986a9 9 0 0 1 -.393 -17.993z"/></svg>
}
</button>
<button class="btn btn-icon btn-ghost-secondary"
type="button"
[attr.aria-label]="currentLanguageLabel()"
[attr.title]="currentLanguageLabel()"
(click)="toggleLanguage()">
<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="M4 5h7"/><path d="M7 4c0 4.846 0 7 .5 8"/><path d="M10 8l-3 4l-3 -4"/><path d="M19 22l0 -3"/><path d="M17 19h4"/><path d="M20 19l-3 -7l-3 7"/><path d="M11 19l4 0"/></svg>
</button>
</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>
@if (errorMessage()) {
<div class="alert alert-danger py-2 mb-0">{{ errorMessage() }}</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="M15 15l6 6"/><path d="M4 11a7 7 0 1 1 14 0a7 7 0 0 1 -14 0"/></svg>
{{ loading() ? (mode() === 'login' ? ui.t('action.loggingIn') : ui.t('action.creatingAccount')) : (mode() === 'login' ? ui.t('action.login') : ui.t('action.createAccount')) }}
</button>
</form>
<div class="login-footer-note">
{{ mode() === 'login' ? ui.t('login.footer') : ui.t('register.footer') }}
</div>
@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)="switchMode()">
{{ mode() === 'login' ? ui.t('action.registerMode') : ui.t('action.loginMode') }}
</button>
</div>
}
</div>
</div>
</div>
</div>
</div>
</div>
`
}) })
export class LoginComponent { export class LoginComponent {
private readonly fb = inject(FormBuilder); private readonly fb = inject(FormBuilder);

View File

@@ -0,0 +1,76 @@
<div class="page-header d-print-none mb-3 ec-page-header">
<div class="row align-items-center g-3">
<div class="col">
<h2 class="page-title mb-1">{{ ui.t('nav.budgets') }}</h2>
<div class="text-secondary">{{ ui.t('budget.subtitle') }}</div>
</div>
</div>
</div>
<div class="row row-cards">
<div class="col-lg-4">
<div class="card overflow-hidden">
<div class="card-header d-flex justify-content-between align-items-center">
<h3 class="card-title">{{ editingId() ? ui.t('budget.edit') : ui.t('budget.new') }}</h3>
@if (editingId()) {
<button class="btn btn-outline-secondary btn-sm" type="button" (click)="cancelEdit()">{{ ui.t('action.cancelEdit') }}</button>
}
</div>
<div class="card-body">
<form [formGroup]="form" (ngSubmit)="save()" class="d-grid gap-3">
<div><label class="form-label">{{ ui.t('budget.month') }}</label><input class="form-control" type="month" formControlName="month" /></div>
<div><label class="form-label">{{ ui.t('budget.name') }}</label><input class="form-control" formControlName="name" /></div>
<div><label class="form-label">{{ ui.t('budget.amount') }}</label><input class="form-control" type="number" step="0.01" formControlName="amount" /></div>
<div>
<label class="form-label">{{ ui.t('budget.category') }}</label>
<select class="form-select" formControlName="categoryId">
<option value="">{{ ui.t('budget.overall') }}</option>
@for (category of categories(); track category.id) { <option [value]="category.id">{{ category.name }}</option> }
</select>
</div>
<div><label class="form-label">{{ ui.t('budget.thresholds') }}</label><input class="form-control" formControlName="thresholdsText" /></div>
<label class="form-check mb-0"><input class="form-check-input" type="checkbox" formControlName="isActive" /><span class="form-check-label">{{ ui.t('common.active') }}</span></label>
<button class="btn btn-success" [disabled]="form.invalid">{{ ui.t('action.save') }}</button>
</form>
</div>
</div>
</div>
<div class="col-lg-8">
<div class="row g-3 mb-3">
<div class="col-sm-4"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('budget.total') }}</div><div class="display-6">{{ summary()?.totalBudget || 0 | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
<div class="col-sm-4"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('budget.spent') }}</div><div class="display-6">{{ summary()?.totalSpent || 0 | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
<div class="col-sm-4"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('budget.alerts') }}</div><div class="display-6">{{ summary()?.alerts?.length || 0 }}</div></div></div></div>
</div>
@if (summary()?.alerts?.length) {
<div class="alert alert-warning">
<div class="fw-semibold mb-2">{{ ui.t('budget.alerts') }}</div>
<div class="d-grid gap-1">@for (alert of summary()!.alerts; track alert.budgetId) { <div>{{ alert.message }}</div> }</div>
</div>
}
<div class="card overflow-hidden">
<div class="card-header d-flex justify-content-between align-items-center">
<h3 class="card-title">{{ ui.t('budget.title') }}</h3>
<input class="form-control form-control-sm" style="max-width: 10rem" type="month" [value]="selectedMonth()" (change)="changeMonth($any($event.target).value)" />
</div>
<div class="table-responsive">
<table class="table table-vcenter card-table mb-0">
<thead><tr><th>{{ ui.t('budget.name') }}</th><th>{{ ui.t('table.category') }}</th><th>{{ ui.t('budget.usage') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th><th></th></tr></thead>
<tbody>
@for (item of items(); track item.id) {
<tr>
<td><div class="fw-semibold">{{ item.name || item.category?.name || ui.t('budget.overall') }}</div><div class="progress progress-sm mt-2"><div class="progress-bar" [style.width.%]="item.usagePercent > 100 ? 100 : item.usagePercent"></div></div></td>
<td>{{ item.category?.name || ui.t('budget.overall') }}</td>
<td><span class="badge" [ngClass]="item.alertLevel ? 'text-bg-warning' : 'text-bg-success'">{{ item.usagePercent }}%</span></td>
<td class="text-end"><div>{{ item.spent | currency:'PLN':'symbol':'1.2-2' }}</div><div class="text-secondary small">/ {{ item.amount | currency:'PLN':'symbol':'1.2-2' }}</div></td>
<td class="text-end"><div class="btn-list justify-content-end flex-nowrap"><button class="btn btn-sm btn-outline-primary" type="button" (click)="edit(item)">{{ ui.t('action.edit') }}</button><button class="btn btn-sm btn-outline-danger" type="button" (click)="remove(item)">{{ ui.t('action.delete') }}</button></div></td>
</tr>
} @empty { <tr><td colspan="5" class="text-secondary">{{ ui.t('common.noData') }}</td></tr> }
</tbody>
</table>
</div>
</div>
</div>
</div>

View File

@@ -16,84 +16,7 @@ const currentMonth = () => {
selector: 'app-budgets', selector: 'app-budgets',
standalone: true, standalone: true,
imports: [CommonModule, ReactiveFormsModule, CurrencyPipe], imports: [CommonModule, ReactiveFormsModule, CurrencyPipe],
template: ` templateUrl: './budgets.component.html'
<div class="page-header d-print-none mb-3 ec-page-header">
<div class="row align-items-center g-3">
<div class="col">
<h2 class="page-title mb-1">{{ ui.t('nav.budgets') }}</h2>
<div class="text-secondary">{{ ui.t('budget.subtitle') }}</div>
</div>
</div>
</div>
<div class="row row-cards">
<div class="col-lg-4">
<div class="card overflow-hidden">
<div class="card-header d-flex justify-content-between align-items-center">
<h3 class="card-title">{{ editingId() ? ui.t('budget.edit') : ui.t('budget.new') }}</h3>
@if (editingId()) {
<button class="btn btn-outline-secondary btn-sm" type="button" (click)="cancelEdit()">{{ ui.t('action.cancelEdit') }}</button>
}
</div>
<div class="card-body">
<form [formGroup]="form" (ngSubmit)="save()" class="d-grid gap-3">
<div><label class="form-label">{{ ui.t('budget.month') }}</label><input class="form-control" type="month" formControlName="month" /></div>
<div><label class="form-label">{{ ui.t('budget.name') }}</label><input class="form-control" formControlName="name" /></div>
<div><label class="form-label">{{ ui.t('budget.amount') }}</label><input class="form-control" type="number" step="0.01" formControlName="amount" /></div>
<div>
<label class="form-label">{{ ui.t('budget.category') }}</label>
<select class="form-select" formControlName="categoryId">
<option value="">{{ ui.t('budget.overall') }}</option>
@for (category of categories(); track category.id) { <option [value]="category.id">{{ category.name }}</option> }
</select>
</div>
<div><label class="form-label">{{ ui.t('budget.thresholds') }}</label><input class="form-control" formControlName="thresholdsText" /></div>
<label class="form-check mb-0"><input class="form-check-input" type="checkbox" formControlName="isActive" /><span class="form-check-label">{{ ui.t('common.active') }}</span></label>
<button class="btn btn-success" [disabled]="form.invalid">{{ ui.t('action.save') }}</button>
</form>
</div>
</div>
</div>
<div class="col-lg-8">
<div class="row g-3 mb-3">
<div class="col-sm-4"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('budget.total') }}</div><div class="display-6">{{ summary()?.totalBudget || 0 | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
<div class="col-sm-4"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('budget.spent') }}</div><div class="display-6">{{ summary()?.totalSpent || 0 | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
<div class="col-sm-4"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('budget.alerts') }}</div><div class="display-6">{{ summary()?.alerts?.length || 0 }}</div></div></div></div>
</div>
@if (summary()?.alerts?.length) {
<div class="alert alert-warning">
<div class="fw-semibold mb-2">{{ ui.t('budget.alerts') }}</div>
<div class="d-grid gap-1">@for (alert of summary()!.alerts; track alert.budgetId) { <div>{{ alert.message }}</div> }</div>
</div>
}
<div class="card overflow-hidden">
<div class="card-header d-flex justify-content-between align-items-center">
<h3 class="card-title">{{ ui.t('budget.title') }}</h3>
<input class="form-control form-control-sm" style="max-width: 10rem" type="month" [value]="selectedMonth()" (change)="changeMonth($any($event.target).value)" />
</div>
<div class="table-responsive">
<table class="table table-vcenter card-table mb-0">
<thead><tr><th>{{ ui.t('budget.name') }}</th><th>{{ ui.t('table.category') }}</th><th>{{ ui.t('budget.usage') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th><th></th></tr></thead>
<tbody>
@for (item of items(); track item.id) {
<tr>
<td><div class="fw-semibold">{{ item.name || item.category?.name || ui.t('budget.overall') }}</div><div class="progress progress-sm mt-2"><div class="progress-bar" [style.width.%]="item.usagePercent > 100 ? 100 : item.usagePercent"></div></div></td>
<td>{{ item.category?.name || ui.t('budget.overall') }}</td>
<td><span class="badge" [ngClass]="item.alertLevel ? 'text-bg-warning' : 'text-bg-success'">{{ item.usagePercent }}%</span></td>
<td class="text-end"><div>{{ item.spent | currency:'PLN':'symbol':'1.2-2' }}</div><div class="text-secondary small">/ {{ item.amount | currency:'PLN':'symbol':'1.2-2' }}</div></td>
<td class="text-end"><div class="btn-list justify-content-end flex-nowrap"><button class="btn btn-sm btn-outline-primary" type="button" (click)="edit(item)">{{ ui.t('action.edit') }}</button><button class="btn btn-sm btn-outline-danger" type="button" (click)="remove(item)">{{ ui.t('action.delete') }}</button></div></td>
</tr>
} @empty { <tr><td colspan="5" class="text-secondary">{{ ui.t('common.noData') }}</td></tr> }
</tbody>
</table>
</div>
</div>
</div>
</div>
`
}) })
export class BudgetsComponent implements OnInit { export class BudgetsComponent implements OnInit {
readonly ui = inject(UiService); readonly ui = inject(UiService);

View File

@@ -0,0 +1,42 @@
<div class="page-header d-print-none mb-3 ec-page-header">
<div class="row align-items-center g-3"><div class="col"><h2 class="page-title mb-1">{{ ui.t('nav.cashflow') }}</h2><div class="text-secondary">{{ ui.t('cashflow.subtitle') }}</div></div></div>
</div>
<div class="row row-cards">
<div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('cashflow.actual') }}</div><div class="display-6">{{ (data()?.actualCurrent || 0) | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
<div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('cashflow.budget') }}</div><div class="display-6">{{ (data()?.totalBudget || 0) | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
<div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('cashflow.forecast') }}</div><div class="display-6">{{ (data()?.forecastCurrentMonth || 0) | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
<div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('cashflow.pending') }}</div><div class="display-6">{{ data()?.pendingApproval || 0 }}</div></div></div></div>
<div class="col-lg-8 d-flex align-items-stretch">
<div class="card pv-card h-100 w-100 overflow-hidden">
<div class="card-header"><h3 class="card-title">{{ ui.t('cashflow.trend') }}</h3></div>
<div class="card-body"><div class="ec-chart-wrap"><canvas id="cashflowTrendChart"></canvas></div></div>
</div>
</div>
<div class="col-lg-4 d-flex align-items-stretch">
<div class="card pv-card h-100 w-100 overflow-hidden">
<div class="card-header"><h3 class="card-title">{{ ui.t('budget.alerts') }}</h3></div>
<div class="card-body d-grid gap-2">
@for (alert of data()?.alerts || []; track alert.id) {
<div class="alert alert-warning mb-0 py-2 px-3">{{ alert.name }} · {{ alert.usagePercent }}%</div>
} @empty {
<div class="text-secondary">{{ ui.t('common.noData') }}</div>
}
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card overflow-hidden">
<div class="card-header"><h3 class="card-title">{{ ui.t('cashflow.statusSummary') }}</h3></div>
<div class="table-responsive"><table class="table table-vcenter card-table mb-0"><thead><tr><th>{{ ui.t('expenses.field.status') }}</th><th class="text-end">{{ ui.t('table.count') }}</th></tr></thead><tbody>@for (item of data()?.statusSummary || []; track item.status) { <tr><td>{{ ui.t('status.' + item.status.toLowerCase()) }}</td><td class="text-end">{{ item.count }}</td></tr> } @empty { <tr><td colspan="2" class="text-secondary">{{ ui.t('common.noData') }}</td></tr> }</tbody></table></div>
</div>
</div>
<div class="col-lg-6">
<div class="card overflow-hidden">
<div class="card-header"><h3 class="card-title">{{ ui.t('cashflow.upcomingRecurring') }}</h3></div>
<div class="table-responsive"><table class="table table-vcenter card-table mb-0"><thead><tr><th>{{ ui.t('table.title') }}</th><th>{{ ui.t('table.date') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th></tr></thead><tbody>@for (item of data()?.upcomingRecurring || []; track item.id) { <tr><td>{{ item.title }}</td><td>{{ item.nextRunDate | date:'yyyy-MM-dd' }}</td><td class="text-end">{{ item.amount | currency:'PLN':'symbol':'1.2-2' }}</td></tr> } @empty { <tr><td colspan="3" class="text-secondary">{{ ui.t('common.noData') }}</td></tr> }</tbody></table></div>
</div>
</div>
</div>

View File

@@ -11,50 +11,7 @@ Chart.register(LineController, LineElement, PointElement, CategoryScale, LinearS
selector: 'app-cashflow', selector: 'app-cashflow',
standalone: true, standalone: true,
imports: [CommonModule, CurrencyPipe, DatePipe], imports: [CommonModule, CurrencyPipe, DatePipe],
template: ` templateUrl: './cashflow.component.html'
<div class="page-header d-print-none mb-3 ec-page-header">
<div class="row align-items-center g-3"><div class="col"><h2 class="page-title mb-1">{{ ui.t('nav.cashflow') }}</h2><div class="text-secondary">{{ ui.t('cashflow.subtitle') }}</div></div></div>
</div>
<div class="row row-cards">
<div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('cashflow.actual') }}</div><div class="display-6">{{ (data()?.actualCurrent || 0) | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
<div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('cashflow.budget') }}</div><div class="display-6">{{ (data()?.totalBudget || 0) | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
<div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('cashflow.forecast') }}</div><div class="display-6">{{ (data()?.forecastCurrentMonth || 0) | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
<div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('cashflow.pending') }}</div><div class="display-6">{{ data()?.pendingApproval || 0 }}</div></div></div></div>
<div class="col-lg-8 d-flex align-items-stretch">
<div class="card pv-card h-100 w-100 overflow-hidden">
<div class="card-header"><h3 class="card-title">{{ ui.t('cashflow.trend') }}</h3></div>
<div class="card-body"><div class="ec-chart-wrap"><canvas id="cashflowTrendChart"></canvas></div></div>
</div>
</div>
<div class="col-lg-4 d-flex align-items-stretch">
<div class="card pv-card h-100 w-100 overflow-hidden">
<div class="card-header"><h3 class="card-title">{{ ui.t('budget.alerts') }}</h3></div>
<div class="card-body d-grid gap-2">
@for (alert of data()?.alerts || []; track alert.id) {
<div class="alert alert-warning mb-0 py-2 px-3">{{ alert.name }} · {{ alert.usagePercent }}%</div>
} @empty {
<div class="text-secondary">{{ ui.t('common.noData') }}</div>
}
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card overflow-hidden">
<div class="card-header"><h3 class="card-title">{{ ui.t('cashflow.statusSummary') }}</h3></div>
<div class="table-responsive"><table class="table table-vcenter card-table mb-0"><thead><tr><th>{{ ui.t('expenses.field.status') }}</th><th class="text-end">{{ ui.t('table.count') }}</th></tr></thead><tbody>@for (item of data()?.statusSummary || []; track item.status) { <tr><td>{{ ui.t('status.' + item.status.toLowerCase()) }}</td><td class="text-end">{{ item.count }}</td></tr> } @empty { <tr><td colspan="2" class="text-secondary">{{ ui.t('common.noData') }}</td></tr> }</tbody></table></div>
</div>
</div>
<div class="col-lg-6">
<div class="card overflow-hidden">
<div class="card-header"><h3 class="card-title">{{ ui.t('cashflow.upcomingRecurring') }}</h3></div>
<div class="table-responsive"><table class="table table-vcenter card-table mb-0"><thead><tr><th>{{ ui.t('table.title') }}</th><th>{{ ui.t('table.date') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th></tr></thead><tbody>@for (item of data()?.upcomingRecurring || []; track item.id) { <tr><td>{{ item.title }}</td><td>{{ item.nextRunDate | date:'yyyy-MM-dd' }}</td><td class="text-end">{{ item.amount | currency:'PLN':'symbol':'1.2-2' }}</td></tr> } @empty { <tr><td colspan="3" class="text-secondary">{{ ui.t('common.noData') }}</td></tr> }</tbody></table></div>
</div>
</div>
</div>
`
}) })
export class CashflowComponent implements OnInit, OnDestroy { export class CashflowComponent implements OnInit, OnDestroy {
readonly ui = inject(UiService); readonly ui = inject(UiService);

View File

@@ -0,0 +1,81 @@
<div class="page-header d-print-none mb-3 ec-page-header">
<div class="row align-items-center g-3">
<div class="col">
<h2 class="page-title mb-1">{{ ui.t('categories.title') }}</h2>
<div class="text-secondary">{{ ui.t('categories.subtitle') }}</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 ? ui.t('categories.edit') : ui.t('categories.new') }}</h3></div>
<div class="card-body">
<form [formGroup]="form" (ngSubmit)="submit()" class="d-grid gap-3">
<div>
<label class="form-label">{{ ui.t('categories.name') }}</label>
<input class="form-control" formControlName="name" />
</div>
<div>
<label class="form-label">{{ ui.t('categories.color') }}</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 ? ui.t('action.save') : ui.t('action.addCategory') }}</span>
</button>
@if (editingId) {
<button class="btn btn-outline-secondary" type="button" (click)="resetForm()">{{ ui.t('action.cancel') }}</button>
}
</div>
</form>
</div>
</div>
</div>
<div class="col-lg-8">
<div class="card pv-card overflow-hidden">
<div class="table-responsive">
<table class="table table-vcenter card-table mb-0">
<thead>
<tr><th>{{ ui.t('categories.name') }}</th><th>{{ ui.t('categories.color') }}</th><th>{{ ui.t('categories.type') }}</th><th class="w-1"></th></tr>
</thead>
<tbody>
@for (item of items(); track item.id) {
<tr>
<td>{{ item.name }}</td>
<td><span class="ec-color-swatch" [style.background]="item.color"></span> <span class="ms-2">{{ item.color }}</span></td>
<td>{{ item.isSystem ? ui.t('categories.system') : ui.t('categories.custom') }}</td>
<td>
<div class="btn-list flex-nowrap">
<button class="btn btn-outline-primary btn-sm" type="button" (click)="edit(item)">{{ ui.t('action.edit') }}</button>
@if (!item.isSystem) {
<button class="btn btn-outline-danger btn-sm" type="button" (click)="remove(item)">{{ ui.t('action.delete') }}</button>
}
</div>
</td>
</tr>
} @empty {
<tr><td colspan="4" class="text-secondary">{{ ui.t('common.noCategories') }}</td></tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>

View File

@@ -12,89 +12,7 @@ const presets = ['#b91c1c', '#2563eb', '#0891b2', '#16a34a', '#7c3aed', '#f59e0b
selector: 'app-categories', selector: 'app-categories',
standalone: true, standalone: true,
imports: [CommonModule, ReactiveFormsModule], imports: [CommonModule, ReactiveFormsModule],
template: ` templateUrl: './categories.component.html'
<div class="page-header d-print-none mb-3 ec-page-header">
<div class="row align-items-center g-3">
<div class="col">
<h2 class="page-title mb-1">{{ ui.t('categories.title') }}</h2>
<div class="text-secondary">{{ ui.t('categories.subtitle') }}</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 ? ui.t('categories.edit') : ui.t('categories.new') }}</h3></div>
<div class="card-body">
<form [formGroup]="form" (ngSubmit)="submit()" class="d-grid gap-3">
<div>
<label class="form-label">{{ ui.t('categories.name') }}</label>
<input class="form-control" formControlName="name" />
</div>
<div>
<label class="form-label">{{ ui.t('categories.color') }}</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 ? ui.t('action.save') : ui.t('action.addCategory') }}</span>
</button>
@if (editingId) {
<button class="btn btn-outline-secondary" type="button" (click)="resetForm()">{{ ui.t('action.cancel') }}</button>
}
</div>
</form>
</div>
</div>
</div>
<div class="col-lg-8">
<div class="card pv-card overflow-hidden">
<div class="table-responsive">
<table class="table table-vcenter card-table mb-0">
<thead>
<tr><th>{{ ui.t('categories.name') }}</th><th>{{ ui.t('categories.color') }}</th><th>{{ ui.t('categories.type') }}</th><th class="w-1"></th></tr>
</thead>
<tbody>
@for (item of items(); track item.id) {
<tr>
<td>{{ item.name }}</td>
<td><span class="ec-color-swatch" [style.background]="item.color"></span> <span class="ms-2">{{ item.color }}</span></td>
<td>{{ item.isSystem ? ui.t('categories.system') : ui.t('categories.custom') }}</td>
<td>
<div class="btn-list flex-nowrap">
<button class="btn btn-outline-primary btn-sm" type="button" (click)="edit(item)">{{ ui.t('action.edit') }}</button>
@if (!item.isSystem) {
<button class="btn btn-outline-danger btn-sm" type="button" (click)="remove(item)">{{ ui.t('action.delete') }}</button>
}
</div>
</td>
</tr>
} @empty {
<tr><td colspan="4" class="text-secondary">{{ ui.t('common.noCategories') }}</td></tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
`
}) })
export class CategoriesComponent implements OnInit { export class CategoriesComponent implements OnInit {
readonly ui = inject(UiService); readonly ui = inject(UiService);

View File

@@ -0,0 +1,125 @@
<div class="page-header d-print-none mb-3 ec-page-header">
<div class="row align-items-center g-3">
<div class="col">
<h2 class="page-title mb-1">{{ ui.t('nav.dashboard') }}</h2>
<div class="text-secondary">{{ ui.t('dashboard.cashflowHint') }}</div>
</div>
</div>
</div>
<div class="row row-cards">
<div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('dashboard.total') }}</div><div class="display-6">{{ (stats?.total || 0) | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
<div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('dashboard.count') }}</div><div class="display-6">{{ stats?.count || 0 }}</div></div></div></div>
<div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('dashboard.budgetUsage') }}</div><div class="display-6">{{ cashflow?.budgetUsagePercent || 0 }}%</div></div></div></div>
<div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('cashflow.forecast') }}</div><div class="display-6">{{ (cashflow?.forecastCurrentMonth || 0) | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
@if (canShowExternalStats()) {
<div class="col-md-6"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('dashboard.externalSpend') }}</div><div class="display-6">{{ externalAmount() | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
<div class="col-md-6"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('dashboard.externalRecords') }}</div><div class="display-6">{{ externalCount() }}</div></div></div></div>
}
<div class="col-12 d-flex align-items-stretch">
<div class="card pv-card h-100 w-100 overflow-hidden">
<div class="card-header d-flex justify-content-between align-items-center gap-2 flex-wrap">
<h3 class="card-title mb-0">{{ ui.t('dashboard.trend') }}</h3>
<div class="btn-list">
@for (option of timelineRangeOptions; track option.value) {
<button class="btn btn-sm"
type="button"
[class.btn-primary]="selectedTimelineRange === option.value"
[class.btn-outline-secondary]="selectedTimelineRange !== option.value"
(click)="changeTimelineRange(option.value)">
{{ ui.t(option.labelKey) }}
</button>
}
</div>
</div>
<div class="card-body">
@if (timelineStats?.timeline?.length) {
<div class="ec-chart-wrap"><canvas id="dashboardTimelineChart"></canvas></div>
} @else {
<div class="alert alert-info mb-0">{{ ui.t('dashboard.noTrendData') }}</div>
}
</div>
</div>
</div>
<div class="col-lg-7 d-flex align-items-stretch">
<div class="card pv-card h-100 w-100 overflow-hidden">
<div class="card-header"><h3 class="card-title">{{ ui.t('dashboard.share') }}</h3></div>
<div class="card-body">
@if (stats?.byCategory?.length) {
<div class="ec-chart-wrap"><canvas id="dashboardCategoryChart"></canvas></div>
} @else {
<div class="alert alert-info mb-0">{{ ui.t('dashboard.noChartData') }}</div>
}
</div>
</div>
</div>
<div class="col-lg-5 d-flex align-items-stretch">
<div class="card pv-card h-100 w-100 overflow-hidden">
<div class="card-header"><h3 class="card-title">{{ ui.t('nav.cashflow') }}</h3></div>
<div class="card-body d-grid gap-3">
<div class="d-flex justify-content-between"><span class="text-secondary">{{ ui.t('cashflow.actual') }}</span><strong>{{ (cashflow?.actualCurrent || 0) | currency:'PLN':'symbol':'1.2-2' }}</strong></div>
<div class="d-flex justify-content-between"><span class="text-secondary">{{ ui.t('cashflow.budget') }}</span><strong>{{ (cashflow?.totalBudget || 0) | currency:'PLN':'symbol':'1.2-2' }}</strong></div>
<div class="d-flex justify-content-between"><span class="text-secondary">{{ ui.t('cashflow.pending') }}</span><strong>{{ cashflow?.pendingApproval || 0 }}</strong></div>
<div class="d-flex justify-content-between"><span class="text-secondary">{{ ui.t('cashflow.duplicates') }}</span><strong>{{ cashflow?.duplicateCount || 0 }}</strong></div>
<div>
<div class="form-label mb-2">{{ ui.t('budget.alerts') }}</div>
<div class="d-grid gap-2">
@for (alert of cashflow?.alerts || []; track alert.id) {
<div class="alert alert-warning mb-0 py-2 px-3">{{ alert.name }} · {{ alert.usagePercent }}%</div>
} @empty {
<div class="text-secondary">{{ ui.t('common.noData') }}</div>
}
</div>
</div>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card overflow-hidden">
<div class="card-header"><h3 class="card-title">{{ ui.t('dashboard.recent') }}</h3></div>
<div class="table-responsive">
<table class="table table-vcenter card-table mb-0">
<thead><tr><th>{{ ui.t('table.title') }}</th><th>{{ ui.t('table.category') }}</th><th>{{ ui.t('expenses.field.status') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th></tr></thead>
<tbody>
@for (item of recentExpenses; track item.id) {
<tr>
<td>
<div class="fw-semibold">{{ item.title }}</div>
<div class="text-secondary small">{{ item.merchant || ui.t('expenses.noMerchant') }}</div>
</td>
<td>{{ item.category.name }}</td>
<td><span class="badge" [ngClass]="statusBadgeClass(item.status)">{{ ui.t('status.' + item.status.toLowerCase()) }}</span></td>
<td class="text-end">{{ item.amount | currency:'PLN':'symbol':'1.2-2' }}</td>
</tr>
} @empty {
<tr><td colspan="4" class="text-secondary">{{ ui.t('common.noExpenses') }}</td></tr>
}
</tbody>
</table>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card overflow-hidden">
<div class="card-header"><h3 class="card-title">{{ ui.t('cashflow.upcomingRecurring') }}</h3></div>
<div class="table-responsive">
<table class="table table-vcenter card-table mb-0">
<thead><tr><th>{{ ui.t('table.title') }}</th><th>{{ ui.t('table.date') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th></tr></thead>
<tbody>
@for (item of cashflow?.upcomingRecurring || []; track item.id) {
<tr><td>{{ item.title }}</td><td>{{ item.nextRunDate | date:'yyyy-MM-dd' }}</td><td class="text-end">{{ item.amount | currency:'PLN':'symbol':'1.2-2' }}</td></tr>
} @empty {
<tr><td colspan="3" class="text-secondary">{{ ui.t('common.noData') }}</td></tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>

View File

@@ -55,133 +55,7 @@ const getTimelineRange = (range: TimelineRangeKey) => {
selector: 'app-dashboard', selector: 'app-dashboard',
standalone: true, standalone: true,
imports: [CommonModule, CurrencyPipe, DatePipe], imports: [CommonModule, CurrencyPipe, DatePipe],
template: ` templateUrl: './dashboard.component.html'
<div class="page-header d-print-none mb-3 ec-page-header">
<div class="row align-items-center g-3">
<div class="col">
<h2 class="page-title mb-1">{{ ui.t('nav.dashboard') }}</h2>
<div class="text-secondary">{{ ui.t('dashboard.cashflowHint') }}</div>
</div>
</div>
</div>
<div class="row row-cards">
<div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('dashboard.total') }}</div><div class="display-6">{{ (stats?.total || 0) | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
<div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('dashboard.count') }}</div><div class="display-6">{{ stats?.count || 0 }}</div></div></div></div>
<div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('dashboard.budgetUsage') }}</div><div class="display-6">{{ cashflow?.budgetUsagePercent || 0 }}%</div></div></div></div>
<div class="col-md-3"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('cashflow.forecast') }}</div><div class="display-6">{{ (cashflow?.forecastCurrentMonth || 0) | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
@if (canShowExternalStats()) {
<div class="col-md-6"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('dashboard.externalSpend') }}</div><div class="display-6">{{ externalAmount() | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
<div class="col-md-6"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('dashboard.externalRecords') }}</div><div class="display-6">{{ externalCount() }}</div></div></div></div>
}
<div class="col-12 d-flex align-items-stretch">
<div class="card pv-card h-100 w-100 overflow-hidden">
<div class="card-header d-flex justify-content-between align-items-center gap-2 flex-wrap">
<h3 class="card-title mb-0">{{ ui.t('dashboard.trend') }}</h3>
<div class="btn-list">
@for (option of timelineRangeOptions; track option.value) {
<button class="btn btn-sm"
type="button"
[class.btn-primary]="selectedTimelineRange === option.value"
[class.btn-outline-secondary]="selectedTimelineRange !== option.value"
(click)="changeTimelineRange(option.value)">
{{ ui.t(option.labelKey) }}
</button>
}
</div>
</div>
<div class="card-body">
@if (timelineStats?.timeline?.length) {
<div class="ec-chart-wrap"><canvas id="dashboardTimelineChart"></canvas></div>
} @else {
<div class="alert alert-info mb-0">{{ ui.t('dashboard.noTrendData') }}</div>
}
</div>
</div>
</div>
<div class="col-lg-7 d-flex align-items-stretch">
<div class="card pv-card h-100 w-100 overflow-hidden">
<div class="card-header"><h3 class="card-title">{{ ui.t('dashboard.share') }}</h3></div>
<div class="card-body">
@if (stats?.byCategory?.length) {
<div class="ec-chart-wrap"><canvas id="dashboardCategoryChart"></canvas></div>
} @else {
<div class="alert alert-info mb-0">{{ ui.t('dashboard.noChartData') }}</div>
}
</div>
</div>
</div>
<div class="col-lg-5 d-flex align-items-stretch">
<div class="card pv-card h-100 w-100 overflow-hidden">
<div class="card-header"><h3 class="card-title">{{ ui.t('nav.cashflow') }}</h3></div>
<div class="card-body d-grid gap-3">
<div class="d-flex justify-content-between"><span class="text-secondary">{{ ui.t('cashflow.actual') }}</span><strong>{{ (cashflow?.actualCurrent || 0) | currency:'PLN':'symbol':'1.2-2' }}</strong></div>
<div class="d-flex justify-content-between"><span class="text-secondary">{{ ui.t('cashflow.budget') }}</span><strong>{{ (cashflow?.totalBudget || 0) | currency:'PLN':'symbol':'1.2-2' }}</strong></div>
<div class="d-flex justify-content-between"><span class="text-secondary">{{ ui.t('cashflow.pending') }}</span><strong>{{ cashflow?.pendingApproval || 0 }}</strong></div>
<div class="d-flex justify-content-between"><span class="text-secondary">{{ ui.t('cashflow.duplicates') }}</span><strong>{{ cashflow?.duplicateCount || 0 }}</strong></div>
<div>
<div class="form-label mb-2">{{ ui.t('budget.alerts') }}</div>
<div class="d-grid gap-2">
@for (alert of cashflow?.alerts || []; track alert.id) {
<div class="alert alert-warning mb-0 py-2 px-3">{{ alert.name }} · {{ alert.usagePercent }}%</div>
} @empty {
<div class="text-secondary">{{ ui.t('common.noData') }}</div>
}
</div>
</div>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card overflow-hidden">
<div class="card-header"><h3 class="card-title">{{ ui.t('dashboard.recent') }}</h3></div>
<div class="table-responsive">
<table class="table table-vcenter card-table mb-0">
<thead><tr><th>{{ ui.t('table.title') }}</th><th>{{ ui.t('table.category') }}</th><th>{{ ui.t('expenses.field.status') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th></tr></thead>
<tbody>
@for (item of recentExpenses; track item.id) {
<tr>
<td>
<div class="fw-semibold">{{ item.title }}</div>
<div class="text-secondary small">{{ item.merchant || ui.t('expenses.noMerchant') }}</div>
</td>
<td>{{ item.category.name }}</td>
<td><span class="badge" [ngClass]="statusBadgeClass(item.status)">{{ ui.t('status.' + item.status.toLowerCase()) }}</span></td>
<td class="text-end">{{ item.amount | currency:'PLN':'symbol':'1.2-2' }}</td>
</tr>
} @empty {
<tr><td colspan="4" class="text-secondary">{{ ui.t('common.noExpenses') }}</td></tr>
}
</tbody>
</table>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card overflow-hidden">
<div class="card-header"><h3 class="card-title">{{ ui.t('cashflow.upcomingRecurring') }}</h3></div>
<div class="table-responsive">
<table class="table table-vcenter card-table mb-0">
<thead><tr><th>{{ ui.t('table.title') }}</th><th>{{ ui.t('table.date') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th></tr></thead>
<tbody>
@for (item of cashflow?.upcomingRecurring || []; track item.id) {
<tr><td>{{ item.title }}</td><td>{{ item.nextRunDate | date:'yyyy-MM-dd' }}</td><td class="text-end">{{ item.amount | currency:'PLN':'symbol':'1.2-2' }}</td></tr>
} @empty {
<tr><td colspan="3" class="text-secondary">{{ ui.t('common.noData') }}</td></tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
`
}) })
export class DashboardComponent implements OnInit, AfterViewChecked, OnDestroy { export class DashboardComponent implements OnInit, AfterViewChecked, OnDestroy {
readonly ui = inject(UiService); readonly ui = inject(UiService);

View File

@@ -0,0 +1,108 @@
<div class="page-header d-print-none mb-3 ec-page-header">
<div class="row align-items-center g-3">
<div class="col">
<h2 class="page-title mb-1">{{ ui.t('expenses.detailTitle') }}</h2>
<div class="text-secondary">{{ expense()?.title || ui.t('expenses.title') }}</div>
</div>
<div class="col-auto ms-auto">
<div class="btn-list">
<button class="btn btn-outline-secondary" type="button" (click)="goBack()">{{ ui.t('action.backToList') }}</button>
@if (expense()) {
<button class="btn btn-primary" type="button" (click)="editExpense()">{{ ui.t('action.edit') }}</button>
}
</div>
</div>
</div>
</div>
@if (loadError()) {
<div class="alert alert-danger">{{ loadError() }}</div>
} @else if (loading()) {
<div class="card"><div class="card-body text-secondary">{{ ui.t('common.loading') }}</div></div>
} @else if (expense(); as item) {
<div class="row row-cards">
<div class="col-12 col-xl-8">
<div class="card overflow-hidden">
<div class="card-header"><h3 class="card-title mb-0">{{ item.title }}</h3></div>
<div class="card-body d-grid gap-3">
<div class="row g-3">
<div class="col-md-4"><div class="text-secondary small">{{ ui.t('expenses.field.date') }}</div><div class="fw-semibold">{{ item.expenseDate | date:'yyyy-MM-dd' }}</div></div>
<div class="col-md-4"><div class="text-secondary small">{{ ui.t('expenses.field.amount') }}</div><div class="fw-semibold">{{ item.amount | currency:'PLN':'symbol':'1.2-2' }}</div></div>
<div class="col-md-4"><div class="text-secondary small">{{ ui.t('expenses.field.status') }}</div><div><span class="badge" [ngClass]="statusBadgeClass(item.status)">{{ ui.t('status.' + item.status.toLowerCase()) }}</span></div></div>
<div class="col-md-4"><div class="text-secondary small">{{ ui.t('expenses.field.category') }}</div><div class="fw-semibold">{{ item.category.name }}</div></div>
<div class="col-md-4"><div class="text-secondary small">{{ ui.t('expenses.field.payment') }}</div><div>{{ paymentLabel(item.paymentMethod) }}</div></div>
<div class="col-md-4"><div class="text-secondary small">{{ ui.t('expenses.field.merchantName') }}</div><div>{{ item.merchant || ui.t('expenses.noMerchant') }}</div></div>
</div>
@if (item.description) {
<div>
<div class="text-secondary small mb-1">{{ ui.t('expenses.field.description') }}</div>
<div>{{ item.description }}</div>
</div>
}
@if (item.tags.length) {
<div>
<div class="text-secondary small mb-1">{{ ui.t('expenses.field.tags') }}</div>
<div class="d-flex flex-wrap gap-1">@for (tag of item.tags; track tag) { <span class="badge text-bg-secondary">#{{ tag }}</span> }</div>
</div>
}
@if (customFieldEntries(item).length) {
<div>
<div class="text-secondary small mb-1">{{ ui.t('expenses.field.customFields') }}</div>
<div class="row g-2">@for (field of customFieldEntries(item); track field[0]) { <div class="col-sm-6"><div class="border rounded-3 p-2 h-100"><div class="text-secondary small">{{ field[0] }}</div><div class="fw-semibold">{{ field[1] }}</div></div></div> }</div>
</div>
}
</div>
</div>
</div>
<div class="col-12 col-xl-4">
<div class="card overflow-hidden">
<div class="card-header"><h3 class="card-title mb-0">{{ ui.t('expenses.existingProofs') }}</h3></div>
<div class="card-body">
@if (item.proofs.length) {
<div class="d-grid gap-2">
@for (proof of item.proofs; track proof.id) {
<button class="btn btn-outline-secondary text-start" type="button" (click)="openProof(proof)">{{ proof.label || proof.originalName || ui.t('expenses.proof') }}</button>
}
</div>
} @else {
<div class="text-secondary">{{ ui.t('expenses.noProofs') }}</div>
}
</div>
</div>
<div class="card overflow-hidden mt-3">
<div class="card-header"><h3 class="card-title mb-0">{{ ui.t('expenses.meta') }}</h3></div>
<div class="card-body d-grid gap-2 small">
<div><span class="text-secondary">ID:</span> {{ item.id }}</div>
<div><span class="text-secondary">{{ ui.t('table.createdAt') || 'Utworzono' }}:</span> {{ item.createdAt | date:'yyyy-MM-dd HH:mm' }}</div>
<div><span class="text-secondary">{{ ui.t('table.updatedAt') || 'Zmieniono' }}:</span> {{ item.updatedAt | date:'yyyy-MM-dd HH:mm' }}</div>
</div>
</div>
</div>
</div>
}
@if (proofPreview()) {
<div class="modal modal-blur fade show d-block" tabindex="-1">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ proofPreview()?.label || proofPreview()?.originalName || ui.t('expenses.proof') }}</h5>
<button class="btn-close ec-modal-close" type="button" (click)="closeProofPreview()"></button>
</div>
<div class="modal-body ec-proof-modal-body">
@if (isPdf(proofPreview()!)) {
<iframe class="ec-proof-frame" [src]="proofPreviewUrl()"></iframe>
} @else {
<img class="img-fluid ec-proof-preview" [src]="proofPreview()?.previewUrl || proofPreview()?.fileUrl" [alt]="ui.t('expenses.proof')" />
}
</div>
</div>
</div>
</div>
<div class="modal-backdrop fade show" (click)="closeProofPreview()"></div>
}

View File

@@ -12,116 +12,7 @@ import type { Expense, Proof } from '../../shared/models';
selector: 'app-expense-detail', selector: 'app-expense-detail',
standalone: true, standalone: true,
imports: [CommonModule, RouterLink, CurrencyPipe, DatePipe], imports: [CommonModule, RouterLink, CurrencyPipe, DatePipe],
template: ` templateUrl: './expense-detail.component.html'
<div class="page-header d-print-none mb-3 ec-page-header">
<div class="row align-items-center g-3">
<div class="col">
<h2 class="page-title mb-1">{{ ui.t('expenses.detailTitle') }}</h2>
<div class="text-secondary">{{ expense()?.title || ui.t('expenses.title') }}</div>
</div>
<div class="col-auto ms-auto">
<div class="btn-list">
<button class="btn btn-outline-secondary" type="button" (click)="goBack()">{{ ui.t('action.backToList') }}</button>
@if (expense()) {
<button class="btn btn-primary" type="button" (click)="editExpense()">{{ ui.t('action.edit') }}</button>
}
</div>
</div>
</div>
</div>
@if (loadError()) {
<div class="alert alert-danger">{{ loadError() }}</div>
} @else if (loading()) {
<div class="card"><div class="card-body text-secondary">{{ ui.t('common.loading') }}</div></div>
} @else if (expense(); as item) {
<div class="row row-cards">
<div class="col-12 col-xl-8">
<div class="card overflow-hidden">
<div class="card-header"><h3 class="card-title mb-0">{{ item.title }}</h3></div>
<div class="card-body d-grid gap-3">
<div class="row g-3">
<div class="col-md-4"><div class="text-secondary small">{{ ui.t('expenses.field.date') }}</div><div class="fw-semibold">{{ item.expenseDate | date:'yyyy-MM-dd' }}</div></div>
<div class="col-md-4"><div class="text-secondary small">{{ ui.t('expenses.field.amount') }}</div><div class="fw-semibold">{{ item.amount | currency:'PLN':'symbol':'1.2-2' }}</div></div>
<div class="col-md-4"><div class="text-secondary small">{{ ui.t('expenses.field.status') }}</div><div><span class="badge" [ngClass]="statusBadgeClass(item.status)">{{ ui.t('status.' + item.status.toLowerCase()) }}</span></div></div>
<div class="col-md-4"><div class="text-secondary small">{{ ui.t('expenses.field.category') }}</div><div class="fw-semibold">{{ item.category.name }}</div></div>
<div class="col-md-4"><div class="text-secondary small">{{ ui.t('expenses.field.payment') }}</div><div>{{ paymentLabel(item.paymentMethod) }}</div></div>
<div class="col-md-4"><div class="text-secondary small">{{ ui.t('expenses.field.merchantName') }}</div><div>{{ item.merchant || ui.t('expenses.noMerchant') }}</div></div>
</div>
@if (item.description) {
<div>
<div class="text-secondary small mb-1">{{ ui.t('expenses.field.description') }}</div>
<div>{{ item.description }}</div>
</div>
}
@if (item.tags.length) {
<div>
<div class="text-secondary small mb-1">{{ ui.t('expenses.field.tags') }}</div>
<div class="d-flex flex-wrap gap-1">@for (tag of item.tags; track tag) { <span class="badge text-bg-secondary">#{{ tag }}</span> }</div>
</div>
}
@if (customFieldEntries(item).length) {
<div>
<div class="text-secondary small mb-1">{{ ui.t('expenses.field.customFields') }}</div>
<div class="row g-2">@for (field of customFieldEntries(item); track field[0]) { <div class="col-sm-6"><div class="border rounded-3 p-2 h-100"><div class="text-secondary small">{{ field[0] }}</div><div class="fw-semibold">{{ field[1] }}</div></div></div> }</div>
</div>
}
</div>
</div>
</div>
<div class="col-12 col-xl-4">
<div class="card overflow-hidden">
<div class="card-header"><h3 class="card-title mb-0">{{ ui.t('expenses.existingProofs') }}</h3></div>
<div class="card-body">
@if (item.proofs.length) {
<div class="d-grid gap-2">
@for (proof of item.proofs; track proof.id) {
<button class="btn btn-outline-secondary text-start" type="button" (click)="openProof(proof)">{{ proof.label || proof.originalName || ui.t('expenses.proof') }}</button>
}
</div>
} @else {
<div class="text-secondary">{{ ui.t('expenses.noProofs') }}</div>
}
</div>
</div>
<div class="card overflow-hidden mt-3">
<div class="card-header"><h3 class="card-title mb-0">{{ ui.t('expenses.meta') }}</h3></div>
<div class="card-body d-grid gap-2 small">
<div><span class="text-secondary">ID:</span> {{ item.id }}</div>
<div><span class="text-secondary">{{ ui.t('table.createdAt') || 'Utworzono' }}:</span> {{ item.createdAt | date:'yyyy-MM-dd HH:mm' }}</div>
<div><span class="text-secondary">{{ ui.t('table.updatedAt') || 'Zmieniono' }}:</span> {{ item.updatedAt | date:'yyyy-MM-dd HH:mm' }}</div>
</div>
</div>
</div>
</div>
}
@if (proofPreview()) {
<div class="modal modal-blur fade show d-block" tabindex="-1">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ proofPreview()?.label || proofPreview()?.originalName || ui.t('expenses.proof') }}</h5>
<button class="btn-close ec-modal-close" type="button" (click)="closeProofPreview()"></button>
</div>
<div class="modal-body ec-proof-modal-body">
@if (isPdf(proofPreview()!)) {
<iframe class="ec-proof-frame" [src]="proofPreviewUrl()"></iframe>
} @else {
<img class="img-fluid ec-proof-preview" [src]="proofPreview()?.previewUrl || proofPreview()?.fileUrl" [alt]="ui.t('expenses.proof')" />
}
</div>
</div>
</div>
</div>
<div class="modal-backdrop fade show" (click)="closeProofPreview()"></div>
}
`
}) })
export class ExpenseDetailComponent implements OnInit { export class ExpenseDetailComponent implements OnInit {
readonly ui = inject(UiService); readonly ui = inject(UiService);

View File

@@ -0,0 +1,178 @@
<div class="page-header d-print-none mb-3 ec-page-header">
<div class="row align-items-center g-3">
<div class="col">
<h2 class="page-title mb-1">{{ ui.t('expenses.title') }}</h2>
<div class="text-secondary">{{ ui.t('expenses.listSubtitle') }}</div>
</div>
</div>
</div>
<div class="mb-3">
<nav class="nav nav-pills gap-2">
<a class="nav-link" [routerLink]="['/expenses/add']">{{ ui.t('action.addExpense') }}</a>
<a class="nav-link active" [routerLink]="['/expenses/list']">{{ ui.t('expenses.listTitle') }}</a>
</nav>
</div>
@if (duplicateGroups().length) {
<div class="alert alert-warning">
<div class="fw-semibold mb-2">{{ ui.t('expenses.duplicatesTitle') }}</div>
<div class="d-grid gap-1">
@for (group of duplicateGroups().slice(0, 3); track group.source.id) {
<div>{{ group.source.title }} · {{ group.matches.length }} {{ ui.t('expenses.potentialMatches') }}</div>
}
</div>
</div>
}
<div class="card overflow-hidden mb-3">
<div class="card-header d-flex justify-content-between align-items-center gap-2 flex-wrap">
<h3 class="card-title mb-0">{{ ui.t('expenses.filters') }}</h3>
@if (hasActiveFilters()) {
<span class="badge text-bg-primary">{{ ui.t('action.filter') }}</span>
}
</div>
<div class="card-body">
<form [formGroup]="filterForm" (ngSubmit)="applyFilters()" class="row g-3 align-items-end">
<div class="col-sm-6 col-lg-2"><label class="form-label">{{ ui.t('stats.from') }}</label><input class="form-control" type="date" formControlName="startDate" /></div>
<div class="col-sm-6 col-lg-2"><label class="form-label">{{ ui.t('stats.to') }}</label><input class="form-control" type="date" formControlName="endDate" /></div>
<div class="col-sm-6 col-lg-2"><label class="form-label">{{ ui.t('expenses.field.category') }}</label><select class="form-select" formControlName="categoryId"><option value="">{{ ui.t('expenses.allCategories') }}</option>@for (category of categories(); track category.id) { <option [value]="category.id">{{ category.name }}</option> }</select></div>
<div class="col-sm-6 col-lg-2"><label class="form-label">{{ ui.t('expenses.field.status') }}</label><select class="form-select" formControlName="status"><option value="">{{ ui.t('common.none') }}</option><option value="DRAFT">{{ ui.t('status.draft') }}</option><option value="PENDING">{{ ui.t('status.pending') }}</option><option value="APPROVED">{{ ui.t('status.approved') }}</option><option value="REJECTED">{{ ui.t('status.rejected') }}</option></select></div>
<div class="col-sm-6 col-lg-2"><label class="form-label">{{ ui.t('expenses.field.tags') }}</label><input class="form-control" formControlName="tags" /></div>
<div class="col-sm-6 col-lg-2"><label class="form-label">{{ ui.t('expenses.search') }}</label><input class="form-control" formControlName="search" /></div>
<div class="col-12"><label class="form-check"><input class="form-check-input" type="checkbox" formControlName="duplicatesOnly" /><span class="form-check-label">{{ ui.t('expenses.duplicatesOnly') }}</span></label></div>
<div class="col-12 d-flex gap-2 flex-wrap"><button class="btn btn-primary" type="submit">{{ ui.t('action.filter') }}</button><button class="btn btn-outline-secondary" type="button" (click)="resetFilters()">{{ ui.t('action.reset') }}</button></div>
</form>
</div>
</div>
<div class="card overflow-hidden">
<div class="card-header d-flex justify-content-between align-items-center gap-2 flex-wrap">
<div>
<h3 class="card-title mb-0">{{ ui.t('expenses.listTitle') }}</h3>
<div class="small text-secondary">{{ ui.t('expenses.totalItems') }}: {{ pagination().total }}</div>
</div>
<div class="d-flex align-items-center gap-2 flex-wrap">
<select class="form-select form-select-sm w-auto" [value]="pagination().pageSize" (change)="changePageSize($any($event.target).value)">
@for (size of pageSizeOptions; track size) {
<option [value]="size">{{ size }} / {{ ui.t('expenses.perPage') }}</option>
}
</select>
</div>
</div>
@if (selectedIds().length) {
<div class="card-body border-bottom bg-body-tertiary py-3">
<div class="d-flex justify-content-between align-items-center gap-2 flex-wrap">
<div class="fw-semibold">{{ ui.t('expenses.selectedCount') }}: {{ selectedIds().length }}</div>
<div class="btn-list flex-wrap">
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="clearSelection()">{{ ui.t('action.clearSelection') }}</button>
<button class="btn btn-sm btn-outline-warning" type="button" (click)="bulkUpdateStatus('PENDING')">{{ ui.t('status.pending') }}</button>
<button class="btn btn-sm btn-outline-success" type="button" (click)="bulkUpdateStatus('APPROVED')">{{ ui.t('status.approved') }}</button>
<button class="btn btn-sm btn-outline-danger" type="button" (click)="bulkDelete()">{{ ui.t('action.delete') }}</button>
</div>
</div>
</div>
}
<div class="table-responsive">
<table class="table table-vcenter card-table mb-0">
<thead>
<tr>
<th class="w-1">
<input class="form-check-input" type="checkbox" [checked]="allVisibleSelected()" [indeterminate]="someVisibleSelected()" (change)="toggleAllVisible($any($event.target).checked)" />
</th>
<th><button class="btn btn-link p-0 text-decoration-none fw-semibold" type="button" (click)="setSort('expenseDate')">{{ ui.t('expenses.field.date') }} {{ sortIndicator('expenseDate') }}</button></th>
<th><button class="btn btn-link p-0 text-decoration-none fw-semibold" type="button" (click)="setSort('title')">{{ ui.t('table.title') }} {{ sortIndicator('title') }}</button></th>
<th><button class="btn btn-link p-0 text-decoration-none fw-semibold" type="button" (click)="setSort('category')">{{ ui.t('expenses.field.category') }} {{ sortIndicator('category') }}</button></th>
<th><button class="btn btn-link p-0 text-decoration-none fw-semibold" type="button" (click)="setSort('status')">{{ ui.t('expenses.field.status') }} {{ sortIndicator('status') }}</button></th>
<th class="text-end"><button class="btn btn-link p-0 text-decoration-none fw-semibold" type="button" (click)="setSort('amount')">{{ ui.t('table.amount') }} {{ sortIndicator('amount') }}</button></th>
<th class="text-end">{{ ui.t('table.actions') }}</th>
</tr>
</thead>
<tbody>
@for (item of expenses(); track item.id) {
<tr>
<td><input class="form-check-input" type="checkbox" [checked]="isSelected(item.id)" (change)="toggleSelection(item.id, $any($event.target).checked)" /></td>
<td>{{ item.expenseDate | date:'yyyy-MM-dd' }}</td>
<td>
<div class="fw-semibold d-flex align-items-center gap-2 flex-wrap">
<a class="link-body-emphasis text-decoration-none" [routerLink]="['/expenses', item.id]">{{ item.title }}</a>
@if (item.possibleDuplicate || item.duplicateStatus) {
<span class="badge" [ngClass]="duplicateBadgeClass(item)">{{ duplicateLabel(item) }}</span>
}
@if (item.recurringSourceId) {
<span class="badge text-bg-info">{{ ui.t('recurring.badge') }}</span>
}
</div>
<div class="text-secondary small">{{ item.merchant || ui.t('expenses.noMerchant') }}</div>
@if (item.tags.length) { <div class="mt-1 d-flex flex-wrap gap-1">@for (tag of item.tags; track tag) { <span class="badge text-bg-secondary">#{{ tag }}</span> }</div> }
@if (customFieldEntries(item).length) { <div class="small text-secondary mt-1">@for (field of customFieldEntries(item); track field[0]) { <span class="me-2">{{ field[0] }}: {{ field[1] }}</span> }</div> }
@if (item.proofs.length) { <div class="mt-2 d-flex flex-wrap gap-2">@for (proof of item.proofs; track proof.id) { <button class="btn btn-sm btn-outline-secondary" type="button" (click)="openProof(proof)">{{ proof.label || proof.originalName || ui.t('expenses.proof') }}</button> }</div> }
</td>
<td>{{ item.category.name }}</td>
<td>
<div class="d-grid gap-2">
<span class="badge d-inline-flex align-items-center justify-content-center" [ngClass]="statusBadgeClass(item.status)">{{ ui.t('status.' + item.status.toLowerCase()) }}</span>
<select class="form-select form-select-sm" [value]="item.status" [disabled]="statusSavingId() === item.id" (change)="quickChangeStatus(item, $any($event.target).value)">
<option value="DRAFT">{{ ui.t('status.draft') }}</option>
<option value="PENDING">{{ ui.t('status.pending') }}</option>
<option value="APPROVED">{{ ui.t('status.approved') }}</option>
<option value="REJECTED">{{ ui.t('status.rejected') }}</option>
</select>
</div>
</td>
<td class="text-end">{{ item.amount | currency:'PLN':'symbol':'1.2-2' }}</td>
<td class="text-end">
<div class="btn-list justify-content-end flex-wrap">
<a class="btn btn-sm btn-outline-secondary" [routerLink]="['/expenses', item.id]">{{ ui.t('action.view') }}</a>
@if (item.possibleDuplicate && item.duplicateStatus !== 'CONFIRMED') {
<button class="btn btn-sm btn-outline-success" type="button" (click)="reviewDuplicate(item, 'CONFIRM')">OK</button>
}
@if (item.possibleDuplicate && item.duplicateStatus !== 'DISMISSED') {
<button class="btn btn-sm btn-outline-warning" type="button" (click)="reviewDuplicate(item, 'DISMISS')">X</button>
}
@if (item.duplicateStatus === 'DISMISSED' || item.duplicateStatus === 'CONFIRMED') {
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="reviewDuplicate(item, 'REOPEN')"></button>
}
<button class="btn btn-sm btn-outline-primary" type="button" (click)="startEdit(item)">{{ ui.t('action.edit') }}</button>
<button class="btn btn-sm btn-outline-danger" type="button" (click)="removeExpense(item)">{{ ui.t('action.delete') }}</button>
</div>
</td>
</tr>
} @empty { <tr><td colspan="7" class="text-secondary">{{ ui.t('expenses.noItems') }}</td></tr> }
</tbody>
</table>
</div>
<div class="card-footer d-flex justify-content-between align-items-center gap-2 flex-wrap">
<div class="small text-secondary">{{ pageStart() }}-{{ pageEnd() }} / {{ pagination().total }}</div>
<div class="btn-group">
<button class="btn btn-outline-secondary btn-sm" type="button" [disabled]="!pagination().hasPrev" (click)="changePage(1)">«</button>
<button class="btn btn-outline-secondary btn-sm" type="button" [disabled]="!pagination().hasPrev" (click)="changePage(pagination().page - 1)"></button>
<button class="btn btn-outline-secondary btn-sm" type="button" disabled>{{ pagination().page }} / {{ pagination().totalPages }}</button>
<button class="btn btn-outline-secondary btn-sm" type="button" [disabled]="!pagination().hasNext" (click)="changePage(pagination().page + 1)"></button>
<button class="btn btn-outline-secondary btn-sm" type="button" [disabled]="!pagination().hasNext" (click)="changePage(pagination().totalPages)">»</button>
</div>
</div>
</div>
@if (proofPreview()) {
<div class="modal modal-blur fade show d-block" tabindex="-1">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ proofPreview()?.label || proofPreview()?.originalName || ui.t('expenses.proof') }}</h5>
<button class="btn-close ec-modal-close" type="button" (click)="closeProofPreview()"></button>
</div>
<div class="modal-body ec-proof-modal-body">
@if (isPdf(proofPreview()!)) {
<iframe class="ec-proof-frame" [src]="proofPreviewUrl()"></iframe>
} @else {
<img class="img-fluid ec-proof-preview" [src]="proofPreview()?.previewUrl || proofPreview()?.fileUrl" [alt]="ui.t('expenses.proof')" />
}
</div>
</div>
</div>
</div>
<div class="modal-backdrop fade show" (click)="closeProofPreview()"></div>
}

View File

@@ -43,186 +43,7 @@ const defaultState: ListState = {
selector: 'app-expense-list', selector: 'app-expense-list',
standalone: true, standalone: true,
imports: [CommonModule, ReactiveFormsModule, RouterLink, CurrencyPipe, DatePipe], imports: [CommonModule, ReactiveFormsModule, RouterLink, CurrencyPipe, DatePipe],
template: ` templateUrl: './expense-list.component.html'
<div class="page-header d-print-none mb-3 ec-page-header">
<div class="row align-items-center g-3">
<div class="col">
<h2 class="page-title mb-1">{{ ui.t('expenses.title') }}</h2>
<div class="text-secondary">{{ ui.t('expenses.listSubtitle') }}</div>
</div>
</div>
</div>
<div class="mb-3">
<nav class="nav nav-pills gap-2">
<a class="nav-link" [routerLink]="['/expenses/add']">{{ ui.t('action.addExpense') }}</a>
<a class="nav-link active" [routerLink]="['/expenses/list']">{{ ui.t('expenses.listTitle') }}</a>
</nav>
</div>
@if (duplicateGroups().length) {
<div class="alert alert-warning">
<div class="fw-semibold mb-2">{{ ui.t('expenses.duplicatesTitle') }}</div>
<div class="d-grid gap-1">
@for (group of duplicateGroups().slice(0, 3); track group.source.id) {
<div>{{ group.source.title }} · {{ group.matches.length }} {{ ui.t('expenses.potentialMatches') }}</div>
}
</div>
</div>
}
<div class="card overflow-hidden mb-3">
<div class="card-header d-flex justify-content-between align-items-center gap-2 flex-wrap">
<h3 class="card-title mb-0">{{ ui.t('expenses.filters') }}</h3>
@if (hasActiveFilters()) {
<span class="badge text-bg-primary">{{ ui.t('action.filter') }}</span>
}
</div>
<div class="card-body">
<form [formGroup]="filterForm" (ngSubmit)="applyFilters()" class="row g-3 align-items-end">
<div class="col-sm-6 col-lg-2"><label class="form-label">{{ ui.t('stats.from') }}</label><input class="form-control" type="date" formControlName="startDate" /></div>
<div class="col-sm-6 col-lg-2"><label class="form-label">{{ ui.t('stats.to') }}</label><input class="form-control" type="date" formControlName="endDate" /></div>
<div class="col-sm-6 col-lg-2"><label class="form-label">{{ ui.t('expenses.field.category') }}</label><select class="form-select" formControlName="categoryId"><option value="">{{ ui.t('expenses.allCategories') }}</option>@for (category of categories(); track category.id) { <option [value]="category.id">{{ category.name }}</option> }</select></div>
<div class="col-sm-6 col-lg-2"><label class="form-label">{{ ui.t('expenses.field.status') }}</label><select class="form-select" formControlName="status"><option value="">{{ ui.t('common.none') }}</option><option value="DRAFT">{{ ui.t('status.draft') }}</option><option value="PENDING">{{ ui.t('status.pending') }}</option><option value="APPROVED">{{ ui.t('status.approved') }}</option><option value="REJECTED">{{ ui.t('status.rejected') }}</option></select></div>
<div class="col-sm-6 col-lg-2"><label class="form-label">{{ ui.t('expenses.field.tags') }}</label><input class="form-control" formControlName="tags" /></div>
<div class="col-sm-6 col-lg-2"><label class="form-label">{{ ui.t('expenses.search') }}</label><input class="form-control" formControlName="search" /></div>
<div class="col-12"><label class="form-check"><input class="form-check-input" type="checkbox" formControlName="duplicatesOnly" /><span class="form-check-label">{{ ui.t('expenses.duplicatesOnly') }}</span></label></div>
<div class="col-12 d-flex gap-2 flex-wrap"><button class="btn btn-primary" type="submit">{{ ui.t('action.filter') }}</button><button class="btn btn-outline-secondary" type="button" (click)="resetFilters()">{{ ui.t('action.reset') }}</button></div>
</form>
</div>
</div>
<div class="card overflow-hidden">
<div class="card-header d-flex justify-content-between align-items-center gap-2 flex-wrap">
<div>
<h3 class="card-title mb-0">{{ ui.t('expenses.listTitle') }}</h3>
<div class="small text-secondary">{{ ui.t('expenses.totalItems') }}: {{ pagination().total }}</div>
</div>
<div class="d-flex align-items-center gap-2 flex-wrap">
<select class="form-select form-select-sm w-auto" [value]="pagination().pageSize" (change)="changePageSize($any($event.target).value)">
@for (size of pageSizeOptions; track size) {
<option [value]="size">{{ size }} / {{ ui.t('expenses.perPage') }}</option>
}
</select>
</div>
</div>
@if (selectedIds().length) {
<div class="card-body border-bottom bg-body-tertiary py-3">
<div class="d-flex justify-content-between align-items-center gap-2 flex-wrap">
<div class="fw-semibold">{{ ui.t('expenses.selectedCount') }}: {{ selectedIds().length }}</div>
<div class="btn-list flex-wrap">
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="clearSelection()">{{ ui.t('action.clearSelection') }}</button>
<button class="btn btn-sm btn-outline-warning" type="button" (click)="bulkUpdateStatus('PENDING')">{{ ui.t('status.pending') }}</button>
<button class="btn btn-sm btn-outline-success" type="button" (click)="bulkUpdateStatus('APPROVED')">{{ ui.t('status.approved') }}</button>
<button class="btn btn-sm btn-outline-danger" type="button" (click)="bulkDelete()">{{ ui.t('action.delete') }}</button>
</div>
</div>
</div>
}
<div class="table-responsive">
<table class="table table-vcenter card-table mb-0">
<thead>
<tr>
<th class="w-1">
<input class="form-check-input" type="checkbox" [checked]="allVisibleSelected()" [indeterminate]="someVisibleSelected()" (change)="toggleAllVisible($any($event.target).checked)" />
</th>
<th><button class="btn btn-link p-0 text-decoration-none fw-semibold" type="button" (click)="setSort('expenseDate')">{{ ui.t('expenses.field.date') }} {{ sortIndicator('expenseDate') }}</button></th>
<th><button class="btn btn-link p-0 text-decoration-none fw-semibold" type="button" (click)="setSort('title')">{{ ui.t('table.title') }} {{ sortIndicator('title') }}</button></th>
<th><button class="btn btn-link p-0 text-decoration-none fw-semibold" type="button" (click)="setSort('category')">{{ ui.t('expenses.field.category') }} {{ sortIndicator('category') }}</button></th>
<th><button class="btn btn-link p-0 text-decoration-none fw-semibold" type="button" (click)="setSort('status')">{{ ui.t('expenses.field.status') }} {{ sortIndicator('status') }}</button></th>
<th class="text-end"><button class="btn btn-link p-0 text-decoration-none fw-semibold" type="button" (click)="setSort('amount')">{{ ui.t('table.amount') }} {{ sortIndicator('amount') }}</button></th>
<th class="text-end">{{ ui.t('table.actions') }}</th>
</tr>
</thead>
<tbody>
@for (item of expenses(); track item.id) {
<tr>
<td><input class="form-check-input" type="checkbox" [checked]="isSelected(item.id)" (change)="toggleSelection(item.id, $any($event.target).checked)" /></td>
<td>{{ item.expenseDate | date:'yyyy-MM-dd' }}</td>
<td>
<div class="fw-semibold d-flex align-items-center gap-2 flex-wrap">
<a class="link-body-emphasis text-decoration-none" [routerLink]="['/expenses', item.id]">{{ item.title }}</a>
@if (item.possibleDuplicate || item.duplicateStatus) {
<span class="badge" [ngClass]="duplicateBadgeClass(item)">{{ duplicateLabel(item) }}</span>
}
@if (item.recurringSourceId) {
<span class="badge text-bg-info">{{ ui.t('recurring.badge') }}</span>
}
</div>
<div class="text-secondary small">{{ item.merchant || ui.t('expenses.noMerchant') }}</div>
@if (item.tags.length) { <div class="mt-1 d-flex flex-wrap gap-1">@for (tag of item.tags; track tag) { <span class="badge text-bg-secondary">#{{ tag }}</span> }</div> }
@if (customFieldEntries(item).length) { <div class="small text-secondary mt-1">@for (field of customFieldEntries(item); track field[0]) { <span class="me-2">{{ field[0] }}: {{ field[1] }}</span> }</div> }
@if (item.proofs.length) { <div class="mt-2 d-flex flex-wrap gap-2">@for (proof of item.proofs; track proof.id) { <button class="btn btn-sm btn-outline-secondary" type="button" (click)="openProof(proof)">{{ proof.label || proof.originalName || ui.t('expenses.proof') }}</button> }</div> }
</td>
<td>{{ item.category.name }}</td>
<td>
<div class="d-grid gap-2">
<span class="badge d-inline-flex align-items-center justify-content-center" [ngClass]="statusBadgeClass(item.status)">{{ ui.t('status.' + item.status.toLowerCase()) }}</span>
<select class="form-select form-select-sm" [value]="item.status" [disabled]="statusSavingId() === item.id" (change)="quickChangeStatus(item, $any($event.target).value)">
<option value="DRAFT">{{ ui.t('status.draft') }}</option>
<option value="PENDING">{{ ui.t('status.pending') }}</option>
<option value="APPROVED">{{ ui.t('status.approved') }}</option>
<option value="REJECTED">{{ ui.t('status.rejected') }}</option>
</select>
</div>
</td>
<td class="text-end">{{ item.amount | currency:'PLN':'symbol':'1.2-2' }}</td>
<td class="text-end">
<div class="btn-list justify-content-end flex-wrap">
<a class="btn btn-sm btn-outline-secondary" [routerLink]="['/expenses', item.id]">{{ ui.t('action.view') }}</a>
@if (item.possibleDuplicate && item.duplicateStatus !== 'CONFIRMED') {
<button class="btn btn-sm btn-outline-success" type="button" (click)="reviewDuplicate(item, 'CONFIRM')">OK</button>
}
@if (item.possibleDuplicate && item.duplicateStatus !== 'DISMISSED') {
<button class="btn btn-sm btn-outline-warning" type="button" (click)="reviewDuplicate(item, 'DISMISS')">X</button>
}
@if (item.duplicateStatus === 'DISMISSED' || item.duplicateStatus === 'CONFIRMED') {
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="reviewDuplicate(item, 'REOPEN')">↺</button>
}
<button class="btn btn-sm btn-outline-primary" type="button" (click)="startEdit(item)">{{ ui.t('action.edit') }}</button>
<button class="btn btn-sm btn-outline-danger" type="button" (click)="removeExpense(item)">{{ ui.t('action.delete') }}</button>
</div>
</td>
</tr>
} @empty { <tr><td colspan="7" class="text-secondary">{{ ui.t('expenses.noItems') }}</td></tr> }
</tbody>
</table>
</div>
<div class="card-footer d-flex justify-content-between align-items-center gap-2 flex-wrap">
<div class="small text-secondary">{{ pageStart() }}-{{ pageEnd() }} / {{ pagination().total }}</div>
<div class="btn-group">
<button class="btn btn-outline-secondary btn-sm" type="button" [disabled]="!pagination().hasPrev" (click)="changePage(1)">«</button>
<button class="btn btn-outline-secondary btn-sm" type="button" [disabled]="!pagination().hasPrev" (click)="changePage(pagination().page - 1)"></button>
<button class="btn btn-outline-secondary btn-sm" type="button" disabled>{{ pagination().page }} / {{ pagination().totalPages }}</button>
<button class="btn btn-outline-secondary btn-sm" type="button" [disabled]="!pagination().hasNext" (click)="changePage(pagination().page + 1)"></button>
<button class="btn btn-outline-secondary btn-sm" type="button" [disabled]="!pagination().hasNext" (click)="changePage(pagination().totalPages)">»</button>
</div>
</div>
</div>
@if (proofPreview()) {
<div class="modal modal-blur fade show d-block" tabindex="-1">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ proofPreview()?.label || proofPreview()?.originalName || ui.t('expenses.proof') }}</h5>
<button class="btn-close ec-modal-close" type="button" (click)="closeProofPreview()"></button>
</div>
<div class="modal-body ec-proof-modal-body">
@if (isPdf(proofPreview()!)) {
<iframe class="ec-proof-frame" [src]="proofPreviewUrl()"></iframe>
} @else {
<img class="img-fluid ec-proof-preview" [src]="proofPreview()?.previewUrl || proofPreview()?.fileUrl" [alt]="ui.t('expenses.proof')" />
}
</div>
</div>
</div>
</div>
<div class="modal-backdrop fade show" (click)="closeProofPreview()"></div>
}
`
}) })
export class ExpenseListComponent implements OnInit { export class ExpenseListComponent implements OnInit {
readonly ui = inject(UiService); readonly ui = inject(UiService);

View File

@@ -0,0 +1,108 @@
<div class="page-header d-print-none mb-3 ec-page-header">
<div class="row align-items-center g-3">
<div class="col">
<h2 class="page-title mb-1">{{ ui.t('expenses.title') }}</h2>
<div class="text-secondary">{{ ui.t('expenses.subtitle') }}</div>
</div>
</div>
</div>
<div class="mb-3">
<nav class="nav nav-pills gap-2">
<a class="nav-link active" [routerLink]="['/expenses/add']">{{ ui.t('action.addExpense') }}</a>
<a class="nav-link" [routerLink]="['/expenses/list']">{{ ui.t('expenses.listTitle') }}</a>
</nav>
</div>
<div class="row row-cards align-items-start">
<div class="col-12">
<div class="card overflow-hidden">
<div class="card-header d-flex justify-content-between align-items-center gap-2 flex-wrap">
<h3 class="card-title mb-0">{{ editingExpenseId() ? ui.t('expenses.edit') : ui.t('expenses.new') }}</h3>
@if (editingExpenseId()) {
<button class="btn btn-outline-secondary btn-sm" type="button" (click)="cancelEdit()">{{ ui.t('action.cancelEdit') }}</button>
}
</div>
<div class="card-body">
<form [formGroup]="expenseForm" (ngSubmit)="submitExpense()" class="d-grid gap-3" novalidate>
@if (submitted() && expenseForm.invalid) {
<div class="alert alert-danger mb-0">{{ ui.t('expenses.requiredHint') }}</div>
}
<div class="row g-3">
<div class="col-md-7"><label class="form-label">{{ ui.t('expenses.field.title') }} <span class="text-danger">*</span></label><input class="form-control" formControlName="title" [class.is-invalid]="expenseForm.controls.title.invalid && (expenseForm.controls.title.touched || submitted())" /></div>
<div class="col-md-5"><label class="form-label">{{ ui.t('expenses.field.amount') }} <span class="text-danger">*</span></label><input class="form-control" type="number" step="0.01" formControlName="amount" [class.is-invalid]="expenseForm.controls.amount.invalid && (expenseForm.controls.amount.touched || submitted())" /></div>
<div class="col-md-4"><label class="form-label">{{ ui.t('expenses.field.date') }} <span class="text-danger">*</span></label><input class="form-control" type="date" formControlName="expenseDate" /></div>
<div class="col-md-4"><label class="form-label">{{ ui.t('expenses.field.category') }} <span class="text-danger">*</span></label><select class="form-select" formControlName="categoryId"><option value="">{{ ui.t('common.select') }}</option>@for (category of categories(); track category.id) { <option [value]="category.id">{{ category.name }}</option> }</select></div>
<div class="col-md-4"><label class="form-label">{{ ui.t('expenses.field.status') }}</label><select class="form-select" formControlName="status"><option value="DRAFT">{{ ui.t('status.draft') }}</option><option value="PENDING">{{ ui.t('status.pending') }}</option><option value="APPROVED">{{ ui.t('status.approved') }}</option><option value="REJECTED">{{ ui.t('status.rejected') }}</option></select></div>
<div class="col-md-5"><label class="form-label">{{ ui.t('expenses.field.payment') }}</label><select class="form-select" formControlName="paymentMethod"><option value="">{{ ui.t('expenses.payment.none') }}</option><option value="CARD">{{ ui.t('expenses.payment.card') }}</option><option value="CASH">{{ ui.t('expenses.payment.cash') }}</option><option value="TRANSFER">{{ ui.t('expenses.payment.transfer') }}</option><option value="BLIK">BLIK</option><option value="OTHER">{{ ui.t('expenses.payment.other') }}</option></select></div>
<div class="col-md-7"><label class="form-label">{{ ui.t('expenses.field.merchantPicker') }}</label><div class="input-group"><select class="form-select" [value]="selectedMerchantId()" (change)="selectMerchant($any($event.target).value)"><option value="">{{ ui.t('expenses.customEntry') }}</option>@for (item of activeMerchants(); track item.id) { <option [value]="item.id">{{ item.name }}</option> }</select><button class="btn btn-outline-primary" type="button" (click)="openMerchantModal()">{{ ui.t('action.add') }}</button></div></div>
<div class="col-md-6"><label class="form-label">{{ ui.t('expenses.field.merchantName') }}</label><input class="form-control" formControlName="merchant" /></div>
<div class="col-md-6"><label class="form-label">{{ ui.t('expenses.field.tags') }}</label><input class="form-control" formControlName="tagsText" [placeholder]="ui.t('expenses.tagPlaceholder')" /></div>
<div class="col-12"><label class="form-label">{{ ui.t('expenses.field.description') }}</label><textarea class="form-control" rows="3" formControlName="description"></textarea></div>
</div>
<div class="card bg-body-tertiary overflow-hidden"><div class="card-body d-grid gap-3">
<div class="d-flex justify-content-between align-items-center"><div class="form-label mb-0">{{ ui.t('expenses.field.customFields') }}</div><button class="btn btn-outline-secondary btn-sm" type="button" (click)="addCustomField()">{{ ui.t('action.add') }}</button></div>
<div formArrayName="customFields" class="d-grid gap-2">
@for (group of customFields.controls; track $index) {
<div [formGroupName]="$index" class="row g-2">
<div class="col-sm-5"><input class="form-control" formControlName="key" [placeholder]="ui.t('expenses.field.customKey')" /></div>
<div class="col-sm-5"><input class="form-control" formControlName="value" [placeholder]="ui.t('expenses.field.customValue')" /></div>
<div class="col-sm-2"><button class="btn btn-outline-danger w-100" type="button" (click)="removeCustomField($index)">{{ ui.t('action.delete') }}</button></div>
</div>
} @empty {
<div class="text-secondary small">{{ ui.t('expenses.noCustomFields') }}</div>
}
</div>
</div></div>
<div class="card bg-body-tertiary overflow-hidden"><div class="card-body d-grid gap-3">
<div class="row g-3">
<div class="col-md-4"><label class="form-label">{{ ui.t('expenses.field.proofType') }}</label><select class="form-select" formControlName="proofType"><option value="RECEIPT">{{ ui.t('proof.receipt') }}</option><option value="INVOICE">{{ ui.t('proof.invoice') }}</option><option value="NOTE">{{ ui.t('proof.note') }}</option><option value="BANK_STATEMENT">{{ ui.t('proof.statement') }}</option><option value="OTHER">{{ ui.t('proof.other') }}</option></select></div>
<div class="col-md-4"><label class="form-label">{{ ui.t('expenses.field.proofLabel') }}</label><input class="form-control" formControlName="proofLabel" /></div>
<div class="col-md-4"><label class="form-label">{{ ui.t('expenses.field.file') }}</label><input class="form-control" type="file" accept="image/*,.pdf" multiple (change)="onProofSelected($event)" /></div>
<div class="col-12"><label class="form-label">{{ ui.t('expenses.field.proofNote') }}</label><textarea class="form-control" rows="2" formControlName="proofNote"></textarea></div>
</div>
@if (editingExpenseId() && editingProofs().length) {
<div>
<div class="form-label">{{ ui.t('expenses.existingProofs') }}</div>
<div class="d-grid gap-2">
@for (proof of editingProofs(); track proof.id) {
<div class="d-flex justify-content-between align-items-center gap-2 border rounded-3 p-2 bg-white">
<button class="btn btn-link text-start p-0 text-decoration-none flex-grow-1" type="button" (click)="openProof(proof)">{{ proof.label || proof.originalName || ui.t('expenses.proof') }}</button>
<button class="btn btn-sm btn-outline-danger" type="button" (click)="markProofForRemoval(proof)">{{ ui.t('action.delete') }}</button>
</div>
}
</div>
</div>
}
@if (showCropper()) {
<div><div class="form-label">{{ ui.t('expenses.field.crop') }}</div><image-cropper [imageChangedEvent]="imageChangedEvent()" [maintainAspectRatio]="false" format="png" (imageCropped)="onImageCropped($event)"></image-cropper></div>
}
@if (croppedPreview()) {
<div><div class="form-label">{{ ui.t('expenses.field.cropPreview') }}</div><img class="img-fluid rounded" [src]="croppedPreview()" [alt]="ui.t('expenses.field.cropPreview')" /></div>
}
@if (selectedFiles().length) {
<div><div class="form-label">{{ ui.t('expenses.attachmentsSelected') }}</div><div class="d-flex flex-wrap gap-2">@for (file of selectedFiles(); track file.name + $index) { <span class="badge text-bg-secondary">{{ file.name }}</span> }</div></div>
}
</div></div>
<div class="btn-list flex-wrap">
<button class="btn btn-outline-secondary" type="button" (click)="submitExpense('DRAFT')" [disabled]="saving()">{{ ui.t('action.saveDraft') }}</button>
<button class="btn btn-success" [disabled]="saving()">{{ saving() ? ui.t('expenses.saving') : ui.t('action.save') }}</button>
</div>
</form>
</div>
</div>
</div>
@if (merchantModalOpen()) {
<div class="modal modal-blur fade show d-block" tabindex="-1"><div class="modal-dialog modal-dialog-centered"><div class="modal-content"><div class="modal-header"><h5 class="modal-title">{{ ui.t('merchant.new') }}</h5><button class="btn-close ec-modal-close" type="button" (click)="closeMerchantModal()"></button></div><form [formGroup]="merchantForm" (ngSubmit)="saveMerchant()"><div class="modal-body"><div class="d-grid gap-3"><div><label class="form-label">{{ ui.t('merchant.name') }}</label><input class="form-control" formControlName="name" /></div><div><label class="form-label">{{ ui.t('merchant.type') }}</label><select class="form-select" formControlName="kind"><option value="MERCHANT">{{ ui.t('merchant.kind.merchant') }}</option><option value="SERVICE_PROVIDER">{{ ui.t('merchant.kind.service') }}</option><option value="OTHER">{{ ui.t('merchant.kind.other') }}</option></select></div><div><label class="form-label">{{ ui.t('merchant.notes') }}</label><textarea class="form-control" rows="3" formControlName="notes"></textarea></div></div></div><div class="modal-footer"><button class="btn btn-ghost-secondary" type="button" (click)="closeMerchantModal()">{{ ui.t('action.cancel') }}</button><button class="btn btn-success" [disabled]="merchantForm.invalid">{{ ui.t('action.saveMerchant') }}</button></div></form></div></div></div><div class="modal-backdrop fade show"></div>
}
@if (proofPreview()) {
<div class="modal modal-blur fade show d-block" tabindex="-1"><div class="modal-dialog modal-xl modal-dialog-centered"><div class="modal-content"><div class="modal-header"><h5 class="modal-title">{{ proofPreview()?.label || proofPreview()?.originalName || ui.t('expenses.proof') }}</h5><button class="btn-close ec-modal-close" type="button" (click)="closeProofPreview()"></button></div><div class="modal-body ec-proof-modal-body">@if (isPdf(proofPreview()!)) { <iframe class="ec-proof-frame" [src]="proofPreviewUrl()"></iframe> } @else { <img class="img-fluid ec-proof-preview" [src]="proofPreview()?.previewUrl || proofPreview()?.fileUrl" [alt]="ui.t('expenses.proof')" /> }</div></div></div></div><div class="modal-backdrop fade show" (click)="closeProofPreview()"></div>
}

View File

@@ -24,116 +24,7 @@ const today = formatLocalDate(new Date());
selector: 'app-expenses', selector: 'app-expenses',
standalone: true, standalone: true,
imports: [CommonModule, ReactiveFormsModule, RouterLink, ImageCropperComponent], imports: [CommonModule, ReactiveFormsModule, RouterLink, ImageCropperComponent],
template: ` templateUrl: './expenses.component.html'
<div class="page-header d-print-none mb-3 ec-page-header">
<div class="row align-items-center g-3">
<div class="col">
<h2 class="page-title mb-1">{{ ui.t('expenses.title') }}</h2>
<div class="text-secondary">{{ ui.t('expenses.subtitle') }}</div>
</div>
</div>
</div>
<div class="mb-3">
<nav class="nav nav-pills gap-2">
<a class="nav-link active" [routerLink]="['/expenses/add']">{{ ui.t('action.addExpense') }}</a>
<a class="nav-link" [routerLink]="['/expenses/list']">{{ ui.t('expenses.listTitle') }}</a>
</nav>
</div>
<div class="row row-cards align-items-start">
<div class="col-12">
<div class="card overflow-hidden">
<div class="card-header d-flex justify-content-between align-items-center gap-2 flex-wrap">
<h3 class="card-title mb-0">{{ editingExpenseId() ? ui.t('expenses.edit') : ui.t('expenses.new') }}</h3>
@if (editingExpenseId()) {
<button class="btn btn-outline-secondary btn-sm" type="button" (click)="cancelEdit()">{{ ui.t('action.cancelEdit') }}</button>
}
</div>
<div class="card-body">
<form [formGroup]="expenseForm" (ngSubmit)="submitExpense()" class="d-grid gap-3" novalidate>
@if (submitted() && expenseForm.invalid) {
<div class="alert alert-danger mb-0">{{ ui.t('expenses.requiredHint') }}</div>
}
<div class="row g-3">
<div class="col-md-7"><label class="form-label">{{ ui.t('expenses.field.title') }} <span class="text-danger">*</span></label><input class="form-control" formControlName="title" [class.is-invalid]="expenseForm.controls.title.invalid && (expenseForm.controls.title.touched || submitted())" /></div>
<div class="col-md-5"><label class="form-label">{{ ui.t('expenses.field.amount') }} <span class="text-danger">*</span></label><input class="form-control" type="number" step="0.01" formControlName="amount" [class.is-invalid]="expenseForm.controls.amount.invalid && (expenseForm.controls.amount.touched || submitted())" /></div>
<div class="col-md-4"><label class="form-label">{{ ui.t('expenses.field.date') }} <span class="text-danger">*</span></label><input class="form-control" type="date" formControlName="expenseDate" /></div>
<div class="col-md-4"><label class="form-label">{{ ui.t('expenses.field.category') }} <span class="text-danger">*</span></label><select class="form-select" formControlName="categoryId"><option value="">{{ ui.t('common.select') }}</option>@for (category of categories(); track category.id) { <option [value]="category.id">{{ category.name }}</option> }</select></div>
<div class="col-md-4"><label class="form-label">{{ ui.t('expenses.field.status') }}</label><select class="form-select" formControlName="status"><option value="DRAFT">{{ ui.t('status.draft') }}</option><option value="PENDING">{{ ui.t('status.pending') }}</option><option value="APPROVED">{{ ui.t('status.approved') }}</option><option value="REJECTED">{{ ui.t('status.rejected') }}</option></select></div>
<div class="col-md-5"><label class="form-label">{{ ui.t('expenses.field.payment') }}</label><select class="form-select" formControlName="paymentMethod"><option value="">{{ ui.t('expenses.payment.none') }}</option><option value="CARD">{{ ui.t('expenses.payment.card') }}</option><option value="CASH">{{ ui.t('expenses.payment.cash') }}</option><option value="TRANSFER">{{ ui.t('expenses.payment.transfer') }}</option><option value="BLIK">BLIK</option><option value="OTHER">{{ ui.t('expenses.payment.other') }}</option></select></div>
<div class="col-md-7"><label class="form-label">{{ ui.t('expenses.field.merchantPicker') }}</label><div class="input-group"><select class="form-select" [value]="selectedMerchantId()" (change)="selectMerchant($any($event.target).value)"><option value="">{{ ui.t('expenses.customEntry') }}</option>@for (item of activeMerchants(); track item.id) { <option [value]="item.id">{{ item.name }}</option> }</select><button class="btn btn-outline-primary" type="button" (click)="openMerchantModal()">{{ ui.t('action.add') }}</button></div></div>
<div class="col-md-6"><label class="form-label">{{ ui.t('expenses.field.merchantName') }}</label><input class="form-control" formControlName="merchant" /></div>
<div class="col-md-6"><label class="form-label">{{ ui.t('expenses.field.tags') }}</label><input class="form-control" formControlName="tagsText" [placeholder]="ui.t('expenses.tagPlaceholder')" /></div>
<div class="col-12"><label class="form-label">{{ ui.t('expenses.field.description') }}</label><textarea class="form-control" rows="3" formControlName="description"></textarea></div>
</div>
<div class="card bg-body-tertiary overflow-hidden"><div class="card-body d-grid gap-3">
<div class="d-flex justify-content-between align-items-center"><div class="form-label mb-0">{{ ui.t('expenses.field.customFields') }}</div><button class="btn btn-outline-secondary btn-sm" type="button" (click)="addCustomField()">{{ ui.t('action.add') }}</button></div>
<div formArrayName="customFields" class="d-grid gap-2">
@for (group of customFields.controls; track $index) {
<div [formGroupName]="$index" class="row g-2">
<div class="col-sm-5"><input class="form-control" formControlName="key" [placeholder]="ui.t('expenses.field.customKey')" /></div>
<div class="col-sm-5"><input class="form-control" formControlName="value" [placeholder]="ui.t('expenses.field.customValue')" /></div>
<div class="col-sm-2"><button class="btn btn-outline-danger w-100" type="button" (click)="removeCustomField($index)">{{ ui.t('action.delete') }}</button></div>
</div>
} @empty {
<div class="text-secondary small">{{ ui.t('expenses.noCustomFields') }}</div>
}
</div>
</div></div>
<div class="card bg-body-tertiary overflow-hidden"><div class="card-body d-grid gap-3">
<div class="row g-3">
<div class="col-md-4"><label class="form-label">{{ ui.t('expenses.field.proofType') }}</label><select class="form-select" formControlName="proofType"><option value="RECEIPT">{{ ui.t('proof.receipt') }}</option><option value="INVOICE">{{ ui.t('proof.invoice') }}</option><option value="NOTE">{{ ui.t('proof.note') }}</option><option value="BANK_STATEMENT">{{ ui.t('proof.statement') }}</option><option value="OTHER">{{ ui.t('proof.other') }}</option></select></div>
<div class="col-md-4"><label class="form-label">{{ ui.t('expenses.field.proofLabel') }}</label><input class="form-control" formControlName="proofLabel" /></div>
<div class="col-md-4"><label class="form-label">{{ ui.t('expenses.field.file') }}</label><input class="form-control" type="file" accept="image/*,.pdf" multiple (change)="onProofSelected($event)" /></div>
<div class="col-12"><label class="form-label">{{ ui.t('expenses.field.proofNote') }}</label><textarea class="form-control" rows="2" formControlName="proofNote"></textarea></div>
</div>
@if (editingExpenseId() && editingProofs().length) {
<div>
<div class="form-label">{{ ui.t('expenses.existingProofs') }}</div>
<div class="d-grid gap-2">
@for (proof of editingProofs(); track proof.id) {
<div class="d-flex justify-content-between align-items-center gap-2 border rounded-3 p-2 bg-white">
<button class="btn btn-link text-start p-0 text-decoration-none flex-grow-1" type="button" (click)="openProof(proof)">{{ proof.label || proof.originalName || ui.t('expenses.proof') }}</button>
<button class="btn btn-sm btn-outline-danger" type="button" (click)="markProofForRemoval(proof)">{{ ui.t('action.delete') }}</button>
</div>
}
</div>
</div>
}
@if (showCropper()) {
<div><div class="form-label">{{ ui.t('expenses.field.crop') }}</div><image-cropper [imageChangedEvent]="imageChangedEvent()" [maintainAspectRatio]="false" format="png" (imageCropped)="onImageCropped($event)"></image-cropper></div>
}
@if (croppedPreview()) {
<div><div class="form-label">{{ ui.t('expenses.field.cropPreview') }}</div><img class="img-fluid rounded" [src]="croppedPreview()" [alt]="ui.t('expenses.field.cropPreview')" /></div>
}
@if (selectedFiles().length) {
<div><div class="form-label">{{ ui.t('expenses.attachmentsSelected') }}</div><div class="d-flex flex-wrap gap-2">@for (file of selectedFiles(); track file.name + $index) { <span class="badge text-bg-secondary">{{ file.name }}</span> }</div></div>
}
</div></div>
<div class="btn-list flex-wrap">
<button class="btn btn-outline-secondary" type="button" (click)="submitExpense('DRAFT')" [disabled]="saving()">{{ ui.t('action.saveDraft') }}</button>
<button class="btn btn-success" [disabled]="saving()">{{ saving() ? ui.t('expenses.saving') : ui.t('action.save') }}</button>
</div>
</form>
</div>
</div>
</div>
@if (merchantModalOpen()) {
<div class="modal modal-blur fade show d-block" tabindex="-1"><div class="modal-dialog modal-dialog-centered"><div class="modal-content"><div class="modal-header"><h5 class="modal-title">{{ ui.t('merchant.new') }}</h5><button class="btn-close ec-modal-close" type="button" (click)="closeMerchantModal()"></button></div><form [formGroup]="merchantForm" (ngSubmit)="saveMerchant()"><div class="modal-body"><div class="d-grid gap-3"><div><label class="form-label">{{ ui.t('merchant.name') }}</label><input class="form-control" formControlName="name" /></div><div><label class="form-label">{{ ui.t('merchant.type') }}</label><select class="form-select" formControlName="kind"><option value="MERCHANT">{{ ui.t('merchant.kind.merchant') }}</option><option value="SERVICE_PROVIDER">{{ ui.t('merchant.kind.service') }}</option><option value="OTHER">{{ ui.t('merchant.kind.other') }}</option></select></div><div><label class="form-label">{{ ui.t('merchant.notes') }}</label><textarea class="form-control" rows="3" formControlName="notes"></textarea></div></div></div><div class="modal-footer"><button class="btn btn-ghost-secondary" type="button" (click)="closeMerchantModal()">{{ ui.t('action.cancel') }}</button><button class="btn btn-success" [disabled]="merchantForm.invalid">{{ ui.t('action.saveMerchant') }}</button></div></form></div></div></div><div class="modal-backdrop fade show"></div>
}
@if (proofPreview()) {
<div class="modal modal-blur fade show d-block" tabindex="-1"><div class="modal-dialog modal-xl modal-dialog-centered"><div class="modal-content"><div class="modal-header"><h5 class="modal-title">{{ proofPreview()?.label || proofPreview()?.originalName || ui.t('expenses.proof') }}</h5><button class="btn-close ec-modal-close" type="button" (click)="closeProofPreview()"></button></div><div class="modal-body ec-proof-modal-body">@if (isPdf(proofPreview()!)) { <iframe class="ec-proof-frame" [src]="proofPreviewUrl()"></iframe> } @else { <img class="img-fluid ec-proof-preview" [src]="proofPreview()?.previewUrl || proofPreview()?.fileUrl" [alt]="ui.t('expenses.proof')" /> }</div></div></div></div><div class="modal-backdrop fade show" (click)="closeProofPreview()"></div>
}
`
}) })
export class ExpensesComponent implements OnInit { export class ExpensesComponent implements OnInit {
readonly ui = inject(UiService); readonly ui = inject(UiService);

View File

@@ -0,0 +1,213 @@
<div class="page-header d-print-none mb-3 ec-page-header">
<div class="row align-items-center g-3">
<div class="col">
<h2 class="page-title mb-1">{{ ui.t('integrations.title') }}</h2>
<div class="text-secondary">{{ ui.t('integrations.subtitle') }}</div>
</div>
</div>
</div>
<div class="row row-cards mb-3">
<div class="col-lg-5">
<div class="card overflow-hidden ec-accent-card ec-accent-card-primary h-100">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<h3 class="card-title mb-0">{{ ui.t('integrations.shoppingList') }}</h3>
<a class="btn btn-outline-secondary btn-sm" href="https://git.linuxiarz.pl/gru/lista_zakupowa_live" target="_blank" rel="noreferrer">{{ ui.t('integrations.projectLink') }}</a>
</div>
<div class="card-body d-grid gap-3">
<div class="alert alert-info mb-0">
<div class="fw-semibold mb-1">{{ ui.t('integrations.selfHostedTitle') }}</div>
<div>{{ ui.t('integrations.selfHostedHint') }}</div>
</div>
<form [formGroup]="form" (ngSubmit)="save()" class="d-grid gap-3">
<label class="form-check">
<input class="form-check-input" type="checkbox" formControlName="enabled" />
<span class="form-check-label">{{ ui.t('integrations.enabled') }}</span>
</label>
<div>
<label class="form-label">{{ ui.t('integrations.baseUrl') }}</label>
<input class="form-control" formControlName="baseUrl" placeholder="https://host.example.com" />
</div>
<div>
<label class="form-label">{{ ui.t('integrations.apiToken') }}</label>
<input class="form-control" formControlName="apiToken" type="password" />
<div class="form-hint">{{ ui.t('integrations.keepToken') }}</div>
</div>
<div>
<label class="form-label">{{ ui.t('integrations.authMode') }}</label>
<select class="form-select" formControlName="authMode">
<option value="both">Bearer + X-API-Token</option>
<option value="bearer">Bearer</option>
<option value="x-api-token">X-API-Token</option>
</select>
</div>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">{{ ui.t('integrations.ownerId') }}</label>
<input class="form-control" formControlName="ownerId" />
</div>
<div class="col-md-6">
<label class="form-label">{{ ui.t('integrations.defaultListId') }}</label>
<input class="form-control" formControlName="defaultListId" />
</div>
</div>
<div class="btn-list flex-wrap">
<button class="btn btn-success" [disabled]="form.invalid">{{ ui.t('action.save') }}</button>
<button class="btn btn-outline-info" type="button" (click)="test()">{{ ui.t('action.testConnection') }}</button>
</div>
</form>
</div>
</div>
</div>
<div class="col-lg-7">
<div class="card overflow-hidden h-100">
<div class="card-header"><h3 class="card-title">{{ ui.t('integrations.history') }}</h3></div>
<div class="card-body d-grid gap-3">
<form [formGroup]="historyForm" class="row g-3 align-items-end">
<div class="col-md-4">
<label class="form-label">{{ ui.t('integrations.period') }}</label>
<input class="form-control" type="month" formControlName="period" />
</div>
<div class="col-md-3">
<label class="form-label">{{ ui.t('integrations.limit') }}</label>
<input class="form-control" type="number" min="1" max="300" formControlName="limit" />
</div>
<div class="col-md-5">
<div class="btn-list justify-content-md-end">
<button class="btn btn-primary" type="button" (click)="refresh()">{{ ui.t('action.refresh') }}</button>
</div>
</div>
</form>
<div class="row row-cards">
<div class="col-md-4"><div class="ec-stat-tile ec-stat-tile-primary"><div class="ec-stat-label">{{ ui.t('integrations.externalLists') }}</div><div class="ec-stat-value">{{ summaryListCount() }}</div></div></div>
<div class="col-md-4"><div class="ec-stat-tile ec-stat-tile-success"><div class="ec-stat-label">{{ ui.t('integrations.externalSpend') }}</div><div class="ec-stat-value">{{ summaryAmount() | currency:'PLN':'symbol':'1.2-2' }}</div></div></div>
<div class="col-md-4"><div class="ec-stat-tile"><div class="ec-stat-label">{{ ui.t('integrations.externalCount') }}</div><div class="ec-stat-value">{{ summaryCount() }}</div></div></div>
</div>
<div class="border rounded-3 p-3 bg-body-tertiary">
@if (configured()) {
<div class="d-flex justify-content-between gap-2 flex-wrap align-items-center">
<div>
<div class="fw-semibold">{{ ui.t('integrations.summary') }}</div>
<div class="text-secondary small">{{ historyForm.controls.period.value }}</div>
</div>
<div class="text-end small text-secondary">
{{ ui.t('integrations.summaryLists') }}: <strong>{{ summaryListCount() }}</strong> ·
{{ ui.t('integrations.summarySpend') }}: <strong>{{ summaryAmount() | number:'1.2-2' }} PLN</strong>
</div>
</div>
} @else {
<div class="text-secondary">{{ ui.t('integrations.notConfigured') }}</div>
}
</div>
</div>
</div>
</div>
</div>
<div class="row row-cards mb-3">
<div class="col-lg-4">
<div class="card overflow-hidden">
<div class="card-header d-flex justify-content-between align-items-center gap-2">
<h3 class="card-title mb-0">{{ ui.t('integrations.lists') }}</h3>
<span class="badge text-bg-secondary">{{ visibleLists().length }}</span>
</div>
<div class="list-group list-group-flush ec-scroll-list">
@for (item of visibleLists(); track item.id) {
<button class="list-group-item list-group-item-action text-start" type="button" [class.active]="isSelectedList(item)" (click)="selectList(item)">
<div class="fw-semibold">{{ listTitle(item) }}</div>
<div class="small text-secondary">{{ listCreatedAt(item) | date:'yyyy-MM-dd' }} · {{ listOwner(item) || ui.t('common.none') }}</div>
</button>
} @empty {
<div class="list-group-item text-secondary">{{ ui.t('common.noData') }}</div>
}
</div>
</div>
</div>
<div class="col-lg-8">
<div class="card overflow-hidden ec-accent-card ec-accent-card-success">
<div class="card-header"><h3 class="card-title">{{ ui.t('integrations.importTitle') }}</h3></div>
<div class="card-body d-grid gap-3">
<div class="alert alert-warning mb-0">
<div class="fw-semibold mb-1">{{ ui.t('integrations.importExplainTitle') }}</div>
<div>{{ ui.t('integrations.importExplainBodySimple') }}</div>
</div>
<form [formGroup]="importForm" class="row g-3">
<div class="col-md-5">
<label class="form-label">{{ ui.t('expenses.field.category') }}</label>
<select class="form-select" formControlName="categoryId">
<option value="">{{ ui.t('common.select') }}</option>
@for (item of categories(); track item.id) {
<option [value]="item.id">{{ item.name }}</option>
}
</select>
</div>
<div class="col-md-3">
<label class="form-label">{{ ui.t('expenses.field.status') }}</label>
<select class="form-select" formControlName="status">
<option value="DRAFT">{{ ui.t('status.draft') }}</option>
<option value="PENDING">{{ ui.t('status.pending') }}</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label">{{ ui.t('table.merchant') }}</label>
<input class="form-control" formControlName="merchant" />
</div>
</form>
<div class="row g-3">
<div class="col-md-6">
<div class="border rounded-3 p-3 h-100 bg-body-tertiary">
<div class="fw-semibold mb-1">{{ ui.t('integrations.importMonthTitle') }}</div>
<div class="text-secondary small mb-3">{{ ui.t('integrations.importMonthHint') }}</div>
<button class="btn btn-primary" type="button" [disabled]="importForm.invalid || !configured()" (click)="importPeriod()">{{ ui.t('integrations.importPeriod') }}</button>
</div>
</div>
<div class="col-md-6">
<div class="border rounded-3 p-3 h-100 bg-body-tertiary">
<div class="fw-semibold mb-1">{{ ui.t('integrations.importListTitle') }}</div>
<div class="text-secondary small mb-3">{{ selectedList() ? listTitle(selectedList()) : ui.t('integrations.selectListHintSimple') }}</div>
<button class="btn btn-success" type="button" [disabled]="importForm.invalid || !selectedList()" (click)="importSelectedList()">{{ ui.t('integrations.importSelectedList') }}</button>
</div>
</div>
</div>
@if (selectedList()) {
<div class="border rounded-3 p-3 bg-body-tertiary">
<div class="d-flex justify-content-between gap-2 flex-wrap align-items-start mb-2">
<div>
<div class="fw-semibold">{{ listTitle(selectedList()) }}</div>
<div class="small text-secondary">{{ listCreatedAt(selectedList()) | date:'yyyy-MM-dd' }} · {{ listOwner(selectedList()) || ui.t('common.none') }}</div>
</div>
<div class="text-end">
<div class="small text-secondary">{{ ui.t('integrations.selectedListSummary') }}</div>
<div class="fw-semibold">{{ selectedListCount() }} / {{ selectedListTotal() | currency:'PLN':'symbol':'1.2-2' }}</div>
</div>
</div>
<div class="table-responsive">
<table class="table table-sm table-vcenter mb-0">
<thead><tr><th>{{ ui.t('table.title') }}</th><th>{{ ui.t('table.date') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th></tr></thead>
<tbody>
@for (item of selectedListExpenses(); track $index) {
<tr>
<td>{{ itemTitle(item) }}</td>
<td>{{ itemDate(item) }}</td>
<td class="text-end">{{ itemAmount(item) | currency:'PLN':'symbol':'1.2-2' }}</td>
</tr>
} @empty {
<tr><td colspan="3" class="text-secondary">{{ ui.t('common.noData') }}</td></tr>
}
</tbody>
</table>
</div>
</div>
}
</div>
</div>
</div>
</div>

View File

@@ -22,221 +22,7 @@ const monthRange = (period: string) => {
selector: 'app-integrations', selector: 'app-integrations',
standalone: true, standalone: true,
imports: [CommonModule, ReactiveFormsModule, CurrencyPipe, DatePipe], imports: [CommonModule, ReactiveFormsModule, CurrencyPipe, DatePipe],
template: ` templateUrl: './integrations.component.html'
<div class="page-header d-print-none mb-3 ec-page-header">
<div class="row align-items-center g-3">
<div class="col">
<h2 class="page-title mb-1">{{ ui.t('integrations.title') }}</h2>
<div class="text-secondary">{{ ui.t('integrations.subtitle') }}</div>
</div>
</div>
</div>
<div class="row row-cards mb-3">
<div class="col-lg-5">
<div class="card overflow-hidden ec-accent-card ec-accent-card-primary h-100">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<h3 class="card-title mb-0">{{ ui.t('integrations.shoppingList') }}</h3>
<a class="btn btn-outline-secondary btn-sm" href="https://git.linuxiarz.pl/gru/lista_zakupowa_live" target="_blank" rel="noreferrer">{{ ui.t('integrations.projectLink') }}</a>
</div>
<div class="card-body d-grid gap-3">
<div class="alert alert-info mb-0">
<div class="fw-semibold mb-1">{{ ui.t('integrations.selfHostedTitle') }}</div>
<div>{{ ui.t('integrations.selfHostedHint') }}</div>
</div>
<form [formGroup]="form" (ngSubmit)="save()" class="d-grid gap-3">
<label class="form-check">
<input class="form-check-input" type="checkbox" formControlName="enabled" />
<span class="form-check-label">{{ ui.t('integrations.enabled') }}</span>
</label>
<div>
<label class="form-label">{{ ui.t('integrations.baseUrl') }}</label>
<input class="form-control" formControlName="baseUrl" placeholder="https://host.example.com" />
</div>
<div>
<label class="form-label">{{ ui.t('integrations.apiToken') }}</label>
<input class="form-control" formControlName="apiToken" type="password" />
<div class="form-hint">{{ ui.t('integrations.keepToken') }}</div>
</div>
<div>
<label class="form-label">{{ ui.t('integrations.authMode') }}</label>
<select class="form-select" formControlName="authMode">
<option value="both">Bearer + X-API-Token</option>
<option value="bearer">Bearer</option>
<option value="x-api-token">X-API-Token</option>
</select>
</div>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">{{ ui.t('integrations.ownerId') }}</label>
<input class="form-control" formControlName="ownerId" />
</div>
<div class="col-md-6">
<label class="form-label">{{ ui.t('integrations.defaultListId') }}</label>
<input class="form-control" formControlName="defaultListId" />
</div>
</div>
<div class="btn-list flex-wrap">
<button class="btn btn-success" [disabled]="form.invalid">{{ ui.t('action.save') }}</button>
<button class="btn btn-outline-info" type="button" (click)="test()">{{ ui.t('action.testConnection') }}</button>
</div>
</form>
</div>
</div>
</div>
<div class="col-lg-7">
<div class="card overflow-hidden h-100">
<div class="card-header"><h3 class="card-title">{{ ui.t('integrations.history') }}</h3></div>
<div class="card-body d-grid gap-3">
<form [formGroup]="historyForm" class="row g-3 align-items-end">
<div class="col-md-4">
<label class="form-label">{{ ui.t('integrations.period') }}</label>
<input class="form-control" type="month" formControlName="period" />
</div>
<div class="col-md-3">
<label class="form-label">{{ ui.t('integrations.limit') }}</label>
<input class="form-control" type="number" min="1" max="300" formControlName="limit" />
</div>
<div class="col-md-5">
<div class="btn-list justify-content-md-end">
<button class="btn btn-primary" type="button" (click)="refresh()">{{ ui.t('action.refresh') }}</button>
</div>
</div>
</form>
<div class="row row-cards">
<div class="col-md-4"><div class="ec-stat-tile ec-stat-tile-primary"><div class="ec-stat-label">{{ ui.t('integrations.externalLists') }}</div><div class="ec-stat-value">{{ summaryListCount() }}</div></div></div>
<div class="col-md-4"><div class="ec-stat-tile ec-stat-tile-success"><div class="ec-stat-label">{{ ui.t('integrations.externalSpend') }}</div><div class="ec-stat-value">{{ summaryAmount() | currency:'PLN':'symbol':'1.2-2' }}</div></div></div>
<div class="col-md-4"><div class="ec-stat-tile"><div class="ec-stat-label">{{ ui.t('integrations.externalCount') }}</div><div class="ec-stat-value">{{ summaryCount() }}</div></div></div>
</div>
<div class="border rounded-3 p-3 bg-body-tertiary">
@if (configured()) {
<div class="d-flex justify-content-between gap-2 flex-wrap align-items-center">
<div>
<div class="fw-semibold">{{ ui.t('integrations.summary') }}</div>
<div class="text-secondary small">{{ historyForm.controls.period.value }}</div>
</div>
<div class="text-end small text-secondary">
{{ ui.t('integrations.summaryLists') }}: <strong>{{ summaryListCount() }}</strong> ·
{{ ui.t('integrations.summarySpend') }}: <strong>{{ summaryAmount() | number:'1.2-2' }} PLN</strong>
</div>
</div>
} @else {
<div class="text-secondary">{{ ui.t('integrations.notConfigured') }}</div>
}
</div>
</div>
</div>
</div>
</div>
<div class="row row-cards mb-3">
<div class="col-lg-4">
<div class="card overflow-hidden">
<div class="card-header d-flex justify-content-between align-items-center gap-2">
<h3 class="card-title mb-0">{{ ui.t('integrations.lists') }}</h3>
<span class="badge text-bg-secondary">{{ visibleLists().length }}</span>
</div>
<div class="list-group list-group-flush ec-scroll-list">
@for (item of visibleLists(); track item.id) {
<button class="list-group-item list-group-item-action text-start" type="button" [class.active]="isSelectedList(item)" (click)="selectList(item)">
<div class="fw-semibold">{{ listTitle(item) }}</div>
<div class="small text-secondary">{{ listCreatedAt(item) | date:'yyyy-MM-dd' }} · {{ listOwner(item) || ui.t('common.none') }}</div>
</button>
} @empty {
<div class="list-group-item text-secondary">{{ ui.t('common.noData') }}</div>
}
</div>
</div>
</div>
<div class="col-lg-8">
<div class="card overflow-hidden ec-accent-card ec-accent-card-success">
<div class="card-header"><h3 class="card-title">{{ ui.t('integrations.importTitle') }}</h3></div>
<div class="card-body d-grid gap-3">
<div class="alert alert-warning mb-0">
<div class="fw-semibold mb-1">{{ ui.t('integrations.importExplainTitle') }}</div>
<div>{{ ui.t('integrations.importExplainBodySimple') }}</div>
</div>
<form [formGroup]="importForm" class="row g-3">
<div class="col-md-5">
<label class="form-label">{{ ui.t('expenses.field.category') }}</label>
<select class="form-select" formControlName="categoryId">
<option value="">{{ ui.t('common.select') }}</option>
@for (item of categories(); track item.id) {
<option [value]="item.id">{{ item.name }}</option>
}
</select>
</div>
<div class="col-md-3">
<label class="form-label">{{ ui.t('expenses.field.status') }}</label>
<select class="form-select" formControlName="status">
<option value="DRAFT">{{ ui.t('status.draft') }}</option>
<option value="PENDING">{{ ui.t('status.pending') }}</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label">{{ ui.t('table.merchant') }}</label>
<input class="form-control" formControlName="merchant" />
</div>
</form>
<div class="row g-3">
<div class="col-md-6">
<div class="border rounded-3 p-3 h-100 bg-body-tertiary">
<div class="fw-semibold mb-1">{{ ui.t('integrations.importMonthTitle') }}</div>
<div class="text-secondary small mb-3">{{ ui.t('integrations.importMonthHint') }}</div>
<button class="btn btn-primary" type="button" [disabled]="importForm.invalid || !configured()" (click)="importPeriod()">{{ ui.t('integrations.importPeriod') }}</button>
</div>
</div>
<div class="col-md-6">
<div class="border rounded-3 p-3 h-100 bg-body-tertiary">
<div class="fw-semibold mb-1">{{ ui.t('integrations.importListTitle') }}</div>
<div class="text-secondary small mb-3">{{ selectedList() ? listTitle(selectedList()) : ui.t('integrations.selectListHintSimple') }}</div>
<button class="btn btn-success" type="button" [disabled]="importForm.invalid || !selectedList()" (click)="importSelectedList()">{{ ui.t('integrations.importSelectedList') }}</button>
</div>
</div>
</div>
@if (selectedList()) {
<div class="border rounded-3 p-3 bg-body-tertiary">
<div class="d-flex justify-content-between gap-2 flex-wrap align-items-start mb-2">
<div>
<div class="fw-semibold">{{ listTitle(selectedList()) }}</div>
<div class="small text-secondary">{{ listCreatedAt(selectedList()) | date:'yyyy-MM-dd' }} · {{ listOwner(selectedList()) || ui.t('common.none') }}</div>
</div>
<div class="text-end">
<div class="small text-secondary">{{ ui.t('integrations.selectedListSummary') }}</div>
<div class="fw-semibold">{{ selectedListCount() }} / {{ selectedListTotal() | currency:'PLN':'symbol':'1.2-2' }}</div>
</div>
</div>
<div class="table-responsive">
<table class="table table-sm table-vcenter mb-0">
<thead><tr><th>{{ ui.t('table.title') }}</th><th>{{ ui.t('table.date') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th></tr></thead>
<tbody>
@for (item of selectedListExpenses(); track $index) {
<tr>
<td>{{ itemTitle(item) }}</td>
<td>{{ itemDate(item) }}</td>
<td class="text-end">{{ itemAmount(item) | currency:'PLN':'symbol':'1.2-2' }}</td>
</tr>
} @empty {
<tr><td colspan="3" class="text-secondary">{{ ui.t('common.noData') }}</td></tr>
}
</tbody>
</table>
</div>
</div>
}
</div>
</div>
</div>
</div>
`
}) })
export class IntegrationsComponent implements OnInit { export class IntegrationsComponent implements OnInit {
readonly ui = inject(UiService); readonly ui = inject(UiService);

View File

@@ -0,0 +1,90 @@
<div class="page-header d-print-none mb-3 ec-page-header">
<div class="row align-items-center g-3">
<div class="col">
<h2 class="page-title mb-1">{{ ui.t('merchant.title') }}</h2>
<div class="text-secondary">{{ ui.t('merchant.subtitle') }}</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>{{ ui.t('action.addMerchant') }}</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>{{ ui.t('merchant.name') }}</th><th>{{ ui.t('merchant.type') }}</th><th>{{ ui.t('admin.status') }}</th><th>{{ ui.t('merchant.notes') }}</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 ? ui.t('common.active') : ui.t('common.hidden') }}</span></td>
<td>{{ item.notes || ui.t('common.none') }}</td>
<td>
<div class="btn-list flex-nowrap">
<button class="btn btn-outline-primary btn-sm" type="button" (click)="openEdit(item)">{{ ui.t('action.edit') }}</button>
<button class="btn btn-outline-danger btn-sm" type="button" (click)="remove(item)">{{ ui.t('action.delete') }}</button>
</div>
</td>
</tr>
} @empty {
<tr><td colspan="5" class="text-secondary">{{ ui.t('merchant.noneSaved') }}</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() ? ui.t('merchant.edit') : ui.t('merchant.new') }}</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">{{ ui.t('merchant.name') }}</label>
<input class="form-control" formControlName="name" />
</div>
<div class="col-md-6">
<label class="form-label">{{ ui.t('merchant.type') }}</label>
<select class="form-select" formControlName="kind">
<option value="MERCHANT">{{ ui.t('merchant.kind.merchant') }}</option>
<option value="SERVICE_PROVIDER">{{ ui.t('merchant.kind.service') }}</option>
<option value="OTHER">{{ ui.t('merchant.kind.other') }}</option>
</select>
</div>
<div class="col-12">
<label class="form-label">{{ ui.t('merchant.notes') }}</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">{{ ui.t('merchant.showOnLists') }}</span>
</label>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost-secondary" type="button" (click)="closeModal()">{{ ui.t('action.cancel') }}</button>
<button class="btn btn-success" [disabled]="form.invalid">{{ editingId() ? ui.t('action.save') : ui.t('action.add') }}</button>
</div>
</form>
</div>
</div>
</div>
<div class="modal-backdrop fade show"></div>
}

View File

@@ -10,98 +10,7 @@ import type { Merchant } from '../../shared/models';
selector: 'app-merchants', selector: 'app-merchants',
standalone: true, standalone: true,
imports: [CommonModule, ReactiveFormsModule], imports: [CommonModule, ReactiveFormsModule],
template: ` templateUrl: './merchants.component.html'
<div class="page-header d-print-none mb-3 ec-page-header">
<div class="row align-items-center g-3">
<div class="col">
<h2 class="page-title mb-1">{{ ui.t('merchant.title') }}</h2>
<div class="text-secondary">{{ ui.t('merchant.subtitle') }}</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>{{ ui.t('action.addMerchant') }}</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>{{ ui.t('merchant.name') }}</th><th>{{ ui.t('merchant.type') }}</th><th>{{ ui.t('admin.status') }}</th><th>{{ ui.t('merchant.notes') }}</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 ? ui.t('common.active') : ui.t('common.hidden') }}</span></td>
<td>{{ item.notes || ui.t('common.none') }}</td>
<td>
<div class="btn-list flex-nowrap">
<button class="btn btn-outline-primary btn-sm" type="button" (click)="openEdit(item)">{{ ui.t('action.edit') }}</button>
<button class="btn btn-outline-danger btn-sm" type="button" (click)="remove(item)">{{ ui.t('action.delete') }}</button>
</div>
</td>
</tr>
} @empty {
<tr><td colspan="5" class="text-secondary">{{ ui.t('merchant.noneSaved') }}</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() ? ui.t('merchant.edit') : ui.t('merchant.new') }}</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">{{ ui.t('merchant.name') }}</label>
<input class="form-control" formControlName="name" />
</div>
<div class="col-md-6">
<label class="form-label">{{ ui.t('merchant.type') }}</label>
<select class="form-select" formControlName="kind">
<option value="MERCHANT">{{ ui.t('merchant.kind.merchant') }}</option>
<option value="SERVICE_PROVIDER">{{ ui.t('merchant.kind.service') }}</option>
<option value="OTHER">{{ ui.t('merchant.kind.other') }}</option>
</select>
</div>
<div class="col-12">
<label class="form-label">{{ ui.t('merchant.notes') }}</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">{{ ui.t('merchant.showOnLists') }}</span>
</label>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost-secondary" type="button" (click)="closeModal()">{{ ui.t('action.cancel') }}</button>
<button class="btn btn-success" [disabled]="form.invalid">{{ editingId() ? ui.t('action.save') : ui.t('action.add') }}</button>
</div>
</form>
</div>
</div>
</div>
<div class="modal-backdrop fade show"></div>
}
`
}) })
export class MerchantsComponent implements OnInit { export class MerchantsComponent implements OnInit {
readonly ui = inject(UiService); readonly ui = inject(UiService);

View File

@@ -0,0 +1,84 @@
<div class="page-header d-print-none mb-3 ec-page-header">
<div class="row align-items-center g-3">
<div class="col">
<h2 class="page-title mb-1">{{ ui.t('nav.recurring') }}</h2>
<div class="text-secondary">{{ ui.t('recurring.subtitle') }}</div>
</div>
</div>
</div>
<div class="row row-cards">
<div class="col-lg-5">
<div class="card overflow-hidden">
<div class="card-header d-flex justify-content-between align-items-center">
<h3 class="card-title">{{ editingId() ? ui.t('recurring.edit') : ui.t('recurring.new') }}</h3>
<div class="btn-list">
@if (editingId()) { <button class="btn btn-outline-secondary btn-sm" type="button" (click)="cancelEdit()">{{ ui.t('action.cancelEdit') }}</button> }
<button class="btn btn-outline-primary btn-sm" type="button" (click)="runNow()">{{ ui.t('recurring.runNow') }}</button>
</div>
</div>
<div class="card-body">
<form [formGroup]="form" (ngSubmit)="save()" class="d-grid gap-3">
<div class="row g-3">
<div class="col-md-8"><label class="form-label">{{ ui.t('expenses.field.title') }}</label><input class="form-control" formControlName="title" /></div>
<div class="col-md-4"><label class="form-label">{{ ui.t('expenses.field.amount') }}</label><input class="form-control" type="number" step="0.01" formControlName="amount" /></div>
<div class="col-md-6"><label class="form-label">{{ ui.t('expenses.field.category') }}</label><select class="form-select" formControlName="categoryId"><option value="">{{ ui.t('common.select') }}</option>@for (category of categories(); track category.id) { <option [value]="category.id">{{ category.name }}</option> }</select></div>
<div class="col-md-6"><label class="form-label">{{ ui.t('expenses.field.status') }}</label><select class="form-select" formControlName="defaultStatus"><option value="DRAFT">{{ ui.t('status.draft') }}</option><option value="PENDING">{{ ui.t('status.pending') }}</option></select></div>
<div class="col-md-6"><label class="form-label">{{ ui.t('recurring.frequency') }}</label><select class="form-select" formControlName="frequency"><option value="WEEKLY">{{ ui.t('recurring.weekly') }}</option><option value="MONTHLY">{{ ui.t('recurring.monthly') }}</option><option value="YEARLY">{{ ui.t('recurring.yearly') }}</option></select></div>
<div class="col-md-6"><label class="form-label">{{ ui.t('recurring.interval') }}</label><input class="form-control" type="number" min="1" formControlName="intervalValue" /></div>
<div class="col-md-4"><label class="form-label">{{ ui.t('recurring.startDate') }}</label><input class="form-control" type="date" formControlName="startDate" /></div>
<div class="col-md-4"><label class="form-label">{{ ui.t('recurring.nextRunDate') }}</label><input class="form-control" type="date" formControlName="nextRunDate" /></div>
<div class="col-md-4"><label class="form-label">{{ ui.t('recurring.endDate') }}</label><input class="form-control" type="date" formControlName="endDate" /></div>
<div class="col-md-6"><label class="form-label">{{ ui.t('recurring.maxOccurrences') }}</label><input class="form-control" type="number" min="1" formControlName="maxOccurrences" /></div>
<div class="col-md-6"><label class="form-label">{{ ui.t('expenses.field.merchantName') }}</label><input class="form-control" formControlName="merchant" /></div>
<div class="col-md-6"><label class="form-label">{{ ui.t('expenses.field.tags') }}</label><input class="form-control" formControlName="tagsText" /></div>
<div class="col-12"><label class="form-label">{{ ui.t('expenses.field.description') }}</label><textarea class="form-control" rows="2" formControlName="description"></textarea></div>
</div>
<div formArrayName="customFields" class="d-grid gap-2">
<div class="d-flex justify-content-between align-items-center"><div class="form-label mb-0">{{ ui.t('expenses.field.customFields') }}</div><button class="btn btn-outline-secondary btn-sm" type="button" (click)="addCustomField()">{{ ui.t('action.add') }}</button></div>
@for (group of customFields.controls; track $index) {
<div [formGroupName]="$index" class="row g-2">
<div class="col-sm-5"><input class="form-control" formControlName="key" [placeholder]="ui.t('expenses.field.customKey')" /></div>
<div class="col-sm-5"><input class="form-control" formControlName="value" [placeholder]="ui.t('expenses.field.customValue')" /></div>
<div class="col-sm-2"><button class="btn btn-outline-danger w-100" type="button" (click)="removeCustomField($index)">{{ ui.t('action.delete') }}</button></div>
</div>
} @empty {
<div class="text-secondary small">{{ ui.t('expenses.noCustomFields') }}</div>
}
</div>
<label class="form-check"><input class="form-check-input" type="checkbox" formControlName="isActive" /><span class="form-check-label">{{ ui.t('common.active') }}</span></label>
<button class="btn btn-success" [disabled]="form.invalid">{{ ui.t('action.save') }}</button>
</form>
</div>
</div>
</div>
<div class="col-lg-7">
<div class="card overflow-hidden">
<div class="card-header"><h3 class="card-title">{{ ui.t('recurring.title') }}</h3></div>
<div class="table-responsive">
<table class="table table-vcenter card-table mb-0">
<thead><tr><th>{{ ui.t('table.title') }}</th><th>{{ ui.t('recurring.frequency') }}</th><th>{{ ui.t('recurring.nextRunDate') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th><th></th></tr></thead>
<tbody>
@for (item of items(); track item.id) {
<tr>
<td>
<div class="fw-semibold">{{ item.title }}</div>
<div class="text-secondary small">{{ item.category.name }} · {{ item.merchant || ui.t('expenses.noMerchant') }}</div>
<div class="text-secondary small">{{ ui.t('recurring.generatedCount') }}: {{ item.generatedCount }} · {{ ui.t('recurring.endDate') }}: {{ item.endDate || ui.t('common.none') }}</div>
<div class="mt-1 d-flex gap-1 flex-wrap">@for (tag of item.tags; track tag) { <span class="badge text-bg-secondary">#{{ tag }}</span> }</div>
</td>
<td>{{ ui.t('recurring.' + item.frequency.toLowerCase()) }}</td>
<td>{{ item.nextRunDate | date:'yyyy-MM-dd' }}</td>
<td class="text-end">{{ item.amount | currency:'PLN':'symbol':'1.2-2' }}</td>
<td class="text-end"><div class="btn-list justify-content-end flex-nowrap"><button class="btn btn-sm btn-outline-primary" type="button" (click)="edit(item)">{{ ui.t('action.edit') }}</button><button class="btn btn-sm btn-outline-danger" type="button" (click)="remove(item)">{{ ui.t('action.delete') }}</button></div></td>
</tr>
} @empty { <tr><td colspan="5" class="text-secondary">{{ ui.t('common.noData') }}</td></tr> }
</tbody>
</table>
</div>
</div>
</div>
</div>

View File

@@ -13,92 +13,7 @@ const today = () => new Date().toISOString().slice(0, 10);
selector: 'app-recurring', selector: 'app-recurring',
standalone: true, standalone: true,
imports: [CommonModule, ReactiveFormsModule, CurrencyPipe, DatePipe], imports: [CommonModule, ReactiveFormsModule, CurrencyPipe, DatePipe],
template: ` templateUrl: './recurring.component.html'
<div class="page-header d-print-none mb-3 ec-page-header">
<div class="row align-items-center g-3">
<div class="col">
<h2 class="page-title mb-1">{{ ui.t('nav.recurring') }}</h2>
<div class="text-secondary">{{ ui.t('recurring.subtitle') }}</div>
</div>
</div>
</div>
<div class="row row-cards">
<div class="col-lg-5">
<div class="card overflow-hidden">
<div class="card-header d-flex justify-content-between align-items-center">
<h3 class="card-title">{{ editingId() ? ui.t('recurring.edit') : ui.t('recurring.new') }}</h3>
<div class="btn-list">
@if (editingId()) { <button class="btn btn-outline-secondary btn-sm" type="button" (click)="cancelEdit()">{{ ui.t('action.cancelEdit') }}</button> }
<button class="btn btn-outline-primary btn-sm" type="button" (click)="runNow()">{{ ui.t('recurring.runNow') }}</button>
</div>
</div>
<div class="card-body">
<form [formGroup]="form" (ngSubmit)="save()" class="d-grid gap-3">
<div class="row g-3">
<div class="col-md-8"><label class="form-label">{{ ui.t('expenses.field.title') }}</label><input class="form-control" formControlName="title" /></div>
<div class="col-md-4"><label class="form-label">{{ ui.t('expenses.field.amount') }}</label><input class="form-control" type="number" step="0.01" formControlName="amount" /></div>
<div class="col-md-6"><label class="form-label">{{ ui.t('expenses.field.category') }}</label><select class="form-select" formControlName="categoryId"><option value="">{{ ui.t('common.select') }}</option>@for (category of categories(); track category.id) { <option [value]="category.id">{{ category.name }}</option> }</select></div>
<div class="col-md-6"><label class="form-label">{{ ui.t('expenses.field.status') }}</label><select class="form-select" formControlName="defaultStatus"><option value="DRAFT">{{ ui.t('status.draft') }}</option><option value="PENDING">{{ ui.t('status.pending') }}</option></select></div>
<div class="col-md-6"><label class="form-label">{{ ui.t('recurring.frequency') }}</label><select class="form-select" formControlName="frequency"><option value="WEEKLY">{{ ui.t('recurring.weekly') }}</option><option value="MONTHLY">{{ ui.t('recurring.monthly') }}</option><option value="YEARLY">{{ ui.t('recurring.yearly') }}</option></select></div>
<div class="col-md-6"><label class="form-label">{{ ui.t('recurring.interval') }}</label><input class="form-control" type="number" min="1" formControlName="intervalValue" /></div>
<div class="col-md-4"><label class="form-label">{{ ui.t('recurring.startDate') }}</label><input class="form-control" type="date" formControlName="startDate" /></div>
<div class="col-md-4"><label class="form-label">{{ ui.t('recurring.nextRunDate') }}</label><input class="form-control" type="date" formControlName="nextRunDate" /></div>
<div class="col-md-4"><label class="form-label">{{ ui.t('recurring.endDate') }}</label><input class="form-control" type="date" formControlName="endDate" /></div>
<div class="col-md-6"><label class="form-label">{{ ui.t('recurring.maxOccurrences') }}</label><input class="form-control" type="number" min="1" formControlName="maxOccurrences" /></div>
<div class="col-md-6"><label class="form-label">{{ ui.t('expenses.field.merchantName') }}</label><input class="form-control" formControlName="merchant" /></div>
<div class="col-md-6"><label class="form-label">{{ ui.t('expenses.field.tags') }}</label><input class="form-control" formControlName="tagsText" /></div>
<div class="col-12"><label class="form-label">{{ ui.t('expenses.field.description') }}</label><textarea class="form-control" rows="2" formControlName="description"></textarea></div>
</div>
<div formArrayName="customFields" class="d-grid gap-2">
<div class="d-flex justify-content-between align-items-center"><div class="form-label mb-0">{{ ui.t('expenses.field.customFields') }}</div><button class="btn btn-outline-secondary btn-sm" type="button" (click)="addCustomField()">{{ ui.t('action.add') }}</button></div>
@for (group of customFields.controls; track $index) {
<div [formGroupName]="$index" class="row g-2">
<div class="col-sm-5"><input class="form-control" formControlName="key" [placeholder]="ui.t('expenses.field.customKey')" /></div>
<div class="col-sm-5"><input class="form-control" formControlName="value" [placeholder]="ui.t('expenses.field.customValue')" /></div>
<div class="col-sm-2"><button class="btn btn-outline-danger w-100" type="button" (click)="removeCustomField($index)">{{ ui.t('action.delete') }}</button></div>
</div>
} @empty {
<div class="text-secondary small">{{ ui.t('expenses.noCustomFields') }}</div>
}
</div>
<label class="form-check"><input class="form-check-input" type="checkbox" formControlName="isActive" /><span class="form-check-label">{{ ui.t('common.active') }}</span></label>
<button class="btn btn-success" [disabled]="form.invalid">{{ ui.t('action.save') }}</button>
</form>
</div>
</div>
</div>
<div class="col-lg-7">
<div class="card overflow-hidden">
<div class="card-header"><h3 class="card-title">{{ ui.t('recurring.title') }}</h3></div>
<div class="table-responsive">
<table class="table table-vcenter card-table mb-0">
<thead><tr><th>{{ ui.t('table.title') }}</th><th>{{ ui.t('recurring.frequency') }}</th><th>{{ ui.t('recurring.nextRunDate') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th><th></th></tr></thead>
<tbody>
@for (item of items(); track item.id) {
<tr>
<td>
<div class="fw-semibold">{{ item.title }}</div>
<div class="text-secondary small">{{ item.category.name }} · {{ item.merchant || ui.t('expenses.noMerchant') }}</div>
<div class="text-secondary small">{{ ui.t('recurring.generatedCount') }}: {{ item.generatedCount }} · {{ ui.t('recurring.endDate') }}: {{ item.endDate || ui.t('common.none') }}</div>
<div class="mt-1 d-flex gap-1 flex-wrap">@for (tag of item.tags; track tag) { <span class="badge text-bg-secondary">#{{ tag }}</span> }</div>
</td>
<td>{{ ui.t('recurring.' + item.frequency.toLowerCase()) }}</td>
<td>{{ item.nextRunDate | date:'yyyy-MM-dd' }}</td>
<td class="text-end">{{ item.amount | currency:'PLN':'symbol':'1.2-2' }}</td>
<td class="text-end"><div class="btn-list justify-content-end flex-nowrap"><button class="btn btn-sm btn-outline-primary" type="button" (click)="edit(item)">{{ ui.t('action.edit') }}</button><button class="btn btn-sm btn-outline-danger" type="button" (click)="remove(item)">{{ ui.t('action.delete') }}</button></div></td>
</tr>
} @empty { <tr><td colspan="5" class="text-secondary">{{ ui.t('common.noData') }}</td></tr> }
</tbody>
</table>
</div>
</div>
</div>
</div>
`
}) })
export class RecurringComponent implements OnInit { export class RecurringComponent implements OnInit {
readonly ui = inject(UiService); readonly ui = inject(UiService);

View File

@@ -0,0 +1,51 @@
<div class="page-header d-print-none mb-3 ec-page-header">
<div class="row align-items-center g-3"><div class="col"><h2 class="page-title mb-1">{{ ui.t('reports.title') }}</h2><div class="text-secondary">{{ ui.t('reports.subtitle') }}</div></div></div>
</div>
<div class="row row-cards">
<div class="col-lg-5">
<div class="card pv-card overflow-visible mb-3">
<div class="card-header"><h3 class="card-title">{{ ui.t('reports.emailTitle') }}</h3></div>
<div class="card-body">
<form [formGroup]="form" (ngSubmit)="save()" class="d-grid gap-3">
<label class="form-check"><input class="form-check-input" type="checkbox" formControlName="enabled" /><span class="form-check-label">{{ ui.t('reports.enable') }}</span></label>
<div><label class="form-label">{{ ui.t('reports.frequency') }}</label><select class="form-select" formControlName="frequency"><option value="monthly">{{ ui.t('reports.frequency.monthly') }}</option><option value="yearly">{{ ui.t('reports.frequency.yearly') }}</option><option value="threshold">{{ ui.t('reports.frequency.threshold') }}</option></select></div>
<div><label class="form-label">{{ ui.t('reports.targetEmail') }}</label><input class="form-control" formControlName="sendToEmail" /></div>
<div><label class="form-label">{{ ui.t('reports.threshold') }}</label><input class="form-control" type="number" step="0.01" formControlName="thresholdAmount" /></div>
<div><label class="form-label">{{ ui.t('reports.categories') }}</label><app-category-picker [items]="categories()" [selectedIds]="form.getRawValue().categoryIds" [placeholder]="ui.t('expenses.allCategories')" (changed)="setCategoryIds($event)"></app-category-picker></div>
<div class="btn-list flex-wrap"><button class="btn btn-success" [disabled]="form.invalid" type="submit">{{ ui.t('action.save') }}</button><button class="btn btn-outline-info" type="button" (click)="preview()">{{ ui.t('action.refreshPreview') }}</button><button class="btn btn-warning" type="button" (click)="send()">{{ ui.t('action.sendNow') }}</button></div>
</form>
</div>
</div>
<div class="card pv-card overflow-hidden">
<div class="card-header"><h3 class="card-title">{{ ui.t('reports.exportTitle') }}</h3></div>
<div class="card-body">
<form [formGroup]="exportForm" class="row g-3 align-items-end">
<div class="col-md-6"><label class="form-label">{{ ui.t('stats.from') }}</label><input class="form-control" type="date" formControlName="startDate" /></div>
<div class="col-md-6"><label class="form-label">{{ ui.t('stats.to') }}</label><input class="form-control" type="date" formControlName="endDate" /></div>
<div class="col-md-6"><label class="form-label">{{ ui.t('expenses.field.status') }}</label><select class="form-select" formControlName="status"><option value="">{{ ui.t('common.none') }}</option><option value="DRAFT">{{ ui.t('status.draft') }}</option><option value="PENDING">{{ ui.t('status.pending') }}</option><option value="APPROVED">{{ ui.t('status.approved') }}</option><option value="REJECTED">{{ ui.t('status.rejected') }}</option></select></div>
<div class="col-md-6"><label class="form-label">{{ ui.t('expenses.field.tags') }}</label><input class="form-control" formControlName="tag" [placeholder]="ui.t('expenses.tagPlaceholder')" /></div>
<div class="col-12"><label class="form-label">{{ ui.t('reports.categories') }}</label><app-category-picker [items]="categories()" [selectedIds]="exportForm.getRawValue().categoryIds" [placeholder]="ui.t('expenses.allCategories')" (changed)="setExportCategoryIds($event)"></app-category-picker></div>
<div class="col-12"><div class="btn-list flex-wrap"><button class="btn btn-outline-primary" type="button" (click)="download('csv')">CSV</button><button class="btn btn-outline-primary" type="button" (click)="download('json')">JSON</button><button class="btn btn-outline-primary" type="button" (click)="download('html')">HTML</button><button class="btn btn-outline-primary" type="button" (click)="download('pdf')">PDF</button></div></div>
</form>
</div>
</div>
</div>
<div class="col-lg-7">
<div class="card pv-card overflow-hidden">
<div class="card-header"><h3 class="card-title">{{ ui.t('reports.preview') }}</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">{{ ui.t('stats.sum') }}</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">{{ ui.t('dashboard.count') }}</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">{{ ui.t('stats.average') }}</div><div class="h1">{{ summary()!.average.toFixed(2) }}</div></div></div></div>
</div>
}
<div class="card bg-body-tertiary overflow-hidden"><div class="card-body"><div [innerHTML]="html()"></div></div></div>
</div>
</div>
</div>
</div>

View File

@@ -12,59 +12,7 @@ import { CategoryPickerComponent } from '../../shared/ui/category-picker.compone
selector: 'app-reports', selector: 'app-reports',
standalone: true, standalone: true,
imports: [CommonModule, ReactiveFormsModule, CategoryPickerComponent], imports: [CommonModule, ReactiveFormsModule, CategoryPickerComponent],
template: ` templateUrl: './reports.component.html'
<div class="page-header d-print-none mb-3 ec-page-header">
<div class="row align-items-center g-3"><div class="col"><h2 class="page-title mb-1">{{ ui.t('reports.title') }}</h2><div class="text-secondary">{{ ui.t('reports.subtitle') }}</div></div></div>
</div>
<div class="row row-cards">
<div class="col-lg-5">
<div class="card pv-card overflow-visible mb-3">
<div class="card-header"><h3 class="card-title">{{ ui.t('reports.emailTitle') }}</h3></div>
<div class="card-body">
<form [formGroup]="form" (ngSubmit)="save()" class="d-grid gap-3">
<label class="form-check"><input class="form-check-input" type="checkbox" formControlName="enabled" /><span class="form-check-label">{{ ui.t('reports.enable') }}</span></label>
<div><label class="form-label">{{ ui.t('reports.frequency') }}</label><select class="form-select" formControlName="frequency"><option value="monthly">{{ ui.t('reports.frequency.monthly') }}</option><option value="yearly">{{ ui.t('reports.frequency.yearly') }}</option><option value="threshold">{{ ui.t('reports.frequency.threshold') }}</option></select></div>
<div><label class="form-label">{{ ui.t('reports.targetEmail') }}</label><input class="form-control" formControlName="sendToEmail" /></div>
<div><label class="form-label">{{ ui.t('reports.threshold') }}</label><input class="form-control" type="number" step="0.01" formControlName="thresholdAmount" /></div>
<div><label class="form-label">{{ ui.t('reports.categories') }}</label><app-category-picker [items]="categories()" [selectedIds]="form.getRawValue().categoryIds" [placeholder]="ui.t('expenses.allCategories')" (changed)="setCategoryIds($event)"></app-category-picker></div>
<div class="btn-list flex-wrap"><button class="btn btn-success" [disabled]="form.invalid" type="submit">{{ ui.t('action.save') }}</button><button class="btn btn-outline-info" type="button" (click)="preview()">{{ ui.t('action.refreshPreview') }}</button><button class="btn btn-warning" type="button" (click)="send()">{{ ui.t('action.sendNow') }}</button></div>
</form>
</div>
</div>
<div class="card pv-card overflow-hidden">
<div class="card-header"><h3 class="card-title">{{ ui.t('reports.exportTitle') }}</h3></div>
<div class="card-body">
<form [formGroup]="exportForm" class="row g-3 align-items-end">
<div class="col-md-6"><label class="form-label">{{ ui.t('stats.from') }}</label><input class="form-control" type="date" formControlName="startDate" /></div>
<div class="col-md-6"><label class="form-label">{{ ui.t('stats.to') }}</label><input class="form-control" type="date" formControlName="endDate" /></div>
<div class="col-md-6"><label class="form-label">{{ ui.t('expenses.field.status') }}</label><select class="form-select" formControlName="status"><option value="">{{ ui.t('common.none') }}</option><option value="DRAFT">{{ ui.t('status.draft') }}</option><option value="PENDING">{{ ui.t('status.pending') }}</option><option value="APPROVED">{{ ui.t('status.approved') }}</option><option value="REJECTED">{{ ui.t('status.rejected') }}</option></select></div>
<div class="col-md-6"><label class="form-label">{{ ui.t('expenses.field.tags') }}</label><input class="form-control" formControlName="tag" [placeholder]="ui.t('expenses.tagPlaceholder')" /></div>
<div class="col-12"><label class="form-label">{{ ui.t('reports.categories') }}</label><app-category-picker [items]="categories()" [selectedIds]="exportForm.getRawValue().categoryIds" [placeholder]="ui.t('expenses.allCategories')" (changed)="setExportCategoryIds($event)"></app-category-picker></div>
<div class="col-12"><div class="btn-list flex-wrap"><button class="btn btn-outline-primary" type="button" (click)="download('csv')">CSV</button><button class="btn btn-outline-primary" type="button" (click)="download('json')">JSON</button><button class="btn btn-outline-primary" type="button" (click)="download('html')">HTML</button><button class="btn btn-outline-primary" type="button" (click)="download('pdf')">PDF</button></div></div>
</form>
</div>
</div>
</div>
<div class="col-lg-7">
<div class="card pv-card overflow-hidden">
<div class="card-header"><h3 class="card-title">{{ ui.t('reports.preview') }}</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">{{ ui.t('stats.sum') }}</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">{{ ui.t('dashboard.count') }}</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">{{ ui.t('stats.average') }}</div><div class="h1">{{ summary()!.average.toFixed(2) }}</div></div></div></div>
</div>
}
<div class="card bg-body-tertiary overflow-hidden"><div class="card-body"><div [innerHTML]="html()"></div></div></div>
</div>
</div>
</div>
</div>
`
}) })
export class ReportsComponent implements OnInit { export class ReportsComponent implements OnInit {
readonly ui = inject(UiService); readonly ui = inject(UiService);

View File

@@ -0,0 +1,23 @@
<div class="page-header d-print-none mb-3 ec-page-header"><div class="row align-items-center g-3"><div class="col"><h2 class="page-title mb-1">{{ ui.t('stats.title') }}</h2><div class="text-secondary">{{ ui.t('stats.subtitle') }}</div></div></div></div>
<div class="row row-cards">
<div class="col-12"><div class="card overflow-visible"><div class="card-body"><form [formGroup]="form" (ngSubmit)="load()" class="row g-3 align-items-end">
<div class="col-md-2"><label class="form-label">{{ ui.t('stats.period') }}</label><select class="form-select" formControlName="bucket"><option value="month">{{ ui.t('stats.period.month') }}</option><option value="quarter">{{ ui.t('stats.period.quarter') }}</option><option value="year">{{ ui.t('stats.period.year') }}</option></select></div>
<div class="col-md-2"><label class="form-label">{{ ui.t('stats.from') }}</label><input class="form-control" type="date" formControlName="startDate" /></div>
<div class="col-md-2"><label class="form-label">{{ ui.t('stats.to') }}</label><input class="form-control" type="date" formControlName="endDate" /></div>
<div class="col-md-3"><label class="form-label">{{ ui.t('reports.categories') }}</label><app-category-picker [items]="categories()" [selectedIds]="form.getRawValue().categoryIds" [placeholder]="ui.t('expenses.allCategories')" (changed)="setCategoryIds($event)"></app-category-picker></div>
<div class="col-md-1"><label class="form-label">{{ ui.t('expenses.field.status') }}</label><select class="form-select" formControlName="status"><option value="">{{ ui.t('common.none') }}</option><option value="DRAFT">{{ ui.t('status.draft') }}</option><option value="PENDING">{{ ui.t('status.pending') }}</option><option value="APPROVED">{{ ui.t('status.approved') }}</option><option value="REJECTED">{{ ui.t('status.rejected') }}</option></select></div>
<div class="col-md-2"><label class="form-label">{{ ui.t('expenses.field.tags') }}</label><input class="form-control" formControlName="tag" [placeholder]="ui.t('expenses.tagPlaceholder')" /></div>
<div class="col-12 d-flex gap-2 flex-wrap"><button class="btn btn-success" type="submit">{{ ui.t('action.show') }}</button><button class="btn btn-outline-secondary" type="button" (click)="reset()">{{ ui.t('action.reset') }}</button></div>
</form></div></div></div>
<div class="col-md-4"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('stats.sum') }}</div><div class="display-6">{{ (stats()?.total || 0) | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
<div class="col-md-4"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('dashboard.count') }}</div><div class="display-6">{{ stats()?.count || 0 }}</div></div></div></div>
<div class="col-md-4"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('stats.average') }}</div><div class="display-6">{{ (stats()?.average || 0) | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
<div class="col-lg-6 d-flex align-items-stretch"><div class="card pv-card h-100 w-100 overflow-hidden"><div class="card-header"><h3 class="card-title">{{ ui.t('stats.share') }}</h3></div><div class="card-body">@if (hasCategoryData()) { <div class="ec-chart-wrap ec-chart-wrap-sm"><canvas id="statsCategoryChart"></canvas></div> } @else { <div class="alert alert-info mb-0">{{ ui.t('stats.noCategoryChart') }}</div> }</div></div></div>
<div class="col-lg-6 d-flex align-items-stretch"><div class="card pv-card h-100 w-100 overflow-hidden"><div class="card-header"><h3 class="card-title">{{ ui.t('stats.trend') }}</h3></div><div class="card-body">@if (hasTimelineData()) { <div class="ec-chart-wrap ec-chart-wrap-sm"><canvas id="statsLineChart"></canvas></div> } @else { <div class="alert alert-info mb-0">{{ ui.t('stats.noTrendChart') }}</div> }</div></div></div>
<div class="col-lg-6"><div class="card overflow-hidden"><div class="card-header"><h3 class="card-title">{{ ui.t('stats.breakdown') }}</h3></div><div class="table-responsive"><table class="table table-vcenter card-table mb-0"><thead><tr><th>{{ ui.t('table.category') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th><th class="text-end">{{ ui.t('table.count') }}</th></tr></thead><tbody>@for (row of stats()?.byCategory || []; track row.categoryId) { <tr><td>{{ row.categoryName }}</td><td class="text-end">{{ row.total | currency:'PLN':'symbol':'1.2-2' }}</td><td class="text-end">{{ row.count }}</td></tr> } @empty { <tr><td colspan="3" class="text-secondary">{{ ui.t('common.noData') }}</td></tr> }</tbody></table></div></div></div>
<div class="col-lg-6"><div class="card overflow-hidden"><div class="card-header"><h3 class="card-title">{{ ui.t('stats.tags') }}</h3></div><div class="table-responsive"><table class="table table-vcenter card-table mb-0"><thead><tr><th>{{ ui.t('expenses.field.tags') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th></tr></thead><tbody>@for (row of stats()?.byTag || []; track row.tag) { <tr><td>#{{ row.tag }}</td><td class="text-end">{{ row.total | currency:'PLN':'symbol':'1.2-2' }}</td></tr> } @empty { <tr><td colspan="2" class="text-secondary">{{ ui.t('common.noData') }}</td></tr> }</tbody></table></div></div></div>
</div>

View File

@@ -15,31 +15,7 @@ const chartPalette = ['#206bc4', '#2fb344', '#f59f00', '#d63939', '#9b4dca', '#4
selector: 'app-stats', selector: 'app-stats',
standalone: true, standalone: true,
imports: [CommonModule, ReactiveFormsModule, CurrencyPipe, CategoryPickerComponent], imports: [CommonModule, ReactiveFormsModule, CurrencyPipe, CategoryPickerComponent],
template: ` templateUrl: './stats.component.html'
<div class="page-header d-print-none mb-3 ec-page-header"><div class="row align-items-center g-3"><div class="col"><h2 class="page-title mb-1">{{ ui.t('stats.title') }}</h2><div class="text-secondary">{{ ui.t('stats.subtitle') }}</div></div></div></div>
<div class="row row-cards">
<div class="col-12"><div class="card overflow-visible"><div class="card-body"><form [formGroup]="form" (ngSubmit)="load()" class="row g-3 align-items-end">
<div class="col-md-2"><label class="form-label">{{ ui.t('stats.period') }}</label><select class="form-select" formControlName="bucket"><option value="month">{{ ui.t('stats.period.month') }}</option><option value="quarter">{{ ui.t('stats.period.quarter') }}</option><option value="year">{{ ui.t('stats.period.year') }}</option></select></div>
<div class="col-md-2"><label class="form-label">{{ ui.t('stats.from') }}</label><input class="form-control" type="date" formControlName="startDate" /></div>
<div class="col-md-2"><label class="form-label">{{ ui.t('stats.to') }}</label><input class="form-control" type="date" formControlName="endDate" /></div>
<div class="col-md-3"><label class="form-label">{{ ui.t('reports.categories') }}</label><app-category-picker [items]="categories()" [selectedIds]="form.getRawValue().categoryIds" [placeholder]="ui.t('expenses.allCategories')" (changed)="setCategoryIds($event)"></app-category-picker></div>
<div class="col-md-1"><label class="form-label">{{ ui.t('expenses.field.status') }}</label><select class="form-select" formControlName="status"><option value="">{{ ui.t('common.none') }}</option><option value="DRAFT">{{ ui.t('status.draft') }}</option><option value="PENDING">{{ ui.t('status.pending') }}</option><option value="APPROVED">{{ ui.t('status.approved') }}</option><option value="REJECTED">{{ ui.t('status.rejected') }}</option></select></div>
<div class="col-md-2"><label class="form-label">{{ ui.t('expenses.field.tags') }}</label><input class="form-control" formControlName="tag" [placeholder]="ui.t('expenses.tagPlaceholder')" /></div>
<div class="col-12 d-flex gap-2 flex-wrap"><button class="btn btn-success" type="submit">{{ ui.t('action.show') }}</button><button class="btn btn-outline-secondary" type="button" (click)="reset()">{{ ui.t('action.reset') }}</button></div>
</form></div></div></div>
<div class="col-md-4"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('stats.sum') }}</div><div class="display-6">{{ (stats()?.total || 0) | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
<div class="col-md-4"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('dashboard.count') }}</div><div class="display-6">{{ stats()?.count || 0 }}</div></div></div></div>
<div class="col-md-4"><div class="card pv-card overflow-hidden"><div class="card-body"><div class="text-secondary">{{ ui.t('stats.average') }}</div><div class="display-6">{{ (stats()?.average || 0) | currency:'PLN':'symbol':'1.2-2' }}</div></div></div></div>
<div class="col-lg-6 d-flex align-items-stretch"><div class="card pv-card h-100 w-100 overflow-hidden"><div class="card-header"><h3 class="card-title">{{ ui.t('stats.share') }}</h3></div><div class="card-body">@if (hasCategoryData()) { <div class="ec-chart-wrap ec-chart-wrap-sm"><canvas id="statsCategoryChart"></canvas></div> } @else { <div class="alert alert-info mb-0">{{ ui.t('stats.noCategoryChart') }}</div> }</div></div></div>
<div class="col-lg-6 d-flex align-items-stretch"><div class="card pv-card h-100 w-100 overflow-hidden"><div class="card-header"><h3 class="card-title">{{ ui.t('stats.trend') }}</h3></div><div class="card-body">@if (hasTimelineData()) { <div class="ec-chart-wrap ec-chart-wrap-sm"><canvas id="statsLineChart"></canvas></div> } @else { <div class="alert alert-info mb-0">{{ ui.t('stats.noTrendChart') }}</div> }</div></div></div>
<div class="col-lg-6"><div class="card overflow-hidden"><div class="card-header"><h3 class="card-title">{{ ui.t('stats.breakdown') }}</h3></div><div class="table-responsive"><table class="table table-vcenter card-table mb-0"><thead><tr><th>{{ ui.t('table.category') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th><th class="text-end">{{ ui.t('table.count') }}</th></tr></thead><tbody>@for (row of stats()?.byCategory || []; track row.categoryId) { <tr><td>{{ row.categoryName }}</td><td class="text-end">{{ row.total | currency:'PLN':'symbol':'1.2-2' }}</td><td class="text-end">{{ row.count }}</td></tr> } @empty { <tr><td colspan="3" class="text-secondary">{{ ui.t('common.noData') }}</td></tr> }</tbody></table></div></div></div>
<div class="col-lg-6"><div class="card overflow-hidden"><div class="card-header"><h3 class="card-title">{{ ui.t('stats.tags') }}</h3></div><div class="table-responsive"><table class="table table-vcenter card-table mb-0"><thead><tr><th>{{ ui.t('expenses.field.tags') }}</th><th class="text-end">{{ ui.t('table.amount') }}</th></tr></thead><tbody>@for (row of stats()?.byTag || []; track row.tag) { <tr><td>#{{ row.tag }}</td><td class="text-end">{{ row.total | currency:'PLN':'symbol':'1.2-2' }}</td></tr> } @empty { <tr><td colspan="2" class="text-secondary">{{ ui.t('common.noData') }}</td></tr> }</tbody></table></div></div></div>
</div>
`
}) })
export class StatsComponent implements OnInit, OnDestroy { export class StatsComponent implements OnInit, OnDestroy {
readonly ui = inject(UiService); readonly ui = inject(UiService);

View File

@@ -0,0 +1,73 @@
<div class="page">
<header class="navbar navbar-expand-md d-print-none pv-navbar">
<div class="container-xl d-flex align-items-center justify-content-between gap-3 flex-wrap">
<div class="d-flex align-items-center gap-3 flex-grow-1 min-w-0">
<button class="btn btn-icon btn-outline-secondary d-md-none" type="button" (click)="toggleMenu()" [attr.aria-label]="ui.t('nav.toggleMenu')">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16"/><path d="M4 12h16"/><path d="M4 18h16"/></svg>
</button>
<div class="navbar-brand navbar-brand-autodark fw-bold text-truncate">{{ appSettings.appName() }}</div>
</div>
<div class="ms-auto d-flex align-items-center gap-2 flex-wrap justify-content-end">
<div class="ec-toolbar-toggle d-inline-flex align-items-center">
<button class="btn btn-icon btn-ghost-secondary"
type="button"
[attr.aria-label]="ui.t('theme.label')"
[attr.title]="ui.t('theme.label')"
(click)="ui.toggleTheme()">
@if (ui.theme() === 'dark') {
<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 3l0 1"/><path d="M12 20l0 1"/><path d="M3 12l1 0"/><path d="M20 12l1 0"/><path d="M5.6 5.6l.7 .7"/><path d="M18.4 18.4l.7 .7"/><path d="M18.4 5.6l-.7 .7"/><path d="M5.6 18.4l-.7 .7"/><path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"/></svg>
} @else {
<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 3c.132 0 .263 0 .393 .007a9 9 0 1 0 0 17.986a9 9 0 0 1 -.393 -17.993z"/></svg>
}
</button>
<button class="btn btn-icon btn-ghost-secondary"
type="button"
[attr.aria-label]="currentLanguageLabel()"
[attr.title]="currentLanguageLabel()"
(click)="toggleLanguage()">
<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="M4 5h7"/><path d="M7 4c0 4.846 0 7 .5 8"/><path d="M10 8l-3 4l-3 -4"/><path d="M19 22l0 -3"/><path d="M17 19h4"/><path d="M20 19l-3 -7l-3 7"/><path d="M11 19l4 0"/></svg>
</button>
</div>
<div class="pv-navbar-user text-end me-1"><div class="fw-semibold text-truncate">{{ auth.currentUser()?.fullName }}</div><div class="small text-secondary text-truncate">{{ auth.currentUser()?.email }}</div></div>
<button class="btn btn-primary btn-sm" type="button" (click)="logout()" [attr.aria-label]="ui.t('action.logout')" [attr.title]="ui.t('action.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="M13 12v.01"/><path d="M3 21h18"/><path d="M5 21v-14a2 2 0 0 1 2 -2h5m4 0h1a2 2 0 0 1 2 2v14"/><path d="M14 7l3 -3l3 3"/></svg>
<span>{{ ui.t('action.logout') }}</span>
</button>
</div>
</div>
</header>
<div class="pv-subnav" [class.is-open]="menuOpen()">
<div class="container-xl">
<div class="pv-subnav-shell">
<div class="pv-subnav-main">
<div class="ec-nav-caption d-md-none">{{ ui.t('nav.navigation') }}</div>
<nav class="pv-subnav-tabs nav nav-pills gap-1">
@for (item of navItems(); track item.path) {
<a class="nav-link" [routerLink]="item.path" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: !!item.exact }" (click)="closeMenu()">{{ item.label }}</a>
}
</nav>
</div>
</div>
</div>
</div>
<div class="page-wrapper">
<div class="page-body">
<div class="container-xl"><router-outlet></router-outlet></div>
</div>
<footer class="ec-footer border-top">
<div class="container-xl">
<div class="ec-footer-shell">
<div class="d-flex align-items-center gap-2 flex-wrap">
<span class="badge" [class.bg-success]="apiStatus.state() === 'online'" [class.bg-danger]="apiStatus.state() === 'offline'">{{ apiStatus.state() === 'online' ? ui.t('footer.apiOnline') : ui.t('footer.apiOffline') }}</span>
</div>
<div class="d-flex align-items-center gap-3 flex-wrap justify-content-end text-end">
<a class="link-secondary fw-semibold" href="https://www.linuxiarz.pl" target="_blank" rel="noreferrer">Mateusz Gruszczyński | www.linuxiarz.pl</a>
</div>
</div>
</div>
</footer>
</div>
</div>

View File

@@ -12,81 +12,7 @@ import { UiService } from '../core/services/ui.service';
selector: 'app-shell', selector: 'app-shell',
standalone: true, standalone: true,
imports: [CommonModule, RouterOutlet, RouterLink, RouterLinkActive], imports: [CommonModule, RouterOutlet, RouterLink, RouterLinkActive],
template: ` templateUrl: './shell.component.html'
<div class="page">
<header class="navbar navbar-expand-md d-print-none pv-navbar">
<div class="container-xl d-flex align-items-center justify-content-between gap-3 flex-wrap">
<div class="d-flex align-items-center gap-3 flex-grow-1 min-w-0">
<button class="btn btn-icon btn-outline-secondary d-md-none" type="button" (click)="toggleMenu()" [attr.aria-label]="ui.t('nav.toggleMenu')">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16"/><path d="M4 12h16"/><path d="M4 18h16"/></svg>
</button>
<div class="navbar-brand navbar-brand-autodark fw-bold text-truncate">{{ appSettings.appName() }}</div>
</div>
<div class="ms-auto d-flex align-items-center gap-2 flex-wrap justify-content-end">
<div class="ec-toolbar-toggle d-inline-flex align-items-center">
<button class="btn btn-icon btn-ghost-secondary"
type="button"
[attr.aria-label]="ui.t('theme.label')"
[attr.title]="ui.t('theme.label')"
(click)="ui.toggleTheme()">
@if (ui.theme() === 'dark') {
<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 3l0 1"/><path d="M12 20l0 1"/><path d="M3 12l1 0"/><path d="M20 12l1 0"/><path d="M5.6 5.6l.7 .7"/><path d="M18.4 18.4l.7 .7"/><path d="M18.4 5.6l-.7 .7"/><path d="M5.6 18.4l-.7 .7"/><path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"/></svg>
} @else {
<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 3c.132 0 .263 0 .393 .007a9 9 0 1 0 0 17.986a9 9 0 0 1 -.393 -17.993z"/></svg>
}
</button>
<button class="btn btn-icon btn-ghost-secondary"
type="button"
[attr.aria-label]="currentLanguageLabel()"
[attr.title]="currentLanguageLabel()"
(click)="toggleLanguage()">
<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="M4 5h7"/><path d="M7 4c0 4.846 0 7 .5 8"/><path d="M10 8l-3 4l-3 -4"/><path d="M19 22l0 -3"/><path d="M17 19h4"/><path d="M20 19l-3 -7l-3 7"/><path d="M11 19l4 0"/></svg>
</button>
</div>
<div class="pv-navbar-user text-end me-1"><div class="fw-semibold text-truncate">{{ auth.currentUser()?.fullName }}</div><div class="small text-secondary text-truncate">{{ auth.currentUser()?.email }}</div></div>
<button class="btn btn-primary btn-sm" type="button" (click)="logout()" [attr.aria-label]="ui.t('action.logout')" [attr.title]="ui.t('action.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="M13 12v.01"/><path d="M3 21h18"/><path d="M5 21v-14a2 2 0 0 1 2 -2h5m4 0h1a2 2 0 0 1 2 2v14"/><path d="M14 7l3 -3l3 3"/></svg>
<span>{{ ui.t('action.logout') }}</span>
</button>
</div>
</div>
</header>
<div class="pv-subnav" [class.is-open]="menuOpen()">
<div class="container-xl">
<div class="pv-subnav-shell">
<div class="pv-subnav-main">
<div class="ec-nav-caption d-md-none">{{ ui.t('nav.navigation') }}</div>
<nav class="pv-subnav-tabs nav nav-pills gap-1">
@for (item of navItems(); track item.path) {
<a class="nav-link" [routerLink]="item.path" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: !!item.exact }" (click)="closeMenu()">{{ item.label }}</a>
}
</nav>
</div>
</div>
</div>
</div>
<div class="page-wrapper">
<div class="page-body">
<div class="container-xl"><router-outlet></router-outlet></div>
</div>
<footer class="ec-footer border-top">
<div class="container-xl">
<div class="ec-footer-shell">
<div class="d-flex align-items-center gap-2 flex-wrap">
<span class="badge" [class.bg-success]="apiStatus.state() === 'online'" [class.bg-danger]="apiStatus.state() === 'offline'">{{ apiStatus.state() === 'online' ? ui.t('footer.apiOnline') : ui.t('footer.apiOffline') }}</span>
</div>
<div class="d-flex align-items-center gap-3 flex-wrap justify-content-end text-end">
<a class="link-secondary fw-semibold" href="https://www.linuxiarz.pl" target="_blank" rel="noreferrer">Mateusz Gruszczyński | www.linuxiarz.pl</a>
</div>
</div>
</div>
</footer>
</div>
</div>
`
}) })
export class ShellComponent { export class ShellComponent {
readonly auth = inject(AuthService); readonly auth = inject(AuthService);

View File

@@ -0,0 +1,38 @@
<div class="dropdown w-100 position-relative ec-category-picker">
<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 ec-picker-badge d-inline-flex align-items-center gap-1">
<span class="badge rounded-pill ec-picker-dot" [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 ec-category-picker-menu">
<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 ec-picker-dot" [style.background]="item.color">&nbsp;</span>
<span>{{ item.name }}</span>
</span>
@if (isSelected(item.id)) {
<span class="badge text-bg-success">{{ ui.t('common.selected') }}</span>
}
</label>
} @empty {
<div class="dropdown-item text-secondary">{{ ui.t('common.noCategories') }}</div>
}
</div>
</div>
}
</div>

View File

@@ -7,46 +7,7 @@ import type { Category } from '../models';
selector: 'app-category-picker', selector: 'app-category-picker',
standalone: true, standalone: true,
imports: [CommonModule], imports: [CommonModule],
template: ` templateUrl: './category-picker.component.html'
<div class="dropdown w-100 position-relative ec-category-picker">
<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 ec-picker-badge d-inline-flex align-items-center gap-1">
<span class="badge rounded-pill ec-picker-dot" [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 ec-category-picker-menu">
<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 ec-picker-dot" [style.background]="item.color">&nbsp;</span>
<span>{{ item.name }}</span>
</span>
@if (isSelected(item.id)) {
<span class="badge text-bg-success">{{ ui.t('common.selected') }}</span>
}
</label>
} @empty {
<div class="dropdown-item text-secondary">{{ ui.t('common.noCategories') }}</div>
}
</div>
</div>
}
</div>
`
}) })
export class CategoryPickerComponent { export class CategoryPickerComponent {
readonly ui = inject(UiService); readonly ui = inject(UiService);

View File

@@ -0,0 +1,19 @@
<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>

View File

@@ -6,27 +6,7 @@ import { ToastItem, ToastService } from '../../core/services/toast.service';
selector: 'app-toast-outlet', selector: 'app-toast-outlet',
standalone: true, standalone: true,
imports: [CommonModule], imports: [CommonModule],
template: ` templateUrl: './toast-outlet.component.html'
<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 { export class ToastOutletComponent {
readonly toast = inject(ToastService); readonly toast = inject(ToastService);