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,405 @@
<div class="flex flex-col gap-2 min-h-screen">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-semibold">Gestion des Agents</h2>
<z-button (click)="openCreate()">Nouvel agent</z-button>
</div>
<app-search-bar (search)="onSearch($event)"></app-search-bar>
<app-data-table [data]="rows()" [columns]="cols" [sort]="sort()" (sortChange)="sort.set($event)">
<ng-template #rowActions let-row>
<div class="flex gap-3">
<button z-button zType="ghost" (click)="openDetail(row)" title="Voir les détails">
<i class="icon-eye"></i>
</button>
<button z-button zType="ghost" (click)="openAssignTpe(row)" title="Assigner un TPE">
<i class="icon-plus"></i>
</button>
<button z-button zType="ghost" (click)="openEdit(row)" title="Modifier">
<i class="icon-pen"></i>
</button>
<button z-button zType="destructive" (click)="remove(row)" title="Supprimer">
<i class="icon-trash"></i>
</button>
</div>
</ng-template>
</app-data-table>
<app-paginator
[total]="total()"
[page]="page()"
[perPage]="perPage()"
(pageChange)="page.set($event)"
(perPageChange)="perPage.set($event)"
></app-paginator>
</div>
<app-modal [open]="modalOpen()" [title]="modalTitle()" (close)="closeModal()" size="xxl">
<app-agent-full-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>
</div>
</app-modal>
<!-- Detail Modal -->
@if (detailItem()) {
<app-modal [open]="detailModalOpen()" [title]="'Détails de l\'agent'" (close)="closeDetailModal()" size="xxl">
@if (detailItem(); as agent) {
<div class="space-y-6">
<!-- Informations Emploi -->
<z-card class="p-4">
<div class="text-lg font-semibold mb-4">Informations Emploi</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<div class="text-xs text-muted-foreground mb-1">Code</div>
<div class="font-medium">{{ agent.code }}</div>
</div>
<div>
<div class="text-xs text-muted-foreground mb-1">Profil</div>
<div class="font-medium">{{ agent.profile }}</div>
</div>
<div>
<div class="text-xs text-muted-foreground mb-1">Statut</div>
<div class="font-medium">
@if (agent.statut === 'ACTIF') {
<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>
} @else if (agent.statut === 'INACTIF') {
<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>
} @else {
<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>
}
</div>
</div>
@if (agent.principalCode) {
<div>
<div class="text-xs text-muted-foreground mb-1">Agent Principal</div>
<div class="font-medium">{{ agent.principalCode }}</div>
</div>
}
@if (agent.zone) {
<div>
<div class="text-xs text-muted-foreground mb-1">Zone</div>
<div class="font-medium">{{ agent.zone }}</div>
</div>
}
@if (agent.kiosk) {
<div>
<div class="text-xs text-muted-foreground mb-1">Kiosque</div>
<div class="font-medium">{{ agent.kiosk }}</div>
</div>
}
@if (agent.fonction) {
<div>
<div class="text-xs text-muted-foreground mb-1">Fonction</div>
<div class="font-medium">{{ agent.fonction }}</div>
</div>
}
@if (agent.dateEmbauche) {
<div>
<div class="text-xs text-muted-foreground mb-1">Date Embauche</div>
<div class="font-medium">{{ agent.dateEmbauche | date: 'dd/MM/yyyy' }}</div>
</div>
}
</div>
</z-card>
<!-- Informations Personnelles -->
<z-card class="p-4">
<div class="text-lg font-semibold mb-4">Informations Personnelles</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<div class="text-xs text-muted-foreground mb-1">Nom</div>
<div class="font-medium">{{ agent.nom }}</div>
</div>
<div>
<div class="text-xs text-muted-foreground mb-1">Prénom</div>
<div class="font-medium">{{ agent.prenom }}</div>
</div>
@if (agent.autresNoms) {
<div>
<div class="text-xs text-muted-foreground mb-1">Autre(s) Nom(s)</div>
<div class="font-medium">{{ agent.autresNoms }}</div>
</div>
}
@if (agent.dateNaissance) {
<div>
<div class="text-xs text-muted-foreground mb-1">Date de naissance</div>
<div class="font-medium">{{ agent.dateNaissance | date: 'dd/MM/yyyy' }}</div>
</div>
}
@if (agent.lieuNaissance) {
<div>
<div class="text-xs text-muted-foreground mb-1">Lieu de naissance</div>
<div class="font-medium">{{ agent.lieuNaissance }}</div>
</div>
}
@if (agent.ville) {
<div>
<div class="text-xs text-muted-foreground mb-1">Ville</div>
<div class="font-medium">{{ agent.ville }}</div>
</div>
}
@if (agent.adresse) {
<div class="md:col-span-2">
<div class="text-xs text-muted-foreground mb-1">Adresse</div>
<div class="font-medium">{{ agent.adresse }}</div>
</div>
}
@if (agent.phone) {
<div>
<div class="text-xs text-muted-foreground mb-1">Téléphone</div>
<div class="font-medium">{{ agent.phone }}</div>
</div>
}
@if (agent.autoriserAides !== undefined) {
<div>
<div class="text-xs text-muted-foreground mb-1">Autoriser Aides</div>
<div class="font-medium">
@if (agent.autoriserAides) {
<span class="text-green-600 dark:text-green-400">Oui</span>
} @else {
<span class="text-gray-600 dark:text-gray-400">Non</span>
}
</div>
</div>
}
</div>
</z-card>
<!-- Limites et Configuration -->
<z-card class="p-4">
<div class="text-lg font-semibold mb-4">Limites et Configuration</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
@if (agent.limiteInferieure !== undefined) {
<div>
<div class="text-xs text-muted-foreground mb-1">Limite inférieure</div>
<div class="font-medium">{{ agent.limiteInferieure | number: '1.2-2' }}</div>
</div>
}
@if (agent.limiteSuperieure !== undefined) {
<div>
<div class="text-xs text-muted-foreground mb-1">Limite supérieure</div>
<div class="font-medium">{{ agent.limiteSuperieure | number: '1.2-2' }}</div>
</div>
}
@if (agent.limiteParTransaction !== undefined) {
<div>
<div class="text-xs text-muted-foreground mb-1">Limite / transaction</div>
<div class="font-medium">{{ agent.limiteParTransaction | number: '1.2-2' }}</div>
</div>
}
@if (agent.limiteMinAirtime !== undefined) {
<div>
<div class="text-xs text-muted-foreground mb-1">Limite min airtime</div>
<div class="font-medium">{{ agent.limiteMinAirtime | number: '1.2-2' }}</div>
</div>
}
@if (agent.limiteMaxAirtime !== undefined) {
<div>
<div class="text-xs text-muted-foreground mb-1">Limite max airtime</div>
<div class="font-medium">{{ agent.limiteMaxAirtime | number: '1.2-2' }}</div>
</div>
}
@if (agent.maxPeripheriques !== undefined) {
<div>
<div class="text-xs text-muted-foreground mb-1">Nbre max. périphériques</div>
<div class="font-medium">{{ agent.maxPeripheriques }}</div>
</div>
}
</div>
</z-card>
<!-- Informations Légales -->
@if (agent.nationalite || agent.cni || agent.cniDelivreeLe || agent.residence || agent.statutMarital) {
<z-card class="p-4">
<div class="text-lg font-semibold mb-4">Informations Légales</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
@if (agent.nationalite) {
<div>
<div class="text-xs text-muted-foreground mb-1">Nationalité</div>
<div class="font-medium">{{ agent.nationalite }}</div>
</div>
}
@if (agent.cni) {
<div>
<div class="text-xs text-muted-foreground mb-1">N° CNI</div>
<div class="font-medium">{{ agent.cni }}</div>
</div>
}
@if (agent.cniDelivreeLe) {
<div>
<div class="text-xs text-muted-foreground mb-1">CNI Délivrée le</div>
<div class="font-medium">{{ agent.cniDelivreeLe | date: 'dd/MM/yyyy' }}</div>
</div>
}
@if (agent.cniDelivreeA) {
<div>
<div class="text-xs text-muted-foreground mb-1">CNI Délivrée à</div>
<div class="font-medium">{{ agent.cniDelivreeA }}</div>
</div>
}
@if (agent.residence) {
<div>
<div class="text-xs text-muted-foreground mb-1">Résidence</div>
<div class="font-medium">{{ agent.residence }}</div>
</div>
}
@if (agent.statutMarital) {
<div>
<div class="text-xs text-muted-foreground mb-1">Statut marital</div>
<div class="font-medium">{{ agent.statutMarital }}</div>
</div>
}
@if (agent.epoux) {
<div>
<div class="text-xs text-muted-foreground mb-1">Époux/Épouse</div>
<div class="font-medium">{{ agent.epoux }}</div>
</div>
}
</div>
</z-card>
}
<!-- Membres de famille -->
@if (detailFamilyMembers().length > 0) {
<z-card class="p-4">
<div class="text-lg font-semibold mb-4">Membres de famille</div>
<div class="space-y-3">
@for (member of detailFamilyMembers(); track member.id || $index) {
<div class="border rounded-lg p-3 bg-surface/50">
<div class="flex items-start justify-between">
<div class="flex-1 grid grid-cols-1 md:grid-cols-4 gap-3">
<div>
<div class="text-xs text-muted-foreground mb-1">Nom</div>
<div class="font-medium">{{ member.nom }}</div>
</div>
@if (member.statut) {
<div>
<div class="text-xs text-muted-foreground mb-1">Statut</div>
<div class="font-medium">{{ member.statut }}</div>
</div>
}
@if (member.dateNaissance) {
<div>
<div class="text-xs text-muted-foreground mb-1">Date de naissance</div>
<div class="font-medium">{{ member.dateNaissance | date: 'dd/MM/yyyy' }}</div>
</div>
}
@if (member.sexe) {
<div>
<div class="text-xs text-muted-foreground mb-1">Sexe</div>
<div class="font-medium">{{ member.sexe === 'M' ? 'Masculin' : 'Féminin' }}</div>
</div>
}
</div>
</div>
</div>
}
</div>
</z-card>
}
<!-- TPE Assignés -->
@if (getAgentTpes(agent.id).length > 0) {
<z-card class="p-4">
<div class="text-lg font-semibold mb-4">TPE Assignés ({{ getAgentTpes(agent.id).length }})</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
@for (tpe of getAgentTpes(agent.id); track tpe.id) {
<div class="px-3 py-2.5 rounded bg-primary/10 border border-primary/20">
<div class="flex items-start justify-between mb-2">
<div class="font-medium text-sm">{{ tpe.imei }}</div>
@if (tpe.statut) {
<span class="text-xs px-2 py-0.5 rounded bg-surface text-muted-foreground">
{{ formatTpeStatut(tpe.statut) }}
</span>
}
</div>
<div class="space-y-1 text-xs text-muted-foreground">
@if (tpe.marque || tpe.modele) {
<div>
<span class="font-medium">Modèle:</span> {{ tpe.marque }} {{ tpe.modele }}
</div>
}
@if (tpe.serial) {
<div>
<span class="font-medium">Série:</span> {{ tpe.serial }}
</div>
}
@if (tpe.type) {
<div>
<span class="font-medium">Type:</span> {{ tpe.type }}
</div>
}
</div>
</div>
}
</div>
</z-card>
}
</div>
}
<div modal-actions class="flex justify-end gap-2">
<z-button zType="default" (click)="closeDetailModal()">Fermer</z-button>
@if (detailItem()) {
<z-button zType="default" (click)="openEdit(detailItem()!); closeDetailModal()">
<i class="icon-pen mr-2"></i>Modifier
</z-button>
}
</div>
</app-modal>
}
<!-- TPE Assignment Modal -->
@if (assigningAgent()) {
<app-modal
[open]="assignTpeModalOpen()"
[title]="'Assigner un TPE à ' + (assigningAgent()?.nom || '') + ' ' + (assigningAgent()?.prenom || '')"
(close)="closeAssignTpeModal()"
size="md"
>
<div class="space-y-4">
@if (tpesLoading()) {
<div class="text-center py-4">Chargement des TPE disponibles...</div>
} @else if (availableTpes().length === 0) {
<div class="text-center py-4 text-muted-foreground">Aucun TPE disponible</div>
} @else {
<z-form-field>
<label z-form-label>Sélectionner un TPE</label>
<div z-form-control>
<z-select
[zValue]="selectedTpeId()"
(zSelectionChange)="selectedTpeId.set($event)"
[zPlaceholder]="'Sélectionner un TPE...'"
>
@for (tpe of availableTpes(); track tpe.id) {
<z-select-item [zValue]="tpe.id">
{{ tpe.imei }} - {{ tpe.marque }} {{ tpe.modele }}
@if (tpe.statut === 'VALIDE') {
<span class="text-xs text-green-600 dark:text-green-400 ml-2">(Valide)</span>
}
</z-select-item>
}
</z-select>
</div>
</z-form-field>
}
</div>
<div modal-actions class="flex justify-end gap-2">
<z-button zType="destructive" (click)="closeAssignTpeModal()">Annuler</z-button>
<button z-button [disabled]="!selectedTpeId() || tpesLoading()" (click)="confirmAssignTpe()">
Assigner
</button>
</div>
</app-modal>
}

View File

