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

This commit is contained in:
diallolatoile 2025-10-27 18:12:52 +00:00
parent d5714ad0f8
commit 3bb7d21a7f
85 changed files with 6712 additions and 1730 deletions

19
package-lock.json generated
View File

@ -1,11 +1,11 @@
{
"name": "simple",
"name": "dcb-bo-admin",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "simple",
"name": "dcb-bo-admin",
"version": "0.0.0",
"dependencies": {
"@angular/common": "^20.3.6",
@ -57,6 +57,7 @@
"ngx-countup": "^13.2.0",
"ngx-dropzone-wrapper": "^17.0.0",
"ngx-quill": "^28.0.1",
"ngx-toastr": "^19.1.0",
"prettier": "^3.6.2",
"quill": "^2.0.3",
"rxjs": "~7.8.2",
@ -8334,6 +8335,20 @@
"rxjs": "^7.0.0"
}
},
"node_modules/ngx-toastr": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/ngx-toastr/-/ngx-toastr-19.1.0.tgz",
"integrity": "sha512-Qa7Kg7QzGKNtp1v04hu3poPKKx8BGBD/Onkhm6CdH5F0vSMdq+BdR/f8DTpZnGFksW891tAFufpiWb9UZX+3vg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"peerDependencies": {
"@angular/common": ">=16.0.0-0",
"@angular/core": ">=16.0.0-0",
"@angular/platform-browser": ">=16.0.0-0"
}
},
"node_modules/node-addon-api": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz",

View File

@ -60,6 +60,7 @@
"ngx-countup": "^13.2.0",
"ngx-dropzone-wrapper": "^17.0.0",
"ngx-quill": "^28.0.1",
"ngx-toastr": "^19.1.0",
"prettier": "^3.6.2",
"quill": "^2.0.3",
"rxjs": "~7.8.2",

View File

@ -1,9 +1,10 @@
import { Routes } from '@angular/router';
import { VerticalLayout } from '@layouts/vertical-layout/vertical-layout';
import { authGuard } from './core/guards/auth.guard';
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: '/dashboard', pathMatch: 'full' },
{ path: '', redirectTo: '/dcb-dashboard', pathMatch: 'full' },
// Routes publiques (auth)
{
@ -12,17 +13,28 @@ export const routes: Routes = [
import('./modules/auth/auth.route').then(mod => mod.Auth_ROUTES),
},
// Routes d'erreur (publiques)
{
path: '',
loadChildren: () =>
import('./modules/auth/error/error.route').then(mod => mod.ERROR_PAGES_ROUTES),
},
// Routes protégées
{
path: '',
component: VerticalLayout,
canActivate: [authGuard],
canActivate: [authGuard, roleGuard],
loadChildren: () =>
import('./modules/modules-routing.module').then(
m => m.ModulesRoutingModule
),
},
// Catch-all
{ path: '**', redirectTo: '/auth/sign-in' },
];
// Redirections pour les erreurs courantes
{ path: '404', redirectTo: '/error/404' },
{ path: '403', redirectTo: '/error/403' },
// Catch-all - Rediriger vers 404 au lieu de login
{ path: '**', redirectTo: '/error/404' },
]

View File

@ -0,0 +1,201 @@
/* Ajoutez ce CSS dans votre composant ou global */
.cursor-pointer {
cursor: pointer;
}
.cursor-pointer:hover {
background-color: #f8f9fa;
}
.fs-12 {
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;
&: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); }
}
.table {
th {
border-top: none;
font-weight: 600;
color: #6c757d;
font-size: 0.875rem;
}
td {
vertical-align: middle;
}
}
.badge {
font-size: 0.75em;
font-weight: 500;
}
.font-monospace {
font-family: 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas', monospace;
}
}
.transaction-details {
.transaction-amount-icon,
.transaction-date-icon {
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;
}
}
}

View File

