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

22
src/app/app.config.ts Normal file
View File

@@ -0,0 +1,22 @@
import {
ApplicationConfig,
LOCALE_ID,
provideBrowserGlobalErrorListeners,
provideZonelessChangeDetection,
} from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideAnimations } from '@angular/platform-browser/animations';
import { routes } from './app.routes';
import { provideHttpClient } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideZonelessChangeDetection(),
provideRouter(routes),
provideHttpClient(),
provideAnimations(),
{ provide: LOCALE_ID, useValue: 'fr-FR' },
],
};

0
src/app/app.css Normal file
View File

2
src/app/app.html Normal file
View File

@@ -0,0 +1,2 @@
<router-outlet></router-outlet>
<z-toaster position="top-right" [richColors]="true" />

14
src/app/app.routes.ts Normal file
View File

@@ -0,0 +1,14 @@
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: 'auth',
// loadChildren: () => import('./auth/auth.module').then((m) => m.AuthModule),
loadChildren: () => import('./auth/auth-module').then((m) => m.AuthModule),
},
{
path: '',
loadChildren: () => import('./dashboard/dashboard-module').then((m) => m.DashboardModule),
},
{ path: '**', redirectTo: 'auth/login' },
];

25
src/app/app.spec.ts Normal file
View File

@@ -0,0 +1,25 @@
import { provideZonelessChangeDetection } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { App } from './app';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
providers: [provideZonelessChangeDetection()]
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it('should render title', () => {
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, pjp');
});
});

13
src/app/app.ts Normal file
View File

@@ -0,0 +1,13 @@
import { Component, signal } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { ZardToastComponent } from '@shared/components/toast/toast.component';
@Component({
selector: 'app-root',
imports: [RouterOutlet, ZardToastComponent],
templateUrl: './app.html',
styleUrl: './app.css',
})
export class App {
protected readonly title = signal('pjp');
}

View File

@@ -0,0 +1,3 @@
:host {
display: contents;
}

View File

@@ -0,0 +1,74 @@
<div class="min-h-screen relative overflow-hidden bg-surface text-text">
<header
class="absolute inset-x-0 top-10 z-20 h-16 px-4 max-w-7xl mx-auto flex items-center justify-between"
>
<div class="h-full flex items-center gap-2">
<div class="flex items-center gap-3">
<app-pmu-logo variant="default" />
</div>
</div>
<app-mode-toggle></app-mode-toggle>
</header>
<!-- Animated background shapes -->
<div class="pointer-events-none absolute inset-0">
<div
class="absolute -top-28 -left-24 h-72 w-72 rounded-full blur-3xl opacity-40"
style="background: radial-gradient(closest-side, #1c5a29, transparent)"
></div>
<div
class="absolute top-24 -right-16 h-80 w-80 rounded-full blur-3xl opacity-40"
style="background: radial-gradient(closest-side, #fae500, transparent)"
></div>
<div
class="absolute -bottom-10 left-1/3 h-80 w-80 rounded-full blur-3xl opacity-30"
style="background: radial-gradient(closest-side, #c31617, transparent)"
></div>
</div>
<!-- Split layout -->
<div
class="relative z-10 max-w-7xl mx-auto min-h-screen grid grid-cols-1 items-center lg:grid-cols-2 px-4"
>
<!-- Visual side -->
<aside class="hidden lg:flex relative items-center justify-start">
<div class="relative max-w-lg pr-4">
<div class="back backdrop-blur-2xl">
<h2 class="text-2xl font-semibold text-heading">Plateforme de gestion</h2>
<p class="text-sm mt-2">
Gérez les courses, chevaux, paris, résultats et gains dans une interface moderne et
performante.
</p>
<div class="mt-6 grid grid-cols-3 gap-3">
<div class="p-4 border text-center">
<div class="text-2xl font-bold">24/7</div>
<div class="text-xs">Disponibilité</div>
</div>
<div class="p-4 border text-center">
<div class="text-2xl font-bold">XOF</div>
<div class="text-xs">Monnaie locale</div>
</div>
<div class="p-4 border text-center">
<div class="text-2xl font-bold">API</div>
<div class="text-xs">Intégration</div>
</div>
</div>
</div>
<div
class="absolute -bottom-8 right-10 bg-pmu-jaune text-black px-4 py-2 rounded-full shadow-lg animate-float"
>
Opérationnel & Sécurisé
</div>
</div>
</aside>
<!-- Form side -->
<main class="flex items-center justify-center lg:justify-end w-full">
<div class="w-full max-w-md">
<router-outlet></router-outlet>
</div>
</main>
</div>
</div>

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AuthLayout } from './auth-layout';
describe('AuthLayout', () => {
let component: AuthLayout;
let fixture: ComponentFixture<AuthLayout>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AuthLayout]
})
.compileComponents();
fixture = TestBed.createComponent(AuthLayout);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,16 @@
import { Component } from '@angular/core';
import { Theme } from 'src/app/core/services/theme';
@Component({
selector: 'app-auth-layout',
templateUrl: './auth-layout.html',
styleUrl: './auth-layout.css',
standalone: false,
})
export class AuthLayout {
constructor(public theme: Theme) {}
toggleTheme() {
this.theme.toggle();
}
}

View File

@@ -0,0 +1,26 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { AuthRoutingModule } from './auth-routing-module';
import { AuthLayout } from './auth-layout/auth-layout';
import { Login } from './pages/login/login';
import { ModeToggle } from '@shared/components/mode-toggle/mode-toggle';
import { SharedModule } from '@shared/shared-module';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { ZardSwitchComponent } from '@shared/components/switch/switch.component';
import { PmuLogo } from '@shared/components/pmu-logo/pmu-logo';
@NgModule({
declarations: [AuthLayout, Login],
imports: [
CommonModule,
AuthRoutingModule,
SharedModule,
ModeToggle,
PmuLogo,
FormsModule,
ReactiveFormsModule,
ZardSwitchComponent,
],
})
export class AuthModule {}

View File

@@ -0,0 +1,24 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthLayout } from './auth-layout/auth-layout';
import { Login } from './pages/login/login';
const routes: Routes = [
{
path: '',
component: AuthLayout,
children: [
{
path: 'login',
component: Login,
},
{ path: '', pathMatch: 'full', redirectTo: 'login' },
],
},
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class AuthRoutingModule {}

View File

View File

@@ -0,0 +1,84 @@
<div class="rounded-2xl p-6 shadow backdrop-blur animate-glow">
<div class="flex items-center gap-3">
<app-pmu-logo variant="default" />
<h1 class="text-xl font-semibold">Connexion</h1>
</div>
<p class="mt-1 text-sm">Accédez à votre espace PMU MALI</p>
<form class="mt-6 space-y-4" (ngSubmit)="submit()" [formGroup]="form">
<!-- Identifiant -->
<div>
<label class="text-sm">Identifiant</label>
<div class="relative mt-1">
<input
class="w-full rounded-md border p-2 bg-transparent outline-none focus:ring-2 focus:ring-[var(--ring)] placeholder:text-black/50 dark:placeholder:text-white/50"
type="text"
autocomplete="username"
placeholder="ex: AGENT001"
formControlName="identifiant"
/>
</div>
@if (form.controls['identifiant'].touched && form.controls['identifiant'].invalid) {
<div class="mt-1 text-xs text-red-600">Identifiant requis</div>
}
</div>
<!-- Password -->
<div>
<label class="text-sm">Mot de passe</label>
<div class="relative mt-1">
<input
class="w-full rounded-md border p-2 pr-10 bg-transparent outline-none focus:ring-2 focus:ring-[var(--ring)] placeholder:text-black/60 dark:placeholder:text-white/60"
[type]="showPassword ? 'text' : 'password'"
autocomplete="current-password"
formControlName="password"
placeholder="••••••••"
/>
<button
type="button"
class="absolute inset-y-0 right-2 my-auto text-sm opacity-70 hover:opacity-100"
(click)="showPassword = !showPassword"
aria-label="Afficher le mot de passe"
>
{{ showPassword ? 'Masquer' : 'Afficher' }}
</button>
</div>
@if (form.controls['password'].touched && form.controls['password'].invalid) {
<div class="mt-1 text-xs text-red-600">8 caractères minimum</div>
}
</div>
<!-- Submit -->
<button
type="submit"
class="inline-flex w-full items-center justify-center rounded-md cursor-pointer px-3 py-2 text-sm font-medium text-white bg-pmu-vert transition hover:opacity-90 disabled:opacity-40"
[disabled]="loading() || !form.valid"
>
@if (!loading()) {
<span>Se connecter</span>
} @if (loading()) {
<span class="inline-flex items-center gap-2">
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 0 1 8-8v4A4 4 0 0 0 8 12H4z"
/>
</svg>
Connexion…
</span>
}
</button>
</form>
<div class="mt-6 text-xs">Plateforme de Jeux de la PMU.</div>
</div>

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Login } from './login';
describe('Login', () => {
let component: Login;
let fixture: ComponentFixture<Login>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Login]
})
.compileComponents();
fixture = TestBed.createComponent(Login);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,47 @@
import { Component, signal } from '@angular/core'; // Import OnInit
import { Validators, FormBuilder, FormGroup } from '@angular/forms'; // Import FormGroup
import { Router } from '@angular/router';
import { toast } from 'ngx-sonner';
import { Auth } from 'src/app/core/services/auth';
@Component({
selector: 'app-login',
templateUrl: './login.html',
styleUrl: './login.css',
standalone: false,
})
export class Login {
showPassword = false;
loading = signal(false);
errorMsg = signal('');
form!: FormGroup;
constructor(private fb: FormBuilder, private auth: Auth, private router: Router) {
this.form = this.fb.group({
identifiant: ['', [Validators.required]],
password: ['', [Validators.required, Validators.minLength(8)]],
});
}
async submit() {
this.errorMsg.set('');
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
this.loading.set(true);
try {
const { identifiant, password } = this.form.value;
await this.auth.login(identifiant!, password!);
await this.router.navigateByUrl('/');
toast.success('Connexion réussie ! Bienvenue.');
} catch (e: any) {
this.errorMsg.set(
e?.message || e?.error?.message || 'Échec de connexion. Veuillez réessayer.'
);
toast.error(this.errorMsg(), { duration: 5000 });
} finally {
this.loading.set(false);
}
}
}

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

View File

@@ -0,0 +1,36 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DashboardRoutingModule } from './dashboard-routing-module';
import {
Ban,
FolderPen,
Lock,
LucideAngularModule,
Printer,
RefreshCw,
SlidersHorizontal,
Trash2,
Trophy,
Unlink2,
} from 'lucide-angular';
@NgModule({
declarations: [],
imports: [
CommonModule,
DashboardRoutingModule,
LucideAngularModule.pick({
FolderPen,
Trash2,
Ban,
Trophy,
Lock,
Printer,
RefreshCw,
SlidersHorizontal,
Unlink2,
}),
],
})
export class DashboardModule {}

View File

@@ -0,0 +1,67 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { Layout } from './layout/layout';
import { authGuard } from '../core/guards/auth-guard';
const routes: Routes = [
{
path: '',
component: Layout,
canActivate: [authGuard],
children: [
{ path: '', loadComponent: () => import('./pages/main/main').then((m) => m.Main) },
{
path: 'courses',
loadComponent: () => import('./pages/courses/courses').then((m) => m.Course),
},
{
path: 'hippodromes',
loadComponent: () => import('./pages/hippodrome/hippodrome').then((m) => m.Hippodrome),
},
{
path: 'reunions',
loadComponent: () => import('./pages/reunion/reunion').then((m) => m.ReunionList),
},
{
path: 'users',
loadComponent: () => import('./pages/users/users').then((m) => m.UsersPage),
},
{
path: 'profile',
loadComponent: () => import('./pages/profile/profile').then((m) => m.ProfilePage),
},
{
path: 'roles',
loadComponent: () => import('./pages/roles/roles').then((m) => m.RolesPage),
},
{
path: 'tpes',
loadComponent: () => import('./pages/tpe/tpe').then((m) => m.TpePage),
},
{
path: 'agents',
loadComponent: () => import('./pages/agents/agents').then((m) => m.AgentsPage),
},
{
path: 'limits',
loadComponent: () => import('./pages/limits/limits').then((m) => m.LimitsPage),
},
{
path: 'rapport-courses',
loadComponent: () =>
import('./pages/report-courses/report-list').then((m) => m.ReportCoursesListPage),
},
{
path: 'rapport-courses/:id',
loadComponent: () =>
import('./pages/report-courses/report-detail').then((m) => m.ReportCoursesDetailPage),
},
],
},
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class DashboardRoutingModule {}

View File

@@ -0,0 +1,3 @@
:host {
display: contents;
}

View File

@@ -0,0 +1,182 @@
<z-layout class="border overflow-hidden bg-pmu-vert min-h-screen h-full">
<z-sidebar
[zWidth]="250"
[zCollapsible]="true"
[zCollapsed]="sidebarCollapsed()"
[zCollapsedWidth]="70"
(zCollapsedChange)="onCollapsedChange($event)"
class="!p-0 dark:!bg-pmu-vert/10 !bg-surface"
>
<nav
[class]="
'flex flex-col h-full overflow-hidden ' +
(sidebarCollapsed() ? 'gap-1 p-1 pt-4' : 'gap-4 p-4')
"
>
<div class="flex items-center justify-center">
<app-pmu-logo></app-pmu-logo>
</div>
<z-sidebar-group>
@if (!sidebarCollapsed()) {
<z-sidebar-group-label>Menu principal</z-sidebar-group-label>
} @for (item of mainMenuItems; track item.label) {
<button
z-button
zType="ghost"
[class]="
(sidebarCollapsed() ? 'justify-center' : 'justify-start') +
(isActive(item.link || '', item.exact || false)
? ' !bg-primary/10 !text-primary'
: ' hover:bg-accent')
"
[zTooltip]="sidebarCollapsed() ? item.label : ''"
zPosition="right"
(click)="navigate(item?.link)"
>
@if (isEmoji(item.icon)) {
<span class="text-lg">{{ item.icon }}</span>
} @else {
<i [class]="item.icon + (sidebarCollapsed() ? '' : ' mr-2')" aria-hidden="true"></i>
} @if (!sidebarCollapsed()) {
<span>{{ item.label }}</span>
}
</button>
}
</z-sidebar-group>
<z-sidebar-group>
@if (!sidebarCollapsed()) {
<z-sidebar-group-label>Agents & Utilisateurs</z-sidebar-group-label>
} @for (item of workspaceMenuItems; track item.label) { @if (item.submenu) {
<button
z-button
zType="ghost"
z-menu
[zMenuTriggerFor]="submenu"
zPlacement="rightTop"
[class]="
(sidebarCollapsed() ? 'justify-center' : 'justify-start') +
(isAnyActive(item) ? ' !bg-primary/10 !text-primary' : ' hover:bg-accent')
"
[zTooltip]="sidebarCollapsed() ? item.label : null"
zPosition="right"
>
<i [class]="sidebarCollapsed() ? item.icon : item.icon + ' mr-2'"></i>
@if (!sidebarCollapsed()) {
<span class="flex-1 text-left">{{ item.label }}</span>
<i class="icon-chevron-right"></i>
}
</button>
<ng-template #submenu>
<div z-menu-content class="w-48">
@for (subitem of item.submenu; track subitem.label) {
<button
z-menu-item
[class]="isActive(subitem.link || '', subitem.exact || false) ? '!text-primary' : ''"
(click)="navigate(subitem.link)"
>
{{ subitem.label }}
</button>
}
</div>
</ng-template>
} @else {
<button
z-button
zType="ghost"
[class]="
(sidebarCollapsed() ? 'justify-center' : 'justify-start') +
(isActive(item.link || '', item.exact || false)
? ' !bg-primary/10 !text-primary'
: ' hover:bg-accent')
"
[zTooltip]="sidebarCollapsed() ? item.label : ''"
zPosition="right"
(click)="navigate(item?.link)"
>
<i [class]="sidebarCollapsed() ? item.icon : item.icon + ' mr-2'"></i>
@if (!sidebarCollapsed()) {
<span>{{ item.label }}</span>
}
</button>
} }
</z-sidebar-group>
<div class="mt-auto">
<div
z-menu
[zMenuTriggerFor]="userMenu"
zPlacement="rightBottom"
[class]="
'flex items-center justify-center gap-2 cursor-pointer rounded-md hover:bg-accent ' +
(sidebarCollapsed() ? 'p-0 m-2' : 'p-2')
"
>
<z-avatar zSize="sm" [zImage]="avatar" />
@if (!sidebarCollapsed()) {
<div>
<span class="font-medium text-sm truncate">{{ user()?.nom }} {{ user()?.prenom }}</span>
<div class="text-xs">{{ user()?.identifiant }}</div>
</div>
<i class="icon-chevrons-up-down ml-auto"></i>
}
</div>
<ng-template #userMenu>
<div z-menu-content class="w-48">
<button z-menu-item (click)="navigate('/profile')">
<i class="icon-user mr-2"></i>
Profile
</button>
<z-divider zSpacing="sm" />
<button z-menu-item (click)="logout()">
<i class="icon-log-out mr-2"></i>
Déconnexion
</button>
</div>
</ng-template>
</div>
</nav>
</z-sidebar>
<!-- min-h-[200px] is just for the demo purpose to have a minimum height -->
<z-content class="min-h-screen">
<div class="flex items-center">
<button
z-button
zType="ghost"
zSize="sm"
class="-ml-2 dark:text-white text-black"
(click)="toggleSidebar()"
>
<i class="icon-panel-left"></i>
</button>
<z-divider zOrientation="vertical" class="h-4 ml-2" />
<z-breadcrumb>
<z-breadcrumb-list zWrap="wrap" zAlign="start">
<z-breadcrumb-item>
<z-breadcrumb-link zLink="/docs/components/layout">Home</z-breadcrumb-link>
</z-breadcrumb-item>
<z-breadcrumb-separator />
<z-breadcrumb-item>
<z-breadcrumb-link zLink="/docs/components/layout">Components</z-breadcrumb-link>
</z-breadcrumb-item>
</z-breadcrumb-list>
</z-breadcrumb>
<div class="ml-auto flex justify-end items-center">
<app-mode-toggle></app-mode-toggle>
</div>
</div>
<div class="space-y-4 py-4 text-text">
<router-outlet></router-outlet>
</div>
</z-content>
</z-layout>

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Layout } from './layout';
describe('Layout', () => {
let component: Layout;
let fixture: ComponentFixture<Layout>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Layout]
})
.compileComponents();
fixture = TestBed.createComponent(Layout);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,119 @@
import { CommonModule } from '@angular/common';
import { Component, signal } from '@angular/core';
import { Router, RouterModule } from '@angular/router';
import { ZardAvatarComponent } from '@shared/components/avatar/avatar.component';
import { ZardBreadcrumbModule } from '@shared/components/breadcrumb/breadcrumb.module';
import { ZardButtonComponent } from '@shared/components/button/button.component';
import { ZardDividerComponent } from '@shared/components/divider/divider.component';
import { LayoutModule } from '@shared/components/layout/layout.module';
import { ZardMenuModule } from '@shared/components/menu/menu.module';
import { ZardTooltipModule } from '@shared/components/tooltip/tooltip';
import { MenuItem } from 'src/app/core/interfaces/menu-item';
import { Theme } from 'src/app/core/services/theme';
import { ModeToggle } from '@shared/components/mode-toggle/mode-toggle';
import { PmuLogo } from '@shared/components/pmu-logo/pmu-logo';
import { User } from 'src/app/core/interfaces/user';
import { Auth } from 'src/app/core/services/auth';
@Component({
selector: 'app-layout',
imports: [
CommonModule,
RouterModule,
LayoutModule,
ZardButtonComponent,
ZardBreadcrumbModule,
ZardMenuModule,
ZardTooltipModule,
ZardDividerComponent,
ZardAvatarComponent,
ModeToggle,
PmuLogo,
],
templateUrl: './layout.html',
styleUrl: './layout.css',
})
export class Layout {
sidebarCollapsed = signal(false);
user = signal<User | null>(null);
constructor(public theme: Theme, public auth: Auth, public router: Router) {
this.user.set(auth.getUser());
}
mainMenuItems: MenuItem[] = [
{ icon: '🏠', label: 'Tableau de bord', link: '/', exact: true },
{ icon: '🏟️', label: 'Hippodromes', link: '/hippodromes' },
{ icon: '📅', label: 'Reunions', link: '/reunions' },
{ icon: '🏇', label: 'Courses', link: '/courses' },
{ icon: 'icon-chart-bar', label: 'Rapport des courses', link: '/rapport-courses' },
];
workspaceMenuItems: MenuItem[] = [
{
icon: 'icon-folder',
label: 'Gestion Agents',
submenu: [
{ icon: 'icon-user-plus', label: 'Gestion Agents', link: '/agents' },
{ icon: 'icon-sliders', label: 'Gestion limites Agents', link: '/limits' },
],
},
{ icon: 'icon-monitor', label: 'Gestion des TPE', link: '/tpes' },
{
icon: 'icon-users',
label: 'Utilisateurs',
submenu: [
{ icon: 'icon-users', label: 'Liste des utilisateurs', link: '/users' },
{ icon: 'icon-shield', label: 'Rôles & Permissions', link: '/roles' },
],
},
];
avatar = {
fallback: 'ZA',
url: '/assets/images/avatar.svg',
alt: 'ZadUI',
};
toggleSidebar() {
this.sidebarCollapsed.update((collapsed) => !collapsed);
}
onCollapsedChange(collapsed: boolean) {
this.sidebarCollapsed.set(collapsed);
}
toggleTheme() {
this.theme.toggle();
}
async logout() {
this.auth.logout();
await this.router.navigateByUrl('/auth/login');
}
navigate(link: string | undefined) {
if (link) {
this.router.navigateByUrl(link);
}
}
isActive(link: string, exact = false): boolean {
const current = this.router.url;
return exact ? current === link : current.startsWith(link);
}
isAnyActive(item: MenuItem): boolean {
if (item.link && this.isActive(item.link, !!item.exact)) return true;
if (item.submenu && item.submenu.length) {
return item.submenu.some((s) => !!s.link && this.isActive(s.link!, !!s.exact));
}
return false;
}
isEmoji(icon: string): boolean {
// simple: emojis are not alphanumeric or typical class names
// Detect if it contains non-ASCII characters
return /[^\u0000-\u00ff]/.test(icon);
}
}