@@ -0,0 +1,483 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
ViewChild,
effect,
signal,
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 { ZardButtonComponent } from '@shared/components/button/button.component';
import { ZardCardComponent } from '@shared/components/card/card.component';
import { ZardSelectComponent } from '@shared/components/select/select.component';
import { ZardSelectItemComponent } from '@shared/components/select/select-item.component';
import { ZardFormModule } from '@shared/components/form/form.module';
import { SortDir } from '@shared/paging/paging';
import { Agent, AgentFamilyMember } from 'src/app/core/interfaces/agent';
import { AgentService } from 'src/app/core/services/agent';
import { AgentFamilyMemberService } from 'src/app/core/services/agent-family-member';
import { TpeService } from 'src/app/core/services/tpe';
import { TpeDevice, TpeStatus } from 'src/app/core/interfaces/tpe';
import { AgentFullForm } from '@shared/forms/agent-full-form/agent-full-form';
import { forkJoin, of } from 'rxjs';
import { switchMap, catchError } from 'rxjs/operators';
@Component({
standalone: true,
selector: 'app-agents',
templateUrl: './agents.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
DataTable,
Paginator,
SearchBar,
Modal,
ZardButtonComponent,
ZardCardComponent,
ZardSelectComponent,
ZardSelectItemComponent,
ZardFormModule,
AgentFullForm,
],
})
export class AgentsPage {
rows = signal<Agent[]>([]);
total = signal(0);
loading = signal(false);
page = signal(1);
perPage = signal(10);
search = signal('');
sort = signal<SortState>({ key: 'code', dir: 'asc' });
modalOpen = signal(false);
modalTitle = signal('Nouvel agent');
editingItem = signal<Agent | null>(null);
detailModalOpen = signal(false);
detailItem = signal<Agent | null>(null);
detailFamilyMembers = signal<AgentFamilyMember[]>([]);
// TPE Assignment modal
assignTpeModalOpen = signal(false);
assigningAgent = signal<Agent | null>(null);
availableTpes = signal<TpeDevice[]>([]);
selectedTpeId = signal<string>('');
tpesLoading = signal(false);
@ViewChild(AgentFullForm) formComp?: AgentFullForm;
formatTpeStatut(statut: TpeStatus): string {
const statutMap: Record<string, string> = {
VALIDE: 'Valide',
INVALIDE: 'Invalide',
EN_PANNE: 'En panne',
BLOQUE: 'Bloqué',
DISPONIBLE: 'Disponible',
AFFECTE: 'Affecté',
EN_MAINTENANCE: 'En maintenance',
HORS_SERVICE: 'Hors service',
VOLE: 'Volé',
};
return statutMap[statut] || statut;
}
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: '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 },
];
tpeMap = new Map<string, TpeDevice>();
agentTpesMap = new Map<string, TpeDevice[]>();
constructor(
private api: AgentService,
private tpeSvc: TpeService,
private familyMemberService: AgentFamilyMemberService
) {
// Preload TPE maps for display
this.tpeSvc
.list({ page: 1, perPage: 200, search: '', sortKey: 'imei', sortDir: 'asc' } as any)
.subscribe((res) => {
const tpes = res.data as TpeDevice[];
this.rebuildTpeMaps(tpes);
});
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.loading.set(false);
// Refresh TPE map to ensure we have latest data
this.refreshTpeMap();
},
error: () => {
this.rows.set([]);
this.total.set(0);
this.loading.set(false);
},
});
}
private refreshTpeMap() {
this.tpeSvc
.list({ page: 1, perPage: 200, search: '', sortKey: 'imei', sortDir: 'asc' } as any)
.subscribe((res) => {
const tpes = res.data as TpeDevice[];
this.rebuildTpeMaps(tpes);
});
}
private rebuildTpeMaps(tpes: TpeDevice[]) {
this.tpeMap.clear();
this.agentTpesMap.clear();
tpes.forEach((t) => {
this.tpeMap.set(t.id, t);
const agentId = t.agent?.id;
if (agentId) {
const list = this.agentTpesMap.get(agentId) || [];
list.push(t);
this.agentTpesMap.set(agentId, list);
}
});
}
getAgentTpes(agentId: string): TpeDevice[] {
return this.agentTpesMap.get(agentId) || [];
}
onSearch(q: string) {
this.search.set(q);
this.page.set(1);
}
openCreate() {
this.modalTitle.set('Nouvel agent');
this.editingItem.set(null);
queueMicrotask(() => this.modalOpen.set(true));
}
openEdit(row: Agent) {
this.modalTitle.set("Modifier l'agent");
this.editingItem.set(row);
queueMicrotask(() => this.modalOpen.set(true));
}
closeModal() {
this.modalOpen.set(false);
}
openDetail(row: Agent) {
// Fetch full agent details
this.api.getById(row.id).subscribe({
next: (agent) => {
if (agent) {
this.detailItem.set(agent);
// Load family members separately
this.familyMemberService.getByAgentId(agent.id).subscribe({
next: (members) => {
this.detailFamilyMembers.set(members);
},
error: () => {
this.detailFamilyMembers.set([]);
},
});
this.detailModalOpen.set(true);
}
},
error: () => {
// If fetch fails, use the row data
this.detailItem.set(row);
// Try to load family members anyway
this.familyMemberService.getByAgentId(row.id).subscribe({
next: (members) => {
this.detailFamilyMembers.set(members);
},
error: () => {
this.detailFamilyMembers.set([]);
},
});
this.detailModalOpen.set(true);
},
});
}
closeDetailModal() {
this.detailModalOpen.set(false);
this.detailItem.set(null);
this.detailFamilyMembers.set([]);
}
submitChildForm() {
this.formComp?.onSubmit();
}
onFormSave(payload: Partial<Agent>) {
const current = this.editingItem();
const familyMembersData = this.formComp?.getFamilyMembersData() || [];
// Save agent first
const req$ = current?.id
? this.api.update(current.id, payload)
: this.api.create(payload as Omit<Agent, 'id'>);
req$
.pipe(
switchMap((result) => {
if (!result && current?.id) {
// Update failed
throw new Error("Erreur lors de la sauvegarde de l'agent");
}
const savedAgentId = result?.id || current?.id || '';
if (!savedAgentId) {
throw new Error("Impossible d'obtenir l'ID de l'agent sauvegardé");
}
// Get existing family members for this agent
return this.familyMemberService.getByAgentId(savedAgentId).pipe(
switchMap((existingMembers) => {
const existingIds = new Set(existingMembers.map((m) => m.id));
const newMembers = familyMembersData.filter((fm) => !fm.id);
const updatedMembers = familyMembersData.filter(
(fm) => fm.id && existingIds.has(fm.id)
);
const deletedIds = existingMembers
.filter((em) => !familyMembersData.some((fm) => fm.id === em.id))
.map((em) => em.id);
const operations: any[] = [];
// Delete removed members
deletedIds.forEach((id) => {
operations.push(
this.familyMemberService.delete(id).pipe(
catchError((err) => {
console.error(`Error deleting family member ${id}:`, err);
return of(false);
})
)
);
});
// Create new members
newMembers.forEach((member) => {
operations.push(
this.familyMemberService
.create({
agentId: savedAgentId,
nom: member.nom,
statut: member.statut,
dateNaissance: member.dateNaissance,
sexe: member.sexe as 'M' | 'F' | undefined,
})
.pipe(
catchError((err) => {
console.error('Error creating family member:', err);
return of(null);
})
)
);
});
// Update existing members
updatedMembers.forEach((member) => {
if (member.id) {
operations.push(
this.familyMemberService
.update(member.id, {
nom: member.nom,
statut: member.statut,
dateNaissance: member.dateNaissance,
sexe: member.sexe as 'M' | 'F' | undefined,
})
.pipe(
catchError((err) => {
console.error(`Error updating family member ${member.id}:`, err);
return of(null);
})
)
);
}
});
return operations.length > 0 ? forkJoin(operations) : of([]);
})
);
})
)
.subscribe({
next: () => {
// Reset form after successful save
this.formComp?.resetForm();
// Clear editing item
this.editingItem.set(null);
// Close modal
this.closeModal();
// Refresh data
this.fetch({
page: this.page(),
perPage: this.perPage(),
search: this.search(),
sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir,
});
},
error: (err) => {
console.error('Error saving agent:', err);
alert("Erreur lors de la sauvegarde de l'agent");
},
});
}
remove(row: Agent) {
if (!confirm(`Supprimer l\'agent ${row.code} ?`)) 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 as SortDir,
})
);
}
openAssignTpe(agent: Agent) {
this.assigningAgent.set(agent);
this.selectedTpeId.set('');
this.loadAvailableTpes();
this.assignTpeModalOpen.set(true);
}
loadAvailableTpes() {
this.tpesLoading.set(true);
const agent = this.assigningAgent();
if (!agent) {
this.availableTpes.set([]);
this.tpesLoading.set(false);
return;
}
const currentAgentTpes = this.agentTpesMap.get(agent.id) || [];
const agentTpeIds = new Set(currentAgentTpes.map((t) => t.id));
// Load available TPEs (DISPONIBLE or VALIDE status)
forkJoin([this.tpeSvc.getByStatut('DISPONIBLE'), this.tpeSvc.getByStatut('VALIDE')]).subscribe({
next: ([disponibleTpes, valideTpes]) => {
// Combine and filter: only show TPEs that are not assigned to any agent AND not already assigned to this agent
const allTpes = [...disponibleTpes, ...valideTpes];
const available = allTpes.filter(
(t) =>
!t.assigne &&
(t.statut === 'DISPONIBLE' || t.statut === 'VALIDE') &&
!agentTpeIds.has(t.id)
);
// Remove duplicates
const uniqueTpes = Array.from(new Map(available.map((t) => [t.id, t])).values());
this.availableTpes.set(uniqueTpes);
this.tpesLoading.set(false);
},
error: () => {
this.availableTpes.set([]);
this.tpesLoading.set(false);
},
});
}
confirmAssignTpe() {
const agent = this.assigningAgent();
const tpeId = this.selectedTpeId();
if (!agent || !tpeId) {
alert('Veuillez sélectionner un TPE');
return;
}
// Assign TPE to agent
this.tpeSvc.assigner(tpeId, agent.id).subscribe({
next: (tpe) => {
if (tpe) {
// Fermer le modal et recharger complètement la page
this.assignTpeModalOpen.set(false);
this.assigningAgent.set(null);
this.selectedTpeId.set('');
// Rechargement complet pour s'assurer que la liste des agents / TPE est à jour
window.location.reload();
}
},
error: () => {
alert("Erreur lors de l'assignation du TPE");
},
});
}
closeAssignTpeModal() {
this.assignTpeModalOpen.set(false);
this.assigningAgent.set(null);
this.selectedTpeId.set('');
}
}

View File

@@ -0,0 +1,171 @@
<div class="flex flex-col gap-2 min-h-screen">
<!-- Header -->
<div class="flex items-center justify-between">
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Courses</h1>
<button z-button (click)="openCreate()">Nouvelle course</button>
</div>
<!-- Stats -->
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<z-card class="text-center py-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Total des courses</div>
<div class="text-3xl font-bold text-gray-900 dark:text-gray-100 mt-1">
{{ totalCourses() }}
</div>
</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-3xl font-bold text-amber-600 dark:text-amber-400 mt-1">
{{ runningCourses() }}
</div>
</z-card>
<z-card class="text-center py-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Clôturées</div>
<div class="text-3xl font-bold text-green-600 dark:text-green-400 mt-1">
{{ closedCourses() }}
</div>
</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 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">
<span>{{ type.key }}</span>
<strong>{{ type.value }}</strong>
</div>
}
</div>
</z-card>
</div>
<!-- Search -->
<app-search-bar
placeholder="Rechercher (nom, type, réunion, hippodrome…)"
(search)="onSearch($event)"
/>
<!-- Table -->
<div class="rounded-2xl overflow-hidden bg-white dark:bg-gray-900/40">
<app-data-table
persistenceKey="pmu.courses.v1"
[columns]="cols"
[data]="rows()"
[loading]="loading()"
[sort]="sort()"
[actionsPosition]="'left'"
[actionsSticky]="true"
[actionsHeader]="'Actions'"
(sortChange)="sort.set($event)"
actionsHeader="Options"
>
<ng-template #rowActions let-row>
@if (!isClosed(row)) {
<div class="flex flex-row gap-4">
<button
class="p-1 rounded text-blue-600 hover:bg-blue-100 dark:text-blue-400 dark:hover:bg-gray-800"
(click)="openEdit(row)"
title="Modifier la course"
>
<lucide-angular name="folder-pen" class="size-4"></lucide-angular>
</button>
<button
class="p-1 rounded text-emerald-600 hover:bg-emerald-100 dark:text-emerald-400 dark:hover:bg-gray-800"
(click)="openResultat(row)"
title="Déclarer le résultat"
>
<lucide-angular name="trophy" class="size-4"></lucide-angular>
</button>
<button
class="p-1 rounded text-amber-600 hover:bg-amber-100 dark:text-amber-400 dark:hover:bg-gray-800"
(click)="openNonPartant(row)"
title="Marquer les non partants"
>
<lucide-angular name="ban" class="size-4"></lucide-angular>
</button>
<button
class="p-1 rounded text-red-600 hover:bg-red-100 dark:text-red-400 dark:hover:bg-gray-800"
(click)="remove(row)"
title="Supprimer la course"
>
<lucide-angular name="trash-2" class="size-4"></lucide-angular>
</button>
</div>
} @else {
<span
class="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs bg-gray-100 text-gray-600 dark:bg-gray-900/30 dark:text-gray-300"
title="Actions désactivées pour une course clôturée"
>
<lucide-angular name="lock" class="size-3.5"></lucide-angular>
Fermée
</span>
}
</ng-template>
</app-data-table>
<app-paginator
[page]="page()"
[perPage]="perPage()"
[total]="total()"
(pageChange)="page.set($event)"
(perPageChange)="perPage.set($event)"
[pageSizes]="pageSize"
/>
</div>
<!-- Modal -->
<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>
</div>
</app-modal>
@if(selectedCourse()) {
<app-modal
[open]="nonPartantModalOpen()"
[title]="'Déclarer un non-partant'"
size="xxl"
(close)="closeNonPartantModal()"
>
<app-nonpartant-form
[course]="selectedCourse()"
(save)="onNonPartantSave($any($event))"
(cancel)="closeNonPartantModal()"
></app-nonpartant-form>
<div modal-actions class="flex justify-end gap-2">
<z-button zType="destructive" (click)="closeNonPartantModal()">Annuler</z-button>
<z-button zType="default" (click)="submitNonPartant()">Enregistrer</z-button>
</div>
</app-modal>
} @if(selectedCourseForResultat()) {
<app-modal
[open]="resultatModalOpen()"
[title]="'Déclarer le résultat'"
size="xl"
(close)="closeResultatModal()"
>
<app-resultat-form
[course]="selectedCourseForResultat()!"
[resultat]="resultatsMap().get(selectedCourseForResultat()!.id)"
(save)="onResultatSave($event)"
(validate)="onResultatValidate()"
(confirm)="onResultatConfirm()"
(cancel)="closeResultatModal()"
/>
<div modal-actions class="flex justify-end gap-2">
<z-button zType="destructive" (click)="closeResultatModal()">Fermer</z-button>
</div>
</app-modal>
}
</div>

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Courses } from './courses';
describe('Courses', () => {
let component: Courses;
let fixture: ComponentFixture<Courses>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Courses]
})
.compileComponents();
fixture = TestBed.createComponent(Courses);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

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

View File

