first commit

This commit is contained in:
OnlyPapy98
2025-12-16 14:20:02 +01:00
commit dde2e8aebf
320 changed files with 30462 additions and 0 deletions

View 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');
},
});
}
}