Files
pmu-plateforme-jeux-admin-plr/src/app/dashboard/pages/courses/courses.ts
OnlyPapy98 87c33f25cf save result
2025-12-31 10:24:22 +01:00

530 lines
17 KiB
TypeScript

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