@@ -0,0 +1,132 @@
<div class="min-h-screen flex flex-col gap-2">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Hippodromes</h1>
<button z-button (click)="openCreate()">Nouvel hippodrome</button>
</div>
<!-- Cartes statistiques des hippodromes -->
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<z-card class="text-center py-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Total des hippodromes</div>
<div class="text-3xl font-bold text-gray-900 dark:text-gray-100 mt-1">
{{ total() }}
</div>
</z-card>
<z-card class="text-center py-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Pays représentés</div>
<div class="text-3xl font-bold text-blue-600 dark:text-blue-400 mt-1">
{{ uniqueCountries() }}
</div>
</z-card>
<z-card class="text-center py-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Villes uniques</div>
<div class="text-3xl font-bold text-emerald-600 dark:text-emerald-400 mt-1">
{{ uniqueCities() }}
</div>
</z-card>
<z-card class="text-center py-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Moyenne par pays</div>
<div class="text-3xl font-bold text-amber-600 dark:text-amber-400 mt-1">
{{ averageByCountry() }}
</div>
</z-card>
<z-card class="text-center py-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Réunions totales</div>
<div class="text-3xl font-bold text-purple-600 dark:text-purple-400 mt-1">
{{ totalReunions() }}
</div>
</z-card>
<z-card class="text-center py-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Courses totales</div>
<div class="text-3xl font-bold text-pink-600 dark:text-pink-400 mt-1">
{{ totalCourses() }}
</div>
</z-card>
</div>
<app-search-bar placeholder="Rechercher (nom, ville, pays…)" (search)="onSearch($event)" />
<div class="rounded-2xl overflow-hidden">
<app-data-table
[columns]="cols"
[data]="rows()"
[loading]="loading()"
[sort]="sort()"
(sortChange)="onSort($event)"
>
<!-- Template pour Statut -->
<ng-template #statutTpl let-row>
<span
class="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full"
[class.bg-green-100]="row.actif"
[class.text-green-800]="row.actif"
[class.bg-red-100]="!row.actif"
[class.text-red-800]="!row.actif"
>
<span
class="h-2 w-2 rounded-full"
[class.bg-green-500]="row.actif"
[class.bg-red-500]="!row.actif"
></span>
{{ row.actif ? 'Actif' : 'Inactif' }}
</span>
</ng-template>
<!-- Template pour Date -->
<ng-template #dateTpl let-row>
<span class="text-gray-700 dark:text-gray-300">
{{ row.createdAt | date : 'shortDate' }}
</span>
</ng-template>
<!-- Actions par ligne avec le row injecté -->
<ng-template #rowActions let-row let-i="index">
<div class="flex flex-row gap-2">
<button
class="p-1 rounded text-blue-600 hover:bg-blue-100 dark:text-blue-400 dark:hover:bg-gray-800 cursor-pointer"
(click)="openEdit(row)"
aria-label="Modifier"
title="Modifier"
>
<lucide-angular name="folder-pen" class="size-5"></lucide-angular>
</button>
<button
class="p-1 rounded text-red-600 hover:bg-red-100 dark:text-red-400 dark:hover:bg-gray-800 cursor-pointer"
(click)="remove(row)"
aria-label="Supprimer"
title="Supprimer"
>
<lucide-angular name="trash-2" class="size-5"></lucide-angular>
</button>
</div>
</ng-template>
</app-data-table>
<app-paginator
[page]="page()"
[perPage]="perPage()"
[total]="total()"
(pageChange)="page.set($event)"
(perPageChange)="onPerPage($event)"
[pageSizes]="pageSize"
/>
</div>
<!-- MODALE CRÉATION / ÉDITION -->
<app-modal [open]="modalOpen()" [title]="modalTitle()" size="md" (close)="closeModal()">
<app-hippodrome-form
[value]="editingItem()"
(save)="onFormSave($event)"
(cancel)="closeModal()"
[showInternalActions]="false"
></app-hippodrome-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>
</div>
</app-modal>
</div>

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Hippodrome } from './hippodrome';
describe('Hippodrome', () => {
let component: Hippodrome;
let fixture: ComponentFixture<Hippodrome>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Hippodrome]
})
.compileComponents();
fixture = TestBed.createComponent(Hippodrome);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,204 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
TemplateRef,
ViewChild,
computed,
effect,
signal,
} from '@angular/core';
import { DataTable, SortState, TableColumn } from '@shared/components/data-table/data-table';
import { Modal } from '@shared/components/modal/modal';
import { Paginator } from '@shared/components/paginator/paginator';
import { SearchBar } from '@shared/components/search-bar/search-bar';
import { HippodromeForm } from '@shared/forms/hippodrome-form/hippodrome-form';
import { Hippodrome as HippodromeType } from 'src/app/core/interfaces/hippodrome';
import { HippodromeService } from 'src/app/core/services/hippodrome';
import { ZardBreadcrumbModule } from '@shared/components/sheet/sheet.module';
import { ZardCardComponent } from '@shared/components/card/card.component';
import { LucideAngularModule } from 'lucide-angular';
@Component({
standalone: true,
selector: 'app-hippodrome-list',
imports: [
CommonModule,
DataTable,
Paginator,
SearchBar,
Modal,
HippodromeForm,
ZardBreadcrumbModule,
ZardCardComponent,
LucideAngularModule,
],
templateUrl: './hippodrome.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Hippodrome {
rows = signal<HippodromeType[]>([]);
loading = signal(false);
total = signal(0);
uniqueCountries = signal(0);
uniqueCities = signal(0);
averageByCountry = signal(0);
totalReunions = signal(0);
totalCourses = signal(0);
page = signal(1);
perPage = signal(10);
pageSize = [10, 20, 50];
search = signal('');
sort = signal<SortState>({ key: 'nom', dir: 'asc' });
@ViewChild(HippodromeForm) formComp?: HippodromeForm;
cols: TableColumn<HippodromeType>[] = [
{ key: 'nom', label: 'Nom', sortable: true },
{ key: 'ville', label: 'Ville', sortable: true },
{ key: 'pays', label: 'Pays', sortable: true },
{
key: 'reunionCount',
label: 'Réunions',
sortable: true,
cell: (h) => (h.reunionCount ?? 0).toString(),
},
{
key: 'courseCount',
label: 'Courses',
sortable: true,
cell: (h) => (h.courseCount ?? 0).toString(),
},
{
key: 'capacite',
label: 'Capacité',
sortable: true,
cell: (h) => (h.capacite ? h.capacite.toLocaleString('fr-FR') : '—'),
},
{
key: 'actif',
label: 'Statut',
sortable: true,
cell: (h) =>
`<span class="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full ${
h.actif
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'
}">
<span class="h-2 w-2 rounded-full ${h.actif ? 'bg-green-500' : 'bg-red-500'}"></span>
${h.actif ? 'Actif' : 'Inactif'}
</span>`,
},
{
key: 'createdAt',
label: 'Créé le',
sortable: true,
cell: (h) =>
new Date(h.createdAt).toLocaleDateString('fr-FR', {
day: '2-digit',
month: 'short',
year: 'numeric',
}),
},
];
// Modale
modalOpen = signal(false);
modalTitle = signal('Nouvel hippodrome');
editingItem = signal<Partial<HippodromeType> | null>(null); // null => création
constructor(private api: HippodromeService) {
effect(() => this.fetch());
}
private fetch() {
this.loading.set(true);
this.api
.list({
page: this.page(),
perPage: this.perPage(),
search: this.search(),
sortKey: this.sort().key,
sortDir: this.sort().dir,
})
.subscribe({
next: (res) => {
this.rows.set(res.data);
const meta = res.meta ?? {};
this.total.set(meta['total'] ?? 0);
this.uniqueCities.set(meta['uniqueCities'] ?? 0);
this.uniqueCountries.set(meta['uniqueCountries'] ?? 0);
this.averageByCountry.set(meta['averageByCountry'] ?? 0);
this.totalReunions.set(meta['totalReunions'] ?? 0);
this.totalCourses.set(meta['totalCourses'] ?? 0);
this.loading.set(false);
},
error: () => {
this.rows.set([]);
this.total.set(0);
this.uniqueCities.set(0);
this.uniqueCountries.set(0);
this.averageByCountry.set(0);
this.totalReunions.set(0);
this.totalCourses.set(0);
this.loading.set(false);
},
});
}
onSearch(q: string) {
this.search.set(q);
this.page.set(1);
}
onSort(s: SortState) {
this.sort.set(s);
this.page.set(1);
}
onPerPage(n: number) {
this.perPage.set(n);
this.page.set(1);
}
openCreate() {
this.modalTitle.set('Nouvel hippodrome');
this.editingItem.set(null);
queueMicrotask(() => this.modalOpen.set(true));
}
openEdit(ev: HippodromeType) {
this.modalTitle.set('Modifier lhippodrome');
this.editingItem.set(ev);
queueMicrotask(() => this.modalOpen.set(true));
}
closeModal() {
this.modalOpen.set(false);
}
submitChildForm() {
// Déclenche le submit du formulaire enfant
this.formComp?.onSubmit();
}
onFormSave(payload: Partial<HippodromeType>) {
const current = this.editingItem();
const req$ = current?.id
? this.api.update(current.id, payload)
: this.api.create(payload as Omit<HippodromeType, 'id'>);
req$.subscribe(() => {
this.closeModal();
// Reset editing item to null to clear the form
this.editingItem.set(null);
this.fetch();
});
}
remove(ev: HippodromeType) {
if (!confirm(`Supprimer lhippodrome « ${ev.nom} » ?`)) return;
this.api.delete(ev.id).subscribe(() => this.fetch());
}
}

View File

@@ -0,0 +1,63 @@
<div class="flex flex-col gap-2 min-h-screen">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-semibold">Gestion des Limites</h2>
<z-button (click)="openCreate()">Nouvelle limite</z-button>
</div>
<!-- Actif Filter Chips -->
<div class="flex flex-wrap gap-2 items-center">
<span class="text-sm font-medium">Filtrer par statut:</span>
<button
z-button
zType="ghost"
zSize="sm"
[class]="selectedActif() === null ? '!bg-primary/10 !text-primary' : ''"
(click)="onActifFilter(null)"
>
Tous
</button>
<button
z-button
zType="ghost"
zSize="sm"
[class]="selectedActif() === true ? '!bg-green-500/10 !text-green-600 dark:!text-green-400' : ''"
(click)="onActifFilter(true)"
>
<i class="icon-check"></i>
Actives
</button>
<button
z-button
zType="ghost"
zSize="sm"
[class]="selectedActif() === false ? '!bg-gray-500/10 !text-gray-600 dark:!text-gray-400' : ''"
(click)="onActifFilter(false)"
>
<i class="icon-x"></i>
Inactives
</button>
</div>
<app-search-bar (search)="onSearch($event)"></app-search-bar>
<app-data-table [data]="rows()" [columns]="cols" [sort]="sort()" (sortChange)="sort.set($event)">
<ng-template #rowActions let-row>
<div class="flex gap-3">
<button z-button zType="ghost" (click)="openEdit(row)"><i class="icon-pen"></i></button>
<button z-button zType="destructive" (click)="remove(row)"><i class="icon-trash"></i></button>
</div>
</ng-template>
</app-data-table>
<app-paginator [total]="total()" [page]="page()" [perPage]="perPage()" (pageChange)="page.set($event)" (perPageChange)="perPage.set($event)"></app-paginator>
</div>
<app-modal [open]="modalOpen()" [title]="modalTitle()" (close)="closeModal()" size="xl">
<app-limit-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>
</div>
</app-modal>

View File

@@ -0,0 +1,294 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, ViewChild, effect, signal, untracked, OnInit } 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 { ZardButtonComponent } from '@shared/components/button/button.component';
import { SortDir } from '@shared/paging/paging';
import { AgentLimit } from 'src/app/core/interfaces/agent-limit';
import { AgentLimitService } from 'src/app/core/services/agent-limit';
import { LimitForm } from '@shared/forms/limit-form/limit-form';
import { Subject, of } from 'rxjs';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';
@Component({
standalone: true,
selector: 'app-limits',
templateUrl: './limits.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, DataTable, Paginator, SearchBar, Modal, ZardButtonComponent, LimitForm],
})
export class LimitsPage implements OnInit {
rows = signal<AgentLimit[]>([]);
total = signal(0);
loading = signal(false);
page = signal(1);
perPage = signal(10);
search = signal('');
sort = signal<SortState>({ key: 'code', dir: 'asc' });
selectedActif = signal<boolean | null>(null);
modalOpen = signal(false);
modalTitle = signal('Nouvelle limite');
editingItem = signal<AgentLimit | null>(null);
// Live search
private searchSubject = new Subject<string>();
@ViewChild(LimitForm) formComp?: LimitForm;
cols: TableColumn<AgentLimit>[] = [
{ key: 'code', label: 'Code', sortable: true },
{ key: 'configCode', label: 'Config', sortable: true },
{ key: 'nom', label: 'Nom', sortable: true },
{
key: 'isDefault',
label: 'Défaut',
cell: (l) =>
l.isDefault
? '<span class="inline-flex items-center gap-1 px-2 py-1 rounded bg-primary/10 text-primary text-xs font-medium"><i class="icon-star"></i> Par défaut</span>'
: '<span class="text-muted-foreground">—</span>',
},
{
key: 'actif',
label: 'Actif',
cell: (l) =>
l.actif
? '<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>'
: '<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>',
},
{
key: 'betMin',
label: 'Min Bet',
cell: (l) => (l.betMin ?? 0).toLocaleString('fr-FR'),
},
{
key: 'betMax',
label: 'Max Bet',
cell: (l) => (l.betMax ?? 0).toLocaleString('fr-FR'),
},
{
key: 'maxBet',
label: 'Max Bet (tx)',
cell: (l) => (l.maxBet ?? 0).toLocaleString('fr-FR'),
},
{
key: 'maxDisburseBet',
label: 'Max Disburse',
cell: (l) => (l.maxDisburseBet ?? 0).toLocaleString('fr-FR'),
},
{
key: 'airtimeMin',
label: 'Airtime Min',
cell: (l) => (l.airtimeMin ?? 0).toLocaleString('fr-FR'),
},
{
key: 'airtimeMax',
label: 'Airtime Max',
cell: (l) => (l.airtimeMax ?? 0).toLocaleString('fr-FR'),
},
];
constructor(private api: AgentLimitService) {
effect(() => {
// Only trigger fetch when page, perPage, or sort changes (not search - handled by searchSubject)
const searchValue = this.search();
const params = {
page: this.page(),
perPage: this.perPage(),
search: searchValue,
sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir,
};
// Only fetch if search is empty (search is handled by searchSubject)
if (!searchValue.trim()) {
untracked(() => this.fetch(params));
}
});
// Setup live search with debounce
this.searchSubject
.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap((query) => {
if (query.trim()) {
// Use search API which returns array
return this.api.search(query);
} else {
// If empty, use normal list
return this.api.list({
page: this.page(),
perPage: this.perPage(),
search: '',
sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir,
}).pipe(
switchMap((res) => {
// Convert PagedResult to array for consistency
return of(res.data);
})
);
}
})
)
.subscribe({
next: (res) => {
// Search API always returns array
if (Array.isArray(res)) {
this.rows.set(res);
this.total.set(res.length);
}
this.loading.set(false);
},
error: (err) => {
console.error('Search error:', err);
this.rows.set([]);
this.total.set(0);
this.loading.set(false);
},
});
}
ngOnInit() {
// Initial fetch
this.fetch({
page: this.page(),
perPage: this.perPage(),
search: this.search(),
sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir,
});
}
private fetch(params: { page: number; perPage: number; search: string; sortKey: string; sortDir: SortDir }) {
// Don't fetch if there's a search query - it's handled by searchSubject
const searchQuery = params.search.trim();
if (searchQuery) {
return; // Search is handled by searchSubject subscription
}
this.loading.set(true);
const actif = this.selectedActif();
if (actif !== null) {
// Filter by actif status - returns array
this.api.getByActif(actif).subscribe({
next: (res: AgentLimit[]) => {
this.rows.set(res);
this.total.set(res.length);
this.loading.set(false);
},
error: () => {
this.rows.set([]);
this.total.set(0);
this.loading.set(false);
},
});
} else {
// Normal list with pagination
this.api.list(params).subscribe({
next: (res) => {
this.rows.set(res.data);
this.total.set(res.meta.total);
this.loading.set(false);
},
error: () => {
this.rows.set([]);
this.total.set(0);
this.loading.set(false);
},
});
}
}
onSearch(q: string) {
this.search.set(q);
this.page.set(1);
// Trigger search via subject for live search
if (q.trim()) {
this.loading.set(true);
this.searchSubject.next(q);
} else {
// If empty, fetch normally
this.fetch({
page: this.page(),
perPage: this.perPage(),
search: '',
sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir,
});
}
}
onActifFilter(actif: boolean | null) {
this.selectedActif.set(actif);
this.page.set(1);
this.fetch({
page: this.page(),
perPage: this.perPage(),
search: this.search(),
sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir,
});
}
openCreate() { this.modalTitle.set('Nouvelle limite'); this.editingItem.set(null); queueMicrotask(() => this.modalOpen.set(true)); }
openEdit(row: AgentLimit) { this.modalTitle.set('Modifier la limite'); this.editingItem.set(row); queueMicrotask(() => this.modalOpen.set(true)); }
closeModal() { this.modalOpen.set(false); }
submitChildForm() { this.formComp?.onSubmit(); }
onFormSave(payload: Partial<AgentLimit>) {
const current = this.editingItem();
const isSettingDefault = payload.isDefault === true;
const wasDefault = current?.isDefault;
// If setting as default and it wasn't default before, show confirmation
if (isSettingDefault && !wasDefault) {
if (!confirm('Définir cette limite comme limite par défaut ?\n\nTous les agents recevront automatiquement cette limite, et l\'ancienne limite par défaut perdra son statut.')) {
return;
}
}
const req$ = current?.id ? this.api.update(current.id, payload) : this.api.create(payload as Omit<AgentLimit, 'id'>);
req$.subscribe({
next: (result) => {
if (!result && current?.id) {
// Update failed
alert('Erreur lors de la sauvegarde de la limite');
return;
}
this.closeModal();
this.fetch({
page: this.page(),
perPage: this.perPage(),
search: this.search(),
sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir,
});
if (isSettingDefault && !wasDefault) {
alert('La limite a été définie comme limite par défaut. Tous les agents ont été mis à jour.');
}
},
error: (err) => {
console.error('Error saving limit:', err);
alert('Erreur lors de la sauvegarde de la limite');
},
});
}
remove(row: AgentLimit) {
if (!confirm(`Supprimer la limite ${row.code} ?`)) 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 as SortDir,
});
});
}
}

