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

This commit is contained in:
diallolatoile 2025-11-14 18:07:47 +00:00
parent 658559deac
commit 6433da55a9
16 changed files with 3442 additions and 93 deletions

View File

@ -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<string, string> = {
'PENDING': 'En attente',
'SUCCESS': 'Réussi',
'FAILED': 'Échoué'
};
return statusNames[status] || status;
}
}

View File

@ -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',

View File

@ -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',

View File

@ -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é
// ---------------------------

View File

@ -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',

View File

@ -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)
// ---------------------------

View File

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

View File

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

View File

@ -0,0 +1,408 @@
<div class="container-fluid">
<!-- En-tête avec navigation -->
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<div>
<h4 class="mb-1">
@if (subscription) {
Paiements - Abonnement #{{ subscription.id }}
} @else {
Paiements de l'Abonnement
}
</h4>
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item">
<a href="javascript:void(0)" (click)="goBack()" class="text-decoration-none cursor-pointer">
Abonnements
</a>
</li>
<li class="breadcrumb-item active" aria-current="page">
@if (subscription) {
Paiements #{{ subscription.id }}
} @else {
Paiements
}
</li>
</ol>
</nav>
</div>
<div class="d-flex gap-2">
<!-- Bouton d'actualisation -->
<button
class="btn btn-outline-secondary"
(click)="refresh()"
[disabled]="loading || loadingPayments"
>
<ng-icon name="lucideRefreshCw" class="me-1" [class.spin]="loading || loadingPayments"></ng-icon>
Actualiser
</button>
</div>
</div>
</div>
</div>
<!-- Messages d'alerte -->
@if (error) {
<div class="alert alert-danger">
<div class="d-flex align-items-center">
<ng-icon name="lucideAlertCircle" class="me-2"></ng-icon>
<div>{{ error }}</div>
<button class="btn-close ms-auto" (click)="clearMessages()"></button>
</div>
</div>
}
@if (success) {
<div class="alert alert-success">
<div class="d-flex align-items-center">
<ng-icon name="lucideCheckCircle" class="me-2"></ng-icon>
<div>{{ success }}</div>
<button class="btn-close ms-auto" (click)="clearMessages()"></button>
</div>
</div>
}
<div class="row">
<!-- Loading State -->
@if (loading) {
<div class="col-12 text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
<p class="mt-2 text-muted">Chargement des détails de l'abonnement...</p>
</div>
}
<!-- Subscription Details & Payments -->
@if (subscription && !loading) {
<!-- Colonne de gauche - Informations de l'abonnement -->
<div class="col-xl-4 col-lg-5">
<!-- Carte abonnement -->
<div class="card">
<div class="card-header bg-light">
<h5 class="card-title mb-0">Détails de l'Abonnement</h5>
</div>
<div class="card-body text-center">
<!-- Icône abonnement -->
<div class="avatar-lg mx-auto mb-3">
<div class="avatar-title bg-primary bg-opacity-10 rounded-circle text-primary fs-24">
<ng-icon name="lucideRepeat"></ng-icon>
</div>
</div>
<h5>Abonnement #{{ subscription.id }}</h5>
<p class="text-muted mb-2">{{ subscription.token.substring(0, 20) }}...</p>
<!-- Statut -->
<span [class]="getStatusBadgeClass(subscription.status)" class="mb-3 d-block">
<ng-icon
[name]="subscription.status === SubscriptionStatus.ACTIVE ? 'lucideCheckCircle' :
subscription.status === SubscriptionStatus.SUSPENDED ? 'lucidePauseCircle' :
subscription.status === SubscriptionStatus.CANCELLED ? 'lucideXCircle' : 'lucideClock'"
class="me-1"
></ng-icon>
{{ getStatusDisplayName(subscription.status) }}
</span>
<!-- Indicateur de santé -->
<div class="mb-3">
<small class="d-flex align-items-center justify-content-center" [ngClass]="getSubscriptionHealthColor()">
<ng-icon [name]="getSubscriptionHealthIcon()" class="me-1"></ng-icon>
@switch (getSubscriptionHealth()) {
@case ('good') { Bon état }
@case ('warning') { Attention nécessaire }
@case ('danger') { Problème détecté }
}
</small>
</div>
<!-- Informations rapides -->
<div class="mt-4 text-start">
<div class="d-flex align-items-center mb-2">
<ng-icon name="lucideDollarSign" class="me-2 text-muted"></ng-icon>
<small>{{ formatAmount(subscription.amount, subscription.currency) }}</small>
</div>
<div class="d-flex align-items-center mb-2">
<ng-icon name="lucideCalendar" class="me-2 text-muted"></ng-icon>
<small>{{ getPeriodicityDisplayName(subscription.periodicity) }}</small>
</div>
<div class="d-flex align-items-center mb-2">
<ng-icon name="lucidePlay" class="me-2 text-muted"></ng-icon>
<small>Début : {{ formatDate(subscription.startDate) }}</small>
</div>
<div class="d-flex align-items-center mb-2">
<ng-icon name="lucideCalendar" class="me-2 text-muted"></ng-icon>
<small>Prochain : {{ formatDate(subscription.nextPaymentDate) }}</small>
</div>
@if (subscription.endDate) {
<div class="d-flex align-items-center">
<ng-icon name="lucideFlag" class="me-2 text-muted"></ng-icon>
<small>Fin : {{ formatDate(subscription.endDate) }}</small>
</div>
}
</div>
</div>
</div>
<!-- Carte statistiques -->
<div class="card mt-3">
<div class="card-header bg-light">
<h5 class="card-title mb-0">Statistiques des Paiements</h5>
</div>
<div class="card-body">
<div class="row text-center">
<div class="col-6 mb-3">
<div class="text-primary fw-bold fs-4">{{ getTotalPayments() }}</div>
<small class="text-muted">Total</small>
</div>
<div class="col-6 mb-3">
<div class="text-success fw-bold fs-4">{{ getSuccessfulPayments() }}</div>
<small class="text-muted">Réussis</small>
</div>
<div class="col-6">
<div class="text-danger fw-bold fs-4">{{ getFailedPayments() }}</div>
<small class="text-muted">Échoués</small>
</div>
<div class="col-6">
<div class="text-warning fw-bold fs-4">{{ getPendingPayments() }}</div>
<small class="text-muted">En attente</small>
</div>
</div>
<hr>
<div class="text-center">
<div class="fw-bold fs-5">{{ formatAmount(getTotalAmount(), subscription.currency) }}</div>
<small class="text-muted">Montant total collecté</small>
</div>
<div class="text-center mt-2">
<div class="fw-bold fs-6">{{ getSuccessRate().toFixed(1) }}%</div>
<small class="text-muted">Taux de réussite</small>
</div>
</div>
</div>
<!-- Informations techniques -->
<div class="card mt-3">
<div class="card-header bg-light">
<h6 class="card-title mb-0">Informations Techniques</h6>
</div>
<div class="card-body">
<div class="row g-2 small">
<div class="col-12">
<strong>ID Abonnement :</strong>
<div class="text-muted font-monospace">{{ subscription.id }}</div>
</div>
<div class="col-12">
<strong>Token :</strong>
<div class="text-muted font-monospace text-truncate">{{ subscription.token }}</div>
</div>
<div class="col-12">
<strong>Merchant ID :</strong>
<div class="text-muted">{{ subscription.merchantPartnerId }}</div>
</div>
<div class="col-12">
<strong>Client ID :</strong>
<div class="text-muted">{{ subscription.customerId }}</div>
</div>
<div class="col-12">
<strong>Échecs :</strong>
<div class="text-muted">{{ subscription.failureCount }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- Colonne de droite - Historique des paiements -->
<div class="col-xl-8 col-lg-7">
<!-- Filtres -->
<div class="card mb-3">
<div class="card-header bg-light">
<h5 class="card-title mb-0">Filtres des Paiements</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Statut</label>
<select class="form-select" [(ngModel)]="statusFilter" (change)="applyFilters()">
<option value="all">Tous les statuts</option>
<option value="SUCCESS">Réussis</option>
<option value="FAILED">Échoués</option>
<option value="PENDING">En attente</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Période</label>
<select class="form-select" [(ngModel)]="dateFilter" (change)="applyFilters()">
<option value="all">Toute période</option>
<option value="week">7 derniers jours</option>
<option value="month">30 derniers jours</option>
<option value="quarter">3 derniers mois</option>
</select>
</div>
<div class="col-12">
<button class="btn btn-outline-secondary btn-sm" (click)="clearFilters()">
<ng-icon name="lucideX" class="me-1"></ng-icon>
Réinitialiser les filtres
</button>
</div>
</div>
</div>
</div>
<!-- Liste des paiements -->
<div class="card">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">
<ng-icon name="lucideCreditCard" class="me-2"></ng-icon>
Historique des Paiements
<span class="badge bg-primary ms-2">{{ getSortedPayments().length }}</span>
</h5>
</div>
<div class="card-body">
<!-- Loading State Payments -->
@if (loadingPayments) {
<div class="text-center py-4">
<div class="spinner-border spinner-border-sm text-primary" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
<p class="mt-2 text-muted">Chargement des paiements...</p>
</div>
}
<!-- Payments List -->
@if (!loadingPayments) {
<div class="table-responsive">
<table class="table table-hover table-sm">
<thead class="table-light">
<tr>
<th>Référence</th>
<th>Date</th>
<th>Montant</th>
<th>Statut</th>
<th>Description</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (payment of getSortedPayments(); track payment.id) {
<tr>
<td>
<code class="small" [title]="payment.reference">
{{ payment.reference.substring(0, 12) }}...
</code>
</td>
<td>
<small>{{ formatDateTime(payment.createdAt) }}</small>
</td>
<td>
<strong>{{ formatAmount(payment.amount, payment.currency) }}</strong>
</td>
<td>
<span class="badge d-flex align-items-center" [ngClass]="getPaymentStatusBadgeClass(payment.status)">
<ng-icon
[name]="payment.status === 'SUCCESS' ? 'lucideCheckCircle' :
payment.status === 'FAILED' ? 'lucideXCircle' : 'lucideClock'"
class="me-1"
size="12"
></ng-icon>
{{ getPaymentStatusDisplayName(payment.status) }}
</span>
</td>
<td>
<small class="text-muted">{{ payment.description }}</small>
@if (payment.metadata.internatRef) {
<br>
<small class="text-muted">
Ref: {{ payment.metadata.internatRef }}
</small>
}
</td>
<td>
<button class="btn btn-outline-primary btn-sm" title="Voir détails">
<ng-icon name="lucideEye"></ng-icon>
</button>
</td>
</tr>
}
@empty {
<tr>
<td colspan="6" class="text-center py-4">
<div class="text-muted">
<ng-icon name="lucideCreditCard" class="fs-1 mb-3 opacity-50"></ng-icon>
<h5 class="mb-2">Aucun paiement trouvé</h5>
<p class="mb-3">Aucun paiement ne correspond à vos critères de filtrage.</p>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Résumé des résultats filtrés -->
@if (getSortedPayments().length > 0) {
<div class="mt-3 pt-3 border-top">
<div class="row text-center small">
<div class="col">
<span class="text-muted">
Affichage de {{ getSortedPayments().length }} paiement(s)
</span>
</div>
@if (statusFilter !== 'all') {
<div class="col">
<span class="text-muted">
Filtre : {{
statusFilter === 'SUCCESS' ? 'Réussis' :
statusFilter === 'FAILED' ? 'Échoués' : 'En attente'
}}
</span>
</div>
}
</div>
</div>
}
}
</div>
</div>
<!-- Alertes et indicateurs -->
@if (isExpiringSoon()) {
<div class="alert alert-warning mt-3">
<div class="d-flex align-items-center">
<ng-icon name="lucideAlertTriangle" class="me-2"></ng-icon>
<div>
<strong>Attention :</strong> Cet abonnement expire bientôt
</div>
</div>
</div>
}
@if (isExpired()) {
<div class="alert alert-danger mt-3">
<div class="d-flex align-items-center">
<ng-icon name="lucideXCircle" class="me-2"></ng-icon>
<div>
<strong>Expiré :</strong> Cet abonnement a expiré
</div>
</div>
</div>
}
@if (getDaysUntilNextPayment() <= 3 && subscription.status === SubscriptionStatus.ACTIVE) {
<div class="alert alert-info mt-3">
<div class="d-flex align-items-center">
<ng-icon name="lucideCalendar" class="me-2"></ng-icon>
<div>
<strong>Prochain paiement :</strong> Dans {{ getDaysUntilNextPayment() }} jour(s)
</div>
</div>
</div>
}
</div>
}
</div>
</div>

View File

@ -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<void>();
readonly SubscriptionStatus = SubscriptionStatus;
readonly SubscriptionPeriodicity = SubscriptionPeriodicity;
readonly Currency = Currency;
@Input() subscriptionId!: string;
@Output() back = new EventEmitter<void>();
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.';
}
}

