Compare commits

...

2 Commits

Author SHA1 Message Date
7f8104e7e4 fix(auth): correctif login et parsing user (version stable)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-17 15:54:24 +01:00
c4d93ee458 Merge develop into master (squash)
- Ticket #92: endpoint GET /gestionnaires, dashboard admin données réelles
- Seed données de test, script reset-and-seed-db.sh
- Modèles frontend (Parent, AM), user_service, widgets admin

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 22:55:38 +01:00
26 changed files with 1275 additions and 194 deletions

View File

@ -35,7 +35,7 @@ export class AssistantesMaternellesController {
return this.assistantesMaternellesService.create(dto);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@Get()
@ApiOperation({ summary: 'Récupérer la liste des nounous' })
@ApiResponse({ status: 200, description: 'Liste des nounous' })

View File

@ -20,7 +20,7 @@ import { UpdateParentsDto } from '../user/dto/update_parent.dto';
export class ParentsController {
constructor(private readonly parentsService: ParentsService) {}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@Get()
@ApiResponse({ status: 200, type: [Parents], description: 'Liste des parents' })
@ApiResponse({ status: 403, description: 'Accès refusé !' })

View File

@ -35,7 +35,7 @@ export class GestionnairesController {
return this.gestionnairesService.create(dto);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@ApiOperation({ summary: 'Liste des gestionnaires' })
@ApiResponse({ status: 200, description: 'Liste des gestionnaires : ', type: [Users] })
@Get()

View File

@ -3,9 +3,13 @@ import { GestionnairesService } from './gestionnaires.service';
import { GestionnairesController } from './gestionnaires.controller';
import { Users } from 'src/entities/users.entity';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthModule } from 'src/routes/auth/auth.module';
@Module({
imports: [TypeOrmModule.forFeature([Users])],
imports: [
TypeOrmModule.forFeature([Users]),
AuthModule,
],
controllers: [GestionnairesController],
providers: [GestionnairesService],
})

View File

@ -28,7 +28,7 @@ export class UserController {
// Lister tous les utilisateurs (super_admin uniquement)
@Get()
@Roles(RoleType.SUPER_ADMIN)
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
@ApiOperation({ summary: 'Lister tous les utilisateurs' })
findAll() {
return this.userService.findAll();

View File

@ -9,6 +9,7 @@ import { ParentsModule } from '../parents/parents.module';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { AssistantesMaternellesModule } from '../assistantes_maternelles/assistantes_maternelles.module';
import { Parents } from 'src/entities/parents.entity';
import { GestionnairesModule } from './gestionnaires/gestionnaires.module';
@Module({
imports: [TypeOrmModule.forFeature(
@ -20,6 +21,7 @@ import { Parents } from 'src/entities/parents.entity';
]), forwardRef(() => AuthModule),
ParentsModule,
AssistantesMaternellesModule,
GestionnairesModule,
],
controllers: [UserController],
providers: [UserService],

View File

@ -41,6 +41,16 @@ docker compose -f docker-compose.dev.yml down -v
---
## Réinitialiser la BDD et charger les données de test (dashboard admin)
Depuis la **racine du projet** (ptitspas-app, où se trouve `docker-compose.yml`) :
```bash
./scripts/reset-and-seed-db.sh
```
Ce script : arrête les conteneurs, supprime le volume Postgres, redémarre la base (le schéma est recréé via `BDD.sql`), puis exécute `database/seed/03_seed_test_data.sql`. Tu obtiens un super_admin (`admin@ptits-pas.fr`) plus 9 comptes de test (1 admin, 1 gestionnaire, 2 AM, 5 parents) avec **mot de passe : `password`**. Idéal pour développer le ticket #92 (dashboard admin).
## Importation automatique des données de test
Les données de test (CSV) sont automatiquement importées dans la base au démarrage du conteneur Docker grâce aux scripts présents dans le dossier `migrations/`.

View File

@ -0,0 +1,73 @@
-- ============================================================
-- 03_seed_test_data.sql : Données de test complètes (dashboard admin)
-- Aligné sur utilisateurs-test-complet.json
-- Mot de passe universel : password (bcrypt)
-- À exécuter après BDD.sql (init DB)
-- ============================================================
BEGIN;
-- Hash bcrypt pour "password" (10 rounds)
-- ========== UTILISATEURS (1 admin + 1 gestionnaire + 2 AM + 5 parents) ==========
-- On garde admin@ptits-pas.fr (super_admin) déjà créé par BDD.sql
INSERT INTO utilisateurs (id, email, password, prenom, nom, role, statut, telephone, adresse, ville, code_postal, profession, situation_familiale, date_naissance, consentement_photo)
VALUES
('a0000001-0001-0001-0001-000000000001', 'sophie.bernard@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Sophie', 'BERNARD', 'administrateur', 'actif', '0678123456', '12 Avenue Gabriel Péri', 'Bezons', '95870', 'Responsable administrative', 'marie', '1978-03-15', false),
('a0000002-0002-0002-0002-000000000002', 'lucas.moreau@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Lucas', 'MOREAU', 'gestionnaire', 'actif', '0687234567', '8 Rue Jean Jaurès', 'Bezons', '95870', 'Gestionnaire des placements', 'celibataire', '1985-09-22', false),
('a0000003-0003-0003-0003-000000000003', 'marie.dubois@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Marie', 'DUBOIS', 'assistante_maternelle', 'actif', '0696345678', '25 Rue de la République', 'Bezons', '95870', 'Assistante maternelle', 'marie', '1980-06-08', true),
('a0000004-0004-0004-0004-000000000004', 'fatima.elmansouri@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Fatima', 'EL MANSOURI', 'assistante_maternelle', 'actif', '0675456789', '17 Boulevard Aristide Briand', 'Bezons', '95870', 'Assistante maternelle', 'marie', '1975-11-12', true),
('a0000005-0005-0005-0005-000000000005', 'claire.martin@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Claire', 'MARTIN', 'parent', 'actif', '0689567890', '5 Avenue du Général de Gaulle', 'Bezons', '95870', 'Infirmière', 'marie', '1990-04-03', false),
('a0000006-0006-0006-0006-000000000006', 'thomas.martin@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Thomas', 'MARTIN', 'parent', 'actif', '0678456789', '5 Avenue du Général de Gaulle', 'Bezons', '95870', 'Ingénieur', 'marie', '1988-07-18', false),
('a0000007-0007-0007-0007-000000000007', 'amelie.durand@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Amélie', 'DURAND', 'parent', 'actif', '0667788990', '23 Rue Victor Hugo', 'Bezons', '95870', 'Comptable', 'divorce', '1987-12-14', false),
('a0000008-0008-0008-0008-000000000008', 'julien.rousseau@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Julien', 'ROUSSEAU', 'parent', 'actif', '0656677889', '14 Rue Pasteur', 'Bezons', '95870', 'Commercial', 'divorce', '1985-08-29', false),
('a0000009-0009-0009-0009-000000000009', 'david.lecomte@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'David', 'LECOMTE', 'parent', 'actif', '0645566778', '31 Rue Émile Zola', 'Bezons', '95870', 'Développeur web', 'parent_isole', '1992-10-07', false)
ON CONFLICT (email) DO NOTHING;
-- ========== PARENTS (avec co-parent pour le couple Martin) ==========
INSERT INTO parents (id_utilisateur, id_co_parent)
VALUES
('a0000005-0005-0005-0005-000000000005', 'a0000006-0006-0006-0006-000000000006'),
('a0000006-0006-0006-0006-000000000006', 'a0000005-0005-0005-0005-000000000005'),
('a0000007-0007-0007-0007-000000000007', NULL),
('a0000008-0008-0008-0008-000000000008', NULL),
('a0000009-0009-0009-0009-000000000009', NULL)
ON CONFLICT (id_utilisateur) DO NOTHING;
-- ========== ASSISTANTES MATERNELLES ==========
INSERT INTO assistantes_maternelles (id_utilisateur, numero_agrement, nir_chiffre, nb_max_enfants, biographie, date_agrement, ville_residence, disponible, place_disponible)
VALUES
('a0000003-0003-0003-0003-000000000003', 'AGR-2019-095001', '280069512345671', 4, 'Assistante maternelle agréée depuis 2019. Spécialité bébés 0-18 mois. Accueil bienveillant et cadre sécurisant. 2 places disponibles.', '2019-09-01', 'Bezons', true, 2),
('a0000004-0004-0004-0004-000000000004', 'AGR-2017-095002', '275119512345672', 3, 'Assistante maternelle expérimentée. Spécialité 1-3 ans. Accueil à la journée. 1 place disponible.', '2017-06-15', 'Bezons', true, 1)
ON CONFLICT (id_utilisateur) DO NOTHING;
-- ========== ENFANTS ==========
INSERT INTO enfants (id, prenom, nom, genre, date_naissance, statut, est_multiple)
VALUES
('e0000001-0001-0001-0001-000000000001', 'Emma', 'MARTIN', 'F', '2023-02-15', 'actif', true),
('e0000002-0002-0002-0002-000000000002', 'Noah', 'MARTIN', 'H', '2023-02-15', 'actif', true),
('e0000003-0003-0003-0003-000000000003', 'Léa', 'MARTIN', 'F', '2023-02-15', 'actif', true),
('e0000004-0004-0004-0004-000000000004', 'Chloé', 'ROUSSEAU', 'F', '2022-04-20', 'actif', false),
('e0000005-0005-0005-0005-000000000005', 'Hugo', 'ROUSSEAU', 'H', '2024-03-10', 'actif', false),
('e0000006-0006-0006-0006-000000000006', 'Maxime', 'LECOMTE', 'H', '2023-04-15', 'actif', false)
ON CONFLICT (id) DO NOTHING;
-- ========== ENFANTS_PARENTS (liaison N:N) ==========
-- Martin (Claire + Thomas) -> Emma, Noah, Léa
INSERT INTO enfants_parents (id_parent, id_enfant)
VALUES
('a0000005-0005-0005-0005-000000000005', 'e0000001-0001-0001-0001-000000000001'),
('a0000005-0005-0005-0005-000000000005', 'e0000002-0002-0002-0002-000000000002'),
('a0000005-0005-0005-0005-000000000005', 'e0000003-0003-0003-0003-000000000003'),
('a0000006-0006-0006-0006-000000000006', 'e0000001-0001-0001-0001-000000000001'),
('a0000006-0006-0006-0006-000000000006', 'e0000002-0002-0002-0002-000000000002'),
('a0000006-0006-0006-0006-000000000006', 'e0000003-0003-0003-0003-000000000003'),
('a0000007-0007-0007-0007-000000000007', 'e0000004-0004-0004-0004-000000000004'),
('a0000007-0007-0007-0007-000000000007', 'e0000005-0005-0005-0005-000000000005'),
('a0000008-0008-0008-0008-000000000008', 'e0000004-0004-0004-0004-000000000004'),
('a0000008-0008-0008-0008-000000000008', 'e0000005-0005-0005-0005-000000000005'),
('a0000009-0009-0009-0009-000000000009', 'e0000006-0006-0006-0006-000000000006')
ON CONFLICT DO NOTHING;
COMMIT;

View File

@ -55,6 +55,8 @@ services:
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET}
JWT_REFRESH_EXPIRES: ${JWT_REFRESH_EXPIRES}
NODE_ENV: ${NODE_ENV}
LOG_API_REQUESTS: ${LOG_API_REQUESTS:-false}
CONFIG_ENCRYPTION_KEY: ${CONFIG_ENCRYPTION_KEY}
depends_on:
- database
labels:

View File

@ -23,11 +23,12 @@
| 10 | [Backend] Service Configuration | ✅ Fermé |
| 11 | [Backend] API Configuration | ✅ Fermé |
| 12 | [Backend] Guard Configuration Initiale | ✅ Fermé |
| 13 | [Backend] Adaptation MailService pour config dynamique | Ouvert |
| 13 | [Backend] Adaptation MailService pour config dynamique | ✅ Fermé |
| 14 | [Frontend] Panneau Paramètres / Configuration (première config + accès permanent) | Ouvert |
| 15 | [Frontend] Écran Paramètres (accès permanent) | Ouvert |
| 16 | [Doc] Documentation configuration on-premise | Ouvert |
| 1788 | (voir sections cidessous ; #78, #79, #81, #83, #82, #86, #87, #88, etc.) | — |
| 1788 | (voir sections cidessous ; #82, #78, #79, #81, #83 ; #86, #87, #88 fermés en doublon) | — |
| 92 | [Frontend] Dashboard Admin - Données réelles et branchement API | Ouvert |
*Gitea #1 et #2 = anciens tickets de test (fermés). Liste complète : https://git.ptits-pas.fr/jmartin/petitspas/issues*
@ -229,6 +230,8 @@ Créer un Guard/Middleware qui détecte si la configuration initiale est incompl
**Référence** : [21_CONFIGURATION-SYSTEME.md](./21_CONFIGURATION-SYSTEME.md#workflow-setup-initial)
*Issue Gitea #86 fermée en doublon ; ce ticket (#12) est la référence.*
---
### Ticket #13 : [Backend] Adaptation MailService pour config dynamique
@ -267,6 +270,8 @@ Un seul panneau **Paramètres / Configuration** dans le dashboard admin, avec **
**Référence** : [21_CONFIGURATION-SYSTEME.md](./21_CONFIGURATION-SYSTEME.md#interface-admin)
*Issue Gitea #87 fermée en doublon de #14.*
---
### Ticket #15 : [Frontend] Écran Paramètres (accès permanent) / Intégration panneau
@ -281,6 +286,8 @@ Sassurer que le panneau Paramètres (décrit en #14) est accessible en perman
- [ ] Chargement des valeurs actuelles (GET `/configuration` ou par catégorie)
- [ ] Modification et sauvegarde (PATCH bulk) sans appel à `setup/complete`
*Issue Gitea #88 fermée en doublon ; ce ticket (#15) est la référence.*
---
### Ticket #16 : [Doc] Documentation configuration on-premise
@ -302,19 +309,8 @@ Rédiger la documentation pour aider les collectivités à configurer l'applicat
---
### Ticket #86 : [Backend] Guard Configuration Initiale (concept v1.3)
**Estimation** : 2h
**Labels** : `backend`, `p1-bloquant`, `on-premise`
Issue Gitea ouverte pour le Guard aligné avec le concept v1.3 (pas de redirection vers `/admin/setup`, le frontend affiche le panneau Configuration et bloque la navigation). Voir aussi Ticket #12 (version fermée).
---
### Ticket #88 : [Frontend] Intégration panneau Paramètres au dashboard
**Estimation** : 1h
**Labels** : `frontend`, `p1-bloquant`, `on-premise`
Complément de #14 et #15 : sassurer que le panneau Paramètres est accessible en permanence (onglet Configuration, chargement des valeurs, sauvegarde PATCH bulk sans `setup/complete`).
### Ticket #86 / #88 : Doublons fermés
*#86* fermé en doublon de **#12** (Guard). *#88* fermé en doublon de **#15** (Intégration panneau). Voir les tickets #12, #14 et #15 pour le travail à faire.
---
@ -898,6 +894,30 @@ Créer l'écran de gestion des documents légaux (CGU/Privacy) pour l'admin.
---
### Ticket #92 : [Frontend] Dashboard Admin - Données réelles et branchement API
**Estimation** : 8h
**Labels** : `frontend`, `p3`, `admin`
**Description** :
Le dashboard admin (onglets Gestionnaires | Parents | Assistantes maternelles | Administrateurs) affiche actuellement des données en dur (mock). Remplacer par des appels API pour afficher les vrais utilisateurs et permettre les actions de gestion (voir, modifier, valider/refuser). Référence : [90_AUDIT.md](./90_AUDIT.md).
**Fichiers concernés** :
- `gestionnaire_management_widget.dart` — liste actuellement 5 cartes "Dupont" en dur
- `parent_managmant_widget.dart` — 2 parents simulés
- `assistante_maternelle_management_widget.dart` — 2 AM simulées
**Tâches** :
- [ ] S'assurer que les endpoints backend existent (liste users par rôle)
- [ ] Onglet Gestionnaires : appel API, affichage dynamique, recherche, lien "Créer gestionnaire"
- [ ] Onglet Parents : appel API, affichage dynamique, recherche/filtres, actions Voir/Modifier/Valider/Refuser
- [ ] Onglet Assistantes maternelles : appel API, affichage dynamique, filtres, actions
- [ ] Onglet Administrateurs : liste ou placeholder documenté
- [ ] Gestion états (chargement, erreur, liste vide) et rafraîchissement après actions
**Références** : #44, #45, #46 (dashboard Gestionnaire), #25, #26 (API liste/validation), #17, #35 (création gestionnaire)
---
### Ticket #50 : [Frontend] Affichage dynamique CGU lors inscription
**Estimation** : 2h
**Labels** : `frontend`, `p3`, `juridique`
@ -1237,7 +1257,7 @@ Rédiger les documents légaux génériques (CGU et Politique de confidentialit
- **Juridique** : 1 ticket
### Modifications par rapport à la version initiale
- ✅ **v1.4** : Numéros de section du doc = numéros Gitea (Ticket #n = issue #n). Tableau et sections renumérotés en conséquence ; #87 fermé (doublon de #14).
- ✅ **v1.4** : Numéros de section du doc = numéros Gitea (Ticket #n = issue #n). Tableau et sections renumérotés. Doublons #86, #87, #88 fermés sur Gitea (#86#12, #87#14, #88#15) ; tickets sources #12, #14, #15 mis à jour (doc + body Gitea).
- ✅ **Concept v1.3** : Configuration initiale = un seul panneau Paramètres (3 sections) dans le dashboard ; plus de page dédiée « Setup Wizard » ; navigation bloquée jusquà sauvegarde au premier déploiement. Tickets #10, #12, #13 alignés.
- ❌ **Supprimé** : Tickets "Renvoyer email validation" (backend + frontend) - Pas prioritaire
- ✅ **Ajouté** : Ticket #55 "Service Logging Winston" - Monitoring essentiel

View File

@ -0,0 +1,63 @@
# Note Backend - Activation du module Gestionnaires (Ticket #92)
## Problème
L'endpoint `GET /api/v1/gestionnaires` renvoie une erreur **404 Not Found**.
Cela est dû au fait que le `GestionnairesModule` n'est pas importé dans l'arbre des modules de l'application (via `UserModule` ou `AppModule`).
## Solution de contournement actuelle (Frontend)
Le frontend utilise actuellement l'endpoint générique `/api/v1/users` et filtre les résultats côté client pour ne garder que les utilisateurs ayant le rôle `gestionnaire`.
*Fichier concerné : `frontend/lib/services/user_service.dart`*
## Correctif Backend à appliquer
Pour activer proprement l'endpoint dédié, il faut effectuer les modifications suivantes dans le backend :
### 1. Importer le module dans `UserModule`
Fichier : `backend/src/routes/user/user.module.ts`
Ajouter `GestionnairesModule` dans les imports.
```typescript
import { GestionnairesModule } from './gestionnaires/gestionnaires.module';
@Module({
imports: [
// ... autres imports
GestionnairesModule, // <--- AJOUTER ICI
],
// ...
})
export class UserModule { }
```
### 2. Ajouter AuthModule dans `GestionnairesModule`
Fichier : `backend/src/routes/user/gestionnaires/gestionnaires.module.ts`
Le contrôleur utilise `AuthGuard`, qui dépend de `JwtService` fourni par `AuthModule`.
```typescript
import { AuthModule } from 'src/routes/auth/auth.module';
@Module({
imports: [
TypeOrmModule.forFeature([Users]),
AuthModule // <--- AJOUTER ICI
],
controllers: [GestionnairesController],
providers: [GestionnairesService],
})
export class GestionnairesModule { }
```
## Après application du correctif
Une fois ces modifications backend effectuées :
1. Redémarrer le serveur backend.
2. Modifier le frontend (`frontend/lib/services/user_service.dart`) pour utiliser à nouveau l'endpoint dédié :
```dart
static Future<List<AppUser>> getGestionnaires() async {
final response = await http.get(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.gestionnaires}'),
headers: await _headers(),
);
// ...
}
```

176
docs/PROCEDURE-API-GITEA.md Normal file
View File

@ -0,0 +1,176 @@
# Procédure Utilisation de lAPI Gitea
## 1. Contexte
- **Instance** : https://git.ptits-pas.fr
- **API de base** : `https://git.ptits-pas.fr/api/v1`
- **Projet P'titsPas** : dépôt `jmartin/petitspas` (owner = `jmartin`, repo = `petitspas`)
## 2. Authentification
### 2.1 Token
Le token est défini dans lenvironnement (ex. `~/.bashrc`) :
```bash
export GITEA_TOKEN="<votre_token>"
```
Pour lutiliser dans les commandes :
```bash
source ~/.bashrc # ou : . ~/.bashrc
# Puis utiliser $GITEA_TOKEN dans les curl
```
### 2.2 En-tête HTTP
Toutes les requêtes API doivent envoyer le token :
```bash
-H "Authorization: token $GITEA_TOKEN"
```
Exemple :
```bash
curl -s -H "Authorization: token $GITEA_TOKEN" \
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas"
```
## 3. Endpoints utiles
### 3.1 Dépôt (repository)
| Action | Méthode | URL |
|---------------|---------|-----|
| Infos dépôt | GET | `/repos/{owner}/{repo}` |
| Liste dépôts | GET | `/repos/search?q=petitspas` |
Exemple infos du dépôt :
```bash
curl -s -H "Authorization: token $GITEA_TOKEN" \
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas" | jq .
```
### 3.2 Issues (tickets)
| Action | Méthode | URL |
|------------------|---------|-----|
| Liste des issues | GET | `/repos/{owner}/{repo}/issues` |
| Détail dune issue | GET | `/repos/{owner}/{repo}/issues/{index}` |
| Créer une issue | POST | `/repos/{owner}/{repo}/issues` |
| Modifier une issue | PATCH | `/repos/{owner}/{repo}/issues/{index}` |
| Fermer une issue | PATCH | (même URL, `state: "closed"`) |
**Paramètres GET utiles pour la liste :**
- `state` : `open` ou `closed`
- `labels` : filtre par label (ex. `frontend`)
- `page`, `limit` : pagination
Exemples :
```bash
# Toutes les issues ouvertes
curl -s -H "Authorization: token $GITEA_TOKEN" \
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues?state=open" | jq .
# Issues ouvertes avec label "frontend"
curl -s -H "Authorization: token $GITEA_TOKEN" \
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues?state=open" | \
jq '.[] | select(.labels[].name == "frontend") | {number, title, state}'
# Détail de lissue #47
curl -s -H "Authorization: token $GITEA_TOKEN" \
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/47" | jq .
# Fermer lissue #31
curl -s -X PATCH -H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"state":"closed"}' \
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/31"
# Créer une issue
curl -s -X POST -H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"title":"Titre du ticket","body":"Description","labels":[1]}' \
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues"
```
### 3.3 Pull requests
| Action | Méthode | URL |
|---------------|---------|-----|
| Liste des PR | GET | `/repos/{owner}/{repo}/pulls` |
| Détail dune PR | GET | `/repos/{owner}/{repo}/pulls/{index}` |
| Créer une PR | POST | `/repos/{owner}/{repo}/pulls` |
| Fusionner une PR | POST | `/repos/{owner}/{repo}/pulls/{index}/merge` |
Exemples :
```bash
# Liste des PR ouvertes
curl -s -H "Authorization: token $GITEA_TOKEN" \
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/pulls?state=open" | jq .
# Créer une PR (head = branche source, base = branche cible)
curl -s -X POST -H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"head":"develop","base":"master","title":"Titre de la PR"}' \
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/pulls"
```
### 3.4 Branches
| Action | Méthode | URL |
|---------------|---------|-----|
| Liste des branches | GET | `/repos/{owner}/{repo}/branches` |
```bash
curl -s -H "Authorization: token $GITEA_TOKEN" \
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/branches" | jq '.[].name'
```
### 3.5 Webhooks
| Action | Méthode | URL |
|---------------|---------|-----|
| Liste webhooks | GET | `/repos/{owner}/{repo}/hooks` |
| Créer webhook | POST | `/repos/{owner}/{repo}/hooks` |
### 3.6 Labels
| Action | Méthode | URL |
|---------------|---------|-----|
| Liste des labels | GET | `/repos/{owner}/{repo}/labels` |
```bash
curl -s -H "Authorization: token $GITEA_TOKEN" \
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels" | jq '.[] | {id, name}'
```
## 4. Résumé des URLs pour P'titsPas
Remplacer `{owner}` par `jmartin` et `{repo}` par `petitspas` :
| Ressource | URL |
|------------------|-----|
| Dépôt | `https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas` |
| Issues | `https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues` |
| Issue #n | `https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/{n}` |
| Pull requests | `https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/pulls` |
| Branches | `https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/branches` |
| Labels | `https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels` |
## 5. Documentation officielle
- Swagger / OpenAPI : https://docs.gitea.com/api
- Référence selon la version de Gitea installée (ex. 1.21, 1.25).
## 6. Dépannage
- **401 Unauthorized** : vérifier le token et len-tête `Authorization: token <TOKEN>`.
- **404** : vérifier owner/repo et lURL (sensible à la casse).
- **422 / body invalide** : pour POST/PATCH, envoyer `Content-Type: application/json` et un JSON valide.

115
docs/STATUS-APPLICATION.md Normal file
View File

@ -0,0 +1,115 @@
# Statut de l'application P'titsPas
**Date du point** : 8 février 2026
---
## 1. Environnement de production
| Élément | Statut | Détail |
|--------|--------|--------|
| **URL** | OK | https://app.ptits-pas.fr |
| **Frontend** | 200 | Flutter Web, Nginx |
| **API** | 200 | NestJS, préfixe `/api/v1` |
| **Base de données** | OK | PostgreSQL 17 |
| **PgAdmin** | OK | https://app.ptits-pas.fr/pgadmin |
### Conteneurs Docker
| Service | Image | État |
|---------|--------|------|
| ptitspas-frontend | ptitspas-app-frontend | Up (recréé récemment) |
| ptitspas-backend | ptitspas-app-backend | Up ~26h |
| ptitspas-postgres | postgres:17 | Up ~28h |
| ptitspas-pgadmin | dpage/pgadmin4 | Up ~28h |
---
## 2. Dépôt Git
- **Branche déployée** : `master`
- **Derniers commits** :
- `10bf255` fix(ui): renforcer ombre boutons Parents/AM sur mobile
- `678f421` docs: ticket #82 fermé (écran Login mobile)
- `5295e8e` Merge develop: login mobile, formulaire sous slogan par ratio
- `6bf0932` docs: Index, doc API Gitea, script fermeture issue
- `2f1740b` docs: ticket #83 RegisterChoiceScreen Mobile (terminé)
- **Branches actives** : `master`, `develop`, diverses `feature/*` (inscription, config, documents légaux, etc.)
---
## 3. Déploiement (hook Gitea)
| Élément | Statut |
|--------|--------|
| **Webhook** | Opérationnel (`hooks.ptits-pas.fr/hooks/petitspas-deploy`) |
| **Déclencheur** | Push sur `master`, dépôt `petitspas` |
| **Script** | Monté depuis lhôte (verrou + sans Prisma) |
| **Dernier déploiement** | 08/02/2026 18:18:26 Succès |
Un seul déploiement à la fois (verrou) ; plus détape Prisma dans le script.
---
## 4. Fonctionnalités livrées
### Backend (API)
- Auth : login, refresh, profil, **changement MDP obligatoire** (first login)
- Configuration : setup status, bulk, test SMTP, catégories
- Documents légaux : actifs, versions, upload, activation, téléchargement
- Inscription : parents (workflow complet), enfants (CRUD)
- Compte super_admin par défaut (seed BDD) : `admin@ptits-pas.fr` / `4dm1n1strateur`
### Frontend
- **Formulaires dinscription** : compatibles **desktop et mobile**
- Choix dinscription (Parents / Assistante maternelle) responsive
- Inscription Parent : étapes 1 à 5 (infos parent 1 & 2, enfants, présentation, CGU, récap)
- Inscription AM : étapes 1 à 4 (identité, pro, présentation, récap)
- **Login** : écran adapté mobile (formulaire sous slogan selon ratio)
- Modale **changement de mot de passe obligatoire** après première connexion si `changement_mdp_obligatoire`
- CORS configuré (localhost + prod)
### Base de données
- Schéma database-first (BDD.sql)
- Tables : utilisateurs, configuration, documents_legaux, acceptations_documents, enfants, etc.
- Champs tokens création MDP, genre enfants, configuration système
---
## 5. Tickets / Priorités (résumé)
- **Liste détaillée** : `docs/23_LISTE-TICKETS.md`
- **Récent fermé** : #82 (Login mobile), #83 (RegisterChoiceScreen mobile), #73, #78, #79, #81
- **P0 (BDD)** : quelques amendements ouverts (champs CDC, présentation dossier, etc.)
- **P1** : configuration système (panneau Paramètres, 3 sections, première config + accès permanent)
- **P2/P3** : backend métier et frontend (dashboards, écrans création MDP, etc.)
---
## 6. Documentation utile
| Fichier | Usage |
|---------|--------|
| `00_INDEX.md` | Index de la doc |
| `01_CAHIER-DES-CHARGES.md` | CDC v1.3 |
| `11_API.md` | Endpoints API |
| `20_WORKFLOW-CREATION-COMPTE.md` | Workflow création compte |
| `23_LISTE-TICKETS.md` | Liste des tickets |
| `BRIEFING-FRONTEND.md` | Brief frontend, accès Git, tickets prioritaires |
| `PROCEDURE-API-GITEA.md` | Utilisation API Gitea (issues, PR, token) |
---
## 7. Synthèse
Lapplication est **en production** sur https://app.ptits-pas.fr avec :
- Frontend et API accessibles et répondant en 200.
- Déploiement automatique sur push `master` avec script à jour (verrou, sans Prisma).
- Formulaires dinscription (Parents et AM) **responsive desktop et mobile**.
- Login et changement de mot de passe obligatoire opérationnels.
- Prochaines priorités : P0 BDD si besoin, P1 panneau Paramètres / Configuration (tickets #12, #13), puis dashboards et workflows métier (P2/P3).

View File

@ -0,0 +1,30 @@
import 'package:p_tits_pas/models/user.dart';
class AssistanteMaternelleModel {
final AppUser user;
final String? approvalNumber;
final String? residenceCity;
final int? maxChildren;
final int? placesAvailable;
AssistanteMaternelleModel({
required this.user,
this.approvalNumber,
this.residenceCity,
this.maxChildren,
this.placesAvailable,
});
factory AssistanteMaternelleModel.fromJson(Map<String, dynamic> json) {
final userJson = json['user'] ?? json;
final user = AppUser.fromJson(userJson);
return AssistanteMaternelleModel(
user: user,
approvalNumber: json['numero_agrement'] as String?,
residenceCity: json['ville_residence'] as String?,
maxChildren: json['nb_max_enfants'] as int?,
placesAvailable: json['place_disponible'] as int?,
);
}
}

View File

@ -0,0 +1,18 @@
import 'package:p_tits_pas/models/user.dart';
class ParentModel {
final AppUser user;
final int childrenCount;
ParentModel({required this.user, this.childrenCount = 0});
factory ParentModel.fromJson(Map<String, dynamic> json) {
final userJson = json['user'] ?? json;
final user = AppUser.fromJson(userJson);
final children = json['parentChildren'] as List?;
return ParentModel(
user: user,
childrenCount: children?.length ?? 0,
);
}
}

View File

@ -5,6 +5,14 @@ class AppUser {
final DateTime createdAt;
final DateTime updatedAt;
final bool changementMdpObligatoire;
final String? nom;
final String? prenom;
final String? statut;
final String? telephone;
final String? photoUrl;
final String? adresse;
final String? ville;
final String? codePostal;
AppUser({
required this.id,
@ -13,20 +21,53 @@ class AppUser {
required this.createdAt,
required this.updatedAt,
this.changementMdpObligatoire = false,
this.nom,
this.prenom,
this.statut,
this.telephone,
this.photoUrl,
this.adresse,
this.ville,
this.codePostal,
});
factory AppUser.fromJson(Map<String, dynamic> json) {
final id = json['id']?.toString();
final email = json['email']?.toString();
final role = json['role']?.toString();
if (id == null || id.isEmpty) {
throw Exception('Profil invalide: id manquant');
}
if (email == null || email.isEmpty) {
throw Exception('Profil invalide: email manquant');
}
if (role == null || role.isEmpty) {
throw Exception('Profil invalide: rôle manquant');
}
return AppUser(
id: json['id'] as String,
email: json['email'] as String,
role: json['role'] as String,
createdAt: json['createdAt'] != null
? DateTime.parse(json['createdAt'] as String)
: DateTime.now(),
updatedAt: json['updatedAt'] != null
? DateTime.parse(json['updatedAt'] as String)
: DateTime.now(),
changementMdpObligatoire: json['changement_mdp_obligatoire'] as bool? ?? false,
id: id,
email: email,
role: role,
createdAt: json['cree_le'] != null
? DateTime.tryParse(json['cree_le'].toString()) ?? DateTime.now()
: (json['createdAt'] != null
? DateTime.tryParse(json['createdAt'].toString()) ?? DateTime.now()
: DateTime.now()),
updatedAt: json['modifie_le'] != null
? DateTime.tryParse(json['modifie_le'].toString()) ?? DateTime.now()
: (json['updatedAt'] != null
? DateTime.tryParse(json['updatedAt'].toString()) ?? DateTime.now()
: DateTime.now()),
changementMdpObligatoire:
json['changement_mdp_obligatoire'] == true,
nom: json['nom']?.toString(),
prenom: json['prenom']?.toString(),
statut: json['statut']?.toString(),
telephone: json['telephone']?.toString(),
photoUrl: json['photo_url']?.toString(),
adresse: json['adresse']?.toString(),
ville: json['ville']?.toString(),
codePostal: json['code_postal']?.toString(),
);
}
@ -38,6 +79,16 @@ class AppUser {
'createdAt': createdAt.toIso8601String(),
'updatedAt': updatedAt.toIso8601String(),
'changement_mdp_obligatoire': changementMdpObligatoire,
'nom': nom,
'prenom': prenom,
'statut': statut,
'telephone': telephone,
'photo_url': photoUrl,
'adresse': adresse,
'ville': ville,
'code_postal': codePostal,
};
}
String get fullName => '${prenom ?? ''} ${nom ?? ''}'.trim();
}

View File

@ -3,6 +3,7 @@ import 'package:p_tits_pas/services/configuration_service.dart';
import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/parent_managmant_widget.dart';
import 'package:p_tits_pas/widgets/admin/admin_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/parametres_panel.dart';
import 'package:p_tits_pas/widgets/app_footer.dart';
import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart';
@ -104,7 +105,7 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
case 2:
return const AssistanteMaternelleManagementWidget();
case 3:
return const Center(child: Text('👨‍💼 Administrateurs'));
return const AdminManagementWidget();
default:
return const Center(child: Text('Page non trouvée'));
}

View File

@ -236,20 +236,20 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(bottom: 15),
decoration: BoxDecoration(
color: Colors.red[50],
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.red[300]!),
border: Border.all(color: Colors.red.shade300),
),
child: Row(
children: [
Icon(Icons.error_outline, color: Colors.red[700], size: 20),
Icon(Icons.error_outline, color: Colors.red.shade700, size: 20),
const SizedBox(width: 10),
Expanded(
child: Text(
_errorMessage!,
style: GoogleFonts.merienda(
fontSize: 12,
color: Colors.red[700],
color: Colors.red.shade700,
),
),
),

View File

@ -15,6 +15,9 @@ class ApiConfig {
static const String users = '/users';
static const String userProfile = '/users/profile';
static const String userChildren = '/users/children';
static const String gestionnaires = '/gestionnaires';
static const String parents = '/parents';
static const String assistantesMaternelles = '/assistantes-maternelles';
// Configuration (admin)
static const String configuration = '/configuration';

View File

@ -43,7 +43,8 @@ class AuthService {
}
} catch (e) {
if (e is Exception) rethrow;
throw Exception('Erreur réseau: impossible de se connecter au serveur');
if (e is Error) throw Exception('Erreur interne: ${e.toString()}');
throw Exception('Erreur réseau: impossible de se connecter au serveur ($e)');
}
}
@ -56,14 +57,22 @@ class AuthService {
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
final raw = jsonDecode(response.body);
if (raw is! Map<String, dynamic>) {
throw Exception('Profil invalide: réponse serveur inattendue');
}
// Accepter réponse directe ou wrapper { data: {...} }
final data = raw.containsKey('data') && raw['data'] is Map<String, dynamic>
? raw['data'] as Map<String, dynamic>
: raw;
return AppUser.fromJson(data);
} else {
throw Exception('Erreur lors de la récupération du profil');
}
} catch (e) {
if (e is Exception) rethrow;
throw Exception('Erreur réseau: impossible de récupérer le profil');
if (e is Error) throw Exception('Erreur interne: ${e.toString()}');
throw Exception('Erreur réseau: impossible de récupérer le profil ($e)');
}
}
@ -98,7 +107,8 @@ class AuthService {
await _saveCurrentUser(user);
} catch (e) {
if (e is Exception) rethrow;
throw Exception('Erreur réseau: impossible de changer le mot de passe');
if (e is Error) throw Exception('Erreur interne: ${e.toString()}');
throw Exception('Erreur réseau: impossible de changer le mot de passe ($e)');
}
}

View File

@ -0,0 +1,94 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/models/parent_model.dart';
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/services/api/tokenService.dart';
class UserService {
static Future<Map<String, String>> _headers() async {
final token = await TokenService.getToken();
return token != null
? ApiConfig.authHeaders(token)
: Map<String, String>.from(ApiConfig.headers);
}
static String? _toStr(dynamic v) {
if (v == null) return null;
if (v is String) return v;
return v.toString();
}
// Récupérer la liste des gestionnaires (endpoint dédié)
static Future<List<AppUser>> getGestionnaires() async {
final response = await http.get(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.gestionnaires}'),
headers: await _headers(),
);
if (response.statusCode != 200) {
final err = jsonDecode(response.body) as Map<String, dynamic>?;
throw Exception(_toStr(err?['message']) ?? 'Erreur chargement gestionnaires');
}
final List<dynamic> data = jsonDecode(response.body);
return data.map((e) => AppUser.fromJson(e)).toList();
}
// Récupérer la liste des parents
static Future<List<ParentModel>> getParents() async {
final response = await http.get(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parents}'),
headers: await _headers(),
);
if (response.statusCode != 200) {
final err = jsonDecode(response.body) as Map<String, dynamic>?;
throw Exception(_toStr(err?['message']) ?? 'Erreur chargement parents');
}
final List<dynamic> data = jsonDecode(response.body);
return data.map((e) => ParentModel.fromJson(e)).toList();
}
// Récupérer la liste des assistantes maternelles
static Future<List<AssistanteMaternelleModel>> getAssistantesMaternelles() async {
final response = await http.get(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}'),
headers: await _headers(),
);
if (response.statusCode != 200) {
final err = jsonDecode(response.body) as Map<String, dynamic>?;
throw Exception(_toStr(err?['message']) ?? 'Erreur chargement AM');
}
final List<dynamic> data = jsonDecode(response.body);
return data.map((e) => AssistanteMaternelleModel.fromJson(e)).toList();
}
// Récupérer la liste des administrateurs (via /users filtré ou autre)
// Pour l'instant on va utiliser /users et filtrer côté client si on est super admin
static Future<List<AppUser>> getAdministrateurs() async {
// TODO: Endpoint dédié ou filtrage
// En attendant, on retourne une liste vide ou on tente /users
try {
final response = await http.get(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}'),
headers: await _headers(),
);
if (response.statusCode == 200) {
final List<dynamic> data = jsonDecode(response.body);
return data
.map((e) => AppUser.fromJson(e))
.where((u) => u.role == 'administrateur' || u.role == 'super_admin')
.toList();
}
} catch (e) {
print('Erreur chargement admins: $e');
}
return [];
}
}

View File

@ -0,0 +1,141 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/user_service.dart';
class AdminManagementWidget extends StatefulWidget {
const AdminManagementWidget({super.key});
@override
State<AdminManagementWidget> createState() => _AdminManagementWidgetState();
}
class _AdminManagementWidgetState extends State<AdminManagementWidget> {
bool _isLoading = false;
String? _error;
List<AppUser> _admins = [];
List<AppUser> _filteredAdmins = [];
final TextEditingController _searchController = TextEditingController();
@override
void initState() {
super.initState();
_loadAdmins();
_searchController.addListener(_onSearchChanged);
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _loadAdmins() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final list = await UserService.getAdministrateurs();
if (!mounted) return;
setState(() {
_admins = list;
_filteredAdmins = list;
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
void _onSearchChanged() {
final query = _searchController.text.toLowerCase();
setState(() {
_filteredAdmins = _admins.where((u) {
final name = u.fullName.toLowerCase();
final email = u.email.toLowerCase();
return name.contains(query) || email.contains(query);
}).toList();
});
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: TextField(
controller: _searchController,
decoration: const InputDecoration(
hintText: "Rechercher un administrateur...",
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 16),
ElevatedButton.icon(
onPressed: () {
// TODO: Créer admin
},
icon: const Icon(Icons.add),
label: const Text("Créer un admin"),
),
],
),
const SizedBox(height: 24),
if (_isLoading)
const Center(child: CircularProgressIndicator())
else if (_error != null)
Center(child: Text('Erreur: $_error', style: const TextStyle(color: Colors.red)))
else if (_filteredAdmins.isEmpty)
const Center(child: Text("Aucun administrateur trouvé."))
else
Expanded(
child: ListView.builder(
itemCount: _filteredAdmins.length,
itemBuilder: (context, index) {
final user = _filteredAdmins[index];
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: ListTile(
leading: CircleAvatar(
child: Text(user.fullName.isNotEmpty
? user.fullName[0].toUpperCase()
: 'A'),
),
title: Text(user.fullName.isNotEmpty
? user.fullName
: 'Sans nom'),
subtitle: Text(user.email),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit),
onPressed: () {},
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: () {},
),
],
),
),
);
},
),
)
],
),
);
}
}

