diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 57118fd..85e23b0 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -200,6 +200,15 @@ const coreRoutes: Routes = [ 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', loadComponent: () => import('./features/admin/moderation/pages/admin-reviews-list-page.component').then(m => m.AdminReviewsListPageComponent), diff --git a/src/app/core/integrations/models/connector.model.ts b/src/app/core/integrations/models/connector.model.ts new file mode 100644 index 0000000..6a5eec3 --- /dev/null +++ b/src/app/core/integrations/models/connector.model.ts @@ -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; +} diff --git a/src/app/core/integrations/services/connector-gateway.interface.ts b/src/app/core/integrations/services/connector-gateway.interface.ts new file mode 100644 index 0000000..33f0935 --- /dev/null +++ b/src/app/core/integrations/services/connector-gateway.interface.ts @@ -0,0 +1,10 @@ +import { Observable } from 'rxjs'; +import { Connector, DeadLetterEntry } from '../models/connector.model'; + +export interface ConnectorGateway { + loadConnectors(): Observable; + loadDeadLetter(connectorId: string): Observable; + replay(deadLetterId: string): Observable; + pause(connectorId: string): Observable; + resume(connectorId: string): Observable; +} diff --git a/src/app/core/integrations/services/connector-gateway.token.ts b/src/app/core/integrations/services/connector-gateway.token.ts new file mode 100644 index 0000000..8b97d29 --- /dev/null +++ b/src/app/core/integrations/services/connector-gateway.token.ts @@ -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('CONNECTOR_GATEWAY', { + providedIn: 'root', + factory: () => inject(ConnectorLocalGateway), +}); diff --git a/src/app/core/integrations/services/connector-local.gateway.ts b/src/app/core/integrations/services/connector-local.gateway.ts new file mode 100644 index 0000000..aa1cf80 --- /dev/null +++ b/src/app/core/integrations/services/connector-local.gateway.ts @@ -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 { + return of(this.connectors); + } + + loadDeadLetter(_connectorId: string): Observable { + return of([]); + } + + replay(_deadLetterId: string): Observable { + return of(void 0); + } + + pause(connectorId: string): Observable { + this.connectors = this.connectors.map(c => c.id === connectorId ? { ...c, status: 'paused' } : c); + return of(void 0); + } + + resume(connectorId: string): Observable { + this.connectors = this.connectors.map(c => c.id === connectorId ? { ...c, status: 'active' } : c); + return of(void 0); + } +} diff --git a/src/app/features/admin/integrations/facade/admin-integrations.facade.ts b/src/app/features/admin/integrations/facade/admin-integrations.facade.ts new file mode 100644 index 0000000..0c53e56 --- /dev/null +++ b/src/app/features/admin/integrations/facade/admin-integrations.facade.ts @@ -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([]); + 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()); + } +} diff --git a/src/app/features/admin/integrations/pages/admin-integrations-page.component.html b/src/app/features/admin/integrations/pages/admin-integrations-page.component.html new file mode 100644 index 0000000..82ec4c4 --- /dev/null +++ b/src/app/features/admin/integrations/pages/admin-integrations-page.component.html @@ -0,0 +1,38 @@ +
+
+

Integrations

+

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).

+
+ + @if (facade.connectors().length === 0 && !facade.loading()) { + + } @else { + + + + + + + + @for (c of facade.connectors(); track c.id) { + + + + + + + + + + + } + +
ProviderStatusLast successLagErrorsBacklogUnmatched
{{ c.provider }}{{ c.status }}{{ c.lastSuccessAt || '-' }}{{ c.lagSeconds ?? '-' }}s{{ c.errorCount }}{{ c.backlogCount }}{{ c.unmatchedCount }} + @if (c.status === 'active') { + Pause + } @else { + Resume + } +
+ } +
diff --git a/src/app/features/admin/integrations/pages/admin-integrations-page.component.scss b/src/app/features/admin/integrations/pages/admin-integrations-page.component.scss new file mode 100644 index 0000000..22898c5 --- /dev/null +++ b/src/app/features/admin/integrations/pages/admin-integrations-page.component.scss @@ -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); + } + } +} diff --git a/src/app/features/admin/integrations/pages/admin-integrations-page.component.ts b/src/app/features/admin/integrations/pages/admin-integrations-page.component.ts new file mode 100644 index 0000000..1ad1400 --- /dev/null +++ b/src/app/features/admin/integrations/pages/admin-integrations-page.component.ts @@ -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(); + } +} diff --git a/src/app/features/admin/shell/admin-nav.model.ts b/src/app/features/admin/shell/admin-nav.model.ts index e19f198..57d0356 100644 --- a/src/app/features/admin/shell/admin-nav.model.ts +++ b/src/app/features/admin/shell/admin-nav.model.ts @@ -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: 'moderation', icon: 'star', labelKey: 'adminShell.nav.moderation', path: ['moderation'] }, { 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: '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 488a88b..5f35224 100644 --- a/src/app/i18n/en.ts +++ b/src/app/i18n/en.ts @@ -2060,6 +2060,7 @@ export const en: Translations = { transactions: 'Transactions', moderation: 'Reviews & Moderation', notifications: 'Notifications', + integrations: 'Integrations', reviews: 'Reviews', reports: 'Reports', partnersGroup: 'Partners', diff --git a/src/app/i18n/hy.ts b/src/app/i18n/hy.ts index 5e6bb3f..85b7e7b 100644 --- a/src/app/i18n/hy.ts +++ b/src/app/i18n/hy.ts @@ -2054,6 +2054,7 @@ export const hy: Translations = { customers: 'Հաճախորդներ', moderation: 'Կարծիքներ և մոդերացիա', notifications: 'Ծանուցումներ', + integrations: 'Ինտեգրումներ', transactions: 'Գործարքներ', reviews: 'Կարծիքներ', reports: 'Հաշվետվություններ', diff --git a/src/app/i18n/ru.ts b/src/app/i18n/ru.ts index 29e9c91..a2d30f1 100644 --- a/src/app/i18n/ru.ts +++ b/src/app/i18n/ru.ts @@ -2054,6 +2054,7 @@ export const ru: Translations = { customers: 'Клиенты', moderation: 'Отзывы и модерация', notifications: 'Уведомления', + integrations: 'Интеграции', transactions: 'Транзакции', reviews: 'Отзывы', reports: 'Отчёты', diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts index e6b88f3..d5cbb8f 100644 --- a/src/app/i18n/translations.ts +++ b/src/app/i18n/translations.ts @@ -2069,6 +2069,7 @@ export interface Translations { reviews: string; moderation: string; notifications: string; + integrations: string; reports: string; partnersGroup: string; sellerManagement: string;