View File

@@ -0,0 +1,405 @@
<div class="flex flex-col gap-2 min-h-screen">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-semibold">Gestion des Agents</h2>
<z-button (click)="openCreate()">Nouvel agent</z-button>
</div>
<app-search-bar (search)="onSearch($event)"></app-search-bar>
<app-data-table [data]="rows()" [columns]="cols" [sort]="sort()" (sortChange)="sort.set($event)">
<ng-template #rowActions let-row>
<div class="flex gap-3">
<button z-button zType="ghost" (click)="openDetail(row)" title="Voir les détails">
<i class="icon-eye"></i>
</button>
<button z-button zType="ghost" (click)="openAssignTpe(row)" title="Assigner un TPE">
<i class="icon-plus"></i>
</button>
<button z-button zType="ghost" (click)="openEdit(row)" title="Modifier">
<i class="icon-pen"></i>
</button>
<button z-button zType="destructive" (click)="remove(row)" title="Supprimer">
<i class="icon-trash"></i>
</button>
</div>
</ng-template>
</app-data-table>
<app-paginator
[total]="total()"
[page]="page()"
[perPage]="perPage()"
(pageChange)="page.set($event)"
(perPageChange)="perPage.set($event)"
></app-paginator>
</div>
<app-modal [open]="modalOpen()" [title]="modalTitle()" (close)="closeModal()" size="xxl">
<app-agent-full-form
[value]="editingItem() ?? undefined"
(save)="onFormSave($event)"
(cancel)="closeModal()"
/>
<div modal-actions class="flex justify-end gap-2">
<z-button zType="destructive" (click)="closeModal()">Annuler</z-button>
<z-button (click)="submitChildForm()">Enregistrer</z-button>
</div>
</app-modal>
<!-- Detail Modal -->
@if (detailItem()) {
<app-modal [open]="detailModalOpen()" [title]="'Détails de l\'agent'" (close)="closeDetailModal()" size="xxl">
@if (detailItem(); as agent) {
<div class="space-y-6">
<!-- Informations Emploi -->
<z-card class="p-4">
<div class="text-lg font-semibold mb-4">Informations Emploi</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<div class="text-xs text-muted-foreground mb-1">Code</div>
<div class="font-medium">{{ agent.code }}</div>
</div>
<div>
<div class="text-xs text-muted-foreground mb-1">Profil</div>
<div class="font-medium">{{ agent.profile }}</div>
</div>
<div>
<div class="text-xs text-muted-foreground mb-1">Statut</div>
<div class="font-medium">
@if (agent.statut === 'ACTIF') {
<span class="inline-flex items-center gap-1 px-2 py-1 rounded bg-green-500/10 text-green-600 dark:text-green-400 text-xs font-medium">
<i class="icon-check"></i> Actif
</span>
} @else if (agent.statut === 'INACTIF') {
<span class="inline-flex items-center gap-1 px-2 py-1 rounded bg-gray-500/10 text-gray-600 dark:text-gray-400 text-xs font-medium">
<i class="icon-x"></i> Inactif
</span>
} @else {
<span class="inline-flex items-center gap-1 px-2 py-1 rounded bg-orange-500/10 text-orange-600 dark:text-orange-400 text-xs font-medium">
<i class="icon-alert-circle"></i> Suspendu
</span>
}
</div>
</div>
@if (agent.principalCode) {
<div>
<div class="text-xs text-muted-foreground mb-1">Agent Principal</div>
<div class="font-medium">{{ agent.principalCode }}</div>
</div>
}
@if (agent.zone) {
<div>
<div class="text-xs text-muted-foreground mb-1">Zone</div>
<div class="font-medium">{{ agent.zone }}</div>
</div>
}
@if (agent.kiosk) {
<div>
<div class="text-xs text-muted-foreground mb-1">Kiosque</div>
<div class="font-medium">{{ agent.kiosk }}</div>
</div>
}
@if (agent.fonction) {
<div>
<div class="text-xs text-muted-foreground mb-1">Fonction</div>
<div class="font-medium">{{ agent.fonction }}</div>
</div>
}
@if (agent.dateEmbauche) {
<div>
<div class="text-xs text-muted-foreground mb-1">Date Embauche</div>
<div class="font-medium">{{ agent.dateEmbauche | date: 'dd/MM/yyyy' }}</div>
</div>
}
</div>
</z-card>
<!-- Informations Personnelles -->
<z-card class="p-4">
<div class="text-lg font-semibold mb-4">Informations Personnelles</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<div class="text-xs text-muted-foreground mb-1">Nom</div>
<div class="font-medium">{{ agent.nom }}</div>
</div>
<div>
<div class="text-xs text-muted-foreground mb-1">Prénom</div>
<div class="font-medium">{{ agent.prenom }}</div>
</div>
@if (agent.autresNoms) {
<div>
<div class="text-xs text-muted-foreground mb-1">Autre(s) Nom(s)</div>
<div class="font-medium">{{ agent.autresNoms }}</div>
</div>
}
@if (agent.dateNaissance) {
<div>
<div class="text-xs text-muted-foreground mb-1">Date de naissance</div>
<div class="font-medium">{{ agent.dateNaissance | date: 'dd/MM/yyyy' }}</div>
</div>
}
@if (agent.lieuNaissance) {
<div>
<div class="text-xs text-muted-foreground mb-1">Lieu de naissance</div>
<div class="font-medium">{{ agent.lieuNaissance }}</div>
</div>
}
@if (agent.ville) {
<div>
<div class="text-xs text-muted-foreground mb-1">Ville</div>
<div class="font-medium">{{ agent.ville }}</div>
</div>
}
@if (agent.adresse) {
<div class="md:col-span-2">
<div class="text-xs text-muted-foreground mb-1">Adresse</div>
<div class="font-medium">{{ agent.adresse }}</div>
</div>
}
@if (agent.phone) {
<div>
<div class="text-xs text-muted-foreground mb-1">Téléphone</div>
<div class="font-medium">{{ agent.phone }}</div>
</div>
}
@if (agent.autoriserAides !== undefined) {
<div>
<div class="text-xs text-muted-foreground mb-1">Autoriser Aides</div>
<div class="font-medium">
@if (agent.autoriserAides) {
<span class="text-green-600 dark:text-green-400">Oui</span>
} @else {
<span class="text-gray-600 dark:text-gray-400">Non</span>
}
</div>
</div>
}
</div>
</z-card>
<!-- Limites et Configuration -->
<z-card class="p-4">
<div class="text-lg font-semibold mb-4">Limites et Configuration</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
@if (agent.limiteInferieure !== undefined) {
<div>
<div class="text-xs text-muted-foreground mb-1">Limite inférieure</div>
<div class="font-medium">{{ agent.limiteInferieure | number: '1.2-2' }}</div>
</div>
}
@if (agent.limiteSuperieure !== undefined) {
<div>
<div class="text-xs text-muted-foreground mb-1">Limite supérieure</div>
<div class="font-medium">{{ agent.limiteSuperieure | number: '1.2-2' }}</div>
</div>
}
@if (agent.limiteParTransaction !== undefined) {
<div>
<div class="text-xs text-muted-foreground mb-1">Limite / transaction</div>
<div class="font-medium">{{ agent.limiteParTransaction | number: '1.2-2' }}</div>
</div>
}
@if (agent.limiteMinAirtime !== undefined) {
<div>
<div class="text-xs text-muted-foreground mb-1">Limite min airtime</div>
<div class="font-medium">{{ agent.limiteMinAirtime | number: '1.2-2' }}</div>
</div>
}
@if (agent.limiteMaxAirtime !== undefined) {
<div>
<div class="text-xs text-muted-foreground mb-1">Limite max airtime</div>
<div class="font-medium">{{ agent.limiteMaxAirtime | number: '1.2-2' }}</div>
</div>
}
@if (agent.maxPeripheriques !== undefined) {
<div>
<div class="text-xs text-muted-foreground mb-1">Nbre max. périphériques</div>
<div class="font-medium">{{ agent.maxPeripheriques }}</div>
</div>
}
</div>
</z-card>
<!-- Informations Légales -->
@if (agent.nationalite || agent.cni || agent.cniDelivreeLe || agent.residence || agent.statutMarital) {
<z-card class="p-4">
<div class="text-lg font-semibold mb-4">Informations Légales</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
@if (agent.nationalite) {
<div>
<div class="text-xs text-muted-foreground mb-1">Nationalité</div>
<div class="font-medium">{{ agent.nationalite }}</div>
</div>
}
@if (agent.cni) {
<div>
<div class="text-xs text-muted-foreground mb-1">N° CNI</div>
<div class="font-medium">{{ agent.cni }}</div>
</div>
}
@if (agent.cniDelivreeLe) {
<div>
<div class="text-xs text-muted-foreground mb-1">CNI Délivrée le</div>
<div class="font-medium">{{ agent.cniDelivreeLe | date: 'dd/MM/yyyy' }}</div>
</div>
}
@if (agent.cniDelivreeA) {
<div>
<div class="text-xs text-muted-foreground mb-1">CNI Délivrée à</div>
<div class="font-medium">{{ agent.cniDelivreeA }}</div>
</div>
}
@if (agent.residence) {
<div>
<div class="text-xs text-muted-foreground mb-1">Résidence</div>
<div class="font-medium">{{ agent.residence }}</div>
</div>
}
@if (agent.statutMarital) {
<div>
<div class="text-xs text-muted-foreground mb-1">Statut marital</div>
<div class="font-medium">{{ agent.statutMarital }}</div>
</div>
}
@if (agent.epoux) {
<div>
<div class="text-xs text-muted-foreground mb-1">Époux/Épouse</div>
<div class="font-medium">{{ agent.epoux }}</div>
</div>
}
</div>
</z-card>
}
<!-- Membres de famille -->
@if (detailFamilyMembers().length > 0) {
<z-card class="p-4">
<div class="text-lg font-semibold mb-4">Membres de famille</div>
<div class="space-y-3">
@for (member of detailFamilyMembers(); track member.id || $index) {
<div class="border rounded-lg p-3 bg-surface/50">
<div class="flex items-start justify-between">
<div class="flex-1 grid grid-cols-1 md:grid-cols-4 gap-3">
<div>
<div class="text-xs text-muted-foreground mb-1">Nom</div>
<div class="font-medium">{{ member.nom }}</div>
</div>
@if (member.statut) {
<div>
<div class="text-xs text-muted-foreground mb-1">Statut</div>
<div class="font-medium">{{ member.statut }}</div>
</div>
}
@if (member.dateNaissance) {
<div>
<div class="text-xs text-muted-foreground mb-1">Date de naissance</div>
<div class="font-medium">{{ member.dateNaissance | date: 'dd/MM/yyyy' }}</div>
</div>
}
@if (member.sexe) {
<div>
<div class="text-xs text-muted-foreground mb-1">Sexe</div>
<div class="font-medium">{{ member.sexe === 'M' ? 'Masculin' : 'Féminin' }}</div>
</div>
}
</div>
</div>
</div>
}
</div>
</z-card>
}
<!-- TPE Assignés -->
@if (getAgentTpes(agent.id).length > 0) {
<z-card class="p-4">
<div class="text-lg font-semibold mb-4">TPE Assignés ({{ getAgentTpes(agent.id).length }})</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
@for (tpe of getAgentTpes(agent.id); track tpe.id) {
<div class="px-3 py-2.5 rounded bg-primary/10 border border-primary/20">
<div class="flex items-start justify-between mb-2">
<div class="font-medium text-sm">{{ tpe.imei }}</div>
@if (tpe.statut) {
<span class="text-xs px-2 py-0.5 rounded bg-surface text-muted-foreground">
{{ formatTpeStatut(tpe.statut) }}
</span>
}
</div>
<div class="space-y-1 text-xs text-muted-foreground">
@if (tpe.marque || tpe.modele) {
<div>
<span class="font-medium">Modèle:</span> {{ tpe.marque }} {{ tpe.modele }}
</div>
}
@if (tpe.serial) {
<div>
<span class="font-medium">Série:</span> {{ tpe.serial }}
</div>
}
@if (tpe.type) {
<div>
<span class="font-medium">Type:</span> {{ tpe.type }}
</div>
}
</div>
</div>
}
</div>
</z-card>
}
</div>
}
<div modal-actions class="flex justify-end gap-2">
<z-button zType="default" (click)="closeDetailModal()">Fermer</z-button>
@if (detailItem()) {
<z-button zType="default" (click)="openEdit(detailItem()!); closeDetailModal()">
<i class="icon-pen mr-2"></i>Modifier
</z-button>
}
</div>
</app-modal>
}
<!-- TPE Assignment Modal -->
@if (assigningAgent()) {
<app-modal
[open]="assignTpeModalOpen()"
[title]="'Assigner un TPE à ' + (assigningAgent()?.nom || '') + ' ' + (assigningAgent()?.prenom || '')"
(close)="closeAssignTpeModal()"
size="md"
>
<div class="space-y-4">
@if (tpesLoading()) {
<div class="text-center py-4">Chargement des TPE disponibles...</div>
} @else if (availableTpes().length === 0) {
<div class="text-center py-4 text-muted-foreground">Aucun TPE disponible</div>
} @else {
<z-form-field>
<label z-form-label>Sélectionner un TPE</label>
<div z-form-control>
<z-select
[zValue]="selectedTpeId()"
(zSelectionChange)="selectedTpeId.set($event)"
[zPlaceholder]="'Sélectionner un TPE...'"
>
@for (tpe of availableTpes(); track tpe.id) {
<z-select-item [zValue]="tpe.id">
{{ tpe.imei }} - {{ tpe.marque }} {{ tpe.modele }}
@if (tpe.statut === 'VALIDE') {
<span class="text-xs text-green-600 dark:text-green-400 ml-2">(Valide)</span>
}
</z-select-item>
}
</z-select>
</div>
</z-form-field>
}
</div>
<div modal-actions class="flex justify-end gap-2">
<z-button zType="destructive" (click)="closeAssignTpeModal()">Annuler</z-button>
<button z-button [disabled]="!selectedTpeId() || tpesLoading()" (click)="confirmAssignTpe()">
Assigner
</button>
</div>
</app-modal>
}

