first commit

This commit is contained in:
OnlyPapy98
2025-12-16 14:20:02 +01:00
commit dde2e8aebf
320 changed files with 30462 additions and 0 deletions

View 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 {}

View 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();
});
});

View 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');
};

View 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
};

View 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);
}
}

View 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();
});
});

View 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}` } }));
}
}

View 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();
});
});

View 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);
})
);
}
}

View 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;
}

View 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';
}

View 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;
}

View 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;
}

View File

@@ -0,0 +1,7 @@
export interface MenuItem {
icon: string;
label: string;
exact?: boolean;
link?: string;
submenu?: MenuItem[];
}

View 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[];
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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',
},
];

View 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)),
// ];

View 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 lAvenir',
'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 lUnité',
'Prix du Bénin',
'Grand Prix de Sikasso',
'Prix du Commerce',
'Prix du Plateau',
'Course des Champions',
'Trophée de lEspoir',
'Prix du Développement',
'Prix de lAmitié',
'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;

View 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 lArc 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 dobstacles.',
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 dobstacles.',
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 dEl 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 dIvoire
{
id: crypto.randomUUID(),
nom: 'Hippodrome dAbidjan',
ville: 'Abidjan',
pays: 'Côte dIvoire',
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 dIvoire',
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 dAvenches',
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 dAlgérie.',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: crypto.randomUUID(),
nom: 'Hippodrome dOran',
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(),
},
];

View 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));
}

View 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 lAvenir',
'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;
});
});

View 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(),
},
];

View 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)
// ),
// ];

View 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)
),
];

View 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([]);
}
}

View 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([]);
}
}

View 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);
}
}

View 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();
});
});

View 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;
}
}

View 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();
});
});

View 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);
}
}

View 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();
});
});

View 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');
}
}

View 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();
});
});

View 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));
}
}

View 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([]);
}
}

View 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);
}
}

View 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,
};
}
}

View 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();
});
});

View 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);
}
}

View 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 });
})
);
}
}

View 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();
});
});

View 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);
}
}
}

View 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([]);
}
}

View 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))
);
}
}