Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
090ce6e13b | ||
|
|
d66bdd04be | ||
|
|
222d7c702f | ||
|
|
537c46127f | ||
|
|
ed18dcab10 | ||
|
|
bb92f010bd | ||
|
|
42bb872c41 | ||
|
|
fac3ae9baa | ||
|
|
5c28981ac5 | ||
|
|
57ce5af0f4 | ||
|
|
c1204a3050 | ||
|
|
9d4363b2a7 | ||
|
|
af06ab1e66 | ||
|
|
aa148354ec | ||
|
|
a10dc5a195 | ||
|
|
04c0b05aae | ||
|
|
d0b730c8ab | ||
|
|
bc8362bdb7 | ||
|
|
ac3178903d | ||
|
|
aec1990ec9 | ||
|
|
5da2ab9005 | ||
|
|
b2d6414fab | ||
|
|
fbafef8f2c | ||
|
|
135c7c2255 | ||
|
|
9cce326046 | ||
|
|
d697083f54 | ||
|
|
ae786426fd | ||
|
|
e4f7a35f0f | ||
|
|
8a6768b316 | ||
|
|
3892a8beab | ||
|
|
d39bc55be3 | ||
|
|
e0debf0394 |
@@ -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: [
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
});
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { PartialType } from "@nestjs/swagger";
|
import { PartialType, ApiProperty } from "@nestjs/swagger";
|
||||||
import { CreateGestionnaireDto } from "./create_gestionnaire.dto";
|
import { CreateUserDto } from "./create_user.dto";
|
||||||
|
import { IsOptional, IsUUID } from "class-validator";
|
||||||
|
|
||||||
export class UpdateGestionnaireDto extends PartialType(CreateGestionnaireDto) {}
|
export class UpdateGestionnaireDto extends PartialType(CreateUserDto) {
|
||||||
|
@ApiProperty({ required: false, description: 'ID du relais de rattachement' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
relaisId?: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
@@ -134,6 +160,11 @@ export class UserService {
|
|||||||
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) &&
|
||||||
|
|||||||
+18
-2
@@ -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
|
||||||
|
|||||||
@@ -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/`.
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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:
|
||||||
|
|||||||
+172
-35
@@ -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 | Ouvert |
|
||||||
|
| 94 | [Backend] Relais - modele, API CRUD et liaison gestionnaire | ✅ Terminé |
|
||||||
|
| 95 | [Frontend] Admin - gestion des relais et rattachement gestionnaire | Ouvert |
|
||||||
|
| 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,38 @@ 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`
|
||||||
|
|
||||||
|
**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
|
||||||
@@ -898,6 +934,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 +1059,67 @@ Adapter l'écran de choix Parent/AM pour une meilleure expérience mobile et coh
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Ticket #91 : [Frontend] Inscription AM – Branchement soumission formulaire à l'API
|
||||||
|
**Estimation** : 3h
|
||||||
|
**Labels** : `frontend`, `p3`, `auth`, `cdc`
|
||||||
|
|
||||||
|
**Description** :
|
||||||
|
Branchement du formulaire d'inscription AM (étape 4) à l'endpoint d'inscription.
|
||||||
|
|
||||||
|
**Tâches** :
|
||||||
|
- [ ] Construire le body (DTO) à partir de `AmRegistrationData`
|
||||||
|
- [ ] Appel HTTP `POST /api/v1/auth/register/am`
|
||||||
|
- [ ] Gestion réponse (201 : succès + redirection ; 4xx : erreur)
|
||||||
|
- [ ] Conversion photo en base64 si nécessaire
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Ticket #93 : [Frontend] Panneau Admin - Homogénéisation des onglets
|
||||||
|
**Estimation** : 4h
|
||||||
|
**Labels** : `frontend`, `p3`, `admin`, `ux`
|
||||||
|
|
||||||
|
**Description** :
|
||||||
|
Uniformiser l'UI/UX des 4 onglets du dashboard admin (Gestionnaires, Parents, AM, Admins).
|
||||||
|
|
||||||
|
**Tâches** :
|
||||||
|
- [ ] Standardiser le header de liste (Recherche, Filtres, Bouton Action)
|
||||||
|
- [ ] Standardiser les cartes utilisateurs (`ListTile` uniforme)
|
||||||
|
- [ ] Standardiser les états (Loading, Erreur, Vide)
|
||||||
|
- [ ] Factoriser les composants partagés
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Ticket #95 : [Frontend] Admin - Gestion des Relais et rattachement gestionnaire
|
||||||
|
**Estimation** : 5h
|
||||||
|
**Labels** : `frontend`, `p3`, `admin`
|
||||||
|
|
||||||
|
**Description** :
|
||||||
|
Interface de gestion des Relais dans le dashboard admin et rattachement des gestionnaires.
|
||||||
|
|
||||||
|
**Tâches** :
|
||||||
|
- [ ] Section Relais avec 2 sous-onglets : Paramètres techniques / Paramètres territoriaux
|
||||||
|
- [ ] Liste, Création, Édition, Activation/Désactivation des relais
|
||||||
|
- [ ] Champs UI : nom, adresse, horaires, téléphone, statut, notes
|
||||||
|
- [ ] Onglet Gestionnaires : Ajout contrôle de rattachement au relais principal
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 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 +1235,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 +1351,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 +1387,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
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
);
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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).
|
||||||
@@ -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?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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['createdAt'] as String)
|
? DateTime.parse(json['cree_le'] as String)
|
||||||
: DateTime.now(),
|
: (json['createdAt'] != null
|
||||||
updatedAt: json['updatedAt'] != null
|
? DateTime.parse(json['createdAt'] as String)
|
||||||
? DateTime.parse(json['updatedAt'] as String)
|
: DateTime.now()),
|
||||||
: DateTime.now(),
|
updatedAt: json['modifie_le'] != null
|
||||||
changementMdpObligatoire: json['changement_mdp_obligatoire'] as bool? ?? false,
|
? DateTime.parse(json['modifie_le'] as String)
|
||||||
|
: (json['updatedAt'] != null
|
||||||
|
? DateTime.parse(json['updatedAt'] as String)
|
||||||
|
: DateTime.now()),
|
||||||
|
changementMdpObligatoire:
|
||||||
|
json['changement_mdp_obligatoire'] as bool? ?? false,
|
||||||
|
nom: json['nom'] as String?,
|
||||||
|
prenom: json['prenom'] as String?,
|
||||||
|
statut: json['statut'] as String?,
|
||||||
|
telephone: json['telephone'] as String?,
|
||||||
|
photoUrl: json['photo_url'] as String?,
|
||||||
|
adresse: json['adresse'] as String?,
|
||||||
|
ville: json['ville'] as String?,
|
||||||
|
codePostal: json['code_postal'] as String?,
|
||||||
|
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,10 +37,12 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
|
|||||||
if (!completed) mainTabIndex = 1;
|
if (!completed) mainTabIndex = 1;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) setState(() {
|
if (mounted) {
|
||||||
_setupCompleted = false;
|
setState(() {
|
||||||
mainTabIndex = 1;
|
_setupCompleted = false;
|
||||||
});
|
mainTabIndex = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 AdminUserManagementPanel();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,451 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.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 GestionnaireCreateDialog extends StatefulWidget {
|
||||||
const GestionnairesCreate({super.key});
|
final AppUser? initialUser;
|
||||||
|
|
||||||
|
const GestionnaireCreateDialog({
|
||||||
|
super.key,
|
||||||
|
this.initialUser,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<GestionnaireCreateDialog> createState() =>
|
||||||
|
_GestionnaireCreateDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GestionnaireCreateDialogState extends State<GestionnaireCreateDialog> {
|
||||||
|
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 _isLoadingRelais = true;
|
||||||
|
List<RelaisModel> _relais = [];
|
||||||
|
String? _selectedRelaisId;
|
||||||
|
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();
|
||||||
|
final initialRelaisId = user.relaisId?.trim();
|
||||||
|
_selectedRelaisId =
|
||||||
|
(initialRelaisId == null || initialRelaisId.isEmpty)
|
||||||
|
? null
|
||||||
|
: initialRelaisId;
|
||||||
|
}
|
||||||
|
_loadRelais();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_nomController.dispose();
|
||||||
|
_prenomController.dispose();
|
||||||
|
_emailController.dispose();
|
||||||
|
_passwordController.dispose();
|
||||||
|
_telephoneController.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (_isSubmitting) return;
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isSubmitting = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (_isEditMode) {
|
||||||
|
await UserService.updateGestionnaire(
|
||||||
|
gestionnaireId: widget.initialUser!.id,
|
||||||
|
nom: _nomController.text.trim(),
|
||||||
|
prenom: _prenomController.text.trim(),
|
||||||
|
email: _emailController.text.trim(),
|
||||||
|
telephone: _telephoneController.text.trim(),
|
||||||
|
relaisId: _selectedRelaisId,
|
||||||
|
password: _passwordController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: _passwordController.text,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await UserService.createGestionnaire(
|
||||||
|
nom: _nomController.text.trim(),
|
||||||
|
prenom: _prenomController.text.trim(),
|
||||||
|
email: _emailController.text.trim(),
|
||||||
|
password: _passwordController.text,
|
||||||
|
telephone: _telephoneController.text.trim(),
|
||||||
|
relaisId: _selectedRelaisId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
_isEditMode
|
||||||
|
? 'Gestionnaire modifié 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 (!_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: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
_isEditMode
|
||||||
|
? 'Modifier un gestionnaire'
|
||||||
|
: 'Créer un gestionnaire',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_isEditMode)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
tooltip: 'Fermer',
|
||||||
|
onPressed: _isSubmitting
|
||||||
|
? null
|
||||||
|
: () => Navigator.of(context).pop(false),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
body: const Center(
|
content: SizedBox(
|
||||||
child: Text('Formulaire de création de gestionnaire'),
|
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()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildRelaisField(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
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'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
? null
|
||||||
|
: (value) {
|
||||||
|
setState(() {
|
||||||
|
_selectedRelaisId = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (_isLoadingRelais) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const LinearProgressIndicator(minHeight: 2),
|
||||||
|
],
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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';
|
||||||
|
|
||||||
@@ -30,14 +35,14 @@ class ApiConfig {
|
|||||||
static const String conversations = '/conversations';
|
static const String conversations = '/conversations';
|
||||||
static const String notifications = '/notifications';
|
static const String notifications = '/notifications';
|
||||||
|
|
||||||
// Headers
|
// Headers
|
||||||
static Map<String, String> get headers => {
|
static Map<String, String> get headers => {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
};
|
};
|
||||||
|
|
||||||
static Map<String, String> authHeaders(String token) => {
|
static Map<String, String> authHeaders(String token) => {
|
||||||
...headers,
|
...headers,
|
||||||
'Authorization': 'Bearer $token',
|
'Authorization': 'Bearer $token',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -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'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
required String telephone,
|
||||||
|
required String? relaisId,
|
||||||
|
String? password,
|
||||||
|
}) async {
|
||||||
|
final body = <String, dynamic>{
|
||||||
|
'nom': nom,
|
||||||
|
'prenom': prenom,
|
||||||
|
'email': email,
|
||||||
|
'telephone': telephone,
|
||||||
|
'relaisId': relaisId,
|
||||||
|
};
|
||||||
|
|
||||||
|
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<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,105 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
|
import 'package:p_tits_pas/screens/administrateurs/creation/admin_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 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 = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_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> _openAdminEditDialog(AppUser user) async {
|
||||||
|
final changed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (dialogContext) {
|
||||||
|
return AdminCreateDialog(initialUser: user);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (changed == true) {
|
||||||
|
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];
|
||||||
|
return AdminUserCard(
|
||||||
|
title: user.fullName,
|
||||||
|
subtitleLines: [
|
||||||
|
user.email,
|
||||||
|
'Rôle : ${user.role}',
|
||||||
|
],
|
||||||
|
avatarUrl: user.photoUrl,
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.edit),
|
||||||
|
tooltip: 'Modifier',
|
||||||
|
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;
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
return UserList(
|
||||||
|
isLoading: _isLoading,
|
||||||
|
error: _error,
|
||||||
|
isEmpty: filteredAssistantes.isEmpty,
|
||||||
|
emptyMessage: 'Aucune assistante maternelle trouvée.',
|
||||||
|
itemCount: filteredAssistantes.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final assistante = filteredAssistantes[index];
|
||||||
|
return AdminUserCard(
|
||||||
|
title: assistante.user.fullName,
|
||||||
|
avatarUrl: assistante.user.photoUrl,
|
||||||
|
fallbackIcon: Icons.face,
|
||||||
|
subtitleLines: [
|
||||||
|
assistante.user.email,
|
||||||
|
'Zone : ${assistante.residenceCity ?? 'N/A'} | Capacité : ${assistante.maxChildren ?? 0}',
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.edit),
|
||||||
|
tooltip: 'Modifier',
|
||||||
|
onPressed: () {
|
||||||
|
_openAssistanteDetails(assistante);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"nom": "Claire Martin",
|
|
||||||
"numeroAgrement": "AG654321",
|
|
||||||
"zone": "Lyon 7",
|
|
||||||
"capacite": 2,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
// 🔎 Zone de filtre
|
|
||||||
_buildFilterSection(),
|
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// 📋 Liste des assistantes
|
|
||||||
ListView.builder(
|
|
||||||
shrinkWrap: true,
|
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
|
||||||
itemCount: assistantes.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final assistante = assistantes[index];
|
|
||||||
return Card(
|
|
||||||
margin: const EdgeInsets.symmetric(vertical: 8),
|
|
||||||
child: ListTile(
|
|
||||||
leading: const Icon(Icons.face),
|
|
||||||
title: Text(assistante['nom'].toString()),
|
|
||||||
subtitle: Text(
|
|
||||||
"N° Agrément : ${assistante['numeroAgrement']}\nZone : ${assistante['zone']} | Capacité : ${assistante['capacite']}"),
|
|
||||||
trailing: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.edit),
|
|
||||||
onPressed: () {
|
|
||||||
// TODO: Ajouter modification
|
|
||||||
},
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.delete),
|
|
||||||
onPressed: () {
|
|
||||||
// TODO: Ajouter suppression
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildFilterSection() {
|
void _openAssistanteDetails(AssistanteMaternelleModel assistante) {
|
||||||
return Wrap(
|
showDialog<void>(
|
||||||
spacing: 16,
|
context: context,
|
||||||
runSpacing: 8,
|
builder: (context) => AdminDetailModal(
|
||||||
children: [
|
title: assistante.user.fullName.isEmpty
|
||||||
SizedBox(
|
? 'Assistante maternelle'
|
||||||
width: 200,
|
: assistante.user.fullName,
|
||||||
child: TextField(
|
subtitle: assistante.user.email,
|
||||||
decoration: const InputDecoration(
|
fields: [
|
||||||
labelText: "Zone géographique",
|
AdminDetailField(label: 'ID', value: _v(assistante.user.id)),
|
||||||
border: OutlineInputBorder(),
|
AdminDetailField(
|
||||||
),
|
label: 'Numero agrement',
|
||||||
onChanged: (value) {
|
value: _v(assistante.approvalNumber),
|
||||||
// TODO: Ajouter logique de filtrage par zone
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
AdminDetailField(
|
||||||
SizedBox(
|
label: 'Ville residence',
|
||||||
width: 200,
|
value: _v(assistante.residenceCity),
|
||||||
child: TextField(
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: "Capacité minimum",
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
keyboardType: TextInputType.number,
|
|
||||||
onChanged: (value) {
|
|
||||||
// TODO: Ajouter logique de filtrage par capacité
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
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')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _v(String? value) => (value == null || value.isEmpty) ? '-' : value;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,134 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
const AdminUserCard({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.subtitleLines,
|
||||||
|
this.avatarUrl,
|
||||||
|
this.fallbackIcon = Icons.person,
|
||||||
|
this.actions = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
@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,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
side: BorderSide(color: 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,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
infoLine,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.black54,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,8 @@ 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 {
|
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;
|
||||||
@@ -36,7 +37,8 @@ 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),
|
||||||
const SizedBox(width: 24),
|
const SizedBox(width: 24),
|
||||||
_buildNavItem(context, 'Paramètres', 1, enabled: true),
|
_buildNavItem(context, 'Paramètres', 1, enabled: true),
|
||||||
],
|
],
|
||||||
@@ -78,7 +80,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,
|
||||||
@@ -133,11 +136,124 @@ class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidge
|
|||||||
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;
|
||||||
|
|
||||||
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',
|
||||||
|
}) : 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: [
|
||||||
|
_buildSubNavItem(context, 'Gestionnaires', 0),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
_buildSubNavItem(context, 'Parents', 1),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
_buildSubNavItem(context, 'Assistantes maternelles', 2),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
_buildSubNavItem(context, 'Administrateurs', 3),
|
||||||
|
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 +269,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,108 @@
|
|||||||
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 GestionnaireCreateDialog(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: [
|
|
||||||
const Expanded(
|
|
||||||
child: TextField(
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: "Rechercher un gestionnaire...",
|
|
||||||
prefixIcon: Icon(Icons.search),
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
// Rediriger vers la page de création
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.add),
|
|
||||||
label: const Text("Créer un gestionnaire"),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
// 🔹 Liste des gestionnaires
|
return UserList(
|
||||||
Expanded(
|
isLoading: _isLoading,
|
||||||
child: ListView.builder(
|
error: _error,
|
||||||
itemCount: 5, // À remplacer par liste dynamique
|
isEmpty: filteredGestionnaires.isEmpty,
|
||||||
itemBuilder: (context, index) {
|
emptyMessage: 'Aucun gestionnaire trouvé.',
|
||||||
return GestionnaireCard(
|
itemCount: filteredGestionnaires.length,
|
||||||
name: "Dupont $index",
|
itemBuilder: (context, index) {
|
||||||
email: "dupont$index@mail.com",
|
final user = filteredGestionnaires[index];
|
||||||
);
|
return AdminUserCard(
|
||||||
|
title: user.fullName,
|
||||||
|
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: () {
|
||||||
|
_openGestionnaireEditDialog(user);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
],
|
||||||
],
|
);
|
||||||
),
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -234,12 +264,21 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
context,
|
context,
|
||||||
icon: Icons.email_outlined,
|
icon: Icons.email_outlined,
|
||||||
title: 'Configuration Email (SMTP)',
|
title: 'Configuration Email (SMTP)',
|
||||||
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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -290,14 +345,22 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
context,
|
context,
|
||||||
icon: Icons.palette_outlined,
|
icon: Icons.palette_outlined,
|
||||||
title: 'Personnalisation',
|
title: 'Personnalisation',
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
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,154 @@
|
|||||||
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();
|
||||||
|
|
||||||
|
return UserList(
|
||||||
|
isLoading: _isLoading,
|
||||||
|
error: _error,
|
||||||
|
isEmpty: filteredParents.isEmpty,
|
||||||
|
emptyMessage: 'Aucun parent trouvé.',
|
||||||
|
itemCount: filteredParents.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final parent = filteredParents[index];
|
||||||
|
return AdminUserCard(
|
||||||
|
title: parent.user.fullName,
|
||||||
|
avatarUrl: parent.user.photoUrl,
|
||||||
|
subtitleLines: [
|
||||||
|
parent.user.email,
|
||||||
|
'Statut : ${_displayStatus(parent.user.statut)} | Enfants : ${parent.childrenCount}',
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.edit),
|
||||||
|
tooltip: 'Modifier',
|
||||||
|
onPressed: () {
|
||||||
|
_openParentDetails(parent);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"nom": "Lucie Morel",
|
|
||||||
"email": "lucie.morel@email.com",
|
|
||||||
"statut": "En attente",
|
|
||||||
"enfants": 1,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
|
|
||||||
_buildSearchSection(),
|
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
ListView.builder(
|
|
||||||
shrinkWrap: true,
|
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
|
||||||
itemCount: parents.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final parent = parents[index];
|
|
||||||
return Card(
|
|
||||||
margin: const EdgeInsets.symmetric(vertical: 8),
|
|
||||||
child: ListTile(
|
|
||||||
leading: const Icon(Icons.person_outline),
|
|
||||||
title: Text(parent['nom'].toString()),
|
|
||||||
subtitle: Text(
|
|
||||||
"${parent['email']}\nStatut : ${parent['statut']} | Enfants : ${parent['enfants']}",
|
|
||||||
),
|
|
||||||
isThreeLine: true,
|
|
||||||
trailing: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.visibility),
|
|
||||||
tooltip: "Voir dossier",
|
|
||||||
onPressed: () {
|
|
||||||
// TODO: Voir le statut du dossier
|
|
||||||
},
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.edit),
|
|
||||||
tooltip: "Modifier",
|
|
||||||
onPressed: () {
|
|
||||||
// TODO: Modifier 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(),
|
}
|
||||||
),
|
}
|
||||||
onChanged: (value) {
|
|
||||||
// TODO: Ajouter logique de recherche
|
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),
|
||||||
),
|
),
|
||||||
),
|
AdminDetailField(
|
||||||
SizedBox(
|
label: 'Telephone',
|
||||||
width: 220,
|
value: _v(parent.user.telephone),
|
||||||
child: DropdownButtonFormField<String>(
|
|
||||||
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
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
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),
|
||||||
|
),
|
||||||
|
AdminDetailField(
|
||||||
|
label: 'Nombre d\'enfants',
|
||||||
|
value: parent.childrenCount.toString(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
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,227 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:p_tits_pas/screens/administrateurs/creation/admin_create.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 AdminUserManagementPanel extends StatefulWidget {
|
||||||
|
const AdminUserManagementPanel({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AdminUserManagementPanel> createState() =>
|
||||||
|
_AdminUserManagementPanelState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AdminUserManagementPanelState extends State<AdminUserManagementPanel> {
|
||||||
|
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) {
|
||||||
|
setState(() {
|
||||||
|
_subIndex = index;
|
||||||
|
_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) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
DashboardUserManagementSubBar(
|
||||||
|
selectedSubIndex: _subIndex,
|
||||||
|
onSubTabChange: _onSubTabChange,
|
||||||
|
searchController: _searchController,
|
||||||
|
searchHint: _searchHintForTab(),
|
||||||
|
filterControl: _subBarFilterControl(),
|
||||||
|
onAddPressed: _handleAddPressed,
|
||||||
|
addLabel: 'Ajouter',
|
||||||
|
),
|
||||||
|
Expanded(child: _buildBody()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleAddPressed() async {
|
||||||
|
if (_subIndex == 0) {
|
||||||
|
final created = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (dialogContext) {
|
||||||
|
return const GestionnaireCreateDialog();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
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 AdminCreateDialog();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
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 uniquement pour les gestionnaires et les administrateurs.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+61
@@ -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"
|
||||||
Reference in New Issue
Block a user