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

This commit is contained in:
diallolatoile 2025-12-01 16:29:56 +00:00
parent 10a272fb85
commit 2c095ac311
21 changed files with 2849 additions and 641 deletions

View File

@ -8,9 +8,9 @@ export enum UserRole {
// Rôles Hub (sans merchantPartnerId)
DCB_ADMIN = 'dcb-admin',
DCB_SUPPORT = 'dcb-support',
DCB_PARTNER = 'dcb-partner',
DCB_PARTNER = 'dcb-partner', // Propriétaire de merchants
// Rôles Merchant Partner (avec merchantPartnerId obligatoire)
// Rôles Merchant Partner (avec merchantPartnerId obligatoire = ID du DCB_PARTNER)
DCB_PARTNER_ADMIN = 'dcb-partner-admin',
DCB_PARTNER_MANAGER = 'dcb-partner-manager',
DCB_PARTNER_SUPPORT = 'dcb-partner-support'
@ -41,16 +41,18 @@ export interface UsersStatistics {
// === MODÈLE USER PRINCIPAL ===
export interface User {
id: string;
id: string; // UUID Keycloak
username: string;
email: string;
firstName: string;
lastName: string;
enabled: boolean;
emailVerified: boolean;
userType: UserType; // HUB ou MERCHANT
merchantPartnerId?: string;
userType: UserType; // HUB ou MERCHANT_PARTNER
merchantPartnerId?: string; // Pour les users merchant: ID du DCB_PARTNER propriétaire
role: UserRole;
// Merchant Config
merchantConfigId?: number; // ID INT dans Merchant Config
createdBy?: string;
createdByUsername?: string;
createdTimestamp: number;
@ -58,6 +60,13 @@ export interface User {
profileImage?: string | null
}
export interface SyncResult {
success: boolean;
user?: User;
errors?: string[];
warnings?: string[];
}
// === DTOs CRUD ===
export interface CreateUserDto {
username: string;

View File

@ -0,0 +1,116 @@
// === MODÈLES POUR LA SYNCHRONISATION ===
import { User, UserRole } from "./dcb-bo-hub-user.model";
export interface MerchantUserSyncDto {
// Données de base pour Keycloak
username: string;
email: string;
firstName: string;
lastName: string;
password: string;
role: UserRole;
enabled?: boolean;
emailVerified?: boolean;
// Référence au DCB_PARTNER propriétaire
merchantPartnerId: string; // ID Keycloak du DCB_PARTNER
// Données pour Merchant Config
merchantConfig?: {
phone?: string;
technicalContacts?: Array<{
firstName: string;
lastName: string;
email: string;
phone: string;
}>;
};
}
export interface IdMapping {
id?: number;
keycloakId: string; // UUID Keycloak
merchantConfigId: number; // INT Merchant Config
merchantPartnerId: string; // ID du DCB_PARTNER propriétaire
entityType: 'merchant' | 'user';
username?: string;
email?: string;
createdAt?: Date;
updatedAt?: Date;
}
export interface SyncResult {
success: boolean;
keycloakUser?: User;
merchantConfigUser?: any;
mapping?: IdMapping;
errors?: string[];
}
export interface DCBPartnerInfo {
id: string;
username: string;
email: string;
enabled: boolean;
merchantPartnerId?: string; // Pour DCB_PARTNER, ce doit être undefined
}
// === UTILITAIRES DE SYNCHRONISATION ===
export class SyncUtils {
static validateDCBPartner(dcbPartner: User): string[] {
const errors: string[] = [];
if (!dcbPartner) {
errors.push('DCB_PARTNER non trouvé');
return errors;
}
if (dcbPartner.role !== UserRole.DCB_PARTNER) {
errors.push(`L'utilisateur ${dcbPartner.username} n'est pas un DCB_PARTNER`);
}
if (!dcbPartner.enabled) {
errors.push(`Le DCB_PARTNER ${dcbPartner.username} est désactivé`);
}
if (dcbPartner.merchantPartnerId) {
errors.push(`Un DCB_PARTNER ne doit pas avoir de merchantPartnerId`);
}
return errors;
}
static validateMerchantUserCreation(dto: MerchantUserSyncDto): string[] {
const errors: string[] = [];
if (!dto.merchantPartnerId) {
errors.push('merchantPartnerId est obligatoire');
}
if (!dto.username?.trim()) {
errors.push('Username est obligatoire');
}
if (!dto.email?.trim()) {
errors.push('Email est obligatoire');
}
if (!dto.password || dto.password.length < 8) {
errors.push('Le mot de passe doit contenir au moins 8 caractères');
}
// Validation du rôle
const merchantRoles = [
UserRole.DCB_PARTNER_ADMIN,
UserRole.DCB_PARTNER_MANAGER,
UserRole.DCB_PARTNER_SUPPORT
];
if (!merchantRoles.includes(dto.role)) {
errors.push(`Rôle invalide pour un utilisateur merchant: ${dto.role}`);
}
return errors;
}
}

View File

@ -1,44 +1,192 @@
import { Component } from '@angular/core';
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { NgIconComponent } from '@ng-icons/core';
import { NgbProgressbarModule } from '@ng-bootstrap/ng-bootstrap';
import { CountUpModule } from 'ngx-countup';
import { DcbReportingService, SubscriptionItem } from '../services/dcb-reporting.service';
import { catchError, finalize } from 'rxjs/operators';
import { of, Subscription } from 'rxjs';
@Component({
selector: 'app-active-subscriptions',
imports: [NgIconComponent, NgbProgressbarModule, CountUpModule],
imports: [CommonModule, NgIconComponent, NgbProgressbarModule, CountUpModule],
template: `
<div class="card card-h-100">
<div class="card-header bg-light">
<div class="d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">Abonnements Actifs</h5>
<button class="btn btn-sm btn-outline-primary" (click)="refresh()" [disabled]="loading">
<ng-icon name="lucideRefreshCw" [class.spin]="loading" class="me-1"></ng-icon>
</button>
</div>
</div>
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
<h5 class="text-uppercase mb-3">Abonnements Actifs</h5>
<h3 class="mb-0 fw-normal">
<span [countUp]="12543" data-target="12543">12,543</span>
</h3>
<p class="text-muted mb-2">Total abonnements</p>
</div>
<div>
<ng-icon name="lucideUsers" class="text-info fs-24 svg-sw-10" />
<!-- État de chargement -->
<div *ngIf="loading" class="text-center py-3">
<div class="spinner-border text-primary spinner-border-sm" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
</div>
<ngb-progressbar [value]="82" class="progress-lg mb-3" />
<div class="d-flex justify-content-between">
<div>
<span class="text-muted">Nouveaux</span>
<h5 class="mb-0">156</h5>
<!-- Contenu principal -->
<div *ngIf="!loading">
<div class="d-flex justify-content-between align-items-start">
<div>
<h5 class="text-uppercase text-muted mb-3">Total actifs</h5>
<h3 class="mb-0 fw-normal">
<span [countUp]="totalSubscriptions" [options]="{duration: 1}">
{{ formatNumber(totalSubscriptions) }}
</span>
</h3>
</div>
<div>
<ng-icon name="lucideUsers" class="text-info fs-24 svg-sw-10" />
</div>
</div>
<div class="text-end">
<span class="text-muted">Renouvellements</span>
<h5 class="mb-0">89%</h5>
<!-- Barre de progression pour le taux d'activité -->
<div class="mt-4">
<div class="d-flex justify-content-between mb-1">
<small class="text-muted">Taux d'activité</small>
<small class="text-muted">{{ activePercentage | number:'1.1-1' }}%</small>
</div>
<ngb-progressbar [value]="activePercentage" class="progress-lg mb-3" />
</div>
<div class="d-flex justify-content-between">
<div>
<span class="text-muted">Nouveaux</span>
<h5 class="mb-0">{{ newSubscriptionsToday }}</h5>
</div>
<div class="text-end">
<span class="text-muted">Annulés</span>
<h5 class="mb-0">{{ cancelledSubscriptions }}</h5>
</div>
</div>
</div>
<!-- Message d'erreur -->
<div *ngIf="error && !loading" class="mt-3">
<div class="alert alert-warning alert-sm mb-0">
<ng-icon name="lucideAlertTriangle" class="me-2 fs-12"></ng-icon>
{{ error }}
</div>
</div>
</div>
<div class="card-footer text-muted text-center">
52 nouveaux abonnements aujourd'hui
<div *ngIf="!loading && !error" class="card-footer text-muted text-center py-2">
<small>
<ng-icon name="lucideClock" class="me-1 fs-12"></ng-icon>
Mise à jour: {{ lastUpdated | date:'HH:mm' }}
</small>
</div>
</div>
`,
styles: [`
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.alert-sm {
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
}
`]
})
export class ActiveSubscriptions {}
export class ActiveSubscriptions implements OnInit, OnDestroy {
loading = false;
error: string | null = null;
lastUpdated = new Date();
// Données d'abonnements
totalSubscriptions = 0;
newSubscriptionsToday = 0;
cancelledSubscriptions = 0;
activePercentage = 0;
private apiSubscription?: Subscription;
constructor(private reportingService: DcbReportingService) {}
ngOnInit() {
this.loadSubscriptionData();
}
ngOnDestroy() {
if (this.apiSubscription) {
this.apiSubscription.unsubscribe();
}
}
loadSubscriptionData() {
this.loading = true;
this.error = null;
const today = new Date();
const startDate = this.reportingService.formatDate(today);
console.log('ActiveSubscriptions - Loading data for date:', startDate);
this.apiSubscription = this.reportingService.getDailySubscriptions(startDate, startDate)
.pipe(
catchError(err => {
console.error('ActiveSubscriptions - API error:', err);
this.error = 'Impossible de charger les données';
return of([]);
}),
finalize(() => {
this.loading = false;
this.lastUpdated = new Date();
})
)
.subscribe({
next: (subscriptions: SubscriptionItem[]) => {
console.log('ActiveSubscriptions - Received data:', subscriptions);
this.processSubscriptionData(subscriptions);
}
});
}
processSubscriptionData(subscriptions: SubscriptionItem[]) {
if (!subscriptions || subscriptions.length === 0) {
console.warn('ActiveSubscriptions - No data available');
return;
}
// Prendre les données du jour (ou la période la plus récente)
const latestData = subscriptions[subscriptions.length - 1];
console.log('ActiveSubscriptions - Latest data:', latestData);
// Utiliser les données brutes de l'API
// Note: Votre API retourne activeCount et cancelledCount
this.totalSubscriptions = latestData.activeCount || 0;
this.cancelledSubscriptions = latestData.cancelledCount || 0;
// Pour les nouveaux abonnements aujourd'hui
// Si l'API ne fournit pas cette donnée, on peut estimer ou utiliser 0
this.newSubscriptionsToday = 0; // À adapter selon votre logique
// Calculer le pourcentage d'activité
const totalCount = latestData.count || 0;
this.activePercentage = totalCount > 0 ?
((latestData.activeCount || 0) / totalCount) * 100 : 0;
}
formatNumber(value: number): string {
return new Intl.NumberFormat('fr-FR').format(value);
}
refresh() {
console.log('ActiveSubscriptions - Refreshing data');
// Essayer de vider le cache si disponible
if (this.reportingService.clearCache) {
this.reportingService.clearCache();
}
this.loadSubscriptionData();
}
}

View File

@ -1,59 +0,0 @@
import { Component } from '@angular/core';
import { NgIconComponent } from '@ng-icons/core';
@Component({
selector: 'app-alert-widget',
imports: [NgIconComponent],
template: `
<div class="card">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">Alertes Récentes</h5>
<span class="badge bg-danger">3</span>
</div>
<div class="card-body p-0">
<div class="list-group list-group-flush">
<div class="list-group-item">
<div class="d-flex align-items-start">
<ng-icon name="lucideAlertTriangle" class="text-warning me-2 mt-1"></ng-icon>
<div class="flex-grow-1">
<h6 class="mb-1">Taux d'échec élevé Airtel</h6>
<p class="mb-1 text-muted">Le taux d'échec a augmenté de 15%</p>
<small class="text-muted">Il y a 30 min</small>
</div>
</div>
</div>
<div class="list-group-item">
<div class="d-flex align-items-start">
<ng-icon name="lucideInfo" class="text-info me-2 mt-1"></ng-icon>
<div class="flex-grow-1">
<h6 class="mb-1">Maintenance planifiée</h6>
<p class="mb-1 text-muted">Maintenance ce soir de 22h à 00h</p>
<small class="text-muted">Il y a 2h</small>
</div>
</div>
</div>
<div class="list-group-item">
<div class="d-flex align-items-start">
<ng-icon name="lucideCheckCircle" class="text-success me-2 mt-1"></ng-icon>
<div class="flex-grow-1">
<h6 class="mb-1">Nouveau partenaire</h6>
<p class="mb-1 text-muted">MTN Sénégal configuré avec succès</p>
<small class="text-muted">Il y a 4h</small>
</div>
</div>
</div>
</div>
</div>
<div class="card-footer text-center">
<a href="javascript:void(0)" class="btn btn-sm btn-outline-primary">
Voir toutes les alertes
</a>
</div>
</div>
`,
})
export class AlertWidget {}

View File

@ -0,0 +1,612 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Chartjs } from '@app/components/chartjs';
import { ChartConfiguration } from 'chart.js';
import { getColor } from '@/app/utils/color-utils';
import { NgbProgressbarModule, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap';
import { NgIconComponent } from '@ng-icons/core';
import { CountUpModule } from 'ngx-countup';
import { DcbReportingService, TransactionItem, SubscriptionItem } from '../services/dcb-reporting.service';
import { catchError } from 'rxjs/operators';
import { of, Subscription } from 'rxjs';
@Component({
selector: 'app-dashboard-report',
imports: [
CommonModule,
Chartjs,
NgbProgressbarModule,
NgbDropdownModule, // Ajoutez ce module
NgIconComponent,
CountUpModule
],
template: `
<div class="container-fluid">
<!-- Titre principal -->
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<h1 class="h3 mb-0">Dashboard Reporting</h1>
<button class="btn btn-outline-primary" (click)="refreshAll()">
<ng-icon name="lucideRefreshCw" class="me-2"></ng-icon>
Actualiser
</button>
</div>
</div>
</div>
<!-- Cartes de statistiques -->
<div class="row g-3 mb-4">
<!-- Transactions Journalières -->
<div class="col-md-6 col-xl-3">
<div class="card border-primary border-2">
<div class="card-body text-center p-3">
<ng-icon name="lucideCalendar" class="text-primary fs-20 mb-2" />
<h6 class="text-primary mb-2">Transactions Journalières</h6>
<h4 class="fw-bold mb-1">
<span [countUp]="dailyTransactions.total" [options]="{duration: 1}">
{{ dailyTransactions.total }}
</span>
</h4>
<p class="text-muted mb-1">Transactions</p>
<div class="d-flex justify-content-between small">
<span>{{ formatCurrency(dailyTransactions.revenue) }}</span>
<span [ngClass]="dailyTransactions.successRate >= 95 ? 'text-success' : 'text-warning'">
<ng-icon name="lucideCheckCircle" class="me-1 fs-12" />
{{ dailyTransactions.successRate | number:'1.1-1' }}%
</span>
</div>
</div>
</div>
</div>
<!-- Transactions Hebdomadaires -->
<div class="col-md-6 col-xl-3">
<div class="card border-info border-2">
<div class="card-body text-center p-3">
<ng-icon name="lucideCalendarDays" class="text-info fs-20 mb-2" />
<h6 class="text-info mb-2">Transactions Hebdomadaires</h6>
<h4 class="fw-bold mb-1">
<span [countUp]="weeklyTransactions.total" [options]="{duration: 1}">
{{ weeklyTransactions.total }}
</span>
</h4>
<p class="text-muted mb-1">Transactions</p>
<div class="d-flex justify-content-between small">
<span>{{ formatCurrency(weeklyTransactions.revenue) }}</span>
<span [ngClass]="weeklyTransactions.successRate >= 95 ? 'text-success' : 'text-warning'">
<ng-icon name="lucideCheckCircle" class="me-1 fs-12" />
{{ weeklyTransactions.successRate | number:'1.1-1' }}%
</span>
</div>
</div>
</div>
</div>
<!-- Transactions Mensuelles -->
<div class="col-md-6 col-xl-3">
<div class="card border-success border-2">
<div class="card-body text-center p-3">
<ng-icon name="lucideCalendarRange" class="text-success fs-20 mb-2" />
<h6 class="text-success mb-2">Transactions Mensuelles</h6>
<h4 class="fw-bold mb-1">
<span [countUp]="monthlyTransactions.total" [options]="{duration: 1}">
{{ monthlyTransactions.total }}
</span>
</h4>
<p class="text-muted mb-1">Transactions</p>
<div class="d-flex justify-content-between small">
<span>{{ formatCurrency(monthlyTransactions.revenue) }}</span>
<span [ngClass]="monthlyTransactions.successRate >= 95 ? 'text-success' : 'text-warning'">
<ng-icon name="lucideCheckCircle" class="me-1 fs-12" />
{{ monthlyTransactions.successRate | number:'1.1-1' }}%
</span>
</div>
</div>
</div>
</div>
<!-- Taux global -->
<div class="col-md-6 col-xl-3">
<div class="card border-warning border-2">
<div class="card-body text-center p-3">
<ng-icon name="lucideActivity" class="text-warning fs-20 mb-2" />
<h6 class="text-warning mb-2">Taux global</h6>
<h4 class="fw-bold mb-1">
<span [countUp]="overallSuccessRate" [options]="{duration: 1, decimalPlaces: 1}">
{{ overallSuccessRate | number:'1.1-1' }}
</span>%
</h4>
<p class="text-muted mb-1">Succès (30 jours)</p>
<div class="d-flex justify-content-between small">
<span>Période: 30j</span>
<span [ngClass]="overallSuccessRate >= 95 ? 'text-success' : overallSuccessRate >= 90 ? 'text-warning' : 'text-danger'">
{{ getPerformanceLabel(overallSuccessRate) }}
</span>
</div>
</div>
</div>
</div>
</div>
<!-- Graphique et Abonnements -->
<div class="row g-3 mb-4">
<!-- Graphique des transactions mensuelles -->
<div class="col-xl-8">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Revenue Mensuel</h5>
</div>
<div class="card-body">
<app-chartjs [getOptions]="revenueChart" [height]="300" />
</div>
</div>
</div>
<!-- Abonnements -->
<div class="col-xl-4">
<div class="card h-100">
<div class="card-header">
<div class="d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">Abonnements</h5>
<div ngbDropdown>
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" ngbDropdownToggle>
{{ getSubscriptionPeriodLabel() }}
</button>
<div ngbDropdownMenu>
<button ngbDropdownItem (click)="showDailySubscriptions()">Journalier</button>
<button ngbDropdownItem (click)="showWeeklySubscriptions()">Hebdomadaire</button>
<button ngbDropdownItem (click)="showMonthlySubscriptions()">Mensuel</button>
</div>
</div>
</div>
</div>
<div class="card-body">
<!-- Affichage selon la période sélectionnée -->
<div *ngIf="subscriptionPeriod === 'daily'">
<h6 class="text-muted mb-2">Journaliers</h6>
<div class="d-flex align-items-center mb-3">
<ng-icon name="lucideUsers" class="text-primary me-2"></ng-icon>
<div class="flex-grow-1">
<h4 class="mb-0">{{ dailySubscriptions.active }}</h4>
<small class="text-muted">Actifs</small>
</div>
<div class="text-end">
<small class="text-muted">Total: {{ dailySubscriptions.total }}</small><br>
<small class="text-danger">Annulés: {{ dailySubscriptions.cancelled }}</small>
</div>
</div>
</div>
<div *ngIf="subscriptionPeriod === 'weekly'">
<h6 class="text-muted mb-2">Hebdomadaires</h6>
<div class="d-flex align-items-center mb-3">
<ng-icon name="lucideUsers" class="text-info me-2"></ng-icon>
<div class="flex-grow-1">
<h4 class="mb-0">{{ weeklySubscriptions.active }}</h4>
<small class="text-muted">Actifs</small>
</div>
<div class="text-end">
<small class="text-muted">Total: {{ weeklySubscriptions.total }}</small><br>
<small class="text-danger">Annulés: {{ weeklySubscriptions.cancelled }}</small>
</div>
</div>
</div>
<div *ngIf="subscriptionPeriod === 'monthly'">
<h6 class="text-muted mb-2">Mensuels</h6>
<div class="d-flex align-items-center mb-3">
<ng-icon name="lucideUsers" class="text-success me-2"></ng-icon>
<div class="flex-grow-1">
<h4 class="mb-0">{{ monthlySubscriptions.active }}</h4>
<small class="text-muted">Actifs</small>
</div>
<div class="text-end">
<small class="text-muted">Total: {{ monthlySubscriptions.total }}</small><br>
<small class="text-danger">Annulés: {{ monthlySubscriptions.cancelled }}</small>
</div>
</div>
</div>
<!-- Taux d'activité -->
<div class="mt-4 pt-3 border-top">
<div class="d-flex justify-content-between mb-1">
<small class="text-muted">Taux d'activité</small>
<small class="text-muted">{{ getCurrentActivityRate() | number:'1.1-1' }}%</small>
</div>
<ngb-progressbar [value]="getCurrentActivityRate()" class="progress-sm" />
<small class="text-muted mt-1 d-block">
{{ getCurrentActiveSubscriptions() }} actifs sur {{ getCurrentTotalSubscriptions() }} total
</small>
</div>
</div>
</div>
</div>
</div>
<!-- Tableau des transactions récentes -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<div class="d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">Transactions Récentes</h5>
<div ngbDropdown>
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" ngbDropdownToggle>
{{ getTransactionPeriodLabel() }}
</button>
<div ngbDropdownMenu>
<button ngbDropdownItem (click)="showDailyTransactions()">Journalières</button>
<button ngbDropdownItem (click)="showWeeklyTransactions()">Hebdomadaires</button>
<button ngbDropdownItem (click)="showMonthlyTransactions()">Mensuelles</button>
</div>
</div>
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Période</th>
<th>Transactions</th>
<th>Montant Total</th>
<th>Réussies</th>
<th>Échouées</th>
<th>En attente</th>
<th>Taux de succès</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let item of getCurrentTransactions()">
<td>{{ item.period }}</td>
<td>{{ item.count }}</td>
<td>{{ formatCurrency(item.totalAmount) }}</td>
<td class="text-success">{{ item.successCount }}</td>
<td class="text-danger">{{ item.failedCount }}</td>
<td class="text-warning">{{ item.pendingCount }}</td>
<td>
<span [ngClass]="getSuccessRate(item) >= 95 ? 'text-success' : getSuccessRate(item) >= 90 ? 'text-warning' : 'text-danger'">
{{ getSuccessRate(item) | number:'1.1-1' }}%
</span>
</td>
</tr>
<tr *ngIf="getCurrentTransactions().length === 0">
<td colspan="7" class="text-center text-muted py-3">
Aucune donnée de transaction disponible
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
`,
styles: [`
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`]
})
export class DashboardReport implements OnInit, OnDestroy {
// Périodes d'affichage
subscriptionPeriod: 'daily' | 'weekly' | 'monthly' = 'daily';
transactionPeriod: 'daily' | 'weekly' | 'monthly' = 'daily';
// Données des transactions
dailyTransactions = { total: 0, revenue: 0, successRate: 0 };
weeklyTransactions = { total: 0, revenue: 0, successRate: 0 };
monthlyTransactions = { total: 0, revenue: 0, successRate: 0 };
overallSuccessRate = 0;
// Données brutes pour les tableaux
dailyTransactionItems: TransactionItem[] = [];
weeklyTransactionItems: TransactionItem[] = [];
monthlyTransactionItems: TransactionItem[] = [];
// Données des abonnements
dailySubscriptions = { total: 0, active: 0, cancelled: 0 };
weeklySubscriptions = { total: 0, active: 0, cancelled: 0 };
monthlySubscriptions = { total: 0, active: 0, cancelled: 0 };
// Données brutes pour les abonnements
dailySubscriptionItems: SubscriptionItem[] = [];
weeklySubscriptionItems: SubscriptionItem[] = [];
monthlySubscriptionItems: SubscriptionItem[] = [];
// Données pour le graphique
chartData: { date: string; revenue: number }[] = [];
// Abonnements aux API
private subscriptions: Subscription[] = [];
constructor(private reportingService: DcbReportingService) {}
ngOnInit() {
this.loadAllData();
}
ngOnDestroy() {
this.cleanupSubscriptions();
}
private cleanupSubscriptions() {
this.subscriptions.forEach(sub => sub.unsubscribe());
this.subscriptions = [];
}
loadAllData() {
this.cleanupSubscriptions();
// Charger toutes les données en parallèle
this.subscriptions.push(
this.reportingService.getDailyTransactions()
.pipe(catchError(() => of([])))
.subscribe(data => {
this.dailyTransactionItems = data;
this.dailyTransactions = this.calculateTransactionStats(data);
})
);
this.subscriptions.push(
this.reportingService.getWeeklyTransactions()
.pipe(catchError(() => of([])))
.subscribe(data => {
this.weeklyTransactionItems = data;
this.weeklyTransactions = this.calculateTransactionStats(data);
})
);
this.subscriptions.push(
this.reportingService.getMonthlyTransactions()
.pipe(catchError(() => of([])))
.subscribe(data => {
this.monthlyTransactionItems = data;
this.monthlyTransactions = this.calculateTransactionStats(data);
this.overallSuccessRate = this.monthlyTransactions.successRate;
this.prepareChartData(data);
})
);
this.subscriptions.push(
this.reportingService.getDailySubscriptions()
.pipe(catchError(() => of([])))
.subscribe(data => {
this.dailySubscriptionItems = data;
this.dailySubscriptions = this.calculateSubscriptionStats(data);
})
);
this.subscriptions.push(
this.reportingService.getWeeklySubscriptions()
.pipe(catchError(() => of([])))
.subscribe(data => {
this.weeklySubscriptionItems = data;
this.weeklySubscriptions = this.calculateSubscriptionStats(data);
})
);
this.subscriptions.push(
this.reportingService.getMonthlySubscriptions()
.pipe(catchError(() => of([])))
.subscribe(data => {
this.monthlySubscriptionItems = data;
this.monthlySubscriptions = this.calculateSubscriptionStats(data);
})
);
}
// Méthodes pour changer la période d'affichage
showDailySubscriptions() {
this.subscriptionPeriod = 'daily';
}
showWeeklySubscriptions() {
this.subscriptionPeriod = 'weekly';
}
showMonthlySubscriptions() {
this.subscriptionPeriod = 'monthly';
}
showDailyTransactions() {
this.transactionPeriod = 'daily';
}
showWeeklyTransactions() {
this.transactionPeriod = 'weekly';
}
showMonthlyTransactions() {
this.transactionPeriod = 'monthly';
}
// Méthodes utilitaires
calculateTransactionStats(data: TransactionItem[]): { total: number; revenue: number; successRate: number } {
if (!data || data.length === 0) {
return { total: 0, revenue: 0, successRate: 0 };
}
const total = data.reduce((sum, item) => sum + (item.count || 0), 0);
const revenue = data.reduce((sum, item) => sum + (item.totalAmount || 0), 0);
const successful = data.reduce((sum, item) => sum + (item.successCount || 0), 0);
const successRate = total > 0 ? (successful / total) * 100 : 0;
return { total, revenue, successRate };
}
calculateSubscriptionStats(data: SubscriptionItem[]): { total: number; active: number; cancelled: number } {
if (!data || data.length === 0) {
return { total: 0, active: 0, cancelled: 0 };
}
// Prendre la dernière période
const latest = data[data.length - 1];
return {
total: latest.count || 0,
active: latest.activeCount || 0,
cancelled: latest.cancelledCount || 0
};
}
getSubscriptionPeriodLabel(): string {
switch (this.subscriptionPeriod) {
case 'daily': return 'Journalier';
case 'weekly': return 'Hebdomadaire';
case 'monthly': return 'Mensuel';
default: return 'Période';
}
}
getTransactionPeriodLabel(): string {
switch (this.transactionPeriod) {
case 'daily': return 'Journalières';
case 'weekly': return 'Hebdomadaires';
case 'monthly': return 'Mensuelles';
default: return 'Période';
}
}
prepareChartData(data: TransactionItem[]) {
if (!data || data.length === 0) {
this.chartData = [];
return;
}
// Prendre les 6 derniers mois pour le graphique
this.chartData = data
.slice(-6)
.map(item => ({
date: item.period, // Ex: "2025-11"
revenue: item.totalAmount || 0
}));
}
getCurrentTransactions(): TransactionItem[] {
switch (this.transactionPeriod) {
case 'daily': return this.dailyTransactionItems;
case 'weekly': return this.weeklyTransactionItems;
case 'monthly': return this.monthlyTransactionItems;
default: return this.dailyTransactionItems;
}
}
getCurrentTotalSubscriptions(): number {
switch (this.subscriptionPeriod) {
case 'daily': return this.dailySubscriptions.total;
case 'weekly': return this.weeklySubscriptions.total;
case 'monthly': return this.monthlySubscriptions.total;
default: return this.dailySubscriptions.total;
}
}
getCurrentActiveSubscriptions(): number {
switch (this.subscriptionPeriod) {
case 'daily': return this.dailySubscriptions.active;
case 'weekly': return this.weeklySubscriptions.active;
case 'monthly': return this.monthlySubscriptions.active;
default: return this.dailySubscriptions.active;
}
}
getCurrentActivityRate(): number {
const total = this.getCurrentTotalSubscriptions();
const active = this.getCurrentActiveSubscriptions();
return total > 0 ? (active / total) * 100 : 0;
}
getSuccessRate(item: TransactionItem): number {
const count = item.count || 0;
const success = item.successCount || 0;
return count > 0 ? (success / count) * 100 : 0;
}
getPerformanceLabel(successRate: number): string {
if (successRate >= 95) return 'Excellent';
if (successRate >= 90) return 'Bon';
if (successRate >= 80) return 'Moyen';
if (successRate >= 70) return 'Passable';
return 'À améliorer';
}
formatCurrency(amount: number): string {
if (amount >= 1000000) {
return `${(amount / 1000000).toFixed(1)}M XOF`;
} else if (amount >= 1000) {
return `${(amount / 1000).toFixed(0)}K XOF`;
}
return `${Math.round(amount)} XOF`;
}
revenueChart = (): ChartConfiguration => ({
type: 'bar',
data: {
labels: this.chartData.map(item => {
// Gérer différents formats de période
if (item.date.includes('-')) {
const [year, month] = item.date.split('-');
const months = ['Jan', 'Fév', 'Mar', 'Avr', 'Mai', 'Jun', 'Jul', 'Aoû', 'Sep', 'Oct', 'Nov', 'Déc'];
const monthIndex = parseInt(month) - 1;
if (monthIndex >= 0 && monthIndex < months.length) {
return `${months[monthIndex]} ${year}`;
}
}
return item.date;
}),
datasets: [
{
label: 'Revenue (XOF)',
data: this.chartData.map(item => Math.round(item.revenue / 1000)), // En milliers
backgroundColor: this.chartData.map((_, index) =>
getColor(index === this.chartData.length - 1 ? 'chart-primary' : 'chart-secondary')
),
borderRadius: 6,
borderSkipped: false,
}
]
},
options: {
responsive: true,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (context) => {
const value = context.raw as number;
return `Revenue: ${this.formatCurrency(value * 1000)}`;
}
}
}
},
scales: {
x: {
grid: { display: false }
},
y: {
beginAtZero: true,
ticks: {
callback: (value) => `${value}K XOF`
},
title: {
display: true,
text: 'Revenue (en milliers XOF)'
}
}
}
}
});
refreshAll() {
this.loadAllData();
}
}

View File

@ -1,67 +0,0 @@
import { Component } from '@angular/core';
import { NgIconComponent } from '@ng-icons/core';
@Component({
selector: 'app-operator-performance',
imports: [NgIconComponent],
template: `
<div class="card card-h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h5 class="text-uppercase mb-3">Performance Opérateurs</h5>
</div>
<div>
<ng-icon name="lucideSignal" class="text-warning fs-24 svg-sw-10" />
</div>
</div>
<div class="space-y-3">
<div class="d-flex justify-content-between align-items-center">
<span class="text-muted">Orange</span>
<div class="d-flex align-items-center gap-2">
<span class="fw-semibold text-success">98.5%</span>
<div class="progress flex-grow-1" style="width: 100px; height: 6px;">
<div class="progress-bar bg-success" style="width: 98.5%"></div>
</div>
</div>
</div>
<div class="d-flex justify-content-between align-items-center">
<span class="text-muted">MTN</span>
<div class="d-flex align-items-center gap-2">
<span class="fw-semibold text-success">96.2%</span>
<div class="progress flex-grow-1" style="width: 100px; height: 6px;">
<div class="progress-bar bg-success" style="width: 96.2%"></div>
</div>
</div>
</div>
<div class="d-flex justify-content-between align-items-center">
<span class="text-muted">Airtel</span>
<div class="d-flex align-items-center gap-2">
<span class="fw-semibold text-warning">87.4%</span>
<div class="progress flex-grow-1" style="width: 100px; height: 6px;">
<div class="progress-bar bg-warning" style="width: 87.4%"></div>
</div>
</div>
</div>
<div class="d-flex justify-content-between align-items-center">
<span class="text-muted">Moov</span>
<div class="d-flex align-items-center gap-2">
<span class="fw-semibold text-info">92.1%</span>
<div class="progress flex-grow-1" style="width: 100px; height: 6px;">
<div class="progress-bar bg-info" style="width: 92.1%"></div>
</div>
</div>
</div>
</div>
</div>
<div class="card-footer text-muted text-center">
Taux de succès moyen: <strong>94.5%</strong>
</div>
</div>
`,
})
export class OperatorPerformance {}

View File

@ -1,17 +1,37 @@
import { Component } from '@angular/core';
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { NgIconComponent } from '@ng-icons/core';
import { CountUpModule } from 'ngx-countup';
import { DcbReportingService, TransactionItem } from '../services/dcb-reporting.service';
import { catchError, finalize } from 'rxjs/operators';
import { of, Subscription } from 'rxjs';
@Component({
selector: 'app-payment-stats',
imports: [NgIconComponent, CountUpModule],
imports: [CommonModule, NgIconComponent, CountUpModule],
template: `
<div class="card">
<div class="card-header bg-light">
<h5 class="card-title mb-0">Statistiques des Paiements</h5>
<div class="d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">Statistiques des Paiements</h5>
<button class="btn btn-sm btn-outline-primary" (click)="refresh()" [disabled]="loading">
<ng-icon name="lucideRefreshCw" [class.spin]="loading" class="me-1"></ng-icon>
Actualiser
</button>
</div>
</div>
<div class="card-body">
<div class="row g-3">
<!-- État de chargement -->
<div *ngIf="loading" class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
<p class="mt-2 text-muted">Chargement des statistiques...</p>
</div>
<!-- Statistiques -->
<div *ngIf="!loading && !error" class="row g-3">
<!-- Journalier -->
<div class="col-md-6 col-xl-3">
<div class="card border-primary border-2">
@ -19,14 +39,16 @@ import { CountUpModule } from 'ngx-countup';
<ng-icon name="lucideCalendar" class="text-primary fs-20 mb-2" />
<h6 class="text-primary mb-2">Journalier</h6>
<h4 class="fw-bold mb-1">
<span [countUp]="342">342</span>
<span [countUp]="dailyStats.transactions" [options]="{duration: 1}">
{{ dailyStats.transactions }}
</span>
</h4>
<p class="text-muted mb-1">Transactions</p>
<div class="d-flex justify-content-between small">
<span>245K XOF</span>
<span class="text-success">
<ng-icon name="lucideTrendingUp" class="me-1 fs-12" />
98.2%
<span>{{ formatCurrency(dailyStats.revenue) }}</span>
<span [ngClass]="dailyStats.successRate >= 95 ? 'text-success' : 'text-warning'">
<ng-icon name="lucideCheckCircle" class="me-1 fs-12" />
{{ dailyStats.successRate | number:'1.1-1' }}%
</span>
</div>
</div>
@ -40,14 +62,16 @@ import { CountUpModule } from 'ngx-countup';
<ng-icon name="lucideCalendarDays" class="text-info fs-20 mb-2" />
<h6 class="text-info mb-2">Hebdomadaire</h6>
<h4 class="fw-bold mb-1">
<span [countUp]="2150">2,150</span>
<span [countUp]="weeklyStats.transactions" [options]="{duration: 1}">
{{ weeklyStats.transactions }}
</span>
</h4>
<p class="text-muted mb-1">Transactions</p>
<div class="d-flex justify-content-between small">
<span>1.58M XOF</span>
<span class="text-success">
<ng-icon name="lucideTrendingUp" class="me-1 fs-12" />
97.8%
<span>{{ formatCurrency(weeklyStats.revenue) }}</span>
<span [ngClass]="weeklyStats.successRate >= 95 ? 'text-success' : 'text-warning'">
<ng-icon name="lucideCheckCircle" class="me-1 fs-12" />
{{ weeklyStats.successRate | number:'1.1-1' }}%
</span>
</div>
</div>
@ -61,35 +85,38 @@ import { CountUpModule } from 'ngx-countup';
<ng-icon name="lucideCalendarRange" class="text-success fs-20 mb-2" />
<h6 class="text-success mb-2">Mensuel</h6>
<h4 class="fw-bold mb-1">
<span [countUp]="8450">8,450</span>
<span [countUp]="monthlyStats.transactions" [options]="{duration: 1}">
{{ monthlyStats.transactions }}
</span>
</h4>
<p class="text-muted mb-1">Transactions</p>
<div class="d-flex justify-content-between small">
<span>6.25M XOF</span>
<span class="text-warning">
<ng-icon name="lucideTrendingDown" class="me-1 fs-12" />
96.5%
<span>{{ formatCurrency(monthlyStats.revenue) }}</span>
<span [ngClass]="monthlyStats.successRate >= 95 ? 'text-success' : 'text-warning'">
<ng-icon name="lucideCheckCircle" class="me-1 fs-12" />
{{ monthlyStats.successRate | number:'1.1-1' }}%
</span>
</div>
</div>
</div>
</div>
<!-- Annuel -->
<!-- Taux global -->
<div class="col-md-6 col-xl-3">
<div class="card border-warning border-2">
<div class="card-body text-center p-3">
<ng-icon name="lucideCalendar" class="text-warning fs-20 mb-2" />
<h6 class="text-warning mb-2">Annuel</h6>
<ng-icon name="lucideActivity" class="text-warning fs-20 mb-2" />
<h6 class="text-warning mb-2">Taux global</h6>
<h4 class="fw-bold mb-1">
<span [countUp]="12500">12,500</span>
<span [countUp]="overallSuccessRate" [options]="{duration: 1, decimalPlaces: 1}">
{{ overallSuccessRate | number:'1.1-1' }}
</span>%
</h4>
<p class="text-muted mb-1">Transactions</p>
<p class="text-muted mb-1">Succès (30 jours)</p>
<div class="d-flex justify-content-between small">
<span>9.85M XOF</span>
<span class="text-success">
<ng-icon name="lucideTrendingUp" class="me-1 fs-12" />
95.2%
<span>Période: 30j</span>
<span [ngClass]="overallSuccessRate >= 95 ? 'text-success' : overallSuccessRate >= 90 ? 'text-warning' : 'text-danger'">
{{ getPerformanceLabel(overallSuccessRate) }}
</span>
</div>
</div>
@ -98,16 +125,22 @@ import { CountUpModule } from 'ngx-countup';
</div>
<!-- Résumé global -->
<div class="row mt-3">
<div *ngIf="!loading && !error" class="row mt-3">
<div class="col-12">
<div class="alert alert-light mb-0">
<div class="d-flex justify-content-between align-items-center">
<div>
<strong>Performance globale:</strong>
<span class="text-success">97.4% de taux de succès</span>
<span [ngClass]="overallSuccessRate >= 95 ? 'text-success' : overallSuccessRate >= 90 ? 'text-warning' : 'text-danger'">
{{ overallSuccessRate | number:'1.1-1' }}% de taux de succès
</span>
<span class="text-muted ms-2">
({{ dailyStats.transactions }} transactions aujourd'hui)
</span>
</div>
<div class="text-muted small">
Dernière mise à jour: {{ getCurrentTime() }}
<ng-icon name="lucideClock" class="me-1"></ng-icon>
Mise à jour: {{ lastUpdated | date:'HH:mm' }}
</div>
</div>
</div>
@ -116,12 +149,264 @@ import { CountUpModule } from 'ngx-countup';
</div>
</div>
`,
styles: [`
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`]
})
export class PaymentStats {
getCurrentTime(): string {
return new Date().toLocaleTimeString('fr-FR', {
hour: '2-digit',
minute: '2-digit'
export class PaymentStats implements OnInit, OnDestroy {
loading = false;
error: string | null = null;
// Statistiques calculées à partir des données brutes
dailyStats = { transactions: 0, revenue: 0, successRate: 0 };
weeklyStats = { transactions: 0, revenue: 0, successRate: 0 };
monthlyStats = { transactions: 0, revenue: 0, successRate: 0 };
overallSuccessRate = 0;
lastUpdated = new Date();
// Données brutes de l'API
private dailyData: TransactionItem[] = [];
private weeklyData: TransactionItem[] = [];
private monthlyData: TransactionItem[] = [];
private dailySubscription?: Subscription;
private weeklySubscription?: Subscription;
private monthlySubscription?: Subscription;
constructor(private reportingService: DcbReportingService) {}
ngOnInit() {
this.loadStats();
}
ngOnDestroy() {
this.cleanupSubscriptions();
}
private cleanupSubscriptions() {
if (this.dailySubscription) {
this.dailySubscription.unsubscribe();
}
if (this.weeklySubscription) {
this.weeklySubscription.unsubscribe();
}
if (this.monthlySubscription) {
this.monthlySubscription.unsubscribe();
}
}
loadStats() {
console.log('PaymentStats - Starting loadStats()');
this.cleanupSubscriptions(); // Nettoyer les anciennes souscriptions
this.loading = true;
this.error = null;
const today = new Date();
const threeDaysAgo = new Date(today);
threeDaysAgo.setDate(threeDaysAgo.getDate() - 3);
const twoWeeksAgo = new Date(today);
twoWeeksAgo.setDate(twoWeeksAgo.getDate() - 14);
const threeMonthsAgo = new Date(today);
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
console.log('PaymentStats - Date ranges:', {
threeDaysAgo: this.reportingService.formatDate(threeDaysAgo),
today: this.reportingService.formatDate(today),
twoWeeksAgo: this.reportingService.formatDate(twoWeeksAgo),
threeMonthsAgo: this.reportingService.formatDate(threeMonthsAgo)
});
// Charger les données séquentiellement
this.loadSequentialData(threeDaysAgo, twoWeeksAgo, threeMonthsAgo, today);
}
loadSequentialData(threeDaysAgo: Date, twoWeeksAgo: Date, threeMonthsAgo: Date, today: Date) {
// 1. Charger les données quotidiennes
this.dailySubscription = this.reportingService.getDailyTransactions(
this.reportingService.formatDate(threeDaysAgo),
this.reportingService.formatDate(today)
)
.pipe(
catchError(err => {
console.error('PaymentStats - Error loading daily data:', err);
return of([]);
})
)
.subscribe({
next: (dailyData) => {
console.log('PaymentStats - Daily data loaded:', dailyData.length);
this.dailyData = dailyData;
// 2. Après les données quotidiennes, charger les données hebdomadaires
this.weeklySubscription = this.reportingService.getWeeklyTransactions(
this.reportingService.formatDate(twoWeeksAgo),
this.reportingService.formatDate(today)
)
.pipe(
catchError(err => {
console.error('PaymentStats - Error loading weekly data:', err);
return of([]);
})
)
.subscribe({
next: (weeklyData) => {
console.log('PaymentStats - Weekly data loaded:', weeklyData.length);
this.weeklyData = weeklyData;
// 3. Après les données hebdomadaires, charger les données mensuelles
this.monthlySubscription = this.reportingService.getMonthlyTransactions(
this.reportingService.formatDate(threeMonthsAgo),
this.reportingService.formatDate(today)
)
.pipe(
catchError(err => {
console.error('PaymentStats - Error loading monthly data:', err);
return of([]);
}),
finalize(() => {
console.log('PaymentStats - All data loaded, processing...');
this.loading = false;
this.lastUpdated = new Date();
this.processAllData();
})
)
.subscribe({
next: (monthlyData) => {
console.log('PaymentStats - Monthly data loaded:', monthlyData.length);
this.monthlyData = monthlyData;
},
error: (error) => {
console.error('PaymentStats - Monthly subscription error:', error);
}
});
},
error: (error) => {
console.error('PaymentStats - Weekly subscription error:', error);
}
});
},
error: (error) => {
console.error('PaymentStats - Daily subscription error:', error);
}
});
}
processAllData() {
console.log('PaymentStats - Processing all data:', {
daily: this.dailyData.length,
weekly: this.weeklyData.length,
monthly: this.monthlyData.length
});
// Vérifier si nous avons des données
const hasAnyData = this.dailyData.length > 0 ||
this.weeklyData.length > 0 ||
this.monthlyData.length > 0;
if (!hasAnyData) {
console.warn('PaymentStats - No data available');
return;
}
// Calculer les statistiques avec les données disponibles
this.calculateStatsFromRawData();
}
calculateStatsFromRawData() {
// Calculer les statistiques pour chaque période à partir des données brutes
this.dailyStats = this.calculatePeriodStats(this.dailyData);
this.weeklyStats = this.calculatePeriodStats(this.weeklyData);
this.monthlyStats = this.calculatePeriodStats(this.monthlyData);
// Utiliser le taux de succès mensuel comme taux global
this.overallSuccessRate = this.monthlyStats.successRate;
// Si aucune donnée mensuelle, utiliser la meilleure période disponible
if (this.monthlyStats.transactions === 0) {
if (this.weeklyStats.transactions > 0) {
this.overallSuccessRate = this.weeklyStats.successRate;
} else if (this.dailyStats.transactions > 0) {
this.overallSuccessRate = this.dailyStats.successRate;
}
}
console.log('PaymentStats - Calculated stats from raw data:', {
daily: this.dailyStats,
weekly: this.weeklyStats,
monthly: this.monthlyStats,
overallSuccess: this.overallSuccessRate
});
// Si toutes les données sont vides, afficher un message
if (this.dailyStats.transactions === 0 &&
this.weeklyStats.transactions === 0 &&
this.monthlyStats.transactions === 0) {
this.error = 'Les données sont actuellement vides.';
}
}
calculatePeriodStats(data: TransactionItem[]): { transactions: number; revenue: number; successRate: number } {
if (!data || data.length === 0) {
return { transactions: 0, revenue: 0, successRate: 0 };
}
// Calculer les totaux sur tous les éléments
const totalTransactions = data.reduce((sum, item) => sum + (item.count || 0), 0);
const totalRevenue = data.reduce((sum, item) => sum + (item.totalAmount || 0), 0);
const totalSuccessful = data.reduce((sum, item) => sum + (item.successCount || 0), 0);
const successRate = totalTransactions > 0 ? (totalSuccessful / totalTransactions) * 100 : 0;
console.log('PaymentStats - Period stats calculation:', {
dataLength: data.length,
totalTransactions,
totalRevenue,
totalSuccessful,
successRate
});
return {
transactions: totalTransactions,
revenue: totalRevenue,
successRate
};
}
getPerformanceLabel(successRate: number): string {
if (successRate >= 95) return 'Excellent';
if (successRate >= 90) return 'Bon';
if (successRate >= 80) return 'Moyen';
if (successRate >= 70) return 'Passable';
return 'À améliorer';
}
formatCurrency(amount: number): string {
if (amount >= 1000000) {
return `${(amount / 1000000).toFixed(1)}M XOF`;
} else if (amount >= 1000) {
return `${(amount / 1000).toFixed(0)}K XOF`;
}
return `${Math.round(amount)} XOF`;
}
refresh() {
console.log('PaymentStats - Refreshing data');
// Essayer de vider le cache si disponible
if (this.reportingService.clearCache) {
this.reportingService.clearCache();
}
this.loadStats();
}
}

View File

@ -1,190 +1,247 @@
import { Component } from '@angular/core';
import { NgIconComponent } from '@ng-icons/core';
import { DecimalPipe } from '@angular/common';
import { NgbDropdownModule, NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap';
interface Transaction {
id: string;
user: string;
operator: string;
amount: number;
status: 'success' | 'pending' | 'failed';
date: string;
}
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SubscriptionsService } from '../../subscriptions/subscriptions.service';
import { Subscription} from '@core/models/dcb-bo-hub-subscription.model';
import { catchError, finalize } from 'rxjs/operators';
import { of } from 'rxjs';
@Component({
selector: 'app-recent-transactions',
imports: [
NgIconComponent,
DecimalPipe,
NgbPaginationModule,
NgbDropdownModule,
],
imports: [CommonModule],
template: `
<div class="card">
<div class="card-header justify-content-between align-items-center border-dashed">
<h4 class="card-title mb-0">Transactions Récentes</h4>
<a href="javascript:void(0);" class="btn btn-sm btn-primary">
<ng-icon name="lucideFile" class="me-1" />
Exporter
</a>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-centered table-custom table-sm table-nowrap table-hover mb-0">
<thead class="table-light">
<tr>
<th>Transaction</th>
<th>Utilisateur</th>
<th>Opérateur</th>
<th>Montant</th>
<th>Statut</th>
<th style="width: 30px;"></th>
</tr>
</thead>
<tbody>
@for (transaction of transactions; track transaction.id) {
<tr>
<td>
<span class="text-muted fs-xs">#{{ transaction.id }}</span>
</td>
<td>
<span class="fw-semibold">{{ transaction.user }}</span>
</td>
<td>
<span class="badge bg-light text-dark">{{ transaction.operator }}</span>
</td>
<td>
<span class="fw-semibold">{{ transaction.amount | number }} XOF</span>
</td>
<td>
<span [class]="getStatusClass(transaction.status)" class="badge">
{{ getStatusText(transaction.status) }}
</span>
</td>
<td>
<div ngbDropdown placement="bottom-end">
<a href="javascript:void(0);" ngbDropdownToggle class="text-muted drop-arrow-none card-drop p-0">
<ng-icon name="lucideMoreVertical" class="fs-lg" />
</a>
<div class="dropdown-menu-end" ngbDropdownMenu>
<a href="javascript:void(0)" ngbDropdownItem>Voir détails</a>
<a href="javascript:void(0)" ngbDropdownItem>Rembourser</a>
</div>
</div>
</td>
</tr>
}
</tbody>
</table>
<div class="card-header border-dashed">
<div class="d-flex justify-content-between align-items-center">
<h4 class="card-title mb-0">Abonnements Récents</h4>
<div class="d-flex align-items-center gap-2">
<button class="btn btn-sm btn-outline-primary" (click)="refresh()" [disabled]="loading">
<i class="lucideRefreshCw" [class.spin]="loading"></i>
</button>
<a href="/subscriptions" class="btn btn-sm btn-light">Voir tout</a>
</div>
</div>
</div>
<div class="card-footer border-0">
<div class="align-items-center justify-content-between row text-center text-sm-start">
<div class="col-sm">
<div class="text-muted">
Affichage de <span class="fw-semibold">1</span> à
<span class="fw-semibold">8</span> sur
<span class="fw-semibold">156</span> Transactions
</div>
<div class="card-body p-0">
<!-- État de chargement -->
<div *ngIf="loading" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
<div class="col-sm-auto mt-3 mt-sm-0">
<ngb-pagination
[pageSize]="8"
[collectionSize]="156"
class="pagination-sm pagination-boxed mb-0 justify-content-center"
/>
<p class="mt-2 text-muted">Chargement des abonnements...</p>
</div>
<!-- Message d'erreur -->
<div *ngIf="error && !loading" class="text-center py-5">
<i class="lucideAlertTriangle text-warning fs-1"></i>
<p class="mt-2">{{ error }}</p>
<button class="btn btn-sm btn-primary mt-2" (click)="refresh()">
Réessayer
</button>
</div>
<!-- Liste des abonnements -->
<div *ngIf="!loading && !error" class="table-responsive">
<table class="table table-hover table-nowrap mb-0">
<thead class="table-light">
<tr>
<th>ID</th>
<th>Date de création</th>
<th>Statut</th>
<th>Montant</th>
<th>Périodicité</th>
<th>Client</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let subscription of subscriptions"
[class.table-success]="subscription.status === 'ACTIVE'"
[class.table-warning]="subscription.status === 'PENDING'"
[class.table-danger]="subscription.status === 'EXPIRED' || subscription.status === 'CANCELLED'">
<td>
<div class="fw-semibold">#{{ subscription.id }}</div>
<small class="text-muted">{{ subscription.externalReference || 'N/A' }}</small>
</td>
<td>
<div>{{ subscription.createdAt | date:'dd/MM/yyyy' }}</div>
<small class="text-muted">{{ subscription.createdAt | date:'HH:mm' }}</small>
</td>
<td>
<span class="badge" [ngClass]="getStatusClass(subscription.status)">
<i class="lucideCircle me-1" [ngClass]="getStatusIcon(subscription.status)"></i>
{{ getStatusLabel(subscription.status) }}
</span>
</td>
<td>
<div class="fw-bold">{{ subscription.amount | currency:subscription.currency:'symbol':'1.0-0' }}</div>
<small class="text-muted">{{ subscription.currency }}</small>
</td>
<td>
<div>{{ getPeriodicityLabel(subscription.periodicity) }}</div>
<small class="text-muted">
Prochain: {{ subscription.nextPaymentDate | date:'dd/MM' }}
</small>
</td>
<td>
<div>Client #{{ subscription.customerId }}</div>
<small class="text-muted">Partenaire: {{ subscription.merchantPartnerId }}</small>
</td>
</tr>
<tr *ngIf="subscriptions.length === 0">
<td colspan="6" class="text-center py-4">
<div class="text-muted">
<i class="lucidePackageOpen fs-4"></i>
<p class="mt-2 mb-0">Aucun abonnement trouvé</p>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Statistiques -->
<div *ngIf="!loading && !error && subscriptions.length > 0" class="card-footer">
<div class="d-flex justify-content-between align-items-center">
<div class="text-muted">
Affichage de <strong>{{ subscriptions.length }}</strong> abonnements récents
</div>
<div class="text-end">
<small class="text-muted">Total: {{ statistics?.total || 0 }} abonnements</small>
<div class="mt-1">
<span class="badge bg-success-subtle text-success me-2">
{{ statistics?.active || 0 }} actifs
</span>
<span class="badge bg-warning-subtle text-warning">
{{ statistics?.suspended || 0 }} suspendus
</span>
</div>
</div>
</div>
</div>
</div>
</div>
`,
styles: [``]
})
export class RecentTransactions {
transactions: Transaction[] = [
{
id: 'TX-5001',
user: 'Alice Koné',
operator: 'Orange',
amount: 2500,
status: 'success',
date: '2024-10-28 14:30'
},
{
id: 'TX-5002',
user: 'David Sarr',
operator: 'MTN',
amount: 1500,
status: 'success',
date: '2024-10-28 14:25'
},
{
id: 'TX-5003',
user: 'Sophia Diop',
operator: 'Airtel',
amount: 3000,
status: 'pending',
date: '2024-10-28 14:20'
},
{
id: 'TX-5004',
user: 'James Traoré',
operator: 'Moov',
amount: 2000,
status: 'success',
date: '2024-10-28 14:15'
},
{
id: 'TX-5005',
user: 'Ava Camara',
operator: 'Orange',
amount: 1000,
status: 'failed',
date: '2024-10-28 14:10'
},
{
id: 'TX-5006',
user: 'Ethan Diallo',
operator: 'MTN',
amount: 3500,
status: 'success',
date: '2024-10-28 14:05'
},
{
id: 'TX-5007',
user: 'Mia Bah',
operator: 'Orange',
amount: 1800,
status: 'success',
date: '2024-10-28 14:00'
},
{
id: 'TX-5008',
user: 'Lucas Keita',
operator: 'Airtel',
amount: 2200,
status: 'pending',
date: '2024-10-28 13:55'
}
];
export class RecentTransactions implements OnInit {
loading = false;
error: string | null = null;
subscriptions: Subscription[] = [];
statistics: any = null;
constructor(private subscriptionsService: SubscriptionsService) {}
ngOnInit() {
this.loadRecentSubscriptions();
}
loadRecentSubscriptions() {
this.loading = true;
this.error = null;
this.subscriptionsService.getRecentSubscriptions(10)
.pipe(
catchError(err => {
console.error('Erreur lors du chargement des abonnements:', err);
this.error = 'Impossible de charger les abonnements. Veuillez réessayer.';
return of({
subscriptions: [],
statistics: {
total: 0,
active: 0,
suspended: 0,
cancelled: 0,
totalRevenue: 0,
averageAmount: 0
}
});
}),
finalize(() => {
this.loading = false;
})
)
.subscribe({
next: (response) => {
this.subscriptions = response.subscriptions;
}
});
}
getStatusClass(status: string): string {
switch (status) {
case 'success': return 'bg-success';
case 'pending': return 'bg-warning';
case 'failed': return 'bg-danger';
default: return 'bg-secondary';
switch(status?.toUpperCase()) {
case 'ACTIVE':
return 'bg-success-subtle text-success border border-success-subtle';
case 'PENDING':
return 'bg-warning-subtle text-warning border border-warning-subtle';
case 'SUSPENDED':
return 'bg-secondary-subtle text-secondary border border-secondary-subtle';
case 'CANCELLED':
return 'bg-danger-subtle text-danger border border-danger-subtle';
case 'FAILED':
return 'bg-danger-subtle text-danger border border-danger-subtle';
case 'EXPIRED':
return 'bg-info-subtle text-info border border-info-subtle';
default:
return 'bg-light text-dark border';
}
}
getStatusText(status: string): string {
switch (status) {
case 'success': return 'Succès';
case 'pending': return 'En cours';
case 'failed': return 'Échec';
default: return 'Inconnu';
getStatusIcon(status: string): string {
switch(status?.toUpperCase()) {
case 'ACTIVE':
return 'lucideCheckCircle';
case 'PENDING':
return 'lucideClock';
case 'SUSPENDED':
return 'lucidePauseCircle';
case 'CANCELLED':
return 'lucideXCircle';
case 'FAILED':
return 'lucideAlertCircle';
case 'EXPIRED':
return 'lucideCalendarX';
default:
return 'lucideHelpCircle';
}
}
getStatusLabel(status: string): string {
switch(status?.toUpperCase()) {
case 'ACTIVE':
return 'Actif';
case 'PENDING':
return 'En attente';
case 'SUSPENDED':
return 'Suspendu';
case 'CANCELLED':
return 'Annulé';
case 'FAILED':
return 'Échoué';
case 'EXPIRED':
return 'Expiré';
default:
return status || 'Inconnu';
}
}
getPeriodicityLabel(periodicity: string): string {
switch(periodicity?.toUpperCase()) {
case 'DAILY':
return 'Quotidien';
case 'WEEKLY':
return 'Hebdomadaire';
case 'MONTHLY':
return 'Mensuel';
case 'YEARLY':
return 'Annuel';
default:
return periodicity || 'Non défini';
}
}
refresh() {
this.loadRecentSubscriptions();
}
}

View File

@ -1,83 +0,0 @@
import { Component } from '@angular/core';
import { Chartjs } from '@app/components/chartjs';
import { ChartConfiguration } from 'chart.js';
import { getColor } from '@/app/utils/color-utils';
@Component({
selector: 'app-revenue-chart',
imports: [Chartjs],
template: `
<div class="card">
<div class="card-header justify-content-between align-items-center border-dashed">
<h4 class="card-title mb-0">Revenue Mensuel</h4>
<div class="dropdown">
<button class="btn btn-sm btn-light dropdown-toggle" type="button" data-bs-toggle="dropdown">
Octobre 2024
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="#">Septembre 2024</a></li>
<li><a class="dropdown-item" href="#">Août 2024</a></li>
<li><a class="dropdown-item" href="#">Juillet 2024</a></li>
</ul>
</div>
</div>
<div class="card-body">
<app-chartjs [getOptions]="revenueChart" [height]="300" />
<div class="row text-center mt-4">
<div class="col-3">
<small class="text-muted">Semaine 1</small>
<div class="fw-bold text-primary">175K XOF</div>
</div>
<div class="col-3">
<small class="text-muted">Semaine 2</small>
<div class="fw-bold text-success">125K XOF</div>
</div>
<div class="col-3">
<small class="text-muted">Semaine 3</small>
<div class="fw-bold text-info">105K XOF</div>
</div>
<div class="col-3">
<small class="text-muted">Semaine 4</small>
<div class="fw-bold text-warning">85K XOF</div>
</div>
</div>
</div>
</div>
`,
})
export class RevenueChart {
public revenueChart = (): ChartConfiguration => ({
type: 'bar',
data: {
labels: ['Sem 1', 'Sem 2', 'Sem 3', 'Sem 4'],
datasets: [
{
label: 'Revenue (K XOF)',
data: [175, 125, 105, 85],
backgroundColor: getColor('chart-primary'),
borderRadius: 6,
borderSkipped: false,
},
],
},
options: {
plugins: {
legend: { display: false },
},
scales: {
x: {
grid: { display: false },
},
y: {
beginAtZero: true,
ticks: {
callback: function(value) {
return value + 'K';
}
}
},
},
},
});
}

View File

@ -0,0 +1,307 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Chartjs } from '@app/components/chartjs';
import { ChartConfiguration } from 'chart.js';
import { getColor } from '@/app/utils/color-utils';
import { DcbReportingService, SubscriptionItem } from '../services/dcb-reporting.service';
import { catchError, finalize } from 'rxjs/operators';
import { of } from 'rxjs';
@Component({
selector: 'app-subscription-overview',
imports: [CommonModule, Chartjs],
template: `
<div class="card">
<div class="card-header border-dashed">
<div class="d-flex justify-content-between align-items-center">
<h4 class="card-title mb-0">Aperçu des Abonnements</h4>
<div class="d-flex align-items-center gap-2">
<span class="badge bg-primary-subtle text-primary">
<i class="lucideUsers me-1"></i>
{{ formatNumber(totalSubscriptions) }} total
</span>
<button class="btn btn-sm btn-outline-primary" (click)="refresh()" [disabled]="loading">
<i class="lucideRefreshCw" [class.spin]="loading"></i>
</button>
</div>
</div>
</div>
<div class="card-body">
<!-- État de chargement -->
<div *ngIf="loading" class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
<p class="mt-2 text-muted">Chargement des données...</p>
</div>
<!-- Contenu principal -->
<div *ngIf="!loading && !error">
<!-- Graphique circulaire -->
<div class="text-center mb-4" style="height: 200px;">
<app-chartjs [getOptions]="subscriptionChart" [height]="200" />
</div>
<!-- Statistiques détaillées -->
<div class="row">
<div class="col-6 mb-3">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="avatar-xs">
<div class="avatar-title bg-success-subtle rounded">
<i class="lucideCheckCircle text-success fs-12"></i>
</div>
</div>
</div>
<div class="flex-grow-1 ms-2">
<h6 class="mb-0">Actifs</h6>
<div class="d-flex justify-content-between">
<span class="text-muted">{{ formatNumber(activeSubscriptions) }}</span>
<small class="text-success" *ngIf="growthRate > 0">
<i class="lucideTrendingUp me-1"></i>
+{{ growthRate | number:'1.1-1' }}%
</small>
<small class="text-danger" *ngIf="growthRate < 0">
<i class="lucideTrendingDown me-1"></i>
{{ growthRate | number:'1.1-1' }}%
</small>
</div>
</div>
</div>
</div>
<div class="col-6 mb-3">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="avatar-xs">
<div class="avatar-title bg-warning-subtle rounded">
<i class="lucideClock text-warning fs-12"></i>
</div>
</div>
</div>
<div class="flex-grow-1 ms-2">
<h6 class="mb-0">En période d'essai</h6>
<div class="d-flex justify-content-between">
<span class="text-muted">{{ formatNumber(trialSubscriptions) }}</span>
<small class="text-warning">{{ trialPercentage | number:'1.1-1' }}%</small>
</div>
</div>
</div>
</div>
<div class="col-6">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="avatar-xs">
<div class="avatar-title bg-danger-subtle rounded">
<i class="lucideXCircle text-danger fs-12"></i>
</div>
</div>
</div>
<div class="flex-grow-1 ms-2">
<h6 class="mb-0">Annulés</h6>
<div class="d-flex justify-content-between">
<span class="text-muted">{{ formatNumber(cancelledSubscriptions) }}</span>
<small class="text-danger">{{ cancelledPercentage | number:'1.1-1' }}%</small>
</div>
</div>
</div>
</div>
<div class="col-6">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="avatar-xs">
<div class="avatar-title bg-info-subtle rounded">
<i class="lucideCalendar text-info fs-12"></i>
</div>
</div>
</div>
<div class="flex-grow-1 ms-2">
<h6 class="mb-0">Expirés</h6>
<div class="d-flex justify-content-between">
<span class="text-muted">{{ formatNumber(expiredSubscriptions) }}</span>
<small class="text-info">{{ expiredPercentage | number:'1.1-1' }}%</small>
</div>
</div>
</div>
</div>
</div>
<!-- Tendances mensuelles -->
<div class="mt-4 pt-3 border-top">
<h6 class="mb-3">Nouveaux abonnements (30 derniers jours)</h6>
<div class="d-flex align-items-center">
<div class="flex-grow-1">
<div class="progress" style="height: 8px;">
<div class="progress-bar bg-success" role="progressbar"
[style.width]="newSubscriptionsPercentage + '%'"></div>
</div>
</div>
<div class="flex-shrink-0 ms-3">
<span class="fw-bold">{{ formatNumber(newSubscriptions) }}</span>
<small class="text-muted ms-1">nouveaux</small>
</div>
</div>
<div class="text-end mt-1">
<small class="text-muted">Dernière mise à jour: {{ lastUpdated | date:'HH:mm' }}</small>
</div>
</div>
</div>
</div>
</div>
`,
styles: [`
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`]
})
export class SubscriptionOverview implements OnInit {
loading = false;
error: string | null = null;
lastUpdated = new Date();
// Données d'abonnements
totalSubscriptions = 0;
activeSubscriptions = 0;
trialSubscriptions = 0;
cancelledSubscriptions = 0;
expiredSubscriptions = 0;
newSubscriptions = 0;
// Pourcentages
trialPercentage = 0;
cancelledPercentage = 0;
expiredPercentage = 0;
newSubscriptionsPercentage = 0;
growthRate = 0;
constructor(private reportingService: DcbReportingService) {}
ngOnInit() {
this.loadSubscriptionData();
}
loadSubscriptionData() {
this.loading = true;
this.error = null;
// Récupérer les 30 derniers jours
const endDate = new Date();
const startDate = new Date();
startDate.setDate(startDate.getDate() - 30);
const startDateStr = this.formatDate(startDate);
const endDateStr = this.formatDate(endDate);
console.log('SubscriptionOverview - Loading data for period:', startDateStr, 'to', endDateStr);
this.reportingService.getDailySubscriptions(startDateStr, endDateStr)
.pipe(
catchError(err => {
console.error('SubscriptionOverview - API error:', err);
this.error = 'Impossible de charger les données des abonnements';
return of([]); // Retourner un tableau vide
}),
finalize(() => {
this.loading = false;
this.lastUpdated = new Date();
})
)
.subscribe({
next: (subscriptions: SubscriptionItem[]) => {
console.log('SubscriptionOverview - Received data:', subscriptions);
this.processSubscriptionData(subscriptions);
}
});
}
processSubscriptionData(subscriptions: SubscriptionItem[]) {
if (!subscriptions || subscriptions.length === 0) {
console.warn('SubscriptionOverview - No subscription data');
return; // Afficher rien
}
// Utiliser la dernière période disponible
const latestData = subscriptions[subscriptions.length - 1];
// Note: Votre API retourne activeCount et cancelledCount, pas trial/expired/new
this.activeSubscriptions = latestData.activeCount || 0;
this.cancelledSubscriptions = latestData.cancelledCount || 0;
// Pour les champs manquants, mettre à 0 ou estimer selon votre logique métier
this.trialSubscriptions = 0;
this.expiredSubscriptions = 0;
this.newSubscriptions = 0;
this.totalSubscriptions = latestData.count || 0;
}
subscriptionChart = (): ChartConfiguration => ({
type: 'doughnut',
data: {
labels: ['Actifs', 'En essai', 'Annulés', 'Expirés'],
datasets: [{
data: [
this.activeSubscriptions,
this.trialSubscriptions,
this.cancelledSubscriptions,
this.expiredSubscriptions
],
backgroundColor: [
getColor('chart-success'),
getColor('chart-warning'),
getColor('chart-danger'),
getColor('chart-info')
],
borderWidth: 2,
borderColor: '#fff'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
},
tooltip: {
callbacks: {
label: (context) => {
const label = context.label || '';
const value = context.raw as number;
const total = this.totalSubscriptions;
const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : '0.0';
return `${label}: ${this.formatNumber(value)} (${percentage}%)`;
}
}
}
}
}
});
formatNumber(value: number): string {
if (value >= 1000000) {
return `${(value / 1000000).toFixed(1)}M`;
} else if (value >= 1000) {
return `${(value / 1000).toFixed(1)}K`;
}
return value.toLocaleString('fr-FR');
}
refresh() {
this.reportingService.clearCache?.();
this.loadSubscriptionData();
}
private formatDate(date: Date): string {
return date.toISOString().split('T')[0];
}
}

View File

@ -0,0 +1,281 @@
import { Component, OnInit } from '@angular/core';
import { Chartjs } from '@app/components/chartjs';
import { ChartConfiguration } from 'chart.js';
import { getColor } from '@/app/utils/color-utils';
import { DcbReportingService, TransactionItem } from '../services/dcb-reporting.service';
import { CommonModule } from '@angular/common';
import { catchError, finalize } from 'rxjs/operators';
@Component({
selector: 'app-success-rate-chart',
imports: [Chartjs, CommonModule],
template: `
<div class="card">
<div class="card-header border-dashed">
<h4 class="card-title mb-0">Taux de Succès des Transactions</h4>
</div>
<div class="card-body">
<!-- État de chargement -->
<div *ngIf="loading" class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
</div>
<!-- Contenu principal -->
<div *ngIf="!loading">
<div class="d-flex align-items-center justify-content-between mb-4">
<div>
<h2 class="mb-0">{{ overallSuccessRate | number:'1.1-1' }}%</h2>
<span class="text-muted">Taux global de succès</span>
</div>
<div class="avatar-sm">
<div class="avatar-title bg-success-subtle rounded">
<i class="lucideCheckCircle text-success fs-24"></i>
</div>
</div>
</div>
<app-chartjs [getOptions]="successRateChart" [height]="250" />
<!-- Statistiques détaillées -->
<div class="row mt-4">
<div class="col-6">
<div class="d-flex align-items-center mb-2">
<div class="flex-shrink-0">
<div class="avatar-xs">
<div class="avatar-title bg-success-subtle rounded">
<i class="lucideCheck text-success fs-12"></i>
</div>
</div>
</div>
<div class="flex-grow-1 ms-2">
<h6 class="mb-0">Transactions réussies</h6>
<p class="text-muted mb-0">{{ formatNumber(successfulTransactions) }}</p>
</div>
</div>
</div>
<div class="col-6">
<div class="d-flex align-items-center mb-2">
<div class="flex-shrink-0">
<div class="avatar-xs">
<div class="avatar-title bg-danger-subtle rounded">
<i class="lucideX text-danger fs-12"></i>
</div>
</div>
</div>
<div class="flex-grow-1 ms-2">
<h6 class="mb-0">Transactions échouées</h6>
<p class="text-muted mb-0">{{ formatNumber(failedTransactions) }}</p>
</div>
</div>
</div>
<div class="col-6">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="avatar-xs">
<div class="avatar-title bg-warning-subtle rounded">
<i class="lucideClock text-warning fs-12"></i>
</div>
</div>
</div>
<div class="flex-grow-1 ms-2">
<h6 class="mb-0">En attente</h6>
<p class="text-muted mb-0">{{ formatNumber(pendingTransactions) }}</p>
</div>
</div>
</div>
<div class="col-6">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="avatar-xs">
<div class="avatar-title bg-info-subtle rounded">
<i class="lucideActivity text-info fs-12"></i>
</div>
</div>
</div>
<div class="flex-grow-1 ms-2">
<h6 class="mb-0">Total transactions</h6>
<p class="text-muted mb-0">{{ formatNumber(totalTransactions) }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`,
})
export class SuccessRateChart implements OnInit {
loading = false;
error: string | null = null;
// Données du graphique
chartLabels: string[] = [];
successRates: number[] = [];
// Statistiques
overallSuccessRate = 0;
successfulTransactions = 0;
failedTransactions = 0;
pendingTransactions = 0;
totalTransactions = 0;
constructor(private reportingService: DcbReportingService) {}
ngOnInit() {
this.loadChartData();
}
loadChartData() {
this.loading = true;
this.error = null;
// Récupérer les 30 derniers jours
const endDate = new Date();
const startDate = new Date();
startDate.setDate(startDate.getDate() - 30);
const startDateStr = this.formatDate(startDate);
const endDateStr = this.formatDate(endDate);
this.reportingService.getDailyTransactions(startDateStr, endDateStr)
.pipe(
catchError(err => {
console.error('Erreur API:', err);
this.error = 'Données non disponibles';
return [];
}),
finalize(() => {
this.loading = false;
})
)
.subscribe({
next: (transactions: TransactionItem[]) => {
this.processTransactionData(transactions);
}
});
}
processTransactionData(transactions: any[]) {
// Calculer les statistiques globales
let totalSuccess = 0;
let totalFailed = 0;
let totalPending = 0;
let totalAll = 0;
transactions.forEach(transaction => {
totalSuccess += transaction.successful || 0;
totalFailed += transaction.failed || 0;
totalPending += transaction.pending || 0;
totalAll += transaction.total || 0;
});
this.successfulTransactions = totalSuccess;
this.failedTransactions = totalFailed;
this.pendingTransactions = totalPending;
this.totalTransactions = totalAll;
// Calculer le taux de succès global
if (totalAll > 0) {
this.overallSuccessRate = (totalSuccess / totalAll) * 100;
}
// Préparer les données pour le graphique (dernières 7 jours)
const last7Days = transactions.slice(-7);
this.chartLabels = last7Days.map(t => {
const date = new Date(t.date);
return date.toLocaleDateString('fr-FR', { weekday: 'short', day: 'numeric' });
});
this.successRates = last7Days.map(t => {
const total = t.total || 0;
const success = t.successful || 0;
return total > 0 ? (success / total) * 100 : 0;
});
}
successRateChart = (): ChartConfiguration => ({
type: 'line',
data: {
labels: this.chartLabels,
datasets: [
{
label: 'Taux de succès (%)',
data: this.successRates,
borderColor: getColor('chart-success'),
backgroundColor: getColor('chart-success') + '20',
borderWidth: 2,
fill: true,
tension: 0.4,
pointBackgroundColor: getColor('chart-success'),
pointBorderColor: '#fff',
pointBorderWidth: 2,
pointRadius: 4,
pointHoverRadius: 6
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
},
tooltip: {
mode: 'index',
intersect: false,
callbacks: {
label: (context) => {
return `Taux de succès: ${context.raw}%`;
}
}
}
},
scales: {
x: {
grid: {
display: false
},
ticks: {
color: '#6c757d'
}
},
y: {
beginAtZero: false,
min: 80,
max: 100,
grid: {
tickBorderDash: [5, 5]
},
ticks: {
callback: (value) => `${value}%`,
color: '#6c757d'
},
title: {
display: true,
text: 'Taux de succès (%)'
}
}
}
}
});
formatNumber(value: number): string {
if (value >= 1000000) {
return `${(value / 1000000).toFixed(1)}M`;
} else if (value >= 1000) {
return `${(value / 1000).toFixed(0)}K`;
}
return value.toString();
}
private formatDate(date: Date): string {
return date.toISOString().split('T')[0];
}
}

View File

@ -1,46 +0,0 @@
import { Component } from '@angular/core';
import { NgIconComponent } from '@ng-icons/core';
@Component({
selector: 'app-system-health',
imports: [NgIconComponent],
template: `
<div class="card card-h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
<h5 class="text-uppercase mb-3">Santé du Système</h5>
<h3 class="mb-0 fw-normal text-success">100%</h3>
<p class="text-muted mb-2">Tous les services opérationnels</p>
</div>
<div>
<ng-icon name="lucideActivity" class="text-success fs-24 svg-sw-10" />
</div>
</div>
<div class="mt-4 space-y-2">
<div class="d-flex justify-content-between align-items-center">
<span class="text-muted">API Gateway</span>
<span class="badge bg-success">Online</span>
</div>
<div class="d-flex justify-content-between align-items-center">
<span class="text-muted">Base de données</span>
<span class="badge bg-success">Online</span>
</div>
<div class="d-flex justify-content-between align-items-center">
<span class="text-muted">SMS Gateway</span>
<span class="badge bg-success">Online</span>
</div>
<div class="d-flex justify-content-between align-items-center">
<span class="text-muted">Webhooks</span>
<span class="badge bg-success">Online</span>
</div>
</div>
</div>
<div class="card-footer text-muted text-center">
Dernière vérification: il y a 2 min
</div>
</div>
`,
})
export class SystemHealth {}

View File

@ -7,45 +7,7 @@
<div class="row g-4 mb-4">
<div class="col-12">
<app-payment-stats />
</div>
</div>
<!-- KPI Cards -->
<div class="row row-cols-xxl-4 row-cols-md-2 row-cols-1 g-3 mb-4">
<div class="col">
<app-active-subscriptions />
</div>
<div class="col">
<app-operator-performance />
</div>
<div class="col-xxl-4">
<app-alert-widget />
</div>
<div class="col">
<app-system-health />
</div>
</div>
<!-- Charts and Main Content -->
<div class="row g-4">
<div class="col-xxl-8">
<app-revenue-chart />
</div>
</div>
<!-- Bottom Section -->
<div class="row g-4 mt-1">
<div class="col-xxl-6">
<app-recent-transactions />
</div>
<div class="col-xxl-6">
<!-- Additional components can be added here -->
<app-dashboard-report />
</div>
</div>
</div>

View File

@ -1,24 +1,14 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { PageTitle } from '@app/components/page-title/page-title';
import { PaymentStats } from './components/payment-stats';
import { ActiveSubscriptions } from './components/active-subscriptions';
import { RevenueChart } from './components/revenue-chart';
import { OperatorPerformance } from './components/operator-performance';
import { RecentTransactions } from './components/recent-transactions';
import { SystemHealth } from './components/system-health';
import { AlertWidget } from './components/alert-widget';
import { DashboardReport } from './components/dcb-dashboard-report';
@Component({
selector: 'app-dcb-dashboard',
imports: [
CommonModule,
PageTitle,
PaymentStats,
ActiveSubscriptions,
RevenueChart,
OperatorPerformance,
RecentTransactions,
SystemHealth,
AlertWidget,
DashboardReport,
],
templateUrl: './dcb-dashboard.html',
})

View File

@ -48,3 +48,58 @@ export interface DcbDashboardData {
alerts: Alert[];
lastUpdated: Date;
}
// Interface pour la réponse brute de l'API
export interface ApiTransactionItem {
period: string;
totalAmount: number;
totalTax: number;
count: number;
successCount: number;
failedCount: number;
pendingCount: number;
merchantPartnerId?: number;
}
export interface ApiTransactionResponse {
type: string;
period: string;
startDate: string;
endDate: string;
merchantPartnerId?: number;
totalAmount: number;
totalCount: number;
items: ApiTransactionItem[];
summary: {
avgAmount: number;
minAmount: number;
maxAmount: number;
};
generatedAt: string;
}
export interface ApiSubscriptionItem {
period: string;
active: number;
trial: number;
cancelled: number;
expired: number;
new: number;
total: number;
merchantPartnerId?: number;
}
export interface ApiSubscriptionResponse {
type: string;
period: string;
startDate: string;
endDate: string;
merchantPartnerId?: number;
totalCount: number;
items: ApiSubscriptionItem[];
summary: {
avgActive: number;
avgNew: number;
};
generatedAt: string;
}

View File

@ -1,22 +1,49 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { map, catchError } from 'rxjs/operators';
import { DcbDashboardData, KpiCardModel, PaymentChartData, SubscriptionStatsModel, Alert } from '../models/dcb-dashboard.models';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, of, forkJoin } from 'rxjs';
import { map, catchError, shareReplay } from 'rxjs/operators';
import {
DcbDashboardData,
KpiCardModel,
PaymentChartData,
SubscriptionStatsModel,
Alert
} from '../models/dcb-dashboard.models';
import { environment } from '@environments/environment';
export interface TransactionReport {
date: string;
successful: number;
failed: number;
pending: number;
revenue: number;
total: number;
}
export interface SubscriptionReport {
date: string;
active: number;
trial: number;
cancelled: number;
expired: number;
new: number;
total: number;
}
@Injectable({ providedIn: 'root' })
export class DcbDashboardService {
private http = inject(HttpClient);
private apiUrl = `${environment.iamApiUrl}/dcb-dashboard`;
private reportingApiUrl = `${environment.reportingApiUrl}/reporting`;
// Données mockées pour le développement
private cache = new Map<string, Observable<any>>();
// Données mockées pour fallback
private mockData: DcbDashboardData = {
kpis: [
{
title: 'Revenue Total',
value: 125430,
change: 12.5,
title: 'Revenue Mensuel',
value: 0,
change: 0,
changeType: 'positive',
icon: 'lucideTrendingUp',
color: 'success',
@ -24,110 +51,409 @@ export class DcbDashboardService {
currency: 'XOF'
},
{
title: 'Paiements Today',
value: 342,
change: -2.3,
changeType: 'negative',
title: 'Transactions Journalières',
value: 0,
change: 0,
changeType: 'positive',
icon: 'lucideCreditCard',
color: 'primary',
format: 'number'
},
{
title: 'Taux de Succès',
value: 98.2,
change: 1.2,
value: 0,
change: 0,
changeType: 'positive',
icon: 'lucideCheckCircle',
color: 'info',
format: 'percentage'
},
{
title: 'Abonnements Actifs',
value: 12543,
change: 8.7,
title: 'Nouveaux Abonnements',
value: 0,
change: 0,
changeType: 'positive',
icon: 'lucideUsers',
color: 'warning',
format: 'number'
}
],
paymentChart: [
{ period: 'Jan', successful: 12000, failed: 300, pending: 500, revenue: 4500000 },
{ period: 'Fév', successful: 15000, failed: 250, pending: 400, revenue: 5200000 },
{ period: 'Mar', successful: 18000, failed: 200, pending: 300, revenue: 6100000 },
{ period: 'Avr', successful: 22000, failed: 150, pending: 250, revenue: 7500000 },
{ period: 'Mai', successful: 25000, failed: 100, pending: 200, revenue: 8900000 },
{ period: 'Jun', successful: 30000, failed: 80, pending: 150, revenue: 10500000 }
],
paymentChart: [],
subscriptionStats: {
total: 15600,
active: 12543,
trial: 1850,
cancelled: 857,
expired: 350,
growthRate: 8.7
total: 0,
active: 0,
trial: 0,
cancelled: 0,
expired: 0,
growthRate: 0
},
alerts: [
{
id: '1',
type: 'warning',
title: 'Taux d\'échec élevé Orange CI',
message: 'Le taux d\'échec des paiements Orange Côte d\'Ivoire a augmenté de 15%',
timestamp: new Date(Date.now() - 30 * 60 * 1000), // 30 minutes ago
acknowledged: false,
priority: 'medium',
action: {
label: 'Voir les détails',
route: '/payments'
}
},
{
id: '2',
type: 'info',
title: 'Maintenance planifiée',
message: 'Maintenance système prévue ce soir de 22h à 00h',
timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000), // 2 hours ago
acknowledged: false,
priority: 'low'
},
{
id: '3',
type: 'success',
title: 'Nouveau partenaire activé',
message: 'Le partenaire "MTN Senegal" a été configuré avec succès',
timestamp: new Date(Date.now() - 4 * 60 * 60 * 1000), // 4 hours ago
acknowledged: true,
priority: 'low'
}
],
alerts: [],
lastUpdated: new Date()
};
/**
* Récupère toutes les données du dashboard
*/
getDcbDashboardData(): Observable<DcbDashboardData> {
// En production, utiliser l'API réelle
if (environment.production) {
return this.http.get<DcbDashboardData>(this.apiUrl).pipe(
catchError(() => of(this.mockData)) // Fallback sur les données mockées en cas d'erreur
const cacheKey = 'dashboard-full-data';
if (!this.cache.has(cacheKey)) {
const dashboardData$ = this.fetchAllDashboardData().pipe(
shareReplay({ bufferSize: 1, refCount: true }),
catchError(error => {
console.error('Erreur dashboard:', error);
return of(this.mockData);
})
);
this.cache.set(cacheKey, dashboardData$);
}
// En développement, retourner les données mockées
return of(this.mockData);
return this.cache.get(cacheKey)!;
}
acknowledgeAlert(alertId: string): Observable<{ success: boolean }> {
// Simuler l'acknowledgement
const alert = this.mockData.alerts.find(a => a.id === alertId);
if (alert) {
alert.acknowledged = true;
}
/**
* Récupère toutes les données nécessaires
*/
private fetchAllDashboardData(): Observable<DcbDashboardData> {
/*if (!environment.production) {
return of(this.mockData);
}*/
return of({ success: true });
// Calcul des dates (30 derniers jours)
const endDate = new Date();
const startDate = new Date();
startDate.setDate(startDate.getDate() - 30);
const startDateStr = this.formatDate(startDate);
const endDateStr = this.formatDate(endDate);
// Récupération des transactions mensuelles pour les KPIs
return this.getMonthlyTransactions(startDateStr, endDateStr).pipe(
map(monthlyData => this.transformDashboardData(monthlyData))
);
}
/**
* Récupère les transactions journalières (pour le graphique)
*/
getDailyTransactions(
startDate?: string,
endDate?: string,
merchantPartnerId?: number
): Observable<TransactionReport[]> {
let params = new HttpParams();
if (startDate) params = params.set('startDate', startDate);
if (endDate) params = params.set('endDate', endDate);
if (merchantPartnerId) params = params.set('merchantPartnerId', merchantPartnerId.toString());
const url = `${this.reportingApiUrl}/transactions/daily`;
return this.http.get<any>(url, { params }).pipe(
map(response => response.data || []),
catchError(() => of([]))
);
}
/**
* Récupère les transactions mensuelles (pour les KPIs)
*/
getMonthlyTransactions(
startDate?: string,
endDate?: string,
merchantPartnerId?: number
): Observable<TransactionReport[]> {
let params = new HttpParams();
if (startDate) params = params.set('startDate', startDate);
if (endDate) params = params.set('endDate', endDate);
if (merchantPartnerId) params = params.set('merchantPartnerId', merchantPartnerId.toString());
const url = `${this.reportingApiUrl}/transactions/monthly`;
return this.http.get<any>(url, { params }).pipe(
map(response => response.data || []),
catchError(() => of([]))
);
}
/**
* Récupère les abonnements journaliers
*/
getDailySubscriptions(
startDate?: string,
endDate?: string,
merchantPartnerId?: number
): Observable<SubscriptionReport[]> {
let params = new HttpParams();
if (startDate) params = params.set('startDate', startDate);
if (endDate) params = params.set('endDate', endDate);
if (merchantPartnerId) params = params.set('merchantPartnerId', merchantPartnerId.toString());
const url = `${this.reportingApiUrl}/subscriptions/daily`;
return this.http.get<any>(url, { params }).pipe(
map(response => response.data || []),
catchError(() => of([]))
);
}
/**
* Récupère les abonnements mensuels
*/
getMonthlySubscriptions(
startDate?: string,
endDate?: string,
merchantPartnerId?: number
): Observable<SubscriptionReport[]> {
let params = new HttpParams();
if (startDate) params = params.set('startDate', startDate);
if (endDate) params = params.set('endDate', endDate);
if (merchantPartnerId) params = params.set('merchantPartnerId', merchantPartnerId.toString());
const url = `${this.reportingApiUrl}/subscriptions/monthly`;
return this.http.get<any>(url, { params }).pipe(
map(response => response.data || []),
catchError(() => of([]))
);
}
/**
* Lance une synchronisation manuelle
*/
triggerManualSync(): Observable<{ success: boolean; message?: string }> {
const url = `${this.reportingApiUrl}/sync/full`;
return this.http.post<{ success: boolean; message?: string }>(url, {}).pipe(
catchError(error => of({
success: false,
message: error.message || 'Erreur lors de la synchronisation'
}))
);
}
/**
* Transforme les données d'API en format dashboard
*/
private transformDashboardData(monthlyTransactions: TransactionReport[]): DcbDashboardData {
// Calcul des KPIs à partir des transactions mensuelles
const currentMonth = this.getCurrentMonthData(monthlyTransactions);
const previousMonth = this.getPreviousMonthData(monthlyTransactions);
const totalRevenue = this.calculateTotalRevenue(monthlyTransactions);
const revenueChange = this.calculateRevenueChange(currentMonth, previousMonth);
const totalTransactions = this.calculateTotalTransactions(monthlyTransactions);
const transactionChange = this.calculateTransactionChange(currentMonth, previousMonth);
const successRate = this.calculateSuccessRate(monthlyTransactions);
const successRateChange = this.calculateSuccessRateChange(currentMonth, previousMonth);
// Récupération des abonnements pour le 4ème KPI
const today = this.formatDate(new Date());
const lastMonthDate = this.formatDate(new Date(Date.now() - 30 * 24 * 60 * 60 * 1000));
return {
kpis: [
{
title: 'Revenue Mensuel',
value: totalRevenue,
change: revenueChange,
changeType: revenueChange >= 0 ? 'positive' : 'negative',
icon: 'lucideTrendingUp',
color: 'success',
format: 'currency',
currency: 'XOF'
},
{
title: 'Transactions Total',
value: totalTransactions,
change: transactionChange,
changeType: transactionChange >= 0 ? 'positive' : 'negative',
icon: 'lucideCreditCard',
color: 'primary',
format: 'number'
},
{
title: 'Taux de Succès',
value: successRate,
change: successRateChange,
changeType: successRateChange >= 0 ? 'positive' : 'negative',
icon: 'lucideCheckCircle',
color: 'info',
format: 'percentage'
},
{
title: 'Nouveaux Abonnements',
value: 0, // À calculer via getDailySubscriptions
change: 0,
changeType: 'positive',
icon: 'lucideUsers',
color: 'warning',
format: 'number'
}
],
paymentChart: this.preparePaymentChartData(monthlyTransactions),
subscriptionStats: {
total: 0, // À calculer via getMonthlySubscriptions
active: 0,
trial: 0,
cancelled: 0,
expired: 0,
growthRate: 0
},
alerts: [],
lastUpdated: new Date()
};
}
/**
* Calcule le revenue total des dernières transactions
*/
private calculateTotalRevenue(transactions: TransactionReport[]): number {
if (!transactions || transactions.length === 0) return 0;
return transactions.reduce((sum, transaction) => sum + (transaction.revenue || 0), 0);
}
/**
* Calcule le changement de revenue
*/
private calculateRevenueChange(currentMonth: TransactionReport[], previousMonth: TransactionReport[]): number {
const currentRevenue = this.calculateTotalRevenue(currentMonth);
const previousRevenue = this.calculateTotalRevenue(previousMonth);
if (previousRevenue === 0) return currentRevenue > 0 ? 100 : 0;
return ((currentRevenue - previousRevenue) / previousRevenue) * 100;
}
/**
* Calcule le total des transactions
*/
private calculateTotalTransactions(transactions: TransactionReport[]): number {
if (!transactions || transactions.length === 0) return 0;
return transactions.reduce((sum, transaction) => sum + (transaction.total || 0), 0);
}
/**
* Calcule le changement de transactions
*/
private calculateTransactionChange(currentMonth: TransactionReport[], previousMonth: TransactionReport[]): number {
const currentTransactions = this.calculateTotalTransactions(currentMonth);
const previousTransactions = this.calculateTotalTransactions(previousMonth);
if (previousTransactions === 0) return currentTransactions > 0 ? 100 : 0;
return ((currentTransactions - previousTransactions) / previousTransactions) * 100;
}
/**
* Calcule le taux de succès moyen
*/
private calculateSuccessRate(transactions: TransactionReport[]): number {
if (!transactions || transactions.length === 0) return 0;
let totalSuccess = 0;
let totalTransactions = 0;
transactions.forEach(transaction => {
totalSuccess += transaction.successful || 0;
totalTransactions += transaction.total || 0;
});
if (totalTransactions === 0) return 0;
return (totalSuccess / totalTransactions) * 100;
}
/**
* Calcule le changement du taux de succès
*/
private calculateSuccessRateChange(currentMonth: TransactionReport[], previousMonth: TransactionReport[]): number {
const currentRate = this.calculateSuccessRate(currentMonth);
const previousRate = this.calculateSuccessRate(previousMonth);
if (previousRate === 0) return currentRate > 0 ? 100 : 0;
return currentRate - previousRate;
}
/**
* Prépare les données pour le graphique
*/
private preparePaymentChartData(transactions: TransactionReport[]): PaymentChartData[] {
if (!transactions || transactions.length === 0) return [];
// Limiter aux 6 derniers mois pour le graphique
const recentTransactions = transactions.slice(-6);
return recentTransactions.map(transaction => ({
period: this.formatMonthName(transaction.date),
successful: transaction.successful || 0,
failed: transaction.failed || 0,
pending: transaction.pending || 0,
revenue: transaction.revenue || 0
}));
}
/**
* Récupère les données du mois courant
*/
private getCurrentMonthData(transactions: TransactionReport[]): TransactionReport[] {
const currentMonth = new Date().getMonth();
const currentYear = new Date().getFullYear();
return transactions.filter(transaction => {
const transactionDate = new Date(transaction.date);
return transactionDate.getMonth() === currentMonth &&
transactionDate.getFullYear() === currentYear;
});
}
/**
* Récupère les données du mois précédent
*/
private getPreviousMonthData(transactions: TransactionReport[]): TransactionReport[] {
const now = new Date();
const previousMonth = now.getMonth() === 0 ? 11 : now.getMonth() - 1;
const year = now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear();
return transactions.filter(transaction => {
const transactionDate = new Date(transaction.date);
return transactionDate.getMonth() === previousMonth &&
transactionDate.getFullYear() === year;
});
}
/**
* Format une date au format YYYY-MM-DD
*/
private formatDate(date: Date): string {
return date.toISOString().split('T')[0];
}
/**
* Format le nom du mois à partir d'une date
*/
private formatMonthName(dateString: string): string {
const date = new Date(dateString);
const months = ['Jan', 'Fév', 'Mar', 'Avr', 'Mai', 'Jun', 'Jul', 'Aoû', 'Sep', 'Oct', 'Nov', 'Déc'];
return months[date.getMonth()];
}
/**
* Vide le cache
*/
clearCache(): void {
this.cache.clear();
}
/**
* Rafraîchit les données
*/
refreshData(): Observable<DcbDashboardData> {
// Simuler un rafraîchissement des données
this.mockData.lastUpdated = new Date();
return of(this.mockData);
this.clearCache();
return this.getDcbDashboardData();
}
}

View File

@ -0,0 +1,256 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { map, catchError } from 'rxjs/operators';
import { environment } from '@environments/environment';
// Interfaces pour les réponses de l'API
export interface TransactionItem {
period: string;
totalAmount: number;
totalTax: number;
count: number;
successCount: number;
failedCount: number;
pendingCount: number;
merchantPartnerId?: number;
}
export interface TransactionResponse {
type: string;
period: string;
startDate: string;
endDate: string;
merchantPartnerId?: number;
totalAmount: number;
totalCount: number;
items: TransactionItem[];
summary: {
avgAmount: number;
minAmount: number;
maxAmount: number;
};
generatedAt: string;
}
export interface SubscriptionItem {
period: string;
totalAmount: number;
count: number;
activeCount: number;
cancelledCount: number;
}
export interface SubscriptionResponse {
type: string;
period: string;
startDate: string;
endDate: string;
merchantPartnerId?: number;
totalAmount: number;
totalCount: number;
items: SubscriptionItem[];
summary: {
avgAmount: number;
minAmount: number;
maxAmount: number;
};
generatedAt: string;
}
@Injectable({ providedIn: 'root' })
export class DcbReportingService {
private http = inject(HttpClient);
private reportingApiUrl = `${environment.reportingApiUrl}/reporting`;
/**
* Récupère les transactions journalières
*/
getDailyTransactions(
startDate?: string,
endDate?: string,
merchantPartnerId?: number
): Observable<TransactionItem[]> {
let params = new HttpParams();
if (startDate) params = params.set('startDate', startDate);
if (endDate) params = params.set('endDate', endDate);
if (merchantPartnerId) params = params.set('merchantPartnerId', merchantPartnerId.toString());
const url = `${this.reportingApiUrl}/transactions/daily`;
console.log('Fetching daily transactions from:', url, 'with params:', params.toString());
return this.http.get<TransactionResponse>(url, { params }).pipe(
map(response => {
console.log('Daily transactions raw response:', response);
return response?.items || [];
}),
catchError((error) => {
console.error('Error fetching daily transactions:', error);
return of([]);
})
);
}
/**
* Récupère les transactions hebdomadaires
*/
getWeeklyTransactions(
startDate?: string,
endDate?: string,
merchantPartnerId?: number
): Observable<TransactionItem[]> {
let params = new HttpParams();
if (startDate) params = params.set('startDate', startDate);
if (endDate) params = params.set('endDate', endDate);
if (merchantPartnerId) params = params.set('merchantPartnerId', merchantPartnerId.toString());
const url = `${this.reportingApiUrl}/transactions/weekly`;
console.log('Fetching weekly transactions from:', url);
return this.http.get<TransactionResponse>(url, { params }).pipe(
map(response => {
console.log('Weekly transactions raw response:', response);
return response?.items || [];
}),
catchError((error) => {
console.error('Error fetching weekly transactions:', error);
return of([]);
})
);
}
/**
* Récupère les transactions mensuelles
*/
getMonthlyTransactions(
startDate?: string,
endDate?: string,
merchantPartnerId?: number
): Observable<TransactionItem[]> {
let params = new HttpParams();
if (startDate) params = params.set('startDate', startDate);
if (endDate) params = params.set('endDate', endDate);
if (merchantPartnerId) params = params.set('merchantPartnerId', merchantPartnerId.toString());
const url = `${this.reportingApiUrl}/transactions/monthly`;
console.log('Fetching monthly transactions from:', url);
return this.http.get<TransactionResponse>(url, { params }).pipe(
map(response => {
console.log('Monthly transactions raw response:', response);
return response?.items || [];
}),
catchError((error) => {
console.error('Error fetching monthly transactions:', error);
return of([]);
})
);
}
/**
* Récupère les abonnements journaliers
*/
getDailySubscriptions(
startDate?: string,
endDate?: string,
merchantPartnerId?: number
): Observable<SubscriptionItem[]> {
let params = new HttpParams();
if (startDate) params = params.set('startDate', startDate);
if (endDate) params = params.set('endDate', endDate);
if (merchantPartnerId) params = params.set('merchantPartnerId', merchantPartnerId.toString());
const url = `${this.reportingApiUrl}/subscriptions/daily`;
console.log('Fetching daily subscriptions from:', url);
return this.http.get<SubscriptionResponse>(url, { params }).pipe(
map(response => {
console.log('Daily subscriptions raw response:', response);
return response?.items || [];
}),
catchError((error) => {
console.error('Error fetching daily subscriptions:', error);
return of([]);
})
);
}
/**
* Récupère les abonnements hebdomadaires
*/
getWeeklySubscriptions(
startDate?: string,
endDate?: string,
merchantPartnerId?: number
): Observable<SubscriptionItem[]> {
let params = new HttpParams();
if (startDate) params = params.set('startDate', startDate);
if (endDate) params = params.set('endDate', endDate);
if (merchantPartnerId) params = params.set('merchantPartnerId', merchantPartnerId.toString());
const url = `${this.reportingApiUrl}/subscriptions/weekly`;
console.log('Fetching weekly subscriptions from:', url);
return this.http.get<SubscriptionResponse>(url, { params }).pipe(
map(response => {
console.log('Weekly subscriptions raw response:', response);
return response?.items || [];
}),
catchError((error) => {
console.error('Error fetching weekly subscriptions:', error);
return of([]);
})
);
}
/**
* Récupère les abonnements mensuels
*/
getMonthlySubscriptions(
startDate?: string,
endDate?: string,
merchantPartnerId?: number
): Observable<SubscriptionItem[]> {
let params = new HttpParams();
if (startDate) params = params.set('startDate', startDate);
if (endDate) params = params.set('endDate', endDate);
if (merchantPartnerId) params = params.set('merchantPartnerId', merchantPartnerId.toString());
const url = `${this.reportingApiUrl}/subscriptions/monthly`;
console.log('Fetching monthly subscriptions from:', url);
return this.http.get<SubscriptionResponse>(url, { params }).pipe(
map(response => {
console.log('Monthly subscriptions raw response:', response);
return response?.items || [];
}),
catchError((error) => {
console.error('Error fetching monthly subscriptions:', error);
return of([]);
})
);
}
/**
* Format une date au format YYYY-MM-DD
*/
formatDate(date: Date): string {
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* Vide le cache
*/
clearCache(): void {
// Pas de cache dans cette version simplifiée
}
}

View File

@ -19,6 +19,19 @@ export interface MessageResponse {
message: string;
}
// Interface pour la réponse paginée de l'API
export interface ApiSubscriptionsResponse {
data: any[]; // Les données brutes de l'API
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
};
}
// ===== SERVICE SUBSCRIPTIONS =====
@Injectable({ providedIn: 'root' })
@ -44,11 +57,16 @@ export class SubscriptionsService {
});
}
return this.http.get<Subscription[]>(this.subcriptionBaseApiUrl, { params: httpParams }).pipe(
map(subscriptions => ({
subscriptions: subscriptions.map(sub => this.mapToSubscriptionModel(sub)),
statistics: this.calculateSubscriptionStats(subscriptions)
})),
return this.http.get<ApiSubscriptionsResponse>(this.subcriptionBaseApiUrl, { params: httpParams }).pipe(
map(apiResponse => {
// Vérifier si apiResponse.data existe et est un tableau
const subscriptionsData = apiResponse?.data || [];
return {
subscriptions: subscriptionsData.map((sub: any) => this.mapToSubscriptionModel(sub)),
statistics: this.calculateSubscriptionStats(subscriptionsData)
};
}),
catchError(error => {
console.error('Error loading subscriptions:', error);
return throwError(() => error);
@ -73,13 +91,17 @@ export class SubscriptionsService {
}
}
return this.http.get<Subscription[]>(`${this.subcriptionBaseApiUrl}/merchant/${merchantId}`, {
return this.http.get<ApiSubscriptionsResponse>(`${this.subcriptionBaseApiUrl}/merchant/${merchantId}`, {
params: httpParams
}).pipe(
map(subscriptions => ({
subscriptions: subscriptions.map(sub => this.mapToSubscriptionModel(sub)),
statistics: this.calculateSubscriptionStats(subscriptions)
})),
map(apiResponse => {
const subscriptionsData = apiResponse?.data || [];
return {
subscriptions: subscriptionsData.map((sub: any) => this.mapToSubscriptionModel(sub)),
statistics: this.calculateSubscriptionStats(subscriptionsData)
};
}),
catchError(error => {
console.error(`Error loading subscriptions for merchant ${merchantId}:`, error);
return throwError(() => error);
@ -91,8 +113,12 @@ export class SubscriptionsService {
* 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)),
return this.http.get<ApiSubscriptionsResponse>(`${this.subcriptionBaseApiUrl}/${subscriptionId}`).pipe(
map(apiResponse => {
// L'API retourne probablement un objet avec data[0]
const subscriptionData = apiResponse?.data?.[0] || apiResponse;
return this.mapToSubscriptionModel(subscriptionData);
}),
catchError(error => {
console.error(`Error loading subscription ${subscriptionId}:`, error);
return throwError(() => error);
@ -108,14 +134,18 @@ export class SubscriptionsService {
const merchantIdNum = typeof merchantId === 'string' ? parseInt(merchantId, 10) : merchantId;
const subscriptionIdNum = typeof subscriptionId === 'string' ? parseInt(subscriptionId, 10) : subscriptionId;
return this.http.get<SubscriptionPayment[]>(
return this.http.get<ApiSubscriptionsResponse>(
`${this.paymentBaseApiUrl}/merchant/${merchantIdNum}/subscription/${subscriptionIdNum}`
).pipe(
map(payments => ({
merchantId: merchantIdNum.toString(),
subscriptionId: subscriptionIdNum.toString(),
payments: payments.map(payment => this.mapToPaymentModel(payment))
})),
map(apiResponse => {
const paymentsData = apiResponse?.data || [];
return {
merchantId: merchantIdNum.toString(),
subscriptionId: subscriptionIdNum.toString(),
payments: paymentsData.map((payment: any) => this.mapToPaymentModel(payment))
};
}),
catchError(error => {
console.error(`Error loading payments for subscription ${subscriptionId} for merchant ${merchantId}:`, error);
return throwError(() => error);
@ -160,6 +190,16 @@ export class SubscriptionsService {
return this.getSubscriptionsByStatus(SubscriptionStatus.SUSPENDED);
}
/**
* Récupère les abonnements récents (pour le dashboard)
*/
getRecentSubscriptions(limit: number = 10): Observable<SubscriptionsResponse> {
return this.getSubscriptions({
page: 1,
limit,
});
}
// === MÉTHODES UTILITAIRES ===
/**
@ -174,10 +214,26 @@ export class SubscriptionsService {
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);
// Compter par statut
const active = subscriptions.filter(sub =>
sub.status === SubscriptionStatus.ACTIVE || sub.status === 'ACTIVE'
).length;
const suspended = subscriptions.filter(sub =>
sub.status === SubscriptionStatus.SUSPENDED || sub.status === 'SUSPENDED'
).length;
const cancelled = subscriptions.filter(sub =>
sub.status === SubscriptionStatus.CANCELLED || sub.status === 'CANCELLED'
).length;
// Calculer le revenue total
const totalRevenue = subscriptions.reduce((sum, sub) => {
const amount = parseFloat(sub.amount) || 0;
return sum + amount;
}, 0);
const averageAmount = total > 0 ? totalRevenue / total : 0;
return {
@ -197,20 +253,20 @@ export class SubscriptionsService {
return {
id: apiSubscription.id,
externalReference: apiSubscription.externalReference,
periodicity: apiSubscription.periodicity as SubscriptionPeriodicity,
periodicity: (apiSubscription.periodicity as SubscriptionPeriodicity) || SubscriptionPeriodicity.DAILY,
startDate: apiSubscription.startDate,
endDate: apiSubscription.endDate,
amount: apiSubscription.amount,
currency: apiSubscription.currency as Currency,
amount: parseFloat(apiSubscription.amount) || 0,
currency: (apiSubscription.currency as Currency) || Currency.XOF,
token: apiSubscription.token,
status: apiSubscription.status as SubscriptionStatus,
status: (apiSubscription.status as SubscriptionStatus) || SubscriptionStatus.ACTIVE,
nextPaymentDate: apiSubscription.nextPaymentDate,
suspendedAt: apiSubscription.suspendedAt,
merchantPartnerId: apiSubscription.merchantPartnerId,
customerId: apiSubscription.customerId,
planId: apiSubscription.planId,
serviceId: apiSubscription.serviceId,
failureCount: apiSubscription.failureCount,
failureCount: apiSubscription.failureCount || 0,
createdAt: apiSubscription.createdAt,
updatedAt: apiSubscription.updatedAt,
metadata: apiSubscription.metadata || {}
@ -224,9 +280,9 @@ export class SubscriptionsService {
return {
id: apiPayment.id,
subscriptionId: apiPayment.subscriptionId,
amount: apiPayment.amount,
currency: apiPayment.currency as Currency,
status: apiPayment.status as 'PENDING' | 'SUCCESS' | 'FAILED',
amount: parseFloat(apiPayment.amount) || 0,
currency: (apiPayment.currency as Currency) || Currency.XOF,
status: apiPayment.status as 'PENDING' | 'SUCCESS' | 'FAILED' || 'PENDING',
reference: apiPayment.reference,
description: apiPayment.description,
metadata: apiPayment.metadata || { internatRef: '' },

View File

@ -4,4 +4,5 @@ export const environment = {
iamApiUrl: "https://api-user-service.dcb.pixpay.sn/api/v1",
configApiUrl: 'https://api-merchant-config-service.dcb.pixpay.sn/api/v1',
apiCoreUrl: 'https://api-core-service.dcb.pixpay.sn/api/v1',
reportingApiUrl: 'https://api-reporting-service.dcb.pixpay.sn/api/v1/',
};

View File

@ -4,4 +4,5 @@ export const environment = {
iamApiUrl: "https://api-user-service.dcb.pixpay.sn/api/v1",
configApiUrl: 'https://api-merchant-config-service.dcb.pixpay.sn/api/v1',
apiCoreUrl: 'https://api-core-service.dcb.pixpay.sn/api/v1',
reportingApiUrl: 'https://api-reporting-service.dcb.pixpay.sn/api/v1/',
};

View File

@ -4,4 +4,5 @@ export const environment = {
iamApiUrl: "http://localhost:3000/api/v1",
configApiUrl: 'https://api-merchant-config-service.dcb.pixpay.sn/api/v1',
apiCoreUrl: 'https://api-core-service.dcb.pixpay.sn/api/v1',
reportingApiUrl: 'https://api-reporting-service.dcb.pixpay.sn/api/v1',
}