feat: wire order watcher into admin topbar bell (badge + panel)
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -148,10 +148,28 @@
|
|||||||
(click)="toggleNotifications()"
|
(click)="toggleNotifications()"
|
||||||
>
|
>
|
||||||
<app-icon name="bell" [size]="18" />
|
<app-icon name="bell" [size]="18" />
|
||||||
|
@if (unreadCount() > 0) {
|
||||||
|
<span class="admin-layout__notifications-badge">{{ unreadCount() }}</span>
|
||||||
|
}
|
||||||
</button>
|
</button>
|
||||||
@if (notificationsOpen()) {
|
@if (notificationsOpen()) {
|
||||||
<div class="admin-layout__notifications-panel" role="menu">
|
<div class="admin-layout__notifications-panel" role="menu">
|
||||||
<p>{{ 'adminShell.topbar.notificationsEmpty' | translate }}</p>
|
@if (recentOrders().length === 0) {
|
||||||
|
<p>{{ 'adminShell.topbar.notificationsEmpty' | translate }}</p>
|
||||||
|
} @else {
|
||||||
|
@for (order of recentOrders(); track order.id) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="admin-layout__notification-item"
|
||||||
|
role="menuitem"
|
||||||
|
(click)="goToOrder(order.id)"
|
||||||
|
>
|
||||||
|
<span class="admin-layout__notification-order">#{{ order.orderNumber }}</span>
|
||||||
|
<span class="admin-layout__notification-customer">{{ order.customer.name }}</span>
|
||||||
|
<span class="admin-layout__notification-amount">{{ order.total }} {{ order.currency }}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -347,6 +347,21 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-layout__notifications-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
right: 2px;
|
||||||
|
min-width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-danger, #ef4444);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-layout__notifications-panel {
|
.admin-layout__notifications-panel {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
right: 0;
|
||||||
@@ -366,6 +381,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-layout__notification-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-sm, 4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-layout__notification-item:hover {
|
||||||
|
background: var(--bg-secondary, #f4f6f5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-layout__notification-order { font-weight: var(--font-weight-medium, 500); }
|
||||||
|
.admin-layout__notification-customer { color: var(--text-secondary, #6b7280); font-size: var(--font-size-sm, 0.8125rem); }
|
||||||
|
.admin-layout__notification-amount { font-size: var(--font-size-sm, 0.8125rem); }
|
||||||
|
|
||||||
.admin-layout__account {
|
.admin-layout__account {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
76
src/app/features/admin/shell/admin-layout.component.spec.ts
Normal file
76
src/app/features/admin/shell/admin-layout.component.spec.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { signal } from '@angular/core';
|
||||||
|
import { AdminLayoutComponent } from './admin-layout.component';
|
||||||
|
import { AdminOrderWatcherService } from './services/admin-order-watcher.service';
|
||||||
|
import { AdminOrder } from '../orders/models/admin-order.model';
|
||||||
|
|
||||||
|
function makeOrder(id: string, orderNumber: string): AdminOrder {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
orderNumber,
|
||||||
|
status: 'pending',
|
||||||
|
customer: { name: 'Test Customer', email: 't@example.com', phone: '' },
|
||||||
|
payment: { method: 'card', status: 'paid', amount: 500, currency: 'RUB' },
|
||||||
|
shipping: { address: '', method: '', trackingNumber: '' },
|
||||||
|
items: [],
|
||||||
|
total: 500,
|
||||||
|
currency: 'RUB',
|
||||||
|
notes: '',
|
||||||
|
internalNotes: '',
|
||||||
|
timeline: [],
|
||||||
|
archived: false,
|
||||||
|
createdAt: '2026-08-15T10:00:00.000Z',
|
||||||
|
updatedAt: '2026-08-15T10:00:00.000Z',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AdminLayoutComponent notifications bell', () => {
|
||||||
|
let watcherStub: {
|
||||||
|
recentOrders: ReturnType<typeof signal<AdminOrder[]>>;
|
||||||
|
unreadCount: ReturnType<typeof signal<number>>;
|
||||||
|
start: jasmine.Spy;
|
||||||
|
markAllSeen: jasmine.Spy;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
watcherStub = {
|
||||||
|
recentOrders: signal<AdminOrder[]>([makeOrder('o1', '1001')]),
|
||||||
|
unreadCount: signal(1),
|
||||||
|
start: jasmine.createSpy('start'),
|
||||||
|
markAllSeen: jasmine.createSpy('markAllSeen'),
|
||||||
|
};
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [AdminLayoutComponent],
|
||||||
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
{ provide: AdminOrderWatcherService, useValue: watcherStub },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts the watcher once on construction', () => {
|
||||||
|
TestBed.createComponent(AdminLayoutComponent);
|
||||||
|
expect(watcherStub.start).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes unreadCount and recentOrders from the watcher', () => {
|
||||||
|
const fixture = TestBed.createComponent(AdminLayoutComponent);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
expect(component.unreadCount()).toBe(1);
|
||||||
|
expect(component.recentOrders().map(o => o.id)).toEqual(['o1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks orders seen when the notifications panel opens', () => {
|
||||||
|
const fixture = TestBed.createComponent(AdminLayoutComponent);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
component.toggleNotifications();
|
||||||
|
expect(component.notificationsOpen()).toBe(true);
|
||||||
|
expect(watcherStub.markAllSeen).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
component.toggleNotifications();
|
||||||
|
expect(component.notificationsOpen()).toBe(false);
|
||||||
|
expect(watcherStub.markAllSeen).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,6 +12,7 @@ import { IconComponent } from '../../../shared/ui/icon/icon.component';
|
|||||||
import { AdminPreferencesService } from '../settings/services/admin-preferences.service';
|
import { AdminPreferencesService } from '../settings/services/admin-preferences.service';
|
||||||
import { UiRuntimeFacade } from '../../../facades/runtime/ui-runtime.facade';
|
import { UiRuntimeFacade } from '../../../facades/runtime/ui-runtime.facade';
|
||||||
import { ConfigService } from '../../../core/config/config.service';
|
import { ConfigService } from '../../../core/config/config.service';
|
||||||
|
import { AdminOrderWatcherService } from './services/admin-order-watcher.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-admin-layout',
|
selector: 'app-admin-layout',
|
||||||
@@ -31,6 +32,10 @@ export class AdminLayoutComponent {
|
|||||||
private readonly preferences = inject(AdminPreferencesService);
|
private readonly preferences = inject(AdminPreferencesService);
|
||||||
private readonly uiRuntime = inject(UiRuntimeFacade);
|
private readonly uiRuntime = inject(UiRuntimeFacade);
|
||||||
private readonly configService = inject(ConfigService);
|
private readonly configService = inject(ConfigService);
|
||||||
|
private readonly orderWatcher = inject(AdminOrderWatcherService);
|
||||||
|
|
||||||
|
readonly unreadCount = this.orderWatcher.unreadCount;
|
||||||
|
readonly recentOrders = this.orderWatcher.recentOrders;
|
||||||
|
|
||||||
readonly density = this.preferences.density;
|
readonly density = this.preferences.density;
|
||||||
|
|
||||||
@@ -87,6 +92,7 @@ export class AdminLayoutComponent {
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.readRouteData();
|
this.readRouteData();
|
||||||
|
this.orderWatcher.start();
|
||||||
this.router.events
|
this.router.events
|
||||||
.pipe(
|
.pipe(
|
||||||
filter(event => event instanceof NavigationEnd),
|
filter(event => event instanceof NavigationEnd),
|
||||||
@@ -130,6 +136,9 @@ export class AdminLayoutComponent {
|
|||||||
|
|
||||||
toggleNotifications(): void {
|
toggleNotifications(): void {
|
||||||
this.notificationsOpen.update(open => !open);
|
this.notificationsOpen.update(open => !open);
|
||||||
|
if (this.notificationsOpen()) {
|
||||||
|
this.orderWatcher.markAllSeen();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
adminLinkFor(entry: Extract<AdminNavEntry, { type: 'link' }>): string[] {
|
adminLinkFor(entry: Extract<AdminNavEntry, { type: 'link' }>): string[] {
|
||||||
@@ -140,6 +149,11 @@ export class AdminLayoutComponent {
|
|||||||
return ['/', lang, 'backoffice', ...(entry.path ?? [])];
|
return ['/', lang, 'backoffice', ...(entry.path ?? [])];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
goToOrder(orderId: string): void {
|
||||||
|
void this.router.navigate([this.currentLang(), 'backoffice', 'orders', orderId]);
|
||||||
|
this.notificationsOpen.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
breadcrumbLink(entry: AdminBreadcrumbEntry): string[] {
|
breadcrumbLink(entry: AdminBreadcrumbEntry): string[] {
|
||||||
return ['/', this.currentLang(), 'backoffice', ...(entry.path ?? [])];
|
return ['/', this.currentLang(), 'backoffice', ...(entry.path ?? [])];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user