Files
marketplaces/src/app/features/admin/dashboard/facade/admin-dashboard.facade.ts
sdarbinyan 48bcffa22c
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
feat: close stub-page gaps - profile login/logout, admin Reports/Settings, Help/Docs links
Sprint A: storefront header profile control (login/logout only, no menu),
wired to existing customer Telegram auth (AuthService).

Sprint B: backoffice/reports page, reuses AdminAnalyticsFacade (Sales,
Top Products, Marketplace Health cards + CSV export).

Sprint C: backoffice/settings page, admin UI density preference
(comfortable/compact), localStorage-persisted, applied to app-table
across all admin list pages.

Sprint D: admin bottom-nav Help -> mailto using existing supportEmail,
Documentation -> external link via new TenantConfig.documentationUrl.
AdminNavLink gains externalHref for non-routerLink nav entries.

Docs: docs/GLOBAL-SPRINT-PLAN.md tracks the full sprint breakdown.
docs/COMING-SOON-AUDIT.md removed, folded into docs/KNOWN-ISSUES.md.
docs/BACKEND.md updated with the new documentationUrl bootstrap field.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 17:48:50 +04:00

202 lines
11 KiB
TypeScript

import { Injectable, computed, effect, inject, signal } from '@angular/core';
import { take } from 'rxjs/operators';
import { ProjectEditorFacade } from '../../../project-editor/facade/project-editor.facade';
import { EditorSchemaService } from '../../../project-editor/schema/editor-schema.service';
import { AdminAuthService } from '../../../../core/admin-auth/admin-auth.service';
import { environment } from '../../../../../environments/environment';
import { ADMIN_DASHBOARD_METRICS_GATEWAY } from '../services/admin-dashboard-metrics-gateway.token';
import { AdminDashboardHistoryService } from '../services/admin-dashboard-history.service';
import {
AdminDashboardCardState,
AdminDashboardHealthCheck,
AdminDashboardHomeHealthCheck,
AdminDashboardMetrics,
AdminDashboardQuickAction,
AdminDashboardShortcut,
} from '../models/admin-dashboard.model';
const QUICK_ACTIONS: AdminDashboardQuickAction[] = [
{ id: 'add-product', icon: 'plusCircle', labelKey: 'dashboard.actionAddProduct', descriptionKey: 'dashboard.actionAddProductDescription', route: ['backoffice', 'products', 'create'] },
{ id: 'create-category', icon: 'network', labelKey: 'dashboard.actionCreateCategory', descriptionKey: 'dashboard.actionCreateCategoryDescription', route: ['backoffice', 'categories', 'create'] },
{ id: 'edit-project', icon: 'layoutGrid', labelKey: 'dashboard.actionEditProject', descriptionKey: 'dashboard.actionEditProjectDescription', route: ['edit'] },
{ id: 'edit-homepage', icon: 'home', labelKey: 'dashboard.actionEditHomepage', descriptionKey: 'dashboard.actionEditHomepageDescription', route: ['edit', 'homepage'] },
{ id: 'media-library', icon: 'images', labelKey: 'dashboard.actionMediaLibrary', descriptionKey: 'dashboard.actionMediaLibraryDescription', route: ['backoffice', 'media'] },
{ id: 'orders', icon: 'cart', labelKey: 'dashboard.actionOrders', descriptionKey: 'dashboard.actionOrdersDescription', route: ['backoffice', 'orders'] },
];
const SHORTCUTS: AdminDashboardShortcut[] = [
{ id: 'products', icon: 'package', labelKey: 'dashboard.actionProducts', route: ['backoffice', 'products'] },
{ id: 'categories', icon: 'tags', labelKey: 'dashboard.actionCategories', route: ['backoffice', 'categories'] },
{ id: 'marketplace-builder', icon: 'network', labelKey: 'dashboard.shortcutMarketplaceBuilder', route: ['edit'] },
{ id: 'static-pages', icon: 'edit', labelKey: 'dashboard.actionStaticPages', route: ['backoffice', 'static-pages'] },
{ id: 'orders', icon: 'cart', labelKey: 'dashboard.actionOrders', route: ['backoffice', 'orders'] },
{ id: 'users', icon: 'users', labelKey: 'dashboard.actionUsers', route: ['backoffice', 'users'] },
{ id: 'settings', icon: 'settings', labelKey: 'dashboard.shortcutSettings', route: ['backoffice', 'settings'] },
{ id: 'media-library', icon: 'images', labelKey: 'dashboard.actionMediaLibrary', route: ['backoffice', 'media'] },
{ id: 'content', icon: 'alignLeft', labelKey: 'dashboard.shortcutContent', route: ['edit', 'static-pages'] },
];
/**
* Composes ProjectEditorFacade (bootstrap/status/save-publish timestamps/validation/dirty
* state) with dashboard-only metrics/history/auth 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 schema = inject(EditorSchemaService);
private readonly adminAuth = inject(AdminAuthService);
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 dirty = this.projectEditor.dirty;
readonly lastSavedAt = this.projectEditor.lastSavedAt;
readonly lastPublishedAt = this.projectEditor.lastPublishedAt;
readonly validationIssues = this.projectEditor.validationIssues;
readonly homepageWidgets = this.projectEditor.homepageWidgets;
readonly modifiedFields = this.projectEditor.modifiedFields;
readonly metrics = this.metricsState.asReadonly();
private static readonly MAX_MODIFIED_FIELD_CHIPS = 6;
/** Human field labels (schema labelKey) for the fields the draft actually changed, capped so the widget stays scannable. */
readonly modifiedFieldLabelKeys = computed<string[]>(() => {
const keys = [...this.modifiedFields()];
const fields = this.schema.all();
return keys
.slice(0, AdminDashboardFacade.MAX_MODIFIED_FIELD_CHIPS)
.map(key => fields.find(field => field.key === key)?.labelKey ?? key);
});
readonly modifiedFieldsOverflowCount = computed(() =>
Math.max(0, this.modifiedFields().size - AdminDashboardFacade.MAX_MODIFIED_FIELD_CHIPS),
);
readonly currentUserName = this.adminAuth.displayName;
readonly isProductionEnvironment = environment.production;
readonly quickActions: AdminDashboardQuickAction[] = QUICK_ACTIONS;
readonly shortcuts: AdminDashboardShortcut[] = SHORTCUTS;
readonly enabledWidgetsCount = computed(() => this.homepageWidgets().length);
readonly staticPagesUnpublishedCount = computed(() => {
const staticPages = this.bootstrap()?.staticPages;
if (!staticPages) {
return 0;
}
const pages = Array.isArray(staticPages) ? staticPages : Object.values(staticPages);
return pages.filter(page => (page as { status?: string }).status !== 'published').length;
});
readonly activityEntries = computed(() => {
this.activityTick();
const tenantId = this.bootstrap()?.tenant.id;
return tenantId ? this.history.list(tenantId) : [];
});
/** Unchanged shape/logic - AdminMonitoringPageComponent also consumes this signal. */
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') },
];
});
readonly homeHealthChecks = computed<AdminDashboardHomeHealthCheck[]>(() => {
const current = this.bootstrap();
const bootstrapLoading = !current;
const issues = new Set(this.validationIssues().map(issue => issue.code));
const metrics = this.metrics();
const staticPagesUnpublished = this.staticPagesUnpublishedCount();
return [
{ code: 'configuration-valid', labelKey: 'dashboard.healthConfigurationValid', status: bootstrapLoading ? 'loading' : issues.size === 0 ? 'healthy' : 'unhealthy' },
{ code: 'products-count', labelKey: 'dashboard.healthProductsCount', status: metrics.status === 'loading' ? 'loading' : metrics.status === 'error' ? 'unhealthy' : 'healthy', displayValue: metrics.value ? String(metrics.value.productsCount) : null },
{ code: 'categories-count', labelKey: 'dashboard.healthCategoriesCount', status: metrics.status === 'loading' ? 'loading' : metrics.status === 'error' ? 'unhealthy' : 'healthy', displayValue: metrics.value ? String(metrics.value.categoriesCount) : null },
{ code: 'missing-translations', labelKey: 'dashboard.healthMissingTranslations', status: bootstrapLoading ? 'loading' : issues.has('missing-translations') ? 'unhealthy' : 'healthy' },
{ code: 'draft-exists', labelKey: 'dashboard.healthDraftExists', status: bootstrapLoading ? 'loading' : this.dirty() ? 'attention' : 'healthy' },
{ code: 'images-without-alt', labelKey: 'dashboard.healthImagesWithoutAlt', status: 'unknown' },
{ code: 'static-pages-unpublished', labelKey: 'dashboard.healthStaticPagesUnpublished', status: bootstrapLoading ? 'loading' : staticPagesUnpublished > 0 ? 'attention' : 'healthy', displayValue: bootstrapLoading ? null : String(staticPagesUnpublished) },
{ code: 'homepage-configured', labelKey: 'dashboard.healthHomepageConfigured', status: bootstrapLoading ? 'loading' : this.enabledWidgetsCount() > 0 ? 'healthy' : 'attention' },
{ code: 'theme-configured', labelKey: 'dashboard.healthThemeConfigured', status: bootstrapLoading ? 'loading' : current?.theme?.themeId ? 'healthy' : 'attention' },
];
});
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 }),
});
}
publishDraft(): void {
this.projectEditor.publish();
}
discardDraft(): void {
this.projectEditor.resetDraft();
}
}