View File

@@ -0,0 +1,483 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
ViewChild,
effect,
signal,
untracked,
} from '@angular/core';
import { DataTable, SortState, TableColumn } from '@shared/components/data-table/data-table';
import { Paginator } from '@shared/components/paginator/paginator';
import { SearchBar } from '@shared/components/search-bar/search-bar';
import { Modal } from '@shared/components/modal/modal';
import { ZardButtonComponent } from '@shared/components/button/button.component';
import { ZardCardComponent } from '@shared/components/card/card.component';
import { ZardSelectComponent } from '@shared/components/select/select.component';
import { ZardSelectItemComponent } from '@shared/components/select/select-item.component';
import { ZardFormModule } from '@shared/components/form/form.module';
import { SortDir } from '@shared/paging/paging';
import { Agent, AgentFamilyMember } from 'src/app/core/interfaces/agent';
import { AgentService } from 'src/app/core/services/agent';
import { AgentFamilyMemberService } from 'src/app/core/services/agent-family-member';
import { TpeService } from 'src/app/core/services/tpe';
import { TpeDevice, TpeStatus } from 'src/app/core/interfaces/tpe';
import { AgentFullForm } from '@shared/forms/agent-full-form/agent-full-form';
import { forkJoin, of } from 'rxjs';
import { switchMap, catchError } from 'rxjs/operators';
@Component({
standalone: true,
selector: 'app-agents',
templateUrl: './agents.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
DataTable,
Paginator,
SearchBar,
Modal,
ZardButtonComponent,
ZardCardComponent,
ZardSelectComponent,
ZardSelectItemComponent,
ZardFormModule,
AgentFullForm,
],
})
export class AgentsPage {
rows = signal<Agent[]>([]);
total = signal(0);
loading = signal(false);
page = signal(1);
perPage = signal(10);
search = signal('');
sort = signal<SortState>({ key: 'code', dir: 'asc' });
modalOpen = signal(false);
modalTitle = signal('Nouvel agent');
editingItem = signal<Agent | null>(null);
detailModalOpen = signal(false);
detailItem = signal<Agent | null>(null);
detailFamilyMembers = signal<AgentFamilyMember[]>([]);
// TPE Assignment modal
assignTpeModalOpen = signal(false);
assigningAgent = signal<Agent | null>(null);
availableTpes = signal<TpeDevice[]>([]);
selectedTpeId = signal<string>('');
tpesLoading = signal(false);
@ViewChild(AgentFullForm) formComp?: AgentFullForm;
formatTpeStatut(statut: TpeStatus): string {
const statutMap: Record<string, string> = {
VALIDE: 'Valide',
INVALIDE: 'Invalide',
EN_PANNE: 'En panne',
BLOQUE: 'Bloqué',
DISPONIBLE: 'Disponible',
AFFECTE: 'Affecté',
EN_MAINTENANCE: 'En maintenance',
HORS_SERVICE: 'Hors service',
VOLE: 'Volé',
};
return statutMap[statut] || statut;
}
cols: TableColumn<Agent>[] = [
{ key: 'code', label: 'Code', sortable: true },
{ key: 'nom', label: 'Nom', sortable: true },
{ key: 'prenom', label: 'Prénom', sortable: true },
{ key: 'phone', label: 'Téléphone', sortable: true },
{
key: 'tpes',
label: 'TPE assignés',
cell: (a) => {
const tpes = this.agentTpesMap.get(a.id) || [];
if (tpes.length === 0) {
return '<span class="text-muted-foreground text-sm">Aucun</span>';
}
// Show up to 2 TPEs with full details, then count for the rest
const displayCount = Math.min(2, tpes.length);
const displayed = tpes.slice(0, displayCount);
const remaining = tpes.length - displayCount;
const tpeCards = displayed
.map((t) => {
const imei = `<div class="font-medium text-xs">${t.imei}</div>`;
const details = [
t.marque && t.modele ? `${t.marque} ${t.modele}` : t.marque || t.modele || '',
t.statut ? this.formatTpeStatut(t.statut) : '',
]
.filter(Boolean)
.join(' • ');
const detailsHtml = details
? `<div class="text-xs text-muted-foreground">${details}</div>`
: '';
return `<div class="px-2 py-1.5 rounded bg-primary/10 border border-primary/20 flex flex-col gap-0.5">${imei}${detailsHtml}</div>`;
})
.join(' ');
const moreHtml =
remaining > 0
? `<div class="text-xs text-muted-foreground px-2 py-1.5">+${remaining} autre${
remaining > 1 ? 's' : ''
}</div>`
: '';
return `<div class="flex flex-col gap-1">${tpeCards}${moreHtml}</div>`;
},
},
{ key: 'zone', label: 'Zone', sortable: true },
{ key: 'kiosk', label: 'Kiosque', sortable: true },
{ key: 'profile', label: 'Profil', sortable: true },
{ key: 'statut', label: 'Statut', sortable: true },
{ key: 'limiteSuperieure', label: 'Limite sup.', sortable: true },
];
tpeMap = new Map<string, TpeDevice>();
agentTpesMap = new Map<string, TpeDevice[]>();
constructor(
private api: AgentService,
private tpeSvc: TpeService,
private familyMemberService: AgentFamilyMemberService
) {
// Preload TPE maps for display
this.tpeSvc
.list({ page: 1, perPage: 200, search: '', sortKey: 'imei', sortDir: 'asc' } as any)
.subscribe((res) => {
const tpes = res.data as TpeDevice[];
this.rebuildTpeMaps(tpes);
});
effect(() => {
const params = {
page: this.page(),
perPage: this.perPage(),
search: this.search(),
sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir,
};
untracked(() => this.fetch(params));
});
}
private fetch(params: {
page: number;
perPage: number;
search: string;
sortKey: string;
sortDir: SortDir;
}) {
this.loading.set(true);
this.api.list(params).subscribe({
next: (res) => {
this.rows.set(res.data);
this.total.set(res.meta.total);
this.loading.set(false);
// Refresh TPE map to ensure we have latest data
this.refreshTpeMap();
},
error: () => {
this.rows.set([]);
this.total.set(0);
this.loading.set(false);
},
});
}
private refreshTpeMap() {
this.tpeSvc
.list({ page: 1, perPage: 200, search: '', sortKey: 'imei', sortDir: 'asc' } as any)
.subscribe((res) => {
const tpes = res.data as TpeDevice[];
this.rebuildTpeMaps(tpes);
});
}
private rebuildTpeMaps(tpes: TpeDevice[]) {
this.tpeMap.clear();
this.agentTpesMap.clear();
tpes.forEach((t) => {
this.tpeMap.set(t.id, t);
const agentId = t.agent?.id;
if (agentId) {
const list = this.agentTpesMap.get(agentId) || [];
list.push(t);
this.agentTpesMap.set(agentId, list);
}
});
}
getAgentTpes(agentId: string): TpeDevice[] {
return this.agentTpesMap.get(agentId) || [];
}
onSearch(q: string) {
this.search.set(q);
this.page.set(1);
}
openCreate() {
this.modalTitle.set('Nouvel agent');
this.editingItem.set(null);
queueMicrotask(() => this.modalOpen.set(true));
}
openEdit(row: Agent) {
this.modalTitle.set("Modifier l'agent");
this.editingItem.set(row);
queueMicrotask(() => this.modalOpen.set(true));
}
closeModal() {
this.modalOpen.set(false);
}
openDetail(row: Agent) {
// Fetch full agent details
this.api.getById(row.id).subscribe({
next: (agent) => {
if (agent) {
this.detailItem.set(agent);
// Load family members separately
this.familyMemberService.getByAgentId(agent.id).subscribe({
next: (members) => {
this.detailFamilyMembers.set(members);
},
error: () => {
this.detailFamilyMembers.set([]);
},
});
this.detailModalOpen.set(true);
}
},
error: () => {
// If fetch fails, use the row data
this.detailItem.set(row);
// Try to load family members anyway
this.familyMemberService.getByAgentId(row.id).subscribe({
next: (members) => {
this.detailFamilyMembers.set(members);
},
error: () => {
this.detailFamilyMembers.set([]);
},
});
this.detailModalOpen.set(true);
},
});
}
closeDetailModal() {
this.detailModalOpen.set(false);
this.detailItem.set(null);
this.detailFamilyMembers.set([]);
}
submitChildForm() {
this.formComp?.onSubmit();
}
onFormSave(payload: Partial<Agent>) {
const current = this.editingItem();
const familyMembersData = this.formComp?.getFamilyMembersData() || [];
// Save agent first
const req$ = current?.id
? this.api.update(current.id, payload)
: this.api.create(payload as Omit<Agent, 'id'>);
req$
.pipe(
switchMap((result) => {
if (!result && current?.id) {
// Update failed
throw new Error("Erreur lors de la sauvegarde de l'agent");
}
const savedAgentId = result?.id || current?.id || '';
if (!savedAgentId) {
throw new Error("Impossible d'obtenir l'ID de l'agent sauvegardé");
}
// Get existing family members for this agent
return this.familyMemberService.getByAgentId(savedAgentId).pipe(
switchMap((existingMembers) => {
const existingIds = new Set(existingMembers.map((m) => m.id));
const newMembers = familyMembersData.filter((fm) => !fm.id);
const updatedMembers = familyMembersData.filter(
(fm) => fm.id && existingIds.has(fm.id)
);
const deletedIds = existingMembers
.filter((em) => !familyMembersData.some((fm) => fm.id === em.id))
.map((em) => em.id);
const operations: any[] = [];
// Delete removed members
deletedIds.forEach((id) => {
operations.push(
this.familyMemberService.delete(id).pipe(
catchError((err) => {
console.error(`Error deleting family member ${id}:`, err);
return of(false);
})
)
);
});
// Create new members
newMembers.forEach((member) => {
operations.push(
this.familyMemberService
.create({
agentId: savedAgentId,
nom: member.nom,
statut: member.statut,
dateNaissance: member.dateNaissance,
sexe: member.sexe as 'M' | 'F' | undefined,
})
.pipe(
catchError((err) => {
console.error('Error creating family member:', err);
return of(null);
})
)
);
});
// Update existing members
updatedMembers.forEach((member) => {
if (member.id) {
operations.push(
this.familyMemberService
.update(member.id, {
nom: member.nom,
statut: member.statut,
dateNaissance: member.dateNaissance,
sexe: member.sexe as 'M' | 'F' | undefined,
})
.pipe(
catchError((err) => {
console.error(`Error updating family member ${member.id}:`, err);
return of(null);
})
)
);
}
});
return operations.length > 0 ? forkJoin(operations) : of([]);
})
);
})
)
.subscribe({
next: () => {
// Reset form after successful save
this.formComp?.resetForm();
// Clear editing item
this.editingItem.set(null);
// Close modal
this.closeModal();
// Refresh data
this.fetch({
page: this.page(),
perPage: this.perPage(),
search: this.search(),
sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir,
});
},
error: (err) => {
console.error('Error saving agent:', err);
alert("Erreur lors de la sauvegarde de l'agent");
},
});
}
remove(row: Agent) {
if (!confirm(`Supprimer l\'agent ${row.code} ?`)) return;
this.api.delete(row.id).subscribe(() =>
this.fetch({
page: this.page(),
perPage: this.perPage(),
search: this.search(),
sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir,
})
);
}
openAssignTpe(agent: Agent) {
this.assigningAgent.set(agent);
this.selectedTpeId.set('');
this.loadAvailableTpes();
this.assignTpeModalOpen.set(true);
}
loadAvailableTpes() {
this.tpesLoading.set(true);
const agent = this.assigningAgent();
if (!agent) {
this.availableTpes.set([]);
this.tpesLoading.set(false);
return;
}
const currentAgentTpes = this.agentTpesMap.get(agent.id) || [];
const agentTpeIds = new Set(currentAgentTpes.map((t) => t.id));
// Load available TPEs (DISPONIBLE or VALIDE status)
forkJoin([this.tpeSvc.getByStatut('DISPONIBLE'), this.tpeSvc.getByStatut('VALIDE')]).subscribe({
next: ([disponibleTpes, valideTpes]) => {
// Combine and filter: only show TPEs that are not assigned to any agent AND not already assigned to this agent
const allTpes = [...disponibleTpes, ...valideTpes];
const available = allTpes.filter(
(t) =>
!t.assigne &&
(t.statut === 'DISPONIBLE' || t.statut === 'VALIDE') &&
!agentTpeIds.has(t.id)
);
// Remove duplicates
const uniqueTpes = Array.from(new Map(available.map((t) => [t.id, t])).values());
this.availableTpes.set(uniqueTpes);
this.tpesLoading.set(false);
},
error: () => {
this.availableTpes.set([]);
this.tpesLoading.set(false);
},
});
}
confirmAssignTpe() {
const agent = this.assigningAgent();
const tpeId = this.selectedTpeId();
if (!agent || !tpeId) {
alert('Veuillez sélectionner un TPE');
return;
}
// Assign TPE to agent
this.tpeSvc.assigner(tpeId, agent.id).subscribe({
next: (tpe) => {
if (tpe) {
// Fermer le modal et recharger complètement la page
this.assignTpeModalOpen.set(false);
this.assigningAgent.set(null);
this.selectedTpeId.set('');
// Rechargement complet pour s'assurer que la liste des agents / TPE est à jour
window.location.reload();
}
},
error: () => {
alert("Erreur lors de l'assignation du TPE");
},
});
}
closeAssignTpeModal() {
this.assignTpeModalOpen.set(false);
this.assigningAgent.set(null);
this.selectedTpeId.set('');
}
}

View File

