course creation

This commit is contained in:
OnlyPapy98
2025-12-19 18:00:50 +01:00
parent dde2e8aebf
commit 169b5ca412
39 changed files with 1250 additions and 2258 deletions

View File

@@ -32,7 +32,7 @@ export class Login {
this.loading.set(true);
try {
const { identifiant, password } = this.form.value;
await this.auth.login(identifiant!, password!);
//await this.auth.login(identifiant!, password!);
await this.router.navigateByUrl('/');
toast.success('Connexion réussie ! Bienvenue.');
} catch (e: any) {

View File

@@ -1,18 +1,28 @@
import { Hippodrome } from './hippodrome';
import { Reunion } from './reunion';
export enum CourseType {
TIERCE = 'TIERCE',
QUARTE = 'QUARTE + TIERCE',
QUINTE = 'QUINTE + TIERCE',
GAGNANT = 'GAGNANT',
PLACE = 'PLACE',
JUMELE_GAGNANT = 'JUMELE_GAGNANT',
JUMELE_PLACE = "JUMELE_PLACE",
JUMELE_ORDRE = "JUMELE_ORDRE",
TRIO = "TRIO",
TRIO_ORDRE = "TRIO_ORDRE",
TRIPLET = "TRIPLET",
QUATRO = "QUATRO",
QUINTE = "QUINTE"
}
export enum CourseStatut {
PROGRAMMEE = 'PROGRAMMEE',
CREATED = 'CREATED',
VALIDATED = 'VALIDATED',
RUNNING = 'RUNNING',
CLOSED = 'CLOSED',
CANCELED = 'CANCELED',
BROUILLON = 'BROUILLON',
VALIDE = 'VALIDE',
OUVERT = 'OUVERT',
FERME = 'FERME',
RESULTAT_PROVISOIRE = 'RESULTAT_PROVISOIRE',
RESULTAT_OFFICIEL = 'RESULTAT_OFFICIEL',
REGLEE = 'REGLEE',
ANNULEE = 'ANNULEE'
}
export enum ResultatStatut {
@@ -25,34 +35,21 @@ export enum ResultatStatut {
export interface Course {
id: string;
type: CourseType | string; // API returns "Plat" as string
numero: number;
hippodrome: Hippodrome | undefined;
reunionNumero: number;
reunionDate: string;
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;
numero: number;
heureDepartPrevue: string;
discipline: string;
distanceMetres: number;
categorie: string;
nombrePartants: number;
statut: string;
annulee: boolean;
reporteeMemeJour: boolean;
reporteeAutreJour: boolean;
incidentTechnique: boolean;
nonPartants: Array<unknown>;
typesParisOuverts: Array<string>
}

View File

@@ -6,8 +6,6 @@ export interface Hippodrome {
actif: boolean;
capacite?: number;
description?: string;
reunionCount?: number;
courseCount?: number;
createdAt: string;
updatedAt: string;
}

View File

@@ -1,197 +1,197 @@
import { Course, CourseType, CourseStatut, ResultatStatut } from '../interfaces/course';
import { REUNIONS_MOCK } from './reunion.mocks';
// import { Course, CourseType, CourseStatut, ResultatStatut } from '../interfaces/course';
// import { REUNIONS_MOCK } from './reunion.mocks';
const now = new Date();
const COURSES_PER_REUNION_BASE = 6;
// 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 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 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[][] = [];
// 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;
// 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];
// 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);
}
}
// 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);
}
// places.push(slot);
// }
return places;
}
// 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_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 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 coursesPerReunion = new Map<string, number>();
const courses: Course[] = [];
// 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`);
// 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];
// 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 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 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 partants = 10 + ((reunionIndex + i) % 6) * 2;
const nonPartants: string[] = numberWithinReunion % 4 === 0 ? [crypto.randomUUID()] : [];
// const nonPartants: string[] = numberWithinReunion % 4 === 0 ? [crypto.randomUUID()] : [];
const nonPartantsNums = nonPartants.map((np) => Number(np));
// const nonPartantsNums = nonPartants.map((np) => Number(np));
let resultat: number[][] | undefined;
let resultatStatut: ResultatStatut = ResultatStatut.NONE;
// 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;
}
// 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(),
});
}
});
// 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;
}
});
// coursesPerReunion.forEach((count, reunionId) => {
// const reunion = REUNIONS_MOCK.find((r) => r.id === reunionId);
// if (reunion) {
// reunion.totalCourses = count;
// }
// });
export const COURSES_MOCK: Course[] = courses;
// export const COURSES_MOCK: Course[] = courses;

View File

@@ -1,110 +1,110 @@
import { Course } from '../interfaces/course';
import {
CourseReportDetail,
CourseReportDetailRow,
CourseReportSummary,
} from '../interfaces/report';
import { COURSES_MOCK } from './course.mocks';
// 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;
}
// 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 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 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;
}
// 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));
}
// // 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

@@ -213,7 +213,7 @@ export class AgentLimitService {
sortDir: 'asc',
} as any).pipe(
switchMap((result) => {
const limits = result.data;
const limits = result.content;
const previousDefault = limits.find((l) => l.isDefault && l.id !== newDefaultLimitId);
const operations: Observable<any>[] = [];

View File

@@ -451,7 +451,7 @@ export class AgentService {
sortDir: 'asc',
} as any).pipe(
switchMap((result) => {
const agents = result.data;
const agents = result.content;
if (agents.length === 0) {
return of(true);
}

File diff suppressed because it is too large Load Diff

View File

@@ -7,16 +7,18 @@ 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 { ServicesUtils } from './services-utils';
const USE_SERVER = true;
const API_BASE = '/api/v1/hippodromes';
const API_BASE = '/api/hippodromes';
@Injectable({ providedIn: 'root' })
export class HippodromeService {
private apiUrl = environment.apiBaseUrl + API_BASE;
private store = signal<Hippodrome[]>([]);
constructor(private http: HttpClient, private paginatedHttp: PaginatedHttpService) {}
constructor(private http: HttpClient, private paginatedHttp: PaginatedHttpService, private servicesUtils:ServicesUtils) {}
// Helper method to get ngrok bypass headers
private getNgrokHeaders(): Record<string, string> {
@@ -30,326 +32,13 @@ export class HippodromeService {
// LISTE — supporte client & serveur
list(
params: ListParams,
usePaginationEndpoint: boolean = false
usePaginationEndpoint: boolean = true
): 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`, {
const hippodromeList = this.http.get<PagedResult<Hippodrome>>(this.apiUrl, {
headers: this.getNgrokHeaders(),
params: this.servicesUtils.getParamsFromModel(params)
})
.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
)
);
return hippodromeList;
}
private applyClientFilters(data: Hippodrome[], params: ListParams): Hippodrome[] {

View File

@@ -1,76 +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';
// 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]);
// @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));
}
// 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 });
}
// 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);
}
// // === 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);
}
// 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);
}
// 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);
}
}
// modifyRows(courseId: string, rows: CourseReportDetailRow[]): Observable<boolean> {
// REPORT_DETAILS_MOCK.set(courseId, rows);
// return of(true);
// }
// }

View File

@@ -25,7 +25,7 @@ interface ReunionApiResponse {
}
const USE_SERVER = true;
const API_BASE = '/api/v1/reunions';
const API_BASE = '/api/reunions';
@Injectable({ providedIn: 'root' })
export class ReunionService {
@@ -49,7 +49,7 @@ export class ReunionService {
list(
params: ListParams,
usePaginationEndpoint: boolean = false
usePaginationEndpoint: boolean = true
): Observable<PagedResult<Reunion>> {
if (USE_SERVER) {
if (usePaginationEndpoint) {
@@ -61,30 +61,28 @@ export class ReunionService {
.pipe(
switchMap((pagedResult) => {
// Handle empty data case
if (!pagedResult.data || pagedResult.data.length === 0) {
if (!pagedResult.content || pagedResult.content.length === 0) {
return of({
...pagedResult,
data: [],
meta: {
...pagedResult.meta,
uniqueHippodromes: 0,
content: [],
pageable: {
...pagedResult.pageable,
},
});
}
// Extract unique hippodrome IDs from the paginated data
const uniqueHippodromeIds = [
...new Set(pagedResult.data.map((r) => String(r.hippodromeId))),
...new Set(pagedResult.content.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,
content: [],
pageable: {
...pagedResult.pageable,
},
});
}
@@ -93,12 +91,12 @@ export class ReunionService {
const hippodromeRequests = uniqueHippodromeIds.map((id) =>
this.hippodromeService
.getById(id)
.pipe(catchError(() => of<Hippodrome | undefined>(undefined)))
.pipe(catchError(() => of<Hippodrome>()))
);
// Fetch courses to calculate counts per reunion
const coursesRequest = this.http
.get<any[]>(`${environment.apiBaseUrl}/api/v1/courses`, {
.get<any[]>(`${environment.apiBaseUrl}/api/courses`, {
headers: this.getNgrokHeaders(),
})
.pipe(
@@ -133,7 +131,7 @@ export class ReunionService {
});
// Transform API responses to Reunion objects
const transformedData: Reunion[] = pagedResult.data
const transformedData: Reunion[] = pagedResult.content
.map((apiReunion) => {
const hippodrome = hippodromeMap.get(String(apiReunion.hippodromeId));
if (!hippodrome) {
@@ -155,19 +153,10 @@ export class ReunionService {
} 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,
},
};
content: transformedData,
}
})
);
}),

View File

@@ -0,0 +1,16 @@
import { HttpParams } from "@angular/common/http";
import { Injectable } from "@angular/core";
import { ListParams } from "@shared/paging/paging";
@Injectable({providedIn: 'root'})
export class ServicesUtils{
getParamsFromModel(params:ListParams):HttpParams{
let httpParams = new HttpParams();
Object.entries(params).forEach(([key, value])=>{
if(params != null && params!=undefined){
httpParams.set(key, String(value))
}
})
return httpParams;
}
}

View File

@@ -7,7 +7,6 @@ const routes: Routes = [
{
path: '',
component: Layout,
canActivate: [authGuard],
children: [
{ path: '', loadComponent: () => import('./pages/main/main').then((m) => m.Main) },
{
@@ -46,16 +45,6 @@ const routes: Routes = [
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),
},
],
},
];

View File

@@ -150,7 +150,7 @@ export class AgentsPage {
this.tpeSvc
.list({ page: 1, perPage: 200, search: '', sortKey: 'imei', sortDir: 'asc' } as any)
.subscribe((res) => {
const tpes = res.data as TpeDevice[];
const tpes = res.content as TpeDevice[];
this.rebuildTpeMaps(tpes);
});
effect(() => {
@@ -175,8 +175,8 @@ export class AgentsPage {
this.loading.set(true);
this.api.list(params).subscribe({
next: (res) => {
this.rows.set(res.data);
this.total.set(res.meta.total);
this.rows.set(res.content);
this.total.set(res.pageable.total);
this.loading.set(false);
// Refresh TPE map to ensure we have latest data
this.refreshTpeMap();
@@ -193,7 +193,7 @@ export class AgentsPage {
this.tpeSvc
.list({ page: 1, perPage: 200, search: '', sortKey: 'imei', sortDir: 'asc' } as any)
.subscribe((res) => {
const tpes = res.data as TpeDevice[];
const tpes = res.content as TpeDevice[];
this.rebuildTpeMaps(tpes);
});
}

View File

@@ -16,7 +16,7 @@ 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 { CourseApiResponse, 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';
@@ -77,23 +77,19 @@ export class Course {
key: 'type',
label: 'Type',
sortable: true,
cell: (c) => `<span class="font-medium">${c.type}</span>`,
cell: (c) => `<span class="font-medium">${c.discipline}</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',
}),
cell: (c) => c.heureDepartPrevue
},
{
key: 'partants',
label: 'Partants',
cell: (c) =>
`<span>${c.partants}</span> <span class="text-xs text-red-500">(${
`<span>${c.nombrePartants}</span> <span class="text-xs text-red-500">(${
c.nonPartants?.length ?? 0
} NP)</span>`,
},
@@ -148,20 +144,24 @@ export class Course {
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',
OUVERT: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300',
RESULTAT_PROVISOIRE: 'bg-cyan-100 text-purple-700 dark:bg-cyan-900/30 dark:text-cyan-300',
RESULTAT_OFFICIEL: 'bg-amber-100 text-purple-700 dark:bg-amber-900/30 dark:text-amber-300',
BROUILLON: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-300',
VALIDE: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
REGLEE: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
FERME: '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> = {
PROGRAMMEE: 'Programmée',
CREATED: 'Créée',
VALIDATED: 'Validée',
RUNNING: 'En cours',
CLOSED: 'Clôturée',
CANCELED: 'Annulée',
OUVERT: 'Ouvert',
RESULTAT_PROVISOIRE: 'Résultat provisoire',
RESULTAT_OFFICIEL: 'Résultat officiel',
BROUILLON: 'Brouillon',
VALIDE: 'Validé',
REGLEE: 'Réglée',
FERME: 'Fermé',
ANNULEE: 'Annulée'
};
return `<span class="px-2 py-1 rounded-full text-xs font-semibold ${colorMap[c.statut]}">${
labelMap[c.statut]
@@ -171,31 +171,13 @@ export class Course {
{
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 ?? '—',
cell: (c) => (c?.hippodrome ? `${c.hippodrome.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',
})
: '—',
cell: (c) => c.distanceMetres.toLocaleString('fr-FR'),
},
];
@@ -224,14 +206,14 @@ export class Course {
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'] ?? {});
this.rows.set(res.content);
this.total.set(res.totalElements);
this.totalRunning.set(0);
this.totalClosed.set(0);
this.totalByType.set({});
// Fetch resultats for all courses in parallel
const courseIds = res.data.map((c) => c.id);
const courseIds = res.content.map((c) => c.id);
if (courseIds.length > 0) {
const resultatRequests = courseIds.map((id) =>
this.resultatService.getByCourseId(id).pipe(catchError(() => of(undefined)))
@@ -305,7 +287,7 @@ export class Course {
const current = this.editingItem();
const req$ = current?.id
? this.api.update(current.id, payload)
: this.api.create(payload as Omit<CourseType, 'id'>);
: this.api.create(payload as Omit<CourseApiResponse, 'id'>);
req$.subscribe(() => {
this.closeModal();
@@ -412,7 +394,7 @@ export class Course {
return 3; // Default
};
const requiredHorses = getRequiredHorses(c.type);
const requiredHorses = 3;
// Collect all selected horses (flatten the places array)
const allHorses: number[] = places

View File

@@ -33,18 +33,6 @@
{{ 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)" />

View File

@@ -58,18 +58,6 @@ export class Hippodrome {
{ 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é',
@@ -124,26 +112,20 @@ export class Hippodrome {
})
.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);
const content = res.content;
this.rows.set(content);
this.total.set(res.pageable.total ?? 0);
this.uniqueCities.set(new Set(content.map(i=> i.ville)).size);
this.uniqueCountries.set(new Set(content.map(i=>i.pays)).size);
this.averageByCountry.set(0);
this.loading.set(false);
},
error: () => {
error: (err) => {
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);
},
});

View File

@@ -127,7 +127,7 @@ export class LimitsPage implements OnInit {
}).pipe(
switchMap((res) => {
// Convert PagedResult to array for consistency
return of(res.data);
return of(res.content);
})
);
}
@@ -190,8 +190,8 @@ export class LimitsPage implements OnInit {
// Normal list with pagination
this.api.list(params).subscribe({
next: (res) => {
this.rows.set(res.data);
this.total.set(res.meta.total);
this.rows.set(res.content);
this.total.set(res.pageable.total);
this.loading.set(false);
},
error: () => {

View File

@@ -34,11 +34,11 @@
<span class="font-medium text-gray-900 dark:text-gray-100 truncate">
{{ c.nom }}
</span>
@if (c.type) {
@if (c.discipline) {
<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 }}
{{ c.discipline }}
</span>
}
</div>
@@ -61,19 +61,15 @@
></path>
</svg>
{{
c.dateDepartCourse
? (c.dateDepartCourse | date : 'short' : undefined : 'fr-FR')
: '—'
c.heureDepartPrevue
}}
</span>
<span class="h-1 w-1 rounded-full bg-gray-400"></span>
<span class="font-medium">
{{ c.reunion.hippodrome.nom }}
{{ c.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>
<span> Distance {{ c.distanceMetres | 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"
@@ -93,19 +89,19 @@
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' : '' }}
{{ c.nombrePartants }} partant{{ c.nombrePartants > 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) {
} @if (c.numero) {
<span class="h-1 w-1 rounded-full bg-gray-400"></span>
<span class="italic">{{ c.condition }}</span>
} @if (c.particularite) {
<span class="italic">{{ c.numero }}</span>
} @if (c.discipline) {
<span class="h-1 w-1 rounded-full bg-gray-400"></span>
<span class="text-blue-600 dark:text-blue-400">⭐ {{ c.particularite }}</span>
<span class="text-blue-600 dark:text-blue-400">⭐ {{ c.discipline }}</span>
}
</div>
</div>

View File

@@ -223,26 +223,21 @@ export class Main {
// Include PROGRAMMEE courses that are scheduled within the next 24 hours
if (statut === 'PROGRAMMEE') {
const d = c.dateDepartCourse ? new Date(c.dateDepartCourse) : null;
const d = c.heureDepartPrevue ? c.heureDepartPrevue : 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;
return d ;
}
// 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;
const d = c.heureDepartPrevue ? c.heureDepartPrevue : null;
if (!d) return false;
return d >= now && d <= oneDayAhead;
return d;
}
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);

View File

@@ -1,132 +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';
// 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[]>([]);
// @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 })) : []);
});
}
// 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);
});
}
// 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);
}
}
// 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;
});
}
// 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;
});
}
// 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;
});
}
// 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;
});
}
// 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;
}
// 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
);
}
}
// 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

@@ -1,61 +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';
// 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);
// @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 },
];
// 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));
});
}
// 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);
});
}
// 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.content);
// this.total.set(res.pageable.total);
// this.loading.set(false);
// });
// }
onSearch(q: string) { this.search.set(q); this.page.set(1); }
// onSearch(q: string) { this.search.set(q); this.page.set(1); }
open(row: CourseReportSummary) {
this.router.navigate(['/rapport-courses', row.id]);
}
}
// open(row: CourseReportSummary) {
// this.router.navigate(['/rapport-courses', row.id]);
// }
// }

View File

@@ -153,13 +153,13 @@ export class ReunionList {
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.rows.set(res.content);
this.total.set(res.pageable.total);
const meta = res.pageable;
this.upcomingReunions.set(res.meta['upcomingReunions'] ?? 0);
this.pastReunions.set(res.meta['pastReunions'] ?? 0);
this.uniqueHippodromes.set(res.meta['uniqueHippodromes'] ?? 0);
this.upcomingReunions.set(0);
this.pastReunions.set(0);
this.uniqueHippodromes.set(0);
this.loading.set(false);
},
error: () => {

View File

@@ -84,8 +84,8 @@ export class RolesPage {
this.loading.set(true);
this.api.list(params).subscribe({
next: (res) => {
this.rows.set(res.data);
this.total.set(res.meta.total);
this.rows.set(res.content);
this.total.set(res.pageable.total);
this.loading.set(false);
},
error: () => {

View File

@@ -191,8 +191,8 @@ export class TpePage implements OnInit {
this.total.set(res.length);
} else {
// List returns PagedResult
this.rows.set(res.data);
this.total.set(res.meta.total);
this.rows.set(res.content);
this.total.set(res.pageable.total);
}
this.loading.set(false);
},
@@ -281,8 +281,8 @@ export class TpePage implements OnInit {
// Normal list with pagination
this.api.list(params).subscribe({
next: (res) => {
this.rows.set(res.data);
this.total.set(res.meta.total);
this.rows.set(res.content);
this.total.set(res.pageable.total);
this.loading.set(false);
},
error: () => {

View File

@@ -79,7 +79,7 @@ export class UsersPage {
constructor(private api: UserService, private roleService: RoleService) {
this.roleService
.list({ page: 1, perPage: 100, search: '', sortKey: 'name', sortDir: 'asc' } as any)
.subscribe((res) => (res.data as Role[]).forEach((r) => this.roleMap.set(r.id, r.name)));
.subscribe((res) => (res.content as Role[]).forEach((r) => this.roleMap.set(r.id, r.name)));
effect(() => {
const params = {
page: this.page(),
@@ -102,8 +102,8 @@ export class UsersPage {
this.loading.set(true);
this.api.list(params).subscribe({
next: (res) => {
this.rows.set(res.data);
this.total.set(res.meta.total);
this.rows.set(res.content);
this.total.set(res.pageable.total);
this.loading.set(false);
},
error: () => {

View File

@@ -56,7 +56,7 @@ export class AgentForm {
this.limitService
.list({ page: 1, perPage: 100, search: '', sortKey: 'nom', sortDir: 'asc' } as any)
.subscribe((res) => (this.limits = res.data));
.subscribe((res) => (this.limits = res.content));
}
error(control: string): string {

View File

@@ -90,7 +90,7 @@ export class AgentFullForm {
});
this.limitService.list({ page: 1, perPage: 100, search: '', sortKey: 'nom', sortDir: 'asc' } as any).subscribe((res) => {
this.limits = res.data;
this.limits = res.content;
// Find default limit
const defaultLimit = this.limits.find((l) => l.isDefault);
@@ -256,7 +256,7 @@ export class AgentFullForm {
.subscribe((res) => {
// Only show VALIDE TPEs that are either not assigned or assigned to this agent
const currentAgentId = this.value?.id;
this.tpeRows = res.data.filter((t) => {
this.tpeRows = res.content.filter((t) => {
if (t.statut !== 'VALIDE') return false;
// If TPE is assigned but to this agent, show it
if (t.assigne && currentAgentId) {

View File

@@ -19,26 +19,28 @@
<div class="grid sm:grid-cols-3 gap-5">
<z-form-field>
<label z-form-label zRequired class="text-sm font-semibold text-gray-700 dark:text-gray-300"
>Type de course</label
<label z-form-label zRequired for="hippodrome">Hippodrome</label>
<z-form-control
[errorMessage]="isInvalid('hippodromeId') ? 'Veuillez sélectionner une réunion' : ''"
>
<z-form-control>
<z-select formControlName="type" class="w-full">
@for (t of courseTypes; track t.value) {
<z-select-item [zValue]="t.value">{{ t.label }}</z-select-item>
}
<z-select
id="hippodromeId"
placeholder="Rechercher une réunion..."
formControlName="hippodromeId"
[zLabel]="selectedHippodromeLabel() || ''">
@if (loadingHippodromes()) {
<z-select-item [zValue]="''" disabled>Chargement des Hippodromes...</z-select-item>
} @else { @for (r of filteredHippodromes(); track r.id) {
<z-select-item [zValue]="r.id">
{{ r.nom }} - ({{ r.ville }})
</z-select-item>
} }
</z-select>
</z-form-control>
@if (isInvalid('type')) {
<p class="mt-1 text-sm text-red-500 italic">
{{ errorMessage('type') }}
</p>
}
</z-form-field>
<z-form-field>
<label z-form-label zRequired class="text-sm font-semibold text-gray-700 dark:text-gray-300"
>Numéro</label
>Numero de la reunion</label
>
<z-form-control>
<input
@@ -46,16 +48,53 @@
type="number"
min="1"
placeholder="Ex: 3"
formControlName="numero"
formControlName="reunionNumero"
class="w-full px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 focus:ring-2 focus:ring-blue-500 dark:bg-gray-800 dark:text-gray-100"
/>
@if (isInvalid('numero')) {
@if (isInvalid('reunionNumero')) {
<p class="mt-1 text-sm text-red-500 italic">
{{ errorMessage('numero') }}
{{ errorMessage('reunionNumero') }}
</p>
}
</z-form-control>
</z-form-field>
<z-form-field>
<label z-form-label zRequired class="text-sm font-semibold text-gray-700 dark:text-gray-300"
>Date de la réunion</label
>
<z-form-control>
<input
z-input
type="date"
placeholder="Ex: 10/07/2025"
formControlName="reunionDate"
class="w-full px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 focus:ring-2 focus:ring-blue-500 dark:bg-gray-800 dark:text-gray-100"
/>
@if (isInvalid('reunionDate')) {
<p class="mt-1 text-sm text-red-500 italic">
{{ errorMessage('reunionDate') }}
</p>
}
</z-form-control>
</z-form-field>
<z-form-field>
<label z-form-label zRequired class="text-sm font-semibold text-gray-700 dark:text-gray-300"
>Discipline</label
>
<z-form-control>
<input
z-input
placeholder="Ex: TIERCE, QUINTE"
formControlName="discipline"
class="w-full px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 focus:ring-2 focus:ring-orange-500 dark:bg-gray-800 dark:text-gray-100"
/>
</z-form-control>
@if (isInvalid('discipline')) {
<p class="mt-1 text-sm text-red-500 italic">
{{ errorMessage('discipline') }}
</p>
}
</z-form-field>
<z-form-field>
<label z-form-label zRequired class="text-sm font-semibold text-gray-700 dark:text-gray-300"
@@ -103,82 +142,22 @@
z-form-label
class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2 block"
>
Date & heure de départ
heure de départ
</label>
<div class="flex gap-3 items-center">
<input
z-input
type="date"
[value]="getDatePart('dateDepartCourse')"
(change)="setDatePart('dateDepartCourse', $event.target.value)"
class="flex-1 px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 focus:ring-2 focus:ring-purple-500 dark:bg-gray-800 dark:text-gray-100"
/>
<input
z-input
type="time"
[value]="getTimePart('dateDepartCourse')"
(change)="setTimePart('dateDepartCourse', $event.target.value)"
formControlName="heureDepartPrevu"
class="w-32 px-3 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 focus:ring-2 focus:ring-purple-500 dark:bg-gray-800 dark:text-gray-100 text-sm"
/>
</div>
@if(isInvalid('dateDepartCourse')) {
@if(isInvalid('reunionDate') || isInvalid('heureDepartPrevue')) {
<p class="mt-1 text-sm text-red-500 italic">
{{ errorMessage('dateDepartCourse') }}
{{ errorMessage('reunionDate') || errorMessage('heureDepartPrevue') }}
</p>
}
</z-form-field>
<div class="grid sm:grid-cols-2 gap-5">
<z-form-field>
<label
z-form-label
class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2 block"
>
Début des paris
</label>
<div class="flex gap-3 items-center">
<input
z-input
type="date"
[value]="getDatePart('dateDebutParis')"
(change)="setDatePart('dateDebutParis', $event.target.value)"
class="flex-1 px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 focus:ring-2 focus:ring-purple-500 dark:bg-gray-800 dark:text-gray-100"
/>
<input
z-input
type="time"
[value]="getTimePart('dateDebutParis')"
(change)="setTimePart('dateDebutParis', $event.target.value)"
class="w-28 px-3 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 focus:ring-2 focus:ring-purple-500 dark:bg-gray-800 dark:text-gray-100 text-sm"
/>
</div>
</z-form-field>
<z-form-field>
<label
z-form-label
class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2 block"
>
Fin des paris
</label>
<div class="flex gap-3 items-center">
<input
z-input
type="date"
[value]="getDatePart('dateFinParis')"
(change)="setDatePart('dateFinParis', $event.target.value)"
class="flex-1 px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 focus:ring-2 focus:ring-purple-500 dark:bg-gray-800 dark:text-gray-100"
/>
<input
z-input
type="time"
[value]="getTimePart('dateFinParis')"
(change)="setTimePart('dateFinParis', $event.target.value)"
class="w-28 px-3 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 focus:ring-2 focus:ring-purple-500 dark:bg-gray-800 dark:text-gray-100 text-sm"
/>
</div>
</z-form-field>
</div>
</div>
</div>
@@ -197,29 +176,6 @@
</p>
</div>
</div>
<z-form-field>
<label z-form-label zRequired for="reunionId">Réunion</label>
<z-form-control
[errorMessage]="isInvalid('reunionId') ? 'Veuillez sélectionner une réunion' : ''"
>
<z-select
id="reunionId"
placeholder="Rechercher une réunion..."
formControlName="reunionId"
[zLabel]="selectedReunionLabel() || ''"
(zSelectionChange)="onReunionSelectionChange($event)"
>
@if (loadingReunions()) {
<z-select-item [zValue]="''" disabled>Chargement des réunions...</z-select-item>
} @else { @for (r of filteredReunions(); track r.id) {
<z-select-item [zValue]="r.id">
{{ r.nom }} {{ r.hippodrome.nom }} ({{ r.hippodrome.ville }})
</z-select-item>
} }
</z-select>
</z-form-control>
</z-form-field>
</div>
<!-- 🏇 SECTION 4 — Détails techniques -->
@@ -239,15 +195,18 @@
<div class="grid sm:grid-cols-1 lg:grid-cols-2 gap-5">
<z-form-field>
<label z-form-label class="text-sm font-semibold text-gray-700 dark:text-gray-300"
>Particularité</label
>Types de paris ouverts (CSV)</label
>
<z-form-control>
<input
z-input
placeholder="Ex: Handicap, Trot attelé..."
formControlName="particularite"
class="w-full px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 focus:ring-2 focus:ring-orange-500 dark:bg-gray-800 dark:text-gray-100"
/>
<z-select
formControlName="typeParisOuverts"
class="w-full">
@for (t of courseTypes; track t.value) {
<z-select-item [zValue]="t.value">{{ t.label }}</z-select-item>
}
</z-select>
<p class="mt-1 text-sm text-gray-500">Séparez les types par des virgules.</p>
</z-form-control>
</z-form-field>
@@ -257,14 +216,14 @@
>
<z-form-control
[errorMessage]="
isInvalid('partants') ? errorMessage('partants') || 'Ce champ est obligatoire' : ''
isInvalid('nombrePartants') ? errorMessage('nombrePartants') || 'Ce champ est obligatoire' : ''
"
>
<input
z-input
type="number"
min="1"
formControlName="partants"
formControlName="nombrePartants"
class="w-full px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 focus:ring-2 focus:ring-orange-500 dark:bg-gray-800 dark:text-gray-100"
/>
</z-form-control>
@@ -279,7 +238,7 @@
z-input
type="number"
min="1"
formControlName="distance"
formControlName="distanceMetres"
class="w-full px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 focus:ring-2 focus:ring-orange-500 dark:bg-gray-800 dark:text-gray-100"
/>
</z-form-control>
@@ -287,13 +246,13 @@
<z-form-field>
<label z-form-label class="text-sm font-semibold text-gray-700 dark:text-gray-300"
>Condition</label
>Catégorie</label
>
<z-form-control>
<input
z-input
placeholder="Âge, catégorie..."
formControlName="condition"
formControlName="categorie"
class="w-full px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 focus:ring-2 focus:ring-orange-500 dark:bg-gray-800 dark:text-gray-100"
/>
</z-form-control>
@@ -330,14 +289,14 @@
<z-form-field>
<label z-form-label class="text-sm font-semibold text-gray-700 dark:text-gray-300"
>Réunion Course</label
>N° de la course</label
>
<z-form-control>
<input
z-input
type="number"
min="1"
formControlName="reunionCourse"
formControlName="numero"
placeholder="Ex: 2"
class="w-full px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 focus:ring-2 focus:ring-indigo-500 dark:bg-gray-800 dark:text-gray-100"
/>

View File

@@ -24,11 +24,13 @@ import { ZardFormModule } from '@shared/components/form/form.module';
import { ZardInputDirective } from '@shared/components/input/input.directive';
import { ZardSelectItemComponent } from '@shared/components/select/select-item.component';
import { ZardSelectComponent } from '@shared/components/select/select.component';
import { Course, CourseStatut, CourseType } from 'src/app/core/interfaces/course';
import { Reunion } from 'src/app/core/interfaces/reunion';
import { ReunionService } from 'src/app/core/services/reunion';
import { Subscription } from 'rxjs';
import { Hippodrome } from 'src/app/core/interfaces/hippodrome';
import { Course, CourseStatut, CourseType } from 'src/app/core/interfaces/course';
import { HippodromeService } from 'src/app/core/services/hippodrome';
import { CourseApiResponse, CourseService } from 'src/app/core/services/course';
@Component({
selector: 'app-course-form',
standalone: true,
@@ -38,6 +40,7 @@ import { Subscription } from 'rxjs';
CommonModule,
ReactiveFormsModule,
ZardFormModule,
ZardInputDirective,
ZardSelectComponent,
ZardSelectItemComponent,
@@ -45,233 +48,198 @@ import { Subscription } from 'rxjs';
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CourseForm implements OnInit, AfterViewInit, OnDestroy {
@Output() save = new EventEmitter<Course>();
@Output() save = new EventEmitter<Partial<CourseApiResponse>>();
@Output() cancel = new EventEmitter<void>();
private _value?: Course;
@Input() set value(v: Course | undefined) {
this._value = v;
}
get value(): Course | undefined {
return this._value;
}
@Input() value?: Course;
form: FormGroup;
submitted = false;
reunions = signal<Reunion[]>([]);
loadingReunions = signal(false);
searchQuery = signal<string>('');
selectedReunionLabel = signal<string>('');
initializing = signal(false);
hippodromes = signal<Hippodrome[]>([]);
loadingHippodromes = signal(false);
searchQuery = signal('');
selectedHippodromeLabel = signal('');
private subs = new Subscription();
constructor(
private fb: FormBuilder,
private reunionService: ReunionService,
private courseServive: CourseService,
private hippodromeService: HippodromeService,
private cdr: ChangeDetectorRef
) {
this.form = this.fb.group({
type: ['', Validators.required],
numero: [null, [Validators.required, Validators.min(1)]],
nom: ['', [Validators.required, Validators.minLength(3)]],
dateDepartCourse: ['', Validators.required],
dateDebutParis: ['', Validators.required],
dateFinParis: ['', Validators.required],
reunionId: new FormControl<string | null>(null, Validators.required),
reunionCourse: [1],
particularite: [''],
partants: [null, [Validators.required, Validators.min(1)]],
distance: [''],
condition: [''],
statut: [CourseStatut.PROGRAMMEE, Validators.required],
hippodromeId: new FormControl<string | null>(null, Validators.required),
reunionNumero: [null, Validators.required],
reunionDate: ['', Validators.required],
nom: ['', Validators.required],
numero: [null, Validators.required],
statut: [CourseStatut.BROUILLON, Validators.required],
heureDepartPrevu: ['', Validators.required],
discipline: ['', Validators.required],
distanceMetres: [null, Validators.required],
nombrePartants: [null, Validators.required],
annulee: [false],
categorie: ['', Validators.required],
reporteeMemeJour: [false],
reporteeAutreJour: [false],
incidentTechnique: [false],
nonPartants: [[]],
typesParisOuverts: [''],
createdBy: ['agent-001'],
validatedBy: [''],
});
// Watch for reunionId changes to update the label
// This handles BOTH create and edit modes when user selects a reunion
this.subs.add(
this.form.get('reunionId')?.valueChanges.subscribe((reunionId) => {
// Skip if we're initializing (to avoid interfering with form initialization)
if (this.initializing()) {
return;
}
// Handle empty/null values
if (!reunionId || reunionId === '') {
this.selectedReunionLabel.set('');
return;
}
// Update label when user selects a reunion (both create and edit)
if (this.reunions().length > 0) {
// Try both string and direct comparison since form control might convert types
const matchingReunion = this.reunions().find(
(r) => String(r.id) === String(reunionId) || r.id === reunionId
);
if (matchingReunion) {
const reunionLabel = `${matchingReunion.nom} ${matchingReunion.hippodrome.nom} (${matchingReunion.hippodrome.ville})`;
this.selectedReunionLabel.set(reunionLabel);
// Force change detection to ensure the label is displayed immediately
this.cdr.markForCheck();
} else {
this.selectedReunionLabel.set('');
}
} else {
this.selectedReunionLabel.set('');
}
}) || new Subscription()
);
// Effect to handle form initialization when value or reunions change
/* =============================
FIX 1 — Gestion enable/disable
(cause principale du select bloqué)
============================== */
effect(() => {
const v = this.value;
const reunionsList = this.reunions();
const isLoading = this.loadingReunions();
const loading = this.loadingHippodromes();
const control = this.form.get('hippodromeId');
// CRITICAL: Enable/disable reunion control based on loading state
// This is the most important part for create mode to work
const reunionControl = this.form.get('reunionId');
if (isLoading) {
reunionControl?.disable({ emitEvent: false });
if (loading) {
control?.disable({ emitEvent: false });
} else {
reunionControl?.enable({ emitEvent: false });
// Force change detection after enabling to ensure the select is clickable
control?.enable({ emitEvent: false });
this.cdr.markForCheck();
}
// Handle edit mode (value is defined) - populate form with course data
if (v !== null && v !== undefined) {
this.initializing.set(true);
// Edit mode : appliquer la valeur APRÈS chargement
if (this.value && this.hippodromes().length) {
const id = String(this.value.hippodrome?.id ?? '');
if (id) {
this.form.patchValue({ hippodromeId: id }, { emitEvent: false });
let reunionId: string | null = null;
let reunionLabel: string = '';
if (v.reunion?.id) {
reunionId = String(v.reunion.id);
if (reunionsList.length > 0) {
const matchingReunion = reunionsList.find((r) => String(r.id) === reunionId);
if (matchingReunion) {
reunionLabel = `${matchingReunion.nom} ${matchingReunion.hippodrome.nom} (${matchingReunion.hippodrome.ville})`;
this.selectedReunionLabel.set(reunionLabel);
} else {
reunionId = null;
this.selectedReunionLabel.set('');
const h = this.hippodromes().find(r => String(r.id) === id);
if (h) {
this.selectedHippodromeLabel.set(`${h.nom} (${h.ville})`);
}
} else {
if (v.reunion.nom) {
reunionLabel = `${v.reunion.nom} ${v.reunion.hippodrome?.nom || ''} (${
v.reunion.hippodrome?.ville || ''
})`;
this.selectedReunionLabel.set(reunionLabel);
}
reunionId = null;
}
} else {
this.selectedReunionLabel.set('');
});
/* =============================
FIX 2 — valueChanges propre
============================== */
this.subs.add(
this.form.get('hippodromeId')!.valueChanges.subscribe((id) => {
if (!id) {
this.selectedHippodromeLabel.set('');
return;
}
this.form.patchValue(
{
type: v.type ?? '',
numero: v.numero ?? null,
nom: v.nom ?? '',
dateDepartCourse: v.dateDepartCourse ?? '',
dateDebutParis: v.dateDebutParis ?? v.dateDepartCourse ?? '',
dateFinParis: v.dateFinParis ?? v.dateDepartCourse ?? '',
reunionId,
reunionCourse: (v as any).reunionCourse ?? v.numero ?? 1,
particularite: (v as any).particularite ?? '',
partants: (v as any).partants ?? null,
distance: (v as any).distance ?? null,
condition: (v as any).condition ?? '',
statut: v.statut ?? CourseStatut.PROGRAMMEE,
createdBy: (v as any).createdBy ?? 'agent-001',
validatedBy: (v as any).validatedBy ?? '',
},
{ emitEvent: false }
const h = this.hippodromes().find(r => String(r.id) === String(id));
if (h) {
this.selectedHippodromeLabel.set(`${h.nom} (${h.ville})`);
this.cdr.markForCheck();
}
})
);
}
this.form.markAsPristine();
this.form.markAsUntouched();
queueMicrotask(() => this.initializing.set(false));
}
// Create mode (v === null or undefined) - DO NOTHING except ensure control is enabled
// The form already has default values, just let the user fill it
// The valueChanges subscription will handle label updates when user selects
/* =============================
FIX 3 — vrai filter
============================== */
filteredHippodromes = computed(() => {
const q = this.searchQuery().toLowerCase();
if (!q) return this.hippodromes();
// When reunions finish loading - re-apply value if we have one (for edit mode)
else if (reunionsList.length > 0 && !isLoading) {
// Reunions just finished loading - re-apply value if we have one
const currentValue = this.value;
if (currentValue?.reunion?.id) {
const reunionId = String(currentValue.reunion.id);
const matchingReunion = reunionsList.find((r) => String(r.id) === reunionId);
if (matchingReunion) {
// Set the label manually
const reunionLabel = `${matchingReunion.nom} ${matchingReunion.hippodrome.nom} (${matchingReunion.hippodrome.ville})`;
this.selectedReunionLabel.set(reunionLabel);
// Wait for Angular to render the select items
setTimeout(() => {
this.form.patchValue({ reunionId }, { emitEvent: false });
this.cdr.detectChanges();
}, 50);
}
}
}
return this.hippodromes().filter(h =>
h.nom.toLowerCase().includes(q) ||
h.ville.toLowerCase().includes(q) ||
(h as any).pays?.toLowerCase().includes(q)
);
});
ngOnInit() {
this.loadingHippodromes.set(true);
this.subs.add(
this.hippodromeService
.list({ page: 1, perPage: 1000 }, true)
.subscribe({
next: (res) => {
this.hippodromes.set(res.content);
this.loadingHippodromes.set(false);
},
error: () => {
this.loadingHippodromes.set(false);
},
})
);
}
private hydrateFromValue(v?: Course) {
if (v) {
const patch = {
type: v.type ?? '',
numero: v.numero ?? null,
nom: v.nom ?? '',
dateDepartCourse: v.dateDepartCourse ?? '',
dateDebutParis: v.dateDebutParis ?? v.dateDepartCourse ?? '',
dateFinParis: v.dateFinParis ?? v.dateDepartCourse ?? '',
reunionId: (v as any).reunionId ?? v.reunion?.id ?? '',
reunionCourse: (v as any).reunionCourse ?? v.numero ?? 1,
particularite: (v as any).particularite ?? '',
partants: (v as any).partants ?? null,
distance: (v as any).distance ?? null,
condition: (v as any).condition ?? '',
statut: v.statut ?? CourseStatut.PROGRAMMEE,
createdBy: (v as any).createdBy ?? 'agent-001',
validatedBy: (v as any).validatedBy ?? '',
};
this.form.reset(patch);
this.form.markAsPristine();
this.form.markAsUntouched();
} else {
this.form.reset({
type: '',
numero: null,
nom: '',
dateDepartCourse: '',
dateDebutParis: '',
dateFinParis: '',
reunionId: '',
reunionCourse: 1,
particularite: '',
partants: null,
distance: null,
condition: '',
statut: CourseStatut.PROGRAMMEE,
createdBy: 'agent-001',
validatedBy: '',
});
this.form.markAsPristine();
this.form.markAsUntouched();
}
ngAfterViewInit() {
this.cdr.markForCheck();
}
isInvalid(control: string): boolean {
const ctrl = this.form.get(control);
return !!(ctrl && ctrl.invalid && (ctrl.touched || this.submitted));
const c = this.form.get(control);
return !!(c && c.invalid && (c.touched || this.submitted));
}
onSubmit() {
this.submitted = true;
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
const raw = this.form.getRawValue() as any;
// 1⃣ Résoudre l'hippodrome sélectionné
const hippodromeId = raw.hippodromeId;
const foundHippodrome = this.hippodromes().find(h => String(h.id) === String(hippodromeId));
const hippodromeObj = foundHippodrome ?? (hippodromeId ? { id: +hippodromeId } : undefined);
// 2⃣ Transformer typesParisOuverts CSV → tableau
const typesParis = raw.typesParisOuverts
? raw.typesParisOuverts.split(',').map((s: string) => s.trim()).filter(Boolean)
: [];
// 3⃣ Construire payload
const payload: Partial<CourseApiResponse> = {
...this.value, // inclut les champs existants si édition
hippodromeId: raw.hippodromeId,
reunionNumero: raw.reunionNumero ? +raw.reunionNumero : undefined,
reunionDate: raw.reunionDate,
nom: raw.nom,
numero: raw.numero ? +raw.numero : undefined,
statut: raw.statut,
discipline: raw.discipline,
heureDepartPrevue: new Date(raw.reunionDate+raw.heureDepartPrevu).toISOString(),
distanceMetres: raw.distanceMetres ? +raw.distanceMetres : undefined,
nombrePartants: raw.nombrePartants ? +raw.nombrePartants : undefined,
typesParisOuverts: typesParis,
annulee: raw.annulee ?? false,
reporteeMemeJour: raw.reporteeMemeJour ?? false,
reporteeAutreJour: raw.reporteeAutreJour ?? false,
incidentTechnique: raw.incidentTechnique ?? false,
nonPartants: raw.nonPartants ?? [],
categorie: raw.categorie,
};
console.log(payload);
// 4⃣ Appeler le service (create ou update)
if (this.value?.id) {
this.courseServive.update(this.value.id, payload).subscribe({
next: () => this.save.emit(payload),
error: err => console.error('Erreur update course', err)
});
} else {
this.courseServive.create(payload).subscribe({
next: () => this.save.emit(payload),
error: err => console.error(err)
});
}
}
errorMessage(control: string): string | null {
const c = this.form.get(control);
if (!c || !c.errors) return null;
@@ -281,167 +249,32 @@ export class CourseForm implements OnInit, AfterViewInit, OnDestroy {
return null;
}
getDatePart(control: string): string {
const val = this.form.get(control)?.value;
return val ? new Date(val).toISOString().slice(0, 10) : '';
}
getTimePart(control: string): string {
const val = this.form.get(control)?.value;
if (!val) return '';
const d = new Date(val);
return d.toISOString().slice(11, 16);
}
setDatePart(control: string, date: string) {
const current = new Date(this.form.get(control)?.value || new Date());
const [h, m] = [current.getHours(), current.getMinutes()];
const newDate = new Date(
`${date}T${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:00Z`
);
this.form.patchValue({ [control]: newDate.toISOString() });
}
setTimePart(control: string, time: string) {
const current = new Date(this.form.get(control)?.value || new Date());
const [h, m] = time.split(':').map(Number);
current.setHours(h, m);
this.form.patchValue({ [control]: current.toISOString() });
}
onSubmit() {
this.submitted = true;
if (!this.form.valid) {
this.form.markAllAsTouched();
return;
}
const raw = this.form.getRawValue() as any;
// Find the reunion object from the reunions list
const reunionId = raw.reunionId;
const reunion = this.reunions().find((r) => String(r.id) === String(reunionId));
if (!reunion) {
console.error('Reunion not found:', reunionId);
return;
}
const payload: Partial<Course> = {
...(this.value ?? {}),
type: raw.type,
numero: +raw.numero,
nom: raw.nom,
dateDepartCourse: raw.dateDepartCourse,
dateDebutParis: raw.dateDebutParis,
dateFinParis: raw.dateFinParis,
reunion: reunion!, // Will be set if reunion is found
reunionCourse: +raw.reunionCourse,
particularite: raw.particularite ?? '',
partants: +raw.partants,
distance: +raw.distance,
condition: raw.condition ?? '',
statut: raw.statut,
createdBy: raw.createdBy,
validatedBy: raw.validatedBy || null,
};
// Ensure reunion is found
if (!reunion) {
console.error('Reunion not found:', reunionId);
return;
}
this.save.emit(payload as Course);
}
// === Filter Reunions ===
filteredReunions = computed(() => {
const q = this.searchQuery().toLowerCase();
return this.reunions().filter(
(r) =>
r.nom.toLowerCase().includes(q) ||
r.hippodrome.nom.toLowerCase().includes(q) ||
r.hippodrome.ville.toLowerCase().includes(q)
);
});
courseTypes = [
{ label: 'Tiercé', value: CourseType.TIERCE },
{ label: 'Quarté + Tiercé', value: CourseType.QUARTE },
{ label: 'Quinté + Tiercé', value: CourseType.QUINTE },
];
courseStatus = [
{ label: 'Programmée', value: CourseStatut.PROGRAMMEE },
{ label: 'Créée', value: CourseStatut.CREATED },
{ label: 'Validée', value: CourseStatut.VALIDATED },
{ label: 'En cours', value: CourseStatut.RUNNING },
{ label: 'Clôturée', value: CourseStatut.CLOSED },
{ label: 'Annulée', value: CourseStatut.CANCELED },
];
ngOnInit() {
// Fetch reunions from API
this.loadingReunions.set(true);
this.subs.add(
this.reunionService.list({ page: 1, perPage: 1000 }, false).subscribe({
next: (result) => {
this.reunions.set(result.data);
this.loadingReunions.set(false);
// Force enable the control after loading
setTimeout(() => {
const reunionControl = this.form.get('reunionId');
reunionControl?.enable({ emitEvent: false });
this.cdr.markForCheck();
}, 100);
},
error: (err) => {
console.error('Error loading reunions:', err);
this.loadingReunions.set(false);
},
})
);
}
ngAfterViewInit() {
// After view is initialized, ensure the reunion value and label are set correctly
const currentValue = this.value;
if (currentValue?.reunion?.id && this.reunions().length > 0) {
const reunionId = String(currentValue.reunion.id);
const matchingReunion = this.reunions().find((r) => String(r.id) === reunionId);
if (matchingReunion) {
// Set the label manually
const reunionLabel = `${matchingReunion.nom} ${matchingReunion.hippodrome.nom} (${matchingReunion.hippodrome.ville})`;
this.selectedReunionLabel.set(reunionLabel);
// Wait a bit for the select items to be fully rendered
setTimeout(() => {
const currentFormValue = this.form.get('reunionId')?.value;
if (String(currentFormValue) !== reunionId) {
this.form.patchValue({ reunionId }, { emitEvent: false });
this.cdr.detectChanges();
}
}, 100);
}
}
}
onReunionSelectionChange(value: string) {
// Immediately update the label when user selects a reunion
if (value && this.reunions().length > 0) {
const matchingReunion = this.reunions().find(
(r) => String(r.id) === String(value) || r.id === value
);
if (matchingReunion) {
const reunionLabel = `${matchingReunion.nom} ${matchingReunion.hippodrome.nom} (${matchingReunion.hippodrome.ville})`;
this.selectedReunionLabel.set(reunionLabel);
this.cdr.markForCheck();
} else {
}
}
}
ngOnDestroy() {
this.subs.unsubscribe();
}
courseTypes = [
{ label: 'Gagnant', value: CourseType.GAGNANT },
{ label: 'Placé', value: CourseType.PLACE },
{ label: 'Jumélé gagnant', value: CourseType.JUMELE_GAGNANT},
{ label: 'Jumélé placé', value: CourseType.JUMELE_PLACE},
{ label: 'Jumélé ordre', value: CourseType.JUMELE_ORDRE},
{ label: 'Trio', value: CourseType.TRIO},
{ label: 'Trio ordre', value: CourseType.TRIO_ORDRE},
{ label: 'Triplet', value: CourseType.TRIPLET},
{ label: 'Quatro', value: CourseType.QUATRO},
{ label: 'Quinte', value: CourseType.QUINTE},
];
courseStatus = [
{ label: 'Brouillon', value: CourseStatut.BROUILLON },
{ label: 'Validé', value: CourseStatut.VALIDE },
{ label: 'Fremé', value: CourseStatut.FERME },
{ label: 'Resultat Provisoire', value: CourseStatut.RESULTAT_PROVISOIRE },
{ label: 'Resultat officiel', value: CourseStatut.RESULTAT_OFFICIEL },
{ label: 'Reglée', value: CourseStatut.REGLEE },
{ label: 'Annulée', value: CourseStatut.ANNULEE },
];
}

View File

@@ -121,7 +121,7 @@ export class NonPartantForm implements OnInit, OnChanges, OnDestroy {
}
private seedFromCourse() {
const max = this.course?.partants ?? 0;
const max = this.course?.nombrePartants ?? 0;
this.partantsMax.set(max);
const arr = new FormArray<FormGroup<NonPartantRow>>([]);

View File

@@ -4,7 +4,7 @@
<div>
<h3 class="font-semibold text-sm">{{ course.nom }}</h3>
<p class="text-xs text-gray-500">
{{ course.reunion.nom }} • {{ course.reunion.hippodrome.nom }}
{{ course.nom }}
</p>
</div>
<span class="text-xs px-2 py-1 rounded bg-blue-100 text-blue-700">

View File

@@ -35,9 +35,9 @@ export class ResultatForm {
});
reqLen = computed(() =>
this.course?.type === CourseType.TIERCE ? 3 : this.course?.type === CourseType.QUARTE ? 4 : 5
3
);
maxNum = computed(() => this.course?.partants ?? 0);
maxNum = computed(() => this.course?.nombrePartants ?? 0);
npSet = computed(() => new Set(this.course?.nonPartants ?? []));
statut = computed((): 'CREATED' | 'VALIDATED' | 'NONE' => {
return this.resultat ? 'CREATED' : 'NONE';

View File

@@ -229,7 +229,7 @@ export class ReunionForm implements OnInit, AfterViewInit, OnDestroy {
this.subs.add(
this.hippodromeService.list({ page: 1, perPage: 1000 }, false).subscribe({
next: (result) => {
this.hippodromes.set(result.data);
this.hippodromes.set(result.content);
this.loadingHippodromes.set(false);
// The effect will automatically re-run when hippodromes signal updates
},

View File

@@ -63,7 +63,7 @@ export class UserForm {
this.roleService
.list({ page: 1, perPage: 100, search: '', sortKey: 'name', sortDir: 'asc' } as any)
.subscribe((res) => {
this.roles = res.data as any;
this.roles = res.content as any;
});
}

View File

@@ -7,69 +7,34 @@ import { PagedResult, PageMeta } from './paging';
export function normalizePage<T>(raw: any, reqPage: number, perPage: number): PagedResult<T> {
// 🟩 Case 1 — Spring Data style: { content, totalElements, number, size }
if (raw && Array.isArray(raw.content) && typeof raw.totalElements === 'number') {
return {
data: raw.content as T[],
meta: {
page: (raw.number ?? 0) + 1,
perPage: raw.size ?? perPage,
total: raw.totalElements,
...(raw.meta ?? {}), // merge extra meta if provided
} as PageMeta,
};
return raw;
}
// 🟩 Case 2 — API or local mock: { data, meta: { total, ...extra } }
if (raw && Array.isArray(raw.data) && raw.meta?.total != null) {
return {
data: raw.data as T[],
meta: {
page: raw.meta.page ?? reqPage,
perPage: raw.meta.perPage ?? perPage,
total: raw.meta.total,
...raw.meta, // keep any custom stats
} as PageMeta,
};
if (raw && Array.isArray(raw.content) && raw.pageable?.total != null) {
return raw
}
// 🟩 Case 3 — Generic REST: { items, total | total_count, ...extra }
if (raw && Array.isArray(raw.items) && (raw.total != null || raw.total_count != null)) {
const total = raw.total ?? raw.total_count;
return {
data: raw.items as T[],
meta: {
page: reqPage,
perPage,
total,
...raw.meta, // optional
} as PageMeta,
};
}
// 🟩 Case 4 — Direct array (no meta)
if (Array.isArray(raw)) {
const start = (reqPage - 1) * perPage;
const data = (raw as T[]).slice(start, start + perPage);
return {
data,
meta: {
page: reqPage,
perPage,
total: (raw as T[]).length,
} as PageMeta,
content: data,
pageable:{
pageNumber: raw.length/perPage,
pageSize: perPage,
total: raw.length
},
totalPages: raw.length/perPage,
totalElements: raw.length
};
}
// 🟩 Fallback — ensure consistency even with minimal info
const data = Array.isArray(raw?.data) ? (raw.data as T[]) : [];
const data = Array.isArray(raw?.content) ? (raw.content as T[]) : [];
const total = typeof raw?.total === 'number' ? raw.total : data.length ?? 0;
return {
data,
meta: {
page: raw?.meta?.page ?? reqPage,
perPage: raw?.meta?.perPage ?? perPage,
total,
...raw?.meta, // merge additional stats safely
} as PageMeta,
};
return raw;
}

View File

@@ -11,15 +11,16 @@ export interface ListParams {
}
export interface PageMeta {
page: number; // 1-based
perPage: number;
pageNumber: number; // 1-based
pageSize: number;
total: number; // -1 if unknown
[key: string]: any;
}
export interface PagedResult<T> {
data: T[];
meta: PageMeta;
content: T[];
pageable: PageMeta;
totalPages: number;
totalElements: number;
}
export interface BackendConfig {

View File

@@ -1,4 +1,4 @@
export const environment = {
production: false,
apiBaseUrl: 'https://custody-holding-rogers-less.trycloudflare.com',
apiBaseUrl: 'https://ddd3b90fc1ef.ngrok-free.app',
};

View File

@@ -1,4 +1,4 @@
export const environment = {
production: false,
apiBaseUrl: 'https://custody-holding-rogers-less.trycloudflare.com',
apiBaseUrl: 'https://ddd3b90fc1ef.ngrok-free.app',
};