4 Commits

Author SHA1 Message Date
OnlyPapy98
6fbda8dc5e working in progress 2026-01-09 10:40:55 +01:00
OnlyPapy98
665c15b3d7 tasks done 2026-01-08 17:13:46 +01:00
OnlyPapy98
d7bcbce50d agent done 2026-01-07 16:06:54 +01:00
OnlyPapy98
0ae7fa316e agent and tpe save done 2026-01-06 14:07:09 +01:00
39 changed files with 2362 additions and 675 deletions

View File

@@ -48,7 +48,7 @@ export interface Agent {
autreTelephone?: string; autreTelephone?: string;
// TPE assignés (actifs seulement) // TPE assignés (actifs seulement)
tpes?: TpeDevice[]; terminauxIds?: number[] | number;
createdAt?: string; createdAt?: string;
updatedAt?: string; updatedAt?: string;

View File

@@ -0,0 +1,12 @@
export type PointVenteStatut = 'ACTIF' | 'INACTIF';
export interface PointVente {
id: string;
code: string;
nom: string;
adresse: string;
ville: string;
latitude: number;
longitude: number;
statut: PointVenteStatut;
}

View File

@@ -1,27 +1,24 @@
import { Agent } from './agent';
export type TpeStatus = export type TpeStatus =
| 'VALIDE' | 'ACTIF'
| 'INVALIDE' | 'HORS_SERVICE';
| 'EN_PANNE'
| 'BLOQUE'
| 'DISPONIBLE'
| 'AFFECTE'
| 'EN_MAINTENANCE'
| 'HORS_SERVICE'
| 'VOLE';
export type TpeType = 'POS' | 'OTHER'; export type TpeType = 'POS' | 'OTHER';
export interface TpeDevice { export interface TpeDevice {
id: string; id: string;
imei: string; numeroSerie: string;
serial: string; pointDeVenteId: string;
type: TpeType;
marque: string;
modele: string;
statut: TpeStatus; statut: TpeStatus;
agent?: Agent; versionLogicielle: string;
assigne: boolean; typeTerminal: string;
createdAt?: string; plateforme: string;
updatedAt?: string; modeleAppareil: string;
systemeExploitation: string;
versionOs: string;
adresseIp: string;
adresseMac: string;
assigned: boolean;
agentConnecteId: string;
derniereConnexionAgent: string;
derniereDeconnexionAgent: string;
journalSession: string;
} }

View File

@@ -66,7 +66,7 @@ interface AgentApiResponse {
statutMarital?: string; statutMarital?: string;
epoux?: string; epoux?: string;
autreTelephone?: string; autreTelephone?: string;
tpes?: TpeApiResponse[]; terminauxIds?: number[] | number;
createdAt?: string; createdAt?: string;
updatedAt?: string; updatedAt?: string;
createdBy?: string; createdBy?: string;
@@ -88,44 +88,37 @@ export class AgentService {
} }
// Transform API TPE response to TpeDevice // Transform API TPE response to TpeDevice
private transformTpe(apiTpe: TpeApiResponse): TpeDevice { // private transformTpe(apiTpe: TpeApiResponse): TpeDevice {
const transformStatut = (apiStatut: string): TpeStatus => { // const transformStatut = (apiStatut: string): TpeStatus => {
const upperStatut = apiStatut.toUpperCase() as TpeStatus; // const upperStatut = apiStatut.toUpperCase() as TpeStatus;
const validStatuses: TpeStatus[] = [ // const validStatuses: TpeStatus[] = [
'VALIDE', // 'ACTIF',
'INVALIDE', // 'HORS_SERVICE'
'EN_PANNE', // ];
'BLOQUE', // return validStatuses.includes(upperStatut) ? upperStatut : 'INVALIDE';
'DISPONIBLE', // };
'AFFECTE',
'EN_MAINTENANCE',
'HORS_SERVICE',
'VOLE',
];
return validStatuses.includes(upperStatut) ? upperStatut : 'INVALIDE';
};
// Transform agent if it's an object (not just a string reference) // // Transform agent if it's an object (not just a string reference)
let transformedAgent: Agent | undefined = undefined; // let transformedAgent: Agent | undefined = undefined;
if (apiTpe.agent && typeof apiTpe.agent === 'object' && apiTpe.agent.id) { // if (apiTpe.agent && typeof apiTpe.agent === 'object' && apiTpe.agent.id) {
// If agent is a full object, transform it // // If agent is a full object, transform it
transformedAgent = this.transformAgent(apiTpe.agent as any); // transformedAgent = this.transformAgent(apiTpe.agent as any);
} // }
return { // return {
id: String(apiTpe.id), // id: String(apiTpe.id),
imei: apiTpe.imei, // imei: apiTpe.imei,
serial: apiTpe.serial, // serial: apiTpe.serial,
type: apiTpe.type as TpeType, // type: apiTpe.type as TpeType,
marque: apiTpe.marque, // marque: apiTpe.marque,
modele: apiTpe.modele, // modele: apiTpe.modele,
statut: transformStatut(apiTpe.statut), // statut: transformStatut(apiTpe.statut),
agent: transformedAgent, // agent: transformedAgent,
assigne: apiTpe.assigne, // assigne: apiTpe.assigne,
createdAt: apiTpe.createdAt, // createdAt: apiTpe.createdAt,
updatedAt: apiTpe.updatedAt, // updatedAt: apiTpe.updatedAt,
}; // };
} // }
// Transform API response to Agent // Transform API response to Agent
private transformAgent(apiAgent: AgentApiResponse): Agent { private transformAgent(apiAgent: AgentApiResponse): Agent {
@@ -166,10 +159,7 @@ export class AgentService {
statutMarital: apiAgent.statutMarital, statutMarital: apiAgent.statutMarital,
epoux: apiAgent.epoux, epoux: apiAgent.epoux,
autreTelephone: apiAgent.autreTelephone, autreTelephone: apiAgent.autreTelephone,
tpes: apiAgent.tpes?.map((tpe) => { terminauxIds: apiAgent.terminauxIds ,
const transformed = this.transformTpe(tpe);
return transformed;
}),
createdAt: apiAgent.createdAt, createdAt: apiAgent.createdAt,
updatedAt: apiAgent.updatedAt, updatedAt: apiAgent.updatedAt,
createdBy: apiAgent.createdBy, createdBy: apiAgent.createdBy,
@@ -237,6 +227,23 @@ export class AgentService {
return payload; return payload;
} }
assigner(tpeIds: string[], agentId:string):Observable<Agent | undefined>{
const payload = {
terminalIds: [
...tpeIds
]
}
return this.http.put<Agent>(`${this.apiUrl}/${agentId}/terminaux`,
payload, {headers: this.getNgrokHeaders()}).pipe(map(res=> res),
catchError((err)=>{
console.error(err);
return of(undefined);
})
)
}
// GET /api/v1/agents/{id} - Get by ID // GET /api/v1/agents/{id} - Get by ID
getById(id: string): Observable<Agent | undefined> { getById(id: string): Observable<Agent | undefined> {
if (USE_SERVER) { if (USE_SERVER) {
@@ -270,7 +277,6 @@ export class AgentService {
}) })
.pipe( .pipe(
map((res) => { map((res) => {
console.log(res);
const agents = res.content.map((apiAgent) => { const agents = res.content.map((apiAgent) => {
const transformed = this.transformAgent(apiAgent); const transformed = this.transformAgent(apiAgent);
return transformed; return transformed;

View File

@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { PointsVente } from './points-vente';
describe('PointsVente', () => {
let service: PointsVente;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(PointsVente);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@@ -0,0 +1,92 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { ListParams, PagedResult } from '@shared/paging/paging';
import { Observable, of } from 'rxjs';
import { map, catchError } from 'rxjs/operators';
import { PointVente, PointVenteStatut } from 'src/app/core/interfaces/points-ventes';
import { environment } from 'src/environments/environment.development';
import { ServicesUtils } from './services-utils';
const API_BASE = '/api/points-de-vente';
@Injectable({
providedIn: 'root',
})
export class PointsVenteService {
private apiUrl = environment.apiBaseUrl + API_BASE;
constructor(private http: HttpClient, private servicesUtils: ServicesUtils) {}
private getNgrokHeaders(): Record<string, string> {
const isNgrok =
environment.apiBaseUrl.includes('ngrok-free.app') ||
environment.apiBaseUrl.includes('ngrok.io') ||
environment.apiBaseUrl.includes('ngrok');
return isNgrok ? { 'ngrok-skip-browser-warning': 'true' } : {};
}
list(params: ListParams): Observable<PagedResult<PointVente>> {
return this.http
.get<PagedResult<PointVente>>(this.apiUrl, {
params: this.servicesUtils.getParamsFromModel(params),
headers: this.getNgrokHeaders(),
})
.pipe(
catchError((err) => {
console.error('Error fetching points de vente:', err);
return of({
content: [],
pageable: { pageNumber: 0, pageSize: params.size || 10, total: 0 },
totalPages: 0,
totalElements: 0,
});
})
);
}
getById(id: string): Observable<PointVente | undefined> {
return this.http
.get<PointVente>(`${this.apiUrl}/${id}`, { headers: this.getNgrokHeaders() })
.pipe(
catchError((err) => {
console.error(`Error fetching point de vente ${id}:`, err);
return of(undefined);
})
);
}
create(pointVente: Omit<PointVente, 'id'>): Observable<PointVente> {
return this.http
.post<PointVente>(this.apiUrl, pointVente, { headers: this.getNgrokHeaders() })
.pipe(
catchError((err) => {
console.log(err)
console.error('Error creating point de vente:', err);
throw err;
})
);
}
update(id: string, payload: Partial<PointVente>): Observable<PointVente | undefined> {
return this.http
.put<PointVente>(`${this.apiUrl}/${id}`, payload, { headers: this.getNgrokHeaders() })
.pipe(
catchError((err) => {
console.error(`Error updating point de vente ${id}:`, err);
return of(undefined);
})
);
}
delete(id: string): Observable<boolean> {
return this.http
.delete<void>(`${this.apiUrl}/${id}`, { headers: this.getNgrokHeaders() })
.pipe(
map(() => true),
catchError((err) => {
console.error(`Error deleting point de vente ${id}:`, err);
return of(false);
})
);
}
}

View File

@@ -104,17 +104,10 @@ export class TpeService {
private transformStatut(apiStatut: string): TpeStatus { private transformStatut(apiStatut: string): TpeStatus {
const upperStatut = apiStatut.toUpperCase() as TpeStatus; const upperStatut = apiStatut.toUpperCase() as TpeStatus;
const validStatuses: TpeStatus[] = [ const validStatuses: TpeStatus[] = [
'VALIDE', 'ACTIF',
'INVALIDE', 'HORS_SERVICE'
'EN_PANNE',
'BLOQUE',
'DISPONIBLE',
'AFFECTE',
'EN_MAINTENANCE',
'HORS_SERVICE',
'VOLE',
]; ];
return validStatuses.includes(upperStatut) ? upperStatut : 'INVALIDE'; return validStatuses.includes(upperStatut) ? upperStatut : 'HORS_SERVICE';
} }
// Transform interface statut to API statut (both use uppercase now, so direct return) // Transform interface statut to API statut (both use uppercase now, so direct return)
@@ -168,55 +161,55 @@ export class TpeService {
} }
// Transform API response to TpeDevice // Transform API response to TpeDevice
private transformTpe(apiTpe: TpeApiResponse): TpeDevice { // private transformTpe(apiTpe: TpeApiResponse): TpeDevice {
// Map API-specific names to our generic interface where possible // // Map API-specific names to our generic interface where possible
const serial = (apiTpe as any).numeroSerie || (apiTpe as any).serial || ''; // const serial = (apiTpe as any).numeroSerie || (apiTpe as any).serial || '';
const imei = (apiTpe as any).imei || serial || ''; // const imei = (apiTpe as any).imei || serial || '';
const typeRaw = String((apiTpe as any).typeTerminal || '').toUpperCase(); // const typeRaw = String((apiTpe as any).typeTerminal || '').toUpperCase();
const type = typeRaw.includes('POS') ? ('POS' as TpeType) : ('OTHER' as TpeType); // const type = typeRaw.includes('POS') ? ('POS' as TpeType) : ('OTHER' as TpeType);
const marque = (apiTpe as any).plateforme || (apiTpe as any).marque || ''; // const marque = (apiTpe as any).plateforme || (apiTpe as any).marque || '';
const modele = (apiTpe as any).modeleAppareil || (apiTpe as any).modele || ''; // const modele = (apiTpe as any).modeleAppareil || (apiTpe as any).modele || '';
const statut = this.transformStatut(String(apiTpe.statut || 'INVALIDE')); // const statut = this.transformStatut(String(apiTpe.statut || 'INVALIDE'));
// Agent mapping: sometimes API returns an agent object or only an id // // Agent mapping: sometimes API returns an agent object or only an id
let agent: Agent | undefined = undefined; // let agent: Agent | undefined = undefined;
if ((apiTpe as any).agent && typeof (apiTpe as any).agent === 'object' && (apiTpe as any).agent.id) { // if ((apiTpe as any).agent && typeof (apiTpe as any).agent === 'object' && (apiTpe as any).agent.id) {
agent = this.transformAgent((apiTpe as any).agent as AgentApiResponse); // agent = this.transformAgent((apiTpe as any).agent as AgentApiResponse);
} // }
const assigne = Boolean((apiTpe as any).agentConnecteId || (apiTpe as any).assigne); // const assigne = Boolean((apiTpe as any).agentConnecteId || (apiTpe as any).assigne);
return { // return {
id: String(apiTpe.id), // id: String(apiTpe.id),
imei: String(imei), // imei: String(imei),
serial: String(serial), // serial: String(serial),
type, // type,
marque: String(marque), // marque: String(marque),
modele: String(modele), // modele: String(modele),
statut, // statut,
agent, // agent,
assigne, // assigne,
createdAt: (apiTpe as any).createdAt, // createdAt: (apiTpe as any).createdAt,
updatedAt: (apiTpe as any).updatedAt, // updatedAt: (apiTpe as any).updatedAt,
}; // };
} // }
// Transform TpeDevice to API payload (best-effort) // Transform TpeDevice to API payload (best-effort)
private transformToApiPayload(tpe: Partial<TpeDevice>): any { // private transformToApiPayload(tpe: Partial<TpeDevice>): any {
const payload: any = {}; // const payload: any = {};
if (tpe.imei !== undefined) payload.numeroSerie = tpe.imei; // if (tpe.imei !== undefined) payload.numeroSerie = tpe.imei;
if (tpe.serial !== undefined) payload.numeroSerie = tpe.serial; // if (tpe.serial !== undefined) payload.numeroSerie = tpe.serial;
if (tpe.type !== undefined) payload.typeTerminal = tpe.type; // if (tpe.type !== undefined) payload.typeTerminal = tpe.type;
if (tpe.marque !== undefined) payload.plateforme = tpe.marque; // if (tpe.marque !== undefined) payload.plateforme = tpe.marque;
if (tpe.modele !== undefined) payload.modeleAppareil = tpe.modele; // if (tpe.modele !== undefined) payload.modeleAppareil = tpe.modele;
if (tpe.statut !== undefined) payload.statut = this.transformStatutToApi(tpe.statut); // if (tpe.statut !== undefined) payload.statut = this.transformStatutToApi(tpe.statut);
if (tpe.assigne !== undefined) payload.assigne = tpe.assigne; // if (tpe.assigne !== undefined) payload.assigne = tpe.assigne;
return payload; // return payload;
} // }
// GET /api/v1/tpes/{id} - Get by ID // GET /api/v1/tpes/{id} - Get by ID
getById(id: string): Observable<TpeDevice | undefined> { getById(id: string): Observable<TpeDevice | undefined> {
if (USE_SERVER) { if (USE_SERVER) {
return this.http.get<TpeApiResponse>(`${this.apiUrl}/${id}`, { headers: this.getNgrokHeaders() }).pipe( return this.http.get<TpeDevice>(`${this.apiUrl}/${id}`, { headers: this.getNgrokHeaders() }).pipe(
map((api) => this.transformTpe(api)), map((api) => api),
catchError((err) => { catchError((err) => {
console.error(`Error fetching TPE ${id}:`, err); console.error(`Error fetching TPE ${id}:`, err);
return of(undefined); return of(undefined);
@@ -239,13 +232,13 @@ getById(id: string): Observable<TpeDevice | undefined> {
} }
return this.http return this.http
.get<PagedResult<TpeApiResponse>>(this.apiUrl, { .get<PagedResult<TpeDevice>>(this.apiUrl, {
params: httpParams, params: httpParams,
headers: this.getNgrokHeaders(), headers: this.getNgrokHeaders(),
}) })
.pipe( .pipe(
map((list) => { map((list) => {
const content = (list.content || []).map((api) => this.transformTpe(api)); const content = (list.content || []).map((api) => api);
return { ...list, content } as PagedResult<TpeDevice>; return { ...list, content } as PagedResult<TpeDevice>;
}), }),
catchError((err) => { catchError((err) => {
@@ -257,11 +250,10 @@ getById(id: string): Observable<TpeDevice | undefined> {
// POST /api/v1/tpes - Create // POST /api/v1/tpes - Create
create(payload: Partial<TpeDevice>): Observable<TpeDevice> { create(payload: Partial<TpeDevice>): Observable<TpeDevice> {
const apiPayload = this.transformToApiPayload(payload);
return this.http return this.http
.post<TpeApiResponse>(this.apiUrl, apiPayload, { headers: this.getNgrokHeaders() }) .post<TpeDevice>(this.apiUrl, payload, { headers: this.getNgrokHeaders() })
.pipe( .pipe(
map((apiTpe) => this.transformTpe(apiTpe)), map((apiTpe) => apiTpe),
catchError((err) => { catchError((err) => {
console.error('Error creating TPE:', err); console.error('Error creating TPE:', err);
throw err; throw err;
@@ -271,13 +263,12 @@ getById(id: string): Observable<TpeDevice | undefined> {
// PUT /api/v1/tpes/{id} - Update // PUT /api/v1/tpes/{id} - Update
update(id: string, payload: Partial<TpeDevice>): Observable<TpeDevice | undefined> { update(id: string, payload: Partial<TpeDevice>): Observable<TpeDevice | undefined> {
const apiPayload = this.transformToApiPayload(payload);
return this.http return this.http
.put<TpeApiResponse>(`${this.apiUrl}/${id}`, apiPayload, { .put<TpeDevice>(`${this.apiUrl}/${id}`, payload, {
headers: this.getNgrokHeaders(), headers: this.getNgrokHeaders(),
}) })
.pipe( .pipe(
map((apiTpe) => this.transformTpe(apiTpe)), map((apiTpe) => apiTpe),
catchError((err) => { catchError((err) => {
console.error(`Error updating TPE ${id}:`, err); console.error(`Error updating TPE ${id}:`, err);
return of(undefined); return of(undefined);
@@ -305,13 +296,13 @@ update(id: string, payload: Partial<TpeDevice>): Observable<TpeDevice | undefine
updateStatut(id: string, statut: TpeStatus): Observable<TpeDevice | undefined> { updateStatut(id: string, statut: TpeStatus): Observable<TpeDevice | undefined> {
if (USE_SERVER) { if (USE_SERVER) {
return this.http return this.http
.patch<TpeApiResponse>( .patch<TpeDevice>(
`${this.apiUrl}/${id}/statut`, `${this.apiUrl}/${id}/statut`,
{ statut: this.transformStatutToApi(statut) }, { statut: this.transformStatutToApi(statut) },
{ headers: this.getNgrokHeaders() } { headers: this.getNgrokHeaders() }
) )
.pipe( .pipe(
map((apiTpe) => this.transformTpe(apiTpe)), map((apiTpe) => apiTpe),
catchError((err) => { catchError((err) => {
console.error(`Error updating TPE statut ${id}:`, err); console.error(`Error updating TPE statut ${id}:`, err);
return of(undefined); return of(undefined);
@@ -323,7 +314,6 @@ update(id: string, payload: Partial<TpeDevice>): Observable<TpeDevice | undefine
// PATCH /api/v1/tpes/{id}/liberer - Liberate TPE (updates whole TPE, sets assigne to false and statut to DISPONIBLE) // PATCH /api/v1/tpes/{id}/liberer - Liberate TPE (updates whole TPE, sets assigne to false and statut to DISPONIBLE)
liberer(id: string): Observable<TpeDevice | undefined> { liberer(id: string): Observable<TpeDevice | undefined> {
if (USE_SERVER) {
// First get the current TPE data // First get the current TPE data
return this.getById(id).pipe( return this.getById(id).pipe(
switchMap((tpe) => { switchMap((tpe) => {
@@ -331,14 +321,16 @@ update(id: string, payload: Partial<TpeDevice>): Observable<TpeDevice | undefine
return of(undefined); return of(undefined);
} }
// Update the whole TPE with assigne set to false and statut to DISPONIBLE // Update the whole TPE with assigne set to false and statut to DISPONIBLE
const updatedTpe = { ...tpe, assigne: false, statut: 'DISPONIBLE' as TpeStatus }; const updatedTpe = {
const apiPayload = this.transformToApiPayload(updatedTpe); ...tpe,
statut: 'ACTIF'
};
return this.http return this.http
.patch<TpeApiResponse>(`${this.apiUrl}/liberer/${id}`, apiPayload, { .patch<TpeDevice>(`${this.apiUrl}/liberer/${id}`, updatedTpe, {
headers: this.getNgrokHeaders(), headers: this.getNgrokHeaders(),
}) })
.pipe( .pipe(
map((apiTpe) => this.transformTpe(apiTpe)), map((apiTpe) => apiTpe),
catchError((err) => { catchError((err) => {
console.error(`Error liberating TPE ${id}:`, err); console.error(`Error liberating TPE ${id}:`, err);
return of(undefined); return of(undefined);
@@ -350,51 +342,44 @@ update(id: string, payload: Partial<TpeDevice>): Observable<TpeDevice | undefine
return of(undefined); return of(undefined);
}) })
); );
}
return of(undefined);
} }
// PATCH /api/v1/tpes/assigner - Assign TPE // PATCH /api/v1/tpes/assigner - Assign TPE
// Payload: { tpeId: number, agentId: number } // Payload: { tpeId: number, agentId: number }
assigner(id: string, agentId: string): Observable<TpeDevice | undefined> { assigner(id: string, agentId: string): Observable<TpeDevice | undefined> {
if (USE_SERVER) {
const payload = { const payload = {
tpeId: Number(id), tpeId: Number(id),
agentId: Number(agentId), agentId: Number(agentId),
}; };
return this.http return this.http
.patch<TpeApiResponse>(`${this.apiUrl}/assigner`, payload, { .patch<TpeDevice>(`${this.apiUrl}/assigner`, payload, {
headers: this.getNgrokHeaders(), headers: this.getNgrokHeaders(),
}) })
.pipe( .pipe(
map((apiTpe) => this.transformTpe(apiTpe)), map((apiTpe) => apiTpe),
catchError((err) => { catchError((err) => {
console.error(`Error assigning TPE ${id}:`, err); console.error(`Error assigning TPE ${id}:`, err);
return of(undefined); return of(undefined);
}) })
); );
} }
return of(undefined);
}
// GET /api/v1/tpes/statut/{statut} - List by statut // GET /api/v1/tpes/statut/{statut} - List by statut
getByStatut(statut: TpeStatus): Observable<TpeDevice[]> { getByStatut(statut: TpeStatus): Observable<TpeDevice[]> {
if (USE_SERVER) {
const apiStatut = this.transformStatutToApi(statut); const apiStatut = this.transformStatutToApi(statut);
return this.http return this.http
.get<TpeApiResponse[]>(`${this.apiUrl}/statut/${apiStatut}`, { .get<TpeDevice[]>(`${this.apiUrl}/statut/${apiStatut}`, {
headers: this.getNgrokHeaders(), headers: this.getNgrokHeaders(),
}) })
.pipe( .pipe(
map((list) => list.map((apiTpe) => this.transformTpe(apiTpe))), map((list) => list.map((apiTpe) => apiTpe)),
catchError((err) => { catchError((err) => {
console.error(`Error fetching TPEs by statut ${statut}:`, err); console.error(`Error fetching TPEs by statut ${statut}:`, err);
return of([]); return of([]);
}) })
); );
} }
return of([]);
}
// GET /api/v1/tpes/stats/count-by-statut - Get count by statut // GET /api/v1/tpes/stats/count-by-statut - Get count by statut
getCountByStatut(): Observable<CountByStatutResponse> { getCountByStatut(): Observable<CountByStatutResponse> {
@@ -432,36 +417,30 @@ update(id: string, payload: Partial<TpeDevice>): Observable<TpeDevice | undefine
// GET /api/v1/tpes/search - Search // GET /api/v1/tpes/search - Search
search(query: string): Observable<TpeDevice[]> { search(query: string): Observable<TpeDevice[]> {
if (USE_SERVER) {
return this.http return this.http
.get<TpeApiResponse[]>(`${this.apiUrl}/search`, { .get<TpeDevice[]>(`${this.apiUrl}/search`, {
params: { q: query.trim() }, params: { q: query.trim() },
headers: this.getNgrokHeaders(), headers: this.getNgrokHeaders(),
}) })
.pipe( .pipe(
map((list) => list.map((apiTpe) => this.transformTpe(apiTpe))), map((list) => list.map((apiTpe) => apiTpe)),
catchError((err) => { catchError((err) => {
console.error(`Error searching TPEs with query ${query}:`, err); console.error(`Error searching TPEs with query ${query}:`, err);
return of([]); return of([]);
}) })
); );
} }
return of([]);
}
// GET /api/v1/tpes/disponibles - List available TPEs // GET /api/v1/tpes/disponibles - List available TPEs
getDisponibles(): Observable<TpeDevice[]> { getDisponibles(): Observable<TpeDevice[]> {
if (USE_SERVER) {
return this.http return this.http
.get<TpeApiResponse[]>(`${this.apiUrl}/disponibles`, { headers: this.getNgrokHeaders() }) .get<TpeDevice[]>(`${this.apiUrl}/disponibles`, { headers: this.getNgrokHeaders() })
.pipe( .pipe(
map((list) => list.map((apiTpe) => this.transformTpe(apiTpe))), map((list) => list.map((apiTpe) => apiTpe)),
catchError((err) => { catchError((err) => {
console.error('Error fetching available TPEs:', err); console.error('Error fetching available TPEs:', err);
return of([]); return of([]);
}) })
); );
} }
return of([]);
}
} }

View File

@@ -57,6 +57,10 @@ const routes: Routes = [
path: 'limits', path: 'limits',
loadComponent: () => import('./pages/limits/limits').then((m) => m.LimitsPage), loadComponent: () => import('./pages/limits/limits').then((m) => m.LimitsPage),
}, },
{
path: 'points-vente',
loadComponent: () => import('./pages/point-vente/point-vente').then((m) => m.PointVentePage),
},
], ],
}, },
]; ];

View File

@@ -44,7 +44,6 @@ export class Layout {
mainMenuItems: MenuItem[] = [ mainMenuItems: MenuItem[] = [
{ icon: '🏠', label: 'Tableau de bord', link: '/', exact: true }, { icon: '🏠', label: 'Tableau de bord', link: '/', exact: true },
{ icon: '🏟️', label: 'Hippodromes', link: '/hippodromes' }, { icon: '🏟️', label: 'Hippodromes', link: '/hippodromes' },
{ icon: '📅', label: 'Reunions', link: '/reunions' },
{ icon: '🏇', label: 'Courses', link: '/courses' }, { icon: '🏇', label: 'Courses', link: '/courses' },
{ icon: 'icon-chart-bar', label: 'Résultats des courses', link: '/resultat' }, { icon: 'icon-chart-bar', label: 'Résultats des courses', link: '/resultat' },
{ icon: '💰', label: 'Gains (cagnotte)', link: '/gains' }, { icon: '💰', label: 'Gains (cagnotte)', link: '/gains' },
@@ -60,6 +59,7 @@ export class Layout {
], ],
}, },
{ icon: 'icon-monitor', label: 'Gestion des TPE', link: '/tpes' }, { icon: 'icon-monitor', label: 'Gestion des TPE', link: '/tpes' },
{ icon: 'icon-map-pin', label: 'Points de vente', link: '/points-vente' },
{ {
icon: 'icon-users', icon: 'icon-users',
label: 'Utilisateurs', label: 'Utilisateurs',

View File

@@ -35,16 +35,11 @@
</div> </div>
<app-modal [open]="modalOpen()" [title]="modalTitle()" (close)="closeModal()" size="xxl"> <app-modal [open]="modalOpen()" [title]="modalTitle()" (close)="closeModal()" size="xxl">
<app-agent-full-form <app-agent-form
[value]="editingItem() ?? undefined" [value]="editingItem() ?? undefined"
[compact]="!editingItem()"
(save)="onFormSave($event)" (save)="onFormSave($event)"
(cancel)="closeModal()" (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> </app-modal>
<!-- Detail Modal --> <!-- Detail Modal -->
@@ -312,42 +307,19 @@
} }
<!-- TPE Assignés --> <!-- TPE Assignés -->
@if (getAgentTpes(agent.id).length > 0) { @if (tpeArray()) {
<z-card class="p-4"> @for (tpe of tpeDevices(); track tpe.id) {
<div class="text-lg font-semibold mb-4">TPE Assignés ({{ getAgentTpes(agent.id).length }})</div> <ng-container
<div class="grid grid-cols-1 md:grid-cols-2 gap-3"> *ngTemplateOutlet="tpeCard; context: { $implicit: tpe }"
@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) { @else {
<div> <ng-container
<span class="font-medium">Série:</span> {{ tpe.serial }} *ngTemplateOutlet="tpeCard; context: { $implicit: tpeDevice() }"
</div> />
}
@if (tpe.type) {
<div>
<span class="font-medium">Type:</span> {{ tpe.type }}
</div>
}
</div>
</div>
}
</div>
</z-card>
} }
</div> </div>
} }
<div modal-actions class="flex justify-end gap-2"> <div modal-actions class="flex justify-end gap-2">
@@ -362,20 +334,20 @@
} }
<!-- TPE Assignment Modal --> <!-- TPE Assignment Modal -->
@if (assigningAgent()) { @if (assigningAgent() !== undefined) {
<app-modal <app-modal
[open]="assignTpeModalOpen()" [open]="assignTpeModalOpen()"
[title]="'Assigner un TPE à ' + (assigningAgent()?.nom || '') + ' ' + (assigningAgent()?.prenom || '')" [title]="'Assigner un TPE à ' + (assigningAgent()?.nom || '') + ' ' + (assigningAgent()?.prenom || '')"
(close)="closeAssignTpeModal()" (close)="closeAssignTpeModal()"
size="md" size="xxl"
> >
<div class="space-y-4"> <div class="space-y-4">
@if (tpesLoading()) { @if (tpesLoading()) {
<div class="text-center py-4">Chargement des TPE disponibles...</div> <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 { } @else {
<z-form-field> <app-tpe-select (selectionChange)="selectedTpeId.set($event)" [agent]="assigningAgent()" [selected]="getSelectedTpeIds()" ></app-tpe-select>
<!-- <z-form-field>
<label z-form-label>Sélectionner un TPE</label> <label z-form-label>Sélectionner un TPE</label>
<div z-form-control> <div z-form-control>
<z-select <z-select
@@ -385,22 +357,51 @@
> >
@for (tpe of availableTpes(); track tpe.id) { @for (tpe of availableTpes(); track tpe.id) {
<z-select-item [zValue]="tpe.id"> <z-select-item [zValue]="tpe.id">
{{ tpe.imei }} - {{ tpe.marque }} {{ tpe.modele }} {{ tpe.numeroSerie }} - {{ tpe.modeleAppareil }}
@if (tpe.statut === 'VALIDE') { @if (tpe.statut === 'ACTIF') {
<span class="text-xs text-green-600 dark:text-green-400 ml-2">(Valide)</span> <span class="text-xs text-green-600 dark:text-green-400 ml-2">(Actif)</span>
} }
</z-select-item> </z-select-item>
} }
</z-select> </z-select>
</div> </div>
</z-form-field> </z-form-field> -->
} }
</div> </div>
<div modal-actions class="flex justify-end gap-2"> <div modal-actions class="flex justify-end gap-2">
<z-button zType="destructive" (click)="closeAssignTpeModal()">Annuler</z-button> <z-button zType="destructive" (click)="closeAssignTpeModal()">Annuler</z-button>
<button z-button [disabled]="!selectedTpeId() || tpesLoading()" (click)="confirmAssignTpe()"> <button z-button [disabled]="tpesLoading()" (click)="confirmAssignTpe()">
Assigner Assigner
</button> </button>
</div> </div>
</app-modal> </app-modal>
} }
<ng-template #tpeCard let-tpe>
<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.numeroSerie }}
</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.modeleAppareil) {
<div><span class="font-medium">Modèle:</span> {{ tpe.modeleAppareil }}</div>
}
@if (tpe.numeroSerie) {
<div><span class="font-medium">Série:</span> {{ tpe.numeroSerie }}</div>
}
@if (tpe.typeTerminal) {
<div><span class="font-medium">Type:</span> {{ tpe.typeTerminal }}</div>
}
</div>
</div>
</ng-template>

View File

@@ -26,6 +26,8 @@ import { AgentFullForm } from '@shared/forms/agent-full-form/agent-full-form';
import { forkJoin, of } from 'rxjs'; import { forkJoin, of } from 'rxjs';
import { switchMap, catchError } from 'rxjs/operators'; import { switchMap, catchError } from 'rxjs/operators';
import { toast } from 'ngx-sonner'; import { toast } from 'ngx-sonner';
import { AgentForm } from '@shared/forms/agent-form/agent-form';
import { TpeSelect } from "../tpe-select/tpe-select";
@Component({ @Component({
standalone: true, standalone: true,
@@ -40,11 +42,10 @@ import { toast } from 'ngx-sonner';
Modal, Modal,
ZardButtonComponent, ZardButtonComponent,
ZardCardComponent, ZardCardComponent,
ZardSelectComponent,
ZardSelectItemComponent,
ZardFormModule, ZardFormModule,
AgentFullForm, AgentForm,
], TpeSelect
],
}) })
export class AgentsPage { export class AgentsPage {
rows = signal<Agent[]>([]); rows = signal<Agent[]>([]);
@@ -66,11 +67,17 @@ export class AgentsPage {
// TPE Assignment modal // TPE Assignment modal
assignTpeModalOpen = signal(false); assignTpeModalOpen = signal(false);
assigningAgent = signal<Agent | null>(null); assigningAgent = signal<Agent | undefined>(undefined);
availableTpes = signal<TpeDevice[]>([]); availableTpes = signal<TpeDevice[]>([]);
selectedTpeId = signal<string>(''); selectedTpeId = signal<string[]>([]);
tpesLoading = signal(false); tpesLoading = signal(false);
tpeDevice = signal<TpeDevice | undefined>(undefined);
tpeDevices = signal<TpeDevice[]>([]);
tpeArray = signal<boolean>(false);
@ViewChild(AgentFullForm) formComp?: AgentFullForm; @ViewChild(AgentFullForm) formComp?: AgentFullForm;
formatTpeStatut(statut: TpeStatus): string { formatTpeStatut(statut: TpeStatus): string {
@@ -94,28 +101,19 @@ export class AgentsPage {
{ key: 'profil', label: 'Profil', sortable: true, defaultVisible: true }, { key: 'profil', label: 'Profil', sortable: true, defaultVisible: true },
{ key: 'statut', label: 'Statut', sortable: true, defaultVisible: true, cell: (a) => this.renderStatutBadge(a.statut) }, { key: 'statut', label: 'Statut', sortable: true, defaultVisible: true, cell: (a) => this.renderStatutBadge(a.statut) },
{ key: 'phone', label: 'Téléphone', sortable: true, defaultVisible: true }, { key: 'phone', label: 'Téléphone', sortable: true, defaultVisible: true },
{ key: 'zone', label: 'Zone', sortable: true }, { key: 'zone', label: 'Zone', sortable: true, defaultVisible: true },
{ key: 'kiosk', label: 'Kiosque', sortable: true },
{ key: 'tpes', label: 'TPE', cell: (a) => `${(this.getAgentTpes(a.id) || []).length}` },
{ key: 'limites', label: 'Limites', cell: (a) => this.formatLimits(a) },
{ key: 'dateEmbauche', label: 'Embauché le', cell: (a) => (a.dateEmbauche ? new Date(a.dateEmbauche).toLocaleDateString() : '') },
]; ];
tpeMap = new Map<string, TpeDevice>(); tpeMap = new Map<string, TpeDevice>();
agentTpesMap = new Map<string, TpeDevice[]>(); agentTpesMap = new Map<string, TpeDevice[]>();
constructor( constructor(
private api: AgentService, private api: AgentService,
private tpeSvc: TpeService, private tpeSvc: TpeService,
private familyMemberService: AgentFamilyMemberService private familyMemberService: AgentFamilyMemberService
) { ) {
// Preload TPE maps for display
this.tpeSvc
.list({ page: 1, size: 200, search: '', sortKey: 'imei', sortDir: 'asc' } as any)
.subscribe((res) => {
const tpes = res.content as TpeDevice[];
this.rebuildTpeMaps(tpes);
});
effect(() => { effect(() => {
const params = { const params = {
page: this.page(), page: this.page(),
@@ -141,8 +139,6 @@ export class AgentsPage {
this.rows.set(res.content); this.rows.set(res.content);
this.total.set(res.pageable.total); this.total.set(res.pageable.total);
this.loading.set(false); this.loading.set(false);
// Refresh TPE map to ensure we have latest data
this.refreshTpeMap();
}, },
error: () => { error: () => {
this.rows.set([]); this.rows.set([]);
@@ -152,32 +148,6 @@ export class AgentsPage {
}); });
} }
private refreshTpeMap() {
this.tpeSvc
.list({ page: 1, size: 200, search: '', sortKey: 'imei', sortDir: 'asc' } as any)
.subscribe((res) => {
const tpes = res.content 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) || [];
}
renderStatutBadge(statut: Agent['statut'] | string | undefined): string { renderStatutBadge(statut: Agent['statut'] | string | undefined): string {
if (!statut) return ''; if (!statut) return '';
@@ -216,6 +186,8 @@ export class AgentsPage {
closeModal() { closeModal() {
this.modalOpen.set(false); this.modalOpen.set(false);
} }
openDetail(row: Agent) { openDetail(row: Agent) {
// Fetch full agent details // Fetch full agent details
this.api.getById(row.id).subscribe({ this.api.getById(row.id).subscribe({
@@ -231,6 +203,31 @@ export class AgentsPage {
this.detailFamilyMembers.set([]); this.detailFamilyMembers.set([]);
}, },
}); });
const tpeIds = agent.terminauxIds;
if(Array.isArray(tpeIds)){
this.tpeArray.set(true);
forkJoin(
tpeIds.map(id=>this.tpeSvc.getById(String(id)))
).subscribe({
next:(tpes)=>{
this.tpeDevices.set(tpes.filter(tpe=>tpe!==undefined))
},
error:(err)=>{
console.error(err);
}
})
}else{
this.tpeArray.set(false);
this.tpeSvc.getById(String(tpeIds)).subscribe({
next:(tpe)=>{
if(tpe && tpe !== undefined)
this.tpeDevice.set(tpe);
},
error:(err)=>{
console.error(err);
}
})
}
this.detailModalOpen.set(true); this.detailModalOpen.set(true);
} }
}, },
@@ -384,6 +381,23 @@ export class AgentsPage {
}); });
} }
getSelectedTpeIds = (): string[] => {
const ids = this.assigningAgent()?.terminauxIds;
if (!ids) return []; // undefined ou null → tableau vide
// Si c'est un tableau, on map en string
if (Array.isArray(ids)) {
return ids.map(id => id.toString());
}
// Si c'est un seul nombre, on retourne un tableau avec un élément
return [ids.toString()];
};
remove(row: Agent) { remove(row: Agent) {
if (!confirm(`Supprimer l\'agent ${row.code} ?`)) return; if (!confirm(`Supprimer l\'agent ${row.code} ?`)) return;
this.api.delete(row.id).subscribe(() => this.api.delete(row.id).subscribe(() =>
@@ -399,75 +413,111 @@ export class AgentsPage {
openAssignTpe(agent: Agent) { openAssignTpe(agent: Agent) {
this.assigningAgent.set(agent); this.assigningAgent.set(agent);
this.selectedTpeId.set(''); this.selectedTpeId.set([]);
this.loadAvailableTpes();
this.assignTpeModalOpen.set(true); this.assignTpeModalOpen.set(true);
} }
loadAvailableTpes() { // loadAvailableTpes() {
this.tpesLoading.set(true); // this.tpesLoading.set(true);
const agent = this.assigningAgent(); // const agent = this.assigningAgent();
if (!agent) { // if (!agent) {
this.availableTpes.set([]); // this.availableTpes.set([]);
this.tpesLoading.set(false); // this.tpesLoading.set(false);
return; // return;
} // }
const currentAgentTpes = this.agentTpesMap.get(agent.id) || []; // const currentAgentTpes = this.agentTpesMap.get(agent.id) || [];
const agentTpeIds = new Set(currentAgentTpes.map((t) => t.id)); // const agentTpeIds = new Set(currentAgentTpes.map((t) => t.id));
// Load available TPEs (DISPONIBLE or VALIDE status) // // Load available TPEs (DISPONIBLE or VALIDE status)
forkJoin([this.tpeSvc.getByStatut('DISPONIBLE'), this.tpeSvc.getByStatut('VALIDE')]).subscribe({ // forkJoin([this.tpeSvc.getByStatut('ACTIF'), this.tpeSvc.getByStatut('ACTIF')]).subscribe({
next: ([disponibleTpes, valideTpes]) => { // next: ([disponibleTpes, valideTpes]) => {
// Combine and filter: only show TPEs that are not assigned to any agent AND not already assigned to this agent // // 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 allTpes = [...disponibleTpes, ...valideTpes];
const available = allTpes.filter( // const available = allTpes.filter(
(t) => // (t) =>
!t.assigne && // !t.agentConnecteId &&
(t.statut === 'DISPONIBLE' || t.statut === 'VALIDE') && // (t.statut === 'ACTIF') &&
!agentTpeIds.has(t.id) // !agentTpeIds.has(t.id)
); // );
// Remove duplicates // // Remove duplicates
const uniqueTpes = Array.from(new Map(available.map((t) => [t.id, t])).values()); // const uniqueTpes = Array.from(new Map(available.map((t) => [t.id, t])).values());
this.availableTpes.set(uniqueTpes); // this.availableTpes.set(uniqueTpes);
this.tpesLoading.set(false); // this.tpesLoading.set(false);
}, // },
error: () => { // error: () => {
this.availableTpes.set([]); // this.availableTpes.set([]);
this.tpesLoading.set(false); // this.tpesLoading.set(false);
}, // },
}); // });
} // }
// testAssigne(tpeIds: string[]){
// console.log(tpeIds);
// }
confirmAssignTpe() { confirmAssignTpe() {
const agent = this.assigningAgent(); const agent = this.assigningAgent();
const tpeId = this.selectedTpeId(); const tpeId = this.selectedTpeId().map(id=> id);
if (!agent || !tpeId) { if (!agent) {
alert('Veuillez sélectionner un TPE'); toast.error('Veuillez sélectionner un TPE');
return; return;
} }
// Assign TPE to agent this.api.assigner(tpeId, agent.id).subscribe({
this.tpeSvc.assigner(tpeId, agent.id).subscribe({ next:()=>{
next: (tpe) => {
if (tpe) {
// Fermer le modal et recharger complètement la page
this.assignTpeModalOpen.set(false); this.assignTpeModalOpen.set(false);
this.assigningAgent.set(null); this.assigningAgent.set(undefined);
this.selectedTpeId.set(''); this.selectedTpeId.set([]);
// Rechargement complet pour s'assurer que la liste des agents / TPE est à jour toast.success("Termiaux affectés avec succès!");
window.location.reload(); const params = {
page: this.page(),
size: this.size(),
search: this.search(),
sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir,
};
this.fetch(params);
},
error:()=>{
console.error("Une érreur s'est produite")
} }
}, })
error: () => {
alert("Erreur lors de l'assignation du TPE"); // forkJoin(this.selectedTpeId().map(id=> this.api.assigner(id, agent.id))).subscribe(
}, // {
}); // next:()=>{
// this.assignTpeModalOpen.set(false);
// this.assigningAgent.set(undefined);
// this.selectedTpeId.set([]);
// toast.success(`Tpe affecté à l'agent avec succès1`)
// },
// error: (err)=>{
// console.error(err);
// }
// }
// )
// // 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.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() { closeAssignTpeModal() {
this.assignTpeModalOpen.set(false); this.assignTpeModalOpen.set(false);
this.assigningAgent.set(null); this.assigningAgent.set(undefined);
this.selectedTpeId.set(''); this.selectedTpeId.set([]);
} }
} }

View File

@@ -497,8 +497,8 @@ export class Course {
onResultatConfirm() { onResultatConfirm() {
const c = this.selectedCourseForResultat(); const c = this.selectedCourseForResultat();
if (!c) return; if (!c) return;
const resultat = this.resultatsMap().get(c.id); const resultat = this.resultatsMap().get(c.id);
if (!resultat) { if (!resultat) {
toast.error('Aucun résultat à confirmer'); toast.error('Aucun résultat à confirmer');
return; return;

View File

@@ -0,0 +1,41 @@
<div class="flex flex-col gap-2 min-h-screen">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-semibold">Gestion des Points de Vente</h2>
<z-button (click)="openCreate()">Nouveau point de vente</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)" 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]="size()"
(pageChange)="page.set($event - 1)"
(perPageChange)="size.set($event)"
></app-paginator>
</div>
<app-modal [open]="modalOpen()" [title]="modalTitle()" (close)="closeModal()" size="xl">
<app-points-vente-form
[value]="editingItem() ?? undefined"
(save)="onFormSave($event)"
(cancel)="closeModal()"
/>
<div modal-actions class="flex justify-end gap-2">
<z-button zType="destructive" (click)="closeModal()">Annuler</z-button>
<z-button (click)="submitChildForm()">Enregistrer</z-button>
</div>
</app-modal>

View File

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

View File

@@ -0,0 +1,209 @@
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 { SortDir } from '@shared/paging/paging';
import { PointVente, PointVenteStatut } from 'src/app/core/interfaces/points-ventes';
import { PointsVenteService } from 'src/app/core/services/points-vente';
import { PointsVenteForm } from '@shared/forms/points-vente-form/points-vente-form';
import { toast } from 'ngx-sonner';
@Component({
standalone: true,
selector: 'app-point-vente',
templateUrl: './point-vente.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
DataTable,
Paginator,
SearchBar,
Modal,
ZardButtonComponent,
PointsVenteForm,
],
})
export class PointVentePage {
rows = signal<PointVente[]>([]);
total = signal(0);
loading = signal(false);
page = signal(0);
size = signal(10);
search = signal('');
sort = signal<SortState>({ key: 'nom', dir: 'asc' });
modalOpen = signal(false);
modalTitle = signal('Nouveau point de vente');
editingItem = signal<PointVente | null>(null);
@ViewChild(PointsVenteForm) formComp?: PointsVenteForm;
cols: TableColumn<PointVente>[] = [
{ key: 'nom', label: 'Nom', sortable: true, defaultVisible: true },
{ key: 'adresse', label: 'Adresse', sortable: true, defaultVisible: true },
{ key: 'ville', label: 'Ville', sortable: true, defaultVisible: true },
{
key: 'statut',
label: 'Statut',
sortable: true,
defaultVisible: true,
cell: (pv) => this.renderStatutBadge(pv.statut),
},
{
key: 'latitude',
label: 'Latitude',
cell: (pv) => pv.latitude?.toFixed(6) || '—',
},
{
key: 'longitude',
label: 'Longitude',
cell: (pv) => pv.longitude?.toFixed(6) || '—',
},
];
constructor(private api: PointsVenteService) {
effect(() => {
const params = {
page: this.page(),
size: this.size(),
search: this.search(),
sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir,
};
untracked(() => this.fetch(params));
});
}
private fetch(params: {
page: number;
size: number;
search: string;
sortKey: string;
sortDir: SortDir;
}) {
this.loading.set(true);
this.api.list(params).subscribe({
next: (res) => {
this.rows.set(res.content);
this.total.set(res.pageable.total);
this.loading.set(false);
},
error: () => {
this.rows.set([]);
this.total.set(0);
this.loading.set(false);
},
});
}
renderStatutBadge(statut: PointVenteStatut | string | undefined): string {
if (!statut) return '';
const s = String(statut).toUpperCase();
if (s === 'ACTIF') {
return `<span class="inline-flex items-center gap-1 px-2 py-1 rounded bg-green-500/10 text-green-600 dark:text-green-400 text-xs font-medium"><i class="icon-check"></i> Actif</span>`;
}
return `<span class="inline-flex items-center gap-1 px-2 py-1 rounded bg-gray-500/10 text-gray-600 dark:text-gray-400 text-xs font-medium"><i class="icon-x"></i> Inactif</span>`;
}
onSearch(q: string) {
this.search.set(q);
this.page.set(0);
}
openCreate() {
this.modalTitle.set('Nouveau point de vente');
this.editingItem.set(null);
queueMicrotask(() => this.modalOpen.set(true));
}
openEdit(row: PointVente) {
this.modalTitle.set("Modifier le point de vente");
this.editingItem.set(row);
queueMicrotask(() => this.modalOpen.set(true));
}
closeModal() {
this.modalOpen.set(false);
}
submitChildForm() {
this.formComp?.onSubmit();
}
onFormSave(payload: Partial<PointVente>) {
const current = this.editingItem();
const isCreating = !current?.id;
const req$ = current?.id
? this.api.update(current.id, payload)
: this.api.create(payload as Omit<PointVente, 'id'>);
req$.subscribe({
next: (result) => {
if (result) {
toast.success(
isCreating
? 'Point de vente créé avec succès'
: 'Point de vente modifié avec succès'
);
// Close modal first
this.closeModal();
// Reset form
this.formComp?.resetForm();
// Clear editing item
this.editingItem.set(null);
// Refresh data
this.fetch({
page: this.page(),
size: this.size(),
search: this.search(),
sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir,
});
}
},
error: (err) => {
console.error('Error saving point de vente:', err);
alert(
isCreating
? "Erreur lors de la création du point de vente"
: "Erreur lors de la modification du point de vente"
);
},
});
}
remove(row: PointVente) {
if (!confirm(`Supprimer le point de vente "${row.nom}" ?`)) return;
this.api.delete(row.id).subscribe({
next: (success) => {
if (success) {
toast.success('Point de vente supprimé avec succès');
this.fetch({
page: this.page(),
size: this.size(),
search: this.search(),
sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir,
});
} else {
alert("Erreur lors de la suppression du point de vente");
}
},
error: () => {
alert("Erreur lors de la suppression du point de vente");
},
});
}
}

View File

@@ -0,0 +1,35 @@
<div class="p-4 border rounded-lg shadow bg-white dark:bg-gray-900">
@if (loading()) {
<div class="flex justify-center py-4">
<div class="w-6 h-6 border-2 border-gray-300 border-t-blue-600 rounded-full animate-spin"></div>
</div>
}
@if(!loading()){
@if(tpes().length > 0){
<div>
<app-data-table
[data]="tpes()"
[columns]="columns">
<ng-template #rowActions let-row>
<div class="flex gap-2 flex-wrap">
<input type="checkbox" (click)="toggleTpe(row)" [checked] = "isChecked(row)" />
</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>
}
@if (tpes().length === 0) {
<p class="text-gray-500 dark:text-gray-400">
Aucun TPE disponible.
</p>
}
}
</div>

View File

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

View File

@@ -0,0 +1,108 @@
import { Component, effect, EventEmitter, Input, Output, signal } from '@angular/core';
import { SortState, TableColumn, DataTable } from '@shared/components/data-table/data-table';
import { ListParams, SortDir } from '@shared/paging/paging';
import { Agent } from 'src/app/core/interfaces/agent';
import { TpeDevice } from 'src/app/core/interfaces/tpe';
import { TpeService } from 'src/app/core/services/tpe';
import { Paginator } from "@shared/components/paginator/paginator";
@Component({
selector: 'app-tpe-select',
imports: [DataTable, Paginator],
templateUrl: './tpe-select.html',
styleUrl: './tpe-select.css',
})
export class TpeSelect {
@Input() agent?: Agent; // Agent pour filtrer les TPE assignés
@Input() selected: string[] = []; // Ids de TPE à cocher par défaut
@Output() selectionChange = new EventEmitter<string[]>(); // TPE sélectionnés
tpes = signal<TpeDevice[]>([]);
total = signal(0);
loading = signal<boolean>(true);
selectedIds = signal<Set<string>>(new Set());
page = signal(0);
perPage = signal(10);
search = signal('');
sort = signal<SortState>({ key: 'id', dir: 'asc' });
columns:TableColumn<TpeDevice>[] = [
{
key: 'numeroSerie',
label: "Numero de serie"
},
{
key: 'versionLogicielle',
label: "Version du logiciel"
},
{
key: 'modeleAppareil',
label: "Model"
},
{
key: 'systemeExploitation',
label: 'Système d\'exploitation'
},
{
key: 'versionOs',
label: 'Version Os'
}
]
constructor(private tpeService: TpeService) {
effect(()=>{
const params = {
page: this.page(),
size: this.perPage(),
sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir,
}
this.loadTpes(params);
})
}
ngOnInit() {
// Initialiser les TPE sélectionnés si fournis
this.selectedIds.set(new Set(this.selected));
}
private loadTpes(params:ListParams) {
this.loading.set(true);
this.tpeService.list(params).subscribe({
next: (res) => {
// Filtrer : TPE non assignés ou déjà assignés à cet agent
//console.log(res.content);
const available = res.content.filter(
(t) =>
(t.assigned && this.selectedIds().has(String(t.id))) || !t.assigned
);
this.tpes.set(available);
this.total.set(res.pageable.total);
this.loading.set(false);
},
error: (err) => {
console.error(err);
this.loading.set(false);
},
});
}
toggleTpe(tpe: TpeDevice) {
const current = new Set(this.selectedIds());
if (current.has(String(tpe.id))) {
current.delete(String(tpe.id));
} else {
current.add(String(tpe.id));
}
this.selectedIds.set(current);
this.selectionChange.emit(Array.from(current));
}
isChecked(tpe: TpeDevice) {
return this.selectedIds().has(String(tpe.id));
}
}

View File

@@ -137,7 +137,7 @@
[total]="total()" [total]="total()"
[page]="page()" [page]="page()"
[perPage]="perPage()" [perPage]="perPage()"
(pageChange)="page.set($event)" (pageChange)="page.set($event - 1)"
(perPageChange)="perPage.set($event)" (perPageChange)="perPage.set($event)"
></app-paginator> ></app-paginator>
</div> </div>
@@ -159,7 +159,7 @@
<!-- Agent Assignment Modal --> <!-- Agent Assignment Modal -->
<app-modal <app-modal
[open]="assignModalOpen()" [open]="assignModalOpen()"
[title]="'Assigner le TPE ' + (assigningTpe()?.imei || '')" [title]="'Assigner le TPE ' + (assigningTpe()?.numeroSerie || '')"
(close)="closeAssignModal()" (close)="closeAssignModal()"
size="md" size="md"
> >

View File

@@ -26,6 +26,9 @@ import { ZardSelectItemComponent } from '@shared/components/select/select-item.c
import { ZardFormModule } from '@shared/components/form/form.module'; import { ZardFormModule } from '@shared/components/form/form.module';
import { forkJoin, Subject } from 'rxjs'; import { forkJoin, Subject } from 'rxjs';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators'; import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';
import { toast } from 'ngx-sonner';
import { PointsVenteService } from 'src/app/core/services/points-vente';
import { PointVente } from 'src/app/core/interfaces/points-ventes';
@Component({ @Component({
standalone: true, standalone: true,
@@ -52,10 +55,10 @@ export class TpePage implements OnInit {
total = signal(0); total = signal(0);
loading = signal(false); loading = signal(false);
page = signal(1); page = signal(0);
perPage = signal(10); perPage = signal(10);
search = signal(''); search = signal('');
sort = signal<SortState>({ key: 'imei', dir: 'asc' }); sort = signal<SortState>({ key: 'id', dir: 'asc' });
selectedStatut = signal<TpeStatus | null>(null); selectedStatut = signal<TpeStatus | null>(null);
modalOpen = signal(false); modalOpen = signal(false);
@@ -74,6 +77,8 @@ export class TpePage implements OnInit {
assignmentStats = signal({ total: 0, assignes: 0, disponibles: 0 }); assignmentStats = signal({ total: 0, assignes: 0, disponibles: 0 });
statsLoading = signal(false); statsLoading = signal(false);
pointDeVenteMap = signal<Map<string, PointVente>>(new Map());
// Live search // Live search
private searchSubject = new Subject<string>(); private searchSubject = new Subject<string>();
@@ -95,21 +100,31 @@ export class TpePage implements OnInit {
} }
cols: TableColumn<TpeDevice>[] = [ cols: TableColumn<TpeDevice>[] = [
{ key: 'imei', label: 'IMEI', sortable: true }, { key: 'numeroSerie', label: 'Numéro de série', sortable: true },
{ key: 'serial', label: 'N° de Série', sortable: true }, {
{ key: 'type', label: 'Type', sortable: true }, key: 'pointDeVenteId',
{ key: 'marque', label: 'Marque', sortable: true }, label: 'Point de vente',
{ key: 'modele', label: 'Modèle', sortable: true }, sortable: true,
cell: (t) => {
const pdv = this.pointDeVenteMap().get(String(t.pointDeVenteId));
if (!pdv) return 'Pas de point de vente';
return `${pdv.ville}/${pdv.adresse}`;
},
},
{ key: 'versionLogicielle', label: 'Version', sortable: true },
{ key: 'typeTerminal', label: 'Type', sortable: true },
{ key: 'systemeExploitation', label: 'Sytème', sortable: true },
{ key: 'statut', label: 'Statut', sortable: true, cell: (d) => this.formatStatut(d.statut) }, { key: 'statut', label: 'Statut', sortable: true, cell: (d) => this.formatStatut(d.statut) },
{ {
key: 'assigne', key: 'assigne',
label: 'Assigné à', label: 'Assigné à',
cell: (d) => { cell: (d) => {
if (!d.assigne || !d.agent) { if (!d.assigned) {
return '<span class="text-muted-foreground text-sm">Non assigné</span>'; return '<span class="text-muted-foreground text-sm">Non assigné</span>';
} }
const agent = d.agent; // a rectifier apres avec les données des agents!
const code = agent.code const agent = {} as any;
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>` ? `<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 = const name =
@@ -134,19 +149,13 @@ export class TpePage implements OnInit {
}, },
]; ];
allStatuses: TpeStatus[] = [ allStatuses: TpeStatus[] = ['ACTIF', 'HORS_SERVICE'];
'VALIDE',
'INVALIDE',
'EN_PANNE',
'BLOQUE',
'DISPONIBLE',
'AFFECTE',
'EN_MAINTENANCE',
'HORS_SERVICE',
'VOLE',
];
constructor(private api: TpeService, private agentService: AgentService) { constructor(
private api: TpeService,
private agentService: AgentService,
private pointVenteService: PointsVenteService
) {
effect(() => { effect(() => {
// Only trigger fetch when page, perPage, or sort changes (not search - handled by searchSubject) // Only trigger fetch when page, perPage, or sort changes (not search - handled by searchSubject)
const searchValue = this.search(); const searchValue = this.search();
@@ -279,10 +288,32 @@ export class TpePage implements OnInit {
}); });
} else { } else {
// Normal list with pagination // Normal list with pagination
const map: Map<string, PointVente> = new Map();
this.api.list(params).subscribe({ this.api.list(params).subscribe({
next: (res) => { next: (res) => {
this.rows.set(res.content); this.rows.set(res.content);
this.total.set(res.pageable.total); this.total.set(res.pageable.total);
const requests = res.content
.filter((t) => t.pointDeVenteId)
.map((t) => this.pointVenteService.getById(t.pointDeVenteId!));
forkJoin(requests).subscribe({
next: (pdvs) => {
res.content.forEach((t) => {
if (!t.pointDeVenteId) return;
const pdv = pdvs.find((p) => p?.id === t.pointDeVenteId);
if (!pdv) return;
map.set(String(t.pointDeVenteId), pdv);
});
// ✅ SET ICI seulement
this.pointDeVenteMap.set(map);
this.loading.set(false);
},
error: () => this.loading.set(false),
});
this.pointDeVenteMap.set(map);
this.loading.set(false); this.loading.set(false);
}, },
error: () => { error: () => {
@@ -326,7 +357,8 @@ export class TpePage implements OnInit {
} }
onUpdateStatut(row: TpeDevice, newStatut: TpeStatus) { onUpdateStatut(row: TpeDevice, newStatut: TpeStatus) {
if (!confirm(`Changer le statut de ${row.imei} vers ${this.formatStatut(newStatut)} ?`)) return; if (!confirm(`Changer le statut de ${row.numeroSerie} vers ${this.formatStatut(newStatut)} ?`))
return;
this.api.updateStatut(row.id, newStatut).subscribe({ this.api.updateStatut(row.id, newStatut).subscribe({
next: () => { next: () => {
this.fetch({ this.fetch({
@@ -342,7 +374,7 @@ export class TpePage implements OnInit {
} }
onLiberer(row: TpeDevice) { onLiberer(row: TpeDevice) {
if (!confirm(`Libérer le TPE ${row.imei} ?`)) return; if (!confirm(`Libérer le TPE ${row.numeroSerie} ?`)) return;
this.api.liberer(row.id).subscribe({ this.api.liberer(row.id).subscribe({
next: () => { next: () => {
this.fetch({ this.fetch({
@@ -440,6 +472,7 @@ export class TpePage implements OnInit {
: this.api.create(payload as Omit<TpeDevice, 'id'>); : this.api.create(payload as Omit<TpeDevice, 'id'>);
req$.subscribe({ req$.subscribe({
next: (result) => { next: (result) => {
toast.success('Tpe créé avec succès!');
// For update, check if result is valid (update can return undefined on error) // For update, check if result is valid (update can return undefined on error)
if (current?.id && !result) { if (current?.id && !result) {
console.error('Update failed - result is undefined'); console.error('Update failed - result is undefined');
@@ -472,7 +505,7 @@ export class TpePage implements OnInit {
} }
remove(row: TpeDevice) { remove(row: TpeDevice) {
if (!confirm(`Supprimer l\'équipement IMEI ${row.imei} ?`)) return; if (!confirm(`Supprimer l\'équipement IMEI ${row.numeroSerie} ?`)) return;
this.api.delete(row.id).subscribe(() => { this.api.delete(row.id).subscribe(() => {
this.fetch({ this.fetch({
page: this.page(), page: this.page(),

View File

@@ -1,91 +1,293 @@
<form class="space-y-4" (ngSubmit)="onSubmit()" [formGroup]="form"> <form class="space-y-6" [formGroup]="form" (ngSubmit)="onSubmit()">
<!-- SECTION IDENTIFICATION -->
<div class="bg-white dark:bg-gray-800 p-6 rounded-xl shadow space-y-4 border border-gray-200 dark:border-gray-700">
<h2 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Identification</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<z-form-field> <z-form-field>
<label z-form-label>Code</label> <label z-form-label>Code</label>
<div z-form-control [errorMessage]="error('code') || ''"><input z-input formControlName="code" /></div> <div z-form-control [errorMessage]="errorMessage('code') || ''">
</z-form-field> <input z-input formControlName="code" placeholder="Code de l'agent" />
<z-form-field>
<label z-form-label>Profil</label>
<div z-form-control [errorMessage]="error('profil') || ''"><input z-input formControlName="profil" /></div>
</z-form-field>
<z-form-field>
<label z-form-label>Statut</label>
<div z-form-control [errorMessage]="error('statut') || ''">
<z-select formControlName="statut" [zPlaceholder]="'Sélectionner...'">
<z-select-item zValue="ACTIF">Actif</z-select-item>
<z-select-item zValue="INACTIF">Inactif</z-select-item>
<z-select-item zValue="SUSPENDU">Suspendu</z-select-item>
</z-select>
</div> </div>
</z-form-field> </z-form-field>
<z-form-field>
<label z-form-label>Zone</label>
<div z-form-control><input z-input formControlName="zone" /></div>
</z-form-field>
<z-form-field>
<label z-form-label>Kiosque</label>
<div z-form-control><input z-input formControlName="kiosk" /></div>
</z-form-field>
<z-form-field>
<label z-form-label>Fonction</label>
<div z-form-control><input z-input formControlName="fonction" /></div>
</z-form-field>
<z-form-field> <z-form-field>
<label z-form-label>Nom</label> <label z-form-label>Profil</label>
<div z-form-control [errorMessage]="error('nom') || ''"><input z-input formControlName="nom" /></div> <div z-form-control [errorMessage]="errorMessage('profil') || ''">
</z-form-field> <z-select formControlName="profil">
<z-form-field> @for (p of profils; track p) {
<label z-form-label>Prénom</label> <z-select-item [zValue]="p.value">{{ p.label }}</z-select-item>
<div z-form-control [errorMessage]="error('prenom') || ''"><input z-input formControlName="prenom" /></div>
</z-form-field>
<z-form-field>
<label z-form-label>Téléphone</label>
<div z-form-control [errorMessage]="error('phone') || ''"><input z-input formControlName="phone" /></div>
</z-form-field>
<z-form-field>
<label z-form-label>Limite inférieure</label>
<div z-form-control><input z-input type="number" formControlName="limiteInferieure" /></div>
</z-form-field>
<z-form-field>
<label z-form-label>Limite supérieure</label>
<div z-form-control><input z-input type="number" formControlName="limiteSuperieure" /></div>
</z-form-field>
<z-form-field>
<label z-form-label>Limite / transaction</label>
<div z-form-control><input z-input type="number" formControlName="limiteParTransaction" /></div>
</z-form-field>
<z-form-field>
<label z-form-label>Min Airtime</label>
<div z-form-control><input z-input type="number" formControlName="limiteMinAirtime" /></div>
</z-form-field>
<z-form-field>
<label z-form-label>Max Airtime</label>
<div z-form-control><input z-input type="number" formControlName="limiteMaxAirtime" /></div>
</z-form-field>
<z-form-field>
<label z-form-label>Nbre max. périphériques</label>
<div z-form-control><input z-input type="number" formControlName="maxPeripheriques" /></div>
</z-form-field>
<z-form-field>
<label z-form-label>Groupe de limites</label>
<div z-form-control [errorMessage]="error('limitId') || ''">
<z-select formControlName="limitId" [zPlaceholder]="'Sélectionner...'">
@for (l of limits; track l.id) {
<z-select-item [zValue]="l.id">{{ l.nom }}</z-select-item>
} }
</z-select> </z-select>
</div> </div>
</z-form-field> </z-form-field>
<z-form-field>
<label z-form-label>Profil caisse</label>
<div z-form-control [errorMessage]="errorMessage('profil') || ''">
<input z-input formControlName="caisseProfile"/>
</div>
</z-form-field>
<z-form-field>
<label z-form-label>Princial</label>
<div z-form-control [errorMessage]="errorMessage('principalCode') || ''">
<input z-input formControlName="principalCode" placeholder="12242" />
</div>
</z-form-field>
<z-form-field>
<label z-form-label>Nom</label>
<div z-form-control [errorMessage]="errorMessage('nom') || ''">
<input z-input formControlName="nom" placeholder="Nom" />
</div>
</z-form-field>
<z-form-field>
<label z-form-label>Prénom</label>
<div z-form-control [errorMessage]="errorMessage('prenom') || ''">
<input z-input formControlName="prenom" placeholder="Prénom" />
</div>
</z-form-field>
<z-form-field>
<label z-form-label>Autres noms</label>
<div z-form-control>
<input z-input formControlName="autresNoms" placeholder="Autres noms" />
</div>
</z-form-field>
<z-form-field>
<label z-form-label>Statut</label>
<div z-form-control [errorMessage]="errorMessage('statut') || ''">
<z-select formControlName="statut">
@for (s of statutOptions; track s) {
<z-select-item [zValue]="s.value">{{ s.label }}</z-select-item>
}
</z-select>
</div>
</z-form-field>
<z-form-field>
<label z-form-label>Date d'embauche</label>
<div z-form-control>
<input z-input type="date" formControlName="dateEmbauche"/>
</div>
</z-form-field>
<z-form-field>
<label z-form-label>Fonction</label>
<div z-form-control>
<input z-input formControlName="fonction" placeholder="Fonction" />
</div>
</z-form-field>
</div>
</div> </div>
<div class="flex justify-end gap-2 pt-4"> <!-- SECTION CONTACT -->
<z-button zType="destructive" type="button" (click)="cancel.emit()">Annuler</z-button> <div class="bg-white dark:bg-gray-800 p-6 rounded-xl shadow space-y-4 border border-gray-200 dark:border-gray-700">
<z-button type="submit">Enregistrer</z-button> <h2 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Contact & Identité</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<z-form-field>
<label z-form-label>Phone</label>
<div z-form-control [errorMessage]="errorMessage('phone') || ''">
<input z-input formControlName="phone" placeholder="Numéro de téléphone" />
</div> </div>
</z-form-field>
<z-form-field>
<label z-form-label>PIN</label>
<div z-form-control>
<input z-input type="password" formControlName="pin" placeholder="PIN" />
</div>
</z-form-field>
<z-form-field>
<label z-form-label>Adresse</label>
<div z-form-control>
<input z-input formControlName="adresse" placeholder="Adresse" />
</div>
</z-form-field>
<z-form-field>
<label z-form-label>Ville</label>
<div z-form-control>
<input z-input formControlName="ville" placeholder="Ville" />
</div>
</z-form-field>
<z-form-field>
<label z-form-label>Lieu de naissance</label>
<div z-form-control>
<input z-input formControlName="lieuNaissance" placeholder="Lieu de naissance" />
</div>
</z-form-field>
<z-form-field>
<label z-form-label>Date de naissance</label>
<div z-form-control>
<input z-input type="date" formControlName="dateNaissance"/>
</div>
</z-form-field>
</div>
</div>
<!-- SECTION LIMITE & PERIPHERIQUES -->
<div class="bg-white dark:bg-gray-800 p-6 rounded-xl shadow space-y-4 border border-gray-200 dark:border-gray-700">
<h2 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Limites & Périphériques</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<z-form-field>
<label z-form-label>Zone</label>
<div z-form-control>
<input z-input formControlName="zone"/>
</div>
</z-form-field>
<z-form-field>
<label z-form-label>Kiosk</label>
<div z-form-control>
<input z-input formControlName="kiosk"/>
</div>
</z-form-field>
<z-form-field>
<label z-form-label>Limite inférieure</label>
<div z-form-control>
<input z-input type="number" formControlName="limiteInferieure"/>
</div>
</z-form-field>
<z-form-field>
<label z-form-label>Limite supérieure</label>
<div z-form-control>
<input z-input type="number" formControlName="limiteSuperieure"/>
</div>
</z-form-field>
<z-form-field>
<label z-form-label>Limite par transaction</label>
<div z-form-control>
<input z-input type="number" formControlName="limiteParTransaction"/>
</div>
</z-form-field>
<z-form-field>
<label z-form-label>Limite Airtime (min)</label>
<div z-form-control>
<input z-input type="number" formControlName="limiteMinAirtime"/>
</div>
</z-form-field>
<z-form-field>
<label z-form-label>Limite Airtime (max)</label>
<div z-form-control>
<input z-input type="number" formControlName="limiteMaxAirtime"/>
</div>
</z-form-field>
<z-form-field>
<label z-form-label>Nombre max périphériques</label>
<div z-form-control>
<input z-input type="number" formControlName="maxPeripheriques"/>
</div>
</z-form-field>
</div>
</div>
<!-- SECTION INFORMATIONS LEGALES -->
<div class="bg-white dark:bg-gray-800 p-6 rounded-xl shadow space-y-4 border border-gray-200 dark:border-gray-700">
<h2 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Informations légales</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<z-form-field>
<label z-form-label>Nationalité</label>
<div z-form-control>
<input z-input formControlName="nationalite" placeholder="Nationalité" />
</div>
</z-form-field>
<z-form-field>
<label z-form-label>CNI</label>
<div z-form-control>
<input z-input formControlName="cni" placeholder="Numéro CNI" />
</div>
</z-form-field>
<z-form-field>
<label z-form-label>CNI délivrée le</label>
<div z-form-control>
<input z-input type="date" formControlName="cniDelivreeLe"/>
</div>
</z-form-field>
<z-form-field>
<label z-form-label>CNI délivrée à</label>
<div z-form-control>
<input z-input formControlName="cniDelivreeA" placeholder="Lieu de délivrance"/>
</div>
</z-form-field>
<z-form-field>
<label z-form-label>Résidence</label>
<div z-form-control>
<input z-input formControlName="residence" placeholder="Résidence"/>
</div>
</z-form-field>
<z-form-field>
<label z-form-label>Autre adresse</label>
<div z-form-control>
<input z-input formControlName="autreAdresse1" placeholder="Résidence"/>
</div>
</z-form-field>
<z-form-field>
<label z-form-label>Statut marital</label>
<div z-form-control>
<input z-input formControlName="statutMarital" placeholder="Statut marital"/>
</div>
</z-form-field>
<z-form-field>
<label z-form-label>Époux / Épouse</label>
<div z-form-control>
<input z-input formControlName="epoux" placeholder="Nom époux / épouse"/>
</div>
</z-form-field>
<z-form-field>
<label z-form-label>Autre téléphone</label>
<div z-form-control>
<input z-input formControlName="autreTelephone" placeholder="Autre numéro"/>
</div>
</z-form-field>
<z-form-field>
<label z-form-label>Autoriser aides</label>
<div z-form-control>
<input type="checkbox" formControlName="autoriserAides"/>
</div>
</z-form-field>
</div>
</div>
<!-- BOUTONS SUBMIT -->
<div class="flex justify-end space-x-4">
<button
type="button"
class="px-4 py-2 rounded-lg bg-gray-300 dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:bg-gray-400 dark:hover:bg-gray-600"
(click)="onClose()"
>
Réinitialiser
</button>
<button
type="submit"
class="px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600"
>
Enregistrer
</button>
</div>
</form> </form>

View File

@@ -9,13 +9,14 @@ import { ZardButtonComponent } from '@shared/components/button/button.component'
import { Agent, AgentStatus } from 'src/app/core/interfaces/agent'; import { Agent, AgentStatus } from 'src/app/core/interfaces/agent';
import { AgentLimit } from 'src/app/core/interfaces/agent-limit'; import { AgentLimit } from 'src/app/core/interfaces/agent-limit';
import { AgentLimitService } from 'src/app/core/services/agent-limit'; import { AgentLimitService } from 'src/app/core/services/agent-limit';
import { ZardCheckboxComponent } from "@shared/components/checkbox/checkbox.component";
@Component({ @Component({
selector: 'app-agent-form', selector: 'app-agent-form',
standalone: true, standalone: true,
templateUrl: './agent-form.html', templateUrl: './agent-form.html',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, ReactiveFormsModule, ZardFormModule, ZardInputDirective, ZardSelectComponent, ZardSelectItemComponent, ZardButtonComponent], imports: [CommonModule, ReactiveFormsModule, ZardFormModule, ZardInputDirective, ZardSelectComponent, ZardSelectItemComponent],
}) })
export class AgentForm { export class AgentForm {
@Output() save = new EventEmitter<Agent>(); @Output() save = new EventEmitter<Agent>();
@@ -23,6 +24,33 @@ export class AgentForm {
limits: AgentLimit[] = []; limits: AgentLimit[] = [];
profils = [
{
label: "Caissier",
value: "CAISSIER"
},
{
label: "Agent",
value: "AGENT"
},
{
label: "Superviseur",
value: "SUPERVISEUR"
},
]
statutOptions = [
{
label: "Actif",
value: "ACTIF"
},
{
label: "Inactif",
value: "INACTIF"
}
]
private _value?: Agent; private _value?: Agent;
@Input() set value(v: Agent | undefined) { this._value = v; this.hydrateFromValue(v); } @Input() set value(v: Agent | undefined) { this._value = v; this.hydrateFromValue(v); }
get value(): Agent | undefined { return this._value; } get value(): Agent | undefined { return this._value; }
@@ -33,25 +61,46 @@ export class AgentForm {
constructor(private fb: FormBuilder, private limitService: AgentLimitService) { constructor(private fb: FormBuilder, private limitService: AgentLimitService) {
this.form = this.fb.group({ this.form = this.fb.group({
code: ['', Validators.required], code: ['', Validators.required],
profil: ['', Validators.required], profil: ['CAISSIER', Validators.required],
statut: ['ACTIF' as AgentStatus, Validators.required], principalCode: ['', Validators.required],
caisseProfile: ['', Validators.required],
statut: ['ACTIF', Validators.required],
zone: [''], zone: [''],
kiosk: [''], kiosk: [''],
fonction: [''], fonction: [''],
dateEmbauche: [''], dateEmbauche: [''],
nom: ['', Validators.required], nom: ['', Validators.required],
prenom: ['', Validators.required], prenom: ['', Validators.required],
phone: ['', [Validators.required, Validators.minLength(6)]], autresNoms: [''],
dateNaissance: [''],
lieuNaissance: [''],
ville: [''],
adresse: [''],
autoriserAides: [false],
limiteInferieure: [0, [Validators.min(0)]], phone: ['', Validators.required],
limiteSuperieure: [0, [Validators.min(0)]], pin: [''],
limiteParTransaction: [0, [Validators.min(0)]],
limiteMinAirtime: [0, [Validators.min(0)]], limiteInferieure: [''],
limiteMaxAirtime: [0, [Validators.min(0)]], limiteSuperieure: [''],
maxPeripheriques: [0, [Validators.min(0)]], limiteParTransaction: [''],
limiteMinAirtime: [''],
limiteMaxAirtime: [''],
maxPeripheriques: [''],
limitId: ['1'],
nationalite: [''],
cni: [''],
cniDelivreeLe: [''],
cniDelivreeA: [''],
residence: [''],
autreAdresse1: [''],
statutMarital: [''],
epoux: [''],
autreTelephone: [''],
limitId: ['', Validators.required],
}); });
this.limitService this.limitService
@@ -59,29 +108,123 @@ export class AgentForm {
.subscribe((res) => (this.limits = res.content)); .subscribe((res) => (this.limits = res.content));
} }
error(control: string): string { errorMessage(control: string): string {
const e = this.form.get(control)?.errors; if (!e) return ''; if (e['required']) return 'Requis'; return ''; const e = this.form.get(control)?.errors; if (!e) return ''; if (e['required']) return 'Requis'; return '';
} }
private hydrateFromValue(v?: Agent) { private hydrateFromValue(v?: Agent) {
if (!v) { if (!v) {
this.form.reset({ this.form.reset({
code: '', profil: '', statut: 'ACTIF', zone: '', kiosk: '', fonction: '', dateEmbauche: '', nom: '', prenom: '', phone: '', limiteInferieure: 0, limiteSuperieure: 0, limiteParTransaction: 0, limiteMinAirtime: 0, limiteMaxAirtime: 0, maxPeripheriques: 0, limitId: '', statut: 'ACTIF',
autoriserAides: false,
}); });
this.submitted = false;
return; return;
} }
console.log(v);
this.form.reset({ this.form.reset({
code: v.code, profil: v.profil, statut: v.statut, zone: v.zone ?? '', kiosk: v.kiosk ?? '', fonction: v.fonction ?? '', dateEmbauche: v.dateEmbauche ?? '', nom: v.nom, prenom: v.prenom, phone: v.phone, limiteInferieure: v.limiteInferieure ?? 0, limiteSuperieure: v.limiteSuperieure ?? 0, limiteParTransaction: v.limiteParTransaction ?? 0, limiteMinAirtime: v.limiteMinAirtime ?? 0, limiteMaxAirtime: v.limiteMaxAirtime ?? 0, maxPeripheriques: v.maxPeripheriques ?? 0, limitId: v.limitId ?? '', code: v.code || '',
profil: v.profil || '',
principalCode: v.principalCode || '',
caisseProfile: v.caisseProfile || '',
statut: v.statut || 'ACTIF',
zone: v.zone || '',
kiosk: v.kiosk || '',
fonction: v.fonction || '',
dateEmbauche: v.dateEmbauche || '',
nom: v.nom || '',
prenom: v.prenom || '',
autresNoms: v.autresNoms || '',
dateNaissance: v.dateNaissance || '',
lieuNaissance: v.lieuNaissance || '',
ville: v.ville || '',
adresse: v.adresse || '',
autoriserAides: v.autoriserAides ?? false,
phone: v.phone || '',
pin: v.pin || '',
limiteInferieure: v.limiteInferieure || '',
limiteSuperieure: v.limiteSuperieure || '',
limiteParTransaction: v.limiteParTransaction || '',
limiteMinAirtime: v.limiteMinAirtime || '',
limiteMaxAirtime: v.limiteMaxAirtime || '',
maxPeripheriques: v.maxPeripheriques || '',
limitId: v.limitId || '',
nationalite: v.nationalite || '',
cni: v.cni || '',
cniDelivreeLe: v.cniDelivreeLe || '',
cniDelivreeA: v.cniDelivreeA || '',
residence: v.residence || '',
autreAdresse1: v.autreAdresse1 || '',
statutMarital: v.statutMarital || '',
epoux: v.epoux || '',
autreTelephone: v.autreTelephone || '',
}); });
this.submitted = false;
} }
onSubmit() { onSubmit() {
this.submitted = true; this.submitted = true;
if (this.form.invalid) { this.form.markAllAsTouched(); return; } if (this.form.invalid) {
this.form.markAllAsTouched(); return;
}
const raw = this.form.getRawValue() as any; const raw = this.form.getRawValue() as any;
const payload: Agent = { id: this.value?.id ?? '', code: raw.code, profil: raw.profil, statut: raw.statut, zone: raw.zone, kiosk: raw.kiosk, fonction: raw.fonction, dateEmbauche: raw.dateEmbauche, nom: raw.nom, prenom: raw.prenom, phone: raw.phone, limiteInferieure: +raw.limiteInferieure, limiteSuperieure: +raw.limiteSuperieure, limiteParTransaction: +raw.limiteParTransaction, limiteMinAirtime: +raw.limiteMinAirtime, limiteMaxAirtime: +raw.limiteMaxAirtime, maxPeripheriques: +raw.maxPeripheriques, limitId: raw.limitId }; const payload: Agent = {
id: this.value?.id ?? '',
code: raw.code,
profil: raw.profil,
statut: raw.statut,
principalCode: raw.principalCode || '',
caisseProfile: raw.caisseProfile || '',
zone: raw.zone || '',
kiosk: raw.kiosk || '',
fonction: raw.fonction || '',
dateEmbauche: raw.dateEmbauche || '',
nom: raw.nom,
prenom: raw.prenom,
autresNoms: raw.autresNoms || '',
dateNaissance: raw.dateNaissance || '',
lieuNaissance: raw.lieuNaissance || '',
ville: raw.ville || '',
adresse: raw.adresse || '',
autoriserAides: raw.autoriserAides ?? false,
phone: raw.phone,
pin: raw.pin || '',
limiteInferieure: +raw.limiteInferieure || 0,
limiteSuperieure: +raw.limiteSuperieure || 0,
limiteParTransaction: +raw.limiteParTransaction || 0,
limiteMinAirtime: +raw.limiteMinAirtime || 0,
limiteMaxAirtime: +raw.limiteMaxAirtime || 0,
maxPeripheriques: +raw.maxPeripheriques || 0,
limitId: raw.limitId || '1',
// Légales
nationalite: raw.nationalite || '',
cni: raw.cni || '',
cniDelivreeLe: raw.cniDelivreeLe || '',
cniDelivreeA: raw.cniDelivreeA || '',
residence: raw.residence || '',
autreAdresse1: raw.autreAdresse1 || '',
statutMarital: raw.statutMarital || '',
epoux: raw.epoux || '',
autreTelephone: raw.autreTelephone || ''
};
this.save.emit(payload); this.save.emit(payload);
} }
onClose(){
this.cancel.emit()
}
} }

View File

@@ -1,5 +1,5 @@
<form class="space-y-6" (ngSubmit)="onSubmit()" [formGroup]="form"> <form class="space-y-6" (ngSubmit)="onSubmit()" [formGroup]="form">
<div class="grid grid-cols-1 xl:grid-cols-2 gap-4"> <div class="grid grid-cols-1 xl:grid-cols-3 gap-4">
<z-card class="p-4 space-y-3"> <z-card class="p-4 space-y-3">
<div class="text-lg font-semibold">Informations Emploi</div> <div class="text-lg font-semibold">Informations Emploi</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3"> <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
@@ -10,8 +10,8 @@
></z-form-field> ></z-form-field>
<z-form-field <z-form-field
><label z-form-label>Profil</label> ><label z-form-label>Profil</label>
<div z-form-control [errorMessage]="error('profil') || ''"> <div z-form-control [errorMessage]="error('profile') || ''">
<input z-input formControlName="profil" /></div <input z-input formControlName="profile" /></div
></z-form-field> ></z-form-field>
<z-form-field class="md:col-span-2" <z-form-field class="md:col-span-2"
><label z-form-label>Agent Principal</label> ><label z-form-label>Agent Principal</label>
@@ -94,6 +94,88 @@
></z-form-field> ></z-form-field>
</div> </div>
</z-card> </z-card>
<z-card class="p-4 space-y-3">
<div class="text-lg font-semibold">Paramètres de connexion</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<z-form-field
><label z-form-label>N° Téléphone</label>
<div z-form-control [errorMessage]="error('phone') || ''">
<input z-input formControlName="phone" /></div
></z-form-field>
<z-form-field
><label z-form-label>PIN</label>
<div z-form-control><input z-input formControlName="pin" type="password" /></div
></z-form-field>
<z-form-field
><label z-form-label>Limite inférieure</label>
<div z-form-control>
<input z-input type="number" formControlName="limiteInferieure" /></div
></z-form-field>
<z-form-field
><label z-form-label>Limite supérieure</label>
<div z-form-control>
<input z-input type="number" formControlName="limiteSuperieure" /></div
></z-form-field>
<z-form-field
><label z-form-label>Limite / transaction</label>
<div z-form-control>
<input z-input type="number" formControlName="limiteParTransaction" /></div
></z-form-field>
<z-form-field
><label z-form-label>Limite min airtime</label>
<div z-form-control>
<input z-input type="number" formControlName="limiteMinAirtime" /></div
></z-form-field>
<z-form-field
><label z-form-label>Limite max airtime</label>
<div z-form-control>
<input z-input type="number" formControlName="limiteMaxAirtime" /></div
></z-form-field>
<z-form-field class="md:col-span-2"
><label z-form-label>Groupe de limites</label>
<div z-form-control [errorMessage]="error('limitId') || ''">
<z-select formControlName="limitId" [zPlaceholder]="'Sélectionner...'">
@for (l of limits; track l.id) {
<z-select-item [zValue]="l.id">
{{ l.nom }}
@if (l.isDefault) {
<span class="text-xs text-primary ml-1">(Par défaut)</span>
} @if (l.actif) {
<span class="text-xs text-green-600 dark:text-green-400 ml-1">• Actif</span>
}
</z-select-item>
}
</z-select>
</div>
@if (selectedLimit) {
<div class="mt-2 p-3 bg-accent rounded text-sm space-y-1">
<div class="font-medium">{{ selectedLimit.nom }}</div>
<div class="text-muted-foreground text-xs">
@if (selectedLimit.isDefault) {
<span class="inline-flex items-center gap-1"
><i class="icon-star size-3"></i> Limite par défaut</span
>
} @if (selectedLimit.actif) {
<span class="inline-flex items-center gap-1 ml-2"
><i class="icon-check size-3"></i> Contrôle actif</span
>
} @else {
<span class="inline-flex items-center gap-1 ml-2"
><i class="icon-x size-3"></i> Contrôle inactif</span
>
}
</div>
<div class="text-xs text-muted-foreground mt-2">
<div>Min Bet: {{ (selectedLimit.betMin ?? 0).toLocaleString('fr-FR') }}</div>
<div>Max Bet: {{ (selectedLimit.betMax ?? 0).toLocaleString('fr-FR') }}</div>
<div>Max Bet (tx): {{ (selectedLimit.maxBet ?? 0).toLocaleString('fr-FR') }}</div>
</div>
</div>
}
</z-form-field>
</div>
</z-card>
</div> </div>
<div class="grid grid-cols-1 xl:grid-cols-2 gap-4"> <div class="grid grid-cols-1 xl:grid-cols-2 gap-4">
@@ -138,56 +220,14 @@
></z-form-field> ></z-form-field>
</div> </div>
</z-card> </z-card>
<z-card class="p-4 space-y-3">
<div class="text-lg font-semibold">Paramètres de connexion</div>
<div class="flex flex-col gap-y-3 mb-3">
<z-form-field
><label z-form-label>N° Téléphone</label>
<div z-form-control [errorMessage]="error('phone') || ''">
<input z-input formControlName="phone" /></div
></z-form-field>
<z-form-field
><label z-form-label>PIN</label>
<div z-form-control><input z-input formControlName="pin" type="password" /></div
></z-form-field>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<z-form-field
><label z-form-label>Limite inférieure</label>
<div z-form-control>
<input z-input type="number" formControlName="limiteInferieure" /></div
></z-form-field>
<z-form-field
><label z-form-label>Limite supérieure</label>
<div z-form-control>
<input z-input type="number" formControlName="limiteSuperieure" /></div
></z-form-field>
<z-form-field
><label z-form-label>Limite / transaction</label>
<div z-form-control>
<input z-input type="number" formControlName="limiteParTransaction" /></div
></z-form-field>
<z-form-field
><label z-form-label>Limite min airtime</label>
<div z-form-control>
<input z-input type="number" formControlName="limiteMinAirtime" /></div
></z-form-field>
<z-form-field
><label z-form-label>Limite max airtime</label>
<div z-form-control>
<input z-input type="number" formControlName="limiteMaxAirtime" /></div
></z-form-field>
</div>
</z-card>
<!-- <z-card class="p-4 space-y-4"> <z-card class="p-4 space-y-4">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<div class="text-lg font-semibold">Membres de famille</div> <div class="text-lg font-semibold">Membres de famille</div>
<z-button zType="default" (click)="addFamily()"> <z-button zType="default" (click)="addFamily()">
<i class="icon-plus mr-2"></i>Ajouter un membre <i class="icon-plus mr-2"></i>Ajouter un membre
</z-button> </z-button>
</div> </div>
@if (!compact) {
<div class="space-y-3" formArrayName="famille"> <div class="space-y-3" formArrayName="famille">
@for (fm of famille.controls; track $index; let i = $index) { @for (fm of famille.controls; track $index; let i = $index) {
<div <div
@@ -267,13 +307,62 @@
</div> </div>
} }
</div> </div>
} @else { </z-card>
<div class="text-sm text-muted">Les membres de la famille sont gérés depuis la fiche agent après la création.</div>
}
</z-card> -->
</div> </div>
<!-- TPE assignment UI removed from this form --> <z-card class="p-4 space-y-3">
<div class="flex items-center justify-between">
<div class="text-lg font-semibold">Assignation TPE (actifs)</div>
<z-button zType="ghost" (click)="openAssignTpe()"
><i class="icon-plus mr-2"></i>Gérer</z-button
>
</div>
<div class="flex flex-wrap gap-2">
@for (id of tpeArray.value; track id) {
<span class="px-2 py-1 rounded bg-accent text-sm flex items-center gap-2">
{{ tpeLabel(id) }}
<button z-button zType="ghost" zSize="sm" (click)="onToggleTpe(id, false)">
<i class="icon-x"></i>
</button>
</span>
} @empty {
<span class="text-muted-foreground text-sm">Aucun TPE assigné</span>
}
</div>
</z-card>
<!-- TPE assignment modal removed from this form; TPE assignment is handled elsewhere --> <app-modal
[open]="assignModalOpen"
[title]="'Assigner des TPE'"
(close)="closeAssignTpe()"
size="xxl"
>
<app-search-bar
placeholder="Rechercher par IMEI, marque, modèle..."
(search)="onTpeSearch($event)"
></app-search-bar>
<app-data-table [data]="tpeRows" [columns]="tpeCols" [sort]="{ key: 'imei', dir: 'asc' }">
<ng-template #rowActions let-row>
@if (!isSelectedTpe(row.id)) {
<button z-button zType="ghost" (click)="onToggleTpe(row.id, true)">
<i class="icon-plus"></i>
</button>
} @else {
<button z-button zType="destructive" (click)="onToggleTpe(row.id, false)">
<i class="icon-trash"></i>
</button>
}
</ng-template>
</app-data-table>
<app-paginator
[total]="tpeTotal"
[page]="tpePage"
[perPage]="tpePerPage"
(pageChange)="onTpePageChange($event)"
(perPageChange)="onTpePerPageChange($event)"
></app-paginator>
<div modal-actions class="flex justify-end gap-2">
<z-button zType="default" (click)="closeAssignTpe()">Terminer</z-button>
</div>
</app-modal>
</form> </form>

View File

@@ -7,12 +7,16 @@ import { ZardSelectComponent } from '@shared/components/select/select.component'
import { ZardSelectItemComponent } from '@shared/components/select/select-item.component'; import { ZardSelectItemComponent } from '@shared/components/select/select-item.component';
import { ZardButtonComponent } from '@shared/components/button/button.component'; import { ZardButtonComponent } from '@shared/components/button/button.component';
import { ZardCardComponent } from '@shared/components/card/card.component'; import { ZardCardComponent } from '@shared/components/card/card.component';
// DataTable removed from this form (TPE UI was removed) 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 { Agent, AgentFamilyMember } from 'src/app/core/interfaces/agent'; import { Agent, AgentFamilyMember } from 'src/app/core/interfaces/agent';
import { AgentLimit } from 'src/app/core/interfaces/agent-limit'; import { AgentLimit } from 'src/app/core/interfaces/agent-limit';
import { AgentLimitService } from 'src/app/core/services/agent-limit'; import { AgentLimitService } from 'src/app/core/services/agent-limit';
import { AgentFamilyMemberService } from 'src/app/core/services/agent-family-member'; import { AgentFamilyMemberService } from 'src/app/core/services/agent-family-member';
// TPE assignment removed from form (handled elsewhere) import { TpeDevice } from 'src/app/core/interfaces/tpe';
import { TpeService } from 'src/app/core/services/tpe';
@Component({ @Component({
selector: 'app-agent-full-form', selector: 'app-agent-full-form',
@@ -26,18 +30,36 @@ import { AgentFamilyMemberService } from 'src/app/core/services/agent-family-mem
ZardInputDirective, ZardInputDirective,
ZardSelectComponent, ZardSelectComponent,
ZardSelectItemComponent, ZardSelectItemComponent,
ZardButtonComponent,
ZardCardComponent, ZardCardComponent,
// TPE UI components removed from this form DataTable,
Paginator,
SearchBar,
Modal,
], ],
}) })
export class AgentFullForm { export class AgentFullForm {
@Output() save = new EventEmitter<Partial<Agent>>(); @Output() save = new EventEmitter<Agent>();
@Output() cancel = new EventEmitter<void>(); @Output() cancel = new EventEmitter<void>();
@Input() compact = false; // when true, hide family and TPE sections for a shorter creation form
limits: AgentLimit[] = []; limits: AgentLimit[] = [];
selectedLimit: AgentLimit | null = null; selectedLimit: AgentLimit | null = null;
// TPE assignment removed from the creation form; handled in separate flows tpes: TpeDevice[] = [];
// TPE picker state
assignModalOpen = false;
tpeRows: TpeDevice[] = [];
tpeTotal = 0;
tpePage = 1;
tpePerPage = 10;
tpeSearch = '';
tpeCols: TableColumn<TpeDevice>[] = [
{ key: 'imei', label: 'IMEI', sortable: true },
{ key: 'serial', label: 'N° Série', sortable: true },
{ key: 'marque', label: 'Marque', sortable: true },
{ key: 'modele', label: 'Modèle', sortable: true },
{ key: 'type', label: 'Type', sortable: true },
{ key: 'statut', label: 'Statut', sortable: true },
];
private _value?: Agent; private _value?: Agent;
@Input() set value(v: Agent | undefined) { this._value = v; this.hydrate(v); } @Input() set value(v: Agent | undefined) { this._value = v; this.hydrate(v); }
@@ -49,19 +71,21 @@ export class AgentFullForm {
constructor( constructor(
private fb: FormBuilder, private fb: FormBuilder,
private limitService: AgentLimitService, private limitService: AgentLimitService,
private tpeService: TpeService,
private familyMemberService: AgentFamilyMemberService private familyMemberService: AgentFamilyMemberService
) { ) {
this.form = this.fb.group({ this.form = this.fb.group({
// Emploi // Emploi
code: ['', Validators.required], profil: ['', Validators.required], principalCode: [''], caisseProfile: [''], statut: ['ACTIF', Validators.required], zone: [''], kiosk: [''], fonction: [''], dateEmbauche: [''], code: ['', Validators.required], profile: ['', Validators.required], principalCode: [''], caisseProfile: [''], statut: ['ACTIF', Validators.required], zone: [''], kiosk: [''], fonction: [''], dateEmbauche: [''],
// Perso // Perso
nom: ['', Validators.required], prenom: ['', Validators.required], autresNoms: [''], dateNaissance: [''], lieuNaissance: [''], adresse: [''], ville: [''], autoriserAides: [false], maxPeripheriques: [0, [Validators.min(0)]], nom: ['', Validators.required], prenom: ['', Validators.required], autresNoms: [''], dateNaissance: [''], lieuNaissance: [''], adresse: [''], ville: [''], autoriserAides: [false], maxPeripheriques: [0, [Validators.min(0)]],
// Connexion & limites // Connexion & limites
phone: ['', Validators.required], pin: [''], limiteInferieure: [0], limiteSuperieure: [0], limiteParTransaction: [0], limiteMinAirtime: [0], limiteMaxAirtime: [0], limitId: ['1'], phone: ['', Validators.required], pin: [''], limiteInferieure: [0], limiteSuperieure: [0], limiteParTransaction: [0], limiteMinAirtime: [0], limiteMaxAirtime: [0], limitId: ['', Validators.required],
// Légales // Légales
nationalite: [''], cni: [''], cniDelivreeLe: [''], cniDelivreeA: [''], residence: [''], autreAdresse1: [''], statutMarital: [''], epoux: [''], autreTelephone: [''], nationalite: [''], cni: [''], cniDelivreeLe: [''], cniDelivreeA: [''], residence: [''], autreAdresse1: [''], statutMarital: [''], epoux: [''], autreTelephone: [''],
// Famille // Famille
famille: this.fb.array([]), famille: this.fb.array([]),
// TPE (stored as IDs in form, converted to full objects on submit)
}); });
this.limitService.list({ page: 1, perPage: 100, search: '', sortKey: 'nom', sortDir: 'asc' } as any).subscribe((res) => { this.limitService.list({ page: 1, perPage: 100, search: '', sortKey: 'nom', sortDir: 'asc' } as any).subscribe((res) => {
@@ -89,14 +113,14 @@ export class AgentFullForm {
this.selectedLimit = defaultLimit; this.selectedLimit = defaultLimit;
} }
}); });
// TPE fetching/assignment handled outside of this form // initial fetch page for TPE modal
this.fetchTpes();
} }
get famille(): FormArray { return this.form.get('famille') as FormArray; } get famille(): FormArray { return this.form.get('famille') as FormArray; }
get tpeArray(): FormArray { return this.form.get('tpeIds') as FormArray; }
addFamily() { addFamily() {
if (this.compact) return; // no family in compact mode
this.famille.push( this.famille.push(
this.fb.group({ this.fb.group({
id: [''], // Will be set when saved id: [''], // Will be set when saved
@@ -118,6 +142,7 @@ export class AgentFullForm {
private hydrate(v?: Agent) { private hydrate(v?: Agent) {
this.famille.clear(); this.famille.clear();
this.tpeArray.clear();
this.selectedLimit = null; this.selectedLimit = null;
if (!v) { if (!v) {
@@ -127,7 +152,7 @@ export class AgentFullForm {
this.form.reset({ this.form.reset({
code: '', code: '',
profil: '', profile: '',
principalCode: '', principalCode: '',
caisseProfile: '', caisseProfile: '',
statut: 'ACTIF', statut: 'ACTIF',
@@ -200,10 +225,59 @@ export class AgentFullForm {
}); });
} }
// Assigned TPEs are not handled in this form
} }
// TPE assignment / picker removed from this form onToggleTpe(id: string, checked: boolean) {
const idx = this.tpeArray.value.indexOf(id);
if (checked && idx === -1) this.tpeArray.push(this.fb.control(id));
if (!checked && idx !== -1) this.tpeArray.removeAt(idx);
}
openAssignTpe() {
this.assignModalOpen = true;
this.fetchTpes();
}
closeAssignTpe() {
this.assignModalOpen = false;
// Refresh TPE list to show current assignments
this.fetchTpes();
}
onTpeSearch(q: string) { this.tpeSearch = q; this.tpePage = 1; this.fetchTpes(); }
onTpePageChange(p: number) { this.tpePage = p; this.fetchTpes(); }
onTpePerPageChange(pp: number) { this.tpePerPage = pp; this.fetchTpes(); }
isSelectedTpe(id: string) { return (this.tpeArray.value as string[]).includes(id); }
fetchTpes() {
this.tpeService
.list({ page: this.tpePage, perPage: this.tpePerPage, search: this.tpeSearch, sortKey: 'imei', sortDir: 'asc' } as any)
.subscribe((res) => {
// Only show VALIDE TPEs that are either not assigned or assigned to this agent
const currentAgentId = this.value?.id;
this.tpeRows = res.content.filter((t) => {
if (t.statut !== 'ACTIF') return false;
// If TPE is assigned but to this agent, show it
if (t.agentConnecteId && currentAgentId) {
// We need to check if this TPE is assigned to this agent
// For now, show all VALIDE TPEs - the backend should handle assignment logic
return true;
}
// Show unassigned TPEs
return !t.agentConnecteId;
});
this.tpeTotal = this.tpeRows.length;
});
}
tpeLabel(id: string): string {
const list = [...(this.tpeRows ?? []), ...(this.tpes ?? [])];
const found = list.find((t) => t.id === id);
if (found) {
return `${found.numeroSerie}`;
}
// Try to load from service if not in current list
return id;
}
// === Validation helpers === // === Validation helpers ===
error(control: string): string { error(control: string): string {
@@ -237,13 +311,23 @@ export class AgentFullForm {
return; return;
} }
const raw = this.form.getRawValue() as any; const raw = this.form.getRawValue() as any;
// Prepare partial agent payload (family members are handled separately)
const payload: Partial<Agent> = {
...raw,
...(this.value?.id ? { id: this.value.id } : {}),
};
// Emit the partial agent payload (parent will decide create vs update) // Convert TPE IDs to full TPE object
const tpes: TpeDevice[] = [];
// Prepare agent payload (without famille - family members are handled separately)
const payload: Agent = {
...(this.value ?? {}),
id: this.value?.id || '',
...raw,
tpes: tpes,
} as Agent;
// Remove tpeIds from payload (it's not part of Agent interface)
delete (payload as any).tpeIds;
// Emit the agent payload first
// Family members will be saved separately in the parent component
this.save.emit(payload); this.save.emit(payload);
} }
@@ -263,6 +347,7 @@ export class AgentFullForm {
this._value = undefined; this._value = undefined;
this.selectedLimit = null; this.selectedLimit = null;
this.famille.clear(); this.famille.clear();
this.tpeArray.clear();
// Find default limit to assign it automatically // Find default limit to assign it automatically
const defaultLimit = this.limits.find((l) => l.isDefault); const defaultLimit = this.limits.find((l) => l.isDefault);
@@ -270,7 +355,7 @@ export class AgentFullForm {
this.form.reset({ this.form.reset({
code: '', code: '',
profil: '', profile: '',
principalCode: '', principalCode: '',
caisseProfile: '', caisseProfile: '',
statut: 'ACTIF', statut: 'ACTIF',

View File

@@ -25,6 +25,7 @@
> >
<z-select <z-select
id="hippodromeId" id="hippodromeId"
[zDisabled]="getCourseStatut()!==undefined && getCourseStatut() !=='BROUILLON'"
placeholder="Rechercher une réunion..." placeholder="Rechercher une réunion..."
formControlName="hippodromeId" formControlName="hippodromeId"
[zLabel]="selectedHippodromeLabel() || ''" [zLabel]="selectedHippodromeLabel() || ''"
@@ -43,6 +44,7 @@
> >
<z-form-control> <z-form-control>
<input <input
[readOnly]="getCourseStatut()!==undefined && getCourseStatut() !=='BROUILLON'"
z-input z-input
type="number" type="number"
min="1" min="1"
@@ -63,6 +65,7 @@
> >
<z-form-control> <z-form-control>
<input <input
[readOnly]="getCourseStatut()!==undefined && getCourseStatut() !=='BROUILLON'"
z-input z-input
type="date" type="date"
placeholder="Ex: 10/07/2025" placeholder="Ex: 10/07/2025"
@@ -82,6 +85,7 @@
> >
<z-form-control> <z-form-control>
<input <input
[readOnly]="getCourseStatut()!==undefined && getCourseStatut() !=='BROUILLON'"
z-input z-input
placeholder="Ex: Plat" placeholder="Ex: Plat"
formControlName="discipline" formControlName="discipline"
@@ -101,6 +105,7 @@
> >
<z-form-control> <z-form-control>
<input <input
[readOnly]="getCourseStatut()!==undefined && getCourseStatut() !=='BROUILLON'"
z-input z-input
type="text" type="text"
placeholder="Ex: Grand Prix du Sahel" placeholder="Ex: Grand Prix du Sahel"
@@ -145,6 +150,7 @@
</label> </label>
<div class="flex gap-3 items-center"> <div class="flex gap-3 items-center">
<input <input
[readOnly]="getCourseStatut()!==undefined && getCourseStatut() !=='BROUILLON'"
z-input z-input
type="time" type="time"
formControlName="heureDepartPrevue" formControlName="heureDepartPrevue"
@@ -184,10 +190,11 @@
@for (t of courseTypes; track t.value) { @for (t of courseTypes; track t.value) {
<label class="flex items-center gap-2 text-sm"> <label class="flex items-center gap-2 text-sm">
<input <input
[disabled]="getCourseStatut()!==undefined && getCourseStatut() !=='BROUILLON'"
type="checkbox" type="checkbox"
[value]="t.value" [value]="t.value"
(change)="onToggleType($event)" (change)="onToggleType($event)"
[checked]="(form.get('typesParisOuverts')?.value || []).includes(t.value)" [checked]="form.value.typesParisOuverts?.includes(t.value)"
/> />
{{ t.label }} {{ t.label }}
</label> </label>
@@ -208,6 +215,7 @@
" "
> >
<input <input
[readOnly]="getCourseStatut()!==undefined && getCourseStatut() !=='BROUILLON'"
z-input z-input
type="number" type="number"
min="1" min="1"
@@ -223,6 +231,7 @@
> >
<z-form-control> <z-form-control>
<input <input
[readOnly]="getCourseStatut()!==undefined && getCourseStatut() !=='BROUILLON'"
z-input z-input
type="number" type="number"
min="1" min="1"
@@ -238,6 +247,7 @@
> >
<z-form-control> <z-form-control>
<input <input
[readOnly]="getCourseStatut()!==undefined && getCourseStatut() !=='BROUILLON'"
z-input z-input
placeholder="Ex: A" placeholder="Ex: A"
formControlName="categorie" formControlName="categorie"
@@ -267,7 +277,7 @@
>Statut</label >Statut</label
> >
<z-form-control> <z-form-control>
<z-select formControlName="statut" class="w-full"> <z-select [zDisabled]="updateDisabled()" formControlName="statut" class="w-full">
@for (s of courseStatus; track s.value) { @for (s of courseStatus; track s.value) {
<z-select-item [zValue]="s.value">{{ s.label }}</z-select-item> <z-select-item [zValue]="s.value">{{ s.label }}</z-select-item>
} }
@@ -281,6 +291,7 @@
> >
<z-form-control> <z-form-control>
<input <input
[readOnly]="getCourseStatut()!==undefined && getCourseStatut() !=='BROUILLON'"
z-input z-input
type="number" type="number"
min="1" min="1"
@@ -314,6 +325,7 @@
> >
<z-form-control> <z-form-control>
<input <input
readOnly
z-input z-input
formControlName="createdBy" formControlName="createdBy"
readonly readonly
@@ -328,6 +340,7 @@
> >
<z-form-control> <z-form-control>
<input <input
readOnly
z-input z-input
formControlName="validatedBy" formControlName="validatedBy"
readonly readonly

View File

@@ -60,6 +60,20 @@ export class CourseForm implements OnInit, AfterViewInit, OnDestroy {
return this._value; return this._value;
} }
getCourseStatut():string|undefined{
return this._value?.statut
}
updateDisabled():boolean{
const statut = this._value?.statut;
if(statut !== undefined){
if(statut === 'OUVERT' || statut === 'FERME'){
return true;
}
}
return false;
}
form: FormGroup; form: FormGroup;
submitted = false; submitted = false;
@@ -216,8 +230,6 @@ export class CourseForm implements OnInit, AfterViewInit, OnDestroy {
this.selectedHippodromeLabel.set(''); this.selectedHippodromeLabel.set('');
this.form.markAsPristine(); this.form.markAsPristine();
this.form.markAsUntouched(); this.form.markAsUntouched();
// Ensure UI updates for cleared form
this.cdr.markForCheck();
return; return;
} }
@@ -248,9 +260,6 @@ export class CourseForm implements OnInit, AfterViewInit, OnDestroy {
{ emitEvent: false } { emitEvent: false }
); );
// Ensure view updates when hydrating values (OnPush component)
this.cdr.markForCheck();
// Set hippodrome label if available // Set hippodrome label if available
if (hippodromeId && this.hippodromes().length > 0) { if (hippodromeId && this.hippodromes().length > 0) {
const h = this.hippodromes().find((r) => String(r.id) === hippodromeId); const h = this.hippodromes().find((r) => String(r.id) === hippodromeId);
@@ -279,9 +288,6 @@ export class CourseForm implements OnInit, AfterViewInit, OnDestroy {
? [...current, value] ? [...current, value]
: current.filter((v: string) => v !== value) : current.filter((v: string) => v !== value)
}); });
// Trigger change detection so checkbox states update in OnPush mode
this.cdr.markForCheck();
} }
@@ -300,6 +306,7 @@ onSubmit() {
const foundHippodrome = this.hippodromes().find(h => String(h.id) === String(hippodromeId)); const foundHippodrome = this.hippodromes().find(h => String(h.id) === String(hippodromeId));
const hippodromeObj = foundHippodrome ?? (hippodromeId ? { id: +hippodromeId } : undefined); const hippodromeObj = foundHippodrome ?? (hippodromeId ? { id: +hippodromeId } : undefined);
// 2⃣ Transformer typesParisOuverts CSV → tablea // 2⃣ Transformer typesParisOuverts CSV → tablea
// 3⃣ Construire payload // 3⃣ Construire payload
@@ -325,8 +332,6 @@ onSubmit() {
}; };
// Persist: create or update via CourseService, then emit the saved Course // Persist: create or update via CourseService, then emit the saved Course
if (this.value && this.value.id) { if (this.value && this.value.id) {
this.courseServive.update(this.value.id, payload).subscribe({ this.courseServive.update(this.value.id, payload).subscribe({
@@ -334,8 +339,7 @@ onSubmit() {
if (updated) this.save.emit(updated); if (updated) this.save.emit(updated);
else console.error('Update returned empty result'); else console.error('Update returned empty result');
}, },
error: (err) => { error: (err) => console.error('Error updating course:', err),
console.error('Error updating course:', err)},
}); });
} else { } else {
this.courseServive.create(payload).subscribe({ this.courseServive.create(payload).subscribe({

View File

@@ -0,0 +1,60 @@
<form class="space-y-4" (ngSubmit)="onSubmit()" [formGroup]="form">
<div 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]="error('nom') || ''">
<input z-input formControlName="nom" placeholder="Nom du point de vente" />
</div>
</z-form-field>
<z-form-field>
<label z-form-label>Ville</label>
<div z-form-control [errorMessage]="error('ville') || ''">
<input z-input formControlName="ville" placeholder="Ville" />
</div>
</z-form-field>
<z-form-field class="md:col-span-2">
<label z-form-label>Adresse</label>
<div z-form-control [errorMessage]="error('adresse') || ''">
<input z-input formControlName="adresse" placeholder="Adresse complète" />
</div>
</z-form-field>
<z-form-field>
<label z-form-label>Latitude</label>
<div z-form-control [errorMessage]="error('latitude') || ''">
<input
z-input
type="number"
step="any"
formControlName="latitude"
placeholder="0.000000"
/>
</div>
</z-form-field>
<z-form-field>
<label z-form-label>Longitude</label>
<div z-form-control [errorMessage]="error('longitude') || ''">
<input
z-input
type="number"
step="any"
formControlName="longitude"
placeholder="0.000000"
/>
</div>
</z-form-field>
<z-form-field>
<label z-form-label>Statut</label>
<div z-form-control [errorMessage]="error('statut') || ''">
<z-select formControlName="statut">
<z-select-item zValue="ACTIVE">Actif</z-select-item>
<z-select-item zValue="INACTIVE">Inactif</z-select-item>
</z-select>
</div>
</z-form-field>
</div>
</form>

View File

@@ -0,0 +1,115 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { ZardFormModule } from '@shared/components/form/form.module';
import { ZardInputDirective } from '@shared/components/input/input.directive';
import { ZardSelectComponent } from '@shared/components/select/select.component';
import { ZardSelectItemComponent } from '@shared/components/select/select-item.component';
import { PointVente, PointVenteStatut } from 'src/app/core/interfaces/points-ventes';
@Component({
selector: 'app-point-vente-form',
standalone: true,
templateUrl: './point-vente-form.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
ReactiveFormsModule,
ZardFormModule,
ZardInputDirective,
ZardSelectComponent,
ZardSelectItemComponent,
],
})
export class PointVenteForm {
@Output() save = new EventEmitter<Partial<PointVente>>();
@Output() cancel = new EventEmitter<void>();
private _value?: PointVente;
@Input() set value(v: PointVente | undefined) {
this._value = v;
this.hydrate(v);
}
get value(): PointVente | undefined {
return this._value;
}
form: FormGroup;
submitted = false;
constructor(private fb: FormBuilder) {
this.form = this.fb.group({
nom: ['', Validators.required],
adresse: ['', Validators.required],
ville: ['', Validators.required],
latitude: [0, [Validators.required, Validators.min(-90), Validators.max(90)]],
longitude: [0, [Validators.required, Validators.min(-180), Validators.max(180)]],
statut: ['ACTIVE', Validators.required],
});
}
error(control: string): string {
const c = this.form.get(control);
if (!c) return '';
const invalid = c.invalid && (c.touched || this.submitted);
if (!invalid) return '';
const e = c.errors || {};
if (e['required']) return 'Ce champ est obligatoire';
if (e['min']) return `Valeur minimale: ${e['min'].min}`;
if (e['max']) return `Valeur maximale: ${e['max'].max}`;
return 'Valeur invalide';
}
private hydrate(v?: PointVente) {
if (!v) {
this.form.reset({
nom: '',
adresse: '',
ville: '',
latitude: 0,
longitude: 0,
statut: 'ACTIVE',
});
this.submitted = false;
return;
}
this.form.reset({
nom: v.nom,
adresse: v.adresse,
ville: v.ville,
latitude: v.latitude ?? 0,
longitude: v.longitude ?? 0,
statut: v.statut ?? 'ACTIVE',
});
this.submitted = false;
}
onSubmit() {
this.submitted = true;
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
const raw = this.form.getRawValue() as any;
const payload: Partial<PointVente> = {
...raw,
latitude: Number(raw.latitude),
longitude: Number(raw.longitude),
...(this.value?.id ? { id: this.value.id } : {}),
};
this.save.emit(payload);
}
resetForm() {
this._value = undefined;
this.form.reset({
nom: '',
adresse: '',
ville: '',
latitude: 0,
longitude: 0,
statut: 'ACTIVE',
});
this.submitted = false;
}
}

View File

@@ -0,0 +1,65 @@
<form class="space-y-4" (ngSubmit)="onSubmit()" [formGroup]="form">
<div 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]="error('nom') || ''">
<input z-input formControlName="nom" placeholder="Nom du point de vente" />
</div>
</z-form-field>
<z-form-field>
<label z-form-label>Ville</label>
<div z-form-control [errorMessage]="error('ville') || ''">
<input z-input formControlName="ville" placeholder="Ville" />
</div>
</z-form-field>
<z-form-field >
<label z-form-label>Adresse</label>
<div z-form-control [errorMessage]="error('adresse') || ''">
<input z-input formControlName="adresse" placeholder="Adresse complète" />
</div>
</z-form-field>
<z-form-field>
<label z-form-label>Code</label>
<div z-form-control [errorMessage]="error('code') || ''">
<input z-input formControlName="code" placeholder="Code du point de vente" />
</div>
</z-form-field>
<z-form-field>
<label z-form-label>Latitude</label>
<div z-form-control [errorMessage]="error('latitude') || ''">
<input
z-input
type="number"
step="any"
formControlName="latitude"
placeholder="0.000000"
/>
</div>
</z-form-field>
<z-form-field>
<label z-form-label>Longitude</label>
<div z-form-control [errorMessage]="error('longitude') || ''">
<input
z-input
type="number"
step="any"
formControlName="longitude"
placeholder="0.000000"
/>
</div>
</z-form-field>
<z-form-field>
<label z-form-label>Statut</label>
<div z-form-control [errorMessage]="error('statut') || ''">
<z-select formControlName="statut">
<z-select-item *ngFor="let statut of pointsVenteStatuts" [zValue]="statut.value">{{ statut.label }}</z-select-item>
</z-select>
</div>
</z-form-field>
</div>
</form>

View File

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

View File

@@ -0,0 +1,126 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { ZardFormModule } from '@shared/components/form/form.module';
import { ZardInputDirective } from '@shared/components/input/input.directive';
import { ZardSelectComponent } from '@shared/components/select/select.component';
import { ZardSelectItemComponent } from '@shared/components/select/select-item.component';
import { PointVente, PointVenteStatut } from 'src/app/core/interfaces/points-ventes';
@Component({
selector: 'app-points-vente-form',
standalone: true,
templateUrl: './points-vente-form.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
ReactiveFormsModule,
ZardFormModule,
ZardInputDirective,
ZardSelectComponent,
ZardSelectItemComponent,
],
})
export class PointsVenteForm {
@Output() save = new EventEmitter<Partial<PointVente>>();
@Output() cancel = new EventEmitter<void>();
pointsVenteStatuts = [{
label: 'Actif',
value: 'ACTIF'
},
{
label: 'Inactif',
value: 'INACTIF'
}
]
private _value?: PointVente;
@Input() set value(v: PointVente | undefined) {
this._value = v;
this.hydrate(v);
}
get value(): PointVente | undefined {
return this._value;
}
form: FormGroup;
submitted = false;
constructor(private fb: FormBuilder) {
this.form = this.fb.group({
nom: ['', Validators.required],
adresse: ['', Validators.required],
code: ['', Validators.required],
ville: ['', Validators.required],
latitude: [0, [Validators.required, Validators.min(-90), Validators.max(90)]],
longitude: [0, [Validators.required, Validators.min(-180), Validators.max(180)]],
statut: ['', Validators.required],
});
}
error(control: string): string {
const c = this.form.get(control);
if (!c) return '';
const invalid = c.invalid && (c.touched || this.submitted);
if (!invalid) return '';
const e = c.errors || {};
if (e['required']) return 'Ce champ est obligatoire';
if (e['min']) return `Valeur minimale: ${e['min'].min}`;
if (e['max']) return `Valeur maximale: ${e['max'].max}`;
return 'Valeur invalide';
}
private hydrate(v?: PointVente) {
if (!v) {
this.form.reset({
nom: '',
adresse: '',
ville: '',
latitude: 0,
longitude: 0,
statut: 'ACTIF',
});
this.submitted = false;
return;
}
this.form.reset({
nom: v.nom,
adresse: v.adresse,
ville: v.ville,
latitude: v.latitude ?? 0,
longitude: v.longitude ?? 0,
statut: v.statut ?? 'ACTIF',
});
this.submitted = false;
}
onSubmit() {
this.submitted = true;
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
const raw = this.form.getRawValue() as any;
const payload: Partial<PointVente> = {
...raw,
latitude: Number(raw.latitude),
longitude: Number(raw.longitude),
...(this.value?.id ? { id: this.value.id } : {}),
};
this.save.emit(payload);
}
resetForm() {
this._value = undefined;
this.form.reset({
nom: '',
adresse: '',
ville: '',
latitude: 0,
longitude: 0,
statut: 'ACTIVE',
});
this.submitted = false;
}
}

View File

@@ -21,19 +21,18 @@
[disabled]="!canSave()"> [disabled]="!canSave()">
Enregistrer Enregistrer
</button> </button>
<button <!-- <button
type="button" type="button"
class="flex-1 px-3 py-2 text-sm rounded border border-blue-300 bg-blue-50 text-blue-700 hover:bg-blue-100 disabled:opacity-50" class="flex-1 px-3 py-2 text-sm rounded border border-blue-300 bg-blue-50 text-blue-700 hover:bg-blue-100 disabled:opacity-50"
(click)="validate.emit()" (click)="validate.emit()"
[disabled]="!canValidate()"> [disabled]="!canValidate()">
Valider Valider
</button> </button> -->
<button <button
type="button" type="button"
class="flex-1 px-3 py-2 text-sm rounded border border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 disabled:opacity-50" class="flex-1 px-3 py-2 text-sm rounded border border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 disabled:opacity-50"
(click)="confirm.emit()" (click)="confirm.emit()"
[disabled]="!canConfirm()" [disabled]="!canConfirm()">
>
Confirmer Confirmer
</button> </button>
</div> </div>

View File

@@ -61,16 +61,16 @@ export class ResultatForm {
maxNum = computed(() => this.course?.nombrePartants ?? 0); maxNum = computed(() => this.course?.nombrePartants ?? 0);
// Ensure non-partants are compared as strings to avoid type mismatches // Ensure non-partants are compared as strings to avoid type mismatches
npSet = computed(() => new Set((this.course?.nonPartants ?? []).map((v) => String(v)))); npSet = computed(() => new Set((this.course?.nonPartants ?? []).map((v) => String(v))));
statut = computed((): ResultatStatut => { statut = computed((): 'PROVISOIRE' | 'OFFICIEL' | 'ANNULE' | 'EN_ATTENTE' => {
return this.resultat ? this.resultat.statut : ResultatStatut.EN_ATTENTE; return this.resultat ? 'PROVISOIRE' : 'EN_ATTENTE';
}); });
canValidate(): boolean { // canValidate(): boolean {
return String(this.statut()) === "EN_ATTENTE"; // return this.statut() === 'PROVISOIRE';
} // }
canConfirm(): boolean { canConfirm(): boolean {
return String(this.statut()) === "PROVISOIRE"; return this.statut() === 'PROVISOIRE' || this.statut() === 'OFFICIEL';
} }
// Helper methods for template // Helper methods for template
@@ -228,6 +228,7 @@ export class ResultatForm {
}); });
ngOnInit() { ngOnInit() {
this.seed(); this.seed();
// Watch for changes to auto-populate places when ex-aequo is detected // Watch for changes to auto-populate places when ex-aequo is detected
this.setupAutoPopulate(); this.setupAutoPopulate();
@@ -549,6 +550,10 @@ export class ResultatForm {
} }
} }
if(this.statut()==='OFFICIEL'){
return false;
}
return true; return true;
} }

View File

@@ -1,60 +1,109 @@
<form class="space-y-4" (ngSubmit)="onSubmit()" [formGroup]="form"> <form class="space-y-4" [formGroup]="form" (ngSubmit)="onSubmit()">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<z-form-field> <z-form-field>
<label z-form-label>IMEI</label> <label z-form-label>Numéro de série</label>
<div z-form-control [errorMessage]="errorMessage('imei') || ''"> <div z-form-control [errorMessage]="errorMessage('numeroSerie') || ''">
<input z-input formControlName="imei" placeholder="IMEI" /> <input z-input formControlName="numeroSerie" placeholder="Numéro de série" />
</div> </div>
</z-form-field> </z-form-field>
<z-form-field> <z-form-field>
<label z-form-label>N° de Série</label> <label z-form-label>Type de terminal</label>
<div z-form-control [errorMessage]="errorMessage('serial') || ''"> <div z-form-control [errorMessage]="errorMessage('typeTerminal') || ''">
<input z-input formControlName="serial" placeholder="Numéro de série" /> <input z-input formControlName="typeTerminal" zPlaceholder="Sélectionner..."/>
</div> </div>
</z-form-field> </z-form-field>
<z-form-field> <z-form-field>
<label z-form-label>Type</label> <label z-form-label>Plateforme</label>
<div z-form-control [errorMessage]="errorMessage('type') || ''"> <div z-form-control [errorMessage]="errorMessage('plateforme') || ''">
<z-select formControlName="type" [zPlaceholder]="'Sélectionner...'"> <input z-input formControlName="plateforme"/>
@for (t of types; track t.value) { </div>
<z-select-item [zValue]="t.value">{{ t.label }}</z-select-item> </z-form-field>
<z-form-field>
<label z-form-label>Modèle appareil</label>
<div z-form-control [errorMessage]="errorMessage('modeleAppareil') || ''">
<input z-input formControlName="modeleAppareil" placeholder="Modèle" />
</div>
</z-form-field>
<z-form-field>
<label z-form-label>Système dexploitation</label>
<div z-form-control [errorMessage]="errorMessage('systemeExploitation') || ''">
<z-select formControlName="systemeExploitation">
@for (os of osList; track os) {
<z-select-item [zValue]="os">{{ os }}</z-select-item>
} }
</z-select> </z-select>
</div> </div>
</z-form-field> </z-form-field>
<z-form-field>
<label z-form-label>Version OS</label>
<div z-form-control [errorMessage]="errorMessage('versionOs') || ''">
<input z-input formControlName="versionOs" placeholder="Ex: 12.0" />
</div>
</z-form-field>
<z-form-field>
<label z-form-label>Version logicielle</label>
<div z-form-control [errorMessage]="errorMessage('versionLogicielle') || ''">
<input z-input formControlName="versionLogicielle" placeholder="Ex: v3.2.1" />
</div>
</z-form-field>
<z-form-field>
<label z-form-label>Adresse IP</label>
<div z-form-control [errorMessage]="errorMessage('adresseIp') || ''">
<input z-input formControlName="adresseIp" placeholder="192.168.1.10" />
</div>
</z-form-field>
<z-form-field>
<label z-form-label>Adresse MAC</label>
<div z-form-control [errorMessage]="errorMessage('adresseMac') || ''">
<input z-input formControlName="adresseMac" placeholder="AA:BB:CC:DD:EE:FF" />
</div>
</z-form-field>
<z-form-field>
<label z-form-label>Point de vente</label>
<input [value]="pointDeVenteText()" (blur)="onClose()" (input)="pointDeVenteTextChange($event)" z-input placeholder="Entrez le nom du point de vente"/>
<div z-form-control [errorMessage]="errorMessage('pointDeVenteId') || ''">
@if (pointDeVenteLoading()){
<div class="flex items-center justify-center">
<div
class="w-6 h-6 border-2 border-gray-300 border-t-blue-600 rounded-full animate-spin"
></div>
</div>
}
@if (pointsDevente.length > 0 && !pointDeVenteLoading()) {
<ul class="mt-1 border rounded-lg bg-white dark:bg-indigo-950 shadow max-h-60 overflow-auto">
@for (pdv of pointsDevente; track pdv.id) {
<li
class="px-3 py-2 cursor-pointer hover:bg-blue-500"
(mousedown)="selectPointDeVente(pdv)">
{{ pdv.nom }} / {{ pdv.code }}
</li>
}
</ul>
}
</div>
</z-form-field>
<z-form-field> <z-form-field>
<label z-form-label>Statut</label> <label z-form-label>Statut</label>
<div z-form-control> <div z-form-control [errorMessage]="errorMessage('statut') || ''">
<z-select formControlName="statut" [zPlaceholder]="'Sélectionner...'"> <z-select formControlName="statut">
@for (s of allStatuses; track s) { @for (s of statuts; track s) {
<z-select-item [zValue]="s">{{ s }}</z-select-item> <z-select-item [zValue]="s.value">{{ s.label }}</z-select-item>
} }
</z-select> </z-select>
</div> </div>
</z-form-field> </z-form-field>
<z-form-field>
<label z-form-label>Assigné</label>
<div z-form-control>
<input type="checkbox" formControlName="assigne" />
</div>
</z-form-field>
<z-form-field>
<label z-form-label>Marque</label>
<div z-form-control [errorMessage]="errorMessage('marque') || ''">
<input z-input formControlName="marque" placeholder="Marque" />
</div>
</z-form-field>
<z-form-field>
<label z-form-label>Modèle</label>
<div z-form-control [errorMessage]="errorMessage('modele') || ''">
<input z-input formControlName="modele" placeholder="Modèle" />
</div>
</z-form-field>
</div> </div>
</form> </form>

View File

@@ -1,11 +1,14 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; import { ChangeDetectionStrategy, Component, effect, EventEmitter, Input, Output, signal } from '@angular/core';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { ZardFormModule } from '@shared/components/form/form.module'; import { ZardFormModule } from '@shared/components/form/form.module';
import { ZardInputDirective } from '@shared/components/input/input.directive'; import { ZardInputDirective } from '@shared/components/input/input.directive';
import { ZardSelectComponent } from '@shared/components/select/select.component'; import { ZardSelectComponent } from '@shared/components/select/select.component';
import { ZardSelectItemComponent } from '@shared/components/select/select-item.component'; import { ZardSelectItemComponent } from '@shared/components/select/select-item.component';
import { TpeDevice, TpeStatus, TpeType } from 'src/app/core/interfaces/tpe'; import { TpeDevice, TpeType } from 'src/app/core/interfaces/tpe';
import { PointsVenteService } from 'src/app/core/services/points-vente';
import { ListParams } from '@shared/paging/paging';
import { PointVente } from 'src/app/core/interfaces/points-ventes';
@Component({ @Component({
selector: 'app-tpe-form', selector: 'app-tpe-form',
@@ -27,6 +30,13 @@ export class TpeForm {
private _value?: TpeDevice; private _value?: TpeDevice;
private _skipHydration = false; private _skipHydration = false;
pointDeVenteText = signal('');
pointDeVenteLoading = signal<boolean>(false);
@Input() set value(v: TpeDevice | undefined) { @Input() set value(v: TpeDevice | undefined) {
this._value = v; this._value = v;
if (!this._skipHydration) { if (!this._skipHydration) {
@@ -37,21 +47,87 @@ export class TpeForm {
return this._value; return this._value;
} }
osList = ['Windows', 'Linux', 'Mac Os'];
pointsDevente:PointVente[] = [];
statuts = [
{
label: 'Actif',
value: 'ACTIF'
},
{
label: 'Hors service',
value: 'HORS_SERVICE'
}
]
form: FormGroup; form: FormGroup;
submitted = false; submitted = false;
constructor(private fb: FormBuilder) { constructor(private fb: FormBuilder, private pointsVenteService: PointsVenteService) {
this.form = this.fb.group({ this.form = this.fb.group({
imei: ['', [Validators.required, Validators.minLength(10)]], numeroSerie: ['', [Validators.required, Validators.minLength(10)]],
serial: ['', Validators.required], pointDeVenteId: ['', Validators.required],
type: ['POS' as TpeType, Validators.required], statut: ['', Validators.required],
marque: ['', Validators.required], versionLogicielle: ['', Validators.required],
modele: ['', Validators.required], typeTerminal: ['', Validators.required],
statut: ['VALIDE' as TpeStatus, Validators.required], plateforme: ['', Validators.required],
assigne: [false], modeleAppareil: ['', Validators.required],
systemeExploitation: ['', Validators.required],
versionOs: ['', Validators.required],
adresseIp: ['', Validators.required],
adresseMac: ['', Validators.required],
agentConnecteId: ['1'],
journalSession: ['1'],
}); });
} }
pointDeVenteTextChange =(event: Event)=>{
const value = (event.target as HTMLInputElement).value;
this.pointDeVenteText.set(value);
const text = this.pointDeVenteText();
const params: ListParams = {
page: 0,
size: 10,
search: text
}
this.getPointDeventeFromText(text, params);
}
getPointDeventeFromText=(text: string, params: ListParams)=>{
if(text.length < 2) return;
this.pointDeVenteLoading.set(true);
this.pointsVenteService.list(params).subscribe({
next: res =>{
this.pointsDevente = res.content;
this.pointDeVenteLoading.set(false);
},
error: err =>{
this.pointDeVenteLoading.set(false);
return console.error(err);
}
})
}
selectPointDeVente =(pvt:PointVente):void=>{
this.form.patchValue({
pointDeVenteId: pvt.id
})
this.pointsDevente = [];
this.pointDeVenteText.set(`${pvt.nom}/${pvt.code} `)
}
onClose(){
this.pointsDevente = [];
}
isInvalid(control: string): boolean { isInvalid(control: string): boolean {
const c = this.form.get(control); const c = this.form.get(control);
return !!(c && c.invalid && (c.touched || this.submitted)); return !!(c && c.invalid && (c.touched || this.submitted));
@@ -67,25 +143,37 @@ export class TpeForm {
private hydrateFromValue(v?: TpeDevice) { private hydrateFromValue(v?: TpeDevice) {
if (!v) { if (!v) {
this.form.reset({ this.form.reset({
imei: '', numeroSerie: '',
serial: '', pointDeVenteId: 0,
type: 'POS', statut: 'ACTIF',
statut: 'VALIDE', versionLogicielle: '',
assigne: false, typeTerminal: '',
marque: '', plateforme: '',
modele: '', modeleAppareil: '',
systemeExploitation: '',
versionOs: '',
adresseIp: '',
adresseMac: '',
agentConnecteId: '',
journalSession: '',
}); });
this.submitted = false; // Reset submitted flag when form is cleared this.submitted = false; // Reset submitted flag when form is cleared
return; return;
} }
this.form.reset({ this.form.reset({
imei: v.imei, numeroSerie: v.numeroSerie,
serial: v.serial, pointDeVenteId: v.pointDeVenteId,
type: v.type, statut: 'ACTIF',
statut: v.statut, versionLogicielle: v.versionLogicielle,
assigne: !!v.assigne, typeTerminal: v.typeTerminal,
marque: v.marque, plateforme: v.plateforme,
modele: v.modele, modeleAppareil: v.modeleAppareil,
systemeExploitation: v.systemeExploitation,
versionOs: v.versionOs,
adresseIp: v.adresseIp,
adresseMac: v.adresseMac,
agentConnecteId: v.agentConnecteId,
journalSession: v.journalSession,
}); });
} }
@@ -95,20 +183,16 @@ export class TpeForm {
this.form.markAllAsTouched(); this.form.markAllAsTouched();
return; return;
} }
const raw = this.form.getRawValue() as any; const raw = this.form.getRawValue() as Partial<TpeDevice>;
const payload: Partial<TpeDevice> = { const payload: Partial<TpeDevice> = {
imei: raw.imei, ...raw
serial: raw.serial,
type: raw.type,
statut: raw.statut,
assigne: !!raw.assigne,
marque: raw.marque,
modele: raw.modele,
}; };
// Preserve existing id, statut, and assigne if editing // Preserve existing id, statut, and assigne if editing
if (this.value?.id) { if (this.value?.id) {
payload.id = this.value.id; payload.id = this.value.id;
} }
this.save.emit(payload as TpeDevice); this.save.emit(payload as TpeDevice);
} }
@@ -116,11 +200,19 @@ export class TpeForm {
this._skipHydration = true; // Prevent hydration when clearing value this._skipHydration = true; // Prevent hydration when clearing value
this._value = undefined; this._value = undefined;
this.form.reset({ this.form.reset({
imei: '', numeroSerie: '',
serial: '', pointDeVenteId: 0,
type: 'POS', statut: 'ACTIF',
marque: '', versionLogicielle: '',
modele: '', typeTerminal: '',
plateforme: '',
modeleAppareil: '',
systemeExploitation: '',
versionOs: '',
adresseIp: '',
adresseMac: '',
agentConnecteId: '',
journalSession: '',
}); });
this.submitted = false; this.submitted = false;
this._skipHydration = false; // Re-enable hydration this._skipHydration = false; // Re-enable hydration
@@ -130,16 +222,4 @@ export class TpeForm {
{ label: 'POS', value: 'POS' as TpeType }, { label: 'POS', value: 'POS' as TpeType },
{ label: 'Autre', value: 'OTHER' as TpeType }, { label: 'Autre', value: 'OTHER' as TpeType },
]; ];
allStatuses: TpeStatus[] = [
'VALIDE',
'INVALIDE',
'EN_PANNE',
'BLOQUE',
'DISPONIBLE',
'AFFECTE',
'EN_MAINTENANCE',
'HORS_SERVICE',
'VOLE',
];
} }

View File

@@ -1,5 +1,5 @@
export const environment = { export const environment = {
production: false, production: false,
apiBaseUrl: 'https://cuddly-years-work.loca.lt', apiBaseUrl: 'http://192.168.1.235:8381',
depouillementBaseUrl: 'http://192.168.1.235:8383' depouillementBaseUrl: 'http://192.168.1.235:8383'
}; };