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