first commit
This commit is contained in:
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user