diff --git a/src/app/core/models/dcb-bo-hub-subscription.model.ts b/src/app/core/models/dcb-bo-hub-subscription.model.ts new file mode 100644 index 0000000..a64ac9d --- /dev/null +++ b/src/app/core/models/dcb-bo-hub-subscription.model.ts @@ -0,0 +1,142 @@ +// === ENUMS COHÉRENTS === +export enum SubscriptionPeriodicity { + DAILY = 'Daily', + WEEKLY = 'Weekly', + MONTHLY = 'Monthly', + YEARLY = 'Yearly' +} + +export enum SubscriptionStatus { + ACTIVE = 'ACTIVE', + SUSPENDED = 'SUSPENDED', + CANCELLED = 'CANCELLED', + EXPIRED = 'EXPIRED', + PENDING = 'PENDING' +} + +export enum Currency { + XOF = 'XOF', + EUR = 'EUR', + USD = 'USD' +} + +// === MODÈLE SUBSCRIPTION PRINCIPAL === +export interface Subscription { + id: number; + externalReference?: string | null; + periodicity: SubscriptionPeriodicity; + startDate: string; + endDate?: string | null; + amount: number; + currency: Currency; + token: string; + status: SubscriptionStatus; + nextPaymentDate: string; + suspendedAt?: string | null; + merchantPartnerId: number; + customerId: number; + planId: number; + serviceId: number; + failureCount: number; + createdAt: string; + updatedAt: string; + metadata?: { + note?: string; + }; +} + +// === RÉPONSES API === +export interface SubscriptionsResponse { + subscriptions: Subscription[]; +} + +export interface MerchantSubscriptionsResponse { + merchantId: number; + subscriptions: Subscription[]; + statistics: { + total: number; + active: number; + totalRevenue: number; + }; +} + +// Interface pour les paramètres de recherche +export interface SearchSubscriptionsParams { + merchantId?: number; + status?: SubscriptionStatus; + startDate?: string; + endDate?: string; + periodicity?: SubscriptionPeriodicity; + customerId?: number; + page?: number; + limit?: number; + sortBy?: 'startDate' | 'amount' | 'nextPaymentDate' | 'createdAt'; + sortOrder?: 'ASC' | 'DESC'; +} + +// === UTILITAIRES === +export class SubscriptionUtils { + static 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; + } + + static getPeriodicityDisplayName(periodicity: SubscriptionPeriodicity): string { + const periodicityNames = { + [SubscriptionPeriodicity.DAILY]: 'Quotidien', + [SubscriptionPeriodicity.WEEKLY]: 'Hebdomadaire', + [SubscriptionPeriodicity.MONTHLY]: 'Mensuel', + [SubscriptionPeriodicity.YEARLY]: 'Annuel' + }; + return periodicityNames[periodicity] || periodicity; + } + + static formatAmount(amount: number, currency: Currency): string { + return new Intl.NumberFormat('fr-FR', { + style: 'currency', + currency: currency + }).format(amount); + } +} + +// === MODÈLE PAIEMENT === +export interface SubscriptionPayment { + id: number; + subscriptionId: string; + amount: number; + currency: Currency; + status: 'PENDING' | 'SUCCESS' | 'FAILED'; + reference: string; + description: string; + metadata: { + internatRef: string; + [key: string]: any; + }; + partnerId: number; + createdAt: string; + updatedAt: string; +} + +// === RÉPONSE LISTE DES PAIEMENTS === +export interface SubscriptionPaymentsResponse { + subscriptionId: string; + payments: SubscriptionPayment[]; +} + +// === UTILITAIRES === +export class PaymentUtils { + static getStatusDisplayName(status: 'PENDING' | 'SUCCESS' | 'FAILED' | string): string { + const statusNames: Record = { + 'PENDING': 'En attente', + 'SUCCESS': 'Réussi', + 'FAILED': 'Échoué' + }; + return statusNames[status] || status; + } +} \ No newline at end of file diff --git a/src/app/core/services/menu.service.ts b/src/app/core/services/menu.service.ts index 5b2ba5d..02eda37 100644 --- a/src/app/core/services/menu.service.ts +++ b/src/app/core/services/menu.service.ts @@ -97,6 +97,13 @@ export class MenuService { ], }, + { 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 }, { label: 'Utilisateurs Hub', diff --git a/src/app/core/services/permissions.service.ts b/src/app/core/services/permissions.service.ts index 3d41aca..330df80 100644 --- a/src/app/core/services/permissions.service.ts +++ b/src/app/core/services/permissions.service.ts @@ -58,6 +58,22 @@ export class PermissionsService { roles: this.allRoles }, + // Subscriptions + { + module: 'subscriptions', + roles: this.allRoles + }, + { + module: 'subscriptions-merchant', + roles: this.allRoles + }, + + // Payments + { + module: 'subscriptions-payments', + roles: this.allRoles + }, + // Settings - Tout le monde { module: 'merchant-configs', diff --git a/src/app/layouts/components/data.ts b/src/app/layouts/components/data.ts index 73cd7cd..719f848 100644 --- a/src/app/layouts/components/data.ts +++ b/src/app/layouts/components/data.ts @@ -94,6 +94,14 @@ export const menuItems: MenuItemType[] = [ ], }, + { 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é // --------------------------- diff --git a/src/app/layouts/components/topbar/topbar.ts b/src/app/layouts/components/topbar/topbar.ts index 0729e27..691105b 100644 --- a/src/app/layouts/components/topbar/topbar.ts +++ b/src/app/layouts/components/topbar/topbar.ts @@ -11,7 +11,6 @@ import { UserProfile } from '@layouts/components/topbar/components/user-profile/ import { NotificationDropdown } from '@layouts/components/topbar/components/notification-dropdown/notification-dropdown' import { ThemeDropdown } from '@layouts/components/topbar/components/theme-dropdown/theme-dropdown' import { - NgbActiveOffcanvas, NgbDropdownModule, } from '@ng-bootstrap/ng-bootstrap' @@ -21,11 +20,9 @@ import { NgIcon, RouterLink, NgbDropdownModule, - LanguageDropdown, CustomizerToggler, ThemeToggler, UserProfile, - NotificationDropdown, ThemeDropdown, ], templateUrl: './topbar.html', diff --git a/src/app/modules/modules.routes.ts b/src/app/modules/modules.routes.ts index 2c86436..193dca5 100644 --- a/src/app/modules/modules.routes.ts +++ b/src/app/modules/modules.routes.ts @@ -21,6 +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'; const routes: Routes = [ // --------------------------- @@ -86,7 +88,83 @@ const routes: Routes = [ }, // --------------------------- - // Partners + // Subscriptions + // --------------------------- + { + path: 'subscriptions', + component: SubscriptionsManagement, + canActivate: [authGuard, roleGuard], + data: { + title: 'Gestion des Abonnements', + module: 'subscriptions', + requiredRoles: [ + 'dcb-admin', + 'dcb-support', + 'dcb-partner', + 'dcb-partner-admin', + 'dcb-partner-manager', + 'dcb-partner-support', + ] + } + }, + { + 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 (existant - gardé pour référence) // --------------------------- { path: 'merchant-users-management', @@ -102,11 +180,10 @@ const routes: Routes = [ 'dcb-partner', 'dcb-partner-admin', 'dcb-partner-manager', - 'dcb-partner-suport', + 'dcb-partner-support', ] } }, - // --------------------------- // Operators (Admin seulement) // --------------------------- diff --git a/src/app/modules/subscriptions/subscription-payments-list/subscription-payments-list.html b/src/app/modules/subscriptions/subscription-payments-list/subscription-payments-list.html new file mode 100644 index 0000000..e362f89 --- /dev/null +++ b/src/app/modules/subscriptions/subscription-payments-list/subscription-payments-list.html @@ -0,0 +1,332 @@ + + + + {{ getHelperText() }} + + +
+ + +
+
+
+ +
+ + + + +
+
+
+ +
+
+ +
+
+
+ + +
+
+
+ + + + +
+
+ +
+ +
+ +
+ +
+ +
+ +
+
+ + + @if (loading) { +
+
+ Chargement... +
+

{{ getLoadingText() }}

+
+ } + + + @if (error && !loading) { + + } + + + @if (!loading && !error) { +
+ + + + + @if (showSubscriptionColumn) { + + } + + + + + + + + + @for (payment of displayedPayments; track payment.id) { + + + @if (showSubscriptionColumn) { + + } + + + + + + + } + @empty { + + + + } + +
Abonnement +
+ Référence + +
+
+
+ Date + +
+
+
+ Montant + +
+
+
+ Statut + +
+
Actions
+
+
+ +
+
+ + #{{ payment.subscriptionId }} + + +
+
+
+
+
+ +
+
+ + {{ truncateText(payment.reference, 15) }} + + + Ref: {{ getInternalReference(payment) }} + +
+
+
+
+ + {{ formatDate(payment.createdAt) }} + + + {{ formatDateTime(payment.createdAt) }} + +
+
+ + {{ formatAmount(payment.amount, payment.currency) }} + + {{ payment.currency }} + + + + {{ getPaymentStatusDisplayName(payment.status) }} + + @if (payment.description) { +
+ + {{ truncateText(payment.description, 30) }} + +
+ } +
+
+ + +
+
+
+ +
{{ getEmptyStateTitle() }}
+

{{ getEmptyStateDescription() }}

+
+
+
+ + + @if (totalPages > 1) { +
+
+ Affichage de {{ getStartIndex() }} à {{ getEndIndex() }} sur {{ totalItems }} paiements +
+ +
+ } + + + @if (displayedPayments.length > 0) { +
+
+
+ + Total : {{ getPaymentsSummary().total }} paiements + +
+
+ + Réussis : {{ getPaymentsSummary().success }} + +
+
+ + Échoués : {{ getPaymentsSummary().failed }} + +
+
+ + En attente : {{ getPaymentsSummary().pending }} + +
+
+ + Montant total : {{ formatAmount(getPaymentsSummary().amount, Currency.XOF) }} + +
+
+ + Taux de réussite : {{ getSuccessRate().toFixed(1) }}% + +
+
+
+ } + } +
+
\ No newline at end of file diff --git a/src/app/modules/subscriptions/subscription-payments-list/subscription-payments-list.ts b/src/app/modules/subscriptions/subscription-payments-list/subscription-payments-list.ts new file mode 100644 index 0000000..b6d705e --- /dev/null +++ b/src/app/modules/subscriptions/subscription-payments-list/subscription-payments-list.ts @@ -0,0 +1,491 @@ +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(); + + // Configuration + readonly Currency = Currency; + + // Inputs + @Input() subscriptionId!: string; + + // Outputs + @Output() paymentSelected = new EventEmitter(); + @Output() viewSubscriptionDetails = new EventEmitter(); + + // 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); + this.currentMerchantPartnerId = this.extractMerchantPartnerId(user); + + console.log('Payments Context Loaded:', { + role: this.currentUserRole, + merchantPartnerId: this.currentMerchantPartnerId + }); + }, + error: (error) => { + console.error('Error loading current user permissions:', error); + this.fallbackPermissions(); + } + }); + } + + private extractUserRole(user: any): string | null { + const userRoles = this.authService.getCurrentUserRoles(); + if (userRoles && userRoles.length > 0) { + return userRoles[0]; + } + return null; + } + + private extractMerchantPartnerId(user: any): string { + if (user?.merchantPartnerId) { + return user.merchantPartnerId; + } + return this.authService.getCurrentMerchantPartnerId() || ''; + } + + private fallbackPermissions(): void { + this.currentUserRole = this.authService.getCurrentUserRole(); + this.currentMerchantPartnerId = this.authService.getCurrentMerchantPartnerId() || ''; + } + + 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; + + 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'; + } +} \ No newline at end of file diff --git a/src/app/modules/subscriptions/subscription-payments/subscription-payments.html b/src/app/modules/subscriptions/subscription-payments/subscription-payments.html new file mode 100644 index 0000000..bf27074 --- /dev/null +++ b/src/app/modules/subscriptions/subscription-payments/subscription-payments.html @@ -0,0 +1,408 @@ +
+ +
+
+
+
+

