feat: add DCB User Service API - Authentication system with KEYCLOAK - Modular architecture with services for each feature

This commit is contained in:
diallolatoile 2025-11-20 17:53:38 +00:00
parent 7091f1665d
commit a4834002df
16 changed files with 3554 additions and 856 deletions

View File

@ -17,4 +17,3 @@ export const appConfig: ApplicationConfig = {
] ]
}; };

View File

@ -36,40 +36,35 @@ export class App implements OnInit {
try { try {
const isAuthenticated = await this.authService.initialize(); const isAuthenticated = await this.authService.initialize();
setTimeout(() => { // Attendre la vraie route après bootstrap
this.router.events
.pipe(filter(event => event instanceof NavigationEnd))
.subscribe(() => {
this.handleInitialNavigation(isAuthenticated); this.handleInitialNavigation(isAuthenticated);
}); });
} catch (error) { } catch (error) {
console.error('Error during authentication initialization:', error); console.error('Error during authentication initialization:', error);
setTimeout(() => {
this.router.navigate(['/auth/login']); this.router.navigate(['/auth/login']);
});
} }
} }
private handleInitialNavigation(isAuthenticated: boolean): void { private handleInitialNavigation(isAuthenticated: boolean): void {
const currentUrl = this.router.url; const currentUrl = this.router.url;
if (!isAuthenticated && this.shouldRedirectToLogin(currentUrl)) { // Non authentifié
if (!isAuthenticated) {
if (!this.isPublicRoute(currentUrl)) {
this.router.navigate(['/auth/login']); this.router.navigate(['/auth/login']);
} else if (isAuthenticated && this.shouldRedirectToDashboard(currentUrl)) { }
this.router.navigate(['/dcb-dashboard']); return;
} }
this.cdr.detectChanges();
}
private shouldRedirectToLogin(url: string): boolean {
return url === '/' || !this.isPublicRoute(url);
}
private shouldRedirectToDashboard(url: string): boolean {
return url === '/' || this.isPublicRoute(url);
} }
private isPublicRoute(url: string): boolean { private isPublicRoute(url: string): boolean {
const publicRoutes = ['/auth/login', '/auth/reset-password', '/auth/forgot-password']; const publicRoutes = ['/auth/login'];
return publicRoutes.some(route => url.startsWith(route)); return publicRoutes.some(route => url.startsWith(route));
} }

View File

@ -1,52 +1,44 @@
// src/app/core/guards/auth.guard.ts
import { inject } from '@angular/core'; import { inject } from '@angular/core';
import { CanActivateFn, Router, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; import { CanActivateFn, Router, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import { AuthService } from '../services/auth.service'; import { AuthService } from '../services/auth.service';
import { RoleService } from '../services/role.service'; import { RoleService } from '../services/role.service';
import { map, catchError, of, tap, switchMap } from 'rxjs'; import { map, catchError, of, switchMap, tap, filter, take } from 'rxjs';
export const authGuard: CanActivateFn = (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => { export const authGuard: CanActivateFn = (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => {
const authService = inject(AuthService); const authService = inject(AuthService);
const roleService = inject(RoleService); const roleService = inject(RoleService);
const router = inject(Router); const router = inject(Router);
// Attendre que l'initialisation du service Auth soit terminée
return authService.getInitializedState().pipe( return authService.getInitializedState().pipe(
switchMap(initialized => { filter(init => init === true),
if (!initialized) { take(1),
return of(false); switchMap(() => {
}
// 🔒 Étape 1 : Vérifier si déjà authentifié // 1) Si déjà authentifié
if (authService.isAuthenticated()) { if (authService.isAuthenticated()) {
return of(checkRoleAccess(route, roleService, router, state.url)); return of(checkRoleAccess(route, roleService, router, state.url));
} }
// 🔄 Étape 2 : Tenter un rafraîchissement du token sil existe // 2) Token expiré → essayer refresh
const refreshToken = authService.getRefreshToken(); const refreshToken = authService.getRefreshToken();
if (refreshToken) { if (refreshToken) {
return authService.refreshAccessToken().pipe( return authService.refreshAccessToken().pipe(
tap(() => { tap(() => roleService.refreshRoles()),
// Recharger les rôles après un refresh réussi
roleService.refreshRoles();
}),
map(() => checkRoleAccess(route, roleService, router, state.url)), map(() => checkRoleAccess(route, roleService, router, state.url)),
catchError(() => { catchError(() => {
// En cas déchec de refresh → déconnexion + redirection login
authService.logout().subscribe(); authService.logout().subscribe();
return of(redirectToLogin(router, state.url)); return of(redirectToLogin(router, state.url));
}) })
); );
} }
// 🚫 Étape 3 : Aucun token → redirection vers login // 3) Pas de token du tout
return of(redirectToLogin(router, state.url)); return of(redirectToLogin(router, state.url));
}), }),
catchError(() => { catchError(() => of(redirectToLogin(router, state.url)))
return of(redirectToLogin(router, state.url));
})
); );
};
}
/** /**
* Vérifie l'accès basé sur les rôles requis * Vérifie l'accès basé sur les rôles requis
@ -57,7 +49,7 @@ function checkRoleAccess(
router: Router, router: Router,
currentUrl: string currentUrl: string
): boolean { ): boolean {
const requiredRoles = route.data?.['roles'] as string[]; const requiredRoles = route.data?.['requiredRoles'] as string[];
// Si aucun rôle requis → accès autorisé // Si aucun rôle requis → accès autorisé
if (!requiredRoles || requiredRoles.length === 0) { if (!requiredRoles || requiredRoles.length === 0) {
@ -67,12 +59,10 @@ function checkRoleAccess(
const hasRequiredRole = roleService.hasAnyRole(requiredRoles); const hasRequiredRole = roleService.hasAnyRole(requiredRoles);
const currentUserRoles = roleService.getCurrentUserRoles(); const currentUserRoles = roleService.getCurrentUserRoles();
// ✅ Lutilisateur possède un des rôles requis
if (hasRequiredRole) { if (hasRequiredRole) {
return true; return true;
} }
// ❌ Sinon → rediriger vers une page "non autorisée"
router.navigate(['/unauthorized'], { router.navigate(['/unauthorized'], {
queryParams: { queryParams: {
requiredRoles: requiredRoles.join(','), requiredRoles: requiredRoles.join(','),

View File

@ -1,35 +1,29 @@
// src/app/core/guards/public.guard.ts
import { inject } from '@angular/core'; import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router'; import { CanActivateFn } from '@angular/router';
import { AuthService } from '../services/auth.service'; import { AuthService } from '../services/auth.service';
import { map, catchError, of } from 'rxjs'; import { map, catchError, of } from 'rxjs';
export const publicGuard: CanActivateFn = () => { export const publicGuard: CanActivateFn = (route, state) => {
const authService = inject(AuthService); const authService = inject(AuthService);
const router = inject(Router);
// 🔒 Si l'utilisateur est déjà authentifié → redirection vers le tableau de bord // Vérifier si un refresh token est disponible
if (authService.isAuthenticated()) {
router.navigate(['/dcb-dashboard'], { replaceUrl: true });
return false;
}
// 🔄 Vérifier si un refresh token est disponible
const refreshToken = authService.getRefreshToken(); const refreshToken = authService.getRefreshToken();
if (refreshToken) { if (refreshToken) {
return authService.refreshAccessToken().pipe( return authService.refreshAccessToken().pipe(
map(() => { map(() => {
// ✅ Rafraîchissement réussi → redirection vers le dashboard // Rafraîchissement réussi → bloquer l'accès aux pages publiques
router.navigate(['/dcb-dashboard'], { replaceUrl: true });
return false; return false;
}), }),
catchError(() => { catchError((error) => {
// ❌ Rafraîchissement échoué → autoriser laccès à la page publique // Rafraîchissement échoué → autoriser l'accès à la page publique
return of(true); return of(true);
}) })
); );
} }
// 👤 Aucun token → accès autorisé à la page publique if (authService.isAuthenticated()) {
return false;
}
return true; return true;
}; };

View File

@ -16,13 +16,6 @@ export enum UserRole {
DCB_PARTNER_SUPPORT = 'dcb-partner-support' DCB_PARTNER_SUPPORT = 'dcb-partner-support'
} }
export enum MerchantStatus {
ACTIVE = 'ACTIVE',
INACTIVE = 'INACTIVE',
PENDING = 'PENDING',
SUSPENDED = 'SUSPENDED'
}
export enum ConfigType { export enum ConfigType {
API_KEY = 'API_KEY', API_KEY = 'API_KEY',
SECRET_KEY = 'SECRET_KEY', SECRET_KEY = 'SECRET_KEY',
@ -34,30 +27,27 @@ export enum ConfigType {
} }
export enum Operator { export enum Operator {
ORANGE_CI = 1, ORANGE_OSN = 1
MTN_CI = 2,
MOOV_CI = 3,
WAVE = 4
} }
// === MODÈLES PRINCIPAUX === // === MODÈLES PRINCIPAUX ===
export interface MerchantConfig { export interface MerchantConfig {
id?: number; id?: string;
name: ConfigType | string; name: ConfigType | string;
value: string; value: string;
operatorId: Operator; operatorId: Operator | null;
merchantId?: number; merchantPartnerId?: string;
createdAt?: string; createdAt?: string;
updatedAt?: string; updatedAt?: string;
} }
export interface TechnicalContact { export interface TechnicalContact {
id?: number; id?: string;
firstName: string; firstName: string;
lastName: string; lastName: string;
phone: string; phone: string;
email: string; email: string;
merchantId?: number; merchantPartnerId?: string;
createdAt?: string; createdAt?: string;
updatedAt?: string; updatedAt?: string;
} }
@ -69,26 +59,70 @@ export interface MerchantUser {
email?: string; email?: string;
firstName?: string; firstName?: string;
lastName?: string; lastName?: string;
merchantPartnerId?: number; merchantPartnerId?: string;
} }
export interface Merchant { export interface Merchant {
id?: string;
name: string;
logo?: string;
description?: string;
adresse: string;
phone: string;
configs: MerchantConfig[];
users: MerchantUser[];
technicalContacts: TechnicalContact[];
createdAt?: string;
updatedAt?: string;
}
// Interfaces pour la réponse API (backend - types number)
export interface ApiMerchantConfig {
id?: number;
name: ConfigType | string;
value: string;
operatorId: Operator | null;
merchantPartnerId?: number;
createdAt?: string;
updatedAt?: string;
}
export interface ApiTechnicalContact {
id?: number;
firstName: string;
lastName: string;
phone: string;
email: string;
merchantPartnerId?: number;
createdAt?: string;
updatedAt?: string;
}
export interface ApiMerchantUser {
userId: string;
role: UserRole;
username?: string;
email?: string;
firstName?: string;
lastName?: string;
merchantPartnerId?: number;
}
export interface ApiMerchant {
id?: number; id?: number;
name: string; name: string;
logo?: string; logo?: string;
description?: string; description?: string;
adresse: string; adresse: string;
phone: string; phone: string;
status?: MerchantStatus; configs: ApiMerchantConfig[];
configs: MerchantConfig[]; merchantUsers: ApiMerchantUser[];
users: MerchantUser[]; technicalContacts: ApiTechnicalContact[];
technicalContacts: TechnicalContact[];
createdAt?: string; createdAt?: string;
updatedAt?: string; updatedAt?: string;
createdBy?: string;
createdByUsername?: string;
} }
// === DTOs CRUD ===
// === DTOs CRUD === // === DTOs CRUD ===
export interface CreateMerchantDto { export interface CreateMerchantDto {
name: string; name: string;
@ -96,18 +130,27 @@ export interface CreateMerchantDto {
description?: string; description?: string;
adresse: string; adresse: string;
phone: string; phone: string;
configs: Omit<MerchantConfig, 'id' | 'merchantId' | 'createdAt' | 'updatedAt'>[]; configs: Omit<MerchantConfig, 'id' | 'merchantPartnerId' | 'createdAt' | 'updatedAt'>[];
technicalContacts: Omit<TechnicalContact, 'id' | 'merchantId' | 'createdAt' | 'updatedAt'>[]; technicalContacts: Omit<TechnicalContact, 'id' | 'merchantPartnerId' | 'createdAt' | 'updatedAt'>[];
} }
export interface UpdateMerchantDto extends Partial<CreateMerchantDto> { export interface UpdateMerchantDto extends Partial<CreateMerchantDto> {}
status?: MerchantStatus;
// DTO mise à jour d'une configuration
export interface UpdateMerchantConfigDto {
name?: string;
value?: string;
operatorId?: Operator | null;
} }
export interface AddUserToMerchantDto { export interface AddUserToMerchantDto {
userId: string; userId: string;
role: UserRole; // Utilisation de vos rôles existants role: UserRole;
merchantPartnerId: number; merchantPartnerId: string;
}
export interface UpdateUserRoleDto {
role: UserRole;
} }
export interface UpdateUserRoleDto { export interface UpdateUserRoleDto {
@ -142,39 +185,15 @@ export interface MerchantStatsResponse {
// === SEARCH === // === SEARCH ===
export interface SearchMerchantsParams { export interface SearchMerchantsParams {
query?: string; query?: string;
status?: MerchantStatus;
page?: number; page?: number;
limit?: number; limit?: number;
} }
// === UTILITAIRES === // === UTILITAIRES ===
export class MerchantUtils { export class MerchantUtils {
static getStatusDisplayName(status: MerchantStatus): string {
const statusNames = {
[MerchantStatus.ACTIVE]: 'Actif',
[MerchantStatus.INACTIVE]: 'Inactif',
[MerchantStatus.PENDING]: 'En attente',
[MerchantStatus.SUSPENDED]: 'Suspendu'
};
return statusNames[status] || status;
}
static getStatusBadgeClass(status: MerchantStatus): string {
const statusClasses = {
[MerchantStatus.ACTIVE]: 'badge bg-success',
[MerchantStatus.INACTIVE]: 'badge bg-secondary',
[MerchantStatus.PENDING]: 'badge bg-warning',
[MerchantStatus.SUSPENDED]: 'badge bg-danger'
};
return statusClasses[status] || 'badge bg-secondary';
}
static getOperatorName(operatorId: Operator): string { static getOperatorName(operatorId: Operator): string {
const operatorNames = { const operatorNames = {
[Operator.ORANGE_CI]: 'Orange CI', [Operator.ORANGE_OSN]: 'Orange OSN'
[Operator.MTN_CI]: 'MTN CI',
[Operator.MOOV_CI]: 'Moov CI',
[Operator.WAVE]: 'Wave'
}; };
return operatorNames[operatorId] || 'Inconnu'; return operatorNames[operatorId] || 'Inconnu';
} }

View File

@ -69,42 +69,30 @@ export class AuthService {
* Initialise l'authentification au démarrage de l'application * Initialise l'authentification au démarrage de l'application
*/ */
async initialize(): Promise<boolean> { async initialize(): Promise<boolean> {
await new Promise(resolve => setTimeout(resolve, 0));
try {
const token = this.getAccessToken(); const token = this.getAccessToken();
// Pas de token → pas authentifié
if (!token) { if (!token) {
setTimeout(() => {
this.initialized$.next(true); this.initialized$.next(true);
});
return false; return false;
} }
// Token expiré → tenter refresh
if (this.isTokenExpired(token)) { if (this.isTokenExpired(token)) {
const refreshSuccess = await this.tryRefreshToken(); const ok = await this.tryRefreshToken();
setTimeout(() => {
this.initialized$.next(true); this.initialized$.next(true);
}); return ok;
return refreshSuccess;
} }
// Token valide : charger le profil utilisateur // Token valide → charger profil
try {
await firstValueFrom(this.loadUserProfile()); await firstValueFrom(this.loadUserProfile());
setTimeout(() => {
this.authState$.next(true); this.authState$.next(true);
this.initialized$.next(true); this.initialized$.next(true);
});
return true; return true;
} catch {
} catch (error) {
this.clearAuthData(); this.clearAuthData();
setTimeout(() => {
this.initialized$.next(true); this.initialized$.next(true);
});
return false; return false;
} }
} }
@ -252,7 +240,6 @@ export class AuthService {
lastLogin: apiUser.lastLogin || apiUser.lastLoginAt || apiUser.lastConnection || null lastLogin: apiUser.lastLogin || apiUser.lastLoginAt || apiUser.lastConnection || null
}; };
console.log('✅ Utilisateur mappé:', mappedUser);
return mappedUser; return mappedUser;
} }

View File

@ -0,0 +1,52 @@
import { AbstractControl, ValidatorFn, ValidationErrors } from '@angular/forms';
export class CustomValidators {
static phoneNumber(control: AbstractControl): ValidationErrors | null {
if (!control.value) return null;
// Format international E.164
const phoneRegex = /^\+?[1-9]\d{1,14}$/;
const cleaned = control.value.replace(/[\s\-\(\)]/g, '');
return phoneRegex.test(cleaned) ? null : { invalidPhone: true };
}
static url(control: AbstractControl): ValidationErrors | null {
if (!control.value) return null;
try {
new URL(control.value);
return null;
} catch {
return { invalidUrl: true };
}
}
static noSpecialCharacters(control: AbstractControl): ValidationErrors | null {
if (!control.value) return null;
const regex = /^[a-zA-Z0-9À-ÿ\s\-_\.]*$/;
return regex.test(control.value) ? null : { specialCharacters: true };
}
static secureUrl(control: AbstractControl): ValidationErrors | null {
if (!control.value) return null;
try {
const url = new URL(control.value);
return url.protocol === 'https:' ? null : { notSecureUrl: true };
} catch {
return { invalidUrl: true };
}
}
static uniqueEmails(emails: string[]): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const email = control.value;
if (!email) return null;
const duplicateCount = emails.filter(e => e === email).length;
return duplicateCount > 1 ? { duplicateEmail: true } : null;
};
}
}

View File

