first commit
This commit is contained in:
17
src/app/core/core-module.ts
Normal file
17
src/app/core/core-module.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { HTTP_INTERCEPTORS } from '@angular/common/http';
|
||||
import { ApiPrefixInterceptor } from './interceptors/api-prefix-interceptor';
|
||||
import { AuthTokenInterceptor } from './interceptors/auth-token-interceptor';
|
||||
import { HttpErrorInterceptor } from './interceptors/http-error-interceptor';
|
||||
|
||||
@NgModule({
|
||||
declarations: [],
|
||||
imports: [CommonModule],
|
||||
providers: [
|
||||
{ provide: HTTP_INTERCEPTORS, useClass: ApiPrefixInterceptor, multi: true },
|
||||
{ provide: HTTP_INTERCEPTORS, useClass: AuthTokenInterceptor, multi: true },
|
||||
{ provide: HTTP_INTERCEPTORS, useClass: HttpErrorInterceptor, multi: true },
|
||||
],
|
||||
})
|
||||
export class CoreModule {}
|
||||
17
src/app/core/guards/auth-guard.spec.ts
Normal file
17
src/app/core/guards/auth-guard.spec.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { CanActivateFn } from '@angular/router';
|
||||
|
||||
import { authGuard } from './auth-guard';
|
||||
|
||||
describe('authGuard', () => {
|
||||
const executeGuard: CanActivateFn = (...guardParameters) =>
|
||||
TestBed.runInInjectionContext(() => authGuard(...guardParameters));
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(executeGuard).toBeTruthy();
|
||||
});
|
||||
});
|
||||
9
src/app/core/guards/auth-guard.ts
Normal file
9
src/app/core/guards/auth-guard.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { CanActivateFn, Router } from '@angular/router';
|
||||
import { Auth } from '../services/auth';
|
||||
|
||||
export const authGuard: CanActivateFn = (route, state) => {
|
||||
const auth = inject(Auth);
|
||||
const router = inject(Router);
|
||||
return auth.isAuthenticated() ? true : router.parseUrl('/auth/login');
|
||||
};
|
||||
33
src/app/core/guards/role-guard.ts
Normal file
33
src/app/core/guards/role-guard.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { CanActivateFn, Router } from '@angular/router';
|
||||
import { Auth } from '../services/auth';
|
||||
|
||||
/**
|
||||
* Guard générique basé sur le roleId de l'utilisateur.
|
||||
* Usage dans le routing:
|
||||
* {
|
||||
* path: 'users',
|
||||
* canActivate: [roleGuard],
|
||||
* data: { roles: ['1', '2'] } // ids de rôles autorisés
|
||||
* }
|
||||
*/
|
||||
export const roleGuard: CanActivateFn = (route, state) => {
|
||||
const auth = inject(Auth);
|
||||
const router = inject(Router);
|
||||
|
||||
const expectedRoles = (route.data?.['roles'] as string[] | undefined) ?? [];
|
||||
|
||||
if (!auth.isAuthenticated()) {
|
||||
return router.parseUrl('/auth/login');
|
||||
}
|
||||
|
||||
if (expectedRoles.length === 0) {
|
||||
// Si aucune contrainte, on laisse passer
|
||||
return true;
|
||||
}
|
||||
|
||||
const ok = auth.hasAnyRoleId(expectedRoles);
|
||||
return ok ? true : router.parseUrl('/dashboard'); // ou une page 403 dédiée
|
||||
};
|
||||
|
||||
|
||||
32
src/app/core/interceptors/api-prefix-interceptor.ts
Normal file
32
src/app/core/interceptors/api-prefix-interceptor.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { environment } from 'src/environments/environment.development';
|
||||
|
||||
@Injectable()
|
||||
export class ApiPrefixInterceptor implements HttpInterceptor {
|
||||
constructor() {}
|
||||
|
||||
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
|
||||
const isAbsolute = /^https?:\/\//i.test(request.url);
|
||||
const url = isAbsolute ? request.url : `${environment.apiBaseUrl}${request.url}`;
|
||||
|
||||
// Add ngrok bypass header to skip the warning page
|
||||
const isNgrok =
|
||||
url.includes('ngrok-free.app') || url.includes('ngrok.io') || url.includes('ngrok');
|
||||
|
||||
// Clone request with updated URL
|
||||
let clonedRequest = request.clone({ url });
|
||||
|
||||
// Add ngrok bypass header if needed (only if not already present)
|
||||
if (isNgrok && !clonedRequest.headers.has('ngrok-skip-browser-warning')) {
|
||||
clonedRequest = clonedRequest.clone({
|
||||
setHeaders: {
|
||||
'ngrok-skip-browser-warning': 'true',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return next.handle(clonedRequest);
|
||||
}
|
||||
}
|
||||
17
src/app/core/interceptors/auth-token-interceptor.spec.ts
Normal file
17
src/app/core/interceptors/auth-token-interceptor.spec.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { HttpInterceptorFn } from '@angular/common/http';
|
||||
|
||||
import { authTokenInterceptor } from './auth-token-interceptor';
|
||||
|
||||
describe('authTokenInterceptor', () => {
|
||||
const interceptor: HttpInterceptorFn = (req, next) =>
|
||||
TestBed.runInInjectionContext(() => authTokenInterceptor(req, next));
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(interceptor).toBeTruthy();
|
||||
});
|
||||
});
|
||||
14
src/app/core/interceptors/auth-token-interceptor.ts
Normal file
14
src/app/core/interceptors/auth-token-interceptor.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Auth } from '../services/auth';
|
||||
|
||||
@Injectable()
|
||||
export class AuthTokenInterceptor implements HttpInterceptor {
|
||||
constructor(private auth: Auth) {}
|
||||
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
|
||||
const token = this.auth.getToken();
|
||||
if (!token) return next.handle(req);
|
||||
return next.handle(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }));
|
||||
}
|
||||
}
|
||||
17
src/app/core/interceptors/http-error-interceptor.spec.ts
Normal file
17
src/app/core/interceptors/http-error-interceptor.spec.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { HttpInterceptorFn } from '@angular/common/http';
|
||||
|
||||
import { httpErrorInterceptor } from './http-error-interceptor';
|
||||
|
||||
describe('httpErrorInterceptor', () => {
|
||||
const interceptor: HttpInterceptorFn = (req, next) =>
|
||||
TestBed.runInInjectionContext(() => httpErrorInterceptor(req, next));
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(interceptor).toBeTruthy();
|
||||
});
|
||||
});
|
||||
24
src/app/core/interceptors/http-error-interceptor.ts
Normal file
24
src/app/core/interceptors/http-error-interceptor.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import {
|
||||
HttpRequest,
|
||||
HttpHandler,
|
||||
HttpEvent,
|
||||
HttpInterceptor,
|
||||
HttpErrorResponse,
|
||||
} from '@angular/common/http';
|
||||
import { catchError, Observable, throwError } from 'rxjs';
|
||||
|
||||
@Injectable()
|
||||
export class HttpErrorInterceptor implements HttpInterceptor {
|
||||
constructor() {}
|
||||
|
||||
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
|
||||
return next.handle(request).pipe(
|
||||
catchError((err: HttpErrorResponse) => {
|
||||
// TODO: remplacer par un toast global
|
||||
console.error('HTTP error:', err.status, err.message);
|
||||
return throwError(() => err);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
23
src/app/core/interfaces/agent-limit.ts
Normal file
23
src/app/core/interfaces/agent-limit.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export interface AgentLimit {
|
||||
id: string;
|
||||
code: string; // e.g., ALC001
|
||||
configCode: string; // e.g., ALC001
|
||||
nom: string;
|
||||
isDefault: boolean;
|
||||
actif: boolean;
|
||||
|
||||
// Bet limits
|
||||
betMin?: number;
|
||||
betMax?: number;
|
||||
maxBet?: number;
|
||||
maxDisburseBet?: number;
|
||||
|
||||
// Airtime
|
||||
airtimeMin?: number;
|
||||
airtimeMax?: number;
|
||||
|
||||
createdAt?: string;
|
||||
createdBy?: string;
|
||||
}
|
||||
|
||||
|
||||
65
src/app/core/interfaces/agent.ts
Normal file
65
src/app/core/interfaces/agent.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { TpeDevice } from './tpe';
|
||||
|
||||
export type AgentStatus = 'ACTIF' | 'INACTIF' | 'SUSPENDU';
|
||||
|
||||
export interface Agent {
|
||||
id: string;
|
||||
code: string;
|
||||
profile: string; // ex. AGENT, SUPERVISEUR, CAISSIER
|
||||
principalCode?: string; // Agent principal
|
||||
caisseProfile?: string;
|
||||
statut: AgentStatus;
|
||||
zone?: string;
|
||||
kiosk?: string;
|
||||
fonction?: string;
|
||||
dateEmbauche?: string; // ISO
|
||||
|
||||
nom: string;
|
||||
prenom: string;
|
||||
autresNoms?: string;
|
||||
dateNaissance?: string;
|
||||
lieuNaissance?: string;
|
||||
ville?: string;
|
||||
adresse?: string;
|
||||
autoriserAides?: boolean;
|
||||
|
||||
phone: string;
|
||||
pin?: string; // masked in UI
|
||||
|
||||
limiteInferieure?: number;
|
||||
limiteSuperieure?: number;
|
||||
limiteParTransaction?: number;
|
||||
limiteMinAirtime?: number;
|
||||
limiteMaxAirtime?: number;
|
||||
|
||||
maxPeripheriques?: number;
|
||||
|
||||
limitId?: string; // reference to AgentLimit config
|
||||
|
||||
// Légales
|
||||
nationalite?: string;
|
||||
cni?: string;
|
||||
cniDelivreeLe?: string;
|
||||
cniDelivreeA?: string;
|
||||
residence?: string;
|
||||
autreAdresse1?: string;
|
||||
statutMarital?: string;
|
||||
epoux?: string;
|
||||
autreTelephone?: string;
|
||||
|
||||
// TPE assignés (actifs seulement)
|
||||
tpes?: TpeDevice[];
|
||||
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
createdBy?: string;
|
||||
}
|
||||
|
||||
export interface AgentFamilyMember {
|
||||
id: string;
|
||||
agentId: string;
|
||||
nom: string;
|
||||
statut?: string; // conjoint, enfant, etc.
|
||||
dateNaissance?: string;
|
||||
sexe?: 'M' | 'F';
|
||||
}
|
||||
58
src/app/core/interfaces/course.ts
Normal file
58
src/app/core/interfaces/course.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Reunion } from './reunion';
|
||||
|
||||
export enum CourseType {
|
||||
TIERCE = 'TIERCE',
|
||||
QUARTE = 'QUARTE + TIERCE',
|
||||
QUINTE = 'QUINTE + TIERCE',
|
||||
}
|
||||
|
||||
export enum CourseStatut {
|
||||
PROGRAMMEE = 'PROGRAMMEE',
|
||||
CREATED = 'CREATED',
|
||||
VALIDATED = 'VALIDATED',
|
||||
RUNNING = 'RUNNING',
|
||||
CLOSED = 'CLOSED',
|
||||
CANCELED = 'CANCELED',
|
||||
}
|
||||
|
||||
export enum ResultatStatut {
|
||||
NONE = 'NONE',
|
||||
NON_GENERE = 'NON_GENERE',
|
||||
CREATED = 'CREATED',
|
||||
VALIDATED = 'VALIDATED',
|
||||
CONFIRMED = 'CONFIRMED',
|
||||
}
|
||||
|
||||
export interface Course {
|
||||
id: string;
|
||||
type: CourseType | string; // API returns "Plat" as string
|
||||
numero: number;
|
||||
nom: string;
|
||||
|
||||
dateDepartCourse: string;
|
||||
dateDebutParis: string;
|
||||
dateFinParis: string;
|
||||
|
||||
reunion: Reunion;
|
||||
reunionCourse: number;
|
||||
|
||||
particularite?: string;
|
||||
partants: number;
|
||||
distance: number;
|
||||
condition?: string;
|
||||
|
||||
statut: CourseStatut | string; // API returns "PROGRAMMEE" as string
|
||||
|
||||
nonPartants: string[];
|
||||
|
||||
// Additional API fields
|
||||
estTerminee?: boolean;
|
||||
estAnnulee?: boolean;
|
||||
nombreChevauxInscrits?: number;
|
||||
adeadHeat?: boolean;
|
||||
|
||||
createdBy: string;
|
||||
validatedBy?: string | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
13
src/app/core/interfaces/hippodrome.ts
Normal file
13
src/app/core/interfaces/hippodrome.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export interface Hippodrome {
|
||||
id: string;
|
||||
nom: string;
|
||||
ville: string;
|
||||
pays: string;
|
||||
actif: boolean;
|
||||
capacite?: number;
|
||||
description?: string;
|
||||
reunionCount?: number;
|
||||
courseCount?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
7
src/app/core/interfaces/menu-item.ts
Normal file
7
src/app/core/interfaces/menu-item.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface MenuItem {
|
||||
icon: string;
|
||||
label: string;
|
||||
exact?: boolean;
|
||||
link?: string;
|
||||
submenu?: MenuItem[];
|
||||
}
|
||||
26
src/app/core/interfaces/report.ts
Normal file
26
src/app/core/interfaces/report.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Course } from './course';
|
||||
|
||||
export type ReportStatut = 'Validé' | 'Non Validé' | 'En attente';
|
||||
export type CourseCloseStatut = 'Clôturée' | 'Ouverte';
|
||||
|
||||
export interface CourseReportSummary {
|
||||
id: string; // same as course id
|
||||
course: Course; // full course reference; the course must be CLOSED
|
||||
statut: ReportStatut;
|
||||
confirmed?: boolean; // when true, report is locked (no further edits)
|
||||
}
|
||||
|
||||
export interface CourseReportDetailRow {
|
||||
typeGain: string; // e.g., QUINTE ORDRE
|
||||
typeJeu: string; // e.g., Quinte+
|
||||
montant: number; // amount per winning ticket
|
||||
nombre: number; // number of winners
|
||||
statut: 'Validée' | 'Non Validée';
|
||||
distributed?: boolean;
|
||||
externe?: boolean;
|
||||
}
|
||||
|
||||
export interface CourseReportDetail {
|
||||
summary: CourseReportSummary;
|
||||
rows: CourseReportDetailRow[];
|
||||
}
|
||||
58
src/app/core/interfaces/resultat.ts
Normal file
58
src/app/core/interfaces/resultat.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Course } from './course';
|
||||
|
||||
export interface Resultat {
|
||||
id: string;
|
||||
course: Course;
|
||||
/**
|
||||
* Ordre d'arrivée des chevaux.
|
||||
* The backend returns an array of strings/numbers (cheval numbers);
|
||||
* in the UI we normalize them to plain numbers.
|
||||
*/
|
||||
ordreArrivee: number[];
|
||||
/**
|
||||
* Chevaux en dead-heat (ex aequo), represented by their numbers.
|
||||
*/
|
||||
chevauxDeadHeat: number[];
|
||||
totalMises: number;
|
||||
masseAPartager: number;
|
||||
prelevementsLegaux: number;
|
||||
montantRembourse: number;
|
||||
montantCagnotte: number;
|
||||
adeadHeat: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
// API response structure (course may be just an ID in some cases)
|
||||
export interface ResultatApiResponse {
|
||||
id: string | number;
|
||||
course: Course | string | number;
|
||||
/**
|
||||
* In the raw API this is an array of strings/numbers.
|
||||
*/
|
||||
ordreArrivee: (string | number)[];
|
||||
chevauxDeadHeat: (string | number)[];
|
||||
totalMises: number;
|
||||
masseAPartager: number;
|
||||
prelevementsLegaux: number;
|
||||
montantRembourse: number;
|
||||
montantCagnotte: number;
|
||||
adeadHeat: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
// POST payload structure
|
||||
export interface CreateResultatPayload {
|
||||
course: {
|
||||
id: string | number;
|
||||
};
|
||||
ordreArrivee: string[];
|
||||
chevauxDeadHeat?: (string | number)[];
|
||||
totalMises?: number;
|
||||
masseAPartager?: number;
|
||||
prelevementsLegaux?: number;
|
||||
montantRembourse?: number;
|
||||
montantCagnotte?: number;
|
||||
adeadHeat?: boolean;
|
||||
}
|
||||
21
src/app/core/interfaces/reunion.ts
Normal file
21
src/app/core/interfaces/reunion.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Hippodrome } from './hippodrome';
|
||||
|
||||
export enum ReunionStatut {
|
||||
PLANIFIEE = 'PLANIFIEE',
|
||||
EN_COURS = 'EN_COURS',
|
||||
TERMINEE = 'TERMINEE',
|
||||
ANNULEE = 'ANNULEE',
|
||||
}
|
||||
|
||||
export interface Reunion {
|
||||
id: string;
|
||||
code: string;
|
||||
nom: string;
|
||||
date: string;
|
||||
numero: number;
|
||||
statut: ReunionStatut;
|
||||
hippodrome: Hippodrome;
|
||||
totalCourses?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
14
src/app/core/interfaces/role.ts
Normal file
14
src/app/core/interfaces/role.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export interface Permission {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface Role {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
permissions: Permission[];
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
27
src/app/core/interfaces/tpe.ts
Normal file
27
src/app/core/interfaces/tpe.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Agent } from './agent';
|
||||
|
||||
export type TpeStatus =
|
||||
| 'VALIDE'
|
||||
| 'INVALIDE'
|
||||
| 'EN_PANNE'
|
||||
| 'BLOQUE'
|
||||
| 'DISPONIBLE'
|
||||
| 'AFFECTE'
|
||||
| 'EN_MAINTENANCE'
|
||||
| 'HORS_SERVICE'
|
||||
| 'VOLE';
|
||||
export type TpeType = 'POS' | 'OTHER';
|
||||
|
||||
export interface TpeDevice {
|
||||
id: string;
|
||||
imei: string;
|
||||
serial: string;
|
||||
type: TpeType;
|
||||
marque: string;
|
||||
modele: string;
|
||||
statut: TpeStatus;
|
||||
agent?: Agent;
|
||||
assigne: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
39
src/app/core/interfaces/user.ts
Normal file
39
src/app/core/interfaces/user.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export type UserStatus = 'ACTIVE' | 'CANCELLED' | 'SUSPENDED' | string;
|
||||
import type { Role } from './role';
|
||||
|
||||
/**
|
||||
* Frontend User model.
|
||||
* Aligns with backend payload while keeping a convenient `role` object when available.
|
||||
*/
|
||||
export interface User {
|
||||
id: string;
|
||||
/** Nom (last name) */
|
||||
nom: string;
|
||||
/** Prénom (first name) */
|
||||
prenom: string;
|
||||
/** Identifiant de connexion (username/login) */
|
||||
identifiant: string;
|
||||
/** (Hashed) password – never filled from backend in UI, only for create/update. */
|
||||
password?: string;
|
||||
/** Matricule Agent */
|
||||
matriculeAgent: string;
|
||||
/** Foreign key vers le rôle */
|
||||
roleId: string;
|
||||
/** Rôle complet (chargé séparément) */
|
||||
role?: Role;
|
||||
/** Restriction de connexion (manual) */
|
||||
restrictionConnexion: boolean;
|
||||
/** Restriction automatique */
|
||||
restrictionAutomatique: boolean;
|
||||
/** Nombre d'IP autorisé (manual) */
|
||||
nombreIpAutorise: number;
|
||||
/** Nombre d'IP auto autorisé (automatic) */
|
||||
nombreIpAutoAutorise: number;
|
||||
/** Statut (from grid / backend) */
|
||||
statut: UserStatus;
|
||||
/** Date de dernière connexion (ISO) */
|
||||
derniereConnexion?: string;
|
||||
/** Timestamps */
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
38
src/app/core/mocks/agent-limit.mocks.ts
Normal file
38
src/app/core/mocks/agent-limit.mocks.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { AgentLimit } from '../interfaces/agent-limit';
|
||||
|
||||
export const AGENT_LIMITS_MOCK: AgentLimit[] = [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
code: 'ALC001',
|
||||
configCode: 'ALC001',
|
||||
nom: 'REGION LIMITS',
|
||||
isDefault: true,
|
||||
actif: true,
|
||||
betMin: 10_000,
|
||||
betMax: 10_000_000,
|
||||
maxBet: 10_000_000,
|
||||
maxDisburseBet: -1,
|
||||
airtimeMin: 0,
|
||||
airtimeMax: 50_000,
|
||||
createdAt: '2017-06-05T00:00:00.000Z',
|
||||
createdBy: 'admin',
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
code: 'ALC002',
|
||||
configCode: 'ALC002',
|
||||
nom: 'INDIV PAY KIOSK 200k',
|
||||
isDefault: false,
|
||||
actif: true,
|
||||
betMin: 10_000,
|
||||
betMax: 10_000_000,
|
||||
maxBet: 10_000_000,
|
||||
maxDisburseBet: 0,
|
||||
airtimeMin: 100,
|
||||
airtimeMax: 100_000,
|
||||
createdAt: '2022-02-01T00:00:00.000Z',
|
||||
createdBy: 'admin',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
65
src/app/core/mocks/agent.mocks.ts
Normal file
65
src/app/core/mocks/agent.mocks.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
// import { Agent } from '../interfaces/agent';
|
||||
// import { AGENT_LIMITS_MOCK } from './agent-limit.mocks';
|
||||
// import { TPE_MOCK } from '../mocks/tpe.mocks';
|
||||
|
||||
// export const AGENTS_MOCK: Agent[] = [
|
||||
// {
|
||||
// id: crypto.randomUUID(),
|
||||
// code: 'ALD001',
|
||||
// profile: 'AGENT',
|
||||
// principalCode: 'ALC001',
|
||||
// caisseProfile: 'ALC001',
|
||||
// statut: 'ACTIF',
|
||||
// zone: 'Bamako',
|
||||
// kiosk: 'K-0001',
|
||||
// fonction: 'Vendeur',
|
||||
// dateEmbauche: '2020-03-07T00:00:00.000Z',
|
||||
// nom: 'Diop',
|
||||
// prenom: 'Amadou',
|
||||
// autresNoms: '',
|
||||
// dateNaissance: '1990-01-01',
|
||||
// lieuNaissance: 'Bamako',
|
||||
// ville: 'Bamako',
|
||||
// adresse: 'Quartier A',
|
||||
// autoriserAides: false,
|
||||
// phone: '+22370000001',
|
||||
// limiteInferieure: 0,
|
||||
// limiteSuperieure: 10_000_000,
|
||||
// limiteParTransaction: 1_000_000,
|
||||
// limiteMinAirtime: 0,
|
||||
// limiteMaxAirtime: 100_000,
|
||||
// maxPeripheriques: 5,
|
||||
// limitId: AGENT_LIMITS_MOCK[0].id,
|
||||
// nationalite: 'ML',
|
||||
// cni: 'CNI123456',
|
||||
// cniDelivreeLe: '2018-06-01',
|
||||
// cniDelivreeA: 'Bamako',
|
||||
// residence: 'Bamako',
|
||||
// statutMarital: 'Marié',
|
||||
// epoux: 'Aissatou',
|
||||
// autreTelephone: '+22370000009',
|
||||
// famille: [
|
||||
// { id: crypto.randomUUID(), nom: 'Aissatou', statut: 'Conjointe', dateNaissance: '1991-03-05', sexe: 'F' },
|
||||
// { id: crypto.randomUUID(), nom: 'Ibrahim', statut: 'Enfant', dateNaissance: '2015-09-10', sexe: 'M' },
|
||||
// ],
|
||||
// assignedTpeIds: TPE_MOCK.filter((t) => t.statut === 'valide').slice(0, 1).map((t) => t.id),
|
||||
// createdAt: '2020-03-07T00:00:00.000Z',
|
||||
// createdBy: 'admin',
|
||||
// },
|
||||
// ...Array.from({ length: 12 }).map((_, i) => ({
|
||||
// id: crypto.randomUUID(),
|
||||
// code: `ALK${String(100 + i).padStart(3, '0')}`,
|
||||
// profile: 'AGENT',
|
||||
// statut: i % 5 === 0 ? 'INACTIF' : 'ACTIF',
|
||||
// nom: `Agent${i + 1}`,
|
||||
// prenom: 'Test',
|
||||
// phone: `+2237${(1000000 + i).toString()}`,
|
||||
// limiteInferieure: 0,
|
||||
// limiteSuperieure: 10_000_000,
|
||||
// limiteParTransaction: 500_000,
|
||||
// limiteMinAirtime: 0,
|
||||
// limiteMaxAirtime: 100_000,
|
||||
// maxPeripheriques: 3,
|
||||
// limitId: AGENT_LIMITS_MOCK[1].id,
|
||||
// } as Agent)),
|
||||
// ];
|
||||
197
src/app/core/mocks/course.mocks.ts
Normal file
197
src/app/core/mocks/course.mocks.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { Course, CourseType, CourseStatut, ResultatStatut } from '../interfaces/course';
|
||||
import { REUNIONS_MOCK } from './reunion.mocks';
|
||||
|
||||
const now = new Date();
|
||||
const COURSES_PER_REUNION_BASE = 6;
|
||||
|
||||
function requiredLength(t: CourseType): number {
|
||||
switch (t) {
|
||||
case CourseType.TIERCE:
|
||||
return 3;
|
||||
case CourseType.QUARTE:
|
||||
return 4;
|
||||
case CourseType.QUINTE:
|
||||
return 5;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function rngPick<T>(arr: T[], seed: number): T {
|
||||
const x = Math.abs(Math.sin(seed) * 10000);
|
||||
const idx = Math.floor((x - Math.floor(x)) * arr.length) % arr.length;
|
||||
return arr[idx];
|
||||
}
|
||||
|
||||
function makeMockResultat(
|
||||
type: CourseType,
|
||||
partants: number,
|
||||
nonPartantsNums: number[],
|
||||
seed: number
|
||||
): number[][] {
|
||||
const req = requiredLength(type);
|
||||
const np = new Set(nonPartantsNums);
|
||||
const all = Array.from({ length: partants }, (_, i) => i + 1).filter((n) => !np.has(n));
|
||||
const used = new Set<number>();
|
||||
const places: number[][] = [];
|
||||
|
||||
const tiePlace = Math.abs(seed) % 10 === 0 ? ((seed % req) + req) % req : -1;
|
||||
|
||||
for (let i = 0; i < req; i++) {
|
||||
const remaining = all.filter((n) => !used.has(n));
|
||||
if (remaining.length === 0) {
|
||||
places.push([]);
|
||||
continue;
|
||||
}
|
||||
const first = rngPick(remaining, seed + i * 7);
|
||||
used.add(first);
|
||||
const slot = [first];
|
||||
|
||||
if (i === tiePlace) {
|
||||
const remaining2 = all.filter((n) => !used.has(n));
|
||||
if (remaining2.length > 0) {
|
||||
const second = rngPick(remaining2, seed + i * 13);
|
||||
used.add(second);
|
||||
slot.push(second);
|
||||
slot.sort((a, b) => a - b);
|
||||
}
|
||||
}
|
||||
|
||||
places.push(slot);
|
||||
}
|
||||
|
||||
return places;
|
||||
}
|
||||
|
||||
const COURSE_NAMES = [
|
||||
'Prix du Delta',
|
||||
'Coupe du Fleuve Niger',
|
||||
'Trophée du Mandé',
|
||||
'Challenge du Nord',
|
||||
'Prix de Bamako',
|
||||
'Grand Prix de Tombouctou',
|
||||
'Prix du Sahara',
|
||||
'Trophée du Mali',
|
||||
'Prix de la Savane',
|
||||
'Course de la Paix',
|
||||
'Grand Prix du Sud',
|
||||
'Coupe de l’Avenir',
|
||||
'Prix du Coton',
|
||||
'Prix de la Liberté',
|
||||
'Prix du Marché Central',
|
||||
'Prix du Rail',
|
||||
'Challenge du Faso',
|
||||
'Prix du Soleil',
|
||||
'Prix du Soudan',
|
||||
'Grand Prix du Président',
|
||||
'Prix de la Jeunesse',
|
||||
'Coupe de la Nation',
|
||||
'Prix des Cavaliers',
|
||||
'Trophée de l’Unité',
|
||||
'Prix du Bénin',
|
||||
'Grand Prix de Sikasso',
|
||||
'Prix du Commerce',
|
||||
'Prix du Plateau',
|
||||
'Course des Champions',
|
||||
'Trophée de l’Espoir',
|
||||
'Prix du Développement',
|
||||
'Prix de l’Amitié',
|
||||
'Grand Prix International',
|
||||
'Prix du Peuple',
|
||||
'Prix de la Baie',
|
||||
'Trophée des Pionniers',
|
||||
'Prix du Littoral',
|
||||
];
|
||||
|
||||
const COURSE_TYPES = [CourseType.TIERCE, CourseType.QUARTE, CourseType.QUINTE];
|
||||
const COURSE_STATUTS = [
|
||||
CourseStatut.CREATED,
|
||||
CourseStatut.VALIDATED,
|
||||
CourseStatut.RUNNING,
|
||||
CourseStatut.CLOSED,
|
||||
CourseStatut.CANCELED,
|
||||
];
|
||||
|
||||
const coursesPerReunion = new Map<string, number>();
|
||||
|
||||
const courses: Course[] = [];
|
||||
|
||||
REUNIONS_MOCK.forEach((reunion, reunionIndex) => {
|
||||
const courseCount = COURSES_PER_REUNION_BASE + (reunionIndex % 2);
|
||||
const reunionDate = new Date(`${reunion.date}T00:00:00`);
|
||||
|
||||
for (let i = 0; i < courseCount; i++) {
|
||||
const globalIndex = courses.length;
|
||||
const type = COURSE_TYPES[(globalIndex + i) % COURSE_TYPES.length];
|
||||
const statut = COURSE_STATUTS[(globalIndex + reunionIndex) % COURSE_STATUTS.length];
|
||||
|
||||
const numberWithinReunion = (coursesPerReunion.get(reunion.id) ?? 0) + 1;
|
||||
coursesPerReunion.set(reunion.id, numberWithinReunion);
|
||||
|
||||
const dateDebutParis = new Date(reunionDate);
|
||||
dateDebutParis.setHours(8 + i, 0, 0, 0);
|
||||
const dateFinParis = new Date(dateDebutParis);
|
||||
dateFinParis.setHours(dateDebutParis.getHours() + 2);
|
||||
const dateDepartCourse = new Date(reunionDate);
|
||||
dateDepartCourse.setHours(12 + i, 30, 0, 0);
|
||||
|
||||
const partants = 10 + ((reunionIndex + i) % 6) * 2;
|
||||
|
||||
const nonPartants: string[] = numberWithinReunion % 4 === 0 ? [crypto.randomUUID()] : [];
|
||||
|
||||
const nonPartantsNums = nonPartants.map((np) => Number(np));
|
||||
|
||||
let resultat: number[][] | undefined;
|
||||
let resultatStatut: ResultatStatut = ResultatStatut.NONE;
|
||||
|
||||
if (statut === CourseStatut.CLOSED) {
|
||||
resultat = makeMockResultat(type, partants, nonPartantsNums, globalIndex * 31);
|
||||
resultatStatut = ResultatStatut.CONFIRMED;
|
||||
} else if (statut === CourseStatut.VALIDATED) {
|
||||
resultat = makeMockResultat(type, partants, nonPartantsNums, globalIndex * 17);
|
||||
resultatStatut = ResultatStatut.VALIDATED;
|
||||
} else if (statut === CourseStatut.RUNNING && (globalIndex + reunionIndex) % 3 === 0) {
|
||||
resultat = makeMockResultat(type, partants, nonPartantsNums, globalIndex * 7);
|
||||
resultatStatut = ResultatStatut.CREATED;
|
||||
}
|
||||
|
||||
courses.push({
|
||||
id: crypto.randomUUID(),
|
||||
type,
|
||||
numero: globalIndex + 1,
|
||||
nom: `${COURSE_NAMES[(globalIndex + reunionIndex) % COURSE_NAMES.length]} - ${
|
||||
reunion.hippodrome.ville
|
||||
}`,
|
||||
dateDebutParis: dateDebutParis.toISOString(),
|
||||
dateFinParis: dateFinParis.toISOString(),
|
||||
dateDepartCourse: dateDepartCourse.toISOString(),
|
||||
reunion,
|
||||
reunionCourse: numberWithinReunion,
|
||||
particularite:
|
||||
(globalIndex + reunionIndex) % 2 === 0
|
||||
? 'Course de galop - conditions variées'
|
||||
: 'Trot attelé - catégorie nationale',
|
||||
partants,
|
||||
distance: 2000 + ((reunionIndex + i) % 5) * 200,
|
||||
condition:
|
||||
(globalIndex + reunionIndex) % 3 === 0
|
||||
? 'Réservée aux chevaux de 3 ans et plus'
|
||||
: 'Course mixte - catégorie B',
|
||||
statut,
|
||||
nonPartants,
|
||||
createdBy: `user-${((globalIndex + reunionIndex) % 5) + 1}`,
|
||||
validatedBy: statut === CourseStatut.VALIDATED ? 'admin-1' : undefined,
|
||||
createdAt: now.toISOString(),
|
||||
updatedAt: now.toISOString(),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
coursesPerReunion.forEach((count, reunionId) => {
|
||||
const reunion = REUNIONS_MOCK.find((r) => r.id === reunionId);
|
||||
if (reunion) {
|
||||
reunion.totalCourses = count;
|
||||
}
|
||||
});
|
||||
|
||||
export const COURSES_MOCK: Course[] = courses;
|
||||
421
src/app/core/mocks/hippodrome.mocks.ts
Normal file
421
src/app/core/mocks/hippodrome.mocks.ts
Normal file
@@ -0,0 +1,421 @@
|
||||
import { Hippodrome } from '../interfaces/hippodrome';
|
||||
|
||||
export const HIPPODROMES_MOCK: Hippodrome[] = [
|
||||
// 🇫🇷 France
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
nom: 'Longchamp',
|
||||
ville: 'Paris',
|
||||
pays: 'France',
|
||||
actif: true,
|
||||
capacite: 50000,
|
||||
description: 'Célèbre hippodrome parisien accueillant le Prix de l’Arc de Triomphe.',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
nom: 'Vincennes',
|
||||
ville: 'Paris',
|
||||
pays: 'France',
|
||||
actif: true,
|
||||
capacite: 40000,
|
||||
description: 'Spécialisé dans les courses de trot attelé.',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
nom: 'Auteuil',
|
||||
ville: 'Paris',
|
||||
pays: 'France',
|
||||
actif: true,
|
||||
capacite: 30000,
|
||||
description: 'Hippodrome de référence pour les courses d’obstacles.',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
nom: 'Deauville-La Touques',
|
||||
ville: 'Deauville',
|
||||
pays: 'France',
|
||||
actif: true,
|
||||
capacite: 20000,
|
||||
description: 'Station balnéaire accueillant les courses estivales.',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
nom: 'Chantilly',
|
||||
ville: 'Chantilly',
|
||||
pays: 'France',
|
||||
actif: true,
|
||||
capacite: 25000,
|
||||
description: 'Hippodrome emblématique adossé au château de Chantilly.',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
nom: 'Saint-Cloud',
|
||||
ville: 'Saint-Cloud',
|
||||
pays: 'France',
|
||||
actif: true,
|
||||
capacite: 20000,
|
||||
description: 'Courses de plat sur herbe, cadre verdoyant.',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
nom: 'Cagnes-sur-Mer',
|
||||
ville: 'Cagnes-sur-Mer',
|
||||
pays: 'France',
|
||||
actif: true,
|
||||
capacite: 15000,
|
||||
description: 'Hippodrome moderne du sud de la France.',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
nom: 'Pau',
|
||||
ville: 'Pau',
|
||||
pays: 'France',
|
||||
actif: true,
|
||||
capacite: 10000,
|
||||
description: 'Hippodrome historique du Béarn, courses d’obstacles.',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
nom: 'Lyon-Parilly',
|
||||
ville: 'Lyon',
|
||||
pays: 'France',
|
||||
actif: true,
|
||||
capacite: 18000,
|
||||
description: 'Hippodrome polyvalent de la région Rhône-Alpes.',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
nom: 'Marseille-Borély',
|
||||
ville: 'Marseille',
|
||||
pays: 'France',
|
||||
actif: true,
|
||||
capacite: 20000,
|
||||
description: 'Hippodrome emblématique du sud avec vue sur mer.',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
nom: 'Nancy-Brabois',
|
||||
ville: 'Nancy',
|
||||
pays: 'France',
|
||||
actif: false,
|
||||
capacite: 8000,
|
||||
description: 'Petit hippodrome régional pour courses locales.',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
|
||||
// 🇲🇦 Maroc
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
nom: 'Hippodrome de Casablanca-Anfa',
|
||||
ville: 'Casablanca',
|
||||
pays: 'Maroc',
|
||||
actif: true,
|
||||
capacite: 30000,
|
||||
description: 'Principal hippodrome du Maroc, moderne et actif.',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
nom: 'Hippodrome de Marrakech',
|
||||
ville: 'Marrakech',
|
||||
pays: 'Maroc',
|
||||
actif: true,
|
||||
capacite: 20000,
|
||||
description: 'Installations modernes, climat idéal pour les courses.',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
nom: 'Hippodrome d’El Jadida',
|
||||
ville: 'El Jadida',
|
||||
pays: 'Maroc',
|
||||
actif: true,
|
||||
capacite: 15000,
|
||||
description: 'Accueille des compétitions nationales et régionales.',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
nom: 'Hippodrome de Meknès',
|
||||
ville: 'Meknès',
|
||||
pays: 'Maroc',
|
||||
actif: false,
|
||||
capacite: 10000,
|
||||
description: 'En rénovation, ancien centre hippique royal.',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
nom: 'Hippodrome de Rabat-Souissi',
|
||||
ville: 'Rabat',
|
||||
pays: 'Maroc',
|
||||
actif: true,
|
||||
capacite: 25000,
|
||||
description: 'Hippodrome royal accueillant de grands événements.',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
|
||||
// 🇸🇳 Sénégal
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
nom: 'Hippodrome de Niaga',
|
||||
ville: 'Dakar',
|
||||
pays: 'Sénégal',
|
||||
actif: true,
|
||||
capacite: 12000,
|
||||
description: 'Centre principal des courses sénégalaises.',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
nom: 'Hippodrome de Thiès',
|
||||
ville: 'Thiès',
|
||||
pays: 'Sénégal',
|
||||
actif: false,
|
||||
capacite: 7000,
|
||||
description: 'Structure régionale en cours de modernisation.',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
nom: 'Hippodrome de Saint-Louis',
|
||||
ville: 'Saint-Louis',
|
||||
pays: 'Sénégal',
|
||||
actif: true,
|
||||
capacite: 9000,
|
||||
description: 'Traditionnel lieu de courses dans le nord du pays.',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
|
||||
// 🇲🇱 Mali
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
nom: 'Hippodrome de Bamako',
|
||||
ville: 'Bamako',
|
||||
pays: 'Mali',
|
||||
actif: true,
|
||||
capacite: 15000,
|
||||
description: 'Hippodrome national du Mali, centre principal des courses.',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
nom: 'Hippodrome de Kayes',
|
||||
ville: 'Kayes',
|
||||
pays: 'Mali',
|
||||
actif: true,
|
||||
capacite: 8000,
|
||||
description: 'Centre hippique de la première région du Mali.',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
nom: 'Hippodrome de Sikasso',
|
||||
ville: 'Sikasso',
|
||||
pays: 'Mali',
|
||||
actif: true,
|
||||
capacite: 7000,
|
||||
description: 'Hippodrome régional accueillant des compétitions locales.',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
nom: 'Hippodrome de Ségou',
|
||||
ville: 'Ségou',
|
||||
pays: 'Mali',
|
||||
actif: false,
|
||||
capacite: 5000,
|
||||
description: 'Hippodrome en cours de réhabilitation.',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
nom: 'Hippodrome de Mopti',
|
||||
ville: 'Mopti',
|
||||
pays: 'Mali',
|
||||
actif: true,
|
||||
capacite: 6000,
|
||||
description: 'Lieu emblématique des courses régionales du centre.',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
|
||||
// 🇨🇮 Côte d’Ivoire
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
nom: 'Hippodrome d’Abidjan',
|
||||
ville: 'Abidjan',
|
||||
pays: 'Côte d’Ivoire',
|
||||
actif: true,
|
||||
capacite: 18000,
|
||||
description: 'Hippodrome national ivoirien.',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
nom: 'Hippodrome de Bouaké',
|
||||
ville: 'Bouaké',
|
||||
pays: 'Côte d’Ivoire',
|
||||
actif: false,
|
||||
capacite: 8000,
|
||||
description: 'Petit hippodrome local en rénovation.',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
|
||||
// 🇧🇪 Belgique
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
nom: 'Hippodrome de Wallonie',
|
||||
ville: 'Mons',
|
||||
pays: 'Belgique',
|
||||
actif: true,
|
||||
capacite: 12000,
|
||||
description: 'Hippodrome principal du sud de la Belgique.',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
nom: 'Hippodrome de Kuurne',
|
||||
ville: 'Kuurne',
|
||||
pays: 'Belgique',
|
||||
actif: true,
|
||||
capacite: 9000,
|
||||
description: 'Spécialisé dans les courses de trot.',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
|
||||
// 🇨🇭 Suisse
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
nom: 'Hippodrome d’Avenches',
|
||||
ville: 'Avenches',
|
||||
pays: 'Suisse',
|
||||
actif: true,
|
||||
capacite: 10000,
|
||||
description: 'Hippodrome moderne et bien équipé au cœur de la Suisse.',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
|
||||
// 🇨🇦 Canada
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
nom: 'Hippodrome 3R',
|
||||
ville: 'Trois-Rivières',
|
||||
pays: 'Canada',
|
||||
actif: true,
|
||||
capacite: 15000,
|
||||
description: 'Hippodrome historique du Québec.',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
nom: 'Hippodrome de Québec',
|
||||
ville: 'Québec',
|
||||
pays: 'Canada',
|
||||
actif: false,
|
||||
capacite: 10000,
|
||||
description: 'Ancien hippodrome du centre-ville, fermé au public.',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
|
||||
// 🇹🇳 Tunisie
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
nom: 'Hippodrome de Ksar Saïd',
|
||||
ville: 'Tunis',
|
||||
pays: 'Tunisie',
|
||||
actif: true,
|
||||
capacite: 20000,
|
||||
description: 'Hippodrome national tunisien.',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
nom: 'Hippodrome de Sfax',
|
||||
ville: 'Sfax',
|
||||
pays: 'Tunisie',
|
||||
actif: true,
|
||||
capacite: 12000,
|
||||
description: 'Centre hippique régional.',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
|
||||
// 🇩🇿 Algérie
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
nom: 'Hippodrome de Caroubier',
|
||||
ville: 'Alger',
|
||||
pays: 'Algérie',
|
||||
actif: true,
|
||||
capacite: 25000,
|
||||
description: 'Principal hippodrome d’Algérie.',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
nom: 'Hippodrome d’Oran',
|
||||
ville: 'Oran',
|
||||
pays: 'Algérie',
|
||||
actif: true,
|
||||
capacite: 15000,
|
||||
description: 'Hippodrome côtier moderne.',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
|
||||
// 🇲🇷 Mauritanie
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
nom: 'Hippodrome de Nouakchott',
|
||||
ville: 'Nouakchott',
|
||||
pays: 'Mauritanie',
|
||||
actif: true,
|
||||
capacite: 10000,
|
||||
description: 'Unique hippodrome national de Mauritanie.',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
110
src/app/core/mocks/report.mocks.ts
Normal file
110
src/app/core/mocks/report.mocks.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Course } from '../interfaces/course';
|
||||
import {
|
||||
CourseReportDetail,
|
||||
CourseReportDetailRow,
|
||||
CourseReportSummary,
|
||||
} from '../interfaces/report';
|
||||
import { COURSES_MOCK } from './course.mocks';
|
||||
|
||||
function randomInt(min: number, max: number) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
export function payoutRowsForCourse(c: Course): CourseReportDetailRow[] {
|
||||
const base: CourseReportDetailRow[] = [
|
||||
{
|
||||
typeGain: 'QUINTE ORDRE',
|
||||
typeJeu: 'Quinte+',
|
||||
montant: 2840500,
|
||||
nombre: randomInt(1, 30),
|
||||
statut: 'Validée',
|
||||
distributed: false,
|
||||
externe: false,
|
||||
},
|
||||
{
|
||||
typeGain: 'QUINTE DESORDRE',
|
||||
typeJeu: 'Quinte+',
|
||||
montant: 40000,
|
||||
nombre: randomInt(300, 5000),
|
||||
statut: 'Validée',
|
||||
distributed: false,
|
||||
externe: false,
|
||||
},
|
||||
{
|
||||
typeGain: 'BONUS 4',
|
||||
typeJeu: 'Quinte+',
|
||||
montant: 2000,
|
||||
nombre: randomInt(5000, 25000),
|
||||
statut: 'Validée',
|
||||
distributed: false,
|
||||
externe: false,
|
||||
},
|
||||
{
|
||||
typeGain: 'REMBOURSEMENT',
|
||||
typeJeu: 'Quinte+',
|
||||
montant: 300,
|
||||
nombre: randomInt(10, 500),
|
||||
statut: 'Validée',
|
||||
distributed: false,
|
||||
externe: false,
|
||||
},
|
||||
{
|
||||
typeGain: 'TIERCE ORDRE',
|
||||
typeJeu: 'Tierce',
|
||||
montant: 37000,
|
||||
nombre: randomInt(100, 2000),
|
||||
statut: 'Validée',
|
||||
distributed: false,
|
||||
externe: false,
|
||||
},
|
||||
{
|
||||
typeGain: 'TIERCE DESORDRE',
|
||||
typeJeu: 'Tierce',
|
||||
montant: 6000,
|
||||
nombre: randomInt(500, 6000),
|
||||
statut: 'Validée',
|
||||
distributed: false,
|
||||
externe: false,
|
||||
},
|
||||
{
|
||||
typeGain: 'TRANSFORME COUPLE',
|
||||
typeJeu: 'Tierce',
|
||||
montant: 3000,
|
||||
nombre: randomInt(200, 2000),
|
||||
statut: 'Validée',
|
||||
distributed: false,
|
||||
externe: false,
|
||||
},
|
||||
{
|
||||
typeGain: 'TRANSFORME SIMPLE',
|
||||
typeJeu: 'Tierce',
|
||||
montant: 1500,
|
||||
nombre: randomInt(10, 500),
|
||||
statut: 'Validée',
|
||||
distributed: false,
|
||||
externe: false,
|
||||
},
|
||||
];
|
||||
return base;
|
||||
}
|
||||
|
||||
export const REPORT_SUMMARIES_MOCK: CourseReportSummary[] = COURSES_MOCK.filter(
|
||||
(c) => c.statut === 'CLOSED'
|
||||
)
|
||||
.slice(0, 300)
|
||||
.map(
|
||||
(c) => ({ id: c.id, course: c, statut: 'En attente', confirmed: false } as CourseReportSummary)
|
||||
);
|
||||
|
||||
export function buildDetailByCourseId(id: string): CourseReportDetail | undefined {
|
||||
const summary = REPORT_SUMMARIES_MOCK.find((s) => s.id === id);
|
||||
if (!summary) return undefined;
|
||||
const rows = payoutRowsForCourse(summary.course as Course);
|
||||
return { summary, rows } as CourseReportDetail;
|
||||
}
|
||||
|
||||
// Pre-built rows map for in-memory updates
|
||||
export const REPORT_DETAILS_MOCK = new Map<string, CourseReportDetailRow[]>();
|
||||
for (const c of COURSES_MOCK.filter((c) => c.statut === 'CLOSED').slice(0, 300)) {
|
||||
REPORT_DETAILS_MOCK.set(c.id, payoutRowsForCourse(c));
|
||||
}
|
||||
61
src/app/core/mocks/reunion.mocks.ts
Normal file
61
src/app/core/mocks/reunion.mocks.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Reunion, ReunionStatut } from '../interfaces/reunion';
|
||||
import { HIPPODROMES_MOCK } from './hippodrome.mocks';
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const REUNIONS_PER_HIPPODROME = 3;
|
||||
const BASE_DATE = new Date('2025-01-05T14:00:00Z');
|
||||
const STATUSES: ReunionStatut[] = [
|
||||
ReunionStatut.TERMINEE,
|
||||
ReunionStatut.EN_COURS,
|
||||
ReunionStatut.PLANIFIEE,
|
||||
];
|
||||
|
||||
const REUNION_TITLES = [
|
||||
'Grand Prix',
|
||||
'Challenge Régional',
|
||||
'Meeting de la Capitale',
|
||||
'Trophée des Champions',
|
||||
'Festival Hippique',
|
||||
'Prix du Président',
|
||||
'Coupe des Nations',
|
||||
'Gala des Courses',
|
||||
'Trophée de la Ville',
|
||||
'Coupe de l’Avenir',
|
||||
'Grand Meeting Nocturne',
|
||||
'Festival International',
|
||||
];
|
||||
|
||||
function slugify(value: string): string {
|
||||
return value
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-zA-Z0-9]/g, '')
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
export const REUNIONS_MOCK: Reunion[] = HIPPODROMES_MOCK.flatMap((hippodrome, hipIndex) => {
|
||||
const slug = slugify(hippodrome.nom || hippodrome.ville || `HIP${hipIndex + 1}`);
|
||||
|
||||
return Array.from({ length: REUNIONS_PER_HIPPODROME }).map((_, reunionOffset) => {
|
||||
const globalIndex = hipIndex * REUNIONS_PER_HIPPODROME + reunionOffset;
|
||||
const date = new Date(BASE_DATE);
|
||||
date.setDate(BASE_DATE.getDate() + globalIndex * 2);
|
||||
|
||||
const title = REUNION_TITLES[globalIndex % REUNION_TITLES.length];
|
||||
const statut = STATUSES[globalIndex % STATUSES.length];
|
||||
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
code: `${slug}-${date.getFullYear()}-${(reunionOffset + 1).toString().padStart(2, '0')}`,
|
||||
nom: `${title} de ${hippodrome.ville}`,
|
||||
date: date.toISOString().slice(0, 10),
|
||||
numero: reunionOffset + 1,
|
||||
statut,
|
||||
hippodrome,
|
||||
totalCourses: 0,
|
||||
createdAt: now.toISOString(),
|
||||
updatedAt: now.toISOString(),
|
||||
} satisfies Reunion;
|
||||
});
|
||||
});
|
||||
144
src/app/core/mocks/role.mocks.ts
Normal file
144
src/app/core/mocks/role.mocks.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { Permission, Role } from '../interfaces/role';
|
||||
|
||||
export const PERMISSIONS_MOCK: Permission[] = [
|
||||
// Users
|
||||
{ id: 'p1', name: 'USERS_READ', description: 'Voir utilisateurs' },
|
||||
{ id: 'p2', name: 'USERS_CREATE', description: 'Créer utilisateurs' },
|
||||
{ id: 'p3', name: 'USERS_UPDATE', description: 'Modifier utilisateurs' },
|
||||
{ id: 'p4', name: 'USERS_DELETE', description: 'Supprimer utilisateurs' },
|
||||
{ id: 'p5', name: 'USERS_RESET_PASSWORD', description: 'Réinitialiser mot de passe' },
|
||||
{ id: 'p6', name: 'USERS_LOCK', description: 'Verrouiller utilisateurs' },
|
||||
{ id: 'p7', name: 'USERS_UNLOCK', description: 'Déverrouiller utilisateurs' },
|
||||
{ id: 'p8', name: 'USERS_RESET_2FA', description: 'Réinitialiser 2FA' },
|
||||
{ id: 'p9', name: 'USERS_CHANGE_ROLE', description: 'Changer de rôle' },
|
||||
{ id: 'p10', name: 'USERS_CHANGE_STATUS', description: 'Changer de statut' },
|
||||
|
||||
// Hippodromes
|
||||
{ id: 'p11', name: 'HIPPODROMES_READ', description: 'Voir hippodromes' },
|
||||
{ id: 'p12', name: 'HIPPODROMES_CREATE', description: 'Créer hippodromes' },
|
||||
{ id: 'p13', name: 'HIPPODROMES_UPDATE', description: 'Modifier hippodromes' },
|
||||
{ id: 'p14', name: 'HIPPODROMES_DELETE', description: 'Supprimer hippodromes' },
|
||||
|
||||
// Reunions
|
||||
{ id: 'p11', name: 'REUNIONS_READ', description: 'Voir reunions' },
|
||||
{ id: 'p12', name: 'REUNIONS_CREATE', description: 'Créer reunions' },
|
||||
{ id: 'p13', name: 'REUNIONS_UPDATE', description: 'Modifier reunions' },
|
||||
{ id: 'p14', name: 'REUNIONS_DELETE', description: 'Supprimer reunions' },
|
||||
{ id: 'p15', name: 'REUNIONS_PLANIFIEE', description: 'Planifier reunions' },
|
||||
{ id: 'p17', name: 'REUNIONS_TERMINEE', description: 'Terminer les reunions' },
|
||||
{ id: 'p18', name: 'REUNIONS_CANCEL', description: 'Annuler les reunions' },
|
||||
|
||||
// Courses
|
||||
{ id: 'p19', name: 'COURSES_READ', description: 'Voir courses' },
|
||||
{ id: 'p20', name: 'COURSES_CREATE', description: 'Créer courses' },
|
||||
{ id: 'p21', name: 'COURSES_UPDATE', description: 'Modifier courses' },
|
||||
{ id: 'p22', name: 'COURSES_DELETE', description: 'Supprimer courses' },
|
||||
{ id: 'p23', name: 'COURSES_VALIDATE', description: 'Valider courses' },
|
||||
{ id: 'p24', name: 'COURSES_CONFIRM', description: 'Confirmer courses' },
|
||||
{ id: 'p25', name: 'COURSES_CLOSE', description: 'Clôturer courses' },
|
||||
{ id: 'p26', name: 'COURSES_CANCEL', description: 'Annuler courses' },
|
||||
|
||||
// TPE
|
||||
{ id: 'p27', name: 'TPE_READ', description: 'Voir TPE' },
|
||||
{ id: 'p28', name: 'TPE_CREATE', description: 'Créer TPE' },
|
||||
{ id: 'p29', name: 'TPE_UPDATE', description: 'Modifier TPE' },
|
||||
{ id: 'p30', name: 'TPE_DELETE', description: 'Supprimer TPE' },
|
||||
{ id: 'p31', name: 'TPE_ASSIGN', description: 'Assigner TPE' },
|
||||
{ id: 'p32', name: 'TPE_UNASSIGN', description: 'Déassigner TPE' },
|
||||
|
||||
// Agents
|
||||
{ id: 'p33', name: 'AGENTS_READ', description: 'Voir agents' },
|
||||
{ id: 'p34', name: 'AGENTS_CREATE', description: 'Créer agents' },
|
||||
{ id: 'p35', name: 'AGENTS_UPDATE', description: 'Modifier agents' },
|
||||
{ id: 'p36', name: 'AGENTS_DELETE', description: 'Supprimer agents' },
|
||||
{ id: 'p37', name: 'AGENTS_ASSIGN', description: 'Assigner agents' },
|
||||
{ id: 'p38', name: 'AGENTS_UNASSIGN', description: 'Déassigner agents' },
|
||||
{ id: 'p39', name: 'AGENTS_ASSIGN_TPE', description: 'Assigner TPE à agents' },
|
||||
{ id: 'p40', name: 'AGENTS_UNASSIGN_TPE', description: 'Déassigner TPE à agents' },
|
||||
|
||||
// Familles Agents
|
||||
{ id: 'p41', name: 'AGENT_FAMILIES_READ', description: 'Voir familles agents' },
|
||||
{ id: 'p42', name: 'AGENT_FAMILIES_CREATE', description: 'Créer familles agents' },
|
||||
{ id: 'p43', name: 'AGENT_FAMILIES_UPDATE', description: 'Modifier familles agents' },
|
||||
{ id: 'p44', name: 'AGENT_FAMILIES_DELETE', description: 'Supprimer familles agents' },
|
||||
|
||||
// Limites Agents
|
||||
{ id: 'p41', name: 'AGENT_LIMITS_READ', description: 'Voir limites agents' },
|
||||
{ id: 'p42', name: 'AGENT_LIMITS_CREATE', description: 'Créer limites agents' },
|
||||
{ id: 'p43', name: 'AGENT_LIMITS_UPDATE', description: 'Modifier limites agents' },
|
||||
{ id: 'p44', name: 'AGENT_LIMITS_DELETE', description: 'Supprimer limites agents' },
|
||||
{ id: 'p45', name: 'AGENT_LIMITS_DEFAULTED', description: 'Définir limites agents par défaut' },
|
||||
|
||||
// Permissions
|
||||
{ id: 'p31', name: 'PERMISSIONS_READ', description: 'Voir permissions' },
|
||||
{ id: 'p32', name: 'PERMISSIONS_CREATE', description: 'Créer permissions' },
|
||||
{ id: 'p33', name: 'PERMISSIONS_UPDATE', description: 'Modifier permissions' },
|
||||
{ id: 'p34', name: 'PERMISSIONS_DELETE', description: 'Supprimer permissions' },
|
||||
{ id: 'p35', name: 'PERMISSIONS_ASSIGN', description: 'Assigner permissions' },
|
||||
{ id: 'p36', name: 'PERMISSIONS_UNASSIGN', description: 'Déassigner permissions' },
|
||||
|
||||
// Roles
|
||||
{ id: 'p37', name: 'ROLES_READ', description: 'Voir rôles' },
|
||||
{ id: 'p38', name: 'ROLES_CREATE', description: 'Créer rôles' },
|
||||
{ id: 'p39', name: 'ROLES_UPDATE', description: 'Modifier rôles' },
|
||||
{ id: 'p40', name: 'ROLES_DELETE', description: 'Supprimer rôles' },
|
||||
{ id: 'p41', name: 'ROLES_ASSIGN', description: 'Assigner rôles' },
|
||||
{ id: 'p42', name: 'ROLES_UNASSIGN', description: 'Déassigner rôles' },
|
||||
{ id: 'p43', name: 'ROLES_ASSIGN_PERMISSIONS', description: 'Assigner permissions à rôles' },
|
||||
{ id: 'p44', name: 'ROLES_UNASSIGN_PERMISSIONS', description: 'Déassigner permissions à rôles' },
|
||||
|
||||
// Users
|
||||
{ id: 'p45', name: 'USERS_READ', description: 'Voir utilisateurs' },
|
||||
{ id: 'p46', name: 'USERS_CREATE', description: 'Créer utilisateurs' },
|
||||
{ id: 'p47', name: 'USERS_UPDATE', description: 'Modifier utilisateurs' },
|
||||
{ id: 'p48', name: 'USERS_DELETE', description: 'Supprimer utilisateurs' },
|
||||
{ id: 'p49', name: 'USERS_RESET_PASSWORD', description: 'Réinitialiser mot de passe' },
|
||||
{ id: 'p50', name: 'USERS_LOCK', description: 'Verrouiller utilisateurs' },
|
||||
{ id: 'p51', name: 'USERS_UNLOCK', description: 'Déverrouiller utilisateurs' },
|
||||
];
|
||||
|
||||
export const ROLES_MOCK: Role[] = [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
name: 'Superadmin',
|
||||
description: 'Accès total à toute la plateforme',
|
||||
permissions: [...PERMISSIONS_MOCK],
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
name: 'Administrateur Hippique',
|
||||
description: 'Gestion des courses et résultats',
|
||||
permissions: PERMISSIONS_MOCK.filter((p) =>
|
||||
[
|
||||
'COURSES_READ',
|
||||
'COURSES_MANAGE',
|
||||
'RESULTATS_VALIDATE',
|
||||
'RESULTATS_CONFIRM',
|
||||
'USERS_READ',
|
||||
].includes(p.name)
|
||||
),
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
name: 'Agent Commercial',
|
||||
description: 'Gestion commerciale',
|
||||
permissions: PERMISSIONS_MOCK.filter((p) => ['USERS_READ', 'COURSES_READ'].includes(p.name)),
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
name: 'Gestionnaires Réseau',
|
||||
description: 'Gestion du réseau et consultation',
|
||||
permissions: PERMISSIONS_MOCK.filter((p) => ['USERS_READ', 'COURSES_READ'].includes(p.name)),
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
name: 'Support PJP',
|
||||
description: 'Support et consultation',
|
||||
permissions: PERMISSIONS_MOCK.filter((p) => ['USERS_READ', 'COURSES_READ'].includes(p.name)),
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
36
src/app/core/mocks/tpe.mocks.ts
Normal file
36
src/app/core/mocks/tpe.mocks.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
// import { TpeDevice } from '../interfaces/tpe';
|
||||
|
||||
// const brands = ['MobioT', 'Pax', 'Ingenico', 'Sunmi'];
|
||||
// const models = ['MP4+', 'A920', 'Move5000', 'P2'];
|
||||
|
||||
// function randomImei(i: number): string {
|
||||
// return `${String(i).padStart(3, '0')}${crypto.randomUUID().replace(/-/g, '').slice(0, 12)}`;
|
||||
// }
|
||||
|
||||
// export const TPE_MOCK: TpeDevice[] = [
|
||||
// {
|
||||
// id: crypto.randomUUID(),
|
||||
// imei: '0000ac43ad03c7fd',
|
||||
// serial: 'S-10001',
|
||||
// type: 'POS',
|
||||
// marque: 'MobioT',
|
||||
// modele: 'MP4+',
|
||||
// statut: 'valide',
|
||||
// assigne: true,
|
||||
// createdAt: new Date().toISOString(),
|
||||
// },
|
||||
// ...Array.from({ length: 24 }).map(
|
||||
// (_, i) =>
|
||||
// ({
|
||||
// id: crypto.randomUUID(),
|
||||
// imei: randomImei(i + 1),
|
||||
// serial: `S-${10002 + i}`,
|
||||
// type: 'POS',
|
||||
// marque: brands[i % brands.length],
|
||||
// modele: models[i % models.length],
|
||||
// statut: 'valide',
|
||||
// assigne: i % 7 === 0,
|
||||
// createdAt: new Date().toISOString(),
|
||||
// } as TpeDevice)
|
||||
// ),
|
||||
// ];
|
||||
69
src/app/core/mocks/user.mocks.ts
Normal file
69
src/app/core/mocks/user.mocks.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { User } from '../interfaces/user';
|
||||
import { ROLES_MOCK } from '../mocks/role.mocks';
|
||||
|
||||
export const USERS_MOCK: User[] = [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
nom: 'Maiga',
|
||||
prenom: 'Abdoulaye',
|
||||
identifiant: 'maiga',
|
||||
matriculeAgent: '91111',
|
||||
roleId: ROLES_MOCK[1].id,
|
||||
role: ROLES_MOCK[1],
|
||||
restrictionConnexion: false,
|
||||
restrictionAutomatique: false,
|
||||
nombreIpAutorise: 0,
|
||||
nombreIpAutoAutorise: 0,
|
||||
statut: 'Annulé',
|
||||
derniereConnexion: '2021-05-10T09:00:00.000Z',
|
||||
createdAt: '2020-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
nom: 'Toulema',
|
||||
prenom: 'Moussa',
|
||||
identifiant: 'toulema',
|
||||
matriculeAgent: '91111',
|
||||
roleId: ROLES_MOCK[1].id,
|
||||
role: ROLES_MOCK[1],
|
||||
restrictionConnexion: false,
|
||||
restrictionAutomatique: false,
|
||||
nombreIpAutorise: 0,
|
||||
nombreIpAutoAutorise: 0,
|
||||
statut: 'Annulé',
|
||||
derniereConnexion: '2023-09-01T10:10:00.000Z',
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
nom: 'Toure',
|
||||
prenom: 'Ibrahim',
|
||||
identifiant: 'toure',
|
||||
matriculeAgent: '91111',
|
||||
roleId: ROLES_MOCK[1].id,
|
||||
role: ROLES_MOCK[1],
|
||||
restrictionConnexion: false,
|
||||
restrictionAutomatique: false,
|
||||
nombreIpAutorise: 0,
|
||||
nombreIpAutoAutorise: 0,
|
||||
statut: 'Annulé',
|
||||
derniereConnexion: '2022-05-05T08:00:00.000Z',
|
||||
},
|
||||
...Array.from({ length: 20 }).map(
|
||||
(_, i) =>
|
||||
({
|
||||
id: crypto.randomUUID(),
|
||||
nom: `Utilisateur${i + 1}`,
|
||||
prenom: 'Demo',
|
||||
identifiant: `user${i + 1}`,
|
||||
matriculeAgent: String(90000 + i),
|
||||
roleId: (i % 3 === 0 ? ROLES_MOCK[3] : ROLES_MOCK[4]).id,
|
||||
role: (i % 3 === 0 ? ROLES_MOCK[3] : ROLES_MOCK[4]),
|
||||
restrictionConnexion: false,
|
||||
restrictionAutomatique: false,
|
||||
nombreIpAutorise: i % 2 === 0 ? 10 : 8,
|
||||
nombreIpAutoAutorise: i % 2 === 0 ? 10 : 8,
|
||||
statut: i % 5 === 0 ? 'Suspendu' : 'Actif',
|
||||
derniereConnexion: new Date(2024, i % 12, (i % 28) + 1).toISOString(),
|
||||
} as User)
|
||||
),
|
||||
];
|
||||
236
src/app/core/services/agent-family-member.ts
Normal file
236
src/app/core/services/agent-family-member.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { map, catchError } from 'rxjs/operators';
|
||||
import { AgentFamilyMember } from '../interfaces/agent';
|
||||
import { environment } from 'src/environments/environment.development';
|
||||
|
||||
const USE_SERVER = true;
|
||||
const API_BASE = '/api/v1/agent-family-members';
|
||||
|
||||
// Interface to match the API response structure
|
||||
interface AgentFamilyMemberApiResponse {
|
||||
id: number;
|
||||
agentId: number;
|
||||
nom: string;
|
||||
statut?: string;
|
||||
dateNaissance?: string;
|
||||
sexe?: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AgentFamilyMemberService {
|
||||
private apiUrl = environment.apiBaseUrl + API_BASE;
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
// Helper method to get ngrok bypass headers
|
||||
private getNgrokHeaders(): Record<string, string> {
|
||||
const isNgrok =
|
||||
environment.apiBaseUrl.includes('ngrok-free.app') ||
|
||||
environment.apiBaseUrl.includes('ngrok.io') ||
|
||||
environment.apiBaseUrl.includes('ngrok');
|
||||
return isNgrok ? { 'ngrok-skip-browser-warning': 'true' } : {};
|
||||
}
|
||||
|
||||
// Transform API response to AgentFamilyMember
|
||||
private transformMember(apiMember: AgentFamilyMemberApiResponse): AgentFamilyMember {
|
||||
return {
|
||||
id: String(apiMember.id),
|
||||
agentId: String(apiMember.agentId),
|
||||
nom: apiMember.nom,
|
||||
statut: apiMember.statut,
|
||||
dateNaissance: apiMember.dateNaissance,
|
||||
sexe: apiMember.sexe as 'M' | 'F' | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper method to convert date string to LocalDateTime format (YYYY-MM-DDTHH:mm:ss)
|
||||
private formatDateForApi(dateStr: string | undefined): string | undefined {
|
||||
if (!dateStr) return undefined;
|
||||
// If already in ISO format with time, return as is
|
||||
if (dateStr.includes('T') || dateStr.includes(' ')) {
|
||||
return dateStr;
|
||||
}
|
||||
// If only date (YYYY-MM-DD), add time component
|
||||
if (dateStr.match(/^\d{4}-\d{2}-\d{2}$/)) {
|
||||
return `${dateStr}T00:00:00`;
|
||||
}
|
||||
return dateStr;
|
||||
}
|
||||
|
||||
// Transform AgentFamilyMember to API payload
|
||||
private transformToApiPayload(member: Partial<AgentFamilyMember>): any {
|
||||
const payload: any = {};
|
||||
if (member.agentId !== undefined) payload.agentId = Number(member.agentId);
|
||||
if (member.nom !== undefined) payload.nom = member.nom;
|
||||
if (member.statut !== undefined) payload.statut = member.statut;
|
||||
if (member.dateNaissance !== undefined) payload.dateNaissance = this.formatDateForApi(member.dateNaissance);
|
||||
if (member.sexe !== undefined) payload.sexe = member.sexe;
|
||||
return payload;
|
||||
}
|
||||
|
||||
// GET /api/v1/agent-family-members/{id} - Get by ID
|
||||
getById(id: string): Observable<AgentFamilyMember | undefined> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<AgentFamilyMemberApiResponse>(`${this.apiUrl}/${id}`, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map((apiMember) => this.transformMember(apiMember)),
|
||||
catchError((err) => {
|
||||
console.error(`Error fetching agent family member ${id}:`, err);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(undefined);
|
||||
}
|
||||
|
||||
// GET /api/v1/agent-family-members - List all
|
||||
list(): Observable<AgentFamilyMember[]> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<AgentFamilyMemberApiResponse[]>(this.apiUrl, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map((list) => list.map((apiMember) => this.transformMember(apiMember))),
|
||||
catchError((err) => {
|
||||
console.error('Error fetching agent family members:', err);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of([]);
|
||||
}
|
||||
|
||||
// POST /api/v1/agent-family-members - Create
|
||||
create(payload: Omit<AgentFamilyMember, 'id'>): Observable<AgentFamilyMember> {
|
||||
if (USE_SERVER) {
|
||||
const apiPayload = this.transformToApiPayload(payload);
|
||||
return this.http
|
||||
.post<AgentFamilyMemberApiResponse>(this.apiUrl, apiPayload, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map((apiMember) => this.transformMember(apiMember)),
|
||||
catchError((err) => {
|
||||
console.error('Error creating agent family member:', err);
|
||||
throw err;
|
||||
})
|
||||
);
|
||||
}
|
||||
throw new Error('Server mode is required');
|
||||
}
|
||||
|
||||
// PUT /api/v1/agent-family-members/{id} - Update
|
||||
update(id: string, payload: Partial<AgentFamilyMember>): Observable<AgentFamilyMember | undefined> {
|
||||
if (USE_SERVER) {
|
||||
const apiPayload = this.transformToApiPayload(payload);
|
||||
return this.http
|
||||
.put<AgentFamilyMemberApiResponse>(`${this.apiUrl}/${id}`, apiPayload, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map((apiMember) => this.transformMember(apiMember)),
|
||||
catchError((err) => {
|
||||
console.error(`Error updating agent family member ${id}:`, err);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(undefined);
|
||||
}
|
||||
|
||||
// DELETE /api/v1/agent-family-members/{id} - Delete
|
||||
delete(id: string): Observable<boolean> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.delete<void>(`${this.apiUrl}/${id}`, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map(() => true),
|
||||
catchError((err) => {
|
||||
console.error(`Error deleting agent family member ${id}:`, err);
|
||||
return of(false);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(false);
|
||||
}
|
||||
|
||||
// GET /api/v1/agent-family-members/statut/{statut} - List by statut
|
||||
getByStatut(statut: string): Observable<AgentFamilyMember[]> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<AgentFamilyMemberApiResponse[]>(`${this.apiUrl}/statut/${statut}`, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map((list) => list.map((apiMember) => this.transformMember(apiMember))),
|
||||
catchError((err) => {
|
||||
console.error(`Error fetching agent family members by statut ${statut}:`, err);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of([]);
|
||||
}
|
||||
|
||||
// GET /api/v1/agent-family-members/sexe/{sexe} - List by sexe
|
||||
getBySexe(sexe: 'M' | 'F'): Observable<AgentFamilyMember[]> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<AgentFamilyMemberApiResponse[]>(`${this.apiUrl}/sexe/${sexe}`, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map((list) => list.map((apiMember) => this.transformMember(apiMember))),
|
||||
catchError((err) => {
|
||||
console.error(`Error fetching agent family members by sexe ${sexe}:`, err);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of([]);
|
||||
}
|
||||
|
||||
// GET /api/v1/agent-family-members/search - Search by keyword
|
||||
search(query: string): Observable<AgentFamilyMember[]> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<AgentFamilyMemberApiResponse[]>(`${this.apiUrl}/search`, {
|
||||
params: { q: query.trim() },
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
map((list) => list.map((apiMember) => this.transformMember(apiMember))),
|
||||
catchError((err) => {
|
||||
console.error(`Error searching agent family members with query ${query}:`, err);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of([]);
|
||||
}
|
||||
|
||||
// GET /api/v1/agent-family-members/nom/{nom} - List by nom
|
||||
getByNom(nom: string): Observable<AgentFamilyMember[]> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<AgentFamilyMemberApiResponse[]>(`${this.apiUrl}/nom/${nom}`, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map((list) => list.map((apiMember) => this.transformMember(apiMember))),
|
||||
catchError((err) => {
|
||||
console.error(`Error fetching agent family members by nom ${nom}:`, err);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of([]);
|
||||
}
|
||||
|
||||
// Get family members by agentId (filter from list)
|
||||
getByAgentId(agentId: string): Observable<AgentFamilyMember[]> {
|
||||
if (USE_SERVER) {
|
||||
return this.list().pipe(
|
||||
map((list) => list.filter((member) => member.agentId === agentId)),
|
||||
catchError((err) => {
|
||||
console.error(`Error fetching agent family members by agentId ${agentId}:`, err);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of([]);
|
||||
}
|
||||
}
|
||||
|
||||
335
src/app/core/services/agent-limit.ts
Normal file
335
src/app/core/services/agent-limit.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable, of, forkJoin } from 'rxjs';
|
||||
import { map, catchError, switchMap } from 'rxjs/operators';
|
||||
import { AgentLimit } from '../interfaces/agent-limit';
|
||||
import { environment } from 'src/environments/environment.development';
|
||||
import { normalizePage } from '@shared/paging/normalize-page';
|
||||
import { ListParams, PagedResult } from '@shared/paging/paging';
|
||||
import { AgentService } from './agent';
|
||||
|
||||
const USE_SERVER = true;
|
||||
const API_BASE = '/api/v1/agent-limits';
|
||||
|
||||
// Interface to match the API response structure
|
||||
interface AgentLimitApiResponse {
|
||||
id: number;
|
||||
code: string;
|
||||
configCode: string;
|
||||
nom: string;
|
||||
isDefault: boolean;
|
||||
actif: boolean;
|
||||
betMin?: number;
|
||||
betMax?: number;
|
||||
maxBet?: number;
|
||||
maxDisburseBet?: number;
|
||||
airtimeMin?: number;
|
||||
airtimeMax?: number;
|
||||
createdAt?: string;
|
||||
createdBy?: string;
|
||||
default?: boolean;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AgentLimitService {
|
||||
private apiUrl = environment.apiBaseUrl + API_BASE;
|
||||
|
||||
constructor(private http: HttpClient, private agentService: AgentService) {}
|
||||
|
||||
// Helper method to get ngrok bypass headers
|
||||
private getNgrokHeaders(): Record<string, string> {
|
||||
const isNgrok =
|
||||
environment.apiBaseUrl.includes('ngrok-free.app') ||
|
||||
environment.apiBaseUrl.includes('ngrok.io') ||
|
||||
environment.apiBaseUrl.includes('ngrok');
|
||||
return isNgrok ? { 'ngrok-skip-browser-warning': 'true' } : {};
|
||||
}
|
||||
|
||||
// Transform API response to AgentLimit
|
||||
private transformLimit(apiLimit: AgentLimitApiResponse): AgentLimit {
|
||||
return {
|
||||
id: String(apiLimit.id),
|
||||
code: apiLimit.code,
|
||||
configCode: apiLimit.configCode,
|
||||
nom: apiLimit.nom,
|
||||
isDefault: apiLimit.isDefault ?? apiLimit.default ?? false,
|
||||
actif: apiLimit.actif,
|
||||
betMin: apiLimit.betMin,
|
||||
betMax: apiLimit.betMax,
|
||||
maxBet: apiLimit.maxBet,
|
||||
maxDisburseBet: apiLimit.maxDisburseBet,
|
||||
airtimeMin: apiLimit.airtimeMin,
|
||||
airtimeMax: apiLimit.airtimeMax,
|
||||
createdAt: apiLimit.createdAt,
|
||||
createdBy: apiLimit.createdBy,
|
||||
};
|
||||
}
|
||||
|
||||
// Transform AgentLimit to API payload
|
||||
private transformToApiPayload(limit: Partial<AgentLimit>): any {
|
||||
const payload: any = {};
|
||||
if (limit.code !== undefined) payload.code = limit.code;
|
||||
if (limit.configCode !== undefined) payload.configCode = limit.configCode;
|
||||
if (limit.nom !== undefined) payload.nom = limit.nom;
|
||||
if (limit.isDefault !== undefined) {
|
||||
payload.isDefault = limit.isDefault;
|
||||
payload.default = limit.isDefault;
|
||||
}
|
||||
if (limit.actif !== undefined) payload.actif = limit.actif;
|
||||
if (limit.betMin !== undefined) payload.betMin = limit.betMin;
|
||||
if (limit.betMax !== undefined) payload.betMax = limit.betMax;
|
||||
if (limit.maxBet !== undefined) payload.maxBet = limit.maxBet;
|
||||
if (limit.maxDisburseBet !== undefined) payload.maxDisburseBet = limit.maxDisburseBet;
|
||||
if (limit.airtimeMin !== undefined) payload.airtimeMin = limit.airtimeMin;
|
||||
if (limit.airtimeMax !== undefined) payload.airtimeMax = limit.airtimeMax;
|
||||
if (limit.createdBy !== undefined) payload.createdBy = limit.createdBy;
|
||||
return payload;
|
||||
}
|
||||
|
||||
// GET /api/v1/agent-limits/{id} - Get by ID
|
||||
getById(id: string): Observable<AgentLimit | undefined> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<AgentLimitApiResponse>(`${this.apiUrl}/${id}`, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map((apiLimit) => this.transformLimit(apiLimit)),
|
||||
catchError((err) => {
|
||||
console.error(`Error fetching agent limit ${id}:`, err);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(undefined);
|
||||
}
|
||||
|
||||
// GET /api/v1/agent-limits - List all
|
||||
list(params?: ListParams): Observable<PagedResult<AgentLimit>> {
|
||||
if (USE_SERVER) {
|
||||
let httpParams = new HttpParams();
|
||||
if (params) {
|
||||
if (params.page) httpParams = httpParams.set('page', params.page.toString());
|
||||
if (params.perPage) httpParams = httpParams.set('perPage', params.perPage.toString());
|
||||
if (params.search) httpParams = httpParams.set('search', params.search);
|
||||
if (params.sortKey) httpParams = httpParams.set('sortKey', params.sortKey);
|
||||
if (params.sortDir) httpParams = httpParams.set('sortDir', params.sortDir);
|
||||
}
|
||||
|
||||
return this.http
|
||||
.get<AgentLimitApiResponse[]>(this.apiUrl, {
|
||||
params: httpParams,
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
map((list) => {
|
||||
const limits = list.map((apiLimit) => this.transformLimit(apiLimit));
|
||||
// If pagination params provided, return paginated result
|
||||
if (params) {
|
||||
return normalizePage<AgentLimit>(
|
||||
{ data: limits, meta: { total: limits.length } },
|
||||
params.page || 1,
|
||||
params.perPage || 10
|
||||
);
|
||||
}
|
||||
// Otherwise return all as single page
|
||||
return normalizePage<AgentLimit>(
|
||||
{ data: limits, meta: { total: limits.length } },
|
||||
1,
|
||||
limits.length
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error('Error fetching agent limits:', err);
|
||||
return of(normalizePage<AgentLimit>({ data: [], meta: { total: 0 } }, 1, 10));
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(normalizePage<AgentLimit>({ data: [], meta: { total: 0 } }, 1, 10));
|
||||
}
|
||||
|
||||
// POST /api/v1/agent-limits - Create
|
||||
create(payload: Omit<AgentLimit, 'id' | 'createdAt'>): Observable<AgentLimit> {
|
||||
if (USE_SERVER) {
|
||||
const apiPayload = this.transformToApiPayload(payload);
|
||||
return this.http
|
||||
.post<AgentLimitApiResponse>(this.apiUrl, apiPayload, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
switchMap((apiLimit) => {
|
||||
const limit = this.transformLimit(apiLimit);
|
||||
// If this limit is set as default, handle default assignment
|
||||
if (limit.isDefault) {
|
||||
return this.handleDefaultLimitChange(limit.id).pipe(map(() => limit));
|
||||
}
|
||||
return of(limit);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error('Error creating agent limit:', err);
|
||||
throw err;
|
||||
})
|
||||
);
|
||||
}
|
||||
throw new Error('Server mode is required');
|
||||
}
|
||||
|
||||
// PUT /api/v1/agent-limits/{id} - Update
|
||||
update(id: string, payload: Partial<AgentLimit>): Observable<AgentLimit | undefined> {
|
||||
if (USE_SERVER) {
|
||||
// Check if isDefault is being changed to true
|
||||
const isSettingDefault = payload.isDefault === true;
|
||||
const wasDefault = payload.isDefault !== undefined;
|
||||
|
||||
return this.http
|
||||
.put<AgentLimitApiResponse>(`${this.apiUrl}/${id}`, this.transformToApiPayload(payload), {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
switchMap((apiLimit) => {
|
||||
const limit = this.transformLimit(apiLimit);
|
||||
// If this limit is being set as default, handle default assignment
|
||||
if (isSettingDefault) {
|
||||
return this.handleDefaultLimitChange(limit.id).pipe(map(() => limit));
|
||||
}
|
||||
return of(limit);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error(`Error updating agent limit ${id}:`, err);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(undefined);
|
||||
}
|
||||
|
||||
// Helper method to handle default limit changes
|
||||
// When a limit is set as default:
|
||||
// 1. Find the previous default limit and unset it (preserving all other fields)
|
||||
// 2. Assign the new default limit to all agents
|
||||
private handleDefaultLimitChange(newDefaultLimitId: string): Observable<boolean> {
|
||||
// First, find the previous default limit
|
||||
return this.list({
|
||||
page: 1,
|
||||
perPage: 1000,
|
||||
search: '',
|
||||
sortKey: 'code',
|
||||
sortDir: 'asc',
|
||||
} as any).pipe(
|
||||
switchMap((result) => {
|
||||
const limits = result.data;
|
||||
const previousDefault = limits.find((l) => l.isDefault && l.id !== newDefaultLimitId);
|
||||
|
||||
const operations: Observable<any>[] = [];
|
||||
|
||||
// If there's a previous default, unset it while preserving all other fields
|
||||
if (previousDefault) {
|
||||
// Create a payload with all fields from previousDefault, but with isDefault set to false
|
||||
// This ensures we preserve all existing data
|
||||
const updatePayload: Partial<AgentLimit> = {
|
||||
code: previousDefault.code,
|
||||
configCode: previousDefault.configCode,
|
||||
nom: previousDefault.nom,
|
||||
isDefault: false,
|
||||
actif: previousDefault.actif,
|
||||
betMin: previousDefault.betMin,
|
||||
betMax: previousDefault.betMax,
|
||||
maxBet: previousDefault.maxBet,
|
||||
maxDisburseBet: previousDefault.maxDisburseBet,
|
||||
airtimeMin: previousDefault.airtimeMin,
|
||||
airtimeMax: previousDefault.airtimeMax,
|
||||
};
|
||||
|
||||
// Use the update method with the full payload
|
||||
operations.push(
|
||||
this.update(previousDefault.id, updatePayload).pipe(
|
||||
map(() => true),
|
||||
catchError((err) => {
|
||||
console.error(`Error unsetting previous default limit ${previousDefault.id}:`, err);
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Assign the new default limit to all agents
|
||||
operations.push(this.agentService.updateAllAgentsLimitId(newDefaultLimitId));
|
||||
|
||||
return forkJoin(operations).pipe(
|
||||
map(() => true),
|
||||
catchError((err) => {
|
||||
console.error('Error handling default limit change:', err);
|
||||
return of(false);
|
||||
})
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error('Error fetching limits for default change:', err);
|
||||
return of(false);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// DELETE /api/v1/agent-limits/{id} - Delete
|
||||
delete(id: string): Observable<boolean> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.delete<void>(`${this.apiUrl}/${id}`, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map(() => true),
|
||||
catchError((err) => {
|
||||
console.error(`Error deleting agent limit ${id}:`, err);
|
||||
return of(false);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(false);
|
||||
}
|
||||
|
||||
// GET /api/v1/agent-limits/search/{nom} - Search by nom
|
||||
search(query: string): Observable<AgentLimit[]> {
|
||||
if (USE_SERVER) {
|
||||
const searchTerm = encodeURIComponent(query.trim());
|
||||
return this.http
|
||||
.get<AgentLimitApiResponse[]>(`${this.apiUrl}/search/${searchTerm}`, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
map((list) => list.map((apiLimit) => this.transformLimit(apiLimit))),
|
||||
catchError((err) => {
|
||||
console.error(`Error searching agent limits with query ${query}:`, err);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of([]);
|
||||
}
|
||||
|
||||
// GET /api/v1/agent-limits/actif/{actif} - List by actif status
|
||||
getByActif(actif: boolean): Observable<AgentLimit[]> {
|
||||
if (USE_SERVER) {
|
||||
if (actif) {
|
||||
return this.http
|
||||
.get<AgentLimitApiResponse[]>(`${this.apiUrl}/actif`, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
map((list) => list.map((apiLimit) => this.transformLimit(apiLimit))),
|
||||
catchError((err) => {
|
||||
console.error(`Error fetching agent limits by actif ${actif}:`, err);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
} else {
|
||||
return this.http
|
||||
.get<AgentLimitApiResponse[]>(`${this.apiUrl}/inactif`, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
map((list) => list.map((apiLimit) => this.transformLimit(apiLimit))),
|
||||
catchError((err) => {
|
||||
console.error(`Error fetching agent limits by actif ${actif}:`, err);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
return of([]);
|
||||
}
|
||||
}
|
||||
484
src/app/core/services/agent.ts
Normal file
484
src/app/core/services/agent.ts
Normal file
@@ -0,0 +1,484 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable, of, forkJoin } from 'rxjs';
|
||||
import { map, catchError, switchMap } from 'rxjs/operators';
|
||||
import { Agent, AgentStatus } from '../interfaces/agent';
|
||||
import { TpeDevice, TpeStatus, TpeType } from '../interfaces/tpe';
|
||||
import { environment } from 'src/environments/environment.development';
|
||||
import { normalizePage } from '@shared/paging/normalize-page';
|
||||
import { ListParams, PagedResult } from '@shared/paging/paging';
|
||||
|
||||
const USE_SERVER = true;
|
||||
const API_BASE = '/api/v1/agents';
|
||||
|
||||
// Interface to match the API response structure for TPE (nested in Agent)
|
||||
// Note: When TPE is nested in Agent's tpes array, the agent field might be omitted or be a reference
|
||||
interface TpeApiResponse {
|
||||
id: number;
|
||||
imei: string;
|
||||
serial: string;
|
||||
type: string;
|
||||
marque: string;
|
||||
modele: string;
|
||||
statut: string;
|
||||
agent?: any; // Can be Agent object or string reference, we'll handle it in transformTpe
|
||||
assigne: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
// Interface to match the API response structure
|
||||
interface AgentApiResponse {
|
||||
id: number;
|
||||
code: string;
|
||||
profile: string;
|
||||
principalCode?: string;
|
||||
caisseProfile?: string;
|
||||
statut: string;
|
||||
zone?: string;
|
||||
kiosk?: string;
|
||||
fonction?: string;
|
||||
dateEmbauche?: string;
|
||||
nom: string;
|
||||
prenom: string;
|
||||
autresNoms?: string;
|
||||
dateNaissance?: string;
|
||||
lieuNaissance?: string;
|
||||
ville?: string;
|
||||
adresse?: string;
|
||||
autoriserAides?: boolean;
|
||||
phone: string;
|
||||
pin?: string;
|
||||
limiteInferieure?: number;
|
||||
limiteSuperieure?: number;
|
||||
limiteParTransaction?: number;
|
||||
limiteMinAirtime?: number;
|
||||
limiteMaxAirtime?: number;
|
||||
maxPeripheriques?: number;
|
||||
limitId?: number;
|
||||
nationalite?: string;
|
||||
cni?: string;
|
||||
cniDelivreeLe?: string;
|
||||
cniDelivreeA?: string;
|
||||
residence?: string;
|
||||
autreAdresse1?: string;
|
||||
statutMarital?: string;
|
||||
epoux?: string;
|
||||
autreTelephone?: string;
|
||||
tpes?: TpeApiResponse[];
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
createdBy?: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AgentService {
|
||||
private apiUrl = environment.apiBaseUrl + API_BASE;
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
// Helper method to get ngrok bypass headers
|
||||
private getNgrokHeaders(): Record<string, string> {
|
||||
const isNgrok =
|
||||
environment.apiBaseUrl.includes('ngrok-free.app') ||
|
||||
environment.apiBaseUrl.includes('ngrok.io') ||
|
||||
environment.apiBaseUrl.includes('ngrok');
|
||||
return isNgrok ? { 'ngrok-skip-browser-warning': 'true' } : {};
|
||||
}
|
||||
|
||||
// Transform API TPE response to TpeDevice
|
||||
private transformTpe(apiTpe: TpeApiResponse): TpeDevice {
|
||||
const transformStatut = (apiStatut: string): TpeStatus => {
|
||||
const upperStatut = apiStatut.toUpperCase() as TpeStatus;
|
||||
const validStatuses: TpeStatus[] = [
|
||||
'VALIDE',
|
||||
'INVALIDE',
|
||||
'EN_PANNE',
|
||||
'BLOQUE',
|
||||
'DISPONIBLE',
|
||||
'AFFECTE',
|
||||
'EN_MAINTENANCE',
|
||||
'HORS_SERVICE',
|
||||
'VOLE',
|
||||
];
|
||||
return validStatuses.includes(upperStatut) ? upperStatut : 'INVALIDE';
|
||||
};
|
||||
|
||||
// Transform agent if it's an object (not just a string reference)
|
||||
let transformedAgent: Agent | undefined = undefined;
|
||||
if (apiTpe.agent && typeof apiTpe.agent === 'object' && apiTpe.agent.id) {
|
||||
// If agent is a full object, transform it
|
||||
transformedAgent = this.transformAgent(apiTpe.agent as any);
|
||||
}
|
||||
|
||||
return {
|
||||
id: String(apiTpe.id),
|
||||
imei: apiTpe.imei,
|
||||
serial: apiTpe.serial,
|
||||
type: apiTpe.type as TpeType,
|
||||
marque: apiTpe.marque,
|
||||
modele: apiTpe.modele,
|
||||
statut: transformStatut(apiTpe.statut),
|
||||
agent: transformedAgent,
|
||||
assigne: apiTpe.assigne,
|
||||
createdAt: apiTpe.createdAt,
|
||||
updatedAt: apiTpe.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// Transform API response to Agent
|
||||
private transformAgent(apiAgent: AgentApiResponse): Agent {
|
||||
return {
|
||||
id: String(apiAgent.id),
|
||||
code: apiAgent.code,
|
||||
profile: apiAgent.profile,
|
||||
principalCode: apiAgent.principalCode,
|
||||
caisseProfile: apiAgent.caisseProfile,
|
||||
statut: apiAgent.statut as AgentStatus,
|
||||
zone: apiAgent.zone,
|
||||
kiosk: apiAgent.kiosk,
|
||||
fonction: apiAgent.fonction,
|
||||
dateEmbauche: apiAgent.dateEmbauche,
|
||||
nom: apiAgent.nom,
|
||||
prenom: apiAgent.prenom,
|
||||
autresNoms: apiAgent.autresNoms,
|
||||
dateNaissance: apiAgent.dateNaissance,
|
||||
lieuNaissance: apiAgent.lieuNaissance,
|
||||
ville: apiAgent.ville,
|
||||
adresse: apiAgent.adresse,
|
||||
autoriserAides: apiAgent.autoriserAides,
|
||||
phone: apiAgent.phone,
|
||||
pin: apiAgent.pin,
|
||||
limiteInferieure: apiAgent.limiteInferieure,
|
||||
limiteSuperieure: apiAgent.limiteSuperieure,
|
||||
limiteParTransaction: apiAgent.limiteParTransaction,
|
||||
limiteMinAirtime: apiAgent.limiteMinAirtime,
|
||||
limiteMaxAirtime: apiAgent.limiteMaxAirtime,
|
||||
maxPeripheriques: apiAgent.maxPeripheriques,
|
||||
limitId: apiAgent.limitId ? String(apiAgent.limitId) : undefined,
|
||||
nationalite: apiAgent.nationalite,
|
||||
cni: apiAgent.cni,
|
||||
cniDelivreeLe: apiAgent.cniDelivreeLe,
|
||||
cniDelivreeA: apiAgent.cniDelivreeA,
|
||||
residence: apiAgent.residence,
|
||||
autreAdresse1: apiAgent.autreAdresse1,
|
||||
statutMarital: apiAgent.statutMarital,
|
||||
epoux: apiAgent.epoux,
|
||||
autreTelephone: apiAgent.autreTelephone,
|
||||
tpes: apiAgent.tpes?.map((tpe) => {
|
||||
const transformed = this.transformTpe(tpe);
|
||||
return transformed;
|
||||
}),
|
||||
createdAt: apiAgent.createdAt,
|
||||
updatedAt: apiAgent.updatedAt,
|
||||
createdBy: apiAgent.createdBy,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper method to convert date string to LocalDateTime format (YYYY-MM-DDTHH:mm:ss)
|
||||
private formatDateForApi(dateStr: string | undefined): string | undefined {
|
||||
if (!dateStr) return undefined;
|
||||
// If already in ISO format with time, return as is
|
||||
if (dateStr.includes('T') || dateStr.includes(' ')) {
|
||||
return dateStr;
|
||||
}
|
||||
// If only date (YYYY-MM-DD), add time component
|
||||
if (dateStr.match(/^\d{4}-\d{2}-\d{2}$/)) {
|
||||
return `${dateStr}T00:00:00`;
|
||||
}
|
||||
return dateStr;
|
||||
}
|
||||
|
||||
// Transform Agent to API payload
|
||||
private transformToApiPayload(agent: Partial<Agent>): any {
|
||||
const payload: any = {};
|
||||
if (agent.code !== undefined) payload.code = agent.code;
|
||||
if (agent.profile !== undefined) payload.profile = agent.profile;
|
||||
if (agent.principalCode !== undefined) payload.principalCode = agent.principalCode;
|
||||
if (agent.caisseProfile !== undefined) payload.caisseProfile = agent.caisseProfile;
|
||||
if (agent.statut !== undefined) payload.statut = agent.statut;
|
||||
if (agent.zone !== undefined) payload.zone = agent.zone;
|
||||
if (agent.kiosk !== undefined) payload.kiosk = agent.kiosk;
|
||||
if (agent.fonction !== undefined) payload.fonction = agent.fonction;
|
||||
if (agent.dateEmbauche !== undefined)
|
||||
payload.dateEmbauche = this.formatDateForApi(agent.dateEmbauche);
|
||||
if (agent.nom !== undefined) payload.nom = agent.nom;
|
||||
if (agent.prenom !== undefined) payload.prenom = agent.prenom;
|
||||
if (agent.autresNoms !== undefined) payload.autresNoms = agent.autresNoms;
|
||||
if (agent.dateNaissance !== undefined)
|
||||
payload.dateNaissance = this.formatDateForApi(agent.dateNaissance);
|
||||
if (agent.lieuNaissance !== undefined) payload.lieuNaissance = agent.lieuNaissance;
|
||||
if (agent.ville !== undefined) payload.ville = agent.ville;
|
||||
if (agent.adresse !== undefined) payload.adresse = agent.adresse;
|
||||
if (agent.autoriserAides !== undefined) payload.autoriserAides = agent.autoriserAides;
|
||||
if (agent.phone !== undefined) payload.phone = agent.phone;
|
||||
if (agent.pin !== undefined) payload.pin = agent.pin;
|
||||
if (agent.limiteInferieure !== undefined) payload.limiteInferieure = agent.limiteInferieure;
|
||||
if (agent.limiteSuperieure !== undefined) payload.limiteSuperieure = agent.limiteSuperieure;
|
||||
if (agent.limiteParTransaction !== undefined)
|
||||
payload.limiteParTransaction = agent.limiteParTransaction;
|
||||
if (agent.limiteMinAirtime !== undefined) payload.limiteMinAirtime = agent.limiteMinAirtime;
|
||||
if (agent.limiteMaxAirtime !== undefined) payload.limiteMaxAirtime = agent.limiteMaxAirtime;
|
||||
if (agent.maxPeripheriques !== undefined) payload.maxPeripheriques = agent.maxPeripheriques;
|
||||
if (agent.limitId !== undefined)
|
||||
payload.limitId = agent.limitId ? Number(agent.limitId) : undefined;
|
||||
if (agent.nationalite !== undefined) payload.nationalite = agent.nationalite;
|
||||
if (agent.cni !== undefined) payload.cni = agent.cni;
|
||||
if (agent.cniDelivreeLe !== undefined)
|
||||
payload.cniDelivreeLe = this.formatDateForApi(agent.cniDelivreeLe);
|
||||
if (agent.cniDelivreeA !== undefined) payload.cniDelivreeA = agent.cniDelivreeA;
|
||||
if (agent.residence !== undefined) payload.residence = agent.residence;
|
||||
if (agent.autreAdresse1 !== undefined) payload.autreAdresse1 = agent.autreAdresse1;
|
||||
if (agent.statutMarital !== undefined) payload.statutMarital = agent.statutMarital;
|
||||
if (agent.epoux !== undefined) payload.epoux = agent.epoux;
|
||||
if (agent.autreTelephone !== undefined) payload.autreTelephone = agent.autreTelephone;
|
||||
if (agent.createdBy !== undefined) payload.createdBy = agent.createdBy;
|
||||
// Include tpes if provided - transform to API format
|
||||
if (agent.tpes !== undefined) {
|
||||
payload.tpes = agent.tpes.map((tpe) => ({
|
||||
id: tpe.id ? Number(tpe.id) : undefined,
|
||||
imei: tpe.imei,
|
||||
serial: tpe.serial,
|
||||
type: tpe.type,
|
||||
marque: tpe.marque,
|
||||
modele: tpe.modele,
|
||||
statut: tpe.statut,
|
||||
agent: undefined, // Will be set by backend
|
||||
assigne: tpe.assigne,
|
||||
createdAt: tpe.createdAt,
|
||||
updatedAt: tpe.updatedAt,
|
||||
}));
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
// GET /api/v1/agents/{id} - Get by ID
|
||||
getById(id: string): Observable<Agent | undefined> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<AgentApiResponse>(`${this.apiUrl}/${id}`, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map((apiAgent) => this.transformAgent(apiAgent)),
|
||||
catchError((err) => {
|
||||
console.error(`Error fetching agent ${id}:`, err);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(undefined);
|
||||
}
|
||||
|
||||
// GET /api/v1/agents - List all
|
||||
list(params?: ListParams): Observable<PagedResult<Agent>> {
|
||||
if (USE_SERVER) {
|
||||
let httpParams = new HttpParams();
|
||||
if (params) {
|
||||
if (params.page) httpParams = httpParams.set('page', params.page.toString());
|
||||
if (params.perPage) httpParams = httpParams.set('perPage', params.perPage.toString());
|
||||
if (params.search) httpParams = httpParams.set('search', params.search);
|
||||
if (params.sortKey) httpParams = httpParams.set('sortKey', params.sortKey);
|
||||
if (params.sortDir) httpParams = httpParams.set('sortDir', params.sortDir);
|
||||
}
|
||||
|
||||
return this.http
|
||||
.get<AgentApiResponse[]>(this.apiUrl, {
|
||||
params: httpParams,
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
map((list) => {
|
||||
const agents = list.map((apiAgent) => {
|
||||
const transformed = this.transformAgent(apiAgent);
|
||||
return transformed;
|
||||
});
|
||||
// If pagination params provided, return paginated result
|
||||
if (params) {
|
||||
return normalizePage<Agent>(
|
||||
{ data: agents, meta: { total: agents.length } },
|
||||
params.page || 1,
|
||||
params.perPage || 10
|
||||
);
|
||||
}
|
||||
// Otherwise return all as single page
|
||||
return normalizePage<Agent>(
|
||||
{ data: agents, meta: { total: agents.length } },
|
||||
1,
|
||||
agents.length
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error('Error fetching agents:', err);
|
||||
return of(normalizePage<Agent>({ data: [], meta: { total: 0 } }, 1, 10));
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(normalizePage<Agent>({ data: [], meta: { total: 0 } }, 1, 10));
|
||||
}
|
||||
|
||||
// POST /api/v1/agents - Create
|
||||
create(payload: Omit<Agent, 'id' | 'createdAt' | 'updatedAt'>): Observable<Agent> {
|
||||
if (USE_SERVER) {
|
||||
const apiPayload = this.transformToApiPayload(payload);
|
||||
return this.http
|
||||
.post<AgentApiResponse>(this.apiUrl, apiPayload, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map((apiAgent) => this.transformAgent(apiAgent)),
|
||||
catchError((err) => {
|
||||
console.error('Error creating agent:', err);
|
||||
throw err;
|
||||
})
|
||||
);
|
||||
}
|
||||
throw new Error('Server mode is required');
|
||||
}
|
||||
|
||||
// PUT /api/v1/agents/{id} - Update
|
||||
update(id: string, payload: Partial<Agent>): Observable<Agent | undefined> {
|
||||
if (USE_SERVER) {
|
||||
const apiPayload = this.transformToApiPayload(payload);
|
||||
return this.http
|
||||
.put<AgentApiResponse>(`${this.apiUrl}/${id}`, apiPayload, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
map((apiAgent) => this.transformAgent(apiAgent)),
|
||||
catchError((err) => {
|
||||
console.error(`Error updating agent ${id}:`, err);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(undefined);
|
||||
}
|
||||
|
||||
// DELETE /api/v1/agents/{id} - Delete
|
||||
delete(id: string): Observable<boolean> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.delete<void>(`${this.apiUrl}/${id}`, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map(() => true),
|
||||
catchError((err) => {
|
||||
console.error(`Error deleting agent ${id}:`, err);
|
||||
return of(false);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(false);
|
||||
}
|
||||
|
||||
// GET /api/v1/agents/ville/{ville} - List by ville
|
||||
getByVille(ville: string): Observable<Agent[]> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<AgentApiResponse[]>(`${this.apiUrl}/ville/${ville}`, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
map((list) => list.map((apiAgent) => this.transformAgent(apiAgent))),
|
||||
catchError((err) => {
|
||||
console.error(`Error fetching agents by ville ${ville}:`, err);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of([]);
|
||||
}
|
||||
|
||||
// GET /api/v1/agents/statut/{statut} - List by statut
|
||||
getByStatut(statut: AgentStatus): Observable<Agent[]> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<AgentApiResponse[]>(`${this.apiUrl}/statut/${statut}`, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
map((list) => list.map((apiAgent) => this.transformAgent(apiAgent))),
|
||||
catchError((err) => {
|
||||
console.error(`Error fetching agents by statut ${statut}:`, err);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of([]);
|
||||
}
|
||||
|
||||
// GET /api/v1/agents/search - Search by nom or prenom
|
||||
search(query: string): Observable<Agent[]> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<AgentApiResponse[]>(`${this.apiUrl}/search`, {
|
||||
params: { q: query.trim() },
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
map((list) => list.map((apiAgent) => this.transformAgent(apiAgent))),
|
||||
catchError((err) => {
|
||||
console.error(`Error searching agents with query ${query}:`, err);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of([]);
|
||||
}
|
||||
|
||||
// GET /api/v1/agents/code/{code} - Get by code
|
||||
getByCode(code: string): Observable<Agent | undefined> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<AgentApiResponse>(`${this.apiUrl}/code/${code}`, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map((apiAgent) => this.transformAgent(apiAgent)),
|
||||
catchError((err) => {
|
||||
console.error(`Error fetching agent by code ${code}:`, err);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(undefined);
|
||||
}
|
||||
|
||||
// Helper method to update all agents' limitId to a new default limit
|
||||
// This is used when a limit is set as default
|
||||
updateAllAgentsLimitId(limitId: string): Observable<boolean> {
|
||||
if (USE_SERVER) {
|
||||
// Get all agents first
|
||||
return this.list({
|
||||
page: 1,
|
||||
perPage: 10000,
|
||||
search: '',
|
||||
sortKey: 'code',
|
||||
sortDir: 'asc',
|
||||
} as any).pipe(
|
||||
switchMap((result) => {
|
||||
const agents = result.data;
|
||||
if (agents.length === 0) {
|
||||
return of(true);
|
||||
}
|
||||
// Update each agent's limitId in parallel
|
||||
const updateObservables = agents.map((agent) =>
|
||||
this.update(agent.id, { limitId }).pipe(
|
||||
catchError((err) => {
|
||||
console.error(`Error updating agent ${agent.id} limitId:`, err);
|
||||
return of(undefined);
|
||||
})
|
||||
)
|
||||
);
|
||||
// Wait for all updates to complete
|
||||
return forkJoin(updateObservables).pipe(
|
||||
map(() => true),
|
||||
catchError((err) => {
|
||||
console.error('Error updating all agents limitId:', err);
|
||||
return of(false);
|
||||
})
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error('Error fetching agents for limitId update:', err);
|
||||
return of(false);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(false);
|
||||
}
|
||||
}
|
||||
16
src/app/core/services/auth.spec.ts
Normal file
16
src/app/core/services/auth.spec.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { Auth } from './auth';
|
||||
|
||||
describe('Auth', () => {
|
||||
let service: Auth;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(Auth);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
||||
103
src/app/core/services/auth.ts
Normal file
103
src/app/core/services/auth.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { environment } from 'src/environments/environment.development';
|
||||
import { User } from '../interfaces/user';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
interface LoginRequest {
|
||||
identifiant: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
// Backend returns full user; no token specified in current spec.
|
||||
type LoginResponse = any;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class Auth {
|
||||
private tokenKey = 'pmu_token';
|
||||
private userKey = 'pmu_user';
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
isAuthenticated(): boolean {
|
||||
return !!localStorage.getItem(this.tokenKey);
|
||||
}
|
||||
|
||||
getUser(): User | null {
|
||||
const raw = localStorage.getItem(this.userKey);
|
||||
return raw ? (JSON.parse(raw) as User) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si l'utilisateur connecté possède un roleId donné.
|
||||
*/
|
||||
hasRoleId(roleId: string): boolean {
|
||||
const user = this.getUser();
|
||||
if (!user?.roleId) return false;
|
||||
return String(user.roleId) === String(roleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si l'utilisateur possède l'un des rôles attendus (par id).
|
||||
*/
|
||||
hasAnyRoleId(roleIds: string[]): boolean {
|
||||
const user = this.getUser();
|
||||
if (!user?.roleId) return false;
|
||||
return roleIds.map(String).includes(String(user.roleId));
|
||||
}
|
||||
|
||||
getToken(): string | null {
|
||||
return localStorage.getItem(this.tokenKey);
|
||||
}
|
||||
|
||||
setToken(token: string) {
|
||||
localStorage.setItem(this.tokenKey, token);
|
||||
}
|
||||
|
||||
logout() {
|
||||
localStorage.removeItem(this.tokenKey);
|
||||
localStorage.removeItem(this.userKey);
|
||||
}
|
||||
|
||||
private setSession(token: string, user: User) {
|
||||
localStorage.setItem(this.tokenKey, token);
|
||||
localStorage.setItem(this.userKey, JSON.stringify(user));
|
||||
}
|
||||
|
||||
async login(identifiant: string, password: string) {
|
||||
const url = `${environment.apiBaseUrl}/api/v1/auth/login`;
|
||||
const body: LoginRequest = { identifiant, password };
|
||||
|
||||
const res = (await firstValueFrom(this.http.post<LoginResponse>(url, body))) as any;
|
||||
|
||||
if (!res) {
|
||||
throw new Error('Réponse de connexion invalide');
|
||||
}
|
||||
|
||||
// Map backend user to frontend User model
|
||||
const user: User = {
|
||||
id: String(res.id),
|
||||
nom: res.nom,
|
||||
prenom: res.prenom,
|
||||
identifiant: res.identifiant,
|
||||
matriculeAgent: res.matriculeAgent,
|
||||
roleId: String(res.roleId),
|
||||
restrictionConnexion: !!res.restrictionConnexion,
|
||||
restrictionAutomatique: !!res.restrictionAutomatique,
|
||||
nombreIpAutorise: res.nombreIpAutorise ?? 0,
|
||||
nombreIpAutoAutorise: res.nombreIpAutoAutorise ?? 0,
|
||||
statut: res.statut ?? 'ACTIVE',
|
||||
derniereConnexion: res.derniereConnexion,
|
||||
createdAt: res.createdAt,
|
||||
updatedAt: res.updatedAt,
|
||||
};
|
||||
|
||||
// Backend spec does not expose a token yet; we set a dummy non-empty token
|
||||
// so that authGuard & interceptors keep working.
|
||||
const token = (res && (res.token || res.accessToken)) || 'session';
|
||||
this.setSession(token, user);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
16
src/app/core/services/course-sample.spec.ts
Normal file
16
src/app/core/services/course-sample.spec.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { CourseSample } from './course-sample';
|
||||
|
||||
describe('CourseSample', () => {
|
||||
let service: CourseSample;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(CourseSample);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
||||
38
src/app/core/services/course-sample.ts
Normal file
38
src/app/core/services/course-sample.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// src/app/features/courses/course.service.ts
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { PaginatedHttpService } from '@shared/paging/paginated-http.service';
|
||||
import { BackendConfig, ListParams, PagedResult } from '@shared/paging/paging';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
export interface Course {
|
||||
id: string;
|
||||
numero: number;
|
||||
nom: string;
|
||||
type_course: string;
|
||||
depart_at: string | null;
|
||||
statut: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class CourseService {
|
||||
private http = inject(PaginatedHttpService);
|
||||
private base = '/api/courses';
|
||||
|
||||
list(params: ListParams): Observable<PagedResult<Course>> {
|
||||
const cfg: BackendConfig = {
|
||||
zeroBasedPageIndex: true,
|
||||
buildSort: (key, dir) => (key && dir ? ['sort', `${key},${dir}`] : null),
|
||||
mapClientSortKey: (k) => {
|
||||
const map: Record<string, string> = {
|
||||
depart_at: 'departAt',
|
||||
type_course: 'type',
|
||||
numero: 'numero',
|
||||
nom: 'nom',
|
||||
statut: 'statut',
|
||||
};
|
||||
return k ? map[k] ?? k : undefined;
|
||||
},
|
||||
};
|
||||
return this.http.fetch<Course>(this.base, params, cfg);
|
||||
}
|
||||
}
|
||||
16
src/app/core/services/course.spec.ts
Normal file
16
src/app/core/services/course.spec.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { Course } from './course';
|
||||
|
||||
describe('Course', () => {
|
||||
let service: Course;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(Course);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
||||
874
src/app/core/services/course.ts
Normal file
874
src/app/core/services/course.ts
Normal file
@@ -0,0 +1,874 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, of, forkJoin } from 'rxjs';
|
||||
import { map, catchError, switchMap } from 'rxjs/operators';
|
||||
import { Course, CourseType, CourseStatut } from '../interfaces/course';
|
||||
import { normalizePage } from '@shared/paging/normalize-page';
|
||||
import { PaginatedHttpService } from '@shared/paging/paginated-http.service';
|
||||
import { ListParams, PagedResult } from '@shared/paging/paging';
|
||||
import { environment } from 'src/environments/environment.development';
|
||||
import { Reunion } from '../interfaces/reunion';
|
||||
import { ReunionService } from './reunion';
|
||||
import { NonPartantService } from './non-partant';
|
||||
|
||||
const USE_SERVER = true;
|
||||
const API_BASE = '/api/v1/courses';
|
||||
// Interface to match the API response structure for Course
|
||||
interface CourseApiResponse {
|
||||
id: number;
|
||||
type: string;
|
||||
numero: number;
|
||||
nom: string;
|
||||
dateDepartCourse: string;
|
||||
dateDebutParis: string;
|
||||
dateFinParis: string;
|
||||
reunionId: number; // API returns reunionId
|
||||
reunionCourse: number;
|
||||
particularite?: string;
|
||||
partants: number;
|
||||
distance: number;
|
||||
condition?: string;
|
||||
estTerminee: boolean;
|
||||
estAnnulee: boolean;
|
||||
statut: CourseStatut;
|
||||
nombreChevauxInscrits: number;
|
||||
createdBy: string;
|
||||
validatedBy: string | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
nonPartants: string[];
|
||||
adeadHeat: boolean;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class CourseService {
|
||||
private apiUrl = environment.apiBaseUrl + API_BASE;
|
||||
|
||||
constructor(
|
||||
private http: HttpClient,
|
||||
private paginatedHttp: PaginatedHttpService,
|
||||
private reunionService: ReunionService, // Inject ReunionService
|
||||
private nonPartantService: NonPartantService // Inject NonPartantService
|
||||
) {}
|
||||
|
||||
// Helper method to get ngrok bypass headers
|
||||
private getNgrokHeaders(): Record<string, string> {
|
||||
const isNgrok =
|
||||
environment.apiBaseUrl.includes('ngrok-free.app') ||
|
||||
environment.apiBaseUrl.includes('ngrok.io') ||
|
||||
environment.apiBaseUrl.includes('ngrok');
|
||||
return isNgrok ? { 'ngrok-skip-browser-warning': 'true' } : {};
|
||||
}
|
||||
|
||||
list(
|
||||
params: ListParams,
|
||||
usePaginationEndpoint: boolean = false
|
||||
): Observable<PagedResult<Course>> {
|
||||
if (USE_SERVER) {
|
||||
// If there's a search query, use the search endpoint
|
||||
if (params.search && params.search.trim()) {
|
||||
return this.search(params.search.trim()).pipe(
|
||||
map((courses) => {
|
||||
// Apply client-side sorting and pagination
|
||||
let filtered = [...courses];
|
||||
|
||||
// Sort
|
||||
if (params.sortKey && params.sortDir) {
|
||||
const { sortKey, sortDir } = params;
|
||||
filtered.sort((a: any, b: any) => {
|
||||
const getValue = (obj: any, path: string): any =>
|
||||
path.split('.').reduce((o, key) => o?.[key], obj);
|
||||
|
||||
const va = getValue(a, sortKey);
|
||||
const vb = getValue(b, sortKey);
|
||||
const sa = va == null ? '' : String(va);
|
||||
const sb = vb == null ? '' : String(vb);
|
||||
const cmp = sa.localeCompare(sb, 'fr', { numeric: true });
|
||||
return sortDir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
}
|
||||
|
||||
const total = filtered.length;
|
||||
const start = (params.page - 1) * params.perPage;
|
||||
const pageData = filtered.slice(start, start + params.perPage);
|
||||
|
||||
const totalByType = filtered.reduce<Record<string, number>>((acc, c) => {
|
||||
const type = String(c.type);
|
||||
acc[type] = (acc[type] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
const totalRunning = filtered.filter(
|
||||
(c) => c.statut === CourseStatut.RUNNING || c.statut === 'RUNNING'
|
||||
).length;
|
||||
const totalClosed = filtered.filter(
|
||||
(c) => c.statut === CourseStatut.CLOSED || c.statut === 'CLOSED'
|
||||
).length;
|
||||
|
||||
return normalizePage<Course>(
|
||||
{
|
||||
data: pageData,
|
||||
meta: {
|
||||
total,
|
||||
totalRunning,
|
||||
totalClosed,
|
||||
totalByType,
|
||||
},
|
||||
},
|
||||
params.page,
|
||||
params.perPage
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error('Error searching courses:', err);
|
||||
return of(
|
||||
normalizePage<Course>(
|
||||
{
|
||||
data: [],
|
||||
meta: {
|
||||
total: 0,
|
||||
totalRunning: 0,
|
||||
totalClosed: 0,
|
||||
totalByType: {},
|
||||
},
|
||||
},
|
||||
params.page,
|
||||
params.perPage
|
||||
)
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (usePaginationEndpoint) {
|
||||
return this.paginatedHttp
|
||||
.fetch<CourseApiResponse>(this.apiUrl, params, {
|
||||
zeroBasedPageIndex: false,
|
||||
})
|
||||
.pipe(
|
||||
switchMap((pagedResult) => {
|
||||
// Handle empty data case
|
||||
if (!pagedResult.data || pagedResult.data.length === 0) {
|
||||
return of(
|
||||
normalizePage<Course>(
|
||||
{
|
||||
data: [],
|
||||
meta: {
|
||||
total: pagedResult.meta?.total ?? 0,
|
||||
totalRunning: 0,
|
||||
totalClosed: 0,
|
||||
totalByType: {},
|
||||
},
|
||||
},
|
||||
params.page,
|
||||
params.perPage
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Extract unique reunionIds
|
||||
const uniqueReunionIds = [
|
||||
...new Set(pagedResult.data.map((c) => String(c.reunionId))),
|
||||
];
|
||||
|
||||
// If no reunion IDs, we can't build valid Reunion objects – return empty page
|
||||
if (uniqueReunionIds.length === 0) {
|
||||
return of(
|
||||
normalizePage<Course>(
|
||||
{
|
||||
data: [],
|
||||
meta: {
|
||||
total: pagedResult.meta?.total ?? 0,
|
||||
totalRunning: 0,
|
||||
totalClosed: 0,
|
||||
totalByType: {},
|
||||
},
|
||||
},
|
||||
params.page,
|
||||
params.perPage
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch all reunions in parallel
|
||||
const reunionRequests = uniqueReunionIds.map((id) =>
|
||||
this.reunionService
|
||||
.getById(id)
|
||||
.pipe(catchError(() => of<Reunion | undefined>(undefined)))
|
||||
);
|
||||
|
||||
return forkJoin(reunionRequests).pipe(
|
||||
map((reunions) => {
|
||||
// Create a map of reunionId -> Reunion
|
||||
const reunionMap = new Map<string, Reunion>();
|
||||
uniqueReunionIds.forEach((id, index) => {
|
||||
const reunion = reunions[index];
|
||||
if (reunion) {
|
||||
reunionMap.set(id, reunion);
|
||||
}
|
||||
});
|
||||
|
||||
// Transform API data to Course objects
|
||||
const transformedData: Course[] = pagedResult.data
|
||||
.map((apiCourse) => {
|
||||
const reunion = reunionMap.get(String(apiCourse.reunionId));
|
||||
if (!reunion) {
|
||||
// If we couldn't resolve the reunion, drop this course to keep typing sound
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: String(apiCourse.id),
|
||||
type: apiCourse.type,
|
||||
numero: apiCourse.numero,
|
||||
nom: apiCourse.nom,
|
||||
dateDepartCourse: apiCourse.dateDepartCourse,
|
||||
dateDebutParis: apiCourse.dateDebutParis,
|
||||
dateFinParis: apiCourse.dateFinParis,
|
||||
reunion,
|
||||
reunionCourse: apiCourse.reunionCourse,
|
||||
particularite: apiCourse.particularite,
|
||||
partants: apiCourse.partants,
|
||||
distance: apiCourse.distance,
|
||||
condition: apiCourse.condition,
|
||||
statut: apiCourse.statut as CourseStatut,
|
||||
nonPartants: apiCourse.nonPartants || [],
|
||||
estTerminee: apiCourse.estTerminee,
|
||||
estAnnulee: apiCourse.estAnnulee,
|
||||
nombreChevauxInscrits: apiCourse.nombreChevauxInscrits,
|
||||
adeadHeat: apiCourse.adeadHeat,
|
||||
createdBy: apiCourse.createdBy,
|
||||
validatedBy: apiCourse.validatedBy,
|
||||
createdAt: apiCourse.createdAt || new Date().toISOString(),
|
||||
updatedAt: apiCourse.updatedAt || new Date().toISOString(),
|
||||
} as Course;
|
||||
})
|
||||
.filter((c): c is Course => c !== null);
|
||||
|
||||
// Calculate meta stats
|
||||
const totalByType = transformedData.reduce<Record<string, number>>((acc, c) => {
|
||||
const type = String(c.type);
|
||||
acc[type] = (acc[type] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
const totalRunning = transformedData.filter(
|
||||
(c) => c.statut === CourseStatut.RUNNING || c.statut === 'RUNNING'
|
||||
).length;
|
||||
const totalClosed = transformedData.filter(
|
||||
(c) => c.statut === CourseStatut.CLOSED || c.statut === 'CLOSED'
|
||||
).length;
|
||||
|
||||
return normalizePage<Course>(
|
||||
{
|
||||
data: transformedData,
|
||||
meta: {
|
||||
total: pagedResult.meta?.total ?? transformedData.length,
|
||||
totalRunning,
|
||||
totalClosed,
|
||||
totalByType,
|
||||
},
|
||||
},
|
||||
params.page,
|
||||
params.perPage
|
||||
);
|
||||
})
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error('Error fetching courses:', err);
|
||||
return of(
|
||||
normalizePage<Course>(
|
||||
{
|
||||
data: [],
|
||||
meta: {
|
||||
total: 0,
|
||||
totalRunning: 0,
|
||||
totalClosed: 0,
|
||||
totalByType: {},
|
||||
},
|
||||
},
|
||||
params.page,
|
||||
params.perPage
|
||||
)
|
||||
);
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// Fetch all data and apply client-side pagination
|
||||
return this.http
|
||||
.get<CourseApiResponse[]>(this.apiUrl, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
switchMap((apiData) => {
|
||||
// Handle empty data case
|
||||
if (!apiData || apiData.length === 0) {
|
||||
return of(
|
||||
normalizePage<Course>(
|
||||
{
|
||||
data: [],
|
||||
meta: {
|
||||
total: 0,
|
||||
totalRunning: 0,
|
||||
totalClosed: 0,
|
||||
totalByType: {},
|
||||
},
|
||||
},
|
||||
params.page,
|
||||
params.perPage
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Extract unique reunionIds
|
||||
const uniqueReunionIds = [...new Set(apiData.map((c) => String(c.reunionId)))];
|
||||
|
||||
// Handle case where there are no unique IDs (shouldn't happen, but be safe)
|
||||
if (uniqueReunionIds.length === 0) {
|
||||
return of(
|
||||
normalizePage<Course>(
|
||||
{
|
||||
data: [],
|
||||
meta: {
|
||||
total: 0,
|
||||
totalRunning: 0,
|
||||
totalClosed: 0,
|
||||
totalByType: {},
|
||||
},
|
||||
},
|
||||
params.page,
|
||||
params.perPage
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch all reunions in parallel
|
||||
const reunionRequests = uniqueReunionIds.map((id) =>
|
||||
this.reunionService
|
||||
.getById(id)
|
||||
.pipe(catchError(() => of<Reunion | undefined>(undefined)))
|
||||
);
|
||||
|
||||
return forkJoin(reunionRequests).pipe(
|
||||
map((reunions) => {
|
||||
// Create a map of reunionId -> Reunion
|
||||
const reunionMap = new Map<string, Reunion>();
|
||||
uniqueReunionIds.forEach((id, index) => {
|
||||
const reunion = reunions[index];
|
||||
if (reunion) {
|
||||
reunionMap.set(id, reunion);
|
||||
}
|
||||
});
|
||||
|
||||
// Transform API data to Course objects
|
||||
const transformedData: Course[] = apiData
|
||||
.map((apiCourse) => {
|
||||
const reunion = reunionMap.get(String(apiCourse.reunionId));
|
||||
if (!reunion) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: String(apiCourse.id),
|
||||
type: apiCourse.type,
|
||||
numero: apiCourse.numero,
|
||||
nom: apiCourse.nom,
|
||||
dateDepartCourse: apiCourse.dateDepartCourse,
|
||||
dateDebutParis: apiCourse.dateDebutParis,
|
||||
dateFinParis: apiCourse.dateFinParis,
|
||||
reunion,
|
||||
reunionCourse: apiCourse.reunionCourse,
|
||||
particularite: apiCourse.particularite,
|
||||
partants: apiCourse.partants,
|
||||
distance: apiCourse.distance,
|
||||
condition: apiCourse.condition,
|
||||
statut: apiCourse.statut as CourseStatut,
|
||||
nonPartants: apiCourse.nonPartants || [],
|
||||
estTerminee: apiCourse.estTerminee,
|
||||
estAnnulee: apiCourse.estAnnulee,
|
||||
nombreChevauxInscrits: apiCourse.nombreChevauxInscrits,
|
||||
adeadHeat: apiCourse.adeadHeat,
|
||||
createdBy: apiCourse.createdBy,
|
||||
validatedBy: apiCourse.validatedBy,
|
||||
createdAt: apiCourse.createdAt || new Date().toISOString(),
|
||||
updatedAt: apiCourse.updatedAt || new Date().toISOString(),
|
||||
} as Course;
|
||||
})
|
||||
.filter((c): c is Course => c !== null);
|
||||
|
||||
// Apply client-side filtering, sorting, and pagination
|
||||
let filtered = this.applyClientFilters(transformedData, params);
|
||||
const total = filtered.length;
|
||||
const start = (params.page - 1) * params.perPage;
|
||||
const pageData = filtered.slice(start, start + params.perPage);
|
||||
|
||||
const totalByType = filtered.reduce<Record<string, number>>((acc, c) => {
|
||||
const type = String(c.type);
|
||||
acc[type] = (acc[type] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
const totalRunning = filtered.filter(
|
||||
(c) => c.statut === CourseStatut.RUNNING || c.statut === 'RUNNING'
|
||||
).length;
|
||||
const totalClosed = filtered.filter(
|
||||
(c) => c.statut === CourseStatut.CLOSED || c.statut === 'CLOSED'
|
||||
).length;
|
||||
|
||||
return normalizePage<Course>(
|
||||
{
|
||||
data: pageData,
|
||||
meta: {
|
||||
total,
|
||||
totalRunning,
|
||||
totalClosed,
|
||||
totalByType,
|
||||
},
|
||||
},
|
||||
params.page,
|
||||
params.perPage
|
||||
);
|
||||
})
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error('Error fetching courses:', err);
|
||||
return of(
|
||||
normalizePage<Course>(
|
||||
{
|
||||
data: [],
|
||||
meta: {
|
||||
total: 0,
|
||||
totalRunning: 0,
|
||||
totalClosed: 0,
|
||||
totalByType: {},
|
||||
},
|
||||
},
|
||||
params.page,
|
||||
params.perPage
|
||||
)
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// If USE_SERVER is false, return empty result
|
||||
return of(
|
||||
normalizePage<Course>(
|
||||
{
|
||||
data: [],
|
||||
meta: {
|
||||
total: 0,
|
||||
totalRunning: 0,
|
||||
totalClosed: 0,
|
||||
totalByType: {},
|
||||
},
|
||||
},
|
||||
params.page,
|
||||
params.perPage
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private applyClientFilters(data: Course[], params: ListParams): Course[] {
|
||||
let filtered = [...data];
|
||||
|
||||
// Search filter
|
||||
const q = (params.search ?? '').toLowerCase();
|
||||
if (q) {
|
||||
filtered = filtered.filter((c) => {
|
||||
const reunionName = c.reunion?.nom?.toLowerCase?.() ?? '';
|
||||
const hippodromeName = c.reunion?.hippodrome?.nom?.toLowerCase?.() ?? '';
|
||||
return (
|
||||
c.nom.toLowerCase().includes(q) ||
|
||||
c.type.toLowerCase().includes(q) ||
|
||||
reunionName.includes(q) ||
|
||||
hippodromeName.includes(q)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Sort
|
||||
if (params.sortKey && params.sortDir) {
|
||||
const { sortKey, sortDir } = params;
|
||||
filtered.sort((a: any, b: any) => {
|
||||
const getValue = (obj: any, path: string): any =>
|
||||
path.split('.').reduce((o, key) => o?.[key], obj);
|
||||
|
||||
const va = getValue(a, sortKey);
|
||||
const vb = getValue(b, sortKey);
|
||||
const sa = va == null ? '' : String(va);
|
||||
const sb = vb == null ? '' : String(vb);
|
||||
const cmp = sa.localeCompare(sb, 'fr', { numeric: true });
|
||||
return sortDir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
getById(id: string): Observable<Course | undefined> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<CourseApiResponse>(`${this.apiUrl}/${id}`, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
switchMap((apiCourse) => {
|
||||
// Fetch the reunion (non-partants are already included in the API response)
|
||||
return this.reunionService.getById(String(apiCourse.reunionId)).pipe(
|
||||
map((reunion) => {
|
||||
if (!reunion) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
id: String(apiCourse.id),
|
||||
type: apiCourse.type,
|
||||
numero: apiCourse.numero,
|
||||
nom: apiCourse.nom,
|
||||
dateDepartCourse: apiCourse.dateDepartCourse,
|
||||
dateDebutParis: apiCourse.dateDebutParis,
|
||||
dateFinParis: apiCourse.dateFinParis,
|
||||
reunion,
|
||||
reunionCourse: apiCourse.reunionCourse,
|
||||
particularite: apiCourse.particularite,
|
||||
partants: apiCourse.partants,
|
||||
distance: apiCourse.distance,
|
||||
condition: apiCourse.condition,
|
||||
statut: apiCourse.statut as CourseStatut,
|
||||
nonPartants: apiCourse.nonPartants || [],
|
||||
estTerminee: apiCourse.estTerminee,
|
||||
estAnnulee: apiCourse.estAnnulee,
|
||||
nombreChevauxInscrits: apiCourse.nombreChevauxInscrits,
|
||||
adeadHeat: apiCourse.adeadHeat,
|
||||
createdBy: apiCourse.createdBy,
|
||||
validatedBy: apiCourse.validatedBy,
|
||||
createdAt: apiCourse.createdAt || new Date().toISOString(),
|
||||
updatedAt: apiCourse.updatedAt || new Date().toISOString(),
|
||||
};
|
||||
})
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error(`Error fetching course ${id}:`, err);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(undefined);
|
||||
}
|
||||
|
||||
getByReunionId(reunionId: string): Observable<Course[]> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<CourseApiResponse[]>(`${this.apiUrl}/reunion/${reunionId}`, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
switchMap((apiData) => {
|
||||
// Fetch the reunion once
|
||||
return this.reunionService.getById(reunionId).pipe(
|
||||
map((reunion) => {
|
||||
if (!reunion) {
|
||||
return [];
|
||||
}
|
||||
// Transform all courses with the same reunion
|
||||
return apiData.map((apiCourse) => ({
|
||||
id: String(apiCourse.id),
|
||||
type: apiCourse.type,
|
||||
numero: apiCourse.numero,
|
||||
nom: apiCourse.nom,
|
||||
dateDepartCourse: apiCourse.dateDepartCourse,
|
||||
dateDebutParis: apiCourse.dateDebutParis,
|
||||
dateFinParis: apiCourse.dateFinParis,
|
||||
reunion,
|
||||
reunionCourse: apiCourse.reunionCourse,
|
||||
particularite: apiCourse.particularite,
|
||||
partants: apiCourse.partants,
|
||||
distance: apiCourse.distance,
|
||||
condition: apiCourse.condition,
|
||||
statut: apiCourse.statut as CourseStatut,
|
||||
nonPartants: apiCourse.nonPartants || [],
|
||||
estTerminee: apiCourse.estTerminee,
|
||||
estAnnulee: apiCourse.estAnnulee,
|
||||
nombreChevauxInscrits: apiCourse.nombreChevauxInscrits,
|
||||
adeadHeat: apiCourse.adeadHeat,
|
||||
createdBy: apiCourse.createdBy,
|
||||
validatedBy: apiCourse.validatedBy,
|
||||
createdAt: apiCourse.createdAt || new Date().toISOString(),
|
||||
updatedAt: apiCourse.updatedAt || new Date().toISOString(),
|
||||
}));
|
||||
})
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error(`Error fetching courses for reunion ${reunionId}:`, err);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of([]);
|
||||
}
|
||||
|
||||
search(query: string): Observable<Course[]> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<CourseApiResponse[]>(`${this.apiUrl}/search`, {
|
||||
params: { q: query.trim() },
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
switchMap((apiData) => {
|
||||
// Extract unique reunionIds
|
||||
const uniqueReunionIds = [...new Set(apiData.map((c) => String(c.reunionId)))];
|
||||
|
||||
// Fetch all reunions in parallel
|
||||
const reunionRequests = uniqueReunionIds.map((id) =>
|
||||
this.reunionService
|
||||
.getById(id)
|
||||
.pipe(catchError(() => of<Reunion | undefined>(undefined)))
|
||||
);
|
||||
|
||||
return forkJoin(reunionRequests).pipe(
|
||||
map((reunions) => {
|
||||
// Create a map of reunionId -> Reunion
|
||||
const reunionMap = new Map<string, Reunion>();
|
||||
uniqueReunionIds.forEach((id, index) => {
|
||||
const reunion = reunions[index];
|
||||
if (reunion) {
|
||||
reunionMap.set(id, reunion);
|
||||
}
|
||||
});
|
||||
|
||||
// Transform API data to Course objects
|
||||
return apiData
|
||||
.map((apiCourse) => {
|
||||
const reunion = reunionMap.get(String(apiCourse.reunionId));
|
||||
if (!reunion) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: String(apiCourse.id),
|
||||
type: apiCourse.type,
|
||||
numero: apiCourse.numero,
|
||||
nom: apiCourse.nom,
|
||||
dateDepartCourse: apiCourse.dateDepartCourse,
|
||||
dateDebutParis: apiCourse.dateDebutParis,
|
||||
dateFinParis: apiCourse.dateFinParis,
|
||||
reunion,
|
||||
reunionCourse: apiCourse.reunionCourse,
|
||||
particularite: apiCourse.particularite,
|
||||
partants: apiCourse.partants,
|
||||
distance: apiCourse.distance,
|
||||
condition: apiCourse.condition,
|
||||
statut: apiCourse.statut as CourseStatut,
|
||||
nonPartants: apiCourse.nonPartants || [],
|
||||
estTerminee: apiCourse.estTerminee,
|
||||
estAnnulee: apiCourse.estAnnulee,
|
||||
nombreChevauxInscrits: apiCourse.nombreChevauxInscrits,
|
||||
adeadHeat: apiCourse.adeadHeat,
|
||||
createdBy: apiCourse.createdBy,
|
||||
validatedBy: apiCourse.validatedBy,
|
||||
createdAt: apiCourse.createdAt || new Date().toISOString(),
|
||||
updatedAt: apiCourse.updatedAt || new Date().toISOString(),
|
||||
} as Course;
|
||||
})
|
||||
.filter((c): c is Course => c !== null);
|
||||
})
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error(`Error searching courses with query ${query}:`, err);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of([]);
|
||||
}
|
||||
|
||||
create(payload: Omit<Course, 'id' | 'nonPartants'>): Observable<Course> {
|
||||
if (USE_SERVER) {
|
||||
// Transform payload to API format (send reunionId instead of reunion object)
|
||||
const apiPayload: any = {
|
||||
type: payload.type,
|
||||
numero: payload.numero,
|
||||
nom: payload.nom,
|
||||
dateDepartCourse: payload.dateDepartCourse,
|
||||
dateDebutParis: payload.dateDebutParis,
|
||||
dateFinParis: payload.dateFinParis,
|
||||
reunionId: typeof payload.reunion === 'object' ? payload.reunion.id : payload.reunion,
|
||||
reunionCourse: payload.reunionCourse,
|
||||
particularite: payload.particularite,
|
||||
partants: payload.partants,
|
||||
distance: payload.distance,
|
||||
condition: payload.condition,
|
||||
statut: payload.statut,
|
||||
createdBy: payload.createdBy,
|
||||
validatedBy: payload.validatedBy,
|
||||
};
|
||||
|
||||
return this.http
|
||||
.post<CourseApiResponse>(this.apiUrl, apiPayload, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
switchMap((apiCourse) => {
|
||||
// Fetch the reunion to build the full Course object
|
||||
return this.reunionService.getById(String(apiCourse.reunionId)).pipe(
|
||||
map((reunion) => {
|
||||
if (!reunion) {
|
||||
throw new Error('Reunion not found');
|
||||
}
|
||||
const item: Course = {
|
||||
id: String(apiCourse.id),
|
||||
type: apiCourse.type,
|
||||
numero: apiCourse.numero,
|
||||
nom: apiCourse.nom,
|
||||
dateDepartCourse: apiCourse.dateDepartCourse,
|
||||
dateDebutParis: apiCourse.dateDebutParis,
|
||||
dateFinParis: apiCourse.dateFinParis,
|
||||
reunion,
|
||||
reunionCourse: apiCourse.reunionCourse,
|
||||
particularite: apiCourse.particularite,
|
||||
partants: apiCourse.partants,
|
||||
distance: apiCourse.distance,
|
||||
condition: apiCourse.condition,
|
||||
statut: apiCourse.statut as CourseStatut,
|
||||
nonPartants: apiCourse.nonPartants || [],
|
||||
estTerminee: apiCourse.estTerminee,
|
||||
estAnnulee: apiCourse.estAnnulee,
|
||||
nombreChevauxInscrits: apiCourse.nombreChevauxInscrits,
|
||||
adeadHeat: apiCourse.adeadHeat,
|
||||
createdBy: apiCourse.createdBy,
|
||||
validatedBy: apiCourse.validatedBy,
|
||||
createdAt: apiCourse.createdAt || new Date().toISOString(),
|
||||
updatedAt: apiCourse.updatedAt || new Date().toISOString(),
|
||||
};
|
||||
return item;
|
||||
})
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error('Error creating course:', err);
|
||||
throw err;
|
||||
})
|
||||
);
|
||||
}
|
||||
throw new Error('Server mode is required');
|
||||
}
|
||||
|
||||
update(id: string, payload: Partial<Course>): Observable<Course | undefined> {
|
||||
if (USE_SERVER) {
|
||||
// Transform payload to API format (send reunionId instead of reunion object)
|
||||
const apiPayload: any = { ...payload };
|
||||
if (payload.reunion) {
|
||||
apiPayload.reunionId =
|
||||
typeof payload.reunion === 'object' ? payload.reunion.id : payload.reunion;
|
||||
delete apiPayload.reunion;
|
||||
}
|
||||
|
||||
return this.http
|
||||
.put<CourseApiResponse>(`${this.apiUrl}/${id}`, apiPayload, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
switchMap((apiCourse) => {
|
||||
// Fetch the reunion to build the full Course object
|
||||
return this.reunionService.getById(String(apiCourse.reunionId)).pipe(
|
||||
map((reunion) => {
|
||||
if (!reunion) {
|
||||
throw new Error('Reunion not found');
|
||||
}
|
||||
return {
|
||||
id: String(apiCourse.id),
|
||||
type: apiCourse.type,
|
||||
numero: apiCourse.numero,
|
||||
nom: apiCourse.nom,
|
||||
dateDepartCourse: apiCourse.dateDepartCourse,
|
||||
dateDebutParis: apiCourse.dateDebutParis,
|
||||
dateFinParis: apiCourse.dateFinParis,
|
||||
reunion,
|
||||
reunionCourse: apiCourse.reunionCourse,
|
||||
particularite: apiCourse.particularite,
|
||||
partants: apiCourse.partants,
|
||||
distance: apiCourse.distance,
|
||||
condition: apiCourse.condition,
|
||||
statut: apiCourse.statut as CourseStatut,
|
||||
nonPartants: apiCourse.nonPartants || [],
|
||||
estTerminee: apiCourse.estTerminee,
|
||||
estAnnulee: apiCourse.estAnnulee,
|
||||
nombreChevauxInscrits: apiCourse.nombreChevauxInscrits,
|
||||
adeadHeat: apiCourse.adeadHeat,
|
||||
createdBy: apiCourse.createdBy,
|
||||
validatedBy: apiCourse.validatedBy,
|
||||
createdAt: apiCourse.createdAt || new Date().toISOString(),
|
||||
updatedAt: apiCourse.updatedAt || new Date().toISOString(),
|
||||
};
|
||||
})
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error(`Error updating course ${id}:`, err);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
throw new Error('Server mode is required');
|
||||
}
|
||||
|
||||
updateStatut(id: string, statut: CourseStatut): Observable<Course | undefined> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.patch<Course>(
|
||||
`${this.apiUrl}/${id}/statut`,
|
||||
{ statut },
|
||||
{ headers: this.getNgrokHeaders() }
|
||||
)
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
console.error(`Error updating course statut ${id}:`, err);
|
||||
return this.update(id, { statut });
|
||||
})
|
||||
);
|
||||
}
|
||||
return this.update(id, { statut });
|
||||
}
|
||||
|
||||
delete(id: string): Observable<boolean> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.delete<void>(`${this.apiUrl}/${id}`, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map(() => true),
|
||||
catchError((err) => {
|
||||
console.error(`Error deleting course ${id}:`, err);
|
||||
return of(false);
|
||||
})
|
||||
);
|
||||
}
|
||||
throw new Error('Server mode is required');
|
||||
}
|
||||
|
||||
addNonPartant(courseId: string, npList: string[]) {
|
||||
console.warn('addNonPartant is deprecated. Use setNonPartants instead.');
|
||||
return this.setNonPartants(courseId, npList);
|
||||
}
|
||||
|
||||
setNonPartants(courseId: string, npList: string[]): Observable<Course | undefined> {
|
||||
if (USE_SERVER) {
|
||||
// Use PUT endpoint to replace the entire list
|
||||
return this.nonPartantService.replaceNonPartants(courseId, npList).pipe(
|
||||
switchMap((updatedNonPartants) => {
|
||||
// Fetch the updated course to return it
|
||||
return this.getById(courseId).pipe(
|
||||
map((course) => {
|
||||
if (course) {
|
||||
return {
|
||||
...course,
|
||||
nonPartants: updatedNonPartants,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
})
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error(`Error setting nonPartants for course ${courseId}:`, err);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
throw new Error('Server mode is required');
|
||||
}
|
||||
}
|
||||
16
src/app/core/services/hippodrome.spec.ts
Normal file
16
src/app/core/services/hippodrome.spec.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { Hippodrome } from './hippodrome';
|
||||
|
||||
describe('Hippodrome', () => {
|
||||
let service: Hippodrome;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(Hippodrome);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
||||
547
src/app/core/services/hippodrome.ts
Normal file
547
src/app/core/services/hippodrome.ts
Normal file
@@ -0,0 +1,547 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, of, forkJoin } from 'rxjs';
|
||||
import { map, catchError, switchMap } from 'rxjs/operators';
|
||||
import { Hippodrome } from '../interfaces/hippodrome';
|
||||
import { normalizePage } from '@shared/paging/normalize-page';
|
||||
import { PaginatedHttpService } from '@shared/paging/paginated-http.service';
|
||||
import { ListParams, PagedResult } from '@shared/paging/paging';
|
||||
import { environment } from 'src/environments/environment.development';
|
||||
|
||||
const USE_SERVER = true;
|
||||
const API_BASE = '/api/v1/hippodromes';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class HippodromeService {
|
||||
private apiUrl = environment.apiBaseUrl + API_BASE;
|
||||
private store = signal<Hippodrome[]>([]);
|
||||
|
||||
constructor(private http: HttpClient, private paginatedHttp: PaginatedHttpService) {}
|
||||
|
||||
// Helper method to get ngrok bypass headers
|
||||
private getNgrokHeaders(): Record<string, string> {
|
||||
const isNgrok =
|
||||
this.apiUrl.includes('ngrok-free.app') ||
|
||||
this.apiUrl.includes('ngrok.io') ||
|
||||
this.apiUrl.includes('ngrok');
|
||||
return isNgrok ? { 'ngrok-skip-browser-warning': 'true' } : {};
|
||||
}
|
||||
|
||||
// LISTE — supporte client & serveur
|
||||
list(
|
||||
params: ListParams,
|
||||
usePaginationEndpoint: boolean = false
|
||||
): Observable<PagedResult<Hippodrome>> {
|
||||
if (USE_SERVER) {
|
||||
// If there's a search query, use the search endpoint
|
||||
if (params.search && params.search.trim()) {
|
||||
return this.search(params.search.trim()).pipe(
|
||||
switchMap((hippodromes) => {
|
||||
// Fetch all reunions and courses to calculate counts
|
||||
return forkJoin({
|
||||
reunions: this.http
|
||||
.get<any[]>(`${environment.apiBaseUrl}/api/v1/reunions`, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
catchError(() => of([])),
|
||||
map((data) => ({ data: data || [], meta: { total: (data || []).length } }))
|
||||
),
|
||||
courses: this.http
|
||||
.get<any[]>(`${environment.apiBaseUrl}/api/v1/courses`, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
catchError(() => of([])),
|
||||
map((data) => ({ data: data || [], meta: { total: (data || []).length } }))
|
||||
),
|
||||
}).pipe(
|
||||
map(({ reunions, courses }) => {
|
||||
// Count reunions per hippodrome
|
||||
const reunionCountMap = new Map<string, number>();
|
||||
reunions.data.forEach((reunion: any) => {
|
||||
const hippodromeId = String(reunion.hippodromeId || reunion.hippodrome?.id);
|
||||
if (hippodromeId && hippodromeId !== 'undefined' && hippodromeId !== 'null') {
|
||||
reunionCountMap.set(hippodromeId, (reunionCountMap.get(hippodromeId) || 0) + 1);
|
||||
}
|
||||
});
|
||||
|
||||
// Create a map of reunionId -> hippodromeId from reunions
|
||||
const reunionToHippodromeMap = new Map<string, string>();
|
||||
reunions.data.forEach((reunion: any) => {
|
||||
const reunionId = String(reunion.id);
|
||||
const hippodromeId = String(reunion.hippodromeId || reunion.hippodrome?.id);
|
||||
if (
|
||||
reunionId &&
|
||||
reunionId !== 'undefined' &&
|
||||
reunionId !== 'null' &&
|
||||
hippodromeId &&
|
||||
hippodromeId !== 'undefined' &&
|
||||
hippodromeId !== 'null'
|
||||
) {
|
||||
reunionToHippodromeMap.set(reunionId, hippodromeId);
|
||||
}
|
||||
});
|
||||
|
||||
// Count courses per hippodrome using the reunion -> hippodrome mapping
|
||||
const courseCountMap = new Map<string, number>();
|
||||
courses.data.forEach((course: any) => {
|
||||
const reunionId = String(course.reunionId || course.reunion?.id);
|
||||
if (reunionId && reunionId !== 'undefined' && reunionId !== 'null') {
|
||||
const hippodromeId = reunionToHippodromeMap.get(reunionId);
|
||||
if (hippodromeId) {
|
||||
courseCountMap.set(hippodromeId, (courseCountMap.get(hippodromeId) || 0) + 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Add counts to hippodromes
|
||||
const hippodromesWithCounts = hippodromes.map((h) => ({
|
||||
...h,
|
||||
reunionCount: reunionCountMap.get(String(h.id)) ?? 0,
|
||||
courseCount: courseCountMap.get(String(h.id)) ?? 0,
|
||||
}));
|
||||
|
||||
// Apply client-side sorting and pagination
|
||||
let filtered = this.applyClientFilters(hippodromesWithCounts, {
|
||||
...params,
|
||||
search: '', // Already filtered by search endpoint
|
||||
});
|
||||
const total = filtered.length;
|
||||
const start = (params.page - 1) * params.perPage;
|
||||
const pageData = filtered.slice(start, start + params.perPage);
|
||||
|
||||
const uniqueCountries = new Set(filtered.map((h) => h.pays)).size;
|
||||
const uniqueCities = new Set(filtered.map((h) => h.ville)).size;
|
||||
const averageByCountry = filtered.length
|
||||
? Math.round(filtered.length / uniqueCountries)
|
||||
: 0;
|
||||
const totalReunions = filtered.reduce((acc, h) => acc + (h.reunionCount ?? 0), 0);
|
||||
const totalCourses = filtered.reduce((acc, h) => acc + (h.courseCount ?? 0), 0);
|
||||
|
||||
return normalizePage<Hippodrome>(
|
||||
{
|
||||
data: pageData,
|
||||
meta: {
|
||||
total,
|
||||
uniqueCountries,
|
||||
uniqueCities,
|
||||
averageByCountry,
|
||||
totalReunions,
|
||||
totalCourses,
|
||||
},
|
||||
},
|
||||
params.page,
|
||||
params.perPage
|
||||
);
|
||||
})
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error('Error searching hippodromes:', err);
|
||||
return of(
|
||||
normalizePage<Hippodrome>(
|
||||
{
|
||||
data: [],
|
||||
meta: {
|
||||
total: 0,
|
||||
uniqueCountries: 0,
|
||||
uniqueCities: 0,
|
||||
averageByCountry: 0,
|
||||
totalReunions: 0,
|
||||
totalCourses: 0,
|
||||
},
|
||||
},
|
||||
params.page,
|
||||
params.perPage
|
||||
)
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (usePaginationEndpoint) {
|
||||
return this.paginatedHttp
|
||||
.fetch<Hippodrome>(this.apiUrl, params, {
|
||||
zeroBasedPageIndex: false,
|
||||
buildSort: (key, dir) => (key && dir ? ['sort', `${key},${dir}`] : null),
|
||||
mapClientSortKey: (k) => {
|
||||
const alias: Record<string, string> = {
|
||||
name: 'nom',
|
||||
city: 'ville',
|
||||
country: 'pays',
|
||||
};
|
||||
return k ? alias[k] ?? k : undefined;
|
||||
},
|
||||
})
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
console.error('Error fetching hippodromes:', err);
|
||||
return of(
|
||||
normalizePage<Hippodrome>(
|
||||
{
|
||||
data: [],
|
||||
meta: {
|
||||
total: 0,
|
||||
uniqueCountries: 0,
|
||||
uniqueCities: 0,
|
||||
averageByCountry: 0,
|
||||
totalReunions: 0,
|
||||
totalCourses: 0,
|
||||
},
|
||||
},
|
||||
params.page,
|
||||
params.perPage
|
||||
)
|
||||
);
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// Fetch all data and apply client-side pagination
|
||||
return this.http
|
||||
.get<Hippodrome[]>(`${this.apiUrl}/actifs`, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
switchMap((allData) => {
|
||||
// Fetch all reunions and courses directly from API to calculate counts
|
||||
// We fetch directly to avoid circular dependency with ReunionService and CourseService
|
||||
return forkJoin({
|
||||
reunions: this.http
|
||||
.get<any[]>(`${environment.apiBaseUrl}/api/v1/reunions`, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
catchError(() => of([])),
|
||||
map((data) => ({ data, meta: { total: data.length } }))
|
||||
),
|
||||
courses: this.http
|
||||
.get<any[]>(`${environment.apiBaseUrl}/api/v1/courses`, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
catchError(() => of([])),
|
||||
map((data) => ({ data, meta: { total: data.length } }))
|
||||
),
|
||||
}).pipe(
|
||||
map(({ reunions, courses }) => {
|
||||
// Count reunions per hippodrome
|
||||
const reunionCountMap = new Map<string, number>();
|
||||
reunions.data.forEach((reunion: any) => {
|
||||
const hippodromeId = String(reunion.hippodromeId || reunion.hippodrome?.id);
|
||||
if (hippodromeId && hippodromeId !== 'undefined' && hippodromeId !== 'null') {
|
||||
reunionCountMap.set(
|
||||
hippodromeId,
|
||||
(reunionCountMap.get(hippodromeId) || 0) + 1
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Create a map of reunionId -> hippodromeId from reunions
|
||||
const reunionToHippodromeMap = new Map<string, string>();
|
||||
reunions.data.forEach((reunion: any) => {
|
||||
const reunionId = String(reunion.id);
|
||||
const hippodromeId = String(reunion.hippodromeId || reunion.hippodrome?.id);
|
||||
if (
|
||||
reunionId &&
|
||||
reunionId !== 'undefined' &&
|
||||
reunionId !== 'null' &&
|
||||
hippodromeId &&
|
||||
hippodromeId !== 'undefined' &&
|
||||
hippodromeId !== 'null'
|
||||
) {
|
||||
reunionToHippodromeMap.set(reunionId, hippodromeId);
|
||||
}
|
||||
});
|
||||
|
||||
// Count courses per hippodrome using the reunion -> hippodrome mapping
|
||||
const courseCountMap = new Map<string, number>();
|
||||
courses.data.forEach((course: any) => {
|
||||
const reunionId = String(course.reunionId || course.reunion?.id);
|
||||
if (reunionId && reunionId !== 'undefined' && reunionId !== 'null') {
|
||||
const hippodromeId = reunionToHippodromeMap.get(reunionId);
|
||||
if (hippodromeId) {
|
||||
courseCountMap.set(
|
||||
hippodromeId,
|
||||
(courseCountMap.get(hippodromeId) || 0) + 1
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Add counts to hippodromes
|
||||
const hippodromesWithCounts = allData.map((h) => ({
|
||||
...h,
|
||||
reunionCount: reunionCountMap.get(String(h.id)) ?? 0,
|
||||
courseCount: courseCountMap.get(String(h.id)) ?? 0,
|
||||
}));
|
||||
|
||||
// Apply client-side filtering, sorting, and pagination
|
||||
let filtered = this.applyClientFilters(hippodromesWithCounts, params);
|
||||
const total = filtered.length;
|
||||
const start = (params.page - 1) * params.perPage;
|
||||
const pageData = filtered.slice(start, start + params.perPage);
|
||||
|
||||
const uniqueCountries = new Set(filtered.map((h) => h.pays)).size;
|
||||
const uniqueCities = new Set(filtered.map((h) => h.ville)).size;
|
||||
const averageByCountry = filtered.length
|
||||
? Math.round(filtered.length / uniqueCountries)
|
||||
: 0;
|
||||
const totalReunions = filtered.reduce((acc, h) => acc + (h.reunionCount ?? 0), 0);
|
||||
const totalCourses = filtered.reduce((acc, h) => acc + (h.courseCount ?? 0), 0);
|
||||
|
||||
return normalizePage<Hippodrome>(
|
||||
{
|
||||
data: pageData,
|
||||
meta: {
|
||||
total,
|
||||
uniqueCountries,
|
||||
uniqueCities,
|
||||
averageByCountry,
|
||||
totalReunions,
|
||||
totalCourses,
|
||||
},
|
||||
},
|
||||
params.page,
|
||||
params.perPage
|
||||
);
|
||||
})
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error('Error fetching hippodromes:', err);
|
||||
return of(
|
||||
normalizePage<Hippodrome>(
|
||||
{
|
||||
data: [],
|
||||
meta: {
|
||||
total: 0,
|
||||
uniqueCountries: 0,
|
||||
uniqueCities: 0,
|
||||
averageByCountry: 0,
|
||||
totalReunions: 0,
|
||||
totalCourses: 0,
|
||||
},
|
||||
},
|
||||
params.page,
|
||||
params.perPage
|
||||
)
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Mock mode disabled - return empty result
|
||||
return of(
|
||||
normalizePage<Hippodrome>(
|
||||
{
|
||||
data: [],
|
||||
meta: {
|
||||
total: 0,
|
||||
uniqueCountries: 0,
|
||||
uniqueCities: 0,
|
||||
averageByCountry: 0,
|
||||
totalReunions: 0,
|
||||
totalCourses: 0,
|
||||
},
|
||||
},
|
||||
params.page,
|
||||
params.perPage
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private applyClientFilters(data: Hippodrome[], params: ListParams): Hippodrome[] {
|
||||
let filtered = [...data];
|
||||
|
||||
// Search filter
|
||||
const q = (params.search ?? '').toLowerCase();
|
||||
if (q) {
|
||||
filtered = filtered.filter(
|
||||
(h) =>
|
||||
h.nom.toLowerCase().includes(q) ||
|
||||
h.ville.toLowerCase().includes(q) ||
|
||||
h.pays.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
// Sort
|
||||
if (params.sortKey && params.sortDir) {
|
||||
const { sortKey, sortDir } = params;
|
||||
filtered.sort((a: any, b: any) => {
|
||||
const va = a[sortKey!],
|
||||
vb = b[sortKey!];
|
||||
let cmp: number;
|
||||
|
||||
if (typeof va === 'number' && typeof vb === 'number') {
|
||||
cmp = va - vb;
|
||||
} else {
|
||||
const sa = va == null ? '' : String(va);
|
||||
const sb = vb == null ? '' : String(vb);
|
||||
cmp = sa.localeCompare(sb, 'fr', { numeric: true, sensitivity: 'base' });
|
||||
}
|
||||
|
||||
return sortDir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
// READ
|
||||
getById(id: string): Observable<Hippodrome | undefined> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<Hippodrome>(`${this.apiUrl}/${id}`, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
console.error(`Error fetching hippodrome ${id}:`, err);
|
||||
return of(this.store().find((h) => h.id === id));
|
||||
})
|
||||
);
|
||||
}
|
||||
const found = this.store().find((h) => h.id === id);
|
||||
return of(found);
|
||||
}
|
||||
|
||||
// CREATE
|
||||
create(payload: Omit<Hippodrome, 'id'>): Observable<Hippodrome> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.post<Hippodrome>(this.apiUrl, payload, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
console.error('Error creating hippodrome:', err);
|
||||
const item: Hippodrome = { id: crypto.randomUUID(), ...payload };
|
||||
this.store.set([item, ...this.store()]);
|
||||
return of(item);
|
||||
})
|
||||
);
|
||||
}
|
||||
const item: Hippodrome = { id: crypto.randomUUID(), ...payload };
|
||||
this.store.set([item, ...this.store()]);
|
||||
return of(item);
|
||||
}
|
||||
|
||||
// UPDATE
|
||||
update(id: string, payload: Partial<Hippodrome>): Observable<Hippodrome | undefined> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.put<Hippodrome>(`${this.apiUrl}/${id}`, payload, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
console.error(`Error updating hippodrome ${id}:`, err);
|
||||
let updated: Hippodrome | undefined;
|
||||
this.store.set(
|
||||
this.store().map((h) => {
|
||||
if (h.id === id) {
|
||||
updated = { ...h, ...payload };
|
||||
return updated;
|
||||
}
|
||||
return h;
|
||||
})
|
||||
);
|
||||
return of(updated);
|
||||
})
|
||||
);
|
||||
}
|
||||
let updated: Hippodrome | undefined;
|
||||
this.store.set(
|
||||
this.store().map((h) => {
|
||||
if (h.id === id) {
|
||||
updated = { ...h, ...payload };
|
||||
return updated;
|
||||
}
|
||||
return h;
|
||||
})
|
||||
);
|
||||
return of(updated);
|
||||
}
|
||||
|
||||
// DELETE
|
||||
delete(id: string): Observable<boolean> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.delete<void>(`${this.apiUrl}/${id}`, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map(() => true),
|
||||
catchError((err) => {
|
||||
console.error(`Error deleting hippodrome ${id}:`, err);
|
||||
const before = this.store().length;
|
||||
this.store.set(this.store().filter((h) => h.id !== id));
|
||||
return of(this.store().length < before);
|
||||
})
|
||||
);
|
||||
}
|
||||
const before = this.store().length;
|
||||
this.store.set(this.store().filter((h) => h.id !== id));
|
||||
return of(this.store().length < before);
|
||||
}
|
||||
|
||||
// GET by ville
|
||||
getByVille(ville: string): Observable<Hippodrome[]> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<Hippodrome[]>(`${this.apiUrl}/ville/${encodeURIComponent(ville)}`, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
console.error(`Error fetching hippodromes by ville ${ville}:`, err);
|
||||
return of(this.store().filter((h) => h.ville === ville));
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(this.store().filter((h) => h.ville === ville));
|
||||
}
|
||||
|
||||
// SEARCH by query (q parameter)
|
||||
search(query: string): Observable<Hippodrome[]> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<Hippodrome[]>(`${this.apiUrl}/search`, {
|
||||
params: { nom: query.trim() },
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
console.error(`Error searching hippodromes with query ${query}:`, err);
|
||||
const q = query.toLowerCase();
|
||||
return of(
|
||||
this.store().filter(
|
||||
(h) =>
|
||||
h.nom.toLowerCase().includes(q) ||
|
||||
h.ville.toLowerCase().includes(q) ||
|
||||
h.pays.toLowerCase().includes(q)
|
||||
)
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
const q = query.toLowerCase();
|
||||
return of(
|
||||
this.store().filter(
|
||||
(h) =>
|
||||
h.nom.toLowerCase().includes(q) ||
|
||||
h.ville.toLowerCase().includes(q) ||
|
||||
h.pays.toLowerCase().includes(q)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// GET actifs
|
||||
getActifs(): Observable<Hippodrome[]> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<Hippodrome[]>(`${this.apiUrl}/actifs`, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
console.error('Error fetching active hippodromes:', err);
|
||||
return of(this.store().filter((h) => h.actif));
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(this.store().filter((h) => h.actif));
|
||||
}
|
||||
}
|
||||
40
src/app/core/services/non-partant.ts
Normal file
40
src/app/core/services/non-partant.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { map, catchError } from 'rxjs/operators';
|
||||
import { environment } from 'src/environments/environment.development';
|
||||
|
||||
const USE_SERVER = true;
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class NonPartantService {
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
// Helper method to get ngrok bypass headers
|
||||
private getNgrokHeaders(): Record<string, string> {
|
||||
const isNgrok =
|
||||
environment.apiBaseUrl.includes('ngrok-free.app') ||
|
||||
environment.apiBaseUrl.includes('ngrok.io') ||
|
||||
environment.apiBaseUrl.includes('ngrok');
|
||||
return isNgrok ? { 'ngrok-skip-browser-warning': 'true' } : {};
|
||||
}
|
||||
|
||||
// PUT /api/v1/courses/{courseId}/non-partants - Replace the list of non-partants for a course
|
||||
replaceNonPartants(courseId: string, nonPartants: string[]): Observable<string[]> {
|
||||
if (USE_SERVER) {
|
||||
const courseApiUrl = environment.apiBaseUrl + '/api/v1/courses';
|
||||
return this.http
|
||||
.put<string[]>(`${courseApiUrl}/${courseId}/non-partants`, nonPartants, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
map((list) => list.map((np) => String(np))),
|
||||
catchError((err) => {
|
||||
console.error(`Error replacing non-partants for course ${courseId}:`, err);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of([]);
|
||||
}
|
||||
}
|
||||
76
src/app/core/services/report.ts
Normal file
76
src/app/core/services/report.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { CourseReportDetail, CourseReportDetailRow, CourseReportSummary } from '../interfaces/report';
|
||||
import { REPORT_SUMMARIES_MOCK, REPORT_DETAILS_MOCK } from '../mocks/report.mocks';
|
||||
import { normalizePage } from '@shared/paging/normalize-page';
|
||||
import { ListParams, PagedResult, SortDir } from '@shared/paging/paging';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ReportService {
|
||||
private summaries = signal<CourseReportSummary[]>([...REPORT_SUMMARIES_MOCK]);
|
||||
|
||||
list(params: ListParams): Observable<PagedResult<CourseReportSummary>> {
|
||||
let data = [...this.summaries()];
|
||||
const q = (params.search ?? '').toLowerCase();
|
||||
if (q) {
|
||||
data = data.filter((r) =>
|
||||
[
|
||||
r.course.nom,
|
||||
r.course.type,
|
||||
r.course.reunion?.hippodrome?.nom,
|
||||
String(r.course.numero),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.map((s) => String(s).toLowerCase())
|
||||
.some((s) => s.includes(q))
|
||||
);
|
||||
}
|
||||
if (params.sortKey && params.sortDir) {
|
||||
const { sortKey, sortDir } = params as { sortKey: string; sortDir: SortDir };
|
||||
const get = (o: any, k: string) => k.split('.').reduce((a, b) => a?.[b], o);
|
||||
data = [...data].sort((a, b) => String(get(a, sortKey) ?? '').localeCompare(String(get(b, sortKey) ?? ''), 'fr', { numeric: true }) * (sortDir === 'asc' ? 1 : -1));
|
||||
}
|
||||
const start = (params.page - 1) * params.perPage;
|
||||
const pageData = data.slice(start, start + params.perPage);
|
||||
return of(normalizePage<CourseReportSummary>({ data: pageData, meta: { total: data.length } }, params.page, params.perPage));
|
||||
}
|
||||
|
||||
getDetail(courseId: string): Observable<CourseReportDetail | undefined> {
|
||||
const summary = this.summaries().find((s) => s.id === courseId);
|
||||
if (!summary) return of(undefined);
|
||||
const rows = REPORT_DETAILS_MOCK.get(courseId) ?? [];
|
||||
return of({ summary, rows });
|
||||
}
|
||||
|
||||
// === Actions ===
|
||||
validate(courseId: string): Observable<CourseReportSummary | undefined> {
|
||||
let updated: CourseReportSummary | undefined;
|
||||
this.summaries.set(
|
||||
this.summaries().map((s) => (s.id === courseId ? ((updated = { ...s, statut: 'Validé', confirmed: false }), updated) : s))
|
||||
);
|
||||
return of(updated);
|
||||
}
|
||||
|
||||
confirm(courseId: string): Observable<CourseReportSummary | undefined> {
|
||||
let updated: CourseReportSummary | undefined;
|
||||
this.summaries.set(
|
||||
this.summaries().map((s) => (s.id === courseId ? ((updated = { ...s, statut: 'Validé', confirmed: true }), updated) : s))
|
||||
);
|
||||
return of(updated);
|
||||
}
|
||||
|
||||
resetStatus(courseId: string): Observable<CourseReportSummary | undefined> {
|
||||
let updated: CourseReportSummary | undefined;
|
||||
this.summaries.set(
|
||||
this.summaries().map((s) => (s.id === courseId ? ((updated = { ...s, statut: 'Non Validé', confirmed: false }), updated) : s))
|
||||
);
|
||||
return of(updated);
|
||||
}
|
||||
|
||||
modifyRows(courseId: string, rows: CourseReportDetailRow[]): Observable<boolean> {
|
||||
REPORT_DETAILS_MOCK.set(courseId, rows);
|
||||
return of(true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
282
src/app/core/services/resultat.ts
Normal file
282
src/app/core/services/resultat.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, of, forkJoin } from 'rxjs';
|
||||
import { map, catchError, switchMap } from 'rxjs/operators';
|
||||
import { Resultat, ResultatApiResponse, CreateResultatPayload } from '../interfaces/resultat';
|
||||
import { Course } from '../interfaces/course';
|
||||
import { CourseService } from './course';
|
||||
import { environment } from 'src/environments/environment.development';
|
||||
|
||||
const USE_SERVER = true;
|
||||
const API_BASE = '/api/v1/resultat';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ResultatService {
|
||||
private apiUrl = environment.apiBaseUrl + API_BASE;
|
||||
|
||||
constructor(private http: HttpClient, private courseService: CourseService) {}
|
||||
|
||||
private getNgrokHeaders(): Record<string, string> {
|
||||
const isNgrok =
|
||||
environment.apiBaseUrl.includes('ngrok-free.app') ||
|
||||
environment.apiBaseUrl.includes('ngrok.io') ||
|
||||
environment.apiBaseUrl.includes('ngrok');
|
||||
return isNgrok ? { 'ngrok-skip-browser-warning': 'true' } : {};
|
||||
}
|
||||
|
||||
// GET /api/v1/resultat/{id}
|
||||
getById(id: string): Observable<Resultat | undefined> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<ResultatApiResponse>(`${this.apiUrl}/${id}`, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
switchMap((apiResultat) => {
|
||||
// Fetch the full course object if course is just an ID
|
||||
const courseId =
|
||||
typeof apiResultat.course === 'object' && 'id' in apiResultat.course
|
||||
? String(apiResultat.course.id)
|
||||
: String(apiResultat.course);
|
||||
|
||||
return this.courseService.getById(courseId).pipe(
|
||||
map((course) => {
|
||||
if (!course) {
|
||||
return undefined;
|
||||
}
|
||||
return this.transformApiResponse(apiResultat, course);
|
||||
})
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error(`Error fetching resultat ${id}:`, err);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(undefined);
|
||||
}
|
||||
|
||||
// GET /api/v1/resultat
|
||||
list(): Observable<Resultat[]> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<ResultatApiResponse[]>(this.apiUrl, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
switchMap((apiResultats) => {
|
||||
// Fetch all unique course IDs
|
||||
const courseIds = [
|
||||
...new Set(
|
||||
apiResultats.map((r) =>
|
||||
typeof r.course === 'object' && 'id' in r.course
|
||||
? String(r.course.id)
|
||||
: String(r.course)
|
||||
)
|
||||
),
|
||||
];
|
||||
// Fetch all courses in parallel
|
||||
const courseRequests = courseIds.map((id) =>
|
||||
this.courseService
|
||||
.getById(id)
|
||||
.pipe(catchError(() => of<Course | undefined>(undefined)))
|
||||
);
|
||||
|
||||
return forkJoin(courseRequests).pipe(
|
||||
map((courses) => {
|
||||
const courseMap = new Map<string, Course>();
|
||||
courseIds.forEach((id, index) => {
|
||||
const course = courses[index];
|
||||
if (course) {
|
||||
courseMap.set(id, course);
|
||||
}
|
||||
});
|
||||
|
||||
return apiResultats
|
||||
.map((apiResultat) => {
|
||||
const courseId =
|
||||
typeof apiResultat.course === 'object' && 'id' in apiResultat.course
|
||||
? String(apiResultat.course.id)
|
||||
: String(apiResultat.course);
|
||||
const course = courseMap.get(courseId);
|
||||
if (!course) {
|
||||
return null;
|
||||
}
|
||||
return this.transformApiResponse(apiResultat, course);
|
||||
})
|
||||
.filter((r): r is Resultat => r !== null);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error('Error fetching resultats:', err);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error('Error fetching resultats:', err);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of([]);
|
||||
}
|
||||
|
||||
// GET /api/v1/resultat/course/{courseId}
|
||||
getByCourseId(courseId: string): Observable<Resultat | undefined> {
|
||||
if (!USE_SERVER) {
|
||||
return of(undefined);
|
||||
}
|
||||
|
||||
return this.http
|
||||
.get<any>(`${this.apiUrl}/course/${courseId}`, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
switchMap((raw) => {
|
||||
// Some courses don't have a resultat yet.
|
||||
// In that case the API returns 200 with a body like:
|
||||
// { "message": "Aucun résultat disponible pour cette course" }
|
||||
// We interpret this as "no resultat" and return undefined.
|
||||
if (
|
||||
raw &&
|
||||
typeof raw === 'object' &&
|
||||
'message' in raw &&
|
||||
!('id' in raw) &&
|
||||
!('ordreArrivee' in raw)
|
||||
) {
|
||||
return of<Resultat | undefined>(undefined);
|
||||
}
|
||||
|
||||
const apiResultat = raw as ResultatApiResponse;
|
||||
|
||||
return this.courseService.getById(courseId).pipe(
|
||||
map((course) => {
|
||||
if (!course) {
|
||||
return undefined;
|
||||
}
|
||||
return this.transformApiResponse(apiResultat, course);
|
||||
})
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
// If the backend ever responds with 404 here, also treat as "no resultat".
|
||||
if (err?.status === 404) {
|
||||
return of(undefined);
|
||||
}
|
||||
|
||||
console.error(`Error fetching resultat for course ${courseId}:`, err);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// POST /api/v1/resultat
|
||||
create(payload: CreateResultatPayload): Observable<Resultat> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.post<ResultatApiResponse>(this.apiUrl, payload, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
switchMap((apiResultat) => {
|
||||
const courseId = String(payload.course.id);
|
||||
return this.courseService.getById(courseId).pipe(
|
||||
map((course) => {
|
||||
if (!course) {
|
||||
throw new Error('Course not found');
|
||||
}
|
||||
return this.transformApiResponse(apiResultat, course);
|
||||
})
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error('Error creating resultat:', err);
|
||||
throw err;
|
||||
})
|
||||
);
|
||||
}
|
||||
throw new Error('Server mode is required');
|
||||
}
|
||||
|
||||
// PUT /api/v1/resultat/{id}
|
||||
update(id: string, payload: Partial<CreateResultatPayload>): Observable<Resultat | undefined> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.put<ResultatApiResponse>(`${this.apiUrl}/${id}`, payload, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
switchMap((apiResultat) => {
|
||||
const courseId =
|
||||
typeof apiResultat.course === 'object' && 'id' in apiResultat.course
|
||||
? String(apiResultat.course.id)
|
||||
: String(apiResultat.course);
|
||||
|
||||
return this.courseService.getById(courseId).pipe(
|
||||
map((course) => {
|
||||
if (!course) {
|
||||
return undefined;
|
||||
}
|
||||
return this.transformApiResponse(apiResultat, course);
|
||||
})
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error(`Error updating resultat ${id}:`, err);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
throw new Error('Server mode is required');
|
||||
}
|
||||
|
||||
// DELETE /api/v1/resultat/{id}
|
||||
delete(id: string): Observable<boolean> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.delete<void>(`${this.apiUrl}/${id}`, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map(() => true),
|
||||
catchError((err) => {
|
||||
console.error(`Error deleting resultat ${id}:`, err);
|
||||
return of(false);
|
||||
})
|
||||
);
|
||||
}
|
||||
throw new Error('Server mode is required');
|
||||
}
|
||||
|
||||
// DELETE /api/v1/resultat/course/{courseId}
|
||||
deleteByCourseId(courseId: string): Observable<boolean> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.delete<void>(`${this.apiUrl}/course/${courseId}`, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map(() => true),
|
||||
catchError((err) => {
|
||||
console.error(`Error deleting resultat for course ${courseId}:`, err);
|
||||
return of(false);
|
||||
})
|
||||
);
|
||||
}
|
||||
throw new Error('Server mode is required');
|
||||
}
|
||||
|
||||
private transformApiResponse(apiResultat: ResultatApiResponse, course: Course): Resultat {
|
||||
return {
|
||||
id: String(apiResultat.id),
|
||||
course,
|
||||
// Normalize ordreArrivee to an array of cheval numbers
|
||||
ordreArrivee: (apiResultat.ordreArrivee || [])
|
||||
.map((v) => (typeof v === 'string' ? Number(v) : v))
|
||||
.filter((v): v is number => typeof v === 'number' && !Number.isNaN(v)),
|
||||
// Normalize dead-heat horses to numbers as well
|
||||
chevauxDeadHeat: (apiResultat.chevauxDeadHeat || [])
|
||||
.map((v) => (typeof v === 'string' ? Number(v) : v))
|
||||
.filter((v): v is number => typeof v === 'number' && !Number.isNaN(v)),
|
||||
totalMises: apiResultat.totalMises,
|
||||
masseAPartager: apiResultat.masseAPartager,
|
||||
prelevementsLegaux: apiResultat.prelevementsLegaux,
|
||||
montantRembourse: apiResultat.montantRembourse,
|
||||
montantCagnotte: apiResultat.montantCagnotte,
|
||||
adeadHeat: apiResultat.adeadHeat,
|
||||
createdAt: apiResultat.createdAt,
|
||||
updatedAt: apiResultat.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
16
src/app/core/services/reunion.spec.ts
Normal file
16
src/app/core/services/reunion.spec.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { Reunion } from './reunion';
|
||||
|
||||
describe('Reunion', () => {
|
||||
let service: Reunion;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(Reunion);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
||||
537
src/app/core/services/reunion.ts
Normal file
537
src/app/core/services/reunion.ts
Normal file
@@ -0,0 +1,537 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, of, forkJoin } from 'rxjs';
|
||||
import { map, catchError, switchMap } from 'rxjs/operators';
|
||||
import { Reunion } from '../interfaces/reunion';
|
||||
import { Hippodrome } from '../interfaces/hippodrome';
|
||||
import { normalizePage } from '@shared/paging/normalize-page';
|
||||
import { PaginatedHttpService } from '@shared/paging/paginated-http.service';
|
||||
import { ListParams, PagedResult } from '@shared/paging/paging';
|
||||
import { environment } from 'src/environments/environment.development';
|
||||
import { HippodromeService } from './hippodrome';
|
||||
|
||||
// API response interface (has hippodromeId instead of hippodrome)
|
||||
interface ReunionApiResponse {
|
||||
id: string;
|
||||
code: string;
|
||||
nom: string;
|
||||
date: string;
|
||||
numero: number;
|
||||
statut: string;
|
||||
hippodromeId: string;
|
||||
totalCourses?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const USE_SERVER = true;
|
||||
const API_BASE = '/api/v1/reunions';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ReunionService {
|
||||
private apiUrl = environment.apiBaseUrl + API_BASE;
|
||||
private store = signal<Reunion[]>([]);
|
||||
|
||||
constructor(
|
||||
private http: HttpClient,
|
||||
private paginatedHttp: PaginatedHttpService,
|
||||
private hippodromeService: HippodromeService
|
||||
) {}
|
||||
|
||||
// Helper method to get ngrok bypass headers
|
||||
private getNgrokHeaders(): Record<string, string> {
|
||||
const isNgrok =
|
||||
environment.apiBaseUrl.includes('ngrok-free.app') ||
|
||||
environment.apiBaseUrl.includes('ngrok.io') ||
|
||||
environment.apiBaseUrl.includes('ngrok');
|
||||
return isNgrok ? { 'ngrok-skip-browser-warning': 'true' } : {};
|
||||
}
|
||||
|
||||
list(
|
||||
params: ListParams,
|
||||
usePaginationEndpoint: boolean = false
|
||||
): Observable<PagedResult<Reunion>> {
|
||||
if (USE_SERVER) {
|
||||
if (usePaginationEndpoint) {
|
||||
return this.paginatedHttp
|
||||
.fetch<ReunionApiResponse>(this.apiUrl, params, {
|
||||
zeroBasedPageIndex: false,
|
||||
buildSort: (key, dir) => (key && dir ? ['sort', `${key},${dir}`] : null),
|
||||
})
|
||||
.pipe(
|
||||
switchMap((pagedResult) => {
|
||||
// Handle empty data case
|
||||
if (!pagedResult.data || pagedResult.data.length === 0) {
|
||||
return of({
|
||||
...pagedResult,
|
||||
data: [],
|
||||
meta: {
|
||||
...pagedResult.meta,
|
||||
uniqueHippodromes: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Extract unique hippodrome IDs from the paginated data
|
||||
const uniqueHippodromeIds = [
|
||||
...new Set(pagedResult.data.map((r) => String(r.hippodromeId))),
|
||||
];
|
||||
|
||||
// Handle case where there are no unique IDs
|
||||
if (uniqueHippodromeIds.length === 0) {
|
||||
return of({
|
||||
...pagedResult,
|
||||
data: [],
|
||||
meta: {
|
||||
...pagedResult.meta,
|
||||
uniqueHippodromes: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch all unique hippodromes in parallel
|
||||
const hippodromeRequests = uniqueHippodromeIds.map((id) =>
|
||||
this.hippodromeService
|
||||
.getById(id)
|
||||
.pipe(catchError(() => of<Hippodrome | undefined>(undefined)))
|
||||
);
|
||||
|
||||
// Fetch courses to calculate counts per reunion
|
||||
const coursesRequest = this.http
|
||||
.get<any[]>(`${environment.apiBaseUrl}/api/v1/courses`, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
catchError(() => of([])),
|
||||
map((data) => data || [])
|
||||
);
|
||||
|
||||
return forkJoin({
|
||||
hippodromes: forkJoin(hippodromeRequests),
|
||||
courses: coursesRequest,
|
||||
}).pipe(
|
||||
map(({ hippodromes, courses }) => {
|
||||
// Create a map of hippodrome ID to hippodrome object
|
||||
const hippodromeMap = new Map<string, Hippodrome>();
|
||||
uniqueHippodromeIds.forEach((id, index) => {
|
||||
const hippodrome = hippodromes[index];
|
||||
if (hippodrome) {
|
||||
hippodromeMap.set(id, hippodrome);
|
||||
}
|
||||
});
|
||||
|
||||
// Count courses per reunion
|
||||
const courseCountMap = new Map<string, number>();
|
||||
courses.forEach((course: any) => {
|
||||
const reunionId = String(course.reunionId || course.reunion?.id);
|
||||
if (reunionId && reunionId !== 'undefined' && reunionId !== 'null') {
|
||||
courseCountMap.set(
|
||||
reunionId,
|
||||
(courseCountMap.get(reunionId) || 0) + 1
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Transform API responses to Reunion objects
|
||||
const transformedData: Reunion[] = pagedResult.data
|
||||
.map((apiReunion) => {
|
||||
const hippodrome = hippodromeMap.get(String(apiReunion.hippodromeId));
|
||||
if (!hippodrome) {
|
||||
return null;
|
||||
}
|
||||
const reunionId = String(apiReunion.id);
|
||||
const courseCount = courseCountMap.get(reunionId) ?? apiReunion.totalCourses ?? 0;
|
||||
return {
|
||||
id: reunionId,
|
||||
code: apiReunion.code,
|
||||
nom: apiReunion.nom,
|
||||
date: apiReunion.date,
|
||||
numero: apiReunion.numero,
|
||||
statut: apiReunion.statut as any,
|
||||
hippodrome,
|
||||
totalCourses: courseCount,
|
||||
createdAt: apiReunion.createdAt,
|
||||
updatedAt: apiReunion.updatedAt,
|
||||
} as Reunion;
|
||||
})
|
||||
.filter((r): r is Reunion => r !== null && r !== undefined);
|
||||
|
||||
// Calculate unique hippodromes count
|
||||
const uniqueHippodromes = new Set(transformedData.map((r) => r.hippodrome.id))
|
||||
.size;
|
||||
|
||||
return {
|
||||
...pagedResult,
|
||||
data: transformedData,
|
||||
meta: {
|
||||
...pagedResult.meta,
|
||||
uniqueHippodromes,
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error('Error fetching reunions:', err);
|
||||
return this.getMockList(params);
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// Fetch all data and apply client-side pagination
|
||||
return this.http
|
||||
.get<ReunionApiResponse[]>(this.apiUrl, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
switchMap((apiData) => {
|
||||
// Handle empty data case
|
||||
if (!apiData || apiData.length === 0) {
|
||||
return of(
|
||||
normalizePage<Reunion>(
|
||||
{
|
||||
data: [],
|
||||
meta: { total: 0, uniqueHippodromes: 0, upcomingReunions: 0, pastReunions: 0 },
|
||||
},
|
||||
params.page,
|
||||
params.perPage
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Extract unique hippodrome IDs
|
||||
const uniqueHippodromeIds = [...new Set(apiData.map((r) => String(r.hippodromeId)))];
|
||||
|
||||
// Handle case where there are no unique IDs (shouldn't happen, but be safe)
|
||||
if (uniqueHippodromeIds.length === 0) {
|
||||
return of(
|
||||
normalizePage<Reunion>(
|
||||
{
|
||||
data: [],
|
||||
meta: { total: 0, uniqueHippodromes: 0, upcomingReunions: 0, pastReunions: 0 },
|
||||
},
|
||||
params.page,
|
||||
params.perPage
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch all unique hippodromes and all courses in parallel
|
||||
const hippodromeRequests = uniqueHippodromeIds.map((id) =>
|
||||
this.hippodromeService
|
||||
.getById(id)
|
||||
.pipe(catchError(() => of<Hippodrome | undefined>(undefined)))
|
||||
);
|
||||
|
||||
// Fetch courses to calculate counts per reunion
|
||||
const coursesRequest = this.http
|
||||
.get<any[]>(`${environment.apiBaseUrl}/api/v1/courses`, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
catchError(() => of([])),
|
||||
map((data) => data || [])
|
||||
);
|
||||
|
||||
return forkJoin({
|
||||
hippodromes: forkJoin(hippodromeRequests),
|
||||
courses: coursesRequest,
|
||||
}).pipe(
|
||||
map(({ hippodromes, courses }) => {
|
||||
// Create a map of hippodrome ID to hippodrome object
|
||||
const hippodromeMap = new Map<string, Hippodrome>();
|
||||
uniqueHippodromeIds.forEach((id, index) => {
|
||||
const hippodrome = hippodromes[index];
|
||||
if (hippodrome) {
|
||||
hippodromeMap.set(id, hippodrome);
|
||||
}
|
||||
});
|
||||
|
||||
// Count courses per reunion
|
||||
const courseCountMap = new Map<string, number>();
|
||||
courses.forEach((course: any) => {
|
||||
const reunionId = String(course.reunionId || course.reunion?.id);
|
||||
if (reunionId && reunionId !== 'undefined' && reunionId !== 'null') {
|
||||
courseCountMap.set(
|
||||
reunionId,
|
||||
(courseCountMap.get(reunionId) || 0) + 1
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Transform API responses to Reunion objects
|
||||
const transformedData: Reunion[] = apiData
|
||||
.map((apiReunion) => {
|
||||
const hippodrome = hippodromeMap.get(String(apiReunion.hippodromeId));
|
||||
if (!hippodrome) {
|
||||
// Skip if hippodrome not found
|
||||
return null;
|
||||
}
|
||||
const reunionId = String(apiReunion.id);
|
||||
const courseCount = courseCountMap.get(reunionId) ?? apiReunion.totalCourses ?? 0;
|
||||
return {
|
||||
id: reunionId,
|
||||
code: apiReunion.code,
|
||||
nom: apiReunion.nom,
|
||||
date: apiReunion.date,
|
||||
numero: apiReunion.numero,
|
||||
statut: apiReunion.statut as any,
|
||||
hippodrome,
|
||||
totalCourses: courseCount,
|
||||
createdAt: apiReunion.createdAt,
|
||||
updatedAt: apiReunion.updatedAt,
|
||||
} as Reunion;
|
||||
})
|
||||
.filter((r): r is Reunion => r !== null && r !== undefined);
|
||||
|
||||
// Apply client-side filtering, sorting, and pagination
|
||||
let filtered = this.applyClientFilters(transformedData, params);
|
||||
const total = filtered.length;
|
||||
const start = (params.page - 1) * params.perPage;
|
||||
const pageData = filtered.slice(start, start + params.perPage);
|
||||
|
||||
const upcomingReunions = filtered.filter(
|
||||
(r) => new Date(r.date) >= new Date()
|
||||
).length;
|
||||
const pastReunions = filtered.filter((r) => new Date(r.date) < new Date()).length;
|
||||
const uniqueHippodromes = new Set(filtered.map((r) => r.hippodrome.id)).size;
|
||||
|
||||
return normalizePage<Reunion>(
|
||||
{
|
||||
data: pageData,
|
||||
meta: { total, uniqueHippodromes, upcomingReunions, pastReunions },
|
||||
},
|
||||
params.page,
|
||||
params.perPage
|
||||
);
|
||||
})
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error('Error fetching reunions:', err);
|
||||
return this.getMockList(params);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return this.getMockList(params);
|
||||
}
|
||||
|
||||
private applyClientFilters(data: Reunion[], params: ListParams): Reunion[] {
|
||||
let filtered = [...data];
|
||||
|
||||
// Search filter
|
||||
const q = (params.search ?? '').toLowerCase();
|
||||
if (q) {
|
||||
filtered = filtered.filter(
|
||||
(r) =>
|
||||
r.nom.toLowerCase().includes(q) ||
|
||||
r.hippodrome.nom.toLowerCase().includes(q) ||
|
||||
r.hippodrome.ville.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
// Sort
|
||||
if (params.sortKey && params.sortDir) {
|
||||
const { sortKey, sortDir } = params;
|
||||
filtered.sort((a: any, b: any) => {
|
||||
const va = a[sortKey!],
|
||||
vb = b[sortKey!];
|
||||
const sa = va == null ? '' : String(va);
|
||||
const sb = vb == null ? '' : String(vb);
|
||||
const cmp = sa.localeCompare(sb, 'fr', { numeric: true, sensitivity: 'base' });
|
||||
return sortDir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
private getMockList(params: ListParams): Observable<PagedResult<Reunion>> {
|
||||
const q = (params.search ?? '').toLowerCase();
|
||||
let data = this.store();
|
||||
|
||||
if (q) {
|
||||
data = data.filter(
|
||||
(r) =>
|
||||
r.nom.toLowerCase().includes(q) ||
|
||||
r.hippodrome.nom.toLowerCase().includes(q) ||
|
||||
r.hippodrome.ville.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
if (params.sortKey && params.sortDir) {
|
||||
const { sortKey, sortDir } = params;
|
||||
data = [...data].sort((a: any, b: any) => {
|
||||
const va = a[sortKey!],
|
||||
vb = b[sortKey!];
|
||||
const sa = va == null ? '' : String(va);
|
||||
const sb = vb == null ? '' : String(vb);
|
||||
const cmp = sa.localeCompare(sb, 'fr', { numeric: true, sensitivity: 'base' });
|
||||
return sortDir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
}
|
||||
|
||||
const start = (params.page - 1) * params.perPage;
|
||||
const pageData = data.slice(start, start + params.perPage);
|
||||
|
||||
const upcomingReunions = data.filter((r) => new Date(r.date) >= new Date()).length;
|
||||
const pastReunions = data.filter((r) => new Date(r.date) < new Date()).length;
|
||||
const uniqueHippodromes = new Set(data.map((r) => r.hippodrome.nom)).size;
|
||||
|
||||
return of(
|
||||
normalizePage<Reunion>(
|
||||
{
|
||||
data: pageData,
|
||||
meta: { total: data.length, uniqueHippodromes, upcomingReunions, pastReunions },
|
||||
},
|
||||
params.page,
|
||||
params.perPage
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
getById(id: string): Observable<Reunion | undefined> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<ReunionApiResponse>(`${this.apiUrl}/${id}`, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
switchMap((apiReunion) => {
|
||||
// Fetch the hippodrome data
|
||||
return this.hippodromeService.getById(String(apiReunion.hippodromeId)).pipe(
|
||||
map((hippodrome) => {
|
||||
if (!hippodrome) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
id: String(apiReunion.id),
|
||||
code: apiReunion.code,
|
||||
nom: apiReunion.nom,
|
||||
date: apiReunion.date,
|
||||
numero: apiReunion.numero,
|
||||
statut: apiReunion.statut as any,
|
||||
hippodrome,
|
||||
totalCourses: apiReunion.totalCourses,
|
||||
createdAt: apiReunion.createdAt,
|
||||
updatedAt: apiReunion.updatedAt,
|
||||
} as Reunion;
|
||||
})
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error(`Error fetching reunion ${id}:`, err);
|
||||
return of(this.store().find((r) => r.id === id));
|
||||
})
|
||||
);
|
||||
}
|
||||
const found = this.store().find((r) => r.id === id);
|
||||
return of(found);
|
||||
}
|
||||
|
||||
getByCode(code: string): Observable<Reunion | undefined> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<ReunionApiResponse>(`${this.apiUrl}/code/${encodeURIComponent(code)}`, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
switchMap((apiReunion) => {
|
||||
// Fetch the hippodrome data
|
||||
return this.hippodromeService.getById(String(apiReunion.hippodromeId)).pipe(
|
||||
map((hippodrome) => {
|
||||
if (!hippodrome) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
id: String(apiReunion.id),
|
||||
code: apiReunion.code,
|
||||
nom: apiReunion.nom,
|
||||
date: apiReunion.date,
|
||||
numero: apiReunion.numero,
|
||||
statut: apiReunion.statut as any,
|
||||
hippodrome,
|
||||
totalCourses: apiReunion.totalCourses,
|
||||
createdAt: apiReunion.createdAt,
|
||||
updatedAt: apiReunion.updatedAt,
|
||||
} as Reunion;
|
||||
})
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error(`Error fetching reunion by code ${code}:`, err);
|
||||
return of(this.store().find((r) => r.code === code));
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(this.store().find((r) => r.code === code));
|
||||
}
|
||||
|
||||
create(payload: Omit<Reunion, 'id'>): Observable<Reunion> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.post<Reunion>(this.apiUrl, payload, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
console.error('Error creating reunion:', err);
|
||||
const item: Reunion = { id: crypto.randomUUID(), ...payload };
|
||||
this.store.set([item, ...this.store()]);
|
||||
return of(item);
|
||||
})
|
||||
);
|
||||
}
|
||||
const item: Reunion = { id: crypto.randomUUID(), ...payload };
|
||||
this.store.set([item, ...this.store()]);
|
||||
return of(item);
|
||||
}
|
||||
|
||||
update(id: string, payload: Partial<Reunion>): Observable<Reunion | undefined> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.put<Reunion>(`${this.apiUrl}/${id}`, payload, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
console.error(`Error updating reunion ${id}:`, err);
|
||||
let updated: Reunion | undefined;
|
||||
this.store.set(
|
||||
this.store().map((r) => {
|
||||
if (r.id === id) {
|
||||
updated = { ...r, ...payload };
|
||||
return updated;
|
||||
}
|
||||
return r;
|
||||
})
|
||||
);
|
||||
return of(updated);
|
||||
})
|
||||
);
|
||||
}
|
||||
let updated: Reunion | undefined;
|
||||
this.store.set(
|
||||
this.store().map((r) => {
|
||||
if (r.id === id) {
|
||||
updated = { ...r, ...payload };
|
||||
return updated;
|
||||
}
|
||||
return r;
|
||||
})
|
||||
);
|
||||
return of(updated);
|
||||
}
|
||||
|
||||
delete(id: string): Observable<boolean> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.delete<void>(`${this.apiUrl}/${id}`, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map(() => true),
|
||||
catchError((err) => {
|
||||
console.error(`Error deleting reunion ${id}:`, err);
|
||||
const before = this.store().length;
|
||||
this.store.set(this.store().filter((r) => r.id !== id));
|
||||
return of(this.store().length < before);
|
||||
})
|
||||
);
|
||||
}
|
||||
const before = this.store().length;
|
||||
this.store.set(this.store().filter((r) => r.id !== id));
|
||||
return of(this.store().length < before);
|
||||
}
|
||||
}
|
||||
283
src/app/core/services/role.ts
Normal file
283
src/app/core/services/role.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { map, catchError } from 'rxjs/operators';
|
||||
import { Permission, Role } from '../interfaces/role';
|
||||
import { normalizePage } from '@shared/paging/normalize-page';
|
||||
import { ListParams, PagedResult } from '@shared/paging/paging';
|
||||
import { environment } from 'src/environments/environment.development';
|
||||
|
||||
const USE_SERVER = true;
|
||||
const ROLES_API_BASE = '/api/v1/roles';
|
||||
const PERMISSIONS_API_BASE = '/api/v1/permissions';
|
||||
|
||||
// API Response interfaces
|
||||
interface PermissionApiResponse {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface RoleApiResponse {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
permissions?: PermissionApiResponse[];
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class RoleService {
|
||||
private rolesUrl = environment.apiBaseUrl + ROLES_API_BASE;
|
||||
private permissionsUrl = environment.apiBaseUrl + PERMISSIONS_API_BASE;
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
// Helper method to get ngrok bypass headers
|
||||
private getNgrokHeaders(): Record<string, string> {
|
||||
const isNgrok =
|
||||
environment.apiBaseUrl.includes('ngrok-free.app') ||
|
||||
environment.apiBaseUrl.includes('ngrok.io') ||
|
||||
environment.apiBaseUrl.includes('ngrok');
|
||||
return isNgrok ? { 'ngrok-skip-browser-warning': 'true' } : {};
|
||||
}
|
||||
|
||||
// Transform API response to Permission
|
||||
private transformPermission(api: PermissionApiResponse): Permission {
|
||||
return {
|
||||
id: String(api.id),
|
||||
name: api.name,
|
||||
description: api.description,
|
||||
};
|
||||
}
|
||||
|
||||
// Transform API response to Role
|
||||
private transformRole(api: RoleApiResponse): Role {
|
||||
return {
|
||||
id: String(api.id),
|
||||
name: api.name,
|
||||
description: api.description,
|
||||
permissions: (api.permissions || []).map((p) => this.transformPermission(p)),
|
||||
createdAt: api.createdAt,
|
||||
updatedAt: api.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// Transform Role to API payload
|
||||
private transformRoleToApi(role: Partial<Role>): any {
|
||||
return {
|
||||
id: role.id ? Number(role.id) : undefined,
|
||||
name: role.name ?? '',
|
||||
description: role.description,
|
||||
permissions: (role.permissions || []).map((p) => ({
|
||||
id: p.id ? Number(p.id) : undefined,
|
||||
name: p.name,
|
||||
description: p.description,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// Transform Permission to API payload
|
||||
private transformPermissionToApi(perm: Partial<Permission>): any {
|
||||
return {
|
||||
id: perm.id ? Number(perm.id) : undefined,
|
||||
name: perm.name ?? '',
|
||||
description: perm.description,
|
||||
};
|
||||
}
|
||||
|
||||
// Helpers
|
||||
private buildParams(params: ListParams): HttpParams {
|
||||
let httpParams = new HttpParams()
|
||||
.set('page', String(params.page - 1))
|
||||
.set('size', String(params.perPage));
|
||||
if (params.search) {
|
||||
httpParams = httpParams.set('search', params.search);
|
||||
}
|
||||
if (params.sortKey && params.sortDir) {
|
||||
httpParams = httpParams.set('sort', `${params.sortKey},${params.sortDir}`);
|
||||
}
|
||||
return httpParams;
|
||||
}
|
||||
|
||||
/**
|
||||
* LIST roles – supports both backend pagination and fallback to simple GET all
|
||||
*/
|
||||
list(params: ListParams): Observable<PagedResult<Role>> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<RoleApiResponse[]>(this.rolesUrl, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
params: this.buildParams(params),
|
||||
})
|
||||
.pipe(
|
||||
map((data) => {
|
||||
const roles = (data || []).map((r) => this.transformRole(r));
|
||||
return normalizePage<Role>(
|
||||
{ data: roles, meta: { total: roles.length } },
|
||||
params.page,
|
||||
params.perPage
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error('Error fetching roles:', err);
|
||||
return of(
|
||||
normalizePage<Role>({ data: [], meta: { total: 0 } }, params.page, params.perPage)
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback (should not be used anymore)
|
||||
return of(
|
||||
normalizePage<Role>(
|
||||
{
|
||||
data: [],
|
||||
meta: { total: 0 },
|
||||
},
|
||||
params.page,
|
||||
params.perPage
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* LIST all permissions
|
||||
*/
|
||||
allPermissions(): Observable<Permission[]> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<PermissionApiResponse[]>(this.permissionsUrl, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map((res) => (res || []).map((p) => this.transformPermission(p))),
|
||||
catchError((err) => {
|
||||
console.error('Error fetching permissions:', err);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* CREATE role
|
||||
*/
|
||||
create(payload: Omit<Role, 'id'>): Observable<Role> {
|
||||
const apiPayload = this.transformRoleToApi(payload);
|
||||
return this.http
|
||||
.post<RoleApiResponse>(this.rolesUrl, apiPayload, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map((r) => this.transformRole(r)),
|
||||
catchError((err) => {
|
||||
console.error('Error creating role:', err);
|
||||
throw err;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* UPDATE role
|
||||
*/
|
||||
update(id: string, payload: Partial<Role>): Observable<Role | undefined> {
|
||||
const apiPayload = this.transformRoleToApi(payload);
|
||||
return this.http
|
||||
.put<RoleApiResponse>(`${this.rolesUrl}/${id}`, apiPayload, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
map((r) => this.transformRole(r)),
|
||||
catchError((err) => {
|
||||
console.error(`Error updating role ${id}:`, err);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE role
|
||||
*/
|
||||
delete(id: string): Observable<{ success: boolean; error?: string }> {
|
||||
return this.http
|
||||
.delete<void>(`${this.rolesUrl}/${id}`, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map(() => ({ success: true })),
|
||||
catchError((err) => {
|
||||
console.error(`Error deleting role ${id}:`, err);
|
||||
// Check if error is due to role being used by users
|
||||
const errorMessage =
|
||||
err?.error?.message ||
|
||||
err?.message ||
|
||||
(err?.status === 409 || err?.status === 400
|
||||
? 'Ce rôle est utilisé par des utilisateurs et ne peut pas être supprimé'
|
||||
: 'Erreur lors de la suppression du rôle');
|
||||
return of({ success: false, error: errorMessage });
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// --------------- PERMISSIONS CRUD ----------------
|
||||
|
||||
getPermission(id: string): Observable<Permission | null> {
|
||||
return this.http
|
||||
.get<PermissionApiResponse>(`${this.permissionsUrl}/${id}`, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
map((p) => this.transformPermission(p)),
|
||||
catchError((err) => {
|
||||
console.error(`Error fetching permission ${id}:`, err);
|
||||
return of(null);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
createPermission(payload: Omit<Permission, 'id'>): Observable<Permission> {
|
||||
const apiPayload = this.transformPermissionToApi(payload);
|
||||
return this.http
|
||||
.post<PermissionApiResponse>(this.permissionsUrl, apiPayload, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
map((p) => this.transformPermission(p)),
|
||||
catchError((err) => {
|
||||
console.error('Error creating permission:', err);
|
||||
throw err;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
updatePermission(id: string, payload: Partial<Permission>): Observable<Permission | undefined> {
|
||||
const apiPayload = this.transformPermissionToApi(payload);
|
||||
return this.http
|
||||
.put<PermissionApiResponse>(`${this.permissionsUrl}/${id}`, apiPayload, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
map((p) => this.transformPermission(p)),
|
||||
catchError((err) => {
|
||||
console.error(`Error updating permission ${id}:`, err);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
deletePermission(id: string): Observable<{ success: boolean; error?: string }> {
|
||||
return this.http
|
||||
.delete<void>(`${this.permissionsUrl}/${id}`, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map(() => ({ success: true })),
|
||||
catchError((err) => {
|
||||
console.error(`Error deleting permission ${id}:`, err);
|
||||
// Check if error is due to permission being used by roles
|
||||
const errorMessage =
|
||||
err?.error?.message ||
|
||||
err?.message ||
|
||||
(err?.status === 409 || err?.status === 400
|
||||
? 'Cette permission est utilisée par des rôles et ne peut pas être supprimée'
|
||||
: 'Erreur lors de la suppression de la permission');
|
||||
return of({ success: false, error: errorMessage });
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
16
src/app/core/services/theme.spec.ts
Normal file
16
src/app/core/services/theme.spec.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { Theme } from './theme';
|
||||
|
||||
describe('Theme', () => {
|
||||
let service: Theme;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(Theme);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
||||
90
src/app/core/services/theme.ts
Normal file
90
src/app/core/services/theme.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Injectable, OnDestroy, signal } from '@angular/core';
|
||||
|
||||
const STORAGE_KEY = 'pmu_theme'; // 'light' | 'dark' | 'system'
|
||||
|
||||
type Mode = 'light' | 'dark' | 'system';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class Theme implements OnDestroy {
|
||||
mode = signal<Mode>('light');
|
||||
|
||||
private mql?: MediaQueryList;
|
||||
private onMqlChange = (e: MediaQueryListEvent) => {
|
||||
// only react if user selected "system"
|
||||
if (this.mode() === 'system') this.apply('system', /*fromMql*/ true);
|
||||
};
|
||||
|
||||
constructor() {
|
||||
const saved = (localStorage.getItem(STORAGE_KEY) as Mode | null) ?? 'system';
|
||||
this.setupMql();
|
||||
this.apply(saved);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.teardownMql();
|
||||
}
|
||||
|
||||
toggle() {
|
||||
// If you're on "system", decide based on current resolved value
|
||||
const resolved = this.resolve(this.mode());
|
||||
const next: Mode = resolved === 'dark' ? 'light' : 'dark';
|
||||
this.apply(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Optionally expose a 3-state cycle:
|
||||
* light -> dark -> system -> light ...
|
||||
*/
|
||||
cycle() {
|
||||
const order: Mode[] = ['light', 'dark', 'system'];
|
||||
const i = order.indexOf(this.mode());
|
||||
this.apply(order[(i + 1) % order.length]);
|
||||
}
|
||||
|
||||
apply(next: Mode, fromMql = false) {
|
||||
this.mode.set(next);
|
||||
const root = document.documentElement;
|
||||
const resolved = this.resolve(next);
|
||||
|
||||
// toggle class
|
||||
root.classList.toggle('dark', resolved === 'dark');
|
||||
// attribute for any 3rd-party styling
|
||||
root.setAttribute('data-theme', resolved);
|
||||
|
||||
// store only when user explicitly changed (avoid thrashing on mql change)
|
||||
if (!fromMql) localStorage.setItem(STORAGE_KEY, next);
|
||||
}
|
||||
|
||||
private resolve(mode: Mode): 'light' | 'dark' {
|
||||
// SSR guard
|
||||
if (typeof window === 'undefined') return mode === 'dark' ? 'dark' : 'light';
|
||||
|
||||
if (mode !== 'system') return mode;
|
||||
const prefersDark = this.mql?.matches ?? false;
|
||||
return prefersDark ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
private setupMql() {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return;
|
||||
this.mql = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
// modern browsers
|
||||
if ('addEventListener' in this.mql) {
|
||||
this.mql.addEventListener('change', this.onMqlChange);
|
||||
} else {
|
||||
// Safari < 14 fallback
|
||||
// @ts-expect-error legacy
|
||||
this.mql.addListener(this.onMqlChange);
|
||||
}
|
||||
}
|
||||
|
||||
private teardownMql() {
|
||||
if (!this.mql) return;
|
||||
if ('removeEventListener' in this.mql) {
|
||||
this.mql.removeEventListener('change', this.onMqlChange);
|
||||
} else {
|
||||
// @ts-expect-error legacy
|
||||
this.mql.removeListener(this.onMqlChange);
|
||||
}
|
||||
}
|
||||
}
|
||||
467
src/app/core/services/tpe.ts
Normal file
467
src/app/core/services/tpe.ts
Normal file
@@ -0,0 +1,467 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { map, catchError, switchMap } from 'rxjs/operators';
|
||||
import { TpeDevice, TpeStatus, TpeType } from '../interfaces/tpe';
|
||||
import { Agent, AgentStatus } from '../interfaces/agent';
|
||||
import { environment } from 'src/environments/environment.development';
|
||||
import { normalizePage } from '@shared/paging/normalize-page';
|
||||
import { ListParams, PagedResult } from '@shared/paging/paging';
|
||||
|
||||
const USE_SERVER = true;
|
||||
const API_BASE = '/api/v1/tpes';
|
||||
|
||||
// Interface to match the API response structure for Agent (nested in TPE)
|
||||
interface AgentApiResponse {
|
||||
id: number;
|
||||
code: string;
|
||||
profile: string;
|
||||
principalCode?: string;
|
||||
caisseProfile?: string;
|
||||
statut: string;
|
||||
zone?: string;
|
||||
kiosk?: string;
|
||||
fonction?: string;
|
||||
dateEmbauche?: string;
|
||||
nom: string;
|
||||
prenom: string;
|
||||
autresNoms?: string;
|
||||
dateNaissance?: string;
|
||||
lieuNaissance?: string;
|
||||
ville?: string;
|
||||
adresse?: string;
|
||||
autoriserAides?: boolean;
|
||||
phone: string;
|
||||
pin?: string;
|
||||
limiteInferieure?: number;
|
||||
limiteSuperieure?: number;
|
||||
limiteParTransaction?: number;
|
||||
limiteMinAirtime?: number;
|
||||
limiteMaxAirtime?: number;
|
||||
maxPeripheriques?: number;
|
||||
limitId?: number;
|
||||
nationalite?: string;
|
||||
cni?: string;
|
||||
cniDelivreeLe?: string;
|
||||
cniDelivreeA?: string;
|
||||
residence?: string;
|
||||
autreAdresse1?: string;
|
||||
statutMarital?: string;
|
||||
epoux?: string;
|
||||
autreTelephone?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
createdBy?: string;
|
||||
}
|
||||
|
||||
// Interface to match the API response structure
|
||||
interface TpeApiResponse {
|
||||
id: number;
|
||||
imei: string;
|
||||
serial: string;
|
||||
type: string;
|
||||
marque: string;
|
||||
modele: string;
|
||||
statut: string; // API uses uppercase: VALIDE, INVALIDE, EN_PANNE, BLOQUE
|
||||
agent?: AgentApiResponse;
|
||||
assigne: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
// Stats interfaces
|
||||
interface CountByStatutResponse {
|
||||
[key: string]: number;
|
||||
}
|
||||
|
||||
// Assignment stats is just a number (count of assigned TPEs)
|
||||
type AssignesStatsResponse = number;
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class TpeService {
|
||||
private apiUrl = environment.apiBaseUrl + API_BASE;
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
// Helper method to get ngrok bypass headers
|
||||
private getNgrokHeaders(): Record<string, string> {
|
||||
const isNgrok =
|
||||
environment.apiBaseUrl.includes('ngrok-free.app') ||
|
||||
environment.apiBaseUrl.includes('ngrok.io') ||
|
||||
environment.apiBaseUrl.includes('ngrok');
|
||||
return isNgrok ? { 'ngrok-skip-browser-warning': 'true' } : {};
|
||||
}
|
||||
|
||||
// Transform API statut to interface statut (both use uppercase now)
|
||||
private transformStatut(apiStatut: string): TpeStatus {
|
||||
const upperStatut = apiStatut.toUpperCase() as TpeStatus;
|
||||
const validStatuses: TpeStatus[] = [
|
||||
'VALIDE',
|
||||
'INVALIDE',
|
||||
'EN_PANNE',
|
||||
'BLOQUE',
|
||||
'DISPONIBLE',
|
||||
'AFFECTE',
|
||||
'EN_MAINTENANCE',
|
||||
'HORS_SERVICE',
|
||||
'VOLE',
|
||||
];
|
||||
return validStatuses.includes(upperStatut) ? upperStatut : 'INVALIDE';
|
||||
}
|
||||
|
||||
// Transform interface statut to API statut (both use uppercase now, so direct return)
|
||||
private transformStatutToApi(statut: TpeStatus): string {
|
||||
return statut; // Already uppercase, no transformation needed
|
||||
}
|
||||
|
||||
// Transform API Agent response to Agent
|
||||
private transformAgent(apiAgent: AgentApiResponse): Agent {
|
||||
return {
|
||||
id: String(apiAgent.id),
|
||||
code: apiAgent.code,
|
||||
profile: apiAgent.profile,
|
||||
principalCode: apiAgent.principalCode,
|
||||
caisseProfile: apiAgent.caisseProfile,
|
||||
statut: apiAgent.statut as AgentStatus,
|
||||
zone: apiAgent.zone,
|
||||
kiosk: apiAgent.kiosk,
|
||||
fonction: apiAgent.fonction,
|
||||
dateEmbauche: apiAgent.dateEmbauche,
|
||||
nom: apiAgent.nom,
|
||||
prenom: apiAgent.prenom,
|
||||
autresNoms: apiAgent.autresNoms,
|
||||
dateNaissance: apiAgent.dateNaissance,
|
||||
lieuNaissance: apiAgent.lieuNaissance,
|
||||
ville: apiAgent.ville,
|
||||
adresse: apiAgent.adresse,
|
||||
autoriserAides: apiAgent.autoriserAides,
|
||||
phone: apiAgent.phone,
|
||||
pin: apiAgent.pin,
|
||||
limiteInferieure: apiAgent.limiteInferieure,
|
||||
limiteSuperieure: apiAgent.limiteSuperieure,
|
||||
limiteParTransaction: apiAgent.limiteParTransaction,
|
||||
limiteMinAirtime: apiAgent.limiteMinAirtime,
|
||||
limiteMaxAirtime: apiAgent.limiteMaxAirtime,
|
||||
maxPeripheriques: apiAgent.maxPeripheriques,
|
||||
limitId: apiAgent.limitId ? String(apiAgent.limitId) : undefined,
|
||||
nationalite: apiAgent.nationalite,
|
||||
cni: apiAgent.cni,
|
||||
cniDelivreeLe: apiAgent.cniDelivreeLe,
|
||||
cniDelivreeA: apiAgent.cniDelivreeA,
|
||||
residence: apiAgent.residence,
|
||||
autreAdresse1: apiAgent.autreAdresse1,
|
||||
statutMarital: apiAgent.statutMarital,
|
||||
epoux: apiAgent.epoux,
|
||||
autreTelephone: apiAgent.autreTelephone,
|
||||
createdAt: apiAgent.createdAt,
|
||||
updatedAt: apiAgent.updatedAt,
|
||||
createdBy: apiAgent.createdBy,
|
||||
};
|
||||
}
|
||||
|
||||
// Transform API response to TpeDevice
|
||||
private transformTpe(apiTpe: TpeApiResponse): TpeDevice {
|
||||
return {
|
||||
id: String(apiTpe.id),
|
||||
imei: apiTpe.imei,
|
||||
serial: apiTpe.serial,
|
||||
type: apiTpe.type as TpeType,
|
||||
marque: apiTpe.marque,
|
||||
modele: apiTpe.modele,
|
||||
statut: this.transformStatut(apiTpe.statut),
|
||||
agent: apiTpe.agent ? this.transformAgent(apiTpe.agent) : undefined,
|
||||
assigne: apiTpe.assigne,
|
||||
createdAt: apiTpe.createdAt,
|
||||
updatedAt: apiTpe.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// Transform TpeDevice to API payload
|
||||
private transformToApiPayload(tpe: Partial<TpeDevice>): any {
|
||||
const payload: any = {};
|
||||
if (tpe.imei !== undefined) payload.imei = tpe.imei;
|
||||
if (tpe.serial !== undefined) payload.serial = tpe.serial;
|
||||
if (tpe.type !== undefined) payload.type = tpe.type;
|
||||
if (tpe.marque !== undefined) payload.marque = tpe.marque;
|
||||
if (tpe.modele !== undefined) payload.modele = tpe.modele;
|
||||
if (tpe.statut !== undefined) payload.statut = this.transformStatutToApi(tpe.statut);
|
||||
if (tpe.assigne !== undefined) payload.assigne = tpe.assigne;
|
||||
return payload;
|
||||
}
|
||||
|
||||
// GET /api/v1/tpes/{id} - Get by ID
|
||||
getById(id: string): Observable<TpeDevice | undefined> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<TpeApiResponse>(`${this.apiUrl}/${id}`, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map((apiTpe) => this.transformTpe(apiTpe)),
|
||||
catchError((err) => {
|
||||
console.error(`Error fetching TPE ${id}:`, err);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(undefined);
|
||||
}
|
||||
|
||||
// GET /api/v1/tpes - List all
|
||||
list(params?: ListParams): Observable<PagedResult<TpeDevice>> {
|
||||
if (USE_SERVER) {
|
||||
let httpParams = new HttpParams();
|
||||
if (params) {
|
||||
if (params.page) httpParams = httpParams.set('page', params.page.toString());
|
||||
if (params.perPage) httpParams = httpParams.set('perPage', params.perPage.toString());
|
||||
if (params.search) httpParams = httpParams.set('search', params.search);
|
||||
if (params.sortKey) httpParams = httpParams.set('sortKey', params.sortKey);
|
||||
if (params.sortDir) httpParams = httpParams.set('sortDir', params.sortDir);
|
||||
}
|
||||
|
||||
return this.http
|
||||
.get<TpeApiResponse[]>(this.apiUrl, {
|
||||
params: httpParams,
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
map((list) => {
|
||||
const tpes = list.map((apiTpe) => this.transformTpe(apiTpe));
|
||||
// If pagination params provided, return paginated result
|
||||
if (params) {
|
||||
return normalizePage<TpeDevice>(
|
||||
{ data: tpes, meta: { total: tpes.length } },
|
||||
params.page || 1,
|
||||
params.perPage || 10
|
||||
);
|
||||
}
|
||||
// Otherwise return all as single page
|
||||
return normalizePage<TpeDevice>(
|
||||
{ data: tpes, meta: { total: tpes.length } },
|
||||
1,
|
||||
tpes.length
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error('Error fetching TPEs:', err);
|
||||
return of(normalizePage<TpeDevice>({ data: [], meta: { total: 0 } }, 1, 10));
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(normalizePage<TpeDevice>({ data: [], meta: { total: 0 } }, 1, 10));
|
||||
}
|
||||
|
||||
// POST /api/v1/tpes - Create
|
||||
create(payload: Omit<TpeDevice, 'id' | 'createdAt' | 'updatedAt'>): Observable<TpeDevice> {
|
||||
if (USE_SERVER) {
|
||||
const apiPayload = this.transformToApiPayload(payload);
|
||||
return this.http
|
||||
.post<TpeApiResponse>(this.apiUrl, apiPayload, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map((apiTpe) => this.transformTpe(apiTpe)),
|
||||
catchError((err) => {
|
||||
console.error('Error creating TPE:', err);
|
||||
throw err;
|
||||
})
|
||||
);
|
||||
}
|
||||
throw new Error('Server mode is required');
|
||||
}
|
||||
|
||||
// PUT /api/v1/tpes/{id} - Update
|
||||
update(id: string, payload: Partial<TpeDevice>): Observable<TpeDevice | undefined> {
|
||||
if (USE_SERVER) {
|
||||
const apiPayload = this.transformToApiPayload(payload);
|
||||
return this.http
|
||||
.put<TpeApiResponse>(`${this.apiUrl}/${id}`, apiPayload, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
map((apiTpe) => this.transformTpe(apiTpe)),
|
||||
catchError((err) => {
|
||||
console.error(`Error updating TPE ${id}:`, err);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(undefined);
|
||||
}
|
||||
|
||||
// DELETE /api/v1/tpes/{id} - Delete
|
||||
delete(id: string): Observable<boolean> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.delete<void>(`${this.apiUrl}/${id}`, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map(() => true),
|
||||
catchError((err) => {
|
||||
console.error(`Error deleting TPE ${id}:`, err);
|
||||
return of(false);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(false);
|
||||
}
|
||||
|
||||
// PATCH /api/v1/tpes/{id}/statut - Update statut
|
||||
updateStatut(id: string, statut: TpeStatus): Observable<TpeDevice | undefined> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.patch<TpeApiResponse>(
|
||||
`${this.apiUrl}/${id}/statut`,
|
||||
{ statut: this.transformStatutToApi(statut) },
|
||||
{ headers: this.getNgrokHeaders() }
|
||||
)
|
||||
.pipe(
|
||||
map((apiTpe) => this.transformTpe(apiTpe)),
|
||||
catchError((err) => {
|
||||
console.error(`Error updating TPE statut ${id}:`, err);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(undefined);
|
||||
}
|
||||
|
||||
// PATCH /api/v1/tpes/{id}/liberer - Liberate TPE (updates whole TPE, sets assigne to false and statut to DISPONIBLE)
|
||||
liberer(id: string): Observable<TpeDevice | undefined> {
|
||||
if (USE_SERVER) {
|
||||
// First get the current TPE data
|
||||
return this.getById(id).pipe(
|
||||
switchMap((tpe) => {
|
||||
if (!tpe) {
|
||||
return of(undefined);
|
||||
}
|
||||
// Update the whole TPE with assigne set to false and statut to DISPONIBLE
|
||||
const updatedTpe = { ...tpe, assigne: false, statut: 'DISPONIBLE' as TpeStatus };
|
||||
const apiPayload = this.transformToApiPayload(updatedTpe);
|
||||
return this.http
|
||||
.patch<TpeApiResponse>(`${this.apiUrl}/liberer/${id}`, apiPayload, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
map((apiTpe) => this.transformTpe(apiTpe)),
|
||||
catchError((err) => {
|
||||
console.error(`Error liberating TPE ${id}:`, err);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error(`Error fetching TPE ${id} for liberation:`, err);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(undefined);
|
||||
}
|
||||
|
||||
// PATCH /api/v1/tpes/assigner - Assign TPE
|
||||
// Payload: { tpeId: number, agentId: number }
|
||||
assigner(id: string, agentId: string): Observable<TpeDevice | undefined> {
|
||||
if (USE_SERVER) {
|
||||
const payload = {
|
||||
tpeId: Number(id),
|
||||
agentId: Number(agentId),
|
||||
};
|
||||
return this.http
|
||||
.patch<TpeApiResponse>(`${this.apiUrl}/assigner`, payload, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
map((apiTpe) => this.transformTpe(apiTpe)),
|
||||
catchError((err) => {
|
||||
console.error(`Error assigning TPE ${id}:`, err);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(undefined);
|
||||
}
|
||||
|
||||
// GET /api/v1/tpes/statut/{statut} - List by statut
|
||||
getByStatut(statut: TpeStatus): Observable<TpeDevice[]> {
|
||||
if (USE_SERVER) {
|
||||
const apiStatut = this.transformStatutToApi(statut);
|
||||
return this.http
|
||||
.get<TpeApiResponse[]>(`${this.apiUrl}/statut/${apiStatut}`, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
map((list) => list.map((apiTpe) => this.transformTpe(apiTpe))),
|
||||
catchError((err) => {
|
||||
console.error(`Error fetching TPEs by statut ${statut}:`, err);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of([]);
|
||||
}
|
||||
|
||||
// GET /api/v1/tpes/stats/count-by-statut - Get count by statut
|
||||
getCountByStatut(): Observable<CountByStatutResponse> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<CountByStatutResponse>(`${this.apiUrl}/stats/count-by-statut`, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
console.error('Error fetching TPE count by statut:', err);
|
||||
return of({});
|
||||
})
|
||||
);
|
||||
}
|
||||
return of({});
|
||||
}
|
||||
|
||||
// GET /api/v1/tpes/stats/assignes - Get assignment stats (returns a number)
|
||||
getAssignesStats(): Observable<number> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<number>(`${this.apiUrl}/stats/assignes`, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
console.error('Error fetching TPE assignment stats:', err);
|
||||
return of(0);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(0);
|
||||
}
|
||||
|
||||
// GET /api/v1/tpes/search - Search
|
||||
search(query: string): Observable<TpeDevice[]> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<TpeApiResponse[]>(`${this.apiUrl}/search`, {
|
||||
params: { q: query.trim() },
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
map((list) => list.map((apiTpe) => this.transformTpe(apiTpe))),
|
||||
catchError((err) => {
|
||||
console.error(`Error searching TPEs with query ${query}:`, err);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of([]);
|
||||
}
|
||||
|
||||
// GET /api/v1/tpes/disponibles - List available TPEs
|
||||
getDisponibles(): Observable<TpeDevice[]> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<TpeApiResponse[]>(`${this.apiUrl}/disponibles`, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map((list) => list.map((apiTpe) => this.transformTpe(apiTpe))),
|
||||
catchError((err) => {
|
||||
console.error('Error fetching available TPEs:', err);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of([]);
|
||||
}
|
||||
}
|
||||
162
src/app/core/services/user.ts
Normal file
162
src/app/core/services/user.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { map, catchError } from 'rxjs/operators';
|
||||
import { User } from '../interfaces/user';
|
||||
import { PaginatedHttpService } from '@shared/paging/paginated-http.service';
|
||||
import { ListParams, PagedResult, SortDir } from '@shared/paging/paging';
|
||||
import { normalizePage } from '@shared/paging/normalize-page';
|
||||
import { environment } from 'src/environments/environment.development';
|
||||
|
||||
const USE_SERVER = true;
|
||||
const API_BASE = '/api/v1/users';
|
||||
|
||||
// Backend payload
|
||||
interface UserApiResponse {
|
||||
id: number;
|
||||
nom: string;
|
||||
prenom: string;
|
||||
identifiant: string;
|
||||
password?: string;
|
||||
matriculeAgent: string;
|
||||
roleId: number;
|
||||
restrictionConnexion: boolean;
|
||||
restrictionAutomatique: boolean;
|
||||
nombreIpAutorise: number;
|
||||
nombreIpAutoAutorise: number;
|
||||
statut: string;
|
||||
derniereConnexion?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class UserService {
|
||||
private apiUrl = environment.apiBaseUrl + API_BASE;
|
||||
|
||||
constructor(private http: HttpClient, private paginatedHttp: PaginatedHttpService) {}
|
||||
|
||||
// Helper method to get ngrok bypass headers
|
||||
private getNgrokHeaders(): Record<string, string> {
|
||||
const isNgrok =
|
||||
environment.apiBaseUrl.includes('ngrok-free.app') ||
|
||||
environment.apiBaseUrl.includes('ngrok.io') ||
|
||||
environment.apiBaseUrl.includes('ngrok');
|
||||
return isNgrok ? { 'ngrok-skip-browser-warning': 'true' } : {};
|
||||
}
|
||||
|
||||
private transform(api: UserApiResponse): User {
|
||||
return {
|
||||
id: String(api.id),
|
||||
nom: api.nom,
|
||||
prenom: api.prenom,
|
||||
identifiant: api.identifiant,
|
||||
// We never expose password back to UI
|
||||
matriculeAgent: api.matriculeAgent,
|
||||
roleId: String(api.roleId),
|
||||
restrictionConnexion: api.restrictionConnexion,
|
||||
restrictionAutomatique: api.restrictionAutomatique,
|
||||
nombreIpAutorise: api.nombreIpAutorise,
|
||||
nombreIpAutoAutorise: api.nombreIpAutoAutorise,
|
||||
statut: api.statut,
|
||||
derniereConnexion: api.derniereConnexion,
|
||||
createdAt: api.createdAt,
|
||||
updatedAt: api.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private transformToApiPayload(user: Partial<User>): Partial<UserApiResponse> {
|
||||
return {
|
||||
id: user.id ? Number(user.id) : undefined,
|
||||
nom: user.nom ?? '',
|
||||
prenom: user.prenom ?? '',
|
||||
identifiant: user.identifiant ?? '',
|
||||
password: user.password,
|
||||
matriculeAgent: user.matriculeAgent ?? '',
|
||||
roleId: user.roleId ? Number(user.roleId) : 0,
|
||||
restrictionConnexion: user.restrictionConnexion ?? false,
|
||||
restrictionAutomatique: user.restrictionAutomatique ?? false,
|
||||
nombreIpAutorise: user.nombreIpAutorise ?? 0,
|
||||
nombreIpAutoAutorise: user.nombreIpAutoAutorise ?? 0,
|
||||
statut: user.statut ?? 'Actif',
|
||||
derniereConnexion: user.derniereConnexion,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
list(params: ListParams): Observable<PagedResult<User>> {
|
||||
if (USE_SERVER) {
|
||||
// Backend returns full list; paginate client-side
|
||||
return this.http
|
||||
.get<UserApiResponse[]>(this.apiUrl, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map((items) => (items || []).map((u) => this.transform(u))),
|
||||
map((users) => {
|
||||
const q = (params.search ?? '').toLowerCase();
|
||||
let data = users;
|
||||
|
||||
if (q) {
|
||||
data = data.filter((u) =>
|
||||
[u.nom, u.prenom, u.identifiant, u.matriculeAgent, u.statut]
|
||||
.filter(Boolean)
|
||||
.map((x) => String(x).toLowerCase())
|
||||
.some((s) => s.includes(q))
|
||||
);
|
||||
}
|
||||
|
||||
if (params.sortKey && params.sortDir) {
|
||||
const { sortKey, sortDir } = params as { sortKey: string; sortDir: SortDir };
|
||||
const getValue = (obj: any, path: string) =>
|
||||
path.split('.').reduce((o, k) => o?.[k], obj);
|
||||
data = [...data].sort((a: any, b: any) => {
|
||||
const sa = String(getValue(a, sortKey) ?? '');
|
||||
const sb = String(getValue(b, sortKey) ?? '');
|
||||
const cmp = sa.localeCompare(sb, 'fr', { numeric: true });
|
||||
return sortDir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
}
|
||||
|
||||
const start = (params.page - 1) * params.perPage;
|
||||
const pageData = data.slice(start, start + params.perPage);
|
||||
|
||||
return normalizePage<User>(
|
||||
{ data: pageData, meta: { total: data.length } },
|
||||
params.page,
|
||||
params.perPage
|
||||
);
|
||||
}),
|
||||
catchError(() =>
|
||||
of(normalizePage<User>({ data: [], meta: { total: 0 } }, params.page, params.perPage))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback should not be used anymore
|
||||
return of(normalizePage<User>({ data: [], meta: { total: 0 } }, params.page, params.perPage));
|
||||
}
|
||||
|
||||
create(payload: Omit<User, 'id'>): Observable<User> {
|
||||
const body = this.transformToApiPayload(payload);
|
||||
return this.http
|
||||
.post<UserApiResponse>(this.apiUrl, body, { headers: this.getNgrokHeaders() })
|
||||
.pipe(map((res) => this.transform(res)));
|
||||
}
|
||||
|
||||
update(id: string, payload: Partial<User>): Observable<User | undefined> {
|
||||
const body = this.transformToApiPayload({ ...payload, id });
|
||||
return this.http
|
||||
.put<UserApiResponse>(`${this.apiUrl}/${id}`, body, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map((res) => this.transform(res)),
|
||||
catchError(() => of(undefined))
|
||||
);
|
||||
}
|
||||
|
||||
delete(id: string): Observable<boolean> {
|
||||
return this.http.delete<void>(`${this.apiUrl}/${id}`, { headers: this.getNgrokHeaders() }).pipe(
|
||||
map(() => true),
|
||||
catchError(() => of(false))
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user