+ @if (subscription) { + Paiements - Abonnement #{{ subscription.id }} + } @else { + Paiements de l'Abonnement + } +

+ +
+ +
+ + +
+
+
+
+ + + @if (error) { +
+
+ +
{{ error }}
+ +
+
+ } + + @if (success) { +
+
+ +
{{ success }}
+ +
+
+ } + +
+ + @if (loading) { +
+
+ Chargement... +
+

Chargement des détails de l'abonnement...

+
+ } + + + @if (subscription && !loading) { + +
+ +
+
+
Détails de l'Abonnement
+
+
+ +
+
+ +
+
+ +
Abonnement #{{ subscription.id }}
+

{{ subscription.token.substring(0, 20) }}...

+ + + + + {{ getStatusDisplayName(subscription.status) }} + + + +
+ + + @switch (getSubscriptionHealth()) { + @case ('good') { Bon état } + @case ('warning') { Attention nécessaire } + @case ('danger') { Problème détecté } + } + +
+ + +
+
+ + {{ formatAmount(subscription.amount, subscription.currency) }} +
+
+ + {{ getPeriodicityDisplayName(subscription.periodicity) }} +
+
+ + Début : {{ formatDate(subscription.startDate) }} +
+
+ + Prochain : {{ formatDate(subscription.nextPaymentDate) }} +
+ @if (subscription.endDate) { +
+ + Fin : {{ formatDate(subscription.endDate) }} +
+ } +
+
+
+ + +
+
+
Statistiques des Paiements
+
+
+
+
+
{{ getTotalPayments() }}
+ Total +
+
+
{{ getSuccessfulPayments() }}
+ Réussis +
+
+
{{ getFailedPayments() }}
+ Échoués +
+
+
{{ getPendingPayments() }}
+ En attente +
+
+
+
+
{{ formatAmount(getTotalAmount(), subscription.currency) }}
+ Montant total collecté +
+
+
{{ getSuccessRate().toFixed(1) }}%
+ Taux de réussite +
+
+
+ + +
+
+
Informations Techniques
+
+
+
+
+ ID Abonnement : +
{{ subscription.id }}
+
+
+ Token : +
{{ subscription.token }}
+
+
+ Merchant ID : +
{{ subscription.merchantPartnerId }}
+
+
+ Client ID : +
{{ subscription.customerId }}
+
+
+ Échecs : +
{{ subscription.failureCount }}
+
+
+
+
+
+ + +
+ +
+
+
Filtres des Paiements
+
+
+
+
+ + +
+
+ + +
+
+ +
+
+
+
+ + +
+
+
+ + Historique des Paiements + {{ getSortedPayments().length }} +
+
+ +
+ + @if (loadingPayments) { +
+
+ Chargement... +
+

Chargement des paiements...

+
+ } + + + @if (!loadingPayments) { +
+ + + + + + + + + + + + + @for (payment of getSortedPayments(); track payment.id) { + + + + + + + + + } + @empty { + + + + } + +
RéférenceDateMontantStatutDescriptionActions
+ + {{ payment.reference.substring(0, 12) }}... + + + {{ formatDateTime(payment.createdAt) }} + + {{ formatAmount(payment.amount, payment.currency) }} + + + + {{ getPaymentStatusDisplayName(payment.status) }} + + + {{ payment.description }} + @if (payment.metadata.internatRef) { +
+ + Ref: {{ payment.metadata.internatRef }} + + } +
+ +
+
+ +
Aucun paiement trouvé
+

Aucun paiement ne correspond à vos critères de filtrage.

+
+
+
+ + + @if (getSortedPayments().length > 0) { +
+
+
+ + Affichage de {{ getSortedPayments().length }} paiement(s) + +
+ @if (statusFilter !== 'all') { +
+ + Filtre : {{ + statusFilter === 'SUCCESS' ? 'Réussis' : + statusFilter === 'FAILED' ? 'Échoués' : 'En attente' + }} + +
+ } +
+
+ } + } +
+
+ + + @if (isExpiringSoon()) { +
+
+ +
+ Attention : Cet abonnement expire bientôt +
+
+
+ } + + @if (isExpired()) { +
+
+ +
+ Expiré : Cet abonnement a expiré +
+
+
+ } + + @if (getDaysUntilNextPayment() <= 3 && subscription.status === SubscriptionStatus.ACTIVE) { +
+
+ +
+ Prochain paiement : Dans {{ getDaysUntilNextPayment() }} jour(s) +
+
+
+ } +
+ } +
+
\ No newline at end of file diff --git a/src/app/modules/subscriptions/subscription-payments/subscription-payments.ts b/src/app/modules/subscriptions/subscription-payments/subscription-payments.ts new file mode 100644 index 0000000..09a3b4f --- /dev/null +++ b/src/app/modules/subscriptions/subscription-payments/subscription-payments.ts @@ -0,0 +1,439 @@ +import { Component, inject, OnInit, Input, Output, EventEmitter, ChangeDetectorRef, OnDestroy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { NgIcon } from '@ng-icons/core'; +import { NgbAlertModule } from '@ng-bootstrap/ng-bootstrap'; +import { Subject, takeUntil } from 'rxjs'; + +import { + Subscription, + SubscriptionPayment, + SubscriptionStatus, + SubscriptionPeriodicity, + Currency +} from '@core/models/dcb-bo-hub-subscription.model'; + +import { SubscriptionsService } from '../subscriptions.service'; +import { AuthService } from '@core/services/auth.service'; + +@Component({ + selector: 'app-subscription-payments', + standalone: true, + imports: [CommonModule, FormsModule, NgIcon, NgbAlertModule], + templateUrl: './subscription-payments.html', + styles: [` + .avatar-lg { + width: 80px; + height: 80px; + } + .fs-24 { + font-size: 24px; + } + `] +}) +export class SubscriptionPayments implements OnInit, OnDestroy { + private subscriptionsService = inject(SubscriptionsService); + private authService = inject(AuthService); + private cdRef = inject(ChangeDetectorRef); + private destroy$ = new Subject(); + + readonly SubscriptionStatus = SubscriptionStatus; + readonly SubscriptionPeriodicity = SubscriptionPeriodicity; + readonly Currency = Currency; + + @Input() subscriptionId!: string; + @Output() back = new EventEmitter(); + + subscription: Subscription | null = null; + payments: SubscriptionPayment[] = []; + loading = false; + loadingPayments = false; + error = ''; + success = ''; + + // Gestion des permissions + currentUserRole: string | null = null; + + merchantPartnerId: number | undefined; + + // Filtres + statusFilter: 'all' | 'SUCCESS' | 'FAILED' | 'PENDING' = 'all'; + dateFilter: 'all' | 'week' | 'month' | 'quarter' = 'all'; + + ngOnInit() { + if (this.subscriptionId) { + this.loadCurrentUserPermissions(); + this.loadSubscriptionDetails(); + } + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + /** + * Charge les permissions de l'utilisateur courant + */ + private loadCurrentUserPermissions(): void { + this.authService.getUserProfile() + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (profile) => { + this.currentUserRole = this.authService.getCurrentUserRole(); + this.cdRef.detectChanges(); + }, + error: (error) => { + console.error('Error loading user permissions:', error); + } + }); + } + + /** + * Charge les détails de l'abonnement puis les paiements + */ + loadSubscriptionDetails() { + this.loading = true; + this.error = ''; + + 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); + + 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'; + this.loading = false; + this.cdRef.detectChanges(); + console.error('Error loading subscription details:', error); + } + }); + } + + /** + * Charge les paiements de l'abonnement + */ + 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'; + this.loadingPayments = false; + this.cdRef.detectChanges(); + 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({ + next: (response) => { + this.payments = response.payments || []; + this.loadingPayments = false; + this.cdRef.detectChanges(); + }, + 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) { + this.error = 'Aucun paiement trouvé pour cet abonnement'; + } else { + this.error = 'Erreur lors du chargement des paiements'; + } + + this.loadingPayments = false; + this.cdRef.detectChanges(); + } + }); + } + /** + * Applique les filtres aux paiements + */ + applyFilters() { + this.loadPayments(this.merchantPartnerId); // Recharger avec les filtres actuels + } + + /** + * Réinitialise les filtres + */ + clearFilters() { + this.statusFilter = 'all'; + this.dateFilter = 'all'; + this.applyFilters(); + } + + // ==================== 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'; + } + + 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; + } + + 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; + } + + getPeriodicityDisplayName(periodicity: SubscriptionPeriodicity): string { + const periodicityNames = { + [SubscriptionPeriodicity.DAILY]: 'Quotidien', + [SubscriptionPeriodicity.WEEKLY]: 'Hebdomadaire', + [SubscriptionPeriodicity.MONTHLY]: 'Mensuel', + [SubscriptionPeriodicity.YEARLY]: 'Annuel' + }; + return periodicityNames[periodicity] || periodicity; + } + + 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 ==================== + + getTotalPayments(): number { + return this.payments.length; + } + + getSuccessfulPayments(): number { + return this.payments.filter(p => p.status === 'SUCCESS').length; + } + + getFailedPayments(): number { + return this.payments.filter(p => p.status === 'FAILED').length; + } + + getPendingPayments(): number { + return this.payments.filter(p => p.status === 'PENDING').length; + } + + getTotalAmount(): number { + return this.payments + .filter(p => p.status === 'SUCCESS') + .reduce((sum, p) => sum + p.amount, 0); + } + + getSuccessRate(): number { + if (this.payments.length === 0) return 0; + return (this.getSuccessfulPayments() / this.payments.length) * 100; + } + + // ==================== MÉTHODES DE NAVIGATION ==================== + + goBack() { + this.back.emit(); + } + + // ==================== MÉTHODES UTILITAIRES ==================== + + refresh() { + this.loadSubscriptionDetails(); + this.loadPayments(this.merchantPartnerId); + } + + clearMessages() { + this.error = ''; + this.success = ''; + this.cdRef.detectChanges(); + } + + // Méthodes pour le template + getPageTitle(): string { + return `Paiements - Abonnement #${this.subscription?.id || '...'}`; + } + + getContextDescription(): string { + return 'Historique des paiements pour cet abonnement'; + } + + // 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)); + } + + // 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(); + } + + // Obtenir les paiements filtrés + getFilteredPayments(): SubscriptionPayment[] { + let filtered = this.payments; + + // Filtrer par statut + if (this.statusFilter !== 'all') { + filtered = filtered.filter(p => p.status === this.statusFilter); + } + + // Filtrer par date + if (this.dateFilter !== 'all') { + 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; + } + + filtered = filtered.filter(p => new Date(p.createdAt) >= startDate); + } + + return filtered; + } + + // Trier les paiements par date (plus récents en premier) + getSortedPayments(): SubscriptionPayment[] { + return this.getFilteredPayments().sort((a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ); + } + + // Méthodes pour les indicateurs visuels + getSubscriptionHealth(): 'good' | 'warning' | 'danger' { + if (!this.subscription) return 'good'; + + if (this.subscription.failureCount > 3) return 'danger'; + if (this.subscription.failureCount > 0 || this.isExpiringSoon()) return 'warning'; + return 'good'; + } + + getSubscriptionHealthIcon(): string { + const health = this.getSubscriptionHealth(); + const icons = { + 'good': 'lucideCheckCircle', + 'warning': 'lucideAlertTriangle', + 'danger': 'lucideXCircle' + }; + return icons[health]; + } + + getSubscriptionHealthColor(): string { + const health = this.getSubscriptionHealth(); + const colors = { + 'good': 'text-success', + 'warning': 'text-warning', + 'danger': 'text-danger' + }; + return colors[health]; + } + + // Gestion des erreurs + private getErrorMessage(error: any): string { + if (error.error?.message) { + return error.error.message; + } + if (error.status === 400) { + return 'Données invalides.'; + } + 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.'; + } +} \ No newline at end of file diff --git a/src/app/modules/subscriptions/subscriptions-list/subscriptions-list.html b/src/app/modules/subscriptions/subscriptions-list/subscriptions-list.html new file mode 100644 index 0000000..713f300 --- /dev/null +++ b/src/app/modules/subscriptions/subscriptions-list/subscriptions-list.html @@ -0,0 +1,361 @@ + + + + {{ getHelperText() }} + + +
+ + +
+
+
+ +
+ + + + +
+
+
+ +
+
+ +
+
+
+ + +
+
+
+ + + + +
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
+ + + @if (loading) { +
+
+ Chargement... +
+

{{ getLoadingText() }}

+
+ } + + + @if (error && !loading) { + + } + + + @if (!loading && !error) { +
+ + + + + @if (showMerchantColumn) { + + } + + @if (showCustomerColumn) { + + } + + + + + + + + + + @for (subscription of displayedSubscriptions; track subscription.id) { + + + @if (showMerchantColumn) { + + } + + @if (showCustomerColumn) { + + } + + + + + + + + } + @empty { + + + + } + +
MerchantClient +
+ ID + +
+
+
+ Montant + +
+
Périodicité +
+ Statut + +
+
+
+ Date début + +
+
Actions
+
+
+ +
+
+ + #{{ subscription.merchantPartnerId }} + +
+
+
+
+
+ +
+
+ + #{{ subscription.customerId }} + +
+
+
+
+
+ +
+
+ #{{ subscription.id }} + + {{ subscription.token.substring(0, 12) }}... + +
+
+
+ {{ formatAmount(subscription.amount, subscription.currency) }} + {{ subscription.currency }} + + + + {{ getPeriodicityDisplayName(subscription.periodicity) }} + + + + + {{ getStatusDisplayName(subscription.status) }} + + @if (isExpiringSoon(subscription)) { +
+ + + Expire bientôt + +
+ } +
+
+ + Début: {{ formatDate(subscription.startDate) }} + + + Prochain: {{ formatDate(subscription.nextPaymentDate) }} + + @if (subscription.endDate) { + + Fin: {{ formatDate(subscription.endDate) }} + + } +
+
+
+ + +
+
+
+ +
{{ getEmptyStateTitle() }}
+

{{ getEmptyStateDescription() }}

+
+
+
+ + + @if (totalPages > 1) { +
+
+ Affichage de {{ getStartIndex() }} à {{ getEndIndex() }} sur {{ totalItems }} abonnements +
+ +
+ } + + + @if (displayedSubscriptions.length > 0) { +
+
+
+ + Total : {{ getTotalSubscriptionsCount() }} abonnements + +
+
+ + Actifs : {{ getActiveSubscriptionsCount() }} + +
+
+ + Revenu total : {{ formatAmount(getTotalRevenue(), Currency.XOF) }} + +
+
+ + Quotidiens : {{ getDailySubscriptionsCount() }} + +
+
+ + Mensuels : {{ getMonthlySubscriptionsCount() }} + +
+
+
+ } + } +
+
\ No newline at end of file diff --git a/src/app/modules/subscriptions/subscriptions-list/subscriptions-list.ts b/src/app/modules/subscriptions/subscriptions-list/subscriptions-list.ts new file mode 100644 index 0000000..fa24c3a --- /dev/null +++ b/src/app/modules/subscriptions/subscriptions-list/subscriptions-list.ts @@ -0,0 +1,548 @@ +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 { + 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, + imports: [ + CommonModule, + FormsModule, + NgIcon, + UiCard, + NgbPaginationModule + ], + templateUrl: './subscriptions-list.html', +}) +export class SubscriptionsList implements OnInit, OnDestroy { + private authService = inject(AuthService); + private subscriptionsService = inject(SubscriptionsService); + private cdRef = inject(ChangeDetectorRef); + private destroy$ = new Subject(); + + // Configuration + readonly SubscriptionStatus = SubscriptionStatus; + readonly SubscriptionPeriodicity = SubscriptionPeriodicity; + readonly Currency = Currency; + + // Outputs + @Output() subscriptionSelected = new EventEmitter(); + @Output() viewPaymentsRequested = new EventEmitter(); + + // Données + allSubscriptions: Subscription[] = []; + filteredSubscriptions: Subscription[] = []; + displayedSubscriptions: Subscription[] = []; + + // États + loading = false; + error = ''; + + // Recherche et filtres + searchTerm = ''; + statusFilter: SubscriptionStatus | 'all' = 'all'; + periodicityFilter: SubscriptionPeriodicity | 'all' = 'all'; + merchantFilter: number | 'all' = 'all'; + + // Pagination + currentPage = 1; + itemsPerPage = 10; + totalItems = 0; + totalPages = 0; + + // Tri + sortField: keyof Subscription = 'startDate'; + sortDirection: 'asc' | 'desc' = 'desc'; + + // Filtres disponibles + availableStatuses: { value: SubscriptionStatus | 'all'; label: string }[] = [ + { value: 'all', label: 'Tous les statuts' }, + { value: SubscriptionStatus.ACTIVE, label: 'Actif' }, + { value: SubscriptionStatus.SUSPENDED, label: 'Suspendu' }, + { value: SubscriptionStatus.CANCELLED, label: 'Annulé' }, + { value: SubscriptionStatus.EXPIRED, label: 'Expiré' }, + { value: SubscriptionStatus.PENDING, label: 'En attente' } + ]; + + availablePeriodicities: { value: SubscriptionPeriodicity | 'all'; label: string }[] = [ + { value: 'all', label: 'Toutes les périodicités' }, + { value: SubscriptionPeriodicity.DAILY, label: 'Quotidien' }, + { value: SubscriptionPeriodicity.WEEKLY, label: 'Hebdomadaire' }, + { value: SubscriptionPeriodicity.MONTHLY, label: 'Mensuel' }, + { 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(); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + private loadCurrentUserPermissions() { + this.authService.getUserProfile() + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (user) => { + this.currentUserRole = this.extractUserRole(user); + this.currentMerchantPartnerId = this.extractMerchantPartnerId(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(); + } + }); + } + + private extractUserRole(user: any): string | null { + const userRoles = this.authService.getCurrentUserRoles(); + if (userRoles && userRoles.length > 0) { + return userRoles[0]; + } + return null; + } + + private extractMerchantPartnerId(user: any): string { + if (user?.merchantPartnerId) { + return user.merchantPartnerId; + } + return this.authService.getCurrentMerchantPartnerId() || ''; + } + + 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.currentMerchantPartnerId = this.authService.getCurrentMerchantPartnerId() || ''; + this.canViewAllMerchants = this.canViewAllMerchantsCheck(this.currentUserRole); + } + + loadSubscriptions() { + this.loading = true; + this.error = ''; + + let subscriptionsObservable: Observable; + + 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 } + ); + } else { + // Fallback - abonnements généraux + subscriptionsObservable = this.subscriptionsService.getSubscriptions({ + page: this.currentPage, + limit: this.itemsPerPage + }); + } + + 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({ + next: (response) => { + this.allSubscriptions = response.subscriptions || []; + console.log(`✅ Loaded ${this.allSubscriptions.length} subscriptions`); + this.applyFiltersAndPagination(); + this.loading = false; + this.cdRef.detectChanges(); + }, + error: () => { + this.error = 'Erreur lors du chargement des abonnements'; + this.loading = false; + this.allSubscriptions = []; + this.filteredSubscriptions = []; + this.displayedSubscriptions = []; + this.cdRef.detectChanges(); + } + }); + } + + // Recherche et filtres + onSearch() { + this.currentPage = 1; + this.applyFiltersAndPagination(); + } + + onClearFilters() { + this.searchTerm = ''; + this.statusFilter = 'all'; + this.periodicityFilter = 'all'; + this.merchantFilter = 'all'; + this.currentPage = 1; + this.applyFiltersAndPagination(); + } + + applyFiltersAndPagination() { + if (!this.allSubscriptions) { + this.allSubscriptions = []; + } + + // 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); + } + + // Tri + sort(field: keyof Subscription) { + if (this.sortField === field) { + this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc'; + } else { + this.sortField = field; + this.sortDirection = 'asc'; + } + this.applyFiltersAndPagination(); + } + + getSortIcon(field: string): string { + if (this.sortField !== field) return 'lucideArrowUpDown'; + return this.sortDirection === 'asc' ? 'lucideArrowUp' : 'lucideArrowDown'; + } + + // Pagination + onPageChange(page: number) { + this.currentPage = page; + this.applyFiltersAndPagination(); + } + + getStartIndex(): number { + return (this.currentPage - 1) * this.itemsPerPage + 1; + } + + getEndIndex(): number { + return Math.min(this.currentPage * this.itemsPerPage, this.totalItems); + } + + // Actions + viewSubscriptionDetails(subscriptionId: string | number) { + this.subscriptionSelected.emit(subscriptionId.toString()); + } + + viewSubscriptionPayments(subscriptionId: string | number) { + this.viewPaymentsRequested.emit(subscriptionId.toString()); + } + + // 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'; + } + + 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; + } + + // 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 { + 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' + }); + } + + // Recharger les données + refreshData() { + this.loadSubscriptions(); + } + + // Méthodes pour le template + getCardTitle(): string { + return 'Abonnements'; + } + + getHelperText(): string { + return this.canViewAllMerchants + ? 'Vue administrative - Tous les abonnements' + : 'Vos abonnements'; + } + + getHelperIcon(): string { + return this.canViewAllMerchants ? 'lucideShield' : 'lucideRepeat'; + } + + getLoadingText(): string { + return 'Chargement des abonnements...'; + } + + getEmptyStateTitle(): string { + return 'Aucun abonnement trouvé'; + } + + getEmptyStateDescription(): string { + return 'Aucun abonnement ne correspond à vos critères de recherche.'; + } + + // Statistiques + getTotalSubscriptionsCount(): 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(); + } +} \ No newline at end of file diff --git a/src/app/modules/subscriptions/subscriptions.html b/src/app/modules/subscriptions/subscriptions.html new file mode 100644 index 0000000..7d4dd2c --- /dev/null +++ b/src/app/modules/subscriptions/subscriptions.html @@ -0,0 +1,82 @@ +
+ + + + @if (currentUserRole) { +
+
+
+
+ +
+ + Rôle actuel : + + {{ getRoleLabel() }} + + @if (currentMerchantPartnerId) { + + Merchant ID : {{ currentMerchantPartnerId }} + + } + +
+
+
+
+
+ } + + +
+
+ + +
+
+
+
\ No newline at end of file diff --git a/src/app/modules/subscriptions/subscriptions.service.ts b/src/app/modules/subscriptions/subscriptions.service.ts new file mode 100644 index 0000000..0a45fed --- /dev/null +++ b/src/app/modules/subscriptions/subscriptions.service.ts @@ -0,0 +1,259 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { environment } from '@environments/environment'; +import { Observable, map, catchError, throwError } from 'rxjs'; + +import { + Subscription, + SubscriptionPayment, + SubscriptionsResponse, + SubscriptionPaymentsResponse, + SearchSubscriptionsParams, + SubscriptionStatus, + SubscriptionPeriodicity, + Currency +} from '@core/models/dcb-bo-hub-subscription.model'; + +// Interfaces pour les réponses API +export interface MessageResponse { + message: string; +} + +// ===== SERVICE SUBSCRIPTIONS ===== + +@Injectable({ providedIn: 'root' }) +export class SubscriptionsService { + private http = inject(HttpClient); + private subcriptionBaseApiUrl = `${environment.apiCoreUrl}/subscriptions`; + private paymentBaseApiUrl = `${environment.apiCoreUrl}/payments`; + + // === MÉTHODES SUBSCRIPTIONS === + + /** + * Récupère la liste des abonnements + */ + getSubscriptions(params?: SearchSubscriptionsParams): Observable { + let httpParams = new HttpParams(); + + if (params) { + Object.keys(params).forEach(key => { + const value = params[key as keyof SearchSubscriptionsParams]; + if (value !== undefined && value !== null) { + httpParams = httpParams.set(key, value.toString()); + } + }); + } + + return this.http.get(this.subcriptionBaseApiUrl, { params: httpParams }).pipe( + map(subscriptions => ({ + subscriptions: subscriptions.map(sub => this.mapToSubscriptionModel(sub)), + statistics: this.calculateSubscriptionStats(subscriptions) + })), + catchError(error => { + console.error('Error loading subscriptions:', error); + return throwError(() => error); + }) + ); + } + + /** + * Récupère les abonnements par merchant + */ + getSubscriptionsByMerchant(merchantId: number, params?: Omit): Observable { + let httpParams = new HttpParams(); + + if (params) { + for (const key in params) { + if (params.hasOwnProperty(key)) { + const value = params[key as keyof typeof params]; + if (value !== undefined && value !== null) { + httpParams = httpParams.set(key, value.toString()); + } + } + } + } + + return this.http.get(`${this.subcriptionBaseApiUrl}/merchant/${merchantId}`, { + params: httpParams + }).pipe( + map(subscriptions => ({ + subscriptions: subscriptions.map(sub => this.mapToSubscriptionModel(sub)), + statistics: this.calculateSubscriptionStats(subscriptions) + })), + catchError(error => { + console.error(`Error loading subscriptions for merchant ${merchantId}:`, error); + return throwError(() => error); + }) + ); + } + + /** + * Récupère un abonnement par son ID + */ + getSubscriptionById(subscriptionId: string): Observable { + return this.http.get(`${this.subcriptionBaseApiUrl}/${subscriptionId}`).pipe( + map(subscription => this.mapToSubscriptionModel(subscription)), + catchError(error => { + console.error(`Error loading subscription ${subscriptionId}:`, error); + return throwError(() => error); + }) + ); + } + + /** + * Récupère les paiements d'un abonnement + */ + getSubscriptionPayments(merchantId: string | number, subscriptionId: string | number): Observable { + // Convertir en number si c'est une string numérique, sinon utiliser tel quel + const merchantIdNum = typeof merchantId === 'string' ? parseInt(merchantId, 10) : merchantId; + const subscriptionIdNum = typeof subscriptionId === 'string' ? parseInt(subscriptionId, 10) : subscriptionId; + + return this.http.get( + `${this.paymentBaseApiUrl}/merchant/${merchantIdNum}/subscription/${subscriptionIdNum}` + ).pipe( + map(payments => ({ + merchantId: merchantIdNum.toString(), + subscriptionId: subscriptionIdNum.toString(), + payments: payments.map(payment => this.mapToPaymentModel(payment)) + })), + catchError(error => { + console.error(`Error loading payments for subscription ${subscriptionId} for merchant ${merchantId}:`, error); + return throwError(() => error); + }) + ); + } + + /** + * Recherche des abonnements avec filtres + */ + searchSubscriptions(params: SearchSubscriptionsParams): Observable { + return this.getSubscriptions(params).pipe( + map(response => response.subscriptions) + ); + } + + /** + * Récupère les abonnements par statut + */ + getSubscriptionsByStatus(status: SubscriptionStatus): Observable { + return this.searchSubscriptions({ status }); + } + + /** + * Récupère les abonnements par périodicité + */ + getSubscriptionsByPeriodicity(periodicity: SubscriptionPeriodicity): Observable { + return this.searchSubscriptions({ periodicity }); + } + + /** + * Récupère les abonnements actifs + */ + getActiveSubscriptions(): Observable { + return this.getSubscriptionsByStatus(SubscriptionStatus.ACTIVE); + } + + /** + * Récupère les abonnements suspendus + */ + getSuspendedSubscriptions(): Observable { + return this.getSubscriptionsByStatus(SubscriptionStatus.SUSPENDED); + } + + // === MÉTHODES UTILITAIRES === + + /** + * Calcule les statistiques des abonnements + */ + private calculateSubscriptionStats(subscriptions: any[]): { + total: number; + active: number; + suspended: number; + cancelled: number; + totalRevenue: number; + averageAmount: number; + } { + const total = subscriptions.length; + const active = subscriptions.filter(sub => sub.status === SubscriptionStatus.ACTIVE).length; + const suspended = subscriptions.filter(sub => sub.status === SubscriptionStatus.SUSPENDED).length; + const cancelled = subscriptions.filter(sub => sub.status === SubscriptionStatus.CANCELLED).length; + const totalRevenue = subscriptions.reduce((sum, sub) => sum + sub.amount, 0); + const averageAmount = total > 0 ? totalRevenue / total : 0; + + return { + total, + active, + suspended, + cancelled, + totalRevenue, + averageAmount + }; + } + + /** + * Mappe la réponse API vers le modèle Subscription + */ + private mapToSubscriptionModel(apiSubscription: any): Subscription { + return { + id: apiSubscription.id, + externalReference: apiSubscription.externalReference, + periodicity: apiSubscription.periodicity as SubscriptionPeriodicity, + startDate: apiSubscription.startDate, + endDate: apiSubscription.endDate, + amount: apiSubscription.amount, + currency: apiSubscription.currency as Currency, + token: apiSubscription.token, + status: apiSubscription.status as SubscriptionStatus, + nextPaymentDate: apiSubscription.nextPaymentDate, + suspendedAt: apiSubscription.suspendedAt, + merchantPartnerId: apiSubscription.merchantPartnerId, + customerId: apiSubscription.customerId, + planId: apiSubscription.planId, + serviceId: apiSubscription.serviceId, + failureCount: apiSubscription.failureCount, + createdAt: apiSubscription.createdAt, + updatedAt: apiSubscription.updatedAt, + metadata: apiSubscription.metadata || {} + }; + } + + /** + * Mappe la réponse API vers le modèle Payment + */ + private mapToPaymentModel(apiPayment: any): SubscriptionPayment { + return { + id: apiPayment.id, + subscriptionId: apiPayment.subscriptionId, + amount: apiPayment.amount, + currency: apiPayment.currency as Currency, + status: apiPayment.status as 'PENDING' | 'SUCCESS' | 'FAILED', + reference: apiPayment.reference, + description: apiPayment.description, + metadata: apiPayment.metadata || { internatRef: '' }, + partnerId: apiPayment.partnerId, + createdAt: apiPayment.createdAt, + updatedAt: apiPayment.updatedAt + }; + } + + /** + * Valide si un statut est valide + */ + isValidSubscriptionStatus(status: string): boolean { + return Object.values(SubscriptionStatus).includes(status as SubscriptionStatus); + } + + /** + * Valide si une périodicité est valide + */ + isValidPeriodicity(periodicity: string): boolean { + return Object.values(SubscriptionPeriodicity).includes(periodicity as SubscriptionPeriodicity); + } + + /** + * Valide si une devise est valide + */ + isValidCurrency(currency: string): boolean { + return Object.values(Currency).includes(currency as Currency); + } +} \ No newline at end of file diff --git a/src/app/modules/subscriptions/subscriptions.ts b/src/app/modules/subscriptions/subscriptions.ts new file mode 100644 index 0000000..62dc9c3 --- /dev/null +++ b/src/app/modules/subscriptions/subscriptions.ts @@ -0,0 +1,267 @@ +import { Component, inject, OnInit, TemplateRef, ViewChild, ChangeDetectorRef, OnDestroy } from '@angular/core'; +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 { 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.service'; + +@Component({ + selector: 'app-subscriptions', + standalone: true, + imports: [ + CommonModule, + FormsModule, + NgIcon, + NgbNavModule, + NgbModalModule, + PageTitle, + SubscriptionsList, + SubscriptionPayments + ], + templateUrl: './subscriptions.html', +}) +export class SubscriptionsManagement implements OnInit, OnDestroy { + private authService = inject(AuthService); + private subscriptionsService = inject(SubscriptionsService); + private roleService = inject(RoleManagementService); + + private cdRef = inject(ChangeDetectorRef); + private destroy$ = new Subject(); + + // 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'; + selectedSubscriptionId: string | null = null; + + user: User | undefined; + + // Gestion des rôles (lecture seule) + availableRoles: { value: UserRole; label: string; description: string }[] = []; + + // Gestion des permissions + currentUserRole: string | null = null; + currentMerchantPartnerId: string = ''; + + // 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); + this.currentMerchantPartnerId = this.extractMerchantPartnerId(user); + + console.log(`User ROLE: ${this.currentUserRole}`); + console.log(`Merchant Partner ID: ${this.currentMerchantPartnerId}`); + }, + error: (error) => { + console.error('Error loading user profile:', error); + this.fallbackPermissions(); + } + }); + } + + /** + * 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; + } + + /** + * Extraire le merchantPartnerId + */ + private extractMerchantPartnerId(user: any): string { + if (user?.merchantPartnerId) { + return user.merchantPartnerId; + } + return this.authService.getCurrentMerchantPartnerId() || ''; + } + + /** + * Fallback en cas d'erreur de chargement du profil + */ + private fallbackPermissions(): void { + this.currentUserRole = this.authService.getCurrentUserRole(); + this.currentMerchantPartnerId = this.authService.getCurrentMerchantPartnerId() || ''; + } + + // ==================== 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'; + this.selectedSubscriptionId = null; + } + + // Méthodes de gestion des événements du composant enfant + onSubscriptionSelected(subscriptionId: string) { + this.showTab('payments', subscriptionId); + } + + onViewPaymentsRequested(subscriptionId: string) { + this.showTab('payments', 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'); + } + } + + // 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; + } + + 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(); + } +} \ No newline at end of file diff --git a/src/environments/environment.ts b/src/environments/environment.ts index fb72ad8..fe3670e 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -2,90 +2,5 @@ export const environment = { production: false, localServiceTestApiUrl: "http://localhost:4200/api/v1", iamApiUrl: "http://localhost:3000/api/v1", - dcbApiUrl: 'https://api.paymenthub.com/v2', - - // Configuration DCB - dcb: { - // Opérateurs supportés - operators: { - orange: { - endpoint: 'https://api.orange.com/dcb/v2', - timeout: 30000, - retryAttempts: 3, - countries: ['CIV', 'SEN', 'CMR', 'MLI', 'BFA', 'GIN'] - }, - mtn: { - endpoint: 'https://api.mtn.com/dcb/v2', - timeout: 25000, - retryAttempts: 3, - countries: ['CIV', 'GHA', 'NGA', 'CMR', 'RWA'] - }, - airtel: { - endpoint: 'https://api.airtel.com/dcb/v2', - timeout: 30000, - retryAttempts: 3, - countries: ['COD', 'TZN', 'KEN', 'UGA', 'RWA'] - }, - moov: { - endpoint: 'https://api.moov.com/dcb/v2', - timeout: 25000, - retryAttempts: 3, - countries: ['CIV', 'BEN', 'TGO', 'NER', 'BFA'] - } - }, - - // Limitations - limits: { - maxAmount: 50, - minAmount: 0.5, - dailyLimit: 100, - monthlyLimit: 1000 - }, - - // Sécurité - security: { - webhookSecret: 'dcb_wh_secret_2024', - encryptionKey: 'dcb_enc_key_2024', - jwtExpiry: '24h' - }, - - // Monitoring - monitoring: { - healthCheckInterval: 60000, - alertThreshold: 0.1, // 10% d'erreur - performanceThreshold: 5000 // 5 secondes - } - }, - - // Configuration Partners - merchants: { - onboarding: { - maxFileSize: 10 * 1024 * 1024, - allowedFileTypes: ['pdf', 'jpg', 'jpeg', 'png'], - autoApproveThreshold: 1000 - }, - payouts: { - defaultSchedule: 'monthly', - processingDays: [1, 15], - minPayoutAmount: 50, - fees: { - bankTransfer: 1.5, - mobileMoney: 2.0 - } - }, - kyc: { - requiredDocuments: ['registration_certificate', 'tax_certificate', 'id_document'], - autoExpireDays: 365 - } - }, - - // Configuration générale - app: { - name: 'Payment Aggregation Hub', - version: '2.0.0', - supportEmail: 'support@paymenthub.com', - defaultLanguage: 'fr', - currencies: ['XOF', 'XAF', 'USD', 'EUR', 'TND'], - countries: ['CIV', 'SEN', 'CMR', 'COD', 'TUN', 'BFA', 'MLI', 'GIN', 'NGA', 'GHA'] - } -}; \ No newline at end of file + apiCoreUrl: 'https://api-core-service.dcb.pixpay.sn/api/v1', +} \ No newline at end of file