first commit
This commit is contained in:
554
src/app/dashboard/pages/courses/courses.ts
Normal file
554
src/app/dashboard/pages/courses/courses.ts
Normal file
@@ -0,0 +1,554 @@
|
||||
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 { Course as CourseType } from 'src/app/core/interfaces/course';
|
||||
import { SortDir } from '@shared/paging/paging';
|
||||
import { CourseService } from 'src/app/core/services/course';
|
||||
import { ResultatService } from 'src/app/core/services/resultat';
|
||||
import { Resultat } 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(1);
|
||||
perPage = signal(10);
|
||||
search = signal('');
|
||||
sort = signal<SortState>({ key: 'numero', 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.type}</span>`,
|
||||
},
|
||||
{
|
||||
key: 'dateDepartCourse',
|
||||
label: 'Date et Heure Départ',
|
||||
sortable: true,
|
||||
cell: (c) =>
|
||||
new Date(c.dateDepartCourse).toLocaleDateString('fr-FR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: 'partants',
|
||||
label: 'Partants',
|
||||
cell: (c) =>
|
||||
`<span>${c.partants}</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>';
|
||||
}
|
||||
|
||||
// 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 `<span class="mr-2">${s}</span>`;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'statut',
|
||||
label: 'Statut',
|
||||
sortable: true,
|
||||
cell: (c) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
PROGRAMMEE: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300',
|
||||
CREATED: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-300',
|
||||
VALIDATED: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
|
||||
RUNNING: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
|
||||
CLOSED: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300',
|
||||
CANCELED: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300',
|
||||
};
|
||||
const labelMap: Record<string, string> = {
|
||||
PROGRAMMEE: 'Programmée',
|
||||
CREATED: 'Créée',
|
||||
VALIDATED: 'Validée',
|
||||
RUNNING: 'En cours',
|
||||
CLOSED: 'Clôturée',
|
||||
CANCELED: '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.reunion?.hippodrome ? `${c.reunion.hippodrome.nom}` : '—'),
|
||||
},
|
||||
{
|
||||
key: 'reunion.nom',
|
||||
label: 'Réunion',
|
||||
cell: (c) => c.reunion?.nom ?? '—',
|
||||
},
|
||||
{
|
||||
key: 'distance',
|
||||
label: 'Distance (m)',
|
||||
sortable: true,
|
||||
cell: (c) => c.distance.toLocaleString('fr-FR'),
|
||||
},
|
||||
|
||||
{
|
||||
key: 'createdAt',
|
||||
label: 'Créée le',
|
||||
cell: (c) =>
|
||||
c.createdAt
|
||||
? new Date(c.createdAt).toLocaleDateString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})
|
||||
: '—',
|
||||
},
|
||||
];
|
||||
|
||||
visibleKeys = signal<string[]>([]);
|
||||
|
||||
constructor(private api: CourseService, private resultatService: ResultatService) {
|
||||
effect(() => {
|
||||
const params = {
|
||||
page: this.page(),
|
||||
perPage: this.perPage(),
|
||||
search: this.search(),
|
||||
sortKey: this.sort().key,
|
||||
sortDir: this.sort().dir as SortDir,
|
||||
};
|
||||
untracked(() => this.fetch(params));
|
||||
});
|
||||
}
|
||||
|
||||
private fetch(params: {
|
||||
page: number;
|
||||
perPage: number;
|
||||
search: string;
|
||||
sortKey: string;
|
||||
sortDir: SortDir;
|
||||
}) {
|
||||
this.loading.set(true);
|
||||
this.api.list(params).subscribe({
|
||||
next: (res) => {
|
||||
this.rows.set(res.data);
|
||||
this.total.set(res.meta.total);
|
||||
this.totalRunning.set(res.meta['totalRunning'] ?? 0);
|
||||
this.totalClosed.set(res.meta['totalClosed'] ?? 0);
|
||||
this.totalByType.set(res.meta['totalByType'] ?? {});
|
||||
|
||||
// Fetch resultats for all courses in parallel
|
||||
const courseIds = res.data.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(1);
|
||||
}
|
||||
|
||||
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(payload: Partial<CourseType>) {
|
||||
const current = this.editingItem();
|
||||
const req$ = current?.id
|
||||
? this.api.update(current.id, payload)
|
||||
: this.api.create(payload as Omit<CourseType, 'id'>);
|
||||
|
||||
req$.subscribe(() => {
|
||||
this.closeModal();
|
||||
this.fetch({
|
||||
page: this.page(),
|
||||
perPage: 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(),
|
||||
perPage: 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;
|
||||
|
||||
this.api.setNonPartants(course.id, payload).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(),
|
||||
perPage: 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[][]) {
|
||||
const c = this.selectedCourseForResultat();
|
||||
if (!c) return;
|
||||
|
||||
// Determine required number of horses based on course type
|
||||
const getRequiredHorses = (type: string): number => {
|
||||
const typeStr = String(type).toUpperCase();
|
||||
if (typeStr.includes('TIERCE') || typeStr === 'PLAT') return 3;
|
||||
if (typeStr.includes('QUARTE')) return 4;
|
||||
if (typeStr.includes('QUINTE')) return 5;
|
||||
return 3; // Default
|
||||
};
|
||||
|
||||
const requiredHorses = getRequiredHorses(c.type);
|
||||
|
||||
// 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
|
||||
const ordreArrivee: Array<string> = [];
|
||||
const chevauxDeadHeat: number[] = [];
|
||||
|
||||
if (isAllExAequo) {
|
||||
// All horses are in first place (ex-aequo)
|
||||
allHorses.forEach((numero) => {
|
||||
ordreArrivee.push(numero.toString());
|
||||
chevauxDeadHeat.push(numero);
|
||||
});
|
||||
} else {
|
||||
// Horses are distributed across places
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Check if resultat already exists
|
||||
const existingResultat = this.resultatsMap().get(c.id);
|
||||
|
||||
const payload = {
|
||||
course: { id: c.id },
|
||||
ordreArrivee,
|
||||
chevauxDeadHeat: chevauxDeadHeat.map((n) => String(n)),
|
||||
totalMises: 0,
|
||||
masseAPartager: 0,
|
||||
prelevementsLegaux: 0,
|
||||
montantRembourse: 0,
|
||||
montantCagnotte: 0,
|
||||
adeadHeat: chevauxDeadHeat.length > 0,
|
||||
};
|
||||
|
||||
const request$ = existingResultat
|
||||
? this.resultatService.update(existingResultat.id, payload)
|
||||
: this.resultatService.create(payload);
|
||||
|
||||
request$.subscribe({
|
||||
next: () => {
|
||||
this.closeResultatModal();
|
||||
this.fetch({
|
||||
page: this.page(),
|
||||
perPage: 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, {}).subscribe({
|
||||
next: () => {
|
||||
this.closeResultatModal();
|
||||
this.fetch({
|
||||
page: this.page(),
|
||||
perPage: 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, {}).subscribe({
|
||||
next: () => {
|
||||
this.closeResultatModal();
|
||||
this.fetch({
|
||||
page: this.page(),
|
||||
perPage: 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');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user