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

This commit is contained in:
diallolatoile 2025-12-15 03:25:04 +00:00
parent 7f26a4bdea
commit 02d58ba4fa
41 changed files with 4312 additions and 2473 deletions

View File

@ -7,6 +7,7 @@ import { provideIcons } from '@ng-icons/core';
import { Title } from '@angular/platform-browser'; import { Title } from '@angular/platform-browser';
import { filter, map, mergeMap } from 'rxjs'; import { filter, map, mergeMap } from 'rxjs';
import { AuthService } from './core/services/auth.service'; import { AuthService } from './core/services/auth.service';
import { RoleSyncService } from './core/services/role-sync.service';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
@ -22,6 +23,7 @@ export class App implements OnInit {
private router = inject(Router); private router = inject(Router);
private activatedRoute = inject(ActivatedRoute); private activatedRoute = inject(ActivatedRoute);
private authService = inject(AuthService); private authService = inject(AuthService);
private roleSyncService = inject(RoleSyncService);
private cdr = inject(ChangeDetectorRef); private cdr = inject(ChangeDetectorRef);
async ngOnInit(): Promise<void> { async ngOnInit(): Promise<void> {

View File

@ -0,0 +1,149 @@
// [file name]: transactions/models/transaction.ts
import { Currency } from '@core/models/dcb-bo-hub-subscription.model';
// Types de transaction basés sur les abonnements
export enum TransactionType {
SUBSCRIPTION_PAYMENT = 'SUBSCRIPTION_PAYMENT',
SUBSCRIPTION_RENEWAL = 'SUBSCRIPTION_RENEWAL',
ONE_TIME_PAYMENT = 'ONE_TIME_PAYMENT'
}
export interface Transaction {
// Identifiants
id: string;
externalReference?: string;
subscriptionId?: number;
// Informations financières
amount: number;
currency: Currency;
// Statut et type
status: TransactionStatus;
type: TransactionType;
// Informations produit/abonnement
productId: string;
productName: string;
periodicity?: string;
// Dates
transactionDate: Date;
createdAt: Date;
updatedAt: Date;
nextPaymentDate?: Date;
// Informations marchand
merchantPartnerId?: number;
// Métadonnées
metadata?: any;
}
export interface TransactionQuery {
page?: number;
limit?: number;
search?: string;
status?: TransactionStatus;
startDate?: Date;
endDate?: Date;
merchantPartnerId?: number;
periodicity?: string;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}
export interface TransactionStats {
total: number;
totalAmount: number;
successCount: number;
failedCount: number;
pendingCount: number;
averageAmount: number;
byPeriodicity?: {
DAILY: number;
WEEKLY: number;
MONTHLY: number;
YEARLY: number;
};
}
export interface PaginatedTransactions {
data: Transaction[];
total: number;
page: number;
limit: number;
totalPages: number;
stats: TransactionStats;
}
export type TransactionStatus =
| 'PENDING'
| 'SUCCESS'
| 'FAILED'
| 'CANCELLED'
| 'EXPIRED';
export interface RefundRequest {
transactionId: string;
subscriptionId?: number;
reason?: string;
amount?: number;
}
export interface TransactionExportRequest {
format: 'csv' | 'excel' | 'pdf';
query: TransactionQuery;
columns?: string[];
}
// Utilitaires pour les transactions
export class TransactionUtils {
static getStatusDisplayName(status: TransactionStatus): string {
const statusNames = {
'PENDING': 'En attente',
'SUCCESS': 'Réussi',
'FAILED': 'Échoué',
'CANCELLED': 'Annulé',
'EXPIRED': 'Expiré'
};
return statusNames[status] || status;
}
static getTypeDisplayName(type: TransactionType): string {
const typeNames = {
[TransactionType.SUBSCRIPTION_PAYMENT]: 'Paiement d\'abonnement',
[TransactionType.SUBSCRIPTION_RENEWAL]: 'Renouvellement d\'abonnement',
[TransactionType.ONE_TIME_PAYMENT]: 'Paiement unique'
};
return typeNames[type] || type;
}
static formatAmount(amount: number, currency: Currency): string {
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: currency
}).format(amount);
}
static getPeriodicityDisplayName(periodicity: string): string {
const periodicityNames: Record<string, string> = {
'Daily': 'Quotidien',
'Weekly': 'Hebdomadaire',
'Monthly': 'Mensuel',
'Yearly': 'Annuel'
};
return periodicityNames[periodicity] || periodicity;
}
static mapSubscriptionStatus(status: string): TransactionStatus {
const statusMap: Record<string, TransactionStatus> = {
'ACTIVE': 'SUCCESS',
'PENDING': 'PENDING',
'SUSPENDED': 'FAILED',
'CANCELLED': 'CANCELLED',
'EXPIRED': 'EXPIRED'
};
return statusMap[status] || 'PENDING';
}
}

View File

@ -46,24 +46,36 @@ export interface UsersStatistics {
totalUsers: number; totalUsers: number;
} }
// dcb-bo-hub-user.model.ts - MIS À JOUR
export interface User { export interface User {
id: string; // UUID Keycloak id: string;
username: string; username: string;
email: string; email: string;
firstName: string; firstName?: string;
lastName: string; lastName?: string;
enabled: boolean; enabled: boolean;
emailVerified: boolean; emailVerified: boolean;
userType: UserType; // HUB ou MERCHANT_PARTNER userType: UserType; // HUB ou MERCHANT_PARTNER
role: UserRole; // Rôle(s)
role: UserRole; // Champ critique pour la logique
merchantPartnerId?: string; // ID INT dans Merchant Config pour CE merchant
// Métadonnées
createdBy?: string; createdBy?: string;
createdByUsername?: string; createdByUsername?: string;
createdTimestamp: number; createdTimestamp?: number;
lastLogin?: number; lastLogin?: number;
profileImage?: string | null }
export interface AssociateUserToMerchantDto {
userId: string;
merchantPartnerId: string;
role: UserRole; // Rôle à assigner dans le contexte du merchant
}
export interface DissociateUserFromMerchantDto {
userId: string;
merchantPartnerId: string;
} }
export interface SyncResult { export interface SyncResult {
@ -84,6 +96,7 @@ export interface CreateUserDto {
role: UserRole; role: UserRole;
enabled?: boolean; enabled?: boolean;
emailVerified?: boolean; emailVerified?: boolean;
merchantPartnerId?: string;
} }
export interface UpdateUserDto { export interface UpdateUserDto {
@ -142,6 +155,7 @@ export interface SearchUsersParams {
role?: UserRole; role?: UserRole;
enabled?: boolean; enabled?: boolean;
userType?: UserType; userType?: UserType;
merchantPartnerId?: string;
page?: number; page?: number;
limit?: number; limit?: number;
} }

View File

@ -63,9 +63,7 @@ export interface MerchantUser {
username?: string; username?: string;
email?: string; email?: string;
firstName?: string; firstName?: string;
lastName?: string; lastName?: string; // Référence au merchant dans MerchantConfig
merchantPartnerId?: number
merchantConfigId?: string; // Référence au merchant dans MerchantConfig
createdAt?: string; createdAt?: string;
updatedAt?: string; updatedAt?: string;
} }
@ -113,8 +111,6 @@ export interface ApiMerchantUser {
email?: string; email?: string;
firstName?: string; firstName?: string;
lastName?: string; lastName?: string;
merchantPartnerId?: number;
merchantConfigId?: string;
createdAt?: string; createdAt?: string;
updatedAt?: string; updatedAt?: string;
} }

View File

