Compare commits

...
Author SHA1 Message Date
jmartinandCursor 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
jmartinandCursor 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
jmartin 1fca0cf132 Merge branch 'master' of https://git.ptits-pas.fr/jmartin/petitspas 2026-02-16 16:22:16 +01:00
jmartinandCursor b16dd4b55c merge: resolution conflits develop -> master (ticket #14)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 16:22:04 +01:00
jmartinandCursor 8682421453 Merge develop into master (squash)
- feat(#90): API Inscription AM - POST /auth/register/am
- Suppression legacy register/parent/legacy
- BDD assistantes_maternelles alignée entité
- Script test register AM

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 16:19:23 +01:00
jmartinandCursor 31bd8c3175 fix(#90): BDD assistantes_maternelles alignée entité + script test curl
- BDD.sql: ville_residence, annee_experience, specialite, date_agrement nullable
- scripts/test-register-am.sh pour tester POST /auth/register/am

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 16:18:06 +01:00
jmartinandCursor c94f2cf0d5 feat(#90): API Inscription AM - POST /auth/register/am
- DTO RegisterAMCompletDto (identité, photo, infos pro, CGU)
- Endpoint POST /auth/register/am + inscrireAMComplet() (transaction User + AssistanteMaternelle)
- Photo base64, token création MDP, consentement photo
- Suppression legacy: route register/parent/legacy, registerParent(), RegisterParentDto
- Frontend: ApiConfig.registerAM pour ticket #91

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 00:05:23 +01:00
jmartinandCursor dfe7daed14 Merge squash develop into master (incl. #14 première config setup/complete)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-15 23:20:15 +01:00
jmartin 111935e451 Merge branch 'feature/14-premiere-connexion-config' into develop (#14) 2026-02-15 23:20:00 +01:00
jmartinandCursor ae3292a7fc fix(backend): setup/complete accepte userId null pour éviter erreur UUID (#14)
- completeSetup: userId = req.user?.id ?? null (plus de fallback 'system')
- markSetupCompleted(userId: string | null), set(..., userId ?? undefined)
- Corrige 'invalid input syntax for type uuid: "system"' au clic Sauvegarder

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-15 23:19:18 +01:00
jmartinandCursor 8e8c6d79b1 feat(#14): finalisation redirection et nettoyage
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-15 23:08:02 +01:00
jmartinandCursor 6752dc97b4 feat(#14): redirection première connexion config
- Redirection vers /login après première config réussie
- Gestion défensive des réponses API (200/201, bool/string)
- Force l'onglet Paramètres si setup non terminé

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-15 23:02:12 +01:00
jmartinandCursor 31857ec891 docs(#14): note back config/setup + frontend parsing erreurs
- docs/14_NOTE-BACKEND-CONFIG-SETUP.md : modifs à faire côté back (UUID system)
- configuration_service : parsing défensif des réponses d'erreur (évite JSNull)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 16:10:04 +01:00
jmartinandCursor ca7ef862da feat(admin): première connexion → panneau Paramètres, reste grisé jusqu'à Sauvegarder (#14)
- Au chargement admin: appel getSetupStatus(), si non terminé → onglet Paramètres par défaut
- Onglet Gestion des utilisateurs grisé et inaccessible tant que setup non complété
- Sauvegarder: updateBulk + completeSetup + déblocage des panneaux
- Tester SMTP: saveBulkOnly puis test (sans completeSetup, panneaux restent verrouillés)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 15:54:47 +01:00
jmartinandCursor 11aa66feff Merge squash develop into master (incl. #89 log API requests debug)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 15:26:06 +01:00
jmartin 358eefdab3 Merge branch 'feature/89-log-api-requests' into develop (#89) 2026-02-13 15:25:56 +01:00
jmartinandCursor d23f3c9f4f feat(admin): panneau Paramètres - sauvegarde config + test SMTP (#15)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 15:23:00 +01:00
jmartinandCursor 1834eb8c79 feat(admin): panneau Paramètres - sauvegarde config + test SMTP
- Onglet Paramètres dans l'admin avec 3 sections (Email, Personnalisation, Avancé)
- Service ConfigurationService (GET config, PATCH bulk, POST test-smtp)
- Bouton Sauvegarder et bouton Tester SMTP (sauvegarde avant test)
- Endpoints api_config pour configuration

Closes #15

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 11:16:37 +01:00
jmartinandCursor 0386785f81 feat(backend): log des appels API en mode debug (#89)
- Ajout LogRequestInterceptor (méthode, URL, query, body)
- Activé via LOG_API_REQUESTS=true
- Masquage des champs sensibles (password, smtp_password, token...)
- Enregistrement global dans main.ts, doc dans .env.example

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 11:15:41 +01:00
jmartinandCursor c43f55bed6 Merge origin/develop (conflit 23_LISTE-TICKETS résolu, garde numéros = Gitea)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-10 00:20:59 +01:00
jmartinandCursor 0c48a5c06f Merge branch 'develop' (docs concept v1.3, conflit résolu)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-10 00:20:32 +01:00
jmartinandCursor be8b1f23ed docs: concept v1.3 config (panneau Paramètres 3 sections, numéros = Gitea)
- 21_CONFIGURATION-SYSTEME: workflow sans /admin/setup, 3 sections, panneau unique
- 23_LISTE-TICKETS: numéros de section = numéros Gitea, tickets #14/#15 alignés
- 24_DECISIONS-PROJET: config initiale = panneau + navigation bloquée
- BRIEFING-FRONTEND: tickets #12/#13 remplacés par panneau Paramètres
- Suppression login_screen.dart.bak

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-10 00:19:35 +01:00
jmartinandCursor 18b270eaa3 Merge origin/develop: résolution conflits doc tickets
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 23:55:16 +01:00
jmartinandCursor 68e4f54814 Merge develop (squash): correctifs modale MDP (champs lavande/jaune), doc tickets 84/85
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 23:53:58 +01:00
jmartinandCursor 6794190916 chore(login): retrait du lien Test modale MDP
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 23:52:51 +01:00
jmartinandCursor 790761d576 fix(modale): champs MDP actuel lavande, nouveau et confirmation jaune
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 23:52:25 +01:00
jmartinandCursor 930097f87d Merge develop: fix auth connexion admin (#84)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 22:37:48 +01:00
jmartinandCursor 18af5c9034 fix(auth): connexion admin - token snake_case, routes GoRouter, profil (Closes #84)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 22:36:52 +01:00
jmartinandCursor 10bf2553e7 fix(ui): renforcer ombre des boutons Parents/AM sur mobile (écran choix)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 19:15:10 +01:00
jmartinandCursor 678f4219b5 docs: ticket #82 fermé (écran Login mobile)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 18:56:07 +01:00
jmartinandCursor 5295e8ec72 Merge develop (squash): login mobile, formulaire sous slogan par ratio
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 18:54:16 +01:00
jmartinandCursor 480f4a9396 fix(login): position formulaire sous slogan par ratio image (river_logo_mobile)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 18:53:37 +01:00
jmartinandCursor 6bf0932da8 docs: Index, doc API Gitea et script fermeture issue
- docs/00_INDEX.md : entrée pour 26_GITEA-API
- docs/26_GITEA-API.md : procédure API Gitea (auth, issues, PR, branches, dépannage)
- scripts/gitea-close-issue.sh : fermer une issue via l'API (GITEA_TOKEN / .gitea-token)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 16:39:47 +01:00
jmartinandCursor 2f1740b35f docs: Ajouter ticket #83 RegisterChoiceScreen Mobile (terminé)
Mise à jour de la liste des tickets pour documenter le ticket #83 complété :
- Adaptation responsive RegisterChoiceScreen (mobile/desktop)
- Extraction ChoiceCardWidget réutilisable
- Bouton Précédent stylisé avec CustomNavigationButton
- Tailles icônes augmentées (140px/170px)

Total: 65 tickets (~184h)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 12:37:04 +01:00
jmartinandCursor fd97e68dd9 feat: Adapter RegisterChoiceScreen pour mobile (Closes #83)
- Implémentation responsive avec LayoutBuilder pour détecter mobile/desktop
- Mode mobile : titre au-dessus, carte pleine largeur avec ratio 2/3, boutons verticaux
- Mode desktop : chevron en haut à gauche, layout texte/carte côte à côte
- Extraction de la logique de carte dans ChoiceCardWidget réutilisable
- Bouton "Précédent" stylisé avec CustomNavigationButton et HoverReliefWidget
- Tailles d'icônes augmentées (140px mobile, 170px desktop)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 12:34:50 +01:00
jmartinandCursor 9b007fe490 docs: Mark tickets #38, #39, #40, #41, #42 as completed (Closes #38, Closes #39, Closes #40, Closes #41, Closes #42)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-07 17:15:15 +01:00
jmartinandCursor 7ecb99963c docs: Update ticket list with #81 status (Closes #81)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-07 17:10:47 +01:00
jmartinandCursor 39814c76b1 docs: Update ticket list and cleanup obsolete files (Closes #79, Closes #80)
- Delete obsolete nanny_registration_data.dart and nanny_register_confirmation_screen.dart
- Update 23_LISTE-TICKETS.md with #79 and #80 status

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-07 17:09:29 +01:00
jmartin b956f94ad2 Merge branch 'develop' 2026-02-07 14:52:47 +01:00
jmartinandCursor b18d5c8a9e feat(frontend): Refonte infrastructure formulaires multi-modes
- Support des modes Desktop/Mobile et Édition/Lecture seule
- Refactoring des widgets de formulaire (PersonalInfo, ProfessionalInfo, Presentation, ChildCard)
- Mise à jour des écrans de récapitulatif (ParentStep5, AmStep4)
- Ajout de navigation (Précédent/Soumettre) sur mobile

Closes #78

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-07 14:51:33 +01:00
jmartin 45bd8a9ef1 Sync master with develop 2026-02-03 16:12:58 +01:00
jmartin b6c70a52ac Merge develop into master
Synchronisation complète de develop vers master.

Inclut:
- Backend: API auth, config, documents légaux (#3-#9, #31, #75-#77)
- Database: Schéma unifié, tables config/documents (#3-#7)
- Documentation: Architecture, déploiement, tickets (#61-#62)
- Frontend: Inscription parents/AM, dashboard (#36-#42)
- Frontend: Renommage Nanny→AM (#79)
- Frontend: Widgets génériques réutilisables (#80)
- Frontend: Corrections post-refactoring (#81)

80 commits mergés - master à jour avec develop
2026-02-03 16:10:47 +01:00
63 changed files with 3390 additions and 957 deletions
+18
View File
@@ -0,0 +1,18 @@
# Fins de ligne : toujours LF dans le dépôt (évite les conflits Linux/Windows)
* text=auto eol=lf
# Fichiers binaires : pas de conversion
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.webp binary
*.pdf binary
*.woff binary
*.woff2 binary
*.ttf binary
*.eot binary
# Scripts shell : toujours LF
*.sh text eol=lf
+3
View File
@@ -21,3 +21,6 @@ JWT_EXPIRATION_TIME=7d
# Environnement
NODE_ENV=development
# Log de chaque appel API (mode debug) — mettre à true pour tracer les requêtes front
# LOG_API_REQUESTS=true
+27
View File
@@ -0,0 +1,27 @@
#!/bin/bash
# Test POST /auth/register/am (ticket #90)
# Usage: ./scripts/test-register-am.sh [BASE_URL]
# Exemple: ./scripts/test-register-am.sh https://app.ptits-pas.fr/api/v1
# ./scripts/test-register-am.sh http://localhost:3000/api/v1
BASE_URL="${1:-http://localhost:3000/api/v1}"
echo "Testing POST $BASE_URL/auth/register/am"
echo "---"
curl -s -w "\n\nHTTP %{http_code}\n" -X POST "$BASE_URL/auth/register/am" \
-H "Content-Type: application/json" \
-d '{
"email": "marie.dupont.test@ptits-pas.fr",
"prenom": "Marie",
"nom": "DUPONT",
"telephone": "0612345678",
"adresse": "1 rue Test",
"code_postal": "75001",
"ville": "Paris",
"consentement_photo": true,
"nir": "123456789012345",
"numero_agrement": "AGR-2024-001",
"capacite_accueil": 4,
"acceptation_cgu": true,
"acceptation_privacy": true
}'
@@ -0,0 +1,69 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { Request } from 'express';
/** Clés à masquer dans les logs (corps de requête) */
const SENSITIVE_KEYS = [
'password',
'smtp_password',
'token',
'accessToken',
'refreshToken',
'secret',
];
function maskBody(body: unknown): unknown {
if (body === null || body === undefined) return body;
if (typeof body !== 'object') return body;
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(body)) {
const lower = key.toLowerCase();
const isSensitive = SENSITIVE_KEYS.some((s) => lower.includes(s));
out[key] = isSensitive ? '***' : value;
}
return out;
}
@Injectable()
export class LogRequestInterceptor implements NestInterceptor {
private readonly enabled: boolean;
constructor() {
this.enabled =
process.env.LOG_API_REQUESTS === 'true' ||
process.env.LOG_API_REQUESTS === '1';
}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
if (!this.enabled) return next.handle();
const http = context.switchToHttp();
const req = http.getRequest<Request>();
const { method, url, body, query } = req;
const hasBody = body && Object.keys(body).length > 0;
const logLine = [
`[API] ${method} ${url}`,
Object.keys(query || {}).length ? `query=${JSON.stringify(query)}` : '',
hasBody ? `body=${JSON.stringify(maskBody(body))}` : '',
]
.filter(Boolean)
.join(' ');
console.log(logLine);
return next.handle().pipe(
tap({
next: () => {
// Optionnel: log du statut en fin de requête (si besoin plus tard)
},
}),
);
}
}
+6 -5
View File
@@ -1,17 +1,18 @@
import { NestFactory, Reflector } from '@nestjs/core';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ConfigService } from '@nestjs/config';
import { SwaggerModule } from '@nestjs/swagger/dist/swagger-module';
import { DocumentBuilder } from '@nestjs/swagger';
import { AuthGuard } from './common/guards/auth.guard';
import { JwtService } from '@nestjs/jwt';
import { RolesGuard } from './common/guards/roles.guard';
import { ValidationPipe } from '@nestjs/common';
import { LogRequestInterceptor } from './common/interceptors/log-request.interceptor';
async function bootstrap() {
const app = await NestFactory.create(AppModule,
{ logger: ['error', 'warn', 'log', 'debug', 'verbose'] });
// Log de chaque appel API si LOG_API_REQUESTS=true (mode debug)
app.useGlobalInterceptors(new LogRequestInterceptor());
// Configuration CORS pour autoriser les requêtes depuis localhost (dev) et production
app.enableCors({
origin: true, // Autorise toutes les origines (dev) - à restreindre en prod
@@ -53,8 +53,7 @@ export class ConfigController {
// @Roles('super_admin')
async completeSetup(@Request() req: any) {
try {
// TODO: Récupérer l'ID utilisateur depuis le JWT
const userId = req.user?.id || 'system';
const userId = req.user?.id ?? null;
await this.configService.markSetupCompleted(userId);
+3 -3
View File
@@ -259,10 +259,10 @@ export class AppConfigService implements OnModuleInit {
/**
* Marquer la configuration initiale comme terminée
* @param userId ID de l'utilisateur qui termine la configuration
* @param userId ID de l'utilisateur qui termine la configuration (null si non authentifié)
*/
async markSetupCompleted(userId: string): Promise<void> {
await this.set('setup_completed', 'true', userId);
async markSetupCompleted(userId: string | null): Promise<void> {
await this.set('setup_completed', 'true', userId ?? undefined);
this.logger.log('✅ Configuration initiale marquée comme terminée');
}
@@ -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' })
+10 -6
View File
@@ -3,8 +3,8 @@ import { LoginDto } from './dto/login.dto';
import { AuthService } from './auth.service';
import { Public } from 'src/common/decorators/public.decorator';
import { RegisterDto } from './dto/register.dto';
import { RegisterParentDto } from './dto/register-parent.dto';
import { RegisterParentCompletDto } from './dto/register-parent-complet.dto';
import { RegisterAMCompletDto } from './dto/register-am-complet.dto';
import { ChangePasswordRequiredDto } from './dto/change-password.dto';
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { AuthGuard } from 'src/common/guards/auth.guard';
@@ -53,12 +53,16 @@ export class AuthController {
}
@Public()
@Post('register/parent/legacy')
@ApiOperation({ summary: '[OBSOLÈTE] Inscription Parent (étape 1/6 uniquement)' })
@ApiResponse({ status: 201, description: 'Inscription réussie' })
@Post('register/am')
@ApiOperation({
summary: 'Inscription Assistante Maternelle COMPLÈTE',
description: 'Crée User AM + entrée assistantes_maternelles (identité + infos pro + photo + CGU) en une transaction',
})
@ApiResponse({ status: 201, description: 'Inscription réussie - Dossier en attente de validation' })
@ApiResponse({ status: 400, description: 'Données invalides ou CGU non acceptées' })
@ApiResponse({ status: 409, description: 'Email déjà utilisé' })
async registerParentLegacy(@Body() dto: RegisterParentDto) {
return this.authService.registerParent(dto);
async inscrireAMComplet(@Body() dto: RegisterAMCompletDto) {
return this.authService.inscrireAMComplet(dto);
}
@Public()
+2 -1
View File
@@ -8,11 +8,12 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
import { Users } from 'src/entities/users.entity';
import { Parents } from 'src/entities/parents.entity';
import { Children } from 'src/entities/children.entity';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { AppConfigModule } from 'src/modules/config';
@Module({
imports: [
TypeOrmModule.forFeature([Users, Parents, Children]),
TypeOrmModule.forFeature([Users, Parents, Children, AssistanteMaternelle]),
forwardRef(() => UserModule),
AppConfigModule,
JwtModule.registerAsync({
+79 -121
View File
@@ -13,13 +13,14 @@ import * as crypto from 'crypto';
import * as fs from 'fs/promises';
import * as path from 'path';
import { RegisterDto } from './dto/register.dto';
import { RegisterParentDto } from './dto/register-parent.dto';
import { RegisterParentCompletDto } from './dto/register-parent-complet.dto';
import { RegisterAMCompletDto } from './dto/register-am-complet.dto';
import { ConfigService } from '@nestjs/config';
import { RoleType, StatutUtilisateurType, Users } from 'src/entities/users.entity';
import { Parents } from 'src/entities/parents.entity';
import { Children, StatutEnfantType } from 'src/entities/children.entity';
import { ParentsChildren } from 'src/entities/parents_children.entity';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { LoginDto } from './dto/login.dto';
import { AppConfigService } from 'src/modules/config/config.service';
@@ -116,7 +117,7 @@ export class AuthService {
}
/**
* Inscription utilisateur OBSOLÈTE - Utiliser registerParent() ou registerAM()
* Inscription utilisateur OBSOLÈTE - Utiliser inscrireParentComplet() ou registerAM()
* @deprecated
*/
async register(registerDto: RegisterDto) {
@@ -157,125 +158,6 @@ export class AuthService {
};
}
/**
* Inscription Parent (étape 1/6 du workflow CDC)
* SANS mot de passe - Token de création MDP généré
*/
async registerParent(dto: RegisterParentDto) {
// 1. Vérifier que l'email n'existe pas
const exists = await this.usersService.findByEmailOrNull(dto.email);
if (exists) {
throw new ConflictException('Un compte avec cet email existe déjà');
}
// 2. Vérifier l'email du co-parent s'il existe
if (dto.co_parent_email) {
const coParentExists = await this.usersService.findByEmailOrNull(dto.co_parent_email);
if (coParentExists) {
throw new ConflictException('L\'email du co-parent est déjà utilisé');
}
}
// 3. Récupérer la durée d'expiration du token depuis la config
const tokenExpiryDays = await this.appConfigService.get<number>(
'password_reset_token_expiry_days',
7,
);
// 4. Générer les tokens de création de mot de passe
const tokenCreationMdp = crypto.randomUUID();
const tokenExpiration = new Date();
tokenExpiration.setDate(tokenExpiration.getDate() + tokenExpiryDays);
// 5. Transaction : Créer Parent 1 + Parent 2 (si existe) + entités parents
const result = await this.usersRepo.manager.transaction(async (manager) => {
// Créer Parent 1
const parent1 = manager.create(Users, {
email: dto.email,
prenom: dto.prenom,
nom: dto.nom,
role: RoleType.PARENT,
statut: StatutUtilisateurType.EN_ATTENTE,
telephone: dto.telephone,
adresse: dto.adresse,
code_postal: dto.code_postal,
ville: dto.ville,
token_creation_mdp: tokenCreationMdp,
token_creation_mdp_expire_le: tokenExpiration,
});
const savedParent1 = await manager.save(Users, parent1);
// Créer Parent 2 si renseigné
let savedParent2: Users | null = null;
let tokenCoParent: string | null = null;
if (dto.co_parent_email && dto.co_parent_prenom && dto.co_parent_nom) {
tokenCoParent = crypto.randomUUID();
const tokenExpirationCoParent = new Date();
tokenExpirationCoParent.setDate(tokenExpirationCoParent.getDate() + tokenExpiryDays);
const parent2 = manager.create(Users, {
email: dto.co_parent_email,
prenom: dto.co_parent_prenom,
nom: dto.co_parent_nom,
role: RoleType.PARENT,
statut: StatutUtilisateurType.EN_ATTENTE,
telephone: dto.co_parent_telephone,
adresse: dto.co_parent_meme_adresse ? dto.adresse : dto.co_parent_adresse,
code_postal: dto.co_parent_meme_adresse ? dto.code_postal : dto.co_parent_code_postal,
ville: dto.co_parent_meme_adresse ? dto.ville : dto.co_parent_ville,
token_creation_mdp: tokenCoParent,
token_creation_mdp_expire_le: tokenExpirationCoParent,
});
savedParent2 = await manager.save(Users, parent2);
}
// Créer l'entité métier Parents pour Parent 1
const parentEntity = manager.create(Parents, {
user_id: savedParent1.id,
});
parentEntity.user = savedParent1;
if (savedParent2) {
parentEntity.co_parent = savedParent2;
}
await manager.save(Parents, parentEntity);
// Créer l'entité métier Parents pour Parent 2 (si existe)
if (savedParent2) {
const coParentEntity = manager.create(Parents, {
user_id: savedParent2.id,
});
coParentEntity.user = savedParent2;
coParentEntity.co_parent = savedParent1;
await manager.save(Parents, coParentEntity);
}
return {
parent1: savedParent1,
parent2: savedParent2,
tokenCreationMdp,
tokenCoParent,
};
});
// 6. TODO: Envoyer email avec lien de création de MDP
// await this.mailService.sendPasswordCreationEmail(result.parent1, result.tokenCreationMdp);
// if (result.parent2 && result.tokenCoParent) {
// await this.mailService.sendPasswordCreationEmail(result.parent2, result.tokenCoParent);
// }
return {
message: 'Inscription réussie. Un email de validation vous a été envoyé.',
parent_id: result.parent1.id,
co_parent_id: result.parent2?.id,
statut: StatutUtilisateurType.EN_ATTENTE,
};
}
/**
* Inscription Parent COMPLÈTE - Workflow CDC 6 étapes en 1 transaction
* Gère : Parent 1 + Parent 2 (opt) + Enfants + Présentation + CGU
@@ -432,6 +314,82 @@ export class AuthService {
};
}
/**
* Inscription Assistante Maternelle COMPLÈTE - Un seul endpoint (identité + pro + photo + CGU)
* Crée User (role AM) + entrée assistantes_maternelles, token création MDP
*/
async inscrireAMComplet(dto: RegisterAMCompletDto) {
if (!dto.acceptation_cgu || !dto.acceptation_privacy) {
throw new BadRequestException(
"L'acceptation des CGU et de la politique de confidentialité est obligatoire",
);
}
const existe = await this.usersService.findByEmailOrNull(dto.email);
if (existe) {
throw new ConflictException('Un compte avec cet email existe déjà');
}
const joursExpirationToken = await this.appConfigService.get<number>(
'password_reset_token_expiry_days',
7,
);
const tokenCreationMdp = crypto.randomUUID();
const dateExpiration = new Date();
dateExpiration.setDate(dateExpiration.getDate() + joursExpirationToken);
let urlPhoto: string | null = null;
if (dto.photo_base64 && dto.photo_filename) {
urlPhoto = await this.sauvegarderPhotoDepuisBase64(dto.photo_base64, dto.photo_filename);
}
const dateConsentementPhoto =
dto.consentement_photo ? new Date() : undefined;
const resultat = await this.usersRepo.manager.transaction(async (manager) => {
const user = manager.create(Users, {
email: dto.email,
prenom: dto.prenom,
nom: dto.nom,
role: RoleType.ASSISTANTE_MATERNELLE,
statut: StatutUtilisateurType.EN_ATTENTE,
telephone: dto.telephone,
adresse: dto.adresse,
code_postal: dto.code_postal,
ville: dto.ville,
token_creation_mdp: tokenCreationMdp,
token_creation_mdp_expire_le: dateExpiration,
photo_url: urlPhoto ?? undefined,
consentement_photo: dto.consentement_photo,
date_consentement_photo: dateConsentementPhoto,
date_naissance: dto.date_naissance ? new Date(dto.date_naissance) : undefined,
});
const userEnregistre = await manager.save(Users, user);
const amRepo = manager.getRepository(AssistanteMaternelle);
const am = amRepo.create({
user_id: userEnregistre.id,
approval_number: dto.numero_agrement,
nir: dto.nir,
max_children: dto.capacite_accueil,
biography: dto.biographie,
residence_city: dto.ville ?? undefined,
agreement_date: dto.date_agrement ? new Date(dto.date_agrement) : undefined,
available: true,
});
await amRepo.save(am);
return { user: userEnregistre };
});
return {
message:
'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.',
user_id: resultat.user.id,
statut: StatutUtilisateurType.EN_ATTENTE,
};
}
/**
* Sauvegarde une photo depuis base64 vers le système de fichiers
*/
@@ -0,0 +1,156 @@
import { ApiProperty } from '@nestjs/swagger';
import {
IsEmail,
IsNotEmpty,
IsOptional,
IsString,
IsBoolean,
IsInt,
Min,
Max,
MinLength,
MaxLength,
Matches,
IsDateString,
} from 'class-validator';
export class RegisterAMCompletDto {
// ============================================
// ÉTAPE 1 : IDENTITÉ (Obligatoire)
// ============================================
@ApiProperty({ example: 'marie.dupont@ptits-pas.fr' })
@IsEmail({}, { message: 'Email invalide' })
@IsNotEmpty({ message: "L'email est requis" })
email: string;
@ApiProperty({ example: 'Marie' })
@IsString()
@IsNotEmpty({ message: 'Le prénom est requis' })
@MinLength(2, { message: 'Le prénom doit contenir au moins 2 caractères' })
@MaxLength(100)
prenom: string;
@ApiProperty({ example: 'DUPONT' })
@IsString()
@IsNotEmpty({ message: 'Le nom est requis' })
@MinLength(2, { message: 'Le nom doit contenir au moins 2 caractères' })
@MaxLength(100)
nom: string;
@ApiProperty({ example: '0689567890' })
@IsString()
@IsNotEmpty({ message: 'Le téléphone est requis' })
@Matches(/^(\+33|0)[1-9](\d{2}){4}$/, {
message: 'Le numéro de téléphone doit être valide (ex: 0689567890 ou +33689567890)',
})
telephone: string;
@ApiProperty({ example: '5 Avenue du Général de Gaulle', required: false })
@IsOptional()
@IsString()
adresse?: string;
@ApiProperty({ example: '95870', required: false })
@IsOptional()
@IsString()
@MaxLength(10)
code_postal?: string;
@ApiProperty({ example: 'Bezons', required: false })
@IsOptional()
@IsString()
@MaxLength(150)
ville?: string;
// ============================================
// ÉTAPE 2 : PHOTO + INFOS PRO
// ============================================
@ApiProperty({
example: 'data:image/jpeg;base64,/9j/4AAQ...',
required: false,
description: 'Photo de profil en base64',
})
@IsOptional()
@IsString()
photo_base64?: string;
@ApiProperty({ example: 'photo_profil.jpg', required: false })
@IsOptional()
@IsString()
photo_filename?: string;
@ApiProperty({ example: true, description: 'Consentement utilisation photo' })
@IsBoolean()
@IsNotEmpty({ message: 'Le consentement photo est requis' })
consentement_photo: boolean;
@ApiProperty({ example: '2024-01-15', required: false, description: 'Date de naissance' })
@IsOptional()
@IsDateString()
date_naissance?: string;
@ApiProperty({ example: 'Paris', required: false, description: 'Ville de naissance' })
@IsOptional()
@IsString()
@MaxLength(100)
lieu_naissance_ville?: string;
@ApiProperty({ example: 'France', required: false, description: 'Pays de naissance' })
@IsOptional()
@IsString()
@MaxLength(100)
lieu_naissance_pays?: string;
@ApiProperty({ example: '123456789012345', description: 'NIR 15 chiffres' })
@IsString()
@IsNotEmpty({ message: 'Le NIR est requis' })
@Matches(/^\d{15}$/, { message: 'Le NIR doit contenir exactement 15 chiffres' })
nir: string;
@ApiProperty({ example: 'AGR-2024-12345', description: "Numéro d'agrément" })
@IsString()
@IsNotEmpty({ message: "Le numéro d'agrément est requis" })
@MaxLength(50)
numero_agrement: string;
@ApiProperty({ example: '2024-06-01', required: false, description: "Date d'obtention de l'agrément" })
@IsOptional()
@IsDateString()
date_agrement?: string;
@ApiProperty({ example: 4, description: 'Capacité d\'accueil (nombre d\'enfants)', minimum: 1, maximum: 10 })
@IsInt()
@Min(1, { message: 'La capacité doit être au moins 1' })
@Max(10, { message: 'La capacité ne peut pas dépasser 10' })
capacite_accueil: number;
// ============================================
// ÉTAPE 3 : PRÉSENTATION (Optionnel)
// ============================================
@ApiProperty({
example: 'Assistante maternelle expérimentée, accueil bienveillant...',
required: false,
description: 'Présentation / biographie (max 2000 caractères)',
})
@IsOptional()
@IsString()
@MaxLength(2000, { message: 'La présentation ne peut pas dépasser 2000 caractères' })
biographie?: string;
// ============================================
// ÉTAPE 4 : ACCEPTATION CGU (Obligatoire)
// ============================================
@ApiProperty({ example: true, description: "Acceptation des CGU" })
@IsBoolean()
@IsNotEmpty({ message: "L'acceptation des CGU est requise" })
acceptation_cgu: boolean;
@ApiProperty({ example: true, description: 'Acceptation de la Politique de confidentialité' })
@IsBoolean()
@IsNotEmpty({ message: "L'acceptation de la politique de confidentialité est requise" })
acceptation_privacy: boolean;
}
@@ -1,105 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import {
IsEmail,
IsNotEmpty,
IsOptional,
IsString,
IsDateString,
IsEnum,
MinLength,
MaxLength,
Matches,
} from 'class-validator';
import { SituationFamilialeType } from 'src/entities/users.entity';
export class RegisterParentDto {
// === Informations obligatoires ===
@ApiProperty({ example: 'claire.martin@ptits-pas.fr' })
@IsEmail({}, { message: 'Email invalide' })
@IsNotEmpty({ message: 'L\'email est requis' })
email: string;
@ApiProperty({ example: 'Claire' })
@IsString()
@IsNotEmpty({ message: 'Le prénom est requis' })
@MinLength(2, { message: 'Le prénom doit contenir au moins 2 caractères' })
@MaxLength(100, { message: 'Le prénom ne peut pas dépasser 100 caractères' })
prenom: string;
@ApiProperty({ example: 'MARTIN' })
@IsString()
@IsNotEmpty({ message: 'Le nom est requis' })
@MinLength(2, { message: 'Le nom doit contenir au moins 2 caractères' })
@MaxLength(100, { message: 'Le nom ne peut pas dépasser 100 caractères' })
nom: string;
@ApiProperty({ example: '0689567890' })
@IsString()
@IsNotEmpty({ message: 'Le téléphone est requis' })
@Matches(/^(\+33|0)[1-9](\d{2}){4}$/, {
message: 'Le numéro de téléphone doit être valide (ex: 0689567890 ou +33689567890)',
})
telephone: string;
// === Informations optionnelles ===
@ApiProperty({ example: '5 Avenue du Général de Gaulle', required: false })
@IsOptional()
@IsString()
adresse?: string;
@ApiProperty({ example: '95870', required: false })
@IsOptional()
@IsString()
@MaxLength(10)
code_postal?: string;
@ApiProperty({ example: 'Bezons', required: false })
@IsOptional()
@IsString()
@MaxLength(150)
ville?: string;
// === Informations co-parent (optionnel) ===
@ApiProperty({ example: 'thomas.martin@ptits-pas.fr', required: false })
@IsOptional()
@IsEmail({}, { message: 'Email du co-parent invalide' })
co_parent_email?: string;
@ApiProperty({ example: 'Thomas', required: false })
@IsOptional()
@IsString()
co_parent_prenom?: string;
@ApiProperty({ example: 'MARTIN', required: false })
@IsOptional()
@IsString()
co_parent_nom?: string;
@ApiProperty({ example: '0612345678', required: false })
@IsOptional()
@IsString()
@Matches(/^(\+33|0)[1-9](\d{2}){4}$/, {
message: 'Le numéro de téléphone du co-parent doit être valide',
})
co_parent_telephone?: string;
@ApiProperty({ example: 'true', description: 'Le co-parent habite à la même adresse', required: false })
@IsOptional()
co_parent_meme_adresse?: boolean;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
co_parent_adresse?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
co_parent_code_postal?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
co_parent_ville?: string;
}
@@ -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é !' })
@@ -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()
@@ -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],
})
+1 -1
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();
+2
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],
+6 -3
View File
@@ -80,12 +80,15 @@ CREATE INDEX idx_utilisateurs_token_creation_mdp
CREATE TABLE assistantes_maternelles (
id_utilisateur UUID PRIMARY KEY REFERENCES utilisateurs(id) ON DELETE CASCADE,
numero_agrement VARCHAR(50),
date_agrement DATE NOT NULL, -- Obligatoire selon CDC v1.3
nir_chiffre CHAR(15),
nb_max_enfants INT,
place_disponible INT,
biographie TEXT,
disponible BOOLEAN DEFAULT true
disponible BOOLEAN DEFAULT true,
ville_residence VARCHAR(100),
date_agrement DATE,
annee_experience SMALLINT,
specialite VARCHAR(100),
place_disponible INT
);
-- ==========================================================
+10
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/`.
+73
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;
+2
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:
+1
View File
@@ -27,6 +27,7 @@ Ce fichier sert d'index pour naviguer dans toute la documentation du projet.
- [**23 - Liste des Tickets**](./23_LISTE-TICKETS.md) - 61 tickets Phase 1 détaillés
- [**24 - Décisions Projet**](./24_DECISIONS-PROJET.md) - Décisions architecturales et fonctionnelles
- [**25 - Backlog Phase 2**](./25_PHASE-2-BACKLOG.md) - Fonctionnalités techniques reportées
- [**26 - API Gitea**](./26_GITEA-API.md) - Procédure d'utilisation de l'API Gitea (issues, PR, branches, labels)
### Administration (À créer)
- [**30 - Guide d'administration**](./30_ADMIN.md) - Gestion des utilisateurs, accès PgAdmin, logs
+37
View File
@@ -0,0 +1,37 @@
# Ticket #14 – Note pour modifications backend
**Contexte :** Première connexion admin → panneau Paramètres, déblocage après clic sur « Sauvegarder ». Le front appelle `POST /api/v1/configuration/setup/complete` au clic sur Sauvegarder.
## Problème
Erreur renvoyée par le back :
`invalid input syntax for type uuid: "system"`
- Le controller fait `const userId = req.user?.id || 'system'` puis `markSetupCompleted(userId)`.
- Le service `set()` fait `config.modifiePar = { id: userId }` ; la colonne `modifie_par` est une FK UUID vers `users`.
- La chaîne `"system"` n’est pas un UUID valide → erreur PostgreSQL.
## Modifications à apporter au backend
**Option A – Accepter l’absence d’utilisateur (recommandé si la route peut être appelée sans JWT)**
1. **`config.controller.ts`** (route `completeSetup`)
- Remplacer :
`const userId = req.user?.id || 'system';`
- Par :
`const userId = req.user?.id ?? null;`
2. **`config.service.ts`** (`markSetupCompleted`)
- Changer la signature :
`async markSetupCompleted(userId: string | null): Promise<void>`
- Et appeler :
`await this.set('setup_completed', 'true', userId ?? undefined);`
- Dans `set()`, ne pas remplir `modifiePar` quand `userId` est absent (déjà le cas si `if (userId)`).
**Option B – Imposer un utilisateur authentifié**
- Activer le guard JWT (et éventuellement RolesGuard) sur `POST /configuration/setup/complete` pour que `req.user` soit toujours défini, et garder `userId = req.user.id` (plus de fallback `'system'`).
---
Une fois le back modifié, le flux « Sauvegarder » → déblocage des panneaux fonctionne sans erreur.
+59 -104
View File
@@ -1,7 +1,7 @@
# 🔧 Documentation Technique - Configuration Système On-Premise
**Version** : 1.0
**Date** : 25 Novembre 2025
**Version** : 1.1
**Date** : 9 Février 2026
**Auteur** : Équipe PtitsPas
**Référence** : Architecture On-Premise
@@ -78,7 +78,7 @@ L'application P'titsPas est déployée **on-premise** chez différentes collecti
2. **ConfigService** : Cache en mémoire + chiffrement
3. **ConfigAPI** : Endpoints REST pour CRUD
4. **Guard Setup** : Redirection forcée si config incomplète
5. **Interface Admin** : Formulaire de configuration
5. **Interface Admin** : Panneau Paramètres (3 sections) dans le dashboard, première config + accès permanent
---
@@ -304,93 +304,69 @@ export class ConfigService {
```mermaid
sequenceDiagram
participant SA as Super Admin
participant App as Application
participant Guard as SetupGuard
participant Op as Opérateur
participant App as Application (Dashboard)
participant API as ConfigAPI
participant DB as PostgreSQL
participant SMTP as Serveur SMTP
SA->>App: Première connexion
App->>Guard: Vérifier setup_completed
Guard->>DB: SELECT valeur FROM configuration<br/>WHERE cle='setup_completed'
DB-->>Guard: 'false'
Op->>App: Première connexion (admin)
App->>API: GET /configuration/setup/status
API->>DB: setup_completed ?
DB-->>API: false
API-->>App: setupCompleted: false
Guard-->>App: Redirection forcée vers<br/>/admin/setup
App->>App: Affiche panneau Configuration<br/>et bloque les autres onglets
SA->>SA: Remplit formulaire config<br/>(SMTP, app, sécurité)
Op->>Op: Remplit les 3 sections<br/>(Email, Personnalisation, Avancé)
SA->>App: Clic "Tester la connexion SMTP"
Op->>App: Clic "Tester la connexion SMTP"
App->>API: POST /api/v1/configuration/test-smtp
API->>SMTP: Test connexion
alt Test SMTP OK
SMTP-->>API: ✅ Connexion réussie
API->>SA: Envoi email de test
API->>Op: Envoi email de test
API-->>App: ✅ Test réussi
App-->>SA: Message: "Email de test envoyé"
App-->>Op: Message: "Email de test envoyé"
else Test SMTP KO
SMTP-->>API: ❌ Erreur connexion
API-->>App: ❌ Erreur détaillée
App-->>SA: Message: "Erreur: vérifiez les paramètres"
App-->>Op: Message: "Erreur: vérifiez les paramètres"
end
SA->>App: Clic "Sauvegarder"
Op->>App: Clic "Sauvegarder et terminer la configuration"
App->>API: PATCH /api/v1/configuration/bulk<br/>{smtp_host, smtp_port, ...}
API->>DB: UPDATE configuration SET valeur=...
API-->>App: OK
API->>DB: BEGIN TRANSACTION
API->>DB: UPDATE configuration SET valeur=...<br/>FOR EACH key
API->>DB: UPDATE configuration<br/>SET valeur='true'<br/>WHERE cle='setup_completed'
API->>DB: COMMIT
App->>API: POST /api/v1/configuration/setup/complete
API->>DB: SET setup_completed = true
API->>API: Recharger cache ConfigService
API-->>App: OK
API-->>App: ✅ Configuration sauvegardée
App-->>SA: Redirection vers /admin/dashboard
SA->>App: Accès complet à l'application
App->>App: Débloque la navigation<br/>Message succès
Op->>App: Accès complet au dashboard
```
### Étapes détaillées
#### 1. Détection configuration incomplète
**Guard** : `SetupGuard` (NestJS)
**Backend** : Le `SetupGuard` (NestJS) vérifie `setup_completed`. Si false, il autorise l’accès au dashboard et aux APIs configuration (pas de redirection vers une page dédiée). Le **frontend** appelle `GET /configuration/setup/status` au chargement du dashboard admin ; si `setupCompleted === false`, il affiche directement le **panneau Paramètres** et désactive les autres onglets jusqu’à sauvegarde.
**Guard** (exemple) : exemption des routes login + dashboard + APIs configuration.
```typescript
@Injectable()
export class SetupGuard implements CanActivate {
constructor(private configService: ConfigService) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const setupCompleted = this.configService.get('setup_completed', false);
// Exemptions
const exemptedRoutes = ['/auth/login', '/admin/setup', '/api/v1/configuration'];
if (exemptedRoutes.some(route => request.url.includes(route))) {
return true;
}
// Si setup non complété, bloquer
if (!setupCompleted) {
throw new HttpException(
'Configuration initiale requise',
HttpStatus.TEMPORARY_REDIRECT,
{ location: '/admin/setup' }
);
}
return true;
}
}
// Exemptions : /auth/login, /api/v1/configuration, routes dashboard admin
// Si setup non complété : pas de redirection HTTP ; le frontend gère l’affichage du panneau Config et le blocage des onglets.
```
#### 2. Formulaire Setup (Frontend)
#### 2. Panneau Paramètres (Frontend)
**3 onglets** :
**Une seule page avec 3 sections** (blocs successifs, pas d’onglets dans le formulaire) :
##### Onglet 1 : Configuration Email 📧
##### Section 1 : Configuration Email 📧
| Champ | Type | Valeur par défaut | Obligatoire |
|-------|------|-------------------|-------------|
@@ -405,7 +381,7 @@ export class SetupGuard implements CanActivate {
**Bouton** : "🧪 Tester la connexion SMTP"
##### Onglet 2 : Personnalisation 🎨
##### Section 2 : Personnalisation 🎨
| Champ | Type | Valeur par défaut | Obligatoire |
|-------|------|-------------------|-------------|
@@ -413,7 +389,7 @@ export class SetupGuard implements CanActivate {
| URL de l'application | URL | `https://app.ptits-pas.fr` | ✅ |
| Logo | File (PNG/JPG) | Logo par défaut | ❌ |
##### Onglet 3 : Paramètres avancés ⚙️
##### Section 3 : Paramètres avancés ⚙️
| Champ | Type | Valeur par défaut | Obligatoire |
|-------|------|-------------------|-------------|
@@ -421,7 +397,7 @@ export class SetupGuard implements CanActivate {
| Durée session JWT (heures) | Number | `24` | ✅ |
| Taille max upload (MB) | Number | `5` | ✅ |
**Bouton** : "💾 Sauvegarder et terminer la configuration"
**Bouton** : "💾 Sauvegarder et terminer la configuration" (première config) ou "💾 Enregistrer" (accès permanent).
---
@@ -536,60 +512,39 @@ Content-Type: application/json
## 💻 Interface Admin
### Écran Setup Initial
### Panneau Paramètres / Configuration (unique)
Un **seul panneau** dans le dashboard admin, avec **3 sections** affichées sur une même page (défilement si besoin). Pas d’onglets dans le formulaire.
- **Première configuration** (au déploiement, `setup_completed === false`) : l’opérateur arrive sur le dashboard ; le panneau Configuration est affiché par défaut et les **autres onglets sont bloqués** jusqu’à clic sur « Sauvegarder et terminer la configuration » (PATCH bulk + POST setup/complete).
- **Accès permanent** : même panneau accessible via l’onglet « Configuration » / « Paramètres » du dashboard ; pas de blocage, simple modification et enregistrement (PATCH bulk).
```
┌─────────────────────────────────────────────────────────┐
│ 🚀 Configuration Initiale - P'titsPas │
├─────────────────────────────────────────────────────────┤
│ │
│ Bienvenue ! Configurez votre installation P'titsPas │
│ │
│ [ 📧 Email ] [ 🎨 Personnalisation ] [ ⚙️ Avancé ] │
│ Dashboard Admin [ Gestionnaires ] [ Parents ] ... │
│ [ Configuration ] ← onglet actif │
├─────────────────────────────────────────────────────────┤
│ │
│ 📧 Configuration Email (SMTP) │
│ Serveur SMTP * [_________________________________] │
│ Port * [____] Sécurité [▼] ☐ Auth requise │
│ Utilisateur [__________] Mot de passe [__________] │
│ Nom expéditeur * [__________] Email * [__________] │
│ [ 🧪 Tester la connexion SMTP ] │
│ │
│ Serveur SMTP * │
│ [_____________________________________________] │
│ Ex: mail.mairie-bezons.fr, smtp.gmail.com │
│ │
│ Port SMTP * │
│ [_____] 25 (standard), 465 (SSL), 587 (STARTTLS) │
│ │
│ Sécurité * │
│ [ ▼ Aucune ] STARTTLS SSL/TLS │
│ │
│ ☐ Authentification requise │
│ │
│ Utilisateur SMTP │
│ [_____________________________________________] │
│ │
│ Mot de passe SMTP │
│ [_____________________________________________] │
│ │
│ Nom de l'expéditeur * │
│ [_____________________________________________] │
│ Ex: P'titsPas - Mairie de Bezons │
│ │
│ Email expéditeur * │
│ [_____________________________________________] │
│ Ex: noreply@mairie-bezons.fr │
│ │
│ [ 🧪 Tester la connexion SMTP ] │
│ │
│ ───────────────────────────────────────────────── │
│ │
│ [ ← Précédent ] [ Suivant → ] │
│ ───────────────────────────────────────────────── │
│ 🎨 Personnalisation │
│ Nom application * [__________] URL * [__________] │
│ Logo [ Choisir un fichier ] │
│ ───────────────────────────────────────────────── │
│ ⚙️ Paramètres avancés │
│ Durée token MDP (jours) [__] JWT (h) [__] Upload MB [__] │
│ │
│ [ 💾 Sauvegarder et terminer la configuration ] │
│ (ou « Enregistrer » si config déjà complétée) │
└─────────────────────────────────────────────────────────┘
```
### Écran Paramètres (accès permanent)
Identique au Setup Initial, mais accessible depuis le menu admin :
- Menu Admin → Paramètres → Configuration Système
---
## 📋 Exemples de configuration
@@ -706,7 +661,7 @@ PORT=3000
---
**Dernière mise à jour** : 25 Novembre 2025
**Version** : 1.0
**Dernière mise à jour** : 9 Février 2026
**Version** : 1.1
**Statut** : ✅ Document validé
+230 -119
View File
@@ -1,9 +1,36 @@
# 🎫 Liste Complète des Tickets - Projet P'titsPas
**Version** : 1.0
**Date** : 25 Novembre 2025
**Version** : 1.4
**Date** : 9 Février 2026
**Auteur** : Équipe PtitsPas
**Estimation totale** : ~173h
**Estimation totale** : ~184h
---
## 🔗 Liste des tickets Gitea
**Les numéros de section dans ce document = numéros d’issues Gitea.** Ticket #14 dans le doc = issue Gitea #14, etc. Source : dépôt `jmartin/petitspas` (état au 9 février 2026).
| Gitea # | Titre (dépôt) | Statut |
|--------|----------------|--------|
| 3 | [BDD] Ajout champs manquants conformité CDC | ✅ Fermé |
| 4 | [BDD] Ajout table/champ présentation dossier parent | ✅ Fermé |
| 5 | [BDD] Ajout gestion tokens création mot de passe | ✅ Fermé |
| 6 | [BDD] Ajout champ genre obligatoire enfants | ✅ Fermé |
| 7 | [BDD] Supprimer champs obsolètes | ✅ Fermé |
| 8 | [BDD] Table configuration système | ✅ Fermé |
| 9 | [BDD] Tables documents légaux & acceptations | ✅ Fermé |
| 10 | [Backend] Service Configuration | ✅ Fermé |
| 11 | [Backend] API Configuration | ✅ Fermé |
| 12 | [Backend] Guard Configuration Initiale | ✅ Fermé |
| 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 |
| 17–88 | (voir sections ci‑dessous ; #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*
---
@@ -16,17 +43,17 @@
| **P0** | 7 tickets | ~5h | Amendements BDD (BLOQUANT) |
| **P1** | 7 tickets | ~22h | Configuration système (BLOQUANT) |
| **P2** | 18 tickets | ~50h | Backend métier |
| **P3** | 17 tickets | ~52h | Frontend |
| **P3** | 22 tickets | ~71h | Frontend |
| **P4** | 4 tickets | ~24h | Tests & Documentation |
| **CRITIQUES** | 6 tickets | ~13h | Upload, Logs, Infra, CDC |
| **JURIDIQUE** | 1 ticket | ~8h | Rédaction CGU/Privacy |
| **TOTAL** | **62 tickets** | **~181h** | |
| **TOTAL** | **65 tickets** | **~184h** | |
---
## 🔴 PRIORITÉ 0 : Amendements Base de Données (BLOQUANT)
### Ticket #1 : [BDD] Ajout champs manquants conformité CDC
### Ticket #3 : [BDD] Ajout champs manquants conformité CDC
**Estimation** : 1h
**Labels** : `bdd`, `p0-bloquant`, `cdc`
@@ -44,7 +71,7 @@ Ajouter les champs manquants dans la base de données pour être conforme au Cah
---
### Ticket #2 : [BDD] Ajout table/champ présentation dossier parent
### Ticket #4 : [BDD] Ajout table/champ présentation dossier parent
**Estimation** : 30min
**Labels** : `bdd`, `p0-bloquant`, `cdc`
@@ -58,7 +85,7 @@ Ajouter un champ pour stocker la présentation du dossier parent (étape 4 de l'
---
### Ticket #3 : [BDD] Ajout gestion tokens création mot de passe ✅
### Ticket #5 : [BDD] Ajout gestion tokens création mot de passe ✅
**Estimation** : 30min
**Labels** : `bdd`, `p0-bloquant`, `security`
**Statut** : ✅ TERMINÉ (Fermé le 2025-11-28)
@@ -74,7 +101,7 @@ Ajouter les champs nécessaires pour gérer les tokens de création de mot de pa
---
### Ticket #4 : [BDD] Ajout champ genre obligatoire enfants ✅
### Ticket #6 : [BDD] Ajout champ genre obligatoire enfants ✅
**Estimation** : 30min
**Labels** : `bdd`, `p0-bloquant`, `cdc`
**Statut** : ✅ TERMINÉ (Fermé le 2025-11-28)
@@ -89,7 +116,7 @@ Ajouter le champ `genre` obligatoire (H/F) dans la table `enfants`.
---
### Ticket #5 : [BDD] Supprimer champs obsolètes
### Ticket #7 : [BDD] Supprimer champs obsolètes
**Estimation** : 30min
**Labels** : `bdd`, `p0-bloquant`, `cleanup`
@@ -106,7 +133,7 @@ Supprimer les champs obsolètes identifiés lors de l'audit.
---
### Ticket #6 : [BDD] Table configuration système
### Ticket #8 : [BDD] Table configuration système
**Estimation** : 1h
**Labels** : `bdd`, `p0-bloquant`, `on-premise`
@@ -124,7 +151,7 @@ Créer la table `configuration` pour stocker les paramètres système (SMTP, app
---
### Ticket #7 : [BDD] Tables documents légaux & acceptations ✅
### Ticket #9 : [BDD] Tables documents légaux & acceptations ✅
**Estimation** : 2h
**Labels** : `bdd`, `p0-bloquant`, `rgpd`, `juridique`
**Statut** : ✅ TERMINÉ (Fermé le 2025-11-30 - Ticket #68 sur Gitea)
@@ -147,7 +174,7 @@ Créer les tables pour gérer les versions des documents légaux (CGU/Privacy) e
## 🟠 PRIORITÉ 1 : Configuration Système (BLOQUANT)
### Ticket #8 : [Backend] Service Configuration
### Ticket #10 : [Backend] Service Configuration
**Estimation** : 4h
**Labels** : `backend`, `p1-bloquant`, `on-premise`
@@ -168,7 +195,7 @@ Créer le service de configuration avec cache en mémoire et chiffrement AES-256
---
### Ticket #9 : [Backend] API Configuration
### Ticket #11 : [Backend] API Configuration
**Estimation** : 3h
**Labels** : `backend`, `p1-bloquant`, `on-premise`
@@ -187,26 +214,27 @@ Créer les endpoints REST pour gérer la configuration système.
---
### Ticket #10 : [Backend] Guard Configuration Initiale
### Ticket #12 : [Backend] Guard Configuration Initiale
**Estimation** : 2h
**Labels** : `backend`, `p1-bloquant`, `on-premise`
**Description** :
Créer un Guard/Middleware qui détecte si la configuration initiale est incomplète et force la redirection.
Créer un Guard/Middleware qui détecte si la configuration initiale est incomplète. Le frontend affiche alors directement le panneau Configuration du dashboard et bloque la navigation jusqu'à sauvegarde (pas de page dédiée `/admin/setup`).
**Tâches** :
- [ ] Créer `SetupGuard`
- [ ] Vérifier `setup_completed` dans ConfigService
- [ ] Redirection forcée vers `/admin/setup` si false
- [ ] Exemption pour routes publiques (login, register)
- [ ] Exemption pour route `/admin/setup`
- [ ] Si false : autoriser accès au dashboard et aux APIs configuration (le frontend gère l’affichage du panneau Config et le blocage des onglets)
- [ ] Exemption pour routes publiques (login, register) et pour les APIs `/api/v1/configuration`
- [ ] Tests unitaires
**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 #11 : [Backend] Adaptation MailService pour config dynamique
### Ticket #13 : [Backend] Adaptation MailService pour config dynamique
**Estimation** : 3h
**Labels** : `backend`, `p1-bloquant`, `on-premise`, `email`
@@ -224,44 +252,45 @@ Adapter le service email pour utiliser la configuration dynamique depuis la BDD
---
### Ticket #12 : [Frontend] Écran Configuration Initiale (Setup Wizard)
**Estimation** : 6h
### Ticket #14 : [Frontend] Panneau Paramètres / Configuration (première config + accès permanent)
**Estimation** : 5h
**Labels** : `frontend`, `p1-bloquant`, `on-premise`
**Description** :
Créer l'écran de configuration initiale (Setup Wizard) accessible à la première connexion du super admin.
Un seul panneau **Paramètres / Configuration** dans le dashboard admin, avec **3 sections** sur une même page (pas d’onglets dédiés au formulaire : Email, Personnalisation, Avancé). Utilisé à la fois pour la **première configuration** (au déploiement, par un opérateur) et pour l’**accès permanent** (menu ou onglet Configuration). Lorsque `setup_completed` est false, le dashboard affiche directement ce panneau et **bloque la navigation** (autres onglets désactivés) jusqu’à sauvegarde.
**Tâches** :
- [ ] Page `/admin/setup` (accessible uniquement si config incomplète)
- [ ] Formulaire multi-onglets (Email / Application / Avancé)
- [ ] Onglet 1 : Configuration Email (SMTP, auth, expéditeur)
- [ ] Onglet 2 : Personnalisation (nom app, URL, logo)
- [ ] Onglet 3 : Paramètres avancés (durées tokens, upload max)
- [ ] Panneau Configuration dans le dashboard admin (onglet ou entrée de menu dédiée)
- [ ] Une seule page avec 3 sections : **Email (SMTP)** ; **Personnalisation** (nom app, URL, logo) ; **Avancé** (durées token MDP, JWT, taille max upload)
- [ ] Bouton "Tester la connexion SMTP" (appel API + feedback)
- [ ] Validation côté client
- [ ] Sauvegarde (appel API `PATCH /configuration/bulk`)
- [ ] Message succès + redirection dashboard
- [ ] Sauvegarde : `PATCH /configuration/bulk` puis `POST /configuration/setup/complete` si première config
- [ ] Si `setup_completed` false au chargement : afficher ce panneau par défaut et bloquer les autres onglets jusqu’à sauvegarde
- [ ] Message succès ; après première config, déblocage de la navigation
**Référence** : [21_CONFIGURATION-SYSTEME.md](./21_CONFIGURATION-SYSTEME.md#interface-admin)
*Issue Gitea #87 fermée en doublon de #14.*
---
### Ticket #13 : [Frontend] Écran Paramètres (accès permanent)
**Estimation** : 2h
### Ticket #15 : [Frontend] Écran Paramètres (accès permanent) / Intégration panneau
**Estimation** : 1h
**Labels** : `frontend`, `p1-bloquant`, `on-premise`
**Description** :
Créer l'écran de paramètres accessible depuis le menu admin (même interface que Setup Wizard).
S’assurer que le panneau Paramètres (décrit en #14) est accessible en permanence depuis le dashboard admin (onglet « Configuration » ou « Paramètres »). Même interface que pour la première config ; affichage des valeurs actuelles, modification et sauvegarde sans blocage de navigation.
**Tâches** :
- [ ] Page `/admin/parametres` (accessible depuis menu admin)
- [ ] Même interface que Setup Wizard
- [ ] Affichage valeurs actuelles
- [ ] Modification et sauvegarde
- [ ] Onglet ou entrée menu « Configuration » / « Paramètres » dans le dashboard admin pointant vers le même panneau que #14
- [ ] 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 #14 : [Doc] Documentation configuration on-premise
### Ticket #16 : [Doc] Documentation configuration on-premise
**Estimation** : 2h
**Labels** : `documentation`, `p1-bloquant`, `on-premise`
@@ -280,9 +309,14 @@ Rédiger la documentation pour aider les collectivités à configurer l'applicat
---
### 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.
---
## 🟢 PRIORITÉ 2 : Backend - Authentification & Gestion Comptes
### Ticket #15 : [Backend] API Création gestionnaire
### Ticket #17 : [Backend] API Création gestionnaire
**Estimation** : 3h
**Labels** : `backend`, `p2`, `auth`
@@ -302,7 +336,7 @@ Créer l'endpoint pour permettre au super admin de créer des gestionnaires.
---
### Ticket #16 : [Backend] API Inscription Parent (étape 1 - Parent 1)
### Ticket #18 : [Backend] API Inscription Parent (étape 1 - Parent 1)
**Estimation** : 4h
**Labels** : `backend`, `p2`, `auth`, `cdc`
@@ -322,7 +356,7 @@ Créer l'endpoint d'inscription Parent (étape 1/6 : informations Parent 1).
---
### Ticket #17 : [Backend] API Inscription Parent (étape 2 - Parent 2)
### Ticket #19 : [Backend] API Inscription Parent (étape 2 - Parent 2)
**Estimation** : 2h
**Labels** : `backend`, `p2`, `auth`, `cdc`
@@ -409,7 +443,7 @@ Finalisation de l'inscription parent (présentation, CGU, récapitulatif - inté
---
### Ticket #22 : [Backend] API Création mot de passe
### Ticket #24 : [Backend] API Création mot de passe
**Estimation** : 3h
**Labels** : `backend`, `p2`, `auth`, `security`
@@ -428,7 +462,7 @@ Créer les endpoints pour permettre aux utilisateurs de créer leur mot de passe
---
### Ticket #23 : [Backend] API Liste comptes en attente
### Ticket #25 : [Backend] API Liste comptes en attente
**Estimation** : 2h
**Labels** : `backend`, `p2`, `gestionnaire`
@@ -446,7 +480,7 @@ Créer les endpoints pour lister les comptes en attente de validation.
---
### Ticket #24 : [Backend] API Validation/Refus comptes
### Ticket #26 : [Backend] API Validation/Refus comptes
**Estimation** : 3h
**Labels** : `backend`, `p2`, `gestionnaire`
@@ -464,7 +498,7 @@ Créer les endpoints pour valider ou refuser les comptes en attente.
---
### Ticket #25 : [Backend] Service Email - Installation Nodemailer
### Ticket #27 : [Backend] Service Email - Installation Nodemailer
**Estimation** : 2h
**Labels** : `backend`, `p2`, `email`
@@ -479,7 +513,7 @@ Installer et configurer Nodemailer pour l'envoi d'emails.
---
### Ticket #26 : [Backend] Templates Email - Validation
### Ticket #28 : [Backend] Templates Email - Validation
**Estimation** : 3h
**Labels** : `backend`, `p2`, `email`
@@ -500,7 +534,7 @@ Créer les templates d'emails pour la validation des comptes (avec lien créatio
---
### Ticket #27 : [Backend] Templates Email - Refus
### Ticket #29 : [Backend] Templates Email - Refus
**Estimation** : 1h
**Labels** : `backend`, `p2`, `email`
@@ -515,7 +549,7 @@ Créer le template d'email pour le refus de compte.
---
### Ticket #28 : [Backend] Connexion - Vérification statut
### Ticket #30 : [Backend] Connexion - Vérification statut
**Estimation** : 2h
**Labels** : `backend`, `p2`, `auth`
@@ -533,7 +567,7 @@ Modifier l'endpoint de connexion pour bloquer les comptes en attente ou suspendu
---
### Ticket #29 : [Backend] Changement MDP obligatoire première connexion
### Ticket #31 : [Backend] Changement MDP obligatoire première connexion
**Estimation** : 2h
**Labels** : `backend`, `p2`, `auth`, `security`
@@ -548,7 +582,7 @@ Implémenter le changement de mot de passe obligatoire pour les gestionnaires/ad
---
### Ticket #30 : [Backend] Service Documents Légaux
### Ticket #32 : [Backend] Service Documents Légaux
**Estimation** : 4h
**Labels** : `backend`, `p2`, `juridique`, `rgpd`
@@ -570,7 +604,7 @@ Créer le service de gestion des documents légaux (CGU/Privacy) avec versioning
---
### Ticket #31 : [Backend] API Documents Légaux
### Ticket #33 : [Backend] API Documents Légaux
**Estimation** : 3h
**Labels** : `backend`, `p2`, `juridique`, `rgpd`
@@ -590,7 +624,7 @@ Créer les endpoints REST pour gérer les documents légaux.
---
### Ticket #32 : [Backend] Traçabilité acceptations documents
### Ticket #34 : [Backend] Traçabilité acceptations documents
**Estimation** : 2h
**Labels** : `backend`, `p2`, `rgpd`
@@ -609,7 +643,7 @@ Enregistrer les acceptations de documents légaux lors de l'inscription (traçab
## 🟢 PRIORITÉ 3 : Frontend - Interfaces
### Ticket #33 : [Frontend] Écran Création Gestionnaire
### Ticket #35 : [Frontend] Écran Création Gestionnaire
**Estimation** : 3h
**Labels** : `frontend`, `p3`, `auth`
@@ -624,14 +658,6 @@ Créer l'écran de création de gestionnaire (super admin uniquement).
---
### Ticket #34 : [Réservé - Non utilisé]
---
### Ticket #35 : [Réservé - Non utilisé]
---
### Ticket #36 : [Frontend] Inscription Parent - Étape 1 (Parent 1) ✅
**Estimation** : 3h
**Labels** : `frontend`, `p3`, `auth`, `cdc`
@@ -668,85 +694,90 @@ Créer le formulaire d'inscription parent - étape 2/6 (informations Parent 2 op
---
### Ticket #38 : [Frontend] Inscription Parent - Étape 3 (Enfants)
**Estimation** : 4h
### Ticket #38 : [Frontend] Inscription Parent - Étape 3 (Enfants) ✅
**Estimation** : 4h
**Labels** : `frontend`, `p3`, `auth`, `cdc`, `upload`
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-07)
**Description** :
Créer le formulaire d'inscription parent - étape 3/6 (informations enfants).
**Tâches** :
- [ ] Question "Enfant déjà né ?"
- [ ] Formulaire enfant (prénom, date, genre H/F obligatoire)
- [ ] Upload photo (si né)
- [ ] Validation taille (5MB)
- [ ] Bouton "Ajouter un autre enfant"
- [ ] Navigation vers étape 4
- [x] Question "Enfant déjà né ?"
- [x] Formulaire enfant (prénom, date, genre H/F obligatoire)
- [x] Upload photo (si né)
- [x] Validation taille (5MB)
- [x] Bouton "Ajouter un autre enfant"
- [x] Navigation vers étape 4
---
### Ticket #39 : [Frontend] Inscription Parent - Étapes 4-6 (Finalisation)
**Estimation** : 4h
### Ticket #39 : [Frontend] Inscription Parent - Étapes 4-6 (Finalisation) ✅
**Estimation** : 4h
**Labels** : `frontend`, `p3`, `auth`, `cdc`
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-07)
**Description** :
Créer les étapes finales de l'inscription parent (présentation, CGU, récapitulatif).
**Tâches** :
- [ ] Étape 4 : Textarea présentation
- [ ] Étape 5 : Checkbox CGU + liens PDF
- [ ] Étape 6 : Récapitulatif complet
- [ ] Bouton "Modifier" / "Valider"
- [ ] Appel API final
- [ ] Message confirmation
- [x] Étape 4 : Textarea présentation
- [x] Étape 5 : Checkbox CGU + liens PDF
- [x] Étape 6 : Récapitulatif complet
- [x] Bouton "Modifier" / "Valider"
- [x] Appel API final
- [x] Message confirmation
---
### Ticket #40 : [Frontend] Inscription AM - Panneau 1 (Identité)
**Estimation** : 3h
### Ticket #40 : [Frontend] Inscription AM - Panneau 1 (Identité) ✅
**Estimation** : 3h
**Labels** : `frontend`, `p3`, `auth`, `cdc`, `upload`
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-07)
**Description** :
Créer le formulaire d'inscription AM - panneau 1/5 (identité).
**Tâches** :
- [ ] Formulaire identité
- [ ] Upload photo
- [ ] Checkbox consentement photo
- [ ] Pas de champ mot de passe
- [ ] Navigation vers panneau 2
- [x] Formulaire identité
- [x] Upload photo
- [x] Checkbox consentement photo
- [x] Pas de champ mot de passe
- [x] Navigation vers panneau 2
---
### Ticket #41 : [Frontend] Inscription AM - Panneau 2 (Infos pro)
**Estimation** : 3h
### Ticket #41 : [Frontend] Inscription AM - Panneau 2 (Infos pro) ✅
**Estimation** : 3h
**Labels** : `frontend`, `p3`, `auth`, `cdc`
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-07)
**Description** :
Créer le formulaire d'inscription AM - panneau 2/5 (informations professionnelles).
**Tâches** :
- [ ] Formulaire infos pro
- [ ] Champ NIR (15 chiffres, validation)
- [ ] Date/lieu naissance
- [ ] Agrément + date obtention
- [ ] Navigation vers présentation
- [x] Formulaire infos pro
- [x] Champ NIR (15 chiffres, validation)
- [x] Date/lieu naissance
- [x] Agrément + date obtention
- [x] Navigation vers présentation
---
### Ticket #42 : [Frontend] Inscription AM - Finalisation
**Estimation** : 3h
### Ticket #42 : [Frontend] Inscription AM - Finalisation ✅
**Estimation** : 3h
**Labels** : `frontend`, `p3`, `auth`, `cdc`
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-07)
**Description** :
Créer les étapes finales de l'inscription AM (présentation, CGU, récapitulatif).
**Tâches** :
- [ ] Textarea présentation
- [ ] Checkbox CGU + liens PDF
- [ ] Récapitulatif (NIR masqué)
- [ ] Appel API final
- [ ] Message confirmation
- [x] Textarea présentation
- [x] Checkbox CGU + liens PDF
- [x] Récapitulatif (NIR masqué)
- [x] Appel API final
- [x] Message confirmation
---
@@ -863,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`
@@ -913,6 +968,56 @@ Créer une infrastructure générique pour gérer les formulaires en modes multi
---
### Ticket #79 : [Frontend] Renommer "Nanny" en "Assistante Maternelle" (AM) ✅
**Estimation** : 2h
**Labels** : `frontend`, `p3`, `refactoring`, `cdc`
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-07)
**Description** :
Renommage complet de "Nanny" en "AM" dans le frontend pour cohérence avec le CDC.
**Tâches** :
- [x] Modèles : nanny_registration_data.dart -> am_registration_data.dart
- [x] Écrans : nanny_register_*.dart -> am_register_*.dart
- [x] Routes : /nanny-register -> /am-register
- [x] Suppression fichiers obsolètes
---
### Ticket #81 : [Frontend] Corrections suite refactoring widgets ✅
**Estimation** : 2h
**Labels** : `frontend`, `p3`, `bugfix`
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-07)
**Description** :
Corrections et ajustements suite au refactoring des widgets.
**Corrections :**
- [x] Erreurs compilation (updateParent1/2, updateIdentityInfo)
- [x] Routes obsolètes supprimées
- [x] Toggles Parent Step 2 (2 côte à côte)
- [x] Positionnement éléments dans cartes
---
### Ticket #83 : [Frontend] Adapter RegisterChoiceScreen pour mobile ✅
**Estimation** : 3h
**Labels** : `frontend`, `p3`, `responsive`, `ux`
**Statut** : ✅ TERMINÉ (Fermé le 2026-01-27)
**Description** :
Adapter l'écran de choix Parent/AM pour une meilleure expérience mobile et cohérence avec les autres écrans.
**Tâches :**
- [x] Implémentation responsive avec LayoutBuilder (mobile < 900px)
- [x] Mode mobile : titre au-dessus, carte pleine largeur (ratio 2/3), boutons verticaux
- [x] Mode desktop : chevron haut-gauche, layout texte/carte côte à côte
- [x] Extraction logique carte dans ChoiceCardWidget réutilisable
- [x] Bouton "Précédent" mobile avec CustomNavigationButton + HoverReliefWidget
- [x] Tailles icônes augmentées (140px mobile, 170px desktop)
---
## 🔵 PRIORITÉ 4 : Tests & Documentation
### Ticket #52 : [Tests] Tests unitaires Backend
@@ -930,7 +1035,7 @@ Créer les tests unitaires pour tous les services et controllers backend.
---
### Ticket #50 : [Tests] Tests intégration Backend
### Ticket #53 : [Tests] Tests intégration Backend
**Estimation** : 5h
**Labels** : `tests`, `p4`, `backend`
@@ -945,7 +1050,7 @@ Créer les tests d'intégration pour les workflows complets.
---
### Ticket #51 : [Tests] Tests E2E Frontend
### Ticket #54 : [Tests] Tests E2E Frontend
**Estimation** : 8h
**Labels** : `tests`, `p4`, `frontend`
@@ -961,7 +1066,7 @@ Créer les tests end-to-end pour les parcours utilisateurs.
---
### Ticket #52 : [Doc] Documentation API OpenAPI/Swagger
### Ticket #55 : [Doc] Documentation API OpenAPI/Swagger
**Estimation** : 3h
**Labels** : `documentation`, `p4`, `api`
@@ -978,7 +1083,7 @@ Générer et documenter l'API avec Swagger/OpenAPI.
## ⚠️ CRITIQUES : Upload, Emails, Infra, Doc
### Ticket #53 : [Backend] Service Upload & Stockage fichiers
### Ticket #56 : [Backend] Service Upload & Stockage fichiers
**Estimation** : 3h
**Labels** : `backend`, `critique`, `upload`
@@ -1009,7 +1114,7 @@ Créer l'endpoint sécurisé pour télécharger les photos.
---
### Ticket #55 : [Backend] Service Logging (Winston)
### Ticket #58 : [Backend] Service Logging (Winston)
**Estimation** : 3h
**Labels** : `backend`, `critique`, `monitoring`
@@ -1028,7 +1133,7 @@ Mettre en place un système de logs centralisé avec Winston pour faciliter le d
---
### Ticket #56 : [Frontend] Écran Logs Admin (optionnel v1.1)
### Ticket #51 (réf.) : [Frontend] Écran Logs Admin (optionnel v1.1)
**Estimation** : 4h
**Labels** : `frontend`, `p3`, `monitoring`, `admin`
@@ -1046,7 +1151,7 @@ Créer un écran pour consulter les logs depuis l'interface admin (optionnel Pha
---
### Ticket #57 : [Infra] Volume Docker pour uploads
### Ticket #59 : [Infra] Volume Docker pour uploads
**Estimation** : 30min
**Labels** : `infra`, `critique`, `docker`
@@ -1060,7 +1165,7 @@ Ajouter un volume Docker pour persister les fichiers uploadés.
---
### Ticket #58 : [Infra] Volume Docker pour documents légaux
### Ticket #60 : [Infra] Volume Docker pour documents légaux
**Estimation** : 30min
**Labels** : `infra`, `critique`, `docker`
@@ -1074,7 +1179,7 @@ Ajouter un volume Docker pour persister les documents légaux (CGU/Privacy).
---
### Ticket #59 : [Doc] Guide installation & configuration
### Ticket #61 : [Doc] Guide installation & configuration
**Estimation** : 3h
**Labels** : `documentation`, `critique`, `on-premise`
@@ -1093,7 +1198,7 @@ Rédiger le guide complet d'installation et de configuration pour les collectivi
## 📚 JURIDIQUE & CDC
### Ticket #60 : [Doc] Amendement CDC v1.4 - Suppression SMS
### Ticket #62 : [Doc] Amendement CDC v1.4 - Suppression SMS
**Estimation** : 30min
**Labels** : `documentation`, `cdc`
@@ -1110,7 +1215,7 @@ Amender le Cahier des Charges pour supprimer la mention des notifications SMS (p
---
### Ticket #61 : [Doc] Rédaction CGU/Privacy génériques v1
### Ticket #63 : [Doc] Rédaction CGU/Privacy génériques v1
**Estimation** : 8h
**Labels** : `documentation`, `juridique`, `rgpd`
@@ -1130,36 +1235,42 @@ Rédiger les documents légaux génériques (CGU et Politique de confidentialit
## 📊 Résumé final
**Total** : 61 tickets
**Estimation** : ~173h de développement
**Total** : 65 tickets
**Estimation** : ~184h de développement
### Par priorité
- **P0 (Bloquant BDD)** : 7 tickets (~5h)
- **P1 (Bloquant Config)** : 7 tickets (~22h)
- **P2 (Backend)** : 18 tickets (~50h)
- **P3 (Frontend)** : 18 tickets (~60h) ← +1 ticket logs admin, +1 ticket refonte formulaires
- **P3 (Frontend)** : 22 tickets (~71h) ← +1 mobile RegisterChoice
- **P4 (Tests/Doc)** : 4 tickets (~24h)
- **Critiques** : 6 tickets (~13h) ← -2 email, +1 logs, +1 CDC
- **Critiques** : 6 tickets (~13h)
- **Juridique** : 1 ticket (~8h)
### Par domaine
- **BDD** : 7 tickets
- **Backend** : 23 tickets ← +1 logs
- **Frontend** : 18 tickets ← +1 logs admin, +1 refonte formulaires
- **Backend** : 23 tickets
- **Frontend** : 22 tickets ← +1 mobile RegisterChoice
- **Tests** : 3 tickets
- **Documentation** : 5 tickets ← +1 amendement CDC
- **Documentation** : 5 tickets
- **Infra** : 2 tickets
- **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. 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
- ✅ **Ajouté** : Ticket #56 "Écran Logs Admin" - Optionnel Phase 1.1
- ✅ **Ajouté** : Ticket #78 "Refonte Infrastructure Formulaires" - Harmonisation UI/UX
- ✅ **Ajouté** : Ticket #79 "Renommer Nanny en AM" - Cohérence CDC
- ✅ **Ajouté** : Ticket #81 "Corrections refactoring" - Bugfixes
- ✅ **Ajouté** : Ticket #83 "RegisterChoiceScreen Mobile" - Responsive UX
- ✅ **Fermé** : Ticket #82 "Écran Login mobile" - Merge develop + master
---
**Dernière mise à jour** : 25 Novembre 2025
**Version** : 1.0
**Statut** : ✅ Prêt pour création dans Gitea
**Dernière mise à jour** : 9 Février 2026
**Version** : 1.4
**Statut** : ✅ Aligné avec le dépôt Gitea
+6 -5
View File
@@ -1,7 +1,7 @@
# 📋 Décisions Projet - P'titsPas
**Version** : 1.0
**Date** : 25 Novembre 2025
**Version** : 1.1
**Date** : 9 Février 2026
**Auteur** : Équipe PtitsPas
---
@@ -49,7 +49,7 @@ ptitspas-app/
**Solution technique** :
- Table `configuration` en BDD (clé/valeur)
- Setup Wizard à la première connexion
- Panneau Paramètres (3 sections) dans le dashboard à la première connexion ; navigation bloquée jusqu'à sauvegarde
- Configuration SMTP dynamique
- Personnalisation (nom app, logo, URL)
@@ -559,10 +559,11 @@ docs/
| Date | Version | Modifications |
|------|---------|---------------|
| 25/11/2025 | 1.0 | Création du document - Toutes les décisions initiales |
| 09/02/2026 | 1.1 | Configuration initiale : un seul panneau Paramètres (3 sections) dans le dashboard, plus de Setup Wizard dédié ; navigation bloquée jusqu'à sauvegarde |
---
**Dernière mise à jour** : 25 Novembre 2025
**Version** : 1.0
**Dernière mise à jour** : 9 Février 2026
**Version** : 1.1
**Statut** : ✅ Document validé
+176
View File
@@ -0,0 +1,176 @@
# Procédure – Utilisation de l'API 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 l'environnement (ex. `~/.bashrc`) :
```bash
export GITEA_TOKEN="<votre_token>"
```
Pour l'utiliser 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 d'une 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 l'issue #47
curl -s -H "Authorization: token $GITEA_TOKEN" \
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/47" | jq .
# Fermer l'issue #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 d'une 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 l'en-tête `Authorization: token <TOKEN>`.
- **404** : vérifier owner/repo et l'URL (sensible à la casse).
- **422 / body invalide** : pour POST/PATCH, envoyer `Content-Type: application/json` et un JSON valide.
+63
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(),
);
// ...
}
```
+5 -4
View File
@@ -61,7 +61,8 @@ changement_mdp_obligatoire: true (première connexion)
| # | Ticket | Description | Effort |
|---|--------|-------------|--------|
| **#14** | Setup Wizard | Écran configuration initiale (SMTP, app) | 4-5h |
| **#12** | Panneau Paramètres / Configuration | Une page avec 3 sections (Email, Personnalisation, Avancé) ; première config = affichage direct + navigation bloquée jusqu'à sauvegarde | 5h |
| **#13** | Intégration panneau au dashboard | Onglet Configuration, accès permanent, même interface | 1h |
| **#47** | Changement MDP Obligatoire | Modale bloquante après login si flag=true | 1-2h |
| **#35** | Création Gestionnaire | Formulaire création gestionnaire | 2-3h |
@@ -256,11 +257,11 @@ Configuration des appels API avec intercepteurs.
## Recommandation de démarrage
1. **Ticket #47** - Modale changement MDP obligatoire (simple, rapide)
2. **Ticket #14** - Setup Wizard (écran configuration initiale)
2. **Tickets #12 + #13** - Panneau Paramètres (3 sections) : première config + onglet Configuration dans le dashboard
3. **Ticket #35** - Formulaire création gestionnaire
Ces 3 tickets complètent le **workflow d'initialisation** !
Ces tickets complètent le **workflow d'initialisation** !
---
*Dernière mise à jour : 27 janvier 2026*
*Dernière mise à jour : 9 février 2026*
+35
View File
@@ -0,0 +1,35 @@
Point tickets frontend (API Gitea) - 27/01/2026
================================================
Issues avec label "frontend" : 20 (ouvertes: 12, fermees: 8)
Num | Etat | Titre
----+--------+--------------------------------------------------------
35 | open | [Frontend] Écran Création Gestionnaire
36 | closed | [Frontend] Inscription Parent - Étape 1 (Parent 1)
37 | closed | [Frontend] Inscription Parent - Étape 2 (Parent 2)
38 | closed | [Frontend] Inscription Parent - Étape 3 (Enfants)
39 | closed | [Frontend] Inscription Parent - Étapes 4-6 (Finalisatio
40 | closed | [Frontend] Inscription AM - Panneau 1 (Identité)
41 | closed | [Frontend] Inscription AM - Panneau 2 (Infos pro)
42 | closed | [Frontend] Inscription AM - Finalisation
43 | open | [Frontend] Écran Création Mot de Passe
44 | open | [Frontend] Dashboard Gestionnaire - Structure
45 | open | [Frontend] Dashboard Gestionnaire - Liste Parents
46 | open | [Frontend] Dashboard Gestionnaire - Liste AM
47 | open | [Frontend] Écran Changement MDP Obligatoire
48 | open | [Frontend] Gestion Erreurs & Messages
49 | open | [Frontend] Écran Gestion Documents Légaux (Admin)
50 | open | [Frontend] Affichage dynamique CGU lors inscription
51 | open | [Frontend] Écran Logs Admin (optionnel v1.1)
54 | open | [Tests] Tests E2E Frontend
82 | closed | [Frontend] Adapter �cran Login pour mobile
83 | closed | [Frontend] Adapter �cran Choix Inscription pour mobile
Suivi doc 23_LISTE-TICKETS (Gitea #73,78,79,81,82,83):
#73 closed labels=[]
#78 closed labels=[]
#79 closed labels=[]
#81 closed labels=[]
#82 closed (écran Login mobile)
#83 closed labels=['frontend', 'p3', 'phase-1', 'ux']
+176
View File
@@ -0,0 +1,176 @@
# Procédure – Utilisation de l’API 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 l’environnement (ex. `~/.bashrc`) :
```bash
export GITEA_TOKEN="<votre_token>"
```
Pour l’utiliser 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 d’une 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 l’issue #47
curl -s -H "Authorization: token $GITEA_TOKEN" \
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/47" | jq .
# Fermer l’issue #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 d’une 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 l’en-tête `Authorization: token <TOKEN>`.
- **404** : vérifier owner/repo et l’URL (sensible à la casse).
- **422 / body invalide** : pour POST/PATCH, envoyer `Content-Type: application/json` et un JSON valide.
+115
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 l’hô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 d’inscription** : compatibles **desktop et mobile**
- Choix d’inscription (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
L’application 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 d’inscription (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).
Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

+14
View File
@@ -19,6 +19,8 @@ import '../screens/auth/am_register_step2_screen.dart';
import '../screens/auth/am_register_step3_screen.dart';
import '../screens/auth/am_register_step4_screen.dart';
import '../screens/home/home_screen.dart';
import '../screens/administrateurs/admin_dashboardScreen.dart';
import '../screens/home/parent_screen/ParentDashboardScreen.dart';
import '../screens/unknown_screen.dart';
// --- Provider Instances ---
@@ -47,6 +49,18 @@ class AppRouter {
path: '/home',
builder: (BuildContext context, GoRouterState state) => const HomeScreen(),
),
GoRoute(
path: '/admin-dashboard',
builder: (BuildContext context, GoRouterState state) => const AdminDashboardScreen(),
),
GoRoute(
path: '/parent-dashboard',
builder: (BuildContext context, GoRouterState state) => const ParentDashboardScreen(),
),
GoRoute(
path: '/am-dashboard',
builder: (BuildContext context, GoRouterState state) => const HomeScreen(),
),
// --- Parent Registration Flow ---
ShellRoute(
+1 -1
View File
@@ -6,7 +6,7 @@ class Env {
);
// Construit une URL vers l'API v1 à partir d'un chemin (commençant par '/')
static String apiV1(String path) => "${apiBaseUrl}/api/v1$path";
static String apiV1(String path) => '$apiBaseUrl/api/v1$path';
}
@@ -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?,
);
}
}
@@ -1,136 +0,0 @@
import 'package:flutter/foundation.dart';
class NannyRegistrationData extends ChangeNotifier {
// Step 1: Identity Info
String firstName = '';
String lastName = '';
String streetAddress = ''; // Nouveau pour N° et Rue
String postalCode = ''; // Nouveau
String city = ''; // Nouveau
String phone = '';
String email = '';
String password = '';
// String? photoPath; // Déplacé ou géré à l'étape 2
// bool photoConsent = false; // Déplacé ou géré à l'étape 2
// Step 2: Professional Info
String? photoPath; // Ajouté pour l'étape 2
bool photoConsent = false; // Ajouté pour l'étape 2
DateTime? dateOfBirth;
String birthCity = ''; // Nouveau
String birthCountry = ''; // Nouveau
// String placeOfBirth = ''; // Remplacé par birthCity et birthCountry
String nir = ''; // Numéro de Sécurité Sociale
String agrementNumber = ''; // Numéro d'agrément
int? capacity; // Number of children the nanny can look after
// Step 3: Presentation & CGU
String presentationText = '';
bool cguAccepted = false;
// --- Methods to update data and notify listeners ---
void updateIdentityInfo({
String? firstName,
String? lastName,
String? streetAddress, // Modifié
String? postalCode, // Nouveau
String? city, // Nouveau
String? phone,
String? email,
String? password,
}) {
this.firstName = firstName ?? this.firstName;
this.lastName = lastName ?? this.lastName;
this.streetAddress = streetAddress ?? this.streetAddress; // Modifié
this.postalCode = postalCode ?? this.postalCode; // Nouveau
this.city = city ?? this.city; // Nouveau
this.phone = phone ?? this.phone;
this.email = email ?? this.email;
this.password = password ?? this.password;
// if (photoPath != null || this.photoPath != null) { // Supprimé de l'étape 1
// this.photoPath = photoPath;
// }
// this.photoConsent = photoConsent ?? this.photoConsent; // Supprimé de l'étape 1
notifyListeners();
}
void updateProfessionalInfo({
String? photoPath,
bool? photoConsent,
DateTime? dateOfBirth,
String? birthCity, // Nouveau
String? birthCountry, // Nouveau
// String? placeOfBirth, // Remplacé
String? nir,
String? agrementNumber,
int? capacity,
}) {
// Allow setting photoPath to null explicitly
if (photoPath != null || this.photoPath != null) {
this.photoPath = photoPath;
}
this.photoConsent = photoConsent ?? this.photoConsent;
this.dateOfBirth = dateOfBirth ?? this.dateOfBirth;
this.birthCity = birthCity ?? this.birthCity; // Nouveau
this.birthCountry = birthCountry ?? this.birthCountry; // Nouveau
// this.placeOfBirth = placeOfBirth ?? this.placeOfBirth; // Remplacé
this.nir = nir ?? this.nir;
this.agrementNumber = agrementNumber ?? this.agrementNumber;
this.capacity = capacity ?? this.capacity;
notifyListeners();
}
void updatePresentationAndCgu({
String? presentationText,
bool? cguAccepted,
}) {
this.presentationText = presentationText ?? this.presentationText;
this.cguAccepted = cguAccepted ?? this.cguAccepted;
notifyListeners();
}
// --- Getters for validation or display ---
bool get isStep1Complete =>
firstName.isNotEmpty &&
lastName.isNotEmpty &&
streetAddress.isNotEmpty && // Modifié
postalCode.isNotEmpty && // Nouveau
city.isNotEmpty && // Nouveau
phone.isNotEmpty &&
email.isNotEmpty &&
password.isNotEmpty;
bool get isStep2Complete =>
// photoConsent is mandatory if a photo is system-required, otherwise optional.
// For now, let's assume if photoPath is present, consent should ideally be true.
// Or, make consent always mandatory if photo section exists.
// Based on new mockup, photo is present, so consent might be implicitly or explicitly needed.
(photoPath != null ? photoConsent == true : true) && // Ajuster selon la logique de consentement désirée
dateOfBirth != null &&
birthCity.isNotEmpty &&
birthCountry.isNotEmpty &&
nir.isNotEmpty && // Basic check, could add validation
agrementNumber.isNotEmpty &&
capacity != null && capacity! > 0;
bool get isStep3Complete =>
// presentationText is optional as per CDC (message au gestionnaire)
cguAccepted;
bool get isRegistrationComplete =>
isStep1Complete && isStep2Complete && isStep3Complete;
@override
String toString() {
return 'NannyRegistrationData('
'firstName: $firstName, lastName: $lastName, '
'streetAddress: $streetAddress, postalCode: $postalCode, city: $city, '
'phone: $phone, email: $email, '
// 'photoPath: $photoPath, photoConsent: $photoConsent, ' // Commenté car déplacé/modifié
'dateOfBirth: $dateOfBirth, birthCity: $birthCity, birthCountry: $birthCountry, '
'nir: $nir, agrementNumber: $agrementNumber, capacity: $capacity, '
'photoPath (step2): $photoPath, photoConsent (step2): $photoConsent, '
'presentationText: $presentationText, cguAccepted: $cguAccepted)';
}
}
+18
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,
);
}
}
+62 -7
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,16 +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: DateTime.parse(json['createdAt'] as String),
updatedAt: DateTime.parse(json['updatedAt'] as String),
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(),
);
}
@@ -34,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();
}
@@ -1,7 +1,10 @@
import 'package:flutter/material.dart';
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';
@@ -9,20 +12,55 @@ class AdminDashboardScreen extends StatefulWidget {
const AdminDashboardScreen({super.key});
@override
_AdminDashboardScreenState createState() => _AdminDashboardScreenState();
State<AdminDashboardScreen> createState() => _AdminDashboardScreenState();
}
class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
int selectedIndex = 0;
bool? _setupCompleted;
int mainTabIndex = 0;
int subIndex = 0;
void onTabChange(int index) {
@override
void initState() {
super.initState();
_loadSetupStatus();
}
Future<void> _loadSetupStatus() async {
try {
final completed = await ConfigurationService.getSetupStatus();
if (!mounted) return;
setState(() {
_setupCompleted = completed;
if (!completed) mainTabIndex = 1;
});
} catch (e) {
if (mounted) setState(() {
_setupCompleted = false;
mainTabIndex = 1;
});
}
}
void onMainTabChange(int index) {
setState(() {
selectedIndex = index;
mainTabIndex = index;
});
}
void onSubTabChange(int index) {
setState(() {
subIndex = index;
});
}
@override
Widget build(BuildContext context) {
if (_setupCompleted == null) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
return Scaffold(
appBar: PreferredSize(
preferredSize: const Size.fromHeight(60.0),
@@ -33,13 +71,19 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
),
),
child: DashboardAppBarAdmin(
selectedIndex: selectedIndex,
onTabChange: onTabChange,
selectedIndex: mainTabIndex,
onTabChange: onMainTabChange,
setupCompleted: _setupCompleted!,
),
),
),
body: Column(
children: [
if (mainTabIndex == 0)
DashboardUserManagementSubBar(
selectedSubIndex: subIndex,
onSubTabChange: onSubTabChange,
),
Expanded(
child: _getBody(),
),
@@ -50,17 +94,20 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
}
Widget _getBody() {
switch (selectedIndex) {
if (mainTabIndex == 1) {
return ParametresPanel(redirectToLoginAfterSave: !_setupCompleted!);
}
switch (subIndex) {
case 0:
return const GestionnaireManagementWidget();
return const GestionnaireManagementWidget();
case 1:
return const ParentManagementWidget();
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"));
return const Center(child: Text('Page non trouvée'));
}
}
}
+242 -29
View File
@@ -1,7 +1,6 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:url_launcher/url_launcher.dart';
import 'package:go_router/go_router.dart';
import 'package:p_tits_pas/services/bug_report_service.dart';
@@ -17,7 +16,7 @@ class LoginScreen extends StatefulWidget {
State<LoginScreen> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginScreen> {
class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
@@ -25,13 +24,28 @@ class _LoginPageState extends State<LoginScreen> {
bool _isLoading = false;
String? _errorMessage;
static const double _mobileBreakpoint = 900.0;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
@override
void didChangeMetrics() {
super.didChangeMetrics();
if (mounted) setState(() {});
}
String? _validateEmail(String? value) {
if (value == null || value.isEmpty) {
return 'Veuillez entrer votre email';
@@ -102,36 +116,42 @@ class _LoginPageState extends State<LoginScreen> {
}
}
/// Redirige l'utilisateur selon son rôle
/// Redirige l'utilisateur selon son rôle (GoRouter : context.go).
void _redirectUserByRole(String role) {
setState(() => _isLoading = false);
switch (role.toLowerCase()) {
case 'super_admin':
case 'administrateur':
case 'gestionnaire':
Navigator.pushReplacementNamed(context, '/admin-dashboard');
context.go('/admin-dashboard');
break;
case 'parent':
Navigator.pushReplacementNamed(context, '/parent-dashboard');
context.go('/parent-dashboard');
break;
case 'assistante_maternelle':
Navigator.pushReplacementNamed(context, '/am-dashboard');
context.go('/am-dashboard');
break;
default:
Navigator.pushReplacementNamed(context, '/home');
context.go('/home');
}
}
@override
Widget build(BuildContext context) {
final isMobile = MediaQuery.of(context).size.width < _mobileBreakpoint;
if (isMobile) {
return Scaffold(
backgroundColor: Colors.transparent,
body: _buildMobileLayout(context),
);
}
return Scaffold(
backgroundColor: Colors.transparent,
body: LayoutBuilder(
builder: (context, constraints) {
// Version desktop (web)
if (kIsWeb) {
final w = constraints.maxWidth;
final h = constraints.maxHeight;
return FutureBuilder(
final w = constraints.maxWidth;
final h = constraints.maxHeight;
return FutureBuilder(
future: _getImageDimensions(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
@@ -216,20 +236,20 @@ class _LoginPageState extends State<LoginScreen> {
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,
),
),
),
@@ -289,18 +309,19 @@ class _LoginPageState extends State<LoginScreen> {
),
),
),
// Pied de page
// Pied de page (Wrap pour éviter overflow sur petite largeur)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 8.0),
decoration: BoxDecoration(
decoration: const BoxDecoration(
color: Colors.transparent,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
child: Wrap(
alignment: WrapAlignment.center,
runSpacing: 8,
children: [
_FooterLink(
text: 'Contact support',
@@ -349,17 +370,207 @@ class _LoginPageState extends State<LoginScreen> {
);
},
);
}
// Version mobile (à implémenter)
return const Center(
child: Text('Version mobile à implémenter'),
);
},
),
);
}
/// Dimensions de river_logo_mobile.png (à mettre à jour si l'asset change).
static const int _riverLogoMobileWidth = 600;
static const int _riverLogoMobileHeight = 1080;
/// Fraction de la hauteur de l'image où se termine visuellement le slogan (0 = haut, 1 = bas).
static const double _sloganEndFraction = 0.42;
static const double _gapBelowSlogan = 12.0;
Widget _buildMobileLayout(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final h = constraints.maxHeight;
final w = constraints.maxWidth;
final imageAspectRatio = _riverLogoMobileHeight / _riverLogoMobileWidth;
final formTop = w * imageAspectRatio * _sloganEndFraction + _gapBelowSlogan;
return Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: Image.asset(
'assets/images/paper2.png',
fit: BoxFit.cover,
repeat: ImageRepeat.repeat,
),
),
Positioned(
top: 0,
left: 0,
right: 0,
height: h * 1.2,
child: OverflowBox(
alignment: Alignment.topCenter,
minWidth: w,
maxWidth: w,
minHeight: 0,
maxHeight: h * 2.5,
child: Image.asset(
'assets/images/river_logo_mobile.png',
width: w,
fit: BoxFit.fitWidth,
),
),
),
Positioned(
top: formTop,
left: 0,
right: 0,
bottom: 0,
child: SafeArea(
top: false,
child: Column(
children: [
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 16),
CustomAppTextField(
controller: _emailController,
labelText: 'Email',
showLabel: false,
hintText: 'Votre adresse email',
validator: _validateEmail,
style: CustomAppTextFieldStyle.lavande,
fieldHeight: 48,
fieldWidth: double.infinity,
),
const SizedBox(height: 12),
CustomAppTextField(
controller: _passwordController,
labelText: 'Mot de passe',
showLabel: false,
hintText: 'Votre mot de passe',
obscureText: true,
validator: _validatePassword,
style: CustomAppTextFieldStyle.jaune,
fieldHeight: 48,
fieldWidth: double.infinity,
),
if (_errorMessage != null) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.red.shade300),
),
child: Row(
children: [
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.shade700),
),
),
],
),
),
],
const SizedBox(height: 12),
_isLoading
? const CircularProgressIndicator()
: ImageButton(
bg: 'assets/images/bg_green.png',
width: double.infinity,
height: 44,
text: 'Se connecter',
textColor: const Color(0xFF2D6A4F),
onPressed: _handleLogin,
),
const SizedBox(height: 12),
TextButton(
onPressed: () { /* TODO */ },
child: Text(
'Mot de passe oublié ?',
style: GoogleFonts.merienda(
fontSize: 14,
color: const Color(0xFF2D6A4F),
decoration: TextDecoration.underline,
),
),
),
TextButton(
onPressed: () => context.go('/register-choice'),
child: Text(
'Créer un compte',
style: GoogleFonts.merienda(
fontSize: 16,
color: const Color(0xFF2D6A4F),
decoration: TextDecoration.underline,
),
),
),
],
),
),
),
),
),
Padding(
padding: const EdgeInsets.only(bottom: 12, top: 8),
child: Wrap(
alignment: WrapAlignment.center,
runSpacing: 6,
spacing: 4,
children: [
_FooterLink(
text: 'Contact support',
fontSize: 11,
onTap: () async {
final uri = Uri(scheme: 'mailto', path: 'support@supernounou.local');
if (await canLaunchUrl(uri)) {
await launchUrl(uri);
} else if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Impossible d\'ouvrir le client mail', style: GoogleFonts.merienda())),
);
}
},
),
_FooterLink(
text: 'Signaler un bug',
fontSize: 11,
onTap: () => _showBugReportDialog(context),
),
_FooterLink(
text: 'Mentions légales',
fontSize: 11,
onTap: () => context.go('/legal'),
),
_FooterLink(
text: 'Politique de confidentialité',
fontSize: 11,
onTap: () => context.go('/privacy'),
),
],
),
),
],
),
),
),
],
);
},
);
}
void _showBugReportDialog(BuildContext context) {
final TextEditingController controller = TextEditingController();
@@ -471,10 +682,12 @@ class ImageDimensions {
class _FooterLink extends StatelessWidget {
final String text;
final VoidCallback onTap;
final double fontSize;
const _FooterLink({
required this.text,
required this.onTap,
this.fontSize = 14.0,
});
@override
@@ -482,11 +695,11 @@ class _FooterLink extends StatelessWidget {
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
padding: EdgeInsets.symmetric(horizontal: fontSize > 12 ? 8.0 : 4.0),
child: Text(
text,
style: GoogleFonts.merienda(
fontSize: 14,
fontSize: fontSize,
color: Colors.black87,
decoration: TextDecoration.underline,
),
@@ -1,47 +0,0 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class NannyRegisterConfirmationScreen extends StatelessWidget {
const NannyRegisterConfirmationScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Inscription Soumise'),
automaticallyImplyLeading: false, // Remove back button
),
body: Center(
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Icons.check_circle_outline, color: Colors.green, size: 80),
const SizedBox(height: 20),
const Text(
'Votre demande d\'inscription a été soumise avec succès !',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 15),
const Text(
'Votre compte est en attente de validation par un gestionnaire. Vous recevrez une notification par e-mail une fois votre compte activé.',
textAlign: TextAlign.center,
),
const SizedBox(height: 30),
ElevatedButton(
onPressed: () {
// Navigate back to the login screen
context.go('/login');
},
child: const Text('Retour à la connexion'),
),
],
),
),
),
);
}
}
+12
View File
@@ -5,6 +5,8 @@ class ApiConfig {
// Auth endpoints
static const String login = '/auth/login';
static const String register = '/auth/register';
static const String registerParent = '/auth/register/parent';
static const String registerAM = '/auth/register/am';
static const String refreshToken = '/auth/refresh';
static const String authMe = '/auth/me';
static const String changePasswordRequired = '/auth/change-password-required';
@@ -13,6 +15,16 @@ 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';
static const String configurationSetupStatus = '/configuration/setup/status';
static const String configurationSetupComplete = '/configuration/setup/complete';
static const String configurationTestSmtp = '/configuration/test-smtp';
static const String configurationBulk = '/configuration/bulk';
// Dashboard endpoints
static const String dashboard = '/dashboard';
+26 -13
View File
@@ -23,13 +23,15 @@ class AuthService {
if (response.statusCode == 200 || response.statusCode == 201) {
final data = jsonDecode(response.body);
// Stocker les tokens
await TokenService.saveToken(data['accessToken']);
await TokenService.saveRefreshToken(data['refreshToken']);
// Récupérer le profil utilisateur pour avoir toutes les infos
final user = await _fetchUserProfile(data['accessToken']);
// API renvoie access_token / refresh_token (snake_case)
final accessToken = data['access_token'] as String? ?? data['accessToken'] as String?;
final refreshToken = data['refresh_token'] as String? ?? data['refreshToken'] as String?;
if (accessToken == null) throw Exception('Token absent dans la réponse serveur');
await TokenService.saveToken(accessToken);
await TokenService.saveRefreshToken(refreshToken ?? '');
final user = await _fetchUserProfile(accessToken);
// Stocker l'utilisateur en cache
await _saveCurrentUser(user);
@@ -41,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)');
}
}
@@ -54,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)');
}
}
@@ -80,8 +91,9 @@ class AuthService {
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.changePasswordRequired}'),
headers: ApiConfig.authHeaders(token),
body: jsonEncode({
'currentPassword': currentPassword,
'newPassword': newPassword,
'mot_de_passe_actuel': currentPassword,
'nouveau_mot_de_passe': newPassword,
'confirmation_mot_de_passe': newPassword,
}),
);
@@ -95,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)');
}
}
@@ -0,0 +1,150 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'api/api_config.dart';
import 'api/tokenService.dart';
/// Réponse GET /configuration (liste complète)
class ConfigItem {
final String cle;
final String? valeur;
final String type;
final String? categorie;
final String? description;
ConfigItem({
required this.cle,
this.valeur,
required this.type,
this.categorie,
this.description,
});
factory ConfigItem.fromJson(Map<String, dynamic> json) {
return ConfigItem(
cle: json['cle'] as String,
valeur: json['valeur'] as String?,
type: json['type'] as String? ?? 'string',
categorie: json['categorie'] as String?,
description: json['description'] as String?,
);
}
}
/// Réponse GET /configuration/:category (objet clé -> { value, type, description })
class ConfigValueItem {
final dynamic value;
final String type;
final String? description;
ConfigValueItem({required this.value, required this.type, this.description});
factory ConfigValueItem.fromJson(Map<String, dynamic> json) {
return ConfigValueItem(
value: json['value'],
type: json['type'] as String? ?? 'string',
description: json['description'] as String?,
);
}
}
class ConfigurationService {
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();
}
/// GET /api/v1/configuration/setup/status
static Future<bool> getSetupStatus() async {
final response = await http.get(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.configurationSetupStatus}'),
headers: await _headers(),
);
if (response.statusCode != 200) return true;
final data = jsonDecode(response.body);
final val = data['data']?['setupCompleted'];
if (val is bool) return val;
if (val is String) return val.toLowerCase() == 'true' || val == '1';
if (val is int) return val == 1;
return true; // Par défaut on considère configuré pour ne pas bloquer
}
/// GET /api/v1/configuration (toutes les configs)
static Future<List<ConfigItem>> getAll() async {
final response = await http.get(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.configuration}'),
headers: await _headers(),
);
if (response.statusCode != 200) {
final err = jsonDecode(response.body) as Map<String, dynamic>?;
throw Exception(_toStr(err?['message']) ?? 'Erreur chargement configuration');
}
final data = jsonDecode(response.body);
final list = data['data'] as List<dynamic>? ?? [];
return list.map((e) => ConfigItem.fromJson(e as Map<String, dynamic>)).toList();
}
/// GET /api/v1/configuration/:category
static Future<Map<String, ConfigValueItem>> getByCategory(String category) async {
final response = await http.get(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.configuration}/$category'),
headers: await _headers(),
);
if (response.statusCode != 200) {
final err = jsonDecode(response.body) as Map<String, dynamic>?;
throw Exception(_toStr(err?['message']) ?? 'Erreur chargement configuration');
}
final data = jsonDecode(response.body);
final map = data['data'] as Map<String, dynamic>? ?? {};
return map.map((k, v) => MapEntry(k, ConfigValueItem.fromJson(v as Map<String, dynamic>)));
}
/// PATCH /api/v1/configuration/bulk
static Future<void> updateBulk(Map<String, dynamic> body) async {
final response = await http.patch(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.configurationBulk}'),
headers: await _headers(),
body: jsonEncode(body),
);
if (response.statusCode != 200 && response.statusCode != 201) {
final err = jsonDecode(response.body) as Map<String, dynamic>?;
final msg = err != null ? (_toStr(err['error']) ?? _toStr(err['message'])) : null;
throw Exception(msg ?? 'Erreur lors de la sauvegarde');
}
}
/// POST /api/v1/configuration/test-smtp
static Future<String> testSmtp(String testEmail) async {
final response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.configurationTestSmtp}'),
headers: await _headers(),
body: jsonEncode({'testEmail': testEmail}),
);
final data = jsonDecode(response.body) as Map<String, dynamic>?;
if ((response.statusCode == 200 || response.statusCode == 201) && (data?['success'] == true)) {
return _toStr(data?['message']) ?? 'Test SMTP réussi.';
}
final msg = data != null ? (_toStr(data['error']) ?? _toStr(data['message'])) : null;
throw Exception(msg ?? 'Échec du test SMTP');
}
/// POST /api/v1/configuration/setup/complete (après première config)
static Future<void> completeSetup() async {
final response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.configurationSetupComplete}'),
headers: await _headers(),
);
if (response.statusCode != 200 && response.statusCode != 201) {
final err = jsonDecode(response.body) as Map<String, dynamic>?;
final msg = err != null ? (_toStr(err['error']) ?? _toStr(err['message'])) : null;
throw Exception(msg ?? 'Erreur finalisation configuration');
}
}
}
+94
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 [];
}
}
@@ -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: () {},
),
],
),
),
);
},
),
)
],
),
);
}
}
@@ -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é
},
),
),
],
+113 -67
View File
@@ -1,47 +1,51 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:p_tits_pas/services/auth_service.dart';
/// Barre du dashboard admin : onglets Gestion des utilisateurs | Paramètres + déconnexion.
class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidget {
final int selectedIndex;
final ValueChanged<int> onTabChange;
final bool setupCompleted;
const DashboardAppBarAdmin({Key? key, required this.selectedIndex, required this.onTabChange}) : super(key: key);
const DashboardAppBarAdmin({
Key? key,
required this.selectedIndex,
required this.onTabChange,
this.setupCompleted = true,
}) : super(key: key);
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight + 10);
@override
Widget build(BuildContext context) {
final isMobile = MediaQuery.of(context).size.width < 768;
return AppBar(
elevation: 0,
automaticallyImplyLeading: false,
title: Row(
children: [
SizedBox(width: MediaQuery.of(context).size.width * 0.19),
const Text(
"P'tit Pas",
style: TextStyle(
color: Color(0xFF9CC5C0),
fontWeight: FontWeight.bold,
fontSize: 18,
const SizedBox(width: 24),
Image.asset(
'assets/images/logo.png',
height: 40,
fit: BoxFit.contain,
),
Expanded(
child: Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildNavItem(context, 'Gestion des utilisateurs', 0, enabled: setupCompleted),
const SizedBox(width: 24),
_buildNavItem(context, 'Paramètres', 1, enabled: true),
],
),
),
),
SizedBox(width: MediaQuery.of(context).size.width * 0.1),
// Navigation principale
_buildNavItem(context, 'Gestionnaires', 0),
const SizedBox(width: 24),
_buildNavItem(context, 'Parents', 1),
const SizedBox(width: 24),
_buildNavItem(context, 'Assistantes maternelles', 2),
const SizedBox(width: 24),
_buildNavItem(context, 'Administrateurs', 3),
],
),
actions: isMobile
? [_buildMobileMenu(context)]
: [
// Nom de l'utilisateur
actions: [
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Center(
@@ -55,8 +59,6 @@ class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidge
),
),
),
// Bouton déconnexion
Padding(
padding: const EdgeInsets.only(right: 16),
child: TextButton(
@@ -72,51 +74,33 @@ class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidge
child: const Text('Se déconnecter'),
),
),
SizedBox(width: MediaQuery.of(context).size.width * 0.1),
],
);
}
Widget _buildNavItem(BuildContext context, String title, int index) {
final bool isActive = index == selectedIndex;
return InkWell(
onTap: () => onTabChange(index),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: isActive ? const Color(0xFF9CC5C0) : Colors.transparent,
borderRadius: BorderRadius.circular(20),
border: isActive ? null : Border.all(color: Colors.black26),
),
child: Text(
title,
style: TextStyle(
color: isActive ? Colors.white : Colors.black,
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
fontSize: 14,
Widget _buildNavItem(BuildContext context, String title, int index, {bool enabled = true}) {
final bool isActive = index == selectedIndex;
return InkWell(
onTap: enabled ? () => onTabChange(index) : null,
child: Opacity(
opacity: enabled ? 1.0 : 0.5,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: isActive ? const Color(0xFF9CC5C0) : Colors.transparent,
borderRadius: BorderRadius.circular(20),
border: isActive ? null : Border.all(color: Colors.black26),
),
child: Text(
title,
style: TextStyle(
color: isActive ? Colors.white : Colors.black,
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
fontSize: 14,
),
),
),
),
),
);
}
Widget _buildMobileMenu(BuildContext context) {
return PopupMenuButton<int>(
icon: const Icon(Icons.menu, color: Colors.white),
onSelected: (value) {
if (value == 4) {
_handleLogout(context);
}
},
itemBuilder: (context) => [
const PopupMenuItem(value: 0, child: Text("Gestionnaires")),
const PopupMenuItem(value: 1, child: Text("Parents")),
const PopupMenuItem(value: 2, child: Text("Assistantes maternelles")),
const PopupMenuItem(value: 3, child: Text("Administrateurs")),
const PopupMenuDivider(),
const PopupMenuItem(value: 4, child: Text("Se déconnecter")),
],
);
}
@@ -132,9 +116,10 @@ class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidge
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () {
onPressed: () async {
Navigator.pop(context);
// TODO: Implémenter la logique de déconnexion
await AuthService.logout();
if (context.mounted) context.go('/login');
},
child: const Text('Déconnecter'),
),
@@ -142,4 +127,65 @@ class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidge
),
);
}
}
}
/// Sous-barre : Gestionnaires | Parents | Assistantes maternelles | Administrateurs.
class DashboardUserManagementSubBar extends StatelessWidget {
final int selectedSubIndex;
final ValueChanged<int> onSubTabChange;
const DashboardUserManagementSubBar({
Key? key,
required this.selectedSubIndex,
required this.onSubTabChange,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
height: 48,
decoration: BoxDecoration(
color: Colors.grey.shade100,
border: Border(bottom: BorderSide(color: Colors.grey.shade300)),
),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 6),
child: Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildSubNavItem(context, 'Gestionnaires', 0),
const SizedBox(width: 16),
_buildSubNavItem(context, 'Parents', 1),
const SizedBox(width: 16),
_buildSubNavItem(context, 'Assistantes maternelles', 2),
const SizedBox(width: 16),
_buildSubNavItem(context, 'Administrateurs', 3),
],
),
),
);
}
Widget _buildSubNavItem(BuildContext context, String title, int index) {
final bool isActive = index == selectedSubIndex;
return InkWell(
onTap: () => onSubTabChange(index),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
decoration: BoxDecoration(
color: isActive ? const Color(0xFF9CC5C0) : Colors.transparent,
borderRadius: BorderRadius.circular(16),
border: isActive ? null : Border.all(color: Colors.black26),
),
child: Text(
title,
style: TextStyle(
color: isActive ? Colors.white : Colors.black87,
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
fontSize: 13,
),
),
),
);
}
}
@@ -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,
);
},
),
)
],
),
);
@@ -0,0 +1,428 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:p_tits_pas/services/configuration_service.dart';
/// Panneau Paramètres admin : Email (SMTP), Personnalisation, Avancé.
class ParametresPanel extends StatefulWidget {
/// Si true, après sauvegarde on redirige vers le login (première config). Sinon on reste sur la page.
final bool redirectToLoginAfterSave;
const ParametresPanel({super.key, this.redirectToLoginAfterSave = false});
@override
State<ParametresPanel> createState() => _ParametresPanelState();
}
class _ParametresPanelState extends State<ParametresPanel> {
final _formKey = GlobalKey<FormState>();
bool _isLoading = true;
String? _loadError;
bool _isSaving = false;
String? _message;
final Map<String, TextEditingController> _controllers = {};
bool _smtpSecure = false;
bool _smtpAuthRequired = false;
@override
void initState() {
super.initState();
_createControllers();
_loadConfiguration();
}
void _createControllers() {
final keys = [
'smtp_host', 'smtp_port', 'smtp_user', 'smtp_password',
'email_from_name', 'email_from_address',
'app_name', 'app_url', 'app_logo_url',
'password_reset_token_expiry_days', 'jwt_expiry_hours', 'max_upload_size_mb',
];
for (final k in keys) {
_controllers[k] = TextEditingController();
}
}
Future<void> _loadConfiguration() async {
setState(() {
_isLoading = true;
_loadError = null;
});
try {
final list = await ConfigurationService.getAll();
if (!mounted) return;
for (final item in list) {
final c = _controllers[item.cle];
if (c != null && item.valeur != null && item.valeur != '***********') {
c.text = item.valeur!;
}
if (item.cle == 'smtp_secure') {
_smtpSecure = item.valeur == 'true';
}
if (item.cle == 'smtp_auth_required') {
_smtpAuthRequired = item.valeur == 'true';
}
}
setState(() {
_isLoading = false;
});
} catch (e) {
if (mounted) {
setState(() {
_isLoading = false;
_loadError = e.toString().replaceAll('Exception: ', '');
});
}
}
}
@override
void dispose() {
for (final c in _controllers.values) {
c.dispose();
}
super.dispose();
}
Map<String, dynamic> _buildPayload() {
final payload = <String, dynamic>{};
payload['smtp_host'] = _controllers['smtp_host']!.text.trim();
final port = int.tryParse(_controllers['smtp_port']!.text.trim());
if (port != null) payload['smtp_port'] = port;
payload['smtp_secure'] = _smtpSecure;
payload['smtp_auth_required'] = _smtpAuthRequired;
payload['smtp_user'] = _controllers['smtp_user']!.text.trim();
final pwd = _controllers['smtp_password']!.text.trim();
if (pwd.isNotEmpty && pwd != '***********') payload['smtp_password'] = pwd;
payload['email_from_name'] = _controllers['email_from_name']!.text.trim();
payload['email_from_address'] = _controllers['email_from_address']!.text.trim();
payload['app_name'] = _controllers['app_name']!.text.trim();
payload['app_url'] = _controllers['app_url']!.text.trim();
payload['app_logo_url'] = _controllers['app_logo_url']!.text.trim();
final tokenDays = int.tryParse(_controllers['password_reset_token_expiry_days']!.text.trim());
if (tokenDays != null) payload['password_reset_token_expiry_days'] = tokenDays;
final jwtHours = int.tryParse(_controllers['jwt_expiry_hours']!.text.trim());
if (jwtHours != null) payload['jwt_expiry_hours'] = jwtHours;
final maxMb = int.tryParse(_controllers['max_upload_size_mb']!.text.trim());
if (maxMb != null) payload['max_upload_size_mb'] = maxMb;
return payload;
}
/// Sauvegarde en base sans completeSetup (utilisé avant test SMTP).
Future<void> _saveBulkOnly() async {
await ConfigurationService.updateBulk(_buildPayload());
}
/// Sauvegarde la config, marque le setup comme terminé. Si première config, redirige vers le login.
Future<void> _save() async {
final redirectAfter = widget.redirectToLoginAfterSave;
setState(() {
_message = null;
_isSaving = true;
});
try {
await ConfigurationService.updateBulk(_buildPayload());
if (!mounted) return;
await ConfigurationService.completeSetup();
if (!mounted) return;
setState(() {
_isSaving = false;
_message = 'Configuration enregistrée.';
});
if (!mounted) return;
if (redirectAfter) {
GoRouter.of(context).go('/login');
}
} catch (e) {
if (mounted) {
setState(() {
_isSaving = false;
_message = e.toString().replaceAll('Exception: ', '');
});
}
}
}
Future<void> _testSmtp() async {
final email = await showDialog<String>(
context: context,
builder: (ctx) {
final c = TextEditingController();
return AlertDialog(
title: const Text('Tester la connexion SMTP'),
content: TextField(
controller: c,
decoration: const InputDecoration(
labelText: 'Email pour recevoir le test',
hintText: 'admin@example.com',
),
keyboardType: TextInputType.emailAddress,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () {
final t = c.text.trim();
if (t.isNotEmpty) Navigator.pop(ctx, t);
},
child: const Text('Envoyer'),
),
],
);
},
);
if (email == null || !mounted) return;
setState(() => _message = null);
try {
await _saveBulkOnly();
if (!mounted) return;
final msg = await ConfigurationService.testSmtp(email);
if (!mounted) return;
setState(() => _message = msg);
} catch (e) {
if (mounted) {
setState(() => _message = e.toString().replaceAll('Exception: ', ''));
}
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_loadError != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_loadError!, style: TextStyle(color: Colors.red.shade700)),
const SizedBox(height: 16),
FilledButton(
onPressed: _loadConfiguration,
child: const Text('Réessayer'),
),
],
),
),
);
}
final isSuccess = _message != null &&
(_message!.startsWith('Configuration') || _message!.startsWith('Connexion'));
return Form(
key: _formKey,
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_message != null) ...[
_MessageBanner(message: _message!, isSuccess: isSuccess),
const SizedBox(height: 20),
],
_buildSectionCard(
context,
icon: Icons.email_outlined,
title: 'Configuration Email (SMTP)',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildField('smtp_host', 'Serveur SMTP', hint: 'mail.example.com'),
const SizedBox(height: 14),
_buildField('smtp_port', 'Port SMTP', keyboard: TextInputType.number, hint: '25, 465, 587'),
const SizedBox(height: 14),
Padding(
padding: const EdgeInsets.only(bottom: 14),
child: Row(
children: [
Checkbox(
value: _smtpSecure,
onChanged: (v) => setState(() => _smtpSecure = v ?? false),
activeColor: const Color(0xFF9CC5C0),
),
const Text('SSL/TLS (secure)'),
const SizedBox(width: 24),
Checkbox(
value: _smtpAuthRequired,
onChanged: (v) => setState(() => _smtpAuthRequired = v ?? false),
activeColor: const Color(0xFF9CC5C0),
),
const Text('Authentification requise'),
],
),
),
_buildField('smtp_user', 'Utilisateur SMTP'),
const SizedBox(height: 14),
_buildField('smtp_password', 'Mot de passe SMTP', obscure: true),
const SizedBox(height: 14),
_buildField('email_from_name', 'Nom expéditeur'),
const SizedBox(height: 14),
_buildField('email_from_address', 'Email expéditeur', hint: 'no-reply@example.com'),
const SizedBox(height: 18),
Align(
alignment: Alignment.centerRight,
child: OutlinedButton.icon(
onPressed: _isSaving ? null : _testSmtp,
icon: const Icon(Icons.send_outlined, size: 18),
label: const Text('Tester la connexion SMTP'),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF2D6A4F),
side: const BorderSide(color: Color(0xFF9CC5C0)),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
),
),
),
],
),
),
const SizedBox(height: 24),
_buildSectionCard(
context,
icon: Icons.palette_outlined,
title: 'Personnalisation',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildField('app_name', 'Nom de l\'application'),
const SizedBox(height: 14),
_buildField('app_url', 'URL de l\'application', hint: 'https://app.example.com'),
const SizedBox(height: 14),
_buildField('app_logo_url', 'URL du logo', hint: '/assets/logo.png'),
],
),
),
const SizedBox(height: 24),
_buildSectionCard(
context,
icon: Icons.settings_outlined,
title: 'Paramètres avancés',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildField('password_reset_token_expiry_days', 'Validité token MDP (jours)', keyboard: TextInputType.number),
const SizedBox(height: 14),
_buildField('jwt_expiry_hours', 'Validité session JWT (heures)', keyboard: TextInputType.number),
const SizedBox(height: 14),
_buildField('max_upload_size_mb', 'Taille max upload (MB)', keyboard: TextInputType.number),
],
),
),
const SizedBox(height: 28),
SizedBox(
height: 48,
child: FilledButton(
onPressed: _isSaving ? null : _save,
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF9CC5C0),
foregroundColor: Colors.white,
),
child: _isSaving
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Text('Sauvegarder la configuration'),
),
),
],
),
),
),
),
);
}
Widget _buildSectionCard(BuildContext context, {required IconData icon, required String title, required Widget child}) {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 22, color: const Color(0xFF9CC5C0)),
const SizedBox(width: 10),
Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: const Color(0xFF2D6A4F),
),
),
],
),
const SizedBox(height: 20),
child,
],
),
),
);
}
Widget _buildField(String key, String label, {bool obscure = false, TextInputType? keyboard, String? hint}) {
final c = _controllers[key];
if (c == null) return const SizedBox.shrink();
return TextFormField(
controller: c,
obscureText: obscure,
keyboardType: keyboard,
enabled: !_isSaving,
decoration: InputDecoration(
labelText: label,
hintText: hint,
border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
),
);
}
}
class _MessageBanner extends StatelessWidget {
final String message;
final bool isSuccess;
const _MessageBanner({required this.message, required this.isSuccess});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: isSuccess ? Colors.green.shade50 : Colors.red.shade50,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isSuccess ? Colors.green.shade200 : Colors.red.shade200,
),
),
child: Row(
children: [
Icon(
isSuccess ? Icons.check_circle_outline : Icons.error_outline,
size: 22,
color: isSuccess ? Colors.green.shade700 : Colors.red.shade700,
),
const SizedBox(width: 12),
Expanded(
child: Text(
message,
style: TextStyle(
color: isSuccess ? Colors.green.shade900 : Colors.red.shade900,
fontSize: 14,
),
),
),
],
),
);
}
}
@@ -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();
});
},
),
),
@@ -196,7 +196,7 @@ class _ChangePasswordDialogState extends State<ChangePasswordDialog> {
hintText: 'Retapez le nouveau mot de passe',
obscureText: true,
validator: _validateConfirmPassword,
style: CustomAppTextFieldStyle.lavande,
style: CustomAppTextFieldStyle.jaune,
fieldHeight: 53,
fieldWidth: double.infinity,
enabled: !_isLoading,
+10 -2
View File
@@ -55,8 +55,14 @@ class ChoiceCardWidget extends StatelessWidget {
required bool isMobile,
}) {
final Color baseRoseColor = Colors.pink.shade300;
final Color initialShadow = baseRoseColor.withAlpha(90);
final Color hoverShadow = baseRoseColor.withAlpha(130);
final Color initialShadow = isMobile
? Colors.black.withOpacity(0.45)
: baseRoseColor.withAlpha(90);
final Color hoverShadow = isMobile
? Colors.black.withOpacity(0.5)
: baseRoseColor.withAlpha(130);
final double initialElevation = isMobile ? 14.0 : 4.0;
final double hoverElevation = isMobile ? 18.0 : 8.0;
return Column(
mainAxisSize: MainAxisSize.min,
@@ -64,6 +70,8 @@ class ChoiceCardWidget extends StatelessWidget {
HoverReliefWidget(
onPressed: onPressed,
borderRadius: BorderRadius.circular(15.0),
initialElevation: initialElevation,
hoverElevation: hoverElevation,
initialShadowColor: initialShadow,
hoverShadowColor: hoverShadow,
child: Padding(
@@ -25,11 +25,13 @@ class CustomAppTextField extends StatefulWidget {
final IconData? suffixIcon;
final double labelFontSize;
final double inputFontSize;
final bool showLabel;
const CustomAppTextField({
super.key,
required this.controller,
required this.labelText,
this.showLabel = true,
this.hintText = '',
this.fieldWidth = 300.0,
this.fieldHeight = 53.0,
@@ -73,15 +75,17 @@ class _CustomAppTextFieldState extends State<CustomAppTextField> {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.labelText,
style: GoogleFonts.merienda(
fontSize: widget.labelFontSize,
color: Colors.black87,
fontWeight: FontWeight.w500,
if (widget.showLabel) ...[
Text(
widget.labelText,
style: GoogleFonts.merienda(
fontSize: widget.labelFontSize,
color: Colors.black87,
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(height: 6),
const SizedBox(height: 6),
],
SizedBox(
width: widget.fieldWidth,
height: dynamicFieldHeight,
+19
View File
@@ -0,0 +1,19 @@
# Créer l’issue #84 (correctifs modale MDP) via l’API Gitea
1. Définir un token valide :
`export GITEA_TOKEN="votre_token"`
ou créer `.gitea-token` à la racine du projet avec le token seul.
2. Créer l’issue :
```bash
cd /chemin/vers/PetitsPas
curl -s -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d @scripts/issue-84-payload.json \
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues"
```
3. En cas de succès (HTTP 201), la réponse JSON contient le numéro de l’issue créée.
Payload utilisé : `scripts/issue-84-payload.json` (titre + corps depuis `scripts/issue-84-body.txt`).
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Crée une issue Gitea via l'API.
# Usage: GITEA_TOKEN=xxx ./scripts/create-gitea-issue.sh
# Ou: mettre le token dans .gitea-token à la racine du projet.
set -e
BASE_URL="${GITEA_URL:-https://git.ptits-pas.fr/api/v1}"
REPO="jmartin/petitspas"
if [ -z "$GITEA_TOKEN" ]; then
if [ -f .gitea-token ]; then
GITEA_TOKEN=$(cat .gitea-token)
fi
fi
if [ -z "$GITEA_TOKEN" ]; then
echo "Définir GITEA_TOKEN ou créer .gitea-token avec votre token Gitea."
exit 1
fi
TITLE="$1"
BODY="$2"
if [ -z "$TITLE" ]; then
echo "Usage: $0 \"Titre de l'issue\" \"Corps (optionnel)\""
exit 1
fi
# Build JSON (escape body for JSON)
BODY_ESC=$(echo "$BODY" | jq -Rs . 2>/dev/null || echo "null")
if [ "$BODY_ESC" = "null" ] || [ -z "$BODY" ]; then
PAYLOAD=$(jq -n --arg t "$TITLE" '{title: $t}')
else
PAYLOAD=$(jq -n --arg t "$TITLE" --arg b "$BODY" '{title: $t, body: $b}')
fi
RESP=$(curl -s -w "\n%{http_code}" -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "$PAYLOAD" \
"$BASE_URL/repos/$REPO/issues")
HTTP_CODE=$(echo "$RESP" | tail -1)
BODY_RESP=$(echo "$RESP" | sed '$d')
if [ "$HTTP_CODE" = "201" ]; then
ISSUE_NUM=$(echo "$BODY_RESP" | jq -r .number)
echo "Issue #$ISSUE_NUM créée."
echo "$BODY_RESP" | jq .
else
echo "Erreur HTTP $HTTP_CODE: $BODY_RESP"
exit 1
fi
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Poste un commentaire sur une issue Gitea puis la ferme.
# Usage: GITEA_TOKEN=xxx ./scripts/gitea-close-issue-with-comment.sh <numéro> "Commentaire"
# Ou: mettre le token dans .gitea-token à la racine du projet.
# Exemple: ./scripts/gitea-close-issue-with-comment.sh 15 "Livré : panneau Paramètres opérationnel."
set -e
ISSUE="${1:?Usage: $0 <numéro_issue> \"Commentaire\"}"
COMMENT="${2:?Usage: $0 <numéro_issue> \"Commentaire\"}"
BASE_URL="${GITEA_URL:-https://git.ptits-pas.fr/api/v1}"
REPO="jmartin/petitspas"
if [ -z "$GITEA_TOKEN" ]; then
if [ -f .gitea-token ]; then
GITEA_TOKEN=$(cat .gitea-token)
fi
fi
if [ -z "$GITEA_TOKEN" ]; then
echo "Définir GITEA_TOKEN ou créer .gitea-token avec votre token Gitea."
exit 1
fi
# 1) Poster le commentaire
echo "Ajout du commentaire sur l'issue #$ISSUE..."
# Échapper pour JSON (guillemets et backslash)
COMMENT_ESC=$(printf '%s' "$COMMENT" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\r//g')
PAYLOAD="{\"body\":\"$COMMENT_ESC\"}"
RESP=$(curl -s -w "\n%{http_code}" -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "$PAYLOAD" \
"$BASE_URL/repos/$REPO/issues/$ISSUE/comments")
HTTP_CODE=$(echo "$RESP" | tail -1)
BODY=$(echo "$RESP" | sed '$d')
if [ "$HTTP_CODE" != "201" ]; then
echo "Erreur HTTP $HTTP_CODE lors du commentaire: $BODY"
exit 1
fi
echo "Commentaire ajouté."
# 2) Fermer l'issue
echo "Fermeture de l'issue #$ISSUE..."
RESP2=$(curl -s -w "\n%{http_code}" -X PATCH \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"state":"closed"}' \
"$BASE_URL/repos/$REPO/issues/$ISSUE")
HTTP_CODE2=$(echo "$RESP2" | tail -1)
BODY2=$(echo "$RESP2" | sed '$d')
if [ "$HTTP_CODE2" = "200" ] || [ "$HTTP_CODE2" = "201" ]; then
echo "Issue #$ISSUE fermée."
else
echo "Erreur HTTP $HTTP_CODE2: $BODY2"
exit 1
fi
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Ferme une issue Gitea via l'API.
# Usage: GITEA_TOKEN=votre_token ./scripts/gitea-close-issue.sh [numéro]
# Exemple: GITEA_TOKEN=xxx ./scripts/gitea-close-issue.sh 83
set -e
ISSUE="${1:-83}"
BASE_URL="${GITEA_URL:-https://git.ptits-pas.fr/api/v1}"
REPO="jmartin/petitspas"
if [ -z "$GITEA_TOKEN" ]; then
if [ -f .gitea-token ]; then
GITEA_TOKEN=$(cat .gitea-token)
fi
fi
if [ -z "$GITEA_TOKEN" ]; then
echo "Définir GITEA_TOKEN ou créer .gitea-token avec votre token Gitea."
exit 1
fi
echo "Fermeture de l'issue #$ISSUE..."
RESP=$(curl -s -w "\n%{http_code}" -X PATCH \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"state":"closed"}' \
"$BASE_URL/repos/$REPO/issues/$ISSUE")
HTTP_CODE=$(echo "$RESP" | tail -1)
BODY=$(echo "$RESP" | sed '$d')
if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "201" ]; then
echo "Issue #$ISSUE fermée."
else
echo "Erreur HTTP $HTTP_CODE: $BODY"
exit 1
fi
+14
View File
@@ -0,0 +1,14 @@
Correctifs et améliorations de la modale de changement de mot de passe obligatoire affichée à la première connexion admin.
**Périmètre :**
- Ajustements visuels / UX de la modale (ChangePasswordDialog)
- Cohérence charte graphique, espacements, lisibilité
- Comportement (validation, messages d'erreur, fermeture)
- Lien de test en debug sur l'écran login (« Test modale MDP ») pour faciliter les réglages
**Tâches :**
- [ ] Revoir le design de la modale (relief, bordures, couleurs)
- [ ] Vérifier les champs (MDP actuel, nouveau, confirmation) et validations
- [ ] Ajuster les textes et messages d'erreur
- [ ] Tester sur mobile et desktop
- [ ] Retirer ou conditionner le lien « Test modale MDP » en production si besoin
+1
View File
@@ -0,0 +1 @@
{"title": "[Frontend] Bug – Correctifs modale Changement MDP (première connexion admin)", "body": "Correctifs et améliorations de la modale de changement de mot de passe obligatoire affichée à la première connexion admin.\n\n**Périmètre :**\n- Ajustements visuels / UX de la modale (ChangePasswordDialog)\n- Cohérence charte graphique, espacements, lisibilité\n- Comportement (validation, messages d'erreur, fermeture)\n- Lien de test en debug sur l'écran login (« Test modale MDP ») pour faciliter les réglages\n\n**Tâches :**\n- [ ] Revoir le design de la modale (relief, bordures, couleurs)\n- [ ] Vérifier les champs (MDP actuel, nouveau, confirmation) et validations\n- [ ] Ajuster les textes et messages d'erreur\n- [ ] Tester sur mobile et desktop\n- [ ] Retirer ou conditionner le lien « Test modale MDP » en production si besoin\n"}
+61
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"