first commit

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

17
.editorconfig Normal file
View File

@@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false

45
.gitignore vendored Normal file
View File

@@ -0,0 +1,45 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
/package-lock.json
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
__screenshots__/
# System files
.DS_Store
Thumbs.db

5
.postcssrc.json Normal file
View File

@@ -0,0 +1,5 @@
{
"plugins": {
"@tailwindcss/postcss": {}
}
}

4
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}

20
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,20 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}

42
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,42 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
}
]
}

59
README.md Normal file
View File

@@ -0,0 +1,59 @@
# Pjp
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 20.3.6.
## Development server
To start a local development server, run:
```bash
ng serve
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the project run:
```bash
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.

94
angular.json Normal file
View File

@@ -0,0 +1,94 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"pjp": {
"projectType": "application",
"schematics": {},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.css"
]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true,
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.development.ts"
}
]
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "pjp:build:production"
},
"development": {
"buildTarget": "pjp:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular/build:extract-i18n"
},
"test": {
"builder": "@angular/build:karma",
"options": {
"tsConfig": "tsconfig.spec.json",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.css"
]
}
}
}
}
},
"cli": {
"analytics": false
}
}

13
components.json Normal file
View File

@@ -0,0 +1,13 @@
{
"style": "css",
"packageManager": "npm",
"tailwind": {
"css": "src/styles.css",
"baseColor": "neutral",
"cssVariables": true
},
"aliases": {
"components": "src/app/shared/components",
"utils": "src/app/shared/utils"
}
}

61
package.json Normal file
View File

@@ -0,0 +1,61 @@
{
"name": "pjp-plr",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test",
"init-permissions": "npx tsx scripts/init-permissions.ts"
},
"prettier": {
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
},
"private": true,
"dependencies": {
"@angular/animations": "^20.3.5",
"@angular/cdk": "^20.2.9",
"@angular/common": "^20.3.0",
"@angular/compiler": "^20.3.0",
"@angular/core": "^20.3.0",
"@angular/forms": "^20.3.0",
"@angular/platform-browser": "^20.3.0",
"@angular/router": "^20.3.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-angular": "^0.546.0",
"lucide-static": "^0.546.0",
"ngx-sonner": "^3.1.0",
"rxjs": "~7.8.0",
"tailwind-merge": "^3.3.1",
"tslib": "^2.3.0"
},
"devDependencies": {
"@angular/build": "^20.3.6",
"@angular/cli": "^20.3.6",
"@angular/compiler-cli": "^20.3.0",
"@tailwindcss/postcss": "^4.1.14",
"@types/jasmine": "~5.1.0",
"jasmine-core": "~5.9.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.14",
"tailwindcss-animate": "^1.0.7",
"typescript": "~5.9.2"
}
}

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path d="m 8 1 c -1.65625 0 -3 1.34375 -3 3 s 1.34375 3 3 3 s 3 -1.34375 3 -3 s -1.34375 -3 -3 -3 z m -1.5 7 c -2.492188 0 -4.5 2.007812 -4.5 4.5 v 0.5 c 0 1.109375 0.890625 2 2 2 h 8 c 1.109375 0 2 -0.890625 2 -2 v -0.5 c 0 -2.492188 -2.007812 -4.5 -4.5 -4.5 z m 0 0" fill="#2e3436"/>
</svg>

After

Width:  |  Height:  |  Size: 509 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 813 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

200
scripts/init-permissions.js Normal file
View File

@@ -0,0 +1,200 @@
/**
* Script to initialize all permissions in the backend API
* Usage: node scripts/init-permissions.js
* Or: npm run init-permissions
*/
const PERMISSIONS_DATA = [
// Users
{ name: 'USERS_READ', description: 'Voir utilisateurs' },
{ name: 'USERS_CREATE', description: 'Créer utilisateurs' },
{ name: 'USERS_UPDATE', description: 'Modifier utilisateurs' },
{ name: 'USERS_DELETE', description: 'Supprimer utilisateurs' },
{ name: 'USERS_RESET_PASSWORD', description: 'Réinitialiser mot de passe' },
{ name: 'USERS_LOCK', description: 'Verrouiller utilisateurs' },
{ name: 'USERS_UNLOCK', description: 'Déverrouiller utilisateurs' },
{ name: 'USERS_RESET_2FA', description: 'Réinitialiser 2FA' },
{ name: 'USERS_CHANGE_ROLE', description: 'Changer de rôle' },
{ name: 'USERS_CHANGE_STATUS', description: 'Changer de statut' },
// Hippodromes
{ name: 'HIPPODROMES_READ', description: 'Voir hippodromes' },
{ name: 'HIPPODROMES_CREATE', description: 'Créer hippodromes' },
{ name: 'HIPPODROMES_UPDATE', description: 'Modifier hippodromes' },
{ name: 'HIPPODROMES_DELETE', description: 'Supprimer hippodromes' },
// Reunions
{ name: 'REUNIONS_READ', description: 'Voir reunions' },
{ name: 'REUNIONS_CREATE', description: 'Créer reunions' },
{ name: 'REUNIONS_UPDATE', description: 'Modifier reunions' },
{ name: 'REUNIONS_DELETE', description: 'Supprimer reunions' },
{ name: 'REUNIONS_PLANIFIEE', description: 'Planifier reunions' },
{ name: 'REUNIONS_TERMINEE', description: 'Terminer les reunions' },
{ name: 'REUNIONS_CANCEL', description: 'Annuler les reunions' },
// Courses
{ name: 'COURSES_READ', description: 'Voir courses' },
{ name: 'COURSES_CREATE', description: 'Créer courses' },
{ name: 'COURSES_UPDATE', description: 'Modifier courses' },
{ name: 'COURSES_DELETE', description: 'Supprimer courses' },
{ name: 'COURSES_VALIDATE', description: 'Valider courses' },
{ name: 'COURSES_CONFIRM', description: 'Confirmer courses' },
{ name: 'COURSES_CLOSE', description: 'Clôturer courses' },
{ name: 'COURSES_CANCEL', description: 'Annuler courses' },
// TPE
{ name: 'TPE_READ', description: 'Voir TPE' },
{ name: 'TPE_CREATE', description: 'Créer TPE' },
{ name: 'TPE_UPDATE', description: 'Modifier TPE' },
{ name: 'TPE_DELETE', description: 'Supprimer TPE' },
{ name: 'TPE_ASSIGN', description: 'Assigner TPE' },
{ name: 'TPE_UNASSIGN', description: 'Déassigner TPE' },
// Agents
{ name: 'AGENTS_READ', description: 'Voir agents' },
{ name: 'AGENTS_CREATE', description: 'Créer agents' },
{ name: 'AGENTS_UPDATE', description: 'Modifier agents' },
{ name: 'AGENTS_DELETE', description: 'Supprimer agents' },
{ name: 'AGENTS_ASSIGN', description: 'Assigner agents' },
{ name: 'AGENTS_UNASSIGN', description: 'Déassigner agents' },
{ name: 'AGENTS_ASSIGN_TPE', description: 'Assigner TPE à agents' },
{ name: 'AGENTS_UNASSIGN_TPE', description: 'Déassigner TPE à agents' },
// Familles Agents
{ name: 'AGENT_FAMILIES_READ', description: 'Voir familles agents' },
{ name: 'AGENT_FAMILIES_CREATE', description: 'Créer familles agents' },
{ name: 'AGENT_FAMILIES_UPDATE', description: 'Modifier familles agents' },
{ name: 'AGENT_FAMILIES_DELETE', description: 'Supprimer familles agents' },
// Limites Agents
{ name: 'AGENT_LIMITS_READ', description: 'Voir limites agents' },
{ name: 'AGENT_LIMITS_CREATE', description: 'Créer limites agents' },
{ name: 'AGENT_LIMITS_UPDATE', description: 'Modifier limites agents' },
{ name: 'AGENT_LIMITS_DELETE', description: 'Supprimer limites agents' },
{ name: 'AGENT_LIMITS_DEFAULTED', description: 'Définir limites agents par défaut' },
// Permissions
{ name: 'PERMISSIONS_READ', description: 'Voir permissions' },
{ name: 'PERMISSIONS_CREATE', description: 'Créer permissions' },
{ name: 'PERMISSIONS_UPDATE', description: 'Modifier permissions' },
{ name: 'PERMISSIONS_DELETE', description: 'Supprimer permissions' },
{ name: 'PERMISSIONS_ASSIGN', description: 'Assigner permissions' },
{ name: 'PERMISSIONS_UNASSIGN', description: 'Déassigner permissions' },
// Roles
{ name: 'ROLES_READ', description: 'Voir rôles' },
{ name: 'ROLES_CREATE', description: 'Créer rôles' },
{ name: 'ROLES_UPDATE', description: 'Modifier rôles' },
{ name: 'ROLES_DELETE', description: 'Supprimer rôles' },
{ name: 'ROLES_ASSIGN', description: 'Assigner rôles' },
{ name: 'ROLES_UNASSIGN', description: 'Déassigner rôles' },
{ name: 'ROLES_ASSIGN_PERMISSIONS', description: 'Assigner permissions à rôles' },
{ name: 'ROLES_UNASSIGN_PERMISSIONS', description: 'Déassigner permissions à rôles' },
];
// Remove duplicates by name
const uniquePermissions = Array.from(new Map(PERMISSIONS_DATA.map((p) => [p.name, p])).values());
const API_BASE_URL = process.env.API_BASE_URL || 'https://b440a25a7658.ngrok-free.app';
const PERMISSIONS_ENDPOINT = `${API_BASE_URL}/api/v1/permissions`;
async function createPermission(payload) {
try {
const response = await fetch(PERMISSIONS_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'ngrok-skip-browser-warning': 'true',
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const errorText = await response.text();
return {
success: false,
error: `HTTP ${response.status}: ${errorText}`,
};
}
const data = await response.json();
return { success: true, data };
} catch (error) {
return {
success: false,
error: error.message || 'Unknown error',
};
}
}
async function getAllExistingPermissions() {
try {
const response = await fetch(PERMISSIONS_ENDPOINT, {
headers: {
'ngrok-skip-browser-warning': 'true',
},
});
if (response.ok) {
const data = await response.json();
const permissions = Array.isArray(data) ? data : [];
return new Set(permissions.map((p) => p.name).filter(Boolean));
}
return new Set();
} catch (error) {
console.warn('Warning: Could not fetch existing permissions, will try to create all:', error);
return new Set();
}
}
async function initAllPermissions() {
console.log(`🚀 Initializing ${uniquePermissions.length} permissions...\n`);
console.log(`API Base URL: ${API_BASE_URL}\n`);
// Fetch all existing permissions once at the start
console.log('📋 Fetching existing permissions...');
const existingPermissions = await getAllExistingPermissions();
console.log(` Found ${existingPermissions.size} existing permission(s)\n`);
const results = {
created: 0,
skipped: 0,
errors: 0,
};
for (const perm of uniquePermissions) {
// Check if permission already exists in the set we fetched
if (existingPermissions.has(perm.name)) {
console.log(`⏭️ Skipped: ${perm.name} (already exists)`);
results.skipped++;
continue;
}
const result = await createPermission({
name: perm.name,
description: perm.description || '',
});
if (result.success) {
console.log(`✅ Created: ${perm.name}`);
results.created++;
} else {
console.error(`❌ Failed: ${perm.name} - ${result.error}`);
results.errors++;
}
// Small delay to avoid overwhelming the server
await new Promise((resolve) => setTimeout(resolve, 100));
}
console.log(`\n📊 Summary:`);
console.log(` Created: ${results.created}`);
console.log(` Skipped: ${results.skipped}`);
console.log(` Errors: ${results.errors}`);
console.log(` Total: ${uniquePermissions.length}`);
}
// Run the script
initAllPermissions().catch((error) => {
console.error('Fatal error:', error);
process.exit(1);
});

127
scripts/init-permissions.ts Normal file
View File

@@ -0,0 +1,127 @@
/**
* Script to initialize all permissions in the backend API
* Usage: npx tsx scripts/init-permissions.ts
* Or: node scripts/init-permissions.js (after compiling)
*/
import { PERMISSIONS_MOCK } from '../src/app/core/mocks/role.mocks';
const API_BASE_URL = process.env.API_BASE_URL || 'https://b440a25a7658.ngrok-free.app';
const PERMISSIONS_ENDPOINT = `${API_BASE_URL}/api/v1/permissions`;
// Clean up permissions: remove duplicates by name and fix IDs
const uniquePermissions = Array.from(
new Map(
PERMISSIONS_MOCK.map((p) => [p.name, p])
).values()
).map((p, index) => ({
name: p.name,
description: p.description || '',
}));
interface PermissionPayload {
name: string;
description: string;
}
async function createPermission(payload: PermissionPayload): Promise<{ success: boolean; data?: any; error?: string }> {
try {
const response = await fetch(PERMISSIONS_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'ngrok-skip-browser-warning': 'true',
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const errorText = await response.text();
return {
success: false,
error: `HTTP ${response.status}: ${errorText}`,
};
}
const data = await response.json();
return { success: true, data };
} catch (error: any) {
return {
success: false,
error: error.message || 'Unknown error',
};
}
}
async function getAllExistingPermissions(): Promise<Set<string>> {
try {
const response = await fetch(PERMISSIONS_ENDPOINT, {
headers: {
'ngrok-skip-browser-warning': 'true',
},
});
if (response.ok) {
const data = await response.json();
const permissions = Array.isArray(data) ? data : [];
return new Set(permissions.map((p: any) => p.name).filter(Boolean));
}
return new Set();
} catch (error) {
console.warn('Warning: Could not fetch existing permissions, will try to create all:', error);
return new Set();
}
}
async function initAllPermissions() {
console.log(`🚀 Initializing ${uniquePermissions.length} permissions...\n`);
console.log(`API Base URL: ${API_BASE_URL}\n`);
// Fetch all existing permissions once at the start
console.log('📋 Fetching existing permissions...');
const existingPermissions = await getAllExistingPermissions();
console.log(` Found ${existingPermissions.size} existing permission(s)\n`);
const results = {
created: 0,
skipped: 0,
errors: 0,
};
for (const perm of uniquePermissions) {
// Check if permission already exists in the set we fetched
if (existingPermissions.has(perm.name)) {
console.log(`⏭️ Skipped: ${perm.name} (already exists)`);
results.skipped++;
continue;
}
const result = await createPermission({
name: perm.name,
description: perm.description,
});
if (result.success) {
console.log(`✅ Created: ${perm.name}`);
results.created++;
} else {
console.error(`❌ Failed: ${perm.name} - ${result.error}`);
results.errors++;
}
// Small delay to avoid overwhelming the server
await new Promise((resolve) => setTimeout(resolve, 100));
}
console.log(`\n📊 Summary:`);
console.log(` Created: ${results.created}`);
console.log(` Skipped: ${results.skipped}`);
console.log(` Errors: ${results.errors}`);
console.log(` Total: ${uniquePermissions.length}`);
}
// Run the script
initAllPermissions().catch((error) => {
console.error('Fatal error:', error);
process.exit(1);
});

22
src/app/app.config.ts Normal file
View File

@@ -0,0 +1,22 @@
import {
ApplicationConfig,
LOCALE_ID,
provideBrowserGlobalErrorListeners,
provideZonelessChangeDetection,
} from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideAnimations } from '@angular/platform-browser/animations';
import { routes } from './app.routes';
import { provideHttpClient } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideZonelessChangeDetection(),
provideRouter(routes),
provideHttpClient(),
provideAnimations(),
{ provide: LOCALE_ID, useValue: 'fr-FR' },
],
};

0
src/app/app.css Normal file
View File

2
src/app/app.html Normal file
View File

@@ -0,0 +1,2 @@
<router-outlet></router-outlet>
<z-toaster position="top-right" [richColors]="true" />

14
src/app/app.routes.ts Normal file
View File

@@ -0,0 +1,14 @@
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: 'auth',
// loadChildren: () => import('./auth/auth.module').then((m) => m.AuthModule),
loadChildren: () => import('./auth/auth-module').then((m) => m.AuthModule),
},
{
path: '',
loadChildren: () => import('./dashboard/dashboard-module').then((m) => m.DashboardModule),
},
{ path: '**', redirectTo: 'auth/login' },
];

25
src/app/app.spec.ts Normal file
View File

@@ -0,0 +1,25 @@
import { provideZonelessChangeDetection } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { App } from './app';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
providers: [provideZonelessChangeDetection()]
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it('should render title', () => {
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, pjp');
});
});

13
src/app/app.ts Normal file
View File

@@ -0,0 +1,13 @@
import { Component, signal } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { ZardToastComponent } from '@shared/components/toast/toast.component';
@Component({
selector: 'app-root',
imports: [RouterOutlet, ZardToastComponent],
templateUrl: './app.html',
styleUrl: './app.css',
})
export class App {
protected readonly title = signal('pjp');
}

