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: [], comingSoon: true }, { 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>({ 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(() => { 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(() => { 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(() => { 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(); } }