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

This commit is contained in:
diallolatoile 2025-10-28 16:58:11 +00:00
parent 3e9b8cab2d
commit 79ec4ac00d
43 changed files with 2891 additions and 1280 deletions

View File

@ -1,7 +1,5 @@
import { Routes } from '@angular/router'
import { VerticalLayout } from '@layouts/vertical-layout/vertical-layout'
import { authGuard } from '@core/guards/auth.guard'
import { roleGuard } from '@core/guards/role.guard'
export const routes: Routes = [
{ path: '', redirectTo: '/dcb-dashboard', pathMatch: 'full' },
@ -20,14 +18,13 @@ export const routes: Routes = [
import('./modules/auth/error/error.route').then(mod => mod.ERROR_PAGES_ROUTES),
},
// Routes protégées
// Routes protégées - SANS guards au niveau parent
{
path: '',
component: VerticalLayout,
canActivate: [authGuard, roleGuard],
loadChildren: () =>
import('./modules/modules-routing.module').then(
m => m.ModulesRoutingModule
import('./modules/modules.routes').then(
m => m.ModulesRoutes
),
},
@ -35,6 +32,6 @@ export const routes: Routes = [
{ path: '404', redirectTo: '/error/404' },
{ path: '403', redirectTo: '/error/403' },
// Catch-all - Rediriger vers 404 au lieu de login
// Catch-all
{ path: '**', redirectTo: '/error/404' },
]
];

View File

@ -11,109 +11,6 @@
font-size: 12px;
}
.dcb-dashboard {
.kpi-card {
border: none;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.card-body {
padding: 1.5rem;
}
}
.kpi-icon {
width: 60px;
height: 60px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: white;
ng-icon {
font-size: 24px;
}
}
.rank-badge {
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 0.875rem;
}
.progress-sm {
height: 6px;
}
// Animation de spin pour l'icône de refresh
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
// Badges personnalisés
.badge {
font-size: 0.75em;
font-weight: 500;
}
// Table styles
.table {
th {
border-top: none;
font-weight: 600;
color: #6c757d;
font-size: 0.875rem;
padding: 1rem 0.75rem;
}
td {
padding: 1rem 0.75rem;
vertical-align: middle;
}
tbody tr {
transition: background-color 0.2s ease;
&:hover {
background-color: rgba(0, 0, 0, 0.02);
}
}
}
// Responsive adjustments
@media (max-width: 768px) {
.kpi-icon {
width: 50px;
height: 50px;
ng-icon {
font-size: 20px;
}
}
.card-body {
padding: 1rem;
}
}
}
.transactions-container {
.cursor-pointer {
cursor: pointer;
@ -198,4 +95,554 @@
box-shadow: none !important;
}
}
}
.merchants-container {
.cursor-pointer {
cursor: pointer;
&:hover {
background-color: rgba(0, 0, 0, 0.02);
}
}
.fs-12 {
font-size: 12px;
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.avatar-sm {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 14px;
}
.table {
th {
border-top: none;
font-weight: 600;
color: #6c757d;
font-size: 0.875rem;
padding: 1rem 0.75rem;
background-color: #f8f9fa;
&.cursor-pointer {
&:hover {
background-color: #e9ecef;
}
}
}
td {
padding: 1rem 0.75rem;
vertical-align: middle;
border-top: 1px solid #e9ecef;
}
tbody tr {
transition: all 0.2s ease;
&:hover {
background-color: rgba(0, 123, 255, 0.04);
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
}
}
.badge {
font-size: 0.75em;
font-weight: 500;
padding: 0.35em 0.65em;
// Couleurs personnalisées pour les catégories
&.bg-purple {
background-color: #6f42c1 !important;
}
&.bg-teal {
background-color: #20c997 !important;
}
// Statuts
&.bg-success {
background: linear-gradient(135deg, #28a745, #20c997);
}
&.bg-warning {
background: linear-gradient(135deg, #ffc107, #fd7e14);
}
&.bg-danger {
background: linear-gradient(135deg, #dc3545, #e83e8c);
}
&.bg-info {
background: linear-gradient(135deg, #17a2b8, #6f42c1);
}
&.bg-secondary {
background: linear-gradient(135deg, #6c757d, #868e96);
}
}
.font-monospace {
font-family: 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas', monospace;
font-size: 0.875em;
}
.btn-group-sm {
.btn {
padding: 0.25rem 0.5rem;
border-radius: 0.375rem;
&:not(:last-child) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
&:not(:first-child) {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
}
// Cartes de statistiques
.stats-card {
border: none;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.card-body {
padding: 1.5rem;
}
}
// Pagination personnalisée
.pagination {
.page-item {
margin: 0 2px;
.page-link {
border: none;
border-radius: 6px;
color: #6c757d;
font-weight: 500;
padding: 0.5rem 0.75rem;
&:hover {
background-color: #e9ecef;
color: #495057;
}
}
&.active .page-link {
background: linear-gradient(135deg, #007bff, #0056b3);
border-color: #007bff;
}
&.disabled .page-link {
color: #adb5bd;
background-color: #f8f9fa;
}
}
}
// Dropdown personnalisé
.dropdown-menu {
border: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border-radius: 8px;
padding: 0.5rem;
.dropdown-item {
padding: 0.5rem 1rem;
border-radius: 4px;
font-size: 0.875rem;
display: flex;
align-items: center;
&:hover {
background-color: #f8f9fa;
}
ng-icon {
margin-right: 0.5rem;
font-size: 1em;
}
&.text-danger {
color: #dc3545 !important;
&:hover {
background-color: #f8d7da;
}
}
}
.dropdown-divider {
margin: 0.5rem 0;
border-color: #e9ecef;
}
}
// Alertes personnalisées
.alert {
border: none;
border-radius: 8px;
padding: 1rem 1.5rem;
&.alert-danger {
background: linear-gradient(135deg, #f8d7da, #f1aeb5);
color: #721c24;
}
}
// Responsive design
@media (max-width: 768px) {
.table-responsive {
border: 1px solid #e9ecef;
border-radius: 8px;
.table {
margin-bottom: 0;
th, td {
padding: 0.75rem 0.5rem;
font-size: 0.875rem;
}
}
}
.btn-group-sm {
flex-direction: column;
.btn {
margin-bottom: 2px;
border-radius: 4px !important;
&:not(:last-child) {
margin-right: 0;
}
}
}
.d-flex.gap-2 {
gap: 0.5rem !important;
}
.stats-card .card-body {
padding: 1rem;
}
}
@media (max-width: 576px) {
.table {
font-size: 0.875rem;
th, td {
padding: 0.5rem 0.25rem;
}
.badge {
font-size: 0.7em;
padding: 0.25em 0.5em;
}
}
.pagination {
.page-link {
padding: 0.375rem 0.5rem;
font-size: 0.875rem;
}
}
}
// Animation pour le chargement
.loading-skeleton {
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.5;
}
100% {
opacity: 1;
}
}
// Styles pour les états vides
.empty-state {
padding: 3rem 1rem;
text-align: center;
ng-icon {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.5;
}
p {
color: #6c757d;
margin-bottom: 1.5rem;
}
}
}
.merchant-details-container {
.merchant-avatar {
width: 60px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
}
.font-monospace {
font-family: 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas', monospace;
}
.badge {
font-size: 0.75em;
font-weight: 500;
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
// Styles pour l'impression
@media print {
.btn, .card-header .d-flex:last-child {
display: none !important;
}
.card {
border: 1px solid #dee2e6 !important;
box-shadow: none !important;
}
}
}
.merchant-config-container {
.form-label {
font-weight: 500;
margin-bottom: 0.5rem;
}
.card {
border: 1px solid #e9ecef;
.card-header {
background-color: #f8f9fa;
border-bottom: 1px solid #e9ecef;
}
}
.input-group-text {
background-color: #f8f9fa;
border-color: #ced4da;
}
// Responsive adjustments
@media (max-width: 768px) {
.row.g-3 {
margin-bottom: -0.5rem;
> [class*="col-"] {
margin-bottom: 0.5rem;
}
}
}
}
.merchant-stats-container {
.stats-card {
border: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transition: all 0.3s ease;
border-radius: 12px;
background: white;
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
}
.card-body {
padding: 1.5rem;
}
}
.stats-icon {
width: 60px;
height: 60px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
ng-icon {
font-size: 24px;
}
}
.stats-title {
color: #2c3e50;
font-weight: 600;
}
.stats-controls {
.btn-sm {
padding: 0.375rem 0.5rem;
border-radius: 8px;
}
}
// Progress bars personnalisées
.progress {
border-radius: 10px;
background-color: #f8f9fa;
overflow: hidden;
.progress-bar {
border-radius: 10px;
transition: width 0.6s ease;
&.bg-success {
background: linear-gradient(135deg, #28a745, #20c997) !important;
}
&.bg-warning {
background: linear-gradient(135deg, #ffc107, #fd7e14) !important;
}
}
}
// Animations
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
// Couleurs pour les variations
.text-success {
color: #28a745 !important;
}
.text-danger {
color: #dc3545 !important;
}
.text-primary {
color: #007bff !important;
}
.text-warning {
color: #ffc107 !important;
}
.text-info {
color: #17a2b8 !important;
}
// État vide
.empty-state {
padding: 3rem 2rem;
ng-icon {
opacity: 0.3;
}
}
// Responsive design
@media (max-width: 768px) {
.stats-card .card-body {
padding: 1.25rem;
}
.stats-icon {
width: 50px;
height: 50px;
ng-icon {
font-size: 20px;
}
}
.h4 {
font-size: 1.5rem;
}
}
@media (max-width: 576px) {
.stats-header {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.stats-controls {
align-self: flex-end;
}
.stats-card .card-body {
padding: 1rem;
}
.stats-icon {
width: 45px;
height: 45px;
ng-icon {
font-size: 18px;
}
}
}
// Typographie améliorée
.h4 {
font-weight: 700;
margin-bottom: 0.5rem;
}
.text-muted {
color: #6c757d !important;
font-size: 0.875rem;
}
.small {
font-size: 0.8125rem;
}
}

View File

@ -19,6 +19,13 @@ export const roleGuard: CanActivateFn = (route: ActivatedRouteSnapshot, state) =
// Récupérer les rôles depuis le token
const userRoles = authService.getCurrentUserRoles();
if (!userRoles || userRoles.length === 0) {
console.warn('RoleGuard: User has no roles');
router.navigate(['/unauthorized']);
return false;
}
const modulePath = getModulePath(route);
console.log('RoleGuard check:', {
@ -42,14 +49,32 @@ export const roleGuard: CanActivateFn = (route: ActivatedRouteSnapshot, state) =
// Fonction utilitaire pour extraire le chemin du module
function getModulePath(route: ActivatedRouteSnapshot): string {
// Chercher le module dans les data de la route actuelle ou parente
let currentRoute: ActivatedRouteSnapshot | null = route;
while (currentRoute) {
// Si le module est défini dans les data, l'utiliser
if (currentRoute.data['module']) {
return currentRoute.data['module'];
}
currentRoute = currentRoute.parent;
}
// Fallback: construire le chemin à partir des segments d'URL
return buildPathFromUrl(route);
}
function buildPathFromUrl(route: ActivatedRouteSnapshot): string {
const segments: string[] = [];
let currentRoute: ActivatedRouteSnapshot | null = route;
// Parcourir jusqu'à la racine
while (currentRoute) {
if (currentRoute.url.length > 0) {
segments.unshift(...currentRoute.url.map(segment => segment.path));
}
currentRoute = currentRoute.firstChild;
currentRoute = currentRoute.parent;
}
return segments.join('/');

View File

@ -82,9 +82,9 @@ export class MenuService {
icon: 'lucideStore',
isCollapsed: true,
children: [
{ label: 'Liste des Marchands', url: '/merchants/list' },
{ label: 'Gestion des Marchands', url: '/merchants/list' },
{ label: 'Configuration API / Webhooks', url: '/merchants/config' },
{ label: 'Statistiques & Historique', url: '/merchants/history' },
{ label: 'Statistiques & Historique', url: '/merchants/stats' },
],
},
{

View File

@ -24,11 +24,12 @@ export class PermissionsService {
// Merchants
{
module: 'merchants',
roles: ['admin', 'merchant'],
roles: ['admin', 'merchant', 'support'],
children: {
'list': ['admin'],
'details': ['admin', 'merchant', 'support'],
'config': ['admin', 'merchant'],
'history': ['admin', 'merchant']
'stats': ['admin', 'merchant']
}
},

View File

@ -0,0 +1,44 @@
import { Component } from '@angular/core';
import { NgIconComponent } from '@ng-icons/core';
import { NgbProgressbarModule } from '@ng-bootstrap/ng-bootstrap';
import { CountUpModule } from 'ngx-countup';
@Component({
selector: 'app-active-subscriptions',
imports: [NgIconComponent, NgbProgressbarModule, CountUpModule],
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">Abonnements Actifs</h5>
<h3 class="mb-0 fw-normal">
<span [countUp]="12543" data-target="12543">12,543</span>
</h3>
<p class="text-muted mb-2">Total abonnements</p>
</div>
<div>
<ng-icon name="lucideUsers" class="text-info fs-24 svg-sw-10" />
</div>
</div>
<ngb-progressbar [value]="82" class="progress-lg mb-3" />
<div class="d-flex justify-content-between">
<div>
<span class="text-muted">Nouveaux</span>
<h5 class="mb-0">156</h5>
</div>
<div class="text-end">
<span class="text-muted">Renouvellements</span>
<h5 class="mb-0">89%</h5>
</div>
</div>
</div>
<div class="card-footer text-muted text-center">
52 nouveaux abonnements aujourd'hui
</div>
</div>
`,
})
export class ActiveSubscriptions {}

View File

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

View File

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

View File

@ -0,0 +1,127 @@
import { Component } from '@angular/core';
import { NgIconComponent } from '@ng-icons/core';
import { CountUpModule } from 'ngx-countup';
@Component({
selector: 'app-payment-stats',
imports: [NgIconComponent, CountUpModule],
template: `
<div class="card">
<div class="card-header bg-light">
<h5 class="card-title mb-0">Statistiques des Paiements</h5>
</div>
<div class="card-body">
<div class="row g-3">
<!-- Journalier -->
<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">Journalier</h6>
<h4 class="fw-bold mb-1">
<span [countUp]="342">342</span>
</h4>
<p class="text-muted mb-1">Transactions</p>
<div class="d-flex justify-content-between small">
<span>245K XOF</span>
<span class="text-success">
<ng-icon name="lucideTrendingUp" class="me-1 fs-12" />
98.2%
</span>
</div>
</div>
</div>
</div>
<!-- Hebdomadaire -->
<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">Hebdomadaire</h6>
<h4 class="fw-bold mb-1">
<span [countUp]="2150">2,150</span>
</h4>
<p class="text-muted mb-1">Transactions</p>
<div class="d-flex justify-content-between small">
<span>1.58M XOF</span>
<span class="text-success">
<ng-icon name="lucideTrendingUp" class="me-1 fs-12" />
97.8%
</span>
</div>
</div>
</div>
</div>
<!-- Mensuel -->
<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">Mensuel</h6>
<h4 class="fw-bold mb-1">
<span [countUp]="8450">8,450</span>
</h4>
<p class="text-muted mb-1">Transactions</p>
<div class="d-flex justify-content-between small">
<span>6.25M XOF</span>
<span class="text-warning">
<ng-icon name="lucideTrendingDown" class="me-1 fs-12" />
96.5%
</span>
</div>
</div>
</div>
</div>
<!-- Annuel -->
<div class="col-md-6 col-xl-3">
<div class="card border-warning border-2">
<div class="card-body text-center p-3">
<ng-icon name="lucideCalendar" class="text-warning fs-20 mb-2" />
<h6 class="text-warning mb-2">Annuel</h6>
<h4 class="fw-bold mb-1">
<span [countUp]="12500">12,500</span>
</h4>
<p class="text-muted mb-1">Transactions</p>
<div class="d-flex justify-content-between small">
<span>9.85M XOF</span>
<span class="text-success">
<ng-icon name="lucideTrendingUp" class="me-1 fs-12" />
95.2%
</span>
</div>
</div>
</div>
</div>
</div>
<!-- Résumé global -->
<div class="row mt-3">
<div class="col-12">
<div class="alert alert-light mb-0">
<div class="d-flex justify-content-between align-items-center">
<div>
<strong>Performance globale:</strong>
<span class="text-success">97.4% de taux de succès</span>
</div>
<div class="text-muted small">
Dernière mise à jour: {{ getCurrentTime() }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`,
})
export class PaymentStats {
getCurrentTime(): string {
return new Date().toLocaleTimeString('fr-FR', {
hour: '2-digit',
minute: '2-digit'
});
}
}

View File

@ -0,0 +1,190 @@
import { Component } from '@angular/core';
import { NgIconComponent } from '@ng-icons/core';
import { DecimalPipe } from '@angular/common';
import { NgbDropdownModule, NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap';
interface Transaction {
id: string;
user: string;
operator: string;
amount: number;
status: 'success' | 'pending' | 'failed';
date: string;
}
@Component({
selector: 'app-recent-transactions',
imports: [
NgIconComponent,
DecimalPipe,
NgbPaginationModule,
NgbDropdownModule,
],
template: `
<div class="card">
<div class="card-header justify-content-between align-items-center border-dashed">
<h4 class="card-title mb-0">Transactions Récentes</h4>
<a href="javascript:void(0);" class="btn btn-sm btn-primary">
<ng-icon name="lucideFileExport" class="me-1" />
Exporter
</a>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-centered table-custom table-sm table-nowrap table-hover mb-0">
<thead class="table-light">
<tr>
<th>Transaction</th>
<th>Utilisateur</th>
<th>Opérateur</th>
<th>Montant</th>
<th>Statut</th>
<th style="width: 30px;"></th>
</tr>
</thead>
<tbody>
@for (transaction of transactions; track transaction.id) {
<tr>
<td>
<span class="text-muted fs-xs">#{{ transaction.id }}</span>
</td>
<td>
<span class="fw-semibold">{{ transaction.user }}</span>
</td>
<td>
<span class="badge bg-light text-dark">{{ transaction.operator }}</span>
</td>
<td>
<span class="fw-semibold">{{ transaction.amount | number }} XOF</span>
</td>
<td>
<span [class]="getStatusClass(transaction.status)" class="badge">
{{ getStatusText(transaction.status) }}
</span>
</td>
<td>
<div ngbDropdown placement="bottom-end">
<a href="javascript:void(0);" ngbDropdownToggle class="text-muted drop-arrow-none card-drop p-0">
<ng-icon name="lucideMoreVertical" class="fs-lg" />
</a>
<div class="dropdown-menu-end" ngbDropdownMenu>
<a href="javascript:void(0)" ngbDropdownItem>Voir détails</a>
<a href="javascript:void(0)" ngbDropdownItem>Rembourser</a>
</div>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
<div class="card-footer border-0">
<div class="align-items-center justify-content-between row text-center text-sm-start">
<div class="col-sm">
<div class="text-muted">
Affichage de <span class="fw-semibold">1</span> à
<span class="fw-semibold">8</span> sur
<span class="fw-semibold">156</span> Transactions
</div>
</div>
<div class="col-sm-auto mt-3 mt-sm-0">
<ngb-pagination
[pageSize]="8"
[collectionSize]="156"
class="pagination-sm pagination-boxed mb-0 justify-content-center"
/>
</div>
</div>
</div>
</div>
`,
})
export class RecentTransactions {
transactions: Transaction[] = [
{
id: 'TX-5001',
user: 'Alice Koné',
operator: 'Orange',
amount: 2500,
status: 'success',
date: '2024-10-28 14:30'
},
{
id: 'TX-5002',
user: 'David Sarr',
operator: 'MTN',
amount: 1500,
status: 'success',
date: '2024-10-28 14:25'
},
{
id: 'TX-5003',
user: 'Sophia Diop',
operator: 'Airtel',
amount: 3000,
status: 'pending',
date: '2024-10-28 14:20'
},
{
id: 'TX-5004',
user: 'James Traoré',
operator: 'Moov',
amount: 2000,
status: 'success',
date: '2024-10-28 14:15'
},
{
id: 'TX-5005',
user: 'Ava Camara',
operator: 'Orange',
amount: 1000,
status: 'failed',
date: '2024-10-28 14:10'
},
{
id: 'TX-5006',
user: 'Ethan Diallo',
operator: 'MTN',
amount: 3500,
status: 'success',
date: '2024-10-28 14:05'
},
{
id: 'TX-5007',
user: 'Mia Bah',
operator: 'Orange',
amount: 1800,
status: 'success',
date: '2024-10-28 14:00'
},
{
id: 'TX-5008',
user: 'Lucas Keita',
operator: 'Airtel',
amount: 2200,
status: 'pending',
date: '2024-10-28 13:55'
}
];
getStatusClass(status: string): string {
switch (status) {
case 'success': return 'bg-success';
case 'pending': return 'bg-warning';
case 'failed': return 'bg-danger';
default: return 'bg-secondary';
}
}
getStatusText(status: string): string {
switch (status) {
case 'success': return 'Succès';
case 'pending': return 'En cours';
case 'failed': return 'Échec';
default: return 'Inconnu';
}
}
}

View File

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

View File

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

View File

@ -1,338 +1,51 @@
<div class="container-fluid">
<app-page-title
title="Dashboard DCB"
subTitle="Monitoring en temps réel des paiements mobiles"
[badge]="{icon:'lucideSmartphone', text:'Direct Carrier Billing'}"
title="Payment Aggregation Hub"
subTitle="Monitoring en temps réel des paiements mobiles DCB"
[badge]="{icon: 'lucideSmartphone', text: 'Direct Carrier Billing'}"
/>
<!-- Contenu du dashboard -->
<div class="container-fluid dcb-dashboard">
<!-- En-tête avec contrôles -->
<div class="row mb-4">
<div class="row g-4 mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<div>
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item active">Direct Carrier Billing</li>
</ol>
</nav>
</div>
<div class="d-flex align-items-center gap-3">
<!-- Dernière mise à jour -->
@if (lastUpdated) {
<small class="text-muted">
MAJ: {{ lastUpdated | date:'HH:mm:ss' }}
</small>
}
<!-- Auto-refresh -->
<div class="form-check form-switch">
<input
class="form-check-input"
type="checkbox"
id="autoRefresh"
[(ngModel)]="autoRefresh"
(change)="onAutoRefreshToggle()"
>
<label class="form-check-label small" for="autoRefresh">
Auto-refresh
</label>
</div>
<!-- Bouton refresh manuel -->
<button
class="btn btn-outline-primary btn-sm"
(click)="onRefresh()"
[disabled]="loading"
>
<ng-icon name="lucideRefreshCw" [class.spin]="loading"></ng-icon>
</button>
<!-- Filtres de période -->
<div class="btn-group">
<button
*ngFor="let range of timeRanges"
class="btn btn-outline-primary btn-sm"
[class.active]="timeRange === range"
(click)="onTimeRangeChange(range)"
>
{{ range }}
</button>
</div>
</div>
</div>
<app-payment-stats />
</div>
</div>
<!-- Messages d'alerte -->
@if (error) {
<div class="alert alert-danger d-flex align-items-center">
<ng-icon name="lucideAlertCircle" class="me-2"></ng-icon>
<div class="flex-grow-1">{{ error }}</div>
<button class="btn-close" (click)="error = ''"></button>
</div>
}
<!-- KPI Cards -->
<div class="row row-cols-xxl-4 row-cols-md-2 row-cols-1 g-3 mb-4">
<!-- Loading State -->
@if (loading && !analytics) {
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
<p class="mt-2 text-muted">Chargement des données DCB...</p>
</div>
}
@if (!loading && analytics) {
<!-- KPI Cards -->
<div class="row mb-4">
<!-- Chiffre d'affaires total -->
<div class="col-xl-3 col-md-6 mb-3">
<div class="card kpi-card h-100">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-grow-1">
<span class="text-muted text-uppercase small fw-semibold">Chiffre d'Affaires</span>
<h3 class="mt-2 mb-1 text-primary">{{ formatCurrency(analytics.totalRevenue) }}</h3>
<div class="d-flex align-items-center mt-2">
<span [class]="getGrowthClass(analytics.monthlyGrowth)" class="fw-semibold">
<ng-icon [name]="getGrowthIcon(analytics.monthlyGrowth)" class="me-1"></ng-icon>
{{ analytics.monthlyGrowth > 0 ? '+' : '' }}{{ analytics.monthlyGrowth }}%
</span>
<span class="text-muted small ms-2">vs mois dernier</span>
</div>
</div>
<div class="flex-shrink-0">
<div class="kpi-icon bg-primary">
<ng-icon name="lucideEuro"></ng-icon>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Transactions totales -->
<div class="col-xl-3 col-md-6 mb-3">
<div class="card kpi-card h-100">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-grow-1">
<span class="text-muted text-uppercase small fw-semibold">Transactions</span>
<h3 class="mt-2 mb-1 text-success">{{ formatNumber(analytics.totalTransactions) }}</h3>
<div class="d-flex align-items-center mt-2">
<span class="text-success fw-semibold">
{{ formatPercentage(analytics.successRate) }}
</span>
<span class="text-muted small ms-2">taux de succès</span>
</div>
</div>
<div class="flex-shrink-0">
<div class="kpi-icon bg-success">
<ng-icon name="lucideCreditCard"></ng-icon>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Montant moyen -->
<div class="col-xl-3 col-md-6 mb-3">
<div class="card kpi-card h-100">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-grow-1">
<span class="text-muted text-uppercase small fw-semibold">Montant Moyen</span>
<h3 class="mt-2 mb-1 text-info">{{ formatCurrency(analytics.averageAmount) }}</h3>
<div class="text-muted small mt-2">par transaction</div>
</div>
<div class="flex-shrink-0">
<div class="kpi-icon bg-info">
<ng-icon name="lucideBarChart3"></ng-icon>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Aujourd'hui -->
<div class="col-xl-3 col-md-6 mb-3">
<div class="card kpi-card h-100">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-grow-1">
<span class="text-muted text-uppercase small fw-semibold">Aujourd'hui</span>
<h3 class="mt-2 mb-1 text-warning">{{ formatCurrency(analytics.todayStats.revenue) }}</h3>
<div class="text-muted small mt-2">
{{ analytics.todayStats.transactions }} transactions
</div>
</div>
<div class="flex-shrink-0">
<div class="kpi-icon bg-warning">
<ng-icon name="lucideTrendingUp"></ng-icon>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col">
<app-active-subscriptions />
</div>
<!-- Deuxième ligne : Statistiques détaillées -->
<div class="row mb-4">
<!-- Performances opérateurs -->
<div class="col-xl-8 mb-4">
<div class="card h-100">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">Performances par Opérateur</h5>
<span class="badge bg-primary">{{ operators.length }} opérateurs</span>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover table-sm mb-0">
<thead>
<tr>
<th>Opérateur</th>
<th>Pays</th>
<th>Statut</th>
<th>Taux de Succès</th>
<th>Progression</th>
</tr>
</thead>
<tbody>
@for (operator of operators; track operator.id) {
<tr>
<td class="fw-semibold">{{ operator.name }}</td>
<td>
<span class="badge bg-light text-dark">{{ operator.country }}</span>
</td>
<td>
<span [class]="getOperatorStatusClass(operator.status)" class="badge">
{{ operator.status === 'ACTIVE' ? 'Actif' : 'Inactif' }}
</span>
</td>
<td>
<span [class]="getSuccessRateClass(operator.successRate)" class="fw-semibold">
{{ formatPercentage(operator.successRate) }}
</span>
</td>
<td>
<div class="d-flex align-items-center">
<div class="flex-grow-1 me-2">
<ngb-progressbar
[value]="operator.successRate"
[max]="100"
[class]="getProgressBarClass(operator.successRate)"
class="progress-sm"
></ngb-progressbar>
</div>
<small class="text-muted">{{ operator.successRate }}%</small>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Top opérateurs par revenu -->
<div class="col-xl-4 mb-4">
<div class="card h-100">
<div class="card-header bg-light">
<h5 class="card-title mb-0">Top Opérateurs - Revenus</h5>
</div>
<div class="card-body">
@for (operator of analytics.topOperators; track operator.operator; let i = $index) {
<div class="d-flex align-items-center mb-3 pb-2 border-bottom">
<div class="flex-shrink-0 me-3">
<div class="rank-badge bg-primary text-white">{{ i + 1 }}</div>
</div>
<div class="flex-grow-1">
<h6 class="mb-1">{{ operator.operator }}</h6>
<div class="d-flex justify-content-between align-items-center">
<span class="text-muted small">{{ operator.count }} trans.</span>
<span class="fw-semibold text-primary">
{{ formatCurrency(operator.revenue) }}
</span>
</div>
</div>
</div>
}
</div>
</div>
</div>
<div class="col">
<app-operator-performance />
</div>
<!-- Transactions récentes -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">Transactions Récentes</h5>
<a href="javascript:void(0)" class="btn btn-outline-primary btn-sm">
Voir toutes les transactions
</a>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>MSISDN</th>
<th>Opérateur</th>
<th>Produit</th>
<th>Montant</th>
<th>Statut</th>
<th>Date</th>
</tr>
</thead>
<tbody>
@for (transaction of recentTransactions; track transaction.id) {
<tr>
<td class="fw-medium font-monospace">{{ transaction.msisdn }}</td>
<td>
<span class="badge bg-light text-dark">{{ transaction.operator }}</span>
</td>
<td class="text-truncate" style="max-width: 150px;"
[ngbTooltip]="transaction.productName">
{{ transaction.productName }}
</td>
<td class="fw-bold text-primary">
{{ formatCurrency(transaction.amount, transaction.currency) }}
</td>
<td>
<span [class]="getStatusBadgeClass(transaction.status)" class="badge">
<ng-icon [name]="getStatusIcon(transaction.status)" class="me-1"></ng-icon>
{{ transaction.status }}
</span>
</td>
<td class="text-muted small">
{{ transaction.transactionDate | date:'dd/MM/yy HH:mm' }}
</td>
</tr>
}
@empty {
<tr>
<td colspan="6" class="text-center py-4 text-muted">
<ng-icon name="lucideCreditCard" class="fs-1 mb-2 d-block"></ng-icon>
Aucune transaction récente
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-xxl-4">
<app-alert-widget />
</div>
<div class="col">
<app-system-health />
</div>
}
</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>

View File

@ -1,198 +1,25 @@
import { Component, inject, OnInit, OnDestroy, ChangeDetectorRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { NgIcon, provideNgIconsConfig } from '@ng-icons/core';
import { Component } from '@angular/core';
import { PageTitle } from '@app/components/page-title/page-title';
import {
lucideEuro,
lucideCreditCard,
lucideBarChart3,
lucideSmartphone,
lucideTrendingUp,
lucideTrendingDown,
lucideAlertCircle,
lucideCheckCircle,
lucideClock,
lucideXCircle,
lucideRefreshCw
} from '@ng-icons/lucide';
import { NgbAlertModule, NgbProgressbarModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
import { DcbService } from './services/dcb.service';
import { DcbAnalytics, TransactionStats, DcbTransaction, DcbOperator } from './models/dcb';
// Type pour les plages de temps
type TimeRange = '24h' | '7d' | '30d' | '90d';
import { PaymentStats } from './components/payment-stats';
import { ActiveSubscriptions } from './components/active-subscriptions';
import { RevenueChart } from './components/revenue-chart';
import { OperatorPerformance } from './components/operator-performance';
import { RecentTransactions } from './components/recent-transactions';
import { SystemHealth } from './components/system-health';
import { AlertWidget } from './components/alert-widget';
@Component({
selector: 'app-dcb-dashboard',
standalone: true,
imports: [
CommonModule,
FormsModule,
NgIcon,
NgbAlertModule,
NgbProgressbarModule,
NgbTooltipModule,
PageTitle
PageTitle,
PaymentStats,
ActiveSubscriptions,
RevenueChart,
OperatorPerformance,
RecentTransactions,
SystemHealth,
AlertWidget,
],
providers: [
provideNgIconsConfig({
size: '1.25em'
})
],
templateUrl: './dcb-dashboard.html'
templateUrl: './dcb-dashboard.html',
})
export class DcbDashboard implements OnInit, OnDestroy {
private dcbService = inject(DcbService);
private cdRef = inject(ChangeDetectorRef);
// États
loading = false;
error = '';
lastUpdated: Date | null = null;
// Données
analytics: DcbAnalytics | null = null;
stats: TransactionStats | null = null;
recentTransactions: DcbTransaction[] = [];
operators: DcbOperator[] = [];
// Filtres
timeRange: TimeRange = '7d';
autoRefresh = true;
private refreshInterval: any;
// Plages de temps disponibles (pour le template)
timeRanges: TimeRange[] = ['24h', '7d', '30d', '90d'];
ngOnInit() {
this.loadDashboardData();
this.startAutoRefresh();
}
ngOnDestroy() {
this.stopAutoRefresh();
}
startAutoRefresh() {
if (this.autoRefresh) {
this.refreshInterval = setInterval(() => {
this.loadDashboardData();
}, 30000); // Refresh toutes les 30 secondes
}
}
stopAutoRefresh() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
}
loadDashboardData() {
this.loading = true;
this.error = '';
// Charger toutes les données en parallèle
Promise.all([
this.dcbService.getAnalytics(this.timeRange).toPromise(),
this.dcbService.getTransactionStats().toPromise(),
this.dcbService.getRecentTransactions(8).toPromise(),
this.dcbService.getOperators().toPromise()
]).then(([analytics, stats, transactions, operators]) => {
this.analytics = analytics || null;
this.stats = stats || null;
this.recentTransactions = transactions || [];
this.operators = operators || [];
this.loading = false;
this.lastUpdated = new Date();
this.cdRef.detectChanges();
}).catch(error => {
this.error = 'Erreur lors du chargement des données du dashboard';
this.loading = false;
this.lastUpdated = new Date();
this.cdRef.detectChanges();
console.error('Dashboard loading error:', error);
});
}
onTimeRangeChange(range: TimeRange) {
this.timeRange = range;
this.loadDashboardData();
}
onRefresh() {
this.loadDashboardData();
}
onAutoRefreshToggle() {
this.autoRefresh = !this.autoRefresh;
if (this.autoRefresh) {
this.startAutoRefresh();
} else {
this.stopAutoRefresh();
}
}
// Utilitaires d'affichage
formatCurrency(amount: number, currency: string = 'EUR'): string {
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: currency
}).format(amount);
}
formatNumber(num: number): string {
return new Intl.NumberFormat('fr-FR').format(num);
}
formatPercentage(value: number): string {
return `${value.toFixed(1)}%`;
}
getStatusBadgeClass(status: string): string {
switch (status) {
case 'SUCCESS': return 'badge bg-success';
case 'PENDING': return 'badge bg-warning';
case 'FAILED': return 'badge bg-danger';
case 'REFUNDED': return 'badge bg-info';
default: return 'badge bg-secondary';
}
}
getStatusIcon(status: string): string {
switch (status) {
case 'SUCCESS': return 'lucideCheckCircle';
case 'PENDING': return 'lucideClock';
case 'FAILED': return 'lucideXCircle';
case 'REFUNDED': return 'lucideRefreshCw';
default: return 'lucideAlertCircle';
}
}
getGrowthClass(growth: number): string {
return growth >= 0 ? 'text-success' : 'text-danger';
}
getGrowthIcon(growth: number): string {
return growth >= 0 ? 'lucideTrendingUp' : 'lucideTrendingDown';
}
getOperatorStatusClass(status: string): string {
return status === 'ACTIVE' ? 'badge bg-success' : 'badge bg-secondary';
}
getSuccessRateClass(rate: number): string {
if (rate >= 90) return 'text-success';
if (rate >= 80) return 'text-warning';
return 'text-danger';
}
getProgressBarClass(rate: number): string {
if (rate >= 90) return 'bg-success';
if (rate >= 80) return 'bg-warning';
return 'bg-danger';
}
}
export class DcbDashboard {}

View File

@ -0,0 +1,50 @@
export interface KpiCardModel {
title: string;
value: number | string;
change?: number;
changeType?: 'positive' | 'negative' | 'neutral';
icon: string;
color: string;
format?: 'currency' | 'number' | 'percentage';
currency?: string;
}
export interface PaymentChartData {
period: string;
successful: number;
failed: number;
pending: number;
revenue: number;
}
export interface SubscriptionStatsModel {
total: number;
active: number;
trial: number;
cancelled: number;
expired: number;
growthRate: number;
}
export interface Alert {
id: string;
type: 'error' | 'warning' | 'info' | 'success';
title: string;
message: string;
timestamp: Date;
acknowledged: boolean;
priority: 'low' | 'medium' | 'high';
action?: {
label: string;
route?: string;
action?: () => void;
};
}
export interface DcbDashboardData {
kpis: KpiCardModel[];
paymentChart: PaymentChartData[];
subscriptionStats: SubscriptionStatsModel;
alerts: Alert[];
lastUpdated: Date;
}

View File

@ -1,53 +0,0 @@
export interface DcbTransaction {
id: string;
msisdn: string;
operator: string;
country: string;
amount: number;
currency: string;
status: TransactionStatus;
productId?: string;
productName: string;
transactionDate: Date;
createdAt: Date;
errorCode?: string;
errorMessage?: string;
}
export interface DcbAnalytics {
totalRevenue: number;
totalTransactions: number;
successRate: number;
averageAmount: number;
topOperators: { operator: string; revenue: number; count: number }[];
dailyStats: { date: string; revenue: number; transactions: number }[];
monthlyGrowth: number;
todayStats: {
revenue: number;
transactions: number;
successCount: number;
failedCount: number;
};
}
export interface TransactionStats {
pending: number;
successful: number;
failed: number;
refunded: number;
}
export type TransactionStatus =
| 'PENDING'
| 'SUCCESS'
| 'FAILED'
| 'REFUNDED';
export interface DcbOperator {
id: string;
name: string;
code: string;
country: string;
status: 'ACTIVE' | 'INACTIVE';
successRate: number;
}

View File

@ -0,0 +1,133 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { map, catchError } from 'rxjs/operators';
import { DcbDashboardData, KpiCardModel, PaymentChartData, SubscriptionStatsModel, Alert } from '../models/dcb-dashboard.models';
import { environment } from '@environments/environment';
@Injectable({ providedIn: 'root' })
export class DcbDashboardService {
private http = inject(HttpClient);
private apiUrl = `${environment.apiUrl}/dcb-dashboard`;
// Données mockées pour le développement
private mockData: DcbDashboardData = {
kpis: [
{
title: 'Revenue Total',
value: 125430,
change: 12.5,
changeType: 'positive',
icon: 'lucideTrendingUp',
color: 'success',
format: 'currency',
currency: 'XOF'
},
{
title: 'Paiements Today',
value: 342,
change: -2.3,
changeType: 'negative',
icon: 'lucideCreditCard',
color: 'primary',
format: 'number'
},
{
title: 'Taux de Succès',
value: 98.2,
change: 1.2,
changeType: 'positive',
icon: 'lucideCheckCircle',
color: 'info',
format: 'percentage'
},
{
title: 'Abonnements Actifs',
value: 12543,
change: 8.7,
changeType: 'positive',
icon: 'lucideUsers',
color: 'warning',
format: 'number'
}
],
paymentChart: [
{ period: 'Jan', successful: 12000, failed: 300, pending: 500, revenue: 4500000 },
{ period: 'Fév', successful: 15000, failed: 250, pending: 400, revenue: 5200000 },
{ period: 'Mar', successful: 18000, failed: 200, pending: 300, revenue: 6100000 },
{ period: 'Avr', successful: 22000, failed: 150, pending: 250, revenue: 7500000 },
{ period: 'Mai', successful: 25000, failed: 100, pending: 200, revenue: 8900000 },
{ period: 'Jun', successful: 30000, failed: 80, pending: 150, revenue: 10500000 }
],
subscriptionStats: {
total: 15600,
active: 12543,
trial: 1850,
cancelled: 857,
expired: 350,
growthRate: 8.7
},
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()
};
getDcbDashboardData(): Observable<DcbDashboardData> {
// En production, utiliser l'API réelle
if (environment.production) {
return this.http.get<DcbDashboardData>(this.apiUrl).pipe(
catchError(() => of(this.mockData)) // Fallback sur les données mockées en cas d'erreur
);
}
// En développement, retourner les données mockées
return of(this.mockData);
}
acknowledgeAlert(alertId: string): Observable<{ success: boolean }> {
// Simuler l'acknowledgement
const alert = this.mockData.alerts.find(a => a.id === alertId);
if (alert) {
alert.acknowledged = true;
}
return of({ success: true });
}
refreshData(): Observable<DcbDashboardData> {
// Simuler un rafraîchissement des données
this.mockData.lastUpdated = new Date();
return of(this.mockData);
}
}

View File

@ -1,144 +0,0 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { environment } from '@environments/environment';
import { Observable, map, catchError, of } from 'rxjs';
import {
DcbTransaction,
DcbAnalytics,
TransactionStats,
DcbOperator
} from '../models/dcb';
@Injectable({ providedIn: 'root' })
export class DcbService {
private http = inject(HttpClient);
private apiUrl = `${environment.apiUrl}/dcb`;
// === ANALYTICS & DASHBOARD ===
getAnalytics(timeRange: string = '7d'): Observable<DcbAnalytics> {
return this.http.get<DcbAnalytics>(`${this.apiUrl}/analytics?range=${timeRange}`).pipe(
catchError(error => {
console.error('Error loading analytics:', error);
// Retourner des données mockées en cas d'erreur
return of(this.getMockAnalytics());
})
);
}
getTransactionStats(): Observable<TransactionStats> {
return this.http.get<TransactionStats>(`${this.apiUrl}/analytics/stats`).pipe(
catchError(error => {
console.error('Error loading transaction stats:', error);
return of({
pending: 0,
successful: 0,
failed: 0,
refunded: 0
});
})
);
}
getRecentTransactions(limit: number = 10): Observable<DcbTransaction[]> {
return this.http.get<DcbTransaction[]>(`${this.apiUrl}/transactions/recent?limit=${limit}`).pipe(
catchError(error => {
console.error('Error loading recent transactions:', error);
return of(this.getMockTransactions());
})
);
}
getOperators(): Observable<DcbOperator[]> {
return this.http.get<DcbOperator[]>(`${this.apiUrl}/operators`).pipe(
catchError(error => {
console.error('Error loading operators:', error);
return of(this.getMockOperators());
})
);
}
// Données mockées pour le développement
private getMockAnalytics(): DcbAnalytics {
return {
totalRevenue: 125430.50,
totalTransactions: 2847,
successRate: 87.5,
averageAmount: 44.07,
monthlyGrowth: 12.3,
todayStats: {
revenue: 3420.75,
transactions: 78,
successCount: 68,
failedCount: 10
},
topOperators: [
{ operator: 'Orange', revenue: 45210.25, count: 1024 },
{ operator: 'Free', revenue: 38150.75, count: 865 },
{ operator: 'SFR', revenue: 22470.50, count: 512 },
{ operator: 'Bouygues', revenue: 19598.00, count: 446 }
],
dailyStats: [
{ date: '2024-01-01', revenue: 3420.75, transactions: 78 },
{ date: '2024-01-02', revenue: 3985.25, transactions: 91 },
{ date: '2024-01-03', revenue: 3125.50, transactions: 71 },
{ date: '2024-01-04', revenue: 4250.00, transactions: 96 },
{ date: '2024-01-05', revenue: 3875.25, transactions: 88 },
{ date: '2024-01-06', revenue: 2980.75, transactions: 68 },
{ date: '2024-01-07', revenue: 4125.50, transactions: 94 }
]
};
}
private getMockTransactions(): DcbTransaction[] {
return [
{
id: '1',
msisdn: '+33612345678',
operator: 'Orange',
country: 'FR',
amount: 4.99,
currency: 'EUR',
status: 'SUCCESS',
productName: 'Premium Content',
transactionDate: new Date('2024-01-07T14:30:00'),
createdAt: new Date('2024-01-07T14:30:00')
},
{
id: '2',
msisdn: '+33798765432',
operator: 'Free',
country: 'FR',
amount: 2.99,
currency: 'EUR',
status: 'PENDING',
productName: 'Basic Subscription',
transactionDate: new Date('2024-01-07T14:25:00'),
createdAt: new Date('2024-01-07T14:25:00')
},
{
id: '3',
msisdn: '+33687654321',
operator: 'SFR',
country: 'FR',
amount: 9.99,
currency: 'EUR',
status: 'FAILED',
productName: 'Pro Package',
transactionDate: new Date('2024-01-07T14:20:00'),
createdAt: new Date('2024-01-07T14:20:00'),
errorCode: 'INSUFFICIENT_FUNDS',
errorMessage: 'Solde insuffisant'
}
];
}
private getMockOperators(): DcbOperator[] {
return [
{ id: '1', name: 'Orange', code: 'ORANGE', country: 'FR', status: 'ACTIVE', successRate: 92.5 },
{ id: '2', name: 'Free', code: 'FREE', country: 'FR', status: 'ACTIVE', successRate: 88.2 },
{ id: '3', name: 'SFR', code: 'SFR', country: 'FR', status: 'ACTIVE', successRate: 85.7 },
{ id: '4', name: 'Bouygues', code: 'BOUYGTEL', country: 'FR', status: 'ACTIVE', successRate: 83.9 }
];
}
}

View File

@ -1,70 +0,0 @@
<!-- src/app/modules/merchants/config/config.html -->
<app-ui-card title="Configuration du Marchand">
<form card-body class="row g-3" #configForm="ngForm">
<!-- Clés API avec Input Groups -->
<div class="col-12">
<h5 class="border-bottom pb-2">Clés d'API</h5>
</div>
<div class="col-lg-6">
<app-input-groups />
</div>
<div class="col-lg-6">
<app-input-groups />
</div>
<!-- URLs de Callback avec Floating Labels -->
<div class="col-12">
<h5 class="border-bottom pb-2 mt-4">URLs de Callback</h5>
</div>
<div class="col-12">
<app-floating-labels />
</div>
<!-- Configuration Avancée -->
<div class="col-12">
<h5 class="border-bottom pb-2 mt-4">Configuration Avancée</h5>
</div>
<div class="col-lg-4">
<label class="form-label">Limite de débit</label>
<app-input-fields />
</div>
<div class="col-lg-8">
<label class="form-label">Notifications</label>
<app-checkboxes-and-radios />
</div>
<!-- IPs Autorisées -->
<div class="col-12">
<label class="form-label">IPs Autorisées</label>
@for (ip of config.allowedIPs; track $index; let i = $index) {
<div class="mb-2">
<app-input-groups />
</div>
}
<button type="button" class="btn btn-outline-primary btn-sm" (click)="addIP()">
+ Ajouter une IP
</button>
</div>
<!-- Actions -->
<div class="col-12">
<div class="d-flex gap-2 mt-4">
<button type="button" class="btn btn-primary" (click)="saveConfig()">
Sauvegarder la Configuration
</button>
<button type="button" class="btn btn-outline-success" (click)="testWebhook()">
Tester Webhook
</button>
<button type="button" class="btn btn-outline-warning" (click)="regenerateKeys()">
Régénérer les Clés
</button>
</div>
</div>
</form>
</app-ui-card>

View File

@ -1,2 +0,0 @@
import { MerchantsConfig } from './config';
describe('MerchantsConfig', () => {});

View File

@ -1,57 +0,0 @@
import { Component, Input } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { UiCard } from '@app/components/ui-card';
import { InputFields } from '@/app/modules/components/input-fields';
import { InputGroups } from '@/app/modules/components/input-groups';
import { CheckboxesAndRadios } from '@/app/modules/components/checkboxes-and-radios';
import { FloatingLabels } from '@/app/modules/components/floating-labels';
@Component({
selector: 'app-config',
standalone: true,
imports: [FormsModule, UiCard, InputFields, InputGroups, CheckboxesAndRadios, FloatingLabels],
templateUrl: './config.html',
})
export class MerchantsConfig {
@Input() merchantId: string = '';
config = {
apiKey: '',
secretKey: '',
webhookUrl: '',
callbackUrls: {
paymentSuccess: '',
paymentFailed: '',
subscriptionCreated: '',
subscriptionCancelled: ''
},
allowedIPs: [''],
rateLimit: 100,
isActive: true,
enableSMS: true,
enableEmail: false
};
addIP() {
this.config.allowedIPs.push('');
}
removeIP(index: number) {
this.config.allowedIPs.splice(index, 1);
}
saveConfig() {
console.log('Saving config:', this.config);
// Logique de sauvegarde
}
testWebhook() {
console.log('Testing webhook:', this.config.webhookUrl);
// Logique de test
}
regenerateKeys() {
console.log('Regenerating API keys');
// Logique de régénération
}
}

View File

@ -1 +0,0 @@
<p>Merchants - History</p>

View File

@ -1,2 +0,0 @@
import { MerchantsHistory } from './history';
describe('MerchantsHistory', () => {});

View File

@ -1,7 +0,0 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-history',
templateUrl: './history.html',
})
export class MerchantsHistory {}

View File

@ -1 +0,0 @@
<p>Merchants - List</p>

View File

@ -1,2 +0,0 @@
import { MerchantsList } from './list';
describe('MerchantsList', () => {});

View File

@ -1,7 +0,0 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-list',
templateUrl: './list.html',
})
export class MerchantsList {}

View File

@ -1,13 +1,588 @@
<div class="container-fluid">
<app-page-title
title="Form Wizard"
subTitle="Guide users through multi-step forms with clear navigation and progress tracking for improved user experience."
[badge]="{icon:'lucideWandSparkles',text:'Multi-Step Forms'}"
title="Gestion des Merchants"
subTitle="Administrez vos partenaires marchands DCB - Liste, Configuration et Statistiques"
[badge]="{icon:'lucideStore', text:'Partner Management'}"
/>
<div class="row">
<!-- Navigation par onglets -->
<div class="row mb-4">
<div class="col-12">
<app-merchants />
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTab" class="nav nav-tabs nav-bordered">
<li [ngbNavItem]="'list'">
<a ngbNavLink (click)="onTabChange('list')">
<ng-icon name="lucideList" class="me-1"></ng-icon>
Liste des Merchants
</a>
<ng-template ngbNavContent>
<!-- CONTENU LISTE -->
<div class="row mb-4">
<div class="col-md-6">
<div class="d-flex gap-2">
<button class="btn btn-primary" (click)="activeTab = 'config'">
<ng-icon name="lucidePlus" class="me-1"></ng-icon>
Nouveau Merchant
</button>
<button class="btn btn-outline-secondary" (click)="loadMerchants()">
<ng-icon name="lucideRefreshCw" class="me-1"></ng-icon>
Actualiser
</button>
</div>
</div>
<div class="col-md-6">
<div class="row g-2">
<div class="col-md-4">
<input type="text" class="form-control" placeholder="Rechercher..."
[(ngModel)]="searchTerm" (input)="currentPage = 1">
</div>
<div class="col-md-4">
<select class="form-select" [(ngModel)]="statusFilter" (change)="currentPage = 1">
<option value="all">Tous les statuts</option>
<option value="ACTIVE">Actifs</option>
<option value="PENDING">En attente</option>
<option value="SUSPENDED">Suspendus</option>
</select>
</div>
<div class="col-md-4">
<select class="form-select" [(ngModel)]="countryFilter" (change)="currentPage = 1">
@for (country of countries; track country.code) {
<option [value]="country.code">{{ country.name }}</option>
}
</select>
</div>
</div>
</div>
</div>
<!-- Loading State -->
@if (loading) {
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
<p class="mt-2 text-muted">Chargement des merchants...</p>
</div>
}
<!-- Error State -->
@if (error && !loading) {
<div class="alert alert-danger">
<ng-icon name="lucideAlertCircle" class="me-2"></ng-icon>
{{ error }}
</div>
}
<!-- Tableau des Merchants -->
@if (!loading && !error) {
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-centered table-hover mb-0">
<thead class="table-light">
<tr>
<th>Merchant</th>
<th>Contact</th>
<th>Pays</th>
<th>Statut</th>
<th>Date création</th>
<th width="120">Actions</th>
</tr>
</thead>
<tbody>
@for (merchant of displayedMerchants; track merchant.partnerId) {
<tr>
<td>
<div class="d-flex align-items-center">
<div class="avatar-sm bg-primary rounded-circle d-flex align-items-center justify-content-center me-2">
<ng-icon name="lucideStore" class="text-white fs-14"></ng-icon>
</div>
<div>
<strong>{{ merchant.name }}</strong>
@if (merchant.companyInfo && merchant.companyInfo.legalName) {
<div class="text-muted small">{{ merchant.companyInfo.legalName }}</div>
}
</div>
</div>
</td>
<td>
<div>{{ merchant.email }}</div>
<small class="text-muted">{{ merchant.companyInfo?.address }}</small>
</td>
<td>
<span class="badge bg-light text-dark">{{ getCountryName(merchant.country) }}</span>
</td>
<td>
<span [class]="'badge ' + getStatusBadgeClass(merchant.status)">
{{ getStatusText(merchant.status) }}
</span>
</td>
<td>
<small class="text-muted">
{{ merchant.createdAt | date:'dd/MM/yyyy' }}
</small>
</td>
<td>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-primary" title="Voir détails">
<ng-icon name="lucideEye"></ng-icon>
</button>
@if (merchant.status === 'ACTIVE') {
<button class="btn btn-outline-warning"
(click)="suspendMerchant(merchant)"
title="Suspendre">
<ng-icon name="lucidePause"></ng-icon>
</button>
} @else if (merchant.status === 'SUSPENDED') {
<button class="btn btn-outline-success"
(click)="activateMerchant(merchant)"
title="Activer">
<ng-icon name="lucidePlay"></ng-icon>
</button>
}
<button class="btn btn-outline-info" title="API Keys">
<ng-icon name="lucideKey"></ng-icon>
</button>
</div>
</td>
</tr>
}
@empty {
<tr>
<td colspan="6" class="text-center py-4">
<ng-icon name="lucideStore" class="text-muted fs-1 mb-2"></ng-icon>
<p class="text-muted">Aucun merchant trouvé</p>
<button class="btn btn-primary" (click)="activeTab = 'config'">
Créer le premier merchant
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
<!-- Pagination -->
@if (filteredMerchants.length > 0) {
<div class="card-footer">
<div class="d-flex justify-content-between align-items-center">
<div class="text-muted">
Affichage de {{ (currentPage - 1) * itemsPerPage + 1 }} à
{{ mathMin(currentPage * itemsPerPage, filteredMerchants.length) }} sur
{{ filteredMerchants.length }} merchants
</div>
<ngb-pagination
[collectionSize]="filteredMerchants.length"
[page]="currentPage"
[pageSize]="itemsPerPage"
[maxSize]="5"
(pageChange)="onPageChange($event)"
/>
</div>
</div>
}
</div>
}
</ng-template>
</li>
<li [ngbNavItem]="'config'">
<a ngbNavLink (click)="onTabChange('config')">
<ng-icon name="lucideSettings" class="me-1"></ng-icon>
Configuration
</a>
<ng-template ngbNavContent>
<!-- CONTENU CONFIGURATION -->
<app-ui-card title="Création d'un Nouveau Merchant">
<span helper-text class="badge badge-soft-success badge-label fs-xxs py-1">
Payment Hub DCB
</span>
<div class="ins-wizard" card-body>
<!-- Progress Bar -->
<ngb-progressbar
class="mb-4"
[value]="progressValue"
type="primary"
height="6px"
/>
<!-- Navigation Steps -->
<ul class="nav nav-tabs wizard-tabs" role="tablist">
@for (step of wizardSteps; track $index; let i = $index) {
<li class="nav-item">
<a
href="javascript:void(0);"
[class.active]="i === currentStep"
class="nav-link"
[class]="i < currentStep ? 'wizard-item-done' : ''"
(click)="goToStep(i)"
>
<span class="d-flex align-items-center">
<ng-icon [name]="step.icon" class="fs-32" />
<span class="flex-grow-1 ms-2 text-truncate">
<span class="mb-0 lh-base d-block fw-semibold text-body fs-base">
{{ step.title }}
</span>
<span class="mb-0 fw-normal">{{ step.subtitle }}</span>
</span>
</span>
</a>
</li>
}
</ul>
<!-- Messages -->
@if (configError) {
<div class="alert alert-danger mt-3">
<ng-icon name="lucideAlertCircle" class="me-2"></ng-icon>
{{ configError }}
</div>
}
@if (configSuccess) {
<div class="alert alert-success mt-3">
<ng-icon name="lucideCheckCircle" class="me-2"></ng-icon>
{{ configSuccess }}
</div>
}
<!-- Contenu des Steps -->
<div class="tab-content pt-3">
@for (step of wizardSteps; track $index; let i = $index) {
<div
class="tab-pane fade"
[class.show]="currentStep === i"
[class.active]="currentStep === i"
>
<form [formGroup]="merchantForm">
<!-- Step 1: Informations Société -->
@if (i === 0) {
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Nom de l'entreprise *</label>
<div formGroupName="companyInfo">
<input type="text" class="form-control" formControlName="name"
placeholder="Nom commercial" />
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Nom légal *</label>
<div formGroupName="companyInfo">
<input type="text" class="form-control" formControlName="legalName"
placeholder="Raison sociale" />
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Numéro fiscal</label>
<div formGroupName="companyInfo">
<input type="text" class="form-control" formControlName="taxId"
placeholder="NIF/RC" />
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Pays *</label>
<div formGroupName="companyInfo">
<select class="form-select" formControlName="country">
@for (country of countries; track country.code) {
@if (country.code !== 'all') {
<option [value]="country.code">{{ country.name }}</option>
}
}
</select>
</div>
</div>
<div class="col-12 mb-3">
<label class="form-label">Adresse *</label>
<div formGroupName="companyInfo">
<textarea class="form-control" formControlName="address"
placeholder="Adresse complète" rows="2"></textarea>
</div>
</div>
</div>
}
<!-- Step 2: Contact Principal -->
@if (i === 1) {
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Email *</label>
<!-- CORRECTION : Ajouter formGroupName="contactInfo" -->
<div formGroupName="contactInfo">
<input type="email" class="form-control" formControlName="email"
placeholder="email@entreprise.com" />
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Téléphone *</label>
<div formGroupName="contactInfo">
<input type="tel" class="form-control" formControlName="phone"
placeholder="+225 XX XX XX XX" />
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Prénom *</label>
<div formGroupName="contactInfo">
<input type="text" class="form-control" formControlName="firstName"
placeholder="Prénom du contact" />
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Nom *</label>
<div formGroupName="contactInfo">
<input type="text" class="form-control" formControlName="lastName"
placeholder="Nom du contact" />
</div>
</div>
</div>
}
<!-- Dans la section Configuration - Step 3: Configuration Paiements -->
@if (i === 2) {
<div class="row">
<div class="col-12 mb-3">
<label class="form-label">Opérateurs supportés</label>
<!-- CORRECTION : Ajouter formGroupName="paymentConfig" -->
<div formGroupName="paymentConfig">
<div class="row">
@for (operator of operators; track operator; let idx = $index) {
<div class="col-md-3 mb-2">
<div class="form-check">
<input class="form-check-input" type="checkbox"
[formControl]="supportedOperatorsArray[idx]"
[id]="'operator-' + idx">
<label class="form-check-label" [for]="'operator-' + idx">
{{ operator }}
</label>
</div>
</div>
}
</div>
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Devise par défaut</label>
<div formGroupName="paymentConfig">
<select class="form-select" formControlName="defaultCurrency">
<option value="XOF">XOF (Franc CFA)</option>
<option value="XAF">XAF (Franc CFA)</option>
<option value="USD">USD (Dollar US)</option>
</select>
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Montant max par transaction (XOF)</label>
<div formGroupName="paymentConfig">
<input type="number" class="form-control" formControlName="maxTransactionAmount"
min="1000" max="1000000" />
</div>
</div>
</div>
}
<!-- Dans la section Configuration - Step 4: Webhooks -->
@if (i === 3) {
<div class="row">
<div class="col-12 mb-3">
<h6>Webhooks Abonnements</h6>
<!-- CORRECTION : Ajouter formGroupName="webhookConfig" -->
<div formGroupName="webhookConfig">
<div formGroupName="subscription">
<div class="row">
<div class="col-md-6 mb-2">
<label class="form-label">Création d'abonnement</label>
<input type="url" class="form-control"
formControlName="onCreate"
placeholder="https://votre-domaine.com/webhooks/subscription-created" />
</div>
<div class="col-md-6 mb-2">
<label class="form-label">Renouvellement</label>
<input type="url" class="form-control"
formControlName="onRenew"
placeholder="https://votre-domaine.com/webhooks/subscription-renewed" />
</div>
<div class="col-md-6 mb-2">
<label class="form-label">Annulation</label>
<input type="url" class="form-control"
formControlName="onCancel"
placeholder="https://votre-domaine.com/webhooks/subscription-cancelled" />
</div>
<div class="col-md-6 mb-2">
<label class="form-label">Expiration</label>
<input type="url" class="form-control"
formControlName="onExpire"
placeholder="https://votre-domaine.com/webhooks/subscription-expired" />
</div>
</div>
</div>
</div>
</div>
<div class="col-12 mb-3">
<h6>Webhooks Paiements</h6>
<div formGroupName="webhookConfig">
<div formGroupName="payment">
<div class="row">
<div class="col-md-4 mb-2">
<label class="form-label">Paiement réussi</label>
<input type="url" class="form-control"
formControlName="onSuccess"
placeholder="https://votre-domaine.com/webhooks/payment-success" />
</div>
<div class="col-md-4 mb-2">
<label class="form-label">Paiement échoué</label>
<input type="url" class="form-control"
formControlName="onFailure"
placeholder="https://votre-domaine.com/webhooks/payment-failed" />
</div>
<div class="col-md-4 mb-2">
<label class="form-label">Remboursement</label>
<input type="url" class="form-control"
formControlName="onRefund"
placeholder="https://votre-domaine.com/webhooks/payment-refunded" />
</div>
</div>
</div>
</div>
</div>
</div>
}
</form>
<!-- Navigation Buttons -->
<div class="d-flex justify-content-between mt-4">
@if (i > 0) {
<button type="button" class="btn btn-secondary" (click)="previousStep()">
← Précédent
</button>
} @else {
<div></div>
}
@if (i < wizardSteps.length - 1) {
<button type="button" class="btn btn-primary" (click)="nextStep()">
Suivant →
</button>
} @else {
<button type="button" class="btn btn-success"
(click)="submitForm()" [disabled]="configLoading">
@if (configLoading) {
<div class="spinner-border spinner-border-sm me-2" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
}
Créer le Merchant
</button>
}
</div>
</div>
}
</div>
</div>
</app-ui-card>
</ng-template>
</li>
<li [ngbNavItem]="'stats'">
<a ngbNavLink (click)="onTabChange('stats')">
<ng-icon name="lucideBarChart3" class="me-1"></ng-icon>
Statistiques
</a>
<ng-template ngbNavContent>
<!-- CONTENU STATISTIQUES -->
<!-- Loading State -->
@if (statsLoading) {
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
<p class="mt-2 text-muted">Chargement des statistiques...</p>
</div>
}
<!-- Statistiques Globales -->
@if (stats && !statsLoading) {
<div class="row g-4">
<!-- KPI Cards -->
<div class="col-xl-3 col-md-6">
<div class="card border-primary">
<div class="card-body text-center">
<ng-icon name="lucideCreditCard" class="text-primary fs-24 mb-2"></ng-icon>
<h3 class="text-primary">{{ formatNumber(stats.totalTransactions) }}</h3>
<p class="text-muted mb-0">Transactions totales</p>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6">
<div class="card border-success">
<div class="card-body text-center">
<ng-icon name="lucideTrendingUp" class="text-success fs-24 mb-2"></ng-icon>
<h3 class="text-success">{{ stats.successRate }}%</h3>
<p class="text-muted mb-0">Taux de succès</p>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6">
<div class="card border-info">
<div class="card-body text-center">
<ng-icon name="lucideUsers" class="text-info fs-24 mb-2"></ng-icon>
<h3 class="text-info">{{ formatNumber(stats.activeSubscriptions) }}</h3>
<p class="text-muted mb-0">Abonnements actifs</p>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6">
<div class="card border-warning">
<div class="card-body text-center">
<ng-icon name="lucideDollarSign" class="text-warning fs-24 mb-2"></ng-icon>
<h3 class="text-warning">{{ formatCurrency(stats.totalRevenue) }}</h3>
<p class="text-muted mb-0">Revenue total</p>
</div>
</div>
</div>
<!-- Détails des performances -->
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Détails des Performances</h5>
</div>
<div class="card-body">
<div class="row text-center">
<div class="col-md-4">
<div class="border-end">
<h4 class="text-success">{{ formatNumber(stats.successfulTransactions) }}</h4>
<p class="text-muted mb-0">Transactions réussies</p>
</div>
</div>
<div class="col-md-4">
<div class="border-end">
<h4 class="text-danger">{{ formatNumber(stats.failedTransactions) }}</h4>
<p class="text-muted mb-0">Transactions échouées</p>
</div>
</div>
<div class="col-md-4">
<div>
<h4 class="text-info">+{{ stats.monthlyGrowth }}%</h4>
<p class="text-muted mb-0">Croissance mensuelle</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
}
</ng-template>
</li>
</ul>
<div [ngbNavOutlet]="nav" class="mt-3"></div>
</div>
</div>
</div>
</div>

View File

@ -1,15 +0,0 @@
import { Routes } from '@angular/router';
import { Merchants } from './merchants';
import { authGuard } from '../../core/guards/auth.guard';
import { roleGuard } from '../../core/guards/role.guard';
export const MERCHANTS_ROUTES: Routes = [
{
path: 'merchants',
canActivate: [authGuard, roleGuard],
component: Merchants,
data: {
title: 'Gestion partenaires',
}
}
];

View File

@ -1,24 +1,339 @@
import { Component } from '@angular/core';
import { Component, inject, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { NgIconComponent } from '@ng-icons/core';
import { FormsModule, ReactiveFormsModule, FormBuilder, Validators, FormArray, FormControl } from '@angular/forms';
import { NgbNavModule, NgbProgressbarModule, NgbPaginationModule, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap';
import { firstValueFrom } from 'rxjs'; // ← AJOUT IMPORT
import { PageTitle } from '@app/components/page-title/page-title';
import { MerchantsList } from './list/list';
import { MerchantsConfig } from './config/config';
import { MerchantsHistory } from './history/history';
import { UiCard } from '@app/components/ui-card';
import { MerchantsService } from './services/merchants.service';
import { MerchantResponse, MerchantStats, MerchantFormData } from './models/merchant.models';
@Component({
selector: 'app-merchants',
imports: [PageTitle,
//MerchantsList,
//MerchantsConfig,
//MerchantsHistory
],
selector: 'app-merchant',
standalone: true,
imports: [
CommonModule,
RouterModule,
NgIconComponent,
FormsModule,
ReactiveFormsModule,
NgbNavModule,
NgbProgressbarModule,
NgbPaginationModule,
NgbDropdownModule,
PageTitle,
UiCard
],
templateUrl: './merchants.html',
})
export class Merchants {
activeView: string = 'list';
selectedMerchantId: string = '';
export class Merchants implements OnInit {
private fb = inject(FormBuilder);
private merchantsService = inject(MerchantsService);
setActiveView(view: string, merchantId?: string) {
this.activeView = view;
this.selectedMerchantId = merchantId || '';
// Navigation par onglets
activeTab: 'list' | 'config' | 'stats' = 'list';
// === DONNÉES LISTE ===
merchants: MerchantResponse[] = [];
loading = false;
error = '';
// Pagination et filtres
currentPage = 1;
itemsPerPage = 10;
searchTerm = '';
statusFilter: 'all' | 'ACTIVE' | 'PENDING' | 'SUSPENDED' = 'all';
countryFilter = 'all';
// === DONNÉES CONFIG (WIZARD) ===
currentStep = 0;
wizardSteps = [
{ id: 'company-info', icon: 'lucideBuilding', title: 'Informations Société', subtitle: 'Détails entreprise' },
{ id: 'contact-info', icon: 'lucideUser', title: 'Contact Principal', subtitle: 'Personne de contact' },
{ id: 'payment-config', icon: 'lucideCreditCard', title: 'Configuration Paiements', subtitle: 'Paramètres DCB' },
{ id: 'webhooks', icon: 'lucideWebhook', title: 'Webhooks', subtitle: 'Notifications et retours' },
{ id: 'review', icon: 'lucideCheckCircle', title: 'Validation', subtitle: 'Vérification finale' }
];
configLoading = false;
configError = '';
configSuccess = '';
// === DONNÉES STATS ===
stats: MerchantStats | null = null;
statsLoading = false;
supportedOperatorsArray: FormControl<boolean | null>[] = [
this.fb.control(false), // Orange
this.fb.control(false), // MTN
this.fb.control(false), // Airtel
this.fb.control(false) // Moov
];
// === FORMULAIRE ===
merchantForm = this.fb.group({
companyInfo: this.fb.group({
name: ['', [Validators.required, Validators.minLength(2)]],
legalName: ['', [Validators.required]],
taxId: [''],
address: ['', [Validators.required]],
country: ['CIV', [Validators.required]]
}),
contactInfo: this.fb.group({
email: ['', [Validators.required, Validators.email]],
phone: ['', [Validators.required]],
firstName: ['', [Validators.required]],
lastName: ['', [Validators.required]]
}),
paymentConfig: this.fb.group({
// CORRECTION : Utiliser l'array déclaré séparément
supportedOperators: this.fb.array(this.supportedOperatorsArray),
defaultCurrency: ['XOF', [Validators.required]],
maxTransactionAmount: [50000, [Validators.min(1000), Validators.max(1000000)]]
}),
webhookConfig: this.fb.group({
subscription: this.fb.group({
onCreate: ['', [Validators.pattern('https?://.+')]],
onRenew: ['', [Validators.pattern('https?://.+')]],
onCancel: ['', [Validators.pattern('https?://.+')]],
onExpire: ['', [Validators.pattern('https?://.+')]]
}),
payment: this.fb.group({
onSuccess: ['', [Validators.pattern('https?://.+')]],
onFailure: ['', [Validators.pattern('https?://.+')]],
onRefund: ['', [Validators.pattern('https?://.+')]]
})
})
});
// Données partagées
countries = [
{ code: 'all', name: 'Tous les pays' },
{ code: 'CIV', name: 'Côte d\'Ivoire' },
{ code: 'SEN', name: 'Sénégal' },
{ code: 'CMR', name: 'Cameroun' },
{ code: 'COD', name: 'RDC' },
{ code: 'TUN', name: 'Tunisie' },
{ code: 'BFA', name: 'Burkina Faso' },
{ code: 'MLI', name: 'Mali' },
{ code: 'GIN', name: 'Guinée' }
];
operators = ['Orange', 'MTN', 'Airtel', 'Moov'];
ngOnInit() {
this.loadMerchants();
this.loadStats();
}
// === MÉTHODES LISTE ===
loadMerchants() {
this.loading = true;
this.merchantsService.getAllMerchants().subscribe({
next: (merchants) => {
this.merchants = merchants;
this.loading = false;
},
error: (error) => {
this.error = 'Erreur lors du chargement des merchants';
this.loading = false;
console.error('Error loading merchants:', error);
}
});
}
get filteredMerchants(): MerchantResponse[] {
return this.merchants.filter(merchant => {
const matchesSearch = !this.searchTerm ||
merchant.name.toLowerCase().includes(this.searchTerm.toLowerCase()) ||
merchant.email.toLowerCase().includes(this.searchTerm.toLowerCase());
const matchesStatus = this.statusFilter === 'all' || merchant.status === this.statusFilter;
const matchesCountry = this.countryFilter === 'all' || merchant.country === this.countryFilter;
return matchesSearch && matchesStatus && matchesCountry;
});
}
get displayedMerchants(): MerchantResponse[] {
const startIndex = (this.currentPage - 1) * this.itemsPerPage;
return this.filteredMerchants.slice(startIndex, startIndex + this.itemsPerPage);
}
getStatusBadgeClass(status: string): string {
switch (status) {
case 'ACTIVE': return 'bg-success';
case 'PENDING': return 'bg-warning';
case 'SUSPENDED': return 'bg-danger';
default: return 'bg-secondary';
}
}
getStatusText(status: string): string {
switch (status) {
case 'ACTIVE': return 'Actif';
case 'PENDING': return 'En attente';
case 'SUSPENDED': return 'Suspendu';
default: return 'Inconnu';
}
}
getCountryName(code: string): string {
const country = this.countries.find(c => c.code === code);
return country ? country.name : code;
}
suspendMerchant(merchant: MerchantResponse) {
if (confirm(`Êtes-vous sûr de vouloir suspendre ${merchant.name} ?`)) {
this.merchantsService.updateMerchantStatus(merchant.partnerId, 'SUSPENDED').subscribe({
next: () => {
merchant.status = 'SUSPENDED';
},
error: (error) => {
console.error('Error suspending merchant:', error);
alert('Erreur lors de la suspension du merchant');
}
});
}
}
activateMerchant(merchant: MerchantResponse) {
this.merchantsService.updateMerchantStatus(merchant.partnerId, 'ACTIVE').subscribe({
next: () => {
merchant.status = 'ACTIVE';
},
error: (error) => {
console.error('Error activating merchant:', error);
alert('Erreur lors de l\'activation du merchant');
}
});
}
onPageChange(page: number) {
this.currentPage = page;
}
clearFilters() {
this.searchTerm = '';
this.statusFilter = 'all';
this.countryFilter = 'all';
this.currentPage = 1;
}
// === MÉTHODES CONFIG (WIZARD) ===
get progressValue(): number {
return ((this.currentStep + 1) / this.wizardSteps.length) * 100;
}
nextStep() {
if (this.currentStep < this.wizardSteps.length - 1) {
this.currentStep++;
}
}
previousStep() {
if (this.currentStep > 0) {
this.currentStep--;
}
}
goToStep(index: number) {
this.currentStep = index;
}
getSelectedOperators(): string[] {
return this.supportedOperatorsArray
.map((control, index) => control.value ? this.operators[index] : null)
.filter(op => op !== null) as string[];
}
async submitForm() {
if (this.merchantForm.valid) {
this.configLoading = true;
this.configError = '';
try {
const formData = this.merchantForm.value as unknown as MerchantFormData;
const registrationData = {
name: formData.companyInfo.name,
email: formData.contactInfo.email,
country: formData.companyInfo.country,
companyInfo: {
legalName: formData.companyInfo.legalName,
taxId: formData.companyInfo.taxId,
address: formData.companyInfo.address
}
};
// CORRECTION : Utilisation de firstValueFrom au lieu de toPromise()
const partnerResponse = await firstValueFrom(
this.merchantsService.registerMerchant(registrationData)
);
if (partnerResponse) {
// CORRECTION : Utilisation de firstValueFrom au lieu de toPromise()
await firstValueFrom(
this.merchantsService.updateCallbacks(partnerResponse.partnerId, formData.webhookConfig)
);
this.configSuccess = `Merchant créé avec succès! API Key: ${partnerResponse.apiKey}`;
this.merchantForm.reset();
this.currentStep = 0;
this.loadMerchants(); // Recharger la liste
this.activeTab = 'list'; // Retourner à la liste
}
} catch (error) {
this.configError = 'Erreur lors de la création du merchant';
console.error('Error creating merchant:', error);
} finally {
this.configLoading = false;
}
}
}
// === MÉTHODES STATS ===
loadStats() {
this.statsLoading = true;
// Données mockées pour l'exemple
this.stats = {
totalTransactions: 12543,
successfulTransactions: 12089,
failedTransactions: 454,
totalRevenue: 45875000,
activeSubscriptions: 8450,
successRate: 96.4,
monthlyGrowth: 12.3
};
this.statsLoading = false;
}
formatNumber(num: number): string {
return new Intl.NumberFormat('fr-FR').format(num);
}
formatCurrency(amount: number): string {
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'XOF'
}).format(amount);
}
// Méthode utilitaire pour Math.min dans le template
mathMin(a: number, b: number): number {
return Math.min(a, b);
}
// === MÉTHODES COMMUNES ===
onTabChange(tab: 'list' | 'config' | 'stats') {
this.activeTab = tab;
if (tab === 'list') {
this.loadMerchants();
} else if (tab === 'stats') {
this.loadStats();
}
}
}

View File

@ -0,0 +1,81 @@
export interface MerchantRegistration {
name: string;
email: string;
country: string;
companyInfo?: {
legalName?: string;
taxId?: string;
address?: string;
};
}
export interface MerchantResponse {
partnerId: string;
name: string;
email: string;
country: string;
apiKey: string;
secretKey: string;
status: 'PENDING' | 'ACTIVE' | 'SUSPENDED';
createdAt: string;
companyInfo?: {
legalName?: string;
taxId?: string;
address?: string;
};
}
export interface CallbackConfiguration {
headerEnrichment?: {
url?: string;
method?: 'GET' | 'POST';
headers?: { [key: string]: string };
};
subscription?: {
onCreate?: string;
onRenew?: string;
onCancel?: string;
onExpire?: string;
};
payment?: {
onSuccess?: string;
onFailure?: string;
onRefund?: string;
};
authentication?: {
onSuccess?: string;
onFailure?: string;
};
}
export interface MerchantStats {
totalTransactions: number;
successfulTransactions: number;
failedTransactions: number;
totalRevenue: number;
activeSubscriptions: number;
successRate: number;
monthlyGrowth: number;
}
export interface MerchantFormData {
companyInfo: {
name: string;
legalName: string;
taxId: string;
address: string;
country: string;
};
contactInfo: {
email: string;
phone: string;
firstName: string;
lastName: string;
};
paymentConfig: {
supportedOperators: string[];
defaultCurrency: string;
maxTransactionAmount: number;
};
webhookConfig: CallbackConfiguration;
}

View File

@ -1,51 +0,0 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { environment } from '@environments/environment';
import { Observable } from 'rxjs';
export interface MerchantsConfig {
apiKey: string;
secretKey: string;
webhookUrl: string;
callbackUrls: {
paymentSuccess: string;
paymentFailed: string;
subscriptionCreated: string;
subscriptionCancelled: string;
};
allowedIPs: string[];
rateLimit: number;
isActive: boolean;
}
@Injectable({ providedIn: 'root' })
export class MerchantsConfigService {
private http = inject(HttpClient);
getConfig(merchantId: string): Observable<MerchantsConfig> {
return this.http.get<MerchantsConfig>(
`${environment.apiUrl}/merchants/${merchantId}/config`
);
}
updateConfig(merchantId: string, config: MerchantsConfig): Observable<MerchantsConfig> {
return this.http.put<MerchantsConfig>(
`${environment.apiUrl}/merchants/${merchantId}/config`,
config
);
}
regenerateApiKey(merchantId: string): Observable<{ apiKey: string }> {
return this.http.post<{ apiKey: string }>(
`${environment.apiUrl}/merchants/${merchantId}/regenerate-key`,
{}
);
}
testWebhook(merchantId: string): Observable<{ success: boolean; message: string }> {
return this.http.post<{ success: boolean; message: string }>(
`${environment.apiUrl}/merchants/${merchantId}/test-webhook`,
{}
);
}
}

View File

@ -1,8 +0,0 @@
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class MerchantsHistoryService {
constructor() {}
}

View File

@ -1,8 +0,0 @@
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class MerchantsListService {
constructor() {}
}

View File

@ -1,104 +1,54 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { FormBuilder, FormGroup, Validators, FormArray } from '@angular/forms';
import { environment } from '@environments/environment';
import { Observable } from 'rxjs';
export interface Merchant {
id?: string;
name: string;
logo?: string;
description: string;
address: string;
phone: string;
technicalContacts: TechnicalContact[];
services: Service[];
status: 'ACTIVE' | 'INACTIVE' | 'SUSPENDED';
}
export interface TechnicalContact {
firstName: string;
lastName: string;
phone: string;
email: string;
}
export interface Service {
name: string;
description: string;
pricing: Pricing[];
}
export interface Pricing {
name: string;
type: 'Daily' | 'Weekly' | 'Monthly' | 'OneTime';
amount: number;
tax: number;
currency: string;
periodicity: 'Daily' | 'Weekly' | 'Monthly' | 'OneTime';
}
import {
MerchantRegistration,
MerchantResponse,
CallbackConfiguration,
MerchantStats
} from '../models/merchant.models';
@Injectable({ providedIn: 'root' })
export class MerchantService {
export class MerchantsService {
private http = inject(HttpClient);
private fb = inject(FormBuilder);
private apiUrl = `${environment.apiUrl}/partners`;
// Formulaires réactifs pour création/édition
createMerchantForm(): FormGroup {
return this.fb.group({
name: ['', [Validators.required, Validators.minLength(2)]],
logo: [''],
description: ['', [Validators.required]],
address: ['', [Validators.required]],
phone: ['', [Validators.required, Validators.pattern(/^\+?[\d\s-]+$/)]],
technicalContacts: this.fb.array([]),
services: this.fb.array([]),
status: ['ACTIVE']
});
// Enregistrement d'un nouveau merchant
registerMerchant(registration: MerchantRegistration): Observable<MerchantResponse> {
return this.http.post<MerchantResponse>(`${this.apiUrl}/register`, registration);
}
createTechnicalContactForm(): FormGroup {
return this.fb.group({
firstName: ['', Validators.required],
lastName: ['', Validators.required],
phone: ['', Validators.required],
email: ['', [Validators.required, Validators.email]]
});
// Configuration des webhooks
updateCallbacks(partnerId: string, callbacks: CallbackConfiguration): Observable<CallbackConfiguration> {
return this.http.put<CallbackConfiguration>(`${this.apiUrl}/${partnerId}/callbacks`, callbacks);
}
createServiceForm(): FormGroup {
return this.fb.group({
name: ['', Validators.required],
description: ['', Validators.required],
pricing: this.fb.array([])
});
// Récupération de tous les merchants
getAllMerchants(): Observable<MerchantResponse[]> {
return this.http.get<MerchantResponse[]>(`${this.apiUrl}`);
}
createPricingForm(): FormGroup {
return this.fb.group({
name: ['', Validators.required],
type: ['Daily', Validators.required],
amount: [0, [Validators.required, Validators.min(0)]],
tax: [0, [Validators.required, Validators.min(0)]],
currency: ['XOF', Validators.required],
periodicity: ['Daily', Validators.required]
});
// Récupération d'un merchant par ID
getMerchantById(partnerId: string): Observable<MerchantResponse> {
return this.http.get<MerchantResponse>(`${this.apiUrl}/${partnerId}`);
}
// Méthodes API
getMerchants(): Observable<Merchant[]> {
return this.http.get<Merchant[]>(`${environment.apiUrl}/merchants`);
// Statistiques d'un merchant
getMerchantStats(partnerId: string): Observable<MerchantStats> {
return this.http.get<MerchantStats>(`${this.apiUrl}/${partnerId}/stats`);
}
createMerchant(merchant: Merchant): Observable<Merchant> {
return this.http.post<Merchant>(`${environment.apiUrl}/merchants`, merchant);
// Test des webhooks
testWebhook(url: string, event: string): Observable<{ success: boolean; response: any; responseTime: number }> {
return this.http.post<{ success: boolean; response: any; responseTime: number }>(
`${environment.apiUrl}/webhooks/test`,
{ url, event }
);
}
updateMerchant(id: string, merchant: Merchant): Observable<Merchant> {
return this.http.put<Merchant>(`${environment.apiUrl}/merchants/${id}`, merchant);
}
deleteMerchant(id: string): Observable<void> {
return this.http.delete<void>(`${environment.apiUrl}/merchants/${id}`);
// Suspension/Activation d'un merchant
updateMerchantStatus(partnerId: string, status: 'ACTIVE' | 'SUSPENDED'): Observable<MerchantResponse> {
return this.http.patch<MerchantResponse>(`${this.apiUrl}/${partnerId}`, { status });
}
}

View File

@ -0,0 +1,312 @@
import { Component } from '@angular/core'
import { NgIcon } from '@ng-icons/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { UiCard } from '@app/components/ui-card'
import { NgbProgressbarModule } from '@ng-bootstrap/ng-bootstrap'
import { wizardSteps } from '@/app/modules/merchants/data'
@Component({
selector: 'app-wizard-with-progress',
imports: [
NgIcon,
ReactiveFormsModule,
FormsModule,
UiCard,
NgbProgressbarModule,
],
template: `
<app-ui-card title="Progressbar Support">
<span helper-text class="badge badge-soft-success badge-label fs-xxs py-1"
>Exclusive</span
>
<div class="ins-wizard" card-body>
<ngb-progressbar
class="mb-4"
[value]="progressValue"
type="primary"
height="6px"
>
</ngb-progressbar>
<ul class="nav nav-tabs wizard-tabs" role="tablist">
@for (step of wizardSteps; track $index; let i = $index) {
<li class="nav-item">
<a
href="javascript:void(0);"
[class.active]="i === currentStep"
class="nav-link"
[class]="i < currentStep ? 'wizard-item-done' : ''"
(click)="goToStep(i)"
>
<span class="d-flex align-items-center">
<ng-icon [name]="step.icon" class="fs-32" />
<span class="flex-grow-1 ms-2 text-truncate">
<span
class="mb-0 lh-base d-block fw-semibold text-body fs-base"
>{{ step.title }}</span
>
<span class="mb-0 fw-normal">{{ step.subtitle }}</span>
</span>
</span>
</a>
</li>
}
</ul>
<div class="tab-content pt-3">
@for (step of wizardSteps; track $index; let i = $index) {
<div
class="tab-pane fade"
[class.show]="currentStep === i"
[class.active]="currentStep === i"
>
@switch (i) {
@case (0) {
<div class="row">
<div class="col-xl-6 mb-3">
<label class="form-label">Full Name</label>
<input
type="text"
class="form-control"
placeholder="Enter your full name"
name="fullname"
required
/>
</div>
<div class="col-xl-6 mb-3">
<label class="form-label">Email</label>
<input
type="email"
class="form-control"
placeholder="Enter your email"
name="email"
required
/>
</div>
<div class="col-xl-6 mb-3">
<label class="form-label">Phone Number</label>
<input
type="tel"
class="form-control"
name="phone"
placeholder="Enter your phone number"
required
/>
</div>
<div class="col-xl-6 mb-3">
<label class="form-label">Date of Birth</label>
<input
type="text"
data-provider="flatpickr"
data-date-format="d M, Y"
placeholder="Select your DOB"
class="form-control"
name="dob"
required
/>
</div>
</div>
}
@case (1) {
<div class="row">
<div class="col-xl-6 mb-3">
<label class="form-label">Street Address</label>
<input
type="text"
class="form-control"
name="street"
placeholder="123 Main St"
required
/>
</div>
<div class="col-xl-6 mb-3">
<label class="form-label">City</label>
<input
type="text"
class="form-control"
name="city"
placeholder="e.g., New York"
required
/>
</div>
<div class="col-xl-6 mb-3">
<label class="form-label">State</label>
<input
type="text"
class="form-control"
name="state"
placeholder="e.g., California"
required
/>
</div>
<div class="col-xl-6 mb-3">
<label class="form-label">Zip Code</label>
<input
type="text"
class="form-control"
name="zip"
placeholder="e.g., 10001"
required
/>
</div>
</div>
}
@case (2) {
<div class="row">
<div class="col-xl-6 mb-3">
<label class="form-label">Choose Course</label>
<select class="form-select" name="course" required>
<option value="">Select</option>
<option value="Engineering">Engineering</option>
<option value="Medical">Medical</option>
<option value="Business">Business</option>
</select>
</div>
<div class="col-xl-6 mb-3">
<label class="form-label">Enrollment Type</label>
<select class="form-select" name="enrollment" required>
<option value="">Select</option>
<option value="Full Time">Full Time</option>
<option value="Part Time">Part Time</option>
</select>
</div>
<div class="col-xl-6 mb-3">
<label class="form-label">Preferred Batch Time</label>
<select class="form-select" name="batch_time" required>
<option value="">Select Time</option>
<option value="Morning">Morning (8am 12pm)</option>
<option value="Afternoon">Afternoon (1pm 5pm)</option>
<option value="Evening">Evening (6pm 9pm)</option>
</select>
</div>
<div class="col-xl-6 mb-3">
<label class="form-label">Mode of Study</label>
<select class="form-select" name="mode" required>
<option value="">Select Mode</option>
<option value="Offline">Offline</option>
<option value="Online">Online</option>
<option value="Hybrid">Hybrid</option>
</select>
</div>
</div>
}
@case (3) {
<div class="row">
<div class="col-xl-6 mb-3">
<label class="form-label">Parent/Guardian Name</label>
<input
type="text"
class="form-control"
name="parent_name"
placeholder="e.g., John Doe"
required
/>
</div>
<div class="col-xl-6 mb-3">
<label class="form-label">Relation</label>
<input
type="text"
class="form-control"
name="relation"
placeholder="e.g., Father, Mother"
required
/>
</div>
<div class="col-xl-6 mb-3">
<label class="form-label">Parent Phone</label>
<input
type="tel"
class="form-control"
name="parent_phone"
placeholder="e.g., +1 555 123 4567"
required
/>
</div>
<div class="col-xl-6 mb-3">
<label class="form-label">Parent Email</label>
<input
type="email"
class="form-control"
name="parent_email"
placeholder="e.g., parent@example.com"
required
/>
</div>
</div>
}
@case (4) {
<div class="mb-3">
<label class="form-label">Upload ID Proof</label>
<input
type="file"
class="form-control"
name="id_proof"
required
/>
</div>
<div class="mb-3">
<label class="form-label">Upload Previous Marksheet</label>
<input
type="file"
class="form-control"
name="marksheet"
required
/>
</div>
}
}
<div class="d-flex justify-content-between mt-3">
@if (i > 0) {
<button
type="button"
class="btn btn-secondary"
(click)="previousStep()"
>
Back:
{{ step.title }}
</button>
}
@if (i < wizardSteps.length - 1) {
<button
type="button"
class="btn btn-primary ms-auto"
(click)="nextStep()"
>
Next: {{ step.title }}
</button>
}
@if (i === wizardSteps.length - 1) {
<button type="submit" class="btn btn-success">
Submit Application
</button>
}
</div>
</div>
}
</div>
</div>
</app-ui-card>
`,
styles: ``,
})
export class WizardWithProgress {
currentStep = 0
nextStep() {
if (this.currentStep < wizardSteps.length - 1) this.currentStep++
}
previousStep() {
if (this.currentStep > 0) this.currentStep--
}
goToStep(index: number) {
this.currentStep = index
}
get progressValue(): number {
const totalSteps = this.wizardSteps.length
return ((this.currentStep + 1) / totalSteps) * 100
}
protected readonly wizardSteps = wizardSteps
}

View File

@ -7,11 +7,7 @@
<div class="row">
<div class="col-12">
<app-basic-wizard />
<app-wizard-with-progress />
<app-vertical-wizard />
</div>
</div>
</div>

View File

@ -1,12 +1,10 @@
import { Component } from '@angular/core'
import { PageTitle } from '@app/components/page-title/page-title'
import { BasicWizard } from '@/app/modules/components/basic-wizard'
import { WizardWithProgress } from '@/app/modules/components/wizard-with-progress'
import { VerticalWizard } from '@/app/modules/components/vertical-wizard'
@Component({
selector: 'app-wizard',
imports: [PageTitle, BasicWizard, WizardWithProgress, VerticalWizard],
selector: 'app-merchant-wizard',
imports: [PageTitle, WizardWithProgress],
templateUrl: './wizard.html',
styles: ``,
})

View File

@ -6,26 +6,14 @@ import { Users } from '@modules/users/users';
// Composants principaux
import { DcbDashboard } from './dcb-dashboard/dcb-dashboard';
import { Team } from './team/team';
import { Transactions } from './transactions/transactions';
import { TransactionsList } from './transactions/list/list';
import { TransactionDetails } from './transactions/details/details';
import { Merchants } from './merchants/merchants';
import { MerchantsList } from './merchants/list/list';
import { MerchantsConfig } from './merchants/config/config';
import { MerchantsHistory } from './merchants/history/history';
import { Operators } from './operators/operators';
import { OperatorsConfig } from './operators/config/config';
import { OperatorsStats } from './operators/stats/stats';
import { Webhooks } from './webhooks/webhooks';
import { WebhooksHistory } from './webhooks/history/history';
import { WebhooksStatus } from './webhooks/status/status';
import { WebhooksRetry } from './webhooks/retry/retry';
import { Settings } from './settings/settings';
import { Integrations } from './integrations/integrations';
import { Support } from './support/support';
@ -34,25 +22,9 @@ import { Documentation } from './documentation/documentation';
import { Help } from './help/help';
import { About } from './about/about';
const routes: Routes = [
// ---------------------------
// Users
// ---------------------------
{
path: 'users',
canActivate: [authGuard, roleGuard],
component: Users,
data: {
title: 'Gestion des Utilisateurs',
requiredRoles: ['admin'] // pour information
}
},
// ---------------------------
// Dashboard & Team
// Dashboard
// ---------------------------
{
path: 'dcb-dashboard',
@ -60,15 +32,21 @@ const routes: Routes = [
component: DcbDashboard,
data: {
title: 'Dashboard DCB',
requiredRoles: ['admin', 'merchant', 'support']
module: 'dcb-dashboard'
}
},
// ---------------------------
// Team
// ---------------------------
{
path: 'team',
component: Team,
canActivate: [authGuard, roleGuard],
data: { title: 'Team' }
data: {
title: 'Team',
module: 'team'
}
},
// ---------------------------
@ -80,7 +58,7 @@ const routes: Routes = [
canActivate: [authGuard, roleGuard],
data: {
title: 'Transactions DCB',
requiredRoles: ['admin', 'merchant', 'support']
module: 'transactions'
}
},
{
@ -89,7 +67,20 @@ const routes: Routes = [
canActivate: [authGuard, roleGuard],
data: {
title: 'Détails Transaction',
requiredRoles: ['admin', 'merchant', 'support']
module: 'transactions'
}
},
// ---------------------------
// Users (Admin seulement)
// ---------------------------
{
path: 'users',
canActivate: [authGuard, roleGuard],
component: Users,
data: {
title: 'Gestion des Utilisateurs',
module: 'users'
}
},
@ -98,23 +89,39 @@ const routes: Routes = [
// ---------------------------
{
path: 'merchants',
component: Merchants,
canActivate: [authGuard, roleGuard],
children: [
{ path: 'list', component: MerchantsList, data: { title: 'Liste des Marchands' } },
{ path: 'config', component: MerchantsConfig, data: { title: 'Configuration API / Webhooks' } },
{ path: 'history', component: MerchantsHistory, data: { title: 'Statistiques & Historique' } },
]
data: {
title: 'Gestion des Merchants',
module: 'merchants',
requiredRoles: ['admin', 'support']
}
},
// ---------------------------
// Operators
// Operators (Admin seulement)
// ---------------------------
{
path: 'operators',
canActivate: [authGuard, roleGuard],
data: { module: 'operators' },
children: [
{ path: 'config', component: OperatorsConfig, data: { title: 'Paramètres d\'Intégration' } },
{ path: 'stats', component: OperatorsStats, data: { title: 'Performance & Monitoring' } },
{
path: 'config',
component: OperatorsConfig,
data: {
title: 'Paramètres d\'Intégration',
module: 'operators/config'
}
},
{
path: 'stats',
component: OperatorsStats,
data: {
title: 'Performance & Monitoring',
module: 'operators/stats'
}
},
]
},
@ -124,27 +131,59 @@ const routes: Routes = [
{
path: 'webhooks',
canActivate: [authGuard, roleGuard],
data: { module: 'webhooks' },
children: [
{ path: 'history', component: WebhooksHistory, data: { title: 'Historique' } },
{ path: 'status', component: WebhooksStatus, data: { title: 'Statut des Requêtes' } },
{ path: 'retry', component: WebhooksRetry, data: { title: 'Relancer Webhook' } },
{
path: 'history',
component: WebhooksHistory,
data: {
title: 'Historique',
module: 'webhooks/history'
}
},
{
path: 'status',
component: WebhooksStatus,
data: {
title: 'Statut des Requêtes',
module: 'webhooks/status'
}
},
{
path: 'retry',
component: WebhooksRetry,
data: {
title: 'Relancer Webhook',
module: 'webhooks/retry'
}
},
]
},
// ---------------------------
// Settings & Integrations (Admin seulement)
// Settings
// ---------------------------
{
path: 'settings',
component: Settings,
canActivate: [authGuard, roleGuard],
data: { title: 'Paramètres Système' }
data: {
title: 'Paramètres Système',
module: 'settings'
}
},
// ---------------------------
// Integrations (Admin seulement)
// ---------------------------
{
path: 'integrations',
component: Integrations,
canActivate: [authGuard, roleGuard],
data: { title: 'Intégrations Externes' }
data: {
title: 'Intégrations Externes',
module: 'integrations'
}
},
// ---------------------------
@ -154,13 +193,19 @@ const routes: Routes = [
path: 'support',
component: Support,
canActivate: [authGuard, roleGuard],
data: { title: 'Support' }
data: {
title: 'Support',
module: 'support'
}
},
{
path: 'profile',
component: MyProfile,
canActivate: [authGuard, roleGuard],
data: { title: 'Mon Profil' }
data: {
title: 'Mon Profil',
module: 'profile'
}
},
// ---------------------------
@ -170,19 +215,28 @@ const routes: Routes = [
path: 'documentation',
component: Documentation,
canActivate: [authGuard, roleGuard],
data: { title: 'Documentation' }
data: {
title: 'Documentation',
module: 'documentation'
}
},
{
path: 'help',
component: Help,
canActivate: [authGuard, roleGuard],
data: { title: 'Aide' }
data: {
title: 'Aide',
module: 'help'
}
},
{
path: 'about',
component: About,
canActivate: [authGuard, roleGuard],
data: { title: 'À propos' }
data: {
title: 'À propos',
module: 'about'
}
},
];
@ -190,4 +244,8 @@ const routes: Routes = [
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class ModulesRoutingModule {}
export class ModulesRoutes {
constructor() {
console.log('Modules routes loaded:', routes);
}
}

View File

@ -6,7 +6,7 @@ import { CheckboxesAndRadios } from '@/app/modules/components/checkboxes-and-rad
import { InputTouchspin } from '@/app/modules/components/input-touchspin';
@Component({
selector: 'app-config',
selector: 'app-operator-config',
//imports: [FormsModule, UiCard, InputFields, CheckboxesAndRadios, InputTouchspin],
templateUrl: './config.html',
})

View File

@ -1,19 +0,0 @@
<div class="container-fluid">
<app-page-title
title="Form Plugins"
subTitle="Enhance your forms with powerful plugins like Flatpickr, Choices.js, Typeahead, and Input Touchspin for better interactivity."
[badge]="{icon:'lucidePuzzle',text:'Enhanced Inputs'}"
/>
<div class="row">
<div class="col-12">
<app-flatpickr />
<app-choicesjs />
<app-typeaheds />
<app-input-touchspin />
</div>
</div>
</div>

View File

@ -1,22 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { Plugins } from './plugins'
describe('Plugins', () => {
let component: Plugins
let fixture: ComponentFixture<Plugins>
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Plugins],
}).compileComponents()
fixture = TestBed.createComponent(Plugins)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should create', () => {
expect(component).toBeTruthy()
})
})

View File

@ -1,14 +0,0 @@
import { Component } from '@angular/core'
import { PageTitle } from '@app/components/page-title/page-title'
import { Flatpickr } from '@/app/modules/components/flatpickr'
import { Choicesjs } from '@/app/modules/components/choicesjs'
import { Typeaheds } from '@/app/modules/components/typeaheds'
import { InputTouchspin } from '@/app/modules/components/input-touchspin'
@Component({
selector: 'app-plugins',
imports: [PageTitle, Flatpickr, Choicesjs, Typeaheds, InputTouchspin],
templateUrl: './plugins.html',
styles: ``,
})
export class Plugins {}