View File

@@ -0,0 +1,3 @@
:host {
display: contents;
}

View File

@@ -0,0 +1,74 @@
<div class="min-h-screen relative overflow-hidden bg-surface text-text">
<header
class="absolute inset-x-0 top-10 z-20 h-16 px-4 max-w-7xl mx-auto flex items-center justify-between"
>
<div class="h-full flex items-center gap-2">
<div class="flex items-center gap-3">
<app-pmu-logo variant="default" />
</div>
</div>
<app-mode-toggle></app-mode-toggle>
</header>
<!-- Animated background shapes -->
<div class="pointer-events-none absolute inset-0">
<div
class="absolute -top-28 -left-24 h-72 w-72 rounded-full blur-3xl opacity-40"
style="background: radial-gradient(closest-side, #1c5a29, transparent)"
></div>
<div
class="absolute top-24 -right-16 h-80 w-80 rounded-full blur-3xl opacity-40"
style="background: radial-gradient(closest-side, #fae500, transparent)"
></div>
<div
class="absolute -bottom-10 left-1/3 h-80 w-80 rounded-full blur-3xl opacity-30"
style="background: radial-gradient(closest-side, #c31617, transparent)"
></div>
</div>
<!-- Split layout -->
<div
class="relative z-10 max-w-7xl mx-auto min-h-screen grid grid-cols-1 items-center lg:grid-cols-2 px-4"
>
<!-- Visual side -->
<aside class="hidden lg:flex relative items-center justify-start">
<div class="relative max-w-lg pr-4">
<div class="back backdrop-blur-2xl">
<h2 class="text-2xl font-semibold text-heading">Plateforme de gestion</h2>
<p class="text-sm mt-2">
Gérez les courses, chevaux, paris, résultats et gains dans une interface moderne et
performante.
</p>
<div class="mt-6 grid grid-cols-3 gap-3">
<div class="p-4 border text-center">
<div class="text-2xl font-bold">24/7</div>
<div class="text-xs">Disponibilité</div>
</div>
<div class="p-4 border text-center">
<div class="text-2xl font-bold">XOF</div>
<div class="text-xs">Monnaie locale</div>
</div>
<div class="p-4 border text-center">
<div class="text-2xl font-bold">API</div>
<div class="text-xs">Intégration</div>
</div>
</div>
</div>
<div
class="absolute -bottom-8 right-10 bg-pmu-jaune text-black px-4 py-2 rounded-full shadow-lg animate-float"
>
Opérationnel & Sécurisé
</div>
</div>
</aside>
<!-- Form side -->
<main class="flex items-center justify-center lg:justify-end w-full">
<div class="w-full max-w-md">
<router-outlet></router-outlet>
</div>
</main>
</div>
</div>

View File

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

View File

@@ -0,0 +1,16 @@
import { Component } from '@angular/core';
import { Theme } from 'src/app/core/services/theme';
@Component({
selector: 'app-auth-layout',
templateUrl: './auth-layout.html',
styleUrl: './auth-layout.css',
standalone: false,
})
export class AuthLayout {
constructor(public theme: Theme) {}
toggleTheme() {
this.theme.toggle();
}
}

View File

@@ -0,0 +1,26 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { AuthRoutingModule } from './auth-routing-module';
import { AuthLayout } from './auth-layout/auth-layout';
import { Login } from './pages/login/login';
import { ModeToggle } from '@shared/components/mode-toggle/mode-toggle';
import { SharedModule } from '@shared/shared-module';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { ZardSwitchComponent } from '@shared/components/switch/switch.component';
import { PmuLogo } from '@shared/components/pmu-logo/pmu-logo';
@NgModule({
declarations: [AuthLayout, Login],
imports: [
CommonModule,
AuthRoutingModule,
SharedModule,
ModeToggle,
PmuLogo,
FormsModule,
ReactiveFormsModule,
ZardSwitchComponent,
],
})
export class AuthModule {}

View File

@@ -0,0 +1,24 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthLayout } from './auth-layout/auth-layout';
import { Login } from './pages/login/login';
const routes: Routes = [
{
path: '',
component: AuthLayout,
children: [
{
path: 'login',
component: Login,
},
{ path: '', pathMatch: 'full', redirectTo: 'login' },
],
},
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class AuthRoutingModule {}

View File

View File

@@ -0,0 +1,84 @@
<div class="rounded-2xl p-6 shadow backdrop-blur animate-glow">
<div class="flex items-center gap-3">
<app-pmu-logo variant="default" />
<h1 class="text-xl font-semibold">Connexion</h1>
</div>
<p class="mt-1 text-sm">Accédez à votre espace PMU MALI</p>
<form class="mt-6 space-y-4" (ngSubmit)="submit()" [formGroup]="form">
<!-- Identifiant -->
<div>
<label class="text-sm">Identifiant</label>
<div class="relative mt-1">
<input
class="w-full rounded-md border p-2 bg-transparent outline-none focus:ring-2 focus:ring-[var(--ring)] placeholder:text-black/50 dark:placeholder:text-white/50"
type="text"
autocomplete="username"
placeholder="ex: AGENT001"
formControlName="identifiant"
/>
</div>
@if (form.controls['identifiant'].touched && form.controls['identifiant'].invalid) {
<div class="mt-1 text-xs text-red-600">Identifiant requis</div>
}
</div>
<!-- Password -->
<div>
<label class="text-sm">Mot de passe</label>
<div class="relative mt-1">
<input
class="w-full rounded-md border p-2 pr-10 bg-transparent outline-none focus:ring-2 focus:ring-[var(--ring)] placeholder:text-black/60 dark:placeholder:text-white/60"
[type]="showPassword ? 'text' : 'password'"
autocomplete="current-password"
formControlName="password"
placeholder="••••••••"
/>
<button
type="button"
class="absolute inset-y-0 right-2 my-auto text-sm opacity-70 hover:opacity-100"
(click)="showPassword = !showPassword"
aria-label="Afficher le mot de passe"
>
{{ showPassword ? 'Masquer' : 'Afficher' }}
</button>
</div>
@if (form.controls['password'].touched && form.controls['password'].invalid) {
<div class="mt-1 text-xs text-red-600">8 caractères minimum</div>
}
</div>
<!-- Submit -->
<button
type="submit"
class="inline-flex w-full items-center justify-center rounded-md cursor-pointer px-3 py-2 text-sm font-medium text-white bg-pmu-vert transition hover:opacity-90 disabled:opacity-40"
[disabled]="loading() || !form.valid"
>
@if (!loading()) {
<span>Se connecter</span>
} @if (loading()) {
<span class="inline-flex items-center gap-2">
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 0 1 8-8v4A4 4 0 0 0 8 12H4z"
/>
</svg>
Connexion…
</span>
}
</button>
</form>
<div class="mt-6 text-xs">Plateforme de Jeux de la PMU.</div>
</div>

View File

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

View File

@@ -0,0 +1,47 @@
import { Component, signal } from '@angular/core'; // Import OnInit
import { Validators, FormBuilder, FormGroup } from '@angular/forms'; // Import FormGroup
import { Router } from '@angular/router';
import { toast } from 'ngx-sonner';
import { Auth } from 'src/app/core/services/auth';
@Component({
selector: 'app-login',
templateUrl: './login.html',
styleUrl: './login.css',
standalone: false,
})
export class Login {
showPassword = false;
loading = signal(false);
errorMsg = signal('');
form!: FormGroup;
constructor(private fb: FormBuilder, private auth: Auth, private router: Router) {
this.form = this.fb.group({
identifiant: ['', [Validators.required]],
password: ['', [Validators.required, Validators.minLength(8)]],
});
}
async submit() {
this.errorMsg.set('');
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
this.loading.set(true);
try {
const { identifiant, password } = this.form.value;
await this.auth.login(identifiant!, password!);
await this.router.navigateByUrl('/');
toast.success('Connexion réussie ! Bienvenue.');
} catch (e: any) {
this.errorMsg.set(
e?.message || e?.error?.message || 'Échec de connexion. Veuillez réessayer.'
);
toast.error(this.errorMsg(), { duration: 5000 });
} finally {
this.loading.set(false);
}
}
}

View File

@@ -0,0 +1,17 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { HTTP_INTERCEPTORS } from '@angular/common/http';
import { ApiPrefixInterceptor } from './interceptors/api-prefix-interceptor';
import { AuthTokenInterceptor } from './interceptors/auth-token-interceptor';
import { HttpErrorInterceptor } from './interceptors/http-error-interceptor';
@NgModule({
declarations: [],
imports: [CommonModule],
providers: [
{ provide: HTTP_INTERCEPTORS, useClass: ApiPrefixInterceptor, multi: true },
{ provide: HTTP_INTERCEPTORS, useClass: AuthTokenInterceptor, multi: true },
{ provide: HTTP_INTERCEPTORS, useClass: HttpErrorInterceptor, multi: true },
],
})
export class CoreModule {}

View File

@@ -0,0 +1,17 @@
import { TestBed } from '@angular/core/testing';
import { CanActivateFn } from '@angular/router';
import { authGuard } from './auth-guard';
describe('authGuard', () => {
const executeGuard: CanActivateFn = (...guardParameters) =>
TestBed.runInInjectionContext(() => authGuard(...guardParameters));
beforeEach(() => {
TestBed.configureTestingModule({});
});
it('should be created', () => {
expect(executeGuard).toBeTruthy();
});
});

View File

@@ -0,0 +1,9 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { Auth } from '../services/auth';
export const authGuard: CanActivateFn = (route, state) => {
const auth = inject(Auth);
const router = inject(Router);
return auth.isAuthenticated() ? true : router.parseUrl('/auth/login');
};

View File

@@ -0,0 +1,33 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { Auth } from '../services/auth';
/**
* Guard générique basé sur le roleId de l'utilisateur.
* Usage dans le routing:
* {
* path: 'users',
* canActivate: [roleGuard],
* data: { roles: ['1', '2'] } // ids de rôles autorisés
* }
*/
export const roleGuard: CanActivateFn = (route, state) => {
const auth = inject(Auth);
const router = inject(Router);
const expectedRoles = (route.data?.['roles'] as string[] | undefined) ?? [];
if (!auth.isAuthenticated()) {
return router.parseUrl('/auth/login');
}
if (expectedRoles.length === 0) {
// Si aucune contrainte, on laisse passer
return true;
}
const ok = auth.hasAnyRoleId(expectedRoles);
return ok ? true : router.parseUrl('/dashboard'); // ou une page 403 dédiée
};

View File

@@ -0,0 +1,32 @@
import { Injectable } from '@angular/core';
import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from 'src/environments/environment.development';
@Injectable()
export class ApiPrefixInterceptor implements HttpInterceptor {
constructor() {}
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
const isAbsolute = /^https?:\/\//i.test(request.url);
const url = isAbsolute ? request.url : `${environment.apiBaseUrl}${request.url}`;
// Add ngrok bypass header to skip the warning page
const isNgrok =
url.includes('ngrok-free.app') || url.includes('ngrok.io') || url.includes('ngrok');
// Clone request with updated URL
let clonedRequest = request.clone({ url });
// Add ngrok bypass header if needed (only if not already present)
if (isNgrok && !clonedRequest.headers.has('ngrok-skip-browser-warning')) {
clonedRequest = clonedRequest.clone({
setHeaders: {
'ngrok-skip-browser-warning': 'true',
},
});
}
return next.handle(clonedRequest);
}
}

View File

@@ -0,0 +1,17 @@
import { TestBed } from '@angular/core/testing';
import { HttpInterceptorFn } from '@angular/common/http';
import { authTokenInterceptor } from './auth-token-interceptor';
describe('authTokenInterceptor', () => {
const interceptor: HttpInterceptorFn = (req, next) =>
TestBed.runInInjectionContext(() => authTokenInterceptor(req, next));
beforeEach(() => {
TestBed.configureTestingModule({});
});
it('should be created', () => {
expect(interceptor).toBeTruthy();
});
});

View File

@@ -0,0 +1,14 @@
import { Injectable } from '@angular/core';
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Auth } from '../services/auth';
@Injectable()
export class AuthTokenInterceptor implements HttpInterceptor {
constructor(private auth: Auth) {}
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
const token = this.auth.getToken();
if (!token) return next.handle(req);
return next.handle(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }));
}
}

View File

@@ -0,0 +1,17 @@
import { TestBed } from '@angular/core/testing';
import { HttpInterceptorFn } from '@angular/common/http';
import { httpErrorInterceptor } from './http-error-interceptor';
describe('httpErrorInterceptor', () => {
const interceptor: HttpInterceptorFn = (req, next) =>
TestBed.runInInjectionContext(() => httpErrorInterceptor(req, next));
beforeEach(() => {
TestBed.configureTestingModule({});
});
it('should be created', () => {
expect(interceptor).toBeTruthy();
});
});

View File

@@ -0,0 +1,24 @@
import { Injectable } from '@angular/core';
import {
HttpRequest,
HttpHandler,
HttpEvent,
HttpInterceptor,
HttpErrorResponse,
} from '@angular/common/http';
import { catchError, Observable, throwError } from 'rxjs';
@Injectable()
export class HttpErrorInterceptor implements HttpInterceptor {
constructor() {}
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
return next.handle(request).pipe(
catchError((err: HttpErrorResponse) => {
// TODO: remplacer par un toast global
console.error('HTTP error:', err.status, err.message);
return throwError(() => err);
})
);
}
}

View File

@@ -0,0 +1,23 @@
export interface AgentLimit {
id: string;
code: string; // e.g., ALC001
configCode: string; // e.g., ALC001
nom: string;
isDefault: boolean;
actif: boolean;
// Bet limits
betMin?: number;
betMax?: number;
maxBet?: number;
maxDisburseBet?: number;
// Airtime
airtimeMin?: number;
airtimeMax?: number;
createdAt?: string;
createdBy?: string;
}

View File

@@ -0,0 +1,65 @@
import { TpeDevice } from './tpe';
export type AgentStatus = 'ACTIF' | 'INACTIF' | 'SUSPENDU';
export interface Agent {
id: string;
code: string;
profile: string; // ex. AGENT, SUPERVISEUR, CAISSIER
principalCode?: string; // Agent principal
caisseProfile?: string;
statut: AgentStatus;
zone?: string;
kiosk?: string;
fonction?: string;
dateEmbauche?: string; // ISO
nom: string;
prenom: string;
autresNoms?: string;
dateNaissance?: string;
lieuNaissance?: string;
ville?: string;
adresse?: string;
autoriserAides?: boolean;
phone: string;
pin?: string; // masked in UI
limiteInferieure?: number;
limiteSuperieure?: number;
limiteParTransaction?: number;
limiteMinAirtime?: number;
limiteMaxAirtime?: number;
maxPeripheriques?: number;
limitId?: string; // reference to AgentLimit config
// Légales
nationalite?: string;
cni?: string;
cniDelivreeLe?: string;
cniDelivreeA?: string;
residence?: string;
autreAdresse1?: string;
statutMarital?: string;
epoux?: string;
autreTelephone?: string;
// TPE assignés (actifs seulement)
tpes?: TpeDevice[];
createdAt?: string;
updatedAt?: string;
createdBy?: string;
}
export interface AgentFamilyMember {
id: string;
agentId: string;
nom: string;
statut?: string; // conjoint, enfant, etc.
dateNaissance?: string;
sexe?: 'M' | 'F';
}

View File

@@ -0,0 +1,58 @@
import { Reunion } from './reunion';
export enum CourseType {
TIERCE = 'TIERCE',
QUARTE = 'QUARTE + TIERCE',
QUINTE = 'QUINTE + TIERCE',
}
export enum CourseStatut {
PROGRAMMEE = 'PROGRAMMEE',
CREATED = 'CREATED',
VALIDATED = 'VALIDATED',
RUNNING = 'RUNNING',
CLOSED = 'CLOSED',
CANCELED = 'CANCELED',
}
export enum ResultatStatut {
NONE = 'NONE',
NON_GENERE = 'NON_GENERE',
CREATED = 'CREATED',
VALIDATED = 'VALIDATED',
CONFIRMED = 'CONFIRMED',
}
export interface Course {
id: string;
type: CourseType | string; // API returns "Plat" as string
numero: number;
nom: string;
dateDepartCourse: string;
dateDebutParis: string;
dateFinParis: string;
reunion: Reunion;
reunionCourse: number;
particularite?: string;
partants: number;
distance: number;
condition?: string;
statut: CourseStatut | string; // API returns "PROGRAMMEE" as string
nonPartants: string[];
// Additional API fields
estTerminee?: boolean;
estAnnulee?: boolean;
nombreChevauxInscrits?: number;
adeadHeat?: boolean;
createdBy: string;
validatedBy?: string | null;
createdAt: string | null;
updatedAt: string | null;
}

