feat: add DCB User Service API - Authentication system with KEYCLOAK - Modular architecture with services for each feature
This commit is contained in:
parent
d5714ad0f8
commit
3bb7d21a7f
19
package-lock.json
generated
19
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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' },
|
||||
]
|
||||
201
src/app/app.scss
201
src/app/app.scss
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
|
||||
31
src/app/core/directive/has-role.directive.ts
Normal file
31
src/app/core/directive/has-role.directive.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
56
src/app/core/guards/role.guard.ts
Normal file
56
src/app/core/guards/role.guard.ts
Normal 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('/');
|
||||
}
|
||||
@ -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 c’est 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 c’est 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')
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
148
src/app/core/services/menu.service.ts
Normal file
148
src/app/core/services/menu.service.ts
Normal 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'
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
23
src/app/core/services/notifications.service.ts
Normal file
23
src/app/core/services/notifications.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
148
src/app/core/services/permissions.service.ts
Normal file
148
src/app/core/services/permissions.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
89
src/app/modules/auth/error/error-404.ts
Normal file
89
src/app/modules/auth/error/error-404.ts
Normal 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 été 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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
21
src/app/modules/auth/error/error.route.ts
Normal file
21
src/app/modules/auth/error/error.route.ts
Normal 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é' },
|
||||
}
|
||||
]
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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) => {
|
||||
|
||||
96
src/app/modules/auth/unauthorized.ts
Normal file
96
src/app/modules/auth/unauthorized.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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 {}
|
||||
@ -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,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
@ -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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
@ -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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@ -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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@ -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>
|
||||
@ -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 {}
|
||||
338
src/app/modules/dcb-dashboard/dcb-dashboard.html
Normal file
338
src/app/modules/dcb-dashboard/dcb-dashboard.html
Normal 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>
|
||||
@ -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
|
||||
198
src/app/modules/dcb-dashboard/dcb-dashboard.ts
Normal file
198
src/app/modules/dcb-dashboard/dcb-dashboard.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
53
src/app/modules/dcb-dashboard/models/dcb.ts
Normal file
53
src/app/modules/dcb-dashboard/models/dcb.ts
Normal 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;
|
||||
}
|
||||
144
src/app/modules/dcb-dashboard/services/dcb.service.ts
Normal file
144
src/app/modules/dcb-dashboard/services/dcb.service.ts
Normal 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 }
|
||||
];
|
||||
}
|
||||
}
|
||||
15
src/app/modules/merchants/merchants.routes.ts
Normal file
15
src/app/modules/merchants/merchants.routes.ts
Normal 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',
|
||||
}
|
||||
}
|
||||
];
|
||||
@ -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 d’Inté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({
|
||||
|
||||
@ -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>
|
||||
@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ProfileService {
|
||||
constructor() {}
|
||||
}
|
||||
15
src/app/modules/settings/settings.routes.ts
Normal file
15
src/app/modules/settings/settings.routes.ts
Normal 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',
|
||||
}
|
||||
}
|
||||
];
|
||||
@ -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>
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
<p>Transactions - Export</p>
|
||||
@ -1,2 +0,0 @@
|
||||
import { TransactionsExport } from './export';
|
||||
describe('TransactionsExport', () => {});
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
@ -1,2 +0,0 @@
|
||||
import { TransactionsFilters } from './filters';
|
||||
describe('TransactionsFilters', () => {});
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
80
src/app/modules/transactions/models/transaction.ts
Normal file
80
src/app/modules/transactions/models/transaction.ts
Normal 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[];
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class TransactionsDetailsService {
|
||||
constructor() {}
|
||||
}
|
||||
@ -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`);
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class TransactionsListService {
|
||||
constructor() {}
|
||||
}
|
||||
@ -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'
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
@ -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>;
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
<p>Users - Audits</p>
|
||||
@ -1,2 +0,0 @@
|
||||
import { UsersAudits } from './audits';
|
||||
describe('UsersAudits', () => {});
|
||||
@ -1,7 +0,0 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-users-audits',
|
||||
templateUrl: './audits.html',
|
||||
})
|
||||
export class UsersAudits {}
|
||||
@ -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>
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
180
src/app/modules/users/models/user.ts
Normal file
180
src/app/modules/users/models/user.ts
Normal 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 };
|
||||
}
|
||||
355
src/app/modules/users/profile/profile.html
Normal file
355
src/app/modules/users/profile/profile.html
Normal 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>
|
||||
2
src/app/modules/users/profile/profile.spec.ts
Normal file
2
src/app/modules/users/profile/profile.spec.ts
Normal file
@ -0,0 +1,2 @@
|
||||
import { UsersProfile } from './profile';
|
||||
describe('UsersProfile', () => {});
|
||||
240
src/app/modules/users/profile/profile.ts
Normal file
240
src/app/modules/users/profile/profile.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
<p>Users - Roles</p>
|
||||
@ -1,2 +0,0 @@
|
||||
import { UsersRoles } from './roles';
|
||||
describe('UsersRoles', () => {});
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class UsersListService {
|
||||
constructor() {}
|
||||
}
|
||||
@ -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`);
|
||||
}
|
||||
}
|
||||
@ -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`);
|
||||
}
|
||||
}
|
||||
15
src/app/modules/users/structure.txt
Normal file
15
src/app/modules/users/structure.txt
Normal 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
|
||||
@ -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>
|
||||
16
src/app/modules/users/users.routes.ts
Normal file
16
src/app/modules/users/users.routes.ts
Normal 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
|
||||
}
|
||||
}
|
||||
];
|
||||
@ -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>;
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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']
|
||||
}
|
||||
};
|
||||
@ -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/*"],
|
||||
|
||||
Loading…
Reference in New Issue
Block a user