feat: Phase 4 frontend - Integrations backoffice section (mock-gateway backed)
New core/integrations (Connector/DeadLetterEntry models + gateway/token) and features/admin/integrations (facade + page: connector table with status/lag/errors/backlog/unmatched, pause/resume). Seeded empty per Sprint 0.1's "no fixed partner list" decision - the section is ready to populate the moment the first real connector is onboarded against docs/backend/PHASE-4-CONNECTOR-FRAMEWORK-CONTRACT.md §7. New /backoffice/ integrations route + nav entry, nav i18n key in all 3 languages. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -200,6 +200,15 @@ const coreRoutes: Routes = [
|
|||||||
breadcrumb: [{ labelKey: 'adminShell.nav.notifications' }]
|
breadcrumb: [{ labelKey: 'adminShell.nav.notifications' }]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'integrations',
|
||||||
|
loadComponent: () => import('./features/admin/integrations/pages/admin-integrations-page.component').then(m => m.AdminIntegrationsPageComponent),
|
||||||
|
data: {
|
||||||
|
titleKey: 'adminShell.nav.integrations',
|
||||||
|
descriptionKey: 'adminShell.nav.integrations',
|
||||||
|
breadcrumb: [{ labelKey: 'adminShell.nav.integrations' }]
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
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),
|
||||||
|
|||||||
21
src/app/core/integrations/models/connector.model.ts
Normal file
21
src/app/core/integrations/models/connector.model.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/** Per docs/backend/PHASE-4-CONNECTOR-FRAMEWORK-CONTRACT.md §2. */
|
||||||
|
export interface Connector {
|
||||||
|
id: string;
|
||||||
|
marketplaceId: string;
|
||||||
|
provider: string;
|
||||||
|
authType: 'webhook_signed' | 'api_key' | 'oauth2';
|
||||||
|
status: 'active' | 'paused' | 'error';
|
||||||
|
lastSuccessAt?: string;
|
||||||
|
lagSeconds?: number;
|
||||||
|
errorCount: number;
|
||||||
|
backlogCount: number;
|
||||||
|
unmatchedCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeadLetterEntry {
|
||||||
|
id: string;
|
||||||
|
connectorId: string;
|
||||||
|
reason: string;
|
||||||
|
retryCount: number;
|
||||||
|
lastAttemptAt: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { Connector, DeadLetterEntry } from '../models/connector.model';
|
||||||
|
|
||||||
|
export interface ConnectorGateway {
|
||||||
|
loadConnectors(): Observable<Connector[]>;
|
||||||
|
loadDeadLetter(connectorId: string): Observable<DeadLetterEntry[]>;
|
||||||
|
replay(deadLetterId: string): Observable<void>;
|
||||||
|
pause(connectorId: string): Observable<void>;
|
||||||
|
resume(connectorId: string): Observable<void>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { InjectionToken, inject } from '@angular/core';
|
||||||
|
import { ConnectorGateway } from './connector-gateway.interface';
|
||||||
|
import { ConnectorLocalGateway } from './connector-local.gateway';
|
||||||
|
|
||||||
|
/** Swap point for docs/backend/PHASE-4-CONNECTOR-FRAMEWORK-CONTRACT.md §7. */
|
||||||
|
export const CONNECTOR_GATEWAY = new InjectionToken<ConnectorGateway>('CONNECTOR_GATEWAY', {
|
||||||
|
providedIn: 'root',
|
||||||
|
factory: () => inject(ConnectorLocalGateway),
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { Observable, of } from 'rxjs';
|
||||||
|
import { Connector, DeadLetterEntry } from '../models/connector.model';
|
||||||
|
import { ConnectorGateway } from './connector-gateway.interface';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No connector exists in real life yet (Sprint 0.1: no fixed partner list -
|
||||||
|
* connectors onboard as partners arrive). Seeded with zero rows, ready to
|
||||||
|
* light up as soon as an admin onboards the first real connector via
|
||||||
|
* docs/backend/PHASE-4-CONNECTOR-FRAMEWORK-CONTRACT.md §7.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ConnectorLocalGateway implements ConnectorGateway {
|
||||||
|
private connectors: Connector[] = [];
|
||||||
|
|
||||||
|
loadConnectors(): Observable<Connector[]> {
|
||||||
|
return of(this.connectors);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadDeadLetter(_connectorId: string): Observable<DeadLetterEntry[]> {
|
||||||
|
return of([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
replay(_deadLetterId: string): Observable<void> {
|
||||||
|
return of(void 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
pause(connectorId: string): Observable<void> {
|
||||||
|
this.connectors = this.connectors.map(c => c.id === connectorId ? { ...c, status: 'paused' } : c);
|
||||||
|
return of(void 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
resume(connectorId: string): Observable<void> {
|
||||||
|
this.connectors = this.connectors.map(c => c.id === connectorId ? { ...c, status: 'active' } : c);
|
||||||
|
return of(void 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Injectable, inject, signal } from '@angular/core';
|
||||||
|
import { take } from 'rxjs/operators';
|
||||||
|
import { Connector } from '../../../../core/integrations/models/connector.model';
|
||||||
|
import { CONNECTOR_GATEWAY } from '../../../../core/integrations/services/connector-gateway.token';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AdminIntegrationsFacade {
|
||||||
|
private readonly gateway = inject(CONNECTOR_GATEWAY);
|
||||||
|
|
||||||
|
readonly connectors = signal<Connector[]>([]);
|
||||||
|
readonly loading = signal(false);
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.gateway.loadConnectors().pipe(take(1)).subscribe(items => {
|
||||||
|
this.connectors.set(items);
|
||||||
|
this.loading.set(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pause(id: string): void {
|
||||||
|
this.gateway.pause(id).pipe(take(1)).subscribe(() => this.load());
|
||||||
|
}
|
||||||
|
|
||||||
|
resume(id: string): void {
|
||||||
|
this.gateway.resume(id).pipe(take(1)).subscribe(() => this.load());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<div class="integrations-page">
|
||||||
|
<header>
|
||||||
|
<h1>Integrations</h1>
|
||||||
|
<p>External marketplace connectors, payment providers, FX sources, messaging. No fixed partner list - connectors onboard as partners arrive (docs/backend/PHASE-4-CONNECTOR-FRAMEWORK-CONTRACT.md).</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
@if (facade.connectors().length === 0 && !facade.loading()) {
|
||||||
|
<app-empty-state title="No connectors yet" description="Onboard the first partner connector to see it here." />
|
||||||
|
} @else {
|
||||||
|
<table class="integrations-page__table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Provider</th><th>Status</th><th>Last success</th><th>Lag</th><th>Errors</th><th>Backlog</th><th>Unmatched</th><th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (c of facade.connectors(); track c.id) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ c.provider }}</td>
|
||||||
|
<td><app-badge [variant]="c.status === 'active' ? 'success' : c.status === 'error' ? 'danger' : 'neutral'">{{ c.status }}</app-badge></td>
|
||||||
|
<td>{{ c.lastSuccessAt || '-' }}</td>
|
||||||
|
<td>{{ c.lagSeconds ?? '-' }}s</td>
|
||||||
|
<td>{{ c.errorCount }}</td>
|
||||||
|
<td>{{ c.backlogCount }}</td>
|
||||||
|
<td>{{ c.unmatchedCount }}</td>
|
||||||
|
<td>
|
||||||
|
@if (c.status === 'active') {
|
||||||
|
<app-button variant="secondary" (click)="facade.pause(c.id)">Pause</app-button>
|
||||||
|
} @else {
|
||||||
|
<app-button variant="secondary" (click)="facade.resume(c.id)">Resume</app-button>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
.integrations-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
&__table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { AdminIntegrationsFacade } from '../facade/admin-integrations.facade';
|
||||||
|
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-integrations-page',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, BadgeComponent, ButtonComponent, EmptyStateComponent],
|
||||||
|
templateUrl: './admin-integrations-page.component.html',
|
||||||
|
styleUrls: ['./admin-integrations-page.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class AdminIntegrationsPageComponent {
|
||||||
|
readonly facade = inject(AdminIntegrationsFacade);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.facade.load();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ export const ADMIN_NAV_PRIMARY: AdminNavEntry[] = [
|
|||||||
{ 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: 'notifications', icon: 'bell', labelKey: 'adminShell.nav.notifications', path: ['notifications'] },
|
||||||
|
{ type: 'link', id: 'integrations', icon: 'network', labelKey: 'adminShell.nav.integrations', path: ['integrations'] },
|
||||||
{ 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'] },
|
||||||
|
|||||||
@@ -2060,6 +2060,7 @@ export const en: Translations = {
|
|||||||
transactions: 'Transactions',
|
transactions: 'Transactions',
|
||||||
moderation: 'Reviews & Moderation',
|
moderation: 'Reviews & Moderation',
|
||||||
notifications: 'Notifications',
|
notifications: 'Notifications',
|
||||||
|
integrations: 'Integrations',
|
||||||
reviews: 'Reviews',
|
reviews: 'Reviews',
|
||||||
reports: 'Reports',
|
reports: 'Reports',
|
||||||
partnersGroup: 'Partners',
|
partnersGroup: 'Partners',
|
||||||
|
|||||||
@@ -2054,6 +2054,7 @@ export const hy: Translations = {
|
|||||||
customers: 'Հաճախորդներ',
|
customers: 'Հաճախորդներ',
|
||||||
moderation: 'Կարծիքներ և մոդերացիա',
|
moderation: 'Կարծիքներ և մոդերացիա',
|
||||||
notifications: 'Ծանուցումներ',
|
notifications: 'Ծանուցումներ',
|
||||||
|
integrations: 'Ինտեգրումներ',
|
||||||
transactions: 'Գործարքներ',
|
transactions: 'Գործարքներ',
|
||||||
reviews: 'Կարծիքներ',
|
reviews: 'Կարծիքներ',
|
||||||
reports: 'Հաշվետվություններ',
|
reports: 'Հաշվետվություններ',
|
||||||
|
|||||||
@@ -2054,6 +2054,7 @@ export const ru: Translations = {
|
|||||||
customers: 'Клиенты',
|
customers: 'Клиенты',
|
||||||
moderation: 'Отзывы и модерация',
|
moderation: 'Отзывы и модерация',
|
||||||
notifications: 'Уведомления',
|
notifications: 'Уведомления',
|
||||||
|
integrations: 'Интеграции',
|
||||||
transactions: 'Транзакции',
|
transactions: 'Транзакции',
|
||||||
reviews: 'Отзывы',
|
reviews: 'Отзывы',
|
||||||
reports: 'Отчёты',
|
reports: 'Отчёты',
|
||||||
|
|||||||
@@ -2069,6 +2069,7 @@ export interface Translations {
|
|||||||
reviews: string;
|
reviews: string;
|
||||||
moderation: string;
|
moderation: string;
|
||||||
notifications: string;
|
notifications: string;
|
||||||
|
integrations: string;
|
||||||
reports: string;
|
reports: string;
|
||||||
partnersGroup: string;
|
partnersGroup: string;
|
||||||
sellerManagement: string;
|
sellerManagement: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user