Compare commits

...
Author SHA1 Message Date
jmartinandCursor 51d279e341 docs: fermeture ticket #44 (Dashboard Gestionnaire - Structure)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 16:37:54 +01:00
jmartinandCursor fffe8cd202 merge: squash develop into master (#44 Dashboard Gestionnaire - Structure)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 16:37:15 +01:00
jmartinandCursor 619e39219f merge: squash develop into master (login autofill + clavier #98)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 12:00:51 +01:00
jmartinandCursor 6749f2025a fix(backend): remove date_consentement_photo from gestionnaire update
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 23:15:29 +01:00
jmartinandCursor 119edbcfb4 merge: squash develop into master
Intègre en un seul commit les évolutions récentes de develop vers master, incluant la modale admin/gestionnaire, les protections super admin, les ajustements API associés et la mise à jour documentaire des tickets/spec.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 22:58:40 +01:00
jmartinandCursor 33cc7a9191 feat(backend): add create admin API and update docs
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 11:07:14 +01:00
jmartinandCursor 10ebc77ba1 feat(backend): update gestionnaire creation logic and clean up DTOs
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 10:26:24 +01:00
jmartinandCursor f9477d3fbe feat: livrer ticket #35 et synchroniser les évolutions admin
Intègre en un seul commit les évolutions de develop, avec la création/édition/suppression de gestionnaires via modale unifiée (#35) et les correctifs associés sur la gestion admin.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 00:20:33 +01:00
jmartin 4d37131301 Merge branch 'master' of https://git.ptits-pas.fr/jmartin/petitspas 2026-02-24 00:08:30 +01:00
jmartinandCursor 04b910295c fix(backend): fix UpdateGestionnaireDto compilation error (#35)
- Changed UpdateGestionnaireDto to inherit from PartialType(CreateUserDto) instead of CreateGestionnaireDto
- Ensures all fields (like date_consentement_photo) are available for update logic

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-23 23:53:21 +01:00
jmartinandCursor c136f28f12 fix(backend): remove address from gestionnaire creation (#35)
- Updated CreateGestionnaireDto to omit address field
- Updated GestionnairesService to not map address on creation

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-23 23:21:26 +01:00
jmartinandCursor 4b176b7083 feat: livrer ticket #93 et finaliser #17 avec gestion des Relais (#95)
Homogénéise le dashboard admin (onglets/listes/cartes/états) via composants réutilisables, finalise la création gestionnaire côté backend, et intègre la gestion des Relais avec rattachement gestionnaire.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-23 23:07:04 +01:00
jmartinandCursor 00c42c7bee feat(release): Backend Gestionnaire Creation (#17)
- Implemented MailModule and MailService
- Updated GestionnairesService to send welcome email
- Forced password change on first login for new gestionnaires

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-23 22:50:13 +01:00
jmartinandCursor 42c569e491 feat(release): Backend Relais Module (#94)
- Implemented Relais entity and CRUD API
- Added relation between Users (Gestionnaires) and Relais
- Updated database initialization script
- Documentation updates

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-21 14:40:32 +01:00
jmartinandCursor d32d956b0e feat(dashboard-admin): connect admin dashboard to real API data (Ticket #92)
- Frontend:
  - Create UserService to handle user-related API calls (gestionnaires, parents, AMs, admins)
  - Update AdminDashboardScreen to use dynamic widgets
  - Implement dynamic management widgets:
    - GestionnaireManagementWidget
    - ParentManagementWidget
    - AssistanteMaternelleManagementWidget
    - AdminManagementWidget
  - Add data models: ParentModel, AssistanteMaternelleModel
  - Update AppUser model
  - Update ApiConfig

- Backend:
  - Update controllers (Parents, AMs, Gestionnaires, Users) to allow ADMINISTRATEUR role to list users
  - Fix: Activate endpoint GET /gestionnaires (import GestionnairesModule in UserModule)

- Docs:
  - Add note about backend fix for Gestionnaires module
  - Update .cursorrules to forbid worktrees

- Seed:
  - Add test data seed script (reset-and-seed-db.sh)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-17 22:17:51 +01:00
64 changed files with 5785 additions and 723 deletions
+2
View File
@@ -16,6 +16,7 @@ import { AllExceptionsFilter } from './common/filters/all_exceptions.filters';
import { EnfantsModule } from './routes/enfants/enfants.module'; import { EnfantsModule } from './routes/enfants/enfants.module';
import { AppConfigModule } from './modules/config/config.module'; import { AppConfigModule } from './modules/config/config.module';
import { DocumentsLegauxModule } from './modules/documents-legaux'; import { DocumentsLegauxModule } from './modules/documents-legaux';
import { RelaisModule } from './routes/relais/relais.module';
@Module({ @Module({
imports: [ imports: [
@@ -53,6 +54,7 @@ import { DocumentsLegauxModule } from './modules/documents-legaux';
AuthModule, AuthModule,
AppConfigModule, AppConfigModule,
DocumentsLegauxModule, DocumentsLegauxModule,
RelaisModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [ providers: [
+15
View File
@@ -0,0 +1,15 @@
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
@@ -0,0 +1,35 @@
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[];
}
+10 -2
View File
@@ -1,11 +1,12 @@
import { import {
Entity, PrimaryGeneratedColumn, Column, Entity, PrimaryGeneratedColumn, Column,
CreateDateColumn, UpdateDateColumn, CreateDateColumn, UpdateDateColumn,
OneToOne, OneToMany OneToOne, OneToMany, ManyToOne, JoinColumn
} from 'typeorm'; } from 'typeorm';
import { AssistanteMaternelle } from './assistantes_maternelles.entity'; import { AssistanteMaternelle } from './assistantes_maternelles.entity';
import { Parents } from './parents.entity'; import { Parents } from './parents.entity';
import { Message } from './messages.entity'; import { Message } from './messages.entity';
import { Relais } from './relais.entity';
// Enums alignés avec la BDD PostgreSQL // Enums alignés avec la BDD PostgreSQL
export enum RoleType { export enum RoleType {
@@ -80,7 +81,7 @@ export class Users {
type: 'enum', type: 'enum',
enum: StatutUtilisateurType, enum: StatutUtilisateurType,
enumName: 'statut_utilisateur_type', // correspond à l'enum de la db psql enumName: 'statut_utilisateur_type', // correspond à l'enum de la db psql
default: StatutUtilisateurType.EN_ATTENTE, default: StatutUtilisateurType.ACTIF,
name: 'statut' name: 'statut'
}) })
statut: StatutUtilisateurType; statut: StatutUtilisateurType;
@@ -147,4 +148,11 @@ export class Users {
@OneToMany(() => Parents, parent => parent.co_parent) @OneToMany(() => Parents, parent => parent.co_parent)
co_parent_in?: Parents[]; 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
@@ -0,0 +1,10 @@
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
@@ -0,0 +1,100 @@
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);
}
}
@@ -35,7 +35,7 @@ export class AssistantesMaternellesController {
return this.assistantesMaternellesService.create(dto); return this.assistantesMaternellesService.create(dto);
} }
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE) @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@Get() @Get()
@ApiOperation({ summary: 'Récupérer la liste des nounous' }) @ApiOperation({ summary: 'Récupérer la liste des nounous' })
@ApiResponse({ status: 200, description: 'Liste des nounous' }) @ApiResponse({ status: 200, description: 'Liste des nounous' })
@@ -20,7 +20,7 @@ import { UpdateParentsDto } from '../user/dto/update_parent.dto';
export class ParentsController { export class ParentsController {
constructor(private readonly parentsService: ParentsService) {} constructor(private readonly parentsService: ParentsService) {}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE) @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@Get() @Get()
@ApiResponse({ status: 200, type: [Parents], description: 'Liste des parents' }) @ApiResponse({ status: 200, type: [Parents], description: 'Liste des parents' })
@ApiResponse({ status: 403, description: 'Accès refusé !' }) @ApiResponse({ status: 403, description: 'Accès refusé !' })
@@ -0,0 +1,34 @@
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;
}
@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateRelaisDto } from './create-relais.dto';
export class UpdateRelaisDto extends PartialType(CreateRelaisDto) {}
@@ -0,0 +1,57 @@
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);
}
}
@@ -0,0 +1,17 @@
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 {}
@@ -0,0 +1,42 @@
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,4 +1,10 @@
import { OmitType } from "@nestjs/swagger"; import { PickType } from "@nestjs/swagger";
import { CreateUserDto } from "./create_user.dto"; import { CreateUserDto } from "./create_user.dto";
export class CreateAdminDto extends OmitType(CreateUserDto, ['role'] as const) {} export class CreateAdminDto extends PickType(CreateUserDto, [
'nom',
'prenom',
'email',
'password',
'telephone'
] as const) {}
@@ -1,4 +1,10 @@
import { OmitType } from "@nestjs/swagger"; import { ApiProperty, OmitType } from "@nestjs/swagger";
import { CreateUserDto } from "./create_user.dto"; import { CreateUserDto } from "./create_user.dto";
import { IsOptional, IsUUID } from "class-validator";
export class CreateGestionnaireDto extends OmitType(CreateUserDto, ['role'] as const) {} export class CreateGestionnaireDto extends OmitType(CreateUserDto, ['role', 'adresse', 'genre', 'statut', 'situation_familiale', 'ville', 'code_postal', 'photo_url', 'consentement_photo', 'date_consentement_photo', 'changement_mdp_obligatoire'] as const) {
@ApiProperty({ required: false, description: 'ID du relais de rattachement' })
@IsOptional()
@IsUUID()
relaisId?: string;
}
@@ -36,10 +36,10 @@ export class CreateUserDto {
@MaxLength(100) @MaxLength(100)
nom: string; nom: string;
@ApiProperty({ enum: GenreType, required: false, default: GenreType.AUTRE }) @ApiProperty({ enum: GenreType, required: false })
@IsOptional() @IsOptional()
@IsEnum(GenreType) @IsEnum(GenreType)
genre?: GenreType = GenreType.AUTRE; genre?: GenreType;
@ApiProperty({ enum: RoleType }) @ApiProperty({ enum: RoleType })
@IsEnum(RoleType) @IsEnum(RoleType)
@@ -86,7 +86,7 @@ export class CreateUserDto {
@ApiProperty({ default: false }) @ApiProperty({ default: false })
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
consentement_photo?: boolean = false; consentement_photo?: boolean;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsOptional() @IsOptional()
@@ -96,7 +96,7 @@ export class CreateUserDto {
@ApiProperty({ default: false }) @ApiProperty({ default: false })
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
changement_mdp_obligatoire?: boolean = false; changement_mdp_obligatoire?: boolean;
@ApiProperty({ example: true }) @ApiProperty({ example: true })
@IsBoolean() @IsBoolean()
@@ -35,7 +35,7 @@ export class GestionnairesController {
return this.gestionnairesService.create(dto); return this.gestionnairesService.create(dto);
} }
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE) @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@ApiOperation({ summary: 'Liste des gestionnaires' }) @ApiOperation({ summary: 'Liste des gestionnaires' })
@ApiResponse({ status: 200, description: 'Liste des gestionnaires : ', type: [Users] }) @ApiResponse({ status: 200, description: 'Liste des gestionnaires : ', type: [Users] })
@Get() @Get()
@@ -3,9 +3,15 @@ import { GestionnairesService } from './gestionnaires.service';
import { GestionnairesController } from './gestionnaires.controller'; import { GestionnairesController } from './gestionnaires.controller';
import { Users } from 'src/entities/users.entity'; import { Users } from 'src/entities/users.entity';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthModule } from 'src/routes/auth/auth.module';
import { MailModule } from 'src/modules/mail/mail.module';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Users])], imports: [
TypeOrmModule.forFeature([Users]),
AuthModule,
MailModule,
],
controllers: [GestionnairesController], controllers: [GestionnairesController],
providers: [GestionnairesService], providers: [GestionnairesService],
}) })
@@ -5,16 +5,18 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { RoleType, Users } from 'src/entities/users.entity'; import { RoleType, StatutUtilisateurType, Users } from 'src/entities/users.entity';
import { CreateGestionnaireDto } from '../dto/create_gestionnaire.dto'; import { CreateGestionnaireDto } from '../dto/create_gestionnaire.dto';
import { UpdateGestionnaireDto } from '../dto/update_gestionnaire.dto'; import { UpdateGestionnaireDto } from '../dto/update_gestionnaire.dto';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { MailService } from 'src/modules/mail/mail.service';
@Injectable() @Injectable()
export class GestionnairesService { export class GestionnairesService {
constructor( constructor(
@InjectRepository(Users) @InjectRepository(Users)
private readonly gestionnaireRepository: Repository<Users>, private readonly gestionnaireRepository: Repository<Users>,
private readonly mailService: MailService,
) { } ) { }
// Création d’un gestionnaire // Création d’un gestionnaire
@@ -30,30 +32,51 @@ export class GestionnairesService {
password: hashedPassword, password: hashedPassword,
prenom: dto.prenom, prenom: dto.prenom,
nom: dto.nom, nom: dto.nom,
genre: dto.genre, // genre: dto.genre, // Retiré
statut: dto.statut, // statut: dto.statut, // Retiré
statut: StatutUtilisateurType.ACTIF,
telephone: dto.telephone, telephone: dto.telephone,
adresse: dto.adresse, // adresse: dto.adresse, // Retiré
photo_url: dto.photo_url, // photo_url: dto.photo_url, // Retiré
consentement_photo: dto.consentement_photo ?? false, // consentement_photo: dto.consentement_photo ?? false, // Retiré
date_consentement_photo: dto.date_consentement_photo // date_consentement_photo: dto.date_consentement_photo // Retiré
? new Date(dto.date_consentement_photo) // ? new Date(dto.date_consentement_photo)
: undefined, // : undefined,
changement_mdp_obligatoire: dto.changement_mdp_obligatoire ?? false, changement_mdp_obligatoire: true,
role: RoleType.GESTIONNAIRE, role: RoleType.GESTIONNAIRE,
relaisId: dto.relaisId,
}); });
return this.gestionnaireRepository.save(entity);
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;
} }
// Liste des gestionnaires // Liste des gestionnaires
async findAll(): Promise<Users[]> { async findAll(): Promise<Users[]> {
return this.gestionnaireRepository.find({ where: { role: RoleType.GESTIONNAIRE } }); return this.gestionnaireRepository.find({
where: { role: RoleType.GESTIONNAIRE },
relations: ['relais'],
});
} }
// Récupérer un gestionnaire par ID // Récupérer un gestionnaire par ID
async findOne(id: string): Promise<Users> { async findOne(id: string): Promise<Users> {
const gestionnaire = await this.gestionnaireRepository.findOne({ const gestionnaire = await this.gestionnaireRepository.findOne({
where: { id, role: RoleType.GESTIONNAIRE }, where: { id, role: RoleType.GESTIONNAIRE },
relations: ['relais'],
}); });
if (!gestionnaire) throw new NotFoundException('Gestionnaire introuvable'); if (!gestionnaire) throw new NotFoundException('Gestionnaire introuvable');
return gestionnaire; return gestionnaire;
@@ -68,13 +91,13 @@ export class GestionnairesService {
gestionnaire.password = await bcrypt.hash(dto.password, salt); gestionnaire.password = await bcrypt.hash(dto.password, salt);
} }
if (dto.date_consentement_photo !== undefined) { // if (dto.date_consentement_photo !== undefined) {
gestionnaire.date_consentement_photo = dto.date_consentement_photo // gestionnaire.date_consentement_photo = dto.date_consentement_photo
? new Date(dto.date_consentement_photo) // ? new Date(dto.date_consentement_photo)
: undefined; // : undefined;
} // }
const { password, date_consentement_photo, ...rest } = dto; const { password, ...rest } = dto;
Object.entries(rest).forEach(([key, value]) => { Object.entries(rest).forEach(([key, value]) => {
if (value !== undefined) { if (value !== undefined) {
(gestionnaire as any)[key] = value; (gestionnaire as any)[key] = value;
+15 -3
View File
@@ -6,6 +6,7 @@ import { User } from 'src/common/decorators/user.decorator';
import { RoleType, Users } from 'src/entities/users.entity'; import { RoleType, Users } from 'src/entities/users.entity';
import { UserService } from './user.service'; import { UserService } from './user.service';
import { CreateUserDto } from './dto/create_user.dto'; import { CreateUserDto } from './dto/create_user.dto';
import { CreateAdminDto } from './dto/create_admin.dto';
import { UpdateUserDto } from './dto/update_user.dto'; import { UpdateUserDto } from './dto/update_user.dto';
@ApiTags('Utilisateurs') @ApiTags('Utilisateurs')
@@ -15,6 +16,17 @@ import { UpdateUserDto } from './dto/update_user.dto';
export class UserController { export class UserController {
constructor(private readonly userService: UserService) { } constructor(private readonly userService: UserService) { }
// Création d'un administrateur (réservée aux super admins)
@Post('admin')
@Roles(RoleType.SUPER_ADMIN)
@ApiOperation({ summary: 'Créer un nouvel administrateur (super admin seulement)' })
createAdmin(
@Body() dto: CreateAdminDto,
@User() currentUser: Users
) {
return this.userService.createAdmin(dto, currentUser);
}
// Création d'un utilisateur (réservée aux super admins) // Création d'un utilisateur (réservée aux super admins)
@Post() @Post()
@Roles(RoleType.SUPER_ADMIN) @Roles(RoleType.SUPER_ADMIN)
@@ -28,7 +40,7 @@ export class UserController {
// Lister tous les utilisateurs (super_admin uniquement) // Lister tous les utilisateurs (super_admin uniquement)
@Get() @Get()
@Roles(RoleType.SUPER_ADMIN) @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
@ApiOperation({ summary: 'Lister tous les utilisateurs' }) @ApiOperation({ summary: 'Lister tous les utilisateurs' })
findAll() { findAll() {
return this.userService.findAll(); return this.userService.findAll();
@@ -43,9 +55,9 @@ export class UserController {
return this.userService.findOne(id); return this.userService.findOne(id);
} }
// Modifier un utilisateur (réservé super_admin) // Modifier un utilisateur (réservé super_admin et admin)
@Patch(':id') @Patch(':id')
@Roles(RoleType.SUPER_ADMIN) @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
@ApiOperation({ summary: 'Mettre à jour un utilisateur' }) @ApiOperation({ summary: 'Mettre à jour un utilisateur' })
@ApiParam({ name: 'id', description: "UUID de l'utilisateur" }) @ApiParam({ name: 'id', description: "UUID de l'utilisateur" })
updateUser( updateUser(
+2
View File
@@ -9,6 +9,7 @@ import { ParentsModule } from '../parents/parents.module';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { AssistantesMaternellesModule } from '../assistantes_maternelles/assistantes_maternelles.module'; import { AssistantesMaternellesModule } from '../assistantes_maternelles/assistantes_maternelles.module';
import { Parents } from 'src/entities/parents.entity'; import { Parents } from 'src/entities/parents.entity';
import { GestionnairesModule } from './gestionnaires/gestionnaires.module';
@Module({ @Module({
imports: [TypeOrmModule.forFeature( imports: [TypeOrmModule.forFeature(
@@ -20,6 +21,7 @@ import { Parents } from 'src/entities/parents.entity';
]), forwardRef(() => AuthModule), ]), forwardRef(() => AuthModule),
ParentsModule, ParentsModule,
AssistantesMaternellesModule, AssistantesMaternellesModule,
GestionnairesModule,
], ],
controllers: [UserController], controllers: [UserController],
providers: [UserService], providers: [UserService],
+47
View File
@@ -3,6 +3,7 @@ import { InjectRepository } from "@nestjs/typeorm";
import { RoleType, StatutUtilisateurType, Users } from "src/entities/users.entity"; import { RoleType, StatutUtilisateurType, Users } from "src/entities/users.entity";
import { In, Repository } from "typeorm"; import { In, Repository } from "typeorm";
import { CreateUserDto } from "./dto/create_user.dto"; import { CreateUserDto } from "./dto/create_user.dto";
import { CreateAdminDto } from "./dto/create_admin.dto";
import { UpdateUserDto } from "./dto/update_user.dto"; import { UpdateUserDto } from "./dto/update_user.dto";
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { StatutValidationType, Validation } from "src/entities/validations.entity"; import { StatutValidationType, Validation } from "src/entities/validations.entity";
@@ -106,6 +107,31 @@ export class UserService {
return this.findOne(saved.id); return this.findOne(saved.id);
} }
async createAdmin(dto: CreateAdminDto, currentUser: Users): Promise<Users> {
if (currentUser.role !== RoleType.SUPER_ADMIN) {
throw new ForbiddenException('Seuls les super administrateurs peuvent créer un administrateur');
}
const exist = await this.usersRepository.findOneBy({ email: dto.email });
if (exist) throw new BadRequestException('Email déjà utilisé');
const salt = await bcrypt.genSalt();
const hashedPassword = await bcrypt.hash(dto.password, salt);
const entity = this.usersRepository.create({
email: dto.email,
password: hashedPassword,
prenom: dto.prenom,
nom: dto.nom,
role: RoleType.ADMINISTRATEUR,
statut: StatutUtilisateurType.ACTIF,
telephone: dto.telephone,
changement_mdp_obligatoire: true,
});
return this.usersRepository.save(entity);
}
async findAll(): Promise<Users[]> { async findAll(): Promise<Users[]> {
return this.usersRepository.find(); return this.usersRepository.find();
} }
@@ -129,11 +155,26 @@ export class UserService {
async updateUser(id: string, dto: UpdateUserDto, currentUser: Users): Promise<Users> { async updateUser(id: string, dto: UpdateUserDto, currentUser: Users): Promise<Users> {
const user = await this.findOne(id); const user = await this.findOne(id);
// Le super administrateur conserve une identité figée.
if (
user.role === RoleType.SUPER_ADMIN &&
(dto.nom !== undefined || dto.prenom !== undefined)
) {
throw new ForbiddenException(
'Le nom et le prénom du super administrateur ne peuvent pas être modifiés',
);
}
// Interdire changement de rôle si pas super admin // Interdire changement de rôle si pas super admin
if (dto.role && currentUser.role !== RoleType.SUPER_ADMIN) { if (dto.role && currentUser.role !== RoleType.SUPER_ADMIN) {
throw new ForbiddenException('Accès réservé aux super admins'); throw new ForbiddenException('Accès réservé aux super admins');
} }
// Un admin ne peut pas modifier un super admin
if (currentUser.role === RoleType.ADMINISTRATEUR && user.role === RoleType.SUPER_ADMIN) {
throw new ForbiddenException('Vous ne pouvez pas modifier un super administrateur');
}
// Empêcher de modifier le flag changement_mdp_obligatoire pour admin/gestionnaire // Empêcher de modifier le flag changement_mdp_obligatoire pour admin/gestionnaire
if ( if (
(user.role === RoleType.ADMINISTRATEUR || user.role === RoleType.GESTIONNAIRE) && (user.role === RoleType.ADMINISTRATEUR || user.role === RoleType.GESTIONNAIRE) &&
@@ -225,6 +266,12 @@ export class UserService {
if (currentUser.role !== RoleType.SUPER_ADMIN) { if (currentUser.role !== RoleType.SUPER_ADMIN) {
throw new ForbiddenException('Accès réservé aux super admins'); throw new ForbiddenException('Accès réservé aux super admins');
} }
const user = await this.findOne(id);
if (user.role === RoleType.SUPER_ADMIN) {
throw new ForbiddenException(
'Le super administrateur ne peut pas être supprimé',
);
}
const result = await this.usersRepository.delete(id); const result = await this.usersRepository.delete(id);
if (result.affected === 0) { if (result.affected === 0) {
throw new NotFoundException('Utilisateur introuvable'); throw new NotFoundException('Utilisateur introuvable');
+7
View File
@@ -0,0 +1,7 @@
const bcrypt = require('bcrypt');
const pass = '!Bezons2014';
bcrypt.hash(pass, 10).then(hash => {
console.log('New Hash:', hash);
}).catch(err => console.error(err));
+18 -2
View File
@@ -331,13 +331,29 @@ CREATE INDEX idx_acceptations_utilisateur ON acceptations_documents(id_utilisate
CREATE INDEX idx_acceptations_document ON acceptations_documents(id_document); CREATE INDEX idx_acceptations_document ON acceptations_documents(id_document);
-- ========================================================== -- ==========================================================
-- Modification Table : utilisateurs (ajout colonnes documents) -- 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)
-- ========================================================== -- ==========================================================
ALTER TABLE utilisateurs ALTER TABLE utilisateurs
ADD COLUMN IF NOT EXISTS cgu_version_acceptee INTEGER, ADD COLUMN IF NOT EXISTS cgu_version_acceptee INTEGER,
ADD COLUMN IF NOT EXISTS cgu_acceptee_le TIMESTAMPTZ, ADD COLUMN IF NOT EXISTS cgu_acceptee_le TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS privacy_version_acceptee INTEGER, ADD COLUMN IF NOT EXISTS privacy_version_acceptee INTEGER,
ADD COLUMN IF NOT EXISTS privacy_acceptee_le TIMESTAMPTZ; ADD COLUMN IF NOT EXISTS privacy_acceptee_le TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS relais_id UUID REFERENCES relais(id) ON DELETE SET NULL;
-- ========================================================== -- ==========================================================
-- Seed : Documents légaux génériques v1 -- Seed : Documents légaux génériques v1
+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 ## 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/`. 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_SECRET: ${JWT_REFRESH_SECRET}
JWT_REFRESH_EXPIRES: ${JWT_REFRESH_EXPIRES} JWT_REFRESH_EXPIRES: ${JWT_REFRESH_EXPIRES}
NODE_ENV: ${NODE_ENV} NODE_ENV: ${NODE_ENV}
LOG_API_REQUESTS: ${LOG_API_REQUESTS:-false}
CONFIG_ENCRYPTION_KEY: ${CONFIG_ENCRYPTION_KEY}
depends_on: depends_on:
- database - database
labels: labels:
+180 -39
View File
@@ -1,9 +1,9 @@
# 🎫 Liste Complète des Tickets - Projet P'titsPas # 🎫 Liste Complète des Tickets - Projet P'titsPas
**Version** : 1.4 **Version** : 1.5
**Date** : 9 Février 2026 **Date** : 24 Février 2026
**Auteur** : Équipe PtitsPas **Auteur** : Équipe PtitsPas
**Estimation totale** : ~184h **Estimation totale** : ~208h
--- ---
@@ -23,11 +23,19 @@
| 10 | [Backend] Service Configuration | ✅ Fermé | | 10 | [Backend] Service Configuration | ✅ Fermé |
| 11 | [Backend] API Configuration | ✅ Fermé | | 11 | [Backend] API Configuration | ✅ Fermé |
| 12 | [Backend] Guard Configuration Initiale | ✅ Fermé | | 12 | [Backend] Guard Configuration Initiale | ✅ Fermé |
| 13 | [Backend] Adaptation MailService pour config dynamique | Ouvert | | 13 | [Backend] Adaptation MailService pour config dynamique | ✅ Fermé |
| 14 | [Frontend] Panneau Paramètres / Configuration (première config + accès permanent) | Ouvert | | 14 | [Frontend] Panneau Paramètres / Configuration (première config + accès permanent) | Ouvert |
| 15 | [Frontend] Écran Paramètres (accès permanent) | Ouvert | | 15 | [Frontend] Écran Paramètres (accès permanent) | Ouvert |
| 16 | [Doc] Documentation configuration on-premise | Ouvert | | 16 | [Doc] Documentation configuration on-premise | Ouvert |
| 17–88 | (voir sections ci‑dessous ; #78, #79, #81, #83, #82, #86, #87, #88, etc.) | — | | 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 | ✅ Fermé |
| 94 | [Backend] Relais - modele, API CRUD et liaison gestionnaire | ✅ Terminé |
| 95 | [Frontend] Admin - gestion des relais et rattachement gestionnaire | ✅ Fermé |
| 96 | [Frontend] Admin - Création administrateur via modale (sans relais) | ✅ Terminé |
| 97 | [Backend] Harmoniser API création administrateur avec le contrat frontend | ✅ Terminé |
| 89 | Log des appels API en mode debug | Ouvert |
*Gitea #1 et #2 = anciens tickets de test (fermés). Liste complète : https://git.ptits-pas.fr/jmartin/petitspas/issues* *Gitea #1 et #2 = anciens tickets de test (fermés). Liste complète : https://git.ptits-pas.fr/jmartin/petitspas/issues*
@@ -229,6 +237,8 @@ Créer un Guard/Middleware qui détecte si la configuration initiale est incompl
**Référence** : [21_CONFIGURATION-SYSTEME.md](./21_CONFIGURATION-SYSTEME.md#workflow-setup-initial) **Référence** : [21_CONFIGURATION-SYSTEME.md](./21_CONFIGURATION-SYSTEME.md#workflow-setup-initial)
*Issue Gitea #86 fermée en doublon ; ce ticket (#12) est la référence.*
--- ---
### Ticket #13 : [Backend] Adaptation MailService pour config dynamique ### Ticket #13 : [Backend] Adaptation MailService pour config dynamique
@@ -267,6 +277,8 @@ Un seul panneau **Paramètres / Configuration** dans le dashboard admin, avec **
**Référence** : [21_CONFIGURATION-SYSTEME.md](./21_CONFIGURATION-SYSTEME.md#interface-admin) **Référence** : [21_CONFIGURATION-SYSTEME.md](./21_CONFIGURATION-SYSTEME.md#interface-admin)
*Issue Gitea #87 fermée en doublon de #14.*
--- ---
### Ticket #15 : [Frontend] Écran Paramètres (accès permanent) / Intégration panneau ### Ticket #15 : [Frontend] Écran Paramètres (accès permanent) / Intégration panneau
@@ -281,6 +293,8 @@ S’assurer que le panneau Paramètres (décrit en #14) est accessible en perman
- [ ] Chargement des valeurs actuelles (GET `/configuration` ou par catégorie) - [ ] Chargement des valeurs actuelles (GET `/configuration` ou par catégorie)
- [ ] Modification et sauvegarde (PATCH bulk) sans appel à `setup/complete` - [ ] Modification et sauvegarde (PATCH bulk) sans appel à `setup/complete`
*Issue Gitea #88 fermée en doublon ; ce ticket (#15) est la référence.*
--- ---
### Ticket #16 : [Doc] Documentation configuration on-premise ### Ticket #16 : [Doc] Documentation configuration on-premise
@@ -302,39 +316,29 @@ Rédiger la documentation pour aider les collectivités à configurer l'applicat
--- ---
### Ticket #86 : [Backend] Guard Configuration Initiale (concept v1.3) ### Ticket #86 / #88 : Doublons fermés
**Estimation** : 2h *#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.
**Labels** : `backend`, `p1-bloquant`, `on-premise`
Issue Gitea ouverte pour le Guard aligné avec le concept v1.3 (pas de redirection vers `/admin/setup`, le frontend affiche le panneau Configuration et bloque la navigation). Voir aussi Ticket #12 (version fermée).
---
### Ticket #88 : [Frontend] Intégration panneau Paramètres au dashboard
**Estimation** : 1h
**Labels** : `frontend`, `p1-bloquant`, `on-premise`
Complément de #14 et #15 : s’assurer que le panneau Paramètres est accessible en permanence (onglet Configuration, chargement des valeurs, sauvegarde PATCH bulk sans `setup/complete`).
--- ---
## 🟢 PRIORITÉ 2 : Backend - Authentification & Gestion Comptes ## 🟢 PRIORITÉ 2 : Backend - Authentification & Gestion Comptes
### Ticket #17 : [Backend] API Création gestionnaire ### Ticket #17 : [Backend] API Création gestionnaire ✅
**Estimation** : 3h **Estimation** : 3h
**Labels** : `backend`, `p2`, `auth` **Labels** : `backend`, `p2`, `auth`
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-23)
**Description** : **Description** :
Créer l'endpoint pour permettre au super admin de créer des gestionnaires. Créer l'endpoint pour permettre au super admin de créer des gestionnaires.
**Tâches** : **Tâches** :
- [ ] Endpoint `POST /api/v1/gestionnaires` - [x] Endpoint `POST /api/v1/gestionnaires`
- [ ] Validation DTO - [x] Validation DTO
- [ ] Hash bcrypt - [x] Hash bcrypt
- [ ] Flag `changement_mdp_obligatoire = TRUE` - [x] Flag `changement_mdp_obligatoire = TRUE`
- [ ] Guards (super_admin only) - [x] Guards (super_admin only)
- [ ] Email de notification (utiliser MailService avec config dynamique) - [x] Email de notification (utiliser MailService avec config dynamique)
- [ ] Tests unitaires - [x] Tests unitaires
**Référence** : [20_WORKFLOW-CREATION-COMPTE.md](./20_WORKFLOW-CREATION-COMPTE.md#étape-2--création-dun-gestionnaire) **Référence** : [20_WORKFLOW-CREATION-COMPTE.md](./20_WORKFLOW-CREATION-COMPTE.md#étape-2--création-dun-gestionnaire)
@@ -645,6 +649,39 @@ 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)
---
### Ticket #97 : [Backend] Harmoniser API création administrateur avec le contrat frontend ✅
**Estimation** : 3h
**Labels** : `backend`, `p2`, `auth`, `admin`
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-24)
**Description** :
Rendre l'API de création administrateur cohérente et stable avec le besoin frontend (modale simplifiée), en définissant un contrat clair et minimal.
**Tâches** :
- [ ] Introduire un DTO dédié `CreateAdministrateurDto`
- [ ] Champs autorisés : nom, prenom, email, password, telephone
- [ ] Champs exclus : adresse, ville, photo, etc.
- [ ] Rôle forcé à `ADMINISTRATEUR`
- [ ] Validation stricte
---
## 🟢 PRIORITÉ 3 : Frontend - Interfaces ## 🟢 PRIORITÉ 3 : Frontend - Interfaces
### Ticket #35 : [Frontend] Écran Création Gestionnaire ### Ticket #35 : [Frontend] Écran Création Gestionnaire
@@ -802,7 +839,7 @@ Créer l'écran de création de mot de passe (lien reçu par email).
--- ---
### Ticket #44 : [Frontend] Dashboard Gestionnaire - Structure ### Ticket #44 : [Frontend] Dashboard Gestionnaire - Structure ✅
**Estimation** : 2h **Estimation** : 2h
**Labels** : `frontend`, `p3`, `gestionnaire` **Labels** : `frontend`, `p3`, `gestionnaire`
@@ -810,9 +847,10 @@ Créer l'écran de création de mot de passe (lien reçu par email).
Créer la structure du dashboard gestionnaire avec 2 onglets. Créer la structure du dashboard gestionnaire avec 2 onglets.
**Tâches** : **Tâches** :
- [ ] Layout avec 2 onglets (Parents / AM) - [x] Dashboard gestionnaire = même shell que admin (sans onglet Paramètres), libellé « Gestionnaire »
- [ ] Navigation entre onglets - [x] Réutilisation du widget UserManagementPanel (ex-AdminUserManagementPanel) avec 3 onglets (Gestionnaires, Parents, Assistantes maternelles) ; onglet Administrateurs masqué
- [ ] État vide ("Aucune demande") - [x] Redirection login rôle `gestionnaire` vers `/gestionnaire-dashboard`
- [ ] État vide dédié ("Aucune demande") — optionnel, contenu actuel = listes existantes
--- ---
@@ -898,6 +936,31 @@ 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`
**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).
**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 ### Ticket #50 : [Frontend] Affichage dynamique CGU lors inscription
**Estimation** : 2h **Estimation** : 2h
**Labels** : `frontend`, `p3`, `juridique` **Labels** : `frontend`, `p3`, `juridique`
@@ -998,6 +1061,69 @@ 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`
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-24)
**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`
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-24)
**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
---
### Ticket #96 : [Frontend] Admin - Création administrateur via modale (sans relais) ✅
**Estimation** : 3h
**Labels** : `frontend`, `p3`, `admin`
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-24)
**Description** :
Permettre la création d'un administrateur via une modale simple depuis le dashboard admin.
**Tâches** :
- [x] Bouton "Créer administrateur" dans l'onglet Administrateurs
- [x] Modale avec formulaire simplifié (Nom, Prénom, Email, MDP, Téléphone)
- [x] Appel API `POST /users` (ou endpoint dédié si #97 implémenté)
- [x] Gestion succès/erreur et rafraîchissement liste
---
## 🔵 PRIORITÉ 4 : Tests & Documentation ## 🔵 PRIORITÉ 4 : Tests & Documentation
### Ticket #52 : [Tests] Tests unitaires Backend ### Ticket #52 : [Tests] Tests unitaires Backend
@@ -1113,6 +1239,20 @@ Mettre en place un système de logs centralisé avec Winston pour faciliter le d
--- ---
### Ticket #89 : Log des appels API en mode debug
**Estimation** : 2h
**Labels** : `backend`, `monitoring`
**Description** :
Ajouter des logs détaillés pour les appels API en mode debug pour faciliter le diagnostic.
**Tâches** :
- [ ] Middleware ou Intercepteur pour logger les requêtes entrantes (méthode, URL, body)
- [ ] Logger les réponses (status, temps d'exécution)
- [ ] Activable via variable d'environnement `DEBUG=true` ou niveau de log
---
### Ticket #51 (réf.) : [Frontend] Écran Logs Admin (optionnel v1.1) ### Ticket #51 (réf.) : [Frontend] Écran Logs Admin (optionnel v1.1)
**Estimation** : 4h **Estimation** : 4h
**Labels** : `frontend`, `p3`, `monitoring`, `admin` **Labels** : `frontend`, `p3`, `monitoring`, `admin`
@@ -1215,29 +1355,30 @@ Rédiger les documents légaux génériques (CGU et Politique de confidentialit
## 📊 Résumé final ## 📊 Résumé final
**Total** : 65 tickets **Total** : 72 tickets
**Estimation** : ~184h de développement **Estimation** : ~208h de développement
### Par priorité ### Par priorité
- **P0 (Bloquant BDD)** : 7 tickets (~5h) - **P0 (Bloquant BDD)** : 7 tickets (~5h)
- **P1 (Bloquant Config)** : 7 tickets (~22h) - **P1 (Bloquant Config)** : 7 tickets (~22h)
- **P2 (Backend)** : 18 tickets (~50h) - **P2 (Backend)** : 19 tickets (~54h)
- **P3 (Frontend)** : 22 tickets (~71h) ← +1 mobile RegisterChoice - **P3 (Frontend)** : 25 tickets (~83h)
- **P4 (Tests/Doc)** : 4 tickets (~24h) - **P4 (Tests/Doc)** : 4 tickets (~24h)
- **Critiques** : 6 tickets (~13h) - **Critiques** : 6 tickets (~13h)
- **Juridique** : 1 ticket (~8h) - **Juridique** : 1 ticket (~8h)
### Par domaine ### Par domaine
- **BDD** : 7 tickets - **BDD** : 7 tickets
- **Backend** : 23 tickets - **Backend** : 24 tickets
- **Frontend** : 22 tickets ← +1 mobile RegisterChoice - **Frontend** : 25 tickets
- **Tests** : 3 tickets - **Tests** : 3 tickets
- **Documentation** : 5 tickets - **Documentation** : 5 tickets
- **Infra** : 2 tickets - **Infra** : 2 tickets
- **Juridique** : 1 ticket - **Juridique** : 1 ticket
### Modifications par rapport à la version initiale ### Modifications par rapport à la version initiale
- ✅ **v1.4** : Numéros de section du doc = numéros Gitea (Ticket #n = issue #n). Tableau et sections renumérotés en conséquence ; #87 fermé (doublon de #14). - ✅ **v1.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. - ✅ **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 - ❌ **Supprimé** : Tickets "Renvoyer email validation" (backend + frontend) - Pas prioritaire
- ✅ **Ajouté** : Ticket #55 "Service Logging Winston" - Monitoring essentiel - ✅ **Ajouté** : Ticket #55 "Service Logging Winston" - Monitoring essentiel
@@ -1250,7 +1391,7 @@ Rédiger les documents légaux génériques (CGU et Politique de confidentialit
--- ---
**Dernière mise à jour** : 9 Février 2026 **Dernière mise à jour** : 24 Février 2026
**Version** : 1.4 **Version** : 1.6
**Statut** : ✅ Aligné avec le dépôt Gitea **Statut** : ✅ Aligné avec le dépôt Gitea
+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(),
);
// ...
}
```
+20
View File
@@ -256,3 +256,23 @@ Pour chaque évolution identifiée, ce document suivra la structure suivante :
- Modification du flux de sélection d'image dans les écrans concernés (ex: `parent_register_step3_screen.dart`). - Modification du flux de sélection d'image dans les écrans concernés (ex: `parent_register_step3_screen.dart`).
- Ajout potentiel de nouvelles dépendances et configurations spécifiques aux plateformes. - Ajout potentiel de nouvelles dépendances et configurations spécifiques aux plateformes.
- Mise à jour de la documentation utilisateur si cette fonctionnalité est implémentée. - Mise à jour de la documentation utilisateur si cette fonctionnalité est implémentée.
## 8. Évolution future - Gouvernance intra-RPE
### 8.1 Niveaux d'accès et rôles différenciés dans un même Relais
#### 8.1.1 Situation actuelle
- Le périmètre actuel prévoit un rattachement simple entre gestionnaire et relais.
- Le rôle "gestionnaire" est traité de manière uniforme dans l'outil.
#### 8.1.2 Évolution à prévoir
- Introduire un modèle de rôles internes au relais (par exemple : responsable/coordinatrice, animatrice/référente, administratif).
- Permettre des niveaux d'autorité différents selon les actions (pilotage, validation, consultation, administration locale).
- Définir des permissions fines par fonctionnalité (lecture, création, modification, suppression, validation).
- Prévoir une gestion multi-utilisateurs par relais avec traçabilité des décisions.
#### 8.1.3 Impact attendu
- Évolution du modèle de données vers un RBAC intra-RPE.
- Adaptation des écrans d'administration pour gérer les rôles locaux.
- Renforcement des contrôles d'accès backend et des règles métier.
- Clarification des workflows décisionnels dans l'application.
+1 -1
View File
@@ -14,7 +14,7 @@ Num | Etat | Titre
41 | closed | [Frontend] Inscription AM - Panneau 2 (Infos pro) 41 | closed | [Frontend] Inscription AM - Panneau 2 (Infos pro)
42 | closed | [Frontend] Inscription AM - Finalisation 42 | closed | [Frontend] Inscription AM - Finalisation
43 | open | [Frontend] Écran Création Mot de Passe 43 | open | [Frontend] Écran Création Mot de Passe
44 | open | [Frontend] Dashboard Gestionnaire - Structure 44 | closed | [Frontend] Dashboard Gestionnaire - Structure
45 | open | [Frontend] Dashboard Gestionnaire - Liste Parents 45 | open | [Frontend] Dashboard Gestionnaire - Liste Parents
46 | open | [Frontend] Dashboard Gestionnaire - Liste AM 46 | open | [Frontend] Dashboard Gestionnaire - Liste AM
47 | open | [Frontend] Écran Changement MDP Obligatoire 47 | open | [Frontend] Écran Changement MDP Obligatoire
+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).
+9 -1
View File
@@ -1,6 +1,6 @@
# SuperNounou – SSS-001 # SuperNounou – SSS-001
## Spécification technique & opérationnelle unifiée ## Spécification technique & opérationnelle unifiée
_Version 0.2 – 24 avril 2025_ _Version 0.3 – 27 janvier 2026_
--- ---
@@ -62,6 +62,13 @@ Collection Postman, scripts cURL, guide « Appeler l’API ».
### B.4 Intégrations futures ### B.4 Intégrations futures
SSO LDAP/SAML, webhook `contract.validated`, export statistiques CSV. SSO LDAP/SAML, webhook `contract.validated`, export statistiques CSV.
### B.5 Contrat de gestion des comptes d'administration
- Création d'un administrateur avec un contrat minimal stable : `nom`, `prenom`, `email`, `password`, `telephone`.
- Le rôle n'est jamais fourni par le frontend pour ce flux ; le backend impose `ADMINISTRATEUR`.
- Les champs hors périmètre (adresse complète, photo, métadonnées métier non nécessaires) ne sont pas requis.
- Les protections d'autorisation restent actives : un `SUPER_ADMIN` n'est pas supprimable et son identité (`nom`, `prenom`) est non modifiable.
- Côté interface d'administration, les actions d'édition sont conditionnées aux droits ; les entrées non éditables restent consultables en lecture seule.
--- ---
# C – Déploiement, CI/CD et Observabilité *(nouveau)* # C – Déploiement, CI/CD et Observabilité *(nouveau)*
@@ -106,3 +113,4 @@ AES-256, JWT, KMS, OpenAPI, RPO, RTO, rate-limit, HMAC, Compose, CI/CD…
|---------|------------|------------------|---------------------------------| |---------|------------|------------------|---------------------------------|
| 0.1-draft | 2025-04-24 | Équipe projet | Création du SSS unifié | | 0.1-draft | 2025-04-24 | Équipe projet | Création du SSS unifié |
| 0.2 | 2025-04-24 | ChatGPT & Julien | Ajout déploiement / CI/CD / logs | | 0.2 | 2025-04-24 | ChatGPT & Julien | Ajout déploiement / CI/CD / logs |
| 0.3 | 2026-01-27 | Équipe projet | Contrat admin harmonisé et règles d'autorisation |
+5
View File
@@ -20,6 +20,7 @@ import '../screens/auth/am_register_step3_screen.dart';
import '../screens/auth/am_register_step4_screen.dart'; import '../screens/auth/am_register_step4_screen.dart';
import '../screens/home/home_screen.dart'; import '../screens/home/home_screen.dart';
import '../screens/administrateurs/admin_dashboardScreen.dart'; import '../screens/administrateurs/admin_dashboardScreen.dart';
import '../screens/gestionnaire/gestionnaire_dashboard_screen.dart';
import '../screens/home/parent_screen/ParentDashboardScreen.dart'; import '../screens/home/parent_screen/ParentDashboardScreen.dart';
import '../screens/unknown_screen.dart'; import '../screens/unknown_screen.dart';
@@ -53,6 +54,10 @@ class AppRouter {
path: '/admin-dashboard', path: '/admin-dashboard',
builder: (BuildContext context, GoRouterState state) => const AdminDashboardScreen(), builder: (BuildContext context, GoRouterState state) => const AdminDashboardScreen(),
), ),
GoRoute(
path: '/gestionnaire-dashboard',
builder: (BuildContext context, GoRouterState state) => const GestionnaireDashboardScreen(),
),
GoRoute( GoRoute(
path: '/parent-dashboard', path: '/parent-dashboard',
builder: (BuildContext context, GoRouterState state) => const ParentDashboardScreen(), builder: (BuildContext context, GoRouterState state) => const ParentDashboardScreen(),
@@ -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?,
);
}
}
+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,
);
}
}
+33
View File
@@ -0,0 +1,33 @@
class RelaisModel {
final String id;
final String nom;
final String adresse;
final Map<String, dynamic>? horairesOuverture;
final String? ligneFixe;
final bool actif;
final String? notes;
const RelaisModel({
required this.id,
required this.nom,
required this.adresse,
this.horairesOuverture,
this.ligneFixe,
required this.actif,
this.notes,
});
factory RelaisModel.fromJson(Map<String, dynamic> json) {
return RelaisModel(
id: (json['id'] ?? '').toString(),
nom: (json['nom'] ?? '').toString(),
adresse: (json['adresse'] ?? '').toString(),
horairesOuverture: json['horaires_ouverture'] is Map<String, dynamic>
? json['horaires_ouverture'] as Map<String, dynamic>
: null,
ligneFixe: json['ligne_fixe'] as String?,
actif: json['actif'] as bool? ?? true,
notes: json['notes'] as String?,
);
}
}
+57 -5
View File
@@ -5,6 +5,16 @@ class AppUser {
final DateTime createdAt; final DateTime createdAt;
final DateTime updatedAt; final DateTime updatedAt;
final bool changementMdpObligatoire; 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;
final String? relaisId;
final String? relaisNom;
AppUser({ AppUser({
required this.id, required this.id,
@@ -13,20 +23,50 @@ class AppUser {
required this.createdAt, required this.createdAt,
required this.updatedAt, required this.updatedAt,
this.changementMdpObligatoire = false, this.changementMdpObligatoire = false,
this.nom,
this.prenom,
this.statut,
this.telephone,
this.photoUrl,
this.adresse,
this.ville,
this.codePostal,
this.relaisId,
this.relaisNom,
}); });
factory AppUser.fromJson(Map<String, dynamic> json) { factory AppUser.fromJson(Map<String, dynamic> json) {
final relaisJson = json['relais'];
final relaisMap =
relaisJson is Map<String, dynamic> ? relaisJson : <String, dynamic>{};
return AppUser( return AppUser(
id: json['id'] as String, id: json['id'] as String,
email: json['email'] as String, email: json['email'] as String,
role: json['role'] as String, role: json['role'] as String,
createdAt: json['createdAt'] != null createdAt: json['cree_le'] != null
? DateTime.parse(json['cree_le'] as String)
: (json['createdAt'] != null
? DateTime.parse(json['createdAt'] as String) ? DateTime.parse(json['createdAt'] as String)
: DateTime.now(), : DateTime.now()),
updatedAt: json['updatedAt'] != null updatedAt: json['modifie_le'] != null
? DateTime.parse(json['modifie_le'] as String)
: (json['updatedAt'] != null
? DateTime.parse(json['updatedAt'] as String) ? DateTime.parse(json['updatedAt'] as String)
: DateTime.now(), : DateTime.now()),
changementMdpObligatoire: json['changement_mdp_obligatoire'] as bool? ?? false, 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?,
relaisId: (json['relaisId'] ?? json['relais_id'] ?? relaisMap['id'])
?.toString(),
relaisNom: relaisMap['nom']?.toString(),
); );
} }
@@ -38,6 +78,18 @@ class AppUser {
'createdAt': createdAt.toIso8601String(), 'createdAt': createdAt.toIso8601String(),
'updatedAt': updatedAt.toIso8601String(), 'updatedAt': updatedAt.toIso8601String(),
'changement_mdp_obligatoire': changementMdpObligatoire, 'changement_mdp_obligatoire': changementMdpObligatoire,
'nom': nom,
'prenom': prenom,
'statut': statut,
'telephone': telephone,
'photo_url': photoUrl,
'adresse': adresse,
'ville': ville,
'code_postal': codePostal,
'relais_id': relaisId,
'relais_nom': relaisNom,
}; };
} }
String get fullName => '${prenom ?? ''} ${nom ?? ''}'.trim();
} }
@@ -1,9 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/services/configuration_service.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/parametres_panel.dart'; import 'package:p_tits_pas/widgets/admin/parametres_panel.dart';
import 'package:p_tits_pas/widgets/admin/user_management_panel.dart';
import 'package:p_tits_pas/widgets/app_footer.dart'; import 'package:p_tits_pas/widgets/app_footer.dart';
import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart'; import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart';
@@ -17,7 +15,7 @@ class AdminDashboardScreen extends StatefulWidget {
class _AdminDashboardScreenState extends State<AdminDashboardScreen> { class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
bool? _setupCompleted; bool? _setupCompleted;
int mainTabIndex = 0; int mainTabIndex = 0;
int subIndex = 0; int settingsSubIndex = 0;
@override @override
void initState() { void initState() {
@@ -25,6 +23,11 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
_loadSetupStatus(); _loadSetupStatus();
} }
@override
void dispose() {
super.dispose();
}
Future<void> _loadSetupStatus() async { Future<void> _loadSetupStatus() async {
try { try {
final completed = await ConfigurationService.getSetupStatus(); final completed = await ConfigurationService.getSetupStatus();
@@ -34,12 +37,14 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
if (!completed) mainTabIndex = 1; if (!completed) mainTabIndex = 1;
}); });
} catch (e) { } catch (e) {
if (mounted) setState(() { if (mounted) {
setState(() {
_setupCompleted = false; _setupCompleted = false;
mainTabIndex = 1; mainTabIndex = 1;
}); });
} }
} }
}
void onMainTabChange(int index) { void onMainTabChange(int index) {
setState(() { setState(() {
@@ -47,9 +52,9 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
}); });
} }
void onSubTabChange(int index) { void onSettingsSubTabChange(int index) {
setState(() { setState(() {
subIndex = index; settingsSubIndex = index;
}); });
} }
@@ -79,9 +84,11 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
body: Column( body: Column(
children: [ children: [
if (mainTabIndex == 0) if (mainTabIndex == 0)
DashboardUserManagementSubBar( const SizedBox.shrink()
selectedSubIndex: subIndex, else
onSubTabChange: onSubTabChange, DashboardSettingsSubBar(
selectedSubIndex: settingsSubIndex,
onSubTabChange: onSettingsSubTabChange,
), ),
Expanded( Expanded(
child: _getBody(), child: _getBody(),
@@ -94,19 +101,11 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
Widget _getBody() { Widget _getBody() {
if (mainTabIndex == 1) { if (mainTabIndex == 1) {
return ParametresPanel(redirectToLoginAfterSave: !_setupCompleted!); return ParametresPanel(
} redirectToLoginAfterSave: !_setupCompleted!,
switch (subIndex) { selectedSettingsTabIndex: settingsSubIndex,
case 0: );
return const GestionnaireManagementWidget();
case 1:
return const ParentManagementWidget();
case 2:
return const AssistanteMaternelleManagementWidget();
case 3:
return const Center(child: Text('👨‍💼 Administrateurs'));
default:
return const Center(child: Text('Page non trouvée'));
} }
return const UserManagementPanel();
} }
} }
@@ -0,0 +1,357 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/user_service.dart';
class AdminCreateDialog extends StatefulWidget {
final AppUser? initialUser;
const AdminCreateDialog({
super.key,
this.initialUser,
});
@override
State<AdminCreateDialog> createState() => _AdminCreateDialogState();
}
class _AdminCreateDialogState extends State<AdminCreateDialog> {
final _formKey = GlobalKey<FormState>();
final _nomController = TextEditingController();
final _prenomController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _telephoneController = TextEditingController();
bool _isSubmitting = false;
bool _obscurePassword = true;
bool get _isEditMode => widget.initialUser != null;
@override
void initState() {
super.initState();
final user = widget.initialUser;
if (user != null) {
_nomController.text = user.nom ?? '';
_prenomController.text = user.prenom ?? '';
_emailController.text = user.email;
_telephoneController.text = user.telephone ?? '';
// En édition, on ne préremplit jamais le mot de passe.
_passwordController.clear();
}
}
@override
void dispose() {
_nomController.dispose();
_prenomController.dispose();
_emailController.dispose();
_passwordController.dispose();
_telephoneController.dispose();
super.dispose();
}
String? _required(String? value, String field) {
if (value == null || value.trim().isEmpty) {
return '$field est requis';
}
return null;
}
String? _validateEmail(String? value) {
final base = _required(value, 'Email');
if (base != null) return base;
final email = value!.trim();
final ok = RegExp(r'^[^@]+@[^@]+\.[^@]+$').hasMatch(email);
if (!ok) return 'Format email invalide';
return null;
}
String? _validatePassword(String? value) {
if (_isEditMode && (value == null || value.trim().isEmpty)) {
return null;
}
final base = _required(value, 'Mot de passe');
if (base != null) return base;
if (value!.trim().length < 6) return 'Minimum 6 caractères';
return null;
}
Future<void> _submit() async {
if (_isSubmitting) return;
if (!_formKey.currentState!.validate()) return;
setState(() {
_isSubmitting = true;
});
try {
if (_isEditMode) {
await UserService.updateAdmin(
adminId: widget.initialUser!.id,
nom: _nomController.text.trim(),
prenom: _prenomController.text.trim(),
email: _emailController.text.trim(),
telephone: _telephoneController.text.trim(),
password: _passwordController.text.trim().isEmpty
? null
: _passwordController.text,
);
} else {
await UserService.createAdmin(
nom: _nomController.text.trim(),
prenom: _prenomController.text.trim(),
email: _emailController.text.trim(),
password: _passwordController.text,
telephone: _telephoneController.text.trim(),
);
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
_isEditMode
? 'Administrateur modifié avec succès.'
: 'Administrateur créé avec succès.',
),
),
);
Navigator.of(context).pop(true);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
e.toString().replaceFirst('Exception: ', ''),
),
backgroundColor: Colors.red.shade700,
),
);
} finally {
if (!mounted) return;
setState(() {
_isSubmitting = false;
});
}
}
Future<void> _delete() async {
if (!_isEditMode || _isSubmitting) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) {
return AlertDialog(
title: const Text('Confirmer la suppression'),
content: Text(
'Supprimer ${widget.initialUser!.fullName.isEmpty ? widget.initialUser!.email : widget.initialUser!.fullName} ?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700),
child: const Text('Supprimer'),
),
],
);
},
);
if (confirmed != true) return;
setState(() {
_isSubmitting = true;
});
try {
await UserService.deleteUser(widget.initialUser!.id);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Administrateur supprimé.')),
);
Navigator.of(context).pop(true);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e.toString().replaceFirst('Exception: ', '')),
backgroundColor: Colors.red.shade700,
),
);
setState(() {
_isSubmitting = false;
});
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Row(
children: [
Expanded(
child: Text(
_isEditMode
? 'Modifier un administrateur'
: 'Créer un administrateur',
),
),
if (_isEditMode)
IconButton(
icon: const Icon(Icons.close),
tooltip: 'Fermer',
onPressed: _isSubmitting
? null
: () => Navigator.of(context).pop(false),
),
],
),
content: SizedBox(
width: 620,
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(child: _buildNomField()),
const SizedBox(width: 12),
Expanded(child: _buildPrenomField()),
],
),
const SizedBox(height: 12),
_buildEmailField(),
const SizedBox(height: 12),
Row(
children: [
Expanded(child: _buildPasswordField()),
const SizedBox(width: 12),
Expanded(child: _buildTelephoneField()),
],
),
],
),
),
),
),
actions: [
if (_isEditMode) ...[
OutlinedButton(
onPressed: _isSubmitting ? null : _delete,
style: OutlinedButton.styleFrom(foregroundColor: Colors.red.shade700),
child: const Text('Supprimer'),
),
FilledButton.icon(
onPressed: _isSubmitting ? null : _submit,
icon: _isSubmitting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.edit),
label: Text(_isSubmitting ? 'Modification...' : 'Modifier'),
),
] else ...[
OutlinedButton(
onPressed:
_isSubmitting ? null : () => Navigator.of(context).pop(false),
child: const Text('Annuler'),
),
FilledButton.icon(
onPressed: _isSubmitting ? null : _submit,
icon: _isSubmitting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.person_add_alt_1),
label: Text(_isSubmitting ? 'Création...' : 'Créer'),
),
],
],
);
}
Widget _buildNomField() {
return TextFormField(
controller: _nomController,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(
labelText: 'Nom',
border: OutlineInputBorder(),
),
validator: (v) => _required(v, 'Nom'),
);
}
Widget _buildPrenomField() {
return TextFormField(
controller: _prenomController,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(
labelText: 'Prénom',
border: OutlineInputBorder(),
),
validator: (v) => _required(v, 'Prénom'),
);
}
Widget _buildEmailField() {
return TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Email',
border: OutlineInputBorder(),
),
validator: _validateEmail,
);
}
Widget _buildPasswordField() {
return TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
enableSuggestions: false,
autocorrect: false,
autofillHints: _isEditMode
? const <String>[]
: const [AutofillHints.newPassword],
decoration: InputDecoration(
labelText: _isEditMode
? 'Nouveau mot de passe'
: 'Mot de passe',
border: const OutlineInputBorder(),
suffixIcon: IconButton(
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
icon: Icon(
_obscurePassword ? Icons.visibility_off : Icons.visibility,
),
),
),
validator: _validatePassword,
);
}
Widget _buildTelephoneField() {
return TextFormField(
controller: _telephoneController,
keyboardType: TextInputType.phone,
decoration: const InputDecoration(
labelText: 'Téléphone',
border: OutlineInputBorder(),
),
validator: (v) => _required(v, 'Téléphone'),
);
}
}
@@ -1,17 +1,688 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:p_tits_pas/models/relais_model.dart';
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/relais_service.dart';
import 'package:p_tits_pas/services/user_service.dart';
class GestionnairesCreate extends StatelessWidget { class AdminUserFormDialog extends StatefulWidget {
const GestionnairesCreate({super.key}); final AppUser? initialUser;
final bool withRelais;
final bool adminMode;
final bool readOnly;
const AdminUserFormDialog({
super.key,
this.initialUser,
this.withRelais = true,
this.adminMode = false,
this.readOnly = false,
});
@override
State<AdminUserFormDialog> createState() => _AdminUserFormDialogState();
}
class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
final _formKey = GlobalKey<FormState>();
final _nomController = TextEditingController();
final _prenomController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _telephoneController = TextEditingController();
final _passwordToggleFocusNode =
FocusNode(skipTraversal: true, canRequestFocus: false);
bool _isSubmitting = false;
bool _obscurePassword = true;
bool _isLoadingRelais = true;
List<RelaisModel> _relais = [];
String? _selectedRelaisId;
bool get _isEditMode => widget.initialUser != null;
bool get _isSuperAdminTarget =>
widget.initialUser?.role.toLowerCase() == 'super_admin';
bool get _isLockedAdminIdentity =>
_isEditMode && widget.adminMode && _isSuperAdminTarget;
String get _targetRoleKey {
if (widget.initialUser != null) {
return widget.initialUser!.role.toLowerCase();
}
return widget.adminMode ? 'administrateur' : 'gestionnaire';
}
String get _targetRoleLabel {
switch (_targetRoleKey) {
case 'super_admin':
return 'Super administrateur';
case 'administrateur':
return 'Administrateur';
case 'gestionnaire':
return 'Gestionnaire';
case 'assistante_maternelle':
return 'Assistante maternelle';
case 'parent':
return 'Parent';
default:
return 'Utilisateur';
}
}
IconData get _targetRoleIcon {
switch (_targetRoleKey) {
case 'super_admin':
return Icons.verified_user_outlined;
case 'administrateur':
return Icons.admin_panel_settings_outlined;
case 'gestionnaire':
return Icons.assignment_ind_outlined;
case 'assistante_maternelle':
return Icons.child_care_outlined;
case 'parent':
return Icons.supervisor_account_outlined;
default:
return Icons.person_outline;
}
}
@override
void initState() {
super.initState();
final user = widget.initialUser;
if (user != null) {
_nomController.text = user.nom ?? '';
_prenomController.text = user.prenom ?? '';
_emailController.text = user.email;
_telephoneController.text = _formatPhoneForDisplay(user.telephone ?? '');
// En édition, on ne préremplit jamais le mot de passe.
_passwordController.clear();
final initialRelaisId = user.relaisId?.trim();
_selectedRelaisId =
(initialRelaisId == null || initialRelaisId.isEmpty)
? null
: initialRelaisId;
}
if (widget.withRelais) {
_loadRelais();
} else {
_isLoadingRelais = false;
}
}
@override
void dispose() {
_nomController.dispose();
_prenomController.dispose();
_emailController.dispose();
_passwordController.dispose();
_telephoneController.dispose();
_passwordToggleFocusNode.dispose();
super.dispose();
}
Future<void> _loadRelais() async {
try {
final list = await RelaisService.getRelais();
if (!mounted) return;
final uniqueById = <String, RelaisModel>{};
for (final relais in list) {
uniqueById[relais.id] = relais;
}
final filtered = uniqueById.values.where((r) => r.actif).toList();
if (_selectedRelaisId != null &&
!filtered.any((r) => r.id == _selectedRelaisId)) {
final selected = uniqueById[_selectedRelaisId!];
if (selected != null) {
filtered.add(selected);
} else {
_selectedRelaisId = null;
}
}
setState(() {
_relais = filtered;
_isLoadingRelais = false;
});
} catch (_) {
if (!mounted) return;
setState(() {
_selectedRelaisId = null;
_relais = [];
_isLoadingRelais = false;
});
}
}
String? _required(String? value, String field) {
if (value == null || value.trim().isEmpty) {
return '$field est requis';
}
return null;
}
String? _validateEmail(String? value) {
final base = _required(value, 'Email');
if (base != null) return base;
final email = value!.trim();
final ok = RegExp(r'^[^@]+@[^@]+\.[^@]+$').hasMatch(email);
if (!ok) return 'Format email invalide';
return null;
}
String? _validatePassword(String? value) {
if (_isEditMode && (value == null || value.trim().isEmpty)) {
return null;
}
final base = _required(value, 'Mot de passe');
if (base != null) return base;
if (value!.trim().length < 6) return 'Minimum 6 caractères';
return null;
}
String? _validatePhone(String? value) {
if (_isEditMode && (value == null || value.trim().isEmpty)) {
return null;
}
final base = _required(value, 'Téléphone');
if (base != null) return base;
final digits = _normalizePhone(value!);
if (digits.length != 10) {
return 'Le téléphone doit contenir 10 chiffres';
}
if (!digits.startsWith('0')) {
return 'Le téléphone doit commencer par 0';
}
return null;
}
String _normalizePhone(String raw) {
return raw.replaceAll(RegExp(r'\D'), '');
}
String _formatPhoneForDisplay(String raw) {
final normalized = _normalizePhone(raw);
final digits =
normalized.length > 10 ? normalized.substring(0, 10) : normalized;
final buffer = StringBuffer();
for (var i = 0; i < digits.length; i++) {
if (i > 0 && i.isEven) buffer.write(' ');
buffer.write(digits[i]);
}
return buffer.toString();
}
String _toTitleCase(String raw) {
final trimmed = raw.trim();
if (trimmed.isEmpty) return trimmed;
final words = trimmed.split(RegExp(r'\s+'));
final normalizedWords = words.map(_capitalizeComposedWord).toList();
return normalizedWords.join(' ');
}
String _capitalizeComposedWord(String word) {
if (word.isEmpty) return word;
final lower = word.toLowerCase();
final separators = <String>{"-", "'", "’"};
final buffer = StringBuffer();
var capitalizeNext = true;
for (var i = 0; i < lower.length; i++) {
final char = lower[i];
if (capitalizeNext && RegExp(r'[a-zà-öø-ÿ]').hasMatch(char)) {
buffer.write(char.toUpperCase());
capitalizeNext = false;
} else {
buffer.write(char);
capitalizeNext = separators.contains(char);
}
}
return buffer.toString();
}
Future<void> _submit() async {
if (widget.readOnly) return;
if (_isSubmitting) return;
if (!_formKey.currentState!.validate()) return;
setState(() {
_isSubmitting = true;
});
try {
final normalizedNom = _toTitleCase(_nomController.text);
final normalizedPrenom = _toTitleCase(_prenomController.text);
final normalizedPhone = _normalizePhone(_telephoneController.text);
final passwordProvided = _passwordController.text.trim().isNotEmpty;
if (_isEditMode) {
if (widget.adminMode) {
final lockedNom = _toTitleCase(widget.initialUser!.nom ?? '');
final lockedPrenom = _toTitleCase(widget.initialUser!.prenom ?? '');
await UserService.updateAdministrateur(
adminId: widget.initialUser!.id,
nom: _isLockedAdminIdentity ? lockedNom : normalizedNom,
prenom: _isLockedAdminIdentity ? lockedPrenom : normalizedPrenom,
email: _emailController.text.trim(),
telephone: normalizedPhone.isEmpty
? _normalizePhone(widget.initialUser!.telephone ?? '')
: normalizedPhone,
password: passwordProvided ? _passwordController.text : null,
);
} else {
final currentUser = widget.initialUser!;
final initialNom = _toTitleCase(currentUser.nom ?? '');
final initialPrenom = _toTitleCase(currentUser.prenom ?? '');
final initialEmail = currentUser.email.trim();
final initialPhone = _normalizePhone(currentUser.telephone ?? '');
final onlyRelaisChanged =
normalizedNom == initialNom &&
normalizedPrenom == initialPrenom &&
_emailController.text.trim() == initialEmail &&
normalizedPhone == initialPhone &&
!passwordProvided;
if (onlyRelaisChanged) {
await UserService.updateGestionnaireRelais(
gestionnaireId: currentUser.id,
relaisId: _selectedRelaisId,
);
} else {
await UserService.updateGestionnaire(
gestionnaireId: currentUser.id,
nom: normalizedNom,
prenom: normalizedPrenom,
email: _emailController.text.trim(),
telephone: normalizedPhone.isEmpty ? initialPhone : normalizedPhone,
relaisId: _selectedRelaisId,
password: passwordProvided ? _passwordController.text : null,
);
}
}
} else {
if (widget.adminMode) {
await UserService.createAdministrateur(
nom: normalizedNom,
prenom: normalizedPrenom,
email: _emailController.text.trim(),
password: _passwordController.text,
telephone: _normalizePhone(_telephoneController.text),
);
} else {
await UserService.createGestionnaire(
nom: normalizedNom,
prenom: normalizedPrenom,
email: _emailController.text.trim(),
password: _passwordController.text,
telephone: _normalizePhone(_telephoneController.text),
relaisId: _selectedRelaisId,
);
}
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
_isEditMode
? (widget.adminMode
? 'Administrateur modifié avec succès.'
: 'Gestionnaire modifié avec succès.')
: (widget.adminMode
? 'Administrateur créé avec succès.'
: 'Gestionnaire créé avec succès.'),
),
),
);
Navigator.of(context).pop(true);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
e.toString().replaceFirst('Exception: ', ''),
),
backgroundColor: Colors.red.shade700,
),
);
} finally {
if (!mounted) return;
setState(() {
_isSubmitting = false;
});
}
}
Future<void> _delete() async {
if (widget.readOnly) return;
if (_isSuperAdminTarget) return;
if (!_isEditMode || _isSubmitting) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) {
return AlertDialog(
title: const Text('Confirmer la suppression'),
content: Text(
'Supprimer ${widget.initialUser!.fullName.isEmpty ? widget.initialUser!.email : widget.initialUser!.fullName} ?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700),
child: const Text('Supprimer'),
),
],
);
},
);
if (confirmed != true) return;
setState(() {
_isSubmitting = true;
});
try {
await UserService.deleteUser(widget.initialUser!.id);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Gestionnaire supprimé.')),
);
Navigator.of(context).pop(true);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e.toString().replaceFirst('Exception: ', '')),
backgroundColor: Colors.red.shade700,
),
);
setState(() {
_isSubmitting = false;
});
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return AlertDialog(
appBar: AppBar( title: Row(
title: const Text('Créer un gestionnaire'), children: [
CircleAvatar(
radius: 16,
backgroundColor: const Color(0xFFEDE5FA),
child: Icon(
_targetRoleIcon,
size: 20,
color: const Color(0xFF6B3FA0),
), ),
body: const Center(
child: Text('Formulaire de création de gestionnaire'),
), ),
const SizedBox(width: 8),
Expanded(
child: Text(
_isEditMode
? (widget.readOnly
? 'Consulter un "$_targetRoleLabel"'
: 'Modifier un "$_targetRoleLabel"')
: 'Créer un "$_targetRoleLabel"',
),
),
if (_isEditMode && !widget.readOnly)
IconButton(
icon: const Icon(Icons.close),
tooltip: 'Fermer',
onPressed: _isSubmitting
? null
: () => Navigator.of(context).pop(false),
),
],
),
content: SizedBox(
width: 620,
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(child: _buildPrenomField()),
const SizedBox(width: 12),
Expanded(child: _buildNomField()),
],
),
const SizedBox(height: 12),
_buildEmailField(),
const SizedBox(height: 12),
Row(
children: [
Expanded(child: _buildPasswordField()),
const SizedBox(width: 12),
Expanded(child: _buildTelephoneField()),
],
),
if (widget.withRelais) ...[
const SizedBox(height: 12),
_buildRelaisField(),
],
],
),
),
),
),
actions: [
if (widget.readOnly) ...[
FilledButton(
onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(false),
child: const Text('Fermer'),
),
] else if (_isEditMode) ...[
if (!_isSuperAdminTarget)
OutlinedButton(
onPressed: _isSubmitting ? null : _delete,
style: OutlinedButton.styleFrom(foregroundColor: Colors.red.shade700),
child: const Text('Supprimer'),
),
FilledButton.icon(
onPressed: _isSubmitting ? null : _submit,
icon: _isSubmitting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.edit),
label: Text(_isSubmitting ? 'Modification...' : 'Modifier'),
),
] else ...[
OutlinedButton(
onPressed:
_isSubmitting ? null : () => Navigator.of(context).pop(false),
child: const Text('Annuler'),
),
FilledButton.icon(
onPressed: _isSubmitting ? null : _submit,
icon: _isSubmitting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.person_add_alt_1),
label: Text(_isSubmitting ? 'Création...' : 'Créer'),
),
],
],
);
}
Widget _buildNomField() {
return TextFormField(
controller: _nomController,
readOnly: widget.readOnly || _isLockedAdminIdentity,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(
labelText: 'Nom',
border: OutlineInputBorder(),
),
validator: (widget.readOnly || _isLockedAdminIdentity)
? null
: (v) => _required(v, 'Nom'),
);
}
Widget _buildPrenomField() {
return TextFormField(
controller: _prenomController,
readOnly: widget.readOnly || _isLockedAdminIdentity,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(
labelText: 'Prénom',
border: OutlineInputBorder(),
),
validator: (widget.readOnly || _isLockedAdminIdentity)
? null
: (v) => _required(v, 'Prénom'),
);
}
Widget _buildEmailField() {
return TextFormField(
controller: _emailController,
readOnly: widget.readOnly,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Email',
border: OutlineInputBorder(),
),
validator: widget.readOnly ? null : _validateEmail,
);
}
Widget _buildPasswordField() {
return TextFormField(
controller: _passwordController,
readOnly: widget.readOnly,
obscureText: _obscurePassword,
enableSuggestions: false,
autocorrect: false,
autofillHints: _isEditMode
? const <String>[]
: const [AutofillHints.newPassword],
decoration: InputDecoration(
labelText: _isEditMode
? 'Nouveau mot de passe'
: 'Mot de passe',
border: const OutlineInputBorder(),
suffixIcon: widget.readOnly
? null
: ExcludeFocus(
child: IconButton(
focusNode: _passwordToggleFocusNode,
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
icon: Icon(
_obscurePassword ? Icons.visibility_off : Icons.visibility,
),
),
),
),
validator: widget.readOnly ? null : _validatePassword,
);
}
Widget _buildTelephoneField() {
return TextFormField(
controller: _telephoneController,
readOnly: widget.readOnly,
keyboardType: TextInputType.phone,
inputFormatters: widget.readOnly
? null
: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
_FrenchPhoneNumberFormatter(),
],
decoration: const InputDecoration(
labelText: 'Téléphone (ex: 06 12 34 56 78)',
border: OutlineInputBorder(),
),
validator: widget.readOnly ? null : _validatePhone,
);
}
Widget _buildRelaisField() {
final selectedValue = _selectedRelaisId != null &&
_relais.any((relais) => relais.id == _selectedRelaisId)
? _selectedRelaisId
: null;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
DropdownButtonFormField<String?>(
isExpanded: true,
value: selectedValue,
decoration: const InputDecoration(
labelText: 'Relais principal',
border: OutlineInputBorder(),
),
items: [
const DropdownMenuItem<String?>(
value: null,
child: Text('Aucun relais'),
),
..._relais.map(
(relais) => DropdownMenuItem<String?>(
value: relais.id,
child: Text(relais.nom),
),
),
],
onChanged: (_isLoadingRelais || widget.readOnly)
? null
: (value) {
setState(() {
_selectedRelaisId = value;
});
},
),
if (_isLoadingRelais) ...[
const SizedBox(height: 8),
const LinearProgressIndicator(minHeight: 2),
],
],
);
}
}
class _FrenchPhoneNumberFormatter extends TextInputFormatter {
const _FrenchPhoneNumberFormatter();
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
final digits = newValue.text.replaceAll(RegExp(r'\D'), '');
final normalized = digits.length > 10 ? digits.substring(0, 10) : digits;
final buffer = StringBuffer();
for (var i = 0; i < normalized.length; i++) {
if (i > 0 && i.isEven) buffer.write(' ');
buffer.write(normalized[i]);
}
final formatted = buffer.toString();
return TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(offset: formatted.length),
); );
} }
} }
+66 -12
View File
@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@@ -63,6 +64,11 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
return null; return null;
} }
void _handlePasswordSubmitted(String _) {
if (_isLoading) return;
_handleLogin();
}
/// Gère la connexion de l'utilisateur /// Gère la connexion de l'utilisateur
Future<void> _handleLogin() async { Future<void> _handleLogin() async {
// Réinitialiser le message d'erreur // Réinitialiser le message d'erreur
@@ -106,6 +112,9 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
if (!mounted) return; if (!mounted) return;
// Laisse au navigateur/OS la possibilité de mémoriser les identifiants.
TextInput.finishAutofillContext(shouldSave: true);
// Rediriger selon le rôle de l'utilisateur // Rediriger selon le rôle de l'utilisateur
_redirectUserByRole(user.role); _redirectUserByRole(user.role);
} catch (e) { } catch (e) {
@@ -122,9 +131,11 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
switch (role.toLowerCase()) { switch (role.toLowerCase()) {
case 'super_admin': case 'super_admin':
case 'administrateur': case 'administrateur':
case 'gestionnaire':
context.go('/admin-dashboard'); context.go('/admin-dashboard');
break; break;
case 'gestionnaire':
context.go('/gestionnaire-dashboard');
break;
case 'parent': case 'parent':
context.go('/parent-dashboard'); context.go('/parent-dashboard');
break; break;
@@ -160,7 +171,8 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
final imageDimensions = snapshot.data!; final imageDimensions = snapshot.data!;
final imageHeight = h; final imageHeight = h;
final imageWidth = imageHeight * (imageDimensions.width / imageDimensions.height); final imageWidth = imageHeight *
(imageDimensions.width / imageDimensions.height);
final remainingWidth = w - imageWidth; final remainingWidth = w - imageWidth;
final leftMargin = remainingWidth / 4; final leftMargin = remainingWidth / 4;
@@ -193,6 +205,7 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
height: h * 0.5, // 50% de la hauteur de l'écran height: h * 0.5, // 50% de la hauteur de l'écran
child: Padding( child: Padding(
padding: EdgeInsets.all(w * 0.02), // 2% de padding padding: EdgeInsets.all(w * 0.02), // 2% de padding
child: AutofillGroup(
child: Form( child: Form(
key: _formKey, key: _formKey,
child: Column( child: Column(
@@ -207,6 +220,12 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
controller: _emailController, controller: _emailController,
labelText: 'Email', labelText: 'Email',
hintText: 'Votre adresse email', hintText: 'Votre adresse email',
keyboardType: TextInputType.emailAddress,
autofillHints: const [
AutofillHints.username,
AutofillHints.email,
],
textInputAction: TextInputAction.next,
validator: _validateEmail, validator: _validateEmail,
style: CustomAppTextFieldStyle.lavande, style: CustomAppTextFieldStyle.lavande,
fieldHeight: 53, fieldHeight: 53,
@@ -220,6 +239,12 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
labelText: 'Mot de passe', labelText: 'Mot de passe',
hintText: 'Votre mot de passe', hintText: 'Votre mot de passe',
obscureText: true, obscureText: true,
autofillHints: const [
AutofillHints.password
],
textInputAction: TextInputAction.done,
onFieldSubmitted:
_handlePasswordSubmitted,
validator: _validatePassword, validator: _validatePassword,
style: CustomAppTextFieldStyle.jaune, style: CustomAppTextFieldStyle.jaune,
fieldHeight: 53, fieldHeight: 53,
@@ -242,7 +267,8 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
), ),
child: Row( child: Row(
children: [ children: [
Icon(Icons.error_outline, color: Colors.red[700], size: 20), Icon(Icons.error_outline,
color: Colors.red[700], size: 20),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
child: Text( child: Text(
@@ -309,6 +335,7 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
), ),
), ),
), ),
),
// Pied de page (Wrap pour éviter overflow sur petite largeur) // Pied de page (Wrap pour éviter overflow sur petite largeur)
Positioned( Positioned(
left: 0, left: 0,
@@ -378,6 +405,7 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
/// Dimensions de river_logo_mobile.png (à mettre à jour si l'asset change). /// Dimensions de river_logo_mobile.png (à mettre à jour si l'asset change).
static const int _riverLogoMobileWidth = 600; static const int _riverLogoMobileWidth = 600;
static const int _riverLogoMobileHeight = 1080; static const int _riverLogoMobileHeight = 1080;
/// Fraction de la hauteur de l'image où se termine visuellement le slogan (0 = haut, 1 = bas). /// 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 _sloganEndFraction = 0.42;
static const double _gapBelowSlogan = 12.0; static const double _gapBelowSlogan = 12.0;
@@ -388,7 +416,8 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
final h = constraints.maxHeight; final h = constraints.maxHeight;
final w = constraints.maxWidth; final w = constraints.maxWidth;
final imageAspectRatio = _riverLogoMobileHeight / _riverLogoMobileWidth; final imageAspectRatio = _riverLogoMobileHeight / _riverLogoMobileWidth;
final formTop = w * imageAspectRatio * _sloganEndFraction + _gapBelowSlogan; final formTop =
w * imageAspectRatio * _sloganEndFraction + _gapBelowSlogan;
return Stack( return Stack(
clipBehavior: Clip.none, clipBehavior: Clip.none,
children: [ children: [
@@ -428,9 +457,11 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
children: [ children: [
Expanded( Expanded(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20), padding: const EdgeInsets.symmetric(
horizontal: 24, vertical: 20),
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400), constraints: const BoxConstraints(maxWidth: 400),
child: AutofillGroup(
child: Form( child: Form(
key: _formKey, key: _formKey,
child: Column( child: Column(
@@ -442,6 +473,12 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
labelText: 'Email', labelText: 'Email',
showLabel: false, showLabel: false,
hintText: 'Votre adresse email', hintText: 'Votre adresse email',
keyboardType: TextInputType.emailAddress,
autofillHints: const [
AutofillHints.username,
AutofillHints.email,
],
textInputAction: TextInputAction.next,
validator: _validateEmail, validator: _validateEmail,
style: CustomAppTextFieldStyle.lavande, style: CustomAppTextFieldStyle.lavande,
fieldHeight: 48, fieldHeight: 48,
@@ -454,6 +491,11 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
showLabel: false, showLabel: false,
hintText: 'Votre mot de passe', hintText: 'Votre mot de passe',
obscureText: true, obscureText: true,
autofillHints: const [
AutofillHints.password
],
textInputAction: TextInputAction.done,
onFieldSubmitted: _handlePasswordSubmitted,
validator: _validatePassword, validator: _validatePassword,
style: CustomAppTextFieldStyle.jaune, style: CustomAppTextFieldStyle.jaune,
fieldHeight: 48, fieldHeight: 48,
@@ -466,16 +508,21 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.red.shade50, color: Colors.red.shade50,
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.red.shade300), border: Border.all(
color: Colors.red.shade300),
), ),
child: Row( child: Row(
children: [ children: [
Icon(Icons.error_outline, color: Colors.red.shade700, size: 20), Icon(Icons.error_outline,
color: Colors.red.shade700,
size: 20),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
child: Text( child: Text(
_errorMessage!, _errorMessage!,
style: GoogleFonts.merienda(fontSize: 12, color: Colors.red.shade700), style: GoogleFonts.merienda(
fontSize: 12,
color: Colors.red.shade700),
), ),
), ),
], ],
@@ -495,7 +542,7 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
TextButton( TextButton(
onPressed: () { /* TODO */ }, onPressed: () {/* TODO */},
child: Text( child: Text(
'Mot de passe oublié ?', 'Mot de passe oublié ?',
style: GoogleFonts.merienda( style: GoogleFonts.merienda(
@@ -506,7 +553,8 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
), ),
), ),
TextButton( TextButton(
onPressed: () => context.go('/register-choice'), onPressed: () =>
context.go('/register-choice'),
child: Text( child: Text(
'Créer un compte', 'Créer un compte',
style: GoogleFonts.merienda( style: GoogleFonts.merienda(
@@ -522,6 +570,7 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
), ),
), ),
), ),
),
Padding( Padding(
padding: const EdgeInsets.only(bottom: 12, top: 8), padding: const EdgeInsets.only(bottom: 12, top: 8),
child: Wrap( child: Wrap(
@@ -533,12 +582,17 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
text: 'Contact support', text: 'Contact support',
fontSize: 11, fontSize: 11,
onTap: () async { onTap: () async {
final uri = Uri(scheme: 'mailto', path: 'support@supernounou.local'); final uri = Uri(
scheme: 'mailto',
path: 'support@supernounou.local');
if (await canLaunchUrl(uri)) { if (await canLaunchUrl(uri)) {
await launchUrl(uri); await launchUrl(uri);
} else if (context.mounted) { } else if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Impossible d\'ouvrir le client mail', style: GoogleFonts.merienda())), SnackBar(
content: Text(
'Impossible d\'ouvrir le client mail',
style: GoogleFonts.merienda())),
); );
} }
}, },
@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart';
import 'package:p_tits_pas/widgets/admin/user_management_panel.dart';
import 'package:p_tits_pas/widgets/app_footer.dart';
/// Dashboard gestionnaire – même shell que l'admin, sans onglet Paramètres.
/// Réutilise [UserManagementPanel].
class GestionnaireDashboardScreen extends StatefulWidget {
const GestionnaireDashboardScreen({super.key});
@override
State<GestionnaireDashboardScreen> createState() =>
_GestionnaireDashboardScreenState();
}
class _GestionnaireDashboardScreenState extends State<GestionnaireDashboardScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: PreferredSize(
preferredSize: const Size.fromHeight(60.0),
child: Container(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: Colors.grey.shade300),
),
),
child: DashboardAppBarAdmin(
selectedIndex: 0,
onTabChange: (_) {},
showSettingsTab: false,
roleLabel: 'Gestionnaire',
setupCompleted: true,
),
),
),
body: Column(
children: [
Expanded(
child: UserManagementPanel(showAdministrateursTab: false),
),
const AppFooter(),
],
),
);
}
}
+6 -1
View File
@@ -15,11 +15,16 @@ class ApiConfig {
static const String users = '/users'; static const String users = '/users';
static const String userProfile = '/users/profile'; static const String userProfile = '/users/profile';
static const String userChildren = '/users/children'; static const String userChildren = '/users/children';
static const String gestionnaires = '/gestionnaires';
static const String parents = '/parents';
static const String assistantesMaternelles = '/assistantes-maternelles';
static const String relais = '/relais';
// Configuration (admin) // Configuration (admin)
static const String configuration = '/configuration'; static const String configuration = '/configuration';
static const String configurationSetupStatus = '/configuration/setup/status'; static const String configurationSetupStatus = '/configuration/setup/status';
static const String configurationSetupComplete = '/configuration/setup/complete'; static const String configurationSetupComplete =
'/configuration/setup/complete';
static const String configurationTestSmtp = '/configuration/test-smtp'; static const String configurationTestSmtp = '/configuration/test-smtp';
static const String configurationBulk = '/configuration/bulk'; static const String configurationBulk = '/configuration/bulk';
+97
View File
@@ -0,0 +1,97 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:p_tits_pas/models/relais_model.dart';
import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/services/api/tokenService.dart';
class RelaisService {
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 _extractError(String body, String fallback) {
try {
final decoded = jsonDecode(body);
if (decoded is Map<String, dynamic>) {
final message = decoded['message'];
if (message is String && message.trim().isNotEmpty) {
return message;
}
}
} catch (_) {}
return fallback;
}
static Future<List<RelaisModel>> getRelais() async {
final response = await http.get(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.relais}'),
headers: await _headers(),
);
if (response.statusCode != 200) {
throw Exception(
_extractError(response.body, 'Erreur chargement relais'),
);
}
final List<dynamic> data = jsonDecode(response.body);
return data
.whereType<Map<String, dynamic>>()
.map(RelaisModel.fromJson)
.toList();
}
static Future<RelaisModel> createRelais(Map<String, dynamic> payload) async {
final response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.relais}'),
headers: await _headers(),
body: jsonEncode(payload),
);
if (response.statusCode != 201 && response.statusCode != 200) {
throw Exception(
_extractError(response.body, 'Erreur création relais'),
);
}
return RelaisModel.fromJson(
jsonDecode(response.body) as Map<String, dynamic>);
}
static Future<RelaisModel> updateRelais(
String id,
Map<String, dynamic> payload,
) async {
final response = await http.patch(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.relais}/$id'),
headers: await _headers(),
body: jsonEncode(payload),
);
if (response.statusCode != 200) {
throw Exception(
_extractError(response.body, 'Erreur mise à jour relais'),
);
}
return RelaisModel.fromJson(
jsonDecode(response.body) as Map<String, dynamic>);
}
static Future<void> deleteRelais(String id) async {
final response = await http.delete(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.relais}/$id'),
headers: await _headers(),
);
if (response.statusCode != 200 && response.statusCode != 204) {
throw Exception(
_extractError(response.body, 'Erreur suppression relais'),
);
}
}
}
+373
View File
@@ -0,0 +1,373 @@
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();
}
static Future<AppUser> createGestionnaire({
required String nom,
required String prenom,
required String email,
required String password,
required String telephone,
String? relaisId,
}) async {
final response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.gestionnaires}'),
headers: await _headers(),
body: jsonEncode(<String, dynamic>{
'nom': nom,
'prenom': prenom,
'email': email,
'password': password,
'telephone': telephone,
'cguAccepted': true,
'relaisId': relaisId,
}),
);
if (response.statusCode != 200 && response.statusCode != 201) {
final decoded = jsonDecode(response.body);
if (decoded is Map<String, dynamic>) {
final message = decoded['message'];
if (message is List && message.isNotEmpty) {
throw Exception(message.join(' - '));
}
throw Exception(_toStr(message) ?? 'Erreur création gestionnaire');
}
throw Exception('Erreur création gestionnaire');
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
return AppUser.fromJson(data);
}
static Future<AppUser> createAdministrateur({
required String nom,
required String prenom,
required String email,
required String password,
required String telephone,
}) async {
final response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/admin'),
headers: await _headers(),
body: jsonEncode(<String, dynamic>{
'nom': nom,
'prenom': prenom,
'email': email,
'password': password,
'telephone': telephone,
}),
);
if (response.statusCode != 200 && response.statusCode != 201) {
final decoded = jsonDecode(response.body);
if (decoded is Map<String, dynamic>) {
final message = decoded['message'];
if (message is List && message.isNotEmpty) {
throw Exception(message.join(' - '));
}
throw Exception(_toStr(message) ?? 'Erreur création administrateur');
}
throw Exception('Erreur création administrateur');
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
return AppUser.fromJson(data);
}
// 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) {
// On garde un fallback vide pour ne pas bloquer l'UI admin.
}
return [];
}
static Future<AppUser> createAdmin({
required String nom,
required String prenom,
required String email,
required String password,
required String telephone,
}) async {
final response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/admin'),
headers: await _headers(),
body: jsonEncode(<String, dynamic>{
'nom': nom,
'prenom': prenom,
'email': email,
'password': password,
'telephone': telephone,
}),
);
if (response.statusCode != 200 && response.statusCode != 201) {
final decoded = jsonDecode(response.body);
if (decoded is Map<String, dynamic>) {
final message = decoded['message'];
if (message is List && message.isNotEmpty) {
throw Exception(message.join(' - '));
}
throw Exception(_toStr(message) ?? 'Erreur création administrateur');
}
throw Exception('Erreur création administrateur');
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
return AppUser.fromJson(data);
}
static Future<AppUser> updateAdmin({
required String adminId,
required String nom,
required String prenom,
required String email,
required String telephone,
String? password,
}) async {
final body = <String, dynamic>{
'nom': nom,
'prenom': prenom,
'email': email,
'telephone': telephone,
};
if (password != null && password.trim().isNotEmpty) {
body['password'] = password.trim();
}
final response = await http.patch(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$adminId'),
headers: await _headers(),
body: jsonEncode(body),
);
if (response.statusCode != 200) {
final decoded = jsonDecode(response.body);
if (decoded is Map<String, dynamic>) {
final message = decoded['message'];
if (message is List && message.isNotEmpty) {
throw Exception(message.join(' - '));
}
throw Exception(_toStr(message) ?? 'Erreur modification administrateur');
}
throw Exception('Erreur modification administrateur');
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
return AppUser.fromJson(data);
}
static Future<void> updateGestionnaireRelais({
required String gestionnaireId,
required String? relaisId,
}) async {
final response = await http.patch(
Uri.parse(
'${ApiConfig.baseUrl}${ApiConfig.gestionnaires}/$gestionnaireId'),
headers: await _headers(),
body: jsonEncode(<String, dynamic>{'relaisId': relaisId}),
);
if (response.statusCode != 200 && response.statusCode != 204) {
final err = jsonDecode(response.body) as Map<String, dynamic>?;
throw Exception(
_toStr(err?['message']) ?? 'Erreur rattachement relais au gestionnaire',
);
}
}
static Future<AppUser> updateGestionnaire({
required String gestionnaireId,
required String nom,
required String prenom,
required String email,
String? telephone,
required String? relaisId,
String? password,
}) async {
final body = <String, dynamic>{
'nom': nom,
'prenom': prenom,
'email': email,
'relaisId': relaisId,
};
if (telephone != null && telephone.trim().isNotEmpty) {
body['telephone'] = telephone.trim();
}
if (password != null && password.trim().isNotEmpty) {
body['password'] = password.trim();
}
final response = await http.patch(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.gestionnaires}/$gestionnaireId'),
headers: await _headers(),
body: jsonEncode(body),
);
if (response.statusCode != 200) {
final decoded = jsonDecode(response.body);
if (decoded is Map<String, dynamic>) {
final message = decoded['message'];
if (message is List && message.isNotEmpty) {
throw Exception(message.join(' - '));
}
throw Exception(_toStr(message) ?? 'Erreur modification gestionnaire');
}
throw Exception('Erreur modification gestionnaire');
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
return AppUser.fromJson(data);
}
static Future<AppUser> updateAdministrateur({
required String adminId,
required String nom,
required String prenom,
required String email,
String? telephone,
String? password,
}) async {
final body = <String, dynamic>{
'nom': nom,
'prenom': prenom,
'email': email,
};
if (telephone != null && telephone.trim().isNotEmpty) {
body['telephone'] = telephone.trim();
}
if (password != null && password.trim().isNotEmpty) {
body['password'] = password.trim();
}
final response = await http.patch(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$adminId'),
headers: await _headers(),
body: jsonEncode(body),
);
if (response.statusCode != 200) {
final decoded = jsonDecode(response.body);
if (decoded is Map<String, dynamic>) {
final message = decoded['message'];
if (message is List && message.isNotEmpty) {
throw Exception(message.join(' - '));
}
throw Exception(_toStr(message) ?? 'Erreur modification administrateur');
}
throw Exception('Erreur modification administrateur');
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
return AppUser.fromJson(data);
}
static Future<void> deleteUser(String userId) async {
final response = await http.delete(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$userId'),
headers: await _headers(),
);
if (response.statusCode != 200 && response.statusCode != 204) {
final decoded = jsonDecode(response.body);
if (decoded is Map<String, dynamic>) {
final message = decoded['message'];
if (message is List && message.isNotEmpty) {
throw Exception(message.join(' - '));
}
throw Exception(_toStr(message) ?? 'Erreur suppression utilisateur');
}
throw Exception('Erreur suppression utilisateur');
}
}
}
@@ -0,0 +1,154 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart';
import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
class AdminManagementWidget extends StatefulWidget {
final String searchQuery;
const AdminManagementWidget({
super.key,
required this.searchQuery,
});
@override
State<AdminManagementWidget> createState() => _AdminManagementWidgetState();
}
class _AdminManagementWidgetState extends State<AdminManagementWidget> {
bool _isLoading = false;
String? _error;
List<AppUser> _admins = [];
String? _currentUserRole;
@override
void initState() {
super.initState();
_loadCurrentUserRole();
_loadAdmins();
}
@override
void dispose() => super.dispose();
Future<void> _loadAdmins() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final list = await UserService.getAdministrateurs();
if (!mounted) return;
setState(() {
_admins = list;
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
Future<void> _loadCurrentUserRole() async {
final cached = await AuthService.getCurrentUser();
if (!mounted) return;
if (cached != null) {
setState(() {
_currentUserRole = cached.role.toLowerCase();
});
return;
}
final refreshed = await AuthService.refreshCurrentUser();
if (!mounted || refreshed == null) return;
setState(() {
_currentUserRole = refreshed.role.toLowerCase();
});
}
bool _isSuperAdmin(AppUser user) => user.role.toLowerCase() == 'super_admin';
bool _canEditAdmin(AppUser target) {
if (!_isSuperAdmin(target)) return true;
return _currentUserRole == 'super_admin';
}
Future<void> _openAdminEditDialog(AppUser user) async {
final canEdit = _canEditAdmin(user);
final changed = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
return AdminUserFormDialog(
initialUser: user,
adminMode: true,
withRelais: false,
readOnly: !canEdit,
);
},
);
if (changed == true && canEdit) {
await _loadAdmins();
}
}
@override
Widget build(BuildContext context) {
final query = widget.searchQuery.toLowerCase();
final filteredAdmins = _admins.where((u) {
final name = u.fullName.toLowerCase();
final email = u.email.toLowerCase();
return name.contains(query) || email.contains(query);
}).toList();
return UserList(
isLoading: _isLoading,
error: _error,
isEmpty: filteredAdmins.isEmpty,
emptyMessage: 'Aucun administrateur trouvé.',
itemCount: filteredAdmins.length,
itemBuilder: (context, index) {
final user = filteredAdmins[index];
final isSuperAdmin = _isSuperAdmin(user);
final canEdit = _canEditAdmin(user);
return AdminUserCard(
title: user.fullName,
fallbackIcon: isSuperAdmin
? Icons.verified_user_outlined
: Icons.manage_accounts_outlined,
subtitleLines: [
user.email,
'Téléphone : ${user.telephone?.trim().isNotEmpty == true ? user.telephone : 'Non renseigné'}',
],
avatarUrl: user.photoUrl,
borderColor: isSuperAdmin
? const Color(0xFF8E6AC8)
: Colors.grey.shade300,
backgroundColor: isSuperAdmin
? const Color(0xFFF4EEFF)
: Colors.white,
titleColor: isSuperAdmin ? const Color(0xFF5D2F99) : null,
infoColor: isSuperAdmin
? const Color(0xFF6D4EA1)
: Colors.black54,
actions: [
IconButton(
icon: Icon(
canEdit ? Icons.edit_outlined : Icons.visibility_outlined,
),
tooltip: canEdit ? 'Modifier' : 'Consulter',
onPressed: () {
_openAdminEditDialog(user);
},
),
],
);
},
);
}
}
@@ -1,106 +1,155 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
class AssistanteMaternelleManagementWidget extends StatelessWidget { class AssistanteMaternelleManagementWidget extends StatefulWidget {
const AssistanteMaternelleManagementWidget({super.key}); final String searchQuery;
final int? capacityMin;
const AssistanteMaternelleManagementWidget({
super.key,
required this.searchQuery,
this.capacityMin,
});
@override
State<AssistanteMaternelleManagementWidget> createState() =>
_AssistanteMaternelleManagementWidgetState();
}
class _AssistanteMaternelleManagementWidgetState
extends State<AssistanteMaternelleManagementWidget> {
bool _isLoading = false;
String? _error;
List<AssistanteMaternelleModel> _assistantes = [];
@override
void initState() {
super.initState();
_loadAssistantes();
}
@override
void dispose() => super.dispose();
Future<void> _loadAssistantes() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final list = await UserService.getAssistantesMaternelles();
if (!mounted) return;
setState(() {
_assistantes = list;
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final assistantes = [ final query = widget.searchQuery.toLowerCase();
{ final filteredAssistantes = _assistantes.where((am) {
"nom": "Marie Dupont", final matchesName = am.user.fullName.toLowerCase().contains(query) ||
"numeroAgrement": "AG123456", am.user.email.toLowerCase().contains(query) ||
"zone": "Paris 14", (am.residenceCity?.toLowerCase().contains(query) ?? false);
"capacite": 3, final matchesCapacity = widget.capacityMin == null ||
}, (am.maxChildren != null && am.maxChildren! >= widget.capacityMin!);
{ return matchesName && matchesCapacity;
"nom": "Claire Martin", }).toList();
"numeroAgrement": "AG654321",
"zone": "Lyon 7",
"capacite": 2,
},
];
return Padding( return UserList(
padding: const EdgeInsets.all(16), isLoading: _isLoading,
child: Column( error: _error,
crossAxisAlignment: CrossAxisAlignment.start, isEmpty: filteredAssistantes.isEmpty,
children: [ emptyMessage: 'Aucune assistante maternelle trouvée.',
// 🔎 Zone de filtre itemCount: filteredAssistantes.length,
_buildFilterSection(),
const SizedBox(height: 16),
// 📋 Liste des assistantes
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: assistantes.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final assistante = assistantes[index]; final assistante = filteredAssistantes[index];
return Card( return AdminUserCard(
margin: const EdgeInsets.symmetric(vertical: 8), title: assistante.user.fullName,
child: ListTile( avatarUrl: assistante.user.photoUrl,
leading: const Icon(Icons.face), fallbackIcon: Icons.face,
title: Text(assistante['nom'].toString()), subtitleLines: [
subtitle: Text( assistante.user.email,
"N° Agrément : ${assistante['numeroAgrement']}\nZone : ${assistante['zone']} | Capacité : ${assistante['capacite']}"), 'Zone : ${assistante.residenceCity ?? 'N/A'} | Capacité : ${assistante.maxChildren ?? 0}',
trailing: Row( ],
mainAxisSize: MainAxisSize.min, actions: [
children: [
IconButton( IconButton(
icon: const Icon(Icons.edit), icon: const Icon(Icons.edit),
tooltip: 'Modifier',
onPressed: () { onPressed: () {
// TODO: Ajouter modification _openAssistanteDetails(assistante);
},
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: () {
// TODO: Ajouter suppression
}, },
), ),
], ],
),
),
); );
}, },
);
}
void _openAssistanteDetails(AssistanteMaternelleModel assistante) {
showDialog<void>(
context: context,
builder: (context) => AdminDetailModal(
title: assistante.user.fullName.isEmpty
? 'Assistante maternelle'
: assistante.user.fullName,
subtitle: assistante.user.email,
fields: [
AdminDetailField(label: 'ID', value: _v(assistante.user.id)),
AdminDetailField(
label: 'Numero agrement',
value: _v(assistante.approvalNumber),
),
AdminDetailField(
label: 'Ville residence',
value: _v(assistante.residenceCity),
),
AdminDetailField(
label: 'Capacite max',
value: assistante.maxChildren?.toString() ?? '-',
),
AdminDetailField(
label: 'Places disponibles',
value: assistante.placesAvailable?.toString() ?? '-',
),
AdminDetailField(
label: 'Telephone',
value: _v(assistante.user.telephone),
),
AdminDetailField(label: 'Adresse', value: _v(assistante.user.adresse)),
AdminDetailField(label: 'Ville', value: _v(assistante.user.ville)),
AdminDetailField(
label: 'Code postal',
value: _v(assistante.user.codePostal),
), ),
], ],
onEdit: () {
Navigator.of(context).pop();
ScaffoldMessenger.of(this.context).showSnackBar(
const SnackBar(content: Text('Action Modifier a implementer')),
);
},
onDelete: () {
Navigator.of(context).pop();
ScaffoldMessenger.of(this.context).showSnackBar(
const SnackBar(content: Text('Action Supprimer a implementer')),
);
},
), ),
); );
} }
Widget _buildFilterSection() { String _v(String? value) => (value == null || value.isEmpty) ? '-' : value;
return Wrap(
spacing: 16,
runSpacing: 8,
children: [
SizedBox(
width: 200,
child: TextField(
decoration: const InputDecoration(
labelText: "Zone géographique",
border: OutlineInputBorder(),
),
onChanged: (value) {
// TODO: Ajouter logique de filtrage par zone
},
),
),
SizedBox(
width: 200,
child: TextField(
decoration: const InputDecoration(
labelText: "Capacité minimum",
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
onChanged: (value) {
// TODO: Ajouter logique de filtrage par capacité
},
),
),
],
);
}
} }
@@ -0,0 +1,138 @@
import 'package:flutter/material.dart';
class AdminDetailField {
final String label;
final String value;
const AdminDetailField({
required this.label,
required this.value,
});
}
class AdminDetailModal extends StatelessWidget {
final String title;
final String? subtitle;
final List<AdminDetailField> fields;
final VoidCallback onEdit;
final VoidCallback onDelete;
const AdminDetailModal({
super.key,
required this.title,
this.subtitle,
required this.fields,
required this.onEdit,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 620),
child: Padding(
padding: const EdgeInsets.all(18),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
if (subtitle != null && subtitle!.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
subtitle!,
style: const TextStyle(color: Colors.black54),
),
],
],
),
),
IconButton(
tooltip: 'Fermer',
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
),
],
),
const SizedBox(height: 8),
const Divider(height: 1),
const SizedBox(height: 12),
Flexible(
child: SingleChildScrollView(
child: Column(
children: fields
.map(
(field) => Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 180,
child: Text(
field.label,
style: const TextStyle(
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
),
Expanded(
child: Text(
field.value,
style: const TextStyle(color: Colors.black87),
),
),
],
),
),
)
.toList(),
),
),
),
const SizedBox(height: 14),
Align(
alignment: Alignment.centerRight,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
OutlinedButton.icon(
onPressed: onDelete,
icon: const Icon(Icons.delete_outline),
label: const Text('Supprimer'),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.red.shade700,
side: BorderSide(color: Colors.red.shade300),
),
),
const SizedBox(width: 10),
ElevatedButton.icon(
onPressed: onEdit,
icon: const Icon(Icons.edit),
label: const Text('Modifier'),
),
],
),
),
],
),
),
),
);
}
}
@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
class AdminListState extends StatelessWidget {
final bool isLoading;
final String? error;
final bool isEmpty;
final String emptyMessage;
final Widget list;
const AdminListState({
super.key,
required this.isLoading,
required this.error,
required this.isEmpty,
required this.emptyMessage,
required this.list,
});
@override
Widget build(BuildContext context) {
if (isLoading) {
return const Expanded(
child: Center(child: CircularProgressIndicator()),
);
}
if (error != null) {
return Expanded(
child: Center(
child: Text(
'Erreur: $error',
style: const TextStyle(color: Colors.red),
textAlign: TextAlign.center,
),
),
);
}
if (isEmpty) {
return Expanded(
child: Center(
child: Text(emptyMessage),
),
);
}
return Expanded(child: list);
}
}
@@ -0,0 +1,143 @@
import 'package:flutter/material.dart';
class AdminUserCard extends StatefulWidget {
final String title;
final List<String> subtitleLines;
final String? avatarUrl;
final IconData fallbackIcon;
final List<Widget> actions;
final Color? borderColor;
final Color? backgroundColor;
final Color? titleColor;
final Color? infoColor;
const AdminUserCard({
super.key,
required this.title,
required this.subtitleLines,
this.avatarUrl,
this.fallbackIcon = Icons.person,
this.actions = const [],
this.borderColor,
this.backgroundColor,
this.titleColor,
this.infoColor,
});
@override
State<AdminUserCard> createState() => _AdminUserCardState();
}
class _AdminUserCardState extends State<AdminUserCard> {
bool _isHovered = false;
@override
Widget build(BuildContext context) {
final infoLine =
widget.subtitleLines.where((e) => e.trim().isNotEmpty).join(' ');
final actionsWidth =
widget.actions.isNotEmpty ? widget.actions.length * 30.0 : 0.0;
return MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(10),
child: InkWell(
onTap: () {},
borderRadius: BorderRadius.circular(10),
hoverColor: const Color(0x149CC5C0),
child: Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: 0,
color: widget.backgroundColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(color: widget.borderColor ?? Colors.grey.shade300),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
child: Row(
children: [
CircleAvatar(
radius: 14,
backgroundColor: const Color(0xFFEDE5FA),
backgroundImage: widget.avatarUrl != null
? NetworkImage(widget.avatarUrl!)
: null,
child: widget.avatarUrl == null
? Icon(
widget.fallbackIcon,
size: 16,
color: const Color(0xFF6B3FA0),
)
: null,
),
const SizedBox(width: 10),
Expanded(
child: Row(
children: [
Flexible(
fit: FlexFit.loose,
child: Text(
widget.title.isNotEmpty ? widget.title : 'Sans nom',
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14,
).copyWith(color: widget.titleColor),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
infoLine,
style: const TextStyle(
color: Colors.black54,
fontSize: 12,
).copyWith(color: widget.infoColor),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
if (widget.actions.isNotEmpty)
SizedBox(
width: actionsWidth,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 120),
opacity: _isHovered ? 1 : 0,
child: IgnorePointer(
ignoring: !_isHovered,
child: IconTheme(
data: const IconThemeData(size: 17),
child: IconButtonTheme(
data: IconButtonThemeData(
style: IconButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.all(4),
minimumSize: const Size(28, 28),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: widget.actions,
),
),
),
),
),
),
],
),
),
),
),
),
);
}
}
@@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_list_state.dart';
class UserList extends StatelessWidget {
final bool isLoading;
final String? error;
final bool isEmpty;
final String emptyMessage;
final int itemCount;
final Widget Function(BuildContext context, int index) itemBuilder;
final EdgeInsetsGeometry padding;
const UserList({
super.key,
required this.isLoading,
required this.error,
required this.isEmpty,
required this.emptyMessage,
required this.itemCount,
required this.itemBuilder,
this.padding = const EdgeInsets.all(16),
});
@override
Widget build(BuildContext context) {
return Padding(
padding: padding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AdminListState(
isLoading: isLoading,
error: error,
isEmpty: isEmpty,
emptyMessage: emptyMessage,
list: ListView.builder(
itemCount: itemCount,
itemBuilder: itemBuilder,
),
),
],
),
);
}
}
+140 -14
View File
@@ -3,16 +3,22 @@ import 'package:go_router/go_router.dart';
import 'package:p_tits_pas/services/auth_service.dart'; import 'package:p_tits_pas/services/auth_service.dart';
/// Barre du dashboard admin : onglets Gestion des utilisateurs | Paramètres + déconnexion. /// Barre du dashboard admin : onglets Gestion des utilisateurs | Paramètres + déconnexion.
class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidget { /// Pour le dashboard gestionnaire : [showSettingsTab] = false, [roleLabel] = 'Gestionnaire'.
class DashboardAppBarAdmin extends StatelessWidget
implements PreferredSizeWidget {
final int selectedIndex; final int selectedIndex;
final ValueChanged<int> onTabChange; final ValueChanged<int> onTabChange;
final bool setupCompleted; final bool setupCompleted;
final bool showSettingsTab;
final String roleLabel;
const DashboardAppBarAdmin({ const DashboardAppBarAdmin({
Key? key, Key? key,
required this.selectedIndex, required this.selectedIndex,
required this.onTabChange, required this.onTabChange,
this.setupCompleted = true, this.setupCompleted = true,
this.showSettingsTab = true,
this.roleLabel = 'Admin',
}) : super(key: key); }) : super(key: key);
@override @override
@@ -36,22 +42,25 @@ class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidge
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
_buildNavItem(context, 'Gestion des utilisateurs', 0, enabled: setupCompleted), _buildNavItem(context, 'Gestion des utilisateurs', 0,
enabled: setupCompleted),
if (showSettingsTab) ...[
const SizedBox(width: 24), const SizedBox(width: 24),
_buildNavItem(context, 'Paramètres', 1, enabled: true), _buildNavItem(context, 'Paramètres', 1, enabled: true),
], ],
],
), ),
), ),
), ),
], ],
), ),
actions: [ actions: [
const Padding( Padding(
padding: EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: Center( child: Center(
child: Text( child: Text(
'Admin', roleLabel,
style: TextStyle( style: const TextStyle(
color: Colors.black, color: Colors.black,
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@@ -78,7 +87,8 @@ class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidge
); );
} }
Widget _buildNavItem(BuildContext context, String title, int index, {bool enabled = true}) { Widget _buildNavItem(BuildContext context, String title, int index,
{bool enabled = true}) {
final bool isActive = index == selectedIndex; final bool isActive = index == selectedIndex;
return InkWell( return InkWell(
onTap: enabled ? () => onTabChange(index) : null, onTap: enabled ? () => onTabChange(index) : null,
@@ -129,15 +139,135 @@ class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidge
} }
} }
/// Sous-barre : Gestionnaires | Parents | Assistantes maternelles | Administrateurs. /// Sous-barre : Gestionnaires | Parents | Assistantes maternelles | [Administrateurs].
/// [subTabCount] = 3 pour masquer l'onglet Administrateurs (dashboard gestionnaire).
class DashboardUserManagementSubBar extends StatelessWidget { class DashboardUserManagementSubBar extends StatelessWidget {
final int selectedSubIndex; final int selectedSubIndex;
final ValueChanged<int> onSubTabChange; final ValueChanged<int> onSubTabChange;
final TextEditingController searchController;
final String searchHint;
final Widget? filterControl;
final VoidCallback? onAddPressed;
final String addLabel;
final int subTabCount;
static const List<String> _tabLabels = [
'Gestionnaires',
'Parents',
'Assistantes maternelles',
'Administrateurs',
];
const DashboardUserManagementSubBar({ const DashboardUserManagementSubBar({
Key? key, Key? key,
required this.selectedSubIndex, required this.selectedSubIndex,
required this.onSubTabChange, required this.onSubTabChange,
required this.searchController,
required this.searchHint,
this.filterControl,
this.onAddPressed,
this.addLabel = '+ Ajouter',
this.subTabCount = 4,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
height: 56,
decoration: BoxDecoration(
color: Colors.grey.shade100,
border: Border(bottom: BorderSide(color: Colors.grey.shade300)),
),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 6),
child: Row(
children: [
for (int i = 0; i < subTabCount; i++) ...[
if (i > 0) const SizedBox(width: 12),
_buildSubNavItem(context, _tabLabels[i], i),
],
const SizedBox(width: 36),
_pillField(
width: 320,
child: TextField(
controller: searchController,
decoration: InputDecoration(
hintText: searchHint,
prefixIcon: const Icon(Icons.search, size: 18),
border: InputBorder.none,
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
),
),
),
if (filterControl != null) ...[
const SizedBox(width: 12),
_pillField(width: 150, child: filterControl!),
],
const Spacer(),
_buildAddButton(),
],
),
);
}
Widget _pillField({required double width, required Widget child}) {
return Container(
width: width,
height: 34,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: Colors.black26),
),
alignment: Alignment.centerLeft,
child: child,
);
}
Widget _buildAddButton() {
return ElevatedButton.icon(
onPressed: onAddPressed,
icon: const Icon(Icons.add),
label: Text(addLabel),
);
}
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,
),
),
),
);
}
}
/// Sous-barre Paramètres : Paramètres généraux | Paramètres territoriaux.
class DashboardSettingsSubBar extends StatelessWidget {
final int selectedSubIndex;
final ValueChanged<int> onSubTabChange;
const DashboardSettingsSubBar({
Key? key,
required this.selectedSubIndex,
required this.onSubTabChange,
}) : super(key: key); }) : super(key: key);
@override @override
@@ -153,13 +283,9 @@ class DashboardUserManagementSubBar extends StatelessWidget {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
_buildSubNavItem(context, 'Gestionnaires', 0), _buildSubNavItem(context, 'Paramètres généraux', 0),
const SizedBox(width: 16), const SizedBox(width: 16),
_buildSubNavItem(context, 'Parents', 1), _buildSubNavItem(context, 'Paramètres territoriaux', 1),
const SizedBox(width: 16),
_buildSubNavItem(context, 'Assistantes maternelles', 2),
const SizedBox(width: 16),
_buildSubNavItem(context, 'Administrateurs', 3),
], ],
), ),
), ),
@@ -1,75 +0,0 @@
import 'package:flutter/material.dart';
class GestionnaireCard extends StatelessWidget {
final String name;
final String email;
const GestionnaireCard({
Key? key,
required this.name,
required this.email,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 🔹 Infos principales
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(name, style: const TextStyle(fontWeight: FontWeight.bold)),
Text(email, style: const TextStyle(color: Colors.grey)),
],
),
const SizedBox(height: 12),
// 🔹 Attribution à des RPE (dropdown fictif ici)
Row(
children: [
const Text("RPE attribué : "),
const SizedBox(width: 8),
DropdownButton<String>(
value: "RPE 1",
items: const [
DropdownMenuItem(value: "RPE 1", child: Text("RPE 1")),
DropdownMenuItem(value: "RPE 2", child: Text("RPE 2")),
DropdownMenuItem(value: "RPE 3", child: Text("RPE 3")),
],
onChanged: (value) {},
),
],
),
const SizedBox(height: 12),
// 🔹 Boutons d'action
Row(
children: [
TextButton.icon(
onPressed: () {
// Réinitialisation mot de passe
},
icon: const Icon(Icons.lock_reset),
label: const Text("Réinitialiser MDP"),
),
const SizedBox(width: 12),
TextButton.icon(
onPressed: () {
// Suppression du compte
},
icon: const Icon(Icons.delete, color: Colors.red),
label: const Text("Supprimer", style: TextStyle(color: Colors.red)),
),
],
)
],
),
),
);
}
}
@@ -1,54 +1,109 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/widgets/admin/gestionnaire_card.dart'; import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
class GestionnaireManagementWidget extends StatelessWidget { class GestionnaireManagementWidget extends StatefulWidget {
const GestionnaireManagementWidget({Key? key}) : super(key: key); final String searchQuery;
const GestionnaireManagementWidget({
Key? key,
required this.searchQuery,
}) : super(key: key);
@override
State<GestionnaireManagementWidget> createState() =>
_GestionnaireManagementWidgetState();
}
class _GestionnaireManagementWidgetState
extends State<GestionnaireManagementWidget> {
bool _isLoading = false;
String? _error;
List<AppUser> _gestionnaires = [];
@override
void initState() {
super.initState();
_loadGestionnaires();
}
@override
void dispose() => super.dispose();
Future<void> _loadGestionnaires() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final gestionnaires = await UserService.getGestionnaires();
if (!mounted) return;
setState(() {
_gestionnaires = gestionnaires;
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
Future<void> _openGestionnaireEditDialog(AppUser user) async {
final changed = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
return AdminUserFormDialog(initialUser: user);
},
);
if (changed == true) {
await _loadGestionnaires();
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( final query = widget.searchQuery.toLowerCase();
padding: const EdgeInsets.all(16), final filteredGestionnaires = _gestionnaires.where((u) {
child: Column( final name = u.fullName.toLowerCase();
crossAxisAlignment: CrossAxisAlignment.stretch, final email = u.email.toLowerCase();
children: [ return name.contains(query) || email.contains(query);
// 🔹 Barre du haut avec bouton }).toList();
Row(
children: [ return UserList(
const Expanded( isLoading: _isLoading,
child: TextField( error: _error,
decoration: InputDecoration( isEmpty: filteredGestionnaires.isEmpty,
hintText: "Rechercher un gestionnaire...", emptyMessage: 'Aucun gestionnaire trouvé.',
prefixIcon: Icon(Icons.search), itemCount: filteredGestionnaires.length,
border: OutlineInputBorder(), itemBuilder: (context, index) {
), final user = filteredGestionnaires[index];
), return AdminUserCard(
), title: user.fullName,
const SizedBox(width: 16), fallbackIcon: Icons.assignment_ind_outlined,
ElevatedButton.icon( avatarUrl: user.photoUrl,
subtitleLines: [
user.email,
'Statut : ${user.statut ?? 'Inconnu'}',
'Relais : ${user.relaisNom ?? 'Non rattaché'}',
],
actions: [
IconButton(
icon: const Icon(Icons.edit),
tooltip: 'Modifier',
onPressed: () { onPressed: () {
// Rediriger vers la page de création _openGestionnaireEditDialog(user);
}, },
icon: const Icon(Icons.add),
label: const Text("Créer un gestionnaire"),
), ),
], ],
),
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",
); );
}, },
),
)
],
),
); );
} }
} }
+115 -30
View File
@@ -1,13 +1,19 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:p_tits_pas/services/configuration_service.dart'; import 'package:p_tits_pas/services/configuration_service.dart';
import 'package:p_tits_pas/widgets/admin/relais_management_panel.dart';
/// Panneau Paramètres admin : Email (SMTP), Personnalisation, Avancé. /// Panneau Paramètres admin : Email (SMTP), Personnalisation, Avancé.
class ParametresPanel extends StatefulWidget { class ParametresPanel extends StatefulWidget {
/// Si true, après sauvegarde on redirige vers le login (première config). Sinon on reste sur la page. /// Si true, après sauvegarde on redirige vers le login (première config). Sinon on reste sur la page.
final bool redirectToLoginAfterSave; final bool redirectToLoginAfterSave;
final int selectedSettingsTabIndex;
const ParametresPanel({super.key, this.redirectToLoginAfterSave = false}); const ParametresPanel({
super.key,
this.redirectToLoginAfterSave = false,
this.selectedSettingsTabIndex = 0,
});
@override @override
State<ParametresPanel> createState() => _ParametresPanelState(); State<ParametresPanel> createState() => _ParametresPanelState();
@@ -33,10 +39,18 @@ class _ParametresPanelState extends State<ParametresPanel> {
void _createControllers() { void _createControllers() {
final keys = [ final keys = [
'smtp_host', 'smtp_port', 'smtp_user', 'smtp_password', 'smtp_host',
'email_from_name', 'email_from_address', 'smtp_port',
'app_name', 'app_url', 'app_logo_url', 'smtp_user',
'password_reset_token_expiry_days', 'jwt_expiry_hours', 'max_upload_size_mb', '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) { for (final k in keys) {
_controllers[k] = TextEditingController(); _controllers[k] = TextEditingController();
@@ -93,18 +107,29 @@ class _ParametresPanelState extends State<ParametresPanel> {
payload['smtp_auth_required'] = _smtpAuthRequired; payload['smtp_auth_required'] = _smtpAuthRequired;
payload['smtp_user'] = _controllers['smtp_user']!.text.trim(); payload['smtp_user'] = _controllers['smtp_user']!.text.trim();
final pwd = _controllers['smtp_password']!.text.trim(); final pwd = _controllers['smtp_password']!.text.trim();
if (pwd.isNotEmpty && pwd != '***********') payload['smtp_password'] = pwd; if (pwd.isNotEmpty && pwd != '***********') {
payload['smtp_password'] = pwd;
}
payload['email_from_name'] = _controllers['email_from_name']!.text.trim(); payload['email_from_name'] = _controllers['email_from_name']!.text.trim();
payload['email_from_address'] = _controllers['email_from_address']!.text.trim(); payload['email_from_address'] =
_controllers['email_from_address']!.text.trim();
payload['app_name'] = _controllers['app_name']!.text.trim(); payload['app_name'] = _controllers['app_name']!.text.trim();
payload['app_url'] = _controllers['app_url']!.text.trim(); payload['app_url'] = _controllers['app_url']!.text.trim();
payload['app_logo_url'] = _controllers['app_logo_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()); final tokenDays = int.tryParse(
if (tokenDays != null) payload['password_reset_token_expiry_days'] = tokenDays; _controllers['password_reset_token_expiry_days']!.text.trim());
final jwtHours = int.tryParse(_controllers['jwt_expiry_hours']!.text.trim()); if (tokenDays != null) {
if (jwtHours != null) payload['jwt_expiry_hours'] = jwtHours; 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()); final maxMb = int.tryParse(_controllers['max_upload_size_mb']!.text.trim());
if (maxMb != null) payload['max_upload_size_mb'] = maxMb; if (maxMb != null) {
payload['max_upload_size_mb'] = maxMb;
}
return payload; return payload;
} }
@@ -191,6 +216,10 @@ class _ParametresPanelState extends State<ParametresPanel> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (widget.selectedSettingsTabIndex == 1) {
return const RelaisManagementPanel();
}
if (_isLoading) { if (_isLoading) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
@@ -214,7 +243,8 @@ class _ParametresPanelState extends State<ParametresPanel> {
} }
final isSuccess = _message != null && final isSuccess = _message != null &&
(_message!.startsWith('Configuration') || _message!.startsWith('Connexion')); (_message!.startsWith('Configuration') ||
_message!.startsWith('Connexion'));
return Form( return Form(
key: _formKey, key: _formKey,
@@ -237,9 +267,18 @@ class _ParametresPanelState extends State<ParametresPanel> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildField('smtp_host', 'Serveur SMTP', hint: 'mail.example.com'), _buildField(
'smtp_host',
'Serveur SMTP',
hint: 'mail.example.com',
),
const SizedBox(height: 14), const SizedBox(height: 14),
_buildField('smtp_port', 'Port SMTP', keyboard: TextInputType.number, hint: '25, 465, 587'), _buildField(
'smtp_port',
'Port SMTP',
keyboard: TextInputType.number,
hint: '25, 465, 587',
),
const SizedBox(height: 14), const SizedBox(height: 14),
Padding( Padding(
padding: const EdgeInsets.only(bottom: 14), padding: const EdgeInsets.only(bottom: 14),
@@ -247,14 +286,17 @@ class _ParametresPanelState extends State<ParametresPanel> {
children: [ children: [
Checkbox( Checkbox(
value: _smtpSecure, value: _smtpSecure,
onChanged: (v) => setState(() => _smtpSecure = v ?? false), onChanged: (v) =>
setState(() => _smtpSecure = v ?? false),
activeColor: const Color(0xFF9CC5C0), activeColor: const Color(0xFF9CC5C0),
), ),
const Text('SSL/TLS (secure)'), const Text('SSL/TLS (secure)'),
const SizedBox(width: 24), const SizedBox(width: 24),
Checkbox( Checkbox(
value: _smtpAuthRequired, value: _smtpAuthRequired,
onChanged: (v) => setState(() => _smtpAuthRequired = v ?? false), onChanged: (v) => setState(
() => _smtpAuthRequired = v ?? false,
),
activeColor: const Color(0xFF9CC5C0), activeColor: const Color(0xFF9CC5C0),
), ),
const Text('Authentification requise'), const Text('Authentification requise'),
@@ -263,11 +305,19 @@ class _ParametresPanelState extends State<ParametresPanel> {
), ),
_buildField('smtp_user', 'Utilisateur SMTP'), _buildField('smtp_user', 'Utilisateur SMTP'),
const SizedBox(height: 14), const SizedBox(height: 14),
_buildField('smtp_password', 'Mot de passe SMTP', obscure: true), _buildField(
'smtp_password',
'Mot de passe SMTP',
obscure: true,
),
const SizedBox(height: 14), const SizedBox(height: 14),
_buildField('email_from_name', 'Nom expéditeur'), _buildField('email_from_name', 'Nom expéditeur'),
const SizedBox(height: 14), const SizedBox(height: 14),
_buildField('email_from_address', 'Email expéditeur', hint: 'no-reply@example.com'), _buildField(
'email_from_address',
'Email expéditeur',
hint: 'no-reply@example.com',
),
const SizedBox(height: 18), const SizedBox(height: 18),
Align( Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
@@ -277,8 +327,13 @@ class _ParametresPanelState extends State<ParametresPanel> {
label: const Text('Tester la connexion SMTP'), label: const Text('Tester la connexion SMTP'),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF2D6A4F), foregroundColor: const Color(0xFF2D6A4F),
side: const BorderSide(color: Color(0xFF9CC5C0)), side: const BorderSide(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), color: Color(0xFF9CC5C0),
),
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 12,
),
), ),
), ),
), ),
@@ -295,9 +350,17 @@ class _ParametresPanelState extends State<ParametresPanel> {
children: [ children: [
_buildField('app_name', 'Nom de l\'application'), _buildField('app_name', 'Nom de l\'application'),
const SizedBox(height: 14), const SizedBox(height: 14),
_buildField('app_url', 'URL de l\'application', hint: 'https://app.example.com'), _buildField(
'app_url',
'URL de l\'application',
hint: 'https://app.example.com',
),
const SizedBox(height: 14), const SizedBox(height: 14),
_buildField('app_logo_url', 'URL du logo', hint: '/assets/logo.png'), _buildField(
'app_logo_url',
'URL du logo',
hint: '/assets/logo.png',
),
], ],
), ),
), ),
@@ -309,11 +372,23 @@ class _ParametresPanelState extends State<ParametresPanel> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildField('password_reset_token_expiry_days', 'Validité token MDP (jours)', keyboard: TextInputType.number), _buildField(
'password_reset_token_expiry_days',
'Validité token MDP (jours)',
keyboard: TextInputType.number,
),
const SizedBox(height: 14), const SizedBox(height: 14),
_buildField('jwt_expiry_hours', 'Validité session JWT (heures)', keyboard: TextInputType.number), _buildField(
'jwt_expiry_hours',
'Validité session JWT (heures)',
keyboard: TextInputType.number,
),
const SizedBox(height: 14), const SizedBox(height: 14),
_buildField('max_upload_size_mb', 'Taille max upload (MB)', keyboard: TextInputType.number), _buildField(
'max_upload_size_mb',
'Taille max upload (MB)',
keyboard: TextInputType.number,
),
], ],
), ),
), ),
@@ -327,7 +402,14 @@ class _ParametresPanelState extends State<ParametresPanel> {
foregroundColor: Colors.white, foregroundColor: Colors.white,
), ),
child: _isSaving child: _isSaving
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) ? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text('Sauvegarder la configuration'), : const Text('Sauvegarder la configuration'),
), ),
), ),
@@ -339,7 +421,8 @@ class _ParametresPanelState extends State<ParametresPanel> {
); );
} }
Widget _buildSectionCard(BuildContext context, {required IconData icon, required String title, required Widget child}) { Widget _buildSectionCard(BuildContext context,
{required IconData icon, required String title, required Widget child}) {
return Card( return Card(
elevation: 2, elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
@@ -369,7 +452,8 @@ class _ParametresPanelState extends State<ParametresPanel> {
); );
} }
Widget _buildField(String key, String label, {bool obscure = false, TextInputType? keyboard, String? hint}) { Widget _buildField(String key, String label,
{bool obscure = false, TextInputType? keyboard, String? hint}) {
final c = _controllers[key]; final c = _controllers[key];
if (c == null) return const SizedBox.shrink(); if (c == null) return const SizedBox.shrink();
return TextFormField( return TextFormField(
@@ -381,7 +465,8 @@ class _ParametresPanelState extends State<ParametresPanel> {
labelText: label, labelText: label,
hintText: hint, hintText: hint,
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
), ),
); );
} }
@@ -1,121 +1,155 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/parent_model.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
class ParentManagementWidget extends StatelessWidget { class ParentManagementWidget extends StatefulWidget {
const ParentManagementWidget({super.key}); final String searchQuery;
final String? statusFilter;
const ParentManagementWidget({
super.key,
required this.searchQuery,
this.statusFilter,
});
@override
State<ParentManagementWidget> createState() => _ParentManagementWidgetState();
}
class _ParentManagementWidgetState extends State<ParentManagementWidget> {
bool _isLoading = false;
String? _error;
List<ParentModel> _parents = [];
@override
void initState() {
super.initState();
_loadParents();
}
@override
void dispose() => super.dispose();
Future<void> _loadParents() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final list = await UserService.getParents();
if (!mounted) return;
setState(() {
_parents = list;
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// 🔁 Simulation de données parents final query = widget.searchQuery.toLowerCase();
final parents = [ final filteredParents = _parents.where((p) {
{ final matchesName = p.user.fullName.toLowerCase().contains(query) ||
"nom": "Jean Dupuis", p.user.email.toLowerCase().contains(query);
"email": "jean.dupuis@email.com", final matchesStatus =
"statut": "Actif", widget.statusFilter == null || p.user.statut == widget.statusFilter;
"enfants": 2, return matchesName && matchesStatus;
}, }).toList();
{
"nom": "Lucie Morel",
"email": "lucie.morel@email.com",
"statut": "En attente",
"enfants": 1,
},
];
return Padding( return UserList(
padding: const EdgeInsets.all(16), isLoading: _isLoading,
child: Column( error: _error,
crossAxisAlignment: CrossAxisAlignment.start, isEmpty: filteredParents.isEmpty,
children: [ emptyMessage: 'Aucun parent trouvé.',
itemCount: filteredParents.length,
_buildSearchSection(),
const SizedBox(height: 16),
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: parents.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final parent = parents[index]; final parent = filteredParents[index];
return Card( return AdminUserCard(
margin: const EdgeInsets.symmetric(vertical: 8), title: parent.user.fullName,
child: ListTile( fallbackIcon: Icons.supervisor_account_outlined,
leading: const Icon(Icons.person_outline), avatarUrl: parent.user.photoUrl,
title: Text(parent['nom'].toString()), subtitleLines: [
subtitle: Text( parent.user.email,
"${parent['email']}\nStatut : ${parent['statut']} | Enfants : ${parent['enfants']}", 'Statut : ${_displayStatus(parent.user.statut)} | Enfants : ${parent.childrenCount}',
), ],
isThreeLine: true, actions: [
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.visibility),
tooltip: "Voir dossier",
onPressed: () {
// TODO: Voir le statut du dossier
},
),
IconButton( IconButton(
icon: const Icon(Icons.edit), icon: const Icon(Icons.edit),
tooltip: "Modifier", tooltip: 'Modifier',
onPressed: () { onPressed: () {
// TODO: Modifier parent _openParentDetails(parent);
},
),
IconButton(
icon: const Icon(Icons.delete),
tooltip: "Supprimer",
onPressed: () {
// TODO: Supprimer compte
}, },
), ),
], ],
),
),
); );
}, },
),
],
)
); );
} }
Widget _buildSearchSection() { String _displayStatus(String? status) {
return Wrap( switch (status) {
spacing: 16, case 'actif':
runSpacing: 8, return 'Actif';
children: [ case 'en_attente':
SizedBox( return 'En attente';
width: 220, case 'suspendu':
child: TextField( return 'Suspendu';
decoration: const InputDecoration( default:
labelText: "Nom du parent", return 'Inconnu';
border: OutlineInputBorder(), }
}
void _openParentDetails(ParentModel parent) {
showDialog<void>(
context: context,
builder: (context) => AdminDetailModal(
title: parent.user.fullName.isEmpty ? 'Parent' : parent.user.fullName,
subtitle: parent.user.email,
fields: [
AdminDetailField(label: 'ID', value: _v(parent.user.id)),
AdminDetailField(
label: 'Statut',
value: _displayStatus(parent.user.statut),
), ),
onChanged: (value) { AdminDetailField(
// TODO: Ajouter logique de recherche label: 'Telephone',
}, value: _v(parent.user.telephone),
), ),
AdminDetailField(label: 'Adresse', value: _v(parent.user.adresse)),
AdminDetailField(label: 'Ville', value: _v(parent.user.ville)),
AdminDetailField(
label: 'Code postal',
value: _v(parent.user.codePostal),
), ),
SizedBox( AdminDetailField(
width: 220, label: 'Nombre d\'enfants',
child: DropdownButtonFormField<String>( value: parent.childrenCount.toString(),
decoration: const InputDecoration(
labelText: "Statut",
border: OutlineInputBorder(),
),
items: const [
DropdownMenuItem(value: "Actif", child: Text("Actif")),
DropdownMenuItem(value: "En attente", child: Text("En attente")),
DropdownMenuItem(value: "Supprimé", child: Text("Supprimé")),
],
onChanged: (value) {
// TODO: Ajouter logique de filtrage
},
),
), ),
], ],
onEdit: () {
Navigator.of(context).pop();
ScaffoldMessenger.of(this.context).showSnackBar(
const SnackBar(content: Text('Action Modifier a implementer')),
);
},
onDelete: () {
Navigator.of(context).pop();
ScaffoldMessenger.of(this.context).showSnackBar(
const SnackBar(content: Text('Action Supprimer a implementer')),
);
},
),
); );
} }
String _v(String? value) => (value == null || value.isEmpty) ? '-' : value;
} }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,237 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart';
import 'package:p_tits_pas/widgets/admin/admin_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart';
import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/parent_managmant_widget.dart';
class UserManagementPanel extends StatefulWidget {
/// Afficher l'onglet Administrateurs (sinon 3 onglets : Gestionnaires, Parents, AM).
final bool showAdministrateursTab;
const UserManagementPanel({
super.key,
this.showAdministrateursTab = true,
});
@override
State<UserManagementPanel> createState() => _UserManagementPanelState();
}
class _UserManagementPanelState extends State<UserManagementPanel> {
int _subIndex = 0;
int _gestionnaireRefreshTick = 0;
int _adminRefreshTick = 0;
final TextEditingController _searchController = TextEditingController();
final TextEditingController _amCapacityController = TextEditingController();
String? _parentStatus;
@override
void initState() {
super.initState();
_searchController.addListener(_onFilterChanged);
_amCapacityController.addListener(_onFilterChanged);
}
@override
void dispose() {
_searchController.removeListener(_onFilterChanged);
_amCapacityController.removeListener(_onFilterChanged);
_searchController.dispose();
_amCapacityController.dispose();
super.dispose();
}
void _onFilterChanged() {
if (!mounted) return;
setState(() {});
}
void _onSubTabChange(int index) {
final maxIndex = widget.showAdministrateursTab ? 3 : 2;
setState(() {
_subIndex = index.clamp(0, maxIndex);
_searchController.clear();
_parentStatus = null;
_amCapacityController.clear();
});
}
String _searchHintForTab() {
switch (_subIndex) {
case 0:
return 'Rechercher un gestionnaire...';
case 1:
return 'Rechercher un parent...';
case 2:
return 'Rechercher une assistante...';
case 3:
return 'Rechercher un administrateur...';
default:
return 'Rechercher...';
}
}
Widget? _subBarFilterControl() {
if (_subIndex == 1) {
return DropdownButtonHideUnderline(
child: DropdownButton<String?>(
value: _parentStatus,
isExpanded: true,
hint: const Padding(
padding: EdgeInsets.only(left: 10),
child: Text('Statut', style: TextStyle(fontSize: 12)),
),
items: const [
DropdownMenuItem<String?>(
value: null,
child: Padding(
padding: EdgeInsets.only(left: 10),
child: Text('Tous', style: TextStyle(fontSize: 12)),
),
),
DropdownMenuItem<String?>(
value: 'actif',
child: Padding(
padding: EdgeInsets.only(left: 10),
child: Text('Actif', style: TextStyle(fontSize: 12)),
),
),
DropdownMenuItem<String?>(
value: 'en_attente',
child: Padding(
padding: EdgeInsets.only(left: 10),
child: Text('En attente', style: TextStyle(fontSize: 12)),
),
),
DropdownMenuItem<String?>(
value: 'suspendu',
child: Padding(
padding: EdgeInsets.only(left: 10),
child: Text('Suspendu', style: TextStyle(fontSize: 12)),
),
),
],
onChanged: (value) {
setState(() {
_parentStatus = value;
});
},
),
);
}
if (_subIndex == 2) {
return TextField(
controller: _amCapacityController,
decoration: const InputDecoration(
hintText: 'Capacité min',
hintStyle: TextStyle(fontSize: 12),
border: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8),
),
keyboardType: TextInputType.number,
);
}
return null;
}
Widget _buildBody() {
switch (_subIndex) {
case 0:
return GestionnaireManagementWidget(
key: ValueKey('gestionnaires-$_gestionnaireRefreshTick'),
searchQuery: _searchController.text,
);
case 1:
return ParentManagementWidget(
searchQuery: _searchController.text,
statusFilter: _parentStatus,
);
case 2:
return AssistanteMaternelleManagementWidget(
searchQuery: _searchController.text,
capacityMin: int.tryParse(_amCapacityController.text),
);
case 3:
return AdminManagementWidget(
key: ValueKey('admins-$_adminRefreshTick'),
searchQuery: _searchController.text,
);
default:
return const Center(child: Text('Page non trouvée'));
}
}
@override
Widget build(BuildContext context) {
final subTabCount = widget.showAdministrateursTab ? 4 : 3;
return Column(
children: [
DashboardUserManagementSubBar(
selectedSubIndex: _subIndex,
onSubTabChange: _onSubTabChange,
searchController: _searchController,
searchHint: _searchHintForTab(),
filterControl: _subBarFilterControl(),
onAddPressed: _handleAddPressed,
addLabel: 'Ajouter',
subTabCount: subTabCount,
),
Expanded(child: _buildBody()),
],
);
}
Future<void> _handleAddPressed() async {
if (_subIndex == 0) {
final created = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
return const AdminUserFormDialog();
},
);
if (!mounted) return;
if (created == true) {
setState(() {
_gestionnaireRefreshTick++;
});
}
return;
}
if (_subIndex == 3) {
final created = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
return const AdminUserFormDialog(
adminMode: true,
withRelais: false,
);
},
);
if (!mounted) return;
if (created == true) {
setState(() {
_adminRefreshTick++;
});
}
return;
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'La création est disponible pour les gestionnaires et administrateurs.',
),
),
);
}
}
@@ -10,6 +10,7 @@ enum CustomAppTextFieldStyle {
class CustomAppTextField extends StatefulWidget { class CustomAppTextField extends StatefulWidget {
final TextEditingController controller; final TextEditingController controller;
final FocusNode? focusNode;
final String labelText; final String labelText;
final String hintText; final String hintText;
final double fieldWidth; final double fieldWidth;
@@ -26,10 +27,14 @@ class CustomAppTextField extends StatefulWidget {
final double labelFontSize; final double labelFontSize;
final double inputFontSize; final double inputFontSize;
final bool showLabel; final bool showLabel;
final Iterable<String>? autofillHints;
final TextInputAction? textInputAction;
final ValueChanged<String>? onFieldSubmitted;
const CustomAppTextField({ const CustomAppTextField({
super.key, super.key,
required this.controller, required this.controller,
this.focusNode,
required this.labelText, required this.labelText,
this.showLabel = true, this.showLabel = true,
this.hintText = '', this.hintText = '',
@@ -46,6 +51,9 @@ class CustomAppTextField extends StatefulWidget {
this.suffixIcon, this.suffixIcon,
this.labelFontSize = 18.0, this.labelFontSize = 18.0,
this.inputFontSize = 18.0, this.inputFontSize = 18.0,
this.autofillHints,
this.textInputAction,
this.onFieldSubmitted,
}); });
@override @override
@@ -99,35 +107,44 @@ class _CustomAppTextFieldState extends State<CustomAppTextField> {
), ),
), ),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0, vertical: 8.0), padding:
const EdgeInsets.symmetric(horizontal: 18.0, vertical: 8.0),
child: TextFormField( child: TextFormField(
controller: widget.controller, controller: widget.controller,
focusNode: widget.focusNode,
obscureText: widget.obscureText, obscureText: widget.obscureText,
keyboardType: widget.keyboardType, keyboardType: widget.keyboardType,
autofillHints: widget.autofillHints,
textInputAction: widget.textInputAction,
onFieldSubmitted: widget.onFieldSubmitted,
enabled: widget.enabled, enabled: widget.enabled,
readOnly: widget.readOnly, readOnly: widget.readOnly,
onTap: widget.onTap, onTap: widget.onTap,
style: GoogleFonts.merienda( style: GoogleFonts.merienda(
fontSize: widget.inputFontSize, fontSize: widget.inputFontSize,
color: widget.enabled ? Colors.black87 : Colors.grey color: widget.enabled ? Colors.black87 : Colors.grey),
),
validator: widget.validator ?? validator: widget.validator ??
(value) { (value) {
if (!widget.enabled || widget.readOnly) return null; if (!widget.enabled || widget.readOnly) return null;
if (widget.isRequired && (value == null || value.isEmpty)) { if (widget.isRequired &&
(value == null || value.isEmpty)) {
return 'Ce champ est obligatoire'; return 'Ce champ est obligatoire';
} }
return null; return null;
}, },
decoration: InputDecoration( decoration: InputDecoration(
hintText: widget.hintText, hintText: widget.hintText,
hintStyle: GoogleFonts.merienda(fontSize: widget.inputFontSize, color: Colors.black54.withOpacity(0.7)), hintStyle: GoogleFonts.merienda(
fontSize: widget.inputFontSize,
color: Colors.black54.withOpacity(0.7)),
border: InputBorder.none, border: InputBorder.none,
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
suffixIcon: widget.suffixIcon != null suffixIcon: widget.suffixIcon != null
? Padding( ? Padding(
padding: const EdgeInsets.only(right: 0.0), padding: const EdgeInsets.only(right: 0.0),
child: Icon(widget.suffixIcon, color: Colors.black54, size: widget.inputFontSize * 1.1), child: Icon(widget.suffixIcon,
color: Colors.black54,
size: widget.inputFontSize * 1.1),
) )
: null, : null,
isDense: true, isDense: true,
+17 -5
View File
@@ -23,13 +23,23 @@ class ImageButton extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MouseRegion( return SizedBox(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: onPressed,
child: Container(
width: width, width: width,
height: height, height: height,
child: Semantics(
button: true,
label: text,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: TextButton(
onPressed: onPressed,
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
shape:
const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
),
child: Ink(
decoration: BoxDecoration( decoration: BoxDecoration(
image: DecorationImage( image: DecorationImage(
image: AssetImage(bg), image: AssetImage(bg),
@@ -48,6 +58,8 @@ class ImageButton extends StatelessWidget {
), ),
), ),
), ),
),
),
); );
} }
} }
+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"