View File

@@ -0,0 +1,13 @@
export interface Hippodrome {
id: string;
nom: string;
ville: string;
pays: string;
actif: boolean;
capacite?: number;
description?: string;
reunionCount?: number;
courseCount?: number;
createdAt: string;
updatedAt: string;
}

View File

@@ -0,0 +1,7 @@
export interface MenuItem {
icon: string;
label: string;
exact?: boolean;
link?: string;
submenu?: MenuItem[];
}

View File

@@ -0,0 +1,26 @@
import { Course } from './course';
export type ReportStatut = 'Validé' | 'Non Validé' | 'En attente';
export type CourseCloseStatut = 'Clôturée' | 'Ouverte';
export interface CourseReportSummary {
id: string; // same as course id
course: Course; // full course reference; the course must be CLOSED
statut: ReportStatut;
confirmed?: boolean; // when true, report is locked (no further edits)
}
export interface CourseReportDetailRow {
typeGain: string; // e.g., QUINTE ORDRE
typeJeu: string; // e.g., Quinte+
montant: number; // amount per winning ticket
nombre: number; // number of winners
statut: 'Validée' | 'Non Validée';
distributed?: boolean;
externe?: boolean;
}
export interface CourseReportDetail {
summary: CourseReportSummary;
rows: CourseReportDetailRow[];
}

View File

@@ -0,0 +1,58 @@
import { Course } from './course';
export interface Resultat {
id: string;
course: Course;
/**
* Ordre d'arrivée des chevaux.
* The backend returns an array of strings/numbers (cheval numbers);
* in the UI we normalize them to plain numbers.
*/
ordreArrivee: number[];
/**
* Chevaux en dead-heat (ex aequo), represented by their numbers.
*/
chevauxDeadHeat: number[];
totalMises: number;
masseAPartager: number;
prelevementsLegaux: number;
montantRembourse: number;
montantCagnotte: number;
adeadHeat: boolean;
createdAt?: string;
updatedAt?: string;
}
// API response structure (course may be just an ID in some cases)
export interface ResultatApiResponse {
id: string | number;
course: Course | string | number;
/**
* In the raw API this is an array of strings/numbers.
*/
ordreArrivee: (string | number)[];
chevauxDeadHeat: (string | number)[];
totalMises: number;
masseAPartager: number;
prelevementsLegaux: number;
montantRembourse: number;
montantCagnotte: number;
adeadHeat: boolean;
createdAt?: string;
updatedAt?: string;
}
// POST payload structure
export interface CreateResultatPayload {
course: {
id: string | number;
};
ordreArrivee: string[];
chevauxDeadHeat?: (string | number)[];
totalMises?: number;
masseAPartager?: number;
prelevementsLegaux?: number;
montantRembourse?: number;
montantCagnotte?: number;
adeadHeat?: boolean;
}

View File

@@ -0,0 +1,21 @@
import { Hippodrome } from './hippodrome';
export enum ReunionStatut {
PLANIFIEE = 'PLANIFIEE',
EN_COURS = 'EN_COURS',
TERMINEE = 'TERMINEE',
ANNULEE = 'ANNULEE',
}
export interface Reunion {
id: string;
code: string;
nom: string;
date: string;
numero: number;
statut: ReunionStatut;
hippodrome: Hippodrome;
totalCourses?: number;
createdAt: string;
updatedAt: string;
}

View File

@@ -0,0 +1,14 @@
export interface Permission {
id: string;
name: string;
description?: string;
}
export interface Role {
id: string;
name: string;
description?: string;
permissions: Permission[];
createdAt?: string;
updatedAt?: string;
}

View File

@@ -0,0 +1,27 @@
import { Agent } from './agent';
export type TpeStatus =
| 'VALIDE'
| 'INVALIDE'
| 'EN_PANNE'
| 'BLOQUE'
| 'DISPONIBLE'
| 'AFFECTE'
| 'EN_MAINTENANCE'
| 'HORS_SERVICE'
| 'VOLE';
export type TpeType = 'POS' | 'OTHER';
export interface TpeDevice {
id: string;
imei: string;
serial: string;
type: TpeType;
marque: string;
modele: string;
statut: TpeStatus;
agent?: Agent;
assigne: boolean;
createdAt?: string;
updatedAt?: string;
}

View File

@@ -0,0 +1,39 @@
export type UserStatus = 'ACTIVE' | 'CANCELLED' | 'SUSPENDED' | string;
import type { Role } from './role';
/**
* Frontend User model.
* Aligns with backend payload while keeping a convenient `role` object when available.
*/
export interface User {
id: string;
/** Nom (last name) */
nom: string;
/** Prénom (first name) */
prenom: string;
/** Identifiant de connexion (username/login) */
identifiant: string;
/** (Hashed) password never filled from backend in UI, only for create/update. */
password?: string;
/** Matricule Agent */
matriculeAgent: string;
/** Foreign key vers le rôle */
roleId: string;
/** Rôle complet (chargé séparément) */
role?: Role;
/** Restriction de connexion (manual) */
restrictionConnexion: boolean;
/** Restriction automatique */
restrictionAutomatique: boolean;
/** Nombre d'IP autorisé (manual) */
nombreIpAutorise: number;
/** Nombre d'IP auto autorisé (automatic) */
nombreIpAutoAutorise: number;
/** Statut (from grid / backend) */
statut: UserStatus;
/** Date de dernière connexion (ISO) */
derniereConnexion?: string;
/** Timestamps */
createdAt?: string;
updatedAt?: string;
}

View File

@@ -0,0 +1,38 @@
import { AgentLimit } from '../interfaces/agent-limit';
export const AGENT_LIMITS_MOCK: AgentLimit[] = [
{
id: crypto.randomUUID(),
code: 'ALC001',
configCode: 'ALC001',
nom: 'REGION LIMITS',
isDefault: true,
actif: true,
betMin: 10_000,
betMax: 10_000_000,
maxBet: 10_000_000,
maxDisburseBet: -1,
airtimeMin: 0,
airtimeMax: 50_000,
createdAt: '2017-06-05T00:00:00.000Z',
createdBy: 'admin',
},
{
id: crypto.randomUUID(),
code: 'ALC002',
configCode: 'ALC002',
nom: 'INDIV PAY KIOSK 200k',
isDefault: false,
actif: true,
betMin: 10_000,
betMax: 10_000_000,
maxBet: 10_000_000,
maxDisburseBet: 0,
airtimeMin: 100,
airtimeMax: 100_000,
createdAt: '2022-02-01T00:00:00.000Z',
createdBy: 'admin',
},
];

View File

@@ -0,0 +1,65 @@
// import { Agent } from '../interfaces/agent';
// import { AGENT_LIMITS_MOCK } from './agent-limit.mocks';
// import { TPE_MOCK } from '../mocks/tpe.mocks';
// export const AGENTS_MOCK: Agent[] = [
// {
// id: crypto.randomUUID(),
// code: 'ALD001',
// profile: 'AGENT',
// principalCode: 'ALC001',
// caisseProfile: 'ALC001',
// statut: 'ACTIF',
// zone: 'Bamako',
// kiosk: 'K-0001',
// fonction: 'Vendeur',
// dateEmbauche: '2020-03-07T00:00:00.000Z',
// nom: 'Diop',
// prenom: 'Amadou',
// autresNoms: '',
// dateNaissance: '1990-01-01',
// lieuNaissance: 'Bamako',
// ville: 'Bamako',
// adresse: 'Quartier A',
// autoriserAides: false,
// phone: '+22370000001',
// limiteInferieure: 0,
// limiteSuperieure: 10_000_000,
// limiteParTransaction: 1_000_000,
// limiteMinAirtime: 0,
// limiteMaxAirtime: 100_000,
// maxPeripheriques: 5,
// limitId: AGENT_LIMITS_MOCK[0].id,
// nationalite: 'ML',
// cni: 'CNI123456',
// cniDelivreeLe: '2018-06-01',
// cniDelivreeA: 'Bamako',
// residence: 'Bamako',
// statutMarital: 'Marié',
// epoux: 'Aissatou',
// autreTelephone: '+22370000009',
// famille: [
// { id: crypto.randomUUID(), nom: 'Aissatou', statut: 'Conjointe', dateNaissance: '1991-03-05', sexe: 'F' },
// { id: crypto.randomUUID(), nom: 'Ibrahim', statut: 'Enfant', dateNaissance: '2015-09-10', sexe: 'M' },
// ],
// assignedTpeIds: TPE_MOCK.filter((t) => t.statut === 'valide').slice(0, 1).map((t) => t.id),
// createdAt: '2020-03-07T00:00:00.000Z',
// createdBy: 'admin',
// },
// ...Array.from({ length: 12 }).map((_, i) => ({
// id: crypto.randomUUID(),
// code: `ALK${String(100 + i).padStart(3, '0')}`,
// profile: 'AGENT',
// statut: i % 5 === 0 ? 'INACTIF' : 'ACTIF',
// nom: `Agent${i + 1}`,
// prenom: 'Test',
// phone: `+2237${(1000000 + i).toString()}`,
// limiteInferieure: 0,
// limiteSuperieure: 10_000_000,
// limiteParTransaction: 500_000,
// limiteMinAirtime: 0,
// limiteMaxAirtime: 100_000,
// maxPeripheriques: 3,
// limitId: AGENT_LIMITS_MOCK[1].id,
// } as Agent)),
// ];

View File

@@ -0,0 +1,197 @@
import { Course, CourseType, CourseStatut, ResultatStatut } from '../interfaces/course';
import { REUNIONS_MOCK } from './reunion.mocks';
const now = new Date();
const COURSES_PER_REUNION_BASE = 6;
function requiredLength(t: CourseType): number {
switch (t) {
case CourseType.TIERCE:
return 3;
case CourseType.QUARTE:
return 4;
case CourseType.QUINTE:
return 5;
default:
return 0;
}
}
function rngPick<T>(arr: T[], seed: number): T {
const x = Math.abs(Math.sin(seed) * 10000);
const idx = Math.floor((x - Math.floor(x)) * arr.length) % arr.length;
return arr[idx];
}
function makeMockResultat(
type: CourseType,
partants: number,
nonPartantsNums: number[],
seed: number
): number[][] {
const req = requiredLength(type);
const np = new Set(nonPartantsNums);
const all = Array.from({ length: partants }, (_, i) => i + 1).filter((n) => !np.has(n));
const used = new Set<number>();
const places: number[][] = [];
const tiePlace = Math.abs(seed) % 10 === 0 ? ((seed % req) + req) % req : -1;
for (let i = 0; i < req; i++) {
const remaining = all.filter((n) => !used.has(n));
if (remaining.length === 0) {
places.push([]);
continue;
}
const first = rngPick(remaining, seed + i * 7);
used.add(first);
const slot = [first];
if (i === tiePlace) {
const remaining2 = all.filter((n) => !used.has(n));
if (remaining2.length > 0) {
const second = rngPick(remaining2, seed + i * 13);
used.add(second);
slot.push(second);
slot.sort((a, b) => a - b);
}
}
places.push(slot);
}
return places;
}
const COURSE_NAMES = [
'Prix du Delta',
'Coupe du Fleuve Niger',
'Trophée du Mandé',
'Challenge du Nord',
'Prix de Bamako',
'Grand Prix de Tombouctou',
'Prix du Sahara',
'Trophée du Mali',
'Prix de la Savane',
'Course de la Paix',
'Grand Prix du Sud',
'Coupe de lAvenir',
'Prix du Coton',
'Prix de la Liberté',
'Prix du Marché Central',
'Prix du Rail',
'Challenge du Faso',
'Prix du Soleil',
'Prix du Soudan',
'Grand Prix du Président',
'Prix de la Jeunesse',
'Coupe de la Nation',
'Prix des Cavaliers',
'Trophée de lUnité',
'Prix du Bénin',
'Grand Prix de Sikasso',
'Prix du Commerce',
'Prix du Plateau',
'Course des Champions',
'Trophée de lEspoir',
'Prix du Développement',
'Prix de lAmitié',
'Grand Prix International',
'Prix du Peuple',
'Prix de la Baie',
'Trophée des Pionniers',
'Prix du Littoral',
];
const COURSE_TYPES = [CourseType.TIERCE, CourseType.QUARTE, CourseType.QUINTE];
const COURSE_STATUTS = [
CourseStatut.CREATED,
CourseStatut.VALIDATED,
CourseStatut.RUNNING,
CourseStatut.CLOSED,
CourseStatut.CANCELED,
];
const coursesPerReunion = new Map<string, number>();
const courses: Course[] = [];
REUNIONS_MOCK.forEach((reunion, reunionIndex) => {
const courseCount = COURSES_PER_REUNION_BASE + (reunionIndex % 2);
const reunionDate = new Date(`${reunion.date}T00:00:00`);
for (let i = 0; i < courseCount; i++) {
const globalIndex = courses.length;
const type = COURSE_TYPES[(globalIndex + i) % COURSE_TYPES.length];
const statut = COURSE_STATUTS[(globalIndex + reunionIndex) % COURSE_STATUTS.length];
const numberWithinReunion = (coursesPerReunion.get(reunion.id) ?? 0) + 1;
coursesPerReunion.set(reunion.id, numberWithinReunion);
const dateDebutParis = new Date(reunionDate);
dateDebutParis.setHours(8 + i, 0, 0, 0);
const dateFinParis = new Date(dateDebutParis);
dateFinParis.setHours(dateDebutParis.getHours() + 2);
const dateDepartCourse = new Date(reunionDate);
dateDepartCourse.setHours(12 + i, 30, 0, 0);
const partants = 10 + ((reunionIndex + i) % 6) * 2;
const nonPartants: string[] = numberWithinReunion % 4 === 0 ? [crypto.randomUUID()] : [];
const nonPartantsNums = nonPartants.map((np) => Number(np));
let resultat: number[][] | undefined;
let resultatStatut: ResultatStatut = ResultatStatut.NONE;
if (statut === CourseStatut.CLOSED) {
resultat = makeMockResultat(type, partants, nonPartantsNums, globalIndex * 31);
resultatStatut = ResultatStatut.CONFIRMED;
} else if (statut === CourseStatut.VALIDATED) {
resultat = makeMockResultat(type, partants, nonPartantsNums, globalIndex * 17);
resultatStatut = ResultatStatut.VALIDATED;
} else if (statut === CourseStatut.RUNNING && (globalIndex + reunionIndex) % 3 === 0) {
resultat = makeMockResultat(type, partants, nonPartantsNums, globalIndex * 7);
resultatStatut = ResultatStatut.CREATED;
}
courses.push({
id: crypto.randomUUID(),
type,
numero: globalIndex + 1,
nom: `${COURSE_NAMES[(globalIndex + reunionIndex) % COURSE_NAMES.length]} - ${
reunion.hippodrome.ville
}`,
dateDebutParis: dateDebutParis.toISOString(),
dateFinParis: dateFinParis.toISOString(),
dateDepartCourse: dateDepartCourse.toISOString(),
reunion,
reunionCourse: numberWithinReunion,
particularite:
(globalIndex + reunionIndex) % 2 === 0
? 'Course de galop - conditions variées'
: 'Trot attelé - catégorie nationale',
partants,
distance: 2000 + ((reunionIndex + i) % 5) * 200,
condition:
(globalIndex + reunionIndex) % 3 === 0
? 'Réservée aux chevaux de 3 ans et plus'
: 'Course mixte - catégorie B',
statut,
nonPartants,
createdBy: `user-${((globalIndex + reunionIndex) % 5) + 1}`,
validatedBy: statut === CourseStatut.VALIDATED ? 'admin-1' : undefined,
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
});
}
});
coursesPerReunion.forEach((count, reunionId) => {
const reunion = REUNIONS_MOCK.find((r) => r.id === reunionId);
if (reunion) {
reunion.totalCourses = count;
}
});
export const COURSES_MOCK: Course[] = courses;

View File

