dcb-backoffice/src/app/modules/dcb-dashboard/dcb-reporting-dashboard.ts

1659 lines
50 KiB
TypeScript

import {
Component,
OnInit,
OnDestroy,
inject,
ViewChild,
ElementRef,
AfterViewInit,
ChangeDetectorRef
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { catchError, finalize, tap } from 'rxjs/operators';
import { of, Subscription, forkJoin, Observable } from 'rxjs';
import { Chart, ChartConfiguration as ChartJsConfiguration, registerables } from 'chart.js';
import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import {
lucideActivity, lucideAlertCircle, lucideCheckCircle2, lucideRefreshCw,
lucideServer, lucideXCircle, lucideClock, lucideWifi, lucideWifiOff,
lucideTrendingUp, lucideBarChart3, lucidePieChart, lucideCalendar,
lucideDollarSign, lucideUsers, lucideCreditCard, lucideSettings,
lucideLayoutDashboard, lucideStore, lucideFilter, lucideHeartPulse,
lucideCpu, lucidePhone, lucideTrophy, lucidePlus, lucideListChecks,
lucideDatabase, lucideInfo, lucideBell, lucideCode,
lucideBanknoteArrowUp, lucideShield, lucideGlobe
} from '@ng-icons/lucide';
import {
TransactionReport,
SubscriptionReport,
SyncResponse,
HealthCheckStatus,
ChartDataNormalized,
ReportPeriod
} from './models/dcb-reporting.models';
import { ReportService } from './services/dcb-reporting.service';
import { DashboardAccess, AllowedMerchant, DashboardAccessService } from './services/dashboard-access.service';
import { AuthService } from '@core/services/auth.service';
import { PageTitle } from '@app/components/page-title/page-title';
// ============ TYPES ET INTERFACES ============
export type ChartType = 'line' | 'bar' | 'pie' | 'doughnut';
type ChartConfiguration = ChartJsConfiguration;
export interface ChartConfig {
type: ChartType;
data: ChartDataNormalized;
title?: string;
colors?: {
background?: string | string[];
border?: string;
hover?: string;
};
options?: {
responsive?: boolean;
maintainAspectRatio?: boolean;
plugins?: any;
scales?: any;
};
}
interface ServiceHealth {
service: string;
status: 'UP' | 'DOWN';
color: string;
url: string;
statusCode: number;
responseTime?: string;
checkedAt: string;
error?: string;
}
interface Alert {
type: 'warning' | 'info' | 'success' | 'danger';
title: string;
description: string;
time: string;
timestamp: Date;
}
interface DataSelection {
metric: 'revenue' | 'transactions' | 'successRate' | 'activeSubscriptions';
period: ReportPeriod;
chartType: ChartType;
merchantPartnerId?: number;
}
interface MetricOption {
id: 'revenue' | 'transactions' | 'successRate' | 'activeSubscriptions';
label: string;
icon: string;
}
interface PaymentStats {
daily: {
transactions: number;
revenue: number;
successRate: number;
};
weekly: {
transactions: number;
revenue: number;
successRate: number;
};
monthly: {
transactions: number;
revenue: number;
successRate: number;
};
yearly: {
transactions: number;
revenue: number;
successRate: number;
};
overallSuccessRate: number;
}
interface SubscriptionStats {
total: number;
active: number;
newToday: number;
cancelled: number;
activePercentage: number;
}
// ============ COMPOSANT ============
@Component({
selector: 'app-dcb-reporting-dashboard',
templateUrl: './dcb-reporting-dashboard.html',
styleUrls: ['./dcb-reporting-dashboard.css'],
standalone: true,
imports: [
CommonModule,
FormsModule,
NgIconComponent,
NgbDropdownModule,
PageTitle],
providers: [
provideIcons({
lucideActivity, lucideAlertCircle, lucideCheckCircle2, lucideRefreshCw,
lucideServer, lucideXCircle, lucideClock, lucideWifi, lucideWifiOff,
lucideTrendingUp, lucideBarChart3, lucidePieChart, lucideCalendar,
lucideDollarSign, lucideUsers, lucideCreditCard, lucideSettings,
lucideLayoutDashboard, lucideStore, lucideFilter, lucideHeartPulse,
lucideCpu, lucidePhone, lucideTrophy, lucidePlus, lucideListChecks,
lucideDatabase, lucideInfo, lucideBell, lucideCode, lucideBanknoteArrowUp,
lucideShield, lucideGlobe
})
]
})
export class DcbReportingDashboard implements OnInit, OnDestroy, AfterViewInit {
private subscriptions: Subscription[] = [];
private reportService = inject(ReportService);
private authService = inject(AuthService);
@ViewChild('mainChartCanvas') mainChartCanvas!: ElementRef<HTMLCanvasElement>;
@ViewChild('comparisonChartCanvas') comparisonChartCanvas!: ElementRef<HTMLCanvasElement>;
@ViewChild('successRateChartCanvas') successRateChartCanvas!: ElementRef<HTMLCanvasElement>;
@ViewChild('metricDropdown') metricDropdown?: NgbDropdown;
@ViewChild('optionsDropdown') optionsDropdown?: NgbDropdown;
// ============ DONNÉES ============
dailyTransactions: TransactionReport | null = null;
merchantTransactions: TransactionReport | null = null;
weeklyTransactions: TransactionReport | null = null;
monthlyTransactions: TransactionReport | null = null;
yearlyTransactions: TransactionReport | null = null;
dailySubscriptions: SubscriptionReport | null = null;
merchantSubscriptions: SubscriptionReport | null = null;
syncResponse: SyncResponse | null = null;
healthStatus: HealthCheckStatus[] = [];
// ============ CONFIGURATION ============
chartConfigs: ChartConfig[] = [];
currentChartType: ChartType = 'line';
availableChartTypes: ChartType[] = ['line', 'bar', 'pie', 'doughnut'];
dataSelection: DataSelection = {
metric: 'revenue',
period: 'daily',
chartType: 'line',
merchantPartnerId: undefined
};
availableMetrics: MetricOption[] = [
{ id: 'revenue', label: 'Revenue', icon: 'lucideBanknoteArrowUp' },
{ id: 'transactions', label: 'Transactions', icon: 'lucideCreditCard' },
{ id: 'successRate', label: 'Taux de succès', icon: 'lucideActivity' },
{ id: 'activeSubscriptions', label: 'Abonnements actifs', icon: 'lucideUsers' }
];
availablePeriods = [
{ id: 'daily', label: 'Jour' },
{ id: 'weekly', label: 'Semaine' },
{ id: 'monthly', label: 'Mois' },
{ id: 'yearly', label: 'Année' }
];
// ============ PARAMÈTRES ============
merchantId: number | undefined = undefined;
startDate: string = new Date().toISOString().split('T')[0];
endDate: string = new Date().toISOString().split('T')[0];
currentYear = new Date().getFullYear();
// ============ ÉTATS ============
loading = {
all: false,
dailyTransactions: false,
merchantTransactions: false,
weeklyTransactions: false,
monthlyTransactions: false,
yearlyTransactions: false,
dailySubscriptions: false,
merchantSubscriptions: false,
sync: false,
healthCheck: false,
chart: false,
globalData: false,
merchantData: false
};
errors: { [key: string]: string } = {};
lastUpdated = new Date();
// ============ SANTÉ DU SYSTÈME ============
systemHealth: ServiceHealth[] = [];
alerts: Alert[] = [];
stats = {
totalRevenue: 0,
totalTransactions: 0,
totalSubscriptions: 0,
successRate: 0,
activeSubscriptions: 0,
avgTransaction: 0,
maxRevenueDay: 0,
avgSuccessRate: 98.5,
yearlyRevenue: 0,
yearlyTransactions: 0,
totalServices: 0,
onlineServices: 0,
offlineServices: 0
};
overallHealth: {
status: 'healthy' | 'warning' | 'critical';
message: string;
icon: string;
color: string;
} = {
status: 'healthy',
message: 'Tous les services sont opérationnels',
icon: 'lucideCheckCircle2',
color: 'success'
};
// ============ COULEURS POUR LES GRAPHIQUES ============
private readonly chartColors = {
primary: {
background: 'rgba(13, 110, 253, 0.1)',
border: '#0d6efd',
hover: 'rgba(13, 110, 253, 0.3)'
},
success: {
background: 'rgba(40, 167, 69, 0.1)',
border: '#28a745',
hover: 'rgba(40, 167, 69, 0.3)'
},
warning: {
background: 'rgba(255, 193, 7, 0.1)',
border: '#ffc107',
hover: 'rgba(255, 193, 7, 0.3)'
},
danger: {
background: 'rgba(220, 53, 69, 0.1)',
border: '#dc3545',
hover: 'rgba(220, 53, 69, 0.3)'
},
info: {
background: 'rgba(23, 162, 184, 0.1)',
border: '#17a2b8',
hover: 'rgba(23, 162, 184, 0.3)'
},
palette: [
'#0d6efd', '#6610f2', '#6f42c1', '#d63384', '#fd7e14',
'#20c997', '#198754', '#0dcaf0', '#6c757d', '#ffc107'
]
};
// ============ PROPRIÉTÉS RÔLES ============
access!: DashboardAccess;
currentRoleLabel: string = '';
currentRoleIcon: string = '';
allowedMerchants: AllowedMerchant[] = [];
// ============ ÉTAT D'AFFICHAGE ============
isViewingGlobalData: boolean = true;
constructor(
private accessService: DashboardAccessService,
private cdr: ChangeDetectorRef
) {
Chart.register(...registerables);
}
ngOnInit(): void {
// 1. Initialiser l'accès
this.initializeAccess();
// 2. Initialiser le dashboard (avec délai pour laisser le temps à l'accès)
setTimeout(() => {
this.initializeDashboard();
}, 100);
// 3. Charger les merchants (avec délai)
setTimeout(() => {
this.loadAllowedMerchants();
}, 150);
if (this.accessService.shouldShowSystemHealth()) {
setInterval(() => {
this.checkSystemHealth();
}, 5 * 60 * 1000);
}
}
// ============ INITIALISATION ============
private initializeAccess(): void {
// Attendre que l'accès soit prêt
this.subscriptions.push(
this.accessService.waitForAccess().subscribe(() => {
this.access = this.accessService.getDashboardAccess();
this.currentRoleLabel = this.access.roleLabel;
this.currentRoleIcon = this.access.roleIcon;
console.log('✅ Dashboard initialisé avec:', {
access: this.access,
merchantId: this.access.merchantId,
isHubUser: this.access.isHubUser,
});
// Pour les merchant users
if (this.access.isMerchantUser) {
const merchantId = this.access.merchantId;
if (merchantId && merchantId > 0) {
this.merchantId = merchantId;
this.accessService.setSelectedMerchantId(merchantId);
this.isViewingGlobalData = false;
this.dataSelection.merchantPartnerId = merchantId;
console.log(`✅ Merchant User: ID = ${this.merchantId}`);
} else {
console.error('❌ Merchant ID invalide pour Merchant User:', merchantId);
// Essayer de récupérer directement depuis le profil
const merchantPartnerId = this.authService.getCurrentMerchantPartnerId();
if (merchantPartnerId) {
const id = Number(merchantPartnerId);
if (!isNaN(id) && id > 0) {
this.merchantId = id;
this.accessService.setSelectedMerchantId(id);
this.isViewingGlobalData = false;
this.dataSelection.merchantPartnerId = id;
console.log(`✅ Merchant ID récupéré depuis profil: ${id}`);
}
} else {
this.addAlert('danger', 'Erreur de configuration',
'Impossible de déterminer le merchant ID', 'Maintenant');
this.isViewingGlobalData = false;
}
}
}
// Pour les hub users
else if (this.access.isHubUser) {
const selectedMerchantId = this.accessService.getSelectedMerchantId();
if (selectedMerchantId && selectedMerchantId > 0) {
this.merchantId = selectedMerchantId;
this.isViewingGlobalData = false;
this.dataSelection.merchantPartnerId = selectedMerchantId;
console.log(`✅ Hub User: Merchant sélectionné = ${this.merchantId}`);
} else {
this.isViewingGlobalData = true;
this.merchantId = undefined;
this.dataSelection.merchantPartnerId = undefined;
console.log('✅ Hub User: Mode global (aucun merchant sélectionné)');
}
}
})
);
}
isValidMerchantId(id: any): boolean {
if (id === null || id === undefined) {
return false;
}
const numId = Number(id);
return !isNaN(numId) && Number.isInteger(numId) && numId > 0;
}
private loadAllowedMerchants(): void {
this.subscriptions.push(
this.accessService.getAvailableMerchants().subscribe({
next: (merchants) => {
this.allowedMerchants = merchants;
this.cdr.detectChanges();
},
error: (err) => {
console.error('Erreur lors du chargement des merchants:', err);
this.allowedMerchants = [];
}
})
);
}
private initializeDashboard(): void {
// Vérifier d'abord si le profil est chargé
const profile = this.authService.getProfile();
if (!profile) {
console.log('⏳ Profil non chargé, attente...');
return;
}
if (this.access.isHubUser) {
if (this.isViewingGlobalData) {
this.loadGlobalData();
} else {
this.loadMerchantData(this.merchantId);
}
} else {
this.loadMerchantData(this.merchantId);
}
if (this.accessService.shouldShowSystemHealth()) {
this.checkSystemHealth();
}
}
// ============ CHARGEMENT DES DONNÉES ============
private loadGlobalData(): void {
if (!this.access.isHubUser) return;
console.log('Chargement des données globales');
this.loading.globalData = true;
this.lastUpdated = new Date();
const requests = [
this.loadDailyTransactions(),
this.loadWeeklyTransactions(),
this.loadMonthlyTransactions(),
this.loadYearlyTransactions(),
this.loadDailySubscriptions()
];
this.subscriptions.push(
forkJoin(requests).subscribe({
next: () => {
console.log('Données globales chargées avec succès');
this.loading.globalData = false;
this.calculateStats();
this.cdr.detectChanges();
setTimeout(() => this.updateAllCharts(), 100);
},
error: (err) => {
console.error('Erreur lors du chargement des données globales:', err);
this.loading.globalData = false;
this.addAlert('danger', 'Erreur de chargement', 'Impossible de charger les données globales', 'Maintenant');
this.cdr.detectChanges();
}
})
);
}
private loadMerchantData(merchantId: number | undefined): void {
console.log('Chargement des données pour merchant:', merchantId);
// Vérification plus robuste
if (!merchantId || merchantId <= 0 || isNaN(merchantId)) {
console.error('Merchant ID invalide ou manquant:', merchantId);
this.addAlert('warning', 'Merchant non spécifié',
'Veuillez sélectionner un merchant valide', 'Maintenant');
// Pour les merchant users, essayer de récupérer l'ID depuis le cache
if (this.access.isMerchantUser) {
const cachedId = this.accessService.getSelectedMerchantId();
if (cachedId && cachedId > 0) {
merchantId = cachedId;
this.merchantId = cachedId;
console.log(`Utilisation du merchant ID du cache: ${merchantId}`);
} else {
return;
}
} else {
return;
}
}
this.loading.merchantData = true;
this.lastUpdated = new Date();
const requests = [
this.loadDailyTransactions(merchantId),
this.loadWeeklyTransactions(merchantId),
this.loadMonthlyTransactions(merchantId),
this.loadYearlyTransactions(merchantId),
this.loadDailySubscriptions(merchantId)
];
this.subscriptions.push(
forkJoin(requests).subscribe({
next: () => {
console.log(`Données du merchant ${merchantId} chargées avec succès`);
this.loading.merchantData = false;
this.calculateStats();
this.cdr.detectChanges();
setTimeout(() => this.updateAllCharts(), 100);
},
error: (err) => {
console.error(`Erreur lors du chargement des données du merchant ${merchantId}:`, err);
this.loading.merchantData = false;
this.addAlert('danger', 'Erreur de chargement',
`Impossible de charger les données du merchant ${merchantId}`, 'Maintenant');
this.cdr.detectChanges();
}
})
);
}
// ============ MÉTHODES DE CHARGEMENT INDIVIDUELLES ============
private loadDailyTransactions(merchantId?: number): Observable<any> {
this.loading.dailyTransactions = true;
this.errors['dailyTransactions'] = '';
return this.reportService.getDailyTransactions(merchantId).pipe(
catchError(err => {
this.errors['dailyTransactions'] = err.message || 'Erreur de chargement';
console.error('Error loading daily transactions:', err);
return of(null);
}),
tap(data => {
if (this.isValidTransactionReport(data)) {
if (merchantId) {
this.merchantTransactions = data;
} else {
this.dailyTransactions = data;
}
} else {
if (merchantId) {
this.merchantTransactions = null;
} else {
this.dailyTransactions = null;
}
}
}),
finalize(() => {
this.loading.dailyTransactions = false;
})
);
}
private loadWeeklyTransactions(merchantId?: number): Observable<any> {
this.loading.weeklyTransactions = true;
this.errors['weeklyTransactions'] = '';
return this.reportService.getWeeklyTransactions(merchantId).pipe(
catchError(err => {
this.errors['weeklyTransactions'] = err.message || 'Erreur de chargement';
console.error('Error loading weekly transactions:', err);
return of(null);
}),
tap(data => {
if (this.isValidTransactionReport(data)) {
this.weeklyTransactions = data;
} else {
this.weeklyTransactions = null;
}
}),
finalize(() => {
this.loading.weeklyTransactions = false;
})
);
}
private loadMonthlyTransactions(merchantId?: number): Observable<any> {
this.loading.monthlyTransactions = true;
this.errors['monthlyTransactions'] = '';
return this.reportService.getMonthlyTransactions(merchantId).pipe(
catchError(err => {
this.errors['monthlyTransactions'] = err.message || 'Erreur de chargement';
console.error('Error loading monthly transactions:', err);
return of(null);
}),
tap(data => {
if (this.isValidTransactionReport(data)) {
this.monthlyTransactions = data;
} else {
this.monthlyTransactions = null;
}
}),
finalize(() => {
this.loading.monthlyTransactions = false;
})
);
}
private loadYearlyTransactions(merchantId?: number): Observable<any> {
this.loading.yearlyTransactions = true;
this.errors['yearlyTransactions'] = '';
return this.reportService.getYearlyTransactions(this.currentYear, merchantId).pipe(
catchError(err => {
this.errors['yearlyTransactions'] = err.message || 'Erreur de chargement';
console.error('Error loading yearly transactions:', err);
return of(null);
}),
tap(data => {
if (this.isValidTransactionReport(data)) {
this.yearlyTransactions = data;
} else {
this.yearlyTransactions = null;
}
}),
finalize(() => {
this.loading.yearlyTransactions = false;
})
);
}
private loadDailySubscriptions(merchantId?: number): Observable<any> {
this.loading.dailySubscriptions = true;
this.errors['dailySubscriptions'] = '';
return this.reportService.getDailySubscriptions(merchantId).pipe(
catchError(err => {
this.errors['dailySubscriptions'] = err.message || 'Erreur de chargement';
console.error('Error loading daily subscriptions:', err);
return of(null);
}),
tap(data => {
if (this.isValidSubscriptionReport(data)) {
if (merchantId) {
this.merchantSubscriptions = data;
} else {
this.dailySubscriptions = data;
}
} else {
if (merchantId) {
this.merchantSubscriptions = null;
} else {
this.dailySubscriptions = null;
}
}
}),
finalize(() => {
this.loading.dailySubscriptions = false;
})
);
}
// ============ GESTION DES MERCHANTS ============
selectMerchant(merchantId: number): void {
if (!this.access.isHubUser) {
console.warn('Sélection de merchant réservée aux Hub Users');
return;
}
if (merchantId === 0) {
this.isViewingGlobalData = true;
this.merchantId = 0;
this.accessService.setSelectedMerchantId(0);
this.loadGlobalData();
this.addAlert('info', 'Mode global activé', 'Affichage des données globales', 'Maintenant');
} else {
this.isViewingGlobalData = false;
this.merchantId = merchantId;
this.accessService.setSelectedMerchantId(merchantId);
this.loadMerchantData(merchantId);
this.addAlert('info', 'Merchant changé', `Affichage des données du merchant ${merchantId}`, 'Maintenant');
}
this.dataSelection.merchantPartnerId = merchantId === 0 ? undefined : merchantId;
}
// ============ GESTION DES GRAPHIQUES ============
ngAfterViewInit(): void {
setTimeout(() => {
this.updateAllCharts();
}, 500);
}
updateAllCharts(): void {
const transactionData = this.getCurrentTransactionData();
if (!transactionData) {
return;
}
try {
if (this.mainChartCanvas?.nativeElement) {
this.updateMainChart();
}
if (this.comparisonChartCanvas?.nativeElement && this.weeklyTransactions && this.monthlyTransactions && this.isViewingGlobalData) {
this.updateComparisonChart();
}
if (this.successRateChartCanvas?.nativeElement) {
this.updateSuccessRateChart();
}
} catch (error) {
console.error('Erreur lors de la mise à jour des graphiques:', error);
}
}
private updateMainChart(): void {
if (!this.mainChartCanvas?.nativeElement) return;
const ctx = this.mainChartCanvas.nativeElement.getContext('2d');
if (!ctx) return;
try {
const existingChart = (ctx as any).chart;
if (existingChart) {
existingChart.destroy();
}
this.loading.chart = true;
const transactionData = this.getCurrentTransactionData();
const subscriptionData = this.getCurrentSubscriptionData();
if (!transactionData && !subscriptionData) {
this.loading.chart = false;
return;
}
const normalizedData = this.getNormalizedDataForMetric(
this.dataSelection.metric,
transactionData,
subscriptionData
);
if (!normalizedData || normalizedData.labels.length === 0) {
this.loading.chart = false;
return;
}
const config = this.createChartConfig(
normalizedData,
this.dataSelection.chartType,
this.getChartTitle(this.dataSelection.metric)
);
const newChart = new Chart(ctx, config);
(ctx as any).chart = newChart;
this.loading.chart = false;
this.cdr.detectChanges();
} catch (error) {
console.error('Erreur lors de la création du graphique principal:', error);
this.loading.chart = false;
this.cdr.detectChanges();
}
}
private updateComparisonChart(): void {
if (!this.comparisonChartCanvas?.nativeElement || !this.weeklyTransactions || !this.monthlyTransactions) {
return;
}
const ctx = this.comparisonChartCanvas.nativeElement.getContext('2d');
if (!ctx) return;
try {
const existingChart = (ctx as any).chart;
if (existingChart) {
existingChart.destroy();
}
const weeklyData = this.reportService.normalizeForChart(this.weeklyTransactions, 'totalAmount');
const monthlyData = this.reportService.normalizeForChart(this.monthlyTransactions, 'totalAmount');
if (!weeklyData || !monthlyData) return;
const labels = weeklyData.labels.slice(-8);
const weeklyValues = weeklyData.dataset.slice(-8);
const monthlyValues = monthlyData.dataset.slice(-8);
const config: ChartJsConfiguration<'bar'> = {
type: 'bar',
data: {
labels: labels,
datasets: [
{
label: 'Hebdomadaire',
data: weeklyValues,
backgroundColor: this.chartColors.primary.background,
borderColor: this.chartColors.primary.border,
borderWidth: 1
},
{
label: 'Mensuel',
data: monthlyValues,
backgroundColor: this.chartColors.success.background,
borderColor: this.chartColors.success.border,
borderWidth: 1
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'top' },
tooltip: {
callbacks: {
label: (context: any) => {
const value = context.raw;
return this.dataSelection.metric === 'revenue'
? `${this.formatCurrency(value)}`
: `${this.formatNumber(value)}`;
}
}
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
callback: (value: any) => {
if (typeof value === 'number') {
if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`;
if (value >= 1000) return `${(value / 1000).toFixed(0)}K`;
return value;
}
return value;
}
}
}
}
}
};
const newChart = new Chart(ctx, config);
(ctx as any).chart = newChart;
} catch (error) {
console.error('Erreur lors de la création du graphique de comparaison:', error);
}
}
private updateSuccessRateChart(): void {
if (!this.successRateChartCanvas?.nativeElement) return;
const ctx = this.successRateChartCanvas.nativeElement.getContext('2d');
if (!ctx) return;
try {
const existingChart = (ctx as any).chart;
if (existingChart) {
existingChart.destroy();
}
const transactionData = this.getCurrentTransactionData();
if (!transactionData) return;
const successRate = this.getSuccessRate(transactionData);
const remaining = 100 - successRate;
const config: ChartJsConfiguration<'doughnut'> = {
type: 'doughnut',
data: {
datasets: [{
data: [successRate, remaining],
backgroundColor: [this.getChartColor(successRate), 'rgba(0, 0, 0, 0.1)'],
borderWidth: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '70%',
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (context: any) => `${context.raw.toFixed(1)}%`
}
}
}
}
};
const newChart = new Chart(ctx, config);
(ctx as any).chart = newChart;
} catch (error) {
console.error('Erreur lors de la création du graphique de taux de succès:', error);
}
}
// ============ UTILITAIRES DE DONNÉES ============
getCurrentTransactionData(): TransactionReport | null {
return this.isViewingGlobalData ?
this.dailyTransactions :
(this.merchantTransactions || this.dailyTransactions);
}
getCurrentSubscriptionData(): SubscriptionReport | null {
return this.isViewingGlobalData ?
this.dailySubscriptions :
(this.merchantSubscriptions || this.dailySubscriptions);
}
getNormalizedDataForMetric(
metric: DataSelection['metric'],
transactionData: TransactionReport | null,
subscriptionData: SubscriptionReport | null = null
): ChartDataNormalized | null {
if (!transactionData && !subscriptionData) return null;
switch (metric) {
case 'revenue':
if (transactionData) {
const normalized = this.reportService.normalizeForChart(transactionData, 'totalAmount');
normalized.datasetLabel = 'Revenue (XOF)';
normalized.backgroundColor = this.chartColors.primary.background;
normalized.borderColor = this.chartColors.primary.border;
return normalized;
}
break;
case 'transactions':
if (transactionData) {
const normalized = this.reportService.normalizeForChart(transactionData, 'count');
normalized.datasetLabel = 'Nombre de transactions';
normalized.backgroundColor = this.chartColors.info.background;
normalized.borderColor = this.chartColors.info.border;
return normalized;
}
break;
case 'successRate':
if (transactionData) {
const labels = transactionData.items.map(item => item.period);
const dataset = transactionData.items.map(item => {
const total = item.count || 0;
const success = item.successCount || 0;
return total > 0 ? (success / total) * 100 : 0;
});
return {
labels,
dataset,
datasetLabel: 'Taux de succès (%)',
backgroundColor: this.chartColors.success.background,
borderColor: this.chartColors.success.border
};
}
break;
case 'activeSubscriptions':
const subData = subscriptionData || this.dailySubscriptions;
if (subData) {
const normalized = this.reportService.normalizeForChart(subData, 'activeCount');
normalized.datasetLabel = 'Abonnements actifs';
normalized.backgroundColor = this.chartColors.warning.background;
normalized.borderColor = this.chartColors.warning.border;
return normalized;
}
break;
}
return null;
}
private createChartConfig(
normalizedData: ChartDataNormalized,
chartType: ChartType,
title?: string
): ChartConfiguration {
const isCircular = chartType === 'pie' || chartType === 'doughnut';
return {
type: chartType,
data: {
labels: normalizedData.labels,
datasets: [{
label: normalizedData.datasetLabel || 'Données',
data: normalizedData.dataset,
backgroundColor: normalizedData.backgroundColor || this.chartColors.palette,
borderColor: normalizedData.borderColor || this.chartColors.primary.border,
borderWidth: isCircular ? 0 : 2,
fill: !isCircular,
tension: chartType === 'line' ? 0.4 : 0,
pointRadius: chartType === 'line' ? 3 : 0,
pointHoverRadius: chartType === 'line' ? 5 : 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top',
display: !isCircular
},
title: {
display: !!title,
text: title,
font: {
size: 14,
weight: 'bold'
}
},
tooltip: {
callbacks: {
label: (context: any) => {
const value = context.raw;
if (normalizedData.datasetLabel?.includes('XOF')) {
return `${normalizedData.datasetLabel}: ${this.formatCurrency(value)}`;
} else if (normalizedData.datasetLabel?.includes('%')) {
return `${normalizedData.datasetLabel}: ${value.toFixed(1)}%`;
}
return `${normalizedData.datasetLabel}: ${this.formatNumber(value)}`;
}
}
}
},
scales: isCircular ? {} : {
y: {
beginAtZero: true,
grid: { color: 'rgba(0, 0, 0, 0.05)' },
ticks: {
callback: (value: any) => {
if (typeof value === 'number') {
if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`;
if (value >= 1000) return `${(value / 1000).toFixed(0)}K`;
return value;
}
return value;
}
}
},
x: {
grid: { color: 'rgba(0, 0, 0, 0.05)' },
ticks: { maxRotation: 45 }
}
}
}
};
}
// ============ GESTION DES ACTIONS ============
refreshData(): void {
if (this.access.isHubUser) {
if (this.isViewingGlobalData) {
this.loadGlobalData();
} else {
this.loadMerchantData(this.merchantId);
}
} else {
this.loadMerchantData(this.merchantId);
}
}
triggerSync(): void {
if (!this.accessService.canTriggerSync()) {
this.addAlert('warning', 'Permission refusée', 'Vous n\'avez pas la permission de déclencher une synchronisation', 'Maintenant');
return;
}
const syncService = this.systemHealth.find(s => s.service === 'SYNC');
if (syncService?.status === 'DOWN') {
this.addAlert('danger', 'Synchronisation impossible', 'Le service de synchronisation est actuellement hors ligne', 'Maintenant');
return;
}
this.loading.sync = true;
this.syncResponse = null;
const sub = this.reportService.triggerManualSync().pipe(
catchError(err => {
this.errors['sync'] = err.message || 'Erreur de synchronisation';
if (this.accessService.shouldShowAlerts()) {
this.addAlert('danger', 'Échec de synchronisation', `La synchronisation a échoué: ${err.message}`, 'Maintenant');
}
return of(null);
}),
tap(data => {
if (data && typeof data.message === 'string' && typeof data.timestamp === 'string') {
this.syncResponse = data;
if (this.accessService.shouldShowAlerts()) {
this.addAlert('success', 'Synchronisation réussie', data.message, 'Maintenant');
}
}
}),
finalize(() => {
this.loading.sync = false;
setTimeout(() => this.refreshData(), 2000);
})
).subscribe();
this.subscriptions.push(sub);
}
// ============ HEALTH CHECK ============
checkSystemHealth(): void {
if (!this.accessService.shouldShowSystemHealth()) return;
this.loading.healthCheck = true;
this.systemHealth = [];
this.alerts = [];
this.subscriptions.push(
this.reportService.detailedHealthCheck().pipe(
tap(response => {
this.healthStatus = response.details;
this.systemHealth = response.details.map(api => ({
service: api.service,
status: api.status,
color: api.status === 'UP' ? 'success' : 'danger',
url: api.url,
statusCode: api.statusCode,
checkedAt: api.checkedAt,
}));
this.stats.totalServices = response.summary.total;
this.stats.onlineServices = response.summary.up;
this.stats.offlineServices = response.summary.down;
this.updateOverallHealth();
this.generateHealthAlerts();
this.loading.healthCheck = false;
this.cdr.detectChanges();
}),
catchError(err => {
console.error('Erreur lors du health check:', err);
this.addAlert('danger', 'Erreur de vérification', 'Impossible de vérifier la santé des services', 'Maintenant');
this.loading.healthCheck = false;
this.cdr.detectChanges();
return of(null);
})
).subscribe()
);
}
private updateOverallHealth(): void {
const offlineCount = this.stats.offlineServices;
const totalCount = this.stats.totalServices;
if (offlineCount === 0) {
this.overallHealth = {
status: 'healthy',
message: `Tous les ${totalCount} services sont opérationnels`,
icon: 'lucideCheckCircle2',
color: 'success'
};
} else if (offlineCount === 1) {
const offlineService = this.systemHealth.find(s => s.status === 'DOWN')?.service || 'un service';
this.overallHealth = {
status: 'warning',
message: `${offlineService} est hors ligne`,
icon: 'lucideAlertCircle',
color: 'warning'
};
} else {
this.overallHealth = {
status: 'critical',
message: `${offlineCount} services sur ${totalCount} sont hors ligne`,
icon: 'lucideXCircle',
color: 'danger'
};
}
}
private generateHealthAlerts(): void {
this.alerts = this.alerts.filter(alert =>
!alert.title.includes('hors ligne') &&
!alert.title.includes('opérationnels')
);
const offlineServices = this.systemHealth.filter(s => s.status === 'DOWN');
if (offlineServices.length > 0) {
offlineServices.forEach(service => {
this.addAlert('danger', `${service.service} hors ligne`,
`Le service ${service.service} ne répond pas (${service.statusCode})`,
this.formatTimeAgo(service.checkedAt));
});
} else {
this.addAlert('success', 'Tous les services opérationnels',
'Toutes les APIs répondent correctement', 'Maintenant');
}
}
// ============ CALCUL DES STATISTIQUES ============
private calculateStats(): void {
const transactionData = this.getCurrentTransactionData();
const subscriptionData = this.getCurrentSubscriptionData();
if (transactionData) {
this.stats.totalRevenue = transactionData.totalAmount || 0;
this.stats.totalTransactions = transactionData.totalCount || 0;
this.stats.successRate = this.getSuccessRate(transactionData);
if (transactionData.items?.length) {
this.stats.maxRevenueDay = Math.max(...transactionData.items.map(item => item.totalAmount || 0));
}
}
this.stats.yearlyRevenue = this.yearlyTransactions?.totalAmount || 0;
this.stats.yearlyTransactions = this.yearlyTransactions?.totalCount || 0;
if (subscriptionData) {
this.stats.totalSubscriptions = subscriptionData.totalCount || 0;
this.stats.activeSubscriptions = subscriptionData.items?.reduce(
(sum, item) => sum + (item.activeCount || 0), 0
) || 0;
}
this.stats.avgTransaction = this.stats.totalTransactions > 0
? this.stats.totalRevenue / this.stats.totalTransactions
: 0;
}
getSuccessRate(transaction: TransactionReport | null): number {
if (!transaction || !transaction.items || transaction.items.length === 0) return 0;
const item = transaction.items[0];
const total = item.count || 0;
const success = item.successCount || 0;
return total > 0 ? (success / total) * 100 : 0;
}
// ============ MÉTHODES UTILITAIRES POUR LE TEMPLATE ============
getPaymentStats(): PaymentStats {
const transactionData = this.getCurrentTransactionData();
return {
daily: {
transactions: transactionData?.totalCount || 0,
revenue: transactionData?.totalAmount || 0,
successRate: this.getSuccessRate(transactionData)
},
weekly: {
transactions: this.weeklyTransactions?.totalCount || 0,
revenue: this.weeklyTransactions?.totalAmount || 0,
successRate: this.getSuccessRate(this.weeklyTransactions)
},
monthly: {
transactions: this.monthlyTransactions?.totalCount || 0,
revenue: this.monthlyTransactions?.totalAmount || 0,
successRate: this.getSuccessRate(this.monthlyTransactions)
},
yearly: {
transactions: this.stats.yearlyTransactions,
revenue: this.stats.yearlyRevenue,
successRate: this.stats.avgSuccessRate
},
overallSuccessRate: this.stats.successRate
};
}
getSubscriptionStats(): SubscriptionStats {
const subscriptionData = this.getCurrentSubscriptionData();
const dailyItem = subscriptionData?.items?.[0];
return {
total: subscriptionData?.totalCount || 0,
active: this.stats.activeSubscriptions,
newToday: dailyItem?.count || 0,
cancelled: dailyItem?.cancelledCount || 0,
activePercentage: subscriptionData?.totalCount ?
(this.stats.activeSubscriptions / subscriptionData.totalCount) * 100 : 0
};
}
getPerformanceLabel(successRate: number): string {
if (successRate >= 95) return 'Excellent';
if (successRate >= 90) return 'Bon';
if (successRate >= 80) return 'Moyen';
return 'À améliorer';
}
getAlertBadgeClass(): string {
if (this.alerts.length === 0) return 'bg-success';
if (this.alerts.filter(a => a.type === 'danger').length > 0) return 'bg-danger';
if (this.alerts.filter(a => a.type === 'warning').length > 0) return 'bg-warning';
return 'bg-info';
}
// ============ GESTION DES GRAPHIQUES (PUBLIC) ============
changeMetric(metric: 'revenue' | 'transactions' | 'successRate' | 'activeSubscriptions'): void {
this.dataSelection.metric = metric;
if (this.metricDropdown) {
this.metricDropdown.close();
}
this.cdr.detectChanges();
setTimeout(() => {
this.updateMainChart();
}, 50);
}
changePeriod(period: ReportPeriod): void {
this.dataSelection.period = period;
this.cdr.detectChanges();
setTimeout(() => {
this.updateMainChart();
}, 50);
}
changeChartType(type: ChartType): void {
this.dataSelection.chartType = type;
this.cdr.detectChanges();
setTimeout(() => {
this.updateMainChart();
}, 50);
}
refreshChartData(): void {
this.cdr.detectChanges();
setTimeout(() => {
this.updateAllCharts();
}, 50);
}
// ============ FORMATAGE ============
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`;
}
formatNumber(num: number): string {
return num.toLocaleString('fr-FR');
}
formatDate(dateString: string): string {
try {
return new Date(dateString).toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
} catch {
return dateString;
}
}
formatTimeAgo(date: Date | string | undefined): string {
if (!date) return 'Jamais';
const checkDate = typeof date === 'string' ? new Date(date) : date;
const now = new Date();
const diffMs = now.getTime() - checkDate.getTime();
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return 'À l\'instant';
if (diffMins < 60) return `Il y a ${diffMins} min`;
if (diffMins < 1440) return `Il y a ${Math.floor(diffMins / 60)} h`;
return `Il y a ${Math.floor(diffMins / 1440)} j`;
}
// ============ MÉTHODES POUR LE TEMPLATE ============
getSuccessRateClass(rate: number): string {
if (rate >= 95) return 'text-success';
if (rate >= 90) return 'text-warning';
return 'text-danger';
}
getHealthIcon(service: ServiceHealth): string {
return service.status === 'UP' ? 'lucideCheckCircle2' : 'lucideXCircle';
}
getStatusCodeClass(statusCode: number): string {
if (statusCode >= 200 && statusCode < 300) return 'text-success';
if (statusCode >= 300 && statusCode < 400) return 'text-warning';
if (statusCode >= 400 && statusCode < 500) return 'text-warning';
if (statusCode >= 500) return 'text-danger';
return 'text-muted';
}
getResponseTimeClass(responseTime?: string): string {
if (!responseTime) return 'text-muted';
const time = parseInt(responseTime.replace('ms', ''));
if (time < 500) return 'text-success';
if (time < 2000) return 'text-warning';
return 'text-danger';
}
getMetricIcon(metricId: string): string {
const metric = this.availableMetrics.find(m => m.id === metricId);
return metric?.icon || 'lucideActivity';
}
getCurrentMetricLabel(): string {
const metric = this.availableMetrics.find(m => m.id === this.dataSelection.metric);
return metric?.label || 'Revenue';
}
getChartTitle(metric: DataSelection['metric']): string {
const titles: Record<DataSelection['metric'], string> = {
revenue: 'Évolution du Revenue',
transactions: 'Nombre de Transactions',
successRate: 'Taux de Succès',
activeSubscriptions: 'Abonnements Actifs'
};
return titles[metric];
}
private getChartColor(rate: number): string {
if (rate >= 95) return '#28a745';
if (rate >= 90) return '#ffc107';
return '#dc3545';
}
// ============ MÉTHODES UTILITAIRES POUR LES RÔLES ============
getRoleStatusIcon(): string {
if (this.access.isHubUser) return 'lucideShield';
return 'lucideStore';
}
getRoleStatusColor(): string {
if (this.access.isHubUser) return 'text-primary';
return 'text-success';
}
getRoleBadgeClass(): string {
if (this.access.isHubUser) return 'bg-primary';
return 'bg-success';
}
// ============ MÉTHODES UTILITAIRES POUR LES PERMISSIONS ============
shouldShowSystemHealth(): boolean {
return this.accessService.shouldShowSystemHealth();
}
shouldShowAllTransactions(): boolean {
return this.access.isHubUser;
}
canTriggerSync(): boolean {
return this.accessService.canTriggerSync();
}
canManageMerchants(): boolean {
return this.accessService.canManageMerchants();
}
shouldShowTransactions(): boolean {
return true;
}
shouldShowCharts(): boolean {
return true;
}
shouldShowKPIs(): boolean {
return true;
}
shouldShowAlerts(): boolean {
return this.accessService.shouldShowAlerts();
}
canRefreshData(): boolean {
return true;
}
canSelectMerchant(): boolean {
return this.access.isHubUser && this.allowedMerchants.length > 0;
}
shouldShowMerchantId(): boolean {
return this.accessService.shouldShowMerchantId();
}
canEditMerchantFilter(): boolean {
return this.accessService.canEditMerchantFilter();
}
// ============ MÉTHODES SPÉCIFIQUES AU CONTEXTE ============
isViewingGlobal(): boolean {
return this.isViewingGlobalData;
}
isViewingMerchant(): boolean {
return !this.isViewingGlobalData && !this.merchantId ;
}
getCurrentMerchantName(): string {
if (this.isViewingGlobalData) {
return 'Données globales';
}
const merchant = this.allowedMerchants.find(m => m.id === this.merchantId);
return merchant ? merchant.name : `Merchant ${this.merchantId}`;
}
private getCurrentMerchantPartnerId(): string | null {
// Utiliser une valeur par défaut sécurisée
return this.access?.merchantId?.toString() || null;
}
shouldShowMerchantSelector(): boolean {
return this.access.isHubUser && this.allowedMerchants.length > 1;
}
// ============ MÉTHODES POUR LES KPIs ============
getDisplayedKPIs(): any[] {
const kpis = [];
const paymentStats = this.getPaymentStats();
const subscriptionStats = this.getSubscriptionStats();
kpis.push({
title: 'Transactions',
value: this.formatNumber(paymentStats.daily.transactions),
subtext: 'Journalier',
metric: 'transactions',
color: 'primary',
icon: 'lucideStore',
period: 'daily'
});
if (this.access.isHubUser && this.isViewingGlobalData) {
kpis.push({
title: 'Transactions',
value: this.formatNumber(paymentStats.weekly.transactions),
subtext: 'Hebdomadaire',
metric: 'transactions',
color: 'info',
icon: 'lucideCalendar',
period: 'weekly'
});
}
kpis.push({
title: `Revenue ${this.currentYear}`,
value: this.formatCurrency(this.stats.yearlyRevenue),
subtext: 'Annuel',
metric: 'revenue',
color: 'purple',
icon: 'lucideTrophy',
period: 'yearly'
});
kpis.push({
title: 'Abonnements',
value: this.formatNumber(subscriptionStats.active),
subtext: 'Actifs',
metric: 'subscriptions',
color: 'warning',
icon: 'lucideUsers',
period: 'current'
});
return kpis;
}
shouldShowChart(chartType: 'main' | 'comparison' | 'successRate'): boolean {
switch (chartType) {
case 'main':
return true;
case 'comparison':
return this.access.isHubUser && this.isViewingGlobalData;
case 'successRate':
return this.getCurrentTransactionData() !== null;
default:
return false;
}
}
shouldShowSection(section: 'health' | 'alerts' | 'tables' | 'footerInfo'): boolean {
switch (section) {
case 'health':
return this.shouldShowSystemHealth();
case 'alerts':
return this.shouldShowAlerts();
case 'tables':
return true;
case 'footerInfo':
return true;
default:
return false;
}
}
// ============ GESTION DES ALERTES ============
private addAlert(type: 'warning' | 'info' | 'success' | 'danger',
title: string,
description: string,
time: string): void {
if (this.alerts.length >= 5) {
this.alerts.shift();
}
this.alerts.push({
type,
title,
description,
time,
timestamp: new Date()
});
}
// ============ VALIDATION ============
private isValidTransactionReport(data: any): data is TransactionReport {
return data !== null &&
data !== undefined &&
typeof data === 'object' &&
Array.isArray(data.items);
}
private isValidSubscriptionReport(data: any): data is SubscriptionReport {
return data !== null &&
data !== undefined &&
typeof data === 'object' &&
Array.isArray(data.items);
}
// ============ DESTRUCTION ============
ngOnDestroy(): void {
this.subscriptions.forEach(sub => sub.unsubscribe());
}
}