first commit
This commit is contained in:
236
src/app/core/services/agent-family-member.ts
Normal file
236
src/app/core/services/agent-family-member.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { map, catchError } from 'rxjs/operators';
|
||||
import { AgentFamilyMember } from '../interfaces/agent';
|
||||
import { environment } from 'src/environments/environment.development';
|
||||
|
||||
const USE_SERVER = true;
|
||||
const API_BASE = '/api/v1/agent-family-members';
|
||||
|
||||
// Interface to match the API response structure
|
||||
interface AgentFamilyMemberApiResponse {
|
||||
id: number;
|
||||
agentId: number;
|
||||
nom: string;
|
||||
statut?: string;
|
||||
dateNaissance?: string;
|
||||
sexe?: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AgentFamilyMemberService {
|
||||
private apiUrl = environment.apiBaseUrl + API_BASE;
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
// Helper method to get ngrok bypass headers
|
||||
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' } : {};
|
||||
}
|
||||
|
||||
// Transform API response to AgentFamilyMember
|
||||
private transformMember(apiMember: AgentFamilyMemberApiResponse): AgentFamilyMember {
|
||||
return {
|
||||
id: String(apiMember.id),
|
||||
agentId: String(apiMember.agentId),
|
||||
nom: apiMember.nom,
|
||||
statut: apiMember.statut,
|
||||
dateNaissance: apiMember.dateNaissance,
|
||||
sexe: apiMember.sexe as 'M' | 'F' | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper method to convert date string to LocalDateTime format (YYYY-MM-DDTHH:mm:ss)
|
||||
private formatDateForApi(dateStr: string | undefined): string | undefined {
|
||||
if (!dateStr) return undefined;
|
||||
// If already in ISO format with time, return as is
|
||||
if (dateStr.includes('T') || dateStr.includes(' ')) {
|
||||
return dateStr;
|
||||
}
|
||||
// If only date (YYYY-MM-DD), add time component
|
||||
if (dateStr.match(/^\d{4}-\d{2}-\d{2}$/)) {
|
||||
return `${dateStr}T00:00:00`;
|
||||
}
|
||||
return dateStr;
|
||||
}
|
||||
|
||||
// Transform AgentFamilyMember to API payload
|
||||
private transformToApiPayload(member: Partial<AgentFamilyMember>): any {
|
||||
const payload: any = {};
|
||||
if (member.agentId !== undefined) payload.agentId = Number(member.agentId);
|
||||
if (member.nom !== undefined) payload.nom = member.nom;
|
||||
if (member.statut !== undefined) payload.statut = member.statut;
|
||||
if (member.dateNaissance !== undefined) payload.dateNaissance = this.formatDateForApi(member.dateNaissance);
|
||||
if (member.sexe !== undefined) payload.sexe = member.sexe;
|
||||
return payload;
|
||||
}
|
||||
|
||||
// GET /api/v1/agent-family-members/{id} - Get by ID
|
||||
getById(id: string): Observable<AgentFamilyMember | undefined> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<AgentFamilyMemberApiResponse>(`${this.apiUrl}/${id}`, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map((apiMember) => this.transformMember(apiMember)),
|
||||
catchError((err) => {
|
||||
console.error(`Error fetching agent family member ${id}:`, err);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(undefined);
|
||||
}
|
||||
|
||||
// GET /api/v1/agent-family-members - List all
|
||||
list(): Observable<AgentFamilyMember[]> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<AgentFamilyMemberApiResponse[]>(this.apiUrl, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map((list) => list.map((apiMember) => this.transformMember(apiMember))),
|
||||
catchError((err) => {
|
||||
console.error('Error fetching agent family members:', err);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of([]);
|
||||
}
|
||||
|
||||
// POST /api/v1/agent-family-members - Create
|
||||
create(payload: Omit<AgentFamilyMember, 'id'>): Observable<AgentFamilyMember> {
|
||||
if (USE_SERVER) {
|
||||
const apiPayload = this.transformToApiPayload(payload);
|
||||
return this.http
|
||||
.post<AgentFamilyMemberApiResponse>(this.apiUrl, apiPayload, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map((apiMember) => this.transformMember(apiMember)),
|
||||
catchError((err) => {
|
||||
console.error('Error creating agent family member:', err);
|
||||
throw err;
|
||||
})
|
||||
);
|
||||
}
|
||||
throw new Error('Server mode is required');
|
||||
}
|
||||
|
||||
// PUT /api/v1/agent-family-members/{id} - Update
|
||||
update(id: string, payload: Partial<AgentFamilyMember>): Observable<AgentFamilyMember | undefined> {
|
||||
if (USE_SERVER) {
|
||||
const apiPayload = this.transformToApiPayload(payload);
|
||||
return this.http
|
||||
.put<AgentFamilyMemberApiResponse>(`${this.apiUrl}/${id}`, apiPayload, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map((apiMember) => this.transformMember(apiMember)),
|
||||
catchError((err) => {
|
||||
console.error(`Error updating agent family member ${id}:`, err);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(undefined);
|
||||
}
|
||||
|
||||
// DELETE /api/v1/agent-family-members/{id} - Delete
|
||||
delete(id: string): Observable<boolean> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.delete<void>(`${this.apiUrl}/${id}`, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map(() => true),
|
||||
catchError((err) => {
|
||||
console.error(`Error deleting agent family member ${id}:`, err);
|
||||
return of(false);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(false);
|
||||
}
|
||||
|
||||
// GET /api/v1/agent-family-members/statut/{statut} - List by statut
|
||||
getByStatut(statut: string): Observable<AgentFamilyMember[]> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<AgentFamilyMemberApiResponse[]>(`${this.apiUrl}/statut/${statut}`, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map((list) => list.map((apiMember) => this.transformMember(apiMember))),
|
||||
catchError((err) => {
|
||||
console.error(`Error fetching agent family members by statut ${statut}:`, err);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of([]);
|
||||
}
|
||||
|
||||
// GET /api/v1/agent-family-members/sexe/{sexe} - List by sexe
|
||||
getBySexe(sexe: 'M' | 'F'): Observable<AgentFamilyMember[]> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<AgentFamilyMemberApiResponse[]>(`${this.apiUrl}/sexe/${sexe}`, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map((list) => list.map((apiMember) => this.transformMember(apiMember))),
|
||||
catchError((err) => {
|
||||
console.error(`Error fetching agent family members by sexe ${sexe}:`, err);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of([]);
|
||||
}
|
||||
|
||||
// GET /api/v1/agent-family-members/search - Search by keyword
|
||||
search(query: string): Observable<AgentFamilyMember[]> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<AgentFamilyMemberApiResponse[]>(`${this.apiUrl}/search`, {
|
||||
params: { q: query.trim() },
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
map((list) => list.map((apiMember) => this.transformMember(apiMember))),
|
||||
catchError((err) => {
|
||||
console.error(`Error searching agent family members with query ${query}:`, err);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of([]);
|
||||
}
|
||||
|
||||
// GET /api/v1/agent-family-members/nom/{nom} - List by nom
|
||||
getByNom(nom: string): Observable<AgentFamilyMember[]> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<AgentFamilyMemberApiResponse[]>(`${this.apiUrl}/nom/${nom}`, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map((list) => list.map((apiMember) => this.transformMember(apiMember))),
|
||||
catchError((err) => {
|
||||
console.error(`Error fetching agent family members by nom ${nom}:`, err);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of([]);
|
||||
}
|
||||
|
||||
// Get family members by agentId (filter from list)
|
||||
getByAgentId(agentId: string): Observable<AgentFamilyMember[]> {
|
||||
if (USE_SERVER) {
|
||||
return this.list().pipe(
|
||||
map((list) => list.filter((member) => member.agentId === agentId)),
|
||||
catchError((err) => {
|
||||
console.error(`Error fetching agent family members by agentId ${agentId}:`, err);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of([]);
|
||||
}
|
||||
}
|
||||
|
||||
335
src/app/core/services/agent-limit.ts
Normal file
335
src/app/core/services/agent-limit.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable, of, forkJoin } from 'rxjs';
|
||||
import { map, catchError, switchMap } from 'rxjs/operators';
|
||||
import { AgentLimit } from '../interfaces/agent-limit';
|
||||
import { environment } from 'src/environments/environment.development';
|
||||
import { normalizePage } from '@shared/paging/normalize-page';
|
||||
import { ListParams, PagedResult } from '@shared/paging/paging';
|
||||
import { AgentService } from './agent';
|
||||
|
||||
const USE_SERVER = true;
|
||||
const API_BASE = '/api/v1/agent-limits';
|
||||
|
||||
// Interface to match the API response structure
|
||||
interface AgentLimitApiResponse {
|
||||
id: number;
|
||||
code: string;
|
||||
configCode: string;
|
||||
nom: string;
|
||||
isDefault: boolean;
|
||||
actif: boolean;
|
||||
betMin?: number;
|
||||
betMax?: number;
|
||||
maxBet?: number;
|
||||
maxDisburseBet?: number;
|
||||
airtimeMin?: number;
|
||||
airtimeMax?: number;
|
||||
createdAt?: string;
|
||||
createdBy?: string;
|
||||
default?: boolean;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AgentLimitService {
|
||||
private apiUrl = environment.apiBaseUrl + API_BASE;
|
||||
|
||||
constructor(private http: HttpClient, private agentService: AgentService) {}
|
||||
|
||||
// Helper method to get ngrok bypass headers
|
||||
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' } : {};
|
||||
}
|
||||
|
||||
// Transform API response to AgentLimit
|
||||
private transformLimit(apiLimit: AgentLimitApiResponse): AgentLimit {
|
||||
return {
|
||||
id: String(apiLimit.id),
|
||||
code: apiLimit.code,
|
||||
configCode: apiLimit.configCode,
|
||||
nom: apiLimit.nom,
|
||||
isDefault: apiLimit.isDefault ?? apiLimit.default ?? false,
|
||||
actif: apiLimit.actif,
|
||||
betMin: apiLimit.betMin,
|
||||
betMax: apiLimit.betMax,
|
||||
maxBet: apiLimit.maxBet,
|
||||
maxDisburseBet: apiLimit.maxDisburseBet,
|
||||
airtimeMin: apiLimit.airtimeMin,
|
||||
airtimeMax: apiLimit.airtimeMax,
|
||||
createdAt: apiLimit.createdAt,
|
||||
createdBy: apiLimit.createdBy,
|
||||
};
|
||||
}
|
||||
|
||||
// Transform AgentLimit to API payload
|
||||
private transformToApiPayload(limit: Partial<AgentLimit>): any {
|
||||
const payload: any = {};
|
||||
if (limit.code !== undefined) payload.code = limit.code;
|
||||
if (limit.configCode !== undefined) payload.configCode = limit.configCode;
|
||||
if (limit.nom !== undefined) payload.nom = limit.nom;
|
||||
if (limit.isDefault !== undefined) {
|
||||
payload.isDefault = limit.isDefault;
|
||||
payload.default = limit.isDefault;
|
||||
}
|
||||
if (limit.actif !== undefined) payload.actif = limit.actif;
|
||||
if (limit.betMin !== undefined) payload.betMin = limit.betMin;
|
||||
if (limit.betMax !== undefined) payload.betMax = limit.betMax;
|
||||
if (limit.maxBet !== undefined) payload.maxBet = limit.maxBet;
|
||||
if (limit.maxDisburseBet !== undefined) payload.maxDisburseBet = limit.maxDisburseBet;
|
||||
if (limit.airtimeMin !== undefined) payload.airtimeMin = limit.airtimeMin;
|
||||
if (limit.airtimeMax !== undefined) payload.airtimeMax = limit.airtimeMax;
|
||||
if (limit.createdBy !== undefined) payload.createdBy = limit.createdBy;
|
||||
return payload;
|
||||
}
|
||||
|
||||
// GET /api/v1/agent-limits/{id} - Get by ID
|
||||
getById(id: string): Observable<AgentLimit | undefined> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<AgentLimitApiResponse>(`${this.apiUrl}/${id}`, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map((apiLimit) => this.transformLimit(apiLimit)),
|
||||
catchError((err) => {
|
||||
console.error(`Error fetching agent limit ${id}:`, err);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(undefined);
|
||||
}
|
||||
|
||||
// GET /api/v1/agent-limits - List all
|
||||
list(params?: ListParams): Observable<PagedResult<AgentLimit>> {
|
||||
if (USE_SERVER) {
|
||||
let httpParams = new HttpParams();
|
||||
if (params) {
|
||||
if (params.page) httpParams = httpParams.set('page', params.page.toString());
|
||||
if (params.perPage) httpParams = httpParams.set('perPage', params.perPage.toString());
|
||||
if (params.search) httpParams = httpParams.set('search', params.search);
|
||||
if (params.sortKey) httpParams = httpParams.set('sortKey', params.sortKey);
|
||||
if (params.sortDir) httpParams = httpParams.set('sortDir', params.sortDir);
|
||||
}
|
||||
|
||||
return this.http
|
||||
.get<AgentLimitApiResponse[]>(this.apiUrl, {
|
||||
params: httpParams,
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
map((list) => {
|
||||
const limits = list.map((apiLimit) => this.transformLimit(apiLimit));
|
||||
// If pagination params provided, return paginated result
|
||||
if (params) {
|
||||
return normalizePage<AgentLimit>(
|
||||
{ data: limits, meta: { total: limits.length } },
|
||||
params.page || 1,
|
||||
params.perPage || 10
|
||||
);
|
||||
}
|
||||
// Otherwise return all as single page
|
||||
return normalizePage<AgentLimit>(
|
||||
{ data: limits, meta: { total: limits.length } },
|
||||
1,
|
||||
limits.length
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error('Error fetching agent limits:', err);
|
||||
return of(normalizePage<AgentLimit>({ data: [], meta: { total: 0 } }, 1, 10));
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(normalizePage<AgentLimit>({ data: [], meta: { total: 0 } }, 1, 10));
|
||||
}
|
||||
|
||||
// POST /api/v1/agent-limits - Create
|
||||
create(payload: Omit<AgentLimit, 'id' | 'createdAt'>): Observable<AgentLimit> {
|
||||
if (USE_SERVER) {
|
||||
const apiPayload = this.transformToApiPayload(payload);
|
||||
return this.http
|
||||
.post<AgentLimitApiResponse>(this.apiUrl, apiPayload, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
switchMap((apiLimit) => {
|
||||
const limit = this.transformLimit(apiLimit);
|
||||
// If this limit is set as default, handle default assignment
|
||||
if (limit.isDefault) {
|
||||
return this.handleDefaultLimitChange(limit.id).pipe(map(() => limit));
|
||||
}
|
||||
return of(limit);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error('Error creating agent limit:', err);
|
||||
throw err;
|
||||
})
|
||||
);
|
||||
}
|
||||
throw new Error('Server mode is required');
|
||||
}
|
||||
|
||||
// PUT /api/v1/agent-limits/{id} - Update
|
||||
update(id: string, payload: Partial<AgentLimit>): Observable<AgentLimit | undefined> {
|
||||
if (USE_SERVER) {
|
||||
// Check if isDefault is being changed to true
|
||||
const isSettingDefault = payload.isDefault === true;
|
||||
const wasDefault = payload.isDefault !== undefined;
|
||||
|
||||
return this.http
|
||||
.put<AgentLimitApiResponse>(`${this.apiUrl}/${id}`, this.transformToApiPayload(payload), {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
switchMap((apiLimit) => {
|
||||
const limit = this.transformLimit(apiLimit);
|
||||
// If this limit is being set as default, handle default assignment
|
||||
if (isSettingDefault) {
|
||||
return this.handleDefaultLimitChange(limit.id).pipe(map(() => limit));
|
||||
}
|
||||
return of(limit);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error(`Error updating agent limit ${id}:`, err);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(undefined);
|
||||
}
|
||||
|
||||
// Helper method to handle default limit changes
|
||||
// When a limit is set as default:
|
||||
// 1. Find the previous default limit and unset it (preserving all other fields)
|
||||
// 2. Assign the new default limit to all agents
|
||||
private handleDefaultLimitChange(newDefaultLimitId: string): Observable<boolean> {
|
||||
// First, find the previous default limit
|
||||
return this.list({
|
||||
page: 1,
|
||||
perPage: 1000,
|
||||
search: '',
|
||||
sortKey: 'code',
|
||||
sortDir: 'asc',
|
||||
} as any).pipe(
|
||||
switchMap((result) => {
|
||||
const limits = result.data;
|
||||
const previousDefault = limits.find((l) => l.isDefault && l.id !== newDefaultLimitId);
|
||||
|
||||
const operations: Observable<any>[] = [];
|
||||
|
||||
// If there's a previous default, unset it while preserving all other fields
|
||||
if (previousDefault) {
|
||||
// Create a payload with all fields from previousDefault, but with isDefault set to false
|
||||
// This ensures we preserve all existing data
|
||||
const updatePayload: Partial<AgentLimit> = {
|
||||
code: previousDefault.code,
|
||||
configCode: previousDefault.configCode,
|
||||
nom: previousDefault.nom,
|
||||
isDefault: false,
|
||||
actif: previousDefault.actif,
|
||||
betMin: previousDefault.betMin,
|
||||
betMax: previousDefault.betMax,
|
||||
maxBet: previousDefault.maxBet,
|
||||
maxDisburseBet: previousDefault.maxDisburseBet,
|
||||
airtimeMin: previousDefault.airtimeMin,
|
||||
airtimeMax: previousDefault.airtimeMax,
|
||||
};
|
||||
|
||||
// Use the update method with the full payload
|
||||
operations.push(
|
||||
this.update(previousDefault.id, updatePayload).pipe(
|
||||
map(() => true),
|
||||
catchError((err) => {
|
||||
console.error(`Error unsetting previous default limit ${previousDefault.id}:`, err);
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Assign the new default limit to all agents
|
||||
operations.push(this.agentService.updateAllAgentsLimitId(newDefaultLimitId));
|
||||
|
||||
return forkJoin(operations).pipe(
|
||||
map(() => true),
|
||||
catchError((err) => {
|
||||
console.error('Error handling default limit change:', err);
|
||||
return of(false);
|
||||
})
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error('Error fetching limits for default change:', err);
|
||||
return of(false);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// DELETE /api/v1/agent-limits/{id} - Delete
|
||||
delete(id: string): Observable<boolean> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.delete<void>(`${this.apiUrl}/${id}`, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map(() => true),
|
||||
catchError((err) => {
|
||||
console.error(`Error deleting agent limit ${id}:`, err);
|
||||
return of(false);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(false);
|
||||
}
|
||||
|
||||
// GET /api/v1/agent-limits/search/{nom} - Search by nom
|
||||
search(query: string): Observable<AgentLimit[]> {
|
||||
if (USE_SERVER) {
|
||||
const searchTerm = encodeURIComponent(query.trim());
|
||||
return this.http
|
||||
.get<AgentLimitApiResponse[]>(`${this.apiUrl}/search/${searchTerm}`, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
map((list) => list.map((apiLimit) => this.transformLimit(apiLimit))),
|
||||
catchError((err) => {
|
||||
console.error(`Error searching agent limits with query ${query}:`, err);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of([]);
|
||||
}
|
||||
|
||||
// GET /api/v1/agent-limits/actif/{actif} - List by actif status
|
||||
getByActif(actif: boolean): Observable<AgentLimit[]> {
|
||||
if (USE_SERVER) {
|
||||
if (actif) {
|
||||
return this.http
|
||||
.get<AgentLimitApiResponse[]>(`${this.apiUrl}/actif`, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
map((list) => list.map((apiLimit) => this.transformLimit(apiLimit))),
|
||||
catchError((err) => {
|
||||
console.error(`Error fetching agent limits by actif ${actif}:`, err);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
} else {
|
||||
return this.http
|
||||
.get<AgentLimitApiResponse[]>(`${this.apiUrl}/inactif`, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
map((list) => list.map((apiLimit) => this.transformLimit(apiLimit))),
|
||||
catchError((err) => {
|
||||
console.error(`Error fetching agent limits by actif ${actif}:`, err);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
return of([]);
|
||||
}
|
||||
}
|
||||
484
src/app/core/services/agent.ts
Normal file
484
src/app/core/services/agent.ts
Normal file
@@ -0,0 +1,484 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable, of, forkJoin } from 'rxjs';
|
||||
import { map, catchError, switchMap } from 'rxjs/operators';
|
||||
import { Agent, AgentStatus } from '../interfaces/agent';
|
||||
import { TpeDevice, TpeStatus, TpeType } from '../interfaces/tpe';
|
||||
import { environment } from 'src/environments/environment.development';
|
||||
import { normalizePage } from '@shared/paging/normalize-page';
|
||||
import { ListParams, PagedResult } from '@shared/paging/paging';
|
||||
|
||||
const USE_SERVER = true;
|
||||
const API_BASE = '/api/v1/agents';
|
||||
|
||||
// Interface to match the API response structure for TPE (nested in Agent)
|
||||
// Note: When TPE is nested in Agent's tpes array, the agent field might be omitted or be a reference
|
||||
interface TpeApiResponse {
|
||||
id: number;
|
||||
imei: string;
|
||||
serial: string;
|
||||
type: string;
|
||||
marque: string;
|
||||
modele: string;
|
||||
statut: string;
|
||||
agent?: any; // Can be Agent object or string reference, we'll handle it in transformTpe
|
||||
assigne: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
// Interface to match the API response structure
|
||||
interface AgentApiResponse {
|
||||
id: number;
|
||||
code: string;
|
||||
profile: string;
|
||||
principalCode?: string;
|
||||
caisseProfile?: string;
|
||||
statut: string;
|
||||
zone?: string;
|
||||
kiosk?: string;
|
||||
fonction?: string;
|
||||
dateEmbauche?: string;
|
||||
nom: string;
|
||||
prenom: string;
|
||||
autresNoms?: string;
|
||||
dateNaissance?: string;
|
||||
lieuNaissance?: string;
|
||||
ville?: string;
|
||||
adresse?: string;
|
||||
autoriserAides?: boolean;
|
||||
phone: string;
|
||||
pin?: string;
|
||||
limiteInferieure?: number;
|
||||
limiteSuperieure?: number;
|
||||
limiteParTransaction?: number;
|
||||
limiteMinAirtime?: number;
|
||||
limiteMaxAirtime?: number;
|
||||
maxPeripheriques?: number;
|
||||
limitId?: number;
|
||||
nationalite?: string;
|
||||
cni?: string;
|
||||
cniDelivreeLe?: string;
|
||||
cniDelivreeA?: string;
|
||||
residence?: string;
|
||||
autreAdresse1?: string;
|
||||
statutMarital?: string;
|
||||
epoux?: string;
|
||||
autreTelephone?: string;
|
||||
tpes?: TpeApiResponse[];
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
createdBy?: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AgentService {
|
||||
private apiUrl = environment.apiBaseUrl + API_BASE;
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
// Helper method to get ngrok bypass headers
|
||||
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' } : {};
|
||||
}
|
||||
|
||||
// Transform API TPE response to TpeDevice
|
||||
private transformTpe(apiTpe: TpeApiResponse): TpeDevice {
|
||||
const transformStatut = (apiStatut: string): TpeStatus => {
|
||||
const upperStatut = apiStatut.toUpperCase() as TpeStatus;
|
||||
const validStatuses: TpeStatus[] = [
|
||||
'VALIDE',
|
||||
'INVALIDE',
|
||||
'EN_PANNE',
|
||||
'BLOQUE',
|
||||
'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)
|
||||
let transformedAgent: Agent | undefined = undefined;
|
||||
if (apiTpe.agent && typeof apiTpe.agent === 'object' && apiTpe.agent.id) {
|
||||
// If agent is a full object, transform it
|
||||
transformedAgent = this.transformAgent(apiTpe.agent as any);
|
||||
}
|
||||
|
||||
return {
|
||||
id: String(apiTpe.id),
|
||||
imei: apiTpe.imei,
|
||||
serial: apiTpe.serial,
|
||||
type: apiTpe.type as TpeType,
|
||||
marque: apiTpe.marque,
|
||||
modele: apiTpe.modele,
|
||||
statut: transformStatut(apiTpe.statut),
|
||||
agent: transformedAgent,
|
||||
assigne: apiTpe.assigne,
|
||||
createdAt: apiTpe.createdAt,
|
||||
updatedAt: apiTpe.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// Transform API response to Agent
|
||||
private transformAgent(apiAgent: AgentApiResponse): Agent {
|
||||
return {
|
||||
id: String(apiAgent.id),
|
||||
code: apiAgent.code,
|
||||
profile: apiAgent.profile,
|
||||
principalCode: apiAgent.principalCode,
|
||||
caisseProfile: apiAgent.caisseProfile,
|
||||
statut: apiAgent.statut as AgentStatus,
|
||||
zone: apiAgent.zone,
|
||||
kiosk: apiAgent.kiosk,
|
||||
fonction: apiAgent.fonction,
|
||||
dateEmbauche: apiAgent.dateEmbauche,
|
||||
nom: apiAgent.nom,
|
||||
prenom: apiAgent.prenom,
|
||||
autresNoms: apiAgent.autresNoms,
|
||||
dateNaissance: apiAgent.dateNaissance,
|
||||
lieuNaissance: apiAgent.lieuNaissance,
|
||||
ville: apiAgent.ville,
|
||||
adresse: apiAgent.adresse,
|
||||
autoriserAides: apiAgent.autoriserAides,
|
||||
phone: apiAgent.phone,
|
||||
pin: apiAgent.pin,
|
||||
limiteInferieure: apiAgent.limiteInferieure,
|
||||
limiteSuperieure: apiAgent.limiteSuperieure,
|
||||
limiteParTransaction: apiAgent.limiteParTransaction,
|
||||
limiteMinAirtime: apiAgent.limiteMinAirtime,
|
||||
limiteMaxAirtime: apiAgent.limiteMaxAirtime,
|
||||
maxPeripheriques: apiAgent.maxPeripheriques,
|
||||
limitId: apiAgent.limitId ? String(apiAgent.limitId) : undefined,
|
||||
nationalite: apiAgent.nationalite,
|
||||
cni: apiAgent.cni,
|
||||
cniDelivreeLe: apiAgent.cniDelivreeLe,
|
||||
cniDelivreeA: apiAgent.cniDelivreeA,
|
||||
residence: apiAgent.residence,
|
||||
autreAdresse1: apiAgent.autreAdresse1,
|
||||
statutMarital: apiAgent.statutMarital,
|
||||
epoux: apiAgent.epoux,
|
||||
autreTelephone: apiAgent.autreTelephone,
|
||||
tpes: apiAgent.tpes?.map((tpe) => {
|
||||
const transformed = this.transformTpe(tpe);
|
||||
return transformed;
|
||||
}),
|
||||
createdAt: apiAgent.createdAt,
|
||||
updatedAt: apiAgent.updatedAt,
|
||||
createdBy: apiAgent.createdBy,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper method to convert date string to LocalDateTime format (YYYY-MM-DDTHH:mm:ss)
|
||||
private formatDateForApi(dateStr: string | undefined): string | undefined {
|
||||
if (!dateStr) return undefined;
|
||||
// If already in ISO format with time, return as is
|
||||
if (dateStr.includes('T') || dateStr.includes(' ')) {
|
||||
return dateStr;
|
||||
}
|
||||
// If only date (YYYY-MM-DD), add time component
|
||||
if (dateStr.match(/^\d{4}-\d{2}-\d{2}$/)) {
|
||||
return `${dateStr}T00:00:00`;
|
||||
}
|
||||
return dateStr;
|
||||
}
|
||||
|
||||
// Transform Agent to API payload
|
||||
private transformToApiPayload(agent: Partial<Agent>): any {
|
||||
const payload: any = {};
|
||||
if (agent.code !== undefined) payload.code = agent.code;
|
||||
if (agent.profile !== undefined) payload.profile = agent.profile;
|
||||
if (agent.principalCode !== undefined) payload.principalCode = agent.principalCode;
|
||||
if (agent.caisseProfile !== undefined) payload.caisseProfile = agent.caisseProfile;
|
||||
if (agent.statut !== undefined) payload.statut = agent.statut;
|
||||
if (agent.zone !== undefined) payload.zone = agent.zone;
|
||||
if (agent.kiosk !== undefined) payload.kiosk = agent.kiosk;
|
||||
if (agent.fonction !== undefined) payload.fonction = agent.fonction;
|
||||
if (agent.dateEmbauche !== undefined)
|
||||
payload.dateEmbauche = this.formatDateForApi(agent.dateEmbauche);
|
||||
if (agent.nom !== undefined) payload.nom = agent.nom;
|
||||
if (agent.prenom !== undefined) payload.prenom = agent.prenom;
|
||||
if (agent.autresNoms !== undefined) payload.autresNoms = agent.autresNoms;
|
||||
if (agent.dateNaissance !== undefined)
|
||||
payload.dateNaissance = this.formatDateForApi(agent.dateNaissance);
|
||||
if (agent.lieuNaissance !== undefined) payload.lieuNaissance = agent.lieuNaissance;
|
||||
if (agent.ville !== undefined) payload.ville = agent.ville;
|
||||
if (agent.adresse !== undefined) payload.adresse = agent.adresse;
|
||||
if (agent.autoriserAides !== undefined) payload.autoriserAides = agent.autoriserAides;
|
||||
if (agent.phone !== undefined) payload.phone = agent.phone;
|
||||
if (agent.pin !== undefined) payload.pin = agent.pin;
|
||||
if (agent.limiteInferieure !== undefined) payload.limiteInferieure = agent.limiteInferieure;
|
||||
if (agent.limiteSuperieure !== undefined) payload.limiteSuperieure = agent.limiteSuperieure;
|
||||
if (agent.limiteParTransaction !== undefined)
|
||||
payload.limiteParTransaction = agent.limiteParTransaction;
|
||||
if (agent.limiteMinAirtime !== undefined) payload.limiteMinAirtime = agent.limiteMinAirtime;
|
||||
if (agent.limiteMaxAirtime !== undefined) payload.limiteMaxAirtime = agent.limiteMaxAirtime;
|
||||
if (agent.maxPeripheriques !== undefined) payload.maxPeripheriques = agent.maxPeripheriques;
|
||||
if (agent.limitId !== undefined)
|
||||
payload.limitId = agent.limitId ? Number(agent.limitId) : undefined;
|
||||
if (agent.nationalite !== undefined) payload.nationalite = agent.nationalite;
|
||||
if (agent.cni !== undefined) payload.cni = agent.cni;
|
||||
if (agent.cniDelivreeLe !== undefined)
|
||||
payload.cniDelivreeLe = this.formatDateForApi(agent.cniDelivreeLe);
|
||||
if (agent.cniDelivreeA !== undefined) payload.cniDelivreeA = agent.cniDelivreeA;
|
||||
if (agent.residence !== undefined) payload.residence = agent.residence;
|
||||
if (agent.autreAdresse1 !== undefined) payload.autreAdresse1 = agent.autreAdresse1;
|
||||
if (agent.statutMarital !== undefined) payload.statutMarital = agent.statutMarital;
|
||||
if (agent.epoux !== undefined) payload.epoux = agent.epoux;
|
||||
if (agent.autreTelephone !== undefined) payload.autreTelephone = agent.autreTelephone;
|
||||
if (agent.createdBy !== undefined) payload.createdBy = agent.createdBy;
|
||||
// Include tpes if provided - transform to API format
|
||||
if (agent.tpes !== undefined) {
|
||||
payload.tpes = agent.tpes.map((tpe) => ({
|
||||
id: tpe.id ? Number(tpe.id) : undefined,
|
||||
imei: tpe.imei,
|
||||
serial: tpe.serial,
|
||||
type: tpe.type,
|
||||
marque: tpe.marque,
|
||||
modele: tpe.modele,
|
||||
statut: tpe.statut,
|
||||
agent: undefined, // Will be set by backend
|
||||
assigne: tpe.assigne,
|
||||
createdAt: tpe.createdAt,
|
||||
updatedAt: tpe.updatedAt,
|
||||
}));
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
// GET /api/v1/agents/{id} - Get by ID
|
||||
getById(id: string): Observable<Agent | undefined> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<AgentApiResponse>(`${this.apiUrl}/${id}`, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map((apiAgent) => this.transformAgent(apiAgent)),
|
||||
catchError((err) => {
|
||||
console.error(`Error fetching agent ${id}:`, err);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(undefined);
|
||||
}
|
||||
|
||||
// GET /api/v1/agents - List all
|
||||
list(params?: ListParams): Observable<PagedResult<Agent>> {
|
||||
if (USE_SERVER) {
|
||||
let httpParams = new HttpParams();
|
||||
if (params) {
|
||||
if (params.page) httpParams = httpParams.set('page', params.page.toString());
|
||||
if (params.perPage) httpParams = httpParams.set('perPage', params.perPage.toString());
|
||||
if (params.search) httpParams = httpParams.set('search', params.search);
|
||||
if (params.sortKey) httpParams = httpParams.set('sortKey', params.sortKey);
|
||||
if (params.sortDir) httpParams = httpParams.set('sortDir', params.sortDir);
|
||||
}
|
||||
|
||||
return this.http
|
||||
.get<AgentApiResponse[]>(this.apiUrl, {
|
||||
params: httpParams,
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
map((list) => {
|
||||
const agents = list.map((apiAgent) => {
|
||||
const transformed = this.transformAgent(apiAgent);
|
||||
return transformed;
|
||||
});
|
||||
// If pagination params provided, return paginated result
|
||||
if (params) {
|
||||
return normalizePage<Agent>(
|
||||
{ data: agents, meta: { total: agents.length } },
|
||||
params.page || 1,
|
||||
params.perPage || 10
|
||||
);
|
||||
}
|
||||
// Otherwise return all as single page
|
||||
return normalizePage<Agent>(
|
||||
{ data: agents, meta: { total: agents.length } },
|
||||
1,
|
||||
agents.length
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error('Error fetching agents:', err);
|
||||
return of(normalizePage<Agent>({ data: [], meta: { total: 0 } }, 1, 10));
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(normalizePage<Agent>({ data: [], meta: { total: 0 } }, 1, 10));
|
||||
}
|
||||
|
||||
// POST /api/v1/agents - Create
|
||||
create(payload: Omit<Agent, 'id' | 'createdAt' | 'updatedAt'>): Observable<Agent> {
|
||||
if (USE_SERVER) {
|
||||
const apiPayload = this.transformToApiPayload(payload);
|
||||
return this.http
|
||||
.post<AgentApiResponse>(this.apiUrl, apiPayload, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map((apiAgent) => this.transformAgent(apiAgent)),
|
||||
catchError((err) => {
|
||||
console.error('Error creating agent:', err);
|
||||
throw err;
|
||||
})
|
||||
);
|
||||
}
|
||||
throw new Error('Server mode is required');
|
||||
}
|
||||
|
||||
// PUT /api/v1/agents/{id} - Update
|
||||
update(id: string, payload: Partial<Agent>): Observable<Agent | undefined> {
|
||||
if (USE_SERVER) {
|
||||
const apiPayload = this.transformToApiPayload(payload);
|
||||
return this.http
|
||||
.put<AgentApiResponse>(`${this.apiUrl}/${id}`, apiPayload, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
map((apiAgent) => this.transformAgent(apiAgent)),
|
||||
catchError((err) => {
|
||||
console.error(`Error updating agent ${id}:`, err);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(undefined);
|
||||
}
|
||||
|
||||
// DELETE /api/v1/agents/{id} - Delete
|
||||
delete(id: string): Observable<boolean> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.delete<void>(`${this.apiUrl}/${id}`, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map(() => true),
|
||||
catchError((err) => {
|
||||
console.error(`Error deleting agent ${id}:`, err);
|
||||
return of(false);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(false);
|
||||
}
|
||||
|
||||
// GET /api/v1/agents/ville/{ville} - List by ville
|
||||
getByVille(ville: string): Observable<Agent[]> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<AgentApiResponse[]>(`${this.apiUrl}/ville/${ville}`, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
map((list) => list.map((apiAgent) => this.transformAgent(apiAgent))),
|
||||
catchError((err) => {
|
||||
console.error(`Error fetching agents by ville ${ville}:`, err);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of([]);
|
||||
}
|
||||
|
||||
// GET /api/v1/agents/statut/{statut} - List by statut
|
||||
getByStatut(statut: AgentStatus): Observable<Agent[]> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<AgentApiResponse[]>(`${this.apiUrl}/statut/${statut}`, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
map((list) => list.map((apiAgent) => this.transformAgent(apiAgent))),
|
||||
catchError((err) => {
|
||||
console.error(`Error fetching agents by statut ${statut}:`, err);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of([]);
|
||||
}
|
||||
|
||||
// GET /api/v1/agents/search - Search by nom or prenom
|
||||
search(query: string): Observable<Agent[]> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<AgentApiResponse[]>(`${this.apiUrl}/search`, {
|
||||
params: { q: query.trim() },
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
map((list) => list.map((apiAgent) => this.transformAgent(apiAgent))),
|
||||
catchError((err) => {
|
||||
console.error(`Error searching agents with query ${query}:`, err);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of([]);
|
||||
}
|
||||
|
||||
// GET /api/v1/agents/code/{code} - Get by code
|
||||
getByCode(code: string): Observable<Agent | undefined> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<AgentApiResponse>(`${this.apiUrl}/code/${code}`, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map((apiAgent) => this.transformAgent(apiAgent)),
|
||||
catchError((err) => {
|
||||
console.error(`Error fetching agent by code ${code}:`, err);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(undefined);
|
||||
}
|
||||
|
||||
// Helper method to update all agents' limitId to a new default limit
|
||||
// This is used when a limit is set as default
|
||||
updateAllAgentsLimitId(limitId: string): Observable<boolean> {
|
||||
if (USE_SERVER) {
|
||||
// Get all agents first
|
||||
return this.list({
|
||||
page: 1,
|
||||
perPage: 10000,
|
||||
search: '',
|
||||
sortKey: 'code',
|
||||
sortDir: 'asc',
|
||||
} as any).pipe(
|
||||
switchMap((result) => {
|
||||
const agents = result.data;
|
||||
if (agents.length === 0) {
|
||||
return of(true);
|
||||
}
|
||||
// Update each agent's limitId in parallel
|
||||
const updateObservables = agents.map((agent) =>
|
||||
this.update(agent.id, { limitId }).pipe(
|
||||
catchError((err) => {
|
||||
console.error(`Error updating agent ${agent.id} limitId:`, err);
|
||||
return of(undefined);
|
||||
})
|
||||
)
|
||||
);
|
||||
// Wait for all updates to complete
|
||||
return forkJoin(updateObservables).pipe(
|
||||
map(() => true),
|
||||
catchError((err) => {
|
||||
console.error('Error updating all agents limitId:', err);
|
||||
return of(false);
|
||||
})
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error('Error fetching agents for limitId update:', err);
|
||||
return of(false);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(false);
|
||||
}
|
||||
}
|
||||
16
src/app/core/services/auth.spec.ts
Normal file
16
src/app/core/services/auth.spec.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { Auth } from './auth';
|
||||
|
||||
describe('Auth', () => {
|
||||
let service: Auth;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(Auth);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
||||
103
src/app/core/services/auth.ts
Normal file
103
src/app/core/services/auth.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { environment } from 'src/environments/environment.development';
|
||||
import { User } from '../interfaces/user';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
interface LoginRequest {
|
||||
identifiant: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
// Backend returns full user; no token specified in current spec.
|
||||
type LoginResponse = any;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class Auth {
|
||||
private tokenKey = 'pmu_token';
|
||||
private userKey = 'pmu_user';
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
isAuthenticated(): boolean {
|
||||
return !!localStorage.getItem(this.tokenKey);
|
||||
}
|
||||
|
||||
getUser(): User | null {
|
||||
const raw = localStorage.getItem(this.userKey);
|
||||
return raw ? (JSON.parse(raw) as User) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si l'utilisateur connecté possède un roleId donné.
|
||||
*/
|
||||
hasRoleId(roleId: string): boolean {
|
||||
const user = this.getUser();
|
||||
if (!user?.roleId) return false;
|
||||
return String(user.roleId) === String(roleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si l'utilisateur possède l'un des rôles attendus (par id).
|
||||
*/
|
||||
hasAnyRoleId(roleIds: string[]): boolean {
|
||||
const user = this.getUser();
|
||||
if (!user?.roleId) return false;
|
||||
return roleIds.map(String).includes(String(user.roleId));
|
||||
}
|
||||
|
||||
getToken(): string | null {
|
||||
return localStorage.getItem(this.tokenKey);
|
||||
}
|
||||
|
||||
setToken(token: string) {
|
||||
localStorage.setItem(this.tokenKey, token);
|
||||
}
|
||||
|
||||
logout() {
|
||||
localStorage.removeItem(this.tokenKey);
|
||||
localStorage.removeItem(this.userKey);
|
||||
}
|
||||
|
||||
private setSession(token: string, user: User) {
|
||||
localStorage.setItem(this.tokenKey, token);
|
||||
localStorage.setItem(this.userKey, JSON.stringify(user));
|
||||
}
|
||||
|
||||
async login(identifiant: string, password: string) {
|
||||
const url = `${environment.apiBaseUrl}/api/v1/auth/login`;
|
||||
const body: LoginRequest = { identifiant, password };
|
||||
|
||||
const res = (await firstValueFrom(this.http.post<LoginResponse>(url, body))) as any;
|
||||
|
||||
if (!res) {
|
||||
throw new Error('Réponse de connexion invalide');
|
||||
}
|
||||
|
||||
// Map backend user to frontend User model
|
||||
const user: User = {
|
||||
id: String(res.id),
|
||||
nom: res.nom,
|
||||
prenom: res.prenom,
|
||||
identifiant: res.identifiant,
|
||||
matriculeAgent: res.matriculeAgent,
|
||||
roleId: String(res.roleId),
|
||||
restrictionConnexion: !!res.restrictionConnexion,
|
||||
restrictionAutomatique: !!res.restrictionAutomatique,
|
||||
nombreIpAutorise: res.nombreIpAutorise ?? 0,
|
||||
nombreIpAutoAutorise: res.nombreIpAutoAutorise ?? 0,
|
||||
statut: res.statut ?? 'ACTIVE',
|
||||
derniereConnexion: res.derniereConnexion,
|
||||
createdAt: res.createdAt,
|
||||
updatedAt: res.updatedAt,
|
||||
};
|
||||
|
||||
// Backend spec does not expose a token yet; we set a dummy non-empty token
|
||||
// so that authGuard & interceptors keep working.
|
||||
const token = (res && (res.token || res.accessToken)) || 'session';
|
||||
this.setSession(token, user);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
16
src/app/core/services/course-sample.spec.ts
Normal file
16
src/app/core/services/course-sample.spec.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { CourseSample } from './course-sample';
|
||||
|
||||
describe('CourseSample', () => {
|
||||
let service: CourseSample;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(CourseSample);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
||||
38
src/app/core/services/course-sample.ts
Normal file
38
src/app/core/services/course-sample.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// src/app/features/courses/course.service.ts
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { PaginatedHttpService } from '@shared/paging/paginated-http.service';
|
||||
import { BackendConfig, ListParams, PagedResult } from '@shared/paging/paging';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
export interface Course {
|
||||
id: string;
|
||||
numero: number;
|
||||
nom: string;
|
||||
type_course: string;
|
||||
depart_at: string | null;
|
||||
statut: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class CourseService {
|
||||
private http = inject(PaginatedHttpService);
|
||||
private base = '/api/courses';
|
||||
|
||||
list(params: ListParams): Observable<PagedResult<Course>> {
|
||||
const cfg: BackendConfig = {
|
||||
zeroBasedPageIndex: true,
|
||||
buildSort: (key, dir) => (key && dir ? ['sort', `${key},${dir}`] : null),
|
||||
mapClientSortKey: (k) => {
|
||||
const map: Record<string, string> = {
|
||||
depart_at: 'departAt',
|
||||
type_course: 'type',
|
||||
numero: 'numero',
|
||||
nom: 'nom',
|
||||
statut: 'statut',
|
||||
};
|
||||
return k ? map[k] ?? k : undefined;
|
||||
},
|
||||
};
|
||||
return this.http.fetch<Course>(this.base, params, cfg);
|
||||
}
|
||||
}
|
||||
16
src/app/core/services/course.spec.ts
Normal file
16
src/app/core/services/course.spec.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { Course } from './course';
|
||||
|
||||
describe('Course', () => {
|
||||
let service: Course;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(Course);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
||||
874
src/app/core/services/course.ts
Normal file
874
src/app/core/services/course.ts
Normal file
@@ -0,0 +1,874 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, of, forkJoin } from 'rxjs';
|
||||
import { map, catchError, switchMap } from 'rxjs/operators';
|
||||
import { Course, CourseType, CourseStatut } from '../interfaces/course';
|
||||
import { normalizePage } from '@shared/paging/normalize-page';
|
||||
import { PaginatedHttpService } from '@shared/paging/paginated-http.service';
|
||||
import { ListParams, PagedResult } from '@shared/paging/paging';
|
||||
import { environment } from 'src/environments/environment.development';
|
||||
import { Reunion } from '../interfaces/reunion';
|
||||
import { ReunionService } from './reunion';
|
||||
import { NonPartantService } from './non-partant';
|
||||
|
||||
const USE_SERVER = true;
|
||||
const API_BASE = '/api/v1/courses';
|
||||
// Interface to match the API response structure for Course
|
||||
interface CourseApiResponse {
|
||||
id: number;
|
||||
type: string;
|
||||
numero: number;
|
||||
nom: string;
|
||||
dateDepartCourse: string;
|
||||
dateDebutParis: string;
|
||||
dateFinParis: string;
|
||||
reunionId: number; // API returns reunionId
|
||||
reunionCourse: number;
|
||||
particularite?: string;
|
||||
partants: number;
|
||||
distance: number;
|
||||
condition?: string;
|
||||
estTerminee: boolean;
|
||||
estAnnulee: boolean;
|
||||
statut: CourseStatut;
|
||||
nombreChevauxInscrits: number;
|
||||
createdBy: string;
|
||||
validatedBy: string | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
nonPartants: string[];
|
||||
adeadHeat: boolean;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class CourseService {
|
||||
private apiUrl = environment.apiBaseUrl + API_BASE;
|
||||
|
||||
constructor(
|
||||
private http: HttpClient,
|
||||
private paginatedHttp: PaginatedHttpService,
|
||||
private reunionService: ReunionService, // Inject ReunionService
|
||||
private nonPartantService: NonPartantService // Inject NonPartantService
|
||||
) {}
|
||||
|
||||
// Helper method to get ngrok bypass headers
|
||||
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,
|
||||
usePaginationEndpoint: boolean = false
|
||||
): Observable<PagedResult<Course>> {
|
||||
if (USE_SERVER) {
|
||||
// If there's a search query, use the search endpoint
|
||||
if (params.search && params.search.trim()) {
|
||||
return this.search(params.search.trim()).pipe(
|
||||
map((courses) => {
|
||||
// Apply client-side sorting and pagination
|
||||
let filtered = [...courses];
|
||||
|
||||
// Sort
|
||||
if (params.sortKey && params.sortDir) {
|
||||
const { sortKey, sortDir } = params;
|
||||
filtered.sort((a: any, b: any) => {
|
||||
const getValue = (obj: any, path: string): any =>
|
||||
path.split('.').reduce((o, key) => o?.[key], obj);
|
||||
|
||||
const va = getValue(a, sortKey);
|
||||
const vb = getValue(b, sortKey);
|
||||
const sa = va == null ? '' : String(va);
|
||||
const sb = vb == null ? '' : String(vb);
|
||||
const cmp = sa.localeCompare(sb, 'fr', { numeric: true });
|
||||
return sortDir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
}
|
||||
|
||||
const total = filtered.length;
|
||||
const start = (params.page - 1) * params.perPage;
|
||||
const pageData = filtered.slice(start, start + params.perPage);
|
||||
|
||||
const totalByType = filtered.reduce<Record<string, number>>((acc, c) => {
|
||||
const type = String(c.type);
|
||||
acc[type] = (acc[type] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
const totalRunning = filtered.filter(
|
||||
(c) => c.statut === CourseStatut.RUNNING || c.statut === 'RUNNING'
|
||||
).length;
|
||||
const totalClosed = filtered.filter(
|
||||
(c) => c.statut === CourseStatut.CLOSED || c.statut === 'CLOSED'
|
||||
).length;
|
||||
|
||||
return normalizePage<Course>(
|
||||
{
|
||||
data: pageData,
|
||||
meta: {
|
||||
total,
|
||||
totalRunning,
|
||||
totalClosed,
|
||||
totalByType,
|
||||
},
|
||||
},
|
||||
params.page,
|
||||
params.perPage
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error('Error searching courses:', err);
|
||||
return of(
|
||||
normalizePage<Course>(
|
||||
{
|
||||
data: [],
|
||||
meta: {
|
||||
total: 0,
|
||||
totalRunning: 0,
|
||||
totalClosed: 0,
|
||||
totalByType: {},
|
||||
},
|
||||
},
|
||||
params.page,
|
||||
params.perPage
|
||||
)
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (usePaginationEndpoint) {
|
||||
return this.paginatedHttp
|
||||
.fetch<CourseApiResponse>(this.apiUrl, params, {
|
||||
zeroBasedPageIndex: false,
|
||||
})
|
||||
.pipe(
|
||||
switchMap((pagedResult) => {
|
||||
// Handle empty data case
|
||||
if (!pagedResult.data || pagedResult.data.length === 0) {
|
||||
return of(
|
||||
normalizePage<Course>(
|
||||
{
|
||||
data: [],
|
||||
meta: {
|
||||
total: pagedResult.meta?.total ?? 0,
|
||||
totalRunning: 0,
|
||||
totalClosed: 0,
|
||||
totalByType: {},
|
||||
},
|
||||
},
|
||||
params.page,
|
||||
params.perPage
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Extract unique reunionIds
|
||||
const uniqueReunionIds = [
|
||||
...new Set(pagedResult.data.map((c) => String(c.reunionId))),
|
||||
];
|
||||
|
||||
// If no reunion IDs, we can't build valid Reunion objects – return empty page
|
||||
if (uniqueReunionIds.length === 0) {
|
||||
return of(
|
||||
normalizePage<Course>(
|
||||
{
|
||||
data: [],
|
||||
meta: {
|
||||
total: pagedResult.meta?.total ?? 0,
|
||||
totalRunning: 0,
|
||||
totalClosed: 0,
|
||||
totalByType: {},
|
||||
},
|
||||
},
|
||||
params.page,
|
||||
params.perPage
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch all reunions in parallel
|
||||
const reunionRequests = uniqueReunionIds.map((id) =>
|
||||
this.reunionService
|
||||
.getById(id)
|
||||
.pipe(catchError(() => of<Reunion | undefined>(undefined)))
|
||||
);
|
||||
|
||||
return forkJoin(reunionRequests).pipe(
|
||||
map((reunions) => {
|
||||
// Create a map of reunionId -> Reunion
|
||||
const reunionMap = new Map<string, Reunion>();
|
||||
uniqueReunionIds.forEach((id, index) => {
|
||||
const reunion = reunions[index];
|
||||
if (reunion) {
|
||||
reunionMap.set(id, reunion);
|
||||
}
|
||||
});
|
||||
|
||||
// Transform API data to Course objects
|
||||
const transformedData: Course[] = pagedResult.data
|
||||
.map((apiCourse) => {
|
||||
const reunion = reunionMap.get(String(apiCourse.reunionId));
|
||||
if (!reunion) {
|
||||
// If we couldn't resolve the reunion, drop this course to keep typing sound
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: String(apiCourse.id),
|
||||
type: apiCourse.type,
|
||||
numero: apiCourse.numero,
|
||||
nom: apiCourse.nom,
|
||||
dateDepartCourse: apiCourse.dateDepartCourse,
|
||||
dateDebutParis: apiCourse.dateDebutParis,
|
||||
dateFinParis: apiCourse.dateFinParis,
|
||||
reunion,
|
||||
reunionCourse: apiCourse.reunionCourse,
|
||||
particularite: apiCourse.particularite,
|
||||
partants: apiCourse.partants,
|
||||
distance: apiCourse.distance,
|
||||
condition: apiCourse.condition,
|
||||
statut: apiCourse.statut as CourseStatut,
|
||||
nonPartants: apiCourse.nonPartants || [],
|
||||
estTerminee: apiCourse.estTerminee,
|
||||
estAnnulee: apiCourse.estAnnulee,
|
||||
nombreChevauxInscrits: apiCourse.nombreChevauxInscrits,
|
||||
adeadHeat: apiCourse.adeadHeat,
|
||||
createdBy: apiCourse.createdBy,
|
||||
validatedBy: apiCourse.validatedBy,
|
||||
createdAt: apiCourse.createdAt || new Date().toISOString(),
|
||||
updatedAt: apiCourse.updatedAt || new Date().toISOString(),
|
||||
} as Course;
|
||||
})
|
||||
.filter((c): c is Course => c !== null);
|
||||
|
||||
// Calculate meta stats
|
||||
const totalByType = transformedData.reduce<Record<string, number>>((acc, c) => {
|
||||
const type = String(c.type);
|
||||
acc[type] = (acc[type] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
const totalRunning = transformedData.filter(
|
||||
(c) => c.statut === CourseStatut.RUNNING || c.statut === 'RUNNING'
|
||||
).length;
|
||||
const totalClosed = transformedData.filter(
|
||||
(c) => c.statut === CourseStatut.CLOSED || c.statut === 'CLOSED'
|
||||
).length;
|
||||
|
||||
return normalizePage<Course>(
|
||||
{
|
||||
data: transformedData,
|
||||
meta: {
|
||||
total: pagedResult.meta?.total ?? transformedData.length,
|
||||
totalRunning,
|
||||
totalClosed,
|
||||
totalByType,
|
||||
},
|
||||
},
|
||||
params.page,
|
||||
params.perPage
|
||||
);
|
||||
})
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error('Error fetching courses:', err);
|
||||
return of(
|
||||
normalizePage<Course>(
|
||||
{
|
||||
data: [],
|
||||
meta: {
|
||||
total: 0,
|
||||
totalRunning: 0,
|
||||
totalClosed: 0,
|
||||
totalByType: {},
|
||||
},
|
||||
},
|
||||
params.page,
|
||||
params.perPage
|
||||
)
|
||||
);
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// Fetch all data and apply client-side pagination
|
||||
return this.http
|
||||
.get<CourseApiResponse[]>(this.apiUrl, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
switchMap((apiData) => {
|
||||
// Handle empty data case
|
||||
if (!apiData || apiData.length === 0) {
|
||||
return of(
|
||||
normalizePage<Course>(
|
||||
{
|
||||
data: [],
|
||||
meta: {
|
||||
total: 0,
|
||||
totalRunning: 0,
|
||||
totalClosed: 0,
|
||||
totalByType: {},
|
||||
},
|
||||
},
|
||||
params.page,
|
||||
params.perPage
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Extract unique reunionIds
|
||||
const uniqueReunionIds = [...new Set(apiData.map((c) => String(c.reunionId)))];
|
||||
|
||||
// Handle case where there are no unique IDs (shouldn't happen, but be safe)
|
||||
if (uniqueReunionIds.length === 0) {
|
||||
return of(
|
||||
normalizePage<Course>(
|
||||
{
|
||||
data: [],
|
||||
meta: {
|
||||
total: 0,
|
||||
totalRunning: 0,
|
||||
totalClosed: 0,
|
||||
totalByType: {},
|
||||
},
|
||||
},
|
||||
params.page,
|
||||
params.perPage
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch all reunions in parallel
|
||||
const reunionRequests = uniqueReunionIds.map((id) =>
|
||||
this.reunionService
|
||||
.getById(id)
|
||||
.pipe(catchError(() => of<Reunion | undefined>(undefined)))
|
||||
);
|
||||
|
||||
return forkJoin(reunionRequests).pipe(
|
||||
map((reunions) => {
|
||||
// Create a map of reunionId -> Reunion
|
||||
const reunionMap = new Map<string, Reunion>();
|
||||
uniqueReunionIds.forEach((id, index) => {
|
||||
const reunion = reunions[index];
|
||||
if (reunion) {
|
||||
reunionMap.set(id, reunion);
|
||||
}
|
||||
});
|
||||
|
||||
// Transform API data to Course objects
|
||||
const transformedData: Course[] = apiData
|
||||
.map((apiCourse) => {
|
||||
const reunion = reunionMap.get(String(apiCourse.reunionId));
|
||||
if (!reunion) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: String(apiCourse.id),
|
||||
type: apiCourse.type,
|
||||
numero: apiCourse.numero,
|
||||
nom: apiCourse.nom,
|
||||
dateDepartCourse: apiCourse.dateDepartCourse,
|
||||
dateDebutParis: apiCourse.dateDebutParis,
|
||||
dateFinParis: apiCourse.dateFinParis,
|
||||
reunion,
|
||||
reunionCourse: apiCourse.reunionCourse,
|
||||
particularite: apiCourse.particularite,
|
||||
partants: apiCourse.partants,
|
||||
distance: apiCourse.distance,
|
||||
condition: apiCourse.condition,
|
||||
statut: apiCourse.statut as CourseStatut,
|
||||
nonPartants: apiCourse.nonPartants || [],
|
||||
estTerminee: apiCourse.estTerminee,
|
||||
estAnnulee: apiCourse.estAnnulee,
|
||||
nombreChevauxInscrits: apiCourse.nombreChevauxInscrits,
|
||||
adeadHeat: apiCourse.adeadHeat,
|
||||
createdBy: apiCourse.createdBy,
|
||||
validatedBy: apiCourse.validatedBy,
|
||||
createdAt: apiCourse.createdAt || new Date().toISOString(),
|
||||
updatedAt: apiCourse.updatedAt || new Date().toISOString(),
|
||||
} as Course;
|
||||
})
|
||||
.filter((c): c is Course => c !== null);
|
||||
|
||||
// Apply client-side filtering, sorting, and pagination
|
||||
let filtered = this.applyClientFilters(transformedData, params);
|
||||
const total = filtered.length;
|
||||
const start = (params.page - 1) * params.perPage;
|
||||
const pageData = filtered.slice(start, start + params.perPage);
|
||||
|
||||
const totalByType = filtered.reduce<Record<string, number>>((acc, c) => {
|
||||
const type = String(c.type);
|
||||
acc[type] = (acc[type] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
const totalRunning = filtered.filter(
|
||||
(c) => c.statut === CourseStatut.RUNNING || c.statut === 'RUNNING'
|
||||
).length;
|
||||
const totalClosed = filtered.filter(
|
||||
(c) => c.statut === CourseStatut.CLOSED || c.statut === 'CLOSED'
|
||||
).length;
|
||||
|
||||
return normalizePage<Course>(
|
||||
{
|
||||
data: pageData,
|
||||
meta: {
|
||||
total,
|
||||
totalRunning,
|
||||
totalClosed,
|
||||
totalByType,
|
||||
},
|
||||
},
|
||||
params.page,
|
||||
params.perPage
|
||||
);
|
||||
})
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error('Error fetching courses:', err);
|
||||
return of(
|
||||
normalizePage<Course>(
|
||||
{
|
||||
data: [],
|
||||
meta: {
|
||||
total: 0,
|
||||
totalRunning: 0,
|
||||
totalClosed: 0,
|
||||
totalByType: {},
|
||||
},
|
||||
},
|
||||
params.page,
|
||||
params.perPage
|
||||
)
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// If USE_SERVER is false, return empty result
|
||||
return of(
|
||||
normalizePage<Course>(
|
||||
{
|
||||
data: [],
|
||||
meta: {
|
||||
total: 0,
|
||||
totalRunning: 0,
|
||||
totalClosed: 0,
|
||||
totalByType: {},
|
||||
},
|
||||
},
|
||||
params.page,
|
||||
params.perPage
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private applyClientFilters(data: Course[], params: ListParams): Course[] {
|
||||
let filtered = [...data];
|
||||
|
||||
// Search filter
|
||||
const q = (params.search ?? '').toLowerCase();
|
||||
if (q) {
|
||||
filtered = filtered.filter((c) => {
|
||||
const reunionName = c.reunion?.nom?.toLowerCase?.() ?? '';
|
||||
const hippodromeName = c.reunion?.hippodrome?.nom?.toLowerCase?.() ?? '';
|
||||
return (
|
||||
c.nom.toLowerCase().includes(q) ||
|
||||
c.type.toLowerCase().includes(q) ||
|
||||
reunionName.includes(q) ||
|
||||
hippodromeName.includes(q)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Sort
|
||||
if (params.sortKey && params.sortDir) {
|
||||
const { sortKey, sortDir } = params;
|
||||
filtered.sort((a: any, b: any) => {
|
||||
const getValue = (obj: any, path: string): any =>
|
||||
path.split('.').reduce((o, key) => o?.[key], obj);
|
||||
|
||||
const va = getValue(a, sortKey);
|
||||
const vb = getValue(b, sortKey);
|
||||
const sa = va == null ? '' : String(va);
|
||||
const sb = vb == null ? '' : String(vb);
|
||||
const cmp = sa.localeCompare(sb, 'fr', { numeric: true });
|
||||
return sortDir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
getById(id: string): Observable<Course | undefined> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<CourseApiResponse>(`${this.apiUrl}/${id}`, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
switchMap((apiCourse) => {
|
||||
// Fetch the reunion (non-partants are already included in the API response)
|
||||
return this.reunionService.getById(String(apiCourse.reunionId)).pipe(
|
||||
map((reunion) => {
|
||||
if (!reunion) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
id: String(apiCourse.id),
|
||||
type: apiCourse.type,
|
||||
numero: apiCourse.numero,
|
||||
nom: apiCourse.nom,
|
||||
dateDepartCourse: apiCourse.dateDepartCourse,
|
||||
dateDebutParis: apiCourse.dateDebutParis,
|
||||
dateFinParis: apiCourse.dateFinParis,
|
||||
reunion,
|
||||
reunionCourse: apiCourse.reunionCourse,
|
||||
particularite: apiCourse.particularite,
|
||||
partants: apiCourse.partants,
|
||||
distance: apiCourse.distance,
|
||||
condition: apiCourse.condition,
|
||||
statut: apiCourse.statut as CourseStatut,
|
||||
nonPartants: apiCourse.nonPartants || [],
|
||||
estTerminee: apiCourse.estTerminee,
|
||||
estAnnulee: apiCourse.estAnnulee,
|
||||
nombreChevauxInscrits: apiCourse.nombreChevauxInscrits,
|
||||
adeadHeat: apiCourse.adeadHeat,
|
||||
createdBy: apiCourse.createdBy,
|
||||
validatedBy: apiCourse.validatedBy,
|
||||
createdAt: apiCourse.createdAt || new Date().toISOString(),
|
||||
updatedAt: apiCourse.updatedAt || new Date().toISOString(),
|
||||
};
|
||||
})
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error(`Error fetching course ${id}:`, err);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(undefined);
|
||||
}
|
||||
|
||||
getByReunionId(reunionId: string): Observable<Course[]> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<CourseApiResponse[]>(`${this.apiUrl}/reunion/${reunionId}`, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
switchMap((apiData) => {
|
||||
// Fetch the reunion once
|
||||
return this.reunionService.getById(reunionId).pipe(
|
||||
map((reunion) => {
|
||||
if (!reunion) {
|
||||
return [];
|
||||
}
|
||||
// Transform all courses with the same reunion
|
||||
return apiData.map((apiCourse) => ({
|
||||
id: String(apiCourse.id),
|
||||
type: apiCourse.type,
|
||||
numero: apiCourse.numero,
|
||||
nom: apiCourse.nom,
|
||||
dateDepartCourse: apiCourse.dateDepartCourse,
|
||||
dateDebutParis: apiCourse.dateDebutParis,
|
||||
dateFinParis: apiCourse.dateFinParis,
|
||||
reunion,
|
||||
reunionCourse: apiCourse.reunionCourse,
|
||||
particularite: apiCourse.particularite,
|
||||
partants: apiCourse.partants,
|
||||
distance: apiCourse.distance,
|
||||
condition: apiCourse.condition,
|
||||
statut: apiCourse.statut as CourseStatut,
|
||||
nonPartants: apiCourse.nonPartants || [],
|
||||
estTerminee: apiCourse.estTerminee,
|
||||
estAnnulee: apiCourse.estAnnulee,
|
||||
nombreChevauxInscrits: apiCourse.nombreChevauxInscrits,
|
||||
adeadHeat: apiCourse.adeadHeat,
|
||||
createdBy: apiCourse.createdBy,
|
||||
validatedBy: apiCourse.validatedBy,
|
||||
createdAt: apiCourse.createdAt || new Date().toISOString(),
|
||||
updatedAt: apiCourse.updatedAt || new Date().toISOString(),
|
||||
}));
|
||||
})
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error(`Error fetching courses for reunion ${reunionId}:`, err);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of([]);
|
||||
}
|
||||
|
||||
search(query: string): Observable<Course[]> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<CourseApiResponse[]>(`${this.apiUrl}/search`, {
|
||||
params: { q: query.trim() },
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
switchMap((apiData) => {
|
||||
// Extract unique reunionIds
|
||||
const uniqueReunionIds = [...new Set(apiData.map((c) => String(c.reunionId)))];
|
||||
|
||||
// Fetch all reunions in parallel
|
||||
const reunionRequests = uniqueReunionIds.map((id) =>
|
||||
this.reunionService
|
||||
.getById(id)
|
||||
.pipe(catchError(() => of<Reunion | undefined>(undefined)))
|
||||
);
|
||||
|
||||
return forkJoin(reunionRequests).pipe(
|
||||
map((reunions) => {
|
||||
// Create a map of reunionId -> Reunion
|
||||
const reunionMap = new Map<string, Reunion>();
|
||||
uniqueReunionIds.forEach((id, index) => {
|
||||
const reunion = reunions[index];
|
||||
if (reunion) {
|
||||
reunionMap.set(id, reunion);
|
||||
}
|
||||
});
|
||||
|
||||
// Transform API data to Course objects
|
||||
return apiData
|
||||
.map((apiCourse) => {
|
||||
const reunion = reunionMap.get(String(apiCourse.reunionId));
|
||||
if (!reunion) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: String(apiCourse.id),
|
||||
type: apiCourse.type,
|
||||
numero: apiCourse.numero,
|
||||
nom: apiCourse.nom,
|
||||
dateDepartCourse: apiCourse.dateDepartCourse,
|
||||
dateDebutParis: apiCourse.dateDebutParis,
|
||||
dateFinParis: apiCourse.dateFinParis,
|
||||
reunion,
|
||||
reunionCourse: apiCourse.reunionCourse,
|
||||
particularite: apiCourse.particularite,
|
||||
partants: apiCourse.partants,
|
||||
distance: apiCourse.distance,
|
||||
condition: apiCourse.condition,
|
||||
statut: apiCourse.statut as CourseStatut,
|
||||
nonPartants: apiCourse.nonPartants || [],
|
||||
estTerminee: apiCourse.estTerminee,
|
||||
estAnnulee: apiCourse.estAnnulee,
|
||||
nombreChevauxInscrits: apiCourse.nombreChevauxInscrits,
|
||||
adeadHeat: apiCourse.adeadHeat,
|
||||
createdBy: apiCourse.createdBy,
|
||||
validatedBy: apiCourse.validatedBy,
|
||||
createdAt: apiCourse.createdAt || new Date().toISOString(),
|
||||
updatedAt: apiCourse.updatedAt || new Date().toISOString(),
|
||||
} as Course;
|
||||
})
|
||||
.filter((c): c is Course => c !== null);
|
||||
})
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error(`Error searching courses with query ${query}:`, err);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of([]);
|
||||
}
|
||||
|
||||
create(payload: Omit<Course, 'id' | 'nonPartants'>): Observable<Course> {
|
||||
if (USE_SERVER) {
|
||||
// Transform payload to API format (send reunionId instead of reunion object)
|
||||
const apiPayload: any = {
|
||||
type: payload.type,
|
||||
numero: payload.numero,
|
||||
nom: payload.nom,
|
||||
dateDepartCourse: payload.dateDepartCourse,
|
||||
dateDebutParis: payload.dateDebutParis,
|
||||
dateFinParis: payload.dateFinParis,
|
||||
reunionId: typeof payload.reunion === 'object' ? payload.reunion.id : payload.reunion,
|
||||
reunionCourse: payload.reunionCourse,
|
||||
particularite: payload.particularite,
|
||||
partants: payload.partants,
|
||||
distance: payload.distance,
|
||||
condition: payload.condition,
|
||||
statut: payload.statut,
|
||||
createdBy: payload.createdBy,
|
||||
validatedBy: payload.validatedBy,
|
||||
};
|
||||
|
||||
return this.http
|
||||
.post<CourseApiResponse>(this.apiUrl, apiPayload, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
switchMap((apiCourse) => {
|
||||
// Fetch the reunion to build the full Course object
|
||||
return this.reunionService.getById(String(apiCourse.reunionId)).pipe(
|
||||
map((reunion) => {
|
||||
if (!reunion) {
|
||||
throw new Error('Reunion not found');
|
||||
}
|
||||
const item: Course = {
|
||||
id: String(apiCourse.id),
|
||||
type: apiCourse.type,
|
||||
numero: apiCourse.numero,
|
||||
nom: apiCourse.nom,
|
||||
dateDepartCourse: apiCourse.dateDepartCourse,
|
||||
dateDebutParis: apiCourse.dateDebutParis,
|
||||
dateFinParis: apiCourse.dateFinParis,
|
||||
reunion,
|
||||
reunionCourse: apiCourse.reunionCourse,
|
||||
particularite: apiCourse.particularite,
|
||||
partants: apiCourse.partants,
|
||||
distance: apiCourse.distance,
|
||||
condition: apiCourse.condition,
|
||||
statut: apiCourse.statut as CourseStatut,
|
||||
nonPartants: apiCourse.nonPartants || [],
|
||||
estTerminee: apiCourse.estTerminee,
|
||||
estAnnulee: apiCourse.estAnnulee,
|
||||
nombreChevauxInscrits: apiCourse.nombreChevauxInscrits,
|
||||
adeadHeat: apiCourse.adeadHeat,
|
||||
createdBy: apiCourse.createdBy,
|
||||
validatedBy: apiCourse.validatedBy,
|
||||
createdAt: apiCourse.createdAt || new Date().toISOString(),
|
||||
updatedAt: apiCourse.updatedAt || new Date().toISOString(),
|
||||
};
|
||||
return item;
|
||||
})
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error('Error creating course:', err);
|
||||
throw err;
|
||||
})
|
||||
);
|
||||
}
|
||||
throw new Error('Server mode is required');
|
||||
}
|
||||
|
||||
update(id: string, payload: Partial<Course>): Observable<Course | undefined> {
|
||||
if (USE_SERVER) {
|
||||
// Transform payload to API format (send reunionId instead of reunion object)
|
||||
const apiPayload: any = { ...payload };
|
||||
if (payload.reunion) {
|
||||
apiPayload.reunionId =
|
||||
typeof payload.reunion === 'object' ? payload.reunion.id : payload.reunion;
|
||||
delete apiPayload.reunion;
|
||||
}
|
||||
|
||||
return this.http
|
||||
.put<CourseApiResponse>(`${this.apiUrl}/${id}`, apiPayload, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
switchMap((apiCourse) => {
|
||||
// Fetch the reunion to build the full Course object
|
||||
return this.reunionService.getById(String(apiCourse.reunionId)).pipe(
|
||||
map((reunion) => {
|
||||
if (!reunion) {
|
||||
throw new Error('Reunion not found');
|
||||
}
|
||||
return {
|
||||
id: String(apiCourse.id),
|
||||
type: apiCourse.type,
|
||||
numero: apiCourse.numero,
|
||||
nom: apiCourse.nom,
|
||||
dateDepartCourse: apiCourse.dateDepartCourse,
|
||||
dateDebutParis: apiCourse.dateDebutParis,
|
||||
dateFinParis: apiCourse.dateFinParis,
|
||||
reunion,
|
||||
reunionCourse: apiCourse.reunionCourse,
|
||||
particularite: apiCourse.particularite,
|
||||
partants: apiCourse.partants,
|
||||
distance: apiCourse.distance,
|
||||
condition: apiCourse.condition,
|
||||
statut: apiCourse.statut as CourseStatut,
|
||||
nonPartants: apiCourse.nonPartants || [],
|
||||
estTerminee: apiCourse.estTerminee,
|
||||
estAnnulee: apiCourse.estAnnulee,
|
||||
nombreChevauxInscrits: apiCourse.nombreChevauxInscrits,
|
||||
adeadHeat: apiCourse.adeadHeat,
|
||||
createdBy: apiCourse.createdBy,
|
||||
validatedBy: apiCourse.validatedBy,
|
||||
createdAt: apiCourse.createdAt || new Date().toISOString(),
|
||||
updatedAt: apiCourse.updatedAt || new Date().toISOString(),
|
||||
};
|
||||
})
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error(`Error updating course ${id}:`, err);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
throw new Error('Server mode is required');
|
||||
}
|
||||
|
||||
updateStatut(id: string, statut: CourseStatut): Observable<Course | undefined> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.patch<Course>(
|
||||
`${this.apiUrl}/${id}/statut`,
|
||||
{ statut },
|
||||
{ headers: this.getNgrokHeaders() }
|
||||
)
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
console.error(`Error updating course statut ${id}:`, err);
|
||||
return this.update(id, { statut });
|
||||
})
|
||||
);
|
||||
}
|
||||
return this.update(id, { statut });
|
||||
}
|
||||
|
||||
delete(id: string): Observable<boolean> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.delete<void>(`${this.apiUrl}/${id}`, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map(() => true),
|
||||
catchError((err) => {
|
||||
console.error(`Error deleting course ${id}:`, err);
|
||||
return of(false);
|
||||
})
|
||||
);
|
||||
}
|
||||
throw new Error('Server mode is required');
|
||||
}
|
||||
|
||||
addNonPartant(courseId: string, npList: string[]) {
|
||||
console.warn('addNonPartant is deprecated. Use setNonPartants instead.');
|
||||
return this.setNonPartants(courseId, npList);
|
||||
}
|
||||
|
||||
setNonPartants(courseId: string, npList: string[]): Observable<Course | undefined> {
|
||||
if (USE_SERVER) {
|
||||
// Use PUT endpoint to replace the entire list
|
||||
return this.nonPartantService.replaceNonPartants(courseId, npList).pipe(
|
||||
switchMap((updatedNonPartants) => {
|
||||
// Fetch the updated course to return it
|
||||
return this.getById(courseId).pipe(
|
||||
map((course) => {
|
||||
if (course) {
|
||||
return {
|
||||
...course,
|
||||
nonPartants: updatedNonPartants,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
})
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error(`Error setting nonPartants for course ${courseId}:`, err);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
throw new Error('Server mode is required');
|
||||
}
|
||||
}
|
||||
16
src/app/core/services/hippodrome.spec.ts
Normal file
16
src/app/core/services/hippodrome.spec.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { Hippodrome } from './hippodrome';
|
||||
|
||||
describe('Hippodrome', () => {
|
||||
let service: Hippodrome;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(Hippodrome);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
||||
547
src/app/core/services/hippodrome.ts
Normal file
547
src/app/core/services/hippodrome.ts
Normal file
@@ -0,0 +1,547 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, of, forkJoin } from 'rxjs';
|
||||
import { map, catchError, switchMap } from 'rxjs/operators';
|
||||
import { Hippodrome } from '../interfaces/hippodrome';
|
||||
import { normalizePage } from '@shared/paging/normalize-page';
|
||||
import { PaginatedHttpService } from '@shared/paging/paginated-http.service';
|
||||
import { ListParams, PagedResult } from '@shared/paging/paging';
|
||||
import { environment } from 'src/environments/environment.development';
|
||||
|
||||
const USE_SERVER = true;
|
||||
const API_BASE = '/api/v1/hippodromes';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class HippodromeService {
|
||||
private apiUrl = environment.apiBaseUrl + API_BASE;
|
||||
private store = signal<Hippodrome[]>([]);
|
||||
|
||||
constructor(private http: HttpClient, private paginatedHttp: PaginatedHttpService) {}
|
||||
|
||||
// Helper method to get ngrok bypass headers
|
||||
private getNgrokHeaders(): Record<string, string> {
|
||||
const isNgrok =
|
||||
this.apiUrl.includes('ngrok-free.app') ||
|
||||
this.apiUrl.includes('ngrok.io') ||
|
||||
this.apiUrl.includes('ngrok');
|
||||
return isNgrok ? { 'ngrok-skip-browser-warning': 'true' } : {};
|
||||
}
|
||||
|
||||
// LISTE — supporte client & serveur
|
||||
list(
|
||||
params: ListParams,
|
||||
usePaginationEndpoint: boolean = false
|
||||
): Observable<PagedResult<Hippodrome>> {
|
||||
if (USE_SERVER) {
|
||||
// If there's a search query, use the search endpoint
|
||||
if (params.search && params.search.trim()) {
|
||||
return this.search(params.search.trim()).pipe(
|
||||
switchMap((hippodromes) => {
|
||||
// Fetch all reunions and courses to calculate counts
|
||||
return forkJoin({
|
||||
reunions: this.http
|
||||
.get<any[]>(`${environment.apiBaseUrl}/api/v1/reunions`, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
catchError(() => of([])),
|
||||
map((data) => ({ data: data || [], meta: { total: (data || []).length } }))
|
||||
),
|
||||
courses: this.http
|
||||
.get<any[]>(`${environment.apiBaseUrl}/api/v1/courses`, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
catchError(() => of([])),
|
||||
map((data) => ({ data: data || [], meta: { total: (data || []).length } }))
|
||||
),
|
||||
}).pipe(
|
||||
map(({ reunions, courses }) => {
|
||||
// Count reunions per hippodrome
|
||||
const reunionCountMap = new Map<string, number>();
|
||||
reunions.data.forEach((reunion: any) => {
|
||||
const hippodromeId = String(reunion.hippodromeId || reunion.hippodrome?.id);
|
||||
if (hippodromeId && hippodromeId !== 'undefined' && hippodromeId !== 'null') {
|
||||
reunionCountMap.set(hippodromeId, (reunionCountMap.get(hippodromeId) || 0) + 1);
|
||||
}
|
||||
});
|
||||
|
||||
// Create a map of reunionId -> hippodromeId from reunions
|
||||
const reunionToHippodromeMap = new Map<string, string>();
|
||||
reunions.data.forEach((reunion: any) => {
|
||||
const reunionId = String(reunion.id);
|
||||
const hippodromeId = String(reunion.hippodromeId || reunion.hippodrome?.id);
|
||||
if (
|
||||
reunionId &&
|
||||
reunionId !== 'undefined' &&
|
||||
reunionId !== 'null' &&
|
||||
hippodromeId &&
|
||||
hippodromeId !== 'undefined' &&
|
||||
hippodromeId !== 'null'
|
||||
) {
|
||||
reunionToHippodromeMap.set(reunionId, hippodromeId);
|
||||
}
|
||||
});
|
||||
|
||||
// Count courses per hippodrome using the reunion -> hippodrome mapping
|
||||
const courseCountMap = new Map<string, number>();
|
||||
courses.data.forEach((course: any) => {
|
||||
const reunionId = String(course.reunionId || course.reunion?.id);
|
||||
if (reunionId && reunionId !== 'undefined' && reunionId !== 'null') {
|
||||
const hippodromeId = reunionToHippodromeMap.get(reunionId);
|
||||
if (hippodromeId) {
|
||||
courseCountMap.set(hippodromeId, (courseCountMap.get(hippodromeId) || 0) + 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Add counts to hippodromes
|
||||
const hippodromesWithCounts = hippodromes.map((h) => ({
|
||||
...h,
|
||||
reunionCount: reunionCountMap.get(String(h.id)) ?? 0,
|
||||
courseCount: courseCountMap.get(String(h.id)) ?? 0,
|
||||
}));
|
||||
|
||||
// Apply client-side sorting and pagination
|
||||
let filtered = this.applyClientFilters(hippodromesWithCounts, {
|
||||
...params,
|
||||
search: '', // Already filtered by search endpoint
|
||||
});
|
||||
const total = filtered.length;
|
||||
const start = (params.page - 1) * params.perPage;
|
||||
const pageData = filtered.slice(start, start + params.perPage);
|
||||
|
||||
const uniqueCountries = new Set(filtered.map((h) => h.pays)).size;
|
||||
const uniqueCities = new Set(filtered.map((h) => h.ville)).size;
|
||||
const averageByCountry = filtered.length
|
||||
? Math.round(filtered.length / uniqueCountries)
|
||||
: 0;
|
||||
const totalReunions = filtered.reduce((acc, h) => acc + (h.reunionCount ?? 0), 0);
|
||||
const totalCourses = filtered.reduce((acc, h) => acc + (h.courseCount ?? 0), 0);
|
||||
|
||||
return normalizePage<Hippodrome>(
|
||||
{
|
||||
data: pageData,
|
||||
meta: {
|
||||
total,
|
||||
uniqueCountries,
|
||||
uniqueCities,
|
||||
averageByCountry,
|
||||
totalReunions,
|
||||
totalCourses,
|
||||
},
|
||||
},
|
||||
params.page,
|
||||
params.perPage
|
||||
);
|
||||
})
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error('Error searching hippodromes:', err);
|
||||
return of(
|
||||
normalizePage<Hippodrome>(
|
||||
{
|
||||
data: [],
|
||||
meta: {
|
||||
total: 0,
|
||||
uniqueCountries: 0,
|
||||
uniqueCities: 0,
|
||||
averageByCountry: 0,
|
||||
totalReunions: 0,
|
||||
totalCourses: 0,
|
||||
},
|
||||
},
|
||||
params.page,
|
||||
params.perPage
|
||||
)
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (usePaginationEndpoint) {
|
||||
return this.paginatedHttp
|
||||
.fetch<Hippodrome>(this.apiUrl, params, {
|
||||
zeroBasedPageIndex: false,
|
||||
buildSort: (key, dir) => (key && dir ? ['sort', `${key},${dir}`] : null),
|
||||
mapClientSortKey: (k) => {
|
||||
const alias: Record<string, string> = {
|
||||
name: 'nom',
|
||||
city: 'ville',
|
||||
country: 'pays',
|
||||
};
|
||||
return k ? alias[k] ?? k : undefined;
|
||||
},
|
||||
})
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
console.error('Error fetching hippodromes:', err);
|
||||
return of(
|
||||
normalizePage<Hippodrome>(
|
||||
{
|
||||
data: [],
|
||||
meta: {
|
||||
total: 0,
|
||||
uniqueCountries: 0,
|
||||
uniqueCities: 0,
|
||||
averageByCountry: 0,
|
||||
totalReunions: 0,
|
||||
totalCourses: 0,
|
||||
},
|
||||
},
|
||||
params.page,
|
||||
params.perPage
|
||||
)
|
||||
);
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// Fetch all data and apply client-side pagination
|
||||
return this.http
|
||||
.get<Hippodrome[]>(`${this.apiUrl}/actifs`, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
switchMap((allData) => {
|
||||
// Fetch all reunions and courses directly from API to calculate counts
|
||||
// We fetch directly to avoid circular dependency with ReunionService and CourseService
|
||||
return forkJoin({
|
||||
reunions: this.http
|
||||
.get<any[]>(`${environment.apiBaseUrl}/api/v1/reunions`, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
catchError(() => of([])),
|
||||
map((data) => ({ data, meta: { total: data.length } }))
|
||||
),
|
||||
courses: this.http
|
||||
.get<any[]>(`${environment.apiBaseUrl}/api/v1/courses`, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
catchError(() => of([])),
|
||||
map((data) => ({ data, meta: { total: data.length } }))
|
||||
),
|
||||
}).pipe(
|
||||
map(({ reunions, courses }) => {
|
||||
// Count reunions per hippodrome
|
||||
const reunionCountMap = new Map<string, number>();
|
||||
reunions.data.forEach((reunion: any) => {
|
||||
const hippodromeId = String(reunion.hippodromeId || reunion.hippodrome?.id);
|
||||
if (hippodromeId && hippodromeId !== 'undefined' && hippodromeId !== 'null') {
|
||||
reunionCountMap.set(
|
||||
hippodromeId,
|
||||
(reunionCountMap.get(hippodromeId) || 0) + 1
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Create a map of reunionId -> hippodromeId from reunions
|
||||
const reunionToHippodromeMap = new Map<string, string>();
|
||||
reunions.data.forEach((reunion: any) => {
|
||||
const reunionId = String(reunion.id);
|
||||
const hippodromeId = String(reunion.hippodromeId || reunion.hippodrome?.id);
|
||||
if (
|
||||
reunionId &&
|
||||
reunionId !== 'undefined' &&
|
||||
reunionId !== 'null' &&
|
||||
hippodromeId &&
|
||||
hippodromeId !== 'undefined' &&
|
||||
hippodromeId !== 'null'
|
||||
) {
|
||||
reunionToHippodromeMap.set(reunionId, hippodromeId);
|
||||
}
|
||||
});
|
||||
|
||||
// Count courses per hippodrome using the reunion -> hippodrome mapping
|
||||
const courseCountMap = new Map<string, number>();
|
||||
courses.data.forEach((course: any) => {
|
||||
const reunionId = String(course.reunionId || course.reunion?.id);
|
||||
if (reunionId && reunionId !== 'undefined' && reunionId !== 'null') {
|
||||
const hippodromeId = reunionToHippodromeMap.get(reunionId);
|
||||
if (hippodromeId) {
|
||||
courseCountMap.set(
|
||||
hippodromeId,
|
||||
(courseCountMap.get(hippodromeId) || 0) + 1
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Add counts to hippodromes
|
||||
const hippodromesWithCounts = allData.map((h) => ({
|
||||
...h,
|
||||
reunionCount: reunionCountMap.get(String(h.id)) ?? 0,
|
||||
courseCount: courseCountMap.get(String(h.id)) ?? 0,
|
||||
}));
|
||||
|
||||
// Apply client-side filtering, sorting, and pagination
|
||||
let filtered = this.applyClientFilters(hippodromesWithCounts, params);
|
||||
const total = filtered.length;
|
||||
const start = (params.page - 1) * params.perPage;
|
||||
const pageData = filtered.slice(start, start + params.perPage);
|
||||
|
||||
const uniqueCountries = new Set(filtered.map((h) => h.pays)).size;
|
||||
const uniqueCities = new Set(filtered.map((h) => h.ville)).size;
|
||||
const averageByCountry = filtered.length
|
||||
? Math.round(filtered.length / uniqueCountries)
|
||||
: 0;
|
||||
const totalReunions = filtered.reduce((acc, h) => acc + (h.reunionCount ?? 0), 0);
|
||||
const totalCourses = filtered.reduce((acc, h) => acc + (h.courseCount ?? 0), 0);
|
||||
|
||||
return normalizePage<Hippodrome>(
|
||||
{
|
||||
data: pageData,
|
||||
meta: {
|
||||
total,
|
||||
uniqueCountries,
|
||||
uniqueCities,
|
||||
averageByCountry,
|
||||
totalReunions,
|
||||
totalCourses,
|
||||
},
|
||||
},
|
||||
params.page,
|
||||
params.perPage
|
||||
);
|
||||
})
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error('Error fetching hippodromes:', err);
|
||||
return of(
|
||||
normalizePage<Hippodrome>(
|
||||
{
|
||||
data: [],
|
||||
meta: {
|
||||
total: 0,
|
||||
uniqueCountries: 0,
|
||||
uniqueCities: 0,
|
||||
averageByCountry: 0,
|
||||
totalReunions: 0,
|
||||
totalCourses: 0,
|
||||
},
|
||||
},
|
||||
params.page,
|
||||
params.perPage
|
||||
)
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Mock mode disabled - return empty result
|
||||
return of(
|
||||
normalizePage<Hippodrome>(
|
||||
{
|
||||
data: [],
|
||||
meta: {
|
||||
total: 0,
|
||||
uniqueCountries: 0,
|
||||
uniqueCities: 0,
|
||||
averageByCountry: 0,
|
||||
totalReunions: 0,
|
||||
totalCourses: 0,
|
||||
},
|
||||
},
|
||||
params.page,
|
||||
params.perPage
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private applyClientFilters(data: Hippodrome[], params: ListParams): Hippodrome[] {
|
||||
let filtered = [...data];
|
||||
|
||||
// Search filter
|
||||
const q = (params.search ?? '').toLowerCase();
|
||||
if (q) {
|
||||
filtered = filtered.filter(
|
||||
(h) =>
|
||||
h.nom.toLowerCase().includes(q) ||
|
||||
h.ville.toLowerCase().includes(q) ||
|
||||
h.pays.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
// Sort
|
||||
if (params.sortKey && params.sortDir) {
|
||||
const { sortKey, sortDir } = params;
|
||||
filtered.sort((a: any, b: any) => {
|
||||
const va = a[sortKey!],
|
||||
vb = b[sortKey!];
|
||||
let cmp: number;
|
||||
|
||||
if (typeof va === 'number' && typeof vb === 'number') {
|
||||
cmp = va - vb;
|
||||
} else {
|
||||
const sa = va == null ? '' : String(va);
|
||||
const sb = vb == null ? '' : String(vb);
|
||||
cmp = sa.localeCompare(sb, 'fr', { numeric: true, sensitivity: 'base' });
|
||||
}
|
||||
|
||||
return sortDir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
// READ
|
||||
getById(id: string): Observable<Hippodrome | undefined> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<Hippodrome>(`${this.apiUrl}/${id}`, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
console.error(`Error fetching hippodrome ${id}:`, err);
|
||||
return of(this.store().find((h) => h.id === id));
|
||||
})
|
||||
);
|
||||
}
|
||||
const found = this.store().find((h) => h.id === id);
|
||||
return of(found);
|
||||
}
|
||||
|
||||
// CREATE
|
||||
create(payload: Omit<Hippodrome, 'id'>): Observable<Hippodrome> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.post<Hippodrome>(this.apiUrl, payload, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
console.error('Error creating hippodrome:', err);
|
||||
const item: Hippodrome = { id: crypto.randomUUID(), ...payload };
|
||||
this.store.set([item, ...this.store()]);
|
||||
return of(item);
|
||||
})
|
||||
);
|
||||
}
|
||||
const item: Hippodrome = { id: crypto.randomUUID(), ...payload };
|
||||
this.store.set([item, ...this.store()]);
|
||||
return of(item);
|
||||
}
|
||||
|
||||
// UPDATE
|
||||
update(id: string, payload: Partial<Hippodrome>): Observable<Hippodrome | undefined> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.put<Hippodrome>(`${this.apiUrl}/${id}`, payload, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
console.error(`Error updating hippodrome ${id}:`, err);
|
||||
let updated: Hippodrome | undefined;
|
||||
this.store.set(
|
||||
this.store().map((h) => {
|
||||
if (h.id === id) {
|
||||
updated = { ...h, ...payload };
|
||||
return updated;
|
||||
}
|
||||
return h;
|
||||
})
|
||||
);
|
||||
return of(updated);
|
||||
})
|
||||
);
|
||||
}
|
||||
let updated: Hippodrome | undefined;
|
||||
this.store.set(
|
||||
this.store().map((h) => {
|
||||
if (h.id === id) {
|
||||
updated = { ...h, ...payload };
|
||||
return updated;
|
||||
}
|
||||
return h;
|
||||
})
|
||||
);
|
||||
return of(updated);
|
||||
}
|
||||
|
||||
// DELETE
|
||||
delete(id: string): Observable<boolean> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.delete<void>(`${this.apiUrl}/${id}`, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map(() => true),
|
||||
catchError((err) => {
|
||||
console.error(`Error deleting hippodrome ${id}:`, err);
|
||||
const before = this.store().length;
|
||||
this.store.set(this.store().filter((h) => h.id !== id));
|
||||
return of(this.store().length < before);
|
||||
})
|
||||
);
|
||||
}
|
||||
const before = this.store().length;
|
||||
this.store.set(this.store().filter((h) => h.id !== id));
|
||||
return of(this.store().length < before);
|
||||
}
|
||||
|
||||
// GET by ville
|
||||
getByVille(ville: string): Observable<Hippodrome[]> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<Hippodrome[]>(`${this.apiUrl}/ville/${encodeURIComponent(ville)}`, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
console.error(`Error fetching hippodromes by ville ${ville}:`, err);
|
||||
return of(this.store().filter((h) => h.ville === ville));
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(this.store().filter((h) => h.ville === ville));
|
||||
}
|
||||
|
||||
// SEARCH by query (q parameter)
|
||||
search(query: string): Observable<Hippodrome[]> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<Hippodrome[]>(`${this.apiUrl}/search`, {
|
||||
params: { nom: query.trim() },
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
console.error(`Error searching hippodromes with query ${query}:`, err);
|
||||
const q = query.toLowerCase();
|
||||
return of(
|
||||
this.store().filter(
|
||||
(h) =>
|
||||
h.nom.toLowerCase().includes(q) ||
|
||||
h.ville.toLowerCase().includes(q) ||
|
||||
h.pays.toLowerCase().includes(q)
|
||||
)
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
const q = query.toLowerCase();
|
||||
return of(
|
||||
this.store().filter(
|
||||
(h) =>
|
||||
h.nom.toLowerCase().includes(q) ||
|
||||
h.ville.toLowerCase().includes(q) ||
|
||||
h.pays.toLowerCase().includes(q)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// GET actifs
|
||||
getActifs(): Observable<Hippodrome[]> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<Hippodrome[]>(`${this.apiUrl}/actifs`, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
console.error('Error fetching active hippodromes:', err);
|
||||
return of(this.store().filter((h) => h.actif));
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(this.store().filter((h) => h.actif));
|
||||
}
|
||||
}
|
||||
40
src/app/core/services/non-partant.ts
Normal file
40
src/app/core/services/non-partant.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { map, catchError } from 'rxjs/operators';
|
||||
import { environment } from 'src/environments/environment.development';
|
||||
|
||||
const USE_SERVER = true;
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class NonPartantService {
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
// Helper method to get ngrok bypass headers
|
||||
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' } : {};
|
||||
}
|
||||
|
||||
// PUT /api/v1/courses/{courseId}/non-partants - Replace the list of non-partants for a course
|
||||
replaceNonPartants(courseId: string, nonPartants: string[]): Observable<string[]> {
|
||||
if (USE_SERVER) {
|
||||
const courseApiUrl = environment.apiBaseUrl + '/api/v1/courses';
|
||||
return this.http
|
||||
.put<string[]>(`${courseApiUrl}/${courseId}/non-partants`, nonPartants, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
map((list) => list.map((np) => String(np))),
|
||||
catchError((err) => {
|
||||
console.error(`Error replacing non-partants for course ${courseId}:`, err);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of([]);
|
||||
}
|
||||
}
|
||||
76
src/app/core/services/report.ts
Normal file
76
src/app/core/services/report.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { CourseReportDetail, CourseReportDetailRow, CourseReportSummary } from '../interfaces/report';
|
||||
import { REPORT_SUMMARIES_MOCK, REPORT_DETAILS_MOCK } from '../mocks/report.mocks';
|
||||
import { normalizePage } from '@shared/paging/normalize-page';
|
||||
import { ListParams, PagedResult, SortDir } from '@shared/paging/paging';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ReportService {
|
||||
private summaries = signal<CourseReportSummary[]>([...REPORT_SUMMARIES_MOCK]);
|
||||
|
||||
list(params: ListParams): Observable<PagedResult<CourseReportSummary>> {
|
||||
let data = [...this.summaries()];
|
||||
const q = (params.search ?? '').toLowerCase();
|
||||
if (q) {
|
||||
data = data.filter((r) =>
|
||||
[
|
||||
r.course.nom,
|
||||
r.course.type,
|
||||
r.course.reunion?.hippodrome?.nom,
|
||||
String(r.course.numero),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.map((s) => String(s).toLowerCase())
|
||||
.some((s) => s.includes(q))
|
||||
);
|
||||
}
|
||||
if (params.sortKey && params.sortDir) {
|
||||
const { sortKey, sortDir } = params as { sortKey: string; sortDir: SortDir };
|
||||
const get = (o: any, k: string) => k.split('.').reduce((a, b) => a?.[b], o);
|
||||
data = [...data].sort((a, b) => String(get(a, sortKey) ?? '').localeCompare(String(get(b, sortKey) ?? ''), 'fr', { numeric: true }) * (sortDir === 'asc' ? 1 : -1));
|
||||
}
|
||||
const start = (params.page - 1) * params.perPage;
|
||||
const pageData = data.slice(start, start + params.perPage);
|
||||
return of(normalizePage<CourseReportSummary>({ data: pageData, meta: { total: data.length } }, params.page, params.perPage));
|
||||
}
|
||||
|
||||
getDetail(courseId: string): Observable<CourseReportDetail | undefined> {
|
||||
const summary = this.summaries().find((s) => s.id === courseId);
|
||||
if (!summary) return of(undefined);
|
||||
const rows = REPORT_DETAILS_MOCK.get(courseId) ?? [];
|
||||
return of({ summary, rows });
|
||||
}
|
||||
|
||||
// === Actions ===
|
||||
validate(courseId: string): Observable<CourseReportSummary | undefined> {
|
||||
let updated: CourseReportSummary | undefined;
|
||||
this.summaries.set(
|
||||
this.summaries().map((s) => (s.id === courseId ? ((updated = { ...s, statut: 'Validé', confirmed: false }), updated) : s))
|
||||
);
|
||||
return of(updated);
|
||||
}
|
||||
|
||||
confirm(courseId: string): Observable<CourseReportSummary | undefined> {
|
||||
let updated: CourseReportSummary | undefined;
|
||||
this.summaries.set(
|
||||
this.summaries().map((s) => (s.id === courseId ? ((updated = { ...s, statut: 'Validé', confirmed: true }), updated) : s))
|
||||
);
|
||||
return of(updated);
|
||||
}
|
||||
|
||||
resetStatus(courseId: string): Observable<CourseReportSummary | undefined> {
|
||||
let updated: CourseReportSummary | undefined;
|
||||
this.summaries.set(
|
||||
this.summaries().map((s) => (s.id === courseId ? ((updated = { ...s, statut: 'Non Validé', confirmed: false }), updated) : s))
|
||||
);
|
||||
return of(updated);
|
||||
}
|
||||
|
||||
modifyRows(courseId: string, rows: CourseReportDetailRow[]): Observable<boolean> {
|
||||
REPORT_DETAILS_MOCK.set(courseId, rows);
|
||||
return of(true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
282
src/app/core/services/resultat.ts
Normal file
282
src/app/core/services/resultat.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, of, forkJoin } from 'rxjs';
|
||||
import { map, catchError, switchMap } from 'rxjs/operators';
|
||||
import { Resultat, ResultatApiResponse, CreateResultatPayload } from '../interfaces/resultat';
|
||||
import { Course } from '../interfaces/course';
|
||||
import { CourseService } from './course';
|
||||
import { environment } from 'src/environments/environment.development';
|
||||
|
||||
const USE_SERVER = true;
|
||||
const API_BASE = '/api/v1/resultat';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ResultatService {
|
||||
private apiUrl = environment.apiBaseUrl + API_BASE;
|
||||
|
||||
constructor(private http: HttpClient, private courseService: CourseService) {}
|
||||
|
||||
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' } : {};
|
||||
}
|
||||
|
||||
// GET /api/v1/resultat/{id}
|
||||
getById(id: string): Observable<Resultat | undefined> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<ResultatApiResponse>(`${this.apiUrl}/${id}`, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
switchMap((apiResultat) => {
|
||||
// Fetch the full course object if course is just an ID
|
||||
const courseId =
|
||||
typeof apiResultat.course === 'object' && 'id' in apiResultat.course
|
||||
? String(apiResultat.course.id)
|
||||
: String(apiResultat.course);
|
||||
|
||||
return this.courseService.getById(courseId).pipe(
|
||||
map((course) => {
|
||||
if (!course) {
|
||||
return undefined;
|
||||
}
|
||||
return this.transformApiResponse(apiResultat, course);
|
||||
})
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error(`Error fetching resultat ${id}:`, err);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(undefined);
|
||||
}
|
||||
|
||||
// GET /api/v1/resultat
|
||||
list(): Observable<Resultat[]> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<ResultatApiResponse[]>(this.apiUrl, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
switchMap((apiResultats) => {
|
||||
// Fetch all unique course IDs
|
||||
const courseIds = [
|
||||
...new Set(
|
||||
apiResultats.map((r) =>
|
||||
typeof r.course === 'object' && 'id' in r.course
|
||||
? String(r.course.id)
|
||||
: String(r.course)
|
||||
)
|
||||
),
|
||||
];
|
||||
// Fetch all courses in parallel
|
||||
const courseRequests = courseIds.map((id) =>
|
||||
this.courseService
|
||||
.getById(id)
|
||||
.pipe(catchError(() => of<Course | undefined>(undefined)))
|
||||
);
|
||||
|
||||
return forkJoin(courseRequests).pipe(
|
||||
map((courses) => {
|
||||
const courseMap = new Map<string, Course>();
|
||||
courseIds.forEach((id, index) => {
|
||||
const course = courses[index];
|
||||
if (course) {
|
||||
courseMap.set(id, course);
|
||||
}
|
||||
});
|
||||
|
||||
return apiResultats
|
||||
.map((apiResultat) => {
|
||||
const courseId =
|
||||
typeof apiResultat.course === 'object' && 'id' in apiResultat.course
|
||||
? String(apiResultat.course.id)
|
||||
: String(apiResultat.course);
|
||||
const course = courseMap.get(courseId);
|
||||
if (!course) {
|
||||
return null;
|
||||
}
|
||||
return this.transformApiResponse(apiResultat, course);
|
||||
})
|
||||
.filter((r): r is Resultat => r !== null);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error('Error fetching resultats:', err);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error('Error fetching resultats:', err);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of([]);
|
||||
}
|
||||
|
||||
// GET /api/v1/resultat/course/{courseId}
|
||||
getByCourseId(courseId: string): Observable<Resultat | undefined> {
|
||||
if (!USE_SERVER) {
|
||||
return of(undefined);
|
||||
}
|
||||
|
||||
return this.http
|
||||
.get<any>(`${this.apiUrl}/course/${courseId}`, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
switchMap((raw) => {
|
||||
// Some courses don't have a resultat yet.
|
||||
// In that case the API returns 200 with a body like:
|
||||
// { "message": "Aucun résultat disponible pour cette course" }
|
||||
// We interpret this as "no resultat" and return undefined.
|
||||
if (
|
||||
raw &&
|
||||
typeof raw === 'object' &&
|
||||
'message' in raw &&
|
||||
!('id' in raw) &&
|
||||
!('ordreArrivee' in raw)
|
||||
) {
|
||||
return of<Resultat | undefined>(undefined);
|
||||
}
|
||||
|
||||
const apiResultat = raw as ResultatApiResponse;
|
||||
|
||||
return this.courseService.getById(courseId).pipe(
|
||||
map((course) => {
|
||||
if (!course) {
|
||||
return undefined;
|
||||
}
|
||||
return this.transformApiResponse(apiResultat, course);
|
||||
})
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
// If the backend ever responds with 404 here, also treat as "no resultat".
|
||||
if (err?.status === 404) {
|
||||
return of(undefined);
|
||||
}
|
||||
|
||||
console.error(`Error fetching resultat for course ${courseId}:`, err);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// POST /api/v1/resultat
|
||||
create(payload: CreateResultatPayload): Observable<Resultat> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.post<ResultatApiResponse>(this.apiUrl, payload, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
switchMap((apiResultat) => {
|
||||
const courseId = String(payload.course.id);
|
||||
return this.courseService.getById(courseId).pipe(
|
||||
map((course) => {
|
||||
if (!course) {
|
||||
throw new Error('Course not found');
|
||||
}
|
||||
return this.transformApiResponse(apiResultat, course);
|
||||
})
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error('Error creating resultat:', err);
|
||||
throw err;
|
||||
})
|
||||
);
|
||||
}
|
||||
throw new Error('Server mode is required');
|
||||
}
|
||||
|
||||
// PUT /api/v1/resultat/{id}
|
||||
update(id: string, payload: Partial<CreateResultatPayload>): Observable<Resultat | undefined> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.put<ResultatApiResponse>(`${this.apiUrl}/${id}`, payload, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
switchMap((apiResultat) => {
|
||||
const courseId =
|
||||
typeof apiResultat.course === 'object' && 'id' in apiResultat.course
|
||||
? String(apiResultat.course.id)
|
||||
: String(apiResultat.course);
|
||||
|
||||
return this.courseService.getById(courseId).pipe(
|
||||
map((course) => {
|
||||
if (!course) {
|
||||
return undefined;
|
||||
}
|
||||
return this.transformApiResponse(apiResultat, course);
|
||||
})
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error(`Error updating resultat ${id}:`, err);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
throw new Error('Server mode is required');
|
||||
}
|
||||
|
||||
// DELETE /api/v1/resultat/{id}
|
||||
delete(id: string): Observable<boolean> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.delete<void>(`${this.apiUrl}/${id}`, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map(() => true),
|
||||
catchError((err) => {
|
||||
console.error(`Error deleting resultat ${id}:`, err);
|
||||
return of(false);
|
||||
})
|
||||
);
|
||||
}
|
||||
throw new Error('Server mode is required');
|
||||
}
|
||||
|
||||
// DELETE /api/v1/resultat/course/{courseId}
|
||||
deleteByCourseId(courseId: string): Observable<boolean> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.delete<void>(`${this.apiUrl}/course/${courseId}`, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map(() => true),
|
||||
catchError((err) => {
|
||||
console.error(`Error deleting resultat for course ${courseId}:`, err);
|
||||
return of(false);
|
||||
})
|
||||
);
|
||||
}
|
||||
throw new Error('Server mode is required');
|
||||
}
|
||||
|
||||
private transformApiResponse(apiResultat: ResultatApiResponse, course: Course): Resultat {
|
||||
return {
|
||||
id: String(apiResultat.id),
|
||||
course,
|
||||
// Normalize ordreArrivee to an array of cheval numbers
|
||||
ordreArrivee: (apiResultat.ordreArrivee || [])
|
||||
.map((v) => (typeof v === 'string' ? Number(v) : v))
|
||||
.filter((v): v is number => typeof v === 'number' && !Number.isNaN(v)),
|
||||
// Normalize dead-heat horses to numbers as well
|
||||
chevauxDeadHeat: (apiResultat.chevauxDeadHeat || [])
|
||||
.map((v) => (typeof v === 'string' ? Number(v) : v))
|
||||
.filter((v): v is number => typeof v === 'number' && !Number.isNaN(v)),
|
||||
totalMises: apiResultat.totalMises,
|
||||
masseAPartager: apiResultat.masseAPartager,
|
||||
prelevementsLegaux: apiResultat.prelevementsLegaux,
|
||||
montantRembourse: apiResultat.montantRembourse,
|
||||
montantCagnotte: apiResultat.montantCagnotte,
|
||||
adeadHeat: apiResultat.adeadHeat,
|
||||
createdAt: apiResultat.createdAt,
|
||||
updatedAt: apiResultat.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
16
src/app/core/services/reunion.spec.ts
Normal file
16
src/app/core/services/reunion.spec.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { Reunion } from './reunion';
|
||||
|
||||
describe('Reunion', () => {
|
||||
let service: Reunion;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(Reunion);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
||||
537
src/app/core/services/reunion.ts
Normal file
537
src/app/core/services/reunion.ts
Normal file
@@ -0,0 +1,537 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, of, forkJoin } from 'rxjs';
|
||||
import { map, catchError, switchMap } from 'rxjs/operators';
|
||||
import { Reunion } from '../interfaces/reunion';
|
||||
import { Hippodrome } from '../interfaces/hippodrome';
|
||||
import { normalizePage } from '@shared/paging/normalize-page';
|
||||
import { PaginatedHttpService } from '@shared/paging/paginated-http.service';
|
||||
import { ListParams, PagedResult } from '@shared/paging/paging';
|
||||
import { environment } from 'src/environments/environment.development';
|
||||
import { HippodromeService } from './hippodrome';
|
||||
|
||||
// API response interface (has hippodromeId instead of hippodrome)
|
||||
interface ReunionApiResponse {
|
||||
id: string;
|
||||
code: string;
|
||||
nom: string;
|
||||
date: string;
|
||||
numero: number;
|
||||
statut: string;
|
||||
hippodromeId: string;
|
||||
totalCourses?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const USE_SERVER = true;
|
||||
const API_BASE = '/api/v1/reunions';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ReunionService {
|
||||
private apiUrl = environment.apiBaseUrl + API_BASE;
|
||||
private store = signal<Reunion[]>([]);
|
||||
|
||||
constructor(
|
||||
private http: HttpClient,
|
||||
private paginatedHttp: PaginatedHttpService,
|
||||
private hippodromeService: HippodromeService
|
||||
) {}
|
||||
|
||||
// Helper method to get ngrok bypass headers
|
||||
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,
|
||||
usePaginationEndpoint: boolean = false
|
||||
): Observable<PagedResult<Reunion>> {
|
||||
if (USE_SERVER) {
|
||||
if (usePaginationEndpoint) {
|
||||
return this.paginatedHttp
|
||||
.fetch<ReunionApiResponse>(this.apiUrl, params, {
|
||||
zeroBasedPageIndex: false,
|
||||
buildSort: (key, dir) => (key && dir ? ['sort', `${key},${dir}`] : null),
|
||||
})
|
||||
.pipe(
|
||||
switchMap((pagedResult) => {
|
||||
// Handle empty data case
|
||||
if (!pagedResult.data || pagedResult.data.length === 0) {
|
||||
return of({
|
||||
...pagedResult,
|
||||
data: [],
|
||||
meta: {
|
||||
...pagedResult.meta,
|
||||
uniqueHippodromes: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Extract unique hippodrome IDs from the paginated data
|
||||
const uniqueHippodromeIds = [
|
||||
...new Set(pagedResult.data.map((r) => String(r.hippodromeId))),
|
||||
];
|
||||
|
||||
// Handle case where there are no unique IDs
|
||||
if (uniqueHippodromeIds.length === 0) {
|
||||
return of({
|
||||
...pagedResult,
|
||||
data: [],
|
||||
meta: {
|
||||
...pagedResult.meta,
|
||||
uniqueHippodromes: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch all unique hippodromes in parallel
|
||||
const hippodromeRequests = uniqueHippodromeIds.map((id) =>
|
||||
this.hippodromeService
|
||||
.getById(id)
|
||||
.pipe(catchError(() => of<Hippodrome | undefined>(undefined)))
|
||||
);
|
||||
|
||||
// Fetch courses to calculate counts per reunion
|
||||
const coursesRequest = this.http
|
||||
.get<any[]>(`${environment.apiBaseUrl}/api/v1/courses`, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
catchError(() => of([])),
|
||||
map((data) => data || [])
|
||||
);
|
||||
|
||||
return forkJoin({
|
||||
hippodromes: forkJoin(hippodromeRequests),
|
||||
courses: coursesRequest,
|
||||
}).pipe(
|
||||
map(({ hippodromes, courses }) => {
|
||||
// Create a map of hippodrome ID to hippodrome object
|
||||
const hippodromeMap = new Map<string, Hippodrome>();
|
||||
uniqueHippodromeIds.forEach((id, index) => {
|
||||
const hippodrome = hippodromes[index];
|
||||
if (hippodrome) {
|
||||
hippodromeMap.set(id, hippodrome);
|
||||
}
|
||||
});
|
||||
|
||||
// Count courses per reunion
|
||||
const courseCountMap = new Map<string, number>();
|
||||
courses.forEach((course: any) => {
|
||||
const reunionId = String(course.reunionId || course.reunion?.id);
|
||||
if (reunionId && reunionId !== 'undefined' && reunionId !== 'null') {
|
||||
courseCountMap.set(
|
||||
reunionId,
|
||||
(courseCountMap.get(reunionId) || 0) + 1
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Transform API responses to Reunion objects
|
||||
const transformedData: Reunion[] = pagedResult.data
|
||||
.map((apiReunion) => {
|
||||
const hippodrome = hippodromeMap.get(String(apiReunion.hippodromeId));
|
||||
if (!hippodrome) {
|
||||
return null;
|
||||
}
|
||||
const reunionId = String(apiReunion.id);
|
||||
const courseCount = courseCountMap.get(reunionId) ?? apiReunion.totalCourses ?? 0;
|
||||
return {
|
||||
id: reunionId,
|
||||
code: apiReunion.code,
|
||||
nom: apiReunion.nom,
|
||||
date: apiReunion.date,
|
||||
numero: apiReunion.numero,
|
||||
statut: apiReunion.statut as any,
|
||||
hippodrome,
|
||||
totalCourses: courseCount,
|
||||
createdAt: apiReunion.createdAt,
|
||||
updatedAt: apiReunion.updatedAt,
|
||||
} as Reunion;
|
||||
})
|
||||
.filter((r): r is Reunion => r !== null && r !== undefined);
|
||||
|
||||
// Calculate unique hippodromes count
|
||||
const uniqueHippodromes = new Set(transformedData.map((r) => r.hippodrome.id))
|
||||
.size;
|
||||
|
||||
return {
|
||||
...pagedResult,
|
||||
data: transformedData,
|
||||
meta: {
|
||||
...pagedResult.meta,
|
||||
uniqueHippodromes,
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error('Error fetching reunions:', err);
|
||||
return this.getMockList(params);
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// Fetch all data and apply client-side pagination
|
||||
return this.http
|
||||
.get<ReunionApiResponse[]>(this.apiUrl, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
switchMap((apiData) => {
|
||||
// Handle empty data case
|
||||
if (!apiData || apiData.length === 0) {
|
||||
return of(
|
||||
normalizePage<Reunion>(
|
||||
{
|
||||
data: [],
|
||||
meta: { total: 0, uniqueHippodromes: 0, upcomingReunions: 0, pastReunions: 0 },
|
||||
},
|
||||
params.page,
|
||||
params.perPage
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Extract unique hippodrome IDs
|
||||
const uniqueHippodromeIds = [...new Set(apiData.map((r) => String(r.hippodromeId)))];
|
||||
|
||||
// Handle case where there are no unique IDs (shouldn't happen, but be safe)
|
||||
if (uniqueHippodromeIds.length === 0) {
|
||||
return of(
|
||||
normalizePage<Reunion>(
|
||||
{
|
||||
data: [],
|
||||
meta: { total: 0, uniqueHippodromes: 0, upcomingReunions: 0, pastReunions: 0 },
|
||||
},
|
||||
params.page,
|
||||
params.perPage
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch all unique hippodromes and all courses in parallel
|
||||
const hippodromeRequests = uniqueHippodromeIds.map((id) =>
|
||||
this.hippodromeService
|
||||
.getById(id)
|
||||
.pipe(catchError(() => of<Hippodrome | undefined>(undefined)))
|
||||
);
|
||||
|
||||
// Fetch courses to calculate counts per reunion
|
||||
const coursesRequest = this.http
|
||||
.get<any[]>(`${environment.apiBaseUrl}/api/v1/courses`, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
catchError(() => of([])),
|
||||
map((data) => data || [])
|
||||
);
|
||||
|
||||
return forkJoin({
|
||||
hippodromes: forkJoin(hippodromeRequests),
|
||||
courses: coursesRequest,
|
||||
}).pipe(
|
||||
map(({ hippodromes, courses }) => {
|
||||
// Create a map of hippodrome ID to hippodrome object
|
||||
const hippodromeMap = new Map<string, Hippodrome>();
|
||||
uniqueHippodromeIds.forEach((id, index) => {
|
||||
const hippodrome = hippodromes[index];
|
||||
if (hippodrome) {
|
||||
hippodromeMap.set(id, hippodrome);
|
||||
}
|
||||
});
|
||||
|
||||
// Count courses per reunion
|
||||
const courseCountMap = new Map<string, number>();
|
||||
courses.forEach((course: any) => {
|
||||
const reunionId = String(course.reunionId || course.reunion?.id);
|
||||
if (reunionId && reunionId !== 'undefined' && reunionId !== 'null') {
|
||||
courseCountMap.set(
|
||||
reunionId,
|
||||
(courseCountMap.get(reunionId) || 0) + 1
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Transform API responses to Reunion objects
|
||||
const transformedData: Reunion[] = apiData
|
||||
.map((apiReunion) => {
|
||||
const hippodrome = hippodromeMap.get(String(apiReunion.hippodromeId));
|
||||
if (!hippodrome) {
|
||||
// Skip if hippodrome not found
|
||||
return null;
|
||||
}
|
||||
const reunionId = String(apiReunion.id);
|
||||
const courseCount = courseCountMap.get(reunionId) ?? apiReunion.totalCourses ?? 0;
|
||||
return {
|
||||
id: reunionId,
|
||||
code: apiReunion.code,
|
||||
nom: apiReunion.nom,
|
||||
date: apiReunion.date,
|
||||
numero: apiReunion.numero,
|
||||
statut: apiReunion.statut as any,
|
||||
hippodrome,
|
||||
totalCourses: courseCount,
|
||||
createdAt: apiReunion.createdAt,
|
||||
updatedAt: apiReunion.updatedAt,
|
||||
} as Reunion;
|
||||
})
|
||||
.filter((r): r is Reunion => r !== null && r !== undefined);
|
||||
|
||||
// Apply client-side filtering, sorting, and pagination
|
||||
let filtered = this.applyClientFilters(transformedData, params);
|
||||
const total = filtered.length;
|
||||
const start = (params.page - 1) * params.perPage;
|
||||
const pageData = filtered.slice(start, start + params.perPage);
|
||||
|
||||
const upcomingReunions = filtered.filter(
|
||||
(r) => new Date(r.date) >= new Date()
|
||||
).length;
|
||||
const pastReunions = filtered.filter((r) => new Date(r.date) < new Date()).length;
|
||||
const uniqueHippodromes = new Set(filtered.map((r) => r.hippodrome.id)).size;
|
||||
|
||||
return normalizePage<Reunion>(
|
||||
{
|
||||
data: pageData,
|
||||
meta: { total, uniqueHippodromes, upcomingReunions, pastReunions },
|
||||
},
|
||||
params.page,
|
||||
params.perPage
|
||||
);
|
||||
})
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error('Error fetching reunions:', err);
|
||||
return this.getMockList(params);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return this.getMockList(params);
|
||||
}
|
||||
|
||||
private applyClientFilters(data: Reunion[], params: ListParams): Reunion[] {
|
||||
let filtered = [...data];
|
||||
|
||||
// Search filter
|
||||
const q = (params.search ?? '').toLowerCase();
|
||||
if (q) {
|
||||
filtered = filtered.filter(
|
||||
(r) =>
|
||||
r.nom.toLowerCase().includes(q) ||
|
||||
r.hippodrome.nom.toLowerCase().includes(q) ||
|
||||
r.hippodrome.ville.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
// Sort
|
||||
if (params.sortKey && params.sortDir) {
|
||||
const { sortKey, sortDir } = params;
|
||||
filtered.sort((a: any, b: any) => {
|
||||
const va = a[sortKey!],
|
||||
vb = b[sortKey!];
|
||||
const sa = va == null ? '' : String(va);
|
||||
const sb = vb == null ? '' : String(vb);
|
||||
const cmp = sa.localeCompare(sb, 'fr', { numeric: true, sensitivity: 'base' });
|
||||
return sortDir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
private getMockList(params: ListParams): Observable<PagedResult<Reunion>> {
|
||||
const q = (params.search ?? '').toLowerCase();
|
||||
let data = this.store();
|
||||
|
||||
if (q) {
|
||||
data = data.filter(
|
||||
(r) =>
|
||||
r.nom.toLowerCase().includes(q) ||
|
||||
r.hippodrome.nom.toLowerCase().includes(q) ||
|
||||
r.hippodrome.ville.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
if (params.sortKey && params.sortDir) {
|
||||
const { sortKey, sortDir } = params;
|
||||
data = [...data].sort((a: any, b: any) => {
|
||||
const va = a[sortKey!],
|
||||
vb = b[sortKey!];
|
||||
const sa = va == null ? '' : String(va);
|
||||
const sb = vb == null ? '' : String(vb);
|
||||
const cmp = sa.localeCompare(sb, 'fr', { numeric: true, sensitivity: 'base' });
|
||||
return sortDir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
}
|
||||
|
||||
const start = (params.page - 1) * params.perPage;
|
||||
const pageData = data.slice(start, start + params.perPage);
|
||||
|
||||
const upcomingReunions = data.filter((r) => new Date(r.date) >= new Date()).length;
|
||||
const pastReunions = data.filter((r) => new Date(r.date) < new Date()).length;
|
||||
const uniqueHippodromes = new Set(data.map((r) => r.hippodrome.nom)).size;
|
||||
|
||||
return of(
|
||||
normalizePage<Reunion>(
|
||||
{
|
||||
data: pageData,
|
||||
meta: { total: data.length, uniqueHippodromes, upcomingReunions, pastReunions },
|
||||
},
|
||||
params.page,
|
||||
params.perPage
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
getById(id: string): Observable<Reunion | undefined> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<ReunionApiResponse>(`${this.apiUrl}/${id}`, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
switchMap((apiReunion) => {
|
||||
// Fetch the hippodrome data
|
||||
return this.hippodromeService.getById(String(apiReunion.hippodromeId)).pipe(
|
||||
map((hippodrome) => {
|
||||
if (!hippodrome) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
id: String(apiReunion.id),
|
||||
code: apiReunion.code,
|
||||
nom: apiReunion.nom,
|
||||
date: apiReunion.date,
|
||||
numero: apiReunion.numero,
|
||||
statut: apiReunion.statut as any,
|
||||
hippodrome,
|
||||
totalCourses: apiReunion.totalCourses,
|
||||
createdAt: apiReunion.createdAt,
|
||||
updatedAt: apiReunion.updatedAt,
|
||||
} as Reunion;
|
||||
})
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error(`Error fetching reunion ${id}:`, err);
|
||||
return of(this.store().find((r) => r.id === id));
|
||||
})
|
||||
);
|
||||
}
|
||||
const found = this.store().find((r) => r.id === id);
|
||||
return of(found);
|
||||
}
|
||||
|
||||
getByCode(code: string): Observable<Reunion | undefined> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<ReunionApiResponse>(`${this.apiUrl}/code/${encodeURIComponent(code)}`, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
switchMap((apiReunion) => {
|
||||
// Fetch the hippodrome data
|
||||
return this.hippodromeService.getById(String(apiReunion.hippodromeId)).pipe(
|
||||
map((hippodrome) => {
|
||||
if (!hippodrome) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
id: String(apiReunion.id),
|
||||
code: apiReunion.code,
|
||||
nom: apiReunion.nom,
|
||||
date: apiReunion.date,
|
||||
numero: apiReunion.numero,
|
||||
statut: apiReunion.statut as any,
|
||||
hippodrome,
|
||||
totalCourses: apiReunion.totalCourses,
|
||||
createdAt: apiReunion.createdAt,
|
||||
updatedAt: apiReunion.updatedAt,
|
||||
} as Reunion;
|
||||
})
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error(`Error fetching reunion by code ${code}:`, err);
|
||||
return of(this.store().find((r) => r.code === code));
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(this.store().find((r) => r.code === code));
|
||||
}
|
||||
|
||||
create(payload: Omit<Reunion, 'id'>): Observable<Reunion> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.post<Reunion>(this.apiUrl, payload, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
console.error('Error creating reunion:', err);
|
||||
const item: Reunion = { id: crypto.randomUUID(), ...payload };
|
||||
this.store.set([item, ...this.store()]);
|
||||
return of(item);
|
||||
})
|
||||
);
|
||||
}
|
||||
const item: Reunion = { id: crypto.randomUUID(), ...payload };
|
||||
this.store.set([item, ...this.store()]);
|
||||
return of(item);
|
||||
}
|
||||
|
||||
update(id: string, payload: Partial<Reunion>): Observable<Reunion | undefined> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.put<Reunion>(`${this.apiUrl}/${id}`, payload, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
console.error(`Error updating reunion ${id}:`, err);
|
||||
let updated: Reunion | undefined;
|
||||
this.store.set(
|
||||
this.store().map((r) => {
|
||||
if (r.id === id) {
|
||||
updated = { ...r, ...payload };
|
||||
return updated;
|
||||
}
|
||||
return r;
|
||||
})
|
||||
);
|
||||
return of(updated);
|
||||
})
|
||||
);
|
||||
}
|
||||
let updated: Reunion | undefined;
|
||||
this.store.set(
|
||||
this.store().map((r) => {
|
||||
if (r.id === id) {
|
||||
updated = { ...r, ...payload };
|
||||
return updated;
|
||||
}
|
||||
return r;
|
||||
})
|
||||
);
|
||||
return of(updated);
|
||||
}
|
||||
|
||||
delete(id: string): Observable<boolean> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.delete<void>(`${this.apiUrl}/${id}`, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map(() => true),
|
||||
catchError((err) => {
|
||||
console.error(`Error deleting reunion ${id}:`, err);
|
||||
const before = this.store().length;
|
||||
this.store.set(this.store().filter((r) => r.id !== id));
|
||||
return of(this.store().length < before);
|
||||
})
|
||||
);
|
||||
}
|
||||
const before = this.store().length;
|
||||
this.store.set(this.store().filter((r) => r.id !== id));
|
||||
return of(this.store().length < before);
|
||||
}
|
||||
}
|
||||
283
src/app/core/services/role.ts
Normal file
283
src/app/core/services/role.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { map, catchError } from 'rxjs/operators';
|
||||
import { Permission, Role } from '../interfaces/role';
|
||||
import { normalizePage } from '@shared/paging/normalize-page';
|
||||
import { ListParams, PagedResult } from '@shared/paging/paging';
|
||||
import { environment } from 'src/environments/environment.development';
|
||||
|
||||
const USE_SERVER = true;
|
||||
const ROLES_API_BASE = '/api/v1/roles';
|
||||
const PERMISSIONS_API_BASE = '/api/v1/permissions';
|
||||
|
||||
// API Response interfaces
|
||||
interface PermissionApiResponse {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface RoleApiResponse {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
permissions?: PermissionApiResponse[];
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class RoleService {
|
||||
private rolesUrl = environment.apiBaseUrl + ROLES_API_BASE;
|
||||
private permissionsUrl = environment.apiBaseUrl + PERMISSIONS_API_BASE;
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
// Helper method to get ngrok bypass headers
|
||||
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' } : {};
|
||||
}
|
||||
|
||||
// Transform API response to Permission
|
||||
private transformPermission(api: PermissionApiResponse): Permission {
|
||||
return {
|
||||
id: String(api.id),
|
||||
name: api.name,
|
||||
description: api.description,
|
||||
};
|
||||
}
|
||||
|
||||
// Transform API response to Role
|
||||
private transformRole(api: RoleApiResponse): Role {
|
||||
return {
|
||||
id: String(api.id),
|
||||
name: api.name,
|
||||
description: api.description,
|
||||
permissions: (api.permissions || []).map((p) => this.transformPermission(p)),
|
||||
createdAt: api.createdAt,
|
||||
updatedAt: api.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// Transform Role to API payload
|
||||
private transformRoleToApi(role: Partial<Role>): any {
|
||||
return {
|
||||
id: role.id ? Number(role.id) : undefined,
|
||||
name: role.name ?? '',
|
||||
description: role.description,
|
||||
permissions: (role.permissions || []).map((p) => ({
|
||||
id: p.id ? Number(p.id) : undefined,
|
||||
name: p.name,
|
||||
description: p.description,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// Transform Permission to API payload
|
||||
private transformPermissionToApi(perm: Partial<Permission>): any {
|
||||
return {
|
||||
id: perm.id ? Number(perm.id) : undefined,
|
||||
name: perm.name ?? '',
|
||||
description: perm.description,
|
||||
};
|
||||
}
|
||||
|
||||
// Helpers
|
||||
private buildParams(params: ListParams): HttpParams {
|
||||
let httpParams = new HttpParams()
|
||||
.set('page', String(params.page - 1))
|
||||
.set('size', String(params.perPage));
|
||||
if (params.search) {
|
||||
httpParams = httpParams.set('search', params.search);
|
||||
}
|
||||
if (params.sortKey && params.sortDir) {
|
||||
httpParams = httpParams.set('sort', `${params.sortKey},${params.sortDir}`);
|
||||
}
|
||||
return httpParams;
|
||||
}
|
||||
|
||||
/**
|
||||
* LIST roles – supports both backend pagination and fallback to simple GET all
|
||||
*/
|
||||
list(params: ListParams): Observable<PagedResult<Role>> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<RoleApiResponse[]>(this.rolesUrl, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
params: this.buildParams(params),
|
||||
})
|
||||
.pipe(
|
||||
map((data) => {
|
||||
const roles = (data || []).map((r) => this.transformRole(r));
|
||||
return normalizePage<Role>(
|
||||
{ data: roles, meta: { total: roles.length } },
|
||||
params.page,
|
||||
params.perPage
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error('Error fetching roles:', err);
|
||||
return of(
|
||||
normalizePage<Role>({ data: [], meta: { total: 0 } }, params.page, params.perPage)
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback (should not be used anymore)
|
||||
return of(
|
||||
normalizePage<Role>(
|
||||
{
|
||||
data: [],
|
||||
meta: { total: 0 },
|
||||
},
|
||||
params.page,
|
||||
params.perPage
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* LIST all permissions
|
||||
*/
|
||||
allPermissions(): Observable<Permission[]> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<PermissionApiResponse[]>(this.permissionsUrl, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map((res) => (res || []).map((p) => this.transformPermission(p))),
|
||||
catchError((err) => {
|
||||
console.error('Error fetching permissions:', err);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* CREATE role
|
||||
*/
|
||||
create(payload: Omit<Role, 'id'>): Observable<Role> {
|
||||
const apiPayload = this.transformRoleToApi(payload);
|
||||
return this.http
|
||||
.post<RoleApiResponse>(this.rolesUrl, apiPayload, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map((r) => this.transformRole(r)),
|
||||
catchError((err) => {
|
||||
console.error('Error creating role:', err);
|
||||
throw err;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* UPDATE role
|
||||
*/
|
||||
update(id: string, payload: Partial<Role>): Observable<Role | undefined> {
|
||||
const apiPayload = this.transformRoleToApi(payload);
|
||||
return this.http
|
||||
.put<RoleApiResponse>(`${this.rolesUrl}/${id}`, apiPayload, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
map((r) => this.transformRole(r)),
|
||||
catchError((err) => {
|
||||
console.error(`Error updating role ${id}:`, err);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE role
|
||||
*/
|
||||
delete(id: string): Observable<{ success: boolean; error?: string }> {
|
||||
return this.http
|
||||
.delete<void>(`${this.rolesUrl}/${id}`, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map(() => ({ success: true })),
|
||||
catchError((err) => {
|
||||
console.error(`Error deleting role ${id}:`, err);
|
||||
// Check if error is due to role being used by users
|
||||
const errorMessage =
|
||||
err?.error?.message ||
|
||||
err?.message ||
|
||||
(err?.status === 409 || err?.status === 400
|
||||
? 'Ce rôle est utilisé par des utilisateurs et ne peut pas être supprimé'
|
||||
: 'Erreur lors de la suppression du rôle');
|
||||
return of({ success: false, error: errorMessage });
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// --------------- PERMISSIONS CRUD ----------------
|
||||
|
||||
getPermission(id: string): Observable<Permission | null> {
|
||||
return this.http
|
||||
.get<PermissionApiResponse>(`${this.permissionsUrl}/${id}`, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
map((p) => this.transformPermission(p)),
|
||||
catchError((err) => {
|
||||
console.error(`Error fetching permission ${id}:`, err);
|
||||
return of(null);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
createPermission(payload: Omit<Permission, 'id'>): Observable<Permission> {
|
||||
const apiPayload = this.transformPermissionToApi(payload);
|
||||
return this.http
|
||||
.post<PermissionApiResponse>(this.permissionsUrl, apiPayload, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
map((p) => this.transformPermission(p)),
|
||||
catchError((err) => {
|
||||
console.error('Error creating permission:', err);
|
||||
throw err;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
updatePermission(id: string, payload: Partial<Permission>): Observable<Permission | undefined> {
|
||||
const apiPayload = this.transformPermissionToApi(payload);
|
||||
return this.http
|
||||
.put<PermissionApiResponse>(`${this.permissionsUrl}/${id}`, apiPayload, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
map((p) => this.transformPermission(p)),
|
||||
catchError((err) => {
|
||||
console.error(`Error updating permission ${id}:`, err);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
deletePermission(id: string): Observable<{ success: boolean; error?: string }> {
|
||||
return this.http
|
||||
.delete<void>(`${this.permissionsUrl}/${id}`, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map(() => ({ success: true })),
|
||||
catchError((err) => {
|
||||
console.error(`Error deleting permission ${id}:`, err);
|
||||
// Check if error is due to permission being used by roles
|
||||
const errorMessage =
|
||||
err?.error?.message ||
|
||||
err?.message ||
|
||||
(err?.status === 409 || err?.status === 400
|
||||
? 'Cette permission est utilisée par des rôles et ne peut pas être supprimée'
|
||||
: 'Erreur lors de la suppression de la permission');
|
||||
return of({ success: false, error: errorMessage });
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
16
src/app/core/services/theme.spec.ts
Normal file
16
src/app/core/services/theme.spec.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { Theme } from './theme';
|
||||
|
||||
describe('Theme', () => {
|
||||
let service: Theme;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(Theme);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
||||
90
src/app/core/services/theme.ts
Normal file
90
src/app/core/services/theme.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Injectable, OnDestroy, signal } from '@angular/core';
|
||||
|
||||
const STORAGE_KEY = 'pmu_theme'; // 'light' | 'dark' | 'system'
|
||||
|
||||
type Mode = 'light' | 'dark' | 'system';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class Theme implements OnDestroy {
|
||||
mode = signal<Mode>('light');
|
||||
|
||||
private mql?: MediaQueryList;
|
||||
private onMqlChange = (e: MediaQueryListEvent) => {
|
||||
// only react if user selected "system"
|
||||
if (this.mode() === 'system') this.apply('system', /*fromMql*/ true);
|
||||
};
|
||||
|
||||
constructor() {
|
||||
const saved = (localStorage.getItem(STORAGE_KEY) as Mode | null) ?? 'system';
|
||||
this.setupMql();
|
||||
this.apply(saved);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.teardownMql();
|
||||
}
|
||||
|
||||
toggle() {
|
||||
// If you're on "system", decide based on current resolved value
|
||||
const resolved = this.resolve(this.mode());
|
||||
const next: Mode = resolved === 'dark' ? 'light' : 'dark';
|
||||
this.apply(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Optionally expose a 3-state cycle:
|
||||
* light -> dark -> system -> light ...
|
||||
*/
|
||||
cycle() {
|
||||
const order: Mode[] = ['light', 'dark', 'system'];
|
||||
const i = order.indexOf(this.mode());
|
||||
this.apply(order[(i + 1) % order.length]);
|
||||
}
|
||||
|
||||
apply(next: Mode, fromMql = false) {
|
||||
this.mode.set(next);
|
||||
const root = document.documentElement;
|
||||
const resolved = this.resolve(next);
|
||||
|
||||
// toggle class
|
||||
root.classList.toggle('dark', resolved === 'dark');
|
||||
// attribute for any 3rd-party styling
|
||||
root.setAttribute('data-theme', resolved);
|
||||
|
||||
// store only when user explicitly changed (avoid thrashing on mql change)
|
||||
if (!fromMql) localStorage.setItem(STORAGE_KEY, next);
|
||||
}
|
||||
|
||||
private resolve(mode: Mode): 'light' | 'dark' {
|
||||
// SSR guard
|
||||
if (typeof window === 'undefined') return mode === 'dark' ? 'dark' : 'light';
|
||||
|
||||
if (mode !== 'system') return mode;
|
||||
const prefersDark = this.mql?.matches ?? false;
|
||||
return prefersDark ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
private setupMql() {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return;
|
||||
this.mql = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
// modern browsers
|
||||
if ('addEventListener' in this.mql) {
|
||||
this.mql.addEventListener('change', this.onMqlChange);
|
||||
} else {
|
||||
// Safari < 14 fallback
|
||||
// @ts-expect-error legacy
|
||||
this.mql.addListener(this.onMqlChange);
|
||||
}
|
||||
}
|
||||
|
||||
private teardownMql() {
|
||||
if (!this.mql) return;
|
||||
if ('removeEventListener' in this.mql) {
|
||||
this.mql.removeEventListener('change', this.onMqlChange);
|
||||
} else {
|
||||
// @ts-expect-error legacy
|
||||
this.mql.removeListener(this.onMqlChange);
|
||||
}
|
||||
}
|
||||
}
|
||||
467
src/app/core/services/tpe.ts
Normal file
467
src/app/core/services/tpe.ts
Normal file
@@ -0,0 +1,467 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { map, catchError, switchMap } from 'rxjs/operators';
|
||||
import { TpeDevice, TpeStatus, TpeType } from '../interfaces/tpe';
|
||||
import { Agent, AgentStatus } from '../interfaces/agent';
|
||||
import { environment } from 'src/environments/environment.development';
|
||||
import { normalizePage } from '@shared/paging/normalize-page';
|
||||
import { ListParams, PagedResult } from '@shared/paging/paging';
|
||||
|
||||
const USE_SERVER = true;
|
||||
const API_BASE = '/api/v1/tpes';
|
||||
|
||||
// Interface to match the API response structure for Agent (nested in TPE)
|
||||
interface AgentApiResponse {
|
||||
id: number;
|
||||
code: string;
|
||||
profile: string;
|
||||
principalCode?: string;
|
||||
caisseProfile?: string;
|
||||
statut: string;
|
||||
zone?: string;
|
||||
kiosk?: string;
|
||||
fonction?: string;
|
||||
dateEmbauche?: string;
|
||||
nom: string;
|
||||
prenom: string;
|
||||
autresNoms?: string;
|
||||
dateNaissance?: string;
|
||||
lieuNaissance?: string;
|
||||
ville?: string;
|
||||
adresse?: string;
|
||||
autoriserAides?: boolean;
|
||||
phone: string;
|
||||
pin?: string;
|
||||
limiteInferieure?: number;
|
||||
limiteSuperieure?: number;
|
||||
limiteParTransaction?: number;
|
||||
limiteMinAirtime?: number;
|
||||
limiteMaxAirtime?: number;
|
||||
maxPeripheriques?: number;
|
||||
limitId?: number;
|
||||
nationalite?: string;
|
||||
cni?: string;
|
||||
cniDelivreeLe?: string;
|
||||
cniDelivreeA?: string;
|
||||
residence?: string;
|
||||
autreAdresse1?: string;
|
||||
statutMarital?: string;
|
||||
epoux?: string;
|
||||
autreTelephone?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
createdBy?: string;
|
||||
}
|
||||
|
||||
// Interface to match the API response structure
|
||||
interface TpeApiResponse {
|
||||
id: number;
|
||||
imei: string;
|
||||
serial: string;
|
||||
type: string;
|
||||
marque: string;
|
||||
modele: string;
|
||||
statut: string; // API uses uppercase: VALIDE, INVALIDE, EN_PANNE, BLOQUE
|
||||
agent?: AgentApiResponse;
|
||||
assigne: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
// Stats interfaces
|
||||
interface CountByStatutResponse {
|
||||
[key: string]: number;
|
||||
}
|
||||
|
||||
// Assignment stats is just a number (count of assigned TPEs)
|
||||
type AssignesStatsResponse = number;
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class TpeService {
|
||||
private apiUrl = environment.apiBaseUrl + API_BASE;
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
// Helper method to get ngrok bypass headers
|
||||
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' } : {};
|
||||
}
|
||||
|
||||
// Transform API statut to interface statut (both use uppercase now)
|
||||
private transformStatut(apiStatut: string): TpeStatus {
|
||||
const upperStatut = apiStatut.toUpperCase() as TpeStatus;
|
||||
const validStatuses: TpeStatus[] = [
|
||||
'VALIDE',
|
||||
'INVALIDE',
|
||||
'EN_PANNE',
|
||||
'BLOQUE',
|
||||
'DISPONIBLE',
|
||||
'AFFECTE',
|
||||
'EN_MAINTENANCE',
|
||||
'HORS_SERVICE',
|
||||
'VOLE',
|
||||
];
|
||||
return validStatuses.includes(upperStatut) ? upperStatut : 'INVALIDE';
|
||||
}
|
||||
|
||||
// Transform interface statut to API statut (both use uppercase now, so direct return)
|
||||
private transformStatutToApi(statut: TpeStatus): string {
|
||||
return statut; // Already uppercase, no transformation needed
|
||||
}
|
||||
|
||||
// Transform API Agent response to Agent
|
||||
private transformAgent(apiAgent: AgentApiResponse): Agent {
|
||||
return {
|
||||
id: String(apiAgent.id),
|
||||
code: apiAgent.code,
|
||||
profile: apiAgent.profile,
|
||||
principalCode: apiAgent.principalCode,
|
||||
caisseProfile: apiAgent.caisseProfile,
|
||||
statut: apiAgent.statut as AgentStatus,
|
||||
zone: apiAgent.zone,
|
||||
kiosk: apiAgent.kiosk,
|
||||
fonction: apiAgent.fonction,
|
||||
dateEmbauche: apiAgent.dateEmbauche,
|
||||
nom: apiAgent.nom,
|
||||
prenom: apiAgent.prenom,
|
||||
autresNoms: apiAgent.autresNoms,
|
||||
dateNaissance: apiAgent.dateNaissance,
|
||||
lieuNaissance: apiAgent.lieuNaissance,
|
||||
ville: apiAgent.ville,
|
||||
adresse: apiAgent.adresse,
|
||||
autoriserAides: apiAgent.autoriserAides,
|
||||
phone: apiAgent.phone,
|
||||
pin: apiAgent.pin,
|
||||
limiteInferieure: apiAgent.limiteInferieure,
|
||||
limiteSuperieure: apiAgent.limiteSuperieure,
|
||||
limiteParTransaction: apiAgent.limiteParTransaction,
|
||||
limiteMinAirtime: apiAgent.limiteMinAirtime,
|
||||
limiteMaxAirtime: apiAgent.limiteMaxAirtime,
|
||||
maxPeripheriques: apiAgent.maxPeripheriques,
|
||||
limitId: apiAgent.limitId ? String(apiAgent.limitId) : undefined,
|
||||
nationalite: apiAgent.nationalite,
|
||||
cni: apiAgent.cni,
|
||||
cniDelivreeLe: apiAgent.cniDelivreeLe,
|
||||
cniDelivreeA: apiAgent.cniDelivreeA,
|
||||
residence: apiAgent.residence,
|
||||
autreAdresse1: apiAgent.autreAdresse1,
|
||||
statutMarital: apiAgent.statutMarital,
|
||||
epoux: apiAgent.epoux,
|
||||
autreTelephone: apiAgent.autreTelephone,
|
||||
createdAt: apiAgent.createdAt,
|
||||
updatedAt: apiAgent.updatedAt,
|
||||
createdBy: apiAgent.createdBy,
|
||||
};
|
||||
}
|
||||
|
||||
// Transform API response to TpeDevice
|
||||
private transformTpe(apiTpe: TpeApiResponse): TpeDevice {
|
||||
return {
|
||||
id: String(apiTpe.id),
|
||||
imei: apiTpe.imei,
|
||||
serial: apiTpe.serial,
|
||||
type: apiTpe.type as TpeType,
|
||||
marque: apiTpe.marque,
|
||||
modele: apiTpe.modele,
|
||||
statut: this.transformStatut(apiTpe.statut),
|
||||
agent: apiTpe.agent ? this.transformAgent(apiTpe.agent) : undefined,
|
||||
assigne: apiTpe.assigne,
|
||||
createdAt: apiTpe.createdAt,
|
||||
updatedAt: apiTpe.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// Transform TpeDevice to API payload
|
||||
private transformToApiPayload(tpe: Partial<TpeDevice>): any {
|
||||
const payload: any = {};
|
||||
if (tpe.imei !== undefined) payload.imei = tpe.imei;
|
||||
if (tpe.serial !== undefined) payload.serial = tpe.serial;
|
||||
if (tpe.type !== undefined) payload.type = tpe.type;
|
||||
if (tpe.marque !== undefined) payload.marque = tpe.marque;
|
||||
if (tpe.modele !== undefined) payload.modele = tpe.modele;
|
||||
if (tpe.statut !== undefined) payload.statut = this.transformStatutToApi(tpe.statut);
|
||||
if (tpe.assigne !== undefined) payload.assigne = tpe.assigne;
|
||||
return payload;
|
||||
}
|
||||
|
||||
// GET /api/v1/tpes/{id} - Get by ID
|
||||
getById(id: string): Observable<TpeDevice | undefined> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<TpeApiResponse>(`${this.apiUrl}/${id}`, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map((apiTpe) => this.transformTpe(apiTpe)),
|
||||
catchError((err) => {
|
||||
console.error(`Error fetching TPE ${id}:`, err);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(undefined);
|
||||
}
|
||||
|
||||
// GET /api/v1/tpes - List all
|
||||
list(params?: ListParams): Observable<PagedResult<TpeDevice>> {
|
||||
if (USE_SERVER) {
|
||||
let httpParams = new HttpParams();
|
||||
if (params) {
|
||||
if (params.page) httpParams = httpParams.set('page', params.page.toString());
|
||||
if (params.perPage) httpParams = httpParams.set('perPage', params.perPage.toString());
|
||||
if (params.search) httpParams = httpParams.set('search', params.search);
|
||||
if (params.sortKey) httpParams = httpParams.set('sortKey', params.sortKey);
|
||||
if (params.sortDir) httpParams = httpParams.set('sortDir', params.sortDir);
|
||||
}
|
||||
|
||||
return this.http
|
||||
.get<TpeApiResponse[]>(this.apiUrl, {
|
||||
params: httpParams,
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
map((list) => {
|
||||
const tpes = list.map((apiTpe) => this.transformTpe(apiTpe));
|
||||
// If pagination params provided, return paginated result
|
||||
if (params) {
|
||||
return normalizePage<TpeDevice>(
|
||||
{ data: tpes, meta: { total: tpes.length } },
|
||||
params.page || 1,
|
||||
params.perPage || 10
|
||||
);
|
||||
}
|
||||
// Otherwise return all as single page
|
||||
return normalizePage<TpeDevice>(
|
||||
{ data: tpes, meta: { total: tpes.length } },
|
||||
1,
|
||||
tpes.length
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error('Error fetching TPEs:', err);
|
||||
return of(normalizePage<TpeDevice>({ data: [], meta: { total: 0 } }, 1, 10));
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(normalizePage<TpeDevice>({ data: [], meta: { total: 0 } }, 1, 10));
|
||||
}
|
||||
|
||||
// POST /api/v1/tpes - Create
|
||||
create(payload: Omit<TpeDevice, 'id' | 'createdAt' | 'updatedAt'>): Observable<TpeDevice> {
|
||||
if (USE_SERVER) {
|
||||
const apiPayload = this.transformToApiPayload(payload);
|
||||
return this.http
|
||||
.post<TpeApiResponse>(this.apiUrl, apiPayload, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map((apiTpe) => this.transformTpe(apiTpe)),
|
||||
catchError((err) => {
|
||||
console.error('Error creating TPE:', err);
|
||||
throw err;
|
||||
})
|
||||
);
|
||||
}
|
||||
throw new Error('Server mode is required');
|
||||
}
|
||||
|
||||
// PUT /api/v1/tpes/{id} - Update
|
||||
update(id: string, payload: Partial<TpeDevice>): Observable<TpeDevice | undefined> {
|
||||
if (USE_SERVER) {
|
||||
const apiPayload = this.transformToApiPayload(payload);
|
||||
return this.http
|
||||
.put<TpeApiResponse>(`${this.apiUrl}/${id}`, apiPayload, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
map((apiTpe) => this.transformTpe(apiTpe)),
|
||||
catchError((err) => {
|
||||
console.error(`Error updating TPE ${id}:`, err);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(undefined);
|
||||
}
|
||||
|
||||
// DELETE /api/v1/tpes/{id} - Delete
|
||||
delete(id: string): Observable<boolean> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.delete<void>(`${this.apiUrl}/${id}`, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map(() => true),
|
||||
catchError((err) => {
|
||||
console.error(`Error deleting TPE ${id}:`, err);
|
||||
return of(false);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(false);
|
||||
}
|
||||
|
||||
// PATCH /api/v1/tpes/{id}/statut - Update statut
|
||||
updateStatut(id: string, statut: TpeStatus): Observable<TpeDevice | undefined> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.patch<TpeApiResponse>(
|
||||
`${this.apiUrl}/${id}/statut`,
|
||||
{ statut: this.transformStatutToApi(statut) },
|
||||
{ headers: this.getNgrokHeaders() }
|
||||
)
|
||||
.pipe(
|
||||
map((apiTpe) => this.transformTpe(apiTpe)),
|
||||
catchError((err) => {
|
||||
console.error(`Error updating TPE statut ${id}:`, err);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(undefined);
|
||||
}
|
||||
|
||||
// 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> {
|
||||
if (USE_SERVER) {
|
||||
// First get the current TPE data
|
||||
return this.getById(id).pipe(
|
||||
switchMap((tpe) => {
|
||||
if (!tpe) {
|
||||
return of(undefined);
|
||||
}
|
||||
// Update the whole TPE with assigne set to false and statut to DISPONIBLE
|
||||
const updatedTpe = { ...tpe, assigne: false, statut: 'DISPONIBLE' as TpeStatus };
|
||||
const apiPayload = this.transformToApiPayload(updatedTpe);
|
||||
return this.http
|
||||
.patch<TpeApiResponse>(`${this.apiUrl}/liberer/${id}`, apiPayload, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
map((apiTpe) => this.transformTpe(apiTpe)),
|
||||
catchError((err) => {
|
||||
console.error(`Error liberating TPE ${id}:`, err);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error(`Error fetching TPE ${id} for liberation:`, err);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(undefined);
|
||||
}
|
||||
|
||||
// PATCH /api/v1/tpes/assigner - Assign TPE
|
||||
// Payload: { tpeId: number, agentId: number }
|
||||
assigner(id: string, agentId: string): Observable<TpeDevice | undefined> {
|
||||
if (USE_SERVER) {
|
||||
const payload = {
|
||||
tpeId: Number(id),
|
||||
agentId: Number(agentId),
|
||||
};
|
||||
return this.http
|
||||
.patch<TpeApiResponse>(`${this.apiUrl}/assigner`, payload, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
map((apiTpe) => this.transformTpe(apiTpe)),
|
||||
catchError((err) => {
|
||||
console.error(`Error assigning TPE ${id}:`, err);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(undefined);
|
||||
}
|
||||
|
||||
// GET /api/v1/tpes/statut/{statut} - List by statut
|
||||
getByStatut(statut: TpeStatus): Observable<TpeDevice[]> {
|
||||
if (USE_SERVER) {
|
||||
const apiStatut = this.transformStatutToApi(statut);
|
||||
return this.http
|
||||
.get<TpeApiResponse[]>(`${this.apiUrl}/statut/${apiStatut}`, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
map((list) => list.map((apiTpe) => this.transformTpe(apiTpe))),
|
||||
catchError((err) => {
|
||||
console.error(`Error fetching TPEs by statut ${statut}:`, err);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of([]);
|
||||
}
|
||||
|
||||
// GET /api/v1/tpes/stats/count-by-statut - Get count by statut
|
||||
getCountByStatut(): Observable<CountByStatutResponse> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<CountByStatutResponse>(`${this.apiUrl}/stats/count-by-statut`, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
console.error('Error fetching TPE count by statut:', err);
|
||||
return of({});
|
||||
})
|
||||
);
|
||||
}
|
||||
return of({});
|
||||
}
|
||||
|
||||
// GET /api/v1/tpes/stats/assignes - Get assignment stats (returns a number)
|
||||
getAssignesStats(): Observable<number> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<number>(`${this.apiUrl}/stats/assignes`, {
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
console.error('Error fetching TPE assignment stats:', err);
|
||||
return of(0);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of(0);
|
||||
}
|
||||
|
||||
// GET /api/v1/tpes/search - Search
|
||||
search(query: string): Observable<TpeDevice[]> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<TpeApiResponse[]>(`${this.apiUrl}/search`, {
|
||||
params: { q: query.trim() },
|
||||
headers: this.getNgrokHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
map((list) => list.map((apiTpe) => this.transformTpe(apiTpe))),
|
||||
catchError((err) => {
|
||||
console.error(`Error searching TPEs with query ${query}:`, err);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of([]);
|
||||
}
|
||||
|
||||
// GET /api/v1/tpes/disponibles - List available TPEs
|
||||
getDisponibles(): Observable<TpeDevice[]> {
|
||||
if (USE_SERVER) {
|
||||
return this.http
|
||||
.get<TpeApiResponse[]>(`${this.apiUrl}/disponibles`, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map((list) => list.map((apiTpe) => this.transformTpe(apiTpe))),
|
||||
catchError((err) => {
|
||||
console.error('Error fetching available TPEs:', err);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
return of([]);
|
||||
}
|
||||
}
|
||||
162
src/app/core/services/user.ts
Normal file
162
src/app/core/services/user.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { map, catchError } from 'rxjs/operators';
|
||||
import { User } from '../interfaces/user';
|
||||
import { PaginatedHttpService } from '@shared/paging/paginated-http.service';
|
||||
import { ListParams, PagedResult, SortDir } from '@shared/paging/paging';
|
||||
import { normalizePage } from '@shared/paging/normalize-page';
|
||||
import { environment } from 'src/environments/environment.development';
|
||||
|
||||
const USE_SERVER = true;
|
||||
const API_BASE = '/api/v1/users';
|
||||
|
||||
// Backend payload
|
||||
interface UserApiResponse {
|
||||
id: number;
|
||||
nom: string;
|
||||
prenom: string;
|
||||
identifiant: string;
|
||||
password?: string;
|
||||
matriculeAgent: string;
|
||||
roleId: number;
|
||||
restrictionConnexion: boolean;
|
||||
restrictionAutomatique: boolean;
|
||||
nombreIpAutorise: number;
|
||||
nombreIpAutoAutorise: number;
|
||||
statut: string;
|
||||
derniereConnexion?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class UserService {
|
||||
private apiUrl = environment.apiBaseUrl + API_BASE;
|
||||
|
||||
constructor(private http: HttpClient, private paginatedHttp: PaginatedHttpService) {}
|
||||
|
||||
// Helper method to get ngrok bypass headers
|
||||
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' } : {};
|
||||
}
|
||||
|
||||
private transform(api: UserApiResponse): User {
|
||||
return {
|
||||
id: String(api.id),
|
||||
nom: api.nom,
|
||||
prenom: api.prenom,
|
||||
identifiant: api.identifiant,
|
||||
// We never expose password back to UI
|
||||
matriculeAgent: api.matriculeAgent,
|
||||
roleId: String(api.roleId),
|
||||
restrictionConnexion: api.restrictionConnexion,
|
||||
restrictionAutomatique: api.restrictionAutomatique,
|
||||
nombreIpAutorise: api.nombreIpAutorise,
|
||||
nombreIpAutoAutorise: api.nombreIpAutoAutorise,
|
||||
statut: api.statut,
|
||||
derniereConnexion: api.derniereConnexion,
|
||||
createdAt: api.createdAt,
|
||||
updatedAt: api.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private transformToApiPayload(user: Partial<User>): Partial<UserApiResponse> {
|
||||
return {
|
||||
id: user.id ? Number(user.id) : undefined,
|
||||
nom: user.nom ?? '',
|
||||
prenom: user.prenom ?? '',
|
||||
identifiant: user.identifiant ?? '',
|
||||
password: user.password,
|
||||
matriculeAgent: user.matriculeAgent ?? '',
|
||||
roleId: user.roleId ? Number(user.roleId) : 0,
|
||||
restrictionConnexion: user.restrictionConnexion ?? false,
|
||||
restrictionAutomatique: user.restrictionAutomatique ?? false,
|
||||
nombreIpAutorise: user.nombreIpAutorise ?? 0,
|
||||
nombreIpAutoAutorise: user.nombreIpAutoAutorise ?? 0,
|
||||
statut: user.statut ?? 'Actif',
|
||||
derniereConnexion: user.derniereConnexion,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
list(params: ListParams): Observable<PagedResult<User>> {
|
||||
if (USE_SERVER) {
|
||||
// Backend returns full list; paginate client-side
|
||||
return this.http
|
||||
.get<UserApiResponse[]>(this.apiUrl, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map((items) => (items || []).map((u) => this.transform(u))),
|
||||
map((users) => {
|
||||
const q = (params.search ?? '').toLowerCase();
|
||||
let data = users;
|
||||
|
||||
if (q) {
|
||||
data = data.filter((u) =>
|
||||
[u.nom, u.prenom, u.identifiant, u.matriculeAgent, u.statut]
|
||||
.filter(Boolean)
|
||||
.map((x) => String(x).toLowerCase())
|
||||
.some((s) => s.includes(q))
|
||||
);
|
||||
}
|
||||
|
||||
if (params.sortKey && params.sortDir) {
|
||||
const { sortKey, sortDir } = params as { sortKey: string; sortDir: SortDir };
|
||||
const getValue = (obj: any, path: string) =>
|
||||
path.split('.').reduce((o, k) => o?.[k], obj);
|
||||
data = [...data].sort((a: any, b: any) => {
|
||||
const sa = String(getValue(a, sortKey) ?? '');
|
||||
const sb = String(getValue(b, sortKey) ?? '');
|
||||
const cmp = sa.localeCompare(sb, 'fr', { numeric: true });
|
||||
return sortDir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
}
|
||||
|
||||
const start = (params.page - 1) * params.perPage;
|
||||
const pageData = data.slice(start, start + params.perPage);
|
||||
|
||||
return normalizePage<User>(
|
||||
{ data: pageData, meta: { total: data.length } },
|
||||
params.page,
|
||||
params.perPage
|
||||
);
|
||||
}),
|
||||
catchError(() =>
|
||||
of(normalizePage<User>({ data: [], meta: { total: 0 } }, params.page, params.perPage))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback should not be used anymore
|
||||
return of(normalizePage<User>({ data: [], meta: { total: 0 } }, params.page, params.perPage));
|
||||
}
|
||||
|
||||
create(payload: Omit<User, 'id'>): Observable<User> {
|
||||
const body = this.transformToApiPayload(payload);
|
||||
return this.http
|
||||
.post<UserApiResponse>(this.apiUrl, body, { headers: this.getNgrokHeaders() })
|
||||
.pipe(map((res) => this.transform(res)));
|
||||
}
|
||||
|
||||
update(id: string, payload: Partial<User>): Observable<User | undefined> {
|
||||
const body = this.transformToApiPayload({ ...payload, id });
|
||||
return this.http
|
||||
.put<UserApiResponse>(`${this.apiUrl}/${id}`, body, { headers: this.getNgrokHeaders() })
|
||||
.pipe(
|
||||
map((res) => this.transform(res)),
|
||||
catchError(() => of(undefined))
|
||||
);
|
||||
}
|
||||
|
||||
delete(id: string): Observable<boolean> {
|
||||
return this.http.delete<void>(`${this.apiUrl}/${id}`, { headers: this.getNgrokHeaders() }).pipe(
|
||||
map(() => true),
|
||||
catchError(() => of(false))
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user