@@ -0,0 +1,421 @@
import { Hippodrome } from '../interfaces/hippodrome';
export const HIPPODROMES_MOCK: Hippodrome[] = [
// 🇫🇷 France
{
id: crypto.randomUUID(),
nom: 'Longchamp',
ville: 'Paris',
pays: 'France',
actif: true,
capacite: 50000,
description: 'Célèbre hippodrome parisien accueillant le Prix de lArc de Triomphe.',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: crypto.randomUUID(),
nom: 'Vincennes',
ville: 'Paris',
pays: 'France',
actif: true,
capacite: 40000,
description: 'Spécialisé dans les courses de trot attelé.',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: crypto.randomUUID(),
nom: 'Auteuil',
ville: 'Paris',
pays: 'France',
actif: true,
capacite: 30000,
description: 'Hippodrome de référence pour les courses dobstacles.',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: crypto.randomUUID(),
nom: 'Deauville-La Touques',
ville: 'Deauville',
pays: 'France',
actif: true,
capacite: 20000,
description: 'Station balnéaire accueillant les courses estivales.',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: crypto.randomUUID(),
nom: 'Chantilly',
ville: 'Chantilly',
pays: 'France',
actif: true,
capacite: 25000,
description: 'Hippodrome emblématique adossé au château de Chantilly.',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: crypto.randomUUID(),
nom: 'Saint-Cloud',
ville: 'Saint-Cloud',
pays: 'France',
actif: true,
capacite: 20000,
description: 'Courses de plat sur herbe, cadre verdoyant.',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: crypto.randomUUID(),
nom: 'Cagnes-sur-Mer',
ville: 'Cagnes-sur-Mer',
pays: 'France',
actif: true,
capacite: 15000,
description: 'Hippodrome moderne du sud de la France.',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: crypto.randomUUID(),
nom: 'Pau',
ville: 'Pau',
pays: 'France',
actif: true,
capacite: 10000,
description: 'Hippodrome historique du Béarn, courses dobstacles.',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: crypto.randomUUID(),
nom: 'Lyon-Parilly',
ville: 'Lyon',
pays: 'France',
actif: true,
capacite: 18000,
description: 'Hippodrome polyvalent de la région Rhône-Alpes.',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: crypto.randomUUID(),
nom: 'Marseille-Borély',
ville: 'Marseille',
pays: 'France',
actif: true,
capacite: 20000,
description: 'Hippodrome emblématique du sud avec vue sur mer.',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: crypto.randomUUID(),
nom: 'Nancy-Brabois',
ville: 'Nancy',
pays: 'France',
actif: false,
capacite: 8000,
description: 'Petit hippodrome régional pour courses locales.',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
// 🇲🇦 Maroc
{
id: crypto.randomUUID(),
nom: 'Hippodrome de Casablanca-Anfa',
ville: 'Casablanca',
pays: 'Maroc',
actif: true,
capacite: 30000,
description: 'Principal hippodrome du Maroc, moderne et actif.',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: crypto.randomUUID(),
nom: 'Hippodrome de Marrakech',
ville: 'Marrakech',
pays: 'Maroc',
actif: true,
capacite: 20000,
description: 'Installations modernes, climat idéal pour les courses.',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: crypto.randomUUID(),
nom: 'Hippodrome dEl Jadida',
ville: 'El Jadida',
pays: 'Maroc',
actif: true,
capacite: 15000,
description: 'Accueille des compétitions nationales et régionales.',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: crypto.randomUUID(),
nom: 'Hippodrome de Meknès',
ville: 'Meknès',
pays: 'Maroc',
actif: false,
capacite: 10000,
description: 'En rénovation, ancien centre hippique royal.',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: crypto.randomUUID(),
nom: 'Hippodrome de Rabat-Souissi',
ville: 'Rabat',
pays: 'Maroc',
actif: true,
capacite: 25000,
description: 'Hippodrome royal accueillant de grands événements.',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
// 🇸🇳 Sénégal
{
id: crypto.randomUUID(),
nom: 'Hippodrome de Niaga',
ville: 'Dakar',
pays: 'Sénégal',
actif: true,
capacite: 12000,
description: 'Centre principal des courses sénégalaises.',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: crypto.randomUUID(),
nom: 'Hippodrome de Thiès',
ville: 'Thiès',
pays: 'Sénégal',
actif: false,
capacite: 7000,
description: 'Structure régionale en cours de modernisation.',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: crypto.randomUUID(),
nom: 'Hippodrome de Saint-Louis',
ville: 'Saint-Louis',
pays: 'Sénégal',
actif: true,
capacite: 9000,
description: 'Traditionnel lieu de courses dans le nord du pays.',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
// 🇲🇱 Mali
{
id: crypto.randomUUID(),
nom: 'Hippodrome de Bamako',
ville: 'Bamako',
pays: 'Mali',
actif: true,
capacite: 15000,
description: 'Hippodrome national du Mali, centre principal des courses.',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: crypto.randomUUID(),
nom: 'Hippodrome de Kayes',
ville: 'Kayes',
pays: 'Mali',
actif: true,
capacite: 8000,
description: 'Centre hippique de la première région du Mali.',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: crypto.randomUUID(),
nom: 'Hippodrome de Sikasso',
ville: 'Sikasso',
pays: 'Mali',
actif: true,
capacite: 7000,
description: 'Hippodrome régional accueillant des compétitions locales.',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: crypto.randomUUID(),
nom: 'Hippodrome de Ségou',
ville: 'Ségou',
pays: 'Mali',
actif: false,
capacite: 5000,
description: 'Hippodrome en cours de réhabilitation.',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: crypto.randomUUID(),
nom: 'Hippodrome de Mopti',
ville: 'Mopti',
pays: 'Mali',
actif: true,
capacite: 6000,
description: 'Lieu emblématique des courses régionales du centre.',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
// 🇨🇮 Côte dIvoire
{
id: crypto.randomUUID(),
nom: 'Hippodrome dAbidjan',
ville: 'Abidjan',
pays: 'Côte dIvoire',
actif: true,
capacite: 18000,
description: 'Hippodrome national ivoirien.',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: crypto.randomUUID(),
nom: 'Hippodrome de Bouaké',
ville: 'Bouaké',
pays: 'Côte dIvoire',
actif: false,
capacite: 8000,
description: 'Petit hippodrome local en rénovation.',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
// 🇧🇪 Belgique
{
id: crypto.randomUUID(),
nom: 'Hippodrome de Wallonie',
ville: 'Mons',
pays: 'Belgique',
actif: true,
capacite: 12000,
description: 'Hippodrome principal du sud de la Belgique.',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: crypto.randomUUID(),
nom: 'Hippodrome de Kuurne',
ville: 'Kuurne',
pays: 'Belgique',
actif: true,
capacite: 9000,
description: 'Spécialisé dans les courses de trot.',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
// 🇨🇭 Suisse
{
id: crypto.randomUUID(),
nom: 'Hippodrome dAvenches',
ville: 'Avenches',
pays: 'Suisse',
actif: true,
capacite: 10000,
description: 'Hippodrome moderne et bien équipé au cœur de la Suisse.',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
// 🇨🇦 Canada
{
id: crypto.randomUUID(),
nom: 'Hippodrome 3R',
ville: 'Trois-Rivières',
pays: 'Canada',
actif: true,
capacite: 15000,
description: 'Hippodrome historique du Québec.',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: crypto.randomUUID(),
nom: 'Hippodrome de Québec',
ville: 'Québec',
pays: 'Canada',
actif: false,
capacite: 10000,
description: 'Ancien hippodrome du centre-ville, fermé au public.',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
// 🇹🇳 Tunisie
{
id: crypto.randomUUID(),
nom: 'Hippodrome de Ksar Saïd',
ville: 'Tunis',
pays: 'Tunisie',
actif: true,
capacite: 20000,
description: 'Hippodrome national tunisien.',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: crypto.randomUUID(),
nom: 'Hippodrome de Sfax',
ville: 'Sfax',
pays: 'Tunisie',
actif: true,
capacite: 12000,
description: 'Centre hippique régional.',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
// 🇩🇿 Algérie
{
id: crypto.randomUUID(),
nom: 'Hippodrome de Caroubier',
ville: 'Alger',
pays: 'Algérie',
actif: true,
capacite: 25000,
description: 'Principal hippodrome dAlgérie.',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: crypto.randomUUID(),
nom: 'Hippodrome dOran',
ville: 'Oran',
pays: 'Algérie',
actif: true,
capacite: 15000,
description: 'Hippodrome côtier moderne.',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
// 🇲🇷 Mauritanie
{
id: crypto.randomUUID(),
nom: 'Hippodrome de Nouakchott',
ville: 'Nouakchott',
pays: 'Mauritanie',
actif: true,
capacite: 10000,
description: 'Unique hippodrome national de Mauritanie.',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
];

View File

@@ -0,0 +1,110 @@
import { Course } from '../interfaces/course';
import {
CourseReportDetail,
CourseReportDetailRow,
CourseReportSummary,
} from '../interfaces/report';
import { COURSES_MOCK } from './course.mocks';
function randomInt(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
export function payoutRowsForCourse(c: Course): CourseReportDetailRow[] {
const base: CourseReportDetailRow[] = [
{
typeGain: 'QUINTE ORDRE',
typeJeu: 'Quinte+',
montant: 2840500,
nombre: randomInt(1, 30),
statut: 'Validée',
distributed: false,
externe: false,
},
{
typeGain: 'QUINTE DESORDRE',
typeJeu: 'Quinte+',
montant: 40000,
nombre: randomInt(300, 5000),
statut: 'Validée',
distributed: false,
externe: false,
},
{
typeGain: 'BONUS 4',
typeJeu: 'Quinte+',
montant: 2000,
nombre: randomInt(5000, 25000),
statut: 'Validée',
distributed: false,
externe: false,
},
{
typeGain: 'REMBOURSEMENT',
typeJeu: 'Quinte+',
montant: 300,
nombre: randomInt(10, 500),
statut: 'Validée',
distributed: false,
externe: false,
},
{
typeGain: 'TIERCE ORDRE',
typeJeu: 'Tierce',
montant: 37000,
nombre: randomInt(100, 2000),
statut: 'Validée',
distributed: false,
externe: false,
},
{
typeGain: 'TIERCE DESORDRE',
typeJeu: 'Tierce',
montant: 6000,
nombre: randomInt(500, 6000),
statut: 'Validée',
distributed: false,
externe: false,
},
{
typeGain: 'TRANSFORME COUPLE',
typeJeu: 'Tierce',
montant: 3000,
nombre: randomInt(200, 2000),
statut: 'Validée',
distributed: false,
externe: false,
},
{
typeGain: 'TRANSFORME SIMPLE',
typeJeu: 'Tierce',
montant: 1500,
nombre: randomInt(10, 500),
statut: 'Validée',
distributed: false,
externe: false,
},
];
return base;
}
export const REPORT_SUMMARIES_MOCK: CourseReportSummary[] = COURSES_MOCK.filter(
(c) => c.statut === 'CLOSED'
)
.slice(0, 300)
.map(
(c) => ({ id: c.id, course: c, statut: 'En attente', confirmed: false } as CourseReportSummary)
);
export function buildDetailByCourseId(id: string): CourseReportDetail | undefined {
const summary = REPORT_SUMMARIES_MOCK.find((s) => s.id === id);
if (!summary) return undefined;
const rows = payoutRowsForCourse(summary.course as Course);
return { summary, rows } as CourseReportDetail;
}
// Pre-built rows map for in-memory updates
export const REPORT_DETAILS_MOCK = new Map<string, CourseReportDetailRow[]>();
for (const c of COURSES_MOCK.filter((c) => c.statut === 'CLOSED').slice(0, 300)) {
REPORT_DETAILS_MOCK.set(c.id, payoutRowsForCourse(c));
}

View File

@@ -0,0 +1,61 @@
import { Reunion, ReunionStatut } from '../interfaces/reunion';
import { HIPPODROMES_MOCK } from './hippodrome.mocks';
const now = new Date();
const REUNIONS_PER_HIPPODROME = 3;
const BASE_DATE = new Date('2025-01-05T14:00:00Z');
const STATUSES: ReunionStatut[] = [
ReunionStatut.TERMINEE,
ReunionStatut.EN_COURS,
ReunionStatut.PLANIFIEE,
];
const REUNION_TITLES = [
'Grand Prix',
'Challenge Régional',
'Meeting de la Capitale',
'Trophée des Champions',
'Festival Hippique',
'Prix du Président',
'Coupe des Nations',
'Gala des Courses',
'Trophée de la Ville',
'Coupe de lAvenir',
'Grand Meeting Nocturne',
'Festival International',
];
function slugify(value: string): string {
return value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-zA-Z0-9]/g, '')
.toUpperCase();
}
export const REUNIONS_MOCK: Reunion[] = HIPPODROMES_MOCK.flatMap((hippodrome, hipIndex) => {
const slug = slugify(hippodrome.nom || hippodrome.ville || `HIP${hipIndex + 1}`);
return Array.from({ length: REUNIONS_PER_HIPPODROME }).map((_, reunionOffset) => {
const globalIndex = hipIndex * REUNIONS_PER_HIPPODROME + reunionOffset;
const date = new Date(BASE_DATE);
date.setDate(BASE_DATE.getDate() + globalIndex * 2);
const title = REUNION_TITLES[globalIndex % REUNION_TITLES.length];
const statut = STATUSES[globalIndex % STATUSES.length];
return {
id: crypto.randomUUID(),
code: `${slug}-${date.getFullYear()}-${(reunionOffset + 1).toString().padStart(2, '0')}`,
nom: `${title} de ${hippodrome.ville}`,
date: date.toISOString().slice(0, 10),
numero: reunionOffset + 1,
statut,
hippodrome,
totalCourses: 0,
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
} satisfies Reunion;
});
});

View File

@@ -0,0 +1,144 @@
import { Permission, Role } from '../interfaces/role';
export const PERMISSIONS_MOCK: Permission[] = [
// Users
{ id: 'p1', name: 'USERS_READ', description: 'Voir utilisateurs' },
{ id: 'p2', name: 'USERS_CREATE', description: 'Créer utilisateurs' },
{ id: 'p3', name: 'USERS_UPDATE', description: 'Modifier utilisateurs' },
{ id: 'p4', name: 'USERS_DELETE', description: 'Supprimer utilisateurs' },
{ id: 'p5', name: 'USERS_RESET_PASSWORD', description: 'Réinitialiser mot de passe' },
{ id: 'p6', name: 'USERS_LOCK', description: 'Verrouiller utilisateurs' },
{ id: 'p7', name: 'USERS_UNLOCK', description: 'Déverrouiller utilisateurs' },
{ id: 'p8', name: 'USERS_RESET_2FA', description: 'Réinitialiser 2FA' },
{ id: 'p9', name: 'USERS_CHANGE_ROLE', description: 'Changer de rôle' },
{ id: 'p10', name: 'USERS_CHANGE_STATUS', description: 'Changer de statut' },
// Hippodromes
{ id: 'p11', name: 'HIPPODROMES_READ', description: 'Voir hippodromes' },
{ id: 'p12', name: 'HIPPODROMES_CREATE', description: 'Créer hippodromes' },
{ id: 'p13', name: 'HIPPODROMES_UPDATE', description: 'Modifier hippodromes' },
{ id: 'p14', name: 'HIPPODROMES_DELETE', description: 'Supprimer hippodromes' },
// Reunions
{ id: 'p11', name: 'REUNIONS_READ', description: 'Voir reunions' },
{ id: 'p12', name: 'REUNIONS_CREATE', description: 'Créer reunions' },
{ id: 'p13', name: 'REUNIONS_UPDATE', description: 'Modifier reunions' },
{ id: 'p14', name: 'REUNIONS_DELETE', description: 'Supprimer reunions' },
{ id: 'p15', name: 'REUNIONS_PLANIFIEE', description: 'Planifier reunions' },
{ id: 'p17', name: 'REUNIONS_TERMINEE', description: 'Terminer les reunions' },
{ id: 'p18', name: 'REUNIONS_CANCEL', description: 'Annuler les reunions' },
// Courses
{ id: 'p19', name: 'COURSES_READ', description: 'Voir courses' },
{ id: 'p20', name: 'COURSES_CREATE', description: 'Créer courses' },
{ id: 'p21', name: 'COURSES_UPDATE', description: 'Modifier courses' },
{ id: 'p22', name: 'COURSES_DELETE', description: 'Supprimer courses' },
{ id: 'p23', name: 'COURSES_VALIDATE', description: 'Valider courses' },
{ id: 'p24', name: 'COURSES_CONFIRM', description: 'Confirmer courses' },
{ id: 'p25', name: 'COURSES_CLOSE', description: 'Clôturer courses' },
{ id: 'p26', name: 'COURSES_CANCEL', description: 'Annuler courses' },
// TPE
{ id: 'p27', name: 'TPE_READ', description: 'Voir TPE' },
{ id: 'p28', name: 'TPE_CREATE', description: 'Créer TPE' },
{ id: 'p29', name: 'TPE_UPDATE', description: 'Modifier TPE' },
{ id: 'p30', name: 'TPE_DELETE', description: 'Supprimer TPE' },
{ id: 'p31', name: 'TPE_ASSIGN', description: 'Assigner TPE' },
{ id: 'p32', name: 'TPE_UNASSIGN', description: 'Déassigner TPE' },
// Agents
{ id: 'p33', name: 'AGENTS_READ', description: 'Voir agents' },
{ id: 'p34', name: 'AGENTS_CREATE', description: 'Créer agents' },
{ id: 'p35', name: 'AGENTS_UPDATE', description: 'Modifier agents' },
{ id: 'p36', name: 'AGENTS_DELETE', description: 'Supprimer agents' },
{ id: 'p37', name: 'AGENTS_ASSIGN', description: 'Assigner agents' },
{ id: 'p38', name: 'AGENTS_UNASSIGN', description: 'Déassigner agents' },
{ id: 'p39', name: 'AGENTS_ASSIGN_TPE', description: 'Assigner TPE à agents' },
{ id: 'p40', name: 'AGENTS_UNASSIGN_TPE', description: 'Déassigner TPE à agents' },
// Familles Agents
{ id: 'p41', name: 'AGENT_FAMILIES_READ', description: 'Voir familles agents' },
{ id: 'p42', name: 'AGENT_FAMILIES_CREATE', description: 'Créer familles agents' },
{ id: 'p43', name: 'AGENT_FAMILIES_UPDATE', description: 'Modifier familles agents' },
{ id: 'p44', name: 'AGENT_FAMILIES_DELETE', description: 'Supprimer familles agents' },
// Limites Agents
{ id: 'p41', name: 'AGENT_LIMITS_READ', description: 'Voir limites agents' },
{ id: 'p42', name: 'AGENT_LIMITS_CREATE', description: 'Créer limites agents' },
{ id: 'p43', name: 'AGENT_LIMITS_UPDATE', description: 'Modifier limites agents' },
{ id: 'p44', name: 'AGENT_LIMITS_DELETE', description: 'Supprimer limites agents' },
{ id: 'p45', name: 'AGENT_LIMITS_DEFAULTED', description: 'Définir limites agents par défaut' },
// Permissions
{ id: 'p31', name: 'PERMISSIONS_READ', description: 'Voir permissions' },
{ id: 'p32', name: 'PERMISSIONS_CREATE', description: 'Créer permissions' },
{ id: 'p33', name: 'PERMISSIONS_UPDATE', description: 'Modifier permissions' },
{ id: 'p34', name: 'PERMISSIONS_DELETE', description: 'Supprimer permissions' },
{ id: 'p35', name: 'PERMISSIONS_ASSIGN', description: 'Assigner permissions' },
{ id: 'p36', name: 'PERMISSIONS_UNASSIGN', description: 'Déassigner permissions' },
// Roles
{ id: 'p37', name: 'ROLES_READ', description: 'Voir rôles' },
{ id: 'p38', name: 'ROLES_CREATE', description: 'Créer rôles' },
{ id: 'p39', name: 'ROLES_UPDATE', description: 'Modifier rôles' },
{ id: 'p40', name: 'ROLES_DELETE', description: 'Supprimer rôles' },
{ id: 'p41', name: 'ROLES_ASSIGN', description: 'Assigner rôles' },
{ id: 'p42', name: 'ROLES_UNASSIGN', description: 'Déassigner rôles' },
{ id: 'p43', name: 'ROLES_ASSIGN_PERMISSIONS', description: 'Assigner permissions à rôles' },
{ id: 'p44', name: 'ROLES_UNASSIGN_PERMISSIONS', description: 'Déassigner permissions à rôles' },
// Users
{ id: 'p45', name: 'USERS_READ', description: 'Voir utilisateurs' },
{ id: 'p46', name: 'USERS_CREATE', description: 'Créer utilisateurs' },
{ id: 'p47', name: 'USERS_UPDATE', description: 'Modifier utilisateurs' },
{ id: 'p48', name: 'USERS_DELETE', description: 'Supprimer utilisateurs' },
{ id: 'p49', name: 'USERS_RESET_PASSWORD', description: 'Réinitialiser mot de passe' },
{ id: 'p50', name: 'USERS_LOCK', description: 'Verrouiller utilisateurs' },
{ id: 'p51', name: 'USERS_UNLOCK', description: 'Déverrouiller utilisateurs' },
];
export const ROLES_MOCK: Role[] = [
{
id: crypto.randomUUID(),
name: 'Superadmin',
description: 'Accès total à toute la plateforme',
permissions: [...PERMISSIONS_MOCK],
createdAt: new Date().toISOString(),
},
{
id: crypto.randomUUID(),
name: 'Administrateur Hippique',
description: 'Gestion des courses et résultats',
permissions: PERMISSIONS_MOCK.filter((p) =>
[
'COURSES_READ',
'COURSES_MANAGE',
'RESULTATS_VALIDATE',
'RESULTATS_CONFIRM',
'USERS_READ',
].includes(p.name)
),
createdAt: new Date().toISOString(),
},
{
id: crypto.randomUUID(),
name: 'Agent Commercial',
description: 'Gestion commerciale',
permissions: PERMISSIONS_MOCK.filter((p) => ['USERS_READ', 'COURSES_READ'].includes(p.name)),
createdAt: new Date().toISOString(),
},
{
id: crypto.randomUUID(),
name: 'Gestionnaires Réseau',
description: 'Gestion du réseau et consultation',
permissions: PERMISSIONS_MOCK.filter((p) => ['USERS_READ', 'COURSES_READ'].includes(p.name)),
createdAt: new Date().toISOString(),
},
{
id: crypto.randomUUID(),
name: 'Support PJP',
description: 'Support et consultation',
permissions: PERMISSIONS_MOCK.filter((p) => ['USERS_READ', 'COURSES_READ'].includes(p.name)),
createdAt: new Date().toISOString(),
},
];

View File

@@ -0,0 +1,36 @@
// import { TpeDevice } from '../interfaces/tpe';
// const brands = ['MobioT', 'Pax', 'Ingenico', 'Sunmi'];
// const models = ['MP4+', 'A920', 'Move5000', 'P2'];
// function randomImei(i: number): string {
// return `${String(i).padStart(3, '0')}${crypto.randomUUID().replace(/-/g, '').slice(0, 12)}`;
// }
// export const TPE_MOCK: TpeDevice[] = [
// {
// id: crypto.randomUUID(),
// imei: '0000ac43ad03c7fd',
// serial: 'S-10001',
// type: 'POS',
// marque: 'MobioT',
// modele: 'MP4+',
// statut: 'valide',
// assigne: true,
// createdAt: new Date().toISOString(),
// },
// ...Array.from({ length: 24 }).map(
// (_, i) =>
// ({
// id: crypto.randomUUID(),
// imei: randomImei(i + 1),
// serial: `S-${10002 + i}`,
// type: 'POS',
// marque: brands[i % brands.length],
// modele: models[i % models.length],
// statut: 'valide',
// assigne: i % 7 === 0,
// createdAt: new Date().toISOString(),
// } as TpeDevice)
// ),
// ];

View File

@@ -0,0 +1,69 @@
import { User } from '../interfaces/user';
import { ROLES_MOCK } from '../mocks/role.mocks';
export const USERS_MOCK: User[] = [
{
id: crypto.randomUUID(),
nom: 'Maiga',
prenom: 'Abdoulaye',
identifiant: 'maiga',
matriculeAgent: '91111',
roleId: ROLES_MOCK[1].id,
role: ROLES_MOCK[1],
restrictionConnexion: false,
restrictionAutomatique: false,
nombreIpAutorise: 0,
nombreIpAutoAutorise: 0,
statut: 'Annulé',
derniereConnexion: '2021-05-10T09:00:00.000Z',
createdAt: '2020-01-01T00:00:00.000Z',
},
{
id: crypto.randomUUID(),
nom: 'Toulema',
prenom: 'Moussa',
identifiant: 'toulema',
matriculeAgent: '91111',
roleId: ROLES_MOCK[1].id,
role: ROLES_MOCK[1],
restrictionConnexion: false,
restrictionAutomatique: false,
nombreIpAutorise: 0,
nombreIpAutoAutorise: 0,
statut: 'Annulé',
derniereConnexion: '2023-09-01T10:10:00.000Z',
},
{
id: crypto.randomUUID(),
nom: 'Toure',
prenom: 'Ibrahim',
identifiant: 'toure',
matriculeAgent: '91111',
roleId: ROLES_MOCK[1].id,
role: ROLES_MOCK[1],
restrictionConnexion: false,
restrictionAutomatique: false,
nombreIpAutorise: 0,
nombreIpAutoAutorise: 0,
statut: 'Annulé',
derniereConnexion: '2022-05-05T08:00:00.000Z',
},
...Array.from({ length: 20 }).map(
(_, i) =>
({
id: crypto.randomUUID(),
nom: `Utilisateur${i + 1}`,
prenom: 'Demo',
identifiant: `user${i + 1}`,
matriculeAgent: String(90000 + i),
roleId: (i % 3 === 0 ? ROLES_MOCK[3] : ROLES_MOCK[4]).id,
role: (i % 3 === 0 ? ROLES_MOCK[3] : ROLES_MOCK[4]),
restrictionConnexion: false,
restrictionAutomatique: false,
nombreIpAutorise: i % 2 === 0 ? 10 : 8,
nombreIpAutoAutorise: i % 2 === 0 ? 10 : 8,
statut: i % 5 === 0 ? 'Suspendu' : 'Actif',
derniereConnexion: new Date(2024, i % 12, (i % 28) + 1).toISOString(),
} as User)
),
];

View 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([]);
}
}

View 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([]);
}
}

View 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);
}
}

View 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();
});
});

