Compare commits

..

25 Commits

Author SHA1 Message Date
060e610a75 Merge branch 'develop' (squash) – Reprise après refus #111
Made-with: Cursor
2026-03-12 23:06:04 +01:00
7e32eef0a7 Merge branch 'develop' (squash) – Refus sans suppression #110
Made-with: Cursor
2026-03-12 22:57:45 +01:00
aa4e240ad1 Merge branch 'develop' (squash) – Validation dossier famille #108
Made-with: Cursor
2026-03-12 22:47:41 +01:00
a92447aaf0 Merge branch 'develop' (squash) – Liste familles en attente #106
Made-with: Cursor
2026-03-12 22:37:41 +01:00
94c8a0d97a Merge branch 'develop' (squash) – Statut refusé #105, script Gitea fallback ~/.bashrc
Made-with: Cursor
2026-03-12 22:28:13 +01:00
af489f39b4 Merge branch 'develop' (squash) – Numéro de dossier #103 et autres avancements
Made-with: Cursor
2026-03-12 22:14:21 +01:00
aefe590d2c Squash merge develop into master (câblage inscription AM #91)
Made-with: Cursor
2026-02-26 21:21:17 +01:00
f749484731 test(inscription AM): Préremplissage données de test Marie DUBOIS (squash develop)
Étapes 1 à 3 du formulaire d'inscription AM : données du jeu de test
officiel (03_seed_test_data.sql) au lieu du générateur aléatoire.

Made-with: Cursor
2026-02-26 19:10:58 +01:00
ca98821b3e Merge develop into master (squash): ticket #102 NIR harmonisation
- Backend: DTO NIR 15 car 2A/2B, validation format+clé, warning cohérence
- BDD: nir_chiffre NOT NULL, migration pour bases existantes
- Seeds: 02 nir_chiffre, 03 Marie 2A / Fatima 99
- Frontend: nir_utils, nir_text_field, formulaire pro, mock inscription AM

Made-with: Cursor
2026-02-26 13:55:42 +01:00
b1a80f85c9 Squash merge develop into master (feat #25 API users/pending, dashboards, login)
Made-with: Cursor
2026-02-26 10:44:04 +01:00
e713c05da1 feat: Bandeau générique, dashboards et doc (squash develop, Closes #100)
- Bandeau générique (DashboardBandeau) pour Parent, Admin, Gestionnaire, AM
- ParentDashboardScreen, AdminDashboardScreen, GestionnaireDashboardScreen, AM dashboard
- AppFooter responsive, scripts Gitea (create/list issues parent API)
- Doc: ticket #101 Inscription Parent API, mise à jour 23_LISTE-TICKETS
- User.fromJson robustesse (nullable id/email/role)
- Suppression dashboard_app_bar.dart au profit de dashboard_bandeau.dart

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

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

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

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-21 14:40:32 +01:00
92 changed files with 6974 additions and 1253 deletions

View File

@ -0,0 +1,89 @@
/**
* Crée l'issue Gitea "[Frontend] Inscription Parent Branchement soumission formulaire à l'API"
* Usage: node backend/scripts/create-gitea-issue-parent-api.js
* Token : .gitea-token (racine du dépôt), sinon GITEA_TOKEN, sinon docs/BRIEFING-FRONTEND.md (voir PROCEDURE-API-GITEA.md)
*/
const https = require('https');
const fs = require('fs');
const path = require('path');
const repoRoot = path.join(__dirname, '../..');
let token = process.env.GITEA_TOKEN;
if (!token) {
try {
const tokenFile = path.join(repoRoot, '.gitea-token');
if (fs.existsSync(tokenFile)) {
token = fs.readFileSync(tokenFile, 'utf8').trim();
}
} catch (_) {}
}
if (!token) {
try {
const briefing = fs.readFileSync(path.join(repoRoot, 'docs/BRIEFING-FRONTEND.md'), 'utf8');
const m = briefing.match(/Token:\s*(giteabu_[a-f0-9]+)/);
if (m) token = m[1].trim();
} catch (_) {}
}
if (!token) {
console.error('Token non trouvé : créer .gitea-token à la racine ou export GITEA_TOKEN (voir docs/PROCEDURE-API-GITEA.md)');
process.exit(1);
}
const body = `## Description
Branchement du formulaire d'inscription parent (étape 5, récapitulatif) à l'endpoint d'inscription. Aujourd'hui la soumission n'appelle pas l'API : elle affiche uniquement une modale puis redirige vers le login.
**Estimation** : 4h | **Labels** : frontend, p3, auth, cdc
## Tâches
- [ ] Créer un service ou méthode (ex. AuthService.registerParent) appelant POST /api/v1/auth/register/parent
- [ ] Construire le body (DTO) à partir de UserRegistrationData (parent1, parent2, children, motivationText, CGU) en cohérence avec le backend (#18)
- [ ] Dans ParentRegisterStep5Screen, au clic « Soumettre » : appel API puis modale + redirection ou message d'erreur
- [ ] Gestion des photos enfants (base64 ou multipart selon API)
## Référence
20_WORKFLOW-CREATION-COMPTE.md § Étape 3 Inscription d'un parent, backend #18`;
const payload = JSON.stringify({
title: "[Frontend] Inscription Parent Branchement soumission formulaire à l'API",
body,
});
const opts = {
hostname: 'git.ptits-pas.fr',
path: '/api/v1/repos/jmartin/petitspas/issues',
method: 'POST',
headers: {
Authorization: 'token ' + token,
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload),
},
};
const req = https.request(opts, (res) => {
let d = '';
res.on('data', (c) => (d += c));
res.on('end', () => {
try {
const o = JSON.parse(d);
if (o.number) {
console.log('NUMBER:', o.number);
console.log('URL:', o.html_url);
} else {
console.error('Erreur API:', o.message || d);
process.exit(1);
}
} catch (e) {
console.error('Réponse:', d);
process.exit(1);
}
});
});
req.on('error', (e) => {
console.error(e);
process.exit(1);
});
req.write(payload);
req.end();

View File

@ -0,0 +1,64 @@
/**
* Liste toutes les issues Gitea (ouvertes + fermées) pour jmartin/petitspas.
* Token : .gitea-token (racine), GITEA_TOKEN, ou docs/BRIEFING-FRONTEND.md
*/
const https = require('https');
const fs = require('fs');
const path = require('path');
const repoRoot = path.join(__dirname, '../..');
let token = process.env.GITEA_TOKEN;
if (!token) {
try {
const tokenFile = path.join(repoRoot, '.gitea-token');
if (fs.existsSync(tokenFile)) token = fs.readFileSync(tokenFile, 'utf8').trim();
} catch (_) {}
}
if (!token) {
try {
const briefing = fs.readFileSync(path.join(repoRoot, 'docs/BRIEFING-FRONTEND.md'), 'utf8');
const m = briefing.match(/Token:\s*(giteabu_[a-f0-9]+)/);
if (m) token = m[1].trim();
} catch (_) {}
}
if (!token) {
console.error('Token non trouvé');
process.exit(1);
}
function get(path) {
return new Promise((resolve, reject) => {
const opts = { hostname: 'git.ptits-pas.fr', path, method: 'GET', headers: { Authorization: 'token ' + token } };
const req = https.request(opts, (res) => {
let d = '';
res.on('data', (c) => (d += c));
res.on('end', () => {
try { resolve(JSON.parse(d)); } catch (e) { reject(e); }
});
});
req.on('error', reject);
req.end();
});
}
async function main() {
const seen = new Map();
for (const state of ['open', 'closed']) {
for (let page = 1; ; page++) {
const raw = await get('/api/v1/repos/jmartin/petitspas/issues?state=' + state + '&limit=50&page=' + page + '&type=issues');
if (raw && raw.message && !Array.isArray(raw)) {
console.error('API:', raw.message);
process.exit(1);
}
const list = Array.isArray(raw) ? raw : [];
for (const i of list) {
if (!i.pull_request) seen.set(i.number, { number: i.number, title: i.title, state: i.state });
}
if (list.length < 50) break;
}
}
const all = [...seen.values()].sort((a, b) => a.number - b.number);
console.log(JSON.stringify(all, null, 2));
}
main().catch((e) => { console.error(e); process.exit(1); });

View File

@ -16,6 +16,7 @@ import { AllExceptionsFilter } from './common/filters/all_exceptions.filters';
import { EnfantsModule } from './routes/enfants/enfants.module';
import { AppConfigModule } from './modules/config/config.module';
import { DocumentsLegauxModule } from './modules/documents-legaux';
import { RelaisModule } from './routes/relais/relais.module';
@Module({
imports: [
@ -53,6 +54,7 @@ import { DocumentsLegauxModule } from './modules/documents-legaux';
AuthModule,
AppConfigModule,
DocumentsLegauxModule,
RelaisModule,
],
controllers: [AppController],
providers: [

View File

@ -0,0 +1,109 @@
/**
* Utilitaire de validation du NIR (numéro de sécurité sociale français).
* - Format 15 caractères (chiffres ou 2A/2B pour la Corse).
* - Clé de contrôle : 97 - (NIR13 mod 97). Pour 2A/2B, conversion temporaire (INSEE : 2A19, 2B20).
* - En cas d'incohérence avec les données (sexe, date, lieu) : warning uniquement, pas de rejet.
*/
const NIR_CORSE_2A = '19';
const NIR_CORSE_2B = '20';
/** Regex 15 caractères : sexe (1-3) + 4 chiffres + (2A|2B|2 chiffres) + 6 chiffres + 2 chiffres clé */
const NIR_FORMAT = /^[1-3]\d{4}(?:2A|2B|\d{2})\d{6}\d{2}$/i;
/**
* Convertit le NIR en chaîne de 13 chiffres pour le calcul de la clé (2A19, 2B20).
*/
export function nirTo13Digits(nir: string): string {
const n = nir.toUpperCase().replace(/\s/g, '');
if (n.length !== 15) return '';
const dept = n.slice(5, 7);
let deptNum: string;
if (dept === '2A') deptNum = NIR_CORSE_2A;
else if (dept === '2B') deptNum = NIR_CORSE_2B;
else deptNum = dept;
return n.slice(0, 5) + deptNum + n.slice(7, 13);
}
/**
* Vérifie que le format NIR est valide (15 caractères, 2A/2B acceptés).
*/
export function isNirFormatValid(nir: string): boolean {
if (!nir || typeof nir !== 'string') return false;
const n = nir.replace(/\s/g, '').toUpperCase();
return NIR_FORMAT.test(n);
}
/**
* Calcule la clé de contrôle attendue (97 - (NIR13 mod 97)).
* Retourne un nombre entre 1 et 97.
*/
export function computeNirKey(nir13: string): number {
const num = parseInt(nir13, 10);
if (Number.isNaN(num) || nir13.length !== 13) return -1;
return 97 - (num % 97);
}
/**
* Vérifie la clé de contrôle du NIR (15 caractères).
* Retourne true si le NIR est valide (format + clé).
*/
export function isNirKeyValid(nir: string): boolean {
const n = nir.replace(/\s/g, '').toUpperCase();
if (n.length !== 15) return false;
const nir13 = nirTo13Digits(n);
if (nir13.length !== 13) return false;
const expectedKey = computeNirKey(nir13);
const actualKey = parseInt(n.slice(13, 15), 10);
return expectedKey === actualKey;
}
export interface NirValidationResult {
valid: boolean;
error?: string;
warning?: string;
}
/**
* Valide le NIR (format + clé). En cas d'incohérence avec date de naissance ou sexe, ajoute un warning sans invalider.
*/
export function validateNir(
nir: string,
options?: { dateNaissance?: string; genre?: 'H' | 'F' },
): NirValidationResult {
const n = (nir || '').replace(/\s/g, '').toUpperCase();
if (n.length === 0) return { valid: false, error: 'Le NIR est requis' };
if (!isNirFormatValid(n)) {
return { valid: false, error: 'Le NIR doit contenir 15 caractères (chiffres, ou 2A/2B pour la Corse)' };
}
if (!isNirKeyValid(n)) {
return { valid: false, error: 'Clé de contrôle du NIR invalide' };
}
let warning: string | undefined;
if (options?.genre) {
const sexNir = n[0];
const expectedSex = options.genre === 'F' ? '2' : '1';
if (sexNir !== expectedSex) {
warning = 'Le NIR ne correspond pas au genre indiqué (position 1 du NIR).';
}
}
if (options?.dateNaissance) {
try {
const d = new Date(options.dateNaissance);
if (!Number.isNaN(d.getTime())) {
const year2 = d.getFullYear() % 100;
const month = d.getMonth() + 1;
const nirYear = parseInt(n.slice(1, 3), 10);
const nirMonth = parseInt(n.slice(3, 5), 10);
if (nirYear !== year2 || nirMonth !== month) {
warning = warning
? `${warning} Le NIR ne correspond pas à la date de naissance (positions 2-5).`
: 'Le NIR ne correspond pas à la date de naissance indiquée (positions 2-5).';
}
}
} catch {
// ignore
}
}
return { valid: true, warning };
}

View File

@ -0,0 +1,15 @@
import { DataSource } from 'typeorm';
import { config } from 'dotenv';
config();
export default new DataSource({
type: 'postgres',
host: process.env.DATABASE_HOST,
port: parseInt(process.env.DATABASE_PORT || '5432', 10),
username: process.env.DATABASE_USERNAME,
password: process.env.DATABASE_PASSWORD,
database: process.env.DATABASE_NAME,
entities: ['src/**/*.entity.ts'],
migrations: ['src/migrations/*.ts'],
});

View File

@ -48,4 +48,7 @@ export class AssistanteMaternelle {
@Column( { name: 'place_disponible', type: 'integer', nullable: true })
places_available?: number;
/** Numéro de dossier (format AAAA-NNNNNN), même valeur que sur utilisateurs (ticket #103) */
@Column({ name: 'numero_dossier', length: 20, nullable: true })
numero_dossier?: string;
}

View File

@ -1,5 +1,5 @@
import {
Entity, PrimaryColumn, OneToOne, JoinColumn,
Entity, PrimaryColumn, Column, OneToOne, JoinColumn,
ManyToOne, OneToMany
} from 'typeorm';
import { Users } from './users.entity';
@ -21,6 +21,10 @@ export class Parents {
@JoinColumn({ name: 'id_co_parent', referencedColumnName: 'id' })
co_parent?: Users;
/** Numéro de dossier famille (format AAAA-NNNNNN), attribué à la soumission (ticket #103) */
@Column({ name: 'numero_dossier', length: 20, nullable: true })
numero_dossier?: string;
// Lien vers enfants via la table enfants_parents
@OneToMany(() => ParentsChildren, pc => pc.parent)
parentChildren: ParentsChildren[];

View File

@ -0,0 +1,35 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, OneToMany } from 'typeorm';
import { Users } from './users.entity';
@Entity('relais', { schema: 'public' })
export class Relais {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'nom' })
nom: string;
@Column({ name: 'adresse' })
adresse: string;
@Column({ type: 'jsonb', name: 'horaires_ouverture', nullable: true })
horaires_ouverture?: any;
@Column({ name: 'ligne_fixe', nullable: true })
ligne_fixe?: string;
@Column({ default: true, name: 'actif' })
actif: boolean;
@Column({ type: 'text', name: 'notes', nullable: true })
notes?: string;
@CreateDateColumn({ name: 'cree_le', type: 'timestamptz' })
cree_le: Date;
@UpdateDateColumn({ name: 'modifie_le', type: 'timestamptz' })
modifie_le: Date;
@OneToMany(() => Users, user => user.relais)
gestionnaires: Users[];
}

View File

@ -1,11 +1,12 @@
import {
Entity, PrimaryGeneratedColumn, Column,
CreateDateColumn, UpdateDateColumn,
OneToOne, OneToMany
OneToOne, OneToMany, ManyToOne, JoinColumn
} from 'typeorm';
import { AssistanteMaternelle } from './assistantes_maternelles.entity';
import { Parents } from './parents.entity';
import { Message } from './messages.entity';
import { Relais } from './relais.entity';
// Enums alignés avec la BDD PostgreSQL
export enum RoleType {
@ -28,6 +29,7 @@ export enum StatutUtilisateurType {
EN_ATTENTE = 'en_attente',
ACTIF = 'actif',
SUSPENDU = 'suspendu',
REFUSE = 'refuse',
}
export enum SituationFamilialeType {
@ -80,7 +82,7 @@ export class Users {
type: 'enum',
enum: StatutUtilisateurType,
enumName: 'statut_utilisateur_type', // correspond à l'enum de la db psql
default: StatutUtilisateurType.EN_ATTENTE,
default: StatutUtilisateurType.ACTIF,
name: 'statut'
})
statut: StatutUtilisateurType;
@ -117,6 +119,13 @@ export class Users {
@Column({ type: 'timestamptz', nullable: true, name: 'token_creation_mdp_expire_le' })
token_creation_mdp_expire_le?: Date;
/** Token pour reprise après refus (lien email), ticket #110 */
@Column({ nullable: true, name: 'token_reprise', length: 255 })
token_reprise?: string;
@Column({ type: 'timestamptz', nullable: true, name: 'token_reprise_expire_le' })
token_reprise_expire_le?: Date;
@Column({ nullable: true, name: 'ville' })
ville?: string;
@ -147,4 +156,15 @@ export class Users {
@OneToMany(() => Parents, parent => parent.co_parent)
co_parent_in?: Parents[];
@Column({ nullable: true, name: 'relais_id' })
relaisId?: string;
/** Numéro de dossier (format AAAA-NNNNNN), attribué à la soumission (ticket #103) */
@Column({ nullable: true, name: 'numero_dossier', length: 20 })
numero_dossier?: string;
@ManyToOne(() => Relais, relais => relais.gestionnaires, { nullable: true })
@JoinColumn({ name: 'relais_id' })
relais?: Relais;
}

View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { MailService } from './mail.service';
import { AppConfigModule } from '../config/config.module';
@Module({
imports: [AppConfigModule],
providers: [MailService],
exports: [MailService],
})
export class MailModule {}

View File

@ -0,0 +1,137 @@
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 é 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 é 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 é envoyé automatiquement. Merci de ne pas y répondre.
</p>
</div>
`;
await this.sendEmail(to, subject, html);
}
/**
* Email de refus de dossier avec lien reprise (token).
* Ticket #110 Refus sans suppression
*/
async sendRefusEmail(
to: string,
prenom: string,
nom: string,
comment: string | undefined,
token: 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 repriseLink = `${appUrl}/reprise?token=${encodeURIComponent(token)}`;
const subject = `Votre dossier compléments demandés`;
const commentBlock = comment
? `<p><strong>Message du gestionnaire :</strong></p><p>${comment.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</p>`
: '';
const html = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #333;">Bonjour ${prenom} ${nom},</h2>
<p>Votre dossier d'inscription sur <strong>${appName}</strong> n'a pas pu être validé en l'état.</p>
${commentBlock}
<p>Vous pouvez corriger les éléments indiqués et soumettre à nouveau votre dossier en cliquant sur le lien ci-dessous.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${repriseLink}" style="background-color: #2196F3; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">Reprendre mon dossier</a>
</div>
<p style="color: #666; font-size: 12px;">Ce lien est valable 7 jours. Si vous n'avez pas demandé cette reprise, vous pouvez ignorer cet email.</p>
<hr style="border: 1px solid #eee; margin: 20px 0;">
<p style="color: #666; font-size: 12px;">Cet email a é envoyé automatiquement. Merci de ne pas y répondre.</p>
</div>
`;
await this.sendEmail(to, subject, html);
}
}

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { NumeroDossierService } from './numero-dossier.service';
@Module({
providers: [NumeroDossierService],
exports: [NumeroDossierService],
})
export class NumeroDossierModule {}

View File

@ -0,0 +1,55 @@
import { Injectable } from '@nestjs/common';
import { EntityManager } from 'typeorm';
const FORMAT_MAX_SEQUENCE = 990000;
/**
* Service de génération du numéro de dossier (ticket #103).
* Format AAAA-NNNNNN (année + 6 chiffres), séquence par année.
* Si séquence >= 990000, overflowWarning est true (alerte gestionnaire).
*/
@Injectable()
export class NumeroDossierService {
/**
* Génère le prochain numéro de dossier dans le cadre d'une transaction.
* À appeler avec le manager de la transaction pour garantir l'unicité.
*/
async getNextNumeroDossier(manager: EntityManager): Promise<{
numero: string;
overflowWarning: boolean;
}> {
const year = new Date().getFullYear();
// Garantir l'existence de la ligne pour l'année
await manager.query(
`INSERT INTO numero_dossier_sequence (annee, prochain)
VALUES ($1, 1)
ON CONFLICT (annee) DO NOTHING`,
[year],
);
// Prendre le prochain numéro et incrémenter (FOR UPDATE pour concurrence)
const selectRows = await manager.query(
`SELECT prochain FROM numero_dossier_sequence WHERE annee = $1 FOR UPDATE`,
[year],
);
const currentVal = selectRows?.[0]?.prochain ?? 1;
await manager.query(
`UPDATE numero_dossier_sequence SET prochain = prochain + 1 WHERE annee = $1`,
[year],
);
const nextVal = currentVal;
const overflowWarning = nextVal >= FORMAT_MAX_SEQUENCE;
if (overflowWarning) {
// Log pour alerte gestionnaire (ticket #103)
console.warn(
`[NumeroDossierService] Séquence année ${year} >= ${FORMAT_MAX_SEQUENCE} (valeur ${nextVal}). Prévoir renouvellement ou format.`,
);
}
const numero = `${year}-${String(nextVal).padStart(6, '0')}`;
return { numero, overflowWarning };
}
}

View File

@ -1,4 +1,4 @@
import { Body, Controller, Get, Post, Req, UnauthorizedException, BadRequestException, UseGuards } from '@nestjs/common';
import { Body, Controller, Get, Patch, Post, Query, Req, UnauthorizedException, BadRequestException, UseGuards } from '@nestjs/common';
import { LoginDto } from './dto/login.dto';
import { AuthService } from './auth.service';
import { Public } from 'src/common/decorators/public.decorator';
@ -6,14 +6,17 @@ import { RegisterDto } from './dto/register.dto';
import { RegisterParentCompletDto } from './dto/register-parent-complet.dto';
import { RegisterAMCompletDto } from './dto/register-am-complet.dto';
import { ChangePasswordRequiredDto } from './dto/change-password.dto';
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
import { AuthGuard } from 'src/common/guards/auth.guard';
import type { Request } from 'express';
import { UserService } from '../user/user.service';
import { ProfileResponseDto } from './dto/profile_response.dto';
import { RefreshTokenDto } from './dto/refresh_token.dto';
import { ResoumettreRepriseDto } from './dto/resoumettre-reprise.dto';
import { RepriseIdentifyBodyDto } from './dto/reprise-identify.dto';
import { User } from 'src/common/decorators/user.decorator';
import { Users } from 'src/entities/users.entity';
import { RepriseDossierDto } from './dto/reprise-dossier.dto';
@ApiTags('Authentification')
@Controller('auth')
@ -65,6 +68,35 @@ export class AuthController {
return this.authService.inscrireAMComplet(dto);
}
@Public()
@Get('reprise-dossier')
@ApiOperation({ summary: 'Dossier pour reprise (token seul)' })
@ApiQuery({ name: 'token', required: true, description: 'Token reprise (lien email)' })
@ApiResponse({ status: 200, description: 'Données dossier pour préremplir', type: RepriseDossierDto })
@ApiResponse({ status: 404, description: 'Token invalide ou expiré' })
async getRepriseDossier(@Query('token') token: string): Promise<RepriseDossierDto> {
return this.authService.getRepriseDossier(token);
}
@Public()
@Patch('reprise-resoumettre')
@ApiOperation({ summary: 'Resoumettre le dossier (mise à jour + statut en_attente, invalide le token)' })
@ApiResponse({ status: 200, description: 'Dossier resoumis' })
@ApiResponse({ status: 404, description: 'Token invalide ou expiré' })
async resoumettreReprise(@Body() dto: ResoumettreRepriseDto) {
const { token, ...fields } = dto;
return this.authService.resoumettreReprise(token, fields);
}
@Public()
@Post('reprise-identify')
@ApiOperation({ summary: 'Modale reprise : numéro + email → type + token' })
@ApiResponse({ status: 201, description: 'type (parent/AM) + token pour GET reprise-dossier / PUT reprise-resoumettre' })
@ApiResponse({ status: 404, description: 'Aucun dossier en reprise pour ce numéro et email' })
async repriseIdentify(@Body() dto: RepriseIdentifyBodyDto) {
return this.authService.identifyReprise(dto.numero_dossier, dto.email);
}
@Public()
@Post('refresh')
@ApiBearerAuth('refresh_token')

View File

@ -10,12 +10,14 @@ import { Parents } from 'src/entities/parents.entity';
import { Children } from 'src/entities/children.entity';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { AppConfigModule } from 'src/modules/config';
import { NumeroDossierModule } from 'src/modules/numero-dossier/numero-dossier.module';
@Module({
imports: [
TypeOrmModule.forFeature([Users, Parents, Children, AssistanteMaternelle]),
forwardRef(() => UserModule),
AppConfigModule,
NumeroDossierModule,
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({

View File

@ -1,6 +1,7 @@
import {
ConflictException,
Injectable,
NotFoundException,
UnauthorizedException,
BadRequestException,
} from '@nestjs/common';
@ -22,7 +23,11 @@ import { Children, StatutEnfantType } from 'src/entities/children.entity';
import { ParentsChildren } from 'src/entities/parents_children.entity';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { LoginDto } from './dto/login.dto';
import { RepriseDossierDto } from './dto/reprise-dossier.dto';
import { RepriseIdentifyResponseDto } from './dto/reprise-identify.dto';
import { AppConfigService } from 'src/modules/config/config.service';
import { validateNir } from 'src/common/utils/nir.util';
import { NumeroDossierService } from 'src/modules/numero-dossier/numero-dossier.service';
@Injectable()
export class AuthService {
@ -31,6 +36,7 @@ export class AuthService {
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
private readonly appConfigService: AppConfigService,
private readonly numeroDossierService: NumeroDossierService,
@InjectRepository(Parents)
private readonly parentsRepo: Repository<Parents>,
@InjectRepository(Users)
@ -93,6 +99,12 @@ export class AuthService {
throw new UnauthorizedException('Votre compte a été suspendu. Contactez un administrateur.');
}
if (user.statut === StatutUtilisateurType.REFUSE) {
throw new UnauthorizedException(
'Votre compte a été refusé. Vous pouvez corriger votre dossier et le soumettre à nouveau ; un gestionnaire pourra le réexaminer.',
);
}
return this.generateTokens(user.id, user.email, user.role);
}
@ -193,6 +205,8 @@ export class AuthService {
dateExpiration.setDate(dateExpiration.getDate() + joursExpirationToken);
const resultat = await this.usersRepo.manager.transaction(async (manager) => {
const { numero: numeroDossier } = await this.numeroDossierService.getNextNumeroDossier(manager);
const parent1 = manager.create(Users, {
email: dto.email,
prenom: dto.prenom,
@ -205,6 +219,7 @@ export class AuthService {
ville: dto.ville,
token_creation_mdp: tokenCreationMdp,
token_creation_mdp_expire_le: dateExpiration,
numero_dossier: numeroDossier,
});
const parent1Enregistre = await manager.save(Users, parent1);
@ -229,6 +244,7 @@ export class AuthService {
ville: dto.co_parent_meme_adresse ? dto.ville : dto.co_parent_ville,
token_creation_mdp: tokenCoParent,
token_creation_mdp_expire_le: dateExpirationCoParent,
numero_dossier: numeroDossier,
});
parent2Enregistre = await manager.save(Users, parent2);
@ -236,6 +252,7 @@ export class AuthService {
const entiteParent = manager.create(Parents, {
user_id: parent1Enregistre.id,
numero_dossier: numeroDossier,
});
entiteParent.user = parent1Enregistre;
if (parent2Enregistre) {
@ -247,6 +264,7 @@ export class AuthService {
if (parent2Enregistre) {
const entiteCoParent = manager.create(Parents, {
user_id: parent2Enregistre.id,
numero_dossier: numeroDossier,
});
entiteCoParent.user = parent2Enregistre;
entiteCoParent.co_parent = parent1Enregistre;
@ -325,6 +343,18 @@ export class AuthService {
);
}
const nirNormalized = (dto.nir || '').replace(/\s/g, '').toUpperCase();
const nirValidation = validateNir(nirNormalized, {
dateNaissance: dto.date_naissance,
});
if (!nirValidation.valid) {
throw new BadRequestException(nirValidation.error || 'NIR invalide');
}
if (nirValidation.warning) {
// Warning uniquement : on ne bloque pas (AM souvent étrangères, DOM-TOM, Corse)
console.warn('[inscrireAMComplet] NIR warning:', nirValidation.warning, 'email=', dto.email);
}
const existe = await this.usersService.findByEmailOrNull(dto.email);
if (existe) {
throw new ConflictException('Un compte avec cet email existe déjà');
@ -347,6 +377,8 @@ export class AuthService {
dto.consentement_photo ? new Date() : undefined;
const resultat = await this.usersRepo.manager.transaction(async (manager) => {
const { numero: numeroDossier } = await this.numeroDossierService.getNextNumeroDossier(manager);
const user = manager.create(Users, {
email: dto.email,
prenom: dto.prenom,
@ -363,6 +395,7 @@ export class AuthService {
consentement_photo: dto.consentement_photo,
date_consentement_photo: dateConsentementPhoto,
date_naissance: dto.date_naissance ? new Date(dto.date_naissance) : undefined,
numero_dossier: numeroDossier,
});
const userEnregistre = await manager.save(Users, user);
@ -370,12 +403,13 @@ export class AuthService {
const am = amRepo.create({
user_id: userEnregistre.id,
approval_number: dto.numero_agrement,
nir: dto.nir,
nir: nirNormalized,
max_children: dto.capacite_accueil,
biography: dto.biographie,
residence_city: dto.ville ?? undefined,
agreement_date: dto.date_agrement ? new Date(dto.date_agrement) : undefined,
available: true,
numero_dossier: numeroDossier,
});
await amRepo.save(am);
@ -470,4 +504,47 @@ export class AuthService {
async logout(userId: string) {
return { success: true, message: 'Deconnexion'}
}
/** GET dossier reprise token seul. Ticket #111 */
async getRepriseDossier(token: string): Promise<RepriseDossierDto> {
const user = await this.usersService.findByTokenReprise(token);
if (!user) {
throw new NotFoundException('Token reprise invalide ou expiré.');
}
return {
id: user.id,
email: user.email,
prenom: user.prenom,
nom: user.nom,
telephone: user.telephone,
adresse: user.adresse,
ville: user.ville,
code_postal: user.code_postal,
numero_dossier: user.numero_dossier,
role: user.role,
photo_url: user.photo_url,
genre: user.genre,
situation_familiale: user.situation_familiale,
};
}
/** PUT resoumission reprise. Ticket #111 */
async resoumettreReprise(
token: string,
dto: { prenom?: string; nom?: string; telephone?: string; adresse?: string; ville?: string; code_postal?: string; photo_url?: string },
): Promise<Users> {
return this.usersService.resoumettreReprise(token, dto);
}
/** POST reprise-identify : numero_dossier + email → type + token. Ticket #111 */
async identifyReprise(numero_dossier: string, email: string): Promise<RepriseIdentifyResponseDto> {
const user = await this.usersService.findByNumeroDossierAndEmailForReprise(numero_dossier, email);
if (!user || !user.token_reprise) {
throw new NotFoundException('Aucun dossier en reprise trouvé pour ce numéro et cet email.');
}
return {
type: user.role === RoleType.PARENT ? 'parent' : 'assistante_maternelle',
token: user.token_reprise,
};
}
}

View File

@ -103,10 +103,12 @@ export class RegisterAMCompletDto {
@MaxLength(100)
lieu_naissance_pays?: string;
@ApiProperty({ example: '123456789012345', description: 'NIR 15 chiffres' })
@ApiProperty({ example: '123456789012345', description: 'NIR 15 caractères (chiffres, ou 2A/2B pour la Corse)' })
@IsString()
@IsNotEmpty({ message: 'Le NIR est requis' })
@Matches(/^\d{15}$/, { message: 'Le NIR doit contenir exactement 15 chiffres' })
@Matches(/^[1-3]\d{4}(?:2A|2B|\d{2})\d{6}\d{2}$/, {
message: 'Le NIR doit contenir 15 caractères (chiffres, ou 2A/2B pour la Corse)',
})
nir: string;
@ApiProperty({ example: 'AGR-2024-12345', description: "Numéro d'agrément" })

View File

@ -0,0 +1,44 @@
import { ApiProperty } from '@nestjs/swagger';
import { RoleType } from 'src/entities/users.entity';
/** Réponse GET /auth/reprise-dossier données dossier pour préremplir le formulaire reprise. Ticket #111 */
export class RepriseDossierDto {
@ApiProperty()
id: string;
@ApiProperty()
email: string;
@ApiProperty({ required: false })
prenom?: string;
@ApiProperty({ required: false })
nom?: string;
@ApiProperty({ required: false })
telephone?: string;
@ApiProperty({ required: false })
adresse?: string;
@ApiProperty({ required: false })
ville?: string;
@ApiProperty({ required: false })
code_postal?: string;
@ApiProperty({ required: false })
numero_dossier?: string;
@ApiProperty({ enum: RoleType })
role: RoleType;
@ApiProperty({ required: false, description: 'Pour AM' })
photo_url?: string;
@ApiProperty({ required: false })
genre?: string;
@ApiProperty({ required: false })
situation_familiale?: string;
}

View File

@ -0,0 +1,23 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsString, MaxLength } from 'class-validator';
/** Body POST /auth/reprise-identify numéro + email pour obtenir token reprise. Ticket #111 */
export class RepriseIdentifyBodyDto {
@ApiProperty({ example: '2026-000001' })
@IsString()
@MaxLength(20)
numero_dossier: string;
@ApiProperty({ example: 'parent@example.com' })
@IsEmail()
email: string;
}
/** Réponse POST /auth/reprise-identify */
export class RepriseIdentifyResponseDto {
@ApiProperty({ enum: ['parent', 'assistante_maternelle'] })
type: 'parent' | 'assistante_maternelle';
@ApiProperty({ description: 'Token à utiliser pour GET reprise-dossier et PUT reprise-resoumettre' })
token: string;
}

View File

@ -0,0 +1,49 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsOptional, IsString, MaxLength, IsUUID } from 'class-validator';
/** Body PUT /auth/reprise-resoumettre token + champs modifiables. Ticket #111 */
export class ResoumettreRepriseDto {
@ApiProperty({ description: 'Token reprise (reçu par email)' })
@IsUUID()
token: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
@MaxLength(100)
prenom?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
@MaxLength(100)
nom?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
@MaxLength(20)
telephone?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
adresse?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
@MaxLength(150)
ville?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
@MaxLength(10)
code_postal?: string;
@ApiProperty({ required: false, description: 'Pour AM' })
@IsOptional()
@IsString()
photo_url?: string;
}

View File

@ -0,0 +1,20 @@
import { ApiProperty } from '@nestjs/swagger';
export class PendingFamilyDto {
@ApiProperty({ example: 'Famille Dupont', description: 'Libellé affiché pour la famille' })
libelle: string;
@ApiProperty({
type: [String],
example: ['uuid-parent-1', 'uuid-parent-2'],
description: 'IDs utilisateur des parents de la famille',
})
parentIds: string[];
@ApiProperty({
nullable: true,
example: '2026-000001',
description: 'Numéro de dossier famille (format AAAA-NNNNNN)',
})
numero_dossier: string | null;
}

View File

@ -1,24 +1,66 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import { ParentsService } from './parents.service';
import { UserService } from '../user/user.service';
import { Parents } from 'src/entities/parents.entity';
import { Users } from 'src/entities/users.entity';
import { Roles } from 'src/common/decorators/roles.decorator';
import { RoleType } from 'src/entities/users.entity';
import { ApiBody, ApiResponse, ApiTags } from '@nestjs/swagger';
import { RoleType, StatutUtilisateurType } from 'src/entities/users.entity';
import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { CreateParentDto } from '../user/dto/create_parent.dto';
import { UpdateParentsDto } from '../user/dto/update_parent.dto';
import { AuthGuard } from 'src/common/guards/auth.guard';
import { RolesGuard } from 'src/common/guards/roles.guard';
import { User } from 'src/common/decorators/user.decorator';
import { PendingFamilyDto } from './dto/pending-family.dto';
@ApiTags('Parents')
@Controller('parents')
@UseGuards(AuthGuard, RolesGuard)
export class ParentsController {
constructor(private readonly parentsService: ParentsService) {}
constructor(
private readonly parentsService: ParentsService,
private readonly userService: UserService,
) {}
@Get('pending-families')
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@ApiOperation({ summary: 'Liste des familles en attente (une entrée par famille)' })
@ApiResponse({ status: 200, description: 'Liste des familles (libellé, parentIds, numero_dossier)', type: [PendingFamilyDto] })
@ApiResponse({ status: 403, description: 'Accès refusé' })
getPendingFamilies(): Promise<PendingFamilyDto[]> {
return this.parentsService.getPendingFamilies();
}
@Post(':parentId/valider-dossier')
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@ApiOperation({ summary: 'Valider tout le dossier famille (les 2 parents en une fois)' })
@ApiParam({ name: 'parentId', description: "UUID d'un des parents (user_id)" })
@ApiResponse({ status: 200, description: 'Utilisateurs validés (famille)' })
@ApiResponse({ status: 404, description: 'Parent introuvable' })
@ApiResponse({ status: 403, description: 'Accès refusé' })
async validerDossierFamille(
@Param('parentId') parentId: string,
@User() currentUser: Users,
@Body('comment') comment?: string,
): Promise<Users[]> {
const familyIds = await this.parentsService.getFamilyUserIds(parentId);
const validated: Users[] = [];
for (const userId of familyIds) {
const user = await this.userService.findOne(userId);
if (user.statut !== StatutUtilisateurType.EN_ATTENTE && user.statut !== StatutUtilisateurType.REFUSE) continue;
const saved = await this.userService.validateUser(userId, currentUser, comment);
validated.push(saved);
}
return validated;
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@Get()

View File

@ -1,12 +1,16 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Parents } from 'src/entities/parents.entity';
import { ParentsController } from './parents.controller';
import { ParentsService } from './parents.service';
import { Users } from 'src/entities/users.entity';
import { UserModule } from '../user/user.module';
@Module({
imports: [TypeOrmModule.forFeature([Parents, Users])],
imports: [
TypeOrmModule.forFeature([Parents, Users]),
forwardRef(() => UserModule),
],
controllers: [ParentsController],
providers: [ParentsService],
exports: [ParentsService,

View File

@ -10,6 +10,7 @@ import { Parents } from 'src/entities/parents.entity';
import { RoleType, Users } from 'src/entities/users.entity';
import { CreateParentDto } from '../user/dto/create_parent.dto';
import { UpdateParentsDto } from '../user/dto/update_parent.dto';
import { PendingFamilyDto } from './dto/pending-family.dto';
@Injectable()
export class ParentsService {
@ -71,4 +72,96 @@ export class ParentsService {
await this.parentsRepository.update(id, dto);
return this.findOne(id);
}
/**
* Liste des familles en attente (une entrée par famille).
* Famille = lien co_parent ou partage d'enfants (même logique que backfill #103).
* Uniquement les parents dont l'utilisateur a statut = en_attente.
*/
async getPendingFamilies(): Promise<PendingFamilyDto[]> {
const raw = await this.parentsRepository.query(`
WITH RECURSIVE
links AS (
SELECT p.id_utilisateur AS p1, p.id_co_parent AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL
UNION ALL
SELECT p.id_co_parent AS p1, p.id_utilisateur AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL
UNION ALL
SELECT ep1.id_parent AS p1, ep2.id_parent AS p2
FROM enfants_parents ep1
JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent
UNION ALL
SELECT ep2.id_parent AS p1, ep1.id_parent AS p2
FROM enfants_parents ep1
JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent
),
rec AS (
SELECT id_utilisateur AS id, id_utilisateur AS rep FROM parents
UNION
SELECT l.p2 AS id, LEAST(rec_alias.rep, l.p2) AS rep FROM links l JOIN rec rec_alias ON rec_alias.id = l.p1
),
family_rep AS (
SELECT id, (MIN(rep::text))::uuid AS rep FROM rec GROUP BY id
)
SELECT
'Famille ' || string_agg(u.nom, ' - ' ORDER BY u.nom, u.prenom) AS libelle,
array_agg(DISTINCT p.id_utilisateur ORDER BY p.id_utilisateur) AS "parentIds",
(array_agg(p.numero_dossier))[1] AS numero_dossier
FROM family_rep fr
JOIN parents p ON p.id_utilisateur = fr.id
JOIN utilisateurs u ON u.id = p.id_utilisateur
WHERE u.role = 'parent' AND u.statut = 'en_attente'
GROUP BY fr.rep
ORDER BY libelle
`);
return raw.map((r: { libelle: string; parentIds: unknown; numero_dossier: string | null }) => ({
libelle: r.libelle,
parentIds: Array.isArray(r.parentIds) ? r.parentIds.map(String) : [],
numero_dossier: r.numero_dossier ?? null,
}));
}
/**
* Retourne les user_id de tous les parents de la même famille (co_parent ou enfants partagés).
* @throws NotFoundException si parentId n'est pas un parent
*/
async getFamilyUserIds(parentId: string): Promise<string[]> {
const raw = await this.parentsRepository.query(
`
WITH RECURSIVE
links AS (
SELECT p.id_utilisateur AS p1, p.id_co_parent AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL
UNION ALL
SELECT p.id_co_parent AS p1, p.id_utilisateur AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL
UNION ALL
SELECT ep1.id_parent AS p1, ep2.id_parent AS p2
FROM enfants_parents ep1
JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent
UNION ALL
SELECT ep2.id_parent AS p1, ep1.id_parent AS p2
FROM enfants_parents ep1
JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent
),
rec AS (
SELECT id_utilisateur AS id, id_utilisateur AS rep FROM parents
UNION
SELECT l.p2 AS id, LEAST(rec_alias.rep, l.p2) AS rep FROM links l JOIN rec rec_alias ON rec_alias.id = l.p1
),
family_rep AS (
SELECT id, (MIN(rep::text))::uuid AS rep FROM rec GROUP BY id
),
input_rep AS (
SELECT rep FROM family_rep WHERE id = $1::uuid LIMIT 1
)
SELECT fr.id::text AS id
FROM family_rep fr
CROSS JOIN input_rep ir
WHERE fr.rep = ir.rep
`,
[parentId],
);
if (!raw || raw.length === 0) {
throw new NotFoundException('Parent introuvable ou pas encore enregistré en base.');
}
return raw.map((r: { id: string }) => r.id);
}
}

View File

@ -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;
}

View File

@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateRelaisDto } from './create-relais.dto';
export class UpdateRelaisDto extends PartialType(CreateRelaisDto) {}

View File

@ -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);
}
}

View File

@ -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 {}

View File

@ -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);
}
}

View File

@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, Matches } from 'class-validator';
/** Format AAAA-NNNNNN (année + 6 chiffres) */
const NUMERO_DOSSIER_REGEX = /^\d{4}-\d{6}$/;
export class AffecterNumeroDossierDto {
@ApiProperty({ example: '2026-000004', description: 'Numéro de dossier (AAAA-NNNNNN)' })
@IsNotEmpty({ message: 'Le numéro de dossier est requis' })
@Matches(NUMERO_DOSSIER_REGEX, {
message: 'Le numéro de dossier doit être au format AAAA-NNNNNN (ex: 2026-000001)',
})
numero_dossier: string;
}

View File

@ -1,4 +1,10 @@
import { OmitType } from "@nestjs/swagger";
import { PickType } from "@nestjs/swagger";
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) {}

View File

@ -1,4 +1,10 @@
import { OmitType } from "@nestjs/swagger";
import { ApiProperty, OmitType } from "@nestjs/swagger";
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;
}

View File

@ -36,10 +36,10 @@ export class CreateUserDto {
@MaxLength(100)
nom: string;
@ApiProperty({ enum: GenreType, required: false, default: GenreType.AUTRE })
@ApiProperty({ enum: GenreType, required: false })
@IsOptional()
@IsEnum(GenreType)
genre?: GenreType = GenreType.AUTRE;
genre?: GenreType;
@ApiProperty({ enum: RoleType })
@IsEnum(RoleType)
@ -86,7 +86,7 @@ export class CreateUserDto {
@ApiProperty({ default: false })
@IsOptional()
@IsBoolean()
consentement_photo?: boolean = false;
consentement_photo?: boolean;
@ApiProperty({ required: false })
@IsOptional()
@ -96,7 +96,7 @@ export class CreateUserDto {
@ApiProperty({ default: false })
@IsOptional()
@IsBoolean()
changement_mdp_obligatoire?: boolean = false;
changement_mdp_obligatoire?: boolean;
@ApiProperty({ example: true })
@IsBoolean()

View File

@ -1,4 +1,4 @@
import { PartialType } from "@nestjs/swagger";
import { CreateGestionnaireDto } from "./create_gestionnaire.dto";
export class UpdateGestionnaireDto extends PartialType(CreateGestionnaireDto) {}
export class UpdateGestionnaireDto extends PartialType(CreateGestionnaireDto) {}

View File

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

View File

@ -5,16 +5,18 @@ import {
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/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 { UpdateGestionnaireDto } from '../dto/update_gestionnaire.dto';
import * as bcrypt from 'bcrypt';
import { MailService } from 'src/modules/mail/mail.service';
@Injectable()
export class GestionnairesService {
constructor(
@InjectRepository(Users)
private readonly gestionnaireRepository: Repository<Users>,
private readonly mailService: MailService,
) { }
// Création dun gestionnaire
@ -30,30 +32,51 @@ export class GestionnairesService {
password: hashedPassword,
prenom: dto.prenom,
nom: dto.nom,
genre: dto.genre,
statut: dto.statut,
// genre: dto.genre, // Retiré
// statut: dto.statut, // Retiré
statut: StatutUtilisateurType.ACTIF,
telephone: dto.telephone,
adresse: dto.adresse,
photo_url: dto.photo_url,
consentement_photo: dto.consentement_photo ?? false,
date_consentement_photo: dto.date_consentement_photo
? new Date(dto.date_consentement_photo)
: undefined,
changement_mdp_obligatoire: dto.changement_mdp_obligatoire ?? false,
// adresse: dto.adresse, // Retiré
// photo_url: dto.photo_url, // Retiré
// consentement_photo: dto.consentement_photo ?? false, // Retiré
// date_consentement_photo: dto.date_consentement_photo // Retiré
// ? new Date(dto.date_consentement_photo)
// : undefined,
changement_mdp_obligatoire: true,
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
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
async findOne(id: string): Promise<Users> {
const gestionnaire = await this.gestionnaireRepository.findOne({
where: { id, role: RoleType.GESTIONNAIRE },
relations: ['relais'],
});
if (!gestionnaire) throw new NotFoundException('Gestionnaire introuvable');
return gestionnaire;
@ -68,13 +91,7 @@ export class GestionnairesService {
gestionnaire.password = await bcrypt.hash(dto.password, salt);
}
if (dto.date_consentement_photo !== undefined) {
gestionnaire.date_consentement_photo = dto.date_consentement_photo
? new Date(dto.date_consentement_photo)
: undefined;
}
const { password, date_consentement_photo, ...rest } = dto;
const { password, ...rest } = dto;
Object.entries(rest).forEach(([key, value]) => {
if (value !== undefined) {
(gestionnaire as any)[key] = value;

View File

@ -1,20 +1,34 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiParam, ApiResponse, ApiTags } 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 { User } from 'src/common/decorators/user.decorator';
import { RoleType, Users } from 'src/entities/users.entity';
import { UserService } from './user.service';
import { CreateUserDto } from './dto/create_user.dto';
import { CreateAdminDto } from './dto/create_admin.dto';
import { UpdateUserDto } from './dto/update_user.dto';
import { AffecterNumeroDossierDto } from './dto/affecter-numero-dossier.dto';
@ApiTags('Utilisateurs')
@ApiBearerAuth('access-token')
@UseGuards(AuthGuard)
@UseGuards(AuthGuard, RolesGuard)
@Controller('users')
export class UserController {
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)
@Post()
@Roles(RoleType.SUPER_ADMIN)
@ -26,6 +40,26 @@ export class UserController {
return this.userService.createUser(dto, currentUser);
}
// Lister les utilisateurs en attente de validation
@Get('pending')
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@ApiOperation({ summary: 'Lister les utilisateurs en attente de validation' })
findPendingUsers(
@Query('role') role?: RoleType
) {
return this.userService.findPendingUsers(role);
}
// Lister les comptes refusés (à corriger / reprise)
@Get('reprise')
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@ApiOperation({ summary: 'Lister les comptes refusés (reprise)' })
findRefusedUsers(
@Query('role') role?: RoleType
) {
return this.userService.findRefusedUsers(role);
}
// Lister tous les utilisateurs (super_admin uniquement)
@Get()
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
@ -43,9 +77,9 @@ export class UserController {
return this.userService.findOne(id);
}
// Modifier un utilisateur (réservé super_admin)
// Modifier un utilisateur (réservé super_admin et admin)
@Patch(':id')
@Roles(RoleType.SUPER_ADMIN)
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
@ApiOperation({ summary: 'Mettre à jour un utilisateur' })
@ApiParam({ name: 'id', description: "UUID de l'utilisateur" })
updateUser(
@ -56,6 +90,23 @@ export class UserController {
return this.userService.updateUser(id, dto, currentUser);
}
@Patch(':id/numero-dossier')
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@ApiOperation({
summary: 'Affecter un numéro de dossier à un utilisateur',
description: 'Permet de rapprocher deux dossiers ou dattribuer un numéro existant à un parent/AM. Réservé aux gestionnaires et administrateurs.',
})
@ApiParam({ name: 'id', description: "UUID de l'utilisateur (parent ou AM)" })
@ApiResponse({ status: 200, description: 'Numéro de dossier affecté' })
@ApiResponse({ status: 400, description: 'Format invalide, rôle non éligible, ou dossier déjà associé à 2 parents' })
@ApiResponse({ status: 404, description: 'Utilisateur introuvable' })
affecterNumeroDossier(
@Param('id') id: string,
@Body() dto: AffecterNumeroDossierDto,
) {
return this.userService.affecterNumeroDossier(id, dto.numero_dossier);
}
@Patch(':id/valider')
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@ApiOperation({ summary: 'Valider un compte utilisateur' })
@ -71,6 +122,18 @@ export class UserController {
return this.userService.validateUser(id, currentUser, comment);
}
@Patch(':id/refuser')
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@ApiOperation({ summary: 'Refuser un compte (à corriger)' })
@ApiParam({ name: 'id', description: "UUID de l'utilisateur" })
refuse(
@Param('id') id: string,
@User() currentUser: Users,
@Body('comment') comment?: string,
) {
return this.userService.refuseUser(id, currentUser, comment);
}
@Patch(':id/suspendre')
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@ApiOperation({ summary: 'Suspendre un compte utilisateur' })

View File

@ -10,6 +10,7 @@ import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entit
import { AssistantesMaternellesModule } from '../assistantes_maternelles/assistantes_maternelles.module';
import { Parents } from 'src/entities/parents.entity';
import { GestionnairesModule } from './gestionnaires/gestionnaires.module';
import { MailModule } from 'src/modules/mail/mail.module';
@Module({
imports: [TypeOrmModule.forFeature(
@ -22,6 +23,7 @@ import { GestionnairesModule } from './gestionnaires/gestionnaires.module';
ParentsModule,
AssistantesMaternellesModule,
GestionnairesModule,
MailModule,
],
controllers: [UserController],
providers: [UserService],

View File

@ -1,16 +1,21 @@
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { RoleType, StatutUtilisateurType, Users } from "src/entities/users.entity";
import { In, Repository } from "typeorm";
import { In, MoreThan, Repository } from "typeorm";
import { CreateUserDto } from "./dto/create_user.dto";
import { CreateAdminDto } from "./dto/create_admin.dto";
import { UpdateUserDto } from "./dto/update_user.dto";
import * as bcrypt from 'bcrypt';
import { StatutValidationType, Validation } from "src/entities/validations.entity";
import { Parents } from "src/entities/parents.entity";
import { AssistanteMaternelle } from "src/entities/assistantes_maternelles.entity";
import { MailService } from "src/modules/mail/mail.service";
import * as crypto from 'crypto';
@Injectable()
export class UserService {
private readonly logger = new Logger(UserService.name);
constructor(
@InjectRepository(Users)
private readonly usersRepository: Repository<Users>,
@ -22,7 +27,9 @@ export class UserService {
private readonly parentsRepository: Repository<Parents>,
@InjectRepository(AssistanteMaternelle)
private readonly assistantesRepository: Repository<AssistanteMaternelle>
private readonly assistantesRepository: Repository<AssistanteMaternelle>,
private readonly mailService: MailService,
) { }
async createUser(dto: CreateUserDto, currentUser?: Users): Promise<Users> {
@ -106,6 +113,48 @@ export class UserService {
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 findPendingUsers(role?: RoleType): Promise<Users[]> {
const where: any = { statut: StatutUtilisateurType.EN_ATTENTE };
if (role) {
where.role = role;
}
return this.usersRepository.find({ where });
}
/** Comptes refusés (à corriger) : liste pour reprise par le gestionnaire */
async findRefusedUsers(role?: RoleType): Promise<Users[]> {
const where: any = { statut: StatutUtilisateurType.REFUSE };
if (role) {
where.role = role;
}
return this.usersRepository.find({ where });
}
async findAll(): Promise<Users[]> {
return this.usersRepository.find();
}
@ -129,11 +178,26 @@ export class UserService {
async updateUser(id: string, dto: UpdateUserDto, currentUser: Users): Promise<Users> {
const user = await this.findOne(id);
// Le super administrateur conserve une identité figée.
if (
user.role === RoleType.SUPER_ADMIN &&
(dto.nom !== undefined || dto.prenom !== undefined)
) {
throw new ForbiddenException(
'Le nom et le prénom du super administrateur ne peuvent pas être modifiés',
);
}
// Interdire changement de rôle si pas super admin
if (dto.role && currentUser.role !== RoleType.SUPER_ADMIN) {
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
if (
(user.role === RoleType.ADMINISTRATEUR || user.role === RoleType.GESTIONNAIRE) &&
@ -165,7 +229,7 @@ export class UserService {
return this.usersRepository.save(user);
}
// Valider un compte utilisateur
// Valider un compte utilisateur (en_attente ou refuse -> actif)
async validateUser(user_id: string, currentUser: Users, comment?: string): Promise<Users> {
if (![RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE].includes(currentUser.role)) {
throw new ForbiddenException('Accès réservé aux super admins, administrateurs et gestionnaires');
@ -173,7 +237,11 @@ export class UserService {
const user = await this.usersRepository.findOne({ where: { id: user_id } });
if (!user) throw new NotFoundException('Utilisateur introuvable');
if (user.statut !== StatutUtilisateurType.EN_ATTENTE && user.statut !== StatutUtilisateurType.REFUSE) {
throw new BadRequestException('Seuls les comptes en attente ou refusés (à corriger) peuvent être validés.');
}
user.statut = StatutUtilisateurType.ACTIF;
const savedUser = await this.usersRepository.save(user);
if (user.role === RoleType.PARENT) {
@ -221,10 +289,165 @@ export class UserService {
await this.validationRepository.save(suspend);
return savedUser;
}
/** Refuser un compte (en_attente -> refuse) ; tracé validations, token reprise, email. Ticket #110 */
async refuseUser(user_id: string, currentUser: Users, comment?: string): Promise<Users> {
if (![RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE].includes(currentUser.role)) {
throw new ForbiddenException('Accès réservé aux super admins, administrateurs et gestionnaires');
}
const user = await this.usersRepository.findOne({ where: { id: user_id } });
if (!user) throw new NotFoundException('Utilisateur introuvable');
if (user.statut !== StatutUtilisateurType.EN_ATTENTE) {
throw new BadRequestException('Seul un compte en attente peut être refusé.');
}
const tokenReprise = crypto.randomUUID();
const expireLe = new Date();
expireLe.setDate(expireLe.getDate() + 7);
user.statut = StatutUtilisateurType.REFUSE;
user.token_reprise = tokenReprise;
user.token_reprise_expire_le = expireLe;
const savedUser = await this.usersRepository.save(user);
const validation = this.validationRepository.create({
user: savedUser,
type: 'refus_compte',
status: StatutValidationType.REFUSE,
validated_by: currentUser,
comment,
});
await this.validationRepository.save(validation);
try {
await this.mailService.sendRefusEmail(
savedUser.email,
savedUser.prenom ?? '',
savedUser.nom ?? '',
comment,
tokenReprise,
);
} catch (err) {
this.logger.warn(`Envoi email refus échoué pour ${savedUser.email}`, err);
}
return savedUser;
}
/**
* Affecter ou modifier le numéro de dossier d'un utilisateur (parent ou AM).
* Permet au gestionnaire/admin de rapprocher deux dossiers (même numéro pour plusieurs personnes).
* Garde-fou : au plus 2 parents par numéro de dossier (couple / co-parents).
*/
async affecterNumeroDossier(userId: string, numeroDossier: string): Promise<Users> {
const user = await this.usersRepository.findOne({ where: { id: userId } });
if (!user) throw new NotFoundException('Utilisateur introuvable');
if (user.role !== RoleType.PARENT && user.role !== RoleType.ASSISTANTE_MATERNELLE) {
throw new BadRequestException(
'Le numéro de dossier ne peut être affecté qu\'à un parent ou une assistante maternelle',
);
}
if (user.role === RoleType.PARENT) {
const uneAMALe = await this.assistantesRepository.count({
where: { numero_dossier: numeroDossier },
});
if (uneAMALe > 0) {
throw new BadRequestException(
'Ce numéro de dossier est celui d\'une assistante maternelle. Un numéro AM ne peut pas être affecté à un parent.',
);
}
const parentsAvecCeNumero = await this.parentsRepository.count({
where: { numero_dossier: numeroDossier },
});
const userADejaCeNumero = user.numero_dossier === numeroDossier;
if (!userADejaCeNumero && parentsAvecCeNumero >= 2) {
throw new BadRequestException(
'Un numéro de dossier ne peut être associé qu\'à 2 parents au maximum (couple / co-parents). Ce dossier a déjà 2 parents.',
);
}
}
if (user.role === RoleType.ASSISTANTE_MATERNELLE) {
const unParentLA = await this.parentsRepository.count({
where: { numero_dossier: numeroDossier },
});
if (unParentLA > 0) {
throw new BadRequestException(
'Ce numéro de dossier est celui d\'une famille (parent). Un numéro famille ne peut pas être affecté à une assistante maternelle.',
);
}
}
user.numero_dossier = numeroDossier;
const savedUser = await this.usersRepository.save(user);
if (user.role === RoleType.PARENT) {
await this.parentsRepository.update({ user_id: userId }, { numero_dossier: numeroDossier });
} else {
await this.assistantesRepository.update({ user_id: userId }, { numero_dossier: numeroDossier });
}
return savedUser;
}
/** Trouve un user par token reprise valide (non expiré). Ticket #111 */
async findByTokenReprise(token: string): Promise<Users | null> {
return this.usersRepository.findOne({
where: {
token_reprise: token,
statut: StatutUtilisateurType.REFUSE,
token_reprise_expire_le: MoreThan(new Date()),
},
});
}
/** Resoumission reprise : met à jour les champs autorisés, passe en en_attente, invalide le token. Ticket #111 */
async resoumettreReprise(
token: string,
dto: { prenom?: string; nom?: string; telephone?: string; adresse?: string; ville?: string; code_postal?: string; photo_url?: string },
): Promise<Users> {
const user = await this.findByTokenReprise(token);
if (!user) {
throw new NotFoundException('Token reprise invalide ou expiré.');
}
if (dto.prenom !== undefined) user.prenom = dto.prenom;
if (dto.nom !== undefined) user.nom = dto.nom;
if (dto.telephone !== undefined) user.telephone = dto.telephone;
if (dto.adresse !== undefined) user.adresse = dto.adresse;
if (dto.ville !== undefined) user.ville = dto.ville;
if (dto.code_postal !== undefined) user.code_postal = dto.code_postal;
if (dto.photo_url !== undefined) user.photo_url = dto.photo_url;
user.statut = StatutUtilisateurType.EN_ATTENTE;
user.token_reprise = undefined;
user.token_reprise_expire_le = undefined;
return this.usersRepository.save(user);
}
/** Pour modale reprise : numero_dossier + email → user en REFUSE avec token valide. Ticket #111 */
async findByNumeroDossierAndEmailForReprise(numero_dossier: string, email: string): Promise<Users | null> {
const user = await this.usersRepository.findOne({
where: {
email: email.trim().toLowerCase(),
numero_dossier: numero_dossier.trim(),
statut: StatutUtilisateurType.REFUSE,
token_reprise_expire_le: MoreThan(new Date()),
},
});
return user ?? null;
}
async remove(id: string, currentUser: Users): Promise<void> {
if (currentUser.role !== RoleType.SUPER_ADMIN) {
throw new ForbiddenException('Accès réservé aux super admins');
}
const user = await this.findOne(id);
if (user.role === RoleType.SUPER_ADMIN) {
throw new ForbiddenException(
'Le super administrateur ne peut pas être supprimé',
);
}
const result = await this.usersRepository.delete(id);
if (result.affected === 0) {
throw new NotFoundException('Utilisateur introuvable');

7
check_hash.js Normal file
View File

@ -0,0 +1,7 @@
const bcrypt = require('bcrypt');
const pass = '!Bezons2014';
bcrypt.hash(pass, 10).then(hash => {
console.log('New Hash:', hash);
}).catch(err => console.error(err));

View File

@ -11,7 +11,7 @@ DO $$ BEGIN
CREATE TYPE genre_type AS ENUM ('H', 'F');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_utilisateur_type') THEN
CREATE TYPE statut_utilisateur_type AS ENUM ('en_attente','actif','suspendu');
CREATE TYPE statut_utilisateur_type AS ENUM ('en_attente','actif','suspendu','refuse');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_enfant_type') THEN
CREATE TYPE statut_enfant_type AS ENUM ('a_naitre','actif','scolarise');
@ -80,7 +80,7 @@ CREATE INDEX idx_utilisateurs_token_creation_mdp
CREATE TABLE assistantes_maternelles (
id_utilisateur UUID PRIMARY KEY REFERENCES utilisateurs(id) ON DELETE CASCADE,
numero_agrement VARCHAR(50),
nir_chiffre CHAR(15),
nir_chiffre CHAR(15) NOT NULL,
nb_max_enfants INT,
biographie TEXT,
disponible BOOLEAN DEFAULT true,
@ -331,13 +331,50 @@ CREATE INDEX idx_acceptations_utilisateur ON acceptations_documents(id_utilisate
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
ADD COLUMN IF NOT EXISTS cgu_version_acceptee INTEGER,
ADD COLUMN IF NOT EXISTS cgu_acceptee_le TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS privacy_version_acceptee INTEGER,
ADD COLUMN IF NOT EXISTS privacy_acceptee_le TIMESTAMPTZ;
ADD COLUMN IF NOT EXISTS privacy_acceptee_le TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS relais_id UUID REFERENCES relais(id) ON DELETE SET NULL;
-- ==========================================================
-- Ticket #103 : Numéro de dossier (AAAA-NNNNNN, séquence par année)
-- ==========================================================
CREATE TABLE IF NOT EXISTS numero_dossier_sequence (
annee INT PRIMARY KEY,
prochain INT NOT NULL DEFAULT 1
);
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL;
ALTER TABLE assistantes_maternelles ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL;
ALTER TABLE parents ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL;
CREATE INDEX IF NOT EXISTS idx_utilisateurs_numero_dossier ON utilisateurs(numero_dossier) WHERE numero_dossier IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_assistantes_maternelles_numero_dossier ON assistantes_maternelles(numero_dossier) WHERE numero_dossier IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_parents_numero_dossier ON parents(numero_dossier) WHERE numero_dossier IS NOT NULL;
-- ==========================================================
-- Ticket #110 : Token reprise après refus (lien email)
-- ==========================================================
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS token_reprise VARCHAR(255) NULL;
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS token_reprise_expire_le TIMESTAMPTZ NULL;
CREATE INDEX IF NOT EXISTS idx_utilisateurs_token_reprise ON utilisateurs(token_reprise) WHERE token_reprise IS NOT NULL;
-- ==========================================================
-- Seed : Documents légaux génériques v1

View File

@ -0,0 +1,16 @@
-- Migration : rendre nir_chiffre NOT NULL (ticket #102)
-- À exécuter sur les bases existantes avant déploiement du schéma avec nir_chiffre NOT NULL.
-- Les lignes sans NIR reçoivent un NIR de test valide (format + clé) pour satisfaire la contrainte.
BEGIN;
-- Renseigner un NIR de test valide pour toute ligne où nir_chiffre est NULL
UPDATE assistantes_maternelles
SET nir_chiffre = '275119900100102'
WHERE nir_chiffre IS NULL;
-- Appliquer la contrainte NOT NULL
ALTER TABLE assistantes_maternelles
ALTER COLUMN nir_chiffre SET NOT NULL;
COMMIT;

View File

@ -0,0 +1,33 @@
-- Migration #103 : Numéro de dossier (format AAAA-NNNNNN, séquence par année)
-- Colonnes sur utilisateurs, assistantes_maternelles, parents.
-- Table de séquence par année pour génération unique.
BEGIN;
-- Table de séquence : une ligne par année, prochain = prochain numéro à attribuer (1..999999)
CREATE TABLE IF NOT EXISTS numero_dossier_sequence (
annee INT PRIMARY KEY,
prochain INT NOT NULL DEFAULT 1
);
-- Colonne sur utilisateurs (AM et parents : numéro attribué à la soumission)
ALTER TABLE utilisateurs
ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL;
-- Colonne sur assistantes_maternelles (redondant avec users pour accès direct)
ALTER TABLE assistantes_maternelles
ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL;
-- Colonne sur parents (un numéro par famille, même valeur sur les deux lignes si co-parent)
ALTER TABLE parents
ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL;
-- Index pour recherche par numéro
CREATE INDEX IF NOT EXISTS idx_utilisateurs_numero_dossier
ON utilisateurs(numero_dossier) WHERE numero_dossier IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_assistantes_maternelles_numero_dossier
ON assistantes_maternelles(numero_dossier) WHERE numero_dossier IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_parents_numero_dossier
ON parents(numero_dossier) WHERE numero_dossier IS NOT NULL;
COMMIT;

View File

@ -0,0 +1,122 @@
-- Backfill #103 : attribuer un numero_dossier aux entrées existantes (NULL)
-- Famille = lien co_parent OU partage d'au moins un enfant (même dossier).
-- Ordre : par année, AM puis familles (une entrée par famille), séquence 000001, 000002...
-- À exécuter après 2026_numero_dossier.sql
DO $$
DECLARE
yr INT;
seq INT;
num TEXT;
r RECORD;
family_user_ids UUID[];
BEGIN
-- Réinitialiser pour rejouer le backfill (cohérence AM + familles)
UPDATE parents SET numero_dossier = NULL;
UPDATE utilisateurs SET numero_dossier = NULL
WHERE role IN ('parent', 'assistante_maternelle');
UPDATE assistantes_maternelles SET numero_dossier = NULL;
FOR yr IN
SELECT DISTINCT EXTRACT(YEAR FROM u.cree_le)::INT
FROM utilisateurs u
WHERE (
(u.role = 'assistante_maternelle' AND u.numero_dossier IS NULL)
OR EXISTS (SELECT 1 FROM parents p WHERE p.id_utilisateur = u.id AND p.numero_dossier IS NULL)
)
ORDER BY 1
LOOP
seq := 0;
-- 1) AM : par ordre de création
FOR r IN
SELECT u.id
FROM utilisateurs u
WHERE u.role = 'assistante_maternelle'
AND u.numero_dossier IS NULL
AND EXTRACT(YEAR FROM u.cree_le) = yr
ORDER BY u.cree_le
LOOP
seq := seq + 1;
num := yr || '-' || LPAD(seq::TEXT, 6, '0');
UPDATE utilisateurs SET numero_dossier = num WHERE id = r.id;
UPDATE assistantes_maternelles SET numero_dossier = num WHERE id_utilisateur = r.id;
END LOOP;
-- 2) Familles : une entrée par "dossier" (co_parent OU enfants partagés)
-- family_rep = min(id) de la composante connexe (lien co_parent + partage d'enfants)
FOR r IN
WITH RECURSIVE
links AS (
SELECT p.id_utilisateur AS p1, p.id_co_parent AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL
UNION ALL
SELECT p.id_co_parent AS p1, p.id_utilisateur AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL
UNION ALL
SELECT ep1.id_parent AS p1, ep2.id_parent AS p2
FROM enfants_parents ep1
JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent
UNION ALL
SELECT ep2.id_parent AS p1, ep1.id_parent AS p2
FROM enfants_parents ep1
JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent
),
rec AS (
SELECT id_utilisateur AS id, id_utilisateur AS rep FROM parents
UNION
SELECT l.p2 AS id, LEAST(rec_alias.rep, l.p2) AS rep FROM links l JOIN rec rec_alias ON rec_alias.id = l.p1
),
family_rep AS (
SELECT id, MIN(rep::text)::uuid AS rep FROM rec GROUP BY id
),
fam_ordered AS (
SELECT fr.rep AS family_rep, MIN(u.cree_le) AS cree_le
FROM family_rep fr
JOIN parents p ON p.id_utilisateur = fr.id
JOIN utilisateurs u ON u.id = p.id_utilisateur
WHERE p.numero_dossier IS NULL
AND EXTRACT(YEAR FROM u.cree_le) = yr
GROUP BY fr.rep
ORDER BY MIN(u.cree_le)
)
SELECT fo.family_rep
FROM fam_ordered fo
LOOP
seq := seq + 1;
num := yr || '-' || LPAD(seq::TEXT, 6, '0');
WITH RECURSIVE
links AS (
SELECT p.id_utilisateur AS p1, p.id_co_parent AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL
UNION ALL
SELECT p.id_co_parent AS p1, p.id_utilisateur AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL
UNION ALL
SELECT ep1.id_parent AS p1, ep2.id_parent AS p2
FROM enfants_parents ep1
JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent
UNION ALL
SELECT ep2.id_parent AS p1, ep1.id_parent AS p2
FROM enfants_parents ep1
JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent
),
rec AS (
SELECT id_utilisateur AS id, id_utilisateur AS rep FROM parents
UNION
SELECT l.p2 AS id, LEAST(rec_alias.rep, l.p2) AS rep FROM links l JOIN rec rec_alias ON rec_alias.id = l.p1
),
family_rep AS (
SELECT id, MIN(rep::text)::uuid AS rep FROM rec GROUP BY id
)
SELECT array_agg(DISTINCT fr.id) INTO family_user_ids
FROM family_rep fr
WHERE fr.rep = r.family_rep;
UPDATE utilisateurs SET numero_dossier = num WHERE id = ANY(family_user_ids);
UPDATE parents SET numero_dossier = num WHERE id_utilisateur = ANY(family_user_ids);
END LOOP;
INSERT INTO numero_dossier_sequence (annee, prochain)
VALUES (yr, seq + 1)
ON CONFLICT (annee) DO UPDATE
SET prochain = GREATEST(numero_dossier_sequence.prochain, seq + 1);
END LOOP;
END $$;

View File

@ -0,0 +1,4 @@
-- Migration #105 : Statut utilisateur « refusé » (à corriger)
-- Ajout de la valeur 'refuse' à l'enum statut_utilisateur_type.
ALTER TYPE statut_utilisateur_type ADD VALUE IF NOT EXISTS 'refuse';

View File

@ -0,0 +1,10 @@
-- Migration #110 : Token reprise après refus (lien email)
-- Permet à l'utilisateur refusé de corriger et resoumettre via un lien sécurisé.
ALTER TABLE utilisateurs
ADD COLUMN IF NOT EXISTS token_reprise VARCHAR(255) NULL,
ADD COLUMN IF NOT EXISTS token_reprise_expire_le TIMESTAMPTZ NULL;
CREATE INDEX IF NOT EXISTS idx_utilisateurs_token_reprise
ON utilisateurs(token_reprise)
WHERE token_reprise IS NOT NULL;

View File

@ -58,9 +58,9 @@ INSERT INTO parents (id_utilisateur, id_co_parent)
VALUES ('55555555-5555-5555-5555-555555555555', NULL) -- parent2 sans co-parent
ON CONFLICT (id_utilisateur) DO NOTHING;
-- assistantes_maternelles
INSERT INTO assistantes_maternelles (id_utilisateur, numero_agrement, nb_max_enfants, disponible, ville_residence)
VALUES ('66666666-6666-6666-6666-666666666666', 'AGR-2025-0001', 3, true, 'Lille')
-- assistantes_maternelles (nir_chiffre NOT NULL depuis ticket #102)
INSERT INTO assistantes_maternelles (id_utilisateur, numero_agrement, nir_chiffre, nb_max_enfants, disponible, ville_residence)
VALUES ('66666666-6666-6666-6666-666666666666', 'AGR-2025-0001', '275119900100102', 3, true, 'Lille')
ON CONFLICT (id_utilisateur) DO NOTHING;
-- ------------------------------------------------------------

View File

@ -2,6 +2,9 @@
-- 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)
-- NIR : numéros de test (non réels), cohérents avec les données (date naissance, genre).
-- - Marie Dubois : née en Corse à Ajaccio → NIR 2A (test exception Corse).
-- - Fatima El Mansouri : née à l'étranger → NIR 99.
-- À exécuter après BDD.sql (init DB)
-- ============================================================
@ -36,10 +39,12 @@ VALUES
ON CONFLICT (id_utilisateur) DO NOTHING;
-- ========== ASSISTANTES MATERNELLES ==========
-- Marie Dubois (a0000003) : née en Corse à Ajaccio NIR 2A pour test exception Corse (1980-06-08, F).
-- Fatima El Mansouri (a0000004) : née à l'étranger NIR 99 pour test (1975-11-12, F).
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)
('a0000003-0003-0003-0003-000000000003', 'AGR-2019-095001', '280062A00100191', 4, 'Assistante maternelle agréée depuis 2019. Née en Corse à Ajaccio. 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', '275119900100102', 3, 'Assistante maternelle expérimentée. Née à l''étranger. 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 ==========

View File

@ -1,15 +1,15 @@
# 🎫 Liste Complète des Tickets - Projet P'titsPas
**Version** : 1.4
**Date** : 9 Février 2026
**Version** : 1.6
**Date** : 25 Février 2026
**Auteur** : Équipe PtitsPas
**Estimation totale** : ~184h
**Estimation totale** : ~208h
---
## 🔗 Liste des tickets Gitea
**Les numéros de section dans ce document = numéros dissues Gitea.** Ticket #14 dans le doc = issue Gitea #14, etc. Source : dépôt `jmartin/petitspas` (état au 9 février 2026).
**Les numéros de section dans ce document = numéros dissues Gitea.** Ticket #14 dans le doc = issue Gitea #14, etc. Source : dépôt `jmartin/petitspas` (état au 25 février 2026).
| Gitea # | Titre (dépôt) | Statut |
|--------|----------------|--------|
@ -25,13 +25,86 @@
| 12 | [Backend] Guard Configuration Initiale | ✅ Fermé |
| 13 | [Backend] Adaptation MailService pour config dynamique | ✅ Fermé |
| 14 | [Frontend] Panneau Paramètres / Configuration (première config + accès permanent) | Ouvert |
| 15 | [Frontend] Écran Paramètres (accès permanent) | Ouvert |
| 15 | [Frontend] Écran Paramètres (accès permanent) / Intégration panneau | Ouvert |
| 16 | [Doc] Documentation configuration on-premise | Ouvert |
| 1788 | (voir sections cidessous ; #82, #78, #79, #81, #83 ; #86, #87, #88 fermés en doublon) | — |
| 92 | [Frontend] Dashboard Admin - Données réelles et branchement API | Ouvert |
| 17 | [Backend] API Création gestionnaire | ✅ Terminé |
| 18 | [Backend] API Inscription Parent - REFONTE (Workflow complet 6 étapes) | ✅ Terminé |
| 19 | [Backend] API Inscription Parent (étape 2 - Parent 2) | ✅ Terminé |
| 20 | [Backend] API Inscription Parent (étape 3 - Enfants) | ✅ Terminé |
| 21 | [Backend] API Inscription Parent (étape 4-6 - Finalisation) | ✅ Terminé |
| 24 | [Backend] API Création mot de passe | Ouvert |
| 25 | [Backend] API Liste comptes en attente | Ouvert |
| 26 | [Backend] API Validation/Refus comptes | Ouvert |
| 27 | [Backend] Service Email - Installation Nodemailer | Ouvert |
| 28 | [Backend] Templates Email - Validation | Ouvert |
| 29 | [Backend] Templates Email - Refus | Ouvert |
| 30 | [Backend] Connexion - Vérification statut | Ouvert |
| 31 | [Backend] Changement MDP obligatoire première connexion | Ouvert |
| 32 | [Backend] Service Documents Légaux | Ouvert |
| 33 | [Backend] API Documents Légaux | Ouvert |
| 34 | [Backend] Traçabilité acceptations documents | Ouvert |
| 35 | [Frontend] Écran Création Gestionnaire | Ouvert |
| 36 | [Frontend] Inscription Parent - Étape 1 (Parent 1) | ✅ Terminé |
| 37 | [Frontend] Inscription Parent - Étape 2 (Parent 2) | Ouvert |
| 38 | [Frontend] Inscription Parent - Étape 3 (Enfants) | ✅ Terminé |
| 39 | [Frontend] Inscription Parent - Étapes 4-6 (Finalisation) | ✅ Terminé |
| 40 | [Frontend] Inscription AM - Panneau 1 (Identité) | ✅ Terminé |
| 41 | [Frontend] Inscription AM - Panneau 2 (Infos pro) | ✅ Terminé |
| 42 | [Frontend] Inscription AM - Finalisation | ✅ Terminé |
| 43 | [Frontend] Écran Création Mot de Passe | Ouvert |
| 44 | [Frontend] Dashboard Gestionnaire - Structure | ✅ Terminé |
| 45 | [Frontend] Dashboard Gestionnaire - Liste Parents | Ouvert |
| 46 | [Frontend] Dashboard Gestionnaire - Liste AM | Ouvert |
| 47 | [Frontend] Écran Changement MDP Obligatoire | Ouvert |
| 48 | [Frontend] Gestion Erreurs & Messages | Ouvert |
| 49 | [Frontend] Écran Gestion Documents Légaux (Admin) | Ouvert |
| 50 | [Frontend] Affichage dynamique CGU lors inscription | Ouvert |
| 51 | [Frontend] Écran Logs Admin (optionnel v1.1) | Ouvert |
| 52 | [Tests] Tests unitaires Backend | Ouvert |
| 53 | [Tests] Tests intégration Backend | Ouvert |
| 54 | [Tests] Tests E2E Frontend | Ouvert |
| 55 | [Doc] Documentation API OpenAPI/Swagger | Ouvert |
| 56 | [Backend] Service Upload & Stockage fichiers | Ouvert |
| 58 | [Backend] Service Logging (Winston) | Ouvert |
| 59 | [Infra] Volume Docker pour uploads | Ouvert |
| 60 | [Infra] Volume Docker pour documents légaux | Ouvert |
| 61 | [Doc] Guide installation & configuration | Ouvert |
| 62 | [Doc] Amendement CDC v1.4 - Suppression SMS | Ouvert |
| 63 | [Doc] Rédaction CGU/Privacy génériques v1 | Ouvert |
| 78 | [Frontend] Refonte Infrastructure Formulaires Multi-modes | ✅ Terminé |
| 79 | [Frontend] Renommer "Nanny" en "Assistante Maternelle" (AM) | ✅ Terminé |
| 81 | [Frontend] Corrections suite refactoring widgets | ✅ Terminé |
| 83 | [Frontend] Adapter RegisterChoiceScreen pour mobile | ✅ Terminé |
| 86 / 88 | Doublons fermés (voir #12, #14, #15) | ✅ Fermé |
| 89 | Log des appels API en mode debug | Ouvert |
| 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 - Homogénéisation des onglets | ✅ Fermé |
| 94 | [Backend] Relais - Modèle, API CRUD et liaison gestionnaire | ✅ Terminé |
| 95 | [Frontend] Admin - Gestion des Relais et rattachement gestionnaire | ✅ Fermé |
| 96 | [Frontend] Admin - Création administrateur via modale (sans relais) | ✅ Terminé |
| 97 | [Backend] Harmoniser API création administrateur avec le contrat frontend | ✅ Terminé |
| 101 | [Frontend] Inscription Parent Branchement soumission formulaire à l'API | Ouvert |
| 103 | Numéro de dossier backend | Ouvert |
| 104 | Numéro de dossier frontend | Ouvert |
| 105 | Statut « refusé » | Ouvert |
| 106 | Liste familles en attente | Ouvert |
| 107 | Onglet « À valider » + listes | Ouvert |
| 108 | Validation dossier famille | Ouvert |
| 109 | Modale de validation | Ouvert |
| 110 | Refus sans suppression | Ouvert |
| 111 | Reprise après refus backend | Ouvert |
| 112 | Reprise après refus frontend | Ouvert |
| 113 | Doublons à l'inscription | Ouvert |
| 114 | Doublons alerte gestionnaire | Ouvert |
| 115 | Rattachement parent backend | Ouvert |
| 116 | Rattachement parent frontend | Ouvert |
| 117 | Évolution du cahier des charges | Ouvert |
*Gitea #1 et #2 = anciens tickets de test (fermés). Liste complète : https://git.ptits-pas.fr/jmartin/petitspas/issues*
*Tickets #103 à #117 = périmètre « Validation des nouveaux comptes par le gestionnaire » (plan + sync via `backend/scripts/sync-gitea-validation-tickets.js`).*
---
## 📊 Vue d'ensemble
@ -316,21 +389,22 @@ Rédiger la documentation pour aider les collectivités à configurer l'applicat
## 🟢 PRIORITÉ 2 : Backend - Authentification & Gestion Comptes
### Ticket #17 : [Backend] API Création gestionnaire
### Ticket #17 : [Backend] API Création gestionnaire
**Estimation** : 3h
**Labels** : `backend`, `p2`, `auth`
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-23)
**Description** :
Créer l'endpoint pour permettre au super admin de créer des gestionnaires.
**Tâches** :
- [ ] Endpoint `POST /api/v1/gestionnaires`
- [ ] Validation DTO
- [ ] Hash bcrypt
- [ ] Flag `changement_mdp_obligatoire = TRUE`
- [ ] Guards (super_admin only)
- [ ] Email de notification (utiliser MailService avec config dynamique)
- [ ] Tests unitaires
- [x] Endpoint `POST /api/v1/gestionnaires`
- [x] Validation DTO
- [x] Hash bcrypt
- [x] Flag `changement_mdp_obligatoire = TRUE`
- [x] Guards (super_admin only)
- [x] Email de notification (utiliser MailService avec config dynamique)
- [x] Tests unitaires
**Référence** : [20_WORKFLOW-CREATION-COMPTE.md](./20_WORKFLOW-CREATION-COMPTE.md#étape-2--création-dun-gestionnaire)
@ -641,6 +715,39 @@ Enregistrer les acceptations de documents légaux lors de l'inscription (traçab
---
### Ticket #94 : [Backend] Relais - Modèle, API CRUD et liaison gestionnaire ✅
**Estimation** : 4h
**Labels** : `backend`, `p2`, `admin`
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-21)
**Description** :
Le back-office admin doit gérer des Relais avec des données réelles en base, et permettre une liaison simple avec les gestionnaires.
**Tâches** :
- [x] Créer le modèle `Relais` (nom, adresse, horaires, téléphone, actif, notes)
- [x] Exposer les endpoints admin CRUD pour les relais (`GET`, `POST`, `PATCH`, `DELETE`)
- [x] Ajouter la liaison : un gestionnaire peut être rattaché à un relais principal (`relais_id` dans `users` ?)
- [x] Validations (champs requis, format horaires)
---
### Ticket #97 : [Backend] Harmoniser API création administrateur avec le contrat frontend ✅
**Estimation** : 3h
**Labels** : `backend`, `p2`, `auth`, `admin`
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-24)
**Description** :
Rendre l'API de création administrateur cohérente et stable avec le besoin frontend (modale simplifiée), en définissant un contrat clair et minimal.
**Tâches** :
- [ ] Introduire un DTO dédié `CreateAdministrateurDto`
- [ ] Champs autorisés : nom, prenom, email, password, telephone
- [ ] Champs exclus : adresse, ville, photo, etc.
- [ ] Rôle forcé à `ADMINISTRATEUR`
- [ ] Validation stricte
---
## 🟢 PRIORITÉ 3 : Frontend - Interfaces
### Ticket #35 : [Frontend] Écran Création Gestionnaire
@ -798,7 +905,7 @@ Créer l'écran de création de mot de passe (lien reçu par email).
---
### Ticket #44 : [Frontend] Dashboard Gestionnaire - Structure
### Ticket #44 : [Frontend] Dashboard Gestionnaire - Structure
**Estimation** : 2h
**Labels** : `frontend`, `p3`, `gestionnaire`
@ -806,9 +913,10 @@ Créer l'écran de création de mot de passe (lien reçu par email).
Créer la structure du dashboard gestionnaire avec 2 onglets.
**Tâches** :
- [ ] Layout avec 2 onglets (Parents / AM)
- [ ] Navigation entre onglets
- [ ] État vide ("Aucune demande")
- [x] Dashboard gestionnaire = même shell que admin (sans onglet Paramètres), libellé « Gestionnaire »
- [x] Réutilisation du widget UserManagementPanel (ex-AdminUserManagementPanel) avec 3 onglets (Gestionnaires, Parents, Assistantes maternelles) ; onglet Administrateurs masqué
- [x] Redirection login rôle `gestionnaire` vers `/gestionnaire-dashboard`
- [ ] État vide dédié ("Aucune demande") — optionnel, contenu actuel = listes existantes
---
@ -894,9 +1002,10 @@ Créer l'écran de gestion des documents légaux (CGU/Privacy) pour l'admin.
---
### Ticket #92 : [Frontend] Dashboard Admin - Données réelles et branchement API
### Ticket #92 : [Frontend] Dashboard Admin - Données réelles et branchement API
**Estimation** : 8h
**Labels** : `frontend`, `p3`, `admin`
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-17)
**Description** :
Le dashboard admin (onglets Gestionnaires | Parents | Assistantes maternelles | Administrateurs) affiche actuellement des données en dur (mock). Remplacer par des appels API pour afficher les vrais utilisateurs et permettre les actions de gestion (voir, modifier, valider/refuser). Référence : [90_AUDIT.md](./90_AUDIT.md).
@ -1018,6 +1127,89 @@ 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 #101 : [Frontend] Inscription Parent Branchement soumission formulaire à l'API
**Estimation** : 4h
**Labels** : `frontend`, `p3`, `auth`, `cdc`
**Description** :
Branchement du formulaire d'inscription parent (étape 5, récapitulatif) à l'endpoint d'inscription. Aujourd'hui la soumission n'appelle pas l'API : elle affiche uniquement une modale de confirmation puis redirige vers le login. Ce ticket vise à envoyer les données collectées (Parent 1, Parent 2 optionnel, enfants, présentation, CGU) à l'API.
**Tâches** :
- [ ] Créer un service ou méthode (ex. `AuthService.registerParent` ou `UserService`) appelant `POST /api/v1/auth/register/parent`
- [ ] Construire le body (DTO) à partir de `UserRegistrationData` (parent1, parent2, children, motivationText, CGU acceptée, etc.) en cohérence avec le contrat backend (voir ticket #18 refonte)
- [ ] Dans `ParentRegisterStep5Screen`, au clic « Soumettre » : appel API puis en cas de succès afficher la modale et redirection vers `/login` ; en cas d'erreur afficher le message (SnackBar/dialog)
- [ ] Gestion des photos enfants (base64 ou multipart selon API)
- [ ] Optionnel : réinitialiser ou conserver `UserRegistrationData` après succès (selon UX)
**Référence** : [20_WORKFLOW-CREATION-COMPTE.md](./20_WORKFLOW-CREATION-COMPTE.md#étape-3--inscription-dun-parent), backend #18 (refonte API inscription parent).
**Création** : issue Gitea #101 créée. Pour recréer ou script : `node backend/scripts/create-gitea-issue-parent-api.js` (token dans `.gitea-token` ou voir [PROCEDURE-API-GITEA.md](./PROCEDURE-API-GITEA.md)).
---
### Ticket #93 : [Frontend] Panneau Admin - Homogénéisation des onglets ✅
**Estimation** : 4h
**Labels** : `frontend`, `p3`, `admin`, `ux`
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-24)
**Description** :
Uniformiser l'UI/UX des 4 onglets du dashboard admin (Gestionnaires, Parents, AM, Admins).
**Tâches** :
- [ ] Standardiser le header de liste (Recherche, Filtres, Bouton Action)
- [ ] Standardiser les cartes utilisateurs (`ListTile` uniforme)
- [ ] Standardiser les états (Loading, Erreur, Vide)
- [ ] Factoriser les composants partagés
---
### Ticket #95 : [Frontend] Admin - Gestion des Relais et rattachement gestionnaire ✅
**Estimation** : 5h
**Labels** : `frontend`, `p3`, `admin`
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-24)
**Description** :
Interface de gestion des Relais dans le dashboard admin et rattachement des gestionnaires.
**Tâches** :
- [ ] Section Relais avec 2 sous-onglets : Paramètres techniques / Paramètres territoriaux
- [ ] Liste, Création, Édition, Activation/Désactivation des relais
- [ ] Champs UI : nom, adresse, horaires, téléphone, statut, notes
- [ ] Onglet Gestionnaires : Ajout contrôle de rattachement au relais principal
---
### Ticket #96 : [Frontend] Admin - Création administrateur via modale (sans relais) ✅
**Estimation** : 3h
**Labels** : `frontend`, `p3`, `admin`
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-24)
**Description** :
Permettre la création d'un administrateur via une modale simple depuis le dashboard admin.
**Tâches** :
- [x] Bouton "Créer administrateur" dans l'onglet Administrateurs
- [x] Modale avec formulaire simplifié (Nom, Prénom, Email, MDP, Téléphone)
- [x] Appel API `POST /users` (ou endpoint dédié si #97 implémenté)
- [x] Gestion succès/erreur et rafraîchissement liste
---
## 🔵 PRIORITÉ 4 : Tests & Documentation
### Ticket #52 : [Tests] Tests unitaires Backend
@ -1133,6 +1325,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)
**Estimation** : 4h
**Labels** : `frontend`, `p3`, `monitoring`, `admin`
@ -1235,28 +1441,29 @@ Rédiger les documents légaux génériques (CGU et Politique de confidentialit
## 📊 Résumé final
**Total** : 65 tickets
**Estimation** : ~184h de développement
**Total** : 72 tickets
**Estimation** : ~208h de développement
### Par priorité
- **P0 (Bloquant BDD)** : 7 tickets (~5h)
- **P1 (Bloquant Config)** : 7 tickets (~22h)
- **P2 (Backend)** : 18 tickets (~50h)
- **P3 (Frontend)** : 22 tickets (~71h) ← +1 mobile RegisterChoice
- **P2 (Backend)** : 19 tickets (~54h)
- **P3 (Frontend)** : 25 tickets (~83h)
- **P4 (Tests/Doc)** : 4 tickets (~24h)
- **Critiques** : 6 tickets (~13h)
- **Juridique** : 1 ticket (~8h)
### Par domaine
- **BDD** : 7 tickets
- **Backend** : 23 tickets
- **Frontend** : 22 tickets ← +1 mobile RegisterChoice
- **Backend** : 24 tickets
- **Frontend** : 25 tickets
- **Tests** : 3 tickets
- **Documentation** : 5 tickets
- **Infra** : 2 tickets
- **Juridique** : 1 ticket
### Modifications par rapport à la version initiale
- ✅ **v1.5** : Ajout tickets #91, #93, #94, #95. Ticket #92 terminé.
- ✅ **v1.4** : Numéros de section du doc = numéros Gitea (Ticket #n = issue #n). Tableau et sections renumérotés. Doublons #86, #87, #88 fermés sur Gitea (#86#12, #87#14, #88#15) ; tickets sources #12, #14, #15 mis à jour (doc + body Gitea).
- ✅ **Concept v1.3** : Configuration initiale = un seul panneau Paramètres (3 sections) dans le dashboard ; plus de page dédiée « Setup Wizard » ; navigation bloquée jusquà sauvegarde au premier déploiement. Tickets #10, #12, #13 alignés.
- ❌ **Supprimé** : Tickets "Renvoyer email validation" (backend + frontend) - Pas prioritaire
@ -1270,7 +1477,7 @@ Rédiger les documents légaux génériques (CGU et Politique de confidentialit
---
**Dernière mise à jour** : 9 Février 2026
**Version** : 1.4
**Statut** : ✅ Aligné avec le dépôt Gitea
**Dernière mise à jour** : 25 Février 2026
**Version** : 1.6
**Statut** : ✅ Aligné avec le dépôt Gitea (tickets #103-#117 créés)

View File

@ -255,4 +255,24 @@ Pour chaque évolution identifiée, ce document suivra la structure suivante :
#### X.1.3 Impact sur l'application
- 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.
- 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.

View File

@ -14,7 +14,7 @@ Num | Etat | Titre
41 | closed | [Frontend] Inscription AM - Panneau 2 (Infos pro)
42 | closed | [Frontend] Inscription AM - Finalisation
43 | open | [Frontend] Écran Création Mot de Passe
44 | open | [Frontend] Dashboard Gestionnaire - Structure
44 | closed | [Frontend] Dashboard Gestionnaire - Structure
45 | open | [Frontend] Dashboard Gestionnaire - Liste Parents
46 | open | [Frontend] Dashboard Gestionnaire - Liste AM
47 | open | [Frontend] Écran Changement MDP Obligatoire

View File

@ -1,6 +1,6 @@
# SuperNounou SSS-001
## Spécification technique & opérationnelle unifiée
_Version 0.2 24 avril 2025_
_Version 0.3 27 janvier 2026_
---
@ -62,6 +62,13 @@ Collection Postman, scripts cURL, guide « Appeler lAPI ».
### B.4 Intégrations futures
SSO LDAP/SAML, webhook `contract.validated`, export statistiques CSV.
### B.5 Contrat de gestion des comptes d'administration
- Création d'un administrateur avec un contrat minimal stable : `nom`, `prenom`, `email`, `password`, `telephone`.
- Le rôle n'est jamais fourni par le frontend pour ce flux ; le backend impose `ADMINISTRATEUR`.
- Les champs hors périmètre (adresse complète, photo, métadonnées métier non nécessaires) ne sont pas requis.
- Les protections d'autorisation restent actives : un `SUPER_ADMIN` n'est pas supprimable et son identité (`nom`, `prenom`) est non modifiable.
- Côté interface d'administration, les actions d'édition sont conditionnées aux droits ; les entrées non éditables restent consultables en lecture seule.
---
# C Déploiement, CI/CD et Observabilité *(nouveau)*
@ -106,3 +113,4 @@ AES-256, JWT, KMS, OpenAPI, RPO, RTO, rate-limit, HMAC, Compose, CI/CD…
|---------|------------|------------------|---------------------------------|
| 0.1-draft | 2025-04-24 | Équipe projet | Création du SSS unifié |
| 0.2 | 2025-04-24 | ChatGPT & Julien | Ajout déploiement / CI/CD / logs |
| 0.3 | 2026-01-27 | Équipe projet | Contrat admin harmonisé et règles d'autorisation |

View File

@ -20,7 +20,11 @@ import '../screens/auth/am_register_step3_screen.dart';
import '../screens/auth/am_register_step4_screen.dart';
import '../screens/home/home_screen.dart';
import '../screens/administrateurs/admin_dashboardScreen.dart';
import '../screens/gestionnaire/gestionnaire_dashboard_screen.dart';
import '../screens/home/parent_screen/ParentDashboardScreen.dart';
import '../screens/am/am_dashboard_screen.dart';
import '../screens/legal/privacy_page.dart';
import '../screens/legal/legal_page.dart';
import '../screens/unknown_screen.dart';
// --- Provider Instances ---
@ -53,13 +57,26 @@ class AppRouter {
path: '/admin-dashboard',
builder: (BuildContext context, GoRouterState state) => const AdminDashboardScreen(),
),
GoRoute(
path: '/gestionnaire-dashboard',
builder: (BuildContext context, GoRouterState state) => const GestionnaireDashboardScreen(),
),
GoRoute(
path: '/parent-dashboard',
builder: (BuildContext context, GoRouterState state) => const ParentDashboardScreen(),
),
GoRoute(
path: '/am-dashboard',
builder: (BuildContext context, GoRouterState state) => const HomeScreen(),
builder: (BuildContext context, GoRouterState state) =>
const AmDashboardScreen(),
),
GoRoute(
path: '/privacy',
builder: (BuildContext context, GoRouterState state) => const PrivacyPage(),
),
GoRoute(
path: '/legal',
builder: (BuildContext context, GoRouterState state) => const LegalPage(),
),
// --- Parent Registration Flow ---

View File

@ -0,0 +1,33 @@
class RelaisModel {
final String id;
final String nom;
final String adresse;
final Map<String, dynamic>? horairesOuverture;
final String? ligneFixe;
final bool actif;
final String? notes;
const RelaisModel({
required this.id,
required this.nom,
required this.adresse,
this.horairesOuverture,
this.ligneFixe,
required this.actif,
this.notes,
});
factory RelaisModel.fromJson(Map<String, dynamic> json) {
return RelaisModel(
id: (json['id'] ?? '').toString(),
nom: (json['nom'] ?? '').toString(),
adresse: (json['adresse'] ?? '').toString(),
horairesOuverture: json['horaires_ouverture'] is Map<String, dynamic>
? json['horaires_ouverture'] as Map<String, dynamic>
: null,
ligneFixe: json['ligne_fixe'] as String?,
actif: json['actif'] as bool? ?? true,
notes: json['notes'] as String?,
);
}
}

View File

@ -13,6 +13,8 @@ class AppUser {
final String? adresse;
final String? ville;
final String? codePostal;
final String? relaisId;
final String? relaisNom;
AppUser({
required this.id,
@ -29,13 +31,19 @@ class AppUser {
this.adresse,
this.ville,
this.codePostal,
this.relaisId,
this.relaisNom,
});
factory AppUser.fromJson(Map<String, dynamic> json) {
final relaisJson = json['relais'];
final relaisMap =
relaisJson is Map<String, dynamic> ? relaisJson : <String, dynamic>{};
return AppUser(
id: json['id'] as String,
email: json['email'] as String,
role: json['role'] as String,
id: (json['id'] as String?) ?? '',
email: (json['email'] as String?) ?? '',
role: (json['role'] as String?) ?? '',
createdAt: json['cree_le'] != null
? DateTime.parse(json['cree_le'] as String)
: (json['createdAt'] != null
@ -56,6 +64,9 @@ class AppUser {
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(),
);
}
@ -75,6 +86,8 @@ class AppUser {
'adresse': adresse,
'ville': ville,
'code_postal': codePostal,
'relais_id': relaisId,
'relais_nom': relaisNom,
};
}

View File

@ -1,12 +1,12 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/auth_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/admin_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/parametres_panel.dart';
import 'package:p_tits_pas/widgets/app_footer.dart';
import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart';
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/dashboard/dashboard_bandeau.dart';
class AdminDashboardScreen extends StatefulWidget {
const AdminDashboardScreen({super.key});
@ -17,8 +17,9 @@ class AdminDashboardScreen extends StatefulWidget {
class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
bool? _setupCompleted;
AppUser? _user;
int mainTabIndex = 0;
int subIndex = 0;
int settingsSubIndex = 0;
@override
void initState() {
@ -26,19 +27,28 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
_loadSetupStatus();
}
@override
void dispose() {
super.dispose();
}
Future<void> _loadSetupStatus() async {
try {
final completed = await ConfigurationService.getSetupStatus();
final user = await AuthService.getCurrentUser();
if (!mounted) return;
setState(() {
_setupCompleted = completed;
_user = user;
if (!completed) mainTabIndex = 1;
});
} catch (e) {
if (mounted) setState(() {
_setupCompleted = false;
mainTabIndex = 1;
});
if (mounted) {
setState(() {
_setupCompleted = false;
mainTabIndex = 1;
});
}
}
}
@ -48,9 +58,9 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
});
}
void onSubTabChange(int index) {
void onSettingsSubTabChange(int index) {
setState(() {
subIndex = index;
settingsSubIndex = index;
});
}
@ -64,25 +74,39 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
return Scaffold(
appBar: PreferredSize(
preferredSize: const Size.fromHeight(60.0),
child: Container(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: Colors.grey.shade300),
child: DashboardBandeau(
tabItems: [
DashboardTabItem(
label: 'Gestion des utilisateurs',
enabled: _setupCompleted!,
),
),
child: DashboardAppBarAdmin(
selectedIndex: mainTabIndex,
onTabChange: onMainTabChange,
setupCompleted: _setupCompleted!,
),
const DashboardTabItem(label: 'Paramètres'),
],
selectedTabIndex: mainTabIndex,
onTabSelected: onMainTabChange,
userDisplayName: _user?.fullName.isNotEmpty == true
? _user!.fullName
: 'Admin',
userEmail: _user?.email,
userRole: _user?.role,
onProfileTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Modification du profil à venir')),
);
},
onSettingsTap: () => onMainTabChange(1),
onLogout: () {},
showLogoutConfirmation: true,
),
),
body: Column(
children: [
if (mainTabIndex == 0)
DashboardUserManagementSubBar(
selectedSubIndex: subIndex,
onSubTabChange: onSubTabChange,
const SizedBox.shrink()
else
DashboardSettingsSubBar(
selectedSubIndex: settingsSubIndex,
onSubTabChange: onSettingsSubTabChange,
),
Expanded(
child: _getBody(),
@ -95,19 +119,11 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
Widget _getBody() {
if (mainTabIndex == 1) {
return ParametresPanel(redirectToLoginAfterSave: !_setupCompleted!);
}
switch (subIndex) {
case 0:
return const GestionnaireManagementWidget();
case 1:
return const ParentManagementWidget();
case 2:
return const AssistanteMaternelleManagementWidget();
case 3:
return const AdminManagementWidget();
default:
return const Center(child: Text('Page non trouvée'));
return ParametresPanel(
redirectToLoginAfterSave: !_setupCompleted!,
selectedSettingsTabIndex: settingsSubIndex,
);
}
return const UserManagementPanel();
}
}

View File

@ -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'),
);
}
}

View File

@ -1,17 +1,688 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:p_tits_pas/models/relais_model.dart';
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/relais_service.dart';
import 'package:p_tits_pas/services/user_service.dart';
class GestionnairesCreate extends StatelessWidget {
const GestionnairesCreate({super.key});
class AdminUserFormDialog extends StatefulWidget {
final AppUser? initialUser;
final bool withRelais;
final bool adminMode;
final bool readOnly;
const AdminUserFormDialog({
super.key,
this.initialUser,
this.withRelais = true,
this.adminMode = false,
this.readOnly = false,
});
@override
State<AdminUserFormDialog> createState() => _AdminUserFormDialogState();
}
class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
final _formKey = GlobalKey<FormState>();
final _nomController = TextEditingController();
final _prenomController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _telephoneController = TextEditingController();
final _passwordToggleFocusNode =
FocusNode(skipTraversal: true, canRequestFocus: false);
bool _isSubmitting = false;
bool _obscurePassword = true;
bool _isLoadingRelais = true;
List<RelaisModel> _relais = [];
String? _selectedRelaisId;
bool get _isEditMode => widget.initialUser != null;
bool get _isSuperAdminTarget =>
widget.initialUser?.role.toLowerCase() == 'super_admin';
bool get _isLockedAdminIdentity =>
_isEditMode && widget.adminMode && _isSuperAdminTarget;
String get _targetRoleKey {
if (widget.initialUser != null) {
return widget.initialUser!.role.toLowerCase();
}
return widget.adminMode ? 'administrateur' : 'gestionnaire';
}
String get _targetRoleLabel {
switch (_targetRoleKey) {
case 'super_admin':
return 'Super administrateur';
case 'administrateur':
return 'Administrateur';
case 'gestionnaire':
return 'Gestionnaire';
case 'assistante_maternelle':
return 'Assistante maternelle';
case 'parent':
return 'Parent';
default:
return 'Utilisateur';
}
}
IconData get _targetRoleIcon {
switch (_targetRoleKey) {
case 'super_admin':
return Icons.verified_user_outlined;
case 'administrateur':
return Icons.admin_panel_settings_outlined;
case 'gestionnaire':
return Icons.assignment_ind_outlined;
case 'assistante_maternelle':
return Icons.child_care_outlined;
case 'parent':
return Icons.supervisor_account_outlined;
default:
return Icons.person_outline;
}
}
@override
void initState() {
super.initState();
final user = widget.initialUser;
if (user != null) {
_nomController.text = user.nom ?? '';
_prenomController.text = user.prenom ?? '';
_emailController.text = user.email;
_telephoneController.text = _formatPhoneForDisplay(user.telephone ?? '');
// En édition, on ne préremplit jamais le mot de passe.
_passwordController.clear();
final initialRelaisId = user.relaisId?.trim();
_selectedRelaisId =
(initialRelaisId == null || initialRelaisId.isEmpty)
? null
: initialRelaisId;
}
if (widget.withRelais) {
_loadRelais();
} else {
_isLoadingRelais = false;
}
}
@override
void dispose() {
_nomController.dispose();
_prenomController.dispose();
_emailController.dispose();
_passwordController.dispose();
_telephoneController.dispose();
_passwordToggleFocusNode.dispose();
super.dispose();
}
Future<void> _loadRelais() async {
try {
final list = await RelaisService.getRelais();
if (!mounted) return;
final uniqueById = <String, RelaisModel>{};
for (final relais in list) {
uniqueById[relais.id] = relais;
}
final filtered = uniqueById.values.where((r) => r.actif).toList();
if (_selectedRelaisId != null &&
!filtered.any((r) => r.id == _selectedRelaisId)) {
final selected = uniqueById[_selectedRelaisId!];
if (selected != null) {
filtered.add(selected);
} else {
_selectedRelaisId = null;
}
}
setState(() {
_relais = filtered;
_isLoadingRelais = false;
});
} catch (_) {
if (!mounted) return;
setState(() {
_selectedRelaisId = null;
_relais = [];
_isLoadingRelais = false;
});
}
}
String? _required(String? value, String field) {
if (value == null || value.trim().isEmpty) {
return '$field est requis';
}
return null;
}
String? _validateEmail(String? value) {
final base = _required(value, 'Email');
if (base != null) return base;
final email = value!.trim();
final ok = RegExp(r'^[^@]+@[^@]+\.[^@]+$').hasMatch(email);
if (!ok) return 'Format email invalide';
return null;
}
String? _validatePassword(String? value) {
if (_isEditMode && (value == null || value.trim().isEmpty)) {
return null;
}
final base = _required(value, 'Mot de passe');
if (base != null) return base;
if (value!.trim().length < 6) return 'Minimum 6 caractères';
return null;
}
String? _validatePhone(String? value) {
if (_isEditMode && (value == null || value.trim().isEmpty)) {
return null;
}
final base = _required(value, 'Téléphone');
if (base != null) return base;
final digits = _normalizePhone(value!);
if (digits.length != 10) {
return 'Le téléphone doit contenir 10 chiffres';
}
if (!digits.startsWith('0')) {
return 'Le téléphone doit commencer par 0';
}
return null;
}
String _normalizePhone(String raw) {
return raw.replaceAll(RegExp(r'\D'), '');
}
String _formatPhoneForDisplay(String raw) {
final normalized = _normalizePhone(raw);
final digits =
normalized.length > 10 ? normalized.substring(0, 10) : normalized;
final buffer = StringBuffer();
for (var i = 0; i < digits.length; i++) {
if (i > 0 && i.isEven) buffer.write(' ');
buffer.write(digits[i]);
}
return buffer.toString();
}
String _toTitleCase(String raw) {
final trimmed = raw.trim();
if (trimmed.isEmpty) return trimmed;
final words = trimmed.split(RegExp(r'\s+'));
final normalizedWords = words.map(_capitalizeComposedWord).toList();
return normalizedWords.join(' ');
}
String _capitalizeComposedWord(String word) {
if (word.isEmpty) return word;
final lower = word.toLowerCase();
final separators = <String>{"-", "'", ""};
final buffer = StringBuffer();
var capitalizeNext = true;
for (var i = 0; i < lower.length; i++) {
final char = lower[i];
if (capitalizeNext && RegExp(r'[a-zà-öø-ÿ]').hasMatch(char)) {
buffer.write(char.toUpperCase());
capitalizeNext = false;
} else {
buffer.write(char);
capitalizeNext = separators.contains(char);
}
}
return buffer.toString();
}
Future<void> _submit() async {
if (widget.readOnly) return;
if (_isSubmitting) return;
if (!_formKey.currentState!.validate()) return;
setState(() {
_isSubmitting = true;
});
try {
final normalizedNom = _toTitleCase(_nomController.text);
final normalizedPrenom = _toTitleCase(_prenomController.text);
final normalizedPhone = _normalizePhone(_telephoneController.text);
final passwordProvided = _passwordController.text.trim().isNotEmpty;
if (_isEditMode) {
if (widget.adminMode) {
final lockedNom = _toTitleCase(widget.initialUser!.nom ?? '');
final lockedPrenom = _toTitleCase(widget.initialUser!.prenom ?? '');
await UserService.updateAdministrateur(
adminId: widget.initialUser!.id,
nom: _isLockedAdminIdentity ? lockedNom : normalizedNom,
prenom: _isLockedAdminIdentity ? lockedPrenom : normalizedPrenom,
email: _emailController.text.trim(),
telephone: normalizedPhone.isEmpty
? _normalizePhone(widget.initialUser!.telephone ?? '')
: normalizedPhone,
password: passwordProvided ? _passwordController.text : null,
);
} else {
final currentUser = widget.initialUser!;
final initialNom = _toTitleCase(currentUser.nom ?? '');
final initialPrenom = _toTitleCase(currentUser.prenom ?? '');
final initialEmail = currentUser.email.trim();
final initialPhone = _normalizePhone(currentUser.telephone ?? '');
final onlyRelaisChanged =
normalizedNom == initialNom &&
normalizedPrenom == initialPrenom &&
_emailController.text.trim() == initialEmail &&
normalizedPhone == initialPhone &&
!passwordProvided;
if (onlyRelaisChanged) {
await UserService.updateGestionnaireRelais(
gestionnaireId: currentUser.id,
relaisId: _selectedRelaisId,
);
} else {
await UserService.updateGestionnaire(
gestionnaireId: currentUser.id,
nom: normalizedNom,
prenom: normalizedPrenom,
email: _emailController.text.trim(),
telephone: normalizedPhone.isEmpty ? initialPhone : normalizedPhone,
relaisId: _selectedRelaisId,
password: passwordProvided ? _passwordController.text : null,
);
}
}
} else {
if (widget.adminMode) {
await UserService.createAdministrateur(
nom: normalizedNom,
prenom: normalizedPrenom,
email: _emailController.text.trim(),
password: _passwordController.text,
telephone: _normalizePhone(_telephoneController.text),
);
} else {
await UserService.createGestionnaire(
nom: normalizedNom,
prenom: normalizedPrenom,
email: _emailController.text.trim(),
password: _passwordController.text,
telephone: _normalizePhone(_telephoneController.text),
relaisId: _selectedRelaisId,
);
}
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
_isEditMode
? (widget.adminMode
? 'Administrateur modifié avec succès.'
: 'Gestionnaire modifié avec succès.')
: (widget.adminMode
? 'Administrateur créé avec succès.'
: 'Gestionnaire créé avec succès.'),
),
),
);
Navigator.of(context).pop(true);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
e.toString().replaceFirst('Exception: ', ''),
),
backgroundColor: Colors.red.shade700,
),
);
} finally {
if (!mounted) return;
setState(() {
_isSubmitting = false;
});
}
}
Future<void> _delete() async {
if (widget.readOnly) return;
if (_isSuperAdminTarget) return;
if (!_isEditMode || _isSubmitting) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) {
return AlertDialog(
title: const Text('Confirmer la suppression'),
content: Text(
'Supprimer ${widget.initialUser!.fullName.isEmpty ? widget.initialUser!.email : widget.initialUser!.fullName} ?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700),
child: const Text('Supprimer'),
),
],
);
},
);
if (confirmed != true) return;
setState(() {
_isSubmitting = true;
});
try {
await UserService.deleteUser(widget.initialUser!.id);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Gestionnaire supprimé.')),
);
Navigator.of(context).pop(true);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e.toString().replaceFirst('Exception: ', '')),
backgroundColor: Colors.red.shade700,
),
);
setState(() {
_isSubmitting = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Créer un gestionnaire'),
return AlertDialog(
title: Row(
children: [
CircleAvatar(
radius: 16,
backgroundColor: const Color(0xFFEDE5FA),
child: Icon(
_targetRoleIcon,
size: 20,
color: const Color(0xFF6B3FA0),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
_isEditMode
? (widget.readOnly
? 'Consulter un "$_targetRoleLabel"'
: 'Modifier un "$_targetRoleLabel"')
: 'Créer un "$_targetRoleLabel"',
),
),
if (_isEditMode && !widget.readOnly)
IconButton(
icon: const Icon(Icons.close),
tooltip: 'Fermer',
onPressed: _isSubmitting
? null
: () => Navigator.of(context).pop(false),
),
],
),
body: const Center(
child: Text('Formulaire de création de gestionnaire'),
content: SizedBox(
width: 620,
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(child: _buildPrenomField()),
const SizedBox(width: 12),
Expanded(child: _buildNomField()),
],
),
const SizedBox(height: 12),
_buildEmailField(),
const SizedBox(height: 12),
Row(
children: [
Expanded(child: _buildPasswordField()),
const SizedBox(width: 12),
Expanded(child: _buildTelephoneField()),
],
),
if (widget.withRelais) ...[
const SizedBox(height: 12),
_buildRelaisField(),
],
],
),
),
),
),
actions: [
if (widget.readOnly) ...[
FilledButton(
onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(false),
child: const Text('Fermer'),
),
] else if (_isEditMode) ...[
if (!_isSuperAdminTarget)
OutlinedButton(
onPressed: _isSubmitting ? null : _delete,
style: OutlinedButton.styleFrom(foregroundColor: Colors.red.shade700),
child: const Text('Supprimer'),
),
FilledButton.icon(
onPressed: _isSubmitting ? null : _submit,
icon: _isSubmitting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.edit),
label: Text(_isSubmitting ? 'Modification...' : 'Modifier'),
),
] else ...[
OutlinedButton(
onPressed:
_isSubmitting ? null : () => Navigator.of(context).pop(false),
child: const Text('Annuler'),
),
FilledButton.icon(
onPressed: _isSubmitting ? null : _submit,
icon: _isSubmitting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.person_add_alt_1),
label: Text(_isSubmitting ? 'Création...' : 'Créer'),
),
],
],
);
}
Widget _buildNomField() {
return TextFormField(
controller: _nomController,
readOnly: widget.readOnly || _isLockedAdminIdentity,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(
labelText: 'Nom',
border: OutlineInputBorder(),
),
validator: (widget.readOnly || _isLockedAdminIdentity)
? null
: (v) => _required(v, 'Nom'),
);
}
Widget _buildPrenomField() {
return TextFormField(
controller: _prenomController,
readOnly: widget.readOnly || _isLockedAdminIdentity,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(
labelText: 'Prénom',
border: OutlineInputBorder(),
),
validator: (widget.readOnly || _isLockedAdminIdentity)
? null
: (v) => _required(v, 'Prénom'),
);
}
Widget _buildEmailField() {
return TextFormField(
controller: _emailController,
readOnly: widget.readOnly,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Email',
border: OutlineInputBorder(),
),
validator: widget.readOnly ? null : _validateEmail,
);
}
Widget _buildPasswordField() {
return TextFormField(
controller: _passwordController,
readOnly: widget.readOnly,
obscureText: _obscurePassword,
enableSuggestions: false,
autocorrect: false,
autofillHints: _isEditMode
? const <String>[]
: const [AutofillHints.newPassword],
decoration: InputDecoration(
labelText: _isEditMode
? 'Nouveau mot de passe'
: 'Mot de passe',
border: const OutlineInputBorder(),
suffixIcon: widget.readOnly
? null
: ExcludeFocus(
child: IconButton(
focusNode: _passwordToggleFocusNode,
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
icon: Icon(
_obscurePassword ? Icons.visibility_off : Icons.visibility,
),
),
),
),
validator: widget.readOnly ? null : _validatePassword,
);
}
Widget _buildTelephoneField() {
return TextFormField(
controller: _telephoneController,
readOnly: widget.readOnly,
keyboardType: TextInputType.phone,
inputFormatters: widget.readOnly
? null
: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
_FrenchPhoneNumberFormatter(),
],
decoration: const InputDecoration(
labelText: 'Téléphone (ex: 06 12 34 56 78)',
border: OutlineInputBorder(),
),
validator: widget.readOnly ? null : _validatePhone,
);
}
Widget _buildRelaisField() {
final selectedValue = _selectedRelaisId != null &&
_relais.any((relais) => relais.id == _selectedRelaisId)
? _selectedRelaisId
: null;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
DropdownButtonFormField<String?>(
isExpanded: true,
value: selectedValue,
decoration: const InputDecoration(
labelText: 'Relais principal',
border: OutlineInputBorder(),
),
items: [
const DropdownMenuItem<String?>(
value: null,
child: Text('Aucun relais'),
),
..._relais.map(
(relais) => DropdownMenuItem<String?>(
value: relais.id,
child: Text(relais.nom),
),
),
],
onChanged: (_isLoadingRelais || widget.readOnly)
? null
: (value) {
setState(() {
_selectedRelaisId = value;
});
},
),
if (_isLoadingRelais) ...[
const SizedBox(height: 8),
const LinearProgressIndicator(minHeight: 2),
],
],
);
}
}
class _FrenchPhoneNumberFormatter extends TextInputFormatter {
const _FrenchPhoneNumberFormatter();
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
final digits = newValue.text.replaceAll(RegExp(r'\D'), '');
final normalized = digits.length > 10 ? digits.substring(0, 10) : digits;
final buffer = StringBuffer();
for (var i = 0; i < normalized.length; i++) {
if (i > 0 && i.isEven) buffer.write(' ');
buffer.write(normalized[i]);
}
final formatted = buffer.toString();
return TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(offset: formatted.length),
);
}
}

View File

@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/widgets/app_footer.dart';
import 'package:p_tits_pas/widgets/dashboard/dashboard_bandeau.dart';
/// Dashboard assistante maternelle page blanche avec bandeau générique.
/// Contenu détaillé à venir.
class AmDashboardScreen extends StatefulWidget {
const AmDashboardScreen({super.key});
@override
State<AmDashboardScreen> createState() => _AmDashboardScreenState();
}
class _AmDashboardScreenState extends State<AmDashboardScreen> {
int selectedTabIndex = 0;
AppUser? _user;
@override
void initState() {
super.initState();
_loadUser();
}
Future<void> _loadUser() async {
final user = await AuthService.getCurrentUser();
if (mounted) setState(() => _user = user);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: PreferredSize(
preferredSize: const Size.fromHeight(60.0),
child: DashboardBandeau(
tabItems: const [
DashboardTabItem(label: 'Mon tableau de bord'),
DashboardTabItem(label: 'Paramètres'),
],
selectedTabIndex: selectedTabIndex,
onTabSelected: (index) => setState(() => selectedTabIndex = index),
userDisplayName: _user?.fullName.isNotEmpty == true
? _user!.fullName
: 'Assistante maternelle',
userEmail: _user?.email,
userRole: _user?.role,
onProfileTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Modification du profil à venir')),
);
},
onSettingsTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Paramètres à venir')),
);
},
onLogout: () {},
showLogoutConfirmation: true,
),
),
body: Column(
children: [
Expanded(
child: Center(
child: Text(
'Dashboard AM à venir',
style: Theme.of(context).textTheme.titleLarge,
),
),
),
const AppFooter(),
],
),
);
}
}

View File

@ -3,7 +3,6 @@ import 'package:provider/provider.dart';
import 'package:go_router/go_router.dart';
import '../../models/am_registration_data.dart';
import '../../utils/data_generator.dart';
import '../../widgets/personal_info_form_screen.dart';
import '../../models/card_assets.dart';
@ -14,19 +13,17 @@ class AmRegisterStep1Screen extends StatelessWidget {
Widget build(BuildContext context) {
final registrationData = Provider.of<AmRegistrationData>(context, listen: false);
// Générer des données de test si vide
// Données de test : Marie DUBOIS (jeu de test 03_seed_test_data.sql / docs/test-data)
PersonalInfoData initialData;
if (registrationData.firstName.isEmpty) {
final genFirstName = DataGenerator.firstName();
final genLastName = DataGenerator.lastName();
initialData = PersonalInfoData(
firstName: genFirstName,
lastName: genLastName,
phone: DataGenerator.phone(),
email: DataGenerator.email(genFirstName, genLastName),
address: DataGenerator.address(),
postalCode: DataGenerator.postalCode(),
city: DataGenerator.city(),
firstName: 'Marie',
lastName: 'DUBOIS',
phone: '0696345678',
email: 'marie.dubois@ptits-pas.fr',
address: '25 Rue de la République',
postalCode: '95870',
city: 'Bezons',
);
} else {
initialData = PersonalInfoData(

View File

@ -6,7 +6,6 @@ import 'dart:io';
import '../../models/am_registration_data.dart';
import '../../models/card_assets.dart';
import '../../utils/data_generator.dart';
import '../../widgets/professional_info_form_screen.dart';
class AmRegisterStep2Screen extends StatefulWidget {
@ -54,17 +53,17 @@ class _AmRegisterStep2ScreenState extends State<AmRegisterStep2Screen> {
capacity: registrationData.capacity,
);
// Générer des données de test si les champs sont vides
// Données de test : Marie DUBOIS (jeu de test 03_seed_test_data.sql / docs/test-data)
if (registrationData.dateOfBirth == null && registrationData.nir.isEmpty) {
initialData = ProfessionalInfoData(
photoPath: 'assets/images/icon_assmat.png',
photoConsent: true,
dateOfBirth: DateTime(1985, 3, 15),
birthCity: DataGenerator.city(),
dateOfBirth: DateTime(1980, 6, 8),
birthCity: 'Bezons',
birthCountry: 'France',
nir: '${DataGenerator.randomIntInRange(1, 3)}${DataGenerator.randomIntInRange(80, 96)}${DataGenerator.randomIntInRange(1, 13).toString().padLeft(2, '0')}${DataGenerator.randomIntInRange(1, 100).toString().padLeft(2, '0')}${DataGenerator.randomIntInRange(100, 1000).toString().padLeft(3, '0')}${DataGenerator.randomIntInRange(100, 1000).toString().padLeft(3, '0')}${DataGenerator.randomIntInRange(10, 100).toString().padLeft(2, '0')}',
agrementNumber: 'AM${DataGenerator.randomIntInRange(10000, 100000)}',
capacity: DataGenerator.randomIntInRange(1, 5),
nir: '280062A00100191',
agrementNumber: 'AGR-2019-095001',
capacity: 4,
);
}

View File

@ -13,12 +13,12 @@ class AmRegisterStep3Screen extends StatelessWidget {
Widget build(BuildContext context) {
final data = Provider.of<AmRegistrationData>(context, listen: false);
// Générer un texte de test si vide
// Données de test : Marie DUBOIS (jeu de test 03_seed_test_data.sql / docs/test-data)
String initialText = data.presentationText;
bool initialCgu = data.cguAccepted;
if (initialText.isEmpty) {
initialText = 'Disponible immédiatement, plus de 10 ans d\'expérience avec les tout-petits. Formation aux premiers secours à jour. Je dispose d\'un jardin sécurisé et d\'un espace de jeu adapté.';
initialText = 'Assistante maternelle agréée depuis 2019. Spécialité bébés 0-18 mois. Accueil bienveillant et cadre sécurisant. 2 places disponibles.';
initialCgu = true;
}

View File

@ -7,6 +7,7 @@ import 'dart:math' as math;
import '../../models/am_registration_data.dart';
import '../../models/card_assets.dart';
import '../../config/display_config.dart';
import '../../services/auth_service.dart';
import '../../widgets/hover_relief_widget.dart';
import '../../widgets/image_button.dart';
import '../../widgets/custom_navigation_button.dart';
@ -22,6 +23,28 @@ class AmRegisterStep4Screen extends StatefulWidget {
}
class _AmRegisterStep4ScreenState extends State<AmRegisterStep4Screen> {
bool _isSubmitting = false;
Future<void> _submitAMRegistration(AmRegistrationData registrationData) async {
if (_isSubmitting) return;
setState(() => _isSubmitting = true);
try {
await AuthService.registerAM(registrationData);
if (!mounted) return;
_showConfirmationModal(context);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e is Exception ? e.toString().replaceFirst('Exception: ', '') : 'Erreur lors de l\'inscription'),
backgroundColor: Colors.red.shade700,
),
);
} finally {
if (mounted) setState(() => _isSubmitting = false);
}
}
@override
Widget build(BuildContext context) {
final registrationData = Provider.of<AmRegistrationData>(context);
@ -90,12 +113,9 @@ class _AmRegisterStep4ScreenState extends State<AmRegisterStep4Screen> {
Expanded(
child: HoverReliefWidget(
child: CustomNavigationButton(
text: 'Soumettre',
text: _isSubmitting ? 'Envoi...' : 'Soumettre',
style: NavigationButtonStyle.green,
onPressed: () {
print("Données AM finales: ${registrationData.firstName} ${registrationData.lastName}");
_showConfirmationModal(context);
},
onPressed: () => _submitAMRegistration(registrationData),
width: double.infinity,
height: 50,
fontSize: 16,
@ -106,17 +126,14 @@ class _AmRegisterStep4ScreenState extends State<AmRegisterStep4Screen> {
),
)
else
ImageButton(
ImageButton(
bg: 'assets/images/bg_green.png',
text: 'Soumettre ma demande',
text: _isSubmitting ? 'Envoi...' : 'Soumettre ma demande',
textColor: const Color(0xFF2D6A4F),
width: 350,
height: 50,
fontSize: 18,
onPressed: () {
print("Données AM finales: ${registrationData.firstName} ${registrationData.lastName}");
_showConfirmationModal(context);
},
onPressed: () => _submitAMRegistration(registrationData),
),
],
),

View File

@ -1,5 +1,6 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:go_router/go_router.dart';
@ -20,7 +21,7 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
bool _isLoading = false;
String? _errorMessage;
@ -63,6 +64,11 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
return null;
}
void _handlePasswordSubmitted(String _) {
if (_isLoading) return;
_handleLogin();
}
/// Gère la connexion de l'utilisateur
Future<void> _handleLogin() async {
// Réinitialiser le message d'erreur
@ -90,7 +96,7 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
// Vérifier si l'utilisateur doit changer son mot de passe
if (user.changementMdpObligatoire) {
if (!mounted) return;
// Afficher la modale de changement de mot de passe (non-dismissible)
final result = await showDialog<bool>(
context: context,
@ -106,6 +112,9 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
if (!mounted) return;
// Laisse au navigateur/OS la possibilité de mémoriser les identifiants.
TextInput.finishAutofillContext(shouldSave: true);
// Rediriger selon le rôle de l'utilisateur
_redirectUserByRole(user.role);
} catch (e) {
@ -122,9 +131,11 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
switch (role.toLowerCase()) {
case 'super_admin':
case 'administrateur':
case 'gestionnaire':
context.go('/admin-dashboard');
break;
case 'gestionnaire':
context.go('/gestionnaire-dashboard');
break;
case 'parent':
context.go('/parent-dashboard');
break;
@ -152,47 +163,49 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
final w = constraints.maxWidth;
final h = constraints.maxHeight;
return FutureBuilder(
future: _getImageDimensions(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
future: _getImageDimensions(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
final imageDimensions = snapshot.data!;
final imageHeight = h;
final imageWidth = imageHeight * (imageDimensions.width / imageDimensions.height);
final remainingWidth = w - imageWidth;
final leftMargin = remainingWidth / 4;
final imageDimensions = snapshot.data!;
final imageHeight = h;
final imageWidth = imageHeight *
(imageDimensions.width / imageDimensions.height);
final remainingWidth = w - imageWidth;
final leftMargin = remainingWidth / 4;
return Stack(
children: [
// Fond en papier
Positioned.fill(
child: Image.asset(
'assets/images/paper2.png',
fit: BoxFit.cover,
repeat: ImageRepeat.repeat,
),
return Stack(
children: [
// Fond en papier
Positioned.fill(
child: Image.asset(
'assets/images/paper2.png',
fit: BoxFit.cover,
repeat: ImageRepeat.repeat,
),
// Image principale
Positioned(
left: leftMargin,
top: 0,
height: imageHeight,
width: imageWidth,
child: Image.asset(
'assets/images/river_logo_desktop.png',
fit: BoxFit.contain,
),
),
// Image principale
Positioned(
left: leftMargin,
top: 0,
height: imageHeight,
width: imageWidth,
child: Image.asset(
'assets/images/river_logo_desktop.png',
fit: BoxFit.contain,
),
// Formulaire dans le cadran en bas à droite
Positioned(
right: 0,
bottom: 0,
width: w * 0.6, // 60% de la largeur de l'écran
height: h * 0.5, // 50% de la hauteur de l'écran
child: Padding(
padding: EdgeInsets.all(w * 0.02), // 2% de padding
),
// Formulaire dans le cadran en bas à droite
Positioned(
right: 0,
bottom: 0,
width: w * 0.6, // 60% de la largeur de l'écran
height: h * 0.5, // 50% de la hauteur de l'écran
child: Padding(
padding: EdgeInsets.all(w * 0.02), // 2% de padding
child: AutofillGroup(
child: Form(
key: _formKey,
child: Column(
@ -207,6 +220,12 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
controller: _emailController,
labelText: 'Email',
hintText: 'Votre adresse email',
keyboardType: TextInputType.emailAddress,
autofillHints: const [
AutofillHints.username,
AutofillHints.email,
],
textInputAction: TextInputAction.next,
validator: _validateEmail,
style: CustomAppTextFieldStyle.lavande,
fieldHeight: 53,
@ -220,6 +239,12 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
labelText: 'Mot de passe',
hintText: 'Votre mot de passe',
obscureText: true,
autofillHints: const [
AutofillHints.password
],
textInputAction: TextInputAction.done,
onFieldSubmitted:
_handlePasswordSubmitted,
validator: _validatePassword,
style: CustomAppTextFieldStyle.jaune,
fieldHeight: 53,
@ -229,7 +254,7 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
],
),
const SizedBox(height: 20),
// Message d'erreur
if (_errorMessage != null)
Container(
@ -242,7 +267,8 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
),
child: Row(
children: [
Icon(Icons.error_outline, color: Colors.red[700], size: 20),
Icon(Icons.error_outline,
color: Colors.red[700], size: 20),
const SizedBox(width: 10),
Expanded(
child: Text(
@ -256,7 +282,7 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
],
),
),
// Bouton centré
Center(
child: _isLoading
@ -309,67 +335,68 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
),
),
),
// Pied de page (Wrap pour éviter overflow sur petite largeur)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 8.0),
decoration: const BoxDecoration(
color: Colors.transparent,
),
child: Wrap(
alignment: WrapAlignment.center,
runSpacing: 8,
children: [
_FooterLink(
text: 'Contact support',
onTap: () async {
final Uri emailLaunchUri = Uri(
scheme: 'mailto',
path: 'support@supernounou.local',
);
if (await canLaunchUrl(emailLaunchUri)) {
await launchUrl(emailLaunchUri);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Impossible d\'ouvrir le client mail',
style: GoogleFonts.merienda(),
),
),
// Pied de page (Wrap pour éviter overflow sur petite largeur)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 8.0),
decoration: const BoxDecoration(
color: Colors.transparent,
),
child: Wrap(
alignment: WrapAlignment.center,
runSpacing: 8,
children: [
_FooterLink(
text: 'Contact support',
onTap: () async {
final Uri emailLaunchUri = Uri(
scheme: 'mailto',
path: 'support@supernounou.local',
);
if (await canLaunchUrl(emailLaunchUri)) {
await launchUrl(emailLaunchUri);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Impossible d\'ouvrir le client mail',
style: GoogleFonts.merienda(),
),
);
}
},
),
_FooterLink(
text: 'Signaler un bug',
onTap: () {
_showBugReportDialog(context);
},
),
_FooterLink(
text: 'Mentions légales',
onTap: () {
context.go('/legal');
},
),
_FooterLink(
text: 'Politique de confidentialité',
onTap: () {
context.go('/privacy');
},
),
],
),
),
);
}
},
),
_FooterLink(
text: 'Signaler un bug',
onTap: () {
_showBugReportDialog(context);
},
),
_FooterLink(
text: 'Mentions légales',
onTap: () {
context.go('/legal');
},
),
_FooterLink(
text: 'Politique de confidentialité',
onTap: () {
context.go('/privacy');
},
),
],
),
),
],
);
},
);
),
],
);
},
);
},
),
);
@ -378,6 +405,7 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
/// Dimensions de river_logo_mobile.png (à mettre à jour si l'asset change).
static const int _riverLogoMobileWidth = 600;
static const int _riverLogoMobileHeight = 1080;
/// Fraction de la hauteur de l'image où se termine visuellement le slogan (0 = haut, 1 = bas).
static const double _sloganEndFraction = 0.42;
static const double _gapBelowSlogan = 12.0;
@ -388,7 +416,8 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
final h = constraints.maxHeight;
final w = constraints.maxWidth;
final imageAspectRatio = _riverLogoMobileHeight / _riverLogoMobileWidth;
final formTop = w * imageAspectRatio * _sloganEndFraction + _gapBelowSlogan;
final formTop =
w * imageAspectRatio * _sloganEndFraction + _gapBelowSlogan;
return Stack(
clipBehavior: Clip.none,
children: [
@ -428,95 +457,115 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
children: [
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20),
padding: const EdgeInsets.symmetric(
horizontal: 24, vertical: 20),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 16),
CustomAppTextField(
controller: _emailController,
labelText: 'Email',
showLabel: false,
hintText: 'Votre adresse email',
validator: _validateEmail,
style: CustomAppTextFieldStyle.lavande,
fieldHeight: 48,
fieldWidth: double.infinity,
),
const SizedBox(height: 12),
CustomAppTextField(
controller: _passwordController,
labelText: 'Mot de passe',
showLabel: false,
hintText: 'Votre mot de passe',
obscureText: true,
validator: _validatePassword,
style: CustomAppTextFieldStyle.jaune,
fieldHeight: 48,
fieldWidth: double.infinity,
),
if (_errorMessage != null) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.red.shade300),
),
child: Row(
children: [
Icon(Icons.error_outline, color: Colors.red.shade700, size: 20),
const SizedBox(width: 10),
Expanded(
child: Text(
_errorMessage!,
style: GoogleFonts.merienda(fontSize: 12, color: Colors.red.shade700),
),
child: AutofillGroup(
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 16),
CustomAppTextField(
controller: _emailController,
labelText: 'Email',
showLabel: false,
hintText: 'Votre adresse email',
keyboardType: TextInputType.emailAddress,
autofillHints: const [
AutofillHints.username,
AutofillHints.email,
],
textInputAction: TextInputAction.next,
validator: _validateEmail,
style: CustomAppTextFieldStyle.lavande,
fieldHeight: 48,
fieldWidth: double.infinity,
),
const SizedBox(height: 12),
CustomAppTextField(
controller: _passwordController,
labelText: 'Mot de passe',
showLabel: false,
hintText: 'Votre mot de passe',
obscureText: true,
autofillHints: const [
AutofillHints.password
],
textInputAction: TextInputAction.done,
onFieldSubmitted: _handlePasswordSubmitted,
validator: _validatePassword,
style: CustomAppTextFieldStyle.jaune,
fieldHeight: 48,
fieldWidth: double.infinity,
),
if (_errorMessage != null) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: Colors.red.shade300),
),
child: Row(
children: [
Icon(Icons.error_outline,
color: Colors.red.shade700,
size: 20),
const SizedBox(width: 10),
Expanded(
child: Text(
_errorMessage!,
style: GoogleFonts.merienda(
fontSize: 12,
color: Colors.red.shade700),
),
),
],
),
),
],
const SizedBox(height: 12),
_isLoading
? const CircularProgressIndicator()
: ImageButton(
bg: 'assets/images/bg_green.png',
width: double.infinity,
height: 44,
text: 'Se connecter',
textColor: const Color(0xFF2D6A4F),
onPressed: _handleLogin,
),
const SizedBox(height: 12),
TextButton(
onPressed: () {/* TODO */},
child: Text(
'Mot de passe oublié ?',
style: GoogleFonts.merienda(
fontSize: 14,
color: const Color(0xFF2D6A4F),
decoration: TextDecoration.underline,
),
),
),
TextButton(
onPressed: () =>
context.go('/register-choice'),
child: Text(
'Créer un compte',
style: GoogleFonts.merienda(
fontSize: 16,
color: const Color(0xFF2D6A4F),
decoration: TextDecoration.underline,
),
),
),
],
),
],
),
),
],
const SizedBox(height: 12),
_isLoading
? const CircularProgressIndicator()
: ImageButton(
bg: 'assets/images/bg_green.png',
width: double.infinity,
height: 44,
text: 'Se connecter',
textColor: const Color(0xFF2D6A4F),
onPressed: _handleLogin,
),
const SizedBox(height: 12),
TextButton(
onPressed: () { /* TODO */ },
child: Text(
'Mot de passe oublié ?',
style: GoogleFonts.merienda(
fontSize: 14,
color: const Color(0xFF2D6A4F),
decoration: TextDecoration.underline,
),
),
),
TextButton(
onPressed: () => context.go('/register-choice'),
child: Text(
'Créer un compte',
style: GoogleFonts.merienda(
fontSize: 16,
color: const Color(0xFF2D6A4F),
decoration: TextDecoration.underline,
),
),
),
],
),
),
),
@ -533,12 +582,17 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
text: 'Contact support',
fontSize: 11,
onTap: () async {
final uri = Uri(scheme: 'mailto', path: 'support@supernounou.local');
final uri = Uri(
scheme: 'mailto',
path: 'support@supernounou.local');
if (await canLaunchUrl(uri)) {
await launchUrl(uri);
} else if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Impossible d\'ouvrir le client mail', style: GoogleFonts.merienda())),
SnackBar(
content: Text(
'Impossible d\'ouvrir le client mail',
style: GoogleFonts.merienda())),
);
}
},
@ -707,4 +761,4 @@ class _FooterLink extends StatelessWidget {
),
);
}
}
}

View File

@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/auth_service.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/dashboard/dashboard_bandeau.dart';
/// Dashboard gestionnaire même shell que l'admin, sans onglet Paramètres.
/// Réutilise [UserManagementPanel].
class GestionnaireDashboardScreen extends StatefulWidget {
const GestionnaireDashboardScreen({super.key});
@override
State<GestionnaireDashboardScreen> createState() =>
_GestionnaireDashboardScreenState();
}
class _GestionnaireDashboardScreenState extends State<GestionnaireDashboardScreen> {
AppUser? _user;
@override
void initState() {
super.initState();
_loadUser();
}
Future<void> _loadUser() async {
final user = await AuthService.getCurrentUser();
if (mounted) setState(() => _user = user);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: PreferredSize(
preferredSize: const Size.fromHeight(60.0),
child: DashboardBandeau(
tabItems: const [
DashboardTabItem(label: 'Gestion des utilisateurs'),
],
selectedTabIndex: 0,
onTabSelected: (_) {},
userDisplayName: _user?.fullName.isNotEmpty == true
? _user!.fullName
: 'Gestionnaire',
userEmail: _user?.email,
userRole: _user?.role,
onProfileTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Modification du profil à venir')),
);
},
onSettingsTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Paramètres à venir')),
);
},
onLogout: () {},
showLogoutConfirmation: true,
),
),
body: Column(
children: [
Expanded(
child: UserManagementPanel(showAdministrateursTab: false),
),
const AppFooter(),
],
),
);
}
}

