feat(admin): redesign dashboard into professional SaaS homepage

Replaced the placeholder dashboard with a real business homepage built
on 6 new reusable widget components (DashboardSection, DashboardCard,
DashboardMetric, DashboardStatusRow, DashboardTimeline,
DashboardShortcutCard), all wired through shared app-card/app-skeleton/
app-empty-state.

Sections: Welcome (tenant, env badge, current user, last publish/save -
all real facade signals, never blank), Quick Actions (large cards: add
product, create category, open builder, edit homepage, media, orders),
Draft Status (real dirty/modifiedFields/publish/discard from
ProjectEditorFacade), Marketplace Health (9 checks - config, product/
category counts, missing translations, draft, static-pages-unpublished,
homepage/theme configured, all real; images-without-alt shown as
'not tracked yet' rather than fabricated), Recent Activity (existing
localStorage-backed history service, loading/empty states), Useful
Shortcuts, Documentation (honest coming-soon list, no dead links).

12-column responsive grid: 3-across cards on desktop, 2-column on
tablet, single column on mobile, no horizontal scroll. Keyboard/focus/
ARIA per shortcut card and status row; health status never conveyed by
color alone (icon + text every time).

AdminDashboardHealthCheck (boolean 'healthy' shape) and its facade
signal are untouched - AdminMonitoringPageComponent also consumes them.
New richer status data lives in a separate homeHealthChecks signal/
AdminDashboardHomeHealthCheck type instead of widening the shared one.

Added ~50 new dashboard.* / kept existing i18n strings across ru/en/hy;
no raw keys.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sdarbinyan
2026-07-18 08:59:29 +04:00
parent eb7b5c996d
commit 419cb9a32c
39 changed files with 1291 additions and 589 deletions

View File

@@ -1,39 +1,53 @@
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: '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'] },
{ id: 'users', labelKey: 'dashboard.actionUsers', route: ['backoffice', 'users'] },
{ id: 'monitoring', labelKey: 'dashboard.actionMonitoring', route: ['backoffice', 'monitoring'] },
{ id: 'analytics', labelKey: 'dashboard.actionAnalytics', route: ['backoffice', 'analytics'] },
{ id: 'preview-marketplace', labelKey: 'dashboard.actionPreviewMarketplace', route: [''] },
{ id: 'add-product', icon: 'pi-plus-circle', labelKey: 'dashboard.actionAddProduct', descriptionKey: 'dashboard.actionAddProductDescription', route: ['backoffice', 'products', 'create'] },
{ id: 'create-category', icon: 'pi-sitemap', labelKey: 'dashboard.actionCreateCategory', descriptionKey: 'dashboard.actionCreateCategoryDescription', route: ['backoffice', 'categories', 'create'] },
{ id: 'edit-project', icon: 'pi-th-large', labelKey: 'dashboard.actionEditProject', descriptionKey: 'dashboard.actionEditProjectDescription', route: ['edit', 'general'] },
{ id: 'edit-homepage', icon: 'pi-home', labelKey: 'dashboard.actionEditHomepage', descriptionKey: 'dashboard.actionEditHomepageDescription', route: ['edit', 'homepage'] },
{ id: 'media-library', icon: 'pi-images', labelKey: 'dashboard.actionMediaLibrary', descriptionKey: 'dashboard.actionMediaLibraryDescription', route: ['backoffice', 'media'] },
{ id: 'orders', icon: 'pi-shopping-cart', labelKey: 'dashboard.actionOrders', descriptionKey: 'dashboard.actionOrdersDescription', route: ['backoffice', 'orders'] },
];
const SHORTCUTS: AdminDashboardShortcut[] = [
{ id: 'products', icon: 'pi-box', labelKey: 'dashboard.actionProducts', route: ['backoffice', 'products'] },
{ id: 'categories', icon: 'pi-tags', labelKey: 'dashboard.actionCategories', route: ['backoffice', 'categories'] },
{ id: 'marketplace-builder', icon: 'pi-sitemap', labelKey: 'dashboard.shortcutMarketplaceBuilder', route: ['edit', 'general'] },
{ id: 'static-pages', icon: 'pi-file-edit', labelKey: 'dashboard.actionStaticPages', route: ['backoffice', 'static-pages'] },
{ id: 'orders', icon: 'pi-shopping-cart', labelKey: 'dashboard.actionOrders', route: ['backoffice', 'orders'] },
{ id: 'users', icon: 'pi-users', labelKey: 'dashboard.actionUsers', route: ['backoffice', 'users'] },
{ id: 'settings', icon: 'pi-cog', labelKey: 'dashboard.shortcutSettings', route: [], comingSoon: true },
{ id: 'media-library', icon: 'pi-images', labelKey: 'dashboard.actionMediaLibrary', route: ['backoffice', 'media'] },
{ id: 'content', icon: 'pi-align-left', labelKey: 'dashboard.shortcutContent', route: ['edit', 'static-pages'] },
];
/**
* 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.
* 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);
@@ -42,22 +56,53 @@ export class AdminDashboardFacade {
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));
@@ -71,6 +116,26 @@ export class AdminDashboardFacade {
];
});
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;
@@ -125,4 +190,12 @@ export class AdminDashboardFacade {
error: () => this.metricsState.set({ status: 'error', value: null }),
});
}
publishDraft(): void {
this.projectEditor.publish();
}
discardDraft(): void {
this.projectEditor.resetDraft();
}
}