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, of } from 'rxjs'; import { catchError, map, takeUntil, tap } from 'rxjs/operators'; import { Merchant, ConfigType, Operator, MerchantUtils, UserRole, PaginatedResponse, SearchMerchantsParams, } from '@core/models/merchant-config.model'; import { MerchantConfigService } from '../merchant-config.service'; import { RoleManagementService } from '@core/services/hub-users-roles-management.service'; import { AuthService } from '@core/services/auth.service'; import { UiCard } from '@app/components/ui-card'; import { DomSanitizer } from '@angular/platform-browser'; import { MinioService } from '@core/services/minio.service'; @Component({ selector: 'app-merchant-config-list', standalone: true, imports: [ CommonModule, FormsModule, NgIcon, UiCard, NgbPaginationModule ], templateUrl: './merchant-config-list.html', }) export class MerchantConfigsList implements OnInit, OnDestroy { private authService = inject(AuthService); private merchantConfigService = inject(MerchantConfigService); protected roleService = inject(RoleManagementService); private cdRef = inject(ChangeDetectorRef); private destroy$ = new Subject(); private minioService = inject(MinioService); // Cache des URLs de logos private logoUrlCache = new Map(); // Ajouter un cache pour les logos non trouvés private logoErrorCache = new Set(); // Configuration readonly ConfigType = ConfigType; readonly Operator = Operator; readonly MerchantUtils = MerchantUtils; // Inputs @Input() canCreateMerchants: boolean = false; @Input() canDeleteMerchants: boolean = false; // Outputs @Output() merchantSelected = new EventEmitter(); @Output() openCreateMerchantModal = new EventEmitter(); @Output() editMerchantRequested = new EventEmitter(); @Output() deleteMerchantRequested = new EventEmitter(); // Données allMerchants: Merchant[] = []; displayedMerchants: Merchant[] = []; // États loading = false; error = ''; // Recherche et filtres searchTerm = ''; operatorFilter: Operator | 'all' = 'all'; // Pagination currentPage = 1; itemsPerPage = 10; itemsPerPageOptions = [5, 10, 20, 50, 100]; // Options pour le sélecteur totalItems = 0; totalPages = 0; // Tri sortField: keyof Merchant = 'name'; sortDirection: 'asc' | 'desc' = 'asc'; // Filtres disponibles availableOperators: { value: Operator | 'all'; label: string }[] = []; // Permissions currentUserRole: any = null; isHubUser = false; // Getters get showCreateButton(): boolean { return this.canCreateMerchants; } get showDeleteButton(): boolean { return this.canDeleteMerchants; } getColumnCount(): number { return 7; } ngOnInit() { this.initializeAvailableFilters(); this.loadCurrentUserPermissions(); } private loadCurrentUserPermissions() { this.authService.getUserProfile() .pipe(takeUntil(this.destroy$)) .subscribe({ next: (user) => { this.currentUserRole = this.extractUserRole(user); this.isHubUser = this.checkIfHubUser(); if (this.isHubUser) { this.loadMerchants(); } }, error: (error) => { console.error('Error loading current user permissions:', error); this.loadMerchants(); } }); } private extractUserRole(user: any): any { const userRoles = this.authService.getCurrentUserRoles(); return userRoles && userRoles.length > 0 ? userRoles[0] : null; } private checkIfHubUser(): boolean { if (!this.currentUserRole) return false; const hubRoles = [ UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT ]; return hubRoles.includes(this.currentUserRole); } private initializeAvailableFilters() { this.availableOperators = [ { value: 'all', label: 'Tous les opérateurs' }, { value: Operator.ORANGE_OSN, label: 'Orange' } ]; } loadMerchants(): void { if (!this.isHubUser) { console.log('⚠️ User is not a Hub user, merchant list not displayed'); return; } this.loading = true; this.error = ''; const params = this.buildSearchParams(); const skip = (this.currentPage - 1) * this.itemsPerPage; this.merchantConfigService.getAllMerchants(this.currentPage, this.itemsPerPage, params) .pipe( takeUntil(this.destroy$), catchError(error => { console.error('❌ Error loading merchants:', error); this.error = 'Erreur lors du chargement des marchands'; return of({ items: [], total: 0, page: this.currentPage, limit: this.itemsPerPage, totalPages: 0 } as PaginatedResponse); }) ) .subscribe(response => { this.allMerchants = response.items || []; this.displayedMerchants = response.items || []; this.totalItems = response.total || 0; this.totalPages = response.totalPages || Math.ceil((response.total || 0) / this.itemsPerPage); this.loading = false; this.cdRef.detectChanges(); console.log('📊 Pagination response:', { page: response.page, total: response.total, totalPages: this.totalPages, itemsCount: response.items?.length, limit: response.limit }); }); } // ==================== AFFICHAGE DU LOGO ==================== /** * Récupère l'URL du logo avec fallback automatique */ getMerchantLogoUrl( merchantId: number | undefined, logoFileName: string, merchantName: string ): Observable { const newMerchantId = String(merchantId); const cacheKey = `${merchantId}_${logoFileName}`; // Vérifier si le logo est en cache d'erreur if (this.logoErrorCache.has(cacheKey)) { const defaultLogo = this.getDefaultLogoUrl(merchantName); return of(defaultLogo); } // Vérifier le cache normal if (this.logoUrlCache.has(cacheKey)) { return of(this.logoUrlCache.get(cacheKey)!); } // Récupérer l'URL depuis l'API avec la nouvelle structure return this.minioService.getMerchantLogoUrl( newMerchantId, logoFileName, { signed: true, expirySeconds: 3600 } ).pipe( map(response => { // Extraire l'URL de la réponse const url = response.data.url ; // Mettre en cache avec la clé composite this.logoUrlCache.set(cacheKey, url); return url; }), catchError(error => { console.warn(`⚠️ Logo not found for merchant ${merchantId}: ${logoFileName}`, error); // En cas d'erreur, ajouter au cache d'erreur this.logoErrorCache.add(cacheKey); // Générer un logo par défaut const defaultLogo = this.getDefaultLogoUrl(merchantName); // Mettre le logo par défaut dans le cache normal aussi this.logoUrlCache.set(cacheKey, defaultLogo); return of(defaultLogo); }) ); } /** * Génère une URL de logo par défaut basée sur les initiales */ getDefaultLogoUrl(merchantName: string): string { // Créer des initiales significatives const initials = this.extractInitials(merchantName); // Palette de couleurs agréables const colors = [ '667eea', // Violet '764ba2', // Violet foncé 'f56565', // Rouge '4299e1', // Bleu '48bb78', // Vert 'ed8936', // Orange 'FF6B6B', // Rouge clair '4ECDC4', // Turquoise '45B7D1', // Bleu clair '96CEB4' // Vert menthe ]; const colorIndex = merchantName.length % colors.length; const backgroundColor = colors[colorIndex]; // Taille fixe à 80px (l'API génère un carré de cette taille) // L'image sera redimensionnée à 40px via CSS return `https://ui-avatars.com/api/?name=${encodeURIComponent(initials)}&background=${backgroundColor}&color=FFFFFF&size=80`; } /** * Extrait les initiales de manière intelligente */ private extractInitials(name: string): string { if (!name || name.trim() === '') { return '??'; } // Nettoyer le nom const cleanedName = name.trim().toUpperCase(); // Extraire les mots const words = cleanedName.split(/\s+/); // Si un seul mot, prendre les deux premières lettres if (words.length === 1) { return words[0].substring(0, 2) || '??'; } // Prendre la première lettre des deux premiers mots const initials = words .slice(0, 2) // Prendre les 2 premiers mots .map(word => word[0] || '') .join(''); return initials || name.substring(0, 2).toUpperCase() || '??'; } /** * Gère les erreurs de chargement des logos MinIO */ onLogoError(event: Event, merchantName: string): void { const img = event.target as HTMLImageElement; if (!img) return; console.warn('Logo MinIO failed to load, using default for:', merchantName); img.onerror = null; img.src = this.getDefaultLogoUrl(merchantName); } /** * Gère les erreurs de chargement des logos par défaut */ onDefaultLogoError(event: Event | string): void { if (!(event instanceof Event)) { console.error('Default logo error (non-event):', event); return; } const img = event.target as HTMLImageElement | null; if (!img) return; console.error('Default logo also failed to load, using fallback SVG'); // SVG local img.onerror = null; // éviter boucle infinie img.src = 'assets/images/default-merchant-logo.svg'; // Dernier recours img.onerror = (e) => { if (!(e instanceof Event)) return; const fallbackImg = e.target as HTMLImageElement | null; if (!fallbackImg) return; fallbackImg.onerror = null; fallbackImg.src = this.generateFallbackDataUrl(); }; } /** * Génère un fallback SVG en data URL */ private generateFallbackDataUrl(): string { const svg = ` ? `; return 'data:image/svg+xml;base64,' + btoa(svg); } private buildSearchParams(): SearchMerchantsParams { const params: SearchMerchantsParams = {}; if (this.searchTerm.trim()) { params.query = this.searchTerm.trim(); } return params; } // ==================== RECHERCHE ET FILTRES ==================== onSearch() { this.currentPage = 1; this.loadMerchants(); } onClearFilters() { this.searchTerm = ''; this.operatorFilter = 'all'; this.currentPage = 1; this.loadMerchants(); } onFilterChange() { this.currentPage = 1; this.loadMerchants(); } filterByOperator(operator: Operator | 'all') { this.operatorFilter = operator; this.currentPage = 1; this.loadMerchants(); } // ==================== GESTION DU NOMBRE D'ÉLÉMENTS PAR PAGE ==================== onItemsPerPageChange() { console.log(`📊 Changing items per page to: ${this.itemsPerPage}`); this.currentPage = 1; // Retour à la première page this.loadMerchants(); } // ==================== TRI ==================== sort(field: keyof Merchant) { if (this.sortField === field) { this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc'; } else { this.sortField = field; this.sortDirection = 'asc'; } this.currentPage = 1; this.loadMerchants(); } getSortIcon(field: string): string { if (this.sortField !== field) return 'lucideArrowUpDown'; return this.sortDirection === 'asc' ? 'lucideArrowUp' : 'lucideArrowDown'; } // ==================== PAGINATION ==================== onPageChange(page: number) { console.log(`📄 Page changed to: ${page}`); this.currentPage = page; this.loadMerchants(); } getStartIndex(): number { return this.totalItems > 0 ? (this.currentPage - 1) * this.itemsPerPage + 1 : 0; } getEndIndex(): number { return Math.min(this.currentPage * this.itemsPerPage, this.totalItems); } // ==================== ACTIONS ==================== viewMerchantProfile(merchant: Merchant) { this.merchantSelected.emit(merchant.id!); } editMerchant(merchant: Merchant) { this.editMerchantRequested.emit(merchant); } deleteMerchant(merchant: Merchant) { this.deleteMerchantRequested.emit(merchant); } // ==================== MÉTHODES STATISTIQUES ==================== getTotalMerchantsCount(): number { return this.totalItems; } getTotalConfigsCount(): number { return this.allMerchants.reduce((total, merchant) => total + (merchant.configs ? merchant.configs.length : 0), 0); } getTotalContactsCount(): number { return this.allMerchants.reduce((total, merchant) => total + (merchant.technicalContacts ? merchant.technicalContacts.length : 0), 0); } // ==================== MÉTHODES D'AFFICHAGE ==================== getConfigTypeLabel(configName: ConfigType | string): string { return MerchantUtils.getConfigTypeName(configName); } formatTimestamp(timestamp: string): string { if (!timestamp) return 'Non disponible'; return new Date(timestamp).toLocaleDateString('fr-FR', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); } // ==================== MÉTHODES POUR LE TEMPLATE ==================== refreshData() { this.currentPage = 1; this.loadMerchants(); } getCardTitle(): string { return 'Tous les Marchands'; } getHelperText(): string { return 'Vue administrative - Gestion de tous les marchands'; } getHelperIcon(): string { return 'lucideShield'; } getLoadingText(): string { return 'Chargement des marchands...'; } getEmptyStateTitle(): string { return 'Aucun marchand trouvé'; } getEmptyStateDescription(): string { return 'Aucun marchand ne correspond à vos critères de recherche.'; } getEmptyStateButtonText(): string { return 'Créer le premier marchand'; } shouldDisplayMerchantList(): boolean { return this.isHubUser; } ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); // Nettoyer les caches this.logoUrlCache.clear(); this.logoErrorCache.clear(); } }