View File

@ -1,11 +1,12 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/controllers/parent_dashboard_controller.dart';
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/services/dashboardService.dart';
import 'package:p_tits_pas/widgets/app_footer.dart';
import 'package:p_tits_pas/widgets/dashbord_parent/app_layout.dart';
import 'package:p_tits_pas/widgets/dashbord_parent/children_sidebar.dart';
import 'package:p_tits_pas/widgets/dashbord_parent/dashboard_app_bar.dart';
import 'package:p_tits_pas/widgets/dashbord_parent/wid_dashbord.dart';
import 'package:p_tits_pas/widgets/dashboard/dashboard_bandeau.dart';
import 'package:p_tits_pas/widgets/main_content_area.dart';
import 'package:p_tits_pas/widgets/messaging_sidebar.dart';
import 'package:provider/provider.dart';
@ -19,6 +20,7 @@ class ParentDashboardScreen extends StatefulWidget {
class _ParentDashboardScreenState extends State<ParentDashboardScreen> {
int selectedIndex = 0;
AppUser? _user;
void onTabChange(int index) {
setState(() {
@ -29,12 +31,18 @@ class _ParentDashboardScreenState extends State<ParentDashboardScreen> {
@override
void initState() {
super.initState();
_loadUser();
// Initialiser les données du dashboard
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<ParentDashboardController>().initDashboard();
});
}
Future<void> _loadUser() async {
final user = await AuthService.getCurrentUser();
if (mounted) setState(() => _user = user);
}
Widget _getBody() {
switch (selectedIndex) {
case 0:
@ -53,29 +61,43 @@ class _ParentDashboardScreenState extends State<ParentDashboardScreen> {
return ChangeNotifierProvider(
create: (context) => ParentDashboardController(DashboardService())..initDashboard(),
child: Scaffold(
appBar: PreferredSize(preferredSize: const Size.fromHeight(60.0),
child: Container(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: Colors.grey.shade300),
),
),
child: DashboardAppBar(
selectedIndex: selectedIndex,
onTabChange: onTabChange,
appBar: PreferredSize(
preferredSize: const Size.fromHeight(60.0),
child: DashboardBandeau(
tabItems: const [
DashboardTabItem(label: 'Mon tableau de bord'),
DashboardTabItem(label: 'Trouver une nounou'),
DashboardTabItem(label: 'Paramètres'),
],
selectedTabIndex: selectedIndex,
onTabSelected: onTabChange,
userDisplayName: _user?.fullName.isNotEmpty == true
? _user!.fullName
: 'Parent',
userEmail: _user?.email,
userRole: _user?.role,
onProfileTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Modification du profil à venir')),
);
},
onSettingsTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Paramètres à venir')),
);
},
onLogout: () {},
showLogoutConfirmation: true,
),
),
),
body: Column(
children: [
Expanded (child: _getBody(),
),
Expanded(child: _getBody()),
const AppFooter(),
],
),
)
// body: _buildResponsiveBody(context, controller),
// footer: const AppFooter(),
),
);
}