@@ -0,0 +1,171 @@
<div class="flex flex-col gap-2 min-h-screen">
<!-- Header -->
<div class="flex items-center justify-between">
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Courses</h1>
<button z-button (click)="openCreate()">Nouvelle course</button>
</div>
<!-- Stats -->
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<z-card class="text-center py-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Total des courses</div>
<div class="text-3xl font-bold text-gray-900 dark:text-gray-100 mt-1">
{{ totalCourses() }}
</div>
</z-card>
<z-card class="text-center py-4">
<div class="text-sm text-gray-500 dark:text-gray-400">En cours</div>
<div class="text-3xl font-bold text-amber-600 dark:text-amber-400 mt-1">
{{ runningCourses() }}
</div>
</z-card>
<z-card class="text-center py-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Clôturées</div>
<div class="text-3xl font-bold text-green-600 dark:text-green-400 mt-1">
{{ closedCourses() }}
</div>
</z-card>
<z-card class="text-center py-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Par type</div>
<div class="text-sm mt-2 text-gray-900 dark:text-gray-100 space-y-1">
@for (type of (byType() | keyvalue); track type.key) {
<div class="flex justify-between px-3">
<span>{{ type.key }}</span>
<strong>{{ type.value }}</strong>
</div>
}
</div>
</z-card>
</div>
<!-- Search -->
<app-search-bar
placeholder="Rechercher (nom, type, réunion, hippodrome…)"
(search)="onSearch($event)"
/>
<!-- Table -->
<div class="rounded-2xl overflow-hidden bg-white dark:bg-gray-900/40">
<app-data-table
persistenceKey="pmu.courses.v1"
[columns]="cols"
[data]="rows()"
[loading]="loading()"
[sort]="sort()"
[actionsPosition]="'left'"
[actionsSticky]="true"
[actionsHeader]="'Actions'"
(sortChange)="sort.set($event)"
actionsHeader="Options"
>
<ng-template #rowActions let-row>
@if (!isClosed(row)) {
<div class="flex flex-row gap-4">
<button
class="p-1 rounded text-blue-600 hover:bg-blue-100 dark:text-blue-400 dark:hover:bg-gray-800"
(click)="openEdit(row)"
title="Modifier la course"
>
<lucide-angular name="folder-pen" class="size-4"></lucide-angular>
</button>
<button
class="p-1 rounded text-emerald-600 hover:bg-emerald-100 dark:text-emerald-400 dark:hover:bg-gray-800"
(click)="openResultat(row)"
title="Déclarer le résultat"
>
<lucide-angular name="trophy" class="size-4"></lucide-angular>
</button>
<button
class="p-1 rounded text-amber-600 hover:bg-amber-100 dark:text-amber-400 dark:hover:bg-gray-800"
(click)="openNonPartant(row)"
title="Marquer les non partants"
>
<lucide-angular name="ban" class="size-4"></lucide-angular>
</button>
<button
class="p-1 rounded text-red-600 hover:bg-red-100 dark:text-red-400 dark:hover:bg-gray-800"
(click)="remove(row)"
title="Supprimer la course"
>
<lucide-angular name="trash-2" class="size-4"></lucide-angular>
</button>
</div>
} @else {
<span
class="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs bg-gray-100 text-gray-600 dark:bg-gray-900/30 dark:text-gray-300"
title="Actions désactivées pour une course clôturée"
>
<lucide-angular name="lock" class="size-3.5"></lucide-angular>
Fermée
</span>
}
</ng-template>
</app-data-table>
<app-paginator
[page]="page()"
[perPage]="perPage()"
[total]="total()"
(pageChange)="page.set($event)"
(perPageChange)="perPage.set($event)"
[pageSizes]="pageSize"
/>
</div>
<!-- Modal -->
<app-modal [open]="modalOpen()" [title]="modalTitle()" size="xl" (close)="closeModal()">
@if(modalOpen()) {
<app-course-form
[value]="editingItem() ?? undefined"
(save)="onFormSave($event)"
(cancel)="closeModal()"
></app-course-form>
}
<div modal-actions class="flex gap-2 justify-end">
<z-button zType="destructive" (click)="closeModal()">Annuler</z-button>
<z-button zType="default" (click)="submitChildForm()">Enregistrer</z-button>
</div>
</app-modal>
@if(selectedCourse()) {
<app-modal
[open]="nonPartantModalOpen()"
[title]="'Déclarer un non-partant'"
size="xxl"
(close)="closeNonPartantModal()"
>
<app-nonpartant-form
[course]="selectedCourse()"
(save)="onNonPartantSave($any($event))"
(cancel)="closeNonPartantModal()"
></app-nonpartant-form>
<div modal-actions class="flex justify-end gap-2">
<z-button zType="destructive" (click)="closeNonPartantModal()">Annuler</z-button>
<z-button zType="default" (click)="submitNonPartant()">Enregistrer</z-button>
</div>
</app-modal>
} @if(selectedCourseForResultat()) {
<app-modal
[open]="resultatModalOpen()"
[title]="'Déclarer le résultat'"
size="xl"
(close)="closeResultatModal()"
>
<app-resultat-form
[course]="selectedCourseForResultat()!"
[resultat]="resultatsMap().get(selectedCourseForResultat()!.id)"
(save)="onResultatSave($event)"
(validate)="onResultatValidate()"
(confirm)="onResultatConfirm()"
(cancel)="closeResultatModal()"
/>
<div modal-actions class="flex justify-end gap-2">
<z-button zType="destructive" (click)="closeResultatModal()">Fermer</z-button>
</div>
</app-modal>
}
</div>

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Courses } from './courses';
describe('Courses', () => {
let component: Courses;
let fixture: ComponentFixture<Courses>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Courses]
})
.compileComponents();
fixture = TestBed.createComponent(Courses);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,554 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
signal,
ViewChild,
untracked,
} from '@angular/core';
import { DataTable, SortState, TableColumn } from '@shared/components/data-table/data-table';
import { Paginator } from '@shared/components/paginator/paginator';
import { SearchBar } from '@shared/components/search-bar/search-bar';
import { Modal } from '@shared/components/modal/modal';
import { ZardCardComponent } from '@shared/components/card/card.component';
import { ZardButtonComponent } from '@shared/components/button/button.component';
import { Course as CourseType } from 'src/app/core/interfaces/course';
import { SortDir } from '@shared/paging/paging';
import { CourseService } from 'src/app/core/services/course';
import { ResultatService } from 'src/app/core/services/resultat';
import { Resultat } from 'src/app/core/interfaces/resultat';
import { A11yModule } from '@angular/cdk/a11y';
import { CourseForm } from '@shared/forms/course-form/course-form';
import { NonPartantForm } from '@shared/forms/nonpartant-form/nonpartant-form';
import { LucideAngularModule } from 'lucide-angular';
import { ResultatForm } from '@shared/forms/resultat-form/resultat-form';
import { toast } from 'ngx-sonner';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
@Component({
standalone: true,
selector: 'app-course-list',
imports: [
CommonModule,
DataTable,
Paginator,
SearchBar,
Modal,
CourseForm,
NonPartantForm,
ResultatForm,
ZardCardComponent,
ZardButtonComponent,
A11yModule,
LucideAngularModule,
],
templateUrl: './courses.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Course {
rows = signal<CourseType[]>([]);
resultatsMap = signal<Map<string, Resultat>>(new Map());
loading = signal(false);
total = signal(0);
totalRunning = signal(0);
totalClosed = signal(0);
totalByType = signal<Record<string, number>>({});
page = signal(1);
perPage = signal(10);
search = signal('');
sort = signal<SortState>({ key: 'numero', dir: 'asc' });
pageSize = [10, 20, 50];
modalOpen = signal(false);
modalTitle = signal('Nouvelle course');
editingItem = signal<CourseType | null>(null);
@ViewChild(CourseForm) formComp?: CourseForm;
// 🟩 Corrected columns
cols: TableColumn<CourseType>[] = [
{ key: 'numero', label: 'N°', sortable: true },
{ key: 'nom', label: 'Nom', sortable: true },
{
key: 'type',
label: 'Type',
sortable: true,
cell: (c) => `<span class="font-medium">${c.type}</span>`,
},
{
key: 'dateDepartCourse',
label: 'Date et Heure Départ',
sortable: true,
cell: (c) =>
new Date(c.dateDepartCourse).toLocaleDateString('fr-FR', {
hour: '2-digit',
minute: '2-digit',
}),
},
{
key: 'partants',
label: 'Partants',
cell: (c) =>
`<span>${c.partants}</span> <span class="text-xs text-red-500">(${
c.nonPartants?.length ?? 0
} NP)</span>`,
},
{
key: 'resultat',
label: 'Résultat',
cell: (c) => {
const resultat = this.resultatsMap().get(c.id);
if (!resultat || !resultat.ordreArrivee || resultat.ordreArrivee.length === 0) {
return '<span class="text-gray-500 dark:text-gray-400">—</span>';
}
// Group horses that are at the same place (ex-aequo/dead heat).
// Backend/Resultat model store ordreArrivee as cheval numbers (1,2,3,...) and
// chevauxDeadHeat as the subset that are ex-aequo.
const deadHeatSet = new Set(resultat.chevauxDeadHeat || []);
const groups: number[][] = [];
let currentGroup: number[] = [];
resultat.ordreArrivee.forEach((num, index) => {
const isInDeadHeat = deadHeatSet.has(num);
const prevNum = index > 0 ? resultat.ordreArrivee[index - 1] : null;
const prevIsInDeadHeat = prevNum !== null && deadHeatSet.has(prevNum);
if (isInDeadHeat && prevIsInDeadHeat && currentGroup.length > 0) {
// Continue the current dead heat group
currentGroup.push(num);
} else {
// Start a new group
if (currentGroup.length > 0) {
groups.push(currentGroup);
}
currentGroup = [num];
}
});
// Don't forget the last group
if (currentGroup.length > 0) {
groups.push(currentGroup);
}
const s = groups.map((nums) => nums.join('=')).join(' - ');
// For now, we'll show the resultat. In the future, we might add a statut field to Resultat
return `<span class="mr-2">${s}</span>`;
},
},
{
key: 'statut',
label: 'Statut',
sortable: true,
cell: (c) => {
const colorMap: Record<string, string> = {
PROGRAMMEE: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300',
CREATED: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-300',
VALIDATED: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
RUNNING: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
CLOSED: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300',
CANCELED: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300',
};
const labelMap: Record<string, string> = {
PROGRAMMEE: 'Programmée',
CREATED: 'Créée',
VALIDATED: 'Validée',
RUNNING: 'En cours',
CLOSED: 'Clôturée',
CANCELED: 'Annulée',
};
return `<span class="px-2 py-1 rounded-full text-xs font-semibold ${colorMap[c.statut]}">${
labelMap[c.statut]
}</span>`;
},
},
{
key: 'reunion.hippodrome.nom',
label: 'Hippodrome',
cell: (c) => (c.reunion?.hippodrome ? `${c.reunion.hippodrome.nom}` : '—'),
},
{
key: 'reunion.nom',
label: 'Réunion',
cell: (c) => c.reunion?.nom ?? '—',
},
{
key: 'distance',
label: 'Distance (m)',
sortable: true,
cell: (c) => c.distance.toLocaleString('fr-FR'),
},
{
key: 'createdAt',
label: 'Créée le',
cell: (c) =>
c.createdAt
? new Date(c.createdAt).toLocaleDateString('fr-FR', {
day: '2-digit',
month: 'short',
year: 'numeric',
})
: '—',
},
];
visibleKeys = signal<string[]>([]);
constructor(private api: CourseService, private resultatService: ResultatService) {
effect(() => {
const params = {
page: this.page(),
perPage: this.perPage(),
search: this.search(),
sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir,
};
untracked(() => this.fetch(params));
});
}
private fetch(params: {
page: number;
perPage: number;
search: string;
sortKey: string;
sortDir: SortDir;
}) {
this.loading.set(true);
this.api.list(params).subscribe({
next: (res) => {
this.rows.set(res.data);
this.total.set(res.meta.total);
this.totalRunning.set(res.meta['totalRunning'] ?? 0);
this.totalClosed.set(res.meta['totalClosed'] ?? 0);
this.totalByType.set(res.meta['totalByType'] ?? {});
// Fetch resultats for all courses in parallel
const courseIds = res.data.map((c) => c.id);
if (courseIds.length > 0) {
const resultatRequests = courseIds.map((id) =>
this.resultatService.getByCourseId(id).pipe(catchError(() => of(undefined)))
);
forkJoin(resultatRequests).subscribe({
next: (resultats) => {
const resultatsMap = new Map<string, Resultat>();
courseIds.forEach((id, index) => {
const resultat = resultats[index];
if (resultat) {
resultatsMap.set(id, resultat);
}
});
this.resultatsMap.set(resultatsMap);
this.loading.set(false);
},
error: () => {
this.resultatsMap.set(new Map());
this.loading.set(false);
},
});
} else {
this.resultatsMap.set(new Map());
this.loading.set(false);
}
},
error: () => {
this.rows.set([]);
this.total.set(0);
this.totalRunning.set(0);
this.totalClosed.set(0);
this.totalByType.set({});
this.resultatsMap.set(new Map());
this.loading.set(false);
},
});
}
// === UI Actions ===
onSearch(q: string) {
this.search.set(q);
this.page.set(1);
}
openCreate() {
this.modalTitle.set('Nouvelle course');
this.editingItem.set(null);
queueMicrotask(() => this.modalOpen.set(true));
}
isClosed = (c: CourseType | null | undefined) =>
c?.statut === 'CLOSED' || c?.statut === 'CANCELED';
openEdit(row: CourseType) {
if (this.isClosed(row)) return;
this.modalTitle.set('Modifier la course');
this.editingItem.set(row);
queueMicrotask(() => this.modalOpen.set(true));
}
closeModal() {
this.modalOpen.set(false);
}
submitChildForm() {
this.formComp?.onSubmit();
}
onFormSave(payload: Partial<CourseType>) {
const current = this.editingItem();
const req$ = current?.id
? this.api.update(current.id, payload)
: this.api.create(payload as Omit<CourseType, 'id'>);
req$.subscribe(() => {
this.closeModal();
this.fetch({
page: this.page(),
perPage: this.perPage(),
search: this.search(),
sortKey: this.sort().key,
sortDir: this.sort().dir,
});
});
}
remove(row: CourseType) {
if (this.isClosed(row)) return;
if (!confirm(`Supprimer la course « ${row.nom} » ?`)) return;
this.api.delete(row.id).subscribe(() =>
this.fetch({
page: this.page(),
perPage: this.perPage(),
search: this.search(),
sortKey: this.sort().key,
sortDir: this.sort().dir,
})
);
}
// === Stats Computed ===
totalCourses = computed(() => this.total());
runningCourses = computed(() => this.totalRunning());
closedCourses = computed(() => this.totalClosed());
byType = computed(() => this.totalByType());
nonPartantModalOpen = signal(false);
selectedCourse = signal<CourseType | null>(null);
@ViewChild(NonPartantForm) npForm?: NonPartantForm;
openResultat(row: CourseType) {
if (this.isClosed(row)) return;
this.selectedCourseForResultat.set(row);
this.resultatModalOpen.set(true);
}
openNonPartant(row: CourseType) {
if (this.isClosed(row)) return;
this.selectedCourse.set(row);
this.nonPartantModalOpen.set(true);
}
closeNonPartantModal() {
this.nonPartantModalOpen.set(false);
this.selectedCourse.set(null);
}
submitNonPartant() {
this.npForm?.onSubmit();
}
onNonPartantSave(payload: string[]) {
const course = this.selectedCourse();
if (!course) return;
this.api.setNonPartants(course.id, payload).subscribe({
next: (updatedCourse) => {
if (updatedCourse) {
toast.success('Non-partants mis à jour avec succès');
} else {
toast.error('Erreur lors de la mise à jour des non-partants');
}
this.closeNonPartantModal();
this.fetch({
page: this.page(),
perPage: this.perPage(),
search: this.search(),
sortKey: this.sort().key,
sortDir: this.sort().dir,
});
},
error: (err) => {
console.error('Error saving non-partants:', err);
toast.error('Erreur lors de la mise à jour des non-partants');
},
});
}
resultatModalOpen = signal(false);
selectedCourseForResultat = signal<CourseType | null>(null);
closeResultatModal() {
this.resultatModalOpen.set(false);
this.selectedCourseForResultat.set(null);
}
onResultatSave(places: number[][]) {
const c = this.selectedCourseForResultat();
if (!c) return;
// Determine required number of horses based on course type
const getRequiredHorses = (type: string): number => {
const typeStr = String(type).toUpperCase();
if (typeStr.includes('TIERCE') || typeStr === 'PLAT') return 3;
if (typeStr.includes('QUARTE')) return 4;
if (typeStr.includes('QUINTE')) return 5;
return 3; // Default
};
const requiredHorses = getRequiredHorses(c.type);
// Collect all selected horses (flatten the places array)
const allHorses: number[] = places
.flatMap((placeGroup) => placeGroup.filter((n) => typeof n === 'number' && n > 0))
.slice(0, requiredHorses); // Only take the first N horses
// Check if all horses are in first place (ex-aequo)
const firstPlaceHorses = places[0]?.filter((n) => typeof n === 'number' && n > 0) || [];
const isAllExAequo =
firstPlaceHorses.length === requiredHorses && allHorses.length === requiredHorses;
// Convert to ordreArrivee format
// If all are ex-aequo, they all go in ordreArrivee as they are (first place)
// Otherwise, distribute them across places
const ordreArrivee: Array<string> = [];
const chevauxDeadHeat: number[] = [];
if (isAllExAequo) {
// All horses are in first place (ex-aequo)
allHorses.forEach((numero) => {
ordreArrivee.push(numero.toString());
chevauxDeadHeat.push(numero);
});
} else {
// Horses are distributed across places
places.forEach((placeGroup, placeIndex) => {
const validHorses = placeGroup.filter((n) => typeof n === 'number' && n > 0);
if (validHorses.length === 0) return;
const isDeadHeat = validHorses.length > 1;
validHorses.forEach((numero) => {
ordreArrivee.push(numero.toString());
if (isDeadHeat) {
chevauxDeadHeat.push(numero);
}
});
});
}
// Check if resultat already exists
const existingResultat = this.resultatsMap().get(c.id);
const payload = {
course: { id: c.id },
ordreArrivee,
chevauxDeadHeat: chevauxDeadHeat.map((n) => String(n)),
totalMises: 0,
masseAPartager: 0,
prelevementsLegaux: 0,
montantRembourse: 0,
montantCagnotte: 0,
adeadHeat: chevauxDeadHeat.length > 0,
};
const request$ = existingResultat
? this.resultatService.update(existingResultat.id, payload)
: this.resultatService.create(payload);
request$.subscribe({
next: () => {
this.closeResultatModal();
this.fetch({
page: this.page(),
perPage: this.perPage(),
search: this.search(),
sortKey: this.sort().key,
sortDir: this.sort().dir,
});
toast.success('Résultat enregistré avec succès');
},
error: (err) => {
console.error('Error saving resultat:', err);
toast.error("Erreur lors de l'enregistrement du résultat");
},
});
}
onResultatValidate() {
const c = this.selectedCourseForResultat();
if (!c) return;
const resultat = this.resultatsMap().get(c.id);
if (!resultat) {
toast.error('Aucun résultat à valider');
return;
}
// For now, validation is just an update. In the future, you might add a statut field
this.resultatService.update(resultat.id, {}).subscribe({
next: () => {
this.closeResultatModal();
this.fetch({
page: this.page(),
perPage: this.perPage(),
search: this.search(),
sortKey: this.sort().key,
sortDir: this.sort().dir,
});
toast.success('Résultat validé avec succès');
},
error: (err) => {
console.error('Error validating resultat:', err);
toast.error('Erreur lors de la validation du résultat');
},
});
}
onResultatConfirm() {
const c = this.selectedCourseForResultat();
if (!c) return;
const resultat = this.resultatsMap().get(c.id);
if (!resultat) {
toast.error('Aucun résultat à confirmer');
return;
}
// For now, confirmation is just an update. In the future, you might add a statut field
this.resultatService.update(resultat.id, {}).subscribe({
next: () => {
this.closeResultatModal();
this.fetch({
page: this.page(),
perPage: this.perPage(),
search: this.search(),
sortKey: this.sort().key,
sortDir: this.sort().dir,
});
toast.success('Résultat confirmé avec succès');
},
error: (err) => {
console.error('Error confirming resultat:', err);
toast.error('Erreur lors de la confirmation du résultat');
},
});
}
}

View File

@@ -0,0 +1,132 @@
<div class="min-h-screen flex flex-col gap-2">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Hippodromes</h1>
<button z-button (click)="openCreate()">Nouvel hippodrome</button>
</div>
<!-- Cartes statistiques des hippodromes -->
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<z-card class="text-center py-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Total des hippodromes</div>
<div class="text-3xl font-bold text-gray-900 dark:text-gray-100 mt-1">
{{ total() }}
</div>
</z-card>
<z-card class="text-center py-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Pays représentés</div>
<div class="text-3xl font-bold text-blue-600 dark:text-blue-400 mt-1">
{{ uniqueCountries() }}
</div>
</z-card>
<z-card class="text-center py-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Villes uniques</div>
<div class="text-3xl font-bold text-emerald-600 dark:text-emerald-400 mt-1">
{{ uniqueCities() }}
</div>
</z-card>
<z-card class="text-center py-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Moyenne par pays</div>
<div class="text-3xl font-bold text-amber-600 dark:text-amber-400 mt-1">
{{ averageByCountry() }}
</div>
</z-card>
<z-card class="text-center py-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Réunions totales</div>
<div class="text-3xl font-bold text-purple-600 dark:text-purple-400 mt-1">
{{ totalReunions() }}
</div>
</z-card>
<z-card class="text-center py-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Courses totales</div>
<div class="text-3xl font-bold text-pink-600 dark:text-pink-400 mt-1">
{{ totalCourses() }}
</div>
</z-card>
</div>
<app-search-bar placeholder="Rechercher (nom, ville, pays…)" (search)="onSearch($event)" />
<div class="rounded-2xl overflow-hidden">
<app-data-table
[columns]="cols"
[data]="rows()"
[loading]="loading()"
[sort]="sort()"
(sortChange)="onSort($event)"
>
<!-- Template pour Statut -->
<ng-template #statutTpl let-row>
<span
class="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full"
[class.bg-green-100]="row.actif"
[class.text-green-800]="row.actif"
[class.bg-red-100]="!row.actif"
[class.text-red-800]="!row.actif"
>
<span
class="h-2 w-2 rounded-full"
[class.bg-green-500]="row.actif"
[class.bg-red-500]="!row.actif"
></span>
{{ row.actif ? 'Actif' : 'Inactif' }}
</span>
</ng-template>
<!-- Template pour Date -->
<ng-template #dateTpl let-row>
<span class="text-gray-700 dark:text-gray-300">
{{ row.createdAt | date : 'shortDate' }}
</span>
</ng-template>
<!-- Actions par ligne avec le row injecté -->
<ng-template #rowActions let-row let-i="index">
<div class="flex flex-row gap-2">
<button
class="p-1 rounded text-blue-600 hover:bg-blue-100 dark:text-blue-400 dark:hover:bg-gray-800 cursor-pointer"
(click)="openEdit(row)"
aria-label="Modifier"
title="Modifier"
>
<lucide-angular name="folder-pen" class="size-5"></lucide-angular>
</button>
<button
class="p-1 rounded text-red-600 hover:bg-red-100 dark:text-red-400 dark:hover:bg-gray-800 cursor-pointer"
(click)="remove(row)"
aria-label="Supprimer"
title="Supprimer"
>
<lucide-angular name="trash-2" class="size-5"></lucide-angular>
</button>
</div>
</ng-template>
</app-data-table>
<app-paginator
[page]="page()"
[perPage]="perPage()"
[total]="total()"
(pageChange)="page.set($event)"
(perPageChange)="onPerPage($event)"
[pageSizes]="pageSize"
/>
</div>
<!-- MODALE CRÉATION / ÉDITION -->
<app-modal [open]="modalOpen()" [title]="modalTitle()" size="md" (close)="closeModal()">
<app-hippodrome-form
[value]="editingItem()"
(save)="onFormSave($event)"
(cancel)="closeModal()"
[showInternalActions]="false"
></app-hippodrome-form>
<div modal-actions class="flex gap-2 justify-end">
<z-button zType="destructive" (click)="closeModal()">Annuler</z-button>
<z-button zType="default" (click)="submitChildForm()">Enregistrer</z-button>
</div>
</app-modal>
</div>

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Hippodrome } from './hippodrome';
describe('Hippodrome', () => {
let component: Hippodrome;
let fixture: ComponentFixture<Hippodrome>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Hippodrome]
})
.compileComponents();
fixture = TestBed.createComponent(Hippodrome);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,204 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
TemplateRef,
ViewChild,
computed,
effect,
signal,
} from '@angular/core';
import { DataTable, SortState, TableColumn } from '@shared/components/data-table/data-table';
import { Modal } from '@shared/components/modal/modal';
import { Paginator } from '@shared/components/paginator/paginator';
import { SearchBar } from '@shared/components/search-bar/search-bar';
import { HippodromeForm } from '@shared/forms/hippodrome-form/hippodrome-form';
import { Hippodrome as HippodromeType } from 'src/app/core/interfaces/hippodrome';
import { HippodromeService } from 'src/app/core/services/hippodrome';
import { ZardBreadcrumbModule } from '@shared/components/sheet/sheet.module';
import { ZardCardComponent } from '@shared/components/card/card.component';
import { LucideAngularModule } from 'lucide-angular';
@Component({
standalone: true,
selector: 'app-hippodrome-list',
imports: [
CommonModule,
DataTable,
Paginator,
SearchBar,
Modal,
HippodromeForm,
ZardBreadcrumbModule,
ZardCardComponent,
LucideAngularModule,
],
templateUrl: './hippodrome.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Hippodrome {
rows = signal<HippodromeType[]>([]);
loading = signal(false);
total = signal(0);
uniqueCountries = signal(0);
uniqueCities = signal(0);
averageByCountry = signal(0);
totalReunions = signal(0);
totalCourses = signal(0);
page = signal(1);
perPage = signal(10);
pageSize = [10, 20, 50];
search = signal('');
sort = signal<SortState>({ key: 'nom', dir: 'asc' });
@ViewChild(HippodromeForm) formComp?: HippodromeForm;
cols: TableColumn<HippodromeType>[] = [
{ key: 'nom', label: 'Nom', sortable: true },
{ key: 'ville', label: 'Ville', sortable: true },
{ key: 'pays', label: 'Pays', sortable: true },
{
key: 'reunionCount',
label: 'Réunions',
sortable: true,
cell: (h) => (h.reunionCount ?? 0).toString(),
},
{
key: 'courseCount',
label: 'Courses',
sortable: true,
cell: (h) => (h.courseCount ?? 0).toString(),
},
{
key: 'capacite',
label: 'Capacité',
sortable: true,
cell: (h) => (h.capacite ? h.capacite.toLocaleString('fr-FR') : '—'),
},
{
key: 'actif',
label: 'Statut',
sortable: true,
cell: (h) =>
`<span class="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full ${
h.actif
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'
}">
<span class="h-2 w-2 rounded-full ${h.actif ? 'bg-green-500' : 'bg-red-500'}"></span>
${h.actif ? 'Actif' : 'Inactif'}
</span>`,
},
{
key: 'createdAt',
label: 'Créé le',
sortable: true,
cell: (h) =>
new Date(h.createdAt).toLocaleDateString('fr-FR', {
day: '2-digit',
month: 'short',
year: 'numeric',
}),
},
];
// Modale
modalOpen = signal(false);
modalTitle = signal('Nouvel hippodrome');
editingItem = signal<Partial<HippodromeType> | null>(null); // null => création
constructor(private api: HippodromeService) {
effect(() => this.fetch());
}
private fetch() {
this.loading.set(true);
this.api
.list({
page: this.page(),
perPage: this.perPage(),
search: this.search(),
sortKey: this.sort().key,
sortDir: this.sort().dir,
})
.subscribe({
next: (res) => {
this.rows.set(res.data);
const meta = res.meta ?? {};
this.total.set(meta['total'] ?? 0);
this.uniqueCities.set(meta['uniqueCities'] ?? 0);
this.uniqueCountries.set(meta['uniqueCountries'] ?? 0);
this.averageByCountry.set(meta['averageByCountry'] ?? 0);
this.totalReunions.set(meta['totalReunions'] ?? 0);
this.totalCourses.set(meta['totalCourses'] ?? 0);
this.loading.set(false);
},
error: () => {
this.rows.set([]);
this.total.set(0);
this.uniqueCities.set(0);
this.uniqueCountries.set(0);
this.averageByCountry.set(0);
this.totalReunions.set(0);
this.totalCourses.set(0);
this.loading.set(false);
},
});
}
onSearch(q: string) {
this.search.set(q);
this.page.set(1);
}
onSort(s: SortState) {
this.sort.set(s);
this.page.set(1);
}
onPerPage(n: number) {
this.perPage.set(n);
this.page.set(1);
}
openCreate() {
this.modalTitle.set('Nouvel hippodrome');
this.editingItem.set(null);
queueMicrotask(() => this.modalOpen.set(true));
}
openEdit(ev: HippodromeType) {
this.modalTitle.set('Modifier lhippodrome');
this.editingItem.set(ev);
queueMicrotask(() => this.modalOpen.set(true));
}
closeModal() {
this.modalOpen.set(false);
}
submitChildForm() {
// Déclenche le submit du formulaire enfant
this.formComp?.onSubmit();
}
onFormSave(payload: Partial<HippodromeType>) {
const current = this.editingItem();
const req$ = current?.id
? this.api.update(current.id, payload)
: this.api.create(payload as Omit<HippodromeType, 'id'>);
req$.subscribe(() => {
this.closeModal();
// Reset editing item to null to clear the form
this.editingItem.set(null);
this.fetch();
});
}
remove(ev: HippodromeType) {
if (!confirm(`Supprimer lhippodrome « ${ev.nom} » ?`)) return;
this.api.delete(ev.id).subscribe(() => this.fetch());
}
}

View File

@@ -0,0 +1,63 @@
<div class="flex flex-col gap-2 min-h-screen">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-semibold">Gestion des Limites</h2>
<z-button (click)="openCreate()">Nouvelle limite</z-button>
</div>
<!-- Actif Filter Chips -->
<div class="flex flex-wrap gap-2 items-center">
<span class="text-sm font-medium">Filtrer par statut:</span>
<button
z-button
zType="ghost"
zSize="sm"
[class]="selectedActif() === null ? '!bg-primary/10 !text-primary' : ''"
(click)="onActifFilter(null)"
>
Tous
</button>
<button
z-button
zType="ghost"
zSize="sm"
[class]="selectedActif() === true ? '!bg-green-500/10 !text-green-600 dark:!text-green-400' : ''"
(click)="onActifFilter(true)"
>
<i class="icon-check"></i>
Actives
</button>
<button
z-button
zType="ghost"
zSize="sm"
[class]="selectedActif() === false ? '!bg-gray-500/10 !text-gray-600 dark:!text-gray-400' : ''"
(click)="onActifFilter(false)"
>
<i class="icon-x"></i>
Inactives
</button>
</div>
<app-search-bar (search)="onSearch($event)"></app-search-bar>
<app-data-table [data]="rows()" [columns]="cols" [sort]="sort()" (sortChange)="sort.set($event)">
<ng-template #rowActions let-row>
<div class="flex gap-3">
<button z-button zType="ghost" (click)="openEdit(row)"><i class="icon-pen"></i></button>
<button z-button zType="destructive" (click)="remove(row)"><i class="icon-trash"></i></button>
</div>
</ng-template>
</app-data-table>
<app-paginator [total]="total()" [page]="page()" [perPage]="perPage()" (pageChange)="page.set($event)" (perPageChange)="perPage.set($event)"></app-paginator>
</div>
<app-modal [open]="modalOpen()" [title]="modalTitle()" (close)="closeModal()" size="xl">
<app-limit-form [value]="editingItem() ?? undefined" (save)="onFormSave($event)" (cancel)="closeModal()" />
<div modal-actions class="flex justify-end gap-2">
<z-button zType="destructive" (click)="closeModal()">Annuler</z-button>
<z-button (click)="submitChildForm()">Enregistrer</z-button>
</div>
</app-modal>

View File

@@ -0,0 +1,294 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, ViewChild, effect, signal, untracked, OnInit } from '@angular/core';
import { DataTable, SortState, TableColumn } from '@shared/components/data-table/data-table';
import { Paginator } from '@shared/components/paginator/paginator';
import { SearchBar } from '@shared/components/search-bar/search-bar';
import { Modal } from '@shared/components/modal/modal';
import { ZardButtonComponent } from '@shared/components/button/button.component';
import { SortDir } from '@shared/paging/paging';
import { AgentLimit } from 'src/app/core/interfaces/agent-limit';
import { AgentLimitService } from 'src/app/core/services/agent-limit';
import { LimitForm } from '@shared/forms/limit-form/limit-form';
import { Subject, of } from 'rxjs';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';
@Component({
standalone: true,
selector: 'app-limits',
templateUrl: './limits.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, DataTable, Paginator, SearchBar, Modal, ZardButtonComponent, LimitForm],
})
export class LimitsPage implements OnInit {
rows = signal<AgentLimit[]>([]);
total = signal(0);
loading = signal(false);
page = signal(1);
perPage = signal(10);
search = signal('');
sort = signal<SortState>({ key: 'code', dir: 'asc' });
selectedActif = signal<boolean | null>(null);
modalOpen = signal(false);
modalTitle = signal('Nouvelle limite');
editingItem = signal<AgentLimit | null>(null);
// Live search
private searchSubject = new Subject<string>();
@ViewChild(LimitForm) formComp?: LimitForm;
cols: TableColumn<AgentLimit>[] = [
{ key: 'code', label: 'Code', sortable: true },
{ key: 'configCode', label: 'Config', sortable: true },
{ key: 'nom', label: 'Nom', sortable: true },
{
key: 'isDefault',
label: 'Défaut',
cell: (l) =>
l.isDefault
? '<span class="inline-flex items-center gap-1 px-2 py-1 rounded bg-primary/10 text-primary text-xs font-medium"><i class="icon-star"></i> Par défaut</span>'
: '<span class="text-muted-foreground">—</span>',
},
{
key: 'actif',
label: 'Actif',
cell: (l) =>
l.actif
? '<span class="inline-flex items-center gap-1 px-2 py-1 rounded bg-green-500/10 text-green-600 dark:text-green-400 text-xs font-medium"><i class="icon-check"></i> Actif</span>'
: '<span class="inline-flex items-center gap-1 px-2 py-1 rounded bg-gray-500/10 text-gray-600 dark:text-gray-400 text-xs font-medium"><i class="icon-x"></i> Inactif</span>',
},
{
key: 'betMin',
label: 'Min Bet',
cell: (l) => (l.betMin ?? 0).toLocaleString('fr-FR'),
},
{
key: 'betMax',
label: 'Max Bet',
cell: (l) => (l.betMax ?? 0).toLocaleString('fr-FR'),
},
{
key: 'maxBet',
label: 'Max Bet (tx)',
cell: (l) => (l.maxBet ?? 0).toLocaleString('fr-FR'),
},
{
key: 'maxDisburseBet',
label: 'Max Disburse',
cell: (l) => (l.maxDisburseBet ?? 0).toLocaleString('fr-FR'),
},
{
key: 'airtimeMin',
label: 'Airtime Min',
cell: (l) => (l.airtimeMin ?? 0).toLocaleString('fr-FR'),
},
{
key: 'airtimeMax',
label: 'Airtime Max',
cell: (l) => (l.airtimeMax ?? 0).toLocaleString('fr-FR'),
},
];
constructor(private api: AgentLimitService) {
effect(() => {
// Only trigger fetch when page, perPage, or sort changes (not search - handled by searchSubject)
const searchValue = this.search();
const params = {
page: this.page(),
perPage: this.perPage(),
search: searchValue,
sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir,
};
// Only fetch if search is empty (search is handled by searchSubject)
if (!searchValue.trim()) {
untracked(() => this.fetch(params));
}
});
// Setup live search with debounce
this.searchSubject
.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap((query) => {
if (query.trim()) {
// Use search API which returns array
return this.api.search(query);
} else {
// If empty, use normal list
return this.api.list({
page: this.page(),
perPage: this.perPage(),
search: '',
sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir,
}).pipe(
switchMap((res) => {
// Convert PagedResult to array for consistency
return of(res.data);
})
);
}
})
)
.subscribe({
next: (res) => {
// Search API always returns array
if (Array.isArray(res)) {
this.rows.set(res);
this.total.set(res.length);
}
this.loading.set(false);
},
error: (err) => {
console.error('Search error:', err);
this.rows.set([]);
this.total.set(0);
this.loading.set(false);
},
});
}
ngOnInit() {
// Initial fetch
this.fetch({
page: this.page(),
perPage: this.perPage(),
search: this.search(),
sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir,
});
}
private fetch(params: { page: number; perPage: number; search: string; sortKey: string; sortDir: SortDir }) {
// Don't fetch if there's a search query - it's handled by searchSubject
const searchQuery = params.search.trim();
if (searchQuery) {
return; // Search is handled by searchSubject subscription
}
this.loading.set(true);
const actif = this.selectedActif();
if (actif !== null) {
// Filter by actif status - returns array
this.api.getByActif(actif).subscribe({
next: (res: AgentLimit[]) => {
this.rows.set(res);
this.total.set(res.length);
this.loading.set(false);
},
error: () => {
this.rows.set([]);
this.total.set(0);
this.loading.set(false);
},
});
} else {
// Normal list with pagination
this.api.list(params).subscribe({
next: (res) => {
this.rows.set(res.data);
this.total.set(res.meta.total);
this.loading.set(false);
},
error: () => {
this.rows.set([]);
this.total.set(0);
this.loading.set(false);
},
});
}
}
onSearch(q: string) {
this.search.set(q);
this.page.set(1);
// Trigger search via subject for live search
if (q.trim()) {
this.loading.set(true);
this.searchSubject.next(q);
} else {
// If empty, fetch normally
this.fetch({
page: this.page(),
perPage: this.perPage(),
search: '',
sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir,
});
}
}
onActifFilter(actif: boolean | null) {
this.selectedActif.set(actif);
this.page.set(1);
this.fetch({
page: this.page(),
perPage: this.perPage(),
search: this.search(),
sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir,
});
}
openCreate() { this.modalTitle.set('Nouvelle limite'); this.editingItem.set(null); queueMicrotask(() => this.modalOpen.set(true)); }
openEdit(row: AgentLimit) { this.modalTitle.set('Modifier la limite'); this.editingItem.set(row); queueMicrotask(() => this.modalOpen.set(true)); }
closeModal() { this.modalOpen.set(false); }
submitChildForm() { this.formComp?.onSubmit(); }
onFormSave(payload: Partial<AgentLimit>) {
const current = this.editingItem();
const isSettingDefault = payload.isDefault === true;
const wasDefault = current?.isDefault;
// If setting as default and it wasn't default before, show confirmation
if (isSettingDefault && !wasDefault) {
if (!confirm('Définir cette limite comme limite par défaut ?\n\nTous les agents recevront automatiquement cette limite, et l\'ancienne limite par défaut perdra son statut.')) {
return;
}
}
const req$ = current?.id ? this.api.update(current.id, payload) : this.api.create(payload as Omit<AgentLimit, 'id'>);
req$.subscribe({
next: (result) => {
if (!result && current?.id) {
// Update failed
alert('Erreur lors de la sauvegarde de la limite');
return;
}
this.closeModal();
this.fetch({
page: this.page(),
perPage: this.perPage(),
search: this.search(),
sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir,
});
if (isSettingDefault && !wasDefault) {
alert('La limite a été définie comme limite par défaut. Tous les agents ont été mis à jour.');
}
},
error: (err) => {
console.error('Error saving limit:', err);
alert('Erreur lors de la sauvegarde de la limite');
},
});
}
remove(row: AgentLimit) {
if (!confirm(`Supprimer la limite ${row.code} ?`)) return;
this.api.delete(row.id).subscribe(() => {
this.fetch({
page: this.page(),
perPage: this.perPage(),
search: this.search(),
sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir,
});
});
}
}

