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