@ -1,15 +1,18 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { HttpClient, HttpErrorResponse, HttpHeaders } from '@angular/common/http';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { environment } from '@environments/environment'; import { environment } from '@environments/environment';
import { BehaviorSubject, Observable, throwError, tap, catchError } from 'rxjs'; import { BehaviorSubject, Observable, throwError, tap, catchError, finalize, of } from 'rxjs';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { DashboardAccessService } from '@modules/dcb-dashboard/services/dashboard-access.service';
import { import {
User, User,
UserType, UserType,
UserRole, UserRole,
} from '@core/models/dcb-bo-hub-user.model'; } from '@core/models/dcb-bo-hub-user.model';
import { TransactionAccessService } from '@modules/transactions/services/transaction-access.service';
// === INTERFACES DTO AUTH === // === INTERFACES DTO AUTH ===
export interface LoginDto { export interface LoginDto {
@ -59,6 +62,9 @@ export class AuthService {
private readonly tokenKey = 'access_token'; private readonly tokenKey = 'access_token';
private readonly refreshTokenKey = 'refresh_token'; private readonly refreshTokenKey = 'refresh_token';
private readonly dashboardAccessService = inject(DashboardAccessService);
private readonly transactionAccessService = inject(TransactionAccessService);
private authState$ = new BehaviorSubject<boolean>(this.isAuthenticated()); private authState$ = new BehaviorSubject<boolean>(this.isAuthenticated());
private userProfile$ = new BehaviorSubject<User | null>(null); private userProfile$ = new BehaviorSubject<User | null>(null);
private initialized$ = new BehaviorSubject<boolean>(false); private initialized$ = new BehaviorSubject<boolean>(false);
@ -168,19 +174,113 @@ export class AuthService {
/** /**
* Déconnexion utilisateur * Déconnexion utilisateur
*/ */
/**
* Déconnexion utilisateur avec nettoyage complet
*/
logout(): Observable<LogoutResponseDto> { logout(): Observable<LogoutResponseDto> {
const token = this.getAccessToken();
// Si pas de token, nettoyer et retourner un observable complet
if (!token) {
this.clearAuthData();
return of({ message: 'Already logged out' });
}
// Ajouter le token dans le header si nécessaire
const headers = new HttpHeaders({
'Authorization': `Bearer ${token}`
});
return this.http.post<LogoutResponseDto>( return this.http.post<LogoutResponseDto>(
`${environment.iamApiUrl}/auth/logout`, `${environment.iamApiUrl}/auth/logout`,
{} {},
{ headers }
).pipe( ).pipe(
tap(() => this.clearAuthData()), tap(() => {
catchError(error => {
this.clearAuthData(); this.clearAuthData();
return throwError(() => error); this.dashboardAccessService.clearCache();
this.transactionAccessService.clearCache();
this.clearAllStorage(); // Nettoyer tout le storage
}),
catchError(error => {
// Même en cas d'erreur, nettoyer tout
this.clearAuthData();
this.dashboardAccessService.clearCache();
this.transactionAccessService.clearCache();
this.clearAllStorage();
console.warn('Logout API error, but local data cleared:', error);
// Retourner un succès simulé pour permettre la navigation
return of({ message: 'Local session cleared' });
}),
finalize(() => {
// Garantir le nettoyage dans tous les cas
this.clearAuthData();
this.dashboardAccessService.clearCache();
this.transactionAccessService.clearCache();
}) })
); );
} }
/**
* Déconnexion forcée sans appel API
*/
forceLogout(): void {
this.clearAuthData();
this.dashboardAccessService.clearCache();
this.transactionAccessService.clearCache();
this.clearAllStorage();
}
/**
* Nettoyer toutes les données d'authentification
*/
private clearAuthData(): void {
this.dashboardAccessService.clearCache();
this.transactionAccessService.clearCache();
// Supprimer tous les tokens et données utilisateur
localStorage.removeItem(this.tokenKey);
localStorage.removeItem(this.refreshTokenKey);
localStorage.removeItem('user_profile');
localStorage.removeItem('auth_state');
// Nettoyer sessionStorage également
sessionStorage.clear();
// Réinitialiser les BehaviorSubjects
this.authState$.next(false);
this.userProfile$.next(null);
}
/**
* Nettoyer tout le stockage local
*/
private clearAllStorage(): void {
// Supprimer toutes les clés liées à l'authentification
Object.keys(localStorage).forEach(key => {
if (key.includes('token') || key.includes('auth') || key.includes('user')) {
localStorage.removeItem(key);
}
});
// Nettoyer sessionStorage
sessionStorage.clear();
// Nettoyer les cookies
this.clearCookies();
}
/**
* Nettoyer les cookies
*/
private clearCookies(): void {
document.cookie.split(';').forEach(cookie => {
const [name] = cookie.split('=');
document.cookie = `${name.trim()}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
});
}
/** /**
* Chargement du profil utilisateur * Chargement du profil utilisateur
*/ */
@ -231,6 +331,7 @@ export class AuthService {
lastName: apiUser.lastName || apiUser.lastname || apiUser.family_name || '', lastName: apiUser.lastName || apiUser.lastname || apiUser.family_name || '',
enabled: apiUser.enabled ?? apiUser.active ?? true, enabled: apiUser.enabled ?? apiUser.active ?? true,
emailVerified: apiUser.emailVerified ?? apiUser.email_verified ?? false, emailVerified: apiUser.emailVerified ?? apiUser.email_verified ?? false,
merchantPartnerId: apiUser.merchantPartnerId || apiUser.partnerId || apiUser.merchantId || null,
userType: userType, userType: userType,
role: apiUser.clientRoles || apiUser.clientRoles?.[0] || '', // Gérer rôle unique ou tableau role: apiUser.clientRoles || apiUser.clientRoles?.[0] || '', // Gérer rôle unique ou tableau
createdBy: apiUser.createdBy || apiUser.creatorId || null, createdBy: apiUser.createdBy || apiUser.creatorId || null,
@ -256,13 +357,6 @@ export class AuthService {
} }
} }
private clearAuthData(): void {
localStorage.removeItem(this.tokenKey);
localStorage.removeItem(this.refreshTokenKey);
this.authState$.next(false);
this.userProfile$.next(null);
}
// === VALIDATION DU TOKEN === // === VALIDATION DU TOKEN ===
validateToken(): Observable<TokenValidationResponseDto> { validateToken(): Observable<TokenValidationResponseDto> {
@ -449,6 +543,14 @@ export class AuthService {
return profile?.id || null; return profile?.id || null;
} }
/**
* Récupère le merchantPartnerId de l'utilisateur courant (si marchand)
*/
getCurrentMerchantPartnerId(): string | null {
const profile = this.userProfile$.value;
return profile?.merchantPartnerId || null;
}
/** /**
* Vérifie si le profil fourni est celui de l'utilisateur courant * Vérifie si le profil fourni est celui de l'utilisateur courant
*/ */

View File

@ -0,0 +1,445 @@
import { Injectable, inject } from '@angular/core';
import { HubUsersService } from '@modules/hub-users-management/hub-users.service';
import { MerchantUsersService } from '@modules/hub-users-management/merchant-users.service';
import { BehaviorSubject, Observable, map, tap, of, catchError } from 'rxjs';
import { UserRole, UserType, AvailableRole } from '@core/models/dcb-bo-hub-user.model';
// Interfaces
export interface RolePermission {
canCreateUsers: boolean;
canEditUsers: boolean;
canDeleteUsers: boolean;
canManageRoles: boolean;
canViewStats: boolean;
canManageMerchants: boolean;
canAccessAdmin: boolean;
canAccessSupport: boolean;
canAccessPartner: boolean;
assignableRoles: UserRole[];
}
export interface AvailableRolesWithPermissions {
roles: (AvailableRole & { permissions: RolePermission })[];
}
interface RoleConfig {
label: string;
description: string;
badgeClass: string;
icon: string;
permissions: RolePermission;
}
// Permissions par défaut
const DEFAULT_PERMISSIONS: RolePermission = {
canCreateUsers: false,
canEditUsers: false,
canDeleteUsers: false,
canManageRoles: false,
canViewStats: false,
canManageMerchants: false,
canAccessAdmin: false,
canAccessSupport: false,
canAccessPartner: false,
assignableRoles: []
};
// Configuration des rôles
const ROLE_CONFIG: Record<UserRole, RoleConfig> = {
[UserRole.DCB_ADMIN]: {
label: 'Administrateur DCB',
description: 'Administrateur système avec tous les accès',
badgeClass: 'bg-danger',
icon: 'lucideShield',
permissions: {
canCreateUsers: true,
canEditUsers: true,
canDeleteUsers: true,
canManageRoles: true,
canViewStats: true,
canManageMerchants: true,
canAccessAdmin: true,
canAccessSupport: true,
canAccessPartner: true,
assignableRoles: Object.values(UserRole)
}
},
[UserRole.DCB_SUPPORT]: {
label: 'Support DCB',
description: 'Support technique avec accès étendus',
badgeClass: 'bg-info',
icon: 'lucideHeadphones',
permissions: {
canCreateUsers: true,
canEditUsers: true,
canDeleteUsers: false,
canManageRoles: true,
canViewStats: true,
canManageMerchants: true,
canAccessAdmin: false,
canAccessSupport: true,
canAccessPartner: true,
assignableRoles: [
UserRole.DCB_SUPPORT,
UserRole.DCB_PARTNER_ADMIN,
UserRole.DCB_PARTNER_MANAGER,
UserRole.DCB_PARTNER_SUPPORT
]
}
},
[UserRole.DCB_PARTNER_ADMIN]: {
label: 'Admin Partenaire',
description: 'Administrateur de partenaire marchand',
badgeClass: 'bg-warning',
icon: 'lucideShieldCheck',
permissions: {
canCreateUsers: true,
canEditUsers: true,
canDeleteUsers: true,
canManageRoles: true,
canViewStats: true,
canManageMerchants: false,
canAccessAdmin: false,
canAccessSupport: false,
canAccessPartner: false,
assignableRoles: [UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT]
}
},
[UserRole.DCB_PARTNER_MANAGER]: {
label: 'Manager Partenaire',
description: 'Manager opérationnel partenaire',
badgeClass: 'bg-success',
icon: 'lucideUserCog',
permissions: {
canCreateUsers: false,
canEditUsers: false,
canDeleteUsers: false,
canManageRoles: false,
canViewStats: true,
canManageMerchants: true,
canAccessAdmin: false,
canAccessSupport: false,
canAccessPartner: true,
assignableRoles: []
}
},
[UserRole.DCB_PARTNER_SUPPORT]: {
label: 'Support Partenaire',
description: 'Support technique partenaire',
badgeClass: 'bg-secondary',
icon: 'lucideHeadphones',
permissions: {
canCreateUsers: false,
canEditUsers: false,
canDeleteUsers: false,
canManageRoles: false,
canViewStats: true,
canManageMerchants: false,
canAccessAdmin: false,
canAccessSupport: false,
canAccessPartner: true,
assignableRoles: []
}
},
[UserRole.MERCHANT_CONFIG_ADMIN]: {
label: 'Admin Marchand',
description: 'Administrateur de configuration marchand',
badgeClass: 'bg-warning',
icon: 'lucideSettings',
permissions: DEFAULT_PERMISSIONS
},
[UserRole.MERCHANT_CONFIG_MANAGER]: {
label: 'Manager Marchand',
description: 'Manager de configuration marchand',
badgeClass: 'bg-success',
icon: 'lucideUserCog',
permissions: DEFAULT_PERMISSIONS
},
[UserRole.MERCHANT_CONFIG_TECHNICAL]: {
label: 'Technique Marchand',
description: 'Support technique configuration marchand',
badgeClass: 'bg-secondary',
icon: 'lucideWrench',
permissions: DEFAULT_PERMISSIONS
},
[UserRole.MERCHANT_CONFIG_VIEWER]: {
label: 'Visualiseur Marchand',
description: 'Visualiseur de configuration marchand',
badgeClass: 'bg-light',
icon: 'lucideEye',
permissions: DEFAULT_PERMISSIONS
}
} as const;
// Rôles Hub (pour les filtres)
const HUB_ROLES = [
UserRole.DCB_ADMIN,
UserRole.DCB_SUPPORT,
] as const;
// Rôles Marchands (pour les filtres)
const MERCHANT_ROLES = [
UserRole.DCB_PARTNER_ADMIN,
UserRole.DCB_PARTNER_MANAGER,
UserRole.DCB_PARTNER_SUPPORT,
UserRole.MERCHANT_CONFIG_ADMIN,
UserRole.MERCHANT_CONFIG_MANAGER,
UserRole.MERCHANT_CONFIG_TECHNICAL,
UserRole.MERCHANT_CONFIG_VIEWER
] as const;
@Injectable({
providedIn: 'root'
})
export class RoleManagementService {
private hubUsersService = inject(HubUsersService);
private merchantUsersService = inject(MerchantUsersService);
private availableRoles$ = new BehaviorSubject<AvailableRolesWithPermissions | null>(null);
private currentUserRole$ = new BehaviorSubject<UserRole | null>(null);
/**
* Charge les rôles Hub disponibles
*/
loadAvailableHubRoles(): Observable<AvailableRolesWithPermissions> {
return this.loadRoles(
() => this.hubUsersService.getAvailableHubRoles(),
'hub'
);
}
/**
* Charge les rôles Marchands disponibles
*/
loadAvailableMerchantRoles(): Observable<AvailableRolesWithPermissions> {
return this.loadRoles(
() => this.merchantUsersService.getAvailableMerchantRoles(),
'merchant'
);
}
/**
* Méthode générique pour charger les rôles
*/
private loadRoles(
fetchFn: () => Observable<{ roles: AvailableRole[] }>,
type: 'hub' | 'merchant'
): Observable<AvailableRolesWithPermissions> {
return fetchFn().pipe(
map(apiResponse => ({
roles: apiResponse.roles.map(role => ({
...role,
permissions: this.getPermissionsForRole(role.value)
}))
})),
tap(roles => this.availableRoles$.next(roles)),
catchError(error => {
console.error(`Error loading ${type} roles:`, error);
return of({ roles: [] } as AvailableRolesWithPermissions);
})
);
}
/**
* Définit le rôle de l'utilisateur courant
*/
setCurrentUserRole(role: UserRole): void {
this.currentUserRole$.next(role);
}
/**
* Récupère le rôle de l'utilisateur courant
*/
getCurrentUserRole(): Observable<UserRole | null> {
return this.currentUserRole$.asObservable();
}
/**
* Récupère la valeur actuelle du rôle utilisateur (synchrone)
*/
getCurrentUserRoleValue(): UserRole | null {
return this.currentUserRole$.value;
}
/**
* Récupère les permissions détaillées selon le rôle
*/
getPermissionsForRole(role: UserRole | null): RolePermission {
if (!role) {
return DEFAULT_PERMISSIONS;
}
return ROLE_CONFIG[role]?.permissions || DEFAULT_PERMISSIONS;
}
/**
* Vérifie si un rôle peut être attribué par l'utilisateur courant
*/
canAssignRole(currentUserRole: UserRole | null, targetRole: UserRole): boolean {
if (!currentUserRole) return false;
const fullPermissionRoles = [
UserRole.DCB_ADMIN,
UserRole.DCB_SUPPORT
];
if (fullPermissionRoles.includes(currentUserRole)) {
return true;
}
const permissions = this.getPermissionsForRole(currentUserRole);
return permissions.assignableRoles.includes(targetRole);
}
// Méthodes d'utilité pour les permissions
canCreateUsers(currentUserRole: UserRole | null): boolean {
return this.getPermission(currentUserRole, 'canCreateUsers');
}
canEditUsers(currentUserRole: UserRole | null): boolean {
return this.getPermission(currentUserRole, 'canEditUsers');
}
canDeleteUsers(currentUserRole: UserRole | null): boolean {
return this.getPermission(currentUserRole, 'canDeleteUsers');
}
canManageRoles(currentUserRole: UserRole | null): boolean {
return this.getPermission(currentUserRole, 'canManageRoles');
}
canViewStats(currentUserRole: UserRole | null): boolean {
return this.getPermission(currentUserRole, 'canViewStats');
}
canManageMerchants(currentUserRole: UserRole | null): boolean {
return this.getPermission(currentUserRole, 'canManageMerchants');
}
canAccessAdmin(currentUserRole: UserRole | null): boolean {
return this.getPermission(currentUserRole, 'canAccessAdmin');
}
canAccessSupport(currentUserRole: UserRole | null): boolean {
return this.getPermission(currentUserRole, 'canAccessSupport');
}
canAccessPartner(currentUserRole: UserRole | null): boolean {
return this.getPermission(currentUserRole, 'canAccessPartner');
}
/**
* Méthode helper générique pour les permissions
*/
private getPermission(
role: UserRole | null,
permissionKey: keyof RolePermission
): boolean {
if (!role) return false;
const permissions = this.getPermissionsForRole(role);
return Boolean(permissions[permissionKey]);
}
/**
* Méthodes d'utilité pour les rôles
*/
getRoleLabel(role: string): string {
const userRole = role as UserRole;
return ROLE_CONFIG[userRole]?.label || role;
}
getRoleDescription(role: string | UserRole): string {
const userRole = role as UserRole;
return ROLE_CONFIG[userRole]?.description || 'Description non disponible';
}
getRoleBadgeClass(role: string): string {
const userRole = role as UserRole;
return ROLE_CONFIG[userRole]?.badgeClass || 'bg-secondary';
}
getRoleIcon(role: string): string {
const userRole = role as UserRole;
return ROLE_CONFIG[userRole]?.icon || 'lucideUser';
}
/**
* Vérifications de type de rôle
*/
isAdminRole(role: UserRole): boolean {
return role === UserRole.DCB_ADMIN;
}
isSupportRole(role: UserRole): boolean {
return role === UserRole.DCB_SUPPORT;
}
isMerchantUserRole(role: UserRole): boolean {
return role === UserRole.DCB_PARTNER_ADMIN
|| role === UserRole.DCB_PARTNER_MANAGER
|| role === UserRole.DCB_PARTNER_SUPPORT
|| role === UserRole.MERCHANT_CONFIG_ADMIN
|| role === UserRole.MERCHANT_CONFIG_MANAGER
|| role === UserRole.MERCHANT_CONFIG_TECHNICAL
|| role === UserRole.MERCHANT_CONFIG_VIEWER;
}
/**
* Gestion des listes de rôles
*/
getAllRoles(): UserRole[] {
return Object.values(UserRole);
}
getHubRoles(): UserRole[] {
return [...HUB_ROLES];
}
getMerchantRoles(): UserRole[] {
return [...MERCHANT_ROLES];
}
getAssignableRoles(currentUserRole: UserRole | null): UserRole[] {
if (!currentUserRole) return [];
return this.getPermissionsForRole(currentUserRole).assignableRoles;
}
getAssignableHubRoles(currentUserRole: UserRole | null): UserRole[] {
return this.filterAssignableRoles(currentUserRole, HUB_ROLES);
}
getAssignableMerchantRoles(currentUserRole: UserRole | null): UserRole[] {
return this.filterAssignableRoles(currentUserRole, MERCHANT_ROLES);
}
private filterAssignableRoles(
currentUserRole: UserRole | null,
roleList: readonly UserRole[]
): UserRole[] {
if (!currentUserRole) return [];
const permissions = this.getPermissionsForRole(currentUserRole);
return roleList.filter(role => permissions.assignableRoles.includes(role));
}
/**
* Vérifications de rôles
*/
hasRole(userRole: UserRole | null, targetRole: UserRole): boolean {
return userRole === targetRole;
}
hasAnyRole(userRole: UserRole | null, targetRoles: UserRole[]): boolean {
return userRole ? targetRoles.includes(userRole) : false;
}
/**
* Gestion du cache
*/
clearCache(): void {
this.availableRoles$.next(null);
this.currentUserRole$.next(null);
}
getAvailableRoles(): Observable<AvailableRolesWithPermissions | null> {
return this.availableRoles$.asObservable();
}
}

View File

@ -1,445 +1,231 @@
import { Injectable, inject } from '@angular/core'; import { Injectable } from '@angular/core';
import { HubUsersService } from '@modules/hub-users-management/hub-users.service';
import { MerchantUsersService } from '@modules/hub-users-management/merchant-users.service';
import { BehaviorSubject, Observable, map, tap, of, catchError } from 'rxjs';
import { UserRole, UserType, AvailableRole } from '@core/models/dcb-bo-hub-user.model';
// Interfaces export enum UserRole {
export interface RolePermission { // Rôles Hub
canCreateUsers: boolean; DCB_ADMIN = 'dcb-admin',
canEditUsers: boolean; DCB_SUPPORT = 'dcb-support',
canDeleteUsers: boolean;
canManageRoles: boolean; // Rôles Merchant User
canViewStats: boolean; // Rôles Partenaires (Business)
canManageMerchants: boolean; DCB_PARTNER_ADMIN = 'dcb-partner-admin',
canAccessAdmin: boolean; DCB_PARTNER_MANAGER = 'dcb-partner-manager',
canAccessSupport: boolean; DCB_PARTNER_SUPPORT = 'dcb-partner-support',
canAccessPartner: boolean;
assignableRoles: UserRole[]; // Rôles Configuration Marchands (Technique)
MERCHANT_CONFIG_ADMIN = 'ADMIN',
MERCHANT_CONFIG_MANAGER = 'MANAGER',
MERCHANT_CONFIG_TECHNICAL = 'TECHNICAL',
MERCHANT_CONFIG_VIEWER = 'VIEWER',
} }
export interface AvailableRolesWithPermissions { type RoleCategory = 'hub' | 'partner' | 'config';
roles: (AvailableRole & { permissions: RolePermission })[];
}
interface RoleConfig { @Injectable({ providedIn: 'root' })
label: string;
description: string;
badgeClass: string;
icon: string;
permissions: RolePermission;
}
// Permissions par défaut
const DEFAULT_PERMISSIONS: RolePermission = {
canCreateUsers: false,
canEditUsers: false,
canDeleteUsers: false,
canManageRoles: false,
canViewStats: false,
canManageMerchants: false,
canAccessAdmin: false,
canAccessSupport: false,
canAccessPartner: false,
assignableRoles: []
};
// Configuration des rôles
const ROLE_CONFIG: Record<UserRole, RoleConfig> = {
[UserRole.DCB_ADMIN]: {
label: 'Administrateur DCB',
description: 'Administrateur système avec tous les accès',
badgeClass: 'bg-danger',
icon: 'lucideShield',
permissions: {
canCreateUsers: true,
canEditUsers: true,
canDeleteUsers: true,
canManageRoles: true,
canViewStats: true,
canManageMerchants: true,
canAccessAdmin: true,
canAccessSupport: true,
canAccessPartner: true,
assignableRoles: Object.values(UserRole)
}
},
[UserRole.DCB_SUPPORT]: {
label: 'Support DCB',
description: 'Support technique avec accès étendus',
badgeClass: 'bg-info',
icon: 'lucideHeadphones',
permissions: {
canCreateUsers: true,
canEditUsers: true,
canDeleteUsers: false,
canManageRoles: true,
canViewStats: true,
canManageMerchants: true,
canAccessAdmin: false,
canAccessSupport: true,
canAccessPartner: true,
assignableRoles: [
UserRole.DCB_SUPPORT,
UserRole.DCB_PARTNER_ADMIN,
UserRole.DCB_PARTNER_MANAGER,
UserRole.DCB_PARTNER_SUPPORT
]
}
},
[UserRole.DCB_PARTNER_ADMIN]: {
label: 'Admin Partenaire',
description: 'Administrateur de partenaire marchand',
badgeClass: 'bg-warning',
icon: 'lucideShieldCheck',
permissions: {
canCreateUsers: true,
canEditUsers: true,
canDeleteUsers: true,
canManageRoles: true,
canViewStats: true,
canManageMerchants: false,
canAccessAdmin: false,
canAccessSupport: false,
canAccessPartner: false,
assignableRoles: [UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT]
}
},
[UserRole.DCB_PARTNER_MANAGER]: {
label: 'Manager Partenaire',
description: 'Manager opérationnel partenaire',
badgeClass: 'bg-success',
icon: 'lucideUserCog',
permissions: {
canCreateUsers: false,
canEditUsers: false,
canDeleteUsers: false,
canManageRoles: false,
canViewStats: true,
canManageMerchants: true,
canAccessAdmin: false,
canAccessSupport: false,
canAccessPartner: true,
assignableRoles: []
}
},
[UserRole.DCB_PARTNER_SUPPORT]: {
label: 'Support Partenaire',
description: 'Support technique partenaire',
badgeClass: 'bg-secondary',
icon: 'lucideHeadphones',
permissions: {
canCreateUsers: false,
canEditUsers: false,
canDeleteUsers: false,
canManageRoles: false,
canViewStats: true,
canManageMerchants: false,
canAccessAdmin: false,
canAccessSupport: false,
canAccessPartner: true,
assignableRoles: []
}
},
[UserRole.MERCHANT_CONFIG_ADMIN]: {
label: 'Admin Marchand',
description: 'Administrateur de configuration marchand',
badgeClass: 'bg-warning',
icon: 'lucideSettings',
permissions: DEFAULT_PERMISSIONS
},
[UserRole.MERCHANT_CONFIG_MANAGER]: {
label: 'Manager Marchand',
description: 'Manager de configuration marchand',
badgeClass: 'bg-success',
icon: 'lucideUserCog',
permissions: DEFAULT_PERMISSIONS
},
[UserRole.MERCHANT_CONFIG_TECHNICAL]: {
label: 'Technique Marchand',
description: 'Support technique configuration marchand',
badgeClass: 'bg-secondary',
icon: 'lucideWrench',
permissions: DEFAULT_PERMISSIONS
},
[UserRole.MERCHANT_CONFIG_VIEWER]: {
label: 'Visualiseur Marchand',
description: 'Visualiseur de configuration marchand',
badgeClass: 'bg-light',
icon: 'lucideEye',
permissions: DEFAULT_PERMISSIONS
}
} as const;
// Rôles Hub (pour les filtres)
const HUB_ROLES = [
UserRole.DCB_ADMIN,
UserRole.DCB_SUPPORT,
] as const;
// Rôles Marchands (pour les filtres)
const MERCHANT_ROLES = [
UserRole.DCB_PARTNER_ADMIN,
UserRole.DCB_PARTNER_MANAGER,
UserRole.DCB_PARTNER_SUPPORT,
UserRole.MERCHANT_CONFIG_ADMIN,
UserRole.MERCHANT_CONFIG_MANAGER,
UserRole.MERCHANT_CONFIG_TECHNICAL,
UserRole.MERCHANT_CONFIG_VIEWER
] as const;
@Injectable({
providedIn: 'root'
})
export class RoleManagementService { export class RoleManagementService {
private hubUsersService = inject(HubUsersService); private currentRole: UserRole | null = null;
private merchantUsersService = inject(MerchantUsersService);
private availableRoles$ = new BehaviorSubject<AvailableRolesWithPermissions | null>(null); // Mapping des rôles équivalents
private currentUserRole$ = new BehaviorSubject<UserRole | null>(null); private readonly roleEquivalents = new Map<UserRole, UserRole[]>([
[UserRole.DCB_PARTNER_ADMIN, [UserRole.MERCHANT_CONFIG_ADMIN]],
[UserRole.DCB_PARTNER_MANAGER, [UserRole.MERCHANT_CONFIG_MANAGER]],
[UserRole.DCB_PARTNER_SUPPORT, [UserRole.MERCHANT_CONFIG_TECHNICAL, UserRole.MERCHANT_CONFIG_VIEWER]],
[UserRole.MERCHANT_CONFIG_ADMIN, [UserRole.DCB_PARTNER_ADMIN]],
[UserRole.MERCHANT_CONFIG_MANAGER, [UserRole.DCB_PARTNER_MANAGER]],
[UserRole.MERCHANT_CONFIG_TECHNICAL, [UserRole.DCB_PARTNER_SUPPORT]],
[UserRole.MERCHANT_CONFIG_VIEWER, [UserRole.DCB_PARTNER_SUPPORT]]
]);
/** // Catégories des rôles
* Charge les rôles Hub disponibles private readonly roleCategories: Record<UserRole, RoleCategory> = {
*/ [UserRole.DCB_ADMIN]: 'hub',
loadAvailableHubRoles(): Observable<AvailableRolesWithPermissions> { [UserRole.DCB_SUPPORT]: 'hub',
return this.loadRoles( [UserRole.DCB_PARTNER_ADMIN]: 'partner',
() => this.hubUsersService.getAvailableHubRoles(), [UserRole.DCB_PARTNER_MANAGER]: 'partner',
'hub' [UserRole.DCB_PARTNER_SUPPORT]: 'partner',
); [UserRole.MERCHANT_CONFIG_ADMIN]: 'config',
[UserRole.MERCHANT_CONFIG_MANAGER]: 'config',
[UserRole.MERCHANT_CONFIG_TECHNICAL]: 'config',
[UserRole.MERCHANT_CONFIG_VIEWER]: 'config'
};
// Labels des rôles
private readonly roleLabels: Record<UserRole, string> = {
[UserRole.DCB_ADMIN]: 'Administrateur DCB',
[UserRole.DCB_SUPPORT]: 'Support DCB',
[UserRole.DCB_PARTNER_ADMIN]: 'Admin Partenaire',
[UserRole.DCB_PARTNER_MANAGER]: 'Manager Partenaire',
[UserRole.DCB_PARTNER_SUPPORT]: 'Support Partenaire',
[UserRole.MERCHANT_CONFIG_ADMIN]: 'Admin Configuration',
[UserRole.MERCHANT_CONFIG_MANAGER]: 'Manager Configuration',
[UserRole.MERCHANT_CONFIG_TECHNICAL]: 'Technique Configuration',
[UserRole.MERCHANT_CONFIG_VIEWER]: 'Visualiseur Configuration'
};
// Icônes des rôles
private readonly roleIcons: Record<UserRole, string> = {
[UserRole.DCB_ADMIN]: 'lucideShield',
[UserRole.DCB_SUPPORT]: 'lucideHeadphones',
[UserRole.DCB_PARTNER_ADMIN]: 'lucideShieldCheck',
[UserRole.DCB_PARTNER_MANAGER]: 'lucideUserCog',
[UserRole.DCB_PARTNER_SUPPORT]: 'user-headset',
[UserRole.MERCHANT_CONFIG_ADMIN]: 'lucideSettings',
[UserRole.MERCHANT_CONFIG_MANAGER]: 'lucideSliders',
[UserRole.MERCHANT_CONFIG_TECHNICAL]: 'lucideWrench',
[UserRole.MERCHANT_CONFIG_VIEWER]: 'lucideEye'
};
// === GESTION DU RÔLE COURANT ===
setCurrentRole(role: UserRole | null): void {
this.currentRole = role;
} }
/** getCurrentRole(): UserRole | null {
* Charge les rôles Marchands disponibles return this.currentRole;
*/
loadAvailableMerchantRoles(): Observable<AvailableRolesWithPermissions> {
return this.loadRoles(
() => this.merchantUsersService.getAvailableMerchantRoles(),
'merchant'
);
} }
/** // === VÉRIFICATIONS DE RÔLES INDIVIDUELS ===
* Méthode générique pour charger les rôles
*/ isAdmin(): boolean {
private loadRoles( return this.currentRole === UserRole.DCB_ADMIN;
fetchFn: () => Observable<{ roles: AvailableRole[] }>,
type: 'hub' | 'merchant'
): Observable<AvailableRolesWithPermissions> {
return fetchFn().pipe(
map(apiResponse => ({
roles: apiResponse.roles.map(role => ({
...role,
permissions: this.getPermissionsForRole(role.value)
}))
})),
tap(roles => this.availableRoles$.next(roles)),
catchError(error => {
console.error(`Error loading ${type} roles:`, error);
return of({ roles: [] } as AvailableRolesWithPermissions);
})
);
} }
/** isSupport(): boolean {
* Définit le rôle de l'utilisateur courant return this.currentRole === UserRole.DCB_SUPPORT;
*/
setCurrentUserRole(role: UserRole): void {
this.currentUserRole$.next(role);
} }
/** isPartnerAdmin(): boolean {
* Récupère le rôle de l'utilisateur courant return this.currentRole === UserRole.DCB_PARTNER_ADMIN;
*/
getCurrentUserRole(): Observable<UserRole | null> {
return this.currentUserRole$.asObservable();
} }
/** isPartnerManager(): boolean {
* Récupère la valeur actuelle du rôle utilisateur (synchrone) return this.currentRole === UserRole.DCB_PARTNER_MANAGER;
*/
getCurrentUserRoleValue(): UserRole | null {
return this.currentUserRole$.value;
} }
/** isPartnerSupport(): boolean {
* Récupère les permissions détaillées selon le rôle return this.currentRole === UserRole.DCB_PARTNER_SUPPORT;
*/
getPermissionsForRole(role: UserRole | null): RolePermission {
if (!role) {
return DEFAULT_PERMISSIONS;
}
return ROLE_CONFIG[role]?.permissions || DEFAULT_PERMISSIONS;
} }
/** isConfigAdmin(): boolean {
* Vérifie si un rôle peut être attribué par l'utilisateur courant return this.currentRole === UserRole.MERCHANT_CONFIG_ADMIN;
*/ }
canAssignRole(currentUserRole: UserRole | null, targetRole: UserRole): boolean {
if (!currentUserRole) return false;
const fullPermissionRoles = [ isConfigManager(): boolean {
UserRole.DCB_ADMIN, return this.currentRole === UserRole.MERCHANT_CONFIG_MANAGER;
UserRole.DCB_SUPPORT }
];
if (fullPermissionRoles.includes(currentUserRole)) { isConfigTechnical(): boolean {
return true; return this.currentRole === UserRole.MERCHANT_CONFIG_TECHNICAL;
}
isConfigViewer(): boolean {
return this.currentRole === UserRole.MERCHANT_CONFIG_VIEWER;
}
// === VÉRIFICATIONS AVEC MAPPING ===
isAnyAdmin(): boolean {
return this.isAdmin() || this.isPartnerAdmin() || this.isConfigAdmin();
}
isAnyManager(): boolean {
return this.isPartnerManager() || this.isConfigManager();
}
isAnySupport(): boolean {
return this.isSupport() || this.isPartnerSupport() || this.isConfigTechnical() || this.isConfigViewer();
}
// === VÉRIFICATIONS DE CATÉGORIES ===
isHubUser(): boolean {
return this.isAdmin() || this.isSupport();
}
isPartnerUser(): boolean {
return this.isPartnerAdmin() || this.isPartnerManager() || this.isPartnerSupport();
}
isConfigUser(): boolean {
return this.isConfigAdmin() || this.isConfigManager() || this.isConfigTechnical() || this.isConfigViewer();
}
getRoleCategory(): RoleCategory | null {
if (!this.currentRole) return null;
return this.roleCategories[this.currentRole];
}
// === MAPPING ET ÉQUIVALENTS ===
getEquivalentRoles(): UserRole[] {
if (!this.currentRole) return [];
const equivalents = [this.currentRole];
const mappedRoles = this.roleEquivalents.get(this.currentRole);
if (mappedRoles) {
equivalents.push(...mappedRoles);
} }
const permissions = this.getPermissionsForRole(currentUserRole); return [...new Set(equivalents)];
return permissions.assignableRoles.includes(targetRole);
} }
// Méthodes d'utilité pour les permissions hasEquivalentRole(targetRole: UserRole): boolean {
canCreateUsers(currentUserRole: UserRole | null): boolean { if (!this.currentRole) return false;
return this.getPermission(currentUserRole, 'canCreateUsers');
if (this.currentRole === targetRole) return true;
const equivalents = this.roleEquivalents.get(this.currentRole);
return equivalents ? equivalents.includes(targetRole) : false;
} }
canEditUsers(currentUserRole: UserRole | null): boolean { getMappedRole(): UserRole | null {
return this.getPermission(currentUserRole, 'canEditUsers'); if (!this.currentRole) return null;
const equivalents = this.roleEquivalents.get(this.currentRole);
return equivalents && equivalents.length > 0 ? equivalents[0] : this.currentRole;
} }
canDeleteUsers(currentUserRole: UserRole | null): boolean { // === VÉRIFICATIONS GÉNÉRIQUES ===
return this.getPermission(currentUserRole, 'canDeleteUsers');
hasRole(role: UserRole): boolean {
return this.currentRole === role;
} }
canManageRoles(currentUserRole: UserRole | null): boolean { hasAnyRole(...roles: UserRole[]): boolean {
return this.getPermission(currentUserRole, 'canManageRoles'); if (!this.currentRole) return false;
return roles.includes(this.currentRole);
} }
canViewStats(currentUserRole: UserRole | null): boolean { // === UTILITAIRES ===
return this.getPermission(currentUserRole, 'canViewStats');
getRoleLabel(role?: UserRole): string {
const targetRole = role || this.currentRole;
return targetRole ? this.roleLabels[targetRole] || targetRole : '';
} }
canManageMerchants(currentUserRole: UserRole | null): boolean { getRoleIcon(role?: UserRole): string {
return this.getPermission(currentUserRole, 'canManageMerchants'); const targetRole = role || this.currentRole;
return targetRole ? this.roleIcons[targetRole] || 'user' : 'user';
} }
canAccessAdmin(currentUserRole: UserRole | null): boolean {
return this.getPermission(currentUserRole, 'canAccessAdmin');
}
canAccessSupport(currentUserRole: UserRole | null): boolean {
return this.getPermission(currentUserRole, 'canAccessSupport');
}
canAccessPartner(currentUserRole: UserRole | null): boolean {
return this.getPermission(currentUserRole, 'canAccessPartner');
}
/**
* Méthode helper générique pour les permissions
*/
private getPermission(
role: UserRole | null,
permissionKey: keyof RolePermission
): boolean {
if (!role) return false;
const permissions = this.getPermissionsForRole(role);
return Boolean(permissions[permissionKey]);
}
/**
* Méthodes d'utilité pour les rôles
*/
getRoleLabel(role: string): string {
const userRole = role as UserRole;
return ROLE_CONFIG[userRole]?.label || role;
}
getRoleDescription(role: string | UserRole): string {
const userRole = role as UserRole;
return ROLE_CONFIG[userRole]?.description || 'Description non disponible';
}
getRoleBadgeClass(role: string): string {
const userRole = role as UserRole;
return ROLE_CONFIG[userRole]?.badgeClass || 'bg-secondary';
}
getRoleIcon(role: string): string {
const userRole = role as UserRole;
return ROLE_CONFIG[userRole]?.icon || 'lucideUser';
}
/**
* Vérifications de type de rôle
*/
isAdminRole(role: UserRole): boolean {
return role === UserRole.DCB_ADMIN;
}
isSupportRole(role: UserRole): boolean {
return role === UserRole.DCB_SUPPORT;
}
isMerchantUserRole(role: UserRole): boolean {
return role === UserRole.DCB_PARTNER_ADMIN
|| role === UserRole.DCB_PARTNER_MANAGER
|| role === UserRole.DCB_PARTNER_SUPPORT
|| role === UserRole.MERCHANT_CONFIG_ADMIN
|| role === UserRole.MERCHANT_CONFIG_MANAGER
|| role === UserRole.MERCHANT_CONFIG_TECHNICAL
|| role === UserRole.MERCHANT_CONFIG_VIEWER;
}
/**
* Gestion des listes de rôles
*/
getAllRoles(): UserRole[] { getAllRoles(): UserRole[] {
return Object.values(UserRole); return Object.values(UserRole);
} }
getHubRoles(): UserRole[] { getHubRoles(): UserRole[] {
return [...HUB_ROLES]; return [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT];
} }
getMerchantRoles(): UserRole[] { getPartnerRoles(): UserRole[] {
return [...MERCHANT_ROLES]; return [UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT];
} }
getAssignableRoles(currentUserRole: UserRole | null): UserRole[] { getConfigRoles(): UserRole[] {
if (!currentUserRole) return []; return [
return this.getPermissionsForRole(currentUserRole).assignableRoles; UserRole.MERCHANT_CONFIG_ADMIN,
} UserRole.MERCHANT_CONFIG_MANAGER,
UserRole.MERCHANT_CONFIG_TECHNICAL,
getAssignableHubRoles(currentUserRole: UserRole | null): UserRole[] { UserRole.MERCHANT_CONFIG_VIEWER
return this.filterAssignableRoles(currentUserRole, HUB_ROLES); ];
}
getAssignableMerchantRoles(currentUserRole: UserRole | null): UserRole[] {
return this.filterAssignableRoles(currentUserRole, MERCHANT_ROLES);
}
private filterAssignableRoles(
currentUserRole: UserRole | null,
roleList: readonly UserRole[]
): UserRole[] {
if (!currentUserRole) return [];
const permissions = this.getPermissionsForRole(currentUserRole);
return roleList.filter(role => permissions.assignableRoles.includes(role));
}
/**
* Vérifications de rôles
*/
hasRole(userRole: UserRole | null, targetRole: UserRole): boolean {
return userRole === targetRole;
}
hasAnyRole(userRole: UserRole | null, targetRoles: UserRole[]): boolean {
return userRole ? targetRoles.includes(userRole) : false;
}
/**
* Gestion du cache
*/
clearCache(): void {
this.availableRoles$.next(null);
this.currentUserRole$.next(null);
}
getAvailableRoles(): Observable<AvailableRolesWithPermissions | null> {
return this.availableRoles$.asObservable();
} }
} }

View File

@ -2,6 +2,7 @@ import { Injectable, inject } from '@angular/core';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { PermissionsService } from './permissions.service'; import { PermissionsService } from './permissions.service';
import { MenuItemType, UserDropdownItemType } from '@/app/types/layout'; import { MenuItemType, UserDropdownItemType } from '@/app/types/layout';
import { UserRole } from './hub-users-roles-management.service';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class MenuService { export class MenuService {
@ -23,7 +24,7 @@ export class MenuService {
return this.permissionsService.canAccessModule(modulePath, userRoles); return this.permissionsService.canAccessModule(modulePath, userRoles);
} }
private filterMenuItems(items: MenuItemType[], userRoles: string[]): MenuItemType[] { private filterMenuItems(items: MenuItemType[], userRoles: UserRole[]): MenuItemType[] {
return items return items
.filter(item => this.shouldDisplayMenuItem(item, userRoles)) .filter(item => this.shouldDisplayMenuItem(item, userRoles))
.map(item => ({ .map(item => ({
@ -32,7 +33,7 @@ export class MenuService {
})); }));
} }
private shouldDisplayMenuItem(item: MenuItemType, userRoles: string[]): boolean { private shouldDisplayMenuItem(item: MenuItemType, userRoles: UserRole[]): boolean {
if (item.isTitle) return true; if (item.isTitle) return true;
if (item.url && item.url !== '#') { if (item.url && item.url !== '#') {
@ -47,7 +48,7 @@ export class MenuService {
return true; return true;
} }
private filterUserDropdownItems(items: UserDropdownItemType[], userRoles: string[]): UserDropdownItemType[] { private filterUserDropdownItems(items: UserDropdownItemType[], userRoles: UserRole[]): UserDropdownItemType[] {
return items.filter(item => { return items.filter(item => {
if (item.isDivider || item.isHeader || !item.url || item.url === '#') { if (item.isDivider || item.isHeader || !item.url || item.url === '#') {
return true; return true;
@ -132,7 +133,6 @@ export class MenuService {
]; ];
} }
// Mettez à jour votre méthode
private getFullUserDropdown(): UserDropdownItemType[] { private getFullUserDropdown(): UserDropdownItemType[] {
return [ return [
{ label: 'Welcome back!', isHeader: true }, { label: 'Welcome back!', isHeader: true },

View File

@ -0,0 +1,49 @@
import { Injectable, inject } from '@angular/core';
import { AuthService } from './auth.service';
import { RoleManagementService } from './hub-users-roles-management.service';
import { filter } from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class RoleSyncService {
private authService = inject(AuthService);
private roleService = inject(RoleManagementService);
constructor() {
this.setupRoleSync();
}
private setupRoleSync(): void {
// Synchroniser le rôle lorsque l'état d'authentification change
this.authService.getAuthState().subscribe(isAuthenticated => {
if (isAuthenticated) {
this.syncRoleFromAuth();
} else {
// Utilisateur déconnecté - réinitialiser le rôle
this.roleService.setCurrentRole(null);
}
});
// Synchroniser également lorsque le profil utilisateur change
this.authService.getUserProfile()
.pipe(filter(profile => !!profile))
.subscribe(() => {
if (this.authService.isAuthenticated()) {
this.syncRoleFromAuth();
}
});
}
private syncRoleFromAuth(): void {
try {
const userRole = this.authService.getCurrentUserRole();
if (userRole) {
this.roleService.setCurrentRole(userRole);
console.log(`Rôle synchronisé: ${userRole}`);
}
} catch (error) {
console.error('Erreur lors de la synchronisation du rôle:', error);
}
}
}

View File

@ -1,18 +1,47 @@
<div class="sidenav-user d-flex align-items-center"> <div class="sidenav-user d-flex align-items-center">
<img <!-- État de chargement avec @if -->
src="assets/images/users/user-2.jpg" @if (isLoading) {
class="rounded-circle me-2" <div class="d-flex align-items-center">
width="36" <div class="spinner-border spinner-border-sm text-primary me-2" role="status">
height="36" <span class="visually-hidden">Loading...</span>
alt="user-image" </div>
onerror="this.src='assets/images/users/user-default.jpg'" <div>
/> <h5 class="my-0 fw-semibold">Chargement...</h5>
<div> <h6 class="my-0 text-muted">Profil utilisateur</h6>
<h5 class="my-0 fw-semibold"> </div>
{{ getUserInitials() }} | {{ getDisplayName() }} </div>
</h5> }
<h6 class="my-0 text-muted">
{{ getUserRole(user) }} <!-- États normal et erreur avec @if -->
</h6> @if (!isLoading) {
</div> <div class="d-flex align-items-center">
<img
[src]="getUserAvatar()"
class="rounded-circle me-2"
width="36"
height="36"
alt="user-image"
(error)="onAvatarError($event)"
/>
<div>
<h5 class="my-0 fw-semibold">
{{ getDisplayName() || 'Utilisateur' }}
</h5>
<h6 class="my-0 text-muted">
<!-- Afficher le rôle ou un message d'erreur -->
@if (!hasError) {
<span>{{ getUserRole() }}</span>
}
@if (hasError) {
<span class="text-danger">
Erreur -
<button class="btn btn-link btn-sm p-0" (click)="retryLoadProfile()">
Réessayer
</button>
</span>
}
</h6>
</div>
</div>
}
</div> </div>

View File

@ -3,7 +3,7 @@ import { NgbCollapseModule } from '@ng-bootstrap/ng-bootstrap';
import { userDropdownItems } from '@layouts/components/data'; import { userDropdownItems } from '@layouts/components/data';
import { AuthService } from '@/app/core/services/auth.service'; import { AuthService } from '@/app/core/services/auth.service';
import { User, UserRole } from '@core/models/dcb-bo-hub-user.model'; import { User, UserRole } from '@core/models/dcb-bo-hub-user.model';
import { Subject, takeUntil } from 'rxjs'; import { Subject, takeUntil, distinctUntilChanged, filter, startWith } from 'rxjs';
@Component({ @Component({
selector: 'app-user-profile', selector: 'app-user-profile',
@ -19,43 +19,159 @@ export class UserProfileComponent implements OnInit, OnDestroy {
user: User | null = null; user: User | null = null;
isLoading = true; isLoading = true;
hasError = false;
currentProfileLoaded = false;
ngOnInit(): void { ngOnInit(): void {
this.loadUser(); console.log('🔄 UserProfileComponent initializing...');
// Subscribe to auth state changes // S'abonner aux changements du profil utilisateur
this.setupProfileSubscription();
// S'abonner aux changements d'état d'authentification
this.setupAuthStateSubscription();
// Vérifier l'état initial
this.checkInitialState();
}
/**
* Vérifie l'état initial sans accéder à la propriété privée
*/
private checkInitialState(): void {
// Utiliser la méthode publique isAuthenticated()
if (this.authService.isAuthenticated()) {
console.log('🔍 User is authenticated, checking profile...');
// Le profil sera chargé via la subscription
} else {
console.log('🔐 User not authenticated');
this.user = null;
this.isLoading = false;
this.cdr.detectChanges();
}
}
/**
* S'abonne aux changements du profil utilisateur via la méthode publique
*/
private setupProfileSubscription(): void {
this.authService.getUserProfile()
.pipe(
takeUntil(this.destroy$),
startWith(null), // Émettre null au début
filter(profile => {
// Filtrer les valeurs null si on a déjà un profil
if (this.currentProfileLoaded && profile === null) {
return false;
}
return true;
}),
distinctUntilChanged((prev, curr) => {
// Comparer les profils par leur ID
return prev?.id === curr?.id;
})
)
.subscribe({
next: (profile) => {
if (profile) {
console.log('📥 User profile updated:', profile.username);
this.user = profile;
this.currentProfileLoaded = true;
} else {
console.log('📭 User profile cleared');
this.user = null;
this.currentProfileLoaded = false;
}
this.isLoading = false;
this.hasError = false;
this.cdr.detectChanges();
},
error: (error) => {
console.error('❌ Error in profile subscription:', error);
this.hasError = true;
this.isLoading = false;
this.cdr.detectChanges();
}
});
}
/**
* S'abonne aux changements d'état d'authentification
*/
private setupAuthStateSubscription(): void {
this.authService.getAuthState() this.authService.getAuthState()
.pipe(takeUntil(this.destroy$)) .pipe(takeUntil(this.destroy$))
.subscribe({ .subscribe({
next: (isAuthenticated) => { next: (isAuthenticated) => {
console.log('🔐 Auth state changed:', isAuthenticated);
if (isAuthenticated) { if (isAuthenticated) {
this.loadUser(); // Si l'utilisateur vient de se connecter, forcer le chargement du profil
if (!this.currentProfileLoaded) {
console.log('🔄 User just logged in, loading profile...');
this.loadUserProfile();
}
} else { } else {
// Si l'utilisateur s'est déconnecté
console.log('👋 User logged out');
this.user = null; this.user = null;
this.currentProfileLoaded = false;
this.isLoading = false; this.isLoading = false;
this.cdr.detectChanges();
} }
} }
}); });
} }
loadUser(): void { /**
* Charge le profil utilisateur explicitement
*/
loadUserProfile(): void {
console.log('🚀 Loading user profile...');
this.isLoading = true; this.isLoading = true;
this.hasError = false;
this.cdr.detectChanges();
this.authService.getProfile() this.authService.loadUserProfile()
.pipe(takeUntil(this.destroy$)) .pipe(takeUntil(this.destroy$))
.subscribe({ .subscribe({
next: (profile) => { next: (profile) => {
this.user = profile; console.log('✅ Profile loaded successfully:', profile.username);
// Note: le profil sera automatiquement mis à jour via la subscription getUserProfile()
this.isLoading = false; this.isLoading = false;
this.cdr.detectChanges();
}, },
error: (error) => { error: (error) => {
console.error('Failed to load user profile:', error); console.error('Failed to load user profile:', error);
this.user = null; this.hasError = true;
this.isLoading = false; this.isLoading = false;
this.cdr.detectChanges();
// Essayer de rafraîchir le token si erreur 401
if (error.status === 401) {
this.authService.refreshAccessToken().subscribe({
next: () => {
// Recharger le profil après rafraîchissement
this.loadUserProfile();
},
error: (refreshError) => {
console.error('❌ Refresh token failed:', refreshError);
}
});
}
} }
}); });
} }
/**
* Méthode pour réessayer le chargement en cas d'erreur
*/
retryLoadProfile(): void {
console.log('🔄 Retrying profile load...');
this.loadUserProfile();
}
ngOnDestroy(): void { ngOnDestroy(): void {
this.destroy$.next(); this.destroy$.next();
this.destroy$.complete(); this.destroy$.complete();
@ -64,7 +180,7 @@ export class UserProfileComponent implements OnInit, OnDestroy {
// Helper methods for template // Helper methods for template
getUserInitials(): string { getUserInitials(): string {
if (!this.user?.firstName || !this.user?.lastName) { if (!this.user?.firstName || !this.user?.lastName) {
return 'UU'; // User Unknown return this.user?.username?.substring(0, 2).toUpperCase() || 'UU';
} }
return `${this.user.firstName.charAt(0)}${this.user.lastName.charAt(0)}`.toUpperCase(); return `${this.user.firstName.charAt(0)}${this.user.lastName.charAt(0)}`.toUpperCase();
} }
@ -75,26 +191,70 @@ export class UserProfileComponent implements OnInit, OnDestroy {
} }
// Get user role with proper mapping // Get user role with proper mapping
getUserRole(user: User | null): string { getUserRole(): string {
if (!user) return 'Utilisateur'; if (!this.user) return 'Utilisateur';
// Use role from profile or fallback to token roles // Utiliser le rôle du profil ou la méthode publique du service
const role = user.role || this.authService.getCurrentUserRoles(); const role = this.user.role || this.authService.getCurrentUserPrimaryRole();
if (!role) return 'Utilisateur';
// Map role to display name // Map role to display name
const roleDisplayNames: { [key in UserRole]: string } = { const roleDisplayNames: { [key: string]: string } = {
[UserRole.DCB_ADMIN]: 'Administrateur système avec tous les accès', [UserRole.DCB_ADMIN]: 'Administrateur système',
[UserRole.DCB_SUPPORT]: 'Support technique avec accès étendus', [UserRole.DCB_SUPPORT]: 'Support technique',
[UserRole.DCB_PARTNER_ADMIN]: 'Administrateur de partenaire marchand', [UserRole.DCB_PARTNER_ADMIN]: 'Administrateur partenaire',
[UserRole.DCB_PARTNER_MANAGER]: 'Manager opérationnel partenaire', [UserRole.DCB_PARTNER_MANAGER]: 'Manager opérationnel',
[UserRole.DCB_PARTNER_SUPPORT]: 'Support technique partenaire', [UserRole.DCB_PARTNER_SUPPORT]: 'Support technique',
[UserRole.MERCHANT_CONFIG_ADMIN]: 'Administrateur de partenaire marchand', [UserRole.MERCHANT_CONFIG_ADMIN]: 'Administrateur configuration',
[UserRole.MERCHANT_CONFIG_MANAGER]: 'Manager opérationnel partenaire', [UserRole.MERCHANT_CONFIG_MANAGER]: 'Manager configuration',
[UserRole.MERCHANT_CONFIG_TECHNICAL]: 'Support technique partenaire', [UserRole.MERCHANT_CONFIG_TECHNICAL]: 'Technicien',
[UserRole.MERCHANT_CONFIG_VIEWER]: 'Support technique partenaire' [UserRole.MERCHANT_CONFIG_VIEWER]: 'Consultant'
}; };
const primaryRole = role; return roleDisplayNames[role] || 'Utilisateur';
return roleDisplayNames[primaryRole] || 'Utilisateur'; }
/**
* Méthode pour obtenir la classe CSS du badge de rôle
*/
getRoleBadgeClass(): string {
if (!this.user?.role) return 'badge bg-secondary';
const roleClassMap: { [key: string]: string } = {
[UserRole.DCB_ADMIN]: 'badge bg-danger',
[UserRole.DCB_SUPPORT]: 'badge bg-info',
[UserRole.DCB_PARTNER_ADMIN]: 'badge bg-primary',
[UserRole.DCB_PARTNER_MANAGER]: 'badge bg-success',
[UserRole.DCB_PARTNER_SUPPORT]: 'badge bg-warning',
[UserRole.MERCHANT_CONFIG_ADMIN]: 'badge bg-primary',
[UserRole.MERCHANT_CONFIG_MANAGER]: 'badge bg-success',
[UserRole.MERCHANT_CONFIG_TECHNICAL]: 'badge bg-warning',
[UserRole.MERCHANT_CONFIG_VIEWER]: 'badge bg-secondary'
};
return roleClassMap[this.user.role] || 'badge bg-secondary';
}
/**
* Obtient l'URL de l'avatar de l'utilisateur
*/
getUserAvatar(): string {
if (!this.user) {
return 'assets/images/users/user-default.jpg';
}
// Vous pouvez implémenter une logique pour générer un avatar personnalisé
// ou utiliser une image par défaut basée sur l'email/nom
return `assets/images/users/user-${(this.user.id?.charCodeAt(0) % 5) + 1}.jpg`;
}
/**
* Gère les erreurs de chargement d'avatar
*/
onAvatarError(event: Event): void {
const img = event.target as HTMLImageElement;
img.src = 'assets/images/users/user-default.jpg';
img.onerror = null; // Éviter les boucles infinies
} }
} }

View File

@ -1,7 +1,7 @@
// app/modules/auth/logout/logout.component.ts import { Component, OnDestroy, OnInit } from "@angular/core";
import { Component, OnInit } from '@angular/core'; import { Router } from "@angular/router";
import { Router } from '@angular/router'; import { AuthService } from "@core/services/auth.service";
import { AuthService } from '@core/services/auth.service'; import { Subject, takeUntil, finalize } from "rxjs";
@Component({ @Component({
template: ` template: `
@ -22,7 +22,9 @@ import { AuthService } from '@core/services/auth.service';
} }
`] `]
}) })
export class Logout implements OnInit { export class Logout implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
private isLoggingOut = false; // Flag pour éviter les doubles déconnexions
constructor( constructor(
private authService: AuthService, private authService: AuthService,
@ -30,22 +32,59 @@ export class Logout implements OnInit {
) {} ) {}
ngOnInit() { ngOnInit() {
// Vérifier si nous sommes déjà en train de déconnecter
if (this.isLoggingOut) {
return;
}
this.isLoggingOut = true;
this.logout(); this.logout();
} }
private logout(): void { private logout(): void {
this.authService.logout().subscribe({
next: () => { // Attendre un peu pour laisser le temps à l'UI de s'afficher
// Redirection vers la page de login après déconnexion setTimeout(() => {
this.router.navigate(['/auth/login'], { this.authService.logout().pipe(
queryParams: { logout: 'success' } takeUntil(this.destroy$),
}); finalize(() => {
this.navigateToLogin();
})
).subscribe({
next: () => {
// Succès - navigation gérée dans finalize
},
error: (error) => {
console.warn('Erreur lors de la déconnexion:', error);
}
});
}, 300);
}
private navigateToLogin(): void {
// S'assurer que nous ne naviguons qu'une seule fois
if (this.router.url.includes('/auth/login')) {
return;
}
// Naviguer UNE seule fois vers le login
this.router.navigate(['/auth/login'], {
queryParams: {
logout: 'success',
timestamp: Date.now() // Pour éviter le cache
}, },
error: (error) => { replaceUrl: true // Remplacer l'historique actuel
console.error('Erreur lors de la déconnexion:', error); }).then(() => {
// Rediriger même en cas d'erreur // Marquer comme terminé
this.router.navigate(['/auth/login']); this.isLoggingOut = false;
} }).catch(() => {
this.isLoggingOut = false;
}); });
} }
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
this.isLoggingOut = false;
}
} }

View File

@ -6,19 +6,36 @@
<h1 class="h3 mb-0 text-primary"> <h1 class="h3 mb-0 text-primary">
<ng-icon name="lucideLayoutDashboard" class="me-2"></ng-icon> <ng-icon name="lucideLayoutDashboard" class="me-2"></ng-icon>
Dashboard FinTech Reporting Dashboard FinTech Reporting
<span *ngIf="access.isMerchantUser" class="badge bg-success ms-2">
<ng-icon name="lucideStore" class="me-1"></ng-icon>
Merchant {{ merchantId }}
</span>
<span *ngIf="access.isHubUser" class="badge bg-primary ms-2">
<ng-icon name="lucideShield" class="me-1"></ng-icon>
Hub Admin
</span>
</h1> </h1>
<p class="text-muted mb-0">Surveillance en temps réel des transactions et abonnements</p> <p class="text-muted mb-0">
<ng-icon [name]="currentRoleIcon" class="me-1"></ng-icon>
{{ currentRoleLabel }} - {{ getCurrentMerchantName() }}
</p>
</div> </div>
<div class="d-flex gap-2 align-items-center"> <div class="d-flex gap-2 align-items-center">
<!-- Contrôles rapides --> <!-- Contrôles rapides -->
<div class="btn-group btn-group-sm" role="group"> <div class="btn-group btn-group-sm" role="group">
<!-- Bouton Actualiser selon le type -->
<button class="btn btn-outline-primary btn-sm" <button class="btn btn-outline-primary btn-sm"
(click)="loadAllData()" (click)="refreshData()"
[disabled]="loading.all"> [disabled]="loading.globalData || loading.merchantData">
<ng-icon name="lucideRefreshCw" [class.spin]="loading.all" class="me-1"></ng-icon> <ng-icon name="lucideRefreshCw"
{{ loading.all ? 'Chargement...' : 'Actualiser' }} [class.spin]="loading.globalData || loading.merchantData"
class="me-1"></ng-icon>
{{ (loading.globalData || loading.merchantData) ? 'Chargement...' : 'Actualiser' }}
</button> </button>
<button class="btn btn-outline-danger btn-sm"
<!-- Bouton Sync seulement si autorisé -->
<button *ngIf="canTriggerSync()"
class="btn btn-outline-danger btn-sm"
(click)="triggerSync()" (click)="triggerSync()"
[disabled]="loading.sync"> [disabled]="loading.sync">
<ng-icon name="lucideRefreshCcw" [class.spin]="loading.sync" class="me-1"></ng-icon> <ng-icon name="lucideRefreshCcw" [class.spin]="loading.sync" class="me-1"></ng-icon>
@ -26,56 +43,58 @@
</button> </button>
</div> </div>
<!-- Filtres --> <!-- Filtres selon le type -->
<div class="filters-card"> <div *ngIf="canSelectMerchant() && shouldShowMerchantSelector()" class="filters-card">
<div class="d-flex align-items-center gap-2"> <div class="d-flex align-items-center gap-2">
<!-- Filtre Merchant pour hub users -->
<div class="input-group input-group-sm"> <div class="input-group input-group-sm">
<span class="input-group-text bg-light"> <span class="input-group-text bg-light">
<ng-icon name="lucideStore"></ng-icon> <ng-icon name="lucideStore"></ng-icon>
</span> </span>
<input type="number" class="form-control form-control-sm"
[(ngModel)]="merchantId" min="1" <select class="form-control form-control-sm"
placeholder="Merchant ID" [ngModel]="merchantId"
(change)="refreshMerchantData()" (ngModelChange)="selectMerchant($event)"
style="width: 100px;"> style="width: 180px;">
<option [value]="undefined">
<ng-icon name="lucideGlobe" class="me-1"></ng-icon>
Données globales
</option>
<option *ngFor="let merchant of allowedMerchants" [value]="merchant.id">
{{ merchant.name }} (ID: {{ merchant.id }})
</option>
</select>
</div> </div>
<div class="input-group input-group-sm"> <!-- Badge de contexte -->
<span class="input-group-text bg-light"> <div class="badge" [ngClass]="isViewingGlobal() ? 'bg-info' : 'bg-success'">
<ng-icon name="lucideCalendar"></ng-icon> <ng-icon [name]="isViewingGlobal() ? 'lucideGlobe' : 'lucideStore'" class="me-1"></ng-icon>
</span> {{ getCurrentMerchantName() }}
<input type="date" class="form-control form-control-sm"
[(ngModel)]="startDate"
style="width: 110px;">
<input type="date" class="form-control form-control-sm"
[(ngModel)]="endDate"
style="width: 110px;">
<button class="btn btn-outline-secondary btn-sm"
(click)="refreshWithDates()"
title="Appliquer les dates">
<ng-icon name="lucideFilter"></ng-icon>
</button>
</div> </div>
</div>
</div>
<div class="dropdown" ngbDropdown> <!-- Options dropdown seulement pour hub users admin -->
<button class="btn btn-outline-secondary btn-sm dropdown-toggle" <div *ngIf="access.isHubUser && canManageMerchants()" ngbDropdown class="dropdown">
type="button" <!-- Bouton déclencheur -->
ngbDropdownToggle> <button class="btn btn-outline-secondary dropdown-toggle"
<ng-icon name="lucideSettings" class="me-1"></ng-icon> ngbDropdownToggle
Options type="button">
</button> <ng-icon name="lucideSettings" class="me-2"></ng-icon>
<div class="dropdown-menu dropdown-menu-end" ngbDropdownMenu> Options
<a class="dropdown-item" href="javascript:void(0)" (click)="checkSystemHealth()"> </button>
<ng-icon name="lucideHeartPulse" class="me-2"></ng-icon>
Vérifier la santé <!-- Menu déroulant -->
</a> <div class="dropdown-menu dropdown-menu-end" ngbDropdownMenu>
<div class="dropdown-divider"></div> <button ngbDropdownItem (click)="checkSystemHealth()">
<a class="dropdown-item" href="javascript:void(0)" (click)="loadAllData()"> <ng-icon name="lucideHeartPulse" class="me-2"></ng-icon>
<ng-icon name="lucideRefreshCw" class="me-2"></ng-icon> Vérifier la santé
Rafraîchir tout </button>
</a> <div class="dropdown-divider"></div>
</div> <button ngbDropdownItem (click)="refreshData()">
</div> <ng-icon name="lucideRefreshCw" class="me-2"></ng-icon>
Rafraîchir tout
</button>
</div> </div>
</div> </div>
</div> </div>
@ -85,31 +104,72 @@
<!-- Barre d'état rapide --> <!-- Barre d'état rapide -->
<div class="status-bar mb-4"> <div class="status-bar mb-4">
<div class="row g-2"> <div class="row g-2">
<!-- Info rôle -->
<div class="col-auto">
<div class="d-flex align-items-center gap-2 p-2 bg-light rounded">
<ng-icon [name]="getRoleStatusIcon()" [class]="getRoleStatusColor()"></ng-icon>
<small>{{ currentRoleLabel }}</small>
</div>
</div>
<!-- Mode d'affichage -->
<div class="col-auto">
<div class="d-flex align-items-center gap-2 p-2 bg-light rounded">
<ng-icon [name]="isViewingGlobal() ? 'lucideGlobe' : 'lucideStore'"
[class]="isViewingGlobal() ? 'text-info' : 'text-success'"></ng-icon>
<small>{{ getCurrentMerchantName() }}</small>
</div>
</div>
<div class="col-auto"> <div class="col-auto">
<div class="d-flex align-items-center gap-2 p-2 bg-light rounded"> <div class="d-flex align-items-center gap-2 p-2 bg-light rounded">
<ng-icon name="lucideClock" class="text-primary"></ng-icon> <ng-icon name="lucideClock" class="text-primary"></ng-icon>
<small>Mis à jour: {{ lastUpdated | date:'HH:mm:ss' }}</small> <small>Mis à jour: {{ lastUpdated | date:'HH:mm:ss' }}</small>
</div> </div>
</div> </div>
<div class="col-auto">
<!-- Services en ligne seulement pour hub users -->
<div *ngIf="shouldShowSystemHealth()" class="col-auto">
<div class="d-flex align-items-center gap-2 p-2 bg-light rounded"> <div class="d-flex align-items-center gap-2 p-2 bg-light rounded">
<ng-icon name="lucideCpu" class="text-success"></ng-icon> <ng-icon name="lucideCpu" class="text-success"></ng-icon>
<small>Services: {{ stats.onlineServices }}/{{ stats.totalServices }} en ligne</small> <small>Services: {{ stats.onlineServices }}/{{ stats.totalServices }} en ligne</small>
</div> </div>
</div> </div>
<div class="col-auto">
<!-- Info merchant pour merchant users -->
<div *ngIf="access.isMerchantUser" class="col-auto">
<div class="d-flex align-items-center gap-2 p-2 bg-light rounded"> <div class="d-flex align-items-center gap-2 p-2 bg-light rounded">
<ng-icon name="lucidePhone" class="text-warning"></ng-icon> <ng-icon name="lucideStore" class="text-success"></ng-icon>
<small>Opérateur: Orange</small> <small>Merchant ID: {{ merchantId }}</small>
</div>
</div>
<!-- Merchant sélectionné pour hub users -->
<div *ngIf="access.isHubUser && isViewingMerchant()" class="col-auto">
<div class="d-flex align-items-center gap-2 p-2 bg-light rounded">
<ng-icon name="lucideStore" class="text-info"></ng-icon>
<small>Merchant ID: {{ merchantId }}</small>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Message de sync --> <!-- Message d'erreur si pas de permissions -->
<div *ngIf="syncResponse" class="alert alert-success alert-dismissible fade show mb-4"> <div *ngIf="!shouldShowTransactions()"
class="alert alert-warning mb-4">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<ng-icon name="lucideCheckCircle" class="me-2 fs-5"></ng-icon> <ng-icon name="lucideAlertCircle" class="me-2"></ng-icon>
<div class="flex-grow-1">
<strong>Permissions insuffisantes</strong>
<div class="text-muted small">Vous n'avez pas les permissions nécessaires pour voir les données.</div>
</div>
</div>
</div>
<!-- Message de sync seulement si autorisé à voir les alertes -->
<div *ngIf="syncResponse && shouldShowAlerts()" class="alert alert-success alert-dismissible fade show mb-4">
<div class="d-flex align-items-center">
<ng-icon name="lucideCheckCircle2" class="me-2 fs-5"></ng-icon>
<div class="flex-grow-1"> <div class="flex-grow-1">
<strong>{{ syncResponse.message }}</strong> <strong>{{ syncResponse.message }}</strong>
<div class="text-muted small">Synchronisée à {{ formatDate(syncResponse.timestamp) }}</div> <div class="text-muted small">Synchronisée à {{ formatDate(syncResponse.timestamp) }}</div>
@ -119,7 +179,7 @@
</div> </div>
<!-- ==================== SECTION DES KPIs HORIZONTAUX ==================== --> <!-- ==================== SECTION DES KPIs HORIZONTAUX ==================== -->
<div class="kpi-section mb-4"> <div *ngIf="shouldShowKPIs()" class="kpi-section mb-4">
<div class="row g-3"> <div class="row g-3">
<!-- Transactions Journalières --> <!-- Transactions Journalières -->
<div class="col-xl-2 col-md-4 col-sm-6"> <div class="col-xl-2 col-md-4 col-sm-6">
@ -171,9 +231,9 @@
</div> </div>
</div> </div>
<!-- Transactions Mensuelles --> <!-- Transactions Mensuel -->
<div class="col-xl-2 col-md-4 col-sm-6"> <div class="col-xl-2 col-md-4 col-sm-6">
<div class="card kpi-card border-start border-success border-4"> <div class="card kpi-card border-start border-info border-4">
<div class="card-body"> <div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-3"> <div class="d-flex justify-content-between align-items-start mb-3">
<div> <div>
@ -181,13 +241,13 @@
<h4 class="fw-bold mb-0">{{ formatNumber(getPaymentStats().monthly.transactions) }}</h4> <h4 class="fw-bold mb-0">{{ formatNumber(getPaymentStats().monthly.transactions) }}</h4>
<small class="text-muted">Mensuel</small> <small class="text-muted">Mensuel</small>
</div> </div>
<div class="avatar-sm bg-success bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center"> <div class="avatar-sm bg-info bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center">
<ng-icon name="lucideCalendar" class="text-success fs-5"></ng-icon> <ng-icon name="lucideCalendar" class="text-info fs-5"></ng-icon>
</div> </div>
</div> </div>
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<span class="text-muted small">{{ formatCurrency(getPaymentStats().monthly.revenue) }}</span> <span class="text-muted small">{{ formatCurrency(getPaymentStats().monthly.revenue) }}</span>
<span class="badge bg-success bg-opacity-25 text-success"> <span class="badge bg-info bg-opacity-25 text-info">
<ng-icon name="lucideArrowUpRight" class="me-1"></ng-icon> <ng-icon name="lucideArrowUpRight" class="me-1"></ng-icon>
{{ getPaymentStats().monthly.successRate | number:'1.0-0' }}% {{ getPaymentStats().monthly.successRate | number:'1.0-0' }}%
</span> </span>
@ -257,7 +317,7 @@
<small class="text-muted">Global</small> <small class="text-muted">Global</small>
</div> </div>
<div class="avatar-sm bg-danger bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center"> <div class="avatar-sm bg-danger bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center">
<ng-icon name="lucideCheckCircle" class="text-danger fs-5"></ng-icon> <ng-icon name="lucideCheckCircle2" class="text-danger fs-5"></ng-icon>
</div> </div>
</div> </div>
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
@ -274,7 +334,7 @@
</div> </div>
<!-- ==================== SECTION DES GRAPHIQUES FLEXIBLES ==================== --> <!-- ==================== SECTION DES GRAPHIQUES FLEXIBLES ==================== -->
<div class="charts-section mb-4" *ngIf="!loading.all"> <div *ngIf="shouldShowCharts() && !loading.globalData && !loading.merchantData" class="charts-section mb-4">
<div class="row g-4"> <div class="row g-4">
<!-- Graphique principal dynamique --> <!-- Graphique principal dynamique -->
<div class="col-xl-8"> <div class="col-xl-8">
@ -285,6 +345,14 @@
<h5 class="card-title mb-0"> <h5 class="card-title mb-0">
<ng-icon [name]="getMetricIcon(dataSelection.metric)" class="text-primary me-2"></ng-icon> <ng-icon [name]="getMetricIcon(dataSelection.metric)" class="text-primary me-2"></ng-icon>
{{ getChartTitle(dataSelection.metric) }} {{ getChartTitle(dataSelection.metric) }}
<span *ngIf="isViewingMerchant()" class="badge bg-success ms-2">
<ng-icon name="lucideStore" class="me-1"></ng-icon>
Merchant {{ merchantId }}
</span>
<span *ngIf="isViewingGlobal()" class="badge bg-info ms-2">
<ng-icon name="lucideGlobe" class="me-1"></ng-icon>
Données globales
</span>
</h5> </h5>
<p class="text-muted small mb-0">Visualisation en temps réel</p> <p class="text-muted small mb-0">Visualisation en temps réel</p>
</div> </div>
@ -334,15 +402,16 @@
<div class="spinner-border text-primary"></div> <div class="spinner-border text-primary"></div>
<p class="mt-2 text-muted">Chargement du graphique...</p> <p class="mt-2 text-muted">Chargement du graphique...</p>
</div> </div>
<div *ngIf="!loading.chart && !dailyTransactions" <div *ngIf="!loading.chart && !getCurrentTransactionData()"
class="text-center py-5 text-muted"> class="text-center py-5 text-muted">
<ng-icon name="lucideBarChart3" class="fs-1 opacity-25"></ng-icon> <ng-icon name="lucideBarChart3" class="fs-1 opacity-25"></ng-icon>
<p class="mt-2">Aucune donnée disponible</p> <p class="mt-2">Aucune donnée disponible</p>
<button class="btn btn-sm btn-outline-primary mt-2" (click)="loadDailyTransactions()"> <button class="btn btn-sm btn-outline-primary mt-2"
(click)="refreshData()">
Charger les données Charger les données
</button> </button>
</div> </div>
<div *ngIf="!loading.chart && dailyTransactions" <div *ngIf="!loading.chart && getCurrentTransactionData()"
class="position-relative" style="height: 300px;"> class="position-relative" style="height: 300px;">
<canvas #mainChartCanvas></canvas> <canvas #mainChartCanvas></canvas>
</div> </div>
@ -353,8 +422,8 @@
<!-- Panneau droit avec 2 mini-graphiques --> <!-- Panneau droit avec 2 mini-graphiques -->
<div class="col-xl-4"> <div class="col-xl-4">
<div class="row h-100 g-4"> <div class="row h-100 g-4">
<!-- Graphique de comparaison --> <!-- Graphique de comparaison seulement pour hub users en mode global -->
<div class="col-12"> <div *ngIf="shouldShowChart('comparison')" class="col-12">
<div class="card h-100"> <div class="card h-100">
<div class="card-header bg-transparent border-bottom-0"> <div class="card-header bg-transparent border-bottom-0">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
@ -380,18 +449,19 @@
</div> </div>
<!-- Taux de succès circulaire --> <!-- Taux de succès circulaire -->
<div class="col-12"> <div *ngIf="shouldShowChart('successRate')" class="col-12">
<div class="card h-100"> <div class="card h-100">
<div class="card-header bg-transparent border-bottom-0"> <div class="card-header bg-transparent border-bottom-0">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0"> <h5 class="card-title mb-0">
<ng-icon name="lucideActivity" class="text-success me-2"></ng-icon> <ng-icon name="lucideActivity" class="text-success me-2"></ng-icon>
Performance Globale Performance
</h5> </h5>
<button class="btn btn-sm btn-outline-success btn-sm" <button class="btn btn-sm btn-outline-success btn-sm"
(click)="refreshDailyTransactions()" (click)="refreshData()"
[disabled]="loading.dailyTransactions"> [disabled]="loading.globalData || loading.merchantData">
<ng-icon name="lucideRefreshCw" [class.spin]="loading.dailyTransactions"></ng-icon> <ng-icon name="lucideRefreshCw"
[class.spin]="loading.globalData || loading.merchantData"></ng-icon>
</button> </button>
</div> </div>
</div> </div>
@ -412,7 +482,7 @@
<div class="col-4"> <div class="col-4">
<div class="p-2 border rounded bg-success bg-opacity-10"> <div class="p-2 border rounded bg-success bg-opacity-10">
<div class="text-success fw-bold"> <div class="text-success fw-bold">
{{ formatNumber(dailyTransactions?.items?.[0]?.successCount || 0) }} {{ formatNumber(getCurrentTransactionData()?.items?.[0]?.successCount || 0) }}
</div> </div>
<small class="text-muted">Réussies</small> <small class="text-muted">Réussies</small>
</div> </div>
@ -420,7 +490,7 @@
<div class="col-4"> <div class="col-4">
<div class="p-2 border rounded bg-danger bg-opacity-10"> <div class="p-2 border rounded bg-danger bg-opacity-10">
<div class="text-danger fw-bold"> <div class="text-danger fw-bold">
{{ formatNumber(dailyTransactions?.items?.[0]?.failedCount || 0) }} {{ formatNumber(getCurrentTransactionData()?.items?.[0]?.failedCount || 0) }}
</div> </div>
<small class="text-muted">Échouées</small> <small class="text-muted">Échouées</small>
</div> </div>
@ -428,7 +498,7 @@
<div class="col-4"> <div class="col-4">
<div class="p-2 border rounded bg-info bg-opacity-10"> <div class="p-2 border rounded bg-info bg-opacity-10">
<div class="text-info fw-bold"> <div class="text-info fw-bold">
{{ formatNumber(dailyTransactions?.items?.[0]?.pendingCount || 0) }} {{ formatNumber(getCurrentTransactionData()?.items?.[0]?.pendingCount || 0) }}
</div> </div>
<small class="text-muted">En attente</small> <small class="text-muted">En attente</small>
</div> </div>
@ -443,7 +513,7 @@
</div> </div>
<!-- ==================== SECTION SANTÉ DES APIS ==================== --> <!-- ==================== SECTION SANTÉ DES APIS ==================== -->
<div class="health-section mb-4"> <div *ngIf="shouldShowSystemHealth()" class="health-section mb-4">
<div class="card"> <div class="card">
<div class="card-header bg-transparent border-bottom-0"> <div class="card-header bg-transparent border-bottom-0">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
@ -462,7 +532,7 @@
</div> </div>
</div> </div>
<button class="btn btn-sm btn-outline-danger" <button class="btn btn-sm btn-outline-danger"
(click)="refreshHealthCheck()" (click)="checkSystemHealth()"
[disabled]="loading.healthCheck"> [disabled]="loading.healthCheck">
<ng-icon name="lucideRefreshCw" [class.spin]="loading.healthCheck"></ng-icon> <ng-icon name="lucideRefreshCw" [class.spin]="loading.healthCheck"></ng-icon>
</button> </button>
@ -522,7 +592,7 @@
</div> </div>
<!-- ==================== SECTION DES TABLEAUX ==================== --> <!-- ==================== SECTION DES TABLEAUX ==================== -->
<div class="tables-section"> <div *ngIf="shouldShowTransactions()" class="tables-section">
<div class="row g-4"> <div class="row g-4">
<!-- Transactions récentes --> <!-- Transactions récentes -->
<div class="col-xl-6"> <div class="col-xl-6">
@ -533,14 +603,19 @@
<h5 class="card-title mb-0"> <h5 class="card-title mb-0">
<ng-icon name="lucideListChecks" class="text-primary me-2"></ng-icon> <ng-icon name="lucideListChecks" class="text-primary me-2"></ng-icon>
Transactions récentes Transactions récentes
<span class="badge ms-2"
[ngClass]="isViewingGlobal() ? 'bg-info' : 'bg-success'">
{{ getCurrentMerchantName() }}
</span>
</h5> </h5>
<p class="text-muted small mb-0">Dernières 24 heures</p> <p class="text-muted small mb-0">Dernières 24 heures</p>
</div> </div>
<div> <div>
<button class="btn btn-sm btn-outline-primary" <button class="btn btn-sm btn-outline-primary"
(click)="refreshDailyTransactions()" (click)="refreshData()"
[disabled]="loading.dailyTransactions"> [disabled]="loading.globalData || loading.merchantData">
<ng-icon name="lucideRefreshCw" [class.spin]="loading.dailyTransactions"></ng-icon> <ng-icon name="lucideRefreshCw"
[class.spin]="loading.globalData || loading.merchantData"></ng-icon>
</button> </button>
</div> </div>
</div> </div>
@ -557,10 +632,10 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr *ngFor="let item of (dailyTransactions?.items?.slice(0, 5) || [])"> <tr *ngFor="let item of (getCurrentTransactionData()?.items?.slice(0, 5) || [])">
<td class="ps-3"> <td class="ps-3">
<div>{{ item.period }}</div> <div>{{ item.period }}</div>
<small class="text-muted">{{ item.merchantPartnerId ? 'Merchant ' + item.merchantPartnerId : 'Tous' }}</small> <small class="text-muted">{{ isViewingGlobal() ? 'Tous merchants' : 'Merchant ' + merchantId }}</small>
</td> </td>
<td class="text-end"> <td class="text-end">
<div class="fw-medium">{{ formatCurrency(item.totalAmount) }}</div> <div class="fw-medium">{{ formatCurrency(item.totalAmount) }}</div>
@ -574,7 +649,8 @@
</span> </span>
</td> </td>
</tr> </tr>
<tr *ngIf="!(dailyTransactions?.items?.length)">
<tr *ngIf="!getCurrentTransactionData()?.items?.length">
<td colspan="4" class="text-center text-muted py-5"> <td colspan="4" class="text-center text-muted py-5">
<ng-icon name="lucideDatabase" class="fs-1 opacity-25 mb-3 d-block"></ng-icon> <ng-icon name="lucideDatabase" class="fs-1 opacity-25 mb-3 d-block"></ng-icon>
<p class="mb-0">Aucune transaction disponible</p> <p class="mb-0">Aucune transaction disponible</p>
@ -588,7 +664,7 @@
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<small class="text-muted"> <small class="text-muted">
<ng-icon name="lucideInfo" class="me-1"></ng-icon> <ng-icon name="lucideInfo" class="me-1"></ng-icon>
{{ (dailyTransactions?.items?.length || 0) }} périodes au total {{ getCurrentTransactionData()?.items?.length || 0 }} périodes au total
</small> </small>
<a href="#" class="btn btn-sm btn-outline-primary">Voir tout</a> <a href="#" class="btn btn-sm btn-outline-primary">Voir tout</a>
</div> </div>
@ -596,8 +672,8 @@
</div> </div>
</div> </div>
<!-- Alertes système --> <!-- Alertes système - Masqué si pas autorisé -->
<div class="col-xl-6"> <div *ngIf="shouldShowAlerts()" class="col-xl-6">
<div class="card h-100"> <div class="card h-100">
<div class="card-header bg-transparent border-bottom-0"> <div class="card-header bg-transparent border-bottom-0">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
@ -627,7 +703,7 @@
name="lucideInfo" name="lucideInfo"
class="text-info fs-5"></ng-icon> class="text-info fs-5"></ng-icon>
<ng-icon *ngIf="alert.type === 'success'" <ng-icon *ngIf="alert.type === 'success'"
name="lucideCheckCircle" name="lucideCheckCircle2"
class="text-success fs-5"></ng-icon> class="text-success fs-5"></ng-icon>
<ng-icon *ngIf="alert.type === 'danger'" <ng-icon *ngIf="alert.type === 'danger'"
name="lucideAlertCircle" name="lucideAlertCircle"
@ -643,7 +719,7 @@
</div> </div>
</div> </div>
<div *ngIf="alerts.length === 0" class="text-center text-muted py-5"> <div *ngIf="alerts.length === 0" class="text-center text-muted py-5">
<ng-icon name="lucideCheckCircle" class="text-success fs-1 opacity-25 mb-3 d-block"></ng-icon> <ng-icon name="lucideCheckCircle2" class="text-success fs-1 opacity-25 mb-3 d-block"></ng-icon>
<p class="mb-0">Aucune alerte active</p> <p class="mb-0">Aucune alerte active</p>
<small class="text-muted">Tous les systèmes fonctionnent normalement</small> <small class="text-muted">Tous les systèmes fonctionnent normalement</small>
</div> </div>
@ -673,11 +749,13 @@
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<div class="text-muted small"> <div class="text-muted small">
<ng-icon name="lucideCode" class="me-1"></ng-icon> <ng-icon name="lucideCode" class="me-1"></ng-icon>
Dashboard FinTech v2.0 Dashboard FinTech v2.0 •
<ng-icon [name]="currentRoleIcon" class="me-1 ms-2"></ng-icon>
{{ currentRoleLabel }}
</div> </div>
<div class="text-muted small"> <div class="text-muted small">
<ng-icon name="lucideDatabase" class="me-1"></ng-icon> <ng-icon [name]="isViewingGlobal() ? 'lucideGlobe' : 'lucideStore'" class="me-1"></ng-icon>
APIs: IAM, Config, Core, Reporting {{ getCurrentMerchantName() }}
</div> </div>
<div class="text-muted small"> <div class="text-muted small">
<ng-icon name="lucideClock" class="me-1"></ng-icon> <ng-icon name="lucideClock" class="me-1"></ng-icon>

View File

@ -0,0 +1,233 @@
import { inject, Injectable, Injector } from '@angular/core';
import { Observable, of } from 'rxjs';
import { map, catchError } from 'rxjs/operators';
import { UserRole, RoleManagementService } from '@core/services/hub-users-roles-management.service';
import { MerchantConfigService } from '@modules/merchant-config/merchant-config.service';
import { AuthService } from '@core/services/auth.service';
// Interface minimaliste
export interface DashboardAccess {
// Type d'utilisateur - CORE
isHubUser: boolean;
isMerchantUser: boolean;
// Info du rôle
roleLabel: string;
roleIcon: string;
userRole: UserRole;
// Merchant info (seulement pour merchant users)
merchantId?: number;
}
export interface AllowedMerchant {
id: number | undefined;
name: string;
}
@Injectable({ providedIn: 'root' })
export class DashboardAccessService {
private accessCache: DashboardAccess | null = null;
private merchantsCache: AllowedMerchant[] | null = null;
private currentMerchantId: number | null = null;
private readonly injector = inject(Injector);
constructor(
private roleService: RoleManagementService,
private merchantService: MerchantConfigService
) {}
/**
* Obtient l'accès simplifié
*/
getDashboardAccess(): DashboardAccess {
if (this.accessCache) {
return this.accessCache;
}
const userRole = this.roleService.getCurrentRole();
const isHubUser = this.roleService.isHubUser();
const access: DashboardAccess = {
isHubUser,
isMerchantUser: !isHubUser,
roleLabel: this.roleService.getRoleLabel(),
roleIcon: this.roleService.getRoleIcon(),
userRole: userRole || UserRole.DCB_SUPPORT,
};
// Pour les merchant users, définir leur merchant ID
if (!isHubUser) {
access.merchantId = this.getMerchantIdForUser();
}
this.accessCache = access;
return access;
}
/**
* Obtient le merchant ID pour un merchant user
*/
private getMerchantIdForUser(): number | undefined {
// Récupérer le merchant ID de l'utilisateur courant
const authService = this.injector.get(AuthService);
const merchantPartnerId = authService.getCurrentMerchantPartnerId();
if (!merchantPartnerId) return undefined;
const merchantId = parseInt(merchantPartnerId, 10);
return isNaN(merchantId) ? undefined : merchantId;
}
/**
* Obtient les merchants disponibles
* - Hub users: tous les merchants
* - Merchant users: seulement leur merchant
*/
getAvailableMerchants(): Observable<AllowedMerchant[]> {
if (this.merchantsCache) {
return of(this.merchantsCache);
}
const access = this.getDashboardAccess();
if (access.isHubUser) {
// Hub users voient tous les merchants
return this.merchantService.getAllMerchants().pipe(
map(merchants => {
const availableMerchants: any[] = merchants.map(m => ({
id: m.id,
name: m.name
}));
this.merchantsCache = availableMerchants;
return availableMerchants;
}),
catchError(error => {
console.error('Erreur chargement merchants:', error);
return of([]);
})
);
} else {
// Merchant users: seulement leur merchant
const merchantId = access.merchantId || this.getMerchantIdForUser();
return of([{
id: merchantId,
name: `Merchant ${merchantId}`
}]);
}
}
/**
* Définit le merchant sélectionné
*/
setSelectedMerchantId(merchantId: number): void {
this.currentMerchantId = merchantId;
}
/**
* Obtient le merchant sélectionné
*/
getSelectedMerchantId(): number | null {
const access = this.getDashboardAccess();
if (access.isMerchantUser) {
// Merchant users: toujours leur merchant
return access.merchantId || null;
}
// Hub users: le merchant sélectionné ou le premier
return this.currentMerchantId;
}
/**
* Vérifie si un merchant est accessible
*/
canAccessMerchant(merchantId: number): Observable<boolean> {
const access = this.getDashboardAccess();
// Hub users: tous les merchants sont accessibles
if (access.isHubUser) {
return of(true);
}
// Merchant users: seulement leur merchant
return of(access.merchantId === merchantId);
}
/**
* Nettoie le cache
*/
clearCache(): void {
this.accessCache = null;
this.merchantsCache = null;
this.currentMerchantId = null;
}
/**
* Méthodes utilitaires pour le template
*/
// Pour les Hub Users
shouldShowSystemHealth(): boolean {
return this.getDashboardAccess().isHubUser;
}
shouldShowAllTransactions(): boolean {
return this.getDashboardAccess().isHubUser;
}
canTriggerSync(): boolean {
const access = this.getDashboardAccess();
return access.isHubUser && access.userRole === UserRole.DCB_ADMIN;
}
canManageMerchants(): boolean {
const access = this.getDashboardAccess();
return access.isHubUser && access.userRole === UserRole.DCB_ADMIN;
}
// Pour tous les utilisateurs
shouldShowTransactions(): boolean {
return true; // Tous peuvent voir les transactions (mais scope différent)
}
shouldShowCharts(): boolean {
return true; // Tous peuvent voir les transactions (mais scope différent)
}
shouldShowKPIs(): boolean {
return true; // Tous peuvent voir les transactions (mais scope différent)
}
shouldShowAlerts(): boolean {
return true; // Tous peuvent voir les transactions (mais scope différent)
}
canRefreshData(): boolean {
return true; // Tous peuvent rafraîchir
}
// Pour la sélection de merchant
canSelectMerchant(): boolean {
return this.getDashboardAccess().isHubUser;
}
// Pour l'affichage du merchant ID
shouldShowMerchantId(): boolean {
const access = this.getDashboardAccess();
return access.isMerchantUser ||
(access.isHubUser && this.getSelectedMerchantId() !== null);
}
// Pour l'édition du merchant filter
canEditMerchantFilter(): boolean {
const access = this.getDashboardAccess();
if (access.isHubUser) {
return access.userRole === UserRole.DCB_ADMIN;
}
return access.userRole === UserRole.DCB_PARTNER_ADMIN;
}
}

View File

@ -14,10 +14,10 @@ import { environment } from '@environments/environment';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class ReportService { export class ReportService {
private baseUrl = `${environment.reportingApiUrl}/reporting`; private baseUrl = `${environment.reportingApiUrl}/reporting`; // Mise à jour du chemin
private readonly DEFAULT_TIMEOUT = 5000; // Timeout réduit pour les health checks private readonly DEFAULT_TIMEOUT = 5000;
private readonly DEFAULT_RETRY = 1; private readonly DEFAULT_RETRY = 1;
private readonly REPORTING_TIMEOUT = 10000; // Timeout normal pour les autres requêtes private readonly REPORTING_TIMEOUT = 10000;
// Configuration des APIs à scanner // Configuration des APIs à scanner
private apiEndpoints = { private apiEndpoints = {
@ -35,8 +35,15 @@ export class ReportService {
private buildParams(params?: ReportParams): HttpParams { private buildParams(params?: ReportParams): HttpParams {
let httpParams = new HttpParams(); let httpParams = new HttpParams();
if (!params) return httpParams; if (!params) return httpParams;
Object.entries(params).forEach(([key, value]) => { Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) httpParams = httpParams.set(key, value.toString()); if (value !== undefined && value !== null) {
if (value instanceof Date) {
httpParams = httpParams.set(key, this.formatDate(value));
} else {
httpParams = httpParams.set(key, value.toString());
}
}
}); });
return httpParams; return httpParams;
} }
@ -57,7 +64,225 @@ export class ReportService {
} }
// --------------------- // ---------------------
// Health checks // TRANSACTIONS - NOUVELLES MÉTHODES AVEC merchantPartnerId
// ---------------------
/**
* Transactions journalières (global ou par merchant)
*/
getDailyTransactions(merchantPartnerId?: number): Observable<TransactionReport> {
const params = merchantPartnerId ? { merchantPartnerId } : undefined;
return this.http.get<TransactionReport>(`${this.baseUrl}/transactions/daily`, {
params: this.buildParams(params)
}).pipe(
timeout(this.REPORTING_TIMEOUT),
retry(this.DEFAULT_RETRY),
catchError(err => this.handleError(err))
);
}
/**
* Transactions hebdomadaires (global ou par merchant)
*/
getWeeklyTransactions(merchantPartnerId?: number): Observable<TransactionReport> {
const params = merchantPartnerId ? { merchantPartnerId } : undefined;
return this.http.get<TransactionReport>(`${this.baseUrl}/transactions/weekly`, {
params: this.buildParams(params)
}).pipe(
timeout(this.REPORTING_TIMEOUT),
retry(this.DEFAULT_RETRY),
catchError(err => this.handleError(err))
);
}
/**
* Transactions mensuelles (global ou par merchant)
*/
getMonthlyTransactions(merchantPartnerId?: number): Observable<TransactionReport> {
const params = merchantPartnerId ? { merchantPartnerId } : undefined;
return this.http.get<TransactionReport>(`${this.baseUrl}/transactions/monthly`, {
params: this.buildParams(params)
}).pipe(
timeout(this.REPORTING_TIMEOUT),
retry(this.DEFAULT_RETRY),
catchError(err => this.handleError(err))
);
}
/**
* Transactions avec dates spécifiques (global ou par merchant)
*/
getTransactionsByDateRange(
startDate: string,
endDate: string,
merchantPartnerId?: number
): Observable<TransactionReport> {
const params: any = { startDate, endDate };
if (merchantPartnerId) {
params.merchantPartnerId = merchantPartnerId;
}
return this.http.get<TransactionReport>(`${this.baseUrl}/transactions/daily`, {
params: this.buildParams(params)
}).pipe(
timeout(this.REPORTING_TIMEOUT),
retry(this.DEFAULT_RETRY),
catchError(err => this.handleError(err))
);
}
/**
* Transactions annuelles (global ou par merchant)
*/
getYearlyTransactions(year: number, merchantPartnerId?: number): Observable<TransactionReport> {
const startDate = `${year}-01-01`;
const endDate = `${year}-12-31`;
return this.getTransactionsByDateRange(startDate, endDate, merchantPartnerId);
}
// ---------------------
// SUBSCRIPTIONS - NOUVELLES MÉTHODES AVEC merchantPartnerId
// ---------------------
/**
* Abonnements journaliers (global ou par merchant)
*/
getDailySubscriptions(merchantPartnerId?: number): Observable<SubscriptionReport> {
const params = merchantPartnerId ? { merchantPartnerId } : undefined;
return this.http.get<SubscriptionReport>(`${this.baseUrl}/subscriptions/daily`, {
params: this.buildParams(params)
}).pipe(
timeout(this.REPORTING_TIMEOUT),
retry(this.DEFAULT_RETRY),
catchError(err => this.handleError(err))
);
}
/**
* Abonnements mensuels (global ou par merchant)
*/
getMonthlySubscriptions(merchantPartnerId?: number): Observable<SubscriptionReport> {
const params = merchantPartnerId ? { merchantPartnerId } : undefined;
return this.http.get<SubscriptionReport>(`${this.baseUrl}/subscriptions/monthly`, {
params: this.buildParams(params)
}).pipe(
timeout(this.REPORTING_TIMEOUT),
retry(this.DEFAULT_RETRY),
catchError(err => this.handleError(err))
);
}
/**
* Abonnements hebdomadaires (global ou par merchant)
*/
getWeeklySubscriptions(merchantPartnerId?: number): Observable<SubscriptionReport> {
const params = merchantPartnerId ? { merchantPartnerId } : undefined;
return this.http.get<SubscriptionReport>(`${this.baseUrl}/subscriptions/weekly`, {
params: this.buildParams(params)
}).pipe(
timeout(this.REPORTING_TIMEOUT),
retry(this.DEFAULT_RETRY),
catchError(err => this.handleError(err))
);
}
// ---------------------
// SYNCHRONISATION
// ---------------------
triggerManualSync(): Observable<SyncResponse> {
return this.http.post<SyncResponse>(`${this.baseUrl}/sync/full`, {}).pipe(
timeout(this.REPORTING_TIMEOUT),
retry(this.DEFAULT_RETRY),
catchError(err => this.handleError(err))
);
}
// ---------------------
// MÉTHODES DE SUPPORT POUR LA COMPATIBILITÉ
// ---------------------
/**
* Compatibilité
*/
getTransactionReport(
params?: ReportParams,
period: 'daily' | 'weekly' | 'monthly' | 'yearly' = 'daily'
): Observable<TransactionReport> {
switch (period) {
case 'daily':
return this.getDailyTransactions(params?.merchantPartnerId);
case 'weekly':
return this.getWeeklyTransactions(params?.merchantPartnerId);
case 'monthly':
return this.getMonthlyTransactions(params?.merchantPartnerId);
case 'yearly':
return this.getYearlyTransactions(
new Date().getFullYear(),
params?.merchantPartnerId
);
default:
return this.getDailyTransactions(params?.merchantPartnerId);
}
}
/**
* Compatibilité
*/
getSubscriptionReport(
params?: ReportParams,
period: 'daily' | 'weekly' | 'monthly' | 'yearly' = 'daily'
): Observable<SubscriptionReport> {
switch (period) {
case 'daily':
return this.getDailySubscriptions(params?.merchantPartnerId);
case 'weekly':
return this.getWeeklySubscriptions(params?.merchantPartnerId);
case 'monthly':
return this.getMonthlySubscriptions(params?.merchantPartnerId);
default:
return this.getDailySubscriptions(params?.merchantPartnerId);
}
}
/**
* Compatibilité
*/
getTransactionsWithDates(
startDate: string,
endDate: string,
merchantPartnerId?: number
): Observable<TransactionReport> {
return this.getTransactionsByDateRange(startDate, endDate, merchantPartnerId);
}
// ---------------------
// Multi-partner normalization for charts
// ---------------------
normalizeForChart(data: TransactionReport | SubscriptionReport, key: string): ChartDataNormalized {
const labels = data.items.map(i => i.period);
const dataset = data.items.map(i => (i as any)[key] ?? 0);
return { labels, dataset };
}
// ---------------------
// Multi-period & Multi-partner reporting
// ---------------------
getMultiPartnerReports(
partners: number[],
period: 'daily' | 'weekly' | 'monthly' | 'yearly' = 'daily',
type: 'transaction' | 'subscription' = 'transaction'
): Observable<(TransactionReport | SubscriptionReport)[]> {
const requests = partners.map(id => {
return type === 'transaction'
? this.getTransactionReport({ merchantPartnerId: id }, period)
: this.getSubscriptionReport({ merchantPartnerId: id }, period);
});
return forkJoin(requests);
}
// ---------------------
// Health checks (rest of the code remains the same)
// --------------------- // ---------------------
private checkApiAvailability( private checkApiAvailability(
@ -240,57 +465,4 @@ export class ReportService {
catchError(err => this.handleError(err)) catchError(err => this.handleError(err))
); );
} }
triggerManualSync(): Observable<SyncResponse> {
return this.http.post<SyncResponse>(`${this.baseUrl}/sync/full`, {}).pipe(
timeout(this.REPORTING_TIMEOUT),
retry(this.DEFAULT_RETRY),
catchError(err => this.handleError(err))
);
}
// ---------------------
// Transactions & Subscriptions Reports
// ---------------------
getTransactionReport(params?: ReportParams, period: 'daily' | 'weekly' | 'monthly' | 'yearly' = 'daily'): Observable<TransactionReport> {
return this.http.get<TransactionReport>(`${this.baseUrl}/transactions/${period}`, { params: this.buildParams(params) }).pipe(
timeout(this.REPORTING_TIMEOUT),
retry(this.DEFAULT_RETRY),
catchError(err => this.handleError(err))
);
}
getSubscriptionReport(params?: ReportParams, period: 'daily' | 'weekly' | 'monthly' | 'yearly' = 'daily'): Observable<SubscriptionReport> {
return this.http.get<SubscriptionReport>(`${this.baseUrl}/subscriptions/${period}`, { params: this.buildParams(params) }).pipe(
timeout(this.REPORTING_TIMEOUT),
retry(this.DEFAULT_RETRY),
catchError(err => this.handleError(err))
);
}
getTransactionsWithDates(startDate: string, endDate: string, merchantPartnerId?: number) {
return this.getTransactionReport({ startDate, endDate, merchantPartnerId }, 'daily');
}
// ---------------------
// Multi-partner normalization for charts
// ---------------------
normalizeForChart(data: TransactionReport | SubscriptionReport, key: string): ChartDataNormalized {
const labels = data.items.map(i => i.period);
const dataset = data.items.map(i => (i as any)[key] ?? 0);
return { labels, dataset };
}
// ---------------------
// Multi-period & Multi-partner reporting
// ---------------------
getMultiPartnerReports(partners: number[], period: 'daily' | 'weekly' | 'monthly' | 'yearly' = 'daily', type: 'transaction' | 'subscription' = 'transaction') {
const requests = partners.map(id => {
return type === 'transaction'
? this.getTransactionReport({ merchantPartnerId: id }, period)
: this.getSubscriptionReport({ merchantPartnerId: id }, period);
});
return forkJoin(requests);
}
} }

View File

@ -229,6 +229,10 @@
@if (showUserTypeColumn()) { @if (showUserTypeColumn()) {
<th>Type</th> <th>Type</th>
} }
<!-- Colonne Merchant Partner pour la vue admin -->
@if (showMerchantPartnerColumn()) {
<th>Merchant Partner</th>
}
<th (click)="sort('username')" class="cursor-pointer"> <th (click)="sort('username')" class="cursor-pointer">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<span>Utilisateur</span> <span>Utilisateur</span>
@ -268,6 +272,25 @@
</span> </span>
</td> </td>
} }
<!-- Colonne Merchant Partner pour la vue admin -->
@if (showMerchantPartnerColumn()) {
<td>
@if (user.merchantPartnerId) {
<div class="d-flex align-items-center">
<div class="avatar-sm bg-secondary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2">
<ng-icon name="lucideBuilding" class="text-secondary fs-12"></ng-icon>
</div>
<div>
<small class="text-muted font-monospace" [title]="user.merchantPartnerId">
{{ user.merchantPartnerId.substring(0, 8) }}...
</small>
</div>
</div>
} @else {
<span class="text-muted">-</span>
}
</td>
}
<td> <td>
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<div class="avatar-sm bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2"> <div class="avatar-sm bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2">

View File

@ -15,7 +15,7 @@ import {
} from '@core/models/dcb-bo-hub-user.model'; } from '@core/models/dcb-bo-hub-user.model';
import { HubUsersService } from '../hub-users.service'; import { HubUsersService } from '../hub-users.service';
import { RoleManagementService } from '@core/services/hub-users-roles-management.service'; import { RoleManagementService } from '@core/services/hub-users-roles-management-old.service';
import { AuthService } from '@core/services/auth.service'; import { AuthService } from '@core/services/auth.service';
import { UiCard } from '@app/components/ui-card'; import { UiCard } from '@app/components/ui-card';
@ -448,7 +448,7 @@ export class HubUsersList implements OnInit, OnDestroy {
return roleInfo?.description || 'Description non disponible'; return roleInfo?.description || 'Description non disponible';
} }
formatTimestamp(timestamp: number): string { formatTimestamp(timestamp: number | undefined): string {
if (!timestamp) return 'Non disponible'; if (!timestamp) return 'Non disponible';
return new Date(timestamp).toLocaleDateString('fr-FR', { return new Date(timestamp).toLocaleDateString('fr-FR', {
year: 'numeric', year: 'numeric',

View File

@ -14,7 +14,7 @@ import {
} from '@core/models/dcb-bo-hub-user.model'; } from '@core/models/dcb-bo-hub-user.model';
import { HubUsersService } from '../hub-users.service'; import { HubUsersService } from '../hub-users.service';
import { RoleManagementService } from '@core/services/hub-users-roles-management.service'; import { RoleManagementService } from '@core/services/hub-users-roles-management-old.service';
import { AuthService } from '@core/services/auth.service'; import { AuthService } from '@core/services/auth.service';
@Component({ @Component({

View File

@ -39,6 +39,7 @@ export interface UserProfileResponse {
emailVerified: boolean; emailVerified: boolean;
enabled: boolean; enabled: boolean;
role: string[]; role: string[];
merchantPartnerId?: string;
createdBy?: string; createdBy?: string;
createdByUsername?: string; createdByUsername?: string;
} }
@ -47,6 +48,10 @@ export interface MessageResponse {
message: string; message: string;
} }
export interface MerchantPartnerIdResponse {
merchantPartnerId: string | null;
}
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class HubUsersService { export class HubUsersService {
private http = inject(HttpClient); private http = inject(HttpClient);
@ -292,6 +297,7 @@ export class HubUsersService {
lastName: apiUser.lastName, lastName: apiUser.lastName,
enabled: apiUser.enabled, enabled: apiUser.enabled,
emailVerified: apiUser.emailVerified, emailVerified: apiUser.emailVerified,
merchantPartnerId: apiUser.merchantPartnerId,
userType: userType, userType: userType,
role: apiUser.role, role: apiUser.role,
createdBy: apiUser.createdBy, createdBy: apiUser.createdBy,
@ -328,6 +334,10 @@ export class HubUsersService {
filteredUsers = filteredUsers.filter(user => user.enabled === filters.enabled); filteredUsers = filteredUsers.filter(user => user.enabled === filters.enabled);
} }
if (filters.merchantPartnerId) {
filteredUsers = filteredUsers.filter(user => user.merchantPartnerId === filters.merchantPartnerId);
}
if (filters.userType) { if (filters.userType) {
filteredUsers = filteredUsers.filter(user => user.userType === filters.userType); filteredUsers = filteredUsers.filter(user => user.userType === filters.userType);
} }

View File

@ -6,7 +6,7 @@ import { NgbNavModule, NgbModal, NgbModalModule } from '@ng-bootstrap/ng-bootstr
import { Subject, takeUntil } from 'rxjs'; import { Subject, takeUntil } from 'rxjs';
import { HubUsersService } from './hub-users.service'; import { HubUsersService } from './hub-users.service';
import { RoleManagementService } from '@core/services/hub-users-roles-management.service'; import { RoleManagementService } from '@core/services/hub-users-roles-management-old.service';
import { AuthService } from '@core/services/auth.service'; import { AuthService } from '@core/services/auth.service';
import { MerchantSyncService } from './merchant-sync-orchestrator.service'; import { MerchantSyncService } from './merchant-sync-orchestrator.service';
import { PageTitle } from '@app/components/page-title/page-title'; import { PageTitle } from '@app/components/page-title/page-title';

View File

@ -1,3 +1,4 @@
import { UserRole, UserType } from '@core/models/dcb-bo-hub-user.model';
import { Observable, of, throwError, firstValueFrom, lastValueFrom } from 'rxjs'; import { Observable, of, throwError, firstValueFrom, lastValueFrom } from 'rxjs';
// Mock des services // Mock des services
@ -36,12 +37,12 @@ const mockHubUsersService = {
createHubUser: (data: any): Observable<any> => of({ createHubUser: (data: any): Observable<any> => of({
id: 'keycloak-123', id: 'keycloak-123',
username: data.username, username: data.username,
merchantConfigId: 123 merchantPartnerId: 123
}), }),
getHubUserById: (id: string): Observable<any> => of({ getHubUserById: (id: string): Observable<any> => of({
id, id,
username: 'owner', username: 'owner',
merchantConfigId: 123 merchantPartnerId: 123
}), }),
updateHubUser: (id: string, data: any): Observable<any> => of({ updateHubUser: (id: string, data: any): Observable<any> => of({
id, id,
@ -49,7 +50,7 @@ const mockHubUsersService = {
}), }),
deleteHubUser: (id: string): Observable<void> => of(void 0), deleteHubUser: (id: string): Observable<void> => of(void 0),
getAllDcbPartners: (): Observable<{users: any[]}> => of({ getAllDcbPartners: (): Observable<{users: any[]}> => of({
users: [{ id: 'keycloak-123', merchantConfigId: 123 }] users: [{ id: 'keycloak-123', merchantPartnerId: 123 }]
}) })
}; };
@ -78,7 +79,7 @@ const mockMerchantUsersService = {
export class MerchantCrudTest { export class MerchantCrudTest {
private testData = { private testData = {
currentMerchantId: 0, currentMerchantId: 0,
currentDcbPartnerId: '', currentMerchantPartnerId: '',
currentUserId: '', currentUserId: '',
currentMerchantConfigUserId: 0 currentMerchantConfigUserId: 0
}; };
@ -132,9 +133,9 @@ export class MerchantCrudTest {
// 2. Créer DCB_PARTNER dans Keycloak // 2. Créer DCB_PARTNER dans Keycloak
const dcbPartnerDto = { const dcbPartnerDto = {
...ownerData, ...ownerData,
userType: 'HUB', userType: UserType.HUB,
role: 'DCB_PARTNER', role: UserRole.DCB_PARTNER_ADMIN,
merchantConfigId: merchantConfig.id merchantPartnerId: merchantConfig.id
}; };
const keycloakMerchant = await firstValueFrom( const keycloakMerchant = await firstValueFrom(
@ -144,7 +145,7 @@ export class MerchantCrudTest {
// Sauvegarder les IDs // Sauvegarder les IDs
this.testData.currentMerchantId = merchantConfig.id; this.testData.currentMerchantId = merchantConfig.id;
this.testData.currentDcbPartnerId = keycloakMerchant.id; this.testData.currentMerchantPartnerId = keycloakMerchant.id;
const result = { const result = {
merchantConfig, merchantConfig,
@ -163,7 +164,7 @@ export class MerchantCrudTest {
async testCreateMerchantUser(): Promise<void> { async testCreateMerchantUser(): Promise<void> {
console.log('🧪 TEST: CREATE Merchant User'); console.log('🧪 TEST: CREATE Merchant User');
if (!this.testData.currentMerchantId || !this.testData.currentDcbPartnerId) { if (!this.testData.currentMerchantId || !this.testData.currentMerchantPartnerId) {
console.log('⚠️ Créez d\'abord un merchant'); console.log('⚠️ Créez d\'abord un merchant');
return; return;
} }
@ -182,8 +183,7 @@ export class MerchantCrudTest {
const keycloakUserDto = { const keycloakUserDto = {
...userData, ...userData,
userType: 'MERCHANT_PARTNER', userType: 'MERCHANT_PARTNER',
merchantPartnerId: this.testData.currentDcbPartnerId, merchantPartnerId: this.testData.currentMerchantId
merchantConfigId: this.testData.currentMerchantId
}; };
const keycloakUser = await firstValueFrom( const keycloakUser = await firstValueFrom(
@ -226,7 +226,7 @@ export class MerchantCrudTest {
async testReadMerchant(): Promise<void> { async testReadMerchant(): Promise<void> {
console.log('🧪 TEST: READ Merchant'); console.log('🧪 TEST: READ Merchant');
if (!this.testData.currentMerchantId || !this.testData.currentDcbPartnerId) { if (!this.testData.currentMerchantId || !this.testData.currentMerchantPartnerId) {
console.log('⚠️ Créez d\'abord un merchant'); console.log('⚠️ Créez d\'abord un merchant');
return; return;
} }
@ -237,14 +237,14 @@ export class MerchantCrudTest {
mockMerchantConfigService.getMerchantById(this.testData.currentMerchantId) mockMerchantConfigService.getMerchantById(this.testData.currentMerchantId)
), ),
firstValueFrom( firstValueFrom(
mockHubUsersService.getHubUserById(this.testData.currentDcbPartnerId) mockHubUsersService.getHubUserById(this.testData.currentMerchantPartnerId)
) )
]); ]);
console.log('🎯 RESULTAT READ:', { console.log('🎯 RESULTAT READ:', {
merchantConfig, merchantConfig,
keycloakMerchant, keycloakMerchant,
coherence: keycloakMerchant.merchantConfigId === merchantConfig.id ? '✅ OK' : '❌ INCOHÉRENT' coherence: keycloakMerchant.merchantPartnerId === merchantConfig.id ? '✅ OK' : '❌ INCOHÉRENT'
}); });
} catch (error) { } catch (error) {
@ -255,7 +255,7 @@ export class MerchantCrudTest {
async testUpdateMerchant(): Promise<void> { async testUpdateMerchant(): Promise<void> {
console.log('🧪 TEST: UPDATE Merchant'); console.log('🧪 TEST: UPDATE Merchant');
if (!this.testData.currentMerchantId || !this.testData.currentDcbPartnerId) { if (!this.testData.currentMerchantId || !this.testData.currentMerchantPartnerId) {
console.log('⚠️ Créez d\'abord un merchant'); console.log('⚠️ Créez d\'abord un merchant');
return; return;
} }
@ -273,7 +273,7 @@ export class MerchantCrudTest {
), ),
firstValueFrom( firstValueFrom(
mockHubUsersService.updateHubUser( mockHubUsersService.updateHubUser(
this.testData.currentDcbPartnerId, this.testData.currentMerchantPartnerId,
{ email: newEmail } { email: newEmail }
) )
) )
@ -363,7 +363,7 @@ export class MerchantCrudTest {
async testDeleteMerchant(): Promise<void> { async testDeleteMerchant(): Promise<void> {
console.log('🧪 TEST: DELETE Merchant'); console.log('🧪 TEST: DELETE Merchant');
if (!this.testData.currentMerchantId || !this.testData.currentDcbPartnerId) { if (!this.testData.currentMerchantId || !this.testData.currentMerchantPartnerId) {
console.log('⚠️ Créez d\'abord un merchant'); console.log('⚠️ Créez d\'abord un merchant');
return; return;
} }
@ -382,7 +382,7 @@ export class MerchantCrudTest {
mockMerchantConfigService.deleteMerchant(this.testData.currentMerchantId) mockMerchantConfigService.deleteMerchant(this.testData.currentMerchantId)
), ),
firstValueFrom( firstValueFrom(
mockHubUsersService.deleteHubUser(this.testData.currentDcbPartnerId) mockHubUsersService.deleteHubUser(this.testData.currentMerchantPartnerId)
) )
]); ]);
@ -390,7 +390,7 @@ export class MerchantCrudTest {
// Réinitialiser // Réinitialiser
this.testData.currentMerchantId = 0; this.testData.currentMerchantId = 0;
this.testData.currentDcbPartnerId = ''; this.testData.currentMerchantPartnerId = '';
} catch (error) { } catch (error) {
console.error('❌ ERREUR DELETE MERCHANT:', error); console.error('❌ ERREUR DELETE MERCHANT:', error);
@ -449,7 +449,7 @@ export class MerchantCrudTest {
resetTestData() { resetTestData() {
this.testData = { this.testData = {
currentMerchantId: 0, currentMerchantId: 0,
currentDcbPartnerId: '', currentMerchantPartnerId: '',
currentUserId: '', currentUserId: '',
currentMerchantConfigUserId: 0 currentMerchantConfigUserId: 0
}; };

View File

@ -153,6 +153,10 @@
<table class="table table-hover table-striped"> <table class="table table-hover table-striped">
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
<!-- Colonne Merchant Partner pour les admins -->
@if (showMerchantPartnerColumn) {
<th>Merchant Partner</th>
}
<th (click)="sort('username')" class="cursor-pointer"> <th (click)="sort('username')" class="cursor-pointer">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<span>Utilisateur</span> <span>Utilisateur</span>
@ -184,6 +188,21 @@
<tbody> <tbody>
@for (user of displayedUsers; track user.id) { @for (user of displayedUsers; track user.id) {
<tr> <tr>
<!-- Colonne Merchant Partner pour les admins -->
@if (showMerchantPartnerColumn) {
<td>
<div class="d-flex align-items-center">
<div class="avatar-sm bg-secondary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2">
<ng-icon name="lucideBuilding" class="text-secondary fs-12"></ng-icon>
</div>
<div>
<small class="text-muted font-monospace" [title]="user.merchantPartnerId || 'N/A'">
{{ (user.merchantPartnerId || 'N/A').substring(0, 8) }}...
</small>
</div>
</div>
</td>
}
<td> <td>
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<div class="avatar-sm bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2"> <div class="avatar-sm bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2">

View File

@ -15,7 +15,7 @@ import {
} from '@core/models/dcb-bo-hub-user.model'; } from '@core/models/dcb-bo-hub-user.model';
import { MerchantUsersService } from '../merchant-users.service'; import { MerchantUsersService } from '../merchant-users.service';
import { RoleManagementService } from '@core/services/hub-users-roles-management.service'; import { RoleManagementService } from '@core/services/hub-users-roles-management-old.service';
import { AuthService } from '@core/services/auth.service'; import { AuthService } from '@core/services/auth.service';
import { UiCard } from '@app/components/ui-card'; import { UiCard } from '@app/components/ui-card';
@ -118,12 +118,6 @@ export class MerchantUsersList implements OnInit, OnDestroy {
this.currentUserRole = this.extractUserRole(user); this.currentUserRole = this.extractUserRole(user);
this.canViewAllMerchants = this.canViewAllMerchantsCheck(this.currentUserRole); this.canViewAllMerchants = this.canViewAllMerchantsCheck(this.currentUserRole);
console.log('Merchant User Context Loaded:', {
role: this.currentUserRole,
merchantPartnerId: this.currentMerchantPartnerId,
canViewAllMerchants: this.canViewAllMerchants
});
this.loadUsers(); this.loadUsers();
}, },
error: (error) => { error: (error) => {
@ -391,7 +385,7 @@ export class MerchantUsersList implements OnInit, OnDestroy {
return roleInfo?.description || 'Description non disponible'; return roleInfo?.description || 'Description non disponible';
} }
formatTimestamp(timestamp: number): string { formatTimestamp(timestamp: number | undefined): string {
if (!timestamp) return 'Non disponible'; if (!timestamp) return 'Non disponible';
return new Date(timestamp).toLocaleDateString('fr-FR', { return new Date(timestamp).toLocaleDateString('fr-FR', {
year: 'numeric', year: 'numeric',

View File

@ -14,7 +14,7 @@ import {
} from '@core/models/dcb-bo-hub-user.model'; } from '@core/models/dcb-bo-hub-user.model';
import { MerchantUsersService } from '../merchant-users.service'; import { MerchantUsersService } from '../merchant-users.service';
import { RoleManagementService } from '@core/services/hub-users-roles-management.service'; import { RoleManagementService } from '@core/services/hub-users-roles-management-old.service';
import { AuthService } from '@core/services/auth.service'; import { AuthService } from '@core/services/auth.service';
@Component({ @Component({

View File

@ -271,6 +271,7 @@ export class MerchantUsersService {
lastName: apiUser.lastName, lastName: apiUser.lastName,
enabled: apiUser.enabled, enabled: apiUser.enabled,
emailVerified: apiUser.emailVerified, emailVerified: apiUser.emailVerified,
merchantPartnerId: apiUser.merchantPartnerId,
userType: userType, userType: userType,
role: apiUser.role, role: apiUser.role,
createdBy: apiUser.createdBy, createdBy: apiUser.createdBy,

View File

@ -6,7 +6,7 @@ import { NgbNavModule, NgbModal, NgbModalModule } from '@ng-bootstrap/ng-bootstr
import { catchError, map, of, Subject, takeUntil } from 'rxjs'; import { catchError, map, of, Subject, takeUntil } from 'rxjs';
import { MerchantUsersService } from './merchant-users.service'; import { MerchantUsersService } from './merchant-users.service';
import { RoleManagementService } from '@core/services/hub-users-roles-management.service'; import { RoleManagementService } from '@core/services/hub-users-roles-management-old.service';
import { AuthService } from '@core/services/auth.service'; import { AuthService } from '@core/services/auth.service';
import { PageTitle } from '@app/components/page-title/page-title'; import { PageTitle } from '@app/components/page-title/page-title';
import { MerchantUsersList } from './merchant-users-list/merchant-users-list'; import { MerchantUsersList } from './merchant-users-list/merchant-users-list';
@ -157,8 +157,6 @@ export class MerchantUsersManagement implements OnInit, OnDestroy {
this.currentUserType = this.extractUserType(user); this.currentUserType = this.extractUserType(user);
console.log(`MERCHANT User ROLE: ${this.currentUserRole}`);
if (this.currentUserRole) { if (this.currentUserRole) {
this.roleService.setCurrentUserRole(this.currentUserRole); this.roleService.setCurrentUserRole(this.currentUserRole);
this.userPermissions = this.roleService.getPermissionsForRole(this.currentUserRole); this.userPermissions = this.roleService.getPermissionsForRole(this.currentUserRole);
@ -167,7 +165,6 @@ export class MerchantUsersManagement implements OnInit, OnDestroy {
this.canManageRoles = this.roleService.canManageRoles(this.currentUserRole); this.canManageRoles = this.roleService.canManageRoles(this.currentUserRole);
this.assignableRoles = this.roleService.getAssignableRoles(this.currentUserRole); this.assignableRoles = this.roleService.getAssignableRoles(this.currentUserRole);
console.log('Assignable roles:', this.assignableRoles);
} }
}, },

View File

@ -15,7 +15,7 @@ import {
} from '@core/models/merchant-config.model'; } from '@core/models/merchant-config.model';
import { MerchantConfigService } from '../merchant-config.service'; import { MerchantConfigService } from '../merchant-config.service';
import { RoleManagementService } from '@core/services/hub-users-roles-management.service'; import { RoleManagementService } from '@core/services/hub-users-roles-management-old.service';
import { AuthService } from '@core/services/auth.service'; import { AuthService } from '@core/services/auth.service';
import { UiCard } from '@app/components/ui-card'; import { UiCard } from '@app/components/ui-card';
@ -158,10 +158,6 @@ export class MerchantConfigsList implements OnInit, OnDestroy {
this.canViewAllMerchants = this.canViewAllMerchantsCheck(this.currentUserRole); this.canViewAllMerchants = this.canViewAllMerchantsCheck(this.currentUserRole);
console.log(`MERCHANT User ROLE: ${this.currentUserRole}`);
console.log(`Merchant Config ID: ${this.currentMerchantConfigId}`);
console.log(`canViewAllMerchants: ${this.canViewAllMerchants}`);
this.loadMerchants(); this.loadMerchants();
}, },
error: (error) => { error: (error) => {

View File

@ -19,7 +19,7 @@ import {
import { MerchantConfigService } from '../merchant-config.service'; import { MerchantConfigService } from '../merchant-config.service';
import { MerchantDataAdapter } from '../merchant-data-adapter.service'; import { MerchantDataAdapter } from '../merchant-data-adapter.service';
import { RoleManagementService } from '@core/services/hub-users-roles-management.service'; import { RoleManagementService } from '@core/services/hub-users-roles-management-old.service';
import { AuthService } from '@core/services/auth.service'; import { AuthService } from '@core/services/auth.service';
import { UserRole } from '@core/models/dcb-bo-hub-user.model'; import { UserRole } from '@core/models/dcb-bo-hub-user.model';

View File

@ -199,7 +199,6 @@ export class MerchantConfigService {
timeout(this.REQUEST_TIMEOUT), timeout(this.REQUEST_TIMEOUT),
map(apiUser => { map(apiUser => {
console.log(`✅ User ${userId} role updated successfully`); console.log(`✅ User ${userId} role updated successfully`);
// ✅ UTILISATION DE L'ADAPTER
return this.dataAdapter.convertApiUserToFrontend(apiUser); return this.dataAdapter.convertApiUserToFrontend(apiUser);
}), }),
catchError(error => this.handleError('updateUserRole', error, { merchantId, userId })) catchError(error => this.handleError('updateUserRole', error, { merchantId, userId }))

View File

@ -6,7 +6,7 @@ import { NgbNavModule, NgbModal, NgbModalModule } from '@ng-bootstrap/ng-bootstr
import { catchError, finalize, map, of, Subject, takeUntil } from 'rxjs'; import { catchError, finalize, map, of, Subject, takeUntil } from 'rxjs';
import { MerchantConfigService } from './merchant-config.service'; import { MerchantConfigService } from './merchant-config.service';
import { RoleManagementService } from '@core/services/hub-users-roles-management.service'; import { RoleManagementService } from '@core/services/hub-users-roles-management-old.service';
import { AuthService } from '@core/services/auth.service'; import { AuthService } from '@core/services/auth.service';
import { MerchantSyncService } from '../hub-users-management/merchant-sync-orchestrator.service'; import { MerchantSyncService } from '../hub-users-management/merchant-sync-orchestrator.service';
import { PageTitle } from '@app/components/page-title/page-title'; import { PageTitle } from '@app/components/page-title/page-title';

View File

@ -16,7 +16,7 @@ import {
import { HubUsersService } from '@modules/hub-users-management/hub-users.service'; import { HubUsersService } from '@modules/hub-users-management/hub-users.service';
import { MerchantUsersService } from '@modules/hub-users-management/merchant-users.service'; import { MerchantUsersService } from '@modules/hub-users-management/merchant-users.service';
import { RoleManagementService } from '@core/services/hub-users-roles-management.service'; import { RoleManagementService } from '@core/services/hub-users-roles-management-old.service';
import { AuthService } from '@core/services/auth.service'; import { AuthService } from '@core/services/auth.service';
@Component({ @Component({

View File

@ -19,7 +19,7 @@ import {
Currency Currency
} from '@core/models/dcb-bo-hub-subscription.model'; } from '@core/models/dcb-bo-hub-subscription.model';
import { User, UserRole } from '@core/models/dcb-bo-hub-user.model'; import { User, UserRole } from '@core/models/dcb-bo-hub-user.model';
import { RoleManagementService } from '@core/services/hub-users-roles-management.service'; import { RoleManagementService } from '@core/services/hub-users-roles-management-old.service';
@Component({ @Component({
selector: 'app-subscriptions', selector: 'app-subscriptions',

View File

@ -1,351 +1,204 @@
<div class="transaction-details"> <div class="transaction-details">
<!-- Loading State -->
@if (loading && !transaction) { <div class="transaction-details">
<!-- Message d'accès refusé -->
@if (accessDenied) {
<div class="text-center py-5"> <div class="text-center py-5">
<div class="spinner-border text-primary" role="status"> <ng-icon name="lucideLock" class="text-danger fs-1 mb-3"></ng-icon>
<span class="visually-hidden">Chargement...</span> <h5 class="text-danger">Accès refusé</h5>
</div> <p class="text-muted mb-4">Vous n'avez pas la permission d'accéder à cette transaction.</p>
<p class="mt-2 text-muted">Chargement des détails de la transaction...</p>
</div>
}
<!-- Messages -->
@if (error) {
<div class="alert alert-danger d-flex align-items-center">
<ng-icon name="lucideAlertCircle" class="me-2"></ng-icon>
<div class="flex-grow-1">{{ error }}</div>
<button class="btn-close" (click)="error = ''"></button>
</div>
}
@if (success) {
<div class="alert alert-success d-flex align-items-center">
<ng-icon name="lucideCheckCircle" class="me-2"></ng-icon>
<div class="flex-grow-1">{{ success }}</div>
<button class="btn-close" (click)="success = ''"></button>
</div>
}
@if (transaction && !loading) {
<div class="row">
<!-- Colonne principale -->
<div class="col-lg-8">
<!-- En-tête de la transaction -->
<div class="card mb-4">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center">
<h5 class="card-title mb-0 me-3">Transaction #{{ transaction.id }}</h5>
<span [class]="getStatusBadgeClass(transaction.status)" class="badge">
<ng-icon [name]="getStatusIcon(transaction.status)" class="me-1"></ng-icon>
{{ transaction.status }}
</span>
</div>
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary btn-sm" (click)="copyToClipboard(transaction.id)"
ngbTooltip="Copier l'ID">
<ng-icon name="lucideCopy"></ng-icon>
</button>
<button class="btn btn-outline-secondary btn-sm" (click)="printDetails()"
ngbTooltip="Imprimer">
<ng-icon name="lucidePrinter"></ng-icon>
</button>
<button class="btn btn-outline-primary btn-sm" (click)="loadTransactionDetails()"
[disabled]="loading" ngbTooltip="Actualiser">
<ng-icon name="lucideRefreshCw" [class.spin]="loading"></ng-icon>
</button>
</div>
</div>
<div class="card-body">
<!-- Montant et informations principales -->
<div class="row mb-4">
<div class="col-md-6">
<div class="d-flex align-items-center">
<div class="transaction-amount-icon bg-primary rounded-circle p-3 me-3">
<ng-icon name="lucideEuro" class="text-white fs-4"></ng-icon>
</div>
<div>
<div class="text-muted small">Montant</div>
<div [class]="getAmountColor(transaction.amount) + ' h3 mb-0'">
{{ formatCurrency(transaction.amount, transaction.currency) }}
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="d-flex align-items-center h-100">
<div class="transaction-date-icon bg-secondary rounded-circle p-3 me-3">
<ng-icon name="lucideCalendar" class="text-white fs-4"></ng-icon>
</div>
<div>
<div class="text-muted small">Date de transaction</div>
<div class="h6 mb-0">{{ formatDate(transaction.transactionDate) }}</div>
<small class="text-muted">{{ formatRelativeTime(transaction.transactionDate) }}</small>
</div>
</div>
</div>
</div>
<!-- Informations détaillées -->
<div class="row">
<div class="col-12">
<h6 class="border-bottom pb-2 mb-3">Informations de la transaction</h6>
</div>
<div class="col-md-6 mb-3">
<label class="form-label text-muted small mb-1">MSISDN</label>
<div class="d-flex align-items-center">
<ng-icon name="lucideSmartphone" class="me-2 text-muted"></ng-icon>
<span class="fw-medium font-monospace">{{ transaction.msisdn }}</span>
<button class="btn btn-sm btn-link p-0 ms-2" (click)="copyToClipboard(transaction.msisdn)">
<ng-icon name="lucideCopy" class="text-muted"></ng-icon>
</button>
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label text-muted small mb-1">Opérateur</label>
<div class="d-flex align-items-center">
<ng-icon name="lucideGlobe" class="me-2 text-muted"></ng-icon>
<span class="fw-medium">{{ transaction.operator }}</span>
<span class="badge bg-light text-dark ms-2">{{ transaction.country }}</span>
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label text-muted small mb-1">Produit</label>
<div class="d-flex align-items-center">
<ng-icon name="lucidePackage" class="me-2 text-muted"></ng-icon>
<div>
<div class="fw-medium">{{ transaction.productName }}</div>
<small class="text-muted">ID: {{ transaction.productId }}</small>
</div>
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label text-muted small mb-1">Catégorie</label>
<div>
<span class="badge bg-info">{{ transaction.productCategory }}</span>
</div>
</div>
@if (transaction.merchantName) {
<div class="col-md-6 mb-3">
<label class="form-label text-muted small mb-1">Marchand</label>
<div class="d-flex align-items-center">
<ng-icon name="lucideUser" class="me-2 text-muted"></ng-icon>
<span class="fw-medium">{{ transaction.merchantName }}</span>
</div>
</div>
}
@if (transaction.externalId) {
<div class="col-md-6 mb-3">
<label class="form-label text-muted small mb-1">ID Externe</label>
<div class="d-flex align-items-center">
<span class="font-monospace small">{{ transaction.externalId }}</span>
<button class="btn btn-sm btn-link p-0 ms-2" (click)="copyToClipboard(transaction.externalId!)">
<ng-icon name="lucideCopy" class="text-muted"></ng-icon>
</button>
</div>
</div>
}
</div>
<!-- Informations techniques -->
<div class="row mt-4">
<div class="col-12">
<h6 class="border-bottom pb-2 mb-3">Informations techniques</h6>
</div>
<div class="col-md-6 mb-2">
<label class="form-label text-muted small mb-1">Créé le</label>
<div class="small">{{ formatDate(transaction.createdAt) }}</div>
</div>
<div class="col-md-6 mb-2">
<label class="form-label text-muted small mb-1">Mis à jour le</label>
<div class="small">{{ formatDate(transaction.updatedAt) }}</div>
</div>
@if (transaction.userAgent) {
<div class="col-12 mb-2">
<label class="form-label text-muted small mb-1">User Agent</label>
<div class="small font-monospace text-truncate">{{ transaction.userAgent }}</div>
</div>
}
@if (transaction.ipAddress) {
<div class="col-md-6 mb-2">
<label class="form-label text-muted small mb-1">Adresse IP</label>
<div class="small font-monospace">{{ transaction.ipAddress }}</div>
</div>
}
</div>
<!-- Détails d'erreur -->
@if (showErrorDetails()) {
<div class="row mt-4">
<div class="col-12">
<h6 class="border-bottom pb-2 mb-3 text-danger">
<ng-icon name="lucideAlertCircle" class="me-2"></ng-icon>
Détails de l'erreur
</h6>
</div>
@if (transaction.errorCode) {
<div class="col-md-6 mb-2">
<label class="form-label text-muted small mb-1">Code d'erreur</label>
<div class="fw-medium text-danger">{{ transaction.errorCode }}</div>
</div>
}
@if (transaction.errorMessage) {
<div class="col-12 mb-2">
<label class="form-label text-muted small mb-1">Message d'erreur</label>
<div class="alert alert-danger small mb-0">{{ transaction.errorMessage }}</div>
</div>
}
</div>
}
</div>
</div>
</div>
<!-- Colonne latérale - Actions et métadonnées -->
<div class="col-lg-4">
<!-- Actions -->
<div class="card mb-4">
<div class="card-header bg-light">
<h6 class="card-title mb-0">Actions</h6>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<!-- Remboursement -->
@if (canRefund()) {
<button
class="btn btn-warning"
(click)="refundTransaction()"
[disabled]="refunding"
>
@if (refunding) {
<div class="spinner-border spinner-border-sm me-2" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
}
<ng-icon name="lucideUndo2" class="me-1"></ng-icon>
Rembourser
</button>
}
<!-- Réessayer -->
@if (canRetry()) {
<button
class="btn btn-info"
(click)="retryTransaction()"
[disabled]="retrying"
>
@if (retrying) {
<div class="spinner-border spinner-border-sm me-2" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
}
<ng-icon name="lucideRefreshCw" class="me-1"></ng-icon>
Réessayer
</button>
}
<!-- Annuler -->
@if (canCancel()) {
<button
class="btn btn-secondary"
(click)="cancelTransaction()"
>
<ng-icon name="lucideBan" class="me-1"></ng-icon>
Annuler
</button>
}
<!-- Télécharger les détails -->
<button class="btn btn-outline-primary">
<ng-icon name="lucideDownload" class="me-1"></ng-icon>
Télécharger PDF
</button>
</div>
</div>
</div>
<!-- Métadonnées -->
<div class="card">
<div class="card-header bg-light">
<h6 class="card-title mb-0">Métadonnées</h6>
</div>
<div class="card-body">
<div class="small">
<div class="d-flex justify-content-between mb-2">
<span class="text-muted">ID Transaction:</span>
<span class="font-monospace">{{ transaction.id }}</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-muted">Opérateur ID:</span>
<span>{{ transaction.operatorId }}</span>
</div>
@if (transaction.merchantId) {
<div class="d-flex justify-content-between mb-2">
<span class="text-muted">Marchand ID:</span>
<span class="font-monospace small">{{ transaction.merchantId }}</span>
</div>
}
<div class="d-flex justify-content-between mb-2">
<span class="text-muted">Devise:</span>
<span>{{ transaction.currency }}</span>
</div>
<div class="d-flex justify-content-between">
<span class="text-muted">Statut:</span>
<span [class]="getStatusBadgeClass(transaction.status)" class="badge">
{{ transaction.status }}
</span>
</div>
</div>
</div>
</div>
<!-- Données personnalisées -->
@if (getCustomDataKeys().length > 0) {
<div class="card mt-4">
<div class="card-header bg-light">
<h6 class="card-title mb-0">Données personnalisées</h6>
</div>
<div class="card-body">
<div class="small">
@for (key of getCustomDataKeys(); track key) {
<div class="d-flex justify-content-between mb-2">
<span class="text-muted">{{ key }}:</span>
<span class="font-monospace small">{{ transaction.customData![key] }}</span>
</div>
}
</div>
</div>
</div>
}
</div>
</div>
}
<!-- Transaction non trouvée -->
@if (!transaction && !loading) {
<div class="text-center py-5">
<ng-icon name="lucideAlertCircle" class="text-muted fs-1 mb-3"></ng-icon>
<h5 class="text-muted">Transaction non trouvée</h5>
<p class="text-muted mb-4">La transaction avec l'ID "{{ transactionId }}" n'existe pas ou a été supprimée.</p>
<button class="btn btn-primary" routerLink="/transactions"> <button class="btn btn-primary" routerLink="/transactions">
<ng-icon name="lucideArrowLeft" class="me-1"></ng-icon> <ng-icon name="lucideArrowLeft" class="me-1"></ng-icon>
Retour à la liste Retour à la liste
</button> </button>
</div> </div>
} @else {
<!-- Loading State -->
@if (loading && !transaction) {
<div class="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 détails de la transaction...</p>
</div>
}
<!-- Messages -->
@if (error) {
<div class="alert alert-danger d-flex align-items-center">
<ng-icon name="lucideAlertCircle" class="me-2"></ng-icon>
<div class="flex-grow-1">{{ error }}</div>
<button class="btn-close" (click)="error = ''"></button>
</div>
}
@if (success) {
<div class="alert alert-success d-flex align-items-center">
<ng-icon name="lucideCheckCircle" class="me-2"></ng-icon>
<div class="flex-grow-1">{{ success }}</div>
<button class="btn-close" (click)="success = ''"></button>
</div>
}
@if (transaction && !loading) {
<div class="row">
<!-- Colonne principale -->
<div class="col-lg-8">
<!-- En-tête de la transaction -->
<div class="card mb-4">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center">
<h5 class="card-title mb-0 me-3">Transaction #{{ transaction.id }}</h5>
<span [class]="getStatusBadgeClass(transaction.status)" class="badge">
<ng-icon [name]="getStatusIcon(transaction.status)" class="me-1"></ng-icon>
{{ transaction.status }}
</span>
</div>
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary btn-sm" (click)="copyToClipboard(transaction.id)"
ngbTooltip="Copier l'ID">
<ng-icon name="lucideCopy"></ng-icon>
</button>
<button class="btn btn-outline-secondary btn-sm" (click)="printDetails()"
ngbTooltip="Imprimer">
<ng-icon name="lucidePrinter"></ng-icon>
</button>
<button class="btn btn-outline-primary btn-sm" (click)="loadTransactionDetails()"
[disabled]="loading" ngbTooltip="Actualiser">
<ng-icon name="lucideRefreshCw" [class.spin]="loading"></ng-icon>
</button>
</div>
</div>
<div class="card-body">
<!-- Montant et informations principales -->
<div class="row mb-4">
<div class="col-md-6">
<div class="d-flex align-items-center">
<div class="transaction-amount-icon bg-primary rounded-circle p-3 me-3">
<ng-icon name="lucideBanknote" class="text-white fs-4"></ng-icon>
</div>
<div>
<div class="text-muted small">Montant</div>
<div [class]="getAmountColor(transaction.amount) + ' h3 mb-0'">
{{ formatCurrency(transaction.amount, transaction.currency) }}
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="d-flex align-items-center h-100">
<div class="transaction-date-icon bg-secondary rounded-circle p-3 me-3">
<ng-icon name="lucideCalendar" class="text-white fs-4"></ng-icon>
</div>
<div>
<div class="text-muted small">Date de transaction</div>
<div class="h6 mb-0">{{ formatDate(transaction.transactionDate) }}</div>
<small class="text-muted">{{ formatRelativeTime(transaction.transactionDate) }}</small>
</div>
</div>
</div>
</div>
<!-- Informations détaillées -->
<div class="row">
<div class="col-12">
<h6 class="border-bottom pb-2 mb-3">Informations de la transaction</h6>
</div>
<div class="col-md-6 mb-3">
<label class="form-label text-muted small mb-1">Plan/Service</label>
<div class="d-flex align-items-center">
<ng-icon name="lucidePackage" class="me-2 text-muted"></ng-icon>
<div>
<div class="fw-medium">{{ transaction.productName }}</div>
<small class="text-muted">ID: {{ transaction.productId }}</small>
</div>
</div>
</div>
@if (transaction.externalReference) {
<div class="col-md-6 mb-3">
<label class="form-label text-muted small mb-1">ID Externe</label>
<div class="d-flex align-items-center">
<span class="font-monospace small">{{ transaction.externalReference }}</span>
<button class="btn btn-sm btn-link p-0 ms-2" (click)="copyToClipboard(transaction.externalReference!)">
<ng-icon name="lucideCopy" class="text-muted"></ng-icon>
</button>
</div>
</div>
}
</div>
<!-- Informations techniques -->
<div class="row mt-4">
<div class="col-12">
<h6 class="border-bottom pb-2 mb-3">Informations techniques</h6>
</div>
<div class="col-md-6 mb-2">
<label class="form-label text-muted small mb-1">Créé le</label>
<div class="small">{{ formatDate(transaction.createdAt) }}</div>
</div>
<div class="col-md-6 mb-2">
<label class="form-label text-muted small mb-1">Mis à jour le</label>
<div class="small">{{ formatDate(transaction.updatedAt) }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- Colonne latérale - Actions et métadonnées -->
<div class="col-lg-4">
<!-- Métadonnées -->
<div class="card">
<div class="card-header bg-light">
<h6 class="card-title mb-0">Métadonnées</h6>
</div>
<div class="card-body">
<div class="small">
<div class="d-flex justify-content-between mb-2">
<span class="text-muted">ID Transaction:</span>
<span class="font-monospace">{{ transaction.id }}</span>
</div>
@if (transaction.merchantPartnerId) {
<div class="d-flex justify-content-between mb-2">
<span class="text-muted">Marchand ID:</span>
<span class="font-monospace small">{{ transaction.merchantPartnerId }}</span>
</div>
}
<div class="d-flex justify-content-between mb-2">
<span class="text-muted">Devise:</span>
<span>{{ transaction.currency }}</span>
</div>
<div class="d-flex justify-content-between">
<span class="text-muted">Statut:</span>
<span [class]="getStatusBadgeClass(transaction.status)" class="badge">
{{ transaction.status }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
}
<!-- Transaction non trouvée -->
@if (!transaction && !loading) {
<div class="text-center py-5">
<ng-icon name="lucideAlertCircle" class="text-muted fs-1 mb-3"></ng-icon>
<h5 class="text-muted">Transaction non trouvée</h5>
<p class="text-muted mb-4">La transaction avec l'ID "{{ transactionId }}" n'existe pas ou a été supprimée.</p>
<button class="btn btn-primary" routerLink="/transactions">
<ng-icon name="lucideArrowLeft" class="me-1"></ng-icon>
Retour à la liste
</button>
</div>
}
} }
</div> </div>

View File

@ -1,3 +1,4 @@
// [file name]: transactions/details/details.ts (mise à jour)
import { Component, inject, OnInit, Input, ChangeDetectorRef } from '@angular/core'; import { Component, inject, OnInit, Input, ChangeDetectorRef } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
@ -21,12 +22,16 @@ import {
lucideUser, lucideUser,
lucideGlobe, lucideGlobe,
lucideAlertCircle, lucideAlertCircle,
lucideInfo lucideInfo,
lucideShield,
lucideStore,
lucideLock
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { NgbAlertModule, NgbTooltipModule, NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { NgbAlertModule, NgbTooltipModule, NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { TransactionsService } from '../services/transactions.service'; import { TransactionsService } from '../services/transactions.service';
import { Transaction, TransactionStatus, RefundRequest } from '../models/transaction'; import { TransactionAccessService, TransactionAccess } from '../services/transaction-access.service';
import { Transaction, TransactionStatus} from '@core/models/dcb-bo-hub-transaction.model';
@Component({ @Component({
selector: 'app-transaction-details', selector: 'app-transaction-details',
@ -48,11 +53,15 @@ import { Transaction, TransactionStatus, RefundRequest } from '../models/transac
}) })
export class TransactionDetails implements OnInit { export class TransactionDetails implements OnInit {
private transactionsService = inject(TransactionsService); private transactionsService = inject(TransactionsService);
private accessService = inject(TransactionAccessService);
private modalService = inject(NgbModal); private modalService = inject(NgbModal);
private cdRef = inject(ChangeDetectorRef); private cdRef = inject(ChangeDetectorRef);
@Input() transactionId!: string; @Input() transactionId!: string;
// Permissions
access!: TransactionAccess;
// Données // Données
transaction: Transaction | null = null; transaction: Transaction | null = null;
loading = false; loading = false;
@ -63,100 +72,62 @@ export class TransactionDetails implements OnInit {
refunding = false; refunding = false;
retrying = false; retrying = false;
// Accès
canViewSensitiveData = false;
accessDenied = false;
ngOnInit() { ngOnInit() {
this.initializePermissions();
if (this.transactionId) { if (this.transactionId) {
this.loadTransactionDetails(); this.loadTransactionDetails();
} }
} }
private initializePermissions() {
this.access = this.accessService.getTransactionAccess();
this.canViewSensitiveData = this.access.canViewSensitiveData;
}
loadTransactionDetails() { loadTransactionDetails() {
if (!this.access.canViewDetails) {
this.error = 'Vous n\'avez pas la permission de voir les détails des transactions';
this.accessDenied = true;
this.cdRef.detectChanges();
return;
}
this.loading = true; this.loading = true;
this.error = ''; this.error = '';
this.accessDenied = false;
this.transactionsService.getTransactionById(this.transactionId).subscribe({ this.transactionsService.getTransactionById(this.transactionId).subscribe({
next: (transaction) => { next: (transaction) => {
this.transaction = transaction; // Vérifier si l'utilisateur a accès à cette transaction spécifique
this.loading = false; this.accessService.canAccessTransaction(transaction.merchantPartnerId ? transaction.merchantPartnerId : undefined)
this.cdRef.detectChanges(); .subscribe(canAccess => {
if (!canAccess) {
this.error = 'Vous n\'avez pas accès à cette transaction';
this.accessDenied = true;
this.loading = false;
this.cdRef.detectChanges();
return;
}
this.transaction = transaction;
this.loading = false;
this.cdRef.detectChanges();
});
}, },
error: (error) => { error: (error) => {
this.error = 'Erreur lors du chargement des détails de la transaction'; this.error = 'Erreur lors du chargement des détails de la transaction';
this.loading = false; this.loading = false;
// Données mockées pour le développement
const mockTransactions = this.transactionsService.getMockTransactions();
this.transaction = mockTransactions.find(tx => tx.id === this.transactionId) || mockTransactions[0];
this.loading = false;
this.cdRef.detectChanges(); this.cdRef.detectChanges();
console.error('Error loading transaction details:', error); console.error('Error loading transaction details:', error);
} }
}); });
} }
// Actions sur la transaction
refundTransaction() {
if (!this.transaction) return;
this.refunding = true;
const refundRequest: RefundRequest = {
transactionId: this.transaction.id,
reason: 'Remboursement manuel par l\'administrateur'
};
this.transactionsService.refundTransaction(refundRequest).subscribe({
next: (response) => {
this.transaction = response.transaction;
this.refunding = false;
this.success = 'Transaction remboursée avec succès';
this.cdRef.detectChanges();
},
error: (error) => {
this.refunding = false;
this.error = 'Erreur lors du remboursement de la transaction';
this.cdRef.detectChanges();
console.error('Error refunding transaction:', error);
}
});
}
retryTransaction() {
if (!this.transaction) return;
this.retrying = true;
this.transactionsService.retryTransaction(this.transaction.id).subscribe({
next: (response) => {
this.transaction = response.transaction;
this.retrying = false;
this.success = 'Nouvelle tentative lancée avec succès';
this.cdRef.detectChanges();
},
error: (error) => {
this.retrying = false;
this.error = 'Erreur lors de la nouvelle tentative';
this.cdRef.detectChanges();
console.error('Error retrying transaction:', error);
}
});
}
cancelTransaction() {
if (!this.transaction) return;
this.transactionsService.cancelTransaction(this.transaction.id).subscribe({
next: () => {
this.success = 'Transaction annulée avec succès';
this.loadTransactionDetails(); // Recharger les données
this.cdRef.detectChanges();
},
error: (error) => {
this.error = 'Erreur lors de l\'annulation de la transaction';
this.cdRef.detectChanges();
console.error('Error cancelling transaction:', error);
}
});
}
// Utilitaires // Utilitaires
copyToClipboard(text: string) { copyToClipboard(text: string) {
navigator.clipboard.writeText(text).then(() => { navigator.clipboard.writeText(text).then(() => {
@ -170,21 +141,12 @@ export class TransactionDetails implements OnInit {
window.print(); window.print();
} }
// Méthode pour obtenir les clés des données personnalisées
getCustomDataKeys(): string[] {
if (!this.transaction?.customData) {
return [];
}
return Object.keys(this.transaction.customData);
}
// Getters pour l'affichage // Getters pour l'affichage
getStatusBadgeClass(status: TransactionStatus): string { getStatusBadgeClass(status: TransactionStatus): string {
switch (status) { switch (status) {
case 'SUCCESS': return 'badge bg-success'; case 'SUCCESS': return 'badge bg-success';
case 'PENDING': return 'badge bg-warning'; case 'PENDING': return 'badge bg-warning';
case 'FAILED': return 'badge bg-danger'; case 'FAILED': return 'badge bg-danger';
case 'REFUNDED': return 'badge bg-info';
case 'CANCELLED': return 'badge bg-secondary'; case 'CANCELLED': return 'badge bg-secondary';
case 'EXPIRED': return 'badge bg-dark'; case 'EXPIRED': return 'badge bg-dark';
default: return 'badge bg-secondary'; default: return 'badge bg-secondary';
@ -196,7 +158,6 @@ export class TransactionDetails implements OnInit {
case 'SUCCESS': return 'lucideCheckCircle'; case 'SUCCESS': return 'lucideCheckCircle';
case 'PENDING': return 'lucideClock'; case 'PENDING': return 'lucideClock';
case 'FAILED': return 'lucideXCircle'; case 'FAILED': return 'lucideXCircle';
case 'REFUNDED': return 'lucideUndo2';
case 'CANCELLED': return 'lucideBan'; case 'CANCELLED': return 'lucideBan';
default: return 'lucideClock'; default: return 'lucideClock';
} }
@ -241,18 +202,31 @@ export class TransactionDetails implements OnInit {
} }
canRefund(): boolean { canRefund(): boolean {
return this.transaction?.status === 'SUCCESS'; return this.access.canRefund && this.transaction?.status === 'SUCCESS';
} }
canRetry(): boolean { canRetry(): boolean {
return this.transaction?.status === 'FAILED'; return this.access.canRetry && this.transaction?.status === 'FAILED';
} }
canCancel(): boolean { canCancel(): boolean {
return this.transaction?.status === 'PENDING'; return this.access.canCancel && this.transaction?.status === 'PENDING';
} }
showErrorDetails(): boolean { // Méthodes pour le template
return !!this.transaction?.errorCode || !!this.transaction?.errorMessage; getUserBadgeClass(): string {
return this.access.isHubUser ? 'bg-primary' : 'bg-success';
}
getUserBadgeIcon(): string {
return this.access.isHubUser ? 'lucideShield' : 'lucideStore';
}
getUserBadgeText(): string {
return this.access.isHubUser ? 'Hub User' : 'Merchant User';
}
showSensitiveData(): boolean {
return this.canViewSensitiveData && !this.accessDenied;
} }
} }

View File

@ -5,29 +5,26 @@
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<div> <div>
<h4 class="mb-1">Gestion des Transactions</h4> <h4 class="mb-1">Gestion des Transactions</h4>
<nav aria-label="breadcrumb"> <div class="d-flex align-items-center gap-2">
<ol class="breadcrumb mb-0"> <nav aria-label="breadcrumb">
<li class="breadcrumb-item"> <ol class="breadcrumb mb-0">
<a href="javascript:void(0)" class="text-decoration-none">DCB Transactions</a> <li class="breadcrumb-item">
</li> <a href="javascript:void(0)" class="text-decoration-none">DCB Transactions</a>
</ol> </li>
</nav> </ol>
</nav>
<span [class]="getUserBadgeClass()" class="badge">
<ng-icon [name]="getUserBadgeIcon()" class="me-1"></ng-icon>
{{ getUserBadgeText() }}
</span>
<span class="badge bg-info" *ngIf="currentMerchantId">
<ng-icon name="lucideStore" class="me-1"></ng-icon>
Merchant {{ currentMerchantId }}
</span>
</div>
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<!-- Export -->
<div ngbDropdown>
<button class="btn btn-outline-primary" ngbDropdownToggle>
<ng-icon name="lucideDownload" class="me-1"></ng-icon>
Exporter
</button>
<div ngbDropdownMenu>
<button ngbDropdownItem (click)="exportTransactions('csv')">CSV</button>
<button ngbDropdownItem (click)="exportTransactions('excel')">Excel</button>
<button ngbDropdownItem (click)="exportTransactions('pdf')">PDF</button>
</div>
</div>
<!-- Refresh --> <!-- Refresh -->
<button class="btn btn-outline-secondary" (click)="loadTransactions()" [disabled]="loading"> <button class="btn btn-outline-secondary" (click)="loadTransactions()" [disabled]="loading">
<ng-icon name="lucideRefreshCw" [class.spin]="loading"></ng-icon> <ng-icon name="lucideRefreshCw" [class.spin]="loading"></ng-icon>
@ -37,262 +34,264 @@
</div> </div>
</div> </div>
<!-- Statistiques rapides --> <!-- Message d'accès refusé -->
@if (paginatedData?.stats) { @if (!access.canViewTransactions) {
<div class="row mb-4"> <div class="alert alert-danger">
<div class="col-12"> <div class="d-flex align-items-center">
<div class="card bg-light"> <ng-icon name="lucideLock" class="me-2"></ng-icon>
<div class="card-body py-3"> <div>
<div class="row text-center"> <strong>Accès refusé</strong>
<div class="col"> <p class="mb-0">Vous n'avez pas les permissions nécessaires pour accéder à cette section.</p>
<small class="text-muted">Total</small>
<div class="h5 mb-0">{{ getTotal() }}</div>
</div>
<div class="col">
<small class="text-muted">Succès</small>
<div class="h5 mb-0 text-success">{{ getSuccessCount() }}</div>
</div>
<div class="col">
<small class="text-muted">Échecs</small>
<div class="h5 mb-0 text-danger">{{ getFailedCount() }}</div>
</div>
<div class="col">
<small class="text-muted">En attente</small>
<div class="h5 mb-0 text-warning">{{ getPendingCount() }}</div>
</div>
<div class="col">
<small class="text-muted">Taux de succès</small>
<div class="h5 mb-0 text-primary">{{ getSuccessRate() }}%</div>
</div>
<div class="col">
<small class="text-muted">Montant total</small>
<div class="h5 mb-0">{{ formatCurrency(getTotalAmount()) }}</div>
</div>
</div>
</div>
</div>
</div> </div>
</div>
</div> </div>
} @else {
<!-- Statistiques rapides -->
@if (paginatedData?.stats) {
<div class="row mb-4">
<div class="col-12">
<div class="card bg-light">
<div class="card-body py-3">
<div class="row text-center">
<div class="col">
<small class="text-muted">Total</small>
<div class="h5 mb-0">{{ getTotal() }}</div>
</div>
<div class="col">
<small class="text-muted">Succès</small>
<div class="h5 mb-0 text-success">{{ getSuccessCount() }}</div>
</div>
<div class="col">
<small class="text-muted">Échecs</small>
<div class="h5 mb-0 text-danger">{{ getFailedCount() }}</div>
</div>
<div class="col">
<small class="text-muted">En attente</small>
<div class="h5 mb-0 text-warning">{{ getPendingCount() }}</div>
</div>
<div class="col">
<small class="text-muted">Montant total</small>
<div class="h5 mb-0">{{ formatCurrency(getTotalAmount()) }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
}
<!-- Barre de recherche et filtres -->
<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 periodicity, Type..."
[(ngModel)]="searchTerm"
(keyup.enter)="onSearch()"
>
</div>
</div>
<div class="col-md-8">
<div class="d-flex gap-2">
<!-- Filtre statut -->
<select class="form-select" style="width: auto;" (change)="onStatusFilterChange($any($event.target).value)">
<option value="all">Tous les statuts</option>
@for (status of statusOptions; track status) {
<option [value]="status">{{ status }}</option>
}
</select>
<!-- Filtre opérateur -->
<select class="form-select" style="width: auto;" (change)="onOperatorFilterChange($any($event.target).value)">
<option value="">Tous les opérateurs</option>
@for (operator of operatorOptions; track operator) {
<option [value]="operator">{{ operator }}</option>
}
</select>
<button class="btn btn-outline-primary" (click)="onSearch()">
<ng-icon name="lucideFilter" class="me-1"></ng-icon>
Filtrer
</button>
<button class="btn btn-outline-secondary" (click)="onClearFilters()">
<ng-icon name="lucideX" class="me-1"></ng-icon>
Effacer
</button>
</div>
</div>
</div>
<!-- Messages d'erreur -->
@if (error) {
<div class="alert alert-danger">
<ng-icon name="lucideXCircle" class="me-2"></ng-icon>
{{ error }}
</div>
} }
<!-- Barre de recherche et filtres --> <!-- Loading -->
<div class="row mb-3"> @if (loading) {
<div class="col-md-4"> <div class="text-center py-4">
<div class="input-group"> <div class="spinner-border text-primary" role="status">
<span class="input-group-text"> <span class="visually-hidden">Chargement...</span>
<ng-icon name="lucideSearch"></ng-icon> </div>
</span> <p class="mt-2 text-muted">Chargement des transactions...</p>
<input
type="text"
class="form-control"
placeholder="Rechercher par MSISDN, ID..."
[(ngModel)]="searchTerm"
(keyup.enter)="onSearch()"
>
</div> </div>
</div> }
<div class="col-md-8"> <!-- Tableau des transactions -->
<div class="d-flex gap-2"> @if (!loading) {
<!-- Filtre statut --> <div class="card">
<select class="form-select" style="width: auto;" (change)="onStatusFilterChange($any($event.target).value)"> <div class="card-body p-0">
<option value="all">Tous les statuts</option> <div class="table-responsive">
@for (status of statusOptions; track status) { <table class="table table-hover mb-0">
<option [value]="status">{{ status }}</option> <thead class="table-light">
} <tr>
</select> <th width="50">
<!-- Filtre opérateur -->
<select class="form-select" style="width: auto;" (change)="onOperatorFilterChange($any($event.target).value)">
<option value="">Tous les opérateurs</option>
@for (operator of operatorOptions; track operator) {
<option [value]="operator">{{ operator }}</option>
}
</select>
<button class="btn btn-outline-primary" (click)="onSearch()">
<ng-icon name="lucideFilter" class="me-1"></ng-icon>
Filtrer
</button>
<button class="btn btn-outline-secondary" (click)="onClearFilters()">
<ng-icon name="lucideX" class="me-1"></ng-icon>
Effacer
</button>
</div>
</div>
</div>
<!-- Messages d'erreur -->
@if (error) {
<div class="alert alert-danger">
<ng-icon name="lucideXCircle" class="me-2"></ng-icon>
{{ error }}
</div>
}
<!-- Loading -->
@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">Chargement des transactions...</p>
</div>
}
<!-- Tableau des transactions -->
@if (!loading) {
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th width="50">
<input
type="checkbox"
class="form-check-input"
[checked]="selectAll"
(change)="toggleSelectAll()"
>
</th>
<th (click)="sort('id')" class="cursor-pointer">
<div class="d-flex align-items-center">
<span>ID</span>
<ng-icon [name]="getSortIcon('id')" class="ms-1 fs-12"></ng-icon>
</div>
</th>
<th (click)="sort('msisdn')" class="cursor-pointer">
<div class="d-flex align-items-center">
<span>MSISDN</span>
<ng-icon [name]="getSortIcon('msisdn')" class="ms-1 fs-12"></ng-icon>
</div>
</th>
<th>Opérateur</th>
<th (click)="sort('amount')" class="cursor-pointer">
<div class="d-flex align-items-center">
<span>Montant</span>
<ng-icon [name]="getSortIcon('amount')" class="ms-1 fs-12"></ng-icon>
</div>
</th>
<th>Produit</th>
<th>Statut</th>
<th (click)="sort('transactionDate')" class="cursor-pointer">
<div class="d-flex align-items-center">
<span>Date</span>
<ng-icon [name]="getSortIcon('transactionDate')" class="ms-1 fs-12"></ng-icon>
</div>
</th>
<th width="120">Actions</th>
</tr>
</thead>
<tbody>
@for (transaction of transactions; track transaction.id) {
<tr [class.table-active]="selectedTransactions.has(transaction.id)">
<td>
<input <input
type="checkbox" type="checkbox"
class="form-check-input" class="form-check-input"
[checked]="selectedTransactions.has(transaction.id)" [checked]="selectAll"
(change)="toggleTransactionSelection(transaction.id)" (change)="toggleSelectAll()"
> >
</td> </th>
<td class="font-monospace small">{{ transaction.id }}</td> <th (click)="sort('id')" class="cursor-pointer">
<td class="font-monospace">{{ transaction.msisdn }}</td> <div class="d-flex align-items-center">
<td> <span>ID</span>
<span class="badge bg-light text-dark">{{ transaction.operator }}</span> <ng-icon [name]="getSortIcon('id')" class="ms-1 fs-12"></ng-icon>
</td>
<td>
<span [class]="getAmountColor(transaction.amount)">
{{ formatCurrency(transaction.amount, transaction.currency) }}
</span>
</td>
<td>
<div class="text-truncate" style="max-width: 150px;"
[ngbTooltip]="transaction.productName">
{{ transaction.productName }}
</div> </div>
</td> </th>
<td> <th>Type</th>
<span [class]="getStatusBadgeClass(transaction.status)" class="badge"> <th>Merchant</th>
<ng-icon [name]="getStatusIcon(transaction.status)" class="me-1"></ng-icon> <th (click)="sort('amount')" class="cursor-pointer">
{{ transaction.status }} <div class="d-flex align-items-center">
</span> <span>Montant</span>
</td> <ng-icon [name]="getSortIcon('amount')" class="ms-1 fs-12"></ng-icon>
<td class="small text-muted"> </div>
{{ formatDate(transaction.transactionDate) }} </th>
</td> <th>Périodicité</th>
<td> <th>Statut</th>
<div class="btn-group btn-group-sm" role="group"> <th (click)="sort('transactionDate')" class="cursor-pointer">
<button <div class="d-flex align-items-center">
class="btn btn-outline-primary" <span>Date début</span>
(click)="viewTransactionDetails(transaction.id)" <ng-icon [name]="getSortIcon('transactionDate')" class="ms-1 fs-12"></ng-icon>
ngbTooltip="Voir les détails" </div>
</th>
<th>Prochain paiement</th>
<th width="120">Actions</th>
</tr>
</thead>
<tbody>
@for (transaction of transactions; track transaction.id) {
<tr [class.table-active]="selectedTransactions.has(transaction.id)">
<td>
<input
type="checkbox"
class="form-check-input"
[checked]="selectedTransactions.has(transaction.id)"
(change)="toggleTransactionSelection(transaction.id)"
> >
<ng-icon name="lucideEye"></ng-icon> </td>
<td class="font-monospace small">{{ transaction.id }}</td>
<td>
<span class="badge bg-light text-dark">
<ng-icon [name]="getTypeIcon(transaction.type)" class="me-1"></ng-icon>
Abonnement
</span>
</td>
<td>
<span class="badge bg-secondary">
Merchant {{ transaction.merchantPartnerId }}
</span>
</td>
<td>
<span [class]="getAmountColor(transaction.amount)">
{{ formatCurrency(transaction.amount, transaction.currency) }}
</span>
</td>
<td>
@if (transaction.periodicity) {
<span [class]="getPeriodicityBadgeClass(transaction.periodicity)" class="badge">
{{ getPeriodicityDisplayName(transaction.periodicity) }}
</span>
}
</td>
<td>
<span [class]="getStatusBadgeClass(transaction.status)" class="badge">
<ng-icon [name]="getStatusIcon(transaction.status)" class="me-1"></ng-icon>
{{ getStatusDisplayName(transaction.status) }}
</span>
</td>
<td class="small text-muted">
{{ formatDate(transaction.transactionDate) }}
</td>
<td class="small text-muted">
@if (transaction.nextPaymentDate) {
{{ formatDate(transaction.nextPaymentDate) }}
} @else {
-
}
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<button
class="btn btn-outline-primary"
(click)="viewTransactionDetails(transaction.id)"
ngbTooltip="Voir les détails"
>
<ng-icon name="lucideEye"></ng-icon>
</button>
</div>
</td>
</tr>
}
@empty {
<tr>
<td colspan="10" class="text-center py-4">
<ng-icon name="lucideCreditCard" class="text-muted fs-1 mb-2 d-block"></ng-icon>
<p class="text-muted mb-3">Aucune transaction trouvée</p>
<button class="btn btn-primary" (click)="onClearFilters()">
Réinitialiser les filtres
</button> </button>
</td>
@if (transaction.status === 'SUCCESS') { </tr>
<button }
class="btn btn-outline-warning" </tbody>
(click)="refundTransaction(transaction.id)" </table>
ngbTooltip="Rembourser" </div>
>
<ng-icon name="lucideUndo2"></ng-icon>
</button>
}
@if (transaction.status === 'FAILED') {
<button
class="btn btn-outline-info"
(click)="retryTransaction(transaction.id)"
ngbTooltip="Réessayer"
>
<ng-icon name="lucideRefreshCw"></ng-icon>
</button>
}
</div>
</td>
</tr>
}
@empty {
<tr>
<td colspan="8" class="text-center py-4">
<ng-icon name="lucideCreditCard" class="text-muted fs-1 mb-2 d-block"></ng-icon>
<p class="text-muted mb-3">Aucune transaction trouvée</p>
<button class="btn btn-primary" (click)="onClearFilters()">
Réinitialiser les filtres
</button>
</td>
</tr>
}
</tbody>
</table>
</div> </div>
</div> </div>
</div>
<!-- Pagination --> <!-- Pagination -->
@if (paginatedData && paginatedData.totalPages > 1) { @if (paginatedData && paginatedData.totalPages >= 1) {
<div class="d-flex justify-content-between align-items-center mt-3"> <div class="d-flex justify-content-between align-items-center mt-3">
<div class="text-muted"> <div class="text-muted">
Affichage de {{ (filters.page! - 1) * filters.limit! + 1 }} à Affichage de {{ (filters.page! - 1) * filters.limit! + 1 }} à
{{ (filters.page! * filters.limit!) > (paginatedData.total || 0) ? (paginatedData.total || 0) : (filters.page! * filters.limit!) }} {{ (filters.page! * filters.limit!) > (paginatedData.total || 0) ? (paginatedData.total || 0) : (filters.page! * filters.limit!) }}
sur {{ paginatedData.total || 0 }} transactions sur {{ paginatedData.total || 0 }} transactions
</div>
<nav>
<ngb-pagination
[collectionSize]="paginatedData.total"
[page]="filters.page!"
[pageSize]="filters.limit!"
[maxSize]="5"
[rotate]="true"
[boundaryLinks]="true"
(pageChange)="onPageChange($event)"
/>
</nav>
</div> </div>
<nav> }
<ngb-pagination
[collectionSize]="paginatedData.total"
[page]="filters.page!"
[pageSize]="filters.limit!"
[maxSize]="5"
[rotate]="true"
[boundaryLinks]="true"
(pageChange)="onPageChange($event)"
/>
</nav>
</div>
} }
} }
</div> </div>

View File

@ -1,4 +1,4 @@
import { Component, inject, OnInit, ChangeDetectorRef, Output, EventEmitter } from '@angular/core'; import { Component, inject, OnInit, ChangeDetectorRef, Output, EventEmitter, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { NgIcon, provideNgIconsConfig } from '@ng-icons/core'; import { NgIcon, provideNgIconsConfig } from '@ng-icons/core';
@ -16,13 +16,32 @@ import {
lucideClock, lucideClock,
lucideXCircle, lucideXCircle,
lucideUndo2, lucideUndo2,
lucideBan lucideBan,
lucideShield,
lucideStore,
lucideCalendar,
lucideRepeat,
lucideCreditCard
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { NgbPaginationModule, NgbDropdownModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; import { NgbPaginationModule, NgbDropdownModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
import { TransactionsService } from '../services/transactions.service'; import { TransactionsService } from '../services/transactions.service';
import { Transaction, TransactionQuery, TransactionStatus, PaginatedTransactions } from '../models/transaction'; import { TransactionAccessService, TransactionAccess } from '../services/transaction-access.service';
import { environment } from '@environments/environment';
import {
Transaction,
TransactionQuery,
PaginatedTransactions,
TransactionType,
TransactionStatus,
TransactionUtils
} from '@core/models/dcb-bo-hub-transaction.model';
import {
Currency,
SubscriptionPeriodicity,
SubscriptionUtils
} from '@core/models/dcb-bo-hub-subscription.model';
@Component({ @Component({
selector: 'app-transactions-list', selector: 'app-transactions-list',
@ -42,13 +61,19 @@ import { environment } from '@environments/environment';
], ],
templateUrl: './list.html' templateUrl: './list.html'
}) })
export class TransactionsList implements OnInit { export class TransactionsList implements OnInit, OnDestroy {
private transactionsService = inject(TransactionsService); private transactionsService = inject(TransactionsService);
private accessService = inject(TransactionAccessService);
private cdRef = inject(ChangeDetectorRef); private cdRef = inject(ChangeDetectorRef);
@Output() transactionSelected = new EventEmitter<string>(); @Output() transactionSelected = new EventEmitter<string>();
@Output() openRefundModal = new EventEmitter<string>(); @Output() openRefundModal = new EventEmitter<string>();
// Permissions
access!: TransactionAccess;
currentUserRole = '';
currentMerchantId?: number;
// Données // Données
transactions: Transaction[] = []; transactions: Transaction[] = [];
paginatedData: PaginatedTransactions | null = null; paginatedData: PaginatedTransactions | null = null;
@ -63,19 +88,22 @@ export class TransactionsList implements OnInit {
page: 1, page: 1,
limit: 20, limit: 20,
status: undefined, status: undefined,
operator: '',
country: '',
startDate: undefined, startDate: undefined,
endDate: undefined, endDate: undefined,
msisdn: '',
sortBy: 'transactionDate', sortBy: 'transactionDate',
sortOrder: 'desc' sortOrder: 'desc'
}; };
// Options de filtre // Options de filtre
statusOptions: TransactionStatus[] = ['PENDING', 'SUCCESS', 'FAILED', 'REFUNDED', 'CANCELLED']; statusOptions: TransactionStatus[] = ['PENDING', 'SUCCESS', 'FAILED', 'CANCELLED'];
operatorOptions: string[] = ['Orange', 'Free', 'SFR', 'Bouygues']; typeOptions: TransactionType[] = [
countryOptions: string[] = ['FR', 'BE', 'CH', 'LU']; TransactionType.SUBSCRIPTION_PAYMENT,
TransactionType.SUBSCRIPTION_RENEWAL,
TransactionType.ONE_TIME_PAYMENT
];
periodicityOptions = Object.values(SubscriptionPeriodicity);
operatorOptions: string[] = ['Orange'];
countryOptions: string[] = ['SN'];
// Tri // Tri
sortField: string = 'transactionDate'; sortField: string = 'transactionDate';
@ -86,10 +114,36 @@ export class TransactionsList implements OnInit {
selectAll = false; selectAll = false;
ngOnInit() { ngOnInit() {
this.initializePermissions();
this.loadTransactions(); this.loadTransactions();
} }
ngOnDestroy() {
// Nettoyage si nécessaire
}
private initializePermissions() {
this.access = this.accessService.getTransactionAccess();
// Ajouter le merchant ID aux filtres si nécessaire
if (this.access.isMerchantUser && this.access.allowedMerchantIds.length > 0) {
this.filters.merchantPartnerId = this.access.allowedMerchantIds[0];
} else if (this.access.isHubUser && this.access.merchantId) {
// Pour les hub users qui veulent voir un merchant spécifique
this.filters.merchantPartnerId = this.access.merchantId;
}
// Définir le rôle pour l'affichage
this.currentUserRole = this.access.userRoleLabel;
this.currentMerchantId = this.access.merchantId;
}
loadTransactions() { loadTransactions() {
if (!this.access.canViewTransactions) {
this.error = 'Vous n\'avez pas la permission de voir les transactions';
return;
}
this.loading = true; this.loading = true;
this.error = ''; this.error = '';
@ -104,6 +158,11 @@ export class TransactionsList implements OnInit {
this.filters.sortBy = this.sortField; this.filters.sortBy = this.sortField;
this.filters.sortOrder = this.sortDirection; this.filters.sortOrder = this.sortDirection;
// Appliquer les restrictions de merchant pour les non-admin hub users
if (!this.access.canManageAll && this.access.allowedMerchantIds.length > 0) {
this.filters.merchantPartnerId = this.access.allowedMerchantIds[0];
}
this.transactionsService.getTransactions(this.filters).subscribe({ this.transactionsService.getTransactions(this.filters).subscribe({
next: (data) => { next: (data) => {
this.paginatedData = data; this.paginatedData = data;
@ -114,30 +173,6 @@ export class TransactionsList implements OnInit {
error: (error) => { error: (error) => {
this.error = 'Erreur lors du chargement des transactions'; this.error = 'Erreur lors du chargement des transactions';
this.loading = false; this.loading = false;
// Fallback sur les données mockées en développement
if (environment.production === false) {
this.transactions = this.transactionsService.getMockTransactions();
this.paginatedData = {
data: this.transactions,
total: this.transactions.length,
page: 1,
limit: 20,
totalPages: 1,
stats: {
total: this.transactions.length,
totalAmount: this.transactions.reduce((sum, tx) => sum + tx.amount, 0),
successCount: this.transactions.filter(tx => tx.status === 'SUCCESS').length,
failedCount: this.transactions.filter(tx => tx.status === 'FAILED').length,
pendingCount: this.transactions.filter(tx => tx.status === 'PENDING').length,
refundedCount: this.transactions.filter(tx => tx.status === 'REFUNDED').length,
successRate: 75,
averageAmount: 4.74
}
};
this.loading = false;
}
this.cdRef.detectChanges(); this.cdRef.detectChanges();
console.error('Error loading transactions:', error); console.error('Error loading transactions:', error);
} }
@ -156,14 +191,17 @@ export class TransactionsList implements OnInit {
page: 1, page: 1,
limit: 20, limit: 20,
status: undefined, status: undefined,
operator: '',
country: '',
startDate: undefined, startDate: undefined,
endDate: undefined, endDate: undefined,
msisdn: '',
sortBy: 'transactionDate', sortBy: 'transactionDate',
sortOrder: 'desc' sortOrder: 'desc'
}; };
// Réappliquer les restrictions de merchant
if (this.access.isMerchantUser && this.access.allowedMerchantIds.length > 0) {
this.filters.merchantPartnerId = this.access.allowedMerchantIds[0];
}
this.loadTransactions(); this.loadTransactions();
} }
@ -174,7 +212,6 @@ export class TransactionsList implements OnInit {
} }
onOperatorFilterChange(operator: string) { onOperatorFilterChange(operator: string) {
this.filters.operator = operator;
this.filters.page = 1; this.filters.page = 1;
this.loadTransactions(); this.loadTransactions();
} }
@ -186,6 +223,15 @@ export class TransactionsList implements OnInit {
this.loadTransactions(); this.loadTransactions();
} }
// Permissions pour les filtres
canUseMerchantFilter(): boolean {
return this.access.canFilterByMerchant && this.access.allowedMerchantIds.length > 1;
}
canUseAllFilters(): boolean {
return this.access.canViewAllTransactions;
}
// Tri // Tri
sort(field: string) { sort(field: string) {
if (this.sortField === field) { if (this.sortField === field) {
@ -210,21 +256,12 @@ export class TransactionsList implements OnInit {
// Actions // Actions
viewTransactionDetails(transactionId: string) { viewTransactionDetails(transactionId: string) {
this.transactionSelected.emit(transactionId); // Vérifier les permissions avant d'afficher
} this.accessService.canAccessTransaction().subscribe(canAccess => {
if (canAccess) {
refundTransaction(transactionId: string) { this.transactionSelected.emit(transactionId);
this.openRefundModal.emit(transactionId); } else {
} this.error = 'Vous n\'avez pas la permission de voir les détails de cette transaction';
retryTransaction(transactionId: string) {
this.transactionsService.retryTransaction(transactionId).subscribe({
next: () => {
this.loadTransactions();
},
error: (error) => {
console.error('Error retrying transaction:', error);
this.error = 'Erreur lors de la nouvelle tentative';
this.cdRef.detectChanges(); this.cdRef.detectChanges();
} }
}); });
@ -253,37 +290,12 @@ export class TransactionsList implements OnInit {
this.selectedTransactions.size === this.transactions.length; this.selectedTransactions.size === this.transactions.length;
} }
// Export // Utilitaires d'affichage MIS À JOUR
exportTransactions(format: 'csv' | 'excel' | 'pdf') {
const exportRequest = {
format: format,
query: this.filters,
columns: ['id', 'msisdn', 'operator', 'amount', 'status', 'transactionDate', 'productName']
};
this.transactionsService.exportTransactions(exportRequest).subscribe({
next: (response) => {
// Télécharger le fichier
const link = document.createElement('a');
link.href = response.url;
link.download = response.filename;
link.click();
},
error: (error) => {
console.error('Error exporting transactions:', error);
this.error = 'Erreur lors de l\'export';
this.cdRef.detectChanges();
}
});
}
// Utilitaires d'affichage
getStatusBadgeClass(status: TransactionStatus): string { getStatusBadgeClass(status: TransactionStatus): string {
switch (status) { switch (status) {
case 'SUCCESS': return 'badge bg-success'; case 'SUCCESS': return 'badge bg-success';
case 'PENDING': return 'badge bg-warning'; case 'PENDING': return 'badge bg-warning';
case 'FAILED': return 'badge bg-danger'; case 'FAILED': return 'badge bg-danger';
case 'REFUNDED': return 'badge bg-info';
case 'CANCELLED': return 'badge bg-secondary'; case 'CANCELLED': return 'badge bg-secondary';
case 'EXPIRED': return 'badge bg-dark'; case 'EXPIRED': return 'badge bg-dark';
default: return 'badge bg-secondary'; default: return 'badge bg-secondary';
@ -295,35 +307,145 @@ export class TransactionsList implements OnInit {
case 'SUCCESS': return 'lucideCheckCircle'; case 'SUCCESS': return 'lucideCheckCircle';
case 'PENDING': return 'lucideClock'; case 'PENDING': return 'lucideClock';
case 'FAILED': return 'lucideXCircle'; case 'FAILED': return 'lucideXCircle';
case 'REFUNDED': return 'lucideUndo2';
case 'CANCELLED': return 'lucideBan'; case 'CANCELLED': return 'lucideBan';
default: return 'lucideClock'; default: return 'lucideClock';
} }
} }
formatCurrency(amount: number, currency: string = 'EUR'): string { getTypeIcon(type: TransactionType): string {
return new Intl.NumberFormat('fr-FR', { switch (type) {
style: 'currency', case TransactionType.SUBSCRIPTION_PAYMENT: return 'lucideCreditCard';
currency: currency case TransactionType.SUBSCRIPTION_RENEWAL: return 'lucideRepeat';
}).format(amount); case TransactionType.ONE_TIME_PAYMENT: return 'lucideCreditCard';
default: return 'lucideCreditCard';
}
} }
formatDate(date: Date): string { getPeriodicityBadgeClass(periodicity?: string): string {
return new Intl.DateTimeFormat('fr-FR', { if (!periodicity) return 'badge bg-secondary';
day: '2-digit',
month: '2-digit', switch (periodicity.toLowerCase()) {
year: 'numeric', case 'daily':
hour: '2-digit', return 'badge bg-primary';
minute: '2-digit' case 'weekly':
}).format(new Date(date)); return 'badge bg-info';
case 'monthly':
return 'badge bg-success';
case 'yearly':
return 'badge bg-warning';
default:
return 'badge bg-secondary';
}
}
formatCurrency(amount: number, currency: Currency = Currency.XOF): string {
return TransactionUtils.formatAmount(amount, currency);
}
formatDate(date: Date | string | undefined | null): string {
// Si la date est null/undefined, retourner une chaîne vide
if (!date) {
return '-';
}
// Si c'est déjà une Date valide
if (date instanceof Date) {
// Vérifier si la Date est valide
if (isNaN(date.getTime())) {
return 'Date invalide';
}
return new Intl.DateTimeFormat('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(date);
}
// Si c'est une chaîne, essayer de la convertir
if (typeof date === 'string') {
const dateObj = new Date(date);
// Vérifier si la conversion a réussi
if (isNaN(dateObj.getTime())) {
// Essayer d'autres formats
const alternativeDate = this.parseDateString(date);
if (alternativeDate && !isNaN(alternativeDate.getTime())) {
return new Intl.DateTimeFormat('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(alternativeDate);
}
return 'Date invalide';
}
return new Intl.DateTimeFormat('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(dateObj);
}
// Pour tout autre type, retourner '-'
return '-';
}
private parseDateString(dateString: string): Date | null {
try {
// Essayer différents formats de date
const formats = [
dateString, // Format ISO original
dateString.replace(' ', 'T'), // Remplacer espace par T
dateString.split('.')[0], // Enlever les millisecondes
];
for (const format of formats) {
const date = new Date(format);
if (!isNaN(date.getTime())) {
return date;
}
}
return null;
} catch {
return null;
}
} }
getAmountColor(amount: number): string { getAmountColor(amount: number): string {
if (amount >= 10) return 'text-danger fw-bold'; if (amount >= 10000) return 'text-danger fw-bold';
if (amount >= 5) return 'text-warning fw-semibold'; if (amount >= 5000) return 'text-warning fw-semibold';
return 'text-success'; return 'text-success';
} }
// utilitaires pour les abonnements
getStatusDisplayName(status: TransactionStatus): string {
return TransactionUtils.getStatusDisplayName(status);
}
getTypeDisplayName(type: TransactionType): string {
return TransactionUtils.getTypeDisplayName(type);
}
getPeriodicityDisplayName(periodicity?: string): string {
if (!periodicity) return '';
const periodicityNames: Record<string, string> = {
'daily': 'Quotidien',
'weekly': 'Hebdomadaire',
'monthly': 'Mensuel',
'yearly': 'Annuel'
};
return periodicityNames[periodicity.toLowerCase()] || periodicity;
}
// Méthodes pour sécuriser l'accès aux stats // Méthodes pour sécuriser l'accès aux stats
getTotal(): number { getTotal(): number {
return this.paginatedData?.stats?.total || 0; return this.paginatedData?.stats?.total || 0;
@ -341,15 +463,27 @@ export class TransactionsList implements OnInit {
return this.paginatedData?.stats?.pendingCount || 0; return this.paginatedData?.stats?.pendingCount || 0;
} }
getSuccessRate(): number {
return this.paginatedData?.stats?.successRate || 0;
}
getTotalAmount(): number { getTotalAmount(): number {
return this.paginatedData?.stats?.totalAmount || 0; return this.paginatedData?.stats?.totalAmount || 0;
} }
getMinValue(a: number, b: number): number { // Méthodes pour le template
return Math.min(a, b); getUserBadgeClass(): string {
return this.access.isHubUser ? 'bg-primary' : 'bg-success';
}
getUserBadgeIcon(): string {
return this.access.isHubUser ? 'lucideShield' : 'lucideStore';
}
getUserBadgeText(): string {
return this.access.isHubUser ? 'Hub User' : 'Merchant User';
}
getScopeText(): string {
if (this.access.isMerchantUser && this.currentMerchantId) {
return `Merchant ${this.currentMerchantId}`;
}
return this.access.canManageAll ? 'Tous les merchants' : 'Merchants autorisés';
} }
} }

View File

@ -1,80 +0,0 @@
export interface Transaction {
id: string;
msisdn: string;
operator: string;
operatorId: string;
country: string;
amount: number;
currency: string;
status: TransactionStatus;
productId: string;
productName: string;
productCategory: string;
transactionDate: Date;
createdAt: Date;
updatedAt: Date;
externalId?: string;
merchantId?: string;
merchantName?: string;
errorCode?: string;
errorMessage?: string;
userAgent?: string;
ipAddress?: string;
customData?: { [key: string]: any };
}
export interface TransactionQuery {
page?: number;
limit?: number;
search?: string;
status?: TransactionStatus;
operator?: string;
country?: string;
startDate?: Date;
endDate?: Date;
msisdn?: string;
productId?: string;
merchantId?: string;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}
export interface TransactionStats {
total: number;
totalAmount: number;
successCount: number;
failedCount: number;
pendingCount: number;
refundedCount: number;
successRate: number;
averageAmount: number;
}
export interface PaginatedTransactions {
data: Transaction[];
total: number;
page: number;
limit: number;
totalPages: number;
stats: TransactionStats;
}
export type TransactionStatus =
| 'PENDING'
| 'SUCCESS'
| 'FAILED'
| 'REFUNDED'
| 'CANCELLED'
| 'EXPIRED';
export interface RefundRequest {
transactionId: string;
reason?: string;
amount?: number;
}
export interface TransactionExportRequest {
format: 'csv' | 'excel' | 'pdf';
query: TransactionQuery;
columns?: string[];
}

View File

@ -0,0 +1,207 @@
// [file name]: transactions/services/transaction-access.service.ts
import { Injectable, Injector, inject } from '@angular/core';
import { Observable, of } from 'rxjs';
import { RoleManagementService, UserRole } from '@core/services/hub-users-roles-management.service';
import { AuthService } from '@core/services/auth.service';
export interface TransactionAccess {
// Permissions de visualisation
canViewTransactions: boolean;
canViewAllTransactions: boolean; // Toutes vs seulement les siennes
canViewDetails: boolean;
// Permissions d'actions
canRefund: boolean;
canRetry: boolean;
canCancel: boolean;
canExport: boolean;
// Permissions administratives
canManageAll: boolean; // Toutes les transactions
canFilterByMerchant: boolean;
canViewSensitiveData: boolean; // IP, User Agent, etc.
// Scope
allowedMerchantIds: number[];
isHubUser: boolean;
isMerchantUser: boolean;
// Informations utilisateur
userRole: UserRole;
userRoleLabel: string;
merchantId?: number;
}
@Injectable({ providedIn: 'root' })
export class TransactionAccessService {
private accessCache: TransactionAccess | null = null;
private readonly injector = inject(Injector);
constructor(
private roleService: RoleManagementService
) {}
getTransactionAccess(): TransactionAccess {
if (this.accessCache) {
return this.accessCache;
}
const userRole = this.roleService.getCurrentRole() || UserRole.DCB_SUPPORT; // Valeur par défaut
const isHubUser = this.roleService.isHubUser();
const merchantId = this.getCurrentMerchantId();
const access: TransactionAccess = {
// Pour tous les utilisateurs (avec restrictions)
canViewTransactions: this.canViewTransactions(userRole, isHubUser),
canViewAllTransactions: this.canViewAllTransactions(userRole, isHubUser),
canViewDetails: this.canViewDetails(userRole, isHubUser),
// Actions selon le rôle
canRefund: this.canPerformRefund(userRole, isHubUser),
canRetry: this.canPerformRetry(userRole, isHubUser),
canCancel: this.canPerformCancel(userRole, isHubUser),
canExport: this.canExport(userRole, isHubUser),
// Permissions administratives
canManageAll: this.canManageAll(userRole, isHubUser),
canFilterByMerchant: this.canFilterByMerchant(userRole, isHubUser),
canViewSensitiveData: this.canViewSensitiveData(userRole, isHubUser),
// Scope
allowedMerchantIds: this.getAllowedMerchantIds(isHubUser, merchantId),
isHubUser,
isMerchantUser: !isHubUser,
// Informations utilisateur
userRole,
userRoleLabel: this.roleService.getRoleLabel() || 'Utilisateur',
merchantId
};
this.accessCache = access;
return access;
}
// === MÉTHODES DE DÉTERMINATION DES PERMISSIONS ===
private canViewTransactions(userRole: UserRole, isHubUser: boolean): boolean {
// Tous les rôles peuvent voir les transactions
return true;
}
private canViewAllTransactions(userRole: UserRole, isHubUser: boolean): boolean {
// Hub users et DCB_PARTNER_ADMIN peuvent voir toutes les transactions
return isHubUser || userRole === UserRole.DCB_PARTNER_ADMIN;
}
private canViewDetails(userRole: UserRole, isHubUser: boolean): boolean {
// Tous peuvent voir les détails (avec restrictions de scope)
return true;
}
private canPerformRefund(userRole: UserRole, isHubUser: boolean): boolean {
// DCB_ADMIN: peut rembourser toutes les transactions
// DCB_SUPPORT: peut rembourser les transactions de son périmètre
// DCB_PARTNER_ADMIN: peut rembourser les transactions de son merchant
return isHubUser
? userRole === UserRole.DCB_ADMIN || userRole === UserRole.DCB_SUPPORT
: userRole === UserRole.DCB_PARTNER_ADMIN;
}
private canPerformRetry(userRole: UserRole, isHubUser: boolean): boolean {
// Mêmes permissions que le remboursement
return this.canPerformRefund(userRole, isHubUser);
}
private canPerformCancel(userRole: UserRole, isHubUser: boolean): boolean {
// Plus restrictif - seulement les admins peuvent annuler
return isHubUser
? userRole === UserRole.DCB_ADMIN
: userRole === UserRole.DCB_PARTNER_ADMIN;
}
private canExport(userRole: UserRole, isHubUser: boolean): boolean {
// Tous peuvent exporter leurs propres données
return true;
}
private canManageAll(userRole: UserRole, isHubUser: boolean): boolean {
// Seulement DCB_ADMIN hub peut tout gérer
return isHubUser && userRole === UserRole.DCB_ADMIN;
}
private canFilterByMerchant(userRole: UserRole, isHubUser: boolean): boolean {
// Seuls les hub users peuvent filtrer par merchant
return isHubUser;
}
private canViewSensitiveData(userRole: UserRole, isHubUser: boolean): boolean {
// Hub users et DCB_PARTNER_ADMIN peuvent voir les données sensibles
return isHubUser || userRole === UserRole.DCB_PARTNER_ADMIN;
}
// === GESTION DU SCOPE ===
private getCurrentMerchantId(): number | undefined {
// Récupérer le merchant ID de l'utilisateur courant
const authService = this.injector.get(AuthService);
const merchantPartnerId = authService.getCurrentMerchantPartnerId();
if (!merchantPartnerId) return undefined;
const merchantId = parseInt(merchantPartnerId, 10);
return isNaN(merchantId) ? undefined : merchantId;
}
private getAllowedMerchantIds(isHubUser: boolean, merchantId?: number): number[] {
if (isHubUser) {
// Hub users peuvent voir tous les merchants
return []; // Tableau vide = tous les merchants
} else {
// Merchant users: seulement leur merchant
return merchantId ? [merchantId] : [];
}
}
// === MÉTHODES PUBLIQUES ===
// Vérifie si l'utilisateur peut accéder à une transaction spécifique
canAccessTransaction(transactionMerchantId?: number): Observable<boolean> {
const access = this.getTransactionAccess();
// Hub users avec permission de tout voir
if (access.canManageAll) {
return of(true);
}
// Si pas de merchant ID sur la transaction, accès limité
if (!transactionMerchantId) {
return of(access.isHubUser);
}
// Merchant users: seulement leur merchant
if (access.isMerchantUser) {
return of(access.allowedMerchantIds.includes(transactionMerchantId));
}
// Hub users: vérifier si le merchant est dans leur liste autorisée
if (access.allowedMerchantIds.length === 0) {
return of(true); // Tous les merchants autorisés
}
return of(access.allowedMerchantIds.includes(transactionMerchantId));
}
// Nettoyer le cache
clearCache(): void {
this.accessCache = null;
}
// Rafraîchir les permissions (après changement de rôle/merchant)
refreshAccess(): void {
this.clearCache();
}
}

View File

@ -1,177 +1,536 @@
// transactions.service.ts
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http'; import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, map, catchError, throwError, of, tap } from 'rxjs';
import { environment } from '@environments/environment'; import { environment } from '@environments/environment';
import { Observable, map, catchError, throwError } from 'rxjs';
import { import {
Transaction, Transaction,
TransactionQuery, TransactionQuery,
PaginatedTransactions, PaginatedTransactions,
TransactionStats, TransactionStats,
RefundRequest TransactionType,
} from '../models/transaction'; TransactionStatus
} from '@core/models/dcb-bo-hub-transaction.model';
import {
Currency
} from '@core/models/dcb-bo-hub-subscription.model';
interface ApiResponse {
data: any[];
meta?: {
total: number;
page: number;
limit: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
};
}
interface CacheEntry {
key: string;
data: any[];
timestamp: number;
query: TransactionQuery;
}
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class TransactionsService { export class TransactionsService {
private http = inject(HttpClient); private http = inject(HttpClient);
private apiUrl = `${environment.localServiceTestApiUrl}/transactions`;
// === CRUD OPERATIONS === private baseApiUrl = `${environment.apiCoreUrl}`;
private subscriptionsUrl = `${this.baseApiUrl}/subscriptions`;
private paymentsUrl = `${this.baseApiUrl}/payments`;
// Cache pour éviter de recharger toutes les données
private cache: CacheEntry | null = null;
private readonly CACHE_TTL = 30000; // 30 secondes
// === OPÉRATIONS PRINCIPALES ===
getTransactions(query: TransactionQuery): Observable<PaginatedTransactions> { getTransactions(query: TransactionQuery): Observable<PaginatedTransactions> {
let params = new HttpParams(); const cacheKey = this.generateCacheKey(query);
// Ajouter tous les paramètres de query const canUseCache = this.cache &&
Object.keys(query).forEach(key => { this.cache.key === cacheKey &&
const value = query[key as keyof TransactionQuery]; Date.now() - this.cache.timestamp < this.CACHE_TTL;
if (value !== undefined && value !== null) {
if (value instanceof Date) { if (query.merchantPartnerId) {
params = params.set(key, value.toISOString()); if (canUseCache) {
} else { console.log('Using cached data for merchant:', query.merchantPartnerId);
params = params.set(key, value.toString()); return of(this.createPaginatedResponseFromCache(query));
}
} }
}); return this.getSubscriptionsByMerchant(query).pipe(
tap((response: PaginatedTransactions & { rawApiData?: any[] }) => {
return this.http.get<PaginatedTransactions>(`${this.apiUrl}`, { params }).pipe( this.cache = {
catchError(error => { key: cacheKey,
console.error('Error loading transactions:', error); data: response.rawApiData || [],
return throwError(() => error); timestamp: Date.now(),
}) query: { ...query, page: 1, limit: 1000 }
); };
})
);
} else {
if (canUseCache) {
console.log('Using cached data for all subscriptions');
return of(this.createPaginatedResponseFromCache(query));
}
return this.getAllSubscriptions(query).pipe(
tap((response: PaginatedTransactions & { rawApiData?: any[] }) => {
// Stocker les données BRUTES de l'API
this.cache = {
key: cacheKey,
data: response.rawApiData || [],
timestamp: Date.now(),
query: { ...query, page: 1, limit: 1000 }
};
})
);
}
} }
getTransactionById(id: string): Observable<Transaction> { getTransactionById(id: string): Observable<Transaction> {
return this.http.get<Transaction>(`${this.apiUrl}/${id}`).pipe( const subscriptionId = parseInt(id, 10);
if (isNaN(subscriptionId)) {
return throwError(() => new Error(`ID de transaction invalide: ${id}`));
}
return this.http.get<any>(`${this.subscriptionsUrl}/${subscriptionId}`).pipe(
map(subscription => this.mapSubscriptionToTransaction(subscription)),
catchError(error => { catchError(error => {
console.error('Error loading transaction:', error); console.error(`Error loading transaction ${id}:`, error);
return throwError(() => error); return throwError(() => error);
}) })
); );
} }
// === ACTIONS === getTransactionPayments(merchantId: number, subscriptionId: number, page: number = 1, limit: number = 10): Observable<PaginatedTransactions> {
refundTransaction(refundRequest: RefundRequest): Observable<{ message: string; transaction: Transaction }> { let params = new HttpParams()
return this.http.post<{ message: string; transaction: Transaction }>( .set('page', page.toString())
`${this.apiUrl}/${refundRequest.transactionId}/refund`, .set('limit', limit.toString());
refundRequest
);
}
cancelTransaction(transactionId: string): Observable<{ message: string }> { return this.http.get<ApiResponse>(`${this.paymentsUrl}/merchant/${merchantId}/subscription/${subscriptionId}`, { params }).pipe(
return this.http.post<{ message: string }>( map(response => {
`${this.apiUrl}/${transactionId}/cancel`, const data = response?.data || [];
{} const meta = response?.meta || {
); total: data.length,
} page: page,
limit: limit,
totalPages: Math.ceil(data.length / limit),
hasNextPage: false,
hasPreviousPage: false
};
retryTransaction(transactionId: string): Observable<{ message: string; transaction: Transaction }> { const transactions = data.map((payment: any) => this.mapPaymentToTransaction(payment));
return this.http.post<{ message: string; transaction: Transaction }>( const stats = this.calculateStats(transactions);
`${this.apiUrl}/${transactionId}/retry`,
{} return {
data: transactions,
total: meta.total,
page: meta.page,
limit: meta.limit,
totalPages: meta.totalPages,
stats
};
}),
catchError(error => {
console.error(`Error loading payments for subscription ${subscriptionId}:`, error);
return of({
data: [],
total: 0,
page: 1,
limit: 10,
totalPages: 1,
stats: this.calculateStats([])
});
})
); );
} }
// === STATISTIQUES === // === STATISTIQUES ===
getTransactionStats(query?: Partial<TransactionQuery>): Observable<TransactionStats> { getTransactionStats(query?: Partial<TransactionQuery>): Observable<TransactionStats> {
let params = new HttpParams(); const transactionQuery: TransactionQuery = {
page: 1,
limit: 1000,
...query
};
if (query) { return this.getTransactions(transactionQuery).pipe(
Object.keys(query).forEach(key => { map(response => response.stats)
const value = query[key as keyof TransactionQuery];
if (value !== undefined && value !== null) {
if (value instanceof Date) {
params = params.set(key, value.toISOString());
} else {
params = params.set(key, value.toString());
}
}
});
}
return this.http.get<TransactionStats>(`${this.apiUrl}/stats`, { params });
}
// === EXPORT ===
exportTransactions(exportRequest: any): Observable<{ url: string; filename: string }> {
return this.http.post<{ url: string; filename: string }>(
`${this.apiUrl}/export`,
exportRequest
); );
} }
// === MOCK DATA POUR LE DÉVELOPPEMENT === // === MÉTHODES PRIVÉES ===
getMockTransactions(): Transaction[] {
return [ private getAllSubscriptions(query: TransactionQuery): Observable<PaginatedTransactions & { rawApiData?: any[] }> {
{ let params = new HttpParams();
id: 'tx_001',
msisdn: '+33612345678', // Paramètres supportés par l'API subscription
operator: 'Orange', if (query.status) {
operatorId: 'orange_fr', const subscriptionStatus = this.mapToSubscriptionStatus(query.status);
country: 'FR', if (subscriptionStatus) {
amount: 4.99, params = params.set('status', subscriptionStatus);
currency: 'EUR',
status: 'SUCCESS',
productId: 'prod_premium',
productName: 'Contenu Premium',
productCategory: 'ENTERTAINMENT',
transactionDate: new Date('2024-01-15T14:30:00'),
createdAt: new Date('2024-01-15T14:30:00'),
updatedAt: new Date('2024-01-15T14:30:00'),
externalId: 'ext_123456',
merchantName: 'MediaCorp'
},
{
id: 'tx_002',
msisdn: '+33798765432',
operator: 'Free',
operatorId: 'free_fr',
country: 'FR',
amount: 2.99,
currency: 'EUR',
status: 'PENDING',
productId: 'prod_basic',
productName: 'Abonnement Basique',
productCategory: 'SUBSCRIPTION',
transactionDate: new Date('2024-01-15T14:25:00'),
createdAt: new Date('2024-01-15T14:25:00'),
updatedAt: new Date('2024-01-15T14:25:00'),
externalId: 'ext_123457'
},
{
id: 'tx_003',
msisdn: '+33687654321',
operator: 'SFR',
operatorId: 'sfr_fr',
country: 'FR',
amount: 9.99,
currency: 'EUR',
status: 'FAILED',
productId: 'prod_pro',
productName: 'Pack Professionnel',
productCategory: 'BUSINESS',
transactionDate: new Date('2024-01-15T14:20:00'),
createdAt: new Date('2024-01-15T14:20:00'),
updatedAt: new Date('2024-01-15T14:20:00'),
errorCode: 'INSUFFICIENT_FUNDS',
errorMessage: 'Solde insuffisant'
},
{
id: 'tx_004',
msisdn: '+33611223344',
operator: 'Bouygues',
operatorId: 'bouygues_fr',
country: 'FR',
amount: 1.99,
currency: 'EUR',
status: 'REFUNDED',
productId: 'prod_mini',
productName: 'Pack Découverte',
productCategory: 'GAMING',
transactionDate: new Date('2024-01-15T14:15:00'),
createdAt: new Date('2024-01-15T14:15:00'),
updatedAt: new Date('2024-01-15T16:30:00'),
merchantName: 'GameStudio'
} }
]; }
if (query.periodicity) {
params = params.set('periodicity', query.periodicity);
}
if (query.startDate) {
params = params.set('startDate', query.startDate.toISOString());
}
if (query.endDate) {
params = params.set('endDate', query.endDate.toISOString());
}
return this.http.get<ApiResponse>(this.subscriptionsUrl, { params }).pipe(
map(apiResponse => this.createPaginatedResponse(apiResponse, query, true)),
catchError(error => {
console.error('Error loading all subscriptions:', error);
return throwError(() => error);
})
);
}
private getSubscriptionsByMerchant(query: TransactionQuery): Observable<PaginatedTransactions & { rawApiData?: any[] }> {
if (!query.merchantPartnerId) {
return throwError(() => new Error('Merchant ID is required'));
}
let params = new HttpParams();
// Paramètres supportés par l'API
if (query.status) {
const subscriptionStatus = this.mapToSubscriptionStatus(query.status);
if (subscriptionStatus) {
params = params.set('status', subscriptionStatus);
}
}
if (query.periodicity) {
params = params.set('periodicity', query.periodicity);
}
if (query.startDate) {
params = params.set('startDate', query.startDate.toISOString());
}
if (query.endDate) {
params = params.set('endDate', query.endDate.toISOString());
}
return this.http.get<ApiResponse>(`${this.subscriptionsUrl}/merchant/${query.merchantPartnerId}`, { params }).pipe(
map(apiResponse => this.createPaginatedResponse(apiResponse, query, true)),
catchError(error => {
console.error(`Error loading subscriptions for merchant ${query.merchantPartnerId}:`, error);
return throwError(() => error);
})
);
}
private createPaginatedResponse(
apiResponse: ApiResponse,
query: TransactionQuery,
clientSidePagination: boolean = false
): PaginatedTransactions & { rawApiData?: any[] } { // <-- Ajout du type de retour
const rawData = apiResponse?.data || [];
// Convertir toutes les données en transactions
const allTransactions = rawData.map((item: any) => this.mapSubscriptionToTransaction(item));
// Appliquer la recherche locale si fournie
let filteredTransactions = [...allTransactions];
if (query.search) {
const searchLower = query.search.toLowerCase();
filteredTransactions = filteredTransactions.filter(tx =>
tx.id.toString().toLowerCase().includes(searchLower) ||
(tx.externalReference && tx.externalReference.toLowerCase().includes(searchLower)) ||
tx.productName.toLowerCase().includes(searchLower)
);
}
// Appliquer le tri côté client
if (query.sortBy) {
this.sortTransactions(filteredTransactions, query.sortBy, query.sortOrder);
}
let data: Transaction[];
let total: number;
let page: number;
let limit: number;
let totalPages: number;
if (clientSidePagination) {
// Pagination côté client
page = query.page || 1;
limit = query.limit || 20;
total = filteredTransactions.length;
totalPages = Math.ceil(total / limit);
const startIndex = (page - 1) * limit;
data = filteredTransactions.slice(startIndex, startIndex + limit);
} else {
// Pagination côté serveur (si meta existe)
const meta = apiResponse?.meta || {
total: filteredTransactions.length,
page: query.page || 1,
limit: query.limit || 20,
totalPages: 1,
hasNextPage: false,
hasPreviousPage: false
};
data = filteredTransactions;
total = meta.total;
page = meta.page;
limit = meta.limit;
totalPages = meta.totalPages;
}
const stats = this.calculateStats(filteredTransactions);
return {
data,
total,
page,
limit,
totalPages,
stats,
rawApiData: rawData
};
}
private createPaginatedResponseFromCache(query: TransactionQuery): PaginatedTransactions {
if (!this.cache) {
throw new Error('No cache available');
}
const rawData = this.cache.data || [];
// Convertir les données brutes en transactions
const allTransactions = rawData.map((item: any) => this.mapSubscriptionToTransaction(item));
// Appliquer la recherche
let filteredTransactions = [...allTransactions];
if (query.search) {
const searchLower = query.search.toLowerCase();
filteredTransactions = filteredTransactions.filter(tx =>
tx.id.toString().toLowerCase().includes(searchLower) ||
(tx.externalReference && tx.externalReference.toLowerCase().includes(searchLower))
);
}
// Appliquer le tri
if (query.sortBy) {
this.sortTransactions(filteredTransactions, query.sortBy, query.sortOrder);
}
// Pagination
const page = query.page || 1;
const limit = query.limit || 20;
const total = filteredTransactions.length;
const totalPages = Math.ceil(total / limit);
const startIndex = (page - 1) * limit;
const data = filteredTransactions.slice(startIndex, startIndex + limit);
// Calculer les stats
const stats = this.calculateStats(filteredTransactions);
return {
data,
total,
page,
limit,
totalPages,
stats
};
}
private generateCacheKey(query: TransactionQuery): string {
return [
query.merchantPartnerId || 'all',
query.status || 'all',
query.periodicity || 'all',
query.startDate?.toISOString().split('T')[0] || 'all',
query.endDate?.toISOString().split('T')[0] || 'all',
query.search || 'all'
].join('|');
}
private mapSubscriptionToTransaction(subscription: any): Transaction {
return {
// Identifiants
id: subscription.id.toString(),
externalReference: subscription.externalReference || undefined,
subscriptionId: subscription.id,
// Informations financières
amount: subscription.amount,
currency: subscription.currency as Currency,
// Statut et type
status: this.mapToTransactionStatus(subscription.status),
type: TransactionType.SUBSCRIPTION_PAYMENT,
// Informations produit/abonnement
productId: subscription.planId?.toString() || '',
productName: `Abonnement ${subscription.periodicity}`,
periodicity: subscription.periodicity,
// Dates
transactionDate: new Date(subscription.startDate),
createdAt: new Date(subscription.createdAt),
updatedAt: new Date(subscription.updatedAt),
nextPaymentDate: subscription.nextPaymentDate ? new Date(subscription.nextPaymentDate) : undefined,
// Informations marchand
merchantPartnerId: subscription.merchantPartnerId,
// Métadonnées
metadata: subscription.metadata || {}
};
}
private mapPaymentToTransaction(payment: any): Transaction {
return {
id: payment.id.toString(),
externalReference: payment.externalReference || undefined,
amount: payment.amount,
currency: payment.currency as Currency,
status: payment.status as TransactionStatus,
type: TransactionType.SUBSCRIPTION_PAYMENT,
transactionDate: new Date(payment.createdAt),
createdAt: new Date(payment.createdAt),
updatedAt: new Date(payment.updatedAt),
merchantPartnerId: payment.merchantPartnerId,
subscriptionId: payment.subscriptionId,
productId: payment.subscriptionId?.toString() || '',
productName: 'Paiement abonnement',
metadata: payment.metadata || {}
};
}
private mapToTransactionStatus(subscriptionStatus: string): TransactionStatus {
const statusMap: Record<string, TransactionStatus> = {
'ACTIVE': 'SUCCESS',
'PENDING': 'PENDING',
'SUSPENDED': 'FAILED',
'CANCELLED': 'CANCELLED',
'EXPIRED': 'EXPIRED'
};
return statusMap[subscriptionStatus] || 'PENDING';
}
private mapToSubscriptionStatus(transactionStatus: TransactionStatus): string | null {
const statusMap: Record<TransactionStatus, string> = {
'SUCCESS': 'ACTIVE',
'PENDING': 'PENDING',
'FAILED': 'SUSPENDED',
'CANCELLED': 'CANCELLED',
'EXPIRED': 'EXPIRED'
};
return statusMap[transactionStatus] || null;
}
private sortTransactions(transactions: Transaction[], field: string, order: 'asc' | 'desc' = 'desc'): void {
transactions.sort((a, b) => {
let aValue: any;
let bValue: any;
switch (field) {
case 'id':
aValue = parseInt(a.id);
bValue = parseInt(b.id);
break;
case 'amount':
aValue = a.amount;
bValue = b.amount;
break;
case 'transactionDate':
aValue = a.transactionDate?.getTime();
bValue = b.transactionDate?.getTime();
break;
case 'createdAt':
aValue = a.createdAt?.getTime();
bValue = b.createdAt?.getTime();
break;
case 'status':
aValue = a.status;
bValue = b.status;
break;
default:
aValue = a[field as keyof Transaction];
bValue = b[field as keyof Transaction];
}
// Gérer les valeurs undefined/null
if (aValue === undefined || aValue === null) aValue = order === 'asc' ? Infinity : -Infinity;
if (bValue === undefined || bValue === null) bValue = order === 'asc' ? Infinity : -Infinity;
// Comparer
if (aValue < bValue) return order === 'asc' ? -1 : 1;
if (aValue > bValue) return order === 'asc' ? 1 : -1;
return 0;
});
}
private calculateStats(transactions: Transaction[]): TransactionStats {
const total = transactions.length;
const totalAmount = transactions.reduce((sum, tx) => sum + tx.amount, 0);
const successCount = transactions.filter(tx => tx.status === 'SUCCESS').length;
const failedCount = transactions.filter(tx => tx.status === 'FAILED').length;
const pendingCount = transactions.filter(tx => tx.status === 'PENDING').length;
const cancelledCount = transactions.filter(tx => tx.status === 'CANCELLED').length;
const byPeriodicity = {
DAILY: transactions.filter(tx => tx.periodicity === 'Daily').length,
WEEKLY: transactions.filter(tx => tx.periodicity === 'Weekly').length,
MONTHLY: transactions.filter(tx => tx.periodicity === 'Monthly').length,
YEARLY: transactions.filter(tx => tx.periodicity === 'Yearly').length
};
return {
total,
totalAmount,
successCount,
failedCount,
pendingCount,
averageAmount: total > 0 ? totalAmount / total : 0,
byPeriodicity
};
}
// === MÉTHODES PUBLIQUES UTILITAIRES ===
clearCache(): void {
this.cache = null;
}
refreshTransactions(query: TransactionQuery): Observable<PaginatedTransactions> {
this.clearCache();
return this.getTransactions(query);
}
// === MÉTHODES NON IMPLÉMENTÉES (APIs non disponibles) ===
refundTransaction(): Observable<{ message: string; transaction: Transaction }> {
return throwError(() => new Error('Remboursement non implémenté - API non disponible'));
}
cancelTransaction(): Observable<{ message: string }> {
return throwError(() => new Error('Annulation non implémentée - API non disponible'));
}
retryTransaction(): Observable<{ message: string; transaction: Transaction }> {
return throwError(() => new Error('Nouvelle tentative non implémentée - API non disponible'));
}
exportTransactions(): Observable<{ url: string; filename: string }> {
return throwError(() => new Error('Export non implémenté - API non disponible'));
} }
} }

View File

@ -1,10 +1,11 @@
import { Component, inject, TemplateRef, ViewChild } from '@angular/core'; import { Component, inject, TemplateRef, ViewChild, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { NgbModal, NgbModalModule } from '@ng-bootstrap/ng-bootstrap'; import { NgbModal, NgbModalModule } from '@ng-bootstrap/ng-bootstrap';
import { PageTitle } from '@app/components/page-title/page-title'; import { PageTitle } from '@app/components/page-title/page-title';
import { TransactionsList } from './list/list'; import { TransactionsList } from './list/list';
import { TransactionDetails } from './details/details'; import { TransactionDetails } from './details/details';
import { NgIcon } from '@ng-icons/core'; import { NgIcon } from '@ng-icons/core';
import { TransactionAccessService } from './services/transaction-access.service';
@Component({ @Component({
selector: 'app-transactions', selector: 'app-transactions',
@ -19,12 +20,27 @@ import { NgIcon } from '@ng-icons/core';
], ],
templateUrl: './transactions.html', templateUrl: './transactions.html',
}) })
export class Transactions { export class Transactions implements OnInit {
private modalService = inject(NgbModal); private modalService = inject(NgbModal);
private accessService = inject(TransactionAccessService);
activeView: 'list' | 'details' = 'list'; activeView: 'list' | 'details' = 'list';
selectedTransactionId: string | null = null; selectedTransactionId: string | null = null;
// Permissions
canAccessModule = true; // Par défaut true, pourrait être configuré par rôle
accessDenied = false;
ngOnInit() {
this.checkAccess();
}
private checkAccess() {
const access = this.accessService.getTransactionAccess();
this.canAccessModule = access.canViewTransactions;
this.accessDenied = !access.canViewTransactions;
}
showListView() { showListView() {
this.activeView = 'list'; this.activeView = 'list';
this.selectedTransactionId = null; this.selectedTransactionId = null;