View 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;
}
}

View 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();
});
});

View 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);
}
}

View 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();
});
});

View 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');
}
}

View 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();
});
});

View 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));
}
}

View 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([]);
}
}

View 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);
}
}

View 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,
};
}
}

View 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();
});
});

View 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);
}
}

View 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 });
})
);
}
}

View 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();
});
});

View 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);
}
}
}

View 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([]);
}
}

View 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))
);
}
}

View File

@@ -0,0 +1,36 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DashboardRoutingModule } from './dashboard-routing-module';
import {
Ban,
FolderPen,
Lock,
LucideAngularModule,
Printer,
RefreshCw,
SlidersHorizontal,
Trash2,
Trophy,
Unlink2,
} from 'lucide-angular';
@NgModule({
declarations: [],
imports: [
CommonModule,
DashboardRoutingModule,
LucideAngularModule.pick({
FolderPen,
Trash2,
Ban,
Trophy,
Lock,
Printer,
RefreshCw,
SlidersHorizontal,
Unlink2,
}),
],
})
export class DashboardModule {}

View File

@@ -0,0 +1,67 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { Layout } from './layout/layout';
import { authGuard } from '../core/guards/auth-guard';
const routes: Routes = [
{
path: '',
component: Layout,
canActivate: [authGuard],
children: [
{ path: '', loadComponent: () => import('./pages/main/main').then((m) => m.Main) },
{
path: 'courses',
loadComponent: () => import('./pages/courses/courses').then((m) => m.Course),
},
{
path: 'hippodromes',
loadComponent: () => import('./pages/hippodrome/hippodrome').then((m) => m.Hippodrome),
},
{
path: 'reunions',
loadComponent: () => import('./pages/reunion/reunion').then((m) => m.ReunionList),
},
{
path: 'users',
loadComponent: () => import('./pages/users/users').then((m) => m.UsersPage),
},
{
path: 'profile',
loadComponent: () => import('./pages/profile/profile').then((m) => m.ProfilePage),
},
{
path: 'roles',
loadComponent: () => import('./pages/roles/roles').then((m) => m.RolesPage),
},
{
path: 'tpes',
loadComponent: () => import('./pages/tpe/tpe').then((m) => m.TpePage),
},
{
path: 'agents',
loadComponent: () => import('./pages/agents/agents').then((m) => m.AgentsPage),
},
{
path: 'limits',
loadComponent: () => import('./pages/limits/limits').then((m) => m.LimitsPage),
},
{
path: 'rapport-courses',
loadComponent: () =>
import('./pages/report-courses/report-list').then((m) => m.ReportCoursesListPage),
},
{
path: 'rapport-courses/:id',
loadComponent: () =>
import('./pages/report-courses/report-detail').then((m) => m.ReportCoursesDetailPage),
},
],
},
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class DashboardRoutingModule {}

View File

@@ -0,0 +1,3 @@
:host {
display: contents;
}

View File

@@ -0,0 +1,182 @@
<z-layout class="border overflow-hidden bg-pmu-vert min-h-screen h-full">
<z-sidebar
[zWidth]="250"
[zCollapsible]="true"
[zCollapsed]="sidebarCollapsed()"
[zCollapsedWidth]="70"
(zCollapsedChange)="onCollapsedChange($event)"
class="!p-0 dark:!bg-pmu-vert/10 !bg-surface"
>
<nav
[class]="
'flex flex-col h-full overflow-hidden ' +
(sidebarCollapsed() ? 'gap-1 p-1 pt-4' : 'gap-4 p-4')
"
>
<div class="flex items-center justify-center">
<app-pmu-logo></app-pmu-logo>
</div>
<z-sidebar-group>
@if (!sidebarCollapsed()) {
<z-sidebar-group-label>Menu principal</z-sidebar-group-label>
} @for (item of mainMenuItems; track item.label) {
<button
z-button
zType="ghost"
[class]="
(sidebarCollapsed() ? 'justify-center' : 'justify-start') +
(isActive(item.link || '', item.exact || false)
? ' !bg-primary/10 !text-primary'
: ' hover:bg-accent')
"
[zTooltip]="sidebarCollapsed() ? item.label : ''"
zPosition="right"
(click)="navigate(item?.link)"
>
@if (isEmoji(item.icon)) {
<span class="text-lg">{{ item.icon }}</span>
} @else {
<i [class]="item.icon + (sidebarCollapsed() ? '' : ' mr-2')" aria-hidden="true"></i>
} @if (!sidebarCollapsed()) {
<span>{{ item.label }}</span>
}
</button>
}
</z-sidebar-group>
<z-sidebar-group>
@if (!sidebarCollapsed()) {
<z-sidebar-group-label>Agents & Utilisateurs</z-sidebar-group-label>
} @for (item of workspaceMenuItems; track item.label) { @if (item.submenu) {
<button
z-button
zType="ghost"
z-menu
[zMenuTriggerFor]="submenu"
zPlacement="rightTop"
[class]="
(sidebarCollapsed() ? 'justify-center' : 'justify-start') +
(isAnyActive(item) ? ' !bg-primary/10 !text-primary' : ' hover:bg-accent')
"
[zTooltip]="sidebarCollapsed() ? item.label : null"
zPosition="right"
>
<i [class]="sidebarCollapsed() ? item.icon : item.icon + ' mr-2'"></i>
@if (!sidebarCollapsed()) {
<span class="flex-1 text-left">{{ item.label }}</span>
<i class="icon-chevron-right"></i>
}
</button>
<ng-template #submenu>
<div z-menu-content class="w-48">
@for (subitem of item.submenu; track subitem.label) {
<button
z-menu-item
[class]="isActive(subitem.link || '', subitem.exact || false) ? '!text-primary' : ''"
(click)="navigate(subitem.link)"
>
{{ subitem.label }}
</button>
}
</div>
</ng-template>
} @else {
<button
z-button
zType="ghost"
[class]="
(sidebarCollapsed() ? 'justify-center' : 'justify-start') +
(isActive(item.link || '', item.exact || false)
? ' !bg-primary/10 !text-primary'
: ' hover:bg-accent')
"
[zTooltip]="sidebarCollapsed() ? item.label : ''"
zPosition="right"
(click)="navigate(item?.link)"
>
<i [class]="sidebarCollapsed() ? item.icon : item.icon + ' mr-2'"></i>
@if (!sidebarCollapsed()) {
<span>{{ item.label }}</span>
}
</button>
} }
</z-sidebar-group>
<div class="mt-auto">
<div
z-menu
[zMenuTriggerFor]="userMenu"
zPlacement="rightBottom"
[class]="
'flex items-center justify-center gap-2 cursor-pointer rounded-md hover:bg-accent ' +
(sidebarCollapsed() ? 'p-0 m-2' : 'p-2')
"
>
<z-avatar zSize="sm" [zImage]="avatar" />
@if (!sidebarCollapsed()) {
<div>
<span class="font-medium text-sm truncate">{{ user()?.nom }} {{ user()?.prenom }}</span>
<div class="text-xs">{{ user()?.identifiant }}</div>
</div>
<i class="icon-chevrons-up-down ml-auto"></i>
}
</div>
<ng-template #userMenu>
<div z-menu-content class="w-48">
<button z-menu-item (click)="navigate('/profile')">
<i class="icon-user mr-2"></i>
Profile
</button>
<z-divider zSpacing="sm" />
<button z-menu-item (click)="logout()">
<i class="icon-log-out mr-2"></i>
Déconnexion
</button>
</div>
</ng-template>
</div>
</nav>
</z-sidebar>
<!-- min-h-[200px] is just for the demo purpose to have a minimum height -->
<z-content class="min-h-screen">
<div class="flex items-center">
<button
z-button
zType="ghost"
zSize="sm"
class="-ml-2 dark:text-white text-black"
(click)="toggleSidebar()"
>
<i class="icon-panel-left"></i>
</button>
<z-divider zOrientation="vertical" class="h-4 ml-2" />
<z-breadcrumb>
<z-breadcrumb-list zWrap="wrap" zAlign="start">
<z-breadcrumb-item>
<z-breadcrumb-link zLink="/docs/components/layout">Home</z-breadcrumb-link>
</z-breadcrumb-item>
<z-breadcrumb-separator />
<z-breadcrumb-item>
<z-breadcrumb-link zLink="/docs/components/layout">Components</z-breadcrumb-link>
</z-breadcrumb-item>
</z-breadcrumb-list>
</z-breadcrumb>
<div class="ml-auto flex justify-end items-center">
<app-mode-toggle></app-mode-toggle>
</div>
</div>
<div class="space-y-4 py-4 text-text">
<router-outlet></router-outlet>
</div>
</z-content>
</z-layout>

View File

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

View File

@@ -0,0 +1,119 @@
import { CommonModule } from '@angular/common';
import { Component, signal } from '@angular/core';
import { Router, RouterModule } from '@angular/router';
import { ZardAvatarComponent } from '@shared/components/avatar/avatar.component';
import { ZardBreadcrumbModule } from '@shared/components/breadcrumb/breadcrumb.module';
import { ZardButtonComponent } from '@shared/components/button/button.component';
import { ZardDividerComponent } from '@shared/components/divider/divider.component';
import { LayoutModule } from '@shared/components/layout/layout.module';
import { ZardMenuModule } from '@shared/components/menu/menu.module';
import { ZardTooltipModule } from '@shared/components/tooltip/tooltip';
import { MenuItem } from 'src/app/core/interfaces/menu-item';
import { Theme } from 'src/app/core/services/theme';
import { ModeToggle } from '@shared/components/mode-toggle/mode-toggle';
import { PmuLogo } from '@shared/components/pmu-logo/pmu-logo';
import { User } from 'src/app/core/interfaces/user';
import { Auth } from 'src/app/core/services/auth';
@Component({
selector: 'app-layout',
imports: [
CommonModule,
RouterModule,
LayoutModule,
ZardButtonComponent,
ZardBreadcrumbModule,
ZardMenuModule,
ZardTooltipModule,
ZardDividerComponent,
ZardAvatarComponent,
ModeToggle,
PmuLogo,
],
templateUrl: './layout.html',
styleUrl: './layout.css',
})
export class Layout {
sidebarCollapsed = signal(false);
user = signal<User | null>(null);
constructor(public theme: Theme, public auth: Auth, public router: Router) {
this.user.set(auth.getUser());
}
mainMenuItems: MenuItem[] = [
{ icon: '🏠', label: 'Tableau de bord', link: '/', exact: true },
{ icon: '🏟️', label: 'Hippodromes', link: '/hippodromes' },
{ icon: '📅', label: 'Reunions', link: '/reunions' },
{ icon: '🏇', label: 'Courses', link: '/courses' },
{ icon: 'icon-chart-bar', label: 'Rapport des courses', link: '/rapport-courses' },
];
workspaceMenuItems: MenuItem[] = [
{
icon: 'icon-folder',
label: 'Gestion Agents',
submenu: [
{ icon: 'icon-user-plus', label: 'Gestion Agents', link: '/agents' },
{ icon: 'icon-sliders', label: 'Gestion limites Agents', link: '/limits' },
],
},
{ icon: 'icon-monitor', label: 'Gestion des TPE', link: '/tpes' },
{
icon: 'icon-users',
label: 'Utilisateurs',
submenu: [
{ icon: 'icon-users', label: 'Liste des utilisateurs', link: '/users' },
{ icon: 'icon-shield', label: 'Rôles & Permissions', link: '/roles' },
],
},
];
avatar = {
fallback: 'ZA',
url: '/assets/images/avatar.svg',
alt: 'ZadUI',
};
toggleSidebar() {
this.sidebarCollapsed.update((collapsed) => !collapsed);
}
onCollapsedChange(collapsed: boolean) {
this.sidebarCollapsed.set(collapsed);
}
toggleTheme() {
this.theme.toggle();
}
async logout() {
this.auth.logout();
await this.router.navigateByUrl('/auth/login');
}
navigate(link: string | undefined) {
if (link) {
this.router.navigateByUrl(link);
}
}
isActive(link: string, exact = false): boolean {
const current = this.router.url;
return exact ? current === link : current.startsWith(link);
}
isAnyActive(item: MenuItem): boolean {
if (item.link && this.isActive(item.link, !!item.exact)) return true;
if (item.submenu && item.submenu.length) {
return item.submenu.some((s) => !!s.link && this.isActive(s.link!, !!s.exact));
}
return false;
}
isEmoji(icon: string): boolean {
// simple: emojis are not alphanumeric or typical class names
// Detect if it contains non-ASCII characters
return /[^\u0000-\u00ff]/.test(icon);
}
}

View File

@@ -0,0 +1,405 @@
<div class="flex flex-col gap-2 min-h-screen">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-semibold">Gestion des Agents</h2>
<z-button (click)="openCreate()">Nouvel agent</z-button>
</div>
<app-search-bar (search)="onSearch($event)"></app-search-bar>
<app-data-table [data]="rows()" [columns]="cols" [sort]="sort()" (sortChange)="sort.set($event)">
<ng-template #rowActions let-row>
<div class="flex gap-3">
<button z-button zType="ghost" (click)="openDetail(row)" title="Voir les détails">
<i class="icon-eye"></i>
</button>
<button z-button zType="ghost" (click)="openAssignTpe(row)" title="Assigner un TPE">
<i class="icon-plus"></i>
</button>
<button z-button zType="ghost" (click)="openEdit(row)" title="Modifier">
<i class="icon-pen"></i>
</button>
<button z-button zType="destructive" (click)="remove(row)" title="Supprimer">
<i class="icon-trash"></i>
</button>
</div>
</ng-template>
</app-data-table>
<app-paginator
[total]="total()"
[page]="page()"
[perPage]="perPage()"
(pageChange)="page.set($event)"
(perPageChange)="perPage.set($event)"
></app-paginator>
</div>
<app-modal [open]="modalOpen()" [title]="modalTitle()" (close)="closeModal()" size="xxl">
<app-agent-full-form
[value]="editingItem() ?? undefined"
(save)="onFormSave($event)"
(cancel)="closeModal()"
/>
<div modal-actions class="flex justify-end gap-2">
<z-button zType="destructive" (click)="closeModal()">Annuler</z-button>
<z-button (click)="submitChildForm()">Enregistrer</z-button>
</div>
</app-modal>
<!-- Detail Modal -->
@if (detailItem()) {
<app-modal [open]="detailModalOpen()" [title]="'Détails de l\'agent'" (close)="closeDetailModal()" size="xxl">
@if (detailItem(); as agent) {
<div class="space-y-6">
<!-- Informations Emploi -->
<z-card class="p-4">
<div class="text-lg font-semibold mb-4">Informations Emploi</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<div class="text-xs text-muted-foreground mb-1">Code</div>
<div class="font-medium">{{ agent.code }}</div>
</div>
<div>
<div class="text-xs text-muted-foreground mb-1">Profil</div>
<div class="font-medium">{{ agent.profile }}</div>
</div>
<div>
<div class="text-xs text-muted-foreground mb-1">Statut</div>
<div class="font-medium">
@if (agent.statut === 'ACTIF') {
<span class="inline-flex items-center gap-1 px-2 py-1 rounded bg-green-500/10 text-green-600 dark:text-green-400 text-xs font-medium">
<i class="icon-check"></i> Actif
</span>
} @else if (agent.statut === 'INACTIF') {
<span class="inline-flex items-center gap-1 px-2 py-1 rounded bg-gray-500/10 text-gray-600 dark:text-gray-400 text-xs font-medium">
<i class="icon-x"></i> Inactif
</span>
} @else {
<span class="inline-flex items-center gap-1 px-2 py-1 rounded bg-orange-500/10 text-orange-600 dark:text-orange-400 text-xs font-medium">
<i class="icon-alert-circle"></i> Suspendu
</span>
}
</div>
</div>
@if (agent.principalCode) {
<div>
<div class="text-xs text-muted-foreground mb-1">Agent Principal</div>
<div class="font-medium">{{ agent.principalCode }}</div>
</div>
}
@if (agent.zone) {
<div>
<div class="text-xs text-muted-foreground mb-1">Zone</div>
<div class="font-medium">{{ agent.zone }}</div>
</div>
}
@if (agent.kiosk) {
<div>
<div class="text-xs text-muted-foreground mb-1">Kiosque</div>
<div class="font-medium">{{ agent.kiosk }}</div>
</div>
}
@if (agent.fonction) {
<div>
<div class="text-xs text-muted-foreground mb-1">Fonction</div>
<div class="font-medium">{{ agent.fonction }}</div>
</div>
}
@if (agent.dateEmbauche) {
<div>
<div class="text-xs text-muted-foreground mb-1">Date Embauche</div>
<div class="font-medium">{{ agent.dateEmbauche | date: 'dd/MM/yyyy' }}</div>
</div>
}
</div>
</z-card>
<!-- Informations Personnelles -->
<z-card class="p-4">
<div class="text-lg font-semibold mb-4">Informations Personnelles</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<div class="text-xs text-muted-foreground mb-1">Nom</div>
<div class="font-medium">{{ agent.nom }}</div>
</div>
<div>
<div class="text-xs text-muted-foreground mb-1">Prénom</div>
<div class="font-medium">{{ agent.prenom }}</div>
</div>
@if (agent.autresNoms) {
<div>
<div class="text-xs text-muted-foreground mb-1">Autre(s) Nom(s)</div>
<div class="font-medium">{{ agent.autresNoms }}</div>
</div>
}
@if (agent.dateNaissance) {
<div>
<div class="text-xs text-muted-foreground mb-1">Date de naissance</div>
<div class="font-medium">{{ agent.dateNaissance | date: 'dd/MM/yyyy' }}</div>
</div>
}
@if (agent.lieuNaissance) {
<div>
<div class="text-xs text-muted-foreground mb-1">Lieu de naissance</div>
<div class="font-medium">{{ agent.lieuNaissance }}</div>
</div>
}
@if (agent.ville) {
<div>
<div class="text-xs text-muted-foreground mb-1">Ville</div>
<div class="font-medium">{{ agent.ville }}</div>
</div>
}
@if (agent.adresse) {
<div class="md:col-span-2">
<div class="text-xs text-muted-foreground mb-1">Adresse</div>
<div class="font-medium">{{ agent.adresse }}</div>
</div>
}
@if (agent.phone) {
<div>
<div class="text-xs text-muted-foreground mb-1">Téléphone</div>
<div class="font-medium">{{ agent.phone }}</div>
</div>
}
@if (agent.autoriserAides !== undefined) {
<div>
<div class="text-xs text-muted-foreground mb-1">Autoriser Aides</div>
<div class="font-medium">
@if (agent.autoriserAides) {
<span class="text-green-600 dark:text-green-400">Oui</span>
} @else {
<span class="text-gray-600 dark:text-gray-400">Non</span>
}
</div>
</div>
}
</div>
</z-card>
<!-- Limites et Configuration -->
<z-card class="p-4">
<div class="text-lg font-semibold mb-4">Limites et Configuration</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
@if (agent.limiteInferieure !== undefined) {
<div>
<div class="text-xs text-muted-foreground mb-1">Limite inférieure</div>
<div class="font-medium">{{ agent.limiteInferieure | number: '1.2-2' }}</div>
</div>
}
@if (agent.limiteSuperieure !== undefined) {
<div>
<div class="text-xs text-muted-foreground mb-1">Limite supérieure</div>
<div class="font-medium">{{ agent.limiteSuperieure | number: '1.2-2' }}</div>
</div>
}
@if (agent.limiteParTransaction !== undefined) {
<div>
<div class="text-xs text-muted-foreground mb-1">Limite / transaction</div>
<div class="font-medium">{{ agent.limiteParTransaction | number: '1.2-2' }}</div>
</div>
}
@if (agent.limiteMinAirtime !== undefined) {
<div>
<div class="text-xs text-muted-foreground mb-1">Limite min airtime</div>
<div class="font-medium">{{ agent.limiteMinAirtime | number: '1.2-2' }}</div>
</div>
}
@if (agent.limiteMaxAirtime !== undefined) {
<div>
<div class="text-xs text-muted-foreground mb-1">Limite max airtime</div>
<div class="font-medium">{{ agent.limiteMaxAirtime | number: '1.2-2' }}</div>
</div>
}
@if (agent.maxPeripheriques !== undefined) {
<div>
<div class="text-xs text-muted-foreground mb-1">Nbre max. périphériques</div>
<div class="font-medium">{{ agent.maxPeripheriques }}</div>
</div>
}
</div>
</z-card>
<!-- Informations Légales -->
@if (agent.nationalite || agent.cni || agent.cniDelivreeLe || agent.residence || agent.statutMarital) {
<z-card class="p-4">
<div class="text-lg font-semibold mb-4">Informations Légales</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
@if (agent.nationalite) {
<div>
<div class="text-xs text-muted-foreground mb-1">Nationalité</div>
<div class="font-medium">{{ agent.nationalite }}</div>
</div>
}
@if (agent.cni) {
<div>
<div class="text-xs text-muted-foreground mb-1">N° CNI</div>
<div class="font-medium">{{ agent.cni }}</div>
</div>
}
@if (agent.cniDelivreeLe) {
<div>
<div class="text-xs text-muted-foreground mb-1">CNI Délivrée le</div>
<div class="font-medium">{{ agent.cniDelivreeLe | date: 'dd/MM/yyyy' }}</div>
</div>
}
@if (agent.cniDelivreeA) {
<div>
<div class="text-xs text-muted-foreground mb-1">CNI Délivrée à</div>
<div class="font-medium">{{ agent.cniDelivreeA }}</div>
</div>
}
@if (agent.residence) {
<div>
<div class="text-xs text-muted-foreground mb-1">Résidence</div>
<div class="font-medium">{{ agent.residence }}</div>
</div>
}
@if (agent.statutMarital) {
<div>
<div class="text-xs text-muted-foreground mb-1">Statut marital</div>
<div class="font-medium">{{ agent.statutMarital }}</div>
</div>
}
@if (agent.epoux) {
<div>
<div class="text-xs text-muted-foreground mb-1">Époux/Épouse</div>
<div class="font-medium">{{ agent.epoux }}</div>
</div>
}
</div>
</z-card>
}
<!-- Membres de famille -->
@if (detailFamilyMembers().length > 0) {
<z-card class="p-4">
<div class="text-lg font-semibold mb-4">Membres de famille</div>
<div class="space-y-3">
@for (member of detailFamilyMembers(); track member.id || $index) {
<div class="border rounded-lg p-3 bg-surface/50">
<div class="flex items-start justify-between">
<div class="flex-1 grid grid-cols-1 md:grid-cols-4 gap-3">
<div>
<div class="text-xs text-muted-foreground mb-1">Nom</div>
<div class="font-medium">{{ member.nom }}</div>
</div>
@if (member.statut) {
<div>
<div class="text-xs text-muted-foreground mb-1">Statut</div>
<div class="font-medium">{{ member.statut }}</div>
</div>
}
@if (member.dateNaissance) {
<div>
<div class="text-xs text-muted-foreground mb-1">Date de naissance</div>
<div class="font-medium">{{ member.dateNaissance | date: 'dd/MM/yyyy' }}</div>
</div>
}
@if (member.sexe) {
<div>
<div class="text-xs text-muted-foreground mb-1">Sexe</div>
<div class="font-medium">{{ member.sexe === 'M' ? 'Masculin' : 'Féminin' }}</div>
</div>
}
</div>
</div>
</div>
}
</div>
</z-card>
}
<!-- TPE Assignés -->
@if (getAgentTpes(agent.id).length > 0) {
<z-card class="p-4">
<div class="text-lg font-semibold mb-4">TPE Assignés ({{ getAgentTpes(agent.id).length }})</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
@for (tpe of getAgentTpes(agent.id); track tpe.id) {
<div class="px-3 py-2.5 rounded bg-primary/10 border border-primary/20">
<div class="flex items-start justify-between mb-2">
<div class="font-medium text-sm">{{ tpe.imei }}</div>
@if (tpe.statut) {
<span class="text-xs px-2 py-0.5 rounded bg-surface text-muted-foreground">
{{ formatTpeStatut(tpe.statut) }}
</span>
}
</div>
<div class="space-y-1 text-xs text-muted-foreground">
@if (tpe.marque || tpe.modele) {
<div>
<span class="font-medium">Modèle:</span> {{ tpe.marque }} {{ tpe.modele }}
</div>
}
@if (tpe.serial) {
<div>
<span class="font-medium">Série:</span> {{ tpe.serial }}
</div>
}
@if (tpe.type) {
<div>
<span class="font-medium">Type:</span> {{ tpe.type }}
</div>
}
</div>
</div>
}
</div>
</z-card>
}
</div>
}
<div modal-actions class="flex justify-end gap-2">
<z-button zType="default" (click)="closeDetailModal()">Fermer</z-button>
@if (detailItem()) {
<z-button zType="default" (click)="openEdit(detailItem()!); closeDetailModal()">
<i class="icon-pen mr-2"></i>Modifier
</z-button>
}
</div>
</app-modal>
}
<!-- TPE Assignment Modal -->
@if (assigningAgent()) {
<app-modal
[open]="assignTpeModalOpen()"
[title]="'Assigner un TPE à ' + (assigningAgent()?.nom || '') + ' ' + (assigningAgent()?.prenom || '')"
(close)="closeAssignTpeModal()"
size="md"
>
<div class="space-y-4">
@if (tpesLoading()) {
<div class="text-center py-4">Chargement des TPE disponibles...</div>
} @else if (availableTpes().length === 0) {
<div class="text-center py-4 text-muted-foreground">Aucun TPE disponible</div>
} @else {
<z-form-field>
<label z-form-label>Sélectionner un TPE</label>
<div z-form-control>
<z-select
[zValue]="selectedTpeId()"
(zSelectionChange)="selectedTpeId.set($event)"
[zPlaceholder]="'Sélectionner un TPE...'"
>
@for (tpe of availableTpes(); track tpe.id) {
<z-select-item [zValue]="tpe.id">
{{ tpe.imei }} - {{ tpe.marque }} {{ tpe.modele }}
@if (tpe.statut === 'VALIDE') {
<span class="text-xs text-green-600 dark:text-green-400 ml-2">(Valide)</span>
}
</z-select-item>
}
</z-select>
</div>
</z-form-field>
}
</div>
<div modal-actions class="flex justify-end gap-2">
<z-button zType="destructive" (click)="closeAssignTpeModal()">Annuler</z-button>
<button z-button [disabled]="!selectedTpeId() || tpesLoading()" (click)="confirmAssignTpe()">
Assigner
</button>
</div>
</app-modal>
}

View File

@@ -0,0 +1,483 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
ViewChild,
effect,
signal,
untracked,
} from '@angular/core';
import { DataTable, SortState, TableColumn } from '@shared/components/data-table/data-table';
import { Paginator } from '@shared/components/paginator/paginator';
import { SearchBar } from '@shared/components/search-bar/search-bar';
import { Modal } from '@shared/components/modal/modal';
import { ZardButtonComponent } from '@shared/components/button/button.component';
import { ZardCardComponent } from '@shared/components/card/card.component';
import { ZardSelectComponent } from '@shared/components/select/select.component';
import { ZardSelectItemComponent } from '@shared/components/select/select-item.component';
import { ZardFormModule } from '@shared/components/form/form.module';
import { SortDir } from '@shared/paging/paging';
import { Agent, AgentFamilyMember } from 'src/app/core/interfaces/agent';
import { AgentService } from 'src/app/core/services/agent';
import { AgentFamilyMemberService } from 'src/app/core/services/agent-family-member';
import { TpeService } from 'src/app/core/services/tpe';
import { TpeDevice, TpeStatus } from 'src/app/core/interfaces/tpe';
import { AgentFullForm } from '@shared/forms/agent-full-form/agent-full-form';
import { forkJoin, of } from 'rxjs';
import { switchMap, catchError } from 'rxjs/operators';
@Component({
standalone: true,
selector: 'app-agents',
templateUrl: './agents.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
DataTable,
Paginator,
SearchBar,
Modal,
ZardButtonComponent,
ZardCardComponent,
ZardSelectComponent,
ZardSelectItemComponent,
ZardFormModule,
AgentFullForm,
],
})
export class AgentsPage {
rows = signal<Agent[]>([]);
total = signal(0);
loading = signal(false);
page = signal(1);
perPage = signal(10);
search = signal('');
sort = signal<SortState>({ key: 'code', dir: 'asc' });
modalOpen = signal(false);
modalTitle = signal('Nouvel agent');
editingItem = signal<Agent | null>(null);
detailModalOpen = signal(false);
detailItem = signal<Agent | null>(null);
detailFamilyMembers = signal<AgentFamilyMember[]>([]);
// TPE Assignment modal
assignTpeModalOpen = signal(false);
assigningAgent = signal<Agent | null>(null);
availableTpes = signal<TpeDevice[]>([]);
selectedTpeId = signal<string>('');
tpesLoading = signal(false);
@ViewChild(AgentFullForm) formComp?: AgentFullForm;
formatTpeStatut(statut: TpeStatus): string {
const statutMap: Record<string, string> = {
VALIDE: 'Valide',
INVALIDE: 'Invalide',
EN_PANNE: 'En panne',
BLOQUE: 'Bloqué',
DISPONIBLE: 'Disponible',
AFFECTE: 'Affecté',
EN_MAINTENANCE: 'En maintenance',
HORS_SERVICE: 'Hors service',
VOLE: 'Volé',
};
return statutMap[statut] || statut;
}
cols: TableColumn<Agent>[] = [
{ key: 'code', label: 'Code', sortable: true },
{ key: 'nom', label: 'Nom', sortable: true },
{ key: 'prenom', label: 'Prénom', sortable: true },
{ key: 'phone', label: 'Téléphone', sortable: true },
{
key: 'tpes',
label: 'TPE assignés',
cell: (a) => {
const tpes = this.agentTpesMap.get(a.id) || [];
if (tpes.length === 0) {
return '<span class="text-muted-foreground text-sm">Aucun</span>';
}
// Show up to 2 TPEs with full details, then count for the rest
const displayCount = Math.min(2, tpes.length);
const displayed = tpes.slice(0, displayCount);
const remaining = tpes.length - displayCount;
const tpeCards = displayed
.map((t) => {
const imei = `<div class="font-medium text-xs">${t.imei}</div>`;
const details = [
t.marque && t.modele ? `${t.marque} ${t.modele}` : t.marque || t.modele || '',
t.statut ? this.formatTpeStatut(t.statut) : '',
]
.filter(Boolean)
.join(' • ');
const detailsHtml = details
? `<div class="text-xs text-muted-foreground">${details}</div>`
: '';
return `<div class="px-2 py-1.5 rounded bg-primary/10 border border-primary/20 flex flex-col gap-0.5">${imei}${detailsHtml}</div>`;
})
.join(' ');
const moreHtml =
remaining > 0
? `<div class="text-xs text-muted-foreground px-2 py-1.5">+${remaining} autre${
remaining > 1 ? 's' : ''
}</div>`
: '';
return `<div class="flex flex-col gap-1">${tpeCards}${moreHtml}</div>`;
},
},
{ key: 'zone', label: 'Zone', sortable: true },
{ key: 'kiosk', label: 'Kiosque', sortable: true },
{ key: 'profile', label: 'Profil', sortable: true },
{ key: 'statut', label: 'Statut', sortable: true },
{ key: 'limiteSuperieure', label: 'Limite sup.', sortable: true },
];
tpeMap = new Map<string, TpeDevice>();
agentTpesMap = new Map<string, TpeDevice[]>();
constructor(
private api: AgentService,
private tpeSvc: TpeService,
private familyMemberService: AgentFamilyMemberService
) {
// Preload TPE maps for display
this.tpeSvc
.list({ page: 1, perPage: 200, search: '', sortKey: 'imei', sortDir: 'asc' } as any)
.subscribe((res) => {
const tpes = res.data as TpeDevice[];
this.rebuildTpeMaps(tpes);
});
effect(() => {
const params = {
page: this.page(),
perPage: this.perPage(),
search: this.search(),
sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir,
};
untracked(() => this.fetch(params));
});
}
private fetch(params: {
page: number;
perPage: number;
search: string;
sortKey: string;
sortDir: SortDir;
}) {
this.loading.set(true);
this.api.list(params).subscribe({
next: (res) => {
this.rows.set(res.data);
this.total.set(res.meta.total);
this.loading.set(false);
// Refresh TPE map to ensure we have latest data
this.refreshTpeMap();
},
error: () => {
this.rows.set([]);
this.total.set(0);
this.loading.set(false);
},
});
}
private refreshTpeMap() {
this.tpeSvc
.list({ page: 1, perPage: 200, search: '', sortKey: 'imei', sortDir: 'asc' } as any)
.subscribe((res) => {
const tpes = res.data as TpeDevice[];
this.rebuildTpeMaps(tpes);
});
}
private rebuildTpeMaps(tpes: TpeDevice[]) {
this.tpeMap.clear();
this.agentTpesMap.clear();
tpes.forEach((t) => {
this.tpeMap.set(t.id, t);
const agentId = t.agent?.id;
if (agentId) {
const list = this.agentTpesMap.get(agentId) || [];
list.push(t);
this.agentTpesMap.set(agentId, list);
}
});
}
getAgentTpes(agentId: string): TpeDevice[] {
return this.agentTpesMap.get(agentId) || [];
}
onSearch(q: string) {
this.search.set(q);
this.page.set(1);
}
openCreate() {
this.modalTitle.set('Nouvel agent');
this.editingItem.set(null);
queueMicrotask(() => this.modalOpen.set(true));
}
openEdit(row: Agent) {
this.modalTitle.set("Modifier l'agent");
this.editingItem.set(row);
queueMicrotask(() => this.modalOpen.set(true));
}
closeModal() {
this.modalOpen.set(false);
}
openDetail(row: Agent) {
// Fetch full agent details
this.api.getById(row.id).subscribe({
next: (agent) => {
if (agent) {
this.detailItem.set(agent);
// Load family members separately
this.familyMemberService.getByAgentId(agent.id).subscribe({
next: (members) => {
this.detailFamilyMembers.set(members);
},
error: () => {
this.detailFamilyMembers.set([]);
},
});
this.detailModalOpen.set(true);
}
},
error: () => {
// If fetch fails, use the row data
this.detailItem.set(row);
// Try to load family members anyway
this.familyMemberService.getByAgentId(row.id).subscribe({
next: (members) => {
this.detailFamilyMembers.set(members);
},
error: () => {
this.detailFamilyMembers.set([]);
},
});
this.detailModalOpen.set(true);
},
});
}
closeDetailModal() {
this.detailModalOpen.set(false);
this.detailItem.set(null);
this.detailFamilyMembers.set([]);
}
submitChildForm() {
this.formComp?.onSubmit();
}
onFormSave(payload: Partial<Agent>) {
const current = this.editingItem();
const familyMembersData = this.formComp?.getFamilyMembersData() || [];
// Save agent first
const req$ = current?.id
? this.api.update(current.id, payload)
: this.api.create(payload as Omit<Agent, 'id'>);
req$
.pipe(
switchMap((result) => {
if (!result && current?.id) {
// Update failed
throw new Error("Erreur lors de la sauvegarde de l'agent");
}
const savedAgentId = result?.id || current?.id || '';
if (!savedAgentId) {
throw new Error("Impossible d'obtenir l'ID de l'agent sauvegardé");
}
// Get existing family members for this agent
return this.familyMemberService.getByAgentId(savedAgentId).pipe(
switchMap((existingMembers) => {
const existingIds = new Set(existingMembers.map((m) => m.id));
const newMembers = familyMembersData.filter((fm) => !fm.id);
const updatedMembers = familyMembersData.filter(
(fm) => fm.id && existingIds.has(fm.id)
);
const deletedIds = existingMembers
.filter((em) => !familyMembersData.some((fm) => fm.id === em.id))
.map((em) => em.id);
const operations: any[] = [];
// Delete removed members
deletedIds.forEach((id) => {
operations.push(
this.familyMemberService.delete(id).pipe(
catchError((err) => {
console.error(`Error deleting family member ${id}:`, err);
return of(false);
})
)
);
});
// Create new members
newMembers.forEach((member) => {
operations.push(
this.familyMemberService
.create({
agentId: savedAgentId,
nom: member.nom,
statut: member.statut,
dateNaissance: member.dateNaissance,
sexe: member.sexe as 'M' | 'F' | undefined,
})
.pipe(
catchError((err) => {
console.error('Error creating family member:', err);
return of(null);
})
)
);
});
// Update existing members
updatedMembers.forEach((member) => {
if (member.id) {
operations.push(
this.familyMemberService
.update(member.id, {
nom: member.nom,
statut: member.statut,
dateNaissance: member.dateNaissance,
sexe: member.sexe as 'M' | 'F' | undefined,
})
.pipe(
catchError((err) => {
console.error(`Error updating family member ${member.id}:`, err);
return of(null);
})
)
);
}
});
return operations.length > 0 ? forkJoin(operations) : of([]);
})
);
})
)
.subscribe({
next: () => {
// Reset form after successful save
this.formComp?.resetForm();
// Clear editing item
this.editingItem.set(null);
// Close modal
this.closeModal();
// Refresh data
this.fetch({
page: this.page(),
perPage: this.perPage(),
search: this.search(),
sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir,
});
},
error: (err) => {
console.error('Error saving agent:', err);
alert("Erreur lors de la sauvegarde de l'agent");
},
});
}
remove(row: Agent) {
if (!confirm(`Supprimer l\'agent ${row.code} ?`)) return;
this.api.delete(row.id).subscribe(() =>
this.fetch({
page: this.page(),
perPage: this.perPage(),
search: this.search(),
sortKey: this.sort().key,
sortDir: this.sort().dir as SortDir,
})
);
}
openAssignTpe(agent: Agent) {
this.assigningAgent.set(agent);
this.selectedTpeId.set('');
this.loadAvailableTpes();
this.assignTpeModalOpen.set(true);
}
loadAvailableTpes() {
this.tpesLoading.set(true);
const agent = this.assigningAgent();
if (!agent) {
this.availableTpes.set([]);
this.tpesLoading.set(false);
return;
}
const currentAgentTpes = this.agentTpesMap.get(agent.id) || [];
const agentTpeIds = new Set(currentAgentTpes.map((t) => t.id));
// Load available TPEs (DISPONIBLE or VALIDE status)
forkJoin([this.tpeSvc.getByStatut('DISPONIBLE'), this.tpeSvc.getByStatut('VALIDE')]).subscribe({
next: ([disponibleTpes, valideTpes]) => {
// Combine and filter: only show TPEs that are not assigned to any agent AND not already assigned to this agent
const allTpes = [...disponibleTpes, ...valideTpes];
const available = allTpes.filter(
(t) =>
!t.assigne &&
(t.statut === 'DISPONIBLE' || t.statut === 'VALIDE') &&
!agentTpeIds.has(t.id)
);
// Remove duplicates
const uniqueTpes = Array.from(new Map(available.map((t) => [t.id, t])).values());
this.availableTpes.set(uniqueTpes);
this.tpesLoading.set(false);
},
error: () => {
this.availableTpes.set([]);
this.tpesLoading.set(false);
},
});
}
confirmAssignTpe() {
const agent = this.assigningAgent();
const tpeId = this.selectedTpeId();
if (!agent || !tpeId) {
alert('Veuillez sélectionner un TPE');
return;
}
// Assign TPE to agent
this.tpeSvc.assigner(tpeId, agent.id).subscribe({
next: (tpe) => {
if (tpe) {
// Fermer le modal et recharger complètement la page
this.assignTpeModalOpen.set(false);
this.assigningAgent.set(null);
this.selectedTpeId.set('');
// Rechargement complet pour s'assurer que la liste des agents / TPE est à jour
window.location.reload();
}
},
error: () => {
alert("Erreur lors de l'assignation du TPE");
},
});
}
closeAssignTpeModal() {
this.assignTpeModalOpen.set(false);
this.assigningAgent.set(null);
this.selectedTpeId.set('');
}
}