View File

@ -18,11 +18,13 @@ class ApiConfig {
static const String gestionnaires = '/gestionnaires';
static const String parents = '/parents';
static const String assistantesMaternelles = '/assistantes-maternelles';
static const String relais = '/relais';
// Configuration (admin)
static const String configuration = '/configuration';
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 configurationBulk = '/configuration/bulk';
@ -33,14 +35,14 @@ class ApiConfig {
static const String conversations = '/conversations';
static const String notifications = '/notifications';
// Headers
// Headers
static Map<String, String> get headers => {
'Content-Type': 'application/json',
'Accept': 'application/json',
};
'Content-Type': 'application/json',
'Accept': 'application/json',
};
static Map<String, String> authHeaders(String token) => {
...headers,
'Authorization': 'Bearer $token',
};
}
...headers,
'Authorization': 'Bearer $token',
};
}

View File

@ -1,9 +1,12 @@
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import '../models/user.dart';
import '../models/am_registration_data.dart';
import 'api/api_config.dart';
import 'api/tokenService.dart';
import '../utils/nir_utils.dart';
class AuthService {
static const String _currentUserKey = 'current_user';
@ -133,6 +136,70 @@ class AuthService {
await prefs.setString(_currentUserKey, jsonEncode(user.toJson()));
}
/// Inscription AM complète (POST /auth/register/am).
/// En cas de succès (201), aucune donnée utilisateur retournée ; rediriger vers login.
static Future<void> registerAM(AmRegistrationData data) async {
String? photoBase64;
if (data.photoPath != null && data.photoPath!.isNotEmpty && !data.photoPath!.startsWith('assets/')) {
try {
final file = File(data.photoPath!);
if (await file.exists()) {
final bytes = await file.readAsBytes();
photoBase64 = 'data:image/jpeg;base64,${base64Encode(bytes)}';
}
} catch (_) {}
}
final body = {
'email': data.email,
'prenom': data.firstName,
'nom': data.lastName,
'telephone': data.phone,
'adresse': data.streetAddress.isNotEmpty ? data.streetAddress : null,
'code_postal': data.postalCode.isNotEmpty ? data.postalCode : null,
'ville': data.city.isNotEmpty ? data.city : null,
if (photoBase64 != null) 'photo_base64': photoBase64,
'consentement_photo': data.photoConsent,
'date_naissance': data.dateOfBirth != null
? '${data.dateOfBirth!.year}-${data.dateOfBirth!.month.toString().padLeft(2, '0')}-${data.dateOfBirth!.day.toString().padLeft(2, '0')}'
: null,
'lieu_naissance_ville': data.birthCity.isNotEmpty ? data.birthCity : null,
'lieu_naissance_pays': data.birthCountry.isNotEmpty ? data.birthCountry : null,
'nir': normalizeNir(data.nir),
'numero_agrement': data.agrementNumber,
'capacite_accueil': data.capacity ?? 1,
'biographie': data.presentationText.isNotEmpty ? data.presentationText : null,
'acceptation_cgu': data.cguAccepted,
'acceptation_privacy': data.cguAccepted,
};
final response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.registerAM}'),
headers: ApiConfig.headers,
body: jsonEncode(body),
);
if (response.statusCode == 200 || response.statusCode == 201) {
return;
}
final decoded = response.body.isNotEmpty ? jsonDecode(response.body) : null;
final message = _extractErrorMessage(decoded, response.statusCode);
throw Exception(message);
}
/// Extrait le message d'erreur des réponses NestJS (message string, array, ou objet).
static String _extractErrorMessage(dynamic decoded, int statusCode) {
const fallback = 'Erreur lors de l\'inscription';
if (decoded == null || decoded is! Map) return '$fallback ($statusCode)';
final msg = decoded['message'];
if (msg == null) return decoded['error'] as String? ?? '$fallback ($statusCode)';
if (msg is String) return msg;
if (msg is List) return msg.map((e) => e.toString()).join('. ').trim();
if (msg is Map && msg['message'] != null) return msg['message'].toString();
return '$fallback ($statusCode)';
}
/// Rafraîchit le profil utilisateur depuis l'API
static Future<AppUser?> refreshCurrentUser() async {
final token = await TokenService.getToken();

View File

@ -0,0 +1,97 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:p_tits_pas/models/relais_model.dart';
import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/services/api/tokenService.dart';
class RelaisService {
static Future<Map<String, String>> _headers() async {
final token = await TokenService.getToken();
return token != null
? ApiConfig.authHeaders(token)
: Map<String, String>.from(ApiConfig.headers);
}
static String _extractError(String body, String fallback) {
try {
final decoded = jsonDecode(body);
if (decoded is Map<String, dynamic>) {
final message = decoded['message'];
if (message is String && message.trim().isNotEmpty) {
return message;
}
}
} catch (_) {}
return fallback;
}
static Future<List<RelaisModel>> getRelais() async {
final response = await http.get(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.relais}'),
headers: await _headers(),
);
if (response.statusCode != 200) {
throw Exception(
_extractError(response.body, 'Erreur chargement relais'),
);
}
final List<dynamic> data = jsonDecode(response.body);
return data
.whereType<Map<String, dynamic>>()
.map(RelaisModel.fromJson)
.toList();
}
static Future<RelaisModel> createRelais(Map<String, dynamic> payload) async {
final response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.relais}'),
headers: await _headers(),
body: jsonEncode(payload),
);
if (response.statusCode != 201 && response.statusCode != 200) {
throw Exception(
_extractError(response.body, 'Erreur création relais'),
);
}
return RelaisModel.fromJson(
jsonDecode(response.body) as Map<String, dynamic>);
}
static Future<RelaisModel> updateRelais(
String id,
Map<String, dynamic> payload,
) async {
final response = await http.patch(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.relais}/$id'),
headers: await _headers(),
body: jsonEncode(payload),
);
if (response.statusCode != 200) {
throw Exception(
_extractError(response.body, 'Erreur mise à jour relais'),
);
}
return RelaisModel.fromJson(
jsonDecode(response.body) as Map<String, dynamic>);
}
static Future<void> deleteRelais(String id) async {
final response = await http.delete(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.relais}/$id'),
headers: await _headers(),
);
if (response.statusCode != 200 && response.statusCode != 204) {
throw Exception(
_extractError(response.body, 'Erreur suppression relais'),
);
}
}
}