View File

@ -0,0 +1,361 @@
<app-ui-card [title]="getCardTitle()">
<a
helper-text
href="javascript:void(0);"
class="icon-link icon-link-hover link-primary fw-semibold"
>
<ng-icon [name]="getHelperIcon()" class="me-1"></ng-icon>
{{ getHelperText() }}
</a>
<div card-body>
<!-- Barre d'actions supérieure -->
<div class="row mb-3">
<div class="col-md-6">
<div class="d-flex align-items-center gap-2">
<!-- Statistiques rapides -->
<div class="btn-group btn-group-sm">
<button
type="button"
class="btn btn-outline-primary"
[class.active]="statusFilter === 'all'"
(click)="filterByStatus('all')"
>
Tous ({{ getTotalSubscriptionsCount() }})
</button>
<button
type="button"
class="btn btn-outline-success"
[class.active]="statusFilter === getActiveStatus()"
(click)="filterByStatus(getActiveStatus())"
>
Actifs ({{ getActiveSubscriptionsCount() }})
</button>
<button
type="button"
class="btn btn-outline-warning text-dark"
[class.active]="statusFilter === getSuspendedStatus()"
(click)="filterByStatus(getSuspendedStatus())"
>
Suspendus ({{ getSuspendedSubscriptionsCount() }})
</button>
<button
type="button"
class="btn btn-outline-danger"
[class.active]="statusFilter === getCancelledStatus()"
(click)="filterByStatus(getCancelledStatus())"
>
Annulés ({{ getCancelledSubscriptionsCount() }})
</button>
</div>
</div>
</div>
<div class="col-md-6">
<div class="d-flex justify-content-end gap-2">
<button
class="btn btn-outline-secondary"
(click)="refreshData()"
[disabled]="loading"
>
<ng-icon name="lucideRefreshCw" class="me-1" [class.spin]="loading"></ng-icon>
Actualiser
</button>
</div>
</div>
</div>
<!-- Barre de recherche et filtres avancés -->
<div class="row mb-3">
<div class="col-md-4">
<div class="input-group">
<span class="input-group-text">
<ng-icon name="lucideSearch"></ng-icon>
</span>
<input
type="text"
class="form-control"
placeholder="Rechercher par ID, token..."
[(ngModel)]="searchTerm"
(input)="onSearch()"
[disabled]="loading"
>
</div>
</div>
<div class="col-md-2">
<select class="form-select" [(ngModel)]="statusFilter" (change)="applyFiltersAndPagination()">
@for (status of availableStatuses; track status.value) {
<option [value]="status.value">{{ status.label }}</option>
}
</select>
</div>
<div class="col-md-2">
<select class="form-select" [(ngModel)]="periodicityFilter" (change)="applyFiltersAndPagination()">
@for (periodicity of availablePeriodicities; track periodicity.value) {
<option [value]="periodicity.value">{{ periodicity.label }}</option>
}
</select>
</div>
<div class="col-md-2">
<select class="form-select" [(ngModel)]="merchantFilter" (change)="applyFiltersAndPagination()">
<option value="all">Tous les merchants</option>
<!-- Les options merchants seraient dynamiquement chargées si nécessaire -->
</select>
</div>
<div class="col-md-2">
<button class="btn btn-outline-secondary w-100" (click)="onClearFilters()" [disabled]="loading">
<ng-icon name="lucideX" class="me-1"></ng-icon>
Effacer
</button>
</div>
</div>
<!-- Loading State -->
@if (loading) {
<div class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
<p class="mt-2 text-muted">{{ getLoadingText() }}</p>
</div>
}
<!-- Error State -->
@if (error && !loading) {
<div class="alert alert-danger" role="alert">
<div class="d-flex align-items-center">
<ng-icon name="lucideAlertCircle" class="me-2"></ng-icon>
<div>{{ error }}</div>
<button class="btn-close ms-auto" (click)="error = ''"></button>
</div>
</div>
}
<!-- Subscriptions Table -->
@if (!loading && !error) {
<div class="table-responsive">
<table class="table table-hover table-striped">
<thead class="table-light">
<tr>
<!-- Colonne Merchant pour les admins -->
@if (showMerchantColumn) {
<th>Merchant</th>
}
<!-- Colonne Customer pour les admins -->
@if (showCustomerColumn) {
<th>Client</th>
}
<th (click)="sort('id')" class="cursor-pointer">
<div class="d-flex align-items-center">
<span>ID</span>
<ng-icon [name]="getSortIcon('id')" class="ms-1 fs-12"></ng-icon>
</div>
</th>
<th (click)="sort('amount')" class="cursor-pointer">
<div class="d-flex align-items-center">
<span>Montant</span>
<ng-icon [name]="getSortIcon('amount')" class="ms-1 fs-12"></ng-icon>
</div>
</th>
<th>Périodicité</th>
<th (click)="sort('status')" class="cursor-pointer">
<div class="d-flex align-items-center">
<span>Statut</span>
<ng-icon [name]="getSortIcon('status')" class="ms-1 fs-12"></ng-icon>
</div>
</th>
<th (click)="sort('startDate')" class="cursor-pointer">
<div class="d-flex align-items-center">
<span>Date début</span>
<ng-icon [name]="getSortIcon('startDate')" class="ms-1 fs-12"></ng-icon>
</div>
</th>
<th width="120">Actions</th>
</tr>
</thead>
<tbody>
@for (subscription of displayedSubscriptions; track subscription.id) {
<tr>
<!-- Colonne Merchant pour les admins -->
@if (showMerchantColumn) {
<td>
<div class="d-flex align-items-center">
<div class="avatar-sm bg-secondary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2">
<ng-icon name="lucideBuilding" class="text-secondary fs-12"></ng-icon>
</div>
<div>
<small class="text-muted font-monospace">
#{{ subscription.merchantPartnerId }}
</small>
</div>
</div>
</td>
}
<!-- Colonne Customer pour les admins -->
@if (showCustomerColumn) {
<td>
<div class="d-flex align-items-center">
<div class="avatar-sm bg-info bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2">
<ng-icon name="lucideUser" class="text-info fs-12"></ng-icon>
</div>
<div>
<small class="text-muted font-monospace">
#{{ subscription.customerId }}
</small>
</div>
</div>
</td>
}
<td>
<div class="d-flex align-items-center">
<div class="avatar-sm bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2">
<ng-icon name="lucideRepeat" class="text-primary fs-12"></ng-icon>
</div>
<div>
<strong class="d-block">#{{ subscription.id }}</strong>
<small class="text-muted" [title]="subscription.token">
{{ subscription.token.substring(0, 12) }}...
</small>
</div>
</div>
</td>
<td>
<strong class="d-block">{{ formatAmount(subscription.amount, subscription.currency) }}</strong>
<small class="text-muted">{{ subscription.currency }}</small>
</td>
<td>
<span class="badge bg-light text-dark d-flex align-items-center">
<ng-icon name="lucideCalendar" class="me-1" size="14"></ng-icon>
{{ getPeriodicityDisplayName(subscription.periodicity) }}
</span>
</td>
<td>
<span class="badge d-flex align-items-center" [ngClass]="getStatusBadgeClass(subscription.status)">
<ng-icon
[name]="subscription.status === SubscriptionStatus.ACTIVE ? 'lucideCheckCircle' :
subscription.status === SubscriptionStatus.SUSPENDED ? 'lucidePauseCircle' :
subscription.status === SubscriptionStatus.CANCELLED ? 'lucideXCircle' : 'lucideClock'"
class="me-1"
size="14"
></ng-icon>
{{ getStatusDisplayName(subscription.status) }}
</span>
@if (isExpiringSoon(subscription)) {
<div class="mt-1">
<small class="text-warning">
<ng-icon name="lucideAlertTriangle" class="me-1" size="12"></ng-icon>
Expire bientôt
</small>
</div>
}
</td>
<td>
<div>
<small class="text-muted d-block">
Début: {{ formatDate(subscription.startDate) }}
</small>
<small class="text-muted d-block">
Prochain: {{ formatDate(subscription.nextPaymentDate) }}
</small>
@if (subscription.endDate) {
<small class="text-muted d-block">
Fin: {{ formatDate(subscription.endDate) }}
</small>
}
</div>
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<button
class="btn btn-outline-primary btn-sm"
(click)="viewSubscriptionDetails(subscription.id)"
title="Voir les détails"
>
<ng-icon name="lucideEye"></ng-icon>
</button>
<button
class="btn btn-outline-info btn-sm"
(click)="viewSubscriptionPayments(subscription.id)"
title="Voir les paiements"
>
<ng-icon name="lucideCreditCard"></ng-icon>
</button>
</div>
</td>
</tr>
}
@empty {
<tr>
<td [attr.colspan]="getColumnCount()" class="text-center py-4">
<div class="text-muted">
<ng-icon name="lucideRepeat" class="fs-1 mb-3 opacity-50"></ng-icon>
<h5 class="mb-2">{{ getEmptyStateTitle() }}</h5>
<p class="mb-3">{{ getEmptyStateDescription() }}</p>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Pagination -->
@if (totalPages > 1) {
<div class="d-flex justify-content-between align-items-center mt-3">
<div class="text-muted">
Affichage de {{ getStartIndex() }} à {{ getEndIndex() }} sur {{ totalItems }} abonnements
</div>
<nav>
<ngb-pagination
[collectionSize]="totalItems"
[page]="currentPage"
[pageSize]="itemsPerPage"
[maxSize]="5"
[rotate]="true"
[boundaryLinks]="true"
(pageChange)="onPageChange($event)"
/>
</nav>
</div>
}
<!-- Résumé des résultats -->
@if (displayedSubscriptions.length > 0) {
<div class="mt-3 pt-3 border-top">
<div class="row text-center">
<div class="col">
<small class="text-muted">
<strong>Total :</strong> {{ getTotalSubscriptionsCount() }} abonnements
</small>
</div>
<div class="col">
<small class="text-muted">
<strong>Actifs :</strong> {{ getActiveSubscriptionsCount() }}
</small>
</div>
<div class="col">
<small class="text-muted">
<strong>Revenu total :</strong> {{ formatAmount(getTotalRevenue(), Currency.XOF) }}
</small>
</div>
<div class="col">
<small class="text-muted">
<strong>Quotidiens :</strong> {{ getDailySubscriptionsCount() }}
</small>
</div>
<div class="col">
<small class="text-muted">
<strong>Mensuels :</strong> {{ getMonthlySubscriptionsCount() }}
</small>
</div>
</div>
</div>
}
}
</div>
</app-ui-card>

View File

@ -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<void>();
// Configuration
readonly SubscriptionStatus = SubscriptionStatus;
readonly SubscriptionPeriodicity = SubscriptionPeriodicity;
readonly Currency = Currency;
// Outputs
@Output() subscriptionSelected = new EventEmitter<string>();
@Output() viewPaymentsRequested = new EventEmitter<string>();
// 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<SubscriptionsResponse>;
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();
}
}