View File

View File

@@ -0,0 +1,333 @@
<div class="min-h-screen gap-4 flex flex-col">
<h1 class="text-3xl font-bold text-gray-800 dark:text-white">Dashboard principale PJP</h1>
<!-- Loading / Error -->
@if (statsLoading()) {
<div class="text-sm text-gray-500 dark:text-gray-400">Chargement des statistiques…</div>
} @if (statsError()) {
<div class="text-sm text-red-500">{{ statsError() }}</div>
}
<!-- Live / upcoming courses with hippodrome & reunion -->
<div class="grid gap-4">
<z-card class="w-full">
<div class="flex items-center justify-between mb-3">
<div>
<div class="text-sm text-gray-500 dark:text-gray-400">Courses en direct & à venir</div>
<div class="text-xs text-gray-400 dark:text-gray-500">
Statuts RUNNING & PROGRAMMEE, triés par heure de départ
</div>
</div>
</div>
@if (liveCourses().length) {
<div class="divide-y divide-gray-100 dark:divide-gray-800 text-xs sm:text-sm">
@for (c of liveCourses(); track c.id) {
<div class="py-3 flex flex-col gap-2">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<div class="flex flex-col gap-1.5">
<div class="flex items-center gap-2 flex-wrap">
<span
class="inline-flex items-center justify-center rounded-full px-2 py-0.5 text-[10px] font-semibold bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300"
>
N° {{ c.numero }}
</span>
<span class="font-medium text-gray-900 dark:text-gray-100 truncate">
{{ c.nom }}
</span>
@if (c.type) {
<span
class="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300"
>
{{ c.type }}
</span>
}
</div>
<div
class="flex flex-wrap items-center gap-2 text-[11px] text-gray-500 dark:text-gray-400"
>
<span class="flex items-center gap-1">
<svg
class="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
{{
c.dateDepartCourse
? (c.dateDepartCourse | date : 'short' : undefined : 'fr-FR')
: '—'
}}
</span>
<span class="h-1 w-1 rounded-full bg-gray-400"></span>
<span class="font-medium">
{{ c.reunion.hippodrome.nom }}
</span>
<span class="h-1 w-1 rounded-full bg-gray-400"></span>
<span> Réunion {{ c.reunion.nom }} </span>
<span class="h-1 w-1 rounded-full bg-gray-400"></span>
<span> Distance {{ c.distance | number : '1.0-0' }} m </span>
</div>
<div
class="flex flex-wrap items-center gap-2 text-[11px] text-gray-600 dark:text-gray-300"
>
<span class="flex items-center gap-1 font-medium">
<svg
class="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
></path>
</svg>
{{ c.partants }} partant{{ c.partants > 1 ? 's' : '' }}
</span>
@if (c.nonPartants && c.nonPartants.length > 0) {
<span class="h-1 w-1 rounded-full bg-gray-400"></span>
<span class="text-orange-600 dark:text-orange-400">
{{ c.nonPartants.length }} non-partant{{ c.nonPartants.length > 1 ? 's' : '' }}
</span>
} @if (c.condition) {
<span class="h-1 w-1 rounded-full bg-gray-400"></span>
<span class="italic">{{ c.condition }}</span>
} @if (c.particularite) {
<span class="h-1 w-1 rounded-full bg-gray-400"></span>
<span class="text-blue-600 dark:text-blue-400">⭐ {{ c.particularite }}</span>
}
</div>
</div>
<div class="flex items-center gap-2 sm:gap-3">
<span
class="px-2 py-1 rounded-full text-[10px] font-semibold"
[ngClass]="{
'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300':
(c.statut || '').toUpperCase() === 'RUNNING',
'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300':
(c.statut || '').toUpperCase() === 'PROGRAMMEE',
'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300':
(c.statut || '').toUpperCase() === 'VALIDATED'
}"
>
{{
(c.statut || '').toUpperCase() === 'RUNNING'
? 'En cours'
: (c.statut || '').toUpperCase() === 'PROGRAMMEE'
? 'Programmée'
: (c.statut || '').toUpperCase() === 'VALIDATED'
? 'Validée'
: c.statut
}}
</span>
</div>
</div>
</div>
}
</div>
} @else {
<div class="py-6 text-xs text-gray-400 dark:text-gray-500 text-center">
Aucune course en cours ou à venir pour le moment.
</div>
}
</z-card>
</div>
<!-- Global entities overview -->
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<z-card class="w-full max-w-sm !mt-0 p-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Utilisateurs</div>
<div class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100">
{{ totalUsers() }}
</div>
</z-card>
<z-card class="w-full max-w-sm !mt-0 p-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Agents</div>
<div class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100">
{{ totalAgents() }}
</div>
</z-card>
<z-card class="w-full max-w-sm !mt-0 p-4">
<div class="text-sm text-gray-500 dark:text-gray-400">TPE</div>
<div class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100">
{{ totalTpes() }}
</div>
</z-card>
<z-card class="w-full max-w-sm !mt-0 p-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Limites agents</div>
<div class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100">
{{ totalAgentLimits() }}
</div>
</z-card>
</div>
<!-- Racing & network overview -->
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<z-card class="w-full max-w-sm p-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Hippodromes</div>
<div class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100">
{{ totalHippodromes() }}
</div>
</z-card>
<z-card class="w-full max-w-sm p-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Réunions</div>
<div class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100">
{{ totalReunions() }}
</div>
</z-card>
<z-card class="w-full max-w-sm p-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Courses</div>
<div class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100">
{{ totalCourses() }}
</div>
</z-card>
<z-card class="w-full max-w-sm p-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Membres de famille d'agents</div>
<div class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100">
{{ totalAgentFamilyMembers() }}
</div>
</z-card>
</div>
<!-- TPE charts & activity -->
<div class="grid gap-4 lg:grid-cols-12">
<!-- TPE status donut -->
<z-card class="w-full lg:col-span-4">
<div class="flex items-center justify-between mb-2">
<div class="text-sm text-gray-500 dark:text-gray-400">Répartition TPE par statut</div>
<div class="text-xs text-gray-400 dark:text-gray-500">Total&nbsp;: {{ totalTpes() }}</div>
</div>
<div class="flex items-center gap-4">
<!-- Donut / Pie chart -->
<div class="relative h-28 w-28">
<div
class="h-28 w-28 rounded-full transition-all duration-700 ease-out shadow-inner"
[style.backgroundImage]="tpePieGradient()"
></div>
<div
class="absolute inset-4 rounded-full bg-white dark:bg-gray-900 flex items-center justify-center text-xs text-gray-700 dark:text-gray-200"
>
{{ totalTpes() }}
</div>
</div>
<!-- Legend -->
<div class="grid grid-cols-1 gap-1 text-xs flex-1">
@for (item of tpeStatusBreakdown(); track item.statut) {
<div class="flex items-center gap-2">
<span
class="inline-block h-2 w-2 rounded-full"
[style.backgroundColor]="item.color"
></span>
<span class="truncate">
{{ item.statut }} ({{ item.count }}) — {{ item.percent }}%
</span>
</div>
} @if (!tpeStatusBreakdown().length) {
<div class="text-gray-400 dark:text-gray-500 text-xs">
Aucune donnée de statut TPE disponible.
</div>
}
</div>
</div>
</z-card>
<!-- TPE assignment gauge -->
<z-card class="w-full lg:col-span-4">
<div class="text-sm text-gray-500 dark:text-gray-400">TPE assignés</div>
<div class="mt-1 flex items-baseline gap-2">
<div class="text-3xl font-semibold text-gray-900 dark:text-gray-100">
{{ tpeAssignRate() }}%
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
({{ tpeAssignedCount() }} / {{ totalTpes() }})
</div>
</div>
<div class="mt-3 h-2 rounded-full bg-gray-100 dark:bg-gray-800 overflow-hidden">
<div
class="h-full bg-emerald-500 dark:bg-emerald-400 transition-all duration-700 ease-out"
[style.width.%]="tpeAssignRate()"
></div>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Pourcentage de TPE actuellement affectés à un agent.
</p>
</z-card>
<!-- Line chart: relative volume par entité -->
<z-card class="w-full lg:col-span-4">
<div class="flex items-center justify-between mb-2">
<div class="text-sm text-gray-500 dark:text-gray-400">Activité par entité</div>
</div>
<div class="h-32 w-full">
<svg viewBox="0 0 100 40" class="w-full h-full">
<!-- Gradient background -->
<defs>
<linearGradient id="entityLineGradient" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#22c55e" />
<stop offset="50%" stop-color="#3b82f6" />
<stop offset="100%" stop-color="#a855f7" />
</linearGradient>
</defs>
<!-- Baseline -->
<line x1="0" y1="38" x2="100" y2="38" stroke="#e5e7eb" stroke-width="0.5" />
<!-- Area under line -->
@if (entityPolylinePoints()) {
<polyline
[attr.points]="'0,40 ' + entityPolylinePoints() + ' 100,40'"
fill="url(#entityLineGradient)"
fill-opacity="0.15"
></polyline>
<!-- Line -->
<polyline
[attr.points]="entityPolylinePoints()"
fill="none"
stroke="url(#entityLineGradient)"
stroke-width="1.5"
stroke-linejoin="round"
stroke-linecap="round"
></polyline>
}
</svg>
</div>
<div class="mt-3 grid grid-cols-3 gap-2 text-xs text-gray-600 dark:text-gray-300">
<div>
<div class="text-[10px] uppercase tracking-wide text-gray-400">Users</div>
<div class="font-semibold">{{ totalUsers() }}</div>
</div>
<div>
<div class="text-[10px] uppercase tracking-wide text-gray-400">Agents</div>
<div class="font-semibold">{{ totalAgents() }}</div>
</div>
<div>
<div class="text-[10px] uppercase tracking-wide text-gray-400">TPE</div>
<div class="font-semibold">{{ totalTpes() }}</div>
</div>
<div>
<div class="text-[10px] uppercase tracking-wide text-gray-400">Reunions</div>
<div class="font-semibold">{{ totalReunions() }}</div>
</div>
<div>
<div class="text-[10px] uppercase tracking-wide text-gray-400">Courses</div>
<div class="font-semibold">{{ totalCourses() }}</div>
</div>
</div>
</z-card>
</div>
</div>

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Main } from './main';
describe('Main', () => {
let component: Main;
let fixture: ComponentFixture<Main>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Main]
})
.compileComponents();
fixture = TestBed.createComponent(Main);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,257 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
import { ZardCardComponent } from '@shared/components/card/card.component';
import { UserService } from 'src/app/core/services/user';
import { AgentService } from 'src/app/core/services/agent';
import { TpeService } from 'src/app/core/services/tpe';
import { AgentLimitService } from 'src/app/core/services/agent-limit';
import { HippodromeService } from 'src/app/core/services/hippodrome';
import { ReunionService } from 'src/app/core/services/reunion';
import { CourseService } from 'src/app/core/services/course';
import { RoleService } from 'src/app/core/services/role';
import { AgentFamilyMemberService } from 'src/app/core/services/agent-family-member';
import { ListParams, SortDir } from '@shared/paging/paging';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import type { Course as CourseModel } from 'src/app/core/interfaces/course';
@Component({
selector: 'app-main',
imports: [ZardCardComponent, CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './main.html',
styleUrl: './main.css',
})
export class Main {
// Loading & error state
statsLoading = signal(false);
statsError = signal<string | null>(null);
// Global totals
totalUsers = signal(0);
totalAgents = signal(0);
totalTpes = signal(0);
totalAgentLimits = signal(0);
totalHippodromes = signal(0);
totalReunions = signal(0);
totalCourses = signal(0);
totalRoles = signal(0);
totalPermissions = signal(0);
totalAgentFamilyMembers = signal(0);
// TPE status breakdown for charts
tpeStatusBreakdown = signal<{ statut: string; count: number; percent: number; color: string }[]>(
[]
);
tpeAssignedCount = signal(0);
// Derived value for TPE assignment rate
tpeAssignRate = computed(() => {
const total = this.totalTpes();
const assigned = this.tpeAssignedCount();
if (!total) return 0;
return Math.round((assigned / total) * 100);
});
// CSS conic-gradient for a TPE status pie/donut chart
tpePieGradient = computed(() => {
const segments = this.tpeStatusBreakdown();
if (!segments.length) {
return 'conic-gradient(#e5e7eb 0deg 360deg)';
}
let current = 0;
const parts: string[] = [];
for (const seg of segments) {
const start = current;
const sweep = (seg.percent || 0) * 3.6; // percent -> degrees
const end = start + sweep;
parts.push(`${seg.color} ${start}deg ${end}deg`);
current = end;
}
return `conic-gradient(${parts.join(', ')})`;
});
// Simple entity activity series for a line chart (users, agents, tpes, reunions, courses)
entityLabels: string[] = ['USERS', 'AGENTS', 'TPE', 'REUNIONS', 'COURSES'];
entitySeries = computed(() => [
this.totalUsers(),
this.totalAgents(),
this.totalTpes(),
this.totalReunions(),
this.totalCourses(),
]);
// SVG polyline points for the entitySeries (normalized to 040 viewport)
entityPolylinePoints = computed(() => {
const values = this.entitySeries();
const max = Math.max(...values, 1);
const n = values.length;
if (!n) return '';
const stepX = n > 1 ? 100 / (n - 1) : 100;
return values
.map((v, i) => {
const x = i * stepX;
const norm = v / max; // 01
const y = 40 - norm * 30; // keep some top/bottom padding
return `${x},${y}`;
})
.join(' ');
});
// Live / upcoming courses (RUNNING or PROGRAMMEE, nearest in time)
liveCourses = signal<CourseModel[]>([]);
constructor(
private userService: UserService,
private agentService: AgentService,
private tpeService: TpeService,
private agentLimitService: AgentLimitService,
private hippodromeService: HippodromeService,
private reunionService: ReunionService,
private courseService: CourseService,
private roleService: RoleService,
private familyService: AgentFamilyMemberService
) {
this.loadStats();
}
private baseParams(): ListParams {
return {
page: 1,
perPage: 1,
search: '',
sortKey: 'id',
sortDir: 'asc' as SortDir,
};
}
private loadStats() {
this.statsLoading.set(true);
this.statsError.set(null);
const params = this.baseParams();
// Fetch more courses to filter for live/upcoming ones
const coursesParams = {
...params,
perPage: 100, // Fetch up to 100 courses to filter from
sortKey: 'dateDepartCourse',
sortDir: 'desc' as SortDir,
};
forkJoin({
users: this.userService
.list(params)
.pipe(catchError(() => of({ data: [], meta: { total: 0 } } as any))),
agents: this.agentService
.list(params)
.pipe(catchError(() => of({ data: [], meta: { total: 0 } } as any))),
tpes: this.tpeService
.list(params)
.pipe(catchError(() => of({ data: [], meta: { total: 0 } } as any))),
limits: this.agentLimitService
.list(params)
.pipe(catchError(() => of({ data: [], meta: { total: 0 } } as any))),
hippodromes: this.hippodromeService
.list(params, true)
.pipe(catchError(() => of({ data: [], meta: { total: 0 } } as any))),
reunions: this.reunionService
.list(params, true)
.pipe(catchError(() => of({ data: [], meta: { total: 0 } } as any))),
courses: this.courseService
.list(coursesParams, true)
.pipe(catchError(() => of({ data: [], meta: { total: 0 } } as any))),
roles: this.roleService
.list(params)
.pipe(catchError(() => of({ data: [], meta: { total: 0 } } as any))),
permissions: this.roleService.allPermissions().pipe(catchError(() => of([]))),
familyMembers: this.familyService.list().pipe(catchError(() => of([]))),
tpeByStatut: this.tpeService.getCountByStatut().pipe(catchError(() => of({}))),
tpeAssignes: this.tpeService.getAssignesStats().pipe(catchError(() => of(0))),
}).subscribe({
next: (res) => {
this.totalUsers.set(res.users.meta?.total ?? 0);
this.totalAgents.set(res.agents.meta?.total ?? 0);
this.totalTpes.set(res.tpes.meta?.total ?? 0);
this.totalAgentLimits.set(res.limits.meta?.total ?? 0);
this.totalHippodromes.set(res.hippodromes.meta?.total ?? 0);
this.totalReunions.set(res.reunions.meta?.total ?? 0);
this.totalCourses.set(res.courses.meta?.total ?? 0);
this.totalRoles.set(res.roles.meta?.total ?? 0);
this.totalPermissions.set((res.permissions as any[]).length ?? 0);
this.totalAgentFamilyMembers.set((res.familyMembers as any[]).length ?? 0);
// TPE status breakdown
const totalTpes = res.tpes.meta?.total ?? 0;
const statusColors: Record<string, string> = {
VALIDE: '#16a34a', // green
DISPONIBLE: '#22c55e',
AFFECTE: '#3b82f6', // blue
EN_PANNE: '#f97316', // orange
EN_MAINTENANCE: '#eab308', // yellow
BLOQUE: '#ef4444', // red
INVALIDE: '#6b7280', // gray
HORS_SERVICE: '#4b5563',
VOLE: '#7c3aed',
};
const rawStats = res.tpeByStatut || {};
const entries = Object.entries(rawStats as Record<string, number>).filter(
([, count]) => count > 0
);
const totalFromStats =
entries.reduce((sum, [, count]) => sum + (count || 0), 0) || totalTpes || 1;
const breakdown = entries.map(([statut, count]) => {
const upper = statut.toUpperCase();
const color = statusColors[upper] || '#6b7280';
const percent = Math.round(((count || 0) / totalFromStats) * 100);
return { statut: upper, count: count || 0, percent, color };
});
this.tpeStatusBreakdown.set(breakdown);
this.tpeAssignedCount.set(res.tpeAssignes ?? 0);
// Live / upcoming courses: filter by statut & date
const allCourses = (res.courses.data as CourseModel[]) ?? [];
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
const oneDayAhead = new Date(now.getTime() + 24 * 60 * 60 * 1000);
const live = allCourses
.filter((c) => {
const statut = String(c.statut || '').toUpperCase();
// Include RUNNING courses
if (statut === 'RUNNING') return true;
// Include PROGRAMMEE courses that are scheduled within the next 24 hours
if (statut === 'PROGRAMMEE') {
const d = c.dateDepartCourse ? new Date(c.dateDepartCourse) : null;
if (!d) return false;
// Include if departure is in the past hour (just started) or within next 24 hours
return d >= oneHourAgo && d <= oneDayAhead;
}
// Also include VALIDATED courses that are about to start (within next 24 hours)
if (statut === 'VALIDATED') {
const d = c.dateDepartCourse ? new Date(c.dateDepartCourse) : null;
if (!d) return false;
return d >= now && d <= oneDayAhead;
}
return false;
})
.sort((a, b) => {
const da = a.dateDepartCourse ? new Date(a.dateDepartCourse).getTime() : 0;
const db = b.dateDepartCourse ? new Date(b.dateDepartCourse).getTime() : 0;
return da - db; // Sort by departure time ascending (earliest first)
})
.slice(0, 6);
this.liveCourses.set(live);
this.statsLoading.set(false);
},
error: () => {
this.statsError.set('Erreur lors du chargement des statistiques du dashboard.');
this.statsLoading.set(false);
},
});
}
}

View File

@@ -0,0 +1,121 @@
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-semibold">Mon profil</h1>
<p class="text-sm text-muted-foreground">Gérez vos informations et la sécurité du compte</p>
</div>
<z-avatar [zImage]="avatar" zSize="lg"></z-avatar>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<!-- Account info -->
<z-card class="p-4">
<div class="mb-3">
<div class="text-lg font-medium">Informations du compte</div>
<div class="text-sm text-muted-foreground">Nom, prénom et identifiant</div>
</div>
<form [formGroup]="profileForm" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<z-form-field>
<label z-form-label>Nom</label>
<div
z-form-control
[errorMessage]="
profileForm.get('nom')?.invalid &&
(submittedProfile || profileForm.get('nom')?.touched)
? 'Ce champ est obligatoire'
: ''
"
>
<input z-input formControlName="nom" />
</div>
</z-form-field>
<z-form-field>
<label z-form-label>Prénom</label>
<div
z-form-control
[errorMessage]="
profileForm.get('prenom')?.invalid &&
(submittedProfile || profileForm.get('prenom')?.touched)
? 'Ce champ est obligatoire'
: ''
"
>
<input z-input formControlName="prenom" />
</div>
</z-form-field>
<z-form-field class="md:col-span-2">
<label z-form-label>Identifiant</label>
<div
z-form-control
[errorMessage]="
profileForm.get('identifiant')?.invalid &&
(submittedProfile || profileForm.get('identifiant')?.touched)
? 'Ce champ est obligatoire'
: ''
"
>
<input z-input formControlName="identifiant" />
</div>
</z-form-field>
</form>
<div class="flex justify-end mt-4">
<z-button (click)="saveProfile()" [zLoading]="savingProfile()">Enregistrer</z-button>
</div>
</z-card>
<!-- Password change -->
<z-card class="p-4">
<div class="mb-3">
<div class="text-lg font-medium">Sécurité</div>
<div class="text-sm text-muted-foreground">Changez votre mot de passe</div>
</div>
<form [formGroup]="passwordForm" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<z-form-field class="md:col-span-2">
<label z-form-label>Mot de passe actuel</label>
<div
z-form-control
[errorMessage]="
passwordForm.get('current')?.invalid &&
(submittedPassword || passwordForm.get('current')?.touched)
? 'Ce champ est obligatoire'
: ''
"
>
<input z-input type="password" formControlName="current" />
</div>
</z-form-field>
<z-form-field>
<label z-form-label>Nouveau mot de passe</label>
<div
z-form-control
[errorMessage]="
passwordForm.get('next')?.invalid &&
(submittedPassword || passwordForm.get('next')?.touched)
? 'Minimum 6 caractères'
: ''
"
>
<input z-input type="password" formControlName="next" />
</div>
</z-form-field>
<z-form-field>
<label z-form-label>Confirmer le mot de passe</label>
<div
z-form-control
[errorMessage]="
passwordForm.get('confirm')?.invalid &&
(submittedPassword || passwordForm.get('confirm')?.touched)
? 'Minimum 6 caractères'
: ''
"
>
<input z-input type="password" formControlName="confirm" />
</div>
</z-form-field>
</form>
<div class="flex justify-end mt-4">
<z-button (click)="changePassword()" [zLoading]="savingPassword()">Mettre à jour</z-button>
</div>
</z-card>
</div>
</div>

View File

@@ -0,0 +1,75 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { ZardCardComponent } from '@shared/components/card/card.component';
import { ZardFormModule } from '@shared/components/form/form.module';
import { ZardInputDirective } from '@shared/components/input/input.directive';
import { ZardButtonComponent } from '@shared/components/button/button.component';
import { ZardAvatarComponent } from '@shared/components/avatar/avatar.component';
@Component({
standalone: true,
selector: 'app-profile',
templateUrl: './profile.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
ReactiveFormsModule,
ZardCardComponent,
ZardFormModule,
ZardInputDirective,
ZardButtonComponent,
ZardAvatarComponent,
],
})
export class ProfilePage {
profileForm;
passwordForm;
savingProfile = signal(false);
savingPassword = signal(false);
submittedProfile = false;
submittedPassword = false;
avatar = { fallback: 'PM', url: '/assets/images/avatar.svg', alt: 'Profil' };
constructor(private fb: FormBuilder) {
this.profileForm = this.fb.group({
nom: ['', Validators.required],
prenom: ['', Validators.required],
identifiant: ['', Validators.required],
});
this.passwordForm = this.fb.group({
current: ['', Validators.required],
next: ['', [Validators.required, Validators.minLength(6)]],
confirm: ['', [Validators.required, Validators.minLength(6)]],
});
}
saveProfile() {
this.submittedProfile = true;
if (this.profileForm.invalid) {
this.profileForm.markAllAsTouched();
return;
}
this.savingProfile.set(true);
setTimeout(() => this.savingProfile.set(false), 600);
}
changePassword() {
this.submittedPassword = true;
if (this.passwordForm.invalid) {
this.passwordForm.markAllAsTouched();
return;
}
const { next, confirm } = this.passwordForm.getRawValue();
if (next !== confirm) {
// simple inline mismatch handling; in real app, set an error
return;
}
this.savingPassword.set(true);
setTimeout(() => this.savingPassword.set(false), 600);
}
}

