2026-07-14 12:21:33 +04:00
|
|
|
import { Injectable, computed, effect, inject, signal } from '@angular/core';
|
|
|
|
|
import { take } from 'rxjs/operators';
|
|
|
|
|
import { ProjectEditorFacade } from '../../../project-editor/facade/project-editor.facade';
|
|
|
|
|
import { ADMIN_DASHBOARD_METRICS_GATEWAY } from '../services/admin-dashboard-metrics-gateway.token';
|
|
|
|
|
import { AdminDashboardHistoryService } from '../services/admin-dashboard-history.service';
|
|
|
|
|
import {
|
|
|
|
|
AdminDashboardCardState,
|
|
|
|
|
AdminDashboardHealthCheck,
|
|
|
|
|
AdminDashboardMetrics,
|
|
|
|
|
AdminDashboardQuickAction,
|
|
|
|
|
} from '../models/admin-dashboard.model';
|
|
|
|
|
|
|
|
|
|
const QUICK_ACTIONS: AdminDashboardQuickAction[] = [
|
|
|
|
|
{ id: 'edit-project', labelKey: 'dashboard.actionEditProject', route: ['edit', 'general'] },
|
|
|
|
|
{ id: 'categories', labelKey: 'dashboard.actionCategories', route: ['backoffice', 'categories'] },
|
|
|
|
|
{ id: 'products', labelKey: 'dashboard.actionProducts', route: ['backoffice', 'products'] },
|
|
|
|
|
{ id: 'static-pages', labelKey: 'dashboard.actionStaticPages', route: ['backoffice', 'static-pages'] },
|
|
|
|
|
{ id: 'transactions', labelKey: 'dashboard.actionTransactions', route: ['backoffice', 'transactions'] },
|
|
|
|
|
{ id: 'orders', labelKey: 'dashboard.actionOrders', route: ['backoffice', 'orders'] },
|
|
|
|
|
{ id: 'media-library', labelKey: 'dashboard.actionMediaLibrary', route: ['backoffice', 'media'] },
|
feat(admin): users and permissions
Sprint 25.
New features/admin/users/ module, net-new /:lang/backoffice/users route +
Dashboard Quick Action.
- users: name, Telegram username, scope (marketplace vs office admin),
role (inline change), status (active/invited/suspended), last login
- 4 built-in roles (owner/admin/editor/viewer) with flat permission lists
- invitations: email + role + scope form, pending list + revoke (no email
actually sends - local record only)
- passwordless login confirmed already real (AdminAuthService Telegram QR,
docs/BACKEND.md item 1) - linked, not reimplemented
- per-user mock session list (device/IP/last-active, revoke) - flagged as
mock since the real AdminAuthService only ever tracks the current
browser's session
- per-user audit log dialog (role/status changes), same pattern as
Sprint 24's per-transaction audit, intentionally separate from the
system-wide log planned for Sprint 26
docs/ADMIN.md + docs/BACKEND.md (new item 14) updated.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 11:05:21 +04:00
|
|
|
{ id: 'users', labelKey: 'dashboard.actionUsers', route: ['backoffice', 'users'] },
|
2026-07-15 11:12:47 +04:00
|
|
|
{ id: 'monitoring', labelKey: 'dashboard.actionMonitoring', route: ['backoffice', 'monitoring'] },
|
2026-07-15 11:17:55 +04:00
|
|
|
{ id: 'analytics', labelKey: 'dashboard.actionAnalytics', route: ['backoffice', 'analytics'] },
|
2026-07-14 12:21:33 +04:00
|
|
|
{ id: 'preview-marketplace', labelKey: 'dashboard.actionPreviewMarketplace', route: [''] },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Composes ProjectEditorFacade (bootstrap/status/save-publish timestamps/validation)
|
|
|
|
|
* with dashboard-only metrics/history so the page component stays presentational.
|
|
|
|
|
* Cards read through here, never directly from ConfigService or localStorage -
|
|
|
|
|
* swapping local sources for real backend endpoints only touches this facade
|
|
|
|
|
* and the gateways it calls, per ADR-006/007.
|
|
|
|
|
*/
|
|
|
|
|
@Injectable({ providedIn: 'root' })
|
|
|
|
|
export class AdminDashboardFacade {
|
|
|
|
|
private readonly projectEditor = inject(ProjectEditorFacade);
|
|
|
|
|
private readonly metricsGateway = inject(ADMIN_DASHBOARD_METRICS_GATEWAY);
|
|
|
|
|
private readonly history = inject(AdminDashboardHistoryService);
|
|
|
|
|
|
|
|
|
|
private readonly metricsState = signal<AdminDashboardCardState<AdminDashboardMetrics>>({ status: 'loading', value: null });
|
|
|
|
|
private readonly activityTick = signal(0);
|
|
|
|
|
|
|
|
|
|
readonly bootstrap = this.projectEditor.bootstrap;
|
|
|
|
|
readonly status = this.projectEditor.status;
|
|
|
|
|
readonly lastSavedAt = this.projectEditor.lastSavedAt;
|
|
|
|
|
readonly lastPublishedAt = this.projectEditor.lastPublishedAt;
|
|
|
|
|
readonly validationIssues = this.projectEditor.validationIssues;
|
|
|
|
|
readonly homepageWidgets = this.projectEditor.homepageWidgets;
|
|
|
|
|
readonly metrics = this.metricsState.asReadonly();
|
|
|
|
|
|
|
|
|
|
readonly quickActions: AdminDashboardQuickAction[] = QUICK_ACTIONS;
|
|
|
|
|
|
|
|
|
|
readonly enabledWidgetsCount = computed(() => this.homepageWidgets().length);
|
|
|
|
|
|
|
|
|
|
readonly activityEntries = computed(() => {
|
|
|
|
|
this.activityTick();
|
|
|
|
|
const tenantId = this.bootstrap()?.tenant.id;
|
|
|
|
|
return tenantId ? this.history.list(tenantId) : [];
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
readonly healthChecks = computed<AdminDashboardHealthCheck[]>(() => {
|
|
|
|
|
const current = this.bootstrap();
|
|
|
|
|
const issues = new Set(this.validationIssues().map(issue => issue.code));
|
|
|
|
|
return [
|
|
|
|
|
{ code: 'bootstrap-valid', labelKey: 'dashboard.healthBootstrapValid', healthy: !!current?.schemaVersion },
|
|
|
|
|
{ code: 'configuration-valid', labelKey: 'dashboard.healthConfigurationValid', healthy: issues.size === 0 },
|
|
|
|
|
{ code: 'missing-translations', labelKey: 'dashboard.healthMissingTranslations', healthy: !issues.has('missing-translations') },
|
|
|
|
|
{ code: 'invalid-colors', labelKey: 'dashboard.healthInvalidColors', healthy: !issues.has('invalid-colors') },
|
|
|
|
|
{ code: 'invalid-widget-references', labelKey: 'dashboard.healthInvalidWidgetReferences', healthy: !issues.has('missing-widget') },
|
|
|
|
|
{ code: 'invalid-layouts', labelKey: 'dashboard.healthInvalidLayouts', healthy: !issues.has('invalid-layouts') },
|
|
|
|
|
];
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
private lastRecordedSavedAt: number | null = null;
|
|
|
|
|
private lastRecordedPublishedAt: number | null = null;
|
|
|
|
|
private historyPrimed = false;
|
|
|
|
|
|
|
|
|
|
constructor() {
|
|
|
|
|
effect(() => {
|
|
|
|
|
const tenantId = this.bootstrap()?.tenant.id;
|
|
|
|
|
const savedAt = this.lastSavedAt();
|
|
|
|
|
const publishedAt = this.lastPublishedAt();
|
|
|
|
|
if (!tenantId) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!this.historyPrimed) {
|
|
|
|
|
this.historyPrimed = true;
|
|
|
|
|
this.lastRecordedSavedAt = savedAt;
|
|
|
|
|
this.lastRecordedPublishedAt = publishedAt;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let recorded = false;
|
|
|
|
|
if (savedAt !== null && savedAt !== this.lastRecordedSavedAt) {
|
|
|
|
|
this.lastRecordedSavedAt = savedAt;
|
|
|
|
|
this.history.record(tenantId, 'draft-saved', savedAt);
|
|
|
|
|
recorded = true;
|
|
|
|
|
}
|
|
|
|
|
if (publishedAt !== null && publishedAt !== this.lastRecordedPublishedAt) {
|
|
|
|
|
this.lastRecordedPublishedAt = publishedAt;
|
|
|
|
|
this.history.record(tenantId, 'published', publishedAt);
|
|
|
|
|
recorded = true;
|
|
|
|
|
}
|
|
|
|
|
if (recorded) {
|
|
|
|
|
this.activityTick.update(tick => tick + 1);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ensureLoaded(): void {
|
|
|
|
|
if (!this.bootstrap()) {
|
|
|
|
|
this.projectEditor.loadBootstrap();
|
|
|
|
|
}
|
|
|
|
|
this.loadMetrics();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
loadMetrics(): void {
|
|
|
|
|
this.metricsState.set({ status: 'loading', value: null });
|
|
|
|
|
this.metricsGateway.loadMetrics().pipe(take(1)).subscribe({
|
|
|
|
|
next: metrics => this.metricsState.set({
|
|
|
|
|
status: metrics.categoriesCount === 0 && metrics.productsCount === 0 ? 'empty' : 'ready',
|
|
|
|
|
value: metrics,
|
|
|
|
|
}),
|
|
|
|
|
error: () => this.metricsState.set({ status: 'error', value: null }),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|