View File

@ -0,0 +1,82 @@
<div class="container-fluid">
<app-page-title
title="pageTitle"
subtitle="Consultez et gérez les abonnements et leurs paiements"
[badge]="badge"
/>
<!-- Indicateur de permissions -->
@if (currentUserRole) {
<div class="row mb-3">
<div class="col-12">
<div class="alert alert-info py-2">
<div class="d-flex align-items-center">
<ng-icon name="lucideInfo" class="me-2"></ng-icon>
<div class="flex-grow-1">
<small>
<strong>Rôle actuel :</strong>
<span class="badge" [ngClass]="getRoleBadgeClass()">
{{ getRoleLabel() }}
</span>
@if (currentMerchantPartnerId) {
<span class="ms-2">
<strong>Merchant ID :</strong> {{ currentMerchantPartnerId }}
</span>
}
</small>
</div>
</div>
</div>
</div>
</div>
}
<!-- Navigation par onglets -->
<div class="row mb-4">
<div class="col-12">
<ul
ngbNav
#subscriptionsNav="ngbNav"
[activeId]="activeTab"
[destroyOnHide]="false"
class="nav nav-tabs nav-justified nav-bordered nav-bordered-primary mb-3"
>
<li [ngbNavItem]="'list'">
<a ngbNavLink (click)="showTab('list')">
<ng-icon name="lucideList" class="fs-lg me-md-1 d-inline-flex align-middle" />
<span class="d-none d-md-inline-block align-middle">Liste des Abonnements</span>
</a>
<ng-template ngbNavContent>
<app-subscriptions-list
#subscriptionsList
(subscriptionSelected)="onSubscriptionSelected($event)"
(viewPaymentsRequested)="onViewPaymentsRequested($event)"
/>
</ng-template>
</li>
<li [ngbNavItem]="'payments'" [hidden]="!selectedSubscriptionId">
<a ngbNavLink (click)="showTab('payments')">
<ng-icon name="lucideCreditCard" class="fs-lg me-md-1 d-inline-flex align-middle" />
<span class="d-none d-md-inline-block align-middle">Paiements</span>
</a>
<ng-template ngbNavContent>
@if (selectedSubscriptionId) {
<app-subscription-payments
[subscriptionId]="selectedSubscriptionId"
(back)="backToList()"
/>
} @else {
<div class="alert alert-warning text-center">
<ng-icon name="lucideAlertCircle" class="me-2"></ng-icon>
Aucun abonnement sélectionné
</div>
}
</ng-template>
</li>
</ul>
<div class="tab-content" [ngbNavOutlet]="subscriptionsNav"></div>
</div>
</div>
</div>