View File

@ -29,13 +29,87 @@ class UserService {
if (response.statusCode != 200) {
final err = jsonDecode(response.body) as Map<String, dynamic>?;
throw Exception(_toStr(err?['message']) ?? 'Erreur chargement gestionnaires');
throw Exception(
_toStr(err?['message']) ?? 'Erreur chargement gestionnaires');
}
final List<dynamic> data = jsonDecode(response.body);
return data.map((e) => AppUser.fromJson(e)).toList();
}
static Future<AppUser> createGestionnaire({
required String nom,
required String prenom,
required String email,
required String password,
required String telephone,
String? relaisId,
}) async {
final response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.gestionnaires}'),
headers: await _headers(),
body: jsonEncode(<String, dynamic>{
'nom': nom,
'prenom': prenom,
'email': email,
'password': password,
'telephone': telephone,
'cguAccepted': true,
'relaisId': relaisId,
}),
);
if (response.statusCode != 200 && response.statusCode != 201) {
final decoded = jsonDecode(response.body);
if (decoded is Map<String, dynamic>) {
final message = decoded['message'];
if (message is List && message.isNotEmpty) {
throw Exception(message.join(' - '));
}
throw Exception(_toStr(message) ?? 'Erreur création gestionnaire');
}
throw Exception('Erreur création gestionnaire');
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
return AppUser.fromJson(data);
}
static Future<AppUser> createAdministrateur({
required String nom,
required String prenom,
required String email,
required String password,
required String telephone,
}) async {
final response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/admin'),
headers: await _headers(),
body: jsonEncode(<String, dynamic>{
'nom': nom,
'prenom': prenom,
'email': email,
'password': password,
'telephone': telephone,
}),
);
if (response.statusCode != 200 && response.statusCode != 201) {
final decoded = jsonDecode(response.body);
if (decoded is Map<String, dynamic>) {
final message = decoded['message'];
if (message is List && message.isNotEmpty) {
throw Exception(message.join(' - '));
}
throw Exception(_toStr(message) ?? 'Erreur création administrateur');
}
throw Exception('Erreur création administrateur');
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
return AppUser.fromJson(data);
}
// Récupérer la liste des parents
static Future<List<ParentModel>> getParents() async {
final response = await http.get(
@ -53,7 +127,8 @@ class UserService {
}
// Récupérer la liste des assistantes maternelles
static Future<List<AssistanteMaternelleModel>> getAssistantesMaternelles() async {
static Future<List<AssistanteMaternelleModel>>
getAssistantesMaternelles() async {
final response = await http.get(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}'),
headers: await _headers(),
@ -87,8 +162,212 @@ class UserService {
.toList();
}
} catch (e) {
print('Erreur chargement admins: $e');
// On garde un fallback vide pour ne pas bloquer l'UI admin.
}
return [];
}
static Future<AppUser> createAdmin({
required String nom,
required String prenom,
required String email,
required String password,
required String telephone,
}) async {
final response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/admin'),
headers: await _headers(),
body: jsonEncode(<String, dynamic>{
'nom': nom,
'prenom': prenom,
'email': email,
'password': password,
'telephone': telephone,
}),
);
if (response.statusCode != 200 && response.statusCode != 201) {
final decoded = jsonDecode(response.body);
if (decoded is Map<String, dynamic>) {
final message = decoded['message'];
if (message is List && message.isNotEmpty) {
throw Exception(message.join(' - '));
}
throw Exception(_toStr(message) ?? 'Erreur création administrateur');
}
throw Exception('Erreur création administrateur');
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
return AppUser.fromJson(data);
}
static Future<AppUser> updateAdmin({
required String adminId,
required String nom,
required String prenom,
required String email,
required String telephone,
String? password,
}) async {
final body = <String, dynamic>{
'nom': nom,
'prenom': prenom,
'email': email,
'telephone': telephone,
};
if (password != null && password.trim().isNotEmpty) {
body['password'] = password.trim();
}
final response = await http.patch(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$adminId'),
headers: await _headers(),
body: jsonEncode(body),
);
if (response.statusCode != 200) {
final decoded = jsonDecode(response.body);
if (decoded is Map<String, dynamic>) {
final message = decoded['message'];
if (message is List && message.isNotEmpty) {
throw Exception(message.join(' - '));
}
throw Exception(_toStr(message) ?? 'Erreur modification administrateur');
}
throw Exception('Erreur modification administrateur');
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
return AppUser.fromJson(data);
}
static Future<void> updateGestionnaireRelais({
required String gestionnaireId,
required String? relaisId,
}) async {
final response = await http.patch(
Uri.parse(
'${ApiConfig.baseUrl}${ApiConfig.gestionnaires}/$gestionnaireId'),
headers: await _headers(),
body: jsonEncode(<String, dynamic>{'relaisId': relaisId}),
);
if (response.statusCode != 200 && response.statusCode != 204) {
final err = jsonDecode(response.body) as Map<String, dynamic>?;
throw Exception(
_toStr(err?['message']) ?? 'Erreur rattachement relais au gestionnaire',
);
}
}
static Future<AppUser> updateGestionnaire({
required String gestionnaireId,
required String nom,
required String prenom,
required String email,
String? telephone,
required String? relaisId,
String? password,
}) async {
final body = <String, dynamic>{
'nom': nom,
'prenom': prenom,
'email': email,
'relaisId': relaisId,
};
if (telephone != null && telephone.trim().isNotEmpty) {
body['telephone'] = telephone.trim();
}
if (password != null && password.trim().isNotEmpty) {
body['password'] = password.trim();
}
final response = await http.patch(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.gestionnaires}/$gestionnaireId'),
headers: await _headers(),
body: jsonEncode(body),
);
if (response.statusCode != 200) {
final decoded = jsonDecode(response.body);
if (decoded is Map<String, dynamic>) {
final message = decoded['message'];
if (message is List && message.isNotEmpty) {
throw Exception(message.join(' - '));
}
throw Exception(_toStr(message) ?? 'Erreur modification gestionnaire');
}
throw Exception('Erreur modification gestionnaire');
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
return AppUser.fromJson(data);
}
static Future<AppUser> updateAdministrateur({
required String adminId,
required String nom,
required String prenom,
required String email,
String? telephone,
String? password,
}) async {
final body = <String, dynamic>{
'nom': nom,
'prenom': prenom,
'email': email,
};
if (telephone != null && telephone.trim().isNotEmpty) {
body['telephone'] = telephone.trim();
}
if (password != null && password.trim().isNotEmpty) {
body['password'] = password.trim();
}
final response = await http.patch(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$adminId'),
headers: await _headers(),
body: jsonEncode(body),
);
if (response.statusCode != 200) {
final decoded = jsonDecode(response.body);
if (decoded is Map<String, dynamic>) {
final message = decoded['message'];
if (message is List && message.isNotEmpty) {
throw Exception(message.join(' - '));
}
throw Exception(_toStr(message) ?? 'Erreur modification administrateur');
}
throw Exception('Erreur modification administrateur');
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
return AppUser.fromJson(data);
}
static Future<void> deleteUser(String userId) async {
final response = await http.delete(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$userId'),
headers: await _headers(),
);
if (response.statusCode != 200 && response.statusCode != 204) {
final decoded = jsonDecode(response.body);
if (decoded is Map<String, dynamic>) {
final message = decoded['message'];
if (message is List && message.isNotEmpty) {
throw Exception(message.join(' - '));
}
throw Exception(_toStr(message) ?? 'Erreur suppression utilisateur');
}
throw Exception('Erreur suppression utilisateur');
}
}
}

View File

@ -0,0 +1,109 @@
import 'package:flutter/services.dart';
/// Utilitaires NIR (Numéro d'Inscription au Répertoire) INSEE, 15 caractères.
/// Corse : 2A (2A) et 2B (2B) au lieu de 19/20. Clé de contrôle : 97 - (NIR13 mod 97).
/// Normalise le NIR : 15 caractères, sans espaces ni séparateurs. Corse conservée (2A/2B).
String normalizeNir(String input) {
if (input.isEmpty) return '';
final cleaned = input.replaceAll(' ', '').replaceAll('-', '').replaceAll('/', '').toUpperCase();
final buf = StringBuffer();
int i = 0;
while (i < cleaned.length && buf.length < 15) {
final c = cleaned[i];
if (buf.length < 5) {
if (c.compareTo('0') >= 0 && c.compareTo('9') <= 0) buf.write(c);
i++;
} else if (buf.length == 5) {
if (c == '2' && i + 1 < cleaned.length && (cleaned[i + 1] == 'A' || cleaned[i + 1] == 'B')) {
buf.write('2');
buf.write(cleaned[i + 1]);
i += 2;
} else if ((c == 'A' || c == 'B')) {
buf.write('2');
buf.write(c);
i++;
} else if (c.compareTo('0') >= 0 && c.compareTo('9') <= 0) {
buf.write(c);
if (i + 1 < cleaned.length && cleaned[i + 1].compareTo('0') >= 0 && cleaned[i + 1].compareTo('9') <= 0) {
buf.write(cleaned[i + 1]);
i += 2;
} else {
i++;
}
} else {
i++;
}
} else {
if (c.compareTo('0') >= 0 && c.compareTo('9') <= 0) buf.write(c);
i++;
}
}
return buf.toString().length > 15 ? buf.toString().substring(0, 15) : buf.toString();
}
/// Retourne la chaîne brute à 15 caractères (chiffres + 2A ou 2B).
String nirToRaw(String normalized) {
String s = normalized.replaceAll(' ', '').replaceAll('-', '').replaceAll('/', '');
if (s.length > 15) s = s.substring(0, 15);
return s;
}
/// Formate pour affichage : 1 12 34 56 789 012-34 ou 1 12 34 2A 789 012-34 (Corse).
String formatNir(String raw) {
final r = nirToRaw(raw);
if (r.length < 15) return r;
// Même structure pour tous : sexe + année + mois + département + commune + ordre-clé.
return '${r.substring(0, 1)} ${r.substring(1, 3)} ${r.substring(3, 5)} ${r.substring(5, 7)} ${r.substring(7, 10)} ${r.substring(10, 13)}-${r.substring(13, 15)}';
}
/// Vérifie le format : 15 caractères, structure 1+2+2+2+3+3+2, département 2A/2B autorisé.
bool _isFormatValid(String raw) {
if (raw.length != 15) return false;
final dept = raw.substring(5, 7);
final restDigits = raw.substring(0, 5) + (dept == '2A' ? '19' : dept == '2B' ? '18' : dept) + raw.substring(7, 15);
if (!RegExp(r'^[12]\d{12}\d{2}$').hasMatch(restDigits)) return false;
return RegExp(r'^[12]\d{4}(?:\d{2}|2A|2B)\d{8}$').hasMatch(raw);
}
/// Calcule la clé de contrôle (97 - (NIR13 mod 97)). Pour 2A19, 2B18.
int _controlKey(String raw13) {
String n = raw13;
if (raw13.length >= 7 && (raw13.substring(5, 7) == '2A' || raw13.substring(5, 7) == '2B')) {
n = raw13.substring(0, 5) + (raw13.substring(5, 7) == '2A' ? '19' : '18') + raw13.substring(7);
}
final big = int.tryParse(n);
if (big == null) return -1;
return 97 - (big % 97);
}
/// Valide le NIR (format + clé). Retourne null si valide, message d'erreur sinon.
String? validateNir(String? value) {
if (value == null || value.isEmpty) return 'NIR requis';
final raw = nirToRaw(value).toUpperCase();
if (raw.length != 15) return 'Le NIR doit contenir 15 caractères (chiffres, ou 2A/2B pour la Corse)';
if (!_isFormatValid(raw)) return 'Format NIR invalide (ex. 1 12 34 56 789 012-34 ou 2A pour la Corse)';
final key = _controlKey(raw.substring(0, 13));
final keyStr = key >= 0 && key <= 99 ? key.toString().padLeft(2, '0') : '';
final expectedKey = raw.substring(13, 15);
if (key < 0 || keyStr != expectedKey) return 'Clé de contrôle NIR invalide';
return null;
}
/// Formateur de saisie : affiche le NIR formaté (1 12 34 56 789 012-34) et limite à 15 caractères utiles.
class NirInputFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
final raw = normalizeNir(newValue.text);
if (raw.isEmpty) return newValue;
final formatted = formatNir(raw);
final offset = formatted.length;
return TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(offset: offset),
);
}
}