View File

@@ -0,0 +1,114 @@
@if(detail()){
<div class="space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-semibold">
Rapport pour la course n° {{ detail()!.summary.course.numero }}
<span class="text-sm text-muted-foreground">({{ detail()!.summary.statut }})</span>
</h2>
<div class="flex items-center gap-2">
<a z-button routerLink="/rapport-courses" zType="ghost"
><i class="icon-arrow-left mr-2"></i>Retour</a
>
<z-button zType="default" (click)="onEditToggle()"
><i class="icon-edit mr-1"></i>{{ editMode() ? 'Quitter édition' : 'Modifier' }}</z-button
>
<z-button zType="default" (click)="onValidate()"
><i class="icon-edit-2 mr-1"></i>Valider</z-button
>
<z-button zType="secondary" (click)="onConfirm()"
><i class="icon-check mr-1"></i>Confirmer</z-button
>
<z-button zType="destructive" (click)="onReset()"
><i class="icon-rotate-ccw mr-1"></i>Réinitialiser</z-button
>
</div>
</div>
<z-card class="p-4">
<div class="grid grid-cols-1 md:grid-cols-4 gap-2 text-sm">
<div>
<span class="font-medium">Date:</span>
{{ detail()!.summary.course.dateDepartCourse | date : 'dd/MM/yyyy' }}
</div>
<div><span class="font-medium">Nom:</span> {{ detail()!.summary.course.nom }}</div>
<div><span class="font-medium">Type:</span> {{ detail()!.summary.course.type }}</div>
<div>
<span class="font-medium">Lieu:</span> {{ detail()!.summary.course.reunion.hippodrome.nom }}
</div>
</div>
</z-card>
<div class="rounded-md border overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-accent">
<tr>
<th class="text-left p-2">Type de gain</th>
<th class="text-left p-2">Type de jeu</th>
<th class="text-left p-2">Montant</th>
<th class="text-left p-2">Nombre</th>
<th class="text-left p-2">Statut</th>
<th class="text-left p-2">Distribué</th>
<th class="text-left p-2">Externe</th>
</tr>
</thead>
<tbody>
<tr
*ngFor="let r of editedRows(); let i = index; trackBy: trackByRow"
class="border-t transition-colors"
[ngClass]="editMode() && isRowDirty(i) ? 'bg-amber-50 dark:bg-amber-900/30' : ''"
>
<td class="p-2">{{ r.typeGain }}</td>
<td class="p-2">{{ r.typeJeu }}</td>
<td class="p-2">
@if(editMode() && !detail()!.summary.confirmed){
<input
z-input
type="number"
[value]="r.montant"
(input)="onChangeMontant(i, $any($event.target).value)"
/>
} @else {
{{ r.montant | number : '1.0-0' : 'fr-FR' }}
}
</td>
<td class="p-2">
@if(editMode() && !detail()!.summary.confirmed){
<input
z-input
type="number"
[value]="r.nombre"
(input)="onChangeNombre(i, $any($event.target).value)"
/>
} @else {
{{ r.nombre | number : '1.0-0' : 'fr-FR' }}
}
</td>
<td class="p-2">{{ r.statut }}</td>
<td class="p-2">
@if(editMode() && !detail()!.summary.confirmed){
<input
type="checkbox"
[checked]="r.distributed"
(change)="onToggleDistributed(i, $any($event.target).checked)"
/>
} @else {
<input type="checkbox" [checked]="r.distributed" disabled />
}
</td>
<td class="p-2">
@if(editMode() && !detail()!.summary.confirmed){
<input
type="checkbox"
[checked]="r.externe"
(change)="onToggleExterne(i, $any($event.target).checked)"
/>
} @else {
<input type="checkbox" [checked]="r.externe" disabled />
}
</td>
</tr>
</tbody>
</table>
</div>
</div>
}

View File