View File

View File

@@ -0,0 +1,333 @@
<div class="min-h-screen gap-4 flex flex-col">
<h1 class="text-3xl font-bold text-gray-800 dark:text-white">Dashboard principale PJP</h1>
<!-- Loading / Error -->
@if (statsLoading()) {
<div class="text-sm text-gray-500 dark:text-gray-400">Chargement des statistiques…</div>
} @if (statsError()) {
<div class="text-sm text-red-500">{{ statsError() }}</div>
}
<!-- Live / upcoming courses with hippodrome & reunion -->
<div class="grid gap-4">
<z-card class="w-full">
<div class="flex items-center justify-between mb-3">
<div>
<div class="text-sm text-gray-500 dark:text-gray-400">Courses en direct & à venir</div>
<div class="text-xs text-gray-400 dark:text-gray-500">
Statuts RUNNING & PROGRAMMEE, triés par heure de départ
</div>
</div>
</div>
@if (liveCourses().length) {
<div class="divide-y divide-gray-100 dark:divide-gray-800 text-xs sm:text-sm">
@for (c of liveCourses(); track c.id) {
<div class="py-3 flex flex-col gap-2">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<div class="flex flex-col gap-1.5">
<div class="flex items-center gap-2 flex-wrap">
<span
class="inline-flex items-center justify-center rounded-full px-2 py-0.5 text-[10px] font-semibold bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300"
>
N° {{ c.numero }}
</span>
<span class="font-medium text-gray-900 dark:text-gray-100 truncate">
{{ c.nom }}
</span>
@if (c.type) {
<span
class="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300"
>
{{ c.type }}
</span>
}
</div>
<div
class="flex flex-wrap items-center gap-2 text-[11px] text-gray-500 dark:text-gray-400"
>
<span class="flex items-center gap-1">
<svg
class="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
{{
c.dateDepartCourse
? (c.dateDepartCourse | date : 'short' : undefined : 'fr-FR')
: '—'
}}
</span>
<span class="h-1 w-1 rounded-full bg-gray-400"></span>
<span class="font-medium">
{{ c.reunion.hippodrome.nom }}
</span>
<span class="h-1 w-1 rounded-full bg-gray-400"></span>
<span> Réunion {{ c.reunion.nom }} </span>
<span class="h-1 w-1 rounded-full bg-gray-400"></span>
<span> Distance {{ c.distance | number : '1.0-0' }} m </span>
</div>
<div
class="flex flex-wrap items-center gap-2 text-[11px] text-gray-600 dark:text-gray-300"
>
<span class="flex items-center gap-1 font-medium">
<svg
class="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
></path>
</svg>
{{ c.partants }} partant{{ c.partants > 1 ? 's' : '' }}
</span>
@if (c.nonPartants && c.nonPartants.length > 0) {
<span class="h-1 w-1 rounded-full bg-gray-400"></span>
<span class="text-orange-600 dark:text-orange-400">
{{ c.nonPartants.length }} non-partant{{ c.nonPartants.length > 1 ? 's' : '' }}
</span>
} @if (c.condition) {
<span class="h-1 w-1 rounded-full bg-gray-400"></span>
<span class="italic">{{ c.condition }}</span>
} @if (c.particularite) {
<span class="h-1 w-1 rounded-full bg-gray-400"></span>
<span class="text-blue-600 dark:text-blue-400">⭐ {{ c.particularite }}</span>
}
</div>
</div>
<div class="flex items-center gap-2 sm:gap-3">
<span
class="px-2 py-1 rounded-full text-[10px] font-semibold"
[ngClass]="{
'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300':
(c.statut || '').toUpperCase() === 'RUNNING',
'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300':
(c.statut || '').toUpperCase() === 'PROGRAMMEE',
'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300':
(c.statut || '').toUpperCase() === 'VALIDATED'
}"
>
{{
(c.statut || '').toUpperCase() === 'RUNNING'
? 'En cours'
: (c.statut || '').toUpperCase() === 'PROGRAMMEE'
? 'Programmée'
: (c.statut || '').toUpperCase() === 'VALIDATED'
? 'Validée'
: c.statut
}}
</span>
</div>
</div>
</div>
}
</div>
} @else {
<div class="py-6 text-xs text-gray-400 dark:text-gray-500 text-center">
Aucune course en cours ou à venir pour le moment.
</div>
}
</z-card>
</div>
<!-- Global entities overview -->
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<z-card class="w-full max-w-sm !mt-0 p-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Utilisateurs</div>
<div class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100">
{{ totalUsers() }}
</div>
</z-card>
<z-card class="w-full max-w-sm !mt-0 p-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Agents</div>
<div class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100">
{{ totalAgents() }}
</div>
</z-card>
<z-card class="w-full max-w-sm !mt-0 p-4">
<div class="text-sm text-gray-500 dark:text-gray-400">TPE</div>
<div class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100">
{{ totalTpes() }}
</div>
</z-card>
<z-card class="w-full max-w-sm !mt-0 p-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Limites agents</div>
<div class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100">
{{ totalAgentLimits() }}
</div>
</z-card>
</div>
<!-- Racing & network overview -->
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<z-card class="w-full max-w-sm p-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Hippodromes</div>
<div class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100">
{{ totalHippodromes() }}
</div>
</z-card>
<z-card class="w-full max-w-sm p-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Réunions</div>
<div class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100">
{{ totalReunions() }}
</div>
</z-card>
<z-card class="w-full max-w-sm p-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Courses</div>
<div class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100">
{{ totalCourses() }}
</div>
</z-card>
<z-card class="w-full max-w-sm p-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Membres de famille d'agents</div>
<div class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100">
{{ totalAgentFamilyMembers() }}
</div>
</z-card>
</div>
<!-- TPE charts & activity -->
<div class="grid gap-4 lg:grid-cols-12">
<!-- TPE status donut -->
<z-card class="w-full lg:col-span-4">
<div class="flex items-center justify-between mb-2">
<div class="text-sm text-gray-500 dark:text-gray-400">Répartition TPE par statut</div>
<div class="text-xs text-gray-400 dark:text-gray-500">Total&nbsp;: {{ totalTpes() }}</div>
</div>
<div class="flex items-center gap-4">
<!-- Donut / Pie chart -->
<div class="relative h-28 w-28">
<div
class="h-28 w-28 rounded-full transition-all duration-700 ease-out shadow-inner"
[style.backgroundImage]="tpePieGradient()"
></div>
<div
class="absolute inset-4 rounded-full bg-white dark:bg-gray-900 flex items-center justify-center text-xs text-gray-700 dark:text-gray-200"
>
{{ totalTpes() }}
</div>
</div>
<!-- Legend -->
<div class="grid grid-cols-1 gap-1 text-xs flex-1">
@for (item of tpeStatusBreakdown(); track item.statut) {
<div class="flex items-center gap-2">
<span
class="inline-block h-2 w-2 rounded-full"
[style.backgroundColor]="item.color"
></span>
<span class="truncate">
{{ item.statut }} ({{ item.count }}) — {{ item.percent }}%
</span>
</div>
} @if (!tpeStatusBreakdown().length) {
<div class="text-gray-400 dark:text-gray-500 text-xs">
Aucune donnée de statut TPE disponible.
</div>
}
</div>
</div>
</z-card>
<!-- TPE assignment gauge -->
<z-card class="w-full lg:col-span-4">
<div class="text-sm text-gray-500 dark:text-gray-400">TPE assignés</div>
<div class="mt-1 flex items-baseline gap-2">
<div class="text-3xl font-semibold text-gray-900 dark:text-gray-100">
{{ tpeAssignRate() }}%
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
({{ tpeAssignedCount() }} / {{ totalTpes() }})
</div>
</div>
<div class="mt-3 h-2 rounded-full bg-gray-100 dark:bg-gray-800 overflow-hidden">
<div
class="h-full bg-emerald-500 dark:bg-emerald-400 transition-all duration-700 ease-out"
[style.width.%]="tpeAssignRate()"
></div>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Pourcentage de TPE actuellement affectés à un agent.
</p>
</z-card>
<!-- Line chart: relative volume par entité -->
<z-card class="w-full lg:col-span-4">
<div class="flex items-center justify-between mb-2">
<div class="text-sm text-gray-500 dark:text-gray-400">Activité par entité</div>
</div>
<div class="h-32 w-full">
<svg viewBox="0 0 100 40" class="w-full h-full">
<!-- Gradient background -->
<defs>
<linearGradient id="entityLineGradient" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#22c55e" />
<stop offset="50%" stop-color="#3b82f6" />
<stop offset="100%" stop-color="#a855f7" />
</linearGradient>
</defs>
<!-- Baseline -->
<line x1="0" y1="38" x2="100" y2="38" stroke="#e5e7eb" stroke-width="0.5" />
<!-- Area under line -->
@if (entityPolylinePoints()) {
<polyline
[attr.points]="'0,40 ' + entityPolylinePoints() + ' 100,40'"
fill="url(#entityLineGradient)"
fill-opacity="0.15"
></polyline>
<!-- Line -->
<polyline
[attr.points]="entityPolylinePoints()"
fill="none"
stroke="url(#entityLineGradient)"
stroke-width="1.5"
stroke-linejoin="round"
stroke-linecap="round"
></polyline>
}
</svg>
</div>
<div class="mt-3 grid grid-cols-3 gap-2 text-xs text-gray-600 dark:text-gray-300">
<div>
<div class="text-[10px] uppercase tracking-wide text-gray-400">Users</div>
<div class="font-semibold">{{ totalUsers() }}</div>
</div>
<div>
<div class="text-[10px] uppercase tracking-wide text-gray-400">Agents</div>
<div class="font-semibold">{{ totalAgents() }}</div>
</div>
<div>
<div class="text-[10px] uppercase tracking-wide text-gray-400">TPE</div>
<div class="font-semibold">{{ totalTpes() }}</div>
</div>
<div>
<div class="text-[10px] uppercase tracking-wide text-gray-400">Reunions</div>
<div class="font-semibold">{{ totalReunions() }}</div>
</div>
<div>
<div class="text-[10px] uppercase tracking-wide text-gray-400">Courses</div>
<div class="font-semibold">{{ totalCourses() }}</div>
</div>
</div>
</z-card>
</div>
</div>

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Main } from './main';
describe('Main', () => {
let component: Main;
let fixture: ComponentFixture<Main>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Main]
})
.compileComponents();
fixture = TestBed.createComponent(Main);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,257 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
import { ZardCardComponent } from '@shared/components/card/card.component';
import { UserService } from 'src/app/core/services/user';
import { AgentService } from 'src/app/core/services/agent';
import { TpeService } from 'src/app/core/services/tpe';
import { AgentLimitService } from 'src/app/core/services/agent-limit';
import { HippodromeService } from 'src/app/core/services/hippodrome';
import { ReunionService } from 'src/app/core/services/reunion';
import { CourseService } from 'src/app/core/services/course';
import { RoleService } from 'src/app/core/services/role';
import { AgentFamilyMemberService } from 'src/app/core/services/agent-family-member';
import { ListParams, SortDir } from '@shared/paging/paging';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import type { Course as CourseModel } from 'src/app/core/interfaces/course';
@Component({
selector: 'app-main',
imports: [ZardCardComponent, CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './main.html',
styleUrl: './main.css',
})
export class Main {
// Loading & error state
statsLoading = signal(false);
statsError = signal<string | null>(null);
// Global totals
totalUsers = signal(0);
totalAgents = signal(0);
totalTpes = signal(0);
totalAgentLimits = signal(0);
totalHippodromes = signal(0);
totalReunions = signal(0);
totalCourses = signal(0);
totalRoles = signal(0);
totalPermissions = signal(0);
totalAgentFamilyMembers = signal(0);
// TPE status breakdown for charts
tpeStatusBreakdown = signal<{ statut: string; count: number; percent: number; color: string }[]>(
[]
);
tpeAssignedCount = signal(0);
// Derived value for TPE assignment rate
tpeAssignRate = computed(() => {
const total = this.totalTpes();
const assigned = this.tpeAssignedCount();
if (!total) return 0;
return Math.round((assigned / total) * 100);
});
// CSS conic-gradient for a TPE status pie/donut chart
tpePieGradient = computed(() => {
const segments = this.tpeStatusBreakdown();
if (!segments.length) {
return 'conic-gradient(#e5e7eb 0deg 360deg)';
}
let current = 0;
const parts: string[] = [];
for (const seg of segments) {
const start = current;
const sweep = (seg.percent || 0) * 3.6; // percent -> degrees
const end = start + sweep;
parts.push(`${seg.color} ${start}deg ${end}deg`);
current = end;
}
return `conic-gradient(${parts.join(', ')})`;
});
// Simple entity activity series for a line chart (users, agents, tpes, reunions, courses)
entityLabels: string[] = ['USERS', 'AGENTS', 'TPE', 'REUNIONS', 'COURSES'];
entitySeries = computed(() => [
this.totalUsers(),
this.totalAgents(),
this.totalTpes(),
this.totalReunions(),
this.totalCourses(),
]);
// SVG polyline points for the entitySeries (normalized to 040 viewport)
entityPolylinePoints = computed(() => {
const values = this.entitySeries();
const max = Math.max(...values, 1);
const n = values.length;
if (!n) return '';
const stepX = n > 1 ? 100 / (n - 1) : 100;
return values
.map((v, i) => {
const x = i * stepX;
const norm = v / max; // 01
const y = 40 - norm * 30; // keep some top/bottom padding
return `${x},${y}`;
})
.join(' ');
});
// Live / upcoming courses (RUNNING or PROGRAMMEE, nearest in time)
liveCourses = signal<CourseModel[]>([]);
constructor(
private userService: UserService,
private agentService: AgentService,
private tpeService: TpeService,
private agentLimitService: AgentLimitService,
private hippodromeService: HippodromeService,
private reunionService: ReunionService,
private courseService: CourseService,
private roleService: RoleService,
private familyService: AgentFamilyMemberService
) {
this.loadStats();
}
private baseParams(): ListParams {
return {
page: 1,
perPage: 1,
search: '',
sortKey: 'id',
sortDir: 'asc' as SortDir,
};
}
private loadStats() {
this.statsLoading.set(true);
this.statsError.set(null);
const params = this.baseParams();
// Fetch more courses to filter for live/upcoming ones
const coursesParams = {
...params,
perPage: 100, // Fetch up to 100 courses to filter from
sortKey: 'dateDepartCourse',
sortDir: 'desc' as SortDir,
};
forkJoin({
users: this.userService
.list(params)
.pipe(catchError(() => of({ data: [], meta: { total: 0 } } as any))),
agents: this.agentService
.list(params)
.pipe(catchError(() => of({ data: [], meta: { total: 0 } } as any))),
tpes: this.tpeService
.list(params)
.pipe(catchError(() => of({ data: [], meta: { total: 0 } } as any))),
limits: this.agentLimitService
.list(params)
.pipe(catchError(() => of({ data: [], meta: { total: 0 } } as any))),
hippodromes: this.hippodromeService
.list(params, true)
.pipe(catchError(() => of({ data: [], meta: { total: 0 } } as any))),
reunions: this.reunionService
.list(params, true)
.pipe(catchError(() => of({ data: [], meta: { total: 0 } } as any))),
courses: this.courseService
.list(coursesParams, true)
.pipe(catchError(() => of({ data: [], meta: { total: 0 } } as any))),
roles: this.roleService
.list(params)
.pipe(catchError(() => of({ data: [], meta: { total: 0 } } as any))),
permissions: this.roleService.allPermissions().pipe(catchError(() => of([]))),
familyMembers: this.familyService.list().pipe(catchError(() => of([]))),
tpeByStatut: this.tpeService.getCountByStatut().pipe(catchError(() => of({}))),
tpeAssignes: this.tpeService.getAssignesStats().pipe(catchError(() => of(0))),
}).subscribe({
next: (res) => {
this.totalUsers.set(res.users.meta?.total ?? 0);
this.totalAgents.set(res.agents.meta?.total ?? 0);
this.totalTpes.set(res.tpes.meta?.total ?? 0);
this.totalAgentLimits.set(res.limits.meta?.total ?? 0);
this.totalHippodromes.set(res.hippodromes.meta?.total ?? 0);
this.totalReunions.set(res.reunions.meta?.total ?? 0);
this.totalCourses.set(res.courses.meta?.total ?? 0);
this.totalRoles.set(res.roles.meta?.total ?? 0);
this.totalPermissions.set((res.permissions as any[]).length ?? 0);
this.totalAgentFamilyMembers.set((res.familyMembers as any[]).length ?? 0);
// TPE status breakdown
const totalTpes = res.tpes.meta?.total ?? 0;
const statusColors: Record<string, string> = {
VALIDE: '#16a34a', // green
DISPONIBLE: '#22c55e',
AFFECTE: '#3b82f6', // blue
EN_PANNE: '#f97316', // orange
EN_MAINTENANCE: '#eab308', // yellow
BLOQUE: '#ef4444', // red
INVALIDE: '#6b7280', // gray
HORS_SERVICE: '#4b5563',
VOLE: '#7c3aed',
};
const rawStats = res.tpeByStatut || {};
const entries = Object.entries(rawStats as Record<string, number>).filter(
([, count]) => count > 0
);
const totalFromStats =
entries.reduce((sum, [, count]) => sum + (count || 0), 0) || totalTpes || 1;
const breakdown = entries.map(([statut, count]) => {
const upper = statut.toUpperCase();
const color = statusColors[upper] || '#6b7280';
const percent = Math.round(((count || 0) / totalFromStats) * 100);
return { statut: upper, count: count || 0, percent, color };
});
this.tpeStatusBreakdown.set(breakdown);
this.tpeAssignedCount.set(res.tpeAssignes ?? 0);
// Live / upcoming courses: filter by statut & date
const allCourses = (res.courses.data as CourseModel[]) ?? [];
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
const oneDayAhead = new Date(now.getTime() + 24 * 60 * 60 * 1000);
const live = allCourses
.filter((c) => {
const statut = String(c.statut || '').toUpperCase();
// Include RUNNING courses
if (statut === 'RUNNING') return true;
// Include PROGRAMMEE courses that are scheduled within the next 24 hours
if (statut === 'PROGRAMMEE') {
const d = c.dateDepartCourse ? new Date(c.dateDepartCourse) : null;
if (!d) return false;
// Include if departure is in the past hour (just started) or within next 24 hours
return d >= oneHourAgo && d <= oneDayAhead;
}
// Also include VALIDATED courses that are about to start (within next 24 hours)
if (statut === 'VALIDATED') {
const d = c.dateDepartCourse ? new Date(c.dateDepartCourse) : null;
if (!d) return false;
return d >= now && d <= oneDayAhead;
}
return false;
})
.sort((a, b) => {
const da = a.dateDepartCourse ? new Date(a.dateDepartCourse).getTime() : 0;
const db = b.dateDepartCourse ? new Date(b.dateDepartCourse).getTime() : 0;
return da - db; // Sort by departure time ascending (earliest first)
})
.slice(0, 6);
this.liveCourses.set(live);
this.statsLoading.set(false);
},
error: () => {
this.statsError.set('Erreur lors du chargement des statistiques du dashboard.');
this.statsLoading.set(false);
},
});
}
}

View File

@@ -0,0 +1,121 @@
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-semibold">Mon profil</h1>
<p class="text-sm text-muted-foreground">Gérez vos informations et la sécurité du compte</p>
</div>
<z-avatar [zImage]="avatar" zSize="lg"></z-avatar>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<!-- Account info -->
<z-card class="p-4">
<div class="mb-3">
<div class="text-lg font-medium">Informations du compte</div>
<div class="text-sm text-muted-foreground">Nom, prénom et identifiant</div>
</div>
<form [formGroup]="profileForm" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<z-form-field>
<label z-form-label>Nom</label>
<div
z-form-control
[errorMessage]="
profileForm.get('nom')?.invalid &&
(submittedProfile || profileForm.get('nom')?.touched)
? 'Ce champ est obligatoire'
: ''
"
>
<input z-input formControlName="nom" />
</div>
</z-form-field>
<z-form-field>
<label z-form-label>Prénom</label>
<div
z-form-control
[errorMessage]="
profileForm.get('prenom')?.invalid &&
(submittedProfile || profileForm.get('prenom')?.touched)
? 'Ce champ est obligatoire'
: ''
"
>
<input z-input formControlName="prenom" />
</div>
</z-form-field>
<z-form-field class="md:col-span-2">
<label z-form-label>Identifiant</label>
<div
z-form-control
[errorMessage]="
profileForm.get('identifiant')?.invalid &&
(submittedProfile || profileForm.get('identifiant')?.touched)
? 'Ce champ est obligatoire'
: ''
"
>
<input z-input formControlName="identifiant" />
</div>
</z-form-field>
</form>
<div class="flex justify-end mt-4">
<z-button (click)="saveProfile()" [zLoading]="savingProfile()">Enregistrer</z-button>
</div>
</z-card>
<!-- Password change -->
<z-card class="p-4">
<div class="mb-3">
<div class="text-lg font-medium">Sécurité</div>
<div class="text-sm text-muted-foreground">Changez votre mot de passe</div>
</div>
<form [formGroup]="passwordForm" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<z-form-field class="md:col-span-2">
<label z-form-label>Mot de passe actuel</label>
<div
z-form-control
[errorMessage]="
passwordForm.get('current')?.invalid &&
(submittedPassword || passwordForm.get('current')?.touched)
? 'Ce champ est obligatoire'
: ''
"
>
<input z-input type="password" formControlName="current" />
</div>
</z-form-field>
<z-form-field>
<label z-form-label>Nouveau mot de passe</label>
<div
z-form-control
[errorMessage]="
passwordForm.get('next')?.invalid &&
(submittedPassword || passwordForm.get('next')?.touched)
? 'Minimum 6 caractères'
: ''
"
>
<input z-input type="password" formControlName="next" />
</div>
</z-form-field>
<z-form-field>
<label z-form-label>Confirmer le mot de passe</label>
<div
z-form-control
[errorMessage]="
passwordForm.get('confirm')?.invalid &&
(submittedPassword || passwordForm.get('confirm')?.touched)
? 'Minimum 6 caractères'
: ''
"
>
<input z-input type="password" formControlName="confirm" />
</div>
</z-form-field>
</form>
<div class="flex justify-end mt-4">
<z-button (click)="changePassword()" [zLoading]="savingPassword()">Mettre à jour</z-button>
</div>
</z-card>
</div>
</div>

View File

@@ -0,0 +1,75 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { ZardCardComponent } from '@shared/components/card/card.component';
import { ZardFormModule } from '@shared/components/form/form.module';
import { ZardInputDirective } from '@shared/components/input/input.directive';
import { ZardButtonComponent } from '@shared/components/button/button.component';
import { ZardAvatarComponent } from '@shared/components/avatar/avatar.component';
@Component({
standalone: true,
selector: 'app-profile',
templateUrl: './profile.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
ReactiveFormsModule,
ZardCardComponent,
ZardFormModule,
ZardInputDirective,
ZardButtonComponent,
ZardAvatarComponent,
],
})
export class ProfilePage {
profileForm;
passwordForm;
savingProfile = signal(false);
savingPassword = signal(false);
submittedProfile = false;
submittedPassword = false;
avatar = { fallback: 'PM', url: '/assets/images/avatar.svg', alt: 'Profil' };
constructor(private fb: FormBuilder) {
this.profileForm = this.fb.group({
nom: ['', Validators.required],
prenom: ['', Validators.required],
identifiant: ['', Validators.required],
});
this.passwordForm = this.fb.group({
current: ['', Validators.required],
next: ['', [Validators.required, Validators.minLength(6)]],
confirm: ['', [Validators.required, Validators.minLength(6)]],
});
}
saveProfile() {
this.submittedProfile = true;
if (this.profileForm.invalid) {
this.profileForm.markAllAsTouched();
return;
}
this.savingProfile.set(true);
setTimeout(() => this.savingProfile.set(false), 600);
}
changePassword() {
this.submittedPassword = true;
if (this.passwordForm.invalid) {
this.passwordForm.markAllAsTouched();
return;
}
const { next, confirm } = this.passwordForm.getRawValue();
if (next !== confirm) {
// simple inline mismatch handling; in real app, set an error
return;
}
this.savingPassword.set(true);
setTimeout(() => this.savingPassword.set(false), 600);
}
}