View File

@ -1,9 +1,18 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart';
import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
class AdminManagementWidget extends StatefulWidget {
const AdminManagementWidget({super.key});
final String searchQuery;
const AdminManagementWidget({
super.key,
required this.searchQuery,
});
@override
State<AdminManagementWidget> createState() => _AdminManagementWidgetState();
@ -13,21 +22,17 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
bool _isLoading = false;
String? _error;
List<AppUser> _admins = [];
List<AppUser> _filteredAdmins = [];
final TextEditingController _searchController = TextEditingController();
String? _currentUserRole;
@override
void initState() {
super.initState();
_loadCurrentUserRole();
_loadAdmins();
_searchController.addListener(_onSearchChanged);
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
void dispose() => super.dispose();
Future<void> _loadAdmins() async {
setState(() {
@ -39,7 +44,6 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
if (!mounted) return;
setState(() {
_admins = list;
_filteredAdmins = list;
_isLoading = false;
});
} catch (e) {
@ -51,91 +55,100 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
}
}
void _onSearchChanged() {
final query = _searchController.text.toLowerCase();
Future<void> _loadCurrentUserRole() async {
final cached = await AuthService.getCurrentUser();
if (!mounted) return;
if (cached != null) {
setState(() {
_currentUserRole = cached.role.toLowerCase();
});
return;
}
final refreshed = await AuthService.refreshCurrentUser();
if (!mounted || refreshed == null) return;
setState(() {
_filteredAdmins = _admins.where((u) {
final name = u.fullName.toLowerCase();
final email = u.email.toLowerCase();
return name.contains(query) || email.contains(query);
}).toList();
_currentUserRole = refreshed.role.toLowerCase();
});
}
bool _isSuperAdmin(AppUser user) => user.role.toLowerCase() == 'super_admin';
bool _canEditAdmin(AppUser target) {
if (!_isSuperAdmin(target)) return true;
return _currentUserRole == 'super_admin';
}
Future<void> _openAdminEditDialog(AppUser user) async {
final canEdit = _canEditAdmin(user);
final changed = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
return AdminUserFormDialog(
initialUser: user,
adminMode: true,
withRelais: false,
readOnly: !canEdit,
);
},
);
if (changed == true && canEdit) {
await _loadAdmins();
}
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: TextField(
controller: _searchController,
decoration: const InputDecoration(
hintText: "Rechercher un administrateur...",
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(),
),
),
final query = widget.searchQuery.toLowerCase();
final filteredAdmins = _admins.where((u) {
final name = u.fullName.toLowerCase();
final email = u.email.toLowerCase();
return name.contains(query) || email.contains(query);
}).toList();
return UserList(
isLoading: _isLoading,
error: _error,
isEmpty: filteredAdmins.isEmpty,
emptyMessage: 'Aucun administrateur trouvé.',
itemCount: filteredAdmins.length,
itemBuilder: (context, index) {
final user = filteredAdmins[index];
final isSuperAdmin = _isSuperAdmin(user);
final canEdit = _canEditAdmin(user);
return AdminUserCard(
title: user.fullName,
fallbackIcon: isSuperAdmin
? Icons.verified_user_outlined
: Icons.manage_accounts_outlined,
subtitleLines: [
user.email,
'Téléphone : ${user.telephone?.trim().isNotEmpty == true ? user.telephone : 'Non renseigné'}',
],
avatarUrl: user.photoUrl,
borderColor: isSuperAdmin
? const Color(0xFF8E6AC8)
: Colors.grey.shade300,
backgroundColor: isSuperAdmin
? const Color(0xFFF4EEFF)
: Colors.white,
titleColor: isSuperAdmin ? const Color(0xFF5D2F99) : null,
infoColor: isSuperAdmin
? const Color(0xFF6D4EA1)
: Colors.black54,
actions: [
IconButton(
icon: Icon(
canEdit ? Icons.edit_outlined : Icons.visibility_outlined,
),
const SizedBox(width: 16),
ElevatedButton.icon(
onPressed: () {
// TODO: Créer admin
},
icon: const Icon(Icons.add),
label: const Text("Créer un admin"),
),
],
),
const SizedBox(height: 24),
if (_isLoading)
const Center(child: CircularProgressIndicator())
else if (_error != null)
Center(child: Text('Erreur: $_error', style: const TextStyle(color: Colors.red)))
else if (_filteredAdmins.isEmpty)
const Center(child: Text("Aucun administrateur trouvé."))
else
Expanded(
child: ListView.builder(
itemCount: _filteredAdmins.length,
itemBuilder: (context, index) {
final user = _filteredAdmins[index];
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: ListTile(
leading: CircleAvatar(
child: Text(user.fullName.isNotEmpty
? user.fullName[0].toUpperCase()
: 'A'),
),
title: Text(user.fullName.isNotEmpty
? user.fullName
: 'Sans nom'),
subtitle: Text(user.email),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit),
onPressed: () {},
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: () {},
),
],
),
),
);
},
),
)
],
),
tooltip: canEdit ? 'Modifier' : 'Consulter',
onPressed: () {
_openAdminEditDialog(user);
},
),
],
);
},
);
}
}