@@ -0,0 +1,132 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { ActivatedRoute, RouterModule } from '@angular/router';
import { ZardCardComponent } from '@shared/components/card/card.component';
import { ReportService } from 'src/app/core/services/report';
import { CourseReportDetail, CourseReportDetailRow } from 'src/app/core/interfaces/report';
import { ZardButtonComponent } from '@shared/components/button/button.component';
@Component({
standalone: true,
selector: 'app-report-courses-detail',
templateUrl: './report-detail.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, RouterModule, ZardCardComponent, ZardButtonComponent],
})
export class ReportCoursesDetailPage {
detail = signal<CourseReportDetail | undefined>(undefined);
editMode = signal(false);
editedRows = signal<CourseReportDetailRow[]>([]);
private originalRows = signal<CourseReportDetailRow[]>([]);
constructor(private route: ActivatedRoute, private api: ReportService) {
const id = this.route.snapshot.params['id'];
this.api.getDetail(id).subscribe((d) => {
this.detail.set(d);
this.editedRows.set(d?.rows ?? []);
this.originalRows.set(d?.rows ? d.rows.map((r) => ({ ...r })) : []);
});
}
onValidate() {
const id = this.detail()?.summary.id;
if (!id) return;
// Persist edited rows then validate
this.api.modifyRows(id, this.editedRows()).subscribe(() => {
this.api.validate(id).subscribe((s) => {
if (this.detail()) this.detail.set({ summary: s!, rows: this.editedRows() });
// Commit current edits as the new baseline
this.originalRows.set(this.editedRows().map((r) => ({ ...r })));
this.editMode.set(false);
});
});
}
onConfirm() {
const id = this.detail()?.summary.id;
if (!id) return;
this.api.confirm(id).subscribe((s) => {
if (this.detail()) this.detail.set({ summary: s!, rows: this.editedRows() });
// Confirm also commits the current edits as baseline
this.originalRows.set(this.editedRows().map((r) => ({ ...r })));
this.editMode.set(false);
});
}
onReset() {
const id = this.detail()?.summary.id;
if (!id) return;
this.api.resetStatus(id).subscribe((s) => {
if (this.detail()) this.detail.set({ summary: s!, rows: this.detail()!.rows });
// Reset discards uncommitted edits
this.editedRows.set(this.originalRows().map((r) => ({ ...r })));
this.editMode.set(false);
});
}
onEditToggle() {
if (this.detail()?.summary.confirmed) return;
const currentlyEditing = this.editMode();
if (currentlyEditing) {
// Leaving edit mode without validation: revert to original snapshot
this.editedRows.set(this.originalRows().map((r) => ({ ...r })));
this.editMode.set(false);
} else {
this.editMode.set(true);
}
}
onChangeMontant(index: number, value: any) {
const v = Number(value);
this.editedRows.update((rows: CourseReportDetailRow[]) => {
const current = rows[index];
if (!current) return rows;
current.montant = Number.isFinite(v) ? v : current.montant;
return rows;
});
}
onChangeNombre(index: number, value: any) {
const v = Number(value);
this.editedRows.update((rows: CourseReportDetailRow[]) => {
const current = rows[index];
if (!current) return rows;
current.nombre = Number.isFinite(v) ? v : current.nombre;
return rows;
});
}
onToggleDistributed(index: number, value: any) {
const checked = !!value;
this.editedRows.update((rows: CourseReportDetailRow[]) => {
const current = rows[index];
if (!current) return rows;
current.distributed = checked;
return rows;
});
}
onToggleExterne(index: number, value: any) {
const checked = !!value;
this.editedRows.update((rows: CourseReportDetailRow[]) => {
const current = rows[index];
if (!current) return rows;
current.externe = checked;
return rows;
});
}
trackByRow(index: number, row: CourseReportDetailRow) {
return row.typeGain + '|' + row.typeJeu + '|' + index;
}
isRowDirty(index: number): boolean {
const current = this.editedRows()[index];
const original = this.originalRows()[index];
if (!current || !original) return false;
return (
current.montant !== original.montant ||
current.nombre !== original.nombre ||
!!current.distributed !== !!original.distributed ||
!!current.externe !== !!original.externe
);
}
}

View File

@@ -0,0 +1,19 @@
<div class="flex flex-col gap-2 min-h-screen">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-semibold">Rapport des courses</h2>
</div>
<app-search-bar (search)="onSearch($event)"></app-search-bar>
<app-data-table [data]="rows()" [columns]="cols" [sort]="sort()" (sortChange)="sort.set($event)">
<ng-template #rowActions let-row>
<div class="flex gap-3">
<button z-button zType="ghost" (click)="open(row)"><i class="icon-eye"></i></button>
</div>
</ng-template>
</app-data-table>
<app-paginator [total]="total()" [page]="page()" [perPage]="perPage()" (pageChange)="page.set($event)" (perPageChange)="perPage.set($event)"></app-paginator>
</div>

View File

@@ -0,0 +1,61 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, signal, effect, 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 { ZardButtonComponent } from '@shared/components/button/button.component';
import { SortDir } from '@shared/paging/paging';
import { Router } from '@angular/router';
import { CourseReportSummary } from 'src/app/core/interfaces/report';
import { ReportService } from 'src/app/core/services/report';
@Component({
standalone: true,
selector: 'app-report-courses-list',
templateUrl: './report-list.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, DataTable, Paginator, SearchBar, ZardButtonComponent],
})
export class ReportCoursesListPage {
rows = signal<CourseReportSummary[]>([]);
total = signal(0);
page = signal(1);
perPage = signal(10);
search = signal('');
sort = signal<SortState>({ key: 'date', dir: 'desc' });
loading = signal(false);
cols: TableColumn<CourseReportSummary>[] = [
{ key: 'course.dateDepartCourse', label: 'Date', sortable: true },
{ key: 'course.numero', label: 'Numéro', sortable: true },
{ key: 'course.nom', label: 'Nom', sortable: true },
{ key: 'course.type', label: 'Type', sortable: true },
{ key: 'course.reunion.hippodrome.nom', label: 'Lieu', sortable: true },
{ key: 'course.particularite', label: 'Particularité' },
{ key: 'statut', label: 'Statut', sortable: true },
];
constructor(private api: ReportService, private router: Router) {
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));
});
}
fetch(params: { page: number; perPage: number; search: string; sortKey: string; sortDir: SortDir }) {
this.loading.set(true);
this.api.list(params).subscribe((res) => {
this.rows.set(res.data);
this.total.set(res.meta.total);
this.loading.set(false);
});
}
onSearch(q: string) { this.search.set(q); this.page.set(1); }
open(row: CourseReportSummary) {
this.router.navigate(['/rapport-courses', row.id]);
}
}

View File

