diff --git a/LICENSE.txt b/LICENSE.txt deleted file mode 100644 index f11a399..0000000 --- a/LICENSE.txt +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 2RK dev - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/package-lock.json b/package-lock.json index b765bb1..54f3ef9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@angular/forms": "^21.1.0", "@angular/platform-browser": "^21.1.0", "@angular/router": "^21.1.0", + "lucide-angular": "^0.575.0", "rxjs": "~7.8.0", "tslib": "^2.3.0" }, @@ -6697,6 +6698,19 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-angular": { + "version": "0.575.0", + "resolved": "https://registry.npmjs.org/lucide-angular/-/lucide-angular-0.575.0.tgz", + "integrity": "sha512-BCf0/PtyyKTzlsm9kxxXWCWa27vFS27Wp4dWxQws7SlY0/XpHqNo8b6RverBoNqd2J1RbHkRtArLTFCEq4WDXA==", + "license": "ISC", + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/common": "13.x - 21.x", + "@angular/core": "13.x - 21.x" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", diff --git a/package.json b/package.json index 7f88ac7..1e1ee87 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@angular/forms": "^21.1.0", "@angular/platform-browser": "^21.1.0", "@angular/router": "^21.1.0", + "lucide-angular": "^0.575.0", "rxjs": "~7.8.0", "tslib": "^2.3.0" }, @@ -43,4 +44,4 @@ "typescript": "~5.9.2", "vitest": "^4.0.8" } -} \ No newline at end of file +} diff --git a/src/app/app.html b/src/app/app.html index e0118a1..3b41646 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -1,342 +1,3 @@ - - - - - - - - - - - -
-
-
- -

Hello, {{ title() }}

-

Congratulations! Your app is running. 🎉

-
- -
-
- @for (item of [ - { title: 'Explore the Docs', link: 'https://angular.dev' }, - { title: 'Learn with Tutorials', link: 'https://angular.dev/tutorials' }, - { title: 'Prompt and best practices for AI', link: 'https://angular.dev/ai/develop-with-ai'}, - { title: 'CLI Docs', link: 'https://angular.dev/tools/cli' }, - { title: 'Angular Language Service', link: 'https://angular.dev/tools/language-service' }, - { title: 'Angular DevTools', link: 'https://angular.dev/tools/devtools' }, - ]; track item.title) { - - {{ item.title }} - - - - - } -
- -
-
-
- - - - - - - - - - - +
+ +
diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index dc39edb..ac07b82 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -1,3 +1,14 @@ -import { Routes } from '@angular/router'; +import {Routes} from '@angular/router'; +import {LevelComponent} from '@features/level/level.component'; -export const routes: Routes = []; +export const routes: Routes = [ + { + path: '', + redirectTo: 'level', + pathMatch: 'full', + }, + { + path: 'level', + component: LevelComponent, + }, +]; diff --git a/src/app/core/domains/group/group.api.ts b/src/app/core/domains/group/group.api.ts new file mode 100644 index 0000000..6e4d24c --- /dev/null +++ b/src/app/core/domains/group/group.api.ts @@ -0,0 +1,30 @@ +import {Injectable} from '@angular/core'; +import {HttpClient} from '@angular/common/http'; +import {Observable} from 'rxjs'; +import {Group, GroupPost} from './group.model'; + +@Injectable({ providedIn: 'root' }) +export class GroupApi { + + constructor(private http: HttpClient) {} + + getAllGroups(): Observable { + return this.http.get('/groups'); + } + + getGroupsByLevel(levelId: number): Observable { + return this.http.get(`/levels/${levelId}/groups`); + } + + createGroup(group: GroupPost): Observable { + return this.http.post('/groups', group); + } + + deleteGroup(id: number): Observable { + return this.http.delete(`/groups/${id}`); + } + + updateGroup(id: number, updatedGroup: GroupPost) { + return this.http.put(`/groups/${id}`, updatedGroup); + } +} diff --git a/src/app/core/domains/group/group.model.ts b/src/app/core/domains/group/group.model.ts index cfbcbb0..0502511 100644 --- a/src/app/core/domains/group/group.model.ts +++ b/src/app/core/domains/group/group.model.ts @@ -4,7 +4,6 @@ export interface Group { type: string; classe: string; size: number; - levelAbr: string; levelId: number; } diff --git a/src/app/core/domains/group/group.service.ts b/src/app/core/domains/group/group.service.ts new file mode 100644 index 0000000..15fe52f --- /dev/null +++ b/src/app/core/domains/group/group.service.ts @@ -0,0 +1,95 @@ +import {Injectable} from '@angular/core'; +import {GroupApi} from './group.api'; +import {GroupStore} from './group.store'; +import {Group, GroupPost} from './group.model'; +import {catchError, finalize, tap} from 'rxjs/operators'; +import {throwError} from 'rxjs'; + +@Injectable({ + providedIn: 'root' +}) +export class GroupService { + + readonly groups$; + readonly isLoading$ + readonly error$; + + constructor( + private groupApi: GroupApi, + private groupStore: GroupStore + ) { + this.groups$ = this.groupStore.groups$; + this.isLoading$ = this.groupStore.isLoading$; + this.error$ = this.groupStore.error$; + } + + loadAllGroups(): void { + this.groupStore.setLoading(true); + this.groupStore.setError(null); + + this.groupApi.getAllGroups().pipe( + tap((groups: Group[]) => this.groupStore.setGroups(groups)), + catchError((error) => { + this.groupStore.setError('Erreur lors du chargement des groupes'); + return throwError(() => error); + }), + finalize(() => this.groupStore.setLoading(false)) + ).subscribe(); + } + + loadGroupsByLevel(levelId: number): void { + this.groupStore.setLoading(true); + this.groupStore.setError(null); + + this.groupApi.getGroupsByLevel(levelId).pipe( + tap((groups: Group[]) => this.groupStore.setGroups(groups)), + catchError((error) => { + this.groupStore.setError('Erreur lors du chargement des groupes du niveau'); + return throwError(() => error); + }), + finalize(() => this.groupStore.setLoading(false)) + ).subscribe(); + } + + createGroup(newGroup: GroupPost): void { + this.groupStore.setLoading(true); + + this.groupApi.createGroup(newGroup).pipe( + tap((createdGroup: Group) => { + this.groupStore.addGroup(createdGroup); + }), + catchError((error) => { + this.groupStore.setError('Erreur lors de la création du groupe'); + return throwError(() => error); + }), + finalize(() => this.groupStore.setLoading(false)) + ).subscribe(); + } + + updateGroup(id: number, updatedGroup: GroupPost): void { + this.groupStore.setLoading(true); + + this.groupApi.updateGroup(id, updatedGroup).pipe( + tap((group: Group) => { + this.groupStore.updateGroup(id,group); + }), + catchError((error) => { + this.groupStore.setError('Erreur lors de la mise à jour du groupe'); + return throwError(() => error); + }), + finalize(() => this.groupStore.setLoading(false)) + ).subscribe(); + } + + deleteGroup(id: number): void { + this.groupApi.deleteGroup(id).pipe( + tap(() => { + this.groupStore.removeGroup(id); + }), + catchError((error) => { + this.groupStore.setError('Erreur lors de la suppression du groupe'); + return throwError(() => error); + }) + ).subscribe(); + } +} diff --git a/src/app/core/domains/group/group.store.ts b/src/app/core/domains/group/group.store.ts new file mode 100644 index 0000000..e911723 --- /dev/null +++ b/src/app/core/domains/group/group.store.ts @@ -0,0 +1,48 @@ +import {Injectable} from '@angular/core'; +import {BehaviorSubject} from 'rxjs'; +import {Group, GroupPost} from './group.model'; + +@Injectable({ providedIn: 'root' }) +export class GroupStore { + + private readonly _groups = new BehaviorSubject([]); + private readonly _isLoading = new BehaviorSubject(false); + private readonly _error = new BehaviorSubject(null); + + readonly groups$ = this._groups.asObservable(); + readonly isLoading$ = this._isLoading.asObservable(); + readonly error$ = this._error.asObservable(); + + setGroups(groups: Group[]) { + this._groups.next(groups); + } + + addGroup(group: Group) { + const currentGroups = this._groups.getValue(); + this._groups.next([...currentGroups, group]); + } + + updateGroup(id: number, group: GroupPost) { + const currentGroups = this._groups.getValue(); + const index = currentGroups.findIndex(g => g.id === id); + if (index !== -1) { + const updatedGroup: Group = { ...currentGroups[index], ...group }; + const updatedGroups = [...currentGroups]; + updatedGroups[index] = updatedGroup; + this._groups.next(updatedGroups); + } + } + + removeGroup(id: number) { + const currentGroups = this._groups.getValue(); + this._groups.next(currentGroups.filter(g => g.id !== id)); + } + + setLoading(isLoading: boolean) { + this._isLoading.next(isLoading); + } + + setError(error: string | null) { + this._error.next(error); + } +} diff --git a/src/app/core/domains/level/level.api.ts b/src/app/core/domains/level/level.api.ts new file mode 100644 index 0000000..93feaba --- /dev/null +++ b/src/app/core/domains/level/level.api.ts @@ -0,0 +1,55 @@ +import {Injectable} from '@angular/core'; +import {HttpClient} from '@angular/common/http'; +import {delay, Observable, of} from 'rxjs'; +import {Level, LevelPost} from './level.model'; +import {mockLevels} from './level.mock'; + +@Injectable({ providedIn: 'root' }) +export class LevelApi { + private readonly API_URL = '/levels'; + private readonly USE_MOCK = true; // Mettre à false pour utiliser l'API réelle + private mockData: Level[] = [...mockLevels]; + + constructor(private http: HttpClient) {} + + getAllLevels(): Observable { + if (this.USE_MOCK) { + return of(this.mockData).pipe(delay(500)); + } + return this.http.get(this.API_URL); + } + + createLevel(level: LevelPost): Observable { + if (this.USE_MOCK) { + const newLevel: Level = { + id: this.mockData.at(-1)?.id ? this.mockData.at(-1)!.id + 1 : 1, + name: level.name || '', + abr: level.abr || '' + }; + this.mockData.push(newLevel); + return of(newLevel).pipe(delay(300)); + } + return this.http.post(this.API_URL, level); + } + + updateLevel(id: number, level: LevelPost): Observable { + if (this.USE_MOCK) { + const index = this.mockData.findIndex(l => l.id === id); + if (index !== -1) { + this.mockData[index] = { ...this.mockData[index], ...level }; + return of(this.mockData[index]).pipe(delay(300)); + } else { + throw new Error(`Level id ${id} not found`); + } + } + return this.http.put(`${this.API_URL}/${id}`, level); + } + + deleteLevel(id: number): Observable { + if (this.USE_MOCK) { + this.mockData = this.mockData.filter(level => level.id !== id); + return of(void 0).pipe(delay(300)); + } + return this.http.delete(`${this.API_URL}/${id}`); + } +} diff --git a/src/app/core/domains/level/level.mock.ts b/src/app/core/domains/level/level.mock.ts new file mode 100644 index 0000000..5db2949 --- /dev/null +++ b/src/app/core/domains/level/level.mock.ts @@ -0,0 +1,7 @@ +import {Level} from './level.model'; + +export const mockLevels: Level[] = [ + { id: 1, name: 'Niveau 1', abr: 'N1' }, + { id: 2, name: 'Niveau 2', abr: 'N2' }, + { id: 3, name: 'Niveau 3', abr: 'N3' } +]; diff --git a/src/app/core/domains/level/level.service.ts b/src/app/core/domains/level/level.service.ts new file mode 100644 index 0000000..e46978e --- /dev/null +++ b/src/app/core/domains/level/level.service.ts @@ -0,0 +1,84 @@ +import {Injectable} from '@angular/core'; +import {LevelApi} from './level.api'; +import {LevelStore} from './level.store'; +import {Level, LevelPost} from './level.model'; +import {catchError, finalize, tap} from 'rxjs/operators'; +import {throwError} from 'rxjs'; + +@Injectable({ + providedIn: 'root' +}) +export class LevelService { + readonly levels$; + readonly isLoading$; + readonly error$; + + constructor( + private levelApi: LevelApi, + private levelStore: LevelStore + ) { + this.levels$ = this.levelStore.levels$; + this.isLoading$ = this.levelStore.isLoading$; + this.error$ = this.levelStore.error$; + } + + loadLevels(): void { + this.levelStore.setLoading(true); + this.levelStore.setError(null); + + this.levelApi.getAllLevels().pipe( + tap((levels: Level[]) => { + this.levelStore.setLevels(levels); + }), + catchError((error) => { + this.levelStore.setError('Erreur lors du chargement des niveaux'); + return throwError(() => error); + }), + finalize(() => { + this.levelStore.setLoading(false); + }) + ).subscribe(); + } + + createLevel(newLevel:LevelPost): void { + this.levelStore.setLoading(true); + + this.levelApi.createLevel(newLevel).pipe( + tap((createdLevel: Level) => { + this.levelStore.addLevel(createdLevel); + }), + catchError((error) => { + this.levelStore.setError('Erreur lors de la création'); + return throwError(() => error); + }), + finalize(() => this.levelStore.setLoading(false)) + ).subscribe(); + } + + updateLevel(id: number, updatedLevel: LevelPost): void { + this.levelStore.setLoading(true); + + this.levelApi.updateLevel(id, updatedLevel).pipe( + tap((level: Level) => { + this.levelStore.updateLevel(level); + }), + catchError((error) => { + this.levelStore.setError('Erreur lors de la mise à jour'); + return throwError(() => error); + }), + finalize(() => this.levelStore.setLoading(false)) + ).subscribe(); + } + + deleteLevel(id: number): void { + this.levelApi.deleteLevel(id).pipe( + tap(() => { + this.levelStore.removeLevel(id); + }), + catchError((error) => { + this.levelStore.setError('Erreur lors de la suppression'); + return throwError(() => error); + }) + ).subscribe(); + } +} diff --git a/src/app/core/domains/level/level.store.ts b/src/app/core/domains/level/level.store.ts new file mode 100644 index 0000000..4e187f1 --- /dev/null +++ b/src/app/core/domains/level/level.store.ts @@ -0,0 +1,44 @@ +import {Injectable} from '@angular/core'; +import {BehaviorSubject} from 'rxjs'; +import {Level} from './level.model'; + +@Injectable({ providedIn: 'root' }) +export class LevelStore { + + private readonly _levels = new BehaviorSubject([]); + private readonly _isLoading = new BehaviorSubject(false); + private readonly _error = new BehaviorSubject(null); + + readonly levels$ = this._levels.asObservable(); + readonly isLoading$ = this._isLoading.asObservable(); + readonly error$ = this._error.asObservable(); + + setLevels(levels: Level[]) { + this._levels.next(levels); + } + + addLevel(level: Level) { + const currentLevels = this._levels.getValue(); + this._levels.next([...currentLevels, level]); + } + + updateLevel(level: Level) { + const currentLevels = this._levels.getValue(); + const updatedLevels = currentLevels.map(l => l.id === level.id ? level : l); + this._levels.next(updatedLevels); + } + + removeLevel(id: number) { + const currentLevels = this._levels.getValue(); + this._levels.next(currentLevels.filter(l => l.id !== id)); + } + + setLoading(isLoading: boolean) { + this._isLoading.next(isLoading); + } + + setError(error: string | null) { + this._error.next(error); + } + +} diff --git a/src/app/features/level/components/form-level/level-form.component.html b/src/app/features/level/components/form-level/level-form.component.html new file mode 100644 index 0000000..794cf65 --- /dev/null +++ b/src/app/features/level/components/form-level/level-form.component.html @@ -0,0 +1,8 @@ + + diff --git a/src/app/features/level/components/form-level/level-form.component.ts b/src/app/features/level/components/form-level/level-form.component.ts new file mode 100644 index 0000000..00fa7c8 --- /dev/null +++ b/src/app/features/level/components/form-level/level-form.component.ts @@ -0,0 +1,52 @@ +import {Component, EventEmitter, Input, Output} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {Validators} from '@angular/forms'; +import {FormComponent, FormField} from '@shared/components/form/form.component'; +import {Level, LevelPost} from '@core/domains/level/level.model'; + +@Component({ + selector: 'app-level-form', + standalone: true, + imports: [CommonModule, FormComponent], + templateUrl: './level-form.component.html', +}) +export class LevelFormComponent { + + @Input() level: Level | null = null; + + @Input() isLoading: boolean = false; + + @Output() save = new EventEmitter(); + @Output() cancel = new EventEmitter(); + + levelFields: FormField[] = [ + { + key: 'name', + label: 'Nom du niveau', + type: 'text', + validators: [Validators.required, Validators.minLength(3)], + errorMessages: { + required: 'Le nom du niveau est obligatoire.', + minlength: 'Le nom doit faire au moins 3 caractères.' + } + }, + { + key: 'abr', + label: 'Abréviation', + type: 'text', + validators: [Validators.required, Validators.maxLength(5)], + errorMessages: { + required: 'L\'abréviation est obligatoire.', + maxlength: 'Maximum 5 caractères autorisés.' + } + } + ]; + + onFormSubmit(formData: any) { + const payload: LevelPost = { + name: formData.name, + abr: formData.abr + }; + this.save.emit(payload); + } +} diff --git a/src/app/features/level/components/group-form/group-form.component.html b/src/app/features/level/components/group-form/group-form.component.html new file mode 100644 index 0000000..931f034 --- /dev/null +++ b/src/app/features/level/components/group-form/group-form.component.html @@ -0,0 +1,8 @@ + + diff --git a/src/app/features/level/components/group-form/group-form.component.ts b/src/app/features/level/components/group-form/group-form.component.ts new file mode 100644 index 0000000..5db9c8e --- /dev/null +++ b/src/app/features/level/components/group-form/group-form.component.ts @@ -0,0 +1,94 @@ +import {Component, EventEmitter, Input, OnChanges, Output, SimpleChanges} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {Validators} from '@angular/forms'; +import {FormComponent, FormField} from '@shared/components/form/form.component'; +import {Level} from '@core/domains/level/level.model'; +import {Group, GroupPost} from '@core/domains/group/group.model'; + +@Component({ + selector: 'app-group-form', + standalone: true, + imports: [CommonModule, FormComponent], + templateUrl: './group-form.component.html', +}) +export class GroupFormComponent implements OnChanges { + + @Input() group: Group | null = null; + + @Input() levels: Level[] = []; + + @Input() isLoading: boolean = false; + + @Output() save = new EventEmitter(); + @Output() cancel = new EventEmitter(); + + groupFields: FormField[] = [ + { + key: 'name', + label: 'Nom du groupe', + type: 'text', + validators: [Validators.required, Validators.minLength(2)], + errorMessages: { required: 'Le nom est obligatoire.', minlength: 'Minimum 2 caractères.' } + }, + { + key: 'type', + label: 'Type de groupe', + type: 'select', + options: [ + { label: 'Cours Magistral (CM)', value: 'CM' }, + { label: 'Travaux Pratiques (TP)', value: 'TP' }, + { label: 'Travaux Dirigés (TD)', value: 'TD' } + ], + validators: [Validators.required], + errorMessages: { required: 'Veuillez sélectionner un type.' } + }, + { + key: 'classe', + label: 'Classe / Parcours', + type: 'text', + validators: [Validators.required], + errorMessages: { required: 'La classe est obligatoire.' } + }, + { + key: 'size', + label: 'Capacité (Nombre d\'élèves)', + type: 'number', + validators: [Validators.required, Validators.min(1)], + errorMessages: { required: 'La capacité est requise.', min: 'La capacité doit être au moins de 1.' } + }, + { + key: 'levelId', + label: 'Niveau associé', + type: 'select', + options: [], + validators: [Validators.required], + errorMessages: { required: 'Vous devez lier ce groupe à un niveau.' } + } + ]; + + ngOnChanges(changes: SimpleChanges): void { + if (changes['levels'] && this.levels) { + + const levelField = this.groupFields.find(f => f.key === 'levelId'); + + if (levelField) { + levelField.options = this.levels.map(level => ({ + label: level.abr, + value: level.id + })); + } + } + } + + onFormSubmit(formData: any) { + const payload: GroupPost = { + name: formData.name, + type: formData.type, + classe: formData.classe, + size: +formData.size, + levelId: +formData.levelId + }; + + this.save.emit(payload); + } +} diff --git a/src/app/features/level/level.component.css b/src/app/features/level/level.component.css new file mode 100644 index 0000000..3e97a18 --- /dev/null +++ b/src/app/features/level/level.component.css @@ -0,0 +1,40 @@ +@reference "tailwindcss"; + +.switch-btn { + @apply px-6 py-2 rounded-lg text-sm font-semibold text-slate-500 transition-all duration-300 ease-in-out cursor-pointer; +} + +.switch-btn:hover:not(.active) { + @apply text-slate-700 bg-slate-200/50; +} + +.switch-btn.active { + @apply bg-white text-blue-600 shadow-md transform scale-100; +} + +.table-wrapper { + @apply bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden; +} + +.badge-level { + @apply bg-indigo-50 text-indigo-700 px-2.5 py-1 rounded-md text-xs font-bold uppercase tracking-wide border border-indigo-100; +} + +.loading-spinner { + @apply text-center py-8 text-slate-500 font-medium animate-pulse; +} + +.fadeIn { + animation: fadeIn 0.3s ease-in-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(5px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/src/app/features/level/level.component.html b/src/app/features/level/level.component.html new file mode 100644 index 0000000..b78b8de --- /dev/null +++ b/src/app/features/level/level.component.html @@ -0,0 +1,112 @@ +
+
+
+

Niveaux et Groupes

+

Gérez vos niveaux et classes en un seul endroit.

+
+
+
+ + +
+
+
+ +
+ + + Nouveau {{ currentView === 'levels' ? 'Niveau' : 'Groupe' }} + +
+ + @if (isLoading$ | async) { +
+ Chargement en cours... +
+ } + + @if (currentView === "levels") { + @if (levels$ | async; as levels) { +
+ + +
+ } + } @else if (currentView === "groups") { + @if ((groups$ | async); as groups) { +
+ + +
+ } + } + +
+ + + {{ levelName }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + @if (levels$ | async; as levels) { + + + } + + + diff --git a/src/app/features/level/level.component.ts b/src/app/features/level/level.component.ts new file mode 100644 index 0000000..37b0c0d --- /dev/null +++ b/src/app/features/level/level.component.ts @@ -0,0 +1,108 @@ +import {Component, OnInit} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {TableColumn, TableComponent} from '@shared/components/table/table.component'; +import {LevelService} from '@core/domains/level/level.service'; +import {GroupService} from '@core/domains/group/group.service'; +import {LucideAngularModule, SquarePen, Trash2} from 'lucide-angular'; +import {ButtonComponent} from '@shared/components/button/button.component'; +import {Level, LevelPost} from '@core/domains/level/level.model'; +import {LevelFormComponent} from '@features/level/components/form-level/level-form.component'; +import {ModalComponent} from '@shared/components/modal/modal.component'; +import {Group, GroupPost} from '@core/domains/group/group.model'; +import {GroupFormComponent} from '@features/level/components/group-form/group-form.component'; + +type ViewMode = 'levels' | 'groups'; + +@Component({ + selector: 'app-level-container', + standalone: true, + imports: [CommonModule, TableComponent, LucideAngularModule, ButtonComponent, LevelFormComponent, ModalComponent, GroupFormComponent], + templateUrl: './level.component.html', + styleUrls: ['./level.component.css'] +}) +export class LevelComponent implements OnInit { + currentView: ViewMode = 'levels'; + levels$ ; + groups$; + isLoading$; + + isModalLevelOpen = false; + selectedLevel: Level | null = null; + + isGroupModalOpen = false; + selectedGroup: Group | null = null; + + readonly SquarePen= SquarePen; + readonly Trash2= Trash2; + + constructor(private levelService: LevelService, private groupService: GroupService) { + this.levels$ = this.levelService.levels$; + this.groups$ = this.groupService.groups$; + this.isLoading$ = this.levelService.isLoading$; + } + + levelColumns: TableColumn[] = [ + { key: 'id', header: 'ID', filterable: true }, + { key: 'name', header: 'Nom du Niveau', filterable: true }, + { key: 'abr', header: 'Abreviation' }, + { key: 'actions', header: 'Actions' } + ]; + + groupColumns: TableColumn[] = [ + { key: 'id', header: 'ID', filterable: true }, + { key: 'name', header: 'Nom du Groupe', filterable: true }, + { key: 'classe', header: 'Classe / Parcours', filterable: true }, + { key: 'levelName', header: 'Niveau Parent', filterable: true }, + { key: 'actions', header: 'Actions' } + ]; + + ngOnInit(): void { + this.levelService.loadLevels(); + this.groupService.loadAllGroups(); + } + + switchView(view: ViewMode) { + this.currentView = view; + } + + openAddModal(){ + if (this.currentView === 'levels') { + this.selectedLevel = null; + this.isModalLevelOpen = true; + } + else { + this.selectedGroup = null; + this.isGroupModalOpen = true; + } + } + + onSaveLevel(levelData: LevelPost): void { + if (this.selectedLevel) { + this.levelService.updateLevel(this.selectedLevel.id, levelData); + } else { + this.levelService.createLevel(levelData); + } + this.isModalLevelOpen = false; + } + + onDeleteLevel(level: Level): void { + if (confirm(`Êtes-vous sûr de vouloir supprimer le niveau "${level.name}" ?`)) { + this.levelService.deleteLevel(level.id); + } + } + + onSaveGroup(groupPayload: GroupPost) { + if (this.selectedGroup) { + this.groupService.updateGroup(this.selectedGroup.id, groupPayload); + } else { + this.groupService.createGroup(groupPayload); + } + this.isGroupModalOpen = false; + } + + onDeleteGroup(group: Group): void { + if (confirm(`Êtes-vous sûr de vouloir supprimer le groupe "${group.name}" ?`)) { + this.groupService.deleteGroup(group.id); + } + } +} diff --git a/src/app/shared/components/button/button.component.css b/src/app/shared/components/button/button.component.css new file mode 100644 index 0000000..f2f645d --- /dev/null +++ b/src/app/shared/components/button/button.component.css @@ -0,0 +1,65 @@ +@reference "tailwindcss"; + +.btn-base { + @apply relative inline-flex items-center justify-center font-semibold rounded-lg transition-all duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-offset-2; +} + +.btn-base:disabled { + @apply opacity-60 cursor-not-allowed; +} + +.btn-full { + @apply w-full; +} + +.btn-sm { + @apply px-3 py-1.5 text-xs; +} + +.btn-md { + @apply px-4 py-2 text-sm; +} + +.btn-lg { + @apply px-6 py-3 text-base; +} + +.btn-primary { + @apply bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500 border border-transparent shadow-sm; +} + +.btn-secondary { + @apply bg-slate-100 text-slate-700 hover:bg-slate-200 focus:ring-slate-500 border border-slate-200; +} + +.btn-danger { + @apply bg-red-600 text-white hover:bg-red-700 focus:ring-red-500 border border-transparent shadow-sm; +} + +.btn-outline { + @apply bg-transparent text-slate-700 border border-slate-300 hover:bg-slate-50 focus:ring-slate-500; +} + +.btn-ghost { + @apply bg-transparent text-slate-600 hover:bg-slate-100 hover:text-slate-900 focus:ring-slate-500 border border-transparent; +} + +.is-loading { + @apply cursor-wait; +} + +.btn-content { + @apply flex items-center justify-center gap-2; +} + +.spinner { + @apply absolute animate-spin w-5 h-5; +} + +.spinner-circle { + @apply opacity-25; +} + +.spinner-path { + @apply opacity-75; +} diff --git a/src/app/shared/components/button/button.component.html b/src/app/shared/components/button/button.component.html new file mode 100644 index 0000000..2052f48 --- /dev/null +++ b/src/app/shared/components/button/button.component.html @@ -0,0 +1,23 @@ + 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..7df0dd6 --- /dev/null +++ b/src/app/shared/components/button/button.component.ts @@ -0,0 +1,45 @@ +import {Component, EventEmitter, Input, Output} from '@angular/core'; +import {CommonModule} from '@angular/common'; + +export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'outline' | 'ghost'; +export type ButtonSize = 'sm' | 'md' | 'lg'; +export type ButtonType = 'button' | 'submit' | 'reset'; + +@Component({ + selector: 'app-button', + standalone: true, + imports: [CommonModule], + templateUrl: './button.component.html', + styleUrls: ['./button.component.css'] +}) +export class ButtonComponent { + + @Input() variant: ButtonVariant = 'primary'; + @Input() size: ButtonSize = 'md'; + @Input() fullWidth: boolean = false; + + @Input() disabled: boolean = false; + @Input() loading: boolean = false; + @Input() type: ButtonType = 'button'; + + @Output() btnClick = new EventEmitter(); + + onClick(event: Event): void { + if (this.disabled || this.loading) { + event.preventDefault(); + event.stopPropagation(); + return; + } + this.btnClick.emit(event); + } + + get buttonClasses(): { [key: string]: boolean } { + return { + 'btn-base': true, + [`btn-${this.size}`]: true, + [`btn-${this.variant}`]: true, + 'btn-full': this.fullWidth, + 'is-loading': this.loading + }; + } +} diff --git a/src/app/shared/components/form/form.component.css b/src/app/shared/components/form/form.component.css new file mode 100644 index 0000000..330f3bb --- /dev/null +++ b/src/app/shared/components/form/form.component.css @@ -0,0 +1,49 @@ +@reference "tailwindcss"; + +.form-container { + @apply flex flex-col gap-5 w-full; +} + +.form-group { + @apply flex flex-col gap-1.5; +} + +.form-label { + @apply text-sm font-semibold text-slate-700 tracking-wide; +} + +.form-control { + @apply w-full px-3 py-2 bg-white border border-slate-300 rounded-lg text-sm text-slate-800 outline-none transition-all duration-200 placeholder-slate-400 shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500; +} + +textarea.form-control { + @apply resize-y; +} + +.input-error { + @apply border-red-500 focus:ring-red-500 focus:border-red-500 bg-red-50/50; +} + +.error-container { + @apply mt-0.5 flex flex-col gap-1; +} + +.error-text { + @apply text-xs text-red-600 font-medium flex items-center; + animation: slideDown 0.2s ease-out forwards; +} + +.form-actions { + @apply flex items-center justify-end gap-3 mt-4 pt-4 border-t border-slate-100; +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-3px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/src/app/shared/components/form/form.component.html b/src/app/shared/components/form/form.component.html new file mode 100644 index 0000000..9c42fba --- /dev/null +++ b/src/app/shared/components/form/form.component.html @@ -0,0 +1,80 @@ +@if (form) { +
+ + @for (field of fields; track $index) { +
+ + + @if (['text', 'number', 'email'].includes(field.type)) { + + } @else if (field.type === 'textarea') { + + } @else if (field.type === 'select') { + + } + + @if (form.get(field.key)?.invalid && form.get(field.key)?.touched) { +
+ @for (error of field.errorMessages | keyvalue; track $index) { + + @if (hasError(field, error.key)) { + + {{ error.value }} + + } + + } +
+ } + + +
+ } + + + +
+ + Annuler + + + + {{ initialData ? 'Mettre à jour' : submitLabel }} + +
+ +
+} + 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..ca01596 --- /dev/null +++ b/src/app/shared/components/form/form.component.ts @@ -0,0 +1,79 @@ +import {Component, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {FormBuilder, FormGroup, ReactiveFormsModule, ValidatorFn} from '@angular/forms'; +import {ButtonComponent} from '@shared/components/button/button.component'; + +export interface FormField { + key: string; + label: string; + type: 'text' | 'number' | 'email' | 'select' | 'textarea'; + options?: { label: string, value: any }[]; + validators?: ValidatorFn[]; + errorMessages?: { [key: string]: string }; +} + +@Component({ + selector: 'app-form', + standalone: true, + imports: [CommonModule, ReactiveFormsModule, ButtonComponent], + templateUrl: './form.component.html', + styleUrls: ['./form.component.css'] +}) +export class FormComponent implements OnInit, OnChanges { + + @Input() fields: FormField[] = []; + + @Input() initialData: any = null; + + @Input() isLoading: boolean = false; + @Input() submitLabel: string = 'Sauvegarder'; + + @Output() save = new EventEmitter(); + @Output() cancel = new EventEmitter(); + + form!: FormGroup; + + constructor(private fb: FormBuilder) {} + + ngOnInit(): void { + this.buildForm(); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes['initialData'] && this.form) { + if (this.initialData) { + this.form.patchValue(this.initialData); + } else { + this.form.reset(); + } + } + } + + private buildForm(): void { + const group: any = {}; + + this.fields.forEach(field => { + group[field.key] = ['', field.validators || []]; + }); + + this.form = this.fb.group(group); + + if (this.initialData) { + this.form.patchValue(this.initialData); + } + } + + onSubmit(): void { + if (this.form.invalid) { + this.form.markAllAsTouched(); + return; + } + this.save.emit(this.form.value); + this.form.reset(); + } + + hasError(field: FormField, errorType: string): boolean { + const control = this.form.get(field.key); + return !!(control && control.invalid && control.touched && control.hasError(errorType)); + } +} diff --git a/src/app/shared/components/modal/modal.component.css b/src/app/shared/components/modal/modal.component.css new file mode 100644 index 0000000..9e113ad --- /dev/null +++ b/src/app/shared/components/modal/modal.component.css @@ -0,0 +1,75 @@ +@reference "tailwindcss"; + +.modal-backdrop { + @apply fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/40 backdrop-blur-sm overflow-y-auto; + animation: fadeIn 0.2s ease-out forwards; +} + +.modal-panel { + @apply relative flex flex-col w-full bg-white rounded-xl shadow-2xl max-h-[90vh]; + animation: scaleUp 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + +.modal-sm { + @apply max-w-sm; +} + +.modal-md { + @apply max-w-lg; +} + +.modal-lg { + @apply max-w-2xl; +} + +.modal-xl { + @apply max-w-4xl; +} + +.modal-full { + @apply max-w-[95vw] h-[95vh]; +} + +.modal-header { + @apply flex items-center justify-between px-6 py-4 border-b border-slate-200 bg-white z-10 rounded-t-xl; +} + +.modal-title { + @apply text-lg font-semibold text-slate-800 m-0; +} + +.modal-close-btn { + @apply p-1.5 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-slate-200; +} + +.modal-body { + @apply px-6 py-5 overflow-y-auto text-slate-600 text-sm flex-1; +} + +.modal-footer { + @apply px-6 py-4 border-t border-slate-200 bg-slate-50 flex items-center justify-end gap-3 rounded-b-xl z-10; +} + +.modal-footer:empty { + @apply hidden; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes scaleUp { + from { + opacity: 0; + transform: scale(0.95) translateY(10px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} diff --git a/src/app/shared/components/modal/modal.component.html b/src/app/shared/components/modal/modal.component.html new file mode 100644 index 0000000..b57e95e --- /dev/null +++ b/src/app/shared/components/modal/modal.component.html @@ -0,0 +1,31 @@ +@if (isOpen) { + +} diff --git a/src/app/shared/components/modal/modal.component.ts b/src/app/shared/components/modal/modal.component.ts new file mode 100644 index 0000000..d06c581 --- /dev/null +++ b/src/app/shared/components/modal/modal.component.ts @@ -0,0 +1,59 @@ +import {Component, EventEmitter, HostListener, Input, OnChanges, OnDestroy, Output, SimpleChanges} from '@angular/core'; +import {CommonModule} from '@angular/common'; + +export type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'full'; + +@Component({ + selector: 'app-modal', + standalone: true, + imports: [CommonModule], + templateUrl: './modal.component.html', + styleUrls: ['./modal.component.css'] +}) +export class ModalComponent implements OnChanges, OnDestroy { + @Input() isOpen: boolean = false; + + @Input() title: string = ''; + @Input() size: ModalSize = 'md'; + @Input() closeOnBackdropClick: boolean = true; + + @Output() isOpenChange = new EventEmitter(); + @Output() onClose = new EventEmitter(); + + ngOnChanges(changes: SimpleChanges): void { + if (changes['isOpen']) { + if (this.isOpen) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = ''; + } + } + } + + ngOnDestroy(): void { + document.body.style.overflow = ''; + } + + close(): void { + this.isOpen = false; + this.isOpenChange.emit(this.isOpen); + this.onClose.emit(); + } + + onBackdropClick(event: MouseEvent): void { + if (this.closeOnBackdropClick && (event.target as HTMLElement).classList.contains('modal-backdrop')) { + this.close(); + } + } + + @HostListener('document:keydown.escape', ['$event']) + onKeydownHandler(event: Event): void { + if (this.isOpen && this.closeOnBackdropClick) { + this.close(); + } + } + + get modalSizeClass(): string { + return `modal-${this.size}`; + } +} diff --git a/src/app/shared/components/table/table.component.css b/src/app/shared/components/table/table.component.css new file mode 100644 index 0000000..0a4f6d2 --- /dev/null +++ b/src/app/shared/components/table/table.component.css @@ -0,0 +1,38 @@ +@reference "tailwindcss" + +.table-container { + @apply w-full overflow-x-auto shadow-md rounded-lg bg-white border border-slate-200; +} + +.custom-table { + @apply w-full text-left border-collapse text-sm text-slate-700 font-sans; +} + +.custom-table th, +.custom-table td { + @apply px-4 py-3 border-b border-slate-200; +} + +.custom-table th { + @apply bg-slate-50 align-top; +} + +.custom-table tbody tr { + @apply hover:bg-slate-50 transition-colors duration-200; +} + +.header-title { + @apply font-semibold uppercase text-xs tracking-wider mb-2 text-slate-500; +} + +.filter-input { + @apply w-full px-2 py-1.5 border border-slate-300 rounded text-sm outline-none transition-all duration-200 bg-white; +} + +.filter-input:focus { + @apply border-blue-500 ring-2 ring-blue-100; +} + +.empty-state { + @apply text-center p-8 text-slate-400 italic bg-slate-50/50; +} diff --git a/src/app/shared/components/table/table.component.html b/src/app/shared/components/table/table.component.html new file mode 100644 index 0000000..87f8251 --- /dev/null +++ b/src/app/shared/components/table/table.component.html @@ -0,0 +1,46 @@ +
+ + + + @for (col of columns; track $index) { + + } + + + + + @for (row of filteredData; track $index) { + + @for (col of columns; track $index) { + + } + + } + @if (filteredData.length === 0) { + + + + } + +
+
{{ col.header }}
+ @if (col.filterable) { + + } +
+ @if (customTemplates[col.key]) { + + + } @else { + {{ getRowValue(row, col.key) }} + } + +
+ Aucune donnée correspondante +
+
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..b65f3d6 --- /dev/null +++ b/src/app/shared/components/table/table.component.ts @@ -0,0 +1,56 @@ +import {Component, Input, OnChanges, SimpleChanges, TemplateRef} from '@angular/core'; +import {CommonModule} from '@angular/common'; + +export interface TableColumn { + key: string; + header: string; + filterable?: boolean; +} + +@Component({ + selector: 'app-table', + standalone: true, + imports: [CommonModule], + templateUrl: './table.component.html', + styleUrls: ['./table.component.css'] +}) +export class TableComponent implements OnChanges { + @Input() data: T[] = []; + @Input() columns: TableColumn[] = []; + @Input() customTemplates: { [key: string]: TemplateRef } = {}; + + filteredData: T[] = []; + + filters: { [key: string]: string } = {}; + + ngOnChanges(changes: SimpleChanges) { + if (changes['data']) { + this.applyFilters(); + } + } + + onFilterChange(columnKey: string, event: Event) { + const inputElement = event.target as HTMLInputElement; + this.filters[columnKey] = inputElement.value.toLowerCase(); + this.applyFilters(); + } + + applyFilters() { + this.filteredData = this.data.filter(item => { + for (const key in this.filters) { + const filterValue = this.filters[key]; + if (filterValue) { + const itemValue = String((item as any)[key] || '').toLowerCase(); + if (!itemValue.includes(filterValue)) { + return false; + } + } + } + return true; + }); + } + + getRowValue(row: T, key: string): any { + return (row as any)[key]; + } +} diff --git a/tsconfig.json b/tsconfig.json index 16b13e3..17c2674 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,7 +14,7 @@ "importHelpers": true, "target": "ES2022", "module": "preserve", - "baseUrl": ".", + "baseUrl": "./", "paths": { "@core/*": ["src/app/core/*"], "@shared/*": ["src/app/shared/*"],