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:
@@ -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: 'transactions', icon: 'creditCard', labelKey: 'adminShell.nav.transactions', path: ['transactions'] },
|
||||
{ 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: 'group', labelKey: 'adminShell.nav.partnersGroup' },
|
||||
{ type: 'link', id: 'seller-management', icon: 'store', labelKey: 'adminShell.nav.sellerManagement', path: ['partners', 'seller-management'] },
|
||||
|
||||
Reference in New Issue
Block a user