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,12 +0,0 @@
|
||||
<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>
|
||||
@@ -1,42 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
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[] = [];
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
<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">
|
||||
<app-skeleton *ngSwitchCase="'loading'" shape="rect" height="24px" width="60%" />
|
||||
|
||||
<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>
|
||||
@@ -1,94 +0,0 @@
|
||||
.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;
|
||||
transition: box-shadow var(--transition-normal, 180ms ease), border-color var(--transition-normal, 180ms ease), transform var(--transition-normal, 180ms ease);
|
||||
animation: dashboard-card-in 320ms ease-out both;
|
||||
}
|
||||
|
||||
.dashboard-card:hover {
|
||||
border-color: var(--primary-color, #497671);
|
||||
box-shadow: var(--shadow-md, 0 4px 12px rgba(0, 0, 0, 0.1));
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
@keyframes dashboard-card-in {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dashboard-card {
|
||||
animation: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.dashboard-card:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
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';
|
||||
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-dashboard-card',
|
||||
standalone: true,
|
||||
imports: [CommonModule, TranslatePipe, SkeletonComponent],
|
||||
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;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
<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>
|
||||
@@ -1,51 +0,0 @@
|
||||
.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; }
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
<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>
|
||||
@@ -1,59 +0,0 @@
|
||||
.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, transform 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));
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.dashboard-quick-actions__item:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dashboard-quick-actions__item {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.dashboard-quick-actions__item:hover,
|
||||
.dashboard-quick-actions__item:focus-visible {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@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; }
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
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[] = [];
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<app-card class="dashboard-card" padding="lg">
|
||||
@if (titleKey(); as title) {
|
||||
<header class="dashboard-card__header">
|
||||
@if (icon(); as iconClass) {
|
||||
<span class="pi {{ iconClass }} dashboard-card__icon" aria-hidden="true"></span>
|
||||
}
|
||||
<h3 class="dashboard-card__title">{{ title | translate }}</h3>
|
||||
<div class="dashboard-card__header-actions">
|
||||
<ng-content select="[dashboardCardActions]"></ng-content>
|
||||
</div>
|
||||
</header>
|
||||
}
|
||||
<div class="dashboard-card__body">
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
</app-card>
|
||||
@@ -0,0 +1,45 @@
|
||||
:host {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-card {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.dashboard-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.dashboard-card__icon {
|
||||
color: var(--primary-color);
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.dashboard-card__title {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.dashboard-card__header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.dashboard-card__header-actions:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dashboard-card__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||
import { CardComponent } from '../../../../shared/ui/card/card.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard-card',
|
||||
standalone: true,
|
||||
imports: [TranslatePipe, CardComponent],
|
||||
templateUrl: './dashboard-card.component.html',
|
||||
styleUrl: './dashboard-card.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
'[style.grid-column]': 'span()',
|
||||
},
|
||||
})
|
||||
export class DashboardCardComponent {
|
||||
readonly titleKey = input<string | null>(null);
|
||||
readonly icon = input<string | null>(null);
|
||||
/** 12-column grid span at desktop width, e.g. 'span 12' or 'span 4'. */
|
||||
readonly span = input<string>('span 4');
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<div class="dashboard-metric">
|
||||
<span class="dashboard-metric__label">{{ labelKey() | translate }}</span>
|
||||
@if (loading()) {
|
||||
<app-skeleton shape="text" width="70%" height="1.1em" />
|
||||
} @else {
|
||||
<span
|
||||
class="dashboard-metric__value"
|
||||
[class.dashboard-metric__value--badge-production]="badge() === 'production'"
|
||||
[class.dashboard-metric__value--badge-development]="badge() === 'development'"
|
||||
>
|
||||
{{ value() ?? '—' }}
|
||||
@if (badgeLabelKey(); as badgeKey) {
|
||||
<span class="dashboard-metric__badge">{{ badgeKey | translate }}</span>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,46 @@
|
||||
.dashboard-metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-metric__label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.dashboard-metric__value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dashboard-metric__badge {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dashboard-metric__value--badge-production .dashboard-metric__badge {
|
||||
background: color-mix(in srgb, var(--success-color) 12%, transparent);
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.dashboard-metric__value--badge-development .dashboard-metric__badge {
|
||||
background: color-mix(in srgb, var(--warning-color) 14%, transparent);
|
||||
color: var(--warning-color);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard-metric',
|
||||
standalone: true,
|
||||
imports: [TranslatePipe, SkeletonComponent],
|
||||
templateUrl: './dashboard-metric.component.html',
|
||||
styleUrl: './dashboard-metric.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DashboardMetricComponent {
|
||||
readonly labelKey = input.required<string>();
|
||||
/** null/undefined renders an em dash placeholder - the metric is never left blank. */
|
||||
readonly value = input<string | null | undefined>(null);
|
||||
readonly loading = input(false);
|
||||
readonly badge = input<'production' | 'development' | null>(null);
|
||||
readonly badgeLabelKey = input<string | null>(null);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<section class="dashboard-section" [attr.aria-labelledby]="titleKey()">
|
||||
<header class="dashboard-section__header">
|
||||
<h2 class="dashboard-section__title" [id]="titleKey()">{{ titleKey() | translate }}</h2>
|
||||
@if (descriptionKey(); as description) {
|
||||
<p class="dashboard-section__description">{{ description | translate }}</p>
|
||||
}
|
||||
<div class="dashboard-section__actions">
|
||||
<ng-content select="[dashboardSectionActions]"></ng-content>
|
||||
</div>
|
||||
</header>
|
||||
<div class="dashboard-section__body">
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,42 @@
|
||||
:host {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-section__header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.dashboard-section__title {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.dashboard-section__description {
|
||||
flex-basis: 100%;
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.dashboard-section__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.dashboard-section__actions:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dashboard-section__body {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard-section',
|
||||
standalone: true,
|
||||
imports: [TranslatePipe],
|
||||
templateUrl: './dashboard-section.component.html',
|
||||
styleUrl: './dashboard-section.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
'[style.grid-column]': 'span()',
|
||||
},
|
||||
})
|
||||
export class DashboardSectionComponent {
|
||||
readonly titleKey = input.required<string>();
|
||||
readonly descriptionKey = input<string | null>(null);
|
||||
/** 12-column grid span at desktop width, e.g. 'span 12' or 'span 4'. */
|
||||
readonly span = input<string>('span 12');
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
@if (comingSoon()) {
|
||||
<button type="button" class="dashboard-shortcut-card dashboard-shortcut-card--disabled" disabled>
|
||||
<span class="pi {{ icon() }} dashboard-shortcut-card__icon" aria-hidden="true"></span>
|
||||
<span class="dashboard-shortcut-card__title">{{ labelKey() | translate }}</span>
|
||||
@if (descriptionKey(); as description) {
|
||||
<span class="dashboard-shortcut-card__description">{{ description | translate }}</span>
|
||||
}
|
||||
<span class="dashboard-shortcut-card__badge">{{ 'adminShell.nav.comingSoonBadge' | translate }}</span>
|
||||
</button>
|
||||
} @else {
|
||||
<a class="dashboard-shortcut-card" [routerLink]="route()">
|
||||
<span class="pi {{ icon() }} dashboard-shortcut-card__icon" aria-hidden="true"></span>
|
||||
<span class="dashboard-shortcut-card__title">{{ labelKey() | translate }}</span>
|
||||
@if (descriptionKey(); as description) {
|
||||
<span class="dashboard-shortcut-card__description">{{ description | translate }}</span>
|
||||
}
|
||||
</a>
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.dashboard-shortcut-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-xs);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: var(--space-md);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-primary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.dashboard-shortcut-card:hover {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.dashboard-shortcut-card:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.dashboard-shortcut-card--disabled {
|
||||
cursor: not-allowed;
|
||||
color: var(--text-light);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.dashboard-shortcut-card--disabled:hover {
|
||||
transform: none;
|
||||
border-color: var(--border-color);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.dashboard-shortcut-card__icon {
|
||||
font-size: 1.125rem;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.dashboard-shortcut-card--disabled .dashboard-shortcut-card__icon {
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.dashboard-shortcut-card__title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dashboard-shortcut-card--disabled .dashboard-shortcut-card__title {
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.dashboard-shortcut-card__description {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.dashboard-shortcut-card__badge {
|
||||
position: absolute;
|
||||
top: var(--space-sm);
|
||||
right: var(--space-sm);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: color-mix(in srgb, var(--primary-color) 8%, transparent);
|
||||
color: var(--primary-color);
|
||||
border: 1px solid color-mix(in srgb, var(--primary-color) 20%, transparent);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dashboard-shortcut-card {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard-shortcut-card',
|
||||
standalone: true,
|
||||
imports: [RouterLink, TranslatePipe],
|
||||
templateUrl: './dashboard-shortcut-card.component.html',
|
||||
styleUrl: './dashboard-shortcut-card.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DashboardShortcutCardComponent {
|
||||
readonly icon = input.required<string>();
|
||||
readonly labelKey = input.required<string>();
|
||||
readonly descriptionKey = input<string | null>(null);
|
||||
readonly route = input<string[] | null>(null);
|
||||
readonly comingSoon = input(false);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
@if (status() === 'loading') {
|
||||
<div class="dashboard-status-row">
|
||||
<app-skeleton shape="circle" width="18px" height="18px" />
|
||||
<app-skeleton shape="text" width="55%" height="1em" />
|
||||
</div>
|
||||
} @else {
|
||||
<div class="dashboard-status-row dashboard-status-row--{{ status() }}">
|
||||
<span class="pi {{ icon() }} dashboard-status-row__icon" aria-hidden="true"></span>
|
||||
<span class="dashboard-status-row__label">{{ labelKey() | translate }}</span>
|
||||
@if (displayValue() !== null) {
|
||||
<span class="dashboard-status-row__value">{{ displayValue() }}</span>
|
||||
}
|
||||
<span class="dashboard-status-row__status-text">{{ statusTextKey() | translate }}</span>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
.dashboard-status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-xs) 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.dashboard-status-row__icon {
|
||||
font-size: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dashboard-status-row__label {
|
||||
flex: 1 1 auto;
|
||||
color: var(--text-primary);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dashboard-status-row__value {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dashboard-status-row__status-text {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-light);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dashboard-status-row--healthy .dashboard-status-row__icon {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.dashboard-status-row--attention .dashboard-status-row__icon {
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
.dashboard-status-row--unhealthy .dashboard-status-row__icon {
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
.dashboard-status-row--unknown .dashboard-status-row__icon {
|
||||
color: var(--text-light);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
|
||||
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
|
||||
import { AdminDashboardHealthStatus } from '../models/admin-dashboard.model';
|
||||
|
||||
const STATUS_ICON: Record<AdminDashboardHealthStatus, string> = {
|
||||
healthy: 'pi-check-circle',
|
||||
attention: 'pi-exclamation-triangle',
|
||||
unhealthy: 'pi-times-circle',
|
||||
unknown: 'pi-question-circle',
|
||||
loading: 'pi-spin pi-spinner',
|
||||
};
|
||||
|
||||
const STATUS_TEXT_KEY: Record<AdminDashboardHealthStatus, string> = {
|
||||
healthy: 'dashboard.statusHealthy',
|
||||
attention: 'dashboard.statusAttention',
|
||||
unhealthy: 'dashboard.statusUnhealthy',
|
||||
unknown: 'dashboard.statusUnknown',
|
||||
loading: 'dashboard.stateLoading',
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard-status-row',
|
||||
standalone: true,
|
||||
imports: [TranslatePipe, SkeletonComponent],
|
||||
templateUrl: './dashboard-status-row.component.html',
|
||||
styleUrl: './dashboard-status-row.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: { class: 'dashboard-status-row-host' },
|
||||
})
|
||||
export class DashboardStatusRowComponent {
|
||||
readonly labelKey = input.required<string>();
|
||||
readonly status = input.required<AdminDashboardHealthStatus>();
|
||||
readonly displayValue = input<string | null>(null);
|
||||
|
||||
readonly icon = computed(() => STATUS_ICON[this.status()]);
|
||||
readonly statusTextKey = computed(() => STATUS_TEXT_KEY[this.status()]);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
@switch (status()) {
|
||||
@case ('loading') {
|
||||
<ul class="dashboard-timeline dashboard-timeline--loading" aria-hidden="true">
|
||||
@for (i of [0, 1, 2]; track i) {
|
||||
<li class="dashboard-timeline__skeleton-row">
|
||||
<app-skeleton shape="circle" width="20px" height="20px" />
|
||||
<app-skeleton shape="text" width="75%" height="1em" />
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
@case ('unavailable') {
|
||||
<app-empty-state
|
||||
[title]="'dashboard.activityUnavailable' | translate"
|
||||
[description]="'dashboard.activityUnavailableHint' | translate"
|
||||
/>
|
||||
}
|
||||
@case ('error') {
|
||||
<app-empty-state
|
||||
[title]="'dashboard.stateError' | translate"
|
||||
[description]="'dashboard.activityErrorHint' | translate"
|
||||
/>
|
||||
}
|
||||
@default {
|
||||
@if (entries().length === 0) {
|
||||
<app-empty-state
|
||||
[title]="'dashboard.activityEmpty' | translate"
|
||||
[description]="'dashboard.activityEmptyHint' | translate"
|
||||
/>
|
||||
} @else {
|
||||
<ol class="dashboard-timeline">
|
||||
@for (entry of entries(); track entry.id) {
|
||||
<li class="dashboard-timeline__item">
|
||||
<span class="pi {{ entry.icon }} dashboard-timeline__icon" aria-hidden="true"></span>
|
||||
<span class="dashboard-timeline__label">{{ entry.labelKey | translate }}</span>
|
||||
<time class="dashboard-timeline__time">{{ entry.timeText }}</time>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
.dashboard-timeline {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.dashboard-timeline__skeleton-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.dashboard-timeline__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding-bottom: var(--space-sm);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.dashboard-timeline__item:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.dashboard-timeline__icon {
|
||||
color: var(--primary-color);
|
||||
font-size: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dashboard-timeline__label {
|
||||
flex: 1 1 auto;
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-timeline__time {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-light);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
|
||||
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
|
||||
|
||||
export interface DashboardTimelineEntry {
|
||||
id: string;
|
||||
labelKey: string;
|
||||
timeText: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export type DashboardTimelineStatus = 'loading' | 'ready' | 'unavailable' | 'error';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard-timeline',
|
||||
standalone: true,
|
||||
imports: [TranslatePipe, SkeletonComponent, EmptyStateComponent],
|
||||
templateUrl: './dashboard-timeline.component.html',
|
||||
styleUrl: './dashboard-timeline.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DashboardTimelineComponent {
|
||||
readonly status = input<DashboardTimelineStatus>('ready');
|
||||
readonly entries = input<DashboardTimelineEntry[]>([]);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,11 +21,16 @@ export type AdminDashboardQuickActionId =
|
||||
| 'users'
|
||||
| 'monitoring'
|
||||
| 'analytics'
|
||||
| 'preview-marketplace';
|
||||
| 'preview-marketplace'
|
||||
| 'add-product'
|
||||
| 'create-category'
|
||||
| 'edit-homepage';
|
||||
|
||||
export interface AdminDashboardQuickAction {
|
||||
id: AdminDashboardQuickActionId;
|
||||
icon: string;
|
||||
labelKey: string;
|
||||
descriptionKey: string;
|
||||
route: string[];
|
||||
external?: boolean;
|
||||
}
|
||||
@@ -38,8 +43,38 @@ export interface AdminDashboardActivityEntry {
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/** Kept exactly as-is: AdminMonitoringPageComponent also reads AdminDashboardFacade.healthChecks() and depends on this boolean shape. */
|
||||
export interface AdminDashboardHealthCheck {
|
||||
code: string;
|
||||
labelKey: string;
|
||||
healthy: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* healthy: green check. attention: amber, informational (e.g. a draft exists) - not a defect.
|
||||
* unhealthy: red, a real validation problem. unknown: this sprint has no data source for the
|
||||
* check yet (never fabricated) - shown honestly rather than guessed.
|
||||
*/
|
||||
export type AdminDashboardHealthStatus = 'healthy' | 'attention' | 'unhealthy' | 'unknown' | 'loading';
|
||||
|
||||
/** Richer status rows for the redesigned dashboard homepage (Sprint 2) - separate from AdminDashboardHealthCheck above so Monitoring's existing boolean-badge usage stays untouched. */
|
||||
export interface AdminDashboardHomeHealthCheck {
|
||||
code: string;
|
||||
labelKey: string;
|
||||
status: AdminDashboardHealthStatus;
|
||||
/** Set for metric-style rows (e.g. product count) rendered as a number instead of a pass/fail statement. */
|
||||
displayValue?: string | null;
|
||||
}
|
||||
|
||||
export interface AdminDashboardDraftField {
|
||||
key: string;
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
export interface AdminDashboardShortcut {
|
||||
id: string;
|
||||
icon: string;
|
||||
labelKey: string;
|
||||
route: string[];
|
||||
comingSoon?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,23 +1,117 @@
|
||||
<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>
|
||||
<div class="dashboard-page__grid">
|
||||
|
||||
<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"
|
||||
/>
|
||||
<!-- Welcome -->
|
||||
<section class="dashboard-welcome" aria-labelledby="dashboard-welcome-title">
|
||||
<div class="dashboard-welcome__intro">
|
||||
<p class="dashboard-welcome__eyebrow">{{ 'dashboard.welcomeEyebrow' | translate }}</p>
|
||||
<h2 id="dashboard-welcome-title" class="dashboard-welcome__title">
|
||||
{{ (tenantName() ?? ('dashboard.welcomeFallbackTenant' | translate)) }}
|
||||
</h2>
|
||||
<span
|
||||
class="dashboard-welcome__env-badge"
|
||||
[class.dashboard-welcome__env-badge--production]="facade.isProductionEnvironment"
|
||||
[class.dashboard-welcome__env-badge--development]="!facade.isProductionEnvironment"
|
||||
>
|
||||
{{ (facade.isProductionEnvironment ? 'dashboard.envProduction' : 'dashboard.envDevelopment') | translate }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-welcome__facts">
|
||||
<app-dashboard-metric [labelKey]="'dashboard.factCurrentUser'" [value]="facade.currentUserName()" [loading]="!facade.currentUserName()" />
|
||||
<app-dashboard-metric [labelKey]="'dashboard.factTenant'" [value]="tenantName()" [loading]="bootstrapLoading()" />
|
||||
<app-dashboard-metric [labelKey]="'dashboard.cardLastPublish'" [value]="lastPublishedText() ?? ('dashboard.never' | translate)" [loading]="bootstrapLoading()" />
|
||||
<app-dashboard-metric [labelKey]="'dashboard.cardLastDraftSave'" [value]="lastSavedText() ?? ('dashboard.never' | translate)" [loading]="bootstrapLoading()" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<app-admin-dashboard-quick-actions class="dashboard-page__section" [actions]="quickActions()" />
|
||||
<!-- Quick Actions -->
|
||||
<app-dashboard-section [titleKey]="'dashboard.quickActionsTitle'" [descriptionKey]="'dashboard.quickActionsDescription'" [span]="'span 12'">
|
||||
<div class="dashboard-shortcut-grid">
|
||||
@for (action of quickActions(); track action.labelKey) {
|
||||
<app-dashboard-shortcut-card
|
||||
[icon]="action.icon"
|
||||
[labelKey]="action.labelKey"
|
||||
[descriptionKey]="action.descriptionKey ?? null"
|
||||
[route]="action.route"
|
||||
[comingSoon]="action.comingSoon"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</app-dashboard-section>
|
||||
|
||||
<!-- Draft Status -->
|
||||
<app-dashboard-card [titleKey]="'dashboard.draftStatusTitle'" [icon]="'pi-file-edit'" [span]="'span 4'">
|
||||
@if (facade.dirty()) {
|
||||
<p class="dashboard-draft__lead">{{ 'dashboard.draftStatusHasChanges' | translate }}</p>
|
||||
@if (facade.modifiedFieldLabelKeys().length > 0) {
|
||||
<ul class="dashboard-draft__fields">
|
||||
@for (labelKey of facade.modifiedFieldLabelKeys(); track labelKey) {
|
||||
<li class="dashboard-draft__field-chip">{{ labelKey | translate }}</li>
|
||||
}
|
||||
@if (facade.modifiedFieldsOverflowCount() > 0) {
|
||||
<li class="dashboard-draft__field-chip dashboard-draft__field-chip--muted">
|
||||
{{ 'dashboard.draftStatusMoreFields' | translate: { count: facade.modifiedFieldsOverflowCount() } }}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
<p class="dashboard-draft__saved-at">{{ 'dashboard.cardLastDraftSave' | translate }}: {{ lastSavedText() ?? ('dashboard.never' | translate) }}</p>
|
||||
<div class="dashboard-draft__actions">
|
||||
<a [routerLink]="['/', 'edit', 'general']">
|
||||
<app-button variant="ghost" size="sm">{{ 'dashboard.draftOpenBuilder' | translate }}</app-button>
|
||||
</a>
|
||||
<app-button variant="primary" size="sm" (click)="publishDraft()">{{ 'dashboard.draftPublish' | translate }}</app-button>
|
||||
<app-button variant="danger" size="sm" (click)="discardDraft()">{{ 'dashboard.draftDiscard' | translate }}</app-button>
|
||||
</div>
|
||||
} @else {
|
||||
<p class="dashboard-draft__all-clear">
|
||||
<span class="pi pi-check-circle" aria-hidden="true"></span>
|
||||
{{ 'dashboard.draftStatusAllPublished' | translate }}
|
||||
</p>
|
||||
}
|
||||
</app-dashboard-card>
|
||||
|
||||
<!-- Marketplace Health -->
|
||||
<app-dashboard-card [titleKey]="'dashboard.healthTitle'" [icon]="'pi-heart'" [span]="'span 4'">
|
||||
@for (check of facade.homeHealthChecks(); track check.code) {
|
||||
<app-dashboard-status-row [labelKey]="check.labelKey" [status]="check.status" [displayValue]="check.displayValue ?? null" />
|
||||
}
|
||||
</app-dashboard-card>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<app-dashboard-card [titleKey]="'dashboard.activityTitle'" [icon]="'pi-clock'" [span]="'span 4'">
|
||||
<app-dashboard-timeline [status]="activityStatus()" [entries]="activityTimelineEntries()" />
|
||||
</app-dashboard-card>
|
||||
|
||||
<!-- Shortcuts -->
|
||||
<app-dashboard-section [titleKey]="'dashboard.shortcutsTitle'" [descriptionKey]="'dashboard.shortcutsDescription'" [span]="'span 12'">
|
||||
<div class="dashboard-shortcut-grid dashboard-shortcut-grid--compact">
|
||||
@for (shortcut of shortcuts(); track shortcut.labelKey) {
|
||||
<app-dashboard-shortcut-card
|
||||
[icon]="shortcut.icon"
|
||||
[labelKey]="shortcut.labelKey"
|
||||
[route]="shortcut.route"
|
||||
[comingSoon]="shortcut.comingSoon"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</app-dashboard-section>
|
||||
|
||||
<!-- Documentation -->
|
||||
<app-dashboard-card [titleKey]="'dashboard.docTitle'" [icon]="'pi-question-circle'" [span]="'span 4'">
|
||||
<ul class="dashboard-doc-list">
|
||||
@for (link of documentationLinks; track link.id) {
|
||||
<li>
|
||||
<button type="button" class="dashboard-doc-list__item" disabled>
|
||||
<span class="pi {{ link.icon }}" aria-hidden="true"></span>
|
||||
<span>{{ link.labelKey | translate }}</span>
|
||||
<span class="dashboard-doc-list__badge">{{ 'adminShell.nav.comingSoonBadge' | translate }}</span>
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</app-dashboard-card>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -1,64 +1,222 @@
|
||||
.dashboard-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 20px;
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dashboard-page__header {
|
||||
.dashboard-page__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
gap: var(--space-lg);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* ---------- Welcome ---------- */
|
||||
|
||||
.dashboard-welcome {
|
||||
grid-column: span 12;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-lg);
|
||||
padding: var(--space-lg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--gradient-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.dashboard-welcome__intro {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-page__title {
|
||||
.dashboard-welcome__eyebrow {
|
||||
margin: 0;
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary, #1e3c38);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.dashboard-page__subtitle {
|
||||
.dashboard-welcome__title {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary, #667a77);
|
||||
font-size: clamp(1.5rem, 3vw, 2rem);
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dashboard-page__cards {
|
||||
.dashboard-welcome__env-badge {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.dashboard-welcome__env-badge--production {
|
||||
background: color-mix(in srgb, #fff 25%, transparent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.dashboard-welcome__env-badge--development {
|
||||
background: color-mix(in srgb, #fff 90%, transparent);
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
.dashboard-welcome__facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(4, minmax(120px, 1fr));
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
.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-welcome__facts app-dashboard-metric {
|
||||
--text-primary: #fff;
|
||||
--text-light: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.dashboard-page__row {
|
||||
/* ---------- Shortcut / quick-action grids ---------- */
|
||||
|
||||
.dashboard-shortcut-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 1fr;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.dashboard-page__cards { grid-template-columns: repeat(3, 1fr); }
|
||||
.dashboard-shortcut-grid--compact {
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.dashboard-page__row { grid-template-columns: 1fr; }
|
||||
/* ---------- Draft status ---------- */
|
||||
|
||||
.dashboard-draft__lead {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.dashboard-page__cards { grid-template-columns: repeat(2, 1fr); }
|
||||
.dashboard-draft__all-clear {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
color: var(--success-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.dashboard-page { padding: 12px; gap: 16px; }
|
||||
.dashboard-page__cards { grid-template-columns: 1fr; }
|
||||
.dashboard-draft__fields {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.dashboard-draft__field-chip {
|
||||
font-size: 0.75rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: color-mix(in srgb, var(--primary-color) 8%, transparent);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.dashboard-draft__field-chip--muted {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.dashboard-draft__saved-at {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.dashboard-draft__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
/* ---------- Documentation ---------- */
|
||||
|
||||
.dashboard-doc-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.dashboard-doc-list__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
width: 100%;
|
||||
padding: var(--space-xs) 0;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 1rem;
|
||||
color: var(--text-light);
|
||||
text-align: left;
|
||||
cursor: not-allowed;
|
||||
|
||||
.pi {
|
||||
color: var(--text-light);
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-doc-list__badge {
|
||||
margin-left: auto;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
/* ---------- Responsive ---------- */
|
||||
|
||||
/* Tablet: the three-across health/draft/activity/doc row becomes 2 columns instead of 3. */
|
||||
@media (max-width: 1023px) and (min-width: 640px) {
|
||||
.dashboard-page__grid > app-dashboard-card {
|
||||
grid-column: span 6 !important;
|
||||
}
|
||||
|
||||
.dashboard-welcome__facts {
|
||||
grid-template-columns: repeat(2, minmax(120px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile: everything is a single column, nothing scrolls sideways. */
|
||||
@media (max-width: 639px) {
|
||||
.dashboard-page__grid > * {
|
||||
grid-column: span 12 !important;
|
||||
}
|
||||
|
||||
.dashboard-welcome {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.dashboard-welcome__facts {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dashboard-shortcut-grid,
|
||||
.dashboard-shortcut-grid--compact {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,56 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { TranslateService } from '../../../../i18n/translate.service';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||
import { TranslateService } from '../../../../i18n/translate.service';
|
||||
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';
|
||||
import { DashboardSectionComponent } from '../components/dashboard-section.component';
|
||||
import { DashboardCardComponent } from '../components/dashboard-card.component';
|
||||
import { DashboardMetricComponent } from '../components/dashboard-metric.component';
|
||||
import { DashboardStatusRowComponent } from '../components/dashboard-status-row.component';
|
||||
import { DashboardShortcutCardComponent } from '../components/dashboard-shortcut-card.component';
|
||||
import { DashboardTimelineComponent, DashboardTimelineEntry, DashboardTimelineStatus } from '../components/dashboard-timeline.component';
|
||||
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
|
||||
import { AdminDashboardQuickAction, AdminDashboardShortcut } from '../models/admin-dashboard.model';
|
||||
|
||||
interface DashboardCardViewModel {
|
||||
id: string;
|
||||
titleKey: string;
|
||||
status: AdminDashboardCardStatus;
|
||||
value: string | null;
|
||||
subtitle: string | null;
|
||||
interface LinkedAction {
|
||||
icon: string;
|
||||
labelKey: string;
|
||||
descriptionKey?: string;
|
||||
route: string[];
|
||||
comingSoon: boolean;
|
||||
}
|
||||
|
||||
const ACTIVITY_ICON: Record<string, string> = {
|
||||
'draft-saved': 'pi-save',
|
||||
published: 'pi-upload',
|
||||
};
|
||||
|
||||
const ACTIVITY_LABEL_KEY: Record<string, string> = {
|
||||
'draft-saved': 'dashboard.activityDraftSaved',
|
||||
published: 'dashboard.activityPublished',
|
||||
};
|
||||
|
||||
const DOCUMENTATION_LINKS = [
|
||||
{ id: 'documentation', icon: 'pi-book', labelKey: 'dashboard.docDocumentation' },
|
||||
{ id: 'shortcuts', icon: 'pi-bolt', labelKey: 'dashboard.docKeyboardShortcuts' },
|
||||
{ id: 'report-issue', icon: 'pi-flag', labelKey: 'dashboard.docReportIssue' },
|
||||
{ id: 'release-notes', icon: 'pi-megaphone', labelKey: 'dashboard.docReleaseNotes' },
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-dashboard-page',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterLink,
|
||||
TranslatePipe,
|
||||
AdminDashboardCardComponent,
|
||||
AdminDashboardQuickActionsComponent,
|
||||
AdminDashboardActivityComponent,
|
||||
AdminDashboardHealthComponent,
|
||||
DashboardSectionComponent,
|
||||
DashboardCardComponent,
|
||||
DashboardMetricComponent,
|
||||
DashboardStatusRowComponent,
|
||||
DashboardShortcutCardComponent,
|
||||
DashboardTimelineComponent,
|
||||
ButtonComponent,
|
||||
],
|
||||
templateUrl: './admin-dashboard-page.component.html',
|
||||
styleUrls: ['./admin-dashboard-page.component.scss'],
|
||||
@@ -38,134 +61,60 @@ export class AdminDashboardPageComponent {
|
||||
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 documentationLinks = DOCUMENTATION_LINKS;
|
||||
|
||||
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(),
|
||||
})),
|
||||
private toLinkedAction(action: AdminDashboardQuickAction | AdminDashboardShortcut): LinkedAction {
|
||||
const lang = this.languageService.currentLanguage();
|
||||
const comingSoon = 'comingSoon' in action ? !!action.comingSoon : action.route.length === 0;
|
||||
return {
|
||||
icon: action.icon,
|
||||
labelKey: action.labelKey,
|
||||
descriptionKey: 'descriptionKey' in action ? action.descriptionKey : undefined,
|
||||
route: comingSoon ? [] : ['/', lang, ...action.route],
|
||||
comingSoon,
|
||||
};
|
||||
}
|
||||
|
||||
readonly quickActions = computed<LinkedAction[]>(() =>
|
||||
this.facade.quickActions.map(action => this.toLinkedAction(action)),
|
||||
);
|
||||
|
||||
readonly healthChecks = this.facade.healthChecks;
|
||||
readonly shortcuts = computed<LinkedAction[]>(() =>
|
||||
this.facade.shortcuts.map(action => this.toLinkedAction(action)),
|
||||
);
|
||||
|
||||
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 ?? [];
|
||||
readonly previewRoute = computed(() => ['/', this.languageService.currentLanguage()]);
|
||||
|
||||
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,
|
||||
},
|
||||
];
|
||||
});
|
||||
readonly tenantName = computed(() => this.facade.bootstrap()?.tenant.name ?? null);
|
||||
readonly bootstrapLoading = computed(() => !this.facade.bootstrap());
|
||||
|
||||
readonly lastPublishedText = computed(() => this.formatTimestamp(this.facade.lastPublishedAt()));
|
||||
readonly lastSavedText = computed(() => this.formatTimestamp(this.facade.lastSavedAt()));
|
||||
|
||||
readonly activityStatus = computed<DashboardTimelineStatus>(() => (this.bootstrapLoading() ? 'loading' : 'ready'));
|
||||
|
||||
readonly activityTimelineEntries = computed<DashboardTimelineEntry[]>(() =>
|
||||
this.facade.activityEntries().map(entry => ({
|
||||
id: entry.id,
|
||||
labelKey: ACTIVITY_LABEL_KEY[entry.type] ?? entry.type,
|
||||
timeText: new Date(entry.timestamp).toLocaleString(this.languageService.currentLanguage()),
|
||||
icon: ACTIVITY_ICON[entry.type] ?? 'pi-circle',
|
||||
})),
|
||||
);
|
||||
|
||||
constructor() {
|
||||
this.facade.ensureLoaded();
|
||||
}
|
||||
|
||||
cardTitle(titleKey: string): string {
|
||||
return this.translate.t(titleKey);
|
||||
private formatTimestamp(timestamp: number | null): string | null {
|
||||
return timestamp ? new Date(timestamp).toLocaleString(this.languageService.currentLanguage()) : null;
|
||||
}
|
||||
|
||||
private formatTimestamp(timestamp: number | null): string {
|
||||
return timestamp ? new Date(timestamp).toLocaleString() : this.translate.t('dashboard.never');
|
||||
publishDraft(): void {
|
||||
this.facade.publishDraft();
|
||||
}
|
||||
|
||||
discardDraft(): void {
|
||||
this.facade.discardDraft();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -720,7 +720,7 @@ export const en: Translations = {
|
||||
actionAnalytics: 'Analytics',
|
||||
actionPreviewMarketplace: 'Preview Marketplace',
|
||||
activityTitle: 'Recent Activity',
|
||||
activityEmpty: 'No activity yet. Save or publish a change to see it here.',
|
||||
activityEmpty: 'No activity yet',
|
||||
activityDraftSaved: 'Draft saved',
|
||||
activityPublished: 'Published',
|
||||
healthTitle: 'System Health',
|
||||
@@ -734,6 +734,54 @@ export const en: Translations = {
|
||||
comingSoonTitle: 'Coming soon',
|
||||
comingSoonDescription: 'This section is not built yet. Check back in a future update.',
|
||||
backToDashboard: 'Back to dashboard',
|
||||
welcomeEyebrow: 'Welcome back',
|
||||
welcomeFallbackTenant: 'Your marketplace',
|
||||
envProduction: 'Production',
|
||||
envDevelopment: 'Development',
|
||||
factCurrentUser: 'Current user',
|
||||
factTenant: 'Store',
|
||||
quickActionsDescription: 'Your most common tasks - start here',
|
||||
actionAddProduct: 'Add product',
|
||||
actionAddProductDescription: 'Create a new product listing',
|
||||
actionCreateCategory: 'Create category',
|
||||
actionCreateCategoryDescription: 'Add a category to the catalog',
|
||||
actionEditProjectDescription: 'Open the marketplace builder',
|
||||
actionEditHomepage: 'Edit homepage',
|
||||
actionEditHomepageDescription: 'Configure the homepage widgets',
|
||||
actionMediaLibraryDescription: 'Upload and organize files',
|
||||
actionOrdersDescription: 'Review recent orders',
|
||||
draftStatusTitle: 'Draft status',
|
||||
draftStatusHasChanges: 'There are unpublished changes waiting to go live.',
|
||||
draftStatusAllPublished: 'Everything published. No draft in progress.',
|
||||
draftStatusMoreFields: '+{{count}} more',
|
||||
draftOpenBuilder: 'Open builder',
|
||||
draftPublish: 'Publish',
|
||||
draftDiscard: 'Discard draft',
|
||||
healthProductsCount: 'Products',
|
||||
healthCategoriesCount: 'Categories',
|
||||
healthDraftExists: 'Draft',
|
||||
healthImagesWithoutAlt: 'Images missing alt text',
|
||||
healthStaticPagesUnpublished: 'Unpublished static pages',
|
||||
healthHomepageConfigured: 'Homepage configured',
|
||||
healthThemeConfigured: 'Theme configured',
|
||||
statusHealthy: 'Healthy',
|
||||
statusAttention: 'Needs attention',
|
||||
statusUnhealthy: 'Issue',
|
||||
statusUnknown: 'Not tracked yet',
|
||||
activityEmptyHint: 'Save or publish a change to see it appear here.',
|
||||
activityUnavailable: 'Activity history unavailable',
|
||||
activityUnavailableHint: "Couldn't reach the activity data source. Try refreshing later.",
|
||||
activityErrorHint: "Couldn't load recent activity.",
|
||||
shortcutsTitle: 'Useful shortcuts',
|
||||
shortcutsDescription: 'Frequently used destinations across the admin panel',
|
||||
shortcutMarketplaceBuilder: 'Marketplace Builder',
|
||||
shortcutSettings: 'Settings',
|
||||
shortcutContent: 'Content',
|
||||
docTitle: 'Documentation',
|
||||
docDocumentation: 'Documentation',
|
||||
docKeyboardShortcuts: 'Keyboard shortcuts',
|
||||
docReportIssue: 'Report an issue',
|
||||
docReleaseNotes: 'Release notes',
|
||||
},
|
||||
staticPages: {
|
||||
notFound: 'Page not found',
|
||||
|
||||
@@ -720,7 +720,7 @@ export const hy: Translations = {
|
||||
actionAnalytics: 'Վերլուծություն',
|
||||
actionPreviewMarketplace: 'Դիտել մարքեթփլեյսը',
|
||||
activityTitle: 'Վերջին ակտիվություն',
|
||||
activityEmpty: 'Դեռ ակտիվություն չկա։ Պահպանեք կամ հրապարակեք փոփոխություն՝ այն այստեղ տեսնելու համար։',
|
||||
activityEmpty: 'Դեռ ակտիվություն չկա',
|
||||
activityDraftSaved: 'Սևագիրը պահպանվեց',
|
||||
activityPublished: 'Հրապարակվեց',
|
||||
healthTitle: 'Համակարգի վիճակ',
|
||||
@@ -734,6 +734,54 @@ export const hy: Translations = {
|
||||
comingSoonTitle: 'Շուտով',
|
||||
comingSoonDescription: 'Այս բաժինը դեռ կառուցված չէ։ Ստուգեք ավելի ուշ։',
|
||||
backToDashboard: 'Վերադառնալ վահանակ',
|
||||
welcomeEyebrow: 'Բարի վերադարձ',
|
||||
welcomeFallbackTenant: 'Ձեր մարկետփլեյսը',
|
||||
envProduction: 'Պրոդակշն',
|
||||
envDevelopment: 'Մշակում',
|
||||
factCurrentUser: 'Ընթացիկ օգտատեր',
|
||||
factTenant: 'Խանութ',
|
||||
quickActionsDescription: 'Ձեր ամենահաճախակի առաջադրանքները՝ սկսեք այստեղից',
|
||||
actionAddProduct: 'Ավելացնել ապրանք',
|
||||
actionAddProductDescription: 'Ստեղծել նոր ապրանքի քարտ',
|
||||
actionCreateCategory: 'Ստեղծել կատեգորիա',
|
||||
actionCreateCategoryDescription: 'Ավելացնել կատեգորիա կատալոգում',
|
||||
actionEditProjectDescription: 'Բացել մարկետփլեյսի կոնստրուկտորը',
|
||||
actionEditHomepage: 'Խմբագրել գլխավոր էջը',
|
||||
actionEditHomepageDescription: 'Կարգավորել գլխավոր էջի վիջեթները',
|
||||
actionMediaLibraryDescription: 'Վերբեռնեք և կազմակերպեք ֆայլերը',
|
||||
actionOrdersDescription: 'Դիտեք վերջին պատվերները',
|
||||
draftStatusTitle: 'Սևագրի կարգավիճակ',
|
||||
draftStatusHasChanges: 'Կան չհրապարակված փոփոխություններ։',
|
||||
draftStatusAllPublished: 'Ամեն ինչ հրապարակված է։ Սևագիր չկա։',
|
||||
draftStatusMoreFields: '+{{count}} ևս',
|
||||
draftOpenBuilder: 'Բացել կոնստրուկտորը',
|
||||
draftPublish: 'Հրապարակել',
|
||||
draftDiscard: 'Չեղարկել սևագիրը',
|
||||
healthProductsCount: 'Ապրանքներ',
|
||||
healthCategoriesCount: 'Կատեգորիաներ',
|
||||
healthDraftExists: 'Սևագիր',
|
||||
healthImagesWithoutAlt: 'Alt տեքստ չունեցող պատկերներ',
|
||||
healthStaticPagesUnpublished: 'Չհրապարակված ստատիկ էջեր',
|
||||
healthHomepageConfigured: 'Գլխավոր էջը կարգավորված է',
|
||||
healthThemeConfigured: 'Թեման կարգավորված է',
|
||||
statusHealthy: 'Կարգին է',
|
||||
statusAttention: 'Ուշադրություն է պահանջում',
|
||||
statusUnhealthy: 'Խնդիր',
|
||||
statusUnknown: 'Դեռ չի հետևվում',
|
||||
activityEmptyHint: 'Պահպանեք կամ հրապարակեք փոփոխություն՝ այն այստեղ տեսնելու համար։',
|
||||
activityUnavailable: 'Ակտիվության պատմությունը հասանելի չէ',
|
||||
activityUnavailableHint: 'Չհաջողվեց միանալ տվյալների աղբյուրին։ Փորձեք թարմացնել ավելի ուշ։',
|
||||
activityErrorHint: 'Չհաջողվեց բեռնել վերջին ակտիվությունը։',
|
||||
shortcutsTitle: 'Օգտակար հղումներ',
|
||||
shortcutsDescription: 'Ադմին վահանակի հաճախակի օգտագործվող բաժինները',
|
||||
shortcutMarketplaceBuilder: 'Մարկետփլեյսի կոնստրուկտոր',
|
||||
shortcutSettings: 'Կարգավորումներ',
|
||||
shortcutContent: 'Բովանդակություն',
|
||||
docTitle: 'Փաստաթղթեր',
|
||||
docDocumentation: 'Փաստաթղթեր',
|
||||
docKeyboardShortcuts: 'Ստեղնաշարի դյուրանցումներ',
|
||||
docReportIssue: 'Հայտնել խնդրի մասին',
|
||||
docReleaseNotes: 'Թողարկման նշումներ',
|
||||
},
|
||||
staticPages: {
|
||||
notFound: 'Էջը չի գտնվել',
|
||||
|
||||
@@ -720,7 +720,7 @@ export const ru: Translations = {
|
||||
actionAnalytics: 'Аналитика',
|
||||
actionPreviewMarketplace: 'Просмотр маркетплейса',
|
||||
activityTitle: 'Недавняя активность',
|
||||
activityEmpty: 'Пока нет активности. Сохраните или опубликуйте изменение, чтобы увидеть его здесь.',
|
||||
activityEmpty: 'Пока нет активности',
|
||||
activityDraftSaved: 'Черновик сохранён',
|
||||
activityPublished: 'Опубликовано',
|
||||
healthTitle: 'Состояние системы',
|
||||
@@ -734,6 +734,54 @@ export const ru: Translations = {
|
||||
comingSoonTitle: 'Скоро',
|
||||
comingSoonDescription: 'Этот раздел ещё не реализован. Загляните позже.',
|
||||
backToDashboard: 'Вернуться на панель управления',
|
||||
welcomeEyebrow: 'С возвращением',
|
||||
welcomeFallbackTenant: 'Ваш маркетплейс',
|
||||
envProduction: 'Продакшн',
|
||||
envDevelopment: 'Разработка',
|
||||
factCurrentUser: 'Текущий пользователь',
|
||||
factTenant: 'Магазин',
|
||||
quickActionsDescription: 'Самые частые задачи — начните здесь',
|
||||
actionAddProduct: 'Добавить товар',
|
||||
actionAddProductDescription: 'Создать новую карточку товара',
|
||||
actionCreateCategory: 'Создать категорию',
|
||||
actionCreateCategoryDescription: 'Добавить категорию в каталог',
|
||||
actionEditProjectDescription: 'Открыть конструктор маркетплейса',
|
||||
actionEditHomepage: 'Редактировать главную',
|
||||
actionEditHomepageDescription: 'Настроить виджеты главной страницы',
|
||||
actionMediaLibraryDescription: 'Загрузить и организовать файлы',
|
||||
actionOrdersDescription: 'Просмотреть последние заказы',
|
||||
draftStatusTitle: 'Статус черновика',
|
||||
draftStatusHasChanges: 'Есть несохранённые изменения, ожидающие публикации.',
|
||||
draftStatusAllPublished: 'Всё опубликовано. Черновика нет.',
|
||||
draftStatusMoreFields: 'ещё {{count}}',
|
||||
draftOpenBuilder: 'Открыть конструктор',
|
||||
draftPublish: 'Опубликовать',
|
||||
draftDiscard: 'Отменить черновик',
|
||||
healthProductsCount: 'Товары',
|
||||
healthCategoriesCount: 'Категории',
|
||||
healthDraftExists: 'Черновик',
|
||||
healthImagesWithoutAlt: 'Изображения без alt-текста',
|
||||
healthStaticPagesUnpublished: 'Неопубликованные статические страницы',
|
||||
healthHomepageConfigured: 'Главная страница настроена',
|
||||
healthThemeConfigured: 'Тема настроена',
|
||||
statusHealthy: 'В порядке',
|
||||
statusAttention: 'Требует внимания',
|
||||
statusUnhealthy: 'Проблема',
|
||||
statusUnknown: 'Пока не отслеживается',
|
||||
activityEmptyHint: 'Сохраните или опубликуйте изменение, чтобы увидеть его здесь.',
|
||||
activityUnavailable: 'История активности недоступна',
|
||||
activityUnavailableHint: 'Не удалось подключиться к источнику данных. Попробуйте обновить страницу позже.',
|
||||
activityErrorHint: 'Не удалось загрузить недавнюю активность.',
|
||||
shortcutsTitle: 'Полезные ссылки',
|
||||
shortcutsDescription: 'Часто используемые разделы админ-панели',
|
||||
shortcutMarketplaceBuilder: 'Конструктор маркетплейса',
|
||||
shortcutSettings: 'Настройки',
|
||||
shortcutContent: 'Контент',
|
||||
docTitle: 'Документация',
|
||||
docDocumentation: 'Документация',
|
||||
docKeyboardShortcuts: 'Горячие клавиши',
|
||||
docReportIssue: 'Сообщить о проблеме',
|
||||
docReleaseNotes: 'Список изменений',
|
||||
},
|
||||
staticPages: {
|
||||
notFound: 'Страница не найдена',
|
||||
|
||||
@@ -732,6 +732,54 @@ export interface Translations {
|
||||
comingSoonTitle: string;
|
||||
comingSoonDescription: string;
|
||||
backToDashboard: string;
|
||||
welcomeEyebrow: string;
|
||||
welcomeFallbackTenant: string;
|
||||
envProduction: string;
|
||||
envDevelopment: string;
|
||||
factCurrentUser: string;
|
||||
factTenant: string;
|
||||
quickActionsDescription: string;
|
||||
actionAddProduct: string;
|
||||
actionAddProductDescription: string;
|
||||
actionCreateCategory: string;
|
||||
actionCreateCategoryDescription: string;
|
||||
actionEditProjectDescription: string;
|
||||
actionEditHomepage: string;
|
||||
actionEditHomepageDescription: string;
|
||||
actionMediaLibraryDescription: string;
|
||||
actionOrdersDescription: string;
|
||||
draftStatusTitle: string;
|
||||
draftStatusHasChanges: string;
|
||||
draftStatusAllPublished: string;
|
||||
draftStatusMoreFields: string;
|
||||
draftOpenBuilder: string;
|
||||
draftPublish: string;
|
||||
draftDiscard: string;
|
||||
healthProductsCount: string;
|
||||
healthCategoriesCount: string;
|
||||
healthDraftExists: string;
|
||||
healthImagesWithoutAlt: string;
|
||||
healthStaticPagesUnpublished: string;
|
||||
healthHomepageConfigured: string;
|
||||
healthThemeConfigured: string;
|
||||
statusHealthy: string;
|
||||
statusAttention: string;
|
||||
statusUnhealthy: string;
|
||||
statusUnknown: string;
|
||||
activityEmptyHint: string;
|
||||
activityUnavailable: string;
|
||||
activityUnavailableHint: string;
|
||||
activityErrorHint: string;
|
||||
shortcutsTitle: string;
|
||||
shortcutsDescription: string;
|
||||
shortcutMarketplaceBuilder: string;
|
||||
shortcutSettings: string;
|
||||
shortcutContent: string;
|
||||
docTitle: string;
|
||||
docDocumentation: string;
|
||||
docKeyboardShortcuts: string;
|
||||
docReportIssue: string;
|
||||
docReleaseNotes: string;
|
||||
};
|
||||
staticPages: {
|
||||
notFound: string;
|
||||
|
||||
Reference in New Issue
Block a user