530 lines
17 KiB
TypeScript
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');
|
|
},
|
|
});
|
|
}
|
|
}
|