View File

@ -1,9 +1,19 @@
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 StatefulWidget {
const AssistanteMaternelleManagementWidget({super.key});
final String searchQuery;
final int? capacityMin;
const AssistanteMaternelleManagementWidget({
super.key,
required this.searchQuery,
this.capacityMin,
});
@override
State<AssistanteMaternelleManagementWidget> createState() =>
@ -15,25 +25,15 @@ class _AssistanteMaternelleManagementWidgetState
bool _isLoading = false;
String? _error;
List<AssistanteMaternelleModel> _assistantes = [];
List<AssistanteMaternelleModel> _filteredAssistantes = [];
final TextEditingController _zoneController = TextEditingController();
final TextEditingController _capacityController = TextEditingController();
@override
void initState() {
super.initState();
_loadAssistantes();
_zoneController.addListener(_filter);
_capacityController.addListener(_filter);
}
@override
void dispose() {
_zoneController.dispose();
_capacityController.dispose();
super.dispose();
}
void dispose() => super.dispose();
Future<void> _loadAssistantes() async {
setState(() {
@ -45,7 +45,6 @@ class _AssistanteMaternelleManagementWidgetState
if (!mounted) return;
setState(() {
_assistantes = list;
_filter();
_isLoading = false;
});
} catch (e) {
@ -57,117 +56,100 @@ class _AssistanteMaternelleManagementWidgetState
}
}
void _filter() {
final zoneQuery = _zoneController.text.toLowerCase();
final capacityQuery = int.tryParse(_capacityController.text);
setState(() {
_filteredAssistantes = _assistantes.where((am) {
final matchesZone = zoneQuery.isEmpty ||
(am.residenceCity?.toLowerCase().contains(zoneQuery) ?? false);
final matchesCapacity = capacityQuery == null ||
(am.maxChildren != null && am.maxChildren! >= capacityQuery);
return matchesZone && matchesCapacity;
}).toList();
});
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 🔎 Zone de filtre
_buildFilterSection(),
final query = widget.searchQuery.toLowerCase();
final filteredAssistantes = _assistantes.where((am) {
final matchesName = am.user.fullName.toLowerCase().contains(query) ||
am.user.email.toLowerCase().contains(query) ||
(am.residenceCity?.toLowerCase().contains(query) ?? false);
final matchesCapacity = widget.capacityMin == null ||
(am.maxChildren != null && am.maxChildren! >= widget.capacityMin!);
return matchesName && matchesCapacity;
}).toList();
const SizedBox(height: 16),
// 📋 Liste des assistantes
if (_isLoading)
const Center(child: CircularProgressIndicator())
else if (_error != null)
Center(child: Text('Erreur: $_error', style: const TextStyle(color: Colors.red)))
else if (_filteredAssistantes.isEmpty)
const Center(child: Text("Aucune assistante maternelle trouvée."))
else
Expanded(
child: ListView.builder(
itemCount: _filteredAssistantes.length,
itemBuilder: (context, index) {
final assistante = _filteredAssistantes[index];
return Card(
margin: const EdgeInsets.symmetric(vertical: 8),
child: ListTile(
leading: CircleAvatar(
backgroundImage: assistante.user.photoUrl != null
? NetworkImage(assistante.user.photoUrl!)
: null,
child: assistante.user.photoUrl == null
? const Icon(Icons.face)
: null,
),
title: Text(assistante.user.fullName.isNotEmpty
? assistante.user.fullName
: 'Sans nom'),
subtitle: Text(
"N° Agrément : ${assistante.approvalNumber ?? 'N/A'}\nZone : ${assistante.residenceCity ?? 'N/A'} | Capacité : ${assistante.maxChildren ?? 0}"),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit),
onPressed: () {
// TODO: Ajouter modification
},
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: () {
// TODO: Ajouter suppression
},
),
],
),
),
);
},
),
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);
},
),
],
);
},
);
}
void _openAssistanteDetails(AssistanteMaternelleModel assistante) {
showDialog<void>(
context: context,
builder: (context) => AdminDetailModal(
title: assistante.user.fullName.isEmpty
? 'Assistante maternelle'
: assistante.user.fullName,
subtitle: assistante.user.email,
fields: [
AdminDetailField(label: 'ID', value: _v(assistante.user.id)),
AdminDetailField(
label: 'Numero agrement',
value: _v(assistante.approvalNumber),
),
AdminDetailField(
label: 'Ville residence',
value: _v(assistante.residenceCity),
),
AdminDetailField(
label: 'Capacite max',
value: assistante.maxChildren?.toString() ?? '-',
),
AdminDetailField(
label: 'Places disponibles',
value: assistante.placesAvailable?.toString() ?? '-',
),
AdminDetailField(
label: 'Telephone',
value: _v(assistante.user.telephone),
),
AdminDetailField(label: 'Adresse', value: _v(assistante.user.adresse)),
AdminDetailField(label: 'Ville', value: _v(assistante.user.ville)),
AdminDetailField(
label: 'Code postal',
value: _v(assistante.user.codePostal),
),
],
onEdit: () {
Navigator.of(context).pop();
ScaffoldMessenger.of(this.context).showSnackBar(
const SnackBar(content: Text('Action Modifier a implementer')),
);
},
onDelete: () {
Navigator.of(context).pop();
ScaffoldMessenger.of(this.context).showSnackBar(
const SnackBar(content: Text('Action Supprimer a implementer')),
);
},
),
);
}
Widget _buildFilterSection() {
return Wrap(
spacing: 16,
runSpacing: 8,
children: [
SizedBox(
width: 200,
child: TextField(
controller: _zoneController,
decoration: const InputDecoration(
labelText: "Zone géographique",
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.location_on),
),
),
),
SizedBox(
width: 200,
child: TextField(
controller: _capacityController,
decoration: const InputDecoration(
labelText: "Capacité minimum",
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
),
),
],
);
}
String _v(String? value) => (value == null || value.isEmpty) ? '-' : value;
}