View File

@@ -0,0 +1,114 @@
@if(detail()){
<div class="space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-semibold">
Rapport pour la course n° {{ detail()!.summary.course.numero }}
<span class="text-sm text-muted-foreground">({{ detail()!.summary.statut }})</span>
</h2>
<div class="flex items-center gap-2">
<a z-button routerLink="/rapport-courses" zType="ghost"
><i class="icon-arrow-left mr-2"></i>Retour</a
>
<z-button zType="default" (click)="onEditToggle()"
><i class="icon-edit mr-1"></i>{{ editMode() ? 'Quitter édition' : 'Modifier' }}</z-button
>
<z-button zType="default" (click)="onValidate()"
><i class="icon-edit-2 mr-1"></i>Valider</z-button
>
<z-button zType="secondary" (click)="onConfirm()"
><i class="icon-check mr-1"></i>Confirmer</z-button
>
<z-button zType="destructive" (click)="onReset()"
><i class="icon-rotate-ccw mr-1"></i>Réinitialiser</z-button
>
</div>
</div>
<z-card class="p-4">
<div class="grid grid-cols-1 md:grid-cols-4 gap-2 text-sm">
<div>
<span class="font-medium">Date:</span>
{{ detail()!.summary.course.dateDepartCourse | date : 'dd/MM/yyyy' }}
</div>
<div><span class="font-medium">Nom:</span> {{ detail()!.summary.course.nom }}</div>
<div><span class="font-medium">Type:</span> {{ detail()!.summary.course.type }}</div>
<div>
<span class="font-medium">Lieu:</span> {{ detail()!.summary.course.reunion.hippodrome.nom }}
</div>
</div>
</z-card>
<div class="rounded-md border overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-accent">
<tr>
<th class="text-left p-2">Type de gain</th>
<th class="text-left p-2">Type de jeu</th>
<th class="text-left p-2">Montant</th>
<th class="text-left p-2">Nombre</th>
<th class="text-left p-2">Statut</th>
<th class="text-left p-2">Distribué</th>
<th class="text-left p-2">Externe</th>
</tr>
</thead>
<tbody>
<tr
*ngFor="let r of editedRows(); let i = index; trackBy: trackByRow"
class="border-t transition-colors"
[ngClass]="editMode() && isRowDirty(i) ? 'bg-amber-50 dark:bg-amber-900/30' : ''"
>
<td class="p-2">{{ r.typeGain }}</td>
<td class="p-2">{{ r.typeJeu }}</td>
<td class="p-2">
@if(editMode() && !detail()!.summary.confirmed){
<input
z-input
type="number"
[value]="r.montant"
(input)="onChangeMontant(i, $any($event.target).value)"
/>
} @else {
{{ r.montant | number : '1.0-0' : 'fr-FR' }}
}
</td>
<td class="p-2">
@if(editMode() && !detail()!.summary.confirmed){
<input
z-input
type="number"
[value]="r.nombre"
(input)="onChangeNombre(i, $any($event.target).value)"
/>
} @else {
{{ r.nombre | number : '1.0-0' : 'fr-FR' }}
}
</td>
<td class="p-2">{{ r.statut }}</td>
<td class="p-2">
@if(editMode() && !detail()!.summary.confirmed){
<input
type="checkbox"
[checked]="r.distributed"
(change)="onToggleDistributed(i, $any($event.target).checked)"
/>
} @else {
<input type="checkbox" [checked]="r.distributed" disabled />
}
</td>
<td class="p-2">
@if(editMode() && !detail()!.summary.confirmed){
<input
type="checkbox"
[checked]="r.externe"
(change)="onToggleExterne(i, $any($event.target).checked)"
/>
} @else {
<input type="checkbox" [checked]="r.externe" disabled />
}
</td>
</tr>
</tbody>
</table>
</div>
</div>
}

