feat: add DCB User Service API - Authentication system with KEYCLOAK - Modular architecture with services for each feature
This commit is contained in:
parent
10a272fb85
commit
2c095ac311
@ -8,9 +8,9 @@ export enum UserRole {
|
|||||||
// Rôles Hub (sans merchantPartnerId)
|
// Rôles Hub (sans merchantPartnerId)
|
||||||
DCB_ADMIN = 'dcb-admin',
|
DCB_ADMIN = 'dcb-admin',
|
||||||
DCB_SUPPORT = 'dcb-support',
|
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_ADMIN = 'dcb-partner-admin',
|
||||||
DCB_PARTNER_MANAGER = 'dcb-partner-manager',
|
DCB_PARTNER_MANAGER = 'dcb-partner-manager',
|
||||||
DCB_PARTNER_SUPPORT = 'dcb-partner-support'
|
DCB_PARTNER_SUPPORT = 'dcb-partner-support'
|
||||||
@ -41,16 +41,18 @@ export interface UsersStatistics {
|
|||||||
|
|
||||||
// === MODÈLE USER PRINCIPAL ===
|
// === MODÈLE USER PRINCIPAL ===
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string; // UUID Keycloak
|
||||||
username: string;
|
username: string;
|
||||||
email: string;
|
email: string;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
emailVerified: boolean;
|
emailVerified: boolean;
|
||||||
userType: UserType; // HUB ou MERCHANT
|
userType: UserType; // HUB ou MERCHANT_PARTNER
|
||||||
merchantPartnerId?: string;
|
merchantPartnerId?: string; // Pour les users merchant: ID du DCB_PARTNER propriétaire
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
|
// Merchant Config
|
||||||
|
merchantConfigId?: number; // ID INT dans Merchant Config
|
||||||
createdBy?: string;
|
createdBy?: string;
|
||||||
createdByUsername?: string;
|
createdByUsername?: string;
|
||||||
createdTimestamp: number;
|
createdTimestamp: number;
|
||||||
@ -58,6 +60,13 @@ export interface User {
|
|||||||
profileImage?: string | null
|
profileImage?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SyncResult {
|
||||||
|
success: boolean;
|
||||||
|
user?: User;
|
||||||
|
errors?: string[];
|
||||||
|
warnings?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
// === DTOs CRUD ===
|
// === DTOs CRUD ===
|
||||||
export interface CreateUserDto {
|
export interface CreateUserDto {
|
||||||
username: string;
|
username: string;
|
||||||
|
|||||||
116
src/app/core/models/merchant-user-sync.model.ts
Normal file
116
src/app/core/models/merchant-user-sync.model.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 { NgIconComponent } from '@ng-icons/core';
|
||||||
import { NgbProgressbarModule } from '@ng-bootstrap/ng-bootstrap';
|
import { NgbProgressbarModule } from '@ng-bootstrap/ng-bootstrap';
|
||||||
import { CountUpModule } from 'ngx-countup';
|
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({
|
@Component({
|
||||||
selector: 'app-active-subscriptions',
|
selector: 'app-active-subscriptions',
|
||||||
imports: [NgIconComponent, NgbProgressbarModule, CountUpModule],
|
imports: [CommonModule, NgIconComponent, NgbProgressbarModule, CountUpModule],
|
||||||
template: `
|
template: `
|
||||||
<div class="card card-h-100">
|
<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="card-body">
|
||||||
<div class="d-flex justify-content-between align-items-start">
|
<!-- État de chargement -->
|
||||||
<div>
|
<div *ngIf="loading" class="text-center py-3">
|
||||||
<h5 class="text-uppercase mb-3">Abonnements Actifs</h5>
|
<div class="spinner-border text-primary spinner-border-sm" role="status">
|
||||||
<h3 class="mb-0 fw-normal">
|
<span class="visually-hidden">Chargement...</span>
|
||||||
<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" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ngb-progressbar [value]="82" class="progress-lg mb-3" />
|
<!-- Contenu principal -->
|
||||||
|
<div *ngIf="!loading">
|
||||||
<div class="d-flex justify-content-between">
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
<div>
|
<div>
|
||||||
<span class="text-muted">Nouveaux</span>
|
<h5 class="text-uppercase text-muted mb-3">Total actifs</h5>
|
||||||
<h5 class="mb-0">156</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>
|
||||||
<div class="text-end">
|
|
||||||
<span class="text-muted">Renouvellements</span>
|
<!-- Barre de progression pour le taux d'activité -->
|
||||||
<h5 class="mb-0">89%</h5>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-footer text-muted text-center">
|
<div *ngIf="!loading && !error" class="card-footer text-muted text-center py-2">
|
||||||
52 nouveaux abonnements aujourd'hui
|
<small>
|
||||||
|
<ng-icon name="lucideClock" class="me-1 fs-12"></ng-icon>
|
||||||
|
Mise à jour: {{ lastUpdated | date:'HH:mm' }}
|
||||||
|
</small>
|
||||||
</div>
|
</div>
|
||||||
</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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 {}
|
|
||||||
612
src/app/modules/dcb-dashboard/components/dcb-dashboard-report.ts
Normal file
612
src/app/modules/dcb-dashboard/components/dcb-dashboard-report.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 {}
|
|
||||||
@ -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 { NgIconComponent } from '@ng-icons/core';
|
||||||
import { CountUpModule } from 'ngx-countup';
|
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({
|
@Component({
|
||||||
selector: 'app-payment-stats',
|
selector: 'app-payment-stats',
|
||||||
imports: [NgIconComponent, CountUpModule],
|
imports: [CommonModule, NgIconComponent, CountUpModule],
|
||||||
template: `
|
template: `
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header bg-light">
|
<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>
|
||||||
|
|
||||||
<div class="card-body">
|
<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 -->
|
<!-- Journalier -->
|
||||||
<div class="col-md-6 col-xl-3">
|
<div class="col-md-6 col-xl-3">
|
||||||
<div class="card border-primary border-2">
|
<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" />
|
<ng-icon name="lucideCalendar" class="text-primary fs-20 mb-2" />
|
||||||
<h6 class="text-primary mb-2">Journalier</h6>
|
<h6 class="text-primary mb-2">Journalier</h6>
|
||||||
<h4 class="fw-bold mb-1">
|
<h4 class="fw-bold mb-1">
|
||||||
<span [countUp]="342">342</span>
|
<span [countUp]="dailyStats.transactions" [options]="{duration: 1}">
|
||||||
|
{{ dailyStats.transactions }}
|
||||||
|
</span>
|
||||||
</h4>
|
</h4>
|
||||||
<p class="text-muted mb-1">Transactions</p>
|
<p class="text-muted mb-1">Transactions</p>
|
||||||
<div class="d-flex justify-content-between small">
|
<div class="d-flex justify-content-between small">
|
||||||
<span>245K XOF</span>
|
<span>{{ formatCurrency(dailyStats.revenue) }}</span>
|
||||||
<span class="text-success">
|
<span [ngClass]="dailyStats.successRate >= 95 ? 'text-success' : 'text-warning'">
|
||||||
<ng-icon name="lucideTrendingUp" class="me-1 fs-12" />
|
<ng-icon name="lucideCheckCircle" class="me-1 fs-12" />
|
||||||
98.2%
|
{{ dailyStats.successRate | number:'1.1-1' }}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -40,14 +62,16 @@ import { CountUpModule } from 'ngx-countup';
|
|||||||
<ng-icon name="lucideCalendarDays" class="text-info fs-20 mb-2" />
|
<ng-icon name="lucideCalendarDays" class="text-info fs-20 mb-2" />
|
||||||
<h6 class="text-info mb-2">Hebdomadaire</h6>
|
<h6 class="text-info mb-2">Hebdomadaire</h6>
|
||||||
<h4 class="fw-bold mb-1">
|
<h4 class="fw-bold mb-1">
|
||||||
<span [countUp]="2150">2,150</span>
|
<span [countUp]="weeklyStats.transactions" [options]="{duration: 1}">
|
||||||
|
{{ weeklyStats.transactions }}
|
||||||
|
</span>
|
||||||
</h4>
|
</h4>
|
||||||
<p class="text-muted mb-1">Transactions</p>
|
<p class="text-muted mb-1">Transactions</p>
|
||||||
<div class="d-flex justify-content-between small">
|
<div class="d-flex justify-content-between small">
|
||||||
<span>1.58M XOF</span>
|
<span>{{ formatCurrency(weeklyStats.revenue) }}</span>
|
||||||
<span class="text-success">
|
<span [ngClass]="weeklyStats.successRate >= 95 ? 'text-success' : 'text-warning'">
|
||||||
<ng-icon name="lucideTrendingUp" class="me-1 fs-12" />
|
<ng-icon name="lucideCheckCircle" class="me-1 fs-12" />
|
||||||
97.8%
|
{{ weeklyStats.successRate | number:'1.1-1' }}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -61,35 +85,38 @@ import { CountUpModule } from 'ngx-countup';
|
|||||||
<ng-icon name="lucideCalendarRange" class="text-success fs-20 mb-2" />
|
<ng-icon name="lucideCalendarRange" class="text-success fs-20 mb-2" />
|
||||||
<h6 class="text-success mb-2">Mensuel</h6>
|
<h6 class="text-success mb-2">Mensuel</h6>
|
||||||
<h4 class="fw-bold mb-1">
|
<h4 class="fw-bold mb-1">
|
||||||
<span [countUp]="8450">8,450</span>
|
<span [countUp]="monthlyStats.transactions" [options]="{duration: 1}">
|
||||||
|
{{ monthlyStats.transactions }}
|
||||||
|
</span>
|
||||||
</h4>
|
</h4>
|
||||||
<p class="text-muted mb-1">Transactions</p>
|
<p class="text-muted mb-1">Transactions</p>
|
||||||
<div class="d-flex justify-content-between small">
|
<div class="d-flex justify-content-between small">
|
||||||
<span>6.25M XOF</span>
|
<span>{{ formatCurrency(monthlyStats.revenue) }}</span>
|
||||||
<span class="text-warning">
|
<span [ngClass]="monthlyStats.successRate >= 95 ? 'text-success' : 'text-warning'">
|
||||||
<ng-icon name="lucideTrendingDown" class="me-1 fs-12" />
|
<ng-icon name="lucideCheckCircle" class="me-1 fs-12" />
|
||||||
96.5%
|
{{ monthlyStats.successRate | number:'1.1-1' }}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Annuel -->
|
<!-- Taux global -->
|
||||||
<div class="col-md-6 col-xl-3">
|
<div class="col-md-6 col-xl-3">
|
||||||
<div class="card border-warning border-2">
|
<div class="card border-warning border-2">
|
||||||
<div class="card-body text-center p-3">
|
<div class="card-body text-center p-3">
|
||||||
<ng-icon name="lucideCalendar" class="text-warning fs-20 mb-2" />
|
<ng-icon name="lucideActivity" class="text-warning fs-20 mb-2" />
|
||||||
<h6 class="text-warning mb-2">Annuel</h6>
|
<h6 class="text-warning mb-2">Taux global</h6>
|
||||||
<h4 class="fw-bold mb-1">
|
<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>
|
</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">
|
<div class="d-flex justify-content-between small">
|
||||||
<span>9.85M XOF</span>
|
<span>Période: 30j</span>
|
||||||
<span class="text-success">
|
<span [ngClass]="overallSuccessRate >= 95 ? 'text-success' : overallSuccessRate >= 90 ? 'text-warning' : 'text-danger'">
|
||||||
<ng-icon name="lucideTrendingUp" class="me-1 fs-12" />
|
{{ getPerformanceLabel(overallSuccessRate) }}
|
||||||
95.2%
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -98,16 +125,22 @@ import { CountUpModule } from 'ngx-countup';
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Résumé global -->
|
<!-- Résumé global -->
|
||||||
<div class="row mt-3">
|
<div *ngIf="!loading && !error" class="row mt-3">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="alert alert-light mb-0">
|
<div class="alert alert-light mb-0">
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<div>
|
<div>
|
||||||
<strong>Performance globale:</strong>
|
<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>
|
||||||
<div class="text-muted small">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -116,12 +149,264 @@ import { CountUpModule } from 'ngx-countup';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
|
styles: [`
|
||||||
|
.spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
`]
|
||||||
})
|
})
|
||||||
export class PaymentStats {
|
export class PaymentStats implements OnInit, OnDestroy {
|
||||||
getCurrentTime(): string {
|
loading = false;
|
||||||
return new Date().toLocaleTimeString('fr-FR', {
|
error: string | null = null;
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
// 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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -1,190 +1,247 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component, OnInit } from '@angular/core';
|
||||||
import { NgIconComponent } from '@ng-icons/core';
|
import { CommonModule } from '@angular/common';
|
||||||
import { DecimalPipe } from '@angular/common';
|
import { SubscriptionsService } from '../../subscriptions/subscriptions.service';
|
||||||
import { NgbDropdownModule, NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap';
|
import { Subscription} from '@core/models/dcb-bo-hub-subscription.model';
|
||||||
|
import { catchError, finalize } from 'rxjs/operators';
|
||||||
interface Transaction {
|
import { of } from 'rxjs';
|
||||||
id: string;
|
|
||||||
user: string;
|
|
||||||
operator: string;
|
|
||||||
amount: number;
|
|
||||||
status: 'success' | 'pending' | 'failed';
|
|
||||||
date: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-recent-transactions',
|
selector: 'app-recent-transactions',
|
||||||
imports: [
|
imports: [CommonModule],
|
||||||
NgIconComponent,
|
|
||||||
DecimalPipe,
|
|
||||||
NgbPaginationModule,
|
|
||||||
NgbDropdownModule,
|
|
||||||
],
|
|
||||||
template: `
|
template: `
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header justify-content-between align-items-center border-dashed">
|
<div class="card-header border-dashed">
|
||||||
<h4 class="card-title mb-0">Transactions Récentes</h4>
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<a href="javascript:void(0);" class="btn btn-sm btn-primary">
|
<h4 class="card-title mb-0">Abonnements Récents</h4>
|
||||||
<ng-icon name="lucideFile" class="me-1" />
|
<div class="d-flex align-items-center gap-2">
|
||||||
Exporter
|
<button class="btn btn-sm btn-outline-primary" (click)="refresh()" [disabled]="loading">
|
||||||
</a>
|
<i class="lucideRefreshCw" [class.spin]="loading"></i>
|
||||||
</div>
|
</button>
|
||||||
|
<a href="/subscriptions" class="btn btn-sm btn-light">Voir tout</a>
|
||||||
<div class="card-body p-0">
|
</div>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-footer border-0">
|
<div class="card-body p-0">
|
||||||
<div class="align-items-center justify-content-between row text-center text-sm-start">
|
<!-- État de chargement -->
|
||||||
<div class="col-sm">
|
<div *ngIf="loading" class="text-center py-5">
|
||||||
<div class="text-muted">
|
<div class="spinner-border text-primary" role="status">
|
||||||
Affichage de <span class="fw-semibold">1</span> à
|
<span class="visually-hidden">Chargement...</span>
|
||||||
<span class="fw-semibold">8</span> sur
|
|
||||||
<span class="fw-semibold">156</span> Transactions
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm-auto mt-3 mt-sm-0">
|
<p class="mt-2 text-muted">Chargement des abonnements...</p>
|
||||||
<ngb-pagination
|
</div>
|
||||||
[pageSize]="8"
|
|
||||||
[collectionSize]="156"
|
<!-- Message d'erreur -->
|
||||||
class="pagination-sm pagination-boxed mb-0 justify-content-center"
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
|
styles: [``]
|
||||||
})
|
})
|
||||||
export class RecentTransactions {
|
export class RecentTransactions implements OnInit {
|
||||||
transactions: Transaction[] = [
|
loading = false;
|
||||||
{
|
error: string | null = null;
|
||||||
id: 'TX-5001',
|
|
||||||
user: 'Alice Koné',
|
subscriptions: Subscription[] = [];
|
||||||
operator: 'Orange',
|
statistics: any = null;
|
||||||
amount: 2500,
|
|
||||||
status: 'success',
|
constructor(private subscriptionsService: SubscriptionsService) {}
|
||||||
date: '2024-10-28 14:30'
|
|
||||||
},
|
ngOnInit() {
|
||||||
{
|
this.loadRecentSubscriptions();
|
||||||
id: 'TX-5002',
|
}
|
||||||
user: 'David Sarr',
|
|
||||||
operator: 'MTN',
|
loadRecentSubscriptions() {
|
||||||
amount: 1500,
|
this.loading = true;
|
||||||
status: 'success',
|
this.error = null;
|
||||||
date: '2024-10-28 14:25'
|
|
||||||
},
|
this.subscriptionsService.getRecentSubscriptions(10)
|
||||||
{
|
.pipe(
|
||||||
id: 'TX-5003',
|
catchError(err => {
|
||||||
user: 'Sophia Diop',
|
console.error('Erreur lors du chargement des abonnements:', err);
|
||||||
operator: 'Airtel',
|
this.error = 'Impossible de charger les abonnements. Veuillez réessayer.';
|
||||||
amount: 3000,
|
return of({
|
||||||
status: 'pending',
|
subscriptions: [],
|
||||||
date: '2024-10-28 14:20'
|
statistics: {
|
||||||
},
|
total: 0,
|
||||||
{
|
active: 0,
|
||||||
id: 'TX-5004',
|
suspended: 0,
|
||||||
user: 'James Traoré',
|
cancelled: 0,
|
||||||
operator: 'Moov',
|
totalRevenue: 0,
|
||||||
amount: 2000,
|
averageAmount: 0
|
||||||
status: 'success',
|
}
|
||||||
date: '2024-10-28 14:15'
|
});
|
||||||
},
|
}),
|
||||||
{
|
finalize(() => {
|
||||||
id: 'TX-5005',
|
this.loading = false;
|
||||||
user: 'Ava Camara',
|
})
|
||||||
operator: 'Orange',
|
)
|
||||||
amount: 1000,
|
.subscribe({
|
||||||
status: 'failed',
|
next: (response) => {
|
||||||
date: '2024-10-28 14:10'
|
this.subscriptions = response.subscriptions;
|
||||||
},
|
}
|
||||||
{
|
});
|
||||||
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'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
getStatusClass(status: string): string {
|
getStatusClass(status: string): string {
|
||||||
switch (status) {
|
switch(status?.toUpperCase()) {
|
||||||
case 'success': return 'bg-success';
|
case 'ACTIVE':
|
||||||
case 'pending': return 'bg-warning';
|
return 'bg-success-subtle text-success border border-success-subtle';
|
||||||
case 'failed': return 'bg-danger';
|
case 'PENDING':
|
||||||
default: return 'bg-secondary';
|
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 {
|
getStatusIcon(status: string): string {
|
||||||
switch (status) {
|
switch(status?.toUpperCase()) {
|
||||||
case 'success': return 'Succès';
|
case 'ACTIVE':
|
||||||
case 'pending': return 'En cours';
|
return 'lucideCheckCircle';
|
||||||
case 'failed': return 'Échec';
|
case 'PENDING':
|
||||||
default: return 'Inconnu';
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
281
src/app/modules/dcb-dashboard/components/success-rate-chart.ts
Normal file
281
src/app/modules/dcb-dashboard/components/success-rate-chart.ts
Normal 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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 {}
|
|
||||||
@ -7,45 +7,7 @@
|
|||||||
|
|
||||||
<div class="row g-4 mb-4">
|
<div class="row g-4 mb-4">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<app-payment-stats />
|
<app-dashboard-report />
|
||||||
</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 -->
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1,24 +1,14 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
import { PageTitle } from '@app/components/page-title/page-title';
|
import { PageTitle } from '@app/components/page-title/page-title';
|
||||||
import { PaymentStats } from './components/payment-stats';
|
import { DashboardReport } from './components/dcb-dashboard-report';
|
||||||
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';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-dcb-dashboard',
|
selector: 'app-dcb-dashboard',
|
||||||
imports: [
|
imports: [
|
||||||
|
CommonModule,
|
||||||
PageTitle,
|
PageTitle,
|
||||||
PaymentStats,
|
DashboardReport,
|
||||||
ActiveSubscriptions,
|
|
||||||
RevenueChart,
|
|
||||||
OperatorPerformance,
|
|
||||||
RecentTransactions,
|
|
||||||
SystemHealth,
|
|
||||||
AlertWidget,
|
|
||||||
],
|
],
|
||||||
templateUrl: './dcb-dashboard.html',
|
templateUrl: './dcb-dashboard.html',
|
||||||
})
|
})
|
||||||
|
|||||||
@ -48,3 +48,58 @@ export interface DcbDashboardData {
|
|||||||
alerts: Alert[];
|
alerts: Alert[];
|
||||||
lastUpdated: Date;
|
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;
|
||||||
|
}
|
||||||
@ -1,22 +1,49 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
import { Observable, of } from 'rxjs';
|
import { Observable, of, forkJoin } from 'rxjs';
|
||||||
import { map, catchError } from 'rxjs/operators';
|
import { map, catchError, shareReplay } from 'rxjs/operators';
|
||||||
import { DcbDashboardData, KpiCardModel, PaymentChartData, SubscriptionStatsModel, Alert } from '../models/dcb-dashboard.models';
|
import {
|
||||||
|
DcbDashboardData,
|
||||||
|
KpiCardModel,
|
||||||
|
PaymentChartData,
|
||||||
|
SubscriptionStatsModel,
|
||||||
|
Alert
|
||||||
|
} from '../models/dcb-dashboard.models';
|
||||||
import { environment } from '@environments/environment';
|
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' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class DcbDashboardService {
|
export class DcbDashboardService {
|
||||||
private http = inject(HttpClient);
|
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 = {
|
private mockData: DcbDashboardData = {
|
||||||
kpis: [
|
kpis: [
|
||||||
{
|
{
|
||||||
title: 'Revenue Total',
|
title: 'Revenue Mensuel',
|
||||||
value: 125430,
|
value: 0,
|
||||||
change: 12.5,
|
change: 0,
|
||||||
changeType: 'positive',
|
changeType: 'positive',
|
||||||
icon: 'lucideTrendingUp',
|
icon: 'lucideTrendingUp',
|
||||||
color: 'success',
|
color: 'success',
|
||||||
@ -24,110 +51,409 @@ export class DcbDashboardService {
|
|||||||
currency: 'XOF'
|
currency: 'XOF'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Paiements Today',
|
title: 'Transactions Journalières',
|
||||||
value: 342,
|
value: 0,
|
||||||
change: -2.3,
|
change: 0,
|
||||||
changeType: 'negative',
|
changeType: 'positive',
|
||||||
icon: 'lucideCreditCard',
|
icon: 'lucideCreditCard',
|
||||||
color: 'primary',
|
color: 'primary',
|
||||||
format: 'number'
|
format: 'number'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Taux de Succès',
|
title: 'Taux de Succès',
|
||||||
value: 98.2,
|
value: 0,
|
||||||
change: 1.2,
|
change: 0,
|
||||||
changeType: 'positive',
|
changeType: 'positive',
|
||||||
icon: 'lucideCheckCircle',
|
icon: 'lucideCheckCircle',
|
||||||
color: 'info',
|
color: 'info',
|
||||||
format: 'percentage'
|
format: 'percentage'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Abonnements Actifs',
|
title: 'Nouveaux Abonnements',
|
||||||
value: 12543,
|
value: 0,
|
||||||
change: 8.7,
|
change: 0,
|
||||||
changeType: 'positive',
|
changeType: 'positive',
|
||||||
icon: 'lucideUsers',
|
icon: 'lucideUsers',
|
||||||
color: 'warning',
|
color: 'warning',
|
||||||
format: 'number'
|
format: 'number'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
paymentChart: [
|
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 }
|
|
||||||
],
|
|
||||||
subscriptionStats: {
|
subscriptionStats: {
|
||||||
total: 15600,
|
total: 0,
|
||||||
active: 12543,
|
active: 0,
|
||||||
trial: 1850,
|
trial: 0,
|
||||||
cancelled: 857,
|
cancelled: 0,
|
||||||
expired: 350,
|
expired: 0,
|
||||||
growthRate: 8.7
|
growthRate: 0
|
||||||
},
|
},
|
||||||
alerts: [
|
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'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
lastUpdated: new Date()
|
lastUpdated: new Date()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère toutes les données du dashboard
|
||||||
|
*/
|
||||||
getDcbDashboardData(): Observable<DcbDashboardData> {
|
getDcbDashboardData(): Observable<DcbDashboardData> {
|
||||||
// En production, utiliser l'API réelle
|
const cacheKey = 'dashboard-full-data';
|
||||||
if (environment.production) {
|
|
||||||
return this.http.get<DcbDashboardData>(this.apiUrl).pipe(
|
if (!this.cache.has(cacheKey)) {
|
||||||
catchError(() => of(this.mockData)) // Fallback sur les données mockées en cas d'erreur
|
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 this.cache.get(cacheKey)!;
|
||||||
return of(this.mockData);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
acknowledgeAlert(alertId: string): Observable<{ success: boolean }> {
|
/**
|
||||||
// Simuler l'acknowledgement
|
* Récupère toutes les données nécessaires
|
||||||
const alert = this.mockData.alerts.find(a => a.id === alertId);
|
*/
|
||||||
if (alert) {
|
private fetchAllDashboardData(): Observable<DcbDashboardData> {
|
||||||
alert.acknowledged = true;
|
/*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> {
|
refreshData(): Observable<DcbDashboardData> {
|
||||||
// Simuler un rafraîchissement des données
|
this.clearCache();
|
||||||
this.mockData.lastUpdated = new Date();
|
return this.getDcbDashboardData();
|
||||||
return of(this.mockData);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
256
src/app/modules/dcb-dashboard/services/dcb-reporting.service.ts
Normal file
256
src/app/modules/dcb-dashboard/services/dcb-reporting.service.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -19,6 +19,19 @@ export interface MessageResponse {
|
|||||||
message: string;
|
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 =====
|
// ===== SERVICE SUBSCRIPTIONS =====
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
@ -44,11 +57,16 @@ export class SubscriptionsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.http.get<Subscription[]>(this.subcriptionBaseApiUrl, { params: httpParams }).pipe(
|
return this.http.get<ApiSubscriptionsResponse>(this.subcriptionBaseApiUrl, { params: httpParams }).pipe(
|
||||||
map(subscriptions => ({
|
map(apiResponse => {
|
||||||
subscriptions: subscriptions.map(sub => this.mapToSubscriptionModel(sub)),
|
// Vérifier si apiResponse.data existe et est un tableau
|
||||||
statistics: this.calculateSubscriptionStats(subscriptions)
|
const subscriptionsData = apiResponse?.data || [];
|
||||||
})),
|
|
||||||
|
return {
|
||||||
|
subscriptions: subscriptionsData.map((sub: any) => this.mapToSubscriptionModel(sub)),
|
||||||
|
statistics: this.calculateSubscriptionStats(subscriptionsData)
|
||||||
|
};
|
||||||
|
}),
|
||||||
catchError(error => {
|
catchError(error => {
|
||||||
console.error('Error loading subscriptions:', error);
|
console.error('Error loading subscriptions:', error);
|
||||||
return throwError(() => 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
|
params: httpParams
|
||||||
}).pipe(
|
}).pipe(
|
||||||
map(subscriptions => ({
|
map(apiResponse => {
|
||||||
subscriptions: subscriptions.map(sub => this.mapToSubscriptionModel(sub)),
|
const subscriptionsData = apiResponse?.data || [];
|
||||||
statistics: this.calculateSubscriptionStats(subscriptions)
|
|
||||||
})),
|
return {
|
||||||
|
subscriptions: subscriptionsData.map((sub: any) => this.mapToSubscriptionModel(sub)),
|
||||||
|
statistics: this.calculateSubscriptionStats(subscriptionsData)
|
||||||
|
};
|
||||||
|
}),
|
||||||
catchError(error => {
|
catchError(error => {
|
||||||
console.error(`Error loading subscriptions for merchant ${merchantId}:`, error);
|
console.error(`Error loading subscriptions for merchant ${merchantId}:`, error);
|
||||||
return throwError(() => error);
|
return throwError(() => error);
|
||||||
@ -91,8 +113,12 @@ export class SubscriptionsService {
|
|||||||
* Récupère un abonnement par son ID
|
* Récupère un abonnement par son ID
|
||||||
*/
|
*/
|
||||||
getSubscriptionById(subscriptionId: string): Observable<Subscription> {
|
getSubscriptionById(subscriptionId: string): Observable<Subscription> {
|
||||||
return this.http.get<Subscription>(`${this.subcriptionBaseApiUrl}/${subscriptionId}`).pipe(
|
return this.http.get<ApiSubscriptionsResponse>(`${this.subcriptionBaseApiUrl}/${subscriptionId}`).pipe(
|
||||||
map(subscription => this.mapToSubscriptionModel(subscription)),
|
map(apiResponse => {
|
||||||
|
// L'API retourne probablement un objet avec data[0]
|
||||||
|
const subscriptionData = apiResponse?.data?.[0] || apiResponse;
|
||||||
|
return this.mapToSubscriptionModel(subscriptionData);
|
||||||
|
}),
|
||||||
catchError(error => {
|
catchError(error => {
|
||||||
console.error(`Error loading subscription ${subscriptionId}:`, error);
|
console.error(`Error loading subscription ${subscriptionId}:`, error);
|
||||||
return throwError(() => error);
|
return throwError(() => error);
|
||||||
@ -108,14 +134,18 @@ export class SubscriptionsService {
|
|||||||
const merchantIdNum = typeof merchantId === 'string' ? parseInt(merchantId, 10) : merchantId;
|
const merchantIdNum = typeof merchantId === 'string' ? parseInt(merchantId, 10) : merchantId;
|
||||||
const subscriptionIdNum = typeof subscriptionId === 'string' ? parseInt(subscriptionId, 10) : subscriptionId;
|
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}`
|
`${this.paymentBaseApiUrl}/merchant/${merchantIdNum}/subscription/${subscriptionIdNum}`
|
||||||
).pipe(
|
).pipe(
|
||||||
map(payments => ({
|
map(apiResponse => {
|
||||||
merchantId: merchantIdNum.toString(),
|
const paymentsData = apiResponse?.data || [];
|
||||||
subscriptionId: subscriptionIdNum.toString(),
|
|
||||||
payments: payments.map(payment => this.mapToPaymentModel(payment))
|
return {
|
||||||
})),
|
merchantId: merchantIdNum.toString(),
|
||||||
|
subscriptionId: subscriptionIdNum.toString(),
|
||||||
|
payments: paymentsData.map((payment: any) => this.mapToPaymentModel(payment))
|
||||||
|
};
|
||||||
|
}),
|
||||||
catchError(error => {
|
catchError(error => {
|
||||||
console.error(`Error loading payments for subscription ${subscriptionId} for merchant ${merchantId}:`, error);
|
console.error(`Error loading payments for subscription ${subscriptionId} for merchant ${merchantId}:`, error);
|
||||||
return throwError(() => error);
|
return throwError(() => error);
|
||||||
@ -160,6 +190,16 @@ export class SubscriptionsService {
|
|||||||
return this.getSubscriptionsByStatus(SubscriptionStatus.SUSPENDED);
|
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 ===
|
// === MÉTHODES UTILITAIRES ===
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -174,10 +214,26 @@ export class SubscriptionsService {
|
|||||||
averageAmount: number;
|
averageAmount: number;
|
||||||
} {
|
} {
|
||||||
const total = subscriptions.length;
|
const total = subscriptions.length;
|
||||||
const active = subscriptions.filter(sub => sub.status === SubscriptionStatus.ACTIVE).length;
|
|
||||||
const suspended = subscriptions.filter(sub => sub.status === SubscriptionStatus.SUSPENDED).length;
|
// Compter par statut
|
||||||
const cancelled = subscriptions.filter(sub => sub.status === SubscriptionStatus.CANCELLED).length;
|
const active = subscriptions.filter(sub =>
|
||||||
const totalRevenue = subscriptions.reduce((sum, sub) => sum + sub.amount, 0);
|
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;
|
const averageAmount = total > 0 ? totalRevenue / total : 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -197,20 +253,20 @@ export class SubscriptionsService {
|
|||||||
return {
|
return {
|
||||||
id: apiSubscription.id,
|
id: apiSubscription.id,
|
||||||
externalReference: apiSubscription.externalReference,
|
externalReference: apiSubscription.externalReference,
|
||||||
periodicity: apiSubscription.periodicity as SubscriptionPeriodicity,
|
periodicity: (apiSubscription.periodicity as SubscriptionPeriodicity) || SubscriptionPeriodicity.DAILY,
|
||||||
startDate: apiSubscription.startDate,
|
startDate: apiSubscription.startDate,
|
||||||
endDate: apiSubscription.endDate,
|
endDate: apiSubscription.endDate,
|
||||||
amount: apiSubscription.amount,
|
amount: parseFloat(apiSubscription.amount) || 0,
|
||||||
currency: apiSubscription.currency as Currency,
|
currency: (apiSubscription.currency as Currency) || Currency.XOF,
|
||||||
token: apiSubscription.token,
|
token: apiSubscription.token,
|
||||||
status: apiSubscription.status as SubscriptionStatus,
|
status: (apiSubscription.status as SubscriptionStatus) || SubscriptionStatus.ACTIVE,
|
||||||
nextPaymentDate: apiSubscription.nextPaymentDate,
|
nextPaymentDate: apiSubscription.nextPaymentDate,
|
||||||
suspendedAt: apiSubscription.suspendedAt,
|
suspendedAt: apiSubscription.suspendedAt,
|
||||||
merchantPartnerId: apiSubscription.merchantPartnerId,
|
merchantPartnerId: apiSubscription.merchantPartnerId,
|
||||||
customerId: apiSubscription.customerId,
|
customerId: apiSubscription.customerId,
|
||||||
planId: apiSubscription.planId,
|
planId: apiSubscription.planId,
|
||||||
serviceId: apiSubscription.serviceId,
|
serviceId: apiSubscription.serviceId,
|
||||||
failureCount: apiSubscription.failureCount,
|
failureCount: apiSubscription.failureCount || 0,
|
||||||
createdAt: apiSubscription.createdAt,
|
createdAt: apiSubscription.createdAt,
|
||||||
updatedAt: apiSubscription.updatedAt,
|
updatedAt: apiSubscription.updatedAt,
|
||||||
metadata: apiSubscription.metadata || {}
|
metadata: apiSubscription.metadata || {}
|
||||||
@ -224,9 +280,9 @@ export class SubscriptionsService {
|
|||||||
return {
|
return {
|
||||||
id: apiPayment.id,
|
id: apiPayment.id,
|
||||||
subscriptionId: apiPayment.subscriptionId,
|
subscriptionId: apiPayment.subscriptionId,
|
||||||
amount: apiPayment.amount,
|
amount: parseFloat(apiPayment.amount) || 0,
|
||||||
currency: apiPayment.currency as Currency,
|
currency: (apiPayment.currency as Currency) || Currency.XOF,
|
||||||
status: apiPayment.status as 'PENDING' | 'SUCCESS' | 'FAILED',
|
status: apiPayment.status as 'PENDING' | 'SUCCESS' | 'FAILED' || 'PENDING',
|
||||||
reference: apiPayment.reference,
|
reference: apiPayment.reference,
|
||||||
description: apiPayment.description,
|
description: apiPayment.description,
|
||||||
metadata: apiPayment.metadata || { internatRef: '' },
|
metadata: apiPayment.metadata || { internatRef: '' },
|
||||||
|
|||||||
@ -4,4 +4,5 @@ export const environment = {
|
|||||||
iamApiUrl: "https://api-user-service.dcb.pixpay.sn/api/v1",
|
iamApiUrl: "https://api-user-service.dcb.pixpay.sn/api/v1",
|
||||||
configApiUrl: 'https://api-merchant-config-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',
|
apiCoreUrl: 'https://api-core-service.dcb.pixpay.sn/api/v1',
|
||||||
|
reportingApiUrl: 'https://api-reporting-service.dcb.pixpay.sn/api/v1/',
|
||||||
};
|
};
|
||||||
|
|||||||
@ -4,4 +4,5 @@ export const environment = {
|
|||||||
iamApiUrl: "https://api-user-service.dcb.pixpay.sn/api/v1",
|
iamApiUrl: "https://api-user-service.dcb.pixpay.sn/api/v1",
|
||||||
configApiUrl: 'https://api-merchant-config-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',
|
apiCoreUrl: 'https://api-core-service.dcb.pixpay.sn/api/v1',
|
||||||
|
reportingApiUrl: 'https://api-reporting-service.dcb.pixpay.sn/api/v1/',
|
||||||
};
|
};
|
||||||
|
|||||||
@ -4,4 +4,5 @@ export const environment = {
|
|||||||
iamApiUrl: "http://localhost:3000/api/v1",
|
iamApiUrl: "http://localhost:3000/api/v1",
|
||||||
configApiUrl: 'https://api-merchant-config-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',
|
apiCoreUrl: 'https://api-core-service.dcb.pixpay.sn/api/v1',
|
||||||
|
reportingApiUrl: 'https://api-reporting-service.dcb.pixpay.sn/api/v1',
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue
Block a user