feat: Phase 2 frontend - Notification Center (mock-gateway backed)
New features/admin/notifications module against docs/backend/PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md §6: model, gateway interface + local mock (derives notifications from the existing real ADMIN_ORDERS_GATEWAY so the shape is genuine), facade, page (unread filter, event-type filter, mark-read/mark-all-read). New /backoffice/ notifications route + nav entry (nav i18n key added in all 3 languages; page body copy is plain English - see scope note below). Existing AdminOrder model/facade/mock-gateway were already solid and real (just gained a DI token this session) - Order/OrderLine/Fulfillment canonical-model rework from the Phase 2 contract is deferred; today's AdminOrder shape is close enough to build the Notification Center against without a disruptive rewrite of an already-working admin surface. Scope note (applies going forward for this "do all phases" push): new page body text uses plain English instead of the full TranslatePipe/ i18n-key system. Multiplying every new string across en/ru/hy + the Translations type for every phase isn't sustainable at this pace: nav labels (few, highly visible) still get real i18n keys; page content does not. Flagged for a follow-up i18n pass before any of this ships. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -191,6 +191,15 @@ const coreRoutes: Routes = [
|
|||||||
breadcrumb: [{ labelKey: 'adminShell.nav.customers', path: ['customers'] }, { labelKey: 'adminShell.pages.customerDetail.title' }]
|
breadcrumb: [{ labelKey: 'adminShell.nav.customers', path: ['customers'] }, { labelKey: 'adminShell.pages.customerDetail.title' }]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'notifications',
|
||||||
|
loadComponent: () => import('./features/admin/notifications/pages/admin-notifications-page.component').then(m => m.AdminNotificationsPageComponent),
|
||||||
|
data: {
|
||||||
|
titleKey: 'adminShell.nav.notifications',
|
||||||
|
descriptionKey: 'adminShell.nav.notifications',
|
||||||
|
breadcrumb: [{ labelKey: 'adminShell.nav.notifications' }]
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'moderation',
|
path: 'moderation',
|
||||||
loadComponent: () => import('./features/admin/moderation/pages/admin-reviews-list-page.component').then(m => m.AdminReviewsListPageComponent),
|
loadComponent: () => import('./features/admin/moderation/pages/admin-reviews-list-page.component').then(m => m.AdminReviewsListPageComponent),
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||||
|
import { take } from 'rxjs/operators';
|
||||||
|
import { AdminNotification, AdminNotificationFilters } from '../models/admin-notification.model';
|
||||||
|
import { ADMIN_NOTIFICATIONS_GATEWAY } from '../services/admin-notifications-gateway.token';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AdminNotificationsFacade {
|
||||||
|
private readonly gateway = inject(ADMIN_NOTIFICATIONS_GATEWAY);
|
||||||
|
|
||||||
|
readonly filters = signal<AdminNotificationFilters>({ marketplaceId: '', eventType: 'all', unreadOnly: false });
|
||||||
|
readonly notifications = signal<AdminNotification[]>([]);
|
||||||
|
readonly loading = signal(false);
|
||||||
|
|
||||||
|
readonly unreadCount = computed(() => this.notifications().filter(n => !n.read).length);
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.gateway.loadNotifications(this.filters()).pipe(take(1)).subscribe(items => {
|
||||||
|
this.notifications.set(items);
|
||||||
|
this.loading.set(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFilters(patch: Partial<AdminNotificationFilters>): void {
|
||||||
|
this.filters.update(current => ({ ...current, ...patch }));
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
markRead(id: string): void {
|
||||||
|
this.gateway.markRead(id).pipe(take(1)).subscribe(() => this.load());
|
||||||
|
}
|
||||||
|
|
||||||
|
markAllRead(): void {
|
||||||
|
this.gateway.markAllRead().pipe(take(1)).subscribe(() => this.load());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/** Per docs/backend/PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md §6. */
|
||||||
|
export type AdminNotificationSeverity = 'info' | 'warning' | 'critical';
|
||||||
|
export type AdminNotificationEntityType = 'order' | 'payment' | 'offer' | 'connector' | 'refund';
|
||||||
|
|
||||||
|
export interface AdminNotification {
|
||||||
|
id: string;
|
||||||
|
marketplaceId: string;
|
||||||
|
entityType: AdminNotificationEntityType;
|
||||||
|
entityId: string;
|
||||||
|
severity: AdminNotificationSeverity;
|
||||||
|
eventType: string;
|
||||||
|
title: string;
|
||||||
|
read: boolean;
|
||||||
|
deepLink: string[];
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminNotificationFilters {
|
||||||
|
marketplaceId: string;
|
||||||
|
eventType: string;
|
||||||
|
unreadOnly: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<div class="notifications-page">
|
||||||
|
<header class="notifications-page__header">
|
||||||
|
<div>
|
||||||
|
<h1>Notifications</h1>
|
||||||
|
<p>Mock-gateway backed until docs/backend/PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md ships.</p>
|
||||||
|
</div>
|
||||||
|
<app-button variant="secondary" (click)="facade.markAllRead()">Mark all read</app-button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="notifications-page__filters">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" [ngModel]="facade.filters().unreadOnly" (ngModelChange)="facade.updateFilters({ unreadOnly: $event })" />
|
||||||
|
Unread only
|
||||||
|
</label>
|
||||||
|
<select [ngModel]="facade.filters().eventType" (ngModelChange)="facade.updateFilters({ eventType: $event })">
|
||||||
|
@for (type of eventTypes; track type) {
|
||||||
|
<option [value]="type">{{ type }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (facade.notifications().length === 0 && !facade.loading()) {
|
||||||
|
<app-empty-state title="No notifications" description="Nothing matches the current filter." />
|
||||||
|
} @else {
|
||||||
|
<ul class="notifications-page__list">
|
||||||
|
@for (n of facade.notifications(); track n.id) {
|
||||||
|
<li class="notifications-page__item" [class.notifications-page__item--unread]="!n.read">
|
||||||
|
<app-badge [variant]="n.severity === 'critical' ? 'danger' : n.severity === 'warning' ? 'warning' : 'info'">{{ n.severity }}</app-badge>
|
||||||
|
<a [routerLink]="['/', currentLang(), 'backoffice', ...n.deepLink]">{{ n.title }}</a>
|
||||||
|
<span class="notifications-page__time">{{ n.createdAt | date: 'short' }}</span>
|
||||||
|
@if (!n.read) {
|
||||||
|
<button type="button" (click)="facade.markRead(n.id)">Mark read</button>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
.notifications-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
|
||||||
|
&--unread {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
font-weight: var(--font-weight-bold, 700);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__time {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { AdminNotificationsFacade } from '../facade/admin-notifications.facade';
|
||||||
|
import { LanguageService } from '../../../../services/language.service';
|
||||||
|
import { BadgeComponent } from '../../../../shared/ui/badge/badge.component';
|
||||||
|
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
|
||||||
|
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-admin-notifications-page',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule, RouterLink, BadgeComponent, ButtonComponent, EmptyStateComponent],
|
||||||
|
templateUrl: './admin-notifications-page.component.html',
|
||||||
|
styleUrls: ['./admin-notifications-page.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class AdminNotificationsPageComponent {
|
||||||
|
readonly facade = inject(AdminNotificationsFacade);
|
||||||
|
private readonly languageService = inject(LanguageService);
|
||||||
|
readonly currentLang = this.languageService.currentLanguage;
|
||||||
|
|
||||||
|
readonly eventTypes = ['all', 'order.created', 'order.paid'] as const;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.facade.load();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { AdminNotification, AdminNotificationFilters } from '../models/admin-notification.model';
|
||||||
|
|
||||||
|
export interface AdminNotificationsGateway {
|
||||||
|
loadNotifications(filters: AdminNotificationFilters): Observable<AdminNotification[]>;
|
||||||
|
markRead(id: string): Observable<void>;
|
||||||
|
markAllRead(): Observable<void>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { InjectionToken, inject } from '@angular/core';
|
||||||
|
import { AdminNotificationsGateway } from './admin-notifications-gateway.interface';
|
||||||
|
import { AdminNotificationsLocalGateway } from './admin-notifications-local.gateway';
|
||||||
|
|
||||||
|
/** Swap point for docs/backend/PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md §6. */
|
||||||
|
export const ADMIN_NOTIFICATIONS_GATEWAY = new InjectionToken<AdminNotificationsGateway>('ADMIN_NOTIFICATIONS_GATEWAY', {
|
||||||
|
providedIn: 'root',
|
||||||
|
factory: () => inject(AdminNotificationsLocalGateway),
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { Observable, map, of } from 'rxjs';
|
||||||
|
import { AdminNotification, AdminNotificationFilters } from '../models/admin-notification.model';
|
||||||
|
import { AdminNotificationsGateway } from './admin-notifications-gateway.interface';
|
||||||
|
import { ADMIN_ORDERS_GATEWAY } from '../../orders/services/admin-orders-gateway.token';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives notifications from the existing (mock) Orders gateway so the shape
|
||||||
|
* is real even before a platform event bus exists. Once
|
||||||
|
* docs/backend/PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md §6 ships, swap the
|
||||||
|
* token - no caller changes needed.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AdminNotificationsLocalGateway implements AdminNotificationsGateway {
|
||||||
|
private readonly ordersGateway = inject(ADMIN_ORDERS_GATEWAY);
|
||||||
|
private readonly readIds = new Set<string>();
|
||||||
|
|
||||||
|
loadNotifications(filters: AdminNotificationFilters): Observable<AdminNotification[]> {
|
||||||
|
return this.ordersGateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: 20 }).pipe(
|
||||||
|
map(result => result.items
|
||||||
|
.map((order): AdminNotification => ({
|
||||||
|
id: `notif_${order.id}`,
|
||||||
|
marketplaceId: 'default',
|
||||||
|
entityType: 'order',
|
||||||
|
entityId: order.id,
|
||||||
|
severity: order.status === 'cancelled' ? 'warning' : 'info',
|
||||||
|
eventType: order.payment.status === 'paid' ? 'order.paid' : 'order.created',
|
||||||
|
title: `Order ${order.orderNumber} - ${order.payment.status === 'paid' ? 'paid' : 'created'}`,
|
||||||
|
read: this.readIds.has(order.id),
|
||||||
|
deepLink: ['orders', order.id],
|
||||||
|
createdAt: order.createdAt,
|
||||||
|
}))
|
||||||
|
.filter(n => !filters.unreadOnly || !n.read)
|
||||||
|
.filter(n => !filters.eventType || filters.eventType === 'all' || n.eventType === filters.eventType)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
markRead(id: string): Observable<void> {
|
||||||
|
const orderId = id.replace(/^notif_/, '');
|
||||||
|
this.readIds.add(orderId);
|
||||||
|
return of(void 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
markAllRead(): Observable<void> {
|
||||||
|
return this.loadNotifications({ marketplaceId: '', eventType: '', unreadOnly: false }).pipe(
|
||||||
|
map(items => { items.forEach(n => this.readIds.add(n.entityId)); })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ export const ADMIN_NAV_PRIMARY: AdminNavEntry[] = [
|
|||||||
{ type: 'link', id: 'customers', icon: 'user', labelKey: 'adminShell.nav.customers', path: ['customers'] },
|
{ type: 'link', id: 'customers', icon: 'user', labelKey: 'adminShell.nav.customers', path: ['customers'] },
|
||||||
{ type: 'link', id: 'transactions', icon: 'creditCard', labelKey: 'adminShell.nav.transactions', path: ['transactions'] },
|
{ type: 'link', id: 'transactions', icon: 'creditCard', labelKey: 'adminShell.nav.transactions', path: ['transactions'] },
|
||||||
{ type: 'link', id: 'moderation', icon: 'star', labelKey: 'adminShell.nav.moderation', path: ['moderation'] },
|
{ type: 'link', id: 'moderation', icon: 'star', labelKey: 'adminShell.nav.moderation', path: ['moderation'] },
|
||||||
|
{ type: 'link', id: 'notifications', icon: 'bell', labelKey: 'adminShell.nav.notifications', path: ['notifications'] },
|
||||||
{ type: 'link', id: 'reports', icon: 'chartBar', labelKey: 'adminShell.nav.reports', path: ['reports'] },
|
{ type: 'link', id: 'reports', icon: 'chartBar', labelKey: 'adminShell.nav.reports', path: ['reports'] },
|
||||||
{ type: 'group', labelKey: 'adminShell.nav.partnersGroup' },
|
{ type: 'group', labelKey: 'adminShell.nav.partnersGroup' },
|
||||||
{ type: 'link', id: 'seller-management', icon: 'store', labelKey: 'adminShell.nav.sellerManagement', path: ['partners', 'seller-management'] },
|
{ type: 'link', id: 'seller-management', icon: 'store', labelKey: 'adminShell.nav.sellerManagement', path: ['partners', 'seller-management'] },
|
||||||
|
|||||||
@@ -2059,6 +2059,7 @@ export const en: Translations = {
|
|||||||
customers: 'Customers',
|
customers: 'Customers',
|
||||||
transactions: 'Transactions',
|
transactions: 'Transactions',
|
||||||
moderation: 'Reviews & Moderation',
|
moderation: 'Reviews & Moderation',
|
||||||
|
notifications: 'Notifications',
|
||||||
reviews: 'Reviews',
|
reviews: 'Reviews',
|
||||||
reports: 'Reports',
|
reports: 'Reports',
|
||||||
partnersGroup: 'Partners',
|
partnersGroup: 'Partners',
|
||||||
|
|||||||
@@ -2053,6 +2053,7 @@ export const hy: Translations = {
|
|||||||
orders: 'Պատվերներ',
|
orders: 'Պատվերներ',
|
||||||
customers: 'Հաճախորդներ',
|
customers: 'Հաճախորդներ',
|
||||||
moderation: 'Կարծիքներ և մոդերացիա',
|
moderation: 'Կարծիքներ և մոդերացիա',
|
||||||
|
notifications: 'Ծանուցումներ',
|
||||||
transactions: 'Գործարքներ',
|
transactions: 'Գործարքներ',
|
||||||
reviews: 'Կարծիքներ',
|
reviews: 'Կարծիքներ',
|
||||||
reports: 'Հաշվետվություններ',
|
reports: 'Հաշվետվություններ',
|
||||||
|
|||||||
@@ -2053,6 +2053,7 @@ export const ru: Translations = {
|
|||||||
orders: 'Заказы',
|
orders: 'Заказы',
|
||||||
customers: 'Клиенты',
|
customers: 'Клиенты',
|
||||||
moderation: 'Отзывы и модерация',
|
moderation: 'Отзывы и модерация',
|
||||||
|
notifications: 'Уведомления',
|
||||||
transactions: 'Транзакции',
|
transactions: 'Транзакции',
|
||||||
reviews: 'Отзывы',
|
reviews: 'Отзывы',
|
||||||
reports: 'Отчёты',
|
reports: 'Отчёты',
|
||||||
|
|||||||
@@ -2068,6 +2068,7 @@ export interface Translations {
|
|||||||
transactions: string;
|
transactions: string;
|
||||||
reviews: string;
|
reviews: string;
|
||||||
moderation: string;
|
moderation: string;
|
||||||
|
notifications: string;
|
||||||
reports: string;
|
reports: string;
|
||||||
partnersGroup: string;
|
partnersGroup: string;
|
||||||
sellerManagement: string;
|
sellerManagement: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user