@ -0,0 +1,315 @@
<app-ui-card [title]="getCardTitle()">
<a
helper-text
href="javascript:void(0);"
class="icon-link icon-link-hover link-primary fw-semibold"
>
<ng-icon [name]="getHelperIcon()" class="me-1"></ng-icon>
{{ getHelperText() }}
</a>
<div card-body>
<!-- Barre d'actions supérieure -->
<div class="row mb-3">
<div class="col-md-6">
<div class="d-flex align-items-center gap-2">
<!-- Statistiques rapides par statut -->
<div class="btn-group btn-group-sm">
<button
type="button"
class="btn btn-outline-primary"
>
Tous ({{ getTotalMerchantsCount() }})
</button>
</div>
</div>
</div>
<div class="col-md-6">
<div class="d-flex justify-content-end gap-2">
@if (showCreateButton && canCreateMerchants) {
<button
class="btn btn-primary"
(click)="openCreateMerchantModal.emit()"
>
<ng-icon name="lucidePlus" class="me-1"></ng-icon>
Nouveau Marchand
</button>
}
<button
class="btn btn-outline-secondary"
(click)="refreshData()"
[disabled]="loading"
>
<ng-icon name="lucideRefreshCw" class="me-1" [class.spin]="loading"></ng-icon>
Actualiser
</button>
</div>
</div>
</div>
<!-- Barre de recherche et filtres avancés -->
<div class="row mb-3">
<div class="col-md-4">
<div class="input-group">
<span class="input-group-text">
<ng-icon name="lucideSearch"></ng-icon>
</span>
<input
type="text"
class="form-control"
placeholder="Rechercher par nom, adresse, téléphone..."
[(ngModel)]="searchTerm"
(input)="onSearch()"
[disabled]="loading"
>
</div>
</div>
<div class="col-md-3">
<select class="form-select" [(ngModel)]="operatorFilter" (change)="applyFiltersAndPagination()">
@for (operator of availableOperators; track operator.value) {
<option [value]="operator.value">{{ operator.label }}</option>
}
</select>
</div>
<div class="col-md-2">
<button class="btn btn-outline-secondary w-100" (click)="onClearFilters()" [disabled]="loading">
<ng-icon name="lucideX" class="me-1"></ng-icon>
Effacer les filtres
</button>
</div>
</div>
<!-- Loading State -->
@if (loading) {
<div class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
<p class="mt-2 text-muted">{{ getLoadingText() }}</p>
</div>
}
<!-- Error State -->
@if (error && !loading) {
<div class="alert alert-danger" role="alert">
<div class="d-flex align-items-center">
<ng-icon name="lucideAlertCircle" class="me-2"></ng-icon>
<div>{{ error }}</div>
<button class="btn-close ms-auto" (click)="error = ''"></button>
</div>
</div>
}
<!-- Merchants Table -->
@if (!loading && !error) {
<div class="table-responsive">
<table class="table table-hover table-striped">
<thead class="table-light">
<tr>
<th (click)="sort('name')" class="cursor-pointer">
<div class="d-flex align-items-center">
<span>Marchand</span>
<ng-icon [name]="getSortIcon('name')" class="ms-1 fs-12"></ng-icon>
</div>
</th>
<th>Description</th>
<th>Contact</th>
<th>Configurations</th>
<th>Contacts Tech</th>
<th (click)="sort('createdAt')" class="cursor-pointer">
<div class="d-flex align-items-center">
<span>Créé le</span>
<ng-icon [name]="getSortIcon('createdAt')" class="ms-1 fs-12"></ng-icon>
</div>
</th>
<th width="180">Actions</th>
</tr>
</thead>
<tbody>
@for (merchant of displayedMerchants; track merchant.id) {
<tr>
<td>
<div class="d-flex align-items-center">
@if (merchant.logo) {
<img
[src]="merchant.logo"
alt="Logo {{ merchant.name }}"
class="avatar-sm rounded-circle me-2"
onerror="this.style.display='none'"
>
}
<div class="avatar-sm bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2">
<ng-icon name="lucideStore" class="text-primary fs-12"></ng-icon>
</div>
<div>
<strong class="d-block">{{ merchant.name }}</strong>
<small class="text-muted">{{ merchant.adresse }}</small>
</div>
</div>
</td>
<td>
@if (merchant.description) {
<span class="text-muted">{{ merchant.description }}</span>
} @else {
<span class="text-muted fst-italic">Aucune description</span>
}
</td>
<td>
<div class="d-flex flex-column">
<small class="text-muted">
<ng-icon name="lucidePhone" class="me-1" size="12"></ng-icon>
{{ merchant.phone }}
</small>
@if (merchant.technicalContacts && merchant.technicalContacts.length > 0) {
<small class="text-muted">
<ng-icon name="lucideUser" class="me-1" size="12"></ng-icon>
{{ merchant.technicalContacts[0].firstName }} {{ merchant.technicalContacts[0].lastName }}
</small>
}
</div>
</td>
<td>
<div class="d-flex flex-column gap-1">
@if (merchant.configs && merchant.configs.length > 0) {
<div class="d-flex flex-wrap gap-1">
@for (config of merchant.configs.slice(0, 2); track config.id) {
<span class="badge bg-primary bg-opacity-10 text-primary border border-primary border-opacity-10">
{{ getConfigTypeLabel(config.name) }}
</span>
}
@if (merchant.configs.length > 2) {
<span class="badge bg-secondary">
+{{ merchant.configs.length - 2 }}
</span>
}
</div>
<small class="text-muted">
{{ merchant.configs.length }} configuration(s)
</small>
} @else {
<span class="badge bg-warning bg-opacity-10 text-warning">
Aucune config
</span>
}
</div>
</td>
<td>
@if (merchant.technicalContacts && merchant.technicalContacts.length > 0) {
<div class="d-flex flex-column">
<span class="badge bg-info bg-opacity-10 text-info">
{{ merchant.technicalContacts.length }} contact(s)
</span>
<small class="text-muted">
{{ merchant.technicalContacts[0].email }}
</small>
</div>
} @else {
<span class="badge bg-warning bg-opacity-10 text-warning">
Aucun contact
</span>
}
</td>
<td>
<small class="text-muted">
{{ formatTimestamp(merchant.createdAt!) }}
</small>
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<button
class="btn btn-outline-primary btn-sm"
(click)="viewMerchantProfile(merchant)"
title="Voir les détails"
>
<ng-icon name="lucideEye"></ng-icon>
</button>
<button
class="btn btn-outline-warning btn-sm"
(click)="editMerchant(merchant)"
title="Modifier le marchand"
>
<ng-icon name="lucideEdit"></ng-icon>
</button>
@if (showDeleteButton) {
<button
class="btn btn-outline-danger btn-sm"
(click)="deleteMerchant(merchant)"
title="Supprimer le marchand"
>
<ng-icon name="lucideTrash2"></ng-icon>
</button>
}
</div>
</td>
</tr>
}
@empty {
<tr>
<td [attr.colspan]="getColumnCount()" class="text-center py-4">
<div class="text-muted">
<ng-icon name="lucideStore" class="fs-1 mb-3 opacity-50"></ng-icon>
<h5 class="mb-2">{{ getEmptyStateTitle() }}</h5>
<p class="mb-3">{{ getEmptyStateDescription() }}</p>
@if (showCreateButton && canCreateMerchants) {
<button class="btn btn-primary" (click)="openCreateMerchantModal.emit()">
<ng-icon name="lucidePlus" class="me-1"></ng-icon>
{{ getEmptyStateButtonText() }}
</button>
}
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Pagination -->
@if (totalPages > 1) {
<div class="d-flex justify-content-between align-items-center mt-3">
<div class="text-muted">
Affichage de {{ getStartIndex() }} à {{ getEndIndex() }} sur {{ totalItems }} marchands
</div>
<nav>
<ngb-pagination
[collectionSize]="totalItems"
[page]="currentPage"
[pageSize]="itemsPerPage"
[maxSize]="5"
[rotate]="true"
[boundaryLinks]="true"
(pageChange)="onPageChange($event)"
/>
</nav>
</div>
}
<!-- Résumé des résultats -->
@if (displayedMerchants.length > 0) {
<div class="mt-3 pt-3 border-top">
<div class="row text-center">
<div class="col">
<small class="text-muted">
<strong>Total :</strong> {{ allMerchants.length }} marchands
</small>
</div>
<div class="col">
<small class="text-muted">
<strong>Configurations :</strong> {{ getTotalConfigsCount() }}
</small>
</div>
<div class="col">
<small class="text-muted">
<strong>Contacts :</strong> {{ getTotalContactsCount() }}
</small>
</div>
</div>
</div>
}
}
</div>
</app-ui-card>

View File

@ -0,0 +1,440 @@
import { Component, inject, OnInit, Output, EventEmitter, ChangeDetectorRef, Input, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { NgIcon } from '@ng-icons/core';
import { NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap';
import { Observable, Subject, map, of } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators';
import {
Merchant,
ConfigType,
Operator,
MerchantUtils,
} from '@core/models/merchant-config.model';
import { MerchantConfigService } from '../merchant-config.service';
import { RoleManagementService } from '@core/services/hub-users-roles-management.service';
import { AuthService } from '@core/services/auth.service';
import { UiCard } from '@app/components/ui-card';
@Component({
selector: 'app-merchant-config-list',
standalone: true,
imports: [
CommonModule,
FormsModule,
NgIcon,
UiCard,
NgbPaginationModule
],
templateUrl: './merchant-config-list.html',
})
export class MerchantConfigsList implements OnInit, OnDestroy {
private authService = inject(AuthService);
private merchantConfigService = inject(MerchantConfigService);
protected roleService = inject(RoleManagementService);
private cdRef = inject(ChangeDetectorRef);
private destroy$ = new Subject<void>();
// Configuration
readonly ConfigType = ConfigType;
readonly Operator = Operator;
readonly MerchantUtils = MerchantUtils;
// Inputs
@Input() canCreateMerchants: boolean = false;
@Input() canDeleteMerchants: boolean = false;
// Outputs
@Output() merchantSelected = new EventEmitter<string>();
@Output() openCreateMerchantModal = new EventEmitter<void>();
@Output() editMerchantRequested = new EventEmitter<Merchant>();
@Output() deleteMerchantRequested = new EventEmitter<Merchant>();
@Output() activateMerchantRequested = new EventEmitter<Merchant>();
@Output() deactivateMerchantRequested = new EventEmitter<Merchant>();
// Données
allMerchants: Merchant[] = [];
filteredMerchants: Merchant[] = [];
displayedMerchants: Merchant[] = [];
// États
loading = false;
error = '';
// Recherche et filtres
searchTerm = '';
operatorFilter: Operator | 'all' = 'all';
// Pagination
currentPage = 1;
itemsPerPage = 10;
totalItems = 0;
totalPages = 0;
// Tri
sortField: keyof Merchant = 'name';
sortDirection: 'asc' | 'desc' = 'asc';
// Filtres disponibles
availableOperators: { value: Operator | 'all'; label: string }[] = [];
// Permissions
currentUserRole: any = null;
canViewAllMerchants = false;
// ==================== CONVERSION IDS ====================
private convertIdToNumber(id: string): number {
const numId = Number(id);
if (isNaN(numId)) {
throw new Error(`ID invalide pour la conversion en number: ${id}`);
}
return numId;
}
private convertIdToString(id: number): string {
return id.toString();
}
private convertMerchantToFrontend(merchant: any): Merchant {
return {
...merchant,
id: merchant.id ? this.convertIdToString(merchant.id) : undefined,
configs: merchant.configs ? merchant.configs.map((config: any) => ({
...config,
id: config.id ? this.convertIdToString(config.id) : undefined,
merchantPartnerId: config.merchantPartnerId ? this.convertIdToString(config.merchantPartnerId) : undefined
})) : [],
technicalContacts: merchant.technicalContacts ? merchant.technicalContacts.map((contact: any) => ({
...contact,
id: contact.id ? this.convertIdToString(contact.id) : undefined,
merchantPartnerId: contact.merchantPartnerId ? this.convertIdToString(contact.merchantPartnerId) : undefined
})) : [],
users: merchant.users ? merchant.users.map((user: any) => ({
...user,
merchantPartnerId: user.merchantPartnerId ? this.convertIdToString(user.merchantPartnerId) : undefined
})) : []
};
}
private convertMerchantsToFrontend(merchants: any[]): Merchant[] {
return merchants.map(merchant => this.convertMerchantToFrontend(merchant));
}
// Getters pour la logique conditionnelle
get showCreateButton(): boolean {
return this.canCreateMerchants;
}
get showDeleteButton(): boolean {
return this.canDeleteMerchants;
}
getColumnCount(): number {
return 8; // Nombre de colonnes dans le tableau
}
ngOnInit() {
this.initializeAvailableFilters();
}
ngAfterViewInit() {
this.loadCurrentUserPermissions();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
private loadCurrentUserPermissions() {
this.authService.getUserProfile()
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (user) => {
this.currentUserRole = this.extractUserRole(user);
this.canViewAllMerchants = this.canViewAllMerchantsCheck(this.currentUserRole);
this.loadMerchants();
},
error: (error) => {
console.error('Error loading current user permissions:', error);
this.loadMerchants();
}
});
}
private extractUserRole(user: any): any {
const userRoles = this.authService.getCurrentUserRoles();
return userRoles && userRoles.length > 0 ? userRoles[0] : null;
}
private canViewAllMerchantsCheck(role: any): boolean {
if (!role) return false;
const canViewAllRoles = [
'DCB_ADMIN',
'DCB_SUPPORT',
'DCB_PARTNER_ADMIN'
];
return canViewAllRoles.includes(role);
}
private initializeAvailableFilters() {
this.availableOperators = [
{ value: 'all', label: 'Tous les opérateurs' },
{ value: Operator.ORANGE_OSN, label: 'Orange' }
];
}
loadMerchants() {
this.loading = true;
this.error = '';
let merchantsObservable: Observable<Merchant[]>;
if (this.canViewAllMerchants) {
merchantsObservable = this.getAllMerchants();
} else {
merchantsObservable = this.getMyMerchants();
}
merchantsObservable
.pipe(
takeUntil(this.destroy$),
catchError(error => {
console.error('Error loading merchants:', error);
this.error = 'Erreur lors du chargement des marchands';
return of([] as Merchant[]);
})
)
.subscribe({
next: (merchants) => {
this.allMerchants = merchants || [];
this.applyFiltersAndPagination();
this.loading = false;
this.cdRef.detectChanges();
},
error: () => {
this.error = 'Erreur lors du chargement des marchands';
this.loading = false;
this.allMerchants = [];
this.filteredMerchants = [];
this.displayedMerchants = [];
this.cdRef.detectChanges();
}
});
}
private getAllMerchants(): Observable<Merchant[]> {
return this.merchantConfigService.getMerchants(1, 1000).pipe(
map(response => {
return this.convertMerchantsToFrontend(response.items);
}),
catchError(error => {
console.error('Error getting all merchants:', error);
return of([]);
})
);
}
private getMyMerchants(): Observable<Merchant[]> {
return this.getAllMerchants();
}
// ==================== ACTIONS ====================
viewMerchantProfile(merchant: Merchant) {
this.merchantSelected.emit(merchant.id!);
}
editMerchant(merchant: Merchant) {
this.editMerchantRequested.emit(merchant);
}
deleteMerchant(merchant: Merchant) {
this.deleteMerchantRequested.emit(merchant);
}
activateMerchant(merchant: Merchant) {
this.activateMerchantRequested.emit(merchant);
}
deactivateMerchant(merchant: Merchant) {
this.deactivateMerchantRequested.emit(merchant);
}
// ==================== FILTRES ET RECHERCHE ====================
onSearch() {
this.currentPage = 1;
this.applyFiltersAndPagination();
}
onClearFilters() {
this.searchTerm = '';
this.operatorFilter = 'all';
this.currentPage = 1;
this.applyFiltersAndPagination();
}
filterByOperator(operator: Operator | 'all') {
this.operatorFilter = operator;
this.currentPage = 1;
this.applyFiltersAndPagination();
}
applyFiltersAndPagination() {
if (!this.allMerchants) {
this.allMerchants = [];
}
// Appliquer les filtres
this.filteredMerchants = this.allMerchants.filter(merchant => {
const matchesSearch = !this.searchTerm ||
merchant.name.toLowerCase().includes(this.searchTerm.toLowerCase()) ||
merchant.adresse.toLowerCase().includes(this.searchTerm.toLowerCase()) ||
merchant.phone.toLowerCase().includes(this.searchTerm.toLowerCase()) ||
(merchant.description && merchant.description.toLowerCase().includes(this.searchTerm.toLowerCase()));
// Filtrer par opérateur basé sur les configurations
const matchesOperator = this.operatorFilter === 'all' ||
(merchant.configs && merchant.configs.some(config => config.operatorId === this.operatorFilter));
return matchesSearch && matchesOperator;
});
// Appliquer le tri
this.filteredMerchants.sort((a, b) => {
const aValue = a[this.sortField];
const bValue = b[this.sortField];
if (aValue === bValue) return 0;
let comparison = 0;
if (typeof aValue === 'string' && typeof bValue === 'string') {
comparison = aValue.localeCompare(bValue);
} else if (aValue instanceof Date && bValue instanceof Date) {
comparison = aValue.getTime() - bValue.getTime();
} else if (typeof aValue === 'number' && typeof bValue === 'number') {
comparison = aValue - bValue;
}
return this.sortDirection === 'asc' ? comparison : -comparison;
});
// Calculer la pagination
this.totalItems = this.filteredMerchants.length;
this.totalPages = Math.ceil(this.totalItems / this.itemsPerPage);
// Appliquer la pagination
const startIndex = (this.currentPage - 1) * this.itemsPerPage;
const endIndex = startIndex + this.itemsPerPage;
this.displayedMerchants = this.filteredMerchants.slice(startIndex, endIndex);
}
// ==================== TRI ====================
sort(field: keyof Merchant) {
if (this.sortField === field) {
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
} else {
this.sortField = field;
this.sortDirection = 'asc';
}
this.applyFiltersAndPagination();
}
getSortIcon(field: string): string {
if (this.sortField !== field) return 'lucideArrowUpDown';
return this.sortDirection === 'asc' ? 'lucideArrowUp' : 'lucideArrowDown';
}
// ==================== PAGINATION ====================
onPageChange(page: number) {
this.currentPage = page;
this.applyFiltersAndPagination();
}
getStartIndex(): number {
return (this.currentPage - 1) * this.itemsPerPage + 1;
}
getEndIndex(): number {
return Math.min(this.currentPage * this.itemsPerPage, this.totalItems);
}
// ==================== MÉTHODES STATISTIQUES ====================
getTotalMerchantsCount(): number {
return this.allMerchants.length;
}
getTotalConfigsCount(): number {
return this.allMerchants.reduce((total, merchant) =>
total + (merchant.configs ? merchant.configs.length : 0), 0);
}
getTotalContactsCount(): number {
return this.allMerchants.reduce((total, merchant) =>
total + (merchant.technicalContacts ? merchant.technicalContacts.length : 0), 0);
}
// ==================== MÉTHODES D'AFFICHAGE ====================
getConfigTypeLabel(configName: ConfigType | string): string {
return MerchantUtils.getConfigTypeName(configName);
}
formatTimestamp(timestamp: string): string {
if (!timestamp) return 'Non disponible';
return new Date(timestamp).toLocaleDateString('fr-FR', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
// ==================== MÉTHODES POUR LE TEMPLATE ====================
refreshData() {
this.loadMerchants();
}
getCardTitle(): string {
return this.canViewAllMerchants
? 'Tous les Marchands'
: 'Mes Marchands';
}
getHelperText(): string {
return this.canViewAllMerchants
? 'Vue administrative - Gestion de tous les marchands'
: 'Vos marchands partenaires';
}
getHelperIcon(): string {
return this.canViewAllMerchants ? 'lucideShield' : 'lucideStore';
}
getLoadingText(): string {
return 'Chargement des marchands...';
}
getEmptyStateTitle(): string {
return 'Aucun marchand trouvé';
}
getEmptyStateDescription(): string {
return 'Aucun marchand ne correspond à vos critères de recherche.';
}
getEmptyStateButtonText(): string {
return 'Créer le premier marchand';
}
}

View File

@ -0,0 +1,410 @@
<div class="container-fluid">
<!-- En-tête avec navigation -->
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<div>
<h4 class="mb-1">{{ getProfileTitle() }}</h4>
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item">
<a href="javascript:void(0)" (click)="goBack()" class="text-decoration-none cursor-pointer">
Marchands
</a>
</li>
<li class="breadcrumb-item active" aria-current="page">
Configurations
</li>
</ol>
</nav>
</div>
<div class="d-flex gap-2">
<!-- Bouton rafraîchissement -->
<button
class="btn btn-outline-secondary"
(click)="refresh()"
[disabled]="loading"
>
<ng-icon name="lucideRefreshCw" class="me-1" [class.spin]="loading"></ng-icon>
Actualiser
</button>
<!-- Bouton retour -->
<button
class="btn btn-outline-secondary"
(click)="goBack()"
>
<ng-icon name="lucideArrowLeft" class="me-1"></ng-icon>
Retour
</button>
</div>
</div>
</div>
</div>
<!-- Indicateur de permissions -->
@if (currentUserRole && !canManageAllConfigs()) {
<div class="row mb-3">
<div class="col-12">
<div class="alert alert-warning">
<div class="d-flex align-items-center">
<ng-icon name="lucideShield" class="me-2"></ng-icon>
<div>
<strong>Permissions limitées :</strong> Vous ne pouvez que consulter vos configurations
</div>
</div>
</div>
</div>
</div>
}
<!-- Messages d'alerte -->
@if (error) {
<div class="alert alert-danger">
<div class="d-flex align-items-center">
<ng-icon name="lucideAlertCircle" class="me-2"></ng-icon>
<div>{{ error }}</div>
<button class="btn-close ms-auto" (click)="clearMessages()"></button>
</div>
</div>
}
@if (success) {
<div class="alert alert-success">
<div class="d-flex align-items-center">
<ng-icon name="lucideCheckCircle" class="me-2"></ng-icon>
<div>{{ success }}</div>
<button class="btn-close ms-auto" (click)="clearMessages()"></button>
</div>
</div>
}
<div class="row">
<!-- Loading State -->
@if (loading) {
<div class="col-12 text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
<p class="mt-2 text-muted">Chargement des configurations...</p>
</div>
}
<!-- Liste des configurations -->
@if (configs.length > 0 && !loading) {
<div class="col-12">
<!-- En-tête de liste -->
<div class="card mb-4">
<div class="card-body">
<div class="row align-items-center">
<div class="col-md-6">
<h5 class="card-title mb-0">
<ng-icon name="lucideSettings" class="me-2"></ng-icon>
{{ configs.length }} configuration(s) trouvée(s)
</h5>
</div>
<div class="col-md-6 text-md-end">
<div class="text-muted small">
Page {{ page }} sur {{ totalPages }}
</div>
</div>
</div>
</div>
</div>
<!-- Liste des configurations -->
@for (config of paginatedConfigs; track config.id) {
<div class="config-card card mb-3" [class.expanded]="isConfigExpanded(config.id!)">
<!-- En-tête de configuration -->
<div class="config-header" (click)="toggleConfigExpansion(config.id!)">
<div class="row align-items-center">
<div class="col-md-6">
<div class="d-flex align-items-center">
<ng-icon
[name]="getConfigTypeIconSafe(config.name)"
class="me-3 text-primary"
></ng-icon>
<div>
<h6 class="mb-1">{{ config.name }}</h6>
<div class="d-flex gap-2">
<span [ngClass]="getTypeBadgeClass(config)" class="badge">
{{ getTypeLabel(config) }}
</span>
<span [ngClass]="getOperatorBadgeClass(config)" class="badge">
{{ getOperatorLabel(config) }}
</span>
</div>
</div>
</div>
</div>
<div class="col-md-6 text-md-end">
<div class="d-flex align-items-center justify-content-end">
<!-- Indicateur sensible -->
@if (isSensitiveConfig(config)) {
<ng-icon name="lucideShield" class="me-2 text-warning" size="16"></ng-icon>
}
<!-- Date de modification -->
<small class="text-muted me-3">
Modifié le {{ getLastUpdateDate(config) }}
</small>
<!-- Icône expansion -->
<ng-icon
[name]="isConfigExpanded(config.id!) ? 'lucideChevronUp' : 'lucideChevronDown'"
class="text-muted"
></ng-icon>
</div>
</div>
</div>
</div>
<!-- Contenu détaillé (expandable) -->
@if (isConfigExpanded(config.id!)) {
<div class="config-content">
<div class="row g-3">
<!-- Informations de base -->
<div class="col-md-6">
<label class="form-label small text-muted">Nom</label>
<div class="fw-semibold">{{ config.name }}</div>
</div>
<div class="col-md-6">
<label class="form-label small text-muted">Type</label>
<div>
<span [ngClass]="getTypeBadgeClass(config)" class="badge">
{{ getTypeLabel(config) }}
</span>
</div>
</div>
<div class="col-md-6">
<label class="form-label small text-muted">Opérateur</label>
<div>
<span [ngClass]="getOperatorBadgeClass(config)" class="badge">
{{ getOperatorLabel(config) }}
</span>
</div>
</div>
<div class="col-md-6">
<label class="form-label small text-muted">ID Configuration</label>
<div class="font-monospace small text-truncate">{{ config.id }}</div>
</div>
<!-- Valeur -->
<div class="col-12">
<label class="form-label small text-muted">
Valeur
@if (isSensitiveConfig(config)) {
<span class="text-warning ms-1">
<ng-icon name="lucideShield" size="12"></ng-icon>
Sensible
</span>
}
</label>
<div class="config-value" [ngClass]="getValueDisplayClass(config)">
<span class="font-monospace">
{{ getDisplayValue(config) }}
</span>
@if (canShowFullValue(config)) {
<button
class="btn btn-sm btn-outline-primary ms-2"
(click)="toggleSensitiveValue(config.id!); $event.stopPropagation()"
[title]="getValueTooltip(config)"
>
<ng-icon [name]="getValueIcon(config)" class="me-1"></ng-icon>
{{ showSensitiveValues[config.id!] ? 'Masquer' : 'Afficher' }}
</button>
}
</div>
@if (isSensitiveConfig(config) && !showSensitiveValues[config.id!]) {
<div class="form-text text-warning">
<ng-icon name="lucideShield" class="me-1"></ng-icon>
Valeur masquée pour des raisons de sécurité
</div>
}
</div>
<!-- Informations système -->
<div class="col-md-6">
<label class="form-label small text-muted">Date de création</label>
<div>{{ getCreationDate(config) }}</div>
</div>
<div class="col-md-6">
<label class="form-label small text-muted">Dernière modification</label>
<div>{{ getLastUpdateDate(config) }}</div>
</div>
<!-- Description -->
<div class="col-12">
<label class="form-label small text-muted">Description</label>
<div class="alert alert-info mb-0">
<small>{{ getConfigUsageInfo(config) }}</small>
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="config-actions">
<div class="row g-2">
<div class="col-auto">
@if (isEditingConfig(config.id!)) {
<!-- Mode édition -->
<div class="d-flex gap-2">
<button
type="button"
class="btn btn-outline-secondary btn-sm"
(click)="cancelEditing(); $event.stopPropagation()"
[disabled]="saving"
>
<ng-icon name="lucideX" class="me-1"></ng-icon>
Annuler
</button>
<button
type="button"
class="btn btn-success btn-sm"
(click)="saveConfig(config); $event.stopPropagation()"
[disabled]="saving || !isFormValid()"
>
@if (saving) {
<div class="spinner-border spinner-border-sm me-1" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
}
<ng-icon name="lucideCheck" class="me-1"></ng-icon>
Enregistrer
</button>
</div>
} @else {
<!-- Mode consultation -->
<div class="d-flex gap-2">
@if (canEditConfig(config)) {
<button
class="btn btn-primary btn-sm"
(click)="startEditing(config); $event.stopPropagation()"
>
<ng-icon name="lucideEdit" class="me-1"></ng-icon>
Modifier
</button>
}
<button
class="btn btn-outline-secondary btn-sm"
(click)="requestEdit(config.id!); $event.stopPropagation()"
>
<ng-icon name="lucideExternalLink" class="me-1"></ng-icon>
Détails
</button>
</div>
}
</div>
<!-- Formulaire d'édition -->
@if (isEditingConfig(config.id!)) {
<div class="col-12 mt-3">
<div class="border-top pt-3">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label small">Nom</label>
<input
type="text"
class="form-control form-control-sm"
[(ngModel)]="editedConfig.name"
placeholder="Nom de la configuration"
[disabled]="saving"
>
</div>
<div class="col-md-4">
<label class="form-label small">Type</label>
<select
class="form-select form-select-sm"
[(ngModel)]="editedConfig.name"
[disabled]="saving"
>
<option value="" disabled>Sélectionnez un type</option>
@for (type of getAvailableConfigTypes(); track type.value) {
<option [value]="type.value">{{ type.label }}</option>
}
</select>
</div>
<div class="col-md-4">
<label class="form-label small">Opérateur</label>
<select
class="form-select form-select-sm"
[(ngModel)]="editedConfig.operatorId"
[disabled]="saving"
>
<option value="" disabled>Sélectionnez un opérateur</option>
@for (operator of getAvailableOperators(); track operator.value) {
<option [value]="operator.value">{{ operator.label }}</option>
}
</select>
</div>
<div class="col-12">
<label class="form-label small">Valeur</label>
<textarea
class="form-control form-control-sm font-monospace"
[(ngModel)]="editedConfig.value"
placeholder="Valeur de la configuration"
[disabled]="saving"
rows="3"
></textarea>
@if (isSensitiveConfig(config)) {
<div class="alert alert-warning mt-2 mb-0">
<small>
<ng-icon name="lucideAlertTriangle" class="me-1"></ng-icon>
Cette valeur contient des informations sensibles. Soyez prudent lors de la modification.
</small>
</div>
}
</div>
</div>
</div>
</div>
}
</div>
</div>
}
</div>
}
<!-- Pagination -->
@if (totalPages > 1) {
<div class="d-flex justify-content-center mt-4">
<ngb-pagination
[collectionSize]="configs.length"
[page]="page"
[pageSize]="pageSize"
(pageChange)="page = $event"
[maxSize]="5"
[boundaryLinks]="true"
>
</ngb-pagination>
</div>
}
</div>
}
<!-- Empty State -->
@if (configs.length === 0 && !loading) {
<div class="col-12 text-center py-5">
<div class="text-muted">
<ng-icon name="lucideSettings" class="fs-1 mb-3 opacity-50"></ng-icon>
<h5 class="mb-2">Aucune configuration trouvée</h5>
<p class="mb-3">Ce marchand ne possède aucune configuration pour le moment.</p>
<button class="btn btn-primary" (click)="refresh()">
<ng-icon name="lucideRefreshCw" class="me-1"></ng-icon>
Actualiser
</button>
</div>
</div>
}
</div>
</div>

View File

@ -0,0 +1,637 @@
import { Component, inject, OnInit, Input, Output, EventEmitter, ChangeDetectorRef, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { NgIcon } from '@ng-icons/core';
import { NgbAlertModule, NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap';
import { Subject, takeUntil } from 'rxjs';
import {
MerchantConfig,
UpdateMerchantConfigDto,
ConfigType,
Operator,
MerchantUtils
} from '@core/models/merchant-config.model';
import { MerchantConfigService } from '../merchant-config.service';
import { RoleManagementService } from '@core/services/hub-users-roles-management.service';
import { AuthService } from '@core/services/auth.service';
import { UserRole } from '@core/models/dcb-bo-hub-user.model';
@Component({
selector: 'app-merchant-config-view',
standalone: true,
imports: [CommonModule, FormsModule, NgIcon, NgbAlertModule, NgbPaginationModule],
templateUrl: './merchant-config-view.html',
styles: [`
.config-card {
border: 1px solid #e0e0e0;
border-radius: 0.5rem;
margin-bottom: 1rem;
transition: box-shadow 0.3s ease;
}
.config-card:hover {
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
.config-header {
background-color: #f8f9fa;
padding: 1rem;
border-bottom: 1px solid #e0e0e0;
cursor: pointer;
}
.config-content {
padding: 1rem;
}
.config-value {
font-family: 'Courier New', monospace;
background-color: #f8f9fa;
padding: 0.5rem;
border-radius: 0.25rem;
word-break: break-all;
}
.sensitive-value {
filter: blur(0.3rem);
transition: filter 0.3s ease;
}
.sensitive-value:hover {
filter: none;
}
.config-actions {
border-top: 1px solid #e0e0e0;
padding: 1rem;
background-color: #fafafa;
}
.expanded .config-header {
background-color: #e9ecef;
}
`]
})
export class MerchantConfigView implements OnInit, OnDestroy {
private merchantConfigService = inject(MerchantConfigService);
private roleService = inject(RoleManagementService);
private authService = inject(AuthService);
private cdRef = inject(ChangeDetectorRef);
private destroy$ = new Subject<void>();
readonly ConfigType = ConfigType;
readonly Operator = Operator;
readonly MerchantUtils = MerchantUtils;
@Input() merchantId!: string;
@Output() back = new EventEmitter<void>();
@Output() editConfigRequested = new EventEmitter<string>();
// Liste de toutes les configurations
configs: MerchantConfig[] = [];
loading = false;
saving = false;
error = '';
success = '';
// Gestion des permissions
currentUserRole: any = null;
// Édition
editingConfigId: string | null = null;
editedConfig: UpdateMerchantConfigDto = {};
// Affichage des valeurs sensibles
showSensitiveValues: { [configId: string]: boolean } = {};
// Pagination et expansion
expandedConfigs: { [configId: string]: boolean } = {};
page = 1;
pageSize = 10;
// ==================== CONVERSION IDS ====================
/**
* Convertit un ID string en number pour l'API
*/
private convertIdToNumber(id: string): number {
const numId = Number(id);
if (isNaN(numId)) {
throw new Error(`ID invalide pour la conversion en number: ${id}`);
}
return numId;
}
/**
* Convertit un ID number en string pour Angular
*/
private convertIdToString(id: number): string {
return id.toString();
}
/**
* Convertit une config avec des IDs number en string
*/
private convertConfigToFrontend(config: any): MerchantConfig {
return {
...config,
id: config.id ? this.convertIdToString(config.id) : undefined,
merchantPartnerId: config.merchantPartnerId ? this.convertIdToString(config.merchantPartnerId) : undefined
};
}
// Getters pour la logique conditionnelle
isSensitiveConfig(config: MerchantConfig): boolean {
return config.name === ConfigType.API_KEY ||
config.name === ConfigType.SECRET_KEY;
}
shouldTruncateValue(config: MerchantConfig): boolean {
return this.isSensitiveConfig(config) || config.value.length > 100;
}
getDisplayValue(config: MerchantConfig): string {
if (this.isSensitiveConfig(config) && !this.showSensitiveValues[config.id!]) {
return '••••••••••••••••';
}
if (this.shouldTruncateValue(config) && !this.showSensitiveValues[config.id!]) {
return config.value.length > 50
? config.value.substring(0, 50) + '...'
: config.value;
}
return config.value;
}
ngOnInit() {
if (this.merchantId) {
this.loadCurrentUserPermissions();
this.loadAllConfigs();
}
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
/**
* Charge les permissions de l'utilisateur courant
*/
private loadCurrentUserPermissions(): void {
this.authService.getUserProfile()
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (profile) => {
this.currentUserRole = this.authService.getCurrentUserRole();
this.cdRef.detectChanges();
},
error: (error) => {
console.error('Error loading user permissions:', error);
}
});
}
/**
* Charge toutes les configurations du merchant
*/
loadAllConfigs() {
this.loading = true;
this.error = '';
console.log("Chargement des configurations pour le merchant ID:", this.merchantId);
this.merchantConfigService.getMerchantById(this.merchantId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (merchant) => {
// Conversion pour Angular
const frontendMerchant = this.convertMerchantToFrontend(merchant);
console.log("Merchant chargé:", frontendMerchant);
console.log("Configurations trouvées:", frontendMerchant.configs?.length || 0);
// Récupérer toutes les configurations du marchand
this.configs = frontendMerchant.configs || [];
if (this.configs.length === 0) {
this.error = 'Aucune configuration trouvée pour ce marchand';
}
this.loading = false;
this.cdRef.detectChanges();
},
error: (error) => {
this.error = 'Erreur lors du chargement des configurations du marchand';
this.loading = false;
this.cdRef.detectChanges();
console.error('Error loading merchant configs:', error);
}
});
}
/**
* Convertit un marchand avec ses configs pour Angular
*/
private convertMerchantToFrontend(merchant: any): any {
return {
...merchant,
id: merchant.id ? this.convertIdToString(merchant.id) : undefined,
configs: merchant.configs?.map((config: any) => this.convertConfigToFrontend(config)) || [],
technicalContacts: merchant.technicalContacts?.map((contact: any) => ({
...contact,
id: contact.id ? this.convertIdToString(contact.id) : undefined,
merchantPartnerId: contact.merchantPartnerId ? this.convertIdToString(contact.merchantPartnerId) : undefined
})) || [],
users: merchant.users?.map((user: any) => ({
...user,
merchantPartnerId: user.merchantPartnerId ? this.convertIdToString(user.merchantPartnerId) : undefined
})) || []
};
}
// ==================== GESTION DE L'EXPANSION ====================
toggleConfigExpansion(configId: string) {
this.expandedConfigs[configId] = !this.expandedConfigs[configId];
this.cdRef.detectChanges();
}
isConfigExpanded(configId: string): boolean {
return !!this.expandedConfigs[configId];
}
// ==================== GESTION DE L'ÉDITION ====================
startEditing(config: MerchantConfig) {
if (!this.canEditConfig(config)) {
this.error = 'Vous n\'avez pas la permission de modifier cette configuration';
return;
}
this.editingConfigId = config.id!;
this.editedConfig = {
name: config.name,
value: config.value,
operatorId: config.operatorId
};
this.cdRef.detectChanges();
}
cancelEditing() {
this.editingConfigId = null;
this.editedConfig = {};
this.error = '';
this.success = '';
this.cdRef.detectChanges();
}
saveConfig(config: MerchantConfig) {
if (!this.canEditConfig(config)) return;
// Validation du formulaire
const validation = this.validateConfigForm();
if (!validation.isValid) {
this.error = validation.error!;
return;
}
this.saving = true;
this.error = '';
this.success = '';
this.merchantConfigService.updateConfig(config.id!, this.editedConfig)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (updatedConfig) => {
// Mettre à jour la configuration dans la liste
const index = this.configs.findIndex(c => c.id === config.id);
if (index !== -1) {
this.configs[index] = this.convertConfigToFrontend(updatedConfig);
}
this.editingConfigId = null;
this.saving = false;
this.success = 'Configuration mise à jour avec succès';
this.editedConfig = {};
this.cdRef.detectChanges();
},
error: (error) => {
this.error = this.getErrorMessage(error);
this.saving = false;
this.cdRef.detectChanges();
console.error('Error updating merchant config:', error);
}
});
}
isEditingConfig(configId: string): boolean {
return this.editingConfigId === configId;
}
// ==================== VÉRIFICATIONS DE PERMISSIONS ====================
/**
* Vérifie si l'utilisateur peut éditer cette configuration
*/
canEditConfig(config: MerchantConfig): boolean {
if (!config) return false;
// ADMIN peut éditer toutes les configs
if (this.canManageAllConfigs()) {
return true;
}
// PARTNER ne peut éditer que ses propres configs
return config.merchantPartnerId === this.authService.getCurrentMerchantPartnerId();
}
/**
* Vérifie si l'utilisateur peut activer/désactiver cette configuration
*/
canToggleStatus(config: MerchantConfig): boolean {
return this.canEditConfig(config);
}
/**
* Vérifie si l'utilisateur peut supprimer cette configuration
*/
canDeleteConfig(config: MerchantConfig): boolean {
return this.canEditConfig(config);
}
/**
* Vérifie si l'utilisateur peut gérer toutes les configurations
*/
canManageAllConfigs(): boolean {
const adminRoles = [UserRole.DCB_ADMIN, UserRole.DCB_PARTNER, UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_SUPPORT];
return adminRoles.includes(this.currentUserRole);
}
// ==================== UTILITAIRES D'AFFICHAGE ====================
getOperatorBadgeClass(config: MerchantConfig): string {
if (!config || config.operatorId === null) return 'badge bg-secondary';
const classes = {
[Operator.ORANGE_OSN]: 'badge bg-warning'
} as const;
const operatorId = config.operatorId;
return operatorId in classes ? classes[operatorId as Operator] : 'badge bg-secondary';
}
getTypeBadgeClass(config: MerchantConfig): string {
if (!config) return 'badge bg-secondary';
const classes = {
[ConfigType.API_KEY]: 'badge bg-primary',
[ConfigType.SECRET_KEY]: 'badge bg-danger',
[ConfigType.WEBHOOK_URL]: 'badge bg-success',
[ConfigType.CALLBACK_URL]: 'badge bg-info',
[ConfigType.TIMEOUT]: 'badge bg-warning',
[ConfigType.RETRY_COUNT]: 'badge bg-secondary',
[ConfigType.CUSTOM]: 'badge bg-dark'
} as const;
const configName = config.name;
return configName in classes ? classes[configName as ConfigType] : 'badge bg-secondary';
}
getOperatorLabel(config: MerchantConfig): string {
if (!config || config.operatorId === null) return 'Inconnu';
return MerchantUtils.getOperatorName(config.operatorId);
}
getTypeLabel(config: MerchantConfig): string {
if (!config) return 'Inconnu';
return MerchantUtils.getConfigTypeName(config.name as ConfigType);
}
formatTimestamp(timestamp: number): string {
if (!timestamp) return 'Non disponible';
return new Date(timestamp).toLocaleDateString('fr-FR', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
toggleSensitiveValue(configId: string) {
this.showSensitiveValues[configId] = !this.showSensitiveValues[configId];
this.cdRef.detectChanges();
}
getValueIcon(config: MerchantConfig): string {
if (!this.isSensitiveConfig(config)) return 'lucideFileText';
return this.showSensitiveValues[config.id!] ? 'lucideEyeOff' : 'lucideEye';
}
getValueTooltip(config: MerchantConfig): string {
if (!this.isSensitiveConfig(config)) return 'Valeur de configuration';
return this.showSensitiveValues[config.id!] ? 'Masquer la valeur' : 'Afficher la valeur';
}
// ==================== GESTION DES ERREURS ====================
private getErrorMessage(error: any): string {
if (error.error?.message) {
return error.error.message;
}
if (error.status === 400) {
return 'Données invalides. Vérifiez les informations saisies.';
}
if (error.status === 403) {
return 'Vous n\'avez pas les permissions pour effectuer cette action.';
}
if (error.status === 404) {
return 'Configuration non trouvée.';
}
if (error.status === 409) {
return 'Conflit de données. Cette configuration existe peut-être déjà.';
}
return 'Erreur lors de l\'opération. Veuillez réessayer.';
}
// ==================== MÉTHODES DE NAVIGATION ====================
goBack() {
this.back.emit();
}
requestEdit(configId: string) {
this.editConfigRequested.emit(configId);
}
// ==================== MÉTHODES DE VALIDATION ====================
private validateConfigForm(): { isValid: boolean; error?: string } {
if (!this.editedConfig.name?.trim()) {
return { isValid: false, error: 'Le nom de la configuration est requis' };
}
if (!this.editedConfig.value?.trim()) {
return { isValid: false, error: 'La valeur de la configuration est requise' };
}
if (!this.editedConfig.operatorId) {
return { isValid: false, error: 'L\'opérateur est requis' };
}
// Validation spécifique selon le type
if (this.editedConfig.name === ConfigType.WEBHOOK_URL ||
this.editedConfig.name === ConfigType.CALLBACK_URL) {
try {
new URL(this.editedConfig.value);
} catch {
return { isValid: false, error: 'URL invalide' };
}
}
if (this.editedConfig.name === ConfigType.TIMEOUT) {
const timeout = parseInt(this.editedConfig.value, 10);
if (isNaN(timeout) || timeout < 0) {
return { isValid: false, error: 'Timeout doit être un nombre positif' };
}
}
if (this.editedConfig.name === ConfigType.RETRY_COUNT) {
const retryCount = parseInt(this.editedConfig.value, 10);
if (isNaN(retryCount) || retryCount < 0) {
return { isValid: false, error: 'Le nombre de tentatives doit être un nombre positif' };
}
}
return { isValid: true };
}
isFormValid(): boolean {
return this.validateConfigForm().isValid;
}
// ==================== MÉTHODES UTILITAIRES ====================
getCreationDate(config: MerchantConfig): string {
if (!config?.createdAt) return 'Non disponible';
return this.formatTimestamp(new Date(config.createdAt).getTime());
}
getLastUpdateDate(config: MerchantConfig): string {
if (!config?.updatedAt) return 'Non disponible';
return this.formatTimestamp(new Date(config.updatedAt).getTime());
}
refresh() {
this.loadAllConfigs();
}
clearMessages() {
this.error = '';
this.success = '';
this.cdRef.detectChanges();
}
// Méthodes pour le template
getProfileTitle(): string {
return `Configurations du Marchand (${this.configs.length})`;
}
getContextDescription(): string {
return 'Gestion des configurations techniques des marchands';
}
showMerchantPartnerInfo(config: MerchantConfig): boolean {
return this.canManageAllConfigs() && Boolean(config?.merchantPartnerId);
}
getConfigUsageInfo(config: MerchantConfig): string {
if (!config) return '';
const usageInfo: Record<ConfigType, string> = {
[ConfigType.API_KEY]: 'Utilisée pour l\'authentification aux APIs',
[ConfigType.SECRET_KEY]: 'Clé secrète pour la signature des requêtes',
[ConfigType.WEBHOOK_URL]: 'URL pour recevoir les notifications',
[ConfigType.CALLBACK_URL]: 'URL pour les retours de paiement',
[ConfigType.TIMEOUT]: 'Délai d\'expiration des requêtes en millisecondes',
[ConfigType.RETRY_COUNT]: 'Nombre de tentatives en cas d\'échec',
[ConfigType.CUSTOM]: 'Configuration personnalisée'
};
const configType = config.name as ConfigType;
return usageInfo[configType] || 'Configuration technique';
}
getSecurityRecommendation(config: MerchantConfig): string {
if (!this.isSensitiveConfig(config)) return '';
return 'Cette configuration contient des informations sensibles. ' +
'Manipulez-la avec précaution et ne la partagez pas.';
}
// Opérateurs disponibles pour l'édition
getAvailableOperators(): { value: Operator; label: string }[] {
return [
{ value: Operator.ORANGE_OSN, label: 'Orange' }
];
}
// Types de configuration disponibles pour l'édition
getAvailableConfigTypes(): { value: ConfigType; label: string }[] {
return [
{ value: ConfigType.API_KEY, label: 'Clé API' },
{ value: ConfigType.SECRET_KEY, label: 'Clé Secrète' },
{ value: ConfigType.WEBHOOK_URL, label: 'URL Webhook' },
{ value: ConfigType.CALLBACK_URL, label: 'URL Callback' },
{ value: ConfigType.TIMEOUT, label: 'Timeout (ms)' },
{ value: ConfigType.RETRY_COUNT, label: 'Nombre de tentatives' },
{ value: ConfigType.CUSTOM, label: 'Personnalisé' }
];
}
// Méthodes pour les actions spécifiques
canShowFullValue(config: MerchantConfig): boolean {
return this.isSensitiveConfig(config) || this.shouldTruncateValue(config);
}
getValueDisplayClass(config: MerchantConfig): string {
if (this.isSensitiveConfig(config) && !this.showSensitiveValues[config.id!]) {
return 'sensitive-value';
}
return '';
}
getConfigTypeIconSafe(configName: string): string {
const validConfigTypes = Object.values(ConfigType);
if (validConfigTypes.includes(configName as ConfigType)) {
return this.getConfigTypeIcon(configName as ConfigType);
}
return 'lucideSettings';
}
getConfigTypeIcon(configType: ConfigType): string {
const icons: Record<ConfigType, string> = {
[ConfigType.API_KEY]: 'lucideKey',
[ConfigType.SECRET_KEY]: 'lucideShield',
[ConfigType.WEBHOOK_URL]: 'lucideGlobe',
[ConfigType.CALLBACK_URL]: 'lucideRefreshCw',
[ConfigType.TIMEOUT]: 'lucideClock',
[ConfigType.RETRY_COUNT]: 'lucideRepeat',
[ConfigType.CUSTOM]: 'lucideSettings'
};
return icons[configType] || 'lucideSettings';
}
getOperatorIcon(operatorId: Operator): string {
const icons = {
[Operator.ORANGE_OSN]: 'lucideSignal'
};
return icons[operatorId] || 'lucideSmartphone';
}
// Pagination
get paginatedConfigs(): MerchantConfig[] {
const startIndex = (this.page - 1) * this.pageSize;
return this.configs.slice(startIndex, startIndex + this.pageSize);
}
get totalPages(): number {
return Math.ceil(this.configs.length / this.pageSize);
}
}

View File

@ -1,335 +1,693 @@
<app-ui-card title="Configuration Merchant"> <div class="container-fluid">
<span helper-text class="badge badge-soft-primary badge-label fs-xxs py-1"> <app-page-title
Gestion des Merchants [title]="pageTitle"
</span> [subTitle]="pageSubtitle"
[badge]="badge"
<div class="ins-wizard" card-body>
<!-- Progress Bar -->
<ngb-progressbar
class="mb-4"
[value]="progressValue"
type="primary"
height="6px"
/> />
<!-- Navigation Steps --> <!-- Indicateur de permissions -->
<ul class="nav nav-tabs wizard-tabs" role="tablist"> @if (currentUserRole) {
@for (step of wizardSteps; track $index; let i = $index) { <div class="row mb-3">
<li class="nav-item"> <div class="col-12">
<a <div class="alert alert-info py-2">
href="javascript:void(0);" <div class="d-flex align-items-center">
[class.active]="i === currentStep" <ng-icon name="lucideInfo" class="me-2"></ng-icon>
class="nav-link" <div class="flex-grow-1">
[class.disabled]="!isStepAccessible(i)" <small>
[class.wizard-item-done]="i < currentStep" <strong>Rôle actuel :</strong>
(click)="goToStep(i)" <span class="badge" [ngClass]="getRoleBadgeClass(currentUserRole)">
> {{ getRoleLabel(currentUserRole) }}
<span class="d-flex align-items-center">
<ng-icon [name]="step.icon" class="fs-32" />
<span class="flex-grow-1 ms-2 text-truncate">
<span class="mb-0 lh-base d-block fw-semibold text-body fs-base">
{{ step.title }}
</span> </span>
<span class="mb-0 fw-normal">{{ step.subtitle }}</span> @if (!canCreateMerchants) {
<span class="text-warning ms-2">
<ng-icon name="lucideShield" class="me-1"></ng-icon>
Permissions limitées
</span> </span>
</span>
</a>
</li>
} }
@if (selectedMerchantPartnerId) {
<span class="ms-2">
<strong>Marchand :</strong> {{ selectedMerchantPartnerId }}
</span>
}
</small>
</div>
@if (canCreateMerchants) {
<button
class="btn btn-primary btn-sm"
(click)="openCreateMerchantModal()"
>
<ng-icon name="lucidePlus" class="me-1"></ng-icon>
Nouveau Marchand
</button>
}
</div>
</div>
</div>
</div>
}
<!-- Sélection du marchand pour les ADMIN -->
@if (showMerchantPartnerField) {
<div class="row mb-3">
<div class="col-12">
<div class="card">
<div class="card-body py-2">
<div class="row align-items-center">
<div class="col-md-4">
<label class="form-label mb-0">
<strong>Filtrer par Marchand :</strong>
</label>
</div>
<div class="col-md-6">
<select
class="form-select form-select-sm"
[(ngModel)]="selectedMerchantPartnerId"
(change)="onMerchantSelectionChange(selectedMerchantPartnerId)"
>
<option value="">Tous les marchands</option>
@for (partner of merchantPartners; track partner.id) {
<option [value]="partner.merchantPartnerId || partner.id">
{{ partner.username }}
@if (partner.firstName || partner.lastName) {
- {{ partner.firstName }} {{ partner.lastName }}
}
@if (!partner.enabled) {
<span class="badge bg-warning ms-1">Inactif</span>
}
</option>
}
</select>
</div>
<div class="col-md-2">
@if (loadingMerchantPartners) {
<div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
}
</div>
</div>
</div>
</div>
</div>
</div>
}
<!-- Navigation par onglets -->
<div class="row mb-4">
<div class="col-12">
<ul
ngbNav
#configsNav="ngbNav"
[activeId]="activeTab"
[destroyOnHide]="false"
class="nav nav-tabs nav-justified nav-bordered nav-bordered-primary mb-3"
>
<li [ngbNavItem]="'list'">
<a ngbNavLink (click)="showTab('list')">
<ng-icon name="lucideList" class="fs-lg me-md-1 d-inline-flex align-middle" />
<span class="d-none d-md-inline-block align-middle">Marchands</span>
</a>
<ng-template ngbNavContent>
<!-- COMPOSANT CORRIGÉ avec les bonnes propriétés -->
<app-merchant-config-list
#merchantConfigsList
[canCreateMerchants]="canCreateMerchants"
[canDeleteMerchants]="canDeleteMerchants"
(merchantSelected)="onMerchantSelected($event)"
(openCreateMerchantModal)="openCreateMerchantModal()"
(editMerchantRequested)="onEditMerchantRequested($event)"
(deleteMerchantRequested)="onDeleteMerchantRequested($event)"
/>
</ng-template>
</li>
<li [ngbNavItem]="'profile'" [hidden]="activeTab !== 'profile'">
<a ngbNavLink (click)="showTab('profile')">
<ng-icon name="lucideSettings" class="fs-lg me-md-1 d-inline-flex align-middle" />
<span class="d-none d-md-inline-block align-middle">Détails Marchand</span>
</a>
<ng-template ngbNavContent>
@if (selectedMerchantId) {
<app-merchant-config-view
[merchantId]="selectedMerchantId"
(editConfigRequested)="onEditConfigRequested($event)"
(back)="backToList()"
/>
} @else {
<div class="alert alert-warning text-center">
<ng-icon name="lucideAlertCircle" class="me-2"></ng-icon>
Aucun marchand sélectionné
</div>
}
</ng-template>
</li>
</ul> </ul>
<!-- Messages --> <div class="tab-content" [ngbNavOutlet]="configsNav"></div>
@if (configError) { </div>
<div class="alert alert-danger mt-3"> </div>
</div>
<!-- Modal de création de marchand -->
<ng-template #createMerchantModal let-modal>
<div class="modal-header">
<h4 class="modal-title">
<ng-icon name="lucidePlus" class="me-2"></ng-icon>
Créer un nouveau marchand
</h4>
<button
type="button"
class="btn-close"
(click)="modal.dismiss()"
[disabled]="creatingMerchant"
aria-label="Fermer"
></button>
</div>
<div class="modal-body">
<!-- Message d'erreur -->
@if (createMerchantError) {
<div class="alert alert-danger d-flex align-items-center">
<ng-icon name="lucideAlertCircle" class="me-2"></ng-icon> <ng-icon name="lucideAlertCircle" class="me-2"></ng-icon>
{{ configError }} <div>{{ createMerchantError }}</div>
</div> </div>
} }
@if (configSuccess) { <form (ngSubmit)="createMerchant()" #merchantForm="ngForm">
<div class="alert alert-success mt-3"> <div class="row g-3">
<ng-icon name="lucideCheckCircle" class="me-2"></ng-icon> <!-- Informations de base du marchand -->
{{ configSuccess }} <div class="col-md-6">
</div> <label class="form-label">
} Nom du marchand <span class="text-danger">*</span>
</label>
<!-- Contenu des Steps --> <input
<div class="tab-content pt-3"> type="text"
@for (step of wizardSteps; track $index; let i = $index) { class="form-control"
<div placeholder="Ex: Boutique ABC"
class="tab-pane fade" [(ngModel)]="newMerchant.name"
[class.show]="currentStep === i" name="name"
[class.active]="currentStep === i" required
[disabled]="creatingMerchant"
#name="ngModel"
> >
<form [formGroup]="merchantForm"> @if (name.invalid && name.touched) {
<!-- Step 1: Informations de Base --> <div class="text-danger small">
@if (i === 0) { Le nom du marchand est requis
<div class="row"> </div>
<div class="col-md-6 mb-3">
<label class="form-label">Nom du Merchant *</label>
<div formGroupName="basicInfo">
<input type="text" class="form-control" formControlName="name"
placeholder="Nom commercial" />
@if (basicInfo.get('name')?.invalid && basicInfo.get('name')?.touched) {
<div class="text-danger small">Le nom du merchant est requis</div>
} }
</div> </div>
</div>
<div class="col-md-6 mb-3"> <div class="col-md-6">
<label class="form-label">Logo URL</label> <label class="form-label">Logo URL</label>
<div formGroupName="basicInfo"> <input
<input type="url" class="form-control" formControlName="logo" type="text"
placeholder="https://..." /> class="form-control"
placeholder="https://exemple.com/logo.png"
[(ngModel)]="newMerchant.logo"
name="logo"
[disabled]="creatingMerchant"
>
</div> </div>
</div>
<div class="col-12 mb-3"> <div class="col-12">
<label class="form-label">Description</label> <label class="form-label">Description</label>
<div formGroupName="basicInfo"> <textarea
<textarea class="form-control" formControlName="description" class="form-control"
placeholder="Description du merchant..." rows="3"></textarea> placeholder="Description du marchand"
[(ngModel)]="newMerchant.description"
name="description"
[disabled]="creatingMerchant"
rows="2"
></textarea>
</div> </div>
<div class="col-md-6">
<label class="form-label">
Adresse <span class="text-danger">*</span>
</label>
<input
type="text"
class="form-control"
placeholder="Adresse complète"
[(ngModel)]="newMerchant.adresse"
name="adresse"
required
[disabled]="creatingMerchant"
#adresse="ngModel"
>
@if (adresse.invalid && adresse.touched) {
<div class="text-danger small">
L'adresse est requise
</div> </div>
<div class="col-12 mb-3">
<label class="form-label">Adresse *</label>
<div formGroupName="basicInfo">
<input type="text" class="form-control" formControlName="adresse"
placeholder="Adresse complète" />
@if (basicInfo.get('adresse')?.invalid && basicInfo.get('adresse')?.touched) {
<div class="text-danger small">L'adresse est requise</div>
} }
</div> </div>
<div class="col-md-6">
<label class="form-label">
Téléphone <span class="text-danger">*</span>
</label>
<input
type="text"
class="form-control"
placeholder="+XX X XX XX XX XX"
[(ngModel)]="newMerchant.phone"
name="phone"
required
[disabled]="creatingMerchant"
#phone="ngModel"
>
@if (phone.invalid && phone.touched) {
<div class="text-danger small">
Le téléphone est requis
</div> </div>
<div class="col-md-6 mb-3">
<label class="form-label">Téléphone *</label>
<div formGroupName="basicInfo">
<input type="tel" class="form-control" formControlName="phone"
placeholder="+225 XX XX XX XX" />
@if (basicInfo.get('phone')?.invalid && basicInfo.get('phone')?.touched) {
<div class="text-danger small">Le téléphone est requis et doit être valide</div>
} }
</div> </div>
<!-- Section Contacts Techniques -->
<div class="col-12">
<div class="border-top pt-3">
<h6 class="mb-3">
<ng-icon name="lucideUsers" class="me-2"></ng-icon>
Contacts Techniques
</h6>
@if (!newMerchant.technicalContacts || newMerchant.technicalContacts.length === 0) {
<div class="alert alert-warning">
<ng-icon name="lucideAlertCircle" class="me-2"></ng-icon>
Au moins un contact technique est requis
</div>
}
<!-- Liste des contacts -->
@for (contact of newMerchant.technicalContacts; track $index; let i = $index) {
<div class="card mb-3">
<div class="card-header py-2 d-flex justify-content-between align-items-center">
<span>Contact {{ i + 1 }}</span>
@if (newMerchant.technicalContacts.length > 1) {
<button
type="button"
class="btn btn-sm btn-outline-danger"
(click)="removeTechnicalContact(i)"
[disabled]="creatingMerchant"
>
<ng-icon name="lucideTrash2" class="me-1"></ng-icon>
Supprimer
</button>
}
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Prénom <span class="text-danger">*</span></label>
<input
type="text"
class="form-control"
[(ngModel)]="contact.firstName"
[name]="'firstName_' + i"
required
[disabled]="creatingMerchant"
>
</div>
<div class="col-md-6">
<label class="form-label">Nom <span class="text-danger">*</span></label>
<input
type="text"
class="form-control"
[(ngModel)]="contact.lastName"
[name]="'lastName_' + i"
required
[disabled]="creatingMerchant"
>
</div>
<div class="col-md-6">
<label class="form-label">Téléphone <span class="text-danger">*</span></label>
<input
type="text"
class="form-control"
[(ngModel)]="contact.phone"
[name]="'phone_' + i"
required
[disabled]="creatingMerchant"
>
</div>
<div class="col-md-6">
<label class="form-label">Email <span class="text-danger">*</span></label>
<input
type="email"
class="form-control"
[(ngModel)]="contact.email"
[name]="'email_' + i"
required
[disabled]="creatingMerchant"
>
</div>
</div>
</div> </div>
</div> </div>
} }
<!-- Step 2: Contacts Techniques --> <button
@if (i === 1) { type="button"
<div class="row"> class="btn btn-outline-primary btn-sm"
<div class="col-12 mb-4"> (click)="addTechnicalContact()"
<div class="d-flex justify-content-between align-items-center"> [disabled]="creatingMerchant"
<h6 class="border-bottom pb-2">Contacts Techniques</h6> >
<button type="button" class="btn btn-sm btn-outline-primary" (click)="addTechnicalContact()"> <ng-icon name="lucideUserPlus" class="me-1"></ng-icon>
<ng-icon name="lucidePlus" class="me-1"></ng-icon>
Ajouter un contact Ajouter un contact
</button> </button>
</div> </div>
</div> </div>
@for (contact of technicalContacts.controls; track $index; let idx = $index) { <!-- Section Configurations -->
<div class="col-12 mb-4 p-3 border rounded"> <div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="border-top pt-3">
<h6 class="mb-0">Contact #{{ idx + 1 }}</h6> <h6 class="mb-3">
@if (technicalContacts.length > 1) { <ng-icon name="lucideSettings" class="me-2"></ng-icon>
<button type="button" class="btn btn-sm btn-outline-danger" Configurations Techniques
(click)="removeTechnicalContact(idx)"> </h6>
<ng-icon name="lucideTrash2"></ng-icon>
@if (!newMerchant.configs || newMerchant.configs.length === 0) {
<div class="alert alert-warning">
<ng-icon name="lucideAlertCircle" class="me-2"></ng-icon>
Au moins une configuration est requise
</div>
}
<!-- Liste des configurations -->
@for (config of newMerchant.configs; track $index; let i = $index) {
<div class="card mb-3">
<div class="card-header py-2 d-flex justify-content-between align-items-center">
<span>Configuration {{ i + 1 }}</span>
@if (newMerchant.configs.length > 1) {
<button
type="button"
class="btn btn-sm btn-outline-danger"
(click)="removeConfig(i)"
[disabled]="creatingMerchant"
>
<ng-icon name="lucideTrash2" class="me-1"></ng-icon>
Supprimer
</button> </button>
} }
</div> </div>
<div class="card-body">
<div class="row"> <div class="row g-3">
<div class="col-md-6 mb-3"> <div class="col-md-6">
<label class="form-label">Prénom *</label> <label class="form-label">Type <span class="text-danger">*</span></label>
<input type="text" class="form-control" <select
[formControl]="getContactControl(contact, 'firstName')" class="form-select"
placeholder="Prénom" /> [(ngModel)]="config.name"
@if (getContactControl(contact, 'firstName').invalid && getContactControl(contact, 'firstName').touched) { [name]="'configType_' + i"
<div class="text-danger small">Le prénom est requis</div> required
} [disabled]="creatingMerchant"
</div> >
<div class="col-md-6 mb-3"> <option value="" disabled>Sélectionnez un type</option>
<label class="form-label">Nom *</label>
<input type="text" class="form-control"
[formControl]="getContactControl(contact, 'lastName')"
placeholder="Nom" />
@if (getContactControl(contact, 'lastName').invalid && getContactControl(contact, 'lastName').touched) {
<div class="text-danger small">Le nom est requis</div>
}
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Téléphone *</label>
<input type="tel" class="form-control"
[formControl]="getContactControl(contact, 'phone')"
placeholder="+225 XX XX XX XX" />
@if (getContactControl(contact, 'phone').invalid && getContactControl(contact, 'phone').touched) {
<div class="text-danger small">Le téléphone est requis</div>
}
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Email *</label>
<input type="email" class="form-control"
[formControl]="getContactControl(contact, 'email')"
placeholder="email@entreprise.com" />
@if (getContactControl(contact, 'email').invalid && getContactControl(contact, 'email').touched) {
<div class="text-danger small">L'email est requis et doit être valide</div>
}
</div>
</div>
</div>
}
</div>
}
<!-- Step 3: Configurations -->
@if (i === 2) {
<div class="row">
<div class="col-12 mb-4">
<div class="d-flex justify-content-between align-items-center">
<h6 class="border-bottom pb-2">Configurations Techniques</h6>
<button type="button" class="btn btn-sm btn-outline-primary" (click)="addConfig()">
<ng-icon name="lucidePlus" class="me-1"></ng-icon>
Ajouter une configuration
</button>
</div>
</div>
@for (config of configs.controls; track $index; let idx = $index) {
<div class="col-12 mb-4 p-3 border rounded">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="mb-0">Configuration #{{ idx + 1 }}</h6>
@if (configs.length > 1) {
<button type="button" class="btn btn-sm btn-outline-danger"
(click)="removeConfig(idx)">
<ng-icon name="lucideTrash2"></ng-icon>
</button>
}
</div>
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label">Nom *</label>
<select class="form-select"
[formControl]="getConfigControl(config, 'name')">
<option value="">Sélectionnez un type</option>
@for (type of configTypes; track type.name) { @for (type of configTypes; track type.name) {
<option [value]="type.name">{{ type.label }}</option> <option [value]="type.name">{{ type.label }}</option>
} }
</select> </select>
@if (getConfigControl(config, 'name').invalid && getConfigControl(config, 'name').touched) {
<div class="text-danger small">Le nom est requis</div>
}
</div> </div>
<div class="col-md-4 mb-3"> <div class="col-md-6">
<label class="form-label">Valeur *</label> <label class="form-label">Opérateur <span class="text-danger">*</span></label>
<input type="text" class="form-control" <select
[formControl]="getConfigControl(config, 'value')" class="form-select"
placeholder="Valeur de configuration" /> [(ngModel)]="config.operatorId"
@if (getConfigControl(config, 'value').invalid && getConfigControl(config, 'value').touched) { [name]="'operatorId_' + i"
<div class="text-danger small">La valeur est requise</div> required
} [disabled]="creatingMerchant"
</div> >
<div class="col-md-4 mb-3"> <option value="" disabled>Sélectionnez un opérateur</option>
<label class="form-label">Opérateur *</label>
<select class="form-select"
[formControl]="getConfigControl(config, 'operatorId')">
@for (operator of operators; track operator.id) { @for (operator of operators; track operator.id) {
<option [value]="operator.id">{{ operator.name }}</option> <option [value]="operator.id">{{ operator.name }}</option>
} }
</select> </select>
@if (getConfigControl(config, 'operatorId').invalid && getConfigControl(config, 'operatorId').touched) {
<div class="text-danger small">L'opérateur est requis</div>
}
</div> </div>
</div>
</div>
}
</div>
}
<!-- Step 4: Validation -->
@if (i === 4) {
<div class="row">
<div class="col-12"> <div class="col-12">
<div class="alert alert-info"> <label class="form-label">Valeur <span class="text-danger">*</span></label>
<ng-icon name="lucideCheckCircle" class="me-2"></ng-icon> <textarea
Vérifiez les informations avant de créer le merchant class="form-control"
</div> [(ngModel)]="config.value"
[name]="'value_' + i"
<div class="card"> required
<div class="card-body"> [disabled]="creatingMerchant"
<h6 class="card-title">Récapitulatif</h6> rows="2"
placeholder="Valeur de configuration"
<div class="row mb-4"> ></textarea>
<div class="col-md-6">
<strong>Informations de Base:</strong><br>
<strong>Nom:</strong> {{ basicInfo.value.name || 'Non renseigné' }}<br>
<strong>Description:</strong> {{ basicInfo.value.description || 'Non renseigné' }}<br>
<strong>Adresse:</strong> {{ basicInfo.value.adresse || 'Non renseigné' }}<br>
<strong>Téléphone:</strong> {{ basicInfo.value.phone || 'Non renseigné' }}
</div>
</div>
<div class="row mb-4">
<div class="col-12">
<strong>Contacts Techniques:</strong>
@for (contact of technicalContacts.value; track $index; let idx = $index) {
<div class="mt-2 p-2 bg-light rounded">
<strong>Contact #{{ idx + 1 }}:</strong>
{{ contact.firstName }} {{ contact.lastName }} -
{{ contact.phone }} - {{ contact.email }}
</div>
}
</div>
</div>
<div class="row">
<div class="col-12">
<strong>Configurations:</strong>
@for (config of configs.value; track $index; let idx = $index) {
<div class="mt-2 p-2 bg-light rounded">
<strong>Configuration #{{ idx + 1 }}:</strong>
{{ getConfigTypeName(config.name) }} = {{ config.value }}
({{ getOperatorName(config.operatorId) }})
</div>
}
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
} }
</form>
<!-- Navigation Buttons --> <button
<div class="d-flex justify-content-between mt-4"> type="button"
@if (i > 0) { class="btn btn-outline-primary btn-sm"
<button type="button" class="btn btn-secondary" (click)="previousStep()"> (click)="addConfig()"
← Précédent [disabled]="creatingMerchant"
>
<ng-icon name="lucideSettings" class="me-1"></ng-icon>
Ajouter une configuration
</button> </button>
} @else { </div>
<div></div> </div>
} </div>
@if (i < wizardSteps.length - 1) { <div class="modal-footer mt-4">
<button type="button" class="btn btn-primary" (click)="nextStep()" <button
[disabled]="!isStepValid(i)"> type="button"
Suivant → class="btn btn-light"
(click)="modal.dismiss()"
[disabled]="creatingMerchant"
>
<ng-icon name="lucideX" class="me-1"></ng-icon>
Annuler
</button> </button>
} @else { <button
<button type="button" class="btn btn-success" type="submit"
(click)="submitForm()" [disabled]="configLoading"> class="btn btn-primary"
@if (configLoading) { [disabled]="!merchantForm.form.valid || creatingMerchant ||
!newMerchant.technicalContacts.length ||
!newMerchant.configs.length"
>
@if (creatingMerchant) {
<div class="spinner-border spinner-border-sm me-2" role="status"> <div class="spinner-border spinner-border-sm me-2" role="status">
<span class="visually-hidden">Chargement...</span> <span class="visually-hidden">Chargement...</span>
</div> </div>
Création...
} @else {
<ng-icon name="lucidePlus" class="me-1"></ng-icon>
Créer le marchand
} }
Créer le Merchant
</button> </button>
}
</div> </div>
</form>
</div>
</ng-template>
<!-- Modal d'édition de marchand -->
<ng-template #editMerchantModal let-modal>
<div class="modal-header">
<h4 class="modal-title">
<ng-icon name="lucideEdit" class="me-2"></ng-icon>
Modifier le marchand
</h4>
<button
type="button"
class="btn-close"
(click)="modal.dismiss()"
[disabled]="updatingMerchant"
aria-label="Fermer"
></button>
</div>
<div class="modal-body">
<!-- Message d'erreur -->
@if (updateMerchantError) {
<div class="alert alert-danger d-flex align-items-center">
<ng-icon name="lucideAlertCircle" class="me-2"></ng-icon>
<div>{{ updateMerchantError }}</div>
</div>
}
@if (selectedMerchantForEdit) {
<form (ngSubmit)="updateMerchant()" #editForm="ngForm">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Nom du marchand <span class="text-danger">*</span></label>
<input
type="text"
class="form-control"
[(ngModel)]="selectedMerchantForEdit.name"
name="name"
required
[disabled]="updatingMerchant"
>
</div>
<div class="col-md-6">
<label class="form-label">Logo URL</label>
<input
type="text"
class="form-control"
[(ngModel)]="selectedMerchantForEdit.logo"
name="logo"
[disabled]="updatingMerchant"
>
</div>
<div class="col-12">
<label class="form-label">Description</label>
<textarea
class="form-control"
[(ngModel)]="selectedMerchantForEdit.description"
name="description"
[disabled]="updatingMerchant"
rows="2"
></textarea>
</div>
<div class="col-md-6">
<label class="form-label">Adresse <span class="text-danger">*</span></label>
<input
type="text"
class="form-control"
[(ngModel)]="selectedMerchantForEdit.adresse"
name="adresse"
required
[disabled]="updatingMerchant"
>
</div>
<div class="col-md-6">
<label class="form-label">Téléphone <span class="text-danger">*</span></label>
<input
type="text"
class="form-control"
[(ngModel)]="selectedMerchantForEdit.phone"
name="phone"
required
[disabled]="updatingMerchant"
>
</div>
</div>
<div class="modal-footer mt-4">
<button
type="button"
class="btn btn-light"
(click)="modal.dismiss()"
[disabled]="updatingMerchant"
>
<ng-icon name="lucideX" class="me-1"></ng-icon>
Annuler
</button>
<button
type="submit"
class="btn btn-primary"
[disabled]="!editForm.form.valid || updatingMerchant"
>
@if (updatingMerchant) {
<div class="spinner-border spinner-border-sm me-2" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
Mise à jour...
} @else {
<ng-icon name="lucideSave" class="me-1"></ng-icon>
Enregistrer
}
</button>
</div>
</form>
} @else {
<div class="alert alert-warning text-center">
<ng-icon name="lucideAlertTriangle" class="me-2"></ng-icon>
Aucun marchand sélectionné pour modification
</div> </div>
} }
</div> </div>
</ng-template>
<!-- Modal de confirmation de suppression -->
<ng-template #deleteMerchantModal let-modal>
<div class="modal-header">
<h4 class="modal-title text-danger">
<ng-icon name="lucideTrash2" class="me-2"></ng-icon>
Confirmer la suppression
</h4>
<button
type="button"
class="btn-close"
(click)="modal.dismiss()"
aria-label="Fermer"
></button>
</div> </div>
</app-ui-card>
<div class="modal-body text-center">
<div class="mb-4">
<div class="avatar-lg mx-auto mb-3 bg-danger bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center">
<ng-icon name="lucideStore" class="text-danger" style="font-size: 2rem;"></ng-icon>
</div>
<h5 class="text-danger mb-2">Êtes-vous sûr de vouloir supprimer ce marchand ?</h5>
<p class="text-muted mb-0">
Cette action est irréversible. Toutes les configurations associées seront également supprimées.
</p>
</div>
@if (selectedMerchantForDelete) {
<div class="alert alert-warning">
<div class="d-flex align-items-start">
<ng-icon name="lucideAlertTriangle" class="me-2 mt-1 text-warning"></ng-icon>
<div>
<strong>Marchand :</strong> {{ selectedMerchantForDelete.name }}<br>
<strong>Adresse :</strong> {{ selectedMerchantForDelete.adresse }}<br>
<strong>Téléphone :</strong> {{ selectedMerchantForDelete.phone }}<br>
<strong>Configurations :</strong> {{ selectedMerchantForDelete.configs.length || 0 }}<br>
<strong>Contacts techniques :</strong> {{ selectedMerchantForDelete.technicalContacts.length || 0 }}<br>
</div>
</div>
</div>
} @else {
<div class="alert alert-warning">
<ng-icon name="lucideAlertCircle" class="me-2"></ng-icon>
Aucun marchand sélectionné pour la suppression
</div>
}
<!-- Message d'erreur -->
@if (deleteMerchantError) {
<div class="alert alert-danger d-flex align-items-center">
<ng-icon name="lucideAlertCircle" class="me-2"></ng-icon>
<div>{{ deleteMerchantError }}</div>
</div>
}
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-light"
(click)="modal.dismiss()"
[disabled]="deletingMerchant"
>
<ng-icon name="lucideX" class="me-1"></ng-icon>
Annuler
</button>
<button
type="button"
class="btn btn-danger"
(click)="confirmDeleteMerchant()"
[disabled]="deletingMerchant || !selectedMerchantForDelete || !canDeleteMerchants"
>
@if (deletingMerchant) {
<div class="spinner-border spinner-border-sm me-2" role="status">
<span class="visually-hidden">Suppression...</span>
</div>
Suppression...
} @else {
<ng-icon name="lucideTrash2" class="me-1"></ng-icon>
Supprimer définitivement
}
</button>
</div>
</ng-template>

View File

@ -19,7 +19,10 @@ import {
PaginatedResponse, PaginatedResponse,
MerchantStatsResponse, MerchantStatsResponse,
SearchMerchantsParams, SearchMerchantsParams,
MerchantStatus ApiMerchant,
ApiMerchantConfig,
ApiTechnicalContact,
ApiMerchantUser
} from '@core/models/merchant-config.model'; } from '@core/models/merchant-config.model';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
@ -29,16 +32,16 @@ export class MerchantConfigService {
// Merchant CRUD Operations // Merchant CRUD Operations
createMerchant(createMerchantDto: CreateMerchantDto): Observable<Merchant> { createMerchant(createMerchantDto: CreateMerchantDto): Observable<Merchant> {
return this.http.post<ApiResponse<Merchant>>(this.baseApiUrl, createMerchantDto).pipe( return this.http.post<ApiMerchant>(this.baseApiUrl, createMerchantDto).pipe(
map(response => { map(apiMerchant => {
if (!response.success) { console.log('Merchant created successfully:', apiMerchant);
throw new Error(response.error || 'Failed to create merchant'); return this.mapApiMerchantToMerchant(apiMerchant);
}
return response.data!;
}), }),
catchError(error => { catchError(error => {
console.error('Error creating merchant:', error); console.error('Error creating merchant:', error);
return throwError(() => error); return throwError(() => new Error(
error.error?.message || error.message || 'An unexpected error occurred while creating merchant'
));
}) })
); );
} }
@ -51,16 +54,24 @@ export class MerchantConfigService {
if (params?.query) { if (params?.query) {
httpParams = httpParams.set('query', params.query); httpParams = httpParams.set('query', params.query);
} }
if (params?.status) { // L'API retourne directement un tableau de merchants
httpParams = httpParams.set('status', params.status); return this.http.get<ApiMerchant[]>(this.baseApiUrl, { params: httpParams }).pipe(
} map(apiMerchants => {
const total = apiMerchants.length;
const totalPages = Math.ceil(total / limit);
return this.http.get<ApiResponse<PaginatedResponse<Merchant>>>(this.baseApiUrl, { params: httpParams }).pipe( // Appliquer la pagination côté client
map(response => { const startIndex = (page - 1) * limit;
if (!response.success) { const endIndex = startIndex + limit;
throw new Error(response.error || 'Failed to load merchants'); const paginatedItems = apiMerchants.slice(startIndex, endIndex);
}
return response.data!; return {
items: paginatedItems.map(apiMerchant => this.mapApiMerchantToMerchant(apiMerchant)),
total: total,
page: page,
limit: limit,
totalPages: totalPages
};
}), }),
catchError(error => { catchError(error => {
console.error('Error loading merchants:', error); console.error('Error loading merchants:', error);
@ -69,13 +80,12 @@ export class MerchantConfigService {
); );
} }
getMerchantById(id: number): Observable<Merchant> { getMerchantById(id: string): Observable<Merchant> {
return this.http.get<ApiResponse<Merchant>>(`${this.baseApiUrl}/${id}`).pipe( const numericId = parseInt(id);
map(response => { // L'API retourne directement l'objet merchant
if (!response.success) { return this.http.get<ApiMerchant>(`${this.baseApiUrl}/${numericId}`).pipe(
throw new Error(response.error || 'Failed to load merchant'); map(apiMerchant => {
} return this.mapApiMerchantToMerchant(apiMerchant);
return response.data!;
}), }),
catchError(error => { catchError(error => {
console.error(`Error loading merchant ${id}:`, error); console.error(`Error loading merchant ${id}:`, error);
@ -84,13 +94,12 @@ export class MerchantConfigService {
); );
} }
updateMerchant(id: number, updateMerchantDto: UpdateMerchantDto): Observable<Merchant> { updateMerchant(id: string, updateMerchantDto: UpdateMerchantDto): Observable<Merchant> {
return this.http.patch<ApiResponse<Merchant>>(`${this.baseApiUrl}/${id}`, updateMerchantDto).pipe( const numericId = parseInt(id);
map(response => { // L'API retourne directement l'objet mis à jour
if (!response.success) { return this.http.patch<ApiMerchant>(`${this.baseApiUrl}/${numericId}`, updateMerchantDto).pipe(
throw new Error(response.error || 'Failed to update merchant'); map(apiMerchant => {
} return this.mapApiMerchantToMerchant(apiMerchant);
return response.data!;
}), }),
catchError(error => { catchError(error => {
console.error(`Error updating merchant ${id}:`, error); console.error(`Error updating merchant ${id}:`, error);
@ -99,12 +108,11 @@ export class MerchantConfigService {
); );
} }
deleteMerchant(id: number): Observable<void> { deleteMerchant(id: string): Observable<void> {
return this.http.delete<ApiResponse<void>>(`${this.baseApiUrl}/${id}`).pipe( const numericId = parseInt(id);
map(response => { // L'API ne retourne probablement rien ou un simple message
if (!response.success) { return this.http.delete<void>(`${this.baseApiUrl}/${numericId}`).pipe(
throw new Error(response.error || 'Failed to delete merchant'); map(() => {
}
}), }),
catchError(error => { catchError(error => {
console.error(`Error deleting merchant ${id}:`, error); console.error(`Error deleting merchant ${id}:`, error);
@ -115,12 +123,16 @@ export class MerchantConfigService {
// User Management // User Management
addUserToMerchant(addUserDto: AddUserToMerchantDto): Observable<MerchantUser> { addUserToMerchant(addUserDto: AddUserToMerchantDto): Observable<MerchantUser> {
return this.http.post<ApiResponse<MerchantUser>>(`${this.baseApiUrl}/users`, addUserDto).pipe( // Convertir merchantPartnerId en number pour l'API
map(response => { const apiDto = {
if (!response.success) { ...addUserDto,
throw new Error(response.error || 'Failed to add user to merchant'); merchantPartnerId: addUserDto.merchantPartnerId ? parseInt(addUserDto.merchantPartnerId) : undefined
} };
return response.data!;
// L'API retourne directement l'utilisateur ajouté
return this.http.post<ApiMerchantUser>(`${this.baseApiUrl}/users`, apiDto).pipe(
map(apiUser => {
return this.mapApiUserToUser(apiUser);
}), }),
catchError(error => { catchError(error => {
console.error('Error adding user to merchant:', error); console.error('Error adding user to merchant:', error);
@ -129,13 +141,16 @@ export class MerchantConfigService {
); );
} }
getMerchantUsers(merchantId: number): Observable<MerchantUser[]> { getMerchantUsers(merchantId: string): Observable<MerchantUser[]> {
return this.http.get<ApiResponse<MerchantUser[]>>(`${this.baseApiUrl}/${merchantId}/users`).pipe( const numericMerchantId = parseInt(merchantId);
map(response => { // Option 1: Si vous avez un endpoint spécifique pour les users
if (!response.success) { // return this.http.get<ApiMerchantUser[]>(`${this.baseApiUrl}/${numericMerchantId}/users`).pipe(
throw new Error(response.error || 'Failed to load merchant users');
} // Option 2: Récupérer le merchant complet et extraire les users
return response.data!; return this.http.get<ApiMerchant>(`${this.baseApiUrl}/${numericMerchantId}`).pipe(
map(apiMerchant => {
// Retourner les users mappés depuis merchantUsers
return (apiMerchant.merchantUsers || []).map(user => this.mapApiUserToUser(user));
}), }),
catchError(error => { catchError(error => {
console.error(`Error loading users for merchant ${merchantId}:`, error); console.error(`Error loading users for merchant ${merchantId}:`, error);
@ -144,16 +159,15 @@ export class MerchantConfigService {
); );
} }
updateUserRole(merchantId: number, userId: string, updateRoleDto: UpdateUserRoleDto): Observable<MerchantUser> { updateUserRole(merchantId: string, userId: string, updateRoleDto: UpdateUserRoleDto): Observable<MerchantUser> {
return this.http.patch<ApiResponse<MerchantUser>>( const numericMerchantId = parseInt(merchantId);
`${this.baseApiUrl}/${merchantId}/users/${userId}/role`, // L'API retourne directement l'utilisateur mis à jour
return this.http.patch<ApiMerchantUser>(
`${this.baseApiUrl}/${numericMerchantId}/users/${userId}/role`,
updateRoleDto updateRoleDto
).pipe( ).pipe(
map(response => { map(apiUser => {
if (!response.success) { return this.mapApiUserToUser(apiUser);
throw new Error(response.error || 'Failed to update user role');
}
return response.data!;
}), }),
catchError(error => { catchError(error => {
console.error(`Error updating user role for merchant ${merchantId}, user ${userId}:`, error); console.error(`Error updating user role for merchant ${merchantId}, user ${userId}:`, error);
@ -162,12 +176,11 @@ export class MerchantConfigService {
); );
} }
removeUserFromMerchant(merchantId: number, userId: string): Observable<void> { removeUserFromMerchant(merchantId: string, userId: string): Observable<void> {
return this.http.delete<ApiResponse<void>>(`${this.baseApiUrl}/${merchantId}/users/${userId}`).pipe( const numericMerchantId = parseInt(merchantId);
map(response => { // L'API ne retourne probablement rien
if (!response.success) { return this.http.delete<void>(`${this.baseApiUrl}/${numericMerchantId}/users/${userId}`).pipe(
throw new Error(response.error || 'Failed to remove user from merchant'); map(() => {
}
}), }),
catchError(error => { catchError(error => {
console.error(`Error removing user ${userId} from merchant ${merchantId}:`, error); console.error(`Error removing user ${userId} from merchant ${merchantId}:`, error);
@ -177,12 +190,10 @@ export class MerchantConfigService {
} }
getUserMerchants(userId: string): Observable<Merchant[]> { getUserMerchants(userId: string): Observable<Merchant[]> {
return this.http.get<ApiResponse<Merchant[]>>(`${this.baseApiUrl}/user/${userId}`).pipe( // L'API retourne directement un tableau de merchants
map(response => { return this.http.get<ApiMerchant[]>(`${this.baseApiUrl}/user/${userId}`).pipe(
if (!response.success) { map(apiMerchants => {
throw new Error(response.error || 'Failed to load user merchants'); return apiMerchants.map(merchant => this.mapApiMerchantToMerchant(merchant));
}
return response.data!;
}), }),
catchError(error => { catchError(error => {
console.error(`Error loading merchants for user ${userId}:`, error); console.error(`Error loading merchants for user ${userId}:`, error);
@ -192,13 +203,18 @@ export class MerchantConfigService {
} }
// Config Management // Config Management
addConfigToMerchant(merchantId: number, config: Omit<MerchantConfig, 'id' | 'merchantId'>): Observable<MerchantConfig> { addConfigToMerchant(merchantId: string, config: Omit<MerchantConfig, 'id' | 'merchantPartnerId'>): Observable<MerchantConfig> {
return this.http.post<ApiResponse<MerchantConfig>>(`${this.baseApiUrl}/${merchantId}/configs`, config).pipe( const numericMerchantId = parseInt(merchantId);
map(response => { const apiConfig: Omit<ApiMerchantConfig, 'id'> = {
if (!response.success) { ...config,
throw new Error(response.error || 'Failed to add config to merchant'); operatorId: config.operatorId,
} merchantPartnerId: numericMerchantId
return response.data!; };
// L'API retourne directement la configuration créée
return this.http.post<ApiMerchantConfig>(`${this.baseApiUrl}/${numericMerchantId}/configs`, apiConfig).pipe(
map(apiConfig => {
return this.mapApiConfigToConfig(apiConfig);
}), }),
catchError(error => { catchError(error => {
console.error(`Error adding config to merchant ${merchantId}:`, error); console.error(`Error adding config to merchant ${merchantId}:`, error);
@ -207,13 +223,24 @@ export class MerchantConfigService {
); );
} }
updateConfig(configId: number, config: Partial<MerchantConfig>): Observable<MerchantConfig> { updateConfig(configId: string, config: Partial<MerchantConfig>): Observable<MerchantConfig> {
return this.http.patch<ApiResponse<MerchantConfig>>(`${this.baseApiUrl}/configs/${configId}`, config).pipe( const numericConfigId = parseInt(configId);
map(response => {
if (!response.success) { // Préparer l'objet de configuration pour l'API
throw new Error(response.error || 'Failed to update config'); const apiConfig: Partial<ApiMerchantConfig> = {
} name: config.name,
return response.data!; value: config.value,
operatorId: config.operatorId,
// Si merchantPartnerId est présent, le convertir en number
...(config.merchantPartnerId && {
merchantPartnerId: parseInt(config.merchantPartnerId)
})
};
// L'API retourne directement la configuration mise à jour
return this.http.patch<ApiMerchantConfig>(`${this.baseApiUrl}/configs/${numericConfigId}`, apiConfig).pipe(
map(apiConfig => {
return this.mapApiConfigToConfig(apiConfig);
}), }),
catchError(error => { catchError(error => {
console.error(`Error updating config ${configId}:`, error); console.error(`Error updating config ${configId}:`, error);
@ -222,12 +249,12 @@ export class MerchantConfigService {
); );
} }
deleteConfig(configId: number): Observable<void> { deleteConfig(configId: string): Observable<void> {
return this.http.delete<ApiResponse<void>>(`${this.baseApiUrl}/configs/${configId}`).pipe( const numericConfigId = parseInt(configId);
map(response => { // L'API ne retourne probablement rien
if (!response.success) { return this.http.delete<void>(`${this.baseApiUrl}/configs/${numericConfigId}`).pipe(
throw new Error(response.error || 'Failed to delete config'); map(() => {
}
}), }),
catchError(error => { catchError(error => {
console.error(`Error deleting config ${configId}:`, error); console.error(`Error deleting config ${configId}:`, error);
@ -237,13 +264,17 @@ export class MerchantConfigService {
} }
// Technical Contacts Management // Technical Contacts Management
addTechnicalContact(merchantId: number, contact: Omit<TechnicalContact, 'id' | 'merchantId'>): Observable<TechnicalContact> { addTechnicalContactToMerchant(merchantId: string, contact: Omit<TechnicalContact, 'id' | 'merchantPartnerId'>): Observable<TechnicalContact> {
return this.http.post<ApiResponse<TechnicalContact>>(`${this.baseApiUrl}/${merchantId}/contacts`, contact).pipe( const numericMerchantId = parseInt(merchantId);
map(response => { const apiContact: Omit<ApiTechnicalContact, 'id'> = {
if (!response.success) { ...contact,
throw new Error(response.error || 'Failed to add technical contact'); merchantPartnerId: numericMerchantId
} };
return response.data!;
// L'API retourne directement le contact créé
return this.http.post<ApiTechnicalContact>(`${this.baseApiUrl}/${numericMerchantId}/technical-contacts`, apiContact).pipe(
map(apiContact => {
return this.mapApiContactToContact(apiContact);
}), }),
catchError(error => { catchError(error => {
console.error(`Error adding technical contact to merchant ${merchantId}:`, error); console.error(`Error adding technical contact to merchant ${merchantId}:`, error);
@ -252,13 +283,24 @@ export class MerchantConfigService {
); );
} }
updateTechnicalContact(contactId: number, contact: Partial<TechnicalContact>): Observable<TechnicalContact> { updateTechnicalContact(contactId: string, contact: Partial<TechnicalContact>): Observable<TechnicalContact> {
return this.http.patch<ApiResponse<TechnicalContact>>(`${this.baseApiUrl}/contacts/${contactId}`, contact).pipe( const numericContactId = parseInt(contactId);
map(response => {
if (!response.success) { const apiContact: Partial<ApiTechnicalContact> = {
throw new Error(response.error || 'Failed to update technical contact'); firstName: contact.firstName,
} lastName: contact.lastName,
return response.data!; phone: contact.phone,
email: contact.email,
// Si merchantPartnerId est présent, le convertir en number
...(contact.merchantPartnerId && {
merchantPartnerId: parseInt(contact.merchantPartnerId)
})
};
// L'API retourne directement le contact mis à jour
return this.http.patch<ApiTechnicalContact>(`${this.baseApiUrl}/technical-contacts/${numericContactId}`, apiContact).pipe(
map(apiContact => {
return this.mapApiContactToContact(apiContact);
}), }),
catchError(error => { catchError(error => {
console.error(`Error updating technical contact ${contactId}:`, error); console.error(`Error updating technical contact ${contactId}:`, error);
@ -267,12 +309,12 @@ export class MerchantConfigService {
); );
} }
deleteTechnicalContact(contactId: number): Observable<void> { deleteTechnicalContact(contactId: string): Observable<void> {
return this.http.delete<ApiResponse<void>>(`${this.baseApiUrl}/contacts/${contactId}`).pipe( const numericContactId = parseInt(contactId);
map(response => { // L'API ne retourne probablement rien
if (!response.success) { return this.http.delete<void>(`${this.baseApiUrl}/technical-contacts/${numericContactId}`).pipe(
throw new Error(response.error || 'Failed to delete technical contact'); map(() => {
}
}), }),
catchError(error => { catchError(error => {
console.error(`Error deleting technical contact ${contactId}:`, error); console.error(`Error deleting technical contact ${contactId}:`, error);
@ -281,35 +323,57 @@ export class MerchantConfigService {
); );
} }
// Statistics // Stats
getMerchantStats(): Observable<MerchantStatsResponse> { getMerchantStats(): Observable<MerchantStatsResponse> {
return this.http.get<ApiResponse<MerchantStatsResponse>>(`${this.baseApiUrl}/stats`).pipe( // L'API retourne directement les stats
map(response => { return this.http.get<MerchantStatsResponse>(`${this.baseApiUrl}/stats`).pipe(
if (!response.success) { map(stats => {
throw new Error(response.error || 'Failed to load merchant statistics'); return stats;
}
return response.data!;
}), }),
catchError(error => { catchError(error => {
console.error('Error loading merchant statistics:', error); console.error('Error loading merchant stats:', error);
return throwError(() => error); return throwError(() => error);
}) })
); );
} }
// Status Management // ==================== MAPPING METHODS ====================
updateMerchantStatus(merchantId: number, status: MerchantStatus): Observable<Merchant> {
return this.http.patch<ApiResponse<Merchant>>(`${this.baseApiUrl}/${merchantId}/status`, { status }).pipe( // Méthode principale de mapping Merchant
map(response => { private mapApiMerchantToMerchant(apiMerchant: any): Merchant {
if (!response.success) {
throw new Error(response.error || 'Failed to update merchant status'); return {
...apiMerchant,
id: apiMerchant.id?.toString() || '', // number → string
configs: (apiMerchant.configs || []).map((config: any) => this.mapApiConfigToConfig(config)),
users: (apiMerchant.merchantUsers || []).map((user: any) => this.mapApiUserToUser(user)), // merchantUsers → users
technicalContacts: (apiMerchant.technicalContacts || []).map((contact: any) => this.mapApiContactToContact(contact)),
};
} }
return response.data!;
}), // Méthode de mapping pour les configurations
catchError(error => { private mapApiConfigToConfig(apiConfig: any): MerchantConfig {
console.error(`Error updating status for merchant ${merchantId}:`, error); return {
return throwError(() => error); ...apiConfig,
}) id: apiConfig.id?.toString(), // number → string
); merchantPartnerId: apiConfig.merchantPartnerId?.toString() // number → string
};
}
// Méthode de mapping pour les contacts techniques
private mapApiContactToContact(apiContact: any): TechnicalContact {
return {
...apiContact,
id: apiContact.id?.toString(), // number → string
merchantPartnerId: apiContact.merchantPartnerId?.toString() // number → string
};
}
// Méthode de mapping pour les utilisateurs
private mapApiUserToUser(apiUser: any): MerchantUser {
return {
...apiUser,
merchantPartnerId: apiUser.merchantPartnerId?.toString() // number → string
};
} }
} }

View File

@ -1,14 +1,27 @@
import { ChangeDetectorRef, Component, inject, OnInit } from '@angular/core'; import { Component, inject, OnInit, TemplateRef, ViewChild, ChangeDetectorRef, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule, FormBuilder, Validators, FormArray, FormGroup, FormControl } from '@angular/forms'; import { FormsModule, ReactiveFormsModule, FormBuilder, Validators, FormArray, FormGroup } from '@angular/forms';
import { NgIcon } from '@ng-icons/core'; import { NgIcon } from '@ng-icons/core';
import { NgbProgressbarModule } from '@ng-bootstrap/ng-bootstrap'; import { NgbNavModule, NgbModal, NgbModalModule } from '@ng-bootstrap/ng-bootstrap';
import { UiCard } from '@app/components/ui-card'; import { catchError, map, of, Subject, takeUntil } from 'rxjs';
import { MerchantConfigService } from './merchant-config.service'; import { MerchantConfigService } from './merchant-config.service';
import { CreateMerchantDto, MerchantConfig as MerchantConfigModel, TechnicalContact, MerchantUtils, ConfigType, Operator } from '@core/models/merchant-config.model';
import { UserRole } from '@core/models/dcb-bo-hub-user.model';
import { firstValueFrom, Subject } from 'rxjs';
import { RoleManagementService } from '@core/services/hub-users-roles-management.service'; import { RoleManagementService } from '@core/services/hub-users-roles-management.service';
import { AuthService } from '@core/services/auth.service';
import { PageTitle } from '@app/components/page-title/page-title';
import { MerchantConfigsList } from './merchant-config-list/merchant-config-list';
import { MerchantConfigView } from './merchant-config-view/merchant-config-view';
import {
CreateMerchantDto,
MerchantUtils,
ConfigType,
Operator,
UpdateMerchantDto,
Merchant,
} from '@core/models/merchant-config.model';
import { UserRole, UserType, PaginatedUserResponse, User } from '@core/models/dcb-bo-hub-user.model';
import { HubUsersService } from '../hub-users-management/hub-users.service';
@Component({ @Component({
selector: 'app-merchant-config', selector: 'app-merchant-config',
@ -18,50 +31,73 @@ import { RoleManagementService } from '@core/services/hub-users-roles-management
FormsModule, FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
NgIcon, NgIcon,
NgbProgressbarModule, NgbNavModule,
UiCard NgbModalModule,
PageTitle,
MerchantConfigsList,
MerchantConfigView
], ],
templateUrl: './merchant-config.html' templateUrl: './merchant-config.html',
}) })
export class MerchantConfig implements OnInit { export class MerchantConfigManagement implements OnInit, OnDestroy {
private modalService = inject(NgbModal);
private fb = inject(FormBuilder); private fb = inject(FormBuilder);
private authService = inject(AuthService);
private merchantConfigService = inject(MerchantConfigService); private merchantConfigService = inject(MerchantConfigService);
private hubUsersService = inject(HubUsersService);
protected roleService = inject(RoleManagementService); protected roleService = inject(RoleManagementService);
private cdRef = inject(ChangeDetectorRef); private cdRef = inject(ChangeDetectorRef);
private destroy$ = new Subject<void>(); private destroy$ = new Subject<void>();
// Configuration wizard // Configuration
currentStep = 0; readonly UserRole = UserRole;
wizardSteps = [ readonly ConfigType = ConfigType;
{ id: 'basic-info', icon: 'lucideBuilding', title: 'Informations de Base', subtitle: 'Détails du merchant' }, readonly Operator = Operator;
{ id: 'technical-contacts', icon: 'lucideUsers', title: 'Contacts Techniques', subtitle: 'Personnes de contact' },
{ id: 'configurations', icon: 'lucideSettings', title: 'Configurations', subtitle: 'Paramètres techniques' },
{ id: 'review', icon: 'lucideCheckCircle', title: 'Validation', subtitle: 'Vérification finale' }
];
configLoading = false; // Propriétés de configuration
configError = ''; pageTitle: string = 'Gestion des Marchands';
configSuccess = ''; pageSubtitle: string = 'Administrez les marchands et leurs configurations techniques';
badge: any = { icon: 'lucideSettings', text: 'Merchant Management' };
// Formulaires // État de l'interface
merchantForm = this.fb.group({ activeTab: 'list' | 'profile' = 'list';
basicInfo: this.fb.group({ selectedMerchantId: string | null = null;
name: ['', [Validators.required, Validators.minLength(2)]], selectedConfigId: string | null = null;
logo: [''],
description: [''], // Gestion des permissions
adresse: ['', [Validators.required]], currentUserRole: UserRole | null = null;
phone: ['', [Validators.required, Validators.pattern(/^\+?[0-9\s\-\(\)]+$/)]] currentUserType: UserType | null = null;
}), currentMerchantPartnerId: string = '';
technicalContacts: this.fb.array([]), userPermissions: any = null;
configs: this.fb.array([]) canCreateMerchants = false;
}); canDeleteMerchants = false;
canManageMerchants = false;
// Formulaire de création
newMerchant: CreateMerchantDto = this.getDefaultMerchantForm();
// États des opérations
creatingMerchant = false;
createMerchantError = '';
updatingMerchant = false;
updateMerchantError = '';
deletingMerchant = false;
deleteMerchantError = '';
selectedMerchantForEdit: Merchant | null = null;
selectedMerchantForDelete: Merchant | null = null;
// Références aux templates de modals
@ViewChild('createMerchantModal') createMerchantModal!: TemplateRef<any>;
@ViewChild('editMerchantModal') editMerchantModal!: TemplateRef<any>;
@ViewChild('deleteMerchantModal') deleteMerchantModal!: TemplateRef<any>;
// Références aux composants enfants
@ViewChild(MerchantConfigsList) merchantConfigsList!: MerchantConfigsList;
// Opérateurs disponibles // Opérateurs disponibles
operators = [ operators = [
{ id: Operator.ORANGE_CI, name: 'Orange CI' }, { id: Operator.ORANGE_OSN, name: 'Orange' }
{ id: Operator.MTN_CI, name: 'MTN CI' },
{ id: Operator.MOOV_CI, name: 'Moov CI' },
{ id: Operator.WAVE, name: 'Wave' }
]; ];
// Types de configuration // Types de configuration
@ -75,220 +111,622 @@ export class MerchantConfig implements OnInit {
{ name: ConfigType.CUSTOM, label: 'Personnalisé' } { name: ConfigType.CUSTOM, label: 'Personnalisé' }
]; ];
// Rôles disponibles pour les merchants (utilisation de vos rôles existants) // Liste des partenaires marchands (pour les admins)
availableMerchantRoles = MerchantUtils.getAvailableMerchantRoles(); merchantPartners: User[] = [];
loadingMerchantPartners = false;
merchantPartnersError = '';
selectedMerchantPartnerId: string = '';
// Cache des marchands
merchantProfiles: { [merchantId: string]: Merchant } = {};
loadingMerchants: { [merchantId: string]: boolean } = {};
ngOnInit() { ngOnInit() {
// Ajouter un contact technique par défaut this.activeTab = 'list';
this.addTechnicalContact(); this.loadCurrentUserPermissions();
// Ajouter une configuration par défaut this.loadMerchantPartnersIfNeeded();
this.addConfig(); this.initializeMerchantPartnerContext();
} }
// Navigation du wizard // ==================== MÉTHODES MANQUANTES POUR LE TEMPLATE ====================
get progressValue(): number {
return ((this.currentStep + 1) / this.wizardSteps.length) * 100;
}
nextStep() { /**
if (this.currentStep < this.wizardSteps.length - 1 && this.isStepValid(this.currentStep)) { * Méthodes pour la gestion des rôles (manquantes)
this.currentStep++; */
} getRoleBadgeClass(role: UserRole): string {
} return this.roleService.getRoleBadgeClass(role);
previousStep() {
if (this.currentStep > 0) {
this.currentStep--;
}
}
goToStep(index: number) {
if (this.isStepAccessible(index)) {
this.currentStep = index;
}
}
isStepAccessible(index: number): boolean {
if (index === 0) return true;
for (let i = 0; i < index; i++) {
if (!this.isStepValid(i)) {
return false;
}
}
return true;
}
isStepValid(stepIndex: number): boolean {
switch (stepIndex) {
case 0: // Basic Info
return this.basicInfo.valid;
case 1: // Technical Contacts
return this.technicalContacts.valid && this.technicalContacts.length > 0;
case 2: // Configurations
return this.configs.valid;
case 3: // Review
return this.merchantForm.valid;
default:
return false;
}
}
// Gestion des contacts techniques
get technicalContacts(): FormArray {
return this.merchantForm.get('technicalContacts') as FormArray;
}
addTechnicalContact() {
const contactGroup = this.fb.group({
firstName: ['', [Validators.required]],
lastName: ['', [Validators.required]],
phone: ['', [Validators.required, Validators.pattern(/^\+?[0-9\s\-\(\)]+$/)]],
email: ['', [Validators.required, Validators.email]]
});
this.technicalContacts.push(contactGroup);
}
removeTechnicalContact(index: number) {
if (this.technicalContacts.length > 1) {
this.technicalContacts.removeAt(index);
}
}
getContactControl(contact: any, field: string): FormControl {
return contact.get(field) as FormControl;
}
// Gestion des configurations
get configs(): FormArray {
return this.merchantForm.get('configs') as FormArray;
}
addConfig() {
const configGroup = this.fb.group({
name: ['', [Validators.required]],
value: ['', [Validators.required]],
operatorId: [Operator.ORANGE_CI, [Validators.required]]
});
this.configs.push(configGroup);
}
removeConfig(index: number) {
if (this.configs.length > 1) {
this.configs.removeAt(index);
}
}
getConfigControl(config: any, field: string): FormControl {
return config.get(field) as FormControl;
}
// Soumission du formulaire
async submitForm() {
if (this.merchantForm.valid) {
this.configLoading = true;
this.configError = '';
try {
const formData = this.merchantForm.value;
const createMerchantDto: CreateMerchantDto = {
name: this.safeString(formData.basicInfo?.name),
logo: this.safeString(formData.basicInfo?.logo),
description: this.safeString(formData.basicInfo?.description),
adresse: this.safeString(formData.basicInfo?.adresse),
phone: this.safeString(formData.basicInfo?.phone),
technicalContacts: (formData.technicalContacts || []).map((contact: any) => ({
firstName: this.safeString(contact.firstName),
lastName: this.safeString(contact.lastName),
phone: this.safeString(contact.phone),
email: this.safeString(contact.email)
})),
configs: (formData.configs || []).map((config: any) => ({
name: this.safeString(config.name),
value: this.safeString(config.value),
operatorId: this.safeNumber(config.operatorId)
}))
};
// Validation avant envoi
const validationErrors = MerchantUtils.validateMerchantCreation(createMerchantDto);
if (validationErrors.length > 0) {
this.configError = validationErrors.join(', ');
return;
}
const response = await firstValueFrom(
this.merchantConfigService.createMerchant(createMerchantDto)
);
this.configSuccess = `Merchant créé avec succès! ID: ${response.id}`;
this.merchantForm.reset();
this.currentStep = 0;
// Réinitialiser les tableaux
this.technicalContacts.clear();
this.configs.clear();
this.addTechnicalContact();
this.addConfig();
} catch (error: any) {
this.configError = error.message || 'Erreur lors de la création du merchant';
console.error('Error creating merchant:', error);
} finally {
this.configLoading = false;
}
} else {
this.configError = 'Veuillez corriger les erreurs dans le formulaire';
this.markAllFieldsAsTouched();
}
}
// Méthodes utilitaires
private safeString(value: string | null | undefined): string {
return value || '';
}
private safeNumber(value: number | null | undefined): number {
return value || 0;
}
private markAllFieldsAsTouched() {
Object.keys(this.merchantForm.controls).forEach(key => {
const control = this.merchantForm.get(key);
if (control instanceof FormGroup) {
Object.keys(control.controls).forEach(subKey => {
control.get(subKey)?.markAsTouched();
});
} else if (control instanceof FormArray) {
control.controls.forEach((arrayControl: any) => {
if (arrayControl instanceof FormGroup) {
Object.keys(arrayControl.controls).forEach(subKey => {
arrayControl.get(subKey)?.markAsTouched();
});
}
});
} else {
control?.markAsTouched();
}
});
}
// Getters pour les formulaires
get basicInfo() {
return this.merchantForm.get('basicInfo') as FormGroup;
}
// Méthodes pour le template
getOperatorName(operatorId: Operator): string {
return MerchantUtils.getOperatorName(operatorId);
}
getConfigTypeName(configName: string): string {
return MerchantUtils.getConfigTypeName(configName);
} }
getRoleLabel(role: UserRole): string { getRoleLabel(role: UserRole): string {
return this.roleService.getRoleLabel(role); return this.roleService.getRoleLabel(role);
} }
/**
* Méthodes pour la gestion des contacts techniques
*/
addTechnicalContact(): void {
if (!this.newMerchant.technicalContacts) {
this.newMerchant.technicalContacts = [];
}
this.newMerchant.technicalContacts.push({
firstName: '',
lastName: '',
phone: '',
email: ''
});
}
removeTechnicalContact(index: number): void {
if (this.newMerchant.technicalContacts && this.newMerchant.technicalContacts.length > 1) {
this.newMerchant.technicalContacts.splice(index, 1);
}
}
/**
* Méthodes pour la gestion des configurations
*/
addConfig(): void {
if (!this.newMerchant.configs) {
this.newMerchant.configs = [];
}
this.newMerchant.configs.push({
name: ConfigType.API_KEY,
value: '',
operatorId: Operator.ORANGE_OSN
});
}
removeConfig(index: number): void {
if (this.newMerchant.configs && this.newMerchant.configs.length > 1) {
this.newMerchant.configs.splice(index, 1);
}
}
// ==================== CONVERSION IDS ====================
/**
* Convertit un ID number en string pour Angular
*/
private convertIdToString(id: number): string {
return id.toString();
}
/**
* Convertit un marchand avec des IDs number en string
*/
private convertMerchantToFrontend(merchant: any): Merchant {
return {
...merchant,
id: merchant.id ? this.convertIdToString(merchant.id) : undefined,
configs: merchant.configs?.map((config: any) => ({
...config,
id: config.id ? this.convertIdToString(config.id) : undefined,
merchantPartnerId: config.merchantPartnerId ? this.convertIdToString(config.merchantPartnerId) : undefined
})) || [],
technicalContacts: merchant.technicalContacts?.map((contact: any) => ({
...contact,
id: contact.id ? this.convertIdToString(contact.id) : undefined,
merchantPartnerId: contact.merchantPartnerId ? this.convertIdToString(contact.merchantPartnerId) : undefined
})) || [],
users: merchant.users?.map((user: any) => ({
...user,
merchantPartnerId: user.merchantPartnerId ? this.convertIdToString(user.merchantPartnerId) : undefined
})) || []
};
}
/**
* Convertit un DTO avec des IDs string en number pour l'API
*/
private convertMerchantToBackend(dto: any): any {
console.log("convertMerchantToBackend")
return {
...dto,
// Pas de conversion pour l'ID du marchand en création (généré par le backend)
configs: dto.configs?.map((config: any) => ({
...config,
// Pas de conversion pour l'ID de config en création
operatorId: parseInt(config.operatorId),
merchantPartnerId: config.merchantPartnerId
})) || [],
technicalContacts: dto.technicalContacts?.map((contact: any) => ({
...contact,
// Pas de conversion pour l'ID de contact en création
merchantPartnerId: contact.merchantPartnerId
})) || []
};
}
// ==================== GESTION DES PERMISSIONS ====================
private loadCurrentUserPermissions(): void {
this.authService.getUserProfile()
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (user) => {
this.currentUserRole = this.extractUserRole(user);
this.currentMerchantPartnerId = this.extractMerchantPartnerId(user);
this.currentUserType = this.extractUserType(user);
if (this.currentUserRole) {
this.userPermissions = this.roleService.getPermissionsForRole(this.currentUserRole);
this.canCreateMerchants = this.canManageMerchant();
this.canDeleteMerchants = this.canManageMerchant();
this.canManageMerchants = this.canManageMerchant();
}
// Initialiser le contexte marchand
this.selectedMerchantPartnerId = this.currentMerchantPartnerId;
},
error: (error) => {
console.error('Error loading user profile:', error);
}
});
}
private extractUserRole(user: any): UserRole | null {
const userRoles = this.authService.getCurrentUserRoles();
return userRoles && userRoles.length > 0 ? userRoles[0] : null;
}
private extractUserType(user: any): UserType | null {
return this.authService.getCurrentUserType();
}
private extractMerchantPartnerId(user: any): string {
return user?.merchantPartnerId || this.authService.getCurrentMerchantPartnerId() || '';
}
// ==================== PERMISSIONS SPÉCIFIQUES MARCHAND ====================
private canManageMerchant(): boolean {
return this.roleService.canManageMerchants(this.currentUserRole);
}
/**
* Vérifie si l'utilisateur peut modifier un marchand spécifique
*/
canModifyMerchant(merchant: Merchant): boolean {
if (this.canManageMerchants) return true;
// PARTNER ne peut modifier que ses propres marchands
// Vérifier via les configs ou users
return merchant.configs?.some(config => config.merchantPartnerId === this.currentMerchantPartnerId) ||
merchant.users?.some(user => user.merchantPartnerId === this.currentMerchantPartnerId);
}
// ==================== GESTION DES MARCHANDS ====================
private loadMerchantPartnersIfNeeded(): void {
if (this.canManageMerchants) {
this.loadMerchantPartners();
}
}
private loadMerchantPartners(): void {
this.loadingMerchantPartners = true;
this.merchantPartnersError = '';
this.hubUsersService.getAllDcbPartners()
.pipe(
map((response: PaginatedUserResponse) => response.users),
takeUntil(this.destroy$),
catchError(error => {
console.error('Error loading merchant partners:', error);
this.loadingMerchantPartners = false;
this.merchantPartnersError = 'Impossible de charger la liste des marchands';
return of([] as User[]);
})
)
.subscribe({
next: (partners) => {
this.merchantPartners = partners;
this.loadingMerchantPartners = false;
},
error: (error) => {
console.error('❌ Erreur lors du chargement des marchands:', error);
this.loadingMerchantPartners = false;
this.merchantPartnersError = 'Impossible de charger la liste des marchands';
}
});
}
private initializeMerchantPartnerContext(): void {
if ((this.currentUserRole === UserRole.DCB_PARTNER ||
this.currentUserRole === UserRole.DCB_PARTNER_ADMIN) &&
this.currentMerchantPartnerId) {
this.selectedMerchantPartnerId = this.currentMerchantPartnerId;
}
}
onMerchantSelectionChange(selectedMerchantId: string): void {
this.selectedMerchantPartnerId = selectedMerchantId;
// Recharger les marchands pour le partenaire sélectionné
if (this.merchantConfigsList) {
this.merchantConfigsList.refreshData();
}
}
// ==================== GESTION DES ONGLETS ====================
showTab(tab: 'list' | 'profile', merchantId?: string): void {
console.log(`Switching to tab: ${tab}`, merchantId ? `for merchant ${merchantId}` : '');
this.activeTab = tab;
if (merchantId) {
this.selectedMerchantId = merchantId;
// Charger le profil si pas déjà chargé
if (!this.merchantProfiles[merchantId]) {
this.loadMerchantProfile(merchantId);
}
} else {
this.selectedMerchantId = null;
}
}
private loadMerchantProfile(merchantId: string): void {
if (this.loadingMerchants[merchantId]) return;
this.loadingMerchants[merchantId] = true;
this.merchantConfigService.getMerchantById(merchantId).subscribe({
next: (merchant) => {
// Conversion pour Angular
const frontendMerchant = this.convertMerchantToFrontend(merchant);
this.merchantProfiles[merchantId] = frontendMerchant;
this.loadingMerchants[merchantId] = false;
},
error: (error) => {
console.error(`Error loading merchant profile ${merchantId}:`, error);
this.loadingMerchants[merchantId] = false;
}
});
}
backToList(): void {
console.log('🔙 Returning to list view');
this.activeTab = 'list';
this.selectedMerchantId = null;
}
// ==================== ÉVÉNEMENTS DES COMPOSANTS ENFANTS ====================
onMerchantSelected(merchantId: string): void {
this.showTab('profile', merchantId);
}
onEditMerchantRequested(merchant: Merchant): void {
// Le composant enfant envoie un objet Merchant complet
this.openEditMerchantModal(merchant.id!);
}
onDeleteMerchantRequested(merchant: Merchant): void {
// Le composant enfant envoie un objet Merchant complet
this.openDeleteMerchantModal(merchant.id!);
}
onEditConfigRequested(configId: string): void {
this.openEditMerchantModal(configId);
}
// ==================== GESTION DES MODALS ====================
openModal(content: TemplateRef<any>, size: 'sm' | 'lg' | 'xl' = 'lg'): void {
this.modalService.open(content, {
size: size,
centered: true,
scrollable: true
});
}
openCreateMerchantModal(): void {
if (!this.canCreateMerchants) {
console.warn('User does not have permission to create merchants');
return;
}
this.resetMerchantForm();
this.createMerchantError = '';
this.openModal(this.createMerchantModal);
}
private openEditMerchantModal(merchantId: string): void {
this.merchantConfigService.getMerchantById(merchantId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (merchant) => {
// Conversion pour Angular
const frontendMerchant = this.convertMerchantToFrontend(merchant);
if (!this.canModifyMerchant(frontendMerchant)) {
this.updateMerchantError = 'Vous n\'avez pas la permission de modifier ce marchand';
return;
}
this.selectedMerchantForEdit = frontendMerchant;
this.updateMerchantError = '';
this.populateEditForm(frontendMerchant);
this.openModal(this.editMerchantModal);
},
error: (error) => {
console.error('❌ Error loading merchant for edit:', error);
this.updateMerchantError = 'Erreur lors du chargement du marchand';
this.cdRef.detectChanges();
}
});
}
private openDeleteMerchantModal(merchantId: string): void {
if (!this.canDeleteMerchants) {
console.warn('User does not have permission to delete merchants');
return;
}
this.merchantConfigService.getMerchantById(merchantId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (merchant) => {
// Conversion pour Angular
const frontendMerchant = this.convertMerchantToFrontend(merchant);
if (!this.canModifyMerchant(frontendMerchant)) {
this.deleteMerchantError = 'Vous n\'avez pas la permission de supprimer ce marchand';
return;
}
this.selectedMerchantForDelete = frontendMerchant;
this.deleteMerchantError = '';
this.openModal(this.deleteMerchantModal);
},
error: (error) => {
console.error('❌ Error loading merchant for deletion:', error);
this.deleteMerchantError = 'Erreur lors du chargement du marchand';
this.cdRef.detectChanges();
}
});
}
// ==================== OPÉRATIONS CRUD ====================
createMerchant(): void {
if (!this.canCreateMerchants) {
this.createMerchantError = 'Vous n\'avez pas la permission de créer des marchands';
return;
}
const validation = MerchantUtils.validateMerchantCreation(this.newMerchant);
if (validation.length > 0) {
this.createMerchantError = validation.join(', ');
return;
}
this.creatingMerchant = true;
this.createMerchantError = '';
// Conversion pour l'API
const createDto = this.convertMerchantToBackend(this.newMerchant);
console.log('📤 Creating merchant:', createDto);
this.merchantConfigService.createMerchant(createDto)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (createdMerchant) => {
// Conversion de la réponse pour Angular
const frontendMerchant = this.convertMerchantToFrontend(createdMerchant);
console.log('✅ Merchant created successfully:', frontendMerchant);
this.creatingMerchant = false;
this.modalService.dismissAll();
this.refreshMerchantsList();
this.cdRef.detectChanges();
},
error: (error) => {
console.error('❌ Error creating merchant:', error);
this.creatingMerchant = false;
this.createMerchantError = this.getCreateErrorMessage(error);
this.cdRef.detectChanges();
}
});
}
updateMerchant(): void {
if (!this.selectedMerchantForEdit) {
this.updateMerchantError = 'Aucun marchand sélectionné pour modification';
return;
}
const validation = MerchantUtils.validateMerchantCreation(this.selectedMerchantForEdit as CreateMerchantDto);
if (validation.length > 0) {
this.updateMerchantError = validation.join(', ');
return;
}
this.updatingMerchant = true;
this.updateMerchantError = '';
// Conversion pour l'API
const merchantId = this.selectedMerchantForEdit.id!;
const updateDto: UpdateMerchantDto = {
name: this.selectedMerchantForEdit.name,
logo: this.selectedMerchantForEdit.logo,
description: this.selectedMerchantForEdit.description,
adresse: this.selectedMerchantForEdit.adresse,
phone: this.selectedMerchantForEdit.phone
};
this.merchantConfigService.updateMerchant(merchantId, updateDto)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (updatedMerchant) => {
// Conversion pour Angular
const frontendMerchant = this.convertMerchantToFrontend(updatedMerchant);
this.updatingMerchant = false;
this.modalService.dismissAll();
this.refreshMerchantsList();
// Mettre à jour le cache
if (this.selectedMerchantId) {
this.merchantProfiles[this.selectedMerchantId] = frontendMerchant;
}
this.cdRef.detectChanges();
},
error: (error) => {
console.error('❌ Error updating merchant:', error);
this.updatingMerchant = false;
this.updateMerchantError = this.getUpdateErrorMessage(error);
this.cdRef.detectChanges();
}
});
}
confirmDeleteMerchant(): void {
if (!this.selectedMerchantForDelete) {
this.deleteMerchantError = 'Aucun marchand sélectionné pour suppression';
return;
}
this.deletingMerchant = true;
this.deleteMerchantError = '';
// Conversion pour l'API
const merchantId = this.selectedMerchantForDelete.id!;
this.merchantConfigService.deleteMerchant(merchantId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: () => {
console.log('✅ Merchant deleted successfully');
this.deletingMerchant = false;
this.modalService.dismissAll();
this.refreshMerchantsList();
// Nettoyer le cache
if (this.selectedMerchantId === this.selectedMerchantForDelete?.id) {
this.backToList();
}
this.cdRef.detectChanges();
},
error: (error) => {
console.error('❌ Error deleting merchant:', error);
this.deletingMerchant = false;
this.deleteMerchantError = this.getDeleteErrorMessage(error);
this.cdRef.detectChanges();
}
});
}
// ==================== MÉTHODES UTILITAIRES ====================
private getDefaultMerchantForm(): CreateMerchantDto {
return {
name: '',
logo: '',
description: '',
adresse: '',
phone: '',
configs: [{
name: ConfigType.API_KEY,
value: '',
operatorId: Operator.ORANGE_OSN
}],
technicalContacts: [{
firstName: '',
lastName: '',
phone: '',
email: ''
}]
};
}
private resetMerchantForm(): void {
this.newMerchant = this.getDefaultMerchantForm();
console.log('🔄 Merchant form reset');
}
private populateEditForm(merchant: Merchant): void {
this.selectedMerchantForEdit = { ...merchant };
}
private refreshMerchantsList(): void {
if (this.merchantConfigsList && typeof this.merchantConfigsList.refreshData === 'function') {
console.log('🔄 Refreshing merchants list...');
this.merchantConfigsList.refreshData();
} else {
console.warn('❌ MerchantConfigsList component not available for refresh');
this.showTab('list');
}
}
// ==================== GESTION DES ERREURS ====================
private getCreateErrorMessage(error: any): string {
if (error.error?.message) return error.error.message;
if (error.status === 400) return 'Données invalides. Vérifiez les champs.';
if (error.status === 409) return 'Un marchand similaire existe déjà.';
if (error.status === 403) return 'Permission refusée.';
return 'Erreur lors de la création. Veuillez réessayer.';
}
private getUpdateErrorMessage(error: any): string {
if (error.error?.message) return error.error.message;
if (error.status === 400) return 'Données invalides.';
if (error.status === 404) return 'Marchand non trouvé.';
if (error.status === 403) return 'Permission refusée.';
return 'Erreur lors de la modification. Veuillez réessayer.';
}
private getDeleteErrorMessage(error: any): string {
if (error.error?.message) return error.error.message;
if (error.status === 404) return 'Marchand non trouvé.';
if (error.status === 403) return 'Permission refusée.';
if (error.status === 409) return 'Marchand utilisé, impossible de supprimer.';
return 'Erreur lors de la suppression. Veuillez réessayer.';
}
// ==================== MÉTHODES TEMPLATE ====================
getOperatorName(operatorId: Operator): string {
return MerchantUtils.getOperatorName(operatorId);
}
getConfigTypeName(configType: ConfigType): string {
return MerchantUtils.getConfigTypeName(configType);
}
// ==================== GETTERS TEMPLATE ====================
get currentMerchantProfile(): Merchant | null {
return this.selectedMerchantId ? this.merchantProfiles[this.selectedMerchantId] : null;
}
get isLoadingMerchant(): boolean {
return this.selectedMerchantId ? this.loadingMerchants[this.selectedMerchantId] : false;
}
get showMerchantPartnerField(): boolean {
return this.canManageMerchants;
}
get requireMerchantPartnerSelection(): boolean {
return this.canManageMerchants;
}
get isPartnerUser(): boolean {
return this.currentUserRole === UserRole.DCB_PARTNER ||
this.currentUserRole === UserRole.DCB_PARTNER_ADMIN;
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
} }

View File

@ -23,7 +23,7 @@ import { Help } from '@modules/help/help';
import { About } from '@modules/about/about'; import { About } from '@modules/about/about';
import { SubscriptionsManagement } from './subscriptions/subscriptions'; import { SubscriptionsManagement } from './subscriptions/subscriptions';
import { SubscriptionPayments } from './subscriptions/subscription-payments/subscription-payments'; import { SubscriptionPayments } from './subscriptions/subscription-payments/subscription-payments';
import { MerchantConfig } from './merchant-config/merchant-config'; import { MerchantConfigManagement } from './merchant-config/merchant-config';
const routes: Routes = [ const routes: Routes = [
// --------------------------- // ---------------------------
@ -191,7 +191,7 @@ const routes: Routes = [
// --------------------------- // ---------------------------
{ {
path: 'merchant-config', path: 'merchant-config',
component: MerchantConfig, component: MerchantConfigManagement,
canActivate: [authGuard, roleGuard], canActivate: [authGuard, roleGuard],
data: { data: {
title: 'Merchant Config', title: 'Merchant Config',