first commit

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

View File

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

View File

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