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-16 22:06:22 +00:00
parent 25d99b4edc
commit ab49f56ea7
42 changed files with 3653 additions and 3697 deletions

View File

@ -22,7 +22,7 @@ export enum Currency {
// === MODÈLE SUBSCRIPTION PRINCIPAL ===
export interface Subscription {
id: number;
id: string;
externalReference?: string | null;
periodicity: SubscriptionPeriodicity;
startDate: string;

View File

@ -152,14 +152,27 @@ export interface RoleOperationResponse {
// === SEARCH ===
export interface SearchUsersParams {
query?: string;
role?: UserRole;
role?: string;
enabled?: boolean;
userType?: UserType;
merchantPartnerId?: string;
searchTerm?: string;
status?: 'all' | 'enabled' | 'disabled';
emailVerified?: 'all' | 'verified' | 'not-verified';
sortField?: keyof User;
sortDirection?: 'asc' | 'desc';
page?: number;
limit?: number;
}
export interface UserStats {
total: number;
enabled: number;
disabled: number;
emailVerified: number;
roleCounts: Record<string, number>;
}
// === UTILITAIRES ===
export class UserUtils {
static isHubUser(user: User): boolean {

View File

@ -111,6 +111,7 @@ export interface ApiMerchantUser {
email?: string;
firstName?: string;
lastName?: string;
merchantPartnerId?: number;
createdAt?: string;
updatedAt?: string;
}
@ -129,6 +130,10 @@ export interface ApiMerchant {
updatedAt?: string;
}
export interface MerchantUserWithMerchant extends MerchantUser {
merchant: Merchant;
}
// === DTOs CRUD ===
export interface CreateMerchantDto {
name: string;

View File

@ -1,18 +1,14 @@
import { Injectable, inject } from '@angular/core';
import { Injectable, inject, EventEmitter } from '@angular/core';
import { HttpClient, HttpErrorResponse, HttpHeaders } from '@angular/common/http';
import { Router } from '@angular/router';
import { environment } from '@environments/environment';
import { BehaviorSubject, Observable, throwError, tap, catchError, finalize, of } from 'rxjs';
import { BehaviorSubject, Observable, throwError, tap, catchError, finalize, of, filter, take } from 'rxjs';
import { firstValueFrom } from 'rxjs';
import { DashboardAccessService } from '@modules/dcb-dashboard/services/dashboard-access.service';
import {
User,
UserType,
UserRole,
} from '@core/models/dcb-bo-hub-user.model';
import { TransactionAccessService } from '@modules/transactions/services/transaction-access.service';
// === INTERFACES DTO AUTH ===
export interface LoginDto {
@ -66,18 +62,32 @@ export class AuthService {
private userProfile$ = new BehaviorSubject<User | null>(null);
private initialized$ = new BehaviorSubject<boolean>(false);
private readonly transactionAccessService = inject(TransactionAccessService);
// Observable pour les changements d'état d'authentification
private authStateChanged$ = new BehaviorSubject<boolean>(this.isAuthenticated());
private profileLoaded$ = new BehaviorSubject<boolean>(false);
// Événement émis lors de la déconnexion
private logoutEvent = new EventEmitter<void>();
// Observable pour les autres services
onLogout$ = this.logoutEvent.asObservable();
// === INITIALISATION DE L'APPLICATION ===
/**
* Initialise l'authentification au démarrage de l'application
*/
/**
* Initialise l'authentification et charge le profil
*/
async initialize(): Promise<boolean> {
const token = this.getAccessToken();
// Pas de token → pas authentifié
if (!token) {
this.profileLoaded$.next(true);
this.initialized$.next(true);
return false;
}
@ -85,6 +95,7 @@ export class AuthService {
// Token expiré → tenter refresh
if (this.isTokenExpired(token)) {
const ok = await this.tryRefreshToken();
this.profileLoaded$.next(true);
this.initialized$.next(true);
return ok;
}
@ -93,15 +104,50 @@ export class AuthService {
try {
await firstValueFrom(this.loadUserProfile());
this.authState$.next(true);
this.profileLoaded$.next(true);
this.initialized$.next(true);
return true;
} catch {
this.clearAuthData();
this.profileLoaded$.next(true);
this.initialized$.next(true);
return false;
}
}
/**
* Attendre que le profil soit chargé
*/
waitForProfile(): Observable<boolean> {
return this.profileLoaded$.pipe(
filter(loaded => loaded),
take(1)
);
}
/**
* Vérifier si le profil est chargé
*/
isProfileLoaded(): boolean {
return this.profileLoaded$.value;
}
/**
* Forcer le chargement du profil
*/
loadProfileIfNeeded(): Observable<User> {
if (this.userProfile$.value) {
return of(this.userProfile$.value);
}
return this.loadUserProfile();
}
// Émettre les changements d'état
getAuthStateChanged(): Observable<boolean> {
return this.authStateChanged$.asObservable();
}
/**
* Tente de rafraîchir le token de manière synchrone
*/
@ -171,21 +217,16 @@ export class AuthService {
}
/**
* Déconnexion utilisateur
*/
/**
* Déconnexion utilisateur avec nettoyage complet
*/
logout(): Observable<LogoutResponseDto> {
const token = this.getAccessToken();
// Si pas de token, nettoyer et retourner un observable complet
if (!token) {
this.clearAuthData();
this.performLogoutCleanup(); // Nettoyage local
return of({ message: 'Already logged out' });
}
// Ajouter le token dans le header si nécessaire
const headers = new HttpHeaders({
'Authorization': `Bearer ${token}`
});
@ -196,48 +237,44 @@ export class AuthService {
{ headers }
).pipe(
tap(() => {
this.clearAuthData();
this.transactionAccessService.clearCache();
this.clearAllStorage(); // Nettoyer tout le storage
this.performLogoutCleanup();
}),
catchError(error => {
// Même en cas d'erreur, nettoyer tout
this.clearAuthData();
this.transactionAccessService.clearCache();
this.clearAllStorage();
this.performLogoutCleanup();
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.transactionAccessService.clearCache();
this.performLogoutCleanup();
})
);
}
/**
* Déconnexion forcée sans appel API
*/
forceLogout(): void {
this.clearAuthData();
private performLogoutCleanup(): void {
// 1. Nettoyer les données d'authentification
localStorage.removeItem(this.tokenKey);
localStorage.removeItem(this.refreshTokenKey);
localStorage.removeItem('user_profile');
sessionStorage.clear();
this.transactionAccessService.clearCache();
this.clearAllStorage();
// 2. Réinitialiser les BehaviorSubjects
this.authState$.next(false);
this.userProfile$.next(null);
// 3. Émettre l'événement de déconnexion
this.logoutEvent.emit();
}
forceLogout(): void {
this.performLogoutCleanup();
}
/**
* Nettoyer toutes les données d'authentification
*/
private clearAuthData(): void {
this.transactionAccessService.clearCache();
// Supprimer tous les tokens et données utilisateur
localStorage.removeItem(this.tokenKey);
localStorage.removeItem(this.refreshTokenKey);
@ -250,6 +287,7 @@ export class AuthService {
// Réinitialiser les BehaviorSubjects
this.authState$.next(false);
this.userProfile$.next(null);
this.authStateChanged$.next(false);
}
/**
@ -307,18 +345,28 @@ export class AuthService {
*/
private determineUserType(apiUser: any): UserType {
const hubRoles = [UserRole.DCB_ADMIN || UserRole.DCB_SUPPORT];
const merchantRoles = [UserRole.DCB_PARTNER_ADMIN || UserRole.DCB_PARTNER_MANAGER || UserRole.DCB_PARTNER_SUPPORT];
const hubRoles = [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT];
const merchantRoles = [UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT];
// Logique pour déterminer le type d'utilisateur
if (apiUser.clientRoles?.[0].includes(merchantRoles)) {
return UserType.MERCHANT_PARTNER;
} else if (apiUser.clientRoles?.[0].includes(hubRoles)) {
return UserType.HUB;
} else {
console.warn('Type d\'utilisateur non reconnu, rôle:', apiUser.clientRoles?.[0]);
return UserType.HUB; // Fallback
// Récupérer le rôle depuis l'API
const clientRole = apiUser.clientRoles?.[0];
if (!clientRole) {
console.warn('Aucun rôle trouvé dans le profil');
throw new Error(`Type d'utilisateur non reconnu: ${clientRole}`);
}
// Vérifier si c'est un rôle marchand
if (merchantRoles.some(role => clientRole.includes(role))) {
return UserType.MERCHANT_PARTNER;
}
// Vérifier si c'est un rôle hub
if (hubRoles.some(role => clientRole.includes(role))) {
return UserType.HUB;
}
throw new Error(`Type d'utilisateur non reconnu: ${clientRole}`);
}
private mapToUserModel(apiUser: any, userType: UserType): User {
@ -374,6 +422,9 @@ export class AuthService {
return this.userProfile$.asObservable();
}
/**
* Récupérer le profil utilisateur (synchrone, après chargement)
*/
getCurrentUserProfile(): User | null {
return this.userProfile$.value;
}
@ -557,6 +608,8 @@ export class AuthService {
return null;
}
console.log("Merchant Partner ID : " + profile.merchantPartnerId)
const merchantId = profile.merchantPartnerId
if (merchantId === null || merchantId === undefined) {

View File

@ -20,6 +20,88 @@ export enum UserRole {
type RoleCategory = 'hub' | 'partner' | 'config';
interface RoleConfig {
label: string;
description: string;
badgeClass: string;
icon: string;
}
// 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'
},
[UserRole.DCB_SUPPORT]: {
label: 'Support DCB',
description: 'Support technique avec accès étendus',
badgeClass: 'bg-info',
icon: 'lucideHeadphones'
},
[UserRole.DCB_PARTNER_ADMIN]: {
label: 'Admin Partenaire',
description: 'Administrateur de partenaire marchand',
badgeClass: 'bg-warning',
icon: 'lucideShieldCheck'
},
[UserRole.DCB_PARTNER_MANAGER]: {
label: 'Manager Partenaire',
description: 'Manager opérationnel partenaire',
badgeClass: 'bg-success',
icon: 'lucideUserCog'
},
[UserRole.DCB_PARTNER_SUPPORT]: {
label: 'Support Partenaire',
description: 'Support technique partenaire',
badgeClass: 'bg-secondary',
icon: 'lucideHeadphones'
},
[UserRole.MERCHANT_CONFIG_ADMIN]: {
label: 'Admin Marchand',
description: 'Administrateur de configuration marchand',
badgeClass: 'bg-warning',
icon: 'lucideSettings'
},
[UserRole.MERCHANT_CONFIG_MANAGER]: {
label: 'Manager Marchand',
description: 'Manager de configuration marchand',
badgeClass: 'bg-success',
icon: 'lucideUserCog'
},
[UserRole.MERCHANT_CONFIG_TECHNICAL]: {
label: 'Technique Marchand',
description: 'Support technique configuration marchand',
badgeClass: 'bg-secondary',
icon: 'lucideWrench'
},
[UserRole.MERCHANT_CONFIG_VIEWER]: {
label: 'Visualiseur Marchand',
description: 'Visualiseur de configuration marchand',
badgeClass: 'bg-light',
icon: 'lucideEye'
}
} 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 currentRole: UserRole | null = null;
@ -198,14 +280,27 @@ export class RoleManagementService {
// === UTILITAIRES ===
getRoleLabel(role?: UserRole): string {
const targetRole = role || this.currentRole;
return targetRole ? this.roleLabels[targetRole] || targetRole : '';
/**
* Méthodes d'utilité pour les rôles
*/
getRoleLabel(role: string): string {
const userRole = role as UserRole;
return ROLE_CONFIG[userRole]?.label || role;
}
getRoleIcon(role?: UserRole): string {
const targetRole = role || this.currentRole;
return targetRole ? this.roleIcons[targetRole] || 'user' : 'user';
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';
}
getAllRoles(): UserRole[] {

View File

@ -77,33 +77,9 @@ export class MenuService {
icon: 'lucideCreditCard',
url: '/transactions',
},
{
label: 'Opérateurs',
icon: 'lucideServer',
isCollapsed: true,
children: [
{ label: 'Paramètres d\'Intégration', url: '/operators/config' },
{ label: 'Performance & Monitoring', url: '/operators/stats' },
],
},
{
label: 'Webhooks',
icon: 'lucideShare',
isCollapsed: true,
children: [
{ label: 'Historique', url: '/webhooks/history' },
{ label: 'Statut des Requêtes', url: '/webhooks/status' },
{ label: 'Relancer Webhook', url: '/webhooks/retry' },
],
},
{ label: 'Abonnements', isTitle: true },
{ label: 'Gestion des Abonnements', icon: 'lucideRepeat', url: '/subscriptions' },
{ label: 'Abonnements par Merchant', icon: 'lucideStore', url: '/subscriptions/merchant' },
{ label: 'Paiements', isTitle: true },
{ label: 'Historique des Paiements', icon: 'lucideCreditCard', url: '/subscriptions/payments' },
{ label: 'Utilisateurs & Sécurité', isTitle: true },
{
@ -119,8 +95,6 @@ export class MenuService {
{ label: 'Configurations', isTitle: true },
{ label: 'Merchant Config', icon: 'lucideStore', url: '/merchant-config' },
{ label: 'Paramètres Système', icon: 'lucideSettings', url: '/settings' },
{ label: 'Intégrations Externes', icon: 'lucidePlug', url: '/integrations' },
{ label: 'Support & Profil', isTitle: true },
{ label: 'Support', icon: 'lucideLifeBuoy', url: '/support' },

View File

@ -33,25 +33,7 @@ export class PermissionsService {
module: 'merchant-users-management',
roles: this.allRoles,
},
// Operators - Admin seulement
{
module: 'operators',
roles: [UserRole.DCB_ADMIN],
children: {
'config': [UserRole.DCB_ADMIN],
'stats': [UserRole.DCB_ADMIN]
}
},
// Webhooks - Admin et Partner
{
module: 'webhooks',
roles: [UserRole.DCB_ADMIN, UserRole.DCB_PARTNER_ADMIN],
children: {
'history': [UserRole.DCB_ADMIN, UserRole.DCB_PARTNER_ADMIN],
'status': [UserRole.DCB_ADMIN, UserRole.DCB_PARTNER_ADMIN],
'retry': [UserRole.DCB_ADMIN]
}
},
// Settings - Tout le monde
{
module: 'settings',
@ -63,16 +45,6 @@ export class PermissionsService {
module: 'subscriptions',
roles: this.allRoles
},
{
module: 'subscriptions-merchant',
roles: this.allRoles
},
// Payments
{
module: 'subscriptions-payments',
roles: this.allRoles
},
// Settings - Tout le monde
{
@ -80,11 +52,6 @@ export class PermissionsService {
roles: this.allRoles
},
// Integrations - Admin seulement
{
module: 'integrations',
roles: [UserRole.DCB_ADMIN]
},
// Modules publics - Tout le monde
{
module: 'support',

View File

@ -59,49 +59,11 @@ export const menuItems: MenuItemType[] = [
icon: 'lucideCreditCard',
url: '/transactions',
},
{
label: 'Opérateurs',
icon: 'lucideServer',
isCollapsed: true,
children: [
{ label: 'Paramètres dIntégration', url: '/operators/config' },
{ label: 'Performance & Monitoring', url: '/operators/stats' },
],
},
// ---------------------------
// Notifications & Communication
// ---------------------------
{ label: 'Communication', isTitle: true },
{
label: 'Notifications',
icon: 'lucideBell',
isCollapsed: true,
children: [
{ label: 'Liste des Notifications', url: '/notifications/list' },
{ label: 'Filtrage par Type', url: '/notifications/filters' },
{ label: 'Actions Automatiques', url: '/notifications/actions' },
],
},
{
label: 'Webhooks',
icon: 'lucideShare',
isCollapsed: true,
children: [
{ label: 'Historique', url: '/webhooks/history' },
{ label: 'Statut des Requêtes', url: '/webhooks/status' },
{ label: 'Relancer Webhook', url: '/webhooks/retry' },
],
},
{ label: 'Abonnements', isTitle: true },
{ label: 'Gestion des Abonnements', icon: 'lucideRepeat', url: '/subscriptions' },
{ label: 'Abonnements par Merchant', icon: 'lucideStore', url: '/subscriptions/merchant' },
{ label: 'Paiements', isTitle: true },
{ label: 'Historique des Paiements', icon: 'lucideCreditCard', url: '/subscriptions/payments' },
// ---------------------------
// Utilisateurs & Sécurité
// ---------------------------
@ -128,7 +90,6 @@ export const menuItems: MenuItemType[] = [
{ label: 'Configurations', isTitle: true },
{ label: 'Merchant Config', icon: 'lucideStore', url: '/merchant-config' },
{ label: 'Paramètres Système', icon: 'lucideSettings', url: '/settings' },
{ label: 'Intégrations Externes', icon: 'lucidePlug', url: '/integrations' },
// ---------------------------
// Support & Profil

View File

@ -7,6 +7,14 @@
<div class="row g-4 mb-4">
<div class="col-12">
<div *ngIf="!dashboardInitialized" class="dashboard-loading">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
<p>Initialisation du dashboard...</p>
</div>
<ng-container *ngIf="dashboardInitialized">
<div class="container-fluid dashboard-container">
<!-- Header avec navigation -->
<div class="dashboard-header">
@ -680,7 +688,7 @@
<ng-icon name="lucideInfo" class="me-1"></ng-icon>
{{ getCurrentTransactionData()?.items?.length || 0 }} périodes au total
</small>
<a href="#" class="btn btn-sm btn-outline-primary">Voir tout</a>
<a [routerLink]="'/transactions'" class="btn btn-sm btn-outline-primary">Voir tout</a>
</div>
</div>
</div>
@ -780,6 +788,7 @@
</div>
</div>
</div>
</ng-container>
</div>
</div>
</div>

View File

@ -39,6 +39,7 @@ import { ReportService } from './services/dcb-reporting.service';
import { DashboardAccess, AllowedMerchant, DashboardAccessService } from './services/dashboard-access.service';
import { AuthService } from '@core/services/auth.service';
import { PageTitle } from '@app/components/page-title/page-title';
import { RouterModule } from '@angular/router';
// ============ TYPES ET INTERFACES ============
@ -138,7 +139,9 @@ interface SubscriptionStats {
FormsModule,
NgIconComponent,
NgbDropdownModule,
PageTitle],
PageTitle,
RouterModule
],
providers: [
provideIcons({
lucideActivity, lucideAlertCircle, lucideCheckCircle2, lucideRefreshCw,
@ -309,103 +312,100 @@ export class DcbReportingDashboard implements OnInit, OnDestroy, AfterViewInit {
Chart.register(...registerables);
}
ngOnInit(): void {
// 1. Initialiser l'accès
this.initializeAccess();
// 2. Initialiser le dashboard (avec délai pour laisser le temps à l'accès)
setTimeout(() => {
this.initializeDashboard();
}, 100);
// 3. Charger les merchants (avec délai)
setTimeout(() => {
this.loadAllowedMerchants();
}, 150);
if (this.accessService.shouldShowSystemHealth()) {
setInterval(() => {
this.checkSystemHealth();
}, 5 * 60 * 1000);
}
}
dashboardInitialized = false;
// ============ INITIALISATION ============
private initializeAccess(): void {
// Attendre que l'accès soit prêt
this.subscriptions.push(
this.accessService.waitForAccess().subscribe(() => {
this.access = this.accessService.getDashboardAccess();
this.currentRoleLabel = this.access.roleLabel;
this.currentRoleIcon = this.access.roleIcon;
ngOnInit(): void {
console.log('🔍 Dashboard: ngOnInit() appelé');
console.log('✅ Dashboard initialisé avec:', {
access: this.access,
merchantId: this.access.merchantId,
// Attendre que le DashboardAccessService soit VRAIMENT prêt
this.subscriptions.push(
this.accessService.waitForReady().subscribe({
next: () => {
console.log('✅ Dashboard: waitForReady() a émis - Initialisation...');
this.dashboardInitialized = true;
this.initializeDashboard();
},
error: (err) => {
console.error('❌ Dashboard: Erreur dans waitForReady():', err);
// Gérer l'erreur - peut-être rediriger vers une page d'erreur
this.addAlert('danger', 'Erreur d\'initialisation',
'Impossible de charger les informations d\'accès', 'Maintenant');
}
})
);
}
private initializeDashboard(): void {
console.log('🚀 Dashboard: initializeDashboard() appelé');
try {
// 1. Récupérer l'accès
this.access = this.accessService.getDashboardAccess();
console.log('✅ Dashboard: Accès déterminé avec succès:', {
isHubUser: this.access.isHubUser,
merchantId: this.access.merchantId,
role: this.access.userRole,
profile: this.authService.getCurrentUserProfile()
});
// Pour les merchant users
// 2. Configurer les paramètres
if (this.access.isMerchantUser) {
const merchantId = this.access.merchantId;
if (merchantId && merchantId > 0) {
this.merchantId = merchantId;
this.accessService.setSelectedMerchantId(merchantId);
if (this.access.merchantId) {
this.merchantId = this.access.merchantId;
this.isViewingGlobalData = false;
this.dataSelection.merchantPartnerId = merchantId;
console.log(`✅ Merchant User: ID = ${this.merchantId}`);
} else {
console.error('❌ Merchant ID invalide pour Merchant User:', merchantId);
// Essayer de récupérer directement depuis le profil
const merchantPartnerId = this.authService.getCurrentMerchantPartnerId();
if (merchantPartnerId) {
const id = Number(merchantPartnerId);
if (!isNaN(id) && id > 0) {
this.merchantId = id;
this.accessService.setSelectedMerchantId(id);
this.isViewingGlobalData = false;
this.dataSelection.merchantPartnerId = id;
console.log(`✅ Merchant ID récupéré depuis profil: ${id}`);
}
this.dataSelection.merchantPartnerId = this.merchantId;
console.log(`✅ Dashboard: Merchant User avec ID: ${this.merchantId}`);
} else {
console.error('❌ Dashboard: Merchant User sans merchantId!');
this.addAlert('danger', 'Erreur de configuration',
'Impossible de déterminer le merchant ID', 'Maintenant');
this.isViewingGlobalData = false;
return;
}
}
}
// Pour les hub users
else if (this.access.isHubUser) {
} else if (this.access.isHubUser) {
const selectedMerchantId = this.accessService.getSelectedMerchantId();
if (selectedMerchantId && selectedMerchantId > 0) {
this.merchantId = selectedMerchantId;
this.isViewingGlobalData = false;
this.dataSelection.merchantPartnerId = selectedMerchantId;
console.log(`Hub User: Merchant sélectionné = ${this.merchantId}`);
console.log(`✅ Dashboard: Hub User avec merchant sélectionné: ${this.merchantId}`);
} else {
this.isViewingGlobalData = true;
this.merchantId = undefined;
this.dataSelection.merchantPartnerId = undefined;
console.log('✅ Hub User: Mode global (aucun merchant sélectionné)');
console.log('✅ Dashboard: Hub User en mode global');
}
}
})
);
}
// 3. Charger les merchants
this.loadAllowedMerchants();
isValidMerchantId(id: any): boolean {
if (id === null || id === undefined) {
return false;
// 4. Charger les données
setTimeout(() => {
this.loadData();
}, 100);
// 5. Vérifier la santé du système
if (this.accessService.shouldShowSystemHealth()) {
this.checkSystemHealth();
}
const numId = Number(id);
return !isNaN(numId) && Number.isInteger(numId) && numId > 0;
} catch (error) {
console.error('❌ Dashboard: Erreur lors de l\'initialisation:', error);
this.addAlert('danger', 'Erreur d\'initialisation',
'Impossible de configurer le dashboard', 'Maintenant');
}
}
private loadData(): void {
if (this.access.isHubUser && this.isViewingGlobalData) {
this.loadGlobalData();
} else {
this.loadMerchantData(this.merchantId);
}
}
private loadAllowedMerchants(): void {
@ -423,30 +423,6 @@ export class DcbReportingDashboard implements OnInit, OnDestroy, AfterViewInit {
);
}
private initializeDashboard(): void {
// Vérifier d'abord si le profil est chargé
const profile = this.authService.getProfile();
if (!profile) {
console.log('⏳ Profil non chargé, attente...');
return;
}
if (this.access.isHubUser) {
if (this.isViewingGlobalData) {
this.loadGlobalData();
} else {
this.loadMerchantData(this.merchantId);
}
} else {
this.loadMerchantData(this.merchantId);
}
if (this.accessService.shouldShowSystemHealth()) {
this.checkSystemHealth();
}
}
// ============ CHARGEMENT DES DONNÉES ============
private loadGlobalData(): void {
if (!this.access.isHubUser) return;
@ -485,26 +461,12 @@ export class DcbReportingDashboard implements OnInit, OnDestroy, AfterViewInit {
private loadMerchantData(merchantId: number | undefined): void {
console.log('Chargement des données pour merchant:', merchantId);
// Vérification plus robuste
if (!merchantId || merchantId <= 0 || isNaN(merchantId)) {
console.error('Merchant ID invalide ou manquant:', merchantId);
this.addAlert('warning', 'Merchant non spécifié',
'Veuillez sélectionner un merchant valide', 'Maintenant');
// Pour les merchant users, essayer de récupérer l'ID depuis le cache
if (this.access.isMerchantUser) {
const cachedId = this.accessService.getSelectedMerchantId();
if (cachedId && cachedId > 0) {
merchantId = cachedId;
this.merchantId = cachedId;
console.log(`Utilisation du merchant ID du cache: ${merchantId}`);
} else {
return;
}
} else {
return;
}
}
this.loading.merchantData = true;
this.lastUpdated = new Date();
@ -1507,6 +1469,15 @@ export class DcbReportingDashboard implements OnInit, OnDestroy, AfterViewInit {
}
// ============ MÉTHODES SPÉCIFIQUES AU CONTEXTE ============
isValidMerchantId(id: any): boolean {
if (id === null || id === undefined) {
return false;
}
const numId = Number(id);
return !isNaN(numId) && Number.isInteger(numId) && numId > 0;
}
isViewingGlobal(): boolean {
return this.isViewingGlobalData;
}
@ -1525,7 +1496,6 @@ export class DcbReportingDashboard implements OnInit, OnDestroy, AfterViewInit {
}
private getCurrentMerchantPartnerId(): string | null {
// Utiliser une valeur par défaut sécurisée
return this.access?.merchantId?.toString() || null;
}

View File

@ -1,6 +1,6 @@
import { Injectable } from '@angular/core';
import { Observable, of, BehaviorSubject } from 'rxjs';
import { map, catchError, switchMap, take, filter, first } from 'rxjs/operators';
import { map, catchError, switchMap, take, filter, tap } 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';
@ -8,8 +8,6 @@ import { AuthService } from '@core/services/auth.service';
export interface DashboardAccess {
isHubUser: boolean;
isMerchantUser: boolean;
roleLabel: string;
roleIcon: string;
userRole: UserRole;
merchantId?: number;
}
@ -24,130 +22,124 @@ export class DashboardAccessService {
private accessCache: DashboardAccess | null = null;
private merchantsCache: AllowedMerchant[] | null = null;
private currentMerchantId: number | null = null;
private accessReady$ = new BehaviorSubject<boolean>(false);
private ready$ = new BehaviorSubject<boolean>(false);
private profileLoaded = false;
constructor(
private roleService: RoleManagementService,
private merchantService: MerchantConfigService,
private authService: AuthService
) {
// S'abonner aux changements du profil utilisateur
this.initializeProfileSubscription();
// Initialisation simple
this.initialize();
}
/**
* Initialise la surveillance du profil utilisateur
*/
private initializeProfileSubscription(): void {
this.authService.getUserProfile().pipe(
filter(profile => profile !== null),
first()
).subscribe(profile => {
console.log('📊 DashboardAccessService: Profil utilisateur chargé', {
private initialize(): void {
console.log('🚀 DashboardAccessService: Initialisation');
// S'abonner aux changements de profil
this.authService.getUserProfile().subscribe({
next: (profile) => {
if (profile) {
console.log('✅ DashboardAccessService: Profil chargé', {
username: profile.username,
merchantPartnerId: profile.merchantPartnerId,
userType: profile.userType
});
this.profileLoaded = true;
this.ready$.next(true);
}
},
error: (err) => {
console.error('❌ DashboardAccessService: Erreur de profil:', err);
this.profileLoaded = false;
this.ready$.next(false);
}
});
// Nettoyer à la déconnexion
this.authService.getAuthState().subscribe(isAuthenticated => {
if (!isAuthenticated) {
console.log('🚨 DashboardAccessService: Déconnexion détectée');
this.clearCache();
this.accessReady$.next(true);
this.profileLoaded = false;
this.ready$.next(false);
}
});
}
/**
* Attend que l'accès soit prêt
*/
waitForAccess(): Observable<boolean> {
return this.accessReady$.pipe(
filter(ready => ready),
take(1)
// Attendre que le service soit prêt AVEC PROFIL CHARGÉ
waitForReady(): Observable<boolean> {
return this.ready$.pipe(
filter(ready => ready && this.profileLoaded),
take(1),
tap(() => {
console.log('✅ DashboardAccessService: waitForReady() - Service vraiment prêt');
})
);
}
/**
* Obtient l'accès dashboard (version synchrone)
*/
// Obtenir l'accès dashboard
getDashboardAccess(): DashboardAccess {
if (this.accessCache) {
return this.accessCache;
}
// VÉRIFIER que le profil est chargé
if (!this.profileLoaded) {
console.warn('⚠️ DashboardAccessService: Tentative d\'accès avant chargement du profil');
throw new Error('Profil non chargé');
}
const profile = this.authService.getCurrentUserProfile();
// VÉRIFIER que le profil existe
if (!profile) {
console.error('❌ DashboardAccessService: Profil null dans getDashboardAccess()');
throw new Error('Profil utilisateur non disponible');
}
console.log('📊 DashboardAccessService: getDashboardAccess() avec profil:', {
username: profile.username,
merchantPartnerId: profile.merchantPartnerId,
userType: profile.userType
});
const userRole = this.roleService.getCurrentRole();
const isHubUser = this.roleService.isHubUser();
let merchantId: number | undefined = undefined;
if (!isHubUser) {
merchantId = this.getMerchantIdForCurrentUser();
if (!isHubUser && profile.merchantPartnerId) {
merchantId = Number(profile.merchantPartnerId);
if (isNaN(merchantId) || merchantId <= 0) {
console.warn(`⚠️ DashboardAccessService: merchantPartnerId invalide: ${profile.merchantPartnerId}`);
merchantId = undefined;
}
}
const access: DashboardAccess = {
this.accessCache = {
isHubUser,
isMerchantUser: !isHubUser,
roleLabel: this.roleService.getRoleLabel(),
roleIcon: this.roleService.getRoleIcon(),
userRole: userRole || UserRole.DCB_SUPPORT,
merchantId
};
console.log('📊 DashboardAccess créé:', {
...access,
merchantId,
userRoleLabel: userRole
});
console.log('🎯 DashboardAccessService: Accès créé:', this.accessCache);
this.accessCache = access;
return access;
return this.accessCache;
}
/**
* Obtient l'accès dashboard (version asynchrone)
*/
// Obtenir l'accès dashboard avec attente
getDashboardAccessAsync(): Observable<DashboardAccess> {
return this.waitForAccess().pipe(
return this.waitForReady().pipe(
map(() => this.getDashboardAccess())
);
}
/**
* Récupère le merchant ID pour l'utilisateur courant
*/
private getMerchantIdForCurrentUser(): number | undefined {
// Utiliser la méthode optimisée d'AuthService
const merchantId = this.authService.getCurrentMerchantPartnerIdAsNumber();
if (this.isValidMerchantId(merchantId)) {
console.log(`✅ Merchant ID récupéré: ${merchantId}`);
return merchantId;
}
console.warn('⚠️ Aucun merchant ID valide trouvé pour l\'utilisateur');
console.log('Debug:', {
merchantId,
profile: this.authService.getCurrentUserProfile(),
isAuthenticated: this.authService.isAuthenticated()
});
return undefined;
}
/**
* Valide si un ID marchand est valide
*/
private isValidMerchantId(id: any): id is number {
if (id === null || id === undefined) return false;
const numId = Number(id);
return !isNaN(numId) &&
Number.isInteger(numId) &&
numId > 0;
}
/**
* Obtient la liste des merchants disponibles
*/
// Obtenir les marchands disponibles
getAvailableMerchants(): Observable<AllowedMerchant[]> {
return this.waitForAccess().pipe(
return this.waitForReady().pipe(
switchMap(() => {
if (this.merchantsCache) {
return of(this.merchantsCache);
@ -155,162 +147,71 @@ export class DashboardAccessService {
const access = this.getDashboardAccess();
console.log('📊 getAvailableMerchants pour:', {
isHubUser: access.isHubUser,
merchantId: access.merchantId,
role: access.userRole
});
if (access.isHubUser) {
// Hub users: tous les merchants + option globale
return this.loadAllMerchantsForHubUser();
} else {
// Merchant users: seulement leur merchant
return this.loadSingleMerchantForUser(access.merchantId);
}
})
);
}
/**
* Charge tous les merchants pour les hub users
*/
private loadAllMerchantsForHubUser(): Observable<AllowedMerchant[]> {
return this.merchantService.getAllMerchants().pipe(
map(merchants => {
const availableMerchants: AllowedMerchant[] = merchants.map(m => ({
const available: AllowedMerchant[] = merchants.map(m => ({
id: m.id,
name: m.name
}));
// Ajouter l'option "Global"
availableMerchants.unshift({
id: 0,
name: '🌐 Données globales'
});
// Option globale pour les hub users
available.unshift({ id: 0, name: '🌐 Données globales' });
this.merchantsCache = availableMerchants;
return availableMerchants;
this.merchantsCache = available;
return available;
}),
catchError(error => {
console.error('❌ Erreur lors du chargement des merchants:', error);
// Retourner au moins l'option globale
return of([{
id: 0,
name: '🌐 Données globales'
}]);
catchError(() => {
return of([{ id: 0, name: '🌐 Données globales' }]);
})
);
} else {
// Merchant user: seulement son merchant
const merchantId = access.merchantId;
if (merchantId) {
const merchants = [{ id: merchantId, name: `🏪 Merchant ${merchantId}` }];
this.merchantsCache = merchants;
return of(merchants);
}
return of([]);
}
})
);
}
/**
* Charge le merchant unique pour un merchant user
*/
private loadSingleMerchantForUser(merchantId?: number): Observable<AllowedMerchant[]> {
if (!merchantId || merchantId <= 0) {
console.warn('⚠️ Aucun merchant ID valide pour merchant user');
// Dernière tentative de récupération
const fallbackId = this.getMerchantIdForCurrentUser();
if (this.isValidMerchantId(fallbackId)) {
const merchants = [{
id: fallbackId,
name: `🏪 Merchant ${fallbackId}`
}];
this.merchantsCache = merchants;
return of(merchants);
}
return of([]);
}
const merchants: AllowedMerchant[] = [{
id: merchantId,
name: `🏪 Merchant ${merchantId}`
}];
this.merchantsCache = merchants;
return of(merchants);
}
/**
* Définit le merchant sélectionné (pour hub users)
*/
// Définir le marchand sélectionné (hub users seulement)
setSelectedMerchantId(merchantId: number): void {
if (this.getDashboardAccess().isHubUser) {
this.currentMerchantId = merchantId;
console.log(`📌 Merchant sélectionné: ${merchantId}`);
} else {
console.warn('⚠️ Seuls les hub users peuvent sélectionner un merchant');
}
}
/**
* Obtient le merchant sélectionné
*/
// Obtenir le marchand 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é
return this.currentMerchantId;
}
/**
* Vérifie si un merchant est accessible
*/
canAccessMerchant(merchantId: number): Observable<boolean> {
const access = this.getDashboardAccess();
if (access.isHubUser) {
return of(true); // Hub users: accès à tous
}
// Merchant users: seulement leur merchant
return of(access.merchantId === merchantId);
}
/**
* Nettoie le cache
*/
// Nettoyer le cache
clearCache(): void {
this.accessCache = null;
this.merchantsCache = null;
this.currentMerchantId = null;
console.log('🗑️ DashboardAccessService: Cache nettoyé');
}
/**
* Méthode de débogage
*/
debug(): void {
console.log('=== 🔍 DEBUG DashboardAccessService ===');
console.log('1. Access cache:', this.accessCache);
console.log('2. Merchants cache:', this.merchantsCache);
console.log('3. Current merchant ID:', this.currentMerchantId);
console.log('4. Access ready:', this.accessReady$.value);
console.log('5. AuthService info:');
console.log(' - Merchant ID (number):', this.authService.getCurrentMerchantPartnerIdAsNumber());
console.log(' - Merchant ID (string):', this.authService.getCurrentMerchantPartnerId());
console.log(' - Profil courant:', this.authService.getCurrentUserProfile());
console.log(' - Token présent:', !!this.authService.getAccessToken());
console.log(' - Is authenticated:', this.authService.isAuthenticated());
console.log('==============================');
}
// ============ MÉTHODES UTILITAIRES POUR LE TEMPLATE ============
// ============ MÉTHODES UTILITAIRES SIMPLES ============
shouldShowSystemHealth(): boolean {
return this.getDashboardAccess().isHubUser;
}
shouldShowAllTransactions(): boolean {
return this.getDashboardAccess().isHubUser;
shouldShowAlerts(): boolean {
return true;
}
canTriggerSync(): boolean {
@ -323,36 +224,10 @@ export class DashboardAccessService {
return access.isHubUser && access.userRole === UserRole.DCB_ADMIN;
}
shouldShowTransactions(): boolean {
return true;
}
shouldShowCharts(): boolean {
return true;
}
shouldShowKPIs(): boolean {
return true;
}
shouldShowAlerts(): boolean {
return true;
}
canRefreshData(): boolean {
return true;
}
canSelectMerchant(): boolean {
return this.getDashboardAccess().isHubUser;
}
shouldShowMerchantId(): boolean {
const access = this.getDashboardAccess();
return access.isMerchantUser ||
(access.isHubUser && this.getSelectedMerchantId() !== null);
}
canEditMerchantFilter(): boolean {
const access = this.getDashboardAccess();
if (access.isHubUser) {
@ -361,18 +236,16 @@ export class DashboardAccessService {
return access.userRole === UserRole.DCB_PARTNER_ADMIN;
}
// ============ MÉTHODES UTILITAIRES SUPPLÉMENTAIRES ============
shouldShowMerchantId(): boolean {
const access = this.getDashboardAccess();
return access.isMerchantUser ||
(access.isHubUser && this.getSelectedMerchantId() !== null);
}
/**
* Vérifie si l'utilisateur peut voir les données globales
*/
canViewGlobalData(): boolean {
return this.getDashboardAccess().isHubUser;
}
/**
* Vérifie si l'utilisateur est en mode "données globales"
*/
isViewingGlobalData(): boolean {
const access = this.getDashboardAccess();
if (access.isHubUser) {
@ -381,9 +254,6 @@ export class DashboardAccessService {
return false;
}
/**
* Retourne le nom du merchant courant
*/
getCurrentMerchantName(): string {
const access = this.getDashboardAccess();
@ -401,14 +271,4 @@ export class DashboardAccessService {
return 'Inconnu';
}
/**
* Réinitialise complètement le service
*/
reset(): void {
this.clearCache();
this.accessReady$.next(false);
// Réinitialiser la surveillance du profil
this.initializeProfileSubscription();
}
}

View File

@ -15,7 +15,7 @@ import {
} from '@core/models/dcb-bo-hub-user.model';
import { HubUsersService } from '../hub-users.service';
import { RoleManagementService } from '@core/services/hub-users-roles-management-old.service';
import { RoleManagementService } from '@core/services/hub-users-roles-management.service';
import { AuthService } from '@core/services/auth.service';
import { UiCard } from '@app/components/ui-card';

View File

@ -14,7 +14,7 @@ import {
} from '@core/models/dcb-bo-hub-user.model';
import { HubUsersService } from '../hub-users.service';
import { RoleManagementService } from '@core/services/hub-users-roles-management-old.service';
import { RoleManagementService } from '@core/services/hub-users-roles-management.service';
import { AuthService } from '@core/services/auth.service';
@Component({
@ -214,12 +214,6 @@ export class HubUserProfile implements OnInit, OnDestroy {
return;
}
// Vérifier que l'utilisateur peut attribuer ce rôle
if (!this.roleService.canAssignRole(this.currentUserRole, newRole)) {
this.error = 'Vous n\'avez pas la permission d\'attribuer ce rôle';
return;
}
// Vérifier que le rôle est valide pour les utilisateurs Hub
if (!this.isValidHubRole(newRole)) {
this.error = 'Rôle invalide pour un utilisateur Hub';
@ -310,7 +304,8 @@ export class HubUserProfile implements OnInit, OnDestroy {
}
// Pour les utilisateurs Hub, utiliser les permissions du service de rôle
return this.roleService.canEditUsers(this.currentUserRole);
return this.roleService.isAnyAdmin();
}
/**
@ -318,7 +313,8 @@ export class HubUserProfile implements OnInit, OnDestroy {
*/
canManageRoles(): boolean {
// Pour les Hub, utiliser les permissions du service de rôle
return this.roleService.canManageRoles(this.currentUserRole);
return this.roleService.isAnyAdmin();
}
/**
@ -331,7 +327,8 @@ export class HubUserProfile implements OnInit, OnDestroy {
}
// Pour les Hub, utiliser les permissions du service de rôle
return this.roleService.canEditUsers(this.currentUserRole);
return this.roleService.isAnyAdmin();
}
/**
@ -344,7 +341,8 @@ export class HubUserProfile implements OnInit, OnDestroy {
}
// Pour les Hub, utiliser les permissions générales
return this.roleService.canEditUsers(this.currentUserRole);
return this.roleService.isAnyAdmin();
}
/**
@ -357,7 +355,8 @@ export class HubUserProfile implements OnInit, OnDestroy {
}
// Pour les Hub, utiliser les permissions du service de rôle
return this.roleService.canDeleteUsers(this.currentUserRole);
return this.roleService.isAnyAdmin();
}
// ==================== UTILITAIRES D'AFFICHAGE ====================
@ -526,8 +525,7 @@ export class HubUserProfile implements OnInit, OnDestroy {
}
getAssignableRoles(): UserRole[] {
const hubRoles = [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT];
return hubRoles.filter(role => this.roleService.canAssignRole(this.currentUserRole, role));
return [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT];
}
// Méthodes pour les actions spécifiques

View File

@ -6,7 +6,7 @@ import { NgbNavModule, NgbModal, NgbModalModule } from '@ng-bootstrap/ng-bootstr
import { Subject, takeUntil } from 'rxjs';
import { HubUsersService } from './hub-users.service';
import { RoleManagementService } from '@core/services/hub-users-roles-management-old.service';
import { RoleManagementService } from '@core/services/hub-users-roles-management.service';
import { AuthService } from '@core/services/auth.service';
import { MerchantSyncService } from './merchant-sync-orchestrator.service';
import { PageTitle } from '@app/components/page-title/page-title';
@ -145,13 +145,7 @@ export class HubUsersManagement implements OnInit, OnDestroy {
console.log(`HUB User ROLE: ${this.currentUserRole}`);
if (this.currentUserRole) {
this.roleService.setCurrentUserRole(this.currentUserRole);
this.userPermissions = this.roleService.getPermissionsForRole(this.currentUserRole);
this.canCreateUsers = this.roleService.canCreateUsers(this.currentUserRole);
this.canDeleteUsers = this.roleService.canDeleteUsers(this.currentUserRole);
this.canManageRoles = this.roleService.canManageRoles(this.currentUserRole);
this.assignableRoles = this.roleService.getAssignableRoles(this.currentUserRole);
this.roleService.setCurrentRole(this.currentUserRole);
console.log('Assignable roles:', this.assignableRoles);
}
},
@ -176,14 +170,8 @@ export class HubUsersManagement implements OnInit, OnDestroy {
/**
* Fallback en cas d'erreur de chargement du profil
*/
private fallbackPermissions(): void {
this.currentUserRole = this.authService.getCurrentUserRole();
if (this.currentUserRole) {
this.canCreateUsers = this.roleService.canCreateUsers(this.currentUserRole);
this.canDeleteUsers = this.roleService.canDeleteUsers(this.currentUserRole);
this.canManageRoles = this.roleService.canManageRoles(this.currentUserRole);
}
private fallbackPermissions(): boolean {
return this.roleService.isAnyAdmin();
}
/**
@ -541,7 +529,7 @@ export class HubUsersManagement implements OnInit, OnDestroy {
* Vérifie si l'utilisateur peut attribuer un rôle spécifique
*/
canAssignRole(targetRole: UserRole): boolean {
return this.roleService.canAssignRole(this.currentUserRole, targetRole);
return this.roleService.isAnyAdmin();
}
// Réinitialiser le mot de passe

View File

@ -22,7 +22,7 @@
[class.active]="roleFilter === 'all'"
(click)="filterByRole('all')"
>
Tous ({{ getTotalUsersCount() }})
Tous ({{ userStats?.total }})
</button>
<button
type="button"
@ -94,7 +94,7 @@
</div>
<div class="col-md-2">
<select class="form-select" [(ngModel)]="statusFilter" (change)="applyFiltersAndPagination()">
<select class="form-select" [(ngModel)]="statusFilter" (change)="onFilterChange()">
<option value="all">Tous les statuts</option>
<option value="enabled">Activés seulement</option>
<option value="disabled">Désactivés seulement</option>
@ -102,7 +102,7 @@
</div>
<div class="col-md-2">
<select class="form-select" [(ngModel)]="emailVerifiedFilter" (change)="applyFiltersAndPagination()">
<select class="form-select" [(ngModel)]="emailVerifiedFilter" (change)="onFilterChange()">
<option value="all">Tous les emails</option>
<option value="verified">Email vérifié</option>
<option value="not-verified">Email non vérifié</option>
@ -110,14 +110,13 @@
</div>
<div class="col-md-2">
<select class="form-select" [(ngModel)]="roleFilter" (change)="applyFiltersAndPagination()">
<select class="form-select" [(ngModel)]="roleFilter" (change)="onFilterChange()">
<option value="all">Tous les rôles</option>
@for (role of availableRoles; track role.value) {
<option [value]="role.value">{{ role.label }}</option>
}
</select>
</div>
<div class="col-md-2">
<button class="btn btn-outline-secondary w-100" (click)="onClearFilters()" [disabled]="loading">
<ng-icon name="lucideX" class="me-1"></ng-icon>
@ -313,10 +312,12 @@
</div>
<!-- Pagination -->
@if (totalPages > 1) {
@if (totalPages >= 1) {
<div class="d-flex justify-content-between align-items-center mt-3">
<div class="text-muted">
Affichage de {{ getStartIndex() }} à {{ getEndIndex() }} sur {{ totalItems }} utilisateurs
Affichage de {{ getStartIndex() }}
à {{ getEndIndex() }}
sur {{ totalItems }} utilisateurs
</div>
<nav>
<ngb-pagination
@ -338,7 +339,7 @@
<div class="row text-center">
<div class="col">
<small class="text-muted">
<strong>Total :</strong> {{ allUsers.length }} utilisateurs
<strong>Total :</strong> {{ userStats?.total }} utilisateurs
</small>
</div>
<div class="col">

View File

@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { NgIcon } from '@ng-icons/core';
import { NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap';
import { Observable, Subject, map, of } from 'rxjs';
import { Observable, Subject, forkJoin, map, of } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators';
import {
@ -11,13 +11,17 @@ import {
PaginatedUserResponse,
UserRole,
UserType,
UserUtils
UserUtils,
SearchUsersParams,
UserStats
} from '@core/models/dcb-bo-hub-user.model';
import { MerchantUsersService } from '../merchant-users.service';
import { RoleManagementService } from '@core/services/hub-users-roles-management-old.service';
import { RoleManagementService } from '@core/services/hub-users-roles-management.service';
import { AuthService } from '@core/services/auth.service';
import { UiCard } from '@app/components/ui-card';
import { MerchantConfigService } from '@modules/merchant-config/merchant-config.service';
import { MerchantUser } from '@core/models/merchant-config.model';
@Component({
selector: 'app-merchant-users-list',
@ -34,6 +38,7 @@ import { UiCard } from '@app/components/ui-card';
export class MerchantUsersList implements OnInit, OnDestroy {
private authService = inject(AuthService);
private merchantUsersService = inject(MerchantUsersService);
private merchantConfigService = inject(MerchantConfigService);
protected roleService = inject(RoleManagementService);
private cdRef = inject(ChangeDetectorRef);
private destroy$ = new Subject<void>();
@ -85,11 +90,13 @@ export class MerchantUsersList implements OnInit, OnDestroy {
// ID du merchant partner courant et permissions
currentMerchantPartnerId: string = '';
currentUserRole: UserRole | null = null;
canViewAllMerchants = false;
canViewAllMerchantsUsers = false;
canViewOwnMerchantUsers = false;
userStats: UserStats | undefined;
// Getters pour la logique conditionnelle
get showMerchantPartnerColumn(): boolean {
return this.canViewAllMerchants;
return this.canViewAllMerchantsUsers;
}
get showCreateButton(): boolean {
@ -116,14 +123,15 @@ export class MerchantUsersList implements OnInit, OnDestroy {
.subscribe({
next: (user) => {
this.currentUserRole = this.extractUserRole(user);
this.canViewAllMerchants = this.canViewAllMerchantsCheck(this.currentUserRole);
this.canViewAllMerchantsUsers = this.canViewAllMerchantsUsersCheck();
this.canViewOwnMerchantUsers = this.canViewOwnMerchantUsersCheck(); // Propriété différente
this.loadUsers();
setTimeout(() => {
this.loadData();
});
},
error: (error) => {
console.error('Error loading current user permissions:', error);
this.fallbackPermissions();
this.loadUsers();
}
});
}
@ -136,21 +144,12 @@ export class MerchantUsersList implements OnInit, OnDestroy {
return null;
}
private canViewAllMerchantsCheck(role: UserRole | null): boolean {
if (!role) return false;
const canViewAllRoles = [
UserRole.DCB_ADMIN,
UserRole.DCB_SUPPORT,
UserRole.DCB_PARTNER_ADMIN
];
return canViewAllRoles.includes(role);
private canViewAllMerchantsUsersCheck(): boolean {
return this.roleService.isHubUser()
}
private fallbackPermissions(): void {
this.currentUserRole = this.authService.getCurrentUserRole();
this.canViewAllMerchants = this.canViewAllMerchantsCheck(this.currentUserRole);
private canViewOwnMerchantUsersCheck(): boolean {
return !this.roleService.isHubUser()
}
private initializeAvailableRoles() {
@ -162,49 +161,86 @@ export class MerchantUsersList implements OnInit, OnDestroy {
];
}
loadUsers() {
private loadData() {
this.loading = true;
this.error = '';
const usersObservable: Observable<User[]> = this.canViewAllMerchants
? this.merchantUsersService
.getMerchantUsers(this.currentPage, this.itemsPerPage)
.pipe(map((response: PaginatedUserResponse) => response.users))
: of([]); // fallback propre
// Marquer pour détection de changement
this.cdRef.markForCheck();
const filters: SearchUsersParams = {
searchTerm: this.searchTerm,
status: this.statusFilter,
emailVerified: this.emailVerifiedFilter,
role: this.roleFilter,
sortField: this.sortField,
sortDirection: this.sortDirection
};
usersObservable
// Charger les données et les statistiques en parallèle
forkJoin({
users: this.merchantUsersService.getMerchantUsers(this.currentPage, this.itemsPerPage, filters),
stats: this.merchantUsersService.getMerchantUsersStats(filters)
})
.pipe(
takeUntil(this.destroy$),
catchError(error => {
console.error('Error loading merchant users:', error);
this.error = 'Erreur lors du chargement des utilisateurs marchands';
return of([] as User[]);
console.error('Error loading data:', error);
this.error = 'Erreur lors du chargement des données';
this.loading = false;
this.cdRef.markForCheck();
return of({
users: {
users: [],
total: 0,
page: this.currentPage,
limit: this.itemsPerPage,
totalPages: 0
} as PaginatedUserResponse,
stats: {
total: 0,
enabled: 0,
disabled: 0,
emailVerified: 0,
roleCounts: {}
}
});
})
)
.subscribe({
next: (users) => {
this.allUsers = users || [];
console.log(`✅ Loaded ${this.allUsers.length} merchant users`);
this.applyFiltersAndPagination();
next: ({ users, stats }) => {
this.allUsers = users.users || [];
this.displayedUsers = this.allUsers;
this.totalItems = users.total;
this.totalPages = users.totalPages;
this.userStats = stats;
this.loading = false;
this.cdRef.detectChanges();
console.log(`✅ Loaded ${this.allUsers.length} merchant users (total: ${this.totalItems})`);
this.cdRef.markForCheck(); // Marquer pour détection
},
error: () => {
this.error = 'Erreur lors du chargement des utilisateurs marchands';
this.loading = false;
this.allUsers = [];
this.filteredUsers = [];
this.displayedUsers = [];
this.cdRef.detectChanges();
this.totalItems = 0;
this.totalPages = 0;
this.cdRef.markForCheck(); // Marquer pour détection
}
});
}
// Recherche et filtres
onSearch() {
this.currentPage = 1;
this.applyFiltersAndPagination();
this.loadData(); // Recharger avec les nouveaux filtres
}
onFilterChange() {
this.currentPage = 1;
this.loadData();
}
onClearFilters() {
@ -212,64 +248,10 @@ export class MerchantUsersList implements OnInit, OnDestroy {
this.statusFilter = 'all';
this.emailVerifiedFilter = 'all';
this.roleFilter = 'all';
this.sortField = 'username';
this.sortDirection = 'asc';
this.currentPage = 1;
this.applyFiltersAndPagination();
}
applyFiltersAndPagination() {
if (!this.allUsers) {
this.allUsers = [];
}
// Appliquer les filtres
this.filteredUsers = this.allUsers.filter(user => {
const matchesSearch = !this.searchTerm ||
user.username.toLowerCase().includes(this.searchTerm.toLowerCase()) ||
user.email.toLowerCase().includes(this.searchTerm.toLowerCase()) ||
(user.firstName && user.firstName.toLowerCase().includes(this.searchTerm.toLowerCase())) ||
(user.lastName && user.lastName.toLowerCase().includes(this.searchTerm.toLowerCase()));
const matchesStatus = this.statusFilter === 'all' ||
(this.statusFilter === 'enabled' && user.enabled) ||
(this.statusFilter === 'disabled' && !user.enabled);
const matchesEmailVerified = this.emailVerifiedFilter === 'all' ||
(this.emailVerifiedFilter === 'verified' && user.emailVerified) ||
(this.emailVerifiedFilter === 'not-verified' && !user.emailVerified);
const matchesRole = this.roleFilter === 'all' ||
(user.role && user.role.includes(this.roleFilter));
return matchesSearch && matchesStatus && matchesEmailVerified && matchesRole;
});
// Appliquer le tri
this.filteredUsers.sort((a, b) => {
const aValue = a[this.sortField];
const bValue = b[this.sortField];
if (aValue === bValue) return 0;
let comparison = 0;
if (typeof aValue === 'string' && typeof bValue === 'string') {
comparison = aValue.localeCompare(bValue);
} else if (typeof aValue === 'number' && typeof bValue === 'number') {
comparison = aValue - bValue;
} else if (typeof aValue === 'boolean' && typeof bValue === 'boolean') {
comparison = (aValue === bValue) ? 0 : aValue ? -1 : 1;
}
return this.sortDirection === 'asc' ? comparison : -comparison;
});
// Calculer la pagination
this.totalItems = this.filteredUsers.length;
this.totalPages = Math.ceil(this.totalItems / this.itemsPerPage);
// Appliquer la pagination
const startIndex = (this.currentPage - 1) * this.itemsPerPage;
const endIndex = startIndex + this.itemsPerPage;
this.displayedUsers = this.filteredUsers.slice(startIndex, endIndex);
this.loadData(); // Recharger avec filtres réinitialisés
}
// Tri
@ -280,26 +262,25 @@ export class MerchantUsersList implements OnInit, OnDestroy {
this.sortField = field;
this.sortDirection = 'asc';
}
this.applyFiltersAndPagination();
this.loadData(); // Recharger avec le nouveau tri
}
// Pagination
onPageChange(newPage: number) {
this.currentPage = newPage;
this.loadData(); // Recharger avec la nouvelle page
}
getSortIcon(field: string): string {
if (this.sortField !== field) return 'lucideArrowUpDown';
return this.sortDirection === 'asc' ? 'lucideArrowUp' : 'lucideArrowDown';
}
// Pagination
onPageChange(page: number) {
this.currentPage = page;
this.applyFiltersAndPagination();
getEndIndex(): number {
return Math.min(this.currentPage * this.itemsPerPage, this.totalItems);
}
getStartIndex(): number {
return (this.currentPage - 1) * this.itemsPerPage + 1;
}
getEndIndex(): number {
return Math.min(this.currentPage * this.itemsPerPage, this.totalItems);
return this.totalItems > 0 ? (this.currentPage - 1) * this.itemsPerPage + 1 : 0;
}
// Actions
@ -324,7 +305,7 @@ export class MerchantUsersList implements OnInit, OnDestroy {
if (index !== -1) {
this.allUsers[index] = updatedUser;
}
this.applyFiltersAndPagination();
this.loadData();
this.cdRef.detectChanges();
},
error: (error) => {
@ -344,7 +325,7 @@ export class MerchantUsersList implements OnInit, OnDestroy {
if (index !== -1) {
this.allUsers[index] = updatedUser;
}
this.applyFiltersAndPagination();
this.loadData();
this.cdRef.detectChanges();
},
error: (error) => {
@ -407,14 +388,6 @@ export class MerchantUsersList implements OnInit, OnDestroy {
return user.username;
}
getEnabledUsersCount(): number {
return this.allUsers.filter(user => user.enabled).length;
}
getDisabledUsersCount(): number {
return this.allUsers.filter(user => !user.enabled).length;
}
userHasRole(user: User, role: UserRole): boolean {
return UserUtils.hasRole(user, role);
}
@ -423,12 +396,12 @@ export class MerchantUsersList implements OnInit, OnDestroy {
filterByRole(role: UserRole | 'all') {
this.roleFilter = role;
this.currentPage = 1;
this.applyFiltersAndPagination();
this.loadData();
}
// Recharger les données
refreshData() {
this.loadUsers();
this.loadData();
}
// Méthodes pour le template
@ -437,22 +410,13 @@ export class MerchantUsersList implements OnInit, OnDestroy {
}
getHelperText(): string {
return this.canViewAllMerchants
return this.canViewAllMerchantsUsers
? 'Vue administrative - Tous les utilisateurs marchands'
: 'Votre équipe marchande';
}
getHelperIcon(): string {
return this.canViewAllMerchants ? 'lucideShield' : 'lucideUsers';
}
// Méthode pour compter les utilisateurs par rôle
getUsersCountByRole(role: UserRole): number {
if (!this.allUsers || this.allUsers.length === 0) return 0;
return this.allUsers.filter(user =>
user.role && user.role.includes(role)
).length;
return this.canViewAllMerchantsUsers ? 'lucideShield' : 'lucideUsers';
}
getLoadingText(): string {
@ -476,19 +440,34 @@ export class MerchantUsersList implements OnInit, OnDestroy {
}
showMerchantPartnerId(): boolean {
return !this.canViewAllMerchants;
return !this.canViewAllMerchantsUsers;
}
// Statistiques
getTotalUsersCount(): number {
return this.allUsers.length;
return this.userStats?.total || 0;
}
getActiveUsersCount(): number {
return this.allUsers.filter(user => user.enabled).length;
return this.userStats?.enabled || 0;
}
getVerifiedUsersCount(): number {
return this.allUsers.filter(user => user.emailVerified).length;
return this.userStats?.emailVerified || 0;
}
// Méthode pour compter les utilisateurs par rôle
getUsersCountByRole(role?: UserRole): number {
if (!role || !this.userStats?.roleCounts) return 0;
return this.userStats.roleCounts[role] || 0;
}
getEnabledUsersCount(): number {
return this.userStats?.enabled || 0;
}
getDisabledUsersCount(): number {
return this.userStats?.disabled || 0;
}
}

View File

@ -14,7 +14,7 @@ import {
} from '@core/models/dcb-bo-hub-user.model';
import { MerchantUsersService } from '../merchant-users.service';
import { RoleManagementService } from '@core/services/hub-users-roles-management-old.service';
import { RoleManagementService } from '@core/services/hub-users-roles-management.service';
import { AuthService } from '@core/services/auth.service';
@Component({
@ -210,12 +210,6 @@ export class MerchantUserProfile implements OnInit, OnDestroy {
return;
}
// Vérifier que l'utilisateur peut attribuer ce rôle
if (!this.roleService.canAssignRole(this.currentUserRole, newRole)) {
this.error = 'Vous n\'avez pas la permission d\'attribuer ce rôle';
return;
}
// Vérifier que le rôle est valide pour les utilisateurs Merchant
if (!this.isValidMerchantRole(newRole)) {
this.error = 'Rôle invalide pour un utilisateur Merchant';
@ -306,7 +300,7 @@ export class MerchantUserProfile implements OnInit, OnDestroy {
}
// Pour les utilisateurs Merchant, utiliser les permissions du service de rôle
return this.roleService.canEditUsers(this.currentUserRole);
return this.roleService.isAnyAdmin();
}
/**
@ -314,7 +308,8 @@ export class MerchantUserProfile implements OnInit, OnDestroy {
*/
canManageRoles(): boolean {
// Pour les Merchant, utiliser les permissions du service de rôle
return this.roleService.canManageRoles(this.currentUserRole);
return this.roleService.isAnyAdmin();
}
/**
@ -327,7 +322,8 @@ export class MerchantUserProfile implements OnInit, OnDestroy {
}
// Pour les Merchant, utiliser les permissions du service de rôle
return this.roleService.canEditUsers(this.currentUserRole);
return this.roleService.isAnyAdmin();
}
/**
@ -340,7 +336,8 @@ export class MerchantUserProfile implements OnInit, OnDestroy {
}
// Pour les Merchant, utiliser les permissions générales
return this.roleService.canEditUsers(this.currentUserRole);
return this.roleService.isAnyAdmin();
}
/**
@ -353,7 +350,8 @@ export class MerchantUserProfile implements OnInit, OnDestroy {
}
// Pour les Merchant, utiliser les permissions du service de rôle
return this.roleService.canDeleteUsers(this.currentUserRole);
return this.roleService.isAnyAdmin();
}
// ==================== UTILITAIRES D'AFFICHAGE ====================
@ -522,8 +520,7 @@ export class MerchantUserProfile implements OnInit, OnDestroy {
}
getAssignableRoles(): UserRole[] {
const merchantRoles = [UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT];
return merchantRoles.filter(role => this.roleService.canAssignRole(this.currentUserRole, role));
return [UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT];
}
// Méthodes pour les actions spécifiques

View File

@ -136,6 +136,49 @@
<form (ngSubmit)="createUser()" #userForm="ngForm">
<div class="row g-3">
<!-- Pour les Hub Admin/Support : afficher la liste déroulante -->
<div *ngIf="isMerchantRole(newUser.role) && !(isMerchantUser && currentUserType === UserType.MERCHANT_PARTNER)" class="mb-3">
<label class="form-label">Merchant *</label>
<div *ngIf="loadingMerchantPartners" class="text-muted">
Chargement des merchants...
</div>
<div *ngIf="merchantPartnersError" class="alert alert-danger">
{{ merchantPartnersError }}
</div>
<select
class="form-select"
[(ngModel)]="selectedMerchantPartnerId"
(ngModelChange)="onMerchantSelected($event)"
[disabled]="loadingMerchantPartners"
name="merchantPartnerId">
<option value="">Sélectionner un merchant</option>
<option *ngFor="let merchant of merchantPartners" [value]="merchant.id">
{{ merchant.name }} ({{ merchant.id }})
</option>
</select>
<small class="form-text text-muted">
Sélectionnez le merchant auquel associer cet utilisateur
</small>
</div>
<!-- Pour les Merchant users : afficher un message informatif -->
<div *ngIf="isMerchantRole(newUser.role) && isMerchantUser && currentUserType === UserType.MERCHANT_PARTNER" class="alert alert-info">
<i class="bi bi-info-circle"></i>
<ng-container *ngIf="loadingMerchantPartners">
Chargement de votre merchant...
</ng-container>
<ng-container *ngIf="!loadingMerchantPartners && merchantPartnersError">
<span class="text-danger">{{ merchantPartnersError }}</span>
</ng-container>
<ng-container *ngIf="!loadingMerchantPartners && !merchantPartnersError">
Cet utilisateur sera automatiquement associé à votre merchant :
<strong>{{ getCurrentUserMerchantName() }}</strong>
</ng-container>
</div>
<!-- Informations de base -->
<div class="col-md-6">
@ -286,12 +329,8 @@
@for (role of availableRoles; track role.value) {
<option
[value]="role.value"
[disabled]="!canAssignRole(role.value)"
>
{{ role.label }} - {{ role.description }}
@if (!canAssignRole(role.value)) {
(Non autorisé)
}
</option>
}
</select>

View File

@ -13,7 +13,8 @@ import {
SearchUsersParams,
UserRole,
UserType,
UserUtils
UserUtils,
UserStats
} from '@core/models/dcb-bo-hub-user.model';
// Interfaces pour les nouvelles réponses
@ -87,7 +88,8 @@ export class MerchantUsersService {
role: createUserDto.role,
enabled: createUserDto.enabled !== undefined ? createUserDto.enabled : true,
emailVerified: createUserDto.emailVerified !== undefined ? createUserDto.emailVerified : true,
userType: createUserDto.userType.trim()
userType: createUserDto.userType.trim(),
merchantPartnerId: (createUserDto.merchantPartnerId || '').trim(),
};
return this.http.post<User>(`${this.baseApiUrl}`, payload).pipe(
@ -119,9 +121,51 @@ export class MerchantUsersService {
);
}
getMerchantUsersStats(filters?: SearchUsersParams): Observable<UserStats> {
return this.getAllMerchantUsers(filters).pipe(
map(users => {
const enabled = users.filter(u => u.enabled).length;
const disabled = users.filter(u => !u.enabled).length;
const emailVerified = users.filter(u => u.emailVerified).length;
// Compter par rôle
const roleCounts: Record<string, number> = {};
users.forEach(user => {
if (user.role) {
const role = user.role;
roleCounts[role] = (roleCounts[role] || 0) + 1;
}
});
return {
total: users.length,
enabled,
disabled,
emailVerified,
roleCounts
};
})
);
}
getAllMerchantUsers(filters?: SearchUsersParams): Observable<User[]> {
return this.getMyMerchantUsers().pipe(
map(allUsers => {
// Appliquer les filtres
return this.applyFiltersToUsers(allUsers, filters);
})
);
}
getMerchantUsers(page: number = 1, limit: number = 10, filters?: SearchUsersParams): Observable<PaginatedUserResponse> {
return this.getMyMerchantUsers().pipe(
map(users => this.filterAndPaginateUsers(users, page, limit, filters)),
map(allUsers => {
// Appliquer les filtres sur TOUTES les données
const filteredUsers = this.applyFiltersToUsers(allUsers, filters);
// Puis paginer
return this.paginateUsers(filteredUsers, page, limit);
}),
catchError(error => {
console.error('Error loading merchant users:', error);
return throwError(() => error);
@ -129,6 +173,47 @@ export class MerchantUsersService {
);
}
private applyFiltersToUsers(users: User[], filters?: SearchUsersParams): User[] {
if (!filters) return users;
return users.filter(user => {
const matchesSearch = !filters.searchTerm ||
user.username.toLowerCase().includes(filters.searchTerm.toLowerCase()) ||
user.email.toLowerCase().includes(filters.searchTerm.toLowerCase()) ||
(user.firstName && user.firstName.toLowerCase().includes(filters.searchTerm.toLowerCase())) ||
(user.lastName && user.lastName.toLowerCase().includes(filters.searchTerm.toLowerCase()));
const matchesStatus = !filters.status || filters.status === 'all' ||
(filters.status === 'enabled' && user.enabled) ||
(filters.status === 'disabled' && !user.enabled);
const matchesEmailVerified = !filters.emailVerified || filters.emailVerified === 'all' ||
(filters.emailVerified === 'verified' && user.emailVerified) ||
(filters.emailVerified === 'not-verified' && !user.emailVerified);
const matchesRole = !filters.role || filters.role === 'all' ||
(user.role && user.role.includes(filters.role));
return matchesSearch && matchesStatus && matchesEmailVerified && matchesRole;
});
}
private paginateUsers(users: User[], page: number, limit: number): PaginatedUserResponse {
const total = users.length;
const totalPages = Math.ceil(total / limit);
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit;
const paginatedItems = users.slice(startIndex, endIndex);
return {
users: paginatedItems,
total: total,
page: page,
limit: limit,
totalPages: totalPages
};
}
updateMerchantUser(id: string, updateUserDto: UpdateUserDto): Observable<User> {
const payload: any = {
firstName: updateUserDto.firstName,
@ -280,50 +365,4 @@ export class MerchantUsersService {
lastLogin: apiUser.lastLogin
};
}
private filterAndPaginateUsers(
users: User[],
page: number,
limit: number,
filters?: SearchUsersParams
): PaginatedUserResponse {
let filteredUsers = users;
if (filters) {
if (filters.query) {
const query = filters.query.toLowerCase();
filteredUsers = filteredUsers.filter(user =>
user.username.toLowerCase().includes(query) ||
user.email.toLowerCase().includes(query) ||
user.firstName?.toLowerCase().includes(query) ||
user.lastName?.toLowerCase().includes(query)
);
}
if (filters.role) {
filteredUsers = filteredUsers.filter(user => user.role.includes(filters.role!));
}
if (filters.enabled !== undefined) {
filteredUsers = filteredUsers.filter(user => user.enabled === filters.enabled);
}
if (filters.userType) {
filteredUsers = filteredUsers.filter(user => user.userType === filters.userType);
}
}
// Pagination côté client
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit;
const paginatedUsers = filteredUsers.slice(startIndex, endIndex);
return {
users: paginatedUsers,
total: filteredUsers.length,
page,
limit,
totalPages: Math.ceil(filteredUsers.length / limit)
};
}
}

View File

@ -3,10 +3,10 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { NgIcon } from '@ng-icons/core';
import { NgbNavModule, NgbModal, NgbModalModule } from '@ng-bootstrap/ng-bootstrap';
import { catchError, map, of, Subject, takeUntil } from 'rxjs';
import { catchError, map, of, Subject, switchMap, takeUntil } from 'rxjs';
import { MerchantUsersService } from './merchant-users.service';
import { RoleManagementService } from '@core/services/hub-users-roles-management-old.service';
import { RoleManagementService } from '@core/services/hub-users-roles-management.service';
import { AuthService } from '@core/services/auth.service';
import { PageTitle } from '@app/components/page-title/page-title';
import { MerchantUsersList } from './merchant-users-list/merchant-users-list';
@ -15,11 +15,12 @@ import { MerchantUserProfile } from './merchant-users-profile/merchant-users-pro
import {
PaginatedUserResponse,
ResetPasswordDto,
User,
UserRole,
UserType
} from '@core/models/dcb-bo-hub-user.model';
import { HubUsersService } from './hub-users.service';
import { MerchantConfigService } from '@modules/merchant-config/merchant-config.service';
import { AddUserToMerchantDto, Merchant } from '@core/models/merchant-config.model';
import { MerchantSyncService } from './merchant-sync-orchestrator.service';
@Component({
selector: 'app-merchant-users',
@ -40,13 +41,14 @@ export class MerchantUsersManagement implements OnInit, OnDestroy {
private modalService = inject(NgbModal);
private authService = inject(AuthService);
private merchantUsersService = inject(MerchantUsersService);
private hubUsersService = inject(HubUsersService);
private merchantConfigService = inject(MerchantConfigService);
protected roleService = inject(RoleManagementService);
private cdRef = inject(ChangeDetectorRef);
private destroy$ = new Subject<void>();
// Configuration
readonly UserRole = UserRole;
readonly UserType = UserType;
// Propriétés de configuration
pageTitle: string = 'Gestion des Utilisateurs Marchands';
@ -76,6 +78,7 @@ export class MerchantUsersManagement implements OnInit, OnDestroy {
enabled: boolean;
emailVerified: boolean;
userType: UserType;
merchantPartnerId?: string;
} = this.getDefaultUserForm();
// États des opérations
@ -110,22 +113,49 @@ export class MerchantUsersManagement implements OnInit, OnDestroy {
availableRoles: { value: UserRole; label: string; description: string }[] = [];
assignableRoles: UserRole[] = [];
merchantPartners: User[] = [];
merchantPartners: Merchant[] = [];
merchantPartner: Merchant | undefined;
loadingMerchantPartners = false;
merchantPartnersError = '';
selectedMerchantPartnerId: string = '';
ngOnInit() {
this.activeTab = 'list';
this.loadCurrentUserPermissions();
this.loadAvailableRoles();
// Charger les données selon le type d'utilisateur
this.loadUserSpecificData();
this.newUser.role = UserRole.DCB_PARTNER_SUPPORT;
}
private loadUserSpecificData(): void {
if (this.isMerchantUser && this.currentUserType === UserType.MERCHANT_PARTNER) {
// Pour un utilisateur Merchant : charger son propre merchant seulement
this.loadCurrentUserMerchant();
} else {
// Pour un Hub Admin/Support : charger tous les merchants
this.loadAllMerchants();
}
}
constructor() {}
private updateNewUserMerchantId() {
if (this.selectedMerchantPartnerId) {
this.newUser.merchantPartnerId = this.selectedMerchantPartnerId;
}
}
onMerchantSelected(merchantId: string) {
this.selectedMerchantPartnerId = merchantId;
this.updateNewUserMerchantId();
}
onRoleSelectionChange(selectedRole: UserRole) {
this.newUser.role = selectedRole;
}
@ -157,14 +187,10 @@ export class MerchantUsersManagement implements OnInit, OnDestroy {
this.currentUserType = this.extractUserType(user);
if (this.currentUserRole) {
this.roleService.setCurrentUserRole(this.currentUserRole);
this.userPermissions = this.roleService.getPermissionsForRole(this.currentUserRole);
this.canCreateUsers = this.roleService.canCreateUsers(this.currentUserRole);
this.canDeleteUsers = this.roleService.canDeleteUsers(this.currentUserRole);
this.canManageRoles = this.roleService.canManageRoles(this.currentUserRole);
this.canCreateUsers = this.roleService.isAnyAdmin();
this.assignableRoles = this.roleService.getAssignableRoles(this.currentUserRole);
if (this.currentUserRole) {
this.roleService.setCurrentRole(this.currentUserRole);
}
},
@ -215,7 +241,7 @@ export class MerchantUsersManagement implements OnInit, OnDestroy {
}
private getDefaultUserForm() {
return {
const defaultForm = {
username: '',
email: '',
firstName: '',
@ -224,8 +250,12 @@ export class MerchantUsersManagement implements OnInit, OnDestroy {
role: UserRole.DCB_PARTNER_SUPPORT,
enabled: true,
emailVerified: false,
userType: UserType.MERCHANT_PARTNER
userType: UserType.MERCHANT_PARTNER,
merchantPartnerId: ''
};
// Si l'utilisateur est déjà connecté comme Merchant, on pourra pré-remplir plus tard
return defaultForm;
}
// ==================== MÉTHODES D'INTERFACE ====================
@ -279,6 +309,71 @@ export class MerchantUsersManagement implements OnInit, OnDestroy {
return this.selectedUserId ? this.loadingProfiles[this.selectedUserId] : false;
}
private loadCurrentUserMerchant(): void {
this.loadingMerchantPartners = true;
this.merchantPartnersError = '';
const currentUserMerchantPartnerId = this.authService.getCurrentMerchantPartnerId();
if (!currentUserMerchantPartnerId) {
this.merchantPartnersError = 'Impossible de déterminer votre ID utilisateur';
this.loadingMerchantPartners = false;
return;
}
this.merchantConfigService.getMerchantById(Number(currentUserMerchantPartnerId))
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (merchant) => {
if (merchant) {
this.merchantPartner = merchant;
// Pré-remplir le merchantPartnerId
if (this.merchantPartner?.id) {
this.newUser.merchantPartnerId = this.merchantPartner.id.toString();
console.log('✅ Current user merchant loaded:', this.merchantPartner);
}
} else {
this.merchantPartnersError = 'Aucun merchant trouvé pour votre compte';
console.warn('❌ No merchant found for current user');
}
this.loadingMerchantPartners = false;
},
error: (error) => {
console.error('❌ Error loading current user merchant:', error);
this.merchantPartnersError = 'Erreur lors du chargement de votre merchant';
this.loadingMerchantPartners = false;
}
});
}
private loadAllMerchants(): void {
this.loadingMerchantPartners = true;
this.merchantPartnersError = '';
this.merchantConfigService.getAllMerchants()
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (merchants) => {
this.merchantPartners = merchants;
this.loadingMerchantPartners = false;
console.log('✅ All merchants loaded for Hub Admin:', merchants.length);
},
error: (error) => {
console.error('❌ Error loading all merchants:', error);
this.merchantPartnersError = 'Erreur lors du chargement des merchants';
this.loadingMerchantPartners = false;
}
});
}
getCurrentUserMerchantName(): string {
if (!this.newUser.merchantPartnerId || !this.merchantPartner) {
return 'Non défini';
}
const merchant = this.merchantPartner;
return merchant ? `${merchant.name} (${merchant.id})` : 'Non trouvé';
}
backToList() {
console.log('🔙 Returning to list view');
@ -324,17 +419,17 @@ export class MerchantUsersManagement implements OnInit, OnDestroy {
}
private resetUserForm() {
this.newUser = {
username: '',
email: '',
firstName: '',
lastName: '',
password: '',
role: UserRole.DCB_PARTNER_SUPPORT,
enabled: true,
emailVerified: false,
userType: UserType.MERCHANT_PARTNER,
};
this.newUser = this.getDefaultUserForm();
// Si l'utilisateur est un Merchant, utiliser son merchantPartnerId
if (this.isMerchantUser && this.currentUserType === UserType.MERCHANT_PARTNER && this.merchantPartner) {
const currentUserMerchant = this.merchantPartner;
if (currentUserMerchant?.id) {
this.newUser.merchantPartnerId = currentUserMerchant.id.toString();
console.log('🔄 Form reset with current user merchant:', this.newUser.merchantPartnerId);
}
}
console.log('🔄 Merchant user form reset');
}
@ -401,9 +496,62 @@ export class MerchantUsersManagement implements OnInit, OnDestroy {
return;
}
// Vérifier la permission pour attribuer le rôle sélectionné
if (!this.canAssignRole(this.newUser.role)) {
this.createUserError = `Vous n'avez pas la permission d'attribuer le rôle: ${this.getRoleLabel(this.newUser.role)}`;
// Vérifications pour les utilisateurs Merchant
if (this.isMerchantRole(this.newUser.role)) {
// Déterminer merchantPartnerId
if (this.isMerchantUser && this.currentUserType === UserType.MERCHANT_PARTNER) {
// L'utilisateur connecté est un Merchant, utiliser son merchantPartnerId
const currentUserMerchantPartnerId = this.authService.getCurrentMerchantPartnerId();
if (currentUserMerchantPartnerId) {
this.newUser.merchantPartnerId = currentUserMerchantPartnerId;
console.log('✅ Using current user merchantPartnerId:', currentUserMerchantPartnerId);
} else {
// Si l'utilisateur n'a pas de merchantPartnerId, essayez de le trouver
this.findMerchantPartnerIdForCurrentUser();
if (!this.newUser.merchantPartnerId) {
this.createUserError = 'Impossible de déterminer votre merchant partner ID';
return;
}
}
} else if (this.selectedMerchantPartnerId) {
// Hub admin crée pour un merchant spécifique
this.newUser.merchantPartnerId = this.selectedMerchantPartnerId;
console.log('✅ Using selected merchantPartnerId:', this.selectedMerchantPartnerId);
} else {
this.createUserError = 'Merchant partner est requis pour les utilisateurs Merchant';
return;
}
let merchantExists = false;
// Si vous avez une liste de merchants
if (this.merchantPartners && this.merchantPartners.length > 0) {
// Vérifier que le merchant existe dans MerchantConfig
merchantExists = this.merchantPartners.some(
merchant => Number(merchant?.id) === Number(this.newUser.merchantPartnerId)
);
console.log("Merchant exists in list:", merchantExists);
}
// Si vous avez un merchant individuel (pour un utilisateur Merchant)
else if (this.merchantPartner) {
merchantExists = Number(this.merchantPartner?.id) === Number(this.newUser.merchantPartnerId);
console.log("Merchant exists (single):", merchantExists);
}
// Si aucun des deux n'est disponible
else {
console.warn("No merchant data available for validation");
}
if (!merchantExists) {
this.createUserError = `Le merchant avec l'ID ${this.newUser.merchantPartnerId} n'existe pas dans MerchantConfig`;
return;
}
}
const mappedRole = this.mapToMerchantConfigRole(this.newUser.role);
if (!mappedRole) {
this.createUserError = `Impossible de mapper le rôle ${this.getRoleLabel(this.newUser.role)} vers un rôle MerchantConfig valide`;
return;
}
@ -412,18 +560,60 @@ export class MerchantUsersManagement implements OnInit, OnDestroy {
console.log('📤 Creating merchant user with data:', this.newUser);
this.merchantUsersService.createMerchantUser(this.newUser)
.pipe(takeUntil(this.destroy$))
// 1. Créer l'utilisateur dans Keycloak
const userDto = {
username: this.newUser.username,
email: this.newUser.email,
password: this.newUser.password,
firstName: this.newUser.firstName,
lastName: this.newUser.lastName,
role: this.newUser.role,
enabled: this.newUser.enabled,
emailVerified: this.newUser.emailVerified,
userType: this.newUser.userType,
merchantPartnerId: this.newUser.merchantPartnerId // Passer l'ID du merchant
};
this.merchantUsersService.createMerchantUser(userDto)
.pipe(
switchMap((createdKeycloakUser) => {
console.log('✅ Keycloak user created successfully:', createdKeycloakUser);
// 2. Ajouter l'utilisateur au merchant dans MerchantConfig
if (this.isMerchantRole(this.newUser.role) && this.newUser.merchantPartnerId) {
const merchantPartnerId = Number(this.newUser.merchantPartnerId);
const addUserDto: AddUserToMerchantDto = {
userId: createdKeycloakUser.id,
role: mappedRole,
merchantPartnerId: merchantPartnerId
};
console.log('📤 Adding user to merchant config:', addUserDto);
return this.merchantConfigService.addUserToMerchant(addUserDto).pipe(
map((merchantConfigUser) => {
return {
keycloakUser: createdKeycloakUser,
merchantConfigUser
};
})
);
}
return of({ keycloakUser: createdKeycloakUser });
}),
takeUntil(this.destroy$)
)
.subscribe({
next: (createdUser) => {
console.log('✅ Merchant user created successfully:', createdUser);
next: (result) => {
console.log('✅ Complete user creation successful:', result);
this.creatingUser = false;
this.modalService.dismissAll();
this.refreshUsersList();
this.cdRef.detectChanges();
},
error: (error) => {
console.error('❌ Error creating merchant user:', error);
console.error('❌ Error in user creation process:', error);
this.creatingUser = false;
this.createUserError = this.getErrorMessage(error);
this.cdRef.detectChanges();
@ -431,6 +621,50 @@ export class MerchantUsersManagement implements OnInit, OnDestroy {
});
}
// Méthode pour trouver le merchantPartnerId de l'utilisateur connecté
private findMerchantPartnerIdForCurrentUser(): void {
const currentUserId = this.authService.getCurrentUserId();
if (!currentUserId) return;
// Chercher dans les merchants configs
for (const merchant of this.merchantPartners) {
if (merchant.users?.some(user => user.userId === currentUserId)) {
this.newUser.merchantPartnerId = merchant?.id?.toString();
console.log('✅ Found merchantPartnerId for current user:', this.newUser.merchantPartnerId);
return;
}
}
// Si non trouvé, essayer via le service
this.merchantUsersService.getMerchantUserById(currentUserId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (user) => {
if (user.merchantPartnerId) {
this.newUser.merchantPartnerId = user.merchantPartnerId;
console.log('✅ Found merchantPartnerId via service:', this.newUser.merchantPartnerId);
}
},
error: (error) => {
console.warn('Could not find merchantPartnerId for current user:', error);
}
});
}
/**
* Mappe un rôle Keycloak Merchant vers un rôle MerchantConfig
*/
private mapToMerchantConfigRole(keycloakRole: UserRole): UserRole | null {
// Déclarez comme Partial<Record<UserRole, UserRole>> ou utilisez un type plus spécifique
const roleMapping: Partial<Record<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_VIEWER
};
return roleMapping[keycloakRole] || null;
}
/**
* Vérifie si un rôle est un rôle marchand
*/
@ -444,13 +678,6 @@ export class MerchantUsersManagement implements OnInit, OnDestroy {
return merchantRoles.includes(role);
}
/**
* Vérifie si l'utilisateur peut attribuer un rôle spécifique
*/
canAssignRole(targetRole: UserRole): boolean {
return this.roleService.canAssignRole(this.currentUserRole, targetRole);
}
// Réinitialiser le mot de passe
confirmResetPassword() {
if (!this.selectedUserForReset || !this.newPassword || this.newPassword.length < 8) {
@ -537,11 +764,6 @@ export class MerchantUsersManagement implements OnInit, OnDestroy {
}
}
// Méthodes proxy pour le template
getRoleBadgeClass(role: UserRole): string {
return this.roleService.getRoleBadgeClass(role);
}
getRoleLabel(role: UserRole): string {
return this.roleService.getRoleLabel(role);
}
@ -550,6 +772,10 @@ export class MerchantUsersManagement implements OnInit, OnDestroy {
return this.roleService.getRoleIcon(role);
}
getRoleBadgeClass(role: UserRole): string {
return this.roleService.getRoleBadgeClass(role);
}
getRoleDescription(role: UserRole): string {
const roleInfo = this.availableRoles.find(r => r.value === role);
return roleInfo?.description || 'Description non disponible';
@ -616,7 +842,8 @@ export class MerchantUsersManagement implements OnInit, OnDestroy {
{ field: this.newUser.username?.trim(), name: 'Nom d\'utilisateur' },
{ field: this.newUser.email?.trim(), name: 'Email' },
{ field: this.newUser.firstName?.trim(), name: 'Prénom' },
{ field: this.newUser.lastName?.trim(), name: 'Nom' }
{ field: this.newUser.lastName?.trim(), name: 'Nom' },
{ field: this.newUser.merchantPartnerId?.trim(), name: 'Merchant Partner ID' }
];
for (const { field, name } of requiredFields) {

View File

@ -15,7 +15,7 @@ import {
} from '@core/models/merchant-config.model';
import { MerchantConfigService } from '../merchant-config.service';
import { RoleManagementService } from '@core/services/hub-users-roles-management-old.service';
import { RoleManagementService } from '@core/services/hub-users-roles-management.service';
import { AuthService } from '@core/services/auth.service';
import { UiCard } from '@app/components/ui-card';

View File

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

View File

@ -19,7 +19,8 @@ import {
ApiMerchant,
ApiMerchantConfig,
ApiTechnicalContact,
ApiMerchantUser
ApiMerchantUser,
MerchantUserWithMerchant
} from '@core/models/merchant-config.model';
// SERVICE DE CONVERSION
@ -189,6 +190,53 @@ export class MerchantConfigService {
);
}
getAllMerchantUsers(page: number = 1, limit: number = 10, params?: SearchMerchantsParams): Observable<PaginatedResponse<MerchantUser>> {
let httpParams = new HttpParams();
if (params?.query) {
httpParams = httpParams.set('query', params.query.trim());
}
console.log(`📥 Loading all merchant users - page ${page}, limit ${limit}`, params);
return this.http.get<ApiMerchant[]>(this.baseApiUrl, { params: httpParams }).pipe(
timeout(this.REQUEST_TIMEOUT),
map(apiMerchants => {
// Récupérer tous les utilisateurs de tous les marchands
const allUsers: MerchantUser[] = [];
apiMerchants.forEach(apiMerchant => {
if (apiMerchant.users && apiMerchant.users.length > 0) {
const merchantUsers = apiMerchant.users.map(user =>
this.dataAdapter.convertApiUserToFrontend(user)
);
allUsers.push(...merchantUsers);
}
});
// Gestion de la pagination côté client
const total = allUsers.length;
const totalPages = Math.ceil(total / limit);
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit;
const paginatedItems = allUsers.slice(startIndex, endIndex);
const response: PaginatedResponse<MerchantUser> = {
items: paginatedItems,
total: total,
page: page,
limit: limit,
totalPages: totalPages
};
console.log(`✅ Loaded ${paginatedItems.length} merchant users out of ${total} total`);
return response;
}),
catchError(error => this.handleError('getAllMerchantUsers', error))
);
}
updateUserRole(merchantId: number, userId: string, updateRoleDto: UpdateUserRoleDto): Observable<MerchantUser> {
//const merchantId = this.convertIdToNumber(merchantId);

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 { MerchantConfigService } from './merchant-config.service';
import { RoleManagementService } from '@core/services/hub-users-roles-management-old.service';
import { RoleManagementService } from '@core/services/hub-users-roles-management.service';
import { AuthService } from '@core/services/auth.service';
import { MerchantSyncService } from '../hub-users-management/merchant-sync-orchestrator.service';
import { PageTitle } from '@app/components/page-title/page-title';
@ -421,7 +421,6 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
this.currentMerchantConfigId = this.extractMerchantConfigId(user);
if (this.currentUserRole) {
this.userPermissions = this.roleService.getPermissionsForRole(this.currentUserRole);
this.canCreateMerchants = this.canManageMerchant();
this.canDeleteMerchants = this.canManageMerchant();
this.canManageMerchants = this.canManageMerchant();
@ -448,7 +447,7 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
// ==================== PERMISSIONS SPÉCIFIQUES MARCHAND ====================
private canManageMerchant(): boolean {
return this.roleService.canManageMerchants(this.currentUserRole);
return this.roleService.isAnyAdmin();
}
/**

View File

@ -21,8 +21,8 @@ import { MyProfile } from '@modules/profile/profile';
import { Documentation } from '@modules/documentation/documentation';
import { Help } from '@modules/help/help';
import { About } from '@modules/about/about';
import { SubscriptionsManagement } from './subscriptions/subscriptions';
import { SubscriptionPayments } from './subscriptions/subscription-payments/subscription-payments';
import { Subscriptions } from './subscriptions/subscriptions';
import { SubscriptionDetails } from './subscriptions/subscription-details/subscription-details';
import { MerchantConfigManagement } from './merchant-config/merchant-config';
const routes: Routes = [
@ -93,7 +93,7 @@ const routes: Routes = [
// ---------------------------
{
path: 'subscriptions',
component: SubscriptionsManagement,
component: Subscriptions,
canActivate: [authGuard, roleGuard],
data: {
title: 'Gestion des Abonnements',
@ -108,61 +108,6 @@ const routes: Routes = [
]
}
},
{
path: 'subscriptions/merchant',
component: SubscriptionsManagement,
canActivate: [authGuard, roleGuard],
data: {
title: 'Abonnements par Merchant',
module: 'subscriptions-merchant',
requiredRoles: [
'dcb-admin',
'dcb-support',
'dcb-partner',
'dcb-partner-admin',
'dcb-partner-manager',
'dcb-partner-support',
]
}
},
// ---------------------------
// Subscriptions Payments
// ---------------------------
{
path: 'subscriptions/payments',
component: SubscriptionsManagement,
canActivate: [authGuard, roleGuard],
data: {
title: 'Paiements des Abonnements',
module: 'subscriptions-payments',
requiredRoles: [
'dcb-admin',
'dcb-support',
'dcb-partner',
'dcb-partner-admin',
'dcb-partner-manager',
'dcb-partner-support',
]
}
},
{
path: 'subscriptions/:id/payments',
component: SubscriptionPayments,
canActivate: [authGuard, roleGuard],
data: {
title: 'Détails des Paiements',
module: 'subscriptions-payments',
requiredRoles: [
'dcb-admin',
'dcb-support',
'dcb-partner',
'dcb-partner-admin',
'dcb-partner-manager',
'dcb-partner-support',
]
}
},
// ---------------------------
// Partners

View File

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

View File

@ -0,0 +1,219 @@
import { Injectable, Injector, inject } from '@angular/core';
import { BehaviorSubject, filter, map, Observable, of, take, tap } from 'rxjs';
import { RoleManagementService, UserRole } from '@core/services/hub-users-roles-management.service';
import { AuthService } from '@core/services/auth.service';
export interface SubscriptionAccess {
// Permissions de visualisation
canViewSubscriptions: boolean;
canViewAllSubscriptions: boolean;
canViewDetails: boolean;
canViewPayments: boolean;
// Permissions administratives
canManageAll: boolean;
canFilterByMerchant: boolean;
// Scope
allowedMerchantIds: number[];
isHubUser: boolean;
isMerchantUser: boolean;
// Informations utilisateur
userRole: UserRole;
userRoleLabel: string;
merchantId?: number;
}
@Injectable({ providedIn: 'root' })
export class SubscriptionAccessService {
private accessCache: SubscriptionAccess | null = null;
private ready$ = new BehaviorSubject<boolean>(false);
private profileLoaded = false;
constructor(
private roleService: RoleManagementService,
private authService: AuthService
) {
// Initialisation simple
this.initialize();
}
private initialize(): void {
console.log('🚀 DashboardAccessService: Initialisation');
// S'abonner aux changements de profil
this.authService.getUserProfile().subscribe({
next: (profile) => {
if (profile) {
console.log('✅ TransactionAccessService: Profil chargé', {
username: profile.username,
merchantPartnerId: profile.merchantPartnerId,
userType: profile.userType
});
this.profileLoaded = true;
this.ready$.next(true);
}
},
error: (err) => {
console.error('❌ DashboardAccessService: Erreur de profil:', err);
this.profileLoaded = false;
this.ready$.next(false);
}
});
// Nettoyer à la déconnexion
this.authService.getAuthState().subscribe(isAuthenticated => {
if (!isAuthenticated) {
console.log('🚨 DashboardAccessService: Déconnexion détectée');
this.clearCache();
this.profileLoaded = false;
this.ready$.next(false);
}
});
}
// Attendre que le service soit prêt AVEC PROFIL CHARGÉ
waitForReady(): Observable<boolean> {
return this.ready$.pipe(
filter(ready => ready && this.profileLoaded), // <-- VÉRIFIER LES DEUX
take(1),
tap(() => {
console.log('✅ DashboardAccessService: waitForReady() - Service vraiment prêt');
})
);
}
getSubscriptionAccess(): SubscriptionAccess {
if (this.accessCache) {
return this.accessCache;
}
const userRole = this.roleService.getCurrentRole() || UserRole.DCB_SUPPORT;
const isHubUser = this.roleService.isHubUser();
const merchantId = this.getCurrentMerchantId();
const access: SubscriptionAccess = {
// Permissions de visualisation
canViewSubscriptions: this.canViewSubscriptions(userRole, isHubUser),
canViewAllSubscriptions: this.canViewAllSubscriptions(userRole, isHubUser),
canViewDetails: this.canViewDetails(userRole, isHubUser),
canViewPayments: this.canViewPayments(userRole, isHubUser),
// Permissions administratives
canManageAll: this.canManageAll(userRole, isHubUser),
canFilterByMerchant: this.canFilterByMerchant(userRole, isHubUser),
// Scope
allowedMerchantIds: this.getAllowedMerchantIds(isHubUser, merchantId),
isHubUser,
isMerchantUser: !isHubUser,
// Informations utilisateur
userRole,
userRoleLabel: this.roleService.getRoleLabel(userRole) || 'Utilisateur',
merchantId
};
this.accessCache = access;
return access;
}
// Obtenir l'accès dashboard avec attente
getSubscriptionAccessAsync(): Observable<SubscriptionAccess> {
return this.waitForReady().pipe(
map(() => this.getSubscriptionAccess())
);
}
// === MÉTHODES DE DÉTERMINATION DES PERMISSIONS ===
private canViewSubscriptions(userRole: UserRole, isHubUser: boolean): boolean {
// Tous les utilisateurs peuvent voir les abonnements (avec restrictions de scope)
return true;
}
private canViewAllSubscriptions(userRole: UserRole, isHubUser: boolean): boolean {
// Hub users et DCB_PARTNER_ADMIN peuvent voir tous les abonnements
return isHubUser;
}
private canViewDetails(userRole: UserRole, isHubUser: boolean): boolean {
// Tous peuvent voir les détails (avec restrictions de scope)
return true;
}
private canViewPayments(userRole: UserRole, isHubUser: boolean): boolean {
// Tous peuvent voir les paiements
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;
}
// === GESTION DU SCOPE ===
private getCurrentMerchantId(): number | undefined {
const merchantPartnerId = this.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 [];
} else {
// Merchant users: seulement leur merchant
return merchantId ? [merchantId] : [];
}
}
// === MÉTHODES PUBLIQUES ===
canAccessSubscription(subscriptionMerchantId?: number): Observable<boolean> {
const access = this.getSubscriptionAccess();
// Hub users avec permission de tout voir
if (access.canManageAll) {
return of(true);
}
// Si pas de merchant ID sur l'abonnement, accès limité
if (!subscriptionMerchantId) {
return of(access.isHubUser);
}
// Merchant users: seulement leur merchant
if (access.isMerchantUser) {
return of(access.allowedMerchantIds.includes(subscriptionMerchantId));
}
// Hub users: vérifier si le merchant est dans leur liste autorisée
if (access.allowedMerchantIds.length === 0) {
return of(true);
}
return of(access.allowedMerchantIds.includes(subscriptionMerchantId));
}
// Nettoyer le cache
clearCache(): void {
this.accessCache = null;
}
// Rafraîchir les permissions
refreshAccess(): void {
this.clearCache();
}
}

View File

@ -0,0 +1,269 @@
<div class="subscription-details">
<!-- Message d'accès refusé -->
@if (accessDenied) {
<div class="text-center py-5">
<ng-icon name="lucideLock" class="text-danger fs-1 mb-3"></ng-icon>
<h5 class="text-danger">Accès refusé</h5>
<p class="text-muted mb-4">Vous n'avez pas la permission d'accéder à cet abonnement.</p>
<button class="btn btn-primary" routerLink="/subscriptions">
<ng-icon name="lucideArrowLeft" class="me-1"></ng-icon>
Retour à la liste
</button>
</div>
} @else {
<!-- Loading State -->
@if (loading && !subscription) {
<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 l'abonnement...</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 (subscription && !loading) {
<div class="row">
<!-- Colonne principale -->
<div class="col-lg-8">
<!-- En-tête de l'abonnement -->
<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">Abonnement #{{ subscription.id }}</h5>
<span [class]="getStatusBadgeClass(subscription.status)" class="badge">
<ng-icon [name]="getStatusIcon(subscription.status)" class="me-1"></ng-icon>
{{ getStatusDisplayName(subscription.status) }}
</span>
</div>
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary btn-sm" (click)="copyToClipboard(subscription.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)="loadSubscriptionDetails()"
[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="subscription-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="h3 mb-0 text-success">
{{ formatCurrency(subscription.amount, subscription.currency) }}
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="d-flex align-items-center h-100">
<div class="subscription-periodicity-icon bg-info rounded-circle p-3 me-3">
<ng-icon name="lucideRepeat" class="text-white fs-4"></ng-icon>
</div>
<div>
<div class="text-muted small">Périodicité</div>
<div class="h6 mb-0">
<span [class]="getPeriodicityBadgeClass(subscription.periodicity)" class="badge">
{{ getPeriodicityDisplayName(subscription.periodicity) }}
</span>
</div>
</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 l'abonnement</h6>
</div>
<div class="col-md-6 mb-3">
<label class="form-label text-muted small mb-1">Date de début</label>
<div class="d-flex align-items-center">
<ng-icon name="lucideCalendar" class="me-2 text-muted"></ng-icon>
<div>
<div class="fw-medium">{{ formatDate(subscription.startDate) }}</div>
<small class="text-muted">{{ formatRelativeTime(subscription.startDate) }}</small>
</div>
</div>
</div>
@if (subscription.nextPaymentDate) {
<div class="col-md-6 mb-3">
<label class="form-label text-muted small mb-1">Prochain paiement</label>
<div class="d-flex align-items-center">
<ng-icon name="lucideCalendar" class="me-2 text-muted"></ng-icon>
<div>
<div class="fw-medium">{{ formatDate(subscription.nextPaymentDate) }}</div>
<small class="text-muted">Dans {{ getDaysUntilNextPayment() }} jour(s)</small>
</div>
</div>
</div>
}
@if (subscription.endDate) {
<div class="col-md-6 mb-3">
<label class="form-label text-muted small mb-1">Date de fin</label>
<div class="d-flex align-items-center">
<ng-icon name="lucideCalendar" class="me-2 text-muted"></ng-icon>
<div>
<div class="fw-medium">{{ formatDate(subscription.endDate) }}</div>
@if (isExpiringSoon()) {
<small class="text-warning">
<ng-icon name="lucideAlertCircle" class="me-1"></ng-icon>
Expire bientôt
</small>
} @else if (isExpired()) {
<small class="text-danger">
<ng-icon name="lucideXCircle" class="me-1"></ng-icon>
Expiré
</small>
}
</div>
</div>
</div>
}
@if (subscription.externalReference) {
<div class="col-md-6 mb-3">
<label class="form-label text-muted small mb-1">Référence externe</label>
<div class="d-flex align-items-center">
<span class="font-monospace small">{{ subscription.externalReference }}</span>
<button class="btn btn-sm btn-link p-0 ms-2" (click)="copyToClipboard(subscription.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(subscription.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(subscription.updatedAt) }}</div>
</div>
<div class="col-md-6 mb-2">
<label class="form-label text-muted small mb-1">Token</label>
<div class="small font-monospace text-truncate" [title]="subscription.token">
{{ subscription.token.substring(0, 30) }}...
</div>
</div>
<div class="col-md-6 mb-2">
<label class="form-label text-muted small mb-1">Échecs</label>
<div class="small">{{ subscription.failureCount || 0 }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- Colonne latérale - Informations complémentaires -->
<div class="col-lg-4">
<!-- Informations marchand et client -->
<div class="card mb-3">
<div class="card-header bg-light">
<h6 class="card-title mb-0">Informations marchand & client</h6>
</div>
<div class="card-body">
<div class="small">
<div class="d-flex justify-content-between mb-2">
<span class="text-muted">Merchant ID:</span>
<span class="font-monospace">{{ subscription.merchantPartnerId }}</span>
</div>
@if (subscription.customerId) {
<div class="d-flex justify-content-between mb-2">
<span class="text-muted">Client ID:</span>
<span class="font-monospace small">{{ subscription.customerId }}</span>
</div>
}
@if (subscription.planId) {
<div class="d-flex justify-content-between mb-2">
<span class="text-muted">Plan ID:</span>
<span class="font-monospace small">{{ subscription.planId }}</span>
</div>
}
@if (subscription.serviceId) {
<div class="d-flex justify-content-between mb-2">
<span class="text-muted">Service ID:</span>
<span class="font-monospace small">{{ subscription.serviceId }}</span>
</div>
}
</div>
</div>
</div>
<!-- Métadonnées -->
@if (subscription.metadata) {
<div class="card">
<div class="card-header bg-light">
<h6 class="card-title mb-0">Métadonnées</h6>
</div>
<div class="card-body">
<pre class="small mb-0">{{ subscription.metadata | json }}</pre>
</div>
</div>
}
</div>
</div>
}
<!-- Abonnement non trouvé -->
@if (!subscription && !loading) {
<div class="text-center py-5">
<ng-icon name="lucideAlertCircle" class="text-muted fs-1 mb-3"></ng-icon>
<h5 class="text-muted">Abonnement non trouvé</h5>
<p class="text-muted mb-4">L'abonnement avec l'ID "{{ subscriptionId }}" n'existe pas ou a été supprimé.</p>
<button class="btn btn-primary" routerLink="/subscriptions">
<ng-icon name="lucideArrowLeft" class="me-1"></ng-icon>
Retour à la liste
</button>
</div>
}
}
</div>

View File

@ -0,0 +1,297 @@
import { Component, inject, OnInit, Input, ChangeDetectorRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
import { NgIcon } from '@ng-icons/core';
import {
lucideArrowLeft,
lucideCopy,
lucideRefreshCw,
lucidePrinter,
lucideCheckCircle,
lucideClock,
lucideXCircle,
lucidePauseCircle,
lucideCalendarOff,
lucideCalendar,
lucideEuro,
lucidePackage,
lucideUser,
lucideAlertCircle,
lucideInfo,
lucideShield,
lucideStore,
lucideLock,
lucideRepeat
} from '@ng-icons/lucide';
import { NgbAlertModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
import { SubscriptionsService } from '../services/subscriptions.service';
import { SubscriptionAccessService, SubscriptionAccess } from '../services/subscription-access.service';
import {
Subscription,
SubscriptionStatus,
SubscriptionPeriodicity,
Currency
} from '@core/models/dcb-bo-hub-subscription.model';
@Component({
selector: 'app-subscription-details',
standalone: true,
imports: [
CommonModule,
FormsModule,
RouterModule,
NgIcon,
NgbAlertModule,
NgbTooltipModule
],
templateUrl: './subscription-details.html'
})
export class SubscriptionDetails implements OnInit {
private subscriptionsService = inject(SubscriptionsService);
private accessService = inject(SubscriptionAccessService);
private cdRef = inject(ChangeDetectorRef);
@Input() subscriptionId!: string;
// Permissions
access!: SubscriptionAccess;
// Données
subscription: Subscription | null = null;
loading = false;
error = '';
success = '';
// Accès
accessDenied = false;
ngOnInit() {
this.initializePermissions();
if (this.subscriptionId) {
this.loadSubscriptionDetails();
}
}
private initializePermissions() {
this.access = this.accessService.getSubscriptionAccess();
}
loadSubscriptionDetails() {
if (!this.access.canViewDetails) {
this.error = 'Vous n\'avez pas la permission de voir les détails des abonnements';
this.accessDenied = true;
this.cdRef.detectChanges();
return;
}
this.loading = true;
this.error = '';
this.accessDenied = false;
this.subscriptionsService.getSubscriptionById(this.subscriptionId).subscribe({
next: (subscription) => {
// Vérifier si l'utilisateur a accès à cet abonnement spécifique
this.accessService.canAccessSubscription(subscription.merchantPartnerId)
.subscribe(canAccess => {
if (!canAccess) {
this.error = 'Vous n\'avez pas accès à cet abonnement';
this.accessDenied = true;
this.loading = false;
this.cdRef.detectChanges();
return;
}
this.subscription = subscription;
this.loading = false;
this.cdRef.detectChanges();
});
},
error: (error) => {
this.error = 'Erreur lors du chargement des détails de l\'abonnement';
this.loading = false;
this.cdRef.detectChanges();
console.error('Error loading subscription details:', error);
}
});
}
// Utilitaires
copyToClipboard(text: string) {
navigator.clipboard.writeText(text).then(() => {
this.success = 'Copié dans le presse-papier';
setTimeout(() => this.success = '', 3000);
this.cdRef.detectChanges();
});
}
printDetails() {
window.print();
}
// Getters pour l'affichage
getStatusBadgeClass(status: SubscriptionStatus): string {
switch (status) {
case SubscriptionStatus.ACTIVE: return 'badge bg-success';
case SubscriptionStatus.SUSPENDED: return 'badge bg-warning';
case SubscriptionStatus.CANCELLED: return 'badge bg-danger';
case SubscriptionStatus.EXPIRED: return 'badge bg-secondary';
case SubscriptionStatus.PENDING: return 'badge bg-info';
default: return 'badge bg-secondary';
}
}
getStatusIcon(status: SubscriptionStatus): string {
switch (status) {
case SubscriptionStatus.ACTIVE: return 'lucideCheckCircle';
case SubscriptionStatus.SUSPENDED: return 'lucidePauseCircle';
case SubscriptionStatus.CANCELLED: return 'lucideXCircle';
case SubscriptionStatus.EXPIRED: return 'lucideCalendarOff';
case SubscriptionStatus.PENDING: return 'lucideClock';
default: return 'lucideClock';
}
}
getPeriodicityBadgeClass(periodicity: SubscriptionPeriodicity): string {
switch (periodicity) {
case SubscriptionPeriodicity.DAILY: return 'badge bg-primary';
case SubscriptionPeriodicity.WEEKLY: return 'badge bg-info';
case SubscriptionPeriodicity.MONTHLY: return 'badge bg-success';
case SubscriptionPeriodicity.YEARLY: return 'badge bg-warning';
default: return 'badge bg-secondary';
}
}
formatCurrency(amount: number, currency: Currency = Currency.XOF): string {
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: currency
}).format(amount);
}
formatDate(date: string | Date): string {
if (!date) return '-';
const dateObj = date instanceof Date ? date : new Date(date);
if (isNaN(dateObj.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(dateObj);
}
formatRelativeTime(date: string | Date): string {
const now = new Date();
const dateObj = date instanceof Date ? date : new Date(date);
if (isNaN(dateObj.getTime())) {
return 'Date invalide';
}
const diffMs = now.getTime() - dateObj.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return 'À l\'instant';
if (diffMins < 60) return `Il y a ${diffMins} min`;
if (diffHours < 24) return `Il y a ${diffHours} h`;
if (diffDays < 7) return `Il y a ${diffDays} j`;
return this.formatDate(date);
}
// Méthodes pour le template
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';
}
getStatusDisplayName(status: SubscriptionStatus): string {
const statusNames = {
[SubscriptionStatus.ACTIVE]: 'Actif',
[SubscriptionStatus.SUSPENDED]: 'Suspendu',
[SubscriptionStatus.CANCELLED]: 'Annulé',
[SubscriptionStatus.EXPIRED]: 'Expiré',
[SubscriptionStatus.PENDING]: 'En attente'
};
return statusNames[status] || status;
}
getPeriodicityDisplayName(periodicity: SubscriptionPeriodicity): string {
const periodicityNames = {
[SubscriptionPeriodicity.DAILY]: 'Quotidien',
[SubscriptionPeriodicity.WEEKLY]: 'Hebdomadaire',
[SubscriptionPeriodicity.MONTHLY]: 'Mensuel',
[SubscriptionPeriodicity.YEARLY]: 'Annuel'
};
return periodicityNames[periodicity] || periodicity;
}
// Vérifier si l'abonnement est sur le point d'expirer
isExpiringSoon(): boolean {
if (!this.subscription?.endDate) return false;
const endDate = new Date(this.subscription.endDate);
const today = new Date();
const diffTime = endDate.getTime() - today.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return diffDays <= 7 && diffDays > 0;
}
// Vérifier si l'abonnement est expiré
isExpired(): boolean {
if (!this.subscription?.endDate) return false;
return new Date(this.subscription.endDate) < new Date();
}
// Calculer les jours jusqu'au prochain paiement
getDaysUntilNextPayment(): number {
if (!this.subscription?.nextPaymentDate) return 0;
const nextPayment = new Date(this.subscription.nextPaymentDate);
const today = new Date();
const diffTime = nextPayment.getTime() - today.getTime();
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
}
// AJOUTEZ CES MÉTHODES POUR LES MÉTADONNÉES :
getMetadataItems(): { key: string; value: string }[] {
if (!this.subscription?.metadata) {
return [];
}
return Object.entries(this.subscription.metadata).map(([key, value]) => ({
key: this.formatMetadataKey(key),
value: this.formatMetadataValue(value)
}));
}
private formatMetadataKey(key: string): string {
// Convertir camelCase ou snake_case en texte lisible
return key
.replace(/([A-Z])/g, ' $1')
.replace(/_/g, ' ')
.replace(/^./, str => str.toUpperCase())
.trim();
}
private formatMetadataValue(value: any): string {
if (value === null || value === undefined) return 'N/A';
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
}
}

View File

@ -1,332 +0,0 @@
<app-ui-card [title]="getCardTitle()">
<a
helper-text
href="javascript:void(0);"
class="icon-link icon-link-hover link-primary fw-semibold"
>
<ng-icon [name]="getHelperIcon()" class="me-1"></ng-icon>
{{ getHelperText() }}
</a>
<div card-body>
<!-- Barre d'actions supérieure -->
<div class="row mb-3">
<div class="col-md-6">
<div class="d-flex align-items-center gap-2">
<!-- Statistiques rapides -->
<div class="btn-group btn-group-sm">
<button
type="button"
class="btn btn-outline-primary"
[class.active]="statusFilter === 'all'"
(click)="filterByStatus('all')"
>
Tous ({{ getTotalPaymentsCount() }})
</button>
<button
type="button"
class="btn btn-outline-success"
[class.active]="statusFilter === 'SUCCESS'"
(click)="filterByStatus('SUCCESS')"
>
Réussis ({{ getSuccessfulPaymentsCount() }})
</button>
<button
type="button"
class="btn btn-outline-danger"
[class.active]="statusFilter === 'FAILED'"
(click)="filterByStatus('FAILED')"
>
Échoués ({{ getFailedPaymentsCount() }})
</button>
<button
type="button"
class="btn btn-outline-warning text-dark"
[class.active]="statusFilter === 'PENDING'"
(click)="filterByStatus('PENDING')"
>
En attente ({{ getPendingPaymentsCount() }})
</button>
</div>
</div>
</div>
<div class="col-md-6">
<div class="d-flex justify-content-end gap-2">
<button
class="btn btn-outline-secondary"
(click)="refreshData()"
[disabled]="loading"
>
<ng-icon name="lucideRefreshCw" class="me-1" [class.spin]="loading"></ng-icon>
Actualiser
</button>
</div>
</div>
</div>
<!-- Barre de recherche et filtres avancés -->
<div class="row mb-3">
<div class="col-md-4">
<div class="input-group">
<span class="input-group-text">
<ng-icon name="lucideSearch"></ng-icon>
</span>
<input
type="text"
class="form-control"
placeholder="Rechercher par référence, description..."
[(ngModel)]="searchTerm"
(input)="onSearch()"
[disabled]="loading"
>
</div>
</div>
<div class="col-md-3">
<select class="form-select" [(ngModel)]="statusFilter" (change)="applyFiltersAndPagination()">
@for (status of availableStatuses; track status.value) {
<option [value]="status.value">{{ status.label }}</option>
}
</select>
</div>
<div class="col-md-3">
<select class="form-select" [(ngModel)]="dateFilter" (change)="applyFiltersAndPagination()">
@for (dateRange of availableDateRanges; track dateRange.value) {
<option [value]="dateRange.value">{{ dateRange.label }}</option>
}
</select>
</div>
<div class="col-md-2">
<button class="btn btn-outline-secondary w-100" (click)="onClearFilters()" [disabled]="loading">
<ng-icon name="lucideX" class="me-1"></ng-icon>
Effacer
</button>
</div>
</div>
<!-- Loading State -->
@if (loading) {
<div class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
<p class="mt-2 text-muted">{{ getLoadingText() }}</p>
</div>
}
<!-- Error State -->
@if (error && !loading) {
<div class="alert alert-danger" role="alert">
<div class="d-flex align-items-center">
<ng-icon name="lucideAlertCircle" class="me-2"></ng-icon>
<div>{{ error }}</div>
<button class="btn-close ms-auto" (click)="error = ''"></button>
</div>
</div>
}
<!-- Payments Table -->
@if (!loading && !error) {
<div class="table-responsive">
<table class="table table-hover table-striped">
<thead class="table-light">
<tr>
<!-- Colonne Abonnement pour la vue globale -->
@if (showSubscriptionColumn) {
<th>Abonnement</th>
}
<th (click)="sort('reference')" class="cursor-pointer">
<div class="d-flex align-items-center">
<span>Référence</span>
<ng-icon [name]="getSortIcon('reference')" class="ms-1 fs-12"></ng-icon>
</div>
</th>
<th (click)="sort('createdAt')" class="cursor-pointer">
<div class="d-flex align-items-center">
<span>Date</span>
<ng-icon [name]="getSortIcon('createdAt')" class="ms-1 fs-12"></ng-icon>
</div>
</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 (click)="sort('status')" class="cursor-pointer">
<div class="d-flex align-items-center">
<span>Statut</span>
<ng-icon [name]="getSortIcon('status')" class="ms-1 fs-12"></ng-icon>
</div>
</th>
<th width="120">Actions</th>
</tr>
</thead>
<tbody>
@for (payment of displayedPayments; track payment.id) {
<tr>
<!-- Colonne Abonnement pour la vue globale -->
@if (showSubscriptionColumn) {
<td>
<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">
<ng-icon name="lucideRepeat" class="text-primary fs-12"></ng-icon>
</div>
<div>
<small class="text-muted">
#{{ payment.subscriptionId }}
</small>
<button
class="btn btn-link btn-sm p-0 ms-1"
(click)="navigateToSubscription(payment.subscriptionId)"
title="Voir l'abonnement"
>
<ng-icon name="lucideExternalLink" size="12"></ng-icon>
</button>
</div>
</div>
</td>
}
<td>
<div class="d-flex align-items-center">
<div class="avatar-sm bg-info bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2">
<ng-icon name="lucideCreditCard" class="text-info fs-12"></ng-icon>
</div>
<div>
<strong class="d-block font-monospace small">
{{ truncateText(payment.reference, 15) }}
</strong>
<small class="text-muted">
Ref: {{ getInternalReference(payment) }}
</small>
</div>
</div>
</td>
<td>
<div>
<small class="text-muted d-block">
{{ formatDate(payment.createdAt) }}
</small>
<small class="text-muted">
{{ formatDateTime(payment.createdAt) }}
</small>
</div>
</td>
<td>
<strong class="d-block text-success">
{{ formatAmount(payment.amount, payment.currency) }}
</strong>
<small class="text-muted">{{ payment.currency }}</small>
</td>
<td>
<span class="badge d-flex align-items-center" [ngClass]="getPaymentStatusBadgeClass(payment.status)">
<ng-icon [name]="getPaymentStatusIcon(payment.status)" class="me-1" size="14"></ng-icon>
{{ getPaymentStatusDisplayName(payment.status) }}
</span>
@if (payment.description) {
<div class="mt-1">
<small class="text-muted">
{{ truncateText(payment.description, 30) }}
</small>
</div>
}
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<button
class="btn btn-outline-primary btn-sm"
(click)="viewPaymentDetails(payment.id)"
title="Voir les détails"
>
<ng-icon name="lucideEye"></ng-icon>
</button>
<button
class="btn btn-outline-info btn-sm"
(click)="navigateToSubscription(payment.subscriptionId)"
title="Voir l'abonnement"
>
<ng-icon name="lucideRepeat"></ng-icon>
</button>
</div>
</td>
</tr>
}
@empty {
<tr>
<td [attr.colspan]="getColumnCount()" class="text-center py-4">
<div class="text-muted">
<ng-icon name="lucideCreditCard" class="fs-1 mb-3 opacity-50"></ng-icon>
<h5 class="mb-2">{{ getEmptyStateTitle() }}</h5>
<p class="mb-3">{{ getEmptyStateDescription() }}</p>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Pagination -->
@if (totalPages > 1) {
<div class="d-flex justify-content-between align-items-center mt-3">
<div class="text-muted">
Affichage de {{ getStartIndex() }} à {{ getEndIndex() }} sur {{ totalItems }} paiements
</div>
<nav>
<ngb-pagination
[collectionSize]="totalItems"
[page]="currentPage"
[pageSize]="itemsPerPage"
[maxSize]="5"
[rotate]="true"
[boundaryLinks]="true"
(pageChange)="onPageChange($event)"
/>
</nav>
</div>
}
<!-- Résumé des résultats -->
@if (displayedPayments.length > 0) {
<div class="mt-3 pt-3 border-top">
<div class="row text-center">
<div class="col">
<small class="text-muted">
<strong>Total :</strong> {{ getPaymentsSummary().total }} paiements
</small>
</div>
<div class="col">
<small class="text-muted">
<strong>Réussis :</strong> {{ getPaymentsSummary().success }}
</small>
</div>
<div class="col">
<small class="text-muted">
<strong>Échoués :</strong> {{ getPaymentsSummary().failed }}
</small>
</div>
<div class="col">
<small class="text-muted">
<strong>En attente :</strong> {{ getPaymentsSummary().pending }}
</small>
</div>
<div class="col">
<small class="text-muted">
<strong>Montant total :</strong> {{ formatAmount(getPaymentsSummary().amount, Currency.XOF) }}
</small>
</div>
<div class="col">
<small class="text-muted">
<strong>Taux de réussite :</strong> {{ getSuccessRate().toFixed(1) }}%
</small>
</div>
</div>
</div>
}
}
</div>
</app-ui-card>

View File

@ -1,478 +0,0 @@
import { Component, inject, OnInit, Output, EventEmitter, ChangeDetectorRef, Input, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { NgIcon } from '@ng-icons/core';
import { NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap';
import { Observable, Subject, map, of } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators';
import {
SubscriptionPayment,
SubscriptionPaymentsResponse,
Currency
} from '@core/models/dcb-bo-hub-subscription.model';
import { SubscriptionsService } from '../subscriptions.service';
import { AuthService } from '@core/services/auth.service';
import { UiCard } from '@app/components/ui-card';
@Component({
selector: 'app-subscription-payments-list',
standalone: true,
imports: [
CommonModule,
FormsModule,
NgIcon,
UiCard,
NgbPaginationModule
],
templateUrl: './subscription-payments-list.html',
})
export class SubscriptionPaymentsList implements OnInit, OnDestroy {
private authService = inject(AuthService);
private subscriptionsService = inject(SubscriptionsService);
private cdRef = inject(ChangeDetectorRef);
private destroy$ = new Subject<void>();
// Configuration
readonly Currency = Currency;
// Inputs
@Input() subscriptionId!: string;
// Outputs
@Output() paymentSelected = new EventEmitter<string>();
@Output() viewSubscriptionDetails = new EventEmitter<string>();
// Données
allPayments: SubscriptionPayment[] = [];
filteredPayments: SubscriptionPayment[] = [];
displayedPayments: SubscriptionPayment[] = [];
// États
loading = false;
error = '';
// Recherche et filtres
searchTerm = '';
statusFilter: 'all' | 'SUCCESS' | 'FAILED' | 'PENDING' = 'all';
dateFilter: 'all' | 'week' | 'month' | 'quarter' = 'all';
// Pagination
currentPage = 1;
itemsPerPage = 10;
totalItems = 0;
totalPages = 0;
// Tri
sortField: keyof SubscriptionPayment = 'createdAt';
sortDirection: 'asc' | 'desc' = 'desc';
// Filtres disponibles
availableStatuses: { value: 'all' | 'SUCCESS' | 'FAILED' | 'PENDING'; label: string }[] = [
{ value: 'all', label: 'Tous les statuts' },
{ value: 'SUCCESS', label: 'Réussis' },
{ value: 'FAILED', label: 'Échoués' },
{ value: 'PENDING', label: 'En attente' }
];
availableDateRanges: { value: 'all' | 'week' | 'month' | 'quarter'; label: string }[] = [
{ value: 'all', label: 'Toute période' },
{ value: 'week', label: '7 derniers jours' },
{ value: 'month', label: '30 derniers jours' },
{ value: 'quarter', label: '3 derniers mois' }
];
// Informations de l'abonnement
subscriptionDetails: any = null;
// Gestion des permissions
currentUserRole: string | null = null;
currentMerchantPartnerId: string = '';
// Getters pour la logique conditionnelle
get showSubscriptionColumn(): boolean {
return !this.subscriptionId; // Afficher la colonne abonnement seulement si on est dans la vue globale
}
get showAmountColumn(): boolean {
return true;
}
ngOnInit() {
if (this.subscriptionId) {
this.loadCurrentUserPermissions();
this.loadPayments();
this.loadSubscriptionDetails();
}
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
private loadCurrentUserPermissions() {
this.authService.getUserProfile()
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (user) => {
this.currentUserRole = this.extractUserRole(user);
console.log('Payments Context Loaded:', {
role: this.currentUserRole,
merchantPartnerId: this.currentMerchantPartnerId
});
},
error: (error) => {
console.error('Error loading current user permissions:', error);
}
});
}
private extractUserRole(user: any): string | null {
const userRoles = this.authService.getCurrentUserRoles();
if (userRoles && userRoles.length > 0) {
return userRoles[0];
}
return null;
}
private loadSubscriptionDetails() {
if (this.subscriptionId) {
this.subscriptionsService.getSubscriptionById(this.subscriptionId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (subscription) => {
this.subscriptionDetails = subscription;
},
error: (error) => {
console.error('Error loading subscription details:', error);
}
});
}
}
loadPayments() {
this.loading = true;
this.error = '';
let paymentsObservable: Observable<SubscriptionPayment[]>;
if (this.subscriptionId) {
// Paiements d'un abonnement spécifique
paymentsObservable = this.subscriptionsService.getSubscriptionPayments(this.currentMerchantPartnerId, this.subscriptionId).pipe(
map((response: SubscriptionPaymentsResponse) => response.payments)
);
} else {
// Vue globale des paiements (à implémenter si nécessaire)
paymentsObservable = of([]);
}
paymentsObservable
.pipe(
takeUntil(this.destroy$),
catchError(error => {
console.error('Error loading subscription payments:', error);
this.error = 'Erreur lors du chargement des paiements';
return of([] as SubscriptionPayment[]);
})
)
.subscribe({
next: (payments) => {
this.allPayments = payments || [];
console.log(`✅ Loaded ${this.allPayments.length} payments`);
this.applyFiltersAndPagination();
this.loading = false;
this.cdRef.detectChanges();
},
error: () => {
this.error = 'Erreur lors du chargement des paiements';
this.loading = false;
this.allPayments = [];
this.filteredPayments = [];
this.displayedPayments = [];
this.cdRef.detectChanges();
}
});
}
// Recherche et filtres
onSearch() {
this.currentPage = 1;
this.applyFiltersAndPagination();
}
onClearFilters() {
this.searchTerm = '';
this.statusFilter = 'all';
this.dateFilter = 'all';
this.currentPage = 1;
this.applyFiltersAndPagination();
}
applyFiltersAndPagination() {
if (!this.allPayments) {
this.allPayments = [];
}
// Appliquer les filtres
this.filteredPayments = this.allPayments.filter(payment => {
const matchesSearch = !this.searchTerm ||
payment.reference.toLowerCase().includes(this.searchTerm.toLowerCase()) ||
payment.description.toLowerCase().includes(this.searchTerm.toLowerCase()) ||
(payment.metadata?.internatRef && payment.metadata.internatRef.toLowerCase().includes(this.searchTerm.toLowerCase()));
const matchesStatus = this.statusFilter === 'all' ||
payment.status === this.statusFilter;
const matchesDate = this.applyDateFilter(payment);
return matchesSearch && matchesStatus && matchesDate;
});
// Appliquer le tri
this.filteredPayments.sort((a, b) => {
const aValue = a[this.sortField];
const bValue = b[this.sortField];
if (aValue === bValue) return 0;
let comparison = 0;
if (typeof aValue === 'string' && typeof bValue === 'string') {
comparison = aValue.localeCompare(bValue);
} else if (typeof aValue === 'number' && typeof bValue === 'number') {
comparison = aValue - bValue;
} else if (aValue instanceof Date && bValue instanceof Date) {
comparison = aValue.getTime() - bValue.getTime();
}
return this.sortDirection === 'asc' ? comparison : -comparison;
});
// Calculer la pagination
this.totalItems = this.filteredPayments.length;
this.totalPages = Math.ceil(this.totalItems / this.itemsPerPage);
// Appliquer la pagination
const startIndex = (this.currentPage - 1) * this.itemsPerPage;
const endIndex = startIndex + this.itemsPerPage;
this.displayedPayments = this.filteredPayments.slice(startIndex, endIndex);
}
private applyDateFilter(payment: SubscriptionPayment): boolean {
if (this.dateFilter === 'all') return true;
const paymentDate = new Date(payment.createdAt);
const now = new Date();
let startDate = new Date();
switch (this.dateFilter) {
case 'week':
startDate.setDate(now.getDate() - 7);
break;
case 'month':
startDate.setMonth(now.getMonth() - 1);
break;
case 'quarter':
startDate.setMonth(now.getMonth() - 3);
break;
}
return paymentDate >= startDate;
}
// Tri
sort(field: keyof SubscriptionPayment) {
if (this.sortField === field) {
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
} else {
this.sortField = field;
this.sortDirection = 'desc'; // Par défaut, tri décroissant pour les dates
}
this.applyFiltersAndPagination();
}
getSortIcon(field: string): string {
if (this.sortField !== field) return 'lucideArrowUpDown';
return this.sortDirection === 'asc' ? 'lucideArrowUp' : 'lucideArrowDown';
}
// Pagination
onPageChange(page: number) {
this.currentPage = page;
this.applyFiltersAndPagination();
}
getStartIndex(): number {
return (this.currentPage - 1) * this.itemsPerPage + 1;
}
getEndIndex(): number {
return Math.min(this.currentPage * this.itemsPerPage, this.totalItems);
}
// Actions
viewPaymentDetails(paymentId: string | number) {
this.paymentSelected.emit(paymentId.toString());
}
navigateToSubscription(subscriptionId: string | number) {
this.viewSubscriptionDetails.emit(subscriptionId.toString());
}
// Utilitaires d'affichage
getPaymentStatusBadgeClass(status: string): string {
const statusClasses: { [key: string]: string } = {
'SUCCESS': 'badge bg-success',
'FAILED': 'badge bg-danger',
'PENDING': 'badge bg-warning'
};
return statusClasses[status] || 'badge bg-secondary';
}
getPaymentStatusDisplayName(status: string): string {
const statusNames: { [key: string]: string } = {
'SUCCESS': 'Réussi',
'FAILED': 'Échoué',
'PENDING': 'En attente'
};
return statusNames[status] || status;
}
getPaymentStatusIcon(status: string): string {
const statusIcons: { [key: string]: string } = {
'SUCCESS': 'lucideCheckCircle',
'FAILED': 'lucideXCircle',
'PENDING': 'lucideClock'
};
return statusIcons[status] || 'lucideHelpCircle';
}
formatAmount(amount: number, currency: Currency): string {
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: currency
}).format(amount);
}
formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString('fr-FR', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
formatDateTime(dateString: string): string {
return new Date(dateString).toLocaleDateString('fr-FR', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
// Statistiques
getTotalPaymentsCount(): number {
return this.allPayments.length;
}
getSuccessfulPaymentsCount(): number {
return this.allPayments.filter(payment => payment.status === 'SUCCESS').length;
}
getFailedPaymentsCount(): number {
return this.allPayments.filter(payment => payment.status === 'FAILED').length;
}
getPendingPaymentsCount(): number {
return this.allPayments.filter(payment => payment.status === 'PENDING').length;
}
getTotalAmount(): number {
return this.allPayments
.filter(payment => payment.status === 'SUCCESS')
.reduce((sum, payment) => sum + payment.amount, 0);
}
getSuccessRate(): number {
if (this.allPayments.length === 0) return 0;
return (this.getSuccessfulPaymentsCount() / this.allPayments.length) * 100;
}
// Recherche rapide par statut
filterByStatus(status: 'all' | 'SUCCESS' | 'FAILED' | 'PENDING') {
this.statusFilter = status;
this.currentPage = 1;
this.applyFiltersAndPagination();
}
// Recharger les données
refreshData() {
this.loadPayments();
if (this.subscriptionId) {
this.loadSubscriptionDetails();
}
}
// Méthodes pour le template
getCardTitle(): string {
if (this.subscriptionId && this.subscriptionDetails) {
return `Paiements - Abonnement #${this.subscriptionDetails.id}`;
}
return 'Historique des Paiements';
}
getHelperText(): string {
if (this.subscriptionId) {
return 'Historique des paiements pour cet abonnement';
}
return 'Vue globale de tous les paiements';
}
getHelperIcon(): string {
return 'lucideCreditCard';
}
getLoadingText(): string {
return 'Chargement des paiements...';
}
getEmptyStateTitle(): string {
return 'Aucun paiement trouvé';
}
getEmptyStateDescription(): string {
return 'Aucun paiement ne correspond à vos critères de recherche.';
}
getColumnCount(): number {
let count = 5; // Référence, Date, Montant, Statut, Actions
if (this.showSubscriptionColumn) count++;
return count;
}
// Méthodes pour les résumés
getPaymentsSummary(): { total: number; success: number; failed: number; pending: number; amount: number } {
return {
total: this.getTotalPaymentsCount(),
success: this.getSuccessfulPaymentsCount(),
failed: this.getFailedPaymentsCount(),
pending: this.getPendingPaymentsCount(),
amount: this.getTotalAmount()
};
}
// Formatage pour l'affichage
truncateText(text: string, maxLength: number = 20): string {
if (!text) return '';
return text.length > maxLength ? text.substring(0, maxLength) + '...' : text;
}
getInternalReference(payment: SubscriptionPayment): string {
return payment.metadata?.internatRef || 'N/A';
}
}

View File

@ -13,8 +13,8 @@ import {
Currency
} from '@core/models/dcb-bo-hub-subscription.model';
import { SubscriptionsService } from '../subscriptions.service';
import { AuthService } from '@core/services/auth.service';
import { SubscriptionsService } from '../services/subscriptions.service';
import { SubscriptionAccessService, SubscriptionAccess } from '../services/subscription-access.service';
@Component({
selector: 'app-subscription-payments',
@ -33,7 +33,7 @@ import { AuthService } from '@core/services/auth.service';
})
export class SubscriptionPayments implements OnInit, OnDestroy {
private subscriptionsService = inject(SubscriptionsService);
private authService = inject(AuthService);
private accessService = inject(SubscriptionAccessService); // CHANGÉ
private cdRef = inject(ChangeDetectorRef);
private destroy$ = new Subject<void>();
@ -51,8 +51,9 @@ export class SubscriptionPayments implements OnInit, OnDestroy {
error = '';
success = '';
// Gestion des permissions
currentUserRole: string | null = null;
// Gestion des permissions MIS À JOUR
access!: SubscriptionAccess;
accessDenied = false;
merchantPartnerId: number | undefined;
@ -62,7 +63,7 @@ export class SubscriptionPayments implements OnInit, OnDestroy {
ngOnInit() {
if (this.subscriptionId) {
this.loadCurrentUserPermissions();
this.initializePermissions(); // NOUVELLE MÉTHODE
this.loadSubscriptionDetails();
}
}
@ -73,49 +74,49 @@ export class SubscriptionPayments implements OnInit, OnDestroy {
}
/**
* Charge les permissions de l'utilisateur courant
* Initialise les permissions
*/
private loadCurrentUserPermissions(): void {
this.authService.getUserProfile()
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (profile) => {
this.currentUserRole = this.authService.getCurrentUserRole();
this.cdRef.detectChanges();
},
error: (error) => {
console.error('Error loading user permissions:', error);
}
});
private initializePermissions(): void {
this.access = this.accessService.getSubscriptionAccess();
}
/**
* Charge les détails de l'abonnement puis les paiements
*/
loadSubscriptionDetails() {
if (!this.access.canViewPayments) {
this.error = 'Vous n\'avez pas la permission de voir les paiements';
this.accessDenied = true;
this.cdRef.detectChanges();
return;
}
this.loading = true;
this.error = '';
this.accessDenied = false;
this.subscriptionsService.getSubscriptionById(this.subscriptionId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (subscription) => {
console.log('Subscription loaded:', {
id: subscription.id,
merchantPartnerId: subscription.merchantPartnerId,
hasMerchantId: !!subscription.merchantPartnerId
});
this.merchantPartnerId = subscription.merchantPartnerId,
console.log("loadSubscriptionDetails " + this.merchantPartnerId);
// Vérifier l'accès à cet abonnement spécifique
this.accessService.canAccessSubscription(subscription.merchantPartnerId)
.subscribe(canAccess => {
if (!canAccess) {
this.error = 'Vous n\'avez pas accès aux paiements de cet abonnement';
this.accessDenied = true;
this.loading = false;
this.cdRef.detectChanges();
return;
}
this.merchantPartnerId = subscription.merchantPartnerId;
this.subscription = subscription;
this.loading = false;
this.cdRef.detectChanges();
// Passer explicitement le merchantPartnerId à loadPayments
this.loadPayments(subscription.merchantPartnerId);
});
},
error: (error) => {
this.error = 'Erreur lors du chargement des détails de l\'abonnement';
@ -132,12 +133,8 @@ export class SubscriptionPayments implements OnInit, OnDestroy {
loadPayments(merchantPartnerId: number | undefined) {
this.loadingPayments = true;
console.log("loadPayments " + merchantPartnerId);
// Utiliser le merchantPartnerId passé en paramètre ou celui de l'abonnement
const merchantId = merchantPartnerId;
// Vérifier que nous avons les IDs nécessaires
if (!merchantId) {
console.error('MerchantPartnerId manquant pour charger les paiements');
this.error = 'Impossible de charger les paiements : Merchant ID manquant';
@ -146,12 +143,6 @@ export class SubscriptionPayments implements OnInit, OnDestroy {
return;
}
console.log('Loading payments with:', {
merchantId: merchantId,
subscriptionId: this.subscriptionId,
merchantIdType: typeof merchantId
});
this.subscriptionsService.getSubscriptionPayments(merchantId, this.subscriptionId)
.pipe(takeUntil(this.destroy$))
.subscribe({
@ -163,7 +154,6 @@ export class SubscriptionPayments implements OnInit, OnDestroy {
error: (error) => {
console.error('Error loading subscription payments:', error);
// Message d'erreur plus spécifique
if (error.status === 400) {
this.error = 'Données invalides pour charger les paiements';
} else if (error.status === 404) {
@ -177,11 +167,12 @@ export class SubscriptionPayments implements OnInit, OnDestroy {
}
});
}
/**
* Applique les filtres aux paiements
*/
applyFilters() {
this.loadPayments(this.merchantPartnerId); // Recharger avec les filtres actuels
this.loadPayments(this.merchantPartnerId);
}
/**
@ -420,20 +411,16 @@ export class SubscriptionPayments implements OnInit, OnDestroy {
return colors[health];
}
// Gestion des erreurs
private getErrorMessage(error: any): string {
if (error.error?.message) {
return error.error.message;
// NOUVELLES MÉTHODES POUR LE TEMPLATE
getUserBadgeClass(): string {
return this.access.isHubUser ? 'bg-primary' : 'bg-success';
}
if (error.status === 400) {
return 'Données invalides.';
getUserBadgeIcon(): string {
return this.access.isHubUser ? 'lucideShield' : 'lucideStore';
}
if (error.status === 403) {
return 'Vous n\'avez pas les permissions pour accéder à ces informations.';
}
if (error.status === 404) {
return 'Abonnement ou paiements non trouvés.';
}
return 'Erreur lors de l\'opération. Veuillez réessayer.';
getUserBadgeText(): string {
return this.access.isHubUser ? 'Hub User' : 'Merchant User';
}
}

View File

@ -1,72 +1,54 @@
<app-ui-card [title]="getCardTitle()">
<a
helper-text
href="javascript:void(0);"
class="icon-link icon-link-hover link-primary fw-semibold"
>
<ng-icon [name]="getHelperIcon()" class="me-1"></ng-icon>
{{ getHelperText() }}
</a>
<div card-body>
<!-- Barre d'actions supérieure -->
<div class="row mb-3">
<div class="col-md-6">
<div class="transactions-container">
<!-- En-tête avec actions -->
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<div>
<h4 class="mb-1">Gestion des Abonnements</h4>
<div class="d-flex align-items-center gap-2">
<!-- Statistiques rapides -->
<div class="btn-group btn-group-sm">
<button
type="button"
class="btn btn-outline-primary"
[class.active]="statusFilter === 'all'"
(click)="filterByStatus('all')"
>
Tous ({{ getTotalSubscriptionsCount() }})
</button>
<button
type="button"
class="btn btn-outline-success"
[class.active]="statusFilter === getActiveStatus()"
(click)="filterByStatus(getActiveStatus())"
>
Actifs ({{ getActiveSubscriptionsCount() }})
</button>
<button
type="button"
class="btn btn-outline-warning text-dark"
[class.active]="statusFilter === getSuspendedStatus()"
(click)="filterByStatus(getSuspendedStatus())"
>
Suspendus ({{ getSuspendedSubscriptionsCount() }})
</button>
<button
type="button"
class="btn btn-outline-danger"
[class.active]="statusFilter === getCancelledStatus()"
(click)="filterByStatus(getCancelledStatus())"
>
Annulés ({{ getCancelledSubscriptionsCount() }})
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item">
<a href="javascript:void(0)" class="text-decoration-none">DCB Abonnements</a>
</li>
</ol>
</nav>
<span [class]="getUserBadgeClass()" class="badge">
<ng-icon [name]="getUserBadgeIcon()" class="me-1"></ng-icon>
{{ getUserBadgeText() }}
</span>
@if (currentMerchantId) {
<span class="badge bg-info">
<ng-icon name="lucideStore" class="me-1"></ng-icon>
Merchant {{ currentMerchantId }}
</span>
}
</div>
</div>
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary" (click)="loadAllSubscriptions()" [disabled]="loading">
<ng-icon name="lucideRefreshCw" [class.spin]="loading"></ng-icon>
</button>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="d-flex justify-content-end gap-2">
<button
class="btn btn-outline-secondary"
(click)="refreshData()"
[disabled]="loading"
>
<ng-icon name="lucideRefreshCw" class="me-1" [class.spin]="loading"></ng-icon>
Actualiser
</button>
<!-- Message d'accès refusé -->
@if (!access.canViewSubscriptions) {
<div class="alert alert-danger">
<div class="d-flex align-items-center">
<ng-icon name="lucideLock" class="me-2"></ng-icon>
<div>
<strong>Accès refusé</strong>
<p class="mb-0">Vous n'avez pas les permissions nécessaires pour accéder à cette section.</p>
</div>
</div>
</div>
} @else {
<!-- Barre de recherche et filtres avancés -->
<!-- Barre de recherche et filtres -->
<div class="row mb-3">
<div class="col-md-4">
<div class="input-group">
@ -78,84 +60,73 @@
class="form-control"
placeholder="Rechercher par ID, token..."
[(ngModel)]="searchTerm"
(input)="onSearch()"
[disabled]="loading"
(keyup.enter)="onSearch()"
>
</div>
</div>
<div class="col-md-2">
<select class="form-select" [(ngModel)]="statusFilter" (change)="applyFiltersAndPagination()">
@for (status of availableStatuses; track status.value) {
<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)">
@for (status of statusOptions; track status.value) {
<option [value]="status.value">{{ status.label }}</option>
}
</select>
</div>
<div class="col-md-2">
<select class="form-select" [(ngModel)]="periodicityFilter" (change)="applyFiltersAndPagination()">
@for (periodicity of availablePeriodicities; track periodicity.value) {
<!-- Filtre périodicité -->
<select class="form-select" style="width: auto;" (change)="onPeriodicityFilterChange($any($event.target).value)">
@for (periodicity of periodicityOptions; track periodicity.value) {
<option [value]="periodicity.value">{{ periodicity.label }}</option>
}
</select>
</div>
<div class="col-md-2">
<select class="form-select" [(ngModel)]="merchantFilter" (change)="applyFiltersAndPagination()">
<option value="all">Tous les merchants</option>
<!-- Les options merchants seraient dynamiquement chargées si nécessaire -->
</select>
</div>
<button class="btn btn-outline-primary" (click)="onSearch()">
<ng-icon name="lucideFilter" class="me-1"></ng-icon>
Filtrer
</button>
<div class="col-md-2">
<button class="btn btn-outline-secondary w-100" (click)="onClearFilters()" [disabled]="loading">
<button class="btn btn-outline-secondary" (click)="onClearFilters()">
<ng-icon name="lucideX" class="me-1"></ng-icon>
Effacer
</button>
</div>
</div>
</div>
<!-- Loading State -->
<!-- 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">{{ getLoadingText() }}</p>
<p class="mt-2 text-muted">Chargement des abonnements...</p>
</div>
}
<!-- Error State -->
@if (error && !loading) {
<div class="alert alert-danger" role="alert">
<div class="d-flex align-items-center">
<ng-icon name="lucideAlertCircle" class="me-2"></ng-icon>
<div>{{ error }}</div>
<button class="btn-close ms-auto" (click)="error = ''"></button>
</div>
</div>
}
<!-- Subscriptions Table -->
@if (!loading && !error) {
<!-- Tableau des abonnements -->
@if (!loading) {
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover table-striped">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<!-- Colonne Merchant pour les admins -->
@if (showMerchantColumn) {
<th>Merchant</th>
}
<!-- Colonne Customer pour les admins -->
@if (showCustomerColumn) {
<th>Client</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>Merchant</th>
<th (click)="sort('amount')" class="cursor-pointer">
<div class="d-flex align-items-center">
<span>Montant</span>
@ -163,152 +134,89 @@
</div>
</th>
<th>Périodicité</th>
<th (click)="sort('status')" class="cursor-pointer">
<div class="d-flex align-items-center">
<span>Statut</span>
<ng-icon [name]="getSortIcon('status')" class="ms-1 fs-12"></ng-icon>
</div>
</th>
<th>Statut</th>
<th (click)="sort('startDate')" class="cursor-pointer">
<div class="d-flex align-items-center">
<span>Date début</span>
<ng-icon [name]="getSortIcon('startDate')" class="ms-1 fs-12"></ng-icon>
</div>
</th>
<th width="120">Actions</th>
<th>Prochain paiement</th>
<th width="80">Actions</th>
</tr>
</thead>
<tbody>
@for (subscription of displayedSubscriptions; track subscription.id) {
@for (subscription of subscriptions; track subscription.id) {
<tr>
<!-- Colonne Merchant pour les admins -->
@if (showMerchantColumn) {
<td class="font-monospace small">{{ subscription.id }}</td>
<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">
#{{ subscription.merchantPartnerId }}
</small>
</div>
</div>
</td>
}
<!-- Colonne Customer pour les admins -->
@if (showCustomerColumn) {
<td>
<div class="d-flex align-items-center">
<div class="avatar-sm bg-info bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2">
<ng-icon name="lucideUser" class="text-info fs-12"></ng-icon>
</div>
<div>
<small class="text-muted font-monospace">
#{{ subscription.customerId }}
</small>
</div>
</div>
</td>
}
<td>
<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">
<ng-icon name="lucideRepeat" class="text-primary fs-12"></ng-icon>
</div>
<div>
<strong class="d-block">#{{ subscription.id }}</strong>
<small class="text-muted" [title]="subscription.token">
{{ subscription.token.substring(0, 12) }}...
</small>
</div>
</div>
<span class="badge bg-secondary">
Merchant {{ subscription.merchantPartnerId }}
</span>
</td>
<td>
<strong class="d-block">{{ formatAmount(subscription.amount, subscription.currency) }}</strong>
<small class="text-muted">{{ subscription.currency }}</small>
<span class="text-success">
{{ formatCurrency(subscription.amount, subscription.currency) }}
</span>
</td>
<td>
<span class="badge bg-light text-dark d-flex align-items-center">
<ng-icon name="lucideCalendar" class="me-1" size="14"></ng-icon>
<span [class]="getPeriodicityBadgeClass(subscription.periodicity)" class="badge">
{{ getPeriodicityDisplayName(subscription.periodicity) }}
</span>
</td>
<td>
<span class="badge d-flex align-items-center" [ngClass]="getStatusBadgeClass(subscription.status)">
<ng-icon
[name]="subscription.status === SubscriptionStatus.ACTIVE ? 'lucideCheckCircle' :
subscription.status === SubscriptionStatus.SUSPENDED ? 'lucidePauseCircle' :
subscription.status === SubscriptionStatus.CANCELLED ? 'lucideXCircle' : 'lucideClock'"
class="me-1"
size="14"
></ng-icon>
<span [class]="getStatusBadgeClass(subscription.status)" class="badge">
<ng-icon [name]="getStatusIcon(subscription.status)" class="me-1"></ng-icon>
{{ getStatusDisplayName(subscription.status) }}
</span>
@if (isExpiringSoon(subscription)) {
<div class="mt-1">
<small class="text-warning">
<ng-icon name="lucideAlertTriangle" class="me-1" size="12"></ng-icon>
Expire bientôt
</small>
</div>
}
</td>
<td>
<div>
<small class="text-muted d-block">
Début: {{ formatDate(subscription.startDate) }}
</small>
<small class="text-muted d-block">
Prochain: {{ formatDate(subscription.nextPaymentDate) }}
</small>
@if (subscription.endDate) {
<small class="text-muted d-block">
Fin: {{ formatDate(subscription.endDate) }}
</small>
<td class="small text-muted">
{{ formatDate(subscription.startDate) }}
</td>
<td class="small text-muted">
@if (subscription.nextPaymentDate) {
{{ formatDate(subscription.nextPaymentDate) }}
} @else {
-
}
</div>
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<button
class="btn btn-outline-primary btn-sm"
class="btn btn-outline-primary"
(click)="viewSubscriptionDetails(subscription.id)"
title="Voir les détails"
ngbTooltip="Voir les détails"
>
<ng-icon name="lucideEye"></ng-icon>
</button>
<button
class="btn btn-outline-info btn-sm"
(click)="viewSubscriptionPayments(subscription.id)"
title="Voir les paiements"
>
<ng-icon name="lucideCreditCard"></ng-icon>
</button>
</div>
</td>
</tr>
}
@empty {
<tr>
<td [attr.colspan]="getColumnCount()" class="text-center py-4">
<div class="text-muted">
<ng-icon name="lucideRepeat" class="fs-1 mb-3 opacity-50"></ng-icon>
<h5 class="mb-2">{{ getEmptyStateTitle() }}</h5>
<p class="mb-3">{{ getEmptyStateDescription() }}</p>
</div>
<td colspan="8" class="text-center py-4">
<ng-icon name="lucideRepeat" class="text-muted fs-1 mb-2 d-block"></ng-icon>
<p class="text-muted mb-3">Aucun abonnement trouvé</p>
<button class="btn btn-primary" (click)="onClearFilters()">
Réinitialiser les filtres
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
<!-- Pagination -->
@if (totalPages > 1) {
<div class="d-flex justify-content-between align-items-center mt-3">
<div class="text-muted">
Affichage de {{ getStartIndex() }} à {{ getEndIndex() }} sur {{ totalItems }} abonnements
Affichage de {{ (currentPage - 1) * itemsPerPage + 1 }} à
{{ (currentPage * itemsPerPage) > totalItems ? totalItems : (currentPage * itemsPerPage) }}
sur {{ totalItems }} abonnements
</div>
<nav>
<ngb-pagination
@ -323,39 +231,6 @@
</nav>
</div>
}
<!-- Résumé des résultats -->
@if (displayedSubscriptions.length > 0) {
<div class="mt-3 pt-3 border-top">
<div class="row text-center">
<div class="col">
<small class="text-muted">
<strong>Total :</strong> {{ getTotalSubscriptionsCount() }} abonnements
</small>
</div>
<div class="col">
<small class="text-muted">
<strong>Actifs :</strong> {{ getActiveSubscriptionsCount() }}
</small>
</div>
<div class="col">
<small class="text-muted">
<strong>Revenu total :</strong> {{ formatAmount(getTotalRevenue(), Currency.XOF) }}
</small>
</div>
<div class="col">
<small class="text-muted">
<strong>Quotidiens :</strong> {{ getDailySubscriptionsCount() }}
</small>
</div>
<div class="col">
<small class="text-muted">
<strong>Mensuels :</strong> {{ getMonthlySubscriptionsCount() }}
</small>
</div>
</div>
</div>
}
}
</div>
</app-ui-card>
</div>

View File

@ -1,23 +1,19 @@
import { Component, inject, OnInit, Output, EventEmitter, ChangeDetectorRef, Input, OnDestroy } from '@angular/core';
import { Component, inject, OnInit, Output, EventEmitter, ChangeDetectorRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { NgIcon } from '@ng-icons/core';
import { NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap';
import { Observable, Subject, map, of } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators';
import { NgbPaginationModule, NgbDropdownModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
import { SubscriptionsService } from '../services/subscriptions.service';
import { SubscriptionAccessService, SubscriptionAccess } from '../services/subscription-access.service';
import {
Subscription,
SubscriptionsResponse,
SubscriptionStatus,
SubscriptionPeriodicity,
Currency
} from '@core/models/dcb-bo-hub-subscription.model';
import { SubscriptionsService } from '../subscriptions.service';
import { AuthService } from '@core/services/auth.service';
import { UiCard } from '@app/components/ui-card';
@Component({
selector: 'app-subscriptions-list',
standalone: true,
@ -25,53 +21,50 @@ import { UiCard } from '@app/components/ui-card';
CommonModule,
FormsModule,
NgIcon,
UiCard,
NgbPaginationModule
NgbPaginationModule,
NgbDropdownModule,
NgbTooltipModule
],
templateUrl: './subscriptions-list.html',
templateUrl: './subscriptions-list.html'
})
export class SubscriptionsList implements OnInit, OnDestroy {
private authService = inject(AuthService);
export class SubscriptionsList implements OnInit {
private subscriptionsService = inject(SubscriptionsService);
private accessService = inject(SubscriptionAccessService);
private cdRef = inject(ChangeDetectorRef);
private destroy$ = new Subject<void>();
// Configuration
readonly SubscriptionStatus = SubscriptionStatus;
readonly SubscriptionPeriodicity = SubscriptionPeriodicity;
readonly Currency = Currency;
// Outputs
@Output() subscriptionSelected = new EventEmitter<string>();
@Output() viewPaymentsRequested = new EventEmitter<string>();
// Permissions
access!: SubscriptionAccess;
currentUserRole = '';
currentMerchantId: number = 0;
// Données
allSubscriptions: Subscription[] = [];
filteredSubscriptions: Subscription[] = [];
displayedSubscriptions: Subscription[] = [];
subscriptions: Subscription[] = [];
allSubscriptions: Subscription[] = []; // Stocker toutes les données pour filtrage client
// États
loading = false;
error = '';
// Recherche et filtres
// Filtres et recherche
searchTerm = '';
statusFilter: SubscriptionStatus | 'all' = 'all';
periodicityFilter: SubscriptionPeriodicity | 'all' = 'all';
merchantFilter: number | 'all' = 'all';
// Pagination
currentPage = 1;
itemsPerPage = 10;
itemsPerPage = 20;
totalItems = 0;
totalPages = 0;
// Tri
sortField: keyof Subscription = 'startDate';
sortField: string = 'startDate';
sortDirection: 'asc' | 'desc' = 'desc';
// Filtres disponibles
availableStatuses: { value: SubscriptionStatus | 'all'; label: string }[] = [
// Options de filtre
statusOptions = [
{ value: 'all', label: 'Tous les statuts' },
{ value: SubscriptionStatus.ACTIVE, label: 'Actif' },
{ value: SubscriptionStatus.SUSPENDED, label: 'Suspendu' },
@ -80,7 +73,7 @@ export class SubscriptionsList implements OnInit, OnDestroy {
{ value: SubscriptionStatus.PENDING, label: 'En attente' }
];
availablePeriodicities: { value: SubscriptionPeriodicity | 'all'; label: string }[] = [
periodicityOptions = [
{ value: 'all', label: 'Toutes les périodicités' },
{ value: SubscriptionPeriodicity.DAILY, label: 'Quotidien' },
{ value: SubscriptionPeriodicity.WEEKLY, label: 'Hebdomadaire' },
@ -88,203 +81,178 @@ export class SubscriptionsList implements OnInit, OnDestroy {
{ value: SubscriptionPeriodicity.YEARLY, label: 'Annuel' }
];
// ID du merchant partner courant et permissions
currentMerchantPartnerId: string = '';
currentUserRole: string | null = null;
canViewAllMerchants = false;
// Merchants disponibles pour le filtre
availableMerchants: { value: number | 'all'; label: string }[] = [];
ngOnInit() {
this.loadCurrentUserPermissions();
this.initializePermissions();
this.loadAllSubscriptions();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
private initializePermissions() {
this.access = this.accessService.getSubscriptionAccess();
this.currentUserRole = this.access.userRoleLabel;
this.currentMerchantId = this.access.merchantId || 0;
if (!this.access.canViewSubscriptions) {
this.error = 'Vous n\'avez pas la permission de voir les abonnements';
return;
}
private loadCurrentUserPermissions() {
this.authService.getUserProfile()
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (user) => {
this.currentUserRole = this.extractUserRole(user);
this.canViewAllMerchants = this.canViewAllMerchantsCheck(this.currentUserRole);
console.log('Subscriptions Context Loaded:', {
role: this.currentUserRole,
merchantPartnerId: this.currentMerchantPartnerId,
canViewAllMerchants: this.canViewAllMerchants
});
this.loadSubscriptions();
},
error: (error) => {
console.error('Error loading current user permissions:', error);
this.fallbackPermissions();
this.loadSubscriptions();
if (!this.access.isHubUser && this.currentMerchantId === 0) {
this.error = 'Merchant ID non disponible';
return;
}
});
}
private extractUserRole(user: any): string | null {
const userRoles = this.authService.getCurrentUserRoles();
if (userRoles && userRoles.length > 0) {
return userRoles[0];
}
return null;
}
loadAllSubscriptions() {
if (!this.access.canViewSubscriptions) return;
private canViewAllMerchantsCheck(role: string | null): boolean {
if (!role) return false;
const canViewAllRoles = [
'DCB_ADMIN',
'DCB_SUPPORT',
'DCB_PARTNER_ADMIN'
];
return canViewAllRoles.includes(role);
}
private fallbackPermissions(): void {
this.currentUserRole = this.authService.getCurrentUserRole();
this.canViewAllMerchants = this.canViewAllMerchantsCheck(this.currentUserRole);
}
loadSubscriptions() {
this.loading = true;
this.error = '';
let subscriptionsObservable: Observable<SubscriptionsResponse>;
// Déterminer quelle API appeler
let subscriptionObservable;
if (this.canViewAllMerchants) {
// Admin/Support - tous les abonnements
subscriptionsObservable = this.subscriptionsService.getSubscriptions({
page: this.currentPage,
limit: this.itemsPerPage
});
} else if (this.currentMerchantPartnerId) {
// Merchant régulier - ses abonnements
subscriptionsObservable = this.subscriptionsService.getSubscriptionsByMerchant(
parseInt(this.currentMerchantPartnerId),
{ page: this.currentPage, limit: this.itemsPerPage }
);
if (this.access.canViewAllSubscriptions) {
subscriptionObservable = this.subscriptionsService.getSubscriptions({});
} else if (this.currentMerchantId > 0) {
subscriptionObservable = this.subscriptionsService.getSubscriptionsByMerchant(this.currentMerchantId, {});
} else {
// Fallback - abonnements généraux
subscriptionsObservable = this.subscriptionsService.getSubscriptions({
page: this.currentPage,
limit: this.itemsPerPage
});
this.error = 'Configuration invalide';
this.loading = false;
return;
}
subscriptionsObservable
.pipe(
takeUntil(this.destroy$),
catchError(error => {
console.error('Error loading subscriptions:', error);
this.error = 'Erreur lors du chargement des abonnements';
return of({ subscriptions: [], statistics: { total: 0, active: 0, totalRevenue: 0, averageAmount: 0 } } as SubscriptionsResponse);
})
)
.subscribe({
subscriptionObservable.subscribe({
next: (response) => {
this.allSubscriptions = response.subscriptions || [];
console.log(`✅ Loaded ${this.allSubscriptions.length} subscriptions`);
this.applyFiltersAndPagination();
this.loading = false;
this.cdRef.detectChanges();
},
error: () => {
error: (error) => {
this.error = 'Erreur lors du chargement des abonnements';
this.loading = false;
this.allSubscriptions = [];
this.filteredSubscriptions = [];
this.displayedSubscriptions = [];
this.cdRef.detectChanges();
}
});
}
private applyFiltersAndPagination() {
// 1. Appliquer les filtres
let filteredSubscriptions = this.filterSubscriptions(this.allSubscriptions);
// 2. Appliquer le tri
filteredSubscriptions = this.sortSubscriptions(filteredSubscriptions);
// 3. Calculer la pagination
this.totalItems = filteredSubscriptions.length;
this.totalPages = Math.ceil(this.totalItems / this.itemsPerPage);
// Ajuster la page courante si nécessaire
if (this.currentPage > this.totalPages) {
this.currentPage = 1;
}
// 4. Extraire les éléments de la page courante
const startIndex = (this.currentPage - 1) * this.itemsPerPage;
const endIndex = Math.min(startIndex + this.itemsPerPage, this.totalItems);
this.subscriptions = filteredSubscriptions.slice(startIndex, endIndex);
}
private filterSubscriptions(subscriptions: Subscription[]): Subscription[] {
let filtered = subscriptions;
// Filtre par statut
if (this.statusFilter !== 'all') {
filtered = filtered.filter(sub => sub.status === this.statusFilter);
}
// Filtre par périodicité
if (this.periodicityFilter !== 'all') {
filtered = filtered.filter(sub => sub.periodicity === this.periodicityFilter);
}
// Filtre par recherche
if (this.searchTerm.trim()) {
const term = this.searchTerm.toLowerCase().trim();
filtered = filtered.filter(sub =>
(sub.startDate && sub.startDate.toLowerCase().includes(term)) ||
(sub.endDate && sub.endDate.toLowerCase().includes(term)) ||
(sub.amount && sub.amount.toString().toLowerCase().includes(term)) ||
(sub.periodicity && sub.periodicity.toLowerCase().includes(term)) ||
(sub.status && sub.status.toLowerCase().includes(term))
);
}
return filtered;
}
private sortSubscriptions(subscriptions: Subscription[]): Subscription[] {
return [...subscriptions].sort((a: any, b: any) => {
const aValue = a[this.sortField];
const bValue = b[this.sortField];
if (aValue == null && bValue == null) return 0;
if (aValue == null) return 1;
if (bValue == null) return -1;
let comparison = 0;
if (typeof aValue === 'string' && typeof bValue === 'string') {
comparison = aValue.localeCompare(bValue);
} else if (typeof aValue === 'number' && typeof bValue === 'number') {
comparison = aValue - bValue;
} else {
const aDate = new Date(aValue);
const bDate = new Date(bValue);
if (!isNaN(aDate.getTime()) && !isNaN(bDate.getTime())) {
comparison = aDate.getTime() - bDate.getTime();
}
}
return this.sortDirection === 'asc' ? comparison : -comparison;
});
}
// Recherche et filtres
onSearch() {
this.currentPage = 1;
this.applyFiltersAndPagination();
this.cdRef.detectChanges();
}
onClearFilters() {
this.searchTerm = '';
this.statusFilter = 'all';
this.periodicityFilter = 'all';
this.merchantFilter = 'all';
this.currentPage = 1;
this.applyFiltersAndPagination();
this.cdRef.detectChanges();
}
applyFiltersAndPagination() {
if (!this.allSubscriptions) {
this.allSubscriptions = [];
onStatusFilterChange(status: SubscriptionStatus | 'all') {
this.statusFilter = status;
this.currentPage = 1;
this.applyFiltersAndPagination();
this.cdRef.detectChanges();
}
// Appliquer les filtres
this.filteredSubscriptions = this.allSubscriptions.filter(subscription => {
const matchesSearch = !this.searchTerm ||
subscription.id.toString().includes(this.searchTerm) ||
subscription.token.toLowerCase().includes(this.searchTerm.toLowerCase()) ||
(subscription.externalReference && subscription.externalReference.toLowerCase().includes(this.searchTerm.toLowerCase()));
const matchesStatus = this.statusFilter === 'all' ||
subscription.status === this.statusFilter;
const matchesPeriodicity = this.periodicityFilter === 'all' ||
subscription.periodicity === this.periodicityFilter;
const matchesMerchant = this.merchantFilter === 'all' ||
subscription.merchantPartnerId === this.merchantFilter;
return matchesSearch && matchesStatus && matchesPeriodicity && matchesMerchant;
});
// Appliquer le tri
this.filteredSubscriptions.sort((a, b) => {
const aValue = a[this.sortField];
const bValue = b[this.sortField];
if (aValue === bValue) return 0;
let comparison = 0;
if (typeof aValue === 'string' && typeof bValue === 'string') {
comparison = aValue.localeCompare(bValue);
} else if (typeof aValue === 'number' && typeof bValue === 'number') {
comparison = aValue - bValue;
} else if (aValue instanceof Date && bValue instanceof Date) {
comparison = aValue.getTime() - bValue.getTime();
}
return this.sortDirection === 'asc' ? comparison : -comparison;
});
// Calculer la pagination
this.totalItems = this.filteredSubscriptions.length;
this.totalPages = Math.ceil(this.totalItems / this.itemsPerPage);
// Appliquer la pagination
const startIndex = (this.currentPage - 1) * this.itemsPerPage;
const endIndex = startIndex + this.itemsPerPage;
this.displayedSubscriptions = this.filteredSubscriptions.slice(startIndex, endIndex);
onPeriodicityFilterChange(periodicity: SubscriptionPeriodicity | 'all') {
this.periodicityFilter = periodicity;
this.currentPage = 1;
this.applyFiltersAndPagination();
this.cdRef.detectChanges();
}
// Tri
sort(field: keyof Subscription) {
sort(field: string) {
if (this.sortField === field) {
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
} else {
this.sortField = field;
this.sortDirection = 'asc';
this.sortDirection = 'desc';
}
this.applyFiltersAndPagination();
this.cdRef.detectChanges();
}
getSortIcon(field: string): string {
@ -296,244 +264,131 @@ export class SubscriptionsList implements OnInit, OnDestroy {
onPageChange(page: number) {
this.currentPage = page;
this.applyFiltersAndPagination();
}
getStartIndex(): number {
return (this.currentPage - 1) * this.itemsPerPage + 1;
}
getEndIndex(): number {
return Math.min(this.currentPage * this.itemsPerPage, this.totalItems);
this.cdRef.detectChanges();
}
// Actions
viewSubscriptionDetails(subscriptionId: string | number) {
this.subscriptionSelected.emit(subscriptionId.toString());
viewSubscriptionDetails(subscriptionId: string) {
if (!subscriptionId) return;
this.subscriptionSelected.emit(subscriptionId);
}
viewSubscriptionPayments(subscriptionId: string | number) {
this.viewPaymentsRequested.emit(subscriptionId.toString());
viewSubscriptionPayments(subscriptionId: string) {
if (!subscriptionId) return;
this.accessService.canAccessSubscription(Number(subscriptionId)).subscribe(canAccess => {
if (canAccess) {
this.viewPaymentsRequested.emit(subscriptionId);
} else {
this.error = 'Vous n\'avez pas la permission de voir les paiements de cet abonnement';
this.cdRef.detectChanges();
}
});
}
// Utilitaires d'affichage
getStatusBadgeClass(status: SubscriptionStatus): string {
const statusClasses = {
[SubscriptionStatus.ACTIVE]: 'badge bg-success',
[SubscriptionStatus.SUSPENDED]: 'badge bg-warning',
[SubscriptionStatus.CANCELLED]: 'badge bg-danger',
[SubscriptionStatus.EXPIRED]: 'badge bg-secondary',
[SubscriptionStatus.PENDING]: 'badge bg-info'
};
return statusClasses[status] || 'badge bg-secondary';
switch (status) {
case SubscriptionStatus.ACTIVE: return 'badge bg-success';
case SubscriptionStatus.SUSPENDED: return 'badge bg-warning';
case SubscriptionStatus.CANCELLED: return 'badge bg-danger';
case SubscriptionStatus.EXPIRED: return 'badge bg-secondary';
case SubscriptionStatus.PENDING: return 'badge bg-info';
default: return 'badge bg-secondary';
}
}
getStatusDisplayName(status: SubscriptionStatus): string {
const statusNames = {
[SubscriptionStatus.ACTIVE]: 'Actif',
[SubscriptionStatus.SUSPENDED]: 'Suspendu',
[SubscriptionStatus.CANCELLED]: 'Annulé',
[SubscriptionStatus.EXPIRED]: 'Expiré',
[SubscriptionStatus.PENDING]: 'En attente'
};
return statusNames[status] || status;
getStatusIcon(status: SubscriptionStatus): string {
switch (status) {
case SubscriptionStatus.ACTIVE: return 'lucideCheckCircle';
case SubscriptionStatus.SUSPENDED: return 'lucidePauseCircle';
case SubscriptionStatus.CANCELLED: return 'lucideXCircle';
case SubscriptionStatus.EXPIRED: return 'lucideCalendarOff';
case SubscriptionStatus.PENDING: return 'lucideClock';
default: return 'lucideClock';
}
}
getPeriodicityDisplayName(periodicity: SubscriptionPeriodicity): string {
const periodicityNames = {
[SubscriptionPeriodicity.DAILY]: 'Quotidien',
[SubscriptionPeriodicity.WEEKLY]: 'Hebdomadaire',
[SubscriptionPeriodicity.MONTHLY]: 'Mensuel',
[SubscriptionPeriodicity.YEARLY]: 'Annuel'
};
return periodicityNames[periodicity] || periodicity;
getPeriodicityBadgeClass(periodicity: SubscriptionPeriodicity): string {
switch (periodicity) {
case SubscriptionPeriodicity.DAILY: return 'badge bg-primary';
case SubscriptionPeriodicity.WEEKLY: return 'badge bg-info';
case SubscriptionPeriodicity.MONTHLY: return 'badge bg-success';
case SubscriptionPeriodicity.YEARLY: return 'badge bg-warning';
default: return 'badge bg-secondary';
}
}
// Méthodes pour compter les abonnements par périodicité
getDailySubscriptionsCount(): number {
if (!this.allSubscriptions || this.allSubscriptions.length === 0) return 0;
return this.allSubscriptions.filter(s => s.periodicity === SubscriptionPeriodicity.DAILY).length;
}
getWeeklySubscriptionsCount(): number {
if (!this.allSubscriptions || this.allSubscriptions.length === 0) return 0;
return this.allSubscriptions.filter(s => s.periodicity === SubscriptionPeriodicity.WEEKLY).length;
}
getMonthlySubscriptionsCount(): number {
if (!this.allSubscriptions || this.allSubscriptions.length === 0) return 0;
return this.allSubscriptions.filter(s => s.periodicity === SubscriptionPeriodicity.MONTHLY).length;
}
getYearlySubscriptionsCount(): number {
if (!this.allSubscriptions || this.allSubscriptions.length === 0) return 0;
return this.allSubscriptions.filter(s => s.periodicity === SubscriptionPeriodicity.YEARLY).length;
}
formatAmount(amount: number, currency: Currency): string {
formatCurrency(amount: number, currency: Currency = Currency.XOF): string {
if (isNaN(amount)) return '-';
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: currency
}).format(amount);
}
formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString('fr-FR', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
formatDate(date: string | Date): string {
if (!date) return '-';
const dateObj = date instanceof Date ? date : new Date(date);
if (isNaN(dateObj.getTime())) {
return 'Date invalide';
}
formatDateTime(dateString: string): string {
return new Date(dateString).toLocaleDateString('fr-FR', {
return new Intl.DateTimeFormat('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}).format(dateObj);
}
// Recharger les données
refreshData() {
this.loadSubscriptions();
getStatusDisplayName(status: SubscriptionStatus): string {
switch (status) {
case SubscriptionStatus.ACTIVE: return 'Actif';
case SubscriptionStatus.SUSPENDED: return 'Suspendu';
case SubscriptionStatus.CANCELLED: return 'Annulé';
case SubscriptionStatus.EXPIRED: return 'Expiré';
case SubscriptionStatus.PENDING: return 'En attente';
default: return status;
}
}
getPeriodicityDisplayName(periodicity: SubscriptionPeriodicity): string {
switch (periodicity) {
case SubscriptionPeriodicity.DAILY: return 'Quotidien';
case SubscriptionPeriodicity.WEEKLY: return 'Hebdomadaire';
case SubscriptionPeriodicity.MONTHLY: return 'Mensuel';
case SubscriptionPeriodicity.YEARLY: return 'Annuel';
default: return periodicity;
}
}
// Méthodes pour le template
getCardTitle(): string {
return 'Abonnements';
getUserBadgeClass(): string {
return this.access.isHubUser ? 'bg-primary' : 'bg-success';
}
getHelperText(): string {
return this.canViewAllMerchants
? 'Vue administrative - Tous les abonnements'
: 'Vos abonnements';
getUserBadgeIcon(): string {
return this.access.isHubUser ? 'lucideShield' : 'lucideStore';
}
getHelperIcon(): string {
return this.canViewAllMerchants ? 'lucideShield' : 'lucideRepeat';
getUserBadgeText(): string {
return this.access.isHubUser ? 'Hub User' : 'Merchant User';
}
getLoadingText(): string {
return 'Chargement des abonnements...';
// Méthodes utilitaires
refreshData() {
this.loadAllSubscriptions();
}
getEmptyStateTitle(): string {
return 'Aucun abonnement trouvé';
getFilteredCount(): number {
return this.subscriptions.length;
}
getEmptyStateDescription(): string {
return 'Aucun abonnement ne correspond à vos critères de recherche.';
}
// Statistiques
getTotalSubscriptionsCount(): number {
getTotalCount(): number {
return this.allSubscriptions.length;
}
// Méthodes pour exposer les valeurs d'énumération au template
getActiveStatus(): SubscriptionStatus {
return SubscriptionStatus.ACTIVE;
}
getSuspendedStatus(): SubscriptionStatus {
return SubscriptionStatus.SUSPENDED;
}
getCancelledStatus(): SubscriptionStatus {
return SubscriptionStatus.CANCELLED;
}
getExpiredStatus(): SubscriptionStatus {
return SubscriptionStatus.EXPIRED;
}
getPendingStatus(): SubscriptionStatus {
return SubscriptionStatus.PENDING;
}
// Méthodes pour compter les abonnements par statut
getActiveSubscriptionsCount(): number {
if (!this.allSubscriptions || this.allSubscriptions.length === 0) return 0;
return this.allSubscriptions.filter(s => s.status === SubscriptionStatus.ACTIVE).length;
}
getSuspendedSubscriptionsCount(): number {
if (!this.allSubscriptions || this.allSubscriptions.length === 0) return 0;
return this.allSubscriptions.filter(s => s.status === SubscriptionStatus.SUSPENDED).length;
}
getCancelledSubscriptionsCount(): number {
if (!this.allSubscriptions || this.allSubscriptions.length === 0) return 0;
return this.allSubscriptions.filter(s => s.status === SubscriptionStatus.CANCELLED).length;
}
getExpiredSubscriptionsCount(): number {
if (!this.allSubscriptions || this.allSubscriptions.length === 0) return 0;
return this.allSubscriptions.filter(s => s.status === SubscriptionStatus.EXPIRED).length;
}
getPendingSubscriptionsCount(): number {
if (!this.allSubscriptions || this.allSubscriptions.length === 0) return 0;
return this.allSubscriptions.filter(s => s.status === SubscriptionStatus.PENDING).length;
}
getTotalRevenue(): number {
return this.allSubscriptions.reduce((sum, sub) => sum + sub.amount, 0);
}
// Getters pour la logique conditionnelle
get showMerchantColumn(): boolean {
return this.canViewAllMerchants;
}
get showCustomerColumn(): boolean {
return this.canViewAllMerchants;
}
getColumnCount(): number {
let count = 6; // ID, Montant, Périodicité, Statut, Date début, Actions
if (this.showMerchantColumn) count++;
if (this.showCustomerColumn) count++;
return count;
}
// Filtrage rapide par statut
filterByStatus(status: SubscriptionStatus | 'all') {
this.statusFilter = status;
this.currentPage = 1;
this.applyFiltersAndPagination();
}
// Filtrage rapide par périodicité
filterByPeriodicity(periodicity: SubscriptionPeriodicity | 'all') {
this.periodicityFilter = periodicity;
this.currentPage = 1;
this.applyFiltersAndPagination();
}
// Calculer les jours jusqu'au prochain paiement
getDaysUntilNextPayment(subscription: Subscription): number {
const nextPayment = new Date(subscription.nextPaymentDate);
const today = new Date();
const diffTime = nextPayment.getTime() - today.getTime();
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
}
// Vérifier si un abonnement est sur le point d'expirer (dans les 7 jours)
isExpiringSoon(subscription: Subscription): boolean {
if (!subscription.endDate) return false;
const endDate = new Date(subscription.endDate);
const today = new Date();
const diffTime = endDate.getTime() - today.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return diffDays <= 7 && diffDays > 0;
}
// Vérifier si un abonnement est expiré
isExpired(subscription: Subscription): boolean {
if (!subscription.endDate) return false;
return new Date(subscription.endDate) < new Date();
}
}

View File

@ -1,8 +1,8 @@
<div class="container-fluid">
<app-page-title
title="pageTitle"
subtitle="Consultez et gérez les abonnements et leurs paiements"
[badge]="badge"
title="Abonnements DCB"
subTitle="Gestion et suivi des abonnements de paiement mobile"
[badge]="{icon:'lucideRepeat', text:'Abonnements'}"
/>
<!-- Indicateur de permissions -->
@ -15,12 +15,13 @@
<div class="flex-grow-1">
<small>
<strong>Rôle actuel :</strong>
<span class="badge" [ngClass]="getRoleBadgeClass()">
{{ getRoleLabel() }}
<span [class]="getUserBadgeClass()" class="badge">
<ng-icon [name]="getUserBadgeIcon()" class="me-1"></ng-icon>
{{ getUserBadgeText() }}
</span>
@if (currentMerchantPartnerId) {
@if (currentMerchantId) {
<span class="ms-2">
<strong>Merchant ID :</strong> {{ currentMerchantPartnerId }}
<strong>Merchant ID :</strong> {{ currentMerchantId }}
</span>
}
</small>
@ -31,52 +32,46 @@
</div>
}
<!-- Navigation par onglets -->
<!-- Navigation -->
<div class="row mb-4">
<div class="col-12">
<ul
ngbNav
#subscriptionsNav="ngbNav"
[activeId]="activeTab"
[destroyOnHide]="false"
class="nav nav-tabs nav-justified nav-bordered nav-bordered-primary mb-3"
>
<li [ngbNavItem]="'list'">
<a ngbNavLink (click)="showTab('list')">
<ng-icon name="lucideList" class="fs-lg me-md-1 d-inline-flex align-middle" />
<span class="d-none d-md-inline-block align-middle">Liste des Abonnements</span>
</a>
<ng-template ngbNavContent>
@if (activeView === 'list') {
<app-subscriptions-list
#subscriptionsList
(subscriptionSelected)="onSubscriptionSelected($event)"
(subscriptionSelected)="showDetailsView($event)"
(viewPaymentsRequested)="onViewPaymentsRequested($event)"
/>
</ng-template>
</li>
<li [ngbNavItem]="'payments'" [hidden]="!selectedSubscriptionId">
<a ngbNavLink (click)="showTab('payments')">
<ng-icon name="lucideCreditCard" class="fs-lg me-md-1 d-inline-flex align-middle" />
<span class="d-none d-md-inline-block align-middle">Paiements</span>
</a>
<ng-template ngbNavContent>
@if (selectedSubscriptionId) {
} @else if (activeView === 'details' && selectedSubscriptionId) {
<div class="d-flex align-items-center mb-3">
<button class="btn btn-outline-secondary btn-sm me-2" (click)="showListView()">
<ng-icon name="lucideArrowLeft" class="me-1"></ng-icon>
Retour à la liste
</button>
<button class="btn btn-outline-primary btn-sm me-2"
(click)="showPaymentsView(selectedSubscriptionId)">
<ng-icon name="lucideCreditCard" class="me-1"></ng-icon>
Voir les paiements
</button>
<h5 class="mb-0">Détails de l'abonnement</h5>
</div>
<app-subscription-details [subscriptionId]="selectedSubscriptionId" />
} @else if (activeView === 'payments' && selectedSubscriptionId) { <!-- AJOUTER -->
<div class="d-flex align-items-center mb-3">
<button class="btn btn-outline-secondary btn-sm me-2" (click)="showListView()">
<ng-icon name="lucideArrowLeft" class="me-1"></ng-icon>
Retour à la liste
</button>
<button class="btn btn-outline-primary btn-sm me-2"
(click)="showDetailsView(selectedSubscriptionId)">
<ng-icon name="lucideEye" class="me-1"></ng-icon>
Voir les détails
</button>
<h5 class="mb-0">Paiements de l'abonnement</h5>
</div>
<app-subscription-payments
[subscriptionId]="selectedSubscriptionId"
(back)="backToList()"
(back)="showListView()"
/>
} @else {
<div class="alert alert-warning text-center">
<ng-icon name="lucideAlertCircle" class="me-2"></ng-icon>
Aucun abonnement sélectionné
</div>
}
</ng-template>
</li>
</ul>
<div class="tab-content" [ngbNavOutlet]="subscriptionsNav"></div>
</div>
</div>
</div>

View File

@ -1,246 +1,103 @@
import { Component, inject, OnInit, TemplateRef, ViewChild, ChangeDetectorRef, OnDestroy } from '@angular/core';
import { Component, inject, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
import { NgIcon } from '@ng-icons/core';
import { NgbNavModule, NgbModal, NgbModalModule } from '@ng-bootstrap/ng-bootstrap';
import { catchError, map, of, Subject, takeUntil } from 'rxjs';
import { SubscriptionsService } from './subscriptions.service';
import { AuthService } from '@core/services/auth.service';
import { PageTitle } from '@app/components/page-title/page-title';
import { SubscriptionsList } from './subscriptions-list/subscriptions-list';
import { SubscriptionPayments } from './subscription-payments/subscription-payments';
import {
Subscription,
SubscriptionPayment,
SubscriptionStatus,
SubscriptionPeriodicity,
Currency
} from '@core/models/dcb-bo-hub-subscription.model';
import { User, UserRole } from '@core/models/dcb-bo-hub-user.model';
import { RoleManagementService } from '@core/services/hub-users-roles-management-old.service';
import { SubscriptionDetails } from './subscription-details/subscription-details';
import { SubscriptionPayments } from './subscription-payments/subscription-payments'; // AJOUTER
import { SubscriptionAccessService } from './services/subscription-access.service';
import { Subscription } from 'rxjs';
@Component({
selector: 'app-subscriptions',
standalone: true,
imports: [
CommonModule,
FormsModule,
NgIcon,
NgbNavModule,
NgbModalModule,
NgIcon,
PageTitle,
SubscriptionsList,
SubscriptionDetails,
SubscriptionPayments
],
templateUrl: './subscriptions.html',
})
export class SubscriptionsManagement implements OnInit, OnDestroy {
private authService = inject(AuthService);
private subscriptionsService = inject(SubscriptionsService);
private roleService = inject(RoleManagementService);
export class Subscriptions implements OnInit {
private subscriptions: Subscription[] = [];
private accessService = inject(SubscriptionAccessService);
private cdRef = inject(ChangeDetectorRef);
private destroy$ = new Subject<void>();
// Configuration
readonly SubscriptionStatus = SubscriptionStatus;
readonly SubscriptionPeriodicity = SubscriptionPeriodicity;
readonly Currency = Currency;
// Propriétés de configuration
pageTitle: string = 'Gestion des Abonnements';
badge: any = { icon: 'lucideRepeat', text: 'Abonnements' };
// État de l'interface
activeTab: 'list' | 'payments' = 'list';
activeView: 'list' | 'details' | 'payments' = 'list'; // AJOUTER 'payments'
selectedSubscriptionId: string | null = null;
user: User | undefined;
// Permissions
canAccessModule = true;
accessDenied = false;
currentUserRole = '';
currentMerchantId?: number;
// Gestion des rôles (lecture seule)
availableRoles: { value: UserRole; label: string; description: string }[] = [];
subscriptionInitialized = false;
// Gestion des permissions
currentUserRole: string | null = null;
currentMerchantPartnerId: string = '';
ngOnInit(): void {
console.log('🔍 Subscription: ngOnInit() appelé');
// Données
subscriptionPayments: { [subscriptionId: string]: SubscriptionPayment[] } = {};
selectedSubscriptionForPayments: Subscription | null = null;
// Références aux composants enfants
@ViewChild(SubscriptionsList) subscriptionsList!: SubscriptionsList;
ngOnInit() {
this.activeTab = 'list';
this.loadCurrentUserPermissions();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
/**
* Initialise les permissions de l'utilisateur courant
*/
private loadCurrentUserPermissions(): void {
this.authService.getUserProfile()
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (user) => {
this.currentUserRole = this.extractUserRole(user);
console.log(`User ROLE: ${this.currentUserRole}`);
// Attendre que le SubscriptionAccessService soit VRAIMENT prêt
this.subscriptions.push(
this.accessService.waitForReady().subscribe({
next: () => {
console.log('✅ Subscription: waitForReady() a émis - Initialisation...');
this.subscriptionInitialized = true;
this.checkAccess();
},
error: (error) => {
console.error('Error loading user profile:', error);
error: (err) => {
console.error('❌ Subscription: Erreur dans waitForReady():', err);
}
});
})
);
}
/**
* Extraire le rôle de l'utilisateur
*/
private extractUserRole(user: any): string | null {
const userRoles = this.authService.getCurrentUserRoles();
if (userRoles && userRoles.length > 0) {
return userRoles[0];
}
return null;
private checkAccess() {
const access = this.accessService.getSubscriptionAccess();
this.canAccessModule = access.canViewSubscriptions;
this.accessDenied = !access.canViewSubscriptions;
this.currentUserRole = access.userRoleLabel;
this.currentMerchantId = access.merchantId;
}
// ==================== MÉTHODES D'INTERFACE ====================
// Méthode pour changer d'onglet
showTab(tab: 'list' | 'payments', subscriptionId?: string) {
console.log(`Switching to tab: ${tab}`, subscriptionId ? `for subscription ${subscriptionId}` : '');
this.activeTab = tab;
if (subscriptionId) {
this.selectedSubscriptionId = subscriptionId;
}
}
backToList() {
console.log('🔙 Returning to list view');
this.activeTab = 'list';
showListView() {
this.activeView = 'list';
this.selectedSubscriptionId = null;
}
// Méthodes de gestion des événements du composant enfant
onSubscriptionSelected(subscriptionId: string) {
this.showTab('payments', subscriptionId);
showDetailsView(subscriptionId: string) {
this.activeView = 'details';
this.selectedSubscriptionId = subscriptionId;
}
// NOUVELLE MÉTHODE
showPaymentsView(subscriptionId: string) {
this.activeView = 'payments';
this.selectedSubscriptionId = subscriptionId;
}
// NOUVELLE MÉTHODE
onViewPaymentsRequested(subscriptionId: string) {
this.showTab('payments', subscriptionId);
this.showPaymentsView(subscriptionId);
}
// ==================== MÉTHODES UTILITAIRES ====================
private refreshSubscriptionsList(): void {
if (this.subscriptionsList && typeof this.subscriptionsList.refreshData === 'function') {
console.log('🔄 Refreshing subscriptions list...');
this.subscriptionsList.refreshData();
} else {
console.warn('❌ SubscriptionsList component not available for refresh');
this.showTab('list');
}
// Utilitaires pour le template
getUserBadgeClass(): string {
const access = this.accessService.getSubscriptionAccess();
return access.isHubUser ? 'badge bg-primary' : 'badge bg-success';
}
// Méthodes pour les templates
getStatusDisplayName(status: SubscriptionStatus): string {
const statusNames: { [key: string]: string } = {
[SubscriptionStatus.ACTIVE]: 'Actif',
[SubscriptionStatus.SUSPENDED]: 'Suspendu',
[SubscriptionStatus.CANCELLED]: 'Annulé',
[SubscriptionStatus.EXPIRED]: 'Expiré',
[SubscriptionStatus.PENDING]: 'En attente'
};
return statusNames[status] || status;
getUserBadgeIcon(): string {
const access = this.accessService.getSubscriptionAccess();
return access.isHubUser ? 'lucideShield' : 'lucideStore';
}
getPeriodicityDisplayName(periodicity: SubscriptionPeriodicity): string {
const periodicityNames: { [key: string]: string } = {
[SubscriptionPeriodicity.DAILY]: 'Quotidien',
[SubscriptionPeriodicity.WEEKLY]: 'Hebdomadaire',
[SubscriptionPeriodicity.MONTHLY]: 'Mensuel',
[SubscriptionPeriodicity.YEARLY]: 'Annuel'
};
return periodicityNames[periodicity] || periodicity;
}
getPaymentStatusDisplayName(status: string): string {
const statusNames: { [key: string]: string } = {
'PENDING': 'En attente',
'SUCCESS': 'Réussi',
'FAILED': 'Échoué'
};
return statusNames[status] || status;
}
formatAmount(amount: number, currency: Currency): string {
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: currency
}).format(amount);
}
formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString('fr-FR');
}
getUserInitials(): string {
if (!this.user) return 'U';
return (this.user.firstName?.charAt(0) || '') + (this.user.lastName?.charAt(0) || '') || 'U';
}
getUserDisplayName(): string {
if (!this.user) return 'Utilisateur';
if (this.user.firstName && this.user.lastName) {
return `${this.user.firstName} ${this.user.lastName}`;
}
return this.user.username;
}
getRoleBadgeClass(): string {
if (!this.user?.role) return 'badge bg-secondary';
return this.roleService.getRoleBadgeClass(this.user.role);
}
getRoleLabel(): string {
if (!this.user?.role) return 'Aucun rôle';
return this.roleService.getRoleLabel(this.user.role);
}
getRoleDescription(): string {
if (!this.user?.role) return 'Description non disponible';
const roleInfo = this.availableRoles.find(r => r.value === this.user!.role);
return roleInfo?.description || this.roleService.getRoleDescription(this.user.role);
}
getRoleIcon(role: string | UserRole): string {
return this.roleService.getRoleIcon(role);
}
// Obtenir le rôle (peut être string ou UserRole)
getUserRole(): string | UserRole | undefined {
return this.user?.role;
}
// Pour le template, retourner un tableau pour la boucle
getUserRoles(): (string | UserRole)[] {
const role = this.user?.role;
if (!role) return [];
return Array.isArray(role) ? role : [role];
}
// Afficher le rôle
getUserRoleDisplay(): string {
if (!this.user?.role) return 'Aucun rôle';
return this.getRoleLabel();
getUserBadgeText(): string {
return this.currentUserRole;
}
}

View File

@ -0,0 +1,22 @@
/* Dans votre fichier CSS */
.ng-icon.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.cursor-pointer {
cursor: pointer;
}
.fs-12 {
font-size: 12px;
}
.table-responsive {
max-height: 600px;
overflow-y: auto;
}

View File

@ -17,7 +17,7 @@
<ng-icon [name]="getUserBadgeIcon()" class="me-1"></ng-icon>
{{ getUserBadgeText() }}
</span>
<span class="badge bg-info" *ngIf="currentMerchantId">
<span class="badge bg-info" *ngIf="currentMerchantId > 0">
<ng-icon name="lucideStore" class="me-1"></ng-icon>
Merchant {{ currentMerchantId }}
</span>
@ -26,7 +26,7 @@
<div class="d-flex gap-2">
<!-- Refresh -->
<button class="btn btn-outline-secondary" (click)="loadTransactions()" [disabled]="loading">
<button class="btn btn-outline-secondary" (click)="refreshData()" [disabled]="loading">
<ng-icon name="lucideRefreshCw" [class.spin]="loading"></ng-icon>
</button>
</div>
@ -41,14 +41,13 @@
<ng-icon name="lucideLock" class="me-2"></ng-icon>
<div>
<strong>Accès refusé</strong>
<p class="mb-0">Vous n'avez pas les permissions nécessaires pour accéder à cette section.</p>
<p class="mb-0">{{ error || 'Vous n\'avez pas les permissions nécessaires pour accéder à cette section.' }}</p>
</div>
</div>
</div>
} @else {
<!-- Statistiques rapides -->
@if (paginatedData?.stats) {
<div class="row mb-4">
<div class="col-12">
<div class="card bg-light">
@ -74,12 +73,45 @@
<small class="text-muted">Montant total</small>
<div class="h5 mb-0">{{ formatCurrency(getTotalAmount()) }}</div>
</div>
<div class="col">
<small class="text-muted">Taux de succès</small>
<div class="h5 mb-0 text-success">{{ getSuccessRate() }}%</div>
</div>
</div>
</div>
</div>
</div>
}
</div>
<!-- Information sur le scope -->
<div class="row mb-3">
<div class="col-12">
<div class="alert alert-info py-2">
<div class="d-flex align-items-center">
<ng-icon name="lucideShield" class="me-2"></ng-icon>
<small>
<strong>Scope:</strong> {{ getScopeText() }} |
<strong>Filtres actifs:</strong>
<span class="badge bg-primary me-1" *ngIf="statusFilter !== 'all'">
Statut: {{ getStatusDisplayName(statusFilter) }}
</span>
<span class="badge bg-primary me-1" *ngIf="startDateFilter">
Début: {{ formatDate(startDateFilter) }}
</span>
<span class="badge bg-primary me-1" *ngIf="endDateFilter">
Fin: {{ formatDate(endDateFilter) }}
</span>
<span class="badge bg-primary me-1" *ngIf="searchTerm">
Recherche: "{{ searchTerm }}"
</span>
<span class="badge bg-secondary">
{{ getFilteredCount() }} / {{ getTotalCount() }} transactions
</span>
</small>
</div>
</div>
</div>
</div>
<!-- Barre de recherche et filtres -->
<div class="row mb-3">
@ -91,39 +123,71 @@
<input
type="text"
class="form-control"
placeholder="Rechercher par periodicity, Type..."
placeholder="Rechercher par ID, référence, merchant..."
[(ngModel)]="searchTerm"
(keyup.enter)="onSearch()"
>
@if (searchTerm) {
<button class="btn btn-outline-secondary" type="button" (click)="clearSearch()">
<ng-icon name="lucideX"></ng-icon>
</button>
}
</div>
</div>
<div class="col-md-8">
<div class="d-flex gap-2">
<div class="d-flex gap-2 flex-wrap">
<!-- Filtre statut -->
<select class="form-select" style="width: auto;" (change)="onStatusFilterChange($any($event.target).value)">
<option value="all">Tous les statuts</option>
<div ngbDropdown class="dropdown">
<button class="btn btn-outline-secondary dropdown-toggle" ngbDropdownToggle>
<ng-icon name="lucideFilter" class="me-1"></ng-icon>
Statut: {{ statusFilter === 'all' ? 'Tous' : getStatusDisplayName(statusFilter) }}
</button>
<div ngbDropdownMenu>
<button ngbDropdownItem (click)="onStatusFilterChange('all')">
Tous les statuts
</button>
<li><hr class="dropdown-divider"></li>
@for (status of statusOptions; track status) {
<option [value]="status">{{ status }}</option>
<button ngbDropdownItem (click)="onStatusFilterChange(status)">
<span [class]="getStatusBadgeClass(status)" class="badge me-2">{{ getStatusDisplayName(status) }}</span>
</button>
}
</select>
</div>
</div>
<!-- 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>
<!-- Filtre date début -->
<div class="input-group" style="width: 200px;">
<span class="input-group-text">
<ng-icon name="lucideCalendar"></ng-icon>
</span>
<input
type="date"
class="form-control"
[value]="startDateFilter ? startDateFilter.toISOString().split('T')[0] : ''"
(change)="handleStartDateChange($event)"
>
</div>
<!-- Filtre date fin -->
<div class="input-group" style="width: 200px;">
<span class="input-group-text">à</span>
<input
type="date"
class="form-control"
[value]="endDateFilter ? endDateFilter.toISOString().split('T')[0] : ''"
(change)="handleEndDateChange($event)"
>
</div>
<button class="btn btn-outline-primary" (click)="onSearch()">
<ng-icon name="lucideFilter" class="me-1"></ng-icon>
Filtrer
<ng-icon name="lucideSearch" class="me-1"></ng-icon>
Appliquer
</button>
<button class="btn btn-outline-secondary" (click)="onClearFilters()">
<ng-icon name="lucideX" class="me-1"></ng-icon>
Effacer
Tout effacer
</button>
</div>
</div>
@ -181,11 +245,11 @@
<th>Statut</th>
<th (click)="sort('transactionDate')" class="cursor-pointer">
<div class="d-flex align-items-center">
<span>Date début</span>
<span>Date transaction</span>
<ng-icon [name]="getSortIcon('transactionDate')" class="ms-1 fs-12"></ng-icon>
</div>
</th>
<th>Prochain paiement</th>
<th>Date création</th>
<th width="120">Actions</th>
</tr>
</thead>
@ -200,17 +264,27 @@
(change)="toggleTransactionSelection(transaction.id)"
>
</td>
<td class="font-monospace small">{{ transaction.id }}</td>
<td class="font-monospace small">
{{ transaction.id }}
@if (transaction.externalReference) {
<br>
<small class="text-muted">Ref: {{ transaction.externalReference }}</small>
}
</td>
<td>
<span class="badge bg-light text-dark">
<ng-icon [name]="getTypeIcon(transaction.type)" class="me-1"></ng-icon>
Abonnement
{{ getTypeDisplayName(transaction.type) }}
</span>
</td>
<td>
@if (transaction.merchantPartnerId) {
<span class="badge bg-secondary">
Merchant {{ transaction.merchantPartnerId }}
</span>
} @else {
<span class="text-muted">-</span>
}
</td>
<td>
<span [class]="getAmountColor(transaction.amount)">
@ -222,6 +296,8 @@
<span [class]="getPeriodicityBadgeClass(transaction.periodicity)" class="badge">
{{ getPeriodicityDisplayName(transaction.periodicity) }}
</span>
} @else {
<span class="text-muted">-</span>
}
</td>
<td>
@ -234,11 +310,7 @@
{{ formatDate(transaction.transactionDate) }}
</td>
<td class="small text-muted">
@if (transaction.nextPaymentDate) {
{{ formatDate(transaction.nextPaymentDate) }}
} @else {
-
}
{{ formatDate(transaction.createdAt) }}
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
@ -249,6 +321,15 @@
>
<ng-icon name="lucideEye"></ng-icon>
</button>
@if (access.canViewAllTransactions && transaction.status === 'SUCCESS') {
<button
class="btn btn-outline-warning"
(click)="openRefundModal.emit(transaction.id)"
ngbTooltip="Rembourser"
>
<ng-icon name="lucideUndo2"></ng-icon>
</button>
}
</div>
</td>
</tr>
@ -257,10 +338,18 @@
<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>
<p class="text-muted mb-3">
@if (allTransactions.length === 0) {
Aucune transaction disponible
} @else {
Aucune transaction ne correspond aux filtres
}
</p>
@if (allTransactions.length > 0) {
<button class="btn btn-primary" (click)="onClearFilters()">
Réinitialiser les filtres
</button>
}
</td>
</tr>
}
@ -271,18 +360,20 @@
</div>
<!-- Pagination -->
@if (paginatedData && paginatedData.totalPages >= 1) {
@if (totalPages >= 1) {
<div class="d-flex justify-content-between align-items-center mt-3">
<div class="text-muted">
Affichage de {{ (filters.page! - 1) * filters.limit! + 1 }} à
{{ (filters.page! * filters.limit!) > (paginatedData.total || 0) ? (paginatedData.total || 0) : (filters.page! * filters.limit!) }}
sur {{ paginatedData.total || 0 }} transactions
Affichage de
{{ (currentPage - 1) * itemsPerPage + 1 }} à
{{ getCurrentPageEnd() }}
sur {{ totalItems }} transactions
({{ getFilteredCount() }} filtrées / {{ getTotalCount() }} total)
</div>
<nav>
<ngb-pagination
[collectionSize]="paginatedData.total"
[page]="filters.page!"
[pageSize]="filters.limit!"
[collectionSize]="totalItems"
[page]="currentPage"
[pageSize]="itemsPerPage"
[maxSize]="5"
[rotate]="true"
[boundaryLinks]="true"
@ -292,6 +383,5 @@
</div>
}
}
}
</div>

View File

@ -40,8 +40,7 @@ import {
import {
Currency,
SubscriptionPeriodicity,
SubscriptionUtils
SubscriptionPeriodicity
} from '@core/models/dcb-bo-hub-subscription.model';
@Component({
@ -53,14 +52,15 @@ import {
NgIcon,
NgbPaginationModule,
NgbDropdownModule,
NgbTooltipModule
NgbTooltipModule,
],
providers: [
provideNgIconsConfig({
size: '1.25em'
})
],
templateUrl: './list.html'
templateUrl: './list.html',
styleUrl: './list.css'
})
export class TransactionsList implements OnInit, OnDestroy {
private transactionsService = inject(TransactionsService);
@ -72,12 +72,11 @@ export class TransactionsList implements OnInit, OnDestroy {
// Permissions
access!: TransactionAccess;
currentUserRole = '';
currentMerchantId?: number;
currentMerchantId: number = 0;
// Données
transactions: Transaction[] = [];
paginatedData: PaginatedTransactions | null = null;
allTransactions: Transaction[] = [];
// États
loading = false;
@ -85,15 +84,9 @@ export class TransactionsList implements OnInit, OnDestroy {
// Filtres et recherche
searchTerm = '';
filters: TransactionQuery = {
page: 1,
limit: 20,
status: undefined,
startDate: undefined,
endDate: undefined,
sortBy: 'transactionDate',
sortOrder: 'desc'
};
statusFilter: TransactionStatus | 'all' = 'all';
startDateFilter: Date | null = null;
endDateFilter: Date | null = null;
// Options de filtre
statusOptions: TransactionStatus[] = ['PENDING', 'SUCCESS', 'FAILED', 'CANCELLED'];
@ -102,9 +95,12 @@ export class TransactionsList implements OnInit, OnDestroy {
TransactionType.SUBSCRIPTION_RENEWAL,
TransactionType.ONE_TIME_PAYMENT
];
periodicityOptions = Object.values(SubscriptionPeriodicity);
operatorOptions: string[] = ['Orange'];
countryOptions: string[] = ['SN'];
// Pagination
currentPage = 1;
itemsPerPage = 20;
totalItems = 0;
totalPages = 0;
// Tri
sortField: string = 'transactionDate';
@ -114,62 +110,74 @@ export class TransactionsList implements OnInit, OnDestroy {
selectedTransactions: Set<string> = new Set();
selectAll = false;
// Cache
private lastLoadTime: number = 0;
private readonly CACHE_TTL = 30000; // 30 secondes comme le service
ngOnInit() {
this.initializePermissions();
this.loadTransactions();
this.loadAllTransactions();
}
ngOnDestroy() {
// Nettoyage si nécessaire
// Nettoyer le cache si nécessaire
}
private initializePermissions() {
this.access = this.accessService.getTransactionAccess();
// IMPORTANT: Toujours filtrer par merchant pour les merchant users
if (this.access.isMerchantUser && this.access.allowedMerchantIds.length > 0) {
this.filters.merchantPartnerId = this.access.allowedMerchantIds[0];
}
// Définir le rôle pour l'affichage
this.currentUserRole = this.access.userRoleLabel;
this.currentMerchantId = this.access.merchantId;
}
loadTransactions() {
if (!this.access.canViewTransactions) {
this.error = 'Vous n\'avez pas la permission de voir les transactions';
return;
}
if (this.access.isMerchantUser) {
this.currentMerchantId = this.access.merchantId || 0;
if (this.currentMerchantId === 0) {
this.error = 'Merchant ID non disponible pour l\'utilisateur merchant';
return;
}
}
}
private loadAllTransactions() {
if (!this.access.canViewTransactions) return;
// Vérifier si on peut utiliser le cache
const now = Date.now();
const shouldUseCache = now - this.lastLoadTime < this.CACHE_TTL && this.allTransactions.length > 0;
if (shouldUseCache) {
console.log('🔄 Utilisation du cache pour les transactions');
this.applyFiltersAndPagination();
return;
}
this.loading = true;
this.error = '';
// Préparer les filtres pour l'API
const apiFilters = this.prepareFiltersForApi();
// Préparer la requête avec merchantPartnerId si nécessaire
const query: TransactionQuery = {
page: 1,
limit: 1000, // Charger beaucoup d'éléments pour le filtrage client
sortBy: 'createdAt',
sortOrder: 'desc'
};
console.log('Chargement transactions avec filtres:', apiFilters);
// Ajouter le filtre merchant uniquement pour les merchant users
if (this.access.isMerchantUser && this.currentMerchantId > 0) {
query.merchantPartnerId = this.currentMerchantId;
}
this.transactionsService.getTransactions(apiFilters).subscribe({
this.transactionsService.getTransactions(query).subscribe({
next: (data) => {
this.paginatedData = data;
this.transactions = data.data;
// Stocker TOUTES les transactions pour le filtrage client
this.allTransactions = data.data || [];
this.lastLoadTime = Date.now();
console.log(`${this.allTransactions.length} transactions chargées`);
this.applyFiltersAndPagination();
this.loading = false;
// Log pour debug
if (data.data.length > 0) {
console.log(`Chargement réussi: ${data.data.length} transactions`);
if (this.access.isMerchantUser && this.currentMerchantId) {
// Vérifier que toutes les transactions appartiennent bien au marchand
const wrongMerchantTx = data.data.filter(tx =>
tx.merchantPartnerId !== this.currentMerchantId
);
if (wrongMerchantTx.length > 0) {
console.warn('ATTENTION: certaines transactions ne sont pas du bon marchand:', wrongMerchantTx);
}
}
}
this.cdRef.detectChanges();
},
error: (error) => {
@ -181,32 +189,118 @@ export class TransactionsList implements OnInit, OnDestroy {
});
}
private prepareFiltersForApi(): TransactionQuery {
const apiFilters: TransactionQuery = { ...this.filters };
private applyFiltersAndPagination() {
// 1. Appliquer les filtres
let filteredTransactions = this.filterTransactions(this.allTransactions);
// Ajouter la recherche si présente
if (this.searchTerm) {
apiFilters.search = this.searchTerm;
// 2. Appliquer le tri
filteredTransactions = this.sortTransactions(filteredTransactions);
// 3. Calculer la pagination
this.totalItems = filteredTransactions.length;
this.totalPages = Math.ceil(this.totalItems / this.itemsPerPage);
// Ajuster la page courante si nécessaire
if (this.currentPage > this.totalPages) {
this.currentPage = 1;
}
// Appliquer le tri
apiFilters.sortBy = this.sortField;
apiFilters.sortOrder = this.sortDirection;
// 4. Extraire les éléments de la page courante
const startIndex = (this.currentPage - 1) * this.itemsPerPage;
const endIndex = Math.min(startIndex + this.itemsPerPage, this.totalItems);
this.transactions = filteredTransactions.slice(startIndex, endIndex);
// Nettoyer les filtres (enlever les undefined)
Object.keys(apiFilters).forEach(key => {
if (apiFilters[key as keyof TransactionQuery] === undefined) {
delete apiFilters[key as keyof TransactionQuery];
console.log(`📊 Filtrage: ${this.allTransactions.length}${filteredTransactions.length}${this.transactions.length} (page ${this.currentPage}/${this.totalPages})`);
}
private filterTransactions(transactions: Transaction[]): Transaction[] {
let filtered = [...transactions];
// Filtre par statut
if (this.statusFilter !== 'all') {
filtered = filtered.filter(tx => tx.status === this.statusFilter);
}
// Filtre par date de début
if (this.startDateFilter) {
const startDate = new Date(this.startDateFilter);
startDate.setHours(0, 0, 0, 0);
filtered = filtered.filter(tx => {
const txDate = new Date(tx.transactionDate);
return txDate >= startDate;
});
}
return apiFilters;
// Filtre par date de fin
if (this.endDateFilter) {
const endDate = new Date(this.endDateFilter);
endDate.setHours(23, 59, 59, 999);
filtered = filtered.filter(tx => {
const txDate = new Date(tx.transactionDate);
return txDate <= endDate;
});
}
// Filtre par recherche
if (this.searchTerm.trim()) {
const term = this.searchTerm.toLowerCase().trim();
filtered = filtered.filter(tx => {
// Recherche dans tous les champs textuels
return (
(tx.id && tx.id.toString().toLowerCase().includes(term)) ||
(tx.externalReference && tx.externalReference.toLowerCase().includes(term)) ||
(tx.subscriptionId && tx.subscriptionId.toString().toLowerCase().includes(term)) ||
(tx.productName && tx.productName.toLowerCase().includes(term)) ||
(tx.merchantPartnerId && tx.merchantPartnerId.toString().includes(term))
);
});
}
return filtered;
}
private sortTransactions(transactions: Transaction[]): Transaction[] {
return [...transactions].sort((a: any, b: any) => {
const aValue = a[this.sortField];
const bValue = b[this.sortField];
if (aValue == null && bValue == null) return 0;
if (aValue == null) return 1;
if (bValue == null) return -1;
let comparison = 0;
if (typeof aValue === 'string' && typeof bValue === 'string') {
comparison = aValue.localeCompare(bValue);
} else if (typeof aValue === 'number' && typeof bValue === 'number') {
comparison = aValue - bValue;
} else {
// Traitement spécial pour les dates
const aDate = this.parseDate(aValue);
const bDate = this.parseDate(bValue);
if (aDate && bDate) {
comparison = aDate.getTime() - bDate.getTime();
}
}
return this.sortDirection === 'asc' ? comparison : -comparison;
});
}
private parseDate(value: any): Date | null {
if (value instanceof Date) return value;
if (typeof value === 'string' || typeof value === 'number') {
const date = new Date(value);
return !isNaN(date.getTime()) ? date : null;
}
return null;
}
// Recherche et filtres
onSearch() {
this.filters.page = 1;
this.loadTransactions();
this.currentPage = 1;
this.applyFiltersAndPagination();
this.cdRef.detectChanges();
}
clearSearch() {
@ -216,52 +310,33 @@ export class TransactionsList implements OnInit, OnDestroy {
onClearFilters() {
this.searchTerm = '';
this.filters = {
page: 1,
limit: 20,
status: undefined,
startDate: undefined,
endDate: undefined,
sortBy: 'transactionDate',
sortOrder: 'desc'
};
// IMPORTANT: Toujours réappliquer le filtrage par marchand pour les merchant users
if (this.access.isMerchantUser && this.access.allowedMerchantIds.length > 0) {
this.filters.merchantPartnerId = this.access.allowedMerchantIds[0];
}
this.loadTransactions();
this.statusFilter = 'all';
this.startDateFilter = null;
this.endDateFilter = null;
this.currentPage = 1;
this.applyFiltersAndPagination();
this.cdRef.detectChanges();
}
onStatusFilterChange(status: TransactionStatus | 'all') {
this.filters.status = status === 'all' ? undefined : status;
this.filters.page = 1;
this.loadTransactions();
this.statusFilter = status;
this.currentPage = 1;
this.applyFiltersAndPagination();
this.cdRef.detectChanges();
}
onOperatorFilterChange(operator: string) {
this.filters.page = 1;
this.loadTransactions();
onStartDateChange(date: Date | null) {
this.startDateFilter = date;
this.currentPage = 1;
this.applyFiltersAndPagination();
this.cdRef.detectChanges();
}
onDateRangeChange(start: Date | null, end: Date | null) {
this.filters.startDate = start || undefined;
this.filters.endDate = end || undefined;
this.filters.page = 1;
this.loadTransactions();
}
// Permissions pour les filtres
canUseMerchantFilter(): boolean {
// Uniquement pour les hub users avec plusieurs merchants autorisés
return this.access.canFilterByMerchant &&
this.access.isHubUser &&
this.access.allowedMerchantIds.length > 0;
}
canUseAllFilters(): boolean {
return this.access.canViewAllTransactions;
onEndDateChange(date: Date | null) {
this.endDateFilter = date;
this.currentPage = 1;
this.applyFiltersAndPagination();
this.cdRef.detectChanges();
}
// Tri
@ -272,8 +347,8 @@ export class TransactionsList implements OnInit, OnDestroy {
this.sortField = field;
this.sortDirection = 'desc';
}
this.filters.page = 1;
this.loadTransactions();
this.applyFiltersAndPagination();
this.cdRef.detectChanges();
}
getSortIcon(field: string): string {
@ -283,12 +358,14 @@ export class TransactionsList implements OnInit, OnDestroy {
// Pagination
onPageChange(page: number) {
this.filters.page = page;
this.loadTransactions();
this.currentPage = page;
this.applyFiltersAndPagination();
this.cdRef.detectChanges();
}
// Actions
viewTransactionDetails(transactionId: string) {
if (!transactionId) return;
this.transactionSelected.emit(transactionId);
}
@ -316,6 +393,26 @@ export class TransactionsList implements OnInit, OnDestroy {
this.selectedTransactions.size === this.transactions.length;
}
// Méthode pour charger les paiements d'un abonnement spécifique
loadSubscriptionPayments(subscriptionId: number) {
if (this.currentMerchantId === 0) return;
this.loading = true;
this.transactionsService.getTransactionPayments(this.currentMerchantId, subscriptionId).subscribe({
next: (data) => {
this.allTransactions = data.data;
this.applyFiltersAndPagination();
this.loading = false;
this.cdRef.detectChanges();
},
error: (error) => {
this.error = 'Erreur lors du chargement des paiements';
this.loading = false;
this.cdRef.detectChanges();
}
});
}
// Utilitaires d'affichage
getStatusBadgeClass(status: TransactionStatus): string {
switch (status) {
@ -351,16 +448,11 @@ export class TransactionsList implements OnInit, OnDestroy {
if (!periodicity) return 'badge bg-secondary';
switch (periodicity.toLowerCase()) {
case 'daily':
return 'badge bg-primary';
case 'weekly':
return 'badge bg-info';
case 'monthly':
return 'badge bg-success';
case 'yearly':
return 'badge bg-warning';
default:
return 'badge bg-secondary';
case 'daily': return 'badge bg-primary';
case 'weekly': return 'badge bg-info';
case 'monthly': return 'badge bg-success';
case 'yearly': return 'badge bg-warning';
default: return 'badge bg-secondary';
}
}
@ -399,6 +491,24 @@ export class TransactionsList implements OnInit, OnDestroy {
return '-';
}
}
// Méthodes pour gérer les changements de date
handleStartDateChange(event: Event): void {
const input = event.target as HTMLInputElement;
const value = input.value;
this.onStartDateChange(value ? new Date(value) : null);
}
handleEndDateChange(event: Event): void {
const input = event.target as HTMLInputElement;
const value = input.value;
this.onEndDateChange(value ? new Date(value) : null);
}
// Méthode pour calculer la fin de la page courante
getCurrentPageEnd(): number {
const end = this.currentPage * this.itemsPerPage;
return end > this.totalItems ? this.totalItems : end;
}
getAmountColor(amount: number): string {
if (amount >= 10000) return 'text-danger fw-bold';
@ -414,25 +524,41 @@ export class TransactionsList implements OnInit, OnDestroy {
return TransactionUtils.getTypeDisplayName(type);
}
// Méthodes pour calculer les stats côté client
calculateStats() {
const filtered = this.filterTransactions(this.allTransactions);
return {
total: filtered.length,
successCount: filtered.filter(tx => tx.status === 'SUCCESS').length,
failedCount: filtered.filter(tx => tx.status === 'FAILED').length,
pendingCount: filtered.filter(tx => tx.status === 'PENDING').length,
cancelledCount: filtered.filter(tx => tx.status === 'CANCELLED').length,
totalAmount: filtered.reduce((sum, tx) => sum + (tx.amount || 0), 0),
averageAmount: filtered.length > 0 ?
filtered.reduce((sum, tx) => sum + (tx.amount || 0), 0) / filtered.length : 0
};
}
// Méthodes pour sécuriser l'accès aux stats
getTotal(): number {
return this.paginatedData?.stats?.total || 0;
return this.calculateStats().total;
}
getSuccessCount(): number {
return this.paginatedData?.stats?.successCount || 0;
return this.calculateStats().successCount;
}
getFailedCount(): number {
return this.paginatedData?.stats?.failedCount || 0;
return this.calculateStats().failedCount;
}
getPendingCount(): number {
return this.paginatedData?.stats?.pendingCount || 0;
return this.calculateStats().pendingCount;
}
getTotalAmount(): number {
return this.paginatedData?.stats?.totalAmount || 0;
return this.calculateStats().totalAmount;
}
// Méthodes pour le template
@ -449,29 +575,36 @@ export class TransactionsList implements OnInit, OnDestroy {
}
getScopeText(): string {
if (this.access.isMerchantUser && this.currentMerchantId) {
if (this.access.isMerchantUser && this.currentMerchantId > 0) {
return `Marchand ${this.currentMerchantId}`;
} else if (this.access.isHubUser && this.filters.merchantPartnerId) {
return `Marchand ${this.filters.merchantPartnerId}`;
}
} else if (this.access.isHubUser) {
return 'Tous les marchands';
}
return 'Aucun scope défini';
}
// Méthode pour recharger les données
refreshData() {
this.loadTransactions();
this.lastLoadTime = 0; // Invalider le cache
this.loadAllTransactions();
}
// Debug
showDebugInfo() {
console.log('=== DEBUG INFO ===');
console.log('Permissions:', {
isMerchantUser: this.access.isMerchantUser,
isHubUser: this.access.isHubUser,
merchantId: this.currentMerchantId,
allowedMerchantIds: this.access.allowedMerchantIds,
filters: this.filters
});
console.log('Transactions chargées:', this.transactions.length);
// Méthodes utilitaires
getFilteredCount(): number {
return this.transactions.length;
}
getTotalCount(): number {
return this.allTransactions.length;
}
getSuccessRate(): number {
const stats = this.calculateStats();
return stats.total > 0 ? Math.round((stats.successCount / stats.total) * 100) : 0;
}
canUseMerchantFilter(): boolean {
return this.access.isHubUser && this.access.canViewAllTransactions;
}
}

View File

@ -61,7 +61,7 @@ export class TransactionAccessService {
// Informations utilisateur
userRole,
userRoleLabel: this.roleService.getRoleLabel() || 'Utilisateur',
userRoleLabel: this.roleService.getRoleLabel(userRole) || 'Utilisateur',
merchantId
};

View File

@ -1,7 +1,7 @@
export const environment = {
production: false,
localServiceTestApiUrl: "https://backoffice.dcb.pixpay.sn/api/v1",
iamApiUrl: "https://api-user-service.dcb.pixpay.sn/api/v1",
localServiceTestApiUrl: "http://localhost:4200/api/v1",
iamApiUrl: "http://localhost:3000/api/v1",
configApiUrl: 'https://api-merchant-config-service.dcb.pixpay.sn/api/v1',
apiCoreUrl: 'https://api-core-service.dcb.pixpay.sn/api/v1',
reportingApiUrl: 'https://api-reporting-service.dcb.pixpay.sn/api/v1/',