View File

@@ -0,0 +1,132 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { ActivatedRoute, RouterModule } from '@angular/router';
import { ZardCardComponent } from '@shared/components/card/card.component';
import { ReportService } from 'src/app/core/services/report';
import { CourseReportDetail, CourseReportDetailRow } from 'src/app/core/interfaces/report';
import { ZardButtonComponent } from '@shared/components/button/button.component';
@Component({
standalone: true,
selector: 'app-report-courses-detail',
templateUrl: './report-detail.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, RouterModule, ZardCardComponent, ZardButtonComponent],
})
export class ReportCoursesDetailPage {
detail = signal<CourseReportDetail | undefined>(undefined);
editMode = signal(false);
editedRows = signal<CourseReportDetailRow[]>([]);
private originalRows = signal<CourseReportDetailRow[]>([]);
constructor(private route: ActivatedRoute, private api: ReportService) {
const id = this.route.snapshot.params['id'];
this.api.getDetail(id).subscribe((d) => {
this.detail.set(d);
this.editedRows.set(d?.rows ?? []);
this.originalRows.set(d?.rows ? d.rows.map((r) => ({ ...r })) : []);
});
}
onValidate() {
const id = this.detail()?.summary.id;
if (!id) return;
// Persist edited rows then validate
this.api.modifyRows(id, this.editedRows()).subscribe(() => {
this.api.validate(id).subscribe((s) => {
if (this.detail()) this.detail.set({ summary: s!, rows: this.editedRows() });
// Commit current edits as the new baseline
this.originalRows.set(this.editedRows().map((r) => ({ ...r })));
this.editMode.set(false);
});
});
}
onConfirm() {
const id = this.detail()?.summary.id;
if (!id) return;
this.api.confirm(id).subscribe((s) => {
if (this.detail()) this.detail.set({ summary: s!, rows: this.editedRows() });
// Confirm also commits the current edits as baseline
this.originalRows.set(this.editedRows().map((r) => ({ ...r })));
this.editMode.set(false);
});
}
onReset() {
const id = this.detail()?.summary.id;
if (!id) return;
this.api.resetStatus(id).subscribe((s) => {
if (this.detail()) this.detail.set({ summary: s!, rows: this.detail()!.rows });
// Reset discards uncommitted edits
this.editedRows.set(this.originalRows().map((r) => ({ ...r })));
this.editMode.set(false);
});
}
onEditToggle() {
if (this.detail()?.summary.confirmed) return;
const currentlyEditing = this.editMode();
if (currentlyEditing) {
// Leaving edit mode without validation: revert to original snapshot
this.editedRows.set(this.originalRows().map((r) => ({ ...r })));
this.editMode.set(false);
} else {
this.editMode.set(true);
}
}
onChangeMontant(index: number, value: any) {
const v = Number(value);
this.editedRows.update((rows: CourseReportDetailRow[]) => {
const current = rows[index];
if (!current) return rows;
current.montant = Number.isFinite(v) ? v : current.montant;
return rows;
});
}
onChangeNombre(index: number, value: any) {
const v = Number(value);
this.editedRows.update((rows: CourseReportDetailRow[]) => {
const current = rows[index];
if (!current) return rows;
current.nombre = Number.isFinite(v) ? v : current.nombre;
return rows;
});
}
onToggleDistributed(index: number, value: any) {
const checked = !!value;
this.editedRows.update((rows: CourseReportDetailRow[]) => {
const current = rows[index];
if (!current) return rows;
current.distributed = checked;
return rows;
});
}
onToggleExterne(index: number, value: any) {
const checked = !!value;
this.editedRows.update((rows: CourseReportDetailRow[]) => {
const current = rows[index];
if (!current) return rows;
current.externe = checked;
return rows;
});
}
trackByRow(index: number, row: CourseReportDetailRow) {
return row.typeGain + '|' + row.typeJeu + '|' + index;
}
isRowDirty(index: number): boolean {
const current = this.editedRows()[index];
const original = this.originalRows()[index];
if (!current || !original) return false;
return (
current.montant !== original.montant ||
current.nombre !== original.nombre ||
!!current.distributed !== !!original.distributed ||
!!current.externe !== !!original.externe
);
}
}

View File

@@ -0,0 +1,19 @@
<div class="flex flex-col gap-2 min-h-screen">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-semibold">Rapport des courses</h2>
</div>
<app-search-bar (search)="onSearch($event)"></app-search-bar>
<app-data-table [data]="rows()" [columns]="cols" [sort]="sort()" (sortChange)="sort.set($event)">
<ng-template #rowActions let-row>
<div class="flex gap-3">
<button z-button zType="ghost" (click)="open(row)"><i class="icon-eye"></i></button>
</div>
</ng-template>
</app-data-table>
<app-paginator [total]="total()" [page]="page()" [perPage]="perPage()" (pageChange)="page.set($event)" (perPageChange)="perPage.set($event)"></app-paginator>
</div>

View File

@@ -0,0 +1,61 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, signal, effect, untracked } from '@angular/core';
import { DataTable, SortState, TableColumn } from '@shared/components/data-table/data-table';
import { Paginator } from '@shared/components/paginator/paginator';
import { SearchBar } from '@shared/components/search-bar/search-bar';
import { ZardButtonComponent } from '@shared/components/button/button.component';
import { SortDir } from '@shared/paging/paging';
import { Router } from '@angular/router';
import { CourseReportSummary } from 'src/app/core/interfaces/report';
import { ReportService } from 'src/app/core/services/report';
@Component({
standalone: true,
selector: 'app-report-courses-list',
templateUrl: './report-list.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, DataTable, Paginator, SearchBar, ZardButtonComponent],
})
export class ReportCoursesListPage {
rows = signal<CourseReportSummary[]>([]);
total = signal(0);
page = signal(1);
perPage = signal(10);
search = signal('');
sort = signal<SortState>({ key: 'date', dir: 'desc' });
loading = signal(false);
cols: TableColumn<CourseReportSummary>[] = [
{ key: 'course.dateDepartCourse', label: 'Date', sortable: true },
{ key: 'course.numero', label: 'Numéro', sortable: true },
{ key: 'course.nom', label: 'Nom', sortable: true },
{ key: 'course.type', label: 'Type', sortable: true },
{ key: 'course.reunion.hippodrome.nom', label: 'Lieu', sortable: true },
{ key: 'course.particularite', label: 'Particularité' },
{ key: 'statut', label: 'Statut', sortable: true },
];
constructor(private api: ReportService, private router: Router) {
effect(() => {
const params = { page: this.page(), perPage: this.perPage(), search: this.search(), sortKey: this.sort().key, sortDir: this.sort().dir as SortDir };
untracked(() => this.fetch(params));
});
}
fetch(params: { page: number; perPage: number; search: string; sortKey: string; sortDir: SortDir }) {
this.loading.set(true);
this.api.list(params).subscribe((res) => {
this.rows.set(res.data);
this.total.set(res.meta.total);
this.loading.set(false);
});
}
onSearch(q: string) { this.search.set(q); this.page.set(1); }
open(row: CourseReportSummary) {
this.router.navigate(['/rapport-courses', row.id]);
}
}

View File