View File

@ -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'),
),
],
),
),
],
),
),
),
);
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1,143 @@
import 'package:flutter/material.dart';
class AdminUserCard extends StatefulWidget {
final String title;
final List<String> subtitleLines;
final String? avatarUrl;
final IconData fallbackIcon;
final List<Widget> actions;
final Color? borderColor;
final Color? backgroundColor;
final Color? titleColor;
final Color? infoColor;
const AdminUserCard({
super.key,
required this.title,
required this.subtitleLines,
this.avatarUrl,
this.fallbackIcon = Icons.person,
this.actions = const [],
this.borderColor,
this.backgroundColor,
this.titleColor,
this.infoColor,
});
@override
State<AdminUserCard> createState() => _AdminUserCardState();
}
class _AdminUserCardState extends State<AdminUserCard> {
bool _isHovered = false;
@override
Widget build(BuildContext context) {
final infoLine =
widget.subtitleLines.where((e) => e.trim().isNotEmpty).join(' ');
final actionsWidth =
widget.actions.isNotEmpty ? widget.actions.length * 30.0 : 0.0;
return MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(10),
child: InkWell(
onTap: () {},
borderRadius: BorderRadius.circular(10),
hoverColor: const Color(0x149CC5C0),
child: Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: 0,
color: widget.backgroundColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(color: widget.borderColor ?? Colors.grey.shade300),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
child: Row(
children: [
CircleAvatar(
radius: 14,
backgroundColor: const Color(0xFFEDE5FA),
backgroundImage: widget.avatarUrl != null
? NetworkImage(widget.avatarUrl!)
: null,
child: widget.avatarUrl == null
? Icon(
widget.fallbackIcon,
size: 16,
color: const Color(0xFF6B3FA0),
)
: null,
),
const SizedBox(width: 10),
Expanded(
child: Row(
children: [
Flexible(
fit: FlexFit.loose,
child: Text(
widget.title.isNotEmpty ? widget.title : 'Sans nom',
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14,
).copyWith(color: widget.titleColor),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
infoLine,
style: const TextStyle(
color: Colors.black54,
fontSize: 12,
).copyWith(color: widget.infoColor),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
if (widget.actions.isNotEmpty)
SizedBox(
width: actionsWidth,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 120),
opacity: _isHovered ? 1 : 0,
child: IgnorePointer(
ignoring: !_isHovered,
child: IconTheme(
data: const IconThemeData(size: 17),
child: IconButtonTheme(
data: IconButtonThemeData(
style: IconButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.all(4),
minimumSize: const Size(28, 28),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: widget.actions,
),
),
),
),
),
),
],
),
),
),
),
),
);
}
}

View File

@ -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,
),
),
],
),
);
}
}

View File

@ -1,140 +1,131 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:p_tits_pas/services/auth_service.dart';
/// Barre du dashboard admin : onglets Gestion des utilisateurs | Paramètres + déconnexion.
class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidget {
final int selectedIndex;
final ValueChanged<int> onTabChange;
final bool setupCompleted;
/// Sous-barre : Gestionnaires | Parents | Assistantes maternelles | [Administrateurs].
/// [subTabCount] = 3 pour masquer l'onglet Administrateurs (dashboard gestionnaire).
class DashboardUserManagementSubBar extends StatelessWidget {
final int selectedSubIndex;
final ValueChanged<int> onSubTabChange;
final TextEditingController searchController;
final String searchHint;
final Widget? filterControl;
final VoidCallback? onAddPressed;
final String addLabel;
final int subTabCount;
const DashboardAppBarAdmin({
static const List<String> _tabLabels = [
'Gestionnaires',
'Parents',
'Assistantes maternelles',
'Administrateurs',
];
const DashboardUserManagementSubBar({
Key? key,
required this.selectedIndex,
required this.onTabChange,
this.setupCompleted = true,
required this.selectedSubIndex,
required this.onSubTabChange,
required this.searchController,
required this.searchHint,
this.filterControl,
this.onAddPressed,
this.addLabel = '+ Ajouter',
this.subTabCount = 4,
}) : super(key: key);
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight + 10);
@override
Widget build(BuildContext context) {
return AppBar(
elevation: 0,
automaticallyImplyLeading: false,
title: Row(
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: [
const SizedBox(width: 24),
Image.asset(
'assets/images/logo.png',
height: 40,
fit: BoxFit.contain,
),
Expanded(
child: Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildNavItem(context, 'Gestion des utilisateurs', 0, enabled: setupCompleted),
const SizedBox(width: 24),
_buildNavItem(context, 'Paramètres', 1, enabled: true),
],
for (int i = 0; i < subTabCount; i++) ...[
if (i > 0) const SizedBox(width: 12),
_buildSubNavItem(context, _tabLabels[i], i),
],
const SizedBox(width: 36),
_pillField(
width: 320,
child: TextField(
controller: searchController,
decoration: InputDecoration(
hintText: searchHint,
prefixIcon: const Icon(Icons.search, size: 18),
border: InputBorder.none,
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
),
),
),
if (filterControl != null) ...[
const SizedBox(width: 12),
_pillField(width: 150, child: filterControl!),
],
const Spacer(),
_buildAddButton(),
],
),
actions: [
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Center(
child: Text(
'Admin',
style: TextStyle(
color: Colors.black,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
),
Padding(
padding: const EdgeInsets.only(right: 16),
child: TextButton(
onPressed: () => _handleLogout(context),
style: TextButton.styleFrom(
backgroundColor: const Color(0xFF9CC5C0),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5),
),
),
child: const Text('Se déconnecter'),
),
),
],
);
}
Widget _buildNavItem(BuildContext context, String title, int index, {bool enabled = true}) {
final bool isActive = index == selectedIndex;
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: enabled ? () => onTabChange(index) : null,
child: Opacity(
opacity: enabled ? 1.0 : 0.5,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: isActive ? const Color(0xFF9CC5C0) : Colors.transparent,
borderRadius: BorderRadius.circular(20),
border: isActive ? null : Border.all(color: Colors.black26),
),
child: Text(
title,
style: TextStyle(
color: isActive ? Colors.white : Colors.black,
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
fontSize: 14,
),
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,
),
),
),
);
}
void _handleLogout(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Déconnexion'),
content: const Text('Êtes-vous sûr de vouloir vous déconnecter ?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () async {
Navigator.pop(context);
await AuthService.logout();
if (context.mounted) context.go('/login');
},
child: const Text('Déconnecter'),
),
],
),
);
}
}
/// Sous-barre : Gestionnaires | Parents | Assistantes maternelles | Administrateurs.
class DashboardUserManagementSubBar extends StatelessWidget {
/// Sous-barre Paramètres : Paramètres généraux | Paramètres territoriaux.
class DashboardSettingsSubBar extends StatelessWidget {
final int selectedSubIndex;
final ValueChanged<int> onSubTabChange;
const DashboardUserManagementSubBar({
const DashboardSettingsSubBar({
Key? key,
required this.selectedSubIndex,
required this.onSubTabChange,
@ -153,13 +144,9 @@ class DashboardUserManagementSubBar extends StatelessWidget {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildSubNavItem(context, 'Gestionnaires', 0),
_buildSubNavItem(context, 'Paramètres généraux', 0),
const SizedBox(width: 16),
_buildSubNavItem(context, 'Parents', 1),
const SizedBox(width: 16),
_buildSubNavItem(context, 'Assistantes maternelles', 2),
const SizedBox(width: 16),
_buildSubNavItem(context, 'Administrateurs', 3),
_buildSubNavItem(context, 'Paramètres territoriaux', 1),
],
),
),

View File

@ -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)),
),
],
)
],
),
),
);
}
}

View File

@ -1,10 +1,17 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/gestionnaire_card.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 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() =>
@ -16,21 +23,15 @@ class _GestionnaireManagementWidgetState
bool _isLoading = false;
String? _error;
List<AppUser> _gestionnaires = [];
List<AppUser> _filteredGestionnaires = [];
final TextEditingController _searchController = TextEditingController();
@override
void initState() {
super.initState();
_loadGestionnaires();
_searchController.addListener(_onSearchChanged);
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
void dispose() => super.dispose();
Future<void> _loadGestionnaires() async {
setState(() {
@ -38,11 +39,10 @@ class _GestionnaireManagementWidgetState
_error = null;
});
try {
final list = await UserService.getGestionnaires();
final gestionnaires = await UserService.getGestionnaires();
if (!mounted) return;
setState(() {
_gestionnaires = list;
_filteredGestionnaires = list;
_gestionnaires = gestionnaires;
_isLoading = false;
});
} catch (e) {
@ -54,71 +54,56 @@ class _GestionnaireManagementWidgetState
}
}
void _onSearchChanged() {
final query = _searchController.text.toLowerCase();
setState(() {
_filteredGestionnaires = _gestionnaires.where((u) {
final name = u.fullName.toLowerCase();
final email = u.email.toLowerCase();
return name.contains(query) || email.contains(query);
}).toList();
});
Future<void> _openGestionnaireEditDialog(AppUser user) async {
final changed = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
return AdminUserFormDialog(initialUser: user);
},
);
if (changed == true) {
await _loadGestionnaires();
}
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 🔹 Barre du haut avec bouton
Row(
children: [
Expanded(
child: TextField(
controller: _searchController,
decoration: const InputDecoration(
hintText: "Rechercher un gestionnaire...",
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 16),
ElevatedButton.icon(
onPressed: () {
// TODO: Rediriger vers la page de création
},
icon: const Icon(Icons.add),
label: const Text("Créer un gestionnaire"),
),
],
),
const SizedBox(height: 24),
final query = widget.searchQuery.toLowerCase();
final filteredGestionnaires = _gestionnaires.where((u) {
final name = u.fullName.toLowerCase();
final email = u.email.toLowerCase();
return name.contains(query) || email.contains(query);
}).toList();
// 🔹 Liste des gestionnaires
if (_isLoading)
const Center(child: CircularProgressIndicator())
else if (_error != null)
Center(child: Text('Erreur: $_error', style: const TextStyle(color: Colors.red)))
else if (_filteredGestionnaires.isEmpty)
const Center(child: Text("Aucun gestionnaire trouvé."))
else
Expanded(
child: ListView.builder(
itemCount: _filteredGestionnaires.length,
itemBuilder: (context, index) {
final user = _filteredGestionnaires[index];
return GestionnaireCard(
name: user.fullName.isNotEmpty ? user.fullName : "Sans nom",
email: user.email,
);
},
),
)
],
),
return UserList(
isLoading: _isLoading,
error: _error,
isEmpty: filteredGestionnaires.isEmpty,
emptyMessage: 'Aucun gestionnaire trouvé.',
itemCount: filteredGestionnaires.length,
itemBuilder: (context, index) {
final user = filteredGestionnaires[index];
return AdminUserCard(
title: user.fullName,
fallbackIcon: Icons.assignment_ind_outlined,
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);
},
),
],
);
},
);
}
}

View File

@ -1,13 +1,19 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.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é.
class ParametresPanel extends StatefulWidget {
/// Si true, après sauvegarde on redirige vers le login (première config). Sinon on reste sur la page.
final bool redirectToLoginAfterSave;
final int selectedSettingsTabIndex;
const ParametresPanel({super.key, this.redirectToLoginAfterSave = false});
const ParametresPanel({
super.key,
this.redirectToLoginAfterSave = false,
this.selectedSettingsTabIndex = 0,
});
@override
State<ParametresPanel> createState() => _ParametresPanelState();
@ -33,10 +39,18 @@ class _ParametresPanelState extends State<ParametresPanel> {
void _createControllers() {
final keys = [
'smtp_host', 'smtp_port', 'smtp_user', 'smtp_password',
'email_from_name', 'email_from_address',
'app_name', 'app_url', 'app_logo_url',
'password_reset_token_expiry_days', 'jwt_expiry_hours', 'max_upload_size_mb',
'smtp_host',
'smtp_port',
'smtp_user',
'smtp_password',
'email_from_name',
'email_from_address',
'app_name',
'app_url',
'app_logo_url',
'password_reset_token_expiry_days',
'jwt_expiry_hours',
'max_upload_size_mb',
];
for (final k in keys) {
_controllers[k] = TextEditingController();
@ -93,18 +107,29 @@ class _ParametresPanelState extends State<ParametresPanel> {
payload['smtp_auth_required'] = _smtpAuthRequired;
payload['smtp_user'] = _controllers['smtp_user']!.text.trim();
final pwd = _controllers['smtp_password']!.text.trim();
if (pwd.isNotEmpty && pwd != '***********') payload['smtp_password'] = pwd;
if (pwd.isNotEmpty && pwd != '***********') {
payload['smtp_password'] = pwd;
}
payload['email_from_name'] = _controllers['email_from_name']!.text.trim();
payload['email_from_address'] = _controllers['email_from_address']!.text.trim();
payload['email_from_address'] =
_controllers['email_from_address']!.text.trim();
payload['app_name'] = _controllers['app_name']!.text.trim();
payload['app_url'] = _controllers['app_url']!.text.trim();
payload['app_logo_url'] = _controllers['app_logo_url']!.text.trim();
final tokenDays = int.tryParse(_controllers['password_reset_token_expiry_days']!.text.trim());
if (tokenDays != null) payload['password_reset_token_expiry_days'] = tokenDays;
final jwtHours = int.tryParse(_controllers['jwt_expiry_hours']!.text.trim());
if (jwtHours != null) payload['jwt_expiry_hours'] = jwtHours;
final tokenDays = int.tryParse(
_controllers['password_reset_token_expiry_days']!.text.trim());
if (tokenDays != null) {
payload['password_reset_token_expiry_days'] = tokenDays;
}
final jwtHours =
int.tryParse(_controllers['jwt_expiry_hours']!.text.trim());
if (jwtHours != null) {
payload['jwt_expiry_hours'] = jwtHours;
}
final maxMb = int.tryParse(_controllers['max_upload_size_mb']!.text.trim());
if (maxMb != null) payload['max_upload_size_mb'] = maxMb;
if (maxMb != null) {
payload['max_upload_size_mb'] = maxMb;
}
return payload;
}
@ -191,6 +216,10 @@ class _ParametresPanelState extends State<ParametresPanel> {
@override
Widget build(BuildContext context) {
if (widget.selectedSettingsTabIndex == 1) {
return const RelaisManagementPanel();
}
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
@ -214,7 +243,8 @@ class _ParametresPanelState extends State<ParametresPanel> {
}
final isSuccess = _message != null &&
(_message!.startsWith('Configuration') || _message!.startsWith('Connexion'));
(_message!.startsWith('Configuration') ||
_message!.startsWith('Connexion'));
return Form(
key: _formKey,
@ -234,12 +264,21 @@ class _ParametresPanelState extends State<ParametresPanel> {
context,
icon: Icons.email_outlined,
title: 'Configuration Email (SMTP)',
child: Column(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildField('smtp_host', 'Serveur SMTP', hint: 'mail.example.com'),
_buildField(
'smtp_host',
'Serveur SMTP',
hint: 'mail.example.com',
),
const SizedBox(height: 14),
_buildField('smtp_port', 'Port SMTP', keyboard: TextInputType.number, hint: '25, 465, 587'),
_buildField(
'smtp_port',
'Port SMTP',
keyboard: TextInputType.number,
hint: '25, 465, 587',
),
const SizedBox(height: 14),
Padding(
padding: const EdgeInsets.only(bottom: 14),
@ -247,14 +286,17 @@ class _ParametresPanelState extends State<ParametresPanel> {
children: [
Checkbox(
value: _smtpSecure,
onChanged: (v) => setState(() => _smtpSecure = v ?? false),
onChanged: (v) =>
setState(() => _smtpSecure = v ?? false),
activeColor: const Color(0xFF9CC5C0),
),
const Text('SSL/TLS (secure)'),
const SizedBox(width: 24),
Checkbox(
value: _smtpAuthRequired,
onChanged: (v) => setState(() => _smtpAuthRequired = v ?? false),
onChanged: (v) => setState(
() => _smtpAuthRequired = v ?? false,
),
activeColor: const Color(0xFF9CC5C0),
),
const Text('Authentification requise'),
@ -263,11 +305,19 @@ class _ParametresPanelState extends State<ParametresPanel> {
),
_buildField('smtp_user', 'Utilisateur SMTP'),
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),
_buildField('email_from_name', 'Nom expéditeur'),
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),
Align(
alignment: Alignment.centerRight,
@ -277,8 +327,13 @@ class _ParametresPanelState extends State<ParametresPanel> {
label: const Text('Tester la connexion SMTP'),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF2D6A4F),
side: const BorderSide(color: Color(0xFF9CC5C0)),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
side: const BorderSide(
color: Color(0xFF9CC5C0),
),
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 12,
),
),
),
),
@ -290,14 +345,22 @@ class _ParametresPanelState extends State<ParametresPanel> {
context,
icon: Icons.palette_outlined,
title: 'Personnalisation',
child: Column(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildField('app_name', 'Nom de l\'application'),
const SizedBox(height: 14),
_buildField('app_url', 'URL de l\'application', hint: 'https://app.example.com'),
_buildField(
'app_url',
'URL de l\'application',
hint: 'https://app.example.com',
),
const SizedBox(height: 14),
_buildField('app_logo_url', 'URL du logo', hint: '/assets/logo.png'),
_buildField(
'app_logo_url',
'URL du logo',
hint: '/assets/logo.png',
),
],
),
),
@ -309,11 +372,23 @@ class _ParametresPanelState extends State<ParametresPanel> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
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),
_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),
_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,
),
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'),
),
),
@ -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(
elevation: 2,
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];
if (c == null) return const SizedBox.shrink();
return TextFormField(
@ -381,7 +465,8 @@ class _ParametresPanelState extends State<ParametresPanel> {
labelText: label,
hintText: hint,
border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
),
);
}

View File

