diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index cc229b7..57118fd 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -191,6 +191,15 @@ const coreRoutes: Routes = [ 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', loadComponent: () => import('./features/admin/moderation/pages/admin-reviews-list-page.component').then(m => m.AdminReviewsListPageComponent), diff --git a/src/app/features/admin/notifications/facade/admin-notifications.facade.ts b/src/app/features/admin/notifications/facade/admin-notifications.facade.ts new file mode 100644 index 0000000..9157e19 --- /dev/null +++ b/src/app/features/admin/notifications/facade/admin-notifications.facade.ts @@ -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({ marketplaceId: '', eventType: 'all', unreadOnly: false }); + readonly notifications = signal([]); + 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): 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()); + } +} diff --git a/src/app/features/admin/notifications/models/admin-notification.model.ts b/src/app/features/admin/notifications/models/admin-notification.model.ts new file mode 100644 index 0000000..a361e21 --- /dev/null +++ b/src/app/features/admin/notifications/models/admin-notification.model.ts @@ -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; +} diff --git a/src/app/features/admin/notifications/pages/admin-notifications-page.component.html b/src/app/features/admin/notifications/pages/admin-notifications-page.component.html new file mode 100644 index 0000000..3724f2c --- /dev/null +++ b/src/app/features/admin/notifications/pages/admin-notifications-page.component.html @@ -0,0 +1,38 @@ +
+
+
+

Notifications

+

Mock-gateway backed until docs/backend/PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md ships.

+
+ Mark all read +
+ +
+ + +
+ + @if (facade.notifications().length === 0 && !facade.loading()) { + + } @else { +
    + @for (n of facade.notifications(); track n.id) { +
  • + {{ n.severity }} + {{ n.title }} + {{ n.createdAt | date: 'short' }} + @if (!n.read) { + + } +
  • + } +
+ } +
diff --git a/src/app/features/admin/notifications/pages/admin-notifications-page.component.scss b/src/app/features/admin/notifications/pages/admin-notifications-page.component.scss new file mode 100644 index 0000000..f80b209 --- /dev/null +++ b/src/app/features/admin/notifications/pages/admin-notifications-page.component.scss @@ -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; + } +} diff --git a/src/app/features/admin/notifications/pages/admin-notifications-page.component.ts b/src/app/features/admin/notifications/pages/admin-notifications-page.component.ts new file mode 100644 index 0000000..40eefa7 --- /dev/null +++ b/src/app/features/admin/notifications/pages/admin-notifications-page.component.ts @@ -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(); + } +} diff --git a/src/app/features/admin/notifications/services/admin-notifications-gateway.interface.ts b/src/app/features/admin/notifications/services/admin-notifications-gateway.interface.ts new file mode 100644 index 0000000..31996da --- /dev/null +++ b/src/app/features/admin/notifications/services/admin-notifications-gateway.interface.ts @@ -0,0 +1,8 @@ +import { Observable } from 'rxjs'; +import { AdminNotification, AdminNotificationFilters } from '../models/admin-notification.model'; + +export interface AdminNotificationsGateway { + loadNotifications(filters: AdminNotificationFilters): Observable; + markRead(id: string): Observable; + markAllRead(): Observable; +} diff --git a/src/app/features/admin/notifications/services/admin-notifications-gateway.token.ts b/src/app/features/admin/notifications/services/admin-notifications-gateway.token.ts new file mode 100644 index 0000000..5d7ecb9 --- /dev/null +++ b/src/app/features/admin/notifications/services/admin-notifications-gateway.token.ts @@ -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('ADMIN_NOTIFICATIONS_GATEWAY', { + providedIn: 'root', + factory: () => inject(AdminNotificationsLocalGateway), +}); diff --git a/src/app/features/admin/notifications/services/admin-notifications-local.gateway.ts b/src/app/features/admin/notifications/services/admin-notifications-local.gateway.ts new file mode 100644 index 0000000..50e6404 --- /dev/null +++ b/src/app/features/admin/notifications/services/admin-notifications-local.gateway.ts @@ -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(); + + loadNotifications(filters: AdminNotificationFilters): Observable { + 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 { + const orderId = id.replace(/^notif_/, ''); + this.readIds.add(orderId); + return of(void 0); + } + + markAllRead(): Observable { + return this.loadNotifications({ marketplaceId: '', eventType: '', unreadOnly: false }).pipe( + map(items => { items.forEach(n => this.readIds.add(n.entityId)); }) + ); + } +} diff --git a/src/app/features/admin/shell/admin-nav.model.ts b/src/app/features/admin/shell/admin-nav.model.ts index bd474a8..e19f198 100644 --- a/src/app/features/admin/shell/admin-nav.model.ts +++ b/src/app/features/admin/shell/admin-nav.model.ts @@ -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'] }, diff --git a/src/app/i18n/en.ts b/src/app/i18n/en.ts index 76ab6b0..488a88b 100644 --- a/src/app/i18n/en.ts +++ b/src/app/i18n/en.ts @@ -2059,6 +2059,7 @@ export const en: Translations = { customers: 'Customers', transactions: 'Transactions', moderation: 'Reviews & Moderation', + notifications: 'Notifications', reviews: 'Reviews', reports: 'Reports', partnersGroup: 'Partners', diff --git a/src/app/i18n/hy.ts b/src/app/i18n/hy.ts index bfc3278..5e6bb3f 100644 --- a/src/app/i18n/hy.ts +++ b/src/app/i18n/hy.ts @@ -2053,6 +2053,7 @@ export const hy: Translations = { orders: 'Պատվերներ', customers: 'Հաճախորդներ', moderation: 'Կարծիքներ և մոդերացիա', + notifications: 'Ծանուցումներ', transactions: 'Գործարքներ', reviews: 'Կարծիքներ', reports: 'Հաշվետվություններ', diff --git a/src/app/i18n/ru.ts b/src/app/i18n/ru.ts index 6a4fe23..29e9c91 100644 --- a/src/app/i18n/ru.ts +++ b/src/app/i18n/ru.ts @@ -2053,6 +2053,7 @@ export const ru: Translations = { orders: 'Заказы', customers: 'Клиенты', moderation: 'Отзывы и модерация', + notifications: 'Уведомления', transactions: 'Транзакции', reviews: 'Отзывы', reports: 'Отчёты', diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts index cf51603..e6b88f3 100644 --- a/src/app/i18n/translations.ts +++ b/src/app/i18n/translations.ts @@ -2068,6 +2068,7 @@ export interface Translations { transactions: string; reviews: string; moderation: string; + notifications: string; reports: string; partnersGroup: string; sellerManagement: string;