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([]); resultatsMap = signal>(new Map()); loading = signal(false); total = signal(0); totalRunning = signal(0); totalClosed = signal(0); totalByType = signal>({}); page = signal(0); perPage = signal(10); search = signal(''); sort = signal({ key: 'id', dir: 'asc' }); pageSize = [10, 20, 50]; modalOpen = signal(false); modalTitle = signal('Nouvelle course'); editingItem = signal(null); @ViewChild(CourseForm) formComp?: CourseForm; // 🟩 Corrected columns cols: TableColumn[] = [ { key: 'numero', label: 'N°', sortable: true }, { key: 'nom', label: 'Nom', sortable: true }, { key: 'type', label: 'Type', sortable: true, cell: (c) => `${c.discipline}`, }, { key: 'dateDepartCourse', label: 'Date et Heure Départ', sortable: true, cell: (c) => c.heureDepartPrevue }, { key: 'partants', label: 'Partants', cell: (c) => `${c.nombrePartants} (${ c.nonPartants?.length ?? 0 } NP)`, }, { key: 'resultat', label: 'Résultat', cell: (c) => { const resultat = this.resultatsMap().get(c.id); if (!resultat || !resultat.ordreArrivee || resultat.ordreArrivee.length === 0) { return '—'; } return `${resultat.ordreArrivee}` // // 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 = { 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 = { 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 `${ labelMap[c.statut] }`; }, }, { 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([]); 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(); 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(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(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'); }, }); } }