commit dde2e8aebf07b910ef0a20484fad941ca52b36a2 Author: OnlyPapy98 Date: Tue Dec 16 14:20:02 2025 +0100 first commit diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f166060 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3a1257e --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.postcssrc.json b/.postcssrc.json new file mode 100644 index 0000000..e092dc7 --- /dev/null +++ b/.postcssrc.json @@ -0,0 +1,5 @@ +{ + "plugins": { + "@tailwindcss/postcss": {} + } +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..77b3745 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,4 @@ +{ + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846 + "recommendations": ["angular.ng-template"] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..925af83 --- /dev/null +++ b/.vscode/launch.json @@ -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" + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..a298b5b --- /dev/null +++ b/.vscode/tasks.json @@ -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" + } + } + } + } + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..0ae8115 --- /dev/null +++ b/README.md @@ -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. diff --git a/angular.json b/angular.json new file mode 100644 index 0000000..5f885f4 --- /dev/null +++ b/angular.json @@ -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 + } +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..03e8f9d --- /dev/null +++ b/components.json @@ -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" + } +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..72d497f --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/public/assets/images/avatar.svg b/public/assets/images/avatar.svg new file mode 100644 index 0000000..a2eaff1 --- /dev/null +++ b/public/assets/images/avatar.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/public/assets/logos/pmu_logo.png b/public/assets/logos/pmu_logo.png new file mode 100644 index 0000000..f331211 Binary files /dev/null and b/public/assets/logos/pmu_logo.png differ diff --git a/public/assets/logos/pmu_logo_dark.png b/public/assets/logos/pmu_logo_dark.png new file mode 100644 index 0000000..7065e36 Binary files /dev/null and b/public/assets/logos/pmu_logo_dark.png differ diff --git a/public/assets/logos/pmu_logo_light.png b/public/assets/logos/pmu_logo_light.png new file mode 100644 index 0000000..19fb33c Binary files /dev/null and b/public/assets/logos/pmu_logo_light.png differ diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..065d676 Binary files /dev/null and b/public/favicon.ico differ diff --git a/scripts/init-permissions.js b/scripts/init-permissions.js new file mode 100644 index 0000000..7b08ba5 --- /dev/null +++ b/scripts/init-permissions.js @@ -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); +}); diff --git a/scripts/init-permissions.ts b/scripts/init-permissions.ts new file mode 100644 index 0000000..32c8d2d --- /dev/null +++ b/scripts/init-permissions.ts @@ -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> { + 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); +}); + diff --git a/src/app/app.config.ts b/src/app/app.config.ts new file mode 100644 index 0000000..505fc7f --- /dev/null +++ b/src/app/app.config.ts @@ -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' }, + ], +}; diff --git a/src/app/app.css b/src/app/app.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/app.html b/src/app/app.html new file mode 100644 index 0000000..bde60e1 --- /dev/null +++ b/src/app/app.html @@ -0,0 +1,2 @@ + + diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts new file mode 100644 index 0000000..10ac0cb --- /dev/null +++ b/src/app/app.routes.ts @@ -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' }, +]; diff --git a/src/app/app.spec.ts b/src/app/app.spec.ts new file mode 100644 index 0000000..88aab59 --- /dev/null +++ b/src/app/app.spec.ts @@ -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'); + }); +}); diff --git a/src/app/app.ts b/src/app/app.ts new file mode 100644 index 0000000..32e77e0 --- /dev/null +++ b/src/app/app.ts @@ -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'); +} diff --git a/src/app/auth/auth-layout/auth-layout.css b/src/app/auth/auth-layout/auth-layout.css new file mode 100644 index 0000000..92d692c --- /dev/null +++ b/src/app/auth/auth-layout/auth-layout.css @@ -0,0 +1,3 @@ +:host { + display: contents; +} diff --git a/src/app/auth/auth-layout/auth-layout.html b/src/app/auth/auth-layout/auth-layout.html new file mode 100644 index 0000000..39ee889 --- /dev/null +++ b/src/app/auth/auth-layout/auth-layout.html @@ -0,0 +1,74 @@ +
+
+
+
+ +
+
+ +
+ + +
+
+
+
+
+ + +
+ + + + +
+
+ +
+
+
+
diff --git a/src/app/auth/auth-layout/auth-layout.spec.ts b/src/app/auth/auth-layout/auth-layout.spec.ts new file mode 100644 index 0000000..22b7c30 --- /dev/null +++ b/src/app/auth/auth-layout/auth-layout.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AuthLayout } from './auth-layout'; + +describe('AuthLayout', () => { + let component: AuthLayout; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AuthLayout] + }) + .compileComponents(); + + fixture = TestBed.createComponent(AuthLayout); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/auth/auth-layout/auth-layout.ts b/src/app/auth/auth-layout/auth-layout.ts new file mode 100644 index 0000000..b69ec31 --- /dev/null +++ b/src/app/auth/auth-layout/auth-layout.ts @@ -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(); + } +} diff --git a/src/app/auth/auth-module.ts b/src/app/auth/auth-module.ts new file mode 100644 index 0000000..109797c --- /dev/null +++ b/src/app/auth/auth-module.ts @@ -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 {} diff --git a/src/app/auth/auth-routing-module.ts b/src/app/auth/auth-routing-module.ts new file mode 100644 index 0000000..173e337 --- /dev/null +++ b/src/app/auth/auth-routing-module.ts @@ -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 {} diff --git a/src/app/auth/pages/login/login.css b/src/app/auth/pages/login/login.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/auth/pages/login/login.html b/src/app/auth/pages/login/login.html new file mode 100644 index 0000000..e3ca801 --- /dev/null +++ b/src/app/auth/pages/login/login.html @@ -0,0 +1,84 @@ +
+
+ +

Connexion

+
+ +

Accédez à votre espace PMU MALI

+ +
+ +
+ +
+ +
+ @if (form.controls['identifiant'].touched && form.controls['identifiant'].invalid) { +
Identifiant requis
+ } +
+ + +
+ +
+ + +
+ @if (form.controls['password'].touched && form.controls['password'].invalid) { +
8 caractères minimum
+ } +
+ + + +
+ +
Plateforme de Jeux de la PMU.
+
diff --git a/src/app/auth/pages/login/login.spec.ts b/src/app/auth/pages/login/login.spec.ts new file mode 100644 index 0000000..dd8bbb3 --- /dev/null +++ b/src/app/auth/pages/login/login.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { Login } from './login'; + +describe('Login', () => { + let component: Login; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [Login] + }) + .compileComponents(); + + fixture = TestBed.createComponent(Login); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/auth/pages/login/login.ts b/src/app/auth/pages/login/login.ts new file mode 100644 index 0000000..20f500d --- /dev/null +++ b/src/app/auth/pages/login/login.ts @@ -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); + } + } +} diff --git a/src/app/core/core-module.ts b/src/app/core/core-module.ts new file mode 100644 index 0000000..af5e615 --- /dev/null +++ b/src/app/core/core-module.ts @@ -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 {} diff --git a/src/app/core/guards/auth-guard.spec.ts b/src/app/core/guards/auth-guard.spec.ts new file mode 100644 index 0000000..0ffeaf0 --- /dev/null +++ b/src/app/core/guards/auth-guard.spec.ts @@ -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(); + }); +}); diff --git a/src/app/core/guards/auth-guard.ts b/src/app/core/guards/auth-guard.ts new file mode 100644 index 0000000..c4de43e --- /dev/null +++ b/src/app/core/guards/auth-guard.ts @@ -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'); +}; diff --git a/src/app/core/guards/role-guard.ts b/src/app/core/guards/role-guard.ts new file mode 100644 index 0000000..b501e62 --- /dev/null +++ b/src/app/core/guards/role-guard.ts @@ -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 +}; + + diff --git a/src/app/core/interceptors/api-prefix-interceptor.ts b/src/app/core/interceptors/api-prefix-interceptor.ts new file mode 100644 index 0000000..73bf7a5 --- /dev/null +++ b/src/app/core/interceptors/api-prefix-interceptor.ts @@ -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, next: HttpHandler): Observable> { + 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); + } +} diff --git a/src/app/core/interceptors/auth-token-interceptor.spec.ts b/src/app/core/interceptors/auth-token-interceptor.spec.ts new file mode 100644 index 0000000..7ed4c16 --- /dev/null +++ b/src/app/core/interceptors/auth-token-interceptor.spec.ts @@ -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(); + }); +}); diff --git a/src/app/core/interceptors/auth-token-interceptor.ts b/src/app/core/interceptors/auth-token-interceptor.ts new file mode 100644 index 0000000..124fb3d --- /dev/null +++ b/src/app/core/interceptors/auth-token-interceptor.ts @@ -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, next: HttpHandler): Observable> { + const token = this.auth.getToken(); + if (!token) return next.handle(req); + return next.handle(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })); + } +} diff --git a/src/app/core/interceptors/http-error-interceptor.spec.ts b/src/app/core/interceptors/http-error-interceptor.spec.ts new file mode 100644 index 0000000..a5f46f0 --- /dev/null +++ b/src/app/core/interceptors/http-error-interceptor.spec.ts @@ -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(); + }); +}); diff --git a/src/app/core/interceptors/http-error-interceptor.ts b/src/app/core/interceptors/http-error-interceptor.ts new file mode 100644 index 0000000..3ddcc8f --- /dev/null +++ b/src/app/core/interceptors/http-error-interceptor.ts @@ -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, next: HttpHandler): Observable> { + 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); + }) + ); + } +} diff --git a/src/app/core/interfaces/agent-limit.ts b/src/app/core/interfaces/agent-limit.ts new file mode 100644 index 0000000..85fb26b --- /dev/null +++ b/src/app/core/interfaces/agent-limit.ts @@ -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; +} + + diff --git a/src/app/core/interfaces/agent.ts b/src/app/core/interfaces/agent.ts new file mode 100644 index 0000000..8cb2457 --- /dev/null +++ b/src/app/core/interfaces/agent.ts @@ -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'; +} diff --git a/src/app/core/interfaces/course.ts b/src/app/core/interfaces/course.ts new file mode 100644 index 0000000..33fbfd6 --- /dev/null +++ b/src/app/core/interfaces/course.ts @@ -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; +} diff --git a/src/app/core/interfaces/hippodrome.ts b/src/app/core/interfaces/hippodrome.ts new file mode 100644 index 0000000..74dec39 --- /dev/null +++ b/src/app/core/interfaces/hippodrome.ts @@ -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; +} diff --git a/src/app/core/interfaces/menu-item.ts b/src/app/core/interfaces/menu-item.ts new file mode 100644 index 0000000..0e2dbef --- /dev/null +++ b/src/app/core/interfaces/menu-item.ts @@ -0,0 +1,7 @@ +export interface MenuItem { + icon: string; + label: string; + exact?: boolean; + link?: string; + submenu?: MenuItem[]; +} diff --git a/src/app/core/interfaces/report.ts b/src/app/core/interfaces/report.ts new file mode 100644 index 0000000..65bbaac --- /dev/null +++ b/src/app/core/interfaces/report.ts @@ -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[]; +} diff --git a/src/app/core/interfaces/resultat.ts b/src/app/core/interfaces/resultat.ts new file mode 100644 index 0000000..0baa814 --- /dev/null +++ b/src/app/core/interfaces/resultat.ts @@ -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; +} diff --git a/src/app/core/interfaces/reunion.ts b/src/app/core/interfaces/reunion.ts new file mode 100644 index 0000000..7c7ec6d --- /dev/null +++ b/src/app/core/interfaces/reunion.ts @@ -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; +} diff --git a/src/app/core/interfaces/role.ts b/src/app/core/interfaces/role.ts new file mode 100644 index 0000000..f8464b6 --- /dev/null +++ b/src/app/core/interfaces/role.ts @@ -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; +} diff --git a/src/app/core/interfaces/tpe.ts b/src/app/core/interfaces/tpe.ts new file mode 100644 index 0000000..b2a5bec --- /dev/null +++ b/src/app/core/interfaces/tpe.ts @@ -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; +} diff --git a/src/app/core/interfaces/user.ts b/src/app/core/interfaces/user.ts new file mode 100644 index 0000000..0506382 --- /dev/null +++ b/src/app/core/interfaces/user.ts @@ -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; +} diff --git a/src/app/core/mocks/agent-limit.mocks.ts b/src/app/core/mocks/agent-limit.mocks.ts new file mode 100644 index 0000000..638bc7c --- /dev/null +++ b/src/app/core/mocks/agent-limit.mocks.ts @@ -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', + }, +]; + + diff --git a/src/app/core/mocks/agent.mocks.ts b/src/app/core/mocks/agent.mocks.ts new file mode 100644 index 0000000..f9eb7e5 --- /dev/null +++ b/src/app/core/mocks/agent.mocks.ts @@ -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)), +// ]; diff --git a/src/app/core/mocks/course.mocks.ts b/src/app/core/mocks/course.mocks.ts new file mode 100644 index 0000000..8bb403f --- /dev/null +++ b/src/app/core/mocks/course.mocks.ts @@ -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(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(); + 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(); + +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; diff --git a/src/app/core/mocks/hippodrome.mocks.ts b/src/app/core/mocks/hippodrome.mocks.ts new file mode 100644 index 0000000..7ff691e --- /dev/null +++ b/src/app/core/mocks/hippodrome.mocks.ts @@ -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(), + }, +]; diff --git a/src/app/core/mocks/report.mocks.ts b/src/app/core/mocks/report.mocks.ts new file mode 100644 index 0000000..43b132c --- /dev/null +++ b/src/app/core/mocks/report.mocks.ts @@ -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(); +for (const c of COURSES_MOCK.filter((c) => c.statut === 'CLOSED').slice(0, 300)) { + REPORT_DETAILS_MOCK.set(c.id, payoutRowsForCourse(c)); +} diff --git a/src/app/core/mocks/reunion.mocks.ts b/src/app/core/mocks/reunion.mocks.ts new file mode 100644 index 0000000..2ecb49b --- /dev/null +++ b/src/app/core/mocks/reunion.mocks.ts @@ -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; + }); +}); diff --git a/src/app/core/mocks/role.mocks.ts b/src/app/core/mocks/role.mocks.ts new file mode 100644 index 0000000..b649b62 --- /dev/null +++ b/src/app/core/mocks/role.mocks.ts @@ -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(), + }, +]; diff --git a/src/app/core/mocks/tpe.mocks.ts b/src/app/core/mocks/tpe.mocks.ts new file mode 100644 index 0000000..d44f2be --- /dev/null +++ b/src/app/core/mocks/tpe.mocks.ts @@ -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) +// ), +// ]; diff --git a/src/app/core/mocks/user.mocks.ts b/src/app/core/mocks/user.mocks.ts new file mode 100644 index 0000000..da1366f --- /dev/null +++ b/src/app/core/mocks/user.mocks.ts @@ -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) + ), +]; diff --git a/src/app/core/services/agent-family-member.ts b/src/app/core/services/agent-family-member.ts new file mode 100644 index 0000000..819c448 --- /dev/null +++ b/src/app/core/services/agent-family-member.ts @@ -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 { + 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): 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 { + if (USE_SERVER) { + return this.http + .get(`${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 { + if (USE_SERVER) { + return this.http + .get(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): Observable { + if (USE_SERVER) { + const apiPayload = this.transformToApiPayload(payload); + return this.http + .post(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): Observable { + if (USE_SERVER) { + const apiPayload = this.transformToApiPayload(payload); + return this.http + .put(`${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 { + if (USE_SERVER) { + return this.http + .delete(`${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 { + if (USE_SERVER) { + return this.http + .get(`${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 { + if (USE_SERVER) { + return this.http + .get(`${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 { + if (USE_SERVER) { + return this.http + .get(`${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 { + if (USE_SERVER) { + return this.http + .get(`${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 { + 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([]); + } +} + diff --git a/src/app/core/services/agent-limit.ts b/src/app/core/services/agent-limit.ts new file mode 100644 index 0000000..15d02c1 --- /dev/null +++ b/src/app/core/services/agent-limit.ts @@ -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 { + 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): 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 { + if (USE_SERVER) { + return this.http + .get(`${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> { + 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(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( + { data: limits, meta: { total: limits.length } }, + params.page || 1, + params.perPage || 10 + ); + } + // Otherwise return all as single page + return normalizePage( + { data: limits, meta: { total: limits.length } }, + 1, + limits.length + ); + }), + catchError((err) => { + console.error('Error fetching agent limits:', err); + return of(normalizePage({ data: [], meta: { total: 0 } }, 1, 10)); + }) + ); + } + return of(normalizePage({ data: [], meta: { total: 0 } }, 1, 10)); + } + + // POST /api/v1/agent-limits - Create + create(payload: Omit): Observable { + if (USE_SERVER) { + const apiPayload = this.transformToApiPayload(payload); + return this.http + .post(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): Observable { + 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(`${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 { + // 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[] = []; + + // 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 = { + 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 { + if (USE_SERVER) { + return this.http + .delete(`${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 { + if (USE_SERVER) { + const searchTerm = encodeURIComponent(query.trim()); + return this.http + .get(`${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 { + if (USE_SERVER) { + if (actif) { + return this.http + .get(`${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(`${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([]); + } +} diff --git a/src/app/core/services/agent.ts b/src/app/core/services/agent.ts new file mode 100644 index 0000000..78dbac5 --- /dev/null +++ b/src/app/core/services/agent.ts @@ -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 { + 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): 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 { + if (USE_SERVER) { + return this.http + .get(`${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> { + 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(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( + { data: agents, meta: { total: agents.length } }, + params.page || 1, + params.perPage || 10 + ); + } + // Otherwise return all as single page + return normalizePage( + { data: agents, meta: { total: agents.length } }, + 1, + agents.length + ); + }), + catchError((err) => { + console.error('Error fetching agents:', err); + return of(normalizePage({ data: [], meta: { total: 0 } }, 1, 10)); + }) + ); + } + return of(normalizePage({ data: [], meta: { total: 0 } }, 1, 10)); + } + + // POST /api/v1/agents - Create + create(payload: Omit): Observable { + if (USE_SERVER) { + const apiPayload = this.transformToApiPayload(payload); + return this.http + .post(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): Observable { + if (USE_SERVER) { + const apiPayload = this.transformToApiPayload(payload); + return this.http + .put(`${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 { + if (USE_SERVER) { + return this.http + .delete(`${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 { + if (USE_SERVER) { + return this.http + .get(`${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 { + if (USE_SERVER) { + return this.http + .get(`${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 { + if (USE_SERVER) { + return this.http + .get(`${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 { + if (USE_SERVER) { + return this.http + .get(`${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 { + 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); + } +} diff --git a/src/app/core/services/auth.spec.ts b/src/app/core/services/auth.spec.ts new file mode 100644 index 0000000..3a04d76 --- /dev/null +++ b/src/app/core/services/auth.spec.ts @@ -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(); + }); +}); diff --git a/src/app/core/services/auth.ts b/src/app/core/services/auth.ts new file mode 100644 index 0000000..ced41db --- /dev/null +++ b/src/app/core/services/auth.ts @@ -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(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; + } +} diff --git a/src/app/core/services/course-sample.spec.ts b/src/app/core/services/course-sample.spec.ts new file mode 100644 index 0000000..a7963e8 --- /dev/null +++ b/src/app/core/services/course-sample.spec.ts @@ -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(); + }); +}); diff --git a/src/app/core/services/course-sample.ts b/src/app/core/services/course-sample.ts new file mode 100644 index 0000000..20f487d --- /dev/null +++ b/src/app/core/services/course-sample.ts @@ -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> { + const cfg: BackendConfig = { + zeroBasedPageIndex: true, + buildSort: (key, dir) => (key && dir ? ['sort', `${key},${dir}`] : null), + mapClientSortKey: (k) => { + const map: Record = { + depart_at: 'departAt', + type_course: 'type', + numero: 'numero', + nom: 'nom', + statut: 'statut', + }; + return k ? map[k] ?? k : undefined; + }, + }; + return this.http.fetch(this.base, params, cfg); + } +} diff --git a/src/app/core/services/course.spec.ts b/src/app/core/services/course.spec.ts new file mode 100644 index 0000000..23e24d2 --- /dev/null +++ b/src/app/core/services/course.spec.ts @@ -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(); + }); +}); diff --git a/src/app/core/services/course.ts b/src/app/core/services/course.ts new file mode 100644 index 0000000..bb069e6 --- /dev/null +++ b/src/app/core/services/course.ts @@ -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 { + 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> { + 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>((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( + { + data: pageData, + meta: { + total, + totalRunning, + totalClosed, + totalByType, + }, + }, + params.page, + params.perPage + ); + }), + catchError((err) => { + console.error('Error searching courses:', err); + return of( + normalizePage( + { + data: [], + meta: { + total: 0, + totalRunning: 0, + totalClosed: 0, + totalByType: {}, + }, + }, + params.page, + params.perPage + ) + ); + }) + ); + } + + if (usePaginationEndpoint) { + return this.paginatedHttp + .fetch(this.apiUrl, params, { + zeroBasedPageIndex: false, + }) + .pipe( + switchMap((pagedResult) => { + // Handle empty data case + if (!pagedResult.data || pagedResult.data.length === 0) { + return of( + normalizePage( + { + 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( + { + 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(undefined))) + ); + + return forkJoin(reunionRequests).pipe( + map((reunions) => { + // Create a map of reunionId -> Reunion + const reunionMap = new Map(); + 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>((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( + { + 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( + { + 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(this.apiUrl, { headers: this.getNgrokHeaders() }) + .pipe( + switchMap((apiData) => { + // Handle empty data case + if (!apiData || apiData.length === 0) { + return of( + normalizePage( + { + 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( + { + 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(undefined))) + ); + + return forkJoin(reunionRequests).pipe( + map((reunions) => { + // Create a map of reunionId -> Reunion + const reunionMap = new Map(); + 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>((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( + { + data: pageData, + meta: { + total, + totalRunning, + totalClosed, + totalByType, + }, + }, + params.page, + params.perPage + ); + }) + ); + }), + catchError((err) => { + console.error('Error fetching courses:', err); + return of( + normalizePage( + { + data: [], + meta: { + total: 0, + totalRunning: 0, + totalClosed: 0, + totalByType: {}, + }, + }, + params.page, + params.perPage + ) + ); + }) + ); + } + } + + // If USE_SERVER is false, return empty result + return of( + normalizePage( + { + 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 { + if (USE_SERVER) { + return this.http + .get(`${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 { + if (USE_SERVER) { + return this.http + .get(`${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 { + if (USE_SERVER) { + return this.http + .get(`${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(undefined))) + ); + + return forkJoin(reunionRequests).pipe( + map((reunions) => { + // Create a map of reunionId -> Reunion + const reunionMap = new Map(); + 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): Observable { + 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(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): Observable { + 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(`${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 { + if (USE_SERVER) { + return this.http + .patch( + `${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 { + if (USE_SERVER) { + return this.http + .delete(`${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 { + 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'); + } +} diff --git a/src/app/core/services/hippodrome.spec.ts b/src/app/core/services/hippodrome.spec.ts new file mode 100644 index 0000000..8aff47e --- /dev/null +++ b/src/app/core/services/hippodrome.spec.ts @@ -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(); + }); +}); diff --git a/src/app/core/services/hippodrome.ts b/src/app/core/services/hippodrome.ts new file mode 100644 index 0000000..ffd3308 --- /dev/null +++ b/src/app/core/services/hippodrome.ts @@ -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([]); + + constructor(private http: HttpClient, private paginatedHttp: PaginatedHttpService) {} + + // Helper method to get ngrok bypass headers + private getNgrokHeaders(): Record { + 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> { + 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(`${environment.apiBaseUrl}/api/v1/reunions`, { + headers: this.getNgrokHeaders(), + }) + .pipe( + catchError(() => of([])), + map((data) => ({ data: data || [], meta: { total: (data || []).length } })) + ), + courses: this.http + .get(`${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(); + 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(); + 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(); + 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( + { + data: pageData, + meta: { + total, + uniqueCountries, + uniqueCities, + averageByCountry, + totalReunions, + totalCourses, + }, + }, + params.page, + params.perPage + ); + }) + ); + }), + catchError((err) => { + console.error('Error searching hippodromes:', err); + return of( + normalizePage( + { + data: [], + meta: { + total: 0, + uniqueCountries: 0, + uniqueCities: 0, + averageByCountry: 0, + totalReunions: 0, + totalCourses: 0, + }, + }, + params.page, + params.perPage + ) + ); + }) + ); + } + + if (usePaginationEndpoint) { + return this.paginatedHttp + .fetch(this.apiUrl, params, { + zeroBasedPageIndex: false, + buildSort: (key, dir) => (key && dir ? ['sort', `${key},${dir}`] : null), + mapClientSortKey: (k) => { + const alias: Record = { + name: 'nom', + city: 'ville', + country: 'pays', + }; + return k ? alias[k] ?? k : undefined; + }, + }) + .pipe( + catchError((err) => { + console.error('Error fetching hippodromes:', err); + return of( + normalizePage( + { + 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(`${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(`${environment.apiBaseUrl}/api/v1/reunions`, { + headers: this.getNgrokHeaders(), + }) + .pipe( + catchError(() => of([])), + map((data) => ({ data, meta: { total: data.length } })) + ), + courses: this.http + .get(`${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(); + 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(); + 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(); + 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( + { + data: pageData, + meta: { + total, + uniqueCountries, + uniqueCities, + averageByCountry, + totalReunions, + totalCourses, + }, + }, + params.page, + params.perPage + ); + }) + ); + }), + catchError((err) => { + console.error('Error fetching hippodromes:', err); + return of( + normalizePage( + { + 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( + { + 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 { + if (USE_SERVER) { + return this.http + .get(`${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): Observable { + if (USE_SERVER) { + return this.http + .post(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): Observable { + if (USE_SERVER) { + return this.http + .put(`${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 { + if (USE_SERVER) { + return this.http + .delete(`${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 { + if (USE_SERVER) { + return this.http + .get(`${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 { + if (USE_SERVER) { + return this.http + .get(`${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 { + if (USE_SERVER) { + return this.http + .get(`${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)); + } +} diff --git a/src/app/core/services/non-partant.ts b/src/app/core/services/non-partant.ts new file mode 100644 index 0000000..d2773c1 --- /dev/null +++ b/src/app/core/services/non-partant.ts @@ -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 { + 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 { + if (USE_SERVER) { + const courseApiUrl = environment.apiBaseUrl + '/api/v1/courses'; + return this.http + .put(`${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([]); + } +} diff --git a/src/app/core/services/report.ts b/src/app/core/services/report.ts new file mode 100644 index 0000000..5f4fbd5 --- /dev/null +++ b/src/app/core/services/report.ts @@ -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([...REPORT_SUMMARIES_MOCK]); + + list(params: ListParams): Observable> { + 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({ data: pageData, meta: { total: data.length } }, params.page, params.perPage)); + } + + getDetail(courseId: string): Observable { + 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 { + 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 { + 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 { + 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 { + REPORT_DETAILS_MOCK.set(courseId, rows); + return of(true); + } +} + + diff --git a/src/app/core/services/resultat.ts b/src/app/core/services/resultat.ts new file mode 100644 index 0000000..848c2ef --- /dev/null +++ b/src/app/core/services/resultat.ts @@ -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 { + 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 { + if (USE_SERVER) { + return this.http + .get(`${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 { + if (USE_SERVER) { + return this.http + .get(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(undefined))) + ); + + return forkJoin(courseRequests).pipe( + map((courses) => { + const courseMap = new Map(); + 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 { + if (!USE_SERVER) { + return of(undefined); + } + + return this.http + .get(`${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(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 { + if (USE_SERVER) { + return this.http + .post(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): Observable { + if (USE_SERVER) { + return this.http + .put(`${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 { + if (USE_SERVER) { + return this.http + .delete(`${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 { + if (USE_SERVER) { + return this.http + .delete(`${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, + }; + } +} diff --git a/src/app/core/services/reunion.spec.ts b/src/app/core/services/reunion.spec.ts new file mode 100644 index 0000000..a7aa150 --- /dev/null +++ b/src/app/core/services/reunion.spec.ts @@ -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(); + }); +}); diff --git a/src/app/core/services/reunion.ts b/src/app/core/services/reunion.ts new file mode 100644 index 0000000..17e551e --- /dev/null +++ b/src/app/core/services/reunion.ts @@ -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([]); + + constructor( + private http: HttpClient, + private paginatedHttp: PaginatedHttpService, + private hippodromeService: HippodromeService + ) {} + + // Helper method to get ngrok bypass headers + private getNgrokHeaders(): Record { + 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> { + if (USE_SERVER) { + if (usePaginationEndpoint) { + return this.paginatedHttp + .fetch(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(undefined))) + ); + + // Fetch courses to calculate counts per reunion + const coursesRequest = this.http + .get(`${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(); + uniqueHippodromeIds.forEach((id, index) => { + const hippodrome = hippodromes[index]; + if (hippodrome) { + hippodromeMap.set(id, hippodrome); + } + }); + + // Count courses per reunion + const courseCountMap = new Map(); + 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(this.apiUrl, { headers: this.getNgrokHeaders() }) + .pipe( + switchMap((apiData) => { + // Handle empty data case + if (!apiData || apiData.length === 0) { + return of( + normalizePage( + { + 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( + { + 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(undefined))) + ); + + // Fetch courses to calculate counts per reunion + const coursesRequest = this.http + .get(`${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(); + uniqueHippodromeIds.forEach((id, index) => { + const hippodrome = hippodromes[index]; + if (hippodrome) { + hippodromeMap.set(id, hippodrome); + } + }); + + // Count courses per reunion + const courseCountMap = new Map(); + 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( + { + 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> { + 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( + { + data: pageData, + meta: { total: data.length, uniqueHippodromes, upcomingReunions, pastReunions }, + }, + params.page, + params.perPage + ) + ); + } + + getById(id: string): Observable { + if (USE_SERVER) { + return this.http + .get(`${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 { + if (USE_SERVER) { + return this.http + .get(`${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): Observable { + if (USE_SERVER) { + return this.http + .post(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): Observable { + if (USE_SERVER) { + return this.http + .put(`${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 { + if (USE_SERVER) { + return this.http + .delete(`${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); + } +} diff --git a/src/app/core/services/role.ts b/src/app/core/services/role.ts new file mode 100644 index 0000000..c25e41f --- /dev/null +++ b/src/app/core/services/role.ts @@ -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 { + 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): 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): 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> { + if (USE_SERVER) { + return this.http + .get(this.rolesUrl, { + headers: this.getNgrokHeaders(), + params: this.buildParams(params), + }) + .pipe( + map((data) => { + const roles = (data || []).map((r) => this.transformRole(r)); + return normalizePage( + { data: roles, meta: { total: roles.length } }, + params.page, + params.perPage + ); + }), + catchError((err) => { + console.error('Error fetching roles:', err); + return of( + normalizePage({ data: [], meta: { total: 0 } }, params.page, params.perPage) + ); + }) + ); + } + + // Fallback (should not be used anymore) + return of( + normalizePage( + { + data: [], + meta: { total: 0 }, + }, + params.page, + params.perPage + ) + ); + } + + /** + * LIST all permissions + */ + allPermissions(): Observable { + if (USE_SERVER) { + return this.http + .get(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): Observable { + const apiPayload = this.transformRoleToApi(payload); + return this.http + .post(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): Observable { + const apiPayload = this.transformRoleToApi(payload); + return this.http + .put(`${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(`${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 { + return this.http + .get(`${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): Observable { + const apiPayload = this.transformPermissionToApi(payload); + return this.http + .post(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): Observable { + const apiPayload = this.transformPermissionToApi(payload); + return this.http + .put(`${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(`${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 }); + }) + ); + } +} diff --git a/src/app/core/services/theme.spec.ts b/src/app/core/services/theme.spec.ts new file mode 100644 index 0000000..e78dc3d --- /dev/null +++ b/src/app/core/services/theme.spec.ts @@ -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(); + }); +}); diff --git a/src/app/core/services/theme.ts b/src/app/core/services/theme.ts new file mode 100644 index 0000000..0aa6934 --- /dev/null +++ b/src/app/core/services/theme.ts @@ -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('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); + } + } +} diff --git a/src/app/core/services/tpe.ts b/src/app/core/services/tpe.ts new file mode 100644 index 0000000..ad4345f --- /dev/null +++ b/src/app/core/services/tpe.ts @@ -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 { + 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): 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 { + if (USE_SERVER) { + return this.http + .get(`${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> { + 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(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( + { data: tpes, meta: { total: tpes.length } }, + params.page || 1, + params.perPage || 10 + ); + } + // Otherwise return all as single page + return normalizePage( + { data: tpes, meta: { total: tpes.length } }, + 1, + tpes.length + ); + }), + catchError((err) => { + console.error('Error fetching TPEs:', err); + return of(normalizePage({ data: [], meta: { total: 0 } }, 1, 10)); + }) + ); + } + return of(normalizePage({ data: [], meta: { total: 0 } }, 1, 10)); + } + + // POST /api/v1/tpes - Create + create(payload: Omit): Observable { + if (USE_SERVER) { + const apiPayload = this.transformToApiPayload(payload); + return this.http + .post(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): Observable { + if (USE_SERVER) { + const apiPayload = this.transformToApiPayload(payload); + return this.http + .put(`${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 { + if (USE_SERVER) { + return this.http + .delete(`${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 { + if (USE_SERVER) { + return this.http + .patch( + `${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 { + 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(`${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 { + if (USE_SERVER) { + const payload = { + tpeId: Number(id), + agentId: Number(agentId), + }; + return this.http + .patch(`${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 { + if (USE_SERVER) { + const apiStatut = this.transformStatutToApi(statut); + return this.http + .get(`${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 { + if (USE_SERVER) { + return this.http + .get(`${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 { + if (USE_SERVER) { + return this.http + .get(`${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 { + if (USE_SERVER) { + return this.http + .get(`${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 { + if (USE_SERVER) { + return this.http + .get(`${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([]); + } +} diff --git a/src/app/core/services/user.ts b/src/app/core/services/user.ts new file mode 100644 index 0000000..30cd679 --- /dev/null +++ b/src/app/core/services/user.ts @@ -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 { + 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): Partial { + 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> { + if (USE_SERVER) { + // Backend returns full list; paginate client-side + return this.http + .get(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( + { data: pageData, meta: { total: data.length } }, + params.page, + params.perPage + ); + }), + catchError(() => + of(normalizePage({ data: [], meta: { total: 0 } }, params.page, params.perPage)) + ) + ); + } + + // Fallback should not be used anymore + return of(normalizePage({ data: [], meta: { total: 0 } }, params.page, params.perPage)); + } + + create(payload: Omit): Observable { + const body = this.transformToApiPayload(payload); + return this.http + .post(this.apiUrl, body, { headers: this.getNgrokHeaders() }) + .pipe(map((res) => this.transform(res))); + } + + update(id: string, payload: Partial): Observable { + const body = this.transformToApiPayload({ ...payload, id }); + return this.http + .put(`${this.apiUrl}/${id}`, body, { headers: this.getNgrokHeaders() }) + .pipe( + map((res) => this.transform(res)), + catchError(() => of(undefined)) + ); + } + + delete(id: string): Observable { + return this.http.delete(`${this.apiUrl}/${id}`, { headers: this.getNgrokHeaders() }).pipe( + map(() => true), + catchError(() => of(false)) + ); + } +} diff --git a/src/app/dashboard/dashboard-module.ts b/src/app/dashboard/dashboard-module.ts new file mode 100644 index 0000000..4ecbca6 --- /dev/null +++ b/src/app/dashboard/dashboard-module.ts @@ -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 {} diff --git a/src/app/dashboard/dashboard-routing-module.ts b/src/app/dashboard/dashboard-routing-module.ts new file mode 100644 index 0000000..da7b5f2 --- /dev/null +++ b/src/app/dashboard/dashboard-routing-module.ts @@ -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 {} diff --git a/src/app/dashboard/layout/layout.css b/src/app/dashboard/layout/layout.css new file mode 100644 index 0000000..92d692c --- /dev/null +++ b/src/app/dashboard/layout/layout.css @@ -0,0 +1,3 @@ +:host { + display: contents; +} diff --git a/src/app/dashboard/layout/layout.html b/src/app/dashboard/layout/layout.html new file mode 100644 index 0000000..2da27af --- /dev/null +++ b/src/app/dashboard/layout/layout.html @@ -0,0 +1,182 @@ + + + + + + + +
+ + + + + + + + Home + + + + Components + + + + +
+ +
+
+ +
+ +
+
+
diff --git a/src/app/dashboard/layout/layout.spec.ts b/src/app/dashboard/layout/layout.spec.ts new file mode 100644 index 0000000..6ff67d8 --- /dev/null +++ b/src/app/dashboard/layout/layout.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { Layout } from './layout'; + +describe('Layout', () => { + let component: Layout; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [Layout] + }) + .compileComponents(); + + fixture = TestBed.createComponent(Layout); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/dashboard/layout/layout.ts b/src/app/dashboard/layout/layout.ts new file mode 100644 index 0000000..d358ec9 --- /dev/null +++ b/src/app/dashboard/layout/layout.ts @@ -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(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); + } +} diff --git a/src/app/dashboard/pages/agents/agents.html b/src/app/dashboard/pages/agents/agents.html new file mode 100644 index 0000000..9baee08 --- /dev/null +++ b/src/app/dashboard/pages/agents/agents.html @@ -0,0 +1,405 @@ +
+
+

Gestion des Agents

+ Nouvel agent +
+ + + + + +
+ + + + +
+
+
+ + +
+ + + +
+ Annuler + Enregistrer +
+
+ + +@if (detailItem()) { + + @if (detailItem(); as agent) { +
+ + +
Informations Emploi
+
+
+
Code
+
{{ agent.code }}
+
+
+
Profil
+
{{ agent.profile }}
+
+
+
Statut
+
+ @if (agent.statut === 'ACTIF') { + + Actif + + } @else if (agent.statut === 'INACTIF') { + + Inactif + + } @else { + + Suspendu + + } +
+
+ @if (agent.principalCode) { +
+
Agent Principal
+
{{ agent.principalCode }}
+
+ } + @if (agent.zone) { +
+
Zone
+
{{ agent.zone }}
+
+ } + @if (agent.kiosk) { +
+
Kiosque
+
{{ agent.kiosk }}
+
+ } + @if (agent.fonction) { +
+
Fonction
+
{{ agent.fonction }}
+
+ } + @if (agent.dateEmbauche) { +
+
Date Embauche
+
{{ agent.dateEmbauche | date: 'dd/MM/yyyy' }}
+
+ } +
+
+ + + +
Informations Personnelles
+
+
+
Nom
+
{{ agent.nom }}
+
+
+
Prénom
+
{{ agent.prenom }}
+
+ @if (agent.autresNoms) { +
+
Autre(s) Nom(s)
+
{{ agent.autresNoms }}
+
+ } + @if (agent.dateNaissance) { +
+
Date de naissance
+
{{ agent.dateNaissance | date: 'dd/MM/yyyy' }}
+
+ } + @if (agent.lieuNaissance) { +
+
Lieu de naissance
+
{{ agent.lieuNaissance }}
+
+ } + @if (agent.ville) { +
+
Ville
+
{{ agent.ville }}
+
+ } + @if (agent.adresse) { +
+
Adresse
+
{{ agent.adresse }}
+
+ } + @if (agent.phone) { +
+
Téléphone
+
{{ agent.phone }}
+
+ } + @if (agent.autoriserAides !== undefined) { +
+
Autoriser Aides
+
+ @if (agent.autoriserAides) { + Oui + } @else { + Non + } +
+
+ } +
+
+ + + +
Limites et Configuration
+
+ @if (agent.limiteInferieure !== undefined) { +
+
Limite inférieure
+
{{ agent.limiteInferieure | number: '1.2-2' }}
+
+ } + @if (agent.limiteSuperieure !== undefined) { +
+
Limite supérieure
+
{{ agent.limiteSuperieure | number: '1.2-2' }}
+
+ } + @if (agent.limiteParTransaction !== undefined) { +
+
Limite / transaction
+
{{ agent.limiteParTransaction | number: '1.2-2' }}
+
+ } + @if (agent.limiteMinAirtime !== undefined) { +
+
Limite min airtime
+
{{ agent.limiteMinAirtime | number: '1.2-2' }}
+
+ } + @if (agent.limiteMaxAirtime !== undefined) { +
+
Limite max airtime
+
{{ agent.limiteMaxAirtime | number: '1.2-2' }}
+
+ } + @if (agent.maxPeripheriques !== undefined) { +
+
Nbre max. périphériques
+
{{ agent.maxPeripheriques }}
+
+ } +
+
+ + + @if (agent.nationalite || agent.cni || agent.cniDelivreeLe || agent.residence || agent.statutMarital) { + +
Informations Légales
+
+ @if (agent.nationalite) { +
+
Nationalité
+
{{ agent.nationalite }}
+
+ } + @if (agent.cni) { +
+
N° CNI
+
{{ agent.cni }}
+
+ } + @if (agent.cniDelivreeLe) { +
+
CNI Délivrée le
+
{{ agent.cniDelivreeLe | date: 'dd/MM/yyyy' }}
+
+ } + @if (agent.cniDelivreeA) { +
+
CNI Délivrée à
+
{{ agent.cniDelivreeA }}
+
+ } + @if (agent.residence) { +
+
Résidence
+
{{ agent.residence }}
+
+ } + @if (agent.statutMarital) { +
+
Statut marital
+
{{ agent.statutMarital }}
+
+ } + @if (agent.epoux) { +
+
Époux/Épouse
+
{{ agent.epoux }}
+
+ } +
+
+ } + + + @if (detailFamilyMembers().length > 0) { + +
Membres de famille
+
+ @for (member of detailFamilyMembers(); track member.id || $index) { +
+
+
+
+
Nom
+
{{ member.nom }}
+
+ @if (member.statut) { +
+
Statut
+
{{ member.statut }}
+
+ } + @if (member.dateNaissance) { +
+
Date de naissance
+
{{ member.dateNaissance | date: 'dd/MM/yyyy' }}
+
+ } + @if (member.sexe) { +
+
Sexe
+
{{ member.sexe === 'M' ? 'Masculin' : 'Féminin' }}
+
+ } +
+
+
+ } +
+
+ } + + + @if (getAgentTpes(agent.id).length > 0) { + +
TPE Assignés ({{ getAgentTpes(agent.id).length }})
+
+ @for (tpe of getAgentTpes(agent.id); track tpe.id) { +
+
+
{{ tpe.imei }}
+ @if (tpe.statut) { + + {{ formatTpeStatut(tpe.statut) }} + + } +
+
+ @if (tpe.marque || tpe.modele) { +
+ Modèle: {{ tpe.marque }} {{ tpe.modele }} +
+ } + @if (tpe.serial) { +
+ Série: {{ tpe.serial }} +
+ } + @if (tpe.type) { +
+ Type: {{ tpe.type }} +
+ } +
+
+ } +
+
+ } +
+ } +
+ Fermer + @if (detailItem()) { + + Modifier + + } +
+
+} + + +@if (assigningAgent()) { + +
+ @if (tpesLoading()) { +
Chargement des TPE disponibles...
+ } @else if (availableTpes().length === 0) { +
Aucun TPE disponible
+ } @else { + + +
+ + @for (tpe of availableTpes(); track tpe.id) { + + {{ tpe.imei }} - {{ tpe.marque }} {{ tpe.modele }} + @if (tpe.statut === 'VALIDE') { + (Valide) + } + + } + +
+
+ } +
+
+ Annuler + +
+
+} diff --git a/src/app/dashboard/pages/agents/agents.ts b/src/app/dashboard/pages/agents/agents.ts new file mode 100644 index 0000000..5c8c049 --- /dev/null +++ b/src/app/dashboard/pages/agents/agents.ts @@ -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([]); + total = signal(0); + loading = signal(false); + + page = signal(1); + perPage = signal(10); + search = signal(''); + sort = signal({ key: 'code', dir: 'asc' }); + + modalOpen = signal(false); + modalTitle = signal('Nouvel agent'); + editingItem = signal(null); + + detailModalOpen = signal(false); + detailItem = signal(null); + detailFamilyMembers = signal([]); + + // TPE Assignment modal + assignTpeModalOpen = signal(false); + assigningAgent = signal(null); + availableTpes = signal([]); + selectedTpeId = signal(''); + tpesLoading = signal(false); + + @ViewChild(AgentFullForm) formComp?: AgentFullForm; + + formatTpeStatut(statut: TpeStatus): string { + const statutMap: Record = { + 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[] = [ + { 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 'Aucun'; + } + // 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 = `
${t.imei}
`; + 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 + ? `
${details}
` + : ''; + return `
${imei}${detailsHtml}
`; + }) + .join(' '); + + const moreHtml = + remaining > 0 + ? `
+${remaining} autre${ + remaining > 1 ? 's' : '' + }
` + : ''; + + return `
${tpeCards}${moreHtml}
`; + }, + }, + { 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(); + agentTpesMap = new Map(); + + 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) { + 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); + + 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(''); + } +} diff --git a/src/app/dashboard/pages/courses/courses.css b/src/app/dashboard/pages/courses/courses.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/dashboard/pages/courses/courses.html b/src/app/dashboard/pages/courses/courses.html new file mode 100644 index 0000000..cffc9e0 --- /dev/null +++ b/src/app/dashboard/pages/courses/courses.html @@ -0,0 +1,171 @@ +
+ +
+

Courses

+ +
+ + +
+ +
Total des courses
+
+ {{ totalCourses() }} +
+
+ + +
En cours
+
+ {{ runningCourses() }} +
+
+ + +
Clôturées
+
+ {{ closedCourses() }} +
+
+ + +
Par type
+
+ @for (type of (byType() | keyvalue); track type.key) { +
+ {{ type.key }} + {{ type.value }} +
+ } +
+
+
+ + + + + +
+ + + @if (!isClosed(row)) { +
+ + + + +
+ } @else { + + + Fermée + + } +
+
+ + +
+ + + + @if(modalOpen()) { + + } +
+ Annuler + Enregistrer +
+
+ + @if(selectedCourse()) { + + + +
+ Annuler + Enregistrer +
+
+ } @if(selectedCourseForResultat()) { + + +
+ Fermer +
+
+ } +
diff --git a/src/app/dashboard/pages/courses/courses.spec.ts b/src/app/dashboard/pages/courses/courses.spec.ts new file mode 100644 index 0000000..8866c16 --- /dev/null +++ b/src/app/dashboard/pages/courses/courses.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { Courses } from './courses'; + +describe('Courses', () => { + let component: Courses; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [Courses] + }) + .compileComponents(); + + fixture = TestBed.createComponent(Courses); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/dashboard/pages/courses/courses.ts b/src/app/dashboard/pages/courses/courses.ts new file mode 100644 index 0000000..a0600ee --- /dev/null +++ b/src/app/dashboard/pages/courses/courses.ts @@ -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([]); + resultatsMap = signal>(new Map()); + loading = signal(false); + total = signal(0); + totalRunning = signal(0); + totalClosed = signal(0); + totalByType = signal>({}); + + page = signal(1); + perPage = signal(10); + search = signal(''); + sort = signal({ key: 'numero', dir: 'asc' }); + pageSize = [10, 20, 50]; + + modalOpen = signal(false); + modalTitle = signal('Nouvelle course'); + editingItem = signal(null); + + @ViewChild(CourseForm) formComp?: CourseForm; + + // 🟩 Corrected columns + cols: TableColumn[] = [ + { key: 'numero', label: 'N°', sortable: true }, + { key: 'nom', label: 'Nom', sortable: true }, + { + key: 'type', + label: 'Type', + sortable: true, + cell: (c) => `${c.type}`, + }, + { + 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) => + `${c.partants} (${ + c.nonPartants?.length ?? 0 + } NP)`, + }, + { + key: 'resultat', + label: 'Résultat', + cell: (c) => { + const resultat = this.resultatsMap().get(c.id); + if (!resultat || !resultat.ordreArrivee || resultat.ordreArrivee.length === 0) { + return ''; + } + + // 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 `${s}`; + }, + }, + { + key: 'statut', + label: 'Statut', + sortable: true, + cell: (c) => { + const colorMap: Record = { + 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 = { + PROGRAMMEE: 'Programmée', + CREATED: 'Créée', + VALIDATED: 'Validée', + RUNNING: 'En cours', + CLOSED: 'Clôturée', + CANCELED: 'Annulée', + }; + return `${ + labelMap[c.statut] + }`; + }, + }, + { + 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([]); + + 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(); + 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) { + const current = this.editingItem(); + const req$ = current?.id + ? this.api.update(current.id, payload) + : this.api.create(payload as Omit); + + 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(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(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 = []; + 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'); + }, + }); + } +} diff --git a/src/app/dashboard/pages/hippodrome/hippodrome.css b/src/app/dashboard/pages/hippodrome/hippodrome.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/dashboard/pages/hippodrome/hippodrome.html b/src/app/dashboard/pages/hippodrome/hippodrome.html new file mode 100644 index 0000000..62f9a9d --- /dev/null +++ b/src/app/dashboard/pages/hippodrome/hippodrome.html @@ -0,0 +1,132 @@ +
+
+

Hippodromes

+ +
+ + +
+ +
Total des hippodromes
+
+ {{ total() }} +
+
+ + +
Pays représentés
+
+ {{ uniqueCountries() }} +
+
+ + +
Villes uniques
+
+ {{ uniqueCities() }} +
+
+ + +
Moyenne par pays
+
+ {{ averageByCountry() }} +
+
+ +
Réunions totales
+
+ {{ totalReunions() }} +
+
+ +
Courses totales
+
+ {{ totalCourses() }} +
+
+
+ + + +
+ + + + + + {{ row.actif ? 'Actif' : 'Inactif' }} + + + + + + + {{ row.createdAt | date : 'shortDate' }} + + + + + +
+ + +
+
+
+ + +
+ + + + + +
+ Annuler + Enregistrer +
+
+
diff --git a/src/app/dashboard/pages/hippodrome/hippodrome.spec.ts b/src/app/dashboard/pages/hippodrome/hippodrome.spec.ts new file mode 100644 index 0000000..ba008ca --- /dev/null +++ b/src/app/dashboard/pages/hippodrome/hippodrome.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { Hippodrome } from './hippodrome'; + +describe('Hippodrome', () => { + let component: Hippodrome; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [Hippodrome] + }) + .compileComponents(); + + fixture = TestBed.createComponent(Hippodrome); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/dashboard/pages/hippodrome/hippodrome.ts b/src/app/dashboard/pages/hippodrome/hippodrome.ts new file mode 100644 index 0000000..4cf34ee --- /dev/null +++ b/src/app/dashboard/pages/hippodrome/hippodrome.ts @@ -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([]); + 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({ key: 'nom', dir: 'asc' }); + + @ViewChild(HippodromeForm) formComp?: HippodromeForm; + + cols: TableColumn[] = [ + { 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) => + ` + + ${h.actif ? 'Actif' : 'Inactif'} + `, + }, + { + 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 | 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) { + const current = this.editingItem(); + const req$ = current?.id + ? this.api.update(current.id, payload) + : this.api.create(payload as Omit); + + 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()); + } +} diff --git a/src/app/dashboard/pages/limits/limits.html b/src/app/dashboard/pages/limits/limits.html new file mode 100644 index 0000000..ec942be --- /dev/null +++ b/src/app/dashboard/pages/limits/limits.html @@ -0,0 +1,63 @@ +
+
+

Gestion des Limites

+ Nouvelle limite +
+ + +
+ Filtrer par statut: + + + +
+ + + + + +
+ + +
+
+
+ + +
+ + + +
+ Annuler + Enregistrer +
+
+ + diff --git a/src/app/dashboard/pages/limits/limits.ts b/src/app/dashboard/pages/limits/limits.ts new file mode 100644 index 0000000..1a6b0f8 --- /dev/null +++ b/src/app/dashboard/pages/limits/limits.ts @@ -0,0 +1,294 @@ +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component, ViewChild, effect, signal, untracked, OnInit } from '@angular/core'; +import { DataTable, SortState, TableColumn } from '@shared/components/data-table/data-table'; +import { Paginator } from '@shared/components/paginator/paginator'; +import { SearchBar } from '@shared/components/search-bar/search-bar'; +import { Modal } from '@shared/components/modal/modal'; +import { ZardButtonComponent } from '@shared/components/button/button.component'; +import { SortDir } from '@shared/paging/paging'; +import { AgentLimit } from 'src/app/core/interfaces/agent-limit'; +import { AgentLimitService } from 'src/app/core/services/agent-limit'; +import { LimitForm } from '@shared/forms/limit-form/limit-form'; +import { Subject, of } from 'rxjs'; +import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators'; + +@Component({ + standalone: true, + selector: 'app-limits', + templateUrl: './limits.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, DataTable, Paginator, SearchBar, Modal, ZardButtonComponent, LimitForm], +}) +export class LimitsPage implements OnInit { + rows = signal([]); + total = signal(0); + loading = signal(false); + page = signal(1); + perPage = signal(10); + search = signal(''); + sort = signal({ key: 'code', dir: 'asc' }); + selectedActif = signal(null); + + modalOpen = signal(false); + modalTitle = signal('Nouvelle limite'); + editingItem = signal(null); + + // Live search + private searchSubject = new Subject(); + + @ViewChild(LimitForm) formComp?: LimitForm; + + cols: TableColumn[] = [ + { key: 'code', label: 'Code', sortable: true }, + { key: 'configCode', label: 'Config', sortable: true }, + { key: 'nom', label: 'Nom', sortable: true }, + { + key: 'isDefault', + label: 'Défaut', + cell: (l) => + l.isDefault + ? ' Par défaut' + : '', + }, + { + key: 'actif', + label: 'Actif', + cell: (l) => + l.actif + ? ' Actif' + : ' Inactif', + }, + { + key: 'betMin', + label: 'Min Bet', + cell: (l) => (l.betMin ?? 0).toLocaleString('fr-FR'), + }, + { + key: 'betMax', + label: 'Max Bet', + cell: (l) => (l.betMax ?? 0).toLocaleString('fr-FR'), + }, + { + key: 'maxBet', + label: 'Max Bet (tx)', + cell: (l) => (l.maxBet ?? 0).toLocaleString('fr-FR'), + }, + { + key: 'maxDisburseBet', + label: 'Max Disburse', + cell: (l) => (l.maxDisburseBet ?? 0).toLocaleString('fr-FR'), + }, + { + key: 'airtimeMin', + label: 'Airtime Min', + cell: (l) => (l.airtimeMin ?? 0).toLocaleString('fr-FR'), + }, + { + key: 'airtimeMax', + label: 'Airtime Max', + cell: (l) => (l.airtimeMax ?? 0).toLocaleString('fr-FR'), + }, + ]; + + constructor(private api: AgentLimitService) { + effect(() => { + // Only trigger fetch when page, perPage, or sort changes (not search - handled by searchSubject) + const searchValue = this.search(); + const params = { + page: this.page(), + perPage: this.perPage(), + search: searchValue, + sortKey: this.sort().key, + sortDir: this.sort().dir as SortDir, + }; + // Only fetch if search is empty (search is handled by searchSubject) + if (!searchValue.trim()) { + untracked(() => this.fetch(params)); + } + }); + + // Setup live search with debounce + this.searchSubject + .pipe( + debounceTime(300), + distinctUntilChanged(), + switchMap((query) => { + if (query.trim()) { + // Use search API which returns array + return this.api.search(query); + } else { + // If empty, use normal list + return this.api.list({ + page: this.page(), + perPage: this.perPage(), + search: '', + sortKey: this.sort().key, + sortDir: this.sort().dir as SortDir, + }).pipe( + switchMap((res) => { + // Convert PagedResult to array for consistency + return of(res.data); + }) + ); + } + }) + ) + .subscribe({ + next: (res) => { + // Search API always returns array + if (Array.isArray(res)) { + this.rows.set(res); + this.total.set(res.length); + } + this.loading.set(false); + }, + error: (err) => { + console.error('Search error:', err); + this.rows.set([]); + this.total.set(0); + this.loading.set(false); + }, + }); + } + + ngOnInit() { + // Initial fetch + this.fetch({ + page: this.page(), + perPage: this.perPage(), + search: this.search(), + sortKey: this.sort().key, + sortDir: this.sort().dir as SortDir, + }); + } + + private fetch(params: { page: number; perPage: number; search: string; sortKey: string; sortDir: SortDir }) { + // Don't fetch if there's a search query - it's handled by searchSubject + const searchQuery = params.search.trim(); + if (searchQuery) { + return; // Search is handled by searchSubject subscription + } + + this.loading.set(true); + const actif = this.selectedActif(); + + if (actif !== null) { + // Filter by actif status - returns array + this.api.getByActif(actif).subscribe({ + next: (res: AgentLimit[]) => { + this.rows.set(res); + this.total.set(res.length); + this.loading.set(false); + }, + error: () => { + this.rows.set([]); + this.total.set(0); + this.loading.set(false); + }, + }); + } else { + // Normal list with pagination + this.api.list(params).subscribe({ + next: (res) => { + this.rows.set(res.data); + this.total.set(res.meta.total); + this.loading.set(false); + }, + error: () => { + this.rows.set([]); + this.total.set(0); + this.loading.set(false); + }, + }); + } + } + + onSearch(q: string) { + this.search.set(q); + this.page.set(1); + // Trigger search via subject for live search + if (q.trim()) { + this.loading.set(true); + this.searchSubject.next(q); + } else { + // If empty, fetch normally + this.fetch({ + page: this.page(), + perPage: this.perPage(), + search: '', + sortKey: this.sort().key, + sortDir: this.sort().dir as SortDir, + }); + } + } + + onActifFilter(actif: boolean | null) { + this.selectedActif.set(actif); + this.page.set(1); + this.fetch({ + page: this.page(), + perPage: this.perPage(), + search: this.search(), + sortKey: this.sort().key, + sortDir: this.sort().dir as SortDir, + }); + } + openCreate() { this.modalTitle.set('Nouvelle limite'); this.editingItem.set(null); queueMicrotask(() => this.modalOpen.set(true)); } + openEdit(row: AgentLimit) { this.modalTitle.set('Modifier la limite'); this.editingItem.set(row); queueMicrotask(() => this.modalOpen.set(true)); } + closeModal() { this.modalOpen.set(false); } + submitChildForm() { this.formComp?.onSubmit(); } + + onFormSave(payload: Partial) { + const current = this.editingItem(); + const isSettingDefault = payload.isDefault === true; + const wasDefault = current?.isDefault; + + // If setting as default and it wasn't default before, show confirmation + if (isSettingDefault && !wasDefault) { + if (!confirm('Définir cette limite comme limite par défaut ?\n\nTous les agents recevront automatiquement cette limite, et l\'ancienne limite par défaut perdra son statut.')) { + return; + } + } + + const req$ = current?.id ? this.api.update(current.id, payload) : this.api.create(payload as Omit); + req$.subscribe({ + next: (result) => { + if (!result && current?.id) { + // Update failed + alert('Erreur lors de la sauvegarde de la limite'); + return; + } + this.closeModal(); + this.fetch({ + page: this.page(), + perPage: this.perPage(), + search: this.search(), + sortKey: this.sort().key, + sortDir: this.sort().dir as SortDir, + }); + if (isSettingDefault && !wasDefault) { + alert('La limite a été définie comme limite par défaut. Tous les agents ont été mis à jour.'); + } + }, + error: (err) => { + console.error('Error saving limit:', err); + alert('Erreur lors de la sauvegarde de la limite'); + }, + }); + } + + remove(row: AgentLimit) { + if (!confirm(`Supprimer la limite ${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, + }); + }); + } +} + + diff --git a/src/app/dashboard/pages/main/main.css b/src/app/dashboard/pages/main/main.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/dashboard/pages/main/main.html b/src/app/dashboard/pages/main/main.html new file mode 100644 index 0000000..7c4dcd8 --- /dev/null +++ b/src/app/dashboard/pages/main/main.html @@ -0,0 +1,333 @@ +
+

Dashboard principale PJP

+ + + @if (statsLoading()) { +
Chargement des statistiques…
+ } @if (statsError()) { +
{{ statsError() }}
+ } + + +
+ +
+
+
Courses en direct & à venir
+
+ Statuts RUNNING & PROGRAMMEE, triés par heure de départ +
+
+
+ @if (liveCourses().length) { +
+ @for (c of liveCourses(); track c.id) { +
+
+
+
+ + N° {{ c.numero }} + + + {{ c.nom }} + + @if (c.type) { + + {{ c.type }} + + } +
+
+ + + + + {{ + c.dateDepartCourse + ? (c.dateDepartCourse | date : 'short' : undefined : 'fr-FR') + : '—' + }} + + + + {{ c.reunion.hippodrome.nom }} + + + Réunion {{ c.reunion.nom }} + + Distance {{ c.distance | number : '1.0-0' }} m +
+
+ + + + + {{ c.partants }} partant{{ c.partants > 1 ? 's' : '' }} + + @if (c.nonPartants && c.nonPartants.length > 0) { + + + {{ c.nonPartants.length }} non-partant{{ c.nonPartants.length > 1 ? 's' : '' }} + + } @if (c.condition) { + + {{ c.condition }} + } @if (c.particularite) { + + ⭐ {{ c.particularite }} + } +
+
+
+ + {{ + (c.statut || '').toUpperCase() === 'RUNNING' + ? 'En cours' + : (c.statut || '').toUpperCase() === 'PROGRAMMEE' + ? 'Programmée' + : (c.statut || '').toUpperCase() === 'VALIDATED' + ? 'Validée' + : c.statut + }} + +
+
+
+ } +
+ } @else { +
+ Aucune course en cours ou à venir pour le moment. +
+ } +
+
+ + +
+ +
Utilisateurs
+
+ {{ totalUsers() }} +
+
+ + +
Agents
+
+ {{ totalAgents() }} +
+
+ + +
TPE
+
+ {{ totalTpes() }} +
+
+ + +
Limites agents
+
+ {{ totalAgentLimits() }} +
+
+
+ + +
+ +
Hippodromes
+
+ {{ totalHippodromes() }} +
+
+ + +
Réunions
+
+ {{ totalReunions() }} +
+
+ + +
Courses
+
+ {{ totalCourses() }} +
+
+ + +
Membres de famille d'agents
+
+ {{ totalAgentFamilyMembers() }} +
+
+
+ + +
+ + +
+
Répartition TPE par statut
+
Total : {{ totalTpes() }}
+
+
+ +
+
+
+ {{ totalTpes() }} +
+
+ +
+ @for (item of tpeStatusBreakdown(); track item.statut) { +
+ + + {{ item.statut }} ({{ item.count }}) — {{ item.percent }}% + +
+ } @if (!tpeStatusBreakdown().length) { +
+ Aucune donnée de statut TPE disponible. +
+ } +
+
+
+ + + +
TPE assignés
+
+
+ {{ tpeAssignRate() }}% +
+
+ ({{ tpeAssignedCount() }} / {{ totalTpes() }}) +
+
+
+
+
+

+ Pourcentage de TPE actuellement affectés à un agent. +

+
+ + + +
+
Activité par entité
+
+
+ + + + + + + + + + + + + @if (entityPolylinePoints()) { + + + + } + +
+
+
+
Users
+
{{ totalUsers() }}
+
+
+
Agents
+
{{ totalAgents() }}
+
+
+
TPE
+
{{ totalTpes() }}
+
+
+
Reunions
+
{{ totalReunions() }}
+
+
+
Courses
+
{{ totalCourses() }}
+
+
+
+
+
diff --git a/src/app/dashboard/pages/main/main.spec.ts b/src/app/dashboard/pages/main/main.spec.ts new file mode 100644 index 0000000..c005dc2 --- /dev/null +++ b/src/app/dashboard/pages/main/main.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { Main } from './main'; + +describe('Main', () => { + let component: Main; + let fixture: ComponentFixture
; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [Main] + }) + .compileComponents(); + + fixture = TestBed.createComponent(Main); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/dashboard/pages/main/main.ts b/src/app/dashboard/pages/main/main.ts new file mode 100644 index 0000000..ead5990 --- /dev/null +++ b/src/app/dashboard/pages/main/main.ts @@ -0,0 +1,257 @@ +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core'; +import { ZardCardComponent } from '@shared/components/card/card.component'; +import { UserService } from 'src/app/core/services/user'; +import { AgentService } from 'src/app/core/services/agent'; +import { TpeService } from 'src/app/core/services/tpe'; +import { AgentLimitService } from 'src/app/core/services/agent-limit'; +import { HippodromeService } from 'src/app/core/services/hippodrome'; +import { ReunionService } from 'src/app/core/services/reunion'; +import { CourseService } from 'src/app/core/services/course'; +import { RoleService } from 'src/app/core/services/role'; +import { AgentFamilyMemberService } from 'src/app/core/services/agent-family-member'; +import { ListParams, SortDir } from '@shared/paging/paging'; +import { forkJoin, of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; +import type { Course as CourseModel } from 'src/app/core/interfaces/course'; + +@Component({ + selector: 'app-main', + imports: [ZardCardComponent, CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './main.html', + styleUrl: './main.css', +}) +export class Main { + // Loading & error state + statsLoading = signal(false); + statsError = signal(null); + + // Global totals + totalUsers = signal(0); + totalAgents = signal(0); + totalTpes = signal(0); + totalAgentLimits = signal(0); + totalHippodromes = signal(0); + totalReunions = signal(0); + totalCourses = signal(0); + totalRoles = signal(0); + totalPermissions = signal(0); + totalAgentFamilyMembers = signal(0); + + // TPE status breakdown for charts + tpeStatusBreakdown = signal<{ statut: string; count: number; percent: number; color: string }[]>( + [] + ); + tpeAssignedCount = signal(0); + + // Derived value for TPE assignment rate + tpeAssignRate = computed(() => { + const total = this.totalTpes(); + const assigned = this.tpeAssignedCount(); + if (!total) return 0; + return Math.round((assigned / total) * 100); + }); + + // CSS conic-gradient for a TPE status pie/donut chart + tpePieGradient = computed(() => { + const segments = this.tpeStatusBreakdown(); + if (!segments.length) { + return 'conic-gradient(#e5e7eb 0deg 360deg)'; + } + let current = 0; + const parts: string[] = []; + for (const seg of segments) { + const start = current; + const sweep = (seg.percent || 0) * 3.6; // percent -> degrees + const end = start + sweep; + parts.push(`${seg.color} ${start}deg ${end}deg`); + current = end; + } + return `conic-gradient(${parts.join(', ')})`; + }); + + // Simple entity activity series for a line chart (users, agents, tpes, reunions, courses) + entityLabels: string[] = ['USERS', 'AGENTS', 'TPE', 'REUNIONS', 'COURSES']; + entitySeries = computed(() => [ + this.totalUsers(), + this.totalAgents(), + this.totalTpes(), + this.totalReunions(), + this.totalCourses(), + ]); + + // SVG polyline points for the entitySeries (normalized to 0–40 viewport) + entityPolylinePoints = computed(() => { + const values = this.entitySeries(); + const max = Math.max(...values, 1); + const n = values.length; + if (!n) return ''; + const stepX = n > 1 ? 100 / (n - 1) : 100; + return values + .map((v, i) => { + const x = i * stepX; + const norm = v / max; // 0–1 + const y = 40 - norm * 30; // keep some top/bottom padding + return `${x},${y}`; + }) + .join(' '); + }); + + // Live / upcoming courses (RUNNING or PROGRAMMEE, nearest in time) + liveCourses = signal([]); + + constructor( + private userService: UserService, + private agentService: AgentService, + private tpeService: TpeService, + private agentLimitService: AgentLimitService, + private hippodromeService: HippodromeService, + private reunionService: ReunionService, + private courseService: CourseService, + private roleService: RoleService, + private familyService: AgentFamilyMemberService + ) { + this.loadStats(); + } + + private baseParams(): ListParams { + return { + page: 1, + perPage: 1, + search: '', + sortKey: 'id', + sortDir: 'asc' as SortDir, + }; + } + + private loadStats() { + this.statsLoading.set(true); + this.statsError.set(null); + + const params = this.baseParams(); + // Fetch more courses to filter for live/upcoming ones + const coursesParams = { + ...params, + perPage: 100, // Fetch up to 100 courses to filter from + sortKey: 'dateDepartCourse', + sortDir: 'desc' as SortDir, + }; + + forkJoin({ + users: this.userService + .list(params) + .pipe(catchError(() => of({ data: [], meta: { total: 0 } } as any))), + agents: this.agentService + .list(params) + .pipe(catchError(() => of({ data: [], meta: { total: 0 } } as any))), + tpes: this.tpeService + .list(params) + .pipe(catchError(() => of({ data: [], meta: { total: 0 } } as any))), + limits: this.agentLimitService + .list(params) + .pipe(catchError(() => of({ data: [], meta: { total: 0 } } as any))), + hippodromes: this.hippodromeService + .list(params, true) + .pipe(catchError(() => of({ data: [], meta: { total: 0 } } as any))), + reunions: this.reunionService + .list(params, true) + .pipe(catchError(() => of({ data: [], meta: { total: 0 } } as any))), + courses: this.courseService + .list(coursesParams, true) + .pipe(catchError(() => of({ data: [], meta: { total: 0 } } as any))), + roles: this.roleService + .list(params) + .pipe(catchError(() => of({ data: [], meta: { total: 0 } } as any))), + permissions: this.roleService.allPermissions().pipe(catchError(() => of([]))), + familyMembers: this.familyService.list().pipe(catchError(() => of([]))), + tpeByStatut: this.tpeService.getCountByStatut().pipe(catchError(() => of({}))), + tpeAssignes: this.tpeService.getAssignesStats().pipe(catchError(() => of(0))), + }).subscribe({ + next: (res) => { + this.totalUsers.set(res.users.meta?.total ?? 0); + this.totalAgents.set(res.agents.meta?.total ?? 0); + this.totalTpes.set(res.tpes.meta?.total ?? 0); + this.totalAgentLimits.set(res.limits.meta?.total ?? 0); + this.totalHippodromes.set(res.hippodromes.meta?.total ?? 0); + this.totalReunions.set(res.reunions.meta?.total ?? 0); + this.totalCourses.set(res.courses.meta?.total ?? 0); + this.totalRoles.set(res.roles.meta?.total ?? 0); + this.totalPermissions.set((res.permissions as any[]).length ?? 0); + this.totalAgentFamilyMembers.set((res.familyMembers as any[]).length ?? 0); + + // TPE status breakdown + const totalTpes = res.tpes.meta?.total ?? 0; + const statusColors: Record = { + VALIDE: '#16a34a', // green + DISPONIBLE: '#22c55e', + AFFECTE: '#3b82f6', // blue + EN_PANNE: '#f97316', // orange + EN_MAINTENANCE: '#eab308', // yellow + BLOQUE: '#ef4444', // red + INVALIDE: '#6b7280', // gray + HORS_SERVICE: '#4b5563', + VOLE: '#7c3aed', + }; + const rawStats = res.tpeByStatut || {}; + const entries = Object.entries(rawStats as Record).filter( + ([, count]) => count > 0 + ); + const totalFromStats = + entries.reduce((sum, [, count]) => sum + (count || 0), 0) || totalTpes || 1; + const breakdown = entries.map(([statut, count]) => { + const upper = statut.toUpperCase(); + const color = statusColors[upper] || '#6b7280'; + const percent = Math.round(((count || 0) / totalFromStats) * 100); + return { statut: upper, count: count || 0, percent, color }; + }); + this.tpeStatusBreakdown.set(breakdown); + this.tpeAssignedCount.set(res.tpeAssignes ?? 0); + + // Live / upcoming courses: filter by statut & date + const allCourses = (res.courses.data as CourseModel[]) ?? []; + const now = new Date(); + const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000); + const oneDayAhead = new Date(now.getTime() + 24 * 60 * 60 * 1000); + + const live = allCourses + .filter((c) => { + const statut = String(c.statut || '').toUpperCase(); + + // Include RUNNING courses + if (statut === 'RUNNING') return true; + + // Include PROGRAMMEE courses that are scheduled within the next 24 hours + if (statut === 'PROGRAMMEE') { + const d = c.dateDepartCourse ? new Date(c.dateDepartCourse) : null; + if (!d) return false; + // Include if departure is in the past hour (just started) or within next 24 hours + return d >= oneHourAgo && d <= oneDayAhead; + } + + // Also include VALIDATED courses that are about to start (within next 24 hours) + if (statut === 'VALIDATED') { + const d = c.dateDepartCourse ? new Date(c.dateDepartCourse) : null; + if (!d) return false; + return d >= now && d <= oneDayAhead; + } + + return false; + }) + .sort((a, b) => { + const da = a.dateDepartCourse ? new Date(a.dateDepartCourse).getTime() : 0; + const db = b.dateDepartCourse ? new Date(b.dateDepartCourse).getTime() : 0; + return da - db; // Sort by departure time ascending (earliest first) + }) + .slice(0, 6); + this.liveCourses.set(live); + + this.statsLoading.set(false); + }, + error: () => { + this.statsError.set('Erreur lors du chargement des statistiques du dashboard.'); + this.statsLoading.set(false); + }, + }); + } +} diff --git a/src/app/dashboard/pages/profile/profile.html b/src/app/dashboard/pages/profile/profile.html new file mode 100644 index 0000000..fe50a4d --- /dev/null +++ b/src/app/dashboard/pages/profile/profile.html @@ -0,0 +1,121 @@ +
+
+
+

Mon profil

+

Gérez vos informations et la sécurité du compte

+
+ +
+ +
+ + +
+
Informations du compte
+
Nom, prénom et identifiant
+
+
+ + +
+ +
+
+ + +
+ +
+
+ + +
+ +
+
+
+
+ Enregistrer +
+
+ + + +
+
Sécurité
+
Changez votre mot de passe
+
+
+ + +
+ +
+
+ + +
+ +
+
+ + +
+ +
+
+
+
+ Mettre à jour +
+
+
+
diff --git a/src/app/dashboard/pages/profile/profile.ts b/src/app/dashboard/pages/profile/profile.ts new file mode 100644 index 0000000..70d6ce9 --- /dev/null +++ b/src/app/dashboard/pages/profile/profile.ts @@ -0,0 +1,75 @@ +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; +import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; +import { ZardCardComponent } from '@shared/components/card/card.component'; +import { ZardFormModule } from '@shared/components/form/form.module'; +import { ZardInputDirective } from '@shared/components/input/input.directive'; +import { ZardButtonComponent } from '@shared/components/button/button.component'; +import { ZardAvatarComponent } from '@shared/components/avatar/avatar.component'; + +@Component({ + standalone: true, + selector: 'app-profile', + templateUrl: './profile.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + ReactiveFormsModule, + ZardCardComponent, + ZardFormModule, + ZardInputDirective, + ZardButtonComponent, + ZardAvatarComponent, + ], +}) +export class ProfilePage { + profileForm; + passwordForm; + savingProfile = signal(false); + savingPassword = signal(false); + submittedProfile = false; + submittedPassword = false; + + avatar = { fallback: 'PM', url: '/assets/images/avatar.svg', alt: 'Profil' }; + + constructor(private fb: FormBuilder) { + this.profileForm = this.fb.group({ + nom: ['', Validators.required], + prenom: ['', Validators.required], + identifiant: ['', Validators.required], + }); + + this.passwordForm = this.fb.group({ + current: ['', Validators.required], + next: ['', [Validators.required, Validators.minLength(6)]], + confirm: ['', [Validators.required, Validators.minLength(6)]], + }); + } + + saveProfile() { + this.submittedProfile = true; + if (this.profileForm.invalid) { + this.profileForm.markAllAsTouched(); + return; + } + this.savingProfile.set(true); + setTimeout(() => this.savingProfile.set(false), 600); + } + + changePassword() { + this.submittedPassword = true; + if (this.passwordForm.invalid) { + this.passwordForm.markAllAsTouched(); + return; + } + const { next, confirm } = this.passwordForm.getRawValue(); + if (next !== confirm) { + // simple inline mismatch handling; in real app, set an error + return; + } + this.savingPassword.set(true); + setTimeout(() => this.savingPassword.set(false), 600); + } +} + + diff --git a/src/app/dashboard/pages/report-courses/report-detail.html b/src/app/dashboard/pages/report-courses/report-detail.html new file mode 100644 index 0000000..52ef8e4 --- /dev/null +++ b/src/app/dashboard/pages/report-courses/report-detail.html @@ -0,0 +1,114 @@ +@if(detail()){ +
+
+

+ Rapport pour la course n° {{ detail()!.summary.course.numero }} + ({{ detail()!.summary.statut }}) +

+
+ Retour + {{ editMode() ? 'Quitter édition' : 'Modifier' }} + Valider + Confirmer + Réinitialiser +
+
+ + +
+
+ Date: + {{ detail()!.summary.course.dateDepartCourse | date : 'dd/MM/yyyy' }} +
+
Nom: {{ detail()!.summary.course.nom }}
+
Type: {{ detail()!.summary.course.type }}
+
+ Lieu: {{ detail()!.summary.course.reunion.hippodrome.nom }} +
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
Type de gainType de jeuMontantNombreStatutDistribuéExterne
{{ r.typeGain }}{{ r.typeJeu }} + @if(editMode() && !detail()!.summary.confirmed){ + + } @else { + {{ r.montant | number : '1.0-0' : 'fr-FR' }} + } + + @if(editMode() && !detail()!.summary.confirmed){ + + } @else { + {{ r.nombre | number : '1.0-0' : 'fr-FR' }} + } + {{ r.statut }} + @if(editMode() && !detail()!.summary.confirmed){ + + } @else { + + } + + @if(editMode() && !detail()!.summary.confirmed){ + + } @else { + + } +
+
+
+} diff --git a/src/app/dashboard/pages/report-courses/report-detail.ts b/src/app/dashboard/pages/report-courses/report-detail.ts new file mode 100644 index 0000000..f003edc --- /dev/null +++ b/src/app/dashboard/pages/report-courses/report-detail.ts @@ -0,0 +1,132 @@ +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; +import { ActivatedRoute, RouterModule } from '@angular/router'; +import { ZardCardComponent } from '@shared/components/card/card.component'; +import { ReportService } from 'src/app/core/services/report'; +import { CourseReportDetail, CourseReportDetailRow } from 'src/app/core/interfaces/report'; +import { ZardButtonComponent } from '@shared/components/button/button.component'; + +@Component({ + standalone: true, + selector: 'app-report-courses-detail', + templateUrl: './report-detail.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, RouterModule, ZardCardComponent, ZardButtonComponent], +}) +export class ReportCoursesDetailPage { + detail = signal(undefined); + editMode = signal(false); + editedRows = signal([]); + private originalRows = signal([]); + + constructor(private route: ActivatedRoute, private api: ReportService) { + const id = this.route.snapshot.params['id']; + this.api.getDetail(id).subscribe((d) => { + this.detail.set(d); + this.editedRows.set(d?.rows ?? []); + this.originalRows.set(d?.rows ? d.rows.map((r) => ({ ...r })) : []); + }); + } + + onValidate() { + const id = this.detail()?.summary.id; + if (!id) return; + // Persist edited rows then validate + this.api.modifyRows(id, this.editedRows()).subscribe(() => { + this.api.validate(id).subscribe((s) => { + if (this.detail()) this.detail.set({ summary: s!, rows: this.editedRows() }); + // Commit current edits as the new baseline + this.originalRows.set(this.editedRows().map((r) => ({ ...r }))); + this.editMode.set(false); + }); + }); + } + onConfirm() { + const id = this.detail()?.summary.id; + if (!id) return; + this.api.confirm(id).subscribe((s) => { + if (this.detail()) this.detail.set({ summary: s!, rows: this.editedRows() }); + // Confirm also commits the current edits as baseline + this.originalRows.set(this.editedRows().map((r) => ({ ...r }))); + this.editMode.set(false); + }); + } + onReset() { + const id = this.detail()?.summary.id; + if (!id) return; + this.api.resetStatus(id).subscribe((s) => { + if (this.detail()) this.detail.set({ summary: s!, rows: this.detail()!.rows }); + // Reset discards uncommitted edits + this.editedRows.set(this.originalRows().map((r) => ({ ...r }))); + this.editMode.set(false); + }); + } + + onEditToggle() { + if (this.detail()?.summary.confirmed) return; + const currentlyEditing = this.editMode(); + if (currentlyEditing) { + // Leaving edit mode without validation: revert to original snapshot + this.editedRows.set(this.originalRows().map((r) => ({ ...r }))); + this.editMode.set(false); + } else { + this.editMode.set(true); + } + } + + onChangeMontant(index: number, value: any) { + const v = Number(value); + this.editedRows.update((rows: CourseReportDetailRow[]) => { + const current = rows[index]; + if (!current) return rows; + current.montant = Number.isFinite(v) ? v : current.montant; + return rows; + }); + } + + onChangeNombre(index: number, value: any) { + const v = Number(value); + this.editedRows.update((rows: CourseReportDetailRow[]) => { + const current = rows[index]; + if (!current) return rows; + current.nombre = Number.isFinite(v) ? v : current.nombre; + return rows; + }); + } + + onToggleDistributed(index: number, value: any) { + const checked = !!value; + this.editedRows.update((rows: CourseReportDetailRow[]) => { + const current = rows[index]; + if (!current) return rows; + current.distributed = checked; + return rows; + }); + } + + onToggleExterne(index: number, value: any) { + const checked = !!value; + this.editedRows.update((rows: CourseReportDetailRow[]) => { + const current = rows[index]; + if (!current) return rows; + current.externe = checked; + return rows; + }); + } + + trackByRow(index: number, row: CourseReportDetailRow) { + return row.typeGain + '|' + row.typeJeu + '|' + index; + } + + isRowDirty(index: number): boolean { + const current = this.editedRows()[index]; + const original = this.originalRows()[index]; + if (!current || !original) return false; + return ( + current.montant !== original.montant || + current.nombre !== original.nombre || + !!current.distributed !== !!original.distributed || + !!current.externe !== !!original.externe + ); + } +} diff --git a/src/app/dashboard/pages/report-courses/report-list.html b/src/app/dashboard/pages/report-courses/report-list.html new file mode 100644 index 0000000..3d32fe9 --- /dev/null +++ b/src/app/dashboard/pages/report-courses/report-list.html @@ -0,0 +1,19 @@ +
+
+

Rapport des courses

+
+ + + + + +
+ +
+
+
+ + +
+ + diff --git a/src/app/dashboard/pages/report-courses/report-list.ts b/src/app/dashboard/pages/report-courses/report-list.ts new file mode 100644 index 0000000..7e1d1e3 --- /dev/null +++ b/src/app/dashboard/pages/report-courses/report-list.ts @@ -0,0 +1,61 @@ +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component, signal, effect, 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 { ZardButtonComponent } from '@shared/components/button/button.component'; +import { SortDir } from '@shared/paging/paging'; +import { Router } from '@angular/router'; +import { CourseReportSummary } from 'src/app/core/interfaces/report'; +import { ReportService } from 'src/app/core/services/report'; + +@Component({ + standalone: true, + selector: 'app-report-courses-list', + templateUrl: './report-list.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, DataTable, Paginator, SearchBar, ZardButtonComponent], +}) +export class ReportCoursesListPage { + rows = signal([]); + total = signal(0); + page = signal(1); + perPage = signal(10); + search = signal(''); + sort = signal({ key: 'date', dir: 'desc' }); + loading = signal(false); + + cols: TableColumn[] = [ + { key: 'course.dateDepartCourse', label: 'Date', sortable: true }, + { key: 'course.numero', label: 'Numéro', sortable: true }, + { key: 'course.nom', label: 'Nom', sortable: true }, + { key: 'course.type', label: 'Type', sortable: true }, + { key: 'course.reunion.hippodrome.nom', label: 'Lieu', sortable: true }, + { key: 'course.particularite', label: 'Particularité' }, + { key: 'statut', label: 'Statut', sortable: true }, + ]; + + constructor(private api: ReportService, private router: Router) { + 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)); + }); + } + + fetch(params: { page: number; perPage: number; search: string; sortKey: string; sortDir: SortDir }) { + this.loading.set(true); + this.api.list(params).subscribe((res) => { + this.rows.set(res.data); + this.total.set(res.meta.total); + this.loading.set(false); + }); + } + + onSearch(q: string) { this.search.set(q); this.page.set(1); } + + open(row: CourseReportSummary) { + this.router.navigate(['/rapport-courses', row.id]); + } +} + + diff --git a/src/app/dashboard/pages/reunion/reunion.css b/src/app/dashboard/pages/reunion/reunion.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/dashboard/pages/reunion/reunion.html b/src/app/dashboard/pages/reunion/reunion.html new file mode 100644 index 0000000..0cd01af --- /dev/null +++ b/src/app/dashboard/pages/reunion/reunion.html @@ -0,0 +1,87 @@ +
+
+

Réunions

+ +
+ +
+ +
Total des réunions
+
+ {{ total() }} +
+
+ + +
Réunions à venir
+
+ {{ upcomingReunions() }} +
+
+ + +
Réunions passées
+
+ {{ pastReunions() }} +
+
+ + +
Hippodromes concernés
+
+ {{ uniqueHippodromes() }} +
+
+
+ + + +
+ + +
+ + +
+
+
+ + +
+ + + + +
+ Annuler + Enregistrer +
+
+
diff --git a/src/app/dashboard/pages/reunion/reunion.spec.ts b/src/app/dashboard/pages/reunion/reunion.spec.ts new file mode 100644 index 0000000..6aed4ad --- /dev/null +++ b/src/app/dashboard/pages/reunion/reunion.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { Reunion } from './reunion'; + +describe('Reunion', () => { + let component: Reunion; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [Reunion] + }) + .compileComponents(); + + fixture = TestBed.createComponent(Reunion); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/dashboard/pages/reunion/reunion.ts b/src/app/dashboard/pages/reunion/reunion.ts new file mode 100644 index 0000000..bc7a3c9 --- /dev/null +++ b/src/app/dashboard/pages/reunion/reunion.ts @@ -0,0 +1,234 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + signal, + ViewChild, + untracked, +} from '@angular/core'; +import { Reunion as ReunionType } from 'src/app/core/interfaces/reunion'; +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 { ReunionForm } from '@shared/forms/reunion-form/reunion-form'; +import { ReunionService } from 'src/app/core/services/reunion'; +import { ZardButtonComponent } from '@shared/components/button/button.component'; +import { ZardCardComponent } from '@shared/components/card/card.component'; +import { SortDir } from '@shared/paging/paging'; +import { LucideAngularModule } from 'lucide-angular'; + +@Component({ + standalone: true, + selector: 'app-reunion-list', + imports: [ + CommonModule, + DataTable, + Paginator, + SearchBar, + Modal, + ReunionForm, + ZardButtonComponent, + ZardCardComponent, + LucideAngularModule, + ], + templateUrl: './reunion.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ReunionList { + // Core reactive state + rows = signal([]); + loading = signal(false); + total = signal(0); + upcomingReunions = signal(0); + pastReunions = signal(0); + uniqueHippodromes = signal(0); + + // pagination, sorting, search + page = signal(1); + perPage = signal(10); + search = signal(''); + sort = signal({ key: 'date', dir: 'asc' }); + pageSize = [10, 20, 50]; + + // modal management + modalOpen = signal(false); + modalTitle = signal('Nouvelle réunion'); + editingItem = signal | null>(null); + + @ViewChild(ReunionForm) formComp?: ReunionForm; + + cols: TableColumn[] = [ + { key: 'code', label: 'Code', sortable: true }, + { key: 'nom', label: 'Nom', sortable: true }, + { + key: 'date', + label: 'Date', + sortable: true, + cell: (r) => + new Date(r.date).toLocaleDateString('fr-FR', { + day: '2-digit', + month: 'short', + year: 'numeric', + }), + }, + { key: 'numero', label: 'Numéro', sortable: true }, + { + key: 'hippodrome.nom', + label: 'Hippodrome', + cell: (r) => + r.hippodrome ? `${r.hippodrome.nom} (${r.hippodrome.ville}, ${r.hippodrome.pays})` : '—', + }, + { + key: 'statut', + label: 'Statut', + cell: (r) => { + const colorMap: Record = { + PLANIFIEE: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300', + EN_COURS: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300', + TERMINEE: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300', + ANNULEE: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300', + }; + const labelMap: Record = { + PLANIFIEE: 'Planifiée', + EN_COURS: 'En cours', + TERMINEE: 'Terminée', + ANNULEE: 'Annulée', + }; + return `${ + labelMap[r.statut] + }`; + }, + }, + { + key: 'totalCourses', + label: 'Courses', + cell: (r) => (r.totalCourses ? r.totalCourses.toString() : '—'), + }, + { + key: 'createdAt', + label: 'Créée le', + cell: (r) => + new Date(r.createdAt).toLocaleDateString('fr-FR', { + day: '2-digit', + month: 'short', + year: 'numeric', + }), + }, + { + key: 'updatedAt', + label: 'Modifiée le', + cell: (r) => + new Date(r.updatedAt).toLocaleDateString('fr-FR', { + day: '2-digit', + month: 'short', + year: 'numeric', + }), + }, + ]; + + constructor(private api: ReunionService) { + // Effect will run only when one of these dependencies change + effect(() => { + const params = { + page: this.page(), + perPage: this.perPage(), + search: this.search(), + sortKey: this.sort().key, + sortDir: this.sort().dir, + }; + untracked(() => this.fetch(params)); // avoids recursive dependency + }); + } + + 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); + const meta = res.meta; + + this.upcomingReunions.set(res.meta['upcomingReunions'] ?? 0); + this.pastReunions.set(res.meta['pastReunions'] ?? 0); + this.uniqueHippodromes.set(res.meta['uniqueHippodromes'] ?? 0); + this.loading.set(false); + }, + error: () => { + this.rows.set([]); + this.total.set(0); + this.upcomingReunions.set(0); + this.pastReunions.set(0); + this.uniqueHippodromes.set(0); + this.loading.set(false); + }, + }); + } + + // === UI interactions === + onSearch(q: string) { + this.search.set(q); + this.page.set(1); // reset pagination on new search + } + + openCreate() { + this.modalTitle.set('Nouvelle réunion'); + this.editingItem.set(null); + queueMicrotask(() => this.modalOpen.set(true)); + } + + openEdit(row: ReunionType) { + this.modalTitle.set('Modifier la réunion'); + this.editingItem.set(row); + queueMicrotask(() => this.modalOpen.set(true)); + } + + closeModal() { + this.modalOpen.set(false); + } + + submitChildForm() { + this.formComp?.onSubmit(); + } + + onFormSave(payload: Partial) { + const current = this.editingItem(); + const req$ = current?.id + ? this.api.update(current.id, payload) + : this.api.create(payload as Omit); + req$.subscribe(() => { + this.closeModal(); + // Reset editing item to null to clear the form + this.editingItem.set(null); + // refetch current page + this.fetch({ + page: this.page(), + perPage: this.perPage(), + search: this.search(), + sortKey: this.sort().key, + sortDir: this.sort().dir, + }); + }); + } + + remove(row: ReunionType) { + if (!confirm(`Supprimer la réunion « ${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, + }) + ); + } +} diff --git a/src/app/dashboard/pages/roles/roles.html b/src/app/dashboard/pages/roles/roles.html new file mode 100644 index 0000000..52343d0 --- /dev/null +++ b/src/app/dashboard/pages/roles/roles.html @@ -0,0 +1,111 @@ +
+
+
+

Rôles & Permissions

+

Gérez les rôles et assignez les permissions

+
+ Nouveau rôle +
+ + + +
+ + +
+ + +
+
+
+
+ + +
+
+
+

Permissions

+

Gérez la liste des permissions disponibles

+
+ Nouvelle permission +
+ +
+ + + + + + + + + + @for (p of permissions(); track p.id) { + + + + + + } @if (!permissions().length) { + + + + } + +
CodeDescriptionActions
{{ p.name }}{{ p.description || '—' }} +
+ + +
+
+ Aucune permission définie. +
+
+
+ + + @if (modalOpen()) { + + } +
+ Annuler + Enregistrer +
+
+ + + + @if (permissionModalOpen()) { + + } +
+ Annuler + Enregistrer +
+
+
diff --git a/src/app/dashboard/pages/roles/roles.ts b/src/app/dashboard/pages/roles/roles.ts new file mode 100644 index 0000000..e1487c4 --- /dev/null +++ b/src/app/dashboard/pages/roles/roles.ts @@ -0,0 +1,241 @@ +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 { SearchBar } from '@shared/components/search-bar/search-bar'; +import { Modal } from '@shared/components/modal/modal'; +import { ZardButtonComponent } from '@shared/components/button/button.component'; +import { Permission, Role } from 'src/app/core/interfaces/role'; +import { RoleService } from 'src/app/core/services/role'; +import { SortDir } from '@shared/paging/paging'; +import { RoleForm } from '@shared/forms/role-form/role-form'; +import { PermissionForm } from '@shared/forms/permission-form/permission-form'; +import { toast } from 'ngx-sonner'; + +@Component({ + standalone: true, + selector: 'app-roles', + templateUrl: './roles.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + DataTable, + SearchBar, + Modal, + ZardButtonComponent, + RoleForm, + PermissionForm, + ], +}) +export class RolesPage { + rows = signal([]); + total = signal(0); + loading = signal(false); + permissions = signal([]); + page = signal(1); + perPage = signal(10); + search = signal(''); + sort = signal({ key: 'name', dir: 'asc' }); + + modalOpen = signal(false); + modalTitle = signal('Nouveau rôle'); + editingItem = signal(null); + + permissionModalOpen = signal(false); + permissionModalTitle = signal('Nouvelle permission'); + editingPermission = signal(null); + + @ViewChild(RoleForm) formComp?: RoleForm; + @ViewChild(PermissionForm) permFormComp?: PermissionForm; + + cols: TableColumn[] = [ + { key: 'name', label: 'Rôle', sortable: true }, + { key: 'description', label: 'Description', sortable: true }, + { key: 'permissions', label: 'Permissions', cell: (r) => r.permissions.length }, + ]; + + constructor(public api: RoleService) { + this.api.allPermissions().subscribe((list) => this.permissions.set(list)); + 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); + }, + error: () => { + this.rows.set([]); + this.total.set(0); + this.loading.set(false); + }, + }); + } + + onSearch(q: string) { + this.search.set(q); + this.page.set(1); + } + openCreate() { + this.modalTitle.set('Nouveau rôle'); + this.editingItem.set(null); + queueMicrotask(() => this.modalOpen.set(true)); + } + openEdit(row: Role) { + this.modalTitle.set('Modifier le rôle'); + this.editingItem.set(row); + queueMicrotask(() => this.modalOpen.set(true)); + } + closeModal() { + this.modalOpen.set(false); + } + submitChildForm() { + this.formComp?.onSubmit(); + } + + submitPermissionForm() { + this.permFormComp?.onSubmit(); + } + + onFormSave(payload: Partial) { + const current = this.editingItem(); + const req$ = current?.id + ? this.api.update(current.id, payload) + : this.api.create(payload as Omit); + req$.subscribe({ + next: (role) => { + this.closeModal(); + toast.success( + current?.id + ? `Le rôle « ${role?.name ?? ''} » a été mis à jour avec succès` + : `Le rôle « ${role?.name ?? ''} » a été créé avec succès` + ); + this.fetch({ + page: this.page(), + perPage: this.perPage(), + search: this.search(), + sortKey: this.sort().key, + sortDir: this.sort().dir as SortDir, + }); + }, + error: () => { + toast.error( + current?.id + ? 'Erreur lors de la mise à jour du rôle' + : 'Erreur lors de la création du rôle', + { duration: 5000 } + ); + }, + }); + } + + remove(row: Role) { + if (!confirm(`Supprimer le rôle « ${row.name} » ?`)) return; + this.api.delete(row.id).subscribe((result) => { + if (result.success) { + toast.success(`Le rôle « ${row.name} » a été supprimé avec succès`); + this.fetch({ + page: this.page(), + perPage: this.perPage(), + search: this.search(), + sortKey: this.sort().key, + sortDir: this.sort().dir as SortDir, + }); + } else { + toast.error(result.error || 'Erreur lors de la suppression du rôle', { + duration: 5000, + }); + } + }); + } + + // ------- Permissions CRUD ------- + + openCreatePermission() { + this.permissionModalTitle.set('Nouvelle permission'); + this.editingPermission.set(null); + this.permissionModalOpen.set(true); + } + + openEditPermission(p: Permission) { + this.permissionModalTitle.set('Modifier la permission'); + this.editingPermission.set(p); + this.permissionModalOpen.set(true); + } + + closePermissionModal() { + this.permissionModalOpen.set(false); + this.editingPermission.set(null); + } + + onPermissionFormSave(payload: Permission) { + const current = this.editingPermission(); + const isEdit = !!current?.id; + const req$ = isEdit + ? this.api.updatePermission(current.id, { + name: payload.name, + description: payload.description, + }) + : this.api.createPermission({ name: payload.name, description: payload.description }); + + req$.subscribe({ + next: (perm) => { + this.closePermissionModal(); + const permName = perm?.name || payload.name; + toast.success( + isEdit + ? `La permission « ${permName} » a été mise à jour avec succès` + : `La permission « ${permName} » a été créée avec succès` + ); + this.api.allPermissions().subscribe((list) => this.permissions.set(list)); + }, + error: (err) => { + const permName = payload.name; + toast.error( + isEdit + ? `Erreur lors de la mise à jour de la permission « ${permName} »` + : `Erreur lors de la création de la permission « ${permName} »`, + { duration: 5000 } + ); + }, + }); + } + + removePermission(p: Permission) { + if (!confirm(`Supprimer la permission « ${p.name} » ?`)) return; + this.api.deletePermission(p.id).subscribe((result) => { + if (result.success) { + toast.success(`La permission « ${p.name} » a été supprimée avec succès`); + this.api.allPermissions().subscribe((list) => this.permissions.set(list)); + } else { + toast.error(result.error || 'Erreur lors de la suppression de la permission', { + duration: 5000, + }); + } + }); + } +} diff --git a/src/app/dashboard/pages/tpe/tpe.html b/src/app/dashboard/pages/tpe/tpe.html new file mode 100644 index 0000000..314e04e --- /dev/null +++ b/src/app/dashboard/pages/tpe/tpe.html @@ -0,0 +1,194 @@ +
+
+

TPES (Terminal Point de Vente)

+ Nouvel équipement +
+ + + @if (statsLoading()) { +
+ @for (i of [1, 2, 3, 4]; track i) { +
+
+
+
+ } +
+ } @else { +
+
+
Total TPEs
+
{{ assignmentStats().total }}
+
+
+
Assignés
+
{{ assignmentStats().assignes }}
+
+
+
Disponibles
+
{{ assignmentStats().disponibles }}
+
+
+
Valides
+
{{ statsByStatut()['VALIDE'] || 0 }}
+
+
+ } + + +
+ Filtrer par statut: + + @for (statut of allStatuses; track statut) { + + } +
+ + + + + +
+ @if (row.assigne) { + + } @else { + + } +
+ +
+ @for (statut of allStatuses; track statut) { + + } +
+
+ +
+ + +
+
+
+ + +
+ + + +
+ Annuler + Enregistrer +
+
+ + + +
+ @if (agentsLoading()) { +
Chargement des agents...
+ } @else if (agents().length === 0) { +
Aucun agent actif disponible
+ } @else { + + +
+ + @for (agent of agents(); track agent.id) { + + {{ agent.code }} - {{ agent.nom }} {{ agent.prenom }} + + } + +
+
+ } +
+
+ Annuler + +
+
diff --git a/src/app/dashboard/pages/tpe/tpe.ts b/src/app/dashboard/pages/tpe/tpe.ts new file mode 100644 index 0000000..cf80d36 --- /dev/null +++ b/src/app/dashboard/pages/tpe/tpe.ts @@ -0,0 +1,487 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + ViewChild, + effect, + signal, + untracked, + OnInit, +} 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 { ZardMenuModule } from '@shared/components/menu/menu.module'; +import { ZardTooltipModule } from '@shared/components/tooltip/tooltip'; +import { SortDir } from '@shared/paging/paging'; +import { TpeDevice, TpeStatus } from 'src/app/core/interfaces/tpe'; +import { TpeService } from 'src/app/core/services/tpe'; +import { TpeForm } from '@shared/forms/tpe-form/tpe-form'; +import { Agent } from 'src/app/core/interfaces/agent'; +import { AgentService } from 'src/app/core/services/agent'; +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 { forkJoin, Subject } from 'rxjs'; +import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators'; + +@Component({ + standalone: true, + selector: 'app-tpe-list', + templateUrl: './tpe.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + DataTable, + Paginator, + SearchBar, + Modal, + ZardButtonComponent, + TpeForm, + ZardMenuModule, + ZardTooltipModule, + ZardSelectComponent, + ZardSelectItemComponent, + ZardFormModule, + ], +}) +export class TpePage implements OnInit { + rows = signal([]); + total = signal(0); + loading = signal(false); + + page = signal(1); + perPage = signal(10); + search = signal(''); + sort = signal({ key: 'imei', dir: 'asc' }); + selectedStatut = signal(null); + + modalOpen = signal(false); + modalTitle = signal('Nouvel équipement'); + editingItem = signal(null); + + // Agent assignment modal + assignModalOpen = signal(false); + assigningTpe = signal(null); + agents = signal([]); + selectedAgentId = signal(''); + agentsLoading = signal(false); + + // Stats + statsByStatut = signal>({}); + assignmentStats = signal({ total: 0, assignes: 0, disponibles: 0 }); + statsLoading = signal(false); + + // Live search + private searchSubject = new Subject(); + + @ViewChild(TpeForm) formComp?: TpeForm; + + formatStatut(statut: string): string { + const statutMap: Record = { + 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[] = [ + { key: 'imei', label: 'IMEI', sortable: true }, + { key: 'serial', label: 'N° de Série', sortable: true }, + { key: 'type', label: 'Type', sortable: true }, + { key: 'marque', label: 'Marque', sortable: true }, + { key: 'modele', label: 'Modèle', sortable: true }, + { key: 'statut', label: 'Statut', sortable: true, cell: (d) => this.formatStatut(d.statut) }, + { + key: 'assigne', + label: 'Assigné à', + cell: (d) => { + if (!d.assigne || !d.agent) { + return 'Non assigné'; + } + const agent = d.agent; + const code = agent.code + ? `${agent.code}` + : ''; + const name = + agent.nom && agent.prenom + ? `
${agent.nom} ${agent.prenom}
` + : agent.nom || agent.prenom + ? `
${agent.nom || agent.prenom}
` + : ''; + const phone = agent.phone + ? `
${agent.phone}
` + : ''; + const zone = agent.zone + ? `
Zone: ${agent.zone}
` + : ''; + + const parts = [code, name, phone, zone].filter(Boolean); + if (parts.length === 0) { + return 'Agent assigné'; + } + return `
${parts.join('')}
`; + }, + }, + ]; + + allStatuses: TpeStatus[] = [ + 'VALIDE', + 'INVALIDE', + 'EN_PANNE', + 'BLOQUE', + 'DISPONIBLE', + 'AFFECTE', + 'EN_MAINTENANCE', + 'HORS_SERVICE', + 'VOLE', + ]; + + constructor(private api: TpeService, private agentService: AgentService) { + effect(() => { + // Only trigger fetch when page, perPage, or sort changes (not search - handled by searchSubject) + const searchValue = this.search(); + const params = { + page: this.page(), + perPage: this.perPage(), + search: searchValue, + sortKey: this.sort().key, + sortDir: this.sort().dir as SortDir, + }; + // Only fetch if search is empty (search is handled by searchSubject) + if (!searchValue.trim()) { + untracked(() => this.fetch(params)); + } + }); + + // Setup live search with debounce + this.searchSubject + .pipe( + debounceTime(300), + distinctUntilChanged(), + switchMap((query) => { + if (query.trim()) { + return this.api.search(query); + } else { + // If empty, use normal list + return this.api.list({ + page: this.page(), + perPage: this.perPage(), + search: '', + sortKey: this.sort().key, + sortDir: this.sort().dir as SortDir, + }); + } + }) + ) + .subscribe({ + next: (res) => { + if (Array.isArray(res)) { + // Search API returns array + this.rows.set(res); + this.total.set(res.length); + } else { + // List returns PagedResult + this.rows.set(res.data); + this.total.set(res.meta.total); + } + this.loading.set(false); + }, + error: (err) => { + console.error('Search error:', err); + this.rows.set([]); + this.total.set(0); + this.loading.set(false); + }, + }); + } + + ngOnInit() { + this.loadStats(); + // Initial fetch if no search query + if (!this.search().trim()) { + this.fetch({ + page: this.page(), + perPage: this.perPage(), + search: '', + sortKey: this.sort().key, + sortDir: this.sort().dir as SortDir, + }); + } + } + + loadStats() { + this.statsLoading.set(true); + forkJoin({ + byStatut: this.api.getCountByStatut(), + assignes: this.api.getAssignesStats(), + }).subscribe({ + next: ({ byStatut, assignes }) => { + this.statsByStatut.set(byStatut || {}); + // Calculate total from statsByStatut + const total = Object.values(byStatut || {}).reduce((sum, count) => sum + count, 0); + // Calculate disponibles (total - assignes) + const disponibles = Math.max(0, total - (assignes || 0)); + this.assignmentStats.set({ + total: total, + assignes: assignes || 0, + disponibles: disponibles, + }); + this.statsLoading.set(false); + }, + error: (err) => { + console.error('Error loading stats:', err); + this.statsByStatut.set({}); + this.assignmentStats.set({ total: 0, assignes: 0, disponibles: 0 }); + this.statsLoading.set(false); + }, + }); + } + + private fetch(params: { + page: number; + perPage: number; + search: string; + sortKey: string; + sortDir: SortDir; + }) { + // Don't fetch if there's a search query - it's handled by searchSubject + const searchQuery = params.search.trim(); + if (searchQuery) { + return; // Search is handled by searchSubject subscription + } + + this.loading.set(true); + const statut = this.selectedStatut(); + + if (statut) { + // Filter by statut - returns array + this.api.getByStatut(statut).subscribe({ + next: (res: TpeDevice[]) => { + this.rows.set(res); + this.total.set(res.length); + this.loading.set(false); + }, + error: () => { + this.rows.set([]); + this.total.set(0); + this.loading.set(false); + }, + }); + } else { + // Normal list with pagination + this.api.list(params).subscribe({ + next: (res) => { + this.rows.set(res.data); + this.total.set(res.meta.total); + this.loading.set(false); + }, + error: () => { + this.rows.set([]); + this.total.set(0); + this.loading.set(false); + }, + }); + } + } + + onSearch(q: string) { + this.search.set(q); + this.page.set(1); + // Trigger search via subject for live search + if (q.trim()) { + this.loading.set(true); + this.searchSubject.next(q); + } else { + // If empty, fetch normally + this.fetch({ + page: this.page(), + perPage: this.perPage(), + search: '', + sortKey: this.sort().key, + sortDir: this.sort().dir as SortDir, + }); + } + } + + onStatutFilter(statut: TpeStatus | null) { + this.selectedStatut.set(statut); + this.page.set(1); + this.fetch({ + page: this.page(), + perPage: this.perPage(), + search: this.search(), + sortKey: this.sort().key, + sortDir: this.sort().dir as SortDir, + }); + } + + onUpdateStatut(row: TpeDevice, newStatut: TpeStatus) { + if (!confirm(`Changer le statut de ${row.imei} vers ${this.formatStatut(newStatut)} ?`)) return; + this.api.updateStatut(row.id, newStatut).subscribe({ + next: () => { + this.fetch({ + page: this.page(), + perPage: this.perPage(), + search: this.search(), + sortKey: this.sort().key, + sortDir: this.sort().dir as SortDir, + }); + this.loadStats(); + }, + }); + } + + onLiberer(row: TpeDevice) { + if (!confirm(`Libérer le TPE ${row.imei} ?`)) return; + this.api.liberer(row.id).subscribe({ + next: () => { + this.fetch({ + page: this.page(), + perPage: this.perPage(), + search: this.search(), + sortKey: this.sort().key, + sortDir: this.sort().dir as SortDir, + }); + this.loadStats(); + }, + }); + } + + onAssigner(row: TpeDevice) { + this.assigningTpe.set(row); + this.selectedAgentId.set(''); + this.loadAgents(); + this.assignModalOpen.set(true); + } + + loadAgents() { + this.agentsLoading.set(true); + // Load active agents only + this.agentService.getByStatut('ACTIF').subscribe({ + next: (agents) => { + this.agents.set(agents); + this.agentsLoading.set(false); + }, + error: () => { + this.agents.set([]); + this.agentsLoading.set(false); + }, + }); + } + + confirmAssign() { + const tpe = this.assigningTpe(); + const agentId = this.selectedAgentId(); + if (!tpe || !agentId) { + alert('Veuillez sélectionner un agent'); + return; + } + this.api.assigner(tpe.id, agentId).subscribe({ + next: () => { + this.assignModalOpen.set(false); + this.assigningTpe.set(null); + this.selectedAgentId.set(''); + this.fetch({ + page: this.page(), + perPage: this.perPage(), + search: this.search(), + sortKey: this.sort().key, + sortDir: this.sort().dir as SortDir, + }); + this.loadStats(); + }, + error: () => { + alert("Erreur lors de l'assignation du TPE"); + }, + }); + } + + closeAssignModal() { + this.assignModalOpen.set(false); + this.assigningTpe.set(null); + this.selectedAgentId.set(''); + } + openCreate() { + this.modalTitle.set('Nouvel équipement'); + this.editingItem.set(null); + queueMicrotask(() => this.modalOpen.set(true)); + } + openEdit(row: TpeDevice) { + this.modalTitle.set("Modifier l'équipement"); + this.editingItem.set(row); + queueMicrotask(() => this.modalOpen.set(true)); + } + closeModal() { + this.modalOpen.set(false); + setTimeout(() => { + this.editingItem.set(null); + }, 0); + } + + submitChildForm() { + this.formComp?.onSubmit(); + } + + onFormSave(payload: Partial) { + const current = this.editingItem(); + const isCreating = !current?.id; + const req$ = current?.id + ? this.api.update(current.id, payload) + : this.api.create(payload as Omit); + req$.subscribe({ + next: (result) => { + // For update, check if result is valid (update can return undefined on error) + if (current?.id && !result) { + console.error('Update failed - result is undefined'); + // Don't close modal, let user retry + return; + } + // Success - close modal first + this.modalOpen.set(false); + // Then reset form and clear editing item after a short delay + setTimeout(() => { + this.editingItem.set(null); + this.formComp?.resetForm(); + }, 100); + // Refresh data + this.fetch({ + page: this.page(), + perPage: this.perPage(), + search: this.search(), + sortKey: this.sort().key, + sortDir: this.sort().dir as SortDir, + }); + this.loadStats(); + }, + error: (err) => { + console.error('Error saving TPE:', err); + // Don't close modal on error, let user fix and retry + // Form stays filled so user can correct and resubmit + }, + }); + } + + remove(row: TpeDevice) { + if (!confirm(`Supprimer l\'équipement IMEI ${row.imei} ?`)) 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, + }); + this.loadStats(); + }); + } +} diff --git a/src/app/dashboard/pages/users/users.html b/src/app/dashboard/pages/users/users.html new file mode 100644 index 0000000..35d248c --- /dev/null +++ b/src/app/dashboard/pages/users/users.html @@ -0,0 +1,41 @@ +
+
+

+ La liste des utilisateurs +

+ Nouvel utilisateur +
+ + + + + +
+ + +
+
+
+ + +
+ + + +
+ Annuler + Enregistrer +
+
diff --git a/src/app/dashboard/pages/users/users.ts b/src/app/dashboard/pages/users/users.ts new file mode 100644 index 0000000..25f6250 --- /dev/null +++ b/src/app/dashboard/pages/users/users.ts @@ -0,0 +1,198 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + ViewChild, + signal, + effect, + untracked, +} from '@angular/core'; +import { DataTable, TableColumn, SortState } from '@shared/components/data-table/data-table'; +import { Paginator } from '@shared/components/paginator/paginator'; +import { SearchBar } from '@shared/components/search-bar/search-bar'; +import { Modal } from '@shared/components/modal/modal'; +import { ZardButtonComponent } from '@shared/components/button/button.component'; +import { SortDir } from '@shared/paging/paging'; +import { User } from 'src/app/core/interfaces/user'; +import { UserService } from 'src/app/core/services/user'; +import { Role } from 'src/app/core/interfaces/role'; +import { RoleService } from 'src/app/core/services/role'; +import { UserForm } from '@shared/forms/user-form/user-form'; +import { toast } from 'ngx-sonner'; + +@Component({ + standalone: true, + selector: 'app-users', + templateUrl: './users.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, DataTable, Paginator, SearchBar, Modal, ZardButtonComponent, UserForm], +}) +export class UsersPage { + rows = signal([]); + total = signal(0); + loading = signal(false); + roleMap = new Map(); + + page = signal(1); + perPage = signal(10); + search = signal(''); + sort = signal({ key: 'nom', dir: 'asc' }); + + modalOpen = signal(false); + modalTitle = signal('Nouvel utilisateur'); + editingItem = signal(null); + + @ViewChild(UserForm) formComp?: UserForm; + + cols: TableColumn[] = [ + { key: 'nom', label: 'Nom', sortable: true }, + { key: 'prenom', label: 'Prénom', sortable: true }, + { key: 'identifiant', label: 'Identifiant', sortable: true }, + { key: 'matriculeAgent', label: 'Matricule', sortable: true }, + { + key: 'role.name', + label: 'Rôle', + sortable: true, + cell: (u) => this.roleMap.get(u.roleId) ?? u.role?.name ?? '—', + }, + { key: 'statut', label: 'Statut', sortable: true }, + { + key: 'derniereConnexion', + label: 'Dernière connexion', + cell: (u) => + u.derniereConnexion ? new Date(u.derniereConnexion).toLocaleDateString('fr-FR') : '—', + }, + { + key: 'restrictionConnexion', + label: 'Restr. Conn.', + cell: (u) => String(u.restrictionConnexion), + }, + { + key: 'restrictionAutomatique', + label: 'Restr. Auto', + cell: (u) => String(u.restrictionAutomatique), + }, + { key: 'nombreIpAutorise', label: 'IP Autorisé' }, + { key: 'nombreIpAutoAutorise', label: 'IP Auto' }, + ]; + + constructor(private api: UserService, private roleService: RoleService) { + this.roleService + .list({ page: 1, perPage: 100, search: '', sortKey: 'name', sortDir: 'asc' } as any) + .subscribe((res) => (res.data as Role[]).forEach((r) => this.roleMap.set(r.id, r.name))); + 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); + }, + error: () => { + this.rows.set([]); + this.total.set(0); + this.loading.set(false); + }, + }); + } + + onSearch(q: string) { + this.search.set(q); + this.page.set(1); + } + + openCreate() { + this.modalTitle.set('Nouvel utilisateur'); + this.editingItem.set(null); + queueMicrotask(() => this.modalOpen.set(true)); + } + + openEdit(row: User) { + this.modalTitle.set("Modifier l'utilisateur"); + this.editingItem.set(row); + queueMicrotask(() => this.modalOpen.set(true)); + } + + closeModal() { + this.modalOpen.set(false); + } + + submitChildForm() { + this.formComp?.onSubmit(); + } + + onFormSave(payload: Partial) { + const current = this.editingItem(); + const req$ = current?.id + ? this.api.update(current.id, payload) + : this.api.create(payload as Omit); + req$.subscribe({ + next: (user) => { + this.closeModal(); + toast.success( + current?.id + ? `L'utilisateur « ${user?.nom ?? ''} ${ + user?.prenom ?? '' + } » a été mis à jour avec succès` + : `L'utilisateur « ${user?.nom ?? ''} ${user?.prenom ?? ''} » a été créé avec succès` + ); + this.fetch({ + page: this.page(), + perPage: this.perPage(), + search: this.search(), + sortKey: this.sort().key, + sortDir: this.sort().dir as SortDir, + }); + }, + error: () => { + toast.error( + current?.id + ? "Erreur lors de la mise à jour de l'utilisateur" + : "Erreur lors de la création de l'utilisateur", + { duration: 5000 } + ); + }, + }); + } + + remove(row: User) { + if (!confirm(`Supprimer l\'utilisateur « ${row.nom} ${row.prenom} » ?`)) return; + this.api.delete(row.id).subscribe({ + next: (ok) => { + if (ok) { + toast.success(`L'utilisateur « ${row.nom} ${row.prenom} » a été supprimé avec succès`); + this.fetch({ + page: this.page(), + perPage: this.perPage(), + search: this.search(), + sortKey: this.sort().key, + sortDir: this.sort().dir as SortDir, + }); + } else { + toast.error("Erreur lors de la suppression de l'utilisateur", { duration: 5000 }); + } + }, + error: () => { + toast.error("Erreur lors de la suppression de l'utilisateur", { duration: 5000 }); + }, + }); + } +} diff --git a/src/app/shared/components/accordion/accordion-item.component.ts b/src/app/shared/components/accordion/accordion-item.component.ts new file mode 100644 index 0000000..28edb4b --- /dev/null +++ b/src/app/shared/components/accordion/accordion-item.component.ts @@ -0,0 +1,76 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, computed, inject, input, signal, ViewEncapsulation } from '@angular/core'; + +import { ZardAccordionComponent } from './accordion.component'; + +import type { ClassValue } from 'clsx'; + +@Component({ + selector: 'z-accordion-item', + exportAs: 'zAccordionItem', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ + +
+
+
+ +
+
+
+
+ `, +}) +export class ZardAccordionItemComponent { + private cdr = inject(ChangeDetectorRef); + + readonly zTitle = input(''); + readonly zValue = input(''); + readonly class = input(''); + + private isOpenSignal = signal(false); + + accordion?: ZardAccordionComponent; + + isOpen = computed(() => this.isOpenSignal()); + + setOpen(open: boolean): void { + this.isOpenSignal.set(open); + this.cdr.markForCheck(); + } + + toggle(): void { + if (this.accordion) { + this.accordion.toggleItem(this); + } else { + this.setOpen(!this.isOpen()); + } + } +} diff --git a/src/app/shared/components/accordion/accordion.component.ts b/src/app/shared/components/accordion/accordion.component.ts new file mode 100644 index 0000000..c046bbb --- /dev/null +++ b/src/app/shared/components/accordion/accordion.component.ts @@ -0,0 +1,85 @@ +import { AfterContentInit, ChangeDetectionStrategy, Component, contentChildren, input, ViewEncapsulation } from '@angular/core'; + +import { ZardAccordionItemComponent } from './accordion-item.component'; + +import type { ClassValue } from 'clsx'; + +@Component({ + selector: 'z-accordion', + exportAs: 'zAccordion', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ +
+ `, +}) +export class ZardAccordionComponent implements AfterContentInit { + readonly items = contentChildren(ZardAccordionItemComponent); + + readonly class = input(''); + readonly zType = input<'single' | 'multiple'>('single'); + readonly zCollapsible = input(true); + readonly zDefaultValue = input(''); + + ngAfterContentInit(): void { + setTimeout(() => { + this.items().forEach(item => { + item.accordion = this; + }); + + const defaultValue = this.zDefaultValue(); + if (defaultValue) { + if (typeof defaultValue === 'string') { + const item = this.items().find(i => i.zValue() === defaultValue); + if (item) { + item.setOpen(true); + } + } else if (Array.isArray(defaultValue)) { + defaultValue.forEach(value => { + const item = this.items().find(i => i.zValue() === value); + if (item) { + item.setOpen(true); + } + }); + } + } + }); + } + + toggleItem(selectedItem: ZardAccordionItemComponent): void { + const isClosing = selectedItem.isOpen(); + + if (this.zType() === 'single') { + if (isClosing && !this.zCollapsible()) { + return; + } + + this.items().forEach(item => { + const shouldBeOpen = item === selectedItem ? !item.isOpen() : false; + item.setOpen(shouldBeOpen); + }); + } else { + if (isClosing && !this.zCollapsible()) { + const openItemsCount = this.countOpenItems(); + if (openItemsCount <= 1) { + return; + } + } + + selectedItem.setOpen(!selectedItem.isOpen()); + } + } + + private countOpenItems(): number { + let count = 0; + this.items().forEach(item => { + if (item.isOpen()) { + count++; + } + }); + return count; + } +} diff --git a/src/app/shared/components/alert-dialog/alert-dialog-ref.ts b/src/app/shared/components/alert-dialog/alert-dialog-ref.ts new file mode 100644 index 0000000..7ebbf0d --- /dev/null +++ b/src/app/shared/components/alert-dialog/alert-dialog-ref.ts @@ -0,0 +1,104 @@ +import { filter, Observable, Subject, takeUntil } from 'rxjs'; + +import { OverlayRef } from '@angular/cdk/overlay'; + +import { OnClickCallback, ZardAlertDialogComponent, ZardAlertDialogOptions } from './alert-dialog.component'; + +export class ZardAlertDialogRef { + componentInstance?: T; + private destroy$ = new Subject(); + private isClosing = false; + private readonly afterClosedSubject: Subject = new Subject(); + + constructor( + private overlayRef: OverlayRef, + private config: ZardAlertDialogOptions, + private containerInstance: ZardAlertDialogComponent, + ) { + containerInstance.cancelTriggered.subscribe(() => { + this.handleCancel(); + }); + + containerInstance.okTriggered.subscribe(() => { + this.handleOk(); + }); + + this.handleMaskClick(); + this.handleEscapeKey(); + } + + close(dialogResult?: R): void { + if (this.isClosing) { + return; + } + + this.isClosing = true; + this.containerInstance.state.set('close'); + + setTimeout(() => { + if (this.overlayRef) { + this.overlayRef.dispose(); + } + + this.afterClosedSubject.next(dialogResult); + this.afterClosedSubject.complete(); + + if (!this.destroy$.closed) { + this.destroy$.next(); + this.destroy$.complete(); + } + }, 150); + } + + afterClosed(): Observable { + return this.afterClosedSubject.asObservable(); + } + + private handleCancel() { + const cancelFn = this.config.zOnCancel; + + if (typeof cancelFn === 'function') { + const result = (cancelFn as OnClickCallback)(this.componentInstance as T); + if (result !== false) { + this.close(result as R); + } + } else { + this.close(); + } + } + + private handleOk() { + const okFn = this.config.zOnOk; + + if (typeof okFn === 'function') { + const result = (okFn as OnClickCallback)(this.componentInstance as T); + if (result !== false) { + this.close(result as R); + } + } else { + this.close(); + } + } + + private handleMaskClick() { + const hasMaskClosable = this.config.zMaskClosable ?? true; + if (hasMaskClosable) { + this.overlayRef + .outsidePointerEvents() + .pipe(takeUntil(this.destroy$)) + .subscribe(() => { + this.close(); + }); + } + } + + private handleEscapeKey() { + this.overlayRef + .keydownEvents() + .pipe( + filter(event => event.key === 'Escape'), + takeUntil(this.destroy$), + ) + .subscribe(() => this.close()); + } +} diff --git a/src/app/shared/components/alert-dialog/alert-dialog.component.html b/src/app/shared/components/alert-dialog/alert-dialog.component.html new file mode 100644 index 0000000..ae0670c --- /dev/null +++ b/src/app/shared/components/alert-dialog/alert-dialog.component.html @@ -0,0 +1,35 @@ +
+ @if (config.zTitle || config.zDescription) { +
+ @if (config.zTitle) { +

{{ config.zTitle }}

+ } + + @if (config.zDescription) { +

{{ config.zDescription }}

+ } +
+ } + +
+ + + @if (isStringContent) { +
+ } +
+ +
+ @if (config.zCancelText !== null) { + + } + + @if (config.zOkText !== null) { + + } +
+
diff --git a/src/app/shared/components/alert-dialog/alert-dialog.component.ts b/src/app/shared/components/alert-dialog/alert-dialog.component.ts new file mode 100644 index 0000000..eb40351 --- /dev/null +++ b/src/app/shared/components/alert-dialog/alert-dialog.component.ts @@ -0,0 +1,154 @@ +import { animate, state, style, transition, trigger } from '@angular/animations'; +import { A11yModule } from '@angular/cdk/a11y'; +import { OverlayModule } from '@angular/cdk/overlay'; +import { BasePortalOutlet, CdkPortalOutlet, ComponentPortal, PortalModule, TemplatePortal } from '@angular/cdk/portal'; +import { + ChangeDetectionStrategy, + Component, + ComponentRef, + computed, + ElementRef, + EmbeddedViewRef, + EventEmitter, + inject, + NgModule, + output, + signal, + TemplateRef, + Type, + viewChild, + ViewContainerRef, + ViewEncapsulation, +} from '@angular/core'; +import { ClassValue } from 'clsx'; + +import { alertDialogVariants, ZardAlertDialogVariants } from './alert-dialog.variants'; +import { ZardButtonComponent } from '../button/button.component'; +import { ZardAlertDialogService } from './alert-dialog.service'; +import { ZardAlertDialogRef } from './alert-dialog-ref'; +import { generateId, mergeClasses } from '@shared/utils/merge-classes'; + +const noopFun = () => void 0; +export type OnClickCallback = (instance: T) => false | void | object; + +export class ZardAlertDialogOptions { + zCancelText?: string | null; + zClosable?: boolean; + zContent?: string | TemplateRef | Type; + zCustomClasses?: ClassValue; + zData?: object; + zDescription?: string; + zIcon?: string; + zMaskClosable?: boolean; + zOkDestructive?: boolean; + zOkDisabled?: boolean; + zOkText?: string | null; + zOnCancel?: EventEmitter | OnClickCallback = noopFun; + zOnOk?: EventEmitter | OnClickCallback = noopFun; + zTitle?: string | TemplateRef; + zType?: ZardAlertDialogVariants['zType']; + zViewContainerRef?: ViewContainerRef; + zWidth?: string; +} + +@Component({ + selector: 'z-alert-dialog', + exportAs: 'zAlertDialog', + standalone: true, + imports: [OverlayModule, PortalModule, ZardButtonComponent, A11yModule], + templateUrl: './alert-dialog.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + host: { + '[class]': 'classes()', + '[@alertDialogAnimation]': 'state()', + '[style.width]': 'config.zWidth ? config.zWidth : null', + role: 'alertdialog', + '[attr.aria-modal]': 'true', + '[attr.aria-labelledby]': 'titleId()', + '[attr.aria-describedby]': 'descriptionId()', + }, + styles: [ + ` + z-alert-dialog { + inset: 0; + margin: auto; + width: fit-content; + height: fit-content; + transform-origin: center center; + } + `, + ], + animations: [ + trigger('alertDialogAnimation', [ + state('close', style({ opacity: 0, transform: 'scale(0.9)' })), + state('open', style({ opacity: 1, transform: 'scale(1)' })), + transition('close => open', animate('150ms ease-out')), + transition('open => close', animate('150ms ease-in')), + ]), + ], +}) +export class ZardAlertDialogComponent extends BasePortalOutlet { + private readonly host = inject(ElementRef); + protected readonly config = inject(ZardAlertDialogOptions); + + protected readonly classes = computed(() => + mergeClasses( + alertDialogVariants({ + zType: this.config.zType, + }), + this.config.zCustomClasses, + ), + ); + + private alertDialogId = generateId('alert-dialog'); + protected readonly titleId = computed(() => (this.config.zTitle ? `${this.alertDialogId}-title` : null)); + protected readonly descriptionId = computed(() => (this.config.zDescription ? `${this.alertDialogId}-description` : null)); + + public alertDialogRef?: ZardAlertDialogRef; + + protected readonly isStringContent = typeof this.config.zContent === 'string'; + + readonly portalOutlet = viewChild.required(CdkPortalOutlet); + + okTriggered = output(); + cancelTriggered = output(); + state = signal<'close' | 'open'>('close'); + + constructor() { + super(); + } + + getNativeElement(): HTMLElement { + return this.host.nativeElement; + } + + attachComponentPortal(portal: ComponentPortal): ComponentRef { + if (this.portalOutlet()?.hasAttached()) { + throw Error('Attempting to attach alert dialog content after content is already attached'); + } + return this.portalOutlet()?.attachComponentPortal(portal); + } + + attachTemplatePortal(portal: TemplatePortal): EmbeddedViewRef { + if (this.portalOutlet()?.hasAttached()) { + throw Error('Attempting to attach alert dialog content after content is already attached'); + } + + return this.portalOutlet()?.attachTemplatePortal(portal); + } + + onOkClick() { + this.okTriggered.emit(); + } + + onCancelClick() { + this.cancelTriggered.emit(); + } +} + +@NgModule({ + imports: [ZardButtonComponent, ZardAlertDialogComponent, OverlayModule, PortalModule, A11yModule], + providers: [ZardAlertDialogService], +}) +export class ZardAlertDialogModule {} diff --git a/src/app/shared/components/alert-dialog/alert-dialog.service.ts b/src/app/shared/components/alert-dialog/alert-dialog.service.ts new file mode 100644 index 0000000..0868d39 --- /dev/null +++ b/src/app/shared/components/alert-dialog/alert-dialog.service.ts @@ -0,0 +1,144 @@ +import { ComponentType, Overlay, OverlayConfig, OverlayRef } from '@angular/cdk/overlay'; +import { ComponentPortal, TemplatePortal } from '@angular/cdk/portal'; +import { inject, Injectable, InjectionToken, Injector, PLATFORM_ID, TemplateRef } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; + +import { ZardAlertDialogRef } from './alert-dialog-ref'; +import { ZardAlertDialogComponent, ZardAlertDialogOptions } from './alert-dialog.component'; + +type ContentType = ComponentType | TemplateRef | string | undefined; +export const Z_ALERT_MODAL_DATA = new InjectionToken('Z_ALERT_MODAL_DATA'); + +@Injectable({ + providedIn: 'root', +}) +export class ZardAlertDialogService { + private overlay = inject(Overlay); + private injector = inject(Injector); + private platformId = inject(PLATFORM_ID); + + create(config: ZardAlertDialogOptions): ZardAlertDialogRef { + return this.open(config.zContent, config); + } + + confirm( + config: Omit, 'zOkText' | 'zCancelText'> & { + zOkText?: string; + zCancelText?: string; + }, + ): ZardAlertDialogRef { + const confirmConfig: ZardAlertDialogOptions = { + ...config, + zOkText: config.zOkText || 'Confirm', + zCancelText: config.zCancelText || 'Cancel', + zOkDestructive: config.zOkDestructive ?? false, + zIcon: config.zIcon, + zType: config.zType || 'default', + }; + return this.create(confirmConfig); + } + + warning(config: Omit, 'zOkText'> & { zOkText?: string }): ZardAlertDialogRef { + const warningConfig: ZardAlertDialogOptions = { + ...config, + zOkText: config.zOkText || 'OK', + zCancelText: null, + zIcon: config.zIcon || 'alert-triangle', + zType: config.zType || 'warning', + }; + return this.create(warningConfig); + } + + info(config: Omit, 'zOkText'> & { zOkText?: string }): ZardAlertDialogRef { + const infoConfig: ZardAlertDialogOptions = { + ...config, + zOkText: config.zOkText || 'OK', + zCancelText: null, + zIcon: config.zIcon || 'info', + zType: config.zType || 'default', + }; + return this.create(infoConfig); + } + + private open(componentOrTemplateRef: ContentType, config: ZardAlertDialogOptions) { + const overlayRef = this.createOverlay(); + + if (!overlayRef) { + // Return a mock alert dialog ref for SSR environments + return new ZardAlertDialogRef(undefined as any, config, undefined as any); + } + + const alertDialogContainer = this.attachAlertDialogContainer(overlayRef, config); + + const alertDialogRef = this.attachAlertDialogContent(componentOrTemplateRef, alertDialogContainer, overlayRef, config); + alertDialogContainer.alertDialogRef = alertDialogRef; + + return alertDialogRef; + } + + private createOverlay(): OverlayRef | undefined { + if (isPlatformBrowser(this.platformId)) { + const overlayConfig = new OverlayConfig({ + hasBackdrop: true, + backdropClass: 'cdk-overlay-dark-backdrop', + positionStrategy: this.overlay.position().global(), + }); + + return this.overlay.create(overlayConfig); + } + return undefined; + } + + private attachAlertDialogContainer(overlayRef: OverlayRef, config: ZardAlertDialogOptions) { + const injector = Injector.create({ + parent: this.injector, + providers: [ + { provide: OverlayRef, useValue: overlayRef }, + { provide: ZardAlertDialogOptions, useValue: config }, + ], + }); + + const containerPortal = new ComponentPortal>(ZardAlertDialogComponent, config.zViewContainerRef, injector); + const containerRef = overlayRef.attach>(containerPortal); + + setTimeout(() => { + containerRef.instance.state.set('open'); + }, 0); + + return containerRef.instance; + } + + private attachAlertDialogContent( + componentOrTemplateRef: ContentType, + alertDialogContainer: ZardAlertDialogComponent, + overlayRef: OverlayRef, + config: ZardAlertDialogOptions, + ) { + const alertDialogRef = new ZardAlertDialogRef(overlayRef, config, alertDialogContainer); + + if (componentOrTemplateRef instanceof TemplateRef) { + alertDialogContainer.attachTemplatePortal( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + new TemplatePortal(componentOrTemplateRef, null!, { + alertDialogRef: alertDialogRef, + } as any), + ); + } else if (componentOrTemplateRef && typeof componentOrTemplateRef !== 'string') { + const injector = this.createInjector(alertDialogRef, config); + const contentRef = alertDialogContainer.attachComponentPortal(new ComponentPortal(componentOrTemplateRef, config.zViewContainerRef, injector)); + alertDialogRef.componentInstance = contentRef.instance; + } + + return alertDialogRef; + } + + private createInjector(alertDialogRef: ZardAlertDialogRef, config: ZardAlertDialogOptions) { + return Injector.create({ + parent: this.injector, + providers: [ + { provide: ZardAlertDialogRef, useValue: alertDialogRef }, + { provide: Z_ALERT_MODAL_DATA, useValue: config.zData }, + ], + }); + } +} diff --git a/src/app/shared/components/alert-dialog/alert-dialog.variants.ts b/src/app/shared/components/alert-dialog/alert-dialog.variants.ts new file mode 100644 index 0000000..a6e85a1 --- /dev/null +++ b/src/app/shared/components/alert-dialog/alert-dialog.variants.ts @@ -0,0 +1,16 @@ +import { cva, VariantProps } from 'class-variance-authority'; + +export const alertDialogVariants = cva('fixed z-50 w-full max-w-[calc(100%-2rem)] border bg-background shadow-lg rounded-lg sm:max-w-lg', { + variants: { + zType: { + default: '', + destructive: 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive', + warning: 'border-warning/50 text-warning dark:border-warning [&>svg]:text-warning', + }, + }, + defaultVariants: { + zType: 'default', + }, +}); + +export type ZardAlertDialogVariants = VariantProps; diff --git a/src/app/shared/components/alert/alert.component.ts b/src/app/shared/components/alert/alert.component.ts new file mode 100644 index 0000000..1fc378e --- /dev/null +++ b/src/app/shared/components/alert/alert.component.ts @@ -0,0 +1,50 @@ +import { ChangeDetectionStrategy, Component, computed, input, ViewEncapsulation } from '@angular/core'; +import type { ClassValue } from 'clsx'; + +import { mergeClasses } from '@shared/utils/merge-classes'; +import { alertVariants, ZardAlertVariants } from './alert.variants'; + +@Component({ + selector: 'z-alert', + standalone: true, + exportAs: 'zAlert', + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` + @if (iconName()) { + + } + +
+
{{ zTitle() }}
+ {{ zDescription() }} +
+ `, + host: { + '[class]': 'classes()', + '[attr.data-type]': 'zType()', + '[attr.data-appearance]': 'zAppearance()', + }, +}) +export class ZardAlertComponent { + readonly class = input(''); + readonly zTitle = input.required(); + readonly zDescription = input.required(); + readonly zIcon = input(); + readonly zType = input('default'); + readonly zAppearance = input('outline'); + + protected readonly classes = computed(() => mergeClasses(alertVariants({ zType: this.zType(), zAppearance: this.zAppearance() }), this.class())); + + protected readonly iconsType: Record, string> = { + default: '', + info: 'icon-info', + success: 'icon-circle-check', + warning: 'icon-triangle-alert', + error: 'icon-circle-x', + }; + + protected readonly iconName = computed(() => { + return this.zIcon() ?? this.iconsType[this.zType() ?? 'default']; + }); +} diff --git a/src/app/shared/components/alert/alert.variants.ts b/src/app/shared/components/alert/alert.variants.ts new file mode 100644 index 0000000..50edb96 --- /dev/null +++ b/src/app/shared/components/alert/alert.variants.ts @@ -0,0 +1,23 @@ +import { cva, VariantProps } from 'class-variance-authority'; + +export const alertVariants = cva('relative flex gap-2 w-full rounded-lg p-4', { + variants: { + zType: { + default: 'dark:data-[appearance="soft"]:text-zinc-800 data-[appearance="fill"]:text-white', + info: 'text-blue-500 data-[appearance="fill"]:text-white', + success: 'text-green-600 data-[appearance="fill"]:text-white', + warning: 'text-yellow-600 data-[appearance="fill"]:text-white', + error: 'text-red-500 data-[appearance="fill"]:text-white', + }, + zAppearance: { + outline: 'border data-[type="info"]:border-blue-500 data-[type="success"]:border-green-600 data-[type="warning"]:border-yellow-600 data-[type="error"]:border-red-500', + soft: 'bg-zinc-100 data-[type="info"]:bg-blue-50 data-[type="success"]:bg-green-50 data-[type="warning"]:bg-yellow-50 data-[type="error"]:bg-red-50', + fill: 'bg-zinc-500 data-[type="info"]:bg-blue-500 data-[type="success"]:bg-green-600 data-[type="warning"]:bg-yellow-600 data-[type="error"]:bg-red-500', + }, + }, + defaultVariants: { + zType: 'default', + zAppearance: 'outline', + }, +}); +export type ZardAlertVariants = VariantProps; diff --git a/src/app/shared/components/avatar/avatar.component.ts b/src/app/shared/components/avatar/avatar.component.ts new file mode 100644 index 0000000..04a61c9 --- /dev/null +++ b/src/app/shared/components/avatar/avatar.component.ts @@ -0,0 +1,127 @@ +import { ChangeDetectionStrategy, Component, computed, input, ViewEncapsulation } from '@angular/core'; +import { mergeClasses, transform } from '@shared/utils/merge-classes'; +import { avatarVariants, imageVariants, ZardAvatarImage, ZardAvatarVariants } from './avatar.variants'; + +@Component({ + selector: 'z-avatar', + exportAs: 'zAvatar', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` + @if (zLoading()) { + + } @else { + @if (zImage()?.fallback) { + {{ zImage()?.fallback }} + } + @if (zImage()?.url) { + + } + } + @if (zStatus()) { + @switch (zStatus()) { + @case ('online') { + + + + } + @case ('offline') { + + + + } + @case ('doNotDisturb') { + + + + + } + @case ('away') { + + + + } + @case ('invisible') { + + + + } + } + } + `, + host: { + '[class]': 'containerClasses()', + }, +}) +export class ZardAvatarComponent { + readonly zType = input('default'); + readonly zSize = input('default'); + readonly zShape = input('default'); + readonly zStatus = input(null); + readonly zBorder = input(false, { transform }); + readonly zLoading = input(false, { transform }); + readonly zImage = input({ fallback: 'ZA' }); + + readonly class = input(''); + + protected readonly containerClasses = computed(() => + mergeClasses(avatarVariants({ zType: this.zType(), zSize: this.zSize(), zShape: this.zShape(), zBorder: this.zBorder() }), this.class()), + ); + protected readonly imgClasses = computed(() => mergeClasses(imageVariants({ zShape: this.zShape() }))); +} diff --git a/src/app/shared/components/avatar/avatar.variants.ts b/src/app/shared/components/avatar/avatar.variants.ts new file mode 100644 index 0000000..9b0279b --- /dev/null +++ b/src/app/shared/components/avatar/avatar.variants.ts @@ -0,0 +1,65 @@ +import { cva, VariantProps } from 'class-variance-authority'; + +export const avatarVariants = cva('relative flex flex-row items-center justify-center box-content hover:bg-primary/90 cursor-default', { + variants: { + zType: { + default: 'bg-primary text-primary-foreground hover:bg-primary/90', + destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90', + outline: 'border border-input hover:bg-accent hover:text-accent-foreground', + secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground shadow-sm shadow-black', + }, + zSize: { + default: 'w-12 h-12', + sm: 'w-10 h-10', + md: 'w-18 h-18', + lg: 'w-37 h-37', + full: 'w-full h-full', + }, + zShape: { + default: 'rounded-md', + circle: 'rounded-full', + square: 'rounded-none', + }, + zStatus: { + online: 'online', + offline: 'offline', + doNotDisturb: 'doNotDisturb', + away: 'away', + invisible: 'invisible', + }, + zBorder: { + true: 'border border-3 border-white', + }, + zLoading: { + true: 'opacity-100', + }, + }, + defaultVariants: { + zType: 'default', + zSize: 'default', + zShape: 'default', + }, +}); + +export const imageVariants = cva('relative object-cover object-center w-full h-full z-10', { + variants: { + zShape: { + default: 'rounded-md', + circle: 'rounded-full', + square: 'rounded-none', + }, + }, + defaultVariants: { + zShape: 'default', + }, +}); + +export type ZardAvatarImage = { + zImage: { + fallback: string; + url?: string; + alt?: string; + }; +}; +export type ZardAvatarVariants = VariantProps & ZardAvatarImage; diff --git a/src/app/shared/components/badge/badge.component.ts b/src/app/shared/components/badge/badge.component.ts new file mode 100644 index 0000000..1ad0e2a --- /dev/null +++ b/src/app/shared/components/badge/badge.component.ts @@ -0,0 +1,26 @@ +import type { ClassValue } from 'clsx'; + +import { ChangeDetectionStrategy, Component, computed, input, ViewEncapsulation } from '@angular/core'; + +import { mergeClasses } from '@shared/utils/merge-classes'; +import { badgeVariants, ZardBadgeVariants } from './badge.variants'; + +@Component({ + selector: 'z-badge', + exportAs: 'zBadge', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ``, + host: { + '[class]': 'classes()', + }, +}) +export class ZardBadgeComponent { + readonly zType = input('default'); + readonly zShape = input('default'); + + readonly class = input(''); + + protected readonly classes = computed(() => mergeClasses(badgeVariants({ zType: this.zType(), zShape: this.zShape() }), this.class())); +} diff --git a/src/app/shared/components/badge/badge.variants.ts b/src/app/shared/components/badge/badge.variants.ts new file mode 100644 index 0000000..5dc3c36 --- /dev/null +++ b/src/app/shared/components/badge/badge.variants.ts @@ -0,0 +1,24 @@ +import { cva, VariantProps } from 'class-variance-authority'; + +export const badgeVariants = cva( + 'inline-flex items-center border text-xs px-2.5 py-0.5 font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', + { + variants: { + zType: { + default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80', + secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', + destructive: 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80', + outline: 'text-foreground', + }, + zShape: { + default: 'rounded-full', + square: 'rounded-none', + }, + }, + defaultVariants: { + zType: 'default', + zShape: 'default', + }, + }, +); +export type ZardBadgeVariants = VariantProps; diff --git a/src/app/shared/components/breadcrumb/breadcrumb.component.ts b/src/app/shared/components/breadcrumb/breadcrumb.component.ts new file mode 100644 index 0000000..6506035 --- /dev/null +++ b/src/app/shared/components/breadcrumb/breadcrumb.component.ts @@ -0,0 +1,159 @@ +import { ChangeDetectionStrategy, Component, computed, input, TemplateRef, ViewEncapsulation } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { ClassValue } from 'clsx'; + +import { + breadcrumbVariants, + breadcrumbListVariants, + breadcrumbSeparatorVariants, + breadcrumbItemVariants, + breadcrumbLinkVariants, + breadcrumbEllipsisVariants, + breadcrumbPageVariants, + ZardBreadcrumbVariants, + ZardBreadcrumbListVariants, + ZardBreadcrumbItemVariants, + ZardBreadcrumbLinkVariants, + ZardBreadcrumbPageVariants, + ZardBreadcrumbSeparatorVariants, + ZardBreadcrumbEllipsisVariants, +} from './breadcrumb.variants'; +import { mergeClasses } from '@shared/utils/merge-classes'; + +@Component({ + selector: 'z-breadcrumb', + exportAs: 'zBreadcrumb', + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` + + `, +}) +export class ZardBreadcrumbComponent { + readonly zSize = input('md'); + + readonly class = input(''); + + protected readonly classes = computed(() => mergeClasses(breadcrumbVariants({ zSize: this.zSize() }), this.class())); +} + +@Component({ + selector: 'z-breadcrumb-list', + exportAs: 'zBreadcrumbList', + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
    + +
+ `, +}) +export class ZardBreadcrumbListComponent { + readonly zAlign = input('start'); + readonly zWrap = input('wrap'); + + readonly class = input(''); + + protected readonly classes = computed(() => mergeClasses(breadcrumbListVariants({ zAlign: this.zAlign(), zWrap: this.zWrap() }), this.class())); +} + +@Component({ + selector: 'z-breadcrumb-item', + exportAs: 'zBreadcrumbItem', + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
  • + +
  • + `, +}) +export class ZardBreadcrumbItemComponent { + readonly zType = input('default'); + readonly zShape = input('default'); + + readonly class = input(''); + + protected readonly classes = computed(() => mergeClasses(breadcrumbItemVariants({ zType: this.zType(), zShape: this.zShape() }), this.class())); +} + +@Component({ + selector: 'z-breadcrumb-link', + exportAs: 'zBreadcrumbLink', + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + imports: [RouterLink], + template: ` + + + + `, +}) +export class ZardBreadcrumbLinkComponent { + readonly zLink = input('/'); + readonly zType = input('default'); + + readonly class = input(''); + + protected readonly classes = computed(() => mergeClasses(breadcrumbLinkVariants({ zType: this.zType() }), this.class())); +} + +@Component({ + selector: 'z-breadcrumb-page', + exportAs: 'zBreadcrumbPage', + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` + + + + `, +}) +export class ZardBreadcrumbPageComponent { + readonly zType = input('default'); + + readonly class = input(''); + + protected readonly classes = computed(() => mergeClasses(breadcrumbPageVariants({ zType: this.zType() }), this.class())); +} + +@Component({ + selector: 'z-breadcrumb-separator', + exportAs: 'zBreadcrumbSeparator', + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` + + `, +}) +export class ZardBreadcrumbSeparatorComponent { + readonly zSeparator = input | null>('/'); + readonly zType = input('default'); + + readonly class = input(''); + + protected readonly classes = computed(() => mergeClasses(breadcrumbSeparatorVariants({ zType: this.zType() }), this.class())); +} + +@Component({ + selector: 'z-breadcrumb-ellipsis', + exportAs: 'zBreadcrumbEllipsis', + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` `, + host: { + '[class]': 'classes()', + }, +}) +export class ZardBreadcrumbEllipsisComponent { + readonly zColor = input('muted'); + + readonly class = input(''); + protected readonly classes = computed(() => mergeClasses(breadcrumbEllipsisVariants({ zColor: this.zColor() }), this.class())); +} \ No newline at end of file diff --git a/src/app/shared/components/breadcrumb/breadcrumb.module.ts b/src/app/shared/components/breadcrumb/breadcrumb.module.ts new file mode 100644 index 0000000..354c2a0 --- /dev/null +++ b/src/app/shared/components/breadcrumb/breadcrumb.module.ts @@ -0,0 +1,27 @@ +import { NgModule } from '@angular/core'; + +import { + ZardBreadcrumbComponent, + ZardBreadcrumbEllipsisComponent, + ZardBreadcrumbItemComponent, + ZardBreadcrumbLinkComponent, + ZardBreadcrumbListComponent, + ZardBreadcrumbPageComponent, + ZardBreadcrumbSeparatorComponent, +} from './breadcrumb.component'; + +const components = [ + ZardBreadcrumbComponent, + ZardBreadcrumbListComponent, + ZardBreadcrumbItemComponent, + ZardBreadcrumbLinkComponent, + ZardBreadcrumbPageComponent, + ZardBreadcrumbSeparatorComponent, + ZardBreadcrumbEllipsisComponent, +]; + +@NgModule({ + imports: components, + exports: components, +}) +export class ZardBreadcrumbModule {} \ No newline at end of file diff --git a/src/app/shared/components/breadcrumb/breadcrumb.variants.ts b/src/app/shared/components/breadcrumb/breadcrumb.variants.ts new file mode 100644 index 0000000..8e66f08 --- /dev/null +++ b/src/app/shared/components/breadcrumb/breadcrumb.variants.ts @@ -0,0 +1,111 @@ +import { cva, VariantProps } from 'class-variance-authority'; + +export const breadcrumbVariants = cva('w-full', { + variants: { + zSize: { + sm: 'text-xs', + md: 'text-sm', + lg: 'text-base', + }, + }, + defaultVariants: { + zSize: 'md', + }, +}); +export type ZardBreadcrumbVariants = VariantProps; + +export const breadcrumbListVariants = cva('text-muted-foreground flex flex-wrap items-center gap-1.5 break-words sm:gap-2.5', { + variants: { + zAlign: { + start: 'justify-start', + center: 'justify-center', + end: 'justify-end', + }, + zWrap: { + wrap: 'flex-wrap', + nowrap: 'flex-nowrap', + }, + }, + defaultVariants: { + zAlign: 'start', + zWrap: 'wrap', + }, +}); +export type ZardBreadcrumbListVariants = VariantProps; + +export const breadcrumbItemVariants = cva('flex items-center gap-1.5 transition-colors', { + variants: { + zType: { + default: '', + muted: 'text-muted-foreground', + bold: 'font-semibold text-foreground', + subtle: 'text-sm text-muted-foreground hover:text-foreground', + }, + zShape: { + default: '', + square: 'px-1 py-0.5 rounded-none', + rounded: 'px-2 py-0.5 rounded-md', + }, + }, + defaultVariants: { + zType: 'default', + zShape: 'default', + }, +}); +export type ZardBreadcrumbItemVariants = VariantProps; + +export const breadcrumbLinkVariants = cva('flex items-center gap-1 transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', { + variants: { + zType: { + default: 'hover:text-foreground', + underline: 'underline text-foreground hover:no-underline', + subtle: 'text-muted-foreground hover:text-foreground', + }, + }, + defaultVariants: { + zType: 'default', + }, +}); +export type ZardBreadcrumbLinkVariants = VariantProps; + +export const breadcrumbPageVariants = cva('flex items-center gap-1 transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', { + variants: { + zType: { + default: 'text-foreground', + underline: 'underline text-foreground hover:no-underline', + subtle: 'text-muted-foreground hover:text-foreground', + current: 'font-semibold text-foreground cursor-default ' + 'hover:text-foreground focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-ring', + }, + }, + defaultVariants: { + zType: 'default', + }, +}); +export type ZardBreadcrumbPageVariants = VariantProps; + +export const breadcrumbSeparatorVariants = cva('select-none', { + variants: { + zType: { + default: 'text-muted-foreground', + strong: 'text-foreground', + primary: 'text-primary', + }, + }, + defaultVariants: { + zType: 'default', + }, +}); +export type ZardBreadcrumbSeparatorVariants = VariantProps; + +export const breadcrumbEllipsisVariants = cva('flex', { + variants: { + zColor: { + muted: 'text-muted-foreground', + strong: 'text-foreground', + }, + }, + defaultVariants: { + zColor: 'muted', + }, +}); +export type ZardBreadcrumbEllipsisVariants = VariantProps; \ No newline at end of file diff --git a/src/app/shared/components/button/button.component.ts b/src/app/shared/components/button/button.component.ts new file mode 100644 index 0000000..fee9553 --- /dev/null +++ b/src/app/shared/components/button/button.component.ts @@ -0,0 +1,49 @@ +import type { ClassValue } from 'clsx'; + +import { ChangeDetectionStrategy, Component, computed, ElementRef, inject, input, ViewEncapsulation } from '@angular/core'; + +import { mergeClasses, transform } from '@shared/utils/merge-classes'; +import { buttonVariants, ZardButtonVariants } from './button.variants'; + +@Component({ + selector: 'z-button, button[z-button], a[z-button]', + exportAs: 'zButton', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` + @if (zLoading()) { + + } + + + `, + host: { + '[class]': 'classes()', + }, +}) +export class ZardButtonComponent { + private readonly elementRef = inject(ElementRef); + + readonly zType = input('default'); + readonly zSize = input('default'); + readonly zShape = input('default'); + + readonly class = input(''); + + readonly zFull = input(false, { transform }); + readonly zLoading = input(false, { transform }); + + protected readonly classes = computed(() => + mergeClasses( + buttonVariants({ + zType: this.zType(), + zSize: this.zSize(), + zShape: this.zShape(), + zFull: this.zFull(), + zLoading: this.zLoading(), + }), + this.class(), + ), + ); +} diff --git a/src/app/shared/components/button/button.variants.ts b/src/app/shared/components/button/button.variants.ts new file mode 100644 index 0000000..8141248 --- /dev/null +++ b/src/app/shared/components/button/button.variants.ts @@ -0,0 +1,40 @@ +import { cva, VariantProps } from 'class-variance-authority'; + +export const buttonVariants = cva( + "cursor-pointer inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all active:scale-95 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + zType: { + default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90', + destructive: 'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', + outline: 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50', + secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', + link: 'text-primary underline-offset-4 hover:underline', + }, + zSize: { + default: 'h-9 px-4 py-2 has-[>svg]:px-3', + sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5', + lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', + icon: 'size-9', + }, + zShape: { + default: 'rounded-md', + circle: 'rounded-full', + square: 'rounded-none', + }, + zFull: { + true: 'w-full', + }, + zLoading: { + true: 'opacity-50 pointer-events-none', + }, + }, + defaultVariants: { + zType: 'default', + zSize: 'default', + zShape: 'default', + }, + }, +); +export type ZardButtonVariants = VariantProps; diff --git a/src/app/shared/components/calendar/calendar.component.ts b/src/app/shared/components/calendar/calendar.component.ts new file mode 100644 index 0000000..8cc81e5 --- /dev/null +++ b/src/app/shared/components/calendar/calendar.component.ts @@ -0,0 +1,591 @@ +import { ChangeDetectionStrategy, Component, computed, ElementRef, input, linkedSignal, model, signal, viewChild, ViewEncapsulation } from '@angular/core'; +import { outputFromObservable, outputToObservable } from '@angular/core/rxjs-interop'; +import { filter } from 'rxjs'; + +import { calendarDayButtonVariants, calendarDayVariants, calendarNavVariants, calendarVariants, calendarWeekdayVariants, ZardCalendarVariants } from './calendar.variants'; +import { ZardSelectItemComponent } from '../select/select-item.component'; +import { ZardSelectComponent } from '../select/select.component'; +import { ZardButtonComponent } from '../button/button.component'; +import type { ClassValue } from 'clsx'; +import { mergeClasses } from '@shared/utils/merge-classes'; + +export interface CalendarDay { + date: Date; + isCurrentMonth: boolean; + isToday: boolean; + isSelected: boolean; + isDisabled: boolean; + id?: string; +} + +export type { ZardCalendarVariants }; + +@Component({ + selector: 'z-calendar, [z-calendar]', + exportAs: 'zCalendar', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + imports: [ZardButtonComponent, ZardSelectComponent, ZardSelectItemComponent], + host: { + '(keydown)': 'onKeyDown($event)', + '[attr.tabindex]': '0', + '[attr.role]': '"grid"', + '[attr.aria-label]': '"Calendar"', + }, + template: ` +
    + +
    + + + +
    + + + @for (month of months; track $index) { + {{ month }} + } + + + + + @for (year of availableYears(); track year) { + {{ year }} + } + +
    + + +
    + + +
    + @for (weekday of weekdays; track $index) { +
    + {{ weekday }} +
    + } +
    + + +
    + @for (day of calendarDays(); track day.date.getTime(); let i = $index) { +
    + +
    + } +
    +
    + `, +}) +export class ZardCalendarComponent { + private readonly calendarContainer = viewChild.required>('calendarContainer'); + + // Public method to reset navigation (useful for date-picker) + resetNavigation(): void { + const value = this.currentDate(); + this.currentMonthValue.set(value.getMonth().toString()); + this.currentYearValue.set(value.getFullYear().toString()); + this.focusedDayIndex.set(-1); + } + readonly class = input(''); + readonly zSize = input('default'); + readonly value = model(null); + readonly minDate = input(null); + readonly maxDate = input(null); + readonly disabled = input(false); + + readonly dateChange = outputFromObservable(outputToObservable(this.value).pipe(filter(v => v !== null))); + + private readonly focusedDayIndex = signal(-1); + + private readonly currentDate = computed(() => this.value() ?? new Date(), { + equal: (a, b) => a.getTime() === b.getTime(), + }); + + readonly weekdays = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']; + readonly months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + + protected readonly classes = computed(() => + mergeClasses( + calendarVariants({ + zSize: this.zSize(), + }), + this.class(), + ), + ); + + protected readonly navClasses = computed(() => mergeClasses(calendarNavVariants())); + + protected readonly weekdayClasses = computed(() => mergeClasses(calendarWeekdayVariants({ zSize: this.zSize() }))); + + protected readonly dayContainerClasses = computed(() => mergeClasses(calendarDayVariants({ zSize: this.zSize() }))); + + protected readonly selectSize = computed(() => { + const size = this.zSize(); + if (size === 'lg') return 'lg'; + if (size === 'sm') return 'sm'; + return 'default'; + }); + + protected readonly navButtonSize = computed(() => { + const size = this.zSize(); + if (size === 'lg') return 'default'; + return 'sm'; + }); + + protected readonly navButtonClasses = computed(() => { + const size = this.zSize(); + const baseClasses = 'p-0 opacity-50 hover:opacity-100'; + + switch (size) { + case 'sm': + return `h-6 w-6 ${baseClasses}`; + case 'lg': + return `h-8 w-8 ${baseClasses}`; + default: + return `h-7 w-7 ${baseClasses}`; + } + }); + + protected readonly currentMonthValue = linkedSignal(() => this.currentDate().getMonth().toString()); + protected readonly currentYearValue = linkedSignal(() => this.currentDate().getFullYear().toString()); + + protected readonly availableYears = computed(() => { + const currentYear = new Date().getFullYear(); + const years = []; + for (let i = currentYear - 10; i <= currentYear + 10; i++) { + years.push(i); + } + return years; + }); + + protected readonly currentMonthYear = computed(() => { + const date = this.currentDate(); + return date.toLocaleDateString('en-US', { month: 'long', year: 'numeric' }); + }); + + protected readonly currentMonthName = computed(() => { + const selectedMonth = parseInt(this.currentMonthValue()); + if (!isNaN(selectedMonth) && this.months[selectedMonth]) return this.months[selectedMonth]; + return this.months[this.currentDate().getMonth()]; + }); + + protected onMonthChange(monthIndex: string): void { + if (!monthIndex || monthIndex.trim() === '') { + console.warn('Invalid month index received:', monthIndex); + return; + } + + const parsedMonth = parseInt(monthIndex, 10); + if (isNaN(parsedMonth) || parsedMonth < 0 || parsedMonth > 11) { + console.warn('Invalid month value:', monthIndex, 'parsed as:', parsedMonth); + return; + } + + const currentDate = this.currentDate(); + const selectedYear = parseInt(this.currentYearValue()); + const newDate = new Date(isNaN(selectedYear) ? currentDate.getFullYear() : selectedYear, parsedMonth, 1); + this.currentMonthValue.set(newDate.getMonth().toString()); + this.focusedDayIndex.set(-1); + } + + protected onYearChange(year: string): void { + if (!year || year.trim() === '') { + console.warn('Invalid year received:', year); + return; + } + + const parsedYear = parseInt(year, 10); + if (isNaN(parsedYear) || parsedYear < 1900 || parsedYear > 2100) { + console.warn('Invalid year value:', year, 'parsed as:', parsedYear); + return; + } + + const currentDate = this.currentDate(); + const selectedMonth = parseInt(this.currentMonthValue()); + const newDate = new Date(parsedYear, isNaN(selectedMonth) ? currentDate.getMonth() : selectedMonth, 1); + this.currentYearValue.set(newDate.getFullYear().toString()); + this.focusedDayIndex.set(-1); + } + + protected readonly calendarDays = computed(() => { + const currentDate = this.currentDate(); + const navigationDate = new Date(parseInt(this.currentYearValue()), parseInt(this.currentMonthValue()), currentDate.getDate()); + const selectedDate = isNaN(navigationDate.getTime()) ? currentDate : navigationDate; + const today = new Date(); + const minDate = this.minDate(); + const maxDate = this.maxDate(); + + const year = selectedDate.getFullYear(); + const month = selectedDate.getMonth(); + + // Get first day of the month + const firstDay = new Date(year, month, 1); + // Get last day of the month + const lastDay = new Date(year, month + 1, 0); + + // Get the first day of the week for the first day of the month + const startDate = new Date(firstDay); + startDate.setDate(startDate.getDate() - startDate.getDay()); + + // Get the last day of the week for the last day of the month + const endDate = new Date(lastDay); + endDate.setDate(endDate.getDate() + (6 - endDate.getDay())); + + const days: CalendarDay[] = []; + const currentWeekDate = new Date(startDate); + + while (currentWeekDate <= endDate) { + const date = new Date(currentWeekDate); + const isCurrentMonth = date.getMonth() === month; + const isToday = this.isSameDay(date, today); + const isSelected = currentDate ? this.isSameDay(date, currentDate) : false; + const isDisabled = this.disabled() || this.isDateDisabled(date, minDate, maxDate); + + days.push({ + date, + isCurrentMonth, + isToday, + isSelected, + isDisabled, + }); + + currentWeekDate.setDate(currentWeekDate.getDate() + 1); + } + + return days; + }); + + protected dayButtonClasses(day: CalendarDay): string { + return mergeClasses( + calendarDayButtonVariants({ + zSize: this.zSize(), + selected: day.isSelected, + today: day.isToday, + outside: !day.isCurrentMonth, + disabled: day.isDisabled, + }), + ); + } + + protected previousMonth() { + const currentDate = this.currentDate(); + const currentMonth = parseInt(this.currentMonthValue()); + const previous = new Date(currentDate.getFullYear(), (isNaN(currentMonth) ? currentDate.getMonth() : currentMonth) - 1, 1); + this.currentMonthValue.set(previous.getMonth().toString()); + this.focusedDayIndex.set(-1); + } + + protected nextMonth() { + const currentDate = this.currentDate(); + const currentMonth = parseInt(this.currentMonthValue()); + const next = new Date(currentDate.getFullYear(), (isNaN(currentMonth) ? currentDate.getMonth() : currentMonth) + 1, 1); + this.currentMonthValue.set(next.getMonth().toString()); + this.focusedDayIndex.set(-1); + } + + protected isPreviousDisabled(): boolean { + if (this.disabled()) return true; + + const minDate = this.minDate(); + if (!minDate) return false; + + const currentDate = this.currentDate(); + const currentMonth = parseInt(this.currentMonthValue()); + const lastDayOfPreviousMonth = new Date(currentDate.getFullYear(), isNaN(currentMonth) ? currentDate.getMonth() : currentMonth, 0); + + return lastDayOfPreviousMonth.getTime() < minDate.getTime(); + } + + protected isNextDisabled(): boolean { + if (this.disabled()) return true; + + const maxDate = this.maxDate(); + if (!maxDate) return false; + + const currentDate = this.currentDate(); + const currentMonth = parseInt(this.currentMonthValue()); + const nextMonth = new Date(currentDate.getFullYear(), (isNaN(currentMonth) ? currentDate.getMonth() : currentMonth) + 1, 1); + + return nextMonth.getTime() > maxDate.getTime(); + } + + selectDate(date: Date, i?: number) { + if (this.disabled()) return; + + const minDate = this.minDate(); + const maxDate = this.maxDate(); + + if (this.isDateDisabled(date, minDate, maxDate)) return; + + this.value.set(date); + this.focusedDayIndex.set(i ?? this.calendarDays().findIndex(day => this.isSameDay(day.date, date))); + } + + protected getDayAriaLabel(day: CalendarDay): string { + const dateStr = day.date.toLocaleDateString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + }); + + const labels = [dateStr]; + + if (day.isToday) labels.push('Today'); + if (day.isSelected) labels.push('Selected'); + if (!day.isCurrentMonth) labels.push('Outside month'); + if (day.isDisabled) labels.push('Disabled'); + + return labels.join(', '); + } + + protected getDayId(index: number): string { + return `calendar-day-${index}`; + } + + protected getFocusedDayIndex(): number { + const focused = this.focusedDayIndex(); + if (focused >= 0) return focused; + + // Default focus to selected date or today + const days = this.calendarDays(); + const selectedIndex = days.findIndex(day => day.isSelected); + if (selectedIndex >= 0) return selectedIndex; + + const todayIndex = days.findIndex(day => day.isToday && day.isCurrentMonth); + if (todayIndex >= 0) return todayIndex; + + // Fall back to first enabled day of current month + const firstCurrentMonthIndex = days.findIndex(day => day.isCurrentMonth && !day.isDisabled); + return firstCurrentMonthIndex >= 0 ? firstCurrentMonthIndex : 0; + } + + onKeyDown(event: KeyboardEvent): void { + if (this.disabled()) return; + + const days = this.calendarDays(); + if (days.length === 0) return; + + const currentIndex = this.getFocusedDayIndex(); + let newIndex: number | null = null; + + switch (event.key) { + case 'ArrowLeft': + event.preventDefault(); + newIndex = this.navigate(currentIndex, -1, days); + break; + case 'ArrowRight': + event.preventDefault(); + newIndex = this.navigate(currentIndex, 1, days); + break; + case 'ArrowUp': + event.preventDefault(); + newIndex = this.navigate(currentIndex, -7, days); + break; + case 'ArrowDown': + event.preventDefault(); + newIndex = this.navigate(currentIndex, 7, days); + break; + case 'Home': + event.preventDefault(); + newIndex = this.findEnabledInRange(Math.floor(currentIndex / 7) * 7, Math.floor(currentIndex / 7) * 7 + 6, days); + break; + case 'End': + event.preventDefault(); + newIndex = this.findEnabledInRange(Math.floor(currentIndex / 7) * 7 + 6, Math.floor(currentIndex / 7) * 7, days, true); + break; + case 'PageUp': + event.preventDefault(); + if (event.ctrlKey) { + this.navigateYear(-1); + } else { + this.previousMonth(); + } + this.resetFocusAfterNavigation(); + return; + case 'PageDown': + event.preventDefault(); + if (event.ctrlKey) { + this.navigateYear(1); + } else { + this.nextMonth(); + } + this.resetFocusAfterNavigation(); + return; + case 'Enter': + case ' ': { + event.preventDefault(); + const focusedDay = days[currentIndex]; + if (focusedDay && !focusedDay.isDisabled) { + this.selectDate(focusedDay.date, currentIndex); + } + return; + } + default: + return; + } + + if (newIndex !== null && newIndex !== currentIndex) { + this.setFocus(newIndex); + } + } + + private navigate(currentIndex: number, step: number, days: CalendarDay[]): number | null { + const targetIndex = currentIndex + step; + + // If within bounds, find enabled day + if (targetIndex >= 0 && targetIndex < days.length) { + return this.findEnabledInRange(targetIndex, currentIndex, days); + } + + // Handle month boundaries + const dayOfWeek = currentIndex % 7; + + if (step === -1) { + // Going left - navigate to previous month, focus last day + this.previousMonth(); + setTimeout(() => this.resetFocusAfterNavigation('last'), 0); + } else if (step === 1) { + // Going right - navigate to next month, focus first day + this.nextMonth(); + setTimeout(() => this.resetFocusAfterNavigation('first'), 0); + } else if (step === -7) { + // Going up - navigate to previous month, preserve column + this.previousMonth(); + setTimeout(() => this.resetFocusAfterNavigation('lastWeek', dayOfWeek), 0); + } else if (step === 7) { + // Going down - navigate to next month, preserve column + this.nextMonth(); + setTimeout(() => this.resetFocusAfterNavigation('firstWeek', dayOfWeek), 0); + } + + return null; + } + + private findEnabledInRange(start: number, fallback: number, days: CalendarDay[], reverse = false): number { + const clampedStart = Math.max(0, Math.min(start, days.length - 1)); + const clampedFallback = Math.max(0, Math.min(fallback, days.length - 1)); + + if (!reverse) { + // Search forward from start + for (let i = clampedStart; i < days.length; i++) { + if (!days[i].isDisabled) return i; + } + // Search backward from start + for (let i = clampedStart - 1; i >= 0; i--) { + if (!days[i].isDisabled) return i; + } + } else { + // Search backward from start + for (let i = clampedStart; i >= 0; i--) { + if (!days[i].isDisabled) return i; + } + // Search forward from start + for (let i = clampedStart + 1; i < days.length; i++) { + if (!days[i].isDisabled) return i; + } + } + + return clampedFallback; + } + + private setFocus(index: number): void { + this.focusedDayIndex.set(index); + setTimeout(() => { + const dayElement = this.calendarContainer()?.nativeElement.querySelector(`#${this.getDayId(index)}`) as HTMLElement; + dayElement?.focus(); + }, 0); + } + + private navigateYear(direction: number): void { + const current = this.currentDate(); + const newDate = new Date(current.getFullYear() + direction, current.getMonth(), 1); + this.currentYearValue.set(newDate.getFullYear().toString()); + } + + private resetFocusAfterNavigation(position = 'default', dayOfWeek = -1): void { + setTimeout(() => { + const days = this.calendarDays(); + let targetIndex = -1; + + switch (position) { + case 'first': + // Focus first enabled day + targetIndex = days.findIndex(day => !day.isDisabled); + break; + case 'last': + // Focus last enabled day + for (let i = days.length - 1; i >= 0; i--) { + if (!days[i].isDisabled) { + targetIndex = i; + break; + } + } + break; + case 'firstWeek': + // Focus same day of week in first week + if (dayOfWeek >= 0 && dayOfWeek < 7) { + targetIndex = this.findEnabledInRange(dayOfWeek, 0, days); + } + break; + case 'lastWeek': + // Focus same day of week in last week + if (dayOfWeek >= 0) { + const lastWeekStart = Math.floor((days.length - 1) / 7) * 7; + const targetIdx = Math.min(lastWeekStart + dayOfWeek, days.length - 1); + targetIndex = this.findEnabledInRange(targetIdx, days.length - 1, days); + } + break; + default: { + // Default priority: selected > today > first enabled + const selectedIndex = days.findIndex(day => day.isSelected); + const todayIndex = days.findIndex(day => day.isToday && day.isCurrentMonth); + const firstEnabledIndex = days.findIndex(day => day.isCurrentMonth && !day.isDisabled); + + targetIndex = selectedIndex >= 0 ? selectedIndex : todayIndex >= 0 ? todayIndex : firstEnabledIndex >= 0 ? firstEnabledIndex : 0; + break; + } + } + + if (targetIndex >= 0) { + this.setFocus(targetIndex); + } + }, 0); + } + + private isSameDay(date1: Date, date2: Date): boolean { + return date1.getFullYear() === date2.getFullYear() && date1.getMonth() === date2.getMonth() && date1.getDate() === date2.getDate(); + } + + private isDateDisabled(date: Date, minDate: Date | null, maxDate: Date | null): boolean { + if (minDate && date < minDate) return true; + if (maxDate && date > maxDate) return true; + return false; + } +} \ No newline at end of file diff --git a/src/app/shared/components/calendar/calendar.variants.ts b/src/app/shared/components/calendar/calendar.variants.ts new file mode 100644 index 0000000..88b724a --- /dev/null +++ b/src/app/shared/components/calendar/calendar.variants.ts @@ -0,0 +1,105 @@ +import { cva, VariantProps } from 'class-variance-authority'; + +export const calendarVariants = cva('bg-background p-3 w-fit rounded-lg border', { + variants: { + zSize: { + sm: 'text-sm', + default: '', + lg: 'text-lg', + }, + }, + defaultVariants: { + zSize: 'default', + }, +}); + +export const calendarMonthVariants = cva('flex flex-col w-full gap-4'); + +export const calendarNavVariants = cva('flex items-center justify-between gap-2 w-full mb-4'); + +export const calendarNavButtonVariants = cva( + 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:bg-accent hover:text-accent-foreground h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100', +); + +export const calendarWeekdaysVariants = cva('flex'); + +export const calendarWeekdayVariants = cva('text-muted-foreground font-normal text-center', { + variants: { + zSize: { + sm: 'text-xs w-7', + default: 'text-[0.8rem] w-9', + lg: 'text-sm w-11', + }, + }, + defaultVariants: { + zSize: 'default', + }, +}); + +export const calendarWeekVariants = cva('flex w-full mt-2'); + +export const calendarDayVariants = cva('text-center p-0 relative focus-within:relative focus-within:z-20', { + variants: { + zSize: { + sm: 'h-7 w-7 text-xs', + default: 'h-9 w-9 text-sm', + lg: 'h-11 w-11 text-base', + }, + }, + defaultVariants: { + zSize: 'default', + }, +}); + +export const calendarDayButtonVariants = cva( + 'p-0 font-normal inline-flex items-center justify-center whitespace-nowrap rounded-md ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:bg-accent hover:text-accent-foreground', + { + variants: { + zSize: { + sm: 'h-7 w-7 text-xs', + default: 'h-9 w-9 text-sm', + lg: 'h-11 w-11 text-base', + }, + selected: { + true: 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground', + false: '', + }, + today: { + true: 'bg-accent text-accent-foreground', + false: '', + }, + outside: { + true: 'text-muted-foreground opacity-50', + false: '', + }, + disabled: { + true: 'text-muted-foreground opacity-50 cursor-not-allowed', + false: '', + }, + }, + compoundVariants: [ + { + today: true, + selected: false, + className: 'bg-accent text-accent-foreground', + }, + { + today: true, + selected: true, + className: 'bg-primary text-primary-foreground', + }, + ], + defaultVariants: { + zSize: 'default', + selected: false, + today: false, + outside: false, + disabled: false, + }, + }, +); + +export type ZardCalendarVariants = VariantProps; +export type ZardCalendarWeekdayVariants = VariantProps; +export type ZardCalendarDayVariants = VariantProps; +export type ZardCalendarDayButtonVariants = VariantProps; \ No newline at end of file diff --git a/src/app/shared/components/card/card.component.ts b/src/app/shared/components/card/card.component.ts new file mode 100644 index 0000000..afe2b35 --- /dev/null +++ b/src/app/shared/components/card/card.component.ts @@ -0,0 +1,55 @@ +import type { ClassValue } from 'clsx'; + +import { + ChangeDetectionStrategy, + Component, + computed, + input, + TemplateRef, + ViewEncapsulation, +} from '@angular/core'; + +import { mergeClasses } from '@shared/utils/merge-classes'; +import { ZardStringTemplateOutletDirective } from '../core/directives/string-template-outlet/string-template-outlet.directive'; +import { cardBodyVariants, cardHeaderVariants, cardVariants } from './card.variants'; + +@Component({ + selector: 'z-card', + exportAs: 'zCard', + standalone: true, + imports: [ZardStringTemplateOutletDirective], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` + @if (zTitle()) { +
    +
    + {{ zTitle() }} +
    + + @if (zDescription()) { +
    + {{ zDescription() }} +
    + } +
    + } + +
    + +
    + `, + host: { + '[class]': 'classes()', + }, +}) +export class ZardCardComponent { + readonly zTitle = input>(); + readonly zDescription = input>(); + + readonly class = input(''); + + protected readonly classes = computed(() => mergeClasses(cardVariants(), this.class())); + protected readonly headerClasses = computed(() => mergeClasses(cardHeaderVariants())); + protected readonly bodyClasses = computed(() => mergeClasses(cardBodyVariants())); +} diff --git a/src/app/shared/components/card/card.variants.ts b/src/app/shared/components/card/card.variants.ts new file mode 100644 index 0000000..d28a67f --- /dev/null +++ b/src/app/shared/components/card/card.variants.ts @@ -0,0 +1,19 @@ +import { cva, VariantProps } from 'class-variance-authority'; + +export const cardVariants = cva( + 'block rounded-lg border bg-card text-card-foreground shadow-sm w-full p-6', + { + variants: {}, + } +); +export type ZardCardVariants = VariantProps; + +export const cardHeaderVariants = cva('flex flex-col space-y-1.5 pb-0 gap-1.5 mb-6', { + variants: {}, +}); +export type ZardCardHeaderVariants = VariantProps; + +export const cardBodyVariants = cva('block', { + variants: {}, +}); +export type ZardCardBodyVariants = VariantProps; diff --git a/src/app/shared/components/checkbox/checkbox.component.ts b/src/app/shared/components/checkbox/checkbox.component.ts new file mode 100644 index 0000000..0b39752 --- /dev/null +++ b/src/app/shared/components/checkbox/checkbox.component.ts @@ -0,0 +1,83 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, computed, forwardRef, inject, input, output, ViewEncapsulation } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; + +import { mergeClasses, transform } from '@shared/utils/merge-classes'; +import { checkboxLabelVariants, checkboxVariants, ZardCheckboxVariants } from './checkbox.variants'; + +import type { ClassValue } from 'clsx'; + +type OnTouchedType = () => any; +type OnChangeType = (value: any) => void; + +@Component({ + selector: 'z-checkbox, [z-checkbox]', + standalone: true, + exportAs: 'zCheckbox', + template: ` + +
    + + +
    + +
    + `, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ZardCheckboxComponent), + multi: true, + }, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, +}) +export class ZardCheckboxComponent implements ControlValueAccessor { + private cdr = inject(ChangeDetectorRef); + + readonly checkChange = output(); + readonly class = input(''); + readonly disabled = input(false, { transform }); + readonly zType = input('default'); + readonly zSize = input('default'); + readonly zShape = input('default'); + /* eslint-disable-next-line @typescript-eslint/no-empty-function */ + private onChange: OnChangeType = () => {}; + /* eslint-disable-next-line @typescript-eslint/no-empty-function */ + private onTouched: OnTouchedType = () => {}; + + protected readonly classes = computed(() => mergeClasses(checkboxVariants({ zType: this.zType(), zSize: this.zSize(), zShape: this.zShape() }), this.class())); + protected readonly labelClasses = computed(() => mergeClasses(checkboxLabelVariants({ zSize: this.zSize() }))); + checked = false; + + writeValue(val: boolean): void { + this.checked = val; + this.cdr.markForCheck(); + } + + registerOnChange(fn: OnChangeType): void { + this.onChange = fn; + } + + registerOnTouched(fn: OnTouchedType): void { + this.onTouched = fn; + } + + onCheckboxBlur(): void { + this.onTouched(); + this.cdr.markForCheck(); + } + + onCheckboxChange(): void { + if (this.disabled()) return; + + this.checked = !this.checked; + this.onChange(this.checked); + this.checkChange.emit(this.checked); + this.cdr.markForCheck(); + } +} diff --git a/src/app/shared/components/checkbox/checkbox.variants.ts b/src/app/shared/components/checkbox/checkbox.variants.ts new file mode 100644 index 0000000..66d257f --- /dev/null +++ b/src/app/shared/components/checkbox/checkbox.variants.ts @@ -0,0 +1,42 @@ +import { cva, VariantProps } from 'class-variance-authority'; + +export const checkboxVariants = cva( + 'cursor-[unset] peer appearance-none border transition shadow hover:shadow-md focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50', + { + variants: { + zType: { + default: 'border-primary checked:bg-primary', + destructive: 'border-destructive checked:bg-destructive', + }, + zSize: { + default: 'h-4 w-4', + lg: 'h-6 w-6', + }, + zShape: { + default: 'rounded', + circle: 'rounded-full', + square: 'rounded-none', + }, + }, + defaultVariants: { + zType: 'default', + zSize: 'default', + zShape: 'default', + }, + }, +); + +export const checkboxLabelVariants = cva('cursor-[unset] text-current empty:hidden', { + variants: { + zSize: { + default: 'text-base', + lg: 'text-lg', + }, + }, + defaultVariants: { + zSize: 'default', + }, +}); + +export type ZardCheckboxVariants = VariantProps; +export type ZardCheckLabelVariants = VariantProps; diff --git a/src/app/shared/components/combobox/combobox.component.ts b/src/app/shared/components/combobox/combobox.component.ts new file mode 100644 index 0000000..580908e --- /dev/null +++ b/src/app/shared/components/combobox/combobox.component.ts @@ -0,0 +1,407 @@ +import type { ClassValue } from 'clsx'; + +import { + ChangeDetectionStrategy, + Component, + computed, + ElementRef, + EventEmitter, + forwardRef, + HostListener, + input, + Output, + signal, + viewChild, + ViewEncapsulation, +} from '@angular/core'; +import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms'; + +import { mergeClasses } from '@shared/utils/merge-classes'; +import { ZardButtonComponent } from '../button/button.component'; +import { ZardCommandEmptyComponent } from '../command/command-empty.component'; +import { ZardCommandInputComponent } from '../command/command-input.component'; +import { ZardCommandListComponent } from '../command/command-list.component'; +import { ZardCommandOptionGroupComponent } from '../command/command-option-group.component'; +import { ZardCommandOptionComponent } from '../command/command-option.component'; +import { ZardCommandComponent, ZardCommandOption } from '../command/command.component'; +import { ZardPopoverComponent, ZardPopoverDirective } from '../popover/popover.component'; +import { comboboxVariants, ZardComboboxVariants } from './combobox.variants'; +import { ZardEmptyComponent } from '../empty/empty.component'; + +export interface ZardComboboxOption { + value: string; + label: string; + disabled?: boolean; + icon?: string; +} + +export interface ZardComboboxGroup { + label?: string; + options: ZardComboboxOption[]; +} + +@Component({ + selector: 'z-combobox', + exportAs: 'zCombobox', + standalone: true, + imports: [ + FormsModule, + ZardButtonComponent, + ZardCommandComponent, + ZardCommandInputComponent, + ZardCommandListComponent, + ZardCommandEmptyComponent, + ZardCommandOptionComponent, + ZardCommandOptionGroupComponent, + ZardPopoverDirective, + ZardPopoverComponent, + ZardEmptyComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` + + + + + + @if (searchable()) { + + } + + + @if (emptyText()) { + + + + } + + @if (groups().length > 0) { + @for (group of groups(); track group.label || $index) { + @if (group.label) { + + @for (option of group.options; track option.value) { + + {{ option.label }} + @if (option.value === getCurrentValue()) { + + } + + } + + } @else { + @for (option of group.options; track option.value) { + + {{ option.label }} + @if (option.value === getCurrentValue()) { + + } + + } + } + } + } @else if (options().length > 0) { + @for (option of options(); track option.value) { + + {{ option.label }} + @if (option.value === getCurrentValue()) { + + } + + } + } + + + + + `, + host: { + '[class]': 'classes()', + }, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ZardComboboxComponent), + multi: true, + }, + ], +}) +export class ZardComboboxComponent implements ControlValueAccessor { + readonly class = input(''); + readonly buttonVariant = input<'default' | 'outline' | 'secondary' | 'ghost'>('outline'); + readonly zWidth = input('default'); + readonly placeholder = input('Select...'); + readonly searchPlaceholder = input('Search...'); + readonly emptyText = input('No results found.'); + readonly disabled = input(false); + readonly searchable = input(true); + readonly value = input(null); + readonly options = input([]); + readonly groups = input([]); + readonly ariaLabel = input(''); + readonly ariaDescribedBy = input(''); + + @Output() readonly zValueChange = new EventEmitter(); + @Output() readonly zOnSelect = new EventEmitter(); + + readonly popoverDirective = viewChild.required('popoverTrigger', { read: ZardPopoverDirective }); + readonly buttonRef = viewChild.required('popoverTrigger', { read: ElementRef }); + readonly commandRef = viewChild('commandRef', { read: ZardCommandComponent }); + readonly commandInputRef = viewChild('commandInputRef', { read: ZardCommandInputComponent }); + + protected readonly open = signal(false); + protected readonly internalValue = signal(null); + + protected readonly classes = computed(() => + mergeClasses( + comboboxVariants({ + zWidth: this.zWidth(), + }), + this.class(), + ), + ); + + protected readonly buttonClasses = computed(() => 'w-full justify-between'); + + protected readonly iconClasses = computed(() => 'icon-chevrons-up-down ml-2 h-4 w-4 shrink-0 opacity-50'); + + protected readonly popoverClasses = computed(() => { + const widthClass = this.zWidth() === 'full' ? 'w-full' : 'w-[200px]'; + return `${widthClass} p-0`; + }); + + protected readonly getCurrentValue = computed(() => this.value() ?? this.internalValue()); + + protected readonly displayValue = computed(() => { + const currentValue = this.getCurrentValue(); + if (!currentValue) return null; + + // Search in groups first + if (this.groups().length > 0) { + for (const group of this.groups()) { + const option = group.options.find(opt => opt.value === currentValue); + if (option) return option.label; + } + } + + // Then search in flat options + const option = this.options().find(opt => opt.value === currentValue); + return option?.label || null; + }); + + private onChange: (value: string | null) => void = () => { + // ControlValueAccessor implementation + }; + private onTouched: () => void = () => { + // ControlValueAccessor implementation + }; + + setOpen(open: boolean) { + this.open.set(open); + if (open) { + // Give time for the popover content to render and options to be detected + setTimeout(() => { + const commandRef = this.commandRef(); + if (commandRef) { + // Refresh options to ensure they're detected + commandRef.refreshOptions(); + // Focus on search input if searchable, otherwise on command component + if (this.searchable()) { + this.commandInputRef()?.focus(); + } else { + commandRef.focus(); + } + } + }, 10); + } + } + + handleSelect(commandOption: ZardCommandOption) { + const selectedValue = commandOption.value as string; + + // Toggle behavior - if same value is selected, clear it + const newValue = selectedValue === this.getCurrentValue() ? null : selectedValue; + + this.internalValue.set(newValue); + this.onChange(newValue); + this.zValueChange.emit(newValue); + + // Emit the combobox option if we have a selection + if (newValue) { + let selectedOption: ZardComboboxOption | undefined; + + if (this.groups().length > 0) { + for (const group of this.groups()) { + selectedOption = group.options.find(opt => opt.value === newValue); + if (selectedOption) break; + } + } else { + selectedOption = this.options().find(opt => opt.value === newValue); + } + + if (selectedOption) { + this.zOnSelect.emit(selectedOption); + } + } + + // Close the popover + this.popoverDirective().hide(); + + // Return focus to the combobox button after selection + this.buttonRef().nativeElement.focus(); + } + + @HostListener('keydown', ['$event']) + onKeyDown(event: KeyboardEvent) { + if (this.disabled()) return; + + // Handle different keyboard events based on combobox state + if (this.open()) { + // When popover is open + switch (event.key) { + case 'Escape': + event.preventDefault(); + event.stopPropagation(); + this.popoverDirective().hide(); + this.buttonRef().nativeElement.focus(); + break; + + case 'Tab': + // Allow tab to close and move to next element + this.popoverDirective().hide(); + break; + + case 'ArrowDown': + case 'ArrowUp': + case 'Enter': + case 'Home': + case 'End': + case 'PageUp': + case 'PageDown': + // Forward navigation to command component + event.preventDefault(); + this.commandRef()?.onKeyDown(event); + break; + } + } else { + // When popover is closed + switch (event.key) { + case 'ArrowDown': + case 'ArrowUp': + case 'Enter': + case ' ': // Space key + event.preventDefault(); + this.popoverDirective().show(); + break; + + case 'Escape': + // Clear selection if there's a value + if (this.getCurrentValue()) { + event.preventDefault(); + this.internalValue.set(null); + this.onChange(null); + this.zValueChange.emit(null); + } + break; + + default: + // For searchable comboboxes, open and start typing + if (this.searchable() && event.key.length === 1 && !event.ctrlKey && !event.altKey && !event.metaKey) { + event.preventDefault(); + this.popoverDirective().show(); + // Let the command input handle the character after opening + setTimeout(() => { + const inputElement = this.commandInputRef(); + if (inputElement) { + inputElement.focus(); + // Simulate the key press in the input + const input = inputElement as unknown as { + searchInput?: { nativeElement: HTMLInputElement }; + searchTerm: { set: (value: string) => void }; + searchSubject: { next: (value: string) => void }; + }; + if (input.searchInput?.nativeElement) { + input.searchInput.nativeElement.value = event.key; + input.searchTerm.set(event.key); + input.searchSubject.next(event.key); + } + } + }, 20); + } + break; + } + } + } + + @HostListener('document:keydown', ['$event']) + onDocumentKeyDown(event: KeyboardEvent) { + // Close on Escape from anywhere when this combobox is open + if (this.open() && event.key === 'Escape') { + const target = event.target as Element; + const buttonElement = this.buttonRef().nativeElement; + // Only handle if not already handled by the component itself + if (!buttonElement.contains(target)) { + this.popoverDirective().hide(); + this.buttonRef().nativeElement.focus(); + } + } + } + + // ControlValueAccessor implementation + writeValue(value: string | null): void { + this.internalValue.set(value); + } + + registerOnChange(fn: (value: string | null) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState(): void { + // The disabled state is handled by the disabled input + } +} \ No newline at end of file diff --git a/src/app/shared/components/combobox/combobox.variants.ts b/src/app/shared/components/combobox/combobox.variants.ts new file mode 100644 index 0000000..2f4c22f --- /dev/null +++ b/src/app/shared/components/combobox/combobox.variants.ts @@ -0,0 +1,18 @@ +import { cva, type VariantProps } from 'class-variance-authority'; + +export const comboboxVariants = cva('', { + variants: { + zWidth: { + default: 'w-[200px]', + sm: 'w-[150px]', + md: 'w-[250px]', + lg: 'w-[350px]', + full: 'w-full', + }, + }, + defaultVariants: { + zWidth: 'default', + }, +}); + +export type ZardComboboxVariants = VariantProps; \ No newline at end of file diff --git a/src/app/shared/components/command/command-divider.component.ts b/src/app/shared/components/command/command-divider.component.ts new file mode 100644 index 0000000..6c84dcd --- /dev/null +++ b/src/app/shared/components/command/command-divider.component.ts @@ -0,0 +1,39 @@ +import { ChangeDetectionStrategy, Component, computed, inject, input, ViewEncapsulation } from '@angular/core'; + +import { mergeClasses } from '@shared/utils/merge-classes'; +import { ZardCommandComponent } from './command.component'; +import { commandSeparatorVariants } from './command.variants'; + +import type { ClassValue } from 'clsx'; +@Component({ + selector: 'z-command-divider', + exportAs: 'zCommandDivider', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` + @if (shouldShow()) { +
    + } + `, +}) +export class ZardCommandDividerComponent { + private readonly commandComponent = inject(ZardCommandComponent, { optional: true }); + + readonly class = input(''); + + protected readonly classes = computed(() => mergeClasses(commandSeparatorVariants({}), this.class())); + + protected readonly shouldShow = computed(() => { + if (!this.commandComponent) return true; + + const searchTerm = this.commandComponent.searchTerm(); + + // If no search, always show dividers + if (searchTerm === '') return true; + + // If there's a search term, hide all dividers for now + // This is a simple approach - we can make it smarter later + return false; + }); +} \ No newline at end of file diff --git a/src/app/shared/components/command/command-empty.component.ts b/src/app/shared/components/command/command-empty.component.ts new file mode 100644 index 0000000..f13f141 --- /dev/null +++ b/src/app/shared/components/command/command-empty.component.ts @@ -0,0 +1,47 @@ +import { ChangeDetectionStrategy, Component, computed, inject, input, ViewEncapsulation } from '@angular/core'; + +import { mergeClasses } from '@shared/utils/merge-classes'; +import { ZardCommandJsonComponent } from './command-json.component'; +import { ZardCommandComponent } from './command.component'; +import { commandEmptyVariants } from './command.variants'; + +import type { ClassValue } from 'clsx'; + +@Component({ + selector: 'z-command-empty', + exportAs: 'zCommandEmpty', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` + @if (shouldShow()) { +
    + No results found. +
    + } + `, +}) +export class ZardCommandEmptyComponent { + private readonly commandComponent = inject(ZardCommandComponent, { optional: true }); + private readonly jsonCommandComponent = inject(ZardCommandJsonComponent, { optional: true }); + + readonly class = input(''); + + protected readonly classes = computed(() => mergeClasses(commandEmptyVariants({}), this.class())); + + protected readonly shouldShow = computed(() => { + // Check traditional command component + if (this.commandComponent) { + const filteredOptions = this.commandComponent.filteredOptions(); + return filteredOptions.length === 0; + } + + // Check JSON command component + if (this.jsonCommandComponent) { + const filteredGroups = this.jsonCommandComponent.filteredGroups(); + return filteredGroups.length === 0; + } + + return false; + }); +} \ No newline at end of file diff --git a/src/app/shared/components/command/command-input.component.ts b/src/app/shared/components/command/command-input.component.ts new file mode 100644 index 0000000..03938a0 --- /dev/null +++ b/src/app/shared/components/command/command-input.component.ts @@ -0,0 +1,173 @@ +import { Subject, switchMap, takeUntil, timer } from 'rxjs'; + +import { + ChangeDetectionStrategy, + Component, + computed, + ElementRef, + EventEmitter, + forwardRef, + inject, + input, + OnDestroy, + OnInit, + Output, + signal, + viewChild, + ViewEncapsulation, +} from '@angular/core'; +import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms'; + +import { mergeClasses } from '@shared/utils/merge-classes'; +import { ZardCommandJsonComponent } from './command-json.component'; +import { ZardCommandComponent } from './command.component'; +import { commandInputVariants } from './command.variants'; + +import type { ClassValue } from 'clsx'; +@Component({ + selector: 'z-command-input', + exportAs: 'zCommandInput', + standalone: true, + imports: [FormsModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
    + + +
    + `, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ZardCommandInputComponent), + multi: true, + }, + ], +}) +export class ZardCommandInputComponent implements ControlValueAccessor, OnInit, OnDestroy { + private readonly commandComponent = inject(ZardCommandComponent, { optional: true }); + private readonly jsonCommandComponent = inject(ZardCommandJsonComponent, { optional: true }); + readonly searchInput = viewChild.required>('searchInput'); + + readonly placeholder = input('Type a command or search...'); + readonly class = input(''); + + @Output() readonly valueChange = new EventEmitter(); + + readonly searchTerm = signal(''); + private searchSubject = new Subject(); + private destroy$ = new Subject(); + + protected readonly classes = computed(() => mergeClasses(commandInputVariants({}), this.class())); + + private onChange = (_value: string) => { + // ControlValueAccessor implementation - intentionally empty + }; + private onTouched = () => { + // ControlValueAccessor implementation - intentionally empty + }; + + ngOnInit(): void { + // Set up debounced search stream - always send to subject + this.searchSubject + .pipe( + switchMap(value => { + // If empty, emit immediately, otherwise debounce + return value === '' ? timer(0) : timer(150); + }), + takeUntil(this.destroy$), + ) + .subscribe(() => { + // Get the current value from the signal to ensure we have the latest + const currentValue = this.searchTerm(); + this.updateParentComponents(currentValue); + }); + } + + onInput(event: Event) { + const target = event.target as HTMLInputElement; + const value = target.value; + this.searchTerm.set(value); + + // Always send to subject - let the stream handle timing + this.searchSubject.next(value); + } + + private updateParentComponents(value: string): void { + // Send search to appropriate parent component + if (this.commandComponent) { + this.commandComponent.onSearch(value); + } else if (this.jsonCommandComponent) { + this.jsonCommandComponent.onSearch(value); + } + this.onChange(value); + this.valueChange.emit(value); + } + + onKeyDown(event: KeyboardEvent) { + // Let parent command component handle navigation keys + if (['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(event.key)) { + // For Escape key, don't stop propagation to allow document listener to work + if (event.key !== 'Escape') { + event.preventDefault(); // Prevent default input behavior + event.stopPropagation(); // Stop the event from bubbling up + } + + // Try both types of parent components + if (this.commandComponent) { + this.commandComponent.onKeyDown(event); + } else if (this.jsonCommandComponent) { + this.jsonCommandComponent.handleKeydown(event); + } + return; + } + // Handle other keys as needed + } + + writeValue(value: string): void { + this.searchTerm.set(value || ''); + } + + registerOnChange(fn: (value: string) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState(_isDisabled: boolean): void { + // Implementation if needed for form control disabled state + } + + /** + * Focus the input element + */ + focus(): void { + this.searchInput().nativeElement.focus(); + } + + ngOnDestroy(): void { + // Complete subjects to clean up subscriptions + this.destroy$.next(); + this.destroy$.complete(); + this.searchSubject.complete(); + } +} \ No newline at end of file diff --git a/src/app/shared/components/command/command-json.component.ts b/src/app/shared/components/command/command-json.component.ts new file mode 100644 index 0000000..1e6cb2c --- /dev/null +++ b/src/app/shared/components/command/command-json.component.ts @@ -0,0 +1,286 @@ +import type { ClassValue } from 'clsx'; + +import { ChangeDetectionStrategy, Component, computed, EventEmitter, forwardRef, HostListener, input, Output, signal, ViewEncapsulation } from '@angular/core'; +import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms'; + +import { mergeClasses } from '@shared/utils/merge-classes'; +import { ZardCommandDividerComponent } from './command-divider.component'; +import { ZardCommandEmptyComponent } from './command-empty.component'; +import { ZardCommandInputComponent } from './command-input.component'; +import { ZardCommandListComponent } from './command-list.component'; +import { ZardCommandOptionGroupComponent } from './command-option-group.component'; +import { ZardCommandOptionComponent } from './command-option.component'; +import { ZardCommandConfig, ZardCommandOption } from './command.component'; +import { commandVariants, ZardCommandVariants } from './command.variants'; + +@Component({ + selector: 'z-command-json', + exportAs: 'zCommandJson', + standalone: true, + imports: [ + FormsModule, + ZardCommandInputComponent, + ZardCommandListComponent, + ZardCommandEmptyComponent, + ZardCommandOptionComponent, + ZardCommandOptionGroupComponent, + ZardCommandDividerComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
    +
    Use arrow keys to navigate, Enter to select, Escape to clear selection.
    +
    + {{ statusMessage() }} +
    + + + {{ config().emptyText || 'No results found.' }} + + @for (group of filteredGroups(); track group.label; let groupIndex = $index) { + + @for (option of group.visibleOptions; track option.value; let optionIndex = $index) { + + + } + + + @if (config().dividers !== false && shouldShowDivider(groupIndex)) { + + } + } + +
    + `, + host: { + '[attr.role]': '"combobox"', + '[attr.aria-expanded]': 'true', + '[attr.aria-haspopup]': '"listbox"', + }, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ZardCommandJsonComponent), + multi: true, + }, + ], +}) +export class ZardCommandJsonComponent implements ControlValueAccessor { + readonly config = input.required(); + readonly size = input('default'); + readonly class = input(''); + + @Output() readonly zOnChange = new EventEmitter(); + @Output() readonly zOnSelect = new EventEmitter(); + + // Search functionality + readonly searchTerm = signal(''); + readonly selectedIndex = signal(-1); + + protected readonly classes = computed(() => mergeClasses(commandVariants({ size: this.size() }), this.class())); + + // Computed filtered groups based on search term + readonly filteredGroups = computed(() => { + const searchTerm = this.searchTerm().toLowerCase(); + const groups = this.config().groups; + + if (searchTerm === '') { + return groups.map((group, index) => ({ + label: group.label, + visibleOptions: group.options, + originalIndex: index, + })); + } + + return groups + .map((group, index) => ({ + label: group.label, + visibleOptions: group.options.filter(option => { + const label = option.label.toLowerCase(); + const command = option.command?.toLowerCase() || ''; + const value = String(option.value).toLowerCase(); + return label.includes(searchTerm) || command.includes(searchTerm) || value.includes(searchTerm); + }), + originalIndex: index, + })) + .filter(group => group.visibleOptions.length > 0); + }); + + // Status message for screen readers + protected readonly statusMessage = computed(() => { + const searchTerm = this.searchTerm(); + const filteredGroups = this.filteredGroups(); + const totalOptions = filteredGroups.reduce((acc, group) => acc + group.visibleOptions.length, 0); + + if (searchTerm === '') return ''; + + if (totalOptions === 0) { + return `No results found for "${searchTerm}"`; + } + + return `${totalOptions} result${totalOptions === 1 ? '' : 's'} found for "${searchTerm}"`; + }); + + private onChange = (_value: unknown) => { + // ControlValueAccessor implementation + }; + private onTouched = () => { + // ControlValueAccessor implementation + }; + + onSearch(searchTerm: string) { + this.searchTerm.set(searchTerm); + this.selectedIndex.set(-1); // Reset selection when searching + } + + shouldShowDivider(currentIndex: number): boolean { + const filteredGroups = this.filteredGroups(); + return currentIndex < filteredGroups.length - 1; + } + + onOptionClick(option: ZardCommandOption) { + if (option.disabled) return; + + // Execute option's action if defined + if (option.action) { + option.action(); + } + + // Execute global onSelect callback if defined + const onSelect = this.config().onSelect; + if (onSelect) { + onSelect(option); + } + + this.onChange(option.value); + this.zOnChange.emit(option); + this.zOnSelect.emit(option); + } + + // Handle keyboard navigation and shortcuts + @HostListener('keydown', ['$event']) + handleKeydown(event: KeyboardEvent) { + // Handle global shortcuts (Ctrl/Cmd + key) + if (event.metaKey || event.ctrlKey) { + const matchingOption = this.findOptionByKey(event.key.toLowerCase()); + if (matchingOption) { + event.preventDefault(); + this.onOptionClick(matchingOption); + return; + } + } + + // Handle keyboard navigation + const flatOptions = this.getFlatOptions(); + const currentIndex = this.selectedIndex(); + + switch (event.key) { + case 'ArrowDown': { + event.preventDefault(); + const nextIndex = currentIndex < flatOptions.length - 1 ? currentIndex + 1 : 0; + this.selectedIndex.set(nextIndex); + this.scrollToSelectedOption(); + break; + } + + case 'ArrowUp': { + event.preventDefault(); + const prevIndex = currentIndex > 0 ? currentIndex - 1 : flatOptions.length - 1; + this.selectedIndex.set(prevIndex); + this.scrollToSelectedOption(); + break; + } + + case 'Enter': + event.preventDefault(); + if (currentIndex >= 0 && currentIndex < flatOptions.length) { + const selectedOption = flatOptions[currentIndex]; + if (!selectedOption.disabled) { + this.onOptionClick(selectedOption); + } + } + break; + + case 'Escape': + event.preventDefault(); + this.selectedIndex.set(-1); + break; + } + } + + private findOptionByKey(key: string): ZardCommandOption | undefined { + for (const group of this.config().groups) { + const option = group.options.find(opt => opt.key?.toLowerCase() === key); + if (option) return option; + } + return undefined; + } + + private getFlatOptions(): ZardCommandOption[] { + const filteredGroups = this.filteredGroups(); + const flatOptions: ZardCommandOption[] = []; + + filteredGroups.forEach(group => { + flatOptions.push(...group.visibleOptions); + }); + + return flatOptions; + } + + getOptionClasses(groupIndex: number, optionIndex: number): string { + const flatIndex = this.getFlatOptionIndex(groupIndex, optionIndex); + const isSelected = flatIndex === this.selectedIndex(); + return isSelected ? 'bg-accent text-accent-foreground' : ''; + } + + private getFlatOptionIndex(groupIndex: number, optionIndex: number): number { + const filteredGroups = this.filteredGroups(); + let flatIndex = 0; + + for (let i = 0; i < groupIndex; i++) { + flatIndex += filteredGroups[i].visibleOptions.length; + } + + return flatIndex + optionIndex; + } + + // ControlValueAccessor implementation + writeValue(_value: unknown): void { + // Implementation if needed for form control integration + } + + registerOnChange(fn: (value: unknown) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState(_isDisabled: boolean): void { + // Implementation if needed for form control disabled state + } + + private scrollToSelectedOption(): void { + const selectedIndex = this.selectedIndex(); + if (selectedIndex < 0) return; + + // Use a timeout to ensure DOM is updated + setTimeout(() => { + const selectedElement = document.querySelector(`z-command-option.bg-accent`); + if (selectedElement) { + selectedElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + }, 0); + } +} \ No newline at end of file diff --git a/src/app/shared/components/command/command-list.component.ts b/src/app/shared/components/command/command-list.component.ts new file mode 100644 index 0000000..4df61d3 --- /dev/null +++ b/src/app/shared/components/command/command-list.component.ts @@ -0,0 +1,24 @@ +import type { ClassValue } from 'clsx'; + +import { ChangeDetectionStrategy, Component, computed, input, ViewEncapsulation } from '@angular/core'; + +import { mergeClasses } from '@shared/utils/merge-classes'; +import { commandListVariants } from './command.variants'; + +@Component({ + selector: 'z-command-list', + exportAs: 'zCommandList', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
    + +
    + `, +}) +export class ZardCommandListComponent { + readonly class = input(''); + + protected readonly classes = computed(() => mergeClasses(commandListVariants({}), this.class())); +} \ No newline at end of file diff --git a/src/app/shared/components/command/command-option-group.component.ts b/src/app/shared/components/command/command-option-group.component.ts new file mode 100644 index 0000000..041bd68 --- /dev/null +++ b/src/app/shared/components/command/command-option-group.component.ts @@ -0,0 +1,59 @@ +import { AfterContentInit, ChangeDetectionStrategy, Component, computed, contentChildren, inject, input, ViewEncapsulation } from '@angular/core'; + +import { mergeClasses } from '@shared/utils/merge-classes'; +import { ZardCommandOptionComponent } from './command-option.component'; +import { ZardCommandComponent } from './command.component'; +import { commandGroupHeadingVariants, commandGroupVariants } from './command.variants'; + +import type { ClassValue } from 'clsx'; + +@Component({ + selector: 'z-command-option-group', + exportAs: 'zCommandOptionGroup', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` + @if (shouldShow()) { +
    + @if (zLabel()) { +
    + {{ zLabel() }} +
    + } +
    + +
    +
    + } + `, +}) +export class ZardCommandOptionGroupComponent implements AfterContentInit { + private readonly commandComponent = inject(ZardCommandComponent, { optional: true }); + + readonly optionComponents = contentChildren(ZardCommandOptionComponent, { descendants: true }); + + readonly zLabel = input.required(); + readonly class = input(''); + + protected readonly classes = computed(() => mergeClasses(commandGroupVariants({}), this.class())); + + protected readonly headingClasses = computed(() => mergeClasses(commandGroupHeadingVariants({}))); + + protected readonly shouldShow = computed(() => { + if (!this.commandComponent || !this.optionComponents) return true; + + const searchTerm = this.commandComponent.searchTerm(); + const filteredOptions = this.commandComponent.filteredOptions(); + + // If no search term, show all groups + if (searchTerm === '') return true; + + // Check if any option in this group is in the filtered list + return this.optionComponents().some(option => filteredOptions.includes(option)); + }); + + ngAfterContentInit() { + // Component is ready when content children are initialized + } +} \ No newline at end of file diff --git a/src/app/shared/components/command/command-option.component.ts b/src/app/shared/components/command/command-option.component.ts new file mode 100644 index 0000000..0b5c499 --- /dev/null +++ b/src/app/shared/components/command/command-option.component.ts @@ -0,0 +1,104 @@ +import { ChangeDetectionStrategy, Component, computed, ElementRef, inject, input, signal, ViewEncapsulation } from '@angular/core'; + +import { mergeClasses, transform } from '@shared/utils/merge-classes'; +import { ZardCommandComponent } from './command.component'; +import { commandItemVariants, commandShortcutVariants, ZardCommandItemVariants } from './command.variants'; + +import type { ClassValue } from 'clsx'; + +@Component({ + selector: 'z-command-option', + exportAs: 'zCommandOption', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` + @if (shouldShow()) { +
    + @if (zIcon()) { +
    + } + {{ zLabel() }} + @if (zShortcut()) { + {{ zShortcut() }} + } +
    + } + `, +}) +export class ZardCommandOptionComponent { + private readonly elementRef = inject(ElementRef); + private readonly commandComponent = inject(ZardCommandComponent, { optional: true }); + + readonly zValue = input.required(); + readonly zLabel = input.required(); + readonly zIcon = input(''); + readonly zCommand = input(''); + readonly zShortcut = input(''); + readonly zDisabled = input(false, { transform }); + readonly variant = input('default'); + readonly class = input(''); + + readonly isSelected = signal(false); + + protected readonly classes = computed(() => { + const baseClasses = commandItemVariants({ variant: this.variant() }); + const selectedClasses = this.isSelected() ? 'bg-accent text-accent-foreground' : ''; + return mergeClasses(baseClasses, selectedClasses, this.class()); + }); + + protected readonly shortcutClasses = computed(() => mergeClasses(commandShortcutVariants({}))); + + protected readonly shouldShow = computed(() => { + if (!this.commandComponent) return true; + + const filteredOptions = this.commandComponent.filteredOptions(); + const searchTerm = this.commandComponent.searchTerm(); + + // If no search term, show all options + if (searchTerm === '') return true; + + // Check if this option is in the filtered list + return filteredOptions.includes(this); + }); + + onClick() { + if (this.zDisabled()) return; + if (this.commandComponent) { + this.commandComponent.selectOption(this); + } + } + + onKeyDown(event: KeyboardEvent) { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + this.onClick(); + } + } + + onMouseEnter() { + if (this.zDisabled()) return; + // Visual feedback for hover + } + + setSelected(selected: boolean) { + this.isSelected.set(selected); + } + + focus() { + const element = this.elementRef.nativeElement; + element.focus(); + // Scroll element into view if needed + element.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } +} \ No newline at end of file diff --git a/src/app/shared/components/command/command.component.ts b/src/app/shared/components/command/command.component.ts new file mode 100644 index 0000000..ec9443f --- /dev/null +++ b/src/app/shared/components/command/command.component.ts @@ -0,0 +1,257 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + contentChild, + contentChildren, + effect, + EventEmitter, + forwardRef, + HostListener, + input, + Output, + signal, + ViewEncapsulation, +} from '@angular/core'; +import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms'; + +import { mergeClasses } from '@shared/utils/merge-classes'; +import { ZardCommandInputComponent } from './command-input.component'; +import { ZardCommandOptionComponent } from './command-option.component'; +import { commandVariants, ZardCommandVariants } from './command.variants'; + +import type { ClassValue } from 'clsx'; + +export interface ZardCommandOption { + value: unknown; + label: string; + disabled?: boolean; + command?: string; + shortcut?: string; + icon?: string; + action?: () => void; + key?: string; // Keyboard shortcut key (e.g., 'n' for Ctrl+N) +} + +export interface ZardCommandGroup { + label: string; + options: ZardCommandOption[]; +} + +export interface ZardCommandConfig { + placeholder?: string; + emptyText?: string; + groups: ZardCommandGroup[]; + dividers?: boolean; + onSelect?: (option: ZardCommandOption) => void; +} + +@Component({ + selector: 'z-command', + exportAs: 'zCommand', + standalone: true, + imports: [FormsModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
    +
    Use arrow keys to navigate, Enter to select, Escape to clear selection.
    +
    + {{ statusMessage() }} +
    + +
    + `, + host: { + '[attr.role]': '"combobox"', + '[attr.aria-expanded]': 'true', + '[attr.aria-haspopup]': '"listbox"', + }, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ZardCommandComponent), + multi: true, + }, + ], +}) +export class ZardCommandComponent implements ControlValueAccessor { + readonly commandInput = contentChild(ZardCommandInputComponent); + readonly optionComponents = contentChildren(ZardCommandOptionComponent, { descendants: true }); + + readonly size = input('default'); + readonly class = input(''); + + @Output() readonly zOnChange = new EventEmitter(); + @Output() readonly zOnSelect = new EventEmitter(); + + // Internal signals for search functionality + readonly searchTerm = signal(''); + readonly selectedIndex = signal(-1); + + // Signal to trigger updates when optionComponents change + private readonly optionsUpdateTrigger = signal(0); + + protected readonly classes = computed(() => mergeClasses(commandVariants({ size: this.size() }), this.class())); + + // Computed signal for filtered options - this will automatically update when searchTerm or options change + readonly filteredOptions = computed(() => { + const searchTerm = this.searchTerm(); + // Include the trigger signal to make this computed reactive to option changes + this.optionsUpdateTrigger(); + + if (!this.optionComponents()) return []; + + const lowerSearchTerm = searchTerm.toLowerCase().trim(); + if (lowerSearchTerm === '') return this.optionComponents(); + + return this.optionComponents().filter(option => { + const label = option.zLabel().toLowerCase(); + const command = option.zCommand()?.toLowerCase() || ''; + return label.includes(lowerSearchTerm) || command.includes(lowerSearchTerm); + }); + }); + + // Status message for screen readers + protected readonly statusMessage = computed(() => { + const searchTerm = this.searchTerm(); + const filteredCount = this.filteredOptions().length; + + if (searchTerm === '') return ''; + + if (filteredCount === 0) { + return `No results found for "${searchTerm}"`; + } + + return `${filteredCount} result${filteredCount === 1 ? '' : 's'} found for "${searchTerm}"`; + }); + + private onChange = (_value: unknown) => { + // ControlValueAccessor implementation + }; + private onTouched = () => { + // ControlValueAccessor implementation + }; + + constructor() { + effect(() => { + this.triggerOptionsUpdate(); + }); + } + + /** + * Trigger an update to the filteredOptions computed signal + */ + private triggerOptionsUpdate(): void { + this.optionsUpdateTrigger.update(value => value + 1); + } + + onSearch(searchTerm: string) { + this.searchTerm.set(searchTerm); + this.selectedIndex.set(-1); + this.updateSelectedOption(); + } + + selectOption(option: ZardCommandOptionComponent) { + const commandOption: ZardCommandOption = { + value: option.zValue(), + label: option.zLabel(), + disabled: option.zDisabled(), + command: option.zCommand(), + shortcut: option.zShortcut(), + icon: option.zIcon(), + }; + + this.onChange(commandOption.value); + this.zOnChange.emit(commandOption); + this.zOnSelect.emit(commandOption); + } + + @HostListener('keydown', ['$event']) + onKeyDown(event: KeyboardEvent) { + const filteredOptions = this.filteredOptions(); + if (filteredOptions.length === 0) return; + + const currentIndex = this.selectedIndex(); + + switch (event.key) { + case 'ArrowDown': { + event.preventDefault(); + const nextIndex = currentIndex < filteredOptions.length - 1 ? currentIndex + 1 : 0; + this.selectedIndex.set(nextIndex); + this.updateSelectedOption(); + break; + } + + case 'ArrowUp': { + event.preventDefault(); + const prevIndex = currentIndex > 0 ? currentIndex - 1 : filteredOptions.length - 1; + this.selectedIndex.set(prevIndex); + this.updateSelectedOption(); + break; + } + + case 'Enter': + event.preventDefault(); + if (currentIndex >= 0 && currentIndex < filteredOptions.length) { + const selectedOption = filteredOptions[currentIndex]; + if (!selectedOption.zDisabled()) { + this.selectOption(selectedOption); + } + } + break; + + case 'Escape': + event.preventDefault(); + this.selectedIndex.set(-1); + this.updateSelectedOption(); + break; + } + } + + private updateSelectedOption() { + const filteredOptions = this.filteredOptions(); + const selectedIndex = this.selectedIndex(); + + // Clear previous selection + filteredOptions.forEach(option => option.setSelected(false)); + + // Set new selection + if (selectedIndex >= 0 && selectedIndex < filteredOptions.length) { + const selectedOption = filteredOptions[selectedIndex]; + selectedOption.setSelected(true); + selectedOption.focus(); + } + } + + // ControlValueAccessor implementation + writeValue(_value: unknown): void { + // Implementation if needed for form control integration + } + + registerOnChange(fn: (value: unknown) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState(_isDisabled: boolean): void { + // Implementation if needed for form control disabled state + } + + /** + * Refresh the options list - useful when options are added/removed dynamically + */ + refreshOptions(): void { + this.triggerOptionsUpdate(); + } + + /** + * Focus the command input + */ + focus(): void { + this.commandInput()?.focus(); + } +} \ No newline at end of file diff --git a/src/app/shared/components/command/command.module.ts b/src/app/shared/components/command/command.module.ts new file mode 100644 index 0000000..0091616 --- /dev/null +++ b/src/app/shared/components/command/command.module.ts @@ -0,0 +1,28 @@ +import { FormsModule } from '@angular/forms'; +import { NgModule } from '@angular/core'; + +import { ZardCommandOptionGroupComponent } from './command-option-group.component'; +import { ZardCommandDividerComponent } from './command-divider.component'; +import { ZardCommandOptionComponent } from './command-option.component'; +import { ZardCommandInputComponent } from './command-input.component'; +import { ZardCommandEmptyComponent } from './command-empty.component'; +import { ZardCommandListComponent } from './command-list.component'; +import { ZardCommandJsonComponent } from './command-json.component'; +import { ZardCommandComponent } from './command.component'; + +const COMMAND_COMPONENTS = [ + ZardCommandComponent, + ZardCommandInputComponent, + ZardCommandListComponent, + ZardCommandEmptyComponent, + ZardCommandOptionComponent, + ZardCommandOptionGroupComponent, + ZardCommandDividerComponent, + ZardCommandJsonComponent, +]; + +@NgModule({ + imports: [FormsModule, ...COMMAND_COMPONENTS], + exports: [...COMMAND_COMPONENTS], +}) +export class ZardCommandModule {} \ No newline at end of file diff --git a/src/app/shared/components/command/command.variants.ts b/src/app/shared/components/command/command.variants.ts new file mode 100644 index 0000000..311f6b5 --- /dev/null +++ b/src/app/shared/components/command/command.variants.ts @@ -0,0 +1,71 @@ +import { cva, VariantProps } from 'class-variance-authority'; + +export const commandVariants = cva('flex h-full w-full flex-col overflow-hidden shadow-md border rounded-md bg-popover text-popover-foreground', { + variants: { + size: { + sm: 'min-h-64', + default: 'min-h-80', + lg: 'min-h-96', + xl: 'min-h-[30rem]', + }, + }, + defaultVariants: { + size: 'default', + }, +}); + +export const commandInputVariants = cva( + 'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50', + { + variants: {}, + defaultVariants: {}, + }, +); + +export const commandListVariants = cva('max-h-[300px] overflow-y-auto overflow-x-hidden p-1', { + variants: {}, + defaultVariants: {}, +}); + +export const commandEmptyVariants = cva('py-6 text-center text-sm text-muted-foreground', { + variants: {}, + defaultVariants: {}, +}); + +export const commandGroupVariants = cva('overflow-hidden text-foreground', { + variants: {}, + defaultVariants: {}, +}); + +export const commandGroupHeadingVariants = cva('px-2 py-1.5 text-xs font-medium text-muted-foreground', { + variants: {}, + defaultVariants: {}, +}); + +export const commandItemVariants = cva( + 'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50', + { + variants: { + variant: { + default: '', + destructive: 'aria-selected:bg-destructive aria-selected:text-destructive-foreground hover:bg-destructive hover:text-destructive-foreground', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +); + +export const commandSeparatorVariants = cva('-mx-1 my-1 h-px bg-border', { + variants: {}, + defaultVariants: {}, +}); + +export const commandShortcutVariants = cva('ml-auto text-xs tracking-widest text-muted-foreground', { + variants: {}, + defaultVariants: {}, +}); + +export type ZardCommandVariants = VariantProps; +export type ZardCommandItemVariants = VariantProps; \ No newline at end of file diff --git a/src/app/shared/components/core/directives/string-template-outlet/string-template-outlet.directive.ts b/src/app/shared/components/core/directives/string-template-outlet/string-template-outlet.directive.ts new file mode 100644 index 0000000..835d9f4 --- /dev/null +++ b/src/app/shared/components/core/directives/string-template-outlet/string-template-outlet.directive.ts @@ -0,0 +1,83 @@ +import { Directive, EmbeddedViewRef, inject, Input, OnChanges, SimpleChange, SimpleChanges, TemplateRef, ViewContainerRef } from '@angular/core'; + +export function isTemplateRef(value: TemplateRef | unknown): value is TemplateRef { + return value instanceof TemplateRef; +} + +@Directive({ + selector: '[zStringTemplateOutlet]', + exportAs: 'zStringTemplateOutlet', +}) +export class ZardStringTemplateOutletDirective<_T = unknown> implements OnChanges { + private viewContainer = inject(ViewContainerRef); + private templateRef = inject(TemplateRef); + + private embeddedViewRef: EmbeddedViewRef | null = null; + private context = new ZardStringTemplateOutletContext(); + @Input() zStringTemplateOutletContext: any | null = null; + @Input() zStringTemplateOutlet: unknown | TemplateRef = null; + + static ngTemplateContextGuard(_dir: ZardStringTemplateOutletDirective, _ctx: unknown): _ctx is ZardStringTemplateOutletContext { + return true; + } + + private recreateView(): void { + this.viewContainer.clear(); + if (isTemplateRef(this.zStringTemplateOutlet)) { + this.embeddedViewRef = this.viewContainer.createEmbeddedView(this.zStringTemplateOutlet, this.zStringTemplateOutletContext); + } else { + this.embeddedViewRef = this.viewContainer.createEmbeddedView(this.templateRef, this.context); + } + } + + private updateContext(): void { + const newCtx = isTemplateRef(this.zStringTemplateOutlet) ? this.zStringTemplateOutletContext : this.context; + const oldCtx = this.embeddedViewRef?.context as any; + if (newCtx) { + for (const propName of Object.keys(newCtx)) { + oldCtx[propName] = newCtx[propName]; + } + } + } + + ngOnChanges(changes: SimpleChanges): void { + const { zStringTemplateOutletContext, zStringTemplateOutlet } = changes; + const shouldRecreateView = (): boolean => { + let shouldOutletRecreate = false; + if (zStringTemplateOutlet) { + shouldOutletRecreate = zStringTemplateOutlet.firstChange || isTemplateRef(zStringTemplateOutlet.previousValue) || isTemplateRef(zStringTemplateOutlet.currentValue); + } + const hasContextShapeChanged = (ctxChange: SimpleChange): boolean => { + const prevCtxKeys = Object.keys(ctxChange.previousValue || {}); + const currCtxKeys = Object.keys(ctxChange.currentValue || {}); + if (prevCtxKeys.length === currCtxKeys.length) { + for (const propName of currCtxKeys) { + if (prevCtxKeys.indexOf(propName) === -1) { + return true; + } + } + return false; + } else { + return true; + } + }; + const shouldContextRecreate = zStringTemplateOutletContext && hasContextShapeChanged(zStringTemplateOutletContext); + return shouldContextRecreate || shouldOutletRecreate; + }; + + if (zStringTemplateOutlet) { + this.context.$implicit = zStringTemplateOutlet.currentValue; + } + + const recreateView = shouldRecreateView(); + if (recreateView) { + this.recreateView(); + } else { + this.updateContext(); + } + } +} + +export class ZardStringTemplateOutletContext { + public $implicit: unknown; +} diff --git a/src/app/shared/components/data-table/data-table.css b/src/app/shared/components/data-table/data-table.css new file mode 100644 index 0000000..3af9500 --- /dev/null +++ b/src/app/shared/components/data-table/data-table.css @@ -0,0 +1,5 @@ +:host { + display: block; + /* Using a custom font for better aesthetics */ + font-family: 'Inter', sans-serif; +} diff --git a/src/app/shared/components/data-table/data-table.html b/src/app/shared/components/data-table/data-table.html new file mode 100644 index 0000000..50c33c1 --- /dev/null +++ b/src/app/shared/components/data-table/data-table.html @@ -0,0 +1,245 @@ + + +@if (enableColumnChooser()) { + +
    +
    +
    + +
    + + @if (showChooser()) { + + + +
    +
    +
    + @for (c of columns(); track c.key) { + + } +
    + +
    + + +
    +
    + } +
    +} + + + +
    + + + + @if (rowActionsTpl && actionsPosition() === 'left') { + + } @for (c of effectiveColumns(); track c.key) { + + + } @if (rowActionsTpl && actionsPosition() === 'right') { + + } + + + + + @for (row of data(); track $index; let i = $index) { + + @if (rowActionsTpl && actionsPosition() === 'left') { + + } @for (c of effectiveColumns(); track c.key) { + + + } @if (rowActionsTpl && actionsPosition() === 'right') { + + } + + } @empty { + + + + } + +
    + {{ actionsHeader() }} + + @if (c.sortable) { + + } @else { + {{ c.label }} + } + + {{ actionsHeader() }} +
    + + + @if (c.cell) { + + } @else if (c.cellTemplate) { + + } @else { + {{ getNestedValue(row, c.key) }} + } + + +
    + @if (loading()) { +
    + + + + + Chargement en cours… +
    + } @else { +
    + + + + + Aucune donnée disponible + + + Essayez de modifier vos filtres ou ajoutez un nouvel enregistrement + +
    + } +
    +
    diff --git a/src/app/shared/components/data-table/data-table.spec.ts b/src/app/shared/components/data-table/data-table.spec.ts new file mode 100644 index 0000000..bdc1674 --- /dev/null +++ b/src/app/shared/components/data-table/data-table.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { DataTable } from './data-table'; + +describe('DataTable', () => { + let component: DataTable; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DataTable] + }) + .compileComponents(); + + fixture = TestBed.createComponent(DataTable); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/components/data-table/data-table.ts b/src/app/shared/components/data-table/data-table.ts new file mode 100644 index 0000000..005974b --- /dev/null +++ b/src/app/shared/components/data-table/data-table.ts @@ -0,0 +1,265 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + ContentChild, + TemplateRef, + input, + output, + signal, + effect, + computed, +} from '@angular/core'; + +// --- Interfaces (kept outside the component class) --- +export interface TableColumn { + key: string; + label: string; + sortable?: boolean; + width?: string; + cellTemplate?: TemplateRef<{ $implicit: T }>; + cell?: (row: T, index?: number) => string | number | null | undefined; + defaultVisible?: boolean; +} + +export interface SortState { + key: string; + dir: 'asc' | 'desc' | ''; +} +// ----------------------------------------------------- + +@Component({ + selector: 'app-data-table', + standalone: true, // Assuming standalone component structure + imports: [CommonModule], + templateUrl: './data-table.html', + styleUrls: ['./data-table.css'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DataTable> { + // --- Inputs --- + columns = input[]>([]); + data = input([]); + loading = input(false); + sort = input({ key: '', dir: '' }); + enableColumnChooser = input(true); + persistenceKey = input(null); + minVisible = input(1); + visibleKeys = input(null); + actionsPosition = input<'left' | 'right' | 'none'>('right'); + actionsSticky = input(true); + actionsHeader = input('Actions'); + + // --- Outputs --- + sortChange = output(); + visibleKeysChange = output(); + + // --- Content Projection --- + @ContentChild('rowActions', { read: TemplateRef }) rowActionsTpl?: TemplateRef; + + // --- Internal State --- + protected _visible = signal([]); + protected showChooser = signal(false); + + // --- Core Reactivity: The Fix --- + /** Computed signal that filters the columns based on the current internal visible keys. */ + readonly effectiveColumns = computed[]>(() => { + const cols = this.columns() ?? []; + const vk = this._visible(); // Dependency: Updates when visible keys change + + // If no columns are defined or no keys are marked visible, return empty. + if (!cols.length || !vk || vk.length === 0) return []; + + const set = new Set(vk); + // Filter the original column list to maintain the correct order + return cols.filter((c) => set.has(c.key)); + }); + + // Computed Signal for Colspan (used for empty/loading row) + readonly colSpan = computed(() => { + const hasActions = this.rowActionsTpl && this.actionsPosition() !== 'none'; + return this.effectiveColumns().length + (hasActions ? 1 : 0); + }); + + private get isControlled(): boolean { + return this.visibleKeys() !== null && this.visibleKeys() !== undefined; + } + + constructor() { + // 1. Initialize visibility (loads defaults or persisted state) + effect( + () => { + this.columns(); // Re-run when column definition changes + this.loadInitialVisible(); + }, + { allowSignalWrites: true } + ); + + // 2. Handle controlled mode changes + effect(() => { + if (!this.isControlled) return; + const external = this.visibleKeys() ?? []; + const cols = this.columns() ?? []; + const allowed = new Set(cols.map((c) => c.key)); + const sanitized = external.filter((k) => allowed.has(k)); + + // ⬇️ Only mirror if it actually differs from our current optimistic state + if (!this.arraysEqual(this._visible(), sanitized)) { + this._visible.set(sanitized); + } + }); + + // 3. Persist and reset sort when internal state changes (in uncontrolled mode) + effect(() => { + const current = this._visible(); // dep + if (!this.isControlled) { + this.persistVisible(current); + } + this.resetSortIfHidden(); + }); + } + + private arraysEqual(a: string[], b: string[]) { + if (a === b) return true; + if (!a || !b || a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; + return true; + } + + // --- Methods --- + toggleSort(c: TableColumn) { + // ... (Sorting logic remains the same) + if (!c.sortable) return; + const currentSort = this.sort(); + let nextDir: 'asc' | 'desc' | '' = 'asc'; + if (currentSort.key === c.key) { + if (currentSort.dir === 'asc') nextDir = 'desc'; + else if (currentSort.dir === 'desc') nextDir = ''; + } + const newSortState: SortState = { key: nextDir ? c.key : '', dir: nextDir }; + this.sortChange.emit(newSortState); + } + + protected loadInitialVisible() { + const cols = this.columns() ?? []; + if (!cols.length) { + this._visible.set([]); + return; + } + + if (this.isControlled) { + const external = this.visibleKeys() ?? []; + const allowed = new Set(cols.map((c) => c.key)); + const sanitized = external.filter((k) => allowed.has(k)); + this._visible.set(sanitized); + return; + } + + const key = this.persistenceKey(); + if (key) { + try { + const raw = localStorage.getItem(key); + if (raw) { + const parsed = JSON.parse(raw) as { visibleKeys?: string[] }; + const list = (parsed?.visibleKeys ?? []).filter((k) => cols.some((c) => c.key === k)); + if (list.length) { + this._visible.set(list); + return; + } + } + } catch {} + } + + const defaults = cols.filter((c) => c.defaultVisible).map((c) => c.key); + if (defaults.length) { + this._visible.set(defaults); + return; + } + + this._visible.set(cols.map((c) => c.key)); + } + + protected persistVisible(current: string[]) { + const key = this.persistenceKey(); + if (!key) return; + try { + localStorage.setItem(key, JSON.stringify({ visibleKeys: current })); + } catch {} + } + + toggleColumn(key: string) { + const cols = this.columns() ?? []; + const allowed = new Set(cols.map((c) => c.key)); + if (!allowed.has(key)) return; + + const current = this._visible(); + const isOn = current.includes(key); + + let next: string[]; + if (isOn) { + if (current.length <= this.minVisible()) return; // keep at least minVisible + next = current.filter((k) => k !== key); + } else { + next = [...current, key]; + } + + // ⬇️ Optimistic update so the UI updates immediately + this._visible.set(next); + + if (this.isControlled) { + // Inform parent; if parent reflects it back, our effect won't stomp since arraysEqual + this.visibleKeysChange.emit(next); + } else { + // Uncontrolled: persistence handled by the effect that watches _visible() + // (nothing else to do) + } + } + + showAllColumns() { + const next = (this.columns() ?? []).map((c) => c.key); + this._visible.set(next); // optimistic + if (this.isControlled) this.visibleKeysChange.emit(next); + } + + hideAllColumnsExceptFirst() { + const cols = this.columns() ?? []; + if (!cols.length) return; + const next = [cols[0].key]; + this._visible.set(next); + if (this.isControlled) this.visibleKeysChange.emit(next); + } + + protected resetSortIfHidden() { + const s = this.sort(); + if (!s?.key) return; + const visibleSet = new Set(this._visible()); + if (!visibleSet.has(s.key)) { + this.sortChange.emit({ key: '', dir: '' }); + } + } + + getNestedValue(obj: any, path: string): any { + if (!obj || !path) return ''; + return path + .split('.') + .reduce((acc, key) => (acc && acc[key] !== undefined ? acc[key] : ''), obj); + } + + actionsThClasses(): string { + const sticky = this.actionsSticky() + ? 'sticky z-10 bg-white dark:bg-gray-800/80 backdrop-blur-sm transition-all duration-300' + : ''; + return this.actionsPosition() === 'right' + ? `${sticky} right-0 px-5 py-3` + : `${sticky} left-0 px-5 py-3`; + } + + actionsTdClasses(): string { + const sticky = this.actionsSticky() + ? 'sticky z-10 bg-white dark:bg-gray-900 border-l border-gray-100 dark:border-gray-800/50' + : ''; + return this.actionsPosition() === 'right' + ? `${sticky} right-0 p-4 text-right align-middle` + : `${sticky} left-0 p-4 align-middle`; + } +} diff --git a/src/app/shared/components/date-picker/date-picker.component.ts b/src/app/shared/components/date-picker/date-picker.component.ts new file mode 100644 index 0000000..d32e7bb --- /dev/null +++ b/src/app/shared/components/date-picker/date-picker.component.ts @@ -0,0 +1,132 @@ +import { DatePipe } from '@angular/common'; +import { ChangeDetectionStrategy, Component, computed, inject, input, output, TemplateRef, viewChild, ViewEncapsulation } from '@angular/core'; + +import { mergeClasses } from '@shared/utils/merge-classes'; +import { ZardButtonComponent } from '../button/button.component'; +import { ZardCalendarComponent } from '../calendar/calendar.component'; +import { ZardPopoverComponent, ZardPopoverDirective } from '../popover/popover.component'; +import { datePickerVariants, ZardDatePickerVariants } from './date-picker.variants'; + +import type { ClassValue } from '@shared/utils/merge-classes'; + +export type { ZardDatePickerVariants }; + +@Component({ + selector: 'z-date-picker, [z-date-picker]', + exportAs: 'zDatePicker', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + imports: [ZardButtonComponent, ZardCalendarComponent, ZardPopoverComponent, ZardPopoverDirective], + host: {}, + template: ` + + + + + + + + `, + providers: [DatePipe], +}) +export class ZardDatePickerComponent { + private readonly datePipe = inject(DatePipe); + + readonly calendarTemplate = viewChild.required>('calendarTemplate'); + readonly popoverDirective = viewChild.required('popoverDirective'); + readonly calendar = viewChild.required('calendar'); + + readonly class = input(''); + readonly zType = input('outline'); + readonly zSize = input('default'); + readonly value = input(null); + readonly placeholder = input('Pick a date'); + readonly zFormat = input('MMMM d, yyyy'); + readonly minDate = input(null); + readonly maxDate = input(null); + readonly disabled = input(false); + + readonly dateChange = output(); + + protected readonly classes = computed(() => + mergeClasses( + datePickerVariants({ + zSize: this.zSize(), + }), + this.class(), + ), + ); + + protected readonly buttonClasses = computed(() => { + const hasValue = !!this.value(); + return mergeClasses( + 'justify-start text-left font-normal', + !hasValue && 'text-muted-foreground', + this.zSize() === 'sm' ? 'h-8' : this.zSize() === 'lg' ? 'h-12' : 'h-10', + 'min-w-[240px]', + ); + }); + + protected readonly textClasses = computed(() => { + const hasValue = !!this.value(); + return mergeClasses(!hasValue && 'text-muted-foreground'); + }); + + protected readonly popoverClasses = computed(() => mergeClasses('w-auto p-0')); + + protected readonly calendarSize = computed(() => { + const size = this.zSize(); + if (size === 'sm') return 'sm'; + if (size === 'lg') return 'lg'; + return 'default'; + }); + + protected readonly displayText = computed(() => { + const date = this.value(); + if (!date) { + return this.placeholder(); + } + return this.formatDate(date, this.zFormat()); + }); + + protected onDateChange(date: Date): void { + this.dateChange.emit(date); + // Close popover after selection using direct method call + this.popoverDirective().hide(); + } + + protected onPopoverVisibilityChange(visible: boolean): void { + if (visible) { + // Reset calendar navigation when opening to show correct month/year + setTimeout(() => { + if (this.calendar()) { + this.calendar().resetNavigation(); + } + }); + } + } + + private formatDate(date: Date, format: string): string { + return this.datePipe.transform(date, format) || ''; + } +} \ No newline at end of file diff --git a/src/app/shared/components/date-picker/date-picker.variants.ts b/src/app/shared/components/date-picker/date-picker.variants.ts new file mode 100644 index 0000000..a28f1b8 --- /dev/null +++ b/src/app/shared/components/date-picker/date-picker.variants.ts @@ -0,0 +1,23 @@ +import { cva, VariantProps } from 'class-variance-authority'; + +const datePickerVariants = cva('', { + variants: { + zSize: { + sm: '', + default: '', + lg: '', + }, + zType: { + default: '', + outline: '', + ghost: '', + }, + }, + defaultVariants: { + zSize: 'default', + zType: 'outline', + }, +}); + +export { datePickerVariants }; +export type ZardDatePickerVariants = VariantProps; \ No newline at end of file diff --git a/src/app/shared/components/dialog/dialog-ref.ts b/src/app/shared/components/dialog/dialog-ref.ts new file mode 100644 index 0000000..732946c --- /dev/null +++ b/src/app/shared/components/dialog/dialog-ref.ts @@ -0,0 +1,89 @@ +import { filter, fromEvent, Subject, takeUntil } from 'rxjs'; + +import { OverlayRef } from '@angular/cdk/overlay'; +import { isPlatformBrowser } from '@angular/common'; +import { EventEmitter, Inject, PLATFORM_ID } from '@angular/core'; + +import { ZardDialogComponent, ZardDialogOptions } from './dialog.component'; + +const enum eTriggerAction { + CANCEL = 'cancel', + OK = 'ok', +} + +export class ZardDialogRef { + private destroy$ = new Subject(); + private isClosing = false; + protected result?: R; + componentInstance: T | null = null; + + constructor( + private overlayRef: OverlayRef, + private config: ZardDialogOptions, + private containerInstance: ZardDialogComponent, + @Inject(PLATFORM_ID) private platformId: object, + ) { + this.containerInstance.cancelTriggered.subscribe(() => this.trigger(eTriggerAction.CANCEL)); + this.containerInstance.okTriggered.subscribe(() => this.trigger(eTriggerAction.OK)); + + if ((this.config.zMaskClosable ?? true) && isPlatformBrowser(this.platformId)) { + this.overlayRef + .outsidePointerEvents() + .pipe(takeUntil(this.destroy$)) + .subscribe(() => this.close()); + } + + if (isPlatformBrowser(this.platformId)) { + fromEvent(document, 'keydown') + .pipe( + filter(event => event.key === 'Escape'), + takeUntil(this.destroy$), + ) + .subscribe(() => this.close()); + } + } + + close(result?: R) { + if (this.isClosing) { + return; + } + + this.isClosing = true; + this.result = result; + + this.containerInstance.state.set('close'); + + setTimeout(() => { + if (this.overlayRef) { + if (this.overlayRef.hasAttached()) { + this.overlayRef.detachBackdrop(); + } + this.overlayRef.dispose(); + } + + if (!this.destroy$.closed) { + this.destroy$.next(); + this.destroy$.complete(); + } + }, 150); + } + + private trigger(action: eTriggerAction) { + const trigger = { ok: this.config.zOnOk, cancel: this.config.zOnCancel }[action]; + + if (trigger instanceof EventEmitter) { + trigger.emit(this.getContentComponent()); + } else if (typeof trigger === 'function') { + const result = trigger(this.getContentComponent()) as R; + this.closeWithResult(result); + } else this.close(); + } + + private getContentComponent(): T { + return this.componentInstance as T; + } + + private closeWithResult(result: R): void { + if (result !== false) this.close(result); + } +} diff --git a/src/app/shared/components/dialog/dialog.component.ts b/src/app/shared/components/dialog/dialog.component.ts new file mode 100644 index 0000000..ac3032c --- /dev/null +++ b/src/app/shared/components/dialog/dialog.component.ts @@ -0,0 +1,172 @@ +import { animate, state, style, transition, trigger } from '@angular/animations'; +import { OverlayModule } from '@angular/cdk/overlay'; +import { BasePortalOutlet, CdkPortalOutlet, ComponentPortal, PortalModule, TemplatePortal } from '@angular/cdk/portal'; +import { + ChangeDetectionStrategy, + Component, + ComponentRef, + computed, + ElementRef, + EmbeddedViewRef, + EventEmitter, + inject, + NgModule, + output, + signal, + TemplateRef, + Type, + viewChild, + ViewContainerRef, +} from '@angular/core'; + +import { mergeClasses } from '@shared/utils/merge-classes'; +import { ZardButtonComponent } from '../button/button.component'; +import { ZardDialogRef } from './dialog-ref'; +import { ZardDialogService } from './dialog.service'; +import { dialogVariants } from './dialog.variants'; + +const noopFun = () => void 0; +export type OnClickCallback = (instance: T) => false | void | object; +export class ZardDialogOptions { + zCancelIcon?: string; + zCancelText?: string | null; + zClosable?: boolean; + zContent?: string | TemplateRef | Type; + zCustomClasses?: string; + zData?: U; + zDescription?: string; + zHideFooter?: boolean; + zMaskClosable?: boolean; + zOkDestructive?: boolean; + zOkDisabled?: boolean; + zOkIcon?: string; + zOkText?: string | null; + zOnCancel?: EventEmitter | OnClickCallback = noopFun; + zOnOk?: EventEmitter | OnClickCallback = noopFun; + zTitle?: string | TemplateRef; + zViewContainerRef?: ViewContainerRef; + zWidth?: string; +} + +@Component({ + selector: 'z-dialog', + exportAs: 'zDialog', + imports: [OverlayModule, PortalModule, ZardButtonComponent], + template: ` + @if (config.zClosable || config.zClosable === undefined) { + + } + + @if (config.zTitle || config.zDescription) { +
    + @if (config.zTitle) { +

    {{ config.zTitle }}

    + + @if (config.zDescription) { +

    {{ config.zDescription }}

    + } + } +
    + } + +
    + + + @if (isStringContent) { +
    + } +
    + + @if (!config.zHideFooter) { +
    + @if (config.zCancelText !== null) { + + } + + @if (config.zOkText !== null) { + + } +
    + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + '[class]': 'classes()', + '[@dialogAnimation]': 'state()', + '[style.width]': 'config.zWidth ? config.zWidth : null', + }, + animations: [ + trigger('dialogAnimation', [ + state('close', style({ opacity: 0, transform: 'scale(0.9)' })), + state('open', style({ opacity: 1, transform: 'scale(1)' })), + transition('close => open', animate('150ms ease-out')), + transition('open => close', animate('150ms ease-in')), + ]), + ], +}) +export class ZardDialogComponent extends BasePortalOutlet { + private readonly host = inject(ElementRef); + protected readonly config = inject(ZardDialogOptions); + + protected readonly classes = computed(() => mergeClasses(dialogVariants(), this.config.zCustomClasses)); + public dialogRef?: ZardDialogRef; + + protected readonly isStringContent = typeof this.config.zContent === 'string'; + + readonly portalOutlet = viewChild.required(CdkPortalOutlet); + + okTriggered = output(); + cancelTriggered = output(); + state = signal<'close' | 'open'>('close'); + + constructor() { + super(); + } + + getNativeElement(): HTMLElement { + return this.host.nativeElement; + } + + attachComponentPortal(portal: ComponentPortal): ComponentRef { + if (this.portalOutlet()?.hasAttached()) { + throw Error('Attempting to attach modal content after content is already attached'); + } + return this.portalOutlet()?.attachComponentPortal(portal); + } + + attachTemplatePortal(portal: TemplatePortal): EmbeddedViewRef { + if (this.portalOutlet()?.hasAttached()) { + throw Error('Attempting to attach modal content after content is already attached'); + } + + return this.portalOutlet()?.attachTemplatePortal(portal); + } + + onOkClick() { + this.okTriggered.emit(); + } + + onCloseClick() { + this.cancelTriggered.emit(); + } +} + +@NgModule({ + imports: [ZardButtonComponent, ZardDialogComponent, OverlayModule, PortalModule], + providers: [ZardDialogService], +}) +export class ZardDialogModule {} diff --git a/src/app/shared/components/dialog/dialog.service.ts b/src/app/shared/components/dialog/dialog.service.ts new file mode 100644 index 0000000..a2a428f --- /dev/null +++ b/src/app/shared/components/dialog/dialog.service.ts @@ -0,0 +1,99 @@ +import { ComponentType, Overlay, OverlayConfig, OverlayRef } from '@angular/cdk/overlay'; +import { ComponentPortal, TemplatePortal } from '@angular/cdk/portal'; +import { isPlatformBrowser } from '@angular/common'; +import { inject, Injectable, InjectionToken, Injector, PLATFORM_ID, TemplateRef } from '@angular/core'; + +import { ZardDialogRef } from './dialog-ref'; +import { ZardDialogComponent, ZardDialogOptions } from './dialog.component'; + +type ContentType = ComponentType | TemplateRef | string; +export const Z_MODAL_DATA = new InjectionToken('Z_MODAL_DATA'); + +@Injectable({ + providedIn: 'root', +}) +export class ZardDialogService { + private overlay = inject(Overlay); + private injector = inject(Injector); + private platformId = inject(PLATFORM_ID); + + create(config: ZardDialogOptions): ZardDialogRef { + return this.open(config.zContent as ComponentType, config); + } + + private open(componentOrTemplateRef: ContentType, config: ZardDialogOptions) { + const overlayRef = this.createOverlay(); + + if (!overlayRef) { + // Return a mock dialog ref for SSR environments + return new ZardDialogRef(undefined as any, config, undefined as any, this.platformId); + } + + const dialogContainer = this.attachDialogContainer(overlayRef, config); + + const dialogRef = this.attachDialogContent(componentOrTemplateRef, dialogContainer, overlayRef, config); + dialogContainer.dialogRef = dialogRef; + + return dialogRef; + } + + private createOverlay(): OverlayRef | undefined { + if (isPlatformBrowser(this.platformId)) { + const overlayConfig = new OverlayConfig({ + hasBackdrop: true, + positionStrategy: this.overlay.position().global(), + }); + + return this.overlay.create(overlayConfig); + } + return undefined; + } + + private attachDialogContainer(overlayRef: OverlayRef, config: ZardDialogOptions) { + const injector = Injector.create({ + parent: this.injector, + providers: [ + { provide: OverlayRef, useValue: overlayRef }, + { provide: ZardDialogOptions, useValue: config }, + ], + }); + + const containerPortal = new ComponentPortal>(ZardDialogComponent, config.zViewContainerRef, injector); + const containerRef = overlayRef.attach>(containerPortal); + + setTimeout(() => { + containerRef.instance.state.set('open'); + }, 0); + + return containerRef.instance; + } + + private attachDialogContent(componentOrTemplateRef: ContentType, dialogContainer: ZardDialogComponent, overlayRef: OverlayRef, config: ZardDialogOptions) { + const dialogRef = new ZardDialogRef(overlayRef, config, dialogContainer, this.platformId); + + if (componentOrTemplateRef instanceof TemplateRef) { + dialogContainer.attachTemplatePortal( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + new TemplatePortal(componentOrTemplateRef, null!, { + dialogRef: dialogRef, + } as any), + ); + } else if (typeof componentOrTemplateRef !== 'string') { + const injector = this.createInjector(dialogRef, config); + const contentRef = dialogContainer.attachComponentPortal(new ComponentPortal(componentOrTemplateRef, config.zViewContainerRef, injector)); + dialogRef.componentInstance = contentRef.instance; + } + + return dialogRef; + } + + private createInjector(dialogRef: ZardDialogRef, config: ZardDialogOptions) { + return Injector.create({ + parent: this.injector, + providers: [ + { provide: ZardDialogRef, useValue: dialogRef }, + { provide: Z_MODAL_DATA, useValue: config.zData }, + ], + }); + } +} diff --git a/src/app/shared/components/dialog/dialog.variants.ts b/src/app/shared/components/dialog/dialog.variants.ts new file mode 100644 index 0000000..011b01c --- /dev/null +++ b/src/app/shared/components/dialog/dialog.variants.ts @@ -0,0 +1,6 @@ +import { cva, VariantProps } from 'class-variance-authority'; + +export const dialogVariants = cva( + 'fixed left-[50%] top-[50%] z-50 grid w-full translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg rounded-lg max-w-[calc(100%-2rem)] sm:max-w-[425px]', +); +export type ZardDialogVariants = VariantProps; diff --git a/src/app/shared/components/divider/divider.component.ts b/src/app/shared/components/divider/divider.component.ts new file mode 100644 index 0000000..5138c8b --- /dev/null +++ b/src/app/shared/components/divider/divider.component.ts @@ -0,0 +1,34 @@ +import { ChangeDetectionStrategy, Component, computed, input, ViewEncapsulation } from '@angular/core'; +import type { ClassValue } from 'clsx'; + +import { dividerVariants, ZardDividerVariants } from './divider.variants'; +import { mergeClasses } from '@shared/utils/merge-classes'; + +@Component({ + selector: 'z-divider', + standalone: true, + exportAs: 'zDivider', + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: '', + host: { + '[attr.role]': `'separator'`, + '[attr.aria-orientation]': 'zOrientation()', + '[class]': 'classes()', + }, +}) +export class ZardDividerComponent { + readonly zOrientation = input('horizontal'); + readonly zSpacing = input('default'); + readonly class = input(''); + + protected readonly classes = computed(() => + mergeClasses( + dividerVariants({ + zOrientation: this.zOrientation(), + zSpacing: this.zSpacing(), + }), + this.class(), + ), + ); +} \ No newline at end of file diff --git a/src/app/shared/components/divider/divider.variants.ts b/src/app/shared/components/divider/divider.variants.ts new file mode 100644 index 0000000..695b38e --- /dev/null +++ b/src/app/shared/components/divider/divider.variants.ts @@ -0,0 +1,54 @@ +import { cva, VariantProps } from 'class-variance-authority'; + +export const dividerVariants = cva('bg-border block', { + variants: { + zOrientation: { + horizontal: 'h-px w-full', + vertical: 'w-px h-full inline-block', + }, + zSpacing: { + none: '', + sm: '', + default: '', + lg: '', + }, + }, + defaultVariants: { + zOrientation: 'horizontal', + zSpacing: 'default', + }, + compoundVariants: [ + { + zOrientation: 'horizontal', + zSpacing: 'sm', + class: 'my-2', + }, + { + zOrientation: 'horizontal', + zSpacing: 'default', + class: 'my-4', + }, + { + zOrientation: 'horizontal', + zSpacing: 'lg', + class: 'my-8', + }, + { + zOrientation: 'vertical', + zSpacing: 'sm', + class: 'mx-2', + }, + { + zOrientation: 'vertical', + zSpacing: 'default', + class: 'mx-4', + }, + { + zOrientation: 'vertical', + zSpacing: 'lg', + class: 'mx-8', + }, + ], +}); + +export type ZardDividerVariants = VariantProps; \ No newline at end of file diff --git a/src/app/shared/components/dropdown/dropdown-item.component.ts b/src/app/shared/components/dropdown/dropdown-item.component.ts new file mode 100644 index 0000000..c5305e4 --- /dev/null +++ b/src/app/shared/components/dropdown/dropdown-item.component.ts @@ -0,0 +1,56 @@ +import type { ClassValue } from 'clsx'; + +import { Component, computed, HostListener, inject, input, ViewEncapsulation } from '@angular/core'; + +import { mergeClasses, transform } from '@shared/utils/merge-classes'; +import { ZardDropdownService } from './dropdown.service'; +import { dropdownItemVariants, ZardDropdownItemVariants } from './dropdown.variants'; + +@Component({ + selector: 'z-dropdown-menu-item, [z-dropdown-menu-item]', + exportAs: 'zDropdownMenuItem', + standalone: true, + encapsulation: ViewEncapsulation.None, + template: ``, + host: { + '[class]': 'classes()', + '[attr.data-disabled]': 'disabled() || null', + '[attr.data-variant]': 'variant()', + '[attr.data-inset]': 'inset() || null', + '[attr.aria-disabled]': 'disabled()', + role: 'menuitem', + tabindex: '-1', + }, +}) +export class ZardDropdownMenuItemComponent { + private dropdownService = inject(ZardDropdownService); + + readonly variant = input('default'); + readonly inset = input(false, { transform }); + readonly disabled = input(false, { transform }); + readonly class = input(''); + + @HostListener('click', ['$event']) + onClick(event: Event) { + if (this.disabled()) { + event.preventDefault(); + event.stopPropagation(); + return; + } + + // Fechar dropdown após click + setTimeout(() => { + this.dropdownService.close(); + }, 0); + } + + protected readonly classes = computed(() => + mergeClasses( + dropdownItemVariants({ + variant: this.variant(), + inset: this.inset(), + }), + this.class(), + ), + ); +} diff --git a/src/app/shared/components/dropdown/dropdown-label.component.ts b/src/app/shared/components/dropdown/dropdown-label.component.ts new file mode 100644 index 0000000..c0682e6 --- /dev/null +++ b/src/app/shared/components/dropdown/dropdown-label.component.ts @@ -0,0 +1,31 @@ +import type { ClassValue } from 'clsx'; + +import { Component, computed, input, ViewEncapsulation } from '@angular/core'; + +import { mergeClasses, transform } from '@shared/utils/merge-classes'; +import { dropdownLabelVariants } from './dropdown.variants'; + +@Component({ + selector: 'z-dropdown-menu-label, [z-dropdown-menu-label]', + exportAs: 'zDropdownMenuLabel', + standalone: true, + encapsulation: ViewEncapsulation.None, + template: ``, + host: { + '[class]': 'classes()', + '[attr.data-inset]': 'inset() || null', + }, +}) +export class ZardDropdownMenuLabelComponent { + readonly inset = input(false, { transform }); + readonly class = input(''); + + protected readonly classes = computed(() => + mergeClasses( + dropdownLabelVariants({ + inset: this.inset(), + }), + this.class(), + ), + ); +} diff --git a/src/app/shared/components/dropdown/dropdown-menu-content.component.ts b/src/app/shared/components/dropdown/dropdown-menu-content.component.ts new file mode 100644 index 0000000..76abf6d --- /dev/null +++ b/src/app/shared/components/dropdown/dropdown-menu-content.component.ts @@ -0,0 +1,27 @@ +import type { ClassValue } from 'clsx'; + +import { Component, computed, input, TemplateRef, viewChild, ViewEncapsulation } from '@angular/core'; + +import { mergeClasses } from '@shared/utils/merge-classes'; +import { dropdownContentVariants } from './dropdown.variants'; + +@Component({ + selector: 'z-dropdown-menu-content', + exportAs: 'zDropdownMenuContent', + standalone: true, + encapsulation: ViewEncapsulation.None, + template: ` + +
    + +
    +
    + `, +}) +export class ZardDropdownMenuContentComponent { + readonly contentTemplate = viewChild.required>('contentTemplate'); + + readonly class = input(''); + + protected readonly contentClasses = computed(() => mergeClasses(dropdownContentVariants(), this.class())); +} diff --git a/src/app/shared/components/dropdown/dropdown-shortcut.component.ts b/src/app/shared/components/dropdown/dropdown-shortcut.component.ts new file mode 100644 index 0000000..51735ad --- /dev/null +++ b/src/app/shared/components/dropdown/dropdown-shortcut.component.ts @@ -0,0 +1,22 @@ +import type { ClassValue } from 'clsx'; + +import { Component, computed, input, ViewEncapsulation } from '@angular/core'; + +import { mergeClasses } from '@shared/utils/merge-classes'; +import { dropdownShortcutVariants } from './dropdown.variants'; + +@Component({ + selector: 'z-dropdown-menu-shortcut, [z-dropdown-menu-shortcut]', + exportAs: 'zDropdownMenuShortcut', + standalone: true, + encapsulation: ViewEncapsulation.None, + template: ``, + host: { + '[class]': 'classes()', + }, +}) +export class ZardDropdownMenuShortcutComponent { + readonly class = input(''); + + protected readonly classes = computed(() => mergeClasses(dropdownShortcutVariants(), this.class())); +} diff --git a/src/app/shared/components/dropdown/dropdown-trigger.directive.ts b/src/app/shared/components/dropdown/dropdown-trigger.directive.ts new file mode 100644 index 0000000..29d0f84 --- /dev/null +++ b/src/app/shared/components/dropdown/dropdown-trigger.directive.ts @@ -0,0 +1,101 @@ +import { Directive, ElementRef, HostListener, inject, input, OnInit, ViewContainerRef } from '@angular/core'; + +import { ZardDropdownMenuContentComponent } from './dropdown-menu-content.component'; +import { ZardDropdownService } from './dropdown.service'; + +@Directive({ + selector: '[z-dropdown], [zDropdown]', + exportAs: 'zDropdown', + standalone: true, + host: { + '[attr.tabindex]': '0', + '[attr.role]': '"button"', + '[attr.aria-haspopup]': '"menu"', + '[attr.aria-expanded]': 'dropdownService.isOpen()', + '[attr.aria-disabled]': 'zDisabled()', + }, +}) +export class ZardDropdownDirective implements OnInit { + private elementRef = inject(ElementRef); + private viewContainerRef = inject(ViewContainerRef); + protected dropdownService = inject(ZardDropdownService); + + readonly zDropdownMenu = input(); + readonly zTrigger = input<'click' | 'hover'>('click'); + readonly zDisabled = input(false); + + ngOnInit() { + // Ensure button has proper accessibility attributes + const element = this.elementRef.nativeElement; + if (!element.hasAttribute('aria-label') && !element.hasAttribute('aria-labelledby')) { + element.setAttribute('aria-label', element.textContent?.trim() || 'Open menu'); + } + } + + @HostListener('click', ['$event']) + onClick(event: Event) { + if (this.zDisabled() || this.zTrigger() !== 'click') return; + + event.preventDefault(); + event.stopPropagation(); + + const menuContent = this.zDropdownMenu(); + if (menuContent) { + this.dropdownService.toggle(this.elementRef, menuContent?.contentTemplate?.(), this.viewContainerRef); + } + } + + @HostListener('mouseenter') + onMouseEnter() { + if (this.zDisabled() || this.zTrigger() !== 'hover') return; + + const menuContent = this.zDropdownMenu(); + if (menuContent) { + this.dropdownService.open(this.elementRef, menuContent?.contentTemplate?.(), this.viewContainerRef); + } + } + + @HostListener('mouseleave') + onMouseLeave() { + if (this.zDisabled() || this.zTrigger() !== 'hover') return; + + this.dropdownService.close(); + } + + @HostListener('keydown', ['$event']) + onKeydown(event: KeyboardEvent) { + if (this.zDisabled()) return; + + switch (event.key) { + case 'Enter': + case ' ': + event.preventDefault(); + event.stopPropagation(); + this.toggleDropdown(); + break; + case 'ArrowDown': + event.preventDefault(); + this.openDropdown(); + break; + case 'Escape': + event.preventDefault(); + this.dropdownService.close(); + this.elementRef.nativeElement.focus(); + break; + } + } + + private toggleDropdown() { + const menuContent = this.zDropdownMenu(); + if (menuContent) { + this.dropdownService.toggle(this.elementRef, menuContent?.contentTemplate?.(), this.viewContainerRef); + } + } + + private openDropdown() { + const menuContent = this.zDropdownMenu(); + if (menuContent && !this.dropdownService.isOpen()) { + this.dropdownService.open(this.elementRef, menuContent?.contentTemplate?.(), this.viewContainerRef); + } + } +} diff --git a/src/app/shared/components/dropdown/dropdown.component.ts b/src/app/shared/components/dropdown/dropdown.component.ts new file mode 100644 index 0000000..3d8cf6a --- /dev/null +++ b/src/app/shared/components/dropdown/dropdown.component.ts @@ -0,0 +1,279 @@ +import { Overlay, OverlayModule, OverlayPositionBuilder, OverlayRef } from '@angular/cdk/overlay'; +import { TemplatePortal } from '@angular/cdk/portal'; +import { + ChangeDetectionStrategy, + Component, + computed, + ElementRef, + HostListener, + inject, + input, + OnDestroy, + OnInit, + output, + PLATFORM_ID, + signal, + TemplateRef, + viewChild, + ViewContainerRef, + ViewEncapsulation, +} from '@angular/core'; +import { mergeClasses, transform } from '@shared/utils/merge-classes'; +import { dropdownContentVariants } from './dropdown.variants'; + +import type { ClassValue } from 'clsx'; +import { isPlatformBrowser } from '@angular/common'; +@Component({ + selector: 'z-dropdown-menu', + exportAs: 'zDropdownMenu', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + imports: [OverlayModule], + host: { + '[attr.data-state]': 'isOpen() ? "open" : "closed"', + class: 'relative inline-block text-left', + }, + template: ` + +
    + +
    + + + +
    + +
    +
    + `, +}) +export class ZardDropdownMenuComponent implements OnInit, OnDestroy { + private elementRef = inject(ElementRef); + private overlay = inject(Overlay); + private overlayPositionBuilder = inject(OverlayPositionBuilder); + private viewContainerRef = inject(ViewContainerRef); + private platformId = inject(PLATFORM_ID); + + readonly dropdownTemplate = viewChild.required>('dropdownTemplate'); + + private overlayRef?: OverlayRef; + private portal?: TemplatePortal; + + readonly class = input(''); + readonly disabled = input(false, { transform }); + + readonly openChange = output(); + + readonly isOpen = signal(false); + readonly focusedIndex = signal(-1); + + protected readonly contentClasses = computed(() => mergeClasses(dropdownContentVariants(), this.class())); + + ngOnInit() { + setTimeout(() => { + this.createOverlay(); + }); + } + + ngOnDestroy() { + this.destroyOverlay(); + } + + @HostListener('document:click', ['$event']) + onDocumentClick(event: Event) { + if (!this.elementRef.nativeElement.contains(event.target as Node)) { + this.close(); + } + } + + onDropdownKeydown(event: KeyboardEvent) { + const items = this.getDropdownItems(); + + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + this.navigateItems(1, items); + break; + case 'ArrowUp': + event.preventDefault(); + this.navigateItems(-1, items); + break; + case 'Enter': + case ' ': + event.preventDefault(); + this.selectFocusedItem(items); + break; + case 'Escape': + event.preventDefault(); + this.close(); + this.focusTrigger(); + break; + case 'Home': + event.preventDefault(); + this.focusFirstItem(items); + break; + case 'End': + event.preventDefault(); + this.focusLastItem(items); + break; + } + } + + toggle() { + if (this.disabled()) return; + if (this.isOpen()) { + this.close(); + } else { + this.open(); + } + } + + open() { + if (this.isOpen()) return; + + if (!this.overlayRef) { + this.createOverlay(); + } + + if (!this.overlayRef) return; + + this.portal = new TemplatePortal(this.dropdownTemplate(), this.viewContainerRef); + this.overlayRef.attach(this.portal); + this.isOpen.set(true); + this.openChange.emit(true); + + setTimeout(() => { + this.focusDropdown(); + this.focusFirstItem(this.getDropdownItems()); + }, 0); + } + + close() { + if (this.overlayRef?.hasAttached()) { + this.overlayRef.detach(); + } + this.isOpen.set(false); + this.focusedIndex.set(-1); + this.openChange.emit(false); + } + + private createOverlay() { + if (this.overlayRef) return; + + if (isPlatformBrowser(this.platformId)) { + try { + const positionStrategy = this.overlayPositionBuilder + .flexibleConnectedTo(this.elementRef) + .withPositions([ + { + originX: 'start', + originY: 'bottom', + overlayX: 'start', + overlayY: 'top', + offsetY: 4, + }, + { + originX: 'start', + originY: 'top', + overlayX: 'start', + overlayY: 'bottom', + offsetY: -4, + }, + ]) + .withPush(false); + + this.overlayRef = this.overlay.create({ + positionStrategy, + hasBackdrop: false, + scrollStrategy: this.overlay.scrollStrategies.reposition(), + minWidth: 200, + maxHeight: 400, + }); + } catch (error) { + console.error('Error creating overlay:', error); + } + } + } + + private destroyOverlay() { + if (this.overlayRef) { + this.overlayRef.dispose(); + this.overlayRef = undefined; + } + } + + private getDropdownItems(): HTMLElement[] { + if (!this.overlayRef?.hasAttached()) return []; + const dropdownElement = this.overlayRef.overlayElement; + return Array.from(dropdownElement.querySelectorAll('z-dropdown-menu-item, [z-dropdown-menu-item]')).filter( + (item: Element) => !item.hasAttribute('data-disabled'), + ) as HTMLElement[]; + } + + private navigateItems(direction: number, items: HTMLElement[]) { + if (items.length === 0) return; + + const currentIndex = this.focusedIndex(); + let nextIndex = currentIndex + direction; + + if (nextIndex < 0) { + nextIndex = items.length - 1; + } else if (nextIndex >= items.length) { + nextIndex = 0; + } + + this.focusedIndex.set(nextIndex); + this.updateItemFocus(items, nextIndex); + } + + private selectFocusedItem(items: HTMLElement[]) { + const currentIndex = this.focusedIndex(); + if (currentIndex >= 0 && currentIndex < items.length) { + const item = items[currentIndex]; + item.click(); + } + } + + private focusFirstItem(items: HTMLElement[]) { + if (items.length > 0) { + this.focusedIndex.set(0); + this.updateItemFocus(items, 0); + } + } + + private focusLastItem(items: HTMLElement[]) { + if (items.length > 0) { + const lastIndex = items.length - 1; + this.focusedIndex.set(lastIndex); + this.updateItemFocus(items, lastIndex); + } + } + + private updateItemFocus(items: HTMLElement[], focusedIndex: number) { + items.forEach((item, index) => { + if (index === focusedIndex) { + item.focus(); + item.setAttribute('data-highlighted', ''); + } else { + item.removeAttribute('data-highlighted'); + } + }); + } + + private focusDropdown() { + if (this.overlayRef?.hasAttached()) { + const dropdownElement = this.overlayRef.overlayElement.querySelector('[role="menu"]') as HTMLElement; + if (dropdownElement) { + dropdownElement.focus(); + } + } + } + + private focusTrigger() { + const trigger = this.elementRef.nativeElement.querySelector('.trigger-container'); + if (trigger) { + trigger.focus(); + } + } +} diff --git a/src/app/shared/components/dropdown/dropdown.module.ts b/src/app/shared/components/dropdown/dropdown.module.ts new file mode 100644 index 0000000..26742c8 --- /dev/null +++ b/src/app/shared/components/dropdown/dropdown.module.ts @@ -0,0 +1,24 @@ +import { OverlayModule } from '@angular/cdk/overlay'; +import { NgModule } from '@angular/core'; + +import { ZardDropdownMenuContentComponent } from './dropdown-menu-content.component'; +import { ZardDropdownMenuShortcutComponent } from './dropdown-shortcut.component'; +import { ZardDropdownMenuLabelComponent } from './dropdown-label.component'; +import { ZardDropdownMenuItemComponent } from './dropdown-item.component'; +import { ZardDropdownDirective } from './dropdown-trigger.directive'; +import { ZardDropdownMenuComponent } from './dropdown.component'; + +const DROPDOWN_COMPONENTS = [ + ZardDropdownMenuComponent, + ZardDropdownMenuItemComponent, + ZardDropdownMenuLabelComponent, + ZardDropdownMenuShortcutComponent, + ZardDropdownMenuContentComponent, + ZardDropdownDirective, +]; + +@NgModule({ + imports: [OverlayModule, ...DROPDOWN_COMPONENTS], + exports: [...DROPDOWN_COMPONENTS], +}) +export class ZardDropdownModule {} diff --git a/src/app/shared/components/dropdown/dropdown.service.ts b/src/app/shared/components/dropdown/dropdown.service.ts new file mode 100644 index 0000000..8945692 --- /dev/null +++ b/src/app/shared/components/dropdown/dropdown.service.ts @@ -0,0 +1,203 @@ +import { Overlay, OverlayPositionBuilder, OverlayRef } from '@angular/cdk/overlay'; +import { TemplatePortal } from '@angular/cdk/portal'; +import { ElementRef, inject, Injectable, PLATFORM_ID, signal, TemplateRef, ViewContainerRef } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; + +@Injectable({ + providedIn: 'root', +}) +export class ZardDropdownService { + private overlay = inject(Overlay); + private overlayPositionBuilder = inject(OverlayPositionBuilder); + private platformId = inject(PLATFORM_ID); + + private overlayRef?: OverlayRef; + private portal?: TemplatePortal; + private triggerElement?: ElementRef; + private focusedIndex = signal(-1); + + readonly isOpen = signal(false); + + toggle(triggerElement: ElementRef, template: TemplateRef, viewContainerRef: ViewContainerRef) { + if (this.isOpen()) { + this.close(); + } else { + this.open(triggerElement, template, viewContainerRef); + } + } + + open(triggerElement: ElementRef, template: TemplateRef, viewContainerRef: ViewContainerRef) { + if (this.isOpen()) { + this.close(); + } + + this.triggerElement = triggerElement; + this.createOverlay(triggerElement); + + if (!this.overlayRef) return; + + this.portal = new TemplatePortal(template, viewContainerRef); + this.overlayRef.attach(this.portal); + this.isOpen.set(true); + + // Setup keyboard navigation + setTimeout(() => { + this.setupKeyboardNavigation(); + this.focusFirstItem(); + }, 0); + + // Close on outside click + this.overlayRef.outsidePointerEvents().subscribe(() => { + this.close(); + }); + } + + close() { + if (this.overlayRef?.hasAttached()) { + this.overlayRef.detach(); + } + this.isOpen.set(false); + this.focusedIndex.set(-1); + this.destroyOverlay(); + } + + private createOverlay(triggerElement: ElementRef) { + if (this.overlayRef) { + this.destroyOverlay(); + } + + const positionStrategy = this.overlayPositionBuilder + .flexibleConnectedTo(triggerElement) + .withPositions([ + { + originX: 'start', + originY: 'bottom', + overlayX: 'start', + overlayY: 'top', + offsetY: 4, + }, + { + originX: 'start', + originY: 'top', + overlayX: 'start', + overlayY: 'bottom', + offsetY: -4, + }, + ]) + .withPush(false); + + this.overlayRef = this.overlay.create({ + positionStrategy, + hasBackdrop: false, + scrollStrategy: this.overlay.scrollStrategies.reposition(), + minWidth: 200, + maxHeight: 400, + }); + } + + private destroyOverlay() { + if (this.overlayRef) { + this.overlayRef.dispose(); + this.overlayRef = undefined; + } + } + + private setupKeyboardNavigation() { + if (!this.overlayRef?.hasAttached() || !isPlatformBrowser(this.platformId)) return; + + const dropdownElement = this.overlayRef.overlayElement.querySelector('[role="menu"]') as HTMLElement; + if (!dropdownElement) return; + + dropdownElement.addEventListener('keydown', (event: KeyboardEvent) => { + const items = this.getDropdownItems(); + + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + this.navigateItems(1, items); + break; + case 'ArrowUp': + event.preventDefault(); + this.navigateItems(-1, items); + break; + case 'Enter': + case ' ': + event.preventDefault(); + this.selectFocusedItem(items); + break; + case 'Escape': + event.preventDefault(); + this.close(); + this.triggerElement?.nativeElement.focus(); + break; + case 'Home': + event.preventDefault(); + this.focusItemAtIndex(items, 0); + break; + case 'End': + event.preventDefault(); + this.focusItemAtIndex(items, items.length - 1); + break; + } + }); + + // Focus dropdown container + dropdownElement.focus(); + } + + private getDropdownItems(): HTMLElement[] { + if (!this.overlayRef?.hasAttached()) return []; + const dropdownElement = this.overlayRef.overlayElement; + return Array.from(dropdownElement.querySelectorAll('z-dropdown-menu-item, [z-dropdown-menu-item]')).filter( + (item: Element) => !item.hasAttribute('data-disabled'), + ) as HTMLElement[]; + } + + private navigateItems(direction: number, items: HTMLElement[]) { + if (items.length === 0) return; + + const currentIndex = this.focusedIndex(); + let nextIndex = currentIndex + direction; + + if (nextIndex < 0) { + nextIndex = items.length - 1; + } else if (nextIndex >= items.length) { + nextIndex = 0; + } + + this.focusItemAtIndex(items, nextIndex); + } + + private focusItemAtIndex(items: HTMLElement[], index: number) { + if (index >= 0 && index < items.length) { + this.focusedIndex.set(index); + this.updateItemFocus(items, index); + } + } + + private focusFirstItem() { + const items = this.getDropdownItems(); + if (items.length > 0) { + this.focusItemAtIndex(items, 0); + } + } + + private selectFocusedItem(items: HTMLElement[]) { + const currentIndex = this.focusedIndex(); + if (currentIndex >= 0 && currentIndex < items.length) { + const item = items[currentIndex]; + item.click(); + } + } + + private updateItemFocus(items: HTMLElement[], focusedIndex: number) { + items.forEach((item, index) => { + if (index === focusedIndex) { + item.focus(); + item.setAttribute('data-highlighted', ''); + } else { + item.removeAttribute('data-highlighted'); + } + }); + } +} diff --git a/src/app/shared/components/dropdown/dropdown.variants.ts b/src/app/shared/components/dropdown/dropdown.variants.ts new file mode 100644 index 0000000..41107a9 --- /dev/null +++ b/src/app/shared/components/dropdown/dropdown.variants.ts @@ -0,0 +1,40 @@ +import { cva, VariantProps } from 'class-variance-authority'; + +export const dropdownContentVariants = cva('bg-popover text-popover-foreground z-50 min-w-[200px] overflow-y-auto rounded-md border py-1 px-1 shadow-md'); + +export const dropdownItemVariants = cva( + 'relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', + { + variants: { + variant: { + default: '', + destructive: 'text-destructive hover:bg-destructive/10 focus:bg-destructive/10 dark:hover:bg-destructive/20 dark:focus:bg-destructive/20 focus:text-destructive', + }, + inset: { + true: 'pl-8', + false: '', + }, + }, + defaultVariants: { + variant: 'default', + inset: false, + }, + }, +); + +export const dropdownLabelVariants = cva('relative flex items-center px-2 py-1.5 text-sm font-medium text-muted-foreground', { + variants: { + inset: { + true: 'pl-8', + false: '', + }, + }, + defaultVariants: { + inset: false, + }, +}); + +export const dropdownShortcutVariants = cva('ml-auto text-xs tracking-widest text-muted-foreground'); + +export type ZardDropdownItemVariants = VariantProps; +export type ZardDropdownLabelVariants = VariantProps; diff --git a/src/app/shared/components/empty/empty.component.ts b/src/app/shared/components/empty/empty.component.ts new file mode 100644 index 0000000..b29709f --- /dev/null +++ b/src/app/shared/components/empty/empty.component.ts @@ -0,0 +1,66 @@ +import { ChangeDetectionStrategy, Component, computed, input, TemplateRef, ViewEncapsulation } from '@angular/core'; +import type { ClassValue } from 'clsx'; + +import { ZardStringTemplateOutletDirective } from '../core/directives/string-template-outlet/string-template-outlet.directive'; +import { emptyVariants, ZardEmptyVariants } from './empty.variants'; +import { mergeClasses } from '@shared/utils/merge-classes'; + +@Component({ + selector: 'z-empty', + exportAs: 'zEmpty', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + imports: [ZardStringTemplateOutletDirective], + host: { + '[class]': 'classes()', + }, + template: ` + @if (zImage()) { + @if (isTemplate(zImage())) { +
    + +
    + } @else { + Empty + } + } @else { + +
    + + + + + + + + + +
    + } + @if (zDescription()) { +
    + @if (isTemplate(zDescription())) { + + } @else { +

    {{ zDescription() }}

    + } +
    + } + `, +}) +export class ZardEmptyComponent { + readonly zImage = input>(); + readonly zDescription = input>('No data'); + readonly zSize = input('default'); + readonly class = input(''); + + protected readonly classes = computed(() => mergeClasses(emptyVariants({ zSize: this.zSize() }), this.class())); + + isTemplate(value: string | TemplateRef | undefined): value is TemplateRef { + return value instanceof TemplateRef; + } +} diff --git a/src/app/shared/components/empty/empty.variants.ts b/src/app/shared/components/empty/empty.variants.ts new file mode 100644 index 0000000..16259c8 --- /dev/null +++ b/src/app/shared/components/empty/empty.variants.ts @@ -0,0 +1,16 @@ +import { cva, VariantProps } from 'class-variance-authority'; + +export const emptyVariants = cva('flex flex-col items-center justify-center text-center', { + variants: { + zSize: { + default: 'text-sm [&_img]:w-40 [&_svg]:w-16 [&_svg]:h-10', + sm: 'text-xs [&_img]:w-28 [&_svg]:w-12 [&_svg]:h-8', + lg: 'text-base [&_img]:w-52 [&_svg]:w-20 [&_svg]:h-12', + }, + }, + defaultVariants: { + zSize: 'default', + }, +}); + +export type ZardEmptyVariants = VariantProps; diff --git a/src/app/shared/components/form/form.component.ts b/src/app/shared/components/form/form.component.ts new file mode 100644 index 0000000..b8192b0 --- /dev/null +++ b/src/app/shared/components/form/form.component.ts @@ -0,0 +1,92 @@ +import type { ClassValue } from 'clsx'; + +import { ChangeDetectionStrategy, Component, computed, input, ViewEncapsulation } from '@angular/core'; + +import { mergeClasses, transform } from '@shared/utils/merge-classes'; +import { formFieldVariants, formControlVariants, formLabelVariants, formMessageVariants, ZardFormMessageVariants } from './form.variants'; + +@Component({ + selector: 'z-form-field, [z-form-field]', + exportAs: 'zFormField', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: '', + host: { + '[class]': 'classes()', + }, +}) +export class ZardFormFieldComponent { + readonly class = input(''); + + protected readonly classes = computed(() => mergeClasses(formFieldVariants(), this.class())); +} + +@Component({ + selector: 'z-form-control, [z-form-control]', + exportAs: 'zFormControl', + standalone: true, + imports: [], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
    + +
    + @if (errorMessage() || helpText()) { +
    + @if (errorMessage()) { +

    {{ errorMessage() }}

    + } @else if (helpText()) { +

    {{ helpText() }}

    + } +
    + } + `, + host: { + '[class]': 'classes()', + }, +}) +export class ZardFormControlComponent { + readonly class = input(''); + readonly errorMessage = input(''); + readonly helpText = input(''); + + protected readonly classes = computed(() => mergeClasses(formControlVariants(), this.class())); +} + +@Component({ + selector: 'z-form-label, label[z-form-label]', + exportAs: 'zFormLabel', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: '', + host: { + '[class]': 'classes()', + }, +}) +export class ZardFormLabelComponent { + readonly class = input(''); + readonly zRequired = input(false, { transform }); + + protected readonly classes = computed(() => mergeClasses(formLabelVariants({ zRequired: this.zRequired() }), this.class())); +} + +@Component({ + selector: 'z-form-message, [z-form-message]', + exportAs: 'zFormMessage', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: '', + host: { + '[class]': 'classes()', + }, +}) +export class ZardFormMessageComponent { + readonly class = input(''); + readonly zType = input('default'); + + protected readonly classes = computed(() => mergeClasses(formMessageVariants({ zType: this.zType() }), this.class())); +} \ No newline at end of file diff --git a/src/app/shared/components/form/form.module.ts b/src/app/shared/components/form/form.module.ts new file mode 100644 index 0000000..9ca3ed9 --- /dev/null +++ b/src/app/shared/components/form/form.module.ts @@ -0,0 +1,11 @@ +import { NgModule } from '@angular/core'; + +import { ZardFormControlComponent, ZardFormFieldComponent, ZardFormLabelComponent, ZardFormMessageComponent } from './form.component'; + +const FORM_COMPONENTS = [ZardFormFieldComponent, ZardFormLabelComponent, ZardFormControlComponent, ZardFormMessageComponent]; + +@NgModule({ + imports: [...FORM_COMPONENTS], + exports: [...FORM_COMPONENTS], +}) +export class ZardFormModule {} \ No newline at end of file diff --git a/src/app/shared/components/form/form.variants.ts b/src/app/shared/components/form/form.variants.ts new file mode 100644 index 0000000..cde2dcd --- /dev/null +++ b/src/app/shared/components/form/form.variants.ts @@ -0,0 +1,32 @@ +import { cva, VariantProps } from 'class-variance-authority'; + +export const formFieldVariants = cva('grid gap-2'); + +export const formLabelVariants = cva('text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70', { + variants: { + zRequired: { + true: "after:content-['*'] after:ml-0.5 after:text-red-500", + }, + }, +}); + +export const formControlVariants = cva(''); + +export const formMessageVariants = cva('text-sm', { + variants: { + zType: { + default: 'text-muted-foreground', + error: 'text-red-500', + success: 'text-green-500', + warning: 'text-yellow-500', + }, + }, + defaultVariants: { + zType: 'default', + }, +}); + +export type ZardFormFieldVariants = VariantProps; +export type ZardFormLabelVariants = VariantProps; +export type ZardFormControlVariants = VariantProps; +export type ZardFormMessageVariants = VariantProps; \ No newline at end of file diff --git a/src/app/shared/components/input-group/input-group.component.ts b/src/app/shared/components/input-group/input-group.component.ts new file mode 100644 index 0000000..a92b81d --- /dev/null +++ b/src/app/shared/components/input-group/input-group.component.ts @@ -0,0 +1,136 @@ +import { booleanAttribute, ChangeDetectionStrategy, Component, computed, input, TemplateRef, ViewEncapsulation } from '@angular/core'; +import type { ClassValue } from 'clsx'; + +import { generateId, mergeClasses } from '@shared/utils/merge-classes'; +import { ZardStringTemplateOutletDirective } from '../core/directives/string-template-outlet/string-template-outlet.directive'; +import { inputGroupAddonVariants, inputGroupAffixVariants, inputGroupInputVariants, inputGroupVariants, ZardInputGroupVariants } from './input-group.variants'; + +@Component({ + selector: 'z-input-group', + exportAs: 'zInputGroup', + standalone: true, + imports: [ZardStringTemplateOutletDirective], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
    + @if (zAddOnBefore()) { +
    + {{ zAddOnBefore() }} +
    + } + +
    + @if (zPrefix()) { +
    + {{ zPrefix() }} +
    + } + + + + @if (zSuffix()) { +
    + {{ zSuffix() }} +
    + } +
    + + @if (zAddOnAfter()) { +
    + {{ zAddOnAfter() }} +
    + } +
    + `, + host: { + '[class]': 'classes()', + }, +}) +export class ZardInputGroupComponent { + readonly zSize = input('default'); + readonly zAddOnBefore = input>(); + readonly zAddOnAfter = input>(); + readonly zPrefix = input>(); + readonly zSuffix = input>(); + readonly zDisabled = input(false, { transform: booleanAttribute }); + readonly zBorderless = input(false, { transform: booleanAttribute }); + readonly zAriaLabel = input(); + readonly zAriaLabelledBy = input(); + readonly zAriaDescribedBy = input(); + readonly zAddOnBeforeAriaLabel = input(); + readonly zAddOnAfterAriaLabel = input(); + readonly zPrefixAriaLabel = input(); + readonly zSuffixAriaLabel = input(); + readonly class = input(''); + + protected readonly classes = computed(() => mergeClasses('w-full', this.class())); + + private readonly uniqueId = generateId('input-group'); + protected readonly addonBeforeId = computed(() => `${this.uniqueId}-addon-before`); + protected readonly addonAfterId = computed(() => `${this.uniqueId}-addon-after`); + protected readonly prefixId = computed(() => `${this.uniqueId}-prefix`); + protected readonly suffixId = computed(() => `${this.uniqueId}-suffix`); + + protected readonly wrapperClasses = computed(() => + inputGroupVariants({ + zSize: this.zSize(), + zDisabled: this.zDisabled(), + }), + ); + + protected readonly addonBeforeClasses = computed(() => + inputGroupAddonVariants({ + zSize: this.zSize(), + zPosition: 'before', + zDisabled: this.zDisabled(), + zBorderless: this.zBorderless(), + }), + ); + + protected readonly addonAfterClasses = computed(() => + inputGroupAddonVariants({ + zSize: this.zSize(), + zPosition: 'after', + zDisabled: this.zDisabled(), + zBorderless: this.zBorderless(), + }), + ); + + protected readonly prefixClasses = computed(() => + inputGroupAffixVariants({ + zSize: this.zSize(), + zPosition: 'prefix', + }), + ); + + protected readonly suffixClasses = computed(() => + inputGroupAffixVariants({ + zSize: this.zSize(), + zPosition: 'suffix', + }), + ); + + protected readonly inputWrapperClasses = computed(() => { + return mergeClasses( + inputGroupInputVariants({ + zSize: this.zSize(), + zHasPrefix: Boolean(this.zPrefix()), + zHasSuffix: Boolean(this.zSuffix()), + zHasAddonBefore: Boolean(this.zAddOnBefore()), + zHasAddonAfter: Boolean(this.zAddOnAfter()), + zDisabled: this.zDisabled(), + zBorderless: this.zBorderless(), + }), + 'relative', + ); + }); +} diff --git a/src/app/shared/components/input-group/input-group.variants.ts b/src/app/shared/components/input-group/input-group.variants.ts new file mode 100644 index 0000000..8e7f245 --- /dev/null +++ b/src/app/shared/components/input-group/input-group.variants.ts @@ -0,0 +1,151 @@ +import { cva, VariantProps } from 'class-variance-authority'; + +export const inputGroupVariants = cva( + 'flex items-stretch w-full [&_input[z-input]]:!border-0 [&_input[z-input]]:!bg-transparent [&_input[z-input]]:!outline-none [&_input[z-input]]:!ring-0 [&_input[z-input]]:!ring-offset-0 [&_input[z-input]]:!px-0 [&_input[z-input]]:!py-0 [&_input[z-input]]:!h-full [&_input[z-input]]:flex-1 [&_textarea[z-input]]:!border-0 [&_textarea[z-input]]:!bg-transparent [&_textarea[z-input]]:!outline-none [&_textarea[z-input]]:!ring-0 [&_textarea[z-input]]:!ring-offset-0 [&_textarea[z-input]]:!px-0 [&_textarea[z-input]]:!py-0', + { + variants: { + zSize: { + sm: 'h-9', + default: 'h-10', + lg: 'h-11', + }, + zDisabled: { + true: 'opacity-50 cursor-not-allowed', + false: '', + }, + }, + defaultVariants: { + zSize: 'default', + zDisabled: false, + }, + }, +); + +export const inputGroupAddonVariants = cva( + 'addon inline-flex items-center justify-center whitespace-nowrap text-sm font-medium border border-input bg-muted text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', + { + variants: { + zSize: { + sm: 'h-9 px-3 text-xs', + default: 'h-10 px-3 text-sm', + lg: 'h-11 px-4 text-base', + }, + zPosition: { + before: 'rounded-l-md border-r-0', + after: 'rounded-r-md border-l-0', + }, + zDisabled: { + true: 'cursor-not-allowed opacity-50 pointer-events-none', + false: '', + }, + zBorderless: { + true: 'border-0 shadow-none', + false: '', + }, + }, + defaultVariants: { + zSize: 'default', + zPosition: 'before', + zDisabled: false, + zBorderless: false, + }, + }, +); + +export const inputGroupAffixVariants = cva('absolute inset-y-0 flex items-center text-muted-foreground pointer-events-none z-10', { + variants: { + zSize: { + sm: 'text-xs', + default: 'text-sm', + lg: 'text-base', + }, + zPosition: { + prefix: 'left-0 pl-3', + suffix: 'right-0 pr-3', + }, + }, + defaultVariants: { + zSize: 'default', + zPosition: 'prefix', + }, +}); + +export const inputGroupInputVariants = cva( + 'input-wrapper flex h-full w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-within:outline-none focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-colors', + { + variants: { + zSize: { + sm: 'h-9 px-3 py-1 text-sm', + default: 'h-10 px-3 py-2 text-sm', + lg: 'h-11 px-4 py-2 text-base', + }, + zHasPrefix: { + true: '', + false: '', + }, + zHasSuffix: { + true: '', + false: '', + }, + zHasAddonBefore: { + true: 'border-l-0 rounded-l-none', + false: '', + }, + zHasAddonAfter: { + true: 'border-r-0 rounded-r-none', + false: '', + }, + zDisabled: { + true: 'cursor-not-allowed opacity-50', + false: '', + }, + zBorderless: { + true: 'border-0 bg-transparent shadow-none', + false: '', + }, + }, + compoundVariants: [ + { + zHasPrefix: true, + zSize: 'sm', + class: 'pl-7', + }, + { + zHasPrefix: true, + zSize: 'default', + class: 'pl-8', + }, + { + zHasPrefix: true, + zSize: 'lg', + class: 'pl-9', + }, + { + zHasSuffix: true, + zSize: 'sm', + class: 'pr-12', + }, + { + zHasSuffix: true, + zSize: 'default', + class: 'pr-14', + }, + { + zHasSuffix: true, + zSize: 'lg', + class: 'pr-16', + }, + ], + defaultVariants: { + zSize: 'default', + zHasPrefix: false, + zHasSuffix: false, + zHasAddonBefore: false, + zHasAddonAfter: false, + zDisabled: false, + zBorderless: false, + }, + }, +); + +export type ZardInputGroupVariants = VariantProps; diff --git a/src/app/shared/components/input/input.directive.ts b/src/app/shared/components/input/input.directive.ts new file mode 100644 index 0000000..6717794 --- /dev/null +++ b/src/app/shared/components/input/input.directive.ts @@ -0,0 +1,29 @@ +import type { ClassValue } from 'clsx'; + +import { computed, Directive, ElementRef, inject, input } from '@angular/core'; + +import { mergeClasses, transform } from '@shared/utils/merge-classes'; +import { inputVariants, ZardInputVariants } from './input.variants'; + +@Directive({ + selector: 'input[z-input], textarea[z-input]', + exportAs: 'zInput', + standalone: true, + host: { + '[class]': 'classes()', + }, +}) +export class ZardInputDirective { + readonly elementRef = inject(ElementRef); + private readonly isTextarea = this.elementRef.nativeElement.tagName.toLowerCase() === 'textarea'; + + readonly zBorderless = input(false, { transform }); + readonly zSize = input('default'); + readonly zStatus = input(); + + readonly class = input(''); + + protected readonly classes = computed(() => + mergeClasses(inputVariants({ zType: !this.isTextarea ? 'default' : 'textarea', zSize: this.zSize(), zStatus: this.zStatus(), zBorderless: this.zBorderless() }), this.class()), + ); +} diff --git a/src/app/shared/components/input/input.variants.ts b/src/app/shared/components/input/input.variants.ts new file mode 100644 index 0000000..6e1d579 --- /dev/null +++ b/src/app/shared/components/input/input.variants.ts @@ -0,0 +1,33 @@ +import { cva, VariantProps } from 'class-variance-authority'; + +export type zInputIcon = 'email' | 'password' | 'text'; + +export const inputVariants = cva('w-full', { + variants: { + zType: { + default: + 'flex rounded-md border px-4 font-normal border-input bg-transparent text-base md:text-sm ring-offset-background file:border-0 file:text-foreground file:bg-transparent file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', + textarea: + 'flex min-h-[80px] rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm', + }, + zSize: { + default: 'h-10 py-2 file:max-md:py-0', + sm: 'h-9 file:md:py-2 file:max-md:py-1.5', + lg: 'h-11 py-1 file:md:py-3 file:max-md:py-2.5', + }, + zStatus: { + error: 'border-destructive focus-visible:ring-destructive', + warning: 'border-yellow-500 focus-visible:ring-yellow-500', + success: 'border-green-500 focus-visible:ring-green-500', + }, + zBorderless: { + true: 'flex-1 bg-transparent border-0 outline-none focus-visible:ring-0 focus-visible:ring-offset-0 px-0 py-0', + }, + }, + defaultVariants: { + zType: 'default', + zSize: 'default', + }, +}); + +export type ZardInputVariants = VariantProps; diff --git a/src/app/shared/components/layout/content.component.ts b/src/app/shared/components/layout/content.component.ts new file mode 100644 index 0000000..57cb96a --- /dev/null +++ b/src/app/shared/components/layout/content.component.ts @@ -0,0 +1,27 @@ +import type { ClassValue } from 'clsx'; + +import { ChangeDetectionStrategy, Component, computed, input, ViewEncapsulation } from '@angular/core'; + +import { mergeClasses } from '@shared/utils/merge-classes'; +import { contentVariants } from './layout.variants'; + +@Component({ + selector: 'z-content', + exportAs: 'zContent', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
    + +
    + `, + host: { + '[class]': 'classes()', + }, +}) +export class ContentComponent { + readonly class = input(''); + + protected readonly classes = computed(() => mergeClasses(contentVariants(), this.class())); +} diff --git a/src/app/shared/components/layout/footer.component.ts b/src/app/shared/components/layout/footer.component.ts new file mode 100644 index 0000000..8a1f756 --- /dev/null +++ b/src/app/shared/components/layout/footer.component.ts @@ -0,0 +1,25 @@ +import type { ClassValue } from 'clsx'; + +import { ChangeDetectionStrategy, Component, computed, input, ViewEncapsulation } from '@angular/core'; + +import { mergeClasses } from '@shared/utils/merge-classes'; +import { footerVariants } from './layout.variants'; + +@Component({ + selector: 'z-footer', + exportAs: 'zFooter', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
    + +
    + `, +}) +export class FooterComponent { + readonly class = input(''); + readonly zHeight = input(64); + + protected readonly classes = computed(() => mergeClasses(footerVariants(), this.class())); +} diff --git a/src/app/shared/components/layout/header.component.ts b/src/app/shared/components/layout/header.component.ts new file mode 100644 index 0000000..8925449 --- /dev/null +++ b/src/app/shared/components/layout/header.component.ts @@ -0,0 +1,25 @@ +import type { ClassValue } from 'clsx'; + +import { ChangeDetectionStrategy, Component, computed, input, ViewEncapsulation } from '@angular/core'; + +import { mergeClasses } from '@shared/utils/merge-classes'; +import { headerVariants } from './layout.variants'; + +@Component({ + selector: 'z-header', + exportAs: 'zHeader', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
    + +
    + `, +}) +export class HeaderComponent { + readonly class = input(''); + readonly zHeight = input(64); + + protected readonly classes = computed(() => mergeClasses(headerVariants(), this.class())); +} diff --git a/src/app/shared/components/layout/layout.component.ts b/src/app/shared/components/layout/layout.component.ts new file mode 100644 index 0000000..6f92aa6 --- /dev/null +++ b/src/app/shared/components/layout/layout.component.ts @@ -0,0 +1,45 @@ +import type { ClassValue } from 'clsx'; + +import { ChangeDetectionStrategy, Component, computed, contentChildren, input, ViewEncapsulation } from '@angular/core'; + +import { mergeClasses } from '@shared/utils/merge-classes'; +import { layoutVariants, LayoutVariants } from './layout.variants'; +import { SidebarComponent } from './sidebar.component'; + +@Component({ + selector: 'z-layout', + exportAs: 'zLayout', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + host: { + '[class]': 'classes()', + }, + template: ``, +}) +export class LayoutComponent { + readonly class = input(''); + readonly zDirection = input('auto'); + + // Query for direct sidebar children to auto-detect layout direction + private readonly sidebars = contentChildren(SidebarComponent, { descendants: false }); + + private readonly detectedDirection = computed(() => { + if (this.zDirection() !== 'auto') { + return this.zDirection(); + } + + // Auto-detection: Check if there are any sidebar children + const hasSidebar = this.sidebars().length > 0; + return hasSidebar ? 'horizontal' : 'vertical'; + }); + + protected readonly classes = computed(() => + mergeClasses( + layoutVariants({ + zDirection: this.detectedDirection() as LayoutVariants['zDirection'], + }), + this.class(), + ), + ); +} diff --git a/src/app/shared/components/layout/layout.module.ts b/src/app/shared/components/layout/layout.module.ts new file mode 100644 index 0000000..ea5d798 --- /dev/null +++ b/src/app/shared/components/layout/layout.module.ts @@ -0,0 +1,15 @@ +import { NgModule } from '@angular/core'; + +import { ContentComponent } from './content.component'; +import { FooterComponent } from './footer.component'; +import { HeaderComponent } from './header.component'; +import { LayoutComponent } from './layout.component'; +import { SidebarGroupLabelComponent, SidebarGroupComponent, SidebarComponent } from './sidebar.component'; + +const LAYOUT_COMPONENTS = [LayoutComponent, HeaderComponent, FooterComponent, ContentComponent, SidebarComponent, SidebarGroupComponent, SidebarGroupLabelComponent]; + +@NgModule({ + imports: [LAYOUT_COMPONENTS], + exports: [LAYOUT_COMPONENTS], +}) +export class LayoutModule {} diff --git a/src/app/shared/components/layout/layout.variants.ts b/src/app/shared/components/layout/layout.variants.ts new file mode 100644 index 0000000..6df9e37 --- /dev/null +++ b/src/app/shared/components/layout/layout.variants.ts @@ -0,0 +1,48 @@ +import { cva, VariantProps } from 'class-variance-authority'; + +// Layout Variants +export const layoutVariants = cva('flex w-full min-h-0', { + variants: { + zDirection: { + horizontal: 'flex-row', + vertical: 'flex-col', + auto: 'flex-col', + }, + }, + defaultVariants: { + zDirection: 'auto', + }, +}); +export type LayoutVariants = VariantProps; + +// Header Variants +export const headerVariants = cva('flex items-center px-4 bg-background border-b border-border shrink-0', { + variants: {}, +}); +export type HeaderVariants = VariantProps; + +// Footer Variants +export const footerVariants = cva('flex items-center px-6 bg-background border-t border-border shrink-0', { + variants: {}, +}); +export type FooterVariants = VariantProps; + +// Content Variants +export const contentVariants = cva('flex-1 flex flex-col overflow-auto bg-background p-6 min-h-dvh'); +export type ContentVariants = VariantProps; + +// Sidebar Variants +export const sidebarVariants = cva( + 'relative flex flex-col h-full transition-all duration-300 ease-in-out border-r shrink-0 p-6 bg-sidebar text-sidebar-foreground border-sidebar-border', +); + +export const sidebarTriggerVariants = cva( + 'absolute bottom-4 z-10 flex items-center justify-center cursor-pointer rounded-sm border border-sidebar-border bg-sidebar hover:bg-sidebar-accent transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring focus-visible:ring-offset-2 w-6 h-6 -right-3', +); + +// Sidebar Group Variants +export const sidebarGroupVariants = cva('flex flex-col gap-1'); + +export const sidebarGroupLabelVariants = cva( + 'flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 focus-visible:ring-sidebar-ring [&>svg]:size-4 [&>svg]:shrink-0', +); diff --git a/src/app/shared/components/layout/sidebar.component.ts b/src/app/shared/components/layout/sidebar.component.ts new file mode 100644 index 0000000..723aa1e --- /dev/null +++ b/src/app/shared/components/layout/sidebar.component.ts @@ -0,0 +1,127 @@ +import type { ClassValue } from 'clsx'; + +import { ChangeDetectionStrategy, Component, computed, effect, input, output, signal, TemplateRef, ViewEncapsulation } from '@angular/core'; + +import { mergeClasses, transform } from '@shared/utils/merge-classes'; +import { ZardStringTemplateOutletDirective } from '../core/directives/string-template-outlet/string-template-outlet.directive'; +import { sidebarGroupLabelVariants, sidebarGroupVariants, sidebarTriggerVariants, sidebarVariants } from './layout.variants'; + +@Component({ + selector: 'z-sidebar', + exportAs: 'zSidebar', + standalone: true, + imports: [ZardStringTemplateOutletDirective], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` + + `, +}) +export class SidebarComponent { + readonly zWidth = input(200); + readonly zCollapsedWidth = input(64); + readonly zCollapsible = input(false, { transform }); + readonly zCollapsed = input(false, { transform }); + readonly zReverseArrow = input(false, { transform }); + readonly zTrigger = input | null>(null); + readonly class = input(''); + + readonly zCollapsedChange = output(); + + private readonly internalCollapsed = signal(false); + + constructor() { + effect(() => { + this.internalCollapsed.set(this.zCollapsed()); + }); + } + + protected readonly currentWidth = computed(() => { + const collapsed = this.zCollapsed(); + if (collapsed) { + return this.zCollapsedWidth(); + } + + const width = this.zWidth(); + return typeof width === 'number' ? width : parseInt(width, 10); + }); + + protected readonly chevronIcon = computed(() => { + const collapsed = this.zCollapsed(); + const reverse = this.zReverseArrow(); + + if (reverse) { + return collapsed ? 'icon-chevron-left text-base' : 'icon-chevron-right text-base'; + } + return collapsed ? 'icon-chevron-right text-base' : 'icon-chevron-left text-base'; + }); + + protected readonly classes = computed(() => mergeClasses(sidebarVariants(), this.class())); + + protected readonly triggerClasses = computed(() => mergeClasses(sidebarTriggerVariants())); + + toggleCollapsed(): void { + const newState = !this.zCollapsed(); + this.internalCollapsed.set(newState); + this.zCollapsedChange.emit(newState); + } +} + +@Component({ + selector: 'z-sidebar-group', + exportAs: 'zSidebarGroup', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
    + +
    + `, +}) +export class SidebarGroupComponent { + readonly class = input(''); + + protected readonly classes = computed(() => mergeClasses(sidebarGroupVariants(), this.class())); +} + +@Component({ + selector: 'z-sidebar-group-label', + exportAs: 'zSidebarGroupLabel', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
    + +
    + `, +}) +export class SidebarGroupLabelComponent { + readonly class = input(''); + + protected readonly classes = computed(() => mergeClasses(sidebarGroupLabelVariants(), this.class())); +} diff --git a/src/app/shared/components/loader/loader.component.ts b/src/app/shared/components/loader/loader.component.ts new file mode 100644 index 0000000..5bbdcfd --- /dev/null +++ b/src/app/shared/components/loader/loader.component.ts @@ -0,0 +1,55 @@ +import { ChangeDetectionStrategy, Component, computed, input, ViewEncapsulation } from '@angular/core'; +import type { ClassValue } from 'clsx'; + +import { mergeClasses } from '@shared/utils/merge-classes'; +import { loaderVariants, ZardLoaderVariants } from './loader.variants'; + +@Component({ + selector: 'z-loader', + exportAs: 'zLoader', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
    + @for (_ of bars; track $index) { +
    + } +
    + `, + styles: ` + @layer utilities { + @keyframes spinner { + 0% { + opacity: 1; + } + 100% { + opacity: 0.15; + } + } + + .animate-spinner { + animation: spinner 1.2s linear infinite; + } + } + `, + host: { + '[class]': 'classes()', + }, +}) +export class ZardLoaderComponent { + readonly class = input(''); + readonly zSize = input('default'); + + protected readonly bars = Array.from({ length: 12 }); + protected readonly animationDelay = (index: number) => `-${1.3 - index * 0.1}s`; + protected readonly transform = (index: number) => `rotate(${30 * index}deg) translate(146%)`; + + protected readonly classes = computed(() => mergeClasses(loaderVariants({ zSize: this.zSize() }), this.class())); +} \ No newline at end of file diff --git a/src/app/shared/components/loader/loader.variants.ts b/src/app/shared/components/loader/loader.variants.ts new file mode 100644 index 0000000..07770b9 --- /dev/null +++ b/src/app/shared/components/loader/loader.variants.ts @@ -0,0 +1,15 @@ +import { cva, VariantProps } from 'class-variance-authority'; + +export const loaderVariants = cva('', { + variants: { + zSize: { + default: 'size-6', + sm: 'size-4', + lg: 'size-8', + }, + }, + defaultVariants: { + zSize: 'default', + }, +}); +export type ZardLoaderVariants = VariantProps; \ No newline at end of file diff --git a/src/app/shared/components/menu/menu-content.directive.ts b/src/app/shared/components/menu/menu-content.directive.ts new file mode 100644 index 0000000..e91cdba --- /dev/null +++ b/src/app/shared/components/menu/menu-content.directive.ts @@ -0,0 +1,21 @@ +import type { ClassValue } from 'clsx'; + +import { CdkMenu } from '@angular/cdk/menu'; +import { computed, Directive, input } from '@angular/core'; + +import { mergeClasses } from '@shared/utils/merge-classes'; +import { menuContentVariants } from './menu.variants'; + +@Directive({ + selector: '[z-menu-content]', + standalone: true, + hostDirectives: [CdkMenu], + host: { + '[class]': 'classes()', + }, +}) +export class ZardMenuContentDirective { + readonly class = input(''); + + protected readonly classes = computed(() => mergeClasses(menuContentVariants(), this.class())); +} \ No newline at end of file diff --git a/src/app/shared/components/menu/menu-item.directive.ts b/src/app/shared/components/menu/menu-item.directive.ts new file mode 100644 index 0000000..589be95 --- /dev/null +++ b/src/app/shared/components/menu/menu-item.directive.ts @@ -0,0 +1,85 @@ +import type { ClassValue } from 'clsx'; + +import { BooleanInput } from '@angular/cdk/coercion'; +import { CdkMenuItem } from '@angular/cdk/menu'; +import { booleanAttribute, computed, Directive, effect, inject, input, signal, untracked } from '@angular/core'; + +import { mergeClasses } from '@shared/utils/merge-classes'; +import { menuItemVariants, ZardMenuItemVariants } from './menu.variants'; + +@Directive({ + selector: 'button[z-menu-item], [z-menu-item]', + standalone: true, + hostDirectives: [ + { + directive: CdkMenuItem, + outputs: ['cdkMenuItemTriggered: menuItemTriggered'], + }, + ], + host: { + '[class]': 'classes()', + '[attr.data-orientation]': "'horizontal'", + '[attr.data-state]': 'isOpenState()', + '[attr.aria-disabled]': "disabledState() ? '' : undefined", + '[attr.data-disabled]': "disabledState() ? '' : undefined", + '[attr.data-highlighted]': "highlightedState() ? '' : undefined", + + '(focus)': 'onFocus()', + '(blur)': 'onBlur()', + '(pointermove)': 'onPointerMove($event)', + }, +}) +export class ZardMenuItemDirective { + private readonly cdkMenuItem = inject(CdkMenuItem, { host: true }); + + readonly zDisabled = input(false, { transform: booleanAttribute }); + readonly zInset = input(false); + readonly class = input(''); + + private readonly isFocused = signal(false); + + protected readonly disabledState = computed(() => this.zDisabled()); + + protected readonly isOpenState = computed(() => this.cdkMenuItem.isMenuOpen()); + + protected readonly highlightedState = computed(() => this.isFocused()); + + protected readonly classes = computed(() => + mergeClasses( + menuItemVariants({ + inset: this.zInset(), + }), + this.class(), + ), + ); + + constructor() { + effect(() => { + const disabled = this.zDisabled(); + untracked(() => { + this.cdkMenuItem.disabled = disabled; + }); + }); + } + + onFocus(): void { + if (!this.zDisabled()) { + this.isFocused.set(true); + } + } + + onBlur(): void { + this.isFocused.set(false); + } + + onPointerMove(event: PointerEvent) { + if (event.defaultPrevented) return; + + if (!(event.pointerType === 'mouse')) return; + + if (!this.zDisabled()) { + const item = event.currentTarget; + (item as HTMLElement)?.focus({ preventScroll: true }); + } + } +} \ No newline at end of file diff --git a/src/app/shared/components/menu/menu-manager.service.ts b/src/app/shared/components/menu/menu-manager.service.ts new file mode 100644 index 0000000..780b981 --- /dev/null +++ b/src/app/shared/components/menu/menu-manager.service.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@angular/core'; + +import { ZardMenuDirective } from './menu.directive'; + +@Injectable({ + providedIn: 'root', +}) +export class ZardMenuManagerService { + private activeHoverMenu: ZardMenuDirective | null = null; + + registerHoverMenu(menu: ZardMenuDirective): void { + if (this.activeHoverMenu && this.activeHoverMenu !== menu) { + this.activeHoverMenu.close(); + } + this.activeHoverMenu = menu; + } + + unregisterHoverMenu(menu: ZardMenuDirective): void { + if (this.activeHoverMenu === menu) { + this.activeHoverMenu = null; + } + } + + closeActiveMenu(): void { + if (this.activeHoverMenu) { + this.activeHoverMenu.close(); + this.activeHoverMenu = null; + } + } +} \ No newline at end of file diff --git a/src/app/shared/components/menu/menu-positions.ts b/src/app/shared/components/menu/menu-positions.ts new file mode 100644 index 0000000..4bcf9ba --- /dev/null +++ b/src/app/shared/components/menu/menu-positions.ts @@ -0,0 +1,210 @@ +import { ConnectedPosition } from '@angular/cdk/overlay'; + +export const MENU_POSITIONS_MAP: { [key: string]: ConnectedPosition[] } = { + bottomLeft: [ + { + originX: 'start', + originY: 'bottom', + overlayX: 'start', + overlayY: 'top', + offsetY: 8, + }, + { + originX: 'start', + originY: 'top', + overlayX: 'start', + overlayY: 'bottom', + offsetY: -8, + }, + ], + bottomCenter: [ + { + originX: 'center', + originY: 'bottom', + overlayX: 'center', + overlayY: 'top', + offsetY: 8, + }, + { + originX: 'center', + originY: 'top', + overlayX: 'center', + overlayY: 'bottom', + offsetY: -8, + }, + ], + bottomRight: [ + { + originX: 'end', + originY: 'bottom', + overlayX: 'end', + overlayY: 'top', + offsetY: 8, + }, + { + originX: 'end', + originY: 'top', + overlayX: 'end', + overlayY: 'bottom', + offsetY: -8, + }, + ], + topLeft: [ + { + originX: 'start', + originY: 'top', + overlayX: 'start', + overlayY: 'bottom', + offsetY: -8, + }, + { + originX: 'start', + originY: 'bottom', + overlayX: 'start', + overlayY: 'top', + offsetY: 8, + }, + ], + topCenter: [ + { + originX: 'center', + originY: 'top', + overlayX: 'center', + overlayY: 'bottom', + offsetY: -8, + }, + { + originX: 'center', + originY: 'bottom', + overlayX: 'center', + overlayY: 'top', + offsetY: 8, + }, + ], + topRight: [ + { + originX: 'end', + originY: 'top', + overlayX: 'end', + overlayY: 'bottom', + offsetY: -8, + }, + { + originX: 'end', + originY: 'bottom', + overlayX: 'end', + overlayY: 'top', + offsetY: 8, + }, + ], + leftTop: [ + { + originX: 'start', + originY: 'top', + overlayX: 'end', + overlayY: 'top', + offsetX: -8, + }, + { + originX: 'end', + originY: 'top', + overlayX: 'start', + overlayY: 'top', + offsetX: 8, + }, + ], + leftCenter: [ + { + originX: 'start', + originY: 'center', + overlayX: 'end', + overlayY: 'center', + offsetX: -8, + }, + { + originX: 'end', + originY: 'center', + overlayX: 'start', + overlayY: 'center', + offsetX: 8, + }, + ], + leftBottom: [ + { + originX: 'start', + originY: 'bottom', + overlayX: 'end', + overlayY: 'bottom', + offsetX: -8, + }, + { + originX: 'end', + originY: 'bottom', + overlayX: 'start', + overlayY: 'bottom', + offsetX: 8, + }, + ], + rightTop: [ + { + originX: 'end', + originY: 'top', + overlayX: 'start', + overlayY: 'top', + offsetX: 8, + }, + { + originX: 'start', + originY: 'top', + overlayX: 'end', + overlayY: 'top', + offsetX: -8, + }, + ], + rightCenter: [ + { + originX: 'end', + originY: 'center', + overlayX: 'start', + overlayY: 'center', + offsetX: 8, + }, + { + originX: 'start', + originY: 'center', + overlayX: 'end', + overlayY: 'center', + offsetX: -8, + }, + ], + rightBottom: [ + { + originX: 'end', + originY: 'bottom', + overlayX: 'start', + overlayY: 'bottom', + offsetX: 8, + }, + { + originX: 'start', + originY: 'bottom', + overlayX: 'end', + overlayY: 'bottom', + offsetX: -8, + }, + ], +}; + +export type ZardMenuPlacement = + | 'bottomLeft' + | 'bottomCenter' + | 'bottomRight' + | 'topLeft' + | 'topCenter' + | 'topRight' + | 'leftTop' + | 'leftCenter' + | 'leftBottom' + | 'rightTop' + | 'rightCenter' + | 'rightBottom'; \ No newline at end of file diff --git a/src/app/shared/components/menu/menu.directive.ts b/src/app/shared/components/menu/menu.directive.ts new file mode 100644 index 0000000..9d6feba --- /dev/null +++ b/src/app/shared/components/menu/menu.directive.ts @@ -0,0 +1,186 @@ +import { BooleanInput } from '@angular/cdk/coercion'; +import { CdkMenuTrigger } from '@angular/cdk/menu'; +import { ConnectedPosition } from '@angular/cdk/overlay'; +import { booleanAttribute, computed, Directive, effect, ElementRef, inject, input, OnDestroy, OnInit, PLATFORM_ID, untracked } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; + +import { ZardMenuManagerService } from './menu-manager.service'; +import { MENU_POSITIONS_MAP, ZardMenuPlacement } from './menu-positions'; + +export type ZardMenuTrigger = 'click' | 'hover'; + +@Directive({ + selector: '[z-menu]', + standalone: true, + hostDirectives: [ + { + directive: CdkMenuTrigger, + inputs: ['cdkMenuTriggerFor: zMenuTriggerFor'], + }, + ], + host: { + role: 'button', + '[attr.aria-haspopup]': "'menu'", + '[attr.aria-expanded]': 'cdkTrigger.isOpen()', + '[attr.data-state]': "cdkTrigger.isOpen() ? 'open': 'closed'", + '[attr.data-disabled]': "zDisabled() ? '' : undefined", + '[style.cursor]': "'pointer'", + }, +}) +export class ZardMenuDirective implements OnInit, OnDestroy { + private static readonly MENU_OVERLAY_SELECTOR = '.cdk-overlay-container .cdk-overlay-pane:last-child'; + private static readonly MENU_CONTENT_SELECTOR = '.cdk-overlay-pane [z-menu-content]'; + + protected readonly cdkTrigger = inject(CdkMenuTrigger, { host: true }); + private readonly elementRef = inject(ElementRef); + private readonly menuManager = inject(ZardMenuManagerService); + private readonly platformId = inject(PLATFORM_ID); + + private closeTimeout: ReturnType | null = null; + private readonly cleanupFunctions: Array<() => void> = []; + + readonly zMenuTriggerFor = input.required(); + readonly zDisabled = input(false, { transform: booleanAttribute }); + readonly zTrigger = input('click'); + readonly zHoverDelay = input(100); + readonly zPlacement = input('bottomLeft'); + + private readonly menuPositions = computed(() => this.getPositionsByPlacement(this.zPlacement())); + + constructor() { + effect(() => { + const positions = this.menuPositions(); + untracked(() => { + this.cdkTrigger.menuPosition = positions; + }); + }); + } + + private getPositionsByPlacement(placement: ZardMenuPlacement): ConnectedPosition[] { + return MENU_POSITIONS_MAP[placement] || MENU_POSITIONS_MAP['bottomLeft']; + } + + ngOnInit(): void { + const isMobile = this.isMobileDevice(); + + // If trigger is hover but device is mobile, skip hover behavior + // The CDK MenuTrigger will handle click by default + if (this.zTrigger() === 'hover' && !isMobile) { + this.initializeHoverBehavior(); + } + } + + ngOnDestroy(): void { + this.cancelScheduledClose(); + this.menuManager.unregisterHoverMenu(this); + this.cleanupFunctions.forEach(cleanup => cleanup()); + this.cleanupFunctions.length = 0; + } + + close(): void { + this.cancelScheduledClose(); + this.cdkTrigger.close(); + } + + private initializeHoverBehavior(): void { + this.setupTriggerListeners(); + this.setupMenuOpenListener(); + } + + private setupTriggerListeners(): void { + const element = this.elementRef.nativeElement; + + this.addEventListenerWithCleanup(element, 'mouseenter', () => { + if (this.zDisabled()) return; + + this.cancelScheduledClose(); + this.menuManager.registerHoverMenu(this); + this.cdkTrigger.open(); + }); + + this.addEventListenerWithCleanup(element, 'mouseleave', event => this.scheduleCloseIfNeeded(event as MouseEvent)); + } + + private setupMenuOpenListener(): void { + const openSubscription = this.cdkTrigger.opened.subscribe(() => { + setTimeout(() => this.setupMenuContentListeners(), 0); + }); + + const closeSubscription = this.cdkTrigger.closed.subscribe(() => { + this.menuManager.unregisterHoverMenu(this); + }); + + this.cleanupFunctions.push( + () => openSubscription.unsubscribe(), + () => closeSubscription.unsubscribe(), + ); + } + + private setupMenuContentListeners(): void { + const overlay = document.querySelector(ZardMenuDirective.MENU_OVERLAY_SELECTOR); + if (!overlay) return; + + this.addEventListenerWithCleanup(overlay, 'mouseenter', () => this.cancelScheduledClose()); + this.addEventListenerWithCleanup(overlay, 'mouseleave', event => this.scheduleCloseIfNeeded(event as MouseEvent)); + } + + private cancelScheduledClose(): void { + if (this.closeTimeout) { + clearTimeout(this.closeTimeout); + this.closeTimeout = null; + } + } + + private scheduleCloseIfNeeded(event: MouseEvent): void { + if (this.shouldKeepMenuOpen(event.relatedTarget as Element)) { + return; + } + + this.scheduleMenuClose(); + } + + private shouldKeepMenuOpen(relatedTarget: Element | null): boolean { + if (!relatedTarget) return false; + + const isMovingToTrigger = this.elementRef.nativeElement.contains(relatedTarget); + const isMovingToMenu = relatedTarget.closest(ZardMenuDirective.MENU_CONTENT_SELECTOR); + const isMovingToOtherTrigger = relatedTarget.matches('[z-menu]') && !this.elementRef.nativeElement.contains(relatedTarget); + + if (isMovingToOtherTrigger) { + return false; + } + + return isMovingToTrigger || !!isMovingToMenu; + } + + private scheduleMenuClose(): void { + this.closeTimeout = setTimeout(() => { + this.cdkTrigger.close(); + }, this.zHoverDelay()); + } + + private addEventListenerWithCleanup(element: Element, eventType: string, handler: (event: MouseEvent | Event) => void, options?: AddEventListenerOptions): void { + if (isPlatformBrowser(this.platformId)) { + element.addEventListener(eventType, handler, options); + this.cleanupFunctions.push(() => element.removeEventListener(eventType, handler, options)); + } + } + + private isMobileDevice(): boolean { + if (!isPlatformBrowser(this.platformId)) { + return false; // Default to desktop behavior on server + } + + // Check for touch support + const hasTouch = 'ontouchstart' in window || navigator.maxTouchPoints > 0; + + // Check for mobile user agent + const mobileRegex = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i; + const isMobileUA = mobileRegex.test(navigator.userAgent); + + // Check viewport width for small screens + const isSmallScreen = window.innerWidth <= 768; + + return hasTouch && (isMobileUA || isSmallScreen); + } +} \ No newline at end of file diff --git a/src/app/shared/components/menu/menu.module.ts b/src/app/shared/components/menu/menu.module.ts new file mode 100644 index 0000000..ac2cf82 --- /dev/null +++ b/src/app/shared/components/menu/menu.module.ts @@ -0,0 +1,13 @@ +import { NgModule } from '@angular/core'; + +import { ZardMenuContentDirective } from './menu-content.directive'; +import { ZardMenuItemDirective } from './menu-item.directive'; +import { ZardMenuDirective } from './menu.directive'; + +const MENU_COMPONENTS = [ZardMenuContentDirective, ZardMenuItemDirective, ZardMenuDirective]; + +@NgModule({ + imports: [MENU_COMPONENTS], + exports: [MENU_COMPONENTS], +}) +export class ZardMenuModule {} \ No newline at end of file diff --git a/src/app/shared/components/menu/menu.variants.ts b/src/app/shared/components/menu/menu.variants.ts new file mode 100644 index 0000000..bb56ef1 --- /dev/null +++ b/src/app/shared/components/menu/menu.variants.ts @@ -0,0 +1,23 @@ +import { cva, VariantProps } from 'class-variance-authority'; + +export const menuContentVariants = cva( + 'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-2 text-popover-foreground shadow-lg animate-in data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', +); + +export const menuItemVariants = cva( + 'relative flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 text-left', + { + variants: { + inset: { + true: 'pl-8', + false: '', + }, + }, + defaultVariants: { + inset: false, + }, + }, +); + +export type ZardMenuContentVariants = VariantProps; +export type ZardMenuItemVariants = VariantProps; \ No newline at end of file diff --git a/src/app/shared/components/modal/modal.css b/src/app/shared/components/modal/modal.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/shared/components/modal/modal.html b/src/app/shared/components/modal/modal.html new file mode 100644 index 0000000..e0998cb --- /dev/null +++ b/src/app/shared/components/modal/modal.html @@ -0,0 +1,35 @@ +@if (open()) { +
    +
    +
    +
    +

    {{ title() }}

    + +
    + +
    + +
    + + @if (showFooter()) { +
    + +
    + } +
    +
    +
    +} diff --git a/src/app/shared/components/modal/modal.spec.ts b/src/app/shared/components/modal/modal.spec.ts new file mode 100644 index 0000000..45f7009 --- /dev/null +++ b/src/app/shared/components/modal/modal.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { Modal } from './modal'; + +describe('Modal', () => { + let component: Modal; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [Modal] + }) + .compileComponents(); + + fixture = TestBed.createComponent(Modal); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/components/modal/modal.ts b/src/app/shared/components/modal/modal.ts new file mode 100644 index 0000000..9ba2cb4 --- /dev/null +++ b/src/app/shared/components/modal/modal.ts @@ -0,0 +1,56 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + HostListener, + input, + OnDestroy, + OnInit, + output, +} from '@angular/core'; + +@Component({ + selector: 'app-modal', + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './modal.html', + styleUrl: './modal.css', +}) +export class Modal implements OnInit, OnDestroy { + open = input(false); + title = input('Modifier'); + size = input<'sm' | 'md' | 'lg' | 'xl' | 'xxl'>('md'); + closeOnBackdrop = input(true); + showFooter = input(true); + + close = output(); + + sizeClass = computed( + () => + ({ + sm: 'max-w-sm', + md: 'max-w-lg', + lg: 'max-w-2xl', + xl: 'max-w-4xl', + xxl: 'max-w-6xl', + }[this.size()]) + ); + + onBackdrop() { + if (this.closeOnBackdrop()) this.close.emit(); + } + + ngOnInit() { + // Scroll lock quand la modale est ouverte + if (this.open()) document.documentElement.style.overflow = 'hidden'; + } + + ngOnDestroy() { + document.documentElement.style.overflow = ''; + } + + @HostListener('document:keydown.escape') onEsc() { + if (this.open()) this.close.emit(); + } +} diff --git a/src/app/shared/components/mode-toggle/mode-toggle.css b/src/app/shared/components/mode-toggle/mode-toggle.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/shared/components/mode-toggle/mode-toggle.html b/src/app/shared/components/mode-toggle/mode-toggle.html new file mode 100644 index 0000000..634b2f6 --- /dev/null +++ b/src/app/shared/components/mode-toggle/mode-toggle.html @@ -0,0 +1,13 @@ +
    + + + +
    diff --git a/src/app/shared/components/mode-toggle/mode-toggle.spec.ts b/src/app/shared/components/mode-toggle/mode-toggle.spec.ts new file mode 100644 index 0000000..f58d241 --- /dev/null +++ b/src/app/shared/components/mode-toggle/mode-toggle.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ModeToggle } from './mode-toggle'; + +describe('ModeToggle', () => { + let component: ModeToggle; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ModeToggle] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ModeToggle); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/components/mode-toggle/mode-toggle.ts b/src/app/shared/components/mode-toggle/mode-toggle.ts new file mode 100644 index 0000000..1226476 --- /dev/null +++ b/src/app/shared/components/mode-toggle/mode-toggle.ts @@ -0,0 +1,25 @@ +import { Component, computed } from '@angular/core'; +import { LucideAngularModule, MoonIcon, SunIcon } from 'lucide-angular'; +import { Theme } from 'src/app/core/services/theme'; +import { ZardSwitchComponent } from '../switch/switch.component'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; + +@Component({ + selector: 'app-mode-toggle', + imports: [LucideAngularModule, ZardSwitchComponent, FormsModule, ReactiveFormsModule], + templateUrl: './mode-toggle.html', + styleUrl: './mode-toggle.css', + standalone: true, +}) +export class ModeToggle { + readonly SunIcon = SunIcon; + readonly MoonIcon = MoonIcon; + + isDark = computed(() => this.theme.mode() === 'dark'); + + constructor(private theme: Theme) {} + + toggle() { + this.theme.toggle(); + } +} diff --git a/src/app/shared/components/pagination/pagination.component.ts b/src/app/shared/components/pagination/pagination.component.ts new file mode 100644 index 0000000..92fbccc --- /dev/null +++ b/src/app/shared/components/pagination/pagination.component.ts @@ -0,0 +1,245 @@ +import { booleanAttribute, ChangeDetectionStrategy, Component, computed, forwardRef, input, linkedSignal, output, ViewEncapsulation } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { ClassValue } from 'clsx'; + +import { + paginationContentVariants, + paginationEllipsisVariants, + paginationItemVariants, + paginationNextVariants, + paginationPreviousVariants, + paginationVariants, +} from './pagination.variants'; +import { buttonVariants, ZardButtonVariants } from '../button/button.variants'; +import { mergeClasses } from '@shared/utils/merge-classes'; + +@Component({ + selector: 'z-pagination-content', + exportAs: 'zPaginationContent', + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
    + +
    + `, +}) +export class ZardPaginationContentComponent { + readonly ariaLabel = input('pagination-content'); + + readonly class = input(''); + + protected readonly classes = computed(() => mergeClasses(paginationContentVariants(), this.class())); +} + +@Component({ + selector: 'z-pagination-item', + exportAs: 'zPaginationItem', + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
    + +
    + `, +}) +export class ZardPaginationItemComponent { + readonly class = input(''); + + protected readonly classes = computed(() => mergeClasses(paginationItemVariants(), this.class())); +} + +@Component({ + selector: 'z-pagination-button', + exportAs: 'zPaginationButton', + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` + + `, +}) +export class ZardPaginationButtonComponent { + readonly zDisabled = input(false, { transform: booleanAttribute }); + readonly zActive = input(false, { transform: booleanAttribute }); + readonly zSize = input('icon'); + + readonly class = input(''); + readonly zClick = output(); + + protected readonly classes = computed(() => mergeClasses(buttonVariants({ zType: this.zType(), zSize: this.zSize() }), this.class())); + + private readonly zType = computed(() => (this.zActive() ? 'outline' : 'ghost')); + + handleClick() { + if (!this.zDisabled() && !this.zActive()) { + this.zClick.emit(); + } + } +} + +@Component({ + selector: 'z-pagination-previous', + exportAs: 'zPaginationPrevious', + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + imports: [ZardPaginationButtonComponent], + template: ` + +
    + +
    + `, +}) +export class ZardPaginationPreviousComponent { + readonly class = input(''); + + protected readonly classes = computed(() => mergeClasses(paginationPreviousVariants(), this.class())); +} + +@Component({ + selector: 'z-pagination-next', + exportAs: 'zPaginationNext', + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + imports: [ZardPaginationButtonComponent], + template: ` + + +
    +
    + `, +}) +export class ZardPaginationNextComponent { + readonly class = input(''); + + protected readonly classes = computed(() => mergeClasses(paginationNextVariants(), this.class())); +} + +@Component({ + selector: 'z-pagination-ellipsis', + exportAs: 'zPaginationEllipsis', + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` + + More pages + `, + host: { + '[class]': 'classes()', + }, +}) +export class ZardPaginationEllipsisComponent { + readonly class = input(''); + + protected readonly classes = computed(() => mergeClasses(paginationEllipsisVariants(), this.class())); +} + +@Component({ + selector: 'z-pagination', + exportAs: 'zPagination', + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + imports: [ZardPaginationContentComponent, ZardPaginationItemComponent, ZardPaginationButtonComponent], + template: ` + + + +
    +
    +
    + + @for (page of pages(); track page) { + + + {{ page }} + + + } + + + +
    +
    +
    +
    + `, + host: { + '[class]': 'classes()', + }, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ZardPaginationComponent), + multi: true, + }, + ], +}) +export class ZardPaginationComponent implements ControlValueAccessor { + readonly zPageIndex = input(1); + readonly zTotal = input(1); + readonly zSize = input('icon'); + readonly zDisabled = input(false, { transform: booleanAttribute }); + + readonly class = input(''); + + readonly zPageIndexChange = output(); + + protected readonly classes = computed(() => mergeClasses(paginationVariants(), this.class())); + + protected readonly disabled = linkedSignal(() => { + return this.zDisabled(); + }); + + readonly currentPage = linkedSignal(this.zPageIndex); + + readonly pages = computed(() => Array.from({ length: Math.max(0, this.zTotal()) }, (_, i) => i + 1)); + + goToPage(page: number): void { + if (this.disabled()) return; + if (page !== this.currentPage() && page >= 1 && page <= this.zTotal()) { + this.currentPage.set(page); + this.zPageIndexChange.emit(page); + this.onChange(page); + this.onTouched(); + } + } + + goToPrevious() { + this.goToPage(this.currentPage() - 1); + } + + goToNext() { + this.goToPage(this.currentPage() + 1); + } + + // eslint-disable-next-line @typescript-eslint/no-empty-function + private onChange: (value: number) => void = () => {}; + // eslint-disable-next-line @typescript-eslint/no-empty-function + private onTouched: () => void = () => {}; + + writeValue(value: number): void { + this.currentPage.set(value); + } + + registerOnChange(fn: any): void { + this.onChange = fn; + } + + registerOnTouched(fn: any): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled.set(isDisabled); + } +} \ No newline at end of file diff --git a/src/app/shared/components/pagination/pagination.module.ts b/src/app/shared/components/pagination/pagination.module.ts new file mode 100644 index 0000000..ba44ff6 --- /dev/null +++ b/src/app/shared/components/pagination/pagination.module.ts @@ -0,0 +1,27 @@ +import { NgModule } from '@angular/core'; + +import { + ZardPaginationButtonComponent, + ZardPaginationComponent, + ZardPaginationContentComponent, + ZardPaginationEllipsisComponent, + ZardPaginationItemComponent, + ZardPaginationNextComponent, + ZardPaginationPreviousComponent, +} from './pagination.component'; + +const components = [ + ZardPaginationContentComponent, + ZardPaginationItemComponent, + ZardPaginationButtonComponent, + ZardPaginationPreviousComponent, + ZardPaginationNextComponent, + ZardPaginationEllipsisComponent, + ZardPaginationComponent, +]; + +@NgModule({ + imports: components, + exports: components, +}) +export class ZardPaginationModule {} \ No newline at end of file diff --git a/src/app/shared/components/pagination/pagination.variants.ts b/src/app/shared/components/pagination/pagination.variants.ts new file mode 100644 index 0000000..31f8dee --- /dev/null +++ b/src/app/shared/components/pagination/pagination.variants.ts @@ -0,0 +1,19 @@ +import { cva, VariantProps } from 'class-variance-authority'; + +export const paginationContentVariants = cva('flex flex-row items-center gap-1'); +export type ZardPaginationContentVariants = VariantProps; + +export const paginationItemVariants = cva(''); +export type ZardPaginationItemVariants = VariantProps; + +export const paginationPreviousVariants = cva('gap-1 px-2.5 sm:pl-2.5'); +export type ZardPaginationPreviousVariants = VariantProps; + +export const paginationNextVariants = cva('gap-1 px-2.5 sm:pr-2.5'); +export type ZardPaginationNextVariants = VariantProps; + +export const paginationEllipsisVariants = cva('flex size-9 items-center justify-center'); +export type ZardPaginationEllipsisVariants = VariantProps; + +export const paginationVariants = cva('mx-auto flex w-full justify-center'); +export type ZardPaginationVariants = VariantProps; \ No newline at end of file diff --git a/src/app/shared/components/paginator/paginator.css b/src/app/shared/components/paginator/paginator.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/shared/components/paginator/paginator.html b/src/app/shared/components/paginator/paginator.html new file mode 100644 index 0000000..2c0d360 --- /dev/null +++ b/src/app/shared/components/paginator/paginator.html @@ -0,0 +1,84 @@ +
    + +
    + Affichage de {{ (page() - 1) * perPage() + 1 }} à + {{ page() * perPage() < total() ? page() * perPage() : total() }} + sur {{ total() }} résultat{{ total() > 1 ? 's' : '' }} +
    + + +
    + + + + + + + + +
    +
    diff --git a/src/app/shared/components/paginator/paginator.spec.ts b/src/app/shared/components/paginator/paginator.spec.ts new file mode 100644 index 0000000..37cea0e --- /dev/null +++ b/src/app/shared/components/paginator/paginator.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { Paginator } from './paginator'; + +describe('Paginator', () => { + let component: Paginator; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [Paginator] + }) + .compileComponents(); + + fixture = TestBed.createComponent(Paginator); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/components/paginator/paginator.ts b/src/app/shared/components/paginator/paginator.ts new file mode 100644 index 0000000..3946d6b --- /dev/null +++ b/src/app/shared/components/paginator/paginator.ts @@ -0,0 +1,68 @@ +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; + +@Component({ + selector: 'app-paginator', + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './paginator.html', + styleUrl: './paginator.css', +}) +export class Paginator { + page = input(1); + perPage = input(10); + total = input(0); + pageSizes = input([10, 20, 50]); + + pageChange = output(); + perPageChange = output(); + + totalPages = computed(() => Math.max(1, Math.ceil(this.total() / this.perPage()))); + + /** Pages visibles (avec ellipses) */ + visiblePages = computed(() => { + const total = this.totalPages(); + const current = this.page(); + const delta = 2; + const range: (number | string)[] = []; + const left = Math.max(2, current - delta); + const right = Math.min(total - 1, current + delta); + + range.push(1); + if (left > 2) range.push('...'); + for (let i = left; i <= right; i++) range.push(i); + if (right < total - 1) range.push('...'); + if (total > 1) range.push(total); + return range; + }); + + /** Tableau complet des pages pour le sélecteur */ + totalPagesArray = computed(() => Array.from({ length: this.totalPages() }, (_, i) => i + 1)); + + prev() { + const newPage = this.page() - 1; + if (newPage >= 1) this.pageChange.emit(newPage); + } + + next() { + const newPage = this.page() + 1; + if (newPage <= this.totalPages()) this.pageChange.emit(newPage); + } + + goToPage(p: number) { + if (p >= 1 && p <= this.totalPages()) this.pageChange.emit(p); + } + + handlePerPageChange(event: Event) { + const value = (event.target as HTMLSelectElement).value; + this.perPageChange.emit(parseInt(value, 10)); + } + + /** Quand on sélectionne une page depuis le select */ + selectPage(event: Event) { + const pageNumber = parseInt((event.target as HTMLSelectElement).value, 10); + if (!isNaN(pageNumber) && pageNumber >= 1 && pageNumber <= this.totalPages()) { + this.pageChange.emit(pageNumber); + } + } +} diff --git a/src/app/shared/components/pmu-logo/pmu-logo.css b/src/app/shared/components/pmu-logo/pmu-logo.css new file mode 100644 index 0000000..a30e58e --- /dev/null +++ b/src/app/shared/components/pmu-logo/pmu-logo.css @@ -0,0 +1,3 @@ +:host { + @apply inline-block; +} diff --git a/src/app/shared/components/pmu-logo/pmu-logo.html b/src/app/shared/components/pmu-logo/pmu-logo.html new file mode 100644 index 0000000..6ab627a --- /dev/null +++ b/src/app/shared/components/pmu-logo/pmu-logo.html @@ -0,0 +1,9 @@ + diff --git a/src/app/shared/components/pmu-logo/pmu-logo.spec.ts b/src/app/shared/components/pmu-logo/pmu-logo.spec.ts new file mode 100644 index 0000000..60038f1 --- /dev/null +++ b/src/app/shared/components/pmu-logo/pmu-logo.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { PmuLogo } from './pmu-logo'; + +describe('PmuLogo', () => { + let component: PmuLogo; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PmuLogo] + }) + .compileComponents(); + + fixture = TestBed.createComponent(PmuLogo); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/components/pmu-logo/pmu-logo.ts b/src/app/shared/components/pmu-logo/pmu-logo.ts new file mode 100644 index 0000000..d7028f2 --- /dev/null +++ b/src/app/shared/components/pmu-logo/pmu-logo.ts @@ -0,0 +1,40 @@ +import { computed, Input } from '@angular/core'; +import { Component } from '@angular/core'; +import { Theme } from 'src/app/core/services/theme'; + +@Component({ + selector: 'app-pmu-logo', + imports: [], + templateUrl: './pmu-logo.html', + styleUrl: './pmu-logo.css', + standalone: true, +}) +export class PmuLogo { + /** Variants: default | small | footer | hero ... */ + @Input() variant: 'default' | 'small' | 'footer' | 'hero' = 'default'; + + isDark = computed(() => this.theme.mode() === 'dark'); + + constructor(private theme: Theme) {} + + get logoSrc() { + return this.isDark() ? '/assets/logos/pmu_logo_light.png' : '/assets/logos/pmu_logo_dark.png'; + } + + get logoAlt() { + return 'PMU MALI'; + } + + get logoClass() { + switch (this.variant) { + case 'small': + return 'h-10'; + case 'footer': + return 'h-12 opacity-80'; + case 'hero': + return 'h-24'; + default: + return 'h-16'; + } + } +} diff --git a/src/app/shared/components/popover/popover.component.ts b/src/app/shared/components/popover/popover.component.ts new file mode 100644 index 0000000..d57a9b5 --- /dev/null +++ b/src/app/shared/components/popover/popover.component.ts @@ -0,0 +1,368 @@ +import { merge, Subject, takeUntil } from 'rxjs'; + +import { ConnectedPosition, Overlay, OverlayPositionBuilder, OverlayRef } from '@angular/cdk/overlay'; +import { TemplatePortal } from '@angular/cdk/portal'; +import { isPlatformBrowser } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + Directive, + effect, + ElementRef, + inject, + input, + OnDestroy, + OnInit, + output, + PLATFORM_ID, + Renderer2, + signal, + TemplateRef, + ViewContainerRef, +} from '@angular/core'; + +import { mergeClasses } from '@shared/utils/merge-classes'; +import { popoverVariants } from './popover.variants'; + +export type ZardPopoverTrigger = 'click' | 'hover' | null; +export type ZardPopoverPlacement = 'top' | 'bottom' | 'left' | 'right'; + +const POPOVER_POSITIONS_MAP = { + top: { + originX: 'center', + originY: 'top', + overlayX: 'center', + overlayY: 'bottom', + offsetX: 0, + offsetY: -8, + }, + bottom: { + originX: 'center', + originY: 'bottom', + overlayX: 'center', + overlayY: 'top', + offsetX: 0, + offsetY: 8, + }, + left: { + originX: 'start', + originY: 'center', + overlayX: 'end', + overlayY: 'center', + offsetX: -8, + offsetY: 0, + }, + right: { + originX: 'end', + originY: 'center', + overlayX: 'start', + overlayY: 'center', + offsetX: 8, + offsetY: 0, + }, +} as const; + +@Directive({ + selector: '[zPopover]', + exportAs: 'zPopover', + standalone: true, +}) +export class ZardPopoverDirective implements OnInit, OnDestroy { + private readonly destroy$ = new Subject(); + private readonly hidePopover$ = new Subject(); + private readonly overlay = inject(Overlay); + private readonly overlayPositionBuilder = inject(OverlayPositionBuilder); + private readonly elementRef = inject(ElementRef); + private readonly renderer = inject(Renderer2); + private readonly viewContainerRef = inject(ViewContainerRef); + private readonly platformId = inject(PLATFORM_ID); + + private overlayRef?: OverlayRef; + + readonly zTrigger = input('click'); + readonly zContent = input.required>(); + readonly zPlacement = input('bottom'); + readonly zOrigin = input(); + readonly zVisible = input(false); + readonly zOverlayClickable = input(true); + readonly zVisibleChange = output(); + + private isVisible = signal(false); + + get nativeElement() { + return this.zOrigin()?.nativeElement || this.elementRef.nativeElement; + } + + constructor() { + // Watch for changes to zVisible input + // Using untracked for isVisible to avoid circular dependencies + effect(() => { + const visible = this.zVisible(); + + // Defer DOM manipulation to avoid change detection issues + setTimeout(() => { + const currentlyVisible = this.isVisible(); + if (visible && !currentlyVisible) { + this.show(); + } else if (!visible && currentlyVisible) { + this.hide(); + } + }); + }); + } + + ngOnInit() { + this.setupTriggers(); + this.createOverlay(); + } + + ngOnDestroy() { + this.hide(); + this.hidePopover$.complete(); + this.destroy$.next(); + this.destroy$.complete(); + } + + show() { + if (this.isVisible()) return; + + if (!this.overlayRef) { + this.createOverlay(); + } + + const templatePortal = new TemplatePortal(this.zContent(), this.viewContainerRef); + this.overlayRef?.attach(templatePortal); + this.isVisible.set(true); + this.zVisibleChange.emit(true); + + if (this.zOverlayClickable() && this.zTrigger() === 'click' && isPlatformBrowser(this.platformId)) { + this.setupOutsideClickListener(); + } + } + + hide() { + if (!this.isVisible()) return; + + this.hidePopover$.next(); + this.overlayRef?.detach(); + this.isVisible.set(false); + this.zVisibleChange.emit(false); + } + + toggle() { + if (this.isVisible()) { + this.hide(); + } else { + this.show(); + } + } + + private setupTriggers() { + const trigger = this.zTrigger(); + if (!trigger) return; + + if (trigger === 'click') { + this.renderer.listen(this.nativeElement, 'click', (event: Event) => { + event.stopPropagation(); + this.toggle(); + }); + } else if (trigger === 'hover') { + this.renderer.listen(this.nativeElement, 'mouseenter', () => { + this.show(); + }); + + this.renderer.listen(this.nativeElement, 'mouseleave', () => { + this.hide(); + }); + } + } + + private createOverlay() { + if (isPlatformBrowser(this.platformId)) { + const positionStrategy = this.overlayPositionBuilder + .flexibleConnectedTo(this.nativeElement) + .withPositions(this.getPositions()) + .withPush(false) + .withFlexibleDimensions(false) + .withViewportMargin(8); + + this.overlayRef = this.overlay.create({ + positionStrategy, + hasBackdrop: false, + scrollStrategy: this.overlay.scrollStrategies.reposition(), + }); + } + } + + private getPositions(): ConnectedPosition[] { + const placement = this.zPlacement(); + const positions: ConnectedPosition[] = []; + + // Primary position + const primaryConfig = POPOVER_POSITIONS_MAP[placement]; + positions.push({ + originX: primaryConfig.originX as any, + originY: primaryConfig.originY as any, + overlayX: primaryConfig.overlayX as any, + overlayY: primaryConfig.overlayY as any, + offsetX: primaryConfig.offsetX || 0, + offsetY: primaryConfig.offsetY || 0, + }); + + // Fallback positions for better positioning when primary doesn't fit + switch (placement) { + case 'bottom': + // Try top if bottom doesn't fit + positions.push({ + originX: 'center', + originY: 'top', + overlayX: 'center', + overlayY: 'bottom', + offsetX: 0, + offsetY: -8, + }); + // If neither top nor bottom work, try right + positions.push({ + originX: 'end', + originY: 'center', + overlayX: 'start', + overlayY: 'center', + offsetX: 8, + offsetY: 0, + }); + // Finally try left + positions.push({ + originX: 'start', + originY: 'center', + overlayX: 'end', + overlayY: 'center', + offsetX: -8, + offsetY: 0, + }); + break; + case 'top': + // Try bottom if top doesn't fit + positions.push({ + originX: 'center', + originY: 'bottom', + overlayX: 'center', + overlayY: 'top', + offsetX: 0, + offsetY: 8, + }); + // If neither top nor bottom work, try right + positions.push({ + originX: 'end', + originY: 'center', + overlayX: 'start', + overlayY: 'center', + offsetX: 8, + offsetY: 0, + }); + // Finally try left + positions.push({ + originX: 'start', + originY: 'center', + overlayX: 'end', + overlayY: 'center', + offsetX: -8, + offsetY: 0, + }); + break; + case 'right': + // Try left if right doesn't fit + positions.push({ + originX: 'start', + originY: 'center', + overlayX: 'end', + overlayY: 'center', + offsetX: -8, + offsetY: 0, + }); + // If neither left nor right work, try bottom + positions.push({ + originX: 'center', + originY: 'bottom', + overlayX: 'center', + overlayY: 'top', + offsetX: 0, + offsetY: 8, + }); + // Finally try top + positions.push({ + originX: 'center', + originY: 'top', + overlayX: 'center', + overlayY: 'bottom', + offsetX: 0, + offsetY: -8, + }); + break; + case 'left': + // Try right if left doesn't fit + positions.push({ + originX: 'end', + originY: 'center', + overlayX: 'start', + overlayY: 'center', + offsetX: 8, + offsetY: 0, + }); + // If neither left nor right work, try bottom + positions.push({ + originX: 'center', + originY: 'bottom', + overlayX: 'center', + overlayY: 'top', + offsetX: 0, + offsetY: 8, + }); + // Finally try top + positions.push({ + originX: 'center', + originY: 'top', + overlayX: 'center', + overlayY: 'bottom', + offsetX: 0, + offsetY: -8, + }); + break; + } + + return positions; + } + + private setupOutsideClickListener() { + if (!this.overlayRef) return; + + this.overlayRef + .outsidePointerEvents() + .pipe(takeUntil(merge(this.hidePopover$, this.destroy$))) + .subscribe(event => { + const clickTarget = event.target as HTMLElement; + + if (this.nativeElement.contains(clickTarget)) { + return; + } + + this.hide(); + }); + } +} + +@Component({ + selector: 'z-popover', + standalone: true, + imports: [], + template: ``, + host: { + '[class]': 'classes()', + }, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ZardPopoverComponent { + readonly class = input(''); + + protected readonly classes = computed(() => mergeClasses(popoverVariants(), this.class())); +} \ No newline at end of file diff --git a/src/app/shared/components/popover/popover.variants.ts b/src/app/shared/components/popover/popover.variants.ts new file mode 100644 index 0000000..b50576f --- /dev/null +++ b/src/app/shared/components/popover/popover.variants.ts @@ -0,0 +1,7 @@ +import { cva, VariantProps } from 'class-variance-authority'; + +export const popoverVariants = cva( + 'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', +); + +export type ZardPopoverVariants = VariantProps; \ No newline at end of file diff --git a/src/app/shared/components/progress-bar/progress-bar.component.ts b/src/app/shared/components/progress-bar/progress-bar.component.ts new file mode 100644 index 0000000..b9eb086 --- /dev/null +++ b/src/app/shared/components/progress-bar/progress-bar.component.ts @@ -0,0 +1,66 @@ +import type { ClassValue } from 'clsx'; + +import { ChangeDetectionStrategy, Component, computed, input, ViewEncapsulation } from '@angular/core'; + +import { mergeClasses } from '@shared/utils/merge-classes'; +import { containerProgressBarVariants, progressBarVariants, ZardContainerProgressBarVariants, ZardProgressBarVariants } from './progress-bar.variants'; + +@Component({ + selector: 'z-progress-bar', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + styles: ` + @keyframes indeterminate { + 0% { + left: -0%; + width: 30%; + } + 50% { + left: 50%; + width: 30%; + } + 100% { + left: 100%; + width: 0; + } + } + `, + template: ` + @if (zIndeterminate()) { +
    +
    +
    + } @else { +
    +
    +
    + } + `, + host: { + class: 'w-full', + }, +}) +export class ZardProgressBarComponent { + readonly zType = input('default'); + readonly zSize = input('default'); + readonly zShape = input('default'); + readonly zIndeterminate = input(undefined); + readonly class = input(''); + readonly barClass = input(''); + readonly progress = input(0); + + readonly correctedProgress = computed(() => { + if (this.progress() > 100) return 100; + if (this.progress() < 0) return 0; + return this.progress(); + }); + + protected readonly classes = computed(() => + mergeClasses(containerProgressBarVariants({ zIndeterminate: this.zIndeterminate(), zType: this.zType(), zSize: this.zSize(), zShape: this.zShape() }), this.class()), + ); + + protected readonly barClasses = computed(() => + mergeClasses(progressBarVariants({ zIndeterminate: this.zIndeterminate(), zType: this.zType(), zShape: this.zShape() }), this.barClass()), + ); +} \ No newline at end of file diff --git a/src/app/shared/components/progress-bar/progress-bar.variants.ts b/src/app/shared/components/progress-bar/progress-bar.variants.ts new file mode 100644 index 0000000..24b84cb --- /dev/null +++ b/src/app/shared/components/progress-bar/progress-bar.variants.ts @@ -0,0 +1,54 @@ +import { cva, VariantProps } from 'class-variance-authority'; + +export const containerProgressBarVariants = cva('w-full transition-all', { + variants: { + zType: { + default: 'bg-primary-foreground hover:bg-primary/10', + destructive: 'bg-primary-foreground dark:text-secondary-foreground hover:bg-destructive/10', + accent: 'bg-primary-foreground hover:bg-primary/10', + }, + zSize: { + default: 'h-2', + sm: 'h-3', + lg: 'h-5', + }, + zShape: { + default: 'rounded-sm', + circle: 'rounded-full', + square: 'rounded-none', + }, + zIndeterminate: { + true: 'relative', + }, + }, + + defaultVariants: { + zType: 'default', + zSize: 'default', + zShape: 'default', + }, +}); +export type ZardContainerProgressBarVariants = VariantProps; + +export const progressBarVariants = cva('h-full transition-all', { + variants: { + zType: { + default: 'bg-primary', + destructive: 'bg-destructive', + accent: 'bg-chart-1', + }, + zShape: { + default: 'rounded-sm', + circle: 'rounded-full ', + square: 'rounded-none', + }, + zIndeterminate: { + true: 'absolute animate-[indeterminate_1.5s_infinite_ease-out]', + }, + }, + defaultVariants: { + zType: 'default', + zShape: 'default', + }, +}); +export type ZardProgressBarVariants = VariantProps; \ No newline at end of file diff --git a/src/app/shared/components/radio/radio.component.ts b/src/app/shared/components/radio/radio.component.ts new file mode 100644 index 0000000..d3e3de0 --- /dev/null +++ b/src/app/shared/components/radio/radio.component.ts @@ -0,0 +1,108 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, computed, forwardRef, inject, input, output, ViewEncapsulation } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import type { ClassValue } from 'clsx'; +import { NgClass } from '@angular/common'; + +import { radioLabelVariants, radioVariants, ZardRadioVariants } from './radio.variants'; +import { generateId, mergeClasses, transform } from '@shared/utils/merge-classes'; + +type OnTouchedType = () => unknown; +type OnChangeType = (value: unknown) => void; + +@Component({ + selector: 'z-radio, [z-radio]', + standalone: true, + imports: [NgClass], + exportAs: 'zRadio', + template: ` + +
    + + + + +
    + +
    + `, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ZardRadioComponent), + multi: true, + }, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, +}) +export class ZardRadioComponent implements ControlValueAccessor { + private cdr = inject(ChangeDetectorRef); + + readonly radioChange = output(); + readonly class = input(''); + readonly disabled = input(false, { transform }); + readonly zType = input('default'); + readonly zSize = input('default'); + readonly name = input('radio'); + readonly zId = input(generateId('radio')); + readonly value = input(null); + /* eslint-disable-next-line @typescript-eslint/no-empty-function */ + private onChange: OnChangeType = () => {}; + /* eslint-disable-next-line @typescript-eslint/no-empty-function */ + private onTouched: OnTouchedType = () => {}; + + protected readonly classes = computed(() => mergeClasses(radioVariants({ zType: this.zType(), zSize: this.zSize() }), this.class())); + protected readonly labelClasses = computed(() => mergeClasses(radioLabelVariants({ zSize: this.zSize() }))); + + protected readonly svgSizeClass = computed(() => { + const size = this.zSize(); + if (size === 'lg') { + return 'h-5 w-5'; + } + if (size === 'sm') { + return 'h-2.5 w-2.5'; + } + return 'h-3.5 w-3.5'; // default size + }); + + checked = false; + + writeValue(val: unknown): void { + this.checked = val === this.value(); + this.cdr.markForCheck(); + } + + registerOnChange(fn: OnChangeType): void { + this.onChange = fn; + } + + registerOnTouched(fn: OnTouchedType): void { + this.onTouched = fn; + } + + onRadioBlur(): void { + this.onTouched(); + this.cdr.markForCheck(); + } + + onRadioChange(): void { + if (this.disabled()) return; + + this.checked = true; + this.onChange(this.value()); + this.radioChange.emit(this.checked); + this.cdr.markForCheck(); + } +} \ No newline at end of file diff --git a/src/app/shared/components/radio/radio.variants.ts b/src/app/shared/components/radio/radio.variants.ts new file mode 100644 index 0000000..086c722 --- /dev/null +++ b/src/app/shared/components/radio/radio.variants.ts @@ -0,0 +1,45 @@ +import { cva, VariantProps } from 'class-variance-authority'; + +export const radioVariants = cva( + 'cursor-[unset] peer appearance-none border transition shadow hover:shadow-md focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50', + { + variants: { + zType: { + default: 'border-primary checked:bg-primary', + destructive: 'border-destructive checked:bg-destructive', + secondary: 'border-secondary checked:bg-secondary', + }, + zSize: { + default: 'h-4 w-4', + sm: 'h-3 w-3', + lg: 'h-6 w-6', + }, + zShape: { + default: 'rounded-full', + circle: 'rounded-full', + square: 'rounded-none', + }, + }, + defaultVariants: { + zType: 'default', + zSize: 'default', + zShape: 'default', + }, + }, +); + +export const radioLabelVariants = cva('cursor-[unset] text-current empty:hidden', { + variants: { + zSize: { + default: 'text-base', + sm: 'text-sm', + lg: 'text-lg', + }, + }, + defaultVariants: { + zSize: 'default', + }, +}); + +export type ZardRadioVariants = VariantProps; +export type ZardRadioLabelVariants = VariantProps; \ No newline at end of file diff --git a/src/app/shared/components/resizable/resizable-handle.component.ts b/src/app/shared/components/resizable/resizable-handle.component.ts new file mode 100644 index 0000000..6b405ac --- /dev/null +++ b/src/app/shared/components/resizable/resizable-handle.component.ts @@ -0,0 +1,188 @@ +import type { ClassValue } from 'clsx'; + +import { ChangeDetectionStrategy, Component, computed, inject, input, ViewEncapsulation } from '@angular/core'; + +import { mergeClasses, transform } from '@shared/utils/merge-classes'; +import { ZardResizableComponent } from './resizable.component'; +import { resizableHandleIndicatorVariants, resizableHandleVariants } from './resizable.variants'; + +@Component({ + selector: 'z-resizable-handle', + exportAs: 'zResizableHandle', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` + @if (zWithHandle()) { +
    + } + `, + host: { + '[class]': 'classes()', + '[attr.data-layout]': 'layout()', + '[attr.tabindex]': 'zDisabled() ? null : 0', + '[attr.role]': '"separator"', + '[attr.aria-orientation]': 'layout() === "vertical" ? "horizontal" : "vertical"', + '[attr.aria-disabled]': 'zDisabled()', + '(mousedown)': 'handleMouseDown($event)', + '(touchstart)': 'handleTouchStart($event)', + '(keydown)': 'handleKeyDown($event)', + }, +}) +export class ZardResizableHandleComponent { + private readonly resizable = inject(ZardResizableComponent, { optional: true }); + + readonly zWithHandle = input(false, { transform }); + readonly zDisabled = input(false, { transform }); + readonly zHandleIndex = input(0); + readonly class = input(''); + + protected readonly layout = computed(() => this.resizable?.zLayout() || 'horizontal'); + + protected readonly classes = computed(() => + mergeClasses( + resizableHandleVariants({ + zLayout: this.layout(), + zDisabled: this.zDisabled(), + }), + this.class(), + ), + ); + + protected readonly handleClasses = computed(() => resizableHandleIndicatorVariants({ zLayout: this.layout() })); + + handleMouseDown(event: MouseEvent): void { + if (this.zDisabled() || !this.resizable) return; + this.resizable.startResize(this.zHandleIndex(), event); + } + + handleTouchStart(event: TouchEvent): void { + if (this.zDisabled() || !this.resizable) return; + this.resizable.startResize(this.zHandleIndex(), event); + } + + handleKeyDown(event: KeyboardEvent): void { + if (this.zDisabled() || !this.resizable) return; + + const panels = this.resizable.panels(); + const handleIndex = this.zHandleIndex(); + const layout = this.layout(); + + let delta = 0; + const step = event.shiftKey ? 10 : 1; + + switch (event.key) { + case 'ArrowLeft': + if (layout === 'horizontal') delta = -step; + break; + case 'ArrowRight': + if (layout === 'horizontal') delta = step; + break; + case 'ArrowUp': + if (layout === 'vertical') delta = -step; + break; + case 'ArrowDown': + if (layout === 'vertical') delta = step; + break; + case 'Home': + event.preventDefault(); + this.moveToExtreme(true); + return; + case 'End': + event.preventDefault(); + this.moveToExtreme(false); + return; + case 'Enter': + case ' ': + event.preventDefault(); + if (panels[handleIndex]?.zCollapsible() || panels[handleIndex + 1]?.zCollapsible()) { + const collapsibleIndex = panels[handleIndex]?.zCollapsible() ? handleIndex : handleIndex + 1; + this.resizable.collapsePanel(collapsibleIndex); + } + return; + default: + return; + } + + if (delta !== 0) { + event.preventDefault(); + this.adjustSizes(delta); + } + } + + private adjustSizes(delta: number): void { + if (!this.resizable) return; + + const panels = this.resizable.panels(); + const handleIndex = this.zHandleIndex(); + const sizes = [...this.resizable.panelSizes()]; + + const leftPanel = panels[handleIndex]; + const rightPanel = panels[handleIndex + 1]; + + if (!leftPanel || !rightPanel) return; + + const containerSize = this.resizable.getContainerSize(); + const leftMin = this.resizable.convertToPercentage(leftPanel.zMin() || 0, containerSize); + const leftMax = this.resizable.convertToPercentage(leftPanel.zMax() || 100, containerSize); + const rightMin = this.resizable.convertToPercentage(rightPanel.zMin() || 0, containerSize); + const rightMax = this.resizable.convertToPercentage(rightPanel.zMax() || 100, containerSize); + + let newLeftSize = sizes[handleIndex] + delta; + let newRightSize = sizes[handleIndex + 1] - delta; + + newLeftSize = Math.max(leftMin, Math.min(leftMax, newLeftSize)); + newRightSize = Math.max(rightMin, Math.min(rightMax, newRightSize)); + + const totalSize = newLeftSize + newRightSize; + const originalTotal = sizes[handleIndex] + sizes[handleIndex + 1]; + + if (Math.abs(totalSize - originalTotal) < 0.01) { + sizes[handleIndex] = newLeftSize; + sizes[handleIndex + 1] = newRightSize; + + this.resizable.panelSizes.set(sizes); + this.resizable.updatePanelStyles(); + this.resizable.zResize.emit({ + sizes, + layout: this.resizable.zLayout() || 'horizontal', + }); + } + } + + private moveToExtreme(toMin: boolean): void { + if (!this.resizable) return; + + const panels = this.resizable.panels(); + const handleIndex = this.zHandleIndex(); + const sizes = [...this.resizable.panelSizes()]; + + const leftPanel = panels[handleIndex]; + const rightPanel = panels[handleIndex + 1]; + + if (!leftPanel || !rightPanel) return; + + const containerSize = this.resizable.getContainerSize(); + const leftMin = this.resizable.convertToPercentage(leftPanel.zMin() || 0, containerSize); + const leftMax = this.resizable.convertToPercentage(leftPanel.zMax() || 100, containerSize); + const rightMin = this.resizable.convertToPercentage(rightPanel.zMin() || 0, containerSize); + const rightMax = this.resizable.convertToPercentage(rightPanel.zMax() || 100, containerSize); + + const totalSize = sizes[handleIndex] + sizes[handleIndex + 1]; + + if (toMin) { + sizes[handleIndex] = leftMin; + sizes[handleIndex + 1] = Math.min(totalSize - leftMin, rightMax); + } else { + sizes[handleIndex] = Math.min(totalSize - rightMin, leftMax); + sizes[handleIndex + 1] = rightMin; + } + + this.resizable['panelSizes'].set(sizes); + this.resizable['updatePanelStyles'](); + this.resizable.zResize.emit({ + sizes, + layout: this.resizable.zLayout() || 'horizontal', + }); + } +} \ No newline at end of file diff --git a/src/app/shared/components/resizable/resizable-panel.component.ts b/src/app/shared/components/resizable/resizable-panel.component.ts new file mode 100644 index 0000000..699a3e7 --- /dev/null +++ b/src/app/shared/components/resizable/resizable-panel.component.ts @@ -0,0 +1,38 @@ +import type { ClassValue } from 'clsx'; + +import { ChangeDetectionStrategy, Component, computed, ElementRef, inject, input, ViewEncapsulation } from '@angular/core'; + +import { mergeClasses, transform } from '@shared/utils/merge-classes'; +import { resizablePanelVariants } from './resizable.variants'; + +@Component({ + selector: 'z-resizable-panel', + exportAs: 'zResizablePanel', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ``, + host: { + '[class]': 'classes()', + '[attr.data-collapsed]': 'isCollapsed()', + }, +}) +export class ZardResizablePanelComponent { + readonly elementRef = inject(ElementRef); + + readonly zDefaultSize = input(undefined); + readonly zMin = input(0); + readonly zMax = input(100); + readonly zCollapsible = input(false, { transform }); + readonly zResizable = input(true, { transform }); + readonly class = input(''); + + protected readonly isCollapsed = computed(() => { + const element = this.elementRef.nativeElement as HTMLElement; + const width = parseFloat(element.style.width || '0'); + const height = parseFloat(element.style.height || '0'); + return width === 0 || height === 0; + }); + + protected readonly classes = computed(() => mergeClasses(resizablePanelVariants({ zCollapsed: this.isCollapsed() }), this.class())); +} \ No newline at end of file diff --git a/src/app/shared/components/resizable/resizable.component.ts b/src/app/shared/components/resizable/resizable.component.ts new file mode 100644 index 0000000..ad087e1 --- /dev/null +++ b/src/app/shared/components/resizable/resizable.component.ts @@ -0,0 +1,277 @@ +import type { ClassValue } from 'clsx'; + +import { + AfterContentInit, + ChangeDetectionStrategy, + Component, + computed, + contentChildren, + ElementRef, + EventEmitter, + inject, + input, + OnDestroy, + Output, + PLATFORM_ID, + signal, + ViewEncapsulation, +} from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; + +import { mergeClasses, transform } from '@shared/utils/merge-classes'; +import { ZardResizablePanelComponent } from './resizable-panel.component'; +import { resizableVariants, ZardResizableVariants } from './resizable.variants'; + +export interface ZardResizeEvent { + sizes: number[]; + layout: 'horizontal' | 'vertical'; +} + +@Component({ + selector: 'z-resizable', + exportAs: 'zResizable', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ``, + host: { + '[class]': 'classes()', + '[attr.data-layout]': 'zLayout()', + }, +}) +export class ZardResizableComponent implements AfterContentInit, OnDestroy { + private readonly elementRef = inject(ElementRef); + private readonly platformId = inject(PLATFORM_ID); + private listeners: (() => void)[] = []; + + readonly zLayout = input('horizontal'); + readonly zLazy = input(false, { transform }); + readonly class = input(''); + + @Output() readonly zResizeStart = new EventEmitter(); + @Output() readonly zResize = new EventEmitter(); + @Output() readonly zResizeEnd = new EventEmitter(); + + readonly panels = contentChildren(ZardResizablePanelComponent); + readonly panelSizes = signal([]); + protected readonly isResizing = signal(false); + protected readonly activeHandleIndex = signal(null); + + protected readonly classes = computed(() => mergeClasses(resizableVariants({ zLayout: this.zLayout() }), this.class())); + + ngAfterContentInit(): void { + this.initializePanelSizes(); + } + + convertToPercentage(value: number | string, containerSize: number): number { + if (typeof value === 'number') { + return value; + } + + if (typeof value === 'string') { + if (value.endsWith('%')) { + return parseFloat(value); + } + if (value.endsWith('px')) { + const pixels = parseFloat(value); + return (pixels / containerSize) * 100; + } + } + + return parseFloat(value.toString()) || 0; + } + + private initializePanelSizes(): void { + const panels = this.panels(); + const totalPanels = panels.length; + + if (totalPanels === 0) return; + + const containerSize = this.getContainerSize(); + const sizes = panels.map(panel => { + const defaultSize = panel.zDefaultSize(); + if (defaultSize !== undefined) { + return this.convertToPercentage(defaultSize, containerSize); + } + return 100 / totalPanels; + }); + + this.panelSizes.set(sizes); + this.updatePanelStyles(); + } + + startResize(handleIndex: number, event: MouseEvent | TouchEvent): void { + event.preventDefault(); + this.isResizing.set(true); + this.activeHandleIndex.set(handleIndex); + + const sizes = [...this.panelSizes()]; + this.zResizeStart.emit({ sizes, layout: this.zLayout() || 'horizontal' }); + + const startPosition = this.getEventPosition(event); + const startSizes = [...sizes]; + + const handleMove = (moveEvent: MouseEvent | TouchEvent) => { + this.handleResize(moveEvent, handleIndex, startPosition, startSizes); + }; + + const handleEnd = () => { + this.endResize(); + if (isPlatformBrowser(this.platformId)) { + document.removeEventListener('mousemove', handleMove); + document.removeEventListener('touchmove', handleMove); + document.removeEventListener('mouseup', handleEnd); + document.removeEventListener('touchend', handleEnd); + } + }; + + if (isPlatformBrowser(this.platformId)) { + document.addEventListener('mousemove', handleMove); + document.addEventListener('touchmove', handleMove); + document.addEventListener('mouseup', handleEnd); + document.addEventListener('touchend', handleEnd); + + this.listeners.push(() => { + document.removeEventListener('mousemove', handleMove); + document.removeEventListener('touchmove', handleMove); + document.removeEventListener('mouseup', handleEnd); + document.removeEventListener('touchend', handleEnd); + }); + } + } + + private handleResize(event: MouseEvent | TouchEvent, handleIndex: number, startPosition: number, startSizes: number[]): void { + const currentPosition = this.getEventPosition(event); + const delta = currentPosition - startPosition; + const containerSize = this.getContainerSize(); + const deltaPercentage = (delta / containerSize) * 100; + + const newSizes = [...startSizes]; + const panels = this.panels(); + + const leftPanel = panels[handleIndex]; + const rightPanel = panels[handleIndex + 1]; + + if (!leftPanel || !rightPanel) return; + + const leftMin = this.convertToPercentage(leftPanel.zMin() || 0, containerSize); + const leftMax = this.convertToPercentage(leftPanel.zMax() || 100, containerSize); + const rightMin = this.convertToPercentage(rightPanel.zMin() || 0, containerSize); + const rightMax = this.convertToPercentage(rightPanel.zMax() || 100, containerSize); + + let newLeftSize = startSizes[handleIndex] + deltaPercentage; + let newRightSize = startSizes[handleIndex + 1] - deltaPercentage; + + newLeftSize = Math.max(leftMin, Math.min(leftMax, newLeftSize)); + newRightSize = Math.max(rightMin, Math.min(rightMax, newRightSize)); + + const totalSize = newLeftSize + newRightSize; + const originalTotal = startSizes[handleIndex] + startSizes[handleIndex + 1]; + + if (Math.abs(totalSize - originalTotal) < 0.01) { + newSizes[handleIndex] = newLeftSize; + newSizes[handleIndex + 1] = newRightSize; + + this.panelSizes.set(newSizes); + + if (!this.zLazy()) { + this.updatePanelStyles(); + } + + this.zResize.emit({ sizes: newSizes, layout: this.zLayout() || 'horizontal' }); + } + } + + private endResize(): void { + this.isResizing.set(false); + this.activeHandleIndex.set(null); + + if (this.zLazy()) { + this.updatePanelStyles(); + } + + const sizes = [...this.panelSizes()]; + this.zResizeEnd.emit({ sizes, layout: this.zLayout() || 'horizontal' }); + } + + updatePanelStyles(): void { + const panels = this.panels(); + const sizes = this.panelSizes(); + const layout = this.zLayout(); + + panels.forEach((panel, index) => { + const size = sizes[index]; + if (size !== undefined) { + const element = panel.elementRef.nativeElement as HTMLElement; + if (layout === 'vertical') { + element.style.height = `${size}%`; + element.style.width = '100%'; + } else { + element.style.width = `${size}%`; + element.style.height = '100%'; + } + } + }); + } + + private getEventPosition(event: MouseEvent | TouchEvent): number { + const layout = this.zLayout(); + if (event instanceof MouseEvent) { + return layout === 'vertical' ? event.clientY : event.clientX; + } else { + const touch = event.touches[0]; + return layout === 'vertical' ? touch.clientY : touch.clientX; + } + } + + getContainerSize(): number { + const element = this.elementRef.nativeElement as HTMLElement; + const layout = this.zLayout(); + return layout === 'vertical' ? element.offsetHeight : element.offsetWidth; + } + + collapsePanel(index: number): void { + const panels = this.panels(); + const panel = panels[index]; + + if (!panel || !panel.zCollapsible()) return; + + const sizes = [...this.panelSizes()]; + const isCollapsed = sizes[index] === 0; + + if (isCollapsed) { + const containerSize = this.getContainerSize(); + const defaultSize = this.convertToPercentage(panel.zDefaultSize() || 100 / panels.length, containerSize); + sizes[index] = defaultSize; + + const totalOthers = sizes.reduce((sum, size, i) => (i !== index ? sum + size : sum), 0); + const scale = (100 - defaultSize) / totalOthers; + + sizes.forEach((size, i) => { + if (i !== index) { + sizes[i] = size * scale; + } + }); + } else { + const collapsedSize = sizes[index]; + sizes[index] = 0; + + const totalOthers = sizes.reduce((sum, size, i) => (i !== index ? sum + size : sum), 0); + const scale = (totalOthers + collapsedSize) / totalOthers; + + sizes.forEach((size, i) => { + if (i !== index) { + sizes[i] = size * scale; + } + }); + } + + this.panelSizes.set(sizes); + this.updatePanelStyles(); + this.zResize.emit({ sizes, layout: this.zLayout() || 'horizontal' }); + } + + ngOnDestroy(): void { + this.listeners.forEach(cleanup => cleanup()); + } +} \ No newline at end of file diff --git a/src/app/shared/components/resizable/resizable.variants.ts b/src/app/shared/components/resizable/resizable.variants.ts new file mode 100644 index 0000000..d02e115 --- /dev/null +++ b/src/app/shared/components/resizable/resizable.variants.ts @@ -0,0 +1,61 @@ +import { cva, VariantProps } from 'class-variance-authority'; + +export const resizableVariants = cva('flex h-full w-full data-[layout=vertical]:flex-col overflow-hidden', { + variants: { + zLayout: { + horizontal: '', + vertical: '', + }, + }, + defaultVariants: { + zLayout: 'horizontal', + }, +}); + +export const resizablePanelVariants = cva('relative overflow-hidden flex-shrink-0 h-full', { + variants: { + zCollapsed: { + true: 'hidden', + false: '', + }, + }, + defaultVariants: { + zCollapsed: false, + }, +}); + +export const resizableHandleVariants = cva( + 'group relative flex flex-shrink-0 items-center justify-center bg-border transition-colors hover:bg-border/80 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1', + { + variants: { + zLayout: { + horizontal: 'w-[1px] min-w-[1px] cursor-col-resize after:absolute after:inset-y-0 after:left-1/2 after:w-4 after:-translate-x-1/2', + vertical: 'h-[1px] min-h-[1px] w-full cursor-row-resize after:absolute after:inset-x-0 after:top-1/2 after:h-4 after:-translate-y-1/2', + }, + zDisabled: { + true: 'cursor-default pointer-events-none opacity-50', + false: '', + }, + }, + defaultVariants: { + zLayout: 'horizontal', + zDisabled: false, + }, + }, +); + +export const resizableHandleIndicatorVariants = cva('absolute z-10 bg-muted-foreground/30 transition-colors group-hover:bg-muted-foreground/50 rounded-full', { + variants: { + zLayout: { + vertical: 'w-8 h-px', + horizontal: 'w-px h-8', + }, + }, + defaultVariants: { + zLayout: 'horizontal', + }, +}); + +export type ZardResizableVariants = VariantProps; +export type ZardResizablePanelVariants = VariantProps; +export type ZardResizableHandleVariants = VariantProps; \ No newline at end of file diff --git a/src/app/shared/components/search-bar/search-bar.css b/src/app/shared/components/search-bar/search-bar.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/shared/components/search-bar/search-bar.html b/src/app/shared/components/search-bar/search-bar.html new file mode 100644 index 0000000..af493bd --- /dev/null +++ b/src/app/shared/components/search-bar/search-bar.html @@ -0,0 +1,52 @@ +
    +
    + + + + + + + + + + @if (searchTerm().length > 0) { + + } +
    +
    diff --git a/src/app/shared/components/search-bar/search-bar.spec.ts b/src/app/shared/components/search-bar/search-bar.spec.ts new file mode 100644 index 0000000..72f6a71 --- /dev/null +++ b/src/app/shared/components/search-bar/search-bar.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SearchBar } from './search-bar'; + +describe('SearchBar', () => { + let component: SearchBar; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SearchBar] + }) + .compileComponents(); + + fixture = TestBed.createComponent(SearchBar); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/components/search-bar/search-bar.ts b/src/app/shared/components/search-bar/search-bar.ts new file mode 100644 index 0000000..9f4ebe8 --- /dev/null +++ b/src/app/shared/components/search-bar/search-bar.ts @@ -0,0 +1,62 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + input, + output, + signal, + OnDestroy, +} from '@angular/core'; + +@Component({ + selector: 'app-search-bar', + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './search-bar.html', + styleUrl: './search-bar.css', +}) +export class SearchBar implements OnDestroy { + placeholder = input('Rechercher…'); + debounceMs = input(300); // Default 300ms debounce + + // Local signal to hold the current query string in the input field + searchTerm = signal(''); + + // Output event to notify parent component of the search query + search = output(); + + private debounceTimer: ReturnType | null = null; + + // Handles input change, updates the signal, and emits the new search term with debounce + handleInput(value: string) { + this.searchTerm.set(value); + this.debounceEmit(); + } + + private debounceEmit() { + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + } + this.debounceTimer = setTimeout(() => { + this.emitSearch(); + }, this.debounceMs()); + } + + emitSearch() { + this.search.emit(this.searchTerm().trim()); + } + + reset() { + this.searchTerm.set(''); + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + } + this.emitSearch(); + } + + ngOnDestroy() { + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + } + } +} diff --git a/src/app/shared/components/segmented/segmented.component.ts b/src/app/shared/components/segmented/segmented.component.ts new file mode 100644 index 0000000..12adf6a --- /dev/null +++ b/src/app/shared/components/segmented/segmented.component.ts @@ -0,0 +1,156 @@ +import { ChangeDetectionStrategy, Component, computed, contentChildren, effect, forwardRef, input, OnInit, output, signal, ViewEncapsulation } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; + +import { mergeClasses } from '@shared/utils/merge-classes'; +import { segmentedItemVariants, segmentedVariants, ZardSegmentedVariants } from './segmented.variants'; + +import type { ClassValue } from 'clsx'; + +export interface SegmentedOption { + value: string; + label: string; + disabled?: boolean; +} +@Component({ + selector: 'z-segmented-item', + standalone: true, + template: ``, + encapsulation: ViewEncapsulation.None, +}) +export class ZardSegmentedItemComponent { + readonly value = input.required(); + readonly label = input.required(); + readonly disabled = input(false); +} + +@Component({ + selector: 'z-segmented', + exportAs: 'zSegmented', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
    + @if (zOptions().length > 0) { + @for (option of zOptions(); track option.value) { + + } + } @else { + @for (item of items(); track item.value()) { + + } + } +
    + `, + host: { + '[class]': 'wrapperClasses()', + }, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ZardSegmentedComponent), + multi: true, + }, + ], +}) +export class ZardSegmentedComponent implements ControlValueAccessor, OnInit { + private readonly itemComponents = contentChildren(ZardSegmentedItemComponent); + + readonly class = input(''); + readonly zSize = input('default'); + readonly zOptions = input([]); + readonly zDefaultValue = input(''); + readonly zDisabled = input(false); + readonly zAriaLabel = input('Segmented control'); + + readonly zChange = output(); + + protected readonly selectedValue = signal(''); + protected readonly items = signal([]); + + // eslint-disable-next-line @typescript-eslint/no-empty-function + private onChange: (value: string) => void = () => {}; + // eslint-disable-next-line @typescript-eslint/no-empty-function + private onTouched = () => {}; + + constructor() { + effect(() => { + this.items.set(this.itemComponents()); + }); + } + + ngOnInit() { + // Initialize with default value + if (this.zDefaultValue()) { + this.selectedValue.set(this.zDefaultValue()); + } + } + + protected readonly classes = computed(() => mergeClasses(segmentedVariants({ zSize: this.zSize() }), this.class())); + + protected readonly wrapperClasses = computed(() => 'inline-block'); + + protected getItemClasses(value: string): string { + return segmentedItemVariants({ + zSize: this.zSize(), + isActive: this.isSelected(value), + }); + } + + protected isSelected(value: string): boolean { + return this.selectedValue() === value; + } + + protected selectOption(value: string) { + if (this.zDisabled()) return; + + const option = this.zOptions().find(opt => opt.value === value); + const item = this.items().find(item => item.value() === value); + + if ((option && option.disabled) || (item && item.disabled())) return; + + this.selectedValue.set(value); + this.onChange(value); + this.onTouched(); + this.zChange.emit(value); + } + + // ControlValueAccessor implementation + writeValue(value: string): void { + this.selectedValue.set(value || ''); + } + + registerOnChange(fn: (value: string) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState(_isDisabled: boolean): void { + // Handled by zDisabled input + } +} \ No newline at end of file diff --git a/src/app/shared/components/segmented/segmented.variants.ts b/src/app/shared/components/segmented/segmented.variants.ts new file mode 100644 index 0000000..b3bc0db --- /dev/null +++ b/src/app/shared/components/segmented/segmented.variants.ts @@ -0,0 +1,38 @@ +import { cva, VariantProps } from 'class-variance-authority'; + +export const segmentedVariants = cva('inline-flex items-center justify-center rounded-md bg-muted p-1 text-muted-foreground', { + variants: { + zSize: { + sm: 'h-9 text-xs', + default: 'h-10 text-sm', + lg: 'h-12 text-base', + }, + }, + defaultVariants: { + zSize: 'default', + }, +}); + +export const segmentedItemVariants = cva( + 'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', + { + variants: { + zSize: { + sm: 'px-2 py-1 text-xs', + default: 'px-3 py-1.5 text-sm', + lg: 'px-4 py-2 text-base', + }, + isActive: { + true: 'bg-background text-foreground shadow-sm', + false: 'hover:bg-muted/50', + }, + }, + defaultVariants: { + zSize: 'default', + isActive: false, + }, + }, +); + +export type ZardSegmentedVariants = VariantProps; +export type ZardSegmentedItemVariants = VariantProps; \ No newline at end of file diff --git a/src/app/shared/components/select/select-item.component.ts b/src/app/shared/components/select/select-item.component.ts new file mode 100644 index 0000000..27bc5a9 --- /dev/null +++ b/src/app/shared/components/select/select-item.component.ts @@ -0,0 +1,71 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + ElementRef, + forwardRef, + inject, + input, + linkedSignal, +} from '@angular/core'; + +import { mergeClasses, transform } from '@shared/utils/merge-classes'; +import { selectItemVariants } from './select.variants'; + +// Interface to avoid circular dependency +interface SelectHost { + selectedValue(): string; + selectItem(value: string, label: string): void; +} + +@Component({ + selector: 'z-select-item, [z-select-item]', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [], + host: { + '[class]': 'classes()', + '[attr.value]': 'zValue()', + role: 'option', + tabindex: '-1', + '[attr.data-disabled]': 'zDisabled() ? "" : null', + '[attr.data-selected]': 'isSelected() ? "" : null', + '[attr.aria-selected]': 'isSelected()', + '(click)': 'onClick()', + }, + template: ` + + @if (isSelected()) { + + } + + + `, +}) +export class ZardSelectItemComponent { + readonly zValue = input.required(); + readonly zDisabled = input(false, { transform }); + readonly class = input(''); + + private select: SelectHost | null = null; + readonly elementRef = inject(ElementRef); + readonly label = linkedSignal(() => { + const element = this.elementRef?.nativeElement; + return (element?.textContent || element?.innerText)?.trim() ?? ''; + }); + + protected readonly classes = computed(() => mergeClasses(selectItemVariants(), this.class())); + + protected readonly isSelected = computed(() => this.select?.selectedValue() === this.zValue()); + + setSelectHost(selectHost: SelectHost) { + this.select = selectHost; + } + + onClick() { + if (this.zDisabled() || !this.select) { + return; + } + this.select.selectItem(this.zValue(), this.label()); + } +} diff --git a/src/app/shared/components/select/select.component.ts b/src/app/shared/components/select/select.component.ts new file mode 100644 index 0000000..e2d11cf --- /dev/null +++ b/src/app/shared/components/select/select.component.ts @@ -0,0 +1,478 @@ +import { + AfterContentInit, + ChangeDetectionStrategy, + Component, + computed, + contentChildren, + ElementRef, + forwardRef, + HostListener, + inject, + input, + linkedSignal, + OnDestroy, + OnInit, + output, + PLATFORM_ID, + signal, + TemplateRef, + viewChild, + ViewContainerRef, +} from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; +import { Overlay, OverlayModule, OverlayPositionBuilder, OverlayRef } from '@angular/cdk/overlay'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { TemplatePortal } from '@angular/cdk/portal'; + +import { + selectContentVariants, + selectTriggerVariants, + ZardSelectTriggerVariants, +} from './select.variants'; +import { mergeClasses, transform } from '@shared/utils/merge-classes'; +import { ZardSelectItemComponent } from './select-item.component'; + +type OnTouchedType = () => void; +type OnChangeType = (value: string) => void; + +@Component({ + selector: 'z-select, [z-select]', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [OverlayModule], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ZardSelectComponent), + multi: true, + }, + ], + host: { + '[attr.data-disabled]': 'zDisabled() ? "" : null', + '[attr.data-state]': 'isOpen() ? "open" : "closed"', + class: 'relative inline-block w-full', + }, + template: ` + + + +
    +
    + +
    +
    +
    + `, +}) +export class ZardSelectComponent + implements ControlValueAccessor, OnInit, AfterContentInit, OnDestroy +{ + private elementRef = inject(ElementRef); + private overlay = inject(Overlay); + private overlayPositionBuilder = inject(OverlayPositionBuilder); + private viewContainerRef = inject(ViewContainerRef); + private platformId = inject(PLATFORM_ID); + + readonly dropdownTemplate = viewChild.required>('dropdownTemplate'); + + readonly selectItems = contentChildren(ZardSelectItemComponent); + + private overlayRef?: OverlayRef; + private portal?: TemplatePortal; + + readonly zSize = input('default'); + readonly zDisabled = input(false, { transform }); + readonly zPlaceholder = input('Select an option...'); + readonly zValue = input(''); + readonly zLabel = input(''); + readonly class = input(''); + + readonly zSelectionChange = output(); + + readonly isOpen = signal(false); + private readonly _selectedValue = signal(''); + private readonly _selectedLabel = linkedSignal(() => { + const currentValue = this.selectedValue(); + if (!this.zLabel() && currentValue) { + const matchingItem = this.selectItems()?.find((item) => item.zValue() === currentValue); + if (matchingItem) { + return matchingItem.label(); + } + } + return ''; + }); + readonly focusedIndex = signal(-1); + + // Use computed to derive the effective selected value from input or internal state + readonly selectedValue = computed(() => this.zValue() || this._selectedValue()); + + // Compute the label based on selected value + readonly selectedLabel = computed(() => { + const manualLabel = this.zLabel(); + if (manualLabel) return manualLabel; + + return this._selectedLabel() || this.selectedValue(); + }); + + private onChange: OnChangeType = (_value: string) => { + // ControlValueAccessor onChange callback + }; + + private onTouched: OnTouchedType = () => { + // ControlValueAccessor onTouched callback + }; + + protected readonly triggerClasses = computed(() => + mergeClasses( + selectTriggerVariants({ + zSize: this.zSize(), + }), + this.class() + ) + ); + + protected readonly contentClasses = computed(() => mergeClasses(selectContentVariants())); + + ngOnInit() { + // Initialize selected value from input immediately + const inputValue = this.zValue(); + if (inputValue) { + this._selectedValue.set(inputValue); + } + } + + ngAfterContentInit() { + // Setup select host reference for each item + // Note: This might not catch all items if they're rendered dynamically in the overlay + // So we also call setupSelectItems() when opening the overlay + this.setupSelectItems(); + } + + ngOnDestroy() { + this.destroyOverlay(); + } + + @HostListener('document:click', ['$event']) + onDocumentClick(event: Event) { + if (!this.elementRef.nativeElement.contains(event.target as Node)) { + this.close(); + } + } + + onTriggerKeydown(event: KeyboardEvent) { + switch (event.key) { + case 'Enter': + case ' ': + case 'ArrowDown': + case 'ArrowUp': + event.preventDefault(); + if (!this.isOpen()) { + this.open(); + } + break; + case 'Escape': + if (this.isOpen()) { + event.preventDefault(); + this.close(); + } + break; + } + } + + onDropdownKeydown(event: KeyboardEvent) { + const items = this.getSelectItems(); + + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + this.navigateItems(1, items); + break; + case 'ArrowUp': + event.preventDefault(); + this.navigateItems(-1, items); + break; + case 'Enter': + case ' ': + event.preventDefault(); + this.selectFocusedItem(items); + break; + case 'Escape': + event.preventDefault(); + this.close(); + this.focusButton(); + break; + case 'Home': + event.preventDefault(); + this.focusFirstItem(items); + break; + case 'End': + event.preventDefault(); + this.focusLastItem(items); + break; + } + } + + toggle() { + if (this.zDisabled()) return; + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + this.isOpen() ? this.close() : this.open(); + } + + open() { + if (this.isOpen()) return; + + // Create overlay if it doesn't exist + if (!this.overlayRef) { + this.createOverlay(); + } + + if (!this.overlayRef) return; + + this.portal = new TemplatePortal(this.dropdownTemplate(), this.viewContainerRef); + this.overlayRef.attach(this.portal); + this.isOpen.set(true); + + // Focus dropdown after opening and position on selected item + // Also setup select host reference for items (they might be new instances in the overlay) + setTimeout(() => { + this.setupSelectItems(); + this.focusDropdown(); + this.focusSelectedItem(); + }, 0); + } + + private setupSelectItems() { + // Setup select host reference for each item + // This needs to be called when the overlay opens because items are projected into the overlay + this.selectItems().forEach((item) => { + item.setSelectHost({ + selectedValue: () => this.selectedValue(), + selectItem: (value: string, label: string) => this.selectItem(value, label), + }); + }); + } + + close() { + if (this.overlayRef?.hasAttached()) { + this.overlayRef.detach(); + } + this.isOpen.set(false); + this.focusedIndex.set(-1); + this.onTouched(); + } + + selectItem(value: string, label: string) { + if (value === undefined || value === null || value === '') { + console.warn('Attempted to select item with invalid value:', { value, label }); + return; + } + + this._selectedValue.set(value); + this._selectedLabel.set(label || value); // Fallback to value if label is empty + this.onChange(value); + this.zSelectionChange.emit(value); + this.close(); + + // Return focus to the button after selection + setTimeout(() => { + this.focusButton(); + }, 0); + } + + private createOverlay() { + if (this.overlayRef) return; // Already created + + if (isPlatformBrowser(this.platformId)) { + try { + const positionStrategy = this.overlayPositionBuilder + .flexibleConnectedTo(this.elementRef) + .withPositions([ + { + originX: 'start', + originY: 'bottom', + overlayX: 'start', + overlayY: 'top', + offsetY: 4, + }, + { + originX: 'start', + originY: 'top', + overlayX: 'start', + overlayY: 'bottom', + offsetY: -4, + }, + ]) + .withPush(false); + + const elementWidth = this.elementRef.nativeElement.offsetWidth || 200; + + this.overlayRef = this.overlay.create({ + positionStrategy, + hasBackdrop: false, + scrollStrategy: this.overlay.scrollStrategies.reposition(), + width: elementWidth, + minWidth: elementWidth, + maxHeight: 384, // max-h-96 equivalent + }); + } catch (error) { + console.error('Error creating overlay:', error); + } + } + } + + private destroyOverlay() { + if (this.overlayRef) { + this.overlayRef.dispose(); + this.overlayRef = undefined; + } + } + + private getSelectItems(): HTMLElement[] { + if (!this.overlayRef?.hasAttached()) return []; + const dropdownElement = this.overlayRef.overlayElement; + return Array.from(dropdownElement.querySelectorAll('z-select-item, [z-select-item]')).filter( + (item: Element) => !item.hasAttribute('data-disabled') + ) as HTMLElement[]; + } + + private navigateItems(direction: number, items: HTMLElement[]) { + if (items.length === 0) return; + + const currentIndex = this.focusedIndex(); + let nextIndex = currentIndex + direction; + + if (nextIndex < 0) { + nextIndex = items.length - 1; + } else if (nextIndex >= items.length) { + nextIndex = 0; + } + + this.focusedIndex.set(nextIndex); + this.updateItemFocus(items, nextIndex); + } + + private selectFocusedItem(items: HTMLElement[]) { + const currentIndex = this.focusedIndex(); + if (currentIndex >= 0 && currentIndex < items.length) { + const item = items[currentIndex]; + const value = item.getAttribute('value'); + const label = item.textContent?.trim() || ''; + + if (value === null || value === undefined) { + console.warn('No value attribute found on selected item:', item); + return; + } + + this.selectItem(value, label); + } + } + + private focusFirstItem(items: HTMLElement[]) { + if (items.length > 0) { + this.focusedIndex.set(0); + this.updateItemFocus(items, 0); + } + } + + private focusLastItem(items: HTMLElement[]) { + if (items.length > 0) { + const lastIndex = items.length - 1; + this.focusedIndex.set(lastIndex); + this.updateItemFocus(items, lastIndex); + } + } + + private updateItemFocus(items: HTMLElement[], focusedIndex: number) { + items.forEach((item, index) => { + if (index === focusedIndex) { + item.focus(); + item.setAttribute('aria-selected', 'true'); + } else { + item.removeAttribute('aria-selected'); + } + }); + } + + private focusDropdown() { + if (this.overlayRef?.hasAttached()) { + const dropdownElement = this.overlayRef.overlayElement.querySelector( + '[role="listbox"]' + ) as HTMLElement; + if (dropdownElement) { + dropdownElement.focus(); + } + } + } + + private focusButton() { + const button = this.elementRef.nativeElement.querySelector('button'); + if (button) { + button.focus(); + } + } + + private focusSelectedItem() { + const items = this.getSelectItems(); + if (items.length === 0) return; + + // Find the index of the currently selected item + const selectedValue = this.selectedValue(); + let selectedIndex = -1; + + if (selectedValue) { + selectedIndex = items.findIndex((item) => item.getAttribute('value') === selectedValue); + } + + // If no item is selected, focus the first item + if (selectedIndex === -1) { + selectedIndex = 0; + } + + this.focusedIndex.set(selectedIndex); + this.updateItemFocus(items, selectedIndex); + } + + // ControlValueAccessor implementation + writeValue(value: string | null): void { + // Convert null/undefined to empty string, but preserve empty string + const stringValue = value != null ? String(value) : ''; + this._selectedValue.set(stringValue); + } + + registerOnChange(fn: (value: string) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState(): void { + // The disabled state is handled by the disabled input + } +} diff --git a/src/app/shared/components/select/select.variants.ts b/src/app/shared/components/select/select.variants.ts new file mode 100644 index 0000000..2ab5c15 --- /dev/null +++ b/src/app/shared/components/select/select.variants.ts @@ -0,0 +1,29 @@ +import { cva, VariantProps } from 'class-variance-authority'; + +export const selectTriggerVariants = cva( + 'flex w-full items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none cursor-pointer focus-visible:ring-[3px] focus-visible:border-ring focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground [&_svg:not([class*="text-"])]:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*="size-"])]:size-4', + { + variants: { + zSize: { + sm: 'h-8 text-xs', + default: 'h-9 text-sm', + lg: 'h-10 text-base', + }, + }, + defaultVariants: { + zSize: 'default', + }, + }, +); + +export const selectContentVariants = cva( + 'z-[9999] max-h-96 min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-lg animate-in fade-in-0 zoom-in-95', +); + +export const selectItemVariants = cva( + 'relative flex w-full cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed data-[disabled]:hover:bg-transparent data-[disabled]:hover:text-current [&_svg:not([class*="text-"])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*="size-"])]:size-4', +); + +export type ZardSelectTriggerVariants = VariantProps; +export type ZardSelectContentVariants = VariantProps; +export type ZardSelectItemVariants = VariantProps; diff --git a/src/app/shared/components/sheet/sheet-ref.ts b/src/app/shared/components/sheet/sheet-ref.ts new file mode 100644 index 0000000..b11f21b --- /dev/null +++ b/src/app/shared/components/sheet/sheet-ref.ts @@ -0,0 +1,97 @@ +import { filter, fromEvent, Subject, takeUntil } from 'rxjs'; + +import { OverlayRef } from '@angular/cdk/overlay'; +import { isPlatformBrowser } from '@angular/common'; +import { EventEmitter, Inject, PLATFORM_ID } from '@angular/core'; + +import { ZardSheetComponent, ZardSheetOptions } from './sheet.component'; + +const enum eTriggerAction { + CANCEL = 'cancel', + OK = 'ok', +} + +export class ZardSheetRef { + private destroy$ = new Subject(); + private isClosing = false; + protected result?: R; + componentInstance: T | null = null; + + constructor( + private overlayRef: OverlayRef, + private config: ZardSheetOptions, + private containerInstance: ZardSheetComponent, + @Inject(PLATFORM_ID) private platformId: object, + ) { + this.containerInstance.cancelTriggered.subscribe(() => this.trigger(eTriggerAction.CANCEL)); + this.containerInstance.okTriggered.subscribe(() => this.trigger(eTriggerAction.OK)); + + if ((this.config.zMaskClosable ?? true) && isPlatformBrowser(this.platformId)) { + this.overlayRef + .outsidePointerEvents() + .pipe(takeUntil(this.destroy$)) + .subscribe(() => this.close()); + } + + if (isPlatformBrowser(this.platformId)) { + fromEvent(document, 'keydown') + .pipe( + filter(event => event.key === 'Escape'), + takeUntil(this.destroy$), + ) + .subscribe(() => this.close()); + } + } + + close(result?: R) { + if (this.isClosing) { + return; + } + + this.isClosing = true; + this.result = result; + this.containerInstance.state.set('closed'); + + const element = this.containerInstance.getNativeElement(); + const onAnimationEnd = () => { + element.removeEventListener('animationend', onAnimationEnd); + + if (this.overlayRef) { + if (this.overlayRef.hasAttached()) { + this.overlayRef.detachBackdrop(); + } + this.overlayRef.dispose(); + } + + if (!this.destroy$.closed) { + this.destroy$.next(); + this.destroy$.complete(); + } + }; + + element.addEventListener('animationend', onAnimationEnd); + + setTimeout(() => { + onAnimationEnd(); + }, 300); + } + + private trigger(action: eTriggerAction) { + const trigger = { ok: this.config.zOnOk, cancel: this.config.zOnCancel }[action]; + + if (trigger instanceof EventEmitter) { + trigger.emit(this.getContentComponent()); + } else if (typeof trigger === 'function') { + const result = trigger(this.getContentComponent()) as R; + this.closeWithResult(result); + } else this.close(); + } + + private getContentComponent(): T { + return this.componentInstance as T; + } + + private closeWithResult(result: R): void { + if (result !== false) this.close(result); + } +} diff --git a/src/app/shared/components/sheet/sheet.component.ts b/src/app/shared/components/sheet/sheet.component.ts new file mode 100644 index 0000000..929da2e --- /dev/null +++ b/src/app/shared/components/sheet/sheet.component.ts @@ -0,0 +1,177 @@ +import { OverlayModule } from '@angular/cdk/overlay'; +import { BasePortalOutlet, CdkPortalOutlet, ComponentPortal, PortalModule, TemplatePortal } from '@angular/cdk/portal'; +import { + ChangeDetectionStrategy, + Component, + ComponentRef, + computed, + ElementRef, + EmbeddedViewRef, + EventEmitter, + inject, + output, + signal, + TemplateRef, + Type, + viewChild, + ViewContainerRef, +} from '@angular/core'; + +import { mergeClasses } from '@shared/utils/merge-classes'; +import { ZardButtonComponent } from '../button/button.component'; +import { ZardSheetRef } from './sheet-ref'; +import { sheetVariants, ZardSheetVariants } from './sheet.variants'; + +const noopFun = () => void 0; +export type OnClickCallback = (instance: T) => false | void | object; +export class ZardSheetOptions { + zCancelIcon?: string; + zCancelText?: string | null; + zClosable?: boolean; + zContent?: string | TemplateRef | Type; + zCustomClasses?: string; + zData?: U; + zDescription?: string; + zHeight?: string; + zHideFooter?: boolean; + zMaskClosable?: boolean; + zOkDestructive?: boolean; + zOkDisabled?: boolean; + zOkIcon?: string; + zOkText?: string | null; + zOnCancel?: EventEmitter | OnClickCallback = noopFun; + zOnOk?: EventEmitter | OnClickCallback = noopFun; + zSide?: ZardSheetVariants['zSide'] = 'left'; + zSize?: ZardSheetVariants['zSize'] = 'default'; + zTitle?: string | TemplateRef; + zViewContainerRef?: ViewContainerRef; + zWidth?: string; +} + +@Component({ + selector: 'z-sheet', + exportAs: 'zSheet', + imports: [OverlayModule, PortalModule, ZardButtonComponent], + template: ` + @if (config.zClosable || config.zClosable === undefined) { + + } + + @if (config.zTitle || config.zDescription) { +
    + @if (config.zTitle) { +

    {{ config.zTitle }}

    + + @if (config.zDescription) { +

    {{ config.zDescription }}

    + } + } +
    + } + +
    + + + @if (isStringContent) { +
    + } +
    + + @if (!config.zHideFooter) { +
    + @if (config.zOkText !== null) { + + } + + @if (config.zCancelText !== null) { + + } +
    + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + 'data-slot': 'sheet', + '[class]': 'classes()', + '[attr.data-state]': 'state()', + '[style.width]': 'config.zWidth ? config.zWidth + " !important" : null', + '[style.height]': 'config.zHeight ? config.zHeight + " !important" : null', + }, +}) +export class ZardSheetComponent extends BasePortalOutlet { + private readonly host = inject(ElementRef); + protected readonly config = inject(ZardSheetOptions); + + protected readonly classes = computed(() => { + const zSize = this.config.zWidth || this.config.zHeight ? 'custom' : this.config.zSize; + + return mergeClasses( + sheetVariants({ + zSide: this.config.zSide, + zSize, + }), + this.config.zCustomClasses, + ); + }); + public sheetRef?: ZardSheetRef; + + protected readonly isStringContent = typeof this.config.zContent === 'string'; + + readonly portalOutlet = viewChild.required(CdkPortalOutlet); + + readonly okTriggered = output(); + readonly cancelTriggered = output(); + readonly state = signal<'closed' | 'open'>('closed'); + + constructor() { + super(); + } + + getNativeElement(): HTMLElement { + return this.host.nativeElement; + } + + attachComponentPortal(portal: ComponentPortal): ComponentRef { + if (this.portalOutlet()?.hasAttached()) { + throw Error('Attempting to attach modal content after content is already attached'); + } + return this.portalOutlet()?.attachComponentPortal(portal); + } + + attachTemplatePortal(portal: TemplatePortal): EmbeddedViewRef { + if (this.portalOutlet()?.hasAttached()) { + throw Error('Attempting to attach modal content after content is already attached'); + } + + return this.portalOutlet()?.attachTemplatePortal(portal); + } + + onOkClick() { + this.okTriggered.emit(); + } + + onCloseClick() { + this.cancelTriggered.emit(); + } +} diff --git a/src/app/shared/components/sheet/sheet.module.ts b/src/app/shared/components/sheet/sheet.module.ts new file mode 100644 index 0000000..4f5c003 --- /dev/null +++ b/src/app/shared/components/sheet/sheet.module.ts @@ -0,0 +1,22 @@ +import { OverlayModule } from '@angular/cdk/overlay'; +import { PortalModule } from '@angular/cdk/portal'; +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; + +import { ZardButtonComponent } from '../button/button.component'; +import { ZardSheetComponent } from './sheet.component'; +import { ZardSheetService } from './sheet.service'; + +const components = [CommonModule, ZardButtonComponent, ZardSheetComponent, OverlayModule, PortalModule]; + +@NgModule({ + imports: components, + exports: components, +}) +export class ZardBreadcrumbModule {} + +@NgModule({ + imports: [CommonModule, ZardButtonComponent, ZardSheetComponent, OverlayModule, PortalModule], + providers: [ZardSheetService], +}) +export class ZardSheetModule {} diff --git a/src/app/shared/components/sheet/sheet.service.ts b/src/app/shared/components/sheet/sheet.service.ts new file mode 100644 index 0000000..b42275f --- /dev/null +++ b/src/app/shared/components/sheet/sheet.service.ts @@ -0,0 +1,96 @@ +import { inject, Injectable, InjectionToken, Injector, PLATFORM_ID, TemplateRef } from '@angular/core'; +import { ComponentType, Overlay, OverlayConfig, OverlayRef } from '@angular/cdk/overlay'; +import { ComponentPortal, TemplatePortal } from '@angular/cdk/portal'; +import { isPlatformBrowser } from '@angular/common'; + +import { ZardSheetComponent, ZardSheetOptions } from './sheet.component'; +import { ZardSheetRef } from './sheet-ref'; + +type ContentType = ComponentType | TemplateRef | string; +export const Z_MODAL_DATA = new InjectionToken('Z_MODAL_DATA'); + +@Injectable({ + providedIn: 'root', +}) +export class ZardSheetService { + private overlay = inject(Overlay); + private injector = inject(Injector); + private platformId = inject(PLATFORM_ID); + + create(config: ZardSheetOptions): ZardSheetRef { + return this.open(config.zContent as ComponentType, config); + } + + private open(componentOrTemplateRef: ContentType, config: ZardSheetOptions) { + const overlayRef = this.createOverlay(); + + if (!overlayRef) { + // Return a mock sheet ref for SSR environments + return new ZardSheetRef(undefined as any, config, undefined as any, this.platformId); + } + + const sheetContainer = this.attachSheetContainer(overlayRef, config); + + const sheetRef = this.attachSheetContent(componentOrTemplateRef, sheetContainer, overlayRef, config); + sheetContainer.sheetRef = sheetRef; + + return sheetRef; + } + + private createOverlay(): OverlayRef | undefined { + if (isPlatformBrowser(this.platformId)) { + const overlayConfig = new OverlayConfig({ + hasBackdrop: true, + positionStrategy: this.overlay.position().global(), + }); + + return this.overlay.create(overlayConfig); + } + return undefined; + } + + private attachSheetContainer(overlayRef: OverlayRef, config: ZardSheetOptions) { + const injector = Injector.create({ + parent: this.injector, + providers: [ + { provide: OverlayRef, useValue: overlayRef }, + { provide: ZardSheetOptions, useValue: config }, + ], + }); + + const containerPortal = new ComponentPortal>(ZardSheetComponent, config.zViewContainerRef, injector); + const containerRef = overlayRef.attach>(containerPortal); + containerRef.instance.state.set('open'); + + return containerRef.instance; + } + + private attachSheetContent(componentOrTemplateRef: ContentType, sheetContainer: ZardSheetComponent, overlayRef: OverlayRef, config: ZardSheetOptions) { + const sheetRef = new ZardSheetRef(overlayRef, config, sheetContainer, this.platformId); + + if (componentOrTemplateRef instanceof TemplateRef) { + sheetContainer.attachTemplatePortal( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + new TemplatePortal(componentOrTemplateRef, null!, { + sheetRef: sheetRef, + } as any), + ); + } else if (typeof componentOrTemplateRef !== 'string') { + const injector = this.createInjector(sheetRef, config); + const contentRef = sheetContainer.attachComponentPortal(new ComponentPortal(componentOrTemplateRef, config.zViewContainerRef, injector)); + sheetRef.componentInstance = contentRef.instance; + } + + return sheetRef; + } + + private createInjector(sheetRef: ZardSheetRef, config: ZardSheetOptions) { + return Injector.create({ + parent: this.injector, + providers: [ + { provide: ZardSheetRef, useValue: sheetRef }, + { provide: Z_MODAL_DATA, useValue: config.zData }, + ], + }); + } +} diff --git a/src/app/shared/components/sheet/sheet.variants.ts b/src/app/shared/components/sheet/sheet.variants.ts new file mode 100644 index 0000000..4bd1577 --- /dev/null +++ b/src/app/shared/components/sheet/sheet.variants.ts @@ -0,0 +1,58 @@ +import { cva, VariantProps } from 'class-variance-authority'; + +export const sheetVariants = cva( + 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500', + { + variants: { + zSide: { + right: 'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 border-l', + left: 'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 border-r', + top: 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 border-b', + bottom: 'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 border-t', + }, + zSize: { + default: '', + sm: '', + lg: '', + custom: '', + }, + }, + compoundVariants: [ + { + zSide: ['left', 'right'], + zSize: 'default', + class: 'w-3/4 sm:max-w-sm h-full', + }, + { + zSide: ['left', 'right'], + zSize: 'sm', + class: 'w-1/2 sm:max-w-xs h-full', + }, + { + zSide: ['left', 'right'], + zSize: 'lg', + class: 'w-full sm:max-w-lg h-full', + }, + { + zSide: ['top', 'bottom'], + zSize: 'default', + class: 'h-auto', + }, + { + zSide: ['top', 'bottom'], + zSize: 'sm', + class: 'h-1/3', + }, + { + zSide: ['top', 'bottom'], + zSize: 'lg', + class: 'h-3/4', + }, + ], + defaultVariants: { + zSide: 'right', + zSize: 'default', + }, + }, +); +export type ZardSheetVariants = VariantProps; diff --git a/src/app/shared/components/skeleton/skeleton.component.ts b/src/app/shared/components/skeleton/skeleton.component.ts new file mode 100644 index 0000000..dd20936 --- /dev/null +++ b/src/app/shared/components/skeleton/skeleton.component.ts @@ -0,0 +1,21 @@ +import { ChangeDetectionStrategy, Component, computed, input, ViewEncapsulation } from '@angular/core'; +import type { ClassValue } from 'clsx'; + +import { mergeClasses } from '@shared/utils/merge-classes'; +import { skeletonVariants } from './skeleton.variants'; + +@Component({ + selector: 'z-skeleton', + exportAs: 'zSkeleton', + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: `
    `, + host: { + class: 'block', + }, +}) +export class ZardSkeletonComponent { + readonly class = input(''); + + protected readonly classes = computed(() => mergeClasses(skeletonVariants(), this.class())); +} \ No newline at end of file diff --git a/src/app/shared/components/skeleton/skeleton.variants.ts b/src/app/shared/components/skeleton/skeleton.variants.ts new file mode 100644 index 0000000..1e69a08 --- /dev/null +++ b/src/app/shared/components/skeleton/skeleton.variants.ts @@ -0,0 +1,4 @@ +import { cva, VariantProps } from 'class-variance-authority'; + +export const skeletonVariants = cva('bg-accent animate-pulse rounded-md'); +export type ZardSkeletonVariants = VariantProps; \ No newline at end of file diff --git a/src/app/shared/components/slider/slider.component.ts b/src/app/shared/components/slider/slider.component.ts new file mode 100644 index 0000000..0dcd9a8 --- /dev/null +++ b/src/app/shared/components/slider/slider.component.ts @@ -0,0 +1,389 @@ +import { fromEvent, map, Subject, switchMap, takeUntil, tap } from 'rxjs'; + +import { DOCUMENT } from '@angular/common'; +import { + AfterViewInit, + booleanAttribute, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + computed, + ElementRef, + forwardRef, + inject, + input, + linkedSignal, + numberAttribute, + OnChanges, + OnDestroy, + output, + signal, + SimpleChanges, + viewChild, + ViewEncapsulation, +} from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; + +import { clamp, convertValueToPercentage, roundToStep } from '@shared/utils/number'; +import { mergeClasses } from '@shared/utils/merge-classes'; +import { sliderOrientationVariants, sliderRangeVariants, sliderThumbVariants, sliderTrackVariants, sliderVariants } from './slider.variants'; + +import type { ClassValue } from 'clsx'; + +type OnTouchedType = () => void; +type OnChangeType = (value: number) => void; + +@Component({ + selector: 'z-slider-track', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + imports: [], + template: ` + + + + `, + host: { + '[class]': '"data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full"', + '[attr.data-orientation]': 'orientation()', + }, +}) +export class ZSliderTrackComponent { + readonly orientation = input<'horizontal' | 'vertical'>('horizontal'); + readonly class = input(''); + + protected readonly classes = computed(() => mergeClasses(sliderTrackVariants({ zOrientation: this.orientation() }), this.class())); + + private readonly trackEl = viewChild.required>('track'); + + get nativeElement(): HTMLElement { + return this.trackEl().nativeElement; + } +} + +@Component({ + selector: 'z-slider-range', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + imports: [], + template: ` + + `, +}) +export class ZSliderRangeComponent { + readonly percent = input(0); + + readonly orientation = input<'horizontal' | 'vertical'>('horizontal'); + readonly class = input(''); + + protected readonly classes = computed(() => mergeClasses(sliderRangeVariants({ zOrientation: this.orientation() }), this.class())); +} + +@Component({ + selector: 'z-slider-thumb', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + imports: [], + template: ` + + `, + host: { + '[class]': 'orientationClasses()', + '[style.left]': 'orientation() === "horizontal" ? "calc(" + percent() + "% + " + offset() + "px)" : null', + '[style.bottom]': 'orientation() === "vertical" ? "calc(" + percent() + "% + " + offset() + "px)" : null', + }, +}) +export class ZSliderThumbComponent { + readonly value = input(0); + readonly min = input(0); + readonly max = input(100); + readonly disabled = input(false); + readonly percent = input(0); + readonly offset = input(0); + + readonly orientation = input<'horizontal' | 'vertical'>('horizontal'); + readonly class = input(''); + + protected readonly classes = computed(() => mergeClasses(sliderThumbVariants(), this.class())); + protected readonly orientationClasses = computed(() => mergeClasses(sliderOrientationVariants({ zOrientation: this.orientation() }))); + + private readonly thumbEl = viewChild.required>('thumb'); + + get nativeElement(): HTMLElement { + return this.thumbEl().nativeElement; + } +} + +@Component({ + selector: 'z-slider', + exportAs: 'zSlider', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + imports: [ZSliderTrackComponent, ZSliderRangeComponent, ZSliderThumbComponent], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ZardSliderComponent), + multi: true, + }, + ], + template: ` + + + + + + + + `, + host: { + '[class]': 'classes()', + '[attr.data-orientation]': 'zOrientation()', + '[attr.aria-disabled]': 'disabled() ? true : null', + '[attr.data-disabled]': 'disabled() ? true : null', + }, +}) +export class ZardSliderComponent implements ControlValueAccessor, AfterViewInit, OnChanges, OnDestroy { + readonly zMin = input(0, { transform: numberAttribute }); + readonly zMax = input(100, { transform: numberAttribute }); + readonly zDefault = input(0, { transform: numberAttribute }); + readonly zValue = input(null, { transform: numberAttribute }); + readonly zStep = input(1, { transform: numberAttribute }); + readonly zDisabled = input(false, { transform: booleanAttribute }); + + readonly zOrientation = input<'horizontal' | 'vertical'>('horizontal'); + readonly class = input(''); + + readonly onSlide = output(); + + readonly thumbRef = viewChild.required(ZSliderThumbComponent); + readonly trackRef = viewChild.required(ZSliderTrackComponent); + + private elementRef = inject>(ElementRef); + private cdr = inject(ChangeDetectorRef); + private document = inject(DOCUMENT); + + protected readonly classes = computed(() => mergeClasses(sliderVariants({ orientation: this.zOrientation() }), this.class())); + protected disabled = linkedSignal(() => this.zDisabled()); + + readonly percentValue = signal(50); + readonly lastEmittedValue = signal(0); + + readonly thumbOffset = signal(0); + + // eslint-disable-next-line @typescript-eslint/no-empty-function + private onTouched: OnTouchedType = () => {}; + // eslint-disable-next-line @typescript-eslint/no-empty-function + private onChange: OnChangeType = (value: number) => {}; + + private destroy$ = new Subject(); + + ngOnChanges(changes: SimpleChanges): void { + if ('zValue' in changes && !changes['zValue'].firstChange) { + const value = this.zValue(); + if (value !== this.lastEmittedValue()) { + this.setInitialValue(); + } + } + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + ngAfterViewInit() { + const pointerDown$ = fromEvent(this.elementRef.nativeElement, 'pointerdown').pipe( + tap(event => { + if (this.disabled()) return; + + const target = event.target as HTMLElement; + const isThumb = this.thumbRef().nativeElement.contains(target); + const isTrack = this.trackRef().nativeElement.contains(target); + + if (isTrack && !isThumb) { + const coord = this.zOrientation() === 'vertical' ? event.clientY : event.clientX; + const clickPercentage = this.calculatePercentage(coord); + this.updateSliderFromPercentage(clickPercentage); + this.onTouched(); + requestAnimationFrame(() => { + this.thumbRef().nativeElement.focus(); + }); + } + }), + ); + + const pointerMove$ = fromEvent(this.document, 'pointermove'); + const pointerUp$ = fromEvent(this.document, 'pointerup'); + + pointerDown$ + .pipe( + switchMap(() => + pointerMove$.pipe( + takeUntil(pointerUp$), + takeUntil(this.destroy$), + map(event => { + const coord = this.zOrientation() === 'vertical' ? event.clientY : event.clientX; + return this.calculatePercentage(coord); + }), + ), + ), + takeUntil(this.destroy$), + ) + .subscribe(percentage => { + if (this.disabled()) return; + this.updateSliderFromPercentage(percentage); + this.onTouched(); + }); + + this.setInitialValue(); + } + + writeValue(value: number): void { + if (value == null) { + this.setInitialValue(); + return; + } + + const min = this.zMin(); + const max = this.zMax(); + const step = this.zStep(); + + const clampedValue = clamp(value, [min, max]); + const roundedValue = roundToStep(clampedValue, min, step); + + if (roundedValue === this.lastEmittedValue()) return; + + this.percentValue.set(convertValueToPercentage(roundedValue, min, max)); + this.lastEmittedValue.set(roundedValue); + this.cdr.markForCheck(); + } + + registerOnChange(fn: (value: number) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled.set(isDisabled); + this.cdr.markForCheck(); + } + + handleKeydown(event: KeyboardEvent): void { + if (this.disabled()) return; + + const percent = this.percentValue(); + const rawValue = this.zMin() + ((this.zMax() - this.zMin()) * percent) / 100; + const currentValue = roundToStep(rawValue, this.zMin(), this.zStep()); + + let newValue = currentValue; + + switch (event.key) { + case 'Home': + newValue = this.zMin(); + break; + case 'End': + newValue = this.zMax(); + break; + case 'ArrowLeft': + newValue = Math.max(currentValue - this.zStep(), this.zMin()); + break; + case 'ArrowRight': + newValue = Math.min(currentValue + this.zStep(), this.zMax()); + break; + case 'ArrowDown': + newValue = Math.max(currentValue - this.zStep(), this.zMin()); + break; + case 'ArrowUp': + newValue = Math.min(currentValue + this.zStep(), this.zMax()); + break; + default: + return; + } + + this.percentValue.set(convertValueToPercentage(newValue, this.zMin(), this.zMax())); + this.onSlide.emit(newValue); + this.lastEmittedValue.set(newValue); + this.onChange(newValue); + this.cdr.markForCheck(); + event.preventDefault(); + } + + private updateSliderFromPercentage(percentage: number): void { + const clamped = clamp(percentage, [0, 1]); + const rawValue = this.zMin() + (this.zMax() - this.zMin()) * clamped; + const value = roundToStep(rawValue, this.zMin(), this.zStep()); + + if (value !== this.lastEmittedValue()) { + this.percentValue.set(convertValueToPercentage(value, this.zMin(), this.zMax())); + this.onSlide.emit(value); + this.lastEmittedValue.set(value); + this.onChange(value); + this.cdr.markForCheck(); + } + } + + private calculatePercentage(clientCoord: number): number { + const rect = this.elementRef.nativeElement.getBoundingClientRect(); + if (this.zOrientation() === 'vertical') { + const relativeY = (clientCoord - rect.top) / rect.height; + return clamp(1 - relativeY, [0, 1]); + } + const relativeX = (clientCoord - rect.left) / rect.width; + return clamp(relativeX, [0, 1]); + } + + private setInitialValue(): void { + const min = this.zMin(); + const max = this.zMax(); + const step = this.zStep(); + + const def = clamp(this.zDefault(), [min, max]); + const raw = this.zValue(); + const value = raw != null && raw >= min && raw <= max ? raw : def; + + const initial = roundToStep(value, min, step); + this.percentValue.set(convertValueToPercentage(initial, min, max)); + this.lastEmittedValue.set(initial); + this.thumbOffset.set(0); + } +} \ No newline at end of file diff --git a/src/app/shared/components/slider/slider.variants.ts b/src/app/shared/components/slider/slider.variants.ts new file mode 100644 index 0000000..3a8f294 --- /dev/null +++ b/src/app/shared/components/slider/slider.variants.ts @@ -0,0 +1,74 @@ +import { cva, type VariantProps } from 'class-variance-authority'; + +export const sliderVariants = cva( + 'relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col', + { + variants: { + orientation: { + horizontal: 'items-center', + vertical: 'flex-col h-full min-h-44 w-auto', + }, + disabled: { + true: 'opacity-50 pointer-events-none', + false: '', + }, + }, + defaultVariants: { + orientation: 'horizontal', + disabled: false, + }, + }, +); + +export type SliderVariants = VariantProps; + +export const sliderTrackVariants = cva( + 'flex bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5', + { + variants: { + zOrientation: { + horizontal: 'h-1.5 w-full', + vertical: 'w-1.5 h-full min-h-44', + }, + }, + defaultVariants: { + zOrientation: 'horizontal', + }, + }, +); + +export type SliderTrackVariants = VariantProps; + +export const sliderRangeVariants = cva('bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full', { + variants: { + zOrientation: { + horizontal: 'h-full', + vertical: 'w-full', + }, + }, + defaultVariants: { + zOrientation: 'horizontal', + }, +}); + +export type SliderRangeVariants = VariantProps; + +export const sliderThumbVariants = cva( + 'border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50', +); + +export type SliderThumbVariants = VariantProps; + +export const sliderOrientationVariants = cva('absolute', { + variants: { + zOrientation: { + horizontal: 'translate-x-[-50%]', + vertical: 'translate-y-[50%]', + }, + }, + defaultVariants: { + zOrientation: 'horizontal', + }, +}); + +export type SliderOrientationVariants = VariantProps; \ No newline at end of file diff --git a/src/app/shared/components/switch/switch.component.ts b/src/app/shared/components/switch/switch.component.ts new file mode 100644 index 0000000..d32a373 --- /dev/null +++ b/src/app/shared/components/switch/switch.component.ts @@ -0,0 +1,109 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + forwardRef, + input, + output, + signal, + ViewEncapsulation, +} from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import type { ClassValue } from 'clsx'; + +import { switchVariants, ZardSwitchVariants } from './switch.variants'; +import { mergeClasses, generateId } from '@shared/utils/merge-classes'; + +type OnTouchedType = () => any; +type OnChangeType = (value: any) => void; + +@Component({ + selector: 'z-switch, [z-switch]', + standalone: true, + exportAs: 'zSwitch', + template: ` + + + + + + `, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ZardSwitchComponent), + multi: true, + }, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, +}) +export class ZardSwitchComponent implements ControlValueAccessor { + readonly checkChange = output(); + readonly class = input(''); + + readonly zType = input('default'); + readonly zSize = input('default'); + readonly zId = input(''); + + /* eslint-disable-next-line @typescript-eslint/no-empty-function */ + private onChange: OnChangeType = () => {}; + /* eslint-disable-next-line @typescript-eslint/no-empty-function */ + private onTouched: OnTouchedType = () => {}; + + protected readonly classes = computed(() => + mergeClasses(switchVariants({ zType: this.zType(), zSize: this.zSize() }), this.class()) + ); + + protected readonly uniqueId = signal(generateId('switch')); + protected checked = signal(true); + protected status = computed(() => (this.checked() ? 'checked' : 'unchecked')); + protected disabled = signal(false); + + writeValue(val: boolean): void { + this.checked.set(val); + } + + registerOnChange(fn: OnChangeType): void { + this.onChange = fn; + } + + registerOnTouched(fn: OnTouchedType): void { + this.onTouched = fn; + } + + onSwitchChange(): void { + if (this.disabled()) return; + + this.checked.update((checked) => !checked); + this.onTouched(); + this.onChange(this.checked()); + this.checkChange.emit(this.checked()); + } + + setDisabledState(isDisabled: boolean): void { + this.disabled.set(isDisabled); + } +} diff --git a/src/app/shared/components/switch/switch.variants.ts b/src/app/shared/components/switch/switch.variants.ts new file mode 100644 index 0000000..e6ec94b --- /dev/null +++ b/src/app/shared/components/switch/switch.variants.ts @@ -0,0 +1,27 @@ +import { cva, VariantProps } from 'class-variance-authority'; + +export const switchVariants = cva( + 'peer inline-flex shrink-0 cursor-pointer items-center rounded-full border border-slate-300 transition-colors ' + + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 ' + + 'disabled:cursor-not-allowed disabled:opacity-50 ' + + 'data-[state=unchecked]:bg-pmu-jaune data-[state=checked]:bg-pmu-vert', + { + variants: { + zType: { + default: 'bg-pmu-green', + destructive: 'data-[state=checked]:bg-destructive', + }, + zSize: { + default: 'h-6 w-11', + sm: 'h-5 w-9', + lg: 'h-7 w-14', + }, + }, + defaultVariants: { + zType: 'default', + zSize: 'default', + }, + } +); + +export type ZardSwitchVariants = VariantProps; diff --git a/src/app/shared/components/table/table.component.ts b/src/app/shared/components/table/table.component.ts new file mode 100644 index 0000000..4c22cd5 --- /dev/null +++ b/src/app/shared/components/table/table.component.ts @@ -0,0 +1,144 @@ +import type { ClassValue } from 'clsx'; + +import { ChangeDetectionStrategy, Component, computed, input, ViewEncapsulation } from '@angular/core'; + +import { mergeClasses } from '@shared/utils/merge-classes'; +import { + tableVariants, + tableHeaderVariants, + tableBodyVariants, + tableRowVariants, + tableHeadVariants, + tableCellVariants, + tableCaptionVariants, + ZardTableVariants, +} from './table.variants'; + +@Component({ + selector: 'table[z-table]', + exportAs: 'zTable', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ``, + host: { + '[class]': 'classes()', + }, +}) +export class ZardTableComponent { + readonly zType = input('default'); + readonly zSize = input('default'); + readonly class = input(''); + + protected readonly classes = computed(() => + mergeClasses( + tableVariants({ + zType: this.zType(), + zSize: this.zSize(), + }), + this.class(), + ), + ); +} + +@Component({ + selector: 'thead[z-table-header]', + exportAs: 'zTableHeader', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ``, + host: { + '[class]': 'classes()', + }, +}) +export class ZardTableHeaderComponent { + readonly class = input(''); + + protected readonly classes = computed(() => mergeClasses(tableHeaderVariants(), this.class())); +} + +@Component({ + selector: 'tbody[z-table-body]', + exportAs: 'zTableBody', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ``, + host: { + '[class]': 'classes()', + }, +}) +export class ZardTableBodyComponent { + readonly class = input(''); + + protected readonly classes = computed(() => mergeClasses(tableBodyVariants(), this.class())); +} + +@Component({ + selector: 'tr[z-table-row]', + exportAs: 'zTableRow', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ``, + host: { + '[class]': 'classes()', + }, +}) +export class ZardTableRowComponent { + readonly class = input(''); + + protected readonly classes = computed(() => mergeClasses(tableRowVariants(), this.class())); +} + +@Component({ + selector: 'th[z-table-head]', + exportAs: 'zTableHead', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ``, + host: { + '[class]': 'classes()', + }, +}) +export class ZardTableHeadComponent { + readonly class = input(''); + + protected readonly classes = computed(() => mergeClasses(tableHeadVariants(), this.class())); +} + +@Component({ + selector: 'td[z-table-cell]', + exportAs: 'zTableCell', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ``, + host: { + '[class]': 'classes()', + }, +}) +export class ZardTableCellComponent { + readonly class = input(''); + + protected readonly classes = computed(() => mergeClasses(tableCellVariants(), this.class())); +} + +@Component({ + selector: 'caption[z-table-caption]', + exportAs: 'zTableCaption', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ``, + host: { + '[class]': 'classes()', + }, +}) +export class ZardTableCaptionComponent { + readonly class = input(''); + + protected readonly classes = computed(() => mergeClasses(tableCaptionVariants(), this.class())); +} \ No newline at end of file diff --git a/src/app/shared/components/table/table.module.ts b/src/app/shared/components/table/table.module.ts new file mode 100644 index 0000000..100c246 --- /dev/null +++ b/src/app/shared/components/table/table.module.ts @@ -0,0 +1,27 @@ +import { NgModule } from '@angular/core'; + +import { + ZardTableComponent, + ZardTableHeaderComponent, + ZardTableBodyComponent, + ZardTableRowComponent, + ZardTableHeadComponent, + ZardTableCellComponent, + ZardTableCaptionComponent, +} from './table.component'; + +const TABLE_COMPONENTS = [ + ZardTableComponent, + ZardTableHeaderComponent, + ZardTableBodyComponent, + ZardTableRowComponent, + ZardTableHeadComponent, + ZardTableCellComponent, + ZardTableCaptionComponent, +]; + +@NgModule({ + imports: [...TABLE_COMPONENTS], + exports: [...TABLE_COMPONENTS], +}) +export class ZardTableModule {} \ No newline at end of file diff --git a/src/app/shared/components/table/table.variants.ts b/src/app/shared/components/table/table.variants.ts new file mode 100644 index 0000000..02035ba --- /dev/null +++ b/src/app/shared/components/table/table.variants.ts @@ -0,0 +1,61 @@ +import { cva, VariantProps } from 'class-variance-authority'; + +export const tableVariants = cva( + 'w-full caption-bottom text-sm [&_thead_tr]:border-b [&_tbody]:border-0 [&_tbody_tr:last-child]:border-0 [&_tbody_tr]:border-b [&_tbody_tr]:transition-colors [&_tbody_tr]:hover:bg-muted/50 [&_tbody_tr]:data-[state=selected]:bg-muted [&_th]:h-10 [&_th]:px-2 [&_th]:text-left [&_th]:align-middle [&_th]:font-medium [&_th]:text-muted-foreground [&_th:has([role=checkbox])]:pr-0 [&_th>[role=checkbox]]:translate-y-[2px] [&_td]:p-2 [&_td]:align-middle [&_td:has([role=checkbox])]:pr-0 [&_td>[role=checkbox]]:translate-y-[2px] [&_caption]:mt-4 [&_caption]:text-sm [&_caption]:text-muted-foreground', + { + variants: { + zType: { + default: '', + striped: '[&_tbody_tr:nth-child(odd)]:bg-muted/50', + bordered: 'border border-border', + }, + zSize: { + default: '', + compact: '[&_td]:py-2 [&_th]:py-2', + comfortable: '[&_td]:py-4 [&_th]:py-4', + }, + }, + defaultVariants: { + zType: 'default', + zSize: 'default', + }, + }, +); + +export const tableHeaderVariants = cva('[&_tr]:border-b', { + variants: {}, + defaultVariants: {}, +}); + +export const tableBodyVariants = cva('[&_tr:last-child]:border-0', { + variants: {}, + defaultVariants: {}, +}); + +export const tableRowVariants = cva('border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted', { + variants: {}, + defaultVariants: {}, +}); + +export const tableHeadVariants = cva('h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]', { + variants: {}, + defaultVariants: {}, +}); + +export const tableCellVariants = cva('p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]', { + variants: {}, + defaultVariants: {}, +}); + +export const tableCaptionVariants = cva('mt-4 text-sm text-muted-foreground', { + variants: {}, + defaultVariants: {}, +}); + +export type ZardTableVariants = VariantProps; +export type ZardTableHeaderVariants = VariantProps; +export type ZardTableBodyVariants = VariantProps; +export type ZardTableRowVariants = VariantProps; +export type ZardTableHeadVariants = VariantProps; +export type ZardTableCellVariants = VariantProps; +export type ZardTableCaptionVariants = VariantProps; \ No newline at end of file diff --git a/src/app/shared/components/tabs/tabs.component.ts b/src/app/shared/components/tabs/tabs.component.ts new file mode 100644 index 0000000..d630c99 --- /dev/null +++ b/src/app/shared/components/tabs/tabs.component.ts @@ -0,0 +1,272 @@ +import { + afterNextRender, + ChangeDetectionStrategy, + Component, + computed, + contentChildren, + ElementRef, + input, + output, + signal, + TemplateRef, + viewChild, + ViewEncapsulation, +} from '@angular/core'; +import { combineLatest, interval, startWith } from 'rxjs'; +import { CommonModule } from '@angular/common'; + +import { tabButtonVariants, tabContainerVariants, tabNavVariants, ZardTabVariants } from './tabs.variants'; +import { ZardButtonComponent } from '../button/button.component'; + +export type zPosition = 'top' | 'bottom' | 'left' | 'right'; +export type zAlign = 'center' | 'start' | 'end'; + +@Component({ + selector: 'z-tab', + standalone: true, + imports: [], + template: ` + + + + `, + encapsulation: ViewEncapsulation.None, +}) +export class ZardTabComponent { + label = input.required(); + readonly contentTemplate = viewChild.required>('content'); +} + +@Component({ + selector: 'z-tab-group', + standalone: true, + imports: [CommonModule, ZardButtonComponent], + template: ` +
    + @if (navBeforeContent()) { +
    + @if (showArrow()) { + @if (zTabsPosition() === 'top' || zTabsPosition() === 'bottom') { + + } @else { + + } + } + + + + @if (showArrow()) { + @if (zTabsPosition() === 'top' || zTabsPosition() === 'bottom') { + + } @else { + + } + } +
    + } + +
    + @for (tab of tabs(); track $index; let index = $index) { + @if (activeTabIndex() === index) { + + } + } +
    + + @if (!navBeforeContent()) { +
    + @if (showArrow()) { + @if (zTabsPosition() === 'top' || zTabsPosition() === 'bottom') { + + } @else { + + } + } + + + + @if (showArrow()) { + @if (zTabsPosition() === 'top' || zTabsPosition() === 'bottom') { + + } @else { + + } + } +
    + } +
    + `, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + styles: [ + ` + .nav-tab-scroll { + -webkit-overflow-scrolling: touch; + scroll-behavior: smooth; + &::-webkit-scrollbar-thumb { + background-color: rgba(209, 209, 209, 0.2); + border-radius: 2px; + } + &::-webkit-scrollbar { + height: 4px; + width: 4px; + } + &::-webkit-scrollbar-button { + display: none; + } + } + `, + ], +}) +export class ZardTabGroupComponent { + private readonly tabComponents = contentChildren(ZardTabComponent, { descendants: true }); + private readonly tabsContainer = viewChild.required('tabNav'); + + protected readonly tabs = computed(() => this.tabComponents()); + protected readonly activeTabIndex = signal(0); + protected readonly hasScrollSignal = signal(false); + + protected readonly showArrow = computed(() => { + const _tabs = this.tabs(); + const _position = this.zTabsPosition(); + const scrollStatus = this.hasScrollSignal(); + return scrollStatus; + }); + + protected readonly zOnTabChange = output<{ + index: number; + label: string; + tab: ZardTabComponent; + }>(); + protected readonly zDeselect = output<{ + index: number; + label: string; + tab: ZardTabComponent; + }>(); + + public readonly zTabsPosition = input('top'); + public readonly zActivePosition = input('bottom'); + public readonly zShowArrow = input(true); + public readonly zScrollAmount = input(100); + public readonly zAlignTabs = input('start'); + + constructor() { + afterNextRender(() => { + if (this.tabs().length > 0) { + this.setActiveTab(0); + } + + combineLatest([interval(100).pipe(startWith(0))]).subscribe(() => { + this.hasScrollSignal.set(this.hasScroll()); + }); + }); + } + + private hasScroll(): boolean { + if (this.tabsContainer && this.tabsContainer().nativeElement && this.zShowArrow()) { + const navElement: HTMLElement = this.tabsContainer().nativeElement; + return navElement.scrollWidth > navElement.clientWidth || navElement.scrollHeight > navElement.clientHeight; + } + return false; + } + + protected setActiveTab(index: number) { + const currentTab = this.tabs()[this.activeTabIndex()]; + if (index !== this.activeTabIndex()) { + this.zDeselect.emit({ + index: this.activeTabIndex(), + label: currentTab.label(), + tab: currentTab, + }); + } + + this.activeTabIndex.set(index); + const activeTabComponent = this.tabs()[index]; + if (activeTabComponent) { + this.zOnTabChange.emit({ + index, + label: activeTabComponent.label(), + tab: activeTabComponent, + }); + } + } + + protected readonly navBeforeContent = computed(() => { + const position = this.zTabsPosition(); + return position === 'top' || position === 'left'; + }); + + protected readonly isHorizontal = computed(() => { + const position = this.zTabsPosition(); + return position === 'top' || position === 'bottom'; + }); + + protected readonly navGridClasses = computed(() => { + const position = this.zTabsPosition(); + const hasArrows = this.showArrow(); + return position === 'left' || position === 'right' ? `grid${hasArrows ? ' grid-rows-[25px_1fr_25px]' : ''}` : `grid${hasArrows ? ' grid-cols-[25px_1fr_25px]' : ''}`; + }); + + protected readonly containerClasses = computed(() => tabContainerVariants({ zPosition: this.zTabsPosition() })); + + protected readonly navClasses = computed(() => tabNavVariants({ zPosition: this.zTabsPosition(), zAlignTabs: this.showArrow() ? 'start' : this.zAlignTabs() })); + + protected readonly buttonClassesSignal = computed(() => { + const activeIndex = this.activeTabIndex(); + const position = this.zActivePosition(); + return this.tabs().map((_, index) => { + const isActive = activeIndex === index; + return tabButtonVariants({ zActivePosition: position, isActive }); + }); + }); + + protected scrollNav(direction: 'left' | 'right' | 'up' | 'down') { + const container = this.tabsContainer().nativeElement; + const scrollAmount = this.zScrollAmount(); + if (direction === 'left') { + container.scrollLeft -= scrollAmount; + } else if (direction === 'right') { + container.scrollLeft += scrollAmount; + } else if (direction === 'up') { + container.scrollTop -= scrollAmount; + } else if (direction === 'down') { + container.scrollTop += scrollAmount; + } + } + + public selectTabByIndex(index: number): void { + if (index >= 0 && index < this.tabs().length) { + this.setActiveTab(index); + } else { + console.warn(`Index ${index} outside the range of available tabs.`); + } + } +} diff --git a/src/app/shared/components/tabs/tabs.variants.ts b/src/app/shared/components/tabs/tabs.variants.ts new file mode 100644 index 0000000..eac86d6 --- /dev/null +++ b/src/app/shared/components/tabs/tabs.variants.ts @@ -0,0 +1,80 @@ +import { cva, VariantProps } from 'class-variance-authority'; + +import { zAlign } from './tabs.component'; + +export const tabContainerVariants = cva('flex w-full h-full', { + variants: { + zPosition: { + top: 'flex-col', + bottom: 'flex-col', + left: 'flex-row', + right: 'flex-row', + }, + }, + defaultVariants: { + zPosition: 'top', + }, +}); + +export const tabNavVariants = cva('flex gap-4 overflow-auto scroll nav-tab-scroll', { + variants: { + zPosition: { + top: 'flex-row border-b mb-4 w-full', + bottom: 'flex-row border-t mt-4 w-full', + left: 'flex-col border-r mr-4 h-full min-h-0', + right: 'flex-col border-l ml-4 h-full min-h-0', + }, + zAlignTabs: { + start: 'justify-start', + center: 'justify-center', + end: 'justify-end', + }, + }, + defaultVariants: { + zPosition: 'top', + zAlignTabs: 'start', + }, +}); + +export const tabButtonVariants = cva('hover:bg-transparent rounded-none flex-shrink-0', { + variants: { + zActivePosition: { + top: '', + bottom: '', + left: '', + right: '', + }, + isActive: { + true: '', + false: '', + }, + }, + compoundVariants: [ + { + zActivePosition: 'top', + isActive: true, + class: 'border-t-2 border-t-primary', + }, + { + zActivePosition: 'bottom', + isActive: true, + class: 'border-b-2 border-b-primary', + }, + { + zActivePosition: 'left', + isActive: true, + class: 'border-l-2 border-l-primary', + }, + { + zActivePosition: 'right', + isActive: true, + class: 'border-r-2 border-r-primary', + }, + ], + defaultVariants: { + zActivePosition: 'bottom', + isActive: false, + }, +}); + +export type ZardTabVariants = VariantProps & VariantProps & VariantProps & { zAlignTabs: zAlign }; diff --git a/src/app/shared/components/toast/toast.component.ts b/src/app/shared/components/toast/toast.component.ts new file mode 100644 index 0000000..28a3182 --- /dev/null +++ b/src/app/shared/components/toast/toast.component.ts @@ -0,0 +1,44 @@ +import { ChangeDetectionStrategy, Component, computed, input, ViewEncapsulation } from '@angular/core'; +import { NgxSonnerToaster } from 'ngx-sonner'; +import type { ClassValue } from 'clsx'; + +import { mergeClasses } from '@shared/utils/merge-classes'; +import { toastVariants, ZardToastVariants } from './toast.variants'; + +@Component({ + selector: 'z-toast, z-toaster', + standalone: true, + exportAs: 'zToast', + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + imports: [NgxSonnerToaster], + template: ` + + `, +}) +export class ZardToastComponent { + readonly class = input(''); + readonly variant = input('default'); + readonly theme = input<'light' | 'dark' | 'system'>('system'); + readonly position = input<'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right'>('bottom-right'); + readonly richColors = input(false); + readonly expand = input(false); + readonly duration = input(4000); + readonly visibleToasts = input(3); + readonly closeButton = input(false); + readonly toastOptions = input>({}); + readonly dir = input<'ltr' | 'rtl' | 'auto'>('auto'); + + protected readonly classes = computed(() => mergeClasses('toaster group', toastVariants({ variant: this.variant() }), this.class())); +} \ No newline at end of file diff --git a/src/app/shared/components/toast/toast.variants.ts b/src/app/shared/components/toast/toast.variants.ts new file mode 100644 index 0000000..d7bed9c --- /dev/null +++ b/src/app/shared/components/toast/toast.variants.ts @@ -0,0 +1,15 @@ +import { cva, VariantProps } from 'class-variance-authority'; + +export const toastVariants = cva('group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg', { + variants: { + variant: { + default: 'group-[.toaster]:bg-background group-[.toaster]:text-foreground', + destructive: 'group-[.toaster]:bg-destructive group-[.toaster]:text-destructive-foreground destructive group-[.toaster]:border-destructive', + }, + }, + defaultVariants: { + variant: 'default', + }, +}); + +export type ZardToastVariants = VariantProps; \ No newline at end of file diff --git a/src/app/shared/components/toggle-group/toggle-group.component.ts b/src/app/shared/components/toggle-group/toggle-group.component.ts new file mode 100644 index 0000000..c3a5c80 --- /dev/null +++ b/src/app/shared/components/toggle-group/toggle-group.component.ts @@ -0,0 +1,181 @@ +import { ClassValue } from 'clsx'; + +import { ChangeDetectionStrategy, Component, computed, forwardRef, input, output, signal, ViewEncapsulation } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; + +import { mergeClasses } from '@shared/utils/merge-classes'; +import { toggleGroupVariants, toggleGroupItemVariants } from './toggle-group.variants'; + +export interface ZardToggleGroupItem { + value: string; + label?: string; + icon?: string; + disabled?: boolean; + ariaLabel?: string; +} + +type OnTouchedType = () => void; +type OnChangeType = (value: string | string[]) => void; + +@Component({ + selector: 'z-toggle-group', + exportAs: 'zToggleGroup', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
    + @for (item of items(); track item.value; let i = $index) { + + } +
    + `, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ZardToggleGroupComponent), + multi: true, + }, + ], +}) +export class ZardToggleGroupComponent implements ControlValueAccessor { + readonly zMode = input<'single' | 'multiple'>('multiple'); + readonly zType = input<'default' | 'outline'>('default'); + readonly zSize = input<'sm' | 'md' | 'lg'>('md'); + readonly value = input(); + readonly defaultValue = input(); + readonly disabled = input(false); + readonly class = input(''); + readonly items = input([]); + + readonly valueChange = output(); + + private internalValue = signal(undefined); + + protected readonly classes = computed(() => + mergeClasses( + toggleGroupVariants({ + zType: this.zType(), + zSize: this.zSize(), + }), + this.class(), + ), + ); + + protected readonly currentValue = computed(() => { + const internal = this.internalValue(); + const input = this.value(); + const defaultVal = this.defaultValue(); + + if (internal !== undefined) return internal; + if (input !== undefined) return input; + if (defaultVal !== undefined) return defaultVal; + + return this.zMode() === 'single' ? '' : []; + }); + + protected getItemClasses(index: number, total: number): string { + const baseClasses = toggleGroupItemVariants({ + zType: this.zType(), + zSize: this.zSize(), + }); + + const positionClasses = []; + + // Add rounded corners for first and last items + if (index === 0) { + positionClasses.push('first:rounded-l-md'); + } + if (index === total - 1) { + positionClasses.push('last:rounded-r-md'); + } + + // Handle borders for outline variant + if (this.zType() === 'outline') { + if (index === 0) { + // First item gets full border + positionClasses.push('border-l'); + } else { + // Other items don't get left border (connects to previous) + positionClasses.push('border-l-0'); + } + } + + // Focus z-index + positionClasses.push('focus:z-10', 'focus-visible:z-10'); + + return mergeClasses(baseClasses, ...positionClasses); + } + + protected isItemPressed(itemValue: string): boolean { + const current = this.currentValue(); + if (this.zMode() === 'single') { + return current === itemValue; + } + return Array.isArray(current) && current.includes(itemValue); + } + + // eslint-disable-next-line @typescript-eslint/no-empty-function + private onTouched: OnTouchedType = () => {}; + // eslint-disable-next-line @typescript-eslint/no-empty-function + private onChangeFn: OnChangeType = () => {}; + + toggleItem(item: ZardToggleGroupItem) { + if (this.disabled() || item.disabled) return; + + const currentValue = this.currentValue(); + let newValue: string | string[]; + + if (this.zMode() === 'single') { + newValue = currentValue === item.value ? '' : item.value; + } else { + const currentArray = Array.isArray(currentValue) ? currentValue : []; + if (currentArray.includes(item.value)) { + newValue = currentArray.filter(v => v !== item.value); + } else { + newValue = [...currentArray, item.value]; + } + } + + this.internalValue.set(newValue); + this.valueChange.emit(newValue); + this.onChangeFn(newValue); + this.onTouched(); + } + + writeValue(value: string | string[]): void { + if (value !== undefined) { + this.internalValue.set(value); + } + } + + registerOnChange(fn: OnChangeType): void { + this.onChangeFn = fn; + } + + registerOnTouched(fn: OnTouchedType): void { + this.onTouched = fn; + } + + setDisabledState(_isDisabled: boolean): void { + // Note: disabled state is handled through the disabled input + // This method is required by ControlValueAccessor interface + } +} \ No newline at end of file diff --git a/src/app/shared/components/toggle-group/toggle-group.variants.ts b/src/app/shared/components/toggle-group/toggle-group.variants.ts new file mode 100644 index 0000000..6226201 --- /dev/null +++ b/src/app/shared/components/toggle-group/toggle-group.variants.ts @@ -0,0 +1,43 @@ +import { cva, VariantProps } from 'class-variance-authority'; + +export const toggleGroupVariants = cva('flex w-fit items-center rounded-md', { + variants: { + zType: { + default: '', + outline: 'shadow-sm', + }, + zSize: { + sm: '', + md: '', + lg: '', + }, + }, + defaultVariants: { + zType: 'default', + zSize: 'md', + }, +}); + +export const toggleGroupItemVariants = cva( + 'inline-flex items-center justify-center whitespace-nowrap rounded-none gap-2 text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground', + { + variants: { + zType: { + default: 'bg-transparent', + outline: 'border border-input bg-transparent hover:bg-accent hover:text-accent-foreground', + }, + zSize: { + sm: 'h-8 px-2.5 text-xs', + md: 'h-9 px-3 text-sm', + lg: 'h-10 px-4 text-sm', + }, + }, + defaultVariants: { + zType: 'default', + zSize: 'md', + }, + }, +); + +export type ZardToggleGroupVariants = VariantProps; +export type ZardToggleGroupItemVariants = VariantProps; \ No newline at end of file diff --git a/src/app/shared/components/toggle/toggle.component.ts b/src/app/shared/components/toggle/toggle.component.ts new file mode 100644 index 0000000..3968d95 --- /dev/null +++ b/src/app/shared/components/toggle/toggle.component.ts @@ -0,0 +1,99 @@ +import { ChangeDetectionStrategy, Component, forwardRef, HostListener, ViewEncapsulation, signal, computed, input, output, linkedSignal } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { ClassValue } from 'clsx'; + +import { toggleVariants, ZardToggleVariants } from './toggle.variants'; +import { mergeClasses, transform } from '@shared/utils/merge-classes'; + +type OnTouchedType = () => void; +type OnChangeType = (value: boolean) => void; + +@Component({ + selector: 'z-toggle', + exportAs: 'zToggle', + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` + + `, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ZardToggleComponent), + multi: true, + }, + ], +}) +export class ZardToggleComponent implements ControlValueAccessor { + readonly zValue = input(); + readonly zDefault = input(false); + readonly zDisabled = input(false, { alias: 'disabled', transform }); + readonly zType = input('default'); + readonly zSize = input('md'); + readonly zAriaLabel = input('', { alias: 'aria-label' }); + readonly class = input(''); + + readonly onClick = output(); + readonly onHover = output(); + readonly onChange = output(); + + private isUsingNgModel = signal(false); + + protected readonly value = linkedSignal(() => this.zValue() || this.zDefault()); + + protected readonly disabled = linkedSignal(() => this.zDisabled()); + + protected readonly classes = computed(() => mergeClasses(toggleVariants({ zSize: this.zSize(), zType: this.zType() }), this.class())); + + // eslint-disable-next-line @typescript-eslint/no-empty-function + private onTouched: OnTouchedType = () => {}; + // eslint-disable-next-line @typescript-eslint/no-empty-function + private onChangeFn: OnChangeType = () => {}; + + @HostListener('mouseenter') + handleHover() { + this.onHover.emit(); + } + + toggle() { + if (this.disabled()) return; + + const next = !this.value(); + + if (this.zValue() === undefined) { + this.value.set(next); + } + + this.onClick.emit(); + this.onChange.emit(next); + this.onChangeFn(next); + this.onTouched(); + } + + writeValue(val: boolean): void { + this.value.set(val ?? this.zDefault()); + } + + registerOnChange(fn: any): void { + this.onChangeFn = fn; + this.isUsingNgModel.set(true); + } + + registerOnTouched(fn: any): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled.set(isDisabled); + } +} diff --git a/src/app/shared/components/toggle/toggle.variants.ts b/src/app/shared/components/toggle/toggle.variants.ts new file mode 100644 index 0000000..c5f584c --- /dev/null +++ b/src/app/shared/components/toggle/toggle.variants.ts @@ -0,0 +1,23 @@ +import { cva, VariantProps } from 'class-variance-authority'; + +export const toggleVariants = cva( + 'inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground', + { + variants: { + zType: { + default: 'bg-transparent', + outline: 'border border-input bg-transparent hover:bg-accent hover:text-accent-foreground', + }, + zSize: { + sm: 'h-8 px-2', + md: 'h-9 px-3', + lg: 'h-10 px-3', + }, + }, + defaultVariants: { + zType: 'default', + zSize: 'md', + }, + }, +); +export type ZardToggleVariants = VariantProps; \ No newline at end of file diff --git a/src/app/shared/components/tooltip/tooltip-positions.ts b/src/app/shared/components/tooltip/tooltip-positions.ts new file mode 100644 index 0000000..9560cab --- /dev/null +++ b/src/app/shared/components/tooltip/tooltip-positions.ts @@ -0,0 +1,34 @@ +import { ConnectedPosition } from '@angular/cdk/overlay'; + +export const TOOLTIP_POSITIONS_MAP: { [key: string]: ConnectedPosition } = { + top: { + originX: 'center', + originY: 'top', + overlayX: 'center', + overlayY: 'bottom', + offsetY: -8, + }, + bottom: { + originX: 'center', + originY: 'bottom', + overlayX: 'center', + overlayY: 'top', + offsetY: 8, + }, + left: { + originX: 'start', + originY: 'center', + overlayX: 'end', + overlayY: 'center', + offsetX: -8, + }, + right: { + originX: 'end', + originY: 'center', + overlayX: 'start', + overlayY: 'center', + offsetX: 8, + }, +}; + +export type ZardTooltipPositions = 'top' | 'bottom' | 'left' | 'right'; \ No newline at end of file diff --git a/src/app/shared/components/tooltip/tooltip.ts b/src/app/shared/components/tooltip/tooltip.ts new file mode 100644 index 0000000..93d9b34 --- /dev/null +++ b/src/app/shared/components/tooltip/tooltip.ts @@ -0,0 +1,194 @@ +import { merge, Subject, take, takeUntil } from 'rxjs'; + +import { Overlay, OverlayModule, OverlayPositionBuilder, OverlayRef } from '@angular/cdk/overlay'; +import { ComponentPortal } from '@angular/cdk/portal'; +import { isPlatformBrowser } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + ComponentRef, + computed, + Directive, + ElementRef, + inject, + input, + NgModule, + OnDestroy, + OnInit, + output, + PLATFORM_ID, + Renderer2, + signal, +} from '@angular/core'; + +import { mergeClasses } from '@shared/utils/merge-classes'; +import { TOOLTIP_POSITIONS_MAP, ZardTooltipPositions } from './tooltip-positions'; +import { tooltipVariants } from './tooltip.variants'; + +export type ZardTooltipTriggers = 'click' | 'hover'; + +@Directive({ + selector: '[zTooltip]', + exportAs: 'zTooltip', + host: { + style: 'cursor: pointer', + }, +}) +export class ZardTooltipDirective implements OnInit, OnDestroy { + private readonly destroy$ = new Subject(); + private overlayPositionBuilder = inject(OverlayPositionBuilder); + private elementRef = inject(ElementRef); + private overlay = inject(Overlay); + private renderer = inject(Renderer2); + private platformId = inject(PLATFORM_ID); + + private overlayRef?: OverlayRef; + private componentRef?: ComponentRef; + private scrollListenerRef?: () => void; + + readonly zTooltip = input(null); + readonly zPosition = input('top'); + readonly zTrigger = input('hover'); + + readonly zOnShow = output(); + readonly zOnHide = output(); + + get nativeElement() { + return this.elementRef.nativeElement; + } + + get overlayElement() { + return this.componentRef?.instance.elementRef.nativeElement; + } + + ngOnInit() { + this.setTriggers(); + + if (isPlatformBrowser(this.platformId)) { + const positionStrategy = this.overlayPositionBuilder.flexibleConnectedTo(this.elementRef).withPositions([TOOLTIP_POSITIONS_MAP[this.zPosition()]]); + this.overlayRef = this.overlay.create({ positionStrategy }); + } + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + show() { + if (this.componentRef) return; + + const tooltipText = this.zTooltip(); + if (!tooltipText || tooltipText.trim() === '') return; + + const tooltipPortal = new ComponentPortal(ZardTooltipComponent); + this.componentRef = this.overlayRef?.attach(tooltipPortal); + if (!this.componentRef) return; + + this.componentRef.instance.setProps(tooltipText, this.zPosition(), this.zTrigger()); + this.componentRef.instance.state.set('opened'); + + this.componentRef.instance.onLoad$.pipe(take(1)).subscribe(() => { + this.zOnShow.emit(); + + switch (this.zTrigger()) { + case 'click': + if (!this.overlayRef) return; + + this.overlayRef + .outsidePointerEvents() + .pipe(takeUntil(merge(this.destroy$, this.overlayRef.detachments()))) + .subscribe(() => this.hide()); + break; + case 'hover': + this.renderer.listen( + this.elementRef.nativeElement, + 'mouseleave', + (event: Event) => { + event.preventDefault(); + this.hide(); + }, + { once: true }, + ); + break; + } + }); + + this.scrollListenerRef = this.renderer.listen(window, 'scroll', () => { + this.hide(0); + }); + } + + hide(animationDuration = 150) { + if (!this.componentRef) return; + + this.componentRef.instance.state.set('closed'); + + setTimeout(() => { + this.zOnHide.emit(); + + this.overlayRef?.detach(); + this.componentRef?.destroy(); + this.componentRef = undefined; + + if (this.scrollListenerRef) this.scrollListenerRef(); + }, animationDuration); + } + + private setTriggers() { + const showTrigger = this.zTrigger() === 'click' ? 'click' : 'mouseenter'; + + this.renderer.listen(this.elementRef.nativeElement, showTrigger, (event: Event) => { + event.preventDefault(); + this.show(); + }); + } +} + +@Component({ + selector: 'z-tooltip', + template: `{{ text() }}`, + host: { + '[class]': 'classes()', + '[attr.data-side]': 'position()', + '[attr.data-state]': 'state()', + }, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ZardTooltipComponent implements OnInit, OnDestroy { + private readonly destroy$ = new Subject(); + readonly elementRef = inject(ElementRef); + + protected position = signal('top'); + private trigger = signal('hover'); + protected text = signal(''); + + state = signal<'closed' | 'opened'>('closed'); + + private onLoadSubject$ = new Subject(); + onLoad$ = this.onLoadSubject$.asObservable(); + + protected readonly classes = computed(() => mergeClasses(tooltipVariants())); + + ngOnInit(): void { + this.onLoadSubject$.next(); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + this.onLoadSubject$.complete(); + } + + setProps(text: string | null, position: ZardTooltipPositions, trigger: ZardTooltipTriggers) { + if (text) this.text.set(text); + this.position.set(position); + this.trigger.set(trigger); + } +} + +@NgModule({ + imports: [OverlayModule, ZardTooltipComponent, ZardTooltipDirective], + exports: [ZardTooltipComponent, ZardTooltipDirective], +}) +export class ZardTooltipModule {} \ No newline at end of file diff --git a/src/app/shared/components/tooltip/tooltip.variants.ts b/src/app/shared/components/tooltip/tooltip.variants.ts new file mode 100644 index 0000000..7837cc4 --- /dev/null +++ b/src/app/shared/components/tooltip/tooltip.variants.ts @@ -0,0 +1,6 @@ +import { cva, VariantProps } from 'class-variance-authority'; + +export const tooltipVariants = cva( + 'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]', +); +export type ZardTooltipVariants = VariantProps; \ No newline at end of file diff --git a/src/app/shared/directives/tooltip.spec.ts b/src/app/shared/directives/tooltip.spec.ts new file mode 100644 index 0000000..93fa15a --- /dev/null +++ b/src/app/shared/directives/tooltip.spec.ts @@ -0,0 +1,8 @@ +import { Tooltip } from './tooltip'; + +describe('Tooltip', () => { + it('should create an instance', () => { + const directive = new Tooltip(); + expect(directive).toBeTruthy(); + }); +}); diff --git a/src/app/shared/directives/tooltip.ts b/src/app/shared/directives/tooltip.ts new file mode 100644 index 0000000..1819595 --- /dev/null +++ b/src/app/shared/directives/tooltip.ts @@ -0,0 +1,132 @@ +import { Directive, ElementRef, HostListener, Input, Renderer2 } from '@angular/core'; + +@Directive({ + selector: '[appTooltip]', +}) +export class Tooltip { + @Input('appTooltip') text: string | null = null; + @Input() tooltipDisabled = false; + @Input() tooltipPlacement: 'right' | 'left' | 'top' | 'bottom' = 'right'; + @Input() tooltipOffset = 10; + + private tooltipEl?: HTMLElement; + private removeFns: Array<() => void> = []; + + constructor(private el: ElementRef, private r: Renderer2) {} + + @HostListener('mouseenter') + onEnter() { + if (!this.text || this.tooltipDisabled) return; + this.create(); + this.position(); + } + + @HostListener('mouseleave') + onLeave() { + this.destroy(); + } + + @HostListener('focus') + onFocus() { + if (!this.text || this.tooltipDisabled) return; + this.create(); + this.position(); + } + + @HostListener('blur') + onBlur() { + this.destroy(); + } + + @HostListener('window:scroll') + onScroll() { + if (this.tooltipEl) this.position(); + } + + @HostListener('window:resize') + onResize() { + if (this.tooltipEl) this.position(); + } + + private create() { + if (this.tooltipEl) return; + + const el = this.r.createElement('div'); + this.r.setStyle(el, 'position', 'fixed'); + this.r.setStyle(el, 'zIndex', '999999'); + this.r.setStyle(el, 'pointerEvents', 'none'); + this.r.setStyle(el, 'padding', '6px 10px'); + this.r.setStyle(el, 'fontSize', '12px'); + this.r.setStyle(el, 'lineHeight', '1'); + this.r.setStyle(el, 'borderRadius', '8px'); + this.r.setStyle(el, 'background', 'rgba(0,0,0,0.92)'); + this.r.setStyle(el, 'color', '#fff'); + this.r.setStyle(el, 'boxShadow', '0 8px 24px rgba(0,0,0,.28)'); + this.r.setStyle(el, 'transform', 'translate3d(0,0,0)'); + this.r.setStyle(el, 'transition', 'opacity .15s ease'); + this.r.setStyle(el, 'opacity', '0'); + this.r.setProperty(el, 'innerText', this.text ?? ''); + + document.body.appendChild(el); + // fade in next tick + requestAnimationFrame(() => this.r.setStyle(el, 'opacity', '1')); + this.tooltipEl = el; + + // close on escape + const keyFn = this.r.listen('window', 'keydown', (e: KeyboardEvent) => { + if (e.key === 'Escape') this.destroy(); + }); + this.removeFns.push(keyFn); + } + + private position() { + if (!this.tooltipEl) return; + const host = this.el.nativeElement.getBoundingClientRect(); + const tip = this.tooltipEl.getBoundingClientRect(); + const offset = this.tooltipOffset; + + let top = host.top + host.height / 2 - tip.height / 2; + let left = host.right + offset; + + switch (this.tooltipPlacement) { + case 'left': + left = host.left - tip.width - offset; + break; + case 'top': + left = host.left + host.width / 2 - tip.width / 2; + top = host.top - tip.height - offset; + break; + case 'bottom': + left = host.left + host.width / 2 - tip.width / 2; + top = host.bottom + offset; + break; + default: // right + left = host.right + offset; + break; + } + + // viewport guard + const vw = window.innerWidth; + const vh = window.innerHeight; + if (left + tip.width > vw - 8) left = vw - tip.width - 8; + if (left < 8) left = 8; + if (top + tip.height > vh - 8) top = vh - tip.height - 8; + if (top < 8) top = 8; + + this.r.setStyle(this.tooltipEl, 'top', `${top}px`); + this.r.setStyle(this.tooltipEl, 'left', `${left}px`); + } + + private destroy() { + if (this.tooltipEl?.parentNode) { + this.tooltipEl.parentNode.removeChild(this.tooltipEl); + } + this.tooltipEl = undefined; + this.removeFns.forEach((fn) => fn()); + this.removeFns = []; + } + + ngOnDestroy() { + this.destroy(); + } +} diff --git a/src/app/shared/forms/agent-form/agent-form.html b/src/app/shared/forms/agent-form/agent-form.html new file mode 100644 index 0000000..90176a8 --- /dev/null +++ b/src/app/shared/forms/agent-form/agent-form.html @@ -0,0 +1,91 @@ +
    +
    + + +
    +
    + + +
    +
    + + + +
    + + Actif + Inactif + Suspendu + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + + +
    +
    + + +
    +
    + + +
    +
    + + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + + +
    + + @for (l of limits; track l.id) { + {{ l.nom }} + } + +
    +
    +
    + +
    + Annuler + Enregistrer +
    +
    + + diff --git a/src/app/shared/forms/agent-form/agent-form.ts b/src/app/shared/forms/agent-form/agent-form.ts new file mode 100644 index 0000000..4a8cdb1 --- /dev/null +++ b/src/app/shared/forms/agent-form/agent-form.ts @@ -0,0 +1,87 @@ +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { ZardFormModule } from '@shared/components/form/form.module'; +import { ZardInputDirective } from '@shared/components/input/input.directive'; +import { ZardSelectComponent } from '@shared/components/select/select.component'; +import { ZardSelectItemComponent } from '@shared/components/select/select-item.component'; +import { ZardButtonComponent } from '@shared/components/button/button.component'; +import { Agent, AgentStatus } from 'src/app/core/interfaces/agent'; +import { AgentLimit } from 'src/app/core/interfaces/agent-limit'; +import { AgentLimitService } from 'src/app/core/services/agent-limit'; + +@Component({ + selector: 'app-agent-form', + standalone: true, + templateUrl: './agent-form.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, ReactiveFormsModule, ZardFormModule, ZardInputDirective, ZardSelectComponent, ZardSelectItemComponent, ZardButtonComponent], +}) +export class AgentForm { + @Output() save = new EventEmitter(); + @Output() cancel = new EventEmitter(); + + limits: AgentLimit[] = []; + + private _value?: Agent; + @Input() set value(v: Agent | undefined) { this._value = v; this.hydrateFromValue(v); } + get value(): Agent | undefined { return this._value; } + + form: FormGroup; + submitted = false; + + constructor(private fb: FormBuilder, private limitService: AgentLimitService) { + this.form = this.fb.group({ + code: ['', Validators.required], + profile: ['', Validators.required], + statut: ['ACTIF' as AgentStatus, Validators.required], + zone: [''], + kiosk: [''], + fonction: [''], + dateEmbauche: [''], + + nom: ['', Validators.required], + prenom: ['', Validators.required], + phone: ['', [Validators.required, Validators.minLength(6)]], + + limiteInferieure: [0, [Validators.min(0)]], + limiteSuperieure: [0, [Validators.min(0)]], + limiteParTransaction: [0, [Validators.min(0)]], + limiteMinAirtime: [0, [Validators.min(0)]], + limiteMaxAirtime: [0, [Validators.min(0)]], + maxPeripheriques: [0, [Validators.min(0)]], + + limitId: ['', Validators.required], + }); + + this.limitService + .list({ page: 1, perPage: 100, search: '', sortKey: 'nom', sortDir: 'asc' } as any) + .subscribe((res) => (this.limits = res.data)); + } + + error(control: string): string { + const e = this.form.get(control)?.errors; if (!e) return ''; if (e['required']) return 'Requis'; return ''; + } + + private hydrateFromValue(v?: Agent) { + if (!v) { + this.form.reset({ + code: '', profile: '', statut: 'ACTIF', zone: '', kiosk: '', fonction: '', dateEmbauche: '', nom: '', prenom: '', phone: '', limiteInferieure: 0, limiteSuperieure: 0, limiteParTransaction: 0, limiteMinAirtime: 0, limiteMaxAirtime: 0, maxPeripheriques: 0, limitId: '', + }); + return; + } + this.form.reset({ + code: v.code, profile: v.profile, statut: v.statut, zone: v.zone ?? '', kiosk: v.kiosk ?? '', fonction: v.fonction ?? '', dateEmbauche: v.dateEmbauche ?? '', nom: v.nom, prenom: v.prenom, phone: v.phone, limiteInferieure: v.limiteInferieure ?? 0, limiteSuperieure: v.limiteSuperieure ?? 0, limiteParTransaction: v.limiteParTransaction ?? 0, limiteMinAirtime: v.limiteMinAirtime ?? 0, limiteMaxAirtime: v.limiteMaxAirtime ?? 0, maxPeripheriques: v.maxPeripheriques ?? 0, limitId: v.limitId ?? '', + }); + } + + onSubmit() { + this.submitted = true; + if (this.form.invalid) { this.form.markAllAsTouched(); return; } + const raw = this.form.getRawValue() as any; + const payload: Agent = { id: this.value?.id ?? '', code: raw.code, profile: raw.profile, statut: raw.statut, zone: raw.zone, kiosk: raw.kiosk, fonction: raw.fonction, dateEmbauche: raw.dateEmbauche, nom: raw.nom, prenom: raw.prenom, phone: raw.phone, limiteInferieure: +raw.limiteInferieure, limiteSuperieure: +raw.limiteSuperieure, limiteParTransaction: +raw.limiteParTransaction, limiteMinAirtime: +raw.limiteMinAirtime, limiteMaxAirtime: +raw.limiteMaxAirtime, maxPeripheriques: +raw.maxPeripheriques, limitId: raw.limitId }; + this.save.emit(payload); + } +} + + diff --git a/src/app/shared/forms/agent-full-form/agent-full-form.html b/src/app/shared/forms/agent-full-form/agent-full-form.html new file mode 100644 index 0000000..f414cc6 --- /dev/null +++ b/src/app/shared/forms/agent-full-form/agent-full-form.html @@ -0,0 +1,368 @@ +
    +
    + +
    Informations Emploi
    +
    + +
    +
    + +
    +
    + +
    + +
    + +
    + ActifInactifSuspendu +
    + +
    + +
    + +
    + +
    +
    +
    + + +
    Informations Personnelles
    +
    + +
    +
    + +
    +
    + +
    + +
    + +
    + +
    + +
    + +
    + +
    +
    +
    +
    + + +
    Paramètres de connexion
    +
    + +
    +
    + +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    + + @for (l of limits; track l.id) { + + {{ l.nom }} + @if (l.isDefault) { + (Par défaut) + } @if (l.actif) { + • Actif + } + + } + +
    + @if (selectedLimit) { +
    +
    {{ selectedLimit.nom }}
    +
    + @if (selectedLimit.isDefault) { + Limite par défaut + } @if (selectedLimit.actif) { + Contrôle actif + } @else { + Contrôle inactif + } +
    +
    +
    Min Bet: {{ (selectedLimit.betMin ?? 0).toLocaleString('fr-FR') }}
    +
    Max Bet: {{ (selectedLimit.betMax ?? 0).toLocaleString('fr-FR') }}
    +
    Max Bet (tx): {{ (selectedLimit.maxBet ?? 0).toLocaleString('fr-FR') }}
    +
    +
    + } +
    +
    +
    +
    + +
    + +
    Informations Légales
    +
    + +
    + +
    + +
    + +
    + +
    + +
    + +
    + +
    + +
    +
    +
    + + +
    +
    Membres de famille
    + + Ajouter un membre + +
    +
    + @for (fm of famille.controls; track $index; let i = $index) { +
    +
    +
    +
    + +
    +
    +
    Membre #{{ i + 1 }}
    +
    Informations du membre
    +
    +
    + + + +
    +
    + + +
    + +
    +
    + + +
    + + Conjoint(e) + Enfant + Parent + Autre + +
    +
    + + +
    + + Masculin + Féminin + +
    +
    + + +
    + +
    +
    +
    +
    + } @empty { +
    +
    +
    + +
    +
    +
    Aucun membre de famille
    +
    + Ajoutez les membres de la famille de l'agent +
    + + Ajouter le premier membre + +
    +
    +
    + } +
    +
    +
    + + +
    +
    Assignation TPE (actifs)
    + Gérer +
    +
    + @for (id of tpeArray.value; track id) { + + {{ tpeLabel(id) }} + + + } @empty { + Aucun TPE assigné + } +
    +
    + + + + + + @if (!isSelectedTpe(row.id)) { + + } @else { + + } + + + +
    + Terminer +
    +
    +
    diff --git a/src/app/shared/forms/agent-full-form/agent-full-form.ts b/src/app/shared/forms/agent-full-form/agent-full-form.ts new file mode 100644 index 0000000..c705921 --- /dev/null +++ b/src/app/shared/forms/agent-full-form/agent-full-form.ts @@ -0,0 +1,417 @@ +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; +import { FormArray, FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { ZardFormModule } from '@shared/components/form/form.module'; +import { ZardInputDirective } from '@shared/components/input/input.directive'; +import { ZardSelectComponent } from '@shared/components/select/select.component'; +import { ZardSelectItemComponent } from '@shared/components/select/select-item.component'; +import { ZardButtonComponent } from '@shared/components/button/button.component'; +import { ZardCardComponent } from '@shared/components/card/card.component'; +import { DataTable, SortState, TableColumn } from '@shared/components/data-table/data-table'; +import { Paginator } from '@shared/components/paginator/paginator'; +import { SearchBar } from '@shared/components/search-bar/search-bar'; +import { Modal } from '@shared/components/modal/modal'; +import { Agent, AgentFamilyMember } from 'src/app/core/interfaces/agent'; +import { AgentLimit } from 'src/app/core/interfaces/agent-limit'; +import { AgentLimitService } from 'src/app/core/services/agent-limit'; +import { AgentFamilyMemberService } from 'src/app/core/services/agent-family-member'; +import { TpeDevice } from 'src/app/core/interfaces/tpe'; +import { TpeService } from 'src/app/core/services/tpe'; + +@Component({ + selector: 'app-agent-full-form', + standalone: true, + templateUrl: './agent-full-form.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + ReactiveFormsModule, + ZardFormModule, + ZardInputDirective, + ZardSelectComponent, + ZardSelectItemComponent, + ZardButtonComponent, + ZardCardComponent, + DataTable, + Paginator, + SearchBar, + Modal, + ], +}) +export class AgentFullForm { + @Output() save = new EventEmitter(); + @Output() cancel = new EventEmitter(); + + limits: AgentLimit[] = []; + selectedLimit: AgentLimit | null = null; + tpes: TpeDevice[] = []; + // TPE picker state + assignModalOpen = false; + tpeRows: TpeDevice[] = []; + tpeTotal = 0; + tpePage = 1; + tpePerPage = 10; + tpeSearch = ''; + tpeCols: TableColumn[] = [ + { key: 'imei', label: 'IMEI', sortable: true }, + { key: 'serial', label: 'N° Série', sortable: true }, + { key: 'marque', label: 'Marque', sortable: true }, + { key: 'modele', label: 'Modèle', sortable: true }, + { key: 'type', label: 'Type', sortable: true }, + { key: 'statut', label: 'Statut', sortable: true }, + ]; + + private _value?: Agent; + @Input() set value(v: Agent | undefined) { this._value = v; this.hydrate(v); } + get value(): Agent | undefined { return this._value; } + + form: FormGroup; + submitted = false; + + constructor( + private fb: FormBuilder, + private limitService: AgentLimitService, + private tpeService: TpeService, + private familyMemberService: AgentFamilyMemberService + ) { + this.form = this.fb.group({ + // Emploi + code: ['', Validators.required], profile: ['', Validators.required], principalCode: [''], caisseProfile: [''], statut: ['ACTIF', Validators.required], zone: [''], kiosk: [''], fonction: [''], dateEmbauche: [''], + // Perso + nom: ['', Validators.required], prenom: ['', Validators.required], autresNoms: [''], dateNaissance: [''], lieuNaissance: [''], adresse: [''], ville: [''], autoriserAides: [false], maxPeripheriques: [0, [Validators.min(0)]], + // Connexion & limites + phone: ['', Validators.required], pin: [''], limiteInferieure: [0], limiteSuperieure: [0], limiteParTransaction: [0], limiteMinAirtime: [0], limiteMaxAirtime: [0], limitId: ['', Validators.required], + // Légales + nationalite: [''], cni: [''], cniDelivreeLe: [''], cniDelivreeA: [''], residence: [''], autreAdresse1: [''], statutMarital: [''], epoux: [''], autreTelephone: [''], + // Famille + famille: this.fb.array([]), + // TPE (stored as IDs in form, converted to full objects on submit) + tpeIds: this.fb.array([]), + }); + + this.limitService.list({ page: 1, perPage: 100, search: '', sortKey: 'nom', sortDir: 'asc' } as any).subscribe((res) => { + this.limits = res.data; + + // Find default limit + const defaultLimit = this.limits.find((l) => l.isDefault); + + // Watch for limitId changes to update selectedLimit + this.form.get('limitId')?.valueChanges.subscribe((limitId) => { + if (limitId) { + this.selectedLimit = this.limits.find((l) => l.id === limitId) || null; + } else { + this.selectedLimit = null; + } + }); + + // Set initial selectedLimit if form already has a value + const currentLimitId = this.form.get('limitId')?.value; + if (currentLimitId) { + this.selectedLimit = this.limits.find((l) => l.id === currentLimitId) || null; + } else if (defaultLimit && !this.value?.id) { + // If no limit is set and we're creating a new agent, assign the default limit + this.form.patchValue({ limitId: defaultLimit.id }); + this.selectedLimit = defaultLimit; + } + }); + // initial fetch page for TPE modal + this.fetchTpes(); + } + + get famille(): FormArray { return this.form.get('famille') as FormArray; } + get tpeArray(): FormArray { return this.form.get('tpeIds') as FormArray; } + + addFamily() { + this.famille.push( + this.fb.group({ + id: [''], // Will be set when saved + agentId: [this.value?.id || ''], + nom: ['', Validators.required], + statut: [''], + dateNaissance: [''], + sexe: [''], + }) + ); + } + removeFamily(i: number) { + const familyGroup = this.famille.at(i); + const familyId = familyGroup?.get('id')?.value; + // If it has an ID, it means it's already saved - we'll delete it when saving the agent + // For now, just remove from form + this.famille.removeAt(i); + } + + private hydrate(v?: Agent) { + this.famille.clear(); + this.tpeArray.clear(); + this.selectedLimit = null; + + if (!v) { + // Find default limit to assign it automatically + const defaultLimit = this.limits.find((l) => l.isDefault); + const defaultLimitId = defaultLimit?.id || ''; + + this.form.reset({ + code: '', + profile: '', + principalCode: '', + caisseProfile: '', + statut: 'ACTIF', + zone: '', + kiosk: '', + fonction: '', + dateEmbauche: '', + nom: '', + prenom: '', + autresNoms: '', + dateNaissance: '', + lieuNaissance: '', + adresse: '', + ville: '', + autoriserAides: false, + maxPeripheriques: 0, + phone: '', + pin: '', + limiteInferieure: 0, + limiteSuperieure: 0, + limiteParTransaction: 0, + limiteMinAirtime: 0, + limiteMaxAirtime: 0, + limitId: defaultLimitId, + nationalite: '', + cni: '', + cniDelivreeLe: '', + cniDelivreeA: '', + residence: '', + autreAdresse1: '', + statutMarital: '', + epoux: '', + autreTelephone: '', + }); + + // Set selectedLimit if default limit exists + if (defaultLimit) { + this.selectedLimit = defaultLimit; + } + return; + } + + this.form.patchValue(v); + + // Set selected limit if limitId exists + if (v.limitId) { + this.selectedLimit = this.limits.find((l) => l.id === v.limitId) || null; + } + + // Load family members separately via AgentFamilyMemberService + if (v.id) { + this.familyMemberService.getByAgentId(v.id).subscribe({ + next: (members) => { + members.forEach((m) => { + this.famille.push( + this.fb.group({ + id: [m.id || ''], + agentId: [m.agentId || v.id], + nom: [m.nom, Validators.required], + statut: [m.statut || ''], + dateNaissance: [m.dateNaissance || ''], + sexe: [m.sexe || ''], + }) + ); + }); + }, + error: () => { + // If error, just continue without family members + }, + }); + } + + // Load assigned TPE IDs from tpes array + (v.tpes ?? []).forEach((tpe) => this.tpeArray.push(this.fb.control(tpe.id))); + } + + onToggleTpe(id: string, checked: boolean) { + const idx = this.tpeArray.value.indexOf(id); + if (checked && idx === -1) this.tpeArray.push(this.fb.control(id)); + if (!checked && idx !== -1) this.tpeArray.removeAt(idx); + } + + openAssignTpe() { + this.assignModalOpen = true; + this.fetchTpes(); + } + closeAssignTpe() { + this.assignModalOpen = false; + // Refresh TPE list to show current assignments + this.fetchTpes(); + } + onTpeSearch(q: string) { this.tpeSearch = q; this.tpePage = 1; this.fetchTpes(); } + onTpePageChange(p: number) { this.tpePage = p; this.fetchTpes(); } + onTpePerPageChange(pp: number) { this.tpePerPage = pp; this.fetchTpes(); } + isSelectedTpe(id: string) { return (this.tpeArray.value as string[]).includes(id); } + + fetchTpes() { + this.tpeService + .list({ page: this.tpePage, perPage: this.tpePerPage, search: this.tpeSearch, sortKey: 'imei', sortDir: 'asc' } as any) + .subscribe((res) => { + // Only show VALIDE TPEs that are either not assigned or assigned to this agent + const currentAgentId = this.value?.id; + this.tpeRows = res.data.filter((t) => { + if (t.statut !== 'VALIDE') return false; + // If TPE is assigned but to this agent, show it + if (t.assigne && currentAgentId) { + // We need to check if this TPE is assigned to this agent + // For now, show all VALIDE TPEs - the backend should handle assignment logic + return true; + } + // Show unassigned TPEs + return !t.assigne; + }); + this.tpeTotal = this.tpeRows.length; + }); + } + + tpeLabel(id: string): string { + const list = [...(this.tpeRows ?? []), ...(this.tpes ?? [])]; + const found = list.find((t) => t.id === id); + if (found) { + return `${found.imei} (${found.marque} ${found.modele})`; + } + // Try to load from service if not in current list + return id; + } + + // === Validation helpers === + error(control: string): string { + // Handle nested paths like "famille.0.nom" + const parts = control.split('.'); + let c: any = this.form; + for (const part of parts) { + if (c.get) { + c = c.get(part); + } else if (c.controls && c.controls[part]) { + c = c.controls[part]; + } else { + return ''; + } + if (!c) return ''; + } + + const invalid = c.invalid && (c.touched || this.submitted); + if (!invalid) return ''; + const e = c.errors || {}; + if (e['required']) return 'Ce champ est obligatoire'; + if (e['min']) return `Valeur minimale: ${e['min'].min}`; + if (e['minlength']) return `Minimum ${e['minlength'].requiredLength} caractères`; + return 'Valeur invalide'; + } + + onSubmit() { + this.submitted = true; + if (this.form.invalid) { + this.form.markAllAsTouched(); + return; + } + const raw = this.form.getRawValue() as any; + + // Convert TPE IDs to full TPE objects + const tpeIds = [...this.tpeArray.value] as string[]; + const tpes: TpeDevice[] = tpeIds + .map((id) => { + // Try to find in tpeRows first (current modal list) + const found = this.tpeRows.find((t) => t.id === id); + if (found) return found; + // Try to find in existing tpes (from value) + const existing = this.value?.tpes?.find((t) => t.id === id); + if (existing) return existing; + // If not found, create a minimal TPE object (backend will fill in details) + // This shouldn't happen in normal flow, but handle gracefully + return null; + }) + .filter((t): t is TpeDevice => t !== null); + + // Prepare agent payload (without famille - family members are handled separately) + const payload: Agent = { + ...(this.value ?? {}), + id: this.value?.id || '', + ...raw, + tpes: tpes, + } as Agent; + + // Remove tpeIds from payload (it's not part of Agent interface) + delete (payload as any).tpeIds; + + // Emit the agent payload first + // Family members will be saved separately in the parent component + this.save.emit(payload); + } + + // Get family members data for saving + getFamilyMembersData(): Array<{ id?: string; agentId: string; nom: string; statut?: string; dateNaissance?: string; sexe?: string }> { + return this.famille.value.map((fm: any) => ({ + id: fm.id || undefined, + agentId: fm.agentId || this.value?.id || '', + nom: fm.nom, + statut: fm.statut || undefined, + dateNaissance: fm.dateNaissance || undefined, + sexe: fm.sexe || undefined, + })); + } + + resetForm() { + this._value = undefined; + this.selectedLimit = null; + this.famille.clear(); + this.tpeArray.clear(); + + // Find default limit to assign it automatically + const defaultLimit = this.limits.find((l) => l.isDefault); + const defaultLimitId = defaultLimit?.id || ''; + + this.form.reset({ + code: '', + profile: '', + principalCode: '', + caisseProfile: '', + statut: 'ACTIF', + zone: '', + kiosk: '', + fonction: '', + dateEmbauche: '', + nom: '', + prenom: '', + autresNoms: '', + dateNaissance: '', + lieuNaissance: '', + adresse: '', + ville: '', + autoriserAides: false, + maxPeripheriques: 0, + phone: '', + pin: '', + limiteInferieure: 0, + limiteSuperieure: 0, + limiteParTransaction: 0, + limiteMinAirtime: 0, + limiteMaxAirtime: 0, + limitId: defaultLimitId, + nationalite: '', + cni: '', + cniDelivreeLe: '', + cniDelivreeA: '', + residence: '', + autreAdresse1: '', + statutMarital: '', + epoux: '', + autreTelephone: '', + }); + + // Set selectedLimit if default limit exists + if (defaultLimit) { + this.selectedLimit = defaultLimit; + } + this.submitted = false; + } +} + + diff --git a/src/app/shared/forms/course-form/course-form.css b/src/app/shared/forms/course-form/course-form.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/shared/forms/course-form/course-form.html b/src/app/shared/forms/course-form/course-form.html new file mode 100644 index 0000000..5e091be --- /dev/null +++ b/src/app/shared/forms/course-form/course-form.html @@ -0,0 +1,393 @@ +
    + +
    +
    +
    + 🏇 +
    +
    +

    Informations principales

    +

    Détails essentiels de la course

    +
    +
    + +
    + + + + + @for (t of courseTypes; track t.value) { + {{ t.label }} + } + + + @if (isInvalid('type')) { +

    + {{ errorMessage('type') }} +

    + } +
    + + + + + + @if (isInvalid('numero')) { +

    + {{ errorMessage('numero') }} +

    + } +
    +
    + + + + + + @if (isInvalid('nom')) { +

    + {{ errorMessage('nom') }} +

    + } +
    +
    +
    +
    + + +
    +
    +
    + 🕓 +
    +
    +

    Dates & heures

    +

    + Renseignez les horaires clés de la course et des périodes de paris +

    +
    +
    + +
    + + +
    + + +
    + @if(isInvalid('dateDepartCourse')) { +

    + {{ errorMessage('dateDepartCourse') }} +

    + } +
    + +
    + + +
    + + +
    +
    + + + +
    + + +
    +
    +
    +
    +
    + + +
    +
    +
    + 📅 +
    +
    +

    Réunion associée

    +

    + Sélectionnez la réunion concernée ou créez-en une nouvelle +

    +
    +
    + + + + + + @if (loadingReunions()) { + Chargement des réunions... + } @else { @for (r of filteredReunions(); track r.id) { + + {{ r.nom }} – {{ r.hippodrome.nom }} ({{ r.hippodrome.ville }}) + + } } + + + +
    + + +
    +
    +
    + ⚙️ +
    +
    +

    Détails techniques

    +

    Caractéristiques de la course

    +
    +
    + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    + + +
    +
    +
    + 📊 +
    +
    +

    Statut et numérotation

    +
    +
    + +
    + + + + + @for (s of courseStatus; track s.value) { + {{ s.label }} + } + + + + + + + + + + +
    +
    + + +
    +
    +
    + 👤 +
    +
    +

    Informations de suivi

    +

    Traçabilité et validation

    +
    +
    + +
    + + + + + + + + + + + + + +
    +
    +
    diff --git a/src/app/shared/forms/course-form/course-form.spec.ts b/src/app/shared/forms/course-form/course-form.spec.ts new file mode 100644 index 0000000..8090cd5 --- /dev/null +++ b/src/app/shared/forms/course-form/course-form.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CourseForm } from './course-form'; + +describe('CourseForm', () => { + let component: CourseForm; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CourseForm] + }) + .compileComponents(); + + fixture = TestBed.createComponent(CourseForm); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/forms/course-form/course-form.ts b/src/app/shared/forms/course-form/course-form.ts new file mode 100644 index 0000000..fd4a291 --- /dev/null +++ b/src/app/shared/forms/course-form/course-form.ts @@ -0,0 +1,447 @@ +import { CommonModule } from '@angular/common'; +import { + AfterViewInit, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + EventEmitter, + Input, + Output, + computed, + signal, + OnInit, + OnDestroy, + effect, +} from '@angular/core'; +import { + FormBuilder, + FormControl, + FormGroup, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { ZardFormModule } from '@shared/components/form/form.module'; +import { ZardInputDirective } from '@shared/components/input/input.directive'; +import { ZardSelectItemComponent } from '@shared/components/select/select-item.component'; +import { ZardSelectComponent } from '@shared/components/select/select.component'; +import { Course, CourseStatut, CourseType } from 'src/app/core/interfaces/course'; +import { Reunion } from 'src/app/core/interfaces/reunion'; +import { ReunionService } from 'src/app/core/services/reunion'; +import { Subscription } from 'rxjs'; + +@Component({ + selector: 'app-course-form', + standalone: true, + templateUrl: './course-form.html', + styleUrls: ['./course-form.css'], + imports: [ + CommonModule, + ReactiveFormsModule, + ZardFormModule, + ZardInputDirective, + ZardSelectComponent, + ZardSelectItemComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CourseForm implements OnInit, AfterViewInit, OnDestroy { + @Output() save = new EventEmitter(); + @Output() cancel = new EventEmitter(); + + private _value?: Course; + @Input() set value(v: Course | undefined) { + this._value = v; + } + get value(): Course | undefined { + return this._value; + } + + form: FormGroup; + submitted = false; + + reunions = signal([]); + loadingReunions = signal(false); + searchQuery = signal(''); + selectedReunionLabel = signal(''); + initializing = signal(false); + private subs = new Subscription(); + + constructor( + private fb: FormBuilder, + private reunionService: ReunionService, + private cdr: ChangeDetectorRef + ) { + this.form = this.fb.group({ + type: ['', Validators.required], + numero: [null, [Validators.required, Validators.min(1)]], + nom: ['', [Validators.required, Validators.minLength(3)]], + dateDepartCourse: ['', Validators.required], + dateDebutParis: ['', Validators.required], + dateFinParis: ['', Validators.required], + reunionId: new FormControl(null, Validators.required), + reunionCourse: [1], + particularite: [''], + partants: [null, [Validators.required, Validators.min(1)]], + distance: [''], + condition: [''], + statut: [CourseStatut.PROGRAMMEE, Validators.required], + createdBy: ['agent-001'], + validatedBy: [''], + }); + + // Watch for reunionId changes to update the label + // This handles BOTH create and edit modes when user selects a reunion + this.subs.add( + this.form.get('reunionId')?.valueChanges.subscribe((reunionId) => { + // Skip if we're initializing (to avoid interfering with form initialization) + if (this.initializing()) { + return; + } + + // Handle empty/null values + if (!reunionId || reunionId === '') { + this.selectedReunionLabel.set(''); + return; + } + + // Update label when user selects a reunion (both create and edit) + if (this.reunions().length > 0) { + // Try both string and direct comparison since form control might convert types + const matchingReunion = this.reunions().find( + (r) => String(r.id) === String(reunionId) || r.id === reunionId + ); + if (matchingReunion) { + const reunionLabel = `${matchingReunion.nom} – ${matchingReunion.hippodrome.nom} (${matchingReunion.hippodrome.ville})`; + this.selectedReunionLabel.set(reunionLabel); + // Force change detection to ensure the label is displayed immediately + this.cdr.markForCheck(); + } else { + this.selectedReunionLabel.set(''); + } + } else { + this.selectedReunionLabel.set(''); + } + }) || new Subscription() + ); + + // Effect to handle form initialization when value or reunions change + effect(() => { + const v = this.value; + const reunionsList = this.reunions(); + const isLoading = this.loadingReunions(); + + // CRITICAL: Enable/disable reunion control based on loading state + // This is the most important part for create mode to work + const reunionControl = this.form.get('reunionId'); + if (isLoading) { + reunionControl?.disable({ emitEvent: false }); + } else { + reunionControl?.enable({ emitEvent: false }); + // Force change detection after enabling to ensure the select is clickable + this.cdr.markForCheck(); + } + + // Handle edit mode (value is defined) - populate form with course data + if (v !== null && v !== undefined) { + this.initializing.set(true); + + let reunionId: string | null = null; + let reunionLabel: string = ''; + if (v.reunion?.id) { + reunionId = String(v.reunion.id); + if (reunionsList.length > 0) { + const matchingReunion = reunionsList.find((r) => String(r.id) === reunionId); + if (matchingReunion) { + reunionLabel = `${matchingReunion.nom} – ${matchingReunion.hippodrome.nom} (${matchingReunion.hippodrome.ville})`; + this.selectedReunionLabel.set(reunionLabel); + } else { + reunionId = null; + this.selectedReunionLabel.set(''); + } + } else { + if (v.reunion.nom) { + reunionLabel = `${v.reunion.nom} – ${v.reunion.hippodrome?.nom || ''} (${ + v.reunion.hippodrome?.ville || '' + })`; + this.selectedReunionLabel.set(reunionLabel); + } + reunionId = null; + } + } else { + this.selectedReunionLabel.set(''); + } + + this.form.patchValue( + { + type: v.type ?? '', + numero: v.numero ?? null, + nom: v.nom ?? '', + dateDepartCourse: v.dateDepartCourse ?? '', + dateDebutParis: v.dateDebutParis ?? v.dateDepartCourse ?? '', + dateFinParis: v.dateFinParis ?? v.dateDepartCourse ?? '', + reunionId, + reunionCourse: (v as any).reunionCourse ?? v.numero ?? 1, + particularite: (v as any).particularite ?? '', + partants: (v as any).partants ?? null, + distance: (v as any).distance ?? null, + condition: (v as any).condition ?? '', + statut: v.statut ?? CourseStatut.PROGRAMMEE, + createdBy: (v as any).createdBy ?? 'agent-001', + validatedBy: (v as any).validatedBy ?? '', + }, + { emitEvent: false } + ); + + this.form.markAsPristine(); + this.form.markAsUntouched(); + queueMicrotask(() => this.initializing.set(false)); + } + // Create mode (v === null or undefined) - DO NOTHING except ensure control is enabled + // The form already has default values, just let the user fill it + // The valueChanges subscription will handle label updates when user selects + + // When reunions finish loading - re-apply value if we have one (for edit mode) + else if (reunionsList.length > 0 && !isLoading) { + // Reunions just finished loading - re-apply value if we have one + const currentValue = this.value; + if (currentValue?.reunion?.id) { + const reunionId = String(currentValue.reunion.id); + const matchingReunion = reunionsList.find((r) => String(r.id) === reunionId); + if (matchingReunion) { + // Set the label manually + const reunionLabel = `${matchingReunion.nom} – ${matchingReunion.hippodrome.nom} (${matchingReunion.hippodrome.ville})`; + this.selectedReunionLabel.set(reunionLabel); + // Wait for Angular to render the select items + setTimeout(() => { + this.form.patchValue({ reunionId }, { emitEvent: false }); + this.cdr.detectChanges(); + }, 50); + } + } + } + }); + } + + private hydrateFromValue(v?: Course) { + if (v) { + const patch = { + type: v.type ?? '', + numero: v.numero ?? null, + nom: v.nom ?? '', + dateDepartCourse: v.dateDepartCourse ?? '', + dateDebutParis: v.dateDebutParis ?? v.dateDepartCourse ?? '', + dateFinParis: v.dateFinParis ?? v.dateDepartCourse ?? '', + reunionId: (v as any).reunionId ?? v.reunion?.id ?? '', + reunionCourse: (v as any).reunionCourse ?? v.numero ?? 1, + particularite: (v as any).particularite ?? '', + partants: (v as any).partants ?? null, + distance: (v as any).distance ?? null, + condition: (v as any).condition ?? '', + statut: v.statut ?? CourseStatut.PROGRAMMEE, + createdBy: (v as any).createdBy ?? 'agent-001', + validatedBy: (v as any).validatedBy ?? '', + }; + this.form.reset(patch); + this.form.markAsPristine(); + this.form.markAsUntouched(); + } else { + this.form.reset({ + type: '', + numero: null, + nom: '', + dateDepartCourse: '', + dateDebutParis: '', + dateFinParis: '', + reunionId: '', + reunionCourse: 1, + particularite: '', + partants: null, + distance: null, + condition: '', + statut: CourseStatut.PROGRAMMEE, + createdBy: 'agent-001', + validatedBy: '', + }); + this.form.markAsPristine(); + this.form.markAsUntouched(); + } + } + + isInvalid(control: string): boolean { + const ctrl = this.form.get(control); + return !!(ctrl && ctrl.invalid && (ctrl.touched || this.submitted)); + } + + errorMessage(control: string): string | null { + const c = this.form.get(control); + if (!c || !c.errors) return null; + if (c.errors['required']) return 'Ce champ est obligatoire'; + if (c.errors['minlength']) return `Minimum ${c.errors['minlength'].requiredLength} caractères`; + if (c.errors['min']) return `Valeur minimale : ${c.errors['min'].min}`; + return null; + } + + getDatePart(control: string): string { + const val = this.form.get(control)?.value; + return val ? new Date(val).toISOString().slice(0, 10) : ''; + } + + getTimePart(control: string): string { + const val = this.form.get(control)?.value; + if (!val) return ''; + const d = new Date(val); + return d.toISOString().slice(11, 16); + } + + setDatePart(control: string, date: string) { + const current = new Date(this.form.get(control)?.value || new Date()); + const [h, m] = [current.getHours(), current.getMinutes()]; + const newDate = new Date( + `${date}T${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:00Z` + ); + this.form.patchValue({ [control]: newDate.toISOString() }); + } + + setTimePart(control: string, time: string) { + const current = new Date(this.form.get(control)?.value || new Date()); + const [h, m] = time.split(':').map(Number); + current.setHours(h, m); + this.form.patchValue({ [control]: current.toISOString() }); + } + + onSubmit() { + this.submitted = true; + if (!this.form.valid) { + this.form.markAllAsTouched(); + return; + } + + const raw = this.form.getRawValue() as any; + + // Find the reunion object from the reunions list + const reunionId = raw.reunionId; + const reunion = this.reunions().find((r) => String(r.id) === String(reunionId)); + + if (!reunion) { + console.error('Reunion not found:', reunionId); + return; + } + + const payload: Partial = { + ...(this.value ?? {}), + type: raw.type, + numero: +raw.numero, + nom: raw.nom, + dateDepartCourse: raw.dateDepartCourse, + dateDebutParis: raw.dateDebutParis, + dateFinParis: raw.dateFinParis, + reunion: reunion!, // Will be set if reunion is found + reunionCourse: +raw.reunionCourse, + particularite: raw.particularite ?? '', + partants: +raw.partants, + distance: +raw.distance, + condition: raw.condition ?? '', + statut: raw.statut, + createdBy: raw.createdBy, + validatedBy: raw.validatedBy || null, + }; + + // Ensure reunion is found + if (!reunion) { + console.error('Reunion not found:', reunionId); + return; + } + + this.save.emit(payload as Course); + } + + // === Filter Reunions === + filteredReunions = computed(() => { + const q = this.searchQuery().toLowerCase(); + return this.reunions().filter( + (r) => + r.nom.toLowerCase().includes(q) || + r.hippodrome.nom.toLowerCase().includes(q) || + r.hippodrome.ville.toLowerCase().includes(q) + ); + }); + + courseTypes = [ + { label: 'Tiercé', value: CourseType.TIERCE }, + { label: 'Quarté + Tiercé', value: CourseType.QUARTE }, + { label: 'Quinté + Tiercé', value: CourseType.QUINTE }, + ]; + + courseStatus = [ + { label: 'Programmée', value: CourseStatut.PROGRAMMEE }, + { label: 'Créée', value: CourseStatut.CREATED }, + { label: 'Validée', value: CourseStatut.VALIDATED }, + { label: 'En cours', value: CourseStatut.RUNNING }, + { label: 'Clôturée', value: CourseStatut.CLOSED }, + { label: 'Annulée', value: CourseStatut.CANCELED }, + ]; + + ngOnInit() { + // Fetch reunions from API + this.loadingReunions.set(true); + this.subs.add( + this.reunionService.list({ page: 1, perPage: 1000 }, false).subscribe({ + next: (result) => { + this.reunions.set(result.data); + this.loadingReunions.set(false); + // Force enable the control after loading + setTimeout(() => { + const reunionControl = this.form.get('reunionId'); + reunionControl?.enable({ emitEvent: false }); + this.cdr.markForCheck(); + }, 100); + }, + error: (err) => { + console.error('Error loading reunions:', err); + this.loadingReunions.set(false); + }, + }) + ); + } + + ngAfterViewInit() { + // After view is initialized, ensure the reunion value and label are set correctly + const currentValue = this.value; + if (currentValue?.reunion?.id && this.reunions().length > 0) { + const reunionId = String(currentValue.reunion.id); + const matchingReunion = this.reunions().find((r) => String(r.id) === reunionId); + if (matchingReunion) { + // Set the label manually + const reunionLabel = `${matchingReunion.nom} – ${matchingReunion.hippodrome.nom} (${matchingReunion.hippodrome.ville})`; + this.selectedReunionLabel.set(reunionLabel); + // Wait a bit for the select items to be fully rendered + setTimeout(() => { + const currentFormValue = this.form.get('reunionId')?.value; + if (String(currentFormValue) !== reunionId) { + this.form.patchValue({ reunionId }, { emitEvent: false }); + this.cdr.detectChanges(); + } + }, 100); + } + } + } + + onReunionSelectionChange(value: string) { + // Immediately update the label when user selects a reunion + if (value && this.reunions().length > 0) { + const matchingReunion = this.reunions().find( + (r) => String(r.id) === String(value) || r.id === value + ); + if (matchingReunion) { + const reunionLabel = `${matchingReunion.nom} – ${matchingReunion.hippodrome.nom} (${matchingReunion.hippodrome.ville})`; + this.selectedReunionLabel.set(reunionLabel); + this.cdr.markForCheck(); + } else { + } + } + } + + ngOnDestroy() { + this.subs.unsubscribe(); + } +} diff --git a/src/app/shared/forms/hippodrome-form/hippodrome-form.css b/src/app/shared/forms/hippodrome-form/hippodrome-form.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/shared/forms/hippodrome-form/hippodrome-form.html b/src/app/shared/forms/hippodrome-form/hippodrome-form.html new file mode 100644 index 0000000..e4189dd --- /dev/null +++ b/src/app/shared/forms/hippodrome-form/hippodrome-form.html @@ -0,0 +1,85 @@ +
    + +
    + + + + + + + + + + + + + +
    + + + + + + + @for (c of countries; track c.value) { + {{ c.label }} + } + + + + + + + + + + + + + + + + + + + + + + + +
    + + +
    +
    + + + @if(showInternalActions()) { +
    + + +
    + } +
    diff --git a/src/app/shared/forms/hippodrome-form/hippodrome-form.spec.ts b/src/app/shared/forms/hippodrome-form/hippodrome-form.spec.ts new file mode 100644 index 0000000..cc1b11f --- /dev/null +++ b/src/app/shared/forms/hippodrome-form/hippodrome-form.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { HippodromeForm } from './hippodrome-form'; + +describe('HippodromeForm', () => { + let component: HippodromeForm; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [HippodromeForm] + }) + .compileComponents(); + + fixture = TestBed.createComponent(HippodromeForm); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/forms/hippodrome-form/hippodrome-form.ts b/src/app/shared/forms/hippodrome-form/hippodrome-form.ts new file mode 100644 index 0000000..df57f57 --- /dev/null +++ b/src/app/shared/forms/hippodrome-form/hippodrome-form.ts @@ -0,0 +1,151 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + effect, + input, + OnDestroy, + output, + signal, +} from '@angular/core'; +import { + FormBuilder, + ReactiveFormsModule, + Validators, + FormGroup, + FormControl, +} from '@angular/forms'; +import { Subscription } from 'rxjs'; +import { Hippodrome } from 'src/app/core/interfaces/hippodrome'; + +import { ZardButtonComponent } from '@shared/components/button/button.component'; +import { ZardFormModule } from '@shared/components/form/form.module'; +import { ZardInputDirective } from '@shared/components/input/input.directive'; +import { ZardSelectComponent } from '@shared/components/select/select.component'; +import { ZardSelectItemComponent } from '@shared/components/select/select-item.component'; + +@Component({ + selector: 'app-hippodrome-form', + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + ZardFormModule, + ZardInputDirective, + ZardSelectComponent, + ZardSelectItemComponent, + ZardButtonComponent, + ], + templateUrl: './hippodrome-form.html', + styleUrls: ['./hippodrome-form.css'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class HippodromeForm implements OnDestroy { + // Inputs + value = input | null>(null); + showInternalActions = input(false); + + // Outputs + save = output>(); + cancel = output(); + + submitting = signal(false); + initializing = signal(false); + submitted = signal(false); + paysInteracted = signal(false); + + form!: FormGroup; + private subs = new Subscription(); + + countries = [ + { value: 'France', label: 'France' }, + { value: 'Mali', label: 'Mali' }, + { value: 'Sénégal', label: 'Sénégal' }, + { value: 'Maroc', label: 'Maroc' }, + { value: 'Tunisie', label: 'Tunisie' }, + { value: 'Algérie', label: 'Algérie' }, + { value: 'Côte d’Ivoire', label: 'Côte d’Ivoire' }, + { value: 'Belgique', label: 'Belgique' }, + { value: 'Suisse', label: 'Suisse' }, + { value: 'Canada', label: 'Canada' }, + { value: 'Mauritanie', label: 'Mauritanie' }, + ]; + + constructor(private fb: FormBuilder) { + this.form = this.fb.group({ + nom: ['', Validators.required], + ville: ['', Validators.required], + pays: new FormControl(null, { validators: [Validators.required] }), + capacite: new FormControl(null, { + validators: [Validators.min(1)], + }), + actif: new FormControl(true), + description: new FormControl(null), + }); + + // Auto-update form when editing + effect( + () => { + const v = this.value(); + this.initializing.set(true); + + this.form.reset( + { nom: '', ville: '', pays: null, capacite: null, actif: true, description: null }, + { emitEvent: false } + ); + + if (v) + this.form.patchValue( + { + nom: v.nom ?? '', + ville: v.ville ?? '', + pays: v.pays ?? null, + capacite: v.capacite ?? null, + actif: v.actif ?? true, + description: v.description ?? null, + }, + { emitEvent: false } + ); + + this.form.markAsPristine(); + this.form.markAsUntouched(); + this.paysInteracted.set(false); + queueMicrotask(() => this.initializing.set(false)); + }, + { allowSignalWrites: true } + ); + + const pays = this.form.get('pays')!; + this.subs.add(pays.valueChanges.subscribe(() => this.paysInteracted.set(true))); + } + + invalid(name: keyof Hippodrome) { + const c = this.form.get(name as string); + return !!((this.submitted() || c?.touched || c?.dirty) && c?.invalid); + } + + showPaysError(): boolean { + const c = this.form.get('pays'); + if (!c || this.initializing()) return false; + return (this.submitted() || this.paysInteracted()) && c.hasError('required'); + } + + onSubmit() { + this.submitted.set(true); + if (this.form.invalid) { + this.form.markAllAsTouched(); + return; + } + this.submitting.set(true); + this.save.emit(this.form.value as Partial); + + // Reset form state after emitting (parent will handle actual form reset via value input) + // But reset submitting state so button is enabled again + this.submitting.set(false); + this.submitted.set(false); + } + + ngOnDestroy() { + this.subs.unsubscribe(); + } +} diff --git a/src/app/shared/forms/limit-form/limit-form.html b/src/app/shared/forms/limit-form/limit-form.html new file mode 100644 index 0000000..3fd232b --- /dev/null +++ b/src/app/shared/forms/limit-form/limit-form.html @@ -0,0 +1,74 @@ +
    +
    + +
    + +
    + +
    + + +
    + +
    +
    + +
    + +
    +
    +
    + +
    + +
    + +
    + +
    + +
    + +
    + +
    +
    +
    diff --git a/src/app/shared/forms/limit-form/limit-form.ts b/src/app/shared/forms/limit-form/limit-form.ts new file mode 100644 index 0000000..3e5fc8c --- /dev/null +++ b/src/app/shared/forms/limit-form/limit-form.ts @@ -0,0 +1,112 @@ +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { ZardFormModule } from '@shared/components/form/form.module'; +import { ZardInputDirective } from '@shared/components/input/input.directive'; +import { AgentLimit } from 'src/app/core/interfaces/agent-limit'; + +@Component({ + selector: 'app-limit-form', + standalone: true, + templateUrl: './limit-form.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, ReactiveFormsModule, ZardFormModule, ZardInputDirective], +}) +export class LimitForm { + @Output() save = new EventEmitter(); + @Output() cancel = new EventEmitter(); + + private _value?: AgentLimit; + @Input() set value(v: AgentLimit | undefined) { + this._value = v; + this.hydrate(v); + } + get value(): AgentLimit | undefined { + return this._value; + } + + form: FormGroup; + submitted = false; + + constructor(private fb: FormBuilder) { + this.form = this.fb.group({ + code: ['', Validators.required], + configCode: ['', Validators.required], + nom: ['', Validators.required], + isDefault: [false], + actif: [true], + betMin: [0], + betMax: [0], + maxBet: [0], + maxDisburseBet: [0], + airtimeMin: [0], + airtimeMax: [0], + }); + } + + error(control: string): string { + const c = this.form.get(control); + if (!c) return ''; + const invalid = c.invalid && (c.touched || this.submitted); + if (!invalid) return ''; + const e = c.errors || {}; + if (e['required']) return 'Ce champ est obligatoire'; + return 'Valeur invalide'; + } + + private hydrate(v?: AgentLimit) { + if (!v) { + this.form.reset({ + code: '', + configCode: '', + nom: '', + isDefault: false, + actif: true, + betMin: 0, + betMax: 0, + maxBet: 0, + maxDisburseBet: 0, + airtimeMin: 0, + airtimeMax: 0, + }); + return; + } + this.form.reset({ + code: v.code, + configCode: v.configCode, + nom: v.nom, + isDefault: v.isDefault, + actif: v.actif, + betMin: v.betMin ?? 0, + betMax: v.betMax ?? 0, + maxBet: v.maxBet ?? 0, + maxDisburseBet: v.maxDisburseBet ?? 0, + airtimeMin: v.airtimeMin ?? 0, + airtimeMax: v.airtimeMax ?? 0, + }); + } + + onSubmit() { + this.submitted = true; + if (this.form.invalid) { + this.form.markAllAsTouched(); + return; + } + const raw = this.form.getRawValue() as any; + const payload: AgentLimit = { + id: this.value?.id ?? '', + code: raw.code, + configCode: raw.configCode, + nom: raw.nom, + isDefault: !!raw.isDefault, + actif: !!raw.actif, + betMin: +raw.betMin, + betMax: +raw.betMax, + maxBet: +raw.maxBet, + maxDisburseBet: +raw.maxDisburseBet, + airtimeMin: +raw.airtimeMin, + airtimeMax: +raw.airtimeMax, + }; + this.save.emit(payload); + } +} diff --git a/src/app/shared/forms/nonpartant-form/nonpartant-form.css b/src/app/shared/forms/nonpartant-form/nonpartant-form.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/shared/forms/nonpartant-form/nonpartant-form.html b/src/app/shared/forms/nonpartant-form/nonpartant-form.html new file mode 100644 index 0000000..682fd6e --- /dev/null +++ b/src/app/shared/forms/nonpartant-form/nonpartant-form.html @@ -0,0 +1,74 @@ +
    +
    +
    +

    + Déclarer les non-partants +

    + + {{ chosenNumbers().length }} sélection(s) / {{ partantsMax() }} partants + +
    + +
    + +
    + @for (row of rows.controls; track $index) { +
    + + + + + @for (num of optionsForRow($index); track num) { + {{ num }} + } + + + @if (rows.at($index).get('numero')?.errors?.['range']) { +
    Numéro en dehors de la plage autorisée.
    + } @if (rows.at($index).get('numero')?.hasError('required') && + rows.at($index).get('numero')?.touched) { +
    Ce champ est obligatoire.
    + } +
    + + + + + + + + + + + + + + + + +
    + } +
    +
    diff --git a/src/app/shared/forms/nonpartant-form/nonpartant-form.spec.ts b/src/app/shared/forms/nonpartant-form/nonpartant-form.spec.ts new file mode 100644 index 0000000..0f4bfc4 --- /dev/null +++ b/src/app/shared/forms/nonpartant-form/nonpartant-form.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { NonpartantForm } from './nonpartant-form'; + +describe('NonpartantForm', () => { + let component: NonpartantForm; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [NonpartantForm] + }) + .compileComponents(); + + fixture = TestBed.createComponent(NonpartantForm); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/forms/nonpartant-form/nonpartant-form.ts b/src/app/shared/forms/nonpartant-form/nonpartant-form.ts new file mode 100644 index 0000000..a516c7f --- /dev/null +++ b/src/app/shared/forms/nonpartant-form/nonpartant-form.ts @@ -0,0 +1,221 @@ +import { CommonModule } from '@angular/common'; +import { + Component, + EventEmitter, + Input, + OnChanges, + OnDestroy, + OnInit, + Output, + SimpleChanges, + computed, + signal, +} from '@angular/core'; +import { FormArray, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { ZardFormModule } from '@shared/components/form/form.module'; +import { ZardInputDirective } from '@shared/components/input/input.directive'; +import { ZardButtonComponent } from '@shared/components/button/button.component'; +import { ZardSelectComponent } from '@shared/components/select/select.component'; +import { ZardSelectItemComponent } from '@shared/components/select/select-item.component'; +import { Subscription } from 'rxjs'; +import { Course } from 'src/app/core/interfaces/course'; + +/** One non-partant row shape (controls) */ +type NonPartantRow = { + numero: FormControl; + motif: FormControl; + declarePar: FormControl; +}; + +/** Whole form shape */ +type NonPartantFormShape = { + nonPartants: FormArray>; +}; + +@Component({ + selector: 'app-nonpartant-form', + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + ZardFormModule, + ZardInputDirective, + ZardButtonComponent, + ZardSelectComponent, + ZardSelectItemComponent, + ], + templateUrl: './nonpartant-form.html', +}) +export class NonPartantForm implements OnInit, OnChanges, OnDestroy { + @Input() course!: Course | null; + + /** We emit the UPDATED course with merged nonPartants */ + @Output() save = new EventEmitter(); + @Output() cancel = new EventEmitter(); + + // 🔒 Strongly typed form + form = new FormGroup({ + nonPartants: new FormArray>([]), + }); + + private sub = new Subscription(); + + // reactive signals + partantsMax = signal(0); + chosenNumbers = signal([]); // current selected numbers across rows + + // --------- lifecycle ---------- + ngOnInit(): void { + this.seedFromCourse(); + + // Keep chosenNumbers in sync + this.sub.add( + this.rows.valueChanges.subscribe(() => { + const nums = this.rows.controls + .map((g) => g.controls.numero.value) + .filter((n): n is number => Number.isFinite(n as number)); + this.chosenNumbers.set(nums); + }) + ); + + // initial seed chosenNumbers + const nums = this.rows.controls + .map((g) => g.controls.numero.value) + .filter((n): n is number => Number.isFinite(n as number)); + this.chosenNumbers.set(nums); + } + + ngOnChanges(changes: SimpleChanges): void { + if ('course' in changes && this.course) { + this.seedFromCourse(); + } + } + + ngOnDestroy(): void { + this.sub.unsubscribe(); + } + + // --------- typed helpers ---------- + get rows(): FormArray> { + return this.form.controls.nonPartants; + } + + private createRow(initial?: { + numero?: number | null; + motif?: string; + declarePar?: string; + }): FormGroup { + return new FormGroup({ + numero: new FormControl(initial?.numero ?? null, { + validators: [Validators.required, Validators.min(1)], + nonNullable: false, + }), + motif: new FormControl(initial?.motif ?? '', { + nonNullable: true, + }), + declarePar: new FormControl(initial?.declarePar ?? 'commissaire-1', { + validators: [Validators.required], + nonNullable: true, + }), + }); + } + + private seedFromCourse() { + const max = this.course?.partants ?? 0; + this.partantsMax.set(max); + + const arr = new FormArray>([]); + + const existing = this.course?.nonPartants ?? []; + if (existing.length) { + for (const np of existing) { + arr.push( + this.createRow({ + numero: Number(np), + motif: '', + declarePar: 'commissaire-1', + }) + ); + } + } else { + arr.push(this.createRow()); + } + + // Replace control with properly typed FormArray> + this.form.setControl('nonPartants', arr); + + // Refresh chosen numbers + const nums = arr.controls + .map((g) => g.controls.numero.value) + .filter((n): n is number => Number.isFinite(n as number)); + this.chosenNumbers.set(nums); + + // Optional: coerce numero values to numbers if your z-select emits strings + // (keep this if you notice strings arriving) + this.sub.add( + arr.valueChanges.subscribe(() => { + arr.controls.forEach((g) => { + const v = g.controls.numero.value as unknown as string | number | null; + if (typeof v === 'string') { + const n = Number(v); + if (!Number.isNaN(n)) g.controls.numero.setValue(n, { emitEvent: false }); + } + }); + }) + ); + } + + addRow() { + if (!this.hasFreeSlots()) return; + this.rows.push(this.createRow()); + } + + removeRow(i: number) { + this.rows.removeAt(i); + } + + // --------- options & validation ---------- + /** All numbers 1..partants */ + allNumbers = computed(() => Array.from({ length: this.partantsMax() }, (_, i) => i + 1)); + + /** Remaining (not chosen elsewhere) */ + remainingNumbers = computed(() => { + const chosen = new Set(this.chosenNumbers()); + return this.allNumbers().filter((n) => !chosen.has(n)); + }); + + /** Options for a given row = remaining + its current value (so it stays visible) */ + optionsForRow(i: number) { + const current = this.rows.at(i).controls.numero.value ?? undefined; + const base = this.remainingNumbers(); + return typeof current === 'number' ? [...base, current].sort((a, b) => a - b) : base; + } + + hasFreeSlots(): boolean { + return this.remainingNumbers().length > 0; + } + + onSubmit() { + const max = this.partantsMax(); + let ok = true; + + this.rows.controls.forEach((g) => { + const num = g.controls.numero.value; + if (typeof num !== 'number' || num < 1 || num > max) { + g.controls.numero.setErrors({ range: true }); + ok = false; + } + }); + + if (ok && this.form.valid && this.course) { + const nonPartants = this.rows.controls.map((g) => { + const numero = g.controls.numero.value as number; + return String(numero); + }); + + this.save.emit(nonPartants); + } else { + this.form.markAllAsTouched(); + } + } +} diff --git a/src/app/shared/forms/permission-form/permission-form.html b/src/app/shared/forms/permission-form/permission-form.html new file mode 100644 index 0000000..e26ffbd --- /dev/null +++ b/src/app/shared/forms/permission-form/permission-form.html @@ -0,0 +1,18 @@ +
    +
    + + +
    + +
    +
    + + +
    + +
    +
    +
    +
    + + diff --git a/src/app/shared/forms/permission-form/permission-form.ts b/src/app/shared/forms/permission-form/permission-form.ts new file mode 100644 index 0000000..5a00c76 --- /dev/null +++ b/src/app/shared/forms/permission-form/permission-form.ts @@ -0,0 +1,73 @@ +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { ZardFormModule } from '@shared/components/form/form.module'; +import { ZardInputDirective } from '@shared/components/input/input.directive'; +import { Permission } from 'src/app/core/interfaces/role'; + +@Component({ + selector: 'app-permission-form', + standalone: true, + templateUrl: './permission-form.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, ReactiveFormsModule, ZardFormModule, ZardInputDirective], +}) +export class PermissionForm { + @Output() save = new EventEmitter(); + @Output() cancel = new EventEmitter(); + + private _value?: Permission; + @Input() set value(v: Permission | undefined) { + this._value = v; + this.hydrateFromValue(v); + } + get value(): Permission | undefined { + return this._value; + } + + form: FormGroup; + submitted = false; + + constructor(private fb: FormBuilder) { + this.form = this.fb.group({ + name: ['', Validators.required], + description: [''], + }); + } + + private hydrateFromValue(v?: Permission) { + this.form.reset({ + name: v?.name ?? '', + description: v?.description ?? '', + }); + } + + isInvalid(control: string): boolean { + const ctrl = this.form.get(control); + return !!(ctrl && ctrl.invalid && (ctrl.touched || this.submitted)); + } + + errorMessage(control: string): string | null { + const c = this.form.get(control); + if (!c || !c.errors) return null; + if (c.errors['required']) return 'Ce champ est obligatoire'; + return null; + } + + onSubmit() { + this.submitted = true; + if (this.form.invalid) { + this.form.markAllAsTouched(); + return; + } + const raw = this.form.getRawValue() as any; + const payload: Permission = { + id: this.value?.id ?? '', + name: raw.name, + description: raw.description, + }; + this.save.emit(payload); + } +} + + diff --git a/src/app/shared/forms/resultat-form/resultat-form.css b/src/app/shared/forms/resultat-form/resultat-form.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/shared/forms/resultat-form/resultat-form.html b/src/app/shared/forms/resultat-form/resultat-form.html new file mode 100644 index 0000000..2289212 --- /dev/null +++ b/src/app/shared/forms/resultat-form/resultat-form.html @@ -0,0 +1,141 @@ +
    + +
    +
    +

    {{ course.nom }}

    +

    + {{ course.reunion.nom }} • {{ course.reunion.hippodrome.nom }} +

    +
    + + {{ reqLen() }} chevaux / {{ maxNum() }} partants + +
    + + +
    + + + +
    + + +
    + @for (pg of places.controls; track $index; let i = $index) { +
    +
    +
    + @if (!isPlaceSkipped(i)) { + + {{ getActualPlaceNumber(i) }} + + Place {{ getActualPlaceNumber(i) }} + @if (hasExAequoAtPlace(i)) { + Ex-aequo + } } @else { + + — + + Place sautée + } +
    + @if (!isPlaceSkipped(i)) { + + } +
    + + @if (!isPlaceSkipped(i)) { + + @if (hasHorsesAtPlace(i)) { +
    + @for (ctrl of picksAt(i).controls; track $index; let j = $index) { @if (typeof ctrl.value + === 'number') { +
    + {{ ctrl.value }} + +
    + } } +
    + } + + +
    + @for (ctrl of picksAt(i).controls; track $index; let j = $index) { +
    + + @for (num of optionsForPlace(i, j); track num) { + {{ num }} + } + + @if (ctrl.invalid && ctrl.touched) { + Requis + } +
    + } +
    + } +
    + } +
    + + + @if (combinations().length > 0) { +
    +
    + {{ combinations().length }} combinaisons +
    +
    + @for (combo of combinations(); track $index) { + {{ + combo.join('-') + }} + } +
    +
    + } +
    diff --git a/src/app/shared/forms/resultat-form/resultat-form.spec.ts b/src/app/shared/forms/resultat-form/resultat-form.spec.ts new file mode 100644 index 0000000..f7d1858 --- /dev/null +++ b/src/app/shared/forms/resultat-form/resultat-form.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ResultatForm } from './resultat-form'; + +describe('ResultatForm', () => { + let component: ResultatForm; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ResultatForm] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ResultatForm); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/forms/resultat-form/resultat-form.ts b/src/app/shared/forms/resultat-form/resultat-form.ts new file mode 100644 index 0000000..5afd8f4 --- /dev/null +++ b/src/app/shared/forms/resultat-form/resultat-form.ts @@ -0,0 +1,543 @@ +import { CommonModule } from '@angular/common'; +import { Component, EventEmitter, Input, Output, computed } from '@angular/core'; +import { FormArray, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { ZardFormModule } from '@shared/components/form/form.module'; +import { ZardSelectComponent } from '@shared/components/select/select.component'; +import { ZardSelectItemComponent } from '@shared/components/select/select-item.component'; +import { Course, CourseType } from 'src/app/core/interfaces/course'; +import { Resultat } from 'src/app/core/interfaces/resultat'; + +type PlaceRow = { picks: FormArray> }; +type ResultatShape = { places: FormArray> }; + +@Component({ + standalone: true, + selector: 'app-resultat-form', + imports: [ + CommonModule, + ReactiveFormsModule, + ZardFormModule, + ZardSelectComponent, + ZardSelectItemComponent, + ], + templateUrl: './resultat-form.html', +}) +export class ResultatForm { + @Input() course!: Course; + @Input() resultat?: Resultat | null; + @Output() save = new EventEmitter(); + @Output() validate = new EventEmitter(); + @Output() confirm = new EventEmitter(); + @Output() cancel = new EventEmitter(); + + form = new FormGroup({ + places: new FormArray>([]), + }); + + reqLen = computed(() => + this.course?.type === CourseType.TIERCE ? 3 : this.course?.type === CourseType.QUARTE ? 4 : 5 + ); + maxNum = computed(() => this.course?.partants ?? 0); + npSet = computed(() => new Set(this.course?.nonPartants ?? [])); + statut = computed((): 'CREATED' | 'VALIDATED' | 'NONE' => { + return this.resultat ? 'CREATED' : 'NONE'; + }); + + canValidate(): boolean { + return this.statut() === 'CREATED'; + } + + canConfirm(): boolean { + return this.statut() === 'VALIDATED'; + } + + // Helper methods for template + hasHorsesAtPlace(i: number): boolean { + return this.picksAt(i).controls.some((c) => typeof c.value === 'number'); + } + + getHorsesAtPlace(i: number): string { + return this.picksAt(i) + .controls.map((c) => c.value) + .filter((v): v is number => typeof v === 'number') + .join('='); + } + + // Calculate all combinations when there are ex-aequo + combinations = computed(() => { + const places: number[][] = []; + for (let i = 0; i < this.places.length; i++) { + if (this.isPlaceSkipped(i)) continue; + const placeHorses = this.picksAt(i) + .controls.map((c) => c.value) + .filter((v): v is number => typeof v === 'number'); + if (placeHorses.length > 0) { + places.push(placeHorses); + } + } + + // Generate all combinations based on ex-aequo + return this.generateCombinations(places); + }); + + // Generate combinations considering ex-aequo + private generateCombinations(places: number[][]): number[][] { + if (places.length === 0) return []; + + // If all horses are in first place (all ex-aequo), generate all permutations + if (places.length === 1 && places[0].length === this.reqLen()) { + return this.generatePermutations(places[0]); + } + + // Otherwise, generate combinations based on ex-aequo in each place + const result: number[][] = []; + this.generateCombinationsRecursive(places, 0, [], result); + return result; + } + + private generateCombinationsRecursive( + places: number[][], + placeIndex: number, + current: number[], + result: number[][] + ): void { + if (placeIndex >= places.length) { + if (current.length === this.reqLen()) { + result.push([...current]); + } + return; + } + + const placeHorses = places[placeIndex]; + if (placeHorses.length === 1) { + // Single horse, add it and continue + current.push(placeHorses[0]); + this.generateCombinationsRecursive(places, placeIndex + 1, current, result); + current.pop(); + } else { + // Ex-aequo: generate all permutations of these horses + const perms = this.generatePermutations(placeHorses); + for (const perm of perms) { + // Add all horses from this place + current.push(...perm); + this.generateCombinationsRecursive(places, placeIndex + 1, current, result); + // Remove them + current.splice(current.length - perm.length); + } + } + } + + private isAutoPopulating = false; + + // Generate all permutations of an array + private generatePermutations(arr: number[]): number[][] { + if (arr.length <= 1) return [arr]; + const result: number[][] = []; + for (let i = 0; i < arr.length; i++) { + const rest = [...arr.slice(0, i), ...arr.slice(i + 1)]; + const perms = this.generatePermutations(rest); + for (const perm of perms) { + result.push([arr[i], ...perm]); + } + } + return result; + } + + // Check if a place has ex-aequo (multiple horses) + hasExAequoAtPlace(i: number): boolean { + const placeHorses = this.picksAt(i) + .controls.map((c) => c.value) + .filter((v): v is number => typeof v === 'number'); + return placeHorses.length > 1; + } + + // Check if a place should be skipped + // Simple logic: If previous places with horses have consumed position (i+1), skip it + isPlaceSkipped(i: number): boolean { + if (i === 0) return false; // First place is never skipped + + // Calculate total positions consumed by previous places (only places with horses) + let totalConsumed = 0; + for (let j = 0; j < i; j++) { + const horses = this.picksAt(j) + .controls.map((c) => c.value) + .filter((v): v is number => typeof v === 'number'); + // Only places with horses consume positions + if (horses.length > 0) { + totalConsumed += horses.length; + } + } + + // The place at index i should be at position (i + 1) if no ex-aequo + // If totalConsumed >= (i + 1), then position (i+1) is already taken, so skip + // Example: If place 0 has 2 horses, totalConsumed = 2 + // Place 1 (i=1) should be at position 2, but 2 >= 2, so skipped ✓ + // Place 2 (i=2) should be at position 3, but 2 >= 3 is false, so active ✓ + return totalConsumed >= i + 1; + } + + // Get the actual place number displayed to user + getActualPlaceNumber(i: number): number { + if (i === 0) return 1; + + // Count actual positions used by non-skipped places before this one + let position = 1; + for (let j = 0; j < i; j++) { + if (!this.isPlaceSkipped(j)) { + const horses = this.picksAt(j) + .controls.map((c) => c.value) + .filter((v): v is number => typeof v === 'number'); + position += horses.length > 0 ? horses.length : 1; + } + } + return position; + } + + // Count total horses selected across all places + totalHorsesSelected = computed(() => { + let total = 0; + for (let i = 0; i < this.places.length; i++) { + const placeHorses = this.picksAt(i) + .controls.map((c) => c.value) + .filter((v): v is number => typeof v === 'number'); + total += placeHorses.length; + } + return total; + }); + + ngOnInit() { + this.seed(); + // Watch for changes to auto-populate places when ex-aequo is detected + this.setupAutoPopulate(); + } + ngOnChanges() { + this.seed(); + } + + private setupAutoPopulate() { + // Watch for changes in all places to clear skipped places + this.places.controls.forEach((placeGroup) => { + placeGroup.controls.picks.valueChanges.subscribe(() => { + this.autoPopulatePlaces(); + }); + }); + } + + private autoPopulatePlaces() { + if (this.isAutoPopulating) return; // Prevent infinite loops + this.isAutoPopulating = true; + + // Clear places that should be skipped (when previous place has ex-aequo) + for (let i = 1; i < this.places.length; i++) { + if (this.isPlaceSkipped(i)) { + const placePicks = this.picksAt(i); + const currentHorses = placePicks.controls + .map((c) => c.value) + .filter((v): v is number => typeof v === 'number'); + + // Clear this place if it has horses (it should be skipped) + if (currentHorses.length > 0) { + while (placePicks.length > 0) { + placePicks.removeAt(0); + } + placePicks.push(this.makePickControl(null)); + } + } + } + + this.isAutoPopulating = false; + } + + private seed() { + if (!this.course) return; + + const placesFA = new FormArray>([]); + const len = this.reqLen(); + + // Extract existing places from resultat if available. + // Backend now returns ordreArrivee as an array of cheval numbers and + // chevauxDeadHeat as the subset that are in dead-heat (ex-aequo). + let existing: number[][] = []; + if (this.resultat && this.resultat.ordreArrivee && this.resultat.ordreArrivee.length > 0) { + const deadHeatSet = new Set(this.resultat.chevauxDeadHeat || []); + + const allHorses = this.resultat.ordreArrivee; + const allInDeadHeat = + allHorses.every((num) => deadHeatSet.has(num)) && allHorses.length === len; + + if (allInDeadHeat) { + // All horses are in first place (ex-aequo) + existing = [allHorses]; // Put all in first place + // Fill remaining places with empty arrays + for (let i = 1; i < len; i++) { + existing.push([]); + } + } else { + // Group consecutive horses that are in dead heat together + const groups: number[][] = []; + let currentGroup: number[] = []; + + this.resultat.ordreArrivee.forEach((num, index) => { + const isInDeadHeat = deadHeatSet.has(num); + const prevNum = index > 0 ? this.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); + } + + // Limit to required places + existing = groups.slice(0, len); + } + } + + // Create form controls for each place + for (let i = 0; i < len; i++) { + const picksFA = new FormArray>([]); + const row = existing[i] ?? []; + if (row.length) { + row.forEach((n) => picksFA.push(this.makePickControl(n))); + } else { + picksFA.push(this.makePickControl(null)); + } + placesFA.push(new FormGroup({ picks: picksFA })); + } + this.form.setControl('places', placesFA); + } + + private makePickControl(initial: number | null) { + const ctrl = new FormControl(initial, [Validators.required]); + + ctrl.valueChanges.subscribe((v) => { + if (typeof v === 'string') { + const n = Number(v); + ctrl.setValue(Number.isNaN(n) ? null : (n as any), { emitEvent: false }); + return; + } + + if (v === null) { + const loc = this.locateControl(ctrl); + if (!loc) return; + const { placeIdx, pickIdx } = loc; + const fa = this.picksAt(placeIdx); + if (fa.length > 1) fa.removeAt(pickIdx); + // Check if we need to clear skipped places after removal + setTimeout(() => this.autoPopulatePlaces(), 0); + return; + } + + const loc = this.locateControl(ctrl); + if (!loc) return; + const { placeIdx, pickIdx } = loc; + const fa = this.picksAt(placeIdx); + const dup = fa.controls.some((c, idx) => idx !== pickIdx && c.value === v); + if (dup) { + ctrl.setValue(null, { emitEvent: false }); + return; + } + + // After value change, check if we need to clear skipped places + setTimeout(() => this.autoPopulatePlaces(), 0); + }); + + return ctrl; + } + + private locateControl( + ctrl: FormControl + ): { placeIdx: number; pickIdx: number } | null { + for (let i = 0; i < this.places.length; i++) { + const fa = this.picksAt(i); + const idx = fa.controls.indexOf(ctrl); + if (idx !== -1) return { placeIdx: i, pickIdx: idx }; + } + return null; + } + + get places(): FormArray> { + return this.form.controls.places; + } + picksAt(i: number): FormArray> { + return this.places.at(i).controls.picks; + } + + private usedElsewhere(exceptPlace: number): Set { + const used = new Set(); + this.places.controls.forEach((pg, idx) => { + if (idx === exceptPlace) return; + pg.controls.picks.controls.forEach((c) => { + if (typeof c.value === 'number') used.add(c.value); + }); + }); + return used; + } + + optionsForPlace(i: number, j: number): number[] { + const all = Array.from({ length: this.maxNum() }, (_, k) => k + 1); + const np = this.npSet(); + + // Count total horses already selected (excluding current control's value) + const allUsed = new Set(); + this.places.controls.forEach((pg) => { + pg.controls.picks.controls.forEach((c) => { + if (typeof c.value === 'number') allUsed.add(c.value); + }); + }); + + // Remove the current control's value from used set (so it can be re-selected) + const currentValue = this.picksAt(i).at(j).value; + if (typeof currentValue === 'number') { + allUsed.delete(currentValue); + } + + // Also remove other horses in the same place (for ex-aequo) + const usedSamePlace = new Set( + this.picksAt(i) + .controls.map((c, idx) => (idx === j ? null : c.value)) + .filter((v): v is number => typeof v === 'number') + ); + + // CRITICAL: Count how many horses are already selected (excluding current control) + const totalSelected = allUsed.size; + const requiredHorses = this.reqLen(); + + // CRITICAL: If we already have exactly reqLen() horses selected (excluding current control), + // block ALL new selections (except keeping current value) + if (totalSelected >= requiredHorses) { + // Only allow keeping the current value if it exists + if (typeof currentValue === 'number') { + return [currentValue]; + } + return []; + } + + // For first place: can have multiple horses (ex-aequo), but not duplicates in same place + // For other places: can only use horses not used anywhere + let opts: number[]; + if (i === 0) { + // First place: can have multiple horses (ex-aequo), but not duplicates in same place + opts = all.filter((n) => !np.has(String(n)) && !usedSamePlace.has(n)); + } else { + // Other places: can only use horses not used anywhere + opts = all.filter((n) => !np.has(String(n)) && !allUsed.has(n) && !usedSamePlace.has(n)); + } + + // Always include current value if it exists (so user can keep their current selection) + if (typeof currentValue === 'number' && !opts.includes(currentValue)) { + opts.push(currentValue); + } + + return opts.sort((a, b) => a - b); + } + + addTie(i: number) { + // Don't allow adding ex-aequo to skipped places + if (this.isPlaceSkipped(i)) return; + + // Check if we can add another horse (ex-aequo) + const totalSelected = this.totalHorsesSelected(); + const requiredHorses = this.reqLen(); + + // Only allow adding ex-aequo if we haven't reached the required number of horses yet + if (totalSelected >= requiredHorses) { + // Already have enough horses - don't allow adding more + return; + } + + this.picksAt(i).push(this.makePickControl(null)); + // Check if we need to clear skipped places after adding + setTimeout(() => this.autoPopulatePlaces(), 0); + } + + removePick(i: number, j: number) { + const fa = this.picksAt(i); + fa.removeAt(j); + if (fa.length === 0) fa.push(this.makePickControl(null)); + } + + private isValidGlobally(): boolean { + const np = this.npSet(); + const max = this.maxNum(); + + // Collect all selected horses (excluding skipped places) + const allHorses: number[] = []; + for (let i = 0; i < this.places.length; i++) { + if (this.isPlaceSkipped(i)) continue; // Skip places that should be skipped + const picks = this.picksAt(i) + .controls.map((c) => c.value) + .filter((v): v is number => typeof v === 'number'); + allHorses.push(...picks); + } + + // Must have exactly reqLen() horses + if (allHorses.length !== this.reqLen()) return false; + + // All horses must be valid (within range, not non-partants, unique) + for (const horse of allHorses) { + if (horse < 1 || horse > max || np.has(String(horse))) return false; + } + if (new Set(allHorses).size !== allHorses.length) return false; + + // First place must have at least one horse + const firstPlaceHorses = this.picksAt(0) + .controls.map((c) => c.value) + .filter((v): v is number => typeof v === 'number'); + if (firstPlaceHorses.length === 0) return false; + + // Check that all non-skipped places have valid horses (not just form.valid which includes skipped places) + for (let i = 0; i < this.places.length; i++) { + if (!this.isPlaceSkipped(i)) { + const placeHorses = this.picksAt(i) + .controls.map((c) => c.value) + .filter((v): v is number => typeof v === 'number'); + // Each non-skipped place must have at least one horse + if (placeHorses.length === 0) { + return false; + } + // Check that all controls in this place are valid + const placePicks = this.picksAt(i); + for (const ctrl of placePicks.controls) { + if (ctrl.invalid && ctrl.touched) { + return false; + } + } + } + } + + return true; + } + + canSave(): boolean { + return this.isValidGlobally(); + } + + onSave() { + if (!this.isValidGlobally()) { + this.form.markAllAsTouched(); + return; + } + // Build places array, excluding skipped places + const places: number[][] = []; + for (let i = 0; i < this.places.length; i++) { + if (this.isPlaceSkipped(i)) continue; // Skip places that should be skipped + const placeHorses = this.picksAt(i) + .controls.map((c) => c.value) + .filter((v): v is number => typeof v === 'number'); + places.push(placeHorses); + } + this.save.emit(places); + } +} diff --git a/src/app/shared/forms/reunion-form/reunion-form.css b/src/app/shared/forms/reunion-form/reunion-form.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/shared/forms/reunion-form/reunion-form.html b/src/app/shared/forms/reunion-form/reunion-form.html new file mode 100644 index 0000000..5b54aa2 --- /dev/null +++ b/src/app/shared/forms/reunion-form/reunion-form.html @@ -0,0 +1,99 @@ +
    + + + + + + + + + +
    + + + + + + + + + + + + + +
    + + + + + + + + + + + + + + + @if (loadingHippodromes()) { + Chargement des hippodromes... + } @else { @for (h of hippodromes(); track h.id) { + {{ h.nom }} ({{ h.ville }}, {{ h.pays }}) + } } + + + + + + + + + + @for (s of statuts; track s.value) { + {{ s.label }} + } + + + + + + + + + + + + + + @if (showInternalActions()) { +
    + + +
    + } +
    diff --git a/src/app/shared/forms/reunion-form/reunion-form.spec.ts b/src/app/shared/forms/reunion-form/reunion-form.spec.ts new file mode 100644 index 0000000..5717dee --- /dev/null +++ b/src/app/shared/forms/reunion-form/reunion-form.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ReunionForm } from './reunion-form'; + +describe('ReunionForm', () => { + let component: ReunionForm; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ReunionForm] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ReunionForm); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/forms/reunion-form/reunion-form.ts b/src/app/shared/forms/reunion-form/reunion-form.ts new file mode 100644 index 0000000..31bdc73 --- /dev/null +++ b/src/app/shared/forms/reunion-form/reunion-form.ts @@ -0,0 +1,310 @@ +import { CommonModule } from '@angular/common'; +import { + AfterViewInit, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + effect, + input, + OnDestroy, + output, + signal, + OnInit, +} from '@angular/core'; +import { + FormBuilder, + FormControl, + FormGroup, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { Subscription } from 'rxjs'; + +import { Reunion, ReunionStatut } from 'src/app/core/interfaces/reunion'; +import { Hippodrome } from 'src/app/core/interfaces/hippodrome'; +import { HippodromeService } from 'src/app/core/services/hippodrome'; + +import { ZardButtonComponent } from '@shared/components/button/button.component'; +import { ZardFormModule } from '@shared/components/form/form.module'; +import { ZardInputDirective } from '@shared/components/input/input.directive'; +import { ZardSelectComponent } from '@shared/components/select/select.component'; +import { ZardSelectItemComponent } from '@shared/components/select/select-item.component'; + +@Component({ + selector: 'app-reunion-form', + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + ZardFormModule, + ZardInputDirective, + ZardSelectComponent, + ZardSelectItemComponent, + ZardButtonComponent, + ], + templateUrl: './reunion-form.html', + styleUrls: ['./reunion-form.css'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ReunionForm implements OnInit, AfterViewInit, OnDestroy { + value = input | null>(null); + save = output>(); + cancel = output(); + showInternalActions = input(false); + + submitting = signal(false); + submitted = signal(false); + initializing = signal(false); + loadingHippodromes = signal(false); + + private subs = new Subscription(); + form!: FormGroup; + + hippodromes = signal([]); + selectedHippodromeLabel = signal(''); + statuts = Object.values(ReunionStatut).map((s) => ({ + value: s, + label: + s === ReunionStatut.PLANIFIEE + ? 'Planifiée' + : s === ReunionStatut.EN_COURS + ? 'En cours' + : s === ReunionStatut.TERMINEE + ? 'Terminée' + : 'Annulée', + })); + + constructor( + private fb: FormBuilder, + private hippodromeService: HippodromeService, + private cdr: ChangeDetectorRef + ) { + this.form = this.fb.group({ + code: ['', Validators.required], + nom: ['', Validators.required], + date: ['', Validators.required], + numero: [1, [Validators.required, Validators.min(1)]], + statut: [ReunionStatut.PLANIFIEE, Validators.required], + hippodromeId: new FormControl(null, Validators.required), + totalCourses: new FormControl(null, [Validators.min(1)]), + }); + + // Update label when user selects a different hippodrome (both create and edit modes) + // We always set the label manually to ensure it displays correctly + this.subs.add( + this.form.get('hippodromeId')?.valueChanges.subscribe((hippodromeId) => { + // Skip if we're initializing (to avoid interfering with form initialization) + if (this.initializing()) { + return; + } + + // Always update the label when user selects a hippodrome (both create and edit) + // Update immediately without setTimeout for better responsiveness + if (hippodromeId && this.hippodromes().length > 0) { + // Try both string and direct comparison since form control might convert types + const matchingHippodrome = this.hippodromes().find( + (h) => String(h.id) === String(hippodromeId) || h.id === hippodromeId + ); + if (matchingHippodrome) { + const hippodromeLabel = `${matchingHippodrome.nom} (${matchingHippodrome.ville}, ${matchingHippodrome.pays})`; + this.selectedHippodromeLabel.set(hippodromeLabel); + // Force change detection to ensure the label is displayed immediately + this.cdr.markForCheck(); + } else { + this.selectedHippodromeLabel.set(''); + } + } else { + this.selectedHippodromeLabel.set(''); + } + }) || new Subscription() + ); + + // When editing - watch both value and hippodromes to handle timing + effect(() => { + const v = this.value(); + const hippodromesList = this.hippodromes(); + const isLoading = this.loadingHippodromes(); + + // Enable/disable hippodrome control based on loading state + const hippodromeControl = this.form.get('hippodromeId'); + if (isLoading) { + hippodromeControl?.disable({ emitEvent: false }); + } else { + hippodromeControl?.enable({ emitEvent: false }); + } + + // Only reset if we have a new value (not just hippodromes loading) + if (v !== null && v !== undefined) { + this.initializing.set(true); + + // Get hippodromeId - convert to string if needed + let hippodromeId: string | null = null; + let hippodromeLabel: string = ''; + if (v.hippodrome?.id) { + hippodromeId = String(v.hippodrome.id); + // Only set hippodromeId if hippodromes are loaded and the ID exists + // This ensures the select component can find the matching label + if (hippodromesList.length > 0) { + const matchingHippodrome = hippodromesList.find((h) => String(h.id) === hippodromeId); + if (matchingHippodrome) { + // Set the label manually so the select component displays it + hippodromeLabel = `${matchingHippodrome.nom} (${matchingHippodrome.ville}, ${matchingHippodrome.pays})`; + this.selectedHippodromeLabel.set(hippodromeLabel); + } else { + hippodromeId = null; // Don't set if not found + this.selectedHippodromeLabel.set(''); + } + } else { + // If hippodromes aren't loaded yet, use the hippodrome from the value if available + if (v.hippodrome.nom) { + hippodromeLabel = `${v.hippodrome.nom} (${v.hippodrome.ville}, ${v.hippodrome.pays})`; + this.selectedHippodromeLabel.set(hippodromeLabel); + } + // Don't set the value yet - it will be set once hippodromes finish loading + hippodromeId = null; + } + } else { + this.selectedHippodromeLabel.set(''); + } + + this.form.patchValue( + { + code: v.code ?? '', + nom: v.nom ?? '', + date: v.date ?? '', + numero: v.numero ?? 1, + statut: v.statut ?? ReunionStatut.PLANIFIEE, + hippodromeId, + totalCourses: v.totalCourses ?? null, + }, + { emitEvent: false } + ); + + this.form.markAsPristine(); + this.form.markAsUntouched(); + queueMicrotask(() => this.initializing.set(false)); + } else if (v === null) { + // Reset form when value is cleared + this.initializing.set(true); + this.selectedHippodromeLabel.set(''); + this.form.reset( + { + code: '', + nom: '', + date: '', + numero: 1, + statut: ReunionStatut.PLANIFIEE, + hippodromeId: null, + totalCourses: null, + }, + { emitEvent: false } + ); + this.form.markAsPristine(); + this.form.markAsUntouched(); + queueMicrotask(() => this.initializing.set(false)); + } else if (hippodromesList.length > 0 && !isLoading) { + // Hippodromes just finished loading - re-apply value if we have one + const currentValue = this.value(); + if (currentValue?.hippodrome?.id) { + const hippodromeId = String(currentValue.hippodrome.id); + const matchingHippodrome = hippodromesList.find((h) => String(h.id) === hippodromeId); + if (matchingHippodrome) { + // Set the label manually + const hippodromeLabel = `${matchingHippodrome.nom} (${matchingHippodrome.ville}, ${matchingHippodrome.pays})`; + this.selectedHippodromeLabel.set(hippodromeLabel); + // Wait for Angular to render the select items + setTimeout(() => { + this.form.patchValue({ hippodromeId }, { emitEvent: false }); + this.cdr.detectChanges(); + }, 50); + } + } + } + }); + } + + ngOnInit() { + // Fetch hippodromes from API + this.loadingHippodromes.set(true); + this.subs.add( + this.hippodromeService.list({ page: 1, perPage: 1000 }, false).subscribe({ + next: (result) => { + this.hippodromes.set(result.data); + this.loadingHippodromes.set(false); + // The effect will automatically re-run when hippodromes signal updates + }, + error: (err) => { + console.error('Error loading hippodromes:', err); + this.loadingHippodromes.set(false); + }, + }) + ); + } + + ngAfterViewInit() { + // After view is initialized, ensure the hippodrome value and label are set correctly + const currentValue = this.value(); + if (currentValue?.hippodrome?.id && this.hippodromes().length > 0) { + const hippodromeId = String(currentValue.hippodrome.id); + const matchingHippodrome = this.hippodromes().find((h) => String(h.id) === hippodromeId); + if (matchingHippodrome) { + // Set the label manually + const hippodromeLabel = `${matchingHippodrome.nom} (${matchingHippodrome.ville}, ${matchingHippodrome.pays})`; + this.selectedHippodromeLabel.set(hippodromeLabel); + // Wait a bit for the select items to be fully rendered + setTimeout(() => { + const currentFormValue = this.form.get('hippodromeId')?.value; + if (String(currentFormValue) !== hippodromeId) { + this.form.patchValue({ hippodromeId }, { emitEvent: false }); + this.cdr.detectChanges(); + } + }, 100); + } + } + } + + onHippodromeSelectionChange(value: string) { + // Immediately update the label when user selects a hippodrome + if (value && this.hippodromes().length > 0) { + const matchingHippodrome = this.hippodromes().find( + (h) => String(h.id) === String(value) || h.id === value + ); + if (matchingHippodrome) { + const hippodromeLabel = `${matchingHippodrome.nom} (${matchingHippodrome.ville}, ${matchingHippodrome.pays})`; + this.selectedHippodromeLabel.set(hippodromeLabel); + this.cdr.markForCheck(); + } + } + } + + invalid(name: string): boolean { + const c = this.form.get(name); + return !!((this.submitted() || c?.touched || c?.dirty) && c?.invalid); + } + + onSubmit() { + this.submitted.set(true); + if (this.form.invalid) { + this.form.markAllAsTouched(); + return; + } + this.submitting.set(true); + + const values = this.form.value; + const hippodrome = this.hippodromes().find((h) => h.id === values.hippodromeId) ?? null; + + this.save.emit({ + ...values, + hippodrome, + } as Partial); + + // Reset form state after emitting (parent will handle actual form reset via value input) + // But reset submitting state so button is enabled again + this.submitting.set(false); + this.submitted.set(false); + } + + ngOnDestroy() { + this.subs.unsubscribe(); + } +} diff --git a/src/app/shared/forms/role-form/role-form.html b/src/app/shared/forms/role-form/role-form.html new file mode 100644 index 0000000..cb053df --- /dev/null +++ b/src/app/shared/forms/role-form/role-form.html @@ -0,0 +1,36 @@ +
    +
    + + +
    + +
    +
    + + +
    + +
    +
    +
    + +
    +
    Permissions
    +
    +
    + @for (p of permissions; track p.id) { + + } +
    +
    + @if (submitted && selectedPermissionsCount() === 0) { +

    Sélectionnez au moins une permission.

    + } +
    +
    diff --git a/src/app/shared/forms/role-form/role-form.ts b/src/app/shared/forms/role-form/role-form.ts new file mode 100644 index 0000000..f3825d2 --- /dev/null +++ b/src/app/shared/forms/role-form/role-form.ts @@ -0,0 +1,120 @@ +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { FormsModule } from '@angular/forms'; +import { ZardFormModule } from '@shared/components/form/form.module'; +import { ZardInputDirective } from '@shared/components/input/input.directive'; +import { ZardCheckboxComponent } from '@shared/components/checkbox/checkbox.component'; +import { Permission, Role } from 'src/app/core/interfaces/role'; + +@Component({ + selector: 'app-role-form', + standalone: true, + templateUrl: './role-form.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + ReactiveFormsModule, + FormsModule, + ZardFormModule, + ZardInputDirective, + ZardCheckboxComponent, + ], +}) +export class RoleForm { + @Output() save = new EventEmitter(); + @Output() cancel = new EventEmitter(); + + private _permissions: Permission[] = []; + @Input() set permissions(list: Permission[]) { + this._permissions = list ?? []; + this.buildPermissionControls(); + } + get permissions(): Permission[] { + return this._permissions; + } + + private _value?: Role; + @Input() set value(v: Role | undefined) { + this._value = v; + this.hydrateFromValue(v); + } + get value(): Role | undefined { + return this._value; + } + + form: FormGroup; + submitted = false; + + constructor(private fb: FormBuilder) { + this.form = this.fb.group({ + name: ['', Validators.required], + description: [''], + permMap: this.fb.group({}), + }); + } + + private buildPermissionControls() { + const group = this.form.get('permMap') as FormGroup; + if (!group) return; + // Keep existing states where possible + const current = (group.getRawValue?.() as Record) || {}; + const selectedNames = new Set( + (this._value?.permissions ?? []).map((p) => p.name) + ); + // Rebuild controls for each permission (keyed by its name) + const newGroup: Record = {}; + for (const p of this._permissions) { + const key = p.name; + const initial = current[key] ?? selectedNames.has(key); + newGroup[key] = this.fb.control(!!initial); + } + // Replace the group + this.form.setControl('permMap', this.fb.group(newGroup)); + } + + private hydrateFromValue(v?: Role) { + this.form.patchValue({ name: v?.name ?? '', description: v?.description ?? '' }); + // Rebuild permission map based on new value and available permissions + this.buildPermissionControls(); + } + + onSubmit() { + this.submitted = true; + if (this.form.invalid || this.selectedPermissionsCount() === 0) { + this.form.markAllAsTouched(); + return; + } + const raw = this.form.getRawValue() as any; + const permMap: Record = (this.form.get('permMap') as FormGroup).getRawValue(); + const selectedNames = Object.entries(permMap) + .filter(([, v]) => !!v) + .map(([k]) => k); + const permissions = this._permissions.filter((p) => selectedNames.includes(p.name)); + const payload: Role = { + id: this.value?.id ?? '', + name: raw.name, + description: raw.description, + permissions, + }; + this.save.emit(payload); + } + + selectedPermissionsCount(): number { + const permMap: Record = (this.form.get('permMap') as FormGroup).getRawValue(); + return Object.values(permMap).filter(Boolean).length; + } + + isInvalid(control: string): boolean { + const ctrl = this.form.get(control); + return !!(ctrl && ctrl.invalid && (ctrl.touched || this.submitted)); + } + + errorMessage(control: string): string | null { + const c = this.form.get(control); + if (!c || !c.errors) return null; + if (c.errors['required']) return 'Ce champ est obligatoire'; + if (c.errors['minlength']) return `Minimum ${c.errors['minlength'].requiredLength} caractères`; + return null; + } +} diff --git a/src/app/shared/forms/tpe-form/tpe-form.html b/src/app/shared/forms/tpe-form/tpe-form.html new file mode 100644 index 0000000..993e779 --- /dev/null +++ b/src/app/shared/forms/tpe-form/tpe-form.html @@ -0,0 +1,42 @@ +
    +
    + + +
    + +
    +
    + + + +
    + +
    +
    + + + +
    + + @for (t of types; track t.value) { + {{ t.label }} + } + +
    +
    + + + +
    + +
    +
    + + + +
    + +
    +
    +
    +
    diff --git a/src/app/shared/forms/tpe-form/tpe-form.ts b/src/app/shared/forms/tpe-form/tpe-form.ts new file mode 100644 index 0000000..52d968b --- /dev/null +++ b/src/app/shared/forms/tpe-form/tpe-form.ts @@ -0,0 +1,125 @@ +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { ZardFormModule } from '@shared/components/form/form.module'; +import { ZardInputDirective } from '@shared/components/input/input.directive'; +import { ZardSelectComponent } from '@shared/components/select/select.component'; +import { ZardSelectItemComponent } from '@shared/components/select/select-item.component'; +import { TpeDevice, TpeType } from 'src/app/core/interfaces/tpe'; + +@Component({ + selector: 'app-tpe-form', + standalone: true, + templateUrl: './tpe-form.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + ReactiveFormsModule, + ZardFormModule, + ZardInputDirective, + ZardSelectComponent, + ZardSelectItemComponent, + ], +}) +export class TpeForm { + @Output() save = new EventEmitter(); + @Output() cancel = new EventEmitter(); + + private _value?: TpeDevice; + private _skipHydration = false; + @Input() set value(v: TpeDevice | undefined) { + this._value = v; + if (!this._skipHydration) { + this.hydrateFromValue(v); + } + } + get value(): TpeDevice | undefined { + return this._value; + } + + form: FormGroup; + submitted = false; + + constructor(private fb: FormBuilder) { + this.form = this.fb.group({ + imei: ['', [Validators.required, Validators.minLength(10)]], + serial: ['', Validators.required], + type: ['POS' as TpeType, Validators.required], + marque: ['', Validators.required], + modele: ['', Validators.required], + }); + } + + isInvalid(control: string): boolean { + const c = this.form.get(control); + return !!(c && c.invalid && (c.touched || this.submitted)); + } + errorMessage(control: string): string { + const e = this.form.get(control)?.errors; + if (!e) return ''; + if (e['required']) return 'Ce champ est obligatoire'; + if (e['minlength']) return `Minimum ${e['minlength'].requiredLength} caractères`; + return ''; + } + + private hydrateFromValue(v?: TpeDevice) { + if (!v) { + this.form.reset({ + imei: '', + serial: '', + type: 'POS', + marque: '', + modele: '', + }); + this.submitted = false; // Reset submitted flag when form is cleared + return; + } + this.form.reset({ + imei: v.imei, + serial: v.serial, + type: v.type, + marque: v.marque, + modele: v.modele, + }); + } + + onSubmit() { + this.submitted = true; + if (this.form.invalid) { + this.form.markAllAsTouched(); + return; + } + const raw = this.form.getRawValue() as any; + const payload: Partial = { + imei: raw.imei, + serial: raw.serial, + type: raw.type, + marque: raw.marque, + modele: raw.modele, + }; + // Preserve existing id, statut, and assigne if editing + if (this.value?.id) { + payload.id = this.value.id; + } + this.save.emit(payload as TpeDevice); + } + + resetForm() { + this._skipHydration = true; // Prevent hydration when clearing value + this._value = undefined; + this.form.reset({ + imei: '', + serial: '', + type: 'POS', + marque: '', + modele: '', + }); + this.submitted = false; + this._skipHydration = false; // Re-enable hydration + } + + types = [ + { label: 'POS', value: 'POS' as TpeType }, + { label: 'Autre', value: 'OTHER' as TpeType }, + ]; +} diff --git a/src/app/shared/forms/user-form/user-form.css b/src/app/shared/forms/user-form/user-form.css new file mode 100644 index 0000000..e268024 --- /dev/null +++ b/src/app/shared/forms/user-form/user-form.css @@ -0,0 +1,3 @@ +/* Empty file: avoid Tailwind @apply in component CSS to prevent build errors */ + + diff --git a/src/app/shared/forms/user-form/user-form.html b/src/app/shared/forms/user-form/user-form.html new file mode 100644 index 0000000..ba99c37 --- /dev/null +++ b/src/app/shared/forms/user-form/user-form.html @@ -0,0 +1,96 @@ +
    +
    + + +
    + +
    +
    + + + +
    + +
    +
    + + + +
    + +
    +
    + + + +
    + +
    +
    + + + +
    + +
    +
    + + + +
    + + @for (r of roles; track r.id) { + {{ r.name }} + } + +
    +
    + + + + + +
    + +
    +
    + + + +
    + +
    +
    + + + +
    + +
    +
    + + + +
    + +
    +
    + + + +
    + + Active + Cancelled + Suspended + +
    +
    +
    +
    diff --git a/src/app/shared/forms/user-form/user-form.ts b/src/app/shared/forms/user-form/user-form.ts new file mode 100644 index 0000000..87b2f8d --- /dev/null +++ b/src/app/shared/forms/user-form/user-form.ts @@ -0,0 +1,148 @@ +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { ZardFormModule } from '@shared/components/form/form.module'; +import { ZardInputDirective } from '@shared/components/input/input.directive'; +import { ZardSelectItemComponent } from '@shared/components/select/select-item.component'; +import { ZardSelectComponent } from '@shared/components/select/select.component'; +import { User, UserStatus } from 'src/app/core/interfaces/user'; +import { Role } from 'src/app/core/interfaces/role'; +import { RoleService } from 'src/app/core/services/role'; + +@Component({ + selector: 'app-user-form', + standalone: true, + templateUrl: './user-form.html', + styleUrls: ['./user-form.css'], + imports: [ + CommonModule, + ReactiveFormsModule, + ZardFormModule, + ZardInputDirective, + ZardSelectComponent, + ZardSelectItemComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class UserForm { + @Output() save = new EventEmitter(); + @Output() cancel = new EventEmitter(); + + private _value?: User; + @Input() set value(v: User | undefined) { + this._value = v; + this.hydrateFromValue(v); + } + get value(): User | undefined { + return this._value; + } + + form: FormGroup; + submitted = false; + + roles: Role[] = []; + + constructor(private fb: FormBuilder, private roleService: RoleService) { + this.form = this.fb.group({ + nom: ['', Validators.required], + prenom: ['', Validators.required], + identifiant: ['', Validators.required], + password: ['', [Validators.minLength(8)]], + matriculeAgent: ['', Validators.required], + roleId: ['', Validators.required], + restrictionConnexion: [false], + restrictionAutomatique: [false], + nombreIpAutorise: [0, [Validators.required, Validators.min(0)]], + nombreIpAutoAutorise: [0, [Validators.required, Validators.min(0)]], + statut: ['ACTIVE' as UserStatus, Validators.required], + }); + + this.roleService.allPermissions(); // warm permissions cache if needed + // Fetch roles by listing and mapping unique names; use mock store if needed + // Reuse RoleService through list with default params + this.roleService + .list({ page: 1, perPage: 100, search: '', sortKey: 'name', sortDir: 'asc' } as any) + .subscribe((res) => { + this.roles = res.data as any; + }); + } + + isInvalid(control: string): boolean { + const ctrl = this.form.get(control); + return !!(ctrl && ctrl.invalid && (ctrl.touched || this.submitted)); + } + + errorMessage(control: string): string | null { + const c = this.form.get(control); + if (!c || !c.errors) return null; + if (c.errors['required']) return 'Ce champ est obligatoire'; + if (c.errors['min']) return `Valeur minimale : ${c.errors['min'].min}`; + if (c.errors['minlength']) return `Minimum ${c.errors['minlength'].requiredLength} caractères`; + return null; + } + + private hydrateFromValue(v?: User) { + if (v) { + this.form.reset({ + nom: v.nom, + prenom: v.prenom, + identifiant: v.identifiant, + password: '', + matriculeAgent: v.matriculeAgent, + // Utiliser roleId directement si le rôle n'est pas hydraté + roleId: v.role?.id ?? v.roleId ?? '', + restrictionConnexion: v.restrictionConnexion, + restrictionAutomatique: v.restrictionAutomatique, + nombreIpAutorise: v.nombreIpAutorise, + nombreIpAutoAutorise: v.nombreIpAutoAutorise, + statut: v.statut, + }); + } else { + this.form.reset({ + nom: '', + prenom: '', + identifiant: '', + password: '', + matriculeAgent: '', + roleId: '', + restrictionConnexion: false, + restrictionAutomatique: false, + nombreIpAutorise: 0, + nombreIpAutoAutorise: 0, + statut: 'ACTIVE' as UserStatus, + }); + } + } + + onSubmit() { + this.submitted = true; + if (!this.form.valid) { + this.form.markAllAsTouched(); + return; + } + const raw = this.form.getRawValue() as any; + const role = this.roles.find((r) => r.id === raw.roleId); + if (!role) { + return; + } + const payload: User = { + ...(this.value ?? {}), + id: this.value?.id ?? '', + nom: raw.nom, + prenom: raw.prenom, + identifiant: raw.identifiant, + // Only send password if filled (for create or change) + password: raw.password ? String(raw.password) : undefined, + matriculeAgent: raw.matriculeAgent, + roleId: role.id, + role, + restrictionConnexion: !!raw.restrictionConnexion, + restrictionAutomatique: !!raw.restrictionAutomatique, + nombreIpAutorise: Number(raw.nombreIpAutorise), + nombreIpAutoAutorise: Number(raw.nombreIpAutoAutorise), + statut: raw.statut as UserStatus, + updatedAt: new Date().toISOString(), + }; + this.save.emit(payload); + } +} diff --git a/src/app/shared/paging/data-source.ts b/src/app/shared/paging/data-source.ts new file mode 100644 index 0000000..2e5421d --- /dev/null +++ b/src/app/shared/paging/data-source.ts @@ -0,0 +1,28 @@ +import { signal, computed } from '@angular/core'; +import { ListParams, SortDir } from './paging'; + +export class TableDataSource { + readonly page = signal(1); + readonly perPage = signal(10); + readonly search = signal(''); + readonly sortKey = signal(undefined); + readonly sortDir = signal(''); // '', 'asc', 'desc' + + readonly params = computed(() => ({ + page: this.page(), + perPage: this.perPage(), + search: this.search() || undefined, + sortKey: this.sortKey(), + sortDir: this.sortDir(), + })); + + resetToFirst() { + this.page.set(1); + } + + setSort(key?: string, dir: SortDir = '') { + this.sortKey.set(key); + this.sortDir.set(dir); + this.resetToFirst(); + } +} diff --git a/src/app/shared/paging/normalize-page.ts b/src/app/shared/paging/normalize-page.ts new file mode 100644 index 0000000..f6fa39f --- /dev/null +++ b/src/app/shared/paging/normalize-page.ts @@ -0,0 +1,75 @@ +import { PagedResult, PageMeta } from './paging'; + +/** + * Normalizes various pagination response formats into a unified PagedResult. + * Automatically merges any extra statistical fields inside `meta`. + */ +export function normalizePage(raw: any, reqPage: number, perPage: number): PagedResult { + // 🟩 Case 1 — Spring Data style: { content, totalElements, number, size } + if (raw && Array.isArray(raw.content) && typeof raw.totalElements === 'number') { + return { + data: raw.content as T[], + meta: { + page: (raw.number ?? 0) + 1, + perPage: raw.size ?? perPage, + total: raw.totalElements, + ...(raw.meta ?? {}), // merge extra meta if provided + } as PageMeta, + }; + } + + // 🟩 Case 2 — API or local mock: { data, meta: { total, ...extra } } + if (raw && Array.isArray(raw.data) && raw.meta?.total != null) { + return { + data: raw.data as T[], + meta: { + page: raw.meta.page ?? reqPage, + perPage: raw.meta.perPage ?? perPage, + total: raw.meta.total, + ...raw.meta, // keep any custom stats + } as PageMeta, + }; + } + + // 🟩 Case 3 — Generic REST: { items, total | total_count, ...extra } + if (raw && Array.isArray(raw.items) && (raw.total != null || raw.total_count != null)) { + const total = raw.total ?? raw.total_count; + return { + data: raw.items as T[], + meta: { + page: reqPage, + perPage, + total, + ...raw.meta, // optional + } as PageMeta, + }; + } + + // 🟩 Case 4 — Direct array (no meta) + if (Array.isArray(raw)) { + const start = (reqPage - 1) * perPage; + const data = (raw as T[]).slice(start, start + perPage); + return { + data, + meta: { + page: reqPage, + perPage, + total: (raw as T[]).length, + } as PageMeta, + }; + } + + // 🟩 Fallback — ensure consistency even with minimal info + const data = Array.isArray(raw?.data) ? (raw.data as T[]) : []; + const total = typeof raw?.total === 'number' ? raw.total : data.length ?? 0; + + return { + data, + meta: { + page: raw?.meta?.page ?? reqPage, + perPage: raw?.meta?.perPage ?? perPage, + total, + ...raw?.meta, // merge additional stats safely + } as PageMeta, + }; +} diff --git a/src/app/shared/paging/paginated-http.service.ts b/src/app/shared/paging/paginated-http.service.ts new file mode 100644 index 0000000..9ddb6e0 --- /dev/null +++ b/src/app/shared/paging/paginated-http.service.ts @@ -0,0 +1,61 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; +import { map, Observable } from 'rxjs'; +import { BackendConfig, ListParams, PagedResult } from './paging'; +import { normalizePage } from './normalize-page'; +import { environment } from 'src/environments/environment.development'; + +const springDefaults: BackendConfig = { + pageParam: 'page', + sizeParam: 'size', + searchParam: 'q', + zeroBasedPageIndex: true, + buildSort: (key, dir) => (key && dir ? ['sort', `${key},${dir}`] : null), + mapClientSortKey: (k) => k, +}; + +@Injectable({ providedIn: 'root' }) +export class PaginatedHttpService { + private http = inject(HttpClient); + + // Helper method to get ngrok bypass headers + private getNgrokHeaders(): Record { + const isNgrok = environment.apiBaseUrl.includes('ngrok-free.app') || environment.apiBaseUrl.includes('ngrok.io') || environment.apiBaseUrl.includes('ngrok'); + return isNgrok ? { 'ngrok-skip-browser-warning': 'true' } : {}; + } + + fetch( + url: string, + params: ListParams, + cfg: BackendConfig = springDefaults + ): Observable> { + const { + pageParam = 'page', + sizeParam = 'size', + searchParam = 'q', + zeroBasedPageIndex = true, + buildSort = springDefaults.buildSort!, + mapClientSortKey = springDefaults.mapClientSortKey!, + } = cfg; + + let httpParams = new HttpParams() + .set(pageParam, String(zeroBasedPageIndex ? Math.max(params.page - 1, 0) : params.page)) + .set(sizeParam, String(params.perPage)); + + if (params.search) httpParams = httpParams.set(searchParam, params.search); + + const apiSortKey = mapClientSortKey(params.sortKey); + const sortPair = buildSort(apiSortKey, params.sortDir); + if (sortPair) httpParams = httpParams.set(sortPair[0], sortPair[1]); + + if (params.extra) { + for (const [k, v] of Object.entries(params.extra)) { + if (v !== undefined && v !== null && v !== '') httpParams = httpParams.set(k, String(v)); + } + } + + return this.http + .get(url, { params: httpParams, headers: this.getNgrokHeaders() }) + .pipe(map((raw) => normalizePage(raw, params.page, params.perPage))); + } +} diff --git a/src/app/shared/paging/paging.ts b/src/app/shared/paging/paging.ts new file mode 100644 index 0000000..3ddf760 --- /dev/null +++ b/src/app/shared/paging/paging.ts @@ -0,0 +1,32 @@ +// src/app/shared/paging/paging.models.ts +export type SortDir = 'asc' | 'desc' | ''; + +export interface ListParams { + page: number; // 1-based for UI + perPage: number; + search?: string; + sortKey?: string; + sortDir?: SortDir; + extra?: Record; +} + +export interface PageMeta { + page: number; // 1-based + perPage: number; + total: number; // -1 if unknown + [key: string]: any; +} + +export interface PagedResult { + data: T[]; + meta: PageMeta; +} + +export interface BackendConfig { + pageParam?: string; // default 'page' (Spring is 0-based) + sizeParam?: string; // default 'size' + searchParam?: string; // default 'q' + zeroBasedPageIndex?: boolean; // default true (Spring) + buildSort?: (key?: string, dir?: SortDir) => [param: string, value: string] | null; + mapClientSortKey?: (key?: string) => string | undefined; +} diff --git a/src/app/shared/shared-module.ts b/src/app/shared/shared-module.ts new file mode 100644 index 0000000..3da0323 --- /dev/null +++ b/src/app/shared/shared-module.ts @@ -0,0 +1,8 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +@NgModule({ + declarations: [], + imports: [CommonModule], +}) +export class SharedModule {} diff --git a/src/app/shared/utils/cn.ts b/src/app/shared/utils/cn.ts new file mode 100644 index 0000000..a7dc3a1 --- /dev/null +++ b/src/app/shared/utils/cn.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} \ No newline at end of file diff --git a/src/app/shared/utils/merge-classes.ts b/src/app/shared/utils/merge-classes.ts new file mode 100644 index 0000000..6e79043 --- /dev/null +++ b/src/app/shared/utils/merge-classes.ts @@ -0,0 +1,17 @@ +import { twMerge } from 'tailwind-merge'; +import { ClassValue, clsx } from 'clsx'; + +export type { ClassValue }; + +export function mergeClasses(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +export function transform(value: boolean | string): boolean { + return typeof value === 'string' ? value === '' : value; +} + +export function generateId(prefix = ''): string { + const id = crypto.randomUUID(); + return prefix ? `${prefix}-${id}` : id; +} \ No newline at end of file diff --git a/src/app/shared/utils/number.ts b/src/app/shared/utils/number.ts new file mode 100644 index 0000000..5138542 --- /dev/null +++ b/src/app/shared/utils/number.ts @@ -0,0 +1,13 @@ +function clamp(value: number, [min, max]: [number, number]): number { + return Math.min(max, Math.max(min, value)); +} + +function roundToStep(value: number, min: number, step: number): number { + return Math.round((value - min) / step) * step + min; +} + +function convertValueToPercentage(value: number, min: number, max: number): number { + return ((value - min) / (max - min)) * 100; +} + +export { clamp, roundToStep, convertValueToPercentage }; \ No newline at end of file diff --git a/src/environments/environment.development.ts b/src/environments/environment.development.ts new file mode 100644 index 0000000..5bd797b --- /dev/null +++ b/src/environments/environment.development.ts @@ -0,0 +1,4 @@ +export const environment = { + production: false, + apiBaseUrl: 'https://custody-holding-rogers-less.trycloudflare.com', +}; diff --git a/src/environments/environment.ts b/src/environments/environment.ts new file mode 100644 index 0000000..5bd797b --- /dev/null +++ b/src/environments/environment.ts @@ -0,0 +1,4 @@ +export const environment = { + production: false, + apiBaseUrl: 'https://custody-holding-rogers-less.trycloudflare.com', +}; diff --git a/src/index.html b/src/index.html new file mode 100644 index 0000000..4e6de3f --- /dev/null +++ b/src/index.html @@ -0,0 +1,28 @@ + + + + + Plateforme de Jeux PMU (PJP) + + + + + + + + + + diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..33107b4 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,6 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { App } from './app/app'; +import '@angular/common/locales/global/fr'; + +bootstrapApplication(App, appConfig).catch((err) => console.error(err)); diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..a572670 --- /dev/null +++ b/src/styles.css @@ -0,0 +1,253 @@ +@import 'tailwindcss'; +@import 'lucide-static/font/lucide.css'; +@plugin "tailwindcss-animate"; + +@custom-variant dark (&:is(.dark *)); + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); + + --surface: #faf9f7; + --text: #1a1a1a; + --heading: #0c1a0e; +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); + + /* --color-surface: #0f1a12; + --color-text: #e6f0e9; + --color-heading: #f1f7f3; */ + --surface: #0f1a12; + --text: #e6f0e9; + --heading: #f1f7f3; +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + + --color-surface: var(--surface); + --color-text: var(--text); + --color-heading: var(--heading); + + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + + /* PMU brand tokens */ + --color-pmu-vert: #1c5a29; /* Primary */ + --color-pmu-jaune: #fae500; /* Accent */ + --color-pmu-rouge: #c31617; /* Danger */ + + /* --color-surface: #faf9f7; + --color-text: #1a1a1a; + --color-heading: #0c1a0e; */ + + /* sizes/radii */ + --radius-xl: 1rem; +} + +/* utilities bound to CSS variables (Tailwind v4 works config-less) */ +html, +body, +app-root { + height: 100%; +} + +/* Keyframes */ +@keyframes pmu-float { + 0% { + transform: translateY(0); + } + 50% { + transform: translateY(-10px); + } + 100% { + transform: translateY(0); + } +} + +/* Utility: animate-float */ +@utility animate-float { + animation: pmu-float 6s ease-in-out infinite; +} + +/* Variants (optional) */ +@utility animate-float-slow { + animation-duration: 10s; +} +@utility animate-float-fast { + animation-duration: 3s; +} +@utility animate-float-sm { + animation-name: pmu-float-sm; +} +@utility animate-float-lg { + animation-name: pmu-float-lg; +} + +/* Small & Large amplitudes */ +@keyframes pmu-float-sm { + 0% { + transform: translateY(0); + } + 50% { + transform: translateY(-6px); + } + 100% { + transform: translateY(0); + } +} +@keyframes pmu-float-lg { + 0% { + transform: translateY(0); + } + 50% { + transform: translateY(-16px); + } + 100% { + transform: translateY(0); + } +} + +/* Respect reduced motion */ +@media (prefers-reduced-motion: reduce) { + .animate-float, + .animate-float-slow, + .animate-float-fast, + .animate-float-sm, + .animate-float-lg { + animation: none !important; + } +} + +/* === Keyframes for glowing effect === */ +@keyframes pmu-glow { + 0%, + 100% { + box-shadow: 0 0 4px rgba(250, 229, 0, 0.4), /* yellow */ 0 0 8px rgba(28, 90, 41, 0.3), + /* green */ 0 0 12px rgba(195, 22, 23, 0.2); /* red */ + } + 50% { + box-shadow: 0 0 8px rgba(250, 229, 0, 0.7), 0 0 16px rgba(28, 90, 41, 0.5), + 0 0 24px rgba(195, 22, 23, 0.4); + } +} + +/* === Utility class === */ +@utility animate-glow { + animation: pmu-glow 3s ease-in-out infinite alternate; +} + +/* === Optional variants === */ +@utility animate-glow-slow { + animation-duration: 6s; +} +@utility animate-glow-fast { + animation-duration: 1.5s; +} +@utility animate-glow-strong { + filter: brightness(1.2); +} + +/* === Respect reduced motion === */ +@media (prefers-reduced-motion: reduce) { + .animate-glow, + .animate-glow-slow, + .animate-glow-fast { + animation: none !important; + } +} diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..264f459 --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,15 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "types": [] + }, + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "src/**/*.spec.ts" + ] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..da57124 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,40 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "compileOnSave": false, + "compilerOptions": { + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "isolatedModules": true, + "experimentalDecorators": true, + "importHelpers": true, + "target": "ES2022", + "module": "preserve", + "baseUrl": "./", + "paths": { + "@shared/*": [ + "src/app/shared/*" + ] + } + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "typeCheckHostBindings": true, + "strictTemplates": true + }, + "files": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} \ No newline at end of file diff --git a/tsconfig.spec.json b/tsconfig.spec.json new file mode 100644 index 0000000..04df34c --- /dev/null +++ b/tsconfig.spec.json @@ -0,0 +1,14 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/spec", + "types": [ + "jasmine" + ] + }, + "include": [ + "src/**/*.ts" + ] +}