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

@ -645,4 +645,4 @@
.small { .small {
font-size: 0.8125rem; font-size: 0.8125rem;
} }
} }

View File

@ -35,41 +35,36 @@ export class App implements OnInit {
private async initializeAuth(): Promise<void> { private async initializeAuth(): Promise<void> {
try { try {
const isAuthenticated = await this.authService.initialize(); const isAuthenticated = await this.authService.initialize();
setTimeout(() => { // Attendre la vraie route après bootstrap
this.handleInitialNavigation(isAuthenticated); this.router.events
}); .pipe(filter(event => event instanceof NavigationEnd))
.subscribe(() => {
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é
this.router.navigate(['/auth/login']); if (!isAuthenticated) {
} else if (isAuthenticated && this.shouldRedirectToDashboard(currentUrl)) { if (!this.isPublicRoute(currentUrl)) {
this.router.navigate(['/dcb-dashboard']); this.router.navigate(['/auth/login']);
}
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(() => {
}
// 1) Si déjà authentifié
// 🔒 Étape 1 : Vérifier 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> {
const token = this.getAccessToken();
await new Promise(resolve => setTimeout(resolve, 0));
// Pas de token → pas authentifié
if (!token) {
this.initialized$.next(true);
return false;
}
// Token expiré → tenter refresh
if (this.isTokenExpired(token)) {
const ok = await this.tryRefreshToken();
this.initialized$.next(true);
return ok;
}
// Token valide → charger profil
try { try {
const token = this.getAccessToken();
if (!token) {
setTimeout(() => {
this.initialized$.next(true);
});
return false;
}
if (this.isTokenExpired(token)) {
const refreshSuccess = await this.tryRefreshToken();
setTimeout(() => {
this.initialized$.next(true);
});
return refreshSuccess;
}
// Token valide : charger le profil utilisateur
await firstValueFrom(this.loadUserProfile()); await firstValueFrom(this.loadUserProfile());
this.authState$.next(true);
setTimeout(() => { this.initialized$.next(true);
this.authState$.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);
}
}

File diff suppressed because it is too large Load Diff

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;
return this.http.get<ApiResponse<PaginatedResponse<Merchant>>>(this.baseApiUrl, { params: httpParams }).pipe( const totalPages = Math.ceil(total / limit);
map(response => {
if (!response.success) { // Appliquer la pagination côté client
throw new Error(response.error || 'Failed to load merchants'); const startIndex = (page - 1) * limit;
} const endIndex = startIndex + limit;
return response.data!; const paginatedItems = apiMerchants.slice(startIndex, endIndex);
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,
return response.data!; id: apiMerchant.id?.toString() || '', // number → string
}), configs: (apiMerchant.configs || []).map((config: any) => this.mapApiConfigToConfig(config)),
catchError(error => { users: (apiMerchant.merchantUsers || []).map((user: any) => this.mapApiUserToUser(user)), // merchantUsers → users
console.error(`Error updating status for merchant ${merchantId}:`, error); technicalContacts: (apiMerchant.technicalContacts || []).map((contact: any) => this.mapApiContactToContact(contact)),
return throwError(() => error); };
}) }
);
// Méthode de mapping pour les configurations
private mapApiConfigToConfig(apiConfig: any): MerchantConfig {
return {
...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',