View File

@@ -0,0 +1,171 @@
<div class="flex flex-col gap-2 min-h-screen">
<!-- Header -->
<div class="flex items-center justify-between">
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Courses</h1>
<button z-button (click)="openCreate()">Nouvelle course</button>
</div>
<!-- Stats -->
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<z-card class="text-center py-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Total des courses</div>
<div class="text-3xl font-bold text-gray-900 dark:text-gray-100 mt-1">
{{ totalCourses() }}
</div>
</z-card>
<z-card class="text-center py-4">
<div class="text-sm text-gray-500 dark:text-gray-400">En cours</div>
<div class="text-3xl font-bold text-amber-600 dark:text-amber-400 mt-1">
{{ runningCourses() }}
</div>
</z-card>
<z-card class="text-center py-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Clôturées</div>
<div class="text-3xl font-bold text-green-600 dark:text-green-400 mt-1">
{{ closedCourses() }}
</div>
</z-card>
<z-card class="text-center py-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Par type</div>
<div class="text-sm mt-2 text-gray-900 dark:text-gray-100 space-y-1">
@for (type of (byType() | keyvalue); track type.key) {
<div class="flex justify-between px-3">
<span>{{ type.key }}</span>
<strong>{{ type.value }}</strong>
</div>
}
</div>
</z-card>
</div>
<!-- Search -->
<app-search-bar
placeholder="Rechercher (nom, type, réunion, hippodrome…)"
(search)="onSearch($event)"
/>
<!-- Table -->
<div class="rounded-2xl overflow-hidden bg-white dark:bg-gray-900/40">
<app-data-table
persistenceKey="pmu.courses.v1"
[columns]="cols"
[data]="rows()"
[loading]="loading()"
[sort]="sort()"
[actionsPosition]="'left'"
[actionsSticky]="true"
[actionsHeader]="'Actions'"
(sortChange)="sort.set($event)"
actionsHeader="Options"
>
<ng-template #rowActions let-row>
@if (!isClosed(row)) {
<div class="flex flex-row gap-4">
<button
class="p-1 rounded text-blue-600 hover:bg-blue-100 dark:text-blue-400 dark:hover:bg-gray-800"
(click)="openEdit(row)"
title="Modifier la course"
>
<lucide-angular name="folder-pen" class="size-4"></lucide-angular>
</button>
<button
class="p-1 rounded text-emerald-600 hover:bg-emerald-100 dark:text-emerald-400 dark:hover:bg-gray-800"
(click)="openResultat(row)"
title="Déclarer le résultat"
>
<lucide-angular name="trophy" class="size-4"></lucide-angular>
</button>
<button
class="p-1 rounded text-amber-600 hover:bg-amber-100 dark:text-amber-400 dark:hover:bg-gray-800"
(click)="openNonPartant(row)"
title="Marquer les non partants"
>
<lucide-angular name="ban" class="size-4"></lucide-angular>
</button>
<button
class="p-1 rounded text-red-600 hover:bg-red-100 dark:text-red-400 dark:hover:bg-gray-800"
(click)="remove(row)"
title="Supprimer la course"
>
<lucide-angular name="trash-2" class="size-4"></lucide-angular>
</button>
</div>
} @else {
<span
class="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs bg-gray-100 text-gray-600 dark:bg-gray-900/30 dark:text-gray-300"
title="Actions désactivées pour une course clôturée"
>
<lucide-angular name="lock" class="size-3.5"></lucide-angular>
Fermée
</span>
}
</ng-template>
</app-data-table>
<app-paginator
[page]="page()"
[perPage]="perPage()"
[total]="total()"
(pageChange)="page.set($event)"
(perPageChange)="perPage.set($event)"
[pageSizes]="pageSize"
/>
</div>
<!-- Modal -->
<app-modal [open]="modalOpen()" [title]="modalTitle()" size="xl" (close)="closeModal()">
@if(modalOpen()) {
<app-course-form
[value]="editingItem() ?? undefined"
(save)="onFormSave($event)"
(cancel)="closeModal()"
></app-course-form>
}
<div modal-actions class="flex gap-2 justify-end">
<z-button zType="destructive" (click)="closeModal()">Annuler</z-button>
<z-button zType="default" (click)="submitChildForm()">Enregistrer</z-button>
</div>
</app-modal>
@if(selectedCourse()) {
<app-modal
[open]="nonPartantModalOpen()"
[title]="'Déclarer un non-partant'"
size="xxl"
(close)="closeNonPartantModal()"
>
<app-nonpartant-form
[course]="selectedCourse()"
(save)="onNonPartantSave($any($event))"
(cancel)="closeNonPartantModal()"
></app-nonpartant-form>
<div modal-actions class="flex justify-end gap-2">
<z-button zType="destructive" (click)="closeNonPartantModal()">Annuler</z-button>
<z-button zType="default" (click)="submitNonPartant()">Enregistrer</z-button>
</div>
</app-modal>
} @if(selectedCourseForResultat()) {
<app-modal
[open]="resultatModalOpen()"
[title]="'Déclarer le résultat'"
size="xl"
(close)="closeResultatModal()"
>
<app-resultat-form
[course]="selectedCourseForResultat()!"
[resultat]="resultatsMap().get(selectedCourseForResultat()!.id)"
(save)="onResultatSave($event)"
(validate)="onResultatValidate()"
(confirm)="onResultatConfirm()"
(cancel)="closeResultatModal()"
/>
<div modal-actions class="flex justify-end gap-2">
<z-button zType="destructive" (click)="closeResultatModal()">Fermer</z-button>
</div>
</app-modal>
}
</div>

