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.
+
+
+
+
+ Niveaux
+
+
+ Groupes
+
+
+
+
+
+
+
+ + 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 @@
+
+
+ @if (loading) {
+
+
+
+
+ }
+
+
+
+
+
+
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) {
+
+}
+
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 @@
+
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/*"],