View File

@ -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<SubscriptionsResponse> {
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<Subscription[]>(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<SearchSubscriptionsParams, 'merchantId'>): Observable<SubscriptionsResponse> {
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<Subscription[]>(`${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<Subscription> {
return this.http.get<Subscription>(`${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<SubscriptionPaymentsResponse> {
// 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<SubscriptionPayment[]>(
`${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<Subscription[]> {
return this.getSubscriptions(params).pipe(
map(response => response.subscriptions)
);
}
/**
* Récupère les abonnements par statut
*/
getSubscriptionsByStatus(status: SubscriptionStatus): Observable<Subscription[]> {
return this.searchSubscriptions({ status });
}
/**
* Récupère les abonnements par périodicité
*/
getSubscriptionsByPeriodicity(periodicity: SubscriptionPeriodicity): Observable<Subscription[]> {
return this.searchSubscriptions({ periodicity });
}
/**
* Récupère les abonnements actifs
*/
getActiveSubscriptions(): Observable<Subscription[]> {
return this.getSubscriptionsByStatus(SubscriptionStatus.ACTIVE);
}
/**
* Récupère les abonnements suspendus
*/
getSuspendedSubscriptions(): Observable<Subscription[]> {
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);
}
}

View File

@ -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<void>();
// Configuration
readonly SubscriptionStatus = SubscriptionStatus;
readonly SubscriptionPeriodicity = SubscriptionPeriodicity;
readonly Currency = Currency;
// Propriétés de configuration
pageTitle: string = 'Gestion des Abonnements';
badge: any = { icon: 'lucideRepeat', text: 'Abonnements' };
// État de l'interface
activeTab: 'list' | 'payments' = 'list';
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();
}
}

View File

@ -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']
apiCoreUrl: 'https://api-core-service.dcb.pixpay.sn/api/v1',
}
},
// 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']
}
};