View File

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

View File

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

View File

@@ -0,0 +1,132 @@
<div class="min-h-screen flex flex-col gap-2">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Hippodromes</h1>
<button z-button (click)="openCreate()">Nouvel hippodrome</button>
</div>
<!-- Cartes statistiques des hippodromes -->
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<z-card class="text-center py-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Total des hippodromes</div>
<div class="text-3xl font-bold text-gray-900 dark:text-gray-100 mt-1">
{{ total() }}
</div>
</z-card>
<z-card class="text-center py-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Pays représentés</div>
<div class="text-3xl font-bold text-blue-600 dark:text-blue-400 mt-1">
{{ uniqueCountries() }}
</div>
</z-card>
<z-card class="text-center py-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Villes uniques</div>
<div class="text-3xl font-bold text-emerald-600 dark:text-emerald-400 mt-1">
{{ uniqueCities() }}
</div>
</z-card>
<z-card class="text-center py-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Moyenne par pays</div>
<div class="text-3xl font-bold text-amber-600 dark:text-amber-400 mt-1">
{{ averageByCountry() }}
</div>
</z-card>
<z-card class="text-center py-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Réunions totales</div>
<div class="text-3xl font-bold text-purple-600 dark:text-purple-400 mt-1">
{{ totalReunions() }}
</div>
</z-card>
<z-card class="text-center py-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Courses totales</div>
<div class="text-3xl font-bold text-pink-600 dark:text-pink-400 mt-1">
{{ totalCourses() }}
</div>
</z-card>
</div>
<app-search-bar placeholder="Rechercher (nom, ville, pays…)" (search)="onSearch($event)" />
<div class="rounded-2xl overflow-hidden">
<app-data-table
[columns]="cols"
[data]="rows()"
[loading]="loading()"
[sort]="sort()"
(sortChange)="onSort($event)"
>
<!-- Template pour Statut -->
<ng-template #statutTpl let-row>
<span
class="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full"
[class.bg-green-100]="row.actif"
[class.text-green-800]="row.actif"
[class.bg-red-100]="!row.actif"
[class.text-red-800]="!row.actif"
>
<span
class="h-2 w-2 rounded-full"
[class.bg-green-500]="row.actif"
[class.bg-red-500]="!row.actif"
></span>
{{ row.actif ? 'Actif' : 'Inactif' }}
</span>
</ng-template>
<!-- Template pour Date -->
<ng-template #dateTpl let-row>
<span class="text-gray-700 dark:text-gray-300">
{{ row.createdAt | date : 'shortDate' }}
</span>
</ng-template>
<!-- Actions par ligne avec le row injecté -->
<ng-template #rowActions let-row let-i="index">
<div class="flex flex-row gap-2">
<button
class="p-1 rounded text-blue-600 hover:bg-blue-100 dark:text-blue-400 dark:hover:bg-gray-800 cursor-pointer"
(click)="openEdit(row)"
aria-label="Modifier"
title="Modifier"
>
<lucide-angular name="folder-pen" class="size-5"></lucide-angular>
</button>
<button
class="p-1 rounded text-red-600 hover:bg-red-100 dark:text-red-400 dark:hover:bg-gray-800 cursor-pointer"
(click)="remove(row)"
aria-label="Supprimer"
title="Supprimer"
>
<lucide-angular name="trash-2" class="size-5"></lucide-angular>
</button>
</div>
</ng-template>
</app-data-table>
<app-paginator
[page]="page()"
[perPage]="perPage()"
[total]="total()"
(pageChange)="page.set($event)"
(perPageChange)="onPerPage($event)"
[pageSizes]="pageSize"
/>
</div>
<!-- MODALE CRÉATION / ÉDITION -->
<app-modal [open]="modalOpen()" [title]="modalTitle()" size="md" (close)="closeModal()">
<app-hippodrome-form
[value]="editingItem()"
(save)="onFormSave($event)"
(cancel)="closeModal()"
[showInternalActions]="false"
></app-hippodrome-form>
<div modal-actions class="flex gap-2 justify-end">
<z-button zType="destructive" (click)="closeModal()">Annuler</z-button>
<z-button zType="default" (click)="submitChildForm()">Enregistrer</z-button>
</div>
</app-modal>
</div>

