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
20 changed files with 76 additions and 492 deletions
-2
View File
@@ -16,7 +16,6 @@ import { AllExceptionsFilter } from './common/filters/all_exceptions.filters';
import { EnfantsModule } from './routes/enfants/enfants.module';
import { AppConfigModule } from './modules/config/config.module';
import { DocumentsLegauxModule } from './modules/documents-legaux';
import { RelaisModule } from './routes/relais/relais.module';
@Module({
imports: [
@@ -54,7 +53,6 @@ import { RelaisModule } from './routes/relais/relais.module';
AuthModule,
AppConfigModule,
DocumentsLegauxModule,
RelaisModule,
],
controllers: [AppController],
providers: [
-15
View File
@@ -1,15 +0,0 @@
import { DataSource } from 'typeorm';
import { config } from 'dotenv';
config();
export default new DataSource({
type: 'postgres',
host: process.env.DATABASE_HOST,
port: parseInt(process.env.DATABASE_PORT || '5432', 10),
username: process.env.DATABASE_USERNAME,
password: process.env.DATABASE_PASSWORD,
database: process.env.DATABASE_NAME,
entities: ['src/**/*.entity.ts'],
migrations: ['src/migrations/*.ts'],
});
-35
View File
@@ -1,35 +0,0 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, OneToMany } from 'typeorm';
import { Users } from './users.entity';
@Entity('relais', { schema: 'public' })
export class Relais {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'nom' })
nom: string;
@Column({ name: 'adresse' })
adresse: string;
@Column({ type: 'jsonb', name: 'horaires_ouverture', nullable: true })
horaires_ouverture?: any;
@Column({ name: 'ligne_fixe', nullable: true })
ligne_fixe?: string;
@Column({ default: true, name: 'actif' })
actif: boolean;
@Column({ type: 'text', name: 'notes', nullable: true })
notes?: string;
@CreateDateColumn({ name: 'cree_le', type: 'timestamptz' })
cree_le: Date;
@UpdateDateColumn({ name: 'modifie_le', type: 'timestamptz' })
modifie_le: Date;
@OneToMany(() => Users, user => user.relais)
gestionnaires: Users[];
}
+1 -9
View File
@@ -1,12 +1,11 @@
import {
Entity, PrimaryGeneratedColumn, Column,
CreateDateColumn, UpdateDateColumn,
OneToOne, OneToMany, ManyToOne, JoinColumn
OneToOne, OneToMany
} from 'typeorm';
import { AssistanteMaternelle } from './assistantes_maternelles.entity';
import { Parents } from './parents.entity';
import { Message } from './messages.entity';
import { Relais } from './relais.entity';
// Enums alignés avec la BDD PostgreSQL
export enum RoleType {
@@ -148,11 +147,4 @@ export class Users {
@OneToMany(() => Parents, parent => parent.co_parent)
co_parent_in?: Parents[];
@Column({ nullable: true, name: 'relais_id' })
relaisId?: string;
@ManyToOne(() => Relais, relais => relais.gestionnaires, { nullable: true })
@JoinColumn({ name: 'relais_id' })
relais?: Relais;
}
-10
View File
@@ -1,10 +0,0 @@
import { Module } from '@nestjs/common';
import { MailService } from './mail.service';
import { AppConfigModule } from '../config/config.module';
@Module({
imports: [AppConfigModule],
providers: [MailService],
exports: [MailService],
})
export class MailModule {}
-100
View File
@@ -1,100 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { AppConfigService } from '../config/config.service';
@Injectable()
export class MailService {
private readonly logger = new Logger(MailService.name);
constructor(private readonly configService: AppConfigService) {}
/**
* Envoi d'un email générique
* @param to Destinataire
* @param subject Sujet
* @param html Contenu HTML
* @param text Contenu texte (optionnel)
*/
async sendEmail(to: string, subject: string, html: string, text?: string): Promise<void> {
try {
// Récupération de la configuration SMTP
const smtpHost = this.configService.get<string>('smtp_host');
const smtpPort = this.configService.get<number>('smtp_port');
const smtpSecure = this.configService.get<boolean>('smtp_secure');
const smtpAuthRequired = this.configService.get<boolean>('smtp_auth_required');
const smtpUser = this.configService.get<string>('smtp_user');
const smtpPassword = this.configService.get<string>('smtp_password');
const emailFromName = this.configService.get<string>('email_from_name');
const emailFromAddress = this.configService.get<string>('email_from_address');
// Import dynamique de nodemailer
const nodemailer = await import('nodemailer');
// Configuration du transporteur
const transportConfig: any = {
host: smtpHost,
port: smtpPort,
secure: smtpSecure,
};
if (smtpAuthRequired && smtpUser && smtpPassword) {
transportConfig.auth = {
user: smtpUser,
pass: smtpPassword,
};
}
const transporter = nodemailer.createTransport(transportConfig);
// Envoi de l'email
await transporter.sendMail({
from: `"${emailFromName}" <${emailFromAddress}>`,
to,
subject,
text: text || html.replace(/<[^>]*>?/gm, ''), // Fallback texte simple
html,
});
this.logger.log(`📧 Email envoyé à ${to} : ${subject}`);
} catch (error) {
this.logger.error(`❌ Erreur lors de l'envoi de l'email à ${to}`, error);
throw error;
}
}
/**
* Envoi de l'email de bienvenue pour un gestionnaire
* @param to Email du gestionnaire
* @param prenom Prénom
* @param nom Nom
* @param token Token de création de mot de passe (si applicable) ou mot de passe temporaire (si applicable)
* @note Pour l'instant, on suppose que le gestionnaire doit définir son mot de passe via "Mot de passe oublié" ou un lien d'activation
* Mais le ticket #17 parle de "Flag changement_mdp_obligatoire = TRUE", ce qui implique qu'on lui donne un mot de passe temporaire ou qu'on lui envoie un lien.
* Le ticket #24 parle de "API Création mot de passe" via token.
* Pour le ticket #17, on crée le gestionnaire avec un mot de passe (hashé).
* Si on suit le ticket #35 (Frontend), on saisit un mot de passe.
* Donc on envoie juste un email de confirmation de création de compte.
*/
async sendGestionnaireWelcomeEmail(to: string, prenom: string, nom: string): Promise<void> {
const appName = this.configService.get<string>('app_name', 'P\'titsPas');
const appUrl = this.configService.get<string>('app_url', 'https://app.ptits-pas.fr');
const subject = `Bienvenue sur ${appName}`;
const html = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #4CAF50;">Bienvenue ${prenom} ${nom} !</h2>
<p>Votre compte gestionnaire sur <strong>${appName}</strong> a été créé avec succès.</p>
<p>Vous pouvez dès à présent vous connecter avec l'adresse email <strong>${to}</strong> et le mot de passe qui vous a été communiqué.</p>
<p>Lors de votre première connexion, il vous sera demandé de modifier votre mot de passe pour des raisons de sécurité.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${appUrl}" style="background-color: #4CAF50; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">Accéder à l'application</a>
</div>
<hr style="border: 1px solid #eee; margin: 20px 0;">
<p style="color: #666; font-size: 12px;">
Cet email a été envoyé automatiquement. Merci de ne pas y répondre.
</p>
</div>
`;
await this.sendEmail(to, subject, html);
}
}
@@ -1,34 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsBoolean, IsNotEmpty, IsOptional, IsString, IsObject } from 'class-validator';
export class CreateRelaisDto {
@ApiProperty({ example: 'Relais Petite Enfance Centre' })
@IsString()
@IsNotEmpty()
nom: string;
@ApiProperty({ example: '12 rue de la Mairie, 75000 Paris' })
@IsString()
@IsNotEmpty()
adresse: string;
@ApiProperty({ example: { lundi: '09:00-17:00' }, required: false })
@IsOptional()
@IsObject()
horaires_ouverture?: any;
@ApiProperty({ example: '0123456789', required: false })
@IsOptional()
@IsString()
ligne_fixe?: string;
@ApiProperty({ default: true, required: false })
@IsOptional()
@IsBoolean()
actif?: boolean;
@ApiProperty({ example: 'Notes internes...', required: false })
@IsOptional()
@IsString()
notes?: string;
}
@@ -1,4 +0,0 @@
import { PartialType } from '@nestjs/swagger';
import { CreateRelaisDto } from './create-relais.dto';
export class UpdateRelaisDto extends PartialType(CreateRelaisDto) {}
@@ -1,57 +0,0 @@
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards } from '@nestjs/common';
import { RelaisService } from './relais.service';
import { CreateRelaisDto } from './dto/create-relais.dto';
import { UpdateRelaisDto } from './dto/update-relais.dto';
import { ApiBearerAuth, ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { AuthGuard } from 'src/common/guards/auth.guard';
import { RolesGuard } from 'src/common/guards/roles.guard';
import { Roles } from 'src/common/decorators/roles.decorator';
import { RoleType } from 'src/entities/users.entity';
@ApiTags('Relais')
@ApiBearerAuth('access-token')
@UseGuards(AuthGuard, RolesGuard)
@Controller('relais')
export class RelaisController {
constructor(private readonly relaisService: RelaisService) {}
@Post()
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
@ApiOperation({ summary: 'Créer un relais' })
@ApiResponse({ status: 201, description: 'Le relais a été créé.' })
create(@Body() createRelaisDto: CreateRelaisDto) {
return this.relaisService.create(createRelaisDto);
}
@Get()
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
@ApiOperation({ summary: 'Lister tous les relais' })
@ApiResponse({ status: 200, description: 'Liste des relais.' })
findAll() {
return this.relaisService.findAll();
}
@Get(':id')
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
@ApiOperation({ summary: 'Récupérer un relais par ID' })
@ApiResponse({ status: 200, description: 'Le relais trouvé.' })
findOne(@Param('id') id: string) {
return this.relaisService.findOne(id);
}
@Patch(':id')
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
@ApiOperation({ summary: 'Mettre à jour un relais' })
@ApiResponse({ status: 200, description: 'Le relais a été mis à jour.' })
update(@Param('id') id: string, @Body() updateRelaisDto: UpdateRelaisDto) {
return this.relaisService.update(id, updateRelaisDto);
}
@Delete(':id')
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
@ApiOperation({ summary: 'Supprimer un relais' })
@ApiResponse({ status: 200, description: 'Le relais a été supprimé.' })
remove(@Param('id') id: string) {
return this.relaisService.remove(id);
}
}
@@ -1,17 +0,0 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { RelaisService } from './relais.service';
import { RelaisController } from './relais.controller';
import { Relais } from 'src/entities/relais.entity';
import { AuthModule } from 'src/routes/auth/auth.module';
@Module({
imports: [
TypeOrmModule.forFeature([Relais]),
AuthModule,
],
controllers: [RelaisController],
providers: [RelaisService],
exports: [RelaisService],
})
export class RelaisModule {}
@@ -1,42 +0,0 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Relais } from 'src/entities/relais.entity';
import { CreateRelaisDto } from './dto/create-relais.dto';
import { UpdateRelaisDto } from './dto/update-relais.dto';
@Injectable()
export class RelaisService {
constructor(
@InjectRepository(Relais)
private readonly relaisRepository: Repository<Relais>,
) {}
create(createRelaisDto: CreateRelaisDto) {
const relais = this.relaisRepository.create(createRelaisDto);
return this.relaisRepository.save(relais);
}
findAll() {
return this.relaisRepository.find({ order: { nom: 'ASC' } });
}
async findOne(id: string) {
const relais = await this.relaisRepository.findOne({ where: { id } });
if (!relais) {
throw new NotFoundException(`Relais #${id} not found`);
}
return relais;
}
async update(id: string, updateRelaisDto: UpdateRelaisDto) {
const relais = await this.findOne(id);
Object.assign(relais, updateRelaisDto);
return this.relaisRepository.save(relais);
}
async remove(id: string) {
const relais = await this.findOne(id);
return this.relaisRepository.remove(relais);
}
}
@@ -1,10 +1,4 @@
import { ApiProperty, OmitType } from "@nestjs/swagger";
import { OmitType } from "@nestjs/swagger";
import { CreateUserDto } from "./create_user.dto";
import { IsOptional, IsUUID } from "class-validator";
export class CreateGestionnaireDto extends OmitType(CreateUserDto, ['role', 'adresse'] as const) {
@ApiProperty({ required: false, description: 'ID du relais de rattachement' })
@IsOptional()
@IsUUID()
relaisId?: string;
}
export class CreateGestionnaireDto extends OmitType(CreateUserDto, ['role'] as const) {}
@@ -1,4 +1,4 @@
import { PartialType } from "@nestjs/swagger";
import { CreateUserDto } from "./create_user.dto";
import { CreateGestionnaireDto } from "./create_gestionnaire.dto";
export class UpdateGestionnaireDto extends PartialType(CreateUserDto) {}
export class UpdateGestionnaireDto extends PartialType(CreateGestionnaireDto) {}
@@ -4,13 +4,11 @@ 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';
import { MailModule } from 'src/modules/mail/mail.module';
@Module({
imports: [
TypeOrmModule.forFeature([Users]),
AuthModule,
MailModule,
],
controllers: [GestionnairesController],
providers: [GestionnairesService],
@@ -9,14 +9,12 @@ import { RoleType, Users } from 'src/entities/users.entity';
import { CreateGestionnaireDto } from '../dto/create_gestionnaire.dto';
import { UpdateGestionnaireDto } from '../dto/update_gestionnaire.dto';
import * as bcrypt from 'bcrypt';
import { MailService } from 'src/modules/mail/mail.service';
@Injectable()
export class GestionnairesService {
constructor(
@InjectRepository(Users)
private readonly gestionnaireRepository: Repository<Users>,
private readonly mailService: MailService,
) { }
// Création d’un gestionnaire
@@ -35,47 +33,27 @@ export class GestionnairesService {
genre: dto.genre,
statut: dto.statut,
telephone: dto.telephone,
// adresse: dto.adresse, // Adresse retirée du formulaire de création
adresse: dto.adresse,
photo_url: dto.photo_url,
consentement_photo: dto.consentement_photo ?? false,
date_consentement_photo: dto.date_consentement_photo
? new Date(dto.date_consentement_photo)
: undefined,
changement_mdp_obligatoire: true, // Forcé à true pour les nouveaux gestionnaires
changement_mdp_obligatoire: dto.changement_mdp_obligatoire ?? false,
role: RoleType.GESTIONNAIRE,
relaisId: dto.relaisId,
});
const savedUser = await this.gestionnaireRepository.save(entity);
// Envoi de l'email de bienvenue
try {
await this.mailService.sendGestionnaireWelcomeEmail(
savedUser.email,
savedUser.prenom || '',
savedUser.nom || '',
);
} catch (error) {
// On ne bloque pas la création si l'envoi d'email échoue, mais on log l'erreur
console.error('Erreur lors de l\'envoi de l\'email de bienvenue au gestionnaire', error);
}
return savedUser;
return this.gestionnaireRepository.save(entity);
}
// Liste des gestionnaires
async findAll(): Promise<Users[]> {
return this.gestionnaireRepository.find({
where: { role: RoleType.GESTIONNAIRE },
relations: ['relais'],
});
return this.gestionnaireRepository.find({ where: { role: RoleType.GESTIONNAIRE } });
}
// Récupérer un gestionnaire par ID
async findOne(id: string): Promise<Users> {
const gestionnaire = await this.gestionnaireRepository.findOne({
where: { id, role: RoleType.GESTIONNAIRE },
relations: ['relais'],
});
if (!gestionnaire) throw new NotFoundException('Gestionnaire introuvable');
return gestionnaire;
+2 -18
View File
@@ -331,29 +331,13 @@ CREATE INDEX idx_acceptations_utilisateur ON acceptations_documents(id_utilisate
CREATE INDEX idx_acceptations_document ON acceptations_documents(id_document);
-- ==========================================================
-- Table : relais
-- ==========================================================
CREATE TABLE relais (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
nom VARCHAR(255) NOT NULL,
adresse TEXT NOT NULL,
horaires_ouverture JSONB,
ligne_fixe VARCHAR(20),
actif BOOLEAN DEFAULT true,
notes TEXT,
cree_le TIMESTAMPTZ DEFAULT now(),
modifie_le TIMESTAMPTZ DEFAULT now()
);
-- ==========================================================
-- Modification Table : utilisateurs (ajout colonnes documents et relais)
-- Modification Table : utilisateurs (ajout colonnes documents)
-- ==========================================================
ALTER TABLE utilisateurs
ADD COLUMN IF NOT EXISTS cgu_version_acceptee INTEGER,
ADD COLUMN IF NOT EXISTS cgu_acceptee_le TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS privacy_version_acceptee INTEGER,
ADD COLUMN IF NOT EXISTS privacy_acceptee_le TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS relais_id UUID REFERENCES relais(id) ON DELETE SET NULL;
ADD COLUMN IF NOT EXISTS privacy_acceptee_le TIMESTAMPTZ;
-- ==========================================================
-- Seed : Documents légaux génériques v1
+19 -87
View File
@@ -1,7 +1,7 @@
# 🎫 Liste Complète des Tickets - Projet P'titsPas
**Version** : 1.5
**Date** : 17 Février 2026
**Version** : 1.4
**Date** : 9 Février 2026
**Auteur** : Équipe PtitsPas
**Estimation totale** : ~184h
@@ -27,12 +27,8 @@
| 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 | [Backend] API Création gestionnaire | ✅ Terminé |
| 91 | [Frontend] Inscription AM – Branchement soumission formulaire à l'API | Ouvert |
| 92 | [Frontend] Dashboard Admin - Données réelles et branchement API | ✅ Terminé |
| 93 | [Frontend] Panneau Admin - Homogeneiser la presentation des onglets | Ouvert |
| 94 | [Backend] Relais - modele, API CRUD et liaison gestionnaire | ✅ Terminé |
| 95 | [Frontend] Admin - gestion des relais et rattachement gestionnaire | 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*
@@ -320,22 +316,21 @@ Rédiger la documentation pour aider les collectivités à configurer l'applicat
## 🟢 PRIORITÉ 2 : Backend - Authentification & Gestion Comptes
### Ticket #17 : [Backend] API Création gestionnaire ✅
### Ticket #17 : [Backend] API Création gestionnaire
**Estimation** : 3h
**Labels** : `backend`, `p2`, `auth`
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-23)
**Description** :
Créer l'endpoint pour permettre au super admin de créer des gestionnaires.
**Tâches** :
- [x] Endpoint `POST /api/v1/gestionnaires`
- [x] Validation DTO
- [x] Hash bcrypt
- [x] Flag `changement_mdp_obligatoire = TRUE`
- [x] Guards (super_admin only)
- [x] Email de notification (utiliser MailService avec config dynamique)
- [x] Tests unitaires
- [ ] Endpoint `POST /api/v1/gestionnaires`
- [ ] Validation DTO
- [ ] Hash bcrypt
- [ ] Flag `changement_mdp_obligatoire = TRUE`
- [ ] Guards (super_admin only)
- [ ] Email de notification (utiliser MailService avec config dynamique)
- [ ] Tests unitaires
**Référence** : [20_WORKFLOW-CREATION-COMPTE.md](./20_WORKFLOW-CREATION-COMPTE.md#étape-2--création-dun-gestionnaire)
@@ -646,22 +641,6 @@ Enregistrer les acceptations de documents légaux lors de l'inscription (traçab
---
### Ticket #94 : [Backend] Relais - Modèle, API CRUD et liaison gestionnaire ✅
**Estimation** : 4h
**Labels** : `backend`, `p2`, `admin`
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-21)
**Description** :
Le back-office admin doit gérer des Relais avec des données réelles en base, et permettre une liaison simple avec les gestionnaires.
**Tâches** :
- [x] Créer le modèle `Relais` (nom, adresse, horaires, téléphone, actif, notes)
- [x] Exposer les endpoints admin CRUD pour les relais (`GET`, `POST`, `PATCH`, `DELETE`)
- [x] Ajouter la liaison : un gestionnaire peut être rattaché à un relais principal (`relais_id` dans `users` ?)
- [x] Validations (champs requis, format horaires)
---
## 🟢 PRIORITÉ 3 : Frontend - Interfaces
### Ticket #35 : [Frontend] Écran Création Gestionnaire
@@ -915,10 +894,9 @@ 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 ✅
### Ticket #92 : [Frontend] Dashboard Admin - Données réelles et branchement API
**Estimation** : 8h
**Labels** : `frontend`, `p3`, `admin`
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-17)
**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).
@@ -1040,51 +1018,6 @@ Adapter l'écran de choix Parent/AM pour une meilleure expérience mobile et coh
---
### Ticket #91 : [Frontend] Inscription AM – Branchement soumission formulaire à l'API
**Estimation** : 3h
**Labels** : `frontend`, `p3`, `auth`, `cdc`
**Description** :
Branchement du formulaire d'inscription AM (étape 4) à l'endpoint d'inscription.
**Tâches** :
- [ ] Construire le body (DTO) à partir de `AmRegistrationData`
- [ ] Appel HTTP `POST /api/v1/auth/register/am`
- [ ] Gestion réponse (201 : succès + redirection ; 4xx : erreur)
- [ ] Conversion photo en base64 si nécessaire
---
### Ticket #93 : [Frontend] Panneau Admin - Homogénéisation des onglets
**Estimation** : 4h
**Labels** : `frontend`, `p3`, `admin`, `ux`
**Description** :
Uniformiser l'UI/UX des 4 onglets du dashboard admin (Gestionnaires, Parents, AM, Admins).
**Tâches** :
- [ ] Standardiser le header de liste (Recherche, Filtres, Bouton Action)
- [ ] Standardiser les cartes utilisateurs (`ListTile` uniforme)
- [ ] Standardiser les états (Loading, Erreur, Vide)
- [ ] Factoriser les composants partagés
---
### Ticket #95 : [Frontend] Admin - Gestion des Relais et rattachement gestionnaire
**Estimation** : 5h
**Labels** : `frontend`, `p3`, `admin`
**Description** :
Interface de gestion des Relais dans le dashboard admin et rattachement des gestionnaires.
**Tâches** :
- [ ] Section Relais avec 2 sous-onglets : Paramètres techniques / Paramètres territoriaux
- [ ] Liste, Création, Édition, Activation/Désactivation des relais
- [ ] Champs UI : nom, adresse, horaires, téléphone, statut, notes
- [ ] Onglet Gestionnaires : Ajout contrôle de rattachement au relais principal
---
## 🔵 PRIORITÉ 4 : Tests & Documentation
### Ticket #52 : [Tests] Tests unitaires Backend
@@ -1302,29 +1235,28 @@ Rédiger les documents légaux génériques (CGU et Politique de confidentialit
## 📊 Résumé final
**Total** : 69 tickets
**Estimation** : ~200h 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)** : 19 tickets (~54h)
- **P3 (Frontend)** : 25 tickets (~83h)
- **P2 (Backend)** : 18 tickets (~50h)
- **P3 (Frontend)** : 22 tickets (~71h) ← +1 mobile RegisterChoice
- **P4 (Tests/Doc)** : 4 tickets (~24h)
- **Critiques** : 6 tickets (~13h)
- **Juridique** : 1 ticket (~8h)
### Par domaine
- **BDD** : 7 tickets
- **Backend** : 24 tickets
- **Frontend** : 25 tickets
- **Backend** : 23 tickets
- **Frontend** : 22 tickets ← +1 mobile RegisterChoice
- **Tests** : 3 tickets
- **Documentation** : 5 tickets
- **Infra** : 2 tickets
- **Juridique** : 1 ticket
### Modifications par rapport à la version initiale
- ✅ **v1.5** : Ajout tickets #91, #93, #94, #95. Ticket #92 terminé.
- ✅ **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
+28 -16
View File
@@ -32,30 +32,42 @@ class AppUser {
});
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,
id: id,
email: email,
role: role,
createdAt: json['cree_le'] != null
? DateTime.parse(json['cree_le'] as String)
? DateTime.tryParse(json['cree_le'].toString()) ?? DateTime.now()
: (json['createdAt'] != null
? DateTime.parse(json['createdAt'] as String)
? DateTime.tryParse(json['createdAt'].toString()) ?? DateTime.now()
: DateTime.now()),
updatedAt: json['modifie_le'] != null
? DateTime.parse(json['modifie_le'] as String)
? DateTime.tryParse(json['modifie_le'].toString()) ?? DateTime.now()
: (json['updatedAt'] != null
? DateTime.parse(json['updatedAt'] as String)
? DateTime.tryParse(json['updatedAt'].toString()) ?? DateTime.now()
: DateTime.now()),
changementMdpObligatoire:
json['changement_mdp_obligatoire'] as bool? ?? false,
nom: json['nom'] as String?,
prenom: json['prenom'] as String?,
statut: json['statut'] as String?,
telephone: json['telephone'] as String?,
photoUrl: json['photo_url'] as String?,
adresse: json['adresse'] as String?,
ville: json['ville'] as String?,
codePostal: json['code_postal'] as String?,
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(),
);
}
+4 -4
View File
@@ -236,20 +236,20 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(bottom: 15),
decoration: BoxDecoration(
color: Colors.red[50],
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.red[300]!),
border: Border.all(color: Colors.red.shade300),
),
child: Row(
children: [
Icon(Icons.error_outline, color: Colors.red[700], size: 20),
Icon(Icons.error_outline, color: Colors.red.shade700, size: 20),
const SizedBox(width: 10),
Expanded(
child: Text(
_errorMessage!,
style: GoogleFonts.merienda(
fontSize: 12,
color: Colors.red[700],
color: Colors.red.shade700,
),
),
),
+14 -4
View File
@@ -43,7 +43,8 @@ class AuthService {
}
} catch (e) {
if (e is Exception) rethrow;
throw Exception('Erreur réseau: impossible de se connecter au serveur');
if (e is Error) throw Exception('Erreur interne: ${e.toString()}');
throw Exception('Erreur réseau: impossible de se connecter au serveur ($e)');
}
}
@@ -56,14 +57,22 @@ class AuthService {
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
final raw = jsonDecode(response.body);
if (raw is! Map<String, dynamic>) {
throw Exception('Profil invalide: réponse serveur inattendue');
}
// Accepter réponse directe ou wrapper { data: {...} }
final data = raw.containsKey('data') && raw['data'] is Map<String, dynamic>
? raw['data'] as Map<String, dynamic>
: raw;
return AppUser.fromJson(data);
} else {
throw Exception('Erreur lors de la récupération du profil');
}
} catch (e) {
if (e is Exception) rethrow;
throw Exception('Erreur réseau: impossible de récupérer le profil');
if (e is Error) throw Exception('Erreur interne: ${e.toString()}');
throw Exception('Erreur réseau: impossible de récupérer le profil ($e)');
}
}
@@ -98,7 +107,8 @@ class AuthService {
await _saveCurrentUser(user);
} catch (e) {
if (e is Exception) rethrow;
throw Exception('Erreur réseau: impossible de changer le mot de passe');
if (e is Error) throw Exception('Erreur interne: ${e.toString()}');
throw Exception('Erreur réseau: impossible de changer le mot de passe ($e)');
}
}