feat(admin): sprint 19 admin dashboard, routing, i18n

- Add admin dashboard feature (models/gateway/facade/components/page)
- Wire admin/products routes and backoffice coming-soon placeholders
- Add lastPublishedAt to ProjectEditorFacade/state
- Add dashboard i18n keys (en/ru/hy) and docs/ADMIN.md

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sdarbinyan
2026-07-14 12:21:33 +04:00
parent 6aec2ebcb2
commit 325dc17911
36 changed files with 1386 additions and 5 deletions

View File

@@ -0,0 +1,12 @@
<section class="dashboard-activity">
<h2 class="dashboard-activity__title">{{ 'dashboard.activityTitle' | translate }}</h2>
<p class="dashboard-activity__empty" *ngIf="entries.length === 0">{{ 'dashboard.activityEmpty' | translate }}</p>
<ul class="dashboard-activity__list" *ngIf="entries.length > 0">
<li class="dashboard-activity__item" *ngFor="let entry of entries">
<span class="dashboard-activity__label">{{ entry.labelKey | translate }}</span>
<span class="dashboard-activity__time">{{ entry.timeText }}</span>
</li>
</ul>
</section>

View File

@@ -0,0 +1,42 @@
.dashboard-activity__title {
margin: 0 0 12px;
font-size: 16px;
font-weight: 700;
color: var(--text-primary, #1e3c38);
}
.dashboard-activity__empty {
margin: 0;
font-size: 14px;
color: var(--text-light, #828e8d);
}
.dashboard-activity__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.dashboard-activity__item {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--border-color, #d3dad9);
border-radius: var(--radius-sm, 8px);
background: var(--bg-secondary, #f5f5f5);
font-size: 14px;
}
.dashboard-activity__label {
color: var(--text-primary, #1e3c38);
font-weight: 600;
}
.dashboard-activity__time {
color: var(--text-light, #828e8d);
white-space: nowrap;
}

View File

@@ -0,0 +1,21 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TranslatePipe } from '../../../../i18n/translate.pipe';
export interface AdminDashboardActivityViewEntry {
id: string;
labelKey: string;
timeText: string;
}
@Component({
selector: 'app-admin-dashboard-activity',
standalone: true,
imports: [CommonModule, TranslatePipe],
templateUrl: './admin-dashboard-activity.component.html',
styleUrls: ['./admin-dashboard-activity.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AdminDashboardActivityComponent {
@Input() entries: AdminDashboardActivityViewEntry[] = [];
}

View File

@@ -0,0 +1,23 @@
<article class="dashboard-card" [class.dashboard-card--error]="status === 'error'" [class.dashboard-card--pending]="status === 'pending-backend'">
<header class="dashboard-card__header">
<span class="dashboard-card__icon" *ngIf="icon" aria-hidden="true">{{ icon }}</span>
<h3 class="dashboard-card__title">{{ title }}</h3>
</header>
<div class="dashboard-card__body">
<ng-container [ngSwitch]="status">
<div class="dashboard-card__skeleton" *ngSwitchCase="'loading'"></div>
<p class="dashboard-card__muted" *ngSwitchCase="'empty'">{{ 'dashboard.stateEmpty' | translate }}</p>
<p class="dashboard-card__muted dashboard-card__muted--error" *ngSwitchCase="'error'">{{ 'dashboard.stateError' | translate }}</p>
<p class="dashboard-card__muted" *ngSwitchCase="'pending-backend'">{{ 'dashboard.statePendingBackend' | translate }}</p>
<ng-container *ngSwitchDefault>
<p class="dashboard-card__value">{{ value }}</p>
<p class="dashboard-card__subtitle" *ngIf="subtitle">{{ subtitle }}</p>
</ng-container>
</ng-container>
</div>
</article>

View File

@@ -0,0 +1,83 @@
.dashboard-card {
display: flex;
flex-direction: column;
gap: 10px;
padding: 18px;
border: 1px solid var(--border-color, #d3dad9);
border-radius: var(--radius-md, 12px);
background: var(--bg-primary, #fff);
box-shadow: var(--shadow-sm, 0 2px 8px rgba(0, 0, 0, 0.06));
min-height: 108px;
}
.dashboard-card--error {
border-color: var(--error-color, #ef4444);
}
.dashboard-card--pending {
opacity: 0.75;
}
.dashboard-card__header {
display: flex;
align-items: center;
gap: 8px;
}
.dashboard-card__icon {
font-size: 18px;
line-height: 1;
}
.dashboard-card__title {
margin: 0;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary, #667a77);
text-transform: uppercase;
letter-spacing: 0.02em;
}
.dashboard-card__body {
display: flex;
flex-direction: column;
gap: 4px;
}
.dashboard-card__value {
margin: 0;
font-size: 24px;
font-weight: 700;
color: var(--text-primary, #1e3c38);
word-break: break-word;
}
.dashboard-card__subtitle {
margin: 0;
font-size: 13px;
color: var(--text-light, #828e8d);
}
.dashboard-card__muted {
margin: 0;
font-size: 14px;
color: var(--text-light, #828e8d);
}
.dashboard-card__muted--error {
color: var(--error-color, #ef4444);
}
.dashboard-card__skeleton {
height: 24px;
width: 60%;
border-radius: var(--radius-sm, 8px);
background: linear-gradient(90deg, var(--bg-secondary, #f5f5f5) 25%, var(--bg-tertiary, #f0f0f0) 37%, var(--bg-secondary, #f5f5f5) 63%);
background-size: 400% 100%;
animation: dashboard-card-shimmer 1.4s ease infinite;
}
@keyframes dashboard-card-shimmer {
0% { background-position: 100% 50%; }
100% { background-position: 0 50%; }
}

View File

@@ -0,0 +1,20 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TranslatePipe } from '../../../../i18n/translate.pipe';
import { AdminDashboardCardStatus } from '../models/admin-dashboard.model';
@Component({
selector: 'app-admin-dashboard-card',
standalone: true,
imports: [CommonModule, TranslatePipe],
templateUrl: './admin-dashboard-card.component.html',
styleUrls: ['./admin-dashboard-card.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AdminDashboardCardComponent {
@Input() title = '';
@Input() status: AdminDashboardCardStatus = 'ready';
@Input() value: string | null = null;
@Input() subtitle: string | null = null;
@Input() icon: string | null = null;
}

View File

@@ -0,0 +1,12 @@
<section class="dashboard-health" [class.dashboard-health--warning]="hasIssues">
<h2 class="dashboard-health__title">{{ 'dashboard.healthTitle' | translate }}</h2>
<p class="dashboard-health__all-clear" *ngIf="!hasIssues">{{ 'dashboard.healthAllClear' | translate }}</p>
<ul class="dashboard-health__list">
<li class="dashboard-health__item" *ngFor="let check of checks" [class.dashboard-health__item--warning]="!check.healthy">
<span class="dashboard-health__dot" [class.dashboard-health__dot--warning]="!check.healthy" aria-hidden="true"></span>
<span>{{ check.labelKey | translate }}</span>
</li>
</ul>
</section>

View File

@@ -0,0 +1,51 @@
.dashboard-health__title {
margin: 0 0 12px;
font-size: 16px;
font-weight: 700;
color: var(--text-primary, #1e3c38);
}
.dashboard-health__all-clear {
margin: 0 0 8px;
font-size: 14px;
color: var(--success-color, #10b981);
font-weight: 600;
}
.dashboard-health__list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px 16px;
}
.dashboard-health__item {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--text-primary, #1e3c38);
}
.dashboard-health__item--warning {
color: var(--warning-color, #f59e0b);
font-weight: 600;
}
.dashboard-health__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--success-color, #10b981);
flex: none;
}
.dashboard-health__dot--warning {
background: var(--warning-color, #f59e0b);
}
@media (max-width: 640px) {
.dashboard-health__list { grid-template-columns: 1fr; }
}

View File

@@ -0,0 +1,20 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TranslatePipe } from '../../../../i18n/translate.pipe';
import { AdminDashboardHealthCheck } from '../models/admin-dashboard.model';
@Component({
selector: 'app-admin-dashboard-health',
standalone: true,
imports: [CommonModule, TranslatePipe],
templateUrl: './admin-dashboard-health.component.html',
styleUrls: ['./admin-dashboard-health.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AdminDashboardHealthComponent {
@Input() checks: AdminDashboardHealthCheck[] = [];
get hasIssues(): boolean {
return this.checks.some(check => !check.healthy);
}
}

View File

@@ -0,0 +1,10 @@
<section class="dashboard-quick-actions">
<h2 class="dashboard-quick-actions__title">{{ 'dashboard.quickActionsTitle' | translate }}</h2>
<div class="dashboard-quick-actions__grid">
<a
*ngFor="let action of actions"
class="dashboard-quick-actions__item"
[routerLink]="action.route"
>{{ action.labelKey | translate }}</a>
</div>
</section>

View File

@@ -0,0 +1,43 @@
.dashboard-quick-actions__title {
margin: 0 0 12px;
font-size: 16px;
font-weight: 700;
color: var(--text-primary, #1e3c38);
}
.dashboard-quick-actions__grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.dashboard-quick-actions__item {
display: flex;
align-items: center;
justify-content: center;
text-align: center;
min-height: 56px;
padding: 10px 12px;
border: 1px solid var(--border-color, #d3dad9);
border-radius: var(--radius-md, 12px);
background: var(--bg-primary, #fff);
color: var(--text-primary, #1e3c38);
font-weight: 600;
font-size: 14px;
text-decoration: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.dashboard-quick-actions__item:hover,
.dashboard-quick-actions__item:focus-visible {
border-color: var(--primary-color, #497671);
box-shadow: var(--shadow-sm, 0 2px 8px rgba(0, 0, 0, 0.1));
}
@media (max-width: 960px) {
.dashboard-quick-actions__grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 560px) {
.dashboard-quick-actions__grid { grid-template-columns: 1fr; }
}

View File

@@ -0,0 +1,17 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { TranslatePipe } from '../../../../i18n/translate.pipe';
import { AdminDashboardQuickAction } from '../models/admin-dashboard.model';
@Component({
selector: 'app-admin-dashboard-quick-actions',
standalone: true,
imports: [CommonModule, RouterLink, TranslatePipe],
templateUrl: './admin-dashboard-quick-actions.component.html',
styleUrls: ['./admin-dashboard-quick-actions.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AdminDashboardQuickActionsComponent {
@Input() actions: AdminDashboardQuickAction[] = [];
}

View File

@@ -0,0 +1,125 @@
import { Injectable, computed, effect, inject, signal } from '@angular/core';
import { take } from 'rxjs/operators';
import { ProjectEditorFacade } from '../../../project-editor/facade/project-editor.facade';
import { ADMIN_DASHBOARD_METRICS_GATEWAY } from '../services/admin-dashboard-metrics-gateway.token';
import { AdminDashboardHistoryService } from '../services/admin-dashboard-history.service';
import {
AdminDashboardCardState,
AdminDashboardHealthCheck,
AdminDashboardMetrics,
AdminDashboardQuickAction,
} 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: 'preview-marketplace', labelKey: 'dashboard.actionPreviewMarketplace', route: [''] },
];
/**
* 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.
*/
@Injectable({ providedIn: 'root' })
export class AdminDashboardFacade {
private readonly projectEditor = inject(ProjectEditorFacade);
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 lastSavedAt = this.projectEditor.lastSavedAt;
readonly lastPublishedAt = this.projectEditor.lastPublishedAt;
readonly validationIssues = this.projectEditor.validationIssues;
readonly homepageWidgets = this.projectEditor.homepageWidgets;
readonly metrics = this.metricsState.asReadonly();
readonly quickActions: AdminDashboardQuickAction[] = QUICK_ACTIONS;
readonly enabledWidgetsCount = computed(() => this.homepageWidgets().length);
readonly activityEntries = computed(() => {
this.activityTick();
const tenantId = this.bootstrap()?.tenant.id;
return tenantId ? this.history.list(tenantId) : [];
});
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') },
];
});
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 }),
});
}
}

View File

@@ -0,0 +1,42 @@
export type AdminDashboardCardStatus = 'loading' | 'ready' | 'empty' | 'error' | 'pending-backend';
export interface AdminDashboardCardState<T> {
status: AdminDashboardCardStatus;
value: T | null;
}
export interface AdminDashboardMetrics {
categoriesCount: number;
productsCount: number;
}
export type AdminDashboardQuickActionId =
| 'edit-project'
| 'categories'
| 'products'
| 'static-pages'
| 'transactions'
| 'orders'
| 'media-library'
| 'preview-marketplace';
export interface AdminDashboardQuickAction {
id: AdminDashboardQuickActionId;
labelKey: string;
route: string[];
external?: boolean;
}
export type AdminDashboardActivityType = 'draft-saved' | 'published';
export interface AdminDashboardActivityEntry {
id: string;
type: AdminDashboardActivityType;
timestamp: number;
}
export interface AdminDashboardHealthCheck {
code: string;
labelKey: string;
healthy: boolean;
}

View File

@@ -0,0 +1,23 @@
<div class="dashboard-page">
<header class="dashboard-page__header">
<h1 class="dashboard-page__title">{{ 'dashboard.title' | translate }}</h1>
<p class="dashboard-page__subtitle">{{ 'dashboard.subtitle' | translate }}</p>
</header>
<section class="dashboard-page__cards">
<app-admin-dashboard-card
*ngFor="let card of cards()"
[title]="cardTitle(card.titleKey)"
[status]="card.status"
[value]="card.value"
[subtitle]="card.subtitle"
/>
</section>
<app-admin-dashboard-quick-actions class="dashboard-page__section" [actions]="quickActions()" />
<div class="dashboard-page__row">
<app-admin-dashboard-activity class="dashboard-page__section" [entries]="activityEntries()" />
<app-admin-dashboard-health class="dashboard-page__section" [checks]="healthChecks()" />
</div>
</div>

View File

@@ -0,0 +1,64 @@
.dashboard-page {
display: flex;
flex-direction: column;
gap: 24px;
padding: 20px;
max-width: 1280px;
margin: 0 auto;
}
.dashboard-page__header {
display: flex;
flex-direction: column;
gap: 4px;
}
.dashboard-page__title {
margin: 0;
font-size: 26px;
font-weight: 800;
color: var(--text-primary, #1e3c38);
}
.dashboard-page__subtitle {
margin: 0;
font-size: 14px;
color: var(--text-secondary, #667a77);
}
.dashboard-page__cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
.dashboard-page__section {
display: block;
padding: 18px;
border: 1px solid var(--border-color, #d3dad9);
border-radius: var(--radius-md, 12px);
background: var(--bg-primary, #fff);
}
.dashboard-page__row {
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: 16px;
}
@media (max-width: 1100px) {
.dashboard-page__cards { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 900px) {
.dashboard-page__row { grid-template-columns: 1fr; }
}
@media (max-width: 720px) {
.dashboard-page__cards { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 480px) {
.dashboard-page { padding: 12px; gap: 16px; }
.dashboard-page__cards { grid-template-columns: 1fr; }
}

View File

@@ -0,0 +1,171 @@
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TranslateService } from '../../../../i18n/translate.service';
import { TranslatePipe } from '../../../../i18n/translate.pipe';
import { LanguageService } from '../../../../services/language.service';
import { AdminDashboardFacade } from '../facade/admin-dashboard.facade';
import { AdminDashboardCardComponent } from '../components/admin-dashboard-card.component';
import { AdminDashboardQuickActionsComponent } from '../components/admin-dashboard-quick-actions.component';
import { AdminDashboardActivityComponent, AdminDashboardActivityViewEntry } from '../components/admin-dashboard-activity.component';
import { AdminDashboardHealthComponent } from '../components/admin-dashboard-health.component';
import { AdminDashboardCardStatus, AdminDashboardQuickAction } from '../models/admin-dashboard.model';
interface DashboardCardViewModel {
id: string;
titleKey: string;
status: AdminDashboardCardStatus;
value: string | null;
subtitle: string | null;
}
@Component({
selector: 'app-admin-dashboard-page',
standalone: true,
imports: [
CommonModule,
TranslatePipe,
AdminDashboardCardComponent,
AdminDashboardQuickActionsComponent,
AdminDashboardActivityComponent,
AdminDashboardHealthComponent,
],
templateUrl: './admin-dashboard-page.component.html',
styleUrls: ['./admin-dashboard-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AdminDashboardPageComponent {
readonly facade = inject(AdminDashboardFacade);
private readonly translate = inject(TranslateService);
private readonly languageService = inject(LanguageService);
readonly quickActions = computed<AdminDashboardQuickAction[]>(() => {
const lang = this.languageService.currentLanguage();
return this.facade.quickActions.map(action => ({
...action,
route: ['/', lang, ...action.route.filter(segment => segment !== '')],
}));
});
readonly activityEntries = computed<AdminDashboardActivityViewEntry[]>(() =>
this.facade.activityEntries().map(entry => ({
id: entry.id,
labelKey: entry.type === 'published' ? 'dashboard.activityPublished' : 'dashboard.activityDraftSaved',
timeText: new Date(entry.timestamp).toLocaleString(),
})),
);
readonly healthChecks = this.facade.healthChecks;
readonly cards = computed<DashboardCardViewModel[]>(() => {
const bootstrap = this.facade.bootstrap();
const bootstrapLoading: AdminDashboardCardStatus = bootstrap ? 'ready' : 'loading';
const metrics = this.facade.metrics();
const locales = bootstrap?.localization.supportedLocales ?? [];
return [
{
id: 'marketplace-status',
titleKey: 'dashboard.cardMarketplaceStatus',
status: bootstrapLoading,
value: bootstrap ? this.translate.t(this.facade.status() === 'published' ? 'builder.statusPublished' : 'builder.statusDraft') : null,
subtitle: null,
},
{
id: 'project-name',
titleKey: 'dashboard.cardProjectName',
status: bootstrapLoading,
value: bootstrap?.tenant.name ?? null,
subtitle: null,
},
{
id: 'current-theme',
titleKey: 'dashboard.cardCurrentTheme',
status: bootstrapLoading,
value: bootstrap?.theme.themeId ?? null,
subtitle: null,
},
{
id: 'languages',
titleKey: 'dashboard.cardLanguages',
status: bootstrapLoading === 'loading' ? 'loading' : (locales.length === 0 ? 'empty' : 'ready'),
value: locales.length ? locales.join(', ').toUpperCase() : null,
subtitle: null,
},
{
id: 'categories-count',
titleKey: 'dashboard.cardCategoriesCount',
status: metrics.status,
value: metrics.value ? String(metrics.value.categoriesCount) : null,
subtitle: null,
},
{
id: 'products-count',
titleKey: 'dashboard.cardProductsCount',
status: metrics.status,
value: metrics.value ? String(metrics.value.productsCount) : null,
subtitle: null,
},
{
id: 'orders',
titleKey: 'dashboard.cardOrders',
status: 'pending-backend',
value: null,
subtitle: null,
},
{
id: 'revenue',
titleKey: 'dashboard.cardRevenue',
status: 'pending-backend',
value: null,
subtitle: null,
},
{
id: 'last-publish',
titleKey: 'dashboard.cardLastPublish',
status: bootstrapLoading,
value: bootstrap ? this.formatTimestamp(this.facade.lastPublishedAt()) : null,
subtitle: null,
},
{
id: 'last-draft-save',
titleKey: 'dashboard.cardLastDraftSave',
status: bootstrapLoading,
value: bootstrap ? this.formatTimestamp(this.facade.lastSavedAt()) : null,
subtitle: null,
},
{
id: 'bootstrap-version',
titleKey: 'dashboard.cardBootstrapVersion',
status: bootstrapLoading,
value: bootstrap?.schemaVersion ?? null,
subtitle: null,
},
{
id: 'active-layout',
titleKey: 'dashboard.cardActiveLayout',
status: bootstrapLoading,
value: bootstrap ? (bootstrap.layout?.type ?? 'default') : null,
subtitle: null,
},
{
id: 'enabled-widgets',
titleKey: 'dashboard.cardEnabledWidgets',
status: bootstrapLoading,
value: bootstrap ? String(this.facade.enabledWidgetsCount()) : null,
subtitle: null,
},
];
});
constructor() {
this.facade.ensureLoaded();
}
cardTitle(titleKey: string): string {
return this.translate.t(titleKey);
}
private formatTimestamp(timestamp: number | null): string {
return timestamp ? new Date(timestamp).toLocaleString() : this.translate.t('dashboard.never');
}
}

View File

@@ -0,0 +1,45 @@
import { Injectable } from '@angular/core';
import { AdminDashboardActivityEntry, AdminDashboardActivityType } from '../models/admin-dashboard.model';
const HISTORY_STORAGE_KEY = 'adminDashboard.activityHistory.v1';
const MAX_ENTRIES = 20;
interface StoredHistory {
tenantId: string;
entries: AdminDashboardActivityEntry[];
}
/** Local publish/save activity log, scoped per tenant. Same localStorage pattern as ProjectEditorDraftStorageService - a real backend can later replace the read side without changing callers. */
@Injectable({ providedIn: 'root' })
export class AdminDashboardHistoryService {
record(tenantId: string, type: AdminDashboardActivityType, timestamp: number): void {
const entries = [
{ id: `${type}-${timestamp}`, type, timestamp },
...this.list(tenantId),
].slice(0, MAX_ENTRIES);
this.persist(tenantId, entries);
}
list(tenantId: string): AdminDashboardActivityEntry[] {
try {
const raw = localStorage.getItem(HISTORY_STORAGE_KEY);
if (!raw) {
return [];
}
const parsed = JSON.parse(raw) as StoredHistory;
return parsed.tenantId === tenantId ? parsed.entries : [];
} catch {
return [];
}
}
private persist(tenantId: string, entries: AdminDashboardActivityEntry[]): void {
try {
const payload: StoredHistory = { tenantId, entries };
localStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(payload));
} catch {
// storage unavailable (private mode / quota) - history simply won't persist
}
}
}

View File

@@ -0,0 +1,9 @@
import { InjectionToken, inject } from '@angular/core';
import { AdminDashboardMetricsGateway } from './admin-dashboard-metrics.gateway.interface';
import { AdminDashboardMetricsLocalGateway } from './admin-dashboard-metrics.local.gateway';
/** Swap point for a future dedicated dashboard-metrics backend endpoint - today it composes existing backoffice data sources. */
export const ADMIN_DASHBOARD_METRICS_GATEWAY = new InjectionToken<AdminDashboardMetricsGateway>('ADMIN_DASHBOARD_METRICS_GATEWAY', {
providedIn: 'root',
factory: () => inject(AdminDashboardMetricsLocalGateway),
});

View File

@@ -0,0 +1,6 @@
import { Observable } from 'rxjs';
import { AdminDashboardMetrics } from '../models/admin-dashboard.model';
export interface AdminDashboardMetricsGateway {
loadMetrics(): Observable<AdminDashboardMetrics>;
}

View File

@@ -0,0 +1,23 @@
import { Injectable, inject } from '@angular/core';
import { Observable, forkJoin, map } from 'rxjs';
import { BackofficeDataService } from '../../../../core/backoffice/backoffice-data.service';
import { AdminDashboardMetrics } from '../models/admin-dashboard.model';
import { AdminDashboardMetricsGateway } from './admin-dashboard-metrics.gateway.interface';
/** Local composition of existing backoffice data sources. Replace with an HTTP gateway once the backend exposes dashboard metrics endpoints - the facade contract stays the same. */
@Injectable({ providedIn: 'root' })
export class AdminDashboardMetricsLocalGateway implements AdminDashboardMetricsGateway {
private readonly backofficeData = inject(BackofficeDataService);
loadMetrics(): Observable<AdminDashboardMetrics> {
return forkJoin({
categories: this.backofficeData.loadCategories(),
products: this.backofficeData.loadProducts(),
}).pipe(
map(({ categories, products }) => ({
categoriesCount: categories.length,
productsCount: products.length,
})),
);
}
}

View File

@@ -3,6 +3,7 @@ import { ActivatedRoute, Router } from '@angular/router';
import { AdminProductsFacade } from '../facade/admin-products.facade';
import { AdminProductFormComponent } from '../components/admin-product-form.component';
import { TranslatePipe } from '../../../../i18n/translate.pipe';
import { LanguageService } from '../../../../services/language.service';
@Component({
selector: 'app-admin-product-editor-page',
@@ -16,6 +17,7 @@ export class AdminProductEditorPageComponent {
readonly facade = inject(AdminProductsFacade);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly languageService = inject(LanguageService);
readonly title = computed(() => this.facade.editorMode() === 'create' ? 'adminProducts.create' : this.facade.editorMode() === 'duplicate' ? 'adminProducts.duplicate' : 'adminProducts.edit');
constructor() {
@@ -31,6 +33,6 @@ export class AdminProductEditorPageComponent {
save(): void {
this.facade.saveDraft();
void this.router.navigate(['ru/backoffice/products']);
void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'products']);
}
}

View File

@@ -2,6 +2,7 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { AdminProductsFacade } from '../facade/admin-products.facade';
import { AdminProductsListComponent } from '../components/admin-products-list.component';
import { LanguageService } from '../../../../services/language.service';
@Component({
selector: 'app-admin-products-list-page',
@@ -28,13 +29,18 @@ import { AdminProductsListComponent } from '../components/admin-products-list.co
export class AdminProductsListPageComponent {
readonly facade = inject(AdminProductsFacade);
private readonly router = inject(Router);
private readonly languageService = inject(LanguageService);
constructor() {
this.facade.loadCategories();
this.facade.loadList();
}
create(): void { this.facade.startCreate(); void this.router.navigate(['ru/backoffice/products/create']); }
edit(id: string): void { this.facade.loadForEdit(id, 'edit'); void this.router.navigate(['ru/backoffice/products', id, 'edit']); }
duplicate(id: string): void { this.facade.loadForEdit(id, 'duplicate'); void this.router.navigate(['ru/backoffice/products', id, 'duplicate']); }
create(): void { this.facade.startCreate(); void this.router.navigate([this.lang(), 'backoffice', 'products', 'create']); }
edit(id: string): void { this.facade.loadForEdit(id, 'edit'); void this.router.navigate([this.lang(), 'backoffice', 'products', id, 'edit']); }
duplicate(id: string): void { this.facade.loadForEdit(id, 'duplicate'); void this.router.navigate([this.lang(), 'backoffice', 'products', id, 'duplicate']); }
private lang(): string {
return this.languageService.currentLanguage();
}
}