@@ -0,0 +1,87 @@
<div class="min-h-screen flex flex-col gap-2">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Réunions</h1>
<button z-button (click)="openCreate()">Nouvelle réunion</button>
</div>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<z-card class="text-center py-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Total des réunions</div>
<div class="text-3xl font-bold text-gray-900 dark:text-gray-100 mt-1">
{{ total() }}
</div>
</z-card>
<z-card class="text-center py-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Réunions à venir</div>
<div class="text-3xl font-bold text-blue-600 dark:text-blue-400 mt-1">
{{ upcomingReunions() }}
</div>
</z-card>
<z-card class="text-center py-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Réunions passées</div>
<div class="text-3xl font-bold text-amber-600 dark:text-amber-400 mt-1">
{{ pastReunions() }}
</div>
</z-card>
<z-card class="text-center py-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Hippodromes concernés</div>
<div class="text-3xl font-bold text-green-600 dark:text-green-400 mt-1">
{{ uniqueHippodromes() }}
</div>
</z-card>
</div>
<app-search-bar placeholder="Rechercher (nom, date, hippodrome…)" (search)="onSearch($event)" />
<div class="rounded-2xl overflow-hidden">
<app-data-table
[columns]="cols"
[data]="rows()"
[loading]="loading()"
[sort]="sort()"
(sortChange)="sort.set($event)"
>
<ng-template #rowActions let-row>
<div class="flex flex-row gap-2">
<button
class="p-1 rounded text-blue-600 hover:bg-blue-100 dark:text-blue-400 dark:hover:bg-gray-800 cursor-pointer"
(click)="openEdit(row)"
>
<lucide-angular name="folder-pen" class="size-5"></lucide-angular>
</button>
<button
class="p-1 rounded text-red-600 hover:bg-red-100 dark:text-red-400 dark:hover:bg-gray-800 cursor-pointer"
(click)="remove(row)"
>
<lucide-angular name="trash-2" class="size-5"></lucide-angular>
</button>
</div>
</ng-template>
</app-data-table>
<app-paginator
[page]="page()"
[perPage]="perPage()"
[total]="total()"
(pageChange)="page.set($event)"
(perPageChange)="perPage.set($event)"
[pageSizes]="pageSize"
/>
</div>
<app-modal [open]="modalOpen()" [title]="modalTitle()" size="md" (close)="closeModal()">
<app-reunion-form
[value]="editingItem()"
(save)="onFormSave($event)"
(cancel)="closeModal()"
></app-reunion-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>
</div>
</app-modal>
</div>

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Reunion } from './reunion';
describe('Reunion', () => {
let component: Reunion;
let fixture: ComponentFixture<Reunion>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Reunion]
})
.compileComponents();
fixture = TestBed.createComponent(Reunion);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,234 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
signal,
ViewChild,
untracked,
} from '@angular/core';
import { Reunion as ReunionType } from 'src/app/core/interfaces/reunion';
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 { ReunionForm } from '@shared/forms/reunion-form/reunion-form';
import { ReunionService } from 'src/app/core/services/reunion';
import { ZardButtonComponent } from '@shared/components/button/button.component';
import { ZardCardComponent } from '@shared/components/card/card.component';
import { SortDir } from '@shared/paging/paging';
import { LucideAngularModule } from 'lucide-angular';
@Component({
standalone: true,
selector: 'app-reunion-list',
imports: [
CommonModule,
DataTable,
Paginator,
SearchBar,
Modal,
ReunionForm,
ZardButtonComponent,
ZardCardComponent,
LucideAngularModule,
],
templateUrl: './reunion.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ReunionList {
// Core reactive state
rows = signal<ReunionType[]>([]);
loading = signal(false);
total = signal(0);
upcomingReunions = signal(0);
pastReunions = signal(0);
uniqueHippodromes = signal(0);
// pagination, sorting, search
page = signal(1);
perPage = signal(10);
search = signal('');
sort = signal<SortState>({ key: 'date', dir: 'asc' });
pageSize = [10, 20, 50];
// modal management
modalOpen = signal(false);
modalTitle = signal('Nouvelle réunion');
editingItem = signal<Partial<ReunionType> | null>(null);
@ViewChild(ReunionForm) formComp?: ReunionForm;
cols: TableColumn<ReunionType>[] = [
{ key: 'code', label: 'Code', sortable: true },
{ key: 'nom', label: 'Nom', sortable: true },
{
key: 'date',
label: 'Date',
sortable: true,
cell: (r) =>
new Date(r.date).toLocaleDateString('fr-FR', {
day: '2-digit',
month: 'short',
year: 'numeric',
}),
},
{ key: 'numero', label: 'Numéro', sortable: true },
{
key: 'hippodrome.nom',
label: 'Hippodrome',
cell: (r) =>
r.hippodrome ? `${r.hippodrome.nom} (${r.hippodrome.ville}, ${r.hippodrome.pays})` : '—',
},
{
key: 'statut',
label: 'Statut',
cell: (r) => {
const colorMap: Record<string, string> = {
PLANIFIEE: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
EN_COURS: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
TERMINEE: '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> = {
PLANIFIEE: 'Planifiée',
EN_COURS: 'En cours',
TERMINEE: 'Terminée',
ANNULEE: 'Annulée',
};
return `<span class="px-2 py-1 rounded-full text-xs font-semibold ${colorMap[r.statut]}">${
labelMap[r.statut]
}</span>`;
},
},
{
key: 'totalCourses',
label: 'Courses',
cell: (r) => (r.totalCourses ? r.totalCourses.toString() : '—'),
},
{
key: 'createdAt',
label: 'Créée le',
cell: (r) =>
new Date(r.createdAt).toLocaleDateString('fr-FR', {
day: '2-digit',
month: 'short',
year: 'numeric',
}),
},
{
key: 'updatedAt',
label: 'Modifiée le',
cell: (r) =>
new Date(r.updatedAt).toLocaleDateString('fr-FR', {
day: '2-digit',
month: 'short',
year: 'numeric',
}),
},
];
constructor(private api: ReunionService) {
// Effect will run only when one of these dependencies change
effect(() => {
const params = {
page: this.page(),
perPage: this.perPage(),
search: this.search(),
sortKey: this.sort().key,
sortDir: this.sort().dir,
};
untracked(() => this.fetch(params)); // avoids recursive dependency
});
}
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);
const meta = res.meta;
this.upcomingReunions.set(res.meta['upcomingReunions'] ?? 0);
this.pastReunions.set(res.meta['pastReunions'] ?? 0);
this.uniqueHippodromes.set(res.meta['uniqueHippodromes'] ?? 0);
this.loading.set(false);
},
error: () => {
this.rows.set([]);
this.total.set(0);
this.upcomingReunions.set(0);
this.pastReunions.set(0);
this.uniqueHippodromes.set(0);
this.loading.set(false);
},
});
}
// === UI interactions ===
onSearch(q: string) {
this.search.set(q);
this.page.set(1); // reset pagination on new search
}
openCreate() {
this.modalTitle.set('Nouvelle réunion');
this.editingItem.set(null);
queueMicrotask(() => this.modalOpen.set(true));
}
openEdit(row: ReunionType) {
this.modalTitle.set('Modifier la réunion');
this.editingItem.set(row);
queueMicrotask(() => this.modalOpen.set(true));
}
closeModal() {
this.modalOpen.set(false);
}
submitChildForm() {
this.formComp?.onSubmit();
}
onFormSave(payload: Partial<ReunionType>) {
const current = this.editingItem();
const req$ = current?.id
? this.api.update(current.id, payload)
: this.api.create(payload as Omit<ReunionType, 'id'>);
req$.subscribe(() => {
this.closeModal();
// Reset editing item to null to clear the form
this.editingItem.set(null);
// refetch current page
this.fetch({
page: this.page(),
perPage: this.perPage(),
search: this.search(),
sortKey: this.sort().key,
sortDir: this.sort().dir,
});
});
}
remove(row: ReunionType) {
if (!confirm(`Supprimer la réunion « ${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,
})
);
}
}

View File

@@ -0,0 +1,111 @@
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-semibold">Rôles & Permissions</h1>
<p class="text-sm text-muted-foreground">Gérez les rôles et assignez les permissions</p>
</div>
<z-button (click)="openCreate()">Nouveau rôle</z-button>
</div>
<app-search-bar placeholder="Rechercher un rôle" (search)="onSearch($event)" />
<div class="p-0 overflow-hidden">
<app-data-table
[data]="rows()"
[columns]="cols"
[sort]="sort()"
(sortChange)="sort.set($event)"
>
<ng-template #rowActions let-row>
<div class="flex gap-3 items-center justify-center">
<button z-button zType="ghost" (click)="openEdit(row)"><i class="icon-pen"></i></button>
<button z-button zType="destructive" (click)="remove(row)">
<i class="icon-trash"></i>
</button>
</div>
</ng-template>
</app-data-table>
</div>
<!-- Permissions listing -->
<div class="mt-8 space-y-3">
<div class="flex items-center justify-between">
<div>
<h2 class="text-xl font-semibold">Permissions</h2>
<p class="text-sm text-muted-foreground">Gérez la liste des permissions disponibles</p>
</div>
<z-button zType="secondary" (click)="openCreatePermission()">Nouvelle permission</z-button>
</div>
<div class="border rounded-md overflow-hidden">
<table class="min-w-full text-sm">
<thead class="bg-muted">
<tr>
<th class="px-4 py-2 text-left">Code</th>
<th class="px-4 py-2 text-left">Description</th>
<th class="px-4 py-2 text-right">Actions</th>
</tr>
</thead>
<tbody>
@for (p of permissions(); track p.id) {
<tr class="border-t">
<td class="px-4 py-2 font-mono text-xs">{{ p.name }}</td>
<td class="px-4 py-2">{{ p.description || '—' }}</td>
<td class="px-4 py-2">
<div class="flex justify-end gap-2">
<button z-button zType="ghost" (click)="openEditPermission(p)">
<i class="icon-pen"></i>
</button>
<button z-button zType="destructive" (click)="removePermission(p)">
<i class="icon-trash"></i>
</button>
</div>
</td>
</tr>
} @if (!permissions().length) {
<tr>
<td colspan="3" class="px-4 py-4 text-center text-muted-foreground">
Aucune permission définie.
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
<app-modal [open]="modalOpen()" [title]="modalTitle()" (close)="closeModal()" size="xl">
@if (modalOpen()) {
<app-role-form
[value]="editingItem() ?? undefined"
[permissions]="permissions()"
(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>
</div>
</app-modal>
<!-- Permission modal -->
<app-modal
[open]="permissionModalOpen()"
[title]="permissionModalTitle()"
(close)="closePermissionModal()"
size="lg"
>
@if (permissionModalOpen()) {
<app-permission-form
[value]="editingPermission() ?? undefined"
(save)="onPermissionFormSave($event)"
(cancel)="closePermissionModal()"
/>
}
<div modal-actions class="flex justify-end gap-2">
<z-button zType="destructive" (click)="closePermissionModal()">Annuler</z-button>
<z-button (click)="submitPermissionForm()">Enregistrer</z-button>
</div>
</app-modal>
</div>

View File

@@ -0,0 +1,241 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
ViewChild,
effect,
signal,
untracked,
} from '@angular/core';
import { DataTable, SortState, TableColumn } from '@shared/components/data-table/data-table';
import { SearchBar } from '@shared/components/search-bar/search-bar';
import { Modal } from '@shared/components/modal/modal';
import { ZardButtonComponent } from '@shared/components/button/button.component';
import { Permission, Role } from 'src/app/core/interfaces/role';
import { RoleService } from 'src/app/core/services/role';
import { SortDir } from '@shared/paging/paging';
import { RoleForm } from '@shared/forms/role-form/role-form';
import { PermissionForm } from '@shared/forms/permission-form/permission-form';
import { toast } from 'ngx-sonner';
@Component({
standalone: true,
selector: 'app-roles',
templateUrl: './roles.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
DataTable,
SearchBar,
Modal,
ZardButtonComponent,
RoleForm,
PermissionForm,
],
})
export class RolesPage {
rows = signal<Role[]>([]);
total = signal(0);
loading = signal(false);
permissions = signal<Permission[]>([]);
page = signal(1);
perPage = signal(10);
search = signal('');
sort = signal<SortState>({ key: 'name', dir: 'asc' });
modalOpen = signal(false);
modalTitle = signal('Nouveau rôle');
editingItem = signal<Role | null>(null);
permissionModalOpen = signal(false);
permissionModalTitle = signal('Nouvelle permission');
editingPermission = signal<Permission | null>(null);
@ViewChild(RoleForm) formComp?: RoleForm;
@ViewChild(PermissionForm) permFormComp?: PermissionForm;
cols: TableColumn<Role>[] = [
{ key: 'name', label: 'Rôle', sortable: true },
{ key: 'description', label: 'Description', sortable: true },
{ key: 'permissions', label: 'Permissions', cell: (r) => r.permissions.length },
];
constructor(public api: RoleService) {
this.api.allPermissions().subscribe((list) => this.permissions.set(list));
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.loading.set(false);
},
error: () => {
this.rows.set([]);
this.total.set(0);
this.loading.set(false);
},
});
}
onSearch(q: string) {
this.search.set(q);
this.page.set(1);
}
openCreate() {
this.modalTitle.set('Nouveau rôle');
this.editingItem.set(null);
queueMicrotask(() => this.modalOpen.set(true));
}
openEdit(row: Role) {
this.modalTitle.set('Modifier le rôle');
this.editingItem.set(row);
queueMicrotask(() => this.modalOpen.set(true));
}
closeModal() {
this.modalOpen.set(false);
}
submitChildForm() {
this.formComp?.onSubmit();
}
submitPermissionForm() {
this.permFormComp?.onSubmit();
}
onFormSave(payload: Partial<Role>) {
const current = this.editingItem();
const req$ = current?.id
? this.api.update(current.id, payload)
: this.api.create(payload as Omit<Role, 'id'>);
req$.subscribe({
next: (role) => {
this.closeModal();
toast.success(
current?.id
? `Le rôle « ${role?.name ?? ''} » a été mis à jour avec succès`
: `Le rôle « ${role?.name ?? ''} » a été créé avec succès`
);
this.fetch({
page: this.page(),
perPage: this.perPage(),
search: this.search(),
sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir,
});
},
error: () => {
toast.error(
current?.id
? 'Erreur lors de la mise à jour du rôle'
: 'Erreur lors de la création du rôle',
{ duration: 5000 }
);
},
});
}
remove(row: Role) {
if (!confirm(`Supprimer le rôle « ${row.name} » ?`)) return;
this.api.delete(row.id).subscribe((result) => {
if (result.success) {
toast.success(`Le rôle « ${row.name} » a été supprimé avec succès`);
this.fetch({
page: this.page(),
perPage: this.perPage(),
search: this.search(),
sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir,
});
} else {
toast.error(result.error || 'Erreur lors de la suppression du rôle', {
duration: 5000,
});
}
});
}
// ------- Permissions CRUD -------
openCreatePermission() {
this.permissionModalTitle.set('Nouvelle permission');
this.editingPermission.set(null);
this.permissionModalOpen.set(true);
}
openEditPermission(p: Permission) {
this.permissionModalTitle.set('Modifier la permission');
this.editingPermission.set(p);
this.permissionModalOpen.set(true);
}
closePermissionModal() {
this.permissionModalOpen.set(false);
this.editingPermission.set(null);
}
onPermissionFormSave(payload: Permission) {
const current = this.editingPermission();
const isEdit = !!current?.id;
const req$ = isEdit
? this.api.updatePermission(current.id, {
name: payload.name,
description: payload.description,
})
: this.api.createPermission({ name: payload.name, description: payload.description });
req$.subscribe({
next: (perm) => {
this.closePermissionModal();
const permName = perm?.name || payload.name;
toast.success(
isEdit
? `La permission « ${permName} » a été mise à jour avec succès`
: `La permission « ${permName} » a été créée avec succès`
);
this.api.allPermissions().subscribe((list) => this.permissions.set(list));
},
error: (err) => {
const permName = payload.name;
toast.error(
isEdit
? `Erreur lors de la mise à jour de la permission « ${permName} »`
: `Erreur lors de la création de la permission « ${permName} »`,
{ duration: 5000 }
);
},
});
}
removePermission(p: Permission) {
if (!confirm(`Supprimer la permission « ${p.name} » ?`)) return;
this.api.deletePermission(p.id).subscribe((result) => {
if (result.success) {
toast.success(`La permission « ${p.name} » a été supprimée avec succès`);
this.api.allPermissions().subscribe((list) => this.permissions.set(list));
} else {
toast.error(result.error || 'Erreur lors de la suppression de la permission', {
duration: 5000,
});
}
});
}
}

View File

@@ -0,0 +1,194 @@
<div class="flex flex-col gap-4 min-h-screen">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-semibold">TPES (Terminal Point de Vente)</h2>
<z-button (click)="openCreate()">Nouvel équipement</z-button>
</div>
<!-- Stats Cards -->
@if (statsLoading()) {
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
@for (i of [1, 2, 3, 4]; track i) {
<div class="bg-surface border rounded-lg p-4 animate-pulse">
<div class="h-4 bg-gray-200 rounded w-24 mb-2"></div>
<div class="h-8 bg-gray-200 rounded w-16"></div>
</div>
}
</div>
} @else {
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="bg-surface border rounded-lg p-4">
<div class="text-sm text-muted-foreground">Total TPEs</div>
<div class="text-2xl font-bold">{{ assignmentStats().total }}</div>
</div>
<div class="bg-surface border rounded-lg p-4">
<div class="text-sm text-muted-foreground">Assignés</div>
<div class="text-2xl font-bold">{{ assignmentStats().assignes }}</div>
</div>
<div class="bg-surface border rounded-lg p-4">
<div class="text-sm text-muted-foreground">Disponibles</div>
<div class="text-2xl font-bold">{{ assignmentStats().disponibles }}</div>
</div>
<div class="bg-surface border rounded-lg p-4">
<div class="text-sm text-muted-foreground">Valides</div>
<div class="text-2xl font-bold">{{ statsByStatut()['VALIDE'] || 0 }}</div>
</div>
</div>
}
<!-- Statut Filter Chips -->
<div class="flex flex-wrap gap-2 items-center">
<span class="text-sm font-medium">Filtrer par statut:</span>
<button
z-button
zType="ghost"
zSize="sm"
[class]="!selectedStatut() ? '!bg-primary/10 !text-primary' : ''"
(click)="onStatutFilter(null)"
>
Tous
</button>
@for (statut of allStatuses; track statut) {
<button
z-button
zType="ghost"
zSize="sm"
[class]="selectedStatut() === statut ? '!bg-primary/10 !text-primary' : ''"
(click)="onStatutFilter(statut)"
>
{{ formatStatut(statut) }}
@if (statsByStatut()[statut]) {
<span class="ml-1 text-xs">({{ statsByStatut()[statut] }})</span>
}
</button>
}
</div>
<app-search-bar (search)="onSearch($event)"></app-search-bar>
<app-data-table [data]="rows()" [columns]="cols" [sort]="sort()" (sortChange)="sort.set($event)">
<ng-template #rowActions let-row>
<div class="flex gap-2 flex-wrap">
@if (row.assigne) {
<button
z-button
zType="ghost"
zSize="sm"
(click)="onLiberer(row)"
zTooltip="Libérer"
zPosition="top"
>
<i class="icon-unlink-2"></i>
</button>
} @else {
<button
z-button
zType="ghost"
zSize="sm"
(click)="onAssigner(row)"
zTooltip="Assigner"
zPosition="top"
>
<i class="icon-user-plus"></i>
</button>
}
<div z-menu [zMenuTriggerFor]="statutMenu" zPlacement="bottomRight">
<ng-template #statutMenu>
<div z-menu-content class="w-48">
@for (statut of allStatuses; track statut) {
<button
z-menu-item
[class]="row.statut === statut ? '!text-primary' : ''"
(click)="onUpdateStatut(row, statut)"
>
{{ formatStatut(statut) }}
</button>
}
</div>
</ng-template>
<button z-button zType="ghost" zSize="sm" zTooltip="Changer statut" zPosition="top">
<i class="icon-sliders-horizontal"></i>
</button>
</div>
<button
z-button
zType="ghost"
zSize="sm"
(click)="openEdit(row)"
zTooltip="Modifier"
zPosition="top"
>
<i class="icon-pen"></i>
</button>
<button
z-button
zType="destructive"
zSize="sm"
(click)="remove(row)"
zTooltip="Supprimer"
zPosition="top"
>
<i class="icon-trash"></i>
</button>
</div>
</ng-template>
</app-data-table>
<app-paginator
[total]="total()"
[page]="page()"
[perPage]="perPage()"
(pageChange)="page.set($event)"
(perPageChange)="perPage.set($event)"
></app-paginator>
</div>
<app-modal [open]="modalOpen()" [title]="modalTitle()" (close)="closeModal()" size="xl">
<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>
</div>
</app-modal>
<!-- Agent Assignment Modal -->
<app-modal
[open]="assignModalOpen()"
[title]="'Assigner le TPE ' + (assigningTpe()?.imei || '')"
(close)="closeAssignModal()"
size="md"
>
<div class="space-y-4">
@if (agentsLoading()) {
<div class="text-center py-4">Chargement des agents...</div>
} @else if (agents().length === 0) {
<div class="text-center py-4 text-muted-foreground">Aucun agent actif disponible</div>
} @else {
<z-form-field>
<label z-form-label>Sélectionner un agent</label>
<div z-form-control>
<z-select
[zValue]="selectedAgentId()"
(zSelectionChange)="selectedAgentId.set($event)"
[zPlaceholder]="'Sélectionner un agent...'"
>
@for (agent of agents(); track agent.id) {
<z-select-item [zValue]="agent.id">
{{ agent.code }} - {{ agent.nom }} {{ agent.prenom }}
</z-select-item>
}
</z-select>
</div>
</z-form-field>
}
</div>
<div modal-actions class="flex justify-end gap-2">
<z-button zType="destructive" (click)="closeAssignModal()">Annuler</z-button>
<button z-button [disabled]="!selectedAgentId() || agentsLoading()" (click)="confirmAssign()">
Assigner
</button>
</div>
</app-modal>

View File

@@ -0,0 +1,487 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
ViewChild,
effect,
signal,
untracked,
OnInit,
} 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 { ZardButtonComponent } from '@shared/components/button/button.component';
import { ZardMenuModule } from '@shared/components/menu/menu.module';
import { ZardTooltipModule } from '@shared/components/tooltip/tooltip';
import { SortDir } from '@shared/paging/paging';
import { TpeDevice, TpeStatus } from 'src/app/core/interfaces/tpe';
import { TpeService } from 'src/app/core/services/tpe';
import { TpeForm } from '@shared/forms/tpe-form/tpe-form';
import { Agent } from 'src/app/core/interfaces/agent';
import { AgentService } from 'src/app/core/services/agent';
import { ZardSelectComponent } from '@shared/components/select/select.component';
import { ZardSelectItemComponent } from '@shared/components/select/select-item.component';
import { ZardFormModule } from '@shared/components/form/form.module';
import { forkJoin, Subject } from 'rxjs';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';
@Component({
standalone: true,
selector: 'app-tpe-list',
templateUrl: './tpe.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
DataTable,
Paginator,
SearchBar,
Modal,
ZardButtonComponent,
TpeForm,
ZardMenuModule,
ZardTooltipModule,
ZardSelectComponent,
ZardSelectItemComponent,
ZardFormModule,
],
})
export class TpePage implements OnInit {
rows = signal<TpeDevice[]>([]);
total = signal(0);
loading = signal(false);
page = signal(1);
perPage = signal(10);
search = signal('');
sort = signal<SortState>({ key: 'imei', dir: 'asc' });
selectedStatut = signal<TpeStatus | null>(null);
modalOpen = signal(false);
modalTitle = signal('Nouvel équipement');
editingItem = signal<TpeDevice | null>(null);
// Agent assignment modal
assignModalOpen = signal(false);
assigningTpe = signal<TpeDevice | null>(null);
agents = signal<Agent[]>([]);
selectedAgentId = signal<string>('');
agentsLoading = signal(false);
// Stats
statsByStatut = signal<Record<string, number>>({});
assignmentStats = signal({ total: 0, assignes: 0, disponibles: 0 });
statsLoading = signal(false);
// Live search
private searchSubject = new Subject<string>();
@ViewChild(TpeForm) formComp?: TpeForm;
formatStatut(statut: string): string {
const statutMap: Record<string, string> = {
VALIDE: 'Valide',
INVALIDE: 'Invalide',
EN_PANNE: 'En panne',
BLOQUE: 'Bloqué',
DISPONIBLE: 'Disponible',
AFFECTE: 'Affecté',
EN_MAINTENANCE: 'En maintenance',
HORS_SERVICE: 'Hors service',
VOLE: 'Volé',
};
return statutMap[statut] || statut;
}
cols: TableColumn<TpeDevice>[] = [
{ key: 'imei', label: 'IMEI', sortable: true },
{ key: 'serial', label: 'N° de Série', sortable: true },
{ key: 'type', label: 'Type', sortable: true },
{ key: 'marque', label: 'Marque', sortable: true },
{ key: 'modele', label: 'Modèle', sortable: true },
{ key: 'statut', label: 'Statut', sortable: true, cell: (d) => this.formatStatut(d.statut) },
{
key: 'assigne',
label: 'Assigné à',
cell: (d) => {
if (!d.assigne || !d.agent) {
return '<span class="text-muted-foreground text-sm">Non assigné</span>';
}
const agent = d.agent;
const code = agent.code
? `<span class="inline-flex items-center px-2 py-1 rounded bg-primary/10 text-primary text-xs font-medium">${agent.code}</span>`
: '';
const name =
agent.nom && agent.prenom
? `<div class="font-medium text-sm">${agent.nom} ${agent.prenom}</div>`
: agent.nom || agent.prenom
? `<div class="font-medium text-sm">${agent.nom || agent.prenom}</div>`
: '';
const phone = agent.phone
? `<div class="text-xs text-muted-foreground">${agent.phone}</div>`
: '';
const zone = agent.zone
? `<div class="text-xs text-muted-foreground">Zone: ${agent.zone}</div>`
: '';
const parts = [code, name, phone, zone].filter(Boolean);
if (parts.length === 0) {
return '<span class="text-muted-foreground text-sm">Agent assigné</span>';
}
return `<div class="flex flex-col gap-1">${parts.join('')}</div>`;
},
},
];
allStatuses: TpeStatus[] = [
'VALIDE',
'INVALIDE',
'EN_PANNE',
'BLOQUE',
'DISPONIBLE',
'AFFECTE',
'EN_MAINTENANCE',
'HORS_SERVICE',
'VOLE',
];
constructor(private api: TpeService, private agentService: AgentService) {
effect(() => {
// Only trigger fetch when page, perPage, or sort changes (not search - handled by searchSubject)
const searchValue = this.search();
const params = {
page: this.page(),
perPage: this.perPage(),
search: searchValue,
sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir,
};
// Only fetch if search is empty (search is handled by searchSubject)
if (!searchValue.trim()) {
untracked(() => this.fetch(params));
}
});
// Setup live search with debounce
this.searchSubject
.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap((query) => {
if (query.trim()) {
return this.api.search(query);
} else {
// If empty, use normal list
return this.api.list({
page: this.page(),
perPage: this.perPage(),
search: '',
sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir,
});
}
})
)
.subscribe({
next: (res) => {
if (Array.isArray(res)) {
// Search API returns array
this.rows.set(res);
this.total.set(res.length);
} else {
// List returns PagedResult
this.rows.set(res.data);
this.total.set(res.meta.total);
}
this.loading.set(false);
},
error: (err) => {
console.error('Search error:', err);
this.rows.set([]);
this.total.set(0);
this.loading.set(false);
},
});
}
ngOnInit() {
this.loadStats();
// Initial fetch if no search query
if (!this.search().trim()) {
this.fetch({
page: this.page(),
perPage: this.perPage(),
search: '',
sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir,
});
}
}
loadStats() {
this.statsLoading.set(true);
forkJoin({
byStatut: this.api.getCountByStatut(),
assignes: this.api.getAssignesStats(),
}).subscribe({
next: ({ byStatut, assignes }) => {
this.statsByStatut.set(byStatut || {});
// Calculate total from statsByStatut
const total = Object.values(byStatut || {}).reduce((sum, count) => sum + count, 0);
// Calculate disponibles (total - assignes)
const disponibles = Math.max(0, total - (assignes || 0));
this.assignmentStats.set({
total: total,
assignes: assignes || 0,
disponibles: disponibles,
});
this.statsLoading.set(false);
},
error: (err) => {
console.error('Error loading stats:', err);
this.statsByStatut.set({});
this.assignmentStats.set({ total: 0, assignes: 0, disponibles: 0 });
this.statsLoading.set(false);
},
});
}
private fetch(params: {
page: number;
perPage: number;
search: string;
sortKey: string;
sortDir: SortDir;
}) {
// Don't fetch if there's a search query - it's handled by searchSubject
const searchQuery = params.search.trim();
if (searchQuery) {
return; // Search is handled by searchSubject subscription
}
this.loading.set(true);
const statut = this.selectedStatut();
if (statut) {
// Filter by statut - returns array
this.api.getByStatut(statut).subscribe({
next: (res: TpeDevice[]) => {
this.rows.set(res);
this.total.set(res.length);
this.loading.set(false);
},
error: () => {
this.rows.set([]);
this.total.set(0);
this.loading.set(false);
},
});
} else {
// Normal list with pagination
this.api.list(params).subscribe({
next: (res) => {
this.rows.set(res.data);
this.total.set(res.meta.total);
this.loading.set(false);
},
error: () => {
this.rows.set([]);
this.total.set(0);
this.loading.set(false);
},
});
}
}
onSearch(q: string) {
this.search.set(q);
this.page.set(1);
// Trigger search via subject for live search
if (q.trim()) {
this.loading.set(true);
this.searchSubject.next(q);
} else {
// If empty, fetch normally
this.fetch({
page: this.page(),
perPage: this.perPage(),
search: '',
sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir,
});
}
}
onStatutFilter(statut: TpeStatus | null) {
this.selectedStatut.set(statut);
this.page.set(1);
this.fetch({
page: this.page(),
perPage: this.perPage(),
search: this.search(),
sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir,
});
}
onUpdateStatut(row: TpeDevice, newStatut: TpeStatus) {
if (!confirm(`Changer le statut de ${row.imei} vers ${this.formatStatut(newStatut)} ?`)) return;
this.api.updateStatut(row.id, newStatut).subscribe({
next: () => {
this.fetch({
page: this.page(),
perPage: this.perPage(),
search: this.search(),
sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir,
});
this.loadStats();
},
});
}
onLiberer(row: TpeDevice) {
if (!confirm(`Libérer le TPE ${row.imei} ?`)) return;
this.api.liberer(row.id).subscribe({
next: () => {
this.fetch({
page: this.page(),
perPage: this.perPage(),
search: this.search(),
sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir,
});
this.loadStats();
},
});
}
onAssigner(row: TpeDevice) {
this.assigningTpe.set(row);
this.selectedAgentId.set('');
this.loadAgents();
this.assignModalOpen.set(true);
}
loadAgents() {
this.agentsLoading.set(true);
// Load active agents only
this.agentService.getByStatut('ACTIF').subscribe({
next: (agents) => {
this.agents.set(agents);
this.agentsLoading.set(false);
},
error: () => {
this.agents.set([]);
this.agentsLoading.set(false);
},
});
}
confirmAssign() {
const tpe = this.assigningTpe();
const agentId = this.selectedAgentId();
if (!tpe || !agentId) {
alert('Veuillez sélectionner un agent');
return;
}
this.api.assigner(tpe.id, agentId).subscribe({
next: () => {
this.assignModalOpen.set(false);
this.assigningTpe.set(null);
this.selectedAgentId.set('');
this.fetch({
page: this.page(),
perPage: this.perPage(),
search: this.search(),
sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir,
});
this.loadStats();
},
error: () => {
alert("Erreur lors de l'assignation du TPE");
},
});
}
closeAssignModal() {
this.assignModalOpen.set(false);
this.assigningTpe.set(null);
this.selectedAgentId.set('');
}
openCreate() {
this.modalTitle.set('Nouvel équipement');
this.editingItem.set(null);
queueMicrotask(() => this.modalOpen.set(true));
}
openEdit(row: TpeDevice) {
this.modalTitle.set("Modifier l'équipement");
this.editingItem.set(row);
queueMicrotask(() => this.modalOpen.set(true));
}
closeModal() {
this.modalOpen.set(false);
setTimeout(() => {
this.editingItem.set(null);
}, 0);
}
submitChildForm() {
this.formComp?.onSubmit();
}
onFormSave(payload: Partial<TpeDevice>) {
const current = this.editingItem();
const isCreating = !current?.id;
const req$ = current?.id
? this.api.update(current.id, payload)
: this.api.create(payload as Omit<TpeDevice, 'id'>);
req$.subscribe({
next: (result) => {
// For update, check if result is valid (update can return undefined on error)
if (current?.id && !result) {
console.error('Update failed - result is undefined');
// Don't close modal, let user retry
return;
}
// Success - close modal first
this.modalOpen.set(false);
// Then reset form and clear editing item after a short delay
setTimeout(() => {
this.editingItem.set(null);
this.formComp?.resetForm();
}, 100);
// Refresh data
this.fetch({
page: this.page(),
perPage: this.perPage(),
search: this.search(),
sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir,
});
this.loadStats();
},
error: (err) => {
console.error('Error saving TPE:', err);
// Don't close modal on error, let user fix and retry
// Form stays filled so user can correct and resubmit
},
});
}
remove(row: TpeDevice) {
if (!confirm(`Supprimer l\'équipement IMEI ${row.imei} ?`)) 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 as SortDir,
});
this.loadStats();
});
}
}

