This commit is contained in:
OnlyPapy98
2025-12-30 19:09:01 +01:00
parent ed79cae77d
commit f21a5fd4e6
22 changed files with 554 additions and 315 deletions

View File

@@ -22,7 +22,7 @@ const routes: Routes = [
loadComponent: () => import('./pages/reunion/reunion').then((m) => m.ReunionList),
},
{
path: 'rapport',
path: 'resultat',
loadComponent: () => import('./pages/rapport/rapport').then((m) => m.Rapport),
},
{

View File

@@ -46,7 +46,7 @@ export class Layout {
{ icon: '🏟️', label: 'Hippodromes', link: '/hippodromes' },
{ icon: '📅', label: 'Reunions', link: '/reunions' },
{ icon: '🏇', label: 'Courses', link: '/courses' },
{ icon: 'icon-chart-bar', label: 'Rapport des courses', link: '/rapport' },
{ icon: 'icon-chart-bar', label: 'Résultats des courses', link: '/resultat' },
];
workspaceMenuItems: MenuItem[] = [

View File

@@ -88,54 +88,16 @@ export class AgentsPage {
}
cols: TableColumn<Agent>[] = [
{ key: 'code', label: 'Code', sortable: true },
{ key: 'nom', label: 'Nom', sortable: true },
{ key: 'prenom', label: 'Prénom', sortable: true },
{ key: 'phone', label: 'Téléphone', sortable: true },
{
key: 'tpes',
label: 'TPE assignés',
cell: (a) => {
const tpes = this.agentTpesMap.get(a.id) || [];
if (tpes.length === 0) {
return '<span class="text-muted-foreground text-sm">Aucun</span>';
}
// Show up to 2 TPEs with full details, then count for the rest
const displayCount = Math.min(2, tpes.length);
const displayed = tpes.slice(0, displayCount);
const remaining = tpes.length - displayCount;
const tpeCards = displayed
.map((t) => {
const imei = `<div class="font-medium text-xs">${t.imei}</div>`;
const details = [
t.marque && t.modele ? `${t.marque} ${t.modele}` : t.marque || t.modele || '',
t.statut ? this.formatTpeStatut(t.statut) : '',
]
.filter(Boolean)
.join(' • ');
const detailsHtml = details
? `<div class="text-xs text-muted-foreground">${details}</div>`
: '';
return `<div class="px-2 py-1.5 rounded bg-primary/10 border border-primary/20 flex flex-col gap-0.5">${imei}${detailsHtml}</div>`;
})
.join(' ');
const moreHtml =
remaining > 0
? `<div class="text-xs text-muted-foreground px-2 py-1.5">+${remaining} autre${
remaining > 1 ? 's' : ''
}</div>`
: '';
return `<div class="flex flex-col gap-1">${tpeCards}${moreHtml}</div>`;
},
},
{ key: 'code', label: 'Code', sortable: true, defaultVisible: true },
{ key: 'nomPrenom', label: 'Nom complet', sortable: true, defaultVisible: true, cell: (a) => `${a.nom} ${a.prenom}` },
{ key: 'profile', label: 'Profil', sortable: true, defaultVisible: true },
{ key: 'statut', label: 'Statut', sortable: true, defaultVisible: true, cell: (a) => this.renderStatutBadge(a.statut) },
{ key: 'phone', label: 'Téléphone', sortable: true, defaultVisible: true },
{ key: 'zone', label: 'Zone', sortable: true },
{ key: 'kiosk', label: 'Kiosque', sortable: true },
{ key: 'profile', label: 'Profil', sortable: true },
{ key: 'statut', label: 'Statut', sortable: true },
{ key: 'limiteSuperieure', label: 'Limite sup.', sortable: true },
{ key: 'tpes', label: 'TPE', cell: (a) => `${(this.getAgentTpes(a.id) || []).length}` },
{ key: 'limites', label: 'Limites', cell: (a) => this.formatLimits(a) },
{ key: 'dateEmbauche', label: 'Embauché le', cell: (a) => (a.dateEmbauche ? new Date(a.dateEmbauche).toLocaleDateString() : '') },
];
tpeMap = new Map<string, TpeDevice>();
@@ -216,6 +178,26 @@ export class AgentsPage {
return this.agentTpesMap.get(agentId) || [];
}
renderStatutBadge(statut: Agent['statut'] | string | undefined): string {
if (!statut) return '';
const s = String(statut).toUpperCase();
if (s === 'ACTIF') {
return `<span class="inline-flex items-center gap-1 px-2 py-1 rounded bg-green-500/10 text-green-600 dark:text-green-400 text-xs font-medium"><i class="icon-check"></i> Actif</span>`;
}
if (s === 'INACTIF') {
return `<span class="inline-flex items-center gap-1 px-2 py-1 rounded bg-gray-500/10 text-gray-600 dark:text-gray-400 text-xs font-medium"><i class="icon-x"></i> Inactif</span>`;
}
return `<span class="inline-flex items-center gap-1 px-2 py-1 rounded bg-orange-500/10 text-orange-600 dark:text-orange-400 text-xs font-medium"><i class="icon-alert-circle"></i> Suspendu</span>`;
}
formatLimits(a: Agent): string {
const parts: string[] = [];
const nf = new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 });
if (a.limiteInferieure !== undefined) parts.push(nf.format(a.limiteInferieure));
if (a.limiteSuperieure !== undefined) parts.push(nf.format(a.limiteSuperieure));
return parts.length ? parts.join(' — ') : '';
}
onSearch(q: string) {
this.search.set(q);
this.page.set(1);

View File

@@ -15,7 +15,7 @@
</z-card>
<z-card class="text-center py-4">
<div class="text-sm text-gray-500 dark:text-gray-400">En cours</div>
<div class="text-sm text-gray-500 dark:text-gray-400">Ouverts</div>
<div class="text-3xl font-bold text-amber-600 dark:text-amber-400 mt-1">
{{ runningCourses() }}
</div>
@@ -29,7 +29,7 @@
</z-card>
<z-card class="text-center py-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Par type</div>
<div class="text-sm text-gray-500 dark:text-gray-400">Nombre de statuts</div>
<div class="text-sm mt-2 text-gray-900 dark:text-gray-100 space-y-1">
@for (type of (byType() | keyvalue); track type.key) {
<div class="flex justify-between px-3">
@@ -116,19 +116,27 @@
</div>
<!-- Modal -->
@if (modalOpen()) {
<app-modal [open]="modalOpen()" [title]="modalTitle()" size="xl" (close)="closeModal()">
@if(modalOpen()) {
<app-course-form
[value]="editingItem() ?? undefined"
(save)="onFormSave($event)"
(cancel)="closeModal()"
></app-course-form>
}
<div modal-actions class="flex gap-2 justify-end">
<z-button zType="destructive" (click)="closeModal()">Annuler</z-button>
<z-button zType="default" (click)="submitChildForm()">Enregistrer</z-button>
<z-button
zType="default"
(click)="submitChildForm()"
[attr.aria-disabled]="formComp?.form?.invalid ? 'true' : null"
[class.opacity-50]="formComp?.form?.invalid"
[class.pointer-events-none]="formComp?.form?.invalid"
>
Enregistrer
</z-button>
</div>
</app-modal>
}
@if(selectedCourse()) {
<app-modal

View File

@@ -14,7 +14,7 @@ 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 { CourseStatut, Course as CourseType } from 'src/app/core/interfaces/course';
import { SortDir } from '@shared/paging/paging';
import { CourseApiResponse, CourseService } from 'src/app/core/services/course';
import { ResultatService } from 'src/app/core/services/resultat';
@@ -210,9 +210,11 @@ export class Course {
next: (res) => {
this.rows.set(res.content);
this.total.set(res.totalElements);
this.totalRunning.set(0);
this.totalClosed.set(0);
this.totalByType.set({});
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);
@@ -465,7 +467,10 @@ export class Course {
}
// For now, validation is just an update. In the future, you might add a statut field
this.resultatService.update(resultat.id, {}).subscribe({
this.resultatService.update(resultat.id, {
...resultat,
statut: ResultatStatut.PROVISOIRE
}).subscribe({
next: () => {
this.closeResultatModal();
this.fetch({
@@ -495,7 +500,10 @@ export class Course {
}
// For now, confirmation is just an update. In the future, you might add a statut field
this.resultatService.update(resultat.id, {}).subscribe({
this.resultatService.update(resultat.id, {
...resultat,
statut: ResultatStatut.OFFICIEL
}).subscribe({
next: () => {
this.closeResultatModal();
this.fetch({

View File

@@ -1,13 +1,18 @@
<div class="space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-semibold">Rapport — Courses avec résultats</h2>
<z-button zType="default" (click)="fetch()">Récupérer le rapport</z-button>
<h2 class="text-2xl font-semibold">Résultats — Courses</h2>
<z-button zType="default" (click)="fetch()">Récupérer les résultats</z-button>
</div>
<div class="mt-4">
<app-data-table [data]="rows()" [columns]="cols" [loading]="loading()">
<ng-template #rowActions let-row>
<z-button zType="ghost" zSize="icon" aria-label="Voir le rapport" (click)="openReport(row)">
<z-button
(click)="sendToDepouillement(row)"
[zLoading]="isSending(row.id)"
zType="ghost"
zSize="icon"
aria-label="Voir le résultat" >
<div class="icon-file-text"></div>
</z-button>
</ng-template>

View File

@@ -4,8 +4,11 @@ import { DataTable, TableColumn } from '@shared/components/data-table/data-table
import { ZardButtonComponent } from '@shared/components/button/button.component';
import { ZardPaginationModule } from '@shared/components/pagination/pagination.module';
import { ListParams, PagedResult } from '@shared/paging/paging';
import { ResultatApiResponse } from 'src/app/core/interfaces/resultat';
import { ResultatApiResponse, ResultatStatut } from 'src/app/core/interfaces/resultat';
import { ResultatService } from 'src/app/core/services/resultat';
import { Depouillement, ResultatCourse } from 'src/app/core/services/depouillement';
import { Course } from 'src/app/core/interfaces/course';
import { toast } from 'ngx-sonner';
@Component({
standalone: true,
@@ -17,6 +20,7 @@ import { ResultatService } from 'src/app/core/services/resultat';
export class Rapport {
rows = signal<ResultatApiResponse[]>([]);
loading = signal(false);
sending = signal<Map<string, boolean>>(new Map());
// Pagination state
page = signal<number>(1);
perPage = signal<number>(10);
@@ -34,7 +38,7 @@ export class Rapport {
{ key: 'dateValidation', label: 'Date validation' },
];
constructor(private api: ResultatService) {
constructor(private api: ResultatService, private depouillement: Depouillement) {
// initial load
this.fetch();
}
@@ -85,10 +89,90 @@ export class Rapport {
openReport(row: ResultatApiResponse) {
try {
// Open a per-result report URL in a new tab. Adjust path if your server uses another route.
const url = `/rapport/${row.id}`;
const url = `/resultat/${row.id}`;
window.open(url, '_blank');
} catch (err) {
console.error('Failed to open report for', row, err);
}
}
isSending(id: string | number) {
return !!this.sending().get(String(id));
}
private setSending(id: string | number, v: boolean) {
const map = new Map(this.sending());
map.set(String(id), v);
this.sending.set(map);
}
sendToDepouillement(row: ResultatApiResponse) {
if (!row || !row.id) return;
const id = String(row.id);
if (this.isSending(id)) return; // already sending
this.setSending(id, true);
// Build a minimal ResultatCourse payload using available fields.
const course: Course = {
id: String((row as any).courseId ?? ''),
hippodrome: undefined,
reunionNumero: Number((row as any).reunionNumero ?? 0),
reunionDate: '',
nom: row.courseNom ?? '',
numero: Number(row.courseNumero ?? 0),
heureDepartPrevue: '',
discipline: '',
distanceMetres: 0,
categorie: '',
nombrePartants: 0,
statut: '',
annulee: false,
reporteeMemeJour: false,
reporteeAutreJour: false,
incidentTechnique: false,
nonPartants: [],
typesParisOuverts: [],
};
const payload: ResultatCourse = {
id: Number(row.id as any),
course,
statut: (row.statut as any) ?? (0 as any),
ordreArrivee: String(row.ordreArrivee ?? ''),
datePublication: row.datePublication ?? row.createdAt,
dateValidation: row.dateValidation,
dateAnnulation: row.dateAnnulation,
notes: '',
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
this.depouillement.sendResultat(payload).subscribe({
next: (res) => {
console.debug('Depouillement sent:', res);
// After successful depouillement, update the resultat statut to PROVISOIRE
const updateId = String((res && (res as any).id) ?? row.id);
this.api.update(updateId, { statut: ResultatStatut.PROVISOIRE }).subscribe({
next: (updated) => {
// Update the local rows to reflect the new statut
this.rows.set(
this.rows().map((r) => (String(r.id) === String(updateId) ? { ...r, statut: ResultatStatut.PROVISOIRE } : r))
);
toast.success('Résultat envoyé au dépouillement et statut mis à jour.');
this.setSending(id, false);
},
error: (err) => {
console.error('Error updating resultat statut after depouillement:', err);
toast.error('Échec de la mise à jour du statut du résultat.');
this.setSending(id, false);
},
});
},
error: (err) => {
console.error('Error sending to depouillement:', err);
this.setSending(id, false);
},
});
}
}

View File

@@ -72,6 +72,7 @@
/>
</div>
@if (modalOpen()) {
<app-modal [open]="modalOpen()" [title]="modalTitle()" size="md" (close)="closeModal()">
<app-reunion-form
[value]="editingItem()"
@@ -84,4 +85,5 @@
<z-button zType="default" (click)="submitChildForm()">Enregistrer</z-button>
</div>
</app-modal>
}
</div>

View File

@@ -143,11 +143,13 @@
</div>
<app-modal [open]="modalOpen()" [title]="modalTitle()" (close)="closeModal()" size="xl">
@if (modalOpen()) {
<app-tpe-form
[value]="editingItem() ?? undefined"
(save)="onFormSave($event)"
(cancel)="closeModal()"
/>
}
<div modal-actions class="flex justify-end gap-2">
<z-button zType="destructive" (click)="closeModal()">Annuler</z-button>
<z-button (click)="submitChildForm()">Enregistrer</z-button>