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:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user