first commit
This commit is contained in:
405
src/app/dashboard/pages/agents/agents.html
Normal file
405
src/app/dashboard/pages/agents/agents.html
Normal 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>
|
||||
}
|
||||
483
src/app/dashboard/pages/agents/agents.ts
Normal file
483
src/app/dashboard/pages/agents/agents.ts
Normal 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('');
|
||||
}
|
||||
}
|
||||
0
src/app/dashboard/pages/courses/courses.css
Normal file
0
src/app/dashboard/pages/courses/courses.css
Normal file
171
src/app/dashboard/pages/courses/courses.html
Normal file
171
src/app/dashboard/pages/courses/courses.html
Normal 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>
|
||||
23
src/app/dashboard/pages/courses/courses.spec.ts
Normal file
23
src/app/dashboard/pages/courses/courses.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
554
src/app/dashboard/pages/courses/courses.ts
Normal file
554
src/app/dashboard/pages/courses/courses.ts
Normal file
@@ -0,0 +1,554 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
signal,
|
||||
ViewChild,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { DataTable, SortState, TableColumn } from '@shared/components/data-table/data-table';
|
||||
import { Paginator } from '@shared/components/paginator/paginator';
|
||||
import { SearchBar } from '@shared/components/search-bar/search-bar';
|
||||
import { Modal } from '@shared/components/modal/modal';
|
||||
import { ZardCardComponent } from '@shared/components/card/card.component';
|
||||
import { ZardButtonComponent } from '@shared/components/button/button.component';
|
||||
import { Course as CourseType } from 'src/app/core/interfaces/course';
|
||||
import { SortDir } from '@shared/paging/paging';
|
||||
import { CourseService } from 'src/app/core/services/course';
|
||||
import { ResultatService } from 'src/app/core/services/resultat';
|
||||
import { Resultat } from 'src/app/core/interfaces/resultat';
|
||||
import { A11yModule } from '@angular/cdk/a11y';
|
||||
import { CourseForm } from '@shared/forms/course-form/course-form';
|
||||
import { NonPartantForm } from '@shared/forms/nonpartant-form/nonpartant-form';
|
||||
import { LucideAngularModule } from 'lucide-angular';
|
||||
import { ResultatForm } from '@shared/forms/resultat-form/resultat-form';
|
||||
import { toast } from 'ngx-sonner';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app-course-list',
|
||||
imports: [
|
||||
CommonModule,
|
||||
DataTable,
|
||||
Paginator,
|
||||
SearchBar,
|
||||
Modal,
|
||||
CourseForm,
|
||||
NonPartantForm,
|
||||
ResultatForm,
|
||||
ZardCardComponent,
|
||||
ZardButtonComponent,
|
||||
A11yModule,
|
||||
LucideAngularModule,
|
||||
],
|
||||
templateUrl: './courses.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class Course {
|
||||
rows = signal<CourseType[]>([]);
|
||||
resultatsMap = signal<Map<string, Resultat>>(new Map());
|
||||
loading = signal(false);
|
||||
total = signal(0);
|
||||
totalRunning = signal(0);
|
||||
totalClosed = signal(0);
|
||||
totalByType = signal<Record<string, number>>({});
|
||||
|
||||
page = signal(1);
|
||||
perPage = signal(10);
|
||||
search = signal('');
|
||||
sort = signal<SortState>({ key: 'numero', dir: 'asc' });
|
||||
pageSize = [10, 20, 50];
|
||||
|
||||
modalOpen = signal(false);
|
||||
modalTitle = signal('Nouvelle course');
|
||||
editingItem = signal<CourseType | null>(null);
|
||||
|
||||
@ViewChild(CourseForm) formComp?: CourseForm;
|
||||
|
||||
// 🟩 Corrected columns
|
||||
cols: TableColumn<CourseType>[] = [
|
||||
{ key: 'numero', label: 'N°', sortable: true },
|
||||
{ key: 'nom', label: 'Nom', sortable: true },
|
||||
{
|
||||
key: 'type',
|
||||
label: 'Type',
|
||||
sortable: true,
|
||||
cell: (c) => `<span class="font-medium">${c.type}</span>`,
|
||||
},
|
||||
{
|
||||
key: 'dateDepartCourse',
|
||||
label: 'Date et Heure Départ',
|
||||
sortable: true,
|
||||
cell: (c) =>
|
||||
new Date(c.dateDepartCourse).toLocaleDateString('fr-FR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: 'partants',
|
||||
label: 'Partants',
|
||||
cell: (c) =>
|
||||
`<span>${c.partants}</span> <span class="text-xs text-red-500">(${
|
||||
c.nonPartants?.length ?? 0
|
||||
} NP)</span>`,
|
||||
},
|
||||
{
|
||||
key: 'resultat',
|
||||
label: 'Résultat',
|
||||
cell: (c) => {
|
||||
const resultat = this.resultatsMap().get(c.id);
|
||||
if (!resultat || !resultat.ordreArrivee || resultat.ordreArrivee.length === 0) {
|
||||
return '<span class="text-gray-500 dark:text-gray-400">—</span>';
|
||||
}
|
||||
|
||||
// Group horses that are at the same place (ex-aequo/dead heat).
|
||||
// Backend/Resultat model store ordreArrivee as cheval numbers (1,2,3,...) and
|
||||
// chevauxDeadHeat as the subset that are ex-aequo.
|
||||
const deadHeatSet = new Set(resultat.chevauxDeadHeat || []);
|
||||
|
||||
const groups: number[][] = [];
|
||||
let currentGroup: number[] = [];
|
||||
|
||||
resultat.ordreArrivee.forEach((num, index) => {
|
||||
const isInDeadHeat = deadHeatSet.has(num);
|
||||
const prevNum = index > 0 ? resultat.ordreArrivee[index - 1] : null;
|
||||
const prevIsInDeadHeat = prevNum !== null && deadHeatSet.has(prevNum);
|
||||
|
||||
if (isInDeadHeat && prevIsInDeadHeat && currentGroup.length > 0) {
|
||||
// Continue the current dead heat group
|
||||
currentGroup.push(num);
|
||||
} else {
|
||||
// Start a new group
|
||||
if (currentGroup.length > 0) {
|
||||
groups.push(currentGroup);
|
||||
}
|
||||
currentGroup = [num];
|
||||
}
|
||||
});
|
||||
|
||||
// Don't forget the last group
|
||||
if (currentGroup.length > 0) {
|
||||
groups.push(currentGroup);
|
||||
}
|
||||
|
||||
const s = groups.map((nums) => nums.join('=')).join(' - ');
|
||||
|
||||
// For now, we'll show the resultat. In the future, we might add a statut field to Resultat
|
||||
return `<span class="mr-2">${s}</span>`;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'statut',
|
||||
label: 'Statut',
|
||||
sortable: true,
|
||||
cell: (c) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
PROGRAMMEE: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300',
|
||||
CREATED: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-300',
|
||||
VALIDATED: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
|
||||
RUNNING: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
|
||||
CLOSED: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300',
|
||||
CANCELED: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300',
|
||||
};
|
||||
const labelMap: Record<string, string> = {
|
||||
PROGRAMMEE: 'Programmée',
|
||||
CREATED: 'Créée',
|
||||
VALIDATED: 'Validée',
|
||||
RUNNING: 'En cours',
|
||||
CLOSED: 'Clôturée',
|
||||
CANCELED: 'Annulée',
|
||||
};
|
||||
return `<span class="px-2 py-1 rounded-full text-xs font-semibold ${colorMap[c.statut]}">${
|
||||
labelMap[c.statut]
|
||||
}</span>`;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'reunion.hippodrome.nom',
|
||||
label: 'Hippodrome',
|
||||
cell: (c) => (c.reunion?.hippodrome ? `${c.reunion.hippodrome.nom}` : '—'),
|
||||
},
|
||||
{
|
||||
key: 'reunion.nom',
|
||||
label: 'Réunion',
|
||||
cell: (c) => c.reunion?.nom ?? '—',
|
||||
},
|
||||
{
|
||||
key: 'distance',
|
||||
label: 'Distance (m)',
|
||||
sortable: true,
|
||||
cell: (c) => c.distance.toLocaleString('fr-FR'),
|
||||
},
|
||||
|
||||
{
|
||||
key: 'createdAt',
|
||||
label: 'Créée le',
|
||||
cell: (c) =>
|
||||
c.createdAt
|
||||
? new Date(c.createdAt).toLocaleDateString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})
|
||||
: '—',
|
||||
},
|
||||
];
|
||||
|
||||
visibleKeys = signal<string[]>([]);
|
||||
|
||||
constructor(private api: CourseService, private resultatService: ResultatService) {
|
||||
effect(() => {
|
||||
const params = {
|
||||
page: this.page(),
|
||||
perPage: this.perPage(),
|
||||
search: this.search(),
|
||||
sortKey: this.sort().key,
|
||||
sortDir: this.sort().dir as SortDir,
|
||||
};
|
||||
untracked(() => this.fetch(params));
|
||||
});
|
||||
}
|
||||
|
||||
private fetch(params: {
|
||||
page: number;
|
||||
perPage: number;
|
||||
search: string;
|
||||
sortKey: string;
|
||||
sortDir: SortDir;
|
||||
}) {
|
||||
this.loading.set(true);
|
||||
this.api.list(params).subscribe({
|
||||
next: (res) => {
|
||||
this.rows.set(res.data);
|
||||
this.total.set(res.meta.total);
|
||||
this.totalRunning.set(res.meta['totalRunning'] ?? 0);
|
||||
this.totalClosed.set(res.meta['totalClosed'] ?? 0);
|
||||
this.totalByType.set(res.meta['totalByType'] ?? {});
|
||||
|
||||
// Fetch resultats for all courses in parallel
|
||||
const courseIds = res.data.map((c) => c.id);
|
||||
if (courseIds.length > 0) {
|
||||
const resultatRequests = courseIds.map((id) =>
|
||||
this.resultatService.getByCourseId(id).pipe(catchError(() => of(undefined)))
|
||||
);
|
||||
|
||||
forkJoin(resultatRequests).subscribe({
|
||||
next: (resultats) => {
|
||||
const resultatsMap = new Map<string, Resultat>();
|
||||
courseIds.forEach((id, index) => {
|
||||
const resultat = resultats[index];
|
||||
if (resultat) {
|
||||
resultatsMap.set(id, resultat);
|
||||
}
|
||||
});
|
||||
this.resultatsMap.set(resultatsMap);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.resultatsMap.set(new Map());
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
this.resultatsMap.set(new Map());
|
||||
this.loading.set(false);
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
this.rows.set([]);
|
||||
this.total.set(0);
|
||||
this.totalRunning.set(0);
|
||||
this.totalClosed.set(0);
|
||||
this.totalByType.set({});
|
||||
this.resultatsMap.set(new Map());
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// === UI Actions ===
|
||||
onSearch(q: string) {
|
||||
this.search.set(q);
|
||||
this.page.set(1);
|
||||
}
|
||||
|
||||
openCreate() {
|
||||
this.modalTitle.set('Nouvelle course');
|
||||
this.editingItem.set(null);
|
||||
queueMicrotask(() => this.modalOpen.set(true));
|
||||
}
|
||||
|
||||
isClosed = (c: CourseType | null | undefined) =>
|
||||
c?.statut === 'CLOSED' || c?.statut === 'CANCELED';
|
||||
|
||||
openEdit(row: CourseType) {
|
||||
if (this.isClosed(row)) return;
|
||||
this.modalTitle.set('Modifier la course');
|
||||
this.editingItem.set(row);
|
||||
queueMicrotask(() => this.modalOpen.set(true));
|
||||
}
|
||||
|
||||
closeModal() {
|
||||
this.modalOpen.set(false);
|
||||
}
|
||||
|
||||
submitChildForm() {
|
||||
this.formComp?.onSubmit();
|
||||
}
|
||||
|
||||
onFormSave(payload: Partial<CourseType>) {
|
||||
const current = this.editingItem();
|
||||
const req$ = current?.id
|
||||
? this.api.update(current.id, payload)
|
||||
: this.api.create(payload as Omit<CourseType, 'id'>);
|
||||
|
||||
req$.subscribe(() => {
|
||||
this.closeModal();
|
||||
this.fetch({
|
||||
page: this.page(),
|
||||
perPage: this.perPage(),
|
||||
search: this.search(),
|
||||
sortKey: this.sort().key,
|
||||
sortDir: this.sort().dir,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
remove(row: CourseType) {
|
||||
if (this.isClosed(row)) return;
|
||||
if (!confirm(`Supprimer la course « ${row.nom} » ?`)) return;
|
||||
this.api.delete(row.id).subscribe(() =>
|
||||
this.fetch({
|
||||
page: this.page(),
|
||||
perPage: this.perPage(),
|
||||
search: this.search(),
|
||||
sortKey: this.sort().key,
|
||||
sortDir: this.sort().dir,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// === Stats Computed ===
|
||||
totalCourses = computed(() => this.total());
|
||||
runningCourses = computed(() => this.totalRunning());
|
||||
closedCourses = computed(() => this.totalClosed());
|
||||
byType = computed(() => this.totalByType());
|
||||
|
||||
nonPartantModalOpen = signal(false);
|
||||
selectedCourse = signal<CourseType | null>(null);
|
||||
@ViewChild(NonPartantForm) npForm?: NonPartantForm;
|
||||
|
||||
openResultat(row: CourseType) {
|
||||
if (this.isClosed(row)) return;
|
||||
this.selectedCourseForResultat.set(row);
|
||||
this.resultatModalOpen.set(true);
|
||||
}
|
||||
|
||||
openNonPartant(row: CourseType) {
|
||||
if (this.isClosed(row)) return;
|
||||
this.selectedCourse.set(row);
|
||||
this.nonPartantModalOpen.set(true);
|
||||
}
|
||||
|
||||
closeNonPartantModal() {
|
||||
this.nonPartantModalOpen.set(false);
|
||||
this.selectedCourse.set(null);
|
||||
}
|
||||
|
||||
submitNonPartant() {
|
||||
this.npForm?.onSubmit();
|
||||
}
|
||||
|
||||
onNonPartantSave(payload: string[]) {
|
||||
const course = this.selectedCourse();
|
||||
if (!course) return;
|
||||
|
||||
this.api.setNonPartants(course.id, payload).subscribe({
|
||||
next: (updatedCourse) => {
|
||||
if (updatedCourse) {
|
||||
toast.success('Non-partants mis à jour avec succès');
|
||||
} else {
|
||||
toast.error('Erreur lors de la mise à jour des non-partants');
|
||||
}
|
||||
this.closeNonPartantModal();
|
||||
this.fetch({
|
||||
page: this.page(),
|
||||
perPage: this.perPage(),
|
||||
search: this.search(),
|
||||
sortKey: this.sort().key,
|
||||
sortDir: this.sort().dir,
|
||||
});
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Error saving non-partants:', err);
|
||||
toast.error('Erreur lors de la mise à jour des non-partants');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
resultatModalOpen = signal(false);
|
||||
selectedCourseForResultat = signal<CourseType | null>(null);
|
||||
|
||||
closeResultatModal() {
|
||||
this.resultatModalOpen.set(false);
|
||||
this.selectedCourseForResultat.set(null);
|
||||
}
|
||||
|
||||
onResultatSave(places: number[][]) {
|
||||
const c = this.selectedCourseForResultat();
|
||||
if (!c) return;
|
||||
|
||||
// Determine required number of horses based on course type
|
||||
const getRequiredHorses = (type: string): number => {
|
||||
const typeStr = String(type).toUpperCase();
|
||||
if (typeStr.includes('TIERCE') || typeStr === 'PLAT') return 3;
|
||||
if (typeStr.includes('QUARTE')) return 4;
|
||||
if (typeStr.includes('QUINTE')) return 5;
|
||||
return 3; // Default
|
||||
};
|
||||
|
||||
const requiredHorses = getRequiredHorses(c.type);
|
||||
|
||||
// Collect all selected horses (flatten the places array)
|
||||
const allHorses: number[] = places
|
||||
.flatMap((placeGroup) => placeGroup.filter((n) => typeof n === 'number' && n > 0))
|
||||
.slice(0, requiredHorses); // Only take the first N horses
|
||||
|
||||
// Check if all horses are in first place (ex-aequo)
|
||||
const firstPlaceHorses = places[0]?.filter((n) => typeof n === 'number' && n > 0) || [];
|
||||
const isAllExAequo =
|
||||
firstPlaceHorses.length === requiredHorses && allHorses.length === requiredHorses;
|
||||
|
||||
// Convert to ordreArrivee format
|
||||
// If all are ex-aequo, they all go in ordreArrivee as they are (first place)
|
||||
// Otherwise, distribute them across places
|
||||
const ordreArrivee: Array<string> = [];
|
||||
const chevauxDeadHeat: number[] = [];
|
||||
|
||||
if (isAllExAequo) {
|
||||
// All horses are in first place (ex-aequo)
|
||||
allHorses.forEach((numero) => {
|
||||
ordreArrivee.push(numero.toString());
|
||||
chevauxDeadHeat.push(numero);
|
||||
});
|
||||
} else {
|
||||
// Horses are distributed across places
|
||||
places.forEach((placeGroup, placeIndex) => {
|
||||
const validHorses = placeGroup.filter((n) => typeof n === 'number' && n > 0);
|
||||
if (validHorses.length === 0) return;
|
||||
|
||||
const isDeadHeat = validHorses.length > 1;
|
||||
|
||||
validHorses.forEach((numero) => {
|
||||
ordreArrivee.push(numero.toString());
|
||||
|
||||
if (isDeadHeat) {
|
||||
chevauxDeadHeat.push(numero);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Check if resultat already exists
|
||||
const existingResultat = this.resultatsMap().get(c.id);
|
||||
|
||||
const payload = {
|
||||
course: { id: c.id },
|
||||
ordreArrivee,
|
||||
chevauxDeadHeat: chevauxDeadHeat.map((n) => String(n)),
|
||||
totalMises: 0,
|
||||
masseAPartager: 0,
|
||||
prelevementsLegaux: 0,
|
||||
montantRembourse: 0,
|
||||
montantCagnotte: 0,
|
||||
adeadHeat: chevauxDeadHeat.length > 0,
|
||||
};
|
||||
|
||||
const request$ = existingResultat
|
||||
? this.resultatService.update(existingResultat.id, payload)
|
||||
: this.resultatService.create(payload);
|
||||
|
||||
request$.subscribe({
|
||||
next: () => {
|
||||
this.closeResultatModal();
|
||||
this.fetch({
|
||||
page: this.page(),
|
||||
perPage: this.perPage(),
|
||||
search: this.search(),
|
||||
sortKey: this.sort().key,
|
||||
sortDir: this.sort().dir,
|
||||
});
|
||||
toast.success('Résultat enregistré avec succès');
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Error saving resultat:', err);
|
||||
toast.error("Erreur lors de l'enregistrement du résultat");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onResultatValidate() {
|
||||
const c = this.selectedCourseForResultat();
|
||||
if (!c) return;
|
||||
|
||||
const resultat = this.resultatsMap().get(c.id);
|
||||
if (!resultat) {
|
||||
toast.error('Aucun résultat à valider');
|
||||
return;
|
||||
}
|
||||
|
||||
// For now, validation is just an update. In the future, you might add a statut field
|
||||
this.resultatService.update(resultat.id, {}).subscribe({
|
||||
next: () => {
|
||||
this.closeResultatModal();
|
||||
this.fetch({
|
||||
page: this.page(),
|
||||
perPage: this.perPage(),
|
||||
search: this.search(),
|
||||
sortKey: this.sort().key,
|
||||
sortDir: this.sort().dir,
|
||||
});
|
||||
toast.success('Résultat validé avec succès');
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Error validating resultat:', err);
|
||||
toast.error('Erreur lors de la validation du résultat');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onResultatConfirm() {
|
||||
const c = this.selectedCourseForResultat();
|
||||
if (!c) return;
|
||||
|
||||
const resultat = this.resultatsMap().get(c.id);
|
||||
if (!resultat) {
|
||||
toast.error('Aucun résultat à confirmer');
|
||||
return;
|
||||
}
|
||||
|
||||
// For now, confirmation is just an update. In the future, you might add a statut field
|
||||
this.resultatService.update(resultat.id, {}).subscribe({
|
||||
next: () => {
|
||||
this.closeResultatModal();
|
||||
this.fetch({
|
||||
page: this.page(),
|
||||
perPage: this.perPage(),
|
||||
search: this.search(),
|
||||
sortKey: this.sort().key,
|
||||
sortDir: this.sort().dir,
|
||||
});
|
||||
toast.success('Résultat confirmé avec succès');
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Error confirming resultat:', err);
|
||||
toast.error('Erreur lors de la confirmation du résultat');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
0
src/app/dashboard/pages/hippodrome/hippodrome.css
Normal file
0
src/app/dashboard/pages/hippodrome/hippodrome.css
Normal file
132
src/app/dashboard/pages/hippodrome/hippodrome.html
Normal file
132
src/app/dashboard/pages/hippodrome/hippodrome.html
Normal 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>
|
||||
23
src/app/dashboard/pages/hippodrome/hippodrome.spec.ts
Normal file
23
src/app/dashboard/pages/hippodrome/hippodrome.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
204
src/app/dashboard/pages/hippodrome/hippodrome.ts
Normal file
204
src/app/dashboard/pages/hippodrome/hippodrome.ts
Normal 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 l’hippodrome');
|
||||
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 l’hippodrome « ${ev.nom} » ?`)) return;
|
||||
this.api.delete(ev.id).subscribe(() => this.fetch());
|
||||
}
|
||||
}
|
||||
63
src/app/dashboard/pages/limits/limits.html
Normal file
63
src/app/dashboard/pages/limits/limits.html
Normal 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>
|
||||
|
||||
|
||||
294
src/app/dashboard/pages/limits/limits.ts
Normal file
294
src/app/dashboard/pages/limits/limits.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
0
src/app/dashboard/pages/main/main.css
Normal file
0
src/app/dashboard/pages/main/main.css
Normal file
333
src/app/dashboard/pages/main/main.html
Normal file
333
src/app/dashboard/pages/main/main.html
Normal 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 : {{ 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>
|
||||
23
src/app/dashboard/pages/main/main.spec.ts
Normal file
23
src/app/dashboard/pages/main/main.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
257
src/app/dashboard/pages/main/main.ts
Normal file
257
src/app/dashboard/pages/main/main.ts
Normal 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 0–40 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; // 0–1
|
||||
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);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
121
src/app/dashboard/pages/profile/profile.html
Normal file
121
src/app/dashboard/pages/profile/profile.html
Normal 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>
|
||||
75
src/app/dashboard/pages/profile/profile.ts
Normal file
75
src/app/dashboard/pages/profile/profile.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
114
src/app/dashboard/pages/report-courses/report-detail.html
Normal file
114
src/app/dashboard/pages/report-courses/report-detail.html
Normal 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>
|
||||
}
|
||||
132
src/app/dashboard/pages/report-courses/report-detail.ts
Normal file
132
src/app/dashboard/pages/report-courses/report-detail.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
19
src/app/dashboard/pages/report-courses/report-list.html
Normal file
19
src/app/dashboard/pages/report-courses/report-list.html
Normal 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>
|
||||
|
||||
|
||||
61
src/app/dashboard/pages/report-courses/report-list.ts
Normal file
61
src/app/dashboard/pages/report-courses/report-list.ts
Normal 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]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
0
src/app/dashboard/pages/reunion/reunion.css
Normal file
0
src/app/dashboard/pages/reunion/reunion.css
Normal file
87
src/app/dashboard/pages/reunion/reunion.html
Normal file
87
src/app/dashboard/pages/reunion/reunion.html
Normal 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>
|
||||
23
src/app/dashboard/pages/reunion/reunion.spec.ts
Normal file
23
src/app/dashboard/pages/reunion/reunion.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
234
src/app/dashboard/pages/reunion/reunion.ts
Normal file
234
src/app/dashboard/pages/reunion/reunion.ts
Normal 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,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
111
src/app/dashboard/pages/roles/roles.html
Normal file
111
src/app/dashboard/pages/roles/roles.html
Normal 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>
|
||||
241
src/app/dashboard/pages/roles/roles.ts
Normal file
241
src/app/dashboard/pages/roles/roles.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
194
src/app/dashboard/pages/tpe/tpe.html
Normal file
194
src/app/dashboard/pages/tpe/tpe.html
Normal 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>
|
||||
487
src/app/dashboard/pages/tpe/tpe.ts
Normal file
487
src/app/dashboard/pages/tpe/tpe.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
41
src/app/dashboard/pages/users/users.html
Normal file
41
src/app/dashboard/pages/users/users.html
Normal 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>
|
||||
198
src/app/dashboard/pages/users/users.ts
Normal file
198
src/app/dashboard/pages/users/users.ts
Normal 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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user