@@ -0,0 +1,87 @@
<div class="min-h-screen flex flex-col gap-2">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Réunions</h1>
<button z-button (click)="openCreate()">Nouvelle réunion</button>
</div>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<z-card class="text-center py-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Total des réunions</div>
<div class="text-3xl font-bold text-gray-900 dark:text-gray-100 mt-1">
{{ total() }}
</div>
</z-card>
<z-card class="text-center py-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Réunions à venir</div>
<div class="text-3xl font-bold text-blue-600 dark:text-blue-400 mt-1">
{{ upcomingReunions() }}
</div>
</z-card>
<z-card class="text-center py-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Réunions passées</div>
<div class="text-3xl font-bold text-amber-600 dark:text-amber-400 mt-1">
{{ pastReunions() }}
</div>
</z-card>
<z-card class="text-center py-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Hippodromes concernés</div>
<div class="text-3xl font-bold text-green-600 dark:text-green-400 mt-1">
{{ uniqueHippodromes() }}
</div>
</z-card>
</div>
<app-search-bar placeholder="Rechercher (nom, date, hippodrome…)" (search)="onSearch($event)" />
<div class="rounded-2xl overflow-hidden">
<app-data-table
[columns]="cols"
[data]="rows()"
[loading]="loading()"
[sort]="sort()"
(sortChange)="sort.set($event)"
>
<ng-template #rowActions let-row>
<div class="flex flex-row gap-2">
<button
class="p-1 rounded text-blue-600 hover:bg-blue-100 dark:text-blue-400 dark:hover:bg-gray-800 cursor-pointer"
(click)="openEdit(row)"
>
<lucide-angular name="folder-pen" class="size-5"></lucide-angular>
</button>
<button
class="p-1 rounded text-red-600 hover:bg-red-100 dark:text-red-400 dark:hover:bg-gray-800 cursor-pointer"
(click)="remove(row)"
>
<lucide-angular name="trash-2" class="size-5"></lucide-angular>
</button>
</div>
</ng-template>
</app-data-table>
<app-paginator
[page]="page()"
[perPage]="perPage()"
[total]="total()"
(pageChange)="page.set($event)"
(perPageChange)="perPage.set($event)"
[pageSizes]="pageSize"
/>
</div>
<app-modal [open]="modalOpen()" [title]="modalTitle()" size="md" (close)="closeModal()">
<app-reunion-form
[value]="editingItem()"
(save)="onFormSave($event)"
(cancel)="closeModal()"
></app-reunion-form>
<div modal-actions class="flex gap-2 justify-end">
<z-button zType="destructive" (click)="closeModal()">Annuler</z-button>
<z-button zType="default" (click)="submitChildForm()">Enregistrer</z-button>
</div>
</app-modal>
</div>

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Reunion } from './reunion';
describe('Reunion', () => {
let component: Reunion;
let fixture: ComponentFixture<Reunion>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Reunion]
})
.compileComponents();
fixture = TestBed.createComponent(Reunion);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,234 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
signal,
ViewChild,
untracked,
} from '@angular/core';
import { Reunion as ReunionType } from 'src/app/core/interfaces/reunion';
import { DataTable, SortState, TableColumn } from '@shared/components/data-table/data-table';
import { Paginator } from '@shared/components/paginator/paginator';
import { SearchBar } from '@shared/components/search-bar/search-bar';
import { Modal } from '@shared/components/modal/modal';
import { ReunionForm } from '@shared/forms/reunion-form/reunion-form';
import { ReunionService } from 'src/app/core/services/reunion';
import { ZardButtonComponent } from '@shared/components/button/button.component';
import { ZardCardComponent } from '@shared/components/card/card.component';
import { SortDir } from '@shared/paging/paging';
import { LucideAngularModule } from 'lucide-angular';
@Component({
standalone: true,
selector: 'app-reunion-list',
imports: [
CommonModule,
DataTable,
Paginator,
SearchBar,
Modal,
ReunionForm,
ZardButtonComponent,
ZardCardComponent,
LucideAngularModule,
],
templateUrl: './reunion.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ReunionList {
// Core reactive state
rows = signal<ReunionType[]>([]);
loading = signal(false);
total = signal(0);
upcomingReunions = signal(0);
pastReunions = signal(0);
uniqueHippodromes = signal(0);
// pagination, sorting, search
page = signal(1);
perPage = signal(10);
search = signal('');
sort = signal<SortState>({ key: 'date', dir: 'asc' });
pageSize = [10, 20, 50];
// modal management
modalOpen = signal(false);
modalTitle = signal('Nouvelle réunion');
editingItem = signal<Partial<ReunionType> | null>(null);
@ViewChild(ReunionForm) formComp?: ReunionForm;
cols: TableColumn<ReunionType>[] = [
{ key: 'code', label: 'Code', sortable: true },
{ key: 'nom', label: 'Nom', sortable: true },
{
key: 'date',
label: 'Date',
sortable: true,
cell: (r) =>
new Date(r.date).toLocaleDateString('fr-FR', {
day: '2-digit',
month: 'short',
year: 'numeric',
}),
},
{ key: 'numero', label: 'Numéro', sortable: true },
{
key: 'hippodrome.nom',
label: 'Hippodrome',
cell: (r) =>
r.hippodrome ? `${r.hippodrome.nom} (${r.hippodrome.ville}, ${r.hippodrome.pays})` : '—',
},
{
key: 'statut',
label: 'Statut',
cell: (r) => {
const colorMap: Record<string, string> = {
PLANIFIEE: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
EN_COURS: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
TERMINEE: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300',
ANNULEE: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300',
};
const labelMap: Record<string, string> = {
PLANIFIEE: 'Planifiée',
EN_COURS: 'En cours',
TERMINEE: 'Terminée',
ANNULEE: 'Annulée',
};
return `<span class="px-2 py-1 rounded-full text-xs font-semibold ${colorMap[r.statut]}">${
labelMap[r.statut]
}</span>`;
},
},
{
key: 'totalCourses',
label: 'Courses',
cell: (r) => (r.totalCourses ? r.totalCourses.toString() : '—'),
},
{
key: 'createdAt',
label: 'Créée le',
cell: (r) =>
new Date(r.createdAt).toLocaleDateString('fr-FR', {
day: '2-digit',
month: 'short',
year: 'numeric',
}),
},
{
key: 'updatedAt',
label: 'Modifiée le',
cell: (r) =>
new Date(r.updatedAt).toLocaleDateString('fr-FR', {
day: '2-digit',
month: 'short',
year: 'numeric',
}),
},
];
constructor(private api: ReunionService) {
// Effect will run only when one of these dependencies change
effect(() => {
const params = {
page: this.page(),
perPage: this.perPage(),
search: this.search(),
sortKey: this.sort().key,
sortDir: this.sort().dir,
};
untracked(() => this.fetch(params)); // avoids recursive dependency
});
}
private fetch(params: {
page: number;
perPage: number;
search: string;
sortKey: string;
sortDir: SortDir;
}) {
this.loading.set(true);
this.api.list(params).subscribe({
next: (res) => {
this.rows.set(res.data);
this.total.set(res.meta.total);
const meta = res.meta;
this.upcomingReunions.set(res.meta['upcomingReunions'] ?? 0);
this.pastReunions.set(res.meta['pastReunions'] ?? 0);
this.uniqueHippodromes.set(res.meta['uniqueHippodromes'] ?? 0);
this.loading.set(false);
},
error: () => {
this.rows.set([]);
this.total.set(0);
this.upcomingReunions.set(0);
this.pastReunions.set(0);
this.uniqueHippodromes.set(0);
this.loading.set(false);
},
});
}
// === UI interactions ===
onSearch(q: string) {
this.search.set(q);
this.page.set(1); // reset pagination on new search
}
openCreate() {
this.modalTitle.set('Nouvelle réunion');
this.editingItem.set(null);
queueMicrotask(() => this.modalOpen.set(true));
}
openEdit(row: ReunionType) {
this.modalTitle.set('Modifier la réunion');
this.editingItem.set(row);
queueMicrotask(() => this.modalOpen.set(true));
}
closeModal() {
this.modalOpen.set(false);
}
submitChildForm() {
this.formComp?.onSubmit();
}
onFormSave(payload: Partial<ReunionType>) {
const current = this.editingItem();
const req$ = current?.id
? this.api.update(current.id, payload)
: this.api.create(payload as Omit<ReunionType, 'id'>);
req$.subscribe(() => {
this.closeModal();
// Reset editing item to null to clear the form
this.editingItem.set(null);
// refetch current page
this.fetch({
page: this.page(),
perPage: this.perPage(),
search: this.search(),
sortKey: this.sort().key,
sortDir: this.sort().dir,
});
});
}
remove(row: ReunionType) {
if (!confirm(`Supprimer la réunion « ${row.nom} » ?`)) return;
this.api.delete(row.id).subscribe(() =>
this.fetch({
page: this.page(),
perPage: this.perPage(),
search: this.search(),
sortKey: this.sort().key,
sortDir: this.sort().dir,
})
);
}
}

View File

@@ -0,0 +1,111 @@
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-semibold">Rôles & Permissions</h1>
<p class="text-sm text-muted-foreground">Gérez les rôles et assignez les permissions</p>
</div>
<z-button (click)="openCreate()">Nouveau rôle</z-button>
</div>
<app-search-bar placeholder="Rechercher un rôle" (search)="onSearch($event)" />
<div class="p-0 overflow-hidden">
<app-data-table
[data]="rows()"
[columns]="cols"
[sort]="sort()"
(sortChange)="sort.set($event)"
>
<ng-template #rowActions let-row>
<div class="flex gap-3 items-center justify-center">
<button z-button zType="ghost" (click)="openEdit(row)"><i class="icon-pen"></i></button>
<button z-button zType="destructive" (click)="remove(row)">
<i class="icon-trash"></i>
</button>
</div>
</ng-template>
</app-data-table>
</div>
<!-- Permissions listing -->
<div class="mt-8 space-y-3">
<div class="flex items-center justify-between">
<div>
<h2 class="text-xl font-semibold">Permissions</h2>
<p class="text-sm text-muted-foreground">Gérez la liste des permissions disponibles</p>
</div>
<z-button zType="secondary" (click)="openCreatePermission()">Nouvelle permission</z-button>
</div>
<div class="border rounded-md overflow-hidden">
<table class="min-w-full text-sm">
<thead class="bg-muted">
<tr>
<th class="px-4 py-2 text-left">Code</th>
<th class="px-4 py-2 text-left">Description</th>
<th class="px-4 py-2 text-right">Actions</th>
</tr>
</thead>
<tbody>
@for (p of permissions(); track p.id) {
<tr class="border-t">
<td class="px-4 py-2 font-mono text-xs">{{ p.name }}</td>
<td class="px-4 py-2">{{ p.description || '—' }}</td>
<td class="px-4 py-2">
<div class="flex justify-end gap-2">
<button z-button zType="ghost" (click)="openEditPermission(p)">
<i class="icon-pen"></i>
</button>
<button z-button zType="destructive" (click)="removePermission(p)">
<i class="icon-trash"></i>
</button>
</div>
</td>
</tr>
} @if (!permissions().length) {
<tr>
<td colspan="3" class="px-4 py-4 text-center text-muted-foreground">
Aucune permission définie.
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
<app-modal [open]="modalOpen()" [title]="modalTitle()" (close)="closeModal()" size="xl">
@if (modalOpen()) {
<app-role-form
[value]="editingItem() ?? undefined"
[permissions]="permissions()"
(save)="onFormSave($event)"
(cancel)="closeModal()"
/>
}
<div modal-actions class="flex justify-end gap-2">
<z-button zType="destructive" (click)="closeModal()">Annuler</z-button>
<z-button (click)="submitChildForm()">Enregistrer</z-button>
</div>
</app-modal>
<!-- Permission modal -->
<app-modal
[open]="permissionModalOpen()"
[title]="permissionModalTitle()"
(close)="closePermissionModal()"
size="lg"
>
@if (permissionModalOpen()) {
<app-permission-form
[value]="editingPermission() ?? undefined"
(save)="onPermissionFormSave($event)"
(cancel)="closePermissionModal()"
/>
}
<div modal-actions class="flex justify-end gap-2">
<z-button zType="destructive" (click)="closePermissionModal()">Annuler</z-button>
<z-button (click)="submitPermissionForm()">Enregistrer</z-button>
</div>
</app-modal>
</div>

View File

@@ -0,0 +1,241 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
ViewChild,
effect,
signal,
untracked,
} from '@angular/core';
import { DataTable, SortState, TableColumn } from '@shared/components/data-table/data-table';
import { SearchBar } from '@shared/components/search-bar/search-bar';
import { Modal } from '@shared/components/modal/modal';
import { ZardButtonComponent } from '@shared/components/button/button.component';
import { Permission, Role } from 'src/app/core/interfaces/role';
import { RoleService } from 'src/app/core/services/role';
import { SortDir } from '@shared/paging/paging';
import { RoleForm } from '@shared/forms/role-form/role-form';
import { PermissionForm } from '@shared/forms/permission-form/permission-form';
import { toast } from 'ngx-sonner';
@Component({
standalone: true,
selector: 'app-roles',
templateUrl: './roles.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
DataTable,
SearchBar,
Modal,
ZardButtonComponent,
RoleForm,
PermissionForm,
],
})
export class RolesPage {
rows = signal<Role[]>([]);
total = signal(0);
loading = signal(false);
permissions = signal<Permission[]>([]);
page = signal(1);
perPage = signal(10);
search = signal('');
sort = signal<SortState>({ key: 'name', dir: 'asc' });
modalOpen = signal(false);
modalTitle = signal('Nouveau rôle');
editingItem = signal<Role | null>(null);
permissionModalOpen = signal(false);
permissionModalTitle = signal('Nouvelle permission');
editingPermission = signal<Permission | null>(null);
@ViewChild(RoleForm) formComp?: RoleForm;
@ViewChild(PermissionForm) permFormComp?: PermissionForm;
cols: TableColumn<Role>[] = [
{ key: 'name', label: 'Rôle', sortable: true },
{ key: 'description', label: 'Description', sortable: true },
{ key: 'permissions', label: 'Permissions', cell: (r) => r.permissions.length },
];
constructor(public api: RoleService) {
this.api.allPermissions().subscribe((list) => this.permissions.set(list));
effect(() => {
const params = {
page: this.page(),
perPage: this.perPage(),
search: this.search(),
sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir,
};
untracked(() => this.fetch(params));
});
}
private fetch(params: {
page: number;
perPage: number;
search: string;
sortKey: string;
sortDir: SortDir;
}) {
this.loading.set(true);
this.api.list(params).subscribe({
next: (res) => {
this.rows.set(res.data);
this.total.set(res.meta.total);
this.loading.set(false);
},
error: () => {
this.rows.set([]);
this.total.set(0);
this.loading.set(false);
},
});
}
onSearch(q: string) {
this.search.set(q);
this.page.set(1);
}
openCreate() {
this.modalTitle.set('Nouveau rôle');
this.editingItem.set(null);
queueMicrotask(() => this.modalOpen.set(true));
}
openEdit(row: Role) {
this.modalTitle.set('Modifier le rôle');
this.editingItem.set(row);
queueMicrotask(() => this.modalOpen.set(true));
}
closeModal() {
this.modalOpen.set(false);
}
submitChildForm() {
this.formComp?.onSubmit();
}
submitPermissionForm() {
this.permFormComp?.onSubmit();
}
onFormSave(payload: Partial<Role>) {
const current = this.editingItem();
const req$ = current?.id
? this.api.update(current.id, payload)
: this.api.create(payload as Omit<Role, 'id'>);
req$.subscribe({
next: (role) => {
this.closeModal();
toast.success(
current?.id
? `Le rôle « ${role?.name ?? ''} » a été mis à jour avec succès`
: `Le rôle « ${role?.name ?? ''} » a été créé avec succès`
);
this.fetch({
page: this.page(),
perPage: this.perPage(),
search: this.search(),
sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir,
});
},
error: () => {
toast.error(
current?.id
? 'Erreur lors de la mise à jour du rôle'
: 'Erreur lors de la création du rôle',
{ duration: 5000 }
);
},
});
}
remove(row: Role) {
if (!confirm(`Supprimer le rôle « ${row.name} » ?`)) return;
this.api.delete(row.id).subscribe((result) => {
if (result.success) {
toast.success(`Le rôle « ${row.name} » a été supprimé avec succès`);
this.fetch({
page: this.page(),
perPage: this.perPage(),
search: this.search(),
sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir,
});
} else {
toast.error(result.error || 'Erreur lors de la suppression du rôle', {
duration: 5000,
});
}
});
}
// ------- Permissions CRUD -------
openCreatePermission() {
this.permissionModalTitle.set('Nouvelle permission');
this.editingPermission.set(null);
this.permissionModalOpen.set(true);
}
openEditPermission(p: Permission) {
this.permissionModalTitle.set('Modifier la permission');
this.editingPermission.set(p);
this.permissionModalOpen.set(true);
}
closePermissionModal() {
this.permissionModalOpen.set(false);
this.editingPermission.set(null);
}
onPermissionFormSave(payload: Permission) {
const current = this.editingPermission();
const isEdit = !!current?.id;
const req$ = isEdit
? this.api.updatePermission(current.id, {
name: payload.name,
description: payload.description,
})
: this.api.createPermission({ name: payload.name, description: payload.description });
req$.subscribe({
next: (perm) => {
this.closePermissionModal();
const permName = perm?.name || payload.name;
toast.success(
isEdit
? `La permission « ${permName} » a été mise à jour avec succès`
: `La permission « ${permName} » a été créée avec succès`
);
this.api.allPermissions().subscribe((list) => this.permissions.set(list));
},
error: (err) => {
const permName = payload.name;
toast.error(
isEdit
? `Erreur lors de la mise à jour de la permission « ${permName} »`
: `Erreur lors de la création de la permission « ${permName} »`,
{ duration: 5000 }
);
},
});
}
removePermission(p: Permission) {
if (!confirm(`Supprimer la permission « ${p.name} » ?`)) return;
this.api.deletePermission(p.id).subscribe((result) => {
if (result.success) {
toast.success(`La permission « ${p.name} » a été supprimée avec succès`);
this.api.allPermissions().subscribe((list) => this.permissions.set(list));
} else {
toast.error(result.error || 'Erreur lors de la suppression de la permission', {
duration: 5000,
});
}
});
}
}

Some files were not shown because too many files have changed in this diff Show More