@ -24,8 +24,19 @@ export class App implements OnInit {
private authService = inject(AuthService);
async ngOnInit(): Promise<void> {
// Initialiser l'authentification
await this.authService.initialize();
try {
// Initialiser l'authentification avec gestion d'erreur
const isAuthenticated = await this.authService.initialize();
console.log('Authentication initialized:', isAuthenticated);
if (!isAuthenticated) {
console.log('👤 User not authenticated, may redirect to login');
// Note: Votre AuthService gère déjà la redirection dans logout()
}
} catch (error) {
console.error('Error during authentication initialization:', error);
}
// Configurer le titre de la page
this.setupTitleListener();

View File

@ -0,0 +1,31 @@
import { Directive, Input, TemplateRef, ViewContainerRef, inject, OnDestroy } from '@angular/core';
import { AuthService } from '../services/auth.service';
import { Subscription } from 'rxjs';
@Directive({
selector: '[hasRole]',
standalone: true
})
export class HasRoleDirective implements OnDestroy {
private authService = inject(AuthService);
private templateRef = inject(TemplateRef<any>);
private viewContainer = inject(ViewContainerRef);
private subscription?: Subscription;
@Input() set hasRole(roles: string | string[]) {
const requiredRoles = Array.isArray(roles) ? roles : [roles];
const userRoles = this.authService.getCurrentUserRoles();
const hasAccess = requiredRoles.some(role => userRoles.includes(role));
if (hasAccess) {
this.viewContainer.createEmbeddedView(this.templateRef);
} else {
this.viewContainer.clear();
}
}
ngOnDestroy() {
this.subscription?.unsubscribe();
}
}

View File

@ -0,0 +1,56 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router, ActivatedRouteSnapshot } from '@angular/router';
import { AuthService } from '../services/auth.service';
import { PermissionsService } from '../services/permissions.service';
export const roleGuard: CanActivateFn = (route: ActivatedRouteSnapshot, state) => {
const authService = inject(AuthService);
const permissionsService = inject(PermissionsService);
const router = inject(Router);
// Vérifier d'abord l'authentification
if (!authService.isAuthenticated()) {
console.log('RoleGuard: User not authenticated, redirecting to login');
router.navigate(['/auth/sign-in'], {
queryParams: { returnUrl: state.url }
});
return false;
}
// Récupérer les rôles depuis le token
const userRoles = authService.getCurrentUserRoles();
const modulePath = getModulePath(route);
console.log('RoleGuard check:', {
module: modulePath,
userRoles: userRoles,
url: state.url
});
// Vérifier les permissions
const hasAccess = permissionsService.canAccessModule(modulePath, userRoles);
if (!hasAccess) {
console.warn('RoleGuard: Access denied for', modulePath, 'User roles:', userRoles);
router.navigate(['/unauthorized']);
return false;
}
console.log('RoleGuard: Access granted for', modulePath);
return true;
};
// Fonction utilitaire pour extraire le chemin du module
function getModulePath(route: ActivatedRouteSnapshot): string {
const segments: string[] = [];
let currentRoute: ActivatedRouteSnapshot | null = route;
while (currentRoute) {
if (currentRoute.url.length > 0) {
segments.unshift(...currentRoute.url.map(segment => segment.path));
}
currentRoute = currentRoute.firstChild;
}
return segments.join('/');
}

View File

@ -1,43 +1,81 @@
import { HttpInterceptorFn, HttpRequest } from '@angular/common/http';
import { HttpInterceptorFn, HttpRequest, HttpHandlerFn, HttpErrorResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthService } from '../services/auth.service';
import { Router } from '@angular/router';
import { catchError, switchMap, throwError } from 'rxjs';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const authService = inject(AuthService);
const router = inject(Router);
// On ignore les requêtes de login, refresh, logout
// On ignore les requêtes d'authentification
if (isAuthRequest(req)) {
return next(req);
}
const token = authService.getToken();
// On ajoute le token uniquement si cest une requête API
// On ajoute le token uniquement si c'est une requête API et que le token existe
if (token && isApiRequest(req)) {
const cloned = req.clone({
setHeaders: {
Authorization: `Bearer ${token}`,
const cloned = addToken(req, token);
return next(cloned).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === 401) {
// Token expiré, on tente de le rafraîchir
return handle401Error(authService, router, req, next);
}
});
return next(cloned);
return throwError(() => error);
})
);
}
return next(req);
};
// Détermine si cest une requête vers ton backend API
function isApiRequest(req: HttpRequest<any>): boolean {
return req.url.includes('/api/') || (
req.url.includes('/auth/') && !isAuthRequest(req)
// Ajoute le token à la requête
function addToken(req: HttpRequest<any>, token: string): HttpRequest<any> {
return req.clone({
setHeaders: {
Authorization: `Bearer ${token}`
}
});
}
// Gère les erreurs 401 (token expiré)
function handle401Error(
authService: AuthService,
router: Router,
req: HttpRequest<any>,
next: HttpHandlerFn
) {
return authService.refreshToken().pipe(
switchMap((response: any) => {
// Nouveau token obtenu, on relance la requête originale
const newToken = response.access_token;
const newRequest = addToken(req, newToken);
return next(newRequest);
}),
catchError((refreshError) => {
// Échec du rafraîchissement, on déconnecte
authService.logout();
router.navigate(['/auth/sign-in']);
return throwError(() => refreshError);
})
);
}
// Détermine si c'est une requête vers le backend API
function isApiRequest(req: HttpRequest<any>): boolean {
return req.url.includes('/api/') || req.url.includes('/auth/');
}
// Liste des endpoints où le token ne doit pas être ajouté
function isAuthRequest(req: HttpRequest<any>): boolean {
const url = req.url;
return (
url.includes('/auth/login') ||
url.includes('/auth/refresh') ||
url.includes('/auth/logout')
url.endsWith('/auth/refresh') ||
url.endsWith('/auth/logout')
);
}

View File

@ -1,8 +1,8 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Router } from '@angular/router';
import { environment } from '@environments/environment';
import { BehaviorSubject, tap, catchError, Observable, throwError } from 'rxjs';
import { BehaviorSubject, tap, catchError, Observable, throwError, map } from 'rxjs';
import { jwtDecode } from "jwt-decode";
interface DecodedToken {
@ -13,9 +13,11 @@ interface DecodedToken {
email?: string;
given_name?: string;
family_name?: string;
realm_access?: {
resource_access?: {
[key: string]: {
roles: string[];
};
};
}
export interface AuthResponse {
@ -33,8 +35,88 @@ export class AuthService {
private readonly router = inject(Router);
private authState$ = new BehaviorSubject<boolean>(this.isAuthenticated());
private userRoles$ = new BehaviorSubject<string[]>(this.getRolesFromToken());
/**
* Initialisation simple - à appeler dans app.component.ts
* Récupère les rôles depuis le token JWT
*/
private getRolesFromToken(): string[] {
const token = this.getToken();
if (!token) return [];
try {
const decoded: DecodedToken = jwtDecode(token);
// Priorité 2: Rôles du client (resource_access)
// Prendre tous les rôles de tous les clients
if (decoded.resource_access) {
const allClientRoles: string[] = [];
Object.values(decoded.resource_access).forEach(client => {
if (client?.roles) {
allClientRoles.push(...client.roles);
}
});
// Retourner les rôles uniques
return [...new Set(allClientRoles)];
}
return [];
} catch {
return [];
}
}
/**
* Récupère les rôles de l'utilisateur courant
*/
getCurrentUserRoles(): string[] {
return this.userRoles$.value;
}
/**
* Observable des rôles
*/
onRolesChange(): Observable<string[]> {
return this.userRoles$.asObservable();
}
/**
* Vérifications rapides par rôle
*/
isAdmin(): boolean {
return this.getCurrentUserRoles().includes('admin');
}
isMerchant(): boolean {
return this.getCurrentUserRoles().includes('merchant');
}
isSupport(): boolean {
return this.getCurrentUserRoles().includes('support');
}
hasAnyRole(roles: string[]): boolean {
const userRoles = this.getCurrentUserRoles();
return roles.some(role => userRoles.includes(role));
}
hasAllRoles(roles: string[]): boolean {
const userRoles = this.getCurrentUserRoles();
return roles.every(role => userRoles.includes(role));
}
/**
* Rafraîchir les rôles (utile après modification des rôles)
*/
refreshRoles(): void {
const roles = this.getRolesFromToken();
this.userRoles$.next(roles);
}
/**
* Initialisation simple - à appeler dans app.ts
*/
initialize(): Promise<boolean> {
return new Promise((resolve) => {
@ -70,15 +152,15 @@ export class AuthService {
tap(response => {
this.handleLoginResponse(response);
}),
catchError(error => {
console.error('Login failed', error);
catchError((error: HttpErrorResponse) => {
console.error('Login failed:', error);
return throwError(() => this.getErrorMessage(error));
})
);
}
/**
* Rafraîchit le token d'accès (retourne un Observable maintenant)
* Rafraîchit le token d'accès
*/
refreshToken(): Observable<AuthResponse> {
const refreshToken = localStorage.getItem(this.refreshTokenKey);
@ -94,8 +176,8 @@ export class AuthService {
tap(response => {
this.handleLoginResponse(response);
}),
catchError(error => {
console.error('Token refresh failed', error);
catchError((error: HttpErrorResponse) => {
console.error('Token refresh failed:', error);
this.clearSession();
return throwError(() => error);
})
@ -110,7 +192,7 @@ export class AuthService {
// Appel API optionnel (ne pas bloquer dessus)
this.http.post(`${environment.apiUrl}/auth/logout`, {}).subscribe({
error: (err) => console.warn('Logout API call failed', err)
error: () => {} // Ignorer silencieusement les erreurs de logout
});
}
@ -118,12 +200,14 @@ export class AuthService {
localStorage.removeItem(this.tokenKey);
localStorage.removeItem(this.refreshTokenKey);
this.authState$.next(false);
this.userRoles$.next([]);
if (redirect) {
this.router.navigate(['/auth/sign-in']);
}
}
private handleLoginResponse(response: AuthResponse): void {
if (response?.access_token) {
localStorage.setItem(this.tokenKey, response.access_token);
@ -132,11 +216,15 @@ export class AuthService {
localStorage.setItem(this.refreshTokenKey, response.refresh_token);
}
// Mettre à jour les rôles après login
const roles = this.getRolesFromToken();
this.userRoles$.next(roles);
this.authState$.next(true);
}
}
private getErrorMessage(error: any): string {
private getErrorMessage(error: HttpErrorResponse): string {
if (error?.error?.message) {
return error.error.message;
}
@ -162,8 +250,7 @@ export class AuthService {
try {
const decoded: DecodedToken = jwtDecode(token);
const now = Math.floor(Date.now() / 1000);
// Marge de sécurité de 60 secondes
return decoded.exp < (now + 60);
return decoded.exp < (now + 60); // Marge de sécurité de 60 secondes
} catch {
return true;
}
@ -175,7 +262,6 @@ export class AuthService {
isAuthenticated(): boolean {
const token = this.getToken();
if (!token) return false;
return !this.isTokenExpired(token);
}
@ -187,6 +273,10 @@ export class AuthService {
* Récupère les infos utilisateur depuis le backend
*/
getProfile(): Observable<any> {
return this.http.get(`${environment.apiUrl}/auth/me`);
return this.http.get(`${environment.apiUrl}/users/profile/me`).pipe(
catchError(error => {
return throwError(() => error);
})
);
}
}

View File

@ -0,0 +1,148 @@
import { Injectable, inject } from '@angular/core';
import { AuthService } from './auth.service';
import { PermissionsService } from './permissions.service';
import { MenuItemType, UserDropdownItemType } from '@/app/types/layout';
@Injectable({ providedIn: 'root' })
export class MenuService {
private authService = inject(AuthService);
private permissionsService = inject(PermissionsService);
getMenuItems(): MenuItemType[] {
const userRoles = this.authService.getCurrentUserRoles();
return this.filterMenuItems(this.getFullMenu(), userRoles);
}
getUserDropdownItems(): UserDropdownItemType[] {
const userRoles = this.authService.getCurrentUserRoles();
return this.filterUserDropdownItems(this.getFullUserDropdown(), userRoles);
}
canAccess(modulePath: string): boolean {
const userRoles = this.authService.getCurrentUserRoles();
return this.permissionsService.canAccessModule(modulePath, userRoles);
}
private filterMenuItems(items: MenuItemType[], userRoles: string[]): MenuItemType[] {
return items
.filter(item => this.shouldDisplayMenuItem(item, userRoles))
.map(item => ({
...item,
children: item.children ? this.filterMenuItems(item.children, userRoles) : undefined
}));
}
private shouldDisplayMenuItem(item: MenuItemType, userRoles: string[]): boolean {
if (item.isTitle) return true;
if (item.url && item.url !== '#') {
const modulePath = this.normalizePath(item.url);
return this.permissionsService.canAccessModule(modulePath, userRoles);
}
if (item.children) {
return this.filterMenuItems(item.children, userRoles).length > 0;
}
return true;
}
private filterUserDropdownItems(items: UserDropdownItemType[], userRoles: string[]): UserDropdownItemType[] {
return items.filter(item => {
if (item.isDivider || item.isHeader || !item.url || item.url === '#') {
return true;
}
const modulePath = this.normalizePath(item.url);
return this.permissionsService.canAccessModule(modulePath, userRoles);
});
}
private normalizePath(url: string): string {
return url.startsWith('/') ? url.substring(1) : url;
}
private getFullMenu(): MenuItemType[] {
return [
{ label: 'Pilotage', isTitle: true },
{
label: 'Tableau de Bord',
icon: 'lucideBarChart2',
url: '/dcb-dashboard',
},
{ label: 'Business & Transactions', isTitle: true },
{
label: 'Transactions DCB',
icon: 'lucideCreditCard',
url: '/transactions',
},
{
label: 'Marchands',
icon: 'lucideStore',
isCollapsed: true,
children: [
{ label: 'Liste des Marchands', url: '/merchants/list' },
{ label: 'Configuration API / Webhooks', url: '/merchants/config' },
{ label: 'Statistiques & Historique', url: '/merchants/history' },
],
},
{
label: 'Opérateurs',
icon: 'lucideServer',
isCollapsed: true,
children: [
{ label: 'Paramètres d\'Intégration', url: '/operators/config' },
{ label: 'Performance & Monitoring', url: '/operators/stats' },
],
},
{
label: 'Webhooks',
icon: 'lucideShare',
isCollapsed: true,
children: [
{ label: 'Historique', url: '/webhooks/history' },
{ label: 'Statut des Requêtes', url: '/webhooks/status' },
{ label: 'Relancer Webhook', url: '/webhooks/retry' },
],
},
{ label: 'Utilisateurs & Sécurité', isTitle: true },
{
label: 'Gestion des Utilisateurs',
icon: 'lucideUsers',
url: '/users',
},
{ label: 'Configuration', isTitle: true },
{ label: 'Paramètres Système', icon: 'lucideSettings', url: '/settings' },
{ label: 'Intégrations Externes', icon: 'lucidePlug', url: '/integrations' },
{ label: 'Support & Profil', isTitle: true },
{ label: 'Support', icon: 'lucideLifeBuoy', url: '/support' },
{ label: 'Mon Profil', icon: 'lucideUser', url: '/profile' },
{ label: 'Informations', isTitle: true },
{ label: 'Documentation', icon: 'lucideBookOpen', url: '/documentation' },
{ label: 'Aide', icon: 'lucideHelpCircle', url: '/help' },
{ label: 'À propos', icon: 'lucideInfo', url: '/about' },
];
}
private getFullUserDropdown(): UserDropdownItemType[] {
return [
{ label: 'Welcome back!', isHeader: true },
{ label: 'Profile', icon: 'tablerUserCircle', url: '/profile' },
{ label: 'Account Settings', icon: 'tablerSettings2', url: '/settings' },
{ label: 'Support Center', icon: 'tablerHeadset', url: '/support' },
{ isDivider: true },
{
label: 'Log Out',
icon: 'tablerLogout2',
url: '#',
class: 'fw-semibold text-danger'
},
];
}
}

View File

@ -0,0 +1,23 @@
import { Injectable, inject } from '@angular/core';
import { ToastrService } from 'ngx-toastr';
@Injectable({ providedIn: 'root' })
export class NotificationService {
private toastr = inject(ToastrService);
success(message: string, title?: string): void {
this.toastr.success(message, title);
}
error(message: string, title?: string): void {
this.toastr.error(message, title);
}
warning(message: string, title?: string): void {
this.toastr.warning(message, title);
}
info(message: string, title?: string): void {
this.toastr.info(message, title);
}
}

View File

@ -0,0 +1,148 @@
import { Injectable } from '@angular/core';
export interface ModulePermission {
module: string;
roles: string[];
children?: { [key: string]: string[] };
}
@Injectable({ providedIn: 'root' })
export class PermissionsService {
private readonly permissions: ModulePermission[] = [
// Dashboard
{
module: 'dcb-dashboard',
roles: ['admin', 'merchant', 'support'],
},
// Transactions
{
module: 'transactions',
roles: ['admin', 'merchant', 'support'],
},
// Merchants
{
module: 'merchants',
roles: ['admin', 'merchant'],
children: {
'list': ['admin'],
'config': ['admin', 'merchant'],
'history': ['admin', 'merchant']
}
},
// Operators (Admin only)
{
module: 'operators',
roles: ['admin'],
children: {
'config': ['admin'],
'stats': ['admin']
}
},
// Webhooks
{
module: 'webhooks',
roles: ['admin', 'merchant'],
children: {
'history': ['admin', 'merchant'],
'status': ['admin', 'merchant'],
'retry': ['admin']
}
},
// Users (Admin only)
{
module: 'users',
roles: ['admin']
},
// Support (All authenticated users)
{
module: 'settings',
roles: ['admin', 'merchant', 'support']
},
// Integrations (Admin only)
{
module: 'integrations',
roles: ['admin']
},
// Support (All authenticated users)
{
module: 'support',
roles: ['admin', 'merchant', 'support']
},
// Profile (All authenticated users)
{
module: 'profile',
roles: ['admin', 'merchant', 'support']
},
// Documentation (All authenticated users)
{
module: 'documentation',
roles: ['admin', 'merchant', 'support']
},
// Help (All authenticated users)
{
module: 'help',
roles: ['admin', 'merchant', 'support']
},
// About (All authenticated users)
{
module: 'about',
roles: ['admin', 'merchant', 'support']
}
];
canAccessModule(modulePath: string, userRoles: string[]): boolean {
if (!userRoles || userRoles.length === 0) {
return false;
}
const [mainModule, subModule] = modulePath.split('/');
const permission = this.findPermission(mainModule);
if (!permission) {
console.warn(`No permission configuration for module: ${mainModule}`);
return false;
}
// Check main module access
const hasModuleAccess = this.hasAnyRole(permission.roles, userRoles);
if (!hasModuleAccess) return false;
// Check sub-module access if specified
if (subModule && permission.children) {
const subModuleRoles = permission.children[subModule];
if (!subModuleRoles) {
console.warn(`No permission configuration for submodule: ${mainModule}/${subModule}`);
return false;
}
return this.hasAnyRole(subModuleRoles, userRoles);
}
return true;
}
private findPermission(module: string): ModulePermission | undefined {
return this.permissions.find(p => p.module === module);
}
private hasAnyRole(requiredRoles: string[], userRoles: string[]): boolean {
return requiredRoles.some(role => userRoles.includes(role));
}
getAccessibleModules(userRoles: string[]): string[] {
return this.permissions
.filter(permission => this.hasAnyRole(permission.roles, userRoles))
.map(permission => permission.module);
}
}

View File

@ -19,11 +19,6 @@ export const userDropdownItems: UserDropdownItemType[] = [
icon: 'tablerUserCircle',
url: '#',
},
{
label: 'Notifications',
icon: 'tablerBellRinging',
url: '#',
},
{
label: 'Account Settings',
icon: 'tablerSettings2',
@ -58,22 +53,7 @@ export const menuItems: MenuItemType[] = [
{
label: 'Tableau de Bord',
icon: 'lucideBarChart2',
isCollapsed: true,
children: [
{ label: 'Vue Globale', url: '/dashboard/overview' },
{ label: 'KPIs & Graphiques', url: '/dashboard/kpis' },
{ label: 'Rapports', url: '/dashboard/reports' },
],
},
{
label: 'Rapports Avancés',
icon: 'lucideFile',
isCollapsed: true,
children: [
{ label: 'Financiers', url: '/reports/financial' },
{ label: 'Opérationnels', url: '/reports/operations' },
{ label: 'Export CSV / PDF', url: '/reports/export' },
],
url: '/dcb-dashboard',
},
// ---------------------------
@ -83,13 +63,7 @@ export const menuItems: MenuItemType[] = [
{
label: 'Transactions DCB',
icon: 'lucideCreditCard',
isCollapsed: true,
children: [
{ label: 'Liste & Recherche', url: '/transactions/list' },
{ label: 'Filtres Avancés', url: '/transactions/filters' },
{ label: 'Détails & Logs', url: '/transactions/details' },
{ label: 'Export', url: '/transactions/export' },
],
url: '/transactions',
},
{
label: 'Marchands',
@ -145,9 +119,7 @@ export const menuItems: MenuItemType[] = [
icon: 'lucideUsers',
isCollapsed: true,
children: [
{ label: 'Liste des Utilisateurs', url: '/users/list' },
{ label: 'Rôles & Permissions', url: '/users/roles' },
{ label: 'Audit & Historique', url: '/users/audits' },
{ label: 'Liste des Utilisateurs', url: '/users' },
],
},
{

View File

@ -4,7 +4,7 @@
<li class="side-nav-title mt-2">{{ item.label }}</li>
}
@if (!item.isTitle) {
@if (!item.isTitle && shouldDisplayItem(item)) {
<!-- menu item without any child -->
@if (!hasSubMenu(item)) {
<ng-container *ngTemplateOutlet="MenuItem; context: { item }" />
@ -49,6 +49,8 @@
>
<ul class="sub-menu">
@for (child of item.children; track $index) {
<!-- Vérifier si l'enfant doit être affiché -->
@if (shouldDisplayItem(child)) {
<!-- menu item without any child -->
@if (!hasSubMenu(child)) {
<ng-container
@ -63,6 +65,7 @@
/>
}
}
}
</ul>
</div>
</li>

View File

@ -4,25 +4,27 @@ import {
OnInit,
TemplateRef,
ViewChild,
OnDestroy,
} from '@angular/core'
import { MenuItemType } from '@/app/types/layout'
import { CommonModule } from '@angular/common'
import { NgIcon } from '@ng-icons/core'
import { NgbCollapse } from '@ng-bootstrap/ng-bootstrap'
import { NavigationEnd, Router, RouterLink } from '@angular/router'
import { filter } from 'rxjs'
import { filter, Subscription } from 'rxjs'
import { scrollToElement } from '@/app/utils/layout-utils'
import { menuItems } from '@layouts/components/data'
import { LayoutStoreService } from '@core/services/layout-store.service'
import { MenuService } from '@core/services/menu.service'
@Component({
selector: 'app-menu',
imports: [NgIcon, NgbCollapse, RouterLink, CommonModule],
templateUrl: './app-menu.component.html',
})
export class AppMenuComponent implements OnInit {
router = inject(Router)
layout = inject(LayoutStoreService)
export class AppMenuComponent implements OnInit, OnDestroy {
private router = inject(Router)
private layout = inject(LayoutStoreService)
private menuService = inject(MenuService)
@ViewChild('MenuItemWithChildren', { static: true })
menuItemWithChildren!: TemplateRef<{ item: MenuItemType }>
@ -30,9 +32,12 @@ export class AppMenuComponent implements OnInit {
@ViewChild('MenuItem', { static: true })
menuItem!: TemplateRef<{ item: MenuItemType }>
menuItems = menuItems
menuItems: MenuItemType[] = []
private subscription?: Subscription
ngOnInit(): void {
this.loadFilteredMenu()
this.router.events
.pipe(filter((event) => event instanceof NavigationEnd))
.subscribe(() => {
@ -40,15 +45,25 @@ export class AppMenuComponent implements OnInit {
setTimeout(() => this.scrollToActiveLink(), 50)
})
setTimeout(() => {
this.expandActivePaths(this.menuItems)
setTimeout(() => this.scrollToActiveLink(), 100)
this.scrollToActiveLink()
}, 100)
}
ngOnDestroy(): void {
this.subscription?.unsubscribe()
}
private loadFilteredMenu(): void {
this.menuItems = this.menuService.getMenuItems()
}
hasSubMenu(item: MenuItemType): boolean {
return !!item.children
return !!item.children && item.children.length > 0
}
expandActivePaths(items: MenuItemType[]) {
expandActivePaths(items: MenuItemType[]): void {
for (const item of items) {
if (this.hasSubMenu(item)) {
item.isCollapsed = !this.isChildActive(item)
@ -86,4 +101,19 @@ export class AppMenuComponent implements OnInit {
scrollToElement(scrollContainer, scrollContainer.scrollTop + offset, 500)
}
}
/**
* Vérifie si un élément de menu doit être affiché selon les permissions
*/
shouldDisplayItem(item: MenuItemType): boolean {
// Les titres sont toujours affichés
if (item.isTitle) return true
// Les éléments sans URL sont affichés (comme les conteneurs)
if (!item.url || item.url === '#') return true
// Pour les éléments avec URL, vérifier les permissions
const modulePath = item.url.startsWith('/') ? item.url.substring(1) : item.url
return this.menuService.canAccess(modulePath)
}
}

View File

@ -6,7 +6,7 @@
alt="user-image"
/>
<div>
<h5 class="my-0 fw-semibold">{{ user?.given_name }} - {{ user?.family_name }}</h5>
<h5 class="my-0 fw-semibold">{{ user?.firstName }} - {{ user?.lastName }}</h5>
<h6 class="my-0 text-muted">Administrateur</h6>
</div>
</div>

View File

@ -12,16 +12,21 @@
/>
</button>
<div ngbDropdownMenu class="dropdown-menu dropdown-menu-end">
@for (item of menuItems; track i; let i = $index) {
@for (item of menuItems; track $index; let i = $index) {
<div>
<!-- En-tête -->
@if (item.isHeader) {
<div class="dropdown-header noti-title">
<h6 class="text-overflow m-0">{{ item.label }}</h6>
</div>
}
<!-- Séparateur -->
@if (item.isDivider) {
<div class="dropdown-divider"></div>
}
<!-- Élément normal -->
@if (!item.isHeader && !item.isDivider) {
@if (item.label === 'Log Out') {
<!-- Bouton Logout avec appel de méthode -->
@ -37,13 +42,18 @@
</button>
} @else {
<!-- Autres items avec navigation normale -->
<a [routerLink]="item.url" class="dropdown-item" [class]="item.class">
<a
[routerLink]="item.url"
class="dropdown-item"
[class]="item.class"
[class.disabled]="item.isDisabled"
[attr.target]="item.target">
<ng-icon
[name]="item.icon"
size="17"
class="align-middle d-inline-flex align-items-center me-2"
/>
<span class="align-middle" [innerHTML]="item.label"></span>
<span class="align-middle">{{ item.label }}</span>
</a>
}
}

View File

@ -1,13 +1,15 @@
import { Component, inject } from '@angular/core'
import { AuthService } from '@core/services/auth.service';
import { Component, inject, OnInit, OnDestroy } from '@angular/core'
import { AuthService } from '@core/services/auth.service'
import { MenuService } from '@core/services/menu.service'
import {
NgbDropdown,
NgbDropdownMenu,
NgbDropdownToggle,
} from '@ng-bootstrap/ng-bootstrap'
import { userDropdownItems } from '@layouts/components/data'
import { RouterLink } from '@angular/router'
import { NgIcon } from '@ng-icons/core'
import { UserDropdownItemType } from '@/app/types/layout'
import { Subscription } from 'rxjs'
@Component({
selector: 'app-user-profile-topbar',
@ -20,21 +22,37 @@ import { NgIcon } from '@ng-icons/core'
],
templateUrl: './user-profile.html',
})
export class UserProfile {
private authService = inject(AuthService);
export class UserProfile implements OnInit, OnDestroy {
private authService = inject(AuthService)
private menuService = inject(MenuService)
private subscription?: Subscription
menuItems = userDropdownItems;
menuItems: UserDropdownItemType[] = []
ngOnInit() {
this.loadDropdownItems()
// Optionnel : réagir aux changements d'authentification
this.subscription = this.authService.onAuthState().subscribe(() => {
this.loadDropdownItems()
})
}
ngOnDestroy() {
this.subscription?.unsubscribe()
}
private loadDropdownItems() {
this.menuItems = this.menuService.getUserDropdownItems()
}
// Méthode pour gérer le logout
logout() {
this.authService.logout();
this.authService.logout()
}
// Méthode pour gérer les clics sur les items du menu
handleItemClick(item: any) {
handleItemClick(item: UserDropdownItemType) {
if (item.label === 'Log Out') {
this.logout();
this.logout()
}
// Pour les autres items, la navigation se fait via routerLink
}
}

View File

@ -0,0 +1,89 @@
// error-404.component.ts
import { Component } from '@angular/core'
import { Router } from '@angular/router'
import { credits, currentYear } from '@/app/constants'
@Component({
selector: 'app-error-404',
imports: [],
template: `
<div class="auth-box overflow-hidden align-items-center d-flex">
<div class="container">
<div class="row justify-content-center">
<div class="col-xxl-4 col-md-6 col-sm-8">
<div class="card">
<div class="card-body">
<div class="p-4 text-center">
<!-- Icône -->
<div class="mb-4">
<div class="avatar-lg mx-auto bg-warning bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center" style="width: 80px; height: 80px;">
<div class="text-error fw-bold fs-60">404</div>
</div>
</div>
<!-- Titre et message -->
<h1 class="text-warning fw-bold mb-3">Page Non Trouvée</h1>
<h4 class="fw-semibold mb-3">404 - Introuvable</h4>
<p class="text-muted mb-4">
La page que vous recherchez n'existe pas ou a é déplacée.
Vérifiez l'URL ou utilisez la navigation principale.
</p>
<!-- Actions -->
<div class="d-grid gap-2 d-md-flex justify-content-center">
<button
class="btn btn-primary px-4"
(click)="goHome()"
>
<i class="fas fa-home me-2"></i>
Retour à l'accueil
</button>
<button
class="btn btn-outline-secondary px-4"
(click)="goBack()"
>
<i class="fas fa-arrow-left me-2"></i>
Page précédente
</button>
</div>
</div>
</div>
</div>
<p class="text-center text-muted mt-4 mb-0">
© {{ currentYear }} Simple by
<span class="fw-semibold">{{ credits.name }}</span>
</p>
</div>
</div>
</div>
</div>
`,
styles: [`
.auth-box {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.card {
border: none;
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
}
`]
})
export class Error404 {
protected readonly currentYear = currentYear
protected readonly credits = credits
constructor(private router: Router) {}
goHome() {
this.router.navigate(['/dcb-dashboard'])
}
goBack() {
window.history.back()
}
}

View File

@ -0,0 +1,21 @@
import { Routes } from '@angular/router'
import { Error404 } from './error-404'
import { Unauthorized } from '../unauthorized'
export const ERROR_PAGES_ROUTES: Routes = [
{
path: 'error/404',
component: Error404,
data: { title: 'Page Non Trouvée' },
},
{
path: 'error/403',
component: Unauthorized,
data: { title: 'Accès Refusé' },
},
{
path: 'unauthorized',
component: Unauthorized,
data: { title: 'Accès Refusé' },
}
]

View File

@ -1,11 +1,13 @@
import { Component } from '@angular/core'
import { appName, credits, currentYear } from '@/app/constants'
import { Component, inject } from '@angular/core'
import { RouterLink } from '@angular/router'
import { FormsModule } from '@angular/forms'
import { AppLogo } from '@app/components/app-logo'
import { UsersService } from '../users/services/users.service'
import { credits, currentYear } from '@/app/constants'
@Component({
selector: 'app-reset-password',
imports: [RouterLink, AppLogo],
imports: [RouterLink, AppLogo, FormsModule],
template: `
<div class="auth-box overflow-hidden align-items-center d-flex">
<div class="container">
@ -16,23 +18,36 @@ import { AppLogo } from '@app/components/app-logo'
<div class="auth-brand mb-4">
<app-app-logo />
<p class="text-muted w-lg-75 mt-3">
Enter your email address and we'll send you a link to reset
your password.
Entrez votre adresse email et nous vous enverrons un lien pour réinitialiser votre mot de passe.
</p>
</div>
<form>
<!-- Message de succès -->
<div *ngIf="successMessage" class="alert alert-success">
{{ successMessage }}
</div>
<!-- Message d'erreur -->
<div *ngIf="errorMessage" class="alert alert-danger">
{{ errorMessage }}
</div>
<form (ngSubmit)="onSubmit()" #resetForm="ngForm">
<div class="mb-3">
<label for="userEmail" class="form-label"
>Email address <span class="text-danger">*</span></label
>
<label for="userEmail" class="form-label">
Adresse email <span class="text-danger">*</span>
</label>
<div class="input-group">
<input
type="email"
class="form-control"
id="userEmail"
placeholder="you@example.com"
placeholder="vous@exemple.com"
[(ngModel)]="email"
name="email"
required
email
[disabled]="loading"
/>
</div>
</div>
@ -43,10 +58,14 @@ import { AppLogo } from '@app/components/app-logo'
class="form-check-input form-check-input-light fs-14"
type="checkbox"
id="termAndPolicy"
[(ngModel)]="termsAccepted"
name="termsAccepted"
required
[disabled]="loading"
/>
<label class="form-check-label" for="termAndPolicy"
>Agree the Terms & Policy</label
>
<label class="form-check-label" for="termAndPolicy">
J'accepte les Conditions Générales d'Utilisation
</label>
</div>
</div>
@ -54,25 +73,28 @@ import { AppLogo } from '@app/components/app-logo'
<button
type="submit"
class="btn btn-primary fw-semibold py-2"
[disabled]="loading || !resetForm.form.valid"
>
Send Request
<span *ngIf="loading" class="spinner-border spinner-border-sm me-2"></span>
{{ loading ? 'Envoi en cours...' : 'Envoyer la demande' }}
</button>
</div>
</form>
<p class="text-muted text-center mt-4 mb-0">
Return to
Retour à
<a
routerLink="/auth/sign-in"
class="text-decoration-underline link-offset-3 fw-semibold"
>Sign in</a
>
Connexion
</a>
</p>
</div>
</div>
<p class="text-center text-muted mt-4 mb-0">
© {{ currentYear }} {{ appName }}. Tous droits réservés. Développé par
© {{ currentYear }} Simple par
<span class="fw-semibold">{{ credits.name }}</span>
</p>
</div>
@ -80,10 +102,89 @@ import { AppLogo } from '@app/components/app-logo'
</div>
</div>
`,
styles: ``,
styles: [`
.auth-box {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.card {
border: none;
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
}
`]
})
export class ResetPassword {
protected readonly appName = appName
private usersService = inject(UsersService)
email = ''
termsAccepted = false
loading = false
successMessage = ''
errorMessage = ''
protected readonly currentYear = currentYear
protected readonly credits = credits
onSubmit() {
if (!this.email || !this.termsAccepted) {
this.errorMessage = 'Veuillez remplir tous les champs obligatoires et accepter les conditions.';
return;
}
this.loading = true;
this.successMessage = '';
this.errorMessage = '';
// Simulation d'envoi d'email de réinitialisation
// Note: Cette fonctionnalité nécessite un backend configuré pour envoyer des emails
this.usersService.findUserByEmail(this.email).subscribe({
next: (users) => {
this.loading = false;
if (users && users.length > 0) {
// Si l'utilisateur existe, afficher un message de succès
this.successMessage = 'Un lien de réinitialisation a été envoyé à votre adresse email.';
this.errorMessage = '';
// Ici, vous devriez normalement appeler un service backend
// qui envoie un email de réinitialisation
console.log('Email de réinitialisation envoyé à:', this.email);
} else {
this.errorMessage = 'Aucun utilisateur trouvé avec cette adresse email.';
this.successMessage = '';
}
},
error: (error) => {
this.loading = false;
this.errorMessage = 'Une erreur est survenue lors de la recherche de l\'utilisateur.';
this.successMessage = '';
console.error('Error finding user:', error);
}
});
}
// Alternative: Réinitialisation directe du mot de passe
resetPasswordDirectly(userId: string) {
const newPassword = prompt('Nouveau mot de passe:');
if (newPassword && newPassword.length >= 8) {
const resetDto = {
userId: userId,
newPassword: newPassword,
temporary: false
};
this.usersService.resetPassword(resetDto).subscribe({
next: () => {
alert('Mot de passe réinitialisé avec succès');
},
error: (error) => {
console.error('Error resetting password:', error);
alert('Erreur lors de la réinitialisation du mot de passe');
}
});
} else if (newPassword) {
alert('Le mot de passe doit contenir au moins 8 caractères');
}
}
}

View File

@ -160,7 +160,7 @@ export class SignIn {
this.authService.login(this.username, this.password).subscribe({
next: (res) => {
this.router.navigate(['/dashboard']);
this.router.navigate(['/dcb-dashboard']);
this.loading = false;
},
error: (err) => {

View File

@ -0,0 +1,96 @@
// unauthorized.component.ts
import { Component } from '@angular/core'
import { Router } from '@angular/router'
import { credits, currentYear } from '@/app/constants'
@Component({
selector: 'app-unauthorized',
imports: [],
template: `
<div class="auth-box overflow-hidden align-items-center d-flex">
<div class="container">
<div class="row justify-content-center">
<div class="col-xxl-4 col-md-6 col-sm-8">
<div class="card">
<div class="card-body">
<div class="p-4 text-center">
<!-- Icône d'alerte -->
<div class="mb-4">
<div class="avatar-lg mx-auto bg-warning bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center" style="width: 80px; height: 80px;">
<div class="text-error fw-bold fs-60">403</div>
</div>
</div>
<!-- Titre et message -->
<h1 class="text-danger fw-bold mb-3">Accès Refusé</h1>
<h4 class="fw-semibold mb-3">403 - Non Autorisé</h4>
<p class="text-muted mb-4">
Vous n'avez pas les permissions nécessaires pour accéder à cette page.
Contactez votre administrateur si vous pensez qu'il s'agit d'une erreur.
</p>
<!-- Actions -->
<div class="d-grid gap-2 d-md-flex justify-content-center">
<button
class="btn btn-primary px-4"
(click)="goHome()"
>
<i class="fas fa-home me-2"></i>
Retour à l'accueil
</button>
<button
class="btn btn-outline-secondary px-4"
(click)="goBack()"
>
<i class="fas fa-arrow-left me-2"></i>
Page précédente
</button>
</div>
<!-- Informations supplémentaires -->
<div class="mt-4 p-3 bg-light rounded">
<small class="text-muted">
<i class="fas fa-info-circle me-1"></i>
Rôles requis : Administrateur, Gestionnaire ou Support
</small>
</div>
</div>
</div>
</div>
<!-- Footer -->
<p class="text-center text-muted mt-4 mb-0">
© {{ currentYear }} Simple by
<span class="fw-semibold">{{ credits.name }}</span>
</p>
</div>
</div>
</div>
</div>
`,
styles: [`
.auth-box {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.card {
border: none;
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
}
`]
})
export class Unauthorized {
protected readonly currentYear = currentYear
protected readonly credits = credits
constructor(private router: Router) {}
goHome() {
this.router.navigate(['/dcb-dashboard'])
}
goBack() {
window.history.back()
}
}

View File

@ -1,3 +1,10 @@
export const paginationIcons = {
first: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-chevrons-left"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M11 7l-5 5l5 5" /><path d="M17 7l-5 5l5 5" /></svg>`,
previous: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-chevron-left"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M15 6l-6 6l6 6" /></svg>`,
next: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-chevron-right"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M9 6l6 6l-6 6" /></svg>`,
last: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-chevrons-right"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M7 7l5 5l-5 5" /><path d="M13 7l5 5l-5 5" /></svg>`,
}
export const states = [
'Alabama',
'Alaska',

View File

@ -1,42 +0,0 @@
import { Component } from '@angular/core'
import { NgIcon } from '@ng-icons/core'
import { NgbProgressbarModule } from '@ng-bootstrap/ng-bootstrap'
@Component({
selector: 'app-active-users',
imports: [NgIcon, NgbProgressbarModule],
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">Active Users</h5>
<h3 class="mb-0 fw-normal"><span data-target="342">342</span></h3>
<p class="text-muted mb-2">In the last hour</p>
</div>
<div>
<ng-icon name="lucideUsers" class="text-muted fs-24 svg-sw-10" />
</div>
</div>
<ngb-progressbar [value]="68" class="progress-lg mb-3" />
<div class="d-flex justify-content-between">
<div>
<span class="text-muted">Avg. Session Time</span>
<h5 class="mb-0">4m 12s</h5>
</div>
<div class="text-end">
<span class="text-muted">Returning Users</span>
<h5 class="mb-0">54.9%</h5>
</div>
</div>
</div>
<div class="card-footer text-muted text-center">
52 new users joined today
</div>
</div>
`,
styles: ``,
})
export class ActiveUsers {}

View File

@ -1,110 +0,0 @@
import { Component } from '@angular/core'
import { TableType } from '@/app/types'
import { currency } from '@/app/constants'
import { DecimalPipe } from '@angular/common'
type APIPerformanceMetricsType = {
endpoint: string
latency: string
requests: string
errorRate: number
cost: number
}
@Component({
selector: 'app-api-performance-metrics',
imports: [DecimalPipe],
template: `
<div class="card">
<div class="card-header border-dashed">
<h4 class="card-title mb-0">AI API Performance Metrics</h4>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table
class="table table-sm table-centered table-nowrap table-custom mb-0"
>
<thead class="bg-light-subtle thead-sm">
<tr class="text-uppercase fs-xxs">
@for (
header of apiPerformanceMetricsTable.headers;
track header
) {
<th>{{ header }}</th>
}
</tr>
</thead>
<tbody>
@for (
item of apiPerformanceMetricsTable.body;
track item.endpoint
) {
<tr>
<td>{{ item.endpoint }}</td>
<td>{{ item.latency }}</td>
<td>{{ item.requests }}</td>
<td>{{ item.errorRate | number: '1.2-2' }}%</td>
<td>{{ item.cost | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
</div>
</div>
<div class="card-footer border-top-0 text-end">
<span class="text-muted">API stats updated: 2025-06-16 08:32 AM</span>
</div>
</div>
`,
styles: ``,
})
export class ApiPerformanceMetrics {
apiPerformanceMetricsTable: TableType<APIPerformanceMetricsType> = {
headers: [
'Endpoint',
'Latency',
'Requests',
'Error Rate',
`Cost (${currency})`,
],
body: [
{
endpoint: '/v1/chat/completions',
latency: '720ms',
requests: '8,204',
errorRate: 0.18,
cost: 128.34,
},
{
endpoint: '/v1/images/generations',
latency: '930ms',
requests: '1,029',
errorRate: 0.03,
cost: 43.89,
},
{
endpoint: '/v1/audio/transcriptions',
latency: '1.2s',
requests: '489',
errorRate: 0.0,
cost: 16.45,
},
{
endpoint: '/v1/embeddings',
latency: '610ms',
requests: '2,170',
errorRate: 0.1,
cost: 24.98,
},
{
endpoint: '/v1/chat/moderation',
latency: '450ms',
requests: '5,025',
errorRate: 0.01,
cost: 7.52,
},
],
}
}

View File

@ -1,103 +0,0 @@
import { Component } from '@angular/core'
import { TableType } from '@/app/types'
import { DecimalPipe } from '@angular/common'
type ModelUsageType = {
model: string
requests: number
totalTokens: number
averageTokens: number
lastUsed: string
}
@Component({
selector: 'app-model-usage-summary',
imports: [DecimalPipe],
template: `
<div class="card">
<div class="card-header border-dashed">
<h4 class="card-title mb-0">AI Model Usage Summary</h4>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table
class="table table-sm table-centered table-custom table-nowrap mb-0"
>
<thead class="bg-light-subtle thead-sm">
<tr class="text-uppercase fs-xxs">
@for (header of modelUsageTable.headers; track header) {
<th>{{ header }}</th>
}
</tr>
</thead>
<tbody>
@for (item of modelUsageTable.body; track item.model) {
<tr>
<td>{{ item.model }}</td>
<td>{{ item.requests | number }}</td>
<td>{{ item.totalTokens | number }}</td>
<td>{{ item.averageTokens | number }}</td>
<td>{{ item.lastUsed }}</td>
</tr>
}
</tbody>
</table>
</div>
</div>
<div class="card-footer border-top-0 text-end">
<span class="text-muted">Updated 1 hour ago</span>
</div>
</div>
`,
styles: ``,
})
export class ModelUsageSummary {
modelUsageTable: TableType<ModelUsageType> = {
headers: [
'Model',
'Requests',
'Total Tokens',
'Average Tokens',
'Last Used',
],
body: [
{
model: 'GPT-4',
requests: 1248,
totalTokens: 2483920,
averageTokens: 1989,
lastUsed: '2025-06-15',
},
{
model: 'DALL·E',
requests: 328,
totalTokens: 194320,
averageTokens: 592,
lastUsed: '2025-06-14',
},
{
model: 'Claude 2',
requests: 814,
totalTokens: 1102390,
averageTokens: 1354,
lastUsed: '2025-06-13',
},
{
model: 'Whisper',
requests: 512,
totalTokens: 653210,
averageTokens: 1275,
lastUsed: '2025-06-12',
},
{
model: 'Stable Diffusion',
requests: 102,
totalTokens: 61400,
averageTokens: 602,
lastUsed: '2025-06-10',
},
],
}
}

View File

@ -1,87 +0,0 @@
import { Component } from '@angular/core'
import { BaseChartDirective } from 'ng2-charts'
import { NgIcon } from '@ng-icons/core'
import { Chartjs } from '@app/components/chartjs'
import { ChartConfiguration } from 'chart.js'
import { getColor } from '@/app/utils/color-utils'
import { CountUpModule } from 'ngx-countup'
@Component({
selector: 'app-prompts-usage',
imports: [NgIcon, Chartjs, CountUpModule],
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">Today's Prompts</h5>
</div>
<div>
<ng-icon
name="lucideMessageSquare"
class="text-muted fs-24 svg-sw-10"
></ng-icon>
</div>
</div>
<div class="mb-3">
<app-chartjs [getOptions]="promptChart" style="max-height: 60px" />
</div>
<div class="d-flex justify-content-between">
<div>
<span class="text-muted">Today</span>
<div class="fw-semibold">
<span [countUp]="1245">1,245</span> prompts
</div>
</div>
<div class="text-end">
<span class="text-muted">Yesterday</span>
<div
class="fw-semibold d-flex align-items-center justify-content-end gap-1"
>
<span [countUp]="1110">1,110</span>
<ng-icon name="tablerArrowUp" />
</div>
</div>
</div>
</div>
<div class="card-footer text-muted text-center">
Prompt volume increased by <strong>12%</strong> today
</div>
</div>
`,
styles: ``,
})
export class PromptsUsage {
public promptChart = (): ChartConfiguration => ({
type: 'bar',
data: {
labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
datasets: [
{
data: [120, 150, 180, 220, 200, 245, 145],
backgroundColor: getColor('chart-primary'),
borderRadius: 4,
borderSkipped: false,
},
],
},
options: {
plugins: {
legend: { display: false },
tooltip: { enabled: false },
},
scales: {
x: {
display: false,
grid: { display: false },
},
y: {
display: false,
grid: { display: false },
},
},
},
})
}

View File

@ -1,259 +0,0 @@
import { Component } from '@angular/core'
import { NgIcon } from '@ng-icons/core'
import { RouterLink } from '@angular/router'
import { DecimalPipe } from '@angular/common'
import { toTitleCase } from '@/app/utils/string-utils'
import {
NgbDropdownModule,
NgbPaginationModule,
} from '@ng-bootstrap/ng-bootstrap'
type SessionType = {
id: string
user: {
name: string
avatar: string
}
aiModel: string
date: string
tokens: number
status: 'completed' | 'pending' | 'failed'
}
@Component({
selector: 'app-recent-sessions',
imports: [
NgIcon,
RouterLink,
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">Recent AI Sessions</h4>
<div class="d-flex gap-2">
<a href="javascript:void(0);" class="btn btn-sm btn-light">
<ng-icon name="tablerPlus" class="me-1" />
New Session
</a>
<a href="javascript:void(0);" class="btn btn-sm btn-primary">
<ng-icon name="tablerFileExport" class="me-1" />
Export Logs
</a>
</div>
</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"
>
<tbody>
@for (session of sessions; track session.id) {
<tr>
<td>
<div class="d-flex align-items-center">
<img
[src]="session.user.avatar"
[alt]="session.user.name"
class="avatar-sm rounded-circle me-2"
/>
<div>
<span class="text-muted fs-xs">{{
session.user.name
}}</span>
<h5 class="fs-base mb-0">
<a [routerLink]="[]" class="text-body">{{
session.id
}}</a>
</h5>
</div>
</div>
</td>
<td>
<span class="text-muted fs-xs">Model</span>
<h5 class="fs-base mb-0 fw-normal">
{{ session.aiModel }}
</h5>
</td>
<td>
<span class="text-muted fs-xs">Date</span>
<h5 class="fs-base mb-0 fw-normal">{{ session.date }}</h5>
</td>
<td>
<span class="text-muted fs-xs">Tokens</span>
<h5 class="fs-base mb-0 fw-normal">
{{ session.tokens | number }}
</h5>
</td>
<td>
<span class="text-muted fs-xs">Status</span>
<h5
class="fs-base mb-0 fw-normal d-flex align-items-center"
>
<ng-icon
name="tablerCircleFill"
class="fs-xs me-1"
[class]="
session.status === 'failed' ? 'text-danger' : ''
"
></ng-icon>
{{ toTitleCase(session.status) }}
</h5>
</td>
<td style="width: 30px;">
<div ngbDropdown placement="bottom-end">
<a
href="javascript:void(0);"
ngbDropdownToggle
class="text-muted drop-arrow-none card-drop p-0"
>
<ng-icon name="tablerDotsVertical" class="fs-lg" />
</a>
<div class="dropdown-menu-end" ngbDropdownMenu>
<a href="javascript:void(0)" ngbDropdownItem
>View Details</a
>
@if (session.status === 'completed') {
<a href="javascript:void(0)" ngbDropdownItem
>Delete</a
>
} @else if (
session.status === 'pending' ||
session.status === 'failed'
) {
<a href="javascript:void(0)" ngbDropdownItem>Retry</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">
Showing <span class="fw-semibold">1</span> to
<span class="fw-semibold">10</span> of
<span class="fw-semibold">2684</span> Sessions
</div>
</div>
<div class="col-sm-auto mt-3 mt-sm-0">
<ngb-pagination
[pageSize]="10"
[collectionSize]="20"
class="pagination-sm pagination-boxed mb-0 justify-content-center"
>
<ng-template ngbPaginationPrevious>
<ng-icon name="tablerChevronLeft" />
</ng-template>
<ng-template ngbPaginationNext>
<ng-icon name="tablerChevronRight" />
</ng-template>
</ngb-pagination>
</div>
</div>
</div>
</div>
`,
styles: ``,
})
export class RecentSessions {
sessions: SessionType[] = [
{
id: '#AI-5001',
user: { name: 'Alice Cooper', avatar: 'assets/images/users/user-1.jpg' },
aiModel: 'GPT-4',
date: '2025-05-01',
tokens: 2304,
status: 'completed',
},
{
id: '#AI-5002',
user: { name: 'David Lee', avatar: 'assets/images/users/user-2.jpg' },
aiModel: 'DALL·E',
date: '2025-04-30',
tokens: 580,
status: 'pending',
},
{
id: '#AI-5003',
user: { name: 'Sophia Turner', avatar: 'assets/images/users/user-3.jpg' },
aiModel: 'Whisper',
date: '2025-04-29',
tokens: 1102,
status: 'completed',
},
{
id: '#AI-5004',
user: { name: 'James Wilson', avatar: 'assets/images/users/user-4.jpg' },
aiModel: 'GPT-3.5',
date: '2025-04-28',
tokens: 760,
status: 'failed',
},
{
id: '#AI-5005',
user: { name: 'Ava Carter', avatar: 'assets/images/users/user-5.jpg' },
aiModel: 'Claude 2',
date: '2025-04-27',
tokens: 1678,
status: 'completed',
},
{
id: '#AI-5006',
user: { name: 'Ethan Brooks', avatar: 'assets/images/users/user-6.jpg' },
aiModel: 'Gemini Pro',
date: '2025-04-26',
tokens: 945,
status: 'pending',
},
{
id: '#AI-5007',
user: { name: 'Mia Clarke', avatar: 'assets/images/users/user-7.jpg' },
aiModel: 'GPT-4 Turbo',
date: '2025-04-25',
tokens: 2189,
status: 'completed',
},
{
id: '#AI-5008',
user: { name: 'Lucas Perry', avatar: 'assets/images/users/user-8.jpg' },
aiModel: 'Stable Diffusion',
date: '2025-04-24',
tokens: 312,
status: 'failed',
},
{
id: '#AI-5009',
user: { name: 'Chloe Adams', avatar: 'assets/images/users/user-9.jpg' },
aiModel: 'GPT-4',
date: '2025-04-23',
tokens: 1784,
status: 'completed',
},
{
id: '#AI-5010',
user: {
name: 'Benjamin Gray',
avatar: 'assets/images/users/user-10.jpg',
},
aiModel: 'Whisper',
date: '2025-04-22',
tokens: 890,
status: 'pending',
},
]
protected readonly toTitleCase = toTitleCase
}

View File

@ -1,129 +0,0 @@
import { Component } from '@angular/core'
import { CountUpModule } from 'ngx-countup'
import { NgIcon } from '@ng-icons/core'
import { ChartConfiguration } from 'chart.js'
import { getColor } from '@/app/utils/color-utils'
import { Chartjs } from '@app/components/chartjs'
@Component({
selector: 'app-request-statistics',
imports: [CountUpModule, NgIcon, Chartjs],
template: `
<div class="card">
<div class="card-body">
<div class="row align-items-center">
<div class="col-xl-3 col-md-6">
<div class="text-center">
<p
class="mb-4 d-flex align-items-center gap-1 justify-content-center"
>
<ng-icon name="lucideBot" />
AI Requests
</p>
<h2 class="fw-bold mb-0"><span [countUp]="807621">0</span></h2>
<p class="text-muted">Total AI requests in last 30 days</p>
<p
class="mb-0 mt-4 d-flex align-items-center gap-1 justify-content-center"
>
<ng-icon name="lucideCalendar" /> Data from May
</p>
</div>
</div>
<div class="col-xl-3 col-md-6 order-xl-last">
<div class="text-center">
<p
class="mb-4 d-flex align-items-center gap-1 justify-content-center"
>
<ng-icon name="lucideTimer" />
Usage Duration
</p>
<h2 class="fw-bold mb-0">9 Months</h2>
<p class="text-muted">Including 4 weeks this quarter</p>
<p
class="mb-0 mt-4 d-flex align-items-center gap-1 justify-content-center"
>
<ng-icon name="lucideClock" />
Last accessed: 12.06.2025
</p>
</div>
</div>
<div class="col-xl-6">
<div class="w-100" style="height: 240px;">
<app-chartjs
[height]="240"
[getOptions]="activeUsersChartOptions"
style="max-height: 240px"
/>
</div>
</div>
</div>
</div>
<div class="card-footer">
<div
class="d-flex align-items-center text-muted justify-content-between"
>
<div>Last update: 16.06.2025</div>
<div>You received 2 new AI feedback reports</div>
</div>
</div>
</div>
`,
styles: ``,
})
export class RequestStatistics {
generateSmoothData(count: number, start: number = 40, variation: number = 5) {
const data = [start]
for (let i = 1; i < count; i++) {
const prev = data[i - 1]
const next = prev + (Math.random() * variation * 2 - variation)
data.push(Math.round(next))
}
return data
}
generateHigherData(baseData: number[], diffRange: [number, number] = [3, 6]) {
return baseData.map(
(val) =>
val +
Math.floor(Math.random() * (diffRange[1] - diffRange[0] + 1)) +
diffRange[0]
)
}
labels = ['0h', '3h', '6h', '9h', '12h', '15h', '18h', '21h']
currentAiUsers = this.generateSmoothData(8, 45, 4)
previousAiUsers = this.generateHigherData(this.currentAiUsers)
activeUsersChartOptions = (): ChartConfiguration => ({
type: 'line',
data: {
labels: this.labels,
datasets: [
{
label: 'AI Users (Today)',
data: this.currentAiUsers,
fill: true,
borderColor: getColor('chart-primary'),
backgroundColor: getColor('chart-primary-rgb', 0.2),
tension: 0.4,
pointRadius: 0,
borderWidth: 1,
},
{
label: 'AI Users (Yesterday)',
data: this.previousAiUsers,
fill: true,
borderColor: getColor('chart-gray'),
backgroundColor: getColor('chart-gray-rgb', 0.2),
tension: 0.4,
pointRadius: 0,
borderWidth: 1,
},
],
},
})
}

View File

@ -1,81 +0,0 @@
import { Component } from '@angular/core'
import { NgIcon } from '@ng-icons/core'
import { getColor } from '@/app/utils/color-utils'
import { Chartjs } from '@app/components/chartjs'
import { ChartConfiguration } from 'chart.js'
@Component({
selector: 'app-response-accuracy',
imports: [NgIcon, Chartjs],
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">Response Accuracy</h5>
</div>
<div>
<ng-icon name="lucideActivity" class="text-muted fs-24 svg-sw-10" />
</div>
</div>
<div class="d-flex align-items-center justify-content-center ">
<app-chartjs
[getOptions]="accuracyChartOptions"
style="height: 120px"
/>
</div>
</div>
<div class="card-footer text-muted text-center">
Current accuracy: <strong>94.3%</strong>
</div>
</div>
`,
styles: ``,
})
export class ResponseAccuracy {
accuracyChartOptions = (): ChartConfiguration => ({
type: 'pie',
data: {
labels: ['Correct', 'Partially Correct', 'Incorrect', 'Unclear'],
datasets: [
{
data: [65, 20, 10, 5],
backgroundColor: [
getColor('chart-primary'),
getColor('chart-secondary'),
getColor('chart-gray'),
getColor('chart-dark'),
],
borderColor: '#fff',
borderWidth: 0,
},
],
},
options: {
plugins: {
legend: { display: false },
tooltip: {
enabled: true,
callbacks: {
label: function (ctx: any) {
return `${ctx.label}: ${ctx.parsed}%`
},
},
},
},
scales: {
x: {
display: false,
grid: { display: false },
ticks: { display: false },
},
y: {
display: false,
grid: { display: false },
ticks: { display: false },
},
},
},
})
}

View File

@ -1,90 +0,0 @@
import { Component } from '@angular/core'
import { ChartConfiguration } from 'chart.js'
import { getColor } from '@/app/utils/color-utils'
import { NgIcon } from '@ng-icons/core'
import { Chartjs } from '@app/components/chartjs'
import { CountUpModule } from 'ngx-countup'
@Component({
selector: 'app-token-usage',
imports: [NgIcon, Chartjs, CountUpModule],
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">Token Usage</h5>
</div>
<div>
<ng-icon name="lucideCpu" class="text-muted fs-24 svg-sw-10" />
</div>
</div>
<div class="mb-3">
<app-chartjs
[getOptions]="tokenChartOptions"
[height]="60"
style="max-height: 60px"
/>
</div>
<div class="d-flex justify-content-between">
<div>
<span class="text-muted">Today</span>
<div class="fw-semibold">
<span [countUp]="920400">920,400</span> tokens
</div>
</div>
<div class="text-end">
<span class="text-muted">Yesterday</span>
<div
class="fw-semibold d-flex align-items-center justify-content-end gap-1"
>
<span [countUp]="865100">865,100</span>
<ng-icon name="tablerArrowUp" />
</div>
</div>
</div>
</div>
<div class="card-footer text-muted text-center">
Token usage up <strong>6.4%</strong> from yesterday
</div>
</div>
`,
styles: ``,
})
export class TokenUsage {
tokenChartOptions = (): ChartConfiguration => ({
type: 'line',
data: {
labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
datasets: [
{
data: [82000, 95000, 103000, 112000, 121500, 135200, 148000],
backgroundColor: getColor('chart-primary-rgb', 0.1),
borderColor: getColor('chart-primary'),
tension: 0.4,
fill: true,
pointRadius: 0,
borderWidth: 2,
},
],
},
options: {
plugins: {
legend: { display: false },
tooltip: { enabled: false },
},
scales: {
x: {
display: false,
grid: { display: false },
},
y: {
display: false,
grid: { display: false },
},
},
},
})
}

View File

@ -1,43 +0,0 @@
<div class="container-fluid">
<app-page-title
title="The Ultimate Admin & Dashboard Theme"
subTitle="A premium collection of elegant, accessible components and a powerful codebase. Built for modern frameworks. Developer Friendly. Production Ready."
[badge]="{icon: 'lucideSparkles', text: 'Medium and Large Business'}"
/>
<div class="row row-cols-xxl-4 row-cols-md-2 row-cols-1">
<div class="col">
<app-prompts-usage />
</div>
<div class="col">
<app-active-users />
</div>
<div class="col">
<app-response-accuracy />
</div>
<div class="col">
<app-token-usage />
</div>
</div>
<div class="row">
<div class="col-12">
<app-request-statistics />
</div>
</div>
<div class="row">
<div class="col-xxl-6">
<app-recent-sessions />
</div>
<div class="col-xxl-6">
<app-model-usage-summary />
<app-api-performance-metrics />
</div>
</div>
</div>

View File

@ -1,28 +0,0 @@
import { Component } from '@angular/core'
import { PageTitle } from '@app/components/page-title/page-title'
import { PromptsUsage } from './components/prompts-usage'
import { ActiveUsers } from './components/active-users'
import { ResponseAccuracy } from './components/response-accuracy'
import { TokenUsage } from './components/token-usage'
import { RequestStatistics } from './components/request-statistics'
import { RecentSessions } from './components/recent-sessions'
import { ModelUsageSummary } from './components/model-usage-summary'
import { ApiPerformanceMetrics } from './components/api-performance-metrics'
@Component({
selector: 'app-dashboard',
imports: [
PageTitle,
PromptsUsage,
ActiveUsers,
ResponseAccuracy,
TokenUsage,
RequestStatistics,
RecentSessions,
ModelUsageSummary,
ApiPerformanceMetrics,
],
templateUrl: './dashboard.html',
styles: ``,
})
export class Dashboard {}

View File

@ -0,0 +1,338 @@
<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'}"
/>
<!-- Contenu du dashboard -->
<div class="container-fluid dcb-dashboard">
<!-- En-tête avec contrôles -->
<div class="row 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>
</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>
</div>
</div>
</div>
</div>
<!-- Transactions totales -->
<div class="col-xl-3 col-md-6 mb-3">
<div class="card kpi-card h-100">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-grow-1">
<span class="text-muted text-uppercase small fw-semibold">Transactions</span>
<h3 class="mt-2 mb-1 text-success">{{ formatNumber(analytics.totalTransactions) }}</h3>
<div class="d-flex align-items-center mt-2">
<span class="text-success fw-semibold">
{{ formatPercentage(analytics.successRate) }}
</span>
<span class="text-muted small ms-2">taux de succès</span>
</div>
</div>
<div class="flex-shrink-0">
<div class="kpi-icon bg-success">
<ng-icon name="lucideCreditCard"></ng-icon>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Montant moyen -->
<div class="col-xl-3 col-md-6 mb-3">
<div class="card kpi-card h-100">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-grow-1">
<span class="text-muted text-uppercase small fw-semibold">Montant Moyen</span>
<h3 class="mt-2 mb-1 text-info">{{ formatCurrency(analytics.averageAmount) }}</h3>
<div class="text-muted small mt-2">par transaction</div>
</div>
<div class="flex-shrink-0">
<div class="kpi-icon bg-info">
<ng-icon name="lucideBarChart3"></ng-icon>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Aujourd'hui -->
<div class="col-xl-3 col-md-6 mb-3">
<div class="card kpi-card h-100">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-grow-1">
<span class="text-muted text-uppercase small fw-semibold">Aujourd'hui</span>
<h3 class="mt-2 mb-1 text-warning">{{ formatCurrency(analytics.todayStats.revenue) }}</h3>
<div class="text-muted small mt-2">
{{ analytics.todayStats.transactions }} transactions
</div>
</div>
<div class="flex-shrink-0">
<div class="kpi-icon bg-warning">
<ng-icon name="lucideTrendingUp"></ng-icon>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 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>

View File

@ -1,6 +1,6 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { Dashboard } from './dashboard'
import { Dashboard } from './dcb-dashboard'
describe('Dashboard', () => {
let component: Dashboard

View File

@ -0,0 +1,198 @@
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 { 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';
@Component({
selector: 'app-dcb-dashboard',
standalone: true,
imports: [
CommonModule,
FormsModule,
NgIcon,
NgbAlertModule,
NgbProgressbarModule,
NgbTooltipModule,
PageTitle
],
providers: [
provideNgIconsConfig({
size: '1.25em'
})
],
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';
}
}

View File

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

View File

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

View File

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

View File

@ -1,15 +1,16 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { authGuard } from '../core/guards/auth.guard';
import { roleGuard } from '../core/guards/role.guard';
import { Users } from '@modules/users/users';
// Composants principaux
import { Dashboard } from './dashboard/dashboard';
import { DcbDashboard } from './dcb-dashboard/dcb-dashboard';
import { Team } from './team/team';
import { Transactions } from './transactions/transactions';
import { TransactionsList } from './transactions/list/list';
import { TransactionsFilters } from './transactions/filters/filters';
import { TransactionsDetails } from './transactions/details/details';
import { TransactionsExport } from './transactions/export/export';
import { TransactionDetails } from './transactions/details/details';
import { Merchants } from './merchants/merchants';
import { MerchantsList } from './merchants/list/list';
@ -20,47 +21,53 @@ import { Operators } from './operators/operators';
import { OperatorsConfig } from './operators/config/config';
import { OperatorsStats } from './operators/stats/stats';
import { Notifications } from './notifications/notifications';
import { NotificationsList } from './notifications/list/list';
import { NotificationsFilters } from './notifications/filters/filters';
import { NotificationsActions } from './notifications/actions/actions';
import { Webhooks } from './webhooks/webhooks';
import { WebhooksHistory } from './webhooks/history/history';
import { WebhooksStatus } from './webhooks/status/status';
import { WebhooksRetry } from './webhooks/retry/retry';
import { Users } from './users/users';
import { UsersList } from './users/list/list';
import { UsersRoles } from './users/roles/roles';
import { UsersAudits } from './users/audits/audits';
import { Settings } from './settings/settings';
import { Integrations } from './integrations/integrations';
import { Support } from './support/support';
import { Profile } from './profile/profile';
import { MyProfile } from './profile/profile';
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
// ---------------------------
{
path: 'dashboard',
canActivate: [authGuard],
children: [
{ path: '', component: Dashboard, data: { title: 'Dashboard' } },
{ path: 'overview', component: Dashboard, data: { title: 'Vue Globale' } },
{ path: 'kpis', component: Dashboard, data: { title: 'KPIs & Graphiques' } },
{ path: 'reports', component: Dashboard, data: { title: 'Rapports' } },
]
path: 'dcb-dashboard',
canActivate: [authGuard, roleGuard],
component: DcbDashboard,
data: {
title: 'Dashboard DCB',
requiredRoles: ['admin', 'merchant', 'support']
}
},
{
path: 'team',
component: Team,
canActivate: [authGuard],
canActivate: [authGuard, roleGuard],
data: { title: 'Team' }
},
@ -69,14 +76,21 @@ const routes: Routes = [
// ---------------------------
{
path: 'transactions',
//component: Transactions,
canActivate: [authGuard],
children: [
{ path: 'list', component: TransactionsList, data: { title: 'Liste & Recherche' } },
{ path: 'filters', component: TransactionsFilters, data: { title: 'Filtres Avancés' } },
{ path: 'details', component: TransactionsDetails, data: { title: 'Détails & Logs' } },
{ path: 'export', component: TransactionsExport, data: { title: 'Export' } },
]
component: Transactions,
canActivate: [authGuard, roleGuard],
data: {
title: 'Transactions DCB',
requiredRoles: ['admin', 'merchant', 'support']
}
},
{
path: 'transactions/:id',
component: Transactions,
canActivate: [authGuard, roleGuard],
data: {
title: 'Détails Transaction',
requiredRoles: ['admin', 'merchant', 'support']
}
},
// ---------------------------
@ -84,8 +98,7 @@ const routes: Routes = [
// ---------------------------
{
path: 'merchants',
//component: Merchants,
canActivate: [authGuard],
canActivate: [authGuard, roleGuard],
children: [
{ path: 'list', component: MerchantsList, data: { title: 'Liste des Marchands' } },
{ path: 'config', component: MerchantsConfig, data: { title: 'Configuration API / Webhooks' } },
@ -98,35 +111,19 @@ const routes: Routes = [
// ---------------------------
{
path: 'operators',
//component: Operators,
canActivate: [authGuard],
canActivate: [authGuard, roleGuard],
children: [
{ path: 'config', component: OperatorsConfig, data: { title: 'Paramètres dIntégration' } },
{ path: 'config', component: OperatorsConfig, data: { title: 'Paramètres d\'Intégration' } },
{ path: 'stats', component: OperatorsStats, data: { title: 'Performance & Monitoring' } },
]
},
// ---------------------------
// Notifications
// ---------------------------
{
path: 'notifications',
//component: Notifications,
canActivate: [authGuard],
children: [
{ path: 'list', component: NotificationsList, data: { title: 'Liste des Notifications' } },
{ path: 'filters', component: NotificationsFilters, data: { title: 'Filtrage par Type' } },
{ path: 'actions', component: NotificationsActions, data: { title: 'Actions Automatiques' } },
]
},
// ---------------------------
// Webhooks
// ---------------------------
{
path: 'webhooks',
//component: Webhooks,
canActivate: [authGuard],
canActivate: [authGuard, roleGuard],
children: [
{ path: 'history', component: WebhooksHistory, data: { title: 'Historique' } },
{ path: 'status', component: WebhooksStatus, data: { title: 'Statut des Requêtes' } },
@ -135,37 +132,58 @@ const routes: Routes = [
},
// ---------------------------
// Users
// Settings & Integrations (Admin seulement)
// ---------------------------
{
path: 'users',
//component: Users,
canActivate: [authGuard],
children: [
{ path: 'list', component: UsersList, data: { title: 'Liste des Utilisateurs' } },
{ path: 'roles', component: UsersRoles, data: { title: 'Rôles & Permissions' } },
{ path: 'audits', component: UsersAudits, data: { title: 'Audit & Historique' } },
]
path: 'settings',
component: Settings,
canActivate: [authGuard, roleGuard],
data: { title: 'Paramètres Système' }
},
{
path: 'integrations',
component: Integrations,
canActivate: [authGuard, roleGuard],
data: { title: 'Intégrations Externes' }
},
// ---------------------------
// Settings & Integrations
// Support & Profile (Tous les utilisateurs authentifiés)
// ---------------------------
{ path: 'settings', component: Settings, canActivate: [authGuard], data: { title: 'Paramètres Système' } },
{ path: 'integrations', component: Integrations, canActivate: [authGuard], data: { title: 'Intégrations Externes' } },
{
path: 'support',
component: Support,
canActivate: [authGuard, roleGuard],
data: { title: 'Support' }
},
{
path: 'profile',
component: MyProfile,
canActivate: [authGuard, roleGuard],
data: { title: 'Mon Profil' }
},
// ---------------------------
// Support & Profile
// Documentation & Help (Tous les utilisateurs authentifiés)
// ---------------------------
{ path: 'support', component: Support, canActivate: [authGuard], data: { title: 'Support' } },
{ path: 'profile', component: Profile, canActivate: [authGuard], data: { title: 'Mon Profil' } },
// ---------------------------
// Documentation & Help
// ---------------------------
{ path: 'documentation', component: Documentation, canActivate: [authGuard], data: { title: 'Documentation' } },
{ path: 'help', component: Help, canActivate: [authGuard], data: { title: 'Aide' } },
{ path: 'about', component: About, canActivate: [authGuard], data: { title: 'À propos' } },
{
path: 'documentation',
component: Documentation,
canActivate: [authGuard, roleGuard],
data: { title: 'Documentation' }
},
{
path: 'help',
component: Help,
canActivate: [authGuard, roleGuard],
data: { title: 'Aide' }
},
{
path: 'about',
component: About,
canActivate: [authGuard, roleGuard],
data: { title: 'À propos' }
},
];
@NgModule({

View File

@ -1 +1,405 @@
<p>Profile</p>
<!-- my-profile.html -->
<div class="container-fluid">
<!-- En-tête avec navigation -->
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<div>
<h4 class="mb-1">
@if (user) {
Mon Profil - {{ getUserDisplayName() }}
} @else {
Mon Profil
}
</h4>
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item active">Mon Profil</li>
</ol>
</nav>
</div>
<div class="d-flex gap-2">
@if (user && !isEditing) {
<button
class="btn btn-primary"
(click)="startEditing()"
>
<ng-icon name="lucideEdit" class="me-1"></ng-icon>
Modifier
</button>
}
</div>
</div>
</div>
</div>
<!-- Messages d'alerte -->
@if (error) {
<div class="alert alert-danger">
<ng-icon name="lucideAlertCircle" class="me-2"></ng-icon>
{{ error }}
</div>
}
@if (success) {
<div class="alert alert-success">
<ng-icon name="lucideCheckCircle" class="me-2"></ng-icon>
{{ success }}
</div>
}
<div class="row">
<!-- Loading State -->
@if (loading) {
<div class="col-12 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 de votre profil...</p>
</div>
}
<!-- User Profile -->
@if (user && !loading) {
<!-- Colonne de gauche - Informations de base -->
<div class="col-xl-4 col-lg-5">
<!-- Carte profil -->
<div class="card">
<div class="card-header bg-light">
<h5 class="card-title mb-0">Mon Profil</h5>
</div>
<div class="card-body text-center">
<!-- Avatar -->
<div class="avatar-lg mx-auto mb-3">
<div class="avatar-title bg-primary rounded-circle text-white fs-24">
{{ getUserInitials() }}
</div>
</div>
<h5>{{ getUserDisplayName() }}</h5>
<p class="text-muted mb-2">@{{ user.username }}</p>
<!-- Statut -->
<span [class]="getStatusBadgeClass()" class="mb-3">
{{ getStatusText() }}
</span>
<!-- Informations rapides -->
<div class="mt-4 text-start">
<div class="d-flex align-items-center mb-2">
<ng-icon name="lucideMail" class="me-2 text-muted"></ng-icon>
<small>{{ user.email }}</small>
</div>
<div class="d-flex align-items-center">
<ng-icon name="lucideCalendar" class="me-2 text-muted"></ng-icon>
<small>Membre depuis {{ formatTimestamp(user.createdTimestamp) }}</small>
</div>
<div class="d-flex align-items-center mt-2">
<ng-icon name="lucideShield" class="me-2 text-muted"></ng-icon>
<small class="text-info">Vous pouvez modifier votre mot de passe ici</small>
</div>
</div>
<!-- Bouton de sécurité -->
<div class="mt-4">
<button
class="btn btn-outline-warning btn-sm"
(click)="openResetPasswordModal()"
[disabled]="resettingPassword"
>
<ng-icon name="lucideKey" class="me-1"></ng-icon>
Changer mon mot de passe
</button>
</div>
</div>
</div>
<!-- Carte rôles -->
<div class="card mt-3">
<div class="card-header bg-light">
<h5 class="card-title mb-0">Mes Rôles</h5>
</div>
<div class="card-body">
<div class="d-flex flex-wrap gap-1">
@for (role of user.clientRoles; track role) {
<span class="badge" [ngClass]="getRoleBadgeClass(role)">
{{ role }}
</span>
}
</div>
</div>
</div>
</div>
<!-- Colonne de droite - Détails et édition -->
<div class="col-xl-8 col-lg-7">
<div class="card">
<div class="card-header bg-light">
<h5 class="card-title mb-0">
@if (isEditing) {
Modification du Profil
} @else {
Mes Informations
}
</h5>
</div>
<div class="card-body">
<div class="row g-3">
<!-- Prénom -->
<div class="col-md-6">
<label class="form-label">Prénom</label>
@if (isEditing) {
<input
type="text"
class="form-control"
[(ngModel)]="editedUser.firstName"
placeholder="Votre prénom"
>
} @else {
<div class="form-control-plaintext">
{{ user.firstName || 'Non renseigné' }}
</div>
}
</div>
<!-- Nom -->
<div class="col-md-6">
<label class="form-label">Nom</label>
@if (isEditing) {
<input
type="text"
class="form-control"
[(ngModel)]="editedUser.lastName"
placeholder="Votre nom"
>
} @else {
<div class="form-control-plaintext">
{{ user.lastName || 'Non renseigné' }}
</div>
}
</div>
<!-- Nom d'utilisateur -->
<div class="col-md-6">
<label class="form-label">Nom d'utilisateur</label>
<div class="form-control-plaintext">
{{ user.username }}
</div>
<small class="text-muted">Le nom d'utilisateur ne peut pas être modifié</small>
</div>
<!-- Email -->
<div class="col-md-6">
<label class="form-label">Email</label>
@if (isEditing) {
<input
type="email"
class="form-control"
[(ngModel)]="editedUser.email"
placeholder="votre@email.com"
>
} @else {
<div class="form-control-plaintext">
{{ user.email }}
</div>
}
</div>
<!-- Section Sécurité -->
<div class="col-12">
<hr>
<h6>
<ng-icon name="lucideShield" class="me-2 text-warning"></ng-icon>
Sécurité du Compte
</h6>
<div class="alert alert-info">
<div class="d-flex justify-content-between align-items-center">
<div>
<strong>Mot de passe</strong>
<br>
<small class="text-muted">Vous pouvez changer votre mot de passe à tout moment</small>
</div>
</div>
</div>
</div>
<!-- Actions d'édition -->
@if (isEditing) {
<div class="col-12">
<div class="d-flex gap-2 justify-content-end mt-4">
<button
type="button"
class="btn btn-light"
(click)="cancelEditing()"
[disabled]="saving">
Annuler
</button>
<button
type="button"
class="btn btn-success"
(click)="saveProfile()"
[disabled]="saving">
@if (saving) {
<div class="spinner-border spinner-border-sm me-2" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
}
Enregistrer
</button>
</div>
</div>
}
<!-- Informations système -->
@if (!isEditing) {
<div class="col-12">
<hr>
<h6>Informations Système</h6>
<div class="row">
<div class="col-md-6">
<label class="form-label">ID Utilisateur</label>
<div class="form-control-plaintext font-monospace small">
{{ user.id }}
</div>
</div>
<div class="col-md-6">
<label class="form-label">Date de création</label>
<div class="form-control-plaintext">
{{ formatTimestamp(user.createdTimestamp) }}
</div>
</div>
</div>
</div>
}
</div>
</div>
</div>
</div>
}
</div>
</div>
<!-- Modal de réinitialisation de mot de passe -->
<!-- Modal de réinitialisation de mot de passe -->
<ng-template #resetPasswordModal let-modal>
<div class="modal-header">
<h4 class="modal-title">
<ng-icon name="lucideKey" class="me-2"></ng-icon>
Réinitialiser votre mot de passe
</h4>
<button
type="button"
class="btn-close"
(click)="modal.dismiss()"
[disabled]="resettingPassword"
></button>
</div>
<div class="modal-body">
<!-- Message de succès -->
@if (resetPasswordSuccess) {
<div class="alert alert-success d-flex align-items-center">
<ng-icon name="lucideCheckCircle" class="me-2"></ng-icon>
<div>{{ resetPasswordSuccess }}</div>
</div>
}
<!-- Message d'erreur -->
@if (resetPasswordError) {
<div class="alert alert-danger d-flex align-items-center">
<ng-icon name="lucideAlertCircle" class="me-2"></ng-icon>
<div>{{ resetPasswordError }}</div>
</div>
}
@if (!resetPasswordSuccess && user) {
<div class="alert alert-info">
<strong>Utilisateur :</strong> {{ user.username }}
@if (user.firstName || user.lastName) {
<br>
<strong>Nom :</strong> {{ user.firstName }} {{ user.lastName }}
}
</div>
<form (ngSubmit)="confirmResetPassword()" #resetForm="ngForm">
<div class="mb-3">
<label class="form-label">
Nouveau mot de passe <span class="text-danger">*</span>
</label>
<input
type="password"
class="form-control"
placeholder="Entrez votre nouveau mot de passe"
[(ngModel)]="newPassword"
name="newPassword"
required
minlength="8"
[disabled]="resettingPassword"
>
<div class="form-text">
Le mot de passe doit contenir au moins 8 caractères.
</div>
</div>
<div class="mb-3">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="temporaryPassword"
[(ngModel)]="temporaryPassword"
name="temporaryPassword"
[disabled]="resettingPassword"
>
<label class="form-check-label" for="temporaryPassword">
Mot de passe temporaire
</label>
</div>
<div class="form-text">
Vous devrez changer votre mot de passe à la prochaine connexion.
</div>
</div>
</form>
}
</div>
<div class="modal-footer">
@if (resetPasswordSuccess) {
<button
type="button"
class="btn btn-success"
(click)="modal.close()"
>
<ng-icon name="lucideCheck" class="me-1"></ng-icon>
Fermer
</button>
} @else {
<button
type="button"
class="btn btn-light"
(click)="modal.dismiss()"
[disabled]="resettingPassword"
>
Annuler
</button>
<button
type="button"
class="btn btn-primary"
(click)="confirmResetPassword()"
[disabled]="!newPassword || newPassword.length < 8 || resettingPassword"
>
@if (resettingPassword) {
<div class="spinner-border spinner-border-sm me-2" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
Réinitialisation...
} @else {
<ng-icon name="lucideKey" class="me-1"></ng-icon>
Réinitialiser le mot de passe
}
</button>
}
</div>
</ng-template>

View File

@ -1,7 +1,227 @@
import { Component } from '@angular/core';
import { Component, inject, OnInit, TemplateRef, ViewChild, ChangeDetectorRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { NgIcon } from '@ng-icons/core';
import { NgbAlertModule, NgbModal, NgbModalModule } from '@ng-bootstrap/ng-bootstrap';
import { UsersService } from '@modules/users/services/users.service';
import { AuthService } from '@core/services/auth.service';
import { UserResponse, UpdateUserDto } from '@modules/users/models/user';
@Component({
selector: 'app-profile',
selector: 'app-my-profile',
standalone: true,
imports: [CommonModule, FormsModule, NgIcon, NgbAlertModule, NgbModalModule],
templateUrl: './profile.html',
styles: [`
.avatar-lg {
width: 80px;
height: 80px;
}
.fs-24 {
font-size: 24px;
}
`]
})
export class Profile {}
export class MyProfile implements OnInit {
private usersService = inject(UsersService);
private authService = inject(AuthService);
private modalService = inject(NgbModal);
private cdRef = inject(ChangeDetectorRef);
@ViewChild('resetPasswordModal') resetPasswordModal!: TemplateRef<any>;
user: UserResponse | null = null;
loading = false;
saving = false;
error = '';
success = '';
// Édition
isEditing = false;
editedUser: UpdateUserDto = {};
// Réinitialisation mot de passe
newPassword = '';
temporaryPassword = false;
resettingPassword = false;
resetPasswordError = '';
resetPasswordSuccess = '';
ngOnInit() {
this.loadMyProfile();
}
loadMyProfile() {
this.loading = true;
this.error = '';
this.usersService.getCurrentUserProfile().subscribe({
next: (user) => {
this.user = user;
this.loading = false;
this.cdRef.detectChanges();
},
error: (error) => {
this.error = 'Erreur lors du chargement de votre profil';
this.loading = false;
this.cdRef.detectChanges();
console.error('Error loading my profile:', error);
}
});
}
// Ouvrir le modal de réinitialisation de mot de passe
openResetPasswordModal() {
if (!this.user) return;
this.newPassword = '';
this.temporaryPassword = false;
this.resetPasswordError = '';
this.resetPasswordSuccess = '';
this.modalService.open(this.resetPasswordModal, {
centered: true,
size: 'md'
});
}
// Réinitialiser le mot de passe
confirmResetPassword() {
if (!this.user || !this.newPassword || this.newPassword.length < 8) {
this.resetPasswordError = 'Veuillez saisir un mot de passe valide (au moins 8 caractères).';
return;
}
this.resettingPassword = true;
this.resetPasswordError = '';
this.resetPasswordSuccess = '';
const resetDto = {
userId: this.user.id,
newPassword: this.newPassword,
temporary: this.temporaryPassword
};
this.usersService.resetPassword(resetDto).subscribe({
next: () => {
this.resettingPassword = false;
this.resetPasswordSuccess = 'Votre mot de passe a été réinitialisé avec succès !';
this.cdRef.detectChanges();
// Déconnexion automatique si mot de passe temporaire
if (this.temporaryPassword) {
setTimeout(() => {
this.authService.logout();
}, 2000);
}
},
error: (error) => {
this.resettingPassword = false;
this.resetPasswordError = this.getResetPasswordErrorMessage(error);
this.cdRef.detectChanges();
console.error('Error resetting password:', error);
}
});
}
// Gestion des erreurs pour la réinitialisation
private getResetPasswordErrorMessage(error: any): string {
if (error.error?.message) {
return error.error.message;
}
if (error.status === 404) {
return 'Utilisateur non trouvé.';
}
if (error.status === 400) {
return 'Le mot de passe ne respecte pas les critères de sécurité.';
}
return 'Erreur lors de la réinitialisation du mot de passe. Veuillez réessayer.';
}
startEditing() {
this.isEditing = true;
this.editedUser = {
firstName: this.user?.firstName,
lastName: this.user?.lastName,
email: this.user?.email
};
}
cancelEditing() {
this.isEditing = false;
this.editedUser = {};
this.error = '';
this.success = '';
}
saveProfile() {
if (!this.user) return;
this.saving = true;
this.error = '';
this.success = '';
this.usersService.updateCurrentUserProfile(this.editedUser).subscribe({
next: (updatedUser) => {
this.user = updatedUser;
this.isEditing = false;
this.saving = false;
this.success = 'Profil mis à jour avec succès';
this.editedUser = {};
},
error: (error) => {
this.error = 'Erreur lors de la mise à jour du profil';
this.saving = false;
console.error('Error updating profile:', error);
}
});
}
// Utilitaires d'affichage
getStatusBadgeClass(): string {
if (!this.user) return 'badge bg-secondary';
if (!this.user.enabled) return 'badge bg-danger';
if (!this.user.emailVerified) return 'badge bg-warning';
return 'badge bg-success';
}
getStatusText(): string {
if (!this.user) return 'Inconnu';
if (!this.user.enabled) return 'Désactivé';
if (!this.user.emailVerified) return 'Email non vérifié';
return 'Actif';
}
formatTimestamp(timestamp: number): string {
return new Date(timestamp).toLocaleDateString('fr-FR', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
getUserInitials(): string {
if (!this.user) return 'U';
return (this.user.firstName?.charAt(0) || '') + (this.user.lastName?.charAt(0) || '') || 'U';
}
getUserDisplayName(): string {
if (!this.user) return 'Utilisateur';
if (this.user.firstName && this.user.lastName) {
return `${this.user.firstName} ${this.user.lastName}`;
}
return this.user.username;
}
getRoleBadgeClass(role: string): string {
switch (role) {
case 'admin': return 'bg-danger';
case 'merchant': return 'bg-success';
case 'support': return 'bg-info';
case 'user': return 'bg-secondary';
default: return 'bg-secondary';
}
}
}

View File

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

View File

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

View File

@ -1 +1,351 @@
<p>Transactions - Details</p>
<div class="transaction-details">
<!-- Loading State -->
@if (loading && !transaction) {
<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 détails de la transaction...</p>
</div>
}
<!-- Messages -->
@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>
}
@if (success) {
<div class="alert alert-success d-flex align-items-center">
<ng-icon name="lucideCheckCircle" class="me-2"></ng-icon>
<div class="flex-grow-1">{{ success }}</div>
<button class="btn-close" (click)="success = ''"></button>
</div>
}
@if (transaction && !loading) {
<div class="row">
<!-- Colonne principale -->
<div class="col-lg-8">
<!-- En-tête de la transaction -->
<div class="card mb-4">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center">
<h5 class="card-title mb-0 me-3">Transaction #{{ transaction.id }}</h5>
<span [class]="getStatusBadgeClass(transaction.status)" class="badge">
<ng-icon [name]="getStatusIcon(transaction.status)" class="me-1"></ng-icon>
{{ transaction.status }}
</span>
</div>
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary btn-sm" (click)="copyToClipboard(transaction.id)"
ngbTooltip="Copier l'ID">
<ng-icon name="lucideCopy"></ng-icon>
</button>
<button class="btn btn-outline-secondary btn-sm" (click)="printDetails()"
ngbTooltip="Imprimer">
<ng-icon name="lucidePrinter"></ng-icon>
</button>
<button class="btn btn-outline-primary btn-sm" (click)="loadTransactionDetails()"
[disabled]="loading" ngbTooltip="Actualiser">
<ng-icon name="lucideRefreshCw" [class.spin]="loading"></ng-icon>
</button>
</div>
</div>
<div class="card-body">
<!-- Montant et informations principales -->
<div class="row mb-4">
<div class="col-md-6">
<div class="d-flex align-items-center">
<div class="transaction-amount-icon bg-primary rounded-circle p-3 me-3">
<ng-icon name="lucideEuro" class="text-white fs-4"></ng-icon>
</div>
<div>
<div class="text-muted small">Montant</div>
<div [class]="getAmountColor(transaction.amount) + ' h3 mb-0'">
{{ formatCurrency(transaction.amount, transaction.currency) }}
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="d-flex align-items-center h-100">
<div class="transaction-date-icon bg-secondary rounded-circle p-3 me-3">
<ng-icon name="lucideCalendar" class="text-white fs-4"></ng-icon>
</div>
<div>
<div class="text-muted small">Date de transaction</div>
<div class="h6 mb-0">{{ formatDate(transaction.transactionDate) }}</div>
<small class="text-muted">{{ formatRelativeTime(transaction.transactionDate) }}</small>
</div>
</div>
</div>
</div>
<!-- Informations détaillées -->
<div class="row">
<div class="col-12">
<h6 class="border-bottom pb-2 mb-3">Informations de la transaction</h6>
</div>
<div class="col-md-6 mb-3">
<label class="form-label text-muted small mb-1">MSISDN</label>
<div class="d-flex align-items-center">
<ng-icon name="lucideSmartphone" class="me-2 text-muted"></ng-icon>
<span class="fw-medium font-monospace">{{ transaction.msisdn }}</span>
<button class="btn btn-sm btn-link p-0 ms-2" (click)="copyToClipboard(transaction.msisdn)">
<ng-icon name="lucideCopy" class="text-muted"></ng-icon>
</button>
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label text-muted small mb-1">Opérateur</label>
<div class="d-flex align-items-center">
<ng-icon name="lucideGlobe" class="me-2 text-muted"></ng-icon>
<span class="fw-medium">{{ transaction.operator }}</span>
<span class="badge bg-light text-dark ms-2">{{ transaction.country }}</span>
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label text-muted small mb-1">Produit</label>
<div class="d-flex align-items-center">
<ng-icon name="lucidePackage" class="me-2 text-muted"></ng-icon>
<div>
<div class="fw-medium">{{ transaction.productName }}</div>
<small class="text-muted">ID: {{ transaction.productId }}</small>
</div>
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label text-muted small mb-1">Catégorie</label>
<div>
<span class="badge bg-info">{{ transaction.productCategory }}</span>
</div>
</div>
@if (transaction.merchantName) {
<div class="col-md-6 mb-3">
<label class="form-label text-muted small mb-1">Marchand</label>
<div class="d-flex align-items-center">
<ng-icon name="lucideUser" class="me-2 text-muted"></ng-icon>
<span class="fw-medium">{{ transaction.merchantName }}</span>
</div>
</div>
}
@if (transaction.externalId) {
<div class="col-md-6 mb-3">
<label class="form-label text-muted small mb-1">ID Externe</label>
<div class="d-flex align-items-center">
<span class="font-monospace small">{{ transaction.externalId }}</span>
<button class="btn btn-sm btn-link p-0 ms-2" (click)="copyToClipboard(transaction.externalId!)">
<ng-icon name="lucideCopy" class="text-muted"></ng-icon>
</button>
</div>
</div>
}
</div>
<!-- Informations techniques -->
<div class="row mt-4">
<div class="col-12">
<h6 class="border-bottom pb-2 mb-3">Informations techniques</h6>
</div>
<div class="col-md-6 mb-2">
<label class="form-label text-muted small mb-1">Créé le</label>
<div class="small">{{ formatDate(transaction.createdAt) }}</div>
</div>
<div class="col-md-6 mb-2">
<label class="form-label text-muted small mb-1">Mis à jour le</label>
<div class="small">{{ formatDate(transaction.updatedAt) }}</div>
</div>
@if (transaction.userAgent) {
<div class="col-12 mb-2">
<label class="form-label text-muted small mb-1">User Agent</label>
<div class="small font-monospace text-truncate">{{ transaction.userAgent }}</div>
</div>
}
@if (transaction.ipAddress) {
<div class="col-md-6 mb-2">
<label class="form-label text-muted small mb-1">Adresse IP</label>
<div class="small font-monospace">{{ transaction.ipAddress }}</div>
</div>
}
</div>
<!-- Détails d'erreur -->
@if (showErrorDetails()) {
<div class="row mt-4">
<div class="col-12">
<h6 class="border-bottom pb-2 mb-3 text-danger">
<ng-icon name="lucideAlertCircle" class="me-2"></ng-icon>
Détails de l'erreur
</h6>
</div>
@if (transaction.errorCode) {
<div class="col-md-6 mb-2">
<label class="form-label text-muted small mb-1">Code d'erreur</label>
<div class="fw-medium text-danger">{{ transaction.errorCode }}</div>
</div>
}
@if (transaction.errorMessage) {
<div class="col-12 mb-2">
<label class="form-label text-muted small mb-1">Message d'erreur</label>
<div class="alert alert-danger small mb-0">{{ transaction.errorMessage }}</div>
</div>
}
</div>
}
</div>
</div>
</div>
<!-- Colonne latérale - Actions et métadonnées -->
<div class="col-lg-4">
<!-- Actions -->
<div class="card mb-4">
<div class="card-header bg-light">
<h6 class="card-title mb-0">Actions</h6>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<!-- Remboursement -->
@if (canRefund()) {
<button
class="btn btn-warning"
(click)="refundTransaction()"
[disabled]="refunding"
>
@if (refunding) {
<div class="spinner-border spinner-border-sm me-2" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
}
<ng-icon name="lucideUndo2" class="me-1"></ng-icon>
Rembourser
</button>
}
<!-- Réessayer -->
@if (canRetry()) {
<button
class="btn btn-info"
(click)="retryTransaction()"
[disabled]="retrying"
>
@if (retrying) {
<div class="spinner-border spinner-border-sm me-2" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
}
<ng-icon name="lucideRefreshCw" class="me-1"></ng-icon>
Réessayer
</button>
}
<!-- Annuler -->
@if (canCancel()) {
<button
class="btn btn-secondary"
(click)="cancelTransaction()"
>
<ng-icon name="lucideBan" class="me-1"></ng-icon>
Annuler
</button>
}
<!-- Télécharger les détails -->
<button class="btn btn-outline-primary">
<ng-icon name="lucideDownload" class="me-1"></ng-icon>
Télécharger PDF
</button>
</div>
</div>
</div>
<!-- Métadonnées -->
<div class="card">
<div class="card-header bg-light">
<h6 class="card-title mb-0">Métadonnées</h6>
</div>
<div class="card-body">
<div class="small">
<div class="d-flex justify-content-between mb-2">
<span class="text-muted">ID Transaction:</span>
<span class="font-monospace">{{ transaction.id }}</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-muted">Opérateur ID:</span>
<span>{{ transaction.operatorId }}</span>
</div>
@if (transaction.merchantId) {
<div class="d-flex justify-content-between mb-2">
<span class="text-muted">Marchand ID:</span>
<span class="font-monospace small">{{ transaction.merchantId }}</span>
</div>
}
<div class="d-flex justify-content-between mb-2">
<span class="text-muted">Devise:</span>
<span>{{ transaction.currency }}</span>
</div>
<div class="d-flex justify-content-between">
<span class="text-muted">Statut:</span>
<span [class]="getStatusBadgeClass(transaction.status)" class="badge">
{{ transaction.status }}
</span>
</div>
</div>
</div>
</div>
<!-- Données personnalisées -->
@if (getCustomDataKeys().length > 0) {
<div class="card mt-4">
<div class="card-header bg-light">
<h6 class="card-title mb-0">Données personnalisées</h6>
</div>
<div class="card-body">
<div class="small">
@for (key of getCustomDataKeys(); track key) {
<div class="d-flex justify-content-between mb-2">
<span class="text-muted">{{ key }}:</span>
<span class="font-monospace small">{{ transaction.customData![key] }}</span>
</div>
}
</div>
</div>
</div>
}
</div>
</div>
}
<!-- Transaction non trouvée -->
@if (!transaction && !loading) {
<div class="text-center py-5">
<ng-icon name="lucideAlertCircle" class="text-muted fs-1 mb-3"></ng-icon>
<h5 class="text-muted">Transaction non trouvée</h5>
<p class="text-muted mb-4">La transaction avec l'ID "{{ transactionId }}" n'existe pas ou a été supprimée.</p>
<button class="btn btn-primary" routerLink="/transactions">
<ng-icon name="lucideArrowLeft" class="me-1"></ng-icon>
Retour à la liste
</button>
</div>
}
</div>

View File

@ -1,7 +1,258 @@
import { Component } from '@angular/core';
import { Component, inject, OnInit, Input, ChangeDetectorRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
import { NgIcon, provideNgIconsConfig } from '@ng-icons/core';
import {
lucideArrowLeft,
lucideCopy,
lucideRefreshCw,
lucideDownload,
lucidePrinter,
lucideCheckCircle,
lucideClock,
lucideXCircle,
lucideUndo2,
lucideBan,
lucideCalendar,
lucideSmartphone,
lucideEuro,
lucidePackage,
lucideUser,
lucideGlobe,
lucideAlertCircle,
lucideInfo
} from '@ng-icons/lucide';
import { NgbAlertModule, NgbTooltipModule, NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { TransactionsService } from '../services/transactions.service';
import { Transaction, TransactionStatus, RefundRequest } from '../models/transaction';
@Component({
selector: 'app-details',
templateUrl: './details.html',
selector: 'app-transaction-details',
standalone: true,
imports: [
CommonModule,
FormsModule,
RouterModule,
NgIcon,
NgbAlertModule,
NgbTooltipModule
],
providers: [
provideNgIconsConfig({
size: '1.25em'
})
],
templateUrl: './details.html'
})
export class TransactionsDetails {}
export class TransactionDetails implements OnInit {
private transactionsService = inject(TransactionsService);
private modalService = inject(NgbModal);
private cdRef = inject(ChangeDetectorRef);
@Input() transactionId!: string;
// Données
transaction: Transaction | null = null;
loading = false;
error = '';
success = '';
// Actions
refunding = false;
retrying = false;
ngOnInit() {
if (this.transactionId) {
this.loadTransactionDetails();
}
}
loadTransactionDetails() {
this.loading = true;
this.error = '';
this.transactionsService.getTransactionById(this.transactionId).subscribe({
next: (transaction) => {
this.transaction = transaction;
this.loading = false;
this.cdRef.detectChanges();
},
error: (error) => {
this.error = 'Erreur lors du chargement des détails de la transaction';
this.loading = false;
// Données mockées pour le développement
const mockTransactions = this.transactionsService.getMockTransactions();
this.transaction = mockTransactions.find(tx => tx.id === this.transactionId) || mockTransactions[0];
this.loading = false;
this.cdRef.detectChanges();
console.error('Error loading transaction details:', error);
}
});
}
// Actions sur la transaction
refundTransaction() {
if (!this.transaction) return;
this.refunding = true;
const refundRequest: RefundRequest = {
transactionId: this.transaction.id,
reason: 'Remboursement manuel par l\'administrateur'
};
this.transactionsService.refundTransaction(refundRequest).subscribe({
next: (response) => {
this.transaction = response.transaction;
this.refunding = false;
this.success = 'Transaction remboursée avec succès';
this.cdRef.detectChanges();
},
error: (error) => {
this.refunding = false;
this.error = 'Erreur lors du remboursement de la transaction';
this.cdRef.detectChanges();
console.error('Error refunding transaction:', error);
}
});
}
retryTransaction() {
if (!this.transaction) return;
this.retrying = true;
this.transactionsService.retryTransaction(this.transaction.id).subscribe({
next: (response) => {
this.transaction = response.transaction;
this.retrying = false;
this.success = 'Nouvelle tentative lancée avec succès';
this.cdRef.detectChanges();
},
error: (error) => {
this.retrying = false;
this.error = 'Erreur lors de la nouvelle tentative';
this.cdRef.detectChanges();
console.error('Error retrying transaction:', error);
}
});
}
cancelTransaction() {
if (!this.transaction) return;
this.transactionsService.cancelTransaction(this.transaction.id).subscribe({
next: () => {
this.success = 'Transaction annulée avec succès';
this.loadTransactionDetails(); // Recharger les données
this.cdRef.detectChanges();
},
error: (error) => {
this.error = 'Erreur lors de l\'annulation de la transaction';
this.cdRef.detectChanges();
console.error('Error cancelling transaction:', error);
}
});
}
// Utilitaires
copyToClipboard(text: string) {
navigator.clipboard.writeText(text).then(() => {
this.success = 'Copié dans le presse-papier';
setTimeout(() => this.success = '', 3000);
this.cdRef.detectChanges();
});
}
printDetails() {
window.print();
}
// Méthode pour obtenir les clés des données personnalisées
getCustomDataKeys(): string[] {
if (!this.transaction?.customData) {
return [];
}
return Object.keys(this.transaction.customData);
}
// Getters pour l'affichage
getStatusBadgeClass(status: TransactionStatus): 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';
case 'CANCELLED': return 'badge bg-secondary';
case 'EXPIRED': return 'badge bg-dark';
default: return 'badge bg-secondary';
}
}
getStatusIcon(status: TransactionStatus): string {
switch (status) {
case 'SUCCESS': return 'lucideCheckCircle';
case 'PENDING': return 'lucideClock';
case 'FAILED': return 'lucideXCircle';
case 'REFUNDED': return 'lucideUndo2';
case 'CANCELLED': return 'lucideBan';
default: return 'lucideClock';
}
}
getAmountColor(amount: number): string {
if (amount >= 10) return 'text-danger';
if (amount >= 5) return 'text-warning';
return 'text-success';
}
formatCurrency(amount: number, currency: string = 'EUR'): string {
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: currency
}).format(amount);
}
formatDate(date: Date): string {
return new Intl.DateTimeFormat('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}).format(new Date(date));
}
formatRelativeTime(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - new Date(date).getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return 'À l\'instant';
if (diffMins < 60) return `Il y a ${diffMins} min`;
if (diffHours < 24) return `Il y a ${diffHours} h`;
if (diffDays < 7) return `Il y a ${diffDays} j`;
return this.formatDate(date);
}
canRefund(): boolean {
return this.transaction?.status === 'SUCCESS';
}
canRetry(): boolean {
return this.transaction?.status === 'FAILED';
}
canCancel(): boolean {
return this.transaction?.status === 'PENDING';
}
showErrorDetails(): boolean {
return !!this.transaction?.errorCode || !!this.transaction?.errorMessage;
}
}

View File

@ -1 +0,0 @@
<p>Transactions - Export</p>

View File

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

View File

@ -1,40 +0,0 @@
// src/app/modules/transactions/components/export/transaction-export.ts
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { UiCard } from '@app/components/ui-card';
import { InputFields } from '@/app/modules/components/input-fields';
import { CheckboxesAndRadios } from '@/app/modules/components/checkboxes-and-radios';
import { Flatpickr } from '@/app/modules/components/flatpickr';
@Component({
selector: 'app-export',
standalone: true,
imports: [FormsModule, UiCard, InputFields, CheckboxesAndRadios, Flatpickr],
templateUrl: './export.html',
})
export class TransactionsExport {
exportConfig = {
format: 'CSV',
includeHeaders: true,
dateRange: true,
startDate: '',
endDate: '',
columns: ['date', 'amount', 'status', 'operator', 'merchant'],
compression: false
};
availableColumns = [
{ value: 'date', label: 'Date', selected: true },
{ value: 'amount', label: 'Montant', selected: true },
{ value: 'tax', label: 'Taxe', selected: false },
{ value: 'status', label: 'Statut', selected: true },
{ value: 'operator', label: 'Opérateur', selected: true },
{ value: 'merchant', label: 'Marchand', selected: true },
{ value: 'customer', label: 'Client', selected: false }
];
exportData() {
console.log('Export configuration:', this.exportConfig);
// Logique d'export
}
}

View File

@ -1,62 +0,0 @@
<app-ui-card title="Filtres Avancés des Transactions">
<form card-body class="row g-3">
<!-- Période avec Flatpickr -->
<div class="col-lg-6">
<label class="form-label">Date de début</label>
<input
type="text"
class="form-control"
[(ngModel)]="filters.startDate"
name="startDate"
placeholder="Sélectionner la date de début"
/>
</div>
<div class="col-lg-6">
<label class="form-label">Date de fin</label>
<input
type="text"
class="form-control"
[(ngModel)]="filters.endDate"
name="endDate"
placeholder="Sélectionner la date de fin"
/>
</div>
<!-- Statut avec Checkboxes -->
<div class="col-12">
<label class="form-label">Statut des transactions</label>
<app-checkboxes-and-radios />
</div>
<!-- Opérateurs avec Choices.js -->
<div class="col-lg-6">
<label class="form-label">Opérateurs</label>
<app-choicesjs />
</div>
<!-- Montants avec Input Fields -->
<div class="col-lg-3">
<label class="form-label">Montant minimum</label>
<app-input-fields />
</div>
<div class="col-lg-3">
<label class="form-label">Montant maximum</label>
<app-input-fields />
</div>
<!-- Actions -->
<div class="col-12">
<div class="d-flex gap-2">
<button type="button" class="btn btn-primary" (click)="applyFilters()">
Appliquer les Filtres
</button>
<button type="button" class="btn btn-outline-secondary" (click)="resetFilters()">
Réinitialiser
</button>
</div>
</div>
</form>
</app-ui-card>

View File

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

View File

@ -1,46 +0,0 @@
import { Component, EventEmitter, Output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { UiCard } from '@app/components/ui-card';
//import { Flatpickr } from '@/app/modules/components/flatpickr';
import { Choicesjs } from '@/app/modules/components/choicesjs';
import { InputFields } from '@/app/modules/components/input-fields';
import { CheckboxesAndRadios } from '@/app/modules/components/checkboxes-and-radios';
@Component({
selector: 'app-filters',
imports: [FormsModule, UiCard, Choicesjs, InputFields, CheckboxesAndRadios],
templateUrl: './filters.html',
})
export class TransactionsFilters {
@Output() filtersApplied = new EventEmitter<any>();
statusOptions = ['SUCCESS', 'FAILED', 'PENDING'];
operatorOptions = ['ORANGE', 'MTN', 'AIRTEL', 'VODACOM', 'MOOV'];
filters = {
startDate: '',
endDate: '',
status: [] as string[],
operators: [] as string[],
minAmount: null as number | null,
maxAmount: null as number | null,
includeTax: true
};
applyFilters() {
this.filtersApplied.emit(this.filters);
}
resetFilters() {
this.filters = {
startDate: '',
endDate: '',
status: [],
operators: [],
minAmount: null,
maxAmount: null,
includeTax: true
};
this.filtersApplied.emit(this.filters);
}
}

View File

@ -1 +1,298 @@
<p>Transactions - List</p>
<div class="transactions-container">
<!-- En-tête avec actions -->
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<div>
<h4 class="mb-1">Gestion des Transactions</h4>
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item">
<a href="javascript:void(0)" class="text-decoration-none">DCB Transactions</a>
</li>
</ol>
</nav>
</div>
<div class="d-flex gap-2">
<!-- Export -->
<div ngbDropdown>
<button class="btn btn-outline-primary" ngbDropdownToggle>
<ng-icon name="lucideDownload" class="me-1"></ng-icon>
Exporter
</button>
<div ngbDropdownMenu>
<button ngbDropdownItem (click)="exportTransactions('csv')">CSV</button>
<button ngbDropdownItem (click)="exportTransactions('excel')">Excel</button>
<button ngbDropdownItem (click)="exportTransactions('pdf')">PDF</button>
</div>
</div>
<!-- Refresh -->
<button class="btn btn-outline-secondary" (click)="loadTransactions()" [disabled]="loading">
<ng-icon name="lucideRefreshCw" [class.spin]="loading"></ng-icon>
</button>
</div>
</div>
</div>
</div>
<!-- Statistiques rapides -->
@if (paginatedData?.stats) {
<div class="row mb-4">
<div class="col-12">
<div class="card bg-light">
<div class="card-body py-3">
<div class="row text-center">
<div class="col">
<small class="text-muted">Total</small>
<div class="h5 mb-0">{{ getTotal() }}</div>
</div>
<div class="col">
<small class="text-muted">Succès</small>
<div class="h5 mb-0 text-success">{{ getSuccessCount() }}</div>
</div>
<div class="col">
<small class="text-muted">Échecs</small>
<div class="h5 mb-0 text-danger">{{ getFailedCount() }}</div>
</div>
<div class="col">
<small class="text-muted">En attente</small>
<div class="h5 mb-0 text-warning">{{ getPendingCount() }}</div>
</div>
<div class="col">
<small class="text-muted">Taux de succès</small>
<div class="h5 mb-0 text-primary">{{ getSuccessRate() }}%</div>
</div>
<div class="col">
<small class="text-muted">Montant total</small>
<div class="h5 mb-0">{{ formatCurrency(getTotalAmount()) }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
}
<!-- Barre de recherche et filtres -->
<div class="row mb-3">
<div class="col-md-4">
<div class="input-group">
<span class="input-group-text">
<ng-icon name="lucideSearch"></ng-icon>
</span>
<input
type="text"
class="form-control"
placeholder="Rechercher par MSISDN, ID..."
[(ngModel)]="searchTerm"
(keyup.enter)="onSearch()"
>
</div>
</div>
<div class="col-md-8">
<div class="d-flex gap-2">
<!-- Filtre statut -->
<select class="form-select" style="width: auto;" (change)="onStatusFilterChange($any($event.target).value)">
<option value="all">Tous les statuts</option>
@for (status of statusOptions; track status) {
<option [value]="status">{{ status }}</option>
}
</select>
<!-- Filtre opérateur -->
<select class="form-select" style="width: auto;" (change)="onOperatorFilterChange($any($event.target).value)">
<option value="">Tous les opérateurs</option>
@for (operator of operatorOptions; track operator) {
<option [value]="operator">{{ operator }}</option>
}
</select>
<button class="btn btn-outline-primary" (click)="onSearch()">
<ng-icon name="lucideFilter" class="me-1"></ng-icon>
Filtrer
</button>
<button class="btn btn-outline-secondary" (click)="onClearFilters()">
<ng-icon name="lucideX" class="me-1"></ng-icon>
Effacer
</button>
</div>
</div>
</div>
<!-- Messages d'erreur -->
@if (error) {
<div class="alert alert-danger">
<ng-icon name="lucideXCircle" class="me-2"></ng-icon>
{{ error }}
</div>
}
<!-- Loading -->
@if (loading) {
<div class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
<p class="mt-2 text-muted">Chargement des transactions...</p>
</div>
}
<!-- Tableau des transactions -->
@if (!loading) {
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th width="50">
<input
type="checkbox"
class="form-check-input"
[checked]="selectAll"
(change)="toggleSelectAll()"
>
</th>
<th (click)="sort('id')" class="cursor-pointer">
<div class="d-flex align-items-center">
<span>ID</span>
<ng-icon [name]="getSortIcon('id')" class="ms-1 fs-12"></ng-icon>
</div>
</th>
<th (click)="sort('msisdn')" class="cursor-pointer">
<div class="d-flex align-items-center">
<span>MSISDN</span>
<ng-icon [name]="getSortIcon('msisdn')" class="ms-1 fs-12"></ng-icon>
</div>
</th>
<th>Opérateur</th>
<th (click)="sort('amount')" class="cursor-pointer">
<div class="d-flex align-items-center">
<span>Montant</span>
<ng-icon [name]="getSortIcon('amount')" class="ms-1 fs-12"></ng-icon>
</div>
</th>
<th>Produit</th>
<th>Statut</th>
<th (click)="sort('transactionDate')" class="cursor-pointer">
<div class="d-flex align-items-center">
<span>Date</span>
<ng-icon [name]="getSortIcon('transactionDate')" class="ms-1 fs-12"></ng-icon>
</div>
</th>
<th width="120">Actions</th>
</tr>
</thead>
<tbody>
@for (transaction of transactions; track transaction.id) {
<tr [class.table-active]="selectedTransactions.has(transaction.id)">
<td>
<input
type="checkbox"
class="form-check-input"
[checked]="selectedTransactions.has(transaction.id)"
(change)="toggleTransactionSelection(transaction.id)"
>
</td>
<td class="font-monospace small">{{ transaction.id }}</td>
<td class="font-monospace">{{ transaction.msisdn }}</td>
<td>
<span class="badge bg-light text-dark">{{ transaction.operator }}</span>
</td>
<td>
<span [class]="getAmountColor(transaction.amount)">
{{ formatCurrency(transaction.amount, transaction.currency) }}
</span>
</td>
<td>
<div class="text-truncate" style="max-width: 150px;"
[ngbTooltip]="transaction.productName">
{{ transaction.productName }}
</div>
</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="small text-muted">
{{ formatDate(transaction.transactionDate) }}
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<button
class="btn btn-outline-primary"
(click)="viewTransactionDetails(transaction.id)"
ngbTooltip="Voir les détails"
>
<ng-icon name="lucideEye"></ng-icon>
</button>
@if (transaction.status === 'SUCCESS') {
<button
class="btn btn-outline-warning"
(click)="refundTransaction(transaction.id)"
ngbTooltip="Rembourser"
>
<ng-icon name="lucideUndo2"></ng-icon>
</button>
}
@if (transaction.status === 'FAILED') {
<button
class="btn btn-outline-info"
(click)="retryTransaction(transaction.id)"
ngbTooltip="Réessayer"
>
<ng-icon name="lucideRefreshCw"></ng-icon>
</button>
}
</div>
</td>
</tr>
}
@empty {
<tr>
<td colspan="8" class="text-center py-4">
<ng-icon name="lucideCreditCard" class="text-muted fs-1 mb-2 d-block"></ng-icon>
<p class="text-muted mb-3">Aucune transaction trouvée</p>
<button class="btn btn-primary" (click)="onClearFilters()">
Réinitialiser les filtres
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
<!-- Pagination -->
@if (paginatedData && paginatedData.totalPages > 1) {
<div class="d-flex justify-content-between align-items-center mt-3">
<div class="text-muted">
Affichage de {{ (filters.page! - 1) * filters.limit! + 1 }} à
{{ (filters.page! * filters.limit!) > (paginatedData?.total || 0) ? (paginatedData?.total || 0) : (filters.page! * filters.limit!) }}
sur {{ paginatedData?.total || 0 }} transactions
</div>
<nav>
<ngb-pagination
[collectionSize]="paginatedData.total"
[page]="filters.page!"
[pageSize]="filters.limit!"
[maxSize]="5"
[rotate]="true"
[boundaryLinks]="true"
(pageChange)="onPageChange($event)"
/>
</nav>
</div>
}
}
</div>

View File

@ -1,7 +1,355 @@
import { Component } from '@angular/core';
import { Component, inject, OnInit, ChangeDetectorRef, Output, EventEmitter } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { NgIcon, provideNgIconsConfig } from '@ng-icons/core';
import {
lucideSearch,
lucideFilter,
lucideX,
lucideDownload,
lucideEye,
lucideRefreshCw,
lucideArrowUpDown,
lucideArrowUp,
lucideArrowDown,
lucideCheckCircle,
lucideClock,
lucideXCircle,
lucideUndo2,
lucideBan
} from '@ng-icons/lucide';
import { NgbPaginationModule, NgbDropdownModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
import { TransactionsService } from '../services/transactions.service';
import { Transaction, TransactionQuery, TransactionStatus, PaginatedTransactions } from '../models/transaction';
import { environment } from '@environments/environment';
@Component({
selector: 'app-list',
templateUrl: './list.html',
selector: 'app-transactions-list',
standalone: true,
imports: [
CommonModule,
FormsModule,
NgIcon,
NgbPaginationModule,
NgbDropdownModule,
NgbTooltipModule
],
providers: [
provideNgIconsConfig({
size: '1.25em'
})
],
templateUrl: './list.html'
})
export class TransactionsList {}
export class TransactionsList implements OnInit {
private transactionsService = inject(TransactionsService);
private cdRef = inject(ChangeDetectorRef);
@Output() transactionSelected = new EventEmitter<string>();
@Output() openRefundModal = new EventEmitter<string>();
// Données
transactions: Transaction[] = [];
paginatedData: PaginatedTransactions | null = null;
// États
loading = false;
error = '';
// Filtres et recherche
searchTerm = '';
filters: TransactionQuery = {
page: 1,
limit: 20,
status: undefined,
operator: '',
country: '',
startDate: undefined,
endDate: undefined,
msisdn: '',
sortBy: 'transactionDate',
sortOrder: 'desc'
};
// Options de filtre
statusOptions: TransactionStatus[] = ['PENDING', 'SUCCESS', 'FAILED', 'REFUNDED', 'CANCELLED'];
operatorOptions: string[] = ['Orange', 'Free', 'SFR', 'Bouygues'];
countryOptions: string[] = ['FR', 'BE', 'CH', 'LU'];
// Tri
sortField: string = 'transactionDate';
sortDirection: 'asc' | 'desc' = 'desc';
// Sélection multiple
selectedTransactions: Set<string> = new Set();
selectAll = false;
ngOnInit() {
this.loadTransactions();
}
loadTransactions() {
this.loading = true;
this.error = '';
// Mettre à jour les filtres avec la recherche
if (this.searchTerm) {
this.filters.search = this.searchTerm;
} else {
delete this.filters.search;
}
// Appliquer le tri
this.filters.sortBy = this.sortField;
this.filters.sortOrder = this.sortDirection;
this.transactionsService.getTransactions(this.filters).subscribe({
next: (data) => {
this.paginatedData = data;
this.transactions = data.data;
this.loading = false;
this.cdRef.detectChanges();
},
error: (error) => {
this.error = 'Erreur lors du chargement des transactions';
this.loading = false;
// Fallback sur les données mockées en développement
if (environment.production === false) {
this.transactions = this.transactionsService.getMockTransactions();
this.paginatedData = {
data: this.transactions,
total: this.transactions.length,
page: 1,
limit: 20,
totalPages: 1,
stats: {
total: this.transactions.length,
totalAmount: this.transactions.reduce((sum, tx) => sum + tx.amount, 0),
successCount: this.transactions.filter(tx => tx.status === 'SUCCESS').length,
failedCount: this.transactions.filter(tx => tx.status === 'FAILED').length,
pendingCount: this.transactions.filter(tx => tx.status === 'PENDING').length,
refundedCount: this.transactions.filter(tx => tx.status === 'REFUNDED').length,
successRate: 75,
averageAmount: 4.74
}
};
this.loading = false;
}
this.cdRef.detectChanges();
console.error('Error loading transactions:', error);
}
});
}
// Recherche et filtres
onSearch() {
this.filters.page = 1;
this.loadTransactions();
}
onClearFilters() {
this.searchTerm = '';
this.filters = {
page: 1,
limit: 20,
status: undefined,
operator: '',
country: '',
startDate: undefined,
endDate: undefined,
msisdn: '',
sortBy: 'transactionDate',
sortOrder: 'desc'
};
this.loadTransactions();
}
onStatusFilterChange(status: TransactionStatus | 'all') {
this.filters.status = status === 'all' ? undefined : status;
this.filters.page = 1;
this.loadTransactions();
}
onOperatorFilterChange(operator: string) {
this.filters.operator = operator;
this.filters.page = 1;
this.loadTransactions();
}
onDateRangeChange(start: Date | null, end: Date | null) {
this.filters.startDate = start || undefined;
this.filters.endDate = end || undefined;
this.filters.page = 1;
this.loadTransactions();
}
// Tri
sort(field: string) {
if (this.sortField === field) {
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
} else {
this.sortField = field;
this.sortDirection = 'desc';
}
this.loadTransactions();
}
getSortIcon(field: string): string {
if (this.sortField !== field) return 'lucideArrowUpDown';
return this.sortDirection === 'asc' ? 'lucideArrowUp' : 'lucideArrowDown';
}
// Pagination
onPageChange(page: number) {
this.filters.page = page;
this.loadTransactions();
}
// Actions
viewTransactionDetails(transactionId: string) {
this.transactionSelected.emit(transactionId);
}
refundTransaction(transactionId: string) {
this.openRefundModal.emit(transactionId);
}
retryTransaction(transactionId: string) {
this.transactionsService.retryTransaction(transactionId).subscribe({
next: () => {
this.loadTransactions();
},
error: (error) => {
console.error('Error retrying transaction:', error);
this.error = 'Erreur lors de la nouvelle tentative';
this.cdRef.detectChanges();
}
});
}
// Sélection multiple
toggleTransactionSelection(transactionId: string) {
if (this.selectedTransactions.has(transactionId)) {
this.selectedTransactions.delete(transactionId);
} else {
this.selectedTransactions.add(transactionId);
}
this.updateSelectAllState();
}
toggleSelectAll() {
if (this.selectAll) {
this.transactions.forEach(tx => this.selectedTransactions.add(tx.id));
} else {
this.selectedTransactions.clear();
}
}
updateSelectAllState() {
this.selectAll = this.transactions.length > 0 &&
this.selectedTransactions.size === this.transactions.length;
}
// Export
exportTransactions(format: 'csv' | 'excel' | 'pdf') {
const exportRequest = {
format: format,
query: this.filters,
columns: ['id', 'msisdn', 'operator', 'amount', 'status', 'transactionDate', 'productName']
};
this.transactionsService.exportTransactions(exportRequest).subscribe({
next: (response) => {
// Télécharger le fichier
const link = document.createElement('a');
link.href = response.url;
link.download = response.filename;
link.click();
},
error: (error) => {
console.error('Error exporting transactions:', error);
this.error = 'Erreur lors de l\'export';
this.cdRef.detectChanges();
}
});
}
// Utilitaires d'affichage
getStatusBadgeClass(status: TransactionStatus): 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';
case 'CANCELLED': return 'badge bg-secondary';
case 'EXPIRED': return 'badge bg-dark';
default: return 'badge bg-secondary';
}
}
getStatusIcon(status: TransactionStatus): string {
switch (status) {
case 'SUCCESS': return 'lucideCheckCircle';
case 'PENDING': return 'lucideClock';
case 'FAILED': return 'lucideXCircle';
case 'REFUNDED': return 'lucideUndo2';
case 'CANCELLED': return 'lucideBan';
default: return 'lucideClock';
}
}
formatCurrency(amount: number, currency: string = 'EUR'): string {
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: currency
}).format(amount);
}
formatDate(date: Date): string {
return new Intl.DateTimeFormat('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(new Date(date));
}
getAmountColor(amount: number): string {
if (amount >= 10) return 'text-danger fw-bold';
if (amount >= 5) return 'text-warning fw-semibold';
return 'text-success';
}
// Méthodes pour sécuriser l'accès aux stats
getTotal(): number {
return this.paginatedData?.stats?.total || 0;
}
getSuccessCount(): number {
return this.paginatedData?.stats?.successCount || 0;
}
getFailedCount(): number {
return this.paginatedData?.stats?.failedCount || 0;
}
getPendingCount(): number {
return this.paginatedData?.stats?.pendingCount || 0;
}
getSuccessRate(): number {
return this.paginatedData?.stats?.successRate || 0;
}
getTotalAmount(): number {
return this.paginatedData?.stats?.totalAmount || 0;
}
getMinValue(a: number, b: number): number {
return Math.min(a, b);
}
}

View File

@ -0,0 +1,80 @@
export interface Transaction {
id: string;
msisdn: string;
operator: string;
operatorId: string;
country: string;
amount: number;
currency: string;
status: TransactionStatus;
productId: string;
productName: string;
productCategory: string;
transactionDate: Date;
createdAt: Date;
updatedAt: Date;
externalId?: string;
merchantId?: string;
merchantName?: string;
errorCode?: string;
errorMessage?: string;
userAgent?: string;
ipAddress?: string;
customData?: { [key: string]: any };
}
export interface TransactionQuery {
page?: number;
limit?: number;
search?: string;
status?: TransactionStatus;
operator?: string;
country?: string;
startDate?: Date;
endDate?: Date;
msisdn?: string;
productId?: string;
merchantId?: string;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}
export interface TransactionStats {
total: number;
totalAmount: number;
successCount: number;
failedCount: number;
pendingCount: number;
refundedCount: number;
successRate: number;
averageAmount: number;
}
export interface PaginatedTransactions {
data: Transaction[];
total: number;
page: number;
limit: number;
totalPages: number;
stats: TransactionStats;
}
export type TransactionStatus =
| 'PENDING'
| 'SUCCESS'
| 'FAILED'
| 'REFUNDED'
| 'CANCELLED'
| 'EXPIRED';
export interface RefundRequest {
transactionId: string;
reason?: string;
amount?: number;
}
export interface TransactionExportRequest {
format: 'csv' | 'excel' | 'pdf';
query: TransactionQuery;
columns?: string[];
}

View File

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

View File

@ -1,27 +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 ExportConfig {
format: 'CSV' | 'EXCEL' | 'PDF';
includeTax: boolean;
dateRange: boolean;
columns: string[];
filters: any;
}
@Injectable({ providedIn: 'root' })
export class TransactionsExportService {
private http = inject(HttpClient);
exportTransactions(config: ExportConfig): Observable<Blob> {
return this.http.post(`${environment.apiUrl}/transactions/export`, config, {
responseType: 'blob'
});
}
getExportTemplates(): Observable<string[]> {
return this.http.get<string[]>(`${environment.apiUrl}/transactions/export/templates`);
}
}

View File

@ -1,59 +0,0 @@
import { Component, EventEmitter, Output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Flatpickr } from '@/app/modules/components/flatpickr';
import { Choicesjs } from '@/app/modules/components/choicesjs';
import { InputFields } from '@/app/modules/components/input-fields';
@Component({
selector: 'app-filters',
imports: [FormsModule, Flatpickr, Choicesjs, InputFields],
template: `
<div class="card">
<div class="card-body">
<h5 class="card-title">Filtres Avancés des Transactions</h5>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Période</label>
<app-flatpickr />
</div>
<div class="col-md-6">
<label class="form-label">Statut</label>
<app-choicesjs />
</div>
<div class="col-md-6">
<label class="form-label">Montant Minimum</label>
<app-input-fields />
</div>
<div class="col-md-6">
<label class="form-label">Montant Maximum</label>
<app-input-fields />
</div>
</div>
<div class="d-flex gap-2 mt-3">
<button class="btn btn-primary" (click)="applyFilters()">
Appliquer les Filtres
</button>
<button class="btn btn-outline-secondary" (click)="resetFilters()">
Réinitialiser
</button>
</div>
</div>
</div>
`
})
export class TransactionsFilters {
@Output() filtersChange = new EventEmitter<any>();
applyFilters() {
// Logique d'application des filtres
}
resetFilters() {
// Logique de réinitialisation
}
}

View File

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

View File

@ -1,46 +1,177 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { HttpClient, HttpParams } from '@angular/common/http';
import { environment } from '@environments/environment';
import { Observable } from 'rxjs';
import { Observable, map, catchError, throwError } from 'rxjs';
export interface Transaction {
id: string;
date: Date;
amount: number;
tax: number;
status: 'SUCCESS' | 'FAILED' | 'PENDING';
merchantId: string;
operator: string;
customerAlias: string;
subscriptionId?: string;
}
export interface TransactionFilter {
startDate?: Date;
endDate?: Date;
status?: string;
merchantId?: string;
operator?: string;
minAmount?: number;
maxAmount?: number;
}
import {
Transaction,
TransactionQuery,
PaginatedTransactions,
TransactionStats,
RefundRequest
} from '../models/transaction';
@Injectable({ providedIn: 'root' })
export class TransactionService {
export class TransactionsService {
private http = inject(HttpClient);
private apiUrl = `${environment.apiUrl}/transactions`;
getTransactions(filters?: TransactionFilter): Observable<Transaction[]> {
return this.http.post<Transaction[]>(
`${environment.apiUrl}/transactions/list`,
filters
// === CRUD OPERATIONS ===
getTransactions(query: TransactionQuery): Observable<PaginatedTransactions> {
let params = new HttpParams();
// Ajouter tous les paramètres de query
Object.keys(query).forEach(key => {
const value = query[key as keyof TransactionQuery];
if (value !== undefined && value !== null) {
if (value instanceof Date) {
params = params.set(key, value.toISOString());
} else {
params = params.set(key, value.toString());
}
}
});
return this.http.get<PaginatedTransactions>(`${this.apiUrl}`, { params }).pipe(
catchError(error => {
console.error('Error loading transactions:', error);
return throwError(() => error);
})
);
}
getTransactionDetails(id: string): Observable<Transaction> {
return this.http.get<Transaction>(`${environment.apiUrl}/transactions/${id}`);
getTransactionById(id: string): Observable<Transaction> {
return this.http.get<Transaction>(`${this.apiUrl}/${id}`).pipe(
catchError(error => {
console.error('Error loading transaction:', error);
return throwError(() => error);
})
);
}
getTransactionStats(): Observable<any> {
return this.http.get(`${environment.apiUrl}/transactions/stats`);
// === ACTIONS ===
refundTransaction(refundRequest: RefundRequest): Observable<{ message: string; transaction: Transaction }> {
return this.http.post<{ message: string; transaction: Transaction }>(
`${this.apiUrl}/${refundRequest.transactionId}/refund`,
refundRequest
);
}
cancelTransaction(transactionId: string): Observable<{ message: string }> {
return this.http.post<{ message: string }>(
`${this.apiUrl}/${transactionId}/cancel`,
{}
);
}
retryTransaction(transactionId: string): Observable<{ message: string; transaction: Transaction }> {
return this.http.post<{ message: string; transaction: Transaction }>(
`${this.apiUrl}/${transactionId}/retry`,
{}
);
}
// === STATISTIQUES ===
getTransactionStats(query?: Partial<TransactionQuery>): Observable<TransactionStats> {
let params = new HttpParams();
if (query) {
Object.keys(query).forEach(key => {
const value = query[key as keyof TransactionQuery];
if (value !== undefined && value !== null) {
if (value instanceof Date) {
params = params.set(key, value.toISOString());
} else {
params = params.set(key, value.toString());
}
}
});
}
return this.http.get<TransactionStats>(`${this.apiUrl}/stats`, { params });
}
// === EXPORT ===
exportTransactions(exportRequest: any): Observable<{ url: string; filename: string }> {
return this.http.post<{ url: string; filename: string }>(
`${this.apiUrl}/export`,
exportRequest
);
}
// === MOCK DATA POUR LE DÉVELOPPEMENT ===
getMockTransactions(): Transaction[] {
return [
{
id: 'tx_001',
msisdn: '+33612345678',
operator: 'Orange',
operatorId: 'orange_fr',
country: 'FR',
amount: 4.99,
currency: 'EUR',
status: 'SUCCESS',
productId: 'prod_premium',
productName: 'Contenu Premium',
productCategory: 'ENTERTAINMENT',
transactionDate: new Date('2024-01-15T14:30:00'),
createdAt: new Date('2024-01-15T14:30:00'),
updatedAt: new Date('2024-01-15T14:30:00'),
externalId: 'ext_123456',
merchantName: 'MediaCorp'
},
{
id: 'tx_002',
msisdn: '+33798765432',
operator: 'Free',
operatorId: 'free_fr',
country: 'FR',
amount: 2.99,
currency: 'EUR',
status: 'PENDING',
productId: 'prod_basic',
productName: 'Abonnement Basique',
productCategory: 'SUBSCRIPTION',
transactionDate: new Date('2024-01-15T14:25:00'),
createdAt: new Date('2024-01-15T14:25:00'),
updatedAt: new Date('2024-01-15T14:25:00'),
externalId: 'ext_123457'
},
{
id: 'tx_003',
msisdn: '+33687654321',
operator: 'SFR',
operatorId: 'sfr_fr',
country: 'FR',
amount: 9.99,
currency: 'EUR',
status: 'FAILED',
productId: 'prod_pro',
productName: 'Pack Professionnel',
productCategory: 'BUSINESS',
transactionDate: new Date('2024-01-15T14:20:00'),
createdAt: new Date('2024-01-15T14:20:00'),
updatedAt: new Date('2024-01-15T14:20:00'),
errorCode: 'INSUFFICIENT_FUNDS',
errorMessage: 'Solde insuffisant'
},
{
id: 'tx_004',
msisdn: '+33611223344',
operator: 'Bouygues',
operatorId: 'bouygues_fr',
country: 'FR',
amount: 1.99,
currency: 'EUR',
status: 'REFUNDED',
productId: 'prod_mini',
productName: 'Pack Découverte',
productCategory: 'GAMING',
transactionDate: new Date('2024-01-15T14:15:00'),
createdAt: new Date('2024-01-15T14:15:00'),
updatedAt: new Date('2024-01-15T16:30:00'),
merchantName: 'GameStudio'
}
];
}
}

View File

@ -1,49 +1,46 @@
<!-- src/app/modules/transactions/transactions.html -->
<div class="container-fluid">
<app-page-title
title="Gestion des Transactions"
subTitle="Consultez, filtrez et exportez l'ensemble des transactions du système DCB"
[badge]="{icon: 'lucideCreditCard', text: 'Transactions'}"
title="Transactions DCB"
subTitle="Gestion et suivi des transactions de paiement mobile"
[badge]="{icon:'lucideCreditCard', text:'Transactions'}"
/>
<!-- Navigation par onglets -->
<ul class="nav nav-tabs mb-4">
<li class="nav-item">
<button
class="nav-link"
[class.active]="activeTab === 'list'"
(click)="setActiveTab('list')">
Liste & Recherche
<!-- Navigation -->
<div class="row mb-4">
<div class="col-12">
@if (activeView === 'list') {
<app-transactions-list
(transactionSelected)="showDetailsView($event)"
(openRefundModal)="openModal(refundModal)"
/>
} @else if (activeView === 'details' && selectedTransactionId) {
<div class="d-flex align-items-center mb-3">
<button class="btn btn-outline-secondary btn-sm me-2" (click)="showListView()">
<ng-icon name="lucideArrowLeft" class="me-1"></ng-icon>
Retour à la liste
</button>
</li>
<li class="nav-item">
<button
class="nav-link"
[class.active]="activeTab === 'filters'"
(click)="setActiveTab('filters')">
Filtres Avancés
</button>
</li>
<li class="nav-item">
<button
class="nav-link"
[class.active]="activeTab === 'export'"
(click)="setActiveTab('export')">
Export
</button>
</li>
</ul>
<!-- Contenu des onglets avec NOUVEAU control flow @switch -->
@switch (activeTab) {
@case ('list') {
<app-list />
}
@case ('filters') {
<app-filters (filtersApplied)="onFiltersApplied($event)" />
}
@case ('export') {
<app-export />
}
<h5 class="mb-0">Détails de la transaction</h5>
</div>
<app-transaction-details [transactionId]="selectedTransactionId" />
}
</div>
</div>
</div>
<!-- Modal de remboursement -->
<ng-template #refundModal let-modal>
<div class="modal-header">
<h5 class="modal-title">Rembourser la transaction</h5>
<button type="button" class="btn-close" (click)="modal.dismiss()"></button>
</div>
<div class="modal-body">
<p>Êtes-vous sûr de vouloir rembourser cette transaction ?</p>
<div class="alert alert-warning">
<small>Cette action est irréversible et créditera le compte du client.</small>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" (click)="modal.dismiss()">Annuler</button>
<button type="button" class="btn btn-warning" (click)="modal.close()">Confirmer le remboursement</button>
</div>
</ng-template>

View File

@ -1,24 +1,48 @@
import { Component } from '@angular/core';
import { Component, inject, TemplateRef, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { NgbModal, NgbModalModule } from '@ng-bootstrap/ng-bootstrap';
import { PageTitle } from '@app/components/page-title/page-title';
import { TransactionsList } from './list/list';
import { TransactionsFilters } from './filters/filters';
import { TransactionsDetails } from './details/details';
import { TransactionsExport } from './export/export';
import { TransactionDetails } from './details/details';
import { NgIcon } from '@ng-icons/core';
@Component({
selector: 'app-transactions',
imports: [PageTitle, TransactionsList, TransactionsFilters, TransactionsDetails, TransactionsExport],
standalone: true,
imports: [
CommonModule,
NgbModalModule,
PageTitle,
NgIcon,
TransactionsList,
TransactionDetails
],
templateUrl: './transactions.html',
})
export class Transactions {
activeTab: string = 'list';
private modalService = inject(NgbModal);
setActiveTab(tab: string) {
this.activeTab = tab;
activeView: 'list' | 'details' = 'list';
selectedTransactionId: string | null = null;
showListView() {
this.activeView = 'list';
this.selectedTransactionId = null;
}
onFiltersApplied(filters: any) {
console.log('Filters applied:', filters);
// Appliquer les filtres à la liste
showDetailsView(transactionId: string) {
this.activeView = 'details';
this.selectedTransactionId = transactionId;
}
// Gestion des modals
openModal(content: TemplateRef<any>, size: 'sm' | 'lg' | 'xl' = 'lg') {
this.modalService.open(content, {
size: size,
centered: true,
scrollable: true
});
}
@ViewChild('refundModal') refundModal!: TemplateRef<any>;
}

View File

@ -1 +0,0 @@
<p>Users - Audits</p>

View File

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

View File

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

View File

@ -1 +1,243 @@
<p>Users - List</p>
<app-ui-card title="Liste des Utilisateurs">
<a
helper-text
href="javascript:void(0);"
class="icon-link icon-link-hover link-primary fw-semibold"
>Gérez les accès utilisateurs de votre plateforme
</a>
<div card-body>
<!-- Barre d'actions supérieure -->
<div class="row mb-3">
<div class="col-md-6">
<div class="d-flex justify-right-end gap-2">
<button
class="btn btn-primary"
(click)="openCreateModal.emit()"
>
<ng-icon name="lucideUserPlus" class="me-1"></ng-icon>
Nouvel Utilisateur
</button>
</div>
</div>
</div>
<!-- Barre de recherche et filtres -->
<div class="row mb-3">
<div class="col-md-4">
<div class="input-group">
<span class="input-group-text">
<ng-icon name="lucideSearch"></ng-icon>
</span>
<input
type="text"
class="form-control"
placeholder="Rechercher par nom, email, username..."
[(ngModel)]="searchTerm"
(keyup.enter)="onSearch()"
>
</div>
</div>
<div class="col-md-3">
<select class="form-select" [(ngModel)]="statusFilter" (change)="onSearch()">
<option value="all">Tous les statuts</option>
<option value="enabled">Activés</option>
<option value="disabled">Désactivés</option>
</select>
</div>
<div class="col-md-3">
<select class="form-select" [(ngModel)]="emailVerifiedFilter" (change)="onSearch()">
<option value="all">Tous les emails</option>
<option value="verified">Email vérifié</option>
<option value="not-verified">Email non vérifié</option>
</select>
</div>
<div class="col-md-2">
<div class="d-flex gap-2">
<button class="btn btn-outline-primary" (click)="onSearch()">
<ng-icon name="lucideFilter"></ng-icon>
</button>
<button class="btn btn-outline-secondary" (click)="onClearFilters()">
<ng-icon name="lucideX"></ng-icon>
</button>
</div>
</div>
</div>
<!-- Loading State -->
@if (loading) {
<div class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
<p class="mt-2 text-muted">Chargement des utilisateurs...</p>
</div>
}
<!-- Error State -->
@if (error && !loading) {
<div class="alert alert-danger" role="alert">
<ng-icon name="lucideAlertCircle" class="me-2"></ng-icon>
{{ error }}
</div>
}
<!-- Users Table -->
@if (!loading && !error) {
<div class="table-responsive">
<table class="table table-hover table-striped">
<thead class="table-light">
<tr>
<th (click)="sort('username')" class="cursor-pointer">
<div class="d-flex align-items-center">
<span>Utilisateur</span>
<ng-icon [name]="getSortIcon('username')" class="ms-1 fs-12"></ng-icon>
</div>
</th>
<th (click)="sort('email')" class="cursor-pointer">
<div class="d-flex align-items-center">
<span>Email</span>
<ng-icon [name]="getSortIcon('email')" class="ms-1 fs-12"></ng-icon>
</div>
</th>
<th>Rôles</th>
<th (click)="sort('enabled')" class="cursor-pointer">
<div class="d-flex align-items-center">
<span>Statut</span>
<ng-icon [name]="getSortIcon('enabled')" class="ms-1 fs-12"></ng-icon>
</div>
</th>
<th (click)="sort('createdTimestamp')" class="cursor-pointer">
<div class="d-flex align-items-center">
<span>Créé le</span>
<ng-icon [name]="getSortIcon('createdTimestamp')" class="ms-1 fs-12"></ng-icon>
</div>
</th>
<th width="150">Actions</th>
</tr>
</thead>
<tbody>
@for (user of displayedUsers; track user.id) {
<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">
<span class="text-white fw-bold small">
{{ getUserInitials(user) }}
</span>
</div>
<div>
<strong>{{ getUserDisplayName(user) }}</strong>
<div class="text-muted small">@{{ user.username }}</div>
</div>
</div>
</td>
<td>
<div>{{ user.email }}</div>
@if (!user.emailVerified) {
<small class="text-warning">
<ng-icon name="lucideAlertCircle" class="me-1"></ng-icon>
Non vérifié
</small>
}
</td>
<td>
@for (role of user.clientRoles; track role) {
<span class="badge me-1 mb-1" [ngClass]="getRoleBadgeClass(role)">
{{ role }}
</span>
}
@if (user.clientRoles.length === 0) {
<span class="text-muted small">Aucun rôle</span>
}
</td>
<td>
<span [class]="getStatusBadgeClass(user)">
{{ getStatusText(user) }}
</span>
</td>
<td>
<small class="text-muted">
{{ formatTimestamp(user.createdTimestamp) }}
</small>
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<button
class="btn btn-outline-primary btn-sm"
(click)="viewUserProfile(user.id)"
title="Voir le profil"
>
<ng-icon name="lucideEye"></ng-icon>
</button>
<button
class="btn btn-outline-info btn-sm"
(click)="resetPassword(user)"
title="Réinitialiser le mot de passe"
>
<ng-icon name="lucideKey"></ng-icon>
</button>
@if (user.enabled) {
<button
class="btn btn-outline-warning btn-sm"
(click)="disableUser(user)"
title="Désactiver l'utilisateur"
>
<ng-icon name="lucidePause"></ng-icon>
</button>
} @else {
<button
class="btn btn-outline-success btn-sm"
(click)="enableUser(user)"
title="Activer l'utilisateur"
>
<ng-icon name="lucidePlay"></ng-icon>
</button>
}
<button
class="btn btn-outline-danger btn-sm"
(click)="deleteUser(user)"
title="Supprimer l'utilisateur"
>
<ng-icon name="lucideTrash2"></ng-icon>
</button>
</div>
</td>
</tr>
}
@empty {
<tr>
<td colspan="6" class="text-center py-4">
<ng-icon name="lucideUsers" class="text-muted fs-1 mb-2"></ng-icon>
<p class="text-muted">Aucun utilisateur trouvé</p>
<button class="btn btn-primary" (click)="openCreateModal.emit()">
Créer le premier utilisateur
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="d-flex justify-content-between align-items-center mt-3">
<div class="text-muted">
Affichage de {{ getStartIndex() }} à {{ getEndIndex() }} sur {{ totalItems }} utilisateurs
</div>
<nav>
<ngb-pagination
[collectionSize]="totalItems"
[page]="currentPage"
[pageSize]="itemsPerPage"
[maxSize]="5"
[rotate]="true"
[boundaryLinks]="true"
(pageChange)="onPageChange($event)"
/>
</nav>
</div>
}
</div>
</app-ui-card>

View File

@ -1,7 +1,258 @@
import { Component } from '@angular/core';
import { Component, inject, OnInit, Output, EventEmitter, ChangeDetectorRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { NgIcon } from '@ng-icons/core';
import { NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap';
import { UsersService } from '../services/users.service';
import { UserResponse } from '../models/user';
import { UiCard } from '@app/components/ui-card';
@Component({
selector: 'app-users-list',
standalone: true,
imports: [
CommonModule,
FormsModule,
NgIcon,
UiCard,
NgbPaginationModule
],
templateUrl: './list.html',
})
export class UsersList {}
export class UsersList implements OnInit {
private usersService = inject(UsersService);
private cdRef = inject(ChangeDetectorRef);
@Output() userSelected = new EventEmitter<string>();
@Output() openCreateModal = new EventEmitter<void>();
@Output() openResetPasswordModal = new EventEmitter<string>();
@Output() openDeleteUserModal = new EventEmitter<string>();
// Données
allUsers: UserResponse[] = [];
filteredUsers: UserResponse[] = [];
displayedUsers: UserResponse[] = [];
// États
loading = false;
error = '';
// Recherche et filtres
searchTerm = '';
statusFilter: 'all' | 'enabled' | 'disabled' = 'all';
emailVerifiedFilter: 'all' | 'verified' | 'not-verified' = 'all';
// Pagination
currentPage = 1;
itemsPerPage = 10;
totalItems = 0;
totalPages = 0;
// Tri
sortField: keyof UserResponse = 'username';
sortDirection: 'asc' | 'desc' = 'asc';
ngOnInit() {
this.loadUsers();
}
loadUsers() {
this.loading = true;
this.error = '';
this.usersService.findAllUsers().subscribe({
next: (response) => {
this.allUsers = response.data;
this.applyFiltersAndPagination();
this.loading = false;
this.cdRef.detectChanges();
},
error: (error) => {
this.error = 'Erreur lors du chargement des utilisateurs';
this.loading = false;
this.cdRef.detectChanges();
console.error('Error loading users:', error);
}
});
}
// Recherche et filtres
onSearch() {
this.currentPage = 1;
this.applyFiltersAndPagination();
}
onClearFilters() {
this.searchTerm = '';
this.statusFilter = 'all';
this.emailVerifiedFilter = 'all';
this.currentPage = 1;
this.applyFiltersAndPagination();
}
applyFiltersAndPagination() {
// Appliquer les filtres
this.filteredUsers = this.allUsers.filter(user => {
// Filtre de recherche
const matchesSearch = !this.searchTerm ||
user.username.toLowerCase().includes(this.searchTerm.toLowerCase()) ||
user.email.toLowerCase().includes(this.searchTerm.toLowerCase()) ||
user.firstName?.toLowerCase().includes(this.searchTerm.toLowerCase()) ||
user.lastName?.toLowerCase().includes(this.searchTerm.toLowerCase());
// Filtre par statut
const matchesStatus = this.statusFilter === 'all' ||
(this.statusFilter === 'enabled' && user.enabled) ||
(this.statusFilter === 'disabled' && !user.enabled);
// Filtre par email vérifié
const matchesEmailVerified = this.emailVerifiedFilter === 'all' ||
(this.emailVerifiedFilter === 'verified' && user.emailVerified) ||
(this.emailVerifiedFilter === 'not-verified' && !user.emailVerified);
return matchesSearch && matchesStatus && matchesEmailVerified;
});
// Appliquer le tri
this.filteredUsers.sort((a, b) => {
const aValue = a[this.sortField];
const bValue = b[this.sortField];
if (aValue === bValue) return 0;
let comparison = 0;
if (typeof aValue === 'string' && typeof bValue === 'string') {
comparison = aValue.localeCompare(bValue);
} else if (typeof aValue === 'number' && typeof bValue === 'number') {
comparison = aValue - bValue;
} else if (typeof aValue === 'boolean' && typeof bValue === 'boolean') {
comparison = (aValue === bValue) ? 0 : aValue ? -1 : 1;
}
return this.sortDirection === 'asc' ? comparison : -comparison;
});
// Calculer la pagination
this.totalItems = this.filteredUsers.length;
this.totalPages = Math.ceil(this.totalItems / this.itemsPerPage);
// Appliquer la pagination
const startIndex = (this.currentPage - 1) * this.itemsPerPage;
const endIndex = startIndex + this.itemsPerPage;
this.displayedUsers = this.filteredUsers.slice(startIndex, endIndex);
}
// Tri
sort(field: keyof UserResponse) {
if (this.sortField === field) {
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
} else {
this.sortField = field;
this.sortDirection = 'asc';
}
this.applyFiltersAndPagination();
}
getSortIcon(field: keyof UserResponse): string {
if (this.sortField !== field) return 'lucideArrowUpDown';
return this.sortDirection === 'asc' ? 'lucideArrowUp' : 'lucideArrowDown';
}
// Pagination
onPageChange(page: number) {
this.currentPage = page;
this.applyFiltersAndPagination();
}
getStartIndex(): number {
return (this.currentPage - 1) * this.itemsPerPage + 1;
}
getEndIndex(): number {
return Math.min(this.currentPage * this.itemsPerPage, this.totalItems);
}
// Actions
viewUserProfile(userId: string) {
this.userSelected.emit(userId);
}
// Méthode pour réinitialiser le mot de passe
resetPassword(user: UserResponse) {
this.openResetPasswordModal.emit(user.id);
}
// Méthode pour ouvrir le modal de suppression
deleteUser(user: UserResponse) {
this.openDeleteUserModal.emit(user.id);
}
enableUser(user: UserResponse) {
this.usersService.enableUser(user.id).subscribe({
next: () => {
user.enabled = true;
this.applyFiltersAndPagination();
this.cdRef.detectChanges();
},
error: (error) => {
console.error('Error enabling user:', error);
alert('Erreur lors de l\'activation de l\'utilisateur');
}
});
}
disableUser(user: UserResponse) {
this.usersService.disableUser(user.id).subscribe({
next: () => {
user.enabled = false;
this.applyFiltersAndPagination();
this.cdRef.detectChanges();
},
error: (error) => {
console.error('Error disabling user:', error);
alert('Erreur lors de la désactivation de l\'utilisateur');
}
});
}
// Utilitaires d'affichage
getStatusBadgeClass(user: UserResponse): string {
if (!user.enabled) return 'badge bg-danger';
if (!user.emailVerified) return 'badge bg-warning';
return 'badge bg-success';
}
getStatusText(user: UserResponse): string {
if (!user.enabled) return 'Désactivé';
if (!user.emailVerified) return 'Email non vérifié';
return 'Actif';
}
getRoleBadgeClass(role: string): string {
switch (role) {
case 'admin': return 'bg-danger';
case 'merchant': return 'bg-success';
case 'support': return 'bg-info';
case 'user': return 'bg-secondary';
default: return 'bg-secondary';
}
}
formatTimestamp(timestamp: number): string {
return new Date(timestamp).toLocaleDateString('fr-FR');
}
getUserInitials(user: UserResponse): string {
return (user.firstName?.charAt(0) || '') + (user.lastName?.charAt(0) || '') || 'U';
}
getUserDisplayName(user: UserResponse): string {
if (user.firstName && user.lastName) {
return `${user.firstName} ${user.lastName}`;
}
return user.username;
}
}

View File

@ -0,0 +1,180 @@
export class User {
id?: string;
username: string;
email: string;
firstName: string;
lastName: string;
enabled?: boolean;
emailVerified?: boolean;
attributes?: { [key: string]: any };
createdTimestamp?: number;
constructor(partial?: Partial<User>) {
// Initialisation des propriétés obligatoires
this.username = '';
this.email = '';
this.firstName = '';
this.lastName = '';
if (partial) {
Object.assign(this, partial);
}
// Valeurs par défaut
this.enabled = this.enabled ?? true;
this.emailVerified = this.emailVerified ?? false;
}
}
export class UserResponse {
id: string;
username: string;
email: string;
firstName: string;
lastName: string;
enabled: boolean;
emailVerified: boolean;
attributes?: { [key: string]: any };
clientRoles: string[];
createdTimestamp: number;
constructor(user: any) {
// Initialisation avec valeurs par défaut
this.id = user?.id || '';
this.username = user?.username || '';
this.email = user?.email || '';
this.firstName = user?.firstName || '';
this.lastName = user?.lastName || '';
this.enabled = user?.enabled ?? true;
this.emailVerified = user?.emailVerified ?? false;
this.attributes = user?.attributes || {};
this.clientRoles = user?.clientRoles || [];
this.createdTimestamp = user?.createdTimestamp || Date.now();
}
}
export class CreateUserDto {
username: string = '';
email: string = '';
firstName: string = '';
lastName: string = '';
password: string = '';
enabled: boolean = true;
emailVerified: boolean = false;
clientRoles: string[] = [];
constructor(partial?: Partial<CreateUserDto>) {
if (partial) {
Object.assign(this, partial);
}
}
}
export class UpdateUserDto {
username?: string;
email?: string;
firstName?: string;
lastName?: string;
enabled?: boolean;
emailVerified?: boolean;
attributes?: { [key: string]: any };
constructor(partial?: Partial<UpdateUserDto>) {
if (partial) {
Object.assign(this, partial);
}
}
}
export class ResetPasswordDto {
userId: string;
newPassword: string;
temporary?: boolean;
constructor(partial?: Partial<ResetPasswordDto>) {
// Initialisation des propriétés obligatoires
this.userId = '';
this.newPassword = '';
if (partial) {
Object.assign(this, partial);
}
// Valeur par défaut
this.temporary = this.temporary ?? false;
}
}
export class UserQueryDto {
page?: number;
limit?: number;
search?: string;
enabled?: boolean;
emailVerified?: boolean; // 🔥 Ajouter ce champ
email?: string;
username?: string;
firstName?: string;
lastName?: string;
constructor(partial?: Partial<UserQueryDto>) {
if (partial) {
Object.assign(this, partial);
}
}
}
export class PaginatedUserResponse {
data: UserResponse[];
total: number;
page: number;
limit: number;
totalPages: number;
constructor(data: UserResponse[], total: number, page: number, limit: number) {
this.data = data || [];
this.total = total || 0;
this.page = page || 1;
this.limit = limit || 10;
this.totalPages = Math.ceil(this.total / this.limit);
}
}
export class UserCredentials {
type: string;
value: string;
temporary: boolean;
constructor(type: string, value: string, temporary: boolean = false) {
this.type = type;
this.value = value;
this.temporary = temporary;
}
}
// Types (restent des interfaces/type alias)
export type ClientRole = 'admin' | 'merchant' | 'support' | 'user';
export interface ApiResponse<T> {
data: T;
message?: string;
status: string;
}
export interface UserRoleMapping {
id: string;
name: string;
description?: string;
composite?: boolean;
clientRole?: boolean;
containerId?: string;
}
export interface UserSession {
id: string;
username: string;
userId: string;
ipAddress: string;
start: number;
lastAccess: number;
clients: { [key: string]: string };
}

View File

@ -0,0 +1,355 @@
<div class="container-fluid">
<!-- En-tête avec navigation -->
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<div>
<h4 class="mb-1">
@if (user) {
{{ getUserDisplayName() }}
} @else {
Profil Utilisateur
}
</h4>
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item">
<a href="javascript:void(0)" class="text-decoration-none">Profile Utilisateurs</a>
</li>
</ol>
</nav>
</div>
<div class="d-flex gap-2">
@if (user && !isEditing) {
<button
class="btn btn-warning"
(click)="openResetPasswordModal.emit()"
>
<ng-icon name="lucideKey" class="me-1"></ng-icon>
Réinitialiser MDP
</button>
@if (user.enabled) {
<button
class="btn btn-warning"
(click)="disableUser()"
>
<ng-icon name="lucidePause" class="me-1"></ng-icon>
Désactiver
</button>
} @else {
<button
class="btn btn-success"
(click)="enableUser()"
>
<ng-icon name="lucidePlay" class="me-1"></ng-icon>
Activer
</button>
}
<button
class="btn btn-primary"
(click)="startEditing()"
>
<ng-icon name="lucideEdit" class="me-1"></ng-icon>
Modifier
</button>
}
</div>
</div>
</div>
</div>
<!-- Messages d'alerte -->
@if (error) {
<div class="alert alert-danger">
<ng-icon name="lucideAlertCircle" class="me-2"></ng-icon>
{{ error }}
</div>
}
@if (success) {
<div class="alert alert-success">
<ng-icon name="lucideCheckCircle" class="me-2"></ng-icon>
{{ success }}
</div>
}
<div class="row">
<!-- Loading State -->
@if (loading) {
<div class="col-12 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 du profil...</p>
</div>
}
<!-- User Profile -->
@if (user && !loading) {
<!-- Colonne de gauche - Informations de base -->
<div class="col-xl-4 col-lg-5">
<!-- Carte profil -->
<div class="card">
<div class="card-header bg-light">
<h5 class="card-title mb-0">Profil Keycloak</h5>
</div>
<div class="card-body text-center">
<!-- Avatar -->
<div class="avatar-lg mx-auto mb-3">
<div class="avatar-title bg-primary rounded-circle text-white fs-24">
{{ getUserInitials() }}
</div>
</div>
<h5>{{ getUserDisplayName() }}</h5>
<p class="text-muted mb-2">@{{ user.username }}</p>
<!-- Statut -->
<span [class]="getStatusBadgeClass()" class="mb-3">
{{ getStatusText() }}
</span>
<!-- Informations rapides -->
<div class="mt-4 text-start">
<div class="d-flex align-items-center mb-2">
<ng-icon name="lucideMail" class="me-2 text-muted"></ng-icon>
<small>{{ user.email }}</small>
</div>
<div class="d-flex align-items-center">
<ng-icon name="lucideCalendar" class="me-2 text-muted"></ng-icon>
<small>Créé le {{ formatTimestamp(user.createdTimestamp) }}</small>
</div>
</div>
</div>
</div>
<!-- Carte rôles client -->
<div class="card mt-3">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">Rôles Client</h5>
@if (!isEditing) {
<button
class="btn btn-outline-primary btn-sm"
(click)="updateUserRoles()"
[disabled]="updatingRoles"
>
@if (updatingRoles) {
<div class="spinner-border spinner-border-sm me-1" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
}
Mettre à jour
</button>
}
</div>
<div class="card-body">
<div class="row g-2">
@for (role of availableRoles; track role) {
<div class="col-6">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
[id]="'role-' + role"
[checked]="isRoleSelected(role)"
(change)="toggleRole(role)"
[disabled]="isEditing"
>
<label class="form-check-label" [for]="'role-' + role">
<span class="badge" [ngClass]="getRoleBadgeClass(role)">
{{ role }}
</span>
</label>
</div>
</div>
}
</div>
<!-- Rôles actuels -->
@if (user.clientRoles.length > 0) {
<div class="mt-3">
<h6>Rôles assignés :</h6>
@for (role of user.clientRoles; track role) {
<span class="badge me-1 mb-1" [ngClass]="getRoleBadgeClass(role)">
{{ role }}
</span>
}
</div>
}
</div>
</div>
</div>
<!-- Colonne de droite - Détails et édition -->
<div class="col-xl-8 col-lg-7">
<div class="card">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">
@if (isEditing) {
Modification du Profil
} @else {
Détails du Compte
}
</h5>
@if (isEditing) {
<div class="d-flex gap-2">
<button
type="button"
class="btn btn-secondary btn-sm"
(click)="cancelEditing()"
[disabled]="saving"
>
Annuler
</button>
<button
type="button"
class="btn btn-success btn-sm"
(click)="saveProfile()"
[disabled]="saving"
>
@if (saving) {
<div class="spinner-border spinner-border-sm me-1" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
}
Enregistrer
</button>
</div>
}
</div>
<div class="card-body">
<div class="row g-3">
<!-- Prénom -->
<div class="col-md-6">
<label class="form-label">Prénom</label>
@if (isEditing) {
<input
type="text"
class="form-control"
[(ngModel)]="editedUser.firstName"
placeholder="Entrez le prénom"
>
} @else {
<div class="form-control-plaintext">
{{ user.firstName || 'Non défini' }}
</div>
}
</div>
<!-- Nom -->
<div class="col-md-6">
<label class="form-label">Nom</label>
@if (isEditing) {
<input
type="text"
class="form-control"
[(ngModel)]="editedUser.lastName"
placeholder="Entrez le nom"
>
} @else {
<div class="form-control-plaintext">
{{ user.lastName || 'Non défini' }}
</div>
}
</div>
<!-- Nom d'utilisateur -->
<div class="col-md-6">
<label class="form-label">Nom d'utilisateur</label>
@if (isEditing) {
<input
type="text"
class="form-control"
[(ngModel)]="editedUser.username"
placeholder="Nom d'utilisateur"
>
} @else {
<div class="form-control-plaintext">
{{ user.username }}
</div>
}
</div>
<!-- Email -->
<div class="col-md-6">
<label class="form-label">Email</label>
@if (isEditing) {
<input
type="email"
class="form-control"
[(ngModel)]="editedUser.email"
placeholder="email@exemple.com"
>
} @else {
<div class="form-control-plaintext">
{{ user.email }}
</div>
}
</div>
<!-- Statut activé -->
@if (isEditing) {
<div class="col-md-6">
<div class="form-check form-switch">
<input
class="form-check-input"
type="checkbox"
id="enabledSwitch"
[(ngModel)]="editedUser.enabled"
>
<label class="form-check-label" for="enabledSwitch">
Compte activé
</label>
</div>
</div>
<!-- Email vérifié -->
<div class="col-md-6">
<div class="form-check form-switch">
<input
class="form-check-input"
type="checkbox"
id="emailVerifiedSwitch"
[(ngModel)]="editedUser.emailVerified"
>
<label class="form-check-label" for="emailVerifiedSwitch">
Email vérifié
</label>
</div>
</div>
}
<!-- Informations système -->
@if (!isEditing) {
<div class="col-12">
<hr>
<h6>Informations Système</h6>
<div class="row">
<div class="col-md-6">
<label class="form-label">ID Utilisateur</label>
<div class="form-control-plaintext font-monospace small">
{{ user.id }}
</div>
</div>
<div class="col-md-6">
<label class="form-label">Date de création</label>
<div class="form-control-plaintext">
{{ formatTimestamp(user.createdTimestamp) }}
</div>
</div>
</div>
</div>
}
</div>
</div>
</div>
</div>
}
</div>
</div>

View File

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

View File

@ -0,0 +1,240 @@
import { Component, inject, OnInit, Input, Output, EventEmitter, ChangeDetectorRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { NgIcon } from '@ng-icons/core';
import { NgbAlertModule } from '@ng-bootstrap/ng-bootstrap';
import { UsersService } from '../services/users.service';
import { UserResponse, UpdateUserDto, ClientRole } from '../models/user';
@Component({
selector: 'app-user-profile',
standalone: true,
imports: [CommonModule, FormsModule, NgIcon, NgbAlertModule],
templateUrl: './profile.html',
styles: [`
.avatar-lg {
width: 80px;
height: 80px;
}
.fs-24 {
font-size: 24px;
}
`]
})
export class UserProfile implements OnInit {
private usersService = inject(UsersService);
private cdRef = inject(ChangeDetectorRef);
@Input() userId!: string;
@Output() openResetPasswordModal = new EventEmitter<void>();
user: UserResponse | null = null;
loading = false;
saving = false;
error = '';
success = '';
// Édition
isEditing = false;
editedUser: UpdateUserDto = {};
// Gestion des rôles
availableRoles: ClientRole[] = ['admin', 'merchant', 'support', 'user'];
selectedRoles: ClientRole[] = [];
updatingRoles = false;
ngOnInit() {
if (this.userId) {
this.loadUserProfile();
}
}
loadUserProfile() {
this.loading = true;
this.error = '';
this.usersService.getUserById(this.userId).subscribe({
next: (user) => {
this.user = user;
this.selectedRoles = user.clientRoles
.filter((role): role is ClientRole =>
this.availableRoles.includes(role as ClientRole)
);
this.loading = false;
this.cdRef.detectChanges();
},
error: (error) => {
this.error = 'Erreur lors du chargement du profil utilisateur';
this.loading = false;
this.cdRef.detectChanges();
console.error('Error loading user profile:', error);
}
});
}
startEditing() {
this.isEditing = true;
this.editedUser = {
firstName: this.user?.firstName,
lastName: this.user?.lastName,
email: this.user?.email,
username: this.user?.username,
enabled: this.user?.enabled,
emailVerified: this.user?.emailVerified
};
this.cdRef.detectChanges();
}
cancelEditing() {
this.isEditing = false;
this.editedUser = {};
this.error = '';
this.success = '';
this.cdRef.detectChanges();
}
saveProfile() {
if (!this.user) return;
this.saving = true;
this.error = '';
this.success = '';
this.usersService.updateUser(this.user.id, this.editedUser).subscribe({
next: (updatedUser) => {
this.user = updatedUser;
this.isEditing = false;
this.saving = false;
this.success = 'Profil mis à jour avec succès';
this.editedUser = {};
this.cdRef.detectChanges();
},
error: (error) => {
this.error = 'Erreur lors de la mise à jour du profil';
this.saving = false;
this.cdRef.detectChanges();
console.error('Error updating user:', error);
}
});
}
// Gestion des rôles
toggleRole(role: ClientRole) {
const index = this.selectedRoles.indexOf(role);
if (index > -1) {
this.selectedRoles.splice(index, 1);
} else {
this.selectedRoles.push(role);
}
this.cdRef.detectChanges();
}
isRoleSelected(role: ClientRole): boolean {
return this.selectedRoles.includes(role);
}
updateUserRoles() {
if (!this.user) return;
this.updatingRoles = true;
this.usersService.assignClientRoles(this.user.id, this.selectedRoles).subscribe({
next: () => {
this.updatingRoles = false;
this.success = 'Rôles mis à jour avec succès';
if (this.user) {
this.user.clientRoles = [...this.selectedRoles];
}
this.cdRef.detectChanges();
},
error: (error) => {
this.updatingRoles = false;
this.error = 'Erreur lors de la mise à jour des rôles';
this.cdRef.detectChanges();
console.error('Error updating roles:', error);
}
});
}
// Gestion du statut
enableUser() {
if (!this.user) return;
this.usersService.enableUser(this.user.id).subscribe({
next: () => {
this.user!.enabled = true;
this.success = 'Utilisateur activé avec succès';
this.cdRef.detectChanges();
},
error: (error) => {
this.error = 'Erreur lors de l\'activation de l\'utilisateur';
this.cdRef.detectChanges();
console.error('Error enabling user:', error);
}
});
}
disableUser() {
if (!this.user) return;
this.usersService.disableUser(this.user.id).subscribe({
next: () => {
this.user!.enabled = false;
this.success = 'Utilisateur désactivé avec succès';
this.cdRef.detectChanges();
},
error: (error) => {
this.error = 'Erreur lors de la désactivation de l\'utilisateur';
this.cdRef.detectChanges();
console.error('Error disabling user:', error);
}
});
}
// Utilitaires d'affichage
getStatusBadgeClass(): string {
if (!this.user) return 'badge bg-secondary';
if (!this.user.enabled) return 'badge bg-danger';
if (!this.user.emailVerified) return 'badge bg-warning';
return 'badge bg-success';
}
getStatusText(): string {
if (!this.user) return 'Inconnu';
if (!this.user.enabled) return 'Désactivé';
if (!this.user.emailVerified) return 'Email non vérifié';
return 'Actif';
}
formatTimestamp(timestamp: number): string {
return new Date(timestamp).toLocaleDateString('fr-FR', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
getUserInitials(): string {
if (!this.user) return 'U';
return (this.user.firstName?.charAt(0) || '') + (this.user.lastName?.charAt(0) || '') || 'U';
}
getUserDisplayName(): string {
if (!this.user) return 'Utilisateur';
if (this.user.firstName && this.user.lastName) {
return `${this.user.firstName} ${this.user.lastName}`;
}
return this.user.username;
}
getRoleBadgeClass(role: string): string {
switch (role) {
case 'admin': return 'bg-danger';
case 'merchant': return 'bg-success';
case 'support': return 'bg-info';
case 'user': return 'bg-secondary';
default: return 'bg-secondary';
}
}
}

View File

@ -1 +0,0 @@
<p>Users - Roles</p>

View File

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

View File

@ -1,46 +0,0 @@
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { UiCard } from '@app/components/ui-card';
import { CheckboxesAndRadios } from '@/app/modules/components/checkboxes-and-radios';
import { InputFields } from '@/app/modules/components/input-fields';
import { WizardWithProgress } from '@/app/modules/components/wizard-with-progress';
@Component({
selector: 'app-roles',
imports: [FormsModule, UiCard, CheckboxesAndRadios, InputFields, WizardWithProgress],
templateUrl: './roles.html',
})
export class UsersRoles {
role = {
name: '',
description: '',
permissions: {
transactions: ['read', 'export'],
merchants: ['read'],
operators: ['read'],
users: ['read'],
settings: []
} as { [key: string]: string[] }
};
availablePermissions = {
transactions: ['read', 'create', 'update', 'delete', 'export'],
merchants: ['read', 'create', 'update', 'delete', 'config'],
operators: ['read', 'update', 'config'],
users: ['read', 'create', 'update', 'delete', 'roles'],
settings: ['read', 'update']
};
togglePermission(module: string, permission: string) {
const index = this.role.permissions[module].indexOf(permission);
if (index > -1) {
this.role.permissions[module].splice(index, 1);
} else {
this.role.permissions[module].push(permission);
}
}
createRole() {
console.log('Creating role:', this.role);
}
}

View File

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

View File

@ -1,42 +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 Role {
id: string;
name: string;
permissions: string[];
description: string;
userCount: number;
}
export interface Permission {
module: string;
actions: string[];
}
@Injectable({ providedIn: 'root' })
export class UserRolesService {
private http = inject(HttpClient);
getRoles(): Observable<Role[]> {
return this.http.get<Role[]>(`${environment.apiUrl}/users/roles`);
}
createRole(role: Role): Observable<Role> {
return this.http.post<Role>(`${environment.apiUrl}/users/roles`, role);
}
updateRole(id: string, role: Role): Observable<Role> {
return this.http.put<Role>(`${environment.apiUrl}/users/roles/${id}`, role);
}
deleteRole(id: string): Observable<void> {
return this.http.delete<void>(`${environment.apiUrl}/users/roles/${id}`);
}
getAvailablePermissions(): Observable<Permission[]> {
return this.http.get<Permission[]>(`${environment.apiUrl}/users/permissions`);
}
}

View File

@ -1,50 +1,168 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { environment } from '@environments/environment';
import { Observable } from 'rxjs';
import { Observable, map, catchError, throwError } from 'rxjs';
import { of } from 'rxjs';
export interface User {
id: string;
username: string;
email: string;
firstName: string;
lastName: string;
role: string;
status: 'ACTIVE' | 'INACTIVE' | 'SUSPENDED';
lastLogin?: Date;
createdAt: Date;
}
import {
UserResponse,
CreateUserDto,
UpdateUserDto,
ResetPasswordDto,
PaginatedUserResponse,
ClientRole
} from '../models/user';
@Injectable({ providedIn: 'root' })
export class UserService {
export class UsersService {
private http = inject(HttpClient);
private fb = inject(FormBuilder);
private apiUrl = `${environment.apiUrl}/users`;
createUserForm(): FormGroup {
return this.fb.group({
username: ['', [Validators.required, Validators.minLength(3)]],
email: ['', [Validators.required, Validators.email]],
firstName: ['', Validators.required],
lastName: ['', Validators.required],
role: ['USER', Validators.required],
status: ['ACTIVE']
});
// === CRUD COMPLET ===
createUser(createUserDto: CreateUserDto): Observable<UserResponse> {
// Validation
if (!createUserDto.username || createUserDto.username.trim() === '') {
return throwError(() => 'Username is required and cannot be empty');
}
getUsers(): Observable<User[]> {
return this.http.get<User[]>(`${environment.apiUrl}/users`);
if (!createUserDto.email || createUserDto.email.trim() === '') {
return throwError(() => 'Email is required and cannot be empty');
}
createUser(user: User): Observable<User> {
return this.http.post<User>(`${environment.apiUrl}/users`, user);
if (!createUserDto.password || createUserDto.password.length < 8) {
return throwError(() => 'Password must be at least 8 characters');
}
updateUser(id: string, user: User): Observable<User> {
return this.http.put<User>(`${environment.apiUrl}/users/${id}`, user);
// Nettoyage des données
const payload = {
username: createUserDto.username.trim(),
email: createUserDto.email.trim(),
firstName: (createUserDto.firstName || '').trim(),
lastName: (createUserDto.lastName || '').trim(),
password: createUserDto.password,
enabled: createUserDto.enabled !== undefined ? createUserDto.enabled : true,
emailVerified: createUserDto.emailVerified !== undefined ? createUserDto.emailVerified : false,
clientRoles: createUserDto.clientRoles || []
};
return this.http.post<UserResponse>(`${this.apiUrl}`, payload).pipe(
catchError(error => throwError(() => error))
);
}
deleteUser(id: string): Observable<void> {
return this.http.delete<void>(`${environment.apiUrl}/users/${id}`);
// READ - Obtenir tous les utilisateurs
findAllUsers(): Observable<PaginatedUserResponse> {
return this.http.get<{
users: any[];
total: number;
page: number;
limit: number;
totalPages: number;
}>(`${this.apiUrl}`).pipe(
map(response => {
const users = response.users.map(user => new UserResponse(user));
return new PaginatedUserResponse(users, response.total, response.page, response.limit);
}),
catchError(error => {
console.error('Error loading users:', error);
return of(new PaginatedUserResponse([], 0, 1, 10));
})
);
}
// READ - Obtenir un utilisateur par ID
getUserById(id: string): Observable<UserResponse> {
return this.http.get<any>(`${this.apiUrl}/${id}`).pipe(
map(response => new UserResponse(response))
);
}
// READ - Obtenir le profil de l'utilisateur connecté
getCurrentUserProfile(): Observable<UserResponse> {
return this.http.get<any>(`${this.apiUrl}/profile/me`).pipe(
map(response => new UserResponse(response))
);
}
// UPDATE - Mettre à jour un utilisateur
updateUser(id: string, updateUserDto: UpdateUserDto): Observable<UserResponse> {
return this.http.put<any>(`${this.apiUrl}/${id}`, updateUserDto).pipe(
map(response => new UserResponse(response))
);
}
// UPDATE - Mettre à jour le profil de l'utilisateur connecté
updateCurrentUserProfile(updateUserDto: UpdateUserDto): Observable<UserResponse> {
return this.http.put<any>(`${this.apiUrl}/profile/me`, updateUserDto).pipe(
map(response => new UserResponse(response))
);
}
// DELETE - Supprimer un utilisateur
deleteUser(id: string): Observable<{ message: string }> {
return this.http.delete<{ message: string }>(`${this.apiUrl}/${id}`);
}
// === GESTION DES MOTS DE PASSE ===
resetPassword(resetPasswordDto: ResetPasswordDto): Observable<{ message: string }> {
return this.http.put<{ message: string }>(
`${this.apiUrl}/${resetPasswordDto.userId}/password`,
resetPasswordDto
);
}
// === GESTION DU STATUT ===
enableUser(id: string): Observable<{ message: string }> {
return this.http.put<{ message: string }>(`${this.apiUrl}/${id}/enable`, {});
}
disableUser(id: string): Observable<{ message: string }> {
return this.http.put<{ message: string }>(`${this.apiUrl}/${id}/disable`, {});
}
// === RECHERCHE ET VÉRIFICATION ===
userExists(username: string): Observable<{ exists: boolean }> {
return this.http.get<{ exists: boolean }>(`${this.apiUrl}/check/${username}`);
}
findUserByUsername(username: string): Observable<UserResponse[]> {
return this.http.get<any[]>(`${this.apiUrl}/search/username/${username}`).pipe(
map(users => users.map(user => new UserResponse(user)))
);
}
findUserByEmail(email: string): Observable<UserResponse[]> {
return this.http.get<any[]>(`${this.apiUrl}/search/email/${email}`).pipe(
map(users => users.map(user => new UserResponse(user)))
);
}
// === GESTION DES RÔLES ===
getUserClientRoles(id: string): Observable<{ roles: string[] }> {
return this.http.get<{ roles: string[] }>(`${this.apiUrl}/${id}/roles`);
}
assignClientRoles(userId: string, roles: ClientRole[]): Observable<{ message: string }> {
return this.http.put<{ message: string }>(`${this.apiUrl}/${userId}/roles`, { roles });
}
// === SESSIONS ET TOKENS ===
getUserSessions(userId: string): Observable<any[]> {
return this.http.get<any[]>(`${this.apiUrl}/${userId}/sessions`);
}
logoutUser(userId: string): Observable<{ message: string }> {
return this.http.post<{ message: string }>(`${this.apiUrl}/${userId}/logout`, {});
}
// === STATISTIQUES ===
getUserStats(): Observable<{
total: number;
enabled: number;
disabled: number;
emailVerified: number;
emailNotVerified: number;
}> {
return this.http.get<any>(`${this.apiUrl}/stats`);
}
}

View File

@ -0,0 +1,15 @@
modules/users/
├── components/ # Composants réutilisables
│ ├── users-list/
│ │ ├── users-list.ts # Logique du tableau utilisateurs
│ │ └── users-list.html
│ ├── users-profile/
│ │ ├── users-profile.ts # Logique création / modification
│ │ └── users-profile.html
├── services/
│ └── users.service.ts # Service API centralisé (NestJS)
├── users.module.ts # Module principal
├── users.routes.ts # Gestion des routes
└── users.html # Template global du module

View File

@ -1 +1,472 @@
<p>Users</p>
<div class="container-fluid">
<app-page-title
title="Gestion des Utilisateurs"
subTitle="Administrez les utilisateurs Keycloak de votre plateforme"
[badge]="{icon:'lucideUsers', text:'Keycloak Users'}"
/>
<!-- Navigation par onglets avec style bordered -->
<div class="row mb-4">
<div class="col-12">
<ul
ngbNav
#usersNav="ngbNav"
[activeId]="activeTab"
[destroyOnHide]="false"
class="nav nav-tabs nav-justified nav-bordered nav-bordered-primary mb-3"
>
<li [ngbNavItem]="'list'">
<a ngbNavLink (click)="showTab('list')">
<ng-icon name="lucideUsers" class="fs-lg me-md-1 d-inline-flex align-middle" />
<span class="d-none d-md-inline-block align-middle">Liste des Utilisateurs</span>
</a>
<ng-template ngbNavContent>
<app-users-list
(userSelected)="showTab('profile', $event)"
(openCreateModal)="openCreateUserModal()"
(openResetPasswordModal)="openResetPasswordModal($event)"
(openDeleteUserModal)="openDeleteUserModal($event)"
/>
</ng-template>
</li>
<li [ngbNavItem]="'profile'" [hidden]="activeTab !== 'profile'">
<a ngbNavLink (click)="showTab('profile')">
<ng-icon name="lucideUser" class="fs-lg me-md-1 d-inline-flex align-middle" />
<span class="d-none d-md-inline-block align-middle">Profil Utilisateur</span>
</a>
<ng-template ngbNavContent>
@if (selectedUserId) {
<app-user-profile
[userId]="selectedUserId"
(back)="showTab('list')"
/>
}
</ng-template>
</li>
</ul>
<div class="tab-content" [ngbNavOutlet]="usersNav"></div>
</div>
</div>
</div>
<!-- Modal de création d'utilisateur Keycloak -->
<ng-template #createUserModal let-modal>
<div class="modal-header">
<h4 class="modal-title">
<ng-icon name="lucideUserPlus" class="me-2"></ng-icon>
Créer un nouvel utilisateur Keycloak
</h4>
<button
type="button"
class="btn-close"
(click)="modal.dismiss()"
[disabled]="creatingUser"
></button>
</div>
<div class="modal-body">
<!-- Message d'erreur -->
@if (createUserError) {
<div class="alert alert-danger d-flex align-items-center">
<ng-icon name="lucideAlertCircle" class="me-2"></ng-icon>
<div>{{ createUserError }}</div>
</div>
}
<form (ngSubmit)="createUser()" #userForm="ngForm">
<div class="row g-3">
<!-- Informations de base -->
<div class="col-md-6">
<label class="form-label">
Prénom <span class="text-danger">*</span>
</label>
<input
type="text"
class="form-control"
placeholder="Entrez le prénom"
[(ngModel)]="newUser.firstName"
name="firstName"
required
[disabled]="creatingUser"
>
</div>
<div class="col-md-6">
<label class="form-label">
Nom <span class="text-danger">*</span>
</label>
<input
type="text"
class="form-control"
placeholder="Entrez le nom"
[(ngModel)]="newUser.lastName"
name="lastName"
required
[disabled]="creatingUser"
>
</div>
<div class="col-md-6">
<label class="form-label">
Nom d'utilisateur <span class="text-danger">*</span>
</label>
<input
type="text"
class="form-control"
placeholder="Nom d'utilisateur unique"
[(ngModel)]="newUser.username"
name="username"
required
[disabled]="creatingUser"
>
<div class="form-text">Doit être unique dans Keycloak</div>
</div>
<div class="col-md-6">
<label class="form-label">
Email <span class="text-danger">*</span>
</label>
<input
type="email"
class="form-control"
placeholder="email@exemple.com"
[(ngModel)]="newUser.email"
name="email"
required
[disabled]="creatingUser"
>
</div>
<div class="col-12">
<label class="form-label">
Mot de passe <span class="text-danger">*</span>
</label>
<input
type="password"
class="form-control"
placeholder="Mot de passe sécurisé"
[(ngModel)]="newUser.password"
name="password"
required
minlength="8"
[disabled]="creatingUser"
>
<div class="form-text">
Le mot de passe doit contenir au moins 8 caractères.
</div>
</div>
<!-- Configuration du compte -->
<div class="col-md-6">
<div class="form-check form-switch">
<input
class="form-check-input"
type="checkbox"
id="enabledSwitch"
[(ngModel)]="newUser.enabled"
name="enabled"
[disabled]="creatingUser"
>
<label class="form-check-label" for="enabledSwitch">
Compte activé
</label>
</div>
<div class="form-text">L'utilisateur peut se connecter immédiatement</div>
</div>
<div class="col-md-6">
<div class="form-check form-switch">
<input
class="form-check-input"
type="checkbox"
id="emailVerifiedSwitch"
[(ngModel)]="newUser.emailVerified"
name="emailVerified"
[disabled]="creatingUser"
>
<label class="form-check-label" for="emailVerifiedSwitch">
Email vérifié
</label>
</div>
<div class="form-text">L'utilisateur n'aura pas à vérifier son email</div>
</div>
<!-- Rôles client -->
<div class="col-12">
<label class="form-label">Rôles Client</label>
<div class="row g-2">
@for (role of availableRoles; track role) {
<div class="col-md-6">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
[id]="'role-' + role"
[checked]="isRoleSelected(role)"
(change)="toggleRole(role)"
[disabled]="creatingUser"
>
<label class="form-check-label" [for]="'role-' + role">
<span class="badge"
[class]="{
'bg-primary': role === 'admin',
'bg-success': role === 'merchant',
'bg-info': role === 'support'
}"
>
{{ role }}
</span>
</label>
</div>
</div>
}
</div>
<div class="form-text">Sélectionnez les rôles à assigner à cet utilisateur</div>
</div>
<!-- Aperçu des rôles sélectionnés -->
@if (selectedRoles.length > 0) {
<div class="col-12">
<div class="alert alert-info">
<strong>Rôles sélectionnés :</strong>
@for (role of selectedRoles; track role) {
<span class="badge bg-primary ms-1">{{ role }}</span>
}
</div>
</div>
}
</div>
<div class="modal-footer mt-4">
<button
type="button"
class="btn btn-light"
(click)="modal.dismiss()"
[disabled]="creatingUser"
>
Annuler
</button>
<button
type="submit"
class="btn btn-primary"
[disabled]="!userForm.form.valid || creatingUser"
>
@if (creatingUser) {
<div class="spinner-border spinner-border-sm me-2" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
Création...
} @else {
<ng-icon name="lucideUserPlus" class="me-1"></ng-icon>
Créer l'utilisateur
}
</button>
</div>
</form>
</div>
</ng-template>
<!-- Modal de réinitialisation de mot de passe -->
<ng-template #resetPasswordModal let-modal>
<div class="modal-header">
<h4 class="modal-title">
<ng-icon name="lucideKey" class="me-2"></ng-icon>
Réinitialiser le mot de passe
</h4>
<button
type="button"
class="btn-close"
(click)="modal.dismiss()"
[disabled]="resettingPassword"
></button>
</div>
<div class="modal-body">
<!-- Message de succès -->
@if (resetPasswordSuccess) {
<div class="alert alert-success d-flex align-items-center">
<ng-icon name="lucideCheckCircle" class="me-2"></ng-icon>
<div>{{ resetPasswordSuccess }}</div>
</div>
}
<!-- Message d'erreur -->
@if (resetPasswordError) {
<div class="alert alert-danger d-flex align-items-center">
<ng-icon name="lucideAlertCircle" class="me-2"></ng-icon>
<div>{{ resetPasswordError }}</div>
</div>
}
@if (!resetPasswordSuccess && selectedUserForReset) {
<div class="alert alert-info">
<strong>Utilisateur :</strong> {{ selectedUserForReset.username }}
@if (selectedUserForReset.firstName || selectedUserForReset.lastName) {
<br>
<strong>Nom :</strong> {{ selectedUserForReset.firstName }} {{ selectedUserForReset.lastName }}
}
</div>
<form (ngSubmit)="confirmResetPassword()" #resetForm="ngForm">
<div class="mb-3">
<label class="form-label">
Nouveau mot de passe <span class="text-danger">*</span>
</label>
<input
type="password"
class="form-control"
placeholder="Entrez le nouveau mot de passe"
[(ngModel)]="newPassword"
name="newPassword"
required
minlength="8"
[disabled]="resettingPassword"
>
<div class="form-text">
Le mot de passe doit contenir au moins 8 caractères.
</div>
</div>
<div class="mb-3">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="temporaryPassword"
[(ngModel)]="temporaryPassword"
name="temporaryPassword"
[disabled]="resettingPassword"
>
<label class="form-check-label" for="temporaryPassword">
Mot de passe temporaire
</label>
</div>
<div class="form-text">
L'utilisateur devra changer son mot de passe à la prochaine connexion.
</div>
</div>
</form>
}
</div>
<div class="modal-footer">
@if (resetPasswordSuccess) {
<button
type="button"
class="btn btn-success"
(click)="modal.close()"
>
<ng-icon name="lucideCheck" class="me-1"></ng-icon>
Fermer
</button>
} @else {
<button
type="button"
class="btn btn-light"
(click)="modal.dismiss()"
[disabled]="resettingPassword"
>
Annuler
</button>
<button
type="button"
class="btn btn-primary"
(click)="confirmResetPassword()"
[disabled]="!newPassword || newPassword.length < 8 || resettingPassword"
>
@if (resettingPassword) {
<div class="spinner-border spinner-border-sm me-2" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
Réinitialisation...
} @else {
<ng-icon name="lucideKey" class="me-1"></ng-icon>
Réinitialiser le mot de passe
}
</button>
}
</div>
</ng-template>
<!-- Modal de confirmation de suppression -->
<ng-template #deleteUserModal let-modal>
<div class="modal-header">
<h4 class="modal-title text-danger">
<ng-icon name="lucideTrash2" class="me-2"></ng-icon>
Confirmer la suppression
</h4>
<button
type="button"
class="btn-close"
(click)="modal.dismiss()"
></button>
</div>
<div class="modal-body text-center">
<div class="mb-4">
<div class="avatar-lg mx-auto mb-3 bg-danger bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center">
<ng-icon name="lucideUserX" class="text-danger" style="font-size: 2rem;"></ng-icon>
</div>
<h5 class="text-danger mb-2">Êtes-vous sûr de vouloir supprimer cet utilisateur ?</h5>
<p class="text-muted mb-0">
Cette action est irréversible. Toutes les données de
<strong>{{ selectedUserForDelete?.username }}</strong> seront définitivement perdues.
</p>
</div>
@if (selectedUserForDelete) {
<div class="alert alert-warning">
<div class="d-flex align-items-center">
<ng-icon name="lucideAlertTriangle" class="me-2"></ng-icon>
<div>
<strong>Utilisateur :</strong> {{ selectedUserForDelete.username }}
@if (selectedUserForDelete.firstName || selectedUserForDelete.lastName) {
<br>
<strong>Nom :</strong> {{ selectedUserForDelete.firstName }} {{ selectedUserForDelete.lastName }}
}
<br>
<strong>Email :</strong> {{ selectedUserForDelete.email }}
</div>
</div>
</div>
}
<!-- Message d'erreur -->
@if (deleteUserError) {
<div class="alert alert-danger d-flex align-items-center">
<ng-icon name="lucideAlertCircle" class="me-2"></ng-icon>
<div>{{ deleteUserError }}</div>
</div>
}
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-light"
(click)="modal.dismiss()"
[disabled]="deletingUser"
>
<ng-icon name="lucideX" class="me-1"></ng-icon>
Annuler
</button>
<button
type="button"
class="btn btn-danger"
(click)="confirmDeleteUser()"
[disabled]="deletingUser"
>
@if (deletingUser) {
<div class="spinner-border spinner-border-sm me-2" role="status">
<span class="visually-hidden">Suppression...</span>
</div>
Suppression...
} @else {
<ng-icon name="lucideTrash2" class="me-1"></ng-icon>
Supprimer définitivement
}
</button>
</div>
</ng-template>

View File

@ -0,0 +1,16 @@
import { Routes } from '@angular/router';
import { Users } from './users';
import { authGuard } from '../../core/guards/auth.guard';
import { roleGuard } from '../../core/guards/role.guard';
export const USERS_ROUTES: Routes = [
{
path: 'users',
canActivate: [authGuard, roleGuard],
component: Users,
data: {
title: 'Gestion des Utilisateurs',
requiredRoles: ['admin'] // pour information
}
}
];

View File

@ -1,7 +1,340 @@
import { Component } from '@angular/core';
import { Component, inject, OnInit, TemplateRef, ViewChild, ChangeDetectorRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { NgIcon } from '@ng-icons/core';
import { NgbNavModule, NgbModal, NgbModalModule } from '@ng-bootstrap/ng-bootstrap';
import { PageTitle } from '@app/components/page-title/page-title';
import { UsersList } from './list/list';
import { UserProfile } from './profile/profile';
import { UsersService } from './services/users.service';
import { CreateUserDto, ClientRole, UserResponse } from './models/user';
@Component({
selector: 'app-users',
standalone: true,
imports: [
CommonModule,
FormsModule,
NgIcon,
NgbNavModule,
NgbModalModule,
PageTitle,
UsersList,
UserProfile
],
templateUrl: './users.html',
})
export class Users {}
export class Users implements OnInit {
private modalService = inject(NgbModal);
private usersService = inject(UsersService);
private cdRef = inject(ChangeDetectorRef);
activeTab: 'list' | 'profile' = 'list';
selectedUserId: string | null = null;
// Données pour la création d'utilisateur
newUser: CreateUserDto = {
username: '',
email: '',
firstName: '',
lastName: '',
password: '',
enabled: true,
emailVerified: false,
clientRoles: ['user']
};
availableRoles: ClientRole[] = ['admin', 'merchant', 'support', 'user'];
selectedRoles: ClientRole[] = ['user'];
creatingUser = false;
createUserError = '';
// Données pour la réinitialisation de mot de passe
selectedUserForReset: UserResponse | null = null;
newPassword = '';
temporaryPassword = false;
resettingPassword = false;
resetPasswordError = '';
resetPasswordSuccess = '';
selectedUserForDelete: UserResponse | null = null;
deletingUser = false;
deleteUserError = '';
ngOnInit() {
this.activeTab = 'list';
this.synchronizeRoles();
}
private synchronizeRoles(): void {
// S'assurer que les rôles sont synchronisés
this.selectedRoles = [...this.newUser.clientRoles as ClientRole[]];
}
showTab(tab: 'list' | 'profile', userId?: string) {
this.activeTab = tab;
if (userId) {
this.selectedUserId = userId;
}
}
backToList() {
this.activeTab = 'list';
this.selectedUserId = null;
}
// Méthodes pour les modals
openModal(content: TemplateRef<any>, size: 'sm' | 'lg' | 'xl' = 'lg') {
this.modalService.open(content, {
size: size,
centered: true,
scrollable: true
});
}
// Méthode pour ouvrir le modal de création d'utilisateur
openCreateUserModal() {
this.newUser = {
username: '',
email: '',
firstName: '',
lastName: '',
password: '',
enabled: true,
emailVerified: false,
clientRoles: ['user']
};
this.selectedRoles = ['user'];
this.createUserError = '';
this.openModal(this.createUserModal);
}
// Méthode pour ouvrir le modal de réinitialisation de mot de passe
openResetPasswordModal(userId: string) {
// Charger les données de l'utilisateur
this.usersService.getUserById(userId).subscribe({
next: (user) => {
this.selectedUserForReset = user;
this.newPassword = '';
this.temporaryPassword = false;
this.resetPasswordError = '';
this.openModal(this.resetPasswordModal);
},
error: (error) => {
console.error('Error loading user for password reset:', error);
}
});
}
// Gestion des rôles sélectionnés
toggleRole(role: ClientRole) {
const index = this.selectedRoles.indexOf(role);
if (index > -1) {
this.selectedRoles.splice(index, 1);
} else {
this.selectedRoles.push(role);
}
// Mettre à jour les deux propriétés
this.newUser.clientRoles = [...this.selectedRoles];
}
isRoleSelected(role: ClientRole): boolean {
return this.selectedRoles.includes(role);
}
createUser() {
const validation = this.validateUserForm();
if (!validation.isValid) {
this.createUserError = validation.error!;
return;
}
this.creatingUser = true;
this.createUserError = '';
const payload = {
username: this.newUser.username.trim(),
email: this.newUser.email.trim(),
firstName: this.newUser.firstName.trim(),
lastName: this.newUser.lastName.trim(),
password: this.newUser.password,
enabled: this.newUser.enabled,
emailVerified: this.newUser.emailVerified,
clientRoles: this.selectedRoles
};
this.usersService.createUser(payload).subscribe({
next: (createdUser) => {
this.creatingUser = false;
this.modalService.dismissAll();
if(this.usersListComponent){
this.usersListComponent.loadUsers();
this.usersListComponent.onClearFilters();
}
this.showTab('list');
},
error: (error) => {
this.creatingUser = false;
this.createUserError = this.getErrorMessage(error);
}
});
}
// Réinitialiser le mot de passe
confirmResetPassword() {
if (!this.selectedUserForReset || !this.newPassword || this.newPassword.length < 8) {
this.resetPasswordError = 'Veuillez saisir un mot de passe valide (au moins 8 caractères).';
return;
}
this.resettingPassword = true;
this.resetPasswordError = '';
this.resetPasswordSuccess = '';
const resetDto = {
userId: this.selectedUserForReset.id,
newPassword: this.newPassword,
temporary: this.temporaryPassword
};
this.usersService.resetPassword(resetDto).subscribe({
next: () => {
this.resettingPassword = false;
this.resetPasswordSuccess = 'Mot de passe réinitialisé avec succès !';
this.cdRef.detectChanges(); // Forcer la détection des changements
},
error: (error) => {
this.resettingPassword = false;
this.resetPasswordError = this.getResetPasswordErrorMessage(error);
this.cdRef.detectChanges(); // Forcer la détection des changements
}
});
}
// Gestion des erreurs améliorée
private getErrorMessage(error: any): string {
if (error.error?.message) {
return error.error.message;
}
if (error.status === 400) {
return 'Données invalides. Vérifiez les champs du formulaire.';
}
if (error.status === 409) {
return 'Un utilisateur avec ce nom ou email existe déjà.';
}
return 'Erreur lors de la création de l\'utilisateur. Veuillez réessayer.';
}
private getResetPasswordErrorMessage(error: any): string {
if (error.error?.message) {
return error.error.message;
}
if (error.status === 404) {
return 'Utilisateur non trouvé.';
}
if (error.status === 400) {
return 'Le mot de passe ne respecte pas les critères de sécurité.';
}
return 'Erreur lors de la réinitialisation du mot de passe. Veuillez réessayer.';
}
// Méthode pour ouvrir le modal de suppression
openDeleteUserModal(userId: string) {
this.usersService.getUserById(userId).subscribe({
next: (user) => {
this.selectedUserForDelete = user;
this.deleteUserError = '';
this.openModal(this.deleteUserModal);
},
error: (error) => {
console.error('Error loading user for password reset:', error);
}
});
}
@ViewChild(UsersList) usersListComponent!: UsersList;
private refreshUsersList(): void {
if (this.usersListComponent && typeof this.usersListComponent.loadUsers === 'function') {
this.usersListComponent.loadUsers();
} else {
console.warn('UsersList component not available for refresh');
// Alternative: reload the current tab
this.showTab('list');
}
}
confirmDeleteUser() {
if (!this.selectedUserForDelete) return;
this.deletingUser = true;
this.deleteUserError = '';
this.usersService.deleteUser(this.selectedUserForDelete.id).subscribe({
next: () => {
this.deletingUser = false;
this.modalService.dismissAll();
this.refreshUsersList();
this.cdRef.detectChanges();
},
error: (error) => {
this.deletingUser = false;
this.deleteUserError = this.getDeleteErrorMessage(error);
this.cdRef.detectChanges();
}
});
}
// Gestion des erreurs pour la suppression
private getDeleteErrorMessage(error: any): string {
if (error.error?.message) {
return error.error.message;
}
if (error.status === 404) {
return 'Utilisateur non trouvé.';
}
if (error.status === 403) {
return 'Vous n\'avez pas les permissions pour supprimer cet utilisateur.';
}
return 'Erreur lors de la suppression de l\'utilisateur. Veuillez réessayer.';
}
// Méthode pour afficher un message de succès
private showSuccessMessage(message: string) {
console.log('Success:', message);
}
// Validation du formulaire
private validateUserForm(): { isValid: boolean; error?: string } {
const requiredFields = [
{ field: this.newUser.username?.trim(), name: 'Nom d\'utilisateur' },
{ field: this.newUser.email?.trim(), name: 'Email' },
{ field: this.newUser.firstName?.trim(), name: 'Prénom' },
{ field: this.newUser.lastName?.trim(), name: 'Nom' }
];
for (const { field, name } of requiredFields) {
if (!field) {
return { isValid: false, error: `${name} est requis` };
}
}
if (!this.newUser.password || this.newUser.password.length < 8) {
return { isValid: false, error: 'Le mot de passe doit contenir au moins 8 caractères' };
}
return { isValid: true };
}
// Références aux templates de modals
@ViewChild('createUserModal') createUserModal!: TemplateRef<any>;
@ViewChild('resetPasswordModal') resetPasswordModal!: TemplateRef<any>;
@ViewChild('deleteUserModal') deleteUserModal!: TemplateRef<any>;
}

View File

@ -54,6 +54,18 @@ export type MenuItemType = {
isCollapsed?: boolean
}
// types/layout.ts - Ajoutez ce type
export type UserDropdownItemType = {
label?: string;
icon?: string;
url?: string;
isDivider?: boolean;
isHeader?: boolean;
class?: string;
target?: string;
isDisabled?: boolean;
};
export type LanguageOptionType = {
code: string
name: string

View File

@ -1,4 +1,90 @@
export const environment = {
production: false,
apiUrl: "http://localhost:3000/api/v1",
}
dcbApiUrl: 'https://api.paymenthub.com/v2',
// Configuration DCB
dcb: {
// Opérateurs supportés
operators: {
orange: {
endpoint: 'https://api.orange.com/dcb/v2',
timeout: 30000,
retryAttempts: 3,
countries: ['CIV', 'SEN', 'CMR', 'MLI', 'BFA', 'GIN']
},
mtn: {
endpoint: 'https://api.mtn.com/dcb/v2',
timeout: 25000,
retryAttempts: 3,
countries: ['CIV', 'GHA', 'NGA', 'CMR', 'RWA']
},
airtel: {
endpoint: 'https://api.airtel.com/dcb/v2',
timeout: 30000,
retryAttempts: 3,
countries: ['COD', 'TZN', 'KEN', 'UGA', 'RWA']
},
moov: {
endpoint: 'https://api.moov.com/dcb/v2',
timeout: 25000,
retryAttempts: 3,
countries: ['CIV', 'BEN', 'TGO', 'NER', 'BFA']
}
},
// Limitations
limits: {
maxAmount: 50,
minAmount: 0.5,
dailyLimit: 100,
monthlyLimit: 1000
},
// Sécurité
security: {
webhookSecret: 'dcb_wh_secret_2024',
encryptionKey: 'dcb_enc_key_2024',
jwtExpiry: '24h'
},
// Monitoring
monitoring: {
healthCheckInterval: 60000,
alertThreshold: 0.1, // 10% d'erreur
performanceThreshold: 5000 // 5 secondes
}
},
// Configuration Merchants
merchants: {
onboarding: {
maxFileSize: 10 * 1024 * 1024,
allowedFileTypes: ['pdf', 'jpg', 'jpeg', 'png'],
autoApproveThreshold: 1000
},
payouts: {
defaultSchedule: 'monthly',
processingDays: [1, 15],
minPayoutAmount: 50,
fees: {
bankTransfer: 1.5,
mobileMoney: 2.0
}
},
kyc: {
requiredDocuments: ['registration_certificate', 'tax_certificate', 'id_document'],
autoExpireDays: 365
}
},
// Configuration générale
app: {
name: 'Payment Aggregation Hub',
version: '2.0.0',
supportEmail: 'support@paymenthub.com',
defaultLanguage: 'fr',
currencies: ['XOF', 'XAF', 'USD', 'EUR', 'TND'],
countries: ['CIV', 'SEN', 'CMR', 'COD', 'TUN', 'BFA', 'MLI', 'GIN', 'NGA', 'GHA']
}
};

View File

@ -19,6 +19,7 @@
"@app/components/*": ["./src/app/components/*"],
"@common/*": ["./src/app/common/*"],
"@core/*": ["./src/app/core/*"],
"@modules/*": ["./src/app/modules/*"],
"@layouts/*": ["./src/app/layouts/*"],
"@environments/*": ["./src/environments/*"],
"@/*": ["./src/*"],