View File

@@ -0,0 +1,41 @@
<div class="flex flex-col gap-2 min-h-screen">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
La liste des utilisateurs
</h2>
<z-button (click)="openCreate()">Nouvel utilisateur</z-button>
</div>
<app-search-bar (search)="onSearch($event)"></app-search-bar>
<app-data-table [data]="rows()" [columns]="cols" [sort]="sort()" (sortChange)="sort.set($event)">
<ng-template #rowActions let-row>
<div class="flex gap-3">
<button z-button zType="ghost" (click)="openEdit(row)"><i class="icon-pen"></i></button>
<button z-button zType="destructive" (click)="remove(row)">
<i class="icon-trash"></i>
</button>
</div>
</ng-template>
</app-data-table>
<app-paginator
[total]="total()"
[page]="page()"
[perPage]="perPage()"
(pageChange)="page.set($event)"
(perPageChange)="perPage.set($event)"
></app-paginator>
</div>
<app-modal [open]="modalOpen()" [title]="modalTitle()" (close)="closeModal()" size="xl">
<app-user-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 zType="default" (click)="submitChildForm()">Enregistrer</z-button>
</div>
</app-modal>

View File

@@ -0,0 +1,198 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
ViewChild,
signal,
effect,
untracked,
} from '@angular/core';
import { DataTable, TableColumn, SortState } 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 { ZardButtonComponent } from '@shared/components/button/button.component';
import { SortDir } from '@shared/paging/paging';
import { User } from 'src/app/core/interfaces/user';
import { UserService } from 'src/app/core/services/user';
import { Role } from 'src/app/core/interfaces/role';
import { RoleService } from 'src/app/core/services/role';
import { UserForm } from '@shared/forms/user-form/user-form';
import { toast } from 'ngx-sonner';
@Component({
standalone: true,
selector: 'app-users',
templateUrl: './users.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, DataTable, Paginator, SearchBar, Modal, ZardButtonComponent, UserForm],
})
export class UsersPage {
rows = signal<User[]>([]);
total = signal(0);
loading = signal(false);
roleMap = new Map<string, string>();
page = signal(1);
perPage = signal(10);
search = signal('');
sort = signal<SortState>({ key: 'nom', dir: 'asc' });
modalOpen = signal(false);
modalTitle = signal('Nouvel utilisateur');
editingItem = signal<User | null>(null);
@ViewChild(UserForm) formComp?: UserForm;
cols: TableColumn<User>[] = [
{ key: 'nom', label: 'Nom', sortable: true },
{ key: 'prenom', label: 'Prénom', sortable: true },
{ key: 'identifiant', label: 'Identifiant', sortable: true },
{ key: 'matriculeAgent', label: 'Matricule', sortable: true },
{
key: 'role.name',
label: 'Rôle',
sortable: true,
cell: (u) => this.roleMap.get(u.roleId) ?? u.role?.name ?? '—',
},
{ key: 'statut', label: 'Statut', sortable: true },
{
key: 'derniereConnexion',
label: 'Dernière connexion',
cell: (u) =>
u.derniereConnexion ? new Date(u.derniereConnexion).toLocaleDateString('fr-FR') : '—',
},
{
key: 'restrictionConnexion',
label: 'Restr. Conn.',
cell: (u) => String(u.restrictionConnexion),
},
{
key: 'restrictionAutomatique',
label: 'Restr. Auto',
cell: (u) => String(u.restrictionAutomatique),
},
{ key: 'nombreIpAutorise', label: 'IP Autorisé' },
{ key: 'nombreIpAutoAutorise', label: 'IP Auto' },
];
constructor(private api: UserService, private roleService: RoleService) {
this.roleService
.list({ page: 1, perPage: 100, search: '', sortKey: 'name', sortDir: 'asc' } as any)
.subscribe((res) => (res.data as Role[]).forEach((r) => this.roleMap.set(r.id, r.name)));
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.loading.set(false);
},
error: () => {
this.rows.set([]);
this.total.set(0);
this.loading.set(false);
},
});
}
onSearch(q: string) {
this.search.set(q);
this.page.set(1);
}
openCreate() {
this.modalTitle.set('Nouvel utilisateur');
this.editingItem.set(null);
queueMicrotask(() => this.modalOpen.set(true));
}
openEdit(row: User) {
this.modalTitle.set("Modifier l'utilisateur");
this.editingItem.set(row);
queueMicrotask(() => this.modalOpen.set(true));
}
closeModal() {
this.modalOpen.set(false);
}
submitChildForm() {
this.formComp?.onSubmit();
}
onFormSave(payload: Partial<User>) {
const current = this.editingItem();
const req$ = current?.id
? this.api.update(current.id, payload)
: this.api.create(payload as Omit<User, 'id'>);
req$.subscribe({
next: (user) => {
this.closeModal();
toast.success(
current?.id
? `L'utilisateur « ${user?.nom ?? ''} ${
user?.prenom ?? ''
} » a été mis à jour avec succès`
: `L'utilisateur « ${user?.nom ?? ''} ${user?.prenom ?? ''} » a été créé avec succès`
);
this.fetch({
page: this.page(),
perPage: this.perPage(),
search: this.search(),
sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir,
});
},
error: () => {
toast.error(
current?.id
? "Erreur lors de la mise à jour de l'utilisateur"
: "Erreur lors de la création de l'utilisateur",
{ duration: 5000 }
);
},
});
}
remove(row: User) {
if (!confirm(`Supprimer l\'utilisateur « ${row.nom} ${row.prenom} » ?`)) return;
this.api.delete(row.id).subscribe({
next: (ok) => {
if (ok) {
toast.success(`L'utilisateur « ${row.nom} ${row.prenom} » a été supprimé avec succès`);
this.fetch({
page: this.page(),
perPage: this.perPage(),
search: this.search(),
sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir,
});
} else {
toast.error("Erreur lors de la suppression de l'utilisateur", { duration: 5000 });
}
},
error: () => {
toast.error("Erreur lors de la suppression de l'utilisateur", { duration: 5000 });
},
});
}
}