View File

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

View File

@@ -0,0 +1,204 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
TemplateRef,
ViewChild,
computed,
effect,
signal,
} from '@angular/core';
import { DataTable, SortState, TableColumn } from '@shared/components/data-table/data-table';
import { Modal } from '@shared/components/modal/modal';
import { Paginator } from '@shared/components/paginator/paginator';
import { SearchBar } from '@shared/components/search-bar/search-bar';
import { HippodromeForm } from '@shared/forms/hippodrome-form/hippodrome-form';
import { Hippodrome as HippodromeType } from 'src/app/core/interfaces/hippodrome';
import { HippodromeService } from 'src/app/core/services/hippodrome';
import { ZardBreadcrumbModule } from '@shared/components/sheet/sheet.module';
import { ZardCardComponent } from '@shared/components/card/card.component';
import { LucideAngularModule } from 'lucide-angular';
@Component({
standalone: true,
selector: 'app-hippodrome-list',
imports: [
CommonModule,
DataTable,
Paginator,
SearchBar,
Modal,
HippodromeForm,
ZardBreadcrumbModule,
ZardCardComponent,
LucideAngularModule,
],
templateUrl: './hippodrome.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Hippodrome {
rows = signal<HippodromeType[]>([]);
loading = signal(false);
total = signal(0);
uniqueCountries = signal(0);
uniqueCities = signal(0);
averageByCountry = signal(0);
totalReunions = signal(0);
totalCourses = signal(0);
page = signal(1);
perPage = signal(10);
pageSize = [10, 20, 50];
search = signal('');
sort = signal<SortState>({ key: 'nom', dir: 'asc' });
@ViewChild(HippodromeForm) formComp?: HippodromeForm;
cols: TableColumn<HippodromeType>[] = [
{ key: 'nom', label: 'Nom', sortable: true },
{ key: 'ville', label: 'Ville', sortable: true },
{ key: 'pays', label: 'Pays', sortable: true },
{
key: 'reunionCount',
label: 'Réunions',
sortable: true,
cell: (h) => (h.reunionCount ?? 0).toString(),
},
{
key: 'courseCount',
label: 'Courses',
sortable: true,
cell: (h) => (h.courseCount ?? 0).toString(),
},
{
key: 'capacite',
label: 'Capacité',
sortable: true,
cell: (h) => (h.capacite ? h.capacite.toLocaleString('fr-FR') : '—'),
},
{
key: 'actif',
label: 'Statut',
sortable: true,
cell: (h) =>
`<span class="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full ${
h.actif
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'
}">
<span class="h-2 w-2 rounded-full ${h.actif ? 'bg-green-500' : 'bg-red-500'}"></span>
${h.actif ? 'Actif' : 'Inactif'}
</span>`,
},
{
key: 'createdAt',
label: 'Créé le',
sortable: true,
cell: (h) =>
new Date(h.createdAt).toLocaleDateString('fr-FR', {
day: '2-digit',
month: 'short',
year: 'numeric',
}),
},
];
// Modale
modalOpen = signal(false);
modalTitle = signal('Nouvel hippodrome');
editingItem = signal<Partial<HippodromeType> | null>(null); // null => création
constructor(private api: HippodromeService) {
effect(() => this.fetch());
}
private fetch() {
this.loading.set(true);
this.api
.list({
page: this.page(),
perPage: this.perPage(),
search: this.search(),
sortKey: this.sort().key,
sortDir: this.sort().dir,
})
.subscribe({
next: (res) => {
this.rows.set(res.data);
const meta = res.meta ?? {};
this.total.set(meta['total'] ?? 0);
this.uniqueCities.set(meta['uniqueCities'] ?? 0);
this.uniqueCountries.set(meta['uniqueCountries'] ?? 0);
this.averageByCountry.set(meta['averageByCountry'] ?? 0);
this.totalReunions.set(meta['totalReunions'] ?? 0);
this.totalCourses.set(meta['totalCourses'] ?? 0);
this.loading.set(false);
},
error: () => {
this.rows.set([]);
this.total.set(0);
this.uniqueCities.set(0);
this.uniqueCountries.set(0);
this.averageByCountry.set(0);
this.totalReunions.set(0);
this.totalCourses.set(0);
this.loading.set(false);
},
});
}
onSearch(q: string) {
this.search.set(q);
this.page.set(1);
}
onSort(s: SortState) {
this.sort.set(s);
this.page.set(1);
}
onPerPage(n: number) {
this.perPage.set(n);
this.page.set(1);
}
openCreate() {
this.modalTitle.set('Nouvel hippodrome');
this.editingItem.set(null);
queueMicrotask(() => this.modalOpen.set(true));
}
openEdit(ev: HippodromeType) {
this.modalTitle.set('Modifier lhippodrome');
this.editingItem.set(ev);
queueMicrotask(() => this.modalOpen.set(true));
}
closeModal() {
this.modalOpen.set(false);
}
submitChildForm() {
// Déclenche le submit du formulaire enfant
this.formComp?.onSubmit();
}
onFormSave(payload: Partial<HippodromeType>) {
const current = this.editingItem();
const req$ = current?.id
? this.api.update(current.id, payload)
: this.api.create(payload as Omit<HippodromeType, 'id'>);
req$.subscribe(() => {
this.closeModal();
// Reset editing item to null to clear the form
this.editingItem.set(null);
this.fetch();
});
}
remove(ev: HippodromeType) {
if (!confirm(`Supprimer lhippodrome « ${ev.nom} » ?`)) return;
this.api.delete(ev.id).subscribe(() => this.fetch());
}
}

View File

@@ -0,0 +1,63 @@
<div class="flex flex-col gap-2 min-h-screen">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-semibold">Gestion des Limites</h2>
<z-button (click)="openCreate()">Nouvelle limite</z-button>
</div>
<!-- Actif Filter Chips -->
<div class="flex flex-wrap gap-2 items-center">
<span class="text-sm font-medium">Filtrer par statut:</span>
<button
z-button
zType="ghost"
zSize="sm"
[class]="selectedActif() === null ? '!bg-primary/10 !text-primary' : ''"
(click)="onActifFilter(null)"
>
Tous
</button>
<button
z-button
zType="ghost"
zSize="sm"
[class]="selectedActif() === true ? '!bg-green-500/10 !text-green-600 dark:!text-green-400' : ''"
(click)="onActifFilter(true)"
>
<i class="icon-check"></i>
Actives
</button>
<button
z-button
zType="ghost"
zSize="sm"
[class]="selectedActif() === false ? '!bg-gray-500/10 !text-gray-600 dark:!text-gray-400' : ''"
(click)="onActifFilter(false)"
>
<i class="icon-x"></i>
Inactives
</button>
</div>
<app-search-bar (search)="onSearch($event)"></app-search-bar>
<app-data-table [data]="rows()" [columns]="cols" [sort]="sort()" (sortChange)="sort.set($event)">
<ng-template #rowActions let-row>
<div class="flex gap-3">
<button z-button zType="ghost" (click)="openEdit(row)"><i class="icon-pen"></i></button>
<button z-button zType="destructive" (click)="remove(row)"><i class="icon-trash"></i></button>
</div>
</ng-template>
</app-data-table>
<app-paginator [total]="total()" [page]="page()" [perPage]="perPage()" (pageChange)="page.set($event)" (perPageChange)="perPage.set($event)"></app-paginator>
</div>
<app-modal [open]="modalOpen()" [title]="modalTitle()" (close)="closeModal()" size="xl">
<app-limit-form [value]="editingItem() ?? undefined" (save)="onFormSave($event)" (cancel)="closeModal()" />
<div modal-actions class="flex justify-end gap-2">
<z-button zType="destructive" (click)="closeModal()">Annuler</z-button>
<z-button (click)="submitChildForm()">Enregistrer</z-button>
</div>
</app-modal>

Some files were not shown because too many files have changed in this diff Show More