Compare commits
96
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e6e473e84 | ||
|
|
cc3639b19a | ||
|
|
cf9cd495bf | ||
|
|
76ba28a7ce | ||
|
|
1772744c81 | ||
|
|
5465117238 | ||
|
|
6e2343087e | ||
|
|
f6fabc521e | ||
|
|
5390276ecd | ||
|
|
7e9306de01 | ||
|
|
d832559027 | ||
|
|
54ad1d2aa1 | ||
|
|
86b28abe51 | ||
|
|
86d8189038 | ||
|
|
dbcb3611d4 | ||
|
|
1fa70f4052 | ||
|
|
393a527c37 | ||
|
|
dfd58d9b6c | ||
|
|
34a36b069e | ||
|
|
2fa546e6b7 | ||
|
|
8636b16659 | ||
|
|
7e17e5ff8d | ||
|
|
e8b6d906e6 | ||
|
|
ae0be04964 | ||
|
|
ca98821b3e | ||
|
|
447f3d4137 | ||
|
|
721f40599b | ||
|
|
a9c6b9e15b | ||
|
|
38c003ef6f | ||
|
|
3dbddbb8c4 | ||
|
|
f46740c6ab | ||
|
|
85bfef7a6b | ||
|
|
b1a80f85c9 | ||
|
|
3c2ecdff7a | ||
|
|
8b83702bd2 | ||
|
|
19b8be684f | ||
|
|
e713c05da1 | ||
|
|
5950d85876 | ||
|
|
4339e1e53d | ||
|
|
51d279e341 | ||
|
|
fffe8cd202 | ||
|
|
defa438edf | ||
|
|
e990d576cf | ||
|
|
619e39219f | ||
|
|
e8c6665a06 | ||
|
|
a4e6cfc50e | ||
|
|
6749f2025a | ||
|
|
119edbcfb4 | ||
|
|
80d69a5463 | ||
|
|
0579fda553 | ||
|
|
d14550a1cf | ||
|
|
2645cf1cd6 | ||
|
|
e2ebc6a0a1 | ||
|
|
090ce6e13b | ||
|
|
d66bdd04be | ||
|
|
d8572e7fd6 | ||
|
|
33cc7a9191 | ||
|
|
222d7c702f | ||
|
|
537c46127f | ||
|
|
10ebc77ba1 | ||
|
|
ed18dcab10 | ||
|
|
f9477d3fbe | ||
|
|
bb92f010bd | ||
|
|
42bb872c41 | ||
|
|
4d37131301 | ||
|
|
fac3ae9baa | ||
|
|
04b910295c | ||
|
|
5c28981ac5 | ||
|
|
57ce5af0f4 | ||
|
|
c136f28f12 | ||
|
|
c1204a3050 | ||
|
|
9d4363b2a7 | ||
|
|
4b176b7083 | ||
|
|
af06ab1e66 | ||
|
|
aa148354ec | ||
|
|
00c42c7bee | ||
|
|
a10dc5a195 | ||
|
|
04c0b05aae | ||
|
|
d0b730c8ab | ||
|
|
bc8362bdb7 | ||
|
|
ac3178903d | ||
|
|
aec1990ec9 | ||
|
|
5da2ab9005 | ||
|
|
b2d6414fab | ||
|
|
fbafef8f2c | ||
|
|
42c569e491 | ||
|
|
135c7c2255 | ||
|
|
9cce326046 | ||
|
|
d697083f54 | ||
|
|
ae786426fd | ||
|
|
d32d956b0e | ||
|
|
e4f7a35f0f | ||
|
|
8a6768b316 | ||
|
|
3892a8beab | ||
|
|
d39bc55be3 | ||
|
|
e0debf0394 |
@@ -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();
|
||||||
@@ -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); });
|
||||||
@@ -16,6 +16,8 @@ import { AllExceptionsFilter } from './common/filters/all_exceptions.filters';
|
|||||||
import { EnfantsModule } from './routes/enfants/enfants.module';
|
import { EnfantsModule } from './routes/enfants/enfants.module';
|
||||||
import { AppConfigModule } from './modules/config/config.module';
|
import { AppConfigModule } from './modules/config/config.module';
|
||||||
import { DocumentsLegauxModule } from './modules/documents-legaux';
|
import { DocumentsLegauxModule } from './modules/documents-legaux';
|
||||||
|
import { RelaisModule } from './routes/relais/relais.module';
|
||||||
|
import { DossiersModule } from './routes/dossiers/dossiers.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -53,6 +55,8 @@ import { DocumentsLegauxModule } from './modules/documents-legaux';
|
|||||||
AuthModule,
|
AuthModule,
|
||||||
AppConfigModule,
|
AppConfigModule,
|
||||||
DocumentsLegauxModule,
|
DocumentsLegauxModule,
|
||||||
|
RelaisModule,
|
||||||
|
DossiersModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -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 : 2A→19, 2B→20).
|
||||||
|
* - 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é (2A→19, 2B→20).
|
||||||
|
*/
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -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'],
|
||||||
|
});
|
||||||
@@ -48,4 +48,7 @@ export class AssistanteMaternelle {
|
|||||||
@Column( { name: 'place_disponible', type: 'integer', nullable: true })
|
@Column( { name: 'place_disponible', type: 'integer', nullable: true })
|
||||||
places_available?: number;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
ManyToOne,
|
||||||
|
OneToMany,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Parents } from './parents.entity';
|
||||||
|
import { Children } from './children.entity';
|
||||||
|
import { StatutDossierType } from './dossiers.entity';
|
||||||
|
|
||||||
|
/** Un dossier = une famille, N enfants (texte de motivation unique, liste d'enfants). */
|
||||||
|
@Entity('dossier_famille')
|
||||||
|
export class DossierFamille {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ name: 'numero_dossier', length: 20 })
|
||||||
|
numero_dossier: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Parents, { onDelete: 'CASCADE', nullable: false })
|
||||||
|
@JoinColumn({ name: 'id_parent', referencedColumnName: 'user_id' })
|
||||||
|
parent: Parents;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
presentation?: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: StatutDossierType,
|
||||||
|
enumName: 'statut_dossier_type',
|
||||||
|
default: StatutDossierType.ENVOYE,
|
||||||
|
name: 'statut',
|
||||||
|
})
|
||||||
|
statut: StatutDossierType;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'cree_le', type: 'timestamptz' })
|
||||||
|
cree_le: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'modifie_le', type: 'timestamptz' })
|
||||||
|
modifie_le: Date;
|
||||||
|
|
||||||
|
@OneToMany(() => DossierFamilleEnfant, (dfe) => dfe.dossier_famille)
|
||||||
|
enfants: DossierFamilleEnfant[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity('dossier_famille_enfants')
|
||||||
|
export class DossierFamilleEnfant {
|
||||||
|
@Column({ name: 'id_dossier_famille', primary: true })
|
||||||
|
id_dossier_famille: string;
|
||||||
|
|
||||||
|
@Column({ name: 'id_enfant', primary: true })
|
||||||
|
id_enfant: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => DossierFamille, (df) => df.enfants, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'id_dossier_famille' })
|
||||||
|
dossier_famille: DossierFamille;
|
||||||
|
|
||||||
|
@ManyToOne(() => Children, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'id_enfant' })
|
||||||
|
enfant: Children;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
Entity, PrimaryColumn, OneToOne, JoinColumn,
|
Entity, PrimaryColumn, Column, OneToOne, JoinColumn,
|
||||||
ManyToOne, OneToMany
|
ManyToOne, OneToMany
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { Users } from './users.entity';
|
import { Users } from './users.entity';
|
||||||
@@ -21,6 +21,10 @@ export class Parents {
|
|||||||
@JoinColumn({ name: 'id_co_parent', referencedColumnName: 'id' })
|
@JoinColumn({ name: 'id_co_parent', referencedColumnName: 'id' })
|
||||||
co_parent?: Users;
|
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
|
// Lien vers enfants via la table enfants_parents
|
||||||
@OneToMany(() => ParentsChildren, pc => pc.parent)
|
@OneToMany(() => ParentsChildren, pc => pc.parent)
|
||||||
parentChildren: ParentsChildren[];
|
parentChildren: ParentsChildren[];
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, OneToMany } from 'typeorm';
|
||||||
|
import { Users } from './users.entity';
|
||||||
|
|
||||||
|
@Entity('relais', { schema: 'public' })
|
||||||
|
export class Relais {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ name: 'nom' })
|
||||||
|
nom: string;
|
||||||
|
|
||||||
|
@Column({ name: 'adresse' })
|
||||||
|
adresse: string;
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb', name: 'horaires_ouverture', nullable: true })
|
||||||
|
horaires_ouverture?: any;
|
||||||
|
|
||||||
|
@Column({ name: 'ligne_fixe', nullable: true })
|
||||||
|
ligne_fixe?: string;
|
||||||
|
|
||||||
|
@Column({ default: true, name: 'actif' })
|
||||||
|
actif: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'text', name: 'notes', nullable: true })
|
||||||
|
notes?: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'cree_le', type: 'timestamptz' })
|
||||||
|
cree_le: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'modifie_le', type: 'timestamptz' })
|
||||||
|
modifie_le: Date;
|
||||||
|
|
||||||
|
@OneToMany(() => Users, user => user.relais)
|
||||||
|
gestionnaires: Users[];
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
Entity, PrimaryGeneratedColumn, Column,
|
Entity, PrimaryGeneratedColumn, Column,
|
||||||
CreateDateColumn, UpdateDateColumn,
|
CreateDateColumn, UpdateDateColumn,
|
||||||
OneToOne, OneToMany
|
OneToOne, OneToMany, ManyToOne, JoinColumn
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { AssistanteMaternelle } from './assistantes_maternelles.entity';
|
import { AssistanteMaternelle } from './assistantes_maternelles.entity';
|
||||||
import { Parents } from './parents.entity';
|
import { Parents } from './parents.entity';
|
||||||
import { Message } from './messages.entity';
|
import { Message } from './messages.entity';
|
||||||
|
import { Relais } from './relais.entity';
|
||||||
|
|
||||||
// Enums alignés avec la BDD PostgreSQL
|
// Enums alignés avec la BDD PostgreSQL
|
||||||
export enum RoleType {
|
export enum RoleType {
|
||||||
@@ -28,6 +29,7 @@ export enum StatutUtilisateurType {
|
|||||||
EN_ATTENTE = 'en_attente',
|
EN_ATTENTE = 'en_attente',
|
||||||
ACTIF = 'actif',
|
ACTIF = 'actif',
|
||||||
SUSPENDU = 'suspendu',
|
SUSPENDU = 'suspendu',
|
||||||
|
REFUSE = 'refuse',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum SituationFamilialeType {
|
export enum SituationFamilialeType {
|
||||||
@@ -80,7 +82,7 @@ export class Users {
|
|||||||
type: 'enum',
|
type: 'enum',
|
||||||
enum: StatutUtilisateurType,
|
enum: StatutUtilisateurType,
|
||||||
enumName: 'statut_utilisateur_type', // correspond à l'enum de la db psql
|
enumName: 'statut_utilisateur_type', // correspond à l'enum de la db psql
|
||||||
default: StatutUtilisateurType.EN_ATTENTE,
|
default: StatutUtilisateurType.ACTIF,
|
||||||
name: 'statut'
|
name: 'statut'
|
||||||
})
|
})
|
||||||
statut: StatutUtilisateurType;
|
statut: StatutUtilisateurType;
|
||||||
@@ -117,6 +119,13 @@ export class Users {
|
|||||||
@Column({ type: 'timestamptz', nullable: true, name: 'token_creation_mdp_expire_le' })
|
@Column({ type: 'timestamptz', nullable: true, name: 'token_creation_mdp_expire_le' })
|
||||||
token_creation_mdp_expire_le?: Date;
|
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' })
|
@Column({ nullable: true, name: 'ville' })
|
||||||
ville?: string;
|
ville?: string;
|
||||||
|
|
||||||
@@ -147,4 +156,15 @@ export class Users {
|
|||||||
|
|
||||||
@OneToMany(() => Parents, parent => parent.co_parent)
|
@OneToMany(() => Parents, parent => parent.co_parent)
|
||||||
co_parent_in?: Parents[];
|
co_parent_in?: Parents[];
|
||||||
|
|
||||||
|
@Column({ nullable: true, name: 'relais_id' })
|
||||||
|
relaisId?: string;
|
||||||
|
|
||||||
|
/** 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { MailService } from './mail.service';
|
||||||
|
import { AppConfigModule } from '../config/config.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AppConfigModule],
|
||||||
|
providers: [MailService],
|
||||||
|
exports: [MailService],
|
||||||
|
})
|
||||||
|
export class MailModule {}
|
||||||
@@ -0,0 +1,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 été créé avec succès.</p>
|
||||||
|
<p>Vous pouvez dès à présent vous connecter avec l'adresse email <strong>${to}</strong> et le mot de passe qui vous a été communiqué.</p>
|
||||||
|
<p>Lors de votre première connexion, il vous sera demandé de modifier votre mot de passe pour des raisons de sécurité.</p>
|
||||||
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
|
<a href="${appUrl}" style="background-color: #4CAF50; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">Accéder à l'application</a>
|
||||||
|
</div>
|
||||||
|
<hr style="border: 1px solid #eee; margin: 20px 0;">
|
||||||
|
<p style="color: #666; font-size: 12px;">
|
||||||
|
Cet email a été envoyé automatiquement. Merci de ne pas y répondre.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
await this.sendEmail(to, subject, html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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, '<').replace(/>/g, '>')}</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 été envoyé automatiquement. Merci de ne pas y répondre.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
await this.sendEmail(to, subject, html);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { NumeroDossierService } from './numero-dossier.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [NumeroDossierService],
|
||||||
|
exports: [NumeroDossierService],
|
||||||
|
})
|
||||||
|
export class NumeroDossierModule {}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,7 +35,7 @@ export class AssistantesMaternellesController {
|
|||||||
return this.assistantesMaternellesService.create(dto);
|
return this.assistantesMaternellesService.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: 'Récupérer la liste des nounous' })
|
@ApiOperation({ summary: 'Récupérer la liste des nounous' })
|
||||||
@ApiResponse({ status: 200, description: 'Liste des nounous' })
|
@ApiResponse({ status: 200, description: 'Liste des nounous' })
|
||||||
|
|||||||
@@ -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 { LoginDto } from './dto/login.dto';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { Public } from 'src/common/decorators/public.decorator';
|
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 { RegisterParentCompletDto } from './dto/register-parent-complet.dto';
|
||||||
import { RegisterAMCompletDto } from './dto/register-am-complet.dto';
|
import { RegisterAMCompletDto } from './dto/register-am-complet.dto';
|
||||||
import { ChangePasswordRequiredDto } from './dto/change-password.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 { AuthGuard } from 'src/common/guards/auth.guard';
|
||||||
import type { Request } from 'express';
|
import type { Request } from 'express';
|
||||||
import { UserService } from '../user/user.service';
|
import { UserService } from '../user/user.service';
|
||||||
import { ProfileResponseDto } from './dto/profile_response.dto';
|
import { ProfileResponseDto } from './dto/profile_response.dto';
|
||||||
import { RefreshTokenDto } from './dto/refresh_token.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 { User } from 'src/common/decorators/user.decorator';
|
||||||
import { Users } from 'src/entities/users.entity';
|
import { Users } from 'src/entities/users.entity';
|
||||||
|
import { RepriseDossierDto } from './dto/reprise-dossier.dto';
|
||||||
|
|
||||||
@ApiTags('Authentification')
|
@ApiTags('Authentification')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
@@ -65,6 +68,35 @@ export class AuthController {
|
|||||||
return this.authService.inscrireAMComplet(dto);
|
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()
|
@Public()
|
||||||
@Post('refresh')
|
@Post('refresh')
|
||||||
@ApiBearerAuth('refresh_token')
|
@ApiBearerAuth('refresh_token')
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ import { Parents } from 'src/entities/parents.entity';
|
|||||||
import { Children } from 'src/entities/children.entity';
|
import { Children } from 'src/entities/children.entity';
|
||||||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||||
import { AppConfigModule } from 'src/modules/config';
|
import { AppConfigModule } from 'src/modules/config';
|
||||||
|
import { NumeroDossierModule } from 'src/modules/numero-dossier/numero-dossier.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([Users, Parents, Children, AssistanteMaternelle]),
|
TypeOrmModule.forFeature([Users, Parents, Children, AssistanteMaternelle]),
|
||||||
forwardRef(() => UserModule),
|
forwardRef(() => UserModule),
|
||||||
AppConfigModule,
|
AppConfigModule,
|
||||||
|
NumeroDossierModule,
|
||||||
JwtModule.registerAsync({
|
JwtModule.registerAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
useFactory: (config: ConfigService) => ({
|
useFactory: (config: ConfigService) => ({
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
ConflictException,
|
ConflictException,
|
||||||
Injectable,
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@@ -22,7 +23,11 @@ import { Children, StatutEnfantType } from 'src/entities/children.entity';
|
|||||||
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||||
import { LoginDto } from './dto/login.dto';
|
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 { 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()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
@@ -31,12 +36,15 @@ export class AuthService {
|
|||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly appConfigService: AppConfigService,
|
private readonly appConfigService: AppConfigService,
|
||||||
|
private readonly numeroDossierService: NumeroDossierService,
|
||||||
@InjectRepository(Parents)
|
@InjectRepository(Parents)
|
||||||
private readonly parentsRepo: Repository<Parents>,
|
private readonly parentsRepo: Repository<Parents>,
|
||||||
@InjectRepository(Users)
|
@InjectRepository(Users)
|
||||||
private readonly usersRepo: Repository<Users>,
|
private readonly usersRepo: Repository<Users>,
|
||||||
@InjectRepository(Children)
|
@InjectRepository(Children)
|
||||||
private readonly childrenRepo: Repository<Children>,
|
private readonly childrenRepo: Repository<Children>,
|
||||||
|
@InjectRepository(AssistanteMaternelle)
|
||||||
|
private readonly assistantesMaternellesRepo: Repository<AssistanteMaternelle>,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -93,6 +101,12 @@ export class AuthService {
|
|||||||
throw new UnauthorizedException('Votre compte a été suspendu. Contactez un administrateur.');
|
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);
|
return this.generateTokens(user.id, user.email, user.role);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,6 +191,11 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (dto.co_parent_email) {
|
if (dto.co_parent_email) {
|
||||||
|
if (dto.email.trim().toLowerCase() === dto.co_parent_email.trim().toLowerCase()) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'L\'email du parent et du co-parent doivent être différents.',
|
||||||
|
);
|
||||||
|
}
|
||||||
const coParentExiste = await this.usersService.findByEmailOrNull(dto.co_parent_email);
|
const coParentExiste = await this.usersService.findByEmailOrNull(dto.co_parent_email);
|
||||||
if (coParentExiste) {
|
if (coParentExiste) {
|
||||||
throw new ConflictException('L\'email du co-parent est déjà utilisé');
|
throw new ConflictException('L\'email du co-parent est déjà utilisé');
|
||||||
@@ -193,6 +212,8 @@ export class AuthService {
|
|||||||
dateExpiration.setDate(dateExpiration.getDate() + joursExpirationToken);
|
dateExpiration.setDate(dateExpiration.getDate() + joursExpirationToken);
|
||||||
|
|
||||||
const resultat = await this.usersRepo.manager.transaction(async (manager) => {
|
const resultat = await this.usersRepo.manager.transaction(async (manager) => {
|
||||||
|
const { numero: numeroDossier } = await this.numeroDossierService.getNextNumeroDossier(manager);
|
||||||
|
|
||||||
const parent1 = manager.create(Users, {
|
const parent1 = manager.create(Users, {
|
||||||
email: dto.email,
|
email: dto.email,
|
||||||
prenom: dto.prenom,
|
prenom: dto.prenom,
|
||||||
@@ -205,6 +226,7 @@ export class AuthService {
|
|||||||
ville: dto.ville,
|
ville: dto.ville,
|
||||||
token_creation_mdp: tokenCreationMdp,
|
token_creation_mdp: tokenCreationMdp,
|
||||||
token_creation_mdp_expire_le: dateExpiration,
|
token_creation_mdp_expire_le: dateExpiration,
|
||||||
|
numero_dossier: numeroDossier,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parent1Enregistre = await manager.save(Users, parent1);
|
const parent1Enregistre = await manager.save(Users, parent1);
|
||||||
@@ -229,6 +251,7 @@ export class AuthService {
|
|||||||
ville: dto.co_parent_meme_adresse ? dto.ville : dto.co_parent_ville,
|
ville: dto.co_parent_meme_adresse ? dto.ville : dto.co_parent_ville,
|
||||||
token_creation_mdp: tokenCoParent,
|
token_creation_mdp: tokenCoParent,
|
||||||
token_creation_mdp_expire_le: dateExpirationCoParent,
|
token_creation_mdp_expire_le: dateExpirationCoParent,
|
||||||
|
numero_dossier: numeroDossier,
|
||||||
});
|
});
|
||||||
|
|
||||||
parent2Enregistre = await manager.save(Users, parent2);
|
parent2Enregistre = await manager.save(Users, parent2);
|
||||||
@@ -236,6 +259,7 @@ export class AuthService {
|
|||||||
|
|
||||||
const entiteParent = manager.create(Parents, {
|
const entiteParent = manager.create(Parents, {
|
||||||
user_id: parent1Enregistre.id,
|
user_id: parent1Enregistre.id,
|
||||||
|
numero_dossier: numeroDossier,
|
||||||
});
|
});
|
||||||
entiteParent.user = parent1Enregistre;
|
entiteParent.user = parent1Enregistre;
|
||||||
if (parent2Enregistre) {
|
if (parent2Enregistre) {
|
||||||
@@ -247,6 +271,7 @@ export class AuthService {
|
|||||||
if (parent2Enregistre) {
|
if (parent2Enregistre) {
|
||||||
const entiteCoParent = manager.create(Parents, {
|
const entiteCoParent = manager.create(Parents, {
|
||||||
user_id: parent2Enregistre.id,
|
user_id: parent2Enregistre.id,
|
||||||
|
numero_dossier: numeroDossier,
|
||||||
});
|
});
|
||||||
entiteCoParent.user = parent2Enregistre;
|
entiteCoParent.user = parent2Enregistre;
|
||||||
entiteCoParent.co_parent = parent1Enregistre;
|
entiteCoParent.co_parent = parent1Enregistre;
|
||||||
@@ -325,11 +350,44 @@ 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);
|
const existe = await this.usersService.findByEmailOrNull(dto.email);
|
||||||
if (existe) {
|
if (existe) {
|
||||||
throw new ConflictException('Un compte avec cet email existe déjà');
|
throw new ConflictException('Un compte avec cet email existe déjà');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nirDejaUtilise = await this.assistantesMaternellesRepo.findOne({
|
||||||
|
where: { nir: nirNormalized },
|
||||||
|
});
|
||||||
|
if (nirDejaUtilise) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'Un compte assistante maternelle avec ce numéro NIR existe déjà.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const numeroAgrement = (dto.numero_agrement || '').trim();
|
||||||
|
if (numeroAgrement) {
|
||||||
|
const agrementDejaUtilise = await this.assistantesMaternellesRepo.findOne({
|
||||||
|
where: { approval_number: numeroAgrement },
|
||||||
|
});
|
||||||
|
if (agrementDejaUtilise) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'Un compte assistante maternelle avec ce numéro d\'agrément existe déjà.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const joursExpirationToken = await this.appConfigService.get<number>(
|
const joursExpirationToken = await this.appConfigService.get<number>(
|
||||||
'password_reset_token_expiry_days',
|
'password_reset_token_expiry_days',
|
||||||
7,
|
7,
|
||||||
@@ -347,6 +405,8 @@ export class AuthService {
|
|||||||
dto.consentement_photo ? new Date() : undefined;
|
dto.consentement_photo ? new Date() : undefined;
|
||||||
|
|
||||||
const resultat = await this.usersRepo.manager.transaction(async (manager) => {
|
const resultat = await this.usersRepo.manager.transaction(async (manager) => {
|
||||||
|
const { numero: numeroDossier } = await this.numeroDossierService.getNextNumeroDossier(manager);
|
||||||
|
|
||||||
const user = manager.create(Users, {
|
const user = manager.create(Users, {
|
||||||
email: dto.email,
|
email: dto.email,
|
||||||
prenom: dto.prenom,
|
prenom: dto.prenom,
|
||||||
@@ -363,6 +423,7 @@ export class AuthService {
|
|||||||
consentement_photo: dto.consentement_photo,
|
consentement_photo: dto.consentement_photo,
|
||||||
date_consentement_photo: dateConsentementPhoto,
|
date_consentement_photo: dateConsentementPhoto,
|
||||||
date_naissance: dto.date_naissance ? new Date(dto.date_naissance) : undefined,
|
date_naissance: dto.date_naissance ? new Date(dto.date_naissance) : undefined,
|
||||||
|
numero_dossier: numeroDossier,
|
||||||
});
|
});
|
||||||
const userEnregistre = await manager.save(Users, user);
|
const userEnregistre = await manager.save(Users, user);
|
||||||
|
|
||||||
@@ -370,12 +431,13 @@ export class AuthService {
|
|||||||
const am = amRepo.create({
|
const am = amRepo.create({
|
||||||
user_id: userEnregistre.id,
|
user_id: userEnregistre.id,
|
||||||
approval_number: dto.numero_agrement,
|
approval_number: dto.numero_agrement,
|
||||||
nir: dto.nir,
|
nir: nirNormalized,
|
||||||
max_children: dto.capacite_accueil,
|
max_children: dto.capacite_accueil,
|
||||||
biography: dto.biographie,
|
biography: dto.biographie,
|
||||||
residence_city: dto.ville ?? undefined,
|
residence_city: dto.ville ?? undefined,
|
||||||
agreement_date: dto.date_agrement ? new Date(dto.date_agrement) : undefined,
|
agreement_date: dto.date_agrement ? new Date(dto.date_agrement) : undefined,
|
||||||
available: true,
|
available: true,
|
||||||
|
numero_dossier: numeroDossier,
|
||||||
});
|
});
|
||||||
await amRepo.save(am);
|
await amRepo.save(am);
|
||||||
|
|
||||||
@@ -470,4 +532,47 @@ export class AuthService {
|
|||||||
async logout(userId: string) {
|
async logout(userId: string) {
|
||||||
return { success: true, message: 'Deconnexion'}
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,10 +103,12 @@ export class RegisterAMCompletDto {
|
|||||||
@MaxLength(100)
|
@MaxLength(100)
|
||||||
lieu_naissance_pays?: string;
|
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()
|
@IsString()
|
||||||
@IsNotEmpty({ message: 'Le NIR est requis' })
|
@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;
|
nir: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'AGR-2024-12345', description: "Numéro d'agrément" })
|
@ApiProperty({ example: 'AGR-2024-12345', description: "Numéro d'agrément" })
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { Roles } from 'src/common/decorators/roles.decorator';
|
||||||
|
import { RoleType } from 'src/entities/users.entity';
|
||||||
|
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||||
|
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||||
|
import { DossiersService } from './dossiers.service';
|
||||||
|
import { DossierUnifieDto } from './dto/dossier-unifie.dto';
|
||||||
|
|
||||||
|
@ApiTags('Dossiers')
|
||||||
|
@Controller('dossiers')
|
||||||
|
@UseGuards(AuthGuard, RolesGuard)
|
||||||
|
export class DossiersController {
|
||||||
|
constructor(private readonly dossiersService: DossiersService) {}
|
||||||
|
|
||||||
|
@Get(':numeroDossier')
|
||||||
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||||
|
@ApiOperation({ summary: 'Dossier complet par numéro (AM ou famille) – Ticket #119' })
|
||||||
|
@ApiParam({ name: 'numeroDossier', description: 'Numéro de dossier (ex: 2026-000001)' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Dossier famille ou AM', type: DossierUnifieDto })
|
||||||
|
@ApiResponse({ status: 404, description: 'Aucun dossier pour ce numéro' })
|
||||||
|
@ApiResponse({ status: 403, description: 'Accès refusé' })
|
||||||
|
getDossier(@Param('numeroDossier') numeroDossier: string): Promise<DossierUnifieDto> {
|
||||||
|
return this.dossiersService.getDossierByNumero(numeroDossier);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,28 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { Parents } from 'src/entities/parents.entity';
|
||||||
|
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||||
|
import { ParentsModule } from '../parents/parents.module';
|
||||||
|
import { DossiersController } from './dossiers.controller';
|
||||||
|
import { DossiersService } from './dossiers.service';
|
||||||
|
|
||||||
@Module({})
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([Parents, AssistanteMaternelle]),
|
||||||
|
ParentsModule,
|
||||||
|
JwtModule.registerAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
useFactory: (config: ConfigService) => ({
|
||||||
|
secret: config.get('jwt.accessSecret'),
|
||||||
|
signOptions: { expiresIn: config.get('jwt.accessExpiresIn') },
|
||||||
|
}),
|
||||||
|
inject: [ConfigService],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
controllers: [DossiersController],
|
||||||
|
providers: [DossiersService],
|
||||||
|
exports: [DossiersService],
|
||||||
|
})
|
||||||
export class DossiersModule {}
|
export class DossiersModule {}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { Parents } from 'src/entities/parents.entity';
|
||||||
|
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||||
|
import { ParentsService } from '../parents/parents.service';
|
||||||
|
import { DossierUnifieDto } from './dto/dossier-unifie.dto';
|
||||||
|
import { DossierAmCompletDto, DossierAmUserDto } from './dto/dossier-am-complet.dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Endpoint unifié GET /dossiers/:numeroDossier – AM ou famille. Ticket #119.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class DossiersService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Parents)
|
||||||
|
private readonly parentsRepository: Repository<Parents>,
|
||||||
|
@InjectRepository(AssistanteMaternelle)
|
||||||
|
private readonly amRepository: Repository<AssistanteMaternelle>,
|
||||||
|
private readonly parentsService: ParentsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getDossierByNumero(numeroDossier: string): Promise<DossierUnifieDto> {
|
||||||
|
const num = numeroDossier?.trim();
|
||||||
|
if (!num) {
|
||||||
|
throw new NotFoundException('Numéro de dossier requis.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) Famille : un parent a ce numéro ?
|
||||||
|
const parentWithNum = await this.parentsRepository.findOne({
|
||||||
|
where: { numero_dossier: num },
|
||||||
|
select: ['user_id'],
|
||||||
|
});
|
||||||
|
if (parentWithNum) {
|
||||||
|
const dossier = await this.parentsService.getDossierFamilleByNumero(num);
|
||||||
|
return { type: 'family', dossier };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) AM : une assistante maternelle a ce numéro ?
|
||||||
|
const am = await this.amRepository.findOne({
|
||||||
|
where: { numero_dossier: num },
|
||||||
|
relations: ['user'],
|
||||||
|
});
|
||||||
|
if (am?.user) {
|
||||||
|
const dossier: DossierAmCompletDto = {
|
||||||
|
numero_dossier: num,
|
||||||
|
user: this.toDossierAmUserDto(am.user),
|
||||||
|
numero_agrement: am.approval_number,
|
||||||
|
nir: am.nir,
|
||||||
|
biographie: am.biography,
|
||||||
|
disponible: am.available,
|
||||||
|
ville_residence: am.residence_city,
|
||||||
|
date_agrement: am.agreement_date,
|
||||||
|
annees_experience: am.years_experience,
|
||||||
|
specialite: am.specialty,
|
||||||
|
nb_max_enfants: am.max_children,
|
||||||
|
place_disponible: am.places_available,
|
||||||
|
};
|
||||||
|
return { type: 'am', dossier };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new NotFoundException('Aucun dossier trouvé pour ce numéro.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDossierAmUserDto(user: { id: string; email: string; prenom?: string; nom?: string; telephone?: string; adresse?: string; ville?: string; code_postal?: string; profession?: string; date_naissance?: Date; photo_url?: string; statut: any }): DossierAmUserDto {
|
||||||
|
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,
|
||||||
|
profession: user.profession,
|
||||||
|
date_naissance: user.date_naissance,
|
||||||
|
photo_url: user.photo_url,
|
||||||
|
statut: user.statut,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { StatutUtilisateurType } from 'src/entities/users.entity';
|
||||||
|
|
||||||
|
/** Utilisateur AM sans données sensibles (pour dossier AM complet). Ticket #119 */
|
||||||
|
export class DossierAmUserDto {
|
||||||
|
@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 })
|
||||||
|
profession?: string;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
date_naissance?: Date;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
photo_url?: string;
|
||||||
|
@ApiProperty({ enum: StatutUtilisateurType })
|
||||||
|
statut: StatutUtilisateurType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dossier AM complet (fiche AM sans secrets). Ticket #119 */
|
||||||
|
export class DossierAmCompletDto {
|
||||||
|
@ApiProperty({ example: '2026-000003', description: 'Numéro de dossier AM' })
|
||||||
|
numero_dossier: string;
|
||||||
|
@ApiProperty({ type: DossierAmUserDto, description: 'Utilisateur (sans mot de passe ni tokens)' })
|
||||||
|
user: DossierAmUserDto;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
numero_agrement?: string;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
nir?: string;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
biographie?: string;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
disponible?: boolean;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
ville_residence?: string;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
date_agrement?: Date;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
annees_experience?: number;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
specialite?: string;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
nb_max_enfants?: number;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
place_disponible?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { DossierFamilleCompletDto } from '../../parents/dto/dossier-famille-complet.dto';
|
||||||
|
import { DossierAmCompletDto } from './dossier-am-complet.dto';
|
||||||
|
|
||||||
|
/** Réponse unifiée GET /dossiers/:numeroDossier – AM ou famille. Ticket #119 */
|
||||||
|
export class DossierUnifieDto {
|
||||||
|
@ApiProperty({ enum: ['family', 'am'], description: 'Type de dossier' })
|
||||||
|
type: 'family' | 'am';
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Dossier famille (si type=family) ou dossier AM (si type=am)',
|
||||||
|
})
|
||||||
|
dossier: DossierFamilleCompletDto | DossierAmCompletDto;
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { StatutUtilisateurType } from 'src/entities/users.entity';
|
||||||
|
import { StatutEnfantType, GenreType } from 'src/entities/children.entity';
|
||||||
|
|
||||||
|
/** Parent dans le dossier famille (infos utilisateur + parent) */
|
||||||
|
export class DossierFamilleParentDto {
|
||||||
|
@ApiProperty()
|
||||||
|
user_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({ enum: StatutUtilisateurType })
|
||||||
|
statut: StatutUtilisateurType;
|
||||||
|
@ApiProperty({ required: false, description: 'Id du co-parent si couple' })
|
||||||
|
co_parent_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enfant dans le dossier famille */
|
||||||
|
export class DossierFamilleEnfantDto {
|
||||||
|
@ApiProperty()
|
||||||
|
id: string;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
first_name?: string;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
last_name?: string;
|
||||||
|
@ApiProperty({ required: false, enum: GenreType })
|
||||||
|
genre?: GenreType;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
birth_date?: Date;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
due_date?: Date;
|
||||||
|
@ApiProperty({ enum: StatutEnfantType })
|
||||||
|
status: StatutEnfantType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Réponse GET /parents/dossier-famille/:numeroDossier – dossier famille complet. Ticket #119 */
|
||||||
|
export class DossierFamilleCompletDto {
|
||||||
|
@ApiProperty({ example: '2026-000001', description: 'Numéro de dossier famille' })
|
||||||
|
numero_dossier: string;
|
||||||
|
@ApiProperty({ type: [DossierFamilleParentDto] })
|
||||||
|
parents: DossierFamilleParentDto[];
|
||||||
|
@ApiProperty({ type: [DossierFamilleEnfantDto], description: 'Enfants de la famille' })
|
||||||
|
enfants: DossierFamilleEnfantDto[];
|
||||||
|
@ApiProperty({ required: false, description: 'Texte de présentation / motivation (un seul par famille)' })
|
||||||
|
texte_motivation?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class ParentPendingSummaryDto {
|
||||||
|
@ApiProperty({ description: 'UUID utilisateur' })
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ nullable: true })
|
||||||
|
telephone?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ nullable: true })
|
||||||
|
code_postal?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ nullable: true })
|
||||||
|
ville?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
nullable: true,
|
||||||
|
example: '2026-01-12T10:00:00.000Z',
|
||||||
|
description: 'Date de référence dossier soumis / en attente : MIN(cree_le) des parents en_attente du groupe (ISO 8601)',
|
||||||
|
})
|
||||||
|
date_soumission: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: 3,
|
||||||
|
description: 'Nombre d’enfants distincts liés aux parents de la famille (enfants_parents)',
|
||||||
|
})
|
||||||
|
nombre_enfants: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
type: [String],
|
||||||
|
example: ['parent1@example.com', 'parent2@example.com'],
|
||||||
|
description: 'Emails des parents du groupe (ordre stable : nom, prénom)',
|
||||||
|
})
|
||||||
|
emails?: string[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
type: [ParentPendingSummaryDto],
|
||||||
|
description: 'Résumé des parents (ordre stable, aligné sur parentIds/emails)',
|
||||||
|
})
|
||||||
|
parents?: ParentPendingSummaryDto[];
|
||||||
|
}
|
||||||
@@ -1,26 +1,80 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
|
||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ParentsService } from './parents.service';
|
import { ParentsService } from './parents.service';
|
||||||
|
import { UserService } from '../user/user.service';
|
||||||
import { Parents } from 'src/entities/parents.entity';
|
import { Parents } from 'src/entities/parents.entity';
|
||||||
|
import { Users } from 'src/entities/users.entity';
|
||||||
import { Roles } from 'src/common/decorators/roles.decorator';
|
import { Roles } from 'src/common/decorators/roles.decorator';
|
||||||
import { RoleType } from 'src/entities/users.entity';
|
import { RoleType, StatutUtilisateurType } from 'src/entities/users.entity';
|
||||||
import { ApiBody, ApiResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import { CreateParentDto } from '../user/dto/create_parent.dto';
|
import { CreateParentDto } from '../user/dto/create_parent.dto';
|
||||||
import { UpdateParentsDto } from '../user/dto/update_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';
|
||||||
|
import { DossierFamilleCompletDto } from './dto/dossier-famille-complet.dto';
|
||||||
|
|
||||||
@ApiTags('Parents')
|
@ApiTags('Parents')
|
||||||
@Controller('parents')
|
@Controller('parents')
|
||||||
|
@UseGuards(AuthGuard, RolesGuard)
|
||||||
export class ParentsController {
|
export class ParentsController {
|
||||||
constructor(private readonly parentsService: ParentsService) {}
|
constructor(
|
||||||
|
private readonly parentsService: ParentsService,
|
||||||
|
private readonly userService: UserService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
|
@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, date_soumission, nombre_enfants, emails)', type: [PendingFamilyDto] })
|
||||||
|
@ApiResponse({ status: 403, description: 'Accès refusé' })
|
||||||
|
getPendingFamilies(): Promise<PendingFamilyDto[]> {
|
||||||
|
return this.parentsService.getPendingFamilies();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('dossier-famille/:numeroDossier')
|
||||||
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||||
|
@ApiOperation({ summary: 'Dossier famille complet par numéro de dossier (Ticket #119)' })
|
||||||
|
@ApiParam({ name: 'numeroDossier', description: 'Numéro de dossier (ex: 2026-000001)' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Dossier famille (numero_dossier, parents, enfants, presentation)', type: DossierFamilleCompletDto })
|
||||||
|
@ApiResponse({ status: 404, description: 'Aucun dossier pour ce numéro' })
|
||||||
|
@ApiResponse({ status: 403, description: 'Accès refusé' })
|
||||||
|
getDossierFamille(@Param('numeroDossier') numeroDossier: string): Promise<DossierFamilleCompletDto> {
|
||||||
|
return this.parentsService.getDossierFamilleByNumero(numeroDossier);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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()
|
@Get()
|
||||||
@ApiResponse({ status: 200, type: [Parents], description: 'Liste des parents' })
|
@ApiResponse({ status: 200, type: [Parents], description: 'Liste des parents' })
|
||||||
@ApiResponse({ status: 403, description: 'Accès refusé !' })
|
@ApiResponse({ status: 403, description: 'Accès refusé !' })
|
||||||
|
|||||||
@@ -1,12 +1,27 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
import { Parents } from 'src/entities/parents.entity';
|
import { Parents } from 'src/entities/parents.entity';
|
||||||
|
import { DossierFamille, DossierFamilleEnfant } from 'src/entities/dossier_famille.entity';
|
||||||
import { ParentsController } from './parents.controller';
|
import { ParentsController } from './parents.controller';
|
||||||
import { ParentsService } from './parents.service';
|
import { ParentsService } from './parents.service';
|
||||||
import { Users } from 'src/entities/users.entity';
|
import { Users } from 'src/entities/users.entity';
|
||||||
|
import { UserModule } from '../user/user.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Parents, Users])],
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([Parents, Users, DossierFamille, DossierFamilleEnfant]),
|
||||||
|
forwardRef(() => UserModule),
|
||||||
|
JwtModule.registerAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
useFactory: (config: ConfigService) => ({
|
||||||
|
secret: config.get('jwt.accessSecret'),
|
||||||
|
signOptions: { expiresIn: config.get('jwt.accessExpiresIn') },
|
||||||
|
}),
|
||||||
|
inject: [ConfigService],
|
||||||
|
}),
|
||||||
|
],
|
||||||
controllers: [ParentsController],
|
controllers: [ParentsController],
|
||||||
providers: [ParentsService],
|
providers: [ParentsService],
|
||||||
exports: [ParentsService,
|
exports: [ParentsService,
|
||||||
|
|||||||
@@ -5,11 +5,18 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { In, Repository } from 'typeorm';
|
||||||
import { Parents } from 'src/entities/parents.entity';
|
import { Parents } from 'src/entities/parents.entity';
|
||||||
|
import { DossierFamille } from 'src/entities/dossier_famille.entity';
|
||||||
import { RoleType, Users } from 'src/entities/users.entity';
|
import { RoleType, Users } from 'src/entities/users.entity';
|
||||||
import { CreateParentDto } from '../user/dto/create_parent.dto';
|
import { CreateParentDto } from '../user/dto/create_parent.dto';
|
||||||
import { UpdateParentsDto } from '../user/dto/update_parent.dto';
|
import { UpdateParentsDto } from '../user/dto/update_parent.dto';
|
||||||
|
import { PendingFamilyDto } from './dto/pending-family.dto';
|
||||||
|
import {
|
||||||
|
DossierFamilleCompletDto,
|
||||||
|
DossierFamilleParentDto,
|
||||||
|
DossierFamilleEnfantDto,
|
||||||
|
} from './dto/dossier-famille-complet.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ParentsService {
|
export class ParentsService {
|
||||||
@@ -18,6 +25,8 @@ export class ParentsService {
|
|||||||
private readonly parentsRepository: Repository<Parents>,
|
private readonly parentsRepository: Repository<Parents>,
|
||||||
@InjectRepository(Users)
|
@InjectRepository(Users)
|
||||||
private readonly usersRepository: Repository<Users>,
|
private readonly usersRepository: Repository<Users>,
|
||||||
|
@InjectRepository(DossierFamille)
|
||||||
|
private readonly dossierFamilleRepository: Repository<DossierFamille>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// Création d’un parent
|
// Création d’un parent
|
||||||
@@ -71,4 +80,263 @@ export class ParentsService {
|
|||||||
await this.parentsRepository.update(id, dto);
|
await this.parentsRepository.update(id, dto);
|
||||||
return this.findOne(id);
|
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[]> {
|
||||||
|
let raw: {
|
||||||
|
libelle: string;
|
||||||
|
parentIds: unknown;
|
||||||
|
numero_dossier: string | null;
|
||||||
|
date_soumission: Date | string | null;
|
||||||
|
nombre_enfants: string | number | null;
|
||||||
|
emails: unknown;
|
||||||
|
parents: unknown;
|
||||||
|
}[];
|
||||||
|
try {
|
||||||
|
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(p.id_utilisateur ORDER BY u.nom, u.prenom, u.id) AS "parentIds",
|
||||||
|
(array_agg(p.numero_dossier))[1] AS numero_dossier,
|
||||||
|
MIN(u.cree_le) AS date_soumission,
|
||||||
|
COALESCE((
|
||||||
|
SELECT COUNT(DISTINCT ep.id_enfant)::int
|
||||||
|
FROM enfants_parents ep
|
||||||
|
WHERE ep.id_parent IN (
|
||||||
|
SELECT frx.id FROM family_rep frx WHERE frx.rep = fr.rep
|
||||||
|
)
|
||||||
|
), 0) AS nombre_enfants,
|
||||||
|
array_agg(u.email ORDER BY u.nom, u.prenom, u.id) AS emails,
|
||||||
|
json_agg(
|
||||||
|
json_build_object(
|
||||||
|
'id', u.id::text,
|
||||||
|
'email', u.email,
|
||||||
|
'telephone', u.telephone,
|
||||||
|
'code_postal', u.code_postal,
|
||||||
|
'ville', u.ville
|
||||||
|
)
|
||||||
|
ORDER BY u.nom, u.prenom, u.id
|
||||||
|
) AS parents
|
||||||
|
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
|
||||||
|
`);
|
||||||
|
} catch (err) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
return raw.map((r) => ({
|
||||||
|
libelle: r.libelle ?? '',
|
||||||
|
parentIds: this.normalizeParentIds(r.parentIds),
|
||||||
|
numero_dossier: r.numero_dossier ?? null,
|
||||||
|
date_soumission: this.toIsoDateTimeOrNull(r.date_soumission),
|
||||||
|
nombre_enfants: this.normalizeNombreEnfants(r.nombre_enfants),
|
||||||
|
emails: this.normalizeEmails(r.emails),
|
||||||
|
parents: this.normalizeParents(r.parents),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private toIsoDateTimeOrNull(value: Date | string | null | undefined): string | null {
|
||||||
|
if (value == null) return null;
|
||||||
|
if (value instanceof Date) return value.toISOString();
|
||||||
|
const d = new Date(value);
|
||||||
|
return Number.isNaN(d.getTime()) ? null : d.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeNombreEnfants(v: string | number | null | undefined): number {
|
||||||
|
if (v == null) return 0;
|
||||||
|
const n = typeof v === 'number' ? v : parseInt(String(v), 10);
|
||||||
|
return Number.isFinite(n) && n >= 0 ? n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeEmails(emails: unknown): string[] {
|
||||||
|
if (Array.isArray(emails)) return emails.map(String);
|
||||||
|
if (typeof emails === 'string') {
|
||||||
|
const s = emails.replace(/^\{|\}$/g, '').trim();
|
||||||
|
return s ? s.split(',').map((x) => x.trim()) : [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeParents(parents: unknown): { id: string; email: string; telephone: string | null; code_postal: string | null; ville: string | null }[] {
|
||||||
|
if (Array.isArray(parents)) {
|
||||||
|
return parents.map((p: any) => ({
|
||||||
|
id: String(p?.id ?? ''),
|
||||||
|
email: String(p?.email ?? ''),
|
||||||
|
telephone: p?.telephone != null ? String(p.telephone) : null,
|
||||||
|
code_postal: p?.code_postal != null ? String(p.code_postal) : null,
|
||||||
|
ville: p?.ville != null ? String(p.ville) : null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (typeof parents === 'string') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(parents);
|
||||||
|
return this.normalizeParents(parsed);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convertit parentIds (array ou chaîne PG) en string[] pour éviter 500 si le driver renvoie une chaîne. */
|
||||||
|
private normalizeParentIds(parentIds: unknown): string[] {
|
||||||
|
if (Array.isArray(parentIds)) return parentIds.map(String);
|
||||||
|
if (typeof parentIds === 'string') {
|
||||||
|
const s = parentIds.replace(/^\{|\}$/g, '').trim();
|
||||||
|
return s ? s.split(',').map((x) => x.trim()) : [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dossier famille complet par numéro de dossier. Ticket #119.
|
||||||
|
* Rôles : admin, gestionnaire.
|
||||||
|
* @throws NotFoundException si aucun parent avec ce numéro de dossier
|
||||||
|
*/
|
||||||
|
async getDossierFamilleByNumero(numeroDossier: string): Promise<DossierFamilleCompletDto> {
|
||||||
|
const num = numeroDossier?.trim();
|
||||||
|
if (!num) {
|
||||||
|
throw new NotFoundException('Numéro de dossier requis.');
|
||||||
|
}
|
||||||
|
const firstParent = await this.parentsRepository.findOne({
|
||||||
|
where: { numero_dossier: num },
|
||||||
|
relations: ['user'],
|
||||||
|
});
|
||||||
|
if (!firstParent || !firstParent.user) {
|
||||||
|
throw new NotFoundException('Aucun dossier famille trouvé pour ce numéro.');
|
||||||
|
}
|
||||||
|
const familyUserIds = await this.getFamilyUserIds(firstParent.user_id);
|
||||||
|
const parents = await this.parentsRepository.find({
|
||||||
|
where: { user_id: In(familyUserIds) },
|
||||||
|
relations: ['user', 'co_parent', 'parentChildren', 'parentChildren.child', 'dossiers', 'dossiers.child'],
|
||||||
|
});
|
||||||
|
const enfantsMap = new Map<string, DossierFamilleEnfantDto>();
|
||||||
|
let texte_motivation: string | undefined;
|
||||||
|
|
||||||
|
// Un dossier = une famille, un seul texte de motivation
|
||||||
|
const dossierFamille = await this.dossierFamilleRepository.findOne({
|
||||||
|
where: { numero_dossier: num },
|
||||||
|
relations: ['parent', 'enfants', 'enfants.enfant'],
|
||||||
|
});
|
||||||
|
if (dossierFamille?.presentation) {
|
||||||
|
texte_motivation = dossierFamille.presentation;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const p of parents) {
|
||||||
|
// Enfants via parentChildren
|
||||||
|
if (p.parentChildren) {
|
||||||
|
for (const pc of p.parentChildren) {
|
||||||
|
if (pc.child && !enfantsMap.has(pc.child.id)) {
|
||||||
|
enfantsMap.set(pc.child.id, {
|
||||||
|
id: pc.child.id,
|
||||||
|
first_name: pc.child.first_name,
|
||||||
|
last_name: pc.child.last_name,
|
||||||
|
genre: pc.child.gender,
|
||||||
|
birth_date: pc.child.birth_date,
|
||||||
|
due_date: pc.child.due_date,
|
||||||
|
status: pc.child.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback : anciens dossiers (un texte, on prend le premier)
|
||||||
|
if (texte_motivation == null && p.dossiers?.length) {
|
||||||
|
texte_motivation = p.dossiers[0].presentation ?? undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentsDto: DossierFamilleParentDto[] = parents.map((p) => ({
|
||||||
|
user_id: p.user_id,
|
||||||
|
email: p.user.email,
|
||||||
|
prenom: p.user.prenom,
|
||||||
|
nom: p.user.nom,
|
||||||
|
telephone: p.user.telephone,
|
||||||
|
adresse: p.user.adresse,
|
||||||
|
ville: p.user.ville,
|
||||||
|
code_postal: p.user.code_postal,
|
||||||
|
statut: p.user.statut,
|
||||||
|
co_parent_id: p.co_parent?.id,
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
numero_dossier: num,
|
||||||
|
parents: parentsDto,
|
||||||
|
enfants: Array.from(enfantsMap.values()),
|
||||||
|
texte_motivation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsBoolean, IsNotEmpty, IsOptional, IsString, IsObject } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateRelaisDto {
|
||||||
|
@ApiProperty({ example: 'Relais Petite Enfance Centre' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
nom: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '12 rue de la Mairie, 75000 Paris' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
adresse: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: { lundi: '09:00-17:00' }, required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
horaires_ouverture?: any;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '0123456789', required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
ligne_fixe?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ default: true, required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
actif?: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'Notes internes...', required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/swagger';
|
||||||
|
import { CreateRelaisDto } from './create-relais.dto';
|
||||||
|
|
||||||
|
export class UpdateRelaisDto extends PartialType(CreateRelaisDto) {}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards } from '@nestjs/common';
|
||||||
|
import { RelaisService } from './relais.service';
|
||||||
|
import { CreateRelaisDto } from './dto/create-relais.dto';
|
||||||
|
import { UpdateRelaisDto } from './dto/update-relais.dto';
|
||||||
|
import { ApiBearerAuth, ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||||
|
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||||
|
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||||
|
import { Roles } from 'src/common/decorators/roles.decorator';
|
||||||
|
import { RoleType } from 'src/entities/users.entity';
|
||||||
|
|
||||||
|
@ApiTags('Relais')
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
|
@UseGuards(AuthGuard, RolesGuard)
|
||||||
|
@Controller('relais')
|
||||||
|
export class RelaisController {
|
||||||
|
constructor(private readonly relaisService: RelaisService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||||
|
@ApiOperation({ summary: 'Créer un relais' })
|
||||||
|
@ApiResponse({ status: 201, description: 'Le relais a été créé.' })
|
||||||
|
create(@Body() createRelaisDto: CreateRelaisDto) {
|
||||||
|
return this.relaisService.create(createRelaisDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||||
|
@ApiOperation({ summary: 'Lister tous les relais' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Liste des relais.' })
|
||||||
|
findAll() {
|
||||||
|
return this.relaisService.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||||
|
@ApiOperation({ summary: 'Récupérer un relais par ID' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Le relais trouvé.' })
|
||||||
|
findOne(@Param('id') id: string) {
|
||||||
|
return this.relaisService.findOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||||
|
@ApiOperation({ summary: 'Mettre à jour un relais' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Le relais a été mis à jour.' })
|
||||||
|
update(@Param('id') id: string, @Body() updateRelaisDto: UpdateRelaisDto) {
|
||||||
|
return this.relaisService.update(id, updateRelaisDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||||
|
@ApiOperation({ summary: 'Supprimer un relais' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Le relais a été supprimé.' })
|
||||||
|
remove(@Param('id') id: string) {
|
||||||
|
return this.relaisService.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { RelaisService } from './relais.service';
|
||||||
|
import { RelaisController } from './relais.controller';
|
||||||
|
import { Relais } from 'src/entities/relais.entity';
|
||||||
|
import { AuthModule } from 'src/routes/auth/auth.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([Relais]),
|
||||||
|
AuthModule,
|
||||||
|
],
|
||||||
|
controllers: [RelaisController],
|
||||||
|
providers: [RelaisService],
|
||||||
|
exports: [RelaisService],
|
||||||
|
})
|
||||||
|
export class RelaisModule {}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { Relais } from 'src/entities/relais.entity';
|
||||||
|
import { CreateRelaisDto } from './dto/create-relais.dto';
|
||||||
|
import { UpdateRelaisDto } from './dto/update-relais.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RelaisService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Relais)
|
||||||
|
private readonly relaisRepository: Repository<Relais>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
create(createRelaisDto: CreateRelaisDto) {
|
||||||
|
const relais = this.relaisRepository.create(createRelaisDto);
|
||||||
|
return this.relaisRepository.save(relais);
|
||||||
|
}
|
||||||
|
|
||||||
|
findAll() {
|
||||||
|
return this.relaisRepository.find({ order: { nom: 'ASC' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: string) {
|
||||||
|
const relais = await this.relaisRepository.findOne({ where: { id } });
|
||||||
|
if (!relais) {
|
||||||
|
throw new NotFoundException(`Relais #${id} not found`);
|
||||||
|
}
|
||||||
|
return relais;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, updateRelaisDto: UpdateRelaisDto) {
|
||||||
|
const relais = await this.findOne(id);
|
||||||
|
Object.assign(relais, updateRelaisDto);
|
||||||
|
return this.relaisRepository.save(relais);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string) {
|
||||||
|
const relais = await this.findOne(id);
|
||||||
|
return this.relaisRepository.remove(relais);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,4 +1,10 @@
|
|||||||
import { OmitType } from "@nestjs/swagger";
|
import { PickType } from "@nestjs/swagger";
|
||||||
import { CreateUserDto } from "./create_user.dto";
|
import { CreateUserDto } from "./create_user.dto";
|
||||||
|
|
||||||
export class CreateAdminDto extends OmitType(CreateUserDto, ['role'] as const) {}
|
export class CreateAdminDto extends PickType(CreateUserDto, [
|
||||||
|
'nom',
|
||||||
|
'prenom',
|
||||||
|
'email',
|
||||||
|
'password',
|
||||||
|
'telephone'
|
||||||
|
] as const) {}
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { OmitType } from "@nestjs/swagger";
|
import { ApiProperty, OmitType } from "@nestjs/swagger";
|
||||||
import { CreateUserDto } from "./create_user.dto";
|
import { CreateUserDto } from "./create_user.dto";
|
||||||
|
import { IsOptional, IsUUID } from "class-validator";
|
||||||
|
|
||||||
export class CreateGestionnaireDto extends OmitType(CreateUserDto, ['role'] as const) {}
|
export class CreateGestionnaireDto extends OmitType(CreateUserDto, ['role', 'adresse', 'genre', 'statut', 'situation_familiale', 'ville', 'code_postal', 'photo_url', 'consentement_photo', 'date_consentement_photo', 'changement_mdp_obligatoire'] as const) {
|
||||||
|
@ApiProperty({ required: false, description: 'ID du relais de rattachement' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
relaisId?: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,10 +36,10 @@ export class CreateUserDto {
|
|||||||
@MaxLength(100)
|
@MaxLength(100)
|
||||||
nom: string;
|
nom: string;
|
||||||
|
|
||||||
@ApiProperty({ enum: GenreType, required: false, default: GenreType.AUTRE })
|
@ApiProperty({ enum: GenreType, required: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(GenreType)
|
@IsEnum(GenreType)
|
||||||
genre?: GenreType = GenreType.AUTRE;
|
genre?: GenreType;
|
||||||
|
|
||||||
@ApiProperty({ enum: RoleType })
|
@ApiProperty({ enum: RoleType })
|
||||||
@IsEnum(RoleType)
|
@IsEnum(RoleType)
|
||||||
@@ -86,7 +86,7 @@ export class CreateUserDto {
|
|||||||
@ApiProperty({ default: false })
|
@ApiProperty({ default: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
consentement_photo?: boolean = false;
|
consentement_photo?: boolean;
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
@ApiProperty({ required: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -96,7 +96,7 @@ export class CreateUserDto {
|
|||||||
@ApiProperty({ default: false })
|
@ApiProperty({ default: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
changement_mdp_obligatoire?: boolean = false;
|
changement_mdp_obligatoire?: boolean;
|
||||||
|
|
||||||
@ApiProperty({ example: true })
|
@ApiProperty({ example: true })
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export class GestionnairesController {
|
|||||||
return this.gestionnairesService.create(dto);
|
return this.gestionnairesService.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||||
@ApiOperation({ summary: 'Liste des gestionnaires' })
|
@ApiOperation({ summary: 'Liste des gestionnaires' })
|
||||||
@ApiResponse({ status: 200, description: 'Liste des gestionnaires : ', type: [Users] })
|
@ApiResponse({ status: 200, description: 'Liste des gestionnaires : ', type: [Users] })
|
||||||
@Get()
|
@Get()
|
||||||
|
|||||||
@@ -3,9 +3,15 @@ import { GestionnairesService } from './gestionnaires.service';
|
|||||||
import { GestionnairesController } from './gestionnaires.controller';
|
import { GestionnairesController } from './gestionnaires.controller';
|
||||||
import { Users } from 'src/entities/users.entity';
|
import { Users } from 'src/entities/users.entity';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { AuthModule } from 'src/routes/auth/auth.module';
|
||||||
|
import { MailModule } from 'src/modules/mail/mail.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Users])],
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([Users]),
|
||||||
|
AuthModule,
|
||||||
|
MailModule,
|
||||||
|
],
|
||||||
controllers: [GestionnairesController],
|
controllers: [GestionnairesController],
|
||||||
providers: [GestionnairesService],
|
providers: [GestionnairesService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,16 +5,18 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { RoleType, Users } from 'src/entities/users.entity';
|
import { RoleType, StatutUtilisateurType, Users } from 'src/entities/users.entity';
|
||||||
import { CreateGestionnaireDto } from '../dto/create_gestionnaire.dto';
|
import { CreateGestionnaireDto } from '../dto/create_gestionnaire.dto';
|
||||||
import { UpdateGestionnaireDto } from '../dto/update_gestionnaire.dto';
|
import { UpdateGestionnaireDto } from '../dto/update_gestionnaire.dto';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { MailService } from 'src/modules/mail/mail.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GestionnairesService {
|
export class GestionnairesService {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Users)
|
@InjectRepository(Users)
|
||||||
private readonly gestionnaireRepository: Repository<Users>,
|
private readonly gestionnaireRepository: Repository<Users>,
|
||||||
|
private readonly mailService: MailService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
// Création d’un gestionnaire
|
// Création d’un gestionnaire
|
||||||
@@ -30,30 +32,51 @@ export class GestionnairesService {
|
|||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
prenom: dto.prenom,
|
prenom: dto.prenom,
|
||||||
nom: dto.nom,
|
nom: dto.nom,
|
||||||
genre: dto.genre,
|
// genre: dto.genre, // Retiré
|
||||||
statut: dto.statut,
|
// statut: dto.statut, // Retiré
|
||||||
|
statut: StatutUtilisateurType.ACTIF,
|
||||||
telephone: dto.telephone,
|
telephone: dto.telephone,
|
||||||
adresse: dto.adresse,
|
// adresse: dto.adresse, // Retiré
|
||||||
photo_url: dto.photo_url,
|
// photo_url: dto.photo_url, // Retiré
|
||||||
consentement_photo: dto.consentement_photo ?? false,
|
// consentement_photo: dto.consentement_photo ?? false, // Retiré
|
||||||
date_consentement_photo: dto.date_consentement_photo
|
// date_consentement_photo: dto.date_consentement_photo // Retiré
|
||||||
? new Date(dto.date_consentement_photo)
|
// ? new Date(dto.date_consentement_photo)
|
||||||
: undefined,
|
// : undefined,
|
||||||
changement_mdp_obligatoire: dto.changement_mdp_obligatoire ?? false,
|
changement_mdp_obligatoire: true,
|
||||||
role: RoleType.GESTIONNAIRE,
|
role: RoleType.GESTIONNAIRE,
|
||||||
|
relaisId: dto.relaisId,
|
||||||
});
|
});
|
||||||
return this.gestionnaireRepository.save(entity);
|
|
||||||
|
const savedUser = await this.gestionnaireRepository.save(entity);
|
||||||
|
|
||||||
|
// Envoi de l'email de bienvenue
|
||||||
|
try {
|
||||||
|
await this.mailService.sendGestionnaireWelcomeEmail(
|
||||||
|
savedUser.email,
|
||||||
|
savedUser.prenom || '',
|
||||||
|
savedUser.nom || '',
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// On ne bloque pas la création si l'envoi d'email échoue, mais on log l'erreur
|
||||||
|
console.error('Erreur lors de l\'envoi de l\'email de bienvenue au gestionnaire', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return savedUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Liste des gestionnaires
|
// Liste des gestionnaires
|
||||||
async findAll(): Promise<Users[]> {
|
async findAll(): Promise<Users[]> {
|
||||||
return this.gestionnaireRepository.find({ where: { role: RoleType.GESTIONNAIRE } });
|
return this.gestionnaireRepository.find({
|
||||||
|
where: { role: RoleType.GESTIONNAIRE },
|
||||||
|
relations: ['relais'],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Récupérer un gestionnaire par ID
|
// Récupérer un gestionnaire par ID
|
||||||
async findOne(id: string): Promise<Users> {
|
async findOne(id: string): Promise<Users> {
|
||||||
const gestionnaire = await this.gestionnaireRepository.findOne({
|
const gestionnaire = await this.gestionnaireRepository.findOne({
|
||||||
where: { id, role: RoleType.GESTIONNAIRE },
|
where: { id, role: RoleType.GESTIONNAIRE },
|
||||||
|
relations: ['relais'],
|
||||||
});
|
});
|
||||||
if (!gestionnaire) throw new NotFoundException('Gestionnaire introuvable');
|
if (!gestionnaire) throw new NotFoundException('Gestionnaire introuvable');
|
||||||
return gestionnaire;
|
return gestionnaire;
|
||||||
@@ -68,13 +91,7 @@ export class GestionnairesService {
|
|||||||
gestionnaire.password = await bcrypt.hash(dto.password, salt);
|
gestionnaire.password = await bcrypt.hash(dto.password, salt);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dto.date_consentement_photo !== undefined) {
|
const { password, ...rest } = dto;
|
||||||
gestionnaire.date_consentement_photo = dto.date_consentement_photo
|
|
||||||
? new Date(dto.date_consentement_photo)
|
|
||||||
: undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { password, date_consentement_photo, ...rest } = dto;
|
|
||||||
Object.entries(rest).forEach(([key, value]) => {
|
Object.entries(rest).forEach(([key, value]) => {
|
||||||
if (value !== undefined) {
|
if (value !== undefined) {
|
||||||
(gestionnaire as any)[key] = value;
|
(gestionnaire as any)[key] = value;
|
||||||
|
|||||||
@@ -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 { ApiBearerAuth, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import { AuthGuard } from 'src/common/guards/auth.guard';
|
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 { Roles } from 'src/common/decorators/roles.decorator';
|
||||||
import { User } from 'src/common/decorators/user.decorator';
|
import { User } from 'src/common/decorators/user.decorator';
|
||||||
import { RoleType, Users } from 'src/entities/users.entity';
|
import { RoleType, Users } from 'src/entities/users.entity';
|
||||||
import { UserService } from './user.service';
|
import { UserService } from './user.service';
|
||||||
import { CreateUserDto } from './dto/create_user.dto';
|
import { CreateUserDto } from './dto/create_user.dto';
|
||||||
|
import { CreateAdminDto } from './dto/create_admin.dto';
|
||||||
import { UpdateUserDto } from './dto/update_user.dto';
|
import { UpdateUserDto } from './dto/update_user.dto';
|
||||||
|
import { AffecterNumeroDossierDto } from './dto/affecter-numero-dossier.dto';
|
||||||
|
|
||||||
@ApiTags('Utilisateurs')
|
@ApiTags('Utilisateurs')
|
||||||
@ApiBearerAuth('access-token')
|
@ApiBearerAuth('access-token')
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard, RolesGuard)
|
||||||
@Controller('users')
|
@Controller('users')
|
||||||
export class UserController {
|
export class UserController {
|
||||||
constructor(private readonly userService: UserService) { }
|
constructor(private readonly userService: UserService) { }
|
||||||
|
|
||||||
|
// Création d'un administrateur (réservée aux super admins)
|
||||||
|
@Post('admin')
|
||||||
|
@Roles(RoleType.SUPER_ADMIN)
|
||||||
|
@ApiOperation({ summary: 'Créer un nouvel administrateur (super admin seulement)' })
|
||||||
|
createAdmin(
|
||||||
|
@Body() dto: CreateAdminDto,
|
||||||
|
@User() currentUser: Users
|
||||||
|
) {
|
||||||
|
return this.userService.createAdmin(dto, currentUser);
|
||||||
|
}
|
||||||
|
|
||||||
// Création d'un utilisateur (réservée aux super admins)
|
// Création d'un utilisateur (réservée aux super admins)
|
||||||
@Post()
|
@Post()
|
||||||
@Roles(RoleType.SUPER_ADMIN)
|
@Roles(RoleType.SUPER_ADMIN)
|
||||||
@@ -26,9 +40,29 @@ export class UserController {
|
|||||||
return this.userService.createUser(dto, currentUser);
|
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)
|
// Lister tous les utilisateurs (super_admin uniquement)
|
||||||
@Get()
|
@Get()
|
||||||
@Roles(RoleType.SUPER_ADMIN)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||||
@ApiOperation({ summary: 'Lister tous les utilisateurs' })
|
@ApiOperation({ summary: 'Lister tous les utilisateurs' })
|
||||||
findAll() {
|
findAll() {
|
||||||
return this.userService.findAll();
|
return this.userService.findAll();
|
||||||
@@ -43,9 +77,9 @@ export class UserController {
|
|||||||
return this.userService.findOne(id);
|
return this.userService.findOne(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Modifier un utilisateur (réservé super_admin)
|
// Modifier un utilisateur (réservé super_admin et admin)
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@Roles(RoleType.SUPER_ADMIN)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||||
@ApiOperation({ summary: 'Mettre à jour un utilisateur' })
|
@ApiOperation({ summary: 'Mettre à jour un utilisateur' })
|
||||||
@ApiParam({ name: 'id', description: "UUID de l'utilisateur" })
|
@ApiParam({ name: 'id', description: "UUID de l'utilisateur" })
|
||||||
updateUser(
|
updateUser(
|
||||||
@@ -56,6 +90,23 @@ export class UserController {
|
|||||||
return this.userService.updateUser(id, dto, currentUser);
|
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 d’attribuer 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')
|
@Patch(':id/valider')
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||||
@ApiOperation({ summary: 'Valider un compte utilisateur' })
|
@ApiOperation({ summary: 'Valider un compte utilisateur' })
|
||||||
@@ -71,6 +122,18 @@ export class UserController {
|
|||||||
return this.userService.validateUser(id, currentUser, comment);
|
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')
|
@Patch(':id/suspendre')
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||||
@ApiOperation({ summary: 'Suspendre un compte utilisateur' })
|
@ApiOperation({ summary: 'Suspendre un compte utilisateur' })
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { ParentsModule } from '../parents/parents.module';
|
|||||||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||||
import { AssistantesMaternellesModule } from '../assistantes_maternelles/assistantes_maternelles.module';
|
import { AssistantesMaternellesModule } from '../assistantes_maternelles/assistantes_maternelles.module';
|
||||||
import { Parents } from 'src/entities/parents.entity';
|
import { Parents } from 'src/entities/parents.entity';
|
||||||
|
import { GestionnairesModule } from './gestionnaires/gestionnaires.module';
|
||||||
|
import { MailModule } from 'src/modules/mail/mail.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature(
|
imports: [TypeOrmModule.forFeature(
|
||||||
@@ -20,6 +22,8 @@ import { Parents } from 'src/entities/parents.entity';
|
|||||||
]), forwardRef(() => AuthModule),
|
]), forwardRef(() => AuthModule),
|
||||||
ParentsModule,
|
ParentsModule,
|
||||||
AssistantesMaternellesModule,
|
AssistantesMaternellesModule,
|
||||||
|
GestionnairesModule,
|
||||||
|
MailModule,
|
||||||
],
|
],
|
||||||
controllers: [UserController],
|
controllers: [UserController],
|
||||||
providers: [UserService],
|
providers: [UserService],
|
||||||
|
|||||||
@@ -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 { InjectRepository } from "@nestjs/typeorm";
|
||||||
import { RoleType, StatutUtilisateurType, Users } from "src/entities/users.entity";
|
import { RoleType, StatutUtilisateurType, Users } from "src/entities/users.entity";
|
||||||
import { In, Repository } from "typeorm";
|
import { In, MoreThan, Repository } from "typeorm";
|
||||||
import { CreateUserDto } from "./dto/create_user.dto";
|
import { CreateUserDto } from "./dto/create_user.dto";
|
||||||
|
import { CreateAdminDto } from "./dto/create_admin.dto";
|
||||||
import { UpdateUserDto } from "./dto/update_user.dto";
|
import { UpdateUserDto } from "./dto/update_user.dto";
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import { StatutValidationType, Validation } from "src/entities/validations.entity";
|
import { StatutValidationType, Validation } from "src/entities/validations.entity";
|
||||||
import { Parents } from "src/entities/parents.entity";
|
import { Parents } from "src/entities/parents.entity";
|
||||||
import { AssistanteMaternelle } from "src/entities/assistantes_maternelles.entity";
|
import { AssistanteMaternelle } from "src/entities/assistantes_maternelles.entity";
|
||||||
|
import { MailService } from "src/modules/mail/mail.service";
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserService {
|
export class UserService {
|
||||||
|
private readonly logger = new Logger(UserService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Users)
|
@InjectRepository(Users)
|
||||||
private readonly usersRepository: Repository<Users>,
|
private readonly usersRepository: Repository<Users>,
|
||||||
@@ -22,7 +27,9 @@ export class UserService {
|
|||||||
private readonly parentsRepository: Repository<Parents>,
|
private readonly parentsRepository: Repository<Parents>,
|
||||||
|
|
||||||
@InjectRepository(AssistanteMaternelle)
|
@InjectRepository(AssistanteMaternelle)
|
||||||
private readonly assistantesRepository: Repository<AssistanteMaternelle>
|
private readonly assistantesRepository: Repository<AssistanteMaternelle>,
|
||||||
|
|
||||||
|
private readonly mailService: MailService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
async createUser(dto: CreateUserDto, currentUser?: Users): Promise<Users> {
|
async createUser(dto: CreateUserDto, currentUser?: Users): Promise<Users> {
|
||||||
@@ -106,6 +113,48 @@ export class UserService {
|
|||||||
return this.findOne(saved.id);
|
return this.findOne(saved.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createAdmin(dto: CreateAdminDto, currentUser: Users): Promise<Users> {
|
||||||
|
if (currentUser.role !== RoleType.SUPER_ADMIN) {
|
||||||
|
throw new ForbiddenException('Seuls les super administrateurs peuvent créer un administrateur');
|
||||||
|
}
|
||||||
|
|
||||||
|
const exist = await this.usersRepository.findOneBy({ email: dto.email });
|
||||||
|
if (exist) throw new BadRequestException('Email déjà utilisé');
|
||||||
|
|
||||||
|
const salt = await bcrypt.genSalt();
|
||||||
|
const hashedPassword = await bcrypt.hash(dto.password, salt);
|
||||||
|
|
||||||
|
const entity = this.usersRepository.create({
|
||||||
|
email: dto.email,
|
||||||
|
password: hashedPassword,
|
||||||
|
prenom: dto.prenom,
|
||||||
|
nom: dto.nom,
|
||||||
|
role: RoleType.ADMINISTRATEUR,
|
||||||
|
statut: StatutUtilisateurType.ACTIF,
|
||||||
|
telephone: dto.telephone,
|
||||||
|
changement_mdp_obligatoire: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.usersRepository.save(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
async 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[]> {
|
async findAll(): Promise<Users[]> {
|
||||||
return this.usersRepository.find();
|
return this.usersRepository.find();
|
||||||
}
|
}
|
||||||
@@ -129,11 +178,26 @@ export class UserService {
|
|||||||
async updateUser(id: string, dto: UpdateUserDto, currentUser: Users): Promise<Users> {
|
async updateUser(id: string, dto: UpdateUserDto, currentUser: Users): Promise<Users> {
|
||||||
const user = await this.findOne(id);
|
const user = await this.findOne(id);
|
||||||
|
|
||||||
|
// Le super administrateur conserve une identité figée.
|
||||||
|
if (
|
||||||
|
user.role === RoleType.SUPER_ADMIN &&
|
||||||
|
(dto.nom !== undefined || dto.prenom !== undefined)
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Le nom et le prénom du super administrateur ne peuvent pas être modifiés',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Interdire changement de rôle si pas super admin
|
// Interdire changement de rôle si pas super admin
|
||||||
if (dto.role && currentUser.role !== RoleType.SUPER_ADMIN) {
|
if (dto.role && currentUser.role !== RoleType.SUPER_ADMIN) {
|
||||||
throw new ForbiddenException('Accès réservé aux super admins');
|
throw new ForbiddenException('Accès réservé aux super admins');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Un admin ne peut pas modifier un super admin
|
||||||
|
if (currentUser.role === RoleType.ADMINISTRATEUR && user.role === RoleType.SUPER_ADMIN) {
|
||||||
|
throw new ForbiddenException('Vous ne pouvez pas modifier un super administrateur');
|
||||||
|
}
|
||||||
|
|
||||||
// Empêcher de modifier le flag changement_mdp_obligatoire pour admin/gestionnaire
|
// Empêcher de modifier le flag changement_mdp_obligatoire pour admin/gestionnaire
|
||||||
if (
|
if (
|
||||||
(user.role === RoleType.ADMINISTRATEUR || user.role === RoleType.GESTIONNAIRE) &&
|
(user.role === RoleType.ADMINISTRATEUR || user.role === RoleType.GESTIONNAIRE) &&
|
||||||
@@ -165,7 +229,7 @@ export class UserService {
|
|||||||
return this.usersRepository.save(user);
|
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> {
|
async validateUser(user_id: string, currentUser: Users, comment?: string): Promise<Users> {
|
||||||
if (![RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE].includes(currentUser.role)) {
|
if (![RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE].includes(currentUser.role)) {
|
||||||
throw new ForbiddenException('Accès réservé aux super admins, administrateurs et gestionnaires');
|
throw new ForbiddenException('Accès réservé aux super admins, administrateurs et gestionnaires');
|
||||||
@@ -174,6 +238,10 @@ export class UserService {
|
|||||||
const user = await this.usersRepository.findOne({ where: { id: user_id } });
|
const user = await this.usersRepository.findOne({ where: { id: user_id } });
|
||||||
if (!user) throw new NotFoundException('Utilisateur introuvable');
|
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;
|
user.statut = StatutUtilisateurType.ACTIF;
|
||||||
const savedUser = await this.usersRepository.save(user);
|
const savedUser = await this.usersRepository.save(user);
|
||||||
if (user.role === RoleType.PARENT) {
|
if (user.role === RoleType.PARENT) {
|
||||||
@@ -221,10 +289,165 @@ export class UserService {
|
|||||||
await this.validationRepository.save(suspend);
|
await this.validationRepository.save(suspend);
|
||||||
return savedUser;
|
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> {
|
async remove(id: string, currentUser: Users): Promise<void> {
|
||||||
if (currentUser.role !== RoleType.SUPER_ADMIN) {
|
if (currentUser.role !== RoleType.SUPER_ADMIN) {
|
||||||
throw new ForbiddenException('Accès réservé aux super admins');
|
throw new ForbiddenException('Accès réservé aux super admins');
|
||||||
}
|
}
|
||||||
|
const user = await this.findOne(id);
|
||||||
|
if (user.role === RoleType.SUPER_ADMIN) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Le super administrateur ne peut pas être supprimé',
|
||||||
|
);
|
||||||
|
}
|
||||||
const result = await this.usersRepository.delete(id);
|
const result = await this.usersRepository.delete(id);
|
||||||
if (result.affected === 0) {
|
if (result.affected === 0) {
|
||||||
throw new NotFoundException('Utilisateur introuvable');
|
throw new NotFoundException('Utilisateur introuvable');
|
||||||
|
|||||||
@@ -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));
|
||||||
+41
-4
@@ -11,7 +11,7 @@ DO $$ BEGIN
|
|||||||
CREATE TYPE genre_type AS ENUM ('H', 'F');
|
CREATE TYPE genre_type AS ENUM ('H', 'F');
|
||||||
END IF;
|
END IF;
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_utilisateur_type') THEN
|
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;
|
END IF;
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_enfant_type') THEN
|
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');
|
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 (
|
CREATE TABLE assistantes_maternelles (
|
||||||
id_utilisateur UUID PRIMARY KEY REFERENCES utilisateurs(id) ON DELETE CASCADE,
|
id_utilisateur UUID PRIMARY KEY REFERENCES utilisateurs(id) ON DELETE CASCADE,
|
||||||
numero_agrement VARCHAR(50),
|
numero_agrement VARCHAR(50),
|
||||||
nir_chiffre CHAR(15),
|
nir_chiffre CHAR(15) NOT NULL,
|
||||||
nb_max_enfants INT,
|
nb_max_enfants INT,
|
||||||
biographie TEXT,
|
biographie TEXT,
|
||||||
disponible BOOLEAN DEFAULT true,
|
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);
|
CREATE INDEX idx_acceptations_document ON acceptations_documents(id_document);
|
||||||
|
|
||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
-- Modification Table : utilisateurs (ajout colonnes documents)
|
-- Table : relais
|
||||||
|
-- ==========================================================
|
||||||
|
CREATE TABLE relais (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
nom VARCHAR(255) NOT NULL,
|
||||||
|
adresse TEXT NOT NULL,
|
||||||
|
horaires_ouverture JSONB,
|
||||||
|
ligne_fixe VARCHAR(20),
|
||||||
|
actif BOOLEAN DEFAULT true,
|
||||||
|
notes TEXT,
|
||||||
|
cree_le TIMESTAMPTZ DEFAULT now(),
|
||||||
|
modifie_le TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ==========================================================
|
||||||
|
-- Modification Table : utilisateurs (ajout colonnes documents et relais)
|
||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
ALTER TABLE utilisateurs
|
ALTER TABLE utilisateurs
|
||||||
ADD COLUMN IF NOT EXISTS cgu_version_acceptee INTEGER,
|
ADD COLUMN IF NOT EXISTS cgu_version_acceptee INTEGER,
|
||||||
ADD COLUMN IF NOT EXISTS cgu_acceptee_le TIMESTAMPTZ,
|
ADD COLUMN IF NOT EXISTS cgu_acceptee_le TIMESTAMPTZ,
|
||||||
ADD COLUMN IF NOT EXISTS privacy_version_acceptee INTEGER,
|
ADD COLUMN IF NOT EXISTS privacy_version_acceptee INTEGER,
|
||||||
ADD COLUMN IF NOT EXISTS privacy_acceptee_le TIMESTAMPTZ;
|
ADD COLUMN IF NOT EXISTS privacy_acceptee_le TIMESTAMPTZ,
|
||||||
|
ADD COLUMN IF NOT EXISTS relais_id UUID REFERENCES relais(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- ==========================================================
|
||||||
|
-- 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
|
-- Seed : Documents légaux génériques v1
|
||||||
|
|||||||
@@ -41,6 +41,16 @@ docker compose -f docker-compose.dev.yml down -v
|
|||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Réinitialiser la BDD et charger les données de test (dashboard admin)
|
||||||
|
|
||||||
|
Depuis la **racine du projet** (ptitspas-app, où se trouve `docker-compose.yml`) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/reset-and-seed-db.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Ce script : arrête les conteneurs, supprime le volume Postgres, redémarre la base (le schéma est recréé via `BDD.sql`), puis exécute `database/seed/03_seed_test_data.sql`. Tu obtiens un super_admin (`admin@ptits-pas.fr`) plus 9 comptes de test (1 admin, 1 gestionnaire, 2 AM, 5 parents) avec **mot de passe : `password`**. Idéal pour développer le ticket #92 (dashboard admin).
|
||||||
|
|
||||||
## Importation automatique des données de test
|
## Importation automatique des données de test
|
||||||
|
|
||||||
Les données de test (CSV) sont automatiquement importées dans la base au démarrage du conteneur Docker grâce aux scripts présents dans le dossier `migrations/`.
|
Les données de test (CSV) sont automatiquement importées dans la base au démarrage du conteneur Docker grâce aux scripts présents dans le dossier `migrations/`.
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-- Un dossier = une famille, N enfants. Ticket #119 évolution.
|
||||||
|
-- Table: un enregistrement par famille (lien via numero_dossier / id_parent).
|
||||||
|
CREATE TABLE IF NOT EXISTS dossier_famille (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
numero_dossier VARCHAR(20) NOT NULL,
|
||||||
|
id_parent UUID NOT NULL REFERENCES parents(id_utilisateur) ON DELETE CASCADE,
|
||||||
|
presentation TEXT,
|
||||||
|
type_contrat VARCHAR(50),
|
||||||
|
repas BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
budget NUMERIC(10,2),
|
||||||
|
planning_souhaite JSONB,
|
||||||
|
statut statut_dossier_type NOT NULL DEFAULT 'envoye',
|
||||||
|
cree_le TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
modifie_le TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dossier_famille_numero ON dossier_famille(numero_dossier);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dossier_famille_id_parent ON dossier_famille(id_parent);
|
||||||
|
|
||||||
|
-- Enfants concernés par ce dossier famille (N par dossier).
|
||||||
|
CREATE TABLE IF NOT EXISTS dossier_famille_enfants (
|
||||||
|
id_dossier_famille UUID NOT NULL REFERENCES dossier_famille(id) ON DELETE CASCADE,
|
||||||
|
id_enfant UUID NOT NULL REFERENCES enfants(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (id_dossier_famille, id_enfant)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dossier_famille_enfants_enfant ON dossier_famille_enfants(id_enfant);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Dossier famille = inscription uniquement, pas les données de dossier de garde (repas, type_contrat, budget, etc.)
|
||||||
|
ALTER TABLE dossier_famille DROP COLUMN IF EXISTS repas;
|
||||||
|
ALTER TABLE dossier_famille DROP COLUMN IF EXISTS type_contrat;
|
||||||
|
ALTER TABLE dossier_famille DROP COLUMN IF EXISTS budget;
|
||||||
|
ALTER TABLE dossier_famille DROP COLUMN IF EXISTS planning_souhaite;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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 $$;
|
||||||
@@ -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';
|
||||||
@@ -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;
|
||||||
@@ -58,9 +58,9 @@ INSERT INTO parents (id_utilisateur, id_co_parent)
|
|||||||
VALUES ('55555555-5555-5555-5555-555555555555', NULL) -- parent2 sans co-parent
|
VALUES ('55555555-5555-5555-5555-555555555555', NULL) -- parent2 sans co-parent
|
||||||
ON CONFLICT (id_utilisateur) DO NOTHING;
|
ON CONFLICT (id_utilisateur) DO NOTHING;
|
||||||
|
|
||||||
-- assistantes_maternelles
|
-- assistantes_maternelles (nir_chiffre NOT NULL depuis ticket #102)
|
||||||
INSERT INTO assistantes_maternelles (id_utilisateur, numero_agrement, nb_max_enfants, disponible, ville_residence)
|
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', 3, true, 'Lille')
|
VALUES ('66666666-6666-6666-6666-666666666666', 'AGR-2025-0001', '275119900100102', 3, true, 'Lille')
|
||||||
ON CONFLICT (id_utilisateur) DO NOTHING;
|
ON CONFLICT (id_utilisateur) DO NOTHING;
|
||||||
|
|
||||||
-- ------------------------------------------------------------
|
-- ------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 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)
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- Hash bcrypt pour "password" (10 rounds)
|
||||||
|
|
||||||
|
-- ========== UTILISATEURS (1 admin + 1 gestionnaire + 2 AM + 5 parents) ==========
|
||||||
|
-- On garde admin@ptits-pas.fr (super_admin) déjà créé par BDD.sql
|
||||||
|
|
||||||
|
INSERT INTO utilisateurs (id, email, password, prenom, nom, role, statut, telephone, adresse, ville, code_postal, profession, situation_familiale, date_naissance, consentement_photo)
|
||||||
|
VALUES
|
||||||
|
('a0000001-0001-0001-0001-000000000001', 'sophie.bernard@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Sophie', 'BERNARD', 'administrateur', 'actif', '0678123456', '12 Avenue Gabriel Péri', 'Bezons', '95870', 'Responsable administrative', 'marie', '1978-03-15', false),
|
||||||
|
('a0000002-0002-0002-0002-000000000002', 'lucas.moreau@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Lucas', 'MOREAU', 'gestionnaire', 'actif', '0687234567', '8 Rue Jean Jaurès', 'Bezons', '95870', 'Gestionnaire des placements', 'celibataire', '1985-09-22', false),
|
||||||
|
('a0000003-0003-0003-0003-000000000003', 'marie.dubois@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Marie', 'DUBOIS', 'assistante_maternelle', 'actif', '0696345678', '25 Rue de la République', 'Bezons', '95870', 'Assistante maternelle', 'marie', '1980-06-08', true),
|
||||||
|
('a0000004-0004-0004-0004-000000000004', 'fatima.elmansouri@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Fatima', 'EL MANSOURI', 'assistante_maternelle', 'actif', '0675456789', '17 Boulevard Aristide Briand', 'Bezons', '95870', 'Assistante maternelle', 'marie', '1975-11-12', true),
|
||||||
|
('a0000005-0005-0005-0005-000000000005', 'claire.martin@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Claire', 'MARTIN', 'parent', 'actif', '0689567890', '5 Avenue du Général de Gaulle', 'Bezons', '95870', 'Infirmière', 'marie', '1990-04-03', false),
|
||||||
|
('a0000006-0006-0006-0006-000000000006', 'thomas.martin@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Thomas', 'MARTIN', 'parent', 'actif', '0678456789', '5 Avenue du Général de Gaulle', 'Bezons', '95870', 'Ingénieur', 'marie', '1988-07-18', false),
|
||||||
|
('a0000007-0007-0007-0007-000000000007', 'amelie.durand@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Amélie', 'DURAND', 'parent', 'actif', '0667788990', '23 Rue Victor Hugo', 'Bezons', '95870', 'Comptable', 'divorce', '1987-12-14', false),
|
||||||
|
('a0000008-0008-0008-0008-000000000008', 'julien.rousseau@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Julien', 'ROUSSEAU', 'parent', 'actif', '0656677889', '14 Rue Pasteur', 'Bezons', '95870', 'Commercial', 'divorce', '1985-08-29', false),
|
||||||
|
('a0000009-0009-0009-0009-000000000009', 'david.lecomte@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'David', 'LECOMTE', 'parent', 'actif', '0645566778', '31 Rue Émile Zola', 'Bezons', '95870', 'Développeur web', 'parent_isole', '1992-10-07', false)
|
||||||
|
ON CONFLICT (email) DO NOTHING;
|
||||||
|
|
||||||
|
-- ========== PARENTS (avec co-parent pour le couple Martin) ==========
|
||||||
|
INSERT INTO parents (id_utilisateur, id_co_parent)
|
||||||
|
VALUES
|
||||||
|
('a0000005-0005-0005-0005-000000000005', 'a0000006-0006-0006-0006-000000000006'),
|
||||||
|
('a0000006-0006-0006-0006-000000000006', 'a0000005-0005-0005-0005-000000000005'),
|
||||||
|
('a0000007-0007-0007-0007-000000000007', NULL),
|
||||||
|
('a0000008-0008-0008-0008-000000000008', NULL),
|
||||||
|
('a0000009-0009-0009-0009-000000000009', NULL)
|
||||||
|
ON CONFLICT (id_utilisateur) DO NOTHING;
|
||||||
|
|
||||||
|
-- ========== ASSISTANTES MATERNELLES ==========
|
||||||
|
-- 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', '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 ==========
|
||||||
|
INSERT INTO enfants (id, prenom, nom, genre, date_naissance, statut, est_multiple)
|
||||||
|
VALUES
|
||||||
|
('e0000001-0001-0001-0001-000000000001', 'Emma', 'MARTIN', 'F', '2023-02-15', 'actif', true),
|
||||||
|
('e0000002-0002-0002-0002-000000000002', 'Noah', 'MARTIN', 'H', '2023-02-15', 'actif', true),
|
||||||
|
('e0000003-0003-0003-0003-000000000003', 'Léa', 'MARTIN', 'F', '2023-02-15', 'actif', true),
|
||||||
|
('e0000004-0004-0004-0004-000000000004', 'Chloé', 'ROUSSEAU', 'F', '2022-04-20', 'actif', false),
|
||||||
|
('e0000005-0005-0005-0005-000000000005', 'Hugo', 'ROUSSEAU', 'H', '2024-03-10', 'actif', false),
|
||||||
|
('e0000006-0006-0006-0006-000000000006', 'Maxime', 'LECOMTE', 'H', '2023-04-15', 'actif', false)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- ========== ENFANTS_PARENTS (liaison N:N) ==========
|
||||||
|
-- Martin (Claire + Thomas) -> Emma, Noah, Léa
|
||||||
|
INSERT INTO enfants_parents (id_parent, id_enfant)
|
||||||
|
VALUES
|
||||||
|
('a0000005-0005-0005-0005-000000000005', 'e0000001-0001-0001-0001-000000000001'),
|
||||||
|
('a0000005-0005-0005-0005-000000000005', 'e0000002-0002-0002-0002-000000000002'),
|
||||||
|
('a0000005-0005-0005-0005-000000000005', 'e0000003-0003-0003-0003-000000000003'),
|
||||||
|
('a0000006-0006-0006-0006-000000000006', 'e0000001-0001-0001-0001-000000000001'),
|
||||||
|
('a0000006-0006-0006-0006-000000000006', 'e0000002-0002-0002-0002-000000000002'),
|
||||||
|
('a0000006-0006-0006-0006-000000000006', 'e0000003-0003-0003-0003-000000000003'),
|
||||||
|
('a0000007-0007-0007-0007-000000000007', 'e0000004-0004-0004-0004-000000000004'),
|
||||||
|
('a0000007-0007-0007-0007-000000000007', 'e0000005-0005-0005-0005-000000000005'),
|
||||||
|
('a0000008-0008-0008-0008-000000000008', 'e0000004-0004-0004-0004-000000000004'),
|
||||||
|
('a0000008-0008-0008-0008-000000000008', 'e0000005-0005-0005-0005-000000000005'),
|
||||||
|
('a0000009-0009-0009-0009-000000000009', 'e0000006-0006-0006-0006-000000000006')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -55,6 +55,8 @@ services:
|
|||||||
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET}
|
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET}
|
||||||
JWT_REFRESH_EXPIRES: ${JWT_REFRESH_EXPIRES}
|
JWT_REFRESH_EXPIRES: ${JWT_REFRESH_EXPIRES}
|
||||||
NODE_ENV: ${NODE_ENV}
|
NODE_ENV: ${NODE_ENV}
|
||||||
|
LOG_API_REQUESTS: ${LOG_API_REQUESTS:-false}
|
||||||
|
CONFIG_ENCRYPTION_KEY: ${CONFIG_ENCRYPTION_KEY}
|
||||||
depends_on:
|
depends_on:
|
||||||
- database
|
- database
|
||||||
labels:
|
labels:
|
||||||
|
|||||||
+274
-46
@@ -1,15 +1,15 @@
|
|||||||
# 🎫 Liste Complète des Tickets - Projet P'titsPas
|
# 🎫 Liste Complète des Tickets - Projet P'titsPas
|
||||||
|
|
||||||
**Version** : 1.4
|
**Version** : 1.6
|
||||||
**Date** : 9 Février 2026
|
**Date** : 25 Février 2026
|
||||||
**Auteur** : Équipe PtitsPas
|
**Auteur** : Équipe PtitsPas
|
||||||
**Estimation totale** : ~184h
|
**Estimation totale** : ~208h
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔗 Liste des tickets Gitea
|
## 🔗 Liste des tickets Gitea
|
||||||
|
|
||||||
**Les numéros de section dans ce document = numéros d’issues Gitea.** Ticket #14 dans le doc = issue Gitea #14, etc. Source : dépôt `jmartin/petitspas` (état au 9 février 2026).
|
**Les numéros de section dans ce document = numéros d’issues Gitea.** Ticket #14 dans le doc = issue Gitea #14, etc. Source : dépôt `jmartin/petitspas` (état au 25 février 2026).
|
||||||
|
|
||||||
| Gitea # | Titre (dépôt) | Statut |
|
| Gitea # | Titre (dépôt) | Statut |
|
||||||
|--------|----------------|--------|
|
|--------|----------------|--------|
|
||||||
@@ -23,14 +23,88 @@
|
|||||||
| 10 | [Backend] Service Configuration | ✅ Fermé |
|
| 10 | [Backend] Service Configuration | ✅ Fermé |
|
||||||
| 11 | [Backend] API Configuration | ✅ Fermé |
|
| 11 | [Backend] API Configuration | ✅ Fermé |
|
||||||
| 12 | [Backend] Guard Configuration Initiale | ✅ Fermé |
|
| 12 | [Backend] Guard Configuration Initiale | ✅ Fermé |
|
||||||
| 13 | [Backend] Adaptation MailService pour config dynamique | Ouvert |
|
| 13 | [Backend] Adaptation MailService pour config dynamique | ✅ Fermé |
|
||||||
| 14 | [Frontend] Panneau Paramètres / Configuration (première config + accès permanent) | Ouvert |
|
| 14 | [Frontend] Panneau Paramètres / Configuration (première config + accès permanent) | Ouvert |
|
||||||
| 15 | [Frontend] Écran Paramètres (accès permanent) | Ouvert |
|
| 15 | [Frontend] Écran Paramètres (accès permanent) / Intégration panneau | Ouvert |
|
||||||
| 16 | [Doc] Documentation configuration on-premise | Ouvert |
|
| 16 | [Doc] Documentation configuration on-premise | Ouvert |
|
||||||
| 17–88 | (voir sections ci‑dessous ; #78, #79, #81, #83, #82, #86, #87, #88, etc.) | — |
|
| 17 | [Backend] API Création gestionnaire | ✅ Terminé |
|
||||||
|
| 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 | ✅ Fermé (obsolète, couvert #103-#111) |
|
||||||
|
| 26 | [Backend] API Validation/Refus comptes | ✅ Fermé (obsolète, couvert #103-#111) |
|
||||||
|
| 27 | [Backend] Service Email - Installation Nodemailer | ✅ Fermé (obsolète, couvert #103-#111) |
|
||||||
|
| 28 | [Backend] Templates Email - Validation | Ouvert |
|
||||||
|
| 29 | [Backend] Templates Email - Refus | ✅ Fermé (obsolète, couvert #103-#111) |
|
||||||
|
| 30 | [Backend] Connexion - Vérification statut | ✅ Fermé (obsolète, couvert #103-#111) |
|
||||||
|
| 31 | [Backend] Changement MDP obligatoire première connexion | ✅ Terminé |
|
||||||
|
| 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 | ✅ Fermé (obsolète, couvert #103-#111) |
|
||||||
|
| 46 | [Frontend] Dashboard Gestionnaire - Liste AM | ✅ Fermé (obsolète, couvert #103-#111) |
|
||||||
|
| 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*
|
*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 » (voir plan de spec).*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📊 Vue d'ensemble
|
## 📊 Vue d'ensemble
|
||||||
@@ -229,6 +303,8 @@ Créer un Guard/Middleware qui détecte si la configuration initiale est incompl
|
|||||||
|
|
||||||
**Référence** : [21_CONFIGURATION-SYSTEME.md](./21_CONFIGURATION-SYSTEME.md#workflow-setup-initial)
|
**Référence** : [21_CONFIGURATION-SYSTEME.md](./21_CONFIGURATION-SYSTEME.md#workflow-setup-initial)
|
||||||
|
|
||||||
|
*Issue Gitea #86 fermée en doublon ; ce ticket (#12) est la référence.*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Ticket #13 : [Backend] Adaptation MailService pour config dynamique
|
### Ticket #13 : [Backend] Adaptation MailService pour config dynamique
|
||||||
@@ -267,6 +343,8 @@ Un seul panneau **Paramètres / Configuration** dans le dashboard admin, avec **
|
|||||||
|
|
||||||
**Référence** : [21_CONFIGURATION-SYSTEME.md](./21_CONFIGURATION-SYSTEME.md#interface-admin)
|
**Référence** : [21_CONFIGURATION-SYSTEME.md](./21_CONFIGURATION-SYSTEME.md#interface-admin)
|
||||||
|
|
||||||
|
*Issue Gitea #87 fermée en doublon de #14.*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Ticket #15 : [Frontend] Écran Paramètres (accès permanent) / Intégration panneau
|
### Ticket #15 : [Frontend] Écran Paramètres (accès permanent) / Intégration panneau
|
||||||
@@ -281,6 +359,8 @@ S’assurer que le panneau Paramètres (décrit en #14) est accessible en perman
|
|||||||
- [ ] Chargement des valeurs actuelles (GET `/configuration` ou par catégorie)
|
- [ ] Chargement des valeurs actuelles (GET `/configuration` ou par catégorie)
|
||||||
- [ ] Modification et sauvegarde (PATCH bulk) sans appel à `setup/complete`
|
- [ ] Modification et sauvegarde (PATCH bulk) sans appel à `setup/complete`
|
||||||
|
|
||||||
|
*Issue Gitea #88 fermée en doublon ; ce ticket (#15) est la référence.*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Ticket #16 : [Doc] Documentation configuration on-premise
|
### Ticket #16 : [Doc] Documentation configuration on-premise
|
||||||
@@ -302,39 +382,29 @@ Rédiger la documentation pour aider les collectivités à configurer l'applicat
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Ticket #86 : [Backend] Guard Configuration Initiale (concept v1.3)
|
### Ticket #86 / #88 : Doublons fermés
|
||||||
**Estimation** : 2h
|
*#86* fermé en doublon de **#12** (Guard). *#88* fermé en doublon de **#15** (Intégration panneau). Voir les tickets #12, #14 et #15 pour le travail à faire.
|
||||||
**Labels** : `backend`, `p1-bloquant`, `on-premise`
|
|
||||||
|
|
||||||
Issue Gitea ouverte pour le Guard aligné avec le concept v1.3 (pas de redirection vers `/admin/setup`, le frontend affiche le panneau Configuration et bloque la navigation). Voir aussi Ticket #12 (version fermée).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Ticket #88 : [Frontend] Intégration panneau Paramètres au dashboard
|
|
||||||
**Estimation** : 1h
|
|
||||||
**Labels** : `frontend`, `p1-bloquant`, `on-premise`
|
|
||||||
|
|
||||||
Complément de #14 et #15 : s’assurer que le panneau Paramètres est accessible en permanence (onglet Configuration, chargement des valeurs, sauvegarde PATCH bulk sans `setup/complete`).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🟢 PRIORITÉ 2 : Backend - Authentification & Gestion Comptes
|
## 🟢 PRIORITÉ 2 : Backend - Authentification & Gestion Comptes
|
||||||
|
|
||||||
### Ticket #17 : [Backend] API Création gestionnaire
|
### Ticket #17 : [Backend] API Création gestionnaire ✅
|
||||||
**Estimation** : 3h
|
**Estimation** : 3h
|
||||||
**Labels** : `backend`, `p2`, `auth`
|
**Labels** : `backend`, `p2`, `auth`
|
||||||
|
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-23)
|
||||||
|
|
||||||
**Description** :
|
**Description** :
|
||||||
Créer l'endpoint pour permettre au super admin de créer des gestionnaires.
|
Créer l'endpoint pour permettre au super admin de créer des gestionnaires.
|
||||||
|
|
||||||
**Tâches** :
|
**Tâches** :
|
||||||
- [ ] Endpoint `POST /api/v1/gestionnaires`
|
- [x] Endpoint `POST /api/v1/gestionnaires`
|
||||||
- [ ] Validation DTO
|
- [x] Validation DTO
|
||||||
- [ ] Hash bcrypt
|
- [x] Hash bcrypt
|
||||||
- [ ] Flag `changement_mdp_obligatoire = TRUE`
|
- [x] Flag `changement_mdp_obligatoire = TRUE`
|
||||||
- [ ] Guards (super_admin only)
|
- [x] Guards (super_admin only)
|
||||||
- [ ] Email de notification (utiliser MailService avec config dynamique)
|
- [x] Email de notification (utiliser MailService avec config dynamique)
|
||||||
- [ ] Tests unitaires
|
- [x] Tests unitaires
|
||||||
|
|
||||||
**Référence** : [20_WORKFLOW-CREATION-COMPTE.md](./20_WORKFLOW-CREATION-COMPTE.md#étape-2--création-dun-gestionnaire)
|
**Référence** : [20_WORKFLOW-CREATION-COMPTE.md](./20_WORKFLOW-CREATION-COMPTE.md#étape-2--création-dun-gestionnaire)
|
||||||
|
|
||||||
@@ -571,17 +641,18 @@ Modifier l'endpoint de connexion pour bloquer les comptes en attente ou suspendu
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Ticket #31 : [Backend] Changement MDP obligatoire première connexion
|
### Ticket #31 : [Backend] Changement MDP obligatoire première connexion ✅
|
||||||
**Estimation** : 2h
|
**Estimation** : 2h
|
||||||
**Labels** : `backend`, `p2`, `auth`, `security`
|
**Labels** : `backend`, `p2`, `auth`, `security`
|
||||||
|
**Statut** : ✅ TERMINÉ
|
||||||
|
|
||||||
**Description** :
|
**Description** :
|
||||||
Implémenter le changement de mot de passe obligatoire pour les gestionnaires/admins à la première connexion.
|
Implémenter le changement de mot de passe obligatoire pour les gestionnaires/admins à la première connexion.
|
||||||
|
|
||||||
**Tâches** :
|
**Tâches** :
|
||||||
- [ ] Endpoint `POST /api/v1/auth/change-password-required`
|
- [x] Endpoint `POST /api/v1/auth/change-password-required`
|
||||||
- [ ] Vérification flag `changement_mdp_obligatoire`
|
- [x] Vérification flag `changement_mdp_obligatoire`
|
||||||
- [ ] Mise à jour flag après changement
|
- [x] Mise à jour flag après changement
|
||||||
- [ ] Tests unitaires
|
- [ ] Tests unitaires
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -645,6 +716,39 @@ Enregistrer les acceptations de documents légaux lors de l'inscription (traçab
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Ticket #94 : [Backend] Relais - Modèle, API CRUD et liaison gestionnaire ✅
|
||||||
|
**Estimation** : 4h
|
||||||
|
**Labels** : `backend`, `p2`, `admin`
|
||||||
|
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-21)
|
||||||
|
|
||||||
|
**Description** :
|
||||||
|
Le back-office admin doit gérer des Relais avec des données réelles en base, et permettre une liaison simple avec les gestionnaires.
|
||||||
|
|
||||||
|
**Tâches** :
|
||||||
|
- [x] Créer le modèle `Relais` (nom, adresse, horaires, téléphone, actif, notes)
|
||||||
|
- [x] Exposer les endpoints admin CRUD pour les relais (`GET`, `POST`, `PATCH`, `DELETE`)
|
||||||
|
- [x] Ajouter la liaison : un gestionnaire peut être rattaché à un relais principal (`relais_id` dans `users` ?)
|
||||||
|
- [x] Validations (champs requis, format horaires)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Ticket #97 : [Backend] Harmoniser API création administrateur avec le contrat frontend ✅
|
||||||
|
**Estimation** : 3h
|
||||||
|
**Labels** : `backend`, `p2`, `auth`, `admin`
|
||||||
|
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-24)
|
||||||
|
|
||||||
|
**Description** :
|
||||||
|
Rendre l'API de création administrateur cohérente et stable avec le besoin frontend (modale simplifiée), en définissant un contrat clair et minimal.
|
||||||
|
|
||||||
|
**Tâches** :
|
||||||
|
- [ ] Introduire un DTO dédié `CreateAdministrateurDto`
|
||||||
|
- [ ] Champs autorisés : nom, prenom, email, password, telephone
|
||||||
|
- [ ] Champs exclus : adresse, ville, photo, etc.
|
||||||
|
- [ ] Rôle forcé à `ADMINISTRATEUR`
|
||||||
|
- [ ] Validation stricte
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🟢 PRIORITÉ 3 : Frontend - Interfaces
|
## 🟢 PRIORITÉ 3 : Frontend - Interfaces
|
||||||
|
|
||||||
### Ticket #35 : [Frontend] Écran Création Gestionnaire
|
### Ticket #35 : [Frontend] Écran Création Gestionnaire
|
||||||
@@ -802,7 +906,7 @@ Créer l'écran de création de mot de passe (lien reçu par email).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Ticket #44 : [Frontend] Dashboard Gestionnaire - Structure
|
### Ticket #44 : [Frontend] Dashboard Gestionnaire - Structure ✅
|
||||||
**Estimation** : 2h
|
**Estimation** : 2h
|
||||||
**Labels** : `frontend`, `p3`, `gestionnaire`
|
**Labels** : `frontend`, `p3`, `gestionnaire`
|
||||||
|
|
||||||
@@ -810,9 +914,10 @@ Créer l'écran de création de mot de passe (lien reçu par email).
|
|||||||
Créer la structure du dashboard gestionnaire avec 2 onglets.
|
Créer la structure du dashboard gestionnaire avec 2 onglets.
|
||||||
|
|
||||||
**Tâches** :
|
**Tâches** :
|
||||||
- [ ] Layout avec 2 onglets (Parents / AM)
|
- [x] Dashboard gestionnaire = même shell que admin (sans onglet Paramètres), libellé « Gestionnaire »
|
||||||
- [ ] Navigation entre onglets
|
- [x] Réutilisation du widget UserManagementPanel (ex-AdminUserManagementPanel) avec 3 onglets (Gestionnaires, Parents, Assistantes maternelles) ; onglet Administrateurs masqué
|
||||||
- [ ] État vide ("Aucune demande")
|
- [x] Redirection login rôle `gestionnaire` vers `/gestionnaire-dashboard`
|
||||||
|
- [ ] État vide dédié ("Aucune demande") — optionnel, contenu actuel = listes existantes
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -898,6 +1003,31 @@ Créer l'écran de gestion des documents légaux (CGU/Privacy) pour l'admin.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Ticket #92 : [Frontend] Dashboard Admin - Données réelles et branchement API ✅
|
||||||
|
**Estimation** : 8h
|
||||||
|
**Labels** : `frontend`, `p3`, `admin`
|
||||||
|
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-17)
|
||||||
|
|
||||||
|
**Description** :
|
||||||
|
Le dashboard admin (onglets Gestionnaires | Parents | Assistantes maternelles | Administrateurs) affiche actuellement des données en dur (mock). Remplacer par des appels API pour afficher les vrais utilisateurs et permettre les actions de gestion (voir, modifier, valider/refuser). Référence : [90_AUDIT.md](./90_AUDIT.md).
|
||||||
|
|
||||||
|
**Fichiers concernés** :
|
||||||
|
- `gestionnaire_management_widget.dart` — liste actuellement 5 cartes "Dupont" en dur
|
||||||
|
- `parent_managmant_widget.dart` — 2 parents simulés
|
||||||
|
- `assistante_maternelle_management_widget.dart` — 2 AM simulées
|
||||||
|
|
||||||
|
**Tâches** :
|
||||||
|
- [ ] S'assurer que les endpoints backend existent (liste users par rôle)
|
||||||
|
- [ ] Onglet Gestionnaires : appel API, affichage dynamique, recherche, lien "Créer gestionnaire"
|
||||||
|
- [ ] Onglet Parents : appel API, affichage dynamique, recherche/filtres, actions Voir/Modifier/Valider/Refuser
|
||||||
|
- [ ] Onglet Assistantes maternelles : appel API, affichage dynamique, filtres, actions
|
||||||
|
- [ ] Onglet Administrateurs : liste ou placeholder documenté
|
||||||
|
- [ ] Gestion états (chargement, erreur, liste vide) et rafraîchissement après actions
|
||||||
|
|
||||||
|
**Références** : #44, #45, #46 (dashboard Gestionnaire), #25, #26 (API liste/validation), #17, #35 (création gestionnaire)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Ticket #50 : [Frontend] Affichage dynamique CGU lors inscription
|
### Ticket #50 : [Frontend] Affichage dynamique CGU lors inscription
|
||||||
**Estimation** : 2h
|
**Estimation** : 2h
|
||||||
**Labels** : `frontend`, `p3`, `juridique`
|
**Labels** : `frontend`, `p3`, `juridique`
|
||||||
@@ -998,6 +1128,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
|
## 🔵 PRIORITÉ 4 : Tests & Documentation
|
||||||
|
|
||||||
### Ticket #52 : [Tests] Tests unitaires Backend
|
### Ticket #52 : [Tests] Tests unitaires Backend
|
||||||
@@ -1113,6 +1326,20 @@ Mettre en place un système de logs centralisé avec Winston pour faciliter le d
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Ticket #89 : Log des appels API en mode debug
|
||||||
|
**Estimation** : 2h
|
||||||
|
**Labels** : `backend`, `monitoring`
|
||||||
|
|
||||||
|
**Description** :
|
||||||
|
Ajouter des logs détaillés pour les appels API en mode debug pour faciliter le diagnostic.
|
||||||
|
|
||||||
|
**Tâches** :
|
||||||
|
- [ ] Middleware ou Intercepteur pour logger les requêtes entrantes (méthode, URL, body)
|
||||||
|
- [ ] Logger les réponses (status, temps d'exécution)
|
||||||
|
- [ ] Activable via variable d'environnement `DEBUG=true` ou niveau de log
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Ticket #51 (réf.) : [Frontend] Écran Logs Admin (optionnel v1.1)
|
### Ticket #51 (réf.) : [Frontend] Écran Logs Admin (optionnel v1.1)
|
||||||
**Estimation** : 4h
|
**Estimation** : 4h
|
||||||
**Labels** : `frontend`, `p3`, `monitoring`, `admin`
|
**Labels** : `frontend`, `p3`, `monitoring`, `admin`
|
||||||
@@ -1215,29 +1442,30 @@ Rédiger les documents légaux génériques (CGU et Politique de confidentialit
|
|||||||
|
|
||||||
## 📊 Résumé final
|
## 📊 Résumé final
|
||||||
|
|
||||||
**Total** : 65 tickets
|
**Total** : 72 tickets
|
||||||
**Estimation** : ~184h de développement
|
**Estimation** : ~208h de développement
|
||||||
|
|
||||||
### Par priorité
|
### Par priorité
|
||||||
- **P0 (Bloquant BDD)** : 7 tickets (~5h)
|
- **P0 (Bloquant BDD)** : 7 tickets (~5h)
|
||||||
- **P1 (Bloquant Config)** : 7 tickets (~22h)
|
- **P1 (Bloquant Config)** : 7 tickets (~22h)
|
||||||
- **P2 (Backend)** : 18 tickets (~50h)
|
- **P2 (Backend)** : 19 tickets (~54h)
|
||||||
- **P3 (Frontend)** : 22 tickets (~71h) ← +1 mobile RegisterChoice
|
- **P3 (Frontend)** : 25 tickets (~83h)
|
||||||
- **P4 (Tests/Doc)** : 4 tickets (~24h)
|
- **P4 (Tests/Doc)** : 4 tickets (~24h)
|
||||||
- **Critiques** : 6 tickets (~13h)
|
- **Critiques** : 6 tickets (~13h)
|
||||||
- **Juridique** : 1 ticket (~8h)
|
- **Juridique** : 1 ticket (~8h)
|
||||||
|
|
||||||
### Par domaine
|
### Par domaine
|
||||||
- **BDD** : 7 tickets
|
- **BDD** : 7 tickets
|
||||||
- **Backend** : 23 tickets
|
- **Backend** : 24 tickets
|
||||||
- **Frontend** : 22 tickets ← +1 mobile RegisterChoice
|
- **Frontend** : 25 tickets
|
||||||
- **Tests** : 3 tickets
|
- **Tests** : 3 tickets
|
||||||
- **Documentation** : 5 tickets
|
- **Documentation** : 5 tickets
|
||||||
- **Infra** : 2 tickets
|
- **Infra** : 2 tickets
|
||||||
- **Juridique** : 1 ticket
|
- **Juridique** : 1 ticket
|
||||||
|
|
||||||
### Modifications par rapport à la version initiale
|
### Modifications par rapport à la version initiale
|
||||||
- ✅ **v1.4** : Numéros de section du doc = numéros Gitea (Ticket #n = issue #n). Tableau et sections renumérotés en conséquence ; #87 fermé (doublon de #14).
|
- ✅ **v1.5** : Ajout tickets #91, #93, #94, #95. Ticket #92 terminé.
|
||||||
|
- ✅ **v1.4** : Numéros de section du doc = numéros Gitea (Ticket #n = issue #n). Tableau et sections renumérotés. Doublons #86, #87, #88 fermés sur Gitea (#86→#12, #87→#14, #88→#15) ; tickets sources #12, #14, #15 mis à jour (doc + body Gitea).
|
||||||
- ✅ **Concept v1.3** : Configuration initiale = un seul panneau Paramètres (3 sections) dans le dashboard ; plus de page dédiée « Setup Wizard » ; navigation bloquée jusqu’à sauvegarde au premier déploiement. Tickets #10, #12, #13 alignés.
|
- ✅ **Concept v1.3** : Configuration initiale = un seul panneau Paramètres (3 sections) dans le dashboard ; plus de page dédiée « Setup Wizard » ; navigation bloquée jusqu’à sauvegarde au premier déploiement. Tickets #10, #12, #13 alignés.
|
||||||
- ❌ **Supprimé** : Tickets "Renvoyer email validation" (backend + frontend) - Pas prioritaire
|
- ❌ **Supprimé** : Tickets "Renvoyer email validation" (backend + frontend) - Pas prioritaire
|
||||||
- ✅ **Ajouté** : Ticket #55 "Service Logging Winston" - Monitoring essentiel
|
- ✅ **Ajouté** : Ticket #55 "Service Logging Winston" - Monitoring essentiel
|
||||||
@@ -1250,7 +1478,7 @@ Rédiger les documents légaux génériques (CGU et Politique de confidentialit
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Dernière mise à jour** : 9 Février 2026
|
**Dernière mise à jour** : 25 Février 2026
|
||||||
**Version** : 1.4
|
**Version** : 1.6
|
||||||
**Statut** : ✅ Aligné avec le dépôt Gitea
|
**Statut** : ✅ Aligné avec le dépôt Gitea (tickets #103-#117 créés)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Note Backend - Activation du module Gestionnaires (Ticket #92)
|
||||||
|
|
||||||
|
## Problème
|
||||||
|
L'endpoint `GET /api/v1/gestionnaires` renvoie une erreur **404 Not Found**.
|
||||||
|
Cela est dû au fait que le `GestionnairesModule` n'est pas importé dans l'arbre des modules de l'application (via `UserModule` ou `AppModule`).
|
||||||
|
|
||||||
|
## Solution de contournement actuelle (Frontend)
|
||||||
|
Le frontend utilise actuellement l'endpoint générique `/api/v1/users` et filtre les résultats côté client pour ne garder que les utilisateurs ayant le rôle `gestionnaire`.
|
||||||
|
*Fichier concerné : `frontend/lib/services/user_service.dart`*
|
||||||
|
|
||||||
|
## Correctif Backend à appliquer
|
||||||
|
Pour activer proprement l'endpoint dédié, il faut effectuer les modifications suivantes dans le backend :
|
||||||
|
|
||||||
|
### 1. Importer le module dans `UserModule`
|
||||||
|
Fichier : `backend/src/routes/user/user.module.ts`
|
||||||
|
|
||||||
|
Ajouter `GestionnairesModule` dans les imports.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { GestionnairesModule } from './gestionnaires/gestionnaires.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
// ... autres imports
|
||||||
|
GestionnairesModule, // <--- AJOUTER ICI
|
||||||
|
],
|
||||||
|
// ...
|
||||||
|
})
|
||||||
|
export class UserModule { }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Ajouter AuthModule dans `GestionnairesModule`
|
||||||
|
Fichier : `backend/src/routes/user/gestionnaires/gestionnaires.module.ts`
|
||||||
|
|
||||||
|
Le contrôleur utilise `AuthGuard`, qui dépend de `JwtService` fourni par `AuthModule`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { AuthModule } from 'src/routes/auth/auth.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([Users]),
|
||||||
|
AuthModule // <--- AJOUTER ICI
|
||||||
|
],
|
||||||
|
controllers: [GestionnairesController],
|
||||||
|
providers: [GestionnairesService],
|
||||||
|
})
|
||||||
|
export class GestionnairesModule { }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Après application du correctif
|
||||||
|
Une fois ces modifications backend effectuées :
|
||||||
|
1. Redémarrer le serveur backend.
|
||||||
|
2. Modifier le frontend (`frontend/lib/services/user_service.dart`) pour utiliser à nouveau l'endpoint dédié :
|
||||||
|
```dart
|
||||||
|
static Future<List<AppUser>> getGestionnaires() async {
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.gestionnaires}'),
|
||||||
|
headers: await _headers(),
|
||||||
|
);
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -256,3 +256,23 @@ Pour chaque évolution identifiée, ce document suivra la structure suivante :
|
|||||||
- Modification du flux de sélection d'image dans les écrans concernés (ex: `parent_register_step3_screen.dart`).
|
- Modification du flux de sélection d'image dans les écrans concernés (ex: `parent_register_step3_screen.dart`).
|
||||||
- Ajout potentiel de nouvelles dépendances et configurations spécifiques aux plateformes.
|
- Ajout potentiel de nouvelles dépendances et configurations spécifiques aux plateformes.
|
||||||
- Mise à jour de la documentation utilisateur si cette fonctionnalité est implémentée.
|
- Mise à jour de la documentation utilisateur si cette fonctionnalité est implémentée.
|
||||||
|
|
||||||
|
## 8. Évolution future - Gouvernance intra-RPE
|
||||||
|
|
||||||
|
### 8.1 Niveaux d'accès et rôles différenciés dans un même Relais
|
||||||
|
|
||||||
|
#### 8.1.1 Situation actuelle
|
||||||
|
- Le périmètre actuel prévoit un rattachement simple entre gestionnaire et relais.
|
||||||
|
- Le rôle "gestionnaire" est traité de manière uniforme dans l'outil.
|
||||||
|
|
||||||
|
#### 8.1.2 Évolution à prévoir
|
||||||
|
- Introduire un modèle de rôles internes au relais (par exemple : responsable/coordinatrice, animatrice/référente, administratif).
|
||||||
|
- Permettre des niveaux d'autorité différents selon les actions (pilotage, validation, consultation, administration locale).
|
||||||
|
- Définir des permissions fines par fonctionnalité (lecture, création, modification, suppression, validation).
|
||||||
|
- Prévoir une gestion multi-utilisateurs par relais avec traçabilité des décisions.
|
||||||
|
|
||||||
|
#### 8.1.3 Impact attendu
|
||||||
|
- Évolution du modèle de données vers un RBAC intra-RPE.
|
||||||
|
- Adaptation des écrans d'administration pour gérer les rôles locaux.
|
||||||
|
- Renforcement des contrôles d'accès backend et des règles métier.
|
||||||
|
- Clarification des workflows décisionnels dans l'application.
|
||||||
@@ -14,7 +14,7 @@ Num | Etat | Titre
|
|||||||
41 | closed | [Frontend] Inscription AM - Panneau 2 (Infos pro)
|
41 | closed | [Frontend] Inscription AM - Panneau 2 (Infos pro)
|
||||||
42 | closed | [Frontend] Inscription AM - Finalisation
|
42 | closed | [Frontend] Inscription AM - Finalisation
|
||||||
43 | open | [Frontend] Écran Création Mot de Passe
|
43 | open | [Frontend] Écran Création Mot de Passe
|
||||||
44 | open | [Frontend] Dashboard Gestionnaire - Structure
|
44 | closed | [Frontend] Dashboard Gestionnaire - Structure
|
||||||
45 | open | [Frontend] Dashboard Gestionnaire - Liste Parents
|
45 | open | [Frontend] Dashboard Gestionnaire - Liste Parents
|
||||||
46 | open | [Frontend] Dashboard Gestionnaire - Liste AM
|
46 | open | [Frontend] Dashboard Gestionnaire - Liste AM
|
||||||
47 | open | [Frontend] Écran Changement MDP Obligatoire
|
47 | open | [Frontend] Écran Changement MDP Obligatoire
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
# Procédure – Utilisation de l’API Gitea
|
||||||
|
|
||||||
|
## 1. Contexte
|
||||||
|
|
||||||
|
- **Instance** : https://git.ptits-pas.fr
|
||||||
|
- **API de base** : `https://git.ptits-pas.fr/api/v1`
|
||||||
|
- **Projet P'titsPas** : dépôt `jmartin/petitspas` (owner = `jmartin`, repo = `petitspas`)
|
||||||
|
|
||||||
|
## 2. Authentification
|
||||||
|
|
||||||
|
### 2.1 Token
|
||||||
|
|
||||||
|
Le token est défini dans l’environnement (ex. `~/.bashrc`) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export GITEA_TOKEN="<votre_token>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Pour l’utiliser dans les commandes :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source ~/.bashrc # ou : . ~/.bashrc
|
||||||
|
# Puis utiliser $GITEA_TOKEN dans les curl
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 En-tête HTTP
|
||||||
|
|
||||||
|
Toutes les requêtes API doivent envoyer le token :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
-H "Authorization: token $GITEA_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
Exemple :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Endpoints utiles
|
||||||
|
|
||||||
|
### 3.1 Dépôt (repository)
|
||||||
|
|
||||||
|
| Action | Méthode | URL |
|
||||||
|
|---------------|---------|-----|
|
||||||
|
| Infos dépôt | GET | `/repos/{owner}/{repo}` |
|
||||||
|
| Liste dépôts | GET | `/repos/search?q=petitspas` |
|
||||||
|
|
||||||
|
Exemple – infos du dépôt :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas" | jq .
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Issues (tickets)
|
||||||
|
|
||||||
|
| Action | Méthode | URL |
|
||||||
|
|------------------|---------|-----|
|
||||||
|
| Liste des issues | GET | `/repos/{owner}/{repo}/issues` |
|
||||||
|
| Détail d’une issue | GET | `/repos/{owner}/{repo}/issues/{index}` |
|
||||||
|
| Créer une issue | POST | `/repos/{owner}/{repo}/issues` |
|
||||||
|
| Modifier une issue | PATCH | `/repos/{owner}/{repo}/issues/{index}` |
|
||||||
|
| Fermer une issue | PATCH | (même URL, `state: "closed"`) |
|
||||||
|
|
||||||
|
**Paramètres GET utiles pour la liste :**
|
||||||
|
|
||||||
|
- `state` : `open` ou `closed`
|
||||||
|
- `labels` : filtre par label (ex. `frontend`)
|
||||||
|
- `page`, `limit` : pagination
|
||||||
|
|
||||||
|
Exemples :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Toutes les issues ouvertes
|
||||||
|
curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues?state=open" | jq .
|
||||||
|
|
||||||
|
# Issues ouvertes avec label "frontend"
|
||||||
|
curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues?state=open" | \
|
||||||
|
jq '.[] | select(.labels[].name == "frontend") | {number, title, state}'
|
||||||
|
|
||||||
|
# Détail de l’issue #47
|
||||||
|
curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/47" | jq .
|
||||||
|
|
||||||
|
# Fermer l’issue #31
|
||||||
|
curl -s -X PATCH -H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"state":"closed"}' \
|
||||||
|
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/31"
|
||||||
|
|
||||||
|
# Créer une issue
|
||||||
|
curl -s -X POST -H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"title":"Titre du ticket","body":"Description","labels":[1]}' \
|
||||||
|
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 Pull requests
|
||||||
|
|
||||||
|
| Action | Méthode | URL |
|
||||||
|
|---------------|---------|-----|
|
||||||
|
| Liste des PR | GET | `/repos/{owner}/{repo}/pulls` |
|
||||||
|
| Détail d’une PR | GET | `/repos/{owner}/{repo}/pulls/{index}` |
|
||||||
|
| Créer une PR | POST | `/repos/{owner}/{repo}/pulls` |
|
||||||
|
| Fusionner une PR | POST | `/repos/{owner}/{repo}/pulls/{index}/merge` |
|
||||||
|
|
||||||
|
Exemples :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Liste des PR ouvertes
|
||||||
|
curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/pulls?state=open" | jq .
|
||||||
|
|
||||||
|
# Créer une PR (head = branche source, base = branche cible)
|
||||||
|
curl -s -X POST -H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"head":"develop","base":"master","title":"Titre de la PR"}' \
|
||||||
|
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/pulls"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 Branches
|
||||||
|
|
||||||
|
| Action | Méthode | URL |
|
||||||
|
|---------------|---------|-----|
|
||||||
|
| Liste des branches | GET | `/repos/{owner}/{repo}/branches` |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/branches" | jq '.[].name'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.5 Webhooks
|
||||||
|
|
||||||
|
| Action | Méthode | URL |
|
||||||
|
|---------------|---------|-----|
|
||||||
|
| Liste webhooks | GET | `/repos/{owner}/{repo}/hooks` |
|
||||||
|
| Créer webhook | POST | `/repos/{owner}/{repo}/hooks` |
|
||||||
|
|
||||||
|
### 3.6 Labels
|
||||||
|
|
||||||
|
| Action | Méthode | URL |
|
||||||
|
|---------------|---------|-----|
|
||||||
|
| Liste des labels | GET | `/repos/{owner}/{repo}/labels` |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels" | jq '.[] | {id, name}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Résumé des URLs pour P'titsPas
|
||||||
|
|
||||||
|
Remplacer `{owner}` par `jmartin` et `{repo}` par `petitspas` :
|
||||||
|
|
||||||
|
| Ressource | URL |
|
||||||
|
|------------------|-----|
|
||||||
|
| Dépôt | `https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas` |
|
||||||
|
| Issues | `https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues` |
|
||||||
|
| Issue #n | `https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/{n}` |
|
||||||
|
| Pull requests | `https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/pulls` |
|
||||||
|
| Branches | `https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/branches` |
|
||||||
|
| Labels | `https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels` |
|
||||||
|
|
||||||
|
## 5. Documentation officielle
|
||||||
|
|
||||||
|
- Swagger / OpenAPI : https://docs.gitea.com/api
|
||||||
|
- Référence selon la version de Gitea installée (ex. 1.21, 1.25).
|
||||||
|
|
||||||
|
## 6. Dépannage
|
||||||
|
|
||||||
|
- **401 Unauthorized** : vérifier le token et l’en-tête `Authorization: token <TOKEN>`.
|
||||||
|
- **404** : vérifier owner/repo et l’URL (sensible à la casse).
|
||||||
|
- **422 / body invalide** : pour POST/PATCH, envoyer `Content-Type: application/json` et un JSON valide.
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# Statut de l'application P'titsPas
|
||||||
|
|
||||||
|
**Date du point** : 8 février 2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Environnement de production
|
||||||
|
|
||||||
|
| Élément | Statut | Détail |
|
||||||
|
|--------|--------|--------|
|
||||||
|
| **URL** | OK | https://app.ptits-pas.fr |
|
||||||
|
| **Frontend** | 200 | Flutter Web, Nginx |
|
||||||
|
| **API** | 200 | NestJS, préfixe `/api/v1` |
|
||||||
|
| **Base de données** | OK | PostgreSQL 17 |
|
||||||
|
| **PgAdmin** | OK | https://app.ptits-pas.fr/pgadmin |
|
||||||
|
|
||||||
|
### Conteneurs Docker
|
||||||
|
|
||||||
|
| Service | Image | État |
|
||||||
|
|---------|--------|------|
|
||||||
|
| ptitspas-frontend | ptitspas-app-frontend | Up (recréé récemment) |
|
||||||
|
| ptitspas-backend | ptitspas-app-backend | Up ~26h |
|
||||||
|
| ptitspas-postgres | postgres:17 | Up ~28h |
|
||||||
|
| ptitspas-pgadmin | dpage/pgadmin4 | Up ~28h |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Dépôt Git
|
||||||
|
|
||||||
|
- **Branche déployée** : `master`
|
||||||
|
- **Derniers commits** :
|
||||||
|
- `10bf255` – fix(ui): renforcer ombre boutons Parents/AM sur mobile
|
||||||
|
- `678f421` – docs: ticket #82 fermé (écran Login mobile)
|
||||||
|
- `5295e8e` – Merge develop: login mobile, formulaire sous slogan par ratio
|
||||||
|
- `6bf0932` – docs: Index, doc API Gitea, script fermeture issue
|
||||||
|
- `2f1740b` – docs: ticket #83 RegisterChoiceScreen Mobile (terminé)
|
||||||
|
|
||||||
|
- **Branches actives** : `master`, `develop`, diverses `feature/*` (inscription, config, documents légaux, etc.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Déploiement (hook Gitea)
|
||||||
|
|
||||||
|
| Élément | Statut |
|
||||||
|
|--------|--------|
|
||||||
|
| **Webhook** | Opérationnel (`hooks.ptits-pas.fr/hooks/petitspas-deploy`) |
|
||||||
|
| **Déclencheur** | Push sur `master`, dépôt `petitspas` |
|
||||||
|
| **Script** | Monté depuis l’hôte (verrou + sans Prisma) |
|
||||||
|
| **Dernier déploiement** | 08/02/2026 18:18:26 – Succès |
|
||||||
|
|
||||||
|
Un seul déploiement à la fois (verrou) ; plus d’étape Prisma dans le script.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Fonctionnalités livrées
|
||||||
|
|
||||||
|
### Backend (API)
|
||||||
|
|
||||||
|
- Auth : login, refresh, profil, **changement MDP obligatoire** (first login)
|
||||||
|
- Configuration : setup status, bulk, test SMTP, catégories
|
||||||
|
- Documents légaux : actifs, versions, upload, activation, téléchargement
|
||||||
|
- Inscription : parents (workflow complet), enfants (CRUD)
|
||||||
|
- Compte super_admin par défaut (seed BDD) : `admin@ptits-pas.fr` / `4dm1n1strateur`
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
- **Formulaires d’inscription** : compatibles **desktop et mobile**
|
||||||
|
- Choix d’inscription (Parents / Assistante maternelle) – responsive
|
||||||
|
- Inscription Parent : étapes 1 à 5 (infos parent 1 & 2, enfants, présentation, CGU, récap)
|
||||||
|
- Inscription AM : étapes 1 à 4 (identité, pro, présentation, récap)
|
||||||
|
- **Login** : écran adapté mobile (formulaire sous slogan selon ratio)
|
||||||
|
- Modale **changement de mot de passe obligatoire** après première connexion si `changement_mdp_obligatoire`
|
||||||
|
- CORS configuré (localhost + prod)
|
||||||
|
|
||||||
|
### Base de données
|
||||||
|
|
||||||
|
- Schéma database-first (BDD.sql)
|
||||||
|
- Tables : utilisateurs, configuration, documents_legaux, acceptations_documents, enfants, etc.
|
||||||
|
- Champs tokens création MDP, genre enfants, configuration système
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Tickets / Priorités (résumé)
|
||||||
|
|
||||||
|
- **Liste détaillée** : `docs/23_LISTE-TICKETS.md`
|
||||||
|
- **Récent fermé** : #82 (Login mobile), #83 (RegisterChoiceScreen mobile), #73, #78, #79, #81
|
||||||
|
- **P0 (BDD)** : quelques amendements ouverts (champs CDC, présentation dossier, etc.)
|
||||||
|
- **P1** : configuration système (panneau Paramètres, 3 sections, première config + accès permanent)
|
||||||
|
- **P2/P3** : backend métier et frontend (dashboards, écrans création MDP, etc.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Documentation utile
|
||||||
|
|
||||||
|
| Fichier | Usage |
|
||||||
|
|---------|--------|
|
||||||
|
| `00_INDEX.md` | Index de la doc |
|
||||||
|
| `01_CAHIER-DES-CHARGES.md` | CDC v1.3 |
|
||||||
|
| `11_API.md` | Endpoints API |
|
||||||
|
| `20_WORKFLOW-CREATION-COMPTE.md` | Workflow création compte |
|
||||||
|
| `23_LISTE-TICKETS.md` | Liste des tickets |
|
||||||
|
| `BRIEFING-FRONTEND.md` | Brief frontend, accès Git, tickets prioritaires |
|
||||||
|
| `PROCEDURE-API-GITEA.md` | Utilisation API Gitea (issues, PR, token) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Synthèse
|
||||||
|
|
||||||
|
L’application est **en production** sur https://app.ptits-pas.fr avec :
|
||||||
|
|
||||||
|
- Frontend et API accessibles et répondant en 200.
|
||||||
|
- Déploiement automatique sur push `master` avec script à jour (verrou, sans Prisma).
|
||||||
|
- Formulaires d’inscription (Parents et AM) **responsive desktop et mobile**.
|
||||||
|
- Login et changement de mot de passe obligatoire opérationnels.
|
||||||
|
- Prochaines priorités : P0 BDD si besoin, P1 panneau Paramètres / Configuration (tickets #12, #13), puis dashboards et workflows métier (P2/P3).
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# SuperNounou – SSS-001
|
# SuperNounou – SSS-001
|
||||||
## Spécification technique & opérationnelle unifiée
|
## Spécification technique & opérationnelle unifiée
|
||||||
_Version 0.2 – 24 avril 2025_
|
_Version 0.3 – 27 janvier 2026_
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -62,6 +62,13 @@ Collection Postman, scripts cURL, guide « Appeler l’API ».
|
|||||||
### B.4 Intégrations futures
|
### B.4 Intégrations futures
|
||||||
SSO LDAP/SAML, webhook `contract.validated`, export statistiques CSV.
|
SSO LDAP/SAML, webhook `contract.validated`, export statistiques CSV.
|
||||||
|
|
||||||
|
### B.5 Contrat de gestion des comptes d'administration
|
||||||
|
- Création d'un administrateur avec un contrat minimal stable : `nom`, `prenom`, `email`, `password`, `telephone`.
|
||||||
|
- Le rôle n'est jamais fourni par le frontend pour ce flux ; le backend impose `ADMINISTRATEUR`.
|
||||||
|
- Les champs hors périmètre (adresse complète, photo, métadonnées métier non nécessaires) ne sont pas requis.
|
||||||
|
- Les protections d'autorisation restent actives : un `SUPER_ADMIN` n'est pas supprimable et son identité (`nom`, `prenom`) est non modifiable.
|
||||||
|
- Côté interface d'administration, les actions d'édition sont conditionnées aux droits ; les entrées non éditables restent consultables en lecture seule.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# C – Déploiement, CI/CD et Observabilité *(nouveau)*
|
# C – Déploiement, CI/CD et Observabilité *(nouveau)*
|
||||||
@@ -106,3 +113,4 @@ AES-256, JWT, KMS, OpenAPI, RPO, RTO, rate-limit, HMAC, Compose, CI/CD…
|
|||||||
|---------|------------|------------------|---------------------------------|
|
|---------|------------|------------------|---------------------------------|
|
||||||
| 0.1-draft | 2025-04-24 | Équipe projet | Création du SSS unifié |
|
| 0.1-draft | 2025-04-24 | Équipe projet | Création du SSS unifié |
|
||||||
| 0.2 | 2025-04-24 | ChatGPT & Julien | Ajout déploiement / CI/CD / logs |
|
| 0.2 | 2025-04-24 | ChatGPT & Julien | Ajout déploiement / CI/CD / logs |
|
||||||
|
| 0.3 | 2026-01-27 | Équipe projet | Contrat admin harmonisé et règles d'autorisation |
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ import '../screens/auth/am_register_step3_screen.dart';
|
|||||||
import '../screens/auth/am_register_step4_screen.dart';
|
import '../screens/auth/am_register_step4_screen.dart';
|
||||||
import '../screens/home/home_screen.dart';
|
import '../screens/home/home_screen.dart';
|
||||||
import '../screens/administrateurs/admin_dashboardScreen.dart';
|
import '../screens/administrateurs/admin_dashboardScreen.dart';
|
||||||
|
import '../screens/gestionnaire/gestionnaire_dashboard_screen.dart';
|
||||||
import '../screens/home/parent_screen/ParentDashboardScreen.dart';
|
import '../screens/home/parent_screen/ParentDashboardScreen.dart';
|
||||||
|
import '../screens/am/am_dashboard_screen.dart';
|
||||||
|
import '../screens/legal/privacy_page.dart';
|
||||||
|
import '../screens/legal/legal_page.dart';
|
||||||
import '../screens/unknown_screen.dart';
|
import '../screens/unknown_screen.dart';
|
||||||
|
|
||||||
// --- Provider Instances ---
|
// --- Provider Instances ---
|
||||||
@@ -53,13 +57,26 @@ class AppRouter {
|
|||||||
path: '/admin-dashboard',
|
path: '/admin-dashboard',
|
||||||
builder: (BuildContext context, GoRouterState state) => const AdminDashboardScreen(),
|
builder: (BuildContext context, GoRouterState state) => const AdminDashboardScreen(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/gestionnaire-dashboard',
|
||||||
|
builder: (BuildContext context, GoRouterState state) => const GestionnaireDashboardScreen(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/parent-dashboard',
|
path: '/parent-dashboard',
|
||||||
builder: (BuildContext context, GoRouterState state) => const ParentDashboardScreen(),
|
builder: (BuildContext context, GoRouterState state) => const ParentDashboardScreen(),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/am-dashboard',
|
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 ---
|
// --- Parent Registration Flow ---
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
|
|
||||||
|
class AssistanteMaternelleModel {
|
||||||
|
final AppUser user;
|
||||||
|
final String? approvalNumber;
|
||||||
|
final String? residenceCity;
|
||||||
|
final int? maxChildren;
|
||||||
|
final int? placesAvailable;
|
||||||
|
|
||||||
|
AssistanteMaternelleModel({
|
||||||
|
required this.user,
|
||||||
|
this.approvalNumber,
|
||||||
|
this.residenceCity,
|
||||||
|
this.maxChildren,
|
||||||
|
this.placesAvailable,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory AssistanteMaternelleModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
final userJson = json['user'] ?? json;
|
||||||
|
final user = AppUser.fromJson(userJson);
|
||||||
|
|
||||||
|
return AssistanteMaternelleModel(
|
||||||
|
user: user,
|
||||||
|
approvalNumber: json['numero_agrement'] as String?,
|
||||||
|
residenceCity: json['ville_residence'] as String?,
|
||||||
|
maxChildren: json['nb_max_enfants'] as int?,
|
||||||
|
placesAvailable: json['place_disponible'] as int?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
|
|
||||||
|
class ParentModel {
|
||||||
|
final AppUser user;
|
||||||
|
final int childrenCount;
|
||||||
|
|
||||||
|
ParentModel({required this.user, this.childrenCount = 0});
|
||||||
|
|
||||||
|
factory ParentModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
final userJson = json['user'] ?? json;
|
||||||
|
final user = AppUser.fromJson(userJson);
|
||||||
|
final children = json['parentChildren'] as List?;
|
||||||
|
return ParentModel(
|
||||||
|
user: user,
|
||||||
|
childrenCount: children?.length ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
class RelaisModel {
|
||||||
|
final String id;
|
||||||
|
final String nom;
|
||||||
|
final String adresse;
|
||||||
|
final Map<String, dynamic>? horairesOuverture;
|
||||||
|
final String? ligneFixe;
|
||||||
|
final bool actif;
|
||||||
|
final String? notes;
|
||||||
|
|
||||||
|
const RelaisModel({
|
||||||
|
required this.id,
|
||||||
|
required this.nom,
|
||||||
|
required this.adresse,
|
||||||
|
this.horairesOuverture,
|
||||||
|
this.ligneFixe,
|
||||||
|
required this.actif,
|
||||||
|
this.notes,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory RelaisModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return RelaisModel(
|
||||||
|
id: (json['id'] ?? '').toString(),
|
||||||
|
nom: (json['nom'] ?? '').toString(),
|
||||||
|
adresse: (json['adresse'] ?? '').toString(),
|
||||||
|
horairesOuverture: json['horaires_ouverture'] is Map<String, dynamic>
|
||||||
|
? json['horaires_ouverture'] as Map<String, dynamic>
|
||||||
|
: null,
|
||||||
|
ligneFixe: json['ligne_fixe'] as String?,
|
||||||
|
actif: json['actif'] as bool? ?? true,
|
||||||
|
notes: json['notes'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,16 @@ class AppUser {
|
|||||||
final DateTime createdAt;
|
final DateTime createdAt;
|
||||||
final DateTime updatedAt;
|
final DateTime updatedAt;
|
||||||
final bool changementMdpObligatoire;
|
final bool changementMdpObligatoire;
|
||||||
|
final String? nom;
|
||||||
|
final String? prenom;
|
||||||
|
final String? statut;
|
||||||
|
final String? telephone;
|
||||||
|
final String? photoUrl;
|
||||||
|
final String? adresse;
|
||||||
|
final String? ville;
|
||||||
|
final String? codePostal;
|
||||||
|
final String? relaisId;
|
||||||
|
final String? relaisNom;
|
||||||
|
|
||||||
AppUser({
|
AppUser({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -13,20 +23,50 @@ class AppUser {
|
|||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
required this.updatedAt,
|
required this.updatedAt,
|
||||||
this.changementMdpObligatoire = false,
|
this.changementMdpObligatoire = false,
|
||||||
|
this.nom,
|
||||||
|
this.prenom,
|
||||||
|
this.statut,
|
||||||
|
this.telephone,
|
||||||
|
this.photoUrl,
|
||||||
|
this.adresse,
|
||||||
|
this.ville,
|
||||||
|
this.codePostal,
|
||||||
|
this.relaisId,
|
||||||
|
this.relaisNom,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory AppUser.fromJson(Map<String, dynamic> json) {
|
factory AppUser.fromJson(Map<String, dynamic> json) {
|
||||||
|
final relaisJson = json['relais'];
|
||||||
|
final relaisMap =
|
||||||
|
relaisJson is Map<String, dynamic> ? relaisJson : <String, dynamic>{};
|
||||||
|
|
||||||
return AppUser(
|
return AppUser(
|
||||||
id: json['id'] as String,
|
id: (json['id'] as String?) ?? '',
|
||||||
email: json['email'] as String,
|
email: (json['email'] as String?) ?? '',
|
||||||
role: json['role'] as String,
|
role: (json['role'] as String?) ?? '',
|
||||||
createdAt: json['createdAt'] != null
|
createdAt: json['cree_le'] != null
|
||||||
? DateTime.parse(json['createdAt'] as String)
|
? DateTime.parse(json['cree_le'] as String)
|
||||||
: DateTime.now(),
|
: (json['createdAt'] != null
|
||||||
updatedAt: json['updatedAt'] != null
|
? DateTime.parse(json['createdAt'] as String)
|
||||||
? DateTime.parse(json['updatedAt'] as String)
|
: DateTime.now()),
|
||||||
: DateTime.now(),
|
updatedAt: json['modifie_le'] != null
|
||||||
changementMdpObligatoire: json['changement_mdp_obligatoire'] as bool? ?? false,
|
? DateTime.parse(json['modifie_le'] as String)
|
||||||
|
: (json['updatedAt'] != null
|
||||||
|
? DateTime.parse(json['updatedAt'] as String)
|
||||||
|
: DateTime.now()),
|
||||||
|
changementMdpObligatoire:
|
||||||
|
json['changement_mdp_obligatoire'] as bool? ?? false,
|
||||||
|
nom: json['nom'] as String?,
|
||||||
|
prenom: json['prenom'] as String?,
|
||||||
|
statut: json['statut'] as String?,
|
||||||
|
telephone: json['telephone'] as String?,
|
||||||
|
photoUrl: json['photo_url'] as String?,
|
||||||
|
adresse: json['adresse'] as String?,
|
||||||
|
ville: json['ville'] as String?,
|
||||||
|
codePostal: json['code_postal'] as String?,
|
||||||
|
relaisId: (json['relaisId'] ?? json['relais_id'] ?? relaisMap['id'])
|
||||||
|
?.toString(),
|
||||||
|
relaisNom: relaisMap['nom']?.toString(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +78,18 @@ class AppUser {
|
|||||||
'createdAt': createdAt.toIso8601String(),
|
'createdAt': createdAt.toIso8601String(),
|
||||||
'updatedAt': updatedAt.toIso8601String(),
|
'updatedAt': updatedAt.toIso8601String(),
|
||||||
'changement_mdp_obligatoire': changementMdpObligatoire,
|
'changement_mdp_obligatoire': changementMdpObligatoire,
|
||||||
|
'nom': nom,
|
||||||
|
'prenom': prenom,
|
||||||
|
'statut': statut,
|
||||||
|
'telephone': telephone,
|
||||||
|
'photo_url': photoUrl,
|
||||||
|
'adresse': adresse,
|
||||||
|
'ville': ville,
|
||||||
|
'code_postal': codePostal,
|
||||||
|
'relais_id': relaisId,
|
||||||
|
'relais_nom': relaisNom,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String get fullName => '${prenom ?? ''} ${nom ?? ''}'.trim();
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
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/services/configuration_service.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart';
|
|
||||||
import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart';
|
|
||||||
import 'package:p_tits_pas/widgets/admin/parent_managmant_widget.dart';
|
|
||||||
import 'package:p_tits_pas/widgets/admin/parametres_panel.dart';
|
|
||||||
import 'package:p_tits_pas/widgets/app_footer.dart';
|
|
||||||
import 'package:p_tits_pas/widgets/admin/dashboard_admin.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 {
|
class AdminDashboardScreen extends StatefulWidget {
|
||||||
const AdminDashboardScreen({super.key});
|
const AdminDashboardScreen({super.key});
|
||||||
@@ -16,8 +17,9 @@ class AdminDashboardScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
|
class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
|
||||||
bool? _setupCompleted;
|
bool? _setupCompleted;
|
||||||
|
AppUser? _user;
|
||||||
int mainTabIndex = 0;
|
int mainTabIndex = 0;
|
||||||
int subIndex = 0;
|
int settingsSubIndex = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -25,19 +27,28 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
|
|||||||
_loadSetupStatus();
|
_loadSetupStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadSetupStatus() async {
|
Future<void> _loadSetupStatus() async {
|
||||||
try {
|
try {
|
||||||
final completed = await ConfigurationService.getSetupStatus();
|
final completed = await ConfigurationService.getSetupStatus();
|
||||||
|
final user = await AuthService.getCurrentUser();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_setupCompleted = completed;
|
_setupCompleted = completed;
|
||||||
|
_user = user;
|
||||||
if (!completed) mainTabIndex = 1;
|
if (!completed) mainTabIndex = 1;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) setState(() {
|
if (mounted) {
|
||||||
_setupCompleted = false;
|
setState(() {
|
||||||
mainTabIndex = 1;
|
_setupCompleted = false;
|
||||||
});
|
mainTabIndex = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,9 +58,9 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void onSubTabChange(int index) {
|
void onSettingsSubTabChange(int index) {
|
||||||
setState(() {
|
setState(() {
|
||||||
subIndex = index;
|
settingsSubIndex = index;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,25 +74,39 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: PreferredSize(
|
appBar: PreferredSize(
|
||||||
preferredSize: const Size.fromHeight(60.0),
|
preferredSize: const Size.fromHeight(60.0),
|
||||||
child: Container(
|
child: DashboardBandeau(
|
||||||
decoration: BoxDecoration(
|
tabItems: [
|
||||||
border: Border(
|
DashboardTabItem(
|
||||||
bottom: BorderSide(color: Colors.grey.shade300),
|
label: 'Gestion des utilisateurs',
|
||||||
|
enabled: _setupCompleted!,
|
||||||
),
|
),
|
||||||
),
|
const DashboardTabItem(label: 'Paramètres'),
|
||||||
child: DashboardAppBarAdmin(
|
],
|
||||||
selectedIndex: mainTabIndex,
|
selectedTabIndex: mainTabIndex,
|
||||||
onTabChange: onMainTabChange,
|
onTabSelected: onMainTabChange,
|
||||||
setupCompleted: _setupCompleted!,
|
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(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
if (mainTabIndex == 0)
|
if (mainTabIndex == 0)
|
||||||
DashboardUserManagementSubBar(
|
const SizedBox.shrink()
|
||||||
selectedSubIndex: subIndex,
|
else
|
||||||
onSubTabChange: onSubTabChange,
|
DashboardSettingsSubBar(
|
||||||
|
selectedSubIndex: settingsSubIndex,
|
||||||
|
onSubTabChange: onSettingsSubTabChange,
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _getBody(),
|
child: _getBody(),
|
||||||
@@ -94,19 +119,11 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
|
|||||||
|
|
||||||
Widget _getBody() {
|
Widget _getBody() {
|
||||||
if (mainTabIndex == 1) {
|
if (mainTabIndex == 1) {
|
||||||
return ParametresPanel(redirectToLoginAfterSave: !_setupCompleted!);
|
return ParametresPanel(
|
||||||
}
|
redirectToLoginAfterSave: !_setupCompleted!,
|
||||||
switch (subIndex) {
|
selectedSettingsTabIndex: settingsSubIndex,
|
||||||
case 0:
|
);
|
||||||
return const GestionnaireManagementWidget();
|
|
||||||
case 1:
|
|
||||||
return const ParentManagementWidget();
|
|
||||||
case 2:
|
|
||||||
return const AssistanteMaternelleManagementWidget();
|
|
||||||
case 3:
|
|
||||||
return const Center(child: Text('👨💼 Administrateurs'));
|
|
||||||
default:
|
|
||||||
return const Center(child: Text('Page non trouvée'));
|
|
||||||
}
|
}
|
||||||
|
return const UserManagementPanel();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,357 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
|
|
||||||
|
class AdminCreateDialog extends StatefulWidget {
|
||||||
|
final AppUser? initialUser;
|
||||||
|
|
||||||
|
const AdminCreateDialog({
|
||||||
|
super.key,
|
||||||
|
this.initialUser,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AdminCreateDialog> createState() => _AdminCreateDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AdminCreateDialogState extends State<AdminCreateDialog> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _nomController = TextEditingController();
|
||||||
|
final _prenomController = TextEditingController();
|
||||||
|
final _emailController = TextEditingController();
|
||||||
|
final _passwordController = TextEditingController();
|
||||||
|
final _telephoneController = TextEditingController();
|
||||||
|
|
||||||
|
bool _isSubmitting = false;
|
||||||
|
bool _obscurePassword = true;
|
||||||
|
bool get _isEditMode => widget.initialUser != null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final user = widget.initialUser;
|
||||||
|
if (user != null) {
|
||||||
|
_nomController.text = user.nom ?? '';
|
||||||
|
_prenomController.text = user.prenom ?? '';
|
||||||
|
_emailController.text = user.email;
|
||||||
|
_telephoneController.text = user.telephone ?? '';
|
||||||
|
// En édition, on ne préremplit jamais le mot de passe.
|
||||||
|
_passwordController.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_nomController.dispose();
|
||||||
|
_prenomController.dispose();
|
||||||
|
_emailController.dispose();
|
||||||
|
_passwordController.dispose();
|
||||||
|
_telephoneController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _required(String? value, String field) {
|
||||||
|
if (value == null || value.trim().isEmpty) {
|
||||||
|
return '$field est requis';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _validateEmail(String? value) {
|
||||||
|
final base = _required(value, 'Email');
|
||||||
|
if (base != null) return base;
|
||||||
|
final email = value!.trim();
|
||||||
|
final ok = RegExp(r'^[^@]+@[^@]+\.[^@]+$').hasMatch(email);
|
||||||
|
if (!ok) return 'Format email invalide';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _validatePassword(String? value) {
|
||||||
|
if (_isEditMode && (value == null || value.trim().isEmpty)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final base = _required(value, 'Mot de passe');
|
||||||
|
if (base != null) return base;
|
||||||
|
if (value!.trim().length < 6) return 'Minimum 6 caractères';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (_isSubmitting) return;
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isSubmitting = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (_isEditMode) {
|
||||||
|
await UserService.updateAdmin(
|
||||||
|
adminId: widget.initialUser!.id,
|
||||||
|
nom: _nomController.text.trim(),
|
||||||
|
prenom: _prenomController.text.trim(),
|
||||||
|
email: _emailController.text.trim(),
|
||||||
|
telephone: _telephoneController.text.trim(),
|
||||||
|
password: _passwordController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: _passwordController.text,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await UserService.createAdmin(
|
||||||
|
nom: _nomController.text.trim(),
|
||||||
|
prenom: _prenomController.text.trim(),
|
||||||
|
email: _emailController.text.trim(),
|
||||||
|
password: _passwordController.text,
|
||||||
|
telephone: _telephoneController.text.trim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
_isEditMode
|
||||||
|
? 'Administrateur modifié avec succès.'
|
||||||
|
: 'Administrateur créé avec succès.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Navigator.of(context).pop(true);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
e.toString().replaceFirst('Exception: ', ''),
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.red.shade700,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isSubmitting = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _delete() async {
|
||||||
|
if (!_isEditMode || _isSubmitting) return;
|
||||||
|
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Confirmer la suppression'),
|
||||||
|
content: Text(
|
||||||
|
'Supprimer ${widget.initialUser!.fullName.isEmpty ? widget.initialUser!.email : widget.initialUser!.fullName} ?',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(false),
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(true),
|
||||||
|
style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700),
|
||||||
|
child: const Text('Supprimer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed != true) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isSubmitting = true;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await UserService.deleteUser(widget.initialUser!.id);
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Administrateur supprimé.')),
|
||||||
|
);
|
||||||
|
Navigator.of(context).pop(true);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(e.toString().replaceFirst('Exception: ', '')),
|
||||||
|
backgroundColor: Colors.red.shade700,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setState(() {
|
||||||
|
_isSubmitting = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
_isEditMode
|
||||||
|
? 'Modifier un administrateur'
|
||||||
|
: 'Créer un administrateur',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_isEditMode)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
tooltip: 'Fermer',
|
||||||
|
onPressed: _isSubmitting
|
||||||
|
? null
|
||||||
|
: () => Navigator.of(context).pop(false),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 620,
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: _buildNomField()),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(child: _buildPrenomField()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildEmailField(),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: _buildPasswordField()),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(child: _buildTelephoneField()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
if (_isEditMode) ...[
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: _isSubmitting ? null : _delete,
|
||||||
|
style: OutlinedButton.styleFrom(foregroundColor: Colors.red.shade700),
|
||||||
|
child: const Text('Supprimer'),
|
||||||
|
),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _isSubmitting ? null : _submit,
|
||||||
|
icon: _isSubmitting
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.edit),
|
||||||
|
label: Text(_isSubmitting ? 'Modification...' : 'Modifier'),
|
||||||
|
),
|
||||||
|
] else ...[
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed:
|
||||||
|
_isSubmitting ? null : () => Navigator.of(context).pop(false),
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _isSubmitting ? null : _submit,
|
||||||
|
icon: _isSubmitting
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.person_add_alt_1),
|
||||||
|
label: Text(_isSubmitting ? 'Création...' : 'Créer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNomField() {
|
||||||
|
return TextFormField(
|
||||||
|
controller: _nomController,
|
||||||
|
textCapitalization: TextCapitalization.words,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Nom',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
validator: (v) => _required(v, 'Nom'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPrenomField() {
|
||||||
|
return TextFormField(
|
||||||
|
controller: _prenomController,
|
||||||
|
textCapitalization: TextCapitalization.words,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Prénom',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
validator: (v) => _required(v, 'Prénom'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmailField() {
|
||||||
|
return TextFormField(
|
||||||
|
controller: _emailController,
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Email',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
validator: _validateEmail,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPasswordField() {
|
||||||
|
return TextFormField(
|
||||||
|
controller: _passwordController,
|
||||||
|
obscureText: _obscurePassword,
|
||||||
|
enableSuggestions: false,
|
||||||
|
autocorrect: false,
|
||||||
|
autofillHints: _isEditMode
|
||||||
|
? const <String>[]
|
||||||
|
: const [AutofillHints.newPassword],
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: _isEditMode
|
||||||
|
? 'Nouveau mot de passe'
|
||||||
|
: 'Mot de passe',
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_obscurePassword = !_obscurePassword;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
icon: Icon(
|
||||||
|
_obscurePassword ? Icons.visibility_off : Icons.visibility,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
validator: _validatePassword,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTelephoneField() {
|
||||||
|
return TextFormField(
|
||||||
|
controller: _telephoneController,
|
||||||
|
keyboardType: TextInputType.phone,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Téléphone',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
validator: (v) => _required(v, 'Téléphone'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,688 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:p_tits_pas/models/relais_model.dart';
|
||||||
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
|
import 'package:p_tits_pas/services/relais_service.dart';
|
||||||
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
|
|
||||||
class GestionnairesCreate extends StatelessWidget {
|
class AdminUserFormDialog extends StatefulWidget {
|
||||||
const GestionnairesCreate({super.key});
|
final AppUser? initialUser;
|
||||||
|
final bool withRelais;
|
||||||
|
final bool adminMode;
|
||||||
|
final bool readOnly;
|
||||||
|
|
||||||
|
const AdminUserFormDialog({
|
||||||
|
super.key,
|
||||||
|
this.initialUser,
|
||||||
|
this.withRelais = true,
|
||||||
|
this.adminMode = false,
|
||||||
|
this.readOnly = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AdminUserFormDialog> createState() => _AdminUserFormDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _nomController = TextEditingController();
|
||||||
|
final _prenomController = TextEditingController();
|
||||||
|
final _emailController = TextEditingController();
|
||||||
|
final _passwordController = TextEditingController();
|
||||||
|
final _telephoneController = TextEditingController();
|
||||||
|
final _passwordToggleFocusNode =
|
||||||
|
FocusNode(skipTraversal: true, canRequestFocus: false);
|
||||||
|
|
||||||
|
bool _isSubmitting = false;
|
||||||
|
bool _obscurePassword = true;
|
||||||
|
bool _isLoadingRelais = true;
|
||||||
|
List<RelaisModel> _relais = [];
|
||||||
|
String? _selectedRelaisId;
|
||||||
|
bool get _isEditMode => widget.initialUser != null;
|
||||||
|
bool get _isSuperAdminTarget =>
|
||||||
|
widget.initialUser?.role.toLowerCase() == 'super_admin';
|
||||||
|
bool get _isLockedAdminIdentity =>
|
||||||
|
_isEditMode && widget.adminMode && _isSuperAdminTarget;
|
||||||
|
String get _targetRoleKey {
|
||||||
|
if (widget.initialUser != null) {
|
||||||
|
return widget.initialUser!.role.toLowerCase();
|
||||||
|
}
|
||||||
|
return widget.adminMode ? 'administrateur' : 'gestionnaire';
|
||||||
|
}
|
||||||
|
|
||||||
|
String get _targetRoleLabel {
|
||||||
|
switch (_targetRoleKey) {
|
||||||
|
case 'super_admin':
|
||||||
|
return 'Super administrateur';
|
||||||
|
case 'administrateur':
|
||||||
|
return 'Administrateur';
|
||||||
|
case 'gestionnaire':
|
||||||
|
return 'Gestionnaire';
|
||||||
|
case 'assistante_maternelle':
|
||||||
|
return 'Assistante maternelle';
|
||||||
|
case 'parent':
|
||||||
|
return 'Parent';
|
||||||
|
default:
|
||||||
|
return 'Utilisateur';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData get _targetRoleIcon {
|
||||||
|
switch (_targetRoleKey) {
|
||||||
|
case 'super_admin':
|
||||||
|
return Icons.verified_user_outlined;
|
||||||
|
case 'administrateur':
|
||||||
|
return Icons.admin_panel_settings_outlined;
|
||||||
|
case 'gestionnaire':
|
||||||
|
return Icons.assignment_ind_outlined;
|
||||||
|
case 'assistante_maternelle':
|
||||||
|
return Icons.child_care_outlined;
|
||||||
|
case 'parent':
|
||||||
|
return Icons.supervisor_account_outlined;
|
||||||
|
default:
|
||||||
|
return Icons.person_outline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final user = widget.initialUser;
|
||||||
|
if (user != null) {
|
||||||
|
_nomController.text = user.nom ?? '';
|
||||||
|
_prenomController.text = user.prenom ?? '';
|
||||||
|
_emailController.text = user.email;
|
||||||
|
_telephoneController.text = _formatPhoneForDisplay(user.telephone ?? '');
|
||||||
|
// En édition, on ne préremplit jamais le mot de passe.
|
||||||
|
_passwordController.clear();
|
||||||
|
final initialRelaisId = user.relaisId?.trim();
|
||||||
|
_selectedRelaisId =
|
||||||
|
(initialRelaisId == null || initialRelaisId.isEmpty)
|
||||||
|
? null
|
||||||
|
: initialRelaisId;
|
||||||
|
}
|
||||||
|
if (widget.withRelais) {
|
||||||
|
_loadRelais();
|
||||||
|
} else {
|
||||||
|
_isLoadingRelais = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_nomController.dispose();
|
||||||
|
_prenomController.dispose();
|
||||||
|
_emailController.dispose();
|
||||||
|
_passwordController.dispose();
|
||||||
|
_telephoneController.dispose();
|
||||||
|
_passwordToggleFocusNode.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadRelais() async {
|
||||||
|
try {
|
||||||
|
final list = await RelaisService.getRelais();
|
||||||
|
if (!mounted) return;
|
||||||
|
final uniqueById = <String, RelaisModel>{};
|
||||||
|
for (final relais in list) {
|
||||||
|
uniqueById[relais.id] = relais;
|
||||||
|
}
|
||||||
|
|
||||||
|
final filtered = uniqueById.values.where((r) => r.actif).toList();
|
||||||
|
if (_selectedRelaisId != null &&
|
||||||
|
!filtered.any((r) => r.id == _selectedRelaisId)) {
|
||||||
|
final selected = uniqueById[_selectedRelaisId!];
|
||||||
|
if (selected != null) {
|
||||||
|
filtered.add(selected);
|
||||||
|
} else {
|
||||||
|
_selectedRelaisId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_relais = filtered;
|
||||||
|
_isLoadingRelais = false;
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_selectedRelaisId = null;
|
||||||
|
_relais = [];
|
||||||
|
_isLoadingRelais = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _required(String? value, String field) {
|
||||||
|
if (value == null || value.trim().isEmpty) {
|
||||||
|
return '$field est requis';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _validateEmail(String? value) {
|
||||||
|
final base = _required(value, 'Email');
|
||||||
|
if (base != null) return base;
|
||||||
|
final email = value!.trim();
|
||||||
|
final ok = RegExp(r'^[^@]+@[^@]+\.[^@]+$').hasMatch(email);
|
||||||
|
if (!ok) return 'Format email invalide';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _validatePassword(String? value) {
|
||||||
|
if (_isEditMode && (value == null || value.trim().isEmpty)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final base = _required(value, 'Mot de passe');
|
||||||
|
if (base != null) return base;
|
||||||
|
if (value!.trim().length < 6) return 'Minimum 6 caractères';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _validatePhone(String? value) {
|
||||||
|
if (_isEditMode && (value == null || value.trim().isEmpty)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final base = _required(value, 'Téléphone');
|
||||||
|
if (base != null) return base;
|
||||||
|
final digits = _normalizePhone(value!);
|
||||||
|
if (digits.length != 10) {
|
||||||
|
return 'Le téléphone doit contenir 10 chiffres';
|
||||||
|
}
|
||||||
|
if (!digits.startsWith('0')) {
|
||||||
|
return 'Le téléphone doit commencer par 0';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _normalizePhone(String raw) {
|
||||||
|
return raw.replaceAll(RegExp(r'\D'), '');
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatPhoneForDisplay(String raw) {
|
||||||
|
final normalized = _normalizePhone(raw);
|
||||||
|
final digits =
|
||||||
|
normalized.length > 10 ? normalized.substring(0, 10) : normalized;
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
for (var i = 0; i < digits.length; i++) {
|
||||||
|
if (i > 0 && i.isEven) buffer.write(' ');
|
||||||
|
buffer.write(digits[i]);
|
||||||
|
}
|
||||||
|
return buffer.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _toTitleCase(String raw) {
|
||||||
|
final trimmed = raw.trim();
|
||||||
|
if (trimmed.isEmpty) return trimmed;
|
||||||
|
final words = trimmed.split(RegExp(r'\s+'));
|
||||||
|
final normalizedWords = words.map(_capitalizeComposedWord).toList();
|
||||||
|
return normalizedWords.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
String _capitalizeComposedWord(String word) {
|
||||||
|
if (word.isEmpty) return word;
|
||||||
|
final lower = word.toLowerCase();
|
||||||
|
final separators = <String>{"-", "'", "’"};
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
var capitalizeNext = true;
|
||||||
|
|
||||||
|
for (var i = 0; i < lower.length; i++) {
|
||||||
|
final char = lower[i];
|
||||||
|
if (capitalizeNext && RegExp(r'[a-zà-öø-ÿ]').hasMatch(char)) {
|
||||||
|
buffer.write(char.toUpperCase());
|
||||||
|
capitalizeNext = false;
|
||||||
|
} else {
|
||||||
|
buffer.write(char);
|
||||||
|
capitalizeNext = separators.contains(char);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buffer.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (widget.readOnly) return;
|
||||||
|
if (_isSubmitting) return;
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isSubmitting = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final normalizedNom = _toTitleCase(_nomController.text);
|
||||||
|
final normalizedPrenom = _toTitleCase(_prenomController.text);
|
||||||
|
final normalizedPhone = _normalizePhone(_telephoneController.text);
|
||||||
|
final passwordProvided = _passwordController.text.trim().isNotEmpty;
|
||||||
|
|
||||||
|
if (_isEditMode) {
|
||||||
|
if (widget.adminMode) {
|
||||||
|
final lockedNom = _toTitleCase(widget.initialUser!.nom ?? '');
|
||||||
|
final lockedPrenom = _toTitleCase(widget.initialUser!.prenom ?? '');
|
||||||
|
await UserService.updateAdministrateur(
|
||||||
|
adminId: widget.initialUser!.id,
|
||||||
|
nom: _isLockedAdminIdentity ? lockedNom : normalizedNom,
|
||||||
|
prenom: _isLockedAdminIdentity ? lockedPrenom : normalizedPrenom,
|
||||||
|
email: _emailController.text.trim(),
|
||||||
|
telephone: normalizedPhone.isEmpty
|
||||||
|
? _normalizePhone(widget.initialUser!.telephone ?? '')
|
||||||
|
: normalizedPhone,
|
||||||
|
password: passwordProvided ? _passwordController.text : null,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
final currentUser = widget.initialUser!;
|
||||||
|
final initialNom = _toTitleCase(currentUser.nom ?? '');
|
||||||
|
final initialPrenom = _toTitleCase(currentUser.prenom ?? '');
|
||||||
|
final initialEmail = currentUser.email.trim();
|
||||||
|
final initialPhone = _normalizePhone(currentUser.telephone ?? '');
|
||||||
|
|
||||||
|
final onlyRelaisChanged =
|
||||||
|
normalizedNom == initialNom &&
|
||||||
|
normalizedPrenom == initialPrenom &&
|
||||||
|
_emailController.text.trim() == initialEmail &&
|
||||||
|
normalizedPhone == initialPhone &&
|
||||||
|
!passwordProvided;
|
||||||
|
|
||||||
|
if (onlyRelaisChanged) {
|
||||||
|
await UserService.updateGestionnaireRelais(
|
||||||
|
gestionnaireId: currentUser.id,
|
||||||
|
relaisId: _selectedRelaisId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await UserService.updateGestionnaire(
|
||||||
|
gestionnaireId: currentUser.id,
|
||||||
|
nom: normalizedNom,
|
||||||
|
prenom: normalizedPrenom,
|
||||||
|
email: _emailController.text.trim(),
|
||||||
|
telephone: normalizedPhone.isEmpty ? initialPhone : normalizedPhone,
|
||||||
|
relaisId: _selectedRelaisId,
|
||||||
|
password: passwordProvided ? _passwordController.text : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (widget.adminMode) {
|
||||||
|
await UserService.createAdministrateur(
|
||||||
|
nom: normalizedNom,
|
||||||
|
prenom: normalizedPrenom,
|
||||||
|
email: _emailController.text.trim(),
|
||||||
|
password: _passwordController.text,
|
||||||
|
telephone: _normalizePhone(_telephoneController.text),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await UserService.createGestionnaire(
|
||||||
|
nom: normalizedNom,
|
||||||
|
prenom: normalizedPrenom,
|
||||||
|
email: _emailController.text.trim(),
|
||||||
|
password: _passwordController.text,
|
||||||
|
telephone: _normalizePhone(_telephoneController.text),
|
||||||
|
relaisId: _selectedRelaisId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
_isEditMode
|
||||||
|
? (widget.adminMode
|
||||||
|
? 'Administrateur modifié avec succès.'
|
||||||
|
: 'Gestionnaire modifié avec succès.')
|
||||||
|
: (widget.adminMode
|
||||||
|
? 'Administrateur créé avec succès.'
|
||||||
|
: 'Gestionnaire créé avec succès.'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Navigator.of(context).pop(true);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
e.toString().replaceFirst('Exception: ', ''),
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.red.shade700,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isSubmitting = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _delete() async {
|
||||||
|
if (widget.readOnly) return;
|
||||||
|
if (_isSuperAdminTarget) return;
|
||||||
|
if (!_isEditMode || _isSubmitting) return;
|
||||||
|
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Confirmer la suppression'),
|
||||||
|
content: Text(
|
||||||
|
'Supprimer ${widget.initialUser!.fullName.isEmpty ? widget.initialUser!.email : widget.initialUser!.fullName} ?',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(false),
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(true),
|
||||||
|
style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700),
|
||||||
|
child: const Text('Supprimer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed != true) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isSubmitting = true;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await UserService.deleteUser(widget.initialUser!.id);
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Gestionnaire supprimé.')),
|
||||||
|
);
|
||||||
|
Navigator.of(context).pop(true);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(e.toString().replaceFirst('Exception: ', '')),
|
||||||
|
backgroundColor: Colors.red.shade700,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setState(() {
|
||||||
|
_isSubmitting = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return AlertDialog(
|
||||||
appBar: AppBar(
|
title: Row(
|
||||||
title: const Text('Créer un gestionnaire'),
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
radius: 16,
|
||||||
|
backgroundColor: const Color(0xFFEDE5FA),
|
||||||
|
child: Icon(
|
||||||
|
_targetRoleIcon,
|
||||||
|
size: 20,
|
||||||
|
color: const Color(0xFF6B3FA0),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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(
|
content: SizedBox(
|
||||||
child: Text('Formulaire de création de gestionnaire'),
|
width: 620,
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: _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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ import 'package:provider/provider.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../models/am_registration_data.dart';
|
import '../../models/am_registration_data.dart';
|
||||||
import '../../utils/data_generator.dart';
|
|
||||||
import '../../widgets/personal_info_form_screen.dart';
|
import '../../widgets/personal_info_form_screen.dart';
|
||||||
import '../../models/card_assets.dart';
|
import '../../models/card_assets.dart';
|
||||||
|
|
||||||
@@ -14,19 +13,17 @@ class AmRegisterStep1Screen extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final registrationData = Provider.of<AmRegistrationData>(context, listen: false);
|
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;
|
PersonalInfoData initialData;
|
||||||
if (registrationData.firstName.isEmpty) {
|
if (registrationData.firstName.isEmpty) {
|
||||||
final genFirstName = DataGenerator.firstName();
|
|
||||||
final genLastName = DataGenerator.lastName();
|
|
||||||
initialData = PersonalInfoData(
|
initialData = PersonalInfoData(
|
||||||
firstName: genFirstName,
|
firstName: 'Marie',
|
||||||
lastName: genLastName,
|
lastName: 'DUBOIS',
|
||||||
phone: DataGenerator.phone(),
|
phone: '0696345678',
|
||||||
email: DataGenerator.email(genFirstName, genLastName),
|
email: 'marie.dubois@ptits-pas.fr',
|
||||||
address: DataGenerator.address(),
|
address: '25 Rue de la République',
|
||||||
postalCode: DataGenerator.postalCode(),
|
postalCode: '95870',
|
||||||
city: DataGenerator.city(),
|
city: 'Bezons',
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
initialData = PersonalInfoData(
|
initialData = PersonalInfoData(
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import 'dart:io';
|
|||||||
|
|
||||||
import '../../models/am_registration_data.dart';
|
import '../../models/am_registration_data.dart';
|
||||||
import '../../models/card_assets.dart';
|
import '../../models/card_assets.dart';
|
||||||
import '../../utils/data_generator.dart';
|
|
||||||
import '../../widgets/professional_info_form_screen.dart';
|
import '../../widgets/professional_info_form_screen.dart';
|
||||||
|
|
||||||
class AmRegisterStep2Screen extends StatefulWidget {
|
class AmRegisterStep2Screen extends StatefulWidget {
|
||||||
@@ -54,17 +53,17 @@ class _AmRegisterStep2ScreenState extends State<AmRegisterStep2Screen> {
|
|||||||
capacity: registrationData.capacity,
|
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) {
|
if (registrationData.dateOfBirth == null && registrationData.nir.isEmpty) {
|
||||||
initialData = ProfessionalInfoData(
|
initialData = ProfessionalInfoData(
|
||||||
photoPath: 'assets/images/icon_assmat.png',
|
photoPath: 'assets/images/icon_assmat.png',
|
||||||
photoConsent: true,
|
photoConsent: true,
|
||||||
dateOfBirth: DateTime(1985, 3, 15),
|
dateOfBirth: DateTime(1980, 6, 8),
|
||||||
birthCity: DataGenerator.city(),
|
birthCity: 'Bezons',
|
||||||
birthCountry: 'France',
|
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')}',
|
nir: '280062A00100191',
|
||||||
agrementNumber: 'AM${DataGenerator.randomIntInRange(10000, 100000)}',
|
agrementNumber: 'AGR-2019-095001',
|
||||||
capacity: DataGenerator.randomIntInRange(1, 5),
|
capacity: 4,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,12 +13,12 @@ class AmRegisterStep3Screen extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final data = Provider.of<AmRegistrationData>(context, listen: false);
|
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;
|
String initialText = data.presentationText;
|
||||||
bool initialCgu = data.cguAccepted;
|
bool initialCgu = data.cguAccepted;
|
||||||
|
|
||||||
if (initialText.isEmpty) {
|
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;
|
initialCgu = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'dart:math' as math;
|
|||||||
import '../../models/am_registration_data.dart';
|
import '../../models/am_registration_data.dart';
|
||||||
import '../../models/card_assets.dart';
|
import '../../models/card_assets.dart';
|
||||||
import '../../config/display_config.dart';
|
import '../../config/display_config.dart';
|
||||||
|
import '../../services/auth_service.dart';
|
||||||
import '../../widgets/hover_relief_widget.dart';
|
import '../../widgets/hover_relief_widget.dart';
|
||||||
import '../../widgets/image_button.dart';
|
import '../../widgets/image_button.dart';
|
||||||
import '../../widgets/custom_navigation_button.dart';
|
import '../../widgets/custom_navigation_button.dart';
|
||||||
@@ -22,6 +23,28 @@ class AmRegisterStep4Screen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _AmRegisterStep4ScreenState extends State<AmRegisterStep4Screen> {
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final registrationData = Provider.of<AmRegistrationData>(context);
|
final registrationData = Provider.of<AmRegistrationData>(context);
|
||||||
@@ -90,12 +113,9 @@ class _AmRegisterStep4ScreenState extends State<AmRegisterStep4Screen> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: HoverReliefWidget(
|
child: HoverReliefWidget(
|
||||||
child: CustomNavigationButton(
|
child: CustomNavigationButton(
|
||||||
text: 'Soumettre',
|
text: _isSubmitting ? 'Envoi...' : 'Soumettre',
|
||||||
style: NavigationButtonStyle.green,
|
style: NavigationButtonStyle.green,
|
||||||
onPressed: () {
|
onPressed: () => _submitAMRegistration(registrationData),
|
||||||
print("Données AM finales: ${registrationData.firstName} ${registrationData.lastName}");
|
|
||||||
_showConfirmationModal(context);
|
|
||||||
},
|
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 50,
|
height: 50,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
@@ -106,17 +126,14 @@ class _AmRegisterStep4ScreenState extends State<AmRegisterStep4Screen> {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
ImageButton(
|
ImageButton(
|
||||||
bg: 'assets/images/bg_green.png',
|
bg: 'assets/images/bg_green.png',
|
||||||
text: 'Soumettre ma demande',
|
text: _isSubmitting ? 'Envoi...' : 'Soumettre ma demande',
|
||||||
textColor: const Color(0xFF2D6A4F),
|
textColor: const Color(0xFF2D6A4F),
|
||||||
width: 350,
|
width: 350,
|
||||||
height: 50,
|
height: 50,
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
onPressed: () {
|
onPressed: () => _submitAMRegistration(registrationData),
|
||||||
print("Données AM finales: ${registrationData.firstName} ${registrationData.lastName}");
|
|
||||||
_showConfirmationModal(context);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
@@ -63,6 +64,11 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _handlePasswordSubmitted(String _) {
|
||||||
|
if (_isLoading) return;
|
||||||
|
_handleLogin();
|
||||||
|
}
|
||||||
|
|
||||||
/// Gère la connexion de l'utilisateur
|
/// Gère la connexion de l'utilisateur
|
||||||
Future<void> _handleLogin() async {
|
Future<void> _handleLogin() async {
|
||||||
// Réinitialiser le message d'erreur
|
// Réinitialiser le message d'erreur
|
||||||
@@ -106,6 +112,9 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
|||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
|
// Laisse au navigateur/OS la possibilité de mémoriser les identifiants.
|
||||||
|
TextInput.finishAutofillContext(shouldSave: true);
|
||||||
|
|
||||||
// Rediriger selon le rôle de l'utilisateur
|
// Rediriger selon le rôle de l'utilisateur
|
||||||
_redirectUserByRole(user.role);
|
_redirectUserByRole(user.role);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -122,9 +131,11 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
|||||||
switch (role.toLowerCase()) {
|
switch (role.toLowerCase()) {
|
||||||
case 'super_admin':
|
case 'super_admin':
|
||||||
case 'administrateur':
|
case 'administrateur':
|
||||||
case 'gestionnaire':
|
|
||||||
context.go('/admin-dashboard');
|
context.go('/admin-dashboard');
|
||||||
break;
|
break;
|
||||||
|
case 'gestionnaire':
|
||||||
|
context.go('/gestionnaire-dashboard');
|
||||||
|
break;
|
||||||
case 'parent':
|
case 'parent':
|
||||||
context.go('/parent-dashboard');
|
context.go('/parent-dashboard');
|
||||||
break;
|
break;
|
||||||
@@ -152,47 +163,49 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
|||||||
final w = constraints.maxWidth;
|
final w = constraints.maxWidth;
|
||||||
final h = constraints.maxHeight;
|
final h = constraints.maxHeight;
|
||||||
return FutureBuilder(
|
return FutureBuilder(
|
||||||
future: _getImageDimensions(),
|
future: _getImageDimensions(),
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (!snapshot.hasData) {
|
if (!snapshot.hasData) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
|
|
||||||
final imageDimensions = snapshot.data!;
|
final imageDimensions = snapshot.data!;
|
||||||
final imageHeight = h;
|
final imageHeight = h;
|
||||||
final imageWidth = imageHeight * (imageDimensions.width / imageDimensions.height);
|
final imageWidth = imageHeight *
|
||||||
final remainingWidth = w - imageWidth;
|
(imageDimensions.width / imageDimensions.height);
|
||||||
final leftMargin = remainingWidth / 4;
|
final remainingWidth = w - imageWidth;
|
||||||
|
final leftMargin = remainingWidth / 4;
|
||||||
|
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
// Fond en papier
|
// Fond en papier
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: Image.asset(
|
child: Image.asset(
|
||||||
'assets/images/paper2.png',
|
'assets/images/paper2.png',
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
repeat: ImageRepeat.repeat,
|
repeat: ImageRepeat.repeat,
|
||||||
),
|
|
||||||
),
|
),
|
||||||
// Image principale
|
),
|
||||||
Positioned(
|
// Image principale
|
||||||
left: leftMargin,
|
Positioned(
|
||||||
top: 0,
|
left: leftMargin,
|
||||||
height: imageHeight,
|
top: 0,
|
||||||
width: imageWidth,
|
height: imageHeight,
|
||||||
child: Image.asset(
|
width: imageWidth,
|
||||||
'assets/images/river_logo_desktop.png',
|
child: Image.asset(
|
||||||
fit: BoxFit.contain,
|
'assets/images/river_logo_desktop.png',
|
||||||
),
|
fit: BoxFit.contain,
|
||||||
),
|
),
|
||||||
// Formulaire dans le cadran en bas à droite
|
),
|
||||||
Positioned(
|
// Formulaire dans le cadran en bas à droite
|
||||||
right: 0,
|
Positioned(
|
||||||
bottom: 0,
|
right: 0,
|
||||||
width: w * 0.6, // 60% de la largeur de l'écran
|
bottom: 0,
|
||||||
height: h * 0.5, // 50% de la hauteur de l'écran
|
width: w * 0.6, // 60% de la largeur de l'écran
|
||||||
child: Padding(
|
height: h * 0.5, // 50% de la hauteur de l'écran
|
||||||
padding: EdgeInsets.all(w * 0.02), // 2% de padding
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(w * 0.02), // 2% de padding
|
||||||
|
child: AutofillGroup(
|
||||||
child: Form(
|
child: Form(
|
||||||
key: _formKey,
|
key: _formKey,
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -207,6 +220,12 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
|||||||
controller: _emailController,
|
controller: _emailController,
|
||||||
labelText: 'Email',
|
labelText: 'Email',
|
||||||
hintText: 'Votre adresse email',
|
hintText: 'Votre adresse email',
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
autofillHints: const [
|
||||||
|
AutofillHints.username,
|
||||||
|
AutofillHints.email,
|
||||||
|
],
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
validator: _validateEmail,
|
validator: _validateEmail,
|
||||||
style: CustomAppTextFieldStyle.lavande,
|
style: CustomAppTextFieldStyle.lavande,
|
||||||
fieldHeight: 53,
|
fieldHeight: 53,
|
||||||
@@ -220,6 +239,12 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
|||||||
labelText: 'Mot de passe',
|
labelText: 'Mot de passe',
|
||||||
hintText: 'Votre mot de passe',
|
hintText: 'Votre mot de passe',
|
||||||
obscureText: true,
|
obscureText: true,
|
||||||
|
autofillHints: const [
|
||||||
|
AutofillHints.password
|
||||||
|
],
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onFieldSubmitted:
|
||||||
|
_handlePasswordSubmitted,
|
||||||
validator: _validatePassword,
|
validator: _validatePassword,
|
||||||
style: CustomAppTextFieldStyle.jaune,
|
style: CustomAppTextFieldStyle.jaune,
|
||||||
fieldHeight: 53,
|
fieldHeight: 53,
|
||||||
@@ -242,7 +267,8 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.error_outline, color: Colors.red[700], size: 20),
|
Icon(Icons.error_outline,
|
||||||
|
color: Colors.red[700], size: 20),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -309,67 +335,68 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Pied de page (Wrap pour éviter overflow sur petite largeur)
|
),
|
||||||
Positioned(
|
// Pied de page (Wrap pour éviter overflow sur petite largeur)
|
||||||
left: 0,
|
Positioned(
|
||||||
right: 0,
|
left: 0,
|
||||||
bottom: 0,
|
right: 0,
|
||||||
child: Container(
|
bottom: 0,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
child: Container(
|
||||||
decoration: const BoxDecoration(
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
color: Colors.transparent,
|
decoration: const BoxDecoration(
|
||||||
),
|
color: Colors.transparent,
|
||||||
child: Wrap(
|
),
|
||||||
alignment: WrapAlignment.center,
|
child: Wrap(
|
||||||
runSpacing: 8,
|
alignment: WrapAlignment.center,
|
||||||
children: [
|
runSpacing: 8,
|
||||||
_FooterLink(
|
children: [
|
||||||
text: 'Contact support',
|
_FooterLink(
|
||||||
onTap: () async {
|
text: 'Contact support',
|
||||||
final Uri emailLaunchUri = Uri(
|
onTap: () async {
|
||||||
scheme: 'mailto',
|
final Uri emailLaunchUri = Uri(
|
||||||
path: 'support@supernounou.local',
|
scheme: 'mailto',
|
||||||
);
|
path: 'support@supernounou.local',
|
||||||
if (await canLaunchUrl(emailLaunchUri)) {
|
);
|
||||||
await launchUrl(emailLaunchUri);
|
if (await canLaunchUrl(emailLaunchUri)) {
|
||||||
} else {
|
await launchUrl(emailLaunchUri);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
} else {
|
||||||
SnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
content: Text(
|
SnackBar(
|
||||||
'Impossible d\'ouvrir le client mail',
|
content: Text(
|
||||||
style: GoogleFonts.merienda(),
|
'Impossible d\'ouvrir le client mail',
|
||||||
),
|
style: GoogleFonts.merienda(),
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
}
|
);
|
||||||
},
|
}
|
||||||
),
|
},
|
||||||
_FooterLink(
|
),
|
||||||
text: 'Signaler un bug',
|
_FooterLink(
|
||||||
onTap: () {
|
text: 'Signaler un bug',
|
||||||
_showBugReportDialog(context);
|
onTap: () {
|
||||||
},
|
_showBugReportDialog(context);
|
||||||
),
|
},
|
||||||
_FooterLink(
|
),
|
||||||
text: 'Mentions légales',
|
_FooterLink(
|
||||||
onTap: () {
|
text: 'Mentions légales',
|
||||||
context.go('/legal');
|
onTap: () {
|
||||||
},
|
context.go('/legal');
|
||||||
),
|
},
|
||||||
_FooterLink(
|
),
|
||||||
text: 'Politique de confidentialité',
|
_FooterLink(
|
||||||
onTap: () {
|
text: 'Politique de confidentialité',
|
||||||
context.go('/privacy');
|
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).
|
/// Dimensions de river_logo_mobile.png (à mettre à jour si l'asset change).
|
||||||
static const int _riverLogoMobileWidth = 600;
|
static const int _riverLogoMobileWidth = 600;
|
||||||
static const int _riverLogoMobileHeight = 1080;
|
static const int _riverLogoMobileHeight = 1080;
|
||||||
|
|
||||||
/// Fraction de la hauteur de l'image où se termine visuellement le slogan (0 = haut, 1 = bas).
|
/// Fraction de la hauteur de l'image où se termine visuellement le slogan (0 = haut, 1 = bas).
|
||||||
static const double _sloganEndFraction = 0.42;
|
static const double _sloganEndFraction = 0.42;
|
||||||
static const double _gapBelowSlogan = 12.0;
|
static const double _gapBelowSlogan = 12.0;
|
||||||
@@ -388,7 +416,8 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
|||||||
final h = constraints.maxHeight;
|
final h = constraints.maxHeight;
|
||||||
final w = constraints.maxWidth;
|
final w = constraints.maxWidth;
|
||||||
final imageAspectRatio = _riverLogoMobileHeight / _riverLogoMobileWidth;
|
final imageAspectRatio = _riverLogoMobileHeight / _riverLogoMobileWidth;
|
||||||
final formTop = w * imageAspectRatio * _sloganEndFraction + _gapBelowSlogan;
|
final formTop =
|
||||||
|
w * imageAspectRatio * _sloganEndFraction + _gapBelowSlogan;
|
||||||
return Stack(
|
return Stack(
|
||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
children: [
|
children: [
|
||||||
@@ -428,95 +457,115 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 24, vertical: 20),
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 400),
|
constraints: const BoxConstraints(maxWidth: 400),
|
||||||
child: Form(
|
child: AutofillGroup(
|
||||||
key: _formKey,
|
child: Form(
|
||||||
child: Column(
|
key: _formKey,
|
||||||
mainAxisSize: MainAxisSize.min,
|
child: Column(
|
||||||
children: [
|
mainAxisSize: MainAxisSize.min,
|
||||||
const SizedBox(height: 16),
|
children: [
|
||||||
CustomAppTextField(
|
const SizedBox(height: 16),
|
||||||
controller: _emailController,
|
CustomAppTextField(
|
||||||
labelText: 'Email',
|
controller: _emailController,
|
||||||
showLabel: false,
|
labelText: 'Email',
|
||||||
hintText: 'Votre adresse email',
|
showLabel: false,
|
||||||
validator: _validateEmail,
|
hintText: 'Votre adresse email',
|
||||||
style: CustomAppTextFieldStyle.lavande,
|
keyboardType: TextInputType.emailAddress,
|
||||||
fieldHeight: 48,
|
autofillHints: const [
|
||||||
fieldWidth: double.infinity,
|
AutofillHints.username,
|
||||||
),
|
AutofillHints.email,
|
||||||
const SizedBox(height: 12),
|
],
|
||||||
CustomAppTextField(
|
textInputAction: TextInputAction.next,
|
||||||
controller: _passwordController,
|
validator: _validateEmail,
|
||||||
labelText: 'Mot de passe',
|
style: CustomAppTextFieldStyle.lavande,
|
||||||
showLabel: false,
|
fieldHeight: 48,
|
||||||
hintText: 'Votre mot de passe',
|
fieldWidth: double.infinity,
|
||||||
obscureText: true,
|
),
|
||||||
validator: _validatePassword,
|
const SizedBox(height: 12),
|
||||||
style: CustomAppTextFieldStyle.jaune,
|
CustomAppTextField(
|
||||||
fieldHeight: 48,
|
controller: _passwordController,
|
||||||
fieldWidth: double.infinity,
|
labelText: 'Mot de passe',
|
||||||
),
|
showLabel: false,
|
||||||
if (_errorMessage != null) ...[
|
hintText: 'Votre mot de passe',
|
||||||
const SizedBox(height: 12),
|
obscureText: true,
|
||||||
Container(
|
autofillHints: const [
|
||||||
padding: const EdgeInsets.all(12),
|
AutofillHints.password
|
||||||
decoration: BoxDecoration(
|
],
|
||||||
color: Colors.red.shade50,
|
textInputAction: TextInputAction.done,
|
||||||
borderRadius: BorderRadius.circular(10),
|
onFieldSubmitted: _handlePasswordSubmitted,
|
||||||
border: Border.all(color: Colors.red.shade300),
|
validator: _validatePassword,
|
||||||
),
|
style: CustomAppTextFieldStyle.jaune,
|
||||||
child: Row(
|
fieldHeight: 48,
|
||||||
children: [
|
fieldWidth: double.infinity,
|
||||||
Icon(Icons.error_outline, color: Colors.red.shade700, size: 20),
|
),
|
||||||
const SizedBox(width: 10),
|
if (_errorMessage != null) ...[
|
||||||
Expanded(
|
const SizedBox(height: 12),
|
||||||
child: Text(
|
Container(
|
||||||
_errorMessage!,
|
padding: const EdgeInsets.all(12),
|
||||||
style: GoogleFonts.merienda(fontSize: 12, color: Colors.red.shade700),
|
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',
|
text: 'Contact support',
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
final uri = Uri(scheme: 'mailto', path: 'support@supernounou.local');
|
final uri = Uri(
|
||||||
|
scheme: 'mailto',
|
||||||
|
path: 'support@supernounou.local');
|
||||||
if (await canLaunchUrl(uri)) {
|
if (await canLaunchUrl(uri)) {
|
||||||
await launchUrl(uri);
|
await launchUrl(uri);
|
||||||
} else if (context.mounted) {
|
} else if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('Impossible d\'ouvrir le client mail', style: GoogleFonts.merienda())),
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Impossible d\'ouvrir le client mail',
|
||||||
|
style: GoogleFonts.merienda())),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,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(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:p_tits_pas/controllers/parent_dashboard_controller.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/services/dashboardService.dart';
|
||||||
import 'package:p_tits_pas/widgets/app_footer.dart';
|
import 'package:p_tits_pas/widgets/app_footer.dart';
|
||||||
import 'package:p_tits_pas/widgets/dashbord_parent/app_layout.dart';
|
|
||||||
import 'package:p_tits_pas/widgets/dashbord_parent/children_sidebar.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/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/main_content_area.dart';
|
||||||
import 'package:p_tits_pas/widgets/messaging_sidebar.dart';
|
import 'package:p_tits_pas/widgets/messaging_sidebar.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -19,6 +20,7 @@ class ParentDashboardScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _ParentDashboardScreenState extends State<ParentDashboardScreen> {
|
class _ParentDashboardScreenState extends State<ParentDashboardScreen> {
|
||||||
int selectedIndex = 0;
|
int selectedIndex = 0;
|
||||||
|
AppUser? _user;
|
||||||
|
|
||||||
void onTabChange(int index) {
|
void onTabChange(int index) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -29,12 +31,18 @@ class _ParentDashboardScreenState extends State<ParentDashboardScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_loadUser();
|
||||||
// Initialiser les données du dashboard
|
// Initialiser les données du dashboard
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
context.read<ParentDashboardController>().initDashboard();
|
context.read<ParentDashboardController>().initDashboard();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadUser() async {
|
||||||
|
final user = await AuthService.getCurrentUser();
|
||||||
|
if (mounted) setState(() => _user = user);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _getBody() {
|
Widget _getBody() {
|
||||||
switch (selectedIndex) {
|
switch (selectedIndex) {
|
||||||
case 0:
|
case 0:
|
||||||
@@ -53,29 +61,43 @@ class _ParentDashboardScreenState extends State<ParentDashboardScreen> {
|
|||||||
return ChangeNotifierProvider(
|
return ChangeNotifierProvider(
|
||||||
create: (context) => ParentDashboardController(DashboardService())..initDashboard(),
|
create: (context) => ParentDashboardController(DashboardService())..initDashboard(),
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
appBar: PreferredSize(preferredSize: const Size.fromHeight(60.0),
|
appBar: PreferredSize(
|
||||||
child: Container(
|
preferredSize: const Size.fromHeight(60.0),
|
||||||
decoration: BoxDecoration(
|
child: DashboardBandeau(
|
||||||
border: Border(
|
tabItems: const [
|
||||||
bottom: BorderSide(color: Colors.grey.shade300),
|
DashboardTabItem(label: 'Mon tableau de bord'),
|
||||||
),
|
DashboardTabItem(label: 'Trouver une nounou'),
|
||||||
),
|
DashboardTabItem(label: 'Paramètres'),
|
||||||
child: DashboardAppBar(
|
],
|
||||||
selectedIndex: selectedIndex,
|
selectedTabIndex: selectedIndex,
|
||||||
onTabChange: onTabChange,
|
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(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
Expanded (child: _getBody(),
|
Expanded(child: _getBody()),
|
||||||
),
|
|
||||||
const AppFooter(),
|
const AppFooter(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
// body: _buildResponsiveBody(context, controller),
|
|
||||||
// footer: const AppFooter(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,11 +15,16 @@ class ApiConfig {
|
|||||||
static const String users = '/users';
|
static const String users = '/users';
|
||||||
static const String userProfile = '/users/profile';
|
static const String userProfile = '/users/profile';
|
||||||
static const String userChildren = '/users/children';
|
static const String userChildren = '/users/children';
|
||||||
|
static const String gestionnaires = '/gestionnaires';
|
||||||
|
static const String parents = '/parents';
|
||||||
|
static const String assistantesMaternelles = '/assistantes-maternelles';
|
||||||
|
static const String relais = '/relais';
|
||||||
|
|
||||||
// Configuration (admin)
|
// Configuration (admin)
|
||||||
static const String configuration = '/configuration';
|
static const String configuration = '/configuration';
|
||||||
static const String configurationSetupStatus = '/configuration/setup/status';
|
static const String configurationSetupStatus = '/configuration/setup/status';
|
||||||
static const String configurationSetupComplete = '/configuration/setup/complete';
|
static const String configurationSetupComplete =
|
||||||
|
'/configuration/setup/complete';
|
||||||
static const String configurationTestSmtp = '/configuration/test-smtp';
|
static const String configurationTestSmtp = '/configuration/test-smtp';
|
||||||
static const String configurationBulk = '/configuration/bulk';
|
static const String configurationBulk = '/configuration/bulk';
|
||||||
|
|
||||||
@@ -30,14 +35,14 @@ class ApiConfig {
|
|||||||
static const String conversations = '/conversations';
|
static const String conversations = '/conversations';
|
||||||
static const String notifications = '/notifications';
|
static const String notifications = '/notifications';
|
||||||
|
|
||||||
// Headers
|
// Headers
|
||||||
static Map<String, String> get headers => {
|
static Map<String, String> get headers => {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
};
|
};
|
||||||
|
|
||||||
static Map<String, String> authHeaders(String token) => {
|
static Map<String, String> authHeaders(String token) => {
|
||||||
...headers,
|
...headers,
|
||||||
'Authorization': 'Bearer $token',
|
'Authorization': 'Bearer $token',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import '../models/user.dart';
|
import '../models/user.dart';
|
||||||
|
import '../models/am_registration_data.dart';
|
||||||
import 'api/api_config.dart';
|
import 'api/api_config.dart';
|
||||||
import 'api/tokenService.dart';
|
import 'api/tokenService.dart';
|
||||||
|
import '../utils/nir_utils.dart';
|
||||||
|
|
||||||
class AuthService {
|
class AuthService {
|
||||||
static const String _currentUserKey = 'current_user';
|
static const String _currentUserKey = 'current_user';
|
||||||
@@ -133,6 +136,70 @@ class AuthService {
|
|||||||
await prefs.setString(_currentUserKey, jsonEncode(user.toJson()));
|
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
|
/// Rafraîchit le profil utilisateur depuis l'API
|
||||||
static Future<AppUser?> refreshCurrentUser() async {
|
static Future<AppUser?> refreshCurrentUser() async {
|
||||||
final token = await TokenService.getToken();
|
final token = await TokenService.getToken();
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:p_tits_pas/models/relais_model.dart';
|
||||||
|
import 'package:p_tits_pas/services/api/api_config.dart';
|
||||||
|
import 'package:p_tits_pas/services/api/tokenService.dart';
|
||||||
|
|
||||||
|
class RelaisService {
|
||||||
|
static Future<Map<String, String>> _headers() async {
|
||||||
|
final token = await TokenService.getToken();
|
||||||
|
return token != null
|
||||||
|
? ApiConfig.authHeaders(token)
|
||||||
|
: Map<String, String>.from(ApiConfig.headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _extractError(String body, String fallback) {
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(body);
|
||||||
|
if (decoded is Map<String, dynamic>) {
|
||||||
|
final message = decoded['message'];
|
||||||
|
if (message is String && message.trim().isNotEmpty) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<List<RelaisModel>> getRelais() async {
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.relais}'),
|
||||||
|
headers: await _headers(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw Exception(
|
||||||
|
_extractError(response.body, 'Erreur chargement relais'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<dynamic> data = jsonDecode(response.body);
|
||||||
|
return data
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map(RelaisModel.fromJson)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<RelaisModel> createRelais(Map<String, dynamic> payload) async {
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.relais}'),
|
||||||
|
headers: await _headers(),
|
||||||
|
body: jsonEncode(payload),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 201 && response.statusCode != 200) {
|
||||||
|
throw Exception(
|
||||||
|
_extractError(response.body, 'Erreur création relais'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RelaisModel.fromJson(
|
||||||
|
jsonDecode(response.body) as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<RelaisModel> updateRelais(
|
||||||
|
String id,
|
||||||
|
Map<String, dynamic> payload,
|
||||||
|
) async {
|
||||||
|
final response = await http.patch(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.relais}/$id'),
|
||||||
|
headers: await _headers(),
|
||||||
|
body: jsonEncode(payload),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw Exception(
|
||||||
|
_extractError(response.body, 'Erreur mise à jour relais'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RelaisModel.fromJson(
|
||||||
|
jsonDecode(response.body) as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> deleteRelais(String id) async {
|
||||||
|
final response = await http.delete(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.relais}/$id'),
|
||||||
|
headers: await _headers(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 200 && response.statusCode != 204) {
|
||||||
|
throw Exception(
|
||||||
|
_extractError(response.body, 'Erreur suppression relais'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
|
import 'package:p_tits_pas/models/parent_model.dart';
|
||||||
|
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
|
||||||
|
import 'package:p_tits_pas/services/api/api_config.dart';
|
||||||
|
import 'package:p_tits_pas/services/api/tokenService.dart';
|
||||||
|
|
||||||
|
class UserService {
|
||||||
|
static Future<Map<String, String>> _headers() async {
|
||||||
|
final token = await TokenService.getToken();
|
||||||
|
return token != null
|
||||||
|
? ApiConfig.authHeaders(token)
|
||||||
|
: Map<String, String>.from(ApiConfig.headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? _toStr(dynamic v) {
|
||||||
|
if (v == null) return null;
|
||||||
|
if (v is String) return v;
|
||||||
|
return v.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupérer la liste des gestionnaires (endpoint dédié)
|
||||||
|
static Future<List<AppUser>> getGestionnaires() async {
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.gestionnaires}'),
|
||||||
|
headers: await _headers(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
final err = jsonDecode(response.body) as Map<String, dynamic>?;
|
||||||
|
throw Exception(
|
||||||
|
_toStr(err?['message']) ?? 'Erreur chargement gestionnaires');
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<dynamic> data = jsonDecode(response.body);
|
||||||
|
return data.map((e) => AppUser.fromJson(e)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<AppUser> createGestionnaire({
|
||||||
|
required String nom,
|
||||||
|
required String prenom,
|
||||||
|
required String email,
|
||||||
|
required String password,
|
||||||
|
required String telephone,
|
||||||
|
String? relaisId,
|
||||||
|
}) async {
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.gestionnaires}'),
|
||||||
|
headers: await _headers(),
|
||||||
|
body: jsonEncode(<String, dynamic>{
|
||||||
|
'nom': nom,
|
||||||
|
'prenom': prenom,
|
||||||
|
'email': email,
|
||||||
|
'password': password,
|
||||||
|
'telephone': telephone,
|
||||||
|
'cguAccepted': true,
|
||||||
|
'relaisId': relaisId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 200 && response.statusCode != 201) {
|
||||||
|
final decoded = jsonDecode(response.body);
|
||||||
|
if (decoded is Map<String, dynamic>) {
|
||||||
|
final message = decoded['message'];
|
||||||
|
if (message is List && message.isNotEmpty) {
|
||||||
|
throw Exception(message.join(' - '));
|
||||||
|
}
|
||||||
|
throw Exception(_toStr(message) ?? 'Erreur création gestionnaire');
|
||||||
|
}
|
||||||
|
throw Exception('Erreur création gestionnaire');
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
return AppUser.fromJson(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<AppUser> createAdministrateur({
|
||||||
|
required String nom,
|
||||||
|
required String prenom,
|
||||||
|
required String email,
|
||||||
|
required String password,
|
||||||
|
required String telephone,
|
||||||
|
}) async {
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/admin'),
|
||||||
|
headers: await _headers(),
|
||||||
|
body: jsonEncode(<String, dynamic>{
|
||||||
|
'nom': nom,
|
||||||
|
'prenom': prenom,
|
||||||
|
'email': email,
|
||||||
|
'password': password,
|
||||||
|
'telephone': telephone,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 200 && response.statusCode != 201) {
|
||||||
|
final decoded = jsonDecode(response.body);
|
||||||
|
if (decoded is Map<String, dynamic>) {
|
||||||
|
final message = decoded['message'];
|
||||||
|
if (message is List && message.isNotEmpty) {
|
||||||
|
throw Exception(message.join(' - '));
|
||||||
|
}
|
||||||
|
throw Exception(_toStr(message) ?? 'Erreur création administrateur');
|
||||||
|
}
|
||||||
|
throw Exception('Erreur création administrateur');
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
return AppUser.fromJson(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupérer la liste des parents
|
||||||
|
static Future<List<ParentModel>> getParents() async {
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parents}'),
|
||||||
|
headers: await _headers(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
final err = jsonDecode(response.body) as Map<String, dynamic>?;
|
||||||
|
throw Exception(_toStr(err?['message']) ?? 'Erreur chargement parents');
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<dynamic> data = jsonDecode(response.body);
|
||||||
|
return data.map((e) => ParentModel.fromJson(e)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupérer la liste des assistantes maternelles
|
||||||
|
static Future<List<AssistanteMaternelleModel>>
|
||||||
|
getAssistantesMaternelles() async {
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}'),
|
||||||
|
headers: await _headers(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
final err = jsonDecode(response.body) as Map<String, dynamic>?;
|
||||||
|
throw Exception(_toStr(err?['message']) ?? 'Erreur chargement AM');
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<dynamic> data = jsonDecode(response.body);
|
||||||
|
return data.map((e) => AssistanteMaternelleModel.fromJson(e)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupérer la liste des administrateurs (via /users filtré ou autre)
|
||||||
|
// Pour l'instant on va utiliser /users et filtrer côté client si on est super admin
|
||||||
|
static Future<List<AppUser>> getAdministrateurs() async {
|
||||||
|
// TODO: Endpoint dédié ou filtrage
|
||||||
|
// En attendant, on retourne une liste vide ou on tente /users
|
||||||
|
try {
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}'),
|
||||||
|
headers: await _headers(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final List<dynamic> data = jsonDecode(response.body);
|
||||||
|
return data
|
||||||
|
.map((e) => AppUser.fromJson(e))
|
||||||
|
.where((u) => u.role == 'administrateur' || u.role == 'super_admin')
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// On garde un fallback vide pour ne pas bloquer l'UI admin.
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<AppUser> createAdmin({
|
||||||
|
required String nom,
|
||||||
|
required String prenom,
|
||||||
|
required String email,
|
||||||
|
required String password,
|
||||||
|
required String telephone,
|
||||||
|
}) async {
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/admin'),
|
||||||
|
headers: await _headers(),
|
||||||
|
body: jsonEncode(<String, dynamic>{
|
||||||
|
'nom': nom,
|
||||||
|
'prenom': prenom,
|
||||||
|
'email': email,
|
||||||
|
'password': password,
|
||||||
|
'telephone': telephone,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 200 && response.statusCode != 201) {
|
||||||
|
final decoded = jsonDecode(response.body);
|
||||||
|
if (decoded is Map<String, dynamic>) {
|
||||||
|
final message = decoded['message'];
|
||||||
|
if (message is List && message.isNotEmpty) {
|
||||||
|
throw Exception(message.join(' - '));
|
||||||
|
}
|
||||||
|
throw Exception(_toStr(message) ?? 'Erreur création administrateur');
|
||||||
|
}
|
||||||
|
throw Exception('Erreur création administrateur');
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
return AppUser.fromJson(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<AppUser> updateAdmin({
|
||||||
|
required String adminId,
|
||||||
|
required String nom,
|
||||||
|
required String prenom,
|
||||||
|
required String email,
|
||||||
|
required String telephone,
|
||||||
|
String? password,
|
||||||
|
}) async {
|
||||||
|
final body = <String, dynamic>{
|
||||||
|
'nom': nom,
|
||||||
|
'prenom': prenom,
|
||||||
|
'email': email,
|
||||||
|
'telephone': telephone,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (password != null && password.trim().isNotEmpty) {
|
||||||
|
body['password'] = password.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
final response = await http.patch(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$adminId'),
|
||||||
|
headers: await _headers(),
|
||||||
|
body: jsonEncode(body),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
final decoded = jsonDecode(response.body);
|
||||||
|
if (decoded is Map<String, dynamic>) {
|
||||||
|
final message = decoded['message'];
|
||||||
|
if (message is List && message.isNotEmpty) {
|
||||||
|
throw Exception(message.join(' - '));
|
||||||
|
}
|
||||||
|
throw Exception(_toStr(message) ?? 'Erreur modification administrateur');
|
||||||
|
}
|
||||||
|
throw Exception('Erreur modification administrateur');
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
return AppUser.fromJson(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> updateGestionnaireRelais({
|
||||||
|
required String gestionnaireId,
|
||||||
|
required String? relaisId,
|
||||||
|
}) async {
|
||||||
|
final response = await http.patch(
|
||||||
|
Uri.parse(
|
||||||
|
'${ApiConfig.baseUrl}${ApiConfig.gestionnaires}/$gestionnaireId'),
|
||||||
|
headers: await _headers(),
|
||||||
|
body: jsonEncode(<String, dynamic>{'relaisId': relaisId}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 200 && response.statusCode != 204) {
|
||||||
|
final err = jsonDecode(response.body) as Map<String, dynamic>?;
|
||||||
|
throw Exception(
|
||||||
|
_toStr(err?['message']) ?? 'Erreur rattachement relais au gestionnaire',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<AppUser> updateGestionnaire({
|
||||||
|
required String gestionnaireId,
|
||||||
|
required String nom,
|
||||||
|
required String prenom,
|
||||||
|
required String email,
|
||||||
|
String? telephone,
|
||||||
|
required String? relaisId,
|
||||||
|
String? password,
|
||||||
|
}) async {
|
||||||
|
final body = <String, dynamic>{
|
||||||
|
'nom': nom,
|
||||||
|
'prenom': prenom,
|
||||||
|
'email': email,
|
||||||
|
'relaisId': relaisId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (telephone != null && telephone.trim().isNotEmpty) {
|
||||||
|
body['telephone'] = telephone.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password != null && password.trim().isNotEmpty) {
|
||||||
|
body['password'] = password.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
final response = await http.patch(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.gestionnaires}/$gestionnaireId'),
|
||||||
|
headers: await _headers(),
|
||||||
|
body: jsonEncode(body),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
final decoded = jsonDecode(response.body);
|
||||||
|
if (decoded is Map<String, dynamic>) {
|
||||||
|
final message = decoded['message'];
|
||||||
|
if (message is List && message.isNotEmpty) {
|
||||||
|
throw Exception(message.join(' - '));
|
||||||
|
}
|
||||||
|
throw Exception(_toStr(message) ?? 'Erreur modification gestionnaire');
|
||||||
|
}
|
||||||
|
throw Exception('Erreur modification gestionnaire');
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
return AppUser.fromJson(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<AppUser> updateAdministrateur({
|
||||||
|
required String adminId,
|
||||||
|
required String nom,
|
||||||
|
required String prenom,
|
||||||
|
required String email,
|
||||||
|
String? telephone,
|
||||||
|
String? password,
|
||||||
|
}) async {
|
||||||
|
final body = <String, dynamic>{
|
||||||
|
'nom': nom,
|
||||||
|
'prenom': prenom,
|
||||||
|
'email': email,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (telephone != null && telephone.trim().isNotEmpty) {
|
||||||
|
body['telephone'] = telephone.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password != null && password.trim().isNotEmpty) {
|
||||||
|
body['password'] = password.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
final response = await http.patch(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$adminId'),
|
||||||
|
headers: await _headers(),
|
||||||
|
body: jsonEncode(body),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
final decoded = jsonDecode(response.body);
|
||||||
|
if (decoded is Map<String, dynamic>) {
|
||||||
|
final message = decoded['message'];
|
||||||
|
if (message is List && message.isNotEmpty) {
|
||||||
|
throw Exception(message.join(' - '));
|
||||||
|
}
|
||||||
|
throw Exception(_toStr(message) ?? 'Erreur modification administrateur');
|
||||||
|
}
|
||||||
|
throw Exception('Erreur modification administrateur');
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
return AppUser.fromJson(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> deleteUser(String userId) async {
|
||||||
|
final response = await http.delete(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$userId'),
|
||||||
|
headers: await _headers(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 200 && response.statusCode != 204) {
|
||||||
|
final decoded = jsonDecode(response.body);
|
||||||
|
if (decoded is Map<String, dynamic>) {
|
||||||
|
final message = decoded['message'];
|
||||||
|
if (message is List && message.isNotEmpty) {
|
||||||
|
throw Exception(message.join(' - '));
|
||||||
|
}
|
||||||
|
throw Exception(_toStr(message) ?? 'Erreur suppression utilisateur');
|
||||||
|
}
|
||||||
|
throw Exception('Erreur suppression utilisateur');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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 2A→19, 2B→18.
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
|
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart';
|
||||||
|
import 'package:p_tits_pas/services/auth_service.dart';
|
||||||
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
|
||||||
|
|
||||||
|
class AdminManagementWidget extends StatefulWidget {
|
||||||
|
final String searchQuery;
|
||||||
|
|
||||||
|
const AdminManagementWidget({
|
||||||
|
super.key,
|
||||||
|
required this.searchQuery,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AdminManagementWidget> createState() => _AdminManagementWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AdminManagementWidgetState extends State<AdminManagementWidget> {
|
||||||
|
bool _isLoading = false;
|
||||||
|
String? _error;
|
||||||
|
List<AppUser> _admins = [];
|
||||||
|
String? _currentUserRole;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadCurrentUserRole();
|
||||||
|
_loadAdmins();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() => super.dispose();
|
||||||
|
|
||||||
|
Future<void> _loadAdmins() async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final list = await UserService.getAdministrateurs();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_admins = list;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_error = e.toString();
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadCurrentUserRole() async {
|
||||||
|
final cached = await AuthService.getCurrentUser();
|
||||||
|
if (!mounted) return;
|
||||||
|
if (cached != null) {
|
||||||
|
setState(() {
|
||||||
|
_currentUserRole = cached.role.toLowerCase();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final refreshed = await AuthService.refreshCurrentUser();
|
||||||
|
if (!mounted || refreshed == null) return;
|
||||||
|
setState(() {
|
||||||
|
_currentUserRole = refreshed.role.toLowerCase();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isSuperAdmin(AppUser user) => user.role.toLowerCase() == 'super_admin';
|
||||||
|
|
||||||
|
bool _canEditAdmin(AppUser target) {
|
||||||
|
if (!_isSuperAdmin(target)) return true;
|
||||||
|
return _currentUserRole == 'super_admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openAdminEditDialog(AppUser user) async {
|
||||||
|
final canEdit = _canEditAdmin(user);
|
||||||
|
final changed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (dialogContext) {
|
||||||
|
return AdminUserFormDialog(
|
||||||
|
initialUser: user,
|
||||||
|
adminMode: true,
|
||||||
|
withRelais: false,
|
||||||
|
readOnly: !canEdit,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (changed == true && canEdit) {
|
||||||
|
await _loadAdmins();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final query = widget.searchQuery.toLowerCase();
|
||||||
|
final filteredAdmins = _admins.where((u) {
|
||||||
|
final name = u.fullName.toLowerCase();
|
||||||
|
final email = u.email.toLowerCase();
|
||||||
|
return name.contains(query) || email.contains(query);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
return UserList(
|
||||||
|
isLoading: _isLoading,
|
||||||
|
error: _error,
|
||||||
|
isEmpty: filteredAdmins.isEmpty,
|
||||||
|
emptyMessage: 'Aucun administrateur trouvé.',
|
||||||
|
itemCount: filteredAdmins.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final user = filteredAdmins[index];
|
||||||
|
final isSuperAdmin = _isSuperAdmin(user);
|
||||||
|
final canEdit = _canEditAdmin(user);
|
||||||
|
return AdminUserCard(
|
||||||
|
title: user.fullName,
|
||||||
|
fallbackIcon: isSuperAdmin
|
||||||
|
? Icons.verified_user_outlined
|
||||||
|
: Icons.manage_accounts_outlined,
|
||||||
|
subtitleLines: [
|
||||||
|
user.email,
|
||||||
|
'Téléphone : ${user.telephone?.trim().isNotEmpty == true ? user.telephone : 'Non renseigné'}',
|
||||||
|
],
|
||||||
|
avatarUrl: user.photoUrl,
|
||||||
|
borderColor: isSuperAdmin
|
||||||
|
? const Color(0xFF8E6AC8)
|
||||||
|
: Colors.grey.shade300,
|
||||||
|
backgroundColor: isSuperAdmin
|
||||||
|
? const Color(0xFFF4EEFF)
|
||||||
|
: Colors.white,
|
||||||
|
titleColor: isSuperAdmin ? const Color(0xFF5D2F99) : null,
|
||||||
|
infoColor: isSuperAdmin
|
||||||
|
? const Color(0xFF6D4EA1)
|
||||||
|
: Colors.black54,
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
canEdit ? Icons.edit_outlined : Icons.visibility_outlined,
|
||||||
|
),
|
||||||
|
tooltip: canEdit ? 'Modifier' : 'Consulter',
|
||||||
|
onPressed: () {
|
||||||
|
_openAdminEditDialog(user);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,106 +1,155 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
|
||||||
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
|
||||||
|
|
||||||
class AssistanteMaternelleManagementWidget extends StatelessWidget {
|
class AssistanteMaternelleManagementWidget extends StatefulWidget {
|
||||||
const AssistanteMaternelleManagementWidget({super.key});
|
final String searchQuery;
|
||||||
|
final int? capacityMin;
|
||||||
|
|
||||||
|
const AssistanteMaternelleManagementWidget({
|
||||||
|
super.key,
|
||||||
|
required this.searchQuery,
|
||||||
|
this.capacityMin,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AssistanteMaternelleManagementWidget> createState() =>
|
||||||
|
_AssistanteMaternelleManagementWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AssistanteMaternelleManagementWidgetState
|
||||||
|
extends State<AssistanteMaternelleManagementWidget> {
|
||||||
|
bool _isLoading = false;
|
||||||
|
String? _error;
|
||||||
|
List<AssistanteMaternelleModel> _assistantes = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadAssistantes();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() => super.dispose();
|
||||||
|
|
||||||
|
Future<void> _loadAssistantes() async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final list = await UserService.getAssistantesMaternelles();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_assistantes = list;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_error = e.toString();
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final assistantes = [
|
final query = widget.searchQuery.toLowerCase();
|
||||||
{
|
final filteredAssistantes = _assistantes.where((am) {
|
||||||
"nom": "Marie Dupont",
|
final matchesName = am.user.fullName.toLowerCase().contains(query) ||
|
||||||
"numeroAgrement": "AG123456",
|
am.user.email.toLowerCase().contains(query) ||
|
||||||
"zone": "Paris 14",
|
(am.residenceCity?.toLowerCase().contains(query) ?? false);
|
||||||
"capacite": 3,
|
final matchesCapacity = widget.capacityMin == null ||
|
||||||
|
(am.maxChildren != null && am.maxChildren! >= widget.capacityMin!);
|
||||||
|
return matchesName && matchesCapacity;
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
return UserList(
|
||||||
|
isLoading: _isLoading,
|
||||||
|
error: _error,
|
||||||
|
isEmpty: filteredAssistantes.isEmpty,
|
||||||
|
emptyMessage: 'Aucune assistante maternelle trouvée.',
|
||||||
|
itemCount: filteredAssistantes.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final assistante = filteredAssistantes[index];
|
||||||
|
return AdminUserCard(
|
||||||
|
title: assistante.user.fullName,
|
||||||
|
avatarUrl: assistante.user.photoUrl,
|
||||||
|
fallbackIcon: Icons.face,
|
||||||
|
subtitleLines: [
|
||||||
|
assistante.user.email,
|
||||||
|
'Zone : ${assistante.residenceCity ?? 'N/A'} | Capacité : ${assistante.maxChildren ?? 0}',
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.edit),
|
||||||
|
tooltip: 'Modifier',
|
||||||
|
onPressed: () {
|
||||||
|
_openAssistanteDetails(assistante);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"nom": "Claire Martin",
|
|
||||||
"numeroAgrement": "AG654321",
|
|
||||||
"zone": "Lyon 7",
|
|
||||||
"capacite": 2,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
// 🔎 Zone de filtre
|
|
||||||
_buildFilterSection(),
|
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// 📋 Liste des assistantes
|
|
||||||
ListView.builder(
|
|
||||||
shrinkWrap: true,
|
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
|
||||||
itemCount: assistantes.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final assistante = assistantes[index];
|
|
||||||
return Card(
|
|
||||||
margin: const EdgeInsets.symmetric(vertical: 8),
|
|
||||||
child: ListTile(
|
|
||||||
leading: const Icon(Icons.face),
|
|
||||||
title: Text(assistante['nom'].toString()),
|
|
||||||
subtitle: Text(
|
|
||||||
"N° Agrément : ${assistante['numeroAgrement']}\nZone : ${assistante['zone']} | Capacité : ${assistante['capacite']}"),
|
|
||||||
trailing: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.edit),
|
|
||||||
onPressed: () {
|
|
||||||
// TODO: Ajouter modification
|
|
||||||
},
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.delete),
|
|
||||||
onPressed: () {
|
|
||||||
// TODO: Ajouter suppression
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildFilterSection() {
|
void _openAssistanteDetails(AssistanteMaternelleModel assistante) {
|
||||||
return Wrap(
|
showDialog<void>(
|
||||||
spacing: 16,
|
context: context,
|
||||||
runSpacing: 8,
|
builder: (context) => AdminDetailModal(
|
||||||
children: [
|
title: assistante.user.fullName.isEmpty
|
||||||
SizedBox(
|
? 'Assistante maternelle'
|
||||||
width: 200,
|
: assistante.user.fullName,
|
||||||
child: TextField(
|
subtitle: assistante.user.email,
|
||||||
decoration: const InputDecoration(
|
fields: [
|
||||||
labelText: "Zone géographique",
|
AdminDetailField(label: 'ID', value: _v(assistante.user.id)),
|
||||||
border: OutlineInputBorder(),
|
AdminDetailField(
|
||||||
),
|
label: 'Numero agrement',
|
||||||
onChanged: (value) {
|
value: _v(assistante.approvalNumber),
|
||||||
// TODO: Ajouter logique de filtrage par zone
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
AdminDetailField(
|
||||||
SizedBox(
|
label: 'Ville residence',
|
||||||
width: 200,
|
value: _v(assistante.residenceCity),
|
||||||
child: TextField(
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: "Capacité minimum",
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
keyboardType: TextInputType.number,
|
|
||||||
onChanged: (value) {
|
|
||||||
// TODO: Ajouter logique de filtrage par capacité
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
AdminDetailField(
|
||||||
],
|
label: 'Capacite max',
|
||||||
|
value: assistante.maxChildren?.toString() ?? '-',
|
||||||
|
),
|
||||||
|
AdminDetailField(
|
||||||
|
label: 'Places disponibles',
|
||||||
|
value: assistante.placesAvailable?.toString() ?? '-',
|
||||||
|
),
|
||||||
|
AdminDetailField(
|
||||||
|
label: 'Telephone',
|
||||||
|
value: _v(assistante.user.telephone),
|
||||||
|
),
|
||||||
|
AdminDetailField(label: 'Adresse', value: _v(assistante.user.adresse)),
|
||||||
|
AdminDetailField(label: 'Ville', value: _v(assistante.user.ville)),
|
||||||
|
AdminDetailField(
|
||||||
|
label: 'Code postal',
|
||||||
|
value: _v(assistante.user.codePostal),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onEdit: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
ScaffoldMessenger.of(this.context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Action Modifier a implementer')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onDelete: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
ScaffoldMessenger.of(this.context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Action Supprimer a implementer')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _v(String? value) => (value == null || value.isEmpty) ? '-' : value;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class AdminDetailField {
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
const AdminDetailField({
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class AdminDetailModal extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final String? subtitle;
|
||||||
|
final List<AdminDetailField> fields;
|
||||||
|
final VoidCallback onEdit;
|
||||||
|
final VoidCallback onDelete;
|
||||||
|
|
||||||
|
const AdminDetailModal({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
this.subtitle,
|
||||||
|
required this.fields,
|
||||||
|
required this.onEdit,
|
||||||
|
required this.onDelete,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Dialog(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 620),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(18),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (subtitle != null && subtitle!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
subtitle!,
|
||||||
|
style: const TextStyle(color: Colors.black54),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Fermer',
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Divider(height: 1),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Flexible(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
children: fields
|
||||||
|
.map(
|
||||||
|
(field) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 180,
|
||||||
|
child: Text(
|
||||||
|
field.label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
field.value,
|
||||||
|
style: const TextStyle(color: Colors.black87),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: onDelete,
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
label: const Text('Supprimer'),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: Colors.red.shade700,
|
||||||
|
side: BorderSide(color: Colors.red.shade300),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: onEdit,
|
||||||
|
icon: const Icon(Icons.edit),
|
||||||
|
label: const Text('Modifier'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class AdminListState extends StatelessWidget {
|
||||||
|
final bool isLoading;
|
||||||
|
final String? error;
|
||||||
|
final bool isEmpty;
|
||||||
|
final String emptyMessage;
|
||||||
|
final Widget list;
|
||||||
|
|
||||||
|
const AdminListState({
|
||||||
|
super.key,
|
||||||
|
required this.isLoading,
|
||||||
|
required this.error,
|
||||||
|
required this.isEmpty,
|
||||||
|
required this.emptyMessage,
|
||||||
|
required this.list,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (isLoading) {
|
||||||
|
return const Expanded(
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error != null) {
|
||||||
|
return Expanded(
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'Erreur: $error',
|
||||||
|
style: const TextStyle(color: Colors.red),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEmpty) {
|
||||||
|
return Expanded(
|
||||||
|
child: Center(
|
||||||
|
child: Text(emptyMessage),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Expanded(child: list);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class AdminUserCard extends StatefulWidget {
|
||||||
|
final String title;
|
||||||
|
final List<String> subtitleLines;
|
||||||
|
final String? avatarUrl;
|
||||||
|
final IconData fallbackIcon;
|
||||||
|
final List<Widget> actions;
|
||||||
|
final Color? borderColor;
|
||||||
|
final Color? backgroundColor;
|
||||||
|
final Color? titleColor;
|
||||||
|
final Color? infoColor;
|
||||||
|
|
||||||
|
const AdminUserCard({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.subtitleLines,
|
||||||
|
this.avatarUrl,
|
||||||
|
this.fallbackIcon = Icons.person,
|
||||||
|
this.actions = const [],
|
||||||
|
this.borderColor,
|
||||||
|
this.backgroundColor,
|
||||||
|
this.titleColor,
|
||||||
|
this.infoColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AdminUserCard> createState() => _AdminUserCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AdminUserCardState extends State<AdminUserCard> {
|
||||||
|
bool _isHovered = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final infoLine =
|
||||||
|
widget.subtitleLines.where((e) => e.trim().isNotEmpty).join(' ');
|
||||||
|
final actionsWidth =
|
||||||
|
widget.actions.isNotEmpty ? widget.actions.length * 30.0 : 0.0;
|
||||||
|
|
||||||
|
return MouseRegion(
|
||||||
|
onEnter: (_) => setState(() => _isHovered = true),
|
||||||
|
onExit: (_) => setState(() => _isHovered = false),
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () {},
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
hoverColor: const Color(0x149CC5C0),
|
||||||
|
child: Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
elevation: 0,
|
||||||
|
color: widget.backgroundColor,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
side: BorderSide(color: widget.borderColor ?? Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
radius: 14,
|
||||||
|
backgroundColor: const Color(0xFFEDE5FA),
|
||||||
|
backgroundImage: widget.avatarUrl != null
|
||||||
|
? NetworkImage(widget.avatarUrl!)
|
||||||
|
: null,
|
||||||
|
child: widget.avatarUrl == null
|
||||||
|
? Icon(
|
||||||
|
widget.fallbackIcon,
|
||||||
|
size: 16,
|
||||||
|
color: const Color(0xFF6B3FA0),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
fit: FlexFit.loose,
|
||||||
|
child: Text(
|
||||||
|
widget.title.isNotEmpty ? widget.title : 'Sans nom',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 14,
|
||||||
|
).copyWith(color: widget.titleColor),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
infoLine,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.black54,
|
||||||
|
fontSize: 12,
|
||||||
|
).copyWith(color: widget.infoColor),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (widget.actions.isNotEmpty)
|
||||||
|
SizedBox(
|
||||||
|
width: actionsWidth,
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
duration: const Duration(milliseconds: 120),
|
||||||
|
opacity: _isHovered ? 1 : 0,
|
||||||
|
child: IgnorePointer(
|
||||||
|
ignoring: !_isHovered,
|
||||||
|
child: IconTheme(
|
||||||
|
data: const IconThemeData(size: 17),
|
||||||
|
child: IconButtonTheme(
|
||||||
|
data: IconButtonThemeData(
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
minimumSize: const Size(28, 28),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: widget.actions,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/admin_list_state.dart';
|
||||||
|
|
||||||
|
class UserList extends StatelessWidget {
|
||||||
|
final bool isLoading;
|
||||||
|
final String? error;
|
||||||
|
final bool isEmpty;
|
||||||
|
final String emptyMessage;
|
||||||
|
final int itemCount;
|
||||||
|
final Widget Function(BuildContext context, int index) itemBuilder;
|
||||||
|
final EdgeInsetsGeometry padding;
|
||||||
|
|
||||||
|
const UserList({
|
||||||
|
super.key,
|
||||||
|
required this.isLoading,
|
||||||
|
required this.error,
|
||||||
|
required this.isEmpty,
|
||||||
|
required this.emptyMessage,
|
||||||
|
required this.itemCount,
|
||||||
|
required this.itemBuilder,
|
||||||
|
this.padding = const EdgeInsets.all(16),
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: padding,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
AdminListState(
|
||||||
|
isLoading: isLoading,
|
||||||
|
error: error,
|
||||||
|
isEmpty: isEmpty,
|
||||||
|
emptyMessage: emptyMessage,
|
||||||
|
list: ListView.builder(
|
||||||
|
itemCount: itemCount,
|
||||||
|
itemBuilder: itemBuilder,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,140 +1,131 @@
|
|||||||
import 'package:flutter/material.dart';
|
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.
|
/// Sous-barre : Gestionnaires | Parents | Assistantes maternelles | [Administrateurs].
|
||||||
class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidget {
|
/// [subTabCount] = 3 pour masquer l'onglet Administrateurs (dashboard gestionnaire).
|
||||||
final int selectedIndex;
|
class DashboardUserManagementSubBar extends StatelessWidget {
|
||||||
final ValueChanged<int> onTabChange;
|
final int selectedSubIndex;
|
||||||
final bool setupCompleted;
|
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,
|
Key? key,
|
||||||
required this.selectedIndex,
|
required this.selectedSubIndex,
|
||||||
required this.onTabChange,
|
required this.onSubTabChange,
|
||||||
this.setupCompleted = true,
|
required this.searchController,
|
||||||
|
required this.searchHint,
|
||||||
|
this.filterControl,
|
||||||
|
this.onAddPressed,
|
||||||
|
this.addLabel = '+ Ajouter',
|
||||||
|
this.subTabCount = 4,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
|
||||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight + 10);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AppBar(
|
return Container(
|
||||||
elevation: 0,
|
height: 56,
|
||||||
automaticallyImplyLeading: false,
|
decoration: BoxDecoration(
|
||||||
title: Row(
|
color: Colors.grey.shade100,
|
||||||
|
border: Border(bottom: BorderSide(color: Colors.grey.shade300)),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 6),
|
||||||
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(width: 24),
|
for (int i = 0; i < subTabCount; i++) ...[
|
||||||
Image.asset(
|
if (i > 0) const SizedBox(width: 12),
|
||||||
'assets/images/logo.png',
|
_buildSubNavItem(context, _tabLabels[i], i),
|
||||||
height: 40,
|
],
|
||||||
fit: BoxFit.contain,
|
const SizedBox(width: 36),
|
||||||
),
|
_pillField(
|
||||||
Expanded(
|
width: 320,
|
||||||
child: Center(
|
child: TextField(
|
||||||
child: Row(
|
controller: searchController,
|
||||||
mainAxisSize: MainAxisSize.min,
|
decoration: InputDecoration(
|
||||||
children: [
|
hintText: searchHint,
|
||||||
_buildNavItem(context, 'Gestion des utilisateurs', 0, enabled: setupCompleted),
|
prefixIcon: const Icon(Icons.search, size: 18),
|
||||||
const SizedBox(width: 24),
|
border: InputBorder.none,
|
||||||
_buildNavItem(context, 'Paramètres', 1, enabled: true),
|
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}) {
|
Widget _pillField({required double width, required Widget child}) {
|
||||||
final bool isActive = index == selectedIndex;
|
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(
|
return InkWell(
|
||||||
onTap: enabled ? () => onTabChange(index) : null,
|
onTap: () => onSubTabChange(index),
|
||||||
child: Opacity(
|
child: Container(
|
||||||
opacity: enabled ? 1.0 : 0.5,
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||||||
child: Container(
|
decoration: BoxDecoration(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
color: isActive ? const Color(0xFF9CC5C0) : Colors.transparent,
|
||||||
decoration: BoxDecoration(
|
borderRadius: BorderRadius.circular(16),
|
||||||
color: isActive ? const Color(0xFF9CC5C0) : Colors.transparent,
|
border: isActive ? null : Border.all(color: Colors.black26),
|
||||||
borderRadius: BorderRadius.circular(20),
|
),
|
||||||
border: isActive ? null : Border.all(color: Colors.black26),
|
child: Text(
|
||||||
),
|
title,
|
||||||
child: Text(
|
style: TextStyle(
|
||||||
title,
|
color: isActive ? Colors.white : Colors.black87,
|
||||||
style: TextStyle(
|
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
|
||||||
color: isActive ? Colors.white : Colors.black,
|
fontSize: 13,
|
||||||
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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.
|
/// Sous-barre Paramètres : Paramètres généraux | Paramètres territoriaux.
|
||||||
class DashboardUserManagementSubBar extends StatelessWidget {
|
class DashboardSettingsSubBar extends StatelessWidget {
|
||||||
final int selectedSubIndex;
|
final int selectedSubIndex;
|
||||||
final ValueChanged<int> onSubTabChange;
|
final ValueChanged<int> onSubTabChange;
|
||||||
|
|
||||||
const DashboardUserManagementSubBar({
|
const DashboardSettingsSubBar({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.selectedSubIndex,
|
required this.selectedSubIndex,
|
||||||
required this.onSubTabChange,
|
required this.onSubTabChange,
|
||||||
@@ -153,13 +144,9 @@ class DashboardUserManagementSubBar extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
_buildSubNavItem(context, 'Gestionnaires', 0),
|
_buildSubNavItem(context, 'Paramètres généraux', 0),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
_buildSubNavItem(context, 'Parents', 1),
|
_buildSubNavItem(context, 'Paramètres territoriaux', 1),
|
||||||
const SizedBox(width: 16),
|
|
||||||
_buildSubNavItem(context, 'Assistantes maternelles', 2),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
_buildSubNavItem(context, 'Administrateurs', 3),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
class GestionnaireCard extends StatelessWidget {
|
|
||||||
final String name;
|
|
||||||
final String email;
|
|
||||||
|
|
||||||
const GestionnaireCard({
|
|
||||||
Key? key,
|
|
||||||
required this.name,
|
|
||||||
required this.email,
|
|
||||||
}) : super(key: key);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Card(
|
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
// 🔹 Infos principales
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Text(name, style: const TextStyle(fontWeight: FontWeight.bold)),
|
|
||||||
Text(email, style: const TextStyle(color: Colors.grey)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
|
|
||||||
// 🔹 Attribution à des RPE (dropdown fictif ici)
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
const Text("RPE attribué : "),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
DropdownButton<String>(
|
|
||||||
value: "RPE 1",
|
|
||||||
items: const [
|
|
||||||
DropdownMenuItem(value: "RPE 1", child: Text("RPE 1")),
|
|
||||||
DropdownMenuItem(value: "RPE 2", child: Text("RPE 2")),
|
|
||||||
DropdownMenuItem(value: "RPE 3", child: Text("RPE 3")),
|
|
||||||
],
|
|
||||||
onChanged: (value) {},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
|
|
||||||
// 🔹 Boutons d'action
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
// Réinitialisation mot de passe
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.lock_reset),
|
|
||||||
label: const Text("Réinitialiser MDP"),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
// Suppression du compte
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.delete, color: Colors.red),
|
|
||||||
label: const Text("Supprimer", style: TextStyle(color: Colors.red)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +1,109 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/gestionnaire_card.dart';
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
|
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart';
|
||||||
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
|
||||||
|
|
||||||
class GestionnaireManagementWidget extends StatelessWidget {
|
class GestionnaireManagementWidget extends StatefulWidget {
|
||||||
const GestionnaireManagementWidget({Key? key}) : super(key: key);
|
final String searchQuery;
|
||||||
|
|
||||||
|
const GestionnaireManagementWidget({
|
||||||
|
Key? key,
|
||||||
|
required this.searchQuery,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<GestionnaireManagementWidget> createState() =>
|
||||||
|
_GestionnaireManagementWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GestionnaireManagementWidgetState
|
||||||
|
extends State<GestionnaireManagementWidget> {
|
||||||
|
bool _isLoading = false;
|
||||||
|
String? _error;
|
||||||
|
List<AppUser> _gestionnaires = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadGestionnaires();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() => super.dispose();
|
||||||
|
|
||||||
|
Future<void> _loadGestionnaires() async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final gestionnaires = await UserService.getGestionnaires();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_gestionnaires = gestionnaires;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_error = e.toString();
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openGestionnaireEditDialog(AppUser user) async {
|
||||||
|
final changed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (dialogContext) {
|
||||||
|
return AdminUserFormDialog(initialUser: user);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (changed == true) {
|
||||||
|
await _loadGestionnaires();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
final query = widget.searchQuery.toLowerCase();
|
||||||
padding: const EdgeInsets.all(16),
|
final filteredGestionnaires = _gestionnaires.where((u) {
|
||||||
child: Column(
|
final name = u.fullName.toLowerCase();
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
final email = u.email.toLowerCase();
|
||||||
children: [
|
return name.contains(query) || email.contains(query);
|
||||||
// 🔹 Barre du haut avec bouton
|
}).toList();
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
const Expanded(
|
|
||||||
child: TextField(
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: "Rechercher un gestionnaire...",
|
|
||||||
prefixIcon: Icon(Icons.search),
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
// Rediriger vers la page de création
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.add),
|
|
||||||
label: const Text("Créer un gestionnaire"),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
// 🔹 Liste des gestionnaires
|
return UserList(
|
||||||
Expanded(
|
isLoading: _isLoading,
|
||||||
child: ListView.builder(
|
error: _error,
|
||||||
itemCount: 5, // À remplacer par liste dynamique
|
isEmpty: filteredGestionnaires.isEmpty,
|
||||||
itemBuilder: (context, index) {
|
emptyMessage: 'Aucun gestionnaire trouvé.',
|
||||||
return GestionnaireCard(
|
itemCount: filteredGestionnaires.length,
|
||||||
name: "Dupont $index",
|
itemBuilder: (context, index) {
|
||||||
email: "dupont$index@mail.com",
|
final user = filteredGestionnaires[index];
|
||||||
);
|
return AdminUserCard(
|
||||||
|
title: user.fullName,
|
||||||
|
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);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
],
|
||||||
],
|
);
|
||||||
),
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:p_tits_pas/services/configuration_service.dart';
|
import 'package:p_tits_pas/services/configuration_service.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/relais_management_panel.dart';
|
||||||
|
|
||||||
/// Panneau Paramètres admin : Email (SMTP), Personnalisation, Avancé.
|
/// Panneau Paramètres admin : Email (SMTP), Personnalisation, Avancé.
|
||||||
class ParametresPanel extends StatefulWidget {
|
class ParametresPanel extends StatefulWidget {
|
||||||
/// Si true, après sauvegarde on redirige vers le login (première config). Sinon on reste sur la page.
|
/// Si true, après sauvegarde on redirige vers le login (première config). Sinon on reste sur la page.
|
||||||
final bool redirectToLoginAfterSave;
|
final bool redirectToLoginAfterSave;
|
||||||
|
final int selectedSettingsTabIndex;
|
||||||
|
|
||||||
const ParametresPanel({super.key, this.redirectToLoginAfterSave = false});
|
const ParametresPanel({
|
||||||
|
super.key,
|
||||||
|
this.redirectToLoginAfterSave = false,
|
||||||
|
this.selectedSettingsTabIndex = 0,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ParametresPanel> createState() => _ParametresPanelState();
|
State<ParametresPanel> createState() => _ParametresPanelState();
|
||||||
@@ -33,10 +39,18 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
|
|
||||||
void _createControllers() {
|
void _createControllers() {
|
||||||
final keys = [
|
final keys = [
|
||||||
'smtp_host', 'smtp_port', 'smtp_user', 'smtp_password',
|
'smtp_host',
|
||||||
'email_from_name', 'email_from_address',
|
'smtp_port',
|
||||||
'app_name', 'app_url', 'app_logo_url',
|
'smtp_user',
|
||||||
'password_reset_token_expiry_days', 'jwt_expiry_hours', 'max_upload_size_mb',
|
'smtp_password',
|
||||||
|
'email_from_name',
|
||||||
|
'email_from_address',
|
||||||
|
'app_name',
|
||||||
|
'app_url',
|
||||||
|
'app_logo_url',
|
||||||
|
'password_reset_token_expiry_days',
|
||||||
|
'jwt_expiry_hours',
|
||||||
|
'max_upload_size_mb',
|
||||||
];
|
];
|
||||||
for (final k in keys) {
|
for (final k in keys) {
|
||||||
_controllers[k] = TextEditingController();
|
_controllers[k] = TextEditingController();
|
||||||
@@ -93,18 +107,29 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
payload['smtp_auth_required'] = _smtpAuthRequired;
|
payload['smtp_auth_required'] = _smtpAuthRequired;
|
||||||
payload['smtp_user'] = _controllers['smtp_user']!.text.trim();
|
payload['smtp_user'] = _controllers['smtp_user']!.text.trim();
|
||||||
final pwd = _controllers['smtp_password']!.text.trim();
|
final pwd = _controllers['smtp_password']!.text.trim();
|
||||||
if (pwd.isNotEmpty && pwd != '***********') payload['smtp_password'] = pwd;
|
if (pwd.isNotEmpty && pwd != '***********') {
|
||||||
|
payload['smtp_password'] = pwd;
|
||||||
|
}
|
||||||
payload['email_from_name'] = _controllers['email_from_name']!.text.trim();
|
payload['email_from_name'] = _controllers['email_from_name']!.text.trim();
|
||||||
payload['email_from_address'] = _controllers['email_from_address']!.text.trim();
|
payload['email_from_address'] =
|
||||||
|
_controllers['email_from_address']!.text.trim();
|
||||||
payload['app_name'] = _controllers['app_name']!.text.trim();
|
payload['app_name'] = _controllers['app_name']!.text.trim();
|
||||||
payload['app_url'] = _controllers['app_url']!.text.trim();
|
payload['app_url'] = _controllers['app_url']!.text.trim();
|
||||||
payload['app_logo_url'] = _controllers['app_logo_url']!.text.trim();
|
payload['app_logo_url'] = _controllers['app_logo_url']!.text.trim();
|
||||||
final tokenDays = int.tryParse(_controllers['password_reset_token_expiry_days']!.text.trim());
|
final tokenDays = int.tryParse(
|
||||||
if (tokenDays != null) payload['password_reset_token_expiry_days'] = tokenDays;
|
_controllers['password_reset_token_expiry_days']!.text.trim());
|
||||||
final jwtHours = int.tryParse(_controllers['jwt_expiry_hours']!.text.trim());
|
if (tokenDays != null) {
|
||||||
if (jwtHours != null) payload['jwt_expiry_hours'] = jwtHours;
|
payload['password_reset_token_expiry_days'] = tokenDays;
|
||||||
|
}
|
||||||
|
final jwtHours =
|
||||||
|
int.tryParse(_controllers['jwt_expiry_hours']!.text.trim());
|
||||||
|
if (jwtHours != null) {
|
||||||
|
payload['jwt_expiry_hours'] = jwtHours;
|
||||||
|
}
|
||||||
final maxMb = int.tryParse(_controllers['max_upload_size_mb']!.text.trim());
|
final maxMb = int.tryParse(_controllers['max_upload_size_mb']!.text.trim());
|
||||||
if (maxMb != null) payload['max_upload_size_mb'] = maxMb;
|
if (maxMb != null) {
|
||||||
|
payload['max_upload_size_mb'] = maxMb;
|
||||||
|
}
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,6 +216,10 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
if (widget.selectedSettingsTabIndex == 1) {
|
||||||
|
return const RelaisManagementPanel();
|
||||||
|
}
|
||||||
|
|
||||||
if (_isLoading) {
|
if (_isLoading) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
@@ -214,7 +243,8 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final isSuccess = _message != null &&
|
final isSuccess = _message != null &&
|
||||||
(_message!.startsWith('Configuration') || _message!.startsWith('Connexion'));
|
(_message!.startsWith('Configuration') ||
|
||||||
|
_message!.startsWith('Connexion'));
|
||||||
|
|
||||||
return Form(
|
return Form(
|
||||||
key: _formKey,
|
key: _formKey,
|
||||||
@@ -234,12 +264,21 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
context,
|
context,
|
||||||
icon: Icons.email_outlined,
|
icon: Icons.email_outlined,
|
||||||
title: 'Configuration Email (SMTP)',
|
title: 'Configuration Email (SMTP)',
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_buildField('smtp_host', 'Serveur SMTP', hint: 'mail.example.com'),
|
_buildField(
|
||||||
|
'smtp_host',
|
||||||
|
'Serveur SMTP',
|
||||||
|
hint: 'mail.example.com',
|
||||||
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_buildField('smtp_port', 'Port SMTP', keyboard: TextInputType.number, hint: '25, 465, 587'),
|
_buildField(
|
||||||
|
'smtp_port',
|
||||||
|
'Port SMTP',
|
||||||
|
keyboard: TextInputType.number,
|
||||||
|
hint: '25, 465, 587',
|
||||||
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 14),
|
padding: const EdgeInsets.only(bottom: 14),
|
||||||
@@ -247,14 +286,17 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
children: [
|
children: [
|
||||||
Checkbox(
|
Checkbox(
|
||||||
value: _smtpSecure,
|
value: _smtpSecure,
|
||||||
onChanged: (v) => setState(() => _smtpSecure = v ?? false),
|
onChanged: (v) =>
|
||||||
|
setState(() => _smtpSecure = v ?? false),
|
||||||
activeColor: const Color(0xFF9CC5C0),
|
activeColor: const Color(0xFF9CC5C0),
|
||||||
),
|
),
|
||||||
const Text('SSL/TLS (secure)'),
|
const Text('SSL/TLS (secure)'),
|
||||||
const SizedBox(width: 24),
|
const SizedBox(width: 24),
|
||||||
Checkbox(
|
Checkbox(
|
||||||
value: _smtpAuthRequired,
|
value: _smtpAuthRequired,
|
||||||
onChanged: (v) => setState(() => _smtpAuthRequired = v ?? false),
|
onChanged: (v) => setState(
|
||||||
|
() => _smtpAuthRequired = v ?? false,
|
||||||
|
),
|
||||||
activeColor: const Color(0xFF9CC5C0),
|
activeColor: const Color(0xFF9CC5C0),
|
||||||
),
|
),
|
||||||
const Text('Authentification requise'),
|
const Text('Authentification requise'),
|
||||||
@@ -263,11 +305,19 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
),
|
),
|
||||||
_buildField('smtp_user', 'Utilisateur SMTP'),
|
_buildField('smtp_user', 'Utilisateur SMTP'),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_buildField('smtp_password', 'Mot de passe SMTP', obscure: true),
|
_buildField(
|
||||||
|
'smtp_password',
|
||||||
|
'Mot de passe SMTP',
|
||||||
|
obscure: true,
|
||||||
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_buildField('email_from_name', 'Nom expéditeur'),
|
_buildField('email_from_name', 'Nom expéditeur'),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_buildField('email_from_address', 'Email expéditeur', hint: 'no-reply@example.com'),
|
_buildField(
|
||||||
|
'email_from_address',
|
||||||
|
'Email expéditeur',
|
||||||
|
hint: 'no-reply@example.com',
|
||||||
|
),
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.centerRight,
|
alignment: Alignment.centerRight,
|
||||||
@@ -277,8 +327,13 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
label: const Text('Tester la connexion SMTP'),
|
label: const Text('Tester la connexion SMTP'),
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
foregroundColor: const Color(0xFF2D6A4F),
|
foregroundColor: const Color(0xFF2D6A4F),
|
||||||
side: const BorderSide(color: Color(0xFF9CC5C0)),
|
side: const BorderSide(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
color: Color(0xFF9CC5C0),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 20,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -290,14 +345,22 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
context,
|
context,
|
||||||
icon: Icons.palette_outlined,
|
icon: Icons.palette_outlined,
|
||||||
title: 'Personnalisation',
|
title: 'Personnalisation',
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_buildField('app_name', 'Nom de l\'application'),
|
_buildField('app_name', 'Nom de l\'application'),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_buildField('app_url', 'URL de l\'application', hint: 'https://app.example.com'),
|
_buildField(
|
||||||
|
'app_url',
|
||||||
|
'URL de l\'application',
|
||||||
|
hint: 'https://app.example.com',
|
||||||
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_buildField('app_logo_url', 'URL du logo', hint: '/assets/logo.png'),
|
_buildField(
|
||||||
|
'app_logo_url',
|
||||||
|
'URL du logo',
|
||||||
|
hint: '/assets/logo.png',
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -309,11 +372,23 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_buildField('password_reset_token_expiry_days', 'Validité token MDP (jours)', keyboard: TextInputType.number),
|
_buildField(
|
||||||
|
'password_reset_token_expiry_days',
|
||||||
|
'Validité token MDP (jours)',
|
||||||
|
keyboard: TextInputType.number,
|
||||||
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_buildField('jwt_expiry_hours', 'Validité session JWT (heures)', keyboard: TextInputType.number),
|
_buildField(
|
||||||
|
'jwt_expiry_hours',
|
||||||
|
'Validité session JWT (heures)',
|
||||||
|
keyboard: TextInputType.number,
|
||||||
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_buildField('max_upload_size_mb', 'Taille max upload (MB)', keyboard: TextInputType.number),
|
_buildField(
|
||||||
|
'max_upload_size_mb',
|
||||||
|
'Taille max upload (MB)',
|
||||||
|
keyboard: TextInputType.number,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -327,7 +402,14 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
),
|
),
|
||||||
child: _isSaving
|
child: _isSaving
|
||||||
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
? const SizedBox(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
: const Text('Sauvegarder la configuration'),
|
: const Text('Sauvegarder la configuration'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -339,7 +421,8 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSectionCard(BuildContext context, {required IconData icon, required String title, required Widget child}) {
|
Widget _buildSectionCard(BuildContext context,
|
||||||
|
{required IconData icon, required String title, required Widget child}) {
|
||||||
return Card(
|
return Card(
|
||||||
elevation: 2,
|
elevation: 2,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
@@ -369,7 +452,8 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildField(String key, String label, {bool obscure = false, TextInputType? keyboard, String? hint}) {
|
Widget _buildField(String key, String label,
|
||||||
|
{bool obscure = false, TextInputType? keyboard, String? hint}) {
|
||||||
final c = _controllers[key];
|
final c = _controllers[key];
|
||||||
if (c == null) return const SizedBox.shrink();
|
if (c == null) return const SizedBox.shrink();
|
||||||
return TextFormField(
|
return TextFormField(
|
||||||
@@ -381,7 +465,8 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
labelText: label,
|
labelText: label,
|
||||||
hintText: hint,
|
hintText: hint,
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
contentPadding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,121 +1,155 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:p_tits_pas/models/parent_model.dart';
|
||||||
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
|
||||||
|
|
||||||
class ParentManagementWidget extends StatelessWidget {
|
class ParentManagementWidget extends StatefulWidget {
|
||||||
const ParentManagementWidget({super.key});
|
final String searchQuery;
|
||||||
|
final String? statusFilter;
|
||||||
|
|
||||||
|
const ParentManagementWidget({
|
||||||
|
super.key,
|
||||||
|
required this.searchQuery,
|
||||||
|
this.statusFilter,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ParentManagementWidget> createState() => _ParentManagementWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ParentManagementWidgetState extends State<ParentManagementWidget> {
|
||||||
|
bool _isLoading = false;
|
||||||
|
String? _error;
|
||||||
|
List<ParentModel> _parents = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadParents();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() => super.dispose();
|
||||||
|
|
||||||
|
Future<void> _loadParents() async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final list = await UserService.getParents();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_parents = list;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_error = e.toString();
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// 🔁 Simulation de données parents
|
final query = widget.searchQuery.toLowerCase();
|
||||||
final parents = [
|
final filteredParents = _parents.where((p) {
|
||||||
{
|
final matchesName = p.user.fullName.toLowerCase().contains(query) ||
|
||||||
"nom": "Jean Dupuis",
|
p.user.email.toLowerCase().contains(query);
|
||||||
"email": "jean.dupuis@email.com",
|
final matchesStatus =
|
||||||
"statut": "Actif",
|
widget.statusFilter == null || p.user.statut == widget.statusFilter;
|
||||||
"enfants": 2,
|
return matchesName && matchesStatus;
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"nom": "Lucie Morel",
|
|
||||||
"email": "lucie.morel@email.com",
|
|
||||||
"statut": "En attente",
|
|
||||||
"enfants": 1,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
|
|
||||||
_buildSearchSection(),
|
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
ListView.builder(
|
|
||||||
shrinkWrap: true,
|
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
|
||||||
itemCount: parents.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final parent = parents[index];
|
|
||||||
return Card(
|
|
||||||
margin: const EdgeInsets.symmetric(vertical: 8),
|
|
||||||
child: ListTile(
|
|
||||||
leading: const Icon(Icons.person_outline),
|
|
||||||
title: Text(parent['nom'].toString()),
|
|
||||||
subtitle: Text(
|
|
||||||
"${parent['email']}\nStatut : ${parent['statut']} | Enfants : ${parent['enfants']}",
|
|
||||||
),
|
|
||||||
isThreeLine: true,
|
|
||||||
trailing: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.visibility),
|
|
||||||
tooltip: "Voir dossier",
|
|
||||||
onPressed: () {
|
|
||||||
// TODO: Voir le statut du dossier
|
|
||||||
},
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.edit),
|
|
||||||
tooltip: "Modifier",
|
|
||||||
onPressed: () {
|
|
||||||
// TODO: Modifier parent
|
|
||||||
},
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.delete),
|
|
||||||
tooltip: "Supprimer",
|
|
||||||
onPressed: () {
|
|
||||||
// TODO: Supprimer compte
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSearchSection() {
|
String _displayStatus(String? status) {
|
||||||
return Wrap(
|
switch (status) {
|
||||||
spacing: 16,
|
case 'actif':
|
||||||
runSpacing: 8,
|
return 'Actif';
|
||||||
children: [
|
case 'en_attente':
|
||||||
SizedBox(
|
return 'En attente';
|
||||||
width: 220,
|
case 'suspendu':
|
||||||
child: TextField(
|
return 'Suspendu';
|
||||||
decoration: const InputDecoration(
|
default:
|
||||||
labelText: "Nom du parent",
|
return 'Inconnu';
|
||||||
border: OutlineInputBorder(),
|
}
|
||||||
),
|
}
|
||||||
onChanged: (value) {
|
|
||||||
// TODO: Ajouter logique de recherche
|
void _openParentDetails(ParentModel parent) {
|
||||||
},
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AdminDetailModal(
|
||||||
|
title: parent.user.fullName.isEmpty ? 'Parent' : parent.user.fullName,
|
||||||
|
subtitle: parent.user.email,
|
||||||
|
fields: [
|
||||||
|
AdminDetailField(label: 'ID', value: _v(parent.user.id)),
|
||||||
|
AdminDetailField(
|
||||||
|
label: 'Statut',
|
||||||
|
value: _displayStatus(parent.user.statut),
|
||||||
),
|
),
|
||||||
),
|
AdminDetailField(
|
||||||
SizedBox(
|
label: 'Telephone',
|
||||||
width: 220,
|
value: _v(parent.user.telephone),
|
||||||
child: DropdownButtonFormField<String>(
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: "Statut",
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
items: const [
|
|
||||||
DropdownMenuItem(value: "Actif", child: Text("Actif")),
|
|
||||||
DropdownMenuItem(value: "En attente", child: Text("En attente")),
|
|
||||||
DropdownMenuItem(value: "Supprimé", child: Text("Supprimé")),
|
|
||||||
],
|
|
||||||
onChanged: (value) {
|
|
||||||
// TODO: Ajouter logique de filtrage
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
AdminDetailField(label: 'Adresse', value: _v(parent.user.adresse)),
|
||||||
],
|
AdminDetailField(label: 'Ville', value: _v(parent.user.ville)),
|
||||||
|
AdminDetailField(
|
||||||
|
label: 'Code postal',
|
||||||
|
value: _v(parent.user.codePostal),
|
||||||
|
),
|
||||||
|
AdminDetailField(
|
||||||
|
label: 'Nombre d\'enfants',
|
||||||
|
value: parent.childrenCount.toString(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onEdit: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
ScaffoldMessenger.of(this.context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Action Modifier a implementer')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onDelete: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
ScaffoldMessenger.of(this.context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Action Supprimer a implementer')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _v(String? value) => (value == null || value.isEmpty) ? '-' : value;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user