first step for plr game platform

This commit is contained in:
OnlyPapy98
2025-12-29 13:56:18 +01:00
parent 169b5ca412
commit ed79cae77d
40 changed files with 620 additions and 373 deletions

View File

@@ -1,5 +1,13 @@
import { Course } from './course'; import { Course } from './course';
export enum ResultatStatut {
PROVISOIRE,
OFFICIEL,
ANNULE,
EN_ATTENTE
}
export interface Resultat { export interface Resultat {
id: string; id: string;
course: Course; course: Course;
@@ -8,7 +16,7 @@ export interface Resultat {
* The backend returns an array of strings/numbers (cheval numbers); * The backend returns an array of strings/numbers (cheval numbers);
* in the UI we normalize them to plain numbers. * in the UI we normalize them to plain numbers.
*/ */
ordreArrivee: number[]; ordreArrivee: string;
/** /**
* Chevaux en dead-heat (ex aequo), represented by their numbers. * Chevaux en dead-heat (ex aequo), represented by their numbers.
*/ */
@@ -26,33 +34,24 @@ export interface Resultat {
// API response structure (course may be just an ID in some cases) // API response structure (course may be just an ID in some cases)
export interface ResultatApiResponse { export interface ResultatApiResponse {
id: string | number; id: string | number;
course: Course | string | number; courseId: string | number;
/** ordreArrivee: string;
* In the raw API this is an array of strings/numbers. courseNom: string;
*/ courseNumero: number;
ordreArrivee: (string | number)[]; reunionNumero:number;
chevauxDeadHeat: (string | number)[]; hippodromeNom: string;
totalMises: number; statut: ResultatStatut;
masseAPartager: number; datePublication?: string;
prelevementsLegaux: number; dateAnnulation?: string;
montantRembourse: number; dateValidation?: string;
montantCagnotte: number;
adeadHeat: boolean;
createdAt?: string; createdAt?: string;
updatedAt?: string; updatedAt?: string;
} }
// POST payload structure // POST payload structure
export interface CreateResultatPayload { export interface CreateResultatPayload {
course: { courseId: number;
id: string | number; statut: ResultatStatut;
}; ordreArrivee: string;
ordreArrivee: string[]; notes?: string
chevauxDeadHeat?: (string | number)[];
totalMises?: number;
masseAPartager?: number;
prelevementsLegaux?: number;
montantRembourse?: number;
montantCagnotte?: number;
adeadHeat?: boolean;
} }

View File

@@ -108,7 +108,7 @@ export class AgentLimitService {
let httpParams = new HttpParams(); let httpParams = new HttpParams();
if (params) { if (params) {
if (params.page) httpParams = httpParams.set('page', params.page.toString()); if (params.page) httpParams = httpParams.set('page', params.page.toString());
if (params.perPage) httpParams = httpParams.set('perPage', params.perPage.toString()); if (params.size) httpParams = httpParams.set('size', params.size.toString());
if (params.search) httpParams = httpParams.set('search', params.search); if (params.search) httpParams = httpParams.set('search', params.search);
if (params.sortKey) httpParams = httpParams.set('sortKey', params.sortKey); if (params.sortKey) httpParams = httpParams.set('sortKey', params.sortKey);
if (params.sortDir) httpParams = httpParams.set('sortDir', params.sortDir); if (params.sortDir) httpParams = httpParams.set('sortDir', params.sortDir);
@@ -127,7 +127,7 @@ export class AgentLimitService {
return normalizePage<AgentLimit>( return normalizePage<AgentLimit>(
{ data: limits, meta: { total: limits.length } }, { data: limits, meta: { total: limits.length } },
params.page || 1, params.page || 1,
params.perPage || 10 params.size || 10
); );
} }
// Otherwise return all as single page // Otherwise return all as single page
@@ -207,7 +207,7 @@ export class AgentLimitService {
// First, find the previous default limit // First, find the previous default limit
return this.list({ return this.list({
page: 1, page: 1,
perPage: 1000, size: 1000,
search: '', search: '',
sortKey: 'code', sortKey: 'code',
sortDir: 'asc', sortDir: 'asc',

View File

@@ -274,7 +274,7 @@ export class AgentService {
let httpParams = new HttpParams(); let httpParams = new HttpParams();
if (params) { if (params) {
if (params.page) httpParams = httpParams.set('page', params.page.toString()); if (params.page) httpParams = httpParams.set('page', params.page.toString());
if (params.perPage) httpParams = httpParams.set('perPage', params.perPage.toString()); if (params.size) httpParams = httpParams.set('perPage', params.size.toString());
if (params.search) httpParams = httpParams.set('search', params.search); if (params.search) httpParams = httpParams.set('search', params.search);
if (params.sortKey) httpParams = httpParams.set('sortKey', params.sortKey); if (params.sortKey) httpParams = httpParams.set('sortKey', params.sortKey);
if (params.sortDir) httpParams = httpParams.set('sortDir', params.sortDir); if (params.sortDir) httpParams = httpParams.set('sortDir', params.sortDir);
@@ -296,7 +296,7 @@ export class AgentService {
return normalizePage<Agent>( return normalizePage<Agent>(
{ data: agents, meta: { total: agents.length } }, { data: agents, meta: { total: agents.length } },
params.page || 1, params.page || 1,
params.perPage || 10 params.size || 10
); );
} }
// Otherwise return all as single page // Otherwise return all as single page
@@ -445,7 +445,7 @@ export class AgentService {
// Get all agents first // Get all agents first
return this.list({ return this.list({
page: 1, page: 1,
perPage: 10000, size: 10000,
search: '', search: '',
sortKey: 'code', sortKey: 'code',
sortDir: 'asc', sortDir: 'asc',

View File

@@ -59,7 +59,7 @@ export class CourseService {
return isNgrok ? { 'ngrok-skip-browser-warning': 'true' } : {}; return isNgrok ? { 'ngrok-skip-browser-warning': 'true' } : {};
} }
list(params: ListParams, usePaginationEndpoint: boolean = true): Observable<PagedResult<Course>> { list(params: ListParams): Observable<PagedResult<Course>> {
const coursesList = this.http.get<PagedResult<CourseApiResponse>>(this.apiUrl, { const coursesList = this.http.get<PagedResult<CourseApiResponse>>(this.apiUrl, {
headers: this.getNgrokHeaders(), headers: this.getNgrokHeaders(),
params: this.servivesUtil.getParamsFromModel(params), params: this.servivesUtil.getParamsFromModel(params),

View File

@@ -1,6 +1,8 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Observable, of, forkJoin } from 'rxjs'; import { Observable, of, forkJoin } from 'rxjs';
import { PaginatedHttpService } from '@shared/paging/paginated-http.service';
import { ListParams, PagedResult } from '@shared/paging/paging';
import { map, catchError, switchMap } from 'rxjs/operators'; import { map, catchError, switchMap } from 'rxjs/operators';
import { Resultat, ResultatApiResponse, CreateResultatPayload } from '../interfaces/resultat'; import { Resultat, ResultatApiResponse, CreateResultatPayload } from '../interfaces/resultat';
import { Course } from '../interfaces/course'; import { Course } from '../interfaces/course';
@@ -8,13 +10,20 @@ import { CourseService } from './course';
import { environment } from 'src/environments/environment.development'; import { environment } from 'src/environments/environment.development';
const USE_SERVER = true; const USE_SERVER = true;
const API_BASE = '/api/v1/resultat'; const API_BASE = '/api/resultats';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class ResultatService { export class ResultatService {
private apiUrl = environment.apiBaseUrl + API_BASE; private apiUrl = environment.apiBaseUrl + API_BASE;
constructor(private http: HttpClient, private courseService: CourseService) {} constructor(private http: HttpClient, private courseService: CourseService, private pager: PaginatedHttpService) {}
// Fetch raw paginated resultats from the backend and normalize paging
listRawPaged(params: ListParams): Observable<PagedResult<ResultatApiResponse>> {
const url = this.apiUrl;
// Delegate to shared paginated HTTP helper (Spring-style defaults)
return this.pager.fetch<ResultatApiResponse>(url, params);
}
private getNgrokHeaders(): Record<string, string> { private getNgrokHeaders(): Record<string, string> {
const isNgrok = const isNgrok =
@@ -31,11 +40,8 @@ export class ResultatService {
.get<ResultatApiResponse>(`${this.apiUrl}/${id}`, { headers: this.getNgrokHeaders() }) .get<ResultatApiResponse>(`${this.apiUrl}/${id}`, { headers: this.getNgrokHeaders() })
.pipe( .pipe(
switchMap((apiResultat) => { switchMap((apiResultat) => {
// Fetch the full course object if course is just an ID // New API uses `courseId` explicitly
const courseId = const courseId = String((apiResultat as any).courseId ?? '');
typeof apiResultat.course === 'object' && 'id' in apiResultat.course
? String(apiResultat.course.id)
: String(apiResultat.course);
return this.courseService.getById(courseId).pipe( return this.courseService.getById(courseId).pipe(
map((course) => { map((course) => {
@@ -62,16 +68,8 @@ export class ResultatService {
.get<ResultatApiResponse[]>(this.apiUrl, { headers: this.getNgrokHeaders() }) .get<ResultatApiResponse[]>(this.apiUrl, { headers: this.getNgrokHeaders() })
.pipe( .pipe(
switchMap((apiResultats) => { switchMap((apiResultats) => {
// Fetch all unique course IDs // Fetch all unique course IDs (API uses courseId)
const courseIds = [ const courseIds = [...new Set(apiResultats.map((r) => String((r as any).courseId ?? '')))].filter(Boolean);
...new Set(
apiResultats.map((r) =>
typeof r.course === 'object' && 'id' in r.course
? String(r.course.id)
: String(r.course)
)
),
];
// Fetch all courses in parallel // Fetch all courses in parallel
const courseRequests = courseIds.map((id) => const courseRequests = courseIds.map((id) =>
this.courseService this.courseService
@@ -91,10 +89,7 @@ export class ResultatService {
return apiResultats return apiResultats
.map((apiResultat) => { .map((apiResultat) => {
const courseId = const courseId = String((apiResultat as any).courseId ?? '');
typeof apiResultat.course === 'object' && 'id' in apiResultat.course
? String(apiResultat.course.id)
: String(apiResultat.course);
const course = courseMap.get(courseId); const course = courseMap.get(courseId);
if (!course) { if (!course) {
return null; return null;
@@ -118,11 +113,23 @@ export class ResultatService {
return of([]); return of([]);
} }
// GET raw API responses (ResultatApiResponse[])
listRaw(): Observable<ResultatApiResponse[]> {
if (USE_SERVER) {
return this.http
.get<ResultatApiResponse[]>(this.apiUrl, { headers: this.getNgrokHeaders() })
.pipe(
catchError((err) => {
console.error('Error fetching raw resultats:', err);
return of([] as ResultatApiResponse[]);
})
);
}
return of([] as ResultatApiResponse[]);
}
// GET /api/v1/resultat/course/{courseId} // GET /api/v1/resultat/course/{courseId}
getByCourseId(courseId: string): Observable<Resultat | undefined> { getByCourseId(courseId: string): Observable<Resultat | undefined> {
if (!USE_SERVER) {
return of(undefined);
}
return this.http return this.http
.get<any>(`${this.apiUrl}/course/${courseId}`, { .get<any>(`${this.apiUrl}/course/${courseId}`, {
@@ -169,12 +176,11 @@ export class ResultatService {
// POST /api/v1/resultat // POST /api/v1/resultat
create(payload: CreateResultatPayload): Observable<Resultat> { create(payload: CreateResultatPayload): Observable<Resultat> {
if (USE_SERVER) {
return this.http return this.http
.post<ResultatApiResponse>(this.apiUrl, payload, { headers: this.getNgrokHeaders() }) .post<ResultatApiResponse>(this.apiUrl, payload, { headers: this.getNgrokHeaders() })
.pipe( .pipe(
switchMap((apiResultat) => { switchMap((apiResultat) => {
const courseId = String(payload.course.id); const courseId = String(payload.courseId);
return this.courseService.getById(courseId).pipe( return this.courseService.getById(courseId).pipe(
map((course) => { map((course) => {
if (!course) { if (!course) {
@@ -190,8 +196,6 @@ export class ResultatService {
}) })
); );
} }
throw new Error('Server mode is required');
}
// PUT /api/v1/resultat/{id} // PUT /api/v1/resultat/{id}
update(id: string, payload: Partial<CreateResultatPayload>): Observable<Resultat | undefined> { update(id: string, payload: Partial<CreateResultatPayload>): Observable<Resultat | undefined> {
@@ -202,10 +206,7 @@ export class ResultatService {
}) })
.pipe( .pipe(
switchMap((apiResultat) => { switchMap((apiResultat) => {
const courseId = const courseId = String((apiResultat as any).courseId ?? '');
typeof apiResultat.course === 'object' && 'id' in apiResultat.course
? String(apiResultat.course.id)
: String(apiResultat.course);
return this.courseService.getById(courseId).pipe( return this.courseService.getById(courseId).pipe(
map((course) => { map((course) => {
@@ -261,20 +262,17 @@ export class ResultatService {
return { return {
id: String(apiResultat.id), id: String(apiResultat.id),
course, course,
// Normalize ordreArrivee to an array of cheval numbers // API now returns 'ordreArrivee' as CSV/string; normalize to number[]
ordreArrivee: (apiResultat.ordreArrivee || []) ordreArrivee: apiResultat.ordreArrivee,
.map((v) => (typeof v === 'string' ? Number(v) : v)) // dead-heat not provided by new API shape — default to empty
.filter((v): v is number => typeof v === 'number' && !Number.isNaN(v)), chevauxDeadHeat: [],
// Normalize dead-heat horses to numbers as well // Financial fields may not be present in new API; default to 0
chevauxDeadHeat: (apiResultat.chevauxDeadHeat || []) totalMises: (apiResultat as any).totalMises ?? 0,
.map((v) => (typeof v === 'string' ? Number(v) : v)) masseAPartager: (apiResultat as any).masseAPartager ?? 0,
.filter((v): v is number => typeof v === 'number' && !Number.isNaN(v)), prelevementsLegaux: (apiResultat as any).prelevementsLegaux ?? 0,
totalMises: apiResultat.totalMises, montantRembourse: (apiResultat as any).montantRembourse ?? 0,
masseAPartager: apiResultat.masseAPartager, montantCagnotte: (apiResultat as any).montantCagnotte ?? 0,
prelevementsLegaux: apiResultat.prelevementsLegaux, adeadHeat: (apiResultat as any).adeadHeat ?? false,
montantRembourse: apiResultat.montantRembourse,
montantCagnotte: apiResultat.montantCagnotte,
adeadHeat: apiResultat.adeadHeat,
createdAt: apiResultat.createdAt, createdAt: apiResultat.createdAt,
updatedAt: apiResultat.updatedAt, updatedAt: apiResultat.updatedAt,
}; };

View File

@@ -180,7 +180,7 @@ export class ReunionService {
meta: { total: 0, uniqueHippodromes: 0, upcomingReunions: 0, pastReunions: 0 }, meta: { total: 0, uniqueHippodromes: 0, upcomingReunions: 0, pastReunions: 0 },
}, },
params.page, params.page,
params.perPage params.size
) )
); );
} }
@@ -197,7 +197,7 @@ export class ReunionService {
meta: { total: 0, uniqueHippodromes: 0, upcomingReunions: 0, pastReunions: 0 }, meta: { total: 0, uniqueHippodromes: 0, upcomingReunions: 0, pastReunions: 0 },
}, },
params.page, params.page,
params.perPage params.size
) )
); );
} }
@@ -273,8 +273,8 @@ export class ReunionService {
// Apply client-side filtering, sorting, and pagination // Apply client-side filtering, sorting, and pagination
let filtered = this.applyClientFilters(transformedData, params); let filtered = this.applyClientFilters(transformedData, params);
const total = filtered.length; const total = filtered.length;
const start = (params.page - 1) * params.perPage; const start = (params.page - 1) * params.size;
const pageData = filtered.slice(start, start + params.perPage); const pageData = filtered.slice(start, start + params.size);
const upcomingReunions = filtered.filter( const upcomingReunions = filtered.filter(
(r) => new Date(r.date) >= new Date() (r) => new Date(r.date) >= new Date()
@@ -288,7 +288,7 @@ export class ReunionService {
meta: { total, uniqueHippodromes, upcomingReunions, pastReunions }, meta: { total, uniqueHippodromes, upcomingReunions, pastReunions },
}, },
params.page, params.page,
params.perPage params.size
); );
}) })
); );
@@ -359,8 +359,8 @@ export class ReunionService {
}); });
} }
const start = (params.page - 1) * params.perPage; const start = (params.page - 1) * params.size;
const pageData = data.slice(start, start + params.perPage); const pageData = data.slice(start, start + params.size);
const upcomingReunions = data.filter((r) => new Date(r.date) >= new Date()).length; const upcomingReunions = data.filter((r) => new Date(r.date) >= new Date()).length;
const pastReunions = data.filter((r) => new Date(r.date) < new Date()).length; const pastReunions = data.filter((r) => new Date(r.date) < new Date()).length;
@@ -373,7 +373,7 @@ export class ReunionService {
meta: { total: data.length, uniqueHippodromes, upcomingReunions, pastReunions }, meta: { total: data.length, uniqueHippodromes, upcomingReunions, pastReunions },
}, },
params.page, params.page,
params.perPage params.size
) )
); );
} }

View File

@@ -91,7 +91,7 @@ export class RoleService {
private buildParams(params: ListParams): HttpParams { private buildParams(params: ListParams): HttpParams {
let httpParams = new HttpParams() let httpParams = new HttpParams()
.set('page', String(params.page - 1)) .set('page', String(params.page - 1))
.set('size', String(params.perPage)); .set('size', String(params.size));
if (params.search) { if (params.search) {
httpParams = httpParams.set('search', params.search); httpParams = httpParams.set('search', params.search);
} }
@@ -117,13 +117,13 @@ export class RoleService {
return normalizePage<Role>( return normalizePage<Role>(
{ data: roles, meta: { total: roles.length } }, { data: roles, meta: { total: roles.length } },
params.page, params.page,
params.perPage params.size
); );
}), }),
catchError((err) => { catchError((err) => {
console.error('Error fetching roles:', err); console.error('Error fetching roles:', err);
return of( return of(
normalizePage<Role>({ data: [], meta: { total: 0 } }, params.page, params.perPage) normalizePage<Role>({ data: [], meta: { total: 0 } }, params.page, params.size)
); );
}) })
); );
@@ -137,7 +137,7 @@ export class RoleService {
meta: { total: 0 }, meta: { total: 0 },
}, },
params.page, params.page,
params.perPage params.size
) )
); );
} }

View File

@@ -8,7 +8,7 @@ export class ServicesUtils{
let httpParams = new HttpParams(); let httpParams = new HttpParams();
Object.entries(params).forEach(([key, value])=>{ Object.entries(params).forEach(([key, value])=>{
if(params != null && params!=undefined){ if(params != null && params!=undefined){
httpParams.set(key, String(value)) httpParams = httpParams.set(key, String(value))
} }
}) })
return httpParams; return httpParams;

View File

@@ -211,7 +211,7 @@ export class TpeService {
let httpParams = new HttpParams(); let httpParams = new HttpParams();
if (params) { if (params) {
if (params.page) httpParams = httpParams.set('page', params.page.toString()); if (params.page) httpParams = httpParams.set('page', params.page.toString());
if (params.perPage) httpParams = httpParams.set('perPage', params.perPage.toString()); if (params.size) httpParams = httpParams.set('perPage', params.size.toString());
if (params.search) httpParams = httpParams.set('search', params.search); if (params.search) httpParams = httpParams.set('search', params.search);
if (params.sortKey) httpParams = httpParams.set('sortKey', params.sortKey); if (params.sortKey) httpParams = httpParams.set('sortKey', params.sortKey);
if (params.sortDir) httpParams = httpParams.set('sortDir', params.sortDir); if (params.sortDir) httpParams = httpParams.set('sortDir', params.sortDir);
@@ -230,7 +230,7 @@ export class TpeService {
return normalizePage<TpeDevice>( return normalizePage<TpeDevice>(
{ data: tpes, meta: { total: tpes.length } }, { data: tpes, meta: { total: tpes.length } },
params.page || 1, params.page || 1,
params.perPage || 10 params.size || 10
); );
} }
// Otherwise return all as single page // Otherwise return all as single page

View File

@@ -117,23 +117,23 @@ export class UserService {
}); });
} }
const start = (params.page - 1) * params.perPage; const start = (params.page - 1) * params.size;
const pageData = data.slice(start, start + params.perPage); const pageData = data.slice(start, start + params.size);
return normalizePage<User>( return normalizePage<User>(
{ data: pageData, meta: { total: data.length } }, { data: pageData, meta: { total: data.length } },
params.page, params.page,
params.perPage params.size
); );
}), }),
catchError(() => catchError(() =>
of(normalizePage<User>({ data: [], meta: { total: 0 } }, params.page, params.perPage)) of(normalizePage<User>({ data: [], meta: { total: 0 } }, params.page, params.size))
) )
); );
} }
// Fallback should not be used anymore // Fallback should not be used anymore
return of(normalizePage<User>({ data: [], meta: { total: 0 } }, params.page, params.perPage)); return of(normalizePage<User>({ data: [], meta: { total: 0 } }, params.page, params.size));
} }
create(payload: Omit<User, 'id'>): Observable<User> { create(payload: Omit<User, 'id'>): Observable<User> {

View File

@@ -21,6 +21,10 @@ const routes: Routes = [
path: 'reunions', path: 'reunions',
loadComponent: () => import('./pages/reunion/reunion').then((m) => m.ReunionList), loadComponent: () => import('./pages/reunion/reunion').then((m) => m.ReunionList),
}, },
{
path: 'rapport',
loadComponent: () => import('./pages/rapport/rapport').then((m) => m.Rapport),
},
{ {
path: 'users', path: 'users',
loadComponent: () => import('./pages/users/users').then((m) => m.UsersPage), loadComponent: () => import('./pages/users/users').then((m) => m.UsersPage),

View File

@@ -46,7 +46,7 @@ export class Layout {
{ icon: '🏟️', label: 'Hippodromes', link: '/hippodromes' }, { icon: '🏟️', label: 'Hippodromes', link: '/hippodromes' },
{ icon: '📅', label: 'Reunions', link: '/reunions' }, { icon: '📅', label: 'Reunions', link: '/reunions' },
{ icon: '🏇', label: 'Courses', link: '/courses' }, { icon: '🏇', label: 'Courses', link: '/courses' },
{ icon: 'icon-chart-bar', label: 'Rapport des courses', link: '/rapport-courses' }, { icon: 'icon-chart-bar', label: 'Rapport des courses', link: '/rapport' },
]; ];
workspaceMenuItems: MenuItem[] = [ workspaceMenuItems: MenuItem[] = [

View File

@@ -28,9 +28,9 @@
<app-paginator <app-paginator
[total]="total()" [total]="total()"
[page]="page()" [page]="page()"
[perPage]="perPage()" [perPage]="size()"
(pageChange)="page.set($event)" (pageChange)="page.set($event)"
(perPageChange)="perPage.set($event)" (perPageChange)="size.set($event)"
></app-paginator> ></app-paginator>
</div> </div>

View File

@@ -51,7 +51,7 @@ export class AgentsPage {
loading = signal(false); loading = signal(false);
page = signal(1); page = signal(1);
perPage = signal(10); size = signal(10);
search = signal(''); search = signal('');
sort = signal<SortState>({ key: 'code', dir: 'asc' }); sort = signal<SortState>({ key: 'code', dir: 'asc' });
@@ -148,7 +148,7 @@ export class AgentsPage {
) { ) {
// Preload TPE maps for display // Preload TPE maps for display
this.tpeSvc this.tpeSvc
.list({ page: 1, perPage: 200, search: '', sortKey: 'imei', sortDir: 'asc' } as any) .list({ page: 1, size: 200, search: '', sortKey: 'imei', sortDir: 'asc' } as any)
.subscribe((res) => { .subscribe((res) => {
const tpes = res.content as TpeDevice[]; const tpes = res.content as TpeDevice[];
this.rebuildTpeMaps(tpes); this.rebuildTpeMaps(tpes);
@@ -156,7 +156,7 @@ export class AgentsPage {
effect(() => { effect(() => {
const params = { const params = {
page: this.page(), page: this.page(),
perPage: this.perPage(), size: this.size(),
search: this.search(), search: this.search(),
sortKey: this.sort().key, sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir, sortDir: this.sort().dir as SortDir,
@@ -167,7 +167,7 @@ export class AgentsPage {
private fetch(params: { private fetch(params: {
page: number; page: number;
perPage: number; size: number;
search: string; search: string;
sortKey: string; sortKey: string;
sortDir: SortDir; sortDir: SortDir;
@@ -191,7 +191,7 @@ export class AgentsPage {
private refreshTpeMap() { private refreshTpeMap() {
this.tpeSvc this.tpeSvc
.list({ page: 1, perPage: 200, search: '', sortKey: 'imei', sortDir: 'asc' } as any) .list({ page: 1, size: 200, search: '', sortKey: 'imei', sortDir: 'asc' } as any)
.subscribe((res) => { .subscribe((res) => {
const tpes = res.content as TpeDevice[]; const tpes = res.content as TpeDevice[];
this.rebuildTpeMaps(tpes); this.rebuildTpeMaps(tpes);
@@ -381,7 +381,7 @@ export class AgentsPage {
// Refresh data // Refresh data
this.fetch({ this.fetch({
page: this.page(), page: this.page(),
perPage: this.perPage(), size: this.size(),
search: this.search(), search: this.search(),
sortKey: this.sort().key, sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir, sortDir: this.sort().dir as SortDir,
@@ -399,7 +399,7 @@ export class AgentsPage {
this.api.delete(row.id).subscribe(() => this.api.delete(row.id).subscribe(() =>
this.fetch({ this.fetch({
page: this.page(), page: this.page(),
perPage: this.perPage(), size: this.size(),
search: this.search(), search: this.search(),
sortKey: this.sort().key, sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir, sortDir: this.sort().dir as SortDir,

View File

@@ -106,10 +106,10 @@
</app-data-table> </app-data-table>
<app-paginator <app-paginator
[page]="page()" [page]="page() + 1"
[perPage]="perPage()" [perPage]="perPage()"
[total]="total()" [total]="total()"
(pageChange)="page.set($event)" (pageChange)="page.set($event - 1)"
(perPageChange)="perPage.set($event)" (perPageChange)="perPage.set($event)"
[pageSizes]="pageSize" [pageSizes]="pageSize"
/> />
@@ -158,12 +158,12 @@
<app-resultat-form <app-resultat-form
[course]="selectedCourseForResultat()!" [course]="selectedCourseForResultat()!"
[resultat]="resultatsMap().get(selectedCourseForResultat()!.id)" [resultat]="resultatsMap().get(selectedCourseForResultat()!.id)"
(save)="onResultatSave($event)" (save)="onResultatSave($event.horses, $event.typesParisOuverts)"
(validate)="onResultatValidate()" (validate)="onResultatValidate()"
(confirm)="onResultatConfirm()" (confirm)="onResultatConfirm()"
(cancel)="closeResultatModal()" (cancel)="closeResultatModal()"
/> />
<div modal-actions class="flex justify-end gap-2"> <div modal-actions class="flex justify-between gap-2">
<z-button zType="destructive" (click)="closeResultatModal()">Fermer</z-button> <z-button zType="destructive" (click)="closeResultatModal()">Fermer</z-button>
</div> </div>
</app-modal> </app-modal>

View File

@@ -18,7 +18,7 @@ import { Course as CourseType } from 'src/app/core/interfaces/course';
import { SortDir } from '@shared/paging/paging'; import { SortDir } from '@shared/paging/paging';
import { CourseApiResponse, 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, ResultatStatut } from 'src/app/core/interfaces/resultat';
import { A11yModule } from '@angular/cdk/a11y'; import { A11yModule } from '@angular/cdk/a11y';
import { CourseForm } from '@shared/forms/course-form/course-form'; import { CourseForm } from '@shared/forms/course-form/course-form';
import { NonPartantForm } from '@shared/forms/nonpartant-form/nonpartant-form'; import { NonPartantForm } from '@shared/forms/nonpartant-form/nonpartant-form';
@@ -57,10 +57,10 @@ export class Course {
totalClosed = signal(0); totalClosed = signal(0);
totalByType = signal<Record<string, number>>({}); totalByType = signal<Record<string, number>>({});
page = signal(1); page = signal(0);
perPage = signal(10); perPage = signal(10);
search = signal(''); search = signal('');
sort = signal<SortState>({ key: 'numero', dir: 'asc' }); sort = signal<SortState>({ key: 'id', dir: 'asc' });
pageSize = [10, 20, 50]; pageSize = [10, 20, 50];
modalOpen = signal(false); modalOpen = signal(false);
@@ -102,40 +102,42 @@ export class Course {
return '<span class="text-gray-500 dark:text-gray-400">—</span>'; return '<span class="text-gray-500 dark:text-gray-400">—</span>';
} }
// Group horses that are at the same place (ex-aequo/dead heat). return `<span class="text-gray-500 dark:text-gray-400">${resultat.ordreArrivee}</span>`
// Backend/Resultat model store ordreArrivee as cheval numbers (1,2,3,...) and
// chevauxDeadHeat as the subset that are ex-aequo.
const deadHeatSet = new Set(resultat.chevauxDeadHeat || []);
const groups: number[][] = []; // // Group horses that are at the same place (ex-aequo/dead heat).
let currentGroup: number[] = []; // // Backend/Resultat model store ordreArrivee as cheval numbers (1,2,3,...) and
// // chevauxDeadHeat as the subset that are ex-aequo.
// const deadHeatSet = new Set(resultat.chevauxDeadHeat || []);
resultat.ordreArrivee.forEach((num, index) => { // const groups: number[][] = [];
const isInDeadHeat = deadHeatSet.has(num); // let currentGroup: number[] = [];
const prevNum = index > 0 ? resultat.ordreArrivee[index - 1] : null;
const prevIsInDeadHeat = prevNum !== null && deadHeatSet.has(prevNum);
if (isInDeadHeat && prevIsInDeadHeat && currentGroup.length > 0) { // resultat.ordreArrivee.forEach((num, index) => {
// Continue the current dead heat group // const isInDeadHeat = deadHeatSet.has(num);
currentGroup.push(num); // const prevNum = index > 0 ? resultat.ordreArrivee[index - 1] : null;
} else { // const prevIsInDeadHeat = prevNum !== null && deadHeatSet.has(prevNum);
// Start a new group
if (currentGroup.length > 0) {
groups.push(currentGroup);
}
currentGroup = [num];
}
});
// Don't forget the last group // if (isInDeadHeat && prevIsInDeadHeat && currentGroup.length > 0) {
if (currentGroup.length > 0) { // // Continue the current dead heat group
groups.push(currentGroup); // currentGroup.push(num);
} // } else {
// // Start a new group
// if (currentGroup.length > 0) {
// groups.push(currentGroup);
// }
// currentGroup = [num];
// }
// });
const s = groups.map((nums) => nums.join('=')).join(' - '); // // Don't forget the last group
// if (currentGroup.length > 0) {
// groups.push(currentGroup);
// }
// For now, we'll show the resultat. In the future, we might add a statut field to Resultat // const s = groups.map((nums) => nums.join('=')).join(' - ');
return `<span class="mr-2">${s}</span>`;
// // For now, we'll show the resultat. In the future, we might add a statut field to Resultat
// return ;
}, },
}, },
{ {
@@ -187,7 +189,7 @@ export class Course {
effect(() => { effect(() => {
const params = { const params = {
page: this.page(), page: this.page(),
perPage: this.perPage(), size: this.perPage(),
search: this.search(), search: this.search(),
sortKey: this.sort().key, sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir, sortDir: this.sort().dir as SortDir,
@@ -198,7 +200,7 @@ export class Course {
private fetch(params: { private fetch(params: {
page: number; page: number;
perPage: number; size: number;
search: string; search: string;
sortKey: string; sortKey: string;
sortDir: SortDir; sortDir: SortDir;
@@ -256,7 +258,7 @@ export class Course {
// === UI Actions === // === UI Actions ===
onSearch(q: string) { onSearch(q: string) {
this.search.set(q); this.search.set(q);
this.page.set(1); this.page.set(0);
} }
openCreate() { openCreate() {
@@ -283,22 +285,16 @@ export class Course {
this.formComp?.onSubmit(); this.formComp?.onSubmit();
} }
onFormSave(payload: Partial<CourseType>) { onFormSave(_: CourseType) {
const current = this.editingItem(); // The form now persists create/update itself. Just close and refresh.
const req$ = current?.id
? this.api.update(current.id, payload)
: this.api.create(payload as Omit<CourseApiResponse, 'id'>);
req$.subscribe(() => {
this.closeModal(); this.closeModal();
this.fetch({ this.fetch({
page: this.page(), page: this.page(),
perPage: this.perPage(), size: this.perPage(),
search: this.search(), search: this.search(),
sortKey: this.sort().key, sortKey: this.sort().key,
sortDir: this.sort().dir, sortDir: this.sort().dir,
}); });
});
} }
remove(row: CourseType) { remove(row: CourseType) {
@@ -307,7 +303,7 @@ export class Course {
this.api.delete(row.id).subscribe(() => this.api.delete(row.id).subscribe(() =>
this.fetch({ this.fetch({
page: this.page(), page: this.page(),
perPage: this.perPage(), size: this.perPage(),
search: this.search(), search: this.search(),
sortKey: this.sort().key, sortKey: this.sort().key,
sortDir: this.sort().dir, sortDir: this.sort().dir,
@@ -360,7 +356,7 @@ export class Course {
this.closeNonPartantModal(); this.closeNonPartantModal();
this.fetch({ this.fetch({
page: this.page(), page: this.page(),
perPage: this.perPage(), size: this.perPage(),
search: this.search(), search: this.search(),
sortKey: this.sort().key, sortKey: this.sort().key,
sortDir: this.sort().dir, sortDir: this.sort().dir,
@@ -381,20 +377,22 @@ export class Course {
this.selectedCourseForResultat.set(null); this.selectedCourseForResultat.set(null);
} }
onResultatSave(places: number[][]) { onResultatSave(places: number[][], typesParisOuverts: string[]) {
const c = this.selectedCourseForResultat(); const c = this.selectedCourseForResultat();
if (!c) return; if (!c) return;
// Determine required number of horses based on course type // Determine required number of horses based on course type
const getRequiredHorses = (type: string): number => { const getRequiredHorses = (types: string[]): number => {
const typeStr = String(type).toUpperCase(); const typeStr = types;
if (typeStr.includes('TIERCE') || typeStr === 'PLAT') return 3; if (typeStr.includes('PLACE') || typeStr.includes('GAGNANT')) return 3;
if (typeStr.includes('QUARTE')) return 4; if(typeStr.includes('JUMELE_GAGNANT') || typeStr.includes('JUMELE_PLACE') || typeStr.includes('JUMELE_ORDRE')) return 2;
if(typeStr.includes('TRIO') || typeStr.includes('TRIO_ORDRE') || typeStr.includes('TRIPLET')) return 3
if (typeStr.includes('QUATRO')) return 4;
if (typeStr.includes('QUINTE')) return 5; if (typeStr.includes('QUINTE')) return 5;
return 3; // Default return 3; // Default
}; };
const requiredHorses = 3; const requiredHorses = getRequiredHorses(typesParisOuverts);
// Collect all selected horses (flatten the places array) // Collect all selected horses (flatten the places array)
const allHorses: number[] = places const allHorses: number[] = places
@@ -409,46 +407,28 @@ export class Course {
// Convert to ordreArrivee format // Convert to ordreArrivee format
// If all are ex-aequo, they all go in ordreArrivee as they are (first place) // If all are ex-aequo, they all go in ordreArrivee as they are (first place)
// Otherwise, distribute them across places // Otherwise, distribute them across places
const ordreArrivee: Array<string> = []; let ordreArrivee: string = '';
const chevauxDeadHeat: number[] = [];
if (isAllExAequo) { places.forEach((place)=>{
// All horses are in first place (ex-aequo) if(Array.isArray(place) && place.length>1){
allHorses.forEach((numero) => { place.forEach((p, index)=>{
ordreArrivee.push(numero.toString()); if(index == 0){
chevauxDeadHeat.push(numero); ordreArrivee = ordreArrivee ==''? String(p) : ordreArrivee+","+String(p)
});
}else{ }else{
// Horses are distributed across places ordreArrivee = ordreArrivee ==''? String(p) : ordreArrivee+"="+String(p)
places.forEach((placeGroup, placeIndex) => {
const validHorses = placeGroup.filter((n) => typeof n === 'number' && n > 0);
if (validHorses.length === 0) return;
const isDeadHeat = validHorses.length > 1;
validHorses.forEach((numero) => {
ordreArrivee.push(numero.toString());
if (isDeadHeat) {
chevauxDeadHeat.push(numero);
} }
}); })
}); }else{
ordreArrivee = ordreArrivee ==''? String(place[0]): ordreArrivee+","+String(place[0])
} }
})
// Check if resultat already exists // Check if resultat already exists
const existingResultat = this.resultatsMap().get(c.id); const existingResultat = this.resultatsMap().get(c.id);
const payload = { const payload = {
course: { id: c.id }, courseId: Number(c.id) ,
ordreArrivee, ordreArrivee,
chevauxDeadHeat: chevauxDeadHeat.map((n) => String(n)), statut: ResultatStatut.EN_ATTENTE,
totalMises: 0,
masseAPartager: 0,
prelevementsLegaux: 0,
montantRembourse: 0,
montantCagnotte: 0,
adeadHeat: chevauxDeadHeat.length > 0,
}; };
const request$ = existingResultat const request$ = existingResultat
@@ -460,7 +440,7 @@ export class Course {
this.closeResultatModal(); this.closeResultatModal();
this.fetch({ this.fetch({
page: this.page(), page: this.page(),
perPage: this.perPage(), size: this.perPage(),
search: this.search(), search: this.search(),
sortKey: this.sort().key, sortKey: this.sort().key,
sortDir: this.sort().dir, sortDir: this.sort().dir,
@@ -490,7 +470,7 @@ export class Course {
this.closeResultatModal(); this.closeResultatModal();
this.fetch({ this.fetch({
page: this.page(), page: this.page(),
perPage: this.perPage(), size: this.perPage(),
search: this.search(), search: this.search(),
sortKey: this.sort().key, sortKey: this.sort().key,
sortDir: this.sort().dir, sortDir: this.sort().dir,
@@ -520,7 +500,7 @@ export class Course {
this.closeResultatModal(); this.closeResultatModal();
this.fetch({ this.fetch({
page: this.page(), page: this.page(),
perPage: this.perPage(), size: this.perPage(),
search: this.search(), search: this.search(),
sortKey: this.sort().key, sortKey: this.sort().key,
sortDir: this.sort().dir, sortDir: this.sort().dir,

View File

@@ -97,7 +97,7 @@
[page]="page()" [page]="page()"
[perPage]="perPage()" [perPage]="perPage()"
[total]="total()" [total]="total()"
(pageChange)="page.set($event)" (pageChange)="page.set($event - 1)"
(perPageChange)="onPerPage($event)" (perPageChange)="onPerPage($event)"
[pageSizes]="pageSize" [pageSizes]="pageSize"
/> />

View File

@@ -46,7 +46,7 @@ export class Hippodrome {
totalReunions = signal(0); totalReunions = signal(0);
totalCourses = signal(0); totalCourses = signal(0);
page = signal(1); page = signal(0);
perPage = signal(10); perPage = signal(10);
pageSize = [10, 20, 50]; pageSize = [10, 20, 50];
search = signal(''); search = signal('');
@@ -105,7 +105,7 @@ export class Hippodrome {
this.api this.api
.list({ .list({
page: this.page(), page: this.page(),
perPage: this.perPage(), size: this.perPage(),
search: this.search(), search: this.search(),
sortKey: this.sort().key, sortKey: this.sort().key,
sortDir: this.sort().dir, sortDir: this.sort().dir,

View File

@@ -49,7 +49,7 @@
</ng-template> </ng-template>
</app-data-table> </app-data-table>
<app-paginator [total]="total()" [page]="page()" [perPage]="perPage()" (pageChange)="page.set($event)" (perPageChange)="perPage.set($event)"></app-paginator> <app-paginator [total]="total()" [page]="page()" [perPage]="size()" (pageChange)="page.set($event)" (perPageChange)="size.set($event)"></app-paginator>
</div> </div>
<app-modal [open]="modalOpen()" [title]="modalTitle()" (close)="closeModal()" size="xl"> <app-modal [open]="modalOpen()" [title]="modalTitle()" (close)="closeModal()" size="xl">

View File

@@ -24,7 +24,7 @@ export class LimitsPage implements OnInit {
total = signal(0); total = signal(0);
loading = signal(false); loading = signal(false);
page = signal(1); page = signal(1);
perPage = signal(10); size = signal(10);
search = signal(''); search = signal('');
sort = signal<SortState>({ key: 'code', dir: 'asc' }); sort = signal<SortState>({ key: 'code', dir: 'asc' });
selectedActif = signal<boolean | null>(null); selectedActif = signal<boolean | null>(null);
@@ -92,11 +92,11 @@ export class LimitsPage implements OnInit {
constructor(private api: AgentLimitService) { constructor(private api: AgentLimitService) {
effect(() => { effect(() => {
// Only trigger fetch when page, perPage, or sort changes (not search - handled by searchSubject) // Only trigger fetch when page, size, or sort changes (not search - handled by searchSubject)
const searchValue = this.search(); const searchValue = this.search();
const params = { const params = {
page: this.page(), page: this.page(),
perPage: this.perPage(), size: this.size(),
search: searchValue, search: searchValue,
sortKey: this.sort().key, sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir, sortDir: this.sort().dir as SortDir,
@@ -120,7 +120,7 @@ export class LimitsPage implements OnInit {
// If empty, use normal list // If empty, use normal list
return this.api.list({ return this.api.list({
page: this.page(), page: this.page(),
perPage: this.perPage(), size: this.size(),
search: '', search: '',
sortKey: this.sort().key, sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir, sortDir: this.sort().dir as SortDir,
@@ -155,14 +155,14 @@ export class LimitsPage implements OnInit {
// Initial fetch // Initial fetch
this.fetch({ this.fetch({
page: this.page(), page: this.page(),
perPage: this.perPage(), size: this.size(),
search: this.search(), search: this.search(),
sortKey: this.sort().key, sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir, sortDir: this.sort().dir as SortDir,
}); });
} }
private fetch(params: { page: number; perPage: number; search: string; sortKey: string; sortDir: SortDir }) { private fetch(params: { page: number; size: number; search: string; sortKey: string; sortDir: SortDir }) {
// Don't fetch if there's a search query - it's handled by searchSubject // Don't fetch if there's a search query - it's handled by searchSubject
const searchQuery = params.search.trim(); const searchQuery = params.search.trim();
if (searchQuery) { if (searchQuery) {
@@ -214,7 +214,7 @@ export class LimitsPage implements OnInit {
// If empty, fetch normally // If empty, fetch normally
this.fetch({ this.fetch({
page: this.page(), page: this.page(),
perPage: this.perPage(), size: this.size(),
search: '', search: '',
sortKey: this.sort().key, sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir, sortDir: this.sort().dir as SortDir,
@@ -227,7 +227,7 @@ export class LimitsPage implements OnInit {
this.page.set(1); this.page.set(1);
this.fetch({ this.fetch({
page: this.page(), page: this.page(),
perPage: this.perPage(), size: this.size(),
search: this.search(), search: this.search(),
sortKey: this.sort().key, sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir, sortDir: this.sort().dir as SortDir,
@@ -261,7 +261,7 @@ export class LimitsPage implements OnInit {
this.closeModal(); this.closeModal();
this.fetch({ this.fetch({
page: this.page(), page: this.page(),
perPage: this.perPage(), size: this.size(),
search: this.search(), search: this.search(),
sortKey: this.sort().key, sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir, sortDir: this.sort().dir as SortDir,
@@ -282,7 +282,7 @@ export class LimitsPage implements OnInit {
this.api.delete(row.id).subscribe(() => { this.api.delete(row.id).subscribe(() => {
this.fetch({ this.fetch({
page: this.page(), page: this.page(),
perPage: this.perPage(), size: this.size(),
search: this.search(), search: this.search(),
sortKey: this.sort().key, sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir, sortDir: this.sort().dir as SortDir,

View File

@@ -118,7 +118,7 @@ export class Main {
private baseParams(): ListParams { private baseParams(): ListParams {
return { return {
page: 1, page: 1,
perPage: 1, size: 1,
search: '', search: '',
sortKey: 'id', sortKey: 'id',
sortDir: 'asc' as SortDir, sortDir: 'asc' as SortDir,
@@ -158,7 +158,7 @@ export class Main {
.list(params, true) .list(params, true)
.pipe(catchError(() => of({ data: [], meta: { total: 0 } } as any))), .pipe(catchError(() => of({ data: [], meta: { total: 0 } } as any))),
courses: this.courseService courses: this.courseService
.list(coursesParams, true) .list(coursesParams)
.pipe(catchError(() => of({ data: [], meta: { total: 0 } } as any))), .pipe(catchError(() => of({ data: [], meta: { total: 0 } } as any))),
roles: this.roleService roles: this.roleService
.list(params) .list(params)

View File

@@ -0,0 +1,33 @@
<div class="space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-semibold">Rapport — Courses avec résultats</h2>
<z-button zType="default" (click)="fetch()">Récupérer le rapport</z-button>
</div>
<div class="mt-4">
<app-data-table [data]="rows()" [columns]="cols" [loading]="loading()">
<ng-template #rowActions let-row>
<z-button zType="ghost" zSize="icon" aria-label="Voir le rapport" (click)="openReport(row)">
<div class="icon-file-text"></div>
</z-button>
</ng-template>
</app-data-table>
<div class="flex items-center justify-between mt-3">
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<label class="text-sm">Lignes par page</label>
<select class="border rounded px-2 py-1" [value]="perPage()" (change)="onPerPageChangeEvent($event)">
<option [value]="5">5</option>
<option [value]="10">10</option>
<option [value]="25">25</option>
<option [value]="50">50</option>
</select>
</div>
<div class="text-sm text-muted">{{ totalElements() }} résultats</div>
</div>
<div>
<z-pagination [zPageIndex]="page()" [zTotal]="totalPages()" (zPageIndexChange)="onPageChange($event)"></z-pagination>
</div>
</div>
</div>
</div>

View File

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

View File

@@ -0,0 +1,94 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, signal, ViewChild } from '@angular/core';
import { DataTable, TableColumn } from '@shared/components/data-table/data-table';
import { ZardButtonComponent } from '@shared/components/button/button.component';
import { ZardPaginationModule } from '@shared/components/pagination/pagination.module';
import { ListParams, PagedResult } from '@shared/paging/paging';
import { ResultatApiResponse } from 'src/app/core/interfaces/resultat';
import { ResultatService } from 'src/app/core/services/resultat';
@Component({
standalone: true,
selector: 'app-rapport',
templateUrl: './rapport.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, DataTable, ZardButtonComponent, ZardPaginationModule],
})
export class Rapport {
rows = signal<ResultatApiResponse[]>([]);
loading = signal(false);
// Pagination state
page = signal<number>(1);
perPage = signal<number>(10);
totalPages = signal<number>(1);
totalElements = signal<number>(0);
@ViewChild(DataTable) table?: DataTable<ResultatApiResponse>;
cols: TableColumn<ResultatApiResponse>[] = [
{ key: 'courseNumero', label: 'N°' },
{ key: 'hippodromeNom', label: 'Hippodrome' },
{ key: 'courseNom', label: 'Course' },
{ key: 'ordreArrivee', label: "Ordre d'arrivée", cell: (r) => String(r.ordreArrivee ?? '').replace(/,/g, ' - ') },
{ key: 'statut', label: 'Statut', cell: (r)=>(r.statut.toString().toLowerCase().replace("_", " ")) },
{ key: 'datePublication', label: 'Date pub.', cell: (r) => r.datePublication ?? r.createdAt ?? '—' },
{ key: 'dateValidation', label: 'Date validation' },
];
constructor(private api: ResultatService) {
// initial load
this.fetch();
}
private fetchPage(params?: Partial<ListParams>) {
this.loading.set(true);
const p: ListParams = { page: this.page(), size: this.perPage(), ...(params || {}) };
this.api.listRawPaged(p).subscribe({
next: (res: PagedResult<ResultatApiResponse>) => {
const filtered = (res?.content || []).filter((r) => !!(r.ordreArrivee && String(r.ordreArrivee).trim()));
this.rows.set(filtered);
// normalize paging meta
this.totalPages.set(res.totalPages ?? 1);
this.totalElements.set(res.totalElements ?? (filtered.length || 0));
// ensure local page is in sync with backend response
if (res.pageable?.pageNumber) this.page.set(res.pageable.pageNumber);
this.loading.set(false);
},
error: (err) => {
console.error('Error fetching paged reports:', err);
this.rows.set([]);
this.loading.set(false);
},
});
}
fetch() {
this.fetchPage();
}
onPageChange(nextPage: number) {
this.page.set(nextPage);
this.fetchPage();
}
onPerPageChange(size: number) {
this.perPage.set(size);
this.page.set(1);
this.fetchPage();
}
// wrapper for template change event to avoid $event typing issues
onPerPageChangeEvent(e: Event) {
const v = (e.target as HTMLSelectElement)?.value;
const size = Number(v) || 10;
this.onPerPageChange(size);
}
openReport(row: ResultatApiResponse) {
try {
// Open a per-result report URL in a new tab. Adjust path if your server uses another route.
const url = `/rapport/${row.id}`;
window.open(url, '_blank');
} catch (err) {
console.error('Failed to open report for', row, err);
}
}
}

View File

@@ -64,10 +64,10 @@
<app-paginator <app-paginator
[page]="page()" [page]="page()"
[perPage]="perPage()" [perPage]="size()"
[total]="total()" [total]="total()"
(pageChange)="page.set($event)" (pageChange)="page.set($event)"
(perPageChange)="perPage.set($event)" (perPageChange)="size.set($event)"
[pageSizes]="pageSize" [pageSizes]="pageSize"
/> />
</div> </div>

View File

@@ -48,7 +48,7 @@ export class ReunionList {
// pagination, sorting, search // pagination, sorting, search
page = signal(1); page = signal(1);
perPage = signal(10); size = signal(10);
search = signal(''); search = signal('');
sort = signal<SortState>({ key: 'date', dir: 'asc' }); sort = signal<SortState>({ key: 'date', dir: 'asc' });
pageSize = [10, 20, 50]; pageSize = [10, 20, 50];
@@ -134,7 +134,7 @@ export class ReunionList {
effect(() => { effect(() => {
const params = { const params = {
page: this.page(), page: this.page(),
perPage: this.perPage(), size: this.size(),
search: this.search(), search: this.search(),
sortKey: this.sort().key, sortKey: this.sort().key,
sortDir: this.sort().dir, sortDir: this.sort().dir,
@@ -145,7 +145,7 @@ export class ReunionList {
private fetch(params: { private fetch(params: {
page: number; page: number;
perPage: number; size: number;
search: string; search: string;
sortKey: string; sortKey: string;
sortDir: SortDir; sortDir: SortDir;
@@ -211,7 +211,7 @@ export class ReunionList {
// refetch current page // refetch current page
this.fetch({ this.fetch({
page: this.page(), page: this.page(),
perPage: this.perPage(), size: this.size(),
search: this.search(), search: this.search(),
sortKey: this.sort().key, sortKey: this.sort().key,
sortDir: this.sort().dir, sortDir: this.sort().dir,
@@ -224,7 +224,7 @@ export class ReunionList {
this.api.delete(row.id).subscribe(() => this.api.delete(row.id).subscribe(() =>
this.fetch({ this.fetch({
page: this.page(), page: this.page(),
perPage: this.perPage(), size: this.size(),
search: this.search(), search: this.search(),
sortKey: this.sort().key, sortKey: this.sort().key,
sortDir: this.sort().dir, sortDir: this.sort().dir,

View File

@@ -39,7 +39,7 @@ export class RolesPage {
loading = signal(false); loading = signal(false);
permissions = signal<Permission[]>([]); permissions = signal<Permission[]>([]);
page = signal(1); page = signal(1);
perPage = signal(10); size = signal(10);
search = signal(''); search = signal('');
sort = signal<SortState>({ key: 'name', dir: 'asc' }); sort = signal<SortState>({ key: 'name', dir: 'asc' });
@@ -65,7 +65,7 @@ export class RolesPage {
effect(() => { effect(() => {
const params = { const params = {
page: this.page(), page: this.page(),
perPage: this.perPage(), size: this.size(),
search: this.search(), search: this.search(),
sortKey: this.sort().key, sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir, sortDir: this.sort().dir as SortDir,
@@ -76,7 +76,7 @@ export class RolesPage {
private fetch(params: { private fetch(params: {
page: number; page: number;
perPage: number; size: number;
search: string; search: string;
sortKey: string; sortKey: string;
sortDir: SortDir; sortDir: SortDir;
@@ -136,7 +136,7 @@ export class RolesPage {
); );
this.fetch({ this.fetch({
page: this.page(), page: this.page(),
perPage: this.perPage(), size: this.size(),
search: this.search(), search: this.search(),
sortKey: this.sort().key, sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir, sortDir: this.sort().dir as SortDir,
@@ -160,7 +160,7 @@ export class RolesPage {
toast.success(`Le rôle « ${row.name} » a été supprimé avec succès`); toast.success(`Le rôle « ${row.name} » a été supprimé avec succès`);
this.fetch({ this.fetch({
page: this.page(), page: this.page(),
perPage: this.perPage(), size: this.size(),
search: this.search(), search: this.search(),
sortKey: this.sort().key, sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir, sortDir: this.sort().dir as SortDir,

View File

@@ -152,7 +152,7 @@ export class TpePage implements OnInit {
const searchValue = this.search(); const searchValue = this.search();
const params = { const params = {
page: this.page(), page: this.page(),
perPage: this.perPage(), size: this.perPage(),
search: searchValue, search: searchValue,
sortKey: this.sort().key, sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir, sortDir: this.sort().dir as SortDir,
@@ -175,7 +175,7 @@ export class TpePage implements OnInit {
// If empty, use normal list // If empty, use normal list
return this.api.list({ return this.api.list({
page: this.page(), page: this.page(),
perPage: this.perPage(), size: this.perPage(),
search: '', search: '',
sortKey: this.sort().key, sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir, sortDir: this.sort().dir as SortDir,
@@ -211,7 +211,7 @@ export class TpePage implements OnInit {
if (!this.search().trim()) { if (!this.search().trim()) {
this.fetch({ this.fetch({
page: this.page(), page: this.page(),
perPage: this.perPage(), size: this.perPage(),
search: '', search: '',
sortKey: this.sort().key, sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir, sortDir: this.sort().dir as SortDir,
@@ -249,7 +249,7 @@ export class TpePage implements OnInit {
private fetch(params: { private fetch(params: {
page: number; page: number;
perPage: number; size: number;
search: string; search: string;
sortKey: string; sortKey: string;
sortDir: SortDir; sortDir: SortDir;
@@ -305,7 +305,7 @@ export class TpePage implements OnInit {
// If empty, fetch normally // If empty, fetch normally
this.fetch({ this.fetch({
page: this.page(), page: this.page(),
perPage: this.perPage(), size: this.perPage(),
search: '', search: '',
sortKey: this.sort().key, sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir, sortDir: this.sort().dir as SortDir,
@@ -318,7 +318,7 @@ export class TpePage implements OnInit {
this.page.set(1); this.page.set(1);
this.fetch({ this.fetch({
page: this.page(), page: this.page(),
perPage: this.perPage(), size: this.perPage(),
search: this.search(), search: this.search(),
sortKey: this.sort().key, sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir, sortDir: this.sort().dir as SortDir,
@@ -331,7 +331,7 @@ export class TpePage implements OnInit {
next: () => { next: () => {
this.fetch({ this.fetch({
page: this.page(), page: this.page(),
perPage: this.perPage(), size: this.perPage(),
search: this.search(), search: this.search(),
sortKey: this.sort().key, sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir, sortDir: this.sort().dir as SortDir,
@@ -347,7 +347,7 @@ export class TpePage implements OnInit {
next: () => { next: () => {
this.fetch({ this.fetch({
page: this.page(), page: this.page(),
perPage: this.perPage(), size: this.perPage(),
search: this.search(), search: this.search(),
sortKey: this.sort().key, sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir, sortDir: this.sort().dir as SortDir,
@@ -393,7 +393,7 @@ export class TpePage implements OnInit {
this.selectedAgentId.set(''); this.selectedAgentId.set('');
this.fetch({ this.fetch({
page: this.page(), page: this.page(),
perPage: this.perPage(), size: this.perPage(),
search: this.search(), search: this.search(),
sortKey: this.sort().key, sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir, sortDir: this.sort().dir as SortDir,
@@ -456,7 +456,7 @@ export class TpePage implements OnInit {
// Refresh data // Refresh data
this.fetch({ this.fetch({
page: this.page(), page: this.page(),
perPage: this.perPage(), size: this.perPage(),
search: this.search(), search: this.search(),
sortKey: this.sort().key, sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir, sortDir: this.sort().dir as SortDir,
@@ -476,7 +476,7 @@ export class TpePage implements OnInit {
this.api.delete(row.id).subscribe(() => { this.api.delete(row.id).subscribe(() => {
this.fetch({ this.fetch({
page: this.page(), page: this.page(),
perPage: this.perPage(), size: this.perPage(),
search: this.search(), search: this.search(),
sortKey: this.sort().key, sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir, sortDir: this.sort().dir as SortDir,

View File

@@ -83,7 +83,7 @@ export class UsersPage {
effect(() => { effect(() => {
const params = { const params = {
page: this.page(), page: this.page(),
perPage: this.perPage(), size: this.perPage(),
search: this.search(), search: this.search(),
sortKey: this.sort().key, sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir, sortDir: this.sort().dir as SortDir,
@@ -94,7 +94,7 @@ export class UsersPage {
private fetch(params: { private fetch(params: {
page: number; page: number;
perPage: number; size: number;
search: string; search: string;
sortKey: string; sortKey: string;
sortDir: SortDir; sortDir: SortDir;
@@ -156,7 +156,7 @@ export class UsersPage {
); );
this.fetch({ this.fetch({
page: this.page(), page: this.page(),
perPage: this.perPage(), size: this.perPage(),
search: this.search(), search: this.search(),
sortKey: this.sort().key, sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir, sortDir: this.sort().dir as SortDir,
@@ -181,7 +181,7 @@ export class UsersPage {
toast.success(`L'utilisateur « ${row.nom} ${row.prenom} » a été supprimé avec succès`); toast.success(`L'utilisateur « ${row.nom} ${row.prenom} » a été supprimé avec succès`);
this.fetch({ this.fetch({
page: this.page(), page: this.page(),
perPage: this.perPage(), size: this.perPage(),
search: this.search(), search: this.search(),
sortKey: this.sort().key, sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir, sortDir: this.sort().dir as SortDir,

View File

@@ -27,13 +27,12 @@
id="hippodromeId" id="hippodromeId"
placeholder="Rechercher une réunion..." placeholder="Rechercher une réunion..."
formControlName="hippodromeId" formControlName="hippodromeId"
[zLabel]="selectedHippodromeLabel() || ''"> [zLabel]="selectedHippodromeLabel() || ''"
>
@if (loadingHippodromes()) { @if (loadingHippodromes()) {
<z-select-item [zValue]="''" disabled>Chargement des Hippodromes...</z-select-item> <z-select-item [zValue]="''" disabled>Chargement des Hippodromes...</z-select-item>
} @else { @for (r of filteredHippodromes(); track r.id) { } @else { @for (r of filteredHippodromes(); track r.id) {
<z-select-item [zValue]="r.id"> <z-select-item [zValue]="r.id"> {{ r.nom }} - ({{ r.ville }}) </z-select-item>
{{ r.nom }} - ({{ r.ville }})
</z-select-item>
} } } }
</z-select> </z-select>
</z-form-control> </z-form-control>
@@ -84,7 +83,7 @@
<z-form-control> <z-form-control>
<input <input
z-input z-input
placeholder="Ex: TIERCE, QUINTE" placeholder="Ex: Plat"
formControlName="discipline" 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" 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"
/> />
@@ -148,7 +147,7 @@
<input <input
z-input z-input
type="time" type="time"
formControlName="heureDepartPrevu" formControlName="heureDepartPrevue"
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>
@@ -161,23 +160,6 @@
</div> </div>
</div> </div>
<!-- 📅 SECTION 3 — Réunion -->
<div class="space-y-4">
<div class="flex items-center gap-3 pb-3 border-b border-gray-200 dark:border-gray-700">
<div
class="flex items-center justify-center w-10 h-10 rounded-xl bg-gradient-to-br from-green-500 to-green-600 text-white text-xl"
>
📅
</div>
<div>
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100">Réunion associée</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">
Sélectionnez la réunion concernée ou créez-en une nouvelle
</p>
</div>
</div>
</div>
<!-- 🏇 SECTION 4 — Détails techniques --> <!-- 🏇 SECTION 4 — Détails techniques -->
<div class="space-y-4"> <div class="space-y-4">
<div class="flex items-center gap-3 pb-3 border-b border-gray-200 dark:border-gray-700"> <div class="flex items-center gap-3 pb-3 border-b border-gray-200 dark:border-gray-700">
@@ -194,29 +176,35 @@
<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">
>Types de paris ouverts (CSV)</label Types de paris ouverts
> </label>
<z-form-control>
<z-select
formControlName="typeParisOuverts" <z-form-control class="space-y-2">
class="w-full">
@for (t of courseTypes; track t.value) { @for (t of courseTypes; track t.value) {
<z-select-item [zValue]="t.value">{{ t.label }}</z-select-item> <label class="flex items-center gap-2 text-sm">
<input
type="checkbox"
[value]="t.value"
(change)="onToggleType($event)"
[checked]="form.value.typesParisOuverts?.includes(t.value)"
/>
{{ t.label }}
</label>
} }
</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>
<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"
>Partants</label >Partants</label
> >
<z-form-control <z-form-control
[errorMessage]=" [errorMessage]="
isInvalid('nombrePartants') ? errorMessage('nombrePartants') || 'Ce champ est obligatoire' : '' isInvalid('nombrePartants')
? errorMessage('nombrePartants') || 'Ce champ est obligatoire'
: ''
" "
> >
<input <input
@@ -251,7 +239,7 @@
<z-form-control> <z-form-control>
<input <input
z-input z-input
placeholder="Âge, catégorie..." placeholder="Ex: A"
formControlName="categorie" 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"
/> />

View File

@@ -48,10 +48,17 @@ import { CourseApiResponse, CourseService } from 'src/app/core/services/course';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class CourseForm implements OnInit, AfterViewInit, OnDestroy { export class CourseForm implements OnInit, AfterViewInit, OnDestroy {
@Output() save = new EventEmitter<Partial<CourseApiResponse>>(); @Output() save = new EventEmitter<Course>();
@Output() cancel = new EventEmitter<void>(); @Output() cancel = new EventEmitter<void>();
@Input() value?: Course; private _value?: Course;
@Input() set value(v: Course | undefined) {
this._value = v;
this.hydrateFromValue(v);
}
get value(): Course | undefined {
return this._value;
}
form: FormGroup; form: FormGroup;
submitted = false; submitted = false;
@@ -76,7 +83,7 @@ export class CourseForm implements OnInit, AfterViewInit, OnDestroy {
nom: ['', Validators.required], nom: ['', Validators.required],
numero: [null, Validators.required], numero: [null, Validators.required],
statut: [CourseStatut.BROUILLON, Validators.required], statut: [CourseStatut.BROUILLON, Validators.required],
heureDepartPrevu: ['', Validators.required], heureDepartPrevue: ['', Validators.required],
discipline: ['', Validators.required], discipline: ['', Validators.required],
distanceMetres: [null, Validators.required], distanceMetres: [null, Validators.required],
nombrePartants: [null, Validators.required], nombrePartants: [null, Validators.required],
@@ -86,7 +93,7 @@ export class CourseForm implements OnInit, AfterViewInit, OnDestroy {
reporteeAutreJour: [false], reporteeAutreJour: [false],
incidentTechnique: [false], incidentTechnique: [false],
nonPartants: [[]], nonPartants: [[]],
typesParisOuverts: [''], typesParisOuverts: [[], Validators.required],
createdBy: ['agent-001'], createdBy: ['agent-001'],
validatedBy: [''], validatedBy: [''],
}); });
@@ -158,7 +165,7 @@ export class CourseForm implements OnInit, AfterViewInit, OnDestroy {
this.subs.add( this.subs.add(
this.hippodromeService this.hippodromeService
.list({ page: 1, perPage: 1000 }, true) .list({ page: 0, size: 1000 }, true)
.subscribe({ .subscribe({
next: (res) => { next: (res) => {
this.hippodromes.set(res.content); this.hippodromes.set(res.content);
@@ -175,11 +182,101 @@ export class CourseForm implements OnInit, AfterViewInit, OnDestroy {
this.cdr.markForCheck(); this.cdr.markForCheck();
} }
getDateCorrectFormat = (date: Date): string => {
let hour = date.getHours().toString();
let minutes = date.getMinutes().toString();
hour = hour.length == 1? `0${hour}`: hour;
minutes = minutes.length == 1? `0${minutes}`:minutes
return `${hour}:${minutes}`
};
private hydrateFromValue(v?: Course) {
if (!v) {
// Reset to defaults
this.form.reset({
hippodromeId: '',
reunionNumero: null,
reunionDate: '',
nom: '',
numero: null,
statut: CourseStatut.BROUILLON,
heureDepartPrevue: '',
discipline: '',
distanceMetres: null,
nombrePartants: null,
annulee: false,
categorie: '',
reporteeMemeJour: false,
reporteeAutreJour: false,
incidentTechnique: false,
nonPartants: [],
typesParisOuverts: [],
});
this.selectedHippodromeLabel.set('');
this.form.markAsPristine();
this.form.markAsUntouched();
return;
}
const hippodromeId = v.hippodrome?.id ? String(v.hippodrome.id) : '';
this.form.patchValue(
{
hippodromeId,
reunionNumero: v.reunionNumero ?? null,
reunionDate: v.reunionDate ?? '',
nom: v.nom ?? '',
numero: v.numero ?? null,
statut: v.statut ?? CourseStatut.BROUILLON,
heureDepartPrevue: v.heureDepartPrevue? this.getDateCorrectFormat(new Date(v.heureDepartPrevue)) :'',
discipline: v.discipline ?? '',
distanceMetres: v.distanceMetres ?? null,
nombrePartants: v.nombrePartants ?? null,
annulee: v.annulee ?? false,
categorie: v.categorie ?? '',
reporteeMemeJour: v.reporteeMemeJour ?? false,
reporteeAutreJour: v.reporteeAutreJour ?? false,
incidentTechnique: v.incidentTechnique ?? false,
nonPartants: v.nonPartants ?? [],
typesParisOuverts: v.typesParisOuverts,
},
{ emitEvent: false }
);
// Set hippodrome label if available
if (hippodromeId && this.hippodromes().length > 0) {
const h = this.hippodromes().find((r) => String(r.id) === hippodromeId);
if (h) this.selectedHippodromeLabel.set(`${h.nom} (${h.ville})`);
else if (v.hippodrome?.nom) this.selectedHippodromeLabel.set(`${v.hippodrome.nom} (${v.hippodrome.ville || ''})`);
} else if (v.hippodrome?.nom) {
this.selectedHippodromeLabel.set(`${v.hippodrome.nom} (${v.hippodrome.ville || ''})`);
}
this.form.markAsPristine();
this.form.markAsUntouched();
}
isInvalid(control: string): boolean { isInvalid(control: string): boolean {
const c = this.form.get(control); const c = this.form.get(control);
return !!(c && c.invalid && (c.touched || this.submitted)); return !!(c && c.invalid && (c.touched || this.submitted));
} }
onToggleType(event: Event) {
const input = event.target as HTMLInputElement;
const value = input.value;
const current = this.form.value.typesParisOuverts ?? [];
this.form.patchValue({
typesParisOuverts: input.checked
? [...current, value]
: current.filter((v: string) => v !== value)
});
}
onSubmit() { onSubmit() {
this.submitted = true; this.submitted = true;
@@ -196,10 +293,7 @@ onSubmit() {
const hippodromeObj = foundHippodrome ?? (hippodromeId ? { id: +hippodromeId } : undefined); const hippodromeObj = foundHippodrome ?? (hippodromeId ? { id: +hippodromeId } : undefined);
// 2⃣ Transformer typesParisOuverts CSV → tableau // 2⃣ Transformer typesParisOuverts CSV → tablea
const typesParis = raw.typesParisOuverts
? raw.typesParisOuverts.split(',').map((s: string) => s.trim()).filter(Boolean)
: [];
// 3⃣ Construire payload // 3⃣ Construire payload
const payload: Partial<CourseApiResponse> = { const payload: Partial<CourseApiResponse> = {
@@ -211,10 +305,10 @@ onSubmit() {
numero: raw.numero ? +raw.numero : undefined, numero: raw.numero ? +raw.numero : undefined,
statut: raw.statut, statut: raw.statut,
discipline: raw.discipline, discipline: raw.discipline,
heureDepartPrevue: new Date(raw.reunionDate+raw.heureDepartPrevu).toISOString(), heureDepartPrevue: new Date(`${raw.reunionDate}T${raw.heureDepartPrevue}:00`).toISOString(),
distanceMetres: raw.distanceMetres ? +raw.distanceMetres : undefined, distanceMetres: raw.distanceMetres ? +raw.distanceMetres : undefined,
nombrePartants: raw.nombrePartants ? +raw.nombrePartants : undefined, nombrePartants: raw.nombrePartants ? +raw.nombrePartants : undefined,
typesParisOuverts: typesParis, typesParisOuverts: raw.typesParisOuverts,
annulee: raw.annulee ?? false, annulee: raw.annulee ?? false,
reporteeMemeJour: raw.reporteeMemeJour ?? false, reporteeMemeJour: raw.reporteeMemeJour ?? false,
reporteeAutreJour: raw.reporteeAutreJour ?? false, reporteeAutreJour: raw.reporteeAutreJour ?? false,
@@ -223,17 +317,20 @@ onSubmit() {
categorie: raw.categorie, categorie: raw.categorie,
}; };
console.log(payload);
// 4⃣ Appeler le service (create ou update) // Persist: create or update via CourseService, then emit the saved Course
if (this.value?.id) { if (this.value && this.value.id) {
this.courseServive.update(this.value.id, payload).subscribe({ this.courseServive.update(this.value.id, payload).subscribe({
next: () => this.save.emit(payload), next: (updated) => {
error: err => console.error('Erreur update course', err) if (updated) this.save.emit(updated);
else console.error('Update returned empty result');
},
error: (err) => console.error('Error updating course:', err),
}); });
} else { } else {
this.courseServive.create(payload).subscribe({ this.courseServive.create(payload).subscribe({
next: () => this.save.emit(payload), next: (created) => this.save.emit(created),
error: err => console.error(err) error: (err) => console.error('Error creating course:', err),
}); });
} }
} }
@@ -272,6 +369,7 @@ errorMessage(control: string): string | null {
{ label: 'Brouillon', value: CourseStatut.BROUILLON }, { label: 'Brouillon', value: CourseStatut.BROUILLON },
{ label: 'Validé', value: CourseStatut.VALIDE }, { label: 'Validé', value: CourseStatut.VALIDE },
{ label: 'Fremé', value: CourseStatut.FERME }, { label: 'Fremé', value: CourseStatut.FERME },
{label: 'Ouvert', value: CourseStatut.OUVERT},
{ label: 'Resultat Provisoire', value: CourseStatut.RESULTAT_PROVISOIRE }, { label: 'Resultat Provisoire', value: CourseStatut.RESULTAT_PROVISOIRE },
{ label: 'Resultat officiel', value: CourseStatut.RESULTAT_OFFICIEL }, { label: 'Resultat officiel', value: CourseStatut.RESULTAT_OFFICIEL },
{ label: 'Reglée', value: CourseStatut.REGLEE }, { label: 'Reglée', value: CourseStatut.REGLEE },

View File

@@ -13,26 +13,24 @@
</div> </div>
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="flex gap-2"> <div class="flex gap-x-3">
<button <button
type="button" type="button"
class="flex-1 px-3 py-2 text-sm rounded bg-gray-900 text-white hover:bg-gray-800 disabled:opacity-50" class="flex-1 px-3 py-2 text-sm rounded bg-blue-400 text-white hover:bg-blue-600 disabled:opacity-50"
(click)="onSave()" (click)="onSave()"
[disabled]="!canSave()" [disabled]="!canSave()">
>
Enregistrer Enregistrer
</button> </button>
<button <button
type="button" type="button"
class="px-3 py-2 text-sm rounded border border-blue-300 bg-blue-50 text-blue-700 hover:bg-blue-100 disabled:opacity-50" class="flex-1 px-3 py-2 text-sm rounded border border-blue-300 bg-blue-50 text-blue-700 hover:bg-blue-100 disabled:opacity-50"
(click)="validate.emit()" (click)="validate.emit()"
[disabled]="!canValidate()" [disabled]="!canValidate()">
>
Valider Valider
</button> </button>
<button <button
type="button" type="button"
class="px-3 py-2 text-sm rounded border border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 disabled:opacity-50" class="flex-1 px-3 py-2 text-sm rounded border border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 disabled:opacity-50"
(click)="confirm.emit()" (click)="confirm.emit()"
[disabled]="!canConfirm()" [disabled]="!canConfirm()"
> >

View File

@@ -25,7 +25,10 @@ type ResultatShape = { places: FormArray<FormGroup<PlaceRow>> };
export class ResultatForm { export class ResultatForm {
@Input() course!: Course; @Input() course!: Course;
@Input() resultat?: Resultat | null; @Input() resultat?: Resultat | null;
@Output() save = new EventEmitter<number[][]>(); @Output() save = new EventEmitter<{
horses: number[][];
typesParisOuverts: string[];
}>();
@Output() validate = new EventEmitter<void>(); @Output() validate = new EventEmitter<void>();
@Output() confirm = new EventEmitter<void>(); @Output() confirm = new EventEmitter<void>();
@Output() cancel = new EventEmitter<void>(); @Output() cancel = new EventEmitter<void>();
@@ -34,21 +37,40 @@ export class ResultatForm {
places: new FormArray<FormGroup<PlaceRow>>([]), places: new FormArray<FormGroup<PlaceRow>>([]),
}); });
getRequiredHorses = (types: string[]): number => {
// Normalize types to uppercase strings for robust comparisons
const normalized = (types || []).map((t) => String(t).toUpperCase());
if (normalized.includes('QUINTE')) return 5;
if (normalized.includes('QUATRO')) return 4;
if (normalized.includes('TRIO') || normalized.includes('TRIO_ORDRE') || normalized.includes('TRIPLET')) return 3;
if (
normalized.includes('JUMELE_GAGNANT') ||
normalized.includes('JUMELE_PLACE') ||
normalized.includes('JUMELE_ORDRE')
)
return 2;
if (normalized.includes('PLACE') || normalized.includes('GAGNANT')) return 1;
return 3; // Default
};
reqLen = computed(() => reqLen = computed(() =>
3 this.getRequiredHorses(this.course.typesParisOuverts)
); );
maxNum = computed(() => this.course?.nombrePartants ?? 0); maxNum = computed(() => this.course?.nombrePartants ?? 0);
npSet = computed(() => new Set(this.course?.nonPartants ?? [])); // Ensure non-partants are compared as strings to avoid type mismatches
statut = computed((): 'CREATED' | 'VALIDATED' | 'NONE' => { npSet = computed(() => new Set((this.course?.nonPartants ?? []).map((v) => String(v))));
return this.resultat ? 'CREATED' : 'NONE'; statut = computed((): 'PROVISOIRE' | 'OFFICIEL' | 'ANNULE' | 'EN_ATTENTE' => {
return this.resultat ? 'PROVISOIRE' : 'EN_ATTENTE';
}); });
canValidate(): boolean { canValidate(): boolean {
return this.statut() === 'CREATED'; return this.statut() === 'EN_ATTENTE';
} }
canConfirm(): boolean { canConfirm(): boolean {
return this.statut() === 'VALIDATED'; return this.statut() === 'PROVISOIRE';
} }
// Helper methods for template // Helper methods for template
@@ -255,15 +277,25 @@ export class ResultatForm {
const len = this.reqLen(); const len = this.reqLen();
// Extract existing places from resultat if available. // Extract existing places from resultat if available.
// Backend now returns ordreArrivee as an array of cheval numbers and // Be defensive: ordreArrivee may be provided as array or CSV string, normalize to number[]
// chevauxDeadHeat as the subset that are in dead-heat (ex-aequo).
let existing: number[][] = []; let existing: number[][] = [];
if (this.resultat && this.resultat.ordreArrivee && this.resultat.ordreArrivee.length > 0) { if (this.resultat && this.resultat.ordreArrivee && (this.resultat.ordreArrivee as any).length > 0) {
const deadHeatSet = new Set(this.resultat.chevauxDeadHeat || []); const rawOrdre = (this.resultat as any).ordreArrivee;
const allHorses: number[] = ((): number[] => {
if (Array.isArray(rawOrdre)) return rawOrdre.map(Number).filter((n) => !Number.isNaN(n));
if (typeof rawOrdre === 'string') {
return rawOrdre
.split(/[^0-9]+/)
.map((s) => Number(s))
.filter((n) => !Number.isNaN(n));
}
const n = Number(rawOrdre);
return Number.isNaN(n) ? [] : [n];
})();
const allHorses = this.resultat.ordreArrivee; const deadHeatSet = new Set(((this.resultat.chevauxDeadHeat ?? []) as any).map(Number));
const allInDeadHeat =
allHorses.every((num) => deadHeatSet.has(num)) && allHorses.length === len; const allInDeadHeat = allHorses.every((num) => deadHeatSet.has(num)) && allHorses.length === len;
if (allInDeadHeat) { if (allInDeadHeat) {
// All horses are in first place (ex-aequo) // All horses are in first place (ex-aequo)
@@ -277,9 +309,9 @@ export class ResultatForm {
const groups: number[][] = []; const groups: number[][] = [];
let currentGroup: number[] = []; let currentGroup: number[] = [];
this.resultat.ordreArrivee.forEach((num, index) => { allHorses.forEach((num, index) => {
const isInDeadHeat = deadHeatSet.has(num); const isInDeadHeat = deadHeatSet.has(num);
const prevNum = index > 0 ? this.resultat!.ordreArrivee[index - 1] : null; const prevNum = index > 0 ? allHorses[index - 1] : null;
const prevIsInDeadHeat = prevNum !== null && deadHeatSet.has(prevNum); const prevIsInDeadHeat = prevNum !== null && deadHeatSet.has(prevNum);
if (isInDeadHeat && prevIsInDeadHeat && currentGroup.length > 0) { if (isInDeadHeat && prevIsInDeadHeat && currentGroup.length > 0) {
@@ -538,6 +570,6 @@ export class ResultatForm {
.filter((v): v is number => typeof v === 'number'); .filter((v): v is number => typeof v === 'number');
places.push(placeHorses); places.push(placeHorses);
} }
this.save.emit(places); this.save.emit({horses: places, typesParisOuverts: this.course.typesParisOuverts});
} }
} }

View File

@@ -227,7 +227,7 @@ export class ReunionForm implements OnInit, AfterViewInit, OnDestroy {
// Fetch hippodromes from API // Fetch hippodromes from API
this.loadingHippodromes.set(true); this.loadingHippodromes.set(true);
this.subs.add( this.subs.add(
this.hippodromeService.list({ page: 1, perPage: 1000 }, false).subscribe({ this.hippodromeService.list({ page: 1, size: 1000 }, false).subscribe({
next: (result) => { next: (result) => {
this.hippodromes.set(result.content); this.hippodromes.set(result.content);
this.loadingHippodromes.set(false); this.loadingHippodromes.set(false);

View File

@@ -10,7 +10,7 @@ export class TableDataSource {
readonly params = computed<ListParams>(() => ({ readonly params = computed<ListParams>(() => ({
page: this.page(), page: this.page(),
perPage: this.perPage(), size: this.perPage(),
search: this.search() || undefined, search: this.search() || undefined,
sortKey: this.sortKey(), sortKey: this.sortKey(),
sortDir: this.sortDir(), sortDir: this.sortDir(),

View File

@@ -40,7 +40,7 @@ export class PaginatedHttpService {
let httpParams = new HttpParams() let httpParams = new HttpParams()
.set(pageParam, String(zeroBasedPageIndex ? Math.max(params.page - 1, 0) : params.page)) .set(pageParam, String(zeroBasedPageIndex ? Math.max(params.page - 1, 0) : params.page))
.set(sizeParam, String(params.perPage)); .set(sizeParam, String(params.size));
if (params.search) httpParams = httpParams.set(searchParam, params.search); if (params.search) httpParams = httpParams.set(searchParam, params.search);
@@ -56,6 +56,6 @@ export class PaginatedHttpService {
return this.http return this.http
.get<any>(url, { params: httpParams, headers: this.getNgrokHeaders() }) .get<any>(url, { params: httpParams, headers: this.getNgrokHeaders() })
.pipe(map((raw) => normalizePage<T>(raw, params.page, params.perPage))); .pipe(map((raw) => normalizePage<T>(raw, params.page, params.size)));
} }
} }

View File

@@ -3,7 +3,7 @@ export type SortDir = 'asc' | 'desc' | '';
export interface ListParams { export interface ListParams {
page: number; // 1-based for UI page: number; // 1-based for UI
perPage: number; size: number;
search?: string; search?: string;
sortKey?: string; sortKey?: string;
sortDir?: SortDir; sortDir?: SortDir;

View File

@@ -1,4 +1,4 @@
export const environment = { export const environment = {
production: false, production: false,
apiBaseUrl: 'https://ddd3b90fc1ef.ngrok-free.app', apiBaseUrl: 'http://192.168.1.235:8280',
}; };

View File

@@ -1,4 +1,4 @@
export const environment = { export const environment = {
production: false, production: false,
apiBaseUrl: 'https://ddd3b90fc1ef.ngrok-free.app', apiBaseUrl: 'http://192.168.1.235:8280',
}; };