@ -1,9 +1,19 @@
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 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();
@ -13,23 +23,15 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
bool _isLoading = false;
String? _error;
List<ParentModel> _parents = [];
List<ParentModel> _filteredParents = [];
final TextEditingController _searchController = TextEditingController();
String? _selectedStatus;
@override
void initState() {
super.initState();
_loadParents();
_searchController.addListener(_filter);
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
void dispose() => super.dispose();
Future<void> _loadParents() async {
setState(() {
@ -41,7 +43,6 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
if (!mounted) return;
setState(() {
_parents = list;
_filter(); // Apply initial filter (if any)
_isLoading = false;
});
} catch (e) {
@ -53,139 +54,102 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
}
}
void _filter() {
final query = _searchController.text.toLowerCase();
setState(() {
_filteredParents = _parents.where((p) {
final matchesName = p.user.fullName.toLowerCase().contains(query) ||
p.user.email.toLowerCase().contains(query);
final matchesStatus = _selectedStatus == null ||
_selectedStatus == 'Tous' ||
(p.user.statut?.toLowerCase() == _selectedStatus?.toLowerCase());
// Mapping simple pour le statut affiché vs backend
// Backend: en_attente, actif, suspendu
// Dropdown: En attente, Actif, Suspendu
return matchesName && matchesStatus;
}).toList();
});
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSearchSection(),
const SizedBox(height: 16),
if (_isLoading)
const Center(child: CircularProgressIndicator())
else if (_error != null)
Center(child: Text('Erreur: $_error', style: const TextStyle(color: Colors.red)))
else if (_filteredParents.isEmpty)
const Center(child: Text("Aucun parent trouvé."))
else
Expanded(
child: ListView.builder(
itemCount: _filteredParents.length,
itemBuilder: (context, index) {
final parent = _filteredParents[index];
return Card(
margin: const EdgeInsets.symmetric(vertical: 8),
child: ListTile(
leading: CircleAvatar(
backgroundImage: parent.user.photoUrl != null
? NetworkImage(parent.user.photoUrl!)
: null,
child: parent.user.photoUrl == null
? const Icon(Icons.person)
: null,
),
title: Text(parent.user.fullName.isNotEmpty
? parent.user.fullName
: 'Sans nom'),
subtitle: Text(
"${parent.user.email}\nStatut : ${parent.user.statut ?? 'Inconnu'} | Enfants : ${parent.childrenCount}",
),
isThreeLine: true,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.visibility),
tooltip: "Voir dossier",
onPressed: () {
// TODO: Voir le statut du dossier
},
),
IconButton(
icon: const Icon(Icons.edit),
tooltip: "Modifier",
onPressed: () {
// TODO: Modifier parent
},
),
IconButton(
icon: const Icon(Icons.delete),
tooltip: "Supprimer",
onPressed: () {
// TODO: Supprimer compte
},
),
],
),
),
);
},
),
final query = widget.searchQuery.toLowerCase();
final filteredParents = _parents.where((p) {
final matchesName = p.user.fullName.toLowerCase().contains(query) ||
p.user.email.toLowerCase().contains(query);
final matchesStatus =
widget.statusFilter == null || p.user.statut == widget.statusFilter;
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,
fallbackIcon: Icons.supervisor_account_outlined,
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);
},
),
],
);
},
);
}
String _displayStatus(String? status) {
switch (status) {
case 'actif':
return 'Actif';
case 'en_attente':
return 'En attente';
case 'suspendu':
return 'Suspendu';
default:
return 'Inconnu';
}
}
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(
label: 'Telephone',
value: _v(parent.user.telephone),
),
AdminDetailField(label: 'Adresse', value: _v(parent.user.adresse)),
AdminDetailField(label: 'Ville', value: _v(parent.user.ville)),
AdminDetailField(
label: 'Code postal',
value: _v(parent.user.codePostal),
),
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')),
);
},
),
);
}
Widget _buildSearchSection() {
return Wrap(
spacing: 16,
runSpacing: 8,
children: [
SizedBox(
width: 220,
child: TextField(
controller: _searchController,
decoration: const InputDecoration(
labelText: "Nom du parent",
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.search),
),
),
),
SizedBox(
width: 220,
child: DropdownButtonFormField<String>(
decoration: const InputDecoration(
labelText: "Statut",
border: OutlineInputBorder(),
),
value: _selectedStatus,
items: const [
DropdownMenuItem(value: null, child: Text("Tous")),
DropdownMenuItem(value: "actif", child: Text("Actif")),
DropdownMenuItem(value: "en_attente", child: Text("En attente")),
DropdownMenuItem(value: "suspendu", child: Text("Suspendu")),
],
onChanged: (value) {
setState(() {
_selectedStatus = value;
_filter();
});
},
),
),
],
);
}
String _v(String? value) => (value == null || value.isEmpty) ? '-' : value;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,237 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart';
import 'package:p_tits_pas/widgets/admin/admin_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart';
import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/parent_managmant_widget.dart';
class UserManagementPanel extends StatefulWidget {
/// Afficher l'onglet Administrateurs (sinon 3 onglets : Gestionnaires, Parents, AM).
final bool showAdministrateursTab;
const UserManagementPanel({
super.key,
this.showAdministrateursTab = true,
});
@override
State<UserManagementPanel> createState() => _UserManagementPanelState();
}
class _UserManagementPanelState extends State<UserManagementPanel> {
int _subIndex = 0;
int _gestionnaireRefreshTick = 0;
int _adminRefreshTick = 0;
final TextEditingController _searchController = TextEditingController();
final TextEditingController _amCapacityController = TextEditingController();
String? _parentStatus;
@override
void initState() {
super.initState();
_searchController.addListener(_onFilterChanged);
_amCapacityController.addListener(_onFilterChanged);
}
@override
void dispose() {
_searchController.removeListener(_onFilterChanged);
_amCapacityController.removeListener(_onFilterChanged);
_searchController.dispose();
_amCapacityController.dispose();
super.dispose();
}
void _onFilterChanged() {
if (!mounted) return;
setState(() {});
}
void _onSubTabChange(int index) {
final maxIndex = widget.showAdministrateursTab ? 3 : 2;
setState(() {
_subIndex = index.clamp(0, maxIndex);
_searchController.clear();
_parentStatus = null;
_amCapacityController.clear();
});
}
String _searchHintForTab() {
switch (_subIndex) {
case 0:
return 'Rechercher un gestionnaire...';
case 1:
return 'Rechercher un parent...';
case 2:
return 'Rechercher une assistante...';
case 3:
return 'Rechercher un administrateur...';
default:
return 'Rechercher...';
}
}
Widget? _subBarFilterControl() {
if (_subIndex == 1) {
return DropdownButtonHideUnderline(
child: DropdownButton<String?>(
value: _parentStatus,
isExpanded: true,
hint: const Padding(
padding: EdgeInsets.only(left: 10),
child: Text('Statut', style: TextStyle(fontSize: 12)),
),
items: const [
DropdownMenuItem<String?>(
value: null,
child: Padding(
padding: EdgeInsets.only(left: 10),
child: Text('Tous', style: TextStyle(fontSize: 12)),
),
),
DropdownMenuItem<String?>(
value: 'actif',
child: Padding(
padding: EdgeInsets.only(left: 10),
child: Text('Actif', style: TextStyle(fontSize: 12)),
),
),
DropdownMenuItem<String?>(
value: 'en_attente',
child: Padding(
padding: EdgeInsets.only(left: 10),
child: Text('En attente', style: TextStyle(fontSize: 12)),
),
),
DropdownMenuItem<String?>(
value: 'suspendu',
child: Padding(
padding: EdgeInsets.only(left: 10),
child: Text('Suspendu', style: TextStyle(fontSize: 12)),
),
),
],
onChanged: (value) {
setState(() {
_parentStatus = value;
});
},
),
);
}
if (_subIndex == 2) {
return TextField(
controller: _amCapacityController,
decoration: const InputDecoration(
hintText: 'Capacité min',
hintStyle: TextStyle(fontSize: 12),
border: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8),
),
keyboardType: TextInputType.number,
);
}
return null;
}
Widget _buildBody() {
switch (_subIndex) {
case 0:
return GestionnaireManagementWidget(
key: ValueKey('gestionnaires-$_gestionnaireRefreshTick'),
searchQuery: _searchController.text,
);
case 1:
return ParentManagementWidget(
searchQuery: _searchController.text,
statusFilter: _parentStatus,
);
case 2:
return AssistanteMaternelleManagementWidget(
searchQuery: _searchController.text,
capacityMin: int.tryParse(_amCapacityController.text),
);
case 3:
return AdminManagementWidget(
key: ValueKey('admins-$_adminRefreshTick'),
searchQuery: _searchController.text,
);
default:
return const Center(child: Text('Page non trouvée'));
}
}
@override
Widget build(BuildContext context) {
final subTabCount = widget.showAdministrateursTab ? 4 : 3;
return Column(
children: [
DashboardUserManagementSubBar(
selectedSubIndex: _subIndex,
onSubTabChange: _onSubTabChange,
searchController: _searchController,
searchHint: _searchHintForTab(),
filterControl: _subBarFilterControl(),
onAddPressed: _handleAddPressed,
addLabel: 'Ajouter',
subTabCount: subTabCount,
),
Expanded(child: _buildBody()),
],
);
}
Future<void> _handleAddPressed() async {
if (_subIndex == 0) {
final created = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
return const AdminUserFormDialog();
},
);
if (!mounted) return;
if (created == true) {
setState(() {
_gestionnaireRefreshTick++;
});
}
return;
}
if (_subIndex == 3) {
final created = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
return const AdminUserFormDialog(
adminMode: true,
withRelais: false,
);
},
);
if (!mounted) return;
if (created == true) {
setState(() {
_adminRefreshTick++;
});
}
return;
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'La création est disponible pour les gestionnaires et administrateurs.',
),
),
);
}
}

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:p_tits_pas/models/m_dashbord/child_model.dart';
import 'package:p_tits_pas/services/bug_report_service.dart';
@ -185,13 +186,11 @@ class AppFooter extends StatelessWidget {
}
void _handleLegalNotices(BuildContext context) {
// Handle legal notices action
Navigator.pushNamed(context, '/legal');
context.push('/legal');
}
void _handlePrivacyPolicy(BuildContext context) {
// Handle privacy policy action
Navigator.pushNamed(context, '/privacy');
context.push('/privacy');
}
void _handleContactSupport(BuildContext context) {

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:google_fonts/google_fonts.dart';
// Définition de l'enum pour les styles de couleur/fond
@ -10,6 +11,7 @@ enum CustomAppTextFieldStyle {
class CustomAppTextField extends StatefulWidget {
final TextEditingController controller;
final FocusNode? focusNode;
final String labelText;
final String hintText;
final double fieldWidth;
@ -26,10 +28,15 @@ class CustomAppTextField extends StatefulWidget {
final double labelFontSize;
final double inputFontSize;
final bool showLabel;
final Iterable<String>? autofillHints;
final TextInputAction? textInputAction;
final ValueChanged<String>? onFieldSubmitted;
final List<TextInputFormatter>? inputFormatters;
const CustomAppTextField({
super.key,
required this.controller,
this.focusNode,
required this.labelText,
this.showLabel = true,
this.hintText = '',
@ -46,6 +53,10 @@ class CustomAppTextField extends StatefulWidget {
this.suffixIcon,
this.labelFontSize = 18.0,
this.inputFontSize = 18.0,
this.autofillHints,
this.textInputAction,
this.onFieldSubmitted,
this.inputFormatters,
});
@override
@ -68,7 +79,7 @@ class _CustomAppTextFieldState extends State<CustomAppTextField> {
@override
Widget build(BuildContext context) {
const double fontHeightMultiplier = 1.2;
const double internalVerticalPadding = 16.0;
const double internalVerticalPadding = 16.0;
final double dynamicFieldHeight = widget.fieldHeight;
return Column(
@ -90,7 +101,7 @@ class _CustomAppTextFieldState extends State<CustomAppTextField> {
width: widget.fieldWidth,
height: dynamicFieldHeight,
child: Stack(
alignment: Alignment.centerLeft,
alignment: Alignment.centerLeft,
children: [
Positioned.fill(
child: Image.asset(
@ -99,40 +110,50 @@ class _CustomAppTextFieldState extends State<CustomAppTextField> {
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0, vertical: 8.0),
padding:
const EdgeInsets.symmetric(horizontal: 18.0, vertical: 8.0),
child: TextFormField(
controller: widget.controller,
focusNode: widget.focusNode,
obscureText: widget.obscureText,
keyboardType: widget.keyboardType,
inputFormatters: widget.inputFormatters,
autofillHints: widget.autofillHints,
textInputAction: widget.textInputAction,
onFieldSubmitted: widget.onFieldSubmitted,
enabled: widget.enabled,
readOnly: widget.readOnly,
onTap: widget.onTap,
style: GoogleFonts.merienda(
fontSize: widget.inputFontSize,
color: widget.enabled ? Colors.black87 : Colors.grey
),
fontSize: widget.inputFontSize,
color: widget.enabled ? Colors.black87 : Colors.grey),
validator: widget.validator ??
(value) {
if (!widget.enabled || widget.readOnly) return null;
if (widget.isRequired && (value == null || value.isEmpty)) {
return 'Ce champ est obligatoire';
}
return null;
},
if (!widget.enabled || widget.readOnly) return null;
if (widget.isRequired &&
(value == null || value.isEmpty)) {
return 'Ce champ est obligatoire';
}
return null;
},
decoration: InputDecoration(
hintText: widget.hintText,
hintStyle: GoogleFonts.merienda(fontSize: widget.inputFontSize, color: Colors.black54.withOpacity(0.7)),
hintStyle: GoogleFonts.merienda(
fontSize: widget.inputFontSize,
color: Colors.black54.withOpacity(0.7)),
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
suffixIcon: widget.suffixIcon != null
suffixIcon: widget.suffixIcon != null
? Padding(
padding: const EdgeInsets.only(right: 0.0),
child: Icon(widget.suffixIcon, color: Colors.black54, size: widget.inputFontSize * 1.1),
child: Icon(widget.suffixIcon,
color: Colors.black54,
size: widget.inputFontSize * 1.1),
)
: null,
isDense: true,
),
textAlignVertical: TextAlignVertical.center,
textAlignVertical: TextAlignVertical.center,
),
),
],
@ -141,4 +162,4 @@ class _CustomAppTextFieldState extends State<CustomAppTextField> {
],
);
}
}
}

View File

@ -0,0 +1,299 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:p_tits_pas/services/auth_service.dart';
/// Item d'onglet pour le bandeau (label + enabled).
class DashboardTabItem {
final String label;
final bool enabled;
const DashboardTabItem({
required this.label,
this.enabled = true,
});
}
/// Icône associée au rôle utilisateur (alignée sur le panneau admin).
IconData _iconForRole(String? role) {
if (role == null || role.isEmpty) return Icons.person_outline;
final r = role.toLowerCase();
if (r == 'super_admin') return Icons.verified_user_outlined;
if (r == 'admin' || r == 'administrateur') return Icons.manage_accounts_outlined;
if (r == 'gestionnaire') return Icons.assignment_ind_outlined;
if (r == 'parent') return Icons.supervisor_account_outlined;
if (r == 'assistante_maternelle') return Icons.face;
return Icons.person_outline;
}
/// Bandeau générique type Gitea : icône | onglets | capsule (Prénom Nom ) menu (email, Profil, Paramètres, Déconnexion).
class DashboardBandeau extends StatelessWidget implements PreferredSizeWidget {
final Widget? leading;
final List<DashboardTabItem> tabItems;
final int selectedTabIndex;
final ValueChanged<int> onTabSelected;
final String userDisplayName;
final String? userEmail;
/// Rôle de l'utilisateur pour afficher l'icône correspondante (même que panneau admin).
final String? userRole;
final VoidCallback? onProfileTap;
final VoidCallback? onSettingsTap;
final VoidCallback? onLogout;
final bool showLogoutConfirmation;
final bool bottomBorder;
final double? preferredHeight;
const DashboardBandeau({
super.key,
this.leading,
required this.tabItems,
required this.selectedTabIndex,
required this.onTabSelected,
required this.userDisplayName,
this.userEmail,
this.userRole,
this.onProfileTap,
this.onSettingsTap,
this.onLogout,
this.showLogoutConfirmation = true,
this.bottomBorder = true,
this.preferredHeight,
});
@override
Size get preferredSize =>
Size.fromHeight(preferredHeight ?? (kToolbarHeight + 10));
Widget _defaultLeading() {
return Image.asset(
'assets/images/logo.png',
height: 40,
fit: BoxFit.contain,
);
}
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Theme.of(context).appBarTheme.backgroundColor ?? Colors.white,
border: bottomBorder
? Border(bottom: BorderSide(color: Colors.grey.shade300))
: null,
),
child: AppBar(
elevation: 0,
automaticallyImplyLeading: false,
title: Row(
children: [
const SizedBox(width: 24),
leading ?? _defaultLeading(),
Expanded(
child: Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (int i = 0; i < tabItems.length; i++) ...[
if (i > 0) const SizedBox(width: 24),
_buildNavItem(
context,
title: tabItems[i].label,
index: i,
enabled: tabItems[i].enabled,
),
],
],
),
),
),
],
),
actions: [
_buildUserCapsule(context),
],
),
);
}
Widget _buildNavItem(
BuildContext context, {
required String title,
required int index,
bool enabled = true,
}) {
final isActive = index == selectedTabIndex;
return InkWell(
onTap: enabled ? () => onTabSelected(index) : null,
child: Opacity(
opacity: enabled ? 1.0 : 0.5,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: isActive ? const Color(0xFF9CC5C0) : Colors.transparent,
borderRadius: BorderRadius.circular(20),
border: isActive ? null : Border.all(color: Colors.black26),
),
child: Text(
title,
style: TextStyle(
color: isActive ? Colors.white : Colors.black,
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
fontSize: 14,
),
),
),
),
);
}
Widget _buildUserCapsule(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(right: 16),
child: PopupMenuButton<String?>(
offset: const Offset(0, 45),
position: PopupMenuPosition.under,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
onSelected: (value) {
switch (value) {
case 'profile':
onProfileTap?.call();
break;
case 'settings':
onSettingsTap?.call();
break;
case 'logout':
_handleLogout(context);
break;
}
},
itemBuilder: (context) {
final entries = <PopupMenuEntry<String?>>[];
if (userEmail != null && userEmail!.isNotEmpty) {
entries.add(
PopupMenuItem<String?>(
enabled: false,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.email_outlined, size: 16, color: Colors.grey.shade700),
const SizedBox(width: 8),
Flexible(
child: Text(
userEmail!,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade700,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
entries.add(const PopupMenuDivider());
}
if (onProfileTap != null) {
entries.add(
const PopupMenuItem<String?>(
value: 'profile',
child: ListTile(
leading: Icon(Icons.person_outline, size: 20),
title: Text('Modification du profil'),
contentPadding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
),
),
);
}
if (onSettingsTap != null) {
entries.add(
const PopupMenuItem<String?>(
value: 'settings',
child: ListTile(
leading: Icon(Icons.settings_outlined, size: 20),
title: Text('Paramètres'),
contentPadding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
),
),
);
}
if (onLogout != null) {
if (entries.isNotEmpty) entries.add(const PopupMenuDivider());
entries.add(
const PopupMenuItem<String?>(
value: 'logout',
child: ListTile(
leading: Icon(Icons.logout, size: 20),
title: Text('Déconnexion'),
contentPadding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
),
),
);
}
return entries;
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.grey.shade300),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(_iconForRole(userRole), size: 18, color: Colors.grey.shade700),
const SizedBox(width: 6),
Text(
userDisplayName,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.black87,
),
),
const SizedBox(width: 4),
Icon(Icons.keyboard_arrow_down, size: 20, color: Colors.grey.shade700),
],
),
),
),
);
}
void _handleLogout(BuildContext context) {
if (showLogoutConfirmation) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Déconnexion'),
content: const Text(
'Êtes-vous sûr de vouloir vous déconnecter ?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () async {
Navigator.pop(ctx);
onLogout?.call();
await AuthService.logout();
if (context.mounted) context.go('/login');
},
child: const Text('Déconnecter'),
),
],
),
);
} else {
onLogout?.call();
AuthService.logout().then((_) {
if (context.mounted) context.go('/login');
});
}
}
}

View File

@ -1,156 +0,0 @@
import 'package:flutter/material.dart';
class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
final int selectedIndex;
final ValueChanged<int> onTabChange;
const DashboardAppBar({Key? key, required this.selectedIndex, required this.onTabChange}) : super(key: key);
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight + 10);
@override
Widget build(BuildContext context) {
final isMobile = MediaQuery.of(context).size.width < 768;
return AppBar(
// backgroundColor: Colors.white,
elevation: 0,
title: Row(
children: [
// Logo de la ville
// Container(
// height: 32,
// width: 32,
// decoration: BoxDecoration(
// color: Colors.white,
// borderRadius: BorderRadius.circular(8),
// ),
// child: const Icon(
// Icons.location_city,
// color: Color(0xFF9CC5C0),
// size: 20,
// ),
// ),
SizedBox(width: MediaQuery.of(context).size.width * 0.19),
const Text(
"P'tit Pas",
style: TextStyle(
color: Color(0xFF9CC5C0),
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
SizedBox(width: MediaQuery.of(context).size.width * 0.1),
// Navigation principale
_buildNavItem(context, 'Mon tableau de bord', 0),
const SizedBox(width: 24),
_buildNavItem(context, 'Trouver une nounou', 1),
const SizedBox(width: 24),
_buildNavItem(context, 'Paramètres', 2),
],
),
actions: isMobile
? [_buildMobileMenu(context)]
: [
// Nom de l'utilisateur
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Center(
child: Text(
'Jean Dupont',
style: TextStyle(
color: Colors.black,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
),
// Bouton déconnexion
Padding(
padding: const EdgeInsets.only(right: 16),
child: TextButton(
onPressed: () => _handleLogout(context),
style: TextButton.styleFrom(
backgroundColor: const Color(0xFF9CC5C0),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5),
),
),
child: const Text('Se déconnecter'),
),
),
SizedBox(width: MediaQuery.of(context).size.width * 0.1),
],
);
}
Widget _buildNavItem(BuildContext context, String title, int index) {
final bool isActive = index == selectedIndex;
return InkWell(
onTap: () => onTabChange(index),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: isActive ? const Color(0xFF9CC5C0) : Colors.transparent,
borderRadius: BorderRadius.circular(20),
border: isActive ? null : Border.all(color: Colors.black26),
),
child: Text(
title,
style: TextStyle(
color: isActive ? Colors.white : Colors.black,
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
fontSize: 14,
),
),
),
);
}
Widget _buildMobileMenu(BuildContext context) {
return PopupMenuButton<int>(
icon: const Icon(Icons.menu, color: Colors.white),
onSelected: (value) {
if (value == 3) {
_handleLogout(context);
}
},
itemBuilder: (context) => [
const PopupMenuItem(value: 0, child: Text("Mon tableau de bord")),
const PopupMenuItem(value: 1, child: Text("Trouver une nounou")),
const PopupMenuItem(value: 2, child: Text("Paramètres")),
const PopupMenuDivider(),
const PopupMenuItem(value: 3, child: Text("Se déconnecter")),
],
);
}
void _handleLogout(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Déconnexion'),
content: const Text('Êtes-vous sûr de vouloir vous déconnecter ?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () {
Navigator.pop(context);
// TODO: Implémenter la logique de déconnexion
},
child: const Text('Déconnecter'),
),
],
),
);
}
}

View File

@ -23,26 +23,38 @@ class ImageButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: onPressed,
child: Container(
width: width,
height: height,
decoration: BoxDecoration(
image: DecorationImage(
image: AssetImage(bg),
fit: BoxFit.fill,
return SizedBox(
width: width,
height: height,
child: Semantics(
button: true,
label: text,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: TextButton(
onPressed: onPressed,
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
shape:
const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
),
),
child: Center(
child: Text(
text,
style: GoogleFonts.merienda(
color: textColor,
fontSize: fontSize, // Utilisation du paramètre
fontWeight: FontWeight.bold,
child: Ink(
decoration: BoxDecoration(
image: DecorationImage(
image: AssetImage(bg),
fit: BoxFit.fill,
),
),
child: Center(
child: Text(
text,
style: GoogleFonts.merienda(
color: textColor,
fontSize: fontSize, // Utilisation du paramètre
fontWeight: FontWeight.bold,
),
),
),
),
),
@ -50,4 +62,4 @@ class ImageButton extends StatelessWidget {
),
);
}
}
}

View File

@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import '../utils/nir_utils.dart';
import 'custom_app_text_field.dart';
/// Champ de saisie dédié au NIR (Numéro d'Inscription au Répertoire 15 caractères).
/// Format affiché : 1 12 34 56 789 012-34 ou 1 12 34 2A 789 012-34 pour la Corse.
/// La valeur envoyée au [controller] est formatée ; utiliser [normalizeNir](controller.text) à la soumission.
class NirTextField extends StatelessWidget {
final TextEditingController controller;
final String labelText;
final String hintText;
final String? Function(String?)? validator;
final double fieldWidth;
final double fieldHeight;
final double labelFontSize;
final double inputFontSize;
final bool enabled;
final bool readOnly;
final CustomAppTextFieldStyle style;
const NirTextField({
super.key,
required this.controller,
this.labelText = 'N° Sécurité Sociale (NIR)',
this.hintText = '15 car. (ex. 1 12 34 56 789 012-34 ou 2A Corse)',
this.validator,
this.fieldWidth = double.infinity,
this.fieldHeight = 53.0,
this.labelFontSize = 18.0,
this.inputFontSize = 18.0,
this.enabled = true,
this.readOnly = false,
this.style = CustomAppTextFieldStyle.beige,
});
@override
Widget build(BuildContext context) {
return CustomAppTextField(
controller: controller,
labelText: labelText,
hintText: hintText,
fieldWidth: fieldWidth,
fieldHeight: fieldHeight,
labelFontSize: labelFontSize,
inputFontSize: inputFontSize,
keyboardType: TextInputType.text,
validator: validator ?? validateNir,
inputFormatters: [NirInputFormatter()],
enabled: enabled,
readOnly: readOnly,
style: style,
);
}
}

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
@ -6,7 +7,9 @@ import 'dart:math' as math;
import 'dart:io';
import '../models/card_assets.dart';
import '../config/display_config.dart';
import '../utils/nir_utils.dart';
import 'custom_app_text_field.dart';
import 'nir_text_field.dart';
import 'form_field_wrapper.dart';
import 'app_custom_checkbox.dart';
import 'hover_relief_widget.dart';
@ -97,7 +100,8 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
: '';
_birthCityController.text = data.birthCity;
_birthCountryController.text = data.birthCountry;
_nirController.text = data.nir;
final nirRaw = nirToRaw(data.nir);
_nirController.text = nirRaw.length == 15 ? formatNir(nirRaw) : data.nir;
_agrementController.text = data.agrementNumber;
_capacityController.text = data.capacity?.toString() ?? '';
_photoPathFramework = data.photoPath;
@ -161,7 +165,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
dateOfBirth: _selectedDate,
birthCity: _birthCityController.text,
birthCountry: _birthCountryController.text,
nir: _nirController.text,
nir: normalizeNir(_nirController.text),
agrementNumber: _agrementController.text,
capacity: int.tryParse(_capacityController.text),
);
@ -499,7 +503,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
children: [
Expanded(flex: 2, child: _buildReadonlyField('Date de naissance', _dateOfBirthController.text)),
const SizedBox(width: 16),
Expanded(flex: 3, child: _buildReadonlyField('NIR', _nirController.text)),
Expanded(flex: 3, child: _buildReadonlyField('NIR', _formatNirForDisplay(_nirController.text))),
],
),
const SizedBox(height: 12),
@ -525,6 +529,12 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
);
}
/// NIR formaté pour affichage (1 12 34 56 789 012-34 ou 2A pour la Corse).
String _formatNirForDisplay(String value) {
final raw = nirToRaw(value);
return raw.length == 15 ? formatNir(raw) : value;
}
/// Helper pour champ Readonly style "Beige"
Widget _buildReadonlyField(String label, String value) {
return Column(
@ -609,18 +619,12 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
],
),
SizedBox(height: verticalSpacing),
_buildField(
config: config,
label: 'N° Sécurité Sociale (NIR)',
NirTextField(
controller: _nirController,
hint: 'Votre NIR à 13 chiffres',
keyboardType: TextInputType.number,
validator: (v) {
if (v == null || v.isEmpty) return 'NIR requis';
if (v.length != 13) return 'Le NIR doit contenir 13 chiffres';
if (!RegExp(r'^[1-3]').hasMatch(v[0])) return 'Le NIR doit commencer par 1, 2 ou 3';
return null;
},
fieldWidth: double.infinity,
fieldHeight: config.isMobile ? 45.0 : 53.0,
labelFontSize: config.isMobile ? 15.0 : 22.0,
inputFontSize: config.isMobile ? 14.0 : 20.0,
),
SizedBox(height: verticalSpacing),
Row(
@ -695,18 +699,12 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
),
const SizedBox(height: 12),
_buildField(
config: config,
label: 'N° Sécurité Sociale (NIR)',
NirTextField(
controller: _nirController,
hint: 'Votre NIR à 13 chiffres',
keyboardType: TextInputType.number,
validator: (v) {
if (v == null || v.isEmpty) return 'NIR requis';
if (v.length != 13) return 'Le NIR doit contenir 13 chiffres';
if (!RegExp(r'^[1-3]').hasMatch(v[0])) return 'Le NIR doit commencer par 1, 2 ou 3';
return null;
},
fieldWidth: double.infinity,
fieldHeight: 45.0,
labelFontSize: 15.0,
inputFontSize: 14.0,
),
const SizedBox(height: 12),
@ -796,6 +794,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
VoidCallback? onTap,
IconData? suffixIcon,
String? Function(String?)? validator,
List<TextInputFormatter>? inputFormatters,
}) {
if (config.isReadonly) {
return FormFieldWrapper(
@ -817,6 +816,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
onTap: onTap,
suffixIcon: suffixIcon,
validator: validator,
inputFormatters: inputFormatters,
);
}
}

View File

@ -15,9 +15,18 @@ if [ -z "$GITEA_TOKEN" ]; then
GITEA_TOKEN=$(cat .gitea-token)
fi
fi
if [ -z "$GITEA_TOKEN" ] && [ -f ~/.bashrc ]; then
eval "$(grep '^export GITEA_TOKEN=' ~/.bashrc 2>/dev/null)" || true
fi
if [ -z "$GITEA_TOKEN" ] && [ -f docs/BRIEFING-FRONTEND.md ]; then
token_from_briefing=$(sed -n 's/.*Token: *\(giteabu_[a-f0-9]*\).*/\1/p' docs/BRIEFING-FRONTEND.md 2>/dev/null | head -1)
if [ -n "$token_from_briefing" ]; then
GITEA_TOKEN="$token_from_briefing"
fi
fi
if [ -z "$GITEA_TOKEN" ]; then
echo "Définir GITEA_TOKEN ou créer .gitea-token avec votre token Gitea."
echo "Définir GITEA_TOKEN ou créer .gitea-token avec votre token Gitea (voir docs/PROCEDURE-API-GITEA.md)."
exit 1
fi