1659 lines
50 KiB
TypeScript
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());
|
|
}
|
|
} |