feat: add DCB User Service API - Authentication system with KEYCLOAK - Modular architecture with services for each feature
This commit is contained in:
parent
3e9b8cab2d
commit
79ec4ac00d
@ -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' },
|
||||
]
|
||||
];
|
||||
653
src/app/app.scss
653
src/app/app.scss
@ -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;
|
||||
@ -199,3 +96,553 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@ -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('/');
|
||||
|
||||
@ -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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -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']
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@ -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 {}
|
||||
59
src/app/modules/dcb-dashboard/components/alert-widget.ts
Normal file
59
src/app/modules/dcb-dashboard/components/alert-widget.ts
Normal 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 {}
|
||||
@ -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 {}
|
||||
127
src/app/modules/dcb-dashboard/components/payment-stats.ts
Normal file
127
src/app/modules/dcb-dashboard/components/payment-stats.ts
Normal 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'
|
||||
});
|
||||
}
|
||||
}
|
||||
190
src/app/modules/dcb-dashboard/components/recent-transactions.ts
Normal file
190
src/app/modules/dcb-dashboard/components/recent-transactions.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
83
src/app/modules/dcb-dashboard/components/revenue-chart.ts
Normal file
83
src/app/modules/dcb-dashboard/components/revenue-chart.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
46
src/app/modules/dcb-dashboard/components/system-health.ts
Normal file
46
src/app/modules/dcb-dashboard/components/system-health.ts
Normal 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 {}
|
||||
@ -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>
|
||||
}
|
||||
|
||||
<!-- 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 class="row row-cols-xxl-4 row-cols-md-2 row-cols-1 g-3 mb-4">
|
||||
|
||||
<div class="col">
|
||||
<app-active-subscriptions />
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
<app-operator-performance />
|
||||
</div>
|
||||
|
||||
<div class="col-xxl-4">
|
||||
<app-alert-widget />
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
<app-system-health />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<!-- Charts and Main Content -->
|
||||
<div class="row g-4">
|
||||
<div class="col-xxl-8">
|
||||
<app-revenue-chart />
|
||||
</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>
|
||||
<!-- Bottom Section -->
|
||||
<div class="row g-4 mt-1">
|
||||
<div class="col-xxl-6">
|
||||
<app-recent-transactions />
|
||||
</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 class="col-xxl-6">
|
||||
<!-- Additional components can be added here -->
|
||||
</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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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>
|
||||
}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@ -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 {}
|
||||
50
src/app/modules/dcb-dashboard/models/dcb-dashboard.models.ts
Normal file
50
src/app/modules/dcb-dashboard/models/dcb-dashboard.models.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
133
src/app/modules/dcb-dashboard/services/dcb-dashboard.service.ts
Normal file
133
src/app/modules/dcb-dashboard/services/dcb-dashboard.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
@ -1,2 +0,0 @@
|
||||
import { MerchantsConfig } from './config';
|
||||
describe('MerchantsConfig', () => {});
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
<p>Merchants - History</p>
|
||||
@ -1,2 +0,0 @@
|
||||
import { MerchantsHistory } from './history';
|
||||
describe('MerchantsHistory', () => {});
|
||||
@ -1,7 +0,0 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-history',
|
||||
templateUrl: './history.html',
|
||||
})
|
||||
export class MerchantsHistory {}
|
||||
@ -1 +0,0 @@
|
||||
<p>Merchants - List</p>
|
||||
@ -1,2 +0,0 @@
|
||||
import { MerchantsList } from './list';
|
||||
describe('MerchantsList', () => {});
|
||||
@ -1,7 +0,0 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-list',
|
||||
templateUrl: './list.html',
|
||||
})
|
||||
export class MerchantsList {}
|
||||
@ -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>
|
||||
@ -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',
|
||||
}
|
||||
}
|
||||
];
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
81
src/app/modules/merchants/models/merchant.models.ts
Normal file
81
src/app/modules/merchants/models/merchant.models.ts
Normal 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;
|
||||
}
|
||||
@ -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`,
|
||||
{}
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class MerchantsHistoryService {
|
||||
constructor() {}
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class MerchantsListService {
|
||||
constructor() {}
|
||||
}
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
312
src/app/modules/merchants/wizard-with-progress.ts
Normal file
312
src/app/modules/merchants/wizard-with-progress.ts
Normal 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
|
||||
}
|
||||
@ -7,11 +7,7 @@
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<app-basic-wizard />
|
||||
|
||||
<app-wizard-with-progress />
|
||||
|
||||
<app-vertical-wizard />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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: ``,
|
||||
})
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
})
|
||||
|
||||
@ -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>
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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 {}
|
||||
Loading…
Reference in New Issue
Block a user