Some checks failed
Architecture Governance / architecture (push) Has been cancelled
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>
202 lines
11 KiB
TypeScript
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();
|
|
}
|
|
}
|