View File

@ -1,72 +1,142 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
import 'package:p_tits_pas/services/user_service.dart';
class AssistanteMaternelleManagementWidget extends StatelessWidget {
class AssistanteMaternelleManagementWidget extends StatefulWidget {
const AssistanteMaternelleManagementWidget({super.key});
@override
Widget build(BuildContext context) {
final assistantes = [
{
"nom": "Marie Dupont",
"numeroAgrement": "AG123456",
"zone": "Paris 14",
"capacite": 3,
},
{
"nom": "Claire Martin",
"numeroAgrement": "AG654321",
"zone": "Lyon 7",
"capacite": 2,
},
];
State<AssistanteMaternelleManagementWidget> createState() =>
_AssistanteMaternelleManagementWidgetState();
}
class _AssistanteMaternelleManagementWidgetState
extends State<AssistanteMaternelleManagementWidget> {
bool _isLoading = false;
String? _error;
List<AssistanteMaternelleModel> _assistantes = [];
List<AssistanteMaternelleModel> _filteredAssistantes = [];
final TextEditingController _zoneController = TextEditingController();
final TextEditingController _capacityController = TextEditingController();
@override
void initState() {
super.initState();
_loadAssistantes();
_zoneController.addListener(_filter);
_capacityController.addListener(_filter);
}
@override
void dispose() {
_zoneController.dispose();
_capacityController.dispose();
super.dispose();
}
Future<void> _loadAssistantes() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final list = await UserService.getAssistantesMaternelles();
if (!mounted) return;
setState(() {
_assistantes = list;
_filter();
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
void _filter() {
final zoneQuery = _zoneController.text.toLowerCase();
final capacityQuery = int.tryParse(_capacityController.text);
setState(() {
_filteredAssistantes = _assistantes.where((am) {
final matchesZone = zoneQuery.isEmpty ||
(am.residenceCity?.toLowerCase().contains(zoneQuery) ?? false);
final matchesCapacity = capacityQuery == null ||
(am.maxChildren != null && am.maxChildren! >= capacityQuery);
return matchesZone && matchesCapacity;
}).toList();
});
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 🔎 Zone de filtre
_buildFilterSection(),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 🔎 Zone de filtre
_buildFilterSection(),
const SizedBox(height: 16),
const SizedBox(height: 16),
// 📋 Liste des assistantes
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: assistantes.length,
itemBuilder: (context, index) {
final assistante = assistantes[index];
return Card(
margin: const EdgeInsets.symmetric(vertical: 8),
child: ListTile(
leading: const Icon(Icons.face),
title: Text(assistante['nom'].toString()),
subtitle: Text(
"N° Agrément : ${assistante['numeroAgrement']}\nZone : ${assistante['zone']} | Capacité : ${assistante['capacite']}"),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit),
onPressed: () {
// TODO: Ajouter modification
},
// 📋 Liste des assistantes
if (_isLoading)
const Center(child: CircularProgressIndicator())
else if (_error != null)
Center(child: Text('Erreur: $_error', style: const TextStyle(color: Colors.red)))
else if (_filteredAssistantes.isEmpty)
const Center(child: Text("Aucune assistante maternelle trouvée."))
else
Expanded(
child: ListView.builder(
itemCount: _filteredAssistantes.length,
itemBuilder: (context, index) {
final assistante = _filteredAssistantes[index];
return Card(
margin: const EdgeInsets.symmetric(vertical: 8),
child: ListTile(
leading: CircleAvatar(
backgroundImage: assistante.user.photoUrl != null
? NetworkImage(assistante.user.photoUrl!)
: null,
child: assistante.user.photoUrl == null
? const Icon(Icons.face)
: null,
),
title: Text(assistante.user.fullName.isNotEmpty
? assistante.user.fullName
: 'Sans nom'),
subtitle: Text(
"N° Agrément : ${assistante.approvalNumber ?? 'N/A'}\nZone : ${assistante.residenceCity ?? 'N/A'} | Capacité : ${assistante.maxChildren ?? 0}"),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit),
onPressed: () {
// TODO: Ajouter modification
},
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: () {
// TODO: Ajouter suppression
},
),
],
),
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: () {
// TODO: Ajouter suppression
},
),
],
),
);
},
),
);
},
),
],
),
),
],
),
);
}
@ -78,26 +148,23 @@ class AssistanteMaternelleManagementWidget extends StatelessWidget {
SizedBox(
width: 200,
child: TextField(
controller: _zoneController,
decoration: const InputDecoration(
labelText: "Zone géographique",
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.location_on),
),
onChanged: (value) {
// TODO: Ajouter logique de filtrage par zone
},
),
),
SizedBox(
width: 200,
child: TextField(
controller: _capacityController,
decoration: const InputDecoration(
labelText: "Capacité minimum",
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
onChanged: (value) {
// TODO: Ajouter logique de filtrage par capacité
},
),
),
],

View File

@ -1,9 +1,70 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/gestionnaire_card.dart';
class GestionnaireManagementWidget extends StatelessWidget {
class GestionnaireManagementWidget extends StatefulWidget {
const GestionnaireManagementWidget({Key? key}) : super(key: key);
@override
State<GestionnaireManagementWidget> createState() =>
_GestionnaireManagementWidgetState();
}
class _GestionnaireManagementWidgetState
extends State<GestionnaireManagementWidget> {
bool _isLoading = false;
String? _error;
List<AppUser> _gestionnaires = [];
List<AppUser> _filteredGestionnaires = [];
final TextEditingController _searchController = TextEditingController();
@override
void initState() {
super.initState();
_loadGestionnaires();
_searchController.addListener(_onSearchChanged);
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _loadGestionnaires() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final list = await UserService.getGestionnaires();
if (!mounted) return;
setState(() {
_gestionnaires = list;
_filteredGestionnaires = list;
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
void _onSearchChanged() {
final query = _searchController.text.toLowerCase();
setState(() {
_filteredGestionnaires = _gestionnaires.where((u) {
final name = u.fullName.toLowerCase();
final email = u.email.toLowerCase();
return name.contains(query) || email.contains(query);
}).toList();
});
}
@override
Widget build(BuildContext context) {
return Padding(
@ -14,9 +75,10 @@ class GestionnaireManagementWidget extends StatelessWidget {
// 🔹 Barre du haut avec bouton
Row(
children: [
const Expanded(
Expanded(
child: TextField(
decoration: InputDecoration(
controller: _searchController,
decoration: const InputDecoration(
hintText: "Rechercher un gestionnaire...",
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(),
@ -26,7 +88,7 @@ class GestionnaireManagementWidget extends StatelessWidget {
const SizedBox(width: 16),
ElevatedButton.icon(
onPressed: () {
// Rediriger vers la page de création
// TODO: Rediriger vers la page de création
},
icon: const Icon(Icons.add),
label: const Text("Créer un gestionnaire"),
@ -36,17 +98,25 @@ class GestionnaireManagementWidget extends StatelessWidget {
const SizedBox(height: 24),
// 🔹 Liste des gestionnaires
Expanded(
child: ListView.builder(
itemCount: 5, // À remplacer par liste dynamique
itemBuilder: (context, index) {
return GestionnaireCard(
name: "Dupont $index",
email: "dupont$index@mail.com",
);
},
),
)
if (_isLoading)
const Center(child: CircularProgressIndicator())
else if (_error != null)
Center(child: Text('Erreur: $_error', style: const TextStyle(color: Colors.red)))
else if (_filteredGestionnaires.isEmpty)
const Center(child: Text("Aucun gestionnaire trouvé."))
else
Expanded(
child: ListView.builder(
itemCount: _filteredGestionnaires.length,
itemBuilder: (context, index) {
final user = _filteredGestionnaires[index];
return GestionnaireCard(
name: user.fullName.isNotEmpty ? user.fullName : "Sans nom",
email: user.email,
);
},
),
)
],
),
);

View File

@ -1,83 +1,149 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/parent_model.dart';
import 'package:p_tits_pas/services/user_service.dart';
class ParentManagementWidget extends StatelessWidget {
class ParentManagementWidget extends StatefulWidget {
const ParentManagementWidget({super.key});
@override
Widget build(BuildContext context) {
// 🔁 Simulation de données parents
final parents = [
{
"nom": "Jean Dupuis",
"email": "jean.dupuis@email.com",
"statut": "Actif",
"enfants": 2,
},
{
"nom": "Lucie Morel",
"email": "lucie.morel@email.com",
"statut": "En attente",
"enfants": 1,
},
];
State<ParentManagementWidget> createState() => _ParentManagementWidgetState();
}
class _ParentManagementWidgetState extends State<ParentManagementWidget> {
bool _isLoading = false;
String? _error;
List<ParentModel> _parents = [];
List<ParentModel> _filteredParents = [];
final TextEditingController _searchController = TextEditingController();
String? _selectedStatus;
@override
void initState() {
super.initState();
_loadParents();
_searchController.addListener(_filter);
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _loadParents() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final list = await UserService.getParents();
if (!mounted) return;
setState(() {
_parents = list;
_filter(); // Apply initial filter (if any)
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
void _filter() {
final query = _searchController.text.toLowerCase();
setState(() {
_filteredParents = _parents.where((p) {
final matchesName = p.user.fullName.toLowerCase().contains(query) ||
p.user.email.toLowerCase().contains(query);
final matchesStatus = _selectedStatus == null ||
_selectedStatus == 'Tous' ||
(p.user.statut?.toLowerCase() == _selectedStatus?.toLowerCase());
// Mapping simple pour le statut affiché vs backend
// Backend: en_attente, actif, suspendu
// Dropdown: En attente, Actif, Suspendu
return matchesName && matchesStatus;
}).toList();
});
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSearchSection(),
const SizedBox(height: 16),
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: parents.length,
itemBuilder: (context, index) {
final parent = parents[index];
return Card(
margin: const EdgeInsets.symmetric(vertical: 8),
child: ListTile(
leading: const Icon(Icons.person_outline),
title: Text(parent['nom'].toString()),
subtitle: Text(
"${parent['email']}\nStatut : ${parent['statut']} | Enfants : ${parent['enfants']}",
),
isThreeLine: true,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.visibility),
tooltip: "Voir dossier",
onPressed: () {
// TODO: Voir le statut du dossier
},
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSearchSection(),
const SizedBox(height: 16),
if (_isLoading)
const Center(child: CircularProgressIndicator())
else if (_error != null)
Center(child: Text('Erreur: $_error', style: const TextStyle(color: Colors.red)))
else if (_filteredParents.isEmpty)
const Center(child: Text("Aucun parent trouvé."))
else
Expanded(
child: ListView.builder(
itemCount: _filteredParents.length,
itemBuilder: (context, index) {
final parent = _filteredParents[index];
return Card(
margin: const EdgeInsets.symmetric(vertical: 8),
child: ListTile(
leading: CircleAvatar(
backgroundImage: parent.user.photoUrl != null
? NetworkImage(parent.user.photoUrl!)
: null,
child: parent.user.photoUrl == null
? const Icon(Icons.person)
: null,
),
title: Text(parent.user.fullName.isNotEmpty
? parent.user.fullName
: 'Sans nom'),
subtitle: Text(
"${parent.user.email}\nStatut : ${parent.user.statut ?? 'Inconnu'} | Enfants : ${parent.childrenCount}",
),
isThreeLine: true,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.visibility),
tooltip: "Voir dossier",
onPressed: () {
// TODO: Voir le statut du dossier
},
),
IconButton(
icon: const Icon(Icons.edit),
tooltip: "Modifier",
onPressed: () {
// TODO: Modifier parent
},
),
IconButton(
icon: const Icon(Icons.delete),
tooltip: "Supprimer",
onPressed: () {
// TODO: Supprimer compte
},
),
],
),
),
IconButton(
icon: const Icon(Icons.edit),
tooltip: "Modifier",
onPressed: () {
// TODO: Modifier parent
},
),
IconButton(
icon: const Icon(Icons.delete),
tooltip: "Supprimer",
onPressed: () {
// TODO: Supprimer compte
},
),
],
),
);
},
),
);
},
),
],
)
),
],
),
);
}
@ -89,13 +155,12 @@ class ParentManagementWidget extends StatelessWidget {
SizedBox(
width: 220,
child: TextField(
controller: _searchController,
decoration: const InputDecoration(
labelText: "Nom du parent",
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.search),
),
onChanged: (value) {
// TODO: Ajouter logique de recherche
},
),
),
SizedBox(
@ -105,13 +170,18 @@ class ParentManagementWidget extends StatelessWidget {
labelText: "Statut",
border: OutlineInputBorder(),
),
value: _selectedStatus,
items: const [
DropdownMenuItem(value: "Actif", child: Text("Actif")),
DropdownMenuItem(value: "En attente", child: Text("En attente")),
DropdownMenuItem(value: "Supprimé", child: Text("Supprimé")),
DropdownMenuItem(value: null, child: Text("Tous")),
DropdownMenuItem(value: "actif", child: Text("Actif")),
DropdownMenuItem(value: "en_attente", child: Text("En attente")),
DropdownMenuItem(value: "suspendu", child: Text("Suspendu")),
],
onChanged: (value) {
// TODO: Ajouter logique de filtrage
setState(() {
_selectedStatus = value;
_filter();
});
},
),
),

61
scripts/reset-and-seed-db.sh Executable file
View File

@ -0,0 +1,61 @@
#!/usr/bin/env bash
# ============================================================
# reset-and-seed-db.sh : Réinitialise la BDD et injecte les données de test
# Usage : depuis la racine du projet ptitspas-app
# ./scripts/reset-and-seed-db.sh
# ============================================================
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$PROJECT_ROOT"
echo "=== Réinitialisation BDD + seed données de test ==="
echo "Projet : $PROJECT_ROOT"
echo ""
# 1) Arrêter les conteneurs et supprimer le volume Postgres
echo "[1/4] Arrêt des conteneurs et suppression du volume Postgres..."
docker compose down -v 2>/dev/null || docker-compose down -v 2>/dev/null || true
# 2) Démarrer uniquement la base
echo "[2/4] Démarrage du conteneur database..."
docker compose up -d database 2>/dev/null || docker-compose up -d database 2>/dev/null
# 3) Attendre que Postgres soit prêt
echo "[3/4] Attente du démarrage de Postgres..."
for i in {1..30}; do
if docker exec ptitspas-postgres pg_isready -U admin -d ptitpas_db 2>/dev/null; then
echo " Postgres prêt."
break
fi
if [ "$i" -eq 30 ]; then
echo "Erreur : Postgres ne répond pas après 30 tentatives."
exit 1
fi
sleep 1
done
# Petit délai supplémentaire pour la fin de l'init (BDD.sql)
sleep 2
# 4) Exécuter le seed des données de test
echo "[4/4] Exécution du seed (03_seed_test_data.sql)..."
docker exec -i ptitspas-postgres psql -U admin -d ptitpas_db < database/seed/03_seed_test_data.sql
echo ""
echo "=== Terminé ==="
echo "Comptes de test (mot de passe : password) :"
echo " - admin@ptits-pas.fr (super_admin, créé par BDD.sql)"
echo " - sophie.bernard@ptits-pas.fr (administrateur)"
echo " - lucas.moreau@ptits-pas.fr (gestionnaire)"
echo " - marie.dubois@ptits-pas.fr (assistante maternelle)"
echo " - fatima.elmansouri@ptits-pas.fr (assistante maternelle)"
echo " - claire.martin@ptits-pas.fr (parent)"
echo " - thomas.martin@ptits-pas.fr (parent)"
echo " - amelie.durand@ptits-pas.fr (parent)"
echo " - julien.rousseau@ptits-pas.fr (parent)"
echo " - david.lecomte@ptits-pas.fr (parent)"
echo ""
echo "Tu peux redémarrer le backend/frontend si besoin : docker compose up -d"