300 lines
16 KiB
TypeScript
300 lines
16 KiB
TypeScript
import { CommonModule, DatePipe } from '@angular/common';
|
|
import { Component, OnInit, inject, signal } from '@angular/core';
|
|
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
|
import { AdminService } from '../../core/services/admin.service';
|
|
import { AppSettingsService } from '../../core/services/app-settings.service';
|
|
import { ToastService } from '../../core/services/toast.service';
|
|
import { UiService } from '../../core/services/ui.service';
|
|
import type { AdminSystemInfo, AppSettings, User } from '../../shared/models';
|
|
|
|
@Component({
|
|
selector: 'app-admin',
|
|
standalone: true,
|
|
imports: [CommonModule, ReactiveFormsModule, DatePipe],
|
|
template: `
|
|
<div class="page-header d-print-none mb-3 ec-page-header">
|
|
<div class="row align-items-center g-3">
|
|
<div class="col">
|
|
<h2 class="page-title mb-1">{{ 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="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.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>{{ user.createdAt | date:'short' }}</td>
|
|
<td>
|
|
<div class="btn-list flex-nowrap">
|
|
<button class="btn btn-outline-warning btn-sm" type="button" (click)="toggleRole(user)">
|
|
{{ user.role === 'ADMIN' ? 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>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
} @empty {
|
|
<tr><td colspan="5" class="text-secondary">{{ ui.t('admin.noUsers') }}</td></tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`
|
|
})
|
|
export class AdminComponent implements OnInit {
|
|
readonly ui = inject(UiService);
|
|
private readonly fb = inject(FormBuilder);
|
|
private readonly admin = inject(AdminService);
|
|
private readonly appSettings = inject(AppSettingsService);
|
|
private readonly toast = inject(ToastService);
|
|
|
|
readonly users = signal<User[]>([]);
|
|
readonly settings = signal<AppSettings | null>(null);
|
|
readonly systemInfo = signal<AdminSystemInfo | null>(null);
|
|
readonly saving = signal(false);
|
|
|
|
readonly form = this.fb.nonNullable.group({
|
|
appName: ['', [Validators.required, Validators.minLength(2)]],
|
|
defaultCurrency: ['PLN', Validators.required],
|
|
allowedProofTypes: ['RECEIPT,INVOICE,NOTE,BANK_STATEMENT,OTHER', Validators.required],
|
|
registrationEnabled: [true],
|
|
smtpEnabled: [false],
|
|
smtpHost: [''],
|
|
smtpPort: [587],
|
|
smtpSecure: [false],
|
|
smtpUser: [''],
|
|
smtpPassword: [''],
|
|
smtpFromName: [''],
|
|
smtpFromEmail: ['']
|
|
});
|
|
|
|
ngOnInit() { this.load(); }
|
|
|
|
load() {
|
|
this.admin.getSettings().subscribe({
|
|
next: (response) => {
|
|
this.settings.set(response.item);
|
|
this.appSettings.applySettings(response.item);
|
|
this.form.reset({
|
|
appName: response.item.appName,
|
|
defaultCurrency: response.item.defaultCurrency,
|
|
allowedProofTypes: response.item.allowedProofTypes.join(','),
|
|
registrationEnabled: response.item.registrationEnabled,
|
|
smtpEnabled: response.item.smtpEnabled,
|
|
smtpHost: response.item.smtpHost ?? '',
|
|
smtpPort: response.item.smtpPort,
|
|
smtpSecure: response.item.smtpSecure,
|
|
smtpUser: response.item.smtpUser ?? '',
|
|
smtpPassword: response.item.smtpPassword ?? '',
|
|
smtpFromName: response.item.smtpFromName ?? '',
|
|
smtpFromEmail: response.item.smtpFromEmail ?? ''
|
|
});
|
|
}
|
|
});
|
|
|
|
this.admin.listUsers().subscribe({ next: (response) => this.users.set(response.items) });
|
|
this.admin.getSystemInfo().subscribe({ next: (response) => this.systemInfo.set(response.item) });
|
|
}
|
|
|
|
save() {
|
|
if (this.form.invalid) return;
|
|
this.saving.set(true);
|
|
const raw = this.form.getRawValue();
|
|
this.admin.updateSettings({
|
|
appName: raw.appName,
|
|
defaultCurrency: raw.defaultCurrency,
|
|
registrationEnabled: raw.registrationEnabled,
|
|
allowedProofTypes: raw.allowedProofTypes.split(',').map((item) => item.trim()).filter(Boolean),
|
|
uiPreferences: { theme: 'dark', density: 'comfortable', defaultStatsPeriod: 'month' },
|
|
smtpEnabled: raw.smtpEnabled,
|
|
smtpHost: raw.smtpHost || null,
|
|
smtpPort: Number(raw.smtpPort),
|
|
smtpSecure: raw.smtpSecure,
|
|
smtpUser: raw.smtpUser || null,
|
|
smtpPassword: raw.smtpPassword || null,
|
|
smtpFromName: raw.smtpFromName || null,
|
|
smtpFromEmail: raw.smtpFromEmail || null
|
|
}).subscribe({
|
|
next: (response) => {
|
|
this.saving.set(false);
|
|
this.settings.set(response.item);
|
|
this.appSettings.applySettings(response.item);
|
|
this.toast.success(this.ui.t('admin.settingsSaved'));
|
|
this.admin.getSystemInfo().subscribe({ next: (systemResponse) => this.systemInfo.set(systemResponse.item) });
|
|
},
|
|
error: (error) => {
|
|
this.saving.set(false);
|
|
this.toast.error(error.error?.message ?? this.ui.t('admin.settingsError'));
|
|
}
|
|
});
|
|
}
|
|
|
|
sendTest() {
|
|
const to = this.form.getRawValue().smtpFromEmail;
|
|
if (!to) {
|
|
this.toast.error(this.ui.t('admin.missingFromEmail'));
|
|
return;
|
|
}
|
|
this.admin.testSmtp(to).subscribe({
|
|
next: () => this.toast.success(this.ui.t('admin.testSent')),
|
|
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('admin.testError'))
|
|
});
|
|
}
|
|
|
|
toggleRole(user: User) {
|
|
this.admin.updateUser(user.id, { role: user.role === 'ADMIN' ? 'USER' : 'ADMIN' }).subscribe({
|
|
next: (response) => {
|
|
this.users.update((items) => items.map((item) => (item.id === user.id ? response.item : item)));
|
|
this.toast.success(this.ui.t('admin.roleUpdated'));
|
|
},
|
|
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('admin.roleError'))
|
|
});
|
|
}
|
|
|
|
toggleActive(user: User) {
|
|
this.admin.updateUser(user.id, { isActive: !user.isActive }).subscribe({
|
|
next: (response) => {
|
|
this.users.update((items) => items.map((item) => (item.id === user.id ? response.item : item)));
|
|
this.toast.success(this.ui.t('admin.statusUpdated'));
|
|
},
|
|
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('admin.statusError'))
|
|
});
|
|
}
|
|
}
|