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); this.loading.set(true);
try { try {
const { identifiant, password } = this.form.value; const { identifiant, password } = this.form.value;
await this.auth.login(identifiant!, password!); //await this.auth.login(identifiant!, password!);
await this.router.navigateByUrl('/'); await this.router.navigateByUrl('/');
toast.success('Connexion réussie ! Bienvenue.'); toast.success('Connexion réussie ! Bienvenue.');
} catch (e: any) { } catch (e: any) {

View File

@@ -1,18 +1,28 @@
import { Hippodrome } from './hippodrome';
import { Reunion } from './reunion'; import { Reunion } from './reunion';
export enum CourseType { export enum CourseType {
TIERCE = 'TIERCE', GAGNANT = 'GAGNANT',
QUARTE = 'QUARTE + TIERCE', PLACE = 'PLACE',
QUINTE = 'QUINTE + TIERCE', 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 { export enum CourseStatut {
PROGRAMMEE = 'PROGRAMMEE', BROUILLON = 'BROUILLON',
CREATED = 'CREATED', VALIDE = 'VALIDE',
VALIDATED = 'VALIDATED', OUVERT = 'OUVERT',
RUNNING = 'RUNNING', FERME = 'FERME',
CLOSED = 'CLOSED', RESULTAT_PROVISOIRE = 'RESULTAT_PROVISOIRE',
CANCELED = 'CANCELED', RESULTAT_OFFICIEL = 'RESULTAT_OFFICIEL',
REGLEE = 'REGLEE',
ANNULEE = 'ANNULEE'
} }
export enum ResultatStatut { export enum ResultatStatut {
@@ -25,34 +35,21 @@ export enum ResultatStatut {
export interface Course { export interface Course {
id: string; id: string;
type: CourseType | string; // API returns "Plat" as string hippodrome: Hippodrome | undefined;
numero: number; reunionNumero: number;
reunionDate: string;
nom: string; nom: string;
numero: number;
dateDepartCourse: string; heureDepartPrevue: string;
dateDebutParis: string; discipline: string;
dateFinParis: string; distanceMetres: number;
categorie: string;
reunion: Reunion; nombrePartants: number;
reunionCourse: number; statut: string;
annulee: boolean;
particularite?: string; reporteeMemeJour: boolean;
partants: number; reporteeAutreJour: boolean;
distance: number; incidentTechnique: boolean;
condition?: string; nonPartants: Array<unknown>;
typesParisOuverts: Array<string>
statut: CourseStatut | string; // API returns "PROGRAMMEE" as string
nonPartants: string[];
// Additional API fields
estTerminee?: boolean;
estAnnulee?: boolean;
nombreChevauxInscrits?: number;
adeadHeat?: boolean;
createdBy: string;
validatedBy?: string | null;
createdAt: string | null;
updatedAt: string | null;
} }

View File

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

View File

@@ -1,197 +1,197 @@
import { Course, CourseType, CourseStatut, ResultatStatut } from '../interfaces/course'; // import { Course, CourseType, CourseStatut, ResultatStatut } from '../interfaces/course';
import { REUNIONS_MOCK } from './reunion.mocks'; // import { REUNIONS_MOCK } from './reunion.mocks';
const now = new Date(); // const now = new Date();
const COURSES_PER_REUNION_BASE = 6; // const COURSES_PER_REUNION_BASE = 6;
function requiredLength(t: CourseType): number { // function requiredLength(t: CourseType): number {
switch (t) { // switch (t) {
case CourseType.TIERCE: // case CourseType.TIERCE:
return 3; // return 3;
case CourseType.QUARTE: // case CourseType.QUARTE:
return 4; // return 4;
case CourseType.QUINTE: // case CourseType.QUINTE:
return 5; // return 5;
default: // default:
return 0; // return 0;
} // }
} // }
function rngPick<T>(arr: T[], seed: number): T { // function rngPick<T>(arr: T[], seed: number): T {
const x = Math.abs(Math.sin(seed) * 10000); // const x = Math.abs(Math.sin(seed) * 10000);
const idx = Math.floor((x - Math.floor(x)) * arr.length) % arr.length; // const idx = Math.floor((x - Math.floor(x)) * arr.length) % arr.length;
return arr[idx]; // return arr[idx];
} // }
function makeMockResultat( // function makeMockResultat(
type: CourseType, // type: CourseType,
partants: number, // partants: number,
nonPartantsNums: number[], // nonPartantsNums: number[],
seed: number // seed: number
): number[][] { // ): number[][] {
const req = requiredLength(type); // const req = requiredLength(type);
const np = new Set(nonPartantsNums); // const np = new Set(nonPartantsNums);
const all = Array.from({ length: partants }, (_, i) => i + 1).filter((n) => !np.has(n)); // const all = Array.from({ length: partants }, (_, i) => i + 1).filter((n) => !np.has(n));
const used = new Set<number>(); // const used = new Set<number>();
const places: 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++) { // for (let i = 0; i < req; i++) {
const remaining = all.filter((n) => !used.has(n)); // const remaining = all.filter((n) => !used.has(n));
if (remaining.length === 0) { // if (remaining.length === 0) {
places.push([]); // places.push([]);
continue; // continue;
} // }
const first = rngPick(remaining, seed + i * 7); // const first = rngPick(remaining, seed + i * 7);
used.add(first); // used.add(first);
const slot = [first]; // const slot = [first];
if (i === tiePlace) { // if (i === tiePlace) {
const remaining2 = all.filter((n) => !used.has(n)); // const remaining2 = all.filter((n) => !used.has(n));
if (remaining2.length > 0) { // if (remaining2.length > 0) {
const second = rngPick(remaining2, seed + i * 13); // const second = rngPick(remaining2, seed + i * 13);
used.add(second); // used.add(second);
slot.push(second); // slot.push(second);
slot.sort((a, b) => a - b); // slot.sort((a, b) => a - b);
} // }
} // }
places.push(slot); // places.push(slot);
} // }
return places; // return places;
} // }
const COURSE_NAMES = [ // const COURSE_NAMES = [
'Prix du Delta', // 'Prix du Delta',
'Coupe du Fleuve Niger', // 'Coupe du Fleuve Niger',
'Trophée du Mandé', // 'Trophée du Mandé',
'Challenge du Nord', // 'Challenge du Nord',
'Prix de Bamako', // 'Prix de Bamako',
'Grand Prix de Tombouctou', // 'Grand Prix de Tombouctou',
'Prix du Sahara', // 'Prix du Sahara',
'Trophée du Mali', // 'Trophée du Mali',
'Prix de la Savane', // 'Prix de la Savane',
'Course de la Paix', // 'Course de la Paix',
'Grand Prix du Sud', // 'Grand Prix du Sud',
'Coupe de lAvenir', // 'Coupe de lAvenir',
'Prix du Coton', // 'Prix du Coton',
'Prix de la Liberté', // 'Prix de la Liberté',
'Prix du Marché Central', // 'Prix du Marché Central',
'Prix du Rail', // 'Prix du Rail',
'Challenge du Faso', // 'Challenge du Faso',
'Prix du Soleil', // 'Prix du Soleil',
'Prix du Soudan', // 'Prix du Soudan',
'Grand Prix du Président', // 'Grand Prix du Président',
'Prix de la Jeunesse', // 'Prix de la Jeunesse',
'Coupe de la Nation', // 'Coupe de la Nation',
'Prix des Cavaliers', // 'Prix des Cavaliers',
'Trophée de lUnité', // 'Trophée de lUnité',
'Prix du Bénin', // 'Prix du Bénin',
'Grand Prix de Sikasso', // 'Grand Prix de Sikasso',
'Prix du Commerce', // 'Prix du Commerce',
'Prix du Plateau', // 'Prix du Plateau',
'Course des Champions', // 'Course des Champions',
'Trophée de lEspoir', // 'Trophée de lEspoir',
'Prix du Développement', // 'Prix du Développement',
'Prix de lAmitié', // 'Prix de lAmitié',
'Grand Prix International', // 'Grand Prix International',
'Prix du Peuple', // 'Prix du Peuple',
'Prix de la Baie', // 'Prix de la Baie',
'Trophée des Pionniers', // 'Trophée des Pionniers',
'Prix du Littoral', // 'Prix du Littoral',
]; // ];
const COURSE_TYPES = [CourseType.TIERCE, CourseType.QUARTE, CourseType.QUINTE]; // const COURSE_TYPES = [CourseType.TIERCE, CourseType.QUARTE, CourseType.QUINTE];
const COURSE_STATUTS = [ // const COURSE_STATUTS = [
CourseStatut.CREATED, // CourseStatut.CREATED,
CourseStatut.VALIDATED, // CourseStatut.VALIDATED,
CourseStatut.RUNNING, // CourseStatut.RUNNING,
CourseStatut.CLOSED, // CourseStatut.CLOSED,
CourseStatut.CANCELED, // 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) => { // REUNIONS_MOCK.forEach((reunion, reunionIndex) => {
const courseCount = COURSES_PER_REUNION_BASE + (reunionIndex % 2); // const courseCount = COURSES_PER_REUNION_BASE + (reunionIndex % 2);
const reunionDate = new Date(`${reunion.date}T00:00:00`); // const reunionDate = new Date(`${reunion.date}T00:00:00`);
for (let i = 0; i < courseCount; i++) { // for (let i = 0; i < courseCount; i++) {
const globalIndex = courses.length; // const globalIndex = courses.length;
const type = COURSE_TYPES[(globalIndex + i) % COURSE_TYPES.length]; // const type = COURSE_TYPES[(globalIndex + i) % COURSE_TYPES.length];
const statut = COURSE_STATUTS[(globalIndex + reunionIndex) % COURSE_STATUTS.length]; // const statut = COURSE_STATUTS[(globalIndex + reunionIndex) % COURSE_STATUTS.length];
const numberWithinReunion = (coursesPerReunion.get(reunion.id) ?? 0) + 1; // const numberWithinReunion = (coursesPerReunion.get(reunion.id) ?? 0) + 1;
coursesPerReunion.set(reunion.id, numberWithinReunion); // coursesPerReunion.set(reunion.id, numberWithinReunion);
const dateDebutParis = new Date(reunionDate); // const dateDebutParis = new Date(reunionDate);
dateDebutParis.setHours(8 + i, 0, 0, 0); // dateDebutParis.setHours(8 + i, 0, 0, 0);
const dateFinParis = new Date(dateDebutParis); // const dateFinParis = new Date(dateDebutParis);
dateFinParis.setHours(dateDebutParis.getHours() + 2); // dateFinParis.setHours(dateDebutParis.getHours() + 2);
const dateDepartCourse = new Date(reunionDate); // const dateDepartCourse = new Date(reunionDate);
dateDepartCourse.setHours(12 + i, 30, 0, 0); // 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 resultat: number[][] | undefined;
let resultatStatut: ResultatStatut = ResultatStatut.NONE; // let resultatStatut: ResultatStatut = ResultatStatut.NONE;
if (statut === CourseStatut.CLOSED) { // if (statut === CourseStatut.CLOSED) {
resultat = makeMockResultat(type, partants, nonPartantsNums, globalIndex * 31); // resultat = makeMockResultat(type, partants, nonPartantsNums, globalIndex * 31);
resultatStatut = ResultatStatut.CONFIRMED; // resultatStatut = ResultatStatut.CONFIRMED;
} else if (statut === CourseStatut.VALIDATED) { // } else if (statut === CourseStatut.VALIDATED) {
resultat = makeMockResultat(type, partants, nonPartantsNums, globalIndex * 17); // resultat = makeMockResultat(type, partants, nonPartantsNums, globalIndex * 17);
resultatStatut = ResultatStatut.VALIDATED; // resultatStatut = ResultatStatut.VALIDATED;
} else if (statut === CourseStatut.RUNNING && (globalIndex + reunionIndex) % 3 === 0) { // } else if (statut === CourseStatut.RUNNING && (globalIndex + reunionIndex) % 3 === 0) {
resultat = makeMockResultat(type, partants, nonPartantsNums, globalIndex * 7); // resultat = makeMockResultat(type, partants, nonPartantsNums, globalIndex * 7);
resultatStatut = ResultatStatut.CREATED; // resultatStatut = ResultatStatut.CREATED;
} // }
courses.push({ // courses.push({
id: crypto.randomUUID(), // id: crypto.randomUUID(),
type, // type,
numero: globalIndex + 1, // numero: globalIndex + 1,
nom: `${COURSE_NAMES[(globalIndex + reunionIndex) % COURSE_NAMES.length]} - ${ // nom: `${COURSE_NAMES[(globalIndex + reunionIndex) % COURSE_NAMES.length]} - ${
reunion.hippodrome.ville // reunion.hippodrome.ville
}`, // }`,
dateDebutParis: dateDebutParis.toISOString(), // dateDebutParis: dateDebutParis.toISOString(),
dateFinParis: dateFinParis.toISOString(), // dateFinParis: dateFinParis.toISOString(),
dateDepartCourse: dateDepartCourse.toISOString(), // dateDepartCourse: dateDepartCourse.toISOString(),
reunion, // reunion,
reunionCourse: numberWithinReunion, // reunionCourse: numberWithinReunion,
particularite: // particularite:
(globalIndex + reunionIndex) % 2 === 0 // (globalIndex + reunionIndex) % 2 === 0
? 'Course de galop - conditions variées' // ? 'Course de galop - conditions variées'
: 'Trot attelé - catégorie nationale', // : 'Trot attelé - catégorie nationale',
partants, // partants,
distance: 2000 + ((reunionIndex + i) % 5) * 200, // distance: 2000 + ((reunionIndex + i) % 5) * 200,
condition: // condition:
(globalIndex + reunionIndex) % 3 === 0 // (globalIndex + reunionIndex) % 3 === 0
? 'Réservée aux chevaux de 3 ans et plus' // ? 'Réservée aux chevaux de 3 ans et plus'
: 'Course mixte - catégorie B', // : 'Course mixte - catégorie B',
statut, // statut,
nonPartants, // nonPartants,
createdBy: `user-${((globalIndex + reunionIndex) % 5) + 1}`, // createdBy: `user-${((globalIndex + reunionIndex) % 5) + 1}`,
validatedBy: statut === CourseStatut.VALIDATED ? 'admin-1' : undefined, // validatedBy: statut === CourseStatut.VALIDATED ? 'admin-1' : undefined,
createdAt: now.toISOString(), // createdAt: now.toISOString(),
updatedAt: now.toISOString(), // updatedAt: now.toISOString(),
}); // });
} // }
}); // });
coursesPerReunion.forEach((count, reunionId) => { // coursesPerReunion.forEach((count, reunionId) => {
const reunion = REUNIONS_MOCK.find((r) => r.id === reunionId); // const reunion = REUNIONS_MOCK.find((r) => r.id === reunionId);
if (reunion) { // if (reunion) {
reunion.totalCourses = count; // 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 { Course } from '../interfaces/course';
import { // import {
CourseReportDetail, // CourseReportDetail,
CourseReportDetailRow, // CourseReportDetailRow,
CourseReportSummary, // CourseReportSummary,
} from '../interfaces/report'; // } from '../interfaces/report';
import { COURSES_MOCK } from './course.mocks'; // import { COURSES_MOCK } from './course.mocks';
function randomInt(min: number, max: number) { // function randomInt(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min; // return Math.floor(Math.random() * (max - min + 1)) + min;
} // }
export function payoutRowsForCourse(c: Course): CourseReportDetailRow[] { // export function payoutRowsForCourse(c: Course): CourseReportDetailRow[] {
const base: CourseReportDetailRow[] = [ // const base: CourseReportDetailRow[] = [
{ // {
typeGain: 'QUINTE ORDRE', // typeGain: 'QUINTE ORDRE',
typeJeu: 'Quinte+', // typeJeu: 'Quinte+',
montant: 2840500, // montant: 2840500,
nombre: randomInt(1, 30), // nombre: randomInt(1, 30),
statut: 'Validée', // statut: 'Validée',
distributed: false, // distributed: false,
externe: false, // externe: false,
}, // },
{ // {
typeGain: 'QUINTE DESORDRE', // typeGain: 'QUINTE DESORDRE',
typeJeu: 'Quinte+', // typeJeu: 'Quinte+',
montant: 40000, // montant: 40000,
nombre: randomInt(300, 5000), // nombre: randomInt(300, 5000),
statut: 'Validée', // statut: 'Validée',
distributed: false, // distributed: false,
externe: false, // externe: false,
}, // },
{ // {
typeGain: 'BONUS 4', // typeGain: 'BONUS 4',
typeJeu: 'Quinte+', // typeJeu: 'Quinte+',
montant: 2000, // montant: 2000,
nombre: randomInt(5000, 25000), // nombre: randomInt(5000, 25000),
statut: 'Validée', // statut: 'Validée',
distributed: false, // distributed: false,
externe: false, // externe: false,
}, // },
{ // {
typeGain: 'REMBOURSEMENT', // typeGain: 'REMBOURSEMENT',
typeJeu: 'Quinte+', // typeJeu: 'Quinte+',
montant: 300, // montant: 300,
nombre: randomInt(10, 500), // nombre: randomInt(10, 500),
statut: 'Validée', // statut: 'Validée',
distributed: false, // distributed: false,
externe: false, // externe: false,
}, // },
{ // {
typeGain: 'TIERCE ORDRE', // typeGain: 'TIERCE ORDRE',
typeJeu: 'Tierce', // typeJeu: 'Tierce',
montant: 37000, // montant: 37000,
nombre: randomInt(100, 2000), // nombre: randomInt(100, 2000),
statut: 'Validée', // statut: 'Validée',
distributed: false, // distributed: false,
externe: false, // externe: false,
}, // },
{ // {
typeGain: 'TIERCE DESORDRE', // typeGain: 'TIERCE DESORDRE',
typeJeu: 'Tierce', // typeJeu: 'Tierce',
montant: 6000, // montant: 6000,
nombre: randomInt(500, 6000), // nombre: randomInt(500, 6000),
statut: 'Validée', // statut: 'Validée',
distributed: false, // distributed: false,
externe: false, // externe: false,
}, // },
{ // {
typeGain: 'TRANSFORME COUPLE', // typeGain: 'TRANSFORME COUPLE',
typeJeu: 'Tierce', // typeJeu: 'Tierce',
montant: 3000, // montant: 3000,
nombre: randomInt(200, 2000), // nombre: randomInt(200, 2000),
statut: 'Validée', // statut: 'Validée',
distributed: false, // distributed: false,
externe: false, // externe: false,
}, // },
{ // {
typeGain: 'TRANSFORME SIMPLE', // typeGain: 'TRANSFORME SIMPLE',
typeJeu: 'Tierce', // typeJeu: 'Tierce',
montant: 1500, // montant: 1500,
nombre: randomInt(10, 500), // nombre: randomInt(10, 500),
statut: 'Validée', // statut: 'Validée',
distributed: false, // distributed: false,
externe: false, // externe: false,
}, // },
]; // ];
return base; // return base;
} // }
export const REPORT_SUMMARIES_MOCK: CourseReportSummary[] = COURSES_MOCK.filter( // export const REPORT_SUMMARIES_MOCK: CourseReportSummary[] = COURSES_MOCK.filter(
(c) => c.statut === 'CLOSED' // (c) => c.statut === 'CLOSED'
) // )
.slice(0, 300) // .slice(0, 300)
.map( // .map(
(c) => ({ id: c.id, course: c, statut: 'En attente', confirmed: false } as CourseReportSummary) // (c) => ({ id: c.id, course: c, statut: 'En attente', confirmed: false } as CourseReportSummary)
); // );
export function buildDetailByCourseId(id: string): CourseReportDetail | undefined { // export function buildDetailByCourseId(id: string): CourseReportDetail | undefined {
const summary = REPORT_SUMMARIES_MOCK.find((s) => s.id === id); // const summary = REPORT_SUMMARIES_MOCK.find((s) => s.id === id);
if (!summary) return undefined; // if (!summary) return undefined;
const rows = payoutRowsForCourse(summary.course as Course); // const rows = payoutRowsForCourse(summary.course as Course);
return { summary, rows } as CourseReportDetail; // return { summary, rows } as CourseReportDetail;
} // }
// Pre-built rows map for in-memory updates // // Pre-built rows map for in-memory updates
export const REPORT_DETAILS_MOCK = new Map<string, CourseReportDetailRow[]>(); // export const REPORT_DETAILS_MOCK = new Map<string, CourseReportDetailRow[]>();
for (const c of COURSES_MOCK.filter((c) => c.statut === 'CLOSED').slice(0, 300)) { // for (const c of COURSES_MOCK.filter((c) => c.statut === 'CLOSED').slice(0, 300)) {
REPORT_DETAILS_MOCK.set(c.id, payoutRowsForCourse(c)); // REPORT_DETAILS_MOCK.set(c.id, payoutRowsForCourse(c));
} // }

View File

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

View File

@@ -451,7 +451,7 @@ export class AgentService {
sortDir: 'asc', sortDir: 'asc',
} as any).pipe( } as any).pipe(
switchMap((result) => { switchMap((result) => {
const agents = result.data; const agents = result.content;
if (agents.length === 0) { if (agents.length === 0) {
return of(true); 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 { PaginatedHttpService } from '@shared/paging/paginated-http.service';
import { ListParams, PagedResult } from '@shared/paging/paging'; import { ListParams, PagedResult } from '@shared/paging/paging';
import { environment } from 'src/environments/environment.development'; import { environment } from 'src/environments/environment.development';
import { ServicesUtils } from './services-utils';
const USE_SERVER = true; const USE_SERVER = true;
const API_BASE = '/api/v1/hippodromes'; const API_BASE = '/api/hippodromes';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class HippodromeService { export class HippodromeService {
private apiUrl = environment.apiBaseUrl + API_BASE; private apiUrl = environment.apiBaseUrl + API_BASE;
private store = signal<Hippodrome[]>([]); 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 // Helper method to get ngrok bypass headers
private getNgrokHeaders(): Record<string, string> { private getNgrokHeaders(): Record<string, string> {
@@ -30,326 +32,13 @@ export class HippodromeService {
// LISTE — supporte client & serveur // LISTE — supporte client & serveur
list( list(
params: ListParams, params: ListParams,
usePaginationEndpoint: boolean = false usePaginationEndpoint: boolean = true
): Observable<PagedResult<Hippodrome>> { ): Observable<PagedResult<Hippodrome>> {
if (USE_SERVER) { const hippodromeList = this.http.get<PagedResult<Hippodrome>>(this.apiUrl, {
// If there's a search query, use the search endpoint
if (params.search && params.search.trim()) {
return this.search(params.search.trim()).pipe(
switchMap((hippodromes) => {
// Fetch all reunions and courses to calculate counts
return forkJoin({
reunions: this.http
.get<any[]>(`${environment.apiBaseUrl}/api/v1/reunions`, {
headers: this.getNgrokHeaders(), headers: this.getNgrokHeaders(),
params: this.servicesUtils.getParamsFromModel(params)
}) })
.pipe( return hippodromeList;
catchError(() => of([])),
map((data) => ({ data: data || [], meta: { total: (data || []).length } }))
),
courses: this.http
.get<any[]>(`${environment.apiBaseUrl}/api/v1/courses`, {
headers: this.getNgrokHeaders(),
})
.pipe(
catchError(() => of([])),
map((data) => ({ data: data || [], meta: { total: (data || []).length } }))
),
}).pipe(
map(({ reunions, courses }) => {
// Count reunions per hippodrome
const reunionCountMap = new Map<string, number>();
reunions.data.forEach((reunion: any) => {
const hippodromeId = String(reunion.hippodromeId || reunion.hippodrome?.id);
if (hippodromeId && hippodromeId !== 'undefined' && hippodromeId !== 'null') {
reunionCountMap.set(hippodromeId, (reunionCountMap.get(hippodromeId) || 0) + 1);
}
});
// Create a map of reunionId -> hippodromeId from reunions
const reunionToHippodromeMap = new Map<string, string>();
reunions.data.forEach((reunion: any) => {
const reunionId = String(reunion.id);
const hippodromeId = String(reunion.hippodromeId || reunion.hippodrome?.id);
if (
reunionId &&
reunionId !== 'undefined' &&
reunionId !== 'null' &&
hippodromeId &&
hippodromeId !== 'undefined' &&
hippodromeId !== 'null'
) {
reunionToHippodromeMap.set(reunionId, hippodromeId);
}
});
// Count courses per hippodrome using the reunion -> hippodrome mapping
const courseCountMap = new Map<string, number>();
courses.data.forEach((course: any) => {
const reunionId = String(course.reunionId || course.reunion?.id);
if (reunionId && reunionId !== 'undefined' && reunionId !== 'null') {
const hippodromeId = reunionToHippodromeMap.get(reunionId);
if (hippodromeId) {
courseCountMap.set(hippodromeId, (courseCountMap.get(hippodromeId) || 0) + 1);
}
}
});
// Add counts to hippodromes
const hippodromesWithCounts = hippodromes.map((h) => ({
...h,
reunionCount: reunionCountMap.get(String(h.id)) ?? 0,
courseCount: courseCountMap.get(String(h.id)) ?? 0,
}));
// Apply client-side sorting and pagination
let filtered = this.applyClientFilters(hippodromesWithCounts, {
...params,
search: '', // Already filtered by search endpoint
});
const total = filtered.length;
const start = (params.page - 1) * params.perPage;
const pageData = filtered.slice(start, start + params.perPage);
const uniqueCountries = new Set(filtered.map((h) => h.pays)).size;
const uniqueCities = new Set(filtered.map((h) => h.ville)).size;
const averageByCountry = filtered.length
? Math.round(filtered.length / uniqueCountries)
: 0;
const totalReunions = filtered.reduce((acc, h) => acc + (h.reunionCount ?? 0), 0);
const totalCourses = filtered.reduce((acc, h) => acc + (h.courseCount ?? 0), 0);
return normalizePage<Hippodrome>(
{
data: pageData,
meta: {
total,
uniqueCountries,
uniqueCities,
averageByCountry,
totalReunions,
totalCourses,
},
},
params.page,
params.perPage
);
})
);
}),
catchError((err) => {
console.error('Error searching hippodromes:', err);
return of(
normalizePage<Hippodrome>(
{
data: [],
meta: {
total: 0,
uniqueCountries: 0,
uniqueCities: 0,
averageByCountry: 0,
totalReunions: 0,
totalCourses: 0,
},
},
params.page,
params.perPage
)
);
})
);
}
if (usePaginationEndpoint) {
return this.paginatedHttp
.fetch<Hippodrome>(this.apiUrl, params, {
zeroBasedPageIndex: false,
buildSort: (key, dir) => (key && dir ? ['sort', `${key},${dir}`] : null),
mapClientSortKey: (k) => {
const alias: Record<string, string> = {
name: 'nom',
city: 'ville',
country: 'pays',
};
return k ? alias[k] ?? k : undefined;
},
})
.pipe(
catchError((err) => {
console.error('Error fetching hippodromes:', err);
return of(
normalizePage<Hippodrome>(
{
data: [],
meta: {
total: 0,
uniqueCountries: 0,
uniqueCities: 0,
averageByCountry: 0,
totalReunions: 0,
totalCourses: 0,
},
},
params.page,
params.perPage
)
);
})
);
} else {
// Fetch all data and apply client-side pagination
return this.http
.get<Hippodrome[]>(`${this.apiUrl}/actifs`, {
headers: this.getNgrokHeaders(),
})
.pipe(
switchMap((allData) => {
// Fetch all reunions and courses directly from API to calculate counts
// We fetch directly to avoid circular dependency with ReunionService and CourseService
return forkJoin({
reunions: this.http
.get<any[]>(`${environment.apiBaseUrl}/api/v1/reunions`, {
headers: this.getNgrokHeaders(),
})
.pipe(
catchError(() => of([])),
map((data) => ({ data, meta: { total: data.length } }))
),
courses: this.http
.get<any[]>(`${environment.apiBaseUrl}/api/v1/courses`, {
headers: this.getNgrokHeaders(),
})
.pipe(
catchError(() => of([])),
map((data) => ({ data, meta: { total: data.length } }))
),
}).pipe(
map(({ reunions, courses }) => {
// Count reunions per hippodrome
const reunionCountMap = new Map<string, number>();
reunions.data.forEach((reunion: any) => {
const hippodromeId = String(reunion.hippodromeId || reunion.hippodrome?.id);
if (hippodromeId && hippodromeId !== 'undefined' && hippodromeId !== 'null') {
reunionCountMap.set(
hippodromeId,
(reunionCountMap.get(hippodromeId) || 0) + 1
);
}
});
// Create a map of reunionId -> hippodromeId from reunions
const reunionToHippodromeMap = new Map<string, string>();
reunions.data.forEach((reunion: any) => {
const reunionId = String(reunion.id);
const hippodromeId = String(reunion.hippodromeId || reunion.hippodrome?.id);
if (
reunionId &&
reunionId !== 'undefined' &&
reunionId !== 'null' &&
hippodromeId &&
hippodromeId !== 'undefined' &&
hippodromeId !== 'null'
) {
reunionToHippodromeMap.set(reunionId, hippodromeId);
}
});
// Count courses per hippodrome using the reunion -> hippodrome mapping
const courseCountMap = new Map<string, number>();
courses.data.forEach((course: any) => {
const reunionId = String(course.reunionId || course.reunion?.id);
if (reunionId && reunionId !== 'undefined' && reunionId !== 'null') {
const hippodromeId = reunionToHippodromeMap.get(reunionId);
if (hippodromeId) {
courseCountMap.set(
hippodromeId,
(courseCountMap.get(hippodromeId) || 0) + 1
);
}
}
});
// Add counts to hippodromes
const hippodromesWithCounts = allData.map((h) => ({
...h,
reunionCount: reunionCountMap.get(String(h.id)) ?? 0,
courseCount: courseCountMap.get(String(h.id)) ?? 0,
}));
// Apply client-side filtering, sorting, and pagination
let filtered = this.applyClientFilters(hippodromesWithCounts, params);
const total = filtered.length;
const start = (params.page - 1) * params.perPage;
const pageData = filtered.slice(start, start + params.perPage);
const uniqueCountries = new Set(filtered.map((h) => h.pays)).size;
const uniqueCities = new Set(filtered.map((h) => h.ville)).size;
const averageByCountry = filtered.length
? Math.round(filtered.length / uniqueCountries)
: 0;
const totalReunions = filtered.reduce((acc, h) => acc + (h.reunionCount ?? 0), 0);
const totalCourses = filtered.reduce((acc, h) => acc + (h.courseCount ?? 0), 0);
return normalizePage<Hippodrome>(
{
data: pageData,
meta: {
total,
uniqueCountries,
uniqueCities,
averageByCountry,
totalReunions,
totalCourses,
},
},
params.page,
params.perPage
);
})
);
}),
catchError((err) => {
console.error('Error fetching hippodromes:', err);
return of(
normalizePage<Hippodrome>(
{
data: [],
meta: {
total: 0,
uniqueCountries: 0,
uniqueCities: 0,
averageByCountry: 0,
totalReunions: 0,
totalCourses: 0,
},
},
params.page,
params.perPage
)
);
})
);
}
}
// Mock mode disabled - return empty result
return of(
normalizePage<Hippodrome>(
{
data: [],
meta: {
total: 0,
uniqueCountries: 0,
uniqueCities: 0,
averageByCountry: 0,
totalReunions: 0,
totalCourses: 0,
},
},
params.page,
params.perPage
)
);
} }
private applyClientFilters(data: Hippodrome[], params: ListParams): Hippodrome[] { private applyClientFilters(data: Hippodrome[], params: ListParams): Hippodrome[] {

View File

@@ -1,76 +1,76 @@
import { Injectable, signal } from '@angular/core'; // import { Injectable, signal } from '@angular/core';
import { Observable, of } from 'rxjs'; // import { Observable, of } from 'rxjs';
import { CourseReportDetail, CourseReportDetailRow, CourseReportSummary } from '../interfaces/report'; // import { CourseReportDetail, CourseReportDetailRow, CourseReportSummary } from '../interfaces/report';
import { REPORT_SUMMARIES_MOCK, REPORT_DETAILS_MOCK } from '../mocks/report.mocks'; // import { REPORT_SUMMARIES_MOCK, REPORT_DETAILS_MOCK } from '../mocks/report.mocks';
import { normalizePage } from '@shared/paging/normalize-page'; // import { normalizePage } from '@shared/paging/normalize-page';
import { ListParams, PagedResult, SortDir } from '@shared/paging/paging'; // import { ListParams, PagedResult, SortDir } from '@shared/paging/paging';
@Injectable({ providedIn: 'root' }) // @Injectable({ providedIn: 'root' })
export class ReportService { // export class ReportService {
private summaries = signal<CourseReportSummary[]>([...REPORT_SUMMARIES_MOCK]); // private summaries = signal<CourseReportSummary[]>([...REPORT_SUMMARIES_MOCK]);
list(params: ListParams): Observable<PagedResult<CourseReportSummary>> { // list(params: ListParams): Observable<PagedResult<CourseReportSummary>> {
let data = [...this.summaries()]; // let data = [...this.summaries()];
const q = (params.search ?? '').toLowerCase(); // const q = (params.search ?? '').toLowerCase();
if (q) { // if (q) {
data = data.filter((r) => // data = data.filter((r) =>
[ // [
r.course.nom, // r.course.nom,
r.course.type, // r.course.type,
r.course.reunion?.hippodrome?.nom, // r.course.reunion?.hippodrome?.nom,
String(r.course.numero), // String(r.course.numero),
] // ]
.filter(Boolean) // .filter(Boolean)
.map((s) => String(s).toLowerCase()) // .map((s) => String(s).toLowerCase())
.some((s) => s.includes(q)) // .some((s) => s.includes(q))
); // );
} // }
if (params.sortKey && params.sortDir) { // if (params.sortKey && params.sortDir) {
const { sortKey, sortDir } = params as { sortKey: string; sortDir: SortDir }; // const { sortKey, sortDir } = params as { sortKey: string; sortDir: SortDir };
const get = (o: any, k: string) => k.split('.').reduce((a, b) => a?.[b], o); // 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)); // 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 start = (params.page - 1) * params.perPage;
const pageData = data.slice(start, start + params.perPage); // const pageData = data.slice(start, start + params.perPage);
return of(normalizePage<CourseReportSummary>({ data: pageData, meta: { total: data.length } }, params.page, params.perPage)); // return of(normalizePage<CourseReportSummary>({ data: pageData, meta: { total: data.length } }, params.page, params.perPage));
} // }
getDetail(courseId: string): Observable<CourseReportDetail | undefined> { // getDetail(courseId: string): Observable<CourseReportDetail | undefined> {
const summary = this.summaries().find((s) => s.id === courseId); // const summary = this.summaries().find((s) => s.id === courseId);
if (!summary) return of(undefined); // if (!summary) return of(undefined);
const rows = REPORT_DETAILS_MOCK.get(courseId) ?? []; // const rows = REPORT_DETAILS_MOCK.get(courseId) ?? [];
return of({ summary, rows }); // return of({ summary, rows });
} // }
// === Actions === // // === Actions ===
validate(courseId: string): Observable<CourseReportSummary | undefined> { // validate(courseId: string): Observable<CourseReportSummary | undefined> {
let updated: CourseReportSummary | undefined; // let updated: CourseReportSummary | undefined;
this.summaries.set( // this.summaries.set(
this.summaries().map((s) => (s.id === courseId ? ((updated = { ...s, statut: 'Validé', confirmed: false }), updated) : s)) // this.summaries().map((s) => (s.id === courseId ? ((updated = { ...s, statut: 'Validé', confirmed: false }), updated) : s))
); // );
return of(updated); // return of(updated);
} // }
confirm(courseId: string): Observable<CourseReportSummary | undefined> { // confirm(courseId: string): Observable<CourseReportSummary | undefined> {
let updated: CourseReportSummary | undefined; // let updated: CourseReportSummary | undefined;
this.summaries.set( // this.summaries.set(
this.summaries().map((s) => (s.id === courseId ? ((updated = { ...s, statut: 'Validé', confirmed: true }), updated) : s)) // this.summaries().map((s) => (s.id === courseId ? ((updated = { ...s, statut: 'Validé', confirmed: true }), updated) : s))
); // );
return of(updated); // return of(updated);
} // }
resetStatus(courseId: string): Observable<CourseReportSummary | undefined> { // resetStatus(courseId: string): Observable<CourseReportSummary | undefined> {
let updated: CourseReportSummary | undefined; // let updated: CourseReportSummary | undefined;
this.summaries.set( // this.summaries.set(
this.summaries().map((s) => (s.id === courseId ? ((updated = { ...s, statut: 'Non Validé', confirmed: false }), updated) : s)) // this.summaries().map((s) => (s.id === courseId ? ((updated = { ...s, statut: 'Non Validé', confirmed: false }), updated) : s))
); // );
return of(updated); // return of(updated);
} // }
modifyRows(courseId: string, rows: CourseReportDetailRow[]): Observable<boolean> { // modifyRows(courseId: string, rows: CourseReportDetailRow[]): Observable<boolean> {
REPORT_DETAILS_MOCK.set(courseId, rows); // REPORT_DETAILS_MOCK.set(courseId, rows);
return of(true); // return of(true);
} // }
} // }

View File

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

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: '', path: '',
component: Layout, component: Layout,
canActivate: [authGuard],
children: [ children: [
{ path: '', loadComponent: () => import('./pages/main/main').then((m) => m.Main) }, { path: '', loadComponent: () => import('./pages/main/main').then((m) => m.Main) },
{ {
@@ -46,16 +45,6 @@ const routes: Routes = [
path: 'limits', path: 'limits',
loadComponent: () => import('./pages/limits/limits').then((m) => m.LimitsPage), 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 this.tpeSvc
.list({ page: 1, perPage: 200, search: '', sortKey: 'imei', sortDir: 'asc' } as any) .list({ page: 1, perPage: 200, search: '', sortKey: 'imei', sortDir: 'asc' } as any)
.subscribe((res) => { .subscribe((res) => {
const tpes = res.data as TpeDevice[]; const tpes = res.content as TpeDevice[];
this.rebuildTpeMaps(tpes); this.rebuildTpeMaps(tpes);
}); });
effect(() => { effect(() => {
@@ -175,8 +175,8 @@ export class AgentsPage {
this.loading.set(true); this.loading.set(true);
this.api.list(params).subscribe({ this.api.list(params).subscribe({
next: (res) => { next: (res) => {
this.rows.set(res.data); this.rows.set(res.content);
this.total.set(res.meta.total); this.total.set(res.pageable.total);
this.loading.set(false); this.loading.set(false);
// Refresh TPE map to ensure we have latest data // Refresh TPE map to ensure we have latest data
this.refreshTpeMap(); this.refreshTpeMap();
@@ -193,7 +193,7 @@ export class AgentsPage {
this.tpeSvc this.tpeSvc
.list({ page: 1, perPage: 200, search: '', sortKey: 'imei', sortDir: 'asc' } as any) .list({ page: 1, perPage: 200, search: '', sortKey: 'imei', sortDir: 'asc' } as any)
.subscribe((res) => { .subscribe((res) => {
const tpes = res.data as TpeDevice[]; const tpes = res.content as TpeDevice[];
this.rebuildTpeMaps(tpes); 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 { ZardButtonComponent } from '@shared/components/button/button.component';
import { Course as CourseType } from 'src/app/core/interfaces/course'; import { Course as CourseType } from 'src/app/core/interfaces/course';
import { SortDir } from '@shared/paging/paging'; 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 { ResultatService } from 'src/app/core/services/resultat';
import { Resultat } from 'src/app/core/interfaces/resultat'; import { Resultat } from 'src/app/core/interfaces/resultat';
import { A11yModule } from '@angular/cdk/a11y'; import { A11yModule } from '@angular/cdk/a11y';
@@ -77,23 +77,19 @@ export class Course {
key: 'type', key: 'type',
label: 'Type', label: 'Type',
sortable: true, sortable: true,
cell: (c) => `<span class="font-medium">${c.type}</span>`, cell: (c) => `<span class="font-medium">${c.discipline}</span>`,
}, },
{ {
key: 'dateDepartCourse', key: 'dateDepartCourse',
label: 'Date et Heure Départ', label: 'Date et Heure Départ',
sortable: true, sortable: true,
cell: (c) => cell: (c) => c.heureDepartPrevue
new Date(c.dateDepartCourse).toLocaleDateString('fr-FR', {
hour: '2-digit',
minute: '2-digit',
}),
}, },
{ {
key: 'partants', key: 'partants',
label: 'Partants', label: 'Partants',
cell: (c) => 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 c.nonPartants?.length ?? 0
} NP)</span>`, } NP)</span>`,
}, },
@@ -148,20 +144,24 @@ export class Course {
sortable: true, sortable: true,
cell: (c) => { cell: (c) => {
const colorMap: Record<string, string> = { const colorMap: Record<string, string> = {
PROGRAMMEE: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300', OUVERT: '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', RESULTAT_PROVISOIRE: 'bg-cyan-100 text-purple-700 dark:bg-cyan-900/30 dark:text-cyan-300',
VALIDATED: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300', RESULTAT_OFFICIEL: 'bg-amber-100 text-purple-700 dark:bg-amber-900/30 dark:text-amber-300',
RUNNING: 'bg-amber-100 text-amber-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',
CLOSED: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300', VALIDE: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
CANCELED: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-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> = { const labelMap: Record<string, string> = {
PROGRAMMEE: 'Programmée', OUVERT: 'Ouvert',
CREATED: 'Créée', RESULTAT_PROVISOIRE: 'Résultat provisoire',
VALIDATED: 'Validée', RESULTAT_OFFICIEL: 'Résultat officiel',
RUNNING: 'En cours', BROUILLON: 'Brouillon',
CLOSED: 'Clôturée', VALIDE: 'Validé',
CANCELED: 'Annulée', 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]}">${ return `<span class="px-2 py-1 rounded-full text-xs font-semibold ${colorMap[c.statut]}">${
labelMap[c.statut] labelMap[c.statut]
@@ -171,31 +171,13 @@ export class Course {
{ {
key: 'reunion.hippodrome.nom', key: 'reunion.hippodrome.nom',
label: 'Hippodrome', label: 'Hippodrome',
cell: (c) => (c.reunion?.hippodrome ? `${c.reunion.hippodrome.nom}` : '—'), cell: (c) => (c?.hippodrome ? `${c.hippodrome.nom}` : '—'),
},
{
key: 'reunion.nom',
label: 'Réunion',
cell: (c) => c.reunion?.nom ?? '—',
}, },
{ {
key: 'distance', key: 'distance',
label: 'Distance (m)', label: 'Distance (m)',
sortable: true, sortable: true,
cell: (c) => c.distance.toLocaleString('fr-FR'), cell: (c) => c.distanceMetres.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',
})
: '—',
}, },
]; ];
@@ -224,14 +206,14 @@ export class Course {
this.loading.set(true); this.loading.set(true);
this.api.list(params).subscribe({ this.api.list(params).subscribe({
next: (res) => { next: (res) => {
this.rows.set(res.data); this.rows.set(res.content);
this.total.set(res.meta.total); this.total.set(res.totalElements);
this.totalRunning.set(res.meta['totalRunning'] ?? 0); this.totalRunning.set(0);
this.totalClosed.set(res.meta['totalClosed'] ?? 0); this.totalClosed.set(0);
this.totalByType.set(res.meta['totalByType'] ?? {}); this.totalByType.set({});
// Fetch resultats for all courses in parallel // 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) { if (courseIds.length > 0) {
const resultatRequests = courseIds.map((id) => const resultatRequests = courseIds.map((id) =>
this.resultatService.getByCourseId(id).pipe(catchError(() => of(undefined))) this.resultatService.getByCourseId(id).pipe(catchError(() => of(undefined)))
@@ -305,7 +287,7 @@ export class Course {
const current = this.editingItem(); const current = this.editingItem();
const req$ = current?.id const req$ = current?.id
? this.api.update(current.id, payload) ? this.api.update(current.id, payload)
: this.api.create(payload as Omit<CourseType, 'id'>); : this.api.create(payload as Omit<CourseApiResponse, 'id'>);
req$.subscribe(() => { req$.subscribe(() => {
this.closeModal(); this.closeModal();
@@ -412,7 +394,7 @@ export class Course {
return 3; // Default return 3; // Default
}; };
const requiredHorses = getRequiredHorses(c.type); const requiredHorses = 3;
// Collect all selected horses (flatten the places array) // Collect all selected horses (flatten the places array)
const allHorses: number[] = places const allHorses: number[] = places

View File

@@ -33,18 +33,6 @@
{{ averageByCountry() }} {{ averageByCountry() }}
</div> </div>
</z-card> </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> </div>
<app-search-bar placeholder="Rechercher (nom, ville, pays…)" (search)="onSearch($event)" /> <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: 'nom', label: 'Nom', sortable: true },
{ key: 'ville', label: 'Ville', sortable: true }, { key: 'ville', label: 'Ville', sortable: true },
{ key: 'pays', label: 'Pays', 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', key: 'capacite',
label: 'Capacité', label: 'Capacité',
@@ -124,26 +112,20 @@ export class Hippodrome {
}) })
.subscribe({ .subscribe({
next: (res) => { next: (res) => {
this.rows.set(res.data); const content = res.content;
this.rows.set(content);
const meta = res.meta ?? {}; this.total.set(res.pageable.total ?? 0);
this.uniqueCities.set(new Set(content.map(i=> i.ville)).size);
this.total.set(meta['total'] ?? 0); this.uniqueCountries.set(new Set(content.map(i=>i.pays)).size);
this.uniqueCities.set(meta['uniqueCities'] ?? 0); this.averageByCountry.set(0);
this.uniqueCountries.set(meta['uniqueCountries'] ?? 0);
this.averageByCountry.set(meta['averageByCountry'] ?? 0);
this.totalReunions.set(meta['totalReunions'] ?? 0);
this.totalCourses.set(meta['totalCourses'] ?? 0);
this.loading.set(false); this.loading.set(false);
}, },
error: () => { error: (err) => {
this.rows.set([]); this.rows.set([]);
this.total.set(0); this.total.set(0);
this.uniqueCities.set(0); this.uniqueCities.set(0);
this.uniqueCountries.set(0); this.uniqueCountries.set(0);
this.averageByCountry.set(0); this.averageByCountry.set(0);
this.totalReunions.set(0);
this.totalCourses.set(0);
this.loading.set(false); this.loading.set(false);
}, },
}); });

View File

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

View File

@@ -34,11 +34,11 @@
<span class="font-medium text-gray-900 dark:text-gray-100 truncate"> <span class="font-medium text-gray-900 dark:text-gray-100 truncate">
{{ c.nom }} {{ c.nom }}
</span> </span>
@if (c.type) { @if (c.discipline) {
<span <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" 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> </span>
} }
</div> </div>
@@ -61,19 +61,15 @@
></path> ></path>
</svg> </svg>
{{ {{
c.dateDepartCourse c.heureDepartPrevue
? (c.dateDepartCourse | date : 'short' : undefined : 'fr-FR')
: '—'
}} }}
</span> </span>
<span class="h-1 w-1 rounded-full bg-gray-400"></span> <span class="h-1 w-1 rounded-full bg-gray-400"></span>
<span class="font-medium"> <span class="font-medium">
{{ c.reunion.hippodrome.nom }} {{ c.nom }}
</span> </span>
<span class="h-1 w-1 rounded-full bg-gray-400"></span> <span class="h-1 w-1 rounded-full bg-gray-400"></span>
<span> Réunion {{ c.reunion.nom }} </span> <span> Distance {{ c.distanceMetres | number : '1.0-0' }} m </span>
<span class="h-1 w-1 rounded-full bg-gray-400"></span>
<span> Distance {{ c.distance | number : '1.0-0' }} m </span>
</div> </div>
<div <div
class="flex flex-wrap items-center gap-2 text-[11px] text-gray-600 dark:text-gray-300" 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" 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> ></path>
</svg> </svg>
{{ c.partants }} partant{{ c.partants > 1 ? 's' : '' }} {{ c.nombrePartants }} partant{{ c.nombrePartants > 1 ? 's' : '' }}
</span> </span>
@if (c.nonPartants && c.nonPartants.length > 0) { @if (c.nonPartants && c.nonPartants.length > 0) {
<span class="h-1 w-1 rounded-full bg-gray-400"></span> <span class="h-1 w-1 rounded-full bg-gray-400"></span>
<span class="text-orange-600 dark:text-orange-400"> <span class="text-orange-600 dark:text-orange-400">
{{ c.nonPartants.length }} non-partant{{ c.nonPartants.length > 1 ? 's' : '' }} {{ c.nonPartants.length }} non-partant{{ c.nonPartants.length > 1 ? 's' : '' }}
</span> </span>
} @if (c.condition) { } @if (c.numero) {
<span class="h-1 w-1 rounded-full bg-gray-400"></span> <span class="h-1 w-1 rounded-full bg-gray-400"></span>
<span class="italic">{{ c.condition }}</span> <span class="italic">{{ c.numero }}</span>
} @if (c.particularite) { } @if (c.discipline) {
<span class="h-1 w-1 rounded-full bg-gray-400"></span> <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>
</div> </div>

View File

@@ -223,26 +223,21 @@ export class Main {
// Include PROGRAMMEE courses that are scheduled within the next 24 hours // Include PROGRAMMEE courses that are scheduled within the next 24 hours
if (statut === 'PROGRAMMEE') { if (statut === 'PROGRAMMEE') {
const d = c.dateDepartCourse ? new Date(c.dateDepartCourse) : null; const d = c.heureDepartPrevue ? c.heureDepartPrevue : null;
if (!d) return false; if (!d) return false;
// Include if departure is in the past hour (just started) or within next 24 hours // 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) // Also include VALIDATED courses that are about to start (within next 24 hours)
if (statut === 'VALIDATED') { if (statut === 'VALIDATED') {
const d = c.dateDepartCourse ? new Date(c.dateDepartCourse) : null; const d = c.heureDepartPrevue ? c.heureDepartPrevue : null;
if (!d) return false; if (!d) return false;
return d >= now && d <= oneDayAhead; return d;
} }
return false; 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); .slice(0, 6);
this.liveCourses.set(live); this.liveCourses.set(live);

View File

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

View File

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

View File

@@ -153,13 +153,13 @@ export class ReunionList {
this.loading.set(true); this.loading.set(true);
this.api.list(params).subscribe({ this.api.list(params).subscribe({
next: (res) => { next: (res) => {
this.rows.set(res.data); this.rows.set(res.content);
this.total.set(res.meta.total); this.total.set(res.pageable.total);
const meta = res.meta; const meta = res.pageable;
this.upcomingReunions.set(res.meta['upcomingReunions'] ?? 0); this.upcomingReunions.set(0);
this.pastReunions.set(res.meta['pastReunions'] ?? 0); this.pastReunions.set(0);
this.uniqueHippodromes.set(res.meta['uniqueHippodromes'] ?? 0); this.uniqueHippodromes.set(0);
this.loading.set(false); this.loading.set(false);
}, },
error: () => { error: () => {

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,26 +19,28 @@
<div class="grid sm:grid-cols-3 gap-5"> <div class="grid sm:grid-cols-3 gap-5">
<z-form-field> <z-form-field>
<label z-form-label zRequired class="text-sm font-semibold text-gray-700 dark:text-gray-300" <label z-form-label zRequired for="hippodrome">Hippodrome</label>
>Type de course</label <z-form-control
[errorMessage]="isInvalid('hippodromeId') ? 'Veuillez sélectionner une réunion' : ''"
> >
<z-form-control> <z-select
<z-select formControlName="type" class="w-full"> id="hippodromeId"
@for (t of courseTypes; track t.value) { placeholder="Rechercher une réunion..."
<z-select-item [zValue]="t.value">{{ t.label }}</z-select-item> 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-select>
</z-form-control> </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>
<z-form-field> <z-form-field>
<label z-form-label zRequired class="text-sm font-semibold text-gray-700 dark:text-gray-300" <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> <z-form-control>
<input <input
@@ -46,16 +48,53 @@
type="number" type="number"
min="1" min="1"
placeholder="Ex: 3" 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" 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"> <p class="mt-1 text-sm text-red-500 italic">
{{ errorMessage('numero') }} {{ errorMessage('reunionNumero') }}
</p> </p>
} }
</z-form-control> </z-form-control>
</z-form-field> </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> <z-form-field>
<label z-form-label zRequired class="text-sm font-semibold text-gray-700 dark:text-gray-300" <label z-form-label zRequired class="text-sm font-semibold text-gray-700 dark:text-gray-300"
@@ -103,82 +142,22 @@
z-form-label z-form-label
class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2 block" 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> </label>
<div class="flex gap-3 items-center"> <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 <input
z-input z-input
type="time" type="time"
[value]="getTimePart('dateDepartCourse')" formControlName="heureDepartPrevu"
(change)="setTimePart('dateDepartCourse', $event.target.value)"
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" 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> </div>
@if(isInvalid('dateDepartCourse')) { @if(isInvalid('reunionDate') || isInvalid('heureDepartPrevue')) {
<p class="mt-1 text-sm text-red-500 italic"> <p class="mt-1 text-sm text-red-500 italic">
{{ errorMessage('dateDepartCourse') }} {{ errorMessage('reunionDate') || errorMessage('heureDepartPrevue') }}
</p> </p>
} }
</z-form-field> </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>
</div> </div>
@@ -197,29 +176,6 @@
</p> </p>
</div> </div>
</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> </div>
<!-- 🏇 SECTION 4 — Détails techniques --> <!-- 🏇 SECTION 4 — Détails techniques -->
@@ -239,15 +195,18 @@
<div class="grid sm:grid-cols-1 lg:grid-cols-2 gap-5"> <div class="grid sm:grid-cols-1 lg:grid-cols-2 gap-5">
<z-form-field> <z-form-field>
<label z-form-label class="text-sm font-semibold text-gray-700 dark:text-gray-300" <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> <z-form-control>
<input <z-select
z-input
placeholder="Ex: Handicap, Trot attelé..." formControlName="typeParisOuverts"
formControlName="particularite" class="w-full">
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" @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-control>
</z-form-field> </z-form-field>
@@ -257,14 +216,14 @@
> >
<z-form-control <z-form-control
[errorMessage]=" [errorMessage]="
isInvalid('partants') ? errorMessage('partants') || 'Ce champ est obligatoire' : '' isInvalid('nombrePartants') ? errorMessage('nombrePartants') || 'Ce champ est obligatoire' : ''
" "
> >
<input <input
z-input z-input
type="number" type="number"
min="1" 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" 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> </z-form-control>
@@ -279,7 +238,7 @@
z-input z-input
type="number" type="number"
min="1" 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" 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> </z-form-control>
@@ -287,13 +246,13 @@
<z-form-field> <z-form-field>
<label z-form-label class="text-sm font-semibold text-gray-700 dark:text-gray-300" <label z-form-label class="text-sm font-semibold text-gray-700 dark:text-gray-300"
>Condition</label >Catégorie</label
> >
<z-form-control> <z-form-control>
<input <input
z-input z-input
placeholder="Âge, catégorie..." 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" 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> </z-form-control>
@@ -330,14 +289,14 @@
<z-form-field> <z-form-field>
<label z-form-label class="text-sm font-semibold text-gray-700 dark:text-gray-300" <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> <z-form-control>
<input <input
z-input z-input
type="number" type="number"
min="1" min="1"
formControlName="reunionCourse" formControlName="numero"
placeholder="Ex: 2" 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" 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 { ZardInputDirective } from '@shared/components/input/input.directive';
import { ZardSelectItemComponent } from '@shared/components/select/select-item.component'; import { ZardSelectItemComponent } from '@shared/components/select/select-item.component';
import { ZardSelectComponent } from '@shared/components/select/select.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 { 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({ @Component({
selector: 'app-course-form', selector: 'app-course-form',
standalone: true, standalone: true,
@@ -38,6 +40,7 @@ import { Subscription } from 'rxjs';
CommonModule, CommonModule,
ReactiveFormsModule, ReactiveFormsModule,
ZardFormModule, ZardFormModule,
ZardInputDirective, ZardInputDirective,
ZardSelectComponent, ZardSelectComponent,
ZardSelectItemComponent, ZardSelectItemComponent,
@@ -45,403 +48,233 @@ import { Subscription } from 'rxjs';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class CourseForm implements OnInit, AfterViewInit, OnDestroy { export class CourseForm implements OnInit, AfterViewInit, OnDestroy {
@Output() save = new EventEmitter<Course>(); @Output() save = new EventEmitter<Partial<CourseApiResponse>>();
@Output() cancel = new EventEmitter<void>(); @Output() cancel = new EventEmitter<void>();
private _value?: Course; @Input() value?: Course;
@Input() set value(v: Course | undefined) {
this._value = v;
}
get value(): Course | undefined {
return this._value;
}
form: FormGroup; form: FormGroup;
submitted = false; submitted = false;
reunions = signal<Reunion[]>([]); hippodromes = signal<Hippodrome[]>([]);
loadingReunions = signal(false); loadingHippodromes = signal(false);
searchQuery = signal<string>(''); searchQuery = signal('');
selectedReunionLabel = signal<string>(''); selectedHippodromeLabel = signal('');
initializing = signal(false);
private subs = new Subscription(); private subs = new Subscription();
constructor( constructor(
private fb: FormBuilder, private fb: FormBuilder,
private reunionService: ReunionService, private courseServive: CourseService,
private hippodromeService: HippodromeService,
private cdr: ChangeDetectorRef private cdr: ChangeDetectorRef
) { ) {
this.form = this.fb.group({ this.form = this.fb.group({
type: ['', Validators.required], hippodromeId: new FormControl<string | null>(null, Validators.required),
numero: [null, [Validators.required, Validators.min(1)]], reunionNumero: [null, Validators.required],
nom: ['', [Validators.required, Validators.minLength(3)]], reunionDate: ['', Validators.required],
dateDepartCourse: ['', Validators.required], nom: ['', Validators.required],
dateDebutParis: ['', Validators.required], numero: [null, Validators.required],
dateFinParis: ['', Validators.required], statut: [CourseStatut.BROUILLON, Validators.required],
reunionId: new FormControl<string | null>(null, Validators.required), heureDepartPrevu: ['', Validators.required],
reunionCourse: [1], discipline: ['', Validators.required],
particularite: [''], distanceMetres: [null, Validators.required],
partants: [null, [Validators.required, Validators.min(1)]], nombrePartants: [null, Validators.required],
distance: [''], annulee: [false],
condition: [''], categorie: ['', Validators.required],
statut: [CourseStatut.PROGRAMMEE, Validators.required], reporteeMemeJour: [false],
reporteeAutreJour: [false],
incidentTechnique: [false],
nonPartants: [[]],
typesParisOuverts: [''],
createdBy: ['agent-001'], createdBy: ['agent-001'],
validatedBy: [''], validatedBy: [''],
}); });
// Watch for reunionId changes to update the label /* =============================
// This handles BOTH create and edit modes when user selects a reunion FIX 1 — Gestion enable/disable
this.subs.add( (cause principale du select bloqué)
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
effect(() => { effect(() => {
const v = this.value; const loading = this.loadingHippodromes();
const reunionsList = this.reunions(); const control = this.form.get('hippodromeId');
const isLoading = this.loadingReunions();
// CRITICAL: Enable/disable reunion control based on loading state if (loading) {
// This is the most important part for create mode to work control?.disable({ emitEvent: false });
const reunionControl = this.form.get('reunionId');
if (isLoading) {
reunionControl?.disable({ emitEvent: false });
} else { } else {
reunionControl?.enable({ emitEvent: false }); control?.enable({ emitEvent: false });
// Force change detection after enabling to ensure the select is clickable
this.cdr.markForCheck(); this.cdr.markForCheck();
} }
// Handle edit mode (value is defined) - populate form with course data // Edit mode : appliquer la valeur APRÈS chargement
if (v !== null && v !== undefined) { if (this.value && this.hippodromes().length) {
this.initializing.set(true); const id = String(this.value.hippodrome?.id ?? '');
if (id) {
this.form.patchValue({ hippodromeId: id }, { emitEvent: false });
let reunionId: string | null = null; const h = this.hippodromes().find(r => String(r.id) === id);
let reunionLabel: string = ''; if (h) {
if (v.reunion?.id) { this.selectedHippodromeLabel.set(`${h.nom} (${h.ville})`);
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('');
}
} 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('');
}
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 }
);
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
// 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);
} }
} }
} }
}); });
/* =============================
FIX 2 — valueChanges propre
============================== */
this.subs.add(
this.form.get('hippodromeId')!.valueChanges.subscribe((id) => {
if (!id) {
this.selectedHippodromeLabel.set('');
return;
} }
private hydrateFromValue(v?: Course) { const h = this.hippodromes().find(r => String(r.id) === String(id));
if (v) { if (h) {
const patch = { this.selectedHippodromeLabel.set(`${h.nom} (${h.ville})`);
type: v.type ?? '', this.cdr.markForCheck();
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();
} }
} })
isInvalid(control: string): boolean {
const ctrl = this.form.get(control);
return !!(ctrl && ctrl.invalid && (ctrl.touched || this.submitted));
}
errorMessage(control: string): string | null {
const c = this.form.get(control);
if (!c || !c.errors) return null;
if (c.errors['required']) return 'Ce champ est obligatoire';
if (c.errors['minlength']) return `Minimum ${c.errors['minlength'].requiredLength} caractères`;
if (c.errors['min']) return `Valeur minimale : ${c.errors['min'].min}`;
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()); FIX 3 — vrai filter
const [h, m] = time.split(':').map(Number); ============================== */
current.setHours(h, m); filteredHippodromes = computed(() => {
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(); const q = this.searchQuery().toLowerCase();
return this.reunions().filter( if (!q) return this.hippodromes();
(r) =>
r.nom.toLowerCase().includes(q) || return this.hippodromes().filter(h =>
r.hippodrome.nom.toLowerCase().includes(q) || h.nom.toLowerCase().includes(q) ||
r.hippodrome.ville.toLowerCase().includes(q) h.ville.toLowerCase().includes(q) ||
(h as any).pays?.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() { ngOnInit() {
// Fetch reunions from API this.loadingHippodromes.set(true);
this.loadingReunions.set(true);
this.subs.add( this.subs.add(
this.reunionService.list({ page: 1, perPage: 1000 }, false).subscribe({ this.hippodromeService
next: (result) => { .list({ page: 1, perPage: 1000 }, true)
this.reunions.set(result.data); .subscribe({
this.loadingReunions.set(false); next: (res) => {
// Force enable the control after loading this.hippodromes.set(res.content);
setTimeout(() => { this.loadingHippodromes.set(false);
const reunionControl = this.form.get('reunionId');
reunionControl?.enable({ emitEvent: false });
this.cdr.markForCheck();
}, 100);
}, },
error: (err) => { error: () => {
console.error('Error loading reunions:', err); this.loadingHippodromes.set(false);
this.loadingReunions.set(false);
}, },
}) })
); );
} }
ngAfterViewInit() { ngAfterViewInit() {
// After view is initialized, ensure the reunion value and label are set correctly this.cdr.markForCheck();
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) { isInvalid(control: string): boolean {
// Immediately update the label when user selects a reunion const c = this.form.get(control);
if (value && this.reunions().length > 0) { return !!(c && c.invalid && (c.touched || this.submitted));
const matchingReunion = this.reunions().find( }
(r) => String(r.id) === String(value) || r.id === value
); onSubmit() {
if (matchingReunion) { this.submitted = true;
const reunionLabel = `${matchingReunion.nom} ${matchingReunion.hippodrome.nom} (${matchingReunion.hippodrome.ville})`;
this.selectedReunionLabel.set(reunionLabel); if (this.form.invalid) {
this.cdr.markForCheck(); 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 { } 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;
if (c.errors['required']) return 'Ce champ est obligatoire';
if (c.errors['minlength']) return `Minimum ${c.errors['minlength'].requiredLength} caractères`;
if (c.errors['min']) return `Valeur minimale : ${c.errors['min'].min}`;
return null;
}
ngOnDestroy() { ngOnDestroy() {
this.subs.unsubscribe(); 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() { private seedFromCourse() {
const max = this.course?.partants ?? 0; const max = this.course?.nombrePartants ?? 0;
this.partantsMax.set(max); this.partantsMax.set(max);
const arr = new FormArray<FormGroup<NonPartantRow>>([]); const arr = new FormArray<FormGroup<NonPartantRow>>([]);

View File

@@ -4,7 +4,7 @@
<div> <div>
<h3 class="font-semibold text-sm">{{ course.nom }}</h3> <h3 class="font-semibold text-sm">{{ course.nom }}</h3>
<p class="text-xs text-gray-500"> <p class="text-xs text-gray-500">
{{ course.reunion.nom }} • {{ course.reunion.hippodrome.nom }} {{ course.nom }}
</p> </p>
</div> </div>
<span class="text-xs px-2 py-1 rounded bg-blue-100 text-blue-700"> <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(() => 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 ?? [])); npSet = computed(() => new Set(this.course?.nonPartants ?? []));
statut = computed((): 'CREATED' | 'VALIDATED' | 'NONE' => { statut = computed((): 'CREATED' | 'VALIDATED' | 'NONE' => {
return this.resultat ? 'CREATED' : 'NONE'; return this.resultat ? 'CREATED' : 'NONE';

View File

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

View File

@@ -63,7 +63,7 @@ export class UserForm {
this.roleService this.roleService
.list({ page: 1, perPage: 100, search: '', sortKey: 'name', sortDir: 'asc' } as any) .list({ page: 1, perPage: 100, search: '', sortKey: 'name', sortDir: 'asc' } as any)
.subscribe((res) => { .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> { export function normalizePage<T>(raw: any, reqPage: number, perPage: number): PagedResult<T> {
// 🟩 Case 1 — Spring Data style: { content, totalElements, number, size } // 🟩 Case 1 — Spring Data style: { content, totalElements, number, size }
if (raw && Array.isArray(raw.content) && typeof raw.totalElements === 'number') { if (raw && Array.isArray(raw.content) && typeof raw.totalElements === 'number') {
return { return raw;
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,
};
} }
// 🟩 Case 2 — API or local mock: { data, meta: { total, ...extra } } // 🟩 Case 2 — API or local mock: { data, meta: { total, ...extra } }
if (raw && Array.isArray(raw.data) && raw.meta?.total != null) { if (raw && Array.isArray(raw.content) && raw.pageable?.total != null) {
return { return raw
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,
};
} }
// 🟩 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) // 🟩 Case 4 — Direct array (no meta)
if (Array.isArray(raw)) { if (Array.isArray(raw)) {
const start = (reqPage - 1) * perPage; const start = (reqPage - 1) * perPage;
const data = (raw as T[]).slice(start, start + perPage); const data = (raw as T[]).slice(start, start + perPage);
return { return {
data, content: data,
meta: { pageable:{
page: reqPage, pageNumber: raw.length/perPage,
perPage, pageSize: perPage,
total: (raw as T[]).length, total: raw.length
} as PageMeta, },
totalPages: raw.length/perPage,
totalElements: raw.length
}; };
} }
// 🟩 Fallback — ensure consistency even with minimal info // 🟩 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; const total = typeof raw?.total === 'number' ? raw.total : data.length ?? 0;
return { return raw;
data,
meta: {
page: raw?.meta?.page ?? reqPage,
perPage: raw?.meta?.perPage ?? perPage,
total,
...raw?.meta, // merge additional stats safely